@@ -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 = {
5152export 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