Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 25 minutes
  • Examples run with git 2.54.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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”.

The same branch, integrated three ways Python · three_ways.py
# 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

Three histories of main: a merge commit joining the three branch commits; three copied commits in a line; one squash commit.with-merge: a merge commitThe other two copies, drawn down to 6f82a99540597d Merge branch 'feedback'6f82a99 Add thewarden's phoneab1604f Thank students6ac4627 Store feedback16f33c5 Add the formd8d8895 Start the help deskwith-rebase:copies in a linewith-squash:one new commitc559b74 Thank students6d30041 Store feedback4bc160c Add the form6f82a99 Add thewarden's phonecb2b48c Collectstudent feedback (#7)6f82a99 Add thewarden's phoneparent 1parent 2

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:

  1. 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.
  2. The merge commit 540597d, "Merge branch 'feedback'", has two parents: 6f82a99 (parent 1, main) and ab1604f (parent 2, the branch).

with-rebase:

  1. The three changes are new commits in one line on top of 6f82a99: 4bc160c, 6d30041 and c559b74, with the same subjects as the originals.
  2. There is no merge commit.

with-squash:

  1. One new commit, cb2b48c, "Collect student feedback (#7)", holds all three changes and sits directly on 6f82a99.
  2. 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 --merged lists 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 --merged does not list feedback and git branch -d refuses to delete it; once you have checked the pull request was merged, -D deletes 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:

copies.py and gitsession.py, imported by the sessions

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:

Reverting the whole feature in each copy Python · revert_feature.py
# 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.

The three strategies compared
Criterion Merge commitRebase and mergeSquash and merge
History Branch commits plus a merge commitCopies of the commits, in a lineOne commit per pull request
Commit IDs UnchangedNewOne new commit
Undo git revert -m 1 <merge>Revert a range, one per commitgit revert <commit>
Bisect Use --first-parentEvery commit must buildStops at the pull request
Blame The small commitThe small commitThe squash commit
Conflicts OnceOnce per commitOnce
Branch after MergedMerged if rebased locallyNot merged: delete it
When to choose Features undone as units, release branches, auditsCurated commits, linear historyNoisy 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:

Comparing version 1 and version 2 of a pull request Python · range_diff.py
# 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:

A sample convention for a team that squashes Markdown · CONTRIBUTING.md
## 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:

  1. main-merge: a merge commit whose parents are main’s tip and feature.
  2. main-rebase: the three commits copied onto main-rebase in a straight line, without changing feature itself.
  3. main-squash: exactly one new commit holding all of feature’s changes.
setup.py, which creates the practice repository Python · exercise/setup.py
# 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:

The sample checks, run on the practice repository as set up Python · exercise/check.py
# 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.

  1. Question 1 of 6 A team of four ships a web app several times a day. Pull requests are small, and their commits are mostly "wip" and "address review". Which strategy suits them best?

    Choose one answer.

    Show the answer to question 1

    Answer: Squash and merge

    The inner commits are noise, so keeping them adds nothing. One commit per pull request keeps main readable, makes each change one revert and still lets bisect find the pull request that broke something.

  2. Question 2 of 6 An open-source library expects contributors to shape their pull requests into a few commits that each build and explain one step, and the maintainers want a linear history. Which strategy keeps that work?

    Choose one answer.

    Show the answer to question 2

    Answer: Rebase and merge

    Rebase and merge keeps each curated commit on main, in a straight line, so blame and bisect point at the small step that made a change. Squashing would throw that work away.

  3. Question 3 of 6 Release branches are stabilised for two weeks and then brought back into main. The team must be able to see, and if needed undo, everything one release brought in. How should release branches be integrated?

    Choose one answer.

    Show the answer to question 3

    Answer: With a merge commit

    A merge commit marks the whole release as one point in history: git log --first-parent shows it, and git revert -m 1 undoes it in one command. Rebasing a branch that several people used would rewrite their commits.

  4. Question 4 of 6 Three developers push to the same long-running feature branch every day. Which way of bringing main's new commits into that branch avoids rewriting their work?

    Choose one answer.

    Show the answer to question 4

    Answer: Merge main into the branch

    Rebasing or squashing a branch that others have built on replaces commits they already have, and each of them would have to repair their clone. A merge only adds a commit.

  5. Question 5 of 6 A payments team must show auditors that the exact commits reviewed in a pull request are the ones on main. Which strategy keeps those commits unchanged?

    Choose one answer.

    Show the answer to question 5

    Answer: Merge commits

    A merge commit keeps the branch's commits with the IDs that were reviewed. Rebase and merge and squash and merge create new commits, so the IDs on main differ from the ones in the review.

  6. Question 6 of 6 After a squash merge, a developer keeps committing to the same branch and opens a second pull request from it. What happens, and what should they have done?

    Choose one answer.

    Show the answer to question 6

    Answer: The old commits show up again because main only has their squashed copy; start new work from an up-to-date main

    main never received the branch's own commits, only one new commit with the same changes, so the second pull request lists the old commits again and may conflict. Delete a branch once it is squash-merged.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

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-diff shows 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.