Skip to content

Commit af18969

Browse files
committed
fix(checkpoints): restore exactly the requested path when names contain glob characters
The ls-tree existence lookup matches literally, but the checkout pathspec did not: a filename like [a].ts is a character class as a plain pathspec, so checkout restored a.ts instead of (and in addition to) the requested file, breaking the single-file restore contract. The checkout pathspec now uses the same :(literal) magic as the lookups. Regression test checkpoints [a].ts alongside a.ts, modifies both, and pins that restoring [a].ts leaves a.ts untouched (negative control verified: the plain pathspec reddens it). Review feedback on PR 1410; owned by PR 1410's service and byte-copied to the sibling unit carrying the identical file.
1 parent 4dcb8d9 commit af18969

2 files changed

Lines changed: 27 additions & 1 deletion

File tree

‎src/services/checkpoints/ShadowCheckpointService.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,11 @@ export abstract class ShadowCheckpointService extends EventEmitter {
510510
if (mode !== "100644" && mode !== "100755") {
511511
throw new Error(`restoreFile target is not a regular file in the checkpoint: ${filePath}`)
512512
}
513-
await this.git.checkout([commitHash, "--", gitPath])
513+
// The checkout pathspec must be literal too: a filename containing
514+
// glob characters ([a].ts, foo?bar.ts) would otherwise match - or
515+
// miss - other files, restoring more (or the wrong file) than the
516+
// single requested path.
517+
await this.git.checkout([commitHash, "--", `:(literal)${gitPath}`])
514518
} else {
515519
await fs.rm(path.join(this.workspaceDir, filePath), { force: true })
516520
}

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,28 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
280280
expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!")
281281
})
282282

283+
it("restores exactly the requested file when its name contains glob characters", async () => {
284+
// A plain pathspec treats [a].ts as a character class matching
285+
// a.ts, so checkout would restore the wrong file and leave the
286+
// requested one modified. The restore contract is exactly one
287+
// path: the checkout pathspec must be literal.
288+
const workspaceDir = path.dirname(testFile)
289+
const globFile = path.join(workspaceDir, "[a].ts")
290+
const plainFile = path.join(workspaceDir, "a.ts")
291+
await fs.writeFile(globFile, "glob content")
292+
await fs.writeFile(plainFile, "plain content")
293+
const commit = await service.saveCheckpoint("Glob-name checkpoint")
294+
expect(commit?.commit).toBeTruthy()
295+
296+
await fs.writeFile(globFile, "glob modified")
297+
await fs.writeFile(plainFile, "plain modified")
298+
299+
await service.restoreFile(commit!.commit, "[a].ts")
300+
301+
expect(await fs.readFile(globFile, "utf-8")).toBe("glob content")
302+
expect(await fs.readFile(plainFile, "utf-8")).toBe("plain modified")
303+
})
304+
283305
it("rejects a target that escapes the workspace through a symlinked ancestor", async () => {
284306
// A lexical prefix check passes for a path that goes through a
285307
// symlink inside the workspace pointing outside it; the resolved

0 commit comments

Comments
 (0)