ShadowCheckpointService.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. import fs from "fs/promises"
  2. import os from "os"
  3. import * as path from "path"
  4. import { globby } from "globby"
  5. import simpleGit, { SimpleGit } from "simple-git"
  6. import { GIT_DISABLED_SUFFIX, GIT_EXCLUDES } from "./constants"
  7. import { CheckpointStrategy, CheckpointService, CheckpointServiceOptions } from "./types"
  8. export interface ShadowCheckpointServiceOptions extends CheckpointServiceOptions {
  9. shadowDir: string
  10. }
  11. export class ShadowCheckpointService implements CheckpointService {
  12. public readonly strategy: CheckpointStrategy = "shadow"
  13. public readonly version = 1
  14. private _baseHash?: string
  15. public get baseHash() {
  16. return this._baseHash
  17. }
  18. private set baseHash(value: string | undefined) {
  19. this._baseHash = value
  20. }
  21. private readonly shadowGitDir: string
  22. private shadowGitConfigWorktree?: string
  23. private constructor(
  24. public readonly taskId: string,
  25. public readonly git: SimpleGit,
  26. public readonly shadowDir: string,
  27. public readonly workspaceDir: string,
  28. private readonly log: (message: string) => void,
  29. ) {
  30. this.shadowGitDir = path.join(this.shadowDir, "tasks", this.taskId, "checkpoints", ".git")
  31. }
  32. private async initShadowGit() {
  33. const fileExistsAtPath = (path: string) =>
  34. fs
  35. .access(path)
  36. .then(() => true)
  37. .catch(() => false)
  38. if (await fileExistsAtPath(this.shadowGitDir)) {
  39. this.log(`[initShadowGit] shadow git repo already exists at ${this.shadowGitDir}`)
  40. const worktree = await this.getShadowGitConfigWorktree()
  41. if (worktree !== this.workspaceDir) {
  42. throw new Error(
  43. `Checkpoints can only be used in the original workspace: ${worktree} !== ${this.workspaceDir}`,
  44. )
  45. }
  46. this.baseHash = await this.git.revparse(["--abbrev-ref", "HEAD"])
  47. } else {
  48. this.log(`[initShadowGit] creating shadow git repo at ${this.workspaceDir}`)
  49. await this.git.init()
  50. await this.git.addConfig("core.worktree", this.workspaceDir) // Sets the working tree to the current workspace.
  51. await this.git.addConfig("commit.gpgSign", "false") // Disable commit signing for shadow repo.
  52. await this.git.addConfig("user.name", "Roo Code")
  53. await this.git.addConfig("user.email", "[email protected]")
  54. let lfsPatterns: string[] = [] // Get LFS patterns from workspace if they exist.
  55. try {
  56. const attributesPath = path.join(this.workspaceDir, ".gitattributes")
  57. if (await fileExistsAtPath(attributesPath)) {
  58. lfsPatterns = (await fs.readFile(attributesPath, "utf8"))
  59. .split("\n")
  60. .filter((line) => line.includes("filter=lfs"))
  61. .map((line) => line.split(" ")[0].trim())
  62. }
  63. } catch (error) {
  64. this.log(
  65. `[initShadowGit] failed to read .gitattributes: ${error instanceof Error ? error.message : String(error)}`,
  66. )
  67. }
  68. // Add basic excludes directly in git config, while respecting any
  69. // .gitignore in the workspace.
  70. // .git/info/exclude is local to the shadow git repo, so it's not
  71. // shared with the main repo - and won't conflict with user's
  72. // .gitignore.
  73. await fs.mkdir(path.join(this.shadowGitDir, "info"), { recursive: true })
  74. const excludesPath = path.join(this.shadowGitDir, "info", "exclude")
  75. await fs.writeFile(excludesPath, [...GIT_EXCLUDES, ...lfsPatterns].join("\n"))
  76. await this.stageAll()
  77. const { commit } = await this.git.commit("initial commit", { "--allow-empty": null })
  78. this.baseHash = commit
  79. this.log(`[initShadowGit] base commit is ${commit}`)
  80. }
  81. }
  82. private async stageAll() {
  83. await this.renameNestedGitRepos(true)
  84. try {
  85. await this.git.add(".")
  86. } catch (error) {
  87. this.log(`[stageAll] failed to add files to git: ${error instanceof Error ? error.message : String(error)}`)
  88. } finally {
  89. await this.renameNestedGitRepos(false)
  90. }
  91. }
  92. // Since we use git to track checkpoints, we need to temporarily disable
  93. // nested git repos to work around git's requirement of using submodules for
  94. // nested repos.
  95. private async renameNestedGitRepos(disable: boolean) {
  96. // Find all .git directories that are not at the root level.
  97. const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
  98. cwd: this.workspaceDir,
  99. onlyDirectories: true,
  100. ignore: [".git"], // Ignore root level .git.
  101. dot: true,
  102. markDirectories: false,
  103. })
  104. // For each nested .git directory, rename it based on operation.
  105. for (const gitPath of gitPaths) {
  106. const fullPath = path.join(this.workspaceDir, gitPath)
  107. let newPath: string
  108. if (disable) {
  109. newPath = fullPath + GIT_DISABLED_SUFFIX
  110. } else {
  111. newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX)
  112. ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length)
  113. : fullPath
  114. }
  115. try {
  116. await fs.rename(fullPath, newPath)
  117. this.log(`${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
  118. } catch (error) {
  119. this.log(
  120. `failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}: ${error instanceof Error ? error.message : String(error)}`,
  121. )
  122. }
  123. }
  124. }
  125. public async getShadowGitConfigWorktree() {
  126. if (!this.shadowGitConfigWorktree) {
  127. try {
  128. this.shadowGitConfigWorktree = (await this.git.getConfig("core.worktree")).value || undefined
  129. } catch (error) {
  130. this.log(
  131. `[getShadowGitConfigWorktree] failed to get core.worktree: ${error instanceof Error ? error.message : String(error)}`,
  132. )
  133. }
  134. }
  135. return this.shadowGitConfigWorktree
  136. }
  137. public async saveCheckpoint(message: string) {
  138. try {
  139. const startTime = Date.now()
  140. await this.stageAll()
  141. const result = await this.git.commit(message)
  142. if (result.commit) {
  143. const duration = Date.now() - startTime
  144. this.log(`[saveCheckpoint] saved checkpoint ${result.commit} in ${duration}ms`)
  145. return result
  146. } else {
  147. return undefined
  148. }
  149. } catch (error) {
  150. this.log(
  151. `[saveCheckpoint] failed to create checkpoint: ${error instanceof Error ? error.message : String(error)}`,
  152. )
  153. throw error
  154. }
  155. }
  156. public async restoreCheckpoint(commitHash: string) {
  157. const start = Date.now()
  158. await this.git.clean("f", ["-d", "-f"])
  159. await this.git.reset(["--hard", commitHash])
  160. const duration = Date.now() - start
  161. this.log(`[restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`)
  162. }
  163. public async getDiff({ from, to }: { from?: string; to?: string }) {
  164. const result = []
  165. if (!from) {
  166. from = (await this.git.raw(["rev-list", "--max-parents=0", "HEAD"])).trim()
  167. }
  168. // Stage all changes so that untracked files appear in diff summary.
  169. await this.stageAll()
  170. const { files } = to ? await this.git.diffSummary([`${from}..${to}`]) : await this.git.diffSummary([from])
  171. const cwdPath = (await this.getShadowGitConfigWorktree()) || this.workspaceDir || ""
  172. for (const file of files) {
  173. const relPath = file.file
  174. const absPath = path.join(cwdPath, relPath)
  175. const before = await this.git.show([`${from}:${relPath}`]).catch(() => "")
  176. const after = to
  177. ? await this.git.show([`${to}:${relPath}`]).catch(() => "")
  178. : await fs.readFile(absPath, "utf8").catch(() => "")
  179. result.push({ paths: { relative: relPath, absolute: absPath }, content: { before, after } })
  180. }
  181. return result
  182. }
  183. public static async create({ taskId, shadowDir, workspaceDir, log = console.log }: ShadowCheckpointServiceOptions) {
  184. try {
  185. await simpleGit().version()
  186. } catch (error) {
  187. throw new Error("Git must be installed to use checkpoints.")
  188. }
  189. const homedir = os.homedir()
  190. const desktopPath = path.join(homedir, "Desktop")
  191. const documentsPath = path.join(homedir, "Documents")
  192. const downloadsPath = path.join(homedir, "Downloads")
  193. const protectedPaths = [homedir, desktopPath, documentsPath, downloadsPath]
  194. if (protectedPaths.includes(workspaceDir)) {
  195. throw new Error(`Cannot use checkpoints in ${workspaceDir}`)
  196. }
  197. const checkpointsDir = path.join(shadowDir, "tasks", taskId, "checkpoints")
  198. await fs.mkdir(checkpointsDir, { recursive: true })
  199. const gitDir = path.join(checkpointsDir, ".git")
  200. const git = simpleGit(path.dirname(gitDir))
  201. log(`[create] taskId = ${taskId}, workspaceDir = ${workspaceDir}, shadowDir = ${shadowDir}`)
  202. const service = new ShadowCheckpointService(taskId, git, shadowDir, workspaceDir, log)
  203. await service.initShadowGit()
  204. return service
  205. }
  206. }