fix(checkpoints): validate gitdir: content in .git files before flagging as nested repo

Addresses feedback from #11340: .git files without gitdir: content
(e.g. stray files) are no longer false-positived as nested repos.

- findNestedGitEntry now checks .git file content for gitdir: prefix
- .git directories are still always treated as nested repos
- Adds regression test for stray .git file without gitdir: pointer
This commit is contained in:
Roo Code 2026-02-09 21:10:29 +00:00
parent 88a7d3b6df
commit c042bebf2d
2 changed files with 56 additions and 7 deletions

View file

@ -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
}
}
}

View file

@ -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()}`)