手滑把密钥提交进 Git 了?这个脚本帮你从全部历史里彻底抹掉
凌晨一点,你正在排查线上问题,突然想起:那个 .env 文件,好像上周被一句 git add . 一起提交进去了。
更糟的是,代码早就 push 到了远端。
你的第一反应多半是 git rm .env,补一次提交,再把它写进 .gitignore。做完这三步,很多人就觉得安全了。但事实是:密钥依然躺在历史里,任何人执行一次 git log -p,就能原封不动地翻出来。
今天分享一个我整理的交互式 Bash 脚本 git-purge-history.sh。它能把指定的文件或目录从 Git 全部历史中彻底移除,其余文件的提交记录保持不变,并且在动手之前先帮你做好备份。
读完这篇文章,你会知道:为什么删文件没用、这个脚本怎么用、它如何保证操作安全,以及执行完之后一定要做的五件事。
为什么 git rm 删不掉它
因为 Git 是一本只增不减的账本。每个提交保存的是当时仓库的完整快照,后来的提交删掉文件,只是让新快照不再包含它,旧快照原封不动地留在历史里。
这类需要从历史中抹掉的文件,常见的有三种:
- 密钥与凭证:
.env、id_rsa、*.pem、各类 Token 配置文件 - 体积过大的文件:安装包、数据集、视频,让每次 clone 都慢得难受
- 敏感或不合规的数据:含个人信息的导出文件、内部文档
想真正删掉,只有一条路:重写历史,把每个提交里的这些文件都剔除,生成一条全新的历史。代价是所有受影响提交的哈希都会改变,所以它是一个必须谨慎对待的操作。
社区里常用的工具有两个:推荐的 git filter-repo,以及自带的老牌命令 git filter-branch。后者速度慢、参数繁琐,一不小心就会漏掉标签或残留旧对象。手敲这些命令很容易出错,这正是我把整个流程封装成脚本的原因。
这个脚本能做什么
它是一个纯 Bash 脚本,在仓库里运行,通过菜单一步步带你完成。核心能力有八项:
- 找得到:一键列出历史中体积最大的前 N 个文件(默认 20 个),或按关键字搜索历史里出现过的文件名,比如
.env、id_rsa、.pem - 选得准:搜索结果按编号勾选,也可以手动输入路径,支持目录和通配符,如
config/*.key - 看得清:执行前预览每个路径涉及多少个提交,并提醒你哪些文件当前仍被跟踪
- 有后路:重写之前自动生成完整的 bundle 备份,并把当前版本的目标文件另存为 tar 包
- 双引擎:优先使用
git filter-repo,没装时自动回退到git filter-branch - 不丢远程:重写会让 remote 配置丢失,脚本会先记录再恢复
- 能验证:执行后逐个路径检查历史里是否真的清干净了
- 可推送:可选择把所有分支和标签强制推送到远端,并在最后列出后续必做清单
一句话概括:从发现问题到清理完毕,你只需要做选择题,不用背任何一条 Git 命令。
三步上手
脚本已开源,放在 GitHub 上:git-purge-history.sh(仓库路径 shell/linux/git/)。下载后按下面三步就能用。
第一步:安装 git-filter-repo(推荐)
pip install git-filter-repo
不装也能用,脚本会自动回退到 git filter-branch,只是处理大仓库时会慢不少。
第二步:赋予执行权限
chmod +x git-purge-history.sh
第三步:在仓库里运行
# 进入交互菜单
./git-purge-history.sh
# 或者预先指定要移除的路径
./git-purge-history.sh .env secrets/
脚本会自动定位仓库根目录,所以在仓库的任何子目录里运行都可以。它也有几个前提:当前目录必须在 Git 仓库内、仓库至少有一次提交、已跟踪的文件没有未提交的修改。如果工作区不干净,脚本会提醒你先提交或 stash,不会硬往下执行。
交互菜单逐项讲解
运行之后,你会看到这样一个菜单:
================ Git 历史清理工具 ================
仓库: /path/to/your-repo
引擎: git filter-repo
已选路径: 0 项
--------------------------------------------------
1) 扫描历史中最大的文件并挑选
2) 按文件名关键字搜索历史并挑选(如 .env、.pem)
3) 手动输入文件/目录路径
4) 查看 / 修改已选路径
5) 预览影响范围
6) 备份并执行移除
0) 退出
菜单顶部会显示当前仓库、正在使用的引擎和已选路径数量,让你随时知道自己在做什么。
1)扫描最大文件:适合清理体积问题。脚本会遍历所有分支和标签的历史对象,按大小从高到低排列,同一路径只保留最大的版本,避免刷屏。输入编号即可加入移除列表,多个编号用空格分隔,输入 a 表示全选。
2)按关键字搜索:适合清理密钥。输入 .env、id_rsa 或 .pem 这样的关键字,脚本会在历史中出现过的所有文件路径里做不区分大小写的匹配,最多列出 200 条,再按编号挑选。已经被你删掉、只存在于历史里的文件也能搜到,这是它最实用的地方。
3)手动输入:知道确切路径时最快。每行一个文件或目录,相对仓库根目录,支持通配符,空行结束输入。
4)查看 / 修改已选路径:选错了不用重来,输入编号删除单项,或者输入 c 一键清空。
5)预览影响范围:正式动手前的最后一道检查。它会告诉你仓库一共有多少个提交,每个路径涉及其中多少个;如果某个路径在历史中一个提交都找不到,会提示你检查路径是否写错(区分大小写,且相对仓库根目录);如果该文件当前仍被跟踪,会特别标注“重写后工作区文件会被删除”。
6)备份并执行移除:真正动手的一步,下一节详细说。
0)退出:不做任何修改,直接离开。
它为什么“敢用”:执行流程里的六道保险
重写历史最让人不安的,是万一弄错了怎么办。选择 6 之后,脚本按下面的顺序一步步走,每一步都在为“可以反悔”服务:
- 先拦脏工作区:已跟踪的文件有未提交的修改时直接中止,提示你先提交或 stash,避免改动在重写中丢失。
- 展示 + 预览 + 二次确认:把待移除路径、涉及的提交数、将使用的引擎全部列出来,并明确警告“会重写全部提交历史,所有受影响提交的哈希都会改变”。必须手动输入
yes才会继续,输入别的任何内容都视为取消。 - 完整备份整个仓库:确认之后、动手之前,先用
git bundle打包所有引用,生成仓库名-backup-时间戳.bundle,放在仓库的同级目录。出问题时一条git clone 备份文件 restored-repo就能原样恢复。备份失败,脚本会直接中止,不会硬着头皮继续。 - 另存当前版本的目标文件:重写后,工作区里仍被跟踪的目标文件会被删除。脚本会提前把它们打成
仓库名-keep-时间戳.tar,你本地正在用的.env不会就此消失,只是不再属于历史。 - 记住并恢复 remote:
filter-repo会移除远程仓库配置。脚本在重写前记录所有 remote,重写后自动补回,你不用再手动git remote add。 - 结果校验:重写完成后,逐个路径统计历史中还剩多少个提交涉及它。归零就会显示“已不存在”;如果仍有残留(比如被 stash 引用着),会明确告诉你。
如果走的是 filter-branch 路线,脚本还会额外清理 refs/original、过期 reflog 并执行 gc --prune=now,避免旧对象悄悄留在 .git 里。
这六步里,真正“不可逆”的只有强制推送远端。而它同样需要你再输入一次 yes,对每个 remote 单独确认,不想推就跳过,脚本会把手动命令打印给你。
清理完之后,这五件事一件都不能少
脚本跑完只是完成了一半。它在结尾会直接打印一份“后续必做”清单,我把它展开讲一下,这是整篇文章最重要的部分:
- 立刻吊销并轮换密钥。 只要这个文件曾经推送到远端,就应当视为已经泄露。哪怕你把历史清得再干净,爬虫和别人的本地克隆也可能早已拿走了它。清理历史是补救,轮换密钥才是止损。
- 把新历史推上去。 如果还没推送,执行
git push <remote> --force --all,再执行git push <remote> --force --tags。脚本在流程末尾也会逐个 remote 询问你是否现在推送。 - 通知所有协作者重新 clone。 让大家删掉旧的本地仓库重新克隆,千万不要 pull 或 merge,否则旧历史会被重新带回来,一切白做。
- 联系平台清理残留。 GitHub、GitLab 上旧的 PR 引用、缓存视图和 fork 里,仍可能保留旧对象。需要联系平台支持来清理,GitHub 有专门的“移除敏感数据”入口。
- 把这些路径写进
.gitignore。 别让同一个文件第二次被提交进来。
常见问题与避坑
Q:预览里显示“涉及 0 个提交”,是脚本坏了吗?
多半是路径写错了。路径区分大小写,而且必须相对于仓库根目录。建议先用菜单 2 按关键字搜索,再从结果里按编号挑选,比手敲更不容易出错。
Q:校验时提示“仍有 N 个提交涉及”怎么办?
通常是 refs/stash 之类的其他引用还指着旧对象。检查并清理这些引用后再运行一次即可。
Q:强制推送被拒绝了?
大概率是远端的分支开启了保护。需要先在平台上临时关闭保护,推送完成后再打开。
Q:想按规则批量移除一类文件?
用通配符,比如 config/*.key。脚本会自动识别含有 *、?、[ 的路径,并交给对应引擎按通配符匹配。
Q:大仓库扫描很慢?
菜单 1 需要遍历所有分支和标签的历史对象,仓库越大越慢,这是正常的。用菜单 2 按关键字搜索,通常会快很多。重写阶段如果走的是 filter-branch,同样会比 filter-repo 慢,建议先装好后者。
Q:重写之后,我本地正在用的文件被删了?
别慌。已被跟踪的目标文件会在重写后从工作区消失,但脚本已提前把它们打成了 -keep- 开头的 tar 包,需要的话解压放回去,再把路径加进 .gitignore 即可。
最后再强调一次:这是一个会改写全部历史的操作。 团队协作的仓库,请务必先和同事打好招呼,统一时间处理。
写在最后
误提交密钥,几乎每个开发者职业生涯里都会遇到一次。真正拉开差距的,是发现之后的处理:删文件只是表面功夫,重写历史才能真正抹掉;而重写历史这件事,最好有备份、有预览、有确认,一步一步稳稳地做。
这个脚本做的事情并不神秘,它只是把 git filter-repo 的正确姿势、备份习惯和善后清单,包装成了一个不用记命令的菜单。希望你永远用不上它,但万一遇上了,它能让你少熬一个夜。
别忘了,最好的清理,是不让它进来:提交前检查 git status,把 .env、密钥、大文件第一时间写进 .gitignore,必要时再加一道 pre-commit 的密钥扫描。
脚本地址:https://github.com/zhengmingliang/code-fragment/blob/main/shell/linux/git/git-purge-history.sh
文章内的外链可能无法直接跳转,请复制链接到浏览器打开,或点击文末的“阅读原文”。
如果这篇文章对你有帮助,欢迎点个“在看”转给身边的同事。你踩过最惊险的 Git 坑是什么?来评论区聊聊。
Q.E.D.


