ShadowCheckpointService.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. import fs from "fs/promises"
  2. import os from "os"
  3. import * as path from "path"
  4. import EventEmitter from "events"
  5. import simpleGit, { SimpleGit } from "simple-git"
  6. import { globby } from "globby"
  7. import { fileExistsAtPath } from "../../utils/fs"
  8. import { GIT_DISABLED_SUFFIX } from "./constants"
  9. import { CheckpointDiff, CheckpointResult, CheckpointEventMap } from "./types"
  10. import { getExcludePatterns } from "./excludes"
  11. export abstract class ShadowCheckpointService extends EventEmitter {
  12. public readonly taskId: string
  13. public readonly checkpointsDir: string
  14. public readonly workspaceDir: string
  15. protected _checkpoints: string[] = []
  16. protected _baseHash?: string
  17. protected readonly dotGitDir: string
  18. protected git?: SimpleGit
  19. protected readonly log: (message: string) => void
  20. protected shadowGitConfigWorktree?: string
  21. public get baseHash() {
  22. return this._baseHash
  23. }
  24. protected set baseHash(value: string | undefined) {
  25. this._baseHash = value
  26. }
  27. public get isInitialized() {
  28. return !!this.git
  29. }
  30. constructor(taskId: string, checkpointsDir: string, workspaceDir: string, log: (message: string) => void) {
  31. super()
  32. const homedir = os.homedir()
  33. const desktopPath = path.join(homedir, "Desktop")
  34. const documentsPath = path.join(homedir, "Documents")
  35. const downloadsPath = path.join(homedir, "Downloads")
  36. const protectedPaths = [homedir, desktopPath, documentsPath, downloadsPath]
  37. if (protectedPaths.includes(workspaceDir)) {
  38. throw new Error(`Cannot use checkpoints in ${workspaceDir}`)
  39. }
  40. this.taskId = taskId
  41. this.checkpointsDir = checkpointsDir
  42. this.workspaceDir = workspaceDir
  43. this.dotGitDir = path.join(this.checkpointsDir, ".git")
  44. this.log = log
  45. }
  46. public async initShadowGit(onInit?: () => Promise<void>) {
  47. if (this.git) {
  48. throw new Error("Shadow git repo already initialized")
  49. }
  50. await fs.mkdir(this.checkpointsDir, { recursive: true })
  51. const git = simpleGit(this.checkpointsDir)
  52. const gitVersion = await git.version()
  53. this.log(`[${this.constructor.name}#create] git = ${gitVersion}`)
  54. let created = false
  55. const startTime = Date.now()
  56. if (await fileExistsAtPath(this.dotGitDir)) {
  57. this.log(`[${this.constructor.name}#initShadowGit] shadow git repo already exists at ${this.dotGitDir}`)
  58. const worktree = await this.getShadowGitConfigWorktree(git)
  59. if (worktree !== this.workspaceDir) {
  60. throw new Error(
  61. `Checkpoints can only be used in the original workspace: ${worktree} !== ${this.workspaceDir}`,
  62. )
  63. }
  64. await this.writeExcludeFile()
  65. this.baseHash = await git.revparse(["HEAD"])
  66. } else {
  67. this.log(`[${this.constructor.name}#initShadowGit] creating shadow git repo at ${this.checkpointsDir}`)
  68. await git.init()
  69. await git.addConfig("core.worktree", this.workspaceDir) // Sets the working tree to the current workspace.
  70. await git.addConfig("commit.gpgSign", "false") // Disable commit signing for shadow repo.
  71. await git.addConfig("user.name", "Roo Code")
  72. await git.addConfig("user.email", "[email protected]")
  73. await this.writeExcludeFile()
  74. await this.stageAll(git)
  75. const { commit } = await git.commit("initial commit", { "--allow-empty": null })
  76. this.baseHash = commit
  77. created = true
  78. }
  79. const duration = Date.now() - startTime
  80. this.log(
  81. `[${this.constructor.name}#initShadowGit] initialized shadow repo with base commit ${this.baseHash} in ${duration}ms`,
  82. )
  83. this.git = git
  84. await onInit?.()
  85. this.emit("initialize", {
  86. type: "initialize",
  87. workspaceDir: this.workspaceDir,
  88. baseHash: this.baseHash,
  89. created,
  90. duration,
  91. })
  92. return { created, duration }
  93. }
  94. // Add basic excludes directly in git config, while respecting any
  95. // .gitignore in the workspace.
  96. // .git/info/exclude is local to the shadow git repo, so it's not
  97. // shared with the main repo - and won't conflict with user's
  98. // .gitignore.
  99. protected async writeExcludeFile() {
  100. await fs.mkdir(path.join(this.dotGitDir, "info"), { recursive: true })
  101. const patterns = await getExcludePatterns(this.workspaceDir)
  102. await fs.writeFile(path.join(this.dotGitDir, "info", "exclude"), patterns.join("\n"))
  103. }
  104. private async stageAll(git: SimpleGit) {
  105. await this.renameNestedGitRepos(true)
  106. try {
  107. await git.add(".")
  108. } catch (error) {
  109. this.log(
  110. `[${this.constructor.name}#stageAll] failed to add files to git: ${error instanceof Error ? error.message : String(error)}`,
  111. )
  112. } finally {
  113. await this.renameNestedGitRepos(false)
  114. }
  115. }
  116. // Since we use git to track checkpoints, we need to temporarily disable
  117. // nested git repos to work around git's requirement of using submodules for
  118. // nested repos.
  119. private async renameNestedGitRepos(disable: boolean) {
  120. // Find all .git directories that are not at the root level.
  121. const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
  122. cwd: this.workspaceDir,
  123. onlyDirectories: true,
  124. ignore: [".git"], // Ignore root level .git.
  125. dot: true,
  126. markDirectories: false,
  127. })
  128. // For each nested .git directory, rename it based on operation.
  129. for (const gitPath of gitPaths) {
  130. const fullPath = path.join(this.workspaceDir, gitPath)
  131. let newPath: string
  132. if (disable) {
  133. newPath = fullPath + GIT_DISABLED_SUFFIX
  134. } else {
  135. newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX)
  136. ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length)
  137. : fullPath
  138. }
  139. try {
  140. await fs.rename(fullPath, newPath)
  141. this.log(
  142. `[${this.constructor.name}#renameNestedGitRepos] ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`,
  143. )
  144. } catch (error) {
  145. this.log(
  146. `[${this.constructor.name}#renameNestedGitRepos] failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}: ${error instanceof Error ? error.message : String(error)}`,
  147. )
  148. }
  149. }
  150. }
  151. private async getShadowGitConfigWorktree(git: SimpleGit) {
  152. if (!this.shadowGitConfigWorktree) {
  153. try {
  154. this.shadowGitConfigWorktree = (await git.getConfig("core.worktree")).value || undefined
  155. } catch (error) {
  156. this.log(
  157. `[${this.constructor.name}#getShadowGitConfigWorktree] failed to get core.worktree: ${error instanceof Error ? error.message : String(error)}`,
  158. )
  159. }
  160. }
  161. return this.shadowGitConfigWorktree
  162. }
  163. public async saveCheckpoint(message: string): Promise<CheckpointResult | undefined> {
  164. try {
  165. this.log(`[${this.constructor.name}#saveCheckpoint] starting checkpoint save`)
  166. if (!this.git) {
  167. throw new Error("Shadow git repo not initialized")
  168. }
  169. const startTime = Date.now()
  170. await this.stageAll(this.git)
  171. const result = await this.git.commit(message)
  172. const isFirst = this._checkpoints.length === 0
  173. const fromHash = this._checkpoints[this._checkpoints.length - 1] ?? this.baseHash!
  174. const toHash = result.commit || fromHash
  175. this._checkpoints.push(toHash)
  176. const duration = Date.now() - startTime
  177. if (isFirst || result.commit) {
  178. this.emit("checkpoint", { type: "checkpoint", isFirst, fromHash, toHash, duration })
  179. }
  180. if (result.commit) {
  181. this.log(
  182. `[${this.constructor.name}#saveCheckpoint] checkpoint saved in ${duration}ms -> ${result.commit}`,
  183. )
  184. return result
  185. } else {
  186. this.log(`[${this.constructor.name}#saveCheckpoint] found no changes to commit in ${duration}ms`)
  187. return undefined
  188. }
  189. } catch (e) {
  190. const error = e instanceof Error ? e : new Error(String(e))
  191. this.log(`[${this.constructor.name}#saveCheckpoint] failed to create checkpoint: ${error.message}`)
  192. this.emit("error", { type: "error", error })
  193. throw error
  194. }
  195. }
  196. public async restoreCheckpoint(commitHash: string) {
  197. try {
  198. this.log(`[${this.constructor.name}#restoreCheckpoint] starting checkpoint restore`)
  199. if (!this.git) {
  200. throw new Error("Shadow git repo not initialized")
  201. }
  202. const start = Date.now()
  203. await this.git.clean("f", ["-d", "-f"])
  204. await this.git.reset(["--hard", commitHash])
  205. // Remove all checkpoints after the specified commitHash.
  206. const checkpointIndex = this._checkpoints.indexOf(commitHash)
  207. if (checkpointIndex !== -1) {
  208. this._checkpoints = this._checkpoints.slice(0, checkpointIndex + 1)
  209. }
  210. const duration = Date.now() - start
  211. this.emit("restore", { type: "restore", commitHash, duration })
  212. this.log(`[${this.constructor.name}#restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`)
  213. } catch (e) {
  214. const error = e instanceof Error ? e : new Error(String(e))
  215. this.log(`[${this.constructor.name}#restoreCheckpoint] failed to restore checkpoint: ${error.message}`)
  216. this.emit("error", { type: "error", error })
  217. throw error
  218. }
  219. }
  220. public async getDiff({ from, to }: { from?: string; to?: string }): Promise<CheckpointDiff[]> {
  221. if (!this.git) {
  222. throw new Error("Shadow git repo not initialized")
  223. }
  224. const result = []
  225. if (!from) {
  226. from = (await this.git.raw(["rev-list", "--max-parents=0", "HEAD"])).trim()
  227. }
  228. // Stage all changes so that untracked files appear in diff summary.
  229. await this.stageAll(this.git)
  230. this.log(`[${this.constructor.name}#getDiff] diffing ${to ? `${from}..${to}` : `${from}..HEAD`}`)
  231. const { files } = to ? await this.git.diffSummary([`${from}..${to}`]) : await this.git.diffSummary([from])
  232. const cwdPath = (await this.getShadowGitConfigWorktree(this.git)) || this.workspaceDir || ""
  233. for (const file of files) {
  234. const relPath = file.file
  235. const absPath = path.join(cwdPath, relPath)
  236. const before = await this.git.show([`${from}:${relPath}`]).catch(() => "")
  237. const after = to
  238. ? await this.git.show([`${to}:${relPath}`]).catch(() => "")
  239. : await fs.readFile(absPath, "utf8").catch(() => "")
  240. result.push({ paths: { relative: relPath, absolute: absPath }, content: { before, after } })
  241. }
  242. return result
  243. }
  244. /**
  245. * EventEmitter
  246. */
  247. override emit<K extends keyof CheckpointEventMap>(event: K, data: CheckpointEventMap[K]) {
  248. return super.emit(event, data)
  249. }
  250. override on<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
  251. return super.on(event, listener)
  252. }
  253. override off<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
  254. return super.off(event, listener)
  255. }
  256. override once<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
  257. return super.once(event, listener)
  258. }
  259. }