跳转到内容
关系图谱

Git filter-branch 详解

4,439 字 17 分钟

属性 1
status
学习中

Git filter-branch 详解

git filter-branch 是 Git 提供的一个强大命令,用于批量重写仓库的提交历史。它可以修改提交中的文件内容、提交信息、作者信息等,常用于从历史记录中彻底删除敏感文件、修正提交者身份、拆分子目录为独立仓库等场景。

已被官方弃用

Git 官方强烈不推荐使用 filter-branch,因为它存在大量潜在陷阱,可能导致意料之外的历史篡改,且性能极差。官方推荐使用 git filter-repo 作为替代工具。

如果你仍需使用 filter-branch,请务必仔细阅读本文的 安全注意事项 部分。

基本语法

bash
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:

bash
# Linux:使用 tmpfs
git filter-branch -d /dev/shm/git-rewrite --tree-filter '...' HEAD

SHA 级联效应

即使只修改了提交 A 的内容,由于 B 的父提交 SHA 变了,B 的 SHA 也会改变,进而导致 C 的 SHA 改变。这就是为什么 filter-branch 会让整条链上所有后续提交的 SHA 都发生变化

过滤器详解

--env-filter:修改环境变量

重写与作者/提交者相关的环境变量(姓名、邮箱、时间戳),在创建提交之前执行。

bash
# 修正错误的作者邮箱
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
' -- --all
可用环境变量
  • GIT_AUTHOR_NAME / GIT_AUTHOR_EMAIL / GIT_AUTHOR_DATE
  • GIT_COMMITTER_NAME / GIT_COMMITTER_EMAIL / GIT_COMMITTER_DATE

修改后需要 export 才能生效。

--tree-filter:修改文件树

在检出的工作目录上执行命令,可以增删改文件。这是最直观但也最慢的过滤器,因为每个提交都需要完整检出。

bash
# 从所有提交中删除某个文件
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
' HEAD
使用 rm -f 而非 rm

务必使用 rm -f(强制删除),因为目标文件可能并不存在于每个提交中。使用 rm 在文件不存在时会报错并中断操作。

--index-filter:修改索引(推荐)

直接操作 Git 索引(index),无需检出文件树,速度比 --tree-filter 快得多。

bash
# 从所有提交的索引中移除文件(推荐方式)
git filter-branch --index-filter \
    'git rm --cached --ignore-unmatch passwords.txt' HEAD
--index-filter vs --tree-filter

两者都能从历史中删除文件,但 --index-filter 跳过了文件检出和写回的步骤,在大仓库中速度差异可达 10 倍以上。 优先使用 --index-filter

--msg-filter:修改提交信息

从 stdin 读取原始提交信息,stdout 输出新的提交信息。

bash
# 删除所有提交信息中的 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

--parent-filter:修改父提交关系

重写提交的父子关系。从 stdin 读取父提交字符串,stdout 输出新的父提交字符串。

bash
# 让某个提交变成根提交(嫁接)
git filter-branch --parent-filter \
    'test $GIT_COMMIT = <commit-hash> && echo "" || cat' HEAD
Note

这个过滤器比较底层,一般配合 git replace 或 grafts 使用。大多数场景不需要直接使用它。

--commit-filter:替换 commit-tree 命令

最强大的过滤器,直接替换 git commit-tree 命令。可用于跳过提交、拆分提交等高级操作。

bash
# 跳过指定作者的所有提交
git filter-branch --commit-filter '
    if [ "$GIT_AUTHOR_NAME" = "Bad Author" ];
    then
        skip_commit "$@";
    else
        git commit-tree "$@";
    fi
' HEAD
内置辅助函数
  • map — 将旧提交 SHA 映射到新提交 SHA
  • skip_commit — 跳过当前提交,将其变更合入父提交

--subdirectory-filter:提取子目录

将仓库历史重写为以指定子目录为根目录,丢弃所有其他文件的历史。常用于将 monorepo 中的某个子项目拆分为独立仓库。

bash
# 将 src/frontend 提取为独立仓库
git filter-branch --subdirectory-filter src/frontend -- --all

--tag-name-filter:重命名标签

对指向被重写提交的标签进行重命名。

bash
# 保持标签名不变(但指向新提交)
git filter-branch --tag-name-filter cat -- --all

# 给所有标签添加前缀
git filter-branch --tag-name-filter 'echo "old-$(cat)"' -- --all
Warning

标签的签名(GPG signature)会被自动移除。标签的消息、作者、时间戳也不会被保留

常用选项

选项说明
--prune-empty过滤后如果某个提交不再改变任何文件(空提交),自动跳过
--original <namespace>指定原始引用的备份命名空间,默认 refs/original
-d <directory>指定临时工作目录
-f / --force强制执行(忽略已存在的备份引用)
--state-branch <branch>将映射关系保存到指定分支,用于增量重写
--setup <command>在所有过滤器之前执行一次的初始化命令
-- --all重写所有分支和标签(-- 分隔 filter-branch 选项和 rev-list 选项)

实战示例

示例 1:从历史中彻底删除敏感文件

bash
# 使用 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。

示例 2:修正所有提交的作者信息

bash
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

示例 3:拆分子目录为独立仓库

bash
# 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 main

常见问题

执行后报错 Cannot create a new backup,怎么办?

说明之前已经执行过一次 filter-branch,备份引用 refs/original/ 已存在。有两种处理方式:

  • -f--force)强制覆盖:git filter-branch -f ...
  • 手动清理旧备份后重试:
    bash
    git for-each-ref --format='%(refname)' refs/original/ | xargs -n 1 git update-ref -d
执行后远程协作者怎么处理?

由于所有提交 SHA 都已改变,协作者需要:

  1. 备份本地未推送的更改
  2. 删除旧仓库,重新 git clone
  3. 将备份的更改重新应用到新仓库

切勿直接 git pull,否则会将新旧历史合并,导致混乱。

filter-branchrebase -i 有什么区别?
维度filter-branchrebase -i
作用范围可重写全部历史(所有分支和标签)通常重写局部历史(某个提交之后)
修改能力文件内容、作者信息、提交信息、父子关系主要是提交的顺序、合并、拆分
典型场景删除敏感文件、修正全局作者整理最近几个提交、squash
能否只重写某个分支而不影响其他分支?

可以。将 -- --all 替换为具体的分支名即可:

bash
# 只重写 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
强制 GCgit reflog expire --expire=now --all && git gc --prune=now --aggressive
更好的选择

以上所有操作都可以用 git filter-repo 更简洁、更安全地完成。详见 git filter-repo

安全注意事项

使用 filter-branch 前必读
  1. 始终在副本上操作 — 先 git clone 一份副本,在副本上执行 filter-branch
  2. 备份引用filter-branch 会自动将原始引用保存到 refs/original/,不要过早清除
  3. SHA 全部改变 — 所有被重写的提交及其后代提交的 SHA 都会改变,这会影响所有协作者
  4. 子模块问题filter-branch 不会重写子模块引用中的 SHA
  5. 性能极差 — 在大型仓库上可能运行数小时甚至数天

替代方案:git filter-repo

git filter-repo 是官方推荐的 filter-branch 替代工具,具有以下优势:

对比项filter-branchfilter-repo
速度慢(逐个检出提交)快(基于 fast-export/fast-import)
安全性多种已知陷阱设计上规避了常见问题
易用性复杂的 shell 命令简洁的 Python API 和 CLI
官方态度已弃用推荐使用
bash
# 安装
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 选项,但可以用以下方式安全测试:

bash
# 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

查看过滤器执行过程

bash
# 在 tree-filter 中添加调试输出
git filter-branch --tree-filter '
    echo "处理提交: $GIT_COMMIT"
    echo "当前文件列表:"
    ls -la
    rm -f secret.txt
' HEAD

利用 refs/original/ 回滚

bash
# 查看备份的原始引用
git for-each-ref refs/original/

# 回滚到 filter-branch 之前的状态
git reset --hard refs/original/refs/heads/main
回滚窗口

一旦清理了 refs/original/ 并执行了 git gc,就无法回滚了。在确认结果正确之前,不要清理备份引用。

Git 历史重写工具对比

工具适用场景重写范围
git rebase -i整理最近几个提交(squash、reorder、edit)局部(某个提交之后)
git commit --amend修改最后一个提交的信息或内容单个提交
git filter-branch批量重写全部历史(已弃用全局(所有分支)
git filter-repo批量重写全部历史(推荐全局(所有分支)
git replace临时替换某个对象,不改变 SHA单个对象(透明替换)
BFG Repo Cleaner删除大文件/敏感数据(Java 工具)全局(所有分支)
选择建议
  • 改最近几个提交 → rebase -i
  • 改最后一个提交 → commit --amend
  • 批量历史重写 → git filter-repo(首选)
  • 仅临时覆盖某个对象 → git replace

更多实战场景

场景 1:SVN 迁移后清理 svn-id

从 SVN 迁移到 Git 后,每个提交信息末尾都有 git-svn-id 行,需要批量清理。

bash
# 删除所有提交信息中的 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 更简洁:

bash
git filter-repo --message-callback '
    import re
    return re.sub(rb"\ngit-svn-id:.*\n?", b"", message)
' --mailmap authors.txt

场景 2:合规审计——批量删除多种敏感文件

bash
# 企业合规要求清理历史中的所有敏感文件
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"
# 应该无输出

场景 3:拆分 monorepo 保留完整历史

bash
# 将 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

场景 4:修正时区问题

bash
# 某段时间内的提交时区错误(如服务器时区设置不当)
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-filterindex-filterfilter-repo
1K 提交~30 秒~5 秒~1 秒
10K 提交~5 分钟~45 秒~5 秒
100K 提交~1 小时~8 分钟~30 秒
1M 提交~10 小时+~1.5 小时~5 分钟
测试环境

以上为粗略估算值,实际性能取决于提交中的文件数量、磁盘速度等因素。

优化技巧

bash
# 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

常见陷阱

陷阱 1:--tree-filter 中的 shell 陷阱
bash
# ❌ 文件名含空格或特殊字符时出错
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
陷阱 2:子模块引用不会被更新
bash
# 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
陷阱 3:标签签名被静默移除
bash
# 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)"
陷阱 4:--prune-empty 可能删掉有意义的空提交
bash
# merge commit 在 filter 后可能变成空提交
# --prune-empty 会删掉它们,导致分支拓扑改变

# ✅ 如果需要保留 merge 拓扑,不要使用 --prune-empty
# 或者在清理后手动检查
git log --oneline --graph | head -50
陷阱 5:在 Windows 上 tree-filter 行为不一致
bash
# Windows 上 tree-filter 的 shell 命令行为可能不同
# rm、find、sed 等命令可能不存在或行为不同

# ✅ 在 Windows 上使用 Git Bash 运行 filter-branch
# ✅ 或者直接使用 filter-repo(跨平台 Python)

团队协作注意事项

重写前的沟通

bash
# 1. 在团队通讯工具中发布公告
# 内容包括:
# - 重写原因
# - 预计执行时间
# - 对协作者的影响
# - 操作步骤

# 2. 确认所有人的未推送提交已保存
# 让团队成员推送所有本地分支

# 3. 冻结仓库(如果平台支持)
# GitHub: Settings → Branch protection → Lock branch
# GitLab: Settings → Repository → Protected branches

重写后的恢复流程

bash
# === 协作者操作指南 ===

# 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!
# 否则会合并新旧两套历史,产生灾难性后果

相关命令

参考资料