Skip to content

Editing Git Commit History Safely

Lesson 5 of 7Intermediate → Advanced12 min readModern Git Workflows · RebasingVerified: Git 2.43.0 on Ubuntu 24.04; every command and output in this lesson was run

Four commands change what your history looks like, and they are routinely confused because their effects overlap. Choosing wrongly is how people lose work or disrupt colleagues.

CommandWhat it doesRewrites history?
git commit --amendReplaces the most recent commitYes
git rebase -iReplaces a range of commitsYes
git revertAdds a new commit undoing an old oneNo
git resetMoves the branch ref to a different commitYes, in effect

The first, second and fourth produce new commit IDs and require a force push if the branch was shared. The third does not — which is precisely why it is the right answer on a shared branch.

Two questions settle almost every case.

1. Has anyone else got these commits?

If no → rewriting is fine. Use amend, rebase -i or reset. If yes → do not rewrite. Use revert.

2. What exactly do you want to change?

GoalTool
Fix the message of the last commitgit commit --amend
Add a forgotten file to the last commitgit commit --amend
Fix the message of an older commitgit rebase -i with reword
Change the content of an older commitgit rebase -i with edit
Combine commitsgit rebase -i with squash/fixup
Remove a commit from a private branchgit rebase -i with drop
Undo a commit that is already sharedgit revert
Undo the last few local commits, keep the changesgit reset --soft
Undo the last few local commits, discard everythinggit reset --hard

The smallest rewrite: it replaces the most recent commit.

Terminal window
git commit --amend

What it doesCreates a new commit from the current index plus whatever the previous commit contained, then moves the branch to it. The previous commit is replaced, not modified.

Why we run itIt is the quickest way to correct the commit you just made — a typo in the message, a file you forgot to stage.

Expected resultA commit summary line. The commit ID will differ from the one you just made.

Common forms:

Terminal window
git commit --amend # edit the message
git commit --amend -m "Better message" # replace the message inline
git commit --amend --no-edit # keep the message, take newly staged changes
git commit --amend --author="Name <email>" # correct authorship

The --no-edit form is the one to remember: stage the forgotten file, amend, done.

Terminal window
git add forgotten-file.py
git commit --amend --no-edit

--amend only reaches the most recent commit. For anything further back, interactive rebase:

Terminal window
git rebase -i <commit>^

Appending ^ names the parent of the commit you want to change, which is where the range must start.

Todo verbUse for
rewordChange the message only
editChange the content — Git stops so you can amend
squash / fixupCombine with the previous commit
dropRemove it

Remember that changing a commit rewrites every commit after it, because each one’s parent changed. Interactive Rebase covers the mechanics.

Revert does not rewrite anything. It creates a new commit whose changes are the inverse of an old one.

Terminal window
git revert a1b2c3d

What it doesComputes the reverse of the named commit's changes and commits them as a new commit on top of your branch.

Why we run itIt undoes the effect of a commit while leaving history intact, so it is safe on branches other people have.

Expected resultA new commit named Revert "…". The original commit is still present in the history.

[main 7f8e9d0] Revert "Add experimental caching"
1 file changed, 12 deletions(-)

Both commits now exist: the original and its reversal. That is a feature — the history records that something was tried and withdrawn, which is often useful information.

Reverting a merge commit needs -m to say which parent’s line to keep:

Terminal window
git revert -m 1 <merge-commit>

Merge Commits covers the consequences, including the trap that re-merging a reverted branch restores nothing.

Reset moves the current branch to point at a different commit. What happens to your files depends entirely on the mode.

ModeBranch refIndexWorking tree
--softMovesUntouchedUntouched
--mixed (default)MovesReset to targetUntouched
--hardMovesReset to targetOverwritten

--soft: undo commits, keep everything staged

Section titled “--soft: undo commits, keep everything staged”
Terminal window
git reset --soft HEAD~3

The last three commits are no longer on the branch; all their changes are staged, ready to be recommitted differently. This is the cleanest way to collapse several commits into one.

--mixed: undo commits, keep changes unstaged

Section titled “--mixed: undo commits, keep changes unstaged”
Terminal window
git reset HEAD~1

The commit is undone and its changes are in your working tree, unstaged. This is the standard way to split a commit: undo it, then stage and commit the pieces separately.

--hard: undo commits and discard the changes

Section titled “--hard: undo commits and discard the changes”
Terminal window
git reset --hard HEAD~1

The same goal — “undo my last commit” — has four defensible answers depending on circumstances.

SituationCommandWhy
Not pushed, want to fix the messagegit commit --amendSmallest change
Not pushed, want to redo it entirelygit reset --soft HEAD~1Changes stay staged
Not pushed, want it gone completelygit reset --hard HEAD~1Destructive
Pushed, branch is yours alonegit reset --hard HEAD~1 then force-with-leaseAcceptable if nobody has it
Pushed, others have itgit revert HEADNo rewrite, no coordination
On maingit revert HEADAlways

The pattern: rewrite privately, revert publicly.

A commit that does two unrelated things is a common target for editing. The technique combines interactive rebase with a mixed reset.

  1. Mark it edit in the todo list:

    Terminal window
    git rebase -i <commit>^
  2. When Git stops, undo the commit but keep its changes:

    Terminal window
    git reset HEAD^

    No flag means --mixed: the branch moves back, the changes become unstaged, nothing is lost.

  3. Commit the pieces separately. git add -p is invaluable if both changes live in the same file — it walks through each hunk asking whether to stage it:

    Terminal window
    git add -p parser.py
    git commit -m "Add parser"
    git add validate.py
    git commit -m "Add validation"
  4. Continue:

    Terminal window
    git rebase --continue

Changing author or date across many commits

Section titled “Changing author or date across many commits”

Occasionally a whole branch was committed with the wrong identity — a misconfigured user.email on a new machine, say.

For the most recent commit:

Terminal window
git commit --amend --author="Correct Name <correct@example.com>" --no-edit

For a range, use the interactive rebase exec mechanism to amend each one:

Terminal window
git rebase -i --exec "git commit --amend --reset-author --no-edit" main

--reset-author sets both the author and the author date to the current identity and time. If you want to keep the original dates, omit it and pass --author= explicitly instead.

After any history edit, two commands confirm you changed the history and not the code.

Terminal window
git diff backup-branch HEAD

Empty output means the final tree is identical — the edit was purely structural. Any output means the content changed, which is only correct if you meant it to.

Terminal window
git range-diff main..backup-branch main..HEAD

git range-diff compares two series of commits rather than two trees. It attempts to pair up commits that correspond across the rewrite and reports what happened to each — which is exactly the right view after a rebase, and far more informative than diffing the endpoints.

1: b2e9ccb = 1: b2e9ccb Add parser
2: f6c2619 < -: ------- Add validation
-: ------- > 2: a9a992b Add validation
MarkerMeaning
=The commit is unchanged
!Paired with a commit in the other range, with the differences shown below
<Present only in the first range — dropped by the rewrite
>Present only in the second range — new in the rewrite

Pairing is heuristic. Two versions of a commit are matched when they are similar enough; when a commit changed substantially, range-diff reports it as one dropped and one added rather than as a modification, as in the output above.

Either way this is the best available review of your own rewrite before you force-push it: it makes dropped commits obvious, which is precisely the mistake that is hardest to notice afterwards.

A special case that deserves its own treatment, because the intuitive answers are wrong.

If a credential, key or personal data is committed, reverting is not sufficient. The revert adds a commit removing the file; the original commit still contains it, and anyone with a clone still has it.

The response, in order:

  1. Rotate the credential immediately. This is the only step that genuinely fixes the problem. Assume it is compromised from the moment it was pushed.

  2. Remove it from the working tree and add an ignore rule so it cannot recur.

  3. Rewrite the history only if you also control every clone and mirror. git filter-repo is the maintained tool for this; the older git filter-branch is slow and error-prone.

  4. Force-push and tell everyone to re-clone. Anyone who pulls instead of re-cloning may reintroduce the old objects.

Everything in this lesson is recoverable except uncommitted changes destroyed by --hard.

Terminal window
git reflog
04a2fa1 HEAD@{0}: reset: moving to HEAD~1
f1b6bc0 HEAD@{1}: commit: Add experimental caching
0f16212 HEAD@{2}: commit: Main work

Every entry is a position your branch held. Restore any of them:

Terminal window
git reset --hard HEAD@{1}

You can also reflog a specific branch rather than HEAD:

Terminal window
git reflog show feature

If a commit is not in the reflog either — an orphan from a rewrite done in another clone, for example — git fsck can find it:

Terminal window
git fsck --lost-found
dangling commit a1d07c2f8e9b0c3d4e5f60718293a4b5c6d7e8f9

Inspect and rescue:

Terminal window
git show a1d07c2
git branch recovered a1d07c2

Any rewrite of a pushed branch requires overriding the remote’s protection.

Terminal window
git push --force-with-lease

The lease compares the remote’s current position against your remote-tracking ref, refusing if they differ — so a colleague’s push cannot be silently destroyed.

A history edit is not confined to your repository. Four things react to it.

Open pull requests. The pull request follows the branch, so a force push updates it — the diff and commit list refresh. What does not survive cleanly are review comments anchored to specific lines of specific commits: when those commits cease to exist, comments are typically marked outdated and detached from their context. Reviewers then have to work out whether their point was addressed.

CI. A force push is a new head, so pipelines re-run from scratch. Cached results keyed by commit SHA miss entirely. On a repository with a slow pipeline, rebasing repeatedly during review is expensive in build minutes as well as goodwill.

Anything referencing a commit ID. Tickets, chat messages, changelog entries, deployment records and git bisect sessions all point at objects that are no longer on the branch. The objects still exist until garbage collection, so the links do not immediately break — they simply cease to be reachable from any branch, which is subtly worse because nothing announces it.

Other people’s branches. Anyone who branched from your commits now has a branch whose base is orphaned. They need git rebase --onto to re-parent it, which is straightforward but requires them to know it happened.

Rewriting shared history. The recurring theme. Rewrite privately, revert publicly.

Using --hard when you meant --soft. One discards the work; the other keeps it staged. Read the flag before pressing Enter.

Reverting a leaked secret and considering it handled. Rotate it.

Amending a pushed commit without warning anyone. It is a rewrite like any other.

Using bare --force. Use --force-with-lease, ideally with --force-if-includes.

Assuming reflog covers everything. It covers commits. Uncommitted changes destroyed by --hard are gone.

Rewriting during review. Comments are anchored to commits that will cease to exist.

Reaching for filter-repo to tidy history. Rewriting an entire repository’s history for cosmetic reasons imposes a re-clone on everyone. Reserve it for genuine necessity.

There are two ways to change what history says.

Rewriting replaces commits with new ones. The old versions become unreferenced but still exist, which is why recovery works and why everyone else’s copy breaks.

Reverting adds a commit that undoes an earlier one. Nothing is replaced, nobody’s copy breaks, and the record shows both what was done and that it was undone.

Rewriting is for history only you have seen. Reverting is for history you have shared.

  • --amend, rebase -i and reset rewrite history; revert does not.
  • --amend reaches only the last commit; rebase -i reaches any commit, rewriting everything after it.
  • reset --soft keeps changes staged, --mixed keeps them unstaged, --hard destroys them.
  • Uncommitted changes lost to --hard are not in the reflog and are unrecoverable.
  • Reverting is the correct tool on any shared branch, including main.
  • A committed secret must be rotated; rewriting history is cleanup, not remediation.
  • git reflog and git fsck --lost-found recover almost anything, within the reflog’s expiry window.
  • --force-with-lease can be defeated by fetching first; --force-if-includes closes that gap.

Practise each tool, and one recovery, in a disposable repository.

  1. Create a repository with three commits.
  2. Amend: change the last commit’s message with git commit --amend -m "Reworded". Compare the ID before and after.
  3. Soft reset: git reset --soft HEAD~2, then git status. Where are the changes? Recommit them as one commit.
  4. Revert: git revert HEAD --no-edit. Confirm git log now shows both the commit and its reversal.
  5. Reword an old commit: git rebase -i HEAD~3, mark the oldest reword. Note that every later ID changes too.
  6. Recovery drill. Note the current ID, then run git reset --hard HEAD~2. Confirm the commits are gone from git log.
  7. Run git reflog, find the pre-reset entry, and restore with git reset --hard HEAD@{1}.
  8. Finally, run git fsck --lost-found and see whether anything is dangling.

Step 7 is the one to internalise. Doing it once deliberately, in a repository that does not matter, makes it a reflex rather than a panic later.

You now know how to reshape history. The next lesson steps back to the strategic question: rebase or merge, as an integration policy.