Skip to content

Commit 9c6219e

Browse files
Merge pull request #725 from corbitsdev/cl-7267-make-temporary-git-repositories-ignore-developer-hooks
Disable inherited Git hooks in temporary test repositories
2 parents 2503cc6 + 423c883 commit 9c6219e

13 files changed

Lines changed: 186 additions & 47 deletions

src/agent/environment.test.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { join } from "node:path";
66
import { promisify } from "node:util";
77

88
import { gatherEnvironment, getGitBranch } from "./environment.js";
9+
import { initTemporaryGitRepo } from "../../tests/helpers/temporary-git-repo.js";
910

1011
const run = promisify(execFile);
1112

@@ -22,9 +23,7 @@ test("gatherEnvironment detects a git work tree and lists its top level", async
2223
// a runner may check out a detached HEAD, which has no branch name.
2324
const dir = await mkdtemp(join(tmpdir(), "corbits-env-repo-"));
2425
try {
25-
await run("git", ["init"], { cwd: dir });
26-
await run("git", ["config", "user.email", "t@t.test"], { cwd: dir });
27-
await run("git", ["config", "user.name", "t"], { cwd: dir });
26+
initTemporaryGitRepo(dir);
2827
await run("git", ["checkout", "-b", "trunk"], { cwd: dir });
2928
await mkdir(join(dir, "src"));
3029
await writeFile(join(dir, "src", "seed.ts"), "export const seed = 1;\n");
@@ -43,9 +42,7 @@ test("gatherEnvironment detects a git work tree and lists its top level", async
4342
test("gatherEnvironment gathers branch and dirty status from the same work tree", async () => {
4443
const dir = await mkdtemp(join(tmpdir(), "corbits-env-"));
4544
try {
46-
await run("git", ["init"], { cwd: dir });
47-
await run("git", ["config", "user.email", "t@t.test"], { cwd: dir });
48-
await run("git", ["config", "user.name", "t"], { cwd: dir });
45+
initTemporaryGitRepo(dir);
4946
await run("git", ["checkout", "-b", "trunk"], { cwd: dir });
5047
await writeFile(join(dir, "seed.txt"), "seed");
5148
await run("git", ["add", "."], { cwd: dir });

src/permission/gate.test.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createPermissionGate, isRequestCoveredByGrant, preGrantGuardReason } fr
88
import { createPathRestriction } from "./path-restriction.js";
99
import { createWorktreeRootsProvider } from "./worktree-roots.js";
1010
import type { Approval, PermissionRequest } from "./types.js";
11+
import { initTemporaryGitRepo } from "../../tests/helpers/temporary-git-repo.js";
1112

1213
const shellCall = (command: string): ToolCall => ({
1314
id: "c",
@@ -112,9 +113,7 @@ describe("grant coverage rebinds relative paths to the request process cwd", ()
112113
const sessionCwd = join(root, "main");
113114
const git = (args: string[], cwd: string) => execFileSync("git", args, { cwd, stdio: "ignore" });
114115
mkdirSync(sessionCwd);
115-
git(["init", "-q"], sessionCwd);
116-
git(["config", "user.email", "t@example.com"], sessionCwd);
117-
git(["config", "user.name", "t"], sessionCwd);
116+
initTemporaryGitRepo(sessionCwd, { initArgs: ["-q"] });
118117
writeFileSync(join(sessionCwd, "seed.txt"), "seed\n");
119118
git(["add", "."], sessionCwd);
120119
git(["commit", "-qm", "seed"], sessionCwd);
@@ -171,9 +170,7 @@ describe("standing grant covers a later git worktree command (CL-5638)", () => {
171170
const sessionCwd = join(root, "main");
172171
const git = (args: string[], cwd: string) => execFileSync("git", args, { cwd, stdio: "ignore" });
173172
mkdirSync(sessionCwd);
174-
git(["init", "-q"], sessionCwd);
175-
git(["config", "user.email", "t@example.com"], sessionCwd);
176-
git(["config", "user.name", "t"], sessionCwd);
173+
initTemporaryGitRepo(sessionCwd, { initArgs: ["-q"] });
177174
writeFileSync(join(sessionCwd, "seed.txt"), "seed\n");
178175
git(["add", "."], sessionCwd);
179176
git(["commit", "-qm", "seed"], sessionCwd);

src/permission/permission.test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
import { listWorktreeRoots, createWorktreeRootsProvider } from "./worktree-roots.js";
2929
import { createPathRestriction, resolveWorkspacePath } from "./path-restriction.js";
3030
import type { Approval, PermissionRequest } from "./types.js";
31+
import { initTemporaryGitRepo } from "../../tests/helpers/temporary-git-repo.js";
3132
import { secretGuardPlugin } from "../plugins/secret-guard-plugin.js";
3233
import { pathEscapePlugin } from "../plugins/path-escape-plugin.js";
3334

@@ -3275,8 +3276,8 @@ describe("listWorktreeRoots", () => {
32753276
const repo = join(base, "repo");
32763277
const worktree = join(base, "secondary");
32773278
mkdirSync(repo);
3278-
git(repo, "init", "-b", "main");
3279-
git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-m", "init");
3279+
initTemporaryGitRepo(repo, { initArgs: ["-b", "main"] });
3280+
git(repo, "commit", "--allow-empty", "-m", "init");
32803281
git(repo, "worktree", "add", worktree);
32813282
return { repo, worktree };
32823283
};
@@ -3376,8 +3377,8 @@ describe("createWorktreeRootsProvider lazy re-discovery", () => {
33763377
const base = mkdtempSync(join(tmpdir(), "corbits-lazy-"));
33773378
const repo = join(base, "repo");
33783379
mkdirSync(repo);
3379-
git(repo, "init", "-b", "main");
3380-
git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-m", "init");
3380+
initTemporaryGitRepo(repo, { initArgs: ["-b", "main"] });
3381+
git(repo, "commit", "--allow-empty", "-m", "init");
33813382
return repo;
33823383
};
33833384

@@ -3707,8 +3708,8 @@ describe("project-scoped grants match sub-agent worktree requests (CL-5662)", ()
37073708
const repo = join(base, "repo");
37083709
const worktree = join(base, "sibling-worktree");
37093710
mkdirSync(repo);
3710-
git(repo, "init", "-b", "main");
3711-
git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-m", "init");
3711+
initTemporaryGitRepo(repo, { initArgs: ["-b", "main"] });
3712+
git(repo, "commit", "--allow-empty", "-m", "init");
37123713
git(repo, "worktree", "add", worktree);
37133714
return { repo, worktree };
37143715
};

src/session/project-key.test.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os";
66
import { execFileSync } from "node:child_process";
77

88
import { projectKeyFor, projectRootFor, projectSessionsRoot, projectsRoot } from "./project-key.js";
9+
import { initTemporaryGitRepo } from "../../tests/helpers/temporary-git-repo.js";
910

1011
let root = "";
1112

@@ -18,12 +19,6 @@ afterEach(async () => {
1819
await rm(root, { recursive: true, force: true });
1920
});
2021

21-
function initGitRepo(dir: string): void {
22-
execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" });
23-
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir, stdio: "ignore" });
24-
execFileSync("git", ["config", "user.name", "test"], { cwd: dir, stdio: "ignore" });
25-
}
26-
2722
async function commitReadme(dir: string): Promise<void> {
2823
await writeFile(join(dir, "README"), "x");
2924
execFileSync("git", ["add", "README"], { cwd: dir, stdio: "ignore" });
@@ -38,7 +33,7 @@ test("projectKeyFor is stable across calls for the same path", () => {
3833
});
3934

4035
test("projectKeyFor shares nested dirs under the same git toplevel", async () => {
41-
initGitRepo(root);
36+
initTemporaryGitRepo(root);
4237
await commitReadme(root);
4338

4439
const nested = join(root, "nested", "deep");
@@ -49,7 +44,7 @@ test("projectKeyFor shares nested dirs under the same git toplevel", async () =>
4944
});
5045

5146
test("linked worktrees have distinct project roots and keys from main and each other", async () => {
52-
initGitRepo(root);
47+
initTemporaryGitRepo(root);
5348
await commitReadme(root);
5449

5550
const wtA = join(root, "..", `wt-a-${Date.now()}-${Math.random().toString(16).slice(2)}`);

src/session/session-dir.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
migrateLegacySessionIfNeeded,
1414
sessionDir,
1515
} from "./index.js";
16+
import { initTemporaryGitRepo } from "../../tests/helpers/temporary-git-repo.js";
1617

1718
let cwd = "";
1819
let home = "";
@@ -84,9 +85,7 @@ test("listSessions finds legacy sessions and migrates them", async () => {
8485
test("migrateLegacySessionIfNeeded does not migrate main-repo .agent-state from a worktree cwd", async () => {
8586
const main = join(cwd, "main");
8687
await mkdir(main, { recursive: true });
87-
execFileSync("git", ["init"], { cwd: main, stdio: "ignore" });
88-
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: main, stdio: "ignore" });
89-
execFileSync("git", ["config", "user.name", "test"], { cwd: main, stdio: "ignore" });
88+
initTemporaryGitRepo(main);
9089
await writeFile(join(main, "README"), "x");
9190
execFileSync("git", ["add", "README"], { cwd: main, stdio: "ignore" });
9291
execFileSync("git", ["commit", "-m", "init"], { cwd: main, stdio: "ignore" });

src/subagent/spawn-agent-worktree.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { createSubAgentSessionStore } from "./session-store.js";
1010
import { createPermissionGate } from "../permission/gate.js";
1111
import type { RunSubAgentParams, RunSubAgentResult } from "./types.js";
1212
import type { Telemetry } from "../telemetry/index.js";
13+
import { initTemporaryGitRepo } from "../../tests/helpers/temporary-git-repo.js";
1314

1415
const run = promisify(execFile);
1516

@@ -49,9 +50,7 @@ afterEach(async () => {
4950

5051
async function makeRepo(): Promise<string> {
5152
const dir = await mkdtemp(join(tmpdir(), "corbits-spawn-wt-"));
52-
await run("git", ["init"], { cwd: dir });
53-
await run("git", ["config", "user.email", "t@t.test"], { cwd: dir });
54-
await run("git", ["config", "user.name", "t"], { cwd: dir });
53+
initTemporaryGitRepo(dir);
5554
await writeFile(join(dir, "seed.txt"), "seed");
5655
await run("git", ["add", "."], { cwd: dir });
5756
await run("git", ["commit", "-m", "seed"], { cwd: dir });

src/subagent/task-tool-worktree.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createTaskTool } from "./task-tool.js";
99
import type { RunSubAgentParams } from "./types.js";
1010
import { createPermissionGate } from "../permission/gate.js";
1111
import type { Telemetry } from "../telemetry/index.js";
12+
import { initTemporaryGitRepo } from "../../tests/helpers/temporary-git-repo.js";
1213

1314
const run = promisify(execFile);
1415

@@ -51,9 +52,7 @@ async function callTask(
5152

5253
async function makeRepo(): Promise<string> {
5354
const dir = await mkdtemp(join(tmpdir(), "corbits-worktree-"));
54-
await run("git", ["init"], { cwd: dir });
55-
await run("git", ["config", "user.email", "t@t.test"], { cwd: dir });
56-
await run("git", ["config", "user.name", "t"], { cwd: dir });
55+
initTemporaryGitRepo(dir);
5756
await writeFile(join(dir, "seed.txt"), "seed");
5857
await run("git", ["add", "."], { cwd: dir });
5958
await run("git", ["commit", "-m", "seed"], { cwd: dir });
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { afterEach, expect, test } from "bun:test";
2+
import { execFileSync } from "node:child_process";
3+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
7+
import { initTemporaryGitRepo } from "./temporary-git-repo.js";
8+
9+
const tempRoots: string[] = [];
10+
11+
afterEach(() => {
12+
while (tempRoots.length > 0) {
13+
const dir = tempRoots.pop();
14+
if (dir !== undefined) rmSync(dir, { recursive: true, force: true });
15+
}
16+
});
17+
18+
function tempRoot(): string {
19+
const dir = mkdtempSync(join(tmpdir(), "corbits-temp-git-"));
20+
tempRoots.push(dir);
21+
return dir;
22+
}
23+
24+
function rejectingHostConfig(root: string): { configPath: string; env: NodeJS.ProcessEnv } {
25+
const hooksDir = join(root, "host-hooks");
26+
mkdirSync(hooksDir);
27+
const preCommit = join(hooksDir, "pre-commit");
28+
writeFileSync(preCommit, "#!/bin/sh\necho 'host hook rejected fixture commit' >&2\nexit 1\n");
29+
chmodSync(preCommit, 0o755);
30+
const configPath = join(root, "host-gitconfig");
31+
writeFileSync(configPath, `[core]\n\thooksPath = ${hooksDir}\n`);
32+
return {
33+
configPath,
34+
env: {
35+
...process.env,
36+
GIT_CONFIG_GLOBAL: configPath,
37+
GIT_CONFIG_NOSYSTEM: "1",
38+
},
39+
};
40+
}
41+
42+
function gitStatus(
43+
cwd: string,
44+
args: string[],
45+
env: NodeJS.ProcessEnv,
46+
): { status: number; stderr: string } {
47+
const proc = Bun.spawnSync(["git", ...args], { cwd, env, stdout: "pipe", stderr: "pipe" });
48+
return {
49+
status: proc.exitCode ?? 1,
50+
stderr: proc.stderr.toString(),
51+
};
52+
}
53+
54+
test("a naive fixture commit fails when the host config points at a rejecting hook", () => {
55+
const root = tempRoot();
56+
const { env } = rejectingHostConfig(root);
57+
const repo = join(root, "naive");
58+
mkdirSync(repo);
59+
execFileSync("git", ["init"], { cwd: repo, env, stdio: "ignore" });
60+
execFileSync("git", ["config", "--local", "user.email", "t@t.test"], {
61+
cwd: repo,
62+
env,
63+
stdio: "ignore",
64+
});
65+
execFileSync("git", ["config", "--local", "user.name", "t"], {
66+
cwd: repo,
67+
env,
68+
stdio: "ignore",
69+
});
70+
const commit = gitStatus(repo, ["commit", "--allow-empty", "-m", "init"], env);
71+
expect(commit.status).not.toBe(0);
72+
expect(commit.stderr).toContain("host hook rejected fixture commit");
73+
});
74+
75+
test("initTemporaryGitRepo fixture commit succeeds under a rejecting host hooksPath", () => {
76+
const root = tempRoot();
77+
const { configPath, env } = rejectingHostConfig(root);
78+
const before = readFileSync(configPath, "utf8");
79+
const repo = join(root, "hermetic");
80+
mkdirSync(repo);
81+
82+
initTemporaryGitRepo(repo);
83+
84+
const commit = gitStatus(repo, ["commit", "--allow-empty", "-m", "init"], env);
85+
expect(commit.status).toBe(0);
86+
expect(commit.stderr).not.toContain("host hook rejected fixture commit");
87+
execFileSync("git", ["rev-parse", "HEAD"], { cwd: repo, env, stdio: "ignore" });
88+
expect(readFileSync(configPath, "utf8")).toBe(before);
89+
});
90+
91+
test("initTemporaryGitRepo sets local identity and core.hooksPath without touching global config", () => {
92+
const root = tempRoot();
93+
const { configPath, env } = rejectingHostConfig(root);
94+
const before = readFileSync(configPath, "utf8");
95+
const repo = join(root, "local-only");
96+
mkdirSync(repo);
97+
98+
initTemporaryGitRepo(repo);
99+
100+
const email = execFileSync("git", ["config", "--local", "--get", "user.email"], {
101+
cwd: repo,
102+
env,
103+
encoding: "utf8",
104+
}).trim();
105+
const name = execFileSync("git", ["config", "--local", "--get", "user.name"], {
106+
cwd: repo,
107+
env,
108+
encoding: "utf8",
109+
}).trim();
110+
const hooksPath = execFileSync("git", ["config", "--local", "--get", "core.hooksPath"], {
111+
cwd: repo,
112+
env,
113+
encoding: "utf8",
114+
}).trim();
115+
expect(email).toBe("t@t.test");
116+
expect(name).toBe("t");
117+
expect(hooksPath.length).toBeGreaterThan(0);
118+
expect(readFileSync(configPath, "utf8")).toBe(before);
119+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { execFileSync } from "node:child_process";
2+
import { mkdirSync } from "node:fs";
3+
import { join } from "node:path";
4+
5+
export interface InitTemporaryGitRepoOpts {
6+
/** Extra arguments after `git init` (`-b main`, `--bare`, `-q`). */
7+
initArgs?: readonly string[];
8+
}
9+
10+
/**
11+
* Initialize a throwaway Git repository for tests.
12+
*
13+
* Sets a local identity and points `core.hooksPath` at an empty directory
14+
* inside the repo so machine-level hooks cannot reject fixture commits.
15+
* Never writes global or system Git configuration and does not use
16+
* `GIT_CONFIG_*` env workarounds.
17+
*/
18+
export function initTemporaryGitRepo(dir: string, opts: InitTemporaryGitRepoOpts = {}): void {
19+
git(dir, "init", ...(opts.initArgs ?? []));
20+
const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], {
21+
cwd: dir,
22+
encoding: "utf8",
23+
}).trim();
24+
const hooksDir = join(gitDir, "corbits-no-hooks");
25+
mkdirSync(hooksDir, { recursive: true });
26+
git(dir, "config", "--local", "core.hooksPath", hooksDir);
27+
git(dir, "config", "--local", "user.email", "t@t.test");
28+
git(dir, "config", "--local", "user.name", "t");
29+
}
30+
31+
function git(cwd: string, ...args: string[]): void {
32+
execFileSync("git", args, { cwd, stdio: "ignore" });
33+
}

tests/integration/git-push-scoped.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { tmpdir } from "node:os";
1111
import { join } from "node:path";
1212

1313
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
14+
import { initTemporaryGitRepo } from "../helpers/temporary-git-repo.js";
1415

1516
const SCRIPT = join(import.meta.dir, "../../bin/git-push-scoped");
1617

@@ -39,9 +40,7 @@ import { spawnSync } from "node:child_process";
3940
function initWorkingRepo(name: string, remotePath: string): string {
4041
const path = join(root, name);
4142
mkdirSync(path);
42-
spawnSync("git", ["init", "-q", "-b", "main", path]);
43-
spawnSync("git", ["-C", path, "config", "user.email", "test@example.com"]);
44-
spawnSync("git", ["-C", path, "config", "user.name", "Test"]);
43+
initTemporaryGitRepo(path, { initArgs: ["-q", "-b", "main"] });
4544
writeFileSync(join(path, "file.txt"), name);
4645
spawnSync("git", ["-C", path, "add", "file.txt"]);
4746
spawnSync("git", ["-C", path, "commit", "-q", "-m", "initial commit"]);

0 commit comments

Comments
 (0)