Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 27 additions & 37 deletions src/services/checkpoints/ShadowCheckpointService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,52 +220,42 @@ export abstract class ShadowCheckpointService extends EventEmitter {
}

private async getNestedGitRepository(): Promise<string | null> {
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
}
}
Expand Down
188 changes: 181 additions & 7 deletions src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, () => {})
Expand Down Expand Up @@ -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`, () => {
Expand Down
Loading