Rewrite git history's metadata — author/committer identities, timestamps, and commit messages — with a declarative, repeatable recipe.
Git deliberately lets anyone set these fields;gitforgery automates that surface honestly — and helps you understand what a rewrite can and cannot do.
Git history is a chain of commit objects. Each commit stores, alongside the file
snapshot and its parent pointer, a few labels: author (who wrote it,and when),
committer (who recorded it,and when), and the commit message. Git never
validates these labels — anyone can write any name, email, or date. gitforgery
rewrites those labels according to rules you write in a small YAML file.
It is not a content filter like git filter-repo — it leaves files, trees, and
blobs byte-for-byte untouched. It is not a general-purpose git client. And it cannot
forge signatures — any edit invalidates a commit's GPG/SSH signature, so signatures
are stripped by default (see Safety).
| Area | Status |
|---|---|
Declarative recipe rewrite (apply → new repo) |
shipped (today's README |
--in-place rewriting + backup refs + undo |
planned (DESIGN.md §5.2 |
plan preview hash table |
planned |
fabricate (synthetic history from nothing) |
planned (DESIGN.md §7 |
audit (forgery-residue scan) |
planned (DESIGN.md §9 |
re-sign (re-sign after rewrite) |
planned |
| TUI | planned (Bubble Tea stack — see DESIGN.md) |
forge (decompose one squashed commit into organic history) |
planned (DESIGN.md §13 |
The full architecture and milestone roadmap live in DESIGN.md.
- Scrub identities from old history — e.g. replace a former employer's email domain across hundreds of commits before making a repository public (GDPR-style hygiene).
- Normalize authorship — map several personal emails onto one canonical author, or re-date a migrated repo's commits.
- Prepare demo / teaching repositories — de-personalized, deterministic history that reads cleanly.
- Pre-1.0 honesty — it works end-to-end on real repos today, but only in the
safe "new bare repo" mode. Anything riskier — wait for planned
--in-place+ backup/undo flow (or simply keep working on a copy.).
Requirements: Go ≥ 1.25 (build), and the git CLI ≥ 2.45 at runtime
(the rewrite pipeline shells out to git's own fast-export/fast-import.
From source (recommended for now):
git clone https://github.com/sauryagur/gitforgery.git
cd gitforgery
make build # -> ./bin/gitforgery
./bin/gitforgery versionGo install:
go install github.com/sauryagur/gitforgery@latestSee Development for the full make target list.
The tl;dr of apply: export the source history, apply your recipe, import
into a brand-new bare repo, verify nothing but labels changed. The source repo is
never modified — today's only mode writes a new bare repository to --to.
# recipe.yaml — rule-based metadata rewrite
range: "main~5..main" # git rev-list range, or "all" for everything reachable
strip_signatures: true # always true unless re-signing later (top-level option)
preserve_date_order: true # clamp so committer dates stay monotonic
match: # ordered; first matching rule wins per commit
- when:
author_email: "@oldcorp\\.com$" # regex; absent fields match anything
set:
author: "Ada Lovelace <ada@example.com>"
author_date: "2020-01-01T10:00:00+00:00"
committer_date: "author" # mirror the (rewritten) author date
subject: "{{.Subject}}" # optional text/template rewriteSelectors (when:: sha, author, author_email, committer, committer_email,
subject, branch — each a Go regex over the corresponding commit attribute; set:
replacements: author, committer ("inherit" mirrors the author), author_date,
committer_date, subject, body. Date values: RFC3339 literals, now, author,
first±dur, prev±dur (e.g. first+4h, prev+1h).
gitforgery apply --recipe recipe.yaml --repo /path/to/repo --to /tmp/cleaned.git --yesNotes:
--repodefaults to the current directory.--tomust not already exist — apply refuses to clobber.--yesis required for every rewriting run, even the ones that only write a fresh repo — a deliberate consent gate. Review the recipe against the planned output first.--signed-tagsselects annotated-tag handling:strip(default),abort,verbatim,warn,warn-strip.
apply reports the range, old→new repo pair, per-commit counts (planned / modified / cascaded / unchanged), and the verification result:
applied main~5..main
source: /path/to/repo -> target: /tmp/cleaned.git
commits: 34 planned, 28 modified, 6 cascaded, 0 unchanged
verified: fsck --full clean, tree objects untouched
Inspect freely — old history is untouched back at the source:
git -C /tmp/cleaned.git log --oneline --decorate
git -C /tmp/cleaned.git log --format='%h %an <%ae> %ad %s' --date=iso
git -C /tmp/cleaned.git fsck --fullImagine git history as a chain of boxes (commits). Every box has three parts:
- a snapshot of every file in the project (called the tree),
- a pointer to the box before it (the parent),
- a label block: who wrote it (author — name, email, timestamp), who recorded it (committer), and the commit message.
Every box carries a serial number (hash) computed from everything inside it — including the parent pointer. That tiny fact drives everything:
- change any label → that box gets a new serial number;
- change a serial number → every later box that points to it must point elsewhere, and gets a new serial number too — the chain cascades to the tip.
So rewriting history by hand is fiddly. gitforgery doesn't touch git's
internals at all — it rides git's own freight pipeline:
git fast-exportunpacks the whole repo into a flat manifest — one section per commit, labels written as plain readable lines. We edit those lines. Thengit fast-importre-packs them into a fresh repo, assigning fresh serial numbers. The actual file contents ride along byte-for-byte, never reopened.
flowchart LR
A[source repo] -->|fast-export| B[history stream]
B --> C[plan: recipe rules decide label changes]
C --> D[transform: rewrite labels only]
D -->|fast-import| E[new bare repo]
E --> F[verify: fsck + tree equality]
Stage by stage:
- Export —
git fast-exportreads the source history into a plain-text stream (the manifest). Read-only. - Plan — your recipe's rules are matched against every commit, producing an
exact list of label changes — nothing is applied yet. (A
planCLI preview is on the roadmap and will reuse this exact library.) - Transform — the stream's author/committer/date/subject/body lines are rewritten per the plan. Everything else — trees, blobs, parents, tags — passes through unchanged, byte-for-byte.
- Import —
git fast-importpacks the edited stream into a brand-new bare repository. Git itself recomputes every serial number — later commits cascaded automatically, exactly as real git would. - Verify —
git fsck --fullconfirms a sound object store, and every rewritten commit's tree serial number is checked against its source commit's — equal trees are the proof that file contents are untouched: labels only, as promised.
Why this design: the rewrite semantics are git's own. The tool never re-implements
hashing, tree-walking, or merge logic — it edits labels and lets git re-pack. This
is the same transport git filter-repo uses, aimed at metadata rather than content.
-
The source repo is never modified today.
applywrites a fresh bare repo to--to; in-place rewriting (with backup refsrefs/forgery/original. CASupdate-refchecks,and dirty-worktree refusal)is planned — see DESIGN.md §5. -
Consent is required even for fresh-repo runs:
--yesgates every rewrite, and--tomust not already exist. -
Signatures cannot be forged. A
gpgsigsignature covers the commit's bytes;any rewrite voids it.applystrips signatures by default; a futurere-signwill let you re-sign with your own key (planned). -
Rewrites leave fingerprints. New serial numbers mean old objects float unreachable until
gc, reflogs record the old tips,and stale signatures linger. That is detectable by design — a futureauditcommand will scan for it (DESIGN.md §9. -
Legit uses only. Identity scrubbing, demo repos, teaching materials. The tool is explicit that deliberately misleading history is out of scope of its "honest automation" posture.
cmd/ cobra commands (apply, version)
internal/recipe YAML recipe: parse + strict validation
internal/plan match rules → per-commit planned edits; preview-hash canary
internal/stream fast-export/import lexer, transform, emit
internal/exec git CLI wrappers (export, import, fsck, rev-parse)
internal/apply orchestration: export → plan → transform → import → verify
internal/gitx go-git helpers (ident parsing, preview hashing)
internal/testrepo in-memory fixture repos for tests
make targets: build (→ bin/gitforgery), test, test-race, cover, vet,
lint (golangci-lint), fmt/fmt-fix, tidy, release/snapshot (GoReleaser),
clean, help.
Testing story: end-to-end integration tests against real temp git init repos;
golden fixture recipes; a preview-hash canary that re-encodes planned commits in Go
and asserts they match what fast-import actually produces — catching any stream-
vocabulary drift between the two worlds.
Contributions welcome — open an issue became a feature request first, then a PR; check DESIGN.md for the roadmap so your change fits the architecture.
MIT. Full design, rationale, etiquette,and roadmap: DESIGN.md.