Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions src/agent/environment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { join } from "node:path";
import { promisify } from "node:util";

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

const run = promisify(execFile);

Expand All @@ -22,9 +23,7 @@ test("gatherEnvironment detects a git work tree and lists its top level", async
// a runner may check out a detached HEAD, which has no branch name.
const dir = await mkdtemp(join(tmpdir(), "corbits-env-repo-"));
try {
await run("git", ["init"], { cwd: dir });
await run("git", ["config", "user.email", "t@t.test"], { cwd: dir });
await run("git", ["config", "user.name", "t"], { cwd: dir });
initTemporaryGitRepo(dir);
await run("git", ["checkout", "-b", "trunk"], { cwd: dir });
await mkdir(join(dir, "src"));
await writeFile(join(dir, "src", "seed.ts"), "export const seed = 1;\n");
Expand All @@ -43,9 +42,7 @@ test("gatherEnvironment detects a git work tree and lists its top level", async
test("gatherEnvironment gathers branch and dirty status from the same work tree", async () => {
const dir = await mkdtemp(join(tmpdir(), "corbits-env-"));
try {
await run("git", ["init"], { cwd: dir });
await run("git", ["config", "user.email", "t@t.test"], { cwd: dir });
await run("git", ["config", "user.name", "t"], { cwd: dir });
initTemporaryGitRepo(dir);
await run("git", ["checkout", "-b", "trunk"], { cwd: dir });
await writeFile(join(dir, "seed.txt"), "seed");
await run("git", ["add", "."], { cwd: dir });
Expand Down
9 changes: 3 additions & 6 deletions src/permission/gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createPermissionGate, isRequestCoveredByGrant, preGrantGuardReason } fr
import { createPathRestriction } from "./path-restriction.js";
import { createWorktreeRootsProvider } from "./worktree-roots.js";
import type { Approval, PermissionRequest } from "./types.js";
import { initTemporaryGitRepo } from "../../tests/helpers/temporary-git-repo.js";

const shellCall = (command: string): ToolCall => ({
id: "c",
Expand Down Expand Up @@ -112,9 +113,7 @@ describe("grant coverage rebinds relative paths to the request process cwd", ()
const sessionCwd = join(root, "main");
const git = (args: string[], cwd: string) => execFileSync("git", args, { cwd, stdio: "ignore" });
mkdirSync(sessionCwd);
git(["init", "-q"], sessionCwd);
git(["config", "user.email", "t@example.com"], sessionCwd);
git(["config", "user.name", "t"], sessionCwd);
initTemporaryGitRepo(sessionCwd, { initArgs: ["-q"] });
writeFileSync(join(sessionCwd, "seed.txt"), "seed\n");
git(["add", "."], sessionCwd);
git(["commit", "-qm", "seed"], sessionCwd);
Expand Down Expand Up @@ -171,9 +170,7 @@ describe("standing grant covers a later git worktree command (CL-5638)", () => {
const sessionCwd = join(root, "main");
const git = (args: string[], cwd: string) => execFileSync("git", args, { cwd, stdio: "ignore" });
mkdirSync(sessionCwd);
git(["init", "-q"], sessionCwd);
git(["config", "user.email", "t@example.com"], sessionCwd);
git(["config", "user.name", "t"], sessionCwd);
initTemporaryGitRepo(sessionCwd, { initArgs: ["-q"] });
writeFileSync(join(sessionCwd, "seed.txt"), "seed\n");
git(["add", "."], sessionCwd);
git(["commit", "-qm", "seed"], sessionCwd);
Expand Down
13 changes: 7 additions & 6 deletions src/permission/permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import { listWorktreeRoots, createWorktreeRootsProvider } from "./worktree-roots.js";
import { createPathRestriction, resolveWorkspacePath } from "./path-restriction.js";
import type { Approval, PermissionRequest } from "./types.js";
import { initTemporaryGitRepo } from "../../tests/helpers/temporary-git-repo.js";
import { secretGuardPlugin } from "../plugins/secret-guard-plugin.js";
import { pathEscapePlugin } from "../plugins/path-escape-plugin.js";

Expand Down Expand Up @@ -3275,8 +3276,8 @@ describe("listWorktreeRoots", () => {
const repo = join(base, "repo");
const worktree = join(base, "secondary");
mkdirSync(repo);
git(repo, "init", "-b", "main");
git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-m", "init");
initTemporaryGitRepo(repo, { initArgs: ["-b", "main"] });
git(repo, "commit", "--allow-empty", "-m", "init");
git(repo, "worktree", "add", worktree);
return { repo, worktree };
};
Expand Down Expand Up @@ -3376,8 +3377,8 @@ describe("createWorktreeRootsProvider lazy re-discovery", () => {
const base = mkdtempSync(join(tmpdir(), "corbits-lazy-"));
const repo = join(base, "repo");
mkdirSync(repo);
git(repo, "init", "-b", "main");
git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-m", "init");
initTemporaryGitRepo(repo, { initArgs: ["-b", "main"] });
git(repo, "commit", "--allow-empty", "-m", "init");
return repo;
};

Expand Down Expand Up @@ -3707,8 +3708,8 @@ describe("project-scoped grants match sub-agent worktree requests (CL-5662)", ()
const repo = join(base, "repo");
const worktree = join(base, "sibling-worktree");
mkdirSync(repo);
git(repo, "init", "-b", "main");
git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-m", "init");
initTemporaryGitRepo(repo, { initArgs: ["-b", "main"] });
git(repo, "commit", "--allow-empty", "-m", "init");
git(repo, "worktree", "add", worktree);
return { repo, worktree };
};
Expand Down
11 changes: 3 additions & 8 deletions src/session/project-key.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { tmpdir } from "node:os";
import { execFileSync } from "node:child_process";

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

let root = "";

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

function initGitRepo(dir: string): void {
execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "test"], { cwd: dir, stdio: "ignore" });
}

async function commitReadme(dir: string): Promise<void> {
await writeFile(join(dir, "README"), "x");
execFileSync("git", ["add", "README"], { cwd: dir, stdio: "ignore" });
Expand All @@ -38,7 +33,7 @@ test("projectKeyFor is stable across calls for the same path", () => {
});

test("projectKeyFor shares nested dirs under the same git toplevel", async () => {
initGitRepo(root);
initTemporaryGitRepo(root);
await commitReadme(root);

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

test("linked worktrees have distinct project roots and keys from main and each other", async () => {
initGitRepo(root);
initTemporaryGitRepo(root);
await commitReadme(root);

const wtA = join(root, "..", `wt-a-${Date.now()}-${Math.random().toString(16).slice(2)}`);
Expand Down
5 changes: 2 additions & 3 deletions src/session/session-dir.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
migrateLegacySessionIfNeeded,
sessionDir,
} from "./index.js";
import { initTemporaryGitRepo } from "../../tests/helpers/temporary-git-repo.js";

let cwd = "";
let home = "";
Expand Down Expand Up @@ -84,9 +85,7 @@ test("listSessions finds legacy sessions and migrates them", async () => {
test("migrateLegacySessionIfNeeded does not migrate main-repo .agent-state from a worktree cwd", async () => {
const main = join(cwd, "main");
await mkdir(main, { recursive: true });
execFileSync("git", ["init"], { cwd: main, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: main, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "test"], { cwd: main, stdio: "ignore" });
initTemporaryGitRepo(main);
await writeFile(join(main, "README"), "x");
execFileSync("git", ["add", "README"], { cwd: main, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "init"], { cwd: main, stdio: "ignore" });
Expand Down
5 changes: 2 additions & 3 deletions src/subagent/spawn-agent-worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createSubAgentSessionStore } from "./session-store.js";
import { createPermissionGate } from "../permission/gate.js";
import type { RunSubAgentParams, RunSubAgentResult } from "./types.js";
import type { Telemetry } from "../telemetry/index.js";
import { initTemporaryGitRepo } from "../../tests/helpers/temporary-git-repo.js";

const run = promisify(execFile);

Expand Down Expand Up @@ -49,9 +50,7 @@ afterEach(async () => {

async function makeRepo(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "corbits-spawn-wt-"));
await run("git", ["init"], { cwd: dir });
await run("git", ["config", "user.email", "t@t.test"], { cwd: dir });
await run("git", ["config", "user.name", "t"], { cwd: dir });
initTemporaryGitRepo(dir);
await writeFile(join(dir, "seed.txt"), "seed");
await run("git", ["add", "."], { cwd: dir });
await run("git", ["commit", "-m", "seed"], { cwd: dir });
Expand Down
5 changes: 2 additions & 3 deletions src/subagent/task-tool-worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { createTaskTool } from "./task-tool.js";
import type { RunSubAgentParams } from "./types.js";
import { createPermissionGate } from "../permission/gate.js";
import type { Telemetry } from "../telemetry/index.js";
import { initTemporaryGitRepo } from "../../tests/helpers/temporary-git-repo.js";

const run = promisify(execFile);

Expand Down Expand Up @@ -51,9 +52,7 @@ async function callTask(

async function makeRepo(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "corbits-worktree-"));
await run("git", ["init"], { cwd: dir });
await run("git", ["config", "user.email", "t@t.test"], { cwd: dir });
await run("git", ["config", "user.name", "t"], { cwd: dir });
initTemporaryGitRepo(dir);
await writeFile(join(dir, "seed.txt"), "seed");
await run("git", ["add", "."], { cwd: dir });
await run("git", ["commit", "-m", "seed"], { cwd: dir });
Expand Down
119 changes: 119 additions & 0 deletions tests/helpers/temporary-git-repo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { afterEach, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { initTemporaryGitRepo } from "./temporary-git-repo.js";

const tempRoots: string[] = [];

afterEach(() => {
while (tempRoots.length > 0) {
const dir = tempRoots.pop();
if (dir !== undefined) rmSync(dir, { recursive: true, force: true });
}
});

function tempRoot(): string {
const dir = mkdtempSync(join(tmpdir(), "corbits-temp-git-"));
tempRoots.push(dir);
return dir;
}

function rejectingHostConfig(root: string): { configPath: string; env: NodeJS.ProcessEnv } {
const hooksDir = join(root, "host-hooks");
mkdirSync(hooksDir);
const preCommit = join(hooksDir, "pre-commit");
writeFileSync(preCommit, "#!/bin/sh\necho 'host hook rejected fixture commit' >&2\nexit 1\n");
chmodSync(preCommit, 0o755);
const configPath = join(root, "host-gitconfig");
writeFileSync(configPath, `[core]\n\thooksPath = ${hooksDir}\n`);
return {
configPath,
env: {
...process.env,
GIT_CONFIG_GLOBAL: configPath,
GIT_CONFIG_NOSYSTEM: "1",
},
};
}

function gitStatus(
cwd: string,
args: string[],
env: NodeJS.ProcessEnv,
): { status: number; stderr: string } {
const proc = Bun.spawnSync(["git", ...args], { cwd, env, stdout: "pipe", stderr: "pipe" });
return {
status: proc.exitCode ?? 1,
stderr: proc.stderr.toString(),
};
}

test("a naive fixture commit fails when the host config points at a rejecting hook", () => {
const root = tempRoot();
const { env } = rejectingHostConfig(root);
const repo = join(root, "naive");
mkdirSync(repo);
execFileSync("git", ["init"], { cwd: repo, env, stdio: "ignore" });
execFileSync("git", ["config", "--local", "user.email", "t@t.test"], {
cwd: repo,
env,
stdio: "ignore",
});
execFileSync("git", ["config", "--local", "user.name", "t"], {
cwd: repo,
env,
stdio: "ignore",
});
const commit = gitStatus(repo, ["commit", "--allow-empty", "-m", "init"], env);
expect(commit.status).not.toBe(0);
expect(commit.stderr).toContain("host hook rejected fixture commit");
});

test("initTemporaryGitRepo fixture commit succeeds under a rejecting host hooksPath", () => {
const root = tempRoot();
const { configPath, env } = rejectingHostConfig(root);
const before = readFileSync(configPath, "utf8");
const repo = join(root, "hermetic");
mkdirSync(repo);

initTemporaryGitRepo(repo);

const commit = gitStatus(repo, ["commit", "--allow-empty", "-m", "init"], env);
expect(commit.status).toBe(0);
expect(commit.stderr).not.toContain("host hook rejected fixture commit");
execFileSync("git", ["rev-parse", "HEAD"], { cwd: repo, env, stdio: "ignore" });
expect(readFileSync(configPath, "utf8")).toBe(before);
});

test("initTemporaryGitRepo sets local identity and core.hooksPath without touching global config", () => {
const root = tempRoot();
const { configPath, env } = rejectingHostConfig(root);
const before = readFileSync(configPath, "utf8");
const repo = join(root, "local-only");
mkdirSync(repo);

initTemporaryGitRepo(repo);

const email = execFileSync("git", ["config", "--local", "--get", "user.email"], {
cwd: repo,
env,
encoding: "utf8",
}).trim();
const name = execFileSync("git", ["config", "--local", "--get", "user.name"], {
cwd: repo,
env,
encoding: "utf8",
}).trim();
const hooksPath = execFileSync("git", ["config", "--local", "--get", "core.hooksPath"], {
cwd: repo,
env,
encoding: "utf8",
}).trim();
expect(email).toBe("t@t.test");
expect(name).toBe("t");
expect(hooksPath.length).toBeGreaterThan(0);
expect(readFileSync(configPath, "utf8")).toBe(before);
});
33 changes: 33 additions & 0 deletions tests/helpers/temporary-git-repo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { execFileSync } from "node:child_process";
import { mkdirSync } from "node:fs";
import { join } from "node:path";

export interface InitTemporaryGitRepoOpts {
/** Extra arguments after `git init` (`-b main`, `--bare`, `-q`). */
initArgs?: readonly string[];
}

/**
* Initialize a throwaway Git repository for tests.
*
* Sets a local identity and points `core.hooksPath` at an empty directory
* inside the repo so machine-level hooks cannot reject fixture commits.
* Never writes global or system Git configuration and does not use
* `GIT_CONFIG_*` env workarounds.
*/
export function initTemporaryGitRepo(dir: string, opts: InitTemporaryGitRepoOpts = {}): void {
git(dir, "init", ...(opts.initArgs ?? []));
const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], {
cwd: dir,
encoding: "utf8",
}).trim();
const hooksDir = join(gitDir, "corbits-no-hooks");
mkdirSync(hooksDir, { recursive: true });
git(dir, "config", "--local", "core.hooksPath", hooksDir);
git(dir, "config", "--local", "user.email", "t@t.test");
git(dir, "config", "--local", "user.name", "t");
}

function git(cwd: string, ...args: string[]): void {
execFileSync("git", args, { cwd, stdio: "ignore" });
}
5 changes: 2 additions & 3 deletions tests/integration/git-push-scoped.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";

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

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

Expand Down Expand Up @@ -39,9 +40,7 @@ import { spawnSync } from "node:child_process";
function initWorkingRepo(name: string, remotePath: string): string {
const path = join(root, name);
mkdirSync(path);
spawnSync("git", ["init", "-q", "-b", "main", path]);
spawnSync("git", ["-C", path, "config", "user.email", "test@example.com"]);
spawnSync("git", ["-C", path, "config", "user.name", "Test"]);
initTemporaryGitRepo(path, { initArgs: ["-q", "-b", "main"] });
writeFileSync(join(path, "file.txt"), name);
spawnSync("git", ["-C", path, "add", "file.txt"]);
spawnSync("git", ["-C", path, "commit", "-q", "-m", "initial commit"]);
Expand Down
8 changes: 5 additions & 3 deletions tests/unit/prepare-homebrew-tap-release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";

import { initTemporaryGitRepo } from "../helpers/temporary-git-repo.js";

const execFileAsync = promisify(execFile);
const script = join(import.meta.dir, "../../scripts/prepare-homebrew-tap-release.sh");

Expand All @@ -16,10 +18,10 @@ describe("prepare-homebrew-tap-release", () => {
root = await mkdtemp(join(tmpdir(), "corbits-release-tap-"));
const origin = join(root, "origin.git");
tapDir = join(root, "tap");
await execFileAsync("git", ["init", "--bare", "--initial-branch=main", origin]);
await mkdir(origin);
initTemporaryGitRepo(origin, { initArgs: ["--bare", "--initial-branch=main"] });
await execFileAsync("git", ["clone", origin, tapDir]);
await execFileAsync("git", ["-C", tapDir, "config", "user.name", "Release Test"]);
await execFileAsync("git", ["-C", tapDir, "config", "user.email", "release@example.test"]);
initTemporaryGitRepo(tapDir);

await mkdir(join(tapDir, "Formula"));
await writeFile(join(tapDir, "Formula/corbits-code.rb"), "version one\n");
Expand Down
Loading
Loading