一次 Git 历史压平惊魂:orphan 分支和 git add -A 的坑

这次没有造成真实损失,但过程足够吓人。

一个本地小工具仓库准备改名,并考虑放到公网私有仓库里。发布前需要先做一件事:清掉历史里和旧项目、旧公司环境相关的痕迹。当前文件已经基本改干净,但 Git 历史里仍然保留旧提交,所以最后还要把仓库压成一个新的初始化提交。

目标本来很明确:

  • 当前源码保持完整。
  • 当前内容里不要再出现旧项目名、旧域名、旧包名。
  • Git 历史只留下一个 初始化项目 commit。
  • commit author 也换成中性身份,避免邮箱暴露内部域名。

风险集中在最后一步。历史压平时用了 git switch --orphangit add -A,结果生成的新 root commit 没有包含预期源码,反而追踪了 .ideanode_modulesdist 这类本地目录。工作区看起来像是突然少了大量源码。

中间保留下来的完整快照 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 重新扫一遍目录。

git commit-tree 复用已验证 tree 创建新 root commit

第一次异常: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 文件。.ideanode_modulesdist 都出现在 root commit 里。

更吓人的是,当前 main 被切到了这个错误 commit 后,源码目录看起来不见了。比如原本应该存在的 src/popup 目录,在当前工作树里无法找到。

源码并没有从所有 Git 对象里消失,当前分支只是指向了一个错误的文件快照。Git checkout 一个 commit 时,工作树会变成那个 commit 对应的 tree。错误 commit 的 tree 里没有源码,自然就看起来像源码丢了。

Git 视角下到底发生了什么

Git 这里涉及几个需要分开看的对象。

一个 commit 主要保存几类信息:

  • 指向文件快照的 tree。
  • 父提交。
  • author 和 committer。
  • 提交时间和 message。

这里最关键的是 tree 和父提交。

普通提交会有父提交:

A -> B -> C

C 的 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 --orphangit 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”当输入。

orphan 加 git add -A 与 commit-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 历史重写异常后的恢复窗口

先保护现场

即使当前状态是错的,也不要急着删。先给错误状态和正确快照都打临时分支。

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.ideadist 都不再被 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
    ↓
  commit

git 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.ideadist 出现在 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 清理。

历史压平的目标是让仓库变干净,同时保留验证前的恢复路径。越是想删干净,越要先确认还能回来。