Skip to content

Git Replace and Grafts: History Surgery Without Force-Push

git replace and grafts provide surgical, in-place history modification that preserves existing commit SHAs. Unlike git filter-repo or git filter-branch (which rewrite history and generate new SHAs), git replace lets you:

  • Swap one object (commit, tree, blob) for another globally, without changing the SHA of the replaced object.
  • Graft new parentage onto existing commits (change a commit's parents) while keeping its SHA identical.
  • Perform history fixes that are safe to share with CI systems, code-review tools, and downstream clones that have already cached the original commit SHAs.
  • Undo mistakes like commits with wrong parents, merge commits that should have been fast-forwards, or blobs that need replacement (e.g. a secret-leaking file) — all without force-push.

Why a DevOps Engineer Needs Replace and Grafts

  1. CI Pipeline SHA Stability
  2. Your CI system caches build artifacts keyed by commit SHA. If you rewrite history with filter-repo, every SHA changes, breaking caches and forcing full rebuilds.
  3. With git replace, the offending commit stays at its original SHA; only its logical content or parentage changes. CI continues to use the same SHA.

  4. Hotfixing History Without Breaking Forks or Mirrors

  5. You discover that commit abc123 (already pushed and mirrored) has the wrong parent or contains a secret.
  6. A force-push would require every downstream consumer to recover; a replace lets you fix the logical history while keeping abc123 identical on disk.

  7. Grafting Maintenance Backports

  8. You backported a fix to an old release branch, but the merge-base got miscalculated. Instead of rebasing the entire branch (new SHAs), you can graft the backported commit onto the correct base.

  9. Repairing Corrupt or Malformed History

  10. A botched git filter-repo or a bad merge left a commit with nonsensical parents. Replace lets you splice in a corrected version.

  11. Temporary Experimentation

  12. Try out a history rewrite (e.g., squash a series) via replace, test it in CI, and only then decide whether to make it permanent with a real rewrite.

How Git Replace Works Under the Hood

When you run git replace <old> <new>, Git writes a ref:

.git/refs/replace/<old>

whose contents are the SHA-1 of <new>. Whenever Git needs to read object <old> (as a commit, tree, tag, or blob), it transparently substitutes <new> instead. The replacement is:

  • Local by default (stored in .git/refs/replace/ and not pushed).
  • Globally visible if you push the replace refs (git push --refs='*:refs/replace/*').
  • Stackable: you can replace a replaced object again.
  • Overrideable: GIT_NO_REPLACE_OBJECTS=1 or --no-replace-objects disables substitution for debugging.

Grafts (.git/info/grafts or git replace --graft) work similarly: they let you alter a commit's recorded parents without changing the commit's own SHA.


Enabling and Using Replace

Replace is enabled by default in modern Git (since v1.6.5). You can explicitly ensure it is on:

# Global setting (recommended)
git config --global replace.enabled true

# Per-repo (overrides global)
git config replace.enabled true

1. Replace a Commit (Change Its Parents) — Grafting

Suppose commit C has wrong parents; you want to re-parent it onto NewBase while keeping C's SHA identical:

# Step 1: create a replacement commit with the desired parents
# (keeps the same tree and message as C)
git commit-tree -p NewBase -p <second-parent-if-merge> -m "$(git log --format=%B -n 1 C)" ^{tree} > /tmp/newcsha
NEW_C=$(cat /tmp/newcsha)

# Step 2: tell Git to replace C with NEW_C whenever C is read
git replace $C $NEW_C

# Verify: Git now shows C's parents as NewBase and <second-parent>
git log --format=%P -n 1 $C

2. Replace a Blob (Swap a File's Contents) Without Changing History

You committed a large binary or a secret at blob B. You want to replace it with a sanitized version B_clean but keep every commit that references B at its original SHA.

# Create the sanitized blob (e.g. from a filtered version of the file)
git hash-object -w --no-filters path/to/clean-file > /tmp/bclean
B_CLEAN=$(cat /tmp/bclean)

# Replace B with B_clean globally
git replace $B $B_CLEAN

# Now `git show <any-commit>:path/to/file` returns the clean contents

3. Replace a Tree (Swap an Entire Directory Snapshot)

Useful when a directory was accidentally committed with wrong permissions or symlinks.

# Build the corrected tree (e.g. from a filtered checkout)
git write-tree --prefix=corrected/ > /tmp/newtree
TREE_CORRECTED=$(cat /tmp/newtree)

# Replace the erroneous tree T with T_CORRECTED
git replace $T $TREE_CORRECTED

4. Graft via .git/info/grafts (Deprecated but Still Functional)

For compatibility, Git still reads .git/info/grafts if present. Each line:

<commit> <parent1> <parent2> ...

Example: make deadbeef a child of main and feature (a merge):

echo "deadbeef main feature" >> .git/info/grafts

Note: git replace --graft is the preferred modern equivalent.


Common DevOps Workflows & Scenarios

Scenario 1: Fixing a Merge Commit That Should Have Been a Fast-Forward

A feature branch was merged with --no-ff by mistake, creating an unnecessary merge commit M. You want M to appear as if it were a simple fast-forward (i.e., have only one parent) without changing M's SHA.

# Assume:
#   M = the merge commit (to be fixed)
#   P = its first parent (should-be-only parent)
#   S = its second parent (the feature branch tip we want to drop)

# Step 1: create a replacement commit that copies M's tree/message but has only parent P
git commit-tree -p P -m "$(git log --format=%B -n 1 M)" ^{tree} > /tmp/mfix
M_FIXED=$(cat /tmp/mfix)

# Step 2: replace M with the corrected version
git replace $M $M_FIXED

# Verify: M now appears as a single-parent commit
git log --format=%P -n 1 $M   # should output only P

CI continues to use SHA M; the history now linearizes as expected.


Scenario 2: Grafting a Backport onto the Correct Base

You cherry-picked fix fix123 onto release/v1.2 branch, but the merge-base was miscalculated, causing spurious conflicts. Instead of redoing the entire cherry-pick chain, graft fix123 onto the proper base.

# Suppose:
#   fix123 = the backported commit (currently based on old-base)
#   correct-base = the commit it should have been based on

# Step 1: create a grafted copy with the same tree/message but new parent
git commit-tree -p correct-base -m "$(git log --format=%B -n 1 fix123)" ^{tree} > /tmp/fixgraft
FIX_GRAFTED=$(cat /tmp/fixgraft)

# Step 2: replace fix123 with the grafted version
git replace $fix123 $FIX_GRAFTED

# Now `git show-branch` shows fix123 correctly descended from correct-base

Scenario 3: Removing a Large Binary from History Without Rewriting SHAs

You found that a 100 MB video was committed at blob BIG and spread across many commits. You want to replace it with a small pointer file but keep all commit SHAs identical.

# Step 1: create the replacement blob (e.g. a text note)
git hash-object -w -t blob <<<"[REMOVED: large video stored in LTS]" > /tmp/note
NOTE=$(cat /tmp/note)

# Step 2: replace the big blob with the note
git replace $BIG $NOTE

# Verify: any commit that once showed the video now shows the note
git show <some-old-commit>:path/to/video

Scenario 4: Sharing Replace Refs Across CI Runners or Teams

By default, replace refs are local. To make them visible to everyone (useful for a team-wide fix):

# Push all replace refs to the remote
git push origin --refs='*:refs/replace/*'

# On another clone, fetch them:
git fetch origin 'refs/replace/*:refs/replace/*'

# Ensure replacement is enabled
git config replace.enabled true

Caution: Only share replace refs when all consumers agree on the replacement; conflicting replacements cause confusion.


Managing Replace Refs

Listing Active Replacements

# Show all replace refs in this repository
git replace -l

# Verbose: show what each old object is replaced with
git replace -l | while read old new; do
  echo "$old -> $new"
done

Editing a Replace Ref

Replace refs are ordinary refs; you can update them like any other:

# Change the replacement for old-object O to new-object N
git update-ref refs/replace/$O $N

Deleting a Replace Ref

# Stop replacing old-object O
git replace -d $O
# or
git update-ref -d refs/replace/$O

Exporting / Importing Replace Refs

# Bundle all replace refs for backup or migration
git pack-refs --all --prune
mkdir -p /tmp/replace-backup
for ref in $(git show-ref --refs=refs/replace/*); do
  sha=$(echo "$ref" | cut -d' ' -f1)
  name=$(echo "$ref" | cut -d' ' -f2)
  git show $sha > "/tmp/replace-backup/$(basename $name)"
done

# To import on another machine:
cd /tmp/replace-backup
for file in *; do
  sha=$(git hash-object -w -t ref "$file")
  git update-ref refs/replace/"$(basename "$file")" "$sha"
done

Disabling Replace Temporarily

# For one command (disable)
git -c replace.enabled=false log --graph

# Or shell-wide
export GIT_NO_REPLACE_OBJECTS=1

Advanced: Combining Replace with Grafts for Complex Surgery

You can chain replaces to perform multi-step history edits while keeping the final SHAs identical to the originals.

Example: change the parents of a merge commit M and swap a faulty blob B inside it, all without changing M's SHA.

# 1. Replace blob B with B_clean
git replace $B $B_CLEAN

# 2. Create a corrected version of M that uses B_clean (via the replace)
#    Note: Git will automatically use B_clean when reading M's tree because of the replace above.
git commit-tree -p <correct-parent1> -p <correct-parent2> -m "$(git log --format=%B -n 1 M)" ^{tree} > /tmp/mcorrect
M_CORRECTED=$(cat /tmp/mcorrect)

# 3. Replace M with the corrected version
git replace $M $M_CORRECTED

# Result: M now has the correct parents and the clean blob, but its SHA remains unchanged.

Safety, Limitations, and Best Practices

  • Replace refs are not transferred by git clone or git fetch by default — you must explicitly push/fetch them.
  • They do not work with dumb HTTP/SMART HTTP servers that lack ref advertisement (modern servers support them fine).
  • Avoid circular replaces (A replaces B, B replaces A) — Git will detect and break the cycle.
  • Replace does not work for objects that are not reachable from any ref (dangling objects) unless you explicitly read them.
  • For history sharing, agree on a namespace: e.g., only replace objects under refs/replace/teamfix/* and document the convention.
  • Prefer git replace over direct .git/info/grafts edits — the former is safer and more visible.
  • Test replacements in an isolated clone before pushing to shared remotes.
  • Document why a replace exists — add a note in docs/history-fixes.md or similar so future engineers know the intent.

Reference Commands

Goal Command
Enable replace globally git config --global replace.enabled true
List current replacements git replace -l
Replace object <old> with <new> git replace <old> <new>
Stop replacing <old> git replace -d <old>
Create a grafted commit (change parents) git commit-tree -p <newparent1> [-p <newparent2> ...] -m "msg" ^{tree}
Replace a blob with new contents git replace <old-blob> $(git hash-object -w -t blob < /path/to/new)
Push replace refs to remote git push origin --refs='*:refs/replace/*'
Fetch replace refs from remote git fetch origin 'refs/replace/*:refs/replace/*'
Temporarily disable replace git -c replace.enabled=false <cmd>
Check if replace is enabled git config --get replace.enabled

See Also

Last updated: 2026-08-29