Async checkpoints

This commit is contained in:
cte 2025-03-03 12:28:16 -08:00
parent 464d440560
commit 9e471444de
10 changed files with 392 additions and 962 deletions

View file

@ -10,10 +10,10 @@ import getFolderSize from "get-folder-size"
import * as path from "path"
import { serializeError } from "serialize-error"
import * as vscode from "vscode"
import { ApiHandler, SingleCompletionHandler, buildApiHandler } from "../api"
import { ApiHandler, buildApiHandler } from "../api"
import { ApiStream } from "../api/transform/stream"
import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider"
import { CheckpointService, CheckpointServiceFactory } from "../services/checkpoints"
import { ShadowCheckpointService } from "../services/checkpoints/ShadowCheckpointService"
import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
import {
extractTextFromFile,
@ -116,7 +116,7 @@ export class Cline {
// checkpoints
enableCheckpoints: boolean = false
private checkpointService?: CheckpointService
private checkpointService?: ShadowCheckpointService
// streaming
isWaitingForFirstChunk = false
@ -747,8 +747,11 @@ export class Cline {
}
private async initiateTaskLoop(userContent: UserContent): Promise<void> {
this.initializeCheckpoints()
let nextUserContent = userContent
let includeFileDetails = true
while (!this.abort) {
const didEndLoop = await this.recursivelyMakeClineRequests(nextUserContent, includeFileDetails)
includeFileDetails = false // we only need file details the first time
@ -2773,7 +2776,7 @@ export class Cline {
}
if (isCheckpointPossible) {
await this.checkpointSave({ isFirst: false })
this.checkpointSave()
}
/*
@ -2839,13 +2842,6 @@ export class Cline {
// get previous api req's index to check token usage and determine if we need to truncate conversation history
const previousApiReqIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started")
// Save checkpoint if this is the first API request.
const isFirstRequest = this.clineMessages.filter((m) => m.say === "api_req_started").length === 0
if (isFirstRequest) {
await this.checkpointSave({ isFirst: true })
}
// getting verbose details is an expensive operation, it uses globby to top-down build file structure of project which for large projects can take a few seconds
// for the best UX we show a placeholder api_req_started message with a loading spinner as this happens
await this.say(
@ -3356,37 +3352,88 @@ export class Cline {
// Checkpoints
private async getCheckpointService() {
private async initializeCheckpoints() {
if (!this.enableCheckpoints) {
throw new Error("Checkpoints are disabled")
return
}
if (!this.checkpointService) {
const log = (message: string) => {
console.log(message)
try {
this.providerRef.deref()?.log(message)
} catch (err) {
// NO-OP
}
}
try {
if (this.checkpointService) {
log("[Cline#initializeCheckpoints] checkpointService already initialized")
return
}
const workspaceDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
const shadowDir = this.providerRef.deref()?.context.globalStorageUri.fsPath
if (!workspaceDir) {
this.providerRef.deref()?.log("[getCheckpointService] workspace folder not found")
throw new Error("Workspace directory not found")
log("[Cline#initializeCheckpoints] workspace folder not found, disabling checkpoints")
this.enableCheckpoints = false
return
}
const shadowDir = this.providerRef.deref()?.context.globalStorageUri.fsPath
if (!shadowDir) {
this.providerRef.deref()?.log("[getCheckpointService] shadowDir not found")
throw new Error("Global storage directory not found")
log("[Cline#initializeCheckpoints] shadowDir not found, disabling checkpoints")
this.enableCheckpoints = false
return
}
this.checkpointService = await CheckpointServiceFactory.create({
strategy: "shadow",
options: {
taskId: this.taskId,
workspaceDir,
shadowDir,
log: (message) => this.providerRef.deref()?.log(message),
},
})
}
const service = await ShadowCheckpointService.create({ taskId: this.taskId, workspaceDir, shadowDir, log })
return this.checkpointService
if (!service) {
log("[Cline#initializeCheckpoints] failed to create checkpoint service, disabling checkpoints")
this.enableCheckpoints = false
return
}
service.on("initialize", ({ workspaceDir, created, duration }) => {
try {
if (created) {
log(`[Cline#initializeCheckpoints] created new shadow repo (${workspaceDir}) in ${duration}ms`)
} else {
log(
`[Cline#initializeCheckpoints] found existing shadow repo (${workspaceDir}) in ${duration}ms`,
)
}
this.checkpointService = service
this.checkpointSave()
} catch (err) {
log("[Cline#initializeCheckpoints] caught error in on('initialize'), disabling checkpoints")
this.enableCheckpoints = false
}
})
service.on("checkpoint", ({ isFirst, fromHash: from, toHash: to }) => {
try {
log(`[Cline#initializeCheckpoints] ${isFirst ? "initial" : "incremental"} checkpoint saved: ${to}`)
this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: to })
this.say("checkpoint_saved", to, undefined, undefined, { isFirst, from, to }).catch((e) =>
console.error("Error saving checkpoint message:", e),
)
} catch (err) {
log("[Cline#initializeCheckpoints] caught error in on('checkpoint'), disabling checkpoints")
this.enableCheckpoints = false
}
})
service.initShadowGit()
} catch (err) {
log("[Cline#initializeCheckpoints] caught error in initializeCheckpoints(), disabling checkpoints")
this.enableCheckpoints = false
}
}
public async checkpointDiff({
@ -3398,7 +3445,7 @@ export class Cline {
commitHash: string
mode: "full" | "checkpoint"
}) {
if (!this.enableCheckpoints) {
if (!this.checkpointService || !this.enableCheckpoints) {
return
}
@ -3414,8 +3461,7 @@ export class Cline {
}
try {
const service = await this.getCheckpointService()
const changes = await service.getDiff({ from: previousCommitHash, to: commitHash })
const changes = await this.checkpointService.getDiff({ from: previousCommitHash, to: commitHash })
if (!changes?.length) {
vscode.window.showInformationMessage("No changes found.")
@ -3441,30 +3487,13 @@ export class Cline {
}
}
public async checkpointSave({ isFirst }: { isFirst: boolean }) {
if (!this.enableCheckpoints) {
public checkpointSave() {
if (!this.checkpointService || !this.enableCheckpoints) {
return
}
try {
const service = await this.getCheckpointService()
const strategy = service.strategy
const version = service.version
const commit = await service.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`)
const fromHash = service.baseHash
const toHash = isFirst ? commit?.commit || fromHash : commit?.commit
if (toHash) {
await this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: toHash })
const checkpoint = { isFirst, from: fromHash, to: toHash, strategy, version }
await this.say("checkpoint_saved", toHash, undefined, undefined, checkpoint)
}
} catch (err) {
this.providerRef.deref()?.log("[checkpointSave] disabling checkpoints for this task")
this.enableCheckpoints = false
}
// Start the checkpoint process in the background.
this.checkpointService.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`)
}
public async checkpointRestore({
@ -3476,7 +3505,7 @@ export class Cline {
commitHash: string
mode: "preview" | "restore"
}) {
if (!this.enableCheckpoints) {
if (!this.checkpointService || !this.enableCheckpoints) {
return
}
@ -3487,8 +3516,7 @@ export class Cline {
}
try {
const service = await this.getCheckpointService()
await service.restoreCheckpoint(commitHash)
await this.checkpointService.restoreCheckpoint(commitHash)
await this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: commitHash })

View file

@ -1,29 +0,0 @@
import { LocalCheckpointService, LocalCheckpointServiceOptions } from "./LocalCheckpointService"
import { ShadowCheckpointService, ShadowCheckpointServiceOptions } from "./ShadowCheckpointService"
export type CreateCheckpointServiceFactoryOptions =
| {
strategy: "local"
options: LocalCheckpointServiceOptions
}
| {
strategy: "shadow"
options: ShadowCheckpointServiceOptions
}
type CheckpointServiceType<T extends CreateCheckpointServiceFactoryOptions> = T extends { strategy: "local" }
? LocalCheckpointService
: T extends { strategy: "shadow" }
? ShadowCheckpointService
: never
export class CheckpointServiceFactory {
public static create<T extends CreateCheckpointServiceFactoryOptions>(options: T): CheckpointServiceType<T> {
switch (options.strategy) {
case "local":
return LocalCheckpointService.create(options.options) as any
case "shadow":
return ShadowCheckpointService.create(options.options) as any
}
}
}

View file

@ -1,440 +0,0 @@
import fs from "fs/promises"
import { existsSync } from "fs"
import path from "path"
import simpleGit, { SimpleGit, CleanOptions } from "simple-git"
import { CheckpointStrategy, CheckpointService, CheckpointServiceOptions } from "./types"
export interface LocalCheckpointServiceOptions extends CheckpointServiceOptions {}
/**
* The CheckpointService provides a mechanism for storing a snapshot of the
* current VSCode workspace each time a Roo Code tool is executed. It uses Git
* under the hood.
*
* HOW IT WORKS
*
* Two branches are used:
* - A main branch for normal operation (the branch you are currently on).
* - A hidden branch for storing checkpoints.
*
* Saving a checkpoint:
* - A temporary branch is created to store the current state.
* - All changes (including untracked files) are staged and committed on the temp branch.
* - The hidden branch is reset to match main.
* - The temporary branch commit is cherry-picked onto the hidden branch.
* - The workspace is restored to its original state and the temp branch is deleted.
*
* Restoring a checkpoint:
* - The workspace is restored to the state of the specified checkpoint using
* `git restore` and `git clean`.
*
* This approach allows for:
* - Non-destructive version control (main branch remains untouched).
* - Preservation of the full history of checkpoints.
* - Safe restoration to any previous checkpoint.
* - Atomic checkpoint operations with proper error recovery.
*
* NOTES
*
* - Git must be installed.
* - If the current working directory is not a Git repository, we will
* initialize a new one with a .gitkeep file.
* - If you manually edit files and then restore a checkpoint, the changes
* will be lost. Addressing this adds some complexity to the implementation
* and it's not clear whether it's worth it.
*/
export class LocalCheckpointService implements CheckpointService {
private static readonly USER_NAME = "Roo Code"
private static readonly USER_EMAIL = "support@roocode.com"
private static readonly CHECKPOINT_BRANCH = "roo-code-checkpoints"
private static readonly STASH_BRANCH = "roo-code-stash"
public readonly strategy: CheckpointStrategy = "local"
public readonly version = 1
public get baseHash() {
return this._baseHash
}
constructor(
public readonly taskId: string,
public readonly git: SimpleGit,
public readonly workspaceDir: string,
private readonly mainBranch: string,
private _baseHash: string,
private readonly hiddenBranch: string,
private readonly log: (message: string) => void,
) {}
private async ensureBranch(expectedBranch: string) {
const branch = await this.git.revparse(["--abbrev-ref", "HEAD"])
if (branch.trim() !== expectedBranch) {
throw new Error(`Git branch mismatch: expected '${expectedBranch}' but found '${branch}'`)
}
}
public async getDiff({ from, to }: { from?: string; to?: string }) {
const result = []
if (!from) {
from = this.baseHash
}
const { files } = await this.git.diffSummary([`${from}..${to}`])
for (const file of files.filter((f) => !f.binary)) {
const relPath = file.file
const absPath = path.join(this.workspaceDir, 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
}
private async restoreMain({
branch,
stashSha,
force = false,
}: {
branch: string
stashSha: string
force?: boolean
}) {
let currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"])
if (currentBranch !== this.mainBranch) {
if (force) {
try {
await this.git.checkout(["-f", this.mainBranch])
} catch (err) {
this.log(
`[restoreMain] failed to force checkout ${this.mainBranch}: ${err instanceof Error ? err.message : String(err)}`,
)
}
} else {
try {
await this.git.checkout(this.mainBranch)
} catch (err) {
this.log(
`[restoreMain] failed to checkout ${this.mainBranch}: ${err instanceof Error ? err.message : String(err)}`,
)
// Escalate to a forced checkout if we can't checkout the
// main branch under normal circumstances.
currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"])
if (currentBranch !== this.mainBranch) {
await this.git.checkout(["-f", this.mainBranch]).catch(() => {})
}
}
}
}
currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"])
if (currentBranch !== this.mainBranch) {
throw new Error(`Unable to restore ${this.mainBranch}`)
}
if (stashSha) {
this.log(`[restoreMain] applying stash ${stashSha}`)
try {
await this.git.raw(["stash", "apply", "--index", stashSha])
} catch (err) {
this.log(`[restoreMain] Failed to apply stash: ${err instanceof Error ? err.message : String(err)}`)
}
}
this.log(`[restoreMain] restoring from ${branch} branch`)
try {
await this.git.raw(["restore", "--source", branch, "--worktree", "--", "."])
} catch (err) {
this.log(`[restoreMain] Failed to restore branch: ${err instanceof Error ? err.message : String(err)}`)
}
}
public async saveCheckpoint(message: string) {
const startTime = Date.now()
await this.ensureBranch(this.mainBranch)
const stashSha = (await this.git.raw(["stash", "create"])).trim()
const latestSha = await this.git.revparse([this.hiddenBranch])
/**
* PHASE: Create stash
* Mutations:
* - Create branch
* - Change branch
*/
const stashBranch = `${LocalCheckpointService.STASH_BRANCH}-${Date.now()}`
await this.git.checkout(["-b", stashBranch])
this.log(`[saveCheckpoint] created and checked out ${stashBranch}`)
/**
* Phase: Stage stash
* Mutations: None
* Recovery:
* - UNDO: Create branch
* - UNDO: Change branch
*/
try {
await this.git.add(["-A"])
} catch (err) {
this.log(
`[saveCheckpoint] failed in stage stash phase: ${err instanceof Error ? err.message : String(err)}`,
)
await this.restoreMain({ branch: stashBranch, stashSha, force: true })
await this.git.branch(["-D", stashBranch]).catch(() => {})
throw err
}
/**
* Phase: Commit stash
* Mutations:
* - Commit stash
* - Change branch
* Recovery:
* - UNDO: Create branch
* - UNDO: Change branch
*/
let stashCommit
try {
stashCommit = await this.git.commit(message, undefined, { "--no-verify": null })
this.log(`[saveCheckpoint] stashCommit: ${message} -> ${JSON.stringify(stashCommit)}`)
} catch (err) {
this.log(
`[saveCheckpoint] failed in stash commit phase: ${err instanceof Error ? err.message : String(err)}`,
)
await this.restoreMain({ branch: stashBranch, stashSha, force: true })
await this.git.branch(["-D", stashBranch]).catch(() => {})
throw err
}
if (!stashCommit) {
this.log("[saveCheckpoint] no stash commit")
await this.restoreMain({ branch: stashBranch, stashSha })
await this.git.branch(["-D", stashBranch])
return undefined
}
/**
* PHASE: Diff
* Mutations:
* - Checkout hidden branch
* Recovery:
* - UNDO: Create branch
* - UNDO: Change branch
* - UNDO: Commit stash
*/
let diff
try {
diff = await this.git.diff([latestSha, stashBranch])
} catch (err) {
this.log(`[saveCheckpoint] failed in diff phase: ${err instanceof Error ? err.message : String(err)}`)
await this.restoreMain({ branch: stashBranch, stashSha, force: true })
await this.git.branch(["-D", stashBranch]).catch(() => {})
throw err
}
if (!diff) {
this.log("[saveCheckpoint] no diff")
await this.restoreMain({ branch: stashBranch, stashSha })
await this.git.branch(["-D", stashBranch])
return undefined
}
/**
* PHASE: Reset
* Mutations:
* - Reset hidden branch
* Recovery:
* - UNDO: Create branch
* - UNDO: Change branch
* - UNDO: Commit stash
*/
try {
await this.git.checkout(this.hiddenBranch)
this.log(`[saveCheckpoint] checked out ${this.hiddenBranch}`)
await this.git.reset(["--hard", this.mainBranch])
this.log(`[saveCheckpoint] reset ${this.hiddenBranch}`)
} catch (err) {
this.log(`[saveCheckpoint] failed in reset phase: ${err instanceof Error ? err.message : String(err)}`)
await this.restoreMain({ branch: stashBranch, stashSha, force: true })
await this.git.branch(["-D", stashBranch]).catch(() => {})
throw err
}
/**
* PHASE: Cherry pick
* Mutations:
* - Hidden commit (NOTE: reset on hidden branch no longer needed in
* success scenario.)
* Recovery:
* - UNDO: Create branch
* - UNDO: Change branch
* - UNDO: Commit stash
* - UNDO: Reset hidden branch
*/
let commit = ""
try {
try {
await this.git.raw(["cherry-pick", stashBranch])
} catch (err) {
// Check if we're in the middle of a cherry-pick.
// If the cherry-pick resulted in an empty commit (e.g., only
// deletions) then complete it with --allow-empty.
// Otherwise, rethrow the error.
if (existsSync(path.join(this.workspaceDir, ".git/CHERRY_PICK_HEAD"))) {
await this.git.raw(["commit", "--allow-empty", "--no-edit"])
} else {
throw err
}
}
commit = await this.git.revparse(["HEAD"])
this.log(`[saveCheckpoint] cherry-pick commit = ${commit}`)
} catch (err) {
this.log(
`[saveCheckpoint] failed in cherry pick phase: ${err instanceof Error ? err.message : String(err)}`,
)
await this.git.reset(["--hard", latestSha]).catch(() => {})
await this.restoreMain({ branch: stashBranch, stashSha, force: true })
await this.git.branch(["-D", stashBranch]).catch(() => {})
throw err
}
await this.restoreMain({ branch: stashBranch, stashSha })
await this.git.branch(["-D", stashBranch])
// We've gotten reports that checkpoints can be slow in some cases, so
// we'll log the duration of the checkpoint save.
const duration = Date.now() - startTime
this.log(`[saveCheckpoint] saved checkpoint ${commit} in ${duration}ms`)
return { commit }
}
public async restoreCheckpoint(commitHash: string) {
const startTime = Date.now()
await this.ensureBranch(this.mainBranch)
await this.git.clean([CleanOptions.FORCE, CleanOptions.RECURSIVE])
await this.git.raw(["restore", "--source", commitHash, "--worktree", "--", "."])
const duration = Date.now() - startTime
this.log(`[restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`)
}
public static async create({ taskId, workspaceDir, log = console.log }: LocalCheckpointServiceOptions) {
const git = simpleGit(workspaceDir)
const version = await git.version()
if (!version?.installed) {
throw new Error(`Git is not installed. Please install Git if you wish to use checkpoints.`)
}
if (!workspaceDir || !existsSync(workspaceDir)) {
throw new Error(`Base directory is not set or does not exist.`)
}
const { currentBranch, currentSha, hiddenBranch } = await LocalCheckpointService.initRepo(git, {
taskId,
workspaceDir,
log,
})
log(
`[create] taskId = ${taskId}, workspaceDir = ${workspaceDir}, currentBranch = ${currentBranch}, currentSha = ${currentSha}, hiddenBranch = ${hiddenBranch}`,
)
return new LocalCheckpointService(taskId, git, workspaceDir, currentBranch, currentSha, hiddenBranch, log)
}
private static async initRepo(
git: SimpleGit,
{ taskId, workspaceDir, log }: Required<LocalCheckpointServiceOptions>,
) {
const isExistingRepo = existsSync(path.join(workspaceDir, ".git"))
if (!isExistingRepo) {
await git.init()
log(`[initRepo] Initialized new Git repository at ${workspaceDir}`)
}
const globalUserName = await git.getConfig("user.name", "global")
const localUserName = await git.getConfig("user.name", "local")
const userName = localUserName.value || globalUserName.value
const globalUserEmail = await git.getConfig("user.email", "global")
const localUserEmail = await git.getConfig("user.email", "local")
const userEmail = localUserEmail.value || globalUserEmail.value
// Prior versions of this service indiscriminately set the local user
// config, and it should not override the global config. To address
// this we remove the local user config if it matches the default
// user name and email and there's a global config.
if (globalUserName.value && localUserName.value === LocalCheckpointService.USER_NAME) {
await git.raw(["config", "--unset", "--local", "user.name"])
}
if (globalUserEmail.value && localUserEmail.value === LocalCheckpointService.USER_EMAIL) {
await git.raw(["config", "--unset", "--local", "user.email"])
}
// Only set user config if not already configured.
if (!userName) {
await git.addConfig("user.name", LocalCheckpointService.USER_NAME)
}
if (!userEmail) {
await git.addConfig("user.email", LocalCheckpointService.USER_EMAIL)
}
if (!isExistingRepo) {
// We need at least one file to commit, otherwise the initial
// commit will fail, unless we use the `--allow-empty` flag.
// However, using an empty commit causes problems when restoring
// the checkpoint (i.e. the `git restore` command doesn't work
// for empty commits).
await fs.writeFile(path.join(workspaceDir, ".gitkeep"), "")
await git.add(".gitkeep")
const commit = await git.commit("Initial commit")
if (!commit.commit) {
throw new Error("Failed to create initial commit")
}
log(`[initRepo] Initial commit: ${commit.commit}`)
}
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
const currentSha = await git.revparse(["HEAD"])
const hiddenBranch = `${LocalCheckpointService.CHECKPOINT_BRANCH}-${taskId}`
const branchSummary = await git.branch()
if (!branchSummary.all.includes(hiddenBranch)) {
await git.checkoutBranch(hiddenBranch, currentBranch)
await git.checkout(currentBranch)
}
return { currentBranch, currentSha, hiddenBranch }
}
}

View file

@ -5,17 +5,18 @@ import { globby } from "globby"
import simpleGit, { SimpleGit } from "simple-git"
import { GIT_DISABLED_SUFFIX, GIT_EXCLUDES } from "./constants"
import { CheckpointStrategy, CheckpointService, CheckpointServiceOptions } from "./types"
import { CheckpointService, CheckpointServiceOptions, CheckpointEventEmitter } from "./types"
export interface ShadowCheckpointServiceOptions extends CheckpointServiceOptions {
shadowDir: string
}
export class ShadowCheckpointService implements CheckpointService {
public readonly strategy: CheckpointStrategy = "shadow"
export class ShadowCheckpointService extends CheckpointEventEmitter implements CheckpointService {
public readonly version = 1
private _checkpoints: string[] = []
private _baseHash?: string
private _isInitialized = false
public get baseHash() {
return this._baseHash
@ -25,6 +26,18 @@ export class ShadowCheckpointService implements CheckpointService {
this._baseHash = value
}
public get isInitialized() {
return this._isInitialized
}
private set isInitialized(value: boolean) {
this._isInitialized = value
}
public get isDirty() {
return this._checkpoints.length > 0
}
private readonly shadowGitDir: string
private shadowGitConfigWorktree?: string
@ -35,18 +48,26 @@ export class ShadowCheckpointService implements CheckpointService {
public readonly workspaceDir: string,
private readonly log: (message: string) => void,
) {
super()
this.shadowGitDir = path.join(this.shadowDir, "tasks", this.taskId, "checkpoints", ".git")
}
private async initShadowGit() {
public async initShadowGit() {
if (this.isInitialized) {
return
}
const fileExistsAtPath = (path: string) =>
fs
.access(path)
.then(() => true)
.catch(() => false)
let created = false
const startTime = Date.now()
if (await fileExistsAtPath(this.shadowGitDir)) {
this.log(`[initShadowGit] shadow git repo already exists at ${this.shadowGitDir}`)
this.log(`[CheckpointService#initShadowGit] shadow git repo already exists at ${this.shadowGitDir}`)
const worktree = await this.getShadowGitConfigWorktree()
if (worktree !== this.workspaceDir) {
@ -57,7 +78,7 @@ export class ShadowCheckpointService implements CheckpointService {
this.baseHash = await this.git.revparse(["--abbrev-ref", "HEAD"])
} else {
this.log(`[initShadowGit] creating shadow git repo at ${this.workspaceDir}`)
this.log(`[CheckpointService#initShadowGit] creating shadow git repo at ${this.workspaceDir}`)
await this.git.init()
await this.git.addConfig("core.worktree", this.workspaceDir) // Sets the working tree to the current workspace.
@ -78,7 +99,7 @@ export class ShadowCheckpointService implements CheckpointService {
}
} catch (error) {
this.log(
`[initShadowGit] failed to read .gitattributes: ${error instanceof Error ? error.message : String(error)}`,
`[CheckpointService#initShadowGit] failed to read .gitattributes: ${error instanceof Error ? error.message : String(error)}`,
)
}
@ -93,8 +114,23 @@ export class ShadowCheckpointService implements CheckpointService {
await this.stageAll()
const { commit } = await this.git.commit("initial commit", { "--allow-empty": null })
this.baseHash = commit
this.log(`[initShadowGit] base commit is ${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.isInitialized = true
this.emit("initialize", {
type: "initialize",
workspaceDir: this.workspaceDir,
baseHash: this.baseHash,
created,
duration,
})
}
private async stageAll() {
@ -103,7 +139,9 @@ export class ShadowCheckpointService implements CheckpointService {
try {
await this.git.add(".")
} catch (error) {
this.log(`[stageAll] failed to add files to git: ${error instanceof Error ? error.message : String(error)}`)
this.log(
`[CheckpointService#stageAll] failed to add files to git: ${error instanceof Error ? error.message : String(error)}`,
)
} finally {
await this.renameNestedGitRepos(false)
}
@ -137,10 +175,12 @@ export class ShadowCheckpointService implements CheckpointService {
try {
await fs.rename(fullPath, newPath)
this.log(`${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
this.log(
`[CheckpointService#renameNestedGitRepos] ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`,
)
} catch (error) {
this.log(
`failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}: ${error instanceof Error ? error.message : String(error)}`,
`[CheckpointService#renameNestedGitRepos] failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
@ -152,7 +192,7 @@ export class ShadowCheckpointService implements CheckpointService {
this.shadowGitConfigWorktree = (await this.git.getConfig("core.worktree")).value || undefined
} catch (error) {
this.log(
`[getShadowGitConfigWorktree] failed to get core.worktree: ${error instanceof Error ? error.message : String(error)}`,
`[CheckpointService#getShadowGitConfigWorktree] failed to get core.worktree: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
@ -162,35 +202,63 @@ export class ShadowCheckpointService implements CheckpointService {
public async saveCheckpoint(message: string) {
try {
this.log("[CheckpointService#saveCheckpoint] starting checkpoint save")
if (!this.isInitialized) {
throw new Error("Shadow git repo not initialized")
}
const startTime = Date.now()
await this.stageAll()
const result = await this.git.commit(message)
if (result.commit) {
const duration = Date.now() - startTime
this.log(`[saveCheckpoint] saved checkpoint ${result.commit} in ${duration}ms`)
return result
} else {
return undefined
}
} catch (error) {
this.log(
`[saveCheckpoint] failed to create checkpoint: ${error instanceof Error ? error.message : String(error)}`,
)
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
this.emit("checkpoint", { type: "checkpoint", isFirst, fromHash, toHash, duration })
return result.commit ? result : undefined
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e))
this.log(`[CheckpointService#saveCheckpoint] failed to create checkpoint: ${error.message}`)
this.emit("error", { type: "error", error })
throw error
}
}
public async restoreCheckpoint(commitHash: string) {
const start = Date.now()
await this.git.clean("f", ["-d", "-f"])
await this.git.reset(["--hard", commitHash])
const duration = Date.now() - start
this.log(`[restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`)
try {
if (!this.isInitialized) {
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(`[CheckpointService#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.emit("error", { type: "error", error })
throw error
}
}
public async getDiff({ from, to }: { from?: string; to?: string }) {
if (!this.isInitialized) {
throw new Error("Shadow git repo not initialized")
}
const result = []
if (!from) {
@ -223,6 +291,7 @@ export class ShadowCheckpointService implements CheckpointService {
try {
await simpleGit().version()
} catch (error) {
log("[CheckpointService#create] git is not installed")
throw new Error("Git must be installed to use checkpoints.")
}
@ -241,9 +310,7 @@ export class ShadowCheckpointService implements CheckpointService {
const gitDir = path.join(checkpointsDir, ".git")
const git = simpleGit(path.dirname(gitDir))
log(`[create] taskId = ${taskId}, workspaceDir = ${workspaceDir}, shadowDir = ${shadowDir}`)
const service = new ShadowCheckpointService(taskId, git, shadowDir, workspaceDir, log)
await service.initShadowGit()
return service
log(`[CheckpointService#create] taskId = ${taskId}, workspaceDir = ${workspaceDir}, shadowDir = ${shadowDir}`)
return new ShadowCheckpointService(taskId, git, shadowDir, workspaceDir, log)
}
}

View file

@ -1,385 +0,0 @@
// npx jest src/services/checkpoints/__tests__/LocalCheckpointService.test.ts
import fs from "fs/promises"
import path from "path"
import os from "os"
import { simpleGit, SimpleGit } from "simple-git"
import { CheckpointServiceFactory } from "../CheckpointServiceFactory"
import { LocalCheckpointService } from "../LocalCheckpointService"
const tmpDir = path.join(os.tmpdir(), "test-LocalCheckpointService")
describe("LocalCheckpointService", () => {
const taskId = "test-task"
let testFile: string
let service: LocalCheckpointService
const initRepo = async ({
workspaceDir,
userName = "Roo Code",
userEmail = "support@roocode.com",
testFileName = "test.txt",
textFileContent = "Hello, world!",
}: {
workspaceDir: string
userName?: string
userEmail?: string
testFileName?: string
textFileContent?: string
}) => {
// Create a temporary directory for testing.
await fs.mkdir(workspaceDir, { recursive: true })
// Initialize git repo.
const git = simpleGit(workspaceDir)
await git.init()
await git.addConfig("user.name", userName)
await git.addConfig("user.email", userEmail)
// Create test file.
const testFile = path.join(workspaceDir, testFileName)
await fs.writeFile(testFile, textFileContent)
// Create initial commit.
await git.add(".")
await git.commit("Initial commit")!
return { testFile }
}
beforeEach(async () => {
const workspaceDir = path.join(tmpDir, `checkpoint-service-test-${Date.now()}`)
const repo = await initRepo({ workspaceDir })
testFile = repo.testFile
service = await CheckpointServiceFactory.create({
strategy: "local",
options: { taskId, workspaceDir, log: () => {} },
})
})
afterEach(async () => {
jest.restoreAllMocks()
})
afterAll(async () => {
await fs.rm(tmpDir, { recursive: true, force: true })
})
describe("getDiff", () => {
it("returns the correct diff between commits", async () => {
await fs.writeFile(testFile, "Ahoy, world!")
const commit1 = await service.saveCheckpoint("First checkpoint")
expect(commit1?.commit).toBeTruthy()
await fs.writeFile(testFile, "Goodbye, world!")
const commit2 = await service.saveCheckpoint("Second checkpoint")
expect(commit2?.commit).toBeTruthy()
const diff1 = await service.getDiff({ to: commit1!.commit })
expect(diff1).toHaveLength(1)
expect(diff1[0].paths.relative).toBe("test.txt")
expect(diff1[0].paths.absolute).toBe(testFile)
expect(diff1[0].content.before).toBe("Hello, world!")
expect(diff1[0].content.after).toBe("Ahoy, world!")
const diff2 = await service.getDiff({ to: commit2!.commit })
expect(diff2).toHaveLength(1)
expect(diff2[0].paths.relative).toBe("test.txt")
expect(diff2[0].paths.absolute).toBe(testFile)
expect(diff2[0].content.before).toBe("Hello, world!")
expect(diff2[0].content.after).toBe("Goodbye, world!")
const diff12 = await service.getDiff({ from: commit1!.commit, to: commit2!.commit })
expect(diff12).toHaveLength(1)
expect(diff12[0].paths.relative).toBe("test.txt")
expect(diff12[0].paths.absolute).toBe(testFile)
expect(diff12[0].content.before).toBe("Ahoy, world!")
expect(diff12[0].content.after).toBe("Goodbye, world!")
})
it("handles new files in diff", async () => {
const newFile = path.join(service.workspaceDir, "new.txt")
await fs.writeFile(newFile, "New file content")
const commit = await service.saveCheckpoint("Add new file")
expect(commit?.commit).toBeTruthy()
const changes = await service.getDiff({ to: commit!.commit })
const change = changes.find((c) => c.paths.relative === "new.txt")
expect(change).toBeDefined()
expect(change?.content.before).toBe("")
expect(change?.content.after).toBe("New file content")
})
it("handles deleted files in diff", async () => {
const fileToDelete = path.join(service.workspaceDir, "new.txt")
await fs.writeFile(fileToDelete, "New file content")
const commit1 = await service.saveCheckpoint("Add file")
expect(commit1?.commit).toBeTruthy()
await fs.unlink(fileToDelete)
const commit2 = await service.saveCheckpoint("Delete file")
expect(commit2?.commit).toBeTruthy()
const changes = await service.getDiff({ from: commit1!.commit, to: commit2!.commit })
const change = changes.find((c) => c.paths.relative === "new.txt")
expect(change).toBeDefined()
expect(change!.content.before).toBe("New file content")
expect(change!.content.after).toBe("")
})
})
describe("saveCheckpoint", () => {
it("creates a checkpoint if there are pending changes", async () => {
await fs.writeFile(testFile, "Ahoy, world!")
const commit1 = await service.saveCheckpoint("First checkpoint")
expect(commit1?.commit).toBeTruthy()
const details1 = await service.git.show([commit1!.commit])
expect(details1).toContain("-Hello, world!")
expect(details1).toContain("+Ahoy, world!")
await fs.writeFile(testFile, "Hola, world!")
const commit2 = await service.saveCheckpoint("Second checkpoint")
expect(commit2?.commit).toBeTruthy()
const details2 = await service.git.show([commit2!.commit])
expect(details2).toContain("-Hello, world!")
expect(details2).toContain("+Hola, world!")
// Switch to checkpoint 1.
await service.restoreCheckpoint(commit1!.commit)
expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!")
// Switch to checkpoint 2.
await service.restoreCheckpoint(commit2!.commit)
expect(await fs.readFile(testFile, "utf-8")).toBe("Hola, world!")
// Switch back to initial commit.
expect(service.baseHash).toBeTruthy()
await service.restoreCheckpoint(service.baseHash!)
expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!")
})
it("preserves workspace and index state after saving checkpoint", async () => {
// Create three files with different states: staged, unstaged, and mixed.
const unstagedFile = path.join(service.workspaceDir, "unstaged.txt")
const stagedFile = path.join(service.workspaceDir, "staged.txt")
const mixedFile = path.join(service.workspaceDir, "mixed.txt")
await fs.writeFile(unstagedFile, "Initial unstaged")
await fs.writeFile(stagedFile, "Initial staged")
await fs.writeFile(mixedFile, "Initial mixed")
await service.git.add(["."])
const result = await service.git.commit("Add initial files")
expect(result?.commit).toBeTruthy()
await fs.writeFile(unstagedFile, "Modified unstaged")
await fs.writeFile(stagedFile, "Modified staged")
await service.git.add([stagedFile])
await fs.writeFile(mixedFile, "Modified mixed - staged")
await service.git.add([mixedFile])
await fs.writeFile(mixedFile, "Modified mixed - unstaged")
// Save checkpoint.
const commit = await service.saveCheckpoint("Test checkpoint")
expect(commit?.commit).toBeTruthy()
// Verify workspace state is preserved.
const status = await service.git.status()
// All files should be modified.
expect(status.modified).toContain("unstaged.txt")
expect(status.modified).toContain("staged.txt")
expect(status.modified).toContain("mixed.txt")
// Only staged and mixed files should be staged.
expect(status.staged).not.toContain("unstaged.txt")
expect(status.staged).toContain("staged.txt")
expect(status.staged).toContain("mixed.txt")
// Verify file contents.
expect(await fs.readFile(unstagedFile, "utf-8")).toBe("Modified unstaged")
expect(await fs.readFile(stagedFile, "utf-8")).toBe("Modified staged")
expect(await fs.readFile(mixedFile, "utf-8")).toBe("Modified mixed - unstaged")
// Verify staged changes (--cached shows only staged changes).
const stagedDiff = await service.git.diff(["--cached", "mixed.txt"])
expect(stagedDiff).toContain("-Initial mixed")
expect(stagedDiff).toContain("+Modified mixed - staged")
// Verify unstaged changes (shows working directory changes).
const unstagedDiff = await service.git.diff(["mixed.txt"])
expect(unstagedDiff).toContain("-Modified mixed - staged")
expect(unstagedDiff).toContain("+Modified mixed - unstaged")
})
it("does not create a checkpoint if there are no pending changes", async () => {
const commit0 = await service.saveCheckpoint("Zeroth checkpoint")
expect(commit0?.commit).toBeFalsy()
await fs.writeFile(testFile, "Ahoy, world!")
const commit1 = await service.saveCheckpoint("First checkpoint")
expect(commit1?.commit).toBeTruthy()
const commit2 = await service.saveCheckpoint("Second checkpoint")
expect(commit2?.commit).toBeFalsy()
})
it("includes untracked files in checkpoints", async () => {
// Create an untracked file.
const untrackedFile = path.join(service.workspaceDir, "untracked.txt")
await fs.writeFile(untrackedFile, "I am untracked!")
// Save a checkpoint with the untracked file.
const commit1 = await service.saveCheckpoint("Checkpoint with untracked file")
expect(commit1?.commit).toBeTruthy()
// Verify the untracked file was included in the checkpoint.
const details = await service.git.show([commit1!.commit])
expect(details).toContain("+I am untracked!")
// Create another checkpoint with a different state.
await fs.writeFile(testFile, "Changed tracked file")
const commit2 = await service.saveCheckpoint("Second checkpoint")
expect(commit2?.commit).toBeTruthy()
// Restore first checkpoint and verify untracked file is preserved.
await service.restoreCheckpoint(commit1!.commit)
expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!")
// Restore second checkpoint and verify untracked file remains (since
// restore preserves untracked files)
await service.restoreCheckpoint(commit2!.commit)
expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
expect(await fs.readFile(testFile, "utf-8")).toBe("Changed tracked file")
})
it("throws if we're on the wrong branch", async () => {
// Create and switch to a feature branch.
const currentBranch = await service.git.revparse(["--abbrev-ref", "HEAD"])
await service.git.checkoutBranch("feature", currentBranch)
// Attempt to save checkpoint from feature branch.
await expect(service.saveCheckpoint("test")).rejects.toThrow(
`Git branch mismatch: expected '${currentBranch}' but found 'feature'`,
)
// Attempt to restore checkpoint from feature branch.
expect(service.baseHash).toBeTruthy()
await expect(service.restoreCheckpoint(service.baseHash!)).rejects.toThrow(
`Git branch mismatch: expected '${currentBranch}' but found 'feature'`,
)
})
it("cleans up staged files if a commit fails", async () => {
await fs.writeFile(testFile, "Changed content")
// Mock git commit to simulate failure.
jest.spyOn(service.git, "commit").mockRejectedValue(new Error("Simulated commit failure"))
// Attempt to save checkpoint.
await expect(service.saveCheckpoint("test")).rejects.toThrow("Simulated commit failure")
// Verify files are unstaged.
const status = await service.git.status()
expect(status.staged).toHaveLength(0)
})
it("handles file deletions correctly", async () => {
await fs.writeFile(testFile, "I am tracked!")
const untrackedFile = path.join(service.workspaceDir, "new.txt")
await fs.writeFile(untrackedFile, "I am untracked!")
const commit1 = await service.saveCheckpoint("First checkpoint")
expect(commit1?.commit).toBeTruthy()
await fs.unlink(testFile)
await fs.unlink(untrackedFile)
const commit2 = await service.saveCheckpoint("Second checkpoint")
expect(commit2?.commit).toBeTruthy()
// Verify files are gone.
await expect(fs.readFile(testFile, "utf-8")).rejects.toThrow()
await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow()
// Restore first checkpoint.
await service.restoreCheckpoint(commit1!.commit)
expect(await fs.readFile(testFile, "utf-8")).toBe("I am tracked!")
expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
// Restore second checkpoint.
await service.restoreCheckpoint(commit2!.commit)
await expect(fs.readFile(testFile, "utf-8")).rejects.toThrow()
await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow()
})
})
describe("create", () => {
it("initializes a git repository if one does not already exist", async () => {
const workspaceDir = path.join(tmpDir, `checkpoint-service-test2-${Date.now()}`)
await fs.mkdir(workspaceDir)
const newTestFile = path.join(workspaceDir, "test.txt")
await fs.writeFile(newTestFile, "Hello, world!")
// Ensure the git repository was initialized.
const gitDir = path.join(workspaceDir, ".git")
await expect(fs.stat(gitDir)).rejects.toThrow()
const newService = await LocalCheckpointService.create({ taskId, workspaceDir, log: () => {} })
expect(await fs.stat(gitDir)).toBeTruthy()
// Save a checkpoint: Hello, world!
const commit1 = await newService.saveCheckpoint("Hello, world!")
expect(commit1?.commit).toBeTruthy()
expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
// Restore initial commit; the file should no longer exist.
expect(newService.baseHash).toBeTruthy()
await newService.restoreCheckpoint(newService.baseHash!)
await expect(fs.access(newTestFile)).rejects.toThrow()
// Restore to checkpoint 1; the file should now exist.
await newService.restoreCheckpoint(commit1!.commit)
expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
// Save a new checkpoint: Ahoy, world!
await fs.writeFile(newTestFile, "Ahoy, world!")
const commit2 = await newService.saveCheckpoint("Ahoy, world!")
expect(commit2?.commit).toBeTruthy()
expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
// Restore "Hello, world!"
await newService.restoreCheckpoint(commit1!.commit)
expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
// Restore "Ahoy, world!"
await newService.restoreCheckpoint(commit2!.commit)
expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
// Restore initial commit.
expect(newService.baseHash).toBeTruthy()
await newService.restoreCheckpoint(newService.baseHash!)
await expect(fs.access(newTestFile)).rejects.toThrow()
await fs.rm(newService.workspaceDir, { recursive: true, force: true })
})
it("respects existing git user configuration", async () => {
const workspaceDir = path.join(tmpDir, `checkpoint-service-test-config2-${Date.now()}`)
const userName = "Custom User"
const userEmail = "custom@example.com"
await initRepo({ workspaceDir, userName, userEmail })
const newService = await LocalCheckpointService.create({ taskId, workspaceDir, log: () => {} })
expect((await newService.git.getConfig("user.name")).value).toBe(userName)
expect((await newService.git.getConfig("user.email")).value).toBe(userEmail)
await fs.rm(workspaceDir, { recursive: true, force: true })
})
})
})

View file

@ -3,11 +3,11 @@
import fs from "fs/promises"
import path from "path"
import os from "os"
import { EventEmitter } from "events"
import { simpleGit, SimpleGit } from "simple-git"
import { ShadowCheckpointService } from "../ShadowCheckpointService"
import { CheckpointServiceFactory } from "../CheckpointServiceFactory"
jest.mock("globby", () => ({
globby: jest.fn().mockResolvedValue([]),
@ -63,13 +63,10 @@ describe("ShadowCheckpointService", () => {
const repo = await initRepo({ workspaceDir })
testFile = repo.testFile
service = await CheckpointServiceFactory.create({
strategy: "shadow",
options: { taskId, shadowDir, workspaceDir, log: () => {} },
})
workspaceGit = repo.git
service = await ShadowCheckpointService.create({ taskId, shadowDir, workspaceDir, log: () => {} })
await service.initShadowGit()
})
afterEach(async () => {
@ -313,6 +310,7 @@ describe("ShadowCheckpointService", () => {
const gitDir = path.join(shadowDir, "tasks", taskId, "checkpoints", ".git")
await expect(fs.stat(gitDir)).rejects.toThrow()
const newService = await ShadowCheckpointService.create({ taskId, shadowDir, workspaceDir, log: () => {} })
await newService.initShadowGit()
expect(await fs.stat(gitDir)).toBeTruthy()
// Save a new checkpoint: Ahoy, world!
@ -333,4 +331,168 @@ describe("ShadowCheckpointService", () => {
await fs.rm(newService.workspaceDir, { recursive: true, force: true })
})
})
describe("events", () => {
it("emits initialize event when service is created", async () => {
const shadowDir = path.join(tmpDir, `shadow-event-test-${Date.now()}`)
const workspaceDir = path.join(tmpDir, `workspace-event-test-${Date.now()}`)
await fs.mkdir(workspaceDir, { recursive: true })
const newTestFile = path.join(workspaceDir, "test.txt")
await fs.writeFile(newTestFile, "Testing events!")
// Create a mock implementation of emit to track events.
const emitSpy = jest.spyOn(EventEmitter.prototype, "emit")
// Create the service - this will trigger the initialize event.
const newService = await ShadowCheckpointService.create({ taskId, shadowDir, workspaceDir, log: () => {} })
await newService.initShadowGit()
// Find the initialize event in the emit calls.
let initializeEvent = null
for (let i = 0; i < emitSpy.mock.calls.length; i++) {
const call = emitSpy.mock.calls[i]
if (call[0] === "initialize") {
initializeEvent = call[1]
break
}
}
// Restore the spy.
emitSpy.mockRestore()
// Verify the event was emitted with the correct data.
expect(initializeEvent).not.toBeNull()
expect(initializeEvent.type).toBe("initialize")
expect(initializeEvent.workspaceDir).toBe(workspaceDir)
expect(initializeEvent.baseHash).toBeTruthy()
expect(typeof initializeEvent.created).toBe("boolean")
expect(typeof initializeEvent.duration).toBe("number")
// Verify the event was emitted with the correct data.
expect(initializeEvent).not.toBeNull()
expect(initializeEvent.type).toBe("initialize")
expect(initializeEvent.workspaceDir).toBe(workspaceDir)
expect(initializeEvent.baseHash).toBeTruthy()
expect(typeof initializeEvent.created).toBe("boolean")
expect(typeof initializeEvent.duration).toBe("number")
// Clean up.
await fs.rm(shadowDir, { recursive: true, force: true })
await fs.rm(workspaceDir, { recursive: true, force: true })
})
it("emits checkpoint event when saving checkpoint", async () => {
const checkpointHandler = jest.fn()
service.on("checkpoint", checkpointHandler)
await fs.writeFile(testFile, "Changed content for checkpoint event test")
const result = await service.saveCheckpoint("Test checkpoint event")
expect(result?.commit).toBeDefined()
expect(checkpointHandler).toHaveBeenCalledTimes(1)
const eventData = checkpointHandler.mock.calls[0][0]
expect(eventData.type).toBe("checkpoint")
expect(eventData.toHash).toBeDefined()
expect(eventData.toHash).toBe(result!.commit)
expect(typeof eventData.duration).toBe("number")
})
it("emits restore event when restoring checkpoint", async () => {
// First create a checkpoint to restore.
await fs.writeFile(testFile, "Content for restore test")
const commit = await service.saveCheckpoint("Checkpoint for restore test")
expect(commit?.commit).toBeTruthy()
// Change the file again.
await fs.writeFile(testFile, "Changed after checkpoint")
// Setup restore event listener.
const restoreHandler = jest.fn()
service.on("restore", restoreHandler)
// Restore the checkpoint.
await service.restoreCheckpoint(commit!.commit)
// Verify the event was emitted.
expect(restoreHandler).toHaveBeenCalledTimes(1)
const eventData = restoreHandler.mock.calls[0][0]
expect(eventData.type).toBe("restore")
expect(eventData.commitHash).toBe(commit!.commit)
expect(typeof eventData.duration).toBe("number")
// Verify the file was actually restored.
expect(await fs.readFile(testFile, "utf-8")).toBe("Content for restore test")
})
it("emits error event when an error occurs", async () => {
const errorHandler = jest.fn()
service.on("error", errorHandler)
// Force an error by providing an invalid commit hash.
const invalidCommitHash = "invalid-commit-hash"
// Try to restore an invalid checkpoint.
try {
await service.restoreCheckpoint(invalidCommitHash)
} catch (error) {
// Expected to throw, we're testing the event emission.
}
// Verify the error event was emitted.
expect(errorHandler).toHaveBeenCalledTimes(1)
const eventData = errorHandler.mock.calls[0][0]
expect(eventData.type).toBe("error")
expect(eventData.error).toBeInstanceOf(Error)
})
it("supports multiple event listeners for the same event", async () => {
const checkpointHandler1 = jest.fn()
const checkpointHandler2 = jest.fn()
service.on("checkpoint", checkpointHandler1)
service.on("checkpoint", checkpointHandler2)
await fs.writeFile(testFile, "Content for multiple listeners test")
const result = await service.saveCheckpoint("Testing multiple listeners")
// Verify both handlers were called with the same event data.
expect(checkpointHandler1).toHaveBeenCalledTimes(1)
expect(checkpointHandler2).toHaveBeenCalledTimes(1)
const eventData1 = checkpointHandler1.mock.calls[0][0]
const eventData2 = checkpointHandler2.mock.calls[0][0]
expect(eventData1).toEqual(eventData2)
expect(eventData1.type).toBe("checkpoint")
expect(eventData1.toHash).toBe(result?.commit)
})
it("allows removing event listeners", async () => {
const checkpointHandler = jest.fn()
// Add the listener.
service.on("checkpoint", checkpointHandler)
// Make a change and save a checkpoint.
await fs.writeFile(testFile, "Content for remove listener test - part 1")
await service.saveCheckpoint("Testing listener - part 1")
// Verify handler was called.
expect(checkpointHandler).toHaveBeenCalledTimes(1)
checkpointHandler.mockClear()
// Remove the listener.
service.off("checkpoint", checkpointHandler)
// Make another change and save a checkpoint.
await fs.writeFile(testFile, "Content for remove listener test - part 2")
await service.saveCheckpoint("Testing listener - part 2")
// Verify handler was not called after being removed.
expect(checkpointHandler).not.toHaveBeenCalled()
})
})
})

View file

@ -1,2 +0,0 @@
export * from "./types"
export * from "./CheckpointServiceFactory"

View file

@ -1,3 +1,4 @@
import EventEmitter from "events"
import { CommitResult } from "simple-git"
export type CheckpointResult = Partial<CommitResult> & Pick<CommitResult, "commit">
@ -13,15 +14,12 @@ export type CheckpointDiff = {
}
}
export type CheckpointStrategy = "local" | "shadow"
export interface CheckpointService {
saveCheckpoint(message: string): Promise<CheckpointResult | undefined>
restoreCheckpoint(commit: string): Promise<void>
getDiff(range: { from?: string; to?: string }): Promise<CheckpointDiff[]>
workspaceDir: string
baseHash?: string
strategy: CheckpointStrategy
version: number
}
@ -30,3 +28,38 @@ export interface CheckpointServiceOptions {
workspaceDir: string
log?: (message: string) => void
}
/**
* EventEmitter
*/
export interface CheckpointEventMap {
initialize: { type: "initialize"; workspaceDir: string; baseHash: string; created: boolean; duration: number }
checkpoint: {
type: "checkpoint"
isFirst: boolean
fromHash: string
toHash: string
duration: number
}
restore: { type: "restore"; commitHash: string; duration: number }
error: { type: "error"; error: Error }
}
export class CheckpointEventEmitter extends EventEmitter {
override emit<K extends keyof CheckpointEventMap>(event: K, data: CheckpointEventMap[K]): boolean {
return super.emit(event, data)
}
override on<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void): this {
return super.on(event, listener)
}
override off<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void): this {
return super.off(event, listener)
}
override once<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void): this {
return super.once(event, listener)
}
}

View file

@ -3,8 +3,6 @@ import { useMemo } from "react"
import { CheckpointMenu } from "./CheckpointMenu"
import { checkpointSchema } from "./schema"
const REQUIRED_VERSION = 1
type CheckpointSavedProps = {
ts: number
commitHash: string
@ -22,7 +20,7 @@ export const CheckpointSaved = ({ checkpoint, ...props }: CheckpointSavedProps)
const result = checkpointSchema.safeParse(checkpoint)
if (!result.success || result.data.version < REQUIRED_VERSION) {
if (!result.success) {
return undefined
}

View file

@ -4,8 +4,6 @@ export const checkpointSchema = z.object({
isFirst: z.boolean(),
from: z.string(),
to: z.string(),
strategy: z.enum(["local", "shadow"]),
version: z.number(),
})
export type Checkpoint = z.infer<typeof checkpointSchema>