Skip to content

Commit 910bdf1

Browse files
authored
Merge pull request RooCodeInc#626 from RooVetGit/cte/checkpoints
Checkpoint service
2 parents edaa04d + 2bff829 commit 910bdf1

File tree

2 files changed

+654
-0
lines changed

2 files changed

+654
-0
lines changed
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
import fs from "fs/promises"
2+
import { existsSync } from "fs"
3+
import path from "path"
4+
5+
import debug from "debug"
6+
import simpleGit, { SimpleGit, CleanOptions } from "simple-git"
7+
8+
if (process.env.NODE_ENV !== "test") {
9+
debug.enable("simple-git")
10+
}
11+
12+
export interface Checkpoint {
13+
hash: string
14+
message: string
15+
timestamp?: Date
16+
}
17+
18+
export type CheckpointServiceOptions = {
19+
taskId: string
20+
git?: SimpleGit
21+
baseDir: string
22+
log?: (message: string) => void
23+
}
24+
25+
/**
26+
* The CheckpointService provides a mechanism for storing a snapshot of the
27+
* current VSCode workspace each time a Roo Code tool is executed. It uses Git
28+
* under the hood.
29+
*
30+
* HOW IT WORKS
31+
*
32+
* Two branches are used:
33+
* - A main branch for normal operation (the branch you are currently on).
34+
* - A hidden branch for storing checkpoints.
35+
*
36+
* Saving a checkpoint:
37+
* - Current changes are stashed (including untracked files).
38+
* - The hidden branch is reset to match main.
39+
* - Stashed changes are applied and committed as a checkpoint on the hidden
40+
* branch.
41+
* - We return to the main branch with the original state restored.
42+
*
43+
* Restoring a checkpoint:
44+
* - The workspace is restored to the state of the specified checkpoint using
45+
* `git restore` and `git clean`.
46+
*
47+
* This approach allows for:
48+
* - Non-destructive version control (main branch remains untouched).
49+
* - Preservation of the full history of checkpoints.
50+
* - Safe restoration to any previous checkpoint.
51+
*
52+
* NOTES
53+
*
54+
* - Git must be installed.
55+
* - If the current working directory is not a Git repository, we will
56+
* initialize a new one with a .gitkeep file.
57+
* - If you manually edit files and then restore a checkpoint, the changes
58+
* will be lost. Addressing this adds some complexity to the implementation
59+
* and it's not clear whether it's worth it.
60+
*/
61+
62+
export class CheckpointService {
63+
constructor(
64+
public readonly taskId: string,
65+
private readonly git: SimpleGit,
66+
public readonly baseDir: string,
67+
public readonly mainBranch: string,
68+
public readonly baseCommitHash: string,
69+
public readonly hiddenBranch: string,
70+
private readonly log: (message: string) => void,
71+
) {}
72+
73+
private async pushStash() {
74+
const status = await this.git.status()
75+
76+
if (status.files.length > 0) {
77+
await this.git.stash(["-u"]) // Includes tracked and untracked files.
78+
return true
79+
}
80+
81+
return false
82+
}
83+
84+
private async applyStash() {
85+
const stashList = await this.git.stashList()
86+
87+
if (stashList.all.length > 0) {
88+
await this.git.stash(["apply"]) // Applies the most recent stash only.
89+
return true
90+
}
91+
92+
return false
93+
}
94+
95+
private async popStash() {
96+
const stashList = await this.git.stashList()
97+
98+
if (stashList.all.length > 0) {
99+
await this.git.stash(["pop", "--index"]) // Pops the most recent stash only.
100+
return true
101+
}
102+
103+
return false
104+
}
105+
106+
private async ensureBranch(expectedBranch: string) {
107+
const branch = await this.git.revparse(["--abbrev-ref", "HEAD"])
108+
109+
if (branch.trim() !== expectedBranch) {
110+
throw new Error(`Git branch mismatch: expected '${expectedBranch}' but found '${branch}'`)
111+
}
112+
}
113+
114+
public async getDiff({ from, to }: { from?: string; to: string }) {
115+
const result = []
116+
117+
if (!from) {
118+
from = this.baseCommitHash
119+
}
120+
121+
const { files } = await this.git.diffSummary([`${from}..${to}`])
122+
123+
for (const file of files.filter((f) => !f.binary)) {
124+
const relPath = file.file
125+
const absPath = path.join(this.baseDir, relPath)
126+
127+
// If modified both before and after will generate content.
128+
// If added only after will generate content.
129+
// If deleted only before will generate content.
130+
let beforeContent = ""
131+
let afterContent = ""
132+
133+
try {
134+
beforeContent = await this.git.show([`${from}:${relPath}`])
135+
} catch (err) {
136+
// File doesn't exist in older commit.
137+
}
138+
139+
try {
140+
afterContent = await this.git.show([`${to}:${relPath}`])
141+
} catch (err) {
142+
// File doesn't exist in newer commit.
143+
}
144+
145+
result.push({
146+
paths: { relative: relPath, absolute: absPath },
147+
content: { before: beforeContent, after: afterContent },
148+
})
149+
}
150+
151+
return result
152+
}
153+
154+
public async saveCheckpoint(message: string) {
155+
await this.ensureBranch(this.mainBranch)
156+
157+
// Attempt to stash pending changes (including untracked files).
158+
const pendingChanges = await this.pushStash()
159+
160+
// Get the latest commit on the hidden branch before we reset it.
161+
const latestHash = await this.git.revparse([this.hiddenBranch])
162+
163+
// Check if there is any diff relative to the latest commit.
164+
if (!pendingChanges) {
165+
const diff = await this.git.diff([latestHash])
166+
167+
if (!diff) {
168+
this.log(`[saveCheckpoint] No changes detected, giving up`)
169+
return undefined
170+
}
171+
}
172+
173+
await this.git.checkout(this.hiddenBranch)
174+
175+
const reset = async () => {
176+
await this.git.reset(["HEAD", "."])
177+
await this.git.clean([CleanOptions.FORCE, CleanOptions.RECURSIVE])
178+
await this.git.reset(["--hard", latestHash])
179+
await this.git.checkout(this.mainBranch)
180+
await this.popStash()
181+
}
182+
183+
try {
184+
// Reset hidden branch to match main and apply the pending changes.
185+
await this.git.reset(["--hard", this.mainBranch])
186+
187+
if (pendingChanges) {
188+
await this.applyStash()
189+
}
190+
191+
// Using "-A" ensures that deletions are staged as well.
192+
await this.git.add(["-A"])
193+
const diff = await this.git.diff([latestHash])
194+
195+
if (!diff) {
196+
this.log(`[saveCheckpoint] No changes detected, resetting and giving up`)
197+
await reset()
198+
return undefined
199+
}
200+
201+
// Otherwise, commit the changes.
202+
const status = await this.git.status()
203+
this.log(`[saveCheckpoint] Changes detected, committing ${JSON.stringify(status)}`)
204+
205+
// Allow empty commits in order to correctly handle deletion of
206+
// untracked files (see unit tests for an example of this).
207+
// Additionally, skip pre-commit hooks so that they don't slow
208+
// things down or tamper with the contents of the commit.
209+
const commit = await this.git.commit(message, undefined, {
210+
"--allow-empty": null,
211+
"--no-verify": null,
212+
})
213+
214+
await this.git.checkout(this.mainBranch)
215+
216+
if (pendingChanges) {
217+
await this.popStash()
218+
}
219+
220+
return commit
221+
} catch (err) {
222+
this.log(`[saveCheckpoint] Failed to save checkpoint: ${err instanceof Error ? err.message : String(err)}`)
223+
224+
// If we're not on the main branch then we need to trigger a reset
225+
// to return to the main branch and restore it's previous state.
226+
const currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"])
227+
228+
if (currentBranch.trim() !== this.mainBranch) {
229+
await reset()
230+
}
231+
232+
throw err
233+
}
234+
}
235+
236+
public async restoreCheckpoint(commitHash: string) {
237+
await this.ensureBranch(this.mainBranch)
238+
await this.git.clean([CleanOptions.FORCE, CleanOptions.RECURSIVE])
239+
await this.git.raw(["restore", "--source", commitHash, "--worktree", "--", "."])
240+
}
241+
242+
public static async create({ taskId, git, baseDir, log = console.log }: CheckpointServiceOptions) {
243+
git =
244+
git ||
245+
simpleGit({
246+
baseDir,
247+
binary: "git",
248+
maxConcurrentProcesses: 1,
249+
config: [],
250+
trimmed: true,
251+
})
252+
253+
const version = await git.version()
254+
255+
if (!version?.installed) {
256+
throw new Error(`Git is not installed. Please install Git if you wish to use checkpoints.`)
257+
}
258+
259+
if (!baseDir || !existsSync(baseDir)) {
260+
throw new Error(`Base directory is not set or does not exist.`)
261+
}
262+
263+
const { currentBranch, currentSha, hiddenBranch } = await CheckpointService.initRepo({
264+
taskId,
265+
git,
266+
baseDir,
267+
log,
268+
})
269+
270+
log(
271+
`[CheckpointService] taskId = ${taskId}, baseDir = ${baseDir}, currentBranch = ${currentBranch}, currentSha = ${currentSha}, hiddenBranch = ${hiddenBranch}`,
272+
)
273+
return new CheckpointService(taskId, git, baseDir, currentBranch, currentSha, hiddenBranch, log)
274+
}
275+
276+
private static async initRepo({ taskId, git, baseDir, log }: Required<CheckpointServiceOptions>) {
277+
const isExistingRepo = existsSync(path.join(baseDir, ".git"))
278+
279+
if (!isExistingRepo) {
280+
await git.init()
281+
log(`[initRepo] Initialized new Git repository at ${baseDir}`)
282+
}
283+
284+
await git.addConfig("user.name", "Roo Code")
285+
await git.addConfig("user.email", "[email protected]")
286+
287+
if (!isExistingRepo) {
288+
// We need at least one file to commit, otherwise the initial
289+
// commit will fail, unless we use the `--allow-empty` flag.
290+
// However, using an empty commit causes problems when restoring
291+
// the checkpoint (i.e. the `git restore` command doesn't work
292+
// for empty commits).
293+
await fs.writeFile(path.join(baseDir, ".gitkeep"), "")
294+
await git.add(".")
295+
const commit = await git.commit("Initial commit")
296+
297+
if (!commit.commit) {
298+
throw new Error("Failed to create initial commit")
299+
}
300+
301+
log(`[initRepo] Initial commit: ${commit.commit}`)
302+
}
303+
304+
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
305+
const currentSha = await git.revparse(["HEAD"])
306+
307+
const hiddenBranch = `roo-code-checkpoints-${taskId}`
308+
const branchSummary = await git.branch()
309+
310+
if (!branchSummary.all.includes(hiddenBranch)) {
311+
await git.checkoutBranch(hiddenBranch, currentBranch) // git checkout -b <hiddenBranch> <currentBranch>
312+
await git.checkout(currentBranch) // git checkout <currentBranch>
313+
}
314+
315+
return { currentBranch, currentSha, hiddenBranch }
316+
}
317+
}

0 commit comments

Comments
 (0)