diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index fee08b2fa4b..bd44afb3581 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -9,6 +9,7 @@ import pWaitFor from "p-wait-for" import * as vscode from "vscode" import { fileExistsAtPath } from "../../utils/fs" +import { arePathsEqual } from "../../utils/path" import { executeRipgrep } from "../../services/search/file-search" import { t } from "../../i18n" @@ -155,9 +156,15 @@ export abstract class ShadowCheckpointService extends EventEmitter { this.log(`[${this.constructor.name}#initShadowGit] shadow git repo already exists at ${this.dotGitDir}`) const worktree = await this.getShadowGitConfigWorktree(git) - if (worktree !== this.workspaceDir) { + if (!worktree) { + throw new Error("Checkpoints require core.worktree to be set in the shadow git config") + } + + const worktreeTrimmed = worktree.trim() + + if (!arePathsEqual(worktreeTrimmed, this.workspaceDir)) { throw new Error( - `Checkpoints can only be used in the original workspace: ${worktree} !== ${this.workspaceDir}`, + `Checkpoints can only be used in the original workspace: ${worktreeTrimmed} !== ${this.workspaceDir}`, ) } diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index ee8f7bbdc9c..f2d9d12dd07 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -915,3 +915,81 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( }) }, ) + +describe("worktree path comparison", () => { + it("accepts core.worktree with trailing newline from git output", async () => { + const shadowDir = path.join(tmpDir, `worktree-trim-${Date.now()}`) + const workspaceDir = path.join(tmpDir, `workspace-trim-${Date.now()}`) + + try { + 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([]) + }) + + // First init to create the shadow repo + const service1 = new RepoPerTaskCheckpointService("trim-test", shadowDir, workspaceDir, () => {}) + await service1.initShadowGit() + + // Second init with stubbed worktree returning a trailing newline + const service2 = new RepoPerTaskCheckpointService("trim-test-2", shadowDir, workspaceDir, () => {}) + vitest + .spyOn(service2 as any, "getShadowGitConfigWorktree") + .mockResolvedValue(workspaceDir + "\n") + + await service2.initShadowGit() + } finally { + vitest.restoreAllMocks() + await fs.rm(shadowDir, { recursive: true, force: true }) + await fs.rm(workspaceDir, { recursive: true, force: true }) + } + }) + + it("throws when core.worktree is missing", async () => { + const shadowDir = path.join(tmpDir, `worktree-missing-${Date.now()}`) + const workspaceDir = path.join(tmpDir, `workspace-missing-${Date.now()}`) + + try { + 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([]) + }) + + // First init to create the shadow repo + const service1 = new RepoPerTaskCheckpointService("missing-test", shadowDir, workspaceDir, () => {}) + await service1.initShadowGit() + + // Remove core.worktree from the shadow git config + const shadowGit = simpleGit(shadowDir) + await shadowGit.raw(["config", "--unset", "core.worktree"]) + + // Second init should throw because core.worktree is missing + const service2 = new RepoPerTaskCheckpointService("missing-test-2", shadowDir, workspaceDir, () => {}) + await expect(service2.initShadowGit()).rejects.toThrowError( + /core\.worktree to be set/, + ) + } finally { + vitest.restoreAllMocks() + await fs.rm(shadowDir, { recursive: true, force: true }) + await fs.rm(workspaceDir, { recursive: true, force: true }) + } + }) +})