mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-09 22:31:08 +00:00
New checkpoints algorithm
This commit is contained in:
parent
ca54d1e2fe
commit
9d8360663e
2 changed files with 193 additions and 46 deletions
|
|
@ -11,6 +11,12 @@ export type CheckpointServiceOptions = {
|
|||
log?: (message: string) => void
|
||||
}
|
||||
|
||||
export type StagedFile = {
|
||||
path: string
|
||||
isStaged: boolean
|
||||
isPartiallyStaged: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The CheckpointService provides a mechanism for storing a snapshot of the
|
||||
* current VSCode workspace each time a Roo Code tool is executed. It uses Git
|
||||
|
|
@ -23,11 +29,11 @@ export type CheckpointServiceOptions = {
|
|||
* - A hidden branch for storing checkpoints.
|
||||
*
|
||||
* Saving a checkpoint:
|
||||
* - Current changes are stashed (including untracked files).
|
||||
* - A temporary branch is created to store the current state.
|
||||
* - All changes (including untracked files) are staged and committed on the temp branch.
|
||||
* - The hidden branch is reset to match main.
|
||||
* - Stashed changes are applied and committed as a checkpoint on the hidden
|
||||
* branch.
|
||||
* - We return to the main branch with the original state restored.
|
||||
* - The temporary branch commit is cherry-picked onto the hidden branch.
|
||||
* - The workspace is restored to its original state and the temp branch is deleted.
|
||||
*
|
||||
* Restoring a checkpoint:
|
||||
* - The workspace is restored to the state of the specified checkpoint using
|
||||
|
|
@ -37,6 +43,7 @@ export type CheckpointServiceOptions = {
|
|||
* - Non-destructive version control (main branch remains untouched).
|
||||
* - Preservation of the full history of checkpoints.
|
||||
* - Safe restoration to any previous checkpoint.
|
||||
* - Atomic checkpoint operations with proper error recovery.
|
||||
*
|
||||
* NOTES
|
||||
*
|
||||
|
|
@ -122,51 +129,188 @@ export class CheckpointService {
|
|||
return result
|
||||
}
|
||||
|
||||
private async restoreMain({
|
||||
branch,
|
||||
stashSha,
|
||||
force = false,
|
||||
}: {
|
||||
branch: string
|
||||
stashSha: string
|
||||
force?: boolean
|
||||
}) {
|
||||
if (force) {
|
||||
await this.git.checkout(["-f", this.mainBranch])
|
||||
} else {
|
||||
await this.git.checkout(this.mainBranch)
|
||||
}
|
||||
|
||||
if (stashSha) {
|
||||
console.log(`[restoreMain] applying stash ${stashSha}`)
|
||||
await this.git.raw(["stash", "apply", "--index", stashSha])
|
||||
}
|
||||
|
||||
console.log(`[restoreMain] restoring from ${branch}`)
|
||||
await this.git.raw(["restore", "--source", branch, "--worktree", "--", "."])
|
||||
}
|
||||
|
||||
public async saveCheckpoint(message: string) {
|
||||
await this.ensureBranch(this.mainBranch)
|
||||
|
||||
// Create temporary branch with all current changes
|
||||
const tempBranch = `${CheckpointService.STASH_BRANCH}-${Date.now()}`
|
||||
await this.git.checkout(["-b", tempBranch])
|
||||
const status = await this.git.status()
|
||||
console.log(`[saveCheckpoint] status: ${JSON.stringify(status)}`)
|
||||
const stashSha = (await this.git.raw(["stash", "create"])).trim()
|
||||
console.log(`[saveCheckpoint] stashSha: ${stashSha}`)
|
||||
const latestHash = await this.git.revparse([this.hiddenBranch])
|
||||
|
||||
/**
|
||||
* PHASE: Create stash
|
||||
* Mutations:
|
||||
* - Create branch
|
||||
* - Change branch
|
||||
*/
|
||||
const stashBranch = `${CheckpointService.STASH_BRANCH}-${Date.now()}`
|
||||
await this.git.checkout(["-b", stashBranch])
|
||||
this.log(`[saveCheckpoint] created and checked out ${stashBranch}`)
|
||||
|
||||
/**
|
||||
* Phase: Stage stash
|
||||
* Mutations: None
|
||||
* Recovery:
|
||||
* - UNDO: Create branch
|
||||
* - UNDO: Change branch
|
||||
*/
|
||||
try {
|
||||
await this.git.add(["-A"])
|
||||
const status = await this.git.status()
|
||||
this.log(`[saveCheckpoint] status: ${JSON.stringify(status)}`)
|
||||
} catch (err) {
|
||||
await this.git.checkout(["-f", this.mainBranch])
|
||||
await this.git.branch(["-D", stashBranch]).catch(() => {})
|
||||
|
||||
throw new Error(
|
||||
`[saveCheckpoint] Failed in stage stash phase: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase: Commit stash
|
||||
* Mutations:
|
||||
* - Commit stash
|
||||
* - Change branch
|
||||
* Recovery:
|
||||
* - UNDO: Create branch
|
||||
* - UNDO: Change branch
|
||||
*/
|
||||
try {
|
||||
// TODO: Add a test to see if empty commits break this.
|
||||
const tempCommit = await this.git.commit(message, undefined, { "--no-verify": null })
|
||||
this.log(`[saveCheckpoint] tempCommit: ${message} -> ${JSON.stringify(tempCommit)}`)
|
||||
} catch (err) {
|
||||
await this.git.checkout(["-f", this.mainBranch])
|
||||
await this.git.branch(["-D", stashBranch]).catch(() => {})
|
||||
|
||||
throw new Error(
|
||||
`[saveCheckpoint] Failed in stash commit phase: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* PHASE: Diff
|
||||
* Mutations:
|
||||
* - Checkout hidden branch
|
||||
* Recovery:
|
||||
* - UNDO: Create branch
|
||||
* - UNDO: Change branch
|
||||
* - UNDO: Commit stash
|
||||
*/
|
||||
let diff
|
||||
|
||||
try {
|
||||
// Stage and commit all changes to temporary branch
|
||||
await this.git.add(["-A"])
|
||||
await this.git.commit(message, undefined, {
|
||||
"--allow-empty": null,
|
||||
"--no-verify": null,
|
||||
})
|
||||
|
||||
// Get the latest commit on the hidden branch before we reset it
|
||||
const latestHash = await this.git.revparse([this.hiddenBranch])
|
||||
await this.git.checkout(this.hiddenBranch)
|
||||
|
||||
try {
|
||||
// Reset hidden branch to match main and apply the changes
|
||||
await this.git.reset(["--hard", this.mainBranch])
|
||||
|
||||
// Cherry-pick the temporary commit
|
||||
const commit = await this.git.raw(["cherry-pick", tempBranch])
|
||||
|
||||
// Return to main branch and cleanup
|
||||
await this.git.checkout(this.mainBranch)
|
||||
await this.git.branch(["-D", tempBranch])
|
||||
|
||||
this.currentCheckpoint = commit
|
||||
return { commit }
|
||||
} catch (err) {
|
||||
// If something went wrong after switching to hidden branch
|
||||
await this.git.reset(["--hard", latestHash])
|
||||
await this.git.checkout(["-f", this.mainBranch])
|
||||
await this.git.branch(["-D", tempBranch])
|
||||
throw err
|
||||
}
|
||||
diff = await this.git.diff([latestHash, stashBranch])
|
||||
} catch (err) {
|
||||
// If something went wrong before switching to hidden branch
|
||||
await this.git.checkout(["-f", this.mainBranch])
|
||||
await this.git.branch(["-D", tempBranch])
|
||||
throw new Error(`Failed to save checkpoint: ${err instanceof Error ? err.message : String(err)}`)
|
||||
await this.restoreMain({ branch: stashBranch, stashSha, force: true })
|
||||
await this.git.branch(["-D", stashBranch]).catch(() => {})
|
||||
|
||||
throw new Error(
|
||||
`[saveCheckpoint] Failed in diff phase: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!diff) {
|
||||
this.log("[saveCheckpoint] no diff")
|
||||
await this.restoreMain({ branch: stashBranch, stashSha })
|
||||
await this.git.branch(["-D", stashBranch])
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* PHASE: Reset
|
||||
* Mutations:
|
||||
* - Reset hidden branch
|
||||
* Recovery:
|
||||
* - UNDO: Create branch
|
||||
* - UNDO: Change branch
|
||||
* - UNDO: Commit stash
|
||||
*/
|
||||
try {
|
||||
await this.git.checkout(this.hiddenBranch)
|
||||
this.log(`[saveCheckpoint] checked out ${this.hiddenBranch}`)
|
||||
await this.git.reset(["--hard", this.mainBranch])
|
||||
this.log(`[saveCheckpoint] reset ${this.hiddenBranch}`)
|
||||
} catch (err) {
|
||||
await this.restoreMain({ branch: stashBranch, stashSha, force: true })
|
||||
await this.git.branch(["-D", stashBranch]).catch(() => {})
|
||||
|
||||
throw new Error(
|
||||
`[saveCheckpoint] Failed in reset phase: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* PHASE: Cherry pick
|
||||
* Mutations:
|
||||
* - Hidden commit (NOTE: reset on hidden branch no longer needed in
|
||||
* success scenario.)
|
||||
* Recovery:
|
||||
* - UNDO: Create branch
|
||||
* - UNDO: Change branch
|
||||
* - UNDO: Commit stash
|
||||
* - UNDO: Reset hidden branch
|
||||
*/
|
||||
let commit = ""
|
||||
|
||||
try {
|
||||
try {
|
||||
await this.git.raw(["cherry-pick", stashBranch])
|
||||
} catch (err) {
|
||||
// Check if we're in the middle of a cherry-pick.
|
||||
// If the cherry-pick resulted in an empty commit (e.g., only
|
||||
// deletions) then complete it with --allow-empty.
|
||||
// Otherwise, rethrow the error.
|
||||
if (existsSync(path.join(this.baseDir, ".git/CHERRY_PICK_HEAD"))) {
|
||||
await this.git.raw(["commit", "--allow-empty", "--no-edit"])
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
commit = await this.git.revparse(["HEAD"])
|
||||
this.currentCheckpoint = commit
|
||||
this.log(`[saveCheckpoint] cherry-pick commit = ${commit}`)
|
||||
} catch (err) {
|
||||
await this.git.reset(["--hard", latestHash]).catch(() => {})
|
||||
await this.restoreMain({ branch: stashBranch, stashSha, force: true })
|
||||
await this.git.branch(["-D", stashBranch]).catch(() => {})
|
||||
|
||||
throw new Error(
|
||||
`[saveCheckpoint] Failed in cherry pick phase: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
await this.restoreMain({ branch: stashBranch, stashSha })
|
||||
await this.git.branch(["-D", stashBranch])
|
||||
|
||||
return { commit }
|
||||
}
|
||||
|
||||
public async restoreCheckpoint(commitHash: string) {
|
||||
|
|
@ -264,12 +408,12 @@ export class CheckpointService {
|
|||
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
|
||||
const currentSha = await git.revparse(["HEAD"])
|
||||
|
||||
const hiddenBranch = `roo-code-checkpoints-${taskId}`
|
||||
const hiddenBranch = `${CheckpointService.CHECKPOINT_BRANCH}-${taskId}`
|
||||
const branchSummary = await git.branch()
|
||||
|
||||
if (!branchSummary.all.includes(hiddenBranch)) {
|
||||
await git.checkoutBranch(hiddenBranch, currentBranch) // git checkout -b <hiddenBranch> <currentBranch>
|
||||
await git.checkout(currentBranch) // git checkout <currentBranch>
|
||||
await git.checkoutBranch(hiddenBranch, currentBranch)
|
||||
await git.checkout(currentBranch)
|
||||
}
|
||||
|
||||
return { currentBranch, currentSha, hiddenBranch }
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ describe("CheckpointService", () => {
|
|||
|
||||
git = repo.git
|
||||
testFile = repo.testFile
|
||||
service = await CheckpointService.create({ taskId, git, baseDir, log: () => {} })
|
||||
service = await CheckpointService.create({ taskId, git, baseDir })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -295,8 +295,11 @@ describe("CheckpointService", () => {
|
|||
await fs.writeFile(testFile, "I am tracked!")
|
||||
const untrackedFile = path.join(service.baseDir, "new.txt")
|
||||
await fs.writeFile(untrackedFile, "I am untracked!")
|
||||
|
||||
const commit1 = await service.saveCheckpoint("First checkpoint")
|
||||
expect(commit1?.commit).toBeTruthy()
|
||||
expect(await fs.readFile(testFile, "utf-8")).toBe("I am tracked!")
|
||||
expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
|
||||
|
||||
await fs.unlink(testFile)
|
||||
await fs.unlink(untrackedFile)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue