Git 与 Worktree:编码 agent 怎么隔离工作区

编码 agent 会连续读取文件、执行命令、修改代码。单个会话尚且容易留下未提交的改动,多个会话同时操作一个目录时,问题会更直接:一个会话切换分支,另一个会话正在运行的测试和准备提交的文件都会跟着变化。

Git worktree 解决的正是这个问题。它让同一个仓库拥有多个工作目录,每个目录有独立的 HEAD、索引和工作区,同时共享对象库与仓库配置。不同 agent 可以在不同目录里工作,不必反复 stash,也不必为每个任务重新克隆仓库。

不过,框架能够执行 git worktree add,不等于框架原生支持 worktree。真正的支持至少涉及三个层次:

层次框架需要做什么
命令执行允许 agent 通过 shell 执行 git statusgit diffgit 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=explicitDISABLED_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_patchcodex_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 --3wayapply_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 使用 stageclearcommit 表达三个阶段。

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 粒度更细,也不要求用户在每轮对话前手动提交。

三种边界怎么选

三个项目并不是在争论同一种实现方式,它们首先选择了不同的产品边界。

能力MAFCodexOpenCode
通过 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 提供的才是可直接调度的隔离工作区。