diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index 290332c246..692e5e2763 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -236,7 +236,10 @@ export abstract class ShadowCheckpointService extends EventEmitter { * * This detects both: * - `.git` directories (standard nested repos) - * - `.git` files (submodule/worktree pointer files containing `gitdir: ...`) + * - `.git` files containing a `gitdir:` pointer (submodules/worktrees) + * + * A `.git` file that does NOT begin with `gitdir:` is ignored to avoid + * false positives from stray files. * * It uses `lstat` semantics via `withFileTypes` so symbolic links are never * followed, preventing false positives from symlinks pointing outside the @@ -257,13 +260,30 @@ export abstract class ShadowCheckpointService extends EventEmitter { // Look for a .git entry in this directory (skip root level). if (!isRoot) { - const hasGitEntry = entries.some((e) => e.name === ".git") + const gitEntry = entries.find((e) => e.name === ".git") - if (hasGitEntry) { - this.log( - `[${this.constructor.name}#getNestedGitRepository] found nested git repository at: ${path.relative(this.workspaceDir, dir)}`, - ) - return dir + if (gitEntry) { + // .git directories are always valid nested repos. + // .git files are only valid if they contain a "gitdir:" pointer + // (used by submodules and worktrees). Stray .git files without + // "gitdir:" are not treated as nested repos to avoid false positives. + let isNestedRepo = gitEntry.isDirectory() + + if (!isNestedRepo && !gitEntry.isSymbolicLink()) { + try { + const content = await fs.readFile(path.join(dir, ".git"), "utf-8") + isNestedRepo = content.trimStart().toLowerCase().startsWith("gitdir:") + } catch { + // Unreadable .git file -- skip gracefully. + } + } + + if (isNestedRepo) { + this.log( + `[${this.constructor.name}#getNestedGitRepository] found nested git repository at: ${path.relative(this.workspaceDir, dir)}`, + ) + return dir + } } } diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index bd9c996cd6..ecf2ba38ab 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -456,6 +456,35 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( await fs.rm(workspaceDir, { recursive: true, force: true }) }) + it("ignores stray .git file without gitdir: content (no false positive)", async () => { + // Create a new temporary workspace and service for this test. + const shadowDir = path.join(tmpDir, `${prefix}-stray-gitfile-${Date.now()}`) + const workspaceDir = path.join(tmpDir, `workspace-stray-gitfile-${Date.now()}`) + + // Create a primary workspace repo. + await fs.mkdir(workspaceDir, { recursive: true }) + const mainGit = simpleGit(workspaceDir) + await mainGit.init() + await mainGit.addConfig("user.name", "Roo Code") + await mainGit.addConfig("user.email", "support@roocode.com") + + // Create a stray .git file that does NOT contain "gitdir:" -- + // this should NOT be treated as a nested repo. + const strayDir = path.join(workspaceDir, "some-tool-output") + await fs.mkdir(strayDir, { recursive: true }) + await fs.writeFile(path.join(strayDir, ".git"), "not a gitdir pointer\n") + + const service = new klass(taskId, shadowDir, workspaceDir, () => {}) + + // Initialization should succeed because the .git file is not a valid pointer. + await expect(service.initShadowGit()).resolves.not.toThrow() + expect(service.isInitialized).toBe(true) + + // Clean up. + await fs.rm(shadowDir, { recursive: true, force: true }) + await fs.rm(workspaceDir, { recursive: true, force: true }) + }) + it("does not follow symlinks outside the workspace", async () => { // Create a new temporary workspace and service for this test. const shadowDir = path.join(tmpDir, `${prefix}-symlink-${Date.now()}`)