mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-11 22:51:26 +00:00
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:
parent
7c58f29975
commit
88a7d3b6df
2 changed files with 131 additions and 73 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue