CheckpointService.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. import fs from "fs/promises"
  2. import { existsSync } from "fs"
  3. import path from "path"
  4. import simpleGit, { SimpleGit, CleanOptions } from "simple-git"
  5. export type CheckpointServiceOptions = {
  6. taskId: string
  7. git?: SimpleGit
  8. baseDir: string
  9. log?: (message: string) => void
  10. }
  11. /**
  12. * The CheckpointService provides a mechanism for storing a snapshot of the
  13. * current VSCode workspace each time a Roo Code tool is executed. It uses Git
  14. * under the hood.
  15. *
  16. * HOW IT WORKS
  17. *
  18. * Two branches are used:
  19. * - A main branch for normal operation (the branch you are currently on).
  20. * - A hidden branch for storing checkpoints.
  21. *
  22. * Saving a checkpoint:
  23. * - A temporary branch is created to store the current state.
  24. * - All changes (including untracked files) are staged and committed on the temp branch.
  25. * - The hidden branch is reset to match main.
  26. * - The temporary branch commit is cherry-picked onto the hidden branch.
  27. * - The workspace is restored to its original state and the temp branch is deleted.
  28. *
  29. * Restoring a checkpoint:
  30. * - The workspace is restored to the state of the specified checkpoint using
  31. * `git restore` and `git clean`.
  32. *
  33. * This approach allows for:
  34. * - Non-destructive version control (main branch remains untouched).
  35. * - Preservation of the full history of checkpoints.
  36. * - Safe restoration to any previous checkpoint.
  37. * - Atomic checkpoint operations with proper error recovery.
  38. *
  39. * NOTES
  40. *
  41. * - Git must be installed.
  42. * - If the current working directory is not a Git repository, we will
  43. * initialize a new one with a .gitkeep file.
  44. * - If you manually edit files and then restore a checkpoint, the changes
  45. * will be lost. Addressing this adds some complexity to the implementation
  46. * and it's not clear whether it's worth it.
  47. */
  48. export class CheckpointService {
  49. private static readonly USER_NAME = "Roo Code"
  50. private static readonly USER_EMAIL = "[email protected]"
  51. private static readonly CHECKPOINT_BRANCH = "roo-code-checkpoints"
  52. private static readonly STASH_BRANCH = "roo-code-stash"
  53. private _currentCheckpoint?: string
  54. public get currentCheckpoint() {
  55. return this._currentCheckpoint
  56. }
  57. private set currentCheckpoint(value: string | undefined) {
  58. this._currentCheckpoint = value
  59. }
  60. constructor(
  61. public readonly taskId: string,
  62. private readonly git: SimpleGit,
  63. public readonly baseDir: string,
  64. public readonly mainBranch: string,
  65. public readonly baseCommitHash: string,
  66. public readonly hiddenBranch: string,
  67. private readonly log: (message: string) => void,
  68. ) {}
  69. private async ensureBranch(expectedBranch: string) {
  70. const branch = await this.git.revparse(["--abbrev-ref", "HEAD"])
  71. if (branch.trim() !== expectedBranch) {
  72. throw new Error(`Git branch mismatch: expected '${expectedBranch}' but found '${branch}'`)
  73. }
  74. }
  75. public async getDiff({ from, to }: { from?: string; to: string }) {
  76. const result = []
  77. if (!from) {
  78. from = this.baseCommitHash
  79. }
  80. const { files } = await this.git.diffSummary([`${from}..${to}`])
  81. for (const file of files.filter((f) => !f.binary)) {
  82. const relPath = file.file
  83. const absPath = path.join(this.baseDir, relPath)
  84. // If modified both before and after will generate content.
  85. // If added only after will generate content.
  86. // If deleted only before will generate content.
  87. let beforeContent = ""
  88. let afterContent = ""
  89. try {
  90. beforeContent = await this.git.show([`${from}:${relPath}`])
  91. } catch (err) {
  92. // File doesn't exist in older commit.
  93. }
  94. try {
  95. afterContent = await this.git.show([`${to}:${relPath}`])
  96. } catch (err) {
  97. // File doesn't exist in newer commit.
  98. }
  99. result.push({
  100. paths: { relative: relPath, absolute: absPath },
  101. content: { before: beforeContent, after: afterContent },
  102. })
  103. }
  104. return result
  105. }
  106. private async restoreMain({
  107. branch,
  108. stashSha,
  109. force = false,
  110. }: {
  111. branch: string
  112. stashSha: string
  113. force?: boolean
  114. }) {
  115. let currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"])
  116. if (currentBranch !== this.mainBranch) {
  117. if (force) {
  118. try {
  119. await this.git.checkout(["-f", this.mainBranch])
  120. } catch (err) {
  121. this.log(
  122. `[restoreMain] failed to force checkout ${this.mainBranch}: ${err instanceof Error ? err.message : String(err)}`,
  123. )
  124. }
  125. } else {
  126. try {
  127. await this.git.checkout(this.mainBranch)
  128. } catch (err) {
  129. this.log(
  130. `[restoreMain] failed to checkout ${this.mainBranch}: ${err instanceof Error ? err.message : String(err)}`,
  131. )
  132. // Escalate to a forced checkout if we can't checkout the
  133. // main branch under nor
  134. currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"])
  135. if (currentBranch !== this.mainBranch) {
  136. await this.git.checkout(["-f", this.mainBranch]).catch(() => {})
  137. }
  138. }
  139. }
  140. }
  141. currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"])
  142. if (currentBranch !== this.mainBranch) {
  143. throw new Error(`Unable to restore ${this.mainBranch}`)
  144. }
  145. if (stashSha) {
  146. this.log(`[restoreMain] applying stash ${stashSha}`)
  147. try {
  148. await this.git.raw(["stash", "apply", "--index", stashSha])
  149. } catch (err) {
  150. this.log(`[restoreMain] Failed to apply stash: ${err instanceof Error ? err.message : String(err)}`)
  151. }
  152. }
  153. this.log(`[restoreMain] restoring from ${branch} branch`)
  154. try {
  155. await this.git.raw(["restore", "--source", branch, "--worktree", "--", "."])
  156. } catch (err) {
  157. this.log(`[restoreMain] Failed to restore branch: ${err instanceof Error ? err.message : String(err)}`)
  158. }
  159. }
  160. public async saveCheckpoint(message: string) {
  161. const startTime = Date.now()
  162. await this.ensureBranch(this.mainBranch)
  163. const stashSha = (await this.git.raw(["stash", "create"])).trim()
  164. const latestSha = await this.git.revparse([this.hiddenBranch])
  165. /**
  166. * PHASE: Create stash
  167. * Mutations:
  168. * - Create branch
  169. * - Change branch
  170. */
  171. const stashBranch = `${CheckpointService.STASH_BRANCH}-${Date.now()}`
  172. await this.git.checkout(["-b", stashBranch])
  173. this.log(`[saveCheckpoint] created and checked out ${stashBranch}`)
  174. /**
  175. * Phase: Stage stash
  176. * Mutations: None
  177. * Recovery:
  178. * - UNDO: Create branch
  179. * - UNDO: Change branch
  180. */
  181. try {
  182. await this.git.add(["-A"])
  183. } catch (err) {
  184. this.log(
  185. `[saveCheckpoint] failed in stage stash phase: ${err instanceof Error ? err.message : String(err)}`,
  186. )
  187. await this.git.checkout(["-f", this.mainBranch])
  188. await this.git.branch(["-D", stashBranch]).catch(() => {})
  189. throw err
  190. }
  191. /**
  192. * Phase: Commit stash
  193. * Mutations:
  194. * - Commit stash
  195. * - Change branch
  196. * Recovery:
  197. * - UNDO: Create branch
  198. * - UNDO: Change branch
  199. */
  200. try {
  201. // TODO: Add a test to see if empty commits break this.
  202. const stashCommit = await this.git.commit(message, undefined, { "--no-verify": null })
  203. this.log(`[saveCheckpoint] stashCommit: ${message} -> ${JSON.stringify(stashCommit)}`)
  204. } catch (err) {
  205. this.log(
  206. `[saveCheckpoint] failed in stash commit phase: ${err instanceof Error ? err.message : String(err)}`,
  207. )
  208. await this.git.checkout(["-f", this.mainBranch])
  209. await this.git.branch(["-D", stashBranch]).catch(() => {})
  210. throw err
  211. }
  212. /**
  213. * PHASE: Diff
  214. * Mutations:
  215. * - Checkout hidden branch
  216. * Recovery:
  217. * - UNDO: Create branch
  218. * - UNDO: Change branch
  219. * - UNDO: Commit stash
  220. */
  221. let diff
  222. try {
  223. diff = await this.git.diff([latestSha, stashBranch])
  224. } catch (err) {
  225. this.log(`[saveCheckpoint] failed in diff phase: ${err instanceof Error ? err.message : String(err)}`)
  226. await this.restoreMain({ branch: stashBranch, stashSha, force: true })
  227. await this.git.branch(["-D", stashBranch]).catch(() => {})
  228. throw err
  229. }
  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. }
  236. /**
  237. * PHASE: Reset
  238. * Mutations:
  239. * - Reset hidden branch
  240. * Recovery:
  241. * - UNDO: Create branch
  242. * - UNDO: Change branch
  243. * - UNDO: Commit stash
  244. */
  245. try {
  246. await this.git.checkout(this.hiddenBranch)
  247. this.log(`[saveCheckpoint] checked out ${this.hiddenBranch}`)
  248. await this.git.reset(["--hard", this.mainBranch])
  249. this.log(`[saveCheckpoint] reset ${this.hiddenBranch}`)
  250. } catch (err) {
  251. this.log(`[saveCheckpoint] failed in reset phase: ${err instanceof Error ? err.message : String(err)}`)
  252. await this.restoreMain({ branch: stashBranch, stashSha, force: true })
  253. await this.git.branch(["-D", stashBranch]).catch(() => {})
  254. throw err
  255. }
  256. /**
  257. * PHASE: Cherry pick
  258. * Mutations:
  259. * - Hidden commit (NOTE: reset on hidden branch no longer needed in
  260. * success scenario.)
  261. * Recovery:
  262. * - UNDO: Create branch
  263. * - UNDO: Change branch
  264. * - UNDO: Commit stash
  265. * - UNDO: Reset hidden branch
  266. */
  267. let commit = ""
  268. try {
  269. try {
  270. await this.git.raw(["cherry-pick", stashBranch])
  271. } catch (err) {
  272. // Check if we're in the middle of a cherry-pick.
  273. // If the cherry-pick resulted in an empty commit (e.g., only
  274. // deletions) then complete it with --allow-empty.
  275. // Otherwise, rethrow the error.
  276. if (existsSync(path.join(this.baseDir, ".git/CHERRY_PICK_HEAD"))) {
  277. await this.git.raw(["commit", "--allow-empty", "--no-edit"])
  278. } else {
  279. throw err
  280. }
  281. }
  282. commit = await this.git.revparse(["HEAD"])
  283. this.currentCheckpoint = commit
  284. this.log(`[saveCheckpoint] cherry-pick commit = ${commit}`)
  285. } catch (err) {
  286. this.log(
  287. `[saveCheckpoint] failed in cherry pick phase: ${err instanceof Error ? err.message : String(err)}`,
  288. )
  289. await this.git.reset(["--hard", latestSha]).catch(() => {})
  290. await this.restoreMain({ branch: stashBranch, stashSha, force: true })
  291. await this.git.branch(["-D", stashBranch]).catch(() => {})
  292. throw err
  293. }
  294. await this.restoreMain({ branch: stashBranch, stashSha })
  295. await this.git.branch(["-D", stashBranch])
  296. // We've gotten reports that checkpoints can be slow in some cases, so
  297. // we'll log the duration of the checkpoint save.
  298. const duration = Date.now() - startTime
  299. this.log(`[saveCheckpoint] saved checkpoint ${commit} in ${duration}ms`)
  300. return { commit }
  301. }
  302. public async restoreCheckpoint(commitHash: string) {
  303. await this.ensureBranch(this.mainBranch)
  304. await this.git.clean([CleanOptions.FORCE, CleanOptions.RECURSIVE])
  305. await this.git.raw(["restore", "--source", commitHash, "--worktree", "--", "."])
  306. this.currentCheckpoint = commitHash
  307. }
  308. public static async create({ taskId, git, baseDir, log = console.log }: CheckpointServiceOptions) {
  309. git = git || simpleGit({ baseDir })
  310. const version = await git.version()
  311. if (!version?.installed) {
  312. throw new Error(`Git is not installed. Please install Git if you wish to use checkpoints.`)
  313. }
  314. if (!baseDir || !existsSync(baseDir)) {
  315. throw new Error(`Base directory is not set or does not exist.`)
  316. }
  317. const { currentBranch, currentSha, hiddenBranch } = await CheckpointService.initRepo({
  318. taskId,
  319. git,
  320. baseDir,
  321. log,
  322. })
  323. log(
  324. `[CheckpointService] taskId = ${taskId}, baseDir = ${baseDir}, currentBranch = ${currentBranch}, currentSha = ${currentSha}, hiddenBranch = ${hiddenBranch}`,
  325. )
  326. return new CheckpointService(taskId, git, baseDir, currentBranch, currentSha, hiddenBranch, log)
  327. }
  328. private static async initRepo({ taskId, git, baseDir, log }: Required<CheckpointServiceOptions>) {
  329. const isExistingRepo = existsSync(path.join(baseDir, ".git"))
  330. if (!isExistingRepo) {
  331. await git.init()
  332. log(`[initRepo] Initialized new Git repository at ${baseDir}`)
  333. }
  334. const globalUserName = await git.getConfig("user.name", "global")
  335. const localUserName = await git.getConfig("user.name", "local")
  336. const userName = localUserName.value || globalUserName.value
  337. const globalUserEmail = await git.getConfig("user.email", "global")
  338. const localUserEmail = await git.getConfig("user.email", "local")
  339. const userEmail = localUserEmail.value || globalUserEmail.value
  340. // Prior versions of this service indiscriminately set the local user
  341. // config, and it should not override the global config. To address
  342. // this we remove the local user config if it matches the default
  343. // user name and email and there's a global config.
  344. if (globalUserName.value && localUserName.value === CheckpointService.USER_NAME) {
  345. await git.raw(["config", "--unset", "--local", "user.name"])
  346. }
  347. if (globalUserEmail.value && localUserEmail.value === CheckpointService.USER_EMAIL) {
  348. await git.raw(["config", "--unset", "--local", "user.email"])
  349. }
  350. // Only set user config if not already configured.
  351. if (!userName) {
  352. await git.addConfig("user.name", CheckpointService.USER_NAME)
  353. }
  354. if (!userEmail) {
  355. await git.addConfig("user.email", CheckpointService.USER_EMAIL)
  356. }
  357. if (!isExistingRepo) {
  358. // We need at least one file to commit, otherwise the initial
  359. // commit will fail, unless we use the `--allow-empty` flag.
  360. // However, using an empty commit causes problems when restoring
  361. // the checkpoint (i.e. the `git restore` command doesn't work
  362. // for empty commits).
  363. await fs.writeFile(path.join(baseDir, ".gitkeep"), "")
  364. await git.add(".gitkeep")
  365. const commit = await git.commit("Initial commit")
  366. if (!commit.commit) {
  367. throw new Error("Failed to create initial commit")
  368. }
  369. log(`[initRepo] Initial commit: ${commit.commit}`)
  370. }
  371. const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
  372. const currentSha = await git.revparse(["HEAD"])
  373. const hiddenBranch = `${CheckpointService.CHECKPOINT_BRANCH}-${taskId}`
  374. const branchSummary = await git.branch()
  375. if (!branchSummary.all.includes(hiddenBranch)) {
  376. await git.checkoutBranch(hiddenBranch, currentBranch)
  377. await git.checkout(currentBranch)
  378. }
  379. return { currentBranch, currentSha, hiddenBranch }
  380. }
  381. }