隔离工作树 PR 流程
从临时 git 工作树(从 origin 的默认分支切出)提交更改作为 Pull Request 的过程,当主检出不能被触碰时——因为它处于脏状态(未提交的 WIP)、落后于远程,或与其他代理共享。该流程涵盖工作树创建、新工作树依赖项间隙、有界限的 CI 轮询以及合并模式感知的清理保护。
为什么要工作树隔离
当多个代理会话进入同一个工作目录时(例如通过批量会话启动),git 的 HEAD、当前分支和未提交的更改成为共享状态——每个会话都会改变其它会话的基础。在一个已测量的某次事件中,在一个会话使用 git switch -c 创建分支的一分钟内,第二个会话在同一检出中从该分支派生、提交并合并到默认分支。
检测模式(在几分钟内确认冲突):
- 迹象:出现了你未编写的文件,或
git status不再匹配你的会话启动时所做的快照。 - 时间戳检查:
ls --time-style=full-iso(或你平台的等效命令)。多个文件共享相同的毫秒数意味着 git 操作(checkout / merge / stash)写了它们,而不是手动或 LLM 顺序编辑。 git reflog --date=iso确定时间线:谁 checkout、提交或合并,以及何时。- 如果你的代理环境提供了会话列表功能,请使用它来查找具有相同工作目录的其他正在运行的会话。
确认冲突后让步规则:
- 先提交的一方继续担任写入者。后来者停止写入:继续提交或切换分支会移动另一个会话的 HEAD 并破坏双方。
- 后来者改为作为验证者增加价值:对抗性审查写入者的输出(文档声明 vs 代码、重新运行测试、发布前检查)以及不接触任何文件的仅聊天可交付物。
- 只清理你自己创建的中间引用(例如一个空分支);永远不要触碰其他会话使用过的引用。
- 追加式日志对于两个会话都是安全的,可以在不同的章节标题下写入。
预防:一个仓库 = 一个写入会话。当多个代理必须在同一个仓库上工作时,将每个代理隔离在其自己的工作树中——这正是此技能所做的。最便宜的检测习惯是,在你开始工作后立即重新将 git status 与会话启动快照进行比较。
可移植性
该流程在 Windows(PowerShell 加上 Git Bash)上进行了强化,但 git 和 gh 命令本身是跨平台的。在 shell 不同的地方,给出了 PowerShell 和 POSIX 示例。CI 包括了用于在 Ubuntu 24.04 和原生 macOS 15 以及 Windows 主机上进行合成清理保护和私有标记扫描器的 PowerShell 7 作业。这三个作业在 PR #7 run 30216166105 中全部通过。特定于 Windows 的部分——目录交接点及其删除语义——在行内注明了 POSIX 符号链接等效项。
何时使用
- 主检出是脏的(未提交的 WIP)、落后于远程,或与其他代理共享,并且直接在其中切分支可能会破坏该 WIP。
- 即使对于小的维护修复:如果现有检出落后许多提交并带有脏的 WIP,隔离的工作树是更安全的途径(经过现场测试)。
- 假定采用每个任务一个分支的工作流(永远不要直接提交到默认分支)。此技能仅涵盖该分支所在位置的机制:在脏检出之外的一个临时工作树。
步骤
-
非破坏性地检查状态。使用
-C <repo>运行每个git命令,并使用--repo <owner>/<name>运行每个gh命令,以便不依赖当前目录——代理环境可能在调用之间重置工作目录,如果没有--repo,当在仓库外部调用时,gh会失败并显示“不是 git 仓库”。git -C <repo> status --short # 记录工作前的 WIP(结束时验证未更改) git -C <repo> branch --show-current git -C <repo> fetch origin gh repo view <owner>/<name> --json defaultBranchRef -q .defaultBranchRef.name # 在下面使用结果作为 <default>相同的命令在 POSIX shell 中无需修改即可工作。
-
从
origin的默认分支创建一个临时工作树,而不触碰脏检出。替换来自步骤 1 的<default>——不要硬编码main;默认是master的仓库仍然很常见。git -C <repo> worktree add -b fix/<task> ..\_worktrees\<task> origin/<default>POSIX:
git -C <repo> worktree add -b fix/<task> ../_worktrees/<task> origin/<default>将工作树放在确认可写的地方。系统级临时目录有时无法从沙盒化的代理环境中写入(经过现场测试)。优先选择仓库旁边的
_worktrees/目录或你环境的指定临时目录,并且在运行任何繁重操作之前,写入一个探测文件并删除它以确认写访问权限。 -
保持差异最小。在工作树内,只编辑(或复制进)任务所需的文件。不要将主检出的未跟踪 WIP 全部带过来——上游或其他代理可能添加同名文件,多余的文件会在合并时导致添加/添加冲突或污染 PR 差异。如果你必须从基础检出包含一个未提交的依赖文件的副本,请在 PR 正文中说明合并时哪一方获胜(现场教训:明确地这样做避免了一个糟糕的自动解决)。
-
处理新工作树依赖项间隙。一个新的工作树没有
node_modules也没有 virtualenv(经过现场测试)。-
Node: 在工作树中运行
npm ci(需要一个 lockfile)。如果仅用于轻量验证,临时链接到主检出的node_modules也可以——但在清理之前移除该链接(安全条件 5)。PowerShell 交接点:New-Item -ItemType Junction -Path <worktree>\node_modules -Target <repo>\node_modules # 删除必须只删除链接,永远不删除目标:使用 rmdir。 cmd /c rmdir "<worktree>\node_modules"POSIX 符号链接:
ln -s <repo>/node_modules <worktree>/node_modules # 使用简单 rm 删除链接本身(没有 -r,没有尾部斜杠)。 rm <worktree>/node_modules -
Python: 重用主仓库的 virtualenv 解释器并将其指向工作树的源,仅运行目标测试。PowerShell:
$env:PYTHONPATH = "<worktree>\src" & <repo>\.venv\Scripts\python.exe -m pytest <worktree>\tests\<target_test>.pyPOSIX:
PYTHONPATH=<worktree>/src <repo>/.venv/bin/python -m pytest <worktree>/tests/<target_test>.py
-
-
当验证通过时:提交、推送并创建 PR。将提交消息写入一个文件并使用
git commit -F <msgfile>传递它——内联多行消息在 shell 引用下很脆弱,尤其是在 Windows 上。git -C <worktree> add <target files only> git -C <worktree> commit -F <msgfile> git -C <worktree> push -u origin fix/<task> gh pr create --repo <owner>/<name> --head fix/<task> ... -
使用有界限的轮询等待分支保护 CI。
gh pr checks --watch是无界等待——代理框架和审批层可能会拒绝或挂起它(经过现场测试)。使用一次性的检查并带有尝试次数上限:gh pr checks <pr-number> --repo <owner>/<name> --watch=false gh pr view <pr-number> --repo <owner>/<name> --json state,statusCheckRollup有界循环,POSIX。如果框架阻止了前台
sleep(Claude Code 会这样),将循环作为后台任务运行,或者跳过循环并在其他工作之间交织一次性检查:for i in $(seq 1 10); do gh pr checks <pr-number> --repo <owner>/<name> --watch=false && break; sleep 30; done有界循环,PowerShell:
for ($i = 1; $i -le 10; $i++) { gh pr checks <pr-number> --repo <owner>/<name> --watch=false if ($LASTEXITCODE -eq 0) { break } Start-Sleep -Seconds 30 }正确的上限和间隔取决于仓库的 CI 持续时间(通常未经验证——按仓库调优)。
-
合并——并且首先决定合并模式,因为它决定了清理前未合并工作验证(安全条件 2):
--merge(合并提交):分支提交成为默认分支的祖先,因此使用merge-base --is-ancestor验证并使用branch -d删除(安全条件 2a;经过现场测试)。--squash/--rebase:分支提交永远不会成为默认分支的祖先,因此在该分支上的--is-ancestor永远退出 1,即使在完全成功合并之后。改为使用“MERGED + headRefOid 匹配”保护(安全条件 2b)。
gh pr merge <pr-number> --repo <owner>/<name> --merge # squash 策略仓库:--squash,然后使用保护 2b gh pr view <pr-number> --repo <owner>/<name> --json state,mergedAt,mergeCommit关于
gh pr merge的已知行为(经过现场测试):- 如果本地默认分支在另一个工作树中被检出,从仓库内部运行的
gh pr merge --delete-branch可能在本地后处理期间失败,即使远程合并成功。使用--repo从仓库外部运行合并会完全跳过本地后处理,从一开始就避免了那类失败。 - 如果仍然报告失败,首先检查
gh pr view --json state——远程端通常已经是MERGED。如果是这样,在下面的安全条件之后手动完成剩余的本地步骤(分支删除、工作树移除)。 - 从仓库内部运行的
gh pr merge可能会快进当前工作树到默认分支;在其输出中看到来自较早 PR 的大量更新并非异常。
安全条件
在任何破坏性步骤(工作树移除、分支删除、prune)之前,在执行时检查以下所有项——在删除之前运行验证,而不是之后。跳过删除前检查和“之后验证”会破坏保护:一个未合并工作的错误只会在分支已经消失时才会显现(现场教训)。在共享检出中,状态可能在步骤被批准和运行时之间漂移;如果时间已经过去,在执行前立即重新运行检查(经过现场测试)。
-
gh pr view <pr-number> --repo <owner>/<name> --json state,mergedAt报告MERGED。 -
匹配合并模式的未合并工作验证。不要混合模式:在 squash 之后,保护 2a 对于正确合并的 PR 也永远不会通过;并且 squash 之后的
branch -d要么通过合并到上游逻辑滑过(如果origin/fix/...仍然存在——这不是祖先保证),要么永远被拒绝为“未完全合并”(如果上游已消失)。- 2a —
--merge(合并提交)模式:git -C <repo> merge-base --is-ancestor fix/<task> origin/<default>退出 0(在 PowerShell 中检查$LASTEXITCODE,在 POSIX shell 中检查echo $?)。使用-d删除分支,这样 git 本身仍然拒绝是否有任何东西未合并(经过现场测试)。注意:-d根据分支的上游(如果已设置)来判断“是否合并”,否则根据 HEAD——因此在远程分支被删除和修剪后,并且检出的 HEAD 位于旧的、不相关的分支上时,-d可能拒绝并显示not fully merged,即使 PR 已正确合并(经过现场测试)。在这种情况下,只有在--is-ancestor检查退出 0 之后立即使用-D删除才是可接受的——与 2b 中相同的保护-然后--D形状;没有保护的-D仍然被禁止。 - 2b —
--squash/--rebase模式:通过gh pr view <pr-number> --repo <owner>/<name> --json state,mergeCommit,headRefOid获取state、mergeCommit和headRefOid。注意mergeCommit是一个 JSON 对象——提交哈希是其oid字段(-q .mergeCommit.oid提取它);headRefOid是一个普通字符串。然后确认两者:(i)git -C <repo> merge-base --is-ancestor <mergeCommit-oid> origin/<default>退出 0,并且 (ii)git -C <repo> rev-parse fix/<task>等于headRefOid。检查 (ii) 是 squash 模式对“没有留下提交”的替代:它证明合并的 PR 头正好是你的本地分支顶端。仅当两者都成立时,才使用显式的git branch -D删除(此保护替代了-d)。 保护 2b 的拓扑和拒绝路径由scripts/test-cleanup-guards.ps1中的一次性合成 Git 历史覆盖。该测试隔离了系统/全局 Git 配置、钩子、签名和rebase.updateRefs;快照、清除并恢复每个环境变量GIT_*,以便仓库/跟踪路径无法逃脱夹具;并通过操作系统感知的路径比较和重解析点拒绝,将递归清理限制在其生成的直接临时子目录中。实时的 GitHub squash 路径于 2026-07-23 使用 PR #2 测量:MERGED、已落地的mergeCommit、匹配headRefOid的本地和远程顶端、受保护的本地-D以及显式的远程删除全部通过。实时的 GitHub rebase 路径于 2026-07-26 使用 PR #5 测量:MERGED、一个重写的已落地mergeCommit、默认分支祖先之外的原始头、匹配headRefOid的本地和远程顶端、受保护的本地-D、显式的远程删除以及拥有的工作树清理全部通过。
- 2a —
-
删除远程分支(无论合并模式如何;如果在合并时
--delete-branch未能执行此操作,或者你使用了 2b 的 guard-then--D路径并且远程分支仍然存在)。PowerShell:git -C <repo> push origin --delete fix/<task> # 或 gh api 等效项。POSIX:
git -C <repo> push origin --delete fix/<task>可接受的替代方案 GitHub CLI:
gh api repos/<owner>/<name>/git/refs/heads/fix/<task> -X DELETE -
使用
git -C <repo> worktree remove <worktree_path>移除工作树。这不是rm -rf:git 验证没有未提交的更改,并且有多个额外的安全检查。如果由于缺少 unix 域套接字路径(Windows)或 reparse 点(如果工作树包含像 node_modules 这样的目录交接点)而失败,请在worktree remove之前检查、报告,并且如果不存在 GIT 相关文件,则可以删除。在任何情况下都不要在工作树之外使用rm -rf:在移除之前验证工作树路径是预期的、唯一的,并且不匹配 或任何其他已知路径。为什么不能使用
rm -rf:如果一个代理对工作树路径的假设是错误的(例如,由于路径转义或假设由先前代理放置的工作树位置),直接删除可能会删除仓库本身。git worktree remove是唯一安全的路径,因为它应用 git 的内部验证,拒绝破坏仓库的HEAD或索引(安全条件 4)。 -
如果创建了到主检出
node_modules或其他目录的链接,请在工作树自身内移除该链接(在 git 工作树移除之前)。使用rmdir(Windows 上的交接点)或rm(POSIX 上的符号链接)以确保只删除链接本身,而不是目标(安全条件 5;经过现场测试)。如果工作树包含一个虚拟的desktop.ini或其他仅限 UI 的隐藏文件,这些文件不影响 git 并且可以在工作树清理之前删除。 -
运行
git -C <repo> worktree prune以清理git worktree list中的数据。这对于确保将来的git worktree add使用相同的父级路径没有冲突是必要的。 -
验证没有留下任何引用。
git -C <repo> branch -a | Select-String 'fix/<task>' # 应该没有输出 git -C <repo> worktree list | Select-String '<task>' # 应该没有输出 if (Test-Path <worktree_path>) { Write-Warning '工作树目录仍然存在' }POSIX:
git -C <repo> branch -a | grep 'fix/<task>' git -C <repo> worktree list | grep '<task>' [ -d <worktree_path> ] && echo '工作树目录仍然存在' >&2
在拼接步骤 1-7 之后,PR 已合并,并且本地和远程分支及工作树都被清理了。PR 的 gh pr view <number> --json state,mergedAt 仍然返回 MERGED;该记录是永久的且是只读的。
合并后人工检查
对于人工和 LLM 代理:
- PR 差异是否反映了目标变更?如果工作树从脏检出复制了文件,PR 可能包含无关或意外的更改。
- 如果在步骤 4 中链接了
node_modules,请验证链接在工作树移除之前被移除。如果工作树是从脏检出创建的,并且包含主仓库的 WIP 副本,则 PR 可能包含不应提交的更改。 - 删除上游引用的显式 git push(步骤 3)是 GitHub 上标准 PR 清理的一部分;
gh pr merge --delete-branch已经这样做了,但如果你从仓库外部运行合并,这可能是必要的(参见已知行为)。 - 最终视觉检查
gh pr view <pr-number> --repo <owner>/<name> --json state,mergedAt,mergeCommit以确认请求已关闭且已合并。
主检出清理(可选)
合并后,原始(脏)检出的 WIP 仍然存在并且是脏的。如果该 WIP 不再相关,删除它是一个 git 命令(处理 f...)。