fix(checkpoints): canonicalize core.worktree comparison to prevent Windows path mismatch failures (#11346)

This commit is contained in:
0xMink 2026-02-10 09:51:38 -05:00 committed by GitHub
parent b02924530c
commit c1a8767adc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 87 additions and 2 deletions

View file

@ -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}`,
)
}

View file

@ -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 })
}
})
})