mcpField
G
git-safety-net

git-safety-net

github.com/daymade开发1.5k298 次安装今天更新
代码仓库

关于 git-safety-net

用于防止 Git 多分支和工作树操作造成工作丢失,并在问题发生后辅助取证恢复的技能。它按风险选择审计、恢复、合并验证或安全清理流程,强调先保全、再执行获准的更改。

主要功能

  1. 01审计分支、工作树、暂存和悬空提交
  2. 02通过 reflog 与 fsck 查找丢失提交
  3. 03在清理前保全可能丢失的工作
  4. 04通过试合并验证分支内容是否已合并
  5. 05按授权范围执行安全清理

适用场景

  1. 01排查分支、提交或暂存是否丢失
  2. 02清理旧分支或工作树前核实风险
  3. 03判断分支内容是否已进入主分支
  4. 04整理并行开发后的未提交工作

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/daymade/claude-code-skills --skill git-safety-net

安装后在 Claude Code 中输入/git-safety-net确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

agents/
openai.yaml
evals/
evals.json
references/
merge_verification.md
prevention_practices.md
recovery_playbook.md
scripts/
git_align_checkout.sh
git_classify_refs.sh
git_export_before_drop.sh
git_find_all_checkouts.sh
git_hosted_vs_cached.sh
git_loss_audit.sh
git_prepare_clone_retirement.sh
git_preserve_danglers.sh
git_verify_branch_merged.sh
tests/
test_git_align_checkout.py
test_git_classify_refs.py
test_git_export_before_drop.py
test_git_find_all_checkouts.py
test_git_hosted_vs_cached.py
test_git_loss_audit.py
test_git_prepare_clone_retirement.py
test_git_preserve_danglers.py
test_git_verify_branch_merged_options.py
test_git_verify_branch_merged.py

详细介绍

Git 安全网

防止工作在分支、暂存区和变基的纠缠中丢失;如果已经出了问题,则通过取证方式恢复。 除非某一步明确标记为破坏性操作,否则这里的命令都不会造成破坏或只会增加内容——恢复绝不能让损失变得更严重。

结果约定——让安全网服务于用户的任务

在执行 Mode B/E 或任何会写入引用或备份的命令之前,先在对话中说明以下内容 (不要创建另一个文件):

  • 结果: 用用户自己的措辞描述用户可见的最终状态。
  • 当前阶段: 现在获准执行的操作。“之后”要做的工作在此阶段尚未获准。
  • 获准目标: 此阶段可以检查的具名对象,以及其中可以更改的对象。
  • 停止条件: 结束任务时可观察到的事实。

然后严格遵守以下边界:

  • 证据范围不等于操作范围。 只读审计可能发现另一个克隆、引用、仓库或悬空对象。此发现可以扩大报告范围,但不会授权保留、上传、合并、删除或以其他方式更改新发现的对象。
  • 授权针对具体对象,不会默认为整个仓库授权。 “接手”“继续”或“完成清理”仅适用于用户指定的检出项/引用/PR。之后发现的、由协作者持有的工作树、分支或 PR 仍只能报告,除非用户明确将其指定为更改目标。如果用户要求不要动它,则记录此排除项,不要检查其工作树内容、备份、合并、解锁/移除它,也不要修改其引用。
  • 仅保留下一项获准的破坏性操作会威胁到的最小集合。 如果当前阶段仅涉及提交/推送/验证,且未获准执行删除、重置、gc、历史重写或移除工作树,则不要创建包含所有引用的 bundle,也不要固定所有悬空对象。
  • 先对工件进行分类,再选择传输方式。 持久项目源文件遵循仓库的常规 Git/LFS 策略。临时恢复工件(bundle、工作树 diff、快照、传输分块)应放在仓库外部的备份目录中。不要仅仅为了让备份可远程访问,就将其暂存、提交、推送或通过 Git LFS 传输;Git LFS 用于持久的、经过版本控制的项目二进制文件,不是临时恢复材料的备用传输方式。
  • 将保留和清理分为不同阶段。 已验证的恢复工件只能证明可恢复性,不能证明业务代码已落地。在破坏性操作和内容级后置条件验证完成前,保留该工件。只有当每个载荷都已落地或已证明被替代,并且该工件本身已获得明确清理授权时,才清理精确的、由当前任务创建的临时工件。仅用于备份、尚未解决或混合内容的工件应予以保留。
  • 将新的存储或执行界面视为范围变更。 如果既定结果确实依赖第二个仓库、新远程、云端上传、Git LFS 或完整历史导出,则需要重新规划并获得明确授权。不要解决用户未要求产生的传输问题。
  • 在用户所处的环境中证明任务完成。 远程端包含预期提交、WIP 已保留,以及分支/工作树处于用户要求的状态,这些才是结果。bundle 数量、校验和、上传回执和审计范围都只是辅助证据,不能替代结果。约定满足后即停止;将无关发现单独记录,不要据此采取行动。

如果交接说明写着“不要上传”,在以此为由暂缓其他已获授权的提交或推送之前,先核实原限制所针对的对象、操作和目标位置。 使用收敛授权检查;助手自行添加的限制只是查找线索,不是新的用户决定。

入口路由——根据用户担心的问题选择模式

用户表示/需要……转到
本地快照被 index.lock 阻止,或提交钩子拒绝操作本地快照受阻 — 保留锁和暂存状态;在获准恢复之前,先诊断这两个层面
“我觉得丢了一个提交/分支/暂存”, “恢复已删除的 X”, “git reflog”模式 A——恢复
在混乱的会话后问“我丢了什么吗?”,“还剩哪些工作树/暂存/分支?”模式 B——审计并保留
在删除旧分支前问“所有内容都合并了吗?”,“还有什么没在 main 上?”模式 C——验证是否已合并
“别再让这种事发生”,开始并行/多分支工作模式 D——预防
“清理工作树/暂存/分支”,“把所有内容汇总到 main”,“只保留一个 main 分支”模式 E——安全退役
“审计已经说没问题了,但还有其他东西丢了吗?”,“再检查一遍”模式 B,从第 0 步开始——重复请求通常表示首次检查的范围不对,而不是检查得不仔细

不确定时,运行能选定模式的最小只读探测。仅当目标是全面审计丢失内容,或目标检出位置未知时,才使用模式 B 第 0 步的全机器范围发现流程。针对指定仓库/分支/工作树的任务,应始终限定在指定目标;除非用户扩大授权范围,否则目标之外的发现仅作报告。

核心规则(牢记这些;各模式都会应用这些规则)

  1. 在相信任何结论之前,先确认 EVIDENCE SCOPE,且不要悄悄扩大工作范围:这里的每种工具只能看到它运行所在的仓库。 git worktree list、 git branch -a、git fsck、git stash list、 git log --not --remotes——它们在结构上都无法看到机器其他位置的独立克隆。关联工作树(git worktree add)有一个指向主仓库的 gitlink 文件,因此会被发现;第二个 git clone 有自己的完整 .git,且没有反向引用,因此任何地方都看不到它。只有在进行全面审计或目标未知时,才先运行 git_find_all_checkouts.sh;否则审计指定目标。真实事故:一个仓库经审计确认干净、所有分支都已推送,但一个工作中的功能有 440 行内容作为未跟踪文件留在旁边的克隆中,只需执行一次 rm -rf 就会消失。 范围还有第二个维度:时间。 每个 origin/* 引用都是你上次 fetch 时的缓存快照,而非远程端本身——因此,在相信任何依赖该引用的结论之前,先运行 git fetch --all --prune。按正确方向解读过期缓存:对于“会丢失什么”,它偏向安全(可能多报未推送的工作,但绝不会漏报),这也是这里的脚本仍可离线运行的原因。对于“这是否已经在上游?”则恰恰相反——远程端已有的工作会被误判为独有,于是你会重复推送;如果远程端期间已有改进,你的“恢复”会在看似救援的同时悄悄撤销这些改进。真实事故:一天前的比较基准让一个已合并的更改看起来尚未推送;救援 PR 本会撤销后来一次审查中新增的三项修复,其中一项是安全修复。 范围还有第三个维度:引用集合本身会变化。 分支清单和已验证的 bundle 证明的是某一时刻存在的内容;它们并未授权你五分钟后删除内容。删除前,立即重新枚举本地引用和代码托管服务上的分支,然后确认每个目标引用仍与 bundle 中记录的对象一致。新分支、移动过的 tip 或新的并行 PR 都会重新触发分类流程,并要求创建新的 bundle。不要依据过期清单执行删除。 范围还有第四个维度:所有权。 仓库可见性并不意味着每个可见对象都属于这项任务。采取行动前,将发现的引用、工作树和 PR 划分为授权更改、仅供检查和明确排除三类;根据授权集合计算清理是否成功。
  2. 在一个检出中进行权威的“会丢失什么”检查时,运行 git_loss_audit.sh。 它会将当前 HEAD、每个关联工作树的 HEAD、本地分支、标签,以及所有其他持有提交的本地引用与每个远程端进行比较,然后检查每个工作树中的已跟踪/未跟踪更改,以及 stash、仅存在于 reflog 的提交和悬空提交。仅存在于 reflog 的工作包括每个关联工作树的 HEAD 日志;虽然它只作提示,但会阻止得出零风险结论。内容扫描失败或 Git 诊断信息会导致审计不完整(退出码为 1),绝不能据此认定结果干净。非提交引用会列出,供手动审查对象是否需要保留;它们会抑制零风险结论,但不会触发提交风险退出码。较短的 git log HEAD --branches --tags --not --remotes 会漏掉其他工作树中的分离 HEAD、仅由非分支/标签引用固定的提交,以及所有未提交文件。超前/落后计数无法回答这个问题。在指定的检出中运行;只有在每个额外的 Step 0 检出都明确获得更改授权后,才在其中运行。只有当此仓库级脚本枚举的每个范围——关联工作树、本地引用/标签、reflog、stash 和悬空提交——都在声明的证据范围内时,才运行该脚本。它没有排除选项。否则,将结论限制在获授权的检出/引用范围内,并使用其自身的 status、HEAD、上游/远程身份以及 git log HEAD --not --remotes 作为范围内的证据;报告其他范围未经审计。
  3. “我丢了一个提交”时,第一步应运行 git reflog,而非 fsck。 Reflog 会记录约 90 天内每个 HEAD 位置(提交、切换检出、重置、变基),丢失的提交通常就在最前面的几行中。git fsck 则是更深入的手段,用于查找 reflog 无法触及的提交。
  4. 清理前先保留,并确认哪种备份工具确实能访问这些工作。 删除分支、运行 gc 或强制推送之前,先将有风险/悬空的提交固定到垃圾回收无法触及的位置。只有在仍有引用(或仍处于 reflog 保留期)指向这些工作时,清理才可逆。关键的不对称性:bundle、archive 和 format-patch 只能访问 Git 已知的对象。 从未执行过 git add、也从未使用 stash -u 的未跟踪文件,对这些格式不可见——磁盘上的副本就是唯一副本,因此要保留它就必须确实将文件复制出去。备份“仓库”后误以为未跟踪的工作也一并备份,正是导致备份看似完整、却悄悄漏掉唯一风险对象的原因。如果备份是将分支推送到已存在的其他仓库,而非推送到此仓库自己的远程端,那么推送前请验证共享历史(Mode B Step 2)——名称相似并不能证明任何事情。
  5. 依据内容验证“已合并”,绝不要依据提交数量;还要知道大多数内容检查也不可靠。 Squash merge 之后,main..branch 会将分支的原始提交显示为“未合并”,即使其内容已在 main 上——通常会多出 100 多个虚假的未合并提交。但用最接近的内容检查替代计数仍不够:一次审计中,连续三种“这下肯定是基于内容了”的检查都给出了错误答案——git cherry(squash 会改写 patch-id → 错误地判定为 UNMERGED)、用于询问“base 缺少什么”的三点式 diff base...ref(三点式回答的是另一个问题,而且少报了 5 倍的缺失文件),以及文件级存在性检查(文件出现在 base 上,并不代表 ref 的代码行也都存在)。只有试合并(git merge-tree,即 git_verify_branch_merged.sh 所运行的内容)每次都给出了正确结果。不同 diff 形式及其逐级可靠性:references/merge_verification.md。
  6. 如果要在高风险情境下全面判断“一切是否都已合并”,并据此授权删除,请进行对抗式验证。 默认由一位独立审查者执行。只有在不同仓库或证据维度无法由一次审查覆盖,且用户已授权扩大审查范围时,才使用多位审查者。安排一次审查尝试证伪声明的证据范围(规则 1),但任何新发现的目标都必须依据 Outcome contract 仅作报告。

模式 A — 恢复丢失的工作

“消失”的提交、分支或贮藏几乎总会在对象存储中保留约 90 天—— 为什么会这样,以及什么情况会结束这种状态,详见 references/recovery_playbook.md § 心智模型:直到运行 gc,任何内容都不会消失。那里的步骤按症状编排,因此直接跳到对应步骤即可—— § 步骤 1 — git reflog(大多数恢复到此结束), § 步骤 2 — 已丢弃的贮藏, § 步骤 3 — 分离 HEAD 上的工作, § 步骤 4 — 对真正的孤儿运行 git fsck。30 秒速览:

git reflog --date=iso | head -40          # find the lost HEAD position (most recoveries are here)
git show <sha>                            # CONFIRM it's the right commit before acting
git switch -c rescue/<name> <sha>         # recover onto a NEW branch — never reset onto live work

如果 reflog 中没有显示,按症状继续查找,而不是先尝试 fsck:已丢弃的贮藏有专门的恢复途径(references/recovery_playbook.md § 步骤 2),在分离 HEAD 上放弃的工作有另一种途径(§ 步骤 3),只有真正的孤儿——例如由变基产生的孤儿——才需要运行 git fsck --dangling(§ 步骤 4)。

模式 B — 审计风险,然后予以保留

步骤 0 — 确定证据范围(规则 1)。 “有风险”涵盖哪些内容,以及哪些检出类型会将其隐藏,详见 references/recovery_playbook.md § 权威的“是否存在风险”检查和 § 链接工作树与分离的工作树 HEAD。 只有当结果约定要求进行穷尽式审计,或目标检出未知时,才运行全机器范围的检出发现。对于已指定的目标,记录该检出并继续执行步骤 1,不要把无关的克隆也纳入工作范围。 需要进行穷尽式发现时,查找此计算机上此仓库的所有检出,包括任何仓库内命令都无法看到的独立克隆:

scripts/git_find_all_checkouts.sh              # defaults to this repo's parent + grandparent
DEPTH=6 scripts/git_find_all_checkouts.sh ~    # widen when clones live far from each other

它按规范化的远程 URL 匹配同级检出(因此,同一仓库的 SSH 和 HTTPS 形式会被视为相同),如果当前检出或候选检出没有 origin,则改为匹配任意共享的提交历史。此历史检查适用于无法看到仓库真实根目录的浅克隆。它绝不会按目录名匹配,因为独立克隆通常与原始克隆的目录名不同(repo 与 repo-hotfix),这恰恰是按名称匹配会失败的情况。它会在识别当前检出之前规范化路径别名,检查候选项时会禁用仓库提供的 fsmonitor 命令,并且即使分支没有上游,也会将任何本地已知远程跟踪引用可达的提交视为已推送。只要任何其他检出中存在未提交、未跟踪、未推送或无法检查的工作,退出码就为 1。 对于仅供检查的检出,请在发现阶段停止:步骤 1 会获取并更改其远程跟踪引用。只有在授权修改该检出后才运行步骤 1;步骤 2 只应用于已授权的项目。“没有风险”的声明仅涵盖实际审计过的检出。

维护者验证

更改检出发现逻辑后,运行隔离的回归测试套件:

uv run python -m unittest discover -s tests -p 'test_*.py'

步骤 1 — 审计(非破坏性)。 当前是否存在任何有丢失风险的内容:

当脚本枚举的每个工作树、引用、标签、reflog、贮藏和悬空对象都在声明的证据范围内时:

scripts/git_loss_audit.sh          # defaults to remote "origin"; pass a remote name to override

当上述任何一项不在范围内时,跳过该脚本,只收集限定到检出和引用范围的证据:

git status --porcelain=v1 --untracked-files=all
git rev-parse HEAD
git log --oneline HEAD --not --remotes
git ls-remote --exit-code <remote> refs/heads/<branch>   # 0 present · 2 absent · 128 probe failed

此处的 --exit-code 和完整限定的 refs/heads/ 前缀都至关重要,并非风格选择:裸用 git ls-remote <remote> <branch> 时,远程不可达和引用不存在会以完全相同的方式报告(两种情况都不打印任何内容),而裸分支名称也会匹配同名标签。故障排除 § “‘那个分支是不是被删除了?’探查显示它仍然存在”中对这两个方向的陷阱都做了测量。 如需完整审计,预期输出应列出每个工作树的分支/分离状态和干净程度, 以及以下项目的数量: 仅本地提交、脏状态/无法检查的工作树、储藏、仅存在于 reflog 的提交和 悬空提交,并列出非提交引用以供人工审查。 如果有提交不存在于任何远程、工作树处于脏状态/无法检查,或内容扫描不完整,则退出码为 1。储藏、仅存在于 reflog 的提交/悬空提交和非提交引用仅供参考,但会阻止得出零风险结论。因此,退出码为 0 不代表可以删除报告中的工作:请逐一审查或保留所有报告项。除非已确认指定工作树干净,且其 HEAD 已证明包含在其他历史中或已明确保留,否则不要声称可以安全清理。 限定路径只能证明已授权的检出/引用的情况;它不能说明被排除的工作树、其他本地引用、reflog、储藏或悬空对象的情况,这些必须继续列为未审计。

步骤 2 — 仅保留下一项已授权的破坏性操作会威胁到的内容(仅增量、 可防 gc)。 仅发现问题不代表需要备份。如果删除、gc 或重写历史可能导致已报告的提交无法访问,请在执行操作前保留该确切提交。只有当所有已报告的悬空对象都确实属于已授权目标集时,才使用整个集合的辅助工具:

对于仅存在于 reflog 的工作,请使用关联的保留流程中针对目标的固定方法;此整个集合的辅助工具不会枚举仅存在于 reflog 的提交。

scripts/git_preserve_danglers.sh --patch-dir ~/git-danglers   # pin + export patches

有关固定引用为何能经受 gc,以及当整个集合不在范围内时使用的针对单个引用的方法,请参阅: references/recovery_playbook.md § 保留:固定已授权的悬空对象,使 gc 永远无法清理它们。

此操作会在 refs/dangling-backup/<sha> 下固定每个悬空提交(垃圾回收永远无法触及有引用的提交),不会让 git branch 变得杂乱,并且可选择为每个非储藏提交写入一个 .patch 文件。对于某个特定的重要提交,还要完整保留:本地分支以及已推送的远程分支以及一个 git format-patch 文件,这样单个磁盘故障或单次 git gc 都无法将其丢失。详情及三重备份的原因:references/recovery_playbook.md § 为关键提交创建三重备份。

将保留分支推送到已有仓库之前,先验证共享历史——名称相似不能证明仓库正确。 获取候选仓库的默认分支(git fetch <candidate-url> <default-branch>),然后运行 git merge-base <that-default-branch> <ref>;如果本地 merge-base 以 1 退出,或者托管服务自带的比较视图显示没有共同祖先,就表示两者没有共享历史,目标仓库是另一个项目。先将分支推送到此工作的远程仓库;如果没有远程仓库,则创建新仓库;如果两者都不适用或正确的存放位置不明确,请询问用户——但绝不能仅因名称匹配就照常推送。只要正确的存放位置不明显,就始终用 merge-base 检查进行验证:只有已与此工作共享历史的仓库,才能托管该分支而不变成另一个项目。真实事故:某部署源的备份分支仅因名称相似,就被推送到了一个无关的私有仓库。如果分支已推送,而后续回读发现没有共同祖先,就应将其视为放错位置:将其内容迁移到正确位置,再从错误仓库中删除,而不是因为“已经在某处备份”就将其留在那里。

未跟踪文件需要使用不同工具——直接复制(规则 4)。 将 <backup> 放在目标仓库和所有即将退役的检出之外。以上所有操作迁移的都是 git 对象;git 从未获知的文件并不属于其中。请明确保留这些文件,并将不同渠道分开,以便后续阅读者知道每个渠道可以恢复什么:

git -C <checkout> status --porcelain | grep '^??'                     # what is untracked
cp <each-untracked-path> <backup>/                                    # the ONLY copy — plain cp
git -C <checkout> diff > <backup>/uncommitted.diff                    # tracked-but-uncommitted
git -C <checkout> bundle create <backup>/history.bundle origin/main..HEAD   # unpushed commits
git bundle verify <backup>/history.bundle                             # prove it restores

在它们旁边写一段 README,说明它们的来源、所属分支,以及会话何时停止。六周后没人能看懂的备份,只比没有备份好一点点——而阅读它的人并不是制作备份的人。

模式 C — 验证所有内容都已合并(别被计数误导)

陷阱:一个过时的分支显示“比 main 超前 173 个提交”,但其中每一行都已在 main 上(压缩合并造成的假象)——相关机制见 references/merge_verification.md § 为什么提交计数会说谎。 绝不要根据计数断定“未合并”。逐分支内容检查(操作步骤和输出解读: § 逐分支判定流程):

scripts/git_verify_branch_merged.sh <branch> [<base>]   # base defaults to origin/main

这种模式是唯一一种过时基准不安全的情况(规则 1):如果依据的是昨天的 origin/main,一个几小时前已合入内容的分支仍会显示为 UNMERGED,而“抢救”它会把旧版本重新应用到其上构建的内容之上。正因如此,脚本会先执行 fetch。由于 fetch 会移动远程跟踪引用,只有在现有协调已让所有检出目录写入操作静止,并转交独占所有权后才能运行。如果无法做到这一点,就保持只读,并报告合并判定不可用。如果转交所有权后 fetch 本身失败,脚本会回退到缓存的引用,并且仅在 stderr 上说明这一点。 将这条说明视为阻断事项,而不是脚注:等网络恢复后重新运行,再依据判定采取行动。手动比较(git diff origin/main <branch>、git log origin/main..<branch>)完全没有这样的安全保障——唯一的写入者必须先刷新权威状态;而双点和三点语法回答的是不同问题(references/merge_verification.md § 根据所问问题选择 diff 形式)。 可能帮助人工判断、但绝不能用于自动判定的信号,在 § 仅供人工调查的提示 中单独列出。

它会报告 MERGED (ancestor) 或 MERGED (content contained)——对于另行授权的模式 E 删除门槛而言,内容安全;也会报告 UNMERGED / NEEDS REVIEW,并列出该分支仍会更改的文件。这个判定可靠,而非启发式:它会使用 git merge-tree 对分支合入基准进行一次试运行式三方合并(在内存中完成,不检出文件),仅当合并结果没有任何变化时才报告内容已包含。因此,即使提交计数非零,压缩合并的分支也会显示为 MERGED;而基准中缺少的还原操作、编辑或新文件则会显示为 UNMERGED。它偏重安全:凡是无法证明已包含的内容,都会标记为待审核,因为误报“已合并”会丢失工作,而误报“未合并”只会多花一点时间检查 (references/merge_verification.md § 为什么偏重安全)。试运行式合并为何可靠而非启发式,以及为何 --find-object/blob 比较不可靠:见 § 可靠的内容检查。压缩合并之后,如果基准再次更改了相同的行(而非任意后续编辑——无关文件或无关行的更改仍能保证针对当前基准的试运行式合并可靠),导致合并发生冲突而非重现基准树,则可改用 --merge-commit <sha>,在该历史合并提交处证明内容已包含——这是一个不同且范围更窄的问题,不同于“基准现在是否已包含该内容”(§ 历史合并提交这一层)。对于整个仓库中所有分支相对于同一个冻结基准的判定,scripts/git_classify_refs.sh --base <sha> [--pr-map <json>] 会在一次离线运行中执行同一套判定流程——包括通过 --pr-map 使用合并提交判定层——而不是逐个分支调用。对于整个仓库的分支,只读并行分发模式——每批一个 Agent,要求每个 Agent 证伪“所有内容都已合并”这一说法,并对每条发现单独复核——见 § 对抗式多 Agent 验证,以及 § 验证 Agent 必须遵守的规则 中列出的限制。

模式 D — 防止灾难

避免分支纠缠导致工作停滞的习惯: references/prevention_practices.md。以下每个要点都标有其完整说明的 § 名称——请根据你的情况查看对应部分,无需通读整个文件。 最关键的几项:

  • 在规定拓扑前,先阅读当前协作约定。 用户或项目对共享检出、工作树、分支或贡献流程作出的明确决定,优先于这些通用指导。不要因为一次混乱的审计,就把「每个会话一个工作树」变成永久规则。 (§ 根据当前权威决定拓扑。)
  • 一个物理检出只能有一个写入者;并行 Agent 和会话必须保持只读。 在同一检出中使用主题分支,并不能隔离共享工作文件、当前分支或索引。写入者归属由仓库的任务/协调约定决定,而不是靠猜测文件列表来判断。如果写入者归属不明确,或另一个写入者正在活动,不要修改检出。 (§ 共享检出与并发会话:一个写入者——也适用于下方两条「并行会话」要点;现也涵盖通过进程证据而非猜测名称来查找身份不明的写入者。)
  • 切换前先提交,并尽早推送 WIP。 优先使用有远程备份的提交,而不是反复使用 stash;但须保留更高权威的狭义 stash 例外;绝不要使用未指定范围的 stash,让脏检出看起来像是已就绪。 (§ 并行/多分支工作;§ 尽早推送进行中的分支。)
  • 工作树是明确授权的命名例外,不是常规默认做法。 工作树会隔离工作文件、HEAD 和索引,但仍共享 refs、stash、对象存储、配置和 hooks,也不会复制被忽略的依赖项。获批时,链接工作树比隐形的独立克隆更安全,但仍须作为单独的审计清理目标。 (§ 退役前审计每个获批的工作树。)
  • 按确切提交进行交接和合并,最后通过 AND 门槛。 记录分支、本地 HEAD 和最新远程尖端;要求它们与交接 SHA 一致。直接合并时指定该 SHA,而不是分支。托管合并应使用预期的 head SHA 前置条件(如果可用);否则,须在合并前立即读取托管端 head,并确认它仍与交接 SHA 一致。每个由会话产生的字节都必须包含在该远程备份提交中,并须逐一列出并说明所有残留路径的归属。
  • 进程快照不是锁,合并也不代表获得清理权限。 任何可以写入此检出的调度器,即使其路径不重叠,也算写入者。在执行 Git 修改前,项目现有的协调机制必须证明其已静止并转移独占所有权;没有该机制时,应保持只读并报告这一缺口。不要依据此通用 Skill 停止、重新配置或凭空创建自动化租约。只有通过单独获批的 Mode E 证据,才能退役 refs 或检出。(§ 已知的自动写入者不属于会话所有的 WIP。)
  • 提交前确认当前分支(git branch --show-current)——提交到错误的功能分支会导致修复在真正的 PR 中不可见,而且很容易在清理时丢失。 (§ 每次提交前确认分支——包括为什么从错误分支移除内容要等到 Mode E。)
  • 绝不要通过依赖当前检出的修改与另一个写入者发生竞争。 如果另一个写入者处于活动状态,应停止,直到仓库的协调系统转移独占写入权。转移后,修复或推进状态时要指定确切的 ref 和对象;不要依赖当时恰好检出的分支。reset --hard、merge 和 rebase 都会作用于命令运行瞬间检出的分支。在符合获批结果时,使用不依赖检出的 ref 修复方式:
    git branch -f <branch> <target>          # instead of: switch <branch> && reset --hard <target>
    git fetch origin <branch>:<branch>       # fast-forward a branch you are not on
    git push origin <sha>:refs/heads/<branch>
    
    真实事件:另一个会话仍拥有检出时,有人执行了 reset --hard origin/main,结果命令作用于该会话的功能分支,并使其回退了两个提交。正确的第一步是停止并转移所有权;转移后,明确指定目标 ref 的修复方式可避免让检出位置成为操作的一部分。
  • 如果某个并行会话此前切换了共享树,导致你的未提交工作搁置, 在该会话静止且独占所有权已转移之前,不要修改任何内容。之后按照§「并行会话切换共享树后恢复搁置的工作」中的仅限事故处理流程:证明你的文件在不同基线上内容一致,只提交明确指定的路径,并在交还所有权前恢复原分支。删除分支仍是单独获批的 Mode E 操作。
  • 相反的情况:另一个会话的提交落到了你的分支上,而你已运行的所有检查仍然通过。 在共享检出中,HEAD 停留在你的分支时,其他会话提交的内容会成为你分支的父提交,并随你的 PR 一起发布。git branch --show-current 显示的是你的分支,工作树是干净的,git diff --cached --name-status 只显示你自己的路径——这些都是真的,但都无法发现问题,因为对方提交时,其工作就已离开索引。只有在该分支相对于你创建分支时所依据基线的累计提交范围中,才能看到它。检测是只读操作,因此每次推送前以及打开任何 PR 前都要运行:
    base=<the base SHA you recorded when you created the branch>
    git rev-parse --verify "$base^{commit}"   # must print a SHA — see below before trusting the rest
    git log  --oneline "$base"..HEAD     # every commit here must be yours
    git diff --name-only "$base" HEAD    # every path here must be yours
    
    验证命令这一行至关重要。 如果 $base 为空,"$base"..HEAD 就会变成 HEAD..HEAD:退出码为 0,且没有输出,无法与「没有外来提交」区分开来。守卫机制正是为这一静默情况而设;§「落到你分支上的外来提交」已包含此检测,并测量了更大范围。并且,Git 无法推断哪些提交「属于你」:在共享检出中,两个会话使用相同的 author 和 committer,因此没有任何标记能将它们区分开来。归属只能依据你提交时记录的 SHA。如果你说不清哪些提交属于自己,就停下并寻求帮助——修复操作会删除一个提交,在此猜测正是这项技能要防止的损失。 创建分支时记录基线 SHA——之后再推导会读取缓存的远程 ref,而用于刷新它的 fetch 本身也受所有权限制。§「落到你分支上的外来提交」。此范围内出现外来提交,就证明另一个写入者曾在此检出中操作,因此你无权发起修复。 停下;上述所有权规则仍然适用。所有权转移后,修复属于对你自己的分支进行历史重写——git rebase --onto 会检出它要重写的分支——因此必须遵循现有流程,不可走捷径:适用的 Mode B 证据流程,然后执行 git branch backup/pre-rewrite <your-branch>(任何历史重写前先创建快照——这能让 rebase 可逆),接着执行 git branch rescue/foreign-<short-sha> <foreign-sha>(这会保留对方的工作,是一项单独义务,也使用不同的 ref),然后执行 git rebase --onto "<foreign-sha>^" "<foreign-sha>" <your-branch>——基于外来提交的父提交进行变基,绝不能基于基线,因为 --onto "$base" 会丢弃外来提交之前的所有内容,包括你更早的提交,而且退出码为 0。然后重新运行上述检测:rebase 的退出码无法告诉你它是否顺带移除了你的内容。之后退役任一 ref 都需要达到 Mode C/E 删除级别的证据,不能靠猜测。完整流程以及为什么两个显而易见的「对方的工作是否保留下来?」探测会得出错误答案,见 references/prevention_practices.md 中的**「落到你分支上的外来提交」**。真实事件:一个同级会话在 HEAD 停留于刚创建的分支时提交了内容;PR 因此包含了该会话尚在进行的工作,而唯一的信号是仓库验证器报告有两个组件发生变更,但作者实际只改动了一个。
  • 如果并行会话正在主动写入共享树,必须停止所有仓库修改。 不要执行 switch、add、reset,不要使用临时索引创建提交、更新 refs 或推送。使用仓库的协调系统让该写入者静止并转移独占所有权;如果没有此类系统,应报告缺口并保留当前证据。成为唯一写入者后,可以仅操作对象存储来创建提交,将可归属的外来 WIP 排除在共享索引和工作树之外。将每个候选项冻结为确切的 Git 条目元组 (mode, object ID, path)——仅有字节内容并不够,因为 100755、120000 和 160000 分别携带可执行文件、符号链接和 gitlink 行为。最安全的来源是不可变候选提交:
    candidate_ref=<immutable-candidate-commit-oid>
    candidate_path=path/to/file
    candidate_entry=$(git ls-tree "$candidate_ref" -- "$candidate_path")
    candidate_mode=$(printf '%s\n' "$candidate_entry" | awk 'NR == 1 { print $1 }')
    candidate_oid=$(printf '%s\n' "$candidate_entry" | awk 'NR == 1 { print $3 }')
    test -n "$candidate_mode" && test -n "$candidate_oid" || exit 1
    
    candidate_index=$(mktemp /tmp/git_safety_candidate_index.XXXXXX)
    export GIT_INDEX_FILE="$candidate_index"   # the tree's real index is untouched
    git read-tree origin/main           # start from the pushed base, not the dirty tree
    git update-index --add --cacheinfo "$candidate_mode,$candidate_oid,$candidate_path"
    tree=$(git write-tree)
    commit=$(git commit-tree "$tree" -p origin/main -m "…")   # HEAD does not move
    unset GIT_INDEX_FILE
    rm "$candidate_index"
    git push origin "$commit":refs/heads/<branch>             # open the PR from here
    
    对于尚未包含在不可变提交中的自有临时常规文件,应明确推导其预期模式(可执行时为 100755,否则为 100644),并计算其字节内容的哈希;如果此路径涉及符号链接或子模块,则必须失败而不是继续。对于这些条目类型,应先冻结一个不可变候选提交,再如上复制其 mode/object 元组。绝不要从另一个会话正在编辑的共享路径中获取条目。该流程只会读取和写入对象存储以及一个临时索引,因此共享树中的 git status 会逐字节保持不变。这是仅限唯一写入者使用的保留工作手段,并不代表允许在他人拥有仓库时进行修改。commit-tree 不会运行常规 git commit 的 hook 流程:推送前,应对候选提交执行仓库确切的 pre-commit/安全检查;同时仍须让 pre-push 运行。只有在所有权转移之后,且需要保留的外来 WIP 使切换检出或在共享索引中暂存变得不合适时,才可使用此方法。
  • 裸 git commit 会快照整个索引,而不只是你暂存的内容;绕过索引的提交也会在索引中留下陷阱。 如果移动当前分支却不更新共享索引,就会使 HEAD 前进而索引仍停留在旧基线上——无论是在该分支上通过 commit-tree 和 update-ref 操作,还是通过临时 GIT_INDEX_FILE 执行 git commit。(上述唯一写入者推送到另一分支的流程不会移动任何本地 ref,因此不会造成偏差。)新提交引入的每个文件随后都会显示为已暂存删除(git status 会打印 D 行,以及相匹配的 ?? 未跟踪条目)。git commit -- <path> 既不会创建也不会修复这种偏差——它只会更新自身指定的路径。偏差会在任何人下一次执行裸 git commit 时引爆:该提交会快照整个索引,让虚假的删除变成真实删除——已交付的文件会从 HEAD 消失,而工作树看起来毫无变化。真实事件:通过临时索引提交后,有一个已交付的 24 文件目录处于这种状态;并行会话只要执行一次裸提交,就会将其从分支尖端删除,而唯一的迹象就是 git status 中的 D 行。因此有两项义务。推进分支使其超出索引状态的人必须立即重新同步索引——运行 git diff --cached --name-status,然后执行 git restore --staged -- <该提交触及的路径>,直到这些路径不再出现在差异中(并行会话暂存的条目属于该会话,不应由你清除)。在共享树上执行任何裸提交前,都要将同一份差异视为影响范围——包括 D 行在内的每个条目都必须是你有意处理的内容;任何无法识别的条目都意味着应停下,而不是提交。 (§ 提交范围卫生。)
  • 任何 rebase 或删除分支前,都要运行适用的 Mode B 证据流程。 仅当完整损失审计所列出的每个工作树/ref/标签/reflog/stash/悬空对象都属于证据范围时,才运行完整审计;否则应对获批的检出/ref 执行范围受限检查,并据此限制安全声明。
  • **提升共享版本/锁文件的版本前,先检查基线中的当前值,**以免两个并行分支都声称进行了相同的版本提升(这种静默冲突会阻止后续变更发布)。 (§ 并行分支间的版本/锁文件冲突。)

模式 E — 安全清理工作树、储藏和分支

与模式 A 相反,这里担心的不是“我丢失了什么”,而是“这些遗留项越积越多——哪些可以删掉?”删除很简单;逐项证明其已被取代才是关键。 从结果约定开始。对于全面审计或未知目标,运行检出发现;对于一个指定的工作树或分支,则留在其所属仓库中。仅当 git_loss_audit.sh 所枚举的所有工作树、引用、标签、引用日志、储藏和悬空对象都在证据范围内时,才运行该脚本;仅供检查的对象只能作为报告内容处理,将明确排除的协作者资源同时排除在清理计划和最终计数之外,然后只清理指定目标。如果任何枚举到的对象不在范围内,请勿运行完整的损失审计或 --all-refs 导出;改用限定到检出或引用范围的检查,以及针对性导出:

步骤 1 — 对每项遗留内容分类:仍在进行的工作,还是已被取代的草稿? 证据阶梯,按证明力从强到弱排列:

  1. 最新权威依据加试合并 — 刷新基准和确切的分支顶端,然后运行 scripts/git_verify_branch_merged.sh。祖先关系或内容已包含的结论足以作为删除依据。如果返回 NEEDS REVIEW,继续沿阶梯向下检查;不要用较弱的启发式方法把不确定性判成 MERGED。对于同一个已冻结基准下的多个遗留项,scripts/git_classify_refs.sh 会在一次离线操作中,对每个分支运行这一层检查。
  2. git cherry <base> <branch> 只是提示,不是结论。 - 证明某个补丁 ID 已在上游;+ 并不能证明工作缺失,因为压缩合并会刻意产生新的补丁 ID。切勿仅凭此输出恢复或删除整个分支。
  3. 同一文件的取代检查 — 对于储藏,或触及后来在基准上重新修改过文件的 + 提交:提取其中的文件版本,并与基准上的当前版本比较(git show <ref>:<path> | wc -l 对比 git show <base>:<path> | wc -l,然后抽样比较差异)。如果基准版本是超集(包含遗留项的全部内容,外加后续工作),那么该遗留项就是已被取代的草稿。真实案例:一个标记为“未完成开发”的储藏包含一个 1128 行的渲染器;main 的版本有 1151 行——包含相同的函数,外加一个后续新增的功能参数。恢复该储藏会导致回归,而不是恢复。
  4. 函数/标记级探查 — 在基准中搜索遗留项的独特新增内容(def new_helper、某个常量或错误字符串)。如果都已存在于基准中 → 已被取代。这可以识别“被吸收到重构中”的情况,避免第 2 层因文件结构变化过大而无法判断。

任何无法证明已被取代的内容都应保留(与模式 C 的安全倾向相同:误判为“已被取代”会丢失工作;误判为“仍在进行”只会多留一个分支名)。有一点会影响判断:遗留项的标签不是证据——名为“未完成开发”的储藏也可能是已完整合入的早期草稿;应根据当前基准判断内容,切勿根据名称判断。各层检查的示例(包括压缩合并产生的伪差异和被吸收到重构中的情况),请参见: references/merge_verification.md § 取代情况分类。

步骤 2 — 获得删除授权后,在删除前立即准确保留该删除可能威胁到的内容:

# Targeted branch cleanup: prefer the narrow export.
scripts/git_export_before_drop.sh --branch <branch> --out <external-backup-dir>
# Pin only an authorized dangling SHA; leave unrelated danglers report-only.
git update-ref refs/dangling-backup/<sha> <sha>
# Full ref topology: only when every captured ref is explicitly authorized.
scripts/git_export_before_drop.sh --all-refs --out <external-backup-dir>
scripts/git_export_before_drop.sh --verify-current <external-backup-dir>/all-refs.bundle

目标 update-ref 只会触及已获授权的悬空提交。如果所有报告的悬空提交都在范围内,则可以用完整集合版 git_preserve_danglers.sh 替代。对于已命名分支/工作树的退役,优先使用重复的 --branch 选项。--all-refs 会捕获分支、标签、暂存区、隐藏的备份引用以及关联工作树的 HEAD 引用,因此只有在整个捕获集合都获授权时才有效;只有在暂存区也是删除目标时才添加 --all-stashes。--verify-current 是最终的比较并交换关卡:如果任何记录的引用发生移动或消失,它就会以状态码 1 退出。执行前刷新远程授权,出现任何不匹配时都重新生成 bundle。将备份保存在仓库之外;绝不要把单个分支变成仓库导出。

对于多分支“只保留一个 main”的清理,如果其他会话仍可能提交或打开 PR,请在步骤 3 之前阅读 references/merge_verification.md § 通过单写入者窗口将多个分支收敛到一个 main。当另一个写入者处于活动状态时,该流程仅允许只读操作:获取、创建对象/引用、导出 bundle、推送/PR 和删除,都必须等待现有协调机制确认已静止,并通过最终回读移交独占所有权。该参考文档还补充了移动引用清单、脏 WIP 保护、不可变候选项、重复 PR 和最终分支数量等关卡;单分支退役不需要这些关卡。

步骤 3 — 按安全顺序销毁:

  • 储藏:按索引从高到低删除(先执行 drop stash@{2},再执行 stash@{1})——删除时索引会移动,从上往下处理才能确保每个编号都与备份文件名所表示的内容一致。
  • 链接工作树:必须先确认 git -C <path> status --porcelain=v1 --untracked-files=all 为空,然后单独盘点忽略的路径,使用 --ignored。普通的干净状态会隐藏 !! 文件,且没有任何 bundle 能保留这些文件;将无法证明可重现的内容复制出来,保留其相对路径,并根据记录的移除前内容哈希进行验证。 记录精确的 HEAD;通过新获取的基准分支证明该提交已包含其中或已被其取代;将其分支或经冲突检查的恢复引用导出到经过验证的定向 bundle 中;并取得当前会话的删除授权。作为移除前的最后一道关卡,重新检查状态是否为空,并再次完整盘点忽略的内容。要求每个忽略路径、条目类型、文件哈希和符号链接目标都与冻结的移除前清单完全一致,并重新验证每个保留的源文件和备份副本;只要有任何差异,就中止操作。接下来必须立即执行移除操作。先尝试不带强制选项的 git worktree remove <absolute-path>。只有在干净且已完整保留的子模块工作树因子模块本身而拒绝移除时,才可按所引用的 § Worktree retirement 流程使用受控的单个 --force 分支;若状态有改动、未知、已锁定、正被占用或备份不完整,仍必须停止移除。之后,证明路径和注册记录均已消失,同时记录的 HEAD 仍可通过保留的分支/基准分支或已验证的 bundle 找到,并且每个已复制的忽略项仍与记录的哈希一致。绝不移除主检出/当前检出;只有作为单独且另行授权的操作,才能退役其分支。请遵循 references/merge_verification.md § Worktree retirement。
  • 本地分支:优先使用 git branch -d(若尚未合并则会拒绝);只有对 Step 1 已证明被取代、已备份且用户授权删除的项目,才使用 -D。-d 拒绝删除内容已完全合并的分支,通常是因为发生了 squash 合并:-d 根据提交祖先关系判断,而 squash 会用一个新的 SHA 提交替换该分支的提交,因此祖先关系被打破,尽管每一行内容都已合入。这并不意味着可以不加判断地使用 -D——而是要回退到 Step 1 的内容检查(git cherry、超集差异),并且只有在证明内容已包含后才能使用 -D。-d 判断“已合并”时,参照的是检出状态的当前 HEAD(或该分支配置的上游分支),而不是你心里想的基准分支——若检出位于一个过时分支上,-d 会拒绝删除一个实际上确为预期基准分支祖先的分支;这看起来与上面的 squash 情况完全相同,但解决办法不同:不要使用 -D,而应通过 git merge-base --is-ancestor <tip> <base> 直接对照预期基准分支确认祖先关系,并正确切换或指定目标分支。只有在重新核实确切的远程端及仓库可见性/所有权后,才能删除远程分支。
  • 使用 -D 之前,还要检查一个包含关系和祖先关系都无法涵盖的事项:是否有未关闭的 PR 将此分支作为头分支。 分支即使没有任何独有内容,仍可能是未关闭 PR 的头分支;删除它也会一并删除该 PR 的头分支——refs/pull/<N>/head 通常仍可提供相关对象,但这只是碰巧能恢复,并非有意设计的保障(恢复阶梯详见 references/recovery_playbook.md § Ladder step 4)。在 GitHub 上,务必按 PR 编号确认,而不是按分支名称确认:对于一个已有拉取请求的分支,gh pr list --head <branch> 曾返回空结果,而“基于这个空结果作出的分支删除决定会是错误的”(已有实测记录,见 references/merge_verification.md § The historical-merge-commit rung 中的“On GitHub, do that confirmation by PR number”)。使用 gh pr view <number> --json headRefOid,state,mergeCommit,或等效的 REST 请求 gh api repos/<owner>/<repo>/pulls/<number>;若尚不知道编号,REST 搜索仍优于 pr list: gh api "repos/<owner>/<repo>/pulls?state=all&head=<owner>:<branch>"。对于非 GitHub 远程端,使用该平台自己的 PR 查询功能——按分支名称搜索不能替代此操作。务必在删除操作之前立即进行查询,绝不能依赖更早的盘点结果:引用可能会移动,而“你盘点后新建的 PR”正是此检查要捕捉的情况。实测于 2026-09-22——一位同事将“该分支对应一个已关闭的 PR,可以安全删除”转述到调度提示词中,而执行 Agent 在删除前自行复查时发现,该分支是一个新建 PR 的头分支。
  • 删除远程分支后,通过退出码读取结果,不要依赖输出。 有两种情况会误导判断,且方向相反。若仓库配置为合并时删除分支,那么该分支在合并时就已被删除;之后执行 git push <remote> --delete <branch> 会以 1 退出,并报告 remote ref does not exist——这正是你想要的最终状态,却被报告为错误。另一个看似显而易见的回查方式也有问题:git ls-remote <remote> <branch> 没有输出,并不能证明分支已删除,因为无法连接的远程端也会没有输出。使用 git ls-remote --exit-code <remote> refs/heads/<branch> 并读取退出码——0 表示存在,2 表示不存在,128 表示探测本身失败;若为 128,则分支的去向未知,不得据此退役任何内容。这两类陷阱的正反情况都已在 Troubleshooting § “A 'did that branch get deleted?' probe says it still exists” 中实测,该节还说明了为何必须使用完全限定的引用。
  • 独立克隆在退役前需要完整的引用准备。 干净的工作树并不能说明克隆专属引用、reflog 历史、忽略的内容、储藏、钩子/配置,或由 git clone --shared 创建的 objects/info/alternates 依赖情况。运行 scripts/git_prepare_clone_retirement.sh --clone <absolute-clone> --survivor <absolute-kept-checkout> --out <new-external-backup-dir>;如果存在这些隐蔽的数据丢失风险,该脚本会拒绝继续;它也会拒绝克隆中任何无法从其他引用到达的 Git 对象、不完整/promisor 克隆、已挂载链接工作树、本地子模块仓库、已知的 Git LFS/annex 对象存储、受跟踪内容过滤器,以及恢复归档无法安全解析的仓库本地配置/钩子间接引用。脚本会禁用仓库 fsmonitor 执行,冻结引用尖端、符号引用拓扑以及元数据文件的类型/模式,然后创建一个自包含的全引用 bundle 和一份内容绑定的收据。完成所有初步 Git 探测,冻结尚不存在的隔离目标,然后单独运行进程占用检查;之后运行 --verify-current <backup-dir> 作为最后一次探测,并将已授权且不会覆盖内容的隔离移动作为紧接着的下一步操作。此顺序可避免 lsof 观察到同级 Git 进程,同时不会扩大验证后的时间差。证明旧路径已不存在且新路径已存在;永久删除是另一个需要明确授权的决定。完整的 READ-DO 顺序及 --shared 边界详见 references/merge_verification.md § Independent clone retirement。references/prevention_practices.md 中的共享文件写入器探测,以及 references/merge_verification.md 中的克隆/工作树占用探测,遵循相同的只读、遇到任何实际写入进程即停止的规则;修改其中一处的判定标准时,也要同步修改其他位置。 步骤 4 — 删除后,按内容而不是文件名重新检查。 清理操作(或一批压缩合并)已经完成,而问题变成“是否因此丢失了工作内容?”时,仅根据名称进行检查——对 git ls-tree 文件名运行 comm,或确认“每个文件仍在 main 上”——就不够了:文件名相同并不代表内容相同。已删除分支和保留分支上都有的文件,仍可能逐行不同。请在 blob 层面重新验证,并按正确方向阅读差异:
git diff <survivor-ref> <deleted-or-merged-tip>    # survivor first, the gone thing second

标记为 - 的行存在于保留分支,但不在分支顶端 → 保留分支是一个超集(安全:它包含分支顶端已有的所有内容,而且更多)。标记为 + 的行存在于分支顶端,但不在保留分支 → 可能丢失——对每一项使用步骤 1 的检查流程:该符号是否以不同形式存在于保留分支中(属于重命名或重构,而不是删除)?差异中大多是 -、只有少数 +,就表明“保留分支已继续演进,而已删除分支是较早版本”——这说明合并成功,而不是丢失了工作内容。对任何保留的备份也执行相同检查:内容逐字节相同,或保留分支是其超集,都安全;只有保留分支确实在任何地方都没有的一行,才需要升级处理。

如果你后悔了,如何恢复: 可用 git apply 重新应用补丁;未跟踪的 tar 会解压到原位;bundle 可通过 git fetch <file>.bundle <branch>:restored/<branch> 恢复完整历史。

步骤 5 — 仅在获得单独授权后,才结束临时恢复工件的生命周期。 先完成步骤 4,并证明维护中的保留分支包含或有意取代了每一项有效载荷。然后遵循 references/merge_verification.md § Retire task-owned temporary recovery artifacts。要求统一到一个 main 分支的一般请求,并不代表获准删除外部备份。任何仍是工作内容唯一副本、包含尚未解决的有效载荷、混有已清理和未解决有效载荷,或不是由本任务创建或明确接管的工件,都必须保留。获准清理后,须独立证明每个确切的工件路径都已不存在;删除命令的回执并非删除后的验收条件。

脚本(执行这些脚本;除非另有说明,它们不会造成破坏)

以下所有 scripts/... 路径均相对于此 Skill 的 bundle 根目录,而不是承诺可通过 PATH 或在目标仓库中直接调用的命令。解析已加载的 Skill 目录并调用其中的捆绑路径;除非 command -v 确实能找到 git_verify_branch_merged.sh,否则绝不要让用户直接运行它。

脚本功能是否会修改?
scripts/git_find_all_checkouts.sh [root ...]查找此机器上此仓库的所有检出目录——包括 git worktree list 无法显示的独立克隆——并标记未提交/未跟踪/未推送的工作、远程缓存的时间,以及借用的 alternates 对象存储不会(只读,不执行 fetch)
scripts/git_hosted_vs_cached.sh [remote]直接查询托管服务(git ls-remote --heads),并与此检出目录缓存的 refs/remotes/<remote>/* 比较差异:相同/SHA 不同/仅托管端有/仅缓存中有(可清理候选项)。ls-remote 失败时以状态码 3 退出,绝不会把缓存当作托管端的真实状态不会(只读,不执行 fetch——唯一权威查询是 ls-remote,而非 fetch)
scripts/git_loss_audit.sh [remote]刷新一个远程,然后报告每个工作树、本地引用/标签、仅存在于 reflog 的提交、stash 和悬空对象;不做排除,因此必须涵盖完整的证据范围仅远程跟踪引用
scripts/git_preserve_danglers.sh [--patch-dir DIR]将每个悬空提交固定到 refs/dangling-backup/,可选生成补丁;仅支持整组操作仅添加引用(绝不删除或执行 gc)
scripts/git_verify_branch_merged.sh <branch> [base] [--no-fetch] [--base REF] [--merge-commit SHA]对一个分支给出内容级别的 MERGED/UNMERGED 判定。若 base 后来更改了相同的行,导致 CURRENT base 无法再证明包含关系,--merge-commit 会增加一个判定层级,证明该分支在历史合并提交时已被包含;判定也会说明当前 base 的状态(冲突 = 常规情况,干净但存在更改 = 回归候选项)。--no-fetch/--base 支持离线使用和通过标志驱动的批量使用仅远程跟踪引用(使用 --no-fetch 时完全跳过)
scripts/git_classify_refs.sh --base SHA [--pr-map JSON] [--all-namespaces]上述功能的批量版本:在一次离线遍历中,依据一个固定的 base 对每个本地/远程跟踪分支进行分类(祖先/内容已包含/需要审核,并统计文件数量变化);--pr-map 会叠加托管平台 PR 导出数据中的 --merge-commit 判定层级不会(只读,不执行 fetch——请先用 git rev-parse 固定 base)
scripts/git_align_checkout.sh --preview --target SHA / --apply --target SHA --source-worktree PATH --backup-manifest FILE将检出目录的内容预览或物化到目标提交,无需创建临时快照分支,也不移动 HEAD;只有在能够证明某路径的当前内容可复现时,--apply 才会覆盖该路径(依据目标提交自身的历史,或清单中的 sha256)--preview:不会。--apply:仅修改已证明安全的路径对应的工作树文件——绝不修改真实索引,绝不修改 HEAD,绝不删除文件
scripts/git_export_before_drop.sh [export options]将 stash 加上选定分支或所有当前引用导出到经过验证的 bundle 文件仅写入备份文件(绝不丢弃/删除任何内容)
scripts/git_export_before_drop.sh --verify-current BUNDLE如果 bundle 中的任何引用自导出后发生变化或消失,则失败不会(只读)
scripts/git_prepare_clone_retirement.sh --clone PATH --survivor PATH --out DIR拒绝处理存在隐藏/未处理克隆状态的情况,然后将每个引用顶端、符号引用目标、reflog 身份以及范围限定的配置/钩子/info 元数据冻结到一个独立恢复集;冻结目标目录(必须不存在且不会覆盖现有内容)和进程占用状态后,--verify-current DIR 是最后一次探测,接下来必须立即执行移动操作仅写入新的外部备份目录;禁用 lazy fetch/fsmonitor,并拒绝使用已跟踪内容过滤器;绝不移动/删除内容或更改引用
这些辅助脚本从仓库根目录运行。它们使用只读枚举/配置命令,例如
find、config、symbolic-ref、submodule status、status、cat-file、rev-list、rev-parse、
fsck、for-each-ref 和 remote get-url;还会使用范围受限的 fetch、archive、bundle create/verify、
元数据哈希/归档,以及(仅限 preserve)update-ref,具体以各脚本表格行的说明为准。
只有 git_find_all_checkouts.sh 是仓库只读的,可与只读 Agent 安全地并行运行;它不会
执行 fetch,因此离线和使用代理时也能正常运行。任何会执行 fetch、写入备份状态
或添加引用的辅助脚本,只有在现有协调流程移交独占写入权限后才会运行。没有任何辅助脚本授权执行
checkout、reset、push、stash drop、branch -d 或 gc。

故障排除

  • 审计结果干净,但用户仍觉得有所遗漏——相信他们,并怀疑是范围不对,而非检查不够彻底。仓库内的检查工具可能对它们能看到的那一个目录都判断正确。重新运行任何已经运行过的检查之前,先执行步骤 0(git_find_all_checkouts.sh);在错误范围内重复一次正确执行的检查,只会以更大的把握给出同样干净的结果,这比第一次检查更糟。
  • git_find_all_checkouts.sh 没找到任何结果,但你很确定还有另一份副本——按可能性从高到低,原因有三个:(1) 副本位于默认根目录之外(传入显式根目录,例如 ~,并调高 DEPTH);(2) 副本位于被剪枝的路径下——扫描会跳过 node_modules、.venv、vendor、.terraform;(3) 它的 origin 完全指向别处(一个 fork,或路径远程),因此远程匹配会将其排除——用 git -C <suspect> remote -v 检查,并手动比较根提交:git rev-list --max-parents=0 HEAD。如果在仓库设置任何远程之前用 cp -r 创建了副本,它只能通过根提交匹配。
  • git_loss_audit.sh 报告了看起来像旧储藏的悬空提交——大量使用储藏后出现这种情况是正常的。它们现在可以通过 reflog 找到;用定向的 update-ref 固定一个获准的 SHA,或者仅在所有报告的悬空对象都在检查范围内时才使用 git_preserve_danglers.sh,然后有空时用 git show <sha> 检查。
  • 某个分支显示大量“领先提交”,但你怀疑它已经合并——相信 git_verify_branch_merged.sh(按内容判断),而不是提交数量。参见模式 C。
  • 恢复文件意外变得很大,或上传/LFS 传输停滞——停止重试,重新检查结果约定。这表明范围或放置位置有问题,不是传输难题。如果没有获准且迫在眉睫的删除会威胁数据,那么备份就做早了。如果确实需要备份,请将其保留并验证于外部备份目录中;远程传输是单独的决定,不是自动回退方案。
  • 脚本中的 git fetch 因代理/离线而卡住——基于缓存的远程引用仍可检测丢失,因为过期缓存只会多报未推送的工作。合并与取代判定(模式 C、模式 E)是例外,确实需要 fetch;如果无法执行,就在报告中说明,不要把判定说成已确定。
  • 你的工作看起来尚未合并,但你工作期间仓库有了更新——恢复任何内容之前先检查时间:git_find_all_checkouts.sh 会显示每个工作副本上次 fetch 的时间;新鲜 fetch 后,git log --oneline <cached-base>..origin/main 会显示这期间新增的内容。长时间工作会形成风险窗口——你开始时用来比较的基点,到结束时可能已经过时数小时。需要识别的症状:你明明知道自己提交过某项更改,却发现上游没有,于是准备重新提交。先 fetch,再按内容比较;如果更改已经到位,先检查是否有人改进过它,再决定是否用你的版本覆盖。
  • 推送或合并命令没有收到回执(超时、TLS 错误、EOF),且远程引用已移动——引用移动并不能证明你的写入已到达。在有并发会话的仓库中,新的最新提交可能是其他人的合并;基于这一假设重试,可能会重复应用你的更改,或把从未发布的工作误报为成功。应按内容确认,但先注意两点。刷新远程跟踪引用需要 fetch,而模式 C 规定只有独占使用权时才能 fetch——这条规则同样适用于此处。只有在你是唯一写入者时,才能先 fetch 再读取:上文规则 1 尤其适用于这个问题,因为过期的 origin/<branch> 会把远程已有的工作误读为独有工作,这正是本条开头提到的重复应用问题。在有争用的工作副本中,你不得 fetch;基于缓存引用得出的判断不算判断——应报告为无法确认。此外,git merge-base --is-ancestor <your-sha> origin/<branch> 只会在合并保留了你的提交时给出肯定结果——squash 或 rebase 合并会重写它,因此此处退出代码为 1 的意思是“不是这个对象”,而非“没有合并”,据此操作会导致本条警告的重复应用问题(实测某个仓库的一天内:一个已合并 PR 的提交是祖先,另一个不是,但两者都已合并)。它还有第三种退出代码:如果 <your-sha> 不是此仓库中的提交,则返回 128——托管平台创建了自己的对象时就会出现这种情况;这与 1 的含义不同。无论采用哪种合并策略都适用的探查方式是按内容判断,并附上控制行:
    git show origin/<branch>:<path> | grep -cF '<a string only your version contains>'
    git show origin/<branch>:<path> | grep -cF 'string-that-cannot-exist'  # must be 0, or the probe is broken
    
    实际事故:三次合并尝试都在网络层失败,而 origin/main 前进了两次——一次来自本文作者的合并,一次来自并行会话的合并。
  • “那个分支是不是被删除了?”探查显示它仍然存在——先检查探查方式是否正确,再相信结果,因为 git ls-remote <remote> <ref> > f 后接 [ -s f ] 两个方向都不对,而具体会出现哪种错误取决于重定向细节。对不可达的远程进行实测:单独使用 > f 会留下一个0 字节的文件(Git 将错误写入 stderr),因此测试会把命运未知的分支报告为“已删除”——然后你就会停止保留它。合并输出流(> f 2>&1、&> f)后,同样的错误会写入 155 字节,因此测试会把早已删除的分支报告为“仍然存在”。应使用命令为此提供的退出代码:git ls-remote --exit-code <remote> refs/heads/<branch> 在引用匹配时返回 0,不匹配时返回 2,其他值(远程不可达时为 128)表示探查本身失败——而文件大小测试会错误地将这些结果混为一谈,且每次表现不同。返回 128 时,分支的命运未知,这不等于已删除:保留所有能够保护它的内容,不要据此清理任何东西;等远程可访问后再试一次,或把问题交给人工处理。远程不可达是等待的理由,绝不是清理的理由。 传入完全限定的引用:ls-remote 会跨所有命名空间按名称末尾匹配,因此裸分支名也会匹配同名标签,并会对已删除的分支返回 0(实测——发布版本的标签与分支同名后,这种情况很常见)。工作树中也有同样的陷阱:[ -e <path> ] 无法区分已跟踪且干净的文件与未跟踪的同名文件。应询问 Git——使用 git cat-file -e <branch>:<path>,返回 0 表示该分支包含此文件。不要将非零值理解为“文件不存在”:128 也可能表示分支名拼错,或路径经过了已跟踪的符号链接;Git 只能通过 stderr 文本区分这些情况——因此要读取文本,而不能只看代码,否则你又重现了本条开头提到的缺陷。
  • 检出某个提交后,你处于分离的 HEAD 状态——只要执行 git switch -c <branch> HEAD(或者 reflog 还会记住它约 90 天),该提交就是安全的。不要让重要的新工作在分离的 HEAD 状态下跨越一次 gc。恢复在那里已经被放弃的工作:references/recovery_playbook.md § 梯子第 3 步——分离 HEAD 上的工作。如果约 90 天不够这个仓库使用,请一次性延长:参见该文档的 § 扩大安全时间窗口(配置),以及 references/prevention_practices.md § 一次性设置更长的 reflog 安全时间窗口。
  • 清理后只剩一个工作树——git worktree list 始终会包含主仓库工作副本。不要为了让数量变成零而删除它;目标是保留一个维护中的工作副本,而非一个都不留。
  • refs/dangling-backup/* 引用之后造成了杂乱——确认(模式 C)其内容已存在于远程后,使用 git for-each-ref --format='%(refname)' refs/dangling-backup/ | xargs -n1 git update-ref -d 删除它们。仅在验证之后执行。
  • 你的分支与上游分歧,但那些本地独有提交看起来很熟悉——运行 git cherry <upstream> <branch>。所有行都是 -,表示每个本地独有提交都与上游已有提交的补丁完全相同(git cherry 比较的是补丁 ID,因此 SHA 不必相同)。常见原因是:你在远程跟踪引用过期的分支上提交,之后同一更改又被重新提交并推送。当每一行都是 - 时,将分支快进到上游——重复的对象仍可从对象存储中恢复。任何 + 行都表示存在真正的本地独有工作;应像普通分歧一样处理(并记得步骤 1 的注意事项也适用于反向情况:squash 合并后,+ 并不能证明工作缺失,因此应将 git cherry 视为线索,而非定论——只有全为 - 才足以授权快进)。

下一步

完成恢复/审计后,如果仓库还需要常规设置、安全提交/推送、冲突处理或交接整理,则应由 auto-repo-setup 技能负责(调用 /auto-repo-setup)——本技能负责取证/恢复,那项技能负责常规工作流。

评论

支持 Markdown