改写历史的核心动作只有三个:commit --amend(改最近一次)、rebase -i(改任意一段)、filter-repo(批改全历史)。日常代码评审里"补一行到三次前的 commit",配合 --fixup / --autosquash 不必再手动开 rebase 菜单。

改写历史会重算 SHA,只在自己未推送、或确认没人基于该分支工作时做。已 push 到 protected branch 才发现要改的,先看末尾"安全网"一节再动手。

改最近一次提交

改提交说明

git commit --amend              # 进编辑器
git commit --amend -m "msg"     # 一行写完
git commit --amend --no-edit    # 只追加文件,不改说明

追加 / 替换文件

git add fixed.cpp
git commit --amend --no-edit

从最近一次提交中移除某个文件

不要用 git reset HEAD~1 —— 那会把整个提交炸开,其余文件的暂存状态也丢了。正确做法是只把目标文件在 index 里还原到上一版,再 amend:

git restore --source=HEAD~ --staged -- path/to/file   # index 回到上一版
git commit --amend --no-edit                          # 重新打包

如果还想丢掉工作区里的修改,再加 --worktree

git restore --source=HEAD~ --staged --worktree -- path/to/file

改一段提交:交互式 rebase

git rebase -i HEAD~3        # 改最近 3 次
git rebase -i --root        # 改到根提交(含第一次 commit)

打开的 todo 列表里,最早的提交在最上面(和 git log 相反),改第一列动词即可:

命令作用
pick (p)保留
reword (r)保留 commit,只改说明
edit (e)停在这里,让你 amend 或拆分
squash (s)并入上一行,合并 message
fixup (f)并入上一行,丢弃本条 message
drop (d)删除(直接删行也行)
exec (x)在该提交点执行 shell

调整顺序:直接挪行。

拆分一个提交

todo 改为:

edit  bb199a0  Update the version

rebase 暂停时执行:

git reset HEAD^           # 把这次提交的改动还原回工作区
git add file1 && git commit -m "Update version: bump"
git add file2 && git commit -m "Update version: changelog"
git rebase --continue

fixup + autosquash:无菜单 rebase

刚 review 完,发现 3 次前的 commit 漏写了校验逻辑。传统做法是打开 rebase 菜单挑出对应行手动 edit--fixup 把这一步自动化:

git add validator.go
git commit --fixup=<old-sha>           # 生成 "fixup! <old subject>"
git rebase -i --autosquash <old-sha>^  # 自动把 fixup! 行排到 old commit 后,并标 fixup

写进配置就不用每次加 --autosquash

git config --global rebase.autosquash true
git config --global commit.verbose true   # 顺带:让 commit 编辑器里看到 diff

改作者 / 邮箱

最新一笔:amend

漏配 user.name 就提交了?最新一笔用 amend 修:

git config --global user.name "Jiang Xin"
git commit --amend --allow-empty --reset-author
  • --allow-empty:允许空白提交(修补后无文件变化时需要)。
  • --reset-author:同时重置 Author 与 AuthorDate;不加只会改 Committer。

Git 不做提交者身份认证,“谁都能写任意 name/email” 是分布式特性。权限控制交给 GitLab / GitHub 等服务端。

全历史:先 mailmap,必要时 filter-repo

amend 只管最新一笔。历史上一千个用旧邮箱提的 commit 它够不着 —— 两种正解,先试 mailmap

用法 1:mailmap,不动历史只改显示

仓库根目录建 .mailmap,告诉 Git “看到旧身份就当新身份显示”:

# 前是新身份,后是旧身份,空格分隔
New Name <new@gmail.com>  Old Name <old@qq.com>   # 名 + 邮箱完整匹配
New Name <new@gmail.com>  <old@qq.com>            # 只按旧邮箱匹配,旧名任意

之后这些命令自动按映射显示:

git log --use-mailmap
git shortlog -sn                # 默认就 honor mailmap

用法 2:filter-repo,真改写历史

需要 commit 字段本身也变(审计、合规、对外开源前清洗),上 git filter-repo

git filter-repo --mailmap .mailmap

早年的 git filter-branch 官方已弃用 —— 每 commit 起一个 shell 子进程,大仓库小时级别,且默认保留 refs/original/ 导致旧对象"清理完又复活"。filter-repo 是 Python 单进程流式,快 10–100 倍。

两者对比

维度用法 1:mailmap用法 2:filter-repo
改的是显示,原 commit 不动commit 字段本身
commit SHA不变全变
协作者成本零(pull 即可)必须重 clone 或硬 reset
撤销.mailmap 即可不可逆(除非备份)
第三方工具识别GitHub 认;审计工具未必永远认

.mailmap 正好就是 filter-repo --mailmap 吃的输入 —— 先写 mailmap 跑用法 1,确认效果再决定要不要上用法 2。一份配置覆盖两种场景。

实操辅助

查看当前所有作者:

git log --format='%aN <%aE>' | sort -u

filter-repo 改完一定要 force push,并通知所有 collaborator 重新 clone —— 他们本地的旧 SHA 在远程已不存在,再 push 会把旧链子带回来:

git push --force-with-lease --tags    # 比 -f 安全:远程被别人推过会拒绝

安全网

  • 改坏了能找回git reflog 列出 HEAD 最近 90 天的每次跳转,git reset --hard HEAD@{3} 回到任意一步。reflog 是本地的,没 push 也照样能用。
  • 强推首选 --force-with-lease:上游被别人 push 过时会拒绝覆盖,避免顺手抹掉同事的提交。
  • 保护分支兜底:GitHub / GitLab 默认提供 protected branch;本地也可用 pre-push hook 拒绝任何对 main 的非 fast-forward 推送。

References

– EOF –