From ae669364a9919125764b7733ce009bd1a4e36473 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 16 Dec 2025 12:58:05 +0000 Subject: [PATCH] fix: allow checkpoints when workspace root has no .git but subdirs have git repos This fixes issue #10125 where users could not enable checkpoints when opening a parent folder containing multiple sibling projects (e.g., frontend and backend), each with their own git repositories. The previous behavior blocked checkpoints whenever any nested .git directory was detected. This change modifies the detection logic to: 1. Check if the workspace root has its own .git directory first 2. If the root has NO .git, allow checkpoints since subdirectory git repos are independent projects (not truly nested) 3. Only block checkpoints when the root HAS a .git AND there are nested .git directories (true nesting scenario like git submodules) The subdirectory .git folders are already excluded from tracking via the existing .git/ pattern in the exclude patterns. Closes #10125 --- .../checkpoints/ShadowCheckpointService.ts | 18 ++++++ .../__tests__/ShadowCheckpointService.spec.ts | 59 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index fee08b2fa4..608bcec007 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -221,6 +221,24 @@ export abstract class ShadowCheckpointService extends EventEmitter { private async getNestedGitRepository(): Promise { try { + // First, check if the workspace root has its own .git directory. + // If the root does NOT have a .git, we allow checkpoints even if subdirectories + // have their own git repos. This supports the common use case where users open + // a parent folder containing multiple sibling projects (e.g., frontend and backend), + // each with their own git repository. + const rootGitPath = path.join(this.workspaceDir, ".git") + const rootHasGit = await fileExistsAtPath(rootGitPath) + + if (!rootHasGit) { + this.log( + `[${this.constructor.name}#getNestedGitRepository] workspace root has no .git, allowing checkpoints even with subdirectory git repos`, + ) + // No root .git means subdirectory git repos are independent projects, not nested repos. + // The shadow repo will exclude them via .git/ pattern in excludes. + return null + } + + // Root has a .git, so we need to check for truly nested git repos (submodules, etc.) // Find all .git/HEAD files that are not at the root level. const args = ["--files", "--hidden", "--follow", "-g", "**/.git/HEAD", this.workspaceDir] diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index 5172a37369..e8f803b00b 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -483,6 +483,65 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( await fs.rm(shadowDir, { recursive: true, force: true }) await fs.rm(workspaceDir, { recursive: true, force: true }) }) + + it("succeeds when workspace root has no .git but subdirectories have git repos (sibling projects)", async () => { + // Create a new temporary workspace and service for this test. + // This simulates a user opening a parent folder containing multiple sibling projects. + const shadowDir = path.join(tmpDir, `${prefix}-sibling-projects-${Date.now()}`) + const workspaceDir = path.join(tmpDir, `workspace-sibling-projects-${Date.now()}`) + + // Create the parent workspace WITHOUT initializing git (no root .git) + await fs.mkdir(workspaceDir, { recursive: true }) + + // Create a "frontend" project with its own git repo + const frontendDir = path.join(workspaceDir, "frontend") + await fs.mkdir(frontendDir, { recursive: true }) + const frontendGit = simpleGit(frontendDir) + await frontendGit.init() + await frontendGit.addConfig("user.name", "Roo Code") + await frontendGit.addConfig("user.email", "support@roocode.com") + const frontendFile = path.join(frontendDir, "index.js") + await fs.writeFile(frontendFile, "// Frontend code") + await frontendGit.add(".") + await frontendGit.commit("Initial frontend commit") + + // Create a "backend" project with its own git repo + const backendDir = path.join(workspaceDir, "backend") + await fs.mkdir(backendDir, { recursive: true }) + const backendGit = simpleGit(backendDir) + await backendGit.init() + await backendGit.addConfig("user.name", "Roo Code") + await backendGit.addConfig("user.email", "support@roocode.com") + const backendFile = path.join(backendDir, "server.js") + await fs.writeFile(backendFile, "// Backend code") + await backendGit.add(".") + await backendGit.commit("Initial backend commit") + + // Create a test file in the root (outside any git repo) + const rootFile = path.join(workspaceDir, "README.md") + await fs.writeFile(rootFile, "# Project Documentation") + + const service = new klass(taskId, shadowDir, workspaceDir, () => {}) + + // Initialization should succeed because root has no .git + // Even though subdirectories have their own git repos, they are independent projects + await expect(service.initShadowGit()).resolves.not.toThrow() + expect(service.isInitialized).toBe(true) + + // Verify checkpoints work correctly + await fs.writeFile(rootFile, "# Updated Documentation") + const checkpoint = await service.saveCheckpoint("Update readme") + expect(checkpoint?.commit).toBeTruthy() + + // Verify we can restore + await fs.writeFile(rootFile, "# Modified again") + await service.restoreCheckpoint(checkpoint!.commit) + expect(await fs.readFile(rootFile, "utf-8")).toBe("# Updated Documentation") + + // Clean up. + await fs.rm(shadowDir, { recursive: true, force: true }) + await fs.rm(workspaceDir, { recursive: true, force: true }) + }) }) describe(`${klass.name}#events`, () => {