Explorar o código

Await checkpoint saves (except the initial) (#2665)

Chris Estreich hai 8 meses
pai
achega
3b19d7a455

+ 33 - 30
src/core/Cline.ts

@@ -16,11 +16,7 @@ import { TokenUsage } from "../schemas"
 import { ApiHandler, buildApiHandler } from "../api"
 import { ApiStream } from "../api/transform/stream"
 import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider"
-import {
-	CheckpointServiceOptions,
-	RepoPerTaskCheckpointService,
-	RepoPerWorkspaceCheckpointService,
-} from "../services/checkpoints"
+import { CheckpointServiceOptions, RepoPerTaskCheckpointService } from "../services/checkpoints"
 import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
 import { fetchInstructionsTool } from "./tools/fetchInstructionsTool"
 import { listFilesTool } from "./tools/listFilesTool"
@@ -30,7 +26,6 @@ import { Terminal } from "../integrations/terminal/Terminal"
 import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry"
 import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
 import { listFiles } from "../services/glob/list-files"
-import { CheckpointStorage } from "../shared/checkpoints"
 import { ApiConfiguration } from "../shared/api"
 import { findLastIndex } from "../shared/array"
 import { combineApiRequests } from "../shared/combineApiRequests"
@@ -104,7 +99,6 @@ export type ClineOptions = {
 	customInstructions?: string
 	enableDiff?: boolean
 	enableCheckpoints?: boolean
-	checkpointStorage?: CheckpointStorage
 	fuzzyMatchThreshold?: number
 	consecutiveMistakeLimit?: number
 	task?: string
@@ -162,8 +156,8 @@ export class Cline extends EventEmitter<ClineEvents> {
 
 	// checkpoints
 	private enableCheckpoints: boolean
-	private checkpointStorage: CheckpointStorage
-	private checkpointService?: RepoPerTaskCheckpointService | RepoPerWorkspaceCheckpointService
+	private checkpointService?: RepoPerTaskCheckpointService
+	private checkpointServiceInitializing = false
 
 	// streaming
 	isWaitingForFirstChunk = false
@@ -184,7 +178,6 @@ export class Cline extends EventEmitter<ClineEvents> {
 		customInstructions,
 		enableDiff = false,
 		enableCheckpoints = true,
-		checkpointStorage = "task",
 		fuzzyMatchThreshold = 1.0,
 		consecutiveMistakeLimit = 3,
 		task,
@@ -223,7 +216,6 @@ export class Cline extends EventEmitter<ClineEvents> {
 		this.providerRef = new WeakRef(provider)
 		this.diffViewProvider = new DiffViewProvider(this.cwd)
 		this.enableCheckpoints = enableCheckpoints
-		this.checkpointStorage = checkpointStorage
 
 		this.rootTask = rootTask
 		this.parentTask = parentTask
@@ -1680,9 +1672,11 @@ export class Cline extends EventEmitter<ClineEvents> {
 		}
 
 		const recentlyModifiedFiles = this.fileContextTracker.getAndClearCheckpointPossibleFile()
+
 		if (recentlyModifiedFiles.length > 0) {
-			// TODO: we can track what file changes were made and only checkpoint those files, this will be save storage
-			this.checkpointSave()
+			// TODO: We can track what file changes were made and only
+			// checkpoint those files, this will be save storage.
+			await this.checkpointSave()
 		}
 
 		/*
@@ -2397,6 +2391,11 @@ export class Cline extends EventEmitter<ClineEvents> {
 			return this.checkpointService
 		}
 
+		if (this.checkpointServiceInitializing) {
+			console.log("[Cline#getCheckpointService] checkpoint service is still initializing")
+			return undefined
+		}
+
 		const log = (message: string) => {
 			console.log(message)
 
@@ -2407,11 +2406,13 @@ export class Cline extends EventEmitter<ClineEvents> {
 			}
 		}
 
+		console.log("[Cline#getCheckpointService] initializing checkpoints service")
+
 		try {
 			const workspaceDir = getWorkspacePath()
 
 			if (!workspaceDir) {
-				log("[Cline#initializeCheckpoints] workspace folder not found, disabling checkpoints")
+				log("[Cline#getCheckpointService] workspace folder not found, disabling checkpoints")
 				this.enableCheckpoints = false
 				return undefined
 			}
@@ -2419,7 +2420,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 			const globalStorageDir = this.providerRef.deref()?.context.globalStorageUri.fsPath
 
 			if (!globalStorageDir) {
-				log("[Cline#initializeCheckpoints] globalStorageDir not found, disabling checkpoints")
+				log("[Cline#getCheckpointService] globalStorageDir not found, disabling checkpoints")
 				this.enableCheckpoints = false
 				return undefined
 			}
@@ -2431,28 +2432,26 @@ export class Cline extends EventEmitter<ClineEvents> {
 				log,
 			}
 
-			// Only `task` is supported at the moment until we figure out how
-			// to fully isolate the `workspace` variant.
-			// const service =
-			// 	this.checkpointStorage === "task"
-			// 		? RepoPerTaskCheckpointService.create(options)
-			// 		: RepoPerWorkspaceCheckpointService.create(options)
-
 			const service = RepoPerTaskCheckpointService.create(options)
 
+			this.checkpointServiceInitializing = true
+
 			service.on("initialize", () => {
+				log("[Cline#getCheckpointService] service initialized")
+
 				try {
 					const isCheckpointNeeded =
 						typeof this.clineMessages.find(({ say }) => say === "checkpoint_saved") === "undefined"
 
 					this.checkpointService = service
+					this.checkpointServiceInitializing = false
 
 					if (isCheckpointNeeded) {
-						log("[Cline#initializeCheckpoints] no checkpoints found, saving initial checkpoint")
+						log("[Cline#getCheckpointService] no checkpoints found, saving initial checkpoint")
 						this.checkpointSave()
 					}
 				} catch (err) {
-					log("[Cline#initializeCheckpoints] caught error in on('initialize'), disabling checkpoints")
+					log("[Cline#getCheckpointService] caught error in on('initialize'), disabling checkpoints")
 					this.enableCheckpoints = false
 				}
 			})
@@ -2462,21 +2461,23 @@ export class Cline extends EventEmitter<ClineEvents> {
 					this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: to })
 
 					this.say("checkpoint_saved", to, undefined, undefined, { isFirst, from, to }).catch((err) => {
-						log("[Cline#initializeCheckpoints] caught unexpected error in say('checkpoint_saved')")
+						log("[Cline#getCheckpointService] caught unexpected error in say('checkpoint_saved')")
 						console.error(err)
 					})
 				} catch (err) {
 					log(
-						"[Cline#initializeCheckpoints] caught unexpected error in on('checkpoint'), disabling checkpoints",
+						"[Cline#getCheckpointService] caught unexpected error in on('checkpoint'), disabling checkpoints",
 					)
 					console.error(err)
 					this.enableCheckpoints = false
 				}
 			})
 
+			log("[Cline#getCheckpointService] initializing shadow git")
+
 			service.initShadowGit().catch((err) => {
 				log(
-					`[Cline#initializeCheckpoints] caught unexpected error in initShadowGit, disabling checkpoints (${err.message})`,
+					`[Cline#getCheckpointService] caught unexpected error in initShadowGit, disabling checkpoints (${err.message})`,
 				)
 				console.error(err)
 				this.enableCheckpoints = false
@@ -2484,7 +2485,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 
 			return service
 		} catch (err) {
-			log("[Cline#initializeCheckpoints] caught unexpected error, disabling checkpoints")
+			log("[Cline#getCheckpointService] caught unexpected error, disabling checkpoints")
 			this.enableCheckpoints = false
 			return undefined
 		}
@@ -2508,6 +2509,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 				},
 				{ interval, timeout },
 			)
+
 			return service
 		} catch (err) {
 			return undefined
@@ -2569,7 +2571,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 		}
 	}
 
-	public checkpointSave() {
+	public async checkpointSave() {
 		const service = this.getCheckpointService()
 
 		if (!service) {
@@ -2580,6 +2582,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 			this.providerRef
 				.deref()
 				?.log("[checkpointSave] checkpoints didn't initialize in time, disabling checkpoints for this task")
+
 			this.enableCheckpoints = false
 			return
 		}
@@ -2587,7 +2590,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 		telemetryService.captureCheckpointCreated(this.taskId)
 
 		// Start the checkpoint process in the background.
-		service.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`).catch((err) => {
+		return service.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`).catch((err) => {
 			console.error("[Cline#checkpointSave] caught unexpected error, disabling checkpoints", err)
 			this.enableCheckpoints = false
 		})

+ 1 - 34
src/core/webview/ClineProvider.ts

@@ -483,7 +483,6 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 				| "customInstructions"
 				| "enableDiff"
 				| "enableCheckpoints"
-				| "checkpointStorage"
 				| "fuzzyMatchThreshold"
 				| "consecutiveMistakeLimit"
 				| "experiments"
@@ -495,7 +494,6 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 			customModePrompts,
 			diffEnabled: enableDiff,
 			enableCheckpoints,
-			checkpointStorage,
 			fuzzyMatchThreshold,
 			mode,
 			customInstructions: globalInstructions,
@@ -511,7 +509,6 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 			customInstructions: effectiveInstructions,
 			enableDiff,
 			enableCheckpoints,
-			checkpointStorage,
 			fuzzyMatchThreshold,
 			task,
 			images,
@@ -540,7 +537,6 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 			customModePrompts,
 			diffEnabled: enableDiff,
 			enableCheckpoints,
-			checkpointStorage,
 			fuzzyMatchThreshold,
 			mode,
 			customInstructions: globalInstructions,
@@ -550,38 +546,12 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 		const modePrompt = customModePrompts?.[mode] as PromptComponent
 		const effectiveInstructions = [globalInstructions, modePrompt?.customInstructions].filter(Boolean).join("\n\n")
 
-		const taskId = historyItem.id
-		const globalStorageDir = this.contextProxy.globalStorageUri.fsPath
-		const workspaceDir = this.cwd
-
-		const checkpoints: Pick<ClineOptions, "enableCheckpoints" | "checkpointStorage"> = {
-			enableCheckpoints,
-			checkpointStorage,
-		}
-
-		if (enableCheckpoints) {
-			try {
-				checkpoints.checkpointStorage = await ShadowCheckpointService.getTaskStorage({
-					taskId,
-					globalStorageDir,
-					workspaceDir,
-				})
-
-				this.log(
-					`[ClineProvider#initClineWithHistoryItem] Using ${checkpoints.checkpointStorage} storage for ${taskId}`,
-				)
-			} catch (error) {
-				checkpoints.enableCheckpoints = false
-				this.log(`[ClineProvider#initClineWithHistoryItem] Error getting task storage: ${error.message}`)
-			}
-		}
-
 		const cline = new Cline({
 			provider: this,
 			apiConfiguration,
 			customInstructions: effectiveInstructions,
 			enableDiff,
-			...checkpoints,
+			enableCheckpoints,
 			fuzzyMatchThreshold,
 			historyItem,
 			experiments,
@@ -1210,7 +1180,6 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 			ttsSpeed,
 			diffEnabled,
 			enableCheckpoints,
-			checkpointStorage,
 			taskHistory,
 			soundVolume,
 			browserViewportSize,
@@ -1282,7 +1251,6 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 			ttsSpeed: ttsSpeed ?? 1.0,
 			diffEnabled: diffEnabled ?? true,
 			enableCheckpoints: enableCheckpoints ?? true,
-			checkpointStorage: checkpointStorage ?? "task",
 			shouldShowAnnouncement:
 				telemetrySetting !== "unset" && lastShownAnnouncementId !== this.latestAnnouncementId,
 			allowedCommands,
@@ -1377,7 +1345,6 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 			ttsSpeed: stateValues.ttsSpeed ?? 1.0,
 			diffEnabled: stateValues.diffEnabled ?? true,
 			enableCheckpoints: stateValues.enableCheckpoints ?? true,
-			checkpointStorage: stateValues.checkpointStorage ?? "task",
 			soundVolume: stateValues.soundVolume,
 			browserViewportSize: stateValues.browserViewportSize ?? "900x600",
 			screenshotQuality: stateValues.screenshotQuality ?? 75,

+ 0 - 3
src/core/webview/__tests__/ClineProvider.test.ts

@@ -407,7 +407,6 @@ describe("ClineProvider", () => {
 			ttsEnabled: false,
 			diffEnabled: false,
 			enableCheckpoints: false,
-			checkpointStorage: "task",
 			writeDelayMs: 1000,
 			browserViewportSize: "900x600",
 			fuzzyMatchThreshold: 1.0,
@@ -829,7 +828,6 @@ describe("ClineProvider", () => {
 			mode: "code",
 			diffEnabled: true,
 			enableCheckpoints: false,
-			checkpointStorage: "task",
 			fuzzyMatchThreshold: 1.0,
 			experiments: experimentDefault,
 		} as any)
@@ -848,7 +846,6 @@ describe("ClineProvider", () => {
 			customInstructions: modeCustomInstructions,
 			enableDiff: true,
 			enableCheckpoints: false,
-			checkpointStorage: "task",
 			fuzzyMatchThreshold: 1.0,
 			task: "Test task",
 			experiments: experimentDefault,

+ 1 - 7
src/core/webview/webviewMessageHandler.ts

@@ -4,7 +4,7 @@ import pWaitFor from "p-wait-for"
 import * as vscode from "vscode"
 
 import { ClineProvider } from "./ClineProvider"
-import { CheckpointStorage, Language, ApiConfigMeta } from "../../schemas"
+import { Language, ApiConfigMeta } from "../../schemas"
 import { changeLanguage, t } from "../../i18n"
 import { ApiConfiguration } from "../../shared/api"
 import { supportPrompt } from "../../shared/support-prompt"
@@ -655,12 +655,6 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
 			await updateGlobalState("enableCheckpoints", enableCheckpoints)
 			await provider.postStateToWebview()
 			break
-		case "checkpointStorage":
-			console.log(`[ClineProvider] checkpointStorage: ${message.text}`)
-			const checkpointStorage = message.text ?? "task"
-			await updateGlobalState("checkpointStorage", checkpointStorage as CheckpointStorage)
-			await provider.postStateToWebview()
-			break
 		case "browserViewportSize":
 			const browserViewportSize = message.text ?? "900x600"
 			await updateGlobalState("browserViewportSize", browserViewportSize)

+ 0 - 1
src/exports/roo-code.d.ts

@@ -259,7 +259,6 @@ type GlobalSettings = {
 	remoteBrowserHost?: string | undefined
 	cachedChromeHostUrl?: string | undefined
 	enableCheckpoints?: boolean | undefined
-	checkpointStorage?: ("task" | "workspace") | undefined
 	showGreeting?: boolean | undefined
 	ttsEnabled?: boolean | undefined
 	ttsSpeed?: number | undefined

+ 0 - 1
src/exports/types.ts

@@ -262,7 +262,6 @@ type GlobalSettings = {
 	remoteBrowserHost?: string | undefined
 	cachedChromeHostUrl?: string | undefined
 	enableCheckpoints?: boolean | undefined
-	checkpointStorage?: ("task" | "workspace") | undefined
 	showGreeting?: boolean | undefined
 	ttsEnabled?: boolean | undefined
 	ttsSpeed?: number | undefined

+ 0 - 15
src/schemas/index.ts

@@ -44,19 +44,6 @@ export const toolGroupsSchema = z.enum(toolGroups)
 
 export type ToolGroup = z.infer<typeof toolGroupsSchema>
 
-/**
- * CheckpointStorage
- */
-
-export const checkpointStorages = ["task", "workspace"] as const
-
-export const checkpointStoragesSchema = z.enum(checkpointStorages)
-
-export type CheckpointStorage = z.infer<typeof checkpointStoragesSchema>
-
-export const isCheckpointStorage = (value: string): value is CheckpointStorage =>
-	checkpointStorages.includes(value as CheckpointStorage)
-
 /**
  * Language
  */
@@ -536,7 +523,6 @@ export const globalSettingsSchema = z.object({
 	cachedChromeHostUrl: z.string().optional(),
 
 	enableCheckpoints: z.boolean().optional(),
-	checkpointStorage: checkpointStoragesSchema.optional(),
 
 	showGreeting: z.boolean().optional(),
 
@@ -614,7 +600,6 @@ const globalSettingsRecord: GlobalSettingsRecord = {
 	remoteBrowserHost: undefined,
 
 	enableCheckpoints: undefined,
-	checkpointStorage: undefined,
 
 	showGreeting: undefined,
 

+ 0 - 75
src/services/checkpoints/RepoPerWorkspaceCheckpointService.ts

@@ -1,75 +0,0 @@
-import * as path from "path"
-
-import { CheckpointServiceOptions } from "./types"
-import { ShadowCheckpointService } from "./ShadowCheckpointService"
-
-export class RepoPerWorkspaceCheckpointService extends ShadowCheckpointService {
-	private async checkoutTaskBranch(source: string) {
-		if (!this.git) {
-			throw new Error("Shadow git repo not initialized")
-		}
-
-		const startTime = Date.now()
-		const branch = `roo-${this.taskId}`
-		const currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"])
-
-		if (currentBranch === branch) {
-			return
-		}
-
-		this.log(`[${this.constructor.name}#checkoutTaskBranch{${source}}] checking out ${branch}`)
-		const branches = await this.git.branchLocal()
-		let exists = branches.all.includes(branch)
-
-		if (!exists) {
-			await this.git.checkoutLocalBranch(branch)
-		} else {
-			await this.git.checkout(branch)
-		}
-
-		const duration = Date.now() - startTime
-
-		this.log(
-			`[${this.constructor.name}#checkoutTaskBranch{${source}}] ${exists ? "checked out" : "created"} branch "${branch}" in ${duration}ms`,
-		)
-	}
-
-	override async initShadowGit() {
-		return await super.initShadowGit(() => this.checkoutTaskBranch("initShadowGit"))
-	}
-
-	override async saveCheckpoint(message: string) {
-		await this.checkoutTaskBranch("saveCheckpoint")
-		return super.saveCheckpoint(message)
-	}
-
-	override async restoreCheckpoint(commitHash: string) {
-		await this.checkoutTaskBranch("restoreCheckpoint")
-		await super.restoreCheckpoint(commitHash)
-	}
-
-	override async getDiff({ from, to }: { from?: string; to?: string }) {
-		if (!this.git) {
-			throw new Error("Shadow git repo not initialized")
-		}
-
-		await this.checkoutTaskBranch("getDiff")
-
-		if (!from && to) {
-			from = `${to}~`
-		}
-
-		return super.getDiff({ from, to })
-	}
-
-	public static create({ taskId, workspaceDir, shadowDir, log = console.log }: CheckpointServiceOptions) {
-		const workspaceHash = this.hashWorkspaceDir(workspaceDir)
-
-		return new RepoPerWorkspaceCheckpointService(
-			taskId,
-			path.join(shadowDir, "checkpoints", workspaceHash),
-			workspaceDir,
-			log,
-		)
-	}
-}

+ 52 - 79
src/services/checkpoints/ShadowCheckpointService.ts

@@ -5,11 +5,10 @@ import crypto from "crypto"
 import EventEmitter from "events"
 
 import simpleGit, { SimpleGit } from "simple-git"
-import { globby } from "globby"
 import pWaitFor from "p-wait-for"
 
 import { fileExistsAtPath } from "../../utils/fs"
-import { CheckpointStorage } from "../../shared/checkpoints"
+import { executeRipgrep } from "../../services/search/file-search"
 
 import { GIT_DISABLED_SUFFIX } from "./constants"
 import { CheckpointDiff, CheckpointResult, CheckpointEventMap } from "./types"
@@ -150,39 +149,54 @@ export abstract class ShadowCheckpointService extends EventEmitter {
 	// 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,
-		})
+		try {
+			// Find all .git directories that are not at the root level.
+			const gitDir = ".git" + (disable ? "" : GIT_DISABLED_SUFFIX)
+			const args = ["--files", "--hidden", "--follow", "-g", `**/${gitDir}/HEAD`, this.workspaceDir]
+
+			const gitPaths = await (
+				await executeRipgrep({ args, workspacePath: this.workspaceDir })
+			).filter(({ type, path }) => type === "folder" && path.includes(".git") && !path.startsWith(".git"))
+
+			// For each nested .git directory, rename it based on operation.
+			for (const gitPath of gitPaths) {
+				if (gitPath.path.startsWith(".git")) {
+					continue
+				}
 
-		// 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
+				const currentPath = path.join(this.workspaceDir, gitPath.path)
+				let newPath: string
+
+				if (disable) {
+					newPath = !currentPath.endsWith(GIT_DISABLED_SUFFIX)
+						? currentPath + GIT_DISABLED_SUFFIX
+						: currentPath
+				} else {
+					newPath = currentPath.endsWith(GIT_DISABLED_SUFFIX)
+						? currentPath.slice(0, -GIT_DISABLED_SUFFIX.length)
+						: currentPath
+				}
 
-			if (disable) {
-				newPath = fullPath + GIT_DISABLED_SUFFIX
-			} else {
-				newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX)
-					? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length)
-					: fullPath
-			}
+				if (currentPath === newPath) {
+					continue
+				}
 
-			try {
-				await fs.rename(fullPath, newPath)
+				try {
+					await fs.rename(currentPath, 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)}`,
-				)
+					this.log(
+						`[${this.constructor.name}#renameNestedGitRepos] ${disable ? "disabled" : "enabled"} nested git repo ${currentPath}`,
+					)
+				} catch (error) {
+					this.log(
+						`[${this.constructor.name}#renameNestedGitRepos] failed to ${disable ? "disable" : "enable"} nested git repo ${currentPath}: ${error instanceof Error ? error.message : String(error)}`,
+					)
+				}
 			}
+		} catch (error) {
+			this.log(
+				`[${this.constructor.name}#renameNestedGitRepos] failed to ${disable ? "disable" : "enable"} nested git repos: ${error instanceof Error ? error.message : String(error)}`,
+			)
 		}
 	}
 
@@ -344,7 +358,7 @@ export abstract class ShadowCheckpointService extends EventEmitter {
 		return path.join(globalStorageDir, "checkpoints", this.hashWorkspaceDir(workspaceDir))
 	}
 
-	public static async getTaskStorage({
+	public static async deleteTask({
 		taskId,
 		globalStorageDir,
 		workspaceDir,
@@ -352,57 +366,16 @@ export abstract class ShadowCheckpointService extends EventEmitter {
 		taskId: string
 		globalStorageDir: string
 		workspaceDir: string
-	}): Promise<CheckpointStorage | undefined> {
-		// Is there a checkpoints repo in the task directory?
-		const taskRepoDir = this.taskRepoDir({ taskId, globalStorageDir })
-
-		if (await fileExistsAtPath(taskRepoDir)) {
-			return "task"
-		}
-
-		// Does the workspace checkpoints repo have a branch for this task?
+	}) {
 		const workspaceRepoDir = this.workspaceRepoDir({ globalStorageDir, workspaceDir })
-
-		if (!(await fileExistsAtPath(workspaceRepoDir))) {
-			return undefined
-		}
-
+		const branchName = `roo-${taskId}`
 		const git = simpleGit(workspaceRepoDir)
-		const branches = await git.branchLocal()
+		const success = await this.deleteBranch(git, branchName)
 
-		if (branches.all.includes(`roo-${taskId}`)) {
-			return "workspace"
-		}
-
-		return undefined
-	}
-
-	public static async deleteTask({
-		taskId,
-		globalStorageDir,
-		workspaceDir,
-	}: {
-		taskId: string
-		globalStorageDir: string
-		workspaceDir: string
-	}) {
-		const storage = await this.getTaskStorage({ taskId, globalStorageDir, workspaceDir })
-
-		if (storage === "task") {
-			const taskRepoDir = this.taskRepoDir({ taskId, globalStorageDir })
-			await fs.rm(taskRepoDir, { recursive: true, force: true })
-			console.log(`[${this.name}#deleteTask.${taskId}] removed ${taskRepoDir}`)
-		} else if (storage === "workspace") {
-			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}`)
-			}
+		if (success) {
+			console.log(`[${this.name}#deleteTask.${taskId}] deleted branch ${branchName}`)
+		} else {
+			console.error(`[${this.name}#deleteTask.${taskId}] failed to delete branch ${branchName}`)
 		}
 	}
 

+ 513 - 610
src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts

@@ -8,14 +8,9 @@ import { EventEmitter } from "events"
 import { simpleGit, SimpleGit } from "simple-git"
 
 import { fileExistsAtPath } from "../../../utils/fs"
+import * as fileSearch from "../../../services/search/file-search"
 
-import { ShadowCheckpointService } from "../ShadowCheckpointService"
 import { RepoPerTaskCheckpointService } from "../RepoPerTaskCheckpointService"
-import { RepoPerWorkspaceCheckpointService } from "../RepoPerWorkspaceCheckpointService"
-
-jest.mock("globby", () => ({
-	globby: jest.fn().mockResolvedValue([]),
-}))
 
 const tmpDir = path.join(os.tmpdir(), "CheckpointService")
 
@@ -52,680 +47,588 @@ const initWorkspaceRepo = async ({
 	return { git, testFile }
 }
 
-describe.each([
-	[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"],
-	[RepoPerWorkspaceCheckpointService, "RepoPerWorkspaceCheckpointService"],
-])("CheckpointService", (klass, prefix) => {
-	const taskId = "test-task"
-
-	let workspaceGit: SimpleGit
-	let testFile: string
-	let service: RepoPerTaskCheckpointService | RepoPerWorkspaceCheckpointService
-
-	beforeEach(async () => {
-		jest.mocked(require("globby").globby).mockClear().mockResolvedValue([])
-
-		const shadowDir = path.join(tmpDir, `${prefix}-${Date.now()}`)
-		const workspaceDir = path.join(tmpDir, `workspace-${Date.now()}`)
-		const repo = await initWorkspaceRepo({ workspaceDir })
-
-		workspaceGit = repo.git
-		testFile = repo.testFile
-
-		service = await klass.create({ taskId, shadowDir, workspaceDir, log: () => {} })
-		await service.initShadowGit()
-	})
-
-	afterEach(async () => {
-		jest.restoreAllMocks()
-	})
-
-	afterAll(async () => {
-		await fs.rm(tmpDir, { recursive: true, force: true })
-	})
-
-	describe(`${klass.name}#getDiff`, () => {
-		it("returns the correct diff between commits", async () => {
-			await fs.writeFile(testFile, "Ahoy, world!")
-			const commit1 = await service.saveCheckpoint("Ahoy, world!")
-			expect(commit1?.commit).toBeTruthy()
-
-			await fs.writeFile(testFile, "Goodbye, world!")
-			const commit2 = await service.saveCheckpoint("Goodbye, world!")
-			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({ from: service.baseHash, 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(`${klass.name}#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.getDiff({ to: commit1!.commit })
-			expect(details1[0].content.before).toContain("Hello, world!")
-			expect(details1[0].content.after).toContain("Ahoy, world!")
-
-			await fs.writeFile(testFile, "Hola, world!")
-			const commit2 = await service.saveCheckpoint("Second checkpoint")
-			expect(commit2?.commit).toBeTruthy()
-			const details2 = await service.getDiff({ from: commit1!.commit, to: commit2!.commit })
-			expect(details2[0].content.before).toContain("Ahoy, world!")
-			expect(details2[0].content.after).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!")
-		})
+describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
+	"CheckpointService",
+	(klass, prefix) => {
+		const taskId = "test-task"
 
-		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 workspaceGit.add(["."])
-			const result = await workspaceGit.commit("Add initial files")
-			expect(result?.commit).toBeTruthy()
-
-			await fs.writeFile(unstagedFile, "Modified unstaged")
-
-			await fs.writeFile(stagedFile, "Modified staged")
-			await workspaceGit.add([stagedFile])
-
-			await fs.writeFile(mixedFile, "Modified mixed - staged")
-			await workspaceGit.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 workspaceGit.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 workspaceGit.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 workspaceGit.diff(["mixed.txt"])
-			expect(unstagedDiff).toContain("-Modified mixed - staged")
-			expect(unstagedDiff).toContain("+Modified mixed - unstaged")
-		})
+		let workspaceGit: SimpleGit
+		let testFile: string
+		let service: RepoPerTaskCheckpointService
 
-		it("does not create a checkpoint if there are no pending changes", async () => {
-			const commit0 = await service.saveCheckpoint("Zeroth checkpoint")
-			expect(commit0?.commit).toBeFalsy()
+		beforeEach(async () => {
+			const shadowDir = path.join(tmpDir, `${prefix}-${Date.now()}`)
+			const workspaceDir = path.join(tmpDir, `workspace-${Date.now()}`)
+			const repo = await initWorkspaceRepo({ workspaceDir })
 
-			await fs.writeFile(testFile, "Ahoy, world!")
-			const commit1 = await service.saveCheckpoint("First checkpoint")
-			expect(commit1?.commit).toBeTruthy()
+			workspaceGit = repo.git
+			testFile = repo.testFile
 
-			const commit2 = await service.saveCheckpoint("Second checkpoint")
-			expect(commit2?.commit).toBeFalsy()
+			service = await klass.create({ taskId, shadowDir, workspaceDir, log: () => {} })
+			await service.initShadowGit()
 		})
 
-		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.getDiff({ to: commit1!.commit })
-			expect(details[0].content.before).toContain("")
-			expect(details[0].content.after).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")
+		afterEach(async () => {
+			jest.restoreAllMocks()
 		})
 
-		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()
+		afterAll(async () => {
+			await fs.rm(tmpDir, { recursive: true, force: true })
 		})
 
-		it("does not create a checkpoint for ignored files", async () => {
-			// Create a file that matches an ignored pattern (e.g., .log file).
-			const ignoredFile = path.join(service.workspaceDir, "ignored.log")
-			await fs.writeFile(ignoredFile, "Initial ignored content")
+		describe(`${klass.name}#getDiff`, () => {
+			it("returns the correct diff between commits", async () => {
+				await fs.writeFile(testFile, "Ahoy, world!")
+				const commit1 = await service.saveCheckpoint("Ahoy, world!")
+				expect(commit1?.commit).toBeTruthy()
+
+				await fs.writeFile(testFile, "Goodbye, world!")
+				const commit2 = await service.saveCheckpoint("Goodbye, world!")
+				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({ from: service.baseHash, 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!")
+			})
 
-			const commit = await service.saveCheckpoint("Ignored file checkpoint")
-			expect(commit?.commit).toBeFalsy()
+			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("")
+			})
+		})
 
-			await fs.writeFile(ignoredFile, "Modified ignored content")
+		describe(`${klass.name}#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.getDiff({ to: commit1!.commit })
+				expect(details1[0].content.before).toContain("Hello, world!")
+				expect(details1[0].content.after).toContain("Ahoy, world!")
+
+				await fs.writeFile(testFile, "Hola, world!")
+				const commit2 = await service.saveCheckpoint("Second checkpoint")
+				expect(commit2?.commit).toBeTruthy()
+				const details2 = await service.getDiff({ from: commit1!.commit, to: commit2!.commit })
+				expect(details2[0].content.before).toContain("Ahoy, world!")
+				expect(details2[0].content.after).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!")
+			})
 
-			const commit2 = await service.saveCheckpoint("Ignored file modified checkpoint")
-			expect(commit2?.commit).toBeFalsy()
+			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 workspaceGit.add(["."])
+				const result = await workspaceGit.commit("Add initial files")
+				expect(result?.commit).toBeTruthy()
+
+				await fs.writeFile(unstagedFile, "Modified unstaged")
+
+				await fs.writeFile(stagedFile, "Modified staged")
+				await workspaceGit.add([stagedFile])
+
+				await fs.writeFile(mixedFile, "Modified mixed - staged")
+				await workspaceGit.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 workspaceGit.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 workspaceGit.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 workspaceGit.diff(["mixed.txt"])
+				expect(unstagedDiff).toContain("-Modified mixed - staged")
+				expect(unstagedDiff).toContain("+Modified mixed - unstaged")
+			})
 
-			expect(await fs.readFile(ignoredFile, "utf-8")).toBe("Modified ignored content")
-		})
+			it("does not create a checkpoint if there are no pending changes", async () => {
+				const commit0 = await service.saveCheckpoint("Zeroth checkpoint")
+				expect(commit0?.commit).toBeFalsy()
 
-		it("does not create a checkpoint for LFS files", async () => {
-			// Create a .gitattributes file with LFS patterns.
-			const gitattributesPath = path.join(service.workspaceDir, ".gitattributes")
-			await fs.writeFile(gitattributesPath, "*.lfs filter=lfs diff=lfs merge=lfs -text")
+				await fs.writeFile(testFile, "Ahoy, world!")
+				const commit1 = await service.saveCheckpoint("First checkpoint")
+				expect(commit1?.commit).toBeTruthy()
 
-			// Re-initialize the service to trigger a write to .git/info/exclude.
-			service = new klass(service.taskId, service.checkpointsDir, service.workspaceDir, () => {})
-			const excludesPath = path.join(service.checkpointsDir, ".git", "info", "exclude")
-			expect((await fs.readFile(excludesPath, "utf-8")).split("\n")).not.toContain("*.lfs")
-			await service.initShadowGit()
-			expect((await fs.readFile(excludesPath, "utf-8")).split("\n")).toContain("*.lfs")
+				const commit2 = await service.saveCheckpoint("Second checkpoint")
+				expect(commit2?.commit).toBeFalsy()
+			})
 
-			const commit0 = await service.saveCheckpoint("Add gitattributes")
-			expect(commit0?.commit).toBeTruthy()
+			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.getDiff({ to: commit1!.commit })
+				expect(details[0].content.before).toContain("")
+				expect(details[0].content.after).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")
+			})
 
-			// Create a file that matches an LFS pattern.
-			const lfsFile = path.join(service.workspaceDir, "foo.lfs")
-			await fs.writeFile(lfsFile, "Binary file content simulation")
+			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()
+			})
 
-			const commit = await service.saveCheckpoint("LFS file checkpoint")
-			expect(commit?.commit).toBeFalsy()
+			it("does not create a checkpoint for ignored files", async () => {
+				// Create a file that matches an ignored pattern (e.g., .log file).
+				const ignoredFile = path.join(service.workspaceDir, "ignored.log")
+				await fs.writeFile(ignoredFile, "Initial ignored content")
 
-			await fs.writeFile(lfsFile, "Modified binary content")
+				const commit = await service.saveCheckpoint("Ignored file checkpoint")
+				expect(commit?.commit).toBeFalsy()
 
-			const commit2 = await service.saveCheckpoint("LFS file modified checkpoint")
-			expect(commit2?.commit).toBeFalsy()
+				await fs.writeFile(ignoredFile, "Modified ignored content")
 
-			expect(await fs.readFile(lfsFile, "utf-8")).toBe("Modified binary content")
-		})
-	})
-
-	describe(`${klass.name}#create`, () => {
-		it("initializes a git repository if one does not already exist", async () => {
-			const shadowDir = path.join(tmpDir, `${prefix}2-${Date.now()}`)
-			const workspaceDir = path.join(tmpDir, `workspace2-${Date.now()}`)
-			await fs.mkdir(workspaceDir)
-
-			const newTestFile = path.join(workspaceDir, "test.txt")
-			await fs.writeFile(newTestFile, "Hello, world!")
-			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
-
-			// Ensure the git repository was initialized.
-			const newService = await klass.create({ taskId, shadowDir, workspaceDir, log: () => {} })
-			const { created } = await newService.initShadowGit()
-			expect(created).toBeTruthy()
-
-			const gitDir = path.join(newService.checkpointsDir, ".git")
-			expect(await fs.stat(gitDir)).toBeTruthy()
-
-			// Save a new checkpoint: Ahoy, world!
-			await fs.writeFile(newTestFile, "Ahoy, world!")
-			const commit1 = await newService.saveCheckpoint("Ahoy, world!")
-			expect(commit1?.commit).toBeTruthy()
-			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
-
-			// Restore "Hello, world!"
-			await newService.restoreCheckpoint(newService.baseHash!)
-			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
-
-			// Restore "Ahoy, world!"
-			await newService.restoreCheckpoint(commit1!.commit)
-			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
-
-			await fs.rm(newService.checkpointsDir, { recursive: true, force: true })
-			await fs.rm(newService.workspaceDir, { recursive: true, force: true })
-		})
-	})
-
-	describe(`${klass.name}#renameNestedGitRepos`, () => {
-		it("handles nested git repositories during initialization", async () => {
-			// Create a new temporary workspace and service for this test.
-			const shadowDir = path.join(tmpDir, `${prefix}-nested-git-${Date.now()}`)
-			const workspaceDir = path.join(tmpDir, `workspace-nested-git-${Date.now()}`)
-
-			// Create a primary workspace repo.
-			await fs.mkdir(workspaceDir, { recursive: true })
-			const mainGit = simpleGit(workspaceDir)
-			await mainGit.init()
-			await mainGit.addConfig("user.name", "Roo Code")
-			await mainGit.addConfig("user.email", "[email protected]")
-
-			// Create a nested repo inside the workspace.
-			const nestedRepoPath = path.join(workspaceDir, "nested-project")
-			await fs.mkdir(nestedRepoPath, { recursive: true })
-			const nestedGit = simpleGit(nestedRepoPath)
-			await nestedGit.init()
-			await nestedGit.addConfig("user.name", "Roo Code")
-			await nestedGit.addConfig("user.email", "[email protected]")
-
-			// Add a file to the nested repo.
-			const nestedFile = path.join(nestedRepoPath, "nested-file.txt")
-			await fs.writeFile(nestedFile, "Content in nested repo")
-			await nestedGit.add(".")
-			await nestedGit.commit("Initial commit in nested repo")
-
-			// Create a test file in the main workspace.
-			const mainFile = path.join(workspaceDir, "main-file.txt")
-			await fs.writeFile(mainFile, "Content in main repo")
-			await mainGit.add(".")
-			await mainGit.commit("Initial commit in main repo")
-
-			// Confirm nested git directory exists before initialization.
-			const nestedGitDir = path.join(nestedRepoPath, ".git")
-			const nestedGitDisabledDir = `${nestedGitDir}_disabled`
-			expect(await fileExistsAtPath(nestedGitDir)).toBe(true)
-			expect(await fileExistsAtPath(nestedGitDisabledDir)).toBe(false)
-
-			// Configure globby mock to return our nested git repository.
-			const relativeGitPath = path.relative(workspaceDir, nestedGitDir)
-
-			jest.mocked(require("globby").globby).mockImplementation((pattern: string | string[]) => {
-				if (pattern === "**/.git") {
-					return Promise.resolve([relativeGitPath])
-				} else if (pattern === "**/.git_disabled") {
-					return Promise.resolve([`${relativeGitPath}_disabled`])
-				}
+				const commit2 = await service.saveCheckpoint("Ignored file modified checkpoint")
+				expect(commit2?.commit).toBeFalsy()
 
-				return Promise.resolve([])
+				expect(await fs.readFile(ignoredFile, "utf-8")).toBe("Modified ignored content")
 			})
 
-			// Create a spy on fs.rename to track when it's called.
-			const renameSpy = jest.spyOn(fs, "rename")
+			it("does not create a checkpoint for LFS files", async () => {
+				// Create a .gitattributes file with LFS patterns.
+				const gitattributesPath = path.join(service.workspaceDir, ".gitattributes")
+				await fs.writeFile(gitattributesPath, "*.lfs filter=lfs diff=lfs merge=lfs -text")
 
-			// Initialize the shadow git service.
-			const service = new klass(taskId, shadowDir, workspaceDir, () => {})
+				// Re-initialize the service to trigger a write to .git/info/exclude.
+				service = new klass(service.taskId, service.checkpointsDir, service.workspaceDir, () => {})
+				const excludesPath = path.join(service.checkpointsDir, ".git", "info", "exclude")
+				expect((await fs.readFile(excludesPath, "utf-8")).split("\n")).not.toContain("*.lfs")
+				await service.initShadowGit()
+				expect((await fs.readFile(excludesPath, "utf-8")).split("\n")).toContain("*.lfs")
 
-			// Override renameNestedGitRepos to track calls.
-			const originalRenameMethod = service["renameNestedGitRepos"].bind(service)
-			let disableCall = false
-			let enableCall = false
+				const commit0 = await service.saveCheckpoint("Add gitattributes")
+				expect(commit0?.commit).toBeTruthy()
 
-			service["renameNestedGitRepos"] = async (disable: boolean) => {
-				if (disable) {
-					disableCall = true
-				} else {
-					enableCall = true
-				}
+				// Create a file that matches an LFS pattern.
+				const lfsFile = path.join(service.workspaceDir, "foo.lfs")
+				await fs.writeFile(lfsFile, "Binary file content simulation")
 
-				return originalRenameMethod(disable)
-			}
+				const commit = await service.saveCheckpoint("LFS file checkpoint")
+				expect(commit?.commit).toBeFalsy()
 
-			// Initialize the shadow git repo.
-			await service.initShadowGit()
+				await fs.writeFile(lfsFile, "Modified binary content")
 
-			// Verify both disable and enable were called.
-			expect(disableCall).toBe(true)
-			expect(enableCall).toBe(true)
-
-			// Verify rename was called with correct paths.
-			const renameCallsArgs = renameSpy.mock.calls.map((call) => call[0] + " -> " + call[1])
-			expect(
-				renameCallsArgs.some((args) => args.includes(nestedGitDir) && args.includes(nestedGitDisabledDir)),
-			).toBe(true)
-			expect(
-				renameCallsArgs.some((args) => args.includes(nestedGitDisabledDir) && args.includes(nestedGitDir)),
-			).toBe(true)
-
-			// Verify the nested git directory is back to normal after initialization.
-			expect(await fileExistsAtPath(nestedGitDir)).toBe(true)
-			expect(await fileExistsAtPath(nestedGitDisabledDir)).toBe(false)
-
-			// Clean up.
-			renameSpy.mockRestore()
-			await fs.rm(shadowDir, { recursive: true, force: true })
-			await fs.rm(workspaceDir, { recursive: true, force: true })
-		})
-	})
+				const commit2 = await service.saveCheckpoint("LFS file modified checkpoint")
+				expect(commit2?.commit).toBeFalsy()
 
-	describe(`${klass.name}#events`, () => {
-		it("emits initialize event when service is created", async () => {
-			const shadowDir = path.join(tmpDir, `${prefix}3-${Date.now()}`)
-			const workspaceDir = path.join(tmpDir, `workspace3-${Date.now()}`)
-			await fs.mkdir(workspaceDir, { recursive: true })
+				expect(await fs.readFile(lfsFile, "utf-8")).toBe("Modified binary content")
+			})
+		})
 
-			const newTestFile = path.join(workspaceDir, "test.txt")
-			await fs.writeFile(newTestFile, "Testing events!")
+		describe(`${klass.name}#create`, () => {
+			it("initializes a git repository if one does not already exist", async () => {
+				const shadowDir = path.join(tmpDir, `${prefix}2-${Date.now()}`)
+				const workspaceDir = path.join(tmpDir, `workspace2-${Date.now()}`)
+				await fs.mkdir(workspaceDir)
 
-			// Create a mock implementation of emit to track events.
-			const emitSpy = jest.spyOn(EventEmitter.prototype, "emit")
+				const newTestFile = path.join(workspaceDir, "test.txt")
+				await fs.writeFile(newTestFile, "Hello, world!")
+				expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
 
-			// Create the service - this will trigger the initialize event.
-			const newService = await klass.create({ taskId, shadowDir, workspaceDir, log: () => {} })
-			await newService.initShadowGit()
+				// Ensure the git repository was initialized.
+				const newService = await klass.create({ taskId, shadowDir, workspaceDir, log: () => {} })
+				const { created } = await newService.initShadowGit()
+				expect(created).toBeTruthy()
 
-			// Find the initialize event in the emit calls.
-			let initializeEvent = null
+				const gitDir = path.join(newService.checkpointsDir, ".git")
+				expect(await fs.stat(gitDir)).toBeTruthy()
 
-			for (let i = 0; i < emitSpy.mock.calls.length; i++) {
-				const call = emitSpy.mock.calls[i]
+				// Save a new checkpoint: Ahoy, world!
+				await fs.writeFile(newTestFile, "Ahoy, world!")
+				const commit1 = await newService.saveCheckpoint("Ahoy, world!")
+				expect(commit1?.commit).toBeTruthy()
+				expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
 
-				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 })
-		})
+				// Restore "Hello, world!"
+				await newService.restoreCheckpoint(newService.baseHash!)
+				expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
 
-		it("emits checkpoint event when saving checkpoint", async () => {
-			const checkpointHandler = jest.fn()
-			service.on("checkpoint", checkpointHandler)
+				// Restore "Ahoy, world!"
+				await newService.restoreCheckpoint(commit1!.commit)
+				expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
 
-			await fs.writeFile(testFile, "Changed content for checkpoint event test")
-			const result = await service.saveCheckpoint("Test checkpoint event")
-			expect(result?.commit).toBeDefined()
+				await fs.rm(newService.checkpointsDir, { recursive: true, force: true })
+				await fs.rm(newService.workspaceDir, { recursive: true, force: true })
+			})
+		})
 
-			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")
+		describe(`${klass.name}#renameNestedGitRepos`, () => {
+			it("handles nested git repositories during initialization", async () => {
+				// Create a new temporary workspace and service for this test.
+				const shadowDir = path.join(tmpDir, `${prefix}-nested-git-${Date.now()}`)
+				const workspaceDir = path.join(tmpDir, `workspace-nested-git-${Date.now()}`)
+
+				// Create a primary workspace repo.
+				await fs.mkdir(workspaceDir, { recursive: true })
+				const mainGit = simpleGit(workspaceDir)
+				await mainGit.init()
+				await mainGit.addConfig("user.name", "Roo Code")
+				await mainGit.addConfig("user.email", "[email protected]")
+
+				// Create a nested repo inside the workspace.
+				const nestedRepoPath = path.join(workspaceDir, "nested-project")
+				await fs.mkdir(nestedRepoPath, { recursive: true })
+				const nestedGit = simpleGit(nestedRepoPath)
+				await nestedGit.init()
+				await nestedGit.addConfig("user.name", "Roo Code")
+				await nestedGit.addConfig("user.email", "[email protected]")
+
+				// Add a file to the nested repo.
+				const nestedFile = path.join(nestedRepoPath, "nested-file.txt")
+				await fs.writeFile(nestedFile, "Content in nested repo")
+				await nestedGit.add(".")
+				await nestedGit.commit("Initial commit in nested repo")
+
+				// Create a test file in the main workspace.
+				const mainFile = path.join(workspaceDir, "main-file.txt")
+				await fs.writeFile(mainFile, "Content in main repo")
+				await mainGit.add(".")
+				await mainGit.commit("Initial commit in main repo")
+
+				// Confirm nested git directory exists before initialization.
+				const nestedGitDir = path.join(nestedRepoPath, ".git")
+				const headFile = path.join(nestedGitDir, "HEAD")
+				await fs.writeFile(headFile, "HEAD")
+				const nestedGitDisabledDir = `${nestedGitDir}_disabled`
+				expect(await fileExistsAtPath(nestedGitDir)).toBe(true)
+				expect(await fileExistsAtPath(nestedGitDisabledDir)).toBe(false)
+
+				const renameSpy = jest.spyOn(fs, "rename")
+
+				jest.spyOn(fileSearch, "executeRipgrep").mockImplementation(({ args }) => {
+					const searchPattern = args[4]
+
+					if (searchPattern.includes(".git/HEAD")) {
+						return Promise.resolve([
+							{
+								path: path.relative(workspaceDir, nestedGitDir),
+								type: "folder",
+								label: ".git",
+							},
+						])
+					} else {
+						return Promise.resolve([])
+					}
+				})
+
+				const service = new klass(taskId, shadowDir, workspaceDir, () => {})
+				await service.initShadowGit()
+
+				// Verify rename was called with correct paths.
+				expect(renameSpy.mock.calls).toHaveLength(1)
+				expect(renameSpy.mock.calls[0][0]).toBe(nestedGitDir)
+				expect(renameSpy.mock.calls[0][1]).toBe(nestedGitDisabledDir)
+
+				jest.spyOn(require("../../../utils/fs"), "fileExistsAtPath").mockImplementation((path) => {
+					if (path === nestedGitDir) {
+						return Promise.resolve(true)
+					} else if (path === nestedGitDisabledDir) {
+						return Promise.resolve(false)
+					}
+
+					return Promise.resolve(false)
+				})
+
+				// Verify the nested git directory is back to normal after initialization.
+				expect(await fileExistsAtPath(nestedGitDir)).toBe(true)
+				expect(await fileExistsAtPath(nestedGitDisabledDir)).toBe(false)
+
+				// Clean up.
+				renameSpy.mockRestore()
+				jest.restoreAllMocks()
+				await fs.rm(shadowDir, { recursive: true, force: true })
+				await fs.rm(workspaceDir, { recursive: true, force: true })
+			})
 		})
 
-		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()
+		describe(`${klass.name}#events`, () => {
+			it("emits initialize event when service is created", async () => {
+				const shadowDir = path.join(tmpDir, `${prefix}3-${Date.now()}`)
+				const workspaceDir = path.join(tmpDir, `workspace3-${Date.now()}`)
+				await fs.mkdir(workspaceDir, { recursive: true })
 
-			// Change the file again.
-			await fs.writeFile(testFile, "Changed after checkpoint")
+				const newTestFile = path.join(workspaceDir, "test.txt")
+				await fs.writeFile(newTestFile, "Testing events!")
 
-			// Setup restore event listener.
-			const restoreHandler = jest.fn()
-			service.on("restore", restoreHandler)
+				// Create a mock implementation of emit to track events.
+				const emitSpy = jest.spyOn(EventEmitter.prototype, "emit")
 
-			// Restore the checkpoint.
-			await service.restoreCheckpoint(commit!.commit)
+				// Create the service - this will trigger the initialize event.
+				const newService = await klass.create({ taskId, shadowDir, workspaceDir, log: () => {} })
+				await newService.initShadowGit()
 
-			// 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")
+				// Find the initialize event in the emit calls.
+				let initializeEvent = null
 
-			// Verify the file was actually restored.
-			expect(await fs.readFile(testFile, "utf-8")).toBe("Content for restore test")
-		})
+				for (let i = 0; i < emitSpy.mock.calls.length; i++) {
+					const call = emitSpy.mock.calls[i]
 
-		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)
-		})
+					if (call[0] === "initialize") {
+						initializeEvent = call[1]
+						break
+					}
+				}
 
-		it("supports multiple event listeners for the same event", async () => {
-			const checkpointHandler1 = jest.fn()
-			const checkpointHandler2 = jest.fn()
+				// 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 })
+			})
 
-			service.on("checkpoint", checkpointHandler1)
-			service.on("checkpoint", checkpointHandler2)
+			it("emits checkpoint event when saving checkpoint", async () => {
+				const checkpointHandler = jest.fn()
+				service.on("checkpoint", checkpointHandler)
 
-			await fs.writeFile(testFile, "Content for multiple listeners test")
-			const result = await service.saveCheckpoint("Testing multiple listeners")
+				await fs.writeFile(testFile, "Changed content for checkpoint event test")
+				const result = await service.saveCheckpoint("Test checkpoint event")
+				expect(result?.commit).toBeDefined()
 
-			// Verify both handlers were called with the same event data.
-			expect(checkpointHandler1).toHaveBeenCalledTimes(1)
-			expect(checkpointHandler2).toHaveBeenCalledTimes(1)
+				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")
+			})
 
-			const eventData1 = checkpointHandler1.mock.calls[0][0]
-			const eventData2 = checkpointHandler2.mock.calls[0][0]
+			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()
 
-			expect(eventData1).toEqual(eventData2)
-			expect(eventData1.type).toBe("checkpoint")
-			expect(eventData1.toHash).toBe(result?.commit)
-		})
+				// Change the file again.
+				await fs.writeFile(testFile, "Changed after checkpoint")
 
-		it("allows removing event listeners", async () => {
-			const checkpointHandler = jest.fn()
+				// Setup restore event listener.
+				const restoreHandler = jest.fn()
+				service.on("restore", restoreHandler)
 
-			// Add the listener.
-			service.on("checkpoint", checkpointHandler)
+				// Restore the checkpoint.
+				await service.restoreCheckpoint(commit!.commit)
 
-			// 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 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 handler was called.
-			expect(checkpointHandler).toHaveBeenCalledTimes(1)
-			checkpointHandler.mockClear()
+				// Verify the file was actually restored.
+				expect(await fs.readFile(testFile, "utf-8")).toBe("Content for restore test")
+			})
 
-			// Remove the listener.
-			service.off("checkpoint", checkpointHandler)
+			it("emits error event when an error occurs", async () => {
+				const errorHandler = jest.fn()
+				service.on("error", errorHandler)
 
-			// 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")
+				// Force an error by providing an invalid commit hash.
+				const invalidCommitHash = "invalid-commit-hash"
 
-			// Verify handler was not called after being removed.
-			expect(checkpointHandler).not.toHaveBeenCalled()
-		})
-	})
-})
-
-describe("ShadowCheckpointService", () => {
-	const taskId = "test-task-storage"
-	const tmpDir = path.join(os.tmpdir(), "CheckpointService")
-	const globalStorageDir = path.join(tmpDir, "global-storage-dir")
-	const workspaceDir = path.join(tmpDir, "workspace-dir")
-	const workspaceHash = ShadowCheckpointService.hashWorkspaceDir(workspaceDir)
-
-	beforeEach(async () => {
-		await fs.mkdir(globalStorageDir, { recursive: true })
-		await fs.mkdir(workspaceDir, { recursive: true })
-	})
-
-	afterEach(async () => {
-		await fs.rm(globalStorageDir, { recursive: true, force: true })
-		await fs.rm(workspaceDir, { recursive: true, force: true })
-	})
-
-	describe("getTaskStorage", () => {
-		it("returns 'task' when task repo exists", async () => {
-			const service = RepoPerTaskCheckpointService.create({
-				taskId,
-				shadowDir: globalStorageDir,
-				workspaceDir,
-				log: () => {},
+				// 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)
 			})
 
-			await service.initShadowGit()
+			it("supports multiple event listeners for the same event", async () => {
+				const checkpointHandler1 = jest.fn()
+				const checkpointHandler2 = jest.fn()
 
-			const storage = await ShadowCheckpointService.getTaskStorage({ taskId, globalStorageDir, workspaceDir })
-			expect(storage).toBe("task")
-		})
+				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]
 
-		it("returns 'workspace' when workspace repo exists with task branch", async () => {
-			const service = RepoPerWorkspaceCheckpointService.create({
-				taskId,
-				shadowDir: globalStorageDir,
-				workspaceDir,
-				log: () => {},
+				expect(eventData1).toEqual(eventData2)
+				expect(eventData1.type).toBe("checkpoint")
+				expect(eventData1.toHash).toBe(result?.commit)
 			})
 
-			await service.initShadowGit()
+			it("allows removing event listeners", async () => {
+				const checkpointHandler = jest.fn()
 
-			const storage = await ShadowCheckpointService.getTaskStorage({ taskId, globalStorageDir, workspaceDir })
-			expect(storage).toBe("workspace")
-		})
+				// Add the listener.
+				service.on("checkpoint", checkpointHandler)
 
-		it("returns undefined when no repos exist", async () => {
-			const storage = await ShadowCheckpointService.getTaskStorage({ taskId, globalStorageDir, workspaceDir })
-			expect(storage).toBeUndefined()
-		})
+				// 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")
 
-		it("returns undefined when workspace repo exists but has no task branch", async () => {
-			// Setup: Create workspace repo without the task branch
-			const workspaceRepoDir = path.join(globalStorageDir, "checkpoints", workspaceHash)
-			await fs.mkdir(workspaceRepoDir, { recursive: true })
-
-			// Create git repo without adding the specific branch
-			const git = simpleGit(workspaceRepoDir)
-			await git.init()
-			await git.addConfig("user.name", "Roo Code")
-			await git.addConfig("user.email", "[email protected]")
-
-			// We need to create a commit, but we won't create the specific branch
-			const testFile = path.join(workspaceRepoDir, "test.txt")
-			await fs.writeFile(testFile, "Test content")
-			await git.add(".")
-			await git.commit("Initial commit")
-
-			const storage = await ShadowCheckpointService.getTaskStorage({
-				taskId,
-				globalStorageDir,
-				workspaceDir,
-			})
+				// Verify handler was called.
+				expect(checkpointHandler).toHaveBeenCalledTimes(1)
+				checkpointHandler.mockClear()
 
-			expect(storage).toBeUndefined()
+				// 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()
+			})
 		})
-	})
-})
+	},
+)

+ 0 - 1
src/services/checkpoints/index.ts

@@ -1,4 +1,3 @@
 export type { CheckpointServiceOptions } from "./types"
 
 export { RepoPerTaskCheckpointService } from "./RepoPerTaskCheckpointService"
-export { RepoPerWorkspaceCheckpointService } from "./RepoPerWorkspaceCheckpointService"

+ 51 - 44
src/services/search/file-search.ts

@@ -6,35 +6,29 @@ import * as readline from "readline"
 import { byLengthAsc, Fzf } from "fzf"
 import { getBinPath } from "../ripgrep"
 
-async function executeRipgrepForFiles(
-	rgPath: string,
-	workspacePath: string,
-	limit: number = 5000,
-): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> {
-	return new Promise((resolve, reject) => {
-		const args = [
-			"--files",
-			"--follow",
-			"--hidden",
-			"-g",
-			"!**/node_modules/**",
-			"-g",
-			"!**/.git/**",
-			"-g",
-			"!**/out/**",
-			"-g",
-			"!**/dist/**",
-			workspacePath,
-		]
+export type FileResult = { path: string; type: "file" | "folder"; label?: string }
+
+export async function executeRipgrep({
+	args,
+	workspacePath,
+	limit = 500,
+}: {
+	args: string[]
+	workspacePath: string
+	limit?: number
+}): Promise<FileResult[]> {
+	const rgPath = await getBinPath(vscode.env.appRoot)
+
+	if (!rgPath) {
+		throw new Error(`ripgrep not found: ${rgPath}`)
+	}
 
+	return new Promise((resolve, reject) => {
 		const rgProcess = childProcess.spawn(rgPath, args)
-		const rl = readline.createInterface({
-			input: rgProcess.stdout,
-			crlfDelay: Infinity,
-		})
+		const rl = readline.createInterface({ input: rgProcess.stdout, crlfDelay: Infinity })
+		const fileResults: FileResult[] = []
+		const dirSet = new Set<string>() // Track unique directory paths.
 
-		const fileResults: { path: string; type: "file" | "folder"; label?: string }[] = []
-		const dirSet = new Set<string>() // Track unique directory paths
 		let count = 0
 
 		rl.on("line", (line) => {
@@ -42,15 +36,12 @@ async function executeRipgrepForFiles(
 				try {
 					const relativePath = path.relative(workspacePath, line)
 
-					// Add the file itself
-					fileResults.push({
-						path: relativePath,
-						type: "file",
-						label: path.basename(relativePath),
-					})
+					// Add the file itself.
+					fileResults.push({ path: relativePath, type: "file", label: path.basename(relativePath) })
 
-					// Extract and store all parent directory paths
+					// Extract and store all parent directory paths.
 					let dirPath = path.dirname(relativePath)
+
 					while (dirPath && dirPath !== "." && dirPath !== "/") {
 						dirSet.add(dirPath)
 						dirPath = path.dirname(dirPath)
@@ -58,7 +49,7 @@ async function executeRipgrepForFiles(
 
 					count++
 				} catch (error) {
-					// Silently ignore errors processing individual paths
+					// Silently ignore errors processing individual paths.
 				}
 			} else {
 				rl.close()
@@ -67,6 +58,7 @@ async function executeRipgrepForFiles(
 		})
 
 		let errorOutput = ""
+
 		rgProcess.stderr.on("data", (data) => {
 			errorOutput += data.toString()
 		})
@@ -75,14 +67,14 @@ async function executeRipgrepForFiles(
 			if (errorOutput && fileResults.length === 0) {
 				reject(new Error(`ripgrep process error: ${errorOutput}`))
 			} else {
-				// Convert directory set to array of directory objects
+				// Convert directory set to array of directory objects.
 				const dirResults = Array.from(dirSet).map((dirPath) => ({
 					path: dirPath,
 					type: "folder" as const,
 					label: path.basename(dirPath),
 				}))
 
-				// Combine files and directories and resolve
+				// Combine files and directories and resolve.
 				resolve([...fileResults, ...dirResults])
 			}
 		})
@@ -93,21 +85,36 @@ async function executeRipgrepForFiles(
 	})
 }
 
+export async function executeRipgrepForFiles(
+	workspacePath: string,
+	limit: number = 5000,
+): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> {
+	const args = [
+		"--files",
+		"--follow",
+		"--hidden",
+		"-g",
+		"!**/node_modules/**",
+		"-g",
+		"!**/.git/**",
+		"-g",
+		"!**/out/**",
+		"-g",
+		"!**/dist/**",
+		workspacePath,
+	]
+
+	return executeRipgrep({ args, workspacePath, limit })
+}
+
 export async function searchWorkspaceFiles(
 	query: string,
 	workspacePath: string,
 	limit: number = 20,
 ): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> {
 	try {
-		const vscodeAppRoot = vscode.env.appRoot
-		const rgPath = await getBinPath(vscodeAppRoot)
-
-		if (!rgPath) {
-			throw new Error("Could not find ripgrep binary")
-		}
-
 		// Get all files and directories (from our modified function)
-		const allItems = await executeRipgrepForFiles(rgPath, workspacePath, 5000)
+		const allItems = await executeRipgrepForFiles(workspacePath, 5000)
 
 		// If no query, just return the top items
 		if (!query.trim()) {

+ 0 - 3
src/shared/ExtensionMessage.ts

@@ -5,7 +5,6 @@ import {
 	ProviderSettings as ApiConfiguration,
 	HistoryItem,
 	ModeConfig,
-	CheckpointStorage,
 	TelemetrySetting,
 	ExperimentId,
 	ClineAsk,
@@ -142,7 +141,6 @@ export type ExtensionState = Pick<
 	| "remoteBrowserEnabled"
 	| "remoteBrowserHost"
 	// | "enableCheckpoints" // Optional in GlobalSettings, required here.
-	// | "checkpointStorage" // Optional in GlobalSettings, required here.
 	| "showGreeting"
 	| "ttsEnabled"
 	| "ttsSpeed"
@@ -187,7 +185,6 @@ export type ExtensionState = Pick<
 	requestDelaySeconds: number
 
 	enableCheckpoints: boolean
-	checkpointStorage: CheckpointStorage
 	maxOpenTabsContext: number // Maximum number of VSCode open tabs to include in context (0-500)
 	maxWorkspaceFiles: number // Maximum number of files to include in current working directory details (0-500)
 	showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings

+ 0 - 1
src/shared/WebviewMessage.ts

@@ -64,7 +64,6 @@ export interface WebviewMessage {
 		| "soundVolume"
 		| "diffEnabled"
 		| "enableCheckpoints"
-		| "checkpointStorage"
 		| "browserViewportSize"
 		| "screenshotQuality"
 		| "remoteBrowserHost"

+ 0 - 3
src/shared/checkpoints.ts

@@ -1,3 +0,0 @@
-import { CheckpointStorage, isCheckpointStorage } from "../schemas"
-
-export { type CheckpointStorage, isCheckpointStorage }

+ 2 - 10
webview-ui/src/components/settings/CheckpointSettings.tsx

@@ -3,24 +3,16 @@ import { useAppTranslation } from "@/i18n/TranslationContext"
 import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
 import { GitBranch } from "lucide-react"
 
-import { CheckpointStorage } from "../../../../src/shared/checkpoints"
-
 import { SetCachedStateField } from "./types"
 import { SectionHeader } from "./SectionHeader"
 import { Section } from "./Section"
 
 type CheckpointSettingsProps = HTMLAttributes<HTMLDivElement> & {
 	enableCheckpoints?: boolean
-	checkpointStorage?: CheckpointStorage
-	setCachedStateField: SetCachedStateField<"enableCheckpoints" | "checkpointStorage">
+	setCachedStateField: SetCachedStateField<"enableCheckpoints">
 }
 
-export const CheckpointSettings = ({
-	enableCheckpoints,
-	checkpointStorage = "task",
-	setCachedStateField,
-	...props
-}: CheckpointSettingsProps) => {
+export const CheckpointSettings = ({ enableCheckpoints, setCachedStateField, ...props }: CheckpointSettingsProps) => {
 	const { t } = useAppTranslation()
 	return (
 		<div {...props}>

+ 0 - 3
webview-ui/src/components/settings/SettingsView.tsx

@@ -112,7 +112,6 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
 		browserToolEnabled,
 		browserViewportSize,
 		enableCheckpoints,
-		checkpointStorage,
 		diffEnabled,
 		experiments,
 		fuzzyMatchThreshold,
@@ -235,7 +234,6 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
 			vscode.postMessage({ type: "soundVolume", value: soundVolume })
 			vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
 			vscode.postMessage({ type: "enableCheckpoints", bool: enableCheckpoints })
-			vscode.postMessage({ type: "checkpointStorage", text: checkpointStorage })
 			vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize })
 			vscode.postMessage({ type: "remoteBrowserHost", text: remoteBrowserHost })
 			vscode.postMessage({ type: "remoteBrowserEnabled", bool: remoteBrowserEnabled })
@@ -466,7 +464,6 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
 				<div ref={checkpointsRef}>
 					<CheckpointSettings
 						enableCheckpoints={enableCheckpoints}
-						checkpointStorage={checkpointStorage}
 						setCachedStateField={setCachedStateField}
 					/>
 				</div>

+ 0 - 1
webview-ui/src/context/ExtensionStateContext.tsx

@@ -132,7 +132,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		ttsSpeed: 1.0,
 		diffEnabled: false,
 		enableCheckpoints: true,
-		checkpointStorage: "task",
 		fuzzyMatchThreshold: 1.0,
 		language: "en", // Default language code
 		writeDelayMs: 1000,

+ 0 - 1
webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx

@@ -190,7 +190,6 @@ describe("mergeExtensionState", () => {
 			taskHistory: [],
 			shouldShowAnnouncement: false,
 			enableCheckpoints: true,
-			checkpointStorage: "task",
 			writeDelayMs: 1000,
 			requestDelaySeconds: 5,
 			mode: "default",