Skip to content

Checkpoints: a shadow git for task rewind

源码版本v4.0.10

Responsibilities

Checkpoints are Cline's "time machine" for a task. Before the LLM makes any change, Cline takes a snapshot of the current workspace and stores it. If the user is unhappy with the outcome of a tool call, they can roll code, files, or even the entire conversation history back to any snapshot and resume from there.

The whole mechanism is built on a shadow git repository: not the .git in the user's workspace, but a separate git repo that Cline quietly maintains inside its own globalStorage, with core.worktree pointing at the user's working directory. Each commit is a checkpoint, and git reset --hard performs the rewind. This reuses git's diff/stage/commit machinery without ever polluting the user's own repository state.

The externally exposed core is TaskCheckpointManager, which wraps the business logic — whether to take a snapshot, which message a commit hash should be attached to, and how far back to truncate the conversation history on rewind. The actual git work is done by CheckpointTracker and GitOperations underneath.

Design motivation

  • Must not touch the user's .git: the user's workspace may already be a git repo, and a direct git add . would dirty their index. The shadow git uses an independent .git directory with core.worktree pointing back at the workspace, keeping the two repos fully isolated.
  • Every tool call must be reversible: LLM file edits are unpredictable, so users need to see the before/after of a tool call and undo to any prior step. Commits therefore have to be taken on tool-execution boundaries.
  • Don't roll back the message history along with the code: rewind comes in three flavors — workspace touches only files, task touches only conversation messages, and taskAndWorkspace touches both. This lets users either keep the conversation and restore the code, or rewind the conversation itself and start over.
  • Multi-root workspaces need separate handling: a workspace may mount several directories, and a single shadow git isn't enough. MultiRootCheckpointManager dispatches on the outside.
  • Nested .git breaks git add: when git encounters a .git inside a subdirectory it treats it as a submodule by default. The shadow git has to temporarily rename-and-disable nested .git directories before each add, then restore them afterward.
  • Protected directories must be rejected: home / Desktop / Documents / Downloads cover too broad a range and would sweep in unrelated files; validateWorkspacePath refuses them outright.

Key files

  • class CheckpointTracker:50 — the shadow git controller for a single task; holds taskId and cwdHash.
  • commit:212 — acquire the lock → git add .git commit --allow-empty --no-verify, returning the commit hash.
  • resetHead:336git reset --hard <hash> to restore the workspace to a given checkpoint.
  • getDiffSet:397 — file diff between two commits (or commit ↔ workspace), with before/after contents.
  • initShadowGit:59 — first-time creation of the shadow git repo; configures core.worktree / user.email / excludes.
  • renameNestedGitRepos:148 — temporarily disable and restore nested .git directories to bypass the submodule limitation.
  • addCheckpointFiles:203 — disable nested git → git add . --ignore-errors → restore nested git.
  • getShadowGitPath:20 — shadow git path globalStorage/checkpoints/{cwdHash}/.git.
  • hashWorkingDir:103 — hashes the working-directory path into a 13-digit number used as the shadow git directory name.
  • saveCheckpoint:118 — business entry point; decides whether to commit and attaches the hash to the corresponding message.
  • restoreCheckpoint:238 — finds the checkpoint by messageTs and dispatches on restoreType.
  • buildCheckpointManager:59 — picks TaskCheckpointManager or MultiRootCheckpointManager based on whether the workspace is single- or multi-root.
  • saveCheckpointCallback:1113 — the callback the Task exposes to the tool executor; invoked after a tool finishes.
  • ensureCheckpointInitialized:2920 — makes sure the shadow git is initialized before the first API request.

Data flow

On the first API request of each task, the task also spins up the checkpoint system — if initialization times out, the entire task skips checkpoints from then on, so subsequent turns don't keep stalling on the same timeout:

typescript
// core/task/index.ts:2906-2933
// Save checkpoint if this is the first API request
const isFirstRequest =
    this.messageStateHandler
        .getClineMessages()
        .filter((m) => m.say === "api_req_started").length === 0;

// Initialize checkpointManager first if enabled and it's the first request
if (
    isFirstRequest &&
    this.stateManager.getGlobalSettingsKey("enableCheckpointsSetting") &&
    this.checkpointManager && // TODO REVIEW: may be able to implement a replacement for the 15s timer
    !this.taskState.checkpointManagerErrorMessage
) {
    try {
        await ensureCheckpointInitialized({
            checkpointManager: this.checkpointManager,
        });
    } catch (error) {
        const errorMessage =
            error instanceof Error ? error.message : "Unknown error";
        Logger.error("Failed to initialize checkpoint manager:", errorMessage);
        this.taskState.checkpointManagerErrorMessage = errorMessage;
        HostProvider.window.showMessage({
            type: ShowMessageType.ERROR,
            message: `Checkpoint initialization timed out: ${errorMessage}`,
        });
    }
}

After initialization the task triggers saveCheckpoint at two points: (1) when each attempt_completion finishes (AttemptCompletionHandler:156); and (2) after all tools in an assistant message have run (saveCheckpoint after tools:3811). saveCheckpoint doesn't commit directly; it first calls say("checkpoint_created") to reserve a message, then commits asynchronously and backfills the commit hash onto that message:

typescript
// integrations/checkpoints/index.ts:166-187
const messageTs = await this.callbacks.say("checkpoint_created")
if (messageTs) {
    const messages = this.services.messageStateHandler.getClineMessages()
    const targetMessage = messages.find((m) => m.ts === messageTs)

    if (targetMessage) {
        this.state.checkpointTracker
            ?.commit()
            .then(async (commitHash) => {
                if (commitHash) {
                    targetMessage.lastCheckpointHash = commitHash
                    await this.services.messageStateHandler.saveClineMessagesAndUpdateHistory()
                }
            })
            .catch((error) => {
                Logger.error(
                    `[TaskCheckpointManager] Failed to create checkpoint commit for task ${this.task.taskId}:`,
                    error,
                )
            })
    }
}

It's asynchronous so the next LLM turn doesn't have to wait for the commit to finish. The lastCheckpointHash field is the anchor used for future rewinds.

Rewind goes through restoreCheckpoint, branching on restoreType: to restore only code it calls resetHead(hash); to rewind only the conversation it updates conversationHistoryDeletedRange; for both, it does both. When restoring the workspace, if the tracker isn't up yet, it lazily creates one via CheckpointTracker.create.

Boundaries and failure modes

  • home / Desktop / Documents / Downloads are rejected outright (validateWorkspacePath:59) — the scope is too large to scan and tends to hit permission issues.
  • One init timeout kills checkpoints for the entire task (timeout guard:123) — checkpointManagerErrorMessage carries a Checkpoints initialization timed out. marker; any subsequent saveCheckpoint returns immediately and won't retry.
  • Nested .git rename failures retry (retryWithBackoff:223) — addCheckpointFiles restores nested git in a finally block with 3 attempts of exponential backoff; if all fail it only logs the error without throwing, otherwise the user's subproject would be stuck in a .git_disabled state.
  • Directory lock prevents concurrency (tryAcquireCheckpointLockWithRetry:220): multiple Cline instances under the same cwdHash could trample each other, so cwdHash is used as the lock key. VS Code's internal scenarios skip the lock.
  • Back-to-back checkpoint_created messages are deduped (back-to-back guard:160): if the previous message is already checkpoint_created, it returns immediately to avoid empty commits piling up. attempt_completion additionally dedupes against the last 3 messages (completion dedup:192).
  • Empty commits use --allow-empty (empty commit:251): even with no workspace changes, a placeholder commit is kept so the hash chain stays intact and rewind can always find an anchor.
  • Binary files are stripped from diffs (binary skip:434): paths with no extension or dotfiles are probed with isBinaryFile and skipped if binary, otherwise the diff view would be cluttered with garbled bytes.
  • Multi-root workspaces only warn, don't error (multiroot warn:461): on detecting multiple roots the error is stuffed into taskState.checkpointManagerErrorMessage and surfaced as a UI warning, but the task continues.

Summary

Checkpoints are the foundation of Cline's safety net: a shadow git serializes workspace state into a chain of commits, and commit hashes are attached to conversation messages, so "points in time" and "points in the conversation" line up one-to-one. Upper layers only need to know saveCheckpoint / restoreCheckpoint; they don't have to deal with the underlying git operations, nested .git handling, or directory locking.

For the next step on the rewind path, see the agent-loop recursion — how recursivelyMakeClineRequests triggers saveCheckpoint at a specific step and how, after rewind, the conversation is replayed to the LLM from a given point. The way the tool executor exposes saveCheckpoint to all handlers is covered in Tool execution.

See official docs: Cline docs · CheckpointTracker.ts