Skip to content

Commit 90b019f

Browse files
committed
Shadow git checkpoints
1 parent 75f4a6c commit 90b019f

23 files changed

+1397
-634
lines changed

package-lock.json

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@
314314
"diff-match-patch": "^1.0.5",
315315
"fast-deep-equal": "^3.1.3",
316316
"fastest-levenshtein": "^1.0.16",
317+
"get-folder-size": "^5.0.0",
317318
"globby": "^14.0.2",
318319
"isbinaryfile": "^5.0.2",
319320
"mammoth": "^1.8.0",
@@ -322,6 +323,7 @@
322323
"os-name": "^6.0.0",
323324
"p-wait-for": "^5.0.2",
324325
"pdf-parse": "^1.1.1",
326+
"pretty-bytes": "^6.1.1",
325327
"puppeteer-chromium-resolver": "^23.0.0",
326328
"puppeteer-core": "^23.4.0",
327329
"serialize-error": "^11.0.3",
@@ -352,17 +354,17 @@
352354
"@vscode/test-cli": "^0.0.9",
353355
"@vscode/test-electron": "^2.4.0",
354356
"esbuild": "^0.24.0",
355-
"mkdirp": "^3.0.1",
356-
"rimraf": "^6.0.1",
357357
"eslint": "^8.57.0",
358358
"glob": "^11.0.1",
359359
"husky": "^9.1.7",
360360
"jest": "^29.7.0",
361361
"jest-simple-dot-reporter": "^1.0.5",
362362
"lint-staged": "^15.2.11",
363+
"mkdirp": "^3.0.1",
363364
"mocha": "^11.1.0",
364365
"npm-run-all": "^4.1.5",
365366
"prettier": "^3.4.2",
367+
"rimraf": "^6.0.1",
366368
"ts-jest": "^29.2.5",
367369
"typescript": "^5.4.5"
368370
},

src/__mocks__/get-folder-size.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = async function getFolderSize() {
2+
return {
3+
size: 1000,
4+
errors: [],
5+
}
6+
}

src/core/Cline.ts

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import delay from "delay"
66
import fs from "fs/promises"
77
import os from "os"
88
import pWaitFor from "p-wait-for"
9+
import getFolderSize from "get-folder-size"
910
import * as path from "path"
1011
import { serializeError } from "serialize-error"
1112
import * as vscode from "vscode"
1213
import { ApiHandler, SingleCompletionHandler, buildApiHandler } from "../api"
1314
import { ApiStream } from "../api/transform/stream"
1415
import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider"
15-
import { CheckpointService } from "../services/checkpoints/CheckpointService"
16+
import { CheckpointService, CheckpointServiceFactory } from "../services/checkpoints"
1617
import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
1718
import {
1819
extractTextFromFile,
@@ -239,7 +240,8 @@ export class Cline {
239240

240241
private async saveClineMessages() {
241242
try {
242-
const filePath = path.join(await this.ensureTaskDirectoryExists(), GlobalFileNames.uiMessages)
243+
const taskDir = await this.ensureTaskDirectoryExists()
244+
const filePath = path.join(taskDir, GlobalFileNames.uiMessages)
243245
await fs.writeFile(filePath, JSON.stringify(this.clineMessages))
244246
// combined as they are in ChatView
245247
const apiMetrics = getApiMetrics(combineApiRequests(combineCommandSequences(this.clineMessages.slice(1))))
@@ -251,6 +253,17 @@ export class Cline {
251253
(m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"),
252254
)
253255
]
256+
257+
let taskDirSize = 0
258+
259+
try {
260+
taskDirSize = await getFolderSize.loose(taskDir)
261+
} catch (err) {
262+
console.error(
263+
`[saveClineMessages] failed to get task directory size (${taskDir}): ${err instanceof Error ? err.message : String(err)}`,
264+
)
265+
}
266+
254267
await this.providerRef.deref()?.updateTaskHistory({
255268
id: this.taskId,
256269
ts: lastRelevantMessage.ts,
@@ -260,6 +273,7 @@ export class Cline {
260273
cacheWrites: apiMetrics.totalCacheWrites,
261274
cacheReads: apiMetrics.totalCacheReads,
262275
totalCost: apiMetrics.totalCost,
276+
size: taskDirSize,
263277
})
264278
} catch (error) {
265279
console.error("Failed to save cline messages:", error)
@@ -2692,7 +2706,7 @@ export class Cline {
26922706
}
26932707

26942708
if (isCheckpointPossible) {
2695-
await this.checkpointSave()
2709+
await this.checkpointSave({ isFirst: false })
26962710
}
26972711

26982712
/*
@@ -2762,7 +2776,7 @@ export class Cline {
27622776
const isFirstRequest = this.clineMessages.filter((m) => m.say === "api_req_started").length === 0
27632777

27642778
if (isFirstRequest) {
2765-
await this.checkpointSave()
2779+
await this.checkpointSave({ isFirst: true })
27662780
}
27672781

27682782
// getting verbose details is an expensive operation, it uses globby to top-down build file structure of project which for large projects can take a few seconds
@@ -3255,11 +3269,32 @@ export class Cline {
32553269
// Checkpoints
32563270

32573271
private async getCheckpointService() {
3272+
if (!this.checkpointsEnabled) {
3273+
throw new Error("Checkpoints are disabled")
3274+
}
3275+
32583276
if (!this.checkpointService) {
3259-
this.checkpointService = await CheckpointService.create({
3260-
taskId: this.taskId,
3261-
baseDir: vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? "",
3262-
log: (message) => this.providerRef.deref()?.log(message),
3277+
const workspaceDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
3278+
const shadowDir = this.providerRef.deref()?.context.globalStorageUri.fsPath
3279+
3280+
if (!workspaceDir) {
3281+
this.providerRef.deref()?.log("[getCheckpointService] workspace folder not found")
3282+
throw new Error("Workspace directory not found")
3283+
}
3284+
3285+
if (!shadowDir) {
3286+
this.providerRef.deref()?.log("[getCheckpointService] shadowDir not found")
3287+
throw new Error("Global storage directory not found")
3288+
}
3289+
3290+
this.checkpointService = await CheckpointServiceFactory.create({
3291+
strategy: "shadow",
3292+
options: {
3293+
taskId: this.taskId,
3294+
workspaceDir,
3295+
shadowDir,
3296+
log: (message) => this.providerRef.deref()?.log(message),
3297+
},
32633298
})
32643299
}
32653300

@@ -3318,29 +3353,25 @@ export class Cline {
33183353
}
33193354
}
33203355

3321-
public async checkpointSave() {
3356+
public async checkpointSave({ isFirst }: { isFirst: boolean }) {
33223357
if (!this.checkpointsEnabled) {
33233358
return
33243359
}
33253360

33263361
try {
3327-
const isFirst = !this.checkpointService
33283362
const service = await this.getCheckpointService()
3329-
const commit = await service.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`)
3363+
const strategy = service.strategy
3364+
const version = service.version
33303365

3331-
if (commit?.commit) {
3332-
await this.providerRef
3333-
.deref()
3334-
?.postMessageToWebview({ type: "currentCheckpointUpdated", text: service.currentCheckpoint })
3366+
const commit = await service.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`)
3367+
const fromHash = service.baseHash
3368+
const toHash = isFirst ? commit?.commit || fromHash : commit?.commit
33353369

3336-
// Checkpoint metadata required by the UI.
3337-
const checkpoint = {
3338-
isFirst,
3339-
from: service.baseCommitHash,
3340-
to: commit.commit,
3341-
}
3370+
if (toHash) {
3371+
await this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: toHash })
33423372

3343-
await this.say("checkpoint_saved", commit.commit, undefined, undefined, checkpoint)
3373+
const checkpoint = { isFirst, from: fromHash, to: toHash, strategy, version }
3374+
await this.say("checkpoint_saved", toHash, undefined, undefined, checkpoint)
33443375
}
33453376
} catch (err) {
33463377
this.providerRef.deref()?.log("[checkpointSave] disabling checkpoints for this task")
@@ -3371,9 +3402,7 @@ export class Cline {
33713402
const service = await this.getCheckpointService()
33723403
await service.restoreCheckpoint(commitHash)
33733404

3374-
await this.providerRef
3375-
.deref()
3376-
?.postMessageToWebview({ type: "currentCheckpointUpdated", text: service.currentCheckpoint })
3405+
await this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: commitHash })
33773406

33783407
if (mode === "restore") {
33793408
await this.overwriteApiConversationHistory(

src/core/webview/ClineProvider.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2277,35 +2277,55 @@ export class ClineProvider implements vscode.WebviewViewProvider {
22772277

22782278
await this.deleteTaskFromState(id)
22792279

2280-
// Delete the task files
2280+
// Delete the task files.
22812281
const apiConversationHistoryFileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
2282+
22822283
if (apiConversationHistoryFileExists) {
22832284
await fs.unlink(apiConversationHistoryFilePath)
22842285
}
2286+
22852287
const uiMessagesFileExists = await fileExistsAtPath(uiMessagesFilePath)
2288+
22862289
if (uiMessagesFileExists) {
22872290
await fs.unlink(uiMessagesFilePath)
22882291
}
2292+
22892293
const legacyMessagesFilePath = path.join(taskDirPath, "claude_messages.json")
2294+
22902295
if (await fileExistsAtPath(legacyMessagesFilePath)) {
22912296
await fs.unlink(legacyMessagesFilePath)
22922297
}
2293-
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
22942298

22952299
const { checkpointsEnabled } = await this.getState()
22962300
const baseDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
2297-
const branch = `roo-code-checkpoints-${id}`
22982301

2302+
// Delete checkpoints branch.
22992303
if (checkpointsEnabled && baseDir) {
2304+
const branchSummary = await simpleGit(baseDir)
2305+
.branch(["-D", `roo-code-checkpoints-${id}`])
2306+
.catch(() => undefined)
2307+
2308+
if (branchSummary) {
2309+
console.log(`[deleteTaskWithId${id}] deleted checkpoints branch`)
2310+
}
2311+
}
2312+
2313+
// Delete checkpoints directory
2314+
const checkpointsDir = path.join(taskDirPath, "checkpoints")
2315+
2316+
if (await fileExistsAtPath(checkpointsDir)) {
23002317
try {
2301-
await simpleGit(baseDir).branch(["-D", branch])
2302-
console.log(`[deleteTaskWithId] Deleted branch ${branch}`)
2303-
} catch (err) {
2318+
await fs.rm(checkpointsDir, { recursive: true, force: true })
2319+
console.log(`[deleteTaskWithId${id}] removed checkpoints repo`)
2320+
} catch (error) {
23042321
console.error(
2305-
`[deleteTaskWithId] Error deleting branch ${branch}: ${err instanceof Error ? err.message : String(err)}`,
2322+
`[deleteTaskWithId${id}] failed to remove checkpoints repo: ${error instanceof Error ? error.message : String(error)}`,
23062323
)
23072324
}
23082325
}
2326+
2327+
// Succeeds if the dir is empty.
2328+
await fs.rmdir(taskDirPath)
23092329
}
23102330

23112331
async deleteTaskFromState(id: string) {
@@ -2373,6 +2393,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
23732393
alwaysAllowMcp: alwaysAllowMcp ?? false,
23742394
alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false,
23752395
uriScheme: vscode.env.uriScheme,
2396+
currentTaskItem: this.cline?.taskId
2397+
? (taskHistory || []).find((item) => item.id === this.cline?.taskId)
2398+
: undefined,
23762399
clineMessages: this.cline?.clineMessages || [],
23772400
taskHistory: (taskHistory || [])
23782401
.filter((item: HistoryItem) => item.ts && item.task)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { LocalCheckpointService, LocalCheckpointServiceOptions } from "./LocalCheckpointService"
2+
import { ShadowCheckpointService, ShadowCheckpointServiceOptions } from "./ShadowCheckpointService"
3+
4+
export type CreateCheckpointServiceFactoryOptions =
5+
| {
6+
strategy: "local"
7+
options: LocalCheckpointServiceOptions
8+
}
9+
| {
10+
strategy: "shadow"
11+
options: ShadowCheckpointServiceOptions
12+
}
13+
14+
type CheckpointServiceType<T extends CreateCheckpointServiceFactoryOptions> = T extends { strategy: "local" }
15+
? LocalCheckpointService
16+
: T extends { strategy: "shadow" }
17+
? ShadowCheckpointService
18+
: never
19+
20+
export class CheckpointServiceFactory {
21+
public static create<T extends CreateCheckpointServiceFactoryOptions>(options: T): CheckpointServiceType<T> {
22+
switch (options.strategy) {
23+
case "local":
24+
return LocalCheckpointService.create(options.options) as any
25+
case "shadow":
26+
return ShadowCheckpointService.create(options.options) as any
27+
}
28+
}
29+
}

0 commit comments

Comments
 (0)