数据截至 (上游 commit 60706feb348c)
工作区、worktree 与 git 检出:并行开发 的底座
30 秒导读: 五个 agent 同时改同一个仓库,不能都在一个目录里踩来踩去。Paseo 给每个 agent 发一份独立的 git worktree(同一个
.git、不同的工作目录、不同的分支),再用项目 / 工作区 / 检出三层身份把这些目录管起来:哪些状态按目录共享、哪些按工作区隔离,规则写死在键的选择里。
本章讲的是「地基」。上面几层——一条连接、归一层、AgentManager、客户端同步——都假定「agent 有一个 cwd」。这一章回答:那个 cwd 是谁给的、凭什么保证两个 agent 不打架。
1. 这是什么(零基础也能懂)
1.1 先看要解决的麻烦
假设你有一个仓库 ~/code/app,你想同时让三个 agent 干活:
- A 在重构支付模块
- B 在修一个线上 bug
- C 在给 PR #2638 补测试
如果三个都在 ~/code/app 里跑,它们会共用同一个工作目录:A 切分支,B 的编译就炸了;C 改的文件被 A 的 git checkout 覆盖。这 不是 agent 的问题,是文件系统的问题。
1.2 Paseo 的答案:一人一份工作副本
Git 自带的 git worktree 正好解决这个:同一个仓库可以挂出多个工作目录,共享一份 .git(对象库、refs 都是同一份),但每个目录独立检出一个分支。Paseo 把它包装成了一个可管理的资源。
~/code/app ← 你自己的检出(local_checkout)
.git/ ← 唯一的对象库,下面几份共享它
~/.paseo/worktrees/
k3f9x2a1/ ← 这个仓库的哈希目录
refactor-payments/ ← agent A 的工作副本,分支 refactor-payments
fix-login-500/ ← agent B 的工作副本,分支 fix-login-500
pr-2638-tests/ ← agent C 的工作副本,分支 pr-2638-tests
1.3 三个名词,一次说清
Paseo 的 UI 和代码里反复出现三个词,含义严格区分,别混:
| 名词 | 白话 | 它是什么 | 生命周期 |
|---|---|---|---|
| 项目(project) | 「哪个仓库」 | 一个远端 URL(或一个本地路径)对应的逻辑仓库 | 显式删除前一直存在,哪怕当前一个工作区都没有 |
| 工作区(workspace) | 「哪份工作副本」 | 一个具体的 cwd + 它的分支/worktree 元数据 | agent 干活的单位,可归档 |
| 检出(checkout) | 「此刻的 git 事实」 | 从磁盘上真读出来的分支、脏否、领先落后 | 无生命周期,随时重读 |
一句话记忆:项目是身份,工作区是资源,检出是观测。
2. 顶层全景(它大概怎么转)
这张图从左到右是「一次新建工作区」的主流程,回头箭头是持续运行的观测回路。
①请求建工作区 ②算身份 ③造目录
client ──────────────► WorkspaceProvisioning ──► project-key ──► createWorktree
│ (归并到哪个 (git worktree add
│ 项目) + paseo.json setup)
▼
┌───────────────────┐
│ 两个 JSON 注册表 │ $PASEO_HOME/projects/
│ projects.json │ ├ projects.json
│ workspaces.json │ └ workspaces.json
└─────────┬─────────┘
│ ④订阅
▼
┌───────────────────┐ watcher 事件
│ WorkspaceGitService│◄───────────── 磁盘
│ (daemon 全局) │
└─────────┬─────────┘
│ ⑤快照
┌───────────┴───────────┐
▼ ▼
GitObserver Reconciliation
(推给客户端) (把观测写回注册表)
各部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
| WorkspaceProvisioningService | 建/删工作区记录,决定它归哪个项目 | packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts |
| deriveProjectKey | 算「这两个目录算不算同一个项目」 | packages/server/src/server/project-key.ts:6 |
| workspace-registry-model | 定义三种工作区形态 + 落盘字段的推导规则 | packages/server/src/server/workspace-registry-model.ts |
| FileBackedRegistry | 把记录原子写进 JSON,并广播变更 | packages/server/src/server/workspace-registry.ts:169 |
| createWorktree | 真正调 git worktree add、跑 setup 脚本 | packages/server/src/utils/worktree.ts:1211 |
| WorkspaceGitServiceImpl | daemon 全局的 git 观测层:watcher + 缓存 + 自愈 | packages/server/src/server/workspace-git-service.ts:520 |
| WorkspaceReconciliationService | 把磁盘真相写回注册表,归档消失的目录 | packages/server/src/server/workspace-reconciliation-service.ts:115 |
3. 身份模型:三层各是什么
3.1 项目:靠 projectKey 归并
它要解决的小问题: 你在 ~/code/app 有一份检出,同事给你的 worktree 在 ~/.paseo/worktrees/k3f9x2a1/fix-login。这两个目录路径完全不同,但显然是同一个仓库——怎么让程序也这么认为?
思路: 优先用远端 URL 当身份,没有远端才退回本地路径。
deriveProjectKey(packages/server/src/server/project-key.ts:6)的三种输出形态:
| 情况 | key 长什么样 | 为什么 |
|---|---|---|
| 有远端 | remote:github.com/org/repo | 同一个远端 = 同一个项目,和本地路径无关 |
| 有远端 + 选的是子目录 | remote:github.com/org/repo#subdir:packages/app | monorepo 里各包可以是独立项目 |
| 无远端 | host:<serverId>:/abs/path | 纯本地仓库只能靠路径,serverId 防止跨机撞车 |
GitHub 的 path 会被 toLowerCase()(project-key.ts:19),因为 GitHub 的仓库名大小写不敏感;别的 host 不做这个假设。
3.2 工作区:三种形态
PersistedWorkspaceKind(workspace-registry-model.ts:10)只有三个值,判定逻辑就在隔壁的 deriveWorkspaceKind(:24):
| kind | 判定条件 | 典型场景 |
|---|---|---|
directory | 不是 git 仓库 | 让 agent 在一个普通文件夹里干活 |
worktree | 是 git,且 mainRepoRoot 非空 | 挂出来的工作副本(不管是不是 Paseo 建的) |
local_checkout | 是 git,且 mainRepoRoot 为空 | 你自己 clone 的那份主检出 |
判定只看一个信号:checkout.mainRepoRoot 有没有值。这个值由 getMainRepoRoot(packages/server/src/utils/checkout-git.ts:991)从 git rev-parse --git-common-dir 反推——common dir 的父目录就是主仓库根。
显示名的规则(deriveWorkspaceDisplayName,:31)也很简单:有分支名就用分支名,分支是 HEAD(detached)或者根本不是 git,就用路径最后一段。
3.3 落盘时到底写哪些字段
initialWorkspacePlacement(workspace-registry-model.ts:80)是唯一决定新工作区落盘形态的函数。它接受两种来源,输出同一种结构:
| 来源 | 什么时候用 | 特殊之处 |
|---|---|---|
source: "checkout" | 用户挑了一个已有目录 | 所有字段从磁盘观测推导,baseBranch 恒为 null |
source: "created_worktree" | Paseo 刚建完 worktree | kind 硬写 worktree、isPaseoOwnedWorktree: true、记得住 baseBranch |
baseBranch(从哪个分支切出来的)只有第二种来源才有值——因为只有 Paseo 自己建的时候才知道这件事,事后从磁盘是问不出来的。
3.4 观测回写:哪些字段可以被改,哪些永远不动
工作区落盘之后,磁盘上的事实会变(用户手动切了分支、把 worktree 删了)。reconcileWorkspacePlacement(workspace-registry-model.ts:113)负责把观测写回去,但它只动一小撮字段:
| 字段 | 会被观测覆写吗 | 原因 |
|---|---|---|
kind / branch / worktreeRoot / mainRepoRoot / isPaseoOwnedWorktree | 会 | 这些是 git 事实,磁盘说了算 |
title | 不会 | 用户起的名字,机器不许碰(workspace-registry.ts:50-52 的注释写死了) |
displayName | 不会 | 创建时定下的长期名字 |
baseBranch | 不会 | 创建时的历史事实,事后无法重新观测 |
实现上它复用了 initialWorkspacePlacement 算出「应该是什么」,再逐字段 diff,没有 diff 就返回 null 表示不用写盘(:133)。
3.5 两个 JSON 文件
持久化位置固定在 $PASEO_HOME/projects/ 下,由 bootstrap 拼出来(packages/server/src/server/bootstrap.ts:860-867):
$PASEO_HOME/
projects/
projects.json ← PersistedProjectRecord[]
workspaces.json ← PersistedWorkspaceRecord[]
存储层是一个泛型的 FileBackedRegistry(workspace-registry.ts:169),三个设计点值得记:
- 全量读进内存,写时全量刷回。 记录数量是「你手上有几个工作区」这个量级,不需要数据库。
- 写串行化。
enqueuePersist(:307)把每次持久化挂在上一次的 promise 后面,避免并发写把 JSON 写花;失败被.catch(() => {})吞掉以免污染队列。 - schema 里全是宽容解析。 新字段一律
.optional().transform(v => v ?? null),并在注释里打COMPAT(...)标记和删除日期(如:19、:83),这样老 daemon 写的文件新 daemon 读得动。
项目 id 的分配额外加了一把锁:getOrCreateActiveByRoot(:339)用 allocationQueue 串行化整个「查重 + 创建」过程,并且在同 rootPath 有多条时按 createdAt 再按 id 排序取第一条,保证并发调用拿到同一个项目。
3.6 路径反查工作区:一个被刻意限制的口子
resolveWorkspaceIdForPath(packages/server/src/server/resolve-workspace-id-for-path.ts:17)做的是「给我一个路径,告诉我是哪个工作区」。文件顶部的注释(:6-16)把它的适用范围钉死了:
只在客户端交来一个裸 worktree 路径、且没有 id 的边界上使用——按路径归档(老客户端 / CLI)、合并后自动归档、MCP 的
archive_worktree工具。绝不用来归属 agent 状态。
匹配规则也很克制:精确目录匹配优先;否则取最深的包含它的工作区目录;并且显式跳过 home 目录(:34),免得「所有路径都落进 ~」这种灾难。
4. 隔离底座:Paseo 自有的 worktree
4.1 路径形状就是所有权证明
Paseo 需要能回答「这个目录是不是我建的、我能不能删」。它没有维护一张所有权表,而是用路径布局本身当凭证。
三段式路径由三个函数拼出来:
resolvePaseoWorktreesBaseRoot() → ~/.paseo/worktrees
│ (可被 PASEO_HOME / worktreesRoot 覆盖)
▼
deriveWorktreeProjectHash(cwd) → k3f9x2a1
│ (sha256(repoRoot) 取前 8 位 base36)
▼
getPaseoWorktreesRoot(cwd) → ~/.paseo/worktrees/k3f9x2a1
│
▼
computeWorktreePath(cwd, slug) → ~/.paseo/worktrees/k3f9x2a1/fix-login
对应 packages/server/src/utils/worktree.ts:853(base root)、:828(hash)、:854(project root)、:864(最终路径)。
哈希算的是 repoRoot,不是当前 cwd(:830-834 先 getGitCommonDir 再去掉 .git 后缀),所以从主检出和从任意一个 worktree 里发起,算出来的哈希都一样——同一个仓库的 worktree 全部归到同一个哈希目录下。
判所有权的 isPaseoOwnedWorktreeCwd(:919)因此可以不问 git:
输入 cwd
│
├─ 相对 <base-root> 求相对路径 ──► null? ──► 不是我的
│ │
│ ▼
└─ 相对路径至少两段(<hash>/<slug>)? ──► 否 ──► 不是我的
│
└─ 是 ──► 是我的
源码注释(:943-946)把理由写得很直白:<hash>/<slug> 这个前缀是 Paseo 私有的,没有别人往那儿写,所以路径形状本身就足以证明所有权,哪怕 git 已经忘了这个 worktree 的存在。这一条是「归档时 git 已经半坏了也能清干净」的前提。
4.2 建一个 worktree:四种来源
WorktreeSource(worktree.ts:180)是个判别联合,四种来源决定了 git worktree add 的参数长什么样:
| kind | 意思 | git worktree add 参数 | 源码 |
|---|---|---|---|
branch-off | 从某个基线分支切新分支 | -b <new> --no-track <base> | :1324-1338 |
checkout-branch | 检出一个已存在的分支 | <branchName> | :1340-1360 |
checkout-change-request | 检出某个 forge 的 PR/MR | 先 fetch refs,再检出本地分支 | :1362-1425 |
checkout-github-pr | 同上的 GitHub 专用旧形态 | 同上 | 同上 |
branch-off 有个细节值得学:如果你要的分支名已经存在,它不会报错,而是把「基线」换成那个已存在的分支,并用 worktree slug 当新分支名的候选(:1329-1332),再交给 resolveUniqueLocalBranchName(:1661)加 -1、-2 后缀直到不撞车。
checkout-branch 则相反,遇到冲突就直接拒绝——因为 git 本身不允许两个 worktree 检出同一个分支:
// packages/server/src/utils/worktree.ts:1352
if (await isBranchCheckedOut(cwd, source.branchName)) {
throw new BranchAlreadyCheckedOutError(source.branchName);
}
isBranchCheckedOut(:1671)靠 git worktree list --porcelain 判断,而不是猜。
三 个领域错误类型都带结构化字段,方便上层做针对性提示:
| 错误类 | 携带字段 | 触发点 |
|---|---|---|
BranchAlreadyCheckedOutError | branchName | 分支已被别的 worktree 占用(:216) |
UnknownBranchError | branchName、cwd | 本地没有、git fetch origin 也拉不到(:226) |
InvalidGitBranchNameError | branchName | git 拒绝这个 ref 名(:238) |
4.3 完整创建流程
createWorktree(worktree.ts:1211)是唯一直接调 git worktree add 的地方(:1250 的注释明确说了「上层请走 createWorktreeCore」)。它的顺序:
① resolveWorktreeSourcePlan 按 source.kind 算出 add 参数、分支名
│
② 路径去重 while(存在) 加 -1/-2 后缀
│
③ git worktree add 超时 120s
│
④ 配置 push / tracking remote 只有 PR 检出才需要
│
⑤ 写 worktree 元数据 baseRefName、changeRequestLookupTarget
│
⑥ seedPaseoConfigFile 把源仓库的 paseo.json 复制进来
│
⑦ runWorktreeSetupCommands 失败 → 立刻销毁刚建的 worktree
上一层的 createWorktreeCore(packages/server/src/server/worktree-core.ts:50)先把意图解析成规范化 slug(branch-off 就用分支名,checkout PR 就用本地分支名);底层 createWorktree(worktree.ts:1211)遇到路径冲突时不再复用旧 worktree,而是给新路径追加 -1、-2 后缀(worktree.ts:1226-1230)——旧版「同 slug 查 git worktree list 复用并返回 created: false」的幂等逻辑已移除。