Rewriting History Safely

Removing a committed secret in the right order, what filter-repo does that filter-branch should not, and the coordination cost that makes a rewrite a team event.

advanced 22 min lesson hands-on task included

Rewriting history is the most disruptive thing you can do to a shared repository, and occasionally the only correct response. This lesson is about doing it in the right order, and about the fact that the rewrite is never the step that actually protects you.


Topic 1: What a Rewrite Costs

REWRITING HISTORY REPLACES EVERY COMMIT FROM THE CHANGE FORWARD BEFORE a1b2 c3d4 e5f6 7a8b contains the secret AFTER a1b2 9x8y 2m3n 6p7q every hash after the change is new — even untouched commits WHAT THIS COSTS Every clone is now divergent. Open PRs must be rebased. Tags must be re-pointed. Build pins by SHA break. Old objects survive on the forge until GC — so the secret is still reachable. ROTATE THE SECRET FIRST. THE ORDER THAT ACTUALLY WORKS 1. Rotate the credential → 2. git filter-repo --invert-paths --path secrets.env → 3. force-push all refs → 4. everyone re-clones 5. ask the forge to run GC / delete cached views → 6. add a pre-commit secret scan so it cannot recur Step 1 is the only step that actually protects you. Steps 2–5 are hygiene, and they take a day of everyone's time.
Only the first commit keeps its hash. Everything after the change is a new object — which is why every clone diverges, every open PR must be rebased, and every SHA-pinned build breaks.

Because a commit’s hash covers its tree and its parent’s hash, changing anything rewrites every commit after it. The consequences are all downstream of that one fact:

  • Every clone diverges. Colleagues must re-clone or reset hard; anyone who pulls without knowing gets a duplicated history.
  • Open pull requests break. They reference commits that no longer exist on the branch.
  • Tags must be re-pointed, or they point into an abandoned lineage.
  • Anything pinned by SHA breaks — submodules, dependency locks, deploy manifests, CI caches.
  • The old objects survive on the forge until it garbage-collects, so a rewritten secret can still be fetched by hash for a while.

That last point is why the ordering in the next topic matters more than the commands.


Topic 2: Removing a Secret — the Correct Order

1. ROTATE THE CREDENTIAL.               ← this is the step that protects you
2. Confirm the old credential is dead.
3. Remove it from history (filter-repo).
4. Force-push every ref.
5. Everyone re-clones.
6. Ask the forge to garbage-collect and purge cached views.
7. Add a pre-commit secret scan so it cannot recur.

Step 1 is not negotiable and everything else is hygiene. Assume the secret was read the moment it was pushed. Public repositories are scraped by bots within seconds; private ones are visible to everyone with read access, every fork, every CI log, and every clone anyone ever made. A rewrite removes it from your history — it cannot remove it from a copy.

Teams get this backwards constantly: hours of careful history surgery, then rotating the key the next day, having treated a disclosure as a tidiness problem.

Why deleting the file is not enough:

git rm secrets.env && git commit -m "remove secrets"
# the secret is still right here:
git log -p -- secrets.env
git cat-file -p <old-blob-sha>

The blob is an object in history. Deleting the file adds a commit; it does not remove anything.


Topic 3: filter-repo

git filter-branch is what older documentation recommends. It is slow, has subtle bugs, mangles tags, and Git’s own manual now recommends against it. Use git-filter-repo (a separate install, and the officially suggested replacement) or BFG.

# Always work on a fresh clone — filter-repo refuses on a repo with a remote
git clone --mirror git@github.com:org/repo.git repo-rewrite
cd repo-rewrite

# Remove a file from all of history
git filter-repo --invert-paths --path secrets.env

# Remove a directory
git filter-repo --invert-paths --path config/private/

# Replace a string everywhere it appears
echo 'AKIAIOSFODNN7EXAMPLE==>REDACTED' > replacements.txt
git filter-repo --replace-text replacements.txt

# Fix an email address across all commits
git filter-repo --email-callback '
  return email.replace(b"old@example.com", b"new@example.com")'

# Extract a subdirectory into its own repository, keeping its history
git filter-repo --subdirectory-filter services/api

Then publish and coordinate:

git push --force --all
git push --force --tags

Tell everyone before you push, not after. The message people need:

main was rewritten at 14:00 to remove a committed credential.
The credential was rotated at 13:20 — the old one is dead.

Please re-clone. If you have unpushed work:
  git fetch origin
  git rebase --onto origin/main <old-main-tip> your-branch
Do NOT git pull — it will duplicate the entire history.

That last line prevents the second incident, which is otherwise guaranteed.


Topic 4: What Survives the Rewrite

Even after a correct rewrite, the old objects can persist:

  • On the forge, until its garbage collection runs. GitHub keeps unreachable objects accessible by direct SHA for a while; support can expedite removal, and for a public repository you should ask.
  • In every fork, which the forge does not rewrite for you. A fork of a public repository keeps the secret until its owner acts.
  • In pull request views, which many forges cache independently of the branch.
  • In CI logs and build artifacts, which nobody thinks to check and which frequently print environment variables.
  • In every clone on every laptop, indefinitely.

This list is not an argument against rewriting. It is the argument for rotating first.


Topic 5: Legitimate Rewrites Other Than Secrets

Splitting a repository — extracting a service from a monorepo with its history intact:

git clone --no-local monorepo services-api
cd services-api
git filter-repo --subdirectory-filter services/api
git remote add origin git@github.com:org/api.git
git push -u origin main

Joining repositories — merging one repo into a subdirectory of another, preserving history:

git remote add api ../api-repo
git fetch api
git merge --allow-unrelated-histories api/main
git mv <files> services/api/

Removing accidentally committed large files, which is the other reason a repository becomes unpleasant:

git filter-repo --strip-blobs-bigger-than 10M

Find them first, so you know what you are removing:

git rev-list --objects --all |
  git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' |
  awk '$1=="blob" {print $3, $4}' | sort -rn | head -20

That pipeline lists the largest blobs in the entire history by size — usually a revealing five seconds on any repository that has grown mysteriously.

Correcting authorship across a history where commits were made with the wrong email is the same mechanism, and is usually worth doing only if a DCO or CLA check demands it.


Topic 6: Prevention, Which Is Much Cheaper

Client-side scanning catches the mistake before it becomes history:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks: [{ id: gitleaks }]

Server-side push protection catches what the client hook did not, because client hooks are skippable with --no-verify. GitHub secret scanning with push protection, GitLab secret detection, or a pre-receive hook on a self-hosted forge.

Architectural prevention, which removes the class of problem:

  • Secrets come from a secret manager at runtime, never from a file in the repository.
  • .env is in the global gitignore template, and the repository ships .env.example with placeholder values.
  • Config files that must exist are generated, not committed.
  • CI never echoes environment variables — and masks them if it must.

The one-minute audit worth running on any repository you inherit:

git log --all --full-history --diff-filter=A --name-only --pretty=format: |
  sort -u | grep -Ei '\.(env|pem|key|p12|pfx|jks)$|secret|credential|password'

That lists every file ever added whose name suggests a credential. It is not exhaustive, and it finds something more often than it should.

Try it yourself: commit a fake key, delete it in a later commit, then retrieve it with git log -p and again with git cat-file -p on the blob. Watching the “deleted” secret come back twice is what makes the rotate-first rule stick.

Common mistake: performing the rewrite, telling the team afterwards, and letting someone git pull into a rewritten branch. Their merge reintroduces every old commit — including the secret — and pushes it back. The announcement has to precede the push, and it has to say “do not pull, re-clone” in exactly those words.