From ff04091bfaaf04fd96c892696ea95299efaf92f5 Mon Sep 17 00:00:00 2001 From: 0xMink Date: Mon, 9 Feb 2026 14:55:18 -0500 Subject: [PATCH] fix(checkpoints): detect nested repos via .git/HEAD and .git pointer files - Remove --follow flag to prevent symlink traversal outside workspace - Add **/.git glob to detect submodule/worktree .git pointer files - Validate .git files contain gitdir: prefix before treating as repo marker - Use path.basename/path.dirname for classification instead of string matching - Use path.resolve for root exclusion (handles absolute/relative paths) - Add 6 new test cases covering submodule detection, stray .git files, root exclusion, --follow removal, and unreadable file handling Closes #11340 --- .../checkpoints/ShadowCheckpointService.ts | 64 +++--- .../__tests__/ShadowCheckpointService.spec.ts | 188 +++++++++++++++++- 2 files changed, 208 insertions(+), 44 deletions(-) diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index fee08b2fa4b..c36df988840 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -220,52 +220,42 @@ export abstract class ShadowCheckpointService extends EventEmitter { } private async getNestedGitRepository(): Promise { + const tag = `[${this.constructor.name}#getNestedGitRepository]` try { - // Find all .git/HEAD files that are not at the root level. - const args = ["--files", "--hidden", "--follow", "-g", "**/.git/HEAD", this.workspaceDir] - + const workspaceRoot = path.resolve(this.workspaceDir) + const args = ["--files", "--hidden", "-g", "**/.git/HEAD", "-g", "**/.git", 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 + for (const { type, path: filePath } of gitPaths) { + if (type !== "file") continue - // 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) + if (path.basename(filePath) === "HEAD" && path.basename(path.dirname(filePath)) === ".git") { + const repoDir = path.dirname(path.dirname(filePath)) + const absolutePath = path.resolve(this.workspaceDir, repoDir) + if (absolutePath === workspaceRoot) continue + this.log(`${tag} found nested git repository (directory) at: ${repoDir}`) + return absolutePath + } - this.log( - `[${this.constructor.name}#getNestedGitRepository] found ${nestedGitPaths.length} nested git repositories, first at: ${repoDir}`, - ) - return absolutePath + if (path.basename(filePath) === ".git") { + const repoDir = path.dirname(filePath) + const absolutePath = path.resolve(this.workspaceDir, repoDir) + if (absolutePath === workspaceRoot) continue + try { + const content = await fs.readFile(path.resolve(this.workspaceDir, filePath), "utf-8") + if (!content.trimStart().toLowerCase().startsWith("gitdir:")) continue + } catch { + continue + } + this.log(`${tag} found nested git repository (submodule/worktree) at: ${repoDir}`) + return absolutePath + } } return null } catch (error) { - this.log( - `[${this.constructor.name}#getNestedGitRepository] failed to check for nested git repos: ${error instanceof Error ? error.message : String(error)}`, - ) - - // If we can't check, assume there are no nested repos to avoid blocking the feature. + const msg = error instanceof Error ? error.message : String(error) + this.log(`${tag} failed to check for nested git repos: ${msg}`) return null } } diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index ee8f7bbdc9c..3f19a6e016a 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -418,21 +418,17 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( 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 + if (args.includes("**/.git/HEAD")) { const headFilePath = path.join(path.relative(workspaceDir, nestedGitDir), "HEAD") return Promise.resolve([ { path: headFilePath, - type: "file", // HEAD is a file, not a folder + type: "file", label: "HEAD", }, ]) - } else { - return Promise.resolve([]) } + return Promise.resolve([]) }) const service = new klass(taskId, shadowDir, workspaceDir, () => {}) @@ -483,6 +479,184 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( await fs.rm(shadowDir, { recursive: true, force: true }) await fs.rm(workspaceDir, { recursive: true, force: true }) }) + + it("throws error when a submodule .git file is detected", async () => { + const shadowDir = path.join(tmpDir, `${prefix}-submodule-git-${Date.now()}`) + const workspaceDir = path.join(tmpDir, `workspace-submodule-git-${Date.now()}`) + + 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") + + await fs.writeFile(path.join(workspaceDir, "main.txt"), "main content") + await mainGit.add("main.txt") + await mainGit.commit("Initial commit") + + // Create submodule-style .git file after the commit. + const submodulePath = path.join(workspaceDir, "libs", "submodule") + await fs.mkdir(submodulePath, { recursive: true }) + await fs.writeFile(path.join(submodulePath, ".git"), "gitdir: ../../.git/modules/libs/submodule\n") + + vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(() => { + return Promise.resolve([{ path: "libs/submodule/.git", type: "file", label: ".git" }]) + }) + + const service = new klass(taskId, shadowDir, workspaceDir, () => {}) + await expect(service.initShadowGit()).rejects.toThrowError( + /Checkpoints are disabled because a nested git repository was detected at:/, + ) + + vitest.restoreAllMocks() + await fs.rm(shadowDir, { recursive: true, force: true }) + await fs.rm(workspaceDir, { recursive: true, force: true }) + }) + + it("ignores .git file without gitdir: prefix", async () => { + const shadowDir = path.join(tmpDir, `${prefix}-stray-git-${Date.now()}`) + const workspaceDir = path.join(tmpDir, `workspace-stray-git-${Date.now()}`) + + 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") + + await fs.writeFile(path.join(workspaceDir, "main.txt"), "main content") + await mainGit.add("main.txt") + await mainGit.commit("Initial commit") + + // Create a stray .git file that is not a submodule pointer. + const strayDir = path.join(workspaceDir, "somedir") + await fs.mkdir(strayDir, { recursive: true }) + await fs.writeFile(path.join(strayDir, ".git"), "this is not a real git pointer\n") + + vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(() => { + return Promise.resolve([{ path: "somedir/.git", type: "file", label: ".git" }]) + }) + + const service = new klass(taskId, shadowDir, workspaceDir, () => {}) + await service.initShadowGit() + expect(service.isInitialized).toBe(true) + + vitest.restoreAllMocks() + await fs.rm(shadowDir, { recursive: true, force: true }) + await fs.rm(workspaceDir, { recursive: true, force: true }) + }) + + it("ignores root .git/HEAD in results", async () => { + const shadowDir = path.join(tmpDir, `${prefix}-root-head-${Date.now()}`) + const workspaceDir = path.join(tmpDir, `workspace-root-head-${Date.now()}`) + + 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") + + await fs.writeFile(path.join(workspaceDir, "main.txt"), "main content") + await mainGit.add("main.txt") + await mainGit.commit("Initial commit") + + vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(() => { + return Promise.resolve([{ path: ".git/HEAD", type: "file", label: "HEAD" }]) + }) + + const service = new klass(taskId, shadowDir, workspaceDir, () => {}) + await service.initShadowGit() + expect(service.isInitialized).toBe(true) + + vitest.restoreAllMocks() + await fs.rm(shadowDir, { recursive: true, force: true }) + await fs.rm(workspaceDir, { recursive: true, force: true }) + }) + + it("ignores root .git match", async () => { + const shadowDir = path.join(tmpDir, `${prefix}-root-gitfile-${Date.now()}`) + const workspaceDir = path.join(tmpDir, `workspace-root-gitfile-${Date.now()}`) + + 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") + + await fs.writeFile(path.join(workspaceDir, "main.txt"), "main content") + await mainGit.add("main.txt") + await mainGit.commit("Initial commit") + + vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(() => { + return Promise.resolve([{ path: ".git", type: "file", label: ".git" }]) + }) + + const service = new klass(taskId, shadowDir, workspaceDir, () => {}) + await service.initShadowGit() + expect(service.isInitialized).toBe(true) + + vitest.restoreAllMocks() + await fs.rm(shadowDir, { recursive: true, force: true }) + await fs.rm(workspaceDir, { recursive: true, force: true }) + }) + + it("does not use --follow flag in ripgrep args", async () => { + const shadowDir = path.join(tmpDir, `${prefix}-no-follow-${Date.now()}`) + const workspaceDir = path.join(tmpDir, `workspace-no-follow-${Date.now()}`) + + 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") + + await fs.writeFile(path.join(workspaceDir, "main.txt"), "main content") + await mainGit.add("main.txt") + await mainGit.commit("Initial commit") + + let capturedArgs: string[] = [] + vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(({ args }) => { + capturedArgs = args + return Promise.resolve([]) + }) + + const service = new klass(taskId, shadowDir, workspaceDir, () => {}) + await service.initShadowGit() + + expect(capturedArgs).not.toContain("--follow") + expect(capturedArgs).toContain("**/.git/HEAD") + expect(capturedArgs).toContain("**/.git") + + vitest.restoreAllMocks() + await fs.rm(shadowDir, { recursive: true, force: true }) + await fs.rm(workspaceDir, { recursive: true, force: true }) + }) + + it("skips .git file that cannot be read", async () => { + const shadowDir = path.join(tmpDir, `${prefix}-unreadable-${Date.now()}`) + const workspaceDir = path.join(tmpDir, `workspace-unreadable-${Date.now()}`) + + 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") + + await fs.writeFile(path.join(workspaceDir, "main.txt"), "main content") + await mainGit.add("main.txt") + await mainGit.commit("Initial commit") + + vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(() => { + return Promise.resolve([{ path: "nonexistent-dir/.git", type: "file", label: ".git" }]) + }) + + const service = new klass(taskId, shadowDir, workspaceDir, () => {}) + await service.initShadowGit() + expect(service.isInitialized).toBe(true) + + vitest.restoreAllMocks() + await fs.rm(shadowDir, { recursive: true, force: true }) + await fs.rm(workspaceDir, { recursive: true, force: true }) + }) }) describe(`${klass.name}#events`, () => {