CheckpointService.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  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. * - Current changes are stashed (including untracked files).
  24. * - The hidden branch is reset to match main.
  25. * - Stashed changes are applied and committed as a checkpoint on the hidden
  26. * branch.
  27. * - We return to the main branch with the original state restored.
  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. *
  38. * NOTES
  39. *
  40. * - Git must be installed.
  41. * - If the current working directory is not a Git repository, we will
  42. * initialize a new one with a .gitkeep file.
  43. * - If you manually edit files and then restore a checkpoint, the changes
  44. * will be lost. Addressing this adds some complexity to the implementation
  45. * and it's not clear whether it's worth it.
  46. */
  47. export class CheckpointService {
  48. private static readonly USER_NAME = "Roo Code"
  49. private static readonly USER_EMAIL = "[email protected]"
  50. private _currentCheckpoint?: string
  51. public get currentCheckpoint() {
  52. return this._currentCheckpoint
  53. }
  54. private set currentCheckpoint(value: string | undefined) {
  55. this._currentCheckpoint = value
  56. }
  57. constructor(
  58. public readonly taskId: string,
  59. private readonly git: SimpleGit,
  60. public readonly baseDir: string,
  61. public readonly mainBranch: string,
  62. public readonly baseCommitHash: string,
  63. public readonly hiddenBranch: string,
  64. private readonly log: (message: string) => void,
  65. ) {}
  66. private async pushStash() {
  67. const status = await this.git.status()
  68. if (status.files.length > 0) {
  69. await this.git.stash(["-u"]) // Includes tracked and untracked files.
  70. return true
  71. }
  72. return false
  73. }
  74. private async applyStash() {
  75. const stashList = await this.git.stashList()
  76. if (stashList.all.length > 0) {
  77. await this.git.stash(["apply"]) // Applies the most recent stash only.
  78. return true
  79. }
  80. return false
  81. }
  82. private async popStash() {
  83. const stashList = await this.git.stashList()
  84. if (stashList.all.length > 0) {
  85. await this.git.stash(["pop", "--index"]) // Pops the most recent stash only.
  86. return true
  87. }
  88. return false
  89. }
  90. private async ensureBranch(expectedBranch: string) {
  91. const branch = await this.git.revparse(["--abbrev-ref", "HEAD"])
  92. if (branch.trim() !== expectedBranch) {
  93. throw new Error(`Git branch mismatch: expected '${expectedBranch}' but found '${branch}'`)
  94. }
  95. }
  96. public async getDiff({ from, to }: { from?: string; to: string }) {
  97. const result = []
  98. if (!from) {
  99. from = this.baseCommitHash
  100. }
  101. const { files } = await this.git.diffSummary([`${from}..${to}`])
  102. for (const file of files.filter((f) => !f.binary)) {
  103. const relPath = file.file
  104. const absPath = path.join(this.baseDir, relPath)
  105. // If modified both before and after will generate content.
  106. // If added only after will generate content.
  107. // If deleted only before will generate content.
  108. let beforeContent = ""
  109. let afterContent = ""
  110. try {
  111. beforeContent = await this.git.show([`${from}:${relPath}`])
  112. } catch (err) {
  113. // File doesn't exist in older commit.
  114. }
  115. try {
  116. afterContent = await this.git.show([`${to}:${relPath}`])
  117. } catch (err) {
  118. // File doesn't exist in newer commit.
  119. }
  120. result.push({
  121. paths: { relative: relPath, absolute: absPath },
  122. content: { before: beforeContent, after: afterContent },
  123. })
  124. }
  125. return result
  126. }
  127. public async saveCheckpoint(message: string) {
  128. await this.ensureBranch(this.mainBranch)
  129. // Attempt to stash pending changes (including untracked files).
  130. const pendingChanges = await this.pushStash()
  131. // Get the latest commit on the hidden branch before we reset it.
  132. const latestHash = await this.git.revparse([this.hiddenBranch])
  133. // Check if there is any diff relative to the latest commit.
  134. if (!pendingChanges) {
  135. const diff = await this.git.diff([latestHash])
  136. if (!diff) {
  137. this.log(`[saveCheckpoint] No changes detected, giving up`)
  138. return undefined
  139. }
  140. }
  141. await this.git.checkout(this.hiddenBranch)
  142. const reset = async () => {
  143. await this.git.reset(["HEAD", "."])
  144. await this.git.clean([CleanOptions.FORCE, CleanOptions.RECURSIVE])
  145. await this.git.reset(["--hard", latestHash])
  146. await this.git.checkout(this.mainBranch)
  147. await this.popStash()
  148. }
  149. try {
  150. // Reset hidden branch to match main and apply the pending changes.
  151. await this.git.reset(["--hard", this.mainBranch])
  152. if (pendingChanges) {
  153. await this.applyStash()
  154. }
  155. // Using "-A" ensures that deletions are staged as well.
  156. await this.git.add(["-A"])
  157. const diff = await this.git.diff([latestHash])
  158. if (!diff) {
  159. this.log(`[saveCheckpoint] No changes detected, resetting and giving up`)
  160. await reset()
  161. return undefined
  162. }
  163. // Otherwise, commit the changes.
  164. const status = await this.git.status()
  165. this.log(`[saveCheckpoint] Changes detected, committing ${JSON.stringify(status)}`)
  166. // Allow empty commits in order to correctly handle deletion of
  167. // untracked files (see unit tests for an example of this).
  168. // Additionally, skip pre-commit hooks so that they don't slow
  169. // things down or tamper with the contents of the commit.
  170. const commit = await this.git.commit(message, undefined, {
  171. "--allow-empty": null,
  172. "--no-verify": null,
  173. })
  174. await this.git.checkout(this.mainBranch)
  175. if (pendingChanges) {
  176. await this.popStash()
  177. }
  178. this.currentCheckpoint = commit.commit
  179. return commit
  180. } catch (err) {
  181. this.log(`[saveCheckpoint] Failed to save checkpoint: ${err instanceof Error ? err.message : String(err)}`)
  182. // If we're not on the main branch then we need to trigger a reset
  183. // to return to the main branch and restore it's previous state.
  184. const currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"])
  185. if (currentBranch.trim() !== this.mainBranch) {
  186. await reset()
  187. }
  188. throw err
  189. }
  190. }
  191. public async restoreCheckpoint(commitHash: string) {
  192. await this.ensureBranch(this.mainBranch)
  193. await this.git.clean([CleanOptions.FORCE, CleanOptions.RECURSIVE])
  194. await this.git.raw(["restore", "--source", commitHash, "--worktree", "--", "."])
  195. this.currentCheckpoint = commitHash
  196. }
  197. public static async create({ taskId, git, baseDir, log = console.log }: CheckpointServiceOptions) {
  198. if (process.platform === "win32") {
  199. throw new Error("Checkpoints are not supported on Windows.")
  200. }
  201. git = git || simpleGit({ baseDir })
  202. const version = await git.version()
  203. if (!version?.installed) {
  204. throw new Error(`Git is not installed. Please install Git if you wish to use checkpoints.`)
  205. }
  206. if (!baseDir || !existsSync(baseDir)) {
  207. throw new Error(`Base directory is not set or does not exist.`)
  208. }
  209. const { currentBranch, currentSha, hiddenBranch } = await CheckpointService.initRepo({
  210. taskId,
  211. git,
  212. baseDir,
  213. log,
  214. })
  215. log(
  216. `[CheckpointService] taskId = ${taskId}, baseDir = ${baseDir}, currentBranch = ${currentBranch}, currentSha = ${currentSha}, hiddenBranch = ${hiddenBranch}`,
  217. )
  218. return new CheckpointService(taskId, git, baseDir, currentBranch, currentSha, hiddenBranch, log)
  219. }
  220. private static async initRepo({ taskId, git, baseDir, log }: Required<CheckpointServiceOptions>) {
  221. const isExistingRepo = existsSync(path.join(baseDir, ".git"))
  222. if (!isExistingRepo) {
  223. await git.init()
  224. log(`[initRepo] Initialized new Git repository at ${baseDir}`)
  225. }
  226. const globalUserName = await git.getConfig("user.name", "global")
  227. const localUserName = await git.getConfig("user.name", "local")
  228. const userName = localUserName.value || globalUserName.value
  229. const globalUserEmail = await git.getConfig("user.email", "global")
  230. const localUserEmail = await git.getConfig("user.email", "local")
  231. const userEmail = localUserEmail.value || globalUserEmail.value
  232. // Prior versions of this service indiscriminately set the local user
  233. // config, and it should not override the global config. To address
  234. // this we remove the local user config if it matches the default
  235. // user name and email and there's a global config.
  236. if (globalUserName.value && localUserName.value === CheckpointService.USER_NAME) {
  237. await git.raw(["config", "--unset", "--local", "user.name"])
  238. }
  239. if (globalUserEmail.value && localUserEmail.value === CheckpointService.USER_EMAIL) {
  240. await git.raw(["config", "--unset", "--local", "user.email"])
  241. }
  242. // Only set user config if not already configured.
  243. if (!userName) {
  244. await git.addConfig("user.name", CheckpointService.USER_NAME)
  245. }
  246. if (!userEmail) {
  247. await git.addConfig("user.email", CheckpointService.USER_EMAIL)
  248. }
  249. if (!isExistingRepo) {
  250. // We need at least one file to commit, otherwise the initial
  251. // commit will fail, unless we use the `--allow-empty` flag.
  252. // However, using an empty commit causes problems when restoring
  253. // the checkpoint (i.e. the `git restore` command doesn't work
  254. // for empty commits).
  255. await fs.writeFile(path.join(baseDir, ".gitkeep"), "")
  256. await git.add(".gitkeep")
  257. const commit = await git.commit("Initial commit")
  258. if (!commit.commit) {
  259. throw new Error("Failed to create initial commit")
  260. }
  261. log(`[initRepo] Initial commit: ${commit.commit}`)
  262. }
  263. const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
  264. const currentSha = await git.revparse(["HEAD"])
  265. const hiddenBranch = `roo-code-checkpoints-${taskId}`
  266. const branchSummary = await git.branch()
  267. if (!branchSummary.all.includes(hiddenBranch)) {
  268. await git.checkoutBranch(hiddenBranch, currentBranch) // git checkout -b <hiddenBranch> <currentBranch>
  269. await git.checkout(currentBranch) // git checkout <currentBranch>
  270. }
  271. return { currentBranch, currentSha, hiddenBranch }
  272. }
  273. }