Replace bad Git identities in history, or block them before they land — with backups, verification, a pre-commit hook, and a GitHub Action.
2
stars
10
commits
Python
primary language
Aug 22, 2026
updated
A small, cross-platform CLI for replacing one Git identity with another
across a repository's history, safely — without requiring you to hand-write
a git-filter-repo invocation. Also includes guard,
a prevention companion that blocks a denied identity in CI or a local hook
before it ever lands, so you don't need to run the remediation flow again.
Contents: Why? · Features ·
Installation · Quick Start ·
Example · Interactive Usage ·
Non-Interactive Usage · Dry Run ·
Branches · Authors vs Committers ·
Co-authored-by Trailers ·
History Rewriting Warning ·
Push Behavior · Recovery ·
guard: prevent denied identities ·
Limitations · Development ·
License
⚠️ This tool rewrites Git history. Rewriting history changes commit SHAs, invalidates existing signatures on rewritten commits, and requires a force push to update any already-published branch. A force push can disrupt collaborators and invalidate their existing clones. Only use this on repositories and history you are authorized to modify. This tool does not modify GitHub (or any other host's) account data or contributor database directly — hosts may independently recompute contributor attribution from the resulting commit metadata.
A Git "identity" isn't always a person — it can be a placeholder, a bot
account, a shared machine account, or an AI agent that paired on the work.
Correcting a mis-attributed one usually means hand-writing a
git-filter-repo invocation. Git Reattribute turns that into one guided
command.
Co-authored-by: trailers for the replaced identity.--force-with-lease push only, never --force, never silent.guard subcommand: prevent denied identities from ever being
committed or merged, via a GitHub Action and/or a local pre-commit
hook — see guard.git-filter-repo — installed automatically as a dependency when you
pip install git-reattribute; you don't need to install it separately.python -m pip install git-reattribute
cd your-repo
git-reattribute --version
git-reattribute --help
git-reattribute
$ git-reattribute
Git Reattribute
Repository: /home/alice/projects/example
Remote: origin
Branch: main
Contributors on main:
1. Claude <claude@example.com> 47 commits
2. Alice <alice@example.com> 18 commits
3. Bob <bob@example.com> 6 commits
Select contributor to replace: Claude <claude@example.com> 47 commits
Replace with:
Current Git identity
Alice <alice@example.com>
Bob <bob@example.com>
Enter a custom identity
Select replacement: Alice <alice@example.com>
Summary
-------
Branch: main
From: Claude <claude@example.com>
To: Alice <alice@example.com>
Author commits affected: 47
Committer commits affected: 47
Co-authored-by trailers to remove: 3
WARNING: This operation rewrites Git history.
Type REWRITE to continue: REWRITE
History rewrite completed.
Recovery reference:
refs/backup/git-reattribute/2026-08-22T10-30-00
To restore the original branch:
git reset --hard refs/backup/git-reattribute/2026-08-22T10-30-00
Keep this reference until you have verified the rewritten history.
Verification
Old identity commits before: 94
Remaining old identity commits: 0
New identity commits: 94
Remaining Co-authored-by trailers: 0
Verification: PASS
Push rewritten branch to origin with --force-with-lease? [y/N]
Running git-reattribute with no flags walks you through: select a branch,
see its contributors, pick who to replace, pick the replacement, preview the
change, then type REWRITE to confirm.
git-reattribute \
--branch main \
--from-email claude@example.com \
--to-name Alice \
--to-email alice@example.com \
--identity-type both \
--yes --push
--yes does not imply --push — pass both explicitly when you want a
scripted rewrite-and-push.
git-reattribute --branch main --from-email claude@example.com --to-current-user --dry-run
Shows affected author/committer commit counts and any Co-authored-by:
trailers that would be removed. No history or remote is touched.
Local branches are listed and selectable. Remote-tracking branches are not rewritten directly — check out a local branch first. The rewrite scope is the entire reachable history of the selected branch — every commit an ancestor of that branch's tip, not just commits made while that branch was checked out.
Every commit has an author and a committer, and they can differ. Use
--identity-type author, --identity-type committer, or the default
--identity-type both to control which field(s) get rewritten. The
interactive UI shows both roles whenever they differ for a given contributor.
When an identity (for example an AI agent that paired with the human author)
appears as a Co-authored-by: trailer in a commit message, that trailer is
removed (not replaced) for the identity being replaced, by default. This
matters because rewriting only the author field of a commit like:
Author: Claude <claude@example.com>
Co-authored-by: Claude <claude@example.com>
would otherwise leave the old identity behind in the message body even after the commit object's author is fixed.
This runs alongside the author/committer rewrite for the same source
identity — there is no separate replacement target for trailers, and the
trailer is deleted, not rewritten to a new co-author. Disable it with
--no-strip-coauthor-trailers.
Matching rule: a trailer line matches when it starts with
Co-authored-by: (case-insensitive) followed by the source identity's name
and <email>, with any amount of whitespace tolerated around the colon,
name, and angle brackets — but the name and email themselves must match the
source identity's recorded name/email (case-insensitively), not a partial or
fuzzy match. Only matching trailer lines are removed; other trailers (e.g.
Signed-off-by:) and body text that merely mentions the same name are left
untouched.
This tool rewrites Git history using git-filter-repo.
Two different --force flags matter here, and they are not the same thing:
git-filter-repo --force (used internally, always) only permits the
local rewrite to run on a non-fresh-clone repository. git-filter-repo
normally refuses to touch anything but a fresh clone as a generic safety
net; this tool creates its own backup ref (see Recovery below) before
that rewrite ever runs, so that backup is the safety net instead, and the
--force flag just lets the local rewrite proceed.git push --force is never used by this tool, anywhere. Publishing a
rewritten branch always uses git push --force-with-lease (see
Push Behavior), and only after you explicitly confirm it.Contributor discovery uses Git's raw %an/%ae/%cn/%ce fields, not
mailmap-resolved fields — a repository's .mailmap is intentionally ignored
so what you see and rewrite always matches the actual commit-object bytes.
Signed commits lose their signatures when rewritten — a warning is shown
before you confirm. Merge commits are rewritten natively by git-filter-repo
as part of a full-branch rewrite; this requires no special handling in v1
since there is no commit-range filtering yet.
Pushing always uses git push --force-with-lease, never git push --force.
Nothing is pushed unless you pass --push (scripted) or confirm the
interactive prompt. If the remote branch changed since the rewrite began,
the push is rejected and nothing is retried automatically.
Before every rewrite — and before git-filter-repo is ever invoked — a
backup ref is created pointing at the original branch tip:
refs/backup/git-reattribute/<timestamp>
The rewrite is scoped (via --refs <branch>) to only the selected branch,
so the backup ref itself is never touched or rewritten by the same
operation. It is never deleted automatically. After a rewrite, the tool
prints the exact recovery command:
Recovery reference:
refs/backup/git-reattribute/2026-08-22T10-30-00
To restore the original branch:
git reset --hard refs/backup/git-reattribute/2026-08-22T10-30-00
Keep the backup ref until you've verified the rewritten history (and pushed,
if applicable) — then it's safe to delete with
git update-ref -d refs/backup/git-reattribute/<timestamp>.
guard: prevent denied identities before they landEverything above is remediation — fixing history that already has a bad
identity in it. git-reattribute guard is the prevention half: it never
touches history, it only scans and reports (exit 0 clean, exit 1 on a
match), so it's safe to run unattended in a hook or CI job.
git-reattribute guard init
Scaffolds everything in one shot: .git-reattribute-guard.yml (empty
denylist template with commented examples, or pass --deny-name/
--deny-email to populate a real entry immediately) and
.github/workflows/guard.yml (wired to the Action below). It also prints
the pre-commit snippet to paste in yourself — that one's never
auto-written, since safely merging into an existing
.pre-commit-config.yaml needs a human, not a blind overwrite. Existing
files are left untouched unless you pass --force.
Or write .git-reattribute-guard.yml by hand at your repo root:
deny:
- name: Claude
email: claude@example.com
- email: "*@bots.example.com" # glob supported on email only
check_coauthors: true # also check Co-authored-by trailers (default: true)
name: Guard commit identities
on: [pull_request]
jobs:
guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: drk1rd/git-reattribute/.github/actions/guard@v1
This runs git-reattribute guard check against every commit in the PR's
range, server-side — regardless of what a contributor did locally. This is
the layer that actually matters for keeping history clean.
git-reattribute guard check-local checks the identity Git is about to use
right now — your user.name/user.email, plus any Co-authored-by:
trailer in the message you're writing. Wire it up via the pre-commit
framework's local hook type (no separate hook-repo needed — it just runs
the already-installed git-reattribute command):
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: git-reattribute-guard
name: git-reattribute-guard
entry: git-reattribute guard check-local
language: system
stages: [commit-msg]
pre-commit install --hook-type commit-msg
This is a convenience for the person committing — it's skippable with git commit --no-verify, so it's not a substitute for the CI check above.
git-reattribute guard check --base origin/main --head HEAD
git-reattribute guard: found 1 violation(s) in origin/main..HEAD:
a1b2c3d4 author: Claude <claude@example.com>
Fix with:
git-reattribute --branch main --from-email claude@example.com --to-current-user
Every violation names the exact git-reattribute command to fix it — the
two halves of this tool are meant to be used together: guard stops it from
happening again, git-reattribute cleans up what's already there.
git fetch --unshallow first.python -m pip install -e ".[dev]"
pytest
Tests build temporary Git repositories per-test; nothing is ever run against a real/shared repository.
See CHANGELOG.md. Versioning follows SemVer (MAJOR.MINOR.PATCH).
MIT — see LICENSE.
6 commits
4 commits
Python
100.0%
Replace bad Git identities in history, or block them before they land — with backups, verification, a pre-commit hook, and a GitHub Action.
2
stars
10
commits
Python
primary language
Aug 22, 2026
updated
A small, cross-platform CLI for replacing one Git identity with another
across a repository's history, safely — without requiring you to hand-write
a git-filter-repo invocation. Also includes guard,
a prevention companion that blocks a denied identity in CI or a local hook
before it ever lands, so you don't need to run the remediation flow again.
Contents: Why? · Features ·
Installation · Quick Start ·
Example · Interactive Usage ·
Non-Interactive Usage · Dry Run ·
Branches · Authors vs Committers ·
Co-authored-by Trailers ·
History Rewriting Warning ·
Push Behavior · Recovery ·
guard: prevent denied identities ·
Limitations · Development ·
License
⚠️ This tool rewrites Git history. Rewriting history changes commit SHAs, invalidates existing signatures on rewritten commits, and requires a force push to update any already-published branch. A force push can disrupt collaborators and invalidate their existing clones. Only use this on repositories and history you are authorized to modify. This tool does not modify GitHub (or any other host's) account data or contributor database directly — hosts may independently recompute contributor attribution from the resulting commit metadata.
A Git "identity" isn't always a person — it can be a placeholder, a bot
account, a shared machine account, or an AI agent that paired on the work.
Correcting a mis-attributed one usually means hand-writing a
git-filter-repo invocation. Git Reattribute turns that into one guided
command.
Co-authored-by: trailers for the replaced identity.--force-with-lease push only, never --force, never silent.guard subcommand: prevent denied identities from ever being
committed or merged, via a GitHub Action and/or a local pre-commit
hook — see guard.git-filter-repo — installed automatically as a dependency when you
pip install git-reattribute; you don't need to install it separately.python -m pip install git-reattribute
cd your-repo
git-reattribute --version
git-reattribute --help
git-reattribute
$ git-reattribute
Git Reattribute
Repository: /home/alice/projects/example
Remote: origin
Branch: main
Contributors on main:
1. Claude <claude@example.com> 47 commits
2. Alice <alice@example.com> 18 commits
3. Bob <bob@example.com> 6 commits
Select contributor to replace: Claude <claude@example.com> 47 commits
Replace with:
Current Git identity
Alice <alice@example.com>
Bob <bob@example.com>
Enter a custom identity
Select replacement: Alice <alice@example.com>
Summary
-------
Branch: main
From: Claude <claude@example.com>
To: Alice <alice@example.com>
Author commits affected: 47
Committer commits affected: 47
Co-authored-by trailers to remove: 3
WARNING: This operation rewrites Git history.
Type REWRITE to continue: REWRITE
History rewrite completed.
Recovery reference:
refs/backup/git-reattribute/2026-08-22T10-30-00
To restore the original branch:
git reset --hard refs/backup/git-reattribute/2026-08-22T10-30-00
Keep this reference until you have verified the rewritten history.
Verification
Old identity commits before: 94
Remaining old identity commits: 0
New identity commits: 94
Remaining Co-authored-by trailers: 0
Verification: PASS
Push rewritten branch to origin with --force-with-lease? [y/N]
Running git-reattribute with no flags walks you through: select a branch,
see its contributors, pick who to replace, pick the replacement, preview the
change, then type REWRITE to confirm.
git-reattribute \
--branch main \
--from-email claude@example.com \
--to-name Alice \
--to-email alice@example.com \
--identity-type both \
--yes --push
--yes does not imply --push — pass both explicitly when you want a
scripted rewrite-and-push.
git-reattribute --branch main --from-email claude@example.com --to-current-user --dry-run
Shows affected author/committer commit counts and any Co-authored-by:
trailers that would be removed. No history or remote is touched.
Local branches are listed and selectable. Remote-tracking branches are not rewritten directly — check out a local branch first. The rewrite scope is the entire reachable history of the selected branch — every commit an ancestor of that branch's tip, not just commits made while that branch was checked out.
Every commit has an author and a committer, and they can differ. Use
--identity-type author, --identity-type committer, or the default
--identity-type both to control which field(s) get rewritten. The
interactive UI shows both roles whenever they differ for a given contributor.
When an identity (for example an AI agent that paired with the human author)
appears as a Co-authored-by: trailer in a commit message, that trailer is
removed (not replaced) for the identity being replaced, by default. This
matters because rewriting only the author field of a commit like:
Author: Claude <claude@example.com>
Co-authored-by: Claude <claude@example.com>
would otherwise leave the old identity behind in the message body even after the commit object's author is fixed.
This runs alongside the author/committer rewrite for the same source
identity — there is no separate replacement target for trailers, and the
trailer is deleted, not rewritten to a new co-author. Disable it with
--no-strip-coauthor-trailers.
Matching rule: a trailer line matches when it starts with
Co-authored-by: (case-insensitive) followed by the source identity's name
and <email>, with any amount of whitespace tolerated around the colon,
name, and angle brackets — but the name and email themselves must match the
source identity's recorded name/email (case-insensitively), not a partial or
fuzzy match. Only matching trailer lines are removed; other trailers (e.g.
Signed-off-by:) and body text that merely mentions the same name are left
untouched.
This tool rewrites Git history using git-filter-repo.
Two different --force flags matter here, and they are not the same thing:
git-filter-repo --force (used internally, always) only permits the
local rewrite to run on a non-fresh-clone repository. git-filter-repo
normally refuses to touch anything but a fresh clone as a generic safety
net; this tool creates its own backup ref (see Recovery below) before
that rewrite ever runs, so that backup is the safety net instead, and the
--force flag just lets the local rewrite proceed.git push --force is never used by this tool, anywhere. Publishing a
rewritten branch always uses git push --force-with-lease (see
Push Behavior), and only after you explicitly confirm it.Contributor discovery uses Git's raw %an/%ae/%cn/%ce fields, not
mailmap-resolved fields — a repository's .mailmap is intentionally ignored
so what you see and rewrite always matches the actual commit-object bytes.
Signed commits lose their signatures when rewritten — a warning is shown
before you confirm. Merge commits are rewritten natively by git-filter-repo
as part of a full-branch rewrite; this requires no special handling in v1
since there is no commit-range filtering yet.
Pushing always uses git push --force-with-lease, never git push --force.
Nothing is pushed unless you pass --push (scripted) or confirm the
interactive prompt. If the remote branch changed since the rewrite began,
the push is rejected and nothing is retried automatically.
Before every rewrite — and before git-filter-repo is ever invoked — a
backup ref is created pointing at the original branch tip:
refs/backup/git-reattribute/<timestamp>
The rewrite is scoped (via --refs <branch>) to only the selected branch,
so the backup ref itself is never touched or rewritten by the same
operation. It is never deleted automatically. After a rewrite, the tool
prints the exact recovery command:
Recovery reference:
refs/backup/git-reattribute/2026-08-22T10-30-00
To restore the original branch:
git reset --hard refs/backup/git-reattribute/2026-08-22T10-30-00
Keep the backup ref until you've verified the rewritten history (and pushed,
if applicable) — then it's safe to delete with
git update-ref -d refs/backup/git-reattribute/<timestamp>.
guard: prevent denied identities before they landEverything above is remediation — fixing history that already has a bad
identity in it. git-reattribute guard is the prevention half: it never
touches history, it only scans and reports (exit 0 clean, exit 1 on a
match), so it's safe to run unattended in a hook or CI job.
git-reattribute guard init
Scaffolds everything in one shot: .git-reattribute-guard.yml (empty
denylist template with commented examples, or pass --deny-name/
--deny-email to populate a real entry immediately) and
.github/workflows/guard.yml (wired to the Action below). It also prints
the pre-commit snippet to paste in yourself — that one's never
auto-written, since safely merging into an existing
.pre-commit-config.yaml needs a human, not a blind overwrite. Existing
files are left untouched unless you pass --force.
Or write .git-reattribute-guard.yml by hand at your repo root:
deny:
- name: Claude
email: claude@example.com
- email: "*@bots.example.com" # glob supported on email only
check_coauthors: true # also check Co-authored-by trailers (default: true)
name: Guard commit identities
on: [pull_request]
jobs:
guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: drk1rd/git-reattribute/.github/actions/guard@v1
This runs git-reattribute guard check against every commit in the PR's
range, server-side — regardless of what a contributor did locally. This is
the layer that actually matters for keeping history clean.
git-reattribute guard check-local checks the identity Git is about to use
right now — your user.name/user.email, plus any Co-authored-by:
trailer in the message you're writing. Wire it up via the pre-commit
framework's local hook type (no separate hook-repo needed — it just runs
the already-installed git-reattribute command):
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: git-reattribute-guard
name: git-reattribute-guard
entry: git-reattribute guard check-local
language: system
stages: [commit-msg]
pre-commit install --hook-type commit-msg
This is a convenience for the person committing — it's skippable with git commit --no-verify, so it's not a substitute for the CI check above.
git-reattribute guard check --base origin/main --head HEAD
git-reattribute guard: found 1 violation(s) in origin/main..HEAD:
a1b2c3d4 author: Claude <claude@example.com>
Fix with:
git-reattribute --branch main --from-email claude@example.com --to-current-user
Every violation names the exact git-reattribute command to fix it — the
two halves of this tool are meant to be used together: guard stops it from
happening again, git-reattribute cleans up what's already there.
git fetch --unshallow first.python -m pip install -e ".[dev]"
pytest
Tests build temporary Git repositories per-test; nothing is ever run against a real/shared repository.
See CHANGELOG.md. Versioning follows SemVer (MAJOR.MINOR.PATCH).
MIT — see LICENSE.
6 commits
4 commits
Python
100.0%