| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414 |
- import fs from "fs/promises"
- import os from "os"
- import * as path from "path"
- import crypto from "crypto"
- import EventEmitter from "events"
- import simpleGit, { SimpleGit } from "simple-git"
- import pWaitFor from "p-wait-for"
- import { fileExistsAtPath } from "../../utils/fs"
- import { executeRipgrep } from "../../services/search/file-search"
- 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")
- }
- const hasNestedGitRepos = await this.hasNestedGitRepositories()
- if (hasNestedGitRepos) {
- throw new Error(
- "Checkpoints are disabled because nested git repositories were detected in the workspace. " +
- "Please remove or relocate nested git repositories to use the checkpoints feature.",
- )
- }
- 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) {
- 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)}`,
- )
- }
- }
- private async hasNestedGitRepositories(): Promise<boolean> {
- try {
- // Find all .git directories that are not at the root level.
- const args = ["--files", "--hidden", "--follow", "-g", "**/.git/HEAD", this.workspaceDir]
- const gitPaths = await executeRipgrep({ args, workspacePath: this.workspaceDir })
- // Filter to only include nested git directories (not the root .git).
- const nestedGitPaths = gitPaths.filter(
- ({ type, path }) =>
- type === "folder" && path.includes(".git") && !path.startsWith(".git") && path !== ".git",
- )
- if (nestedGitPaths.length > 0) {
- this.log(
- `[${this.constructor.name}#hasNestedGitRepositories] found ${nestedGitPaths.length} nested git repositories: ${nestedGitPaths.map((p) => p.path).join(", ")}`,
- )
- return true
- }
- return false
- } catch (error) {
- this.log(
- `[${this.constructor.name}#hasNestedGitRepositories] failed to check for nested git repos: ${error instanceof Error ? error.message : String(error)}`,
- )
- // If we can't check, assume there are no nested repos to avoid blocking the feature.
- return false
- }
- }
- 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,
- options?: { allowEmpty?: boolean },
- ): Promise<CheckpointResult | undefined> {
- try {
- this.log(
- `[${this.constructor.name}#saveCheckpoint] starting checkpoint save (allowEmpty: ${options?.allowEmpty ?? false})`,
- )
- if (!this.git) {
- throw new Error("Shadow git repo not initialized")
- }
- const startTime = Date.now()
- await this.stageAll(this.git)
- const commitArgs = options?.allowEmpty ? { "--allow-empty": null } : undefined
- const result = await this.git.commit(message, commitArgs)
- 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)
- }
- /**
- * Storage
- */
- public static hashWorkspaceDir(workspaceDir: string) {
- return crypto.createHash("sha256").update(workspaceDir).digest("hex").toString().slice(0, 8)
- }
- protected static taskRepoDir({ taskId, globalStorageDir }: { taskId: string; globalStorageDir: string }) {
- return path.join(globalStorageDir, "tasks", taskId, "checkpoints")
- }
- protected static workspaceRepoDir({
- globalStorageDir,
- workspaceDir,
- }: {
- globalStorageDir: string
- workspaceDir: string
- }) {
- return path.join(globalStorageDir, "checkpoints", this.hashWorkspaceDir(workspaceDir))
- }
- public static async deleteTask({
- taskId,
- globalStorageDir,
- workspaceDir,
- }: {
- taskId: string
- globalStorageDir: string
- workspaceDir: string
- }) {
- const workspaceRepoDir = this.workspaceRepoDir({ globalStorageDir, workspaceDir })
- const branchName = `roo-${taskId}`
- const git = simpleGit(workspaceRepoDir)
- const success = await this.deleteBranch(git, branchName)
- if (success) {
- console.log(`[${this.name}#deleteTask.${taskId}] deleted branch ${branchName}`)
- } else {
- console.error(`[${this.name}#deleteTask.${taskId}] failed to delete branch ${branchName}`)
- }
- }
- public static async deleteBranch(git: SimpleGit, branchName: string) {
- const branches = await git.branchLocal()
- if (!branches.all.includes(branchName)) {
- console.error(`[${this.constructor.name}#deleteBranch] branch ${branchName} does not exist`)
- return false
- }
- const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
- if (currentBranch === branchName) {
- const worktree = await git.getConfig("core.worktree")
- try {
- await git.raw(["config", "--unset", "core.worktree"])
- await git.reset(["--hard"])
- await git.clean("f", ["-d"])
- const defaultBranch = branches.all.includes("main") ? "main" : "master"
- await git.checkout([defaultBranch, "--force"])
- await pWaitFor(
- async () => {
- const newBranch = await git.revparse(["--abbrev-ref", "HEAD"])
- return newBranch === defaultBranch
- },
- { interval: 500, timeout: 2_000 },
- )
- await git.branch(["-D", branchName])
- return true
- } catch (error) {
- console.error(
- `[${this.constructor.name}#deleteBranch] failed to delete branch ${branchName}: ${error instanceof Error ? error.message : String(error)}`,
- )
- return false
- } finally {
- if (worktree.value) {
- await git.addConfig("core.worktree", worktree.value)
- }
- }
- } else {
- await git.branch(["-D", branchName])
- return true
- }
- }
- }
|