fix: checkpoint nested-repo detection handles .git files and avoids symlink follow

Replace ripgrep-based getNestedGitRepository() with a recursive fs.readdir walk
that:
- Detects both .git directories (standard repos) and .git files (submodule/
  worktree pointer files containing gitdir: ...)
- Uses lstat semantics (withFileTypes) so symlinks are never followed, preventing
  false positives from symlinks pointing outside the workspace
- Uses proper path operations instead of fragile string matching
- Skips .git directories and node_modules for performance
- Short-circuits on first nested match

Fixes #11340
This commit is contained in:
Roo Code 2026-02-09 20:32:23 +00:00
parent 7c58f29975
commit 88a7d3b6df
2 changed files with 131 additions and 73 deletions

View file

@ -9,7 +9,6 @@ import pWaitFor from "p-wait-for"
import * as vscode from "vscode"
import { fileExistsAtPath } from "../../utils/fs"
import { executeRipgrep } from "../../services/search/file-search"
import { t } from "../../i18n"
import { CheckpointDiff, CheckpointResult, CheckpointEventMap } from "./types"
@ -221,45 +220,7 @@ export abstract class ShadowCheckpointService extends EventEmitter {
private async getNestedGitRepository(): Promise<string | null> {
try {
// Find all .git/HEAD files that are not at the root level.
const args = ["--files", "--hidden", "--follow", "-g", "**/.git/HEAD", this.workspaceDir]
const gitPaths = await executeRipgrep({ args, workspacePath: this.workspaceDir })
// Filter to only include nested git directories (not the root .git).
// Since we're searching for HEAD files, we expect type to be "file"
const nestedGitPaths = gitPaths.filter(({ type, path: filePath }) => {
// Check if it's a file and is a nested .git/HEAD (not at root)
if (type !== "file") return false
// Ensure it's a .git/HEAD file and not the root one
const normalizedPath = filePath.replace(/\\/g, "/")
return (
normalizedPath.includes(".git/HEAD") &&
!normalizedPath.startsWith(".git/") &&
normalizedPath !== ".git/HEAD"
)
})
if (nestedGitPaths.length > 0) {
// Get the first nested git repository path
// Remove .git/HEAD from the path to get the repository directory
const headPath = nestedGitPaths[0].path
// Use path module to properly extract the repository directory
// The HEAD file is at .git/HEAD, so we need to go up two directories
const gitDir = path.dirname(headPath) // removes HEAD, gives us .git
const repoDir = path.dirname(gitDir) // removes .git, gives us the repo directory
const absolutePath = path.join(this.workspaceDir, repoDir)
this.log(
`[${this.constructor.name}#getNestedGitRepository] found ${nestedGitPaths.length} nested git repositories, first at: ${repoDir}`,
)
return absolutePath
}
return null
return await this.findNestedGitEntry(this.workspaceDir, /* isRoot */ true)
} catch (error) {
this.log(
`[${this.constructor.name}#getNestedGitRepository] failed to check for nested git repos: ${error instanceof Error ? error.message : String(error)}`,
@ -270,6 +231,67 @@ export abstract class ShadowCheckpointService extends EventEmitter {
}
}
/**
* Recursively walks the workspace directory to find nested .git entries.
*
* This detects both:
* - `.git` directories (standard nested repos)
* - `.git` files (submodule/worktree pointer files containing `gitdir: ...`)
*
* It uses `lstat` semantics via `withFileTypes` so symbolic links are never
* followed, preventing false positives from symlinks pointing outside the
* workspace.
*
* The root-level `.git` entry is always skipped so the workspace's own
* repository is not treated as nested.
*/
private async findNestedGitEntry(dir: string, isRoot: boolean): Promise<string | null> {
let entries: import("fs").Dirent[]
try {
entries = await fs.readdir(dir, { withFileTypes: true })
} catch {
// Directory unreadable (permissions, etc.) -- skip silently.
return null
}
// Look for a .git entry in this directory (skip root level).
if (!isRoot) {
const hasGitEntry = entries.some((e) => e.name === ".git")
if (hasGitEntry) {
this.log(
`[${this.constructor.name}#getNestedGitRepository] found nested git repository at: ${path.relative(this.workspaceDir, dir)}`,
)
return dir
}
}
// Recurse into real subdirectories only (skip symlinks, .git dirs,
// and node_modules for performance).
for (const entry of entries) {
if (entry.isSymbolicLink()) {
continue
}
if (!entry.isDirectory()) {
continue
}
if (entry.name === ".git" || entry.name === "node_modules") {
continue
}
const result = await this.findNestedGitEntry(path.join(dir, entry.name), false)
if (result) {
return result
}
}
return null
}
private async getShadowGitConfigWorktree(git: SimpleGit) {
if (!this.shadowGitConfigWorktree) {
try {

View file

@ -8,7 +8,6 @@ import { EventEmitter } from "events"
import { simpleGit, SimpleGit } from "simple-git"
import { fileExistsAtPath } from "../../../utils/fs"
import * as fileSearch from "../../../services/search/file-search"
import { RepoPerTaskCheckpointService } from "../RepoPerTaskCheckpointService"
@ -379,7 +378,7 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
})
describe(`${klass.name}#hasNestedGitRepositories`, () => {
it("throws error when nested git repositories are detected during initialization", async () => {
it("throws error when nested git directories are detected during initialization", async () => {
// Create a new temporary workspace and service for this test.
const shadowDir = path.join(tmpDir, `${prefix}-nested-git-${Date.now()}`)
const workspaceDir = path.join(tmpDir, `workspace-nested-git-${Date.now()}`)
@ -391,7 +390,7 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
await mainGit.addConfig("user.name", "Roo Code")
await mainGit.addConfig("user.email", "support@roocode.com")
// Create a nested repo inside the workspace.
// Create a nested repo inside the workspace (standard .git directory).
const nestedRepoPath = path.join(workspaceDir, "nested-project")
await fs.mkdir(nestedRepoPath, { recursive: true })
const nestedGit = simpleGit(nestedRepoPath)
@ -413,42 +412,85 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
// Confirm nested git directory exists before initialization.
const nestedGitDir = path.join(nestedRepoPath, ".git")
const headFile = path.join(nestedGitDir, "HEAD")
await fs.writeFile(headFile, "HEAD")
expect(await fileExistsAtPath(nestedGitDir)).toBe(true)
vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(({ args }) => {
const searchPattern = args[4]
if (searchPattern.includes(".git/HEAD")) {
// Return the HEAD file path, not the .git directory
const headFilePath = path.join(path.relative(workspaceDir, nestedGitDir), "HEAD")
return Promise.resolve([
{
path: headFilePath,
type: "file", // HEAD is a file, not a folder
label: "HEAD",
},
])
} else {
return Promise.resolve([])
}
})
const service = new klass(taskId, shadowDir, workspaceDir, () => {})
// Verify that initialization throws an error when nested git repos are detected
// The error message now includes the specific path of the nested repository
// Verify that initialization throws an error when nested git repos are detected.
await expect(service.initShadowGit()).rejects.toThrowError(
/Checkpoints are disabled because a nested git repository was detected at:/,
)
// Clean up.
vitest.restoreAllMocks()
await fs.rm(shadowDir, { recursive: true, force: true })
await fs.rm(workspaceDir, { recursive: true, force: true })
})
it("throws error when nested .git pointer file is detected (submodule/worktree)", async () => {
// Create a new temporary workspace and service for this test.
const shadowDir = path.join(tmpDir, `${prefix}-nested-gitfile-${Date.now()}`)
const workspaceDir = path.join(tmpDir, `workspace-nested-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")
// Simulate a submodule: create a directory with a .git *file*
// (not a directory) containing a gitdir pointer.
const submodulePath = path.join(workspaceDir, "my-submodule")
await fs.mkdir(submodulePath, { recursive: true })
await fs.writeFile(path.join(submodulePath, ".git"), "gitdir: ../.git/modules/my-submodule\n")
const service = new klass(taskId, shadowDir, workspaceDir, () => {})
// Verify that initialization throws when a .git file (submodule pointer) is detected.
await expect(service.initShadowGit()).rejects.toThrowError(
/Checkpoints are disabled because a nested git repository was detected at:/,
)
// 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()}`)
const workspaceDir = path.join(tmpDir, `workspace-symlink-${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 an external directory with a nested git repo (outside the workspace).
const externalDir = path.join(tmpDir, `external-repo-${Date.now()}`)
const externalRepoDir = path.join(externalDir, "repo-with-git")
await fs.mkdir(externalRepoDir, { recursive: true })
const externalGit = simpleGit(externalRepoDir)
await externalGit.init()
// Create a symlink inside the workspace pointing to the external directory.
const symlinkPath = path.join(workspaceDir, "external-link")
await fs.symlink(externalDir, symlinkPath, "dir")
const service = new klass(taskId, shadowDir, workspaceDir, () => {})
// The symlink should NOT be followed, so no nested repo should be detected.
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 })
await fs.rm(externalDir, { recursive: true, force: true })
})
it("succeeds when no nested git repositories are detected", async () => {
// Create a new temporary workspace and service for this test.
const shadowDir = path.join(tmpDir, `${prefix}-no-nested-git-${Date.now()}`)
@ -467,19 +509,13 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
await mainGit.add(".")
await mainGit.commit("Initial commit in main repo")
vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(() => {
// Return empty array to simulate no nested git repos found
return Promise.resolve([])
})
const service = new klass(taskId, shadowDir, workspaceDir, () => {})
// Verify that initialization succeeds when no nested git repos are detected
// Verify that initialization succeeds when no nested git repos are detected.
await expect(service.initShadowGit()).resolves.not.toThrow()
expect(service.isInitialized).toBe(true)
// Clean up.
vitest.restoreAllMocks()
await fs.rm(shadowDir, { recursive: true, force: true })
await fs.rm(workspaceDir, { recursive: true, force: true })
})