自作の x86 エミュレータ (Rust + WebAssembly) で、CD-ROM (ISO) からの起動を実装した PR をマージした。その出力の最後に、見慣れない行が 2 つ並んでいた。
28 files changed, 1059 insertions(+), 85 deletions(-)
create mode 100644 core/tests/boot_cd.rs
create mode 100755 tools/images/sh/make-test-image.sh
create mode 100644 web/guest-a.img.gz
create mode 100644 web/guest-b.img.gz
後ろの 2 つはディスクイメージである。中身はゲスト OS (GPL) と、シェアウェア配布のレトロゲーム一式である。public のリポジトリに、配布条件のあるバイナリを 4.5MB ぶん commit していた。
このプロジェクトでは「像 (ディスクイメージ) は private の別リポジトリに置き、web には焼いて配る」と決めてある。決めてあるのに入った。この記事は、なぜ穴が開いたのかと、公開済みの履歴から消すまでの手順である。git を巻き戻すのではなく、全コミットから特定のファイルだけを抜く話になる。
以下、ログのリポジトリ名・SHA・PR 番号・チェック名は伏せてある (手順と出力の形はそのまま)。
なぜ .gitignore をすり抜けたか
.gitignore はこうなっていた。
/target
/web/pkg
/images
/web/*.img
/web/*.iso
...
# 焼いたディスクイメージ (配布物)
web/disk-gcc.img.gz
web/disk-x.img.gz
/web/*.img と /web/*.iso はワイルドカードなのに、gz で固めたものだけ名指しになっている。disk-gcc.img.gz と disk-x.img.gz が生まれたときに 1 行ずつ足していったからだ。そこへ後から guest-a.img.gz と guest-b.img.gz が増えた。名指しの列に加わらなかった 2 つは、当然のように追跡された。
穴の形は「ワイルドカードで書ける場所を、名指しで書いた」である。修正は 1 行で済む。
/web/*.img
+/web/*.img.gz
/web/*.iso
気づけたのは、マージの出力を読んでいたからでしかない。git pull や gh pr merge の create mode の列は、普段の PR では出ないものが出るので目に留まる。読む習慣は安い保険だ。
HEAD から消すだけでは足りない
git rm --cached して commit すれば、最新のツリーからは消える。だがそれは「今の枝には無い」だけで、古い commit を指せば取れる。
$ curl -sIL -o /dev/null -w '%{http_code}\n' \
https://raw.githubusercontent.com/OWNER/REPO/e6b1058.../web/guest-a.img.gz
200
GitHub は SHA を直に指した raw を返す。だから履歴そのものから抜くしかない。
ここで誤解しやすいのが、「じゃあ混入前の commit まで戻すのか」という話である。戻さない。やるのは全コミットの中からそのパスだけを消して歴史を編み直す操作で、成果物 (今回なら ISO 起動の実装) はそのまま残る。

コミットの SHA は変わるが、ファイルの中身は 1 バイトも変わらない。(※公開後の追記: SHA は「混入点から後ろ」ではなく、署名付き merge 以降のほぼ全部が変わっていた — 末尾の追記を参照)
手順
1. mirror clone を別に取る
作業ツリーで直接書き換えない。同じリポジトリを別のセッション (別の作業) が触っている可能性があるし、失敗しても捨てられる場所でやるほうがいい。
git clone --mirror https://github.com/OWNER/REPO.git purge.git
cd purge.git
2. git filter-repo でパスを抜く
git filter-branch は遅くて壊れやすい。今は git-filter-repo が公式に推されている。単発なら uvx で取ってきて走らせるのが速い。
uvx --from git-filter-repo git-filter-repo \
--invert-paths \
--path web/guest-a.img.gz \
--path web/guest-b.img.gz \
--force
641 commit のリポジトリで 2.5 秒だった。
3. 「抜けただけ」を証明する
ここが一番大事な工程である。巻き戻していないことを数字で見せられないと、後から自分でも信じられなくなる。書き換え前の main を別名で取ってきて突き合わせる。
git fetch --no-tags https://github.com/OWNER/REPO.git main:refs/old/main
git diff --stat refs/old/main main
# web/guest-a.img.gz | Bin 2463211 -> 0 bytes
# web/guest-b.img.gz | Bin 2189882 -> 0 bytes
# 2 files changed, 0 insertions(+), 0 deletions(-)
diff <(git log --pretty='%s' refs/old/main) <(git log --pretty='%s' main)
# 1 行だけ差分 (後述) 以外は完全一致
差分が「その 2 ファイルの削除だけ」で、コミット数 (641) も件名の並びも一致していれば、抜けただけだと言える。
件名に 1 行だけ差が出た。
< fix(jit): テストの参照を関数名から添字非依存へ (8d1c4b6 の改名に追従)
> fix(jit): テストの参照を関数名から添字非依存へ (5e0a237 の改名に追従)
コミットメッセージの中に書いた旧 SHA を、filter-repo が新 SHA に書き換えている。親切な仕様で、放っておいてよい。
罠: 検証のために refs/old/main を fetch すると、消したはずの blob がオブジェクトDBに戻ってくる。この状態で「大きい blob 一覧」を見ると当然まだ居るので、「消えていない」と誤診する。検証が済んだら ref を消して掃除する。
git update-ref -d refs/old/main
git reflog expire --expire=now --all
git gc --prune=now
git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectsize) %(rest)' \
| awk '$1=="blob" && $2>300000 {print $2, $3}' | sort -rn | head
# 463858 docs/images/linux-debugger.png ← 像は消えた
pack は 8.6MB → 4.18MB になった。
4. filter-repo は origin を外す
そのまま push しようとすると、こうなる。
fatal: 'origin' does not appear to be a git repository
事故防止のため、filter-repo が remote を削除している (書き換えた履歴を反射的に push させないための仕様)。意図を確認したうえで付け直す。
git remote add origin https://github.com/OWNER/REPO.git
5. ブランチ保護に弾かれる
付け直して押すと、今度は GitHub に断られた。
remote: error: GH006: Protected branch update failed for refs/heads/main.
remote: - Changes must be made through a pull request.
remote: - Required status check "build" is expected.
remote: - Cannot force-push to this branch
+ 7ac8e13...1f4d905 fix/untrack-binaries -> fix/untrack-binaries (forced update)
! [remote rejected] main -> main (protected branch hook declined)
保護されていない枝だけ通った。main を押すには保護を一時的に外すしかない。外している時間を秒にしたいので、外す・押す・戻すを 1 本に繋ぐ。
まず今の設定を控えて、復元用のペイロードを作る。必須チェックは contexts (旧形式) ではなく checks で書くと、どの GitHub App のチェックかという紐づけ (app_id) まで戻せる。
R=OWNER/REPO
gh api repos/$R/branches/main/protection > main-protection.json
{
"required_status_checks": { "strict": false, "checks": [{ "context": "build", "app_id": 15368 }] },
"enforce_admins": true,
"required_pull_request_reviews": {
"dismiss_stale_reviews": false,
"require_code_owner_reviews": false,
"required_approving_review_count": 0
},
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false,
"required_linear_history": false,
"required_conversation_resolution": false
}
そして 1 行で通す。
gh api -X DELETE repos/$R/branches/main/protection \
&& git -C purge.git push --force origin main; \
gh api -X PUT repos/$R/branches/main/protection --input restore-protection.json
&& と ; の使い分けが肝で、push が失敗しても保護は必ず戻る。戻った後に確認する。
gh api repos/$R/branches/main/protection \
--jq '{force_push:.allow_force_pushes.enabled, admins:.enforce_admins.enabled,
checks:[.required_status_checks.checks[].context], pr_required:(.required_pull_request_reviews!=null)}'
# {"admins":true,"checks":["build"],"force_push":false,"pr_required":true}

6. 手元を新しい履歴に付け替える
作業ツリー側は古い SHA のままなので、fetch して各枝を貼り直す。追跡外にしたファイルは reset で消えるので、退避しておいて戻す (以後は未追跡ファイルとして web が配る)。
cp web/guest-a.img.gz web/guest-b.img.gz /tmp/keep/ # 先に退避
git fetch --prune origin
git checkout main && git reset --hard origin/main
git checkout fix/untrack-binaries && git reset --hard origin/fix/untrack-binaries
cp /tmp/keep/*.img.gz web/ # 戻す (gitignore 済みなので untracked)
git status --short # 空 = clean
7. CI が発火しない
書き換えた枝を force push したのに、その PR で CI が回らなかった。
$ gh pr checks 43
no checks reported on the 'fix/untrack-binaries' branch
必須チェックが付かないので PR は BLOCKED のまま動かせない。空コミットで起こす。
git commit --allow-empty -m "CI を回し直す"
git push origin fix/untrack-binaries
消えないもの
ここまでで main からは消えた。だが古い SHA を直に指すと、まだ 200 で返る。
$ gh api "repos/$R/git/refs/pull/42/head" --jq .object.sha
d92f374b8e15c0a7431de92f5a6c8b03e714f2a9
GitHub は PR ごとに refs/pull/<N>/head を持ち、これは永久に消えない。force push はこの ref を書き換えないので、そこから辿れるオブジェクトは残り続ける。完全に消すには GitHub Support に「force push 済み、旧オブジェクトと PR のキャッシュを GC してほしい」と依頼する必要がある (リポジトリ名・パス・旧 SHA を添える)。
ここで一瞬ぞっとした。それなら、有名な OSS の PR に侵害物を投げ込めば永久に残せてしまうのでは?
落ち着いて考えると、そうはならない。ref に残る物の出所は投げた本人であって、維持者の配布行為ではない。GitHub の DMCA 手続きも「通知 → 直す猶予 → それでも残れば措置」の順で、善意の速やかな除去が正解という運用になっている。今回やったこと (HEAD から除去 → 履歴から除去 → 必要なら Support) が、まさに教科書どおりの手当てである。
そして今回入っていたものは、そもそも海賊版ではない。GPL の OS は再配布自体が自由 (義務はソース提供の申し出) で、もう一方も配布を許すシェアウェアである (ただし「改変せず一式で」という条件があり、ディスクイメージに詰め直した時点で条項の外に出る)。海賊版ではなく、ライセンス手続きの取りこぼしだ。だから今回は Support への依頼までは出さず、「利用者が増えたら再訪する」と台帳に書いて閉じた。
追記 (公開後に分かったこと): SHA は「混入点から後ろ」ではなく、ほぼ全部変わっていた
本文では「SHA は混入した地点から後ろが全部変わる」と書いたが、filter-repo が残す記録 (filter-repo/commit-map・first-changed-commits) を数えると 787 commit 中 780 が書き換わっていた。最初に変わったのは混入の 2 週間前、PR #1 の merge commit である。
原因は署名だ。GitHub が作る merge commit には gpgsig ヘッダ (Verified バッジの元) が付いている。filter-repo はこれを全 commit から落とす (書き換え後は署名が合わなくなるので、無条件に剥がす)。署名が消えれば commit オブジェクトの SHA が変わり、子孫も全部変わる。
$ git cat-file -p <旧 PR#1 の merge> # gpgsig -----BEGIN PGP SIGNATURE----- … が居る
$ git cat-file -p <新 PR#1 の merge> # tree/parent/author/committer は同じ、gpgsig だけ無い
「中身は 1 バイトも変わらない」はツリー (ファイル) の話としては正しい。commit オブジェクトは署名のぶん変わっている。
実害は 2 つ:
- 履歴から Verified バッジが消える
- force push しなかった枝とタグは旧 SHA のままになり、新しい main と並行する二重の履歴ができる。像は含まないので漏れの問題は無いが、古い枝から PR を出すと巨大な差分になる。揃えるには、枝とタグも書き換え後のものを force push し、手元の枝は
commit-mapで貼り替える
教訓: filter-repo の後は filter-repo/first-changed-commits と changed-refs を必ず見る。「件名の並びが一致」だけでは、署名が落ちたことは見えない。Support への依頼文にも「書き換えは全 ref に及んだが、対象ファイルを含んでいたのは PR #N/#M だけ」と一言添えると、向こうの確認が速い。
学んだこと
.gitignoreは名指しで伸ばさない。disk-x.img.gzと 1 行ずつ足していたのが穴の正体で、/web/*.img.gzと書けば以後は塞がる- 大きいファイルは「置かない」より「置けない」に寄せる。private の像リポジトリ + 取得スクリプトという構えは既にあったのに、
.gitignoreの穴だけで無効化された - マージの出力を読む。
create modeに見慣れないものが出るのは、たいてい何かが間違っている - 履歴の書き換えは、巻き戻しと違うと証明できる形でやる。
git diff --statが消したいファイルだけ、コミット数と件名の並びが一致 — この 2 つを見せられれば、自分にも他人にも説明できる。ただしそれでは署名が落ちたことは見えない —filter-repo/first-changed-commitsも見る (追記) - 保護は外したら 1 コマンドで戻す。手で戻すつもりでいると、忘れたときの無防備が長い
git の履歴は「追記だけの台帳」に見えて、実際には編み直せる。ただし編み直した瞬間に、公開済みのものは公開済みのまま別の場所に残る。消すのは技術の問題ではなく、どこに残っているかを数える作業だった。
続き: この経緯を手順書に起こした — GitHubのpublicリポジトリに入れてはいけないものを入れたときの緊急手順 (HEADから消す・履歴から抜く・Supportに頼む・二度と入らなくする、スクショとチェックリストつき)


コメント