Skip to content

Commit 43d4c5c

Browse files
authored
Merge pull request #939 from RooVetGit/cte/stashless-checkpoints
New checkpoints algorithm
2 parents e4a1b35 + 6dbb596 commit 43d4c5c

File tree

1 file changed

+167
-100
lines changed

1 file changed

+167
-100
lines changed

src/services/checkpoints/CheckpointService.ts

Lines changed: 167 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ export type CheckpointServiceOptions = {
2323
* - A hidden branch for storing checkpoints.
2424
*
2525
* Saving a checkpoint:
26-
* - Current changes are stashed (including untracked files).
26+
* - A temporary branch is created to store the current state.
27+
* - All changes (including untracked files) are staged and committed on the temp branch.
2728
* - The hidden branch is reset to match main.
28-
* - Stashed changes are applied and committed as a checkpoint on the hidden
29-
* branch.
30-
* - We return to the main branch with the original state restored.
29+
* - The temporary branch commit is cherry-picked onto the hidden branch.
30+
* - The workspace is restored to its original state and the temp branch is deleted.
3131
*
3232
* Restoring a checkpoint:
3333
* - The workspace is restored to the state of the specified checkpoint using
@@ -37,6 +37,7 @@ export type CheckpointServiceOptions = {
3737
* - Non-destructive version control (main branch remains untouched).
3838
* - Preservation of the full history of checkpoints.
3939
* - Safe restoration to any previous checkpoint.
40+
* - Atomic checkpoint operations with proper error recovery.
4041
*
4142
* NOTES
4243
*
@@ -51,6 +52,8 @@ export type CheckpointServiceOptions = {
5152
export class CheckpointService {
5253
private static readonly USER_NAME = "Roo Code"
5354
private static readonly USER_EMAIL = "[email protected]"
55+
private static readonly CHECKPOINT_BRANCH = "roo-code-checkpoints"
56+
private static readonly STASH_BRANCH = "roo-code-stash"
5457

5558
private _currentCheckpoint?: string
5659

@@ -72,39 +75,6 @@ export class CheckpointService {
7275
private readonly log: (message: string) => void,
7376
) {}
7477

75-
private async pushStash() {
76-
const status = await this.git.status()
77-
78-
if (status.files.length > 0) {
79-
await this.git.stash(["-u"]) // Includes tracked and untracked files.
80-
return true
81-
}
82-
83-
return false
84-
}
85-
86-
private async applyStash() {
87-
const stashList = await this.git.stashList()
88-
89-
if (stashList.all.length > 0) {
90-
await this.git.stash(["apply"]) // Applies the most recent stash only.
91-
return true
92-
}
93-
94-
return false
95-
}
96-
97-
private async popStash() {
98-
const stashList = await this.git.stashList()
99-
100-
if (stashList.all.length > 0) {
101-
await this.git.stash(["pop", "--index"]) // Pops the most recent stash only.
102-
return true
103-
}
104-
105-
return false
106-
}
107-
10878
private async ensureBranch(expectedBranch: string) {
10979
const branch = await this.git.revparse(["--abbrev-ref", "HEAD"])
11080

@@ -153,88 +123,185 @@ export class CheckpointService {
153123
return result
154124
}
155125

156-
public async saveCheckpoint(message: string) {
157-
await this.ensureBranch(this.mainBranch)
126+
private async restoreMain({
127+
branch,
128+
stashSha,
129+
force = false,
130+
}: {
131+
branch: string
132+
stashSha: string
133+
force?: boolean
134+
}) {
135+
if (force) {
136+
await this.git.checkout(["-f", this.mainBranch])
137+
} else {
138+
await this.git.checkout(this.mainBranch)
139+
}
158140

159-
// Attempt to stash pending changes (including untracked files).
160-
const pendingChanges = await this.pushStash()
141+
if (stashSha) {
142+
this.log(`[restoreMain] applying stash ${stashSha}`)
143+
await this.git.raw(["stash", "apply", "--index", stashSha])
144+
}
161145

162-
// Get the latest commit on the hidden branch before we reset it.
163-
const latestHash = await this.git.revparse([this.hiddenBranch])
146+
this.log(`[restoreMain] restoring from ${branch}`)
147+
await this.git.raw(["restore", "--source", branch, "--worktree", "--", "."])
148+
}
164149

165-
// Check if there is any diff relative to the latest commit.
166-
if (!pendingChanges) {
167-
const diff = await this.git.diff([latestHash])
150+
public async saveCheckpoint(message: string) {
151+
await this.ensureBranch(this.mainBranch)
168152

169-
if (!diff) {
170-
this.log(`[saveCheckpoint] No changes detected, giving up`)
171-
return undefined
172-
}
153+
const stashSha = (await this.git.raw(["stash", "create"])).trim()
154+
const latestSha = await this.git.revparse([this.hiddenBranch])
155+
156+
/**
157+
* PHASE: Create stash
158+
* Mutations:
159+
* - Create branch
160+
* - Change branch
161+
*/
162+
const stashBranch = `${CheckpointService.STASH_BRANCH}-${Date.now()}`
163+
await this.git.checkout(["-b", stashBranch])
164+
this.log(`[saveCheckpoint] created and checked out ${stashBranch}`)
165+
166+
/**
167+
* Phase: Stage stash
168+
* Mutations: None
169+
* Recovery:
170+
* - UNDO: Create branch
171+
* - UNDO: Change branch
172+
*/
173+
try {
174+
await this.git.add(["-A"])
175+
const status = await this.git.status()
176+
this.log(`[saveCheckpoint] status: ${JSON.stringify(status)}`)
177+
} catch (err) {
178+
await this.git.checkout(["-f", this.mainBranch])
179+
await this.git.branch(["-D", stashBranch]).catch(() => {})
180+
181+
throw new Error(
182+
`[saveCheckpoint] Failed in stage stash phase: ${err instanceof Error ? err.message : String(err)}`,
183+
)
173184
}
174185

175-
await this.git.checkout(this.hiddenBranch)
186+
/**
187+
* Phase: Commit stash
188+
* Mutations:
189+
* - Commit stash
190+
* - Change branch
191+
* Recovery:
192+
* - UNDO: Create branch
193+
* - UNDO: Change branch
194+
*/
195+
try {
196+
// TODO: Add a test to see if empty commits break this.
197+
const tempCommit = await this.git.commit(message, undefined, { "--no-verify": null })
198+
this.log(`[saveCheckpoint] tempCommit: ${message} -> ${JSON.stringify(tempCommit)}`)
199+
} catch (err) {
200+
await this.git.checkout(["-f", this.mainBranch])
201+
await this.git.branch(["-D", stashBranch]).catch(() => {})
176202

177-
const reset = async () => {
178-
await this.git.reset(["HEAD", "."])
179-
await this.git.clean([CleanOptions.FORCE, CleanOptions.RECURSIVE])
180-
await this.git.reset(["--hard", latestHash])
181-
await this.git.checkout(this.mainBranch)
182-
await this.popStash()
203+
throw new Error(
204+
`[saveCheckpoint] Failed in stash commit phase: ${err instanceof Error ? err.message : String(err)}`,
205+
)
183206
}
184207

208+
/**
209+
* PHASE: Diff
210+
* Mutations:
211+
* - Checkout hidden branch
212+
* Recovery:
213+
* - UNDO: Create branch
214+
* - UNDO: Change branch
215+
* - UNDO: Commit stash
216+
*/
217+
let diff
218+
185219
try {
186-
// Reset hidden branch to match main and apply the pending changes.
187-
await this.git.reset(["--hard", this.mainBranch])
220+
diff = await this.git.diff([latestSha, stashBranch])
221+
} catch (err) {
222+
await this.restoreMain({ branch: stashBranch, stashSha, force: true })
223+
await this.git.branch(["-D", stashBranch]).catch(() => {})
188224

189-
if (pendingChanges) {
190-
await this.applyStash()
191-
}
225+
throw new Error(
226+
`[saveCheckpoint] Failed in diff phase: ${err instanceof Error ? err.message : String(err)}`,
227+
)
228+
}
192229

193-
// Using "-A" ensures that deletions are staged as well.
194-
await this.git.add(["-A"])
195-
const diff = await this.git.diff([latestHash])
230+
if (!diff) {
231+
this.log("[saveCheckpoint] no diff")
232+
await this.restoreMain({ branch: stashBranch, stashSha })
233+
await this.git.branch(["-D", stashBranch])
234+
return undefined
235+
}
196236

197-
if (!diff) {
198-
this.log(`[saveCheckpoint] No changes detected, resetting and giving up`)
199-
await reset()
200-
return undefined
201-
}
237+
/**
238+
* PHASE: Reset
239+
* Mutations:
240+
* - Reset hidden branch
241+
* Recovery:
242+
* - UNDO: Create branch
243+
* - UNDO: Change branch
244+
* - UNDO: Commit stash
245+
*/
246+
try {
247+
await this.git.checkout(this.hiddenBranch)
248+
this.log(`[saveCheckpoint] checked out ${this.hiddenBranch}`)
249+
await this.git.reset(["--hard", this.mainBranch])
250+
this.log(`[saveCheckpoint] reset ${this.hiddenBranch}`)
251+
} catch (err) {
252+
await this.restoreMain({ branch: stashBranch, stashSha, force: true })
253+
await this.git.branch(["-D", stashBranch]).catch(() => {})
202254

203-
// Otherwise, commit the changes.
204-
const status = await this.git.status()
205-
this.log(`[saveCheckpoint] Changes detected, committing ${JSON.stringify(status)}`)
206-
207-
// Allow empty commits in order to correctly handle deletion of
208-
// untracked files (see unit tests for an example of this).
209-
// Additionally, skip pre-commit hooks so that they don't slow
210-
// things down or tamper with the contents of the commit.
211-
const commit = await this.git.commit(message, undefined, {
212-
"--allow-empty": null,
213-
"--no-verify": null,
214-
})
255+
throw new Error(
256+
`[saveCheckpoint] Failed in reset phase: ${err instanceof Error ? err.message : String(err)}`,
257+
)
258+
}
215259

216-
await this.git.checkout(this.mainBranch)
260+
/**
261+
* PHASE: Cherry pick
262+
* Mutations:
263+
* - Hidden commit (NOTE: reset on hidden branch no longer needed in
264+
* success scenario.)
265+
* Recovery:
266+
* - UNDO: Create branch
267+
* - UNDO: Change branch
268+
* - UNDO: Commit stash
269+
* - UNDO: Reset hidden branch
270+
*/
271+
let commit = ""
217272

218-
if (pendingChanges) {
219-
await this.popStash()
273+
try {
274+
try {
275+
await this.git.raw(["cherry-pick", stashBranch])
276+
} catch (err) {
277+
// Check if we're in the middle of a cherry-pick.
278+
// If the cherry-pick resulted in an empty commit (e.g., only
279+
// deletions) then complete it with --allow-empty.
280+
// Otherwise, rethrow the error.
281+
if (existsSync(path.join(this.baseDir, ".git/CHERRY_PICK_HEAD"))) {
282+
await this.git.raw(["commit", "--allow-empty", "--no-edit"])
283+
} else {
284+
throw err
285+
}
220286
}
221287

222-
this.currentCheckpoint = commit.commit
223-
224-
return commit
288+
commit = await this.git.revparse(["HEAD"])
289+
this.currentCheckpoint = commit
290+
this.log(`[saveCheckpoint] cherry-pick commit = ${commit}`)
225291
} catch (err) {
226-
this.log(`[saveCheckpoint] Failed to save checkpoint: ${err instanceof Error ? err.message : String(err)}`)
292+
await this.git.reset(["--hard", latestSha]).catch(() => {})
293+
await this.restoreMain({ branch: stashBranch, stashSha, force: true })
294+
await this.git.branch(["-D", stashBranch]).catch(() => {})
227295

228-
// If we're not on the main branch then we need to trigger a reset
229-
// to return to the main branch and restore it's previous state.
230-
const currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"])
296+
throw new Error(
297+
`[saveCheckpoint] Failed in cherry pick phase: ${err instanceof Error ? err.message : String(err)}`,
298+
)
299+
}
231300

232-
if (currentBranch.trim() !== this.mainBranch) {
233-
await reset()
234-
}
301+
await this.restoreMain({ branch: stashBranch, stashSha })
302+
await this.git.branch(["-D", stashBranch])
235303

236-
throw err
237-
}
304+
return { commit }
238305
}
239306

240307
public async restoreCheckpoint(commitHash: string) {
@@ -332,12 +399,12 @@ export class CheckpointService {
332399
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
333400
const currentSha = await git.revparse(["HEAD"])
334401

335-
const hiddenBranch = `roo-code-checkpoints-${taskId}`
402+
const hiddenBranch = `${CheckpointService.CHECKPOINT_BRANCH}-${taskId}`
336403
const branchSummary = await git.branch()
337404

338405
if (!branchSummary.all.includes(hiddenBranch)) {
339-
await git.checkoutBranch(hiddenBranch, currentBranch) // git checkout -b <hiddenBranch> <currentBranch>
340-
await git.checkout(currentBranch) // git checkout <currentBranch>
406+
await git.checkoutBranch(hiddenBranch, currentBranch)
407+
await git.checkout(currentBranch)
341408
}
342409

343410
return { currentBranch, currentSha, hiddenBranch }

0 commit comments

Comments
 (0)