Files
BFM-decomp/docs/public-flip-runbook.md
T

27 KiB
Raw Blame History

The public-flip runbook — Phase 33 Block C (the history rewrite, the push, the purge gate, the flip)

Status: written at C3 (S87, 2026-09-06). The procedure is the approved Phase-33 plan's Block C, made operational: every step names its actor (Claude in the WSL clone, Drew for every push / GitHub action — rule R6), its exact commands, its checks (each an exit code or a count), and what to do if it fails. The tools it names under tools/public_rewrite/ are written in C1; until then only purge_set.txt exists. Scratch for the whole block lives in .run/public_rewrite/ (gitignored — it holds old hashes and personal addresses; never commit it).

0. The decisions this runbook implements (Drew, 2026-09-06 — binding)

  1. Flip IN PLACE with the full history. Druthulu/BFM-decomp is force-pushed with a rewritten history: every commit, date, message and order preserved; hashes change; the one commit that touched only purged paths ("session archive update", 2026-08-12) drops as empty. No new repo.
  2. Purge from ALL history — tools/public_rewrite/purge_set.txt is the single source of truth (filter-repo syntax): the retail EXE at both historical paths, dumps/*.bin (28 RAM images), ghidra/ (the Ghidra project — its database embeds the EXE's bytes under the page XOR mask), tools/psyq/ (Sony's SDK), session archive/ (3 parts, ~271 MB, the only blobs > 50 MiB, ~260k lines of game disassembly inside), tools/ghidra-ext/*.zip (re-downloadable; sha256s in docs/SETUP.md §2.3/§2.4), tools/brave-CUE/brave.exe (a compiled GPL tool; the source stays).
  3. In-history hash scrub. Every old commit hash cited in any historical blob or commit message becomes an inert token commit:NNNN (NNNN = the commit's ordinal on main); a token→new-hash map is committed (docs/commit-map.tsv); one tip commit resolves the tokens at HEAD to the new hashes so current docs stay navigable. Fixed-point rule: a new hash can never be written into a historical blob (it would change every descendant hash) — tokens in history, real hashes only at the tip.
  4. Identity: both personal e-mail identities → Drew T <50529377+Druthulu@users.noreply.github.com> (--mailmap); names and dates untouched. The three tracked files that mention the addresses are literal-replaced in the same pass.
  5. Everything else stays public, including phase-ends/ (PhaseEnds AND logs/), CLAUDE.md, PROJECT_CONTEXT.md, the decision log, the accelerators and the campaign tooling.
  6. The archive: Druthulu/BFM-decomp-archive (private, created EMPTY — never a fork or an import, those share GitHub's object store) receives the current, unrewritten history (all refs + the S76-pre-scrub-backup tag) BEFORE the rewrite. The tag is dropped from the public history.
  7. The flip is gated on GitHub no longer serving the old hashes (Support purge, probed by script); delete-and-recreate under the same name is the fallback if Support stalls.

Data-loss hazard, in force from C3 onward: the purged paths are now ignored but present on disk. git clean -x / git clean -fdx would delete the Ghidra database, the dumps and the SDK. make clean is the only clean. (CLAUDE.md fail-safe rule; R20 amendment proposed at PhaseEnd_Phase33.)

1. Who does what

Actor Steps
Claude (WSL clone ~/bfm-decomp) C3 ✓, C1, C2, the bundle (C4a), the bare clone + rewrite (C4c), C5, C6, C7, C8, the C9 gate, C11's local part, every doc
Drew creates the archive repo and pushes the mirror (C4b), the force-push + remote tag deletion (C9), the Support ticket, the probe re-runs, the visibility flip (C10), the other clones' resets (C11)

Claude never runs git push (R6). Every Drew step is announced with its exact command and its check, and the session WAITS for Drew's word that it ran (never inferred from a later git fetch).

2. C3 — the preparatory commit (DONE, S87)

git rm --cached of the purge set (index only; the files stay on disk, already ignored — checked with git check-ignore --no-index on every path BEFORE they became untracked, because a blanket git add -A would otherwise re-add ROM bytes). tools/psyq/CHECKSUMS.sha256 had already moved to tools/psyq_CHECKSUMS.sha256 (B4) — nothing project-authored remained under a purged directory (census: 189 Sony/third-party files under tools/psyq/, 27 Ghidra DB files, 3 archive parts, 2 zips, brave.exe, the EXE). Controls: git ls-files -- <every purge path> empty; tools/audit_public.py → OK (it FAILED by design until this commit); make check-env 0; main byte-identical with tools/psyq/ + .run/obj40 + .run/obj42 moved aside (the public WITHOUT leg needs none of it). A5's recorded run stays valid for this tip: a --cached removal changes no tracked-content byte.

3. C1 — the tooling (Claude; design Max, execution xHigh)

.venv/bin/pip install git-filter-repo==2.47.0 (SETUP row, R21). tools/public_rewrite/:

Tool Does Measured (S87; C2 re-measures on the final tree)
purge_set.txt the purge paths (exists since B7) 8 rules
gate_scan.py --all | --refs <refs> [--worktree] [--expect-fail tools/public_rewrite/expected_offenders.txt] the first-push gate over history: scans every blob reachable from the refs (and the worktree) for purge-path prefixes, content SHA1s in the known-ROM set (the EXE, the redump Track 1, every sha1 in extracted/retail/manifest.jsonl, every config/check.*.sha), byte signatures (PS-X EXE at offset 0; the 2,097,152-byte RAM image with the resident's first words at 0xCEDF8; the EXE entry code; PsyQ LIB\x01 / LNK\x02 magics), any blob > 50 MiB; emits rom_blob_ids.txt. --expect-fail FIXTURE = the R39 negative control: the fixture lists rule<TAB>min offending paths; exit 0 only when every rule has at least that many AND no content/signature/size offender sits outside the purge rules; rom_blob_ids.txt = content hits ∪ every blob ever under a purge path measured S87 on the current repo: 112,390 blobs / 16.8 GB in 2 m 25 s; paths ever: ghidra/ 42, tools/psyq/ 190, dumps 28, archive 3, zips 2, brave.exe 1, the EXE 1+1; 0 strays; 52 content/signature ids + 269 blobs ever under a purge path
hash_dict.py [--write-mailmap] every commit hash → ordinal (git rev-list --reverse main); off-main commits (the tag lineage, 126) → the ordinal of their (tree, author-timestamp, subject) twin, else orphan-NNN; all prefixes 7..40; asserts 0 ambiguous prefixes and 0 collisions with the content-hash set measured S87: 4,420 commit objects (4,030 main, 339 twins incl. the 126 tag-lineage re-authorings, 51 orphans), 150,280 prefixes, 0 ambiguous, 0 content collisions
scrub.py THE one scrub function: \b[0-9a-f]{7,40}\b → dictionary lookup → commit:NNNN (non-hex in the first 7 chars, so it can never re-match; no Markdown side effects); NUL-sniff binary skip; idempotent at HEAD (S87): 731 distinct resolving tokens, 1,238 replacements in 98 files, git's own lookup agrees exactly; 397 MB in 7.9 s
run_filter.py [--sample] composes the git filter-repo call (below); refuses to run outside a bare repo under .run/public_rewrite/; logs versions + wall time; --sample first (R37) = scrub.py --sample, the independent-oracle check the trial run's numbers are in phase-ends/CURRENT_PHASE.md (S87 C1)
build_commit_map.py [--out PATH] public docs/commit-map.tsv (ordinal new_hash author_date committer_date subject, no old hash anywhere — asserted by running scrub over its own output); private .run/public_rewrite/old-to-new.tsv for the probe 4,0xx rows, exactly one mapped to zeros (the pruned archive-upload commit)
resolve_tokens.py [--check] [--map PATH] at HEAD of the adopted checkout: commit:NNNN → the unique 9-char new abbreviation (asserted by git cat-file --batch-check); --check asserts zero resolvable tokens remain and lists the orphan residue ≥1 orphan: tools/verify_worktree.py cites a dropped TEMP commit
verify_rewrite.py --old ~/bfm-decomp --new .run/public_rewrite/repo.git the pairwise proof (C5): old commits from the ORIGINAL repo (the clone gc's them away), new from the clone every pair
absent_scan.py [--repo PATH] [--tree HEAD] every text blob + every message + every ref → 0 old-hash prefixes, 0 personal addresses, 0 session URLs, 0 trailer lines, every identity = noreply, no replace/original/tag refs (≈7 min over all objects; INFO: bare UUID count) 0 offenders (the current repo: FAIL, 82,362 — its positive control)
probe_github.sh Drew's post-purge probe (C10) —
mailmap (scratch, .run/public_rewrite/mailmap, written by hash_dict.py --write-mailmap from the log's identities) the two personal identities → the noreply identity never committed

Budget: regex+lookup ≈2.2 CPU-min over 16.8 GB of blobs; the filter-repo stream dominates (10–30 min). Disk: a bare --no-local clone ≈0.6 GB + the bundle ≈0.6 GB; no working-tree copy (the WSL disk is capped at 75 GB, ≈13 GB free).

4. C2 — negative control, dictionary, sample (Claude)

  1. gate_scan.py --all --worktree --expect-fail tools/public_rewrite/expected_offenders.txt on the CURRENT repo → must report PASS (= the scan fails exactly as the fixture says; the scan that PASSES with 0 offenders in C5 is this same tool).
  2. hash_dict.py → prints the counts; assert 0 ambiguous, 0 collisions.
  3. run_filter.py --sample → the sample numbers above.

5. C4 — backups, then the rewrite

C4a (Claude) — the bundle: git bundle create .run/public_rewrite/pre-rewrite.bundle --all --reflog && git bundle verify .run/public_rewrite/pre-rewrite.bundle (≈0.6 GB). This is the local restore point for everything below.

C4b (Drew) — the archive mirror:

# on GitHub: New repository → Druthulu/BFM-decomp-archive → Private → EMPTY (no README, no .gitignore, no license;
#            NOT "import" and NOT a fork)
cd ~/bfm-decomp
git remote add archive https://github.com/Druthulu/BFM-decomp-archive.git
git push --mirror archive

Check (Claude): git for-each-ref --format='%(objectname) %(refname)' | sort equals git ls-remote archive | sort (same ref set, same hashes; the tag included). Only then may the rewrite start.

C4c (Claude) — the bare clone + the rewrite:

git clone --no-local --bare ~/bfm-decomp .run/public_rewrite/repo.git
cd .run/public_rewrite/repo.git
git tag -d S76-pre-scrub-backup            # the archive keeps it (decision 11)
git for-each-ref                          # must be exactly refs/heads/main at the prep commit
git count-objects -v                      # one pack, zero loose objects
.venv/bin/python ../../../tools/public_rewrite/run_filter.py   # composes:
#  git filter-repo --invert-paths --paths-from-file purge_set.txt --strip-blobs-with-ids rom_blob_ids.txt \
#      --blob-callback <scrub every text blob> --message-callback <scrub + drop 'Claude-Session:' trailers> \
#      --prune-empty auto --replace-refs delete-no-add --mailmap .run/public_rewrite/mailmap

Why each flag: --prune-empty auto, never always (main carries one pre-existing empty commit from 2026-08-25 that must survive); --replace-refs delete-no-add (no refs/replace/<old> names may be minted — they would leak old hashes); --strip-blobs-with-ids catches the EXE wherever it was renamed; the message callback also strips the 60 remaining Claude-Session: trailer lines the S76 scrub missed. Checks: exit 0; the commit map has (old main count) rows; the rows mapped to zeros are EXACTLY the commits whose every change was a purge path (verify_rewrite derives that set — 1 on this history: "session archive update"); no refs/replace; one pack. Pack size: filter-repo's own gc leaves ≈500 MB (trial #1: 534 → 520 MB — the 16 GB of scrubbed text history re-deltas poorly; the purged binaries ARE gone: the archive/ghidra/dump blobs are absent from the store); C9's local gc uses an aggressive repack — measured on trial #2: git -c pack.threads=16 repack -adf --window=250 --depth=50 took the 500 MB pack to 80 MB in 166 s. Lesson from trial #1 (S87): stripping blobs BY ID must never include a blob that also lives under a non-purge path — the EMPTY blob (an empty file once sat under ghidra/) was in the list, and --strip-blobs-with-ids then dropped every "file emptied" change in history: those files silently kept their previous content and a later restore commit became empty and was pruned. gate_scan now excludes shared blobs from rom_blob_ids.txt (content/signature hits are always kept), and verify_rewrite asserts both that no purge path survives in any new tree and that the pruned set equals the derived purge-only set.

6. C5 — verification on the rewritten clone (Claude; every check an exit code)

verify_rewrite.py — for every (old, new) pair: names/e-mails (post-mailmap) and BOTH timestamps equal; new.message == scrub(old.message); new parents = map(old parents) with the pruned commit spliced out; git diff-tree -r --no-renames old new: every D is a purge path or a ROM blob id, every M satisfies hash-object(scrub(old_blob)) == new_blob, any A FAILS; prints the pair count. Then gate_scan.py --refs --all → PASS (the same tool that failed in C2); absent_scan.py → 0/0/0; git rev-list --count main = old count − pruned; the %at %ct lists match with the pruned commits removed; the pre-existing empty commit's twin exists; no purge path in any new tree; the pruned set == the purge-only commits. A commit the rewrite leaves BYTE-IDENTICAL keeps its hash (old == new — the noreply-authored "Initial commit", which cites no hash and touches no purge path): build_commit_map records those in .run/public_rewrite/unchanged_commits.txt, absent_scan does not count their prefixes as old hashes, and probe_github.sh skips them (they legitimately still resolve on GitHub). Measured trial #1: filter 274 s, verify 385 s, absent_scan 346 s over 16.2 GB of text.

7. C6 — adoption in ~/bfm-decomp (Claude; NO gc yet)

git fetch .run/public_rewrite/repo.git +refs/heads/main:refs/heads/main-rewritten
git diff --stat main main-rewritten        # ONLY text files (≈93) and no purge path
git reset --hard main-rewritten            # on main; the purged paths are untracked+ignored since C3 → they stay on disk
git status --porcelain                     # empty
git branch -D main-rewritten; git stash drop (each); git tag -d S76-pre-scrub-backup; git update-ref -d refs/original/... (if any)

git ls-files | wc -l equals the clone's tree count. No gc yet: origin/main still pins the old lineage until Drew pushes, and the old objects are the local safety net until C9's counts pass.

8. C7 — commit map + tip resolution (Claude)

git config user.email 50529377+Druthulu@users.noreply.github.com (the noreply identity, before the tip commit). build_commit_map.py → docs/commit-map.tsv; resolve_tokens.py; checks: git grep -c 'commit:[0-9]' HEAD = 0; the orphan residue listed in the commit message; every inserted 9-char hash resolves uniquely; absent_scan.py --tree HEAD = 0. Commit: docs(phase-33): commit-map + citations resolved to the rewritten history.

9. C8 — R22 on the adopted tree (Claude, ≈15 min here)

tools/verify_contract.sh (the A5 script) → .run/P33/verify/ refreshed; check-all: 218 passed, 0 failed of 218; report still 100.00 / 100.0 / 100.0. Commit the refreshed evidence (a content-preserving rewrite changed no tracked byte — this run proves it).

10. C9 — final gate, force-push, gc

Claude: gate_scan.py --refs main --worktree → PASS. Drew:

git ls-remote --tags origin                         # is S76-pre-scrub-backup on origin?
git push --force origin main
git push origin :refs/tags/S76-pre-scrub-backup     # if it was
git fetch --prune origin && git rev-parse origin/main main    # equal

Claude, after Drew's word — measured S87: (1) git remote remove archive (its remote-tracking ref pins the old lineage; the mirror push is done); (2) git worktree list — every linked worktree's HEAD counts as REACHABLE: S87 found 12 stale campaign worktrees (.run/S74/wt_*, .run/pgate/wt*, .run/S69_fable3/…, ~/bfm-verify) at old commits — 12 GB of old-history checkouts, each holding the SDK/EXE/Ghidra on disk — git worktree remove --force <path> each, then git worktree prune (rev-list --all went 8,146 → 7,763 after the remote, → 4,034 only after the worktrees); (3) git reflog expire --expire=now --all && rm -f .git/objects/info/commit-graph && git -c pack.threads=16 repack -adf --window=250 --depth=50 && git prune --expire=now && git commit-graph write --reachable (a stale commit-graph names pruned commits and makes fsck fail; 163 s). Checks: git rev-list --all --count == git rev-list --count main (4,034), objects in store == reachable (176,056), git fsck clean, gate_scan.py --all --worktree PASS, absent_scan.py --repo ~/bfm-decomp PASS (≈7 min), .git size (S87: one 80 MB pack, .git 93 MB, from 1.5 GB). Then the probe baseline (probe_github.sh): every sampled old hash still ALIVE is expected until the Support purge — that count (S87: 31 of 33) is what the ticket asks GitHub to make zero.

11. C10 — the Support purge, the probe, the flip (Drew; decision Max)

The ticket (GitHub Support → "Remove data from a repository" / force-push cleanup), text:

Repository: Druthulu/BFM-decomp (private; no forks — network_count 0; no pull requests). On 2026-09-07 05:55 UTC I force-pushed a rewritten history to main that removes proprietary game binaries (a PlayStation executable, RAM dumps and a vendor SDK) and personal session data from every commit; an earlier force-push on 2026-09-03 22:05 UTC left a second unreachable lineage. The old commits are still served by SHA — for example the two pre-force-push tips 3a8af85160f1ae837d9c1b24e78d6fc7530d27fd and 71fc1600397e93c78850e2079832d62b1a8bf664 (both listed as "before" in the repository's Activity view) return 200 from the commits API and can be fetched. Please garbage-collect all unreachable objects in this repository and purge cached views (commit pages, raw blob URLs, API lookups by SHA) so those SHAs return 404. The repository will be made public only after that; please let me know when it is done so I can re-verify. Thank you.

Filing — the route that worked (2026-09-07, ticket #4736982): https://support.github.com/request signed in as the owner → "Remove data from a repository I own or control" → "Clear cached views" → on the Repositories form press the blue "Clear cached views with our Virtual Agent" button (NOT the static form) → "Yes, but I need help removing of cached commits" → "No - Just the repository" (repository-wide, not one commit) → Druthulu/BFM-decomp → in a pull request? No → the reason, in ≤500 characters: third-party proprietary binaries were in the history, removed and force-pushed, purge the whole repository's unreachable objects before it goes public. The agent files the ticket itself. Trap: the static form's "Deletes" sub-option is the delete-the-whole-repository flow (asks for the URL to delete and a purge confirmation) — never submit it. Turnaround is days, not hours, and GitHub publishes no GC schedule; the long text above is for a human follow-up.

Why the flip cannot precede the purge (measured S89, 2026-09-07 — the "no one has the old hashes" premise is false): GitHub's repository Activity view (GET /repos/Druthulu/BFM-decomp/activity, the Activity tab in the UI) lists every ref update since the repo was created — 157 rows back to 2026-06-11, including both force-pushes with their before SHA (the pre-rewrite tip 3a8af85160… and the S76 tip 71fc1600…) and 154 pushes whose before/after are old-lineage SHAs. Unlike the events API (whose rows carry public: false), these rows carry no visibility flag; assume every reader of a public repo sees them. From those SHAs, today, the API serves the commit, extracted/retail/SLUS_007.26 (413,696 bytes, download URL present), all 28 dumps/*.bin and the 3 session archive parts, and git fetch origin <sha> succeeds (the probe: 31 of 33 sampled old hashes ALIVE; the S76 lineage has been unreachable for 4 days with no GC). So a clean tree and a clean history are necessary but not sufficient: until the objects are gone from GitHub's store, the repo's own Activity tab is a one-click path to the purged binaries. A hash that resolves to 404 is harmless, so the purge alone closes it. The deterministic alternative (the fallback below, promoted): delete the repository and recreate it under the same name, then push the rewritten main — a new repository is a new object network AND a fresh Activity log, so the probe passes by construction; nothing registered against the repo id yet (no issues/PRs/stars/wiki/secrets; the Actions runs re-run on the new push; the archive repo, Druthulu/xsig and the permuter fork are separate). gh repo delete needs the delete_repo scope (gh auth refresh -h github.com -s delete_repo); a deleted repo stays owner-restorable for 90 days, not servable.

The probe (tools/public_rewrite/probe_github.sh, needs gh auth login in Drew's shell): for 30 sampled full old hashes + the pruned commit + the old tag tip: gh api repos/Druthulu/BFM-decomp/commits/<sha> must return 404 and a git fetch of the sha must fail; positive control: the PUSHED tip (origin/main) must succeed (the local main may carry unpushed commits, S88). After the flip, also probe 7-char prefixes unauthenticated at github.com/Druthulu/BFM-decomp/commit/<7>. While ANY probe returns 200: wait and re-run daily. R57 (S88, 2026-09-07): the fetch check runs in a THROWAWAY bare repo (.run/public_rewrite/ probe_scratch.git, --filter=blob:none --depth=1, deleted on exit) — a successful git fetch origin <old-sha> pulls that commit's whole closure (the purged EXE, dumps, Ghidra DB, SDK) into the repository that runs it. The S87 baseline run and the first S88 run did exactly that to ~/bfm-decomp (30 packs / 5.97 GiB of unreachable old objects on top of C9's one 80 MB pack); the probe now ends with a self-check that names any sampled old commit the working repo still holds and prints the C11 recipe (git reflog expire --expire-unreachable=now --all && git gc --prune=now) — run it (Drew; the auto-mode classifier refuses it from a Claude shell) and the store returns to one pack. Fallback if Support stalls: delete the repository and recreate it under the same name, push the same rewritten history (nothing else exists to lose — the archive repo and the bundle hold the old history).

The flip: Settings → General → Danger Zone → Change visibility → Public — ONLY after the probe exits 0 (and enable Settings → General → Features → Wikis first: has_wiki read false on 2026-09-07, and F3's push needs it) and Blocks D (README, LICENSE, NOTICE, THIRD_PARTY, badges), E and F have landed on the still-private repo. Then D3's outward actions (frogress slug, decomp.dev registration), E1 (the decomp.me preset — Drew's six steps are in docs/decompme-preset.md §5 and the phase checkpoint §3; bundle .run/decompme/drew_bundle/), E2 (the Archipelago message), F3 (the wiki push: create the first page in the GitHub UI — Wiki → "Create the first page" — then tools/wiki_sync.sh --push; the pages are authored in docs/wiki/ + docs/how-to-ai-decomp/ and the script replaces the wiki's pages with the rendered set).

Drew's post-flip checklist (consolidated S89, 2026-09-07): (0) probe daily until PASS; push; Actions green → (1) the flip + --after-flip probe + enable Wikis → (2) E1: docs/decompme-preset.md §5 (scratch → 100% → preset-request issue → manual search) → (3) E2: the issue in docs/outreach/archipelago.md §4 → (4) D3 outward: decomp.dev manage/new, frogress slug bfm + key, frogress_upload.py --push → (5) wiki: first page in the UI, tools/wiki_sync.sh --push → (6) tell Claude → C11 → G2. The same list is phase-ends/CURRENT_PHASE.md §0b.

12. C11 — aftercare

  • Every other clone of the old history (the Windows tree, any other machine): git fetch origin && git reset --hard origin/main && git reflog expire --expire=now --all && git gc --prune=now — or re-clone. Never git pull (an 8,000-commit merge of two unrelated lineages).
  • git remote remove archive — done at C9 (it pinned the old lineage); never re-add it to the working repo.
  • .run/ (38 GB) → prune regenerables; .run/public_rewrite/ (old hashes, the mailmap, the bundle) → keep until the probe has passed, then delete the clone and the dictionary; keep the bundle off-machine if wanted.
  • Docs: phase-ends/DIGEST.md §1 (H1 in force again; R1/R20 historical), docs/decision-log.md (R31), SETUP's posture section (D4), CLAUDE.md (the git clean -x guard — in place since C3).

13. Risk register (condensed)

Risk Guard
A new hash written into a historical doc (changes every descendant hash) tokens in history, real hashes only in the tip commit (fixed-point rule)
git clean -x after C3 deletes the RE database CLAUDE.md fail-safe line; the bundle; the archive repo; the text export config/ghidra/ + ghidra_rebuild.sh --proof
GitHub keeps serving force-pushed-away objects the Support purge + the probe gate; the earlier S76 pre-scrub lineage sits unreachable on GitHub too and is covered by the same purge
A fork/import-created archive shares the object store the archive is created EMPTY and receives a --mirror push
A scrub false positive (a binary SHA1 abbreviation coinciding with a commit prefix; expected < 1 over 44k tokens) the content-hash exclusion set; ambiguous prefixes become commit:amb-N, never a wrong hash
An old clone git pulls the new history the C11 reset recipe; announced before the force-push
Disk (13 GB free) / time clone 0.6 + rewrite 0.2 + bundle 0.6 GB; rewrite 10–30 min; C8 ≈15 min
The mailmap is cosmetic unless the 3 tracked files mentioning the addresses are also replaced they are (the blob callback) — and absent_scan.py asserts 0 occurrences everywhere

14. Rollback

  • Before C9's force-push: nothing on GitHub has changed; git reset --hard origin/main (or restore from the bundle: git clone .run/public_rewrite/pre-rewrite.bundle) returns the clone to the old history.
  • After the force-push: the archive repo and the bundle hold the complete old history; git push --mirror from the archive into BFM-decomp restores it (it would then need the Support purge again). Old objects on GitHub are unreachable, not gone, until the purge — which is why the flip waits for the probe.