Skip to content

Commit a9499c1

Browse files
authored
Prevent git rebase --exec test runs from corrupting the enclosing worktree (gitgitgadget#2157)
Running `git rebase -x 'npm test'` from a worktree wreaked havoc on my real repository: `core.bare = true` and test `url.*.insteadOf` settings ended up in the shared `.git/config`, test commits landed on the real HEAD, and a bogus `refs/notes/gitgitgadget` ref appeared out of nowhere. For a terrifying moment I thought I had lost actual work. The root cause turned out to be `git rebase --exec` setting `GIT_DIR` in the environment. This leaked into the Node.js test processes, causing `git init <target>` to silently reinitialize the *real* repository instead of creating a fresh one in the target directory -- git simply ignores the target argument when `GIT_DIR` is set. Diagnosing this was a bit trickier than I hoped for. Adding `GIT_CEILING_DIRECTORIES` alone did not help because `GIT_DIR` takes precedence over repository discovery, making ceiling directories irrelevant. A secondary leak hid in `misc-helper.test.ts` which captured `process.env` at module load time, before `testCreateRepo` had a chance to clear the offending variables. This series clears `GIT_DIR`/`GIT_WORK_TREE` early, adds `GIT_CEILING_DIRECTORIES` as defense-in-depth, introduces a `validateWorkDir()` safety net that fails loudly if git would operate outside the test directory, and fixes a pre-existing test that accidentally depended on the enclosing repo's config. It also tells Jest to ignore `.test-dir/` so stale files from corrupted runs don't cause confusing failures on the next run.
2 parents 8ce1ea4 + bfa3684 commit a9499c1

5 files changed

Lines changed: 91 additions & 7 deletions

File tree

lib/git.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ChildProcess } from "child_process";
2+
import * as path from "path";
23
import { exec as GitProcess } from "dugite";
34

45
// For convenience, let's add helpers to call Git:
@@ -16,6 +17,18 @@ export interface IGitOptions {
1617
export const emptyBlobName = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391";
1718
export const emptyTreeName = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
1819

20+
function validateWorkDir(workDir: string): void {
21+
const prefix = process.env.GIT_WORK_DIR_PREFIX;
22+
if (!prefix) return;
23+
const gitDir = process.env.GIT_DIR;
24+
if (gitDir) {
25+
throw new Error(`GIT_DIR is set to '${gitDir}'; This should not happen in tests`);
26+
}
27+
if (!path.resolve(workDir).startsWith(prefix)) {
28+
throw new Error(`workDir '${workDir}' is not inside '${prefix}'`);
29+
}
30+
}
31+
1932
function trimTrailingNewline(str: string): string {
2033
return str.replace(/\r?\n$/, "");
2134
}
@@ -43,6 +56,44 @@ export function git(args: string[], options?: IGitOptions): Promise<string> {
4356
args = [`--git-dir=${options.workDir}`, ...args];
4457
workDir = ".";
4558
}
59+
const prefix = process.env.GIT_WORK_DIR_PREFIX;
60+
if (prefix) {
61+
let cmdStart = 0;
62+
63+
// --git-dir=<path> must be the very first argument (prepended by the bare-repo handling above)
64+
if (args[0]?.startsWith("--git-dir=")) {
65+
validateWorkDir(args[0].substring("--git-dir=".length));
66+
cmdStart = 1;
67+
}
68+
69+
if (args[cmdStart] === "init") {
70+
// Strictly parse: init [--bare] [--initial-branch <name>] <directory>
71+
let i = cmdStart + 1;
72+
while (i < args.length) {
73+
if (args[i] === "--bare") {
74+
i++;
75+
} else if (args[i] === "--initial-branch" || args[i] === "-b") {
76+
if (i + 1 >= args.length) throw new Error(`git init: '${args[i]}' requires a value`);
77+
i += 2;
78+
} else if (args[i].startsWith("-")) {
79+
throw new Error(`git init: unexpected option '${args[i]}'`);
80+
} else {
81+
break;
82+
}
83+
}
84+
if (i >= args.length) {
85+
throw new Error(`git init without an explicit target directory is not allowed in tests`);
86+
}
87+
if (i + 1 < args.length) {
88+
throw new Error(`git init: unexpected trailing argument '${args[i + 1]}'`);
89+
}
90+
validateWorkDir(args[i]);
91+
} else if (cmdStart === 0) {
92+
// No --git-dir, not init: validate workDir
93+
validateWorkDir(workDir);
94+
}
95+
// If --git-dir was present and subcommand is not init, it was already validated above
96+
}
4697
if (options && options.trace) {
4798
process.stderr.write(`Called 'git ${args.join(" ")}' in '${workDir}':\n${new Error().stack}\n`);
4899
}
@@ -158,7 +209,9 @@ export function git(args: string[], options?: IGitOptions): Promise<string> {
158209
* @returns { string | undefined } the full SHA-1, or undefined
159210
*/
160211
export async function revParse(argument: string, workDir?: string): Promise<string | undefined> {
161-
const result = await GitProcess(["rev-parse", "--verify", "-q", argument], workDir || ".");
212+
const dir = workDir || ".";
213+
validateWorkDir(dir);
214+
const result = await GitProcess(["rev-parse", "--verify", "-q", argument], dir);
162215
return result.exitCode ? undefined : trimTrailingNewline(result.stdout);
163216
}
164217

@@ -171,6 +224,7 @@ export async function revParse(argument: string, workDir?: string): Promise<stri
171224
* @returns number the number of commits in the commit range
172225
*/
173226
export async function revListCount(rangeArgs: string | string[], workDir = "."): Promise<number> {
227+
validateWorkDir(workDir);
174228
const gitArgs: string[] = ["rev-list", "--count"];
175229
if (typeof rangeArgs === "string") {
176230
gitArgs.push(rangeArgs);
@@ -196,7 +250,9 @@ export async function commitExists(commit: string, workDir: string): Promise<boo
196250
}
197251

198252
export async function gitConfig(key: string, workDir?: string): Promise<string | undefined> {
199-
const result = await GitProcess(["config", key], workDir || ".");
253+
const dir = workDir || ".";
254+
validateWorkDir(dir);
255+
const result = await GitProcess(["config", key], dir);
200256
if (result.exitCode !== 0) {
201257
return undefined;
202258
}
@@ -208,12 +264,16 @@ export async function gitConfigForEach(
208264
callbackfn: (value: string) => void,
209265
workDir?: string,
210266
): Promise<void> {
211-
const result = await GitProcess(["config", "--get-all", key], workDir || ".");
267+
const dir = workDir || ".";
268+
validateWorkDir(dir);
269+
const result = await GitProcess(["config", "--get-all", key], dir);
212270
result.stdout.split(/\r?\n/).map(callbackfn);
213271
}
214272

215273
export async function gitCommandExists(command: string, workDir?: string): Promise<boolean> {
216-
const result = await GitProcess([command, "-h"], workDir || ".");
274+
const dir = workDir || ".";
275+
validateWorkDir(dir);
276+
const result = await GitProcess([command, "-h"], dir);
217277
return result.exitCode === 129;
218278
}
219279

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@
5353
}
5454
]
5555
},
56-
"testRegex": "/tests/.*\\.test\\.(ts|tsx|js)$"
56+
"testRegex": "/tests/.*\\.test\\.(ts|tsx|js)$",
57+
"testPathIgnorePatterns": [
58+
"\\.test-dir/"
59+
]
5760
},
5861
"devDependencies": {
5962
"@stylistic/eslint-plugin": "^5.9.0",

tests/git.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ const sleep = async (ms: number) => {
1818
});
1919
};
2020

21-
test("finds core.bare", async () => {
22-
expect(await gitConfig("core.bare")).toMatch(/true|false/);
21+
test("reads config", async () => {
22+
expect(await gitConfig("TEST.TS.case0")).toMatch("test.case0 value");
2323
});
2424

2525
test("serialization", async () => {

tests/misc-helper.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ const helperEnv = {
6969
GIT_COMMITTER_NAME: "J Doe",
7070
GIT_COMMITTER_EMAIL: "jdoe@example.com",
7171
...process.env,
72+
GIT_DIR: undefined,
73+
GIT_WORK_TREE: undefined,
7274
};
7375

7476
async function getNote(reg: RegExp, workDir: string): Promise<string> {

tests/test-lib.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,21 @@ export async function testCreateRepo(name: string, suffix?: string): Promise<Tes
136136
}
137137
tmp = await realpath(tmp);
138138

139+
// Prevent git from discovering or operating on the enclosing repository.
140+
// GIT_DIR/GIT_WORK_TREE are set e.g. by `git rebase --exec` in worktrees.
141+
delete process.env.GIT_DIR;
142+
delete process.env.GIT_WORK_TREE;
143+
144+
// Prevent git from discovering the enclosing repository
145+
const ceilings = [];
146+
for (let dir = tmp, parent; (parent = path.dirname(dir)) !== dir; dir = parent) {
147+
ceilings.push(parent);
148+
}
149+
process.env.GIT_CEILING_DIRECTORIES = ceilings.join(path.delimiter);
150+
151+
// Suspend the workDir guard during repo setup (git init, git config --global)
152+
delete process.env.GIT_WORK_DIR_PREFIX;
153+
139154
// eslint-disable-next-line security/detect-unsafe-regex
140155
const match = name.match(/^(.*[\\/])?(.*?)(\.test)?\.ts$/);
141156
if (match) {
@@ -179,6 +194,10 @@ export async function testCreateRepo(name: string, suffix?: string): Promise<Tes
179194
workDir: dir,
180195
};
181196
await git(["commit-tree", "-m", "Test commit", "4b825dc642cb6eb9a060e54bf8d69288fbee4904"], opts);
197+
198+
// From now on, ensure that all git() calls use a workDir inside tmp
199+
process.env.GIT_WORK_DIR_PREFIX = tmp;
200+
182201
const gitOpts: ITestCommitOptions = { workDir: dir };
183202

184203
return new TestRepo(gitOpts);

0 commit comments

Comments
 (0)