diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts index 226f14dd85a..5adbf7017ee 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts @@ -287,6 +287,125 @@ describe("getDefaultBranch", () => { }); }); +describe("Worktree creation bypasses git hooks", () => { + function setupRepoWithFailingHook(testName: string) { + const baseDir = join( + __dirname, + `.test-${testName}-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const repoPath = join(baseDir, "repo"); + mkdirSync(repoPath, { recursive: true }); + execSync("git init", { cwd: repoPath, stdio: "ignore" }); + execSync("git config user.email 'test@test.com'", { + cwd: repoPath, + stdio: "ignore", + }); + execSync("git config user.name 'Test'", { cwd: repoPath, stdio: "ignore" }); + writeFileSync(join(repoPath, "README.md"), "# test\n"); + execSync("git add . && git commit -m 'init'", { + cwd: repoPath, + stdio: "ignore", + }); + + const hooksDir = join(repoPath, ".git", "hooks"); + mkdirSync(hooksDir, { recursive: true }); + writeFileSync( + join(hooksDir, "post-checkout"), + '#!/bin/sh\necho "hook: simulating pnpm not found" >&2\nexit 1\n', + ); + execSync(`chmod +x "${join(hooksDir, "post-checkout")}"`, { + stdio: "ignore", + }); + + return { + repoPath, + worktreePath: join(baseDir, "worktree"), + cleanup: () => { + if (existsSync(baseDir)) { + rmSync(baseDir, { recursive: true, force: true }); + } + }, + }; + } + + test("createWorktree succeeds despite failing post-checkout hook", async () => { + const { createWorktree } = await import("./git"); + const { repoPath, worktreePath, cleanup } = + setupRepoWithFailingHook("create"); + try { + await createWorktree(repoPath, "test-branch", worktreePath, "HEAD"); + + expect(existsSync(worktreePath)).toBe(true); + const branch = execSync("git rev-parse --abbrev-ref HEAD", { + cwd: worktreePath, + encoding: "utf-8", + }).trim(); + expect(branch).toBe("test-branch"); + } finally { + cleanup(); + } + }); + + test("createWorktreeFromExistingBranch succeeds despite failing post-checkout hook", async () => { + const { createWorktreeFromExistingBranch } = await import("./git"); + const { repoPath, worktreePath, cleanup } = + setupRepoWithFailingHook("existing"); + try { + execSync("git branch existing-branch", { + cwd: repoPath, + stdio: "ignore", + }); + + await createWorktreeFromExistingBranch({ + mainRepoPath: repoPath, + branch: "existing-branch", + worktreePath, + }); + + expect(existsSync(worktreePath)).toBe(true); + const branch = execSync("git rev-parse --abbrev-ref HEAD", { + cwd: worktreePath, + encoding: "utf-8", + }).trim(); + expect(branch).toBe("existing-branch"); + } finally { + cleanup(); + } + }); + + test("failing post-checkout hook blocks vanilla worktree add but not with hooksPath=/dev/null", () => { + const { repoPath, cleanup } = setupRepoWithFailingHook("proof"); + const failPath = join(repoPath, "..", "wt-fail"); + const okPath = join(repoPath, "..", "wt-ok"); + try { + expect(() => { + execSync( + `git -C "${repoPath}" worktree add "${failPath}" -b fail HEAD`, + { stdio: "pipe" }, + ); + }).toThrow(); + + // git creates branch + directory before running the hook, clean up residual state + try { + execSync(`git -C "${repoPath}" worktree prune`, { stdio: "ignore" }); + } catch {} + try { + execSync(`git -C "${repoPath}" branch -D fail`, { stdio: "ignore" }); + } catch {} + if (existsSync(failPath)) + rmSync(failPath, { recursive: true, force: true }); + + execSync( + `git -c core.hooksPath=/dev/null -C "${repoPath}" worktree add "${okPath}" -b ok HEAD`, + { stdio: "pipe" }, + ); + expect(existsSync(okPath)).toBe(true); + } finally { + cleanup(); + } + }); +}); + describe("Shell Environment", () => { test("getShellEnvironment returns PATH", async () => { const { getShellEnvironment } = await import("./shell-env"); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts index 70bad22f411..9de83d4b692 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -460,6 +460,8 @@ export async function createWorktree( await execFileAsync( "git", [ + "-c", + "core.hooksPath=/dev/null", "-C", mainRepoPath, "worktree", @@ -567,7 +569,16 @@ export async function createWorktreeFromExistingBranch({ // Branch exists locally - just checkout into the worktree await execFileAsync( "git", - ["-C", mainRepoPath, "worktree", "add", worktreePath, branch], + [ + "-c", + "core.hooksPath=/dev/null", + "-C", + mainRepoPath, + "worktree", + "add", + worktreePath, + branch, + ], { env, timeout: 120_000 }, ); } else { @@ -580,6 +591,8 @@ export async function createWorktreeFromExistingBranch({ await execFileAsync( "git", [ + "-c", + "core.hooksPath=/dev/null", "-C", mainRepoPath, "worktree", @@ -1710,7 +1723,16 @@ export async function createWorktreeFromPr({ await execFileAsync( "git", - ["-C", mainRepoPath, "worktree", "add", worktreePath, branchName], + [ + "-c", + "core.hooksPath=/dev/null", + "-C", + mainRepoPath, + "worktree", + "add", + worktreePath, + branchName, + ], { env, timeout: 120_000 }, ); @@ -1722,7 +1744,14 @@ export async function createWorktreeFromPr({ ); } } else { - const args = ["-C", mainRepoPath, "worktree", "add"]; + const args = [ + "-c", + "core.hooksPath=/dev/null", + "-C", + mainRepoPath, + "worktree", + "add", + ]; if (!prInfo.isCrossRepository) { args.push("--track"); }