ShadowCheckpointService.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. import fs from "fs/promises"
  2. import os from "os"
  3. import * as path from "path"
  4. import crypto from "crypto"
  5. import EventEmitter from "events"
  6. import simpleGit, { SimpleGit } from "simple-git"
  7. import pWaitFor from "p-wait-for"
  8. import { fileExistsAtPath } from "../../utils/fs"
  9. import { executeRipgrep } from "../../services/search/file-search"
  10. import { CheckpointDiff, CheckpointResult, CheckpointEventMap } from "./types"
  11. import { getExcludePatterns } from "./excludes"
  12. export abstract class ShadowCheckpointService extends EventEmitter {
  13. public readonly taskId: string
  14. public readonly checkpointsDir: string
  15. public readonly workspaceDir: string
  16. protected _checkpoints: string[] = []
  17. protected _baseHash?: string
  18. protected readonly dotGitDir: string
  19. protected git?: SimpleGit
  20. protected readonly log: (message: string) => void
  21. protected shadowGitConfigWorktree?: string
  22. public get baseHash() {
  23. return this._baseHash
  24. }
  25. protected set baseHash(value: string | undefined) {
  26. this._baseHash = value
  27. }
  28. public get isInitialized() {
  29. return !!this.git
  30. }
  31. constructor(taskId: string, checkpointsDir: string, workspaceDir: string, log: (message: string) => void) {
  32. super()
  33. const homedir = os.homedir()
  34. const desktopPath = path.join(homedir, "Desktop")
  35. const documentsPath = path.join(homedir, "Documents")
  36. const downloadsPath = path.join(homedir, "Downloads")
  37. const protectedPaths = [homedir, desktopPath, documentsPath, downloadsPath]
  38. if (protectedPaths.includes(workspaceDir)) {
  39. throw new Error(`Cannot use checkpoints in ${workspaceDir}`)
  40. }
  41. this.taskId = taskId
  42. this.checkpointsDir = checkpointsDir
  43. this.workspaceDir = workspaceDir
  44. this.dotGitDir = path.join(this.checkpointsDir, ".git")
  45. this.log = log
  46. }
  47. public async initShadowGit(onInit?: () => Promise<void>) {
  48. if (this.git) {
  49. throw new Error("Shadow git repo already initialized")
  50. }
  51. const hasNestedGitRepos = await this.hasNestedGitRepositories()
  52. if (hasNestedGitRepos) {
  53. throw new Error(
  54. "Checkpoints are disabled because nested git repositories were detected in the workspace. " +
  55. "Please remove or relocate nested git repositories to use the checkpoints feature.",
  56. )
  57. }
  58. await fs.mkdir(this.checkpointsDir, { recursive: true })
  59. const git = simpleGit(this.checkpointsDir)
  60. const gitVersion = await git.version()
  61. this.log(`[${this.constructor.name}#create] git = ${gitVersion}`)
  62. let created = false
  63. const startTime = Date.now()
  64. if (await fileExistsAtPath(this.dotGitDir)) {
  65. this.log(`[${this.constructor.name}#initShadowGit] shadow git repo already exists at ${this.dotGitDir}`)
  66. const worktree = await this.getShadowGitConfigWorktree(git)
  67. if (worktree !== this.workspaceDir) {
  68. throw new Error(
  69. `Checkpoints can only be used in the original workspace: ${worktree} !== ${this.workspaceDir}`,
  70. )
  71. }
  72. await this.writeExcludeFile()
  73. this.baseHash = await git.revparse(["HEAD"])
  74. } else {
  75. this.log(`[${this.constructor.name}#initShadowGit] creating shadow git repo at ${this.checkpointsDir}`)
  76. await git.init()
  77. await git.addConfig("core.worktree", this.workspaceDir) // Sets the working tree to the current workspace.
  78. await git.addConfig("commit.gpgSign", "false") // Disable commit signing for shadow repo.
  79. await git.addConfig("user.name", "Roo Code")
  80. await git.addConfig("user.email", "[email protected]")
  81. await this.writeExcludeFile()
  82. await this.stageAll(git)
  83. const { commit } = await git.commit("initial commit", { "--allow-empty": null })
  84. this.baseHash = commit
  85. created = true
  86. }
  87. const duration = Date.now() - startTime
  88. this.log(
  89. `[${this.constructor.name}#initShadowGit] initialized shadow repo with base commit ${this.baseHash} in ${duration}ms`,
  90. )
  91. this.git = git
  92. await onInit?.()
  93. this.emit("initialize", {
  94. type: "initialize",
  95. workspaceDir: this.workspaceDir,
  96. baseHash: this.baseHash,
  97. created,
  98. duration,
  99. })
  100. return { created, duration }
  101. }
  102. // Add basic excludes directly in git config, while respecting any
  103. // .gitignore in the workspace.
  104. // .git/info/exclude is local to the shadow git repo, so it's not
  105. // shared with the main repo - and won't conflict with user's
  106. // .gitignore.
  107. protected async writeExcludeFile() {
  108. await fs.mkdir(path.join(this.dotGitDir, "info"), { recursive: true })
  109. const patterns = await getExcludePatterns(this.workspaceDir)
  110. await fs.writeFile(path.join(this.dotGitDir, "info", "exclude"), patterns.join("\n"))
  111. }
  112. private async stageAll(git: SimpleGit) {
  113. try {
  114. await git.add(".")
  115. } catch (error) {
  116. this.log(
  117. `[${this.constructor.name}#stageAll] failed to add files to git: ${error instanceof Error ? error.message : String(error)}`,
  118. )
  119. }
  120. }
  121. private async hasNestedGitRepositories(): Promise<boolean> {
  122. try {
  123. // Find all .git directories that are not at the root level.
  124. const args = ["--files", "--hidden", "--follow", "-g", "**/.git/HEAD", this.workspaceDir]
  125. const gitPaths = await executeRipgrep({ args, workspacePath: this.workspaceDir })
  126. // Filter to only include nested git directories (not the root .git).
  127. const nestedGitPaths = gitPaths.filter(
  128. ({ type, path }) =>
  129. type === "folder" && path.includes(".git") && !path.startsWith(".git") && path !== ".git",
  130. )
  131. if (nestedGitPaths.length > 0) {
  132. this.log(
  133. `[${this.constructor.name}#hasNestedGitRepositories] found ${nestedGitPaths.length} nested git repositories: ${nestedGitPaths.map((p) => p.path).join(", ")}`,
  134. )
  135. return true
  136. }
  137. return false
  138. } catch (error) {
  139. this.log(
  140. `[${this.constructor.name}#hasNestedGitRepositories] failed to check for nested git repos: ${error instanceof Error ? error.message : String(error)}`,
  141. )
  142. // If we can't check, assume there are no nested repos to avoid blocking the feature.
  143. return false
  144. }
  145. }
  146. private async getShadowGitConfigWorktree(git: SimpleGit) {
  147. if (!this.shadowGitConfigWorktree) {
  148. try {
  149. this.shadowGitConfigWorktree = (await git.getConfig("core.worktree")).value || undefined
  150. } catch (error) {
  151. this.log(
  152. `[${this.constructor.name}#getShadowGitConfigWorktree] failed to get core.worktree: ${error instanceof Error ? error.message : String(error)}`,
  153. )
  154. }
  155. }
  156. return this.shadowGitConfigWorktree
  157. }
  158. public async saveCheckpoint(
  159. message: string,
  160. options?: { allowEmpty?: boolean },
  161. ): Promise<CheckpointResult | undefined> {
  162. try {
  163. this.log(
  164. `[${this.constructor.name}#saveCheckpoint] starting checkpoint save (allowEmpty: ${options?.allowEmpty ?? false})`,
  165. )
  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 commitArgs = options?.allowEmpty ? { "--allow-empty": null } : undefined
  172. const result = await this.git.commit(message, commitArgs)
  173. const isFirst = this._checkpoints.length === 0
  174. const fromHash = this._checkpoints[this._checkpoints.length - 1] ?? this.baseHash!
  175. const toHash = result.commit || fromHash
  176. this._checkpoints.push(toHash)
  177. const duration = Date.now() - startTime
  178. if (isFirst || result.commit) {
  179. this.emit("checkpoint", { type: "checkpoint", isFirst, fromHash, toHash, duration })
  180. }
  181. if (result.commit) {
  182. this.log(
  183. `[${this.constructor.name}#saveCheckpoint] checkpoint saved in ${duration}ms -> ${result.commit}`,
  184. )
  185. return result
  186. } else {
  187. this.log(`[${this.constructor.name}#saveCheckpoint] found no changes to commit in ${duration}ms`)
  188. return undefined
  189. }
  190. } catch (e) {
  191. const error = e instanceof Error ? e : new Error(String(e))
  192. this.log(`[${this.constructor.name}#saveCheckpoint] failed to create checkpoint: ${error.message}`)
  193. this.emit("error", { type: "error", error })
  194. throw error
  195. }
  196. }
  197. public async restoreCheckpoint(commitHash: string) {
  198. try {
  199. this.log(`[${this.constructor.name}#restoreCheckpoint] starting checkpoint restore`)
  200. if (!this.git) {
  201. throw new Error("Shadow git repo not initialized")
  202. }
  203. const start = Date.now()
  204. await this.git.clean("f", ["-d", "-f"])
  205. await this.git.reset(["--hard", commitHash])
  206. // Remove all checkpoints after the specified commitHash.
  207. const checkpointIndex = this._checkpoints.indexOf(commitHash)
  208. if (checkpointIndex !== -1) {
  209. this._checkpoints = this._checkpoints.slice(0, checkpointIndex + 1)
  210. }
  211. const duration = Date.now() - start
  212. this.emit("restore", { type: "restore", commitHash, duration })
  213. this.log(`[${this.constructor.name}#restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`)
  214. } catch (e) {
  215. const error = e instanceof Error ? e : new Error(String(e))
  216. this.log(`[${this.constructor.name}#restoreCheckpoint] failed to restore checkpoint: ${error.message}`)
  217. this.emit("error", { type: "error", error })
  218. throw error
  219. }
  220. }
  221. public async getDiff({ from, to }: { from?: string; to?: string }): Promise<CheckpointDiff[]> {
  222. if (!this.git) {
  223. throw new Error("Shadow git repo not initialized")
  224. }
  225. const result = []
  226. if (!from) {
  227. from = (await this.git.raw(["rev-list", "--max-parents=0", "HEAD"])).trim()
  228. }
  229. // Stage all changes so that untracked files appear in diff summary.
  230. await this.stageAll(this.git)
  231. this.log(`[${this.constructor.name}#getDiff] diffing ${to ? `${from}..${to}` : `${from}..HEAD`}`)
  232. const { files } = to ? await this.git.diffSummary([`${from}..${to}`]) : await this.git.diffSummary([from])
  233. const cwdPath = (await this.getShadowGitConfigWorktree(this.git)) || this.workspaceDir || ""
  234. for (const file of files) {
  235. const relPath = file.file
  236. const absPath = path.join(cwdPath, relPath)
  237. const before = await this.git.show([`${from}:${relPath}`]).catch(() => "")
  238. const after = to
  239. ? await this.git.show([`${to}:${relPath}`]).catch(() => "")
  240. : await fs.readFile(absPath, "utf8").catch(() => "")
  241. result.push({ paths: { relative: relPath, absolute: absPath }, content: { before, after } })
  242. }
  243. return result
  244. }
  245. /**
  246. * EventEmitter
  247. */
  248. override emit<K extends keyof CheckpointEventMap>(event: K, data: CheckpointEventMap[K]) {
  249. return super.emit(event, data)
  250. }
  251. override on<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
  252. return super.on(event, listener)
  253. }
  254. override off<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
  255. return super.off(event, listener)
  256. }
  257. override once<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
  258. return super.once(event, listener)
  259. }
  260. /**
  261. * Storage
  262. */
  263. public static hashWorkspaceDir(workspaceDir: string) {
  264. return crypto.createHash("sha256").update(workspaceDir).digest("hex").toString().slice(0, 8)
  265. }
  266. protected static taskRepoDir({ taskId, globalStorageDir }: { taskId: string; globalStorageDir: string }) {
  267. return path.join(globalStorageDir, "tasks", taskId, "checkpoints")
  268. }
  269. protected static workspaceRepoDir({
  270. globalStorageDir,
  271. workspaceDir,
  272. }: {
  273. globalStorageDir: string
  274. workspaceDir: string
  275. }) {
  276. return path.join(globalStorageDir, "checkpoints", this.hashWorkspaceDir(workspaceDir))
  277. }
  278. public static async deleteTask({
  279. taskId,
  280. globalStorageDir,
  281. workspaceDir,
  282. }: {
  283. taskId: string
  284. globalStorageDir: string
  285. workspaceDir: string
  286. }) {
  287. const workspaceRepoDir = this.workspaceRepoDir({ globalStorageDir, workspaceDir })
  288. const branchName = `roo-${taskId}`
  289. const git = simpleGit(workspaceRepoDir)
  290. const success = await this.deleteBranch(git, branchName)
  291. if (success) {
  292. console.log(`[${this.name}#deleteTask.${taskId}] deleted branch ${branchName}`)
  293. } else {
  294. console.error(`[${this.name}#deleteTask.${taskId}] failed to delete branch ${branchName}`)
  295. }
  296. }
  297. public static async deleteBranch(git: SimpleGit, branchName: string) {
  298. const branches = await git.branchLocal()
  299. if (!branches.all.includes(branchName)) {
  300. console.error(`[${this.constructor.name}#deleteBranch] branch ${branchName} does not exist`)
  301. return false
  302. }
  303. const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
  304. if (currentBranch === branchName) {
  305. const worktree = await git.getConfig("core.worktree")
  306. try {
  307. await git.raw(["config", "--unset", "core.worktree"])
  308. await git.reset(["--hard"])
  309. await git.clean("f", ["-d"])
  310. const defaultBranch = branches.all.includes("main") ? "main" : "master"
  311. await git.checkout([defaultBranch, "--force"])
  312. await pWaitFor(
  313. async () => {
  314. const newBranch = await git.revparse(["--abbrev-ref", "HEAD"])
  315. return newBranch === defaultBranch
  316. },
  317. { interval: 500, timeout: 2_000 },
  318. )
  319. await git.branch(["-D", branchName])
  320. return true
  321. } catch (error) {
  322. console.error(
  323. `[${this.constructor.name}#deleteBranch] failed to delete branch ${branchName}: ${error instanceof Error ? error.message : String(error)}`,
  324. )
  325. return false
  326. } finally {
  327. if (worktree.value) {
  328. await git.addConfig("core.worktree", worktree.value)
  329. }
  330. }
  331. } else {
  332. await git.branch(["-D", branchName])
  333. return true
  334. }
  335. }
  336. }