Rebasing and Rewriting History
Changing history safely: rebasing a branch, squashing commits, cherry-picking a single commit, force pushing with care, and the rule never to rewrite history others already use.
What rebase does
Merging joins two lines of work with a merge commit (see Merging and Conflicts). A rebase takes another approach: it moves your branch so that it starts from a new base commit. Git takes the commits that are on your branch but not on the new base, and replays them one by one on top of it, as if you had started your work there. Then it moves your branch pointer to the last replayed commit.
In the example below, Sam's branch acl-update has two
commits, and main got a new commit in the meantime. Sam
rebases his branch onto main:
git init -q netops && cd netops
printf 'hostname edge-01\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
printf 'ntp server 192.0.2.10\n' >> router.cfg
git commit -q -am "Add NTP server"
git switch -q -c acl-update
printf 'ip access-list MGMT\n' > acl.cfg
git add acl.cfg && git commit -q -m "Add management ACL"
printf ' permit 198.51.100.0/24\n' >> acl.cfg
git commit -q -am "Allow NOC subnet in ACL"
git switch -q main
printf 'logging host 192.0.2.20\n' >> router.cfg
git commit -q -am "Add syslog server"
git switch -q acl-update
git log --oneline --graph --all
git rebase -q main # -q: no progress lines
git log --oneline --graph --all
* a8fe1f6 Allow NOC subnet in ACL * f7ca96a Add management ACL | * c8dae00 Add syslog server |/ * 5a75fd4 Add NTP server * aedb4dc Add edge-01 base config * 93200a0 Allow NOC subnet in ACL * c7c59e1 Add management ACL * c8dae00 Add syslog server * 5a75fd4 Add NTP server * aedb4dc Add edge-01 base config
Before the rebase, the branch started at 5a75fd4;
afterward it starts at c8dae00, the tip of
main. Look at the hashes: the commits have the same
messages and changes, but f7ca96a became
c7c59e1 and a8fe1f6 became
93200a0. A commit's hash covers its parent, so a commit
with a new parent is a new commit. The originals still exist for a
while but no branch points to them any more. Without
-q, Git ends with
Successfully rebased and updated refs/heads/acl-update.
Merging acl-update into main now is a
fast-forward. That is the typical workflow: rebase your topic branch
onto the latest main, test it, then merge it without a
merge commit.
Merge or rebase?
| Merge | Rebase | |
|---|---|---|
| History | Shows what really happened, including parallel work | A straight line, as if work happened one after another |
| Existing commits | Unchanged; one new merge commit | Replaced by copies with new hashes |
| Conflicts | Resolved once, in the merge commit | Resolved per replayed commit, possibly several times |
| Safe on shared branches | Yes | No: only on commits nobody else has |
Neither is better in general; teams choose. A common combination: rebase your own topic branch to keep it current and tidy, and merge it into the shared branch (often through a pull request). GitHub offers "rebase and merge" and "squash and merge" as merge methods for pull requests; a rebase-merge creates new commit hashes on the base branch (see Pull Requests and Code Review).
Interactive rebase
An
interactive rebase (git rebase -i) lets you edit the list of commits before Git replays them. It is
the tool for cleaning up your own work before you share it:
combining "fix typo" commits, rewording unclear messages, putting
commits in a logical order. Git opens your editor with a
todo list, one line per commit, oldest first:
git rebase -i main # rewrite the commits that are not on main
pick 0fbbd1b Add management ACL squash c32c6f6 Allow NOC subnet in ACL fixup a88ba9b typo reword 3d41f07 add uplink descr drop 9be27c1 Temporary debug logging # Rebase aedb4dc..9be27c1 onto aedb4dc (5 commands) # # Commands: # p, pick <commit> = use commit # r, reword <commit> = use commit, but edit the commit message # e, edit <commit> = use commit, but stop for amending # s, squash <commit> = use commit, but meld into previous commit # f, fixup [-C | -c] <commit> = like "squash" but keep only the previous # commit's log message, unless -C is used, ... # d, drop <commit> = remove commit # ...
| Command | Effect |
|---|---|
pick |
Keep the commit as it is (the default for every line) |
reword |
Keep the changes, but edit the message (Git opens the editor) |
edit |
Stop after this commit so you can amend it, then
git rebase --continue
|
squash |
Meld into the previous commit and combine both messages (editor opens) |
fixup |
Meld into the previous commit and discard this commit's message |
drop |
Remove the commit and its changes (deleting the line does the same) |
You can also reorder lines to reorder commits. When you save and
close the editor, Git replays the list from top to bottom and stops
where it needs you: for a message to write, an edit, or
a conflict. To rewrite the last few commits of the current branch
regardless of other branches, use git rebase -i HEAD~3.
Squashing
To squash is to combine several
commits into one. A branch with "Add ACL", "fix", "fix again" and
"typo" tells reviewers and later readers nothing extra; one commit
"Add management ACL" with a good message tells them everything. The
squash and fixup commands of an
interactive rebase do this on your branch; GitHub's "squash and
merge" does it when a pull request is merged. Keep commits separate
when they are separate, meaningful steps — squashing is for noise,
not for hiding structure.
Fixup commits and autosquash
Often you notice during review that an earlier commit on your branch
needs a correction. git commit --fixup=<commit>
records the correction as a separate commit named
fixup! <original message>.
git rebase -i --autosquash later moves each such commit
right below its target and marks it
fixup automatically. Normally you would just save the
prepared todo list; here GIT_SEQUENCE_EDITOR=: accepts
it unchanged without opening an editor (: is the shell
command that does nothing), so the example can run on its own:
git init -q netops && cd netops
printf 'hostname edge-01\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
git switch -q -c acl-update
printf 'ip access-list MGMT\n permit 198.51.100.0/24\n' > acl.cfg
git add acl.cfg && git commit -q -m "Add management ACL"
printf 'interface ge-0/0/0\n description uplink\n' >> router.cfg
git commit -q -am "Describe uplink interface"
# review comment: the ACL also needs the jump host
printf ' permit 203.0.113.10/32\n' >> acl.cfg
git commit -q -a --fixup=HEAD~1 # "fixup! Add management ACL"
git log --oneline
GIT_SEQUENCE_EDITOR=: git rebase -q -i --autosquash main
git log --oneline
cat acl.cfg
482a7e2 fixup! Add management ACL 8ecefa4 Describe uplink interface c0fcc10 Add management ACL aedb4dc Add edge-01 base config eb2718f Describe uplink interface 9a62549 Add management ACL aedb4dc Add edge-01 base config ip access-list MGMT permit 198.51.100.0/24 permit 203.0.113.10/32
The fixup commit has disappeared into "Add management ACL", which
now contains both permit lines, and both remaining commits on the
branch have new hashes. Setting
git config --global rebase.autoSquash true makes
--autosquash the default for interactive rebases.
Cherry-pick
A cherry-pick (git cherry-pick <commit>) copies the changes of one existing commit onto the current branch
as a new commit. It is the tool for "we need that one fix on
main now, but the rest of the branch is not ready":
git init -q netops && cd netops
printf 'hostname edge-01\nntp server 192.0.2.10\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
git switch -q -c snmp-rework
printf 'snmp-server location rack-3\n' > snmp.cfg
git add snmp.cfg && git commit -q -m "Start SNMP rework"
sed -i 's/192.0.2.10/192.0.2.11/' router.cfg
git commit -q -am "Fix NTP server address"
git log --oneline -1
git switch -q main # main needs the fix now, not the unfinished rework
git cherry-pick snmp-rework
git log --oneline --graph --all
1e67b03 Fix NTP server address [main d4ad17b] Fix NTP server address Date: Mon Mar 2 09:00:00 2026 +0000 1 file changed, 1 insertion(+), 1 deletion(-) * d4ad17b Fix NTP server address | * 1e67b03 Fix NTP server address | * a8e1ab3 Start SNMP rework |/ * 82f7071 Add edge-01 base config
The new commit d4ad17b has the same message, author and
author date (the Date: line) as the original
1e67b03, but a different parent, so a different hash.
The two now exist side by side; when snmp-rework is
merged later, the merge usually goes through cleanly, because both
sides made the same change. With -x, Git appends a line
(cherry picked from commit …) to the message, which is
useful when you backport fixes to release branches. A cherry-pick
can conflict like a merge; resolve the file,
git add it, and run
git cherry-pick --continue (or --abort).
Conflicts during a rebase
Because a rebase replays commits one at a time, each replayed commit
can conflict with the new base. Git then stops in the middle of the
rebase, on a detached HEAD:
git init -q netops && cd netops
printf 'hostname edge-01\nlogging host 192.0.2.20\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
git switch -q -c new-collector
sed -i 's/192.0.2.20/192.0.2.25/' router.cfg
git commit -q -am "Move syslog to new collector"
git switch -q main
sed -i 's/192.0.2.20/198.51.100.20/' router.cfg
git commit -q -am "Send syslog to NOC server"
git switch -q new-collector
git rebase -q main
Auto-merging router.cfg CONFLICT (content): Merge conflict in router.cfg error: could not apply 5e1fd03... Move syslog to new collector hint: Resolve all conflicts manually, mark them as resolved with hint: "git add/rm <conflicted_files>", then run "git rebase --continue". hint: You can instead skip this commit: run "git rebase --skip". hint: To abort and get back to the state before "git rebase", run "git rebase --abort". Could not apply 5e1fd03... Move syslog to new collector
Resolve it the same way as a merge conflict — edit the file, remove
the conflict markers, git add it — but then continue
the rebase instead of committing. Git replays the remaining
commits and moves the branch:
git init -q netops && cd netops
printf 'hostname edge-01\nlogging host 192.0.2.20\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
git switch -q -c new-collector
sed -i 's/192.0.2.20/192.0.2.25/' router.cfg
git commit -q -am "Move syslog to new collector"
git switch -q main
sed -i 's/192.0.2.20/198.51.100.20/' router.cfg
git commit -q -am "Send syslog to NOC server"
git switch -q new-collector
git rebase -q main > /dev/null 2>&1 # stops with the conflict shown above
printf 'hostname edge-01\nlogging host 192.0.2.25\n' > router.cfg
git add router.cfg
GIT_EDITOR=true git rebase --continue > /dev/null # (hides progress lines)
git log --oneline
18f6b42 Move syslog to new collector bdba149 Send syslog to NOC server 1c9b5a4 Add edge-01 base config
GIT_EDITOR=true accepts the commit message unchanged
without opening an editor; in daily use you just run
git rebase --continue. The three ways out of a stopped
rebase:
-
git rebase --continue: after resolving andgit add, go on with the next commit. -
git rebase --skip: drop the commit that conflicts and go on (its changes are lost from the result). -
git rebase --abort: stop and put the branch back exactly where it was before the rebase started.
Note the side effect of rebasing: in a conflict,
HEAD ("ours") is the new base with the commits replayed
so far, and "theirs" is your own commit being replayed — the reverse
of a merge. If a long branch conflicts again and again,
git rebase --abort followed by a merge is often the
calmer choice.
The golden rule
Rewriting history means replacing existing commits with new ones: by rebasing, amending, squashing, dropping commits, or moving a branch back. Locally, on commits only you have, this is everyday cleanup. The golden rule:
Never rewrite commits that you have shared with others — never rebase, amend or squash commits that are already on a shared branch.
Why? Your colleagues' clones still contain the old commits, and their new work builds on them. After you replace those commits, the server's branch and their branch have diverged with what looks like duplicate commits — same messages, different hashes. The next pull merges both versions back together, conflicts appear in code nobody changed, and in the worst case someone's force push erases the other person's work. Untangling it costs the whole team time.
In practice: rebase and squash freely on your own topic branch
before you push it, or while it is pushed but clearly yours (for
example a pull request branch only you work on). Never rewrite
main, release branches, or anything others build on;
undo published commits with git revert instead (see
Undoing Things). Many teams
enforce this on the server with branch protection rules or rulesets
that block force pushes.
Force pushing safely
A normal git push only succeeds if it is a fast-forward
for the server's branch: your commits must build on what is already
there. After rewriting commits you already pushed, that is no longer
true, so Git refuses. A
force push overwrites the server's
branch with yours anyway, discarding whatever commits were only on
the server.
The examples use a bare repository in the same folder as the shared
"server" (see
Remotes and Collaboration), connected with git remote add origin ../shared.git
so the output shows a short path. Sam pushed his branch, then
amended the commit:
git init -q --bare shared.git # the shared repository ("the server")
git init -q sam && cd sam
git remote add origin ../shared.git
printf 'hostname edge-01\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
git push -q origin main
git switch -q -c acl-update
printf 'ip access-list MGMT\n' > acl.cfg
git add acl.cfg && git commit -q -m "Add management ACL"
git push -q -u origin acl-update
# Sam rewrites the commit he already pushed
printf ' permit 198.51.100.0/24\n' >> acl.cfg
git commit -q -a --amend --no-edit
git push
git push --force-with-lease
To ../shared.git ! [rejected] acl-update -> acl-update (non-fast-forward) error: failed to push some refs to '../shared.git' hint: Updates were rejected because the tip of your current branch is behind hint: its remote counterpart. Integrate the remote changes (e.g. hint: 'git pull ...') before pushing again. hint: See the 'Note about fast-forwards' in 'git push --help' for details. To ../shared.git + 0fbbd1b...c0fcc10 acl-update -> acl-update (forced update)
The first push is rejected as non-fast-forward: the
server has commit 0fbbd1b, which is not part of Sam's
rewritten branch. The hint suggests pulling, which would be the
right advice if someone else had pushed — but here Sam replaced the
commit on purpose. --force-with-lease then succeeds:
+ 0fbbd1b...c0fcc10 and (forced update)
show that the branch was replaced rather than extended.
The lease is the safety catch. Git only overwrites the
server's branch if it still points to the commit that your
remote-tracking branch (origin/acl-update) says it
points to — that is, if nobody pushed since you last fetched. In the
next example Amara pushes a commit to the same branch while Sam is
rewriting:
git init -q --bare shared.git # the shared repository ("the server")
git init -q sam && cd sam
git remote add origin ../shared.git
printf 'hostname edge-01\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
git push -q origin main
git switch -q -c acl-update
printf 'ip access-list MGMT\n' > acl.cfg
git add acl.cfg && git commit -q -m "Add management ACL"
git push -q -u origin acl-update
# Sam rewrites the commit he already pushed
printf ' permit 198.51.100.0/24\n' >> acl.cfg
git commit -q -a --amend --no-edit
# meanwhile, Amara adds a commit to the same branch on the server
cd .. && git clone -q -b acl-update shared.git amara && cd amara
git config user.name "Amara Okafor"
git config user.email amara@example.com
printf ' permit 203.0.113.10/32\n' >> acl.cfg
git commit -q -am "Allow jump host in ACL" && git push -q
# Sam has not fetched since: his lease is out of date
cd ../sam
git push --force-with-lease
To ../shared.git ! [rejected] acl-update -> acl-update (stale info) error: failed to push some refs to '../shared.git'
(stale info) means: the server has moved since Sam last
looked, so his view is out of date and the push is refused. A plain
git push --force would have succeeded here and silently
deleted Amara's commit from the branch. Sam should fetch, look at
what Amara added, rebase onto it, and only then push again.
git pull --rebase
git pull is git fetch followed by
integrating the fetched branch into yours. When both you and a
colleague committed to the same branch, the branches have diverged,
and there are two ways to integrate: a merge, which adds a "Merge
branch 'main' of …" commit every time this happens, or a rebase.
git pull --rebase fetches and then replays your local
commits on top of what you fetched:
git init -q --bare shared.git
git clone -q shared.git sam 2>/dev/null && cd sam
printf 'hostname edge-01\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
git push -q origin main
cd .. && git clone -q shared.git amara && cd amara
git config user.name "Amara Okafor"
git config user.email amara@example.com
printf 'ntp server 192.0.2.10\n' > ntp.cfg
git add ntp.cfg && git commit -q -m "Add NTP config" && git push -q
cd ../sam # Sam has a local commit, Amara pushed first
printf 'logging host 192.0.2.20\n' > logging.cfg
git add logging.cfg && git commit -q -m "Add syslog config"
git pull -q --rebase # fetch, then replay Sam's commit on top
git log --oneline --graph --format='%h %an: %s'
git push -q
* 96ba253 Sam Rivera: Add syslog config * b1f7c8a Amara Okafor: Add NTP config * aedb4dc Sam Rivera: Add edge-01 base config
Sam's local commit was replayed on top of Amara's, so the history is
a straight line and the push that follows is a normal fast-forward —
no force needed. This does not break the golden rule: the only
rewritten commit is Sam's own, unpublished one. Without any setting,
a plain git pull on diverged branches stops in Git 2.39
with
fatal: Need to specify how to reconcile divergent branches.
and lists the choices. Choose once with
git config --global pull.rebase true (rebase),
pull.rebase false (merge) or
pull.ff only (only fast-forwards; otherwise stop).