Git History Rebuild
Discard a repository's published history and replay the same file tree as a sequence of commits that reads like the work was done in order: dependencies before the code that uses them, each module its own commit, tests and docs where the repo's own convention puts them, messages in the repo's own format, timestamps spaced instead of stamped to one second. The tree at the end is byte-for-byte what it was at the start — only the path to it is rewritten.
Why the ceremony: this is an irreversible, outward-facing rewrite of a shared remote, and it is strictly more dangerous than a plain squash. It destroys every commit and invents a new structure to replace them, so a mistake is not just lost history — it is a published history that misrepresents the work. Each gate below closes a specific way that goes wrong: pushing without write access, erasing a contributor's attribution, discovering at commit 14 that the commit-msg hook rejects the format, shipping a changelog full of features that were never split that way, or re-tagging a version that a package registry already froze.
Core principle
NOTHING IRREVERSIBLE UNTIL SIX THINGS HOLD: write access is confirmed, a mirror backup exists and is verified, the repository's own commit rules are read and obeyed, the user has approved the exact commit plan, the user has answered what happens to the existing tags, releases and contributors sidebar, and the user has confirmed the force-push itself. If any one is missing, stop at that gate.
Five invariants hold throughout:
- Never operate on the user's existing checkout. All work happens in a fresh clone in a scratch directory. If the result is wrong, the scratch clone is disposable and the user's working copy was never touched.
- The tree is sacred through Phase 9; only the history is rewritten. Every tracked path lands in exactly one commit, and the tree must diff clean against the old tip — once locally before the push, and once more in a fresh clone of the remote afterwards. A rebuild that changes a file has failed, however good the log looks. Files whose content describes the erased history (a changelog, a badge, a pinned sha) are repaired in Phase 10, as one approved commit on top of the proven tip — never inside the rebuild, and never as a second rewrite.
- Never invent work that did not happen. Split along seams that exist in the final tree. A
fix:commit is honest only when the tree actually carries the fix; a fabricated bug-and-repair arc is a lie in the changelog, and this skill does not write one. Seereferences/commit-splitting-patterns.md. - The repository's rules outrank this skill's defaults. If
CONTRIBUTING.md, a commitlint config, a hook, or the existing log says commits look a certain way, that is the format — always, including when this skill's default is nicer. - What survives the rebuild is the user's call, not the run's. Tags, releases, the contributors sidebar, the tree's own references to the erased history, and the merged pull requests whose commits leave the branch — all five outlive the rewritten branch. Each is asked at gate #1 and executed as answered. "I checked and there was nothing to do" is the failure mode this exists to prevent: an API response is not the rendered page, and a cost the run judges too high is a fact to report, not a decision to take.
- What the host records is disclosed, never chased. The force-push, the branch rename and every tag deletion are written to the repository's public activity log, which has no delete endpoint and no documented expiry. The run states that before the push and does not spend a step trying to bury it — an append-only log answers a second rewrite with a second row.
Invocation
/awesome-git-history-rebuild <repository-url-or-path> [branch] [--plan <file>] [--commits N] [--span <duration>] [--sessions N]
[--mode story|bisectable] [--tags keep|delete] [--releases keep|delete] [--contributors clean|skip]
[--drift fix|disclose] [--merged-prs attribute|skip] [--sign auto|on|off] [--release <version>]
<repository-url-or-path>— required. A remote URL (https://…,git@…) or a local path. A local path with no remote is supported: everything runs except the push. A directory that is not a git repository at all is supported too — there is no history to erase and nothing to compare against, so Phases 1, 8, 9 and 10 are skipped and the skill becomes "initialize with a curated history".[branch]— optional. Defaults to the detected default branch. Never hardcodemain.--plan <file>— optional. A commit plan written before this run, usually byawesome-git-commit-plan: commits numbered#1to#N, each with its message and its exact file set. Given one, Phase 4 validates and presents it instead of proposing a split of its own, and Phase 3 narrows to what that validation needs. Without it, the split is built here exactly as before. Ask for a plan file at the start (see below) rather than assuming the user has none.--commits N— optional target count, ignored under--plan. Otherwise proposed from repo size (see the granularity table inreferences/commit-splitting-patterns.md).--span <duration|anchored>— optional wall-clock length the rebuilt ladder covers, ending at "now" (4h,3d,2w). Default: the span the replaced history actually occupied, measured from the backup.anchoredinstead starts the ladder at the repository's earliest evidenced activity, which can predate the history being replaced — the date is recovered in Phase 5, escalating toawesome-git-history-salvagewhen the current refs do not reach far enough. Any span longer than the measured one and not backed by such evidence is backdating and needs a stated reason (Phase 5).--sessions N— optional number of sittings the span is split into. Defaultclamp(round(span ÷ 24 h), 1, 6).--mode—story(default: logical layered split; intermediate commits are not guaranteed to build) orbisectable(fewer, coarser commits, each verified to build).--tags,--releases,--contributors,--drift,--merged-prs— optional. Pre-answer the five end-state decisions so the run needs no interactive gate for them. Omitting them does not choose a default: the run must ask (Phase 0, step 26). There is no "leave it alone" fallback the run may take on its own.--sign auto|on|off— optional.auto(default) signs when the repo, the account or arequired_signaturesruleset already indicates signing, and asks otherwise.onrequires a working signing key and fails the preflight without one;offis a stated choice, recorded in the report.--release <version>— optional. After the push, cut this version with the repository's own release tooling. Implies--tags delete --releases deleteunless those are given explicitly.
If the user invokes the skill without a target, ask for one before doing anything else.
Ask where the split comes from, once, at the start. Two paths reach the same Phase 6, and the user picks:
- A plan file they already have (
--plan) — written byawesome-git-commit-plan, or by hand. Its commits are already grouped and worded, andawesome-git-commit-planadditionally proves the ladder builds, which is the property--mode bisectableotherwise has to establish here. This run validates it against the tree, presents it as the Phase 4 table, and takes approval on that table like any other. - No plan — the split is proposed here, as it always was. This stays the default when the user has nothing prepared.
Ask before the backup, alongside gate #1, and never assume the absence of --plan means the user has no file. A plan that exists and is not used costs the run its cheapest input.
Five things outlive the rebuild, and none of them is the run's to decide: what happens to the existing tags, to the releases attached to them, to the contributors sidebar, to the files whose content describes the erased history (a generated changelog is the usual one), and to the merged pull requests whose commits leave the branch while their records do not. All five are asked at gate #1, before the backup, and executed in Phases 10–12 exactly as answered. A run that reaches Phase 13 having quietly left any of them alone has skipped a decision, not made one.
One thing outlives it that nobody decides: the host's own log of the force-push. Phase 0, step 18 states what it keeps and for how long, before the push rather than after it.
Tooling check (run first)
git --version— required. Everything destructive is plain git.- A host CLI — optional but strongly preferred:
gh(GitHub) orglab(GitLab) verify write permission, branch protection, open pull requests and fork count before the destructive step, and are the only way to delete a release. Without one, write access cannot be confirmed until the push itself — say so explicitly and proceed only after the user accepts that blind spot. For Bitbucket, Gitea/Forgejo, Azure DevOps or a plain SSH remote, assume no CLI and treat those gates as unavailable, not passed. - The repo's own toolchain (
npm/pnpm,cargo,go,python, …) — needed only in--mode bisectableand to validate messages against a commitlint hook. Detect it; never assume it. gitleaks version— optional. A history being erased is the last chance to notice a secret in it; without gitleaks, report that history was not scanned.
Confirm each is on PATH (exit 0) before relying on it.
Shell. Detect the platform before running anything (uname -s, or $IsWindows in PowerShell) and pick the shell from that check rather than from habit. The bash blocks below are POSIX shell — arithmetic for ((…)), RANDOM, awk, wc, xargs, while read — and PowerShell parses none of it. On Windows run them in Git Bash, which ships with Git for Windows and carries every one of those tools. Where a PowerShell twin is given (the timestamp ladder in Phase 5), the two are equivalent: run the one matching the detected platform, never both.
Phase 0 — Preflight and access verification (stop gates)
Every failure here is a hard stop, not a warning to push past. Everything in this phase is read-only.
- Parse the target. A URL gives
<owner>/<repo>; a local path is cloned into scratch in Phase 1 so the user's checkout is never the workspace. For a local path, check the user's own checkout is clean first (git status --porcelainempty) — uncommitted or untracked work is not carried into the clone and would silently vanish from the rebuild.
A — Identity and credentials
The push has to be someone, and every commit is stamped with that identity. Establish who, and through which credential, before anything else.
A git identity exists and is the intended one. An unset or wrong
user.emailproduces a whole rebuilt history attributed to nobody, or to the wrong account:git config user.name && git config user.email git config --show-origin user.email # which config file won — global, local, or a conditional includeEmpty → stop; ask for the identity to commit as. A machine with several git identities (personal and work) is exactly where this goes wrong silently, so quote the resolved email back to the user before continuing.
The host credential is authenticated, and as whom:
gh auth status # GitHub: account, host, token scopes, protocol glab auth status # GitLab: host and user ssh -T git@github.com # SSH remote: prints the account the key authenticates as git config credential.helper # HTTPS remote: which store answersMultiple logged-in accounts, or an SSH key that resolves to a different account than
gh auth statusreports, is a hard stop until the user says which one pushes. This is the check that catches "the commits went out under the wrong account" before the rewrite instead of after.Token scopes cover what this run needs.
gh auth statusprints the scopes; compare against the work:repo(GitHub) /write_repository(GitLab) — the force-push itself.workflow(GitHub) — required if any commit contains.github/workflows/**. A rebuild re-adds every workflow file, so a token without this scope has the push rejected withrefusing to allow an OAuth App to create or update workflow. Almost every repo with CI hits this; check it now.repoagain for deleting releases in Phase 12, and for the branch rename in Phase 11.- SSO / SAML — an org that enforces single sign-on needs the token explicitly authorized for it (
gh auth statusflags it; an unauthorized token returns 403 with an SSO header). Stop and have the user authorize it.
Hard rule — the remote's owner must match the pushing identity.
gh api user -q .login # GitHub glab api user # GitLab — read `username` from the JSONWith a host CLI, compare
<owner>to the authenticated login, case-insensitive; for an org- or group-owned repo the names will not match, so fall back to the write-permission check below as proof. Without one, match<owner>againstuser.nameor the local-part ofuser.email. Mismatch → stop, report both sides, and continue only on the user's explicit confirmation that they mean to rewrite a repo owned by another account.
B — Permission on the remote
Read access and existence — the cheapest real check:
git ls-remote <repository-url>Non-zero exit or an auth prompt → stop. Wrong URL, private repo without credentials, or no network.
Write permission, stated by the host (needs a host CLI):
gh repo view <owner>/<repo> --json viewerPermission,isFork,parent,forkCount,isArchived,visibility,createdAt glab api projects/<url-encoded-path> # permissions, archived, forked_from_project, forks_count, mirrorGitHub:
viewerPermissionmust beWRITE,MAINTAINorADMIN—READornullmeans the push cannot succeed. GitLab: the effective level underpermissions.project_accessorpermissions.group_accessmust be ≥40(Maintainer);30(Developer) cannot force-push a protected branch. URL-encode the GitLab path (group/sub/repo→group%2Fsub%2Frepo).Write permission, proven by the wire — the only check that does not depend on a CLI, and the one that catches a deploy key, a read-only token or an expired credential:
git push --dry-run origin HEAD:refs/heads/<branch>Run it from a clone of the current tip, so it is a genuine no-op that still performs the server-side permission handshake.
403,denied, or an auth prompt → stop. Without a host CLI this is the primary write-access gate, and its result must be reported as such.The repository accepts writes at all.
isArchived/archivedtrue → stop: an archived repo is read-only and every push is rejected until it is unarchived. A GitLab project withmirror: trueis a pull mirror — it overwrites whatever is pushed to it on its next sync, so a rebuild there is silently reverted; stop and say so.
C — Rules that reject a push
Each of these fails at push time, after the backup and the whole rebuild are done. That is the expensive way to learn them.
Classic branch protection:
gh api repos/<owner>/<repo>/branches/<branch>/protection glab api projects/<url-encoded-path>/protected_branches/<branch>404means unprotected — good. A200with force-push disallowed, required reviews, linear history or required status checks → stop; the user lifts protection or grants a bypass first (GitHub: Settings → Branches; GitLab: Settings → Repository → Protected branches, whereallow_force_pushis the field that matters).Rulesets — the check most runs forget. GitHub rulesets are a separate system from classic protection: the protection endpoint answers
404while a ruleset still blocks the push. Ask for the effective rules on the branch:gh api repos/<owner>/<repo>/rules/branches/<branch> gh api repos/<owner>/<repo>/rulesets # includes org-level rules inherited by the repoAnything in the result blocks or constrains the rebuild, and each maps to a different fix:
non_fast_forward→ force-push is forbidden outright. Hard stop.required_signatures→ every rebuilt commit must be signed; feed that into Phase 2 before the plan is built, not after 25 unsigned commits exist.required_linear_history,required_status_checks,pull_request→ the branch cannot take a direct push at all.commit_message_pattern,commit_author_email_pattern,committer_email_pattern→ a server-side format rule that every rebuilt message and identity must satisfy. Read the regex and hand it to Phase 2 as a binding constraint.tagrulesets → they govern Phase 12; record them now.
Server-side hooks on self-managed hosts (GitLab push rules, Gerrit, Bitbucket Server hooks) — a self-managed instance can enforce a commit-message regex, a maximum file size, or a "no force push" rule that no API exposes cleanly:
glab api projects/<url-encoded-path>/push_rulePresent → treat its
commit_message_regex,max_file_sizeandmember_checkfields as binding constraints on the plan. No API and no CLI → declare it an unverified blind spot rather than a passed gate.
D — Repository state and blast radius
Detect the default branch (unless one was passed):
git ls-remote --symref <repository-url> HEADThe
ref:line names it. Use its short name as<branch>; never hardcodemain.Measure the shape of the existing history — the number this whole decision hangs on. Commit subjects are not the signal: a log can be flawless Conventional Commits with scopes and a generated changelog and still be one dump with follow-ups bolted on. What a rebuild fixes is the distribution of the tree across commits, so measure it here, before the gate — not in Phase 3, after the backup:
git rev-list --count <branch> # total commits git ls-tree -r --name-only <branch> | wc -l # tracked paths (works in a bare clone too) for h in $(git rev-list <branch>); do echo "$(git show --name-only --format='' $h | grep -c .) $h $(git log -1 --format=%s $h)" done | sort -rn | head -5 # files per commit, largest first git log --format='%an' <branch> | grep -c '\[bot\]' # bot commits inside that totalRun it read-only against the user's existing checkout, or — when only a URL was given and nothing is cloned yet — against a bare clone in scratch (
git clone --bare, the same one the dry-run push in step 8 needs). Report concentration = files in the largest commit ÷ tracked paths. Above ~50%, one commit carries the tree and everything after it is a follow-up — the case this skill exists for, however good the subjects look. Below ~20% with a conventional log, the history is already granular and the user should hear that before approving a rewrite. Report the number and let the user weigh it; never substitute an impression of the subject lines for this measurement.Hard rule — a second branch with unmerged work stops the run. Other branches keep the old history reachable, so the "clean history" is incomplete, and they usually hold work about to be stranded:
git ls-remote --heads <repository-url> # per other branch — 0 ahead means <branch> already contains every one of its commits gh api "repos/<owner>/<repo>/compare/<branch>...<other>" --jq '.ahead_by' glab api "projects/<url-encoded-path>/repository/compare?from=<branch>&to=<other>" --jq '.commits | length'Classify before stopping. A branch the tip already contains — a stale
dependabot/*, a landed feature branch — strands nothing: list it as debris the user may delete, not as a stop. A branch ahead by one or more commits is a real stop: list them and hand the user the choice — continue (only<branch>is rebuilt, the others keep pointing into the old history) or abort. Never decide this alone.Ask the host, not the local clone:
git branch -r --mergedonly sees remote-tracking refs this checkout happens to have fetched, so a branch that was never fetched reads as unmerged and produces exactly the false stop this step exists to avoid. Without a host CLI, fetch the heads first (git fetch origin 'refs/heads/*:refs/remotes/origin/*'— it writes remote-tracking refs, the one non-read-only act in this phase) or declare the classification unavailable and let the user judge the list.Pull / merge requests — open ones block, and all of them outlive the rebuild:
gh pr list --repo <owner>/<repo> --state open gh pr list --repo <owner>/<repo> --state all --limit 100 --json number,state glab mr list --repo <owner>/<repo>Any open PR references commits that will not exist. Surface the list; the user closes or merges them first.
Every PR record survives the rewrite permanently, and none of them can be deleted. A pull request is a row in the host's database keyed by repository and number — title, author, timeline, and its own
refs/pull/N/*refs — and not one of its fields depends on the branch's commit graph. Rewritingrefs/heads/<branch>cannot reach it. GitHub has no deletion path either: the GraphQL schema carriesdeleteIssue,deletePullRequestReviewanddeletePullRequestReviewCommentbut nodeletePullRequest, andDELETE /repos/{owner}/{repo}/pulls/{n}answers404because the endpoint does not exist. So Insights → Pulse keeps listing the merged PRs, the PR tab keeps its full list, and the only way to clear either is deleting and recreating the repository — which also costs every issue, star, watcher, release and its assets, the Actions history and secrets, the traffic stats and the creation date. Say this at gate #1, in those terms, because a user who asked for a clean history usually believes it covers this too.Those refs are also the reason the wipe is never total. Count the refs, then count what they keep alive that the branch does not. Run the fetch in the scratch bare clone from step 14, never in the user's checkout — it writes twenty-odd remote-tracking refs, the same non-read-only exception step 15 already carves out, and it does not belong in a working copy the user has to live with:
git ls-remote origin 'refs/pull/*' | wc -l git fetch origin 'refs/pull/*/head:refs/remotes/pr/*' refs=$(git for-each-ref --format='%(refname)' refs/remotes/pr) git rev-list $refs --not <branch> | wc -l # commits still served, absent from the tip git rev-list $refs --not <branch> | tail -1 | xargs git log -1 --format='%h %ad %s' --date=short git rev-list $refs --not <branch> | while read h; do git log -1 --format='%an <%ae>' $h; done | sort | uniq -cReport all three. Old commits reachable this way are what defeats the contributors cleanup in Phase 11 — a bot or a co-author whose commits sit on a PR ref stays reachable no matter how the branch is rebuilt.
They are also permanent, which is the part users do not expect.
refs/pull/N/headis a ref, so everything it reaches is reachable, and garbage collection by definition never touches it. A PR branched off the old tip drags its whole ancestry along: in a measured case, 20 PR refs kept 88 commits of an erased history alive — back to its originalInitial commit— retrievable in perpetuity by anyone who runsgit fetch origin 'refs/pull/*/head:refs/pr/*'. Quote the count and the oldest subject at gate #1: it is the honest ceiling on "the old history is gone".Merged PRs are real work whose record survives while its commits do not — offer to reconcile them. The rebuild re-authors the tree to one identity, so a
dependabot[bot]PR that reads "Merged commitabc1234into<branch>" points at a commit the branch no longer contains, while the bumped version it produced sits in the lockfile of the new history with nobody's name on it. That contradiction is visible on the PR page itself, without any forensics. Two things can honestly be done, and the choice is decision #5 at step 26:- Carry the outcome into the plan. Group the paths that hold each merged PR's result — the manifest and lockfile for a dependency bump, the workflow file for an action bump — into their own commit, crediting the bot and naming the PR numbers:
Read the bot's exact address out of the old history (chore(deps): bump the dependencies dependabot opened PRs for Refs: #15, #16, #17, #18, #19, #20 Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>git -C <backup> log --format='%an <%ae>' | grep '\[bot\]' | sort -u); never type a noreply id from memory. The same shape works for any merged human PR whose result is identifiable in the final tree. - What cannot be done, so do not offer it: a merged PR cannot be re-pointed, re-merged, renumbered or recreated under its own number — the record is keyed to the repository and the number, and no API writes it. Synthesizing a merge commit for a diff the final tree does not contain is inventing history, the same rule that forbids fabricated
fix:arcs. A bump whose before state exists nowhere in the tree can be attributed, never re-enacted.
- Carry the outcome into the plan. Group the paths that hold each merged PR's result — the manifest and lockfile for a dependency bump, the workflow file for an action bump — into their own commit, crediting the bot and naming the PR numbers:
Forks (
forkCount/forks_countfrom step 7). Above zero → say plainly that every forker keeps the old history and the rewrite cannot reach them.The host's record of the rewrite — public, permanent, and not deletable. A force-push is logged by the host independently of the commit graph, so "the history is clean" is a true statement about
git logand a false one about the repository page. Read the log now, so the numbers at gate #1 are measured rather than asserted:gh api "repos/<owner>/<repo>/activity?per_page=100" \ --jq '.[] | "\(.timestamp) \(.activity_type) \(.ref) \(.before[0:7])..\(.after[0:7])"' curl -s "https://archive.softwareheritage.org/api/1/origin/<repository-url>/visits/" # third-party snapshot?Four records, three of which never expire:
- The activity log (
/repos/{owner}/{repo}/activity, rendered at Insights → Activity) keeps everypush,force_push,branch_creation,branch_deletionandpr_mergewith both SHAs and the actor — including the rows this run is about to add, the Phase 11 branch rename, and every tag deletion Phase 12 performs. There is no delete endpoint, the reference documents no retention window, itstime_periodfilter acceptsyear, and a repository months old returns rows back to its creation. It is world-readable on a public repo. Nothing overwrites it, either: an append-only log answers a second rewrite with a secondforce_pushrow, so an attempt to bury the first doubles the evidence. Do not spend a step on it — state it. - The events feed (
/repos/{owner}/{repo}/events, the account's public feed, the Atom feeds) carries the same pushes with a 30-day window since 2025-01-30. That one does expire, and it is the only part that does. - The organization audit log keeps its own
git.pushentries on org-owned repositories, under the org's retention and outside the user's control. - Third-party copies — Software Heritage, a GitLab/Codeberg mirror the repo pushes to itself, any existing clone — hold the pre-rewrite history beyond the host's reach entirely. A mirror the repo's own CI maintains will happily receive the rewritten history and keep serving whatever it already had unless it prunes; check both sides.
Deleting and recreating the repository is the only thing that clears the activity log, and it costs everything step 16 lists plus the creation date — which is itself evidence, since a repository whose first commit predates its own creation date reads as a rebuild at a glance. It is not a cleanup step; do not offer it as one.
- The activity log (
What the push will set off. A force-push of N commits is not a quiet event: it fires webhooks, can start a CI run per commit, and on some setups deploys. Read the triggers before pushing:
grep -rl 'on:' .github/workflows/ | xargs grep -l 'push' # which workflows react to a pushThen say plainly what will happen: how many workflow runs, whether any of them deploys or publishes, whether a mirror job will re-push elsewhere, and whether the branch backs GitHub Pages (a force-push republishes the site). If a push triggers a deploy or a publish, that is a decision for the user, not a side effect to discover afterwards.
Hard rule — more than one human author in the history stops the run. A rebuild re-authors everything to the person running it:
git log --format='%an <%ae>' <branch> | sort | uniq -c | sort -rnSplit the result before judging it. Bots have no attribution to erase —
dependabot[bot],github-actions[bot],renovate[bot], anything whose name ends in[bot]or whose address is an app'susers.noreply.github.comalias: report their commit count as a matrix line and move on. Two or more human authors → stop. Erasing someone else's commits erases their attribution, breaks a DCO/CLA audit trail, and in a repo that took outside contributions is not the user's call to make alone. Continue only if the user explicitly confirms they own or have permission for every contribution, and offer the honest alternative: keep the other authors asCo-authored-by:trailers on the commits that carry their code (references/repo-convention-discovery.mdhas the trailer format).Tags, releases and what deleting them would cost — the facts behind the step 26 decision, gathered before the gate rather than argued after the push:
git ls-remote --tags <repository-url> gh release list --repo <owner>/<repo> # tag, latest flag, published date gh release view <tag> --repo <owner>/<repo> --json assets --jq '.assets[] | "\(.name) \(.downloadCount)"' glab release list --repo <owner>/<repo>Three findings, each of which changes the answer the user should give:
- Immutable-registry publication. If any tag matches a version published to npm, PyPI, crates.io, the Go module proxy, Maven Central or NuGet, deleting or moving it is a hard stop in Phase 11: those registries freeze a version to a content hash, and a re-tagged version makes consumers fail checksum verification rather than upgrade. Check the name rather than assuming (
https://registry.npmjs.org/<name>,https://index.crates.io/…); a manifest withprivate: trueorpublish = falsesettles it too. - Uploaded assets and their download counts, per release. They do not come back — a mirror backup restores tags, never a release object or its binaries.
- Anything that reads "the latest release" — an auto-updater endpoint (Tauri, Sparkle, electron-updater), an install script, a docs badge. Deleting every release breaks it until a new one is cut. Grep the tree for the updater endpoint before claiming otherwise.
Note all three now, quote them at step 26, enforce the hard stop in Phase 12.
- Immutable-registry publication. If any tag matches a version published to npm, PyPI, crates.io, the Go module proxy, Maven Central or NuGet, deleting or moving it is a hard stop in Phase 11: those registries freeze a version to a content hash, and a re-tagged version makes consumers fail checksum verification rather than upgrade. Check the name rather than assuming (
References to the erased history inside the tree that survives it. The rebuild keeps every file byte-for-byte — including the files whose content is a claim about the history. Those do not break loudly. They keep rendering, with links that 404, versions nothing points at, and dates that contradict the log beside them. Two of the step 26 answers depend on this list, so build it here.
# commit shas quoted anywhere in the tree — then ask which of them the new history will still contain git grep -hoE '\b[0-9a-f]{7,40}\b' -- ':!*.lock' ':!*lock.yaml' ':!*lock.json' ':!*.sum' | sort -u | while read s; do git cat-file -e "$s^{commit}" 2>/dev/null && echo "$s $(git log -1 --format=%s $s)"; done # links that resolve against the host rather than against git git grep -nE '/(commit|compare|releases/tag|releases/download)/' -- '*.md' '*.json' '*.ya?ml' '*.cff' '*.toml' # badges whose content comes from a release that Phase 12 may delete git grep -nE 'shields\.io/github/(v/release|downloads|release-date|commits-since)' # version claims a tag deletion would strand git grep -nE '"version"|^version *=|^version:' -- package.json Cargo.toml pyproject.toml '*.cff'What turns up, and what leaving it costs:
CHANGELOG.md— the usual worst case, and the one that indicts the rebuild by itself. A generated changelog is a list of commit links andcompare/vA...vBURLs; after the rewrite every one of them 404s against the repository that ships them. Its headings also describe releases whose tags Phase 12 may be about to delete, and it carries dates: a changelog entry for a release on the 27th, inside a tree whose commit adding the release tooling is dated the 28th, is a self-refuting pair any reader hits without opening an API.- README and docs badges —
v/release, download counts, "latest release" links. Deleting the releases empties them; they render asno releases, not as an error anyone notices in review. CITATION.cff(commit:,version:,date-released:),SECURITY.mdsupported-version tables, issue templates that enumerate versions, a docs page quoting a tag.- Self-referencing pins — these break at runtime, not just visually.
uses: <owner>/<repo>@<sha>in the repo's own workflows, a.pre-commit-config.yamlrev:pointing at this repo, an install script curlingraw.githubusercontent.com/<owner>/<repo>/<sha>/…, a Go pseudo-version naming a commit. A sha that leaves the history takes the thing that pins it with it. - Manifest version versus the tags about to go.
package.jsonat0.2.0withv0.2.0deleted leaves the repository claiming a version nothing points at.
Then check chronology, not just links. Sort the planned commit dates against every date written inside the tree — changelog headings, release notes,
date-released, dated docs and runbooks. A commit dated after the artifact it is supposed to have produced is the tell no timestamp model repairs, and it is cheap to avoid while the plan is still a table. Report each conflicting pair; a plan that cannot be ordered to satisfy them is a plan to re-split in Phase 4, not a line in the final report.Repairing any of this changes file content, which Phase 9's tree-identity proof forbids inside the rebuild. It happens in Phase 10, as its own commit on top of the proven tip — decision #4 at step 26.
Secret scan of the history being discarded (if
gitleaksis present) — this is the last moment anyone will look at those commits:gitleaks git . --no-bannerFindings → stop and tell the user to rotate the exposed credential. The rewrite does not make a leaked secret unrecoverable — forks, caches and existing clones keep it — so rotation is the part that protects them. No gitleaks → state that history was not scanned.
E — Local capacity
- Room and limits on this machine. Three copies of the repository exist during the run (the user's checkout, the mirror backup, the scratch clone), plus a fourth for the Phase 9 verification clone. Check free disk against
du -sh .gitbefore starting. Also check what the host will refuse to accept: GitHub rejects any single file over 100 MB and warns above 50 MB, and a push over ~2 GB fails outright.
A hit means those blobs are already in the history (grandfathered or LFS) — confirm LFS is configured before re-pushing them, or the rebuild's push is the moment the limit is enforced. Keep the scratch clone and the backup outside the repository being rewritten.git -C <repo> rev-list --objects --all | git -C <repo> cat-file --batch-check='%(objecttype) %(objectsize) %(rest)' | awk '$1=="blob" && $2>52428800'
F — The gate
Report the preflight matrix, then confirm. List every check as
pass/fail/unavailable— an unrunnable check is a disclosed blind spot, never a silent pass:identity <name> <email> (from <config file>) · credential <account> via <ssh|https> · scopes <list> owner match ✓ · write ✓ (API + dry-run push) · archived ✗ · mirror ✗ protection: none · rulesets: <none | required_signatures | …> · push rules: <none | unavailable> signing <key type, host knows it | none configured | required by ruleset> history <N> commits · largest commit <F>/<T> tracked paths (<P>%) · bot commits <B> branches 1 (+<M> merged, strand nothing) · open PRs 0 · forks 3 PR records <N> — survive permanently, undeletable; Pulse and the PR tab keep showing them refs/pull/* <N> keeping <C> commits permanently reachable, oldest <sha> "<subject>" <date> — GC never collects them merged PRs <N> · reconcilable via attribution: <yes: paths … | no: outcome not identifiable in the tree> human authors 1 · bot authors <list> · push triggers <N workflows, deploys?> host record: activity log permanent (<N> force_push rows already) · events 30 d · audit log <org|n/a> third-party copies <none | Software Heritage <date> | mirror <url>> tree references: <N> shas quoted (<M> leave the history) · <N> host links · badges <list> · pins <list> chronology: <consistent | CHANGELOG 0.2.0 dated 08-27 vs planned release-tooling commit 08-28> tags <list> · releases <N> (assets <n>, downloads <n>) · registry-published <none|list> · reads-latest-release <updater|none>Collect the five end-state decisions — before the backup, not after the push. The rebuild replaces a branch; it does not replace what hangs off the old history. Five things survive it, each is the user's call, each is irreversible or outward-facing, and each is far cheaper to answer now than to discover in Phase 13. Ask all five together with gate #1, quote the step-21 and step-22 findings as the cost, and carry the answers verbatim into Phases 10–12.
- Tags — delete every tag that points into the old history, delete a named subset, or keep them. Say what keeping costs: those tags hold the old commits reachable, so the wipe is not total, and a tag that is no longer an ancestor of the new tip breaks any tooling that computes a range from the last release (
git describe, changelog generators, "commits since"). Say what deleting costs: the hard stop of step 21 applies per tag, and a tag cannot be re-pointed honestly at a rebuilt commit that was never the tree that release shipped. - Releases — delete the releases attached to those tags, or keep them. Per release, state the cost before the answer: the uploaded assets and their download counts are gone for good, the mirror backup restores the tag but never the release object, and anything reading "the latest release" finds nothing until a new one is cut.
- Contributors sidebar — run the Phase 11 cache rebuild after the push, or leave it. Say plainly that the push is the only moment it is cheap, and that no API call can answer this question:
repos/<owner>/<repo>/contributorsand the rendered sidebar are fed by different caches, so a clean API read is not evidence the page is clean. Only the user can openhttps://github.com/<owner>/<repo>and see who is still listed there. - Tree references (decision #4) — repair the step-22 findings in a Phase 10 commit on top, or leave them and disclose. Name the files and what each currently claims. Say what repairing costs: one extra commit that changes content, visible in the log and approved as its own diff,
- Tags — delete every tag that points into the old history, delete a named subset, or keep them. Say what keeping costs: those tags hold the old commits reachable, so the wipe is not total, and a tag that is no longer an ancestor of the new tip breaks any tooling that computes a range from the last release (
…(truncated)