Checkpoints: a shadow git for task rewind
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.gitdirectory withcore.worktreepointing 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 —
workspacetouches only files,tasktouches only conversation messages, andtaskAndWorkspacetouches 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.
MultiRootCheckpointManagerdispatches on the outside. - Nested .git breaks
git add: when git encounters a.gitinside a subdirectory it treats it as a submodule by default. The shadow git has to temporarily rename-and-disable nested.gitdirectories 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;
validateWorkspacePathrefuses them outright.
Key files
class CheckpointTracker:50— the shadow git controller for a single task; holdstaskIdandcwdHash.commit:212— acquire the lock →git add .→git commit --allow-empty --no-verify, returning the commit hash.resetHead:336—git 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; configurescore.worktree/user.email/ excludes.renameNestedGitRepos:148— temporarily disable and restore nested.gitdirectories to bypass the submodule limitation.addCheckpointFiles:203— disable nested git →git add . --ignore-errors→ restore nested git.getShadowGitPath:20— shadow git pathglobalStorage/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 bymessageTsand dispatches onrestoreType.buildCheckpointManager:59— picksTaskCheckpointManagerorMultiRootCheckpointManagerbased 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:
// 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:
// 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) —checkpointManagerErrorMessagecarries aCheckpoints initialization timed out.marker; any subsequentsaveCheckpointreturns immediately and won't retry. - Nested .git rename failures retry (
retryWithBackoff:223) —addCheckpointFilesrestores nested git in afinallyblock 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_disabledstate. - Directory lock prevents concurrency (
tryAcquireCheckpointLockWithRetry:220): multiple Cline instances under the same cwdHash could trample each other, socwdHashis used as the lock key. VS Code's internal scenarios skip the lock. - Back-to-back
checkpoint_createdmessages are deduped (back-to-back guard:160): if the previous message is alreadycheckpoint_created, it returns immediately to avoid empty commits piling up.attempt_completionadditionally 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 withisBinaryFileand 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 intotaskState.checkpointManagerErrorMessageand 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