在 Git 仓库的生命周期中,几乎每个团队都会遇到需要重写历史的场景:不小心提交了数据库密码、某次 commit 带进了一个 500MB 的二进制文件、或者需要将一个巨型 monorepo 拆分成多个独立仓库。过去,我们唯一的工具是
1 | git filter-branch |
,但它以”慢到令人绝望”和”容易出错”而闻名。Git 官方后来推荐了一个第三方工具——git-filter-repo,它由 Git 官方文档推荐,速度快几十倍,安全性也大幅提升。本文将深入讲解 git filter-repo 的安装、核心原理、典型用法和避坑指南。
一、为什么需要 git filter-repo
1 | git filter-branch |
是 Git 自带的历史重写工具,但它在实际使用中有三个致命问题:
- 性能极差:filter-branch 会对每个提交逐一 checkout 工作树再执行你的脚本,一个有 10000 个提交的仓库可能需要数小时。
- 状态不安全:filter-branch 默认不清理原始引用(refs/original/),如果不手动删除,重写后的历史和旧历史会混在一起,导致混乱。
- 脚本编写复杂:你需要写 shell 脚本来操作文件树,很容易出错且难以测试。
Git 官方在
1 | git filter-branch |
的帮助页面中直接加了一条警告:
1
2
3
4 WARNING: git filter-branch has a glut of gotchas generating mangled history
rewrites. Hit Ctrl-C before proceeding. Abandoning this tool and instead
using an alternative history filtering tool such as 'git filter-repo' is
recommended.
这大概是 Git 自带工具里唯一一个官方劝你别用的命令了。git-filter-repo 由 Google 工程师 Elijah Newren 开发,核心特点如下:
| 特性 | git filter-branch | git filter-repo |
|---|---|---|
| 速度 | 极慢(逐提交 checkout) | 极快(批量流式处理) |
| 安全性 | 需要手动清理 refs/original | 自动移除原 remote,防止误推 |
| 脚本编写 | Shell 脚本 | Python 回调函数 |
| 子模块支持 | 有限 | 原生支持 |
| 大文件处理 | 需要手动脚本 | 内置 –strip-blobs 模式 |
| 输出报告 | 无 | 自动生成 .git/filter-repo/ 报告 |
二、安装 git-filter-repo
git-filter-repo 是一个纯 Python 单文件脚本,安装方式非常灵活。它要求 Git 版本 >= 2.22,Python >= 3.5。
方式一:通过 pip 安装(推荐)
1 pip3 install git-filter-repo
方式二:直接下载单文件
1
2 curl -o /usr/local/bin/git-filter-repo https://raw.githubusercontent.com/newren/git-filter-repo/main/git-filter-repo
chmod +x /usr/local/bin/git-filter-repo
因为文件名是
1 | git-filter-repo |
,Git 会自动把它识别为
1 | git filter-repo |
子命令。安装完成后验证:
1
2 git filter-repo --help
# 如果能看到帮助信息,说明安装成功
三、核心原理:为什么它这么快
理解 filter-repo 的速度优势,需要了解它的内部机制。它不像 filter-branch 那样逐个提交 checkout 工作树,而是直接解析 Git 的 fast-export 流:
1
2
3
4 # filter-repo 的内部流程
git fast-export --all --signed-tags=strip --tag-of-filtered-object=rewrite --replace-refs update-no-add |
python3 git-filter-repo ... |
git fast-import --force
1 | git fast-export |
会将整个仓库历史以一个流式文本格式输出,filter-repo 在 Python 层面逐行解析这个流,应用你的过滤规则后,再通过
1 | git fast-import |
写回。整个过程完全不涉及工作树操作,所以速度有数量级的提升。
另一个关键设计是:filter-repo 在运行结束后会自动移除 origin remote。这是一种安全机制——防止你在检查重写结果之前就不小心把错误的历史 push 到远端。你需要手动重新添加 remote 后才能推送。
四、实战场景一:移除误提交的敏感文件
这是最常见的需求。假设你在项目早期不小心把
1 | .env |
文件提交到了仓库,里面包含数据库密码和 API Key。即使后来删除了,历史记录里依然可以找到。用 filter-repo 彻底清除:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 # 1. 先克隆一个全新的裸仓库(永远在副本上操作!)
git clone --mirror git@github.com:myteam/myproject.git myproject-clean.git
cd myproject-clean.git
# 2. 用 filter-repo 移除 .env 文件的所有历史痕迹
git filter-repo --path .env --invert-paths
# 3. 检查结果
git log --all --oneline | head -20
# 4. 确认无误后,重新添加 remote 并强制推送
git remote add origin git@github.com:myteam/myproject.git
git push origin --force --all
git push origin --force --tags
1 | --invert-paths |
表示”排除匹配的路径”,即从所有历史提交中删除
1 | .env |
文件。如果不加
1 | --invert-paths |
,则表示”只保留匹配的路径”。
移除多个敏感文件
1 git filter-repo --path .env --path config/secrets.yml --path id_rsa --invert-paths
使用路径文件批量移除
如果需要移除的文件很多,可以创建一个文件列表:
1
2
3
4
5
6
7
8 # files-to-remove.txt
.env
config/secrets.yml
id_rsa
*.pem
docker-compose.override.yml
git filter-repo --paths-from-file files-to-remove.txt --invert-paths
五、实战场景二:移除大文件释放仓库空间
随着项目发展,仓库可能因为某些大文件而膨胀。即使文件后来被删除,它们仍然存在于 .git 历史中。filter-repo 提供了专门的
1 | --strip-blobs |
选项:
1
2
3
4
5 # 移除所有大于 10MB 的 blob
git filter-repo --strip-blobs-bigger-than 10M
# 移除特定大文件(已知文件名)
git filter-repo --path giant-video.mp4 --path huge-dataset.csv --invert-paths
运行后,filter-repo 会生成一份报告,告诉你移除了哪些 blob、各 commit 有哪些变化:
1
2
3
4
5 # 查看报告
cat .git/filter-repo/commit-map | head -10
# commit-map 格式:old_sha new_sha
# 如果 new_sha 为全零,说明该提交被完全删除了
结合 BFG 的对比
很多人知道 BFG Repo-Cleaner 也能做大文件清理。两者的区别:
| BFG | git filter-repo | |
|---|---|---|
| 语言 | Scala/Java | Python(单文件) |
| 需要 JVM | 是 | 否 |
| 路径级过滤 | 不支持(只能按文件名/大小) | 完整支持 |
| 提交信息修改 | 仅支持简单替换 | 支持 Python 回调 |
| 密码文件替换 | 内置 | 需要回调函数 |
| 维护状态 | 几乎不更新 | 活跃维护 |
对于简单的大文件移除场景,BFG 和 filter-repo 都可以胜任。但如果需要更复杂的过滤逻辑,filter-repo 是更好的选择。
六、实战场景三:拆分仓库(保留子目录历史)
将 monorepo 中的某个目录拆出来成为独立仓库,同时保留该目录的完整提交历史,这是 filter-repo 的拿手好戏:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 # 假设 monorepo 结构如下:
# /
# ├── frontend/
# ├── backend/
# └── shared/
# 只保留 frontend/ 目录的历史,创建独立仓库
git clone --mirror git@github.com:company/monorepo.git frontend-only.git
cd frontend-only.git
git filter-repo --subdirectory-filter frontend
# 现在 frontend/ 目录的内容变成了仓库根目录
# 所有只涉及 frontend/ 的提交被保留,其他提交被丢弃
ls -la
# 应该能看到 package.json, src/, public/ 等 frontend 目录的内容
1 | --subdirectory-filter |
会将指定子目录提升为根目录,并且只保留”修改过该子目录”的提交。这对微服务拆分非常有用。
保留多个目录
1
2 # 同时保留 frontend/ 和 shared/ 目录
git filter-repo --path frontend/ --path shared/
七、实战场景四:用回调函数实现高级过滤
filter-repo 最强大的功能是通过 Python 回调函数实现任意复杂的过滤逻辑。下面演示一个实际场景:将所有提交中的作者邮箱从公司域名替换为个人域名。
1
2
3
4
5
6
7
8
9
10
11
12
13
14 # replace-email.py
def commit_callback(commit, metadata):
# 替换作者邮箱
if commit.author_email == b'old@company.com':
commit.author_email = b'new@personal.com'
# 替换提交者邮箱
if commit.committer_email == b'old@company.com':
commit.committer_email = b'new@personal.com'
# 替换提交信息中的工号
commit.message = commit.message.replace(b'TICKET-', b'JIRA-')
git_filter_repo.commit_callback = commit_callback
1
2 # 执行
git filter-repo --callback-callback replace-email.py
另一个实用回调:清理提交信息中的敏感词
1
2
3
4
5
6
7
8
9
10
11
12
13
14 # sanitize-messages.py
import re
SENSITIVE_PATTERNS = [
rb'password\s*[:=]\s*\S+',
rb'api[_-]?key\s*[:=]\s*\S+',
rb'secret\s*[:=]\s*\S+',
]
def commit_callback(commit, metadata):
for pattern in SENSITIVE_PATTERNS:
commit.message = re.sub(pattern, b'[REDACTED]', commit.message)
git_filter_repo.commit_callback = commit_callback
回调函数可以访问提交的所有属性:
1 | author_name |
、
1 | author_email |
、
1 | author_date |
、
1 | committer_name |
、
1 | committer_email |
、
1 | committer_date |
、
1 | message |
、
1 | branch |
、
1 | parents |
等。你可以根据需要自由修改。
八、实战场景五:替换文件内容
有时候你不需要删除文件,而是需要替换文件中的某些敏感内容。比如把所有历史版本中的某段密钥替换掉:
1
2
3
4
5
6 # 使用 --replace-text 进行内容替换
# replacements.txt 文件格式:
# 密钥值==>****REDACTED****
# old-internal-hostname.com==>internal.example.com
git filter-repo --replace-text replacements.txt
1 | --replace-text |
会扫描所有历史版本中所有文件的内容,将匹配的字符串替换为指定内容。这对于”密钥泄露但不想删除整个文件”的场景非常合适。
正则替换
replacements.txt 也支持正则表达式:
1
2
3
4 # replacements.txt
# 正则用 regex: 前缀
regex:sk-[a-zA-Z0-9]{48}==>****REDACTED****
regex:AKIA[A-Z0-9]{16}==>****AWS-KEY-REDACTED****
九、安全操作最佳实践
历史重写是高风险操作,以下几点务必遵守:
1. 永远在副本上操作
1
2
3
4
5
6 # 正确做法:克隆一份 mirror 副本
git clone --mirror git@github.com:org/repo.git repo-backup.git
# 错误做法:直接在本地工作仓库上跑 filter-repo
cd my-working-repo
git filter-repo ... # 不要这样做!
filter-repo 默认要求在 fresh clone 上运行。如果你非要在已有仓库上运行,它会报错。你可以用
1 | --force |
强制执行,但强烈不建议。
2. 通知所有协作者
历史重写后,所有人的本地仓库都变成了”过期”状态。你需要:
- 通知所有团队成员在特定时间点重新 clone 仓库
- 或者执行
1git fetch origin && git reset --hard origin/main
(会丢失本地未推送的提交)
- 暂停所有人的 push 操作,避免覆盖你重写后的历史
3. 处理 Pull Request
如果你的 PR 是基于被重写的 commit,重写后这些 PR 的 diff 会变成空的或者混乱的。建议在重写前合并所有活跃 PR,或者在重写后手动关闭并重新创建。
4. 备份原始仓库
1
2
3
4
5 # 在重写前,完整备份
cp -r repo-backup.git repo-backup-before-rewrite.git
# 或者推到备份 remote
git push --mirror git@backup-server:backups/myproject-$(date +%Y%m%d).git
十、filter-repo 的报告与回溯
每次运行后,filter-repo 会在
1 | .git/filter-repo/ |
目录下生成多份报告文件:
| 文件 | 用途 |
|---|---|
| commit-map | 新旧 commit SHA 的映射表 |
| ref-map | 新旧 ref(分支/标签)的映射表 |
| changed-refs | 发生变化的 ref 列表 |
| suboptimal-issues | 运行中发现的潜在问题 |
| first-changed-commit | 第一个被修改的提交 |
| already_ran | 标记文件,防止重复运行 |
利用 commit-map 可以做很多事,比如检查哪些提交被删除了:
1
2
3
4
5 # 找出被完全删除的提交
awk '$2 == "0000000000000000000000000000000000000000"' .git/filter-repo/commit-map
# 找出 SHA 发生变化的提交
awk '$1 != $2 && $2 != "0000000000000000000000000000000000000000"' .git/filter-repo/commit-map
十一、常见问题与避坑
Q: filter-repo 之后 push 被拒绝?
这是正常的。filter-repo 会自动移除 origin remote。你需要手动重新添加:
1
2
3 git remote add origin git@github.com:org/repo.git
git push origin --force --all
git push origin --force --tags
Q: 运行报错 “already ran”
filter-repo 会在
1 | .git/filter-repo/already_ran |
留标记文件,防止在同一仓库上多次运行。如果你想重新运行,需要删除该文件或重新 clone:
1
2
3 rm .git/filter-repo/already_ran
# 或者更安全的方式:重新 clone
git clone --mirror git@github.com:org/repo.git fresh-copy.git
Q: 处理 LFS 对象
filter-repo 对 Git LFS 有一定支持,但建议在重写前先将 LFS 对象导出。重写后可能需要重新
1 | git lfs migrate |
:
1
2 # 重写后重新导入 LFS
git lfs migrate import --include="*.mp4,*.zip" --everything
Q: 如何只重写最近 N 个提交?
filter-repo 默认处理所有历史。如果只想处理特定范围,可以使用
1 | --refs |
参数:
1
2 # 只重写 main 分支最近 100 个提交涉及的历史
git filter-repo --refs main --path sensitive.txt --invert-paths
注意:即使指定了
1 | --refs |
,filter-repo 仍然会处理这些 ref 能到达的所有提交,包括共享的祖先提交。
十二、CI/CD 中集成 filter-repo 做安全扫描
你可以在 CI 流水线中加入一个检查步骤,定期扫描仓库是否包含敏感文件。下面是一个 GitHub Actions 示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37 # .github/workflows/security-scan.yml
name: Repository Security Scan
on:
schedule:
- cron: '0 2 * * 1' # 每周一凌晨2点
workflow_dispatch:
jobs:
scan:
runs-on: ubuntu-latest
steps:
- name: Checkout full history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install git-filter-repo
run: pip3 install git-filter-repo
- name: Scan for sensitive files in history
run: |
SENSITIVE_FILES=".env config/secrets.yml *.pem id_rsa"
for file in $SENSITIVE_FILES; do
if git log --all --oneline -- "$file" | grep -q .; then
echo "::warning::Found sensitive file in history: $file"
echo "::warning::Run: git filter-repo --path $file --invert-paths"
fi
done
- name: Check for large blobs
run: |
git rev-list --objects --all | \
git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' | \
awk '$1=="blob" && $3 > 52428800 { print $4, $3 }' | \
while read name size; do
echo "::warning::Large file in history: $name ($(( size / 1048576 ))MB)"
done
这个工作流不会自动重写历史(那是需要人工确认的操作),但会每周扫描并在 Actions 日志中报告发现的问题,让你及时知道何时需要跑一次 filter-repo。
总结
git filter-repo 是现代 Git 历史重写的标准工具。相比
1 | git filter-branch |
,它在性能、安全性和可编程性上都有质的飞跃。核心要点回顾:
- 速度:基于 fast-export/fast-import 流式处理,比 filter-branch 快几十倍
- 安全:自动移除 remote 防止误推,生成详细报告供回溯
- 灵活:支持路径过滤、大文件移除、内容替换、Python 回调等多种模式
- 最佳实践:永远在 mirror clone 上操作,重写前备份,重写后通知所有协作者
历史重写是 Git 中的”核武器”——威力强大但需要谨慎使用。建议在执行前充分测试,先在副本上跑一遍,确认结果符合预期后再操作正式仓库。掌握了 git filter-repo,你就拥有了解决仓库历史问题的最可靠工具。

汤不热吧