一次 Git 历史压平惊魂:orphan 分支和 git add -A 的坑
这次没有造成真实损失,但过程足够吓人。
一个本地小工具仓库准备改名,并考虑放到公网私有仓库里。发布前需要先做一件事:清掉历史里和旧项目、旧公司环境相关的痕迹。当前文件已经基本改干净,但 Git 历史里仍然保留旧提交,所以最后还要把仓库压成一个新的初始化提交。
目标本来很明确:
- 当前源码保持完整。
- 当前内容里不要再出现旧项目名、旧域名、旧包名。
- Git 历史只留下一个
初始化项目commit。 - commit author 也换成中性身份,避免邮箱暴露内部域名。
风险集中在最后一步。历史压平时用了 git switch --orphan 加 git add -A,结果生成的新 root commit 没有包含预期源码,反而追踪了 .idea、node_modules、dist 这类本地目录。工作区看起来像是突然少了大量源码。
中间保留下来的完整快照 commit 成了恢复点。后续从这个快照恢复源码,并改用 git commit-tree 直接基于正确的 tree 创建新 root commit,仓库最后恢复成一个干净的单提交历史。
这份复盘把事故过程、Git 原理、补救措施和后续规则整理清楚。以后让 AI Agent 或自己操作这类高风险 Git 任务时,关键是先保留恢复点,再动历史和对象清理。
目标是什么
这个仓库已经改过名,当前工作区里还保留了一批未提交改动。改动内容大致分两类。
第一类是正常的项目改名和中性化:
- package name 从旧命名空间改成新命名空间。
- README、AGENTS 里的旧项目名改成新项目名。
- 源码里的主题名、storage key、日志前缀和页面标题做中性化。
- 默认文档链接、测试里的租户域名、说明里的内部包名改成示例或通用说法。
第二类是 Git 历史清理:
- 当前文件里已经搜不到敏感词,但旧 root commit 里还能搜到。
git log -S能证明历史快照里曾出现旧项目名。- 提交作者邮箱也带有内部域名。
- 远端还没配置,所以可以直接重写本地历史。
这时比较自然的想法是:既然要清理历史,就把当前干净状态作为一个新的 root commit,旧提交全部丢掉。
目标合理,风险出在实现方式。
目标可以先画成这样:新的 NEW_ROOT 复用已经确认正确的 GOOD_SNAPSHOT 文件树,而不是让 Git 重新扫一遍目录。
第一次异常:orphan 分支被未提交改动拦下
一开始尝试的是常见写法:
git switch --orphan sanitized-main
git add -A
git commit -m '初始化项目'
git branch -D main
git branch -m main--orphan 的意思是创建一个没有父提交的新分支。后面在这个分支上创建的第一个提交,就是新的 root commit。
但当时工作区还有未提交改动,Git 拦住了第一步:
error: Your local changes to the following files would be overwritten by checkout:
AGENTS.md
README.md
package.json
...
Please commit your changes or stash them before you switch branches.
Aborting这个拦截是合理的。Git 不确定切到 orphan 分支后这些未提交改动会不会被覆盖,所以要求先提交或 stash。
随后先提交当前修改,得到一个临时提交。这个提交后来很关键,因为它保存了完整源码快照。
git add -A
git commit -m '初始化项目'假设这个临时提交叫 GOOD_SNAPSHOT。它不是最终想保留的历史,因为它仍然有旧父提交;但它的 tree 是正确的,包含的正是要发布的那 83 个 tracked 文件。
后续恢复依赖这个快照。
问题出在 orphan 分支上的 git add -A
临时提交完成后,工作区变干净了,于是再次执行 orphan 流程。
git switch --orphan sanitized-main
git add -A
git commit -m '初始化项目'这一步生成了一个新的 root commit。表面上看,命令成功了;实际上它提交了错误内容。
提交输出里已经出现了关键异常信号:
[sanitized-main (root-commit) BAD_COMMIT 初始化项目
8337 files changed, 2035289 insertions(+)
create mode 100644 .idea/.gitignore
create mode 100644 .idea/inspectionProfiles/Project_Default.xml
create mode 100644 node_modules/.modules.yaml
create mode 100644 node_modules/.pnpm/...
create mode 100644 packages/.../dist/manifest.json
create mode 100644 packages/.../dist/popup/js/index.js一个原本只有几十个源码文件的小工具仓库,突然变成 8337 个 tracked 文件。.idea、node_modules、dist 都出现在 root commit 里。
更吓人的是,当前 main 被切到了这个错误 commit 后,源码目录看起来不见了。比如原本应该存在的 src/popup 目录,在当前工作树里无法找到。
源码并没有从所有 Git 对象里消失,当前分支只是指向了一个错误的文件快照。Git checkout 一个 commit 时,工作树会变成那个 commit 对应的 tree。错误 commit 的 tree 里没有源码,自然就看起来像源码丢了。
Git 视角下到底发生了什么
Git 这里涉及几个需要分开看的对象。
一个 commit 主要保存几类信息:
- 指向文件快照的 tree。
- 父提交。
- author 和 committer。
- 提交时间和 message。
这里最关键的是 tree 和父提交。
普通提交会有父提交:
A -> B -> CC 的 tree 保存当前项目文件快照,C 的 parent 指向 B。
root commit 没有父提交:
R它仍然有 tree,只是不再指向任何 parent。压平历史的目标,本质上就是创建一个新的 root commit,让它的 tree 等于当前想保留的项目文件。
目标可以写成:
旧历史:A -> B -> GOOD_SNAPSHOT
新历史:NEW_ROOT
并且:
tree(NEW_ROOT) == tree(GOOD_SNAPSHOT)
parent(NEW_ROOT) == 空如果用 git switch --orphan 加 git add -A,流程变成了:
当前工作树里有什么
↓
git add -A 重新写入 index
↓
git commit 创建 root commit它依赖的是「当前工作树现场」。
风险就在这里。工作树现场不仅包含 Git 已经追踪的源码,也可能包含本地目录、安装依赖、构建产物、IDE 配置和缓存。平时这些内容可能被 .gitignore 挡住,但 orphan 切换、索引状态、已有 ignore 规则、目录存在状态混在一起时,git add -A 的风险明显高于直接复用一个已验证 commit 的 tree。
错误 root commit 的来源也在这里:它没有复用 GOOD_SNAPSHOT 的 tree,而是从当时工作树重新收集了一遍文件,最后把本地依赖和构建产物提交进去。
这张图是两条路径的区别。左边的 orphan 流程把“当前工作树现场”当输入;右边的 commit-tree 流程把“已验证 Git tree”当输入。
为什么看起来像源码丢了
Git 工作树永远是当前 HEAD 对应 tree 的展开结果,再叠加未提交改动。
当 main 指向正确提交时,工作树里有源码:
main -> GOOD_SNAPSHOT
tree:
AGENTS.md
README.md
package.json
packages/.../src/popup/...当 main 被改到错误 root commit 时,工作树会变成错误提交的 tree:
main -> BAD_COMMIT
tree:
.idea/...
node_modules/...
packages/.../dist/...这时源码目录不见了,不代表 Git 数据库里没有任何源码对象。只要旧提交或临时快照还可达,就可以从它恢复。即使没有分支指向它,只要 reflog 还没清理,通常也能通过 reflog 找回来。
危险点在于,如果在确认恢复点之前就执行了这些命令,恢复难度会大幅增加:
git reflog expire --expire=now --expire-unreachable=now --all
git gc --prune=now这两个命令会让不可达提交失去最后的恢复窗口。历史清理本来就经常会执行它们,但执行时机必须放在最终验证之后。
这次在清理前发现了异常,并且旧提交对象还在。
如何确认还有恢复点
发现源码像是丢了以后,第一步先暂停清理和 reset,确认几个问题。
当前分支指向哪里:
git status --short
git branch -vv
git log --oneline --decorate --all --max-count=20旧提交对象是否还存在:
git rev-parse --verify GOOD_SNAPSHOT^{commit}
git rev-parse --verify OLD_MAIN^{commit}当前 Git 追踪了多少文件,有没有明显异常目录:
git ls-files | wc -l
git ls-files | python3 -c "import sys; files=[line.strip() for line in sys.stdin]; print(len(files)); print([f for f in files if f.startswith(('node_modules/', '.idea/', 'dist/'))][:20])"当时看到的关键事实是:
- 当前
main指向错误 root commit。 - 正确快照
GOOD_SNAPSHOT仍然能rev-parse到。 - 错误 commit 追踪了 8337 个文件。
- 正确快照对应的 tracked 文件应该只有 83 个。
这些事实指向同一个结论:源码没有永久消失,main 指错了。
补救措施
补救分成四步:先保护现场,再恢复源码,再用正确方法重建历史,最后验证并清理。
恢复时最重要的是顺序。异常发生后,先停止清理,再把错误状态和正确快照都固定成可达对象;等源码恢复、历史重建和验证都完成后,才清理 reflog 和不可达对象。
先保护现场
即使当前状态是错的,也不要急着删。先给错误状态和正确快照都打临时分支。
git branch backup-bad-orphan BAD_COMMIT
git branch backup-good-snapshot GOOD_SNAPSHOT
git branch backup-old-main OLD_MAIN这一步的意义是把关键 commit 重新变成可达对象。只要有分支指向它们,就算后面切分支、reset 或检查文件,也不会轻易被 Git 当成垃圾对象清掉。
恢复 main 到正确快照
确认 GOOD_SNAPSHOT 的 tree 是完整源码后,把 main 恢复过去:
git reset --hard GOOD_SNAPSHOT这是一次会改工作树的操作,执行前必须确认自己要丢弃的是错误 orphan 工作树,而不是用户未保存的真实改动。这条命令可以执行的前提是:错误状态已经有 backup-bad-orphan 保存,正确状态也有 backup-good-snapshot 保存。
恢复后立刻检查:
git status --short
git ls-files | wc -l还要确认源码目录回来了:
git ls-files | grep 'src/popup' | head在真实操作里,源码恢复后 tracked 文件数回到 83,node_modules、.idea、dist 都不再被 Git 追踪。
用 commit-tree 创建目标 root commit
后续直接避开 orphan 工作树流程。
更稳的做法是直接基于正确快照的 tree 创建一个无父提交:
NEW_COMMIT=$(
GIT_AUTHOR_NAME='project-name' \
GIT_AUTHOR_EMAIL='noreply@example.com' \
GIT_COMMITTER_NAME='project-name' \
GIT_COMMITTER_EMAIL='noreply@example.com' \
git commit-tree GOOD_SNAPSHOT^{tree} -m '初始化项目'
)
git update-ref refs/heads/main "$NEW_COMMIT"git commit-tree 是更底层的命令。它接收一个 tree,直接创建 commit。这里没有 git add,没有重新扫描工作树,也不会把本地目录混进去。
这条命令表达的语义很直接:
拿 GOOD_SNAPSHOT 的文件树
↓
创建一个没有父提交的新 commit
↓
把 main 指向这个新 commit它正好符合历史压平的目标。
清理临时分支和旧对象
只有在确认新 main 正确之后,才删除临时分支:
git branch -D backup-bad-orphan backup-good-snapshot backup-old-main再检查当前所有 refs:
git show-ref --heads --tags
git log --all --oneline --decorate
git rev-list --all --count如果确认只剩目标单提交,再清理 reflog 和不可达对象:
git reflog expire --expire=now --expire-unreachable=now --all
git gc --prune=now清理后再看对象状态:
git count-objects -v目标状态是:
git rev-list --all --count输出1。git log --all只看到一个初始化项目。git show-ref --heads --tags里没有临时备份分支。git count-objects -v没有 loose garbage。
为什么 commit-tree 更适合这个任务
这类任务的核心是把已验证快照改造成新的历史起点。
git add -A && git commit 的输入是工作树和 index:
工作树
+ index
↓
commitgit commit-tree <tree> 的输入是一个已经存在的 tree:
已验证 commit 的 tree
↓
新 commit两者的风险完全不同。
工作树会受很多现场因素影响:
- 是否有未跟踪文件。
.gitignore是否完整。- 是否刚跑过构建。
- 是否刚安装过依赖。
- IDE 是否写了项目配置。
- orphan 切换后 index 状态是否符合预期。
tree 则是 Git 已经保存过的文件快照。只要这个快照已经通过 git ls-files、敏感词扫描、测试和构建验证,基于它创建新 root commit 就更可控。
历史压平时应该先得到一个「正确快照 commit」,再用 commit-tree 改父提交关系。不要在 orphan 分支里重新收集文件。
敏感信息清理要分四层检查
原始目标是清理旧项目痕迹。这个目标本身也容易漏边界。
第一层是当前源码正文和文件名:
rg -i 'old-name|old-domain|old-package' .rg 很适合扫文本源码,但它默认不会展开压缩包,也不会替你检查 Git 历史。
第二层是 Git tracked 文件:
git ls-files -z | python3 - <<'PY'
import sys
from pathlib import Path
needles = [b'old-name', b'old-domain', b'old-package']
for raw in sys.stdin.buffer.read().split(b'\0'):
if not raw:
continue
path = Path(raw.decode())
data = path.read_bytes().lower()
for needle in needles:
if needle in data:
print(path)
PY这能避免被未跟踪文件干扰,也更接近「将来会进入仓库的内容」。
第三层是压缩包和构建产物。比如 Chrome Extension 的 zip 里可能还有旧 bundle:
from pathlib import Path
import zipfile
root = Path('.')
needles = [b'old-name', b'old-domain', b'old-package']
for zip_path in root.rglob('*.zip'):
with zipfile.ZipFile(zip_path) as zf:
for info in zf.infolist():
if info.is_dir():
continue
data = zf.read(info).lower()
for needle in needles:
if needle in data:
print(f'{zip_path}::{info.filename}')这类问题很容易出现在构建产物里:源码已经干净,但 zip 内部的 popup/js/index.js 和 sourcemap 里仍然带旧域名。必须重新构建和打包后再扫一遍。
第四层是 Git 历史:
git log --all -S'old-name' --format='%h %s'
git grep -i -n 'old-name' $(git rev-list --all) -- .
git log --all --format='%h %an <%ae> %s'这几条分别覆盖:
- 哪些提交引入或删除过关键词。
- 历史快照里哪里还能 grep 到关键词。
- 提交作者、邮箱和 message 是否仍然暴露旧信息。
如果最终目标是对外发布一个干净仓库,这四层都要过。
高风险 Git 操作的安全顺序
高风险 Git 操作要先把「恢复点」和「验证点」设计出来,再执行会改历史的命令。
比较稳的顺序应该是这样。
第一步,确认当前状态:
git status --short
git branch -vv
git log --all --oneline --decorate --max-count=20
git ls-files | wc -l第二步,创建明确备份:
git branch backup-before-history-rewrite HEAD如果仓库很重要,还可以额外生成 bundle:
git bundle create ../project-before-history-rewrite.bundle --all第三步,得到一个正确快照 commit。这个 commit 可以是普通提交,也可以是已有的目标提交。关键是它的 tree 要已经验证过:
git ls-files | wc -l
git status --short
pnpm test
pnpm build第四步,用 commit-tree 创建新 root commit:
NEW_COMMIT=$(git commit-tree HEAD^{tree} -m '初始化项目')
git update-ref refs/heads/main "$NEW_COMMIT"如果要改 author 和 committer,用命令级环境变量,不要改全局 Git 配置:
NEW_COMMIT=$(
GIT_AUTHOR_NAME='project-name' \
GIT_AUTHOR_EMAIL='noreply@example.com' \
GIT_COMMITTER_NAME='project-name' \
GIT_COMMITTER_EMAIL='noreply@example.com' \
git commit-tree HEAD^{tree} -m '初始化项目'
)第五步,验证新历史:
git rev-list --all --count
git log --all --format='%h %an <%ae> %s'
git ls-files | wc -l
git status --short第六步,重新扫敏感词:
rg -i 'old-name|old-domain|old-package' .
git grep -i -n -E 'old-name|old-domain|old-package' $(git rev-list --all) -- . || true第七步,确认无误后再清理旧对象:
git reflog expire --expire=now --expire-unreachable=now --all
git gc --prune=now这个顺序的核心是:清理操作永远放在最后。只要前面任何一步不对,都还能从备份分支、reflog 或 bundle 回来。
给 AI Agent 的额外规则
问题发生在 AI Agent 代操作 Git 的过程中,还需要补一层 Agent 工作规则。
人自己在终端里敲命令时,看到 8337 files changed 通常会立刻警觉。Agent 也应该把这类输出当成硬性异常,而不是继续执行下一步。
这类任务可以沉淀成几条规则。
第一,压平历史时不要使用 orphan + git add -A 作为默认方案。优先使用 git commit-tree <verified-commit>^{tree}。
第二,任何会重写历史或清理 Git 对象的任务,必须先创建备份 ref:
git branch backup-before-history-rewrite HEAD第三,执行 git gc --prune=now 前必须满足这些条件:
git rev-list --all --count符合预期。git log --all只包含预期提交。git ls-files | wc -l和重写前的已验证快照一致。- 敏感词扫描已经覆盖当前文件、zip 和历史。
- 没有临时错误分支仍然需要保留。
第四,看到异常文件数量、异常目录或异常 diff 时必须停止。例如:
- 小项目突然出现几千个 tracked 文件。
node_modules、.idea、dist出现在 commit 输出里。- 源码目录在 checkout 后消失。
git status出现大量删除或新增,且不符合任务目标。
第五,涉及用户已有改动时,不要把「清理历史」和「顺手整理工作区」混在一起。先确认哪些改动属于本任务,哪些是用户已有改动。需要丢弃工作树时,必须先说明会丢弃什么,并确认已有恢复点。
这些规则不只服务某一个 Agent。Codex、Cursor Agent,或者任何能代执行 shell 的工具,都应该遵守。
可以写进 AGENTS.md 的版本
结论确认后,可以把下面这段压缩进项目级 AGENTS.md。
## Git 历史重写安全规则
- 需要把仓库压成单个初始化提交时,优先使用 `git commit-tree <verified-commit>^{tree}` 创建无父提交,再用 `git update-ref refs/heads/<branch> <new-commit>` 移动分支;不要默认使用 `git switch --orphan` 后再 `git add -A`,避免把当前工作树里的 `node_modules`、`.idea`、`dist` 或其他本地目录误提交。
- 历史重写前必须先创建备份 ref,例如 `git branch backup-before-history-rewrite HEAD`;重要仓库再额外生成 `git bundle create ../repo-before-history-rewrite.bundle --all`。
- 执行 `git reflog expire`、`git gc --prune=now` 这类清理命令前,必须先验证 `git rev-list --all --count`、`git log --all`、`git ls-files | wc -l`、敏感词扫描和构建 / 测试结果都符合预期。
- 如果提交输出或 `git status` 里出现异常规模变化,尤其是几千个文件、`node_modules`、`.idea`、`dist`、源码目录消失等现象,立刻停止后续清理,先保留当前错误状态和最近正确快照的备份分支。这段规则的重点是阻止两类危险动作:从不可信工作树重新收集文件,以及在验证前清掉恢复窗口。
最后留下的判断
这次事故没有造成损失,是因为正确快照还在,清理命令也没有在异常确认前把旧对象删掉。但它暴露的问题很真实:Git 历史重写不是普通文件编辑,错一步可能让工作树瞬间变成另一个世界。
以后遇到类似任务,可以先问三个问题。
第一个问题:要保留的是哪个 tree?如果答案是某个已验证 commit 的 tree,就用 commit-tree,不要重新 git add -A。
第二个问题:现在有没有恢复点?至少要有一个备份分支;更重要的仓库要有 bundle。
第三个问题:什么时候才允许清理旧对象?只有在新历史、文件数量、敏感词扫描、测试构建都确认正确之后,才执行 reflog 和 gc 清理。
历史压平的目标是让仓库变干净,同时保留验证前的恢复路径。越是想删干净,越要先确认还能回来。
