diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 6ffd4218e9b..d025d726683 100644 --- a/src/core/Cline.ts +++ b/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 { // 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 { customInstructions, enableDiff = false, enableCheckpoints = true, - checkpointStorage = "task", fuzzyMatchThreshold = 1.0, consecutiveMistakeLimit = 3, task, @@ -223,7 +216,6 @@ export class Cline extends EventEmitter { 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 { } 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 { 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 { } } + 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 { 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 { 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 { 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 { 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 { }, { interval, timeout }, ) + return service } catch (err) { return undefined @@ -2569,7 +2571,7 @@ export class Cline extends EventEmitter { } } - public checkpointSave() { + public async checkpointSave() { const service = this.getCheckpointService() if (!service) { @@ -2580,6 +2582,7 @@ export class Cline extends EventEmitter { 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 { 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 }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 3184a08f5af..d27eecde245 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -483,7 +483,6 @@ export class ClineProvider extends EventEmitter implements | "customInstructions" | "enableDiff" | "enableCheckpoints" - | "checkpointStorage" | "fuzzyMatchThreshold" | "consecutiveMistakeLimit" | "experiments" @@ -495,7 +494,6 @@ export class ClineProvider extends EventEmitter implements customModePrompts, diffEnabled: enableDiff, enableCheckpoints, - checkpointStorage, fuzzyMatchThreshold, mode, customInstructions: globalInstructions, @@ -511,7 +509,6 @@ export class ClineProvider extends EventEmitter implements customInstructions: effectiveInstructions, enableDiff, enableCheckpoints, - checkpointStorage, fuzzyMatchThreshold, task, images, @@ -540,7 +537,6 @@ export class ClineProvider extends EventEmitter implements customModePrompts, diffEnabled: enableDiff, enableCheckpoints, - checkpointStorage, fuzzyMatchThreshold, mode, customInstructions: globalInstructions, @@ -550,38 +546,12 @@ export class ClineProvider extends EventEmitter 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 = { - 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 implements ttsSpeed, diffEnabled, enableCheckpoints, - checkpointStorage, taskHistory, soundVolume, browserViewportSize, @@ -1282,7 +1251,6 @@ export class ClineProvider extends EventEmitter 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 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, diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index a034a58861d..b6ad6864ec7 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/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, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index cdbe81c8cef..51ddb8dd0b4 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/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) diff --git a/src/exports/roo-code.d.ts b/src/exports/roo-code.d.ts index 066dd2cdc2c..8a62e412f6e 100644 --- a/src/exports/roo-code.d.ts +++ b/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 diff --git a/src/exports/types.ts b/src/exports/types.ts index 931e07fc256..ba3f82b26bb 100644 --- a/src/exports/types.ts +++ b/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 diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 6c30b6334ba..2d71df05333 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -44,19 +44,6 @@ export const toolGroupsSchema = z.enum(toolGroups) export type ToolGroup = z.infer -/** - * CheckpointStorage - */ - -export const checkpointStorages = ["task", "workspace"] as const - -export const checkpointStoragesSchema = z.enum(checkpointStorages) - -export type CheckpointStorage = z.infer - -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, diff --git a/src/services/checkpoints/RepoPerWorkspaceCheckpointService.ts b/src/services/checkpoints/RepoPerWorkspaceCheckpointService.ts deleted file mode 100644 index 6f2f51ad31c..00000000000 --- a/src/services/checkpoints/RepoPerWorkspaceCheckpointService.ts +++ /dev/null @@ -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, - ) - } -} diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index fc7153bab9d..d6e53980cbc 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/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 { - // 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}`) } } diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts index ecf791e9498..6e42cfae077 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts +++ b/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", "support@roocode.com") - - // 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", "support@roocode.com") - - // 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", "support@roocode.com") + + // 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", "support@roocode.com") + + // 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", "noreply@example.com") - - // 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() + }) }) - }) -}) + }, +) diff --git a/src/services/checkpoints/index.ts b/src/services/checkpoints/index.ts index 9794b34d4c8..0fc97869390 100644 --- a/src/services/checkpoints/index.ts +++ b/src/services/checkpoints/index.ts @@ -1,4 +1,3 @@ export type { CheckpointServiceOptions } from "./types" export { RepoPerTaskCheckpointService } from "./RepoPerTaskCheckpointService" -export { RepoPerWorkspaceCheckpointService } from "./RepoPerWorkspaceCheckpointService" diff --git a/src/services/search/file-search.ts b/src/services/search/file-search.ts index 59ac3164616..a25dd4068f9 100644 --- a/src/services/search/file-search.ts +++ b/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 { + 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() // Track unique directory paths. - const fileResults: { path: string; type: "file" | "folder"; label?: string }[] = [] - const dirSet = new Set() // 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()) { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 822e4239b57..23c842ca702 100644 --- a/src/shared/ExtensionMessage.ts +++ b/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 diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 6cfd5823581..ff071ff2ee8 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -64,7 +64,6 @@ export interface WebviewMessage { | "soundVolume" | "diffEnabled" | "enableCheckpoints" - | "checkpointStorage" | "browserViewportSize" | "screenshotQuality" | "remoteBrowserHost" diff --git a/src/shared/checkpoints.ts b/src/shared/checkpoints.ts deleted file mode 100644 index 2776e12b32a..00000000000 --- a/src/shared/checkpoints.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { CheckpointStorage, isCheckpointStorage } from "../schemas" - -export { type CheckpointStorage, isCheckpointStorage } diff --git a/webview-ui/src/components/settings/CheckpointSettings.tsx b/webview-ui/src/components/settings/CheckpointSettings.tsx index 6987ba4a03b..73eb3fe8ee0 100644 --- a/webview-ui/src/components/settings/CheckpointSettings.tsx +++ b/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 & { 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 (
diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 2e32630341a..2ec12cae18a 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -112,7 +112,6 @@ const SettingsView = forwardRef(({ onDone, t browserToolEnabled, browserViewportSize, enableCheckpoints, - checkpointStorage, diffEnabled, experiments, fuzzyMatchThreshold, @@ -235,7 +234,6 @@ const SettingsView = forwardRef(({ 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(({ onDone, t
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 8b50bf0aca7..9f5dc17a407 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/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, diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx index 9113f7a8dbc..39a5ad34dbb 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx +++ b/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",