diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index fee08b2fa4b..692e5e27633 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -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 { 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,87 @@ 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 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 + * 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 { + 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 gitEntry = entries.find((e) => e.name === ".git") + + 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 + } + } + } + + // 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 { diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index ee8f7bbdc9c..ecf2ba38abe 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -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,114 @@ 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. + 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("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 an error when nested git repos are detected - // The error message now includes the specific path of the nested repository + // 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. - vitest.restoreAllMocks() await fs.rm(shadowDir, { recursive: true, force: true }) 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()}`) + 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 +538,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 }) })