Checkpoints:任务回放的影子 Git
职责
Checkpoints 是 Cline 给任务加的「时间机器」。每次 LLM 要动手前,Cline 会把当前工作区拍一张快照存下来;如果用户对某次工具调用的结果不满意,可以把代码、文件甚至整段对话历史回滚到任意一个快照点,然后从那里继续对话。
整套机制建立在影子 git 仓库 (shadow git) 上:不是用户工作区里那个 .git,而是 Cline 在自己的 globalStorage 里偷偷维护的另一个 git 仓库,core.worktree 指向用户的工作目录。每次 commit 就是一个 checkpoint,git reset --hard 就完成回滚。这样既能复用 git 的 diff/stage/commit 能力,又完全不污染用户自己的仓库状态。
对外暴露的核心是 TaskCheckpointManager,它把「该不该建快照、commit hash 该贴到哪条消息上、回滚后要把对话历史删到哪一段」这些业务逻辑包起来;真正干 git 活的是底下的 CheckpointTracker 和 GitOperations。
设计动机
- 不能改用户的 .git:用户的工作区可能已经在另一个 git 仓库下,直接
git add .会污染他们的 index。影子 git 用独立的.git目录 +core.worktree指回去,两个仓库完全隔离。 - 每次工具调用都要可回退:LLM 编辑文件不可预测,用户需要看到「这个工具调用之前」和「之后」的对比,并能撤销到之前任意一步。所以 commit 的节奏必须卡在工具执行边界。
- 避免把消息历史也一起回滚:回滚分两类——
workspace只动文件,task只动对话消息,taskAndWorkspace两者都动。这样用户可以「保留对话但还原代码」,或「连对话一起退回去重走」。 - 多 root 工作区要分别处理:一个 workspace 可能挂多个目录,单根影子 git 不够用,所以有
MultiRootCheckpointManager在外面分发。 - 嵌套 .git 会把 git add 搞挂:git 遇到子目录里的
.git默认按 submodule 处理,影子 git 必须在每次 add 之前把嵌套.git临时改名禁用,add 完再改回来。 - 保护目录要拦下:home / Desktop / Documents / Downloads 这些目录范围太大,会扫到一堆无关文件,直接在
validateWorkspacePath里拒绝。
关键文件
class CheckpointTracker:50— 单个任务对应的影子 git 操控器,持有taskId和cwdHash。commit:212— 获取锁 →git add .→git commit --allow-empty --no-verify,返回 commit hash。resetHead:336—git reset --hard <hash>把工作区还原到指定 checkpoint。getDiffSet:397— 两个 commit 之间(或 commit ↔ 工作区)的文件差异,带前后内容。initShadowGit:59— 首次创建影子 git 仓库,配置core.worktree/user.email/ excludes。renameNestedGitRepos:148— 临时禁用/恢复嵌套.git目录,绕开 submodule 限制。addCheckpointFiles:203— 禁用嵌套 git →git add . --ignore-errors→ 恢复嵌套 git。getShadowGitPath:20— 影子 git 路径globalStorage/checkpoints/{cwdHash}/.git。hashWorkingDir:103— 把工作目录路径 hash 成 13 位数字,作为影子 git 目录名。saveCheckpoint:118— 业务入口,决定要不要建 commit、把 hash 贴到对应消息上。restoreCheckpoint:238— 按messageTs找 checkpoint,按 restoreType 分支还原。buildCheckpointManager:59— 按 workspace 是单根还是多根,挑TaskCheckpointManager或MultiRootCheckpointManager。saveCheckpointCallback:1113— Task 暴露给工具执行器的回调,工具完成后调它。ensureCheckpointInitialized:2920— 第一次 API 请求前确保影子 git 已经初始化好。
数据流
每个任务第一次发 API 请求时,会顺手把 checkpoint 系统拉起来——如果初始化超时,后续整个任务都不再尝试 checkpoint,避免每轮都卡在超时上:
// 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}`,
});
}
}初始化成功后,任务在两个时机触发 saveCheckpoint:(1) 每个 attempt_completion 完成(AttemptCompletionHandler:156);(2) 一次 assistant 消息中所有工具都跑完之后(saveCheckpoint after tools:3811)。saveCheckpoint 内部不直接 commit,而是先 say("checkpoint_created") 占一条消息,然后异步 commit,commit hash 再回填到这条消息上:
// 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,
)
})
}
}之所以异步:LLM 的下一轮请求不用等 commit 写完就能继续。lastCheckpointHash 字段就是日后回滚的锚点。
回滚走 restoreCheckpoint,根据 restoreType 走不同分支:只还原代码就 resetHead(hash),只回退对话就修改 conversationHistoryDeletedRange,两者都做就都做。还原工作区时如果 tracker 还没起来,会现场 CheckpointTracker.create 一个。
边界与失败
- home / Desktop / Documents / Downloads 直接拒绝(
validateWorkspacePath:59),范围太大扫不动也容易撞权限。 - 初始化超时一次就放弃整个任务(
timeout guard:123),checkpointManagerErrorMessage里带Checkpoints initialization timed out.标记,后续saveCheckpoint一进来就 return,不再重试。 - 嵌套 .git 改名失败要重试(
retryWithBackoff:223),addCheckpointFiles在 finally 里用 3 次指数退避恢复嵌套 git,挂了就只记 error 不抛——否则用户子项目会一直处于.git_disabled状态。 - 文件夹锁防并发(
tryAcquireCheckpointLockWithRetry:220):同一个 cwdHash 下多个 Cline 实例可能互相踩,用cwdHash作锁键。VS Code 内部场景会跳过锁。 - 连续 checkpoint_created 会去重(
back-to-back guard:160):上一条消息已经是checkpoint_created就直接 return,避免空 commit 堆积。attempt_completion还有单独的最近 3 条消息去重(completion dedup:192)。 - 空 commit 用
--allow-empty(empty commit:251):即使工作区没变动,也要留一个 commit 占位,这样 hash 链条不断,回滚永远能找到锚点。 - 二进制文件从 diff 结果里剔除(
binary skip:434):无扩展名或 dotfile 路径会用isBinaryFile探一下,二进制就跳过,不然 diff 视图会糊一堆乱码。 - 多 root 工作区只警告不报错(
multiroot warn:461):检测到多 root 直接把错误信息塞进taskState.checkpointManagerErrorMessage,UI 上展示警告,但任务不中断。
小结
Checkpoints 是 Cline 安全网的底座:用影子 git 把工作区状态序列化成一串 commit,再把 commit hash 贴到对话消息上,这样「时间点」和「对话点」就一一对应。上层只要知道 saveCheckpoint / restoreCheckpoint 两个 API,不用关心底下的 git 操作、嵌套 .git 处理、文件夹锁这些脏活。
回滚链路上的下一步看 agent-loop 的递归循环——recursivelyMakeClineRequests 在哪一步触发 saveCheckpoint、回滚后如何从对话历史的某个点重新喂给 LLM,详见该页。工具执行器把 saveCheckpoint 暴露给所有 handler 的细节在 工具执行 里。
对照官方资料:Cline 文档 · CheckpointTracker.ts。