欢迎光临

Git filter-repo 深度实战:安全高效地重写仓库历史、移除敏感文件与大文件

在 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 仓库
  • 或者执行
    1
    git 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,你就拥有了解决仓库历史问题的最可靠工具。

Git 版本控制工作流

【本站文章皆为原创,未经允许不得转载】:汤不热吧 » Git filter-repo 深度实战:安全高效地重写仓库历史、移除敏感文件与大文件
分享到: 更多 (0)