Skip to main content
Git & GitHub · lesson 7 of 12
Git & GitHub intermediate Lesson 7 of 12

Also part of: DevOps

PR Workflow: Rebasing, Squash Merge, and Merge Commits

Sync a feature branch with rebase instead of merge, then compare squash-merge and --no-ff merge-commit history side by side with real git log output.

By SkillByExample · Updated

git rebase pull-request

The mechanics from earlier lessons — branch, merge, conflict, rebase — are exactly what a pull-request workflow is built from. This lesson uses two real merge strategies on two real branches so the resulting history can be compared directly.

1) Sync a PR branch onto main with rebase

$ # ...create feature/mathlib-docs, commit "Document multiply()"...
$ # ...meanwhile, main gains "Add mathlib.mod"...
$ git checkout feature/mathlib-docs
$ git rebase main
Successfully rebased and updated refs/heads/feature/mathlib-docs.
$ git log --oneline --graph -3
* f89e27e Document multiply()
* c3f2b20 Add mathlib.mod
* 0b276a2 Add mathlib.divide

The branch’s one commit now sits directly on top of main’s latest tip, with a brand new hash (f89e27e) — same conflict-resolution mechanics as an interactive rebase, just against main instead of squashing your own commits.

2) Squash-merge collapses the branch into one commit

$ git checkout main
$ git merge --squash feature/mathlib-docs
Updating c3f2b20..f89e27e
Fast-forward
Squash commit -- not updating HEAD
 mathlib.mjs | 1 +
 1 file changed, 1 insertion(+)
$ git commit -m "Document multiply() (#42)"
$ git log --oneline --graph -4
* c8217ef Document multiply() (#42)
* c3f2b20 Add mathlib.mod
* 0b276a2 Add mathlib.divide
* f70cf08 Refactor add for readability

--squash stages the branch’s combined diff but deliberately doesn’t commit — that’s what “not updating HEAD” means. One git commit later, main’s history has exactly one new, linear commit. feature/mathlib-docs itself and its original commit (f89e27e) still exist, just no longer referenced from main.

3) A —no-ff merge keeps the branch’s commits, visibly

$ # ...on a second branch, feature/mathlib-readme, one commit "Add README"...
$ git checkout main
$ git merge --no-ff feature/mathlib-readme -m "Merge feature/mathlib-readme"
$ git log --oneline --graph -3
*   16e3bf5 Merge feature/mathlib-readme
|\
| * 3efe520 Add README
|/
* c8217ef Document multiply() (#42)

--no-ff forces a real merge commit (two parents) even though a fast-forward was possible — the opposite choice from squash. 3efe520 stays visible in main’s log, reachable through the merge commit, which is the tradeoff: full branch history in exchange for a less linear graph.

Practice

  1. Run git log --oneline (no --graph) after both merges above and compare it to git log --oneline --graph. Notice the squashed commit and the merge commit both appear as single lines either way — --graph is what reveals the branch structure.

  2. Repeat the squash-merge steps but this time use git merge --squash on a branch with three commits. Confirm all three collapse into the one commit you make — not three separate ones.

    Solution

    git merge --squash always stages the entire combined diff between the branches’ common ancestor and the branch tip, regardless of how many commits produced it. The commit count on the source branch never affects the result: it’s always one commit.

  3. After a --no-ff merge, run git log --first-parent main to see only main’s own commit sequence with each merge collapsed to one line — the view most PR-based teams actually read day to day.

  4. Force a real conflict during a rebase (not a merge) by editing the same line on both main and a feature branch, then git rebase main. Resolve it with git add <file> followed by git rebase --continue — note the command name differs from a merge conflict’s git commit.

  5. After rebasing a branch that was already pushed, run git push (without --force) and read the rejection. Then use git push --force-with-lease and explain why --force-with-lease is safer than plain --force for a shared branch.

Frequently Asked Questions

Why rebase a PR branch onto main instead of merging main into it?
Rebase replays your branch's commits on top of main's latest tip, producing linear history with no extra merge commit inside the PR. Merging main into your branch works too, but leaves a merge commit in the PR's own history that squash-merge or a clean rebase avoids.
What's the practical difference between squash merge and a --no-ff merge commit, in the resulting history?
Squash merge collapses every commit on the branch into exactly one new commit on main, and the branch's own commits never appear in main's log. --no-ff creates a merge commit with two parents, and every individual branch commit stays visible in main's history via that merge.
Why did git merge --squash say 'Fast-forward' even though nothing was actually fast-forwarded?
That message is git checking whether it could apply the squashed diff cleanly by fast-forward semantics internally — it's part of how --squash computes the combined diff, not a claim that history moved. Notice the very next line, 'Squash commit -- not updating HEAD', which is the real signal: nothing is committed yet, and you still run git commit yourself.
If I rebase a branch after opening a PR, doesn't that rewrite commits the reviewer already saw?
Yes — rebase gives every replayed commit a new hash, so a force-push is required afterward (git push --force-with-lease), and most review tools show this as new commits even if the diff is identical. Many teams reserve rebase-before-merge for local cleanup and let the platform's merge button do the actual integration.