| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321 |
- import fs from "fs/promises"
- import os from "os"
- import * as path from "path"
- import EventEmitter from "events"
- import simpleGit, { SimpleGit } from "simple-git"
- import { globby } from "globby"
- import { fileExistsAtPath } from "../../utils/fs"
- import { GIT_DISABLED_SUFFIX } from "./constants"
- import { CheckpointDiff, CheckpointResult, CheckpointEventMap } from "./types"
- import { getExcludePatterns } from "./excludes"
- export abstract class ShadowCheckpointService extends EventEmitter {
- public readonly taskId: string
- public readonly checkpointsDir: string
- public readonly workspaceDir: string
- protected _checkpoints: string[] = []
- protected _baseHash?: string
- protected readonly dotGitDir: string
- protected git?: SimpleGit
- protected readonly log: (message: string) => void
- protected shadowGitConfigWorktree?: string
- public get baseHash() {
- return this._baseHash
- }
- protected set baseHash(value: string | undefined) {
- this._baseHash = value
- }
- public get isInitialized() {
- return !!this.git
- }
- constructor(taskId: string, checkpointsDir: string, workspaceDir: string, log: (message: string) => void) {
- super()
- const homedir = os.homedir()
- const desktopPath = path.join(homedir, "Desktop")
- const documentsPath = path.join(homedir, "Documents")
- const downloadsPath = path.join(homedir, "Downloads")
- const protectedPaths = [homedir, desktopPath, documentsPath, downloadsPath]
- if (protectedPaths.includes(workspaceDir)) {
- throw new Error(`Cannot use checkpoints in ${workspaceDir}`)
- }
- this.taskId = taskId
- this.checkpointsDir = checkpointsDir
- this.workspaceDir = workspaceDir
- this.dotGitDir = path.join(this.checkpointsDir, ".git")
- this.log = log
- }
- public async initShadowGit(onInit?: () => Promise<void>) {
- if (this.git) {
- throw new Error("Shadow git repo already initialized")
- }
- await fs.mkdir(this.checkpointsDir, { recursive: true })
- const git = simpleGit(this.checkpointsDir)
- const gitVersion = await git.version()
- this.log(`[${this.constructor.name}#create] git = ${gitVersion}`)
- let created = false
- const startTime = Date.now()
- if (await fileExistsAtPath(this.dotGitDir)) {
- this.log(`[${this.constructor.name}#initShadowGit] shadow git repo already exists at ${this.dotGitDir}`)
- const worktree = await this.getShadowGitConfigWorktree(git)
- if (worktree !== this.workspaceDir) {
- throw new Error(
- `Checkpoints can only be used in the original workspace: ${worktree} !== ${this.workspaceDir}`,
- )
- }
- await this.writeExcludeFile()
- this.baseHash = await git.revparse(["HEAD"])
- } else {
- this.log(`[${this.constructor.name}#initShadowGit] creating shadow git repo at ${this.checkpointsDir}`)
- await git.init()
- await git.addConfig("core.worktree", this.workspaceDir) // Sets the working tree to the current workspace.
- await git.addConfig("commit.gpgSign", "false") // Disable commit signing for shadow repo.
- await git.addConfig("user.name", "Roo Code")
- await git.addConfig("user.email", "[email protected]")
- await this.writeExcludeFile()
- await this.stageAll(git)
- const { commit } = await git.commit("initial commit", { "--allow-empty": null })
- this.baseHash = commit
- created = true
- }
- const duration = Date.now() - startTime
- this.log(
- `[${this.constructor.name}#initShadowGit] initialized shadow repo with base commit ${this.baseHash} in ${duration}ms`,
- )
- this.git = git
- await onInit?.()
- this.emit("initialize", {
- type: "initialize",
- workspaceDir: this.workspaceDir,
- baseHash: this.baseHash,
- created,
- duration,
- })
- return { created, duration }
- }
- // Add basic excludes directly in git config, while respecting any
- // .gitignore in the workspace.
- // .git/info/exclude is local to the shadow git repo, so it's not
- // shared with the main repo - and won't conflict with user's
- // .gitignore.
- protected async writeExcludeFile() {
- await fs.mkdir(path.join(this.dotGitDir, "info"), { recursive: true })
- const patterns = await getExcludePatterns(this.workspaceDir)
- await fs.writeFile(path.join(this.dotGitDir, "info", "exclude"), patterns.join("\n"))
- }
- private async stageAll(git: SimpleGit) {
- await this.renameNestedGitRepos(true)
- try {
- await git.add(".")
- } catch (error) {
- this.log(
- `[${this.constructor.name}#stageAll] failed to add files to git: ${error instanceof Error ? error.message : String(error)}`,
- )
- } finally {
- await this.renameNestedGitRepos(false)
- }
- }
- // Since we use git to track checkpoints, we need to temporarily disable
- // nested git repos to work around git's requirement of using submodules for
- // nested repos.
- private async renameNestedGitRepos(disable: boolean) {
- // Find all .git directories that are not at the root level.
- const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
- cwd: this.workspaceDir,
- onlyDirectories: true,
- ignore: [".git"], // Ignore root level .git.
- dot: true,
- markDirectories: false,
- })
- // For each nested .git directory, rename it based on operation.
- for (const gitPath of gitPaths) {
- const fullPath = path.join(this.workspaceDir, gitPath)
- let newPath: string
- if (disable) {
- newPath = fullPath + GIT_DISABLED_SUFFIX
- } else {
- newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX)
- ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length)
- : fullPath
- }
- try {
- await fs.rename(fullPath, newPath)
- this.log(
- `[${this.constructor.name}#renameNestedGitRepos] ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`,
- )
- } catch (error) {
- this.log(
- `[${this.constructor.name}#renameNestedGitRepos] failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}: ${error instanceof Error ? error.message : String(error)}`,
- )
- }
- }
- }
- private async getShadowGitConfigWorktree(git: SimpleGit) {
- if (!this.shadowGitConfigWorktree) {
- try {
- this.shadowGitConfigWorktree = (await git.getConfig("core.worktree")).value || undefined
- } catch (error) {
- this.log(
- `[${this.constructor.name}#getShadowGitConfigWorktree] failed to get core.worktree: ${error instanceof Error ? error.message : String(error)}`,
- )
- }
- }
- return this.shadowGitConfigWorktree
- }
- public async saveCheckpoint(message: string): Promise<CheckpointResult | undefined> {
- try {
- this.log(`[${this.constructor.name}#saveCheckpoint] starting checkpoint save`)
- if (!this.git) {
- throw new Error("Shadow git repo not initialized")
- }
- const startTime = Date.now()
- await this.stageAll(this.git)
- const result = await this.git.commit(message)
- const isFirst = this._checkpoints.length === 0
- const fromHash = this._checkpoints[this._checkpoints.length - 1] ?? this.baseHash!
- const toHash = result.commit || fromHash
- this._checkpoints.push(toHash)
- const duration = Date.now() - startTime
- if (isFirst || result.commit) {
- this.emit("checkpoint", { type: "checkpoint", isFirst, fromHash, toHash, duration })
- }
- if (result.commit) {
- this.log(
- `[${this.constructor.name}#saveCheckpoint] checkpoint saved in ${duration}ms -> ${result.commit}`,
- )
- return result
- } else {
- this.log(`[${this.constructor.name}#saveCheckpoint] found no changes to commit in ${duration}ms`)
- return undefined
- }
- } catch (e) {
- const error = e instanceof Error ? e : new Error(String(e))
- this.log(`[${this.constructor.name}#saveCheckpoint] failed to create checkpoint: ${error.message}`)
- this.emit("error", { type: "error", error })
- throw error
- }
- }
- public async restoreCheckpoint(commitHash: string) {
- try {
- this.log(`[${this.constructor.name}#restoreCheckpoint] starting checkpoint restore`)
- if (!this.git) {
- throw new Error("Shadow git repo not initialized")
- }
- const start = Date.now()
- await this.git.clean("f", ["-d", "-f"])
- await this.git.reset(["--hard", commitHash])
- // Remove all checkpoints after the specified commitHash.
- const checkpointIndex = this._checkpoints.indexOf(commitHash)
- if (checkpointIndex !== -1) {
- this._checkpoints = this._checkpoints.slice(0, checkpointIndex + 1)
- }
- const duration = Date.now() - start
- this.emit("restore", { type: "restore", commitHash, duration })
- this.log(`[${this.constructor.name}#restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`)
- } catch (e) {
- const error = e instanceof Error ? e : new Error(String(e))
- this.log(`[${this.constructor.name}#restoreCheckpoint] failed to restore checkpoint: ${error.message}`)
- this.emit("error", { type: "error", error })
- throw error
- }
- }
- public async getDiff({ from, to }: { from?: string; to?: string }): Promise<CheckpointDiff[]> {
- if (!this.git) {
- throw new Error("Shadow git repo not initialized")
- }
- const result = []
- if (!from) {
- from = (await this.git.raw(["rev-list", "--max-parents=0", "HEAD"])).trim()
- }
- // Stage all changes so that untracked files appear in diff summary.
- await this.stageAll(this.git)
- this.log(`[${this.constructor.name}#getDiff] diffing ${to ? `${from}..${to}` : `${from}..HEAD`}`)
- const { files } = to ? await this.git.diffSummary([`${from}..${to}`]) : await this.git.diffSummary([from])
- const cwdPath = (await this.getShadowGitConfigWorktree(this.git)) || this.workspaceDir || ""
- for (const file of files) {
- const relPath = file.file
- const absPath = path.join(cwdPath, relPath)
- const before = await this.git.show([`${from}:${relPath}`]).catch(() => "")
- const after = to
- ? await this.git.show([`${to}:${relPath}`]).catch(() => "")
- : await fs.readFile(absPath, "utf8").catch(() => "")
- result.push({ paths: { relative: relPath, absolute: absPath }, content: { before, after } })
- }
- return result
- }
- /**
- * EventEmitter
- */
- override emit<K extends keyof CheckpointEventMap>(event: K, data: CheckpointEventMap[K]) {
- return super.emit(event, data)
- }
- override on<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
- return super.on(event, listener)
- }
- override off<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
- return super.off(event, listener)
- }
- override once<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
- return super.once(event, listener)
- }
- }
|