Git & GitHub Module 7 – Rebase, cherry-pick and revert
Merge, rebase or squash: choose a strategy
Compare merge commits, rebase and merge, and squash and merge: the history each leaves, what it means for revert, bisect and blame, and how to set a team rule.
What you will learn
- Compare the history shapes produced by merge commits, rebase and merge, and squash and merge
- Explain how each strategy affects bisect, revert, blame and release notes
- Compare two versions of a rebased series with git range-diff
- Write a team convention for integrating branches and enforce it in repository settings
Before you start
What you’ll learn · Before you start
What you will learn
- Compare the history shapes produced by merge commits, rebase and merge, and squash and merge
- Explain how each strategy affects bisect, revert, blame and release notes
- Compare two versions of a rebased series with git range-diff
- Write a team convention for integrating branches and enforce it in repository settings
Before you start
On this page
Merge, rebase and squash are three ways to bring a finished branch into main, and they differ in the history they leave behind. A merge commit keeps every commit of the branch as it was and adds one commit with two parents, so the history shows where the branch started and where it joined. Rebase and merge replays the branch’s commits on top of main as new commits, which gives one straight line in which every step survives. Squash and merge turns the whole branch into a single new commit on main. No strategy is best everywhere. Merge commits make a whole feature easy to find and undo, rebasing gives a linear history that keeps every step, and squashing gives one tidy commit per pull request at the cost of the steps inside it. Below, one branch is integrated all three ways, and each history is put through the jobs that come later: reverting, bisecting, blaming a line and writing release notes.
One branch, three histories
The branch feedback adds a feedback form to a hostel help desk in three commits, while main has gained a commit of its
own. The repository is copied three times, and each copy integrates the branch in one of the three ways. On GitHub
these are the three choices of the merge button: “Create a merge commit” (a merge with --no-ff), “Rebase and merge”
and “Squash and merge”.
# One feature branch, integrated three ways into three copies of the same repository.
from gitsession import Session
from copies import integrate, make_original
s = Session()
make_original(s)
for folder, how in [("with-merge", "merge"), ("with-rebase", "rebase"), ("with-squash", "squash")]:
integrate(s, folder, how)
s.cd(folder)
s.run("git log --oneline --graph main")
s.run("git branch --merged main")
s.cd("..")
s.say("in the squash copy feedback is not merged, so git branch -d refuses to delete it")
s.cd("with-squash")
s.run("git branch -d feedback")
s.run("git branch -D feedback") Output
$ cd with-merge $ git log --oneline --graph main * 540597d Merge branch 'feedback' |\ | * ab1604f Thank students after they send feedback | * 6ac4627 Store feedback in a file | * 16f33c5 Add the feedback form * | 6f82a99 Add the warden's phone number |/ * d8d8895 Start the hostel help desk $ git branch --merged main feedback * main $ cd .. $ cd with-rebase $ git log --oneline --graph main * c559b74 Thank students after they send feedback * 6d30041 Store feedback in a file * 4bc160c Add the feedback form * 6f82a99 Add the warden's phone number * d8d8895 Start the hostel help desk $ git branch --merged main feedback * main $ cd .. $ cd with-squash $ git log --oneline --graph main * cb2b48c Collect student feedback (#7) * 6f82a99 Add the warden's phone number * d8d8895 Start the hostel help desk $ git branch --merged main * main $ cd .. # in the squash copy feedback is not merged, so git branch -d refuses to delete it $ cd with-squash $ git branch -d feedback error: the branch 'feedback' is not fully merged hint: If you are sure you want to delete it, run 'git branch -D feedback' hint: Disable this message with "git config set advice.forceDeleteBranch false" [exit status 1] $ git branch -D feedback Deleted branch feedback (was ab1604f).
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 three_ways.py
The same branch on main after a merge, a rebase and a squash
Text description of the diagram
The diagram shows main in the three copies of the repository after the branch feedback, with its three commits, was integrated. In all three, main's history starts with d8d8895, "Start the hostel help desk", followed by main's own commit 6f82a99, "Add the warden's phone number"; the merge copy is drawn down to d8d8895, where the branch started, and the other two copies down to 6f82a99.
with-merge:
- The branch's commits keep their IDs: 16f33c5, "Add the feedback form"; 6ac4627, "Store feedback in a file"; ab1604f, "Thank students after they send feedback". The first of them has d8d8895 as its parent.
- The merge commit 540597d, "Merge branch 'feedback'", has two parents: 6f82a99 (parent 1, main) and ab1604f (parent 2, the branch).
with-rebase:
- The three changes are new commits in one line on top of 6f82a99: 4bc160c, 6d30041 and c559b74, with the same subjects as the originals.
- There is no merge commit.
with-squash:
- One new commit, cb2b48c, "Collect student feedback (#7)", holds all three changes and sits directly on 6f82a99.
- None of the branch's own commits is part of main.
- with-merge keeps the three commits with their original IDs and adds a merge commit whose parents are main’s previous tip and the branch’s tip. The graph shows the fork and the join.
- with-rebase has the same three commits, but as new commits with new IDs, in a line on top of main’s newest
commit. In this copy the branch itself was rebased and main fast-forwarded to it, so
git branch --mergedlists feedback. GitHub’s “Rebase and merge” button works differently: it writes the new copies onto the base branch and always creates new commits, even when no rebase was needed, and your branch keeps the old ones, so locally that branch looks unmerged. - with-squash has one new commit holding all three changes, with a message written for the whole pull request.
None of the branch’s commits is an ancestor of main, so
git branch --mergeddoes not list feedback andgit branch -drefuses to delete it; once you have checked the pull request was merged,-Ddeletes it.
copies.py builds the original repository and integrates the branch in each copy. It drives Git through the module’s
session helper, gitsession.py, which fixes the identity and the clock of every command, so running the session
yourself gives the IDs shown here:
Python (copies.py) · copies.py
# One repository with a three-commit feature branch, copied so that each copy can integrate the branch in its own
# way (imported by the examples of this lesson).
import os
import shutil
FORM = "<form>\n <textarea name=\"feedback\"></textarea>\n</form>\n"
def make_original(s):
"""main plus a three-commit branch, feedback, in the folder original/."""
s.run("git init -q -b main original", show=False)
s.cd("original", show=False)
s.commit("Start the hostel help desk", {"README.md": "Hostel help desk\n"})
s.run("git switch -q -c feedback", show=False)
s.commit("Add the feedback form", {"form.html": FORM})
s.commit("Store feedback in a file", {"store.py": "def store(text):\n open('feedback.txt', 'a').write(text + '\\n')\n"})
s.commit("Thank students after they send feedback", {"thanks.html": "<p>Thank you!</p>\n"})
s.run("git switch -q main", show=False)
s.commit("Add the warden's phone number", {"README.md": "Hostel help desk\nWarden: 0000 000 000\n"})
s.run("git tag before-feedback", show=False)
s.cd("..", show=False)
def integrate(s, folder, how):
"""Copies original/ to folder/ and integrates feedback into main there. how: 'merge' (a merge commit),
'rebase' (rebase, then fast-forward main) or 'squash' (one new commit)."""
shutil.copytree(os.path.join(s.cwd, "original"), os.path.join(s.cwd, folder))
s.cd(folder, show=False)
if how == "merge":
s.run("git merge -q --no-ff --no-edit feedback", show=False)
elif how == "rebase":
s.run("git rebase -q main feedback", show=False)
s.run("git switch -q main", show=False)
s.run("git merge -q --ff-only feedback", show=False)
else:
s.run("git merge -q --squash feedback", show=False)
s.run("git commit -q -m 'Collect student feedback (#7)'", show=False)
s.cd("..", show=False) Python (gitsession.py) · gitsession.py
"""Runs Git commands in a throwaway folder and prints them the way a terminal shows them.
Every example of this lesson imports this file. It gives each run the same names, e-mail addresses and clock and
keeps your own Git settings out of the way, so the commit IDs you get are the ones shown with the examples.
"""
import atexit
import os
import re
import shlex
import shutil
import subprocess
import sys
import tempfile
START = 1767600000 # the session's clock in Unix seconds; every Git command moves it on by one minute
ZONE = "+0530" # India Standard Time
def find_git():
"""The git program. `git --exec-path` names the folder of Git's own programs, and the git found there also
runs where /usr/bin/git is only a launcher (macOS)."""
try:
found = subprocess.run(["git", "--exec-path"], capture_output=True, text=True)
except FileNotFoundError:
sys.exit("Git is not installed: see https://git-scm.com/downloads")
path = os.path.join(found.stdout.strip(), "git")
return path if os.path.isfile(path) else "git"
GIT = find_git()
class Session:
"""A terminal in a new, empty folder."""
def __init__(self, name="Asha Rao", email="asha@example.com"):
self.base = tempfile.mkdtemp(prefix="git-lesson-")
atexit.register(shutil.rmtree, self.base, ignore_errors=True)
self.home = os.path.join(self.base, "home")
os.mkdir(self.home)
self.cwd = self.base
self.clock = START
self.user = (name, email)
def env(self):
name, email = self.user
stamp = f"{self.clock} {ZONE}"
env = {
"PATH": os.environ.get("PATH", ""),
"HOME": self.home, # an empty home folder: none of your ~/.gitconfig settings
"GIT_CONFIG_NOSYSTEM": "1", # and no system-wide ones
"LC_ALL": "C", # Git's messages in English
"GIT_AUTHOR_NAME": name,
"GIT_AUTHOR_EMAIL": email,
"GIT_AUTHOR_DATE": stamp,
"GIT_COMMITTER_NAME": name,
"GIT_COMMITTER_EMAIL": email,
"GIT_COMMITTER_DATE": stamp,
"GIT_EDITOR": "true", # accept the commit message Git proposes
}
if "TMPDIR" in os.environ:
env["TMPDIR"] = os.environ["TMPDIR"]
return env
def run(self, command, show=True):
"""Runs one git command, written as you would type it. Shows the command and what it printed (and the exit
status when it is not 0); returns the output. A command that is not shown must succeed."""
args = shlex.split(command)
if args[0] != "git":
raise ValueError("only git commands run here")
done = subprocess.run([GIT, *args[1:]], cwd=self.cwd, env=self.env(), stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, encoding="utf-8")
self.clock += 60
if show:
print(f"$ {command}")
# Git's summary of a new commit has a " Date:" line when the author date was set from outside (as the
# session's clock does) or kept from an earlier commit; the transcript shows that date as <author date>.
out = re.sub(r"^( Date: +).+$", r"\1<author date>", done.stdout, flags=re.M)
print(out, end="" if out.endswith("\n") or not out else "\n")
if done.returncode:
print(f"[exit status {done.returncode}]")
elif done.returncode:
raise RuntimeError(f"{command} failed:\n{done.stdout}")
return done.stdout
def cd(self, folder, show=True):
self.cwd = os.path.normpath(os.path.join(self.cwd, folder))
os.makedirs(self.cwd, exist_ok=True)
if show:
print(f"$ cd {folder}")
def write(self, path, text):
full = os.path.join(self.cwd, path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
def cat(self, path):
print(f"$ cat {path}")
with open(os.path.join(self.cwd, path), encoding="utf-8") as f:
print(f.read(), end="")
def commit(self, message, files):
"""Writes the files, stages them and commits, without showing it; returns the new commit's short ID."""
for path, text in files.items():
self.write(path, text)
self.run("git add " + " ".join(shlex.quote(p) for p in files), show=False)
self.run(f"git commit -q -m {shlex.quote(message)}", show=False)
return self.run("git rev-parse --short HEAD", show=False).strip()
def say(self, text):
"""A comment between commands."""
print(f"# {text}") Taking a feature out again
Sooner or later a merged feature has to come out of main. The command depends on the strategy:
# Taking the whole feature out again: the command depends on how it was integrated.
from gitsession import Session
from copies import integrate, make_original
s = Session()
make_original(s)
for folder, how in [("with-merge", "merge"), ("with-rebase", "rebase"), ("with-squash", "squash")]:
integrate(s, folder, how)
s.cd("with-merge")
s.run("git revert --no-edit -m 1 HEAD")
s.cd("../with-rebase")
s.run("git revert --no-edit HEAD~3..HEAD")
s.cd("../with-squash")
s.run("git revert --no-edit HEAD")
s.say("in every copy, HEAD has the same files (the same tree ID) as main before the feature")
s.cd("..")
for folder in ("with-merge", "with-rebase", "with-squash"):
s.run(f"git -C {folder} log --no-walk --format='tree %t %s' before-feedback HEAD") Output
$ cd with-merge $ git revert --no-edit -m 1 HEAD [main 87d3739] Revert "Merge branch 'feedback'" Date: <author date> 3 files changed, 6 deletions(-) delete mode 100644 form.html delete mode 100644 store.py delete mode 100644 thanks.html $ cd ../with-rebase $ git revert --no-edit HEAD~3..HEAD [main e39f184] Revert "Thank students after they send feedback" Date: <author date> 1 file changed, 1 deletion(-) delete mode 100644 thanks.html [main 0e8924c] Revert "Store feedback in a file" Date: <author date> 1 file changed, 2 deletions(-) delete mode 100644 store.py [main 434caab] Revert "Add the feedback form" Date: <author date> 1 file changed, 3 deletions(-) delete mode 100644 form.html $ cd ../with-squash $ git revert --no-edit HEAD [main ab284ec] Revert "Collect student feedback (#7)" Date: <author date> 3 files changed, 6 deletions(-) delete mode 100644 form.html delete mode 100644 store.py delete mode 100644 thanks.html # in every copy, HEAD has the same files (the same tree ID) as main before the feature $ cd .. $ git -C with-merge log --no-walk --format='tree %t %s' before-feedback HEAD tree a8cdd74 Revert "Merge branch 'feedback'" tree a8cdd74 Add the warden's phone number $ git -C with-rebase log --no-walk --format='tree %t %s' before-feedback HEAD tree a8cdd74 Revert "Add the feedback form" tree a8cdd74 Add the warden's phone number $ git -C with-squash log --no-walk --format='tree %t %s' before-feedback HEAD tree a8cdd74 Revert "Collect student feedback (#7)" tree a8cdd74 Add the warden's phone number
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 revert_feature.py
With a merge commit, one git revert -m 1 undoes the whole feature, because the merge commit stands for it. The
rebased copy needs a range, HEAD~3..HEAD, which makes one revert commit per original commit, newest first, and you
have to know where the feature starts and ends. The squash copy reverts its single commit. The last three commands prove
the result: in each copy, HEAD and the tag before-feedback have the same tree ID, so the files are exactly main’s files
from before the feature.
Bringing a reverted merge back needs care: Git still counts its commits as merged, so the revert has to be reverted before the branch is merged again, as the lesson on cherry-pick and revert shows. A reverted squash commit has no such trap.
What each strategy means later
Bisect. git bisect finds the commit that introduced a bug by testing commits between a good one and a bad one.
In the rebased history every commit is on main’s line and gets tested, so every commit has to build and pass, or bisect
has to skip it. After squash merges bisect names a whole pull request, which is quick but not precise. With merge
commits bisect may stop inside a merged branch; git bisect start --first-parent follows only the first parent of each
merge, tests main’s own line and names the merge that brought the bug in, which helps when a branch contained commits
that did not build.
Blame. git blame names the commit that last changed each line. After a squash merge that is the squash commit, so
its message has to explain the whole change; after a rebase or a merge it is the small commit that made the change,
with its own message.
Release notes. With merge commits, git log --first-parent --oneline main lists one merge per pull request, which
is close to a list of what shipped. After squash merges every commit on main is one pull request. A rebased history
lists every step of every branch, so release notes are better written from the pull-request titles.
Conflicts. A merge resolves conflicts once for the whole branch. A rebase resolves them once for every commit that conflicts, which is more work on a long branch and gives commits that each fit the new base. A squash merge, like a merge, resolves them once.
| Criterion | Merge commit | Rebase and merge | Squash and merge |
|---|---|---|---|
| History | Branch commits plus a merge commit | Copies of the commits, in a line | One commit per pull request |
| Commit IDs | Unchanged | New | One new commit |
| Undo | git revert -m 1 <merge> | Revert a range, one per commit | git revert <commit> |
| Bisect | Use --first-parent | Every commit must build | Stops at the pull request |
| Blame | The small commit | The small commit | The squash commit |
| Conflicts | Once | Once per commit | Once |
| Branch after | Merged | Merged if rebased locally | Not merged: delete it |
| When to choose | Features undone as units, release branches, audits | Curated commits, linear history | Noisy branches, one commit per change |
When a branch is merged twice: squash and long-lived branches
Squashing is risky for a branch that stays open after it was merged. If you keep committing to a branch after a squash merge and open a new pull request from it, GitHub’s documentation warns that the commits you already squashed are listed again and you may have to resolve the same conflicts in each new pull request, because main never received those commits, only their squashed copy. Start the next change from an up-to-date main instead, or use merge commits for long-lived branches such as release branches.
Reviewing a rebased pull request with range-diff
Rebasing a pull request during review makes “what changed since I last looked?” hard to answer, because every commit
has a new ID. git range-diff answers it: it pairs each commit of the old version with its counterpart in the new one
and shows how each patch changed. Here a reviewer asked for a fix in the second commit, and the author folded it in
with --fixup and --autosquash and rebased onto the new main:
# What changed between two versions of a pull request after a rebase and a fix: git range-diff.
from gitsession import Session
from copies import FORM
s = Session()
s.run("git init -q -b main hostel", show=False)
s.cd("hostel", show=False)
s.commit("Start the hostel help desk", {"README.md": "Hostel help desk\n"})
s.run("git switch -q -c feedback", show=False)
s.commit("Add the feedback form", {"form.html": FORM})
store = s.commit("Store feedback in a file", {"store.py": "def store(text):\n open('feedback.txt', 'a').write(text + '\\n')\n"})
s.commit("Thank students after they send feedback", {"thanks.html": "<p>Thank you!</p>\n"})
s.run("git branch feedback-v1", show=False)
s.run("git switch -q main", show=False)
s.commit("Add the warden's phone number", {"README.md": "Hostel help desk\nWarden: 0000 000 000\n"})
s.run("git switch -q feedback", show=False)
s.say("review of version 1: store() must not leave the file open. The fix goes into that commit")
s.write("store.py", "def store(text):\n with open('feedback.txt', 'a') as f:\n f.write(text + '\\n')\n")
s.run("git add store.py")
s.run(f"git commit -q --fixup={store}")
s.run("git rebase -q --autosquash main")
s.say("the reviewer compares version 1 with version 2")
s.run("git range-diff --creation-factor=100 main feedback-v1 feedback") Output
# review of version 1: store() must not leave the file open. The fix goes into that commit
$ git add store.py
$ git commit -q --fixup=6ac4627
$ git rebase -q --autosquash main
# the reviewer compares version 1 with version 2
$ git range-diff --creation-factor=100 main feedback-v1 feedback
1: 16f33c5 = 1: 91727c1 Add the feedback form
2: 6ac4627 ! 2: d5d07d8 Store feedback in a file
@@ Commit message
## store.py (new) ##
@@
+def store(text):
-+ open('feedback.txt', 'a').write(text + '\n')
++ with open('feedback.txt', 'a') as f:
++ f.write(text + '\n')
3: ab1604f = 3: 7eebcce Thank students after they send feedback
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 range_diff.py
= marks a commit whose patch did not change, even though its ID did, so a reviewer can skip it. ! marks a commit
that changed, followed by a diff of the two patches: the lines starting -+ were added by the old patch and are gone,
and the lines starting ++ are added by the new one. < and > would mark a commit that exists only in the old or
only in the new version. The three-argument form git range-diff main feedback-v1 feedback compares main..feedback-v1
with main..feedback. Right after a rebase, git range-diff @{u} @{1} @ compares your branch before and after it.
Choosing for your team
Pick by what the team needs to do with its history, not by taste. These questions decide most cases:
- Do you need to remove or ship whole features as units? Merge commits make each feature one commit to revert or to list in release notes. Release branches merged into main are the classic case.
- Do your pull requests contain commits worth keeping? If developers curate their series with interactive rebase and every commit builds, rebase and merge keeps those steps in a linear history. If branches are full of “wip” and “address review”, squash and merge throws that noise away.
- How many people commit to one branch? Rebasing rewrites commits, so it suits branches with one author. Branches that several people push to are safer with merge commits.
- Must the commits on main be exactly the ones that were reviewed and signed? A merge commit keeps the branch commits unchanged; rebase and squash create new commits.
Whatever you choose, write it down where contributors look, such as a contributing guide:
## Merging pull requests
- Every pull request is merged with **Squash and merge**. The squash commit's title is the pull request's title,
which follows our commit-message rules, and its number, for example "Collect student feedback (#7)".
- Before you ask for review, rebase your branch onto main, and push it with `git push --force-with-lease`.
Never rebase a branch that someone else has pushed commits to without asking them first.
- After a review round, push follow-up commits instead of rewriting the branch, so that reviewers can see what changed.
If you do rewrite it, say so in the pull request and paste the output of `git range-diff`.
- Delete your branch once it is merged. Start the next change from an up-to-date main, not from the merged branch.
- Reverting: revert the squash commit with `git revert <commit>` and say why in the message.
- Exceptions: release branches are merged into main with a merge commit (`git merge --no-ff`), so that a release can
be found and reverted as one unit. Then make the repository enforce it, so that the convention does not rely on memory. On GitHub:
- Repository settings, under “Pull Requests”, have one checkbox per method: “Allow merge commits”, “Allow squash merging” and “Allow rebase merging”. Leaving only one selected makes it the only way to merge.
- A branch protection rule or ruleset with “Require linear history” prevents merge commits on that branch; it needs squash or rebase merging to be allowed.
- A merge queue takes the choice away from the person merging: the queue’s own settings decide the merge method.
Git’s own project shows that merge commits and careful history can go together: its workflow guide, gitworkflows, brings topic branches in by merging them, and a topic is rebased only before it has been merged anywhere.
Exercise: build the three histories yourself
setup.py creates practice-strategies/ for a college fest site: main, a three-commit branch feature, and three
branches that start where main is, main-merge, main-rebase and main-squash. Integrate feature into each:
- main-merge: a merge commit whose parents are main’s tip and feature.
- main-rebase: the three commits copied onto main-rebase in a straight line, without changing feature itself.
- main-squash: exactly one new commit holding all of feature’s changes.
# Creates the practice repository practice-strategies/ in the current folder. Run: python3 setup.py
import os
import subprocess
import sys
FOLDER = "practice-strategies"
def find_git():
found = subprocess.run(["git", "--exec-path"], capture_output=True, text=True)
path = os.path.join(found.stdout.strip(), "git")
return path if os.path.isfile(path) else "git"
GIT = find_git()
clock = [1767600000]
def git(*args, fixed=False, check=True):
"""Runs git in the practice repository. fixed=True gives the set-up commits a fixed author, committer and time."""
env = dict(os.environ, LC_ALL="C")
if fixed:
stamp = f"{clock[0]} +0530"
clock[0] += 60
env.update(GIT_AUTHOR_NAME="Meera Iyer", GIT_AUTHOR_EMAIL="meera@example.com", GIT_AUTHOR_DATE=stamp,
GIT_COMMITTER_NAME="Meera Iyer", GIT_COMMITTER_EMAIL="meera@example.com", GIT_COMMITTER_DATE=stamp)
done = subprocess.run([GIT, *args], cwd=FOLDER, env=env, capture_output=True, text=True, encoding="utf-8")
if check and done.returncode:
sys.exit(f"git {' '.join(args)} failed:\n{done.stdout}{done.stderr}")
return done.stdout.strip()
def succeeds(*args):
"""Whether a git command exits with status 0 (for questions such as merge-base --is-ancestor)."""
return subprocess.run([GIT, *args], cwd=FOLDER, capture_output=True).returncode == 0
def commit(message, files):
for path, text in files.items():
with open(os.path.join(FOLDER, path), "w", encoding="utf-8", newline="\n") as f:
f.write(text)
git("add", *files)
git("commit", "-q", "-m", message, fixed=True)
return git("rev-parse", "HEAD")
def create():
if os.path.exists(FOLDER):
sys.exit(f"{FOLDER}/ already exists: delete it to start again")
os.mkdir(FOLDER)
git("init", "-q", "-b", "main")
if not succeeds("config", "user.email"):
git("config", "user.name", "Practice Learner") # only when you have no Git identity yet
git("config", "user.email", "learner@example.com")
commit("Start the fest site", {"index.html": "<h1>Utsav, the college fest</h1>\n"})
git("switch", "-q", "-c", "feature")
commit("Add the event list", {"events.html": "<ul><li>dance</li><li>music</li></ul>\n"})
commit("Add a quiz to the events", {"events.html": "<ul><li>dance</li><li>music</li><li>quiz</li></ul>\n"})
commit("Link the events from the home page", {"index.html": '<h1>Utsav, the college fest</h1>\n<a href="events.html">Events</a>\n'})
git("switch", "-q", "main")
commit("Add the venue", {"venue.html": "<p>Open-air theatre</p>\n"})
for name in ("main-merge", "main-rebase", "main-squash"):
git("branch", name)
# What the checks compare with: where the three branches and feature started.
with open(os.path.join(FOLDER, ".git", "practice-start"), "w") as f:
f.write(git("rev-parse", "main") + "\n" + git("rev-parse", "feature") + "\n")
if __name__ == "__main__":
create()
print(f"Created {FOLDER}/ with main, feature and three copies of main: main-merge, main-rebase and main-squash.") Run python3 setup.py, work inside practice-strategies/, then run python3 check.py next to setup.py. Before you
start, six of its seven checks fail:
# Sample checks for the strategies practice. Run: python3 check.py (it creates practice-strategies/ first if it is
# missing)
import os
import setup
from setup import FOLDER, git, succeeds
if not os.path.exists(FOLDER):
setup.create()
with open(os.path.join(FOLDER, ".git", "practice-start")) as f:
start, feature = f.read().split()
SUBJECTS = ["Add the event list", "Add a quiz to the events", "Link the events from the home page"]
results = []
def check(name, ok):
results.append(ok)
print(f"{'pass' if ok else 'FAIL'} {name}")
def commits(revs):
"""(id, parents, subject) of each commit in revs, oldest first."""
out = git("log", "--reverse", "--format=%H %P%x09%s", revs)
rows = []
for line in out.splitlines():
ids, subject = line.split("\t", 1)
rows.append((ids.split()[0], ids.split()[1:], subject))
return rows
tree = lambda rev: git("rev-parse", f"{rev}^{{tree}}", check=False)
check("feature is unchanged", git("rev-parse", "feature") == feature)
merged = commits(f"{start}..main-merge")
tip = merged[-1] if merged else None
check("main-merge ends in a merge commit of main and feature",
tip is not None and tip[1] == [start, feature])
rebased = commits(f"{start}..main-rebase")
check("main-rebase has the three commits, in order, without a merge commit",
[c[2] for c in rebased] == SUBJECTS and all(len(c[1]) == 1 for c in rebased))
check("main-rebase's commits are new copies, not feature's own", bool(rebased) and not any(succeeds("merge-base", "--is-ancestor", c[0], feature) for c in rebased))
squashed = commits(f"{start}..main-squash")
check("main-squash has exactly one new commit, with one parent", len(squashed) == 1 and squashed[0][1] == [start])
want = tree("main-merge")
check("main-rebase has the same files as main-merge", bool(rebased) and tree("main-rebase") == want)
check("main-squash has the same files as main-merge", bool(squashed) and tree("main-squash") == want)
print(f"{sum(results)} of {len(results)} checks pass")
raise SystemExit(0 if all(results) else 1) Output (exit status 1)
pass feature is unchanged FAIL main-merge ends in a merge commit of main and feature FAIL main-rebase has the three commits, in order, without a merge commit FAIL main-rebase's commits are new copies, not feature's own FAIL main-squash has exactly one new commit, with one parent FAIL main-rebase has the same files as main-merge FAIL main-squash has the same files as main-merge 1 of 7 checks pass
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check.py
First hint: for main-rebase, make a new branch at feature (git switch -c feature-rebased feature), rebase that one
onto main-rebase, then fast-forward main-rebase to it with git merge --ff-only.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
What history does each of GitHub’s three merge buttons create? “Create a merge commit” merges with --no-ff: the
branch’s commits arrive unchanged, with their IDs, and a new merge commit with two parents joins them to the base
branch. “Rebase and merge” copies each commit of the branch onto the base branch as a new commit, with new IDs and new
committer details, and adds no merge commit, so the history stays linear. “Squash and merge” combines all of the
branch’s changes into one new commit on the base branch; the individual commits do not reach it, so the branch does
not count as merged in Git afterwards and should be deleted rather than reused.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Senior to staff
Your 30-person team argues about squash merges against merge commits. Propose a convention, explain what it costs for bisect, revert, blame and release notes, and say how you would enforce it.
-
Mid to senior
A regression appeared between two releases. Main uses merge commits, and git bisect keeps stopping on commits inside feature branches that do not even build. How do you find the pull request that caused it quickly, then the exact commit?
Key takeaways
- A merge commit keeps the branch’s commits unchanged and records the join; rebase and merge copies them into a line; squash and merge turns them into one commit.
- Undoing a feature takes
git revert -m 1 <merge>, a range of reverts, or one revert of the squash commit. - Bisect, blame and release notes each favour a different strategy; decide by which of them your team relies on.
- After a squash merge, delete the branch and start again from main: its commits never reached main.
git range-diffshows what changed between two versions of a rebased series.- Write the rule down, then enforce it with the repository’s merge-method settings, required linear history or a merge queue.
References
- About merge methods on GitHub (GitHub)
- About protected branches (require linear history, require merge queue) (GitHub)
- Configuring commit merging for pull requests (GitHub)
- Configuring commit squashing for pull requests (GitHub)
- Configuring commit rebasing for pull requests (GitHub)
- Managing a merge queue (GitHub)
- git-range-diff documentation (The Git project)
- git-merge documentation (--no-ff, --ff-only, --squash) (The Git project)
- git-bisect documentation (--first-parent) (The Git project)
- git-revert documentation (-m and ranges) (The Git project)
- gitworkflows documentation (The Git project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress