|
|
@@ -1,58 +1,70 @@
|
|
|
import fs from "fs/promises"
|
|
|
import os from "os"
|
|
|
import * as path from "path"
|
|
|
-import { globby } from "globby"
|
|
|
+import EventEmitter from "events"
|
|
|
+
|
|
|
import simpleGit, { SimpleGit } from "simple-git"
|
|
|
+import { globby } from "globby"
|
|
|
|
|
|
import { GIT_DISABLED_SUFFIX, GIT_EXCLUDES } from "./constants"
|
|
|
-import { CheckpointService, CheckpointServiceOptions, CheckpointEventEmitter } from "./types"
|
|
|
+import { CheckpointDiff, CheckpointResult, CheckpointEventMap } from "./types"
|
|
|
|
|
|
-export interface ShadowCheckpointServiceOptions extends CheckpointServiceOptions {
|
|
|
- shadowDir: string
|
|
|
-}
|
|
|
+export abstract class ShadowCheckpointService extends EventEmitter {
|
|
|
+ public readonly taskId: string
|
|
|
+ public readonly checkpointsDir: string
|
|
|
+ public readonly workspaceDir: string
|
|
|
|
|
|
-export class ShadowCheckpointService extends CheckpointEventEmitter implements CheckpointService {
|
|
|
- public readonly version = 1
|
|
|
+ protected _checkpoints: string[] = []
|
|
|
+ protected _baseHash?: string
|
|
|
|
|
|
- private _checkpoints: string[] = []
|
|
|
- private _baseHash?: string
|
|
|
- private _isInitialized = false
|
|
|
+ protected readonly dotGitDir: string
|
|
|
+ protected git?: SimpleGit
|
|
|
+ protected readonly log: (message: string) => void
|
|
|
+ protected shadowGitConfigWorktree?: string
|
|
|
|
|
|
public get baseHash() {
|
|
|
return this._baseHash
|
|
|
}
|
|
|
|
|
|
- private set baseHash(value: string | undefined) {
|
|
|
+ protected set baseHash(value: string | undefined) {
|
|
|
this._baseHash = value
|
|
|
}
|
|
|
|
|
|
public get isInitialized() {
|
|
|
- return this._isInitialized
|
|
|
+ return !!this.git
|
|
|
}
|
|
|
|
|
|
- private set isInitialized(value: boolean) {
|
|
|
- this._isInitialized = value
|
|
|
- }
|
|
|
+ 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}`)
|
|
|
+ }
|
|
|
|
|
|
- private readonly shadowGitDir: string
|
|
|
- private shadowGitConfigWorktree?: string
|
|
|
+ this.taskId = taskId
|
|
|
+ this.checkpointsDir = checkpointsDir
|
|
|
+ this.workspaceDir = workspaceDir
|
|
|
|
|
|
- private constructor(
|
|
|
- public readonly taskId: string,
|
|
|
- public readonly git: SimpleGit,
|
|
|
- public readonly shadowDir: string,
|
|
|
- public readonly workspaceDir: string,
|
|
|
- private readonly log: (message: string) => void,
|
|
|
- ) {
|
|
|
- super()
|
|
|
- this.shadowGitDir = path.join(this.shadowDir, "tasks", this.taskId, "checkpoints", ".git")
|
|
|
+ this.dotGitDir = path.join(this.checkpointsDir, ".git")
|
|
|
+ this.log = log
|
|
|
}
|
|
|
|
|
|
- public async initShadowGit() {
|
|
|
- if (this.isInitialized) {
|
|
|
- return
|
|
|
+ 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}`)
|
|
|
+
|
|
|
const fileExistsAtPath = (path: string) =>
|
|
|
fs
|
|
|
.access(path)
|
|
|
@@ -62,9 +74,9 @@ export class ShadowCheckpointService extends CheckpointEventEmitter implements C
|
|
|
let created = false
|
|
|
const startTime = Date.now()
|
|
|
|
|
|
- if (await fileExistsAtPath(this.shadowGitDir)) {
|
|
|
- this.log(`[CheckpointService#initShadowGit] shadow git repo already exists at ${this.shadowGitDir}`)
|
|
|
- const worktree = await this.getShadowGitConfigWorktree()
|
|
|
+ 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(
|
|
|
@@ -72,15 +84,15 @@ export class ShadowCheckpointService extends CheckpointEventEmitter implements C
|
|
|
)
|
|
|
}
|
|
|
|
|
|
- this.baseHash = await this.git.revparse(["--abbrev-ref", "HEAD"])
|
|
|
+ this.baseHash = await git.revparse(["HEAD"])
|
|
|
} else {
|
|
|
- this.log(`[CheckpointService#initShadowGit] creating shadow git repo at ${this.workspaceDir}`)
|
|
|
+ this.log(`[${this.constructor.name}#initShadowGit] creating shadow git repo at ${this.checkpointsDir}`)
|
|
|
|
|
|
- await this.git.init()
|
|
|
- await this.git.addConfig("core.worktree", this.workspaceDir) // Sets the working tree to the current workspace.
|
|
|
- await this.git.addConfig("commit.gpgSign", "false") // Disable commit signing for shadow repo.
|
|
|
- await this.git.addConfig("user.name", "Roo Code")
|
|
|
- await this.git.addConfig("user.email", "[email protected]")
|
|
|
+ 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]")
|
|
|
|
|
|
let lfsPatterns: string[] = [] // Get LFS patterns from workspace if they exist.
|
|
|
|
|
|
@@ -95,7 +107,7 @@ export class ShadowCheckpointService extends CheckpointEventEmitter implements C
|
|
|
}
|
|
|
} catch (error) {
|
|
|
this.log(
|
|
|
- `[CheckpointService#initShadowGit] failed to read .gitattributes: ${error instanceof Error ? error.message : String(error)}`,
|
|
|
+ `[${this.constructor.name}#initShadowGit] failed to read .gitattributes: ${error instanceof Error ? error.message : String(error)}`,
|
|
|
)
|
|
|
}
|
|
|
|
|
|
@@ -104,21 +116,23 @@ export class ShadowCheckpointService extends CheckpointEventEmitter implements C
|
|
|
// .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.
|
|
|
- await fs.mkdir(path.join(this.shadowGitDir, "info"), { recursive: true })
|
|
|
- const excludesPath = path.join(this.shadowGitDir, "info", "exclude")
|
|
|
+ await fs.mkdir(path.join(this.dotGitDir, "info"), { recursive: true })
|
|
|
+ const excludesPath = path.join(this.dotGitDir, "info", "exclude")
|
|
|
await fs.writeFile(excludesPath, [...GIT_EXCLUDES, ...lfsPatterns].join("\n"))
|
|
|
- await this.stageAll()
|
|
|
- const { commit } = await this.git.commit("initial commit", { "--allow-empty": null })
|
|
|
+ await this.stageAll(git)
|
|
|
+ const { commit } = await git.commit("initial commit", { "--allow-empty": null })
|
|
|
this.baseHash = commit
|
|
|
- this.log(`[CheckpointService#initShadowGit] base commit is ${commit}`)
|
|
|
-
|
|
|
created = true
|
|
|
}
|
|
|
|
|
|
const duration = Date.now() - startTime
|
|
|
- this.log(`[CheckpointService#initShadowGit] initialized shadow git in ${duration}ms`)
|
|
|
+ this.log(
|
|
|
+ `[${this.constructor.name}#initShadowGit] initialized shadow repo with base commit ${this.baseHash} in ${duration}ms`,
|
|
|
+ )
|
|
|
+
|
|
|
+ this.git = git
|
|
|
|
|
|
- this.isInitialized = true
|
|
|
+ await onInit?.()
|
|
|
|
|
|
this.emit("initialize", {
|
|
|
type: "initialize",
|
|
|
@@ -127,16 +141,19 @@ export class ShadowCheckpointService extends CheckpointEventEmitter implements C
|
|
|
created,
|
|
|
duration,
|
|
|
})
|
|
|
+
|
|
|
+ return { created, duration }
|
|
|
}
|
|
|
|
|
|
- private async stageAll() {
|
|
|
+ private async stageAll(git: SimpleGit) {
|
|
|
+ // await writeExcludesFile(gitPath, await getLfsPatterns(this.cwd)).
|
|
|
await this.renameNestedGitRepos(true)
|
|
|
|
|
|
try {
|
|
|
- await this.git.add(".")
|
|
|
+ await git.add(".")
|
|
|
} catch (error) {
|
|
|
this.log(
|
|
|
- `[CheckpointService#stageAll] failed to add files to git: ${error instanceof Error ? error.message : String(error)}`,
|
|
|
+ `[${this.constructor.name}#stageAll] failed to add files to git: ${error instanceof Error ? error.message : String(error)}`,
|
|
|
)
|
|
|
} finally {
|
|
|
await this.renameNestedGitRepos(false)
|
|
|
@@ -172,23 +189,23 @@ export class ShadowCheckpointService extends CheckpointEventEmitter implements C
|
|
|
try {
|
|
|
await fs.rename(fullPath, newPath)
|
|
|
this.log(
|
|
|
- `[CheckpointService#renameNestedGitRepos] ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`,
|
|
|
+ `[${this.constructor.name}#renameNestedGitRepos] ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`,
|
|
|
)
|
|
|
} catch (error) {
|
|
|
this.log(
|
|
|
- `[CheckpointService#renameNestedGitRepos] failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
|
+ `[${this.constructor.name}#renameNestedGitRepos] failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
|
)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- public async getShadowGitConfigWorktree() {
|
|
|
+ private async getShadowGitConfigWorktree(git: SimpleGit) {
|
|
|
if (!this.shadowGitConfigWorktree) {
|
|
|
try {
|
|
|
- this.shadowGitConfigWorktree = (await this.git.getConfig("core.worktree")).value || undefined
|
|
|
+ this.shadowGitConfigWorktree = (await git.getConfig("core.worktree")).value || undefined
|
|
|
} catch (error) {
|
|
|
this.log(
|
|
|
- `[CheckpointService#getShadowGitConfigWorktree] failed to get core.worktree: ${error instanceof Error ? error.message : String(error)}`,
|
|
|
+ `[${this.constructor.name}#getShadowGitConfigWorktree] failed to get core.worktree: ${error instanceof Error ? error.message : String(error)}`,
|
|
|
)
|
|
|
}
|
|
|
}
|
|
|
@@ -196,27 +213,39 @@ export class ShadowCheckpointService extends CheckpointEventEmitter implements C
|
|
|
return this.shadowGitConfigWorktree
|
|
|
}
|
|
|
|
|
|
- public async saveCheckpoint(message: string) {
|
|
|
+ public async saveCheckpoint(message: string): Promise<CheckpointResult | undefined> {
|
|
|
try {
|
|
|
- this.log("[CheckpointService#saveCheckpoint] starting checkpoint save")
|
|
|
+ this.log(`[${this.constructor.name}#saveCheckpoint] starting checkpoint save`)
|
|
|
|
|
|
- if (!this.isInitialized) {
|
|
|
+ if (!this.git) {
|
|
|
throw new Error("Shadow git repo not initialized")
|
|
|
}
|
|
|
|
|
|
const startTime = Date.now()
|
|
|
- await this.stageAll()
|
|
|
+ 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
|
|
|
+ const toHash = result.commit || fromHash
|
|
|
this._checkpoints.push(toHash)
|
|
|
const duration = Date.now() - startTime
|
|
|
- this.emit("checkpoint", { type: "checkpoint", isFirst, fromHash, toHash, duration })
|
|
|
- return result.commit ? result : undefined
|
|
|
+
|
|
|
+ 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(`[CheckpointService#saveCheckpoint] failed to create checkpoint: ${error.message}`)
|
|
|
+ this.log(`[${this.constructor.name}#saveCheckpoint] failed to create checkpoint: ${error.message}`)
|
|
|
this.emit("error", { type: "error", error })
|
|
|
throw error
|
|
|
}
|
|
|
@@ -224,7 +253,9 @@ export class ShadowCheckpointService extends CheckpointEventEmitter implements C
|
|
|
|
|
|
public async restoreCheckpoint(commitHash: string) {
|
|
|
try {
|
|
|
- if (!this.isInitialized) {
|
|
|
+ this.log(`[${this.constructor.name}#restoreCheckpoint] starting checkpoint restore`)
|
|
|
+
|
|
|
+ if (!this.git) {
|
|
|
throw new Error("Shadow git repo not initialized")
|
|
|
}
|
|
|
|
|
|
@@ -241,17 +272,17 @@ export class ShadowCheckpointService extends CheckpointEventEmitter implements C
|
|
|
|
|
|
const duration = Date.now() - start
|
|
|
this.emit("restore", { type: "restore", commitHash, duration })
|
|
|
- this.log(`[CheckpointService#restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`)
|
|
|
+ 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(`[CheckpointService#restoreCheckpoint] failed to restore checkpoint: ${error.message}`)
|
|
|
+ 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 }) {
|
|
|
- if (!this.isInitialized) {
|
|
|
+ public async getDiff({ from, to }: { from?: string; to?: string }): Promise<CheckpointDiff[]> {
|
|
|
+ if (!this.git) {
|
|
|
throw new Error("Shadow git repo not initialized")
|
|
|
}
|
|
|
|
|
|
@@ -262,11 +293,12 @@ export class ShadowCheckpointService extends CheckpointEventEmitter implements C
|
|
|
}
|
|
|
|
|
|
// Stage all changes so that untracked files appear in diff summary.
|
|
|
- await this.stageAll()
|
|
|
+ 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.workspaceDir || ""
|
|
|
+ const cwdPath = (await this.getShadowGitConfigWorktree(this.git)) || this.workspaceDir || ""
|
|
|
|
|
|
for (const file of files) {
|
|
|
const relPath = file.file
|
|
|
@@ -283,30 +315,23 @@ export class ShadowCheckpointService extends CheckpointEventEmitter implements C
|
|
|
return result
|
|
|
}
|
|
|
|
|
|
- public static async create({ taskId, shadowDir, workspaceDir, log = console.log }: ShadowCheckpointServiceOptions) {
|
|
|
- try {
|
|
|
- await simpleGit().version()
|
|
|
- } catch (error) {
|
|
|
- log("[CheckpointService#create] git is not installed")
|
|
|
- throw new Error("Git must be installed to use checkpoints.")
|
|
|
- }
|
|
|
+ /**
|
|
|
+ * EventEmitter
|
|
|
+ */
|
|
|
|
|
|
- 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]
|
|
|
+ override emit<K extends keyof CheckpointEventMap>(event: K, data: CheckpointEventMap[K]) {
|
|
|
+ return super.emit(event, data)
|
|
|
+ }
|
|
|
|
|
|
- if (protectedPaths.includes(workspaceDir)) {
|
|
|
- throw new Error(`Cannot use checkpoints in ${workspaceDir}`)
|
|
|
- }
|
|
|
+ override on<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
|
|
|
+ return super.on(event, listener)
|
|
|
+ }
|
|
|
|
|
|
- const checkpointsDir = path.join(shadowDir, "tasks", taskId, "checkpoints")
|
|
|
- await fs.mkdir(checkpointsDir, { recursive: true })
|
|
|
- const gitDir = path.join(checkpointsDir, ".git")
|
|
|
- const git = simpleGit(path.dirname(gitDir))
|
|
|
+ override off<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
|
|
|
+ return super.off(event, listener)
|
|
|
+ }
|
|
|
|
|
|
- log(`[CheckpointService#create] taskId = ${taskId}, workspaceDir = ${workspaceDir}, shadowDir = ${shadowDir}`)
|
|
|
- return new ShadowCheckpointService(taskId, git, shadowDir, workspaceDir, log)
|
|
|
+ override once<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
|
|
|
+ return super.once(event, listener)
|
|
|
}
|
|
|
}
|