修复 Git 历史中的作者与提交者信息

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
Gitgit-filter-repo提交历史

我发现这个问题,是因为托管平台上的贡献记录突然少了一批。

代码、提交和提交说明都还在,只是其中一批提交使用了错误的姓名和邮箱。常见原因包括迁移电脑后没有恢复 Git 配置、在共享环境中沿用了别人的配置,或者提交脚本临时覆盖了身份:

git -c user.name="WRONG_USER" -c user.email="WRONG_EMAIL" commit -m "<提交说明>"

这种写法不会修改仓库或全局配置,但会让本次提交的 author 和 committer 都使用临时值。托管平台通常根据提交邮箱关联账户,因此邮箱错误时,贡献记录也可能归到别处或无法归属。

先分清 author 和 committer

Git 提交里有两套身份:author 表示最初写下这次改动的人,committer 表示把这份改动写入当前历史的人。可以直接查看:

git log -1 --format=fuller

如果只修正 author,托管平台仍可能显示另一个人执行了 commit。这不是修复失败,而是 committer 仍保留旧值。要完整修正提交身份,就要同时处理 author 和 committer。

先确认 Git 当前会使用什么身份,以及配置来自哪里:

git config --show-origin --get-regexp '^user\.(name|email)$'
git var GIT_AUTHOR_IDENT
git var GIT_COMMITTER_IDENT

如果只有最近一次、尚未推送的提交有问题,设置好正确身份后直接 amend 即可:

git commit --amend --reset-author --no-edit
git log -1 --format=fuller

这同样会生成新的提交 ID,但影响范围只到当前提交。错误已经进入多条历史或推送到远端时,才需要下面的历史改写流程。

我最终采用的单分支修复流程

历史改写会改变提交 ID,也会让正常 push 变成 non-fast-forward。动手前,我先确认工作区没有尚未提交的已跟踪修改,并取得最新远端状态:

git status --short
git fetch --prune origin

我把准备覆盖的远端 main 当前值记在仓库根目录。这个文件只用于本次操作,不提交:

git rev-parse origin/main | Set-Content -LiteralPath '.\before-identity-rewrite-main.txt'

接着创建临时 mailmap。左边是正确身份,右边是历史里的错误身份:

Set-Content -LiteralPath '.\identity-rewrite.mailmap' -Value 'CORRECT_USER <CORRECT_EMAIL> WRONG_USER <WRONG_EMAIL>'

这种四段写法会同时匹配旧名字和旧邮箱,再替换成规范名字与邮箱,语法可以在 Git mailmap 文档中核对。然后只改写真正受影响的分支:

uvx --from git-filter-repo git-filter-repo --force --mailmap .\identity-rewrite.mailmap --refs main

这里的 --refs main 不能省。它把历史改写限制在 main,同时意味着这是一次 partial rewrite。git-filter-repo 文档明确说明,partial rewrite 会保留 origin 和旧的远端跟踪引用,所以这套流程不需要重新添加远端。

改写后,我只检查目标分支,不使用 --all

git rev-list --count --author='^WRONG_USER <WRONG_EMAIL>$' main
git rev-list --count --committer='^WRONG_USER <WRONG_EMAIL>$' main
git fsck --full

前两个计数应该都是 0。partial rewrite 会故意保留未改写的旧引用;如果此时对 --all 计数,可能把那些引用里的旧身份也算进来,反而误判结果。

最后我用改写前保存的远端提交 ID 做精确 lease:

git push "--force-with-lease=refs/heads/main:$((Get-Content -LiteralPath '.\before-identity-rewrite-main.txt' -Raw).Trim())" origin main

这不是普通的 --forcegit push 文档--force-with-lease=&lt;ref&gt;:&lt;expect&gt; 的定义是:只有远端分支仍指向明确给出的期望值时才允许覆盖。只要有人在我改写期间推送了新提交,这条命令就会被拒绝,不需要靠反复 fetch 和人工比较来碰运气。

推送完成后再看一次远端分支:

git ls-remote --heads origin main

两个临时文件都不提交。确认托管平台、CI 和部署使用的新历史正常后即可删除。

我踩到的真正危险是漏写 --refs

第一次处理较复杂的仓库时,我只想着“mailmap 只会替换匹配到的身份”,因此没有限制引用范围。这个理解漏掉了一层:身份匹配决定一条提交改什么,引用范围决定哪些历史会进入整次导出和导入。

省略 --refs 后,git-filter-repo 默认面对的是仓库中的全部本地引用。某个分支即使没有错误身份,只要它与被修改的祖先相连,后续提交 ID 仍可能变化。结果就是本来不准备处理的本地分支也被卷进改写。

所以我后来不再根据“哪些分支看起来相关”猜范围,而是直接把准备改写和推送的分支写进命令:

uvx --from git-filter-repo git-filter-repo --force --mailmap .\identity-rewrite.mailmap --refs main release

目标只有 main 就只写 main;确实有两个受影响的远端分支才写两个。范围越明确,验证和推送越容易对上。

有子模块时,不能只修两边的身份

子模块让这件事多了一层。父仓库并不保存子模块文件,而是在 mode 为 160000 的 gitlink 中保存一个子仓库提交 ID。子仓库历史改写后,提交 ID 会变化;如果父仓库只做 mailmap 改写,旧历史里的 gitlink 仍可能指向已经被替换的子仓库提交。

我的处理顺序是先改子仓库,再用它生成的 commit map 改父仓库。

先在子模块中记录各远端分支的旧值,并确保要处理的远端分支都有同名本地分支:

git fetch --prune origin
git branch release origin/release
git rev-parse origin/main | Set-Content -LiteralPath '.\before-identity-rewrite-main.txt'
git rev-parse origin/release | Set-Content -LiteralPath '.\before-identity-rewrite-release.txt'
Set-Content -LiteralPath '.\identity-rewrite.mailmap' -Value 'CORRECT_USER <CORRECT_EMAIL> WRONG_USER <WRONG_EMAIL>'

只改写这两个分支:

uvx --from git-filter-repo git-filter-repo --force --mailmap .\identity-rewrite.mailmap --refs main release

git-filter-repo 会把旧提交到新提交的对应关系写进 .git/filter-repo/commit-map。我把它复制到父仓库根目录,仍然不提交:

Copy-Item -LiteralPath (Join-Path (git rev-parse --git-dir) 'filter-repo\commit-map') -Destination '..\submodule-commit-map.txt'

回到父仓库后,用同一份 mailmap 修复身份,同时根据 commit map 替换 path/to/submodule 的 gitlink:

uvx --from git-filter-repo git-filter-repo --force --mailmap .\identity-rewrite.mailmap --file-info-callback 'mapping = value.data.get(b"submodule-map"); mapping = mapping if mapping is not None else dict(line.split() for line in open("submodule-commit-map.txt", "rb").read().splitlines()[1:]); value.data[b"submodule-map"] = mapping; blob_id = mapping.get(blob_id, blob_id) if filename == b"path/to/submodule" and mode == b"160000" else blob_id; return (filename, mode, blob_id)' --refs main

git-filter-repo 更新子模块 hash 的官方示例也是通过 --file-info-callback 替换 gitlink。这里不能手写当前的一个 hash,因为父仓库历史里可能记录过很多个子模块版本。

父仓库改写完成后,我会遍历 main 历史中的子模块指针,确认每一个都能在改写后的子仓库中解析。命令无输出才算通过:

git log main --format='%H' -- path/to/submodule | ForEach-Object { (git ls-tree $_ path/to/submodule).Split()[2] } | Sort-Object -Unique | ForEach-Object { git -C path/to/submodule cat-file -e "$_^{commit}"; if ($LASTEXITCODE -ne 0) { throw "无法解析子模块提交:$_" } }

推送顺序也不能反:先用各自保存的精确 lease 推送子仓库分支,确认新提交已经存在;再推父仓库。否则父仓库会先公开一批暂时无法取得的子模块引用。

其他设备不要直接 pull

执行改写的工作区已经位于新历史,不需要再 reset。其他设备上的旧克隆则不能直接 git pull,否则可能把新旧两套历史重新合并。

如果工作区干净,也没有未推送提交,我会先保留一个本地旧分支,再对齐远端:

git status --short
git branch backup/main-before-identity-rewrite-YYYYMMDD main
git fetch --prune origin
git switch main
git reset --hard origin/main
git log -1 --format=fuller

有未提交修改时先 git stash push -u。有未推送提交时,先用下面的命令记下它们,不要直接 reset:

git log origin/main..main --oneline

对齐新 main 后,再把仍然需要的提交逐个 cherry-pick 回来。

这次修复留下的边界

重写身份并不是修改托管平台上的一条展示记录,而是在重建 Git 历史。从第一个发生变化的提交开始,后代提交 ID 都会跟着改变。依赖旧 SHA 的链接、构建记录、部署配置、签名和未合并分支,都要单独检查。

因此我不会把这套命令当成日常整理工具。先把新提交规则修正,旧历史是否值得改写,再看错误提交的数量、仓库协作人数以及外部系统对旧 SHA 的依赖。决定改写时,真正保护我的也不是命令有多长,而是三个明确边界:只改指定 refs,只推指定分支,只在远端仍等于事前记录值时覆盖。