我所理解的 Git 最佳实践(8):为什么切到 orphan 分支后源码不见了
这次压平历史原本要保留当前文件,只换一个没有父提交的起点。执行 git switch --orphan 后,已经跟踪的源码和 .gitignore 一起离开了工作树。接着执行 git add -A,Git 从剩下的文件里重新暂存,依赖和构建产物反而进了新提交。原来只有 83 个受管文件,错误的新根有 8337 个,连预期的源码目录也不在里面。
git add -A 读取的是切换后的工作树
git switch --orphan 会创建还没有提交的分支,并移除已跟踪文件。之前被 .gitignore 排除的目录可能还在磁盘上。一旦忽略规则也被移走,这些目录就可能变成 git add -A 的输入。那次切换一开始被未提交改动挡住,正是切换前保存的那条提交,留下了正确的源码快照。
命令虽然成功了,提交输出已经暴露了问题。下面节选了当时最容易辨认的几行。
8337 files changed, 2035289 insertions(+)
create mode 100644 .idea/.gitignore
create mode 100644 node_modules/.modules.yaml
create mode 100644 packages/.../dist/manifest.json
源码不见时先找回正确的文件树
错误提交成了 main 之后,连预期的 src/popup 都看不到了。恢复时先停下清理,确认当前引用,再从现有分支和 reflog 找曾经的快照。reflog 是本地引用变动记录,能找到分支曾经指向的位置,前提是记录和对象仍在。
git status --short --branch
git log --all --oneline --decorate -20
git reflog --date=iso
从日志里找到候选提交以后,把下面的 GOOD_SHA 换成候选的完整提交 ID。第一条检查对象是否仍是可用的提交,第二条看这次提交究竟保存了哪些文件。
git rev-parse --verify 'GOOD_SHA^{commit}' &&
git ls-tree -r --name-only GOOD_SHA
当时对照的是 83 个受管文件和 src/popup 等源码路径。确认正确以后,给错误提交和正确快照分别建立恢复分支,旧主干另有需要保留的 tip 也一起固定。分支只能保住提交,未提交或未跟踪文件要另外保存。那次是在确认可以丢弃错误工作树后,用 git reset --hard 恢复到正确快照,受管文件数回到了 83。如果现场仍有要留下的编辑,先从正确快照另开工作树检查,不必覆盖现有目录。
用已验证的 Tree 创建新的根提交
一个根提交没有父提交,但仍然指向完整文件树。如果目标是保留某一版源码并更换历史起点,可以直接复用那版提交的 tree,避开从工作目录重新收集文件的一步。重要仓库先在清理范围外保存一份能独立恢复的 Bundle,把所需的旧分支和标签也纳入备份。
下面以隔离副本为例。先在当前工作树检出 main,核对它指向已验证的快照,且工作树和索引都干净、构建和必要测试通过。确认 backup-before-rewrite 分支名未使用。两段命令在同一个 Git Bash、macOS 或 Linux shell 会话内分开运行。
git status --short --branch &&
old_tip=$(git rev-parse --verify refs/heads/main) &&
tree=$(git rev-parse --verify "$old_tip^{tree}") &&
git branch backup-before-rewrite "$old_tip" &&
new_root=$(git commit-tree "$tree" -m '初始化项目') &&
git show -s --format='%H %T %P' "$new_root" &&
git show -s --format=fuller "$new_root"
git commit-tree 直接创建提交对象,此时 main 仍指向旧提交。没有传 -p,所以新提交没有父节点。命令需要可用的 Git 提交身份。像这次要避免在新根里带入内部邮箱,就要同时检查 author 和 committer,可以只为这条命令设置 GIT_AUTHOR_NAME、GIT_AUTHOR_EMAIL、GIT_COMMITTER_NAME 和 GIT_COMMITTER_EMAIL。最后对照输出的 tree 和空父列表,再核对提交说明和项目要求的签名。
确认第一段全部成功且上述结果符合目标后,才移动引用。update-ref 最后一项是预期旧值,如果 main 已经移动,这次更新就会失败。它只动引用,不能替你检出目标快照,因此这里复用的是当前 main 的同一棵 tree。命令期间也不要切换分支或继续编辑。
git update-ref refs/heads/main "$new_root" "$old_tip" &&
git rev-list --count main &&
git diff --exit-code "$old_tip" main -- &&
git status --short
预期 main 只到达一条提交,旧 tip 与新 main 的文件差异为空,工作树仍然干净。保留了备份分支时,git rev-list --all --count 仍会计入旧历史,这是恢复入口仍有效的表现。
远端更新和对象清理之前保留恢复点
同一棵 tree 可以用来重建根提交,恢复点则要在移动引用之前准备好。保存旧 tip 和能独立恢复的 Bundle,并把尚未提交的工作另存一份。
撤销共享主干上的普通变更时,用 git revert 新增一条反向提交,其他人的基线仍然可用。确实需要重写共享历史时,先确认目标分支并暂停并发写入,再在分支规则允许时用显式 lease 更新远端。更新后从独立目录检出,核对 tree、分支和实际文件,再协调旧工作树如何迁移。
如果原因是已泄露的密钥,先吊销或轮换,再处理 Git 历史。即使只是去除旧项目标识,也要分开检查文件内容、受管路径、发布产物和提交历史。例如 git grep 可以扫指定提交里的内容,git ls-tree -r --name-only 用来核对文件名。发布扩展时还要重新构建,再展开实际要交付的 zip 检查其中的 JS 和 sourcemap。源码里搜不到的旧域名,仍可能存在于上一次打好的包里。
git log -S 可以定位某个字符串出现次数变化的提交,它回答的是「何时引入或移除」,还要扫描发布范围内的历史快照。提交正文、作者和提交者邮箱也需要单独查看,例如用 git log --all --format='%h %an <%ae> %cn <%ce>%n%B' 查看本地各引用可达的提交。恢复分支或 Bundle 可能还保存着清理前的内容,发布时只推核对过的引用。需要保留其余历史时,应针对敏感对象做重写。旧 clone、fork、PR 引用和平台缓存可能仍能访问旧提交,处理结果要分别验收。 托管侧的清理范围可对照 GitHub 的敏感数据清理文档。
清理对象会缩短恢复窗口
git reflog expire 会使本地引用记录过期,git gc 才可能回收已不可达的对象。备份分支和 tag 仍指着的旧对象依然可达。要核对远端是否回收,需要看托管服务的计量和对象保留规则。
给一个分支换历史,无须立即回收对象。如果确实要清掉旧对象,等新分支、需要更新的远端和独立恢复副本都验过,再盘点备份分支、标签、远端跟踪引用和其他 worktree。只有确认可以结束恢复窗口、仓库外有已验证的恢复材料且没有并发写入,才考虑立即过期 reflog 或使用 git gc --prune=now。git rev-list main --count 为 1 只说明该分支剩一条可达提交,--all 计数也不能证明对象库里已没有不可达对象。
这次恢复用了那份仍可检出的 83 文件快照。交给 Agent 操作时,我会把目标 tree、受管文件预期规模和允许改动的引用直接写进任务。发现源码消失、构建目录进入索引或远端旧 tip 变化,就先保留现场。等到新副本和备份都验证过,再决定何时结束恢复窗口。