一个准备开源的小工具仓库,历史提交里的一行注释残留着开发机绝对路径(形如
D:\某目录\...)。当前文件早已清干净,直到有人把某个历史 commit 的链接发来,才发现历史里 5 个 commit 仍带着它——改文件根本改不到历史。本文记录git filter-repo全历史改写、逐提交重签 GPG、强推,以及最终删库重建的完整过程。结论先说:改写历史不等于清除历史,公开平台会缓存不可达对象;防再犯要靠提交前的自动门禁。适用环境 Windows 11 + Git for Windows(bash)+ Python 3.11,处理时间 2026-09-13。
一、症状与第一手数据 #
仓库公开后不久(2026-09-13),有人把某个历史提交的链接发给站主,问了一个问题:这个提交的修改记录里暴露了本机路径(可能之前的提交也有),有什么好的处理方法吗。
这个提交本身是一条「chore: 移除注释中的本机路径(脱敏)」。也就是说,最早发现泄漏后已经修过一轮:仓库公开前做最终审计时,在构建脚本 web/build_web.py 第 6 行抓到了泄漏——那行注释里直接写了开发机绝对路径(形如 D:\某目录\某仓库)。当时的处理是把注释改成「项目根目录」,提交(就是那条「脱敏」提交)、重打 tag、重建 Release——当前内容已经干净。但问题恰恰出在这次"修复"本身:它的 diff 显示的是被删除的那一行,而那一行里就是完整路径。任何人打开这个提交的页面都能看到路径;更糟的是,之前的提交里文件还带着它。
用一条命令把全历史每个提交的 tree 都扫一遍(关键词换成你自己的泄露特征词):
for c in $(git rev-list --all); do
hits=$(git grep -l -I -E "本地路径特征词|AppData|C:\\Users" $c -- 2>/dev/null)
[ -n "$hits" ] && echo "$c $(git log -1 --format=%s $c): $hits"
done结果很干净利落:泄漏只在一行注释里,且只出现在 5 个历史 commit(全部指向同一个文件 web/build_web.py);提交信息本身没有任何痕迹。当时的提交顺序也印证了泄漏路径:最初的 feat 提交新增这一行,其后 4 个中间提交一直带着它,直到「脱敏」提交删除——删除只是最后一步,前 5 个版本里它一直在。全仓库当时共 7 个 commit、2 个 tag。
二、根因 #
Git 的提交是一个内容寻址链表:commit 对象指向 tree,tree 再指向每个文件的 blob。改掉当前文件再提交,只会在新 commit 里换上新 blob;历史上每一个旧 commit 仍指向自己那份旧内容。所以"文件已清理"和"历史已清理"是两回事。
第二个坑更隐蔽:“脱敏"提交本身把路径搬进了 diff。git 的提交记录是公开面的直接表达,任何一次提交的增删行都会永久展示;即使那条提交的目的是删除,被删除的原文也完整留在页面上。
还有一个教训:首轮修复时宣称"公开前做了最终 grep 复查”,但那次复查本质只扫了工作区/当前内容,没有遍历 git rev-list --all 的每一个提交。用 git grep 检查当前文件,是发现不了历史泄漏的。
三、处置过程(含走过的弯路) #
1. 改写前先备份 #
用 git bundle 把全部 refs 打包成一份备份(改动前的历史以此为准),再装 git-filter-repo(pip 安装)。
git bundle create <备份路径> --all
python -m pip install git-filter-repo注意:这份备份打包的是旧的、含路径的历史,它本身绝不能推出去,确认一切稳定后可删。
2. filter-repo 全历史替换 #
准备替换表,长串优先(避免短串先命中、长串匹配不到),把路径特征替换成无信息的占位注释:
D:\某目录\某仓库==>项目根目录
D:\某目录==>项目根目录执行:
python -m git_filter_repo --replace-text 替换表.txt --force实际输出:Parsed 7 commits,New history written in 0.31 seconds; now repacking/cleaning...,Completely finished after 0.72 seconds。对小型仓库,改写本身是秒级操作。替换表里的规则一长一短,正是为了先命中长串:若短串先匹配,长串会被截成残片,替换结果不干净。
一个值得注意的副产物:那条「移除注释中的本机路径」的提交,唯一改动就是删除路径行;替换后它的内容与父提交一致、diff 为空,被 git-filter-repo 自动当空提交清理(7→6 个提交)。“清路径"的提交从历史上消失了——这反而是好事,因为它不再作为"把路径亮出来"的页面存在。改写后全历史重扫应为空,两个 tag 按内容自动重指到改写后的对应提交。
3. 逐提交重新 GPG 签名 #
历史改写必然使所有提交哈希变化,GPG 签名(基于提交内容)全部失效,git log 里签名状态显示 N。用 rebase --root 对每个提交重新签名:
git rebase --root --exec 'git commit --amend --no-edit -S'(gpg 不在 PATH 时,命令要加 -c gpg.program=<gpg 可执行文件路径>。)重签后全部提交显示 G(Verified);两个 tag 用 -a -s 重新打签名,git tag -v 校验输出显示签名算法为 EDDSA,随后强推。
4. 上防再犯门禁:check_privacy.py + CI + pre-commit #
趁历史改写完、还没推上去的窗口,写了一个不到 70 行的隐私检查脚本 scripts/check_privacy.py:遍历 git 跟踪文件(或只查暂存区),命中即退出码 1。规则如下:
| 规则(正则) | 识别目标 | 示例命中 |
|---|---|---|
[A-Za-z]:[\\/](带负向断言,避免误伤 https:// 等) |
Windows 盘符绝对路径 | C:\、F:/ |
AppData[\\/](Local|Roaming) |
Windows 用户配置目录 | AppData\Local |
C:[\\/]Users[\\/] |
Windows 用户目录 | C:\Users\... |
/home/[A-Za-z0-9_.-]+/ |
Linux 家目录 | /home/user/... |
/Users/[A-Za-z0-9_.-]+/ |
macOS 家目录 | /Users/user/... |
%LOCALAPPDATA%|%APPDATA%|%USERPROFILE% |
本机环境变量路径 | %USERPROFILE% |
脚本跳过二进制与图片后缀(.exe/.zip/.dll/.pyc/.png/.ico/.jpg 等)和自身;--staged 模式只扫暂存区(给 pre-commit 钩子用)。
全文如下,连同启用它的本地钩子一起(点开即可复制;只用 Python 标准库,无第三方依赖):
scripts/check_privacy.py(69 行)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""隐私检查:扫描仓库跟踪的文件中是否残留本机绝对路径等痕迹。
CI(每次 push/PR)与本地 pre-commit 钩子共用;命中即退出码 1。
用法:
python scripts/check_privacy.py # 扫描 git 跟踪的全部文件
python scripts/check_privacy.py --staged # 只扫描暂存区(pre-commit 用)
"""
import re
import subprocess
import sys
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
# 每条:正则、说明。注意 (?!...) 负向断言避免误伤 https:// 这类正常文本。
PATTERNS = [
(re.compile(r"(?<![A-Za-z0-9])[A-Za-z]:[\\/]"), "Windows 绝对路径(如 C:\\ 或 F:/)"),
(re.compile(r"AppData[\\/](?:Local|Roaming)"), "Windows 用户配置目录"),
(re.compile(r"C:[\\/]Users[\\/]"), "Windows 用户目录"),
(re.compile(r"/home/[A-Za-z0-9_.-]+/"), "Linux 家目录"),
(re.compile(r"/Users/[A-Za-z0-9_.-]+/"), "macOS 家目录"),
(re.compile(r"%LOCALAPPDATA%|%APPDATA%|%USERPROFILE%"), "本机环境变量路径"),
]
SKIP_FILES = {"scripts/check_privacy.py"} # 本文件含规则字面量,跳过自身
SKIP_SUFFIX = (".ico", ".png", ".jpg", ".zip", ".exe", ".dll", ".pyc")
def tracked_files(staged):
if staged:
out = subprocess.run(["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"],
capture_output=True, text=True, check=True).stdout
return [p for p in out.splitlines() if p.strip()]
out = subprocess.run(["git", "ls-files"], capture_output=True, text=True, check=True).stdout
return [p for p in out.splitlines() if p.strip()]
def main():
staged = "--staged" in sys.argv[1:]
hits = []
for path in tracked_files(staged):
if path in SKIP_FILES or path.endswith(SKIP_SUFFIX):
continue
try:
with open(path, encoding="utf-8", errors="ignore") as f:
for lineno, line in enumerate(f, 1):
for rx, desc in PATTERNS:
m = rx.search(line)
if m:
hits.append((path, lineno, desc, m.group(0)))
except (OSError, UnicodeError):
continue
if hits:
print("✘ 检测到可能的隐私痕迹(本机路径),请脱敏后再提交:")
for path, lineno, desc, frag in hits:
print(f" {path}:{lineno} [{desc}] 命中:{frag}")
print("\n提示:改用相对路径或占位符(如『项目根目录』『示例目录A』)。")
return 1
print("✓ 隐私检查通过:未发现本机绝对路径痕迹")
return 0
if __name__ == "__main__":
sys.exit(main())scripts/hooks/pre-commit(本地拦截,仓库根目录执行一次 git config core.hooksPath scripts/hooks 即生效)
#!/bin/sh
# 本地 pre-commit 钩子:提交前扫描暂存区是否含本机路径等隐私痕迹。
# 启用方式(仓库根目录执行一次):
# git config core.hooksPath scripts/hooks
python scripts/check_privacy.py --staged || {
echo "提交被拦截:请先处理上述隐私痕迹(或临时用 git commit --no-verify 跳过)。"
exit 1
}上线第一天它就立了功:本地首跑立即命中 README.md:67——README 里演示命令行用法写的示例路径(形如 D:\某目录\...)被判为 Windows 绝对路径。演示路径虽是占位用途,但规则不留模糊地带:把示例改成无盘符的「目录A...」占位,提交通过。
门禁分两处挂上:
- CI:workflow 里在语法检查之后、内核一致性验收之前加一步
python scripts/check_privacy.py,每次 push 都跑,命中即失败; - 本地:
scripts/hooks/pre-commit里跑--staged模式;在仓库根目录执行一次git config core.hooksPath scripts/hooks即生效。
5. 弯路:强推之后,旧 commit 依然可达 #
一切就绪后 git push --force origin main 与 git push --force origin --tags 强推。GitHub 侧确认远端 main 已换成干净历史(最新提交即门禁提交,状态 G),Release 完好。然后探测三个旧 SHA,结果如下:
| 探测对象 | 强推之后 | 删库重建之后 |
|---|---|---|
| 旧提交 SHA-1(含路径) | 返回完整 SHA,仍可访问 | HTTP 422 No commit found |
| 旧提交 SHA-2 | 仍可访问 | HTTP 422 |
| 旧提交 SHA-3 | 仍可访问 | HTTP 422 |
这是本篇最值钱的一点:改写历史 ≠ 清除历史。force push 让旧对象在引用层面"不可达”,但公开平台会保留不可达对象一段时间(作为旧链接缓存),不会立即物理删除;被分享出去的旧 commit 链接,打开仍能看到那行路径和完整 diff。
6. 终局:删库重建 #
接受现状不符合"彻底清除"的目标(旧链接还挂着路径),于是选了最彻底也最省心的方案:删除仓库,同名重建。
gh repo delete <账号>/<仓库> --yes # 删除
gh api repos/<账号>/<仓库> # 确认 404
gh repo create <账号>/<仓库> --public --source . --remote origin --push
git push origin --tags # 触发 tag 构建执行要点:删除后先用 API 确认返回 404 再重建(同名);重建后推 main 与两个 tag,同一刻三个 CI run(main 提交 + 两个 tag)排队构建,Release 由 tag 触发自动重建;顺手补上仓库描述与 9 个 topics。
四、验证 #
判定"解决"的标准是旧链接彻底失效:
- 删库重建后,三个旧 SHA 探测全部返回 HTTP 422
No commit found for SHA: ...(GitHub REST 对不存在的提交返回 422 而非 404);当年被分享出去的那个链接也失效了; - 新仓库为 PUBLIC,描述与 9 个 topics 就位;
- 3 个 CI run(main + 两个 tag)全部 success;两个 Release 由 tag 触发重建完毕,资产齐全(可执行程序、GUI 压缩包、网页版、SHA256SUMS 校验文件);
- 远端
web/build_web.py第 6 行确认为「项目根目录」,全历史零泄漏; - 门禁提交在远端日志显示
G(Verified)。
怎么判断"没解决":复制旧 commit 链接直接打开,还能看到 diff 和那行路径——说明不可达对象还活着。
五、留给后来者的清单 #
| 时机 | 动作 |
|---|---|
| 提交前(成本最低) | 仓库放 check_privacy.py;CI 加一步扫描;本地 git config core.hooksPath scripts/hooks 启用 pre-commit;示例路径一律用无盘符占位 |
| 提交前(另一视角) | 记住"历史即公开面":任何一次提交的增删行都会永久展示,删除一行泄漏 = 把泄漏写进 diff |
| 发现历史泄漏后 | 先 git bundle create --all 备份全历史(备份内仍含旧路径,勿外推) |
| 清理时 | filter-repo --replace-text(替换表长串优先)→ 空提交自动清 → 全历史重扫 → rebase --root 重签全部提交与 tag |
| 强推之后 | 用 gh api repos/<账号>/<仓库>/commits/<旧SHA> 探测:仍返回 SHA 就说明缓存还活着 |
| 要彻底清除 | 删库重建(同名):删除 → 404 确认 → 重建 → 推 main/tags → CI 重建 Release → 旧 SHA 全部 422 |
六、碎碎念 #
事后清理这一趟,成本不在改写本身(秒级),而在连锁动作:重签全部提交、重建 tag、删库重建、等 CI 重打 Release、一个个探测旧 SHA。而这一切的起因,只是提交时多写的一行注释,加一次只扫工作区、没扫历史的"复查"。
对开源仓库来说,历史就是公开面,任何一次提交都可能在未来某个时刻被人翻开。比事后清理便宜得多的做法,是让机器在提交发生前拦一道:一条正则、一个 CI 步骤、一个 pre-commit 钩子。最后再强调一遍备份的事:改写前的备份里还躺着那行旧路径,别把备份当成普通仓库推上去,确认干净稳定后记得删掉。