diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index 89ae52c435e..1f7d9345fc9 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -341,6 +341,17 @@ export abstract class ShadowCheckpointService extends EventEmitter { } } + // MUST run before any workspace-mutating git command: `git clean` deletes untracked + // user files, so a missing commit discovered later would mean unrecoverable data loss. + private async commitExists(commitHash: string): Promise { + try { + await this.git!.raw(["cat-file", "-e", `${commitHash}^{commit}`]) + return true + } catch { + return false + } + } + public async restoreCheckpoint(commitHash: string) { try { this.log(`[${this.constructor.name}#restoreCheckpoint] starting checkpoint restore`) @@ -349,6 +360,12 @@ export abstract class ShadowCheckpointService extends EventEmitter { throw new Error("Shadow git repo not initialized") } + if (!(await this.commitExists(commitHash))) { + throw new Error( + `Cannot restore checkpoint ${commitHash}: this checkpoint no longer exists in the checkpoint repository. No files were changed.`, + ) + } + const start = Date.now() await this.git.clean("f", ["-d", "-f"]) await this.git.reset(["--hard", commitHash]) diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index 5bc43d54ce9..9c5090aa5d4 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -294,6 +294,40 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow() }) + it("does not modify the workspace when restoring a missing commit", async () => { + await fs.writeFile(testFile, "Committed content") + const commit = await service.saveCheckpoint("Checkpoint before loss") + expect(commit?.commit).toBeTruthy() + + // Uncommitted user work that `git clean` would have destroyed. + const untrackedFile = path.join(service.workspaceDir, "precious-untracked.txt") + await fs.writeFile(untrackedFile, "Unsaved user work") + await fs.writeFile(testFile, "Uncommitted edit") + + const missingCommitHash = "0".repeat(40) + await expect(service.restoreCheckpoint(missingCommitHash)).rejects.toThrow( + /checkpoint no longer exists/i, + ) + + expect(await fs.readFile(untrackedFile, "utf-8")).toBe("Unsaved user work") + expect(await fs.readFile(testFile, "utf-8")).toBe("Uncommitted edit") + }) + + it("still restores normally to a valid commit", async () => { + await fs.writeFile(testFile, "Checkpointed content") + const commit = await service.saveCheckpoint("Valid checkpoint") + expect(commit?.commit).toBeTruthy() + + await fs.writeFile(testFile, "Later edit") + const laterTrackedFile = path.join(service.workspaceDir, "created-after.txt") + await fs.writeFile(laterTrackedFile, "Created after checkpoint") + await service.saveCheckpoint("After checkpoint") + + await service.restoreCheckpoint(commit!.commit) + expect(await fs.readFile(testFile, "utf-8")).toBe("Checkpointed content") + await expect(fs.readFile(laterTrackedFile, "utf-8")).rejects.toThrow() + }) + it("does not create a checkpoint for ignored files", async () => { // Create a file that matches an ignored pattern (e.g., .log file). const ignoredFile = path.join(service.workspaceDir, "ignored.log")