Skip to content

Commit 0034b35

Browse files
committed
fix: allow Git repositories in non-Git workspaces for checkpointing
- Modified getNestedGitRepository() to first check if workspace is a Git repo - Only flag repositories as nested if workspace itself is a Git repository - Added test case for the specific scenario in issue #8164 - This fixes the false positive where legitimate Git repos in non-Git workspaces were incorrectly flagged as nested Fixes #8164
1 parent 765e3a0 commit 0034b35

File tree

2 files changed

+88
-2
lines changed

2 files changed

+88
-2
lines changed

src/services/checkpoints/ShadowCheckpointService.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,20 @@ export abstract class ShadowCheckpointService extends EventEmitter {
162162

163163
private async getNestedGitRepository(): Promise<string | null> {
164164
try {
165+
// First check if the workspace root itself is a Git repository
166+
const workspaceIsGitRepo = await fileExistsAtPath(path.join(this.workspaceDir, ".git"))
167+
168+
// If the workspace root is not a Git repository, then any Git repositories
169+
// in subdirectories are not "nested" - they're just regular repositories
170+
// that happen to be in the workspace. This is a valid use case.
171+
if (!workspaceIsGitRepo) {
172+
this.log(
173+
`[${this.constructor.name}#getNestedGitRepository] workspace is not a git repository, allowing child repositories`,
174+
)
175+
return null
176+
}
177+
178+
// The workspace IS a Git repository, so now we need to check for nested repos
165179
// Find all .git/HEAD files that are not at the root level.
166180
const args = ["--files", "--hidden", "--follow", "-g", "**/.git/HEAD", this.workspaceDir]
167181

src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { EventEmitter } from "events"
77

88
import { simpleGit, SimpleGit } from "simple-git"
99

10-
import { fileExistsAtPath } from "../../../utils/fs"
10+
import * as fsUtils from "../../../utils/fs"
1111
import * as fileSearch from "../../../services/search/file-search"
1212

1313
import { RepoPerTaskCheckpointService } from "../RepoPerTaskCheckpointService"
@@ -415,7 +415,7 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
415415
const nestedGitDir = path.join(nestedRepoPath, ".git")
416416
const headFile = path.join(nestedGitDir, "HEAD")
417417
await fs.writeFile(headFile, "HEAD")
418-
expect(await fileExistsAtPath(nestedGitDir)).toBe(true)
418+
expect(await fsUtils.fileExistsAtPath(nestedGitDir)).toBe(true)
419419

420420
vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(({ args }) => {
421421
const searchPattern = args[4]
@@ -483,6 +483,78 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
483483
await fs.rm(shadowDir, { recursive: true, force: true })
484484
await fs.rm(workspaceDir, { recursive: true, force: true })
485485
})
486+
487+
it("allows git repositories in non-git workspace (issue #8164)", async () => {
488+
// This test addresses the specific issue where a workspace that is NOT a git repository
489+
// contains cloned git repositories as subdirectories. This should be allowed.
490+
491+
const shadowDir = path.join(tmpDir, `${prefix}-non-git-workspace-${Date.now()}`)
492+
const workspaceDir = path.join(tmpDir, `workspace-non-git-${Date.now()}`)
493+
494+
// Create a workspace directory WITHOUT initializing it as a git repo
495+
await fs.mkdir(workspaceDir, { recursive: true })
496+
497+
// Create a cloned repository inside the workspace (simulating the user's scenario)
498+
const clonedRepoPath = path.join(workspaceDir, "cloned-repository")
499+
await fs.mkdir(clonedRepoPath, { recursive: true })
500+
const clonedGit = simpleGit(clonedRepoPath)
501+
await clonedGit.init()
502+
await clonedGit.addConfig("user.name", "Roo Code")
503+
await clonedGit.addConfig("user.email", "[email protected]")
504+
505+
// Add a file to the cloned repo
506+
const clonedFile = path.join(clonedRepoPath, "cloned-file.txt")
507+
await fs.writeFile(clonedFile, "Content in cloned repo")
508+
await clonedGit.add(".")
509+
await clonedGit.commit("Initial commit in cloned repo")
510+
511+
// Create a regular file in the workspace root
512+
const workspaceFile = path.join(workspaceDir, "workspace-file.txt")
513+
await fs.writeFile(workspaceFile, "Content in workspace")
514+
515+
// Mock executeRipgrep to return the cloned repo's .git/HEAD
516+
vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(({ args }) => {
517+
const searchPattern = args[4]
518+
519+
if (searchPattern.includes(".git/HEAD")) {
520+
// Return the HEAD file path for the cloned repository
521+
const headFilePath = path.join(path.relative(workspaceDir, clonedRepoPath), ".git", "HEAD")
522+
return Promise.resolve([
523+
{
524+
path: headFilePath,
525+
type: "file",
526+
label: "HEAD",
527+
},
528+
])
529+
} else {
530+
return Promise.resolve([])
531+
}
532+
})
533+
534+
// Mock fileExistsAtPath to return false for workspace/.git (workspace is not a git repo)
535+
vitest.spyOn(fsUtils, "fileExistsAtPath").mockImplementation((filePath: string) => {
536+
if (filePath === path.join(workspaceDir, ".git")) {
537+
return Promise.resolve(false) // Workspace is NOT a git repo
538+
}
539+
// For other paths, use the real implementation
540+
return fs
541+
.access(filePath)
542+
.then(() => true)
543+
.catch(() => false)
544+
})
545+
546+
const service = new klass(taskId, shadowDir, workspaceDir, () => {})
547+
548+
// This should NOT throw an error because the workspace is not a git repository,
549+
// so the cloned repository is not considered "nested"
550+
await expect(service.initShadowGit()).resolves.not.toThrow()
551+
expect(service.isInitialized).toBe(true)
552+
553+
// Clean up
554+
vitest.restoreAllMocks()
555+
await fs.rm(shadowDir, { recursive: true, force: true })
556+
await fs.rm(workspaceDir, { recursive: true, force: true })
557+
})
486558
})
487559

488560
describe(`${klass.name}#events`, () => {

0 commit comments

Comments
 (0)