Git 与 Worktree:编码 agent 怎么隔离工作区
编码 agent 会连续读取文件、执行命令、修改代码。单个会话尚且容易留下未提交的改动,多个会话同时操作一个目录时,问题会更直接:一个会话切换分支,另一个会话正在运行的测试和准备提交的文件都会跟着变化。
Git worktree 解决的正是这个问题。它让同一个仓库拥有多个工作目录,每个目录有独立的 HEAD、索引和工作区,同时共享对象库与仓库配置。不同 agent 可以在不同目录里工作,不必反复 stash,也不必为每个任务重新克隆仓库。
不过,框架能够执行 git worktree add,不等于框架原生支持 worktree。真正的支持至少涉及三个层次:
| 层次 | 框架需要做什么 |
|---|---|
| 命令执行 | 允许 agent 通过 shell 执行 git status、git diff 和 git worktree |
| 仓库感知 | 识别仓库根目录、当前分支、linked worktree,并把必要信息交给 agent 或客户端 |
| 工作区编排 | 创建、启动、列出、重置和删除 worktree,把会话绑定到对应目录 |
MAF、Codex 和 OpenCode 对这三层边界的选择完全不同。MAF 只提供第一层所需的通用执行能力,Codex 深入处理 Git 安全和仓库识别,但不替会话创建 worktree,OpenCode 则把 worktree 做成了完整的工作区服务。
MAF:Git 只是 shell 中的一条命令
MAF 是通用 agent 框架,不是专门的编码 agent。它没有 Git 服务、worktree 类型或按会话分配仓库的组件。agent 要操作 Git,只能通过 Microsoft.Agents.AI.Tools.Shell 执行普通命令。
LocalShellExecutor.RunAsync() 接收的核心参数只有命令字符串。策略检查通过后,命令会进入持久 shell 或一次性进程,执行器不会判断它是不是 Git 命令,也不会解析分支和工作区状态。
public override async Task<ShellResult> RunAsync(
string command,
CancellationToken cancellationToken = default)
{
var decision = this._policy.Evaluate(
new ShellRequest(command, this._workingDirectory));
if (!decision.Allowed)
{
throw new ShellCommandRejectedException(...);
}
return this._mode == ShellMode.Persistent
? await this.RunPersistentAsync(command, cancellationToken)
: await this.RunStatelessAsync(command, cancellationToken);
}
源码位置:src/Microsoft.Agents.AI.Tools.Shell/LocalShellExecutor.cs
这里的 WorkingDirectory 只是进程的当前目录。无状态模式把它赋给 ProcessStartInfo.WorkingDirectory,持久模式则可以在每次执行前重新回到指定目录。它能够让 agent 在某个已有 worktree 中运行,却不会创建 worktree,也不会给每个会话分配不同目录。
MAF 还有名为 checkpoint 的功能,但它属于工作流运行时。InProcessRunner.CheckpointAsync() 保存的是 RunnerStateData、状态字典和边状态,恢复时调用的是 ImportStateAsync() 与 ImportEdgeStateAsync():
RunnerStateData runnerData = await this.RunContext.ExportStateAsync();
Dictionary<ScopeKey, PortableValue> stateData =
await this.RunContext.StateManager.ExportStateAsync();
Checkpoint checkpoint = new(
this.StepTracer.StepNumber,
this._workflowInfoCache,
runnerData,
stateData,
edgeData,
this._lastCheckpointInfo);
源码位置:src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs
这些数据用于恢复工作流执行位置和运行状态,不包含仓库文件。即使 workflow 恢复到上一个 checkpoint,agent 已经写入磁盘的代码也不会随之回滚。因此,用 MAF 构建编码 agent 时,Git 感知、worktree 生命周期和文件快照都需要应用自己实现。最直接的做法是由宿主程序预先创建 worktree,再把目录传给 LocalShellExecutorOptions.WorkingDirectory,而不是让多个 agent 共用一个目录。
Codex:理解 Git,但不替会话创建 worktree
Codex 对 Git 的处理远比 MAF 深。codex-rs/git-utils 集中了仓库信息、diff 和 patch 等能力,内部 Git 命令还会统一附加安全参数。
operations.rs 中的 run_git() 会为内部命令增加两项配置:
args_vec.push(OsString::from("-c"));
args_vec.push(OsString::from(crate::SAFE_BARE_REPOSITORY_CONFIG));
args_vec.push(OsString::from("-c"));
args_vec.push(OsString::from(format!(
"core.hooksPath={DISABLED_HOOKS_PATH}"
)));
SAFE_BARE_REPOSITORY_CONFIG 的值是 safe.bareRepository=explicit,DISABLED_HOOKS_PATH 在 Windows 上是 NUL,在 Unix 上是 /dev/null。前者避免 Git 隐式接受裸仓库,后者防止内部查询意外触发用户配置的 hook。另一条带超时的执行路径还会设置 GIT_OPTIONAL_LOCKS=0,并处理 fsmonitor。Codex 不是简单地“能跑 Git”,而是在尽量消除用户环境对内部 Git 操作的影响。
每轮任务开始后,TurnMetadataState::spawn_git_enrichment_task() 会异步获取工作区元数据。三个查询通过 tokio::join! 并行执行:
let (head_commit_hash, associated_remote_urls, has_changes) = tokio::join!(
get_head_commit_hash(&self.cwd),
get_git_remote_urls_assume_git_repo(&self.cwd),
get_has_changes_in_repo(&self.cwd, repo_root),
);
源码位置:codex-rs/core/src/turn_metadata.rs
这里收集的是当前提交、远程地址和工作区是否有改动。它们成为工作区元数据,服务于客户端展示、记录和审计,不等同于“给模型自动创建了一个隔离分支”。
Codex 对 linked worktree 的识别有两条路径。轻量的 get_git_repo_root() 只向上查找 .git 文件或目录,源码注释明确提醒,这条路径不保证覆盖位于主仓库外部的 worktree。用于信任判断的 resolve_root_git_project_for_trust() 则会读取 .git 文件中的 gitdir:,检查路径是否位于 .git/worktrees/<name> 下,再回溯到主仓库根目录。
let git_dir_s = fs.read_file_text(&dot_git_uri, None).await.ok()?;
let git_dir_rel = git_dir_s.trim().strip_prefix("gitdir:")?.trim();
let git_dir_path = AbsolutePathBuf::resolve_path_against_base(
git_dir_rel,
repo_root.as_path(),
);
let worktrees_dir = git_dir_path.parent()?;
if worktrees_dir.as_path().file_name() != Some(OsStr::new("worktrees")) {
return None;
}
let common_dir = worktrees_dir.parent()?;
common_dir.parent()
源码位置:codex-rs/git-utils/src/info.rs
因此,准确的说法不是“Codex 不支持 worktree”,而是它能够识别和处理部分 worktree 语义,却没有生产代码负责为每个会话执行 git worktree add、分配分支、启动项目和清理目录。源码中的 git worktree add 主要出现在识别逻辑的测试里。
文件修改也要和 Git patch 区分。agent 使用的 apply_patch 由 codex_apply_patch::apply_patch() 直接操作文件系统:
let result = codex_apply_patch::apply_patch(
&req.action.patch,
&req.action.cwd,
&mut stdout,
&mut stderr,
fs.as_ref(),
sandbox.as_ref(),
).await;
源码位置:codex-rs/core/src/tools/runtimes/apply_patch.rs
git-utils/src/apply.rs 里确实还有调用 git apply --3way 的 apply_git_patch(),但它是另一条应用 Git diff 的路径,不是 agent apply_patch 工具的主执行链。把两者拆开后,职责更清楚:日常编辑走受控的文件工具,需要 Git 三路合并时才调用 Git。
Codex 当前也没有把“回退对话”包装成“回退文件”。Op::ThreadRollback 的注释写得很直接:它只从线程上下文中移除最近若干轮,客户端要自行撤销磁盘上的编辑。也就是说,Codex 能够感知仓库、生成 diff、在 linked worktree 中工作,但并行工作区仍应由启动 Codex 的外部程序或用户提前准备。
OpenCode:把 worktree 当成工作区服务
OpenCode 不只识别 Git 仓库,还直接管理 worktree 的生命周期。packages/opencode/src/worktree/index.ts 中的服务负责命名、创建、启动、列出、重置和删除,工作区也会登记为项目 sandbox。
创建前,candidate() 最多尝试 26 次来寻找不冲突的名称。普通模式使用 opencode/<name> 作为分支,detached 模式则不创建分支。目录统一放在 OpenCode 数据目录中:
const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id)
const name = input.name
? attempt === 0
? input.name
: `${input.name}-${Slug.create()}`
: Slug.create()
const branch = input.detached ? undefined : `opencode/${name}`
const directory = pathSvc.join(input.root, name)
源码位置:packages/opencode/src/worktree/index.ts
真正创建时,OpenCode 先使用 --no-checkout 建立工作树:
const created = yield* git(
info.branch
? ["worktree", "add", "--no-checkout", "-b", info.branch, info.directory]
: ["worktree", "add", "--no-checkout", "--detach", info.directory, "HEAD"],
{ cwd: ctx.worktree },
)
setup() 完成后,boot() 才在新目录执行 git reset --hard 填充文件,加载这个目录对应的 OpenCode store,发布 worktree.ready 事件,并运行项目启动脚本。创建接口可以尽快返回,耗时的启动过程则在独立 Effect 中继续执行。失败时会发布 worktree.failed,客户端不必通过轮询 Git 状态来猜工作区是否可用。
删除不是简单地删目录。OpenCode 会先停止目标目录的 fsmonitor,执行 git worktree remove --force,清理残留目录,最后删除关联分支。Windows 上文件句柄更容易延迟释放,因此目录清理最多重试 50 次,其他平台重试 5 次。
重置操作也比 git reset --hard 更彻底。它拒绝重置主工作区,然后解析默认分支,必要时先 fetch,接着依次执行:
git reset --hard <default-branch-ref>
git clean -ffdx
git submodule update --init --recursive --force
git submodule foreach --recursive git reset --hard
git submodule foreach --recursive git clean -fdx
最后还会检查 git status --porcelain=v1。只要仍有本地改动,reset 就会返回失败,而不是把一个半干净的目录交给下一个会话。
worktree 创建出来之后,会话还必须知道自己在哪个目录运行。OpenCode 的 system prompt 会明确注入当前目录、工作区根目录和 Git 状态:
` Working directory: ${ctx.directory}`,
` Workspace root folder: ${ctx.worktree}`,
` Is directory a git repo: ${ctx.project.vcs === "git" ? "yes" : "no"}`,
源码位置:packages/opencode/src/session/system.ts
请求进入服务端后,workspace routing 与 instance context 会根据目录加载对应实例。会话记录的路径也相对于当前 ctx.worktree 计算。worktree 因而不只是磁盘上的一个目录,它参与了请求路由、实例状态和会话定位。
OpenCode 还实现了另一种容易与 worktree 混淆的能力:按会话撤销文件。worktree 隔离的是不同任务,snapshot 与 revert 处理的是同一任务中“刚才那轮改错了”。当前源码中旧接口和新接口仍然并存,新版 SessionRevert 使用 stage、clear、commit 表达三个阶段。
plan() 会查找撤销边界之后的 assistant 消息。每条消息都记录了修改开始时的 snapshot 和涉及的文件。对于每个文件,算法保留它第一次被修改前的 snapshot:
const files = new Map<RelativePath, Snapshot.ID>()
for (const row of rows) {
const message = yield* decode(...)
if (message.type !== "assistant" || !message.snapshot?.start) continue
for (const file of message.snapshot.files ?? []) {
if (!files.has(file)) {
files.set(file, Snapshot.ID.make(message.snapshot.start))
}
}
}
源码位置:packages/core/src/session/revert.ts
stage() 按这张文件到 snapshot 的映射恢复工作区,同时保存撤销前的 snapshot,供 clear() 重新恢复。commit() 则确认这次撤销。这个机制比删除整个 worktree 粒度更细,也不要求用户在每轮对话前手动提交。
三种边界怎么选
三个项目并不是在争论同一种实现方式,它们首先选择了不同的产品边界。
| 能力 | MAF | Codex | OpenCode |
|---|---|---|---|
| 通过 shell 执行 Git | 支持 | 支持 | 支持 |
| 内部 Git 调用硬化 | 没有专用 Git 层 | 禁用 hook 并限制裸仓库等 | 统一 Git 参数并处理平台差异 |
| 主动采集仓库元数据 | 不支持 | 支持 | 支持 |
| 识别 linked worktree | 无专用逻辑 | 部分路径支持 | 支持 |
| 主动创建 worktree | 不支持 | 不支持 | 支持 |
| worktree 启动与事件 | 不支持 | 不支持 | 支持 |
| worktree 重置与删除 | 不支持 | 不支持 | 支持 |
| 按会话撤销文件 | 不支持 | 不支持 | 支持 |
如果应用只是偶尔让 agent 执行 Git 命令,MAF 的通用 shell 已经够用,但宿主必须负责目录分配和回滚策略。若重点是安全地分析和修改代码,且工作区由 IDE、CLI 或调度系统预先准备,Codex 的边界更合适:它认真处理 Git 元数据、diff 与安全参数,却不接管仓库布局。需要在一个服务中同时运行多个项目、多个会话,并让系统自动管理隔离目录时,OpenCode 的 Worktree 服务才是完整答案。
实际设计编码 agent 时,最好把三件事分开:shell 权限决定 agent 能执行哪些 Git 命令,worktree 决定并行任务是否共享工作目录,snapshot 决定单轮修改能不能撤销。三者可以组合,却不能彼此替代。只有 shell 而没有 worktree,多个会话仍会互相影响;只有 worktree 而没有命令权限控制,agent 仍可能执行危险操作;只有 Git 分支而没有 snapshot,用户想撤销上一轮修改时仍要自己整理 diff。
因此,worktree 支持的判断标准不该是源码里有没有出现 git worktree,而应该看框架是否真正管理了“目录创建、会话绑定、就绪状态、重置和清理”这一整条链路。按这个标准,MAF 提供的是搭建能力,Codex 提供的是 Git 基础设施,OpenCode 提供的才是可直接调度的隔离工作区。