Skip to content

Commit 30a4a0e

Browse files
canvrnotrevhud
authored andcommitted
Check git out - Checkpoints 2.0
**Branch-Per-Task:** Each repo now has a single Shadow Git repo, with separate branches per task (instead of one Shadow Git repo per task). - **Legacy Support:** Existing Checkpoints remain functional, while all new Checkpoints use branch-per-task. - **Commits:** Legacy tasks commit to legacy Checkpoints; new tasks commit using branch-per-task. - **Diffing & Deletions:** Both legacy and branch-per-task Checkpoints support diffing and deletion. No migration needed—existing tasks stay as-is, and new tasks adopt **branch-per-task** automatically.
1 parent e5d616b commit 30a4a0e

15 files changed

+2014
-316
lines changed

.changeset/silly-coats-switch.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"claude-dev": minor
3+
---
4+
5+
- **Branch-Per-Task:** Each repo now has a single Shadow Git repo, with separate branches per task (instead of one Shadow Git repo per task).
6+
- **Legacy Support:** Existing Checkpoints remain functional, while all new Checkpoints use branch-per-task.
7+
8+
- **Commits:** Legacy tasks commit to legacy Checkpoints; new tasks commit using branch-per-task.
9+
- **Diffing & Deletions:** Both legacy and branch-per-task Checkpoints support diffing and deletion.
10+
11+
No migration needed—existing tasks stay as-is, and new tasks adopt **branch-per-task** automatically.

src/core/Cline.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,10 @@ export class Cline {
301301
case "workspace":
302302
if (!this.checkpointTracker) {
303303
try {
304-
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
304+
this.checkpointTracker = await CheckpointTracker.create(
305+
this.taskId,
306+
this.providerRef.deref()?.context.globalStorageUri.fsPath,
307+
)
305308
this.checkpointTrackerErrorMessage = undefined
306309
} catch (error) {
307310
const errorMessage = error instanceof Error ? error.message : "Unknown error"
@@ -414,7 +417,10 @@ export class Cline {
414417
// TODO: handle if this is called from outside original workspace, in which case we need to show user error message we cant show diff outside of workspace?
415418
if (!this.checkpointTracker) {
416419
try {
417-
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
420+
this.checkpointTracker = await CheckpointTracker.create(
421+
this.taskId,
422+
this.providerRef.deref()?.context.globalStorageUri.fsPath,
423+
)
418424
this.checkpointTrackerErrorMessage = undefined
419425
} catch (error) {
420426
const errorMessage = error instanceof Error ? error.message : "Unknown error"
@@ -518,7 +524,10 @@ export class Cline {
518524

519525
if (!this.checkpointTracker) {
520526
try {
521-
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
527+
this.checkpointTracker = await CheckpointTracker.create(
528+
this.taskId,
529+
this.providerRef.deref()?.context.globalStorageUri.fsPath,
530+
)
522531
this.checkpointTrackerErrorMessage = undefined
523532
} catch (error) {
524533
const errorMessage = error instanceof Error ? error.message : "Unknown error"
@@ -2940,7 +2949,7 @@ export class Cline {
29402949
}
29412950

29422951
/*
2943-
Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present.
2952+
Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present.
29442953
When you see the UI inactive during this, it means that a tool is breaking without presenting any UI. For example the write_to_file tool was breaking when relpath was undefined, and for invalid relpath it never presented UI.
29452954
*/
29462955
this.presentAssistantMessageLocked = false // this needs to be placed here, if not then calling this.presentAssistantMessage below would fail (sometimes) since it's locked
@@ -3047,7 +3056,10 @@ export class Cline {
30473056
// isNewTask &&
30483057
if (!this.checkpointTracker) {
30493058
try {
3050-
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
3059+
this.checkpointTracker = await CheckpointTracker.create(
3060+
this.taskId,
3061+
this.providerRef.deref()?.context.globalStorageUri.fsPath,
3062+
)
30513063
this.checkpointTrackerErrorMessage = undefined
30523064
} catch (error) {
30533065
const errorMessage = error instanceof Error ? error.message : "Unknown error"

src/core/webview/ClineProvider.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import pWaitFor from "p-wait-for"
88
import * as path from "path"
99
import * as vscode from "vscode"
1010
import { buildApiHandler } from "../../api"
11+
import CheckpointTracker from "../../integrations/checkpoints/CheckpointTracker"
1112
import { downloadTask } from "../../integrations/misc/export-markdown"
1213
import { openFile, openImage } from "../../integrations/misc/open-file"
1314
import { fetchOpenGraphData, isImageUrl } from "../../integrations/misc/link-preview"
@@ -1661,12 +1662,29 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
16611662
}
16621663

16631664
async deleteTaskWithId(id: string) {
1665+
console.info("deleteTaskWithId: ", id)
1666+
16641667
if (id === this.cline?.taskId) {
16651668
await this.clearTask()
1669+
console.debug("cleared task")
16661670
}
16671671

16681672
const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath } = await this.getTaskWithId(id)
16691673

1674+
// Delete checkpoints
1675+
// deleteCheckpoints will determine if the task has legacy checkpoints or not and handle it accordingly
1676+
console.info("deleting checkpoints")
1677+
const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
1678+
const historyItem = taskHistory.find((item) => item.id === id)
1679+
//console.log("historyItem: ", historyItem)
1680+
if (historyItem) {
1681+
try {
1682+
await CheckpointTracker.deleteCheckpoints(id, historyItem, this.context.globalStorageUri.fsPath)
1683+
} catch (error) {
1684+
console.error(`Failed to delete checkpoints for task ${id}:`, error)
1685+
}
1686+
}
1687+
16701688
await this.deleteTaskFromState(id)
16711689

16721690
// Delete the task files
@@ -1683,21 +1701,12 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
16831701
await fs.unlink(legacyMessagesFilePath)
16841702
}
16851703

1686-
// Delete the checkpoints directory if it exists
1687-
const checkpointsDir = path.join(taskDirPath, "checkpoints")
1688-
if (await fileExistsAtPath(checkpointsDir)) {
1689-
try {
1690-
await fs.rm(checkpointsDir, { recursive: true, force: true })
1691-
} catch (error) {
1692-
console.error(`Failed to delete checkpoints directory for task ${id}:`, error)
1693-
// Continue with deletion of task directory - don't throw since this is a cleanup operation
1694-
}
1695-
}
1696-
16971704
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
16981705
}
16991706

17001707
async deleteTaskFromState(id: string) {
1708+
console.log("deleteTaskFromState: ", id)
1709+
17011710
// Remove the task from history
17021711
const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
17031712
const updatedTaskHistory = taskHistory.filter((task) => task.id !== id)
@@ -1768,7 +1777,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
17681777
17691778
/*
17701779
It seems that some API messages do not comply with vscode state requirements. Either the Anthropic library is manipulating these values somehow in the backend in a way thats creating cyclic references, or the API returns a function or a Symbol as part of the message content.
1771-
VSCode docs about state: "The value must be JSON-stringifyable ... value A value. MUST not contain cyclic references."
1780+
VSCode docs about state: "The value must be JSON-stringifyable ... value A value. MUST not contain cyclic references."
17721781
For now we'll store the conversation history in memory, and if we need to store in state directly we'd need to do a manual conversion to ensure proper json stringification.
17731782
*/
17741783

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import * as vscode from "vscode"
2+
import fs from "fs/promises"
3+
import path from "path"
4+
import os from "os"
5+
import CheckpointTracker from "./CheckpointTracker"
6+
7+
export async function createTestEnvironment() {
8+
// Create temp directory structure
9+
const tempDir = path.join(os.tmpdir(), `checkpoint-test-${Date.now()}`)
10+
await fs.mkdir(tempDir, { recursive: true })
11+
12+
// Create storage path outside of working directory to avoid submodule issues
13+
const globalStoragePath = path.join(os.tmpdir(), `storage-${Date.now()}`)
14+
await fs.mkdir(globalStoragePath, { recursive: true })
15+
16+
// Create test file in a subdirectory
17+
const testDir = path.join(tempDir, "src")
18+
await fs.mkdir(testDir, { recursive: true })
19+
const testFilePath = path.join(testDir, "test.txt")
20+
21+
// Create .gitignore to prevent git from treating directories as submodules
22+
await fs.writeFile(path.join(tempDir, ".gitignore"), "storage/\n")
23+
24+
// Mock VS Code workspace
25+
const mockWorkspaceFolders = [
26+
{
27+
uri: { fsPath: tempDir },
28+
name: "test",
29+
index: 0,
30+
},
31+
]
32+
33+
const originalDescriptor = Object.getOwnPropertyDescriptor(vscode.workspace, "workspaceFolders")
34+
Object.defineProperty(vscode.workspace, "workspaceFolders", {
35+
get: () => mockWorkspaceFolders,
36+
})
37+
38+
// Mock findFiles to return no nested git repos
39+
const originalFindFiles = vscode.workspace.findFiles
40+
vscode.workspace.findFiles = async () => []
41+
42+
// Mock VS Code configuration
43+
const originalGetConfiguration = vscode.workspace.getConfiguration
44+
vscode.workspace.getConfiguration = () =>
45+
({
46+
get: (key: string) => (key === "enableCheckpoints" ? true : undefined),
47+
}) as any
48+
49+
return {
50+
tempDir,
51+
globalStoragePath,
52+
testFilePath,
53+
originalDescriptor,
54+
originalFindFiles,
55+
originalGetConfiguration,
56+
cleanup: async () => {
57+
// Restore VS Code mocks
58+
if (originalDescriptor) {
59+
Object.defineProperty(vscode.workspace, "workspaceFolders", originalDescriptor)
60+
}
61+
vscode.workspace.getConfiguration = originalGetConfiguration
62+
vscode.workspace.findFiles = originalFindFiles
63+
64+
// Clean up temp directories
65+
await fs.rm(tempDir, { recursive: true, force: true })
66+
await fs.rm(globalStoragePath, { recursive: true, force: true })
67+
}
68+
}
69+
}
70+
71+
export async function createTestTracker(globalStoragePath?: string, taskId = "test-task-1") {
72+
return await CheckpointTracker.create(taskId, globalStoragePath)
73+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { expect } from "chai"
2+
import { describe, it, beforeEach, afterEach } from "mocha"
3+
import fs from "fs/promises"
4+
import path from "path"
5+
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
6+
7+
describe("Checkpoint Commit Operations", () => {
8+
let env: Awaited<ReturnType<typeof createTestEnvironment>>
9+
10+
beforeEach(async () => {
11+
env = await createTestEnvironment()
12+
})
13+
14+
afterEach(async () => {
15+
await env.cleanup()
16+
})
17+
18+
it("should create commit with single file changes", async () => {
19+
const tracker = await createTestTracker(env.globalStoragePath)
20+
if (!tracker) {throw new Error("Failed to create tracker")}
21+
22+
// Create initial file
23+
await fs.writeFile(env.testFilePath, "initial content")
24+
25+
// Create first commit
26+
const firstCommit = await tracker.commit()
27+
expect(firstCommit).to.be.a("string").and.not.empty
28+
29+
// Modify file
30+
await fs.writeFile(env.testFilePath, "modified content")
31+
32+
// Create second commit
33+
const secondCommit = await tracker.commit()
34+
expect(secondCommit).to.be.a("string").and.not.empty
35+
expect(secondCommit).to.not.equal(firstCommit)
36+
37+
// Verify commits are different
38+
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
39+
expect(diffSet).to.have.lengthOf(1)
40+
expect(diffSet[0].before).to.equal("initial content")
41+
expect(diffSet[0].after).to.equal("modified content")
42+
})
43+
44+
it("should create commit with multiple file changes", async () => {
45+
const tracker = await createTestTracker(env.globalStoragePath)
46+
if (!tracker) {throw new Error("Failed to create tracker")}
47+
48+
// Create initial files with newlines
49+
const testFile2Path = path.join(env.tempDir, "src", "test2.txt")
50+
await fs.writeFile(env.testFilePath, "file1 initial\n")
51+
await fs.writeFile(testFile2Path, "file2 initial\n")
52+
53+
// Create first commit
54+
const firstCommit = await tracker.commit()
55+
expect(firstCommit).to.be.a("string").and.not.empty
56+
57+
// Modify both files with newlines
58+
await fs.writeFile(env.testFilePath, "file1 modified\n")
59+
await fs.writeFile(testFile2Path, "file2 modified\n")
60+
61+
// Create second commit
62+
const secondCommit = await tracker.commit()
63+
expect(secondCommit).to.be.a("string").and.not.empty
64+
expect(secondCommit).to.not.equal(firstCommit)
65+
66+
// Get diff between commits
67+
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
68+
expect(diffSet).to.have.lengthOf(2)
69+
70+
// Sort diffSet by path for consistent ordering
71+
const sortedDiffs = diffSet.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
72+
73+
// Verify file paths
74+
expect(sortedDiffs[0].relativePath).to.equal("src/test.txt")
75+
expect(sortedDiffs[1].relativePath).to.equal("src/test2.txt")
76+
77+
// Verify file contents
78+
expect(sortedDiffs[0].before).to.equal("file1 initial\nfile2 initial\n")
79+
expect(sortedDiffs[0].after).to.equal("file1 modified\nfile2 modified\n")
80+
})
81+
82+
it("should create commit when files are deleted", async () => {
83+
const tracker = await createTestTracker(env.globalStoragePath)
84+
if (!tracker) {throw new Error("Failed to create tracker")}
85+
86+
// Create and commit initial file
87+
await fs.writeFile(env.testFilePath, "initial content")
88+
const firstCommit = await tracker.commit()
89+
expect(firstCommit).to.be.a("string").and.not.empty
90+
91+
// Delete file
92+
await fs.unlink(env.testFilePath)
93+
94+
// Create second commit
95+
const secondCommit = await tracker.commit()
96+
expect(secondCommit).to.be.a("string").and.not.empty
97+
expect(secondCommit).to.not.equal(firstCommit)
98+
99+
// Verify file deletion was committed
100+
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
101+
expect(diffSet).to.have.lengthOf(1)
102+
expect(diffSet[0].before).to.equal("initial content")
103+
expect(diffSet[0].after).to.equal("")
104+
})
105+
106+
it("should create empty commit when no changes", async () => {
107+
const tracker = await createTestTracker(env.globalStoragePath)
108+
if (!tracker) {throw new Error("Failed to create tracker")}
109+
110+
// Create and commit initial file
111+
await fs.writeFile(env.testFilePath, "test content")
112+
const firstCommit = await tracker.commit()
113+
expect(firstCommit).to.be.a("string").and.not.empty
114+
115+
// Create commit without changes
116+
const secondCommit = await tracker.commit()
117+
expect(secondCommit).to.be.a("string").and.not.empty
118+
expect(secondCommit).to.not.equal(firstCommit)
119+
120+
// Verify no changes between commits
121+
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
122+
expect(diffSet).to.have.lengthOf(0)
123+
})
124+
125+
it("should handle files in nested directories", async () => {
126+
const tracker = await createTestTracker(env.globalStoragePath)
127+
if (!tracker) {throw new Error("Failed to create tracker")}
128+
129+
// Create nested directory structure
130+
const nestedDir = path.join(env.tempDir, "src", "deep", "nested")
131+
await fs.mkdir(nestedDir, { recursive: true })
132+
const nestedFilePath = path.join(nestedDir, "nested.txt")
133+
134+
// Create and commit file in nested directory
135+
await fs.writeFile(nestedFilePath, "nested content")
136+
const firstCommit = await tracker.commit()
137+
expect(firstCommit).to.be.a("string").and.not.empty
138+
139+
// Modify nested file
140+
await fs.writeFile(nestedFilePath, "modified nested content")
141+
142+
// Create second commit
143+
const secondCommit = await tracker.commit()
144+
expect(secondCommit).to.be.a("string").and.not.empty
145+
146+
// Verify changes were committed
147+
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
148+
expect(diffSet).to.have.lengthOf(1)
149+
expect(diffSet[0].relativePath).to.equal("src/deep/nested/nested.txt")
150+
expect(diffSet[0].before).to.equal("nested content")
151+
expect(diffSet[0].after).to.equal("modified nested content")
152+
})
153+
})

0 commit comments

Comments
 (0)