diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index bd44afb358..c7b8ba9659 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -39,7 +39,8 @@ function createSanitizedGit(baseDir: string): SimpleGit { key === "GIT_INDEX_FILE" || key === "GIT_OBJECT_DIRECTORY" || key === "GIT_ALTERNATE_OBJECT_DIRECTORIES" || - key === "GIT_CEILING_DIRECTORIES" + key === "GIT_CEILING_DIRECTORIES" || + key === "GIT_TEMPLATE_DIR" ) { removedVars.push(`${key}=${value}`) continue @@ -172,7 +173,9 @@ export abstract class ShadowCheckpointService extends EventEmitter { this.baseHash = await git.revparse(["HEAD"]) } else { this.log(`[${this.constructor.name}#initShadowGit] creating shadow git repo at ${this.checkpointsDir}`) - await git.init() + // Use --template= to prevent inheriting user's global init.templateDir configuration + // This ensures no unwanted hooks or templates are copied to the shadow repository + await git.init(["--template="]) await git.addConfig("core.worktree", this.workspaceDir) // Sets the working tree to the current workspace. await git.addConfig("commit.gpgSign", "false") // Disable commit signing for shadow repo. await git.addConfig("user.name", "Roo Code") diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index 92bf1f8e7d..5bc43d54ce 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -824,6 +824,55 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!") }) + it("does not apply git templates when initializing shadow repo", async () => { + // This test verifies that git init uses --template="" and GIT_TEMPLATE_DIR + // is stripped, preventing system/user git hooks from leaking into the shadow repo. + const templateDir = path.join(tmpDir, `git-template-${Date.now()}`) + const hooksDir = path.join(templateDir, "hooks") + await fs.mkdir(hooksDir, { recursive: true }) + await fs.writeFile(path.join(hooksDir, "pre-commit"), "#!/bin/sh\nexit 1", { mode: 0o755 }) + + const testShadowDir = path.join(tmpDir, `shadow-template-test-${Date.now()}`) + const testWorkspaceDir = path.join(tmpDir, `workspace-template-test-${Date.now()}`) + await initWorkspaceRepo({ workspaceDir: testWorkspaceDir }) + + const originalTemplateDir = process.env.GIT_TEMPLATE_DIR + process.env.GIT_TEMPLATE_DIR = templateDir + + try { + const testService = await klass.create({ + taskId: `test-template-${Date.now()}`, + shadowDir: testShadowDir, + workspaceDir: testWorkspaceDir, + log: () => {}, + }) + await testService.initShadowGit() + + // Verify no hooks were copied from the template + const shadowHooksDir = path.join(testShadowDir, ".git", "hooks") + let hookFiles: string[] = [] + + try { + hookFiles = await fs.readdir(shadowHooksDir) + } catch { + // hooks dir may not exist at all, which is fine + } + + // The pre-commit hook from the template should NOT be present + expect(hookFiles).not.toContain("pre-commit") + } finally { + if (originalTemplateDir !== undefined) { + process.env.GIT_TEMPLATE_DIR = originalTemplateDir + } else { + delete process.env.GIT_TEMPLATE_DIR + } + + await fs.rm(testShadowDir, { recursive: true, force: true }) + await fs.rm(testWorkspaceDir, { recursive: true, force: true }) + await fs.rm(templateDir, { recursive: true, force: true }) + } + }) + it("isolates checkpoint operations from GIT_DIR environment variable", async () => { // This test verifies the fix for the issue where GIT_DIR environment variable // causes checkpoint commits to go to the wrong repository.