搜索 K
主题
git filter-branch 是 Git 提供的一个强大命令,用于批量重写仓库的提交历史。它可以修改提交中的文件内容、提交信息、作者信息等,常用于从历史记录中彻底删除敏感文件、修正提交者身份、拆分子目录为独立仓库等场景。
Git 官方强烈不推荐使用 filter-branch,因为它存在大量潜在陷阱,可能导致意料之外的历史篡改,且性能极差。官方推荐使用 git filter-repo 作为替代工具。
如果你仍需使用 filter-branch,请务必仔细阅读本文的 安全注意事项 部分。
git filter-branch [--setup <command>] [--subdirectory-filter <directory>]
[--env-filter <command>] [--tree-filter <command>]
[--index-filter <command>] [--parent-filter <command>]
[--msg-filter <command>] [--commit-filter <command>]
[--tag-name-filter <command>] [--prune-empty]
[--original <namespace>] [-d <directory>] [-f | --force]
[--state-branch <branch>] [--] [<rev-list-options>…]filter-branch 的工作原理是:遍历指定范围内的每一个提交,依次通过一系列「过滤器」(filter)对提交进行变换,最终生成一组全新的提交来替换原始历史。
过滤器按上图从左到右的顺序依次执行。每个过滤器都是可选的,你可以只使用其中一个或多个的组合。
了解 filter-branch 的内部机制有助于理解报错和调试。
refs/original/ 备份机制 执行前,filter-branch 会将所有即将被重写的引用备份到 refs/original/ 命名空间下:
refs/heads/main → refs/original/refs/heads/main
refs/heads/develop → refs/original/refs/heads/develop
refs/tags/v1.0 → refs/original/refs/tags/v1.0这意味着:
git reset --hard refs/original/refs/heads/main 回滚refs/original/ 存在时再次执行会报错(需要 -f 强制覆盖)refs/original/ 并执行 git gc 后,旧对象才会被真正清除filter-branch 使用 -d 指定的目录(默认为 .git-rewrite/)存放临时文件:
| 子目录/文件 | 用途 |
|---|---|
t/ | --tree-filter 的工作目录(每个提交检出到此处) |
map/ | 旧提交 SHA → 新提交 SHA 的映射文件 |
state.branch | --state-branch 保存的增量状态 |
将 -d 指向 tmpfs / RAM disk 可以显著提升 --tree-filter 的速度,因为避免了磁盘 I/O:
# Linux:使用 tmpfs
git filter-branch -d /dev/shm/git-rewrite --tree-filter '...' HEAD即使只修改了提交 A 的内容,由于 B 的父提交 SHA 变了,B 的 SHA 也会改变,进而导致 C 的 SHA 改变。这就是为什么 filter-branch 会让整条链上所有后续提交的 SHA 都发生变化。
重写与作者/提交者相关的环境变量(姓名、邮箱、时间戳),在创建提交之前执行。
# 修正错误的作者邮箱
git filter-branch --env-filter '
if test "$GIT_AUTHOR_EMAIL" = "root@localhost"
then
GIT_AUTHOR_EMAIL=john@example.com
fi
if test "$GIT_COMMITTER_EMAIL" = "root@localhost"
then
GIT_COMMITTER_EMAIL=john@example.com
fi
' -- --allGIT_AUTHOR_NAME / GIT_AUTHOR_EMAIL / GIT_AUTHOR_DATEGIT_COMMITTER_NAME / GIT_COMMITTER_EMAIL / GIT_COMMITTER_DATE修改后需要 export 才能生效。
在检出的工作目录上执行命令,可以增删改文件。这是最直观但也最慢的过滤器,因为每个提交都需要完整检出。
# 从所有提交中删除某个文件
git filter-branch --tree-filter 'rm -f passwords.txt' HEAD
# 批量重命名文件
git filter-branch --tree-filter '
if [ -f old_name.txt ]; then
mv old_name.txt new_name.txt
fi
' HEADrm -f 而非 rm务必使用 rm -f(强制删除),因为目标文件可能并不存在于每个提交中。使用 rm 在文件不存在时会报错并中断操作。
直接操作 Git 索引(index),无需检出文件树,速度比 --tree-filter 快得多。
# 从所有提交的索引中移除文件(推荐方式)
git filter-branch --index-filter \
'git rm --cached --ignore-unmatch passwords.txt' HEAD--index-filter vs --tree-filter两者都能从历史中删除文件,但 --index-filter 跳过了文件检出和写回的步骤,在大仓库中速度差异可达 10 倍以上。 优先使用 --index-filter。
从 stdin 读取原始提交信息,stdout 输出新的提交信息。
# 删除所有提交信息中的 git-svn-id 行
git filter-branch --msg-filter '
sed -e "/^git-svn-id:/d"
' -- --all
# 在最近 10 个提交中追加签名信息
git filter-branch --msg-filter '
cat &&
echo "Acked-by: Zhang San <zhangsan@example.com>"
' HEAD~10..HEAD重写提交的父子关系。从 stdin 读取父提交字符串,stdout 输出新的父提交字符串。
# 让某个提交变成根提交(嫁接)
git filter-branch --parent-filter \
'test $GIT_COMMIT = <commit-hash> && echo "" || cat' HEAD这个过滤器比较底层,一般配合 git replace 或 grafts 使用。大多数场景不需要直接使用它。
最强大的过滤器,直接替换 git commit-tree 命令。可用于跳过提交、拆分提交等高级操作。
# 跳过指定作者的所有提交
git filter-branch --commit-filter '
if [ "$GIT_AUTHOR_NAME" = "Bad Author" ];
then
skip_commit "$@";
else
git commit-tree "$@";
fi
' HEADmap — 将旧提交 SHA 映射到新提交 SHAskip_commit — 跳过当前提交,将其变更合入父提交将仓库历史重写为以指定子目录为根目录,丢弃所有其他文件的历史。常用于将 monorepo 中的某个子项目拆分为独立仓库。
# 将 src/frontend 提取为独立仓库
git filter-branch --subdirectory-filter src/frontend -- --all对指向被重写提交的标签进行重命名。
# 保持标签名不变(但指向新提交)
git filter-branch --tag-name-filter cat -- --all
# 给所有标签添加前缀
git filter-branch --tag-name-filter 'echo "old-$(cat)"' -- --all标签的签名(GPG signature)会被自动移除。标签的消息、作者、时间戳也不会被保留。
| 选项 | 说明 |
|---|---|
--prune-empty | 过滤后如果某个提交不再改变任何文件(空提交),自动跳过 |
--original <namespace> | 指定原始引用的备份命名空间,默认 refs/original |
-d <directory> | 指定临时工作目录 |
-f / --force | 强制执行(忽略已存在的备份引用) |
--state-branch <branch> | 将映射关系保存到指定分支,用于增量重写 |
--setup <command> | 在所有过滤器之前执行一次的初始化命令 |
-- --all | 重写所有分支和标签(-- 分隔 filter-branch 选项和 rev-list 选项) |
# 使用 index-filter(推荐,速度快)
git filter-branch --force --index-filter \
'git rm --cached --ignore-unmatch config/secrets.yml' \
--prune-empty --tag-name-filter cat -- --all
# 清理备份引用
git for-each-ref --format='%(refname)' refs/original/ | \
xargs -n 1 git update-ref -d
# 强制垃圾回收
git reflog expire --expire=now --all
git gc --prune=now --aggressive执行后所有提交的 SHA 都会改变。如果仓库已推送到远程,需要 git push --force --all 强制推送,并通知所有协作者重新 clone。
git filter-branch --env-filter '
OLD_EMAIL="wrong@email.com"
CORRECT_NAME="正确的名字"
CORRECT_EMAIL="correct@email.com"
if [ "$GIT_COMMITTER_EMAIL" = "$OLD_EMAIL" ]
then
export GIT_COMMITTER_NAME="$CORRECT_NAME"
export GIT_COMMITTER_EMAIL="$CORRECT_EMAIL"
fi
if [ "$GIT_AUTHOR_EMAIL" = "$OLD_EMAIL" ]
then
export GIT_AUTHOR_NAME="$CORRECT_NAME"
export GIT_AUTHOR_EMAIL="$CORRECT_EMAIL"
fi
' --tag-name-filter cat -- --all# 1. 克隆原仓库
git clone --no-hardlinks original-repo new-repo
cd new-repo
# 2. 提取子目录
git filter-branch --subdirectory-filter path/to/subdir -- --all
# 3. 清理并推送
git remote set-url origin git@github.com:user/new-repo.git
git push -u origin mainCannot create a new backup,怎么办?说明之前已经执行过一次 filter-branch,备份引用 refs/original/ 已存在。有两种处理方式:
-f(--force)强制覆盖:git filter-branch -f ...git for-each-ref --format='%(refname)' refs/original/ | xargs -n 1 git update-ref -d由于所有提交 SHA 都已改变,协作者需要:
git clone切勿直接 git pull,否则会将新旧历史合并,导致混乱。
filter-branch 和 rebase -i 有什么区别?| 维度 | filter-branch | rebase -i |
|---|---|---|
| 作用范围 | 可重写全部历史(所有分支和标签) | 通常重写局部历史(某个提交之后) |
| 修改能力 | 文件内容、作者信息、提交信息、父子关系 | 主要是提交的顺序、合并、拆分 |
| 典型场景 | 删除敏感文件、修正全局作者 | 整理最近几个提交、squash |
可以。将 -- --all 替换为具体的分支名即可:
# 只重写 main 分支
git filter-branch --tree-filter 'rm -f secret.txt' main
# 只重写某个范围
git filter-branch --msg-filter 'sed ...' abc123..HEAD| 目标 | 命令 |
|---|---|
| 删除文件(快速) | git filter-branch --index-filter 'git rm --cached --ignore-unmatch <file>' HEAD |
| 删除文件(直观) | git filter-branch --tree-filter 'rm -f <file>' HEAD |
| 修改作者邮箱 | git filter-branch --env-filter 'if ...; then GIT_AUTHOR_EMAIL=...; fi' -- --all |
| 修改提交信息 | git filter-branch --msg-filter 'sed ...' -- --all |
| 提取子目录 | git filter-branch --subdirectory-filter <dir> -- --all |
| 跳过空提交 | 加 --prune-empty |
| 重写所有分支 | 末尾加 -- --all |
| 强制覆盖备份 | 加 -f |
| 清理备份引用 | git for-each-ref --format='%(refname)' refs/original/ | xargs -n 1 git update-ref -d |
| 强制 GC | git reflog expire --expire=now --all && git gc --prune=now --aggressive |
以上所有操作都可以用 git filter-repo 更简洁、更安全地完成。详见 git filter-repo。
git clone 一份副本,在副本上执行 filter-branchfilter-branch 会自动将原始引用保存到 refs/original/,不要过早清除filter-branch 不会重写子模块引用中的 SHAgit filter-repo 是官方推荐的 filter-branch 替代工具,具有以下优势:
| 对比项 | filter-branch | filter-repo |
|---|---|---|
| 速度 | 慢(逐个检出提交) | 快(基于 fast-export/fast-import) |
| 安全性 | 多种已知陷阱 | 设计上规避了常见问题 |
| 易用性 | 复杂的 shell 命令 | 简洁的 Python API 和 CLI |
| 官方态度 | 已弃用 | 推荐使用 |
# 安装
pip install git-filter-repo
# 等效操作:删除敏感文件
git filter-repo --invert-paths --path config/secrets.yml
# 等效操作:修改作者邮箱
git filter-repo --mailmap mailmap.txt--dry-run 思路验证 filter-branch 本身没有 --dry-run 选项,但可以用以下方式安全测试:
# 1. 在临时分支上测试
git checkout -b test-filter
git filter-branch --tree-filter 'rm -f secret.txt' test-filter
# 2. 验证结果
git log --oneline test-filter
git diff main..test-filter --stat
# 3. 满意后再应用到 main
git checkout main
git filter-branch -f --tree-filter 'rm -f secret.txt' main
# 4. 清理测试分支
git branch -D test-filter# 在 tree-filter 中添加调试输出
git filter-branch --tree-filter '
echo "处理提交: $GIT_COMMIT"
echo "当前文件列表:"
ls -la
rm -f secret.txt
' HEADrefs/original/ 回滚 # 查看备份的原始引用
git for-each-ref refs/original/
# 回滚到 filter-branch 之前的状态
git reset --hard refs/original/refs/heads/main一旦清理了 refs/original/ 并执行了 git gc,就无法回滚了。在确认结果正确之前,不要清理备份引用。
| 工具 | 适用场景 | 重写范围 |
|---|---|---|
git rebase -i | 整理最近几个提交(squash、reorder、edit) | 局部(某个提交之后) |
git commit --amend | 修改最后一个提交的信息或内容 | 单个提交 |
git filter-branch | 批量重写全部历史(已弃用) | 全局(所有分支) |
| git filter-repo | 批量重写全部历史(推荐) | 全局(所有分支) |
git replace | 临时替换某个对象,不改变 SHA | 单个对象(透明替换) |
| BFG Repo Cleaner | 删除大文件/敏感数据(Java 工具) | 全局(所有分支) |
rebase -icommit --amendgit replace从 SVN 迁移到 Git 后,每个提交信息末尾都有 git-svn-id 行,需要批量清理。
# 删除所有提交信息中的 git-svn-id 行
git filter-branch --msg-filter '
sed -e "/^git-svn-id:/d" -e "/^$/N;/^\n$/d"
' --tag-name-filter cat -- --all
# 同时修正 SVN 迁移留下的作者信息
git filter-branch --env-filter '
case "$GIT_AUTHOR_EMAIL" in
svnuser1) export GIT_AUTHOR_EMAIL="alice@company.com"
export GIT_AUTHOR_NAME="Alice" ;;
svnuser2) export GIT_AUTHOR_EMAIL="bob@company.com"
export GIT_AUTHOR_NAME="Bob" ;;
esac
# committer 同步修改
export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
' --tag-name-filter cat -- --all使用 git filter-repo 更简洁:
git filter-repo --message-callback '
import re
return re.sub(rb"\ngit-svn-id:.*\n?", b"", message)
' --mailmap authors.txt# 企业合规要求清理历史中的所有敏感文件
git filter-branch --force --index-filter '
git rm --cached --ignore-unmatch \
"*.pem" "*.key" "*.p12" "*.pfx" \
".env" ".env.*" \
"**/credentials.json" \
"**/secrets.yml" \
"**/application-prod.properties"
' --prune-empty --tag-name-filter cat -- --all
# 清理后验证
git log --all --diff-filter=A -- "*.pem" "*.key" ".env"
# 应该无输出# 将 monorepo 中的 packages/auth 拆分为独立仓库
# 同时保留该目录下文件的完整 git log 历史
# 1. 克隆副本
git clone --no-hardlinks monorepo auth-service
cd auth-service
# 2. 提取子目录(自动将其变为根目录)
git filter-branch --subdirectory-filter packages/auth \
--prune-empty --tag-name-filter cat -- --all
# 3. 清理不相关的标签
git tag -l | while read tag; do
# 只保留 auth 相关的标签
if ! git log --oneline "$tag" -1 2>/dev/null | grep -qi "auth"; then
git tag -d "$tag"
fi
done
# 4. 清理并推送
git for-each-ref --format='%(refname)' refs/original/ | \
xargs -n 1 git update-ref -d
git reflog expire --expire=now --all
git gc --prune=now --aggressive
git remote set-url origin git@github.com:team/auth-service.git
git push -u origin --all
git push origin --tags# 某段时间内的提交时区错误(如服务器时区设置不当)
git filter-branch --env-filter '
# 将 UTC 时间修正为 +0800
if [ "$GIT_AUTHOR_DATE" ] && echo "$GIT_AUTHOR_DATE" | grep -q "+0000"; then
export GIT_AUTHOR_DATE=$(echo "$GIT_AUTHOR_DATE" | sed "s/+0000/+0800/")
export GIT_COMMITTER_DATE=$(echo "$GIT_COMMITTER_DATE" | sed "s/+0000/+0800/")
fi
' --tag-name-filter cat -- --all| 仓库规模 | tree-filter | index-filter | filter-repo |
|---|---|---|---|
| 1K 提交 | ~30 秒 | ~5 秒 | ~1 秒 |
| 10K 提交 | ~5 分钟 | ~45 秒 | ~5 秒 |
| 100K 提交 | ~1 小时 | ~8 分钟 | ~30 秒 |
| 1M 提交 | ~10 小时+ | ~1.5 小时 | ~5 分钟 |
以上为粗略估算值,实际性能取决于提交中的文件数量、磁盘速度等因素。
# 1. 优先使用 index-filter 而非 tree-filter
# ❌ 慢
git filter-branch --tree-filter 'rm -f secret.txt' HEAD
# ✅ 快 10 倍
git filter-branch --index-filter \
'git rm --cached --ignore-unmatch secret.txt' HEAD
# 2. 将临时目录放在 RAM disk(Linux)
git filter-branch -d /dev/shm/git-rewrite \
--tree-filter 'rm -f large-file.bin' HEAD
# 3. 缩小重写范围
# ❌ 重写所有分支
git filter-branch --index-filter '...' -- --all
# ✅ 只重写需要的分支
git filter-branch --index-filter '...' main develop
# 4. 增量重写(--state-branch)
# 用于超大仓库分批处理
git filter-branch --state-branch filter-state \
--index-filter '...' HEAD~1000..HEAD--tree-filter 中的 shell 陷阱# ❌ 文件名含空格或特殊字符时出错
git filter-branch --tree-filter 'rm -f "my file.txt"' HEAD
# ❌ 通配符可能匹配到意外的文件
git filter-branch --tree-filter 'rm -f *.log' HEAD
# 如果某个提交的根目录有 important.log...
# ✅ 使用 find + 精确匹配
git filter-branch --tree-filter '
find . -name "*.log" -not -path "./important/*" -delete
' HEAD
# ✅ 更好:使用 index-filter 避免这些问题
git filter-branch --index-filter \
'git rm --cached --ignore-unmatch "*.log"' HEAD# filter-branch 不会更新 .gitmodules 中的 SHA
# 如果仓库包含子模块,filter-branch 后子模块引用会断开
# ✅ 解决方案:filter-branch 后手动更新子模块
git submodule sync
git submodule update --init --recursive
# 或者在 tree-filter 中处理
git filter-branch --tree-filter '
if [ -f .gitmodules ]; then
# 更新子模块路径或 URL
sed -i "s|old/path|new/path|g" .gitmodules
fi
' HEAD# filter-branch 重写标签时会自动移除 GPG 签名
# 不会有任何警告!
# 重写后检查标签
git tag -v v1.0
# error: could not verify the tag 'v1.0'
# ✅ 重写后需要重新签名标签
git tag -s -f v1.0 -m "$(git tag -l --format='%(contents)' v1.0)"--prune-empty 可能删掉有意义的空提交# merge commit 在 filter 后可能变成空提交
# --prune-empty 会删掉它们,导致分支拓扑改变
# ✅ 如果需要保留 merge 拓扑,不要使用 --prune-empty
# 或者在清理后手动检查
git log --oneline --graph | head -50# Windows 上 tree-filter 的 shell 命令行为可能不同
# rm、find、sed 等命令可能不存在或行为不同
# ✅ 在 Windows 上使用 Git Bash 运行 filter-branch
# ✅ 或者直接使用 filter-repo(跨平台 Python)# 1. 在团队通讯工具中发布公告
# 内容包括:
# - 重写原因
# - 预计执行时间
# - 对协作者的影响
# - 操作步骤
# 2. 确认所有人的未推送提交已保存
# 让团队成员推送所有本地分支
# 3. 冻结仓库(如果平台支持)
# GitHub: Settings → Branch protection → Lock branch
# GitLab: Settings → Repository → Protected branches# === 协作者操作指南 ===
# 1. 备份本地未推送的更改
git stash
git format-patch origin/main..HEAD -o /tmp/my-patches/
# 2. 删除旧仓库,重新克隆
cd ..
rm -rf old-repo
git clone git@github.com:team/repo.git
cd repo
# 3. 重新应用本地更改
git am /tmp/my-patches/*.patch
# 如果 patch 冲突,手动解决
# ⚠️ 绝对不要 git pull!
# 否则会合并新旧两套历史,产生灾难性后果