PR #112 feat/git-module:daemon 的完整 git 基座
figuretu/eyrie · 99c04e1...823adf8 · 2026-07-05 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。
1TL;DR
这个 PR 在 daemon 里从零建成完整的 git 子系统:不引入任何 git 库,全部行为 = spawn 宿主 git 二进制 + 自己解析输出。分四层——进程边界(runner:环境卫生/两阶段杀/字节上限)、纯解析(porcelain/diff/错误分类)、操作服务(status/diff/worktree/两层写锁)、反应式状态引擎(订阅者驱动的文件系统监听→防抖→重算→推送)。两大设计决策同期落地:2-A 数据模型(worktree 不建新表,是 task↔repo 绑定行的「物化态」)和 1-B handle 授权(客户端只发 server 签发的 id,不发主机路径)。它是 Eyrie「任务隔离工作目录 + 看板实时状态 + diff 审查」闭环的物理基底。
error.code 分类——真 Linux 上订阅状态流仍会崩溃,正是此前那个「降级修复」声称要治的现象(修复当时只改了判定端和注释,漏改了守卫本体;所有测试注入的都是手工带 code 的假错误,任何门禁都探不到)。且守卫前提已过时:Node ≥19.1 起 Linux 支持递归 fs.watch(本仓 runtime 下限 22.12)。修复(823adf8)= 删守卫让 Linux 真开原生监听 + 补一个驱动真工厂的测试(在 ubuntu CI 上即为真机行使)。详见旅程 A 第 3 跳。
2变更地图
15.3k 行变更里,设计承载代码高度集中在 apps/daemon/src/git/(8.9k,含每层 colocated 单测);packages/db 的 2.3k 几乎全是 drizzle 生成的迁移 snapshot。测试占全 diff 48%。称重要诚实地说:这不是一个「大而机械」的 PR——除生成物和搬运外,约 6.5k 行源码是真实设计,每层都有自己的不变式。
| 设计重心(要细读) | 可放心略过(机械) |
|---|---|
|
|
3架构一图流
before 的 daemon 对 git 的全部能力是一个 190 行的 services/git-identity.ts(execFile 包装 + 身份探测);after 的 git 面是一条完整的分层管线,且多了一条反向的推送通道。
以前 · 只有身份探测
现在 · 分层 git 基座 + 推送流
依赖方向有护栏钉死:scripts/check-boundaries.mjs 新增规则把 apps/daemon/src/git/ 定为 daemon 私有——packages/api、packages/client、cli/desktop 的生产代码 import 它就 fail,外界只能走 client.git.* 网络契约。
4数据与状态先行
先看形状,后面的旅程直接用这些词。
4.1 两个 handle:客户端手里只有 id
/**
* Server-vetted handle for a registered repository — the repository record id minted at registration,
* never a host path. ...a client cannot name a repository by an arbitrary directory... */
export type RepoRef = {
repoId: string
}
/**
* Server-vetted handle for one worktree — the task↔repo binding id the daemon resolves to the worktree's
* paths, distinct from and unforgeable as any host path. */
export type WorktreeKey = string // 值 = task_repos 行 id,不是路径
整个 git 契约面只有 resolveIdentity({ dir }) 一个方法还收原始路径——它就是「注册目录」这个动作本身(此刻还没有任何库行存在),且被 filesystem-roots 白名单把守。
4.2 worktree = 绑定行的物化态(2-A)
task_repos(task↔repo 绑定表)加两列,没有新表。判别标志是 worktree_git_dir 是否非空:非空 = 这条绑定背后有一个 daemon 管理的 worktree,此时既有的 working_dir 列改指 worktree 根;删掉 worktree 就是把两列清空、working_dir 拨回主 checkout。迁移全文 3 行(两个可空 ADD COLUMN + 一个局部索引),FK 级联天然覆盖——task/project 删除时绑定行连带消失,不需要新级联。
ALTER TABLE `task_repos` ADD `worktree_branch` text;
ALTER TABLE `task_repos` ADD `worktree_git_dir` text;
CREATE INDEX `task_repos_worktree_idx` ON `task_repos` (`project_id`)
WHERE "task_repos"."worktree_git_dir" IS NOT NULL AND "task_repos"."deleted_at" IS NULL;
-- 局部索引只收「活跃且已物化」的行,专为反应器的同步投影服务
4.3 反应器的输入与输出
export type WatchTarget = {
worktree: WorktreeKey // 发 delta 时打的 key = 客户端手里的同一个 handle
worktreeRoot: string // 递归 watch 的工作区根,也是重算的入参
gitDir: string // 本 worktree 自己的 git 目录(HEAD/index/进行中标记)
gitCommonDir: string // 共享 common 目录(refs/packed-refs);主 worktree 时 == gitDir
}
// 推送单位不是增量 patch,而是「哪个 worktree + 完整新快照」
GitStatusDelta = { worktree: WorktreeKey; status: GitStatusDto }
GitStatusDto 是看板要渲染的整包:branch / upstream / ahead / behind / staged / unstaged / untracked / conflicted 计数 + inProgress(merge/rebase/cherryPick/revert/bisect 五种进行中操作,靠 stat 哨兵文件零 spawn 检出)+ didHitLimit(遍历被熔断,计数是有界前缀)。
4.4 runner 的契约:非零 exit 是数据
export type GitRunResult = {
stdout: Buffer // 保持二进制,由调用方决定解码
stderr: string
exitCode: number // 被信号杀死时是哨兵 -1
signal: NodeJS.Signals | null
truncated: boolean // stdout 被字节上限截断——对流式读这是「有界成功」不是失败
stderrTruncated: boolean
}
// The transport contract's repository handle is resolved to this before the mechanism runs,
// so nothing below this layer ever sees a client-supplied path.
export type RepoCommonDir = { gitCommonDir: string }
diff 侧的关键形状:DiffCompare 是 5 变体判别联合(working / staged / worktreeVsHead / range 两点 / vsBase 三点);DiffFile.status 除常规外还有 'binary' | 'tooLarge' | 'permissionChange' 三个非文本档位——「太大」是一种数据状态,不是错误。行级和文件级各有 hasBidi 位,标记内容藏了能静默重排渲染顺序的双向控制字符(Trojan-source 防御),只警示不改内容。
5底座:一次 git spawn 的生命周期 + 错误管线
所有旅程共用这两台机器,先走通一遍,后面只讲各自特有的逻辑。
5.1 spawn 前:argv 和 env 的防御性组装
daemon 是无人值守进程,任何会弹交互提示的路径(credential、GPG、SSH host key、pager)都会等一个不存在的 tty,把进程永久挂死。所以每次 spawn 前,runner 给 argv 统一加 -c 硬化前缀(写命令额外钉 gc.auto=0 防后台 gc 抢锁、commit.gpgsign=false 防 GPG 弹窗),给 env 叠一层反交互配置:
const NON_INTERACTIVE_ENV = {
GIT_TERMINAL_PROMPT: '0',
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new',
GCM_INTERACTIVE: 'never',
GIT_ASKPASS: '',
GIT_PAGER: 'cat',
LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', LANGUAGE: 'en',
// locale 钉死英文——错误分类的 matcher 表按英文文案匹配,这是它的前提
} as const
同时剥掉三族继承 env:git 仓位族(GIT_DIR 等 8 个——daemon 可能是从 git hook 里启动的,不剥的话每个子进程都会打到宿主仓库)、配置注入族(GIT_CONFIG_COUNT/KEY_n/VALUE_n,数量开放所以按 pattern 剥)、提交身份族(GIT_AUTHOR_* 等 7 个)。读命令再加 GIT_OPTIONAL_LOCKS=0,保证读永不和用户手上的 git 抢 index.lock。
--no-verify 的注入是动词白名单制而非一刀切:它是子命令选项(必须插在动词后),且 cherry-pick/revert/am 在宿主 2.39.x git 上会以 exit 129 拒绝它——盲目注入反而弄坏合法写操作:
const NO_VERIFY_VERBS = new Set(['commit', 'merge', 'push', 'rebase'])
function buildVerbArgs(args: string[], opts: GitRunOptions): string[] {
if (!opts.write || opts.respectHooks || args.length === 0) return args
if (!NO_VERIFY_VERBS.has(args[0] ?? '')) return args
return [...args.slice(0, 1), '--no-verify', ...args.slice(1)] // 插在动词后一位
}
5.2 spawn 后:字节上限、流式 sink、三层杀
stdout/stderr 各有 16MiB 默认上限,超限时保留还装得下的前缀(git 常一个 chunk 吐一大块,整块丢会得到空 buffer)、标 truncated、立即杀进程。调用方可传 onStdout 逐 chunk 流式解析——status 服务靠它在条目数越线的那一刻中途 abort,而不是等全部输出流完。sink 抛错必须内收:它跑在 'data' 事件监听器里,逃出去就是 uncaught exception,直接打挂 daemon。
杀进程是三层防线,因为 git 会派生后代(ssh ControlMaster、smudge filter、hook 子进程),它们继承着 stdio 管道——只杀 git 本体,管道不关,close 事件永不触发,run() 永远 pending:
// beginKill is the single chokepoint for every forced termination (cap, timeout, cancel)
const beginKill = (cause: TerminationCause | null): void => {
// record a real cause before the once-only guard so a cancel/timeout that arrives after a
// cap-triggered kill (which passes null) still upgrades the cause and rejects authoritatively.
if (cause !== null) terminationCause = cause
if (graceTimer !== undefined) return
graceTimer = forceKill(child, opts.graceMs) // ① SIGTERM → 宽限 → ② SIGKILL(对进程组播)
hardDeadlineTimer = setTimeout(forceSettle, opts.graceMs + HARD_DEADLINE_MARGIN_MS)
hardDeadlineTimer.unref() // ③ 后代死握管道时,硬 deadline 强制 settle
}
最终 settle 的优先级:sink 的错误 > timeout/cancel 的权威 reject > 正常 resolve。非零 exit 走 resolve——分类失败的职责整体后移到错误管线:
5.3 错误管线:stderr 考古 → 稳定错误码 → 消毒出线
git 的错误没有机器码,只能按文案「考古」。error-classify.ts 维护 15 个 matcher(稳定错误码 + 行锚定 regex + 可选操作数提取器),逐行匹配防长输出中段误命中。硬规则是消毒:上 wire 的 message 永远是错误目录渲染的句子,原始 stderr 只进结构化数据和日志。
{
code: EyrieErrorCode.repo.localChangesWouldBeOverwritten,
pattern: /^error: Your local changes to the following files would be overwritten by/,
extract: (stderr, line) => ({ files: extractIndentedFiles(stderr, line) }),
// 操作数提取:git 点名的文件清单变成 wire envelope 的 ErrorDetail[],客户端可编程消费
},
{
// the worktree-path error quotes the path (`fatal: '/path' already exists`); requiring the quote
// keeps it from swallowing branch-creation's `fatal: a branch named 'X' already exists`
code: EyrieErrorCode.repo.worktreeAlreadyExists,
pattern: /^fatal: '.*' already exists$/,
},
matcher 表的保真由 real-git 测试层背书:每个 refusal 由活二进制驱动出来、断分类后的 code(从不断原始串、从不 pin git 版本)——git 升版改文案时测试变红,漂移被显式暴露而不是生产里静默误分类。这类回归此前咬过两次,本 PR 收口时把 detachedHead / worktreeAlreadyExists / worktreeLocked 等最后几个 matcher 也全部改为真二进制驱动。
packages/api/src/git.ts 加 schema/DTO(输入收 id 不收路径),注册到 trpc.ts 的 gitRouter;② trpc/services.ts 的 git block 做 id→path 翻译(resolveTarget/resolveRepoCommonDir),转发 opts.signal;③ 机制层实现收 RepoCommonDir+路径,失败走 classifyGitErrorArgs;④ git-wiring.test.ts 补转发断言,涉及真 git 行为的加 real-git 用例。
6旅程 A:看板订阅一个项目的实时 git 状态
这条旅程走通后,你就掌握了整个反应式引擎:订阅怎么把 watcher 拉起来、一次文件保存怎么变成一条 delta、以及资源不够/平台不支持时系统怎么退化而不崩。
trpc/services.ts→ 生命周期 reconcile
git/watch.ts→ 同步投影
worktree-projection.ts→ 原生监听
watcher-factory.ts→ 防抖合并
coalescing-publisher.ts→ 重算(status 服务)→ ephemeral bus 扇出
A.1订阅那一刻:急切注册 + 同步 refresh 的时序契约
watcher 的生命周期完全由订阅者驱动:第一个订阅者来才开监听,最后一个走就拆掉。这个「谁先谁后」有严格时序——refresh 必须在订阅者注册之后跑,才能观察到 hasSubscribers === true 而启动;拆除侧反过来。接线层用了三个容易写错的手法,注释里都自证了:
live: (signal) => {
// eager: registers the subscriber now so hasSubscribers flips true before the refresh below
const iterable = underlying.live(signal)
try {
git?.watcher.refresh(projectId) // 同步启动监听;失败会同步 throw 回订阅路径
} catch (error) {
dropEagerSubscriber(iterable) // 必须先撤掉刚注册的订阅者再重抛,否则 hasSubscribers
throw error // 卡死 true 且无消费者,watcher 永不拆除
}
// a single teardown refresh fired by whichever teardown path settles first; once() keeps it single
const teardownRefresh = once(() => git?.watcher.refresh(projectId))
// EAGERLY arm the abort teardown here, NOT inside the delegating generator's body: an ephemeral
// subscribe opens with a resync marker the consumer commonly reads and then stops on...
const cancelAbortRefresh = scheduleTeardownRefresh(signal, teardownRefresh)
return delegateWithTeardownRefresh(iterable, teardownRefresh, cancelAbortRefresh)
},
这也是 resolveWorktrees 必须是同步函数的原因链:refresh 全程同步,监听挂载失败才能以同步 throw 冒回订阅路径、订阅者才能被同步撤销。生产端能同步是因为 better-sqlite3 内联读——worktree 的三个路径在创建时就物化进了 DB 行,订阅时零 git 进程:
export function resolveProjectWorktrees(db: Db, projectId: string): ManagedWorktree[] {
return db.select(worktreeColumns)
.from(taskReposTable)
.innerJoin(projectReposTable, ...).innerJoin(reposTable, ...).innerJoin(tasksTable, ...)
.where(and(
eq(taskReposTable.projectId, projectId),
isNotNull(taskReposTable.worktreeGitDir), // 已物化
isNull(taskReposTable.deletedAt),
isNull(reposTable.deletedAt),
activeTaskWhere(), // 归档任务不进 watch 目标——归档释放活资源
))
.all()
.flatMap(...)
}
A.2一次保存到一条 delta:过滤、防抖、cancel-in-flight、指纹
每个 worktree 挂 2~3 个原生 watch(工作区递归 + gitDir + 仅 linked worktree 需要的 common dir),各管一半:工作区 watch 整体忽略 gitDir 子树,git 内部写交给 gitDir watch 的白名单(HEAD/index/packed-refs/refs/**/logs/**/五种进行中标记);index.lock 和 watchman cookie 是自激写,无条件免疫,否则 watch→重算→watch 死循环。事件落地后:
const scheduleRecompute = (projectId, watch, debounceMs): void => {
if (!deps.bus.hasSubscribers(projectId)) return // 没人听就什么都不做,闲置即零开销
// cancel-in-flight: a newer event supersedes a recompute still running... aborting here (not when
// the queued re-run starts) is the only point before the prior recompute settles, so the superseded
// run's git child is killed instead of finishing unread
watch.inFlight?.abort()
watch.publisher.schedule(() => recomputeWorktree(watch), debounceMs)
}
防抖是双窗口共用一只发布器:工作区事件 150ms(编辑器保存/checkout 扇出一大波),HEAD/ref 事件 50ms(用户期待分支切换立刻反映);发布器再叠 250ms 最小发布间隔和指纹去重(快照按渲染字段序列化,相同则整个 publish 吞掉)。单飞语义保证同一时刻至多一个重算在跑,期间的 N 个事件折叠成恰好一次排队重跑:
const runCompute = async (): Promise<void> => {
...
try {
const value = await compute()
if (disposed) return
const next = deps.fingerprint(value)
if (next !== lastFingerprint) { // 指纹闸:结果没变就不重发
lastFingerprint = next
lastPublishAt = timers.now()
deps.publish(value)
}
} catch {
// a compute rejection ... must not escape: this runs detached behind a timer, so an unhandled
// rejection here would crash the host process. drop the failed run — the next event reschedules
} finally {
running = false
if (!disposed && rerunQueued) { rerunQueued = false; tryRun() } // 恰好一次排队重跑
}
}
重算完成后有一步重新打标——机制层按路径走,契约层按 handle 说话,这是 1-B 在推送流上的体现(状态流里不出现主机路径):
// abort is cooperative, so a recompute can resolve AFTER a newer event aborted its signal...
// Reject the superseded result rather than returning it: the publisher would otherwise publish this
// stale snapshot and advance its fingerprint/min-gap gates, throttling the fresh re-run.
if (controller.signal.aborted || watch.inFlight !== controller) {
throw new Error('recompute superseded by a newer event')
}
watch.inFlight = null
// re-tag the path-walked snapshot onto the target's key so deltas and the no-op fingerprint name the
// worktree by its stable handle rather than the worktree root the recompute walked
return { ...status, worktree: watch.target.worktree }
A.3降级三护栏,和走读抓出的那个 bug
三种情况让一个 worktree 放弃原生监听、改为 2 秒轮询:全局句柄预算(默认 256)用完;Linux 内核 inotify 配额耗尽(ENOSPC,可恢复——轮询 interval 顺带自愈重试);平台不支持递归 watch(永久——重试永败但无害)。挂载是全有或全无:一个 worktree 的 2~3 个 watch 任一被拒就回滚兄弟句柄,绝不留「半瞎」状态。降级判定按错误码分类:
function errorCode(error: unknown): string | null {
return error instanceof Error && 'code' in error ? String(error.code) : null
}
function isDegradableWatchError(error: unknown): boolean {
return isNoSpaceError(error) || isRecursiveUnsupportedError(error)
// 即 code === 'ENOSPC' 或 code === 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM';其余错误 rethrow
}
bug 在判定的另一端。走读前的 watcher 工厂里有一个平台前置守卫,在 Linux 上先于 fs.watch 触发:
const RECURSIVE_SUPPORTED = process.platform === 'darwin' || process.platform === 'win32'
function openNativeWatch(dir, onEvent): WatcherHandle {
if (!RECURSIVE_SUPPORTED) {
// recursive watching is non-functional here (Linux); the reactor only degrades ENOSPC to
// polling, so this non-ENOSPC throw propagates out of the subscribe path...
throw new Error(`recursive directory watching is unsupported on ${process.platform}`)
// ← 裸 Error,没有 code 字段。降级判定按 code 分类,接不住它 → rethrow → 订阅崩溃。
// 注意这段注释还是「只降级 ENOSPC」的旧叙事——降级修复只改了判定端和文件头 TSDoc,漏改了这里。
}
...
三个事实叠在一起让它一直没被抓到:①所有测试(单测 + wiring 集成测)注入的都是手工构造、带 code 字段的假错误,真工厂全仓零测试触达;②守卫只在 Linux 触发,开发机是 macOS;③ ubuntu CI 的绿也只证明「反应器对带 code 的错误会降级」,不证明「真工厂在 Linux 上抛什么」。而且守卫的前提本身已过时——Node 自 19.1 起在 Linux 支持递归 fs.watch,本仓 runtime 下限是 22.12。修复是删掉守卫,让 Linux 直接开真原生监听:
// Errors are not caught here: fs.watch raises them with `code` set, the field the reactor's
// degrade predicate classifies on.
function openNativeWatch(dir: string, onEvent: (changedPath: string) => void): WatcherHandle {
const watcher: FSWatcher = watch(dir, { recursive: true }, (_event, filename) => {
if (filename === null) return
const changedPath = isAbsolute(filename.toString())
? filename.toString()
: resolve(dir, filename.toString()) // 相对路径归一成绝对路径,反应器的前缀过滤才能比对
onEvent(changedPath)
})
watcher.unref()
return { close: () => watcher.close() }
}
修复后的行为矩阵:现代 Node Linux → 真原生监听;巨型仓耗尽 inotify → ENOSPC(带 code)→ 降级轮询 + 自愈;真不支持递归的平台 → Node 自己抛带 code 的平台错误 → 降级轮询。配套新增 watcher-factory.test.ts 驱动真工厂(预建嵌套目录、写文件、断言事件以绝对路径到达)——它进默认套件,在 ubuntu CI 上就是 Linux 递归 watch 的真机行使,补上了此前缺失的证据环。该 commit 推上后 CI 已全绿。
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| 看板 git 状态列不更新 | watch.ts:先看 isWatching(projectId);再查事件是否被过滤(isStatusRelevantGitDirPath 白名单漏了新哨兵?) |
| 订阅 statusDeltas 直接报错 | watch.ts 的 attachNativeWatches catch:错误码不在降级名单就会 rethrow;liveStatusLog 的同步 throw 路径 |
| 状态更新明显滞后(秒级) | 该 worktree 可能已降级轮询:查 inotify 配额(ENOSPC)、句柄预算 openHandleCount()、poll interval 2s |
| delta 风暴/重复推送 | coalescing-publisher.ts:指纹字段是否漏了新状态字段;fingerprintStatus 与 GitStatusDto 对齐 |
| 状态卡死在旧值 | recomputeWorktree 的 supersede-reject 路径;watch.inFlight 是否被正确清理 |
| 项目删除后订阅挂着不动 | trpc/services.ts project.delete 的 gitStatusBus.closeTopic(id) |
7旅程 B:为任务物化一个 worktree(创建与删除)
这条旅程是 2-A 和 1-B 两个决策的交汇点:客户端只说「给这条 task↔repo 绑定造个 worktree」,磁盘位置、真实 git 目录、库记录全部由 daemon 决定。
packages/api/git.ts→ 编排 + 守卫
use-cases/worktrees.ts→ 两层锁 + git 执行
git/worktree.ts + locks.ts→ 读回真 gitDir
.git 文件→ 落库(2-A)
task_repos 三列
B.1创建:契约里没有 path 字段
/** Validates a worktree-creation request against an existing task↔repo binding; the daemon owns the
* worktree's on-disk location, so the client names the binding to materialize, never a path. */
export const gitCreateWorktreeSchema = z.object({
taskRepoId: z.string(),
branch: z.string(),
baseRef: z.string(),
})
编排层先做三道守卫(绑定不存在→404;task 已归档→拒绝,和其他 task_repos 写操作的归档守卫对称;已物化→worktreeAlreadyExists),然后算落点。布局在 EYRIE_HOME/worktrees/<projectId>/<taskId>/<repoLeaf>——刻意放在所有被管仓库外部,worktree 永远不会被仓库自己的 git status 看见或被 clean 扫掉。repo 显示名是用户可设的,叶子段要消毒防穿越:
async create(input) {
const binding = loadBinding(db, input.taskRepoId) // 四表 join,含守卫用的字段
if (isTaskArchived({ archivedAt: binding.taskArchivedAt }))
throw new AppError({ code: EyrieErrorCode.task.archived })
if (binding.worktreeGitDir !== null)
throw new AppError({ code: EyrieErrorCode.repo.worktreeAlreadyExists })
const path = join(worktreeBaseDir, binding.projectId, binding.taskId, repoLeaf(binding.repoName))
const info = await git.createWorktree({
repo: { gitCommonDir: binding.gitCommonDir }, path,
branch: input.branch, baseRef: input.baseRef,
})
const gitDir = await readLinkedWorktreeGitDir(path) // 关键:不猜,读 git 写的 .git 文件
db.update(taskReposTable).set({
workingDir: path, // 这一写就是 2-A:三列翻转 = 物化
worktreeBranch: input.branch,
worktreeGitDir: gitDir,
updatedAt: now,
}).where(eq(taskReposTable.id, input.taskRepoId)).run()
return info
}
为什么 gitDir 必须读回来而不是按路径猜:git 会对撞名的叶子目录做去重(两个 task 物化同一个 repo 时,管理目录变成 wt 和 wt1)——按 basename 推导的猜测会指错。.git 文件里的 gitdir: <path> 是权威。
git 执行层面,写操作套两层锁:repo 锁(key=gitCommonDir,序列化共享的 refs/objects 写)在外、worktree 锁(key=gitDir,序列化单 worktree 的 index 写)在内,固定锁序免死锁;主 worktree 的 gitDir 就是 common dir,两 key 相等时坍缩成单锁防自锁。锁内再包 *.lock 竞争的有界退避重试([0,100,300,700]ms,判据是 stderr 分类到 indexLocked)。real-git 层的招牌测试用 3 worktree × 24 并发空 commit 证明:不加锁真丢 commit、只留重试网也丢、两层全开一个不丢且零残锁。
B.2删除:探针分类,拿不准就不删
删 worktree 的危险在「rm -rf 错目标」:git 对孤儿 checkout 和从未注册过的普通目录拒绝时用同一句 fatal: ... is not a working tree。机制层(git/worktree.ts)的递进闸门:非 force 被拒一律 typed 上抛(dirty/locked/main 是 git 在保护);force 被拒且措辞不是 stale 句式也上抛;只有 force + stale 措辞才进恢复,且恢复前先做磁盘探针三分类——absent(删除是 no-op)/ worktreeCheckout(.git 是文件且 gitdir 指进 worktrees/,孤儿可删)/ ordinaryDirectory(永不删除,typed 拒绝)。编排层删除成功后把绑定复位:
// clear the worktree record and reset the working dir to the repository's main checkout so the task
// is usable without a worktree
db.update(taskReposTable).set({
workingDir: mainCheckoutDir(target.gitCommonDir), // 剥掉 common dir 尾部 /.git = 主 checkout
worktreeBranch: null,
worktreeGitDir: null,
updatedAt: now,
}).where(eq(taskReposTable.id, input.worktree)).run()
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| 创建报 worktreeAlreadyExists 但盘上没有 | task_repos 行的 worktree_git_dir 残值(半脱附);use-cases/worktrees.ts 的 already-materialized 守卫 |
| 创建成功但状态流不认识它 | worktree-projection.ts:行是否三列齐全(toManagedWorktree 会丢半记录行);task 是否已归档 |
| 删除把不该删的目录删了/该删的删不掉 | git/worktree.ts:classifyRemoveTarget 探针三分类;isStaleWorktreeRefusal 措辞匹配 |
| 并发写丢 commit / 残留 *.lock | git/locks.ts:withWriteLocks 锁序与等键坍缩;runWriteWithRetry 退避表 |
| 删除后 task 没了工作目录 | mainCheckoutDir 对 bare/非常规 common dir 返 null(git/identity.ts) |
8旅程 C:硬删带 worktree 的任务——两阶段删除与终端清扫的交汇
worktree 的路径信息只活在即将被 FK 级联抹掉的 task_repos 行里,行一删路径就没了。所以删除是两阶段:事务内快照要删盘的目标 → 提交 DROP → 事务外 best-effort 删盘。DB 是成功边界,盘是尽力而为。
trpc/services.ts→ 删除事务(快照+DROP)
use-cases/tasks.ts · db/cascade.ts→ 事务外清理
removeWorktreeDisks→ git worktree remove --force(index.ts 注入)
C.1为什么快照必须在事务内:reparent 窗口
删除前有一段事务外的预清理(session 运行时 teardown),这个窗口里用户可能把子 task 拖到别的父级下(reparent)。用窗口前的旧快照删盘,就会删掉一个刚被救走的 task 的活 worktree。解法沿用 main 上已有的「事务内重走子树」纪律,worktree 快照挂进同一个事务:
const dropped = deps.db.transaction((tx) => {
const ids = collectTaskSubtreeIds(tx, id) // 事务内重走子树,以此刻的树为准
const sessionIds = collectSessionIdsUnderTasks(tx, ids)
// snapshot the managed worktrees atomically with the DROP so disk cleanup targets exactly the
// bindings the cascade removes, never a sibling task reparented out during the teardown window
const worktrees = collectManagedWorktreeTargetsUnderTasks(tx, ids)
hardDeleteTaskTree(tx, { ids })
return { sessionIds, worktrees }
})
...
// Detach the dropped tasks' managed worktrees from disk after the authoritative DELETE; best-effort,
// so a worktree git refuses to remove only lingers as prunable disk rather than failing the delete.
await removeWorktreeDisks(deps.removeWorktreeDisk, dropped.worktrees)
收集查询有一个和投影方向相反的取舍:故意不滤 deleted_at——已软删(detach)但两列没清、盘还在的绑定,行马上要被 FK 连带删除,盘也该一并清。另一个防御细节在整形函数里:working_dir 为 null 的半记录行被丢弃,「a half-recorded worktree never produces a removal against an empty path」。
C.2与 #101 终端清扫的合流:一条删除管线,三种副作用
这个 PR rebase 跨过了 main 的 #101(workbench engine),两边在删除路径上各自长了钩子,合并后的 project.delete 是「同一事务收集三样、事务外各自执行」:
const dropped = deps.db.transaction((tx) => {
const taskIds = collectTaskIdsUnderProject(tx, projectId) // main 已有:给终端 pty 清扫
const sessionIds = collectSessionIdsUnderProject(tx, projectId) // main 已有:session 清理
const worktrees = collectManagedWorktreeTargetsUnderProject(tx, projectId) // 本分支新增
hardDeleteProjectTree(tx, { projectId })
return { taskIds, sessionIds, worktrees }
})
const { deletedTaskIds, ...result } = await deps.projectUseCases.delete(id, context)
// Project delete hard-drops every task row; terminal ptys are task-owned but out-of-band, so
// sweep only the tasks that belonged to this project after the authoritative delete settles.
for (const taskId of deletedTaskIds) deps.terminalRegistry.killByTask(taskId) // #101 的钩子
// The project is gone, so both of its ephemeral topics end as a healthy end-of-stream...
boardDeltas.closeTopic(id)
gitStatusBus.closeTopic(id) // 本分支的钩子
删盘能力是可选注入(UseCaseEffects.removeWorktreeDisk):生产在 daemon 入口接到 git 机制并拍了 force: true(脏 worktree 也删,git 拒绝就当可 prune 残盘);测试 harness 不注入即 no-op;每个目标独立吞错,一个顽固 worktree 不拦其余的。归档路径零改动——archive 不碰 task_repos,worktree 留盘是「什么都没写」自然得到的语义;归档态的行为差异全在读侧(投影排除归档 task 的 watch 目标,但按 handle 的单点解析仍可读——留盘就该可读)。
排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
| 删了 task 但磁盘 worktree 还在 | 正常优先怀疑 best-effort 吞错:use-cases/tasks.ts 的 removeWorktreeDisks;git 拒删的原因看 daemon 日志;残盘可 git worktree prune |
| 删 task 误删了别的 task 的 worktree | 不应发生——collectManagedWorktreeTargetsUnderTasks 以事务内子树为准;task-lifecycle.test.ts 的 reparent-window 用例钉着 |
| 归档后 worktree 消失了 | 不应发生——archive 全程不触 task_repos;若消失,查是否有人走了硬删路径 |
| 删除卡住/很慢 | 删盘在事务外逐个串行 await;单个 git worktree remove 超时 60s(git/worktree.ts) |
9旅程 D:一次 diff 读——上限、注入防护、路径 containment
diff 是查询不是搬运:任何单点都不能把 daemon 内存拉爆或把渲染器冻死。这条旅程短,但三个防御点将来排查都会用到。
① 五种比较模式的 argv。vsBase 用三点语法——比的是「这个分支自 merge-base 以来自己的工作」,不含未提交改动、不含 base 侧的分叉;revision 拼进 argv 的位置放不了 -- 分隔符(其后全被当 pathspec),所以前置守卫拒一切 - 开头的输入:
case 'vsBase':
// merge-base(base, HEAD)..HEAD: only this branch's own work, never base's divergent commits
return [`${assertRevision(compare.base)}...HEAD`]
// A leading `-` is the argument-injection vector — git would treat `--output=…`/`-O…` as a flag,
// turning a read-only query into a file write — and is never a legitimate revision
function assertRevision(rev: string): string {
if (rev.startsWith('-')) throw new AppError({ code: EyrieErrorCode.repo.badRevision })
return rev
}
② 四道 size cap,「大」分两种。单文件 2MiB / 单行 5 万字符越线只把那个文件降为 tooLarge(保留 numstat 计数,规模仍可见);累计 128MiB 是 sticky 的——一旦越线,其后每个文本文件一律 tooLarge 且整个结果标 truncated;untracked 数量 cap(1000)防串行 spawn 风暴。另有每 spawn 256MiB 的 OOM 后备,file-list/patch 流被它切断即撕裂流,直接抛 diffTooLarge 绝不解析残片:
// the cumulative cap is sticky: once a prior file crossed it, every later textual file is tooLarge
if (truncated) return { file: tooLargeFile(meta), loadedBytes, truncated: true }
const contentBytes = Buffer.byteLength(section.body, 'utf8')
if (loadedBytes + contentBytes > limits.maxTotalBytes)
return { file: tooLargeFile(meta), loadedBytes, truncated: true }
if (exceedsFileLimits(section, contentBytes, limits))
return { file: tooLargeFile(meta), loadedBytes, truncated } // 单文件超帽:不置全局 truncated
③ worktree 文件读的双查 containment。按相对路径读文件必须锁死在 worktree 根内:词法检查挡 ../,但挡不住「<root>/link 是指向树外的软链」——词法在根内、打开的是树外文件。所以对磁盘上存在的最深前缀做 realpath 后再查一遍:
async function resolveWithinWorktree(worktree: string, path: string): Promise<string> {
const root = resolve(worktree)
const absolutePath = resolve(root, path)
assertLexicallyWithin(root, absolutePath) // 第一道:词法
const realRoot = await realpathOrSelf(root)
const realPath = await realpathOfExistingPrefix(absolutePath)
assertLexicallyWithin(realRoot, realPath) // 第二道:软链解析后再查
return absolutePath
}
已知残留:校验和 open 之间存在竞态窗口(校验后有人把组件换成逃逸软链)。收口裁决判它缓做——利用前提是攻击者已能在 worktree 内写文件,对个人本地产品该前提等于「攻击者就是用户自己」;触发条件(开源/远程形态)到了再用 O_NOFOLLOW/fd 反查收口。此外 untracked 文件因 git diff 天生看不见,用 --no-index 对每个文件单独合成 added section 补齐——exit 1 是数据,exit 128(枚举与 spawn 窗口里文件被删)只跳过该文件。
排查路标 · 旅程 D
| 症状 | 从哪下手 |
|---|---|
| diff 里某文件显示 tooLarge 但不大 | diff.ts:先分清是单文件帽还是 sticky 累计帽(看 result.truncated);限值可经 DiffSizeLimits 覆写 |
| 新建未 add 的文件在 diff 里消失 | readUntracked:数量 cap(1000)截断?ls-files 枚举与 spawn 的 TOCTOU 跳过? |
| 合法 revision 被拒 badRevision | assertRevision 只拒 - 开头;其余是 git 自己拒的,看分类(bad revision/ambiguous) |
| 读文件报 pathInaccessible | resolveWithinWorktree 双查:路径真逃逸了,还是 worktree 根本身是软链(realRoot 比对) |
| 评论锚错位/行号漂移 | diff 全程 pin core.autocrlf=false 和 -M -C(diff.ts 头部);锚定用 blobOid 而非行号 |
10计划 vs 实现的偏差
照计划做成的部分你已经知道了;下面是中途变卦的部分——每条都是一个认知裂缝。从技术方案、run-log、followups、open-decisions 和 21 条 commit message 里挖出,结论自包含。
| 计划原本是 | 实际做成了 | 为什么变 |
|---|---|---|
worktree 归 session(最早基线:worktrees/<repoHash>/<sessionId>) |
三连跳:归 task → 最终连独立表都没有,是 task↔repo 绑定行的物化态(2-A) | 同 task 的多个 session 本就复用工作目录,多数 session 是临时只读验证,per-session 浪费巨仓的漫长 checkout;进绑定行后 FK 级联白送 |
| sparse checkout 是巨仓的一等性能机制 | 明确不做,createWorktree 走全量 checkout | 用户拍板反转:agent 需要完整上下文,残缺工作区编译不过;将来只用不损上下文的手段(partial-clone/fsmonitor)优化 |
| 授权模型 A/B 两案并列(A=对每类原始路径各写规范化+白名单校验) | 收敛到 B(注册表 handle),契约从 RepoRef={gitCommonDir} 改成 {repoId} |
2-A 让 worktree 进了表,B 几乎白送——操作全变查表,原始路径只剩注册入口一处;先做 A 等于给马上被取代的校验写跨切面代码 |
removeWorktree 契约带 {path, force?},从路径反推 repo |
三连跳:上溯最近存在祖先缓解 → 1-B 后彻底改形,repo 从存储行解析,identity 探针整个删掉 | 评审发现 path-only 救不回「主 checkout 外、目录已删」的 sibling worktree;正确形态取决于数据模型,先不仓促定契约的路线被验证正确 |
| removeWorktree 规格:4 步非致命清理,每步吞错继续 | 递进闸门 + 探针三分类;非 force 被拒绝不下删;普通目录永不删 | 裸吞错会在 git 拒绝(脏/锁/主 worktree)后继续 rm -rf,毁掉未提交工作;按 basename 删元数据会撞名,改由 git worktree prune 收 |
runner 设计了 successExitCodes 旋钮(声明哪些非零退出算数据) |
从未被读过,收口时作为死契约删除 | 实现走了更简单的路:一律把 exitCode 当数据返回、成功与否由服务层判断,旋钮失去存在意义 |
| Linux watch:无计划(P0–P8 期间反应器只对 ENOSPC 降级) | 计划外两连修:①降级判定收编平台错误码;②走读抓出工厂守卫抛裸 Error 接不住+前提过时,删守卫让 Linux 真开原生监听 | 2-A 落地让 resolveWorktrees 返回真目标,潜伏 bug 变实时崩溃;第一次修只改了判定端,fake 注入测试探不到真工厂,走读的代码推演才抓到第二半 |
| P3 用「极小字节上限触发截断」当中途 kill 的杠杆 | runner 加正经的 onStdout 逐块回调接缝,条目数越线即 abort |
评审判 hack 不忠实于「流式不全缓冲」纪律,升级为一等接缝 |
绝对路径用 :(top,literal) 魔法前缀防误读;untracked 增删数取自 numstat |
--no-index 拒绝 pathspec 魔法,改用绝对路径;numstat 在 -z 下吐无路径 token 错位,改从 patch 体数行 |
两处都被真实 git 行为推翻——real-git 层的存在价值的直接注脚 |
| #1 within-roots 纵深 = P1「上线真流量前应做」 | 降级 P2,带触发条件缓做(开源前/远程·团队形态/宣传 roots 为安全语义);折中=白名单声明处一行注释把边界写成文档化行为 | 个人本地产品形态下威胁模型不成立(loopback+默认白名单 '/'),1-B 后实现面已坍缩到单咽喉,成本不随时间涨 |
11心智模型补丁
12新词表
| 契约与数据模型 | |
|---|---|
handle / 1-B | 客户端只持有 server 签发的不透明 id,server 拿 id 查库换路径;防客户端指名任意目录 |
RepoRef / WorktreeKey | 两种 handle:前者 {repoId}(repos 表 id),后者裸 string(task_repos 行 id) |
2-A / 物化 worktree | worktree 不建表,是绑定行的落盘形态;判别标志 = worktree_git_dir 非空 |
RepoCommonDir | 机制层内部的仓库标识 {gitCommonDir};handle 在翻译层换成它,机制层以下看不到客户端输入 |
gitCommonDir vs gitDir | 前者=全体 worktree 共享的 git 目录(refs 所在);后者=单个 linked worktree 自己的(commonDir/worktrees/<name>) |
| 反应器 | |
reactor / 反应器 | per-project 的「监听→合并→重算→发布」状态机(watch.ts) |
coalescing publisher | 防抖+限流+指纹去重+单飞的四门发布原语,仓库首个通用合并发布件 |
single-flight / 单飞 | 同一时刻至多一个重算在跑,期间的事件折叠成恰好一次排队重跑 |
cancel-in-flight | 新事件立即 abort 在途重算(杀 git 子进程)并拒收其迟到结果 |
degrade to polling / 自愈 | 预算超限/ENOSPC/平台不支持时放弃原生监听改 2s 轮询;轮询 interval 顺带重试原生监听,恢复后自动拆 interval |
自激路径 | 重算自己引起的写(index.lock、watchman cookie),必须免疫否则死循环 |
| runner 与解析 | |
两阶段杀 / 硬 deadline | SIGTERM→宽限→SIGKILL(对进程组播);后代死握 stdio 管道时定时器强制 settle |
matcher 表 / 操作数提取 | stderr 文案→稳定错误码的行锚定正则表;顺带挖出结构化数据(冲突文件清单)进 wire |
porcelain v2 / -z | git status 的机器可读格式;NUL 分隔记录,路径不转义 |
可续传解析器 | 分多次喂字节、跨调用记住半条记录的解析器,配合流式中途 kill 结算有界前缀 |
sticky truncation | diff 累计字节帽的语义:一旦越线,后续文本文件一律 tooLarge,标志不回落 |
blob-oid 可寻址读 | 按对象 id 读历史内容——review 评论锚定 oid,钉死评论当时的确切字节 |
hasBidi / Trojan source | 行/文件级标记:内容藏了能静默重排渲染顺序的双向控制字符,只警示不改内容 |
| 测试 | |
real-git tier | *.real-git.test.ts 后缀的独立 vitest project,对真 git 二进制验证;不进默认套件和 CI |
dormantize / 休眠化 | fixture 仓库把 .git 改名 _git 休眠,物化时复制+改回唤醒;身份日期钉死使 oid 跨机稳定 |
unreachable proxy | 任何属性访问都 throw 的占位依赖——没接线的依赖被摸到就炸,不静默 undefined |
两层写锁 | repo 锁(gitCommonDir)外、worktree 锁(gitDir)内的 keyed mutex;等键坍缩防自锁 |
13测试与风险地图
有兜底的(行为被测试钉住)
- runner 全谱:env 三族剥离、--no-verify 白名单与插入位、字节帽保留前缀、两阶段杀、硬 deadline、cap-kill 后 cause 升级的竞态(runner.test.ts,573 行,fake child 驱动)
- 解析层:porcelain 逐字节切割等价断言(整条流每个 offset 切一刀都与一次性解析全等)、diff 双流 zip、错误分类的消毒与 enum 完整性(每个 repo.* 码必须有 matcher 或豁免)
- 反应器 18 例:事件过滤白名单、双窗口、cancel-in-flight 两方向、三护栏各自的降级与自愈、订阅生命周期的调用时刻采样断言(observedAtRefresh)
- 真 git 层 41 例:两层锁的并发控制组(不加锁必丢 commit ×8 次试验聚合)、错误 matcher 全部活二进制驱动、worktree 安全护栏四连(脏/锁/主/普通目录的拒删)、diff 形状与 blob-oid oracle 比对
- 删除路径:reparent-window 用例(事务内快照,别人的 worktree 不被误删)、归档零删盘、投影三态(活跃物化/裸绑定/归档)
- 接线层:10 个 procedure 转发、merge 占位的 typed 拒绝、statusDeltas 五个生命周期用例、恶意 repo 名困在 base 目录内
- 本次走读补上的:真工厂 fs.watch 测试(ubuntu CI = Linux 递归 watch 真机行使)
薄冰(重要但无兜底/已知遗留)
- 🔴→✅watcher 工厂平台守卫 bug——走读期间已修(823adf8,CI 绿)。教训条目:「CI 绿」证明的是被测路径,fake 注入型测试的覆盖边界要显式盘点
- 🟠real-git 层无 CI 挂载点:verify 和 workflow 都不含 test:real-git,git 版本漂移的探测依赖有人手动跑。matcher 漂移咬过两次,值得列为下一个 CI 增强项
- 🟠key/path 分离无测试防线:watch 测试夹具的 WorktreeKey 都等于路径('/repo'),re-tag 被删测试也全过;key≠path 只在生产接线成立
- 🟡apps/cli/tests/serve.test.ts 并发负载下偶发超时(60s 线,单独跑 15s)——本 session 两次全量 verify 各撞到一次,就是此前「未定位偶发」的真身;考虑给它加针对性 timeout
- 🟡dubious-ownership 的 matcher 由硬编码 stderr 驱动(foreign-owner 仓库无法可移植摆出),该文案漂移不会变红
- 🟡project.delete 的 worktree 删盘分支无直接测试(task 侧有,project 侧靠共享查询形状背书)
- 🟡removeWorktree 探针
classifyRemoveTarget的真实文件系统行为无测试(恢复路径注入的是桩)——防误删的最后闸门本体未被驱动 - ⚪merge 占位用 internal.serverError,客户端无法区分「未实现」与真故障(Phase-2 实装时消失)
- ⚪迁移 journal 的 when 时间戳乱序(0001 早于 0000);drizzle 按 idx 应用,无行为影响
- ⚪TOCTOU(#3)与 within-roots(#1):触发条件式缓做,裁决全文在 git-module-followups.md 收口段
823adf8 的 ubuntu CI 已全绿(verify + commit 语言门 + PR 语言门),分支 22 commit 处于可 squash-merge 状态。合并后的头号跟进:①给 real-git 层找个 CI 挂载点(如 nightly 或 label 触发);②serve.test.ts 的超时余量。
14验收提示:别被这些吓到
- git.merge 永远抛 500——有意的 Phase-2 占位,typed 拒绝优于假 MergeResult,且有接线测试锁着这个行为。
- packages/api/src/git.js——2 行运行时 shim,仓库既有惯例(main 上已有 14 个同类),不是误提交的编译产物。
- desktop 零生产改动——那 +39 行全是测试 mock 补齐 Services 新形状;UI 本期完全没接 git 能力,git 状态列是后续工作。
- 0001_snapshot.json 的 2,286 行——drizzle 生成物,抽查与 schema 一致即可。
- git/identity.ts「新增」——git 检测为 rename(相似度 70%),主体是从 services/git-identity.ts 搬运;真实改动是 runner 契约反转(异常→数据)和新增 mainCheckoutDir。
- WorktreeInfo.path 输出带绝对路径——有意的:1-B 禁的是客户端发路径(输入面),server 向客户端展示路径(输出面)是 UI 显示需要。
- resolveWorktreeTarget 不滤归档 task——有意的:归档留盘,盘在就该可读可清理;只有 watch 投影(活资源)排除归档。
- facts 解析器的 branch/upstream/baseRef 三字段当前恒为 null——status 走查自己提供这三样,字段是解析层的完整六元组契约,消费方目前只用三路径。
15覆盖声明
本报告基于对 99c04e1...823adf8(22 commit,80 文件,+15,330/−112)的全量精读:7 个子系统精读 agent(地基层/服务层/反应器/契约接线/数据模型与删除/测试层/杂项兜底)覆盖每一行 diff,外加一个文档矿工读完 6 份随附文档与全部 commit message。报告中出现的每一段代码,均由主笔在 agent 素材之外亲自读过所在文件后亲手裁剪(watch.ts / coalescing-publisher.ts / runner.ts / worktree-projection.ts / use-cases/worktrees.ts / use-cases/tasks.ts / use-cases/projects.ts / db/cascade.ts / trpc/services.ts / diff.ts / error-classify.ts / identity.ts / watcher-factory.ts 修复前后 / packages/api/git.ts / filesystem-roots.ts / eslint.config.js / check-boundaries.mjs)。生成物(迁移 snapshot)做了抽查核对而非逐行读。走读期间发现的 watcher 工厂 bug 经主笔对代码路径的独立推演确认后修复并推送,tip 的 CI 已全绿——本报告描述的是修复后的状态。