Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/services/checkpoints/ShadowCheckpointService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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`)
Expand All @@ -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])
Expand Down
34 changes: 34 additions & 0 deletions src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading