From 499c7dfb9f61fe4877c69f4b4941ffd24b66a12d Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Sat, 1 Mar 2025 04:03:32 -0800 Subject: [PATCH] Revert to legacy checkpoint tracking --- CHANGELOG.md | 1 - src/core/Cline.ts | 22 +- src/core/webview/ClineProvider.ts | 30 +- .../checkpoints/CheckpointTracker-new.ts | 453 +++++++++++++ .../checkpoints/CheckpointTracker.ts | 637 +++++++++--------- 5 files changed, 772 insertions(+), 371 deletions(-) create mode 100644 src/integrations/checkpoints/CheckpointTracker-new.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 52be1d31b1..28ebb08cf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,6 @@ ## [3.5.0] - Add 'Enable extended thinking' option Claude 3.7 Sonnet, with ability to set different budgets for Plan and Act modes (thanks @celestial-vault!) -- Update checkpoints with improved storage optimization & faster performance (thanks @canvrno!) - New rich MCP responses with automatic image previews, website thumbnails, and WolframAlpha visualizations right in your conversation (thanks @Garoth!) - Add xAI Provider Integration with support for all Grok models, including the massive 131K token context window for large codebases (thanks @andrewmonostate!) - Add language preferences option in Advanced Settings (thanks @brownrw8!) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 79abc41bcd..30c395ceb2 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -285,10 +285,7 @@ export class Cline { case "workspace": if (!this.checkpointTracker) { try { - this.checkpointTracker = await CheckpointTracker.create( - this.taskId, - this.providerRef.deref()?.context.globalStorageUri.fsPath, - ) + this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref()) this.checkpointTrackerErrorMessage = undefined } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error" @@ -401,10 +398,7 @@ export class Cline { // TODO: handle if this is called from outside original workspace, in which case we need to show user error message we cant show diff outside of workspace? if (!this.checkpointTracker) { try { - this.checkpointTracker = await CheckpointTracker.create( - this.taskId, - this.providerRef.deref()?.context.globalStorageUri.fsPath, - ) + this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref()) this.checkpointTrackerErrorMessage = undefined } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error" @@ -508,10 +502,7 @@ export class Cline { if (!this.checkpointTracker) { try { - this.checkpointTracker = await CheckpointTracker.create( - this.taskId, - this.providerRef.deref()?.context.globalStorageUri.fsPath, - ) + this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref()) this.checkpointTrackerErrorMessage = undefined } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error" @@ -2936,7 +2927,7 @@ export class Cline { } /* - Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present. + Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present. When you see the UI inactive during this, it means that a tool is breaking without presenting any UI. For example the write_to_file tool was breaking when relpath was undefined, and for invalid relpath it never presented UI. */ this.presentAssistantMessageLocked = false // this needs to be placed here, if not then calling this.presentAssistantMessage below would fail (sometimes) since it's locked @@ -3043,10 +3034,7 @@ export class Cline { // isNewTask && if (!this.checkpointTracker) { try { - this.checkpointTracker = await CheckpointTracker.create( - this.taskId, - this.providerRef.deref()?.context.globalStorageUri.fsPath, - ) + this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref()) this.checkpointTrackerErrorMessage = undefined } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index d0d4c07586..d3f8322ee4 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -8,7 +8,6 @@ import pWaitFor from "p-wait-for" import * as path from "path" import * as vscode from "vscode" import { buildApiHandler } from "../../api" -import CheckpointTracker from "../../integrations/checkpoints/CheckpointTracker" import { downloadTask } from "../../integrations/misc/export-markdown" import { openFile, openImage } from "../../integrations/misc/open-file" import { fetchOpenGraphData, isImageUrl } from "../../integrations/misc/link-preview" @@ -1705,20 +1704,6 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath } = await this.getTaskWithId(id) - // Delete checkpoints - // deleteCheckpoints will determine if the task has legacy checkpoints or not and handle it accordingly - console.info("deleting checkpoints") - const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || [] - const historyItem = taskHistory.find((item) => item.id === id) - //console.log("historyItem: ", historyItem) - if (historyItem) { - try { - await CheckpointTracker.deleteCheckpoints(id, historyItem, this.context.globalStorageUri.fsPath) - } catch (error) { - console.error(`Failed to delete checkpoints for task ${id}:`, error) - } - } - await this.deleteTaskFromState(id) // Delete the task files @@ -1735,12 +1720,21 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont await fs.unlink(legacyMessagesFilePath) } + // Delete the checkpoints directory if it exists + const checkpointsDir = path.join(taskDirPath, "checkpoints") + if (await fileExistsAtPath(checkpointsDir)) { + try { + await fs.rm(checkpointsDir, { recursive: true, force: true }) + } catch (error) { + console.error(`Failed to delete checkpoints directory for task ${id}:`, error) + // Continue with deletion of task directory - don't throw since this is a cleanup operation + } + } + await fs.rmdir(taskDirPath) // succeeds if the dir is empty } async deleteTaskFromState(id: string) { - console.log("deleteTaskFromState: ", id) - // Remove the task from history const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || [] const updatedTaskHistory = taskHistory.filter((task) => task.id !== id) @@ -1811,7 +1805,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont /* It seems that some API messages do not comply with vscode state requirements. Either the Anthropic library is manipulating these values somehow in the backend in a way thats creating cyclic references, or the API returns a function or a Symbol as part of the message content. - VSCode docs about state: "The value must be JSON-stringifyable ... value  A value. MUST not contain cyclic references." + VSCode docs about state: "The value must be JSON-stringifyable ... value — A value. MUST not contain cyclic references." For now we'll store the conversation history in memory, and if we need to store in state directly we'd need to do a manual conversion to ensure proper json stringification. */ diff --git a/src/integrations/checkpoints/CheckpointTracker-new.ts b/src/integrations/checkpoints/CheckpointTracker-new.ts new file mode 100644 index 0000000000..bfc84ed178 --- /dev/null +++ b/src/integrations/checkpoints/CheckpointTracker-new.ts @@ -0,0 +1,453 @@ +import fs from "fs/promises" +import * as path from "path" +import simpleGit, { SimpleGit } from "simple-git" +import * as vscode from "vscode" +import { HistoryItem } from "../../shared/HistoryItem" +import { GitOperations } from "./CheckpointGitOperations" +import { getShadowGitPath, hashWorkingDir, getWorkingDirectory, detectLegacyCheckpoint } from "./CheckpointUtils" + +/** + * CheckpointTracker Module + * + * Core implementation of Cline's Checkpoints system that provides version control + * capabilities without interfering with the user's main Git repository. Key features: + * + * Shadow Git Repository: + * - Creates and manages an isolated Git repository for tracking checkpoints + * - Handles nested Git repositories by temporarily disabling them + * - Configures Git settings automatically (identity, LFS, etc.) + * + * File Management: + * - Integrates with CheckpointExclusions for file filtering + * - Handles workspace validation and path resolution + * - Manages Git worktree configuration + * + * Checkpoint Operations: + * - Creates checkpoints (commits) of the current state + * - Provides diff capabilities between checkpoints + * - Supports resetting to previous checkpoints + * + * Safety Features: + * - Prevents usage in sensitive directories (home, desktop, etc.) + * - Validates workspace configuration + * - Handles cleanup and resource disposal + * + * Checkpoint Architecture: + * - Uses a branch-per-task model to consolidate shadow git repositories + * - Each task gets its own branch within a single shadow git per workspace + * - Maintains backward compatibility with legacy checkpoint structure + * - Automatically cleans up by deleting task branches when tasks are removed + */ + +class CheckpointTracker { + private globalStoragePath: string + private taskId: string + private cwd: string + private cwdHash: string + private lastRetrievedShadowGitConfigWorkTree?: string + private lastCheckpointHash?: string + private isLegacyCheckpoint: boolean = false + private gitOperations: GitOperations + + /** + * Creates a new CheckpointTracker instance to manage checkpoints for a specific task. + * The constructor is private - use the static create() method to instantiate. + * + * @param taskId - Unique identifier for the task being tracked + * @param cwd - The current working directory to track files in + * @param cwdHash - Hash of the working directory path for shadow git organization + */ + private constructor(globalStoragePath: string, taskId: string, cwd: string, cwdHash: string) { + this.globalStoragePath = globalStoragePath + this.taskId = taskId + this.cwd = cwd + this.cwdHash = cwdHash + this.gitOperations = new GitOperations(cwd, false) // Initialize with non-legacy mode + } + + /** + * Creates a new CheckpointTracker instance for tracking changes in a task. + * Handles initialization of the shadow git repository and branch setup. + * + * @param taskId - Unique identifier for the task to track + * @param globalStoragePath - the globalStorage path + * @returns Promise resolving to new CheckpointTracker instance, or undefined if checkpoints are disabled + * @throws Error if: + * - globalStoragePath is not supplied + * - Git is not installed + * - Working directory is invalid or in a protected location + * - Shadow git initialization fails + * + * Key operations: + * - Validates git installation and settings + * - Creates/initializes shadow git repository + * - Detects and handles legacy checkpoint structure + * - Sets up task-specific branch for new checkpoints + * + * Configuration: + * - Respects 'cline.enableCheckpoints' VS Code setting + * - Uses branch-per-task architecture for new checkpoints + * - Maintains backwards compatibility with legacy structure + */ + public static async create(taskId: string, globalStoragePath: string | undefined): Promise { + if (!globalStoragePath) { + throw new Error("Global storage path is required to create a checkpoint tracker") + } + try { + console.info(`Creating new CheckpointTracker for task ${taskId}`) + + // Check if checkpoints are disabled in VS Code settings + const enableCheckpoints = vscode.workspace.getConfiguration("cline").get("enableCheckpoints") ?? true + if (!enableCheckpoints) { + return undefined // Don't create tracker when disabled + } + + // Check if git is installed by attempting to get version + try { + await simpleGit().version() + } catch (error) { + throw new Error("Git must be installed to use checkpoints.") // FIXME: must match what we check for in TaskHeader to show link + } + + const workingDir = await getWorkingDirectory() + const cwdHash = hashWorkingDir(workingDir) + console.debug(`Repository ID (cwdHash): ${cwdHash}`) + + const newTracker = new CheckpointTracker(globalStoragePath, taskId, workingDir, cwdHash) + + // Check if this is a legacy task + newTracker.isLegacyCheckpoint = await detectLegacyCheckpoint(newTracker.globalStoragePath, newTracker.taskId) + if (newTracker.isLegacyCheckpoint) { + console.debug("Using legacy checkpoint path structure") + const gitPath = await getShadowGitPath( + newTracker.globalStoragePath, + newTracker.taskId, + newTracker.cwdHash, + newTracker.isLegacyCheckpoint, + ) + await GitOperations.initShadowGit(gitPath, workingDir, newTracker.isLegacyCheckpoint) + await newTracker.gitOperations.switchToTaskBranch(newTracker.taskId, gitPath) + return newTracker + } + + // Branch-per-task structure + const gitPath = await getShadowGitPath( + newTracker.globalStoragePath, + newTracker.taskId, + newTracker.cwdHash, + newTracker.isLegacyCheckpoint, + ) + await GitOperations.initShadowGit(gitPath, workingDir, newTracker.isLegacyCheckpoint) + await newTracker.gitOperations.switchToTaskBranch(newTracker.taskId, gitPath) + return newTracker + } catch (error) { + console.error("Failed to create CheckpointTracker:", error) + throw error + } + } + + /** + * Creates a new checkpoint commit in the shadow git repository. + * + * Key behaviors: + * - Creates commit with checkpoint files in shadow git repo + * - Handles both legacy and branch-per-task checkpoint structures + * - For new tasks, switches to task-specific branch first + * - Caches the created commit hash + * + * Commit structure: + * - Legacy: Simple "checkpoint" message + * - Branch-per-task: "checkpoint-{cwdHash}-{taskId}" + * - Always allows empty commits + * + * Dependencies: + * - Requires initialized shadow git (getShadowGitPath) + * - For new checkpoints, requires task branch setup + * - Uses addCheckpointFiles to stage changes + * + * @returns Promise The created commit hash, or undefined if: + * - Shadow git access fails + * - Branch switch fails + * - Staging files fails + * - Commit creation fails + * @throws Error if unable to: + * - Access shadow git path + * - Initialize simple-git + * - Switch branches + * - Stage or commit files + */ + public async commit(): Promise { + try { + console.info(`Creating new checkpoint commit for task ${this.taskId}`) + const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint) + const git = simpleGit(path.dirname(gitPath)) + + console.info(`Using shadow git at: ${gitPath}`) + + // Disable nested git repos before any operations + await this.gitOperations.renameNestedGitRepos(true) + + try { + if (!this.isLegacyCheckpoint) { + await this.gitOperations.switchToTaskBranch(this.taskId, gitPath) + } + await this.gitOperations.addCheckpointFiles(git, gitPath) + + const commitMessage = this.isLegacyCheckpoint ? "checkpoint" : "checkpoint-" + this.cwdHash + "-" + this.taskId + + console.info( + `Creating ${this.isLegacyCheckpoint ? "legacy" : "new"} checkpoint commit with message: ${commitMessage}`, + ) + const result = await git.commit(commitMessage, { + "--allow-empty": null, + }) + const commitHash = result.commit || "" + this.lastCheckpointHash = commitHash + console.warn(`Checkpoint commit created.`) + return commitHash + } finally { + // Always re-enable nested git repos + await this.gitOperations.renameNestedGitRepos(false) + } + } catch (error) { + console.error("Failed to create checkpoint:", { + taskId: this.taskId, + error, + isLegacyCheckpoint: this.isLegacyCheckpoint, + }) + throw new Error(`Failed to create checkpoint: ${error instanceof Error ? error.message : String(error)}`) + } + } + + /** + * Retrieves the worktree path from the shadow git configuration. + * The worktree path indicates where the shadow git repository is tracking files, + * which should match the current workspace directory. + * + * Key behaviors: + * - Caches result in lastRetrievedShadowGitConfigWorkTree to avoid repeated reads + * - Returns cached value if available + * - Reads git config if no cached value exists + * - Handles both legacy and new checkpoint structures + * + * Configuration read: + * - Uses simple-git to read core.worktree config + * - Operates on shadow git at path from getShadowGitPath() + * + * @returns Promise The configured worktree path, or undefined if: + * - Shadow git repository doesn't exist + * - Config read fails + * - No worktree is configured + * @throws Error if unable to: + * - Access shadow git path + * - Initialize simple-git + * - Read git configuration + */ + public async getShadowGitConfigWorkTree(): Promise { + if (this.lastRetrievedShadowGitConfigWorkTree) { + return this.lastRetrievedShadowGitConfigWorkTree + } + try { + const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint) + this.lastRetrievedShadowGitConfigWorkTree = await this.gitOperations.getShadowGitConfigWorkTree(gitPath) + return this.lastRetrievedShadowGitConfigWorkTree + } catch (error) { + console.error("Failed to get shadow git config worktree:", error) + return undefined + } + } + + /** + * Resets the shadow git repository's HEAD to a specific checkpoint commit. + * This will discard all changes after the target commit and restore the + * working directory to that checkpoint's state. + * + * Dependencies: + * - Requires initialized shadow git (getShadowGitPath) + * - For new checkpoints, requires task branch setup + * - Must be called with a valid commit hash from this task's history + * + * @param commitHash - The hash of the checkpoint commit to reset to + * @returns Promise Resolves when reset is complete + * @throws Error if unable to: + * - Access shadow git path + * - Initialize simple-git + * - Switch to task branch + * - Reset to target commit + */ + public async resetHead(commitHash: string): Promise { + console.info(`Resetting to checkpoint: ${commitHash}`) + const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint) + const git = simpleGit(path.dirname(gitPath)) + console.debug(`Using shadow git at: ${gitPath}`) + await this.gitOperations.switchToTaskBranch(this.taskId, gitPath) + await git.reset(["--hard", commitHash]) // Hard reset to target commit + console.debug(`Successfully reset to checkpoint: ${commitHash}`) + } + + /** + * Return an array describing changed files between one commit and either: + * - another commit, or + * - the current working directory (including uncommitted changes). + * + * If `rhsHash` is omitted, compares `lhsHash` to the working directory. + * If you want truly untracked files to appear, `git add` them first. + * + * @param lhsHash - The commit to compare from (older commit) + * @param rhsHash - The commit to compare to (newer commit). + * If omitted, we compare to the working directory. + * @returns Array of file changes with before/after content + */ + public async getDiffSet( + lhsHash?: string, + rhsHash?: string, + ): Promise< + Array<{ + relativePath: string + absolutePath: string + before: string + after: string + }> + > { + const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint) + const git = simpleGit(path.dirname(gitPath)) + + if (!this.isLegacyCheckpoint) { + await this.gitOperations.switchToTaskBranch(this.taskId, gitPath) + } + + console.info(`Getting diff between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`) + + // If lhsHash is missing, use the initial commit of the repo + let baseHash = lhsHash + if (!baseHash) { + const rootCommit = await git.raw(["rev-list", "--max-parents=0", "HEAD"]) + baseHash = rootCommit.trim() + console.debug(`Using root commit as base: ${baseHash}`) + } + + // Stage all changes so that untracked files appear in diff summary + await this.gitOperations.addCheckpointFiles(git, gitPath) + + const diffSummary = rhsHash ? await git.diffSummary([`${baseHash}..${rhsHash}`]) : await git.diffSummary([baseHash]) + console.info(`Found ${diffSummary.files.length} changed files`) + + // For each changed file, gather before/after content + const result = [] + const cwdPath = (await this.getShadowGitConfigWorkTree()) || this.cwd || "" + const files = diffSummary.files.map((f) => f.file) + const batchSize = 50 + + // Get list of files that exist in base commit + const existingFiles = await this.getExistingFiles(git, baseHash, files) + + // Process files in batches + for (let i = 0; i < files.length; i += batchSize) { + const batch = files.slice(i, i + batchSize) + + // Split batch into existing and new files + const existingBatch = batch.filter((file) => existingFiles.has(file)) + const newBatch = batch.filter((file) => !existingFiles.has(file)) + + // Get before contents for existing files + let beforeContents: string[] = new Array(batch.length).fill("") + if (existingBatch.length > 0) { + await git.addConfig("core.quotePath", "false") + await git.addConfig("core.precomposeunicode", "true") + const args = ["show", "--format="] + existingBatch.forEach((file) => { + args.push(`${baseHash}:${file}`) + }) + const beforeResult = await git.raw(args) + const existingContents = beforeResult.split("\n\0\n") + // Map contents back to original batch positions + existingBatch.forEach((file, index) => { + const batchIndex = batch.indexOf(file) + if (batchIndex !== -1) { + beforeContents[batchIndex] = existingContents[index] || "" + } + }) + } + + // Get after contents + let afterContents: string[] = [] + if (rhsHash) { + // Split after files into existing and new in target commit + const afterExistingFiles = await this.getExistingFiles(git, rhsHash, batch) + const afterExistingBatch = batch.filter((file) => afterExistingFiles.has(file)) + + if (afterExistingBatch.length > 0) { + const args = ["show", "--format="] + afterExistingBatch.forEach((file) => { + args.push(`${rhsHash}:${file}`) + }) + const afterResult = await git.raw(args) + const existingContents = afterResult.split("\n\0\n") + afterContents = new Array(batch.length).fill("") + afterExistingBatch.forEach((file, index) => { + const batchIndex = batch.indexOf(file) + if (batchIndex !== -1) { + afterContents[batchIndex] = existingContents[index] || "" + } + }) + } + } else { + // Read from disk for working directory changes + afterContents = await Promise.all( + batch.map(async (filePath) => { + try { + return await fs.readFile(path.join(cwdPath, filePath), "utf8") + } catch (_) { + return "" + } + }), + ) + } + + // Add results for this batch + for (let j = 0; j < batch.length; j++) { + const filePath = batch[j] + const absolutePath = path.join(cwdPath, filePath) + result.push({ + relativePath: filePath, + absolutePath, + before: beforeContents[j] || "", + after: afterContents[j] || "", + }) + } + } + return result + } + + /** + * Deletes all checkpoint data for a given task. + * Handles both legacy checkpoints and branch-per-task checkpoints. + * + * @param taskId - The ID of the task whose checkpoints should be deleted + * @param historyItem - The history item containing the shadow git config for this task + * @param globalStoragePath - the globalStorage path + * @throws Error if deletion fails + */ + public static async deleteCheckpoints(taskId: string, historyItem: HistoryItem, globalStoragePath: string): Promise { + if (!globalStoragePath) { + throw new Error("Global storage uri is invalid") + } + await GitOperations.deleteTaskBranchStatic(taskId, historyItem, globalStoragePath) + } + + /** + * Helper function to get a set of files that exist in a given commit + */ + private async getExistingFiles(git: SimpleGit, commitHash: string, files: string[]): Promise> { + try { + const result = await git.raw(["ls-tree", "-r", "--name-only", commitHash]) + const existingFiles = new Set(result.split("\n")) + return existingFiles + } catch (error) { + console.error("Error getting existing files:", error) + return new Set() + } + } +} + +export default CheckpointTracker diff --git a/src/integrations/checkpoints/CheckpointTracker.ts b/src/integrations/checkpoints/CheckpointTracker.ts index bfc84ed178..75758e8d63 100644 --- a/src/integrations/checkpoints/CheckpointTracker.ts +++ b/src/integrations/checkpoints/CheckpointTracker.ts @@ -1,100 +1,31 @@ import fs from "fs/promises" +import os from "os" import * as path from "path" import simpleGit, { SimpleGit } from "simple-git" import * as vscode from "vscode" -import { HistoryItem } from "../../shared/HistoryItem" -import { GitOperations } from "./CheckpointGitOperations" -import { getShadowGitPath, hashWorkingDir, getWorkingDirectory, detectLegacyCheckpoint } from "./CheckpointUtils" - -/** - * CheckpointTracker Module - * - * Core implementation of Cline's Checkpoints system that provides version control - * capabilities without interfering with the user's main Git repository. Key features: - * - * Shadow Git Repository: - * - Creates and manages an isolated Git repository for tracking checkpoints - * - Handles nested Git repositories by temporarily disabling them - * - Configures Git settings automatically (identity, LFS, etc.) - * - * File Management: - * - Integrates with CheckpointExclusions for file filtering - * - Handles workspace validation and path resolution - * - Manages Git worktree configuration - * - * Checkpoint Operations: - * - Creates checkpoints (commits) of the current state - * - Provides diff capabilities between checkpoints - * - Supports resetting to previous checkpoints - * - * Safety Features: - * - Prevents usage in sensitive directories (home, desktop, etc.) - * - Validates workspace configuration - * - Handles cleanup and resource disposal - * - * Checkpoint Architecture: - * - Uses a branch-per-task model to consolidate shadow git repositories - * - Each task gets its own branch within a single shadow git per workspace - * - Maintains backward compatibility with legacy checkpoint structure - * - Automatically cleans up by deleting task branches when tasks are removed - */ +import { ClineProvider } from "../../core/webview/ClineProvider" +import { fileExistsAtPath } from "../../utils/fs" +import { globby } from "globby" class CheckpointTracker { - private globalStoragePath: string + private providerRef: WeakRef private taskId: string + private disposables: vscode.Disposable[] = [] private cwd: string - private cwdHash: string private lastRetrievedShadowGitConfigWorkTree?: string - private lastCheckpointHash?: string - private isLegacyCheckpoint: boolean = false - private gitOperations: GitOperations + lastCheckpointHash?: string - /** - * Creates a new CheckpointTracker instance to manage checkpoints for a specific task. - * The constructor is private - use the static create() method to instantiate. - * - * @param taskId - Unique identifier for the task being tracked - * @param cwd - The current working directory to track files in - * @param cwdHash - Hash of the working directory path for shadow git organization - */ - private constructor(globalStoragePath: string, taskId: string, cwd: string, cwdHash: string) { - this.globalStoragePath = globalStoragePath + private constructor(provider: ClineProvider, taskId: string, cwd: string) { + this.providerRef = new WeakRef(provider) this.taskId = taskId this.cwd = cwd - this.cwdHash = cwdHash - this.gitOperations = new GitOperations(cwd, false) // Initialize with non-legacy mode } - /** - * Creates a new CheckpointTracker instance for tracking changes in a task. - * Handles initialization of the shadow git repository and branch setup. - * - * @param taskId - Unique identifier for the task to track - * @param globalStoragePath - the globalStorage path - * @returns Promise resolving to new CheckpointTracker instance, or undefined if checkpoints are disabled - * @throws Error if: - * - globalStoragePath is not supplied - * - Git is not installed - * - Working directory is invalid or in a protected location - * - Shadow git initialization fails - * - * Key operations: - * - Validates git installation and settings - * - Creates/initializes shadow git repository - * - Detects and handles legacy checkpoint structure - * - Sets up task-specific branch for new checkpoints - * - * Configuration: - * - Respects 'cline.enableCheckpoints' VS Code setting - * - Uses branch-per-task architecture for new checkpoints - * - Maintains backwards compatibility with legacy structure - */ - public static async create(taskId: string, globalStoragePath: string | undefined): Promise { - if (!globalStoragePath) { - throw new Error("Global storage path is required to create a checkpoint tracker") - } + public static async create(taskId: string, provider?: ClineProvider): Promise { try { - console.info(`Creating new CheckpointTracker for task ${taskId}`) + if (!provider) { + throw new Error("Provider is required to create a checkpoint tracker") + } // Check if checkpoints are disabled in VS Code settings const enableCheckpoints = vscode.workspace.getConfiguration("cline").get("enableCheckpoints") ?? true @@ -109,36 +40,9 @@ class CheckpointTracker { throw new Error("Git must be installed to use checkpoints.") // FIXME: must match what we check for in TaskHeader to show link } - const workingDir = await getWorkingDirectory() - const cwdHash = hashWorkingDir(workingDir) - console.debug(`Repository ID (cwdHash): ${cwdHash}`) - - const newTracker = new CheckpointTracker(globalStoragePath, taskId, workingDir, cwdHash) - - // Check if this is a legacy task - newTracker.isLegacyCheckpoint = await detectLegacyCheckpoint(newTracker.globalStoragePath, newTracker.taskId) - if (newTracker.isLegacyCheckpoint) { - console.debug("Using legacy checkpoint path structure") - const gitPath = await getShadowGitPath( - newTracker.globalStoragePath, - newTracker.taskId, - newTracker.cwdHash, - newTracker.isLegacyCheckpoint, - ) - await GitOperations.initShadowGit(gitPath, workingDir, newTracker.isLegacyCheckpoint) - await newTracker.gitOperations.switchToTaskBranch(newTracker.taskId, gitPath) - return newTracker - } - - // Branch-per-task structure - const gitPath = await getShadowGitPath( - newTracker.globalStoragePath, - newTracker.taskId, - newTracker.cwdHash, - newTracker.isLegacyCheckpoint, - ) - await GitOperations.initShadowGit(gitPath, workingDir, newTracker.isLegacyCheckpoint) - await newTracker.gitOperations.switchToTaskBranch(newTracker.taskId, gitPath) + const cwd = await CheckpointTracker.getWorkingDirectory() + const newTracker = new CheckpointTracker(provider, taskId, cwd) + await newTracker.initShadowGit() return newTracker } catch (error) { console.error("Failed to create CheckpointTracker:", error) @@ -146,110 +50,203 @@ class CheckpointTracker { } } - /** - * Creates a new checkpoint commit in the shadow git repository. - * - * Key behaviors: - * - Creates commit with checkpoint files in shadow git repo - * - Handles both legacy and branch-per-task checkpoint structures - * - For new tasks, switches to task-specific branch first - * - Caches the created commit hash - * - * Commit structure: - * - Legacy: Simple "checkpoint" message - * - Branch-per-task: "checkpoint-{cwdHash}-{taskId}" - * - Always allows empty commits - * - * Dependencies: - * - Requires initialized shadow git (getShadowGitPath) - * - For new checkpoints, requires task branch setup - * - Uses addCheckpointFiles to stage changes - * - * @returns Promise The created commit hash, or undefined if: - * - Shadow git access fails - * - Branch switch fails - * - Staging files fails - * - Commit creation fails - * @throws Error if unable to: - * - Access shadow git path - * - Initialize simple-git - * - Switch branches - * - Stage or commit files - */ - public async commit(): Promise { - try { - console.info(`Creating new checkpoint commit for task ${this.taskId}`) - const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint) - const git = simpleGit(path.dirname(gitPath)) + private static async getWorkingDirectory(): Promise { + const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) + if (!cwd) { + throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.") + } + const homedir = os.homedir() + const desktopPath = path.join(homedir, "Desktop") + const documentsPath = path.join(homedir, "Documents") + const downloadsPath = path.join(homedir, "Downloads") - console.info(`Using shadow git at: ${gitPath}`) - - // Disable nested git repos before any operations - await this.gitOperations.renameNestedGitRepos(true) - - try { - if (!this.isLegacyCheckpoint) { - await this.gitOperations.switchToTaskBranch(this.taskId, gitPath) - } - await this.gitOperations.addCheckpointFiles(git, gitPath) - - const commitMessage = this.isLegacyCheckpoint ? "checkpoint" : "checkpoint-" + this.cwdHash + "-" + this.taskId - - console.info( - `Creating ${this.isLegacyCheckpoint ? "legacy" : "new"} checkpoint commit with message: ${commitMessage}`, - ) - const result = await git.commit(commitMessage, { - "--allow-empty": null, - }) - const commitHash = result.commit || "" - this.lastCheckpointHash = commitHash - console.warn(`Checkpoint commit created.`) - return commitHash - } finally { - // Always re-enable nested git repos - await this.gitOperations.renameNestedGitRepos(false) - } - } catch (error) { - console.error("Failed to create checkpoint:", { - taskId: this.taskId, - error, - isLegacyCheckpoint: this.isLegacyCheckpoint, - }) - throw new Error(`Failed to create checkpoint: ${error instanceof Error ? error.message : String(error)}`) + switch (cwd) { + case homedir: + throw new Error("Cannot use checkpoints in home directory") + case desktopPath: + throw new Error("Cannot use checkpoints in Desktop directory") + case documentsPath: + throw new Error("Cannot use checkpoints in Documents directory") + case downloadsPath: + throw new Error("Cannot use checkpoints in Downloads directory") + default: + return cwd + } + } + + private async getShadowGitPath(): Promise { + const globalStoragePath = this.providerRef.deref()?.context.globalStorageUri.fsPath + if (!globalStoragePath) { + throw new Error("Global storage uri is invalid") + } + const checkpointsDir = path.join(globalStoragePath, "tasks", this.taskId, "checkpoints") + await fs.mkdir(checkpointsDir, { recursive: true }) + const gitPath = path.join(checkpointsDir, ".git") + return gitPath + } + + public static async doesShadowGitExist(taskId: string, provider?: ClineProvider): Promise { + const globalStoragePath = provider?.context.globalStorageUri.fsPath + if (!globalStoragePath) { + return false + } + const gitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git") + return await fileExistsAtPath(gitPath) + } + + public async initShadowGit(): Promise { + const gitPath = await this.getShadowGitPath() + if (await fileExistsAtPath(gitPath)) { + // Make sure it's the same cwd as the configured worktree + const worktree = await this.getShadowGitConfigWorkTree() + if (worktree !== this.cwd) { + throw new Error("Checkpoints can only be used in the original workspace: " + worktree) + } + + return gitPath + } else { + const checkpointsDir = path.dirname(gitPath) + const git = simpleGit(checkpointsDir) + await git.init() + + await git.addConfig("core.worktree", this.cwd) // sets the working tree to the current workspace + + // Disable commit signing for shadow repo + await git.addConfig("commit.gpgSign", "false") + + // Get LFS patterns from workspace if they exist + let lfsPatterns: string[] = [] + try { + const attributesPath = path.join(this.cwd, ".gitattributes") + if (await fileExistsAtPath(attributesPath)) { + const attributesContent = await fs.readFile(attributesPath, "utf8") + lfsPatterns = attributesContent + .split("\n") + .filter((line) => line.includes("filter=lfs")) + .map((line) => line.split(" ")[0].trim()) + } + } catch (error) { + console.warn("Failed to read .gitattributes:", error) + } + + // Add basic excludes directly in git config, while respecting any .gitignore in the workspace + // .git/info/exclude is local to the shadow git repo, so it's not shared with the main repo - and won't conflict with user's .gitignore + // TODO: let user customize these + const excludesPath = path.join(gitPath, "info", "exclude") + await fs.mkdir(path.join(gitPath, "info"), { recursive: true }) + await fs.writeFile( + excludesPath, + [ + ".git/", // ignore the user's .git + `.git${GIT_DISABLED_SUFFIX}/`, // ignore the disabled nested git repos + ".DS_Store", + "*.log", + "node_modules/", + "__pycache__/", + "env/", + "venv/", + "target/dependency/", + "build/dependencies/", + "dist/", + "out/", + "bundle/", + "vendor/", + "tmp/", + "temp/", + "deps/", + "pkg/", + "Pods/", + // Media files + "*.jpg", + "*.jpeg", + "*.png", + "*.gif", + "*.bmp", + "*.ico", + // "*.svg", + "*.mp3", + "*.mp4", + "*.wav", + "*.avi", + "*.mov", + "*.wmv", + "*.webm", + "*.webp", + "*.m4a", + "*.flac", + // Build and dependency directories + "build/", + "bin/", + "obj/", + ".gradle/", + ".idea/", + ".vscode/", + ".vs/", + "coverage/", + ".next/", + ".nuxt/", + // Cache and temporary files + "*.cache", + "*.tmp", + "*.temp", + "*.swp", + "*.swo", + "*.pyc", + "*.pyo", + ".pytest_cache/", + ".eslintcache", + // Environment and config files + ".env*", + "*.local", + "*.development", + "*.production", + // Large data files + "*.zip", + "*.tar", + "*.gz", + "*.rar", + "*.7z", + "*.iso", + "*.bin", + "*.exe", + "*.dll", + "*.so", + "*.dylib", + // Database files + "*.sqlite", + "*.db", + "*.sql", + // Log files + "*.logs", + "*.error", + "npm-debug.log*", + "yarn-debug.log*", + "yarn-error.log*", + ...lfsPatterns, + ].join("\n"), + ) + + // Set up git identity (git throws an error if user.name or user.email is not set) + await git.addConfig("user.name", "Cline Checkpoint") + await git.addConfig("user.email", "noreply@example.com") + + await this.addAllFiles(git) + // Initial commit (--allow-empty ensures it works even with no files) + await git.commit("initial commit", { "--allow-empty": null }) + + return gitPath } } - /** - * Retrieves the worktree path from the shadow git configuration. - * The worktree path indicates where the shadow git repository is tracking files, - * which should match the current workspace directory. - * - * Key behaviors: - * - Caches result in lastRetrievedShadowGitConfigWorkTree to avoid repeated reads - * - Returns cached value if available - * - Reads git config if no cached value exists - * - Handles both legacy and new checkpoint structures - * - * Configuration read: - * - Uses simple-git to read core.worktree config - * - Operates on shadow git at path from getShadowGitPath() - * - * @returns Promise The configured worktree path, or undefined if: - * - Shadow git repository doesn't exist - * - Config read fails - * - No worktree is configured - * @throws Error if unable to: - * - Access shadow git path - * - Initialize simple-git - * - Read git configuration - */ public async getShadowGitConfigWorkTree(): Promise { if (this.lastRetrievedShadowGitConfigWorkTree) { return this.lastRetrievedShadowGitConfigWorkTree } try { - const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint) - this.lastRetrievedShadowGitConfigWorkTree = await this.gitOperations.getShadowGitConfigWorkTree(gitPath) + const gitPath = await this.getShadowGitPath() + const git = simpleGit(path.dirname(gitPath)) + const worktree = await git.getConfig("core.worktree") + this.lastRetrievedShadowGitConfigWorkTree = worktree.value || undefined return this.lastRetrievedShadowGitConfigWorkTree } catch (error) { console.error("Failed to get shadow git config worktree:", error) @@ -257,32 +254,36 @@ class CheckpointTracker { } } - /** - * Resets the shadow git repository's HEAD to a specific checkpoint commit. - * This will discard all changes after the target commit and restore the - * working directory to that checkpoint's state. - * - * Dependencies: - * - Requires initialized shadow git (getShadowGitPath) - * - For new checkpoints, requires task branch setup - * - Must be called with a valid commit hash from this task's history - * - * @param commitHash - The hash of the checkpoint commit to reset to - * @returns Promise Resolves when reset is complete - * @throws Error if unable to: - * - Access shadow git path - * - Initialize simple-git - * - Switch to task branch - * - Reset to target commit - */ + public async commit(): Promise { + try { + const gitPath = await this.getShadowGitPath() + const git = simpleGit(path.dirname(gitPath)) + await this.addAllFiles(git) + const result = await git.commit("checkpoint", { + "--allow-empty": null, + }) + const commitHash = result.commit || "" + this.lastCheckpointHash = commitHash + return commitHash + } catch (error) { + console.error("Failed to create checkpoint:", error) + return undefined + } + } + public async resetHead(commitHash: string): Promise { - console.info(`Resetting to checkpoint: ${commitHash}`) - const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint) + const gitPath = await this.getShadowGitPath() const git = simpleGit(path.dirname(gitPath)) - console.debug(`Using shadow git at: ${gitPath}`) - await this.gitOperations.switchToTaskBranch(this.taskId, gitPath) + + // Clean working directory and force reset + // This ensures that the operation will succeed regardless of: + // - Untracked files in the workspace + // - Staged changes + // - Unstaged changes + // - Partial commits + // - Merge conflicts + await git.clean("f", ["-d", "-f"]) // Remove untracked files and directories await git.reset(["--hard", commitHash]) // Hard reset to target commit - console.debug(`Successfully reset to checkpoint: ${commitHash}`) } /** @@ -309,145 +310,111 @@ class CheckpointTracker { after: string }> > { - const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint) + const gitPath = await this.getShadowGitPath() const git = simpleGit(path.dirname(gitPath)) - if (!this.isLegacyCheckpoint) { - await this.gitOperations.switchToTaskBranch(this.taskId, gitPath) - } - - console.info(`Getting diff between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`) - // If lhsHash is missing, use the initial commit of the repo let baseHash = lhsHash if (!baseHash) { const rootCommit = await git.raw(["rev-list", "--max-parents=0", "HEAD"]) baseHash = rootCommit.trim() - console.debug(`Using root commit as base: ${baseHash}`) } // Stage all changes so that untracked files appear in diff summary - await this.gitOperations.addCheckpointFiles(git, gitPath) + await this.addAllFiles(git) const diffSummary = rhsHash ? await git.diffSummary([`${baseHash}..${rhsHash}`]) : await git.diffSummary([baseHash]) - console.info(`Found ${diffSummary.files.length} changed files`) // For each changed file, gather before/after content const result = [] const cwdPath = (await this.getShadowGitConfigWorkTree()) || this.cwd || "" - const files = diffSummary.files.map((f) => f.file) - const batchSize = 50 - // Get list of files that exist in base commit - const existingFiles = await this.getExistingFiles(git, baseHash, files) + for (const file of diffSummary.files) { + const filePath = file.file + const absolutePath = path.join(cwdPath, filePath) - // Process files in batches - for (let i = 0; i < files.length; i += batchSize) { - const batch = files.slice(i, i + batchSize) - - // Split batch into existing and new files - const existingBatch = batch.filter((file) => existingFiles.has(file)) - const newBatch = batch.filter((file) => !existingFiles.has(file)) - - // Get before contents for existing files - let beforeContents: string[] = new Array(batch.length).fill("") - if (existingBatch.length > 0) { - await git.addConfig("core.quotePath", "false") - await git.addConfig("core.precomposeunicode", "true") - const args = ["show", "--format="] - existingBatch.forEach((file) => { - args.push(`${baseHash}:${file}`) - }) - const beforeResult = await git.raw(args) - const existingContents = beforeResult.split("\n\0\n") - // Map contents back to original batch positions - existingBatch.forEach((file, index) => { - const batchIndex = batch.indexOf(file) - if (batchIndex !== -1) { - beforeContents[batchIndex] = existingContents[index] || "" - } - }) + let beforeContent = "" + try { + beforeContent = await git.show([`${baseHash}:${filePath}`]) + } catch (_) { + // file didn't exist in older commit => remains empty } - // Get after contents - let afterContents: string[] = [] + let afterContent = "" if (rhsHash) { - // Split after files into existing and new in target commit - const afterExistingFiles = await this.getExistingFiles(git, rhsHash, batch) - const afterExistingBatch = batch.filter((file) => afterExistingFiles.has(file)) - - if (afterExistingBatch.length > 0) { - const args = ["show", "--format="] - afterExistingBatch.forEach((file) => { - args.push(`${rhsHash}:${file}`) - }) - const afterResult = await git.raw(args) - const existingContents = afterResult.split("\n\0\n") - afterContents = new Array(batch.length).fill("") - afterExistingBatch.forEach((file, index) => { - const batchIndex = batch.indexOf(file) - if (batchIndex !== -1) { - afterContents[batchIndex] = existingContents[index] || "" - } - }) + // if user provided a newer commit, use git.show at that commit + try { + afterContent = await git.show([`${rhsHash}:${filePath}`]) + } catch (_) { + // file didn't exist in newer commit => remains empty } } else { - // Read from disk for working directory changes - afterContents = await Promise.all( - batch.map(async (filePath) => { - try { - return await fs.readFile(path.join(cwdPath, filePath), "utf8") - } catch (_) { - return "" - } - }), - ) + // otherwise, read from disk (includes uncommitted changes) + try { + afterContent = await fs.readFile(absolutePath, "utf8") + } catch (_) { + // file might be deleted => remains empty + } } - // Add results for this batch - for (let j = 0; j < batch.length; j++) { - const filePath = batch[j] - const absolutePath = path.join(cwdPath, filePath) - result.push({ - relativePath: filePath, - absolutePath, - before: beforeContents[j] || "", - after: afterContents[j] || "", - }) - } + result.push({ + relativePath: filePath, + absolutePath, + before: beforeContent, + after: afterContent, + }) } + return result } - /** - * Deletes all checkpoint data for a given task. - * Handles both legacy checkpoints and branch-per-task checkpoints. - * - * @param taskId - The ID of the task whose checkpoints should be deleted - * @param historyItem - The history item containing the shadow git config for this task - * @param globalStoragePath - the globalStorage path - * @throws Error if deletion fails - */ - public static async deleteCheckpoints(taskId: string, historyItem: HistoryItem, globalStoragePath: string): Promise { - if (!globalStoragePath) { - throw new Error("Global storage uri is invalid") + private async addAllFiles(git: SimpleGit) { + await this.renameNestedGitRepos(true) + try { + await git.add(".") + } catch (error) { + console.error("Failed to add files to git:", error) + } finally { + await this.renameNestedGitRepos(false) } - await GitOperations.deleteTaskBranchStatic(taskId, historyItem, globalStoragePath) } - /** - * Helper function to get a set of files that exist in a given commit - */ - private async getExistingFiles(git: SimpleGit, commitHash: string, files: string[]): Promise> { - try { - const result = await git.raw(["ls-tree", "-r", "--name-only", commitHash]) - const existingFiles = new Set(result.split("\n")) - return existingFiles - } catch (error) { - console.error("Error getting existing files:", error) - return new Set() + // Since we use git to track checkpoints, we need to temporarily disable 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.cwd, + onlyDirectories: true, + ignore: [".git"], // Ignore root level .git + dot: true, + markDirectories: false, + }) + + // For each nested .git directory, rename it based on operation + for (const gitPath of gitPaths) { + const fullPath = path.join(this.cwd, gitPath) + let newPath: string + if (disable) { + newPath = fullPath + GIT_DISABLED_SUFFIX + } else { + newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath + } + + try { + await fs.rename(fullPath, newPath) + console.log(`CheckpointTracker ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`) + } catch (error) { + console.error(`CheckpointTracker failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error) + } } } + + public dispose() { + this.disposables.forEach((d) => d.dispose()) + this.disposables = [] + } } +const GIT_DISABLED_SUFFIX = "_disabled" + export default CheckpointTracker