Git moves code around. Sometimes you want to bring an entire branch back into the branch you are on. Sometimes you want the same work, but sitting on top of newer work. Sometimes you want one commit and not the rest. Sometimes you just want to fix the commit you made thirty seconds ago.
Git uses different commands for those cases:
mergecombines two lines of development and keeps the fact that they joinedrebaserebuilds a branch on top of a different basecherry-pickcopies one commit's change onto your current branchcommit --amendreplaces the current tip commit
We'll start with merge. It is the simplest picture, and the other commands make more sense once that picture is clear.
Merge Brings Two Lines of Development Together
At the human level, merging means: take the work from that other line of development and make it part of this one too.
A merge commit is one commit that records where two lines of history joined. But sometimes a merge does not write a new commit at all. If the branch you are on is simply behind the branch you are merging, Git can just move the current branch name forward.
- Fast-Forward
- Ref update that advances a branch name to a descendant commit without creating a merge commit.
A---B main
\
C---D feature
git switch main
git merge feature
A---B---C---D main, feature
If you are on main, then HEAD usually points to main, not directly to the commit. So when main moves from B to D, HEAD comes with it because your current branch now points at D. Nothing was copied and no old commit was edited. Git only moved the label. Your latest commit from the feature branch is now the latest commit on main.
However, when both sides have moved, Git has to compute a combined result. It finds the best common ancestor, compares each side against that base, writes the merged tree, and records the result as a new commit with two parents.
- Merge Commit
- Commit with two or more parent links, used to join previously separate lines of development.
A---B---C main
\
D---E feature
git switch main
git merge feature
A---B---C-------M main
\ /
D---E---' feature
A merge keeps the fact that two lines diverged and later rejoined. Merge preserves branch shape instead of flattening it.
A Non-Fast-Forward Merge Writes a New Commit
In a non-fast-forward merge, Git does not splice two branches together retroactively.
A non-fast-forward merge usually does this:
- reads the current
HEAD, the other commit, and their merge base - compares trees and blobs to compute the merged file state
- updates the index, often with conflict stages if needed
- writes a new tree for the resolved result
- writes one new commit with two parents: the merge commit
- moves the current branch ref to that new commit
Rebase Rebuilds a Series on a New Base
Here base just means "the commit underneath the work you are replaying."
git rebase takes a series of commits and rebuilds it on top of a different commit. Git does not move the old commit objects. It checks out the new base and writes new commits that replay the same changes one by one.
Rebased commits get new object IDs for a boring reason: they are literally new commits. A commit ID depends on its tree, parents, metadata, and message. Change the parent, and you changed the commit. Change the commit, and the ID changes with it.
The simplest way to read rebase is:
- find the commits to replay
- find the new base
- apply each change in order
- write replacement commits on top of the new base
- move the branch name to the tip of the rewritten series
Rebasing a branch that others already depend on can get awkward fast for the same reason. The old commits are still in the object database for a while, but the branch now names a different series. To Git, those are different histories even if a human would describe them as "the same work, cleaned up."
A plain rebase flow looks like:
git switch topic
git rebase main
git rebase --continue
git rebase --abort
The second command starts replaying topic on top of main. --continue resumes after you resolve a conflict. --abort returns the branch to its pre-rebase state.
Cherry-Pick Reuses a Change on a New Base
Cherry-pick follows the same model on a smaller stage. It takes the change introduced by an existing commit, applies that change relative to your current HEAD, and writes a new commit for the result.
- Cherry-Pick
- Operation that reapplies the change from an existing commit onto the current
HEADby creating a new commit.
git cherry-pick reuses the change, not the original commit object. Similar effect, new commit, new parent, new ID.
That distinction is important: a cherry-picked change may apply cleanly on one branch and conflict on another. Even when it applies cleanly, the resulting commit has a different parent, a different object ID, and sometimes a different tree than the original.
And that property is what makes cherry-pick useful for backports, hotfix propagation, and selective release work. It lets you reuse one change without merging or rebasing an entire branch.
A small backport flow looks like:
git switch release/1.2
git cherry-pick -x <commit>
git cherry-pick --continue
git cherry-pick --abort
-x appends the source commit ID to the new message, which is often useful when the point is traceable backports.
commit --amend Replaces the Tip
git commit --amend feels like "edit the last commit," but under the hood Git is doing something more mechanical: it reads the current index and current HEAD, writes a replacement commit object, and moves the branch to point to the new one. The use of the term "amend" distorts the picture; it is a replace, not an edit.
If only the message changed, the tree may stay the same while the metadata changes. If staged content changed too, the tree changes as well. Either way, the result is a new commit object replacing the old tip from the branch's point of view.
At the command line, that usually means one of these:
git commit --amend --no-edit
git commit --amend
The first keeps the existing message. The second opens the message for edits too.
Conflicts Live in the Index Before They Become a Commit
- Conflict
- Situation where Git cannot automatically combine competing file states and needs you to resolve the result before it can continue.
A conflict happens when Git can see the two sides you want combined, but cannot decide on one final file state by itself.
During a conflicted merge, rebase, or cherry-pick, Git does not have a finished commit yet. It stores the conflict in the index using multiple stages for the affected paths, and it writes conflict markers into working-tree files so you can resolve them.
You can see that unresolved state directly:
git ls-files -u
100644 1a2b3c4d 1 src/app.ts
100644 5e6f7a8b 2 src/app.ts
100644 9c0d1e2f 3 src/app.ts
That output means Git is holding multiple versions of the same path in the index at once: stage 1 is the merge base version, stage 2 is one side, and stage 3 is the other side. Once you resolve the file and git add it, the index goes back to holding one version of that path, and Git can continue.
That means conflict resolution is not something Git records directly in history as a special object type. Until you resolve it, the messy part lives in the index and working tree. Once you edit the files, stage the resolution, and continue the operation, Git writes an ordinary tree and an ordinary commit from that resolved state.
The resulting commit IDs change even if the conflict felt minor. The resolved tree is part of the commit identity. Different resolution, different tree. Different tree, different commit.
rerere Caches Conflict Resolutions
rerere- "Reuse recorded resolution," a Git feature that remembers how you resolved a conflict and can apply the same resolution again later.
rerere is one of Git's driest feature names and one of its nicest tricks. When enabled, Git records the shape of a conflict and the resolution you chose. If the same conflict appears again in a later merge, rebase, or cherry-pick, Git can often reuse that recorded resolution automatically or at least narrow the work.
History rewriting often means seeing the same conceptual conflict more than once. A branch may be rebased repeatedly while another line of development keeps moving. Without some memory of previous resolutions, you wind up paying the same conflict bill again and again.
rerere does not change Git's merge model. It sits on top of it as a practical cache for repeated conflict work, and on busy branches that can make rewrite-heavy workflows much less tedious.
The setup and inspection surface are small:
git config rerere.enabled true
git config rerere.autoupdate true
git rerere status
git rerere diff
The config turns the cache on. The last two commands are most useful while a conflict is live: they show what rerere is tracking and what resolution it can replay.
range-diff Compares Patch Series, Not Just Commit IDs
When a patch series has been rebased, cleaned up, split apart, or reordered, a plain commit-to-commit diff is often the wrong review tool. The commit IDs changed, and sometimes the parent relationships changed too, but the real review question is usually narrower: how did the series itself change?
range-diff- Git command that compares two commit ranges as patch series, showing how the rewritten series differs from the earlier one.
git range-diff is useful because it compares the series by patch content and structure rather than by asking whether the commit IDs match. That makes it a much better tool for reviewing rebased or amended work.
Once you accept that rewritten commits are new commits, the next question is practical: how do I compare the old series to the new one? range-diff is one of the best answers Git provides.
Two common shapes are:
git range-diff origin/main..topic-v1 origin/main..topic-v2
git range-diff origin/main..topic@{1} origin/main..topic
The first compares two named versions of a patch series. The second uses the reflog to compare the previous form of topic with the current one after a rewrite.
Rewrite Commands Mostly Create and Move
The commands in this chapter sound different, but most of them are combinations of the same few actions:
- read existing commits, trees, and merge-base relationships
- update the index and working tree during application or conflict resolution
- write new trees and new commits
- move refs so branch names point to the new commits
Once you see that pattern, a lot of the drama drains out. Rewrite operations change what the branch names point to, and that changes the visible history. But the underlying mechanism is much more regular than the command vocabulary suggests.
Rewriting gets described as "changing history" because the branch now names a different graph. Git still gets there through the same old machinery: write objects, move refs. The storage model stays consistent the whole time.
Why Recovery Still Starts with Refs and Reflogs
Once you see rewrite commands this way, recovery stories make more sense too. A failed rebase or an amended-away commit often feels like content disappeared. The first question is usually simpler: which ref used to point where?
Reflogs matter so much around rewrite-heavy workflows because rebase, cherry-pick, amend, and reset all move names around new or existing commits. If the outcome is not what you wanted, reflogs often give you the fastest route back to the previous attachment points.
Published history needs a clearer standard around rewriting than local history. Local rewriting is often just cleanup. Rewriting history that others are already building on changes the names and commit identities they are depending on.
History Surgery Is Ordinary Git, Not a Separate Subsystem
Merge, rebase, cherry-pick, and amend are not advanced side quests bolted onto Git's normal model. They are ordinary consequences of it.
Commits are immutable. Trees capture resolved file state. Refs are movable names. The index holds the next snapshot, including conflicted intermediate states. Once those facts are in place, history surgery reads more plainly. It becomes a sequence of reads, temporary local state, new object creation, and ref updates.
At that level, these commands become easier to trust. You do not need to memorize each porcelain workflow as a special ritual if the underlying mechanics are visible.
One cost that can hide inside merge, commit, and rebase is hook execution. Git runs pre-commit, post-merge, post-checkout, and other hooks at defined points, and a slow hook can dominate command time in a way that looks like "Git is slow" rather than "our hook is slow." The transport chapter covers hooks and their performance impact in more detail.