You will practice short-lived feature branches and compare merge versus rebase to keep history readable. By the end you will choose a branching workflow that keeps main always releasable.

Branching strategy sounds abstract until you are the one untangling a three-week-old branch that no longer merges. This lab makes the trade-offs concrete: you will build a small demo repository, run the same change through both a merge-based and a rebase-based integration, read the resulting history graphs side by side, and then write down a workflow policy your team could actually follow. Every command runs locally in a throwaway repo, so you can experiment freely and delete it when you are done.

Learning Objectives

  • Compare GitHub Flow, trunk-based, and GitFlow trade-offs.
  • Integrate work with merge and with rebase.
  • Keep main releasable with short-lived branches.
  • Sync a feature branch with a moving main without polluting history.
  • Recover safely from a bad rebase or an accidental branch deletion.
  • Time: ~3 hours · Difficulty: Intermediate · Prereqs: the Git fundamentals lab.

Architecture Overview

The core pattern behind every strategy in this lab is the same: main stays green and releasable, and work happens on a short-lived branch that comes back to main through one of two integration paths, a merge commit that preserves the branch in the graph, or a rebase that replays the work as a straight line on top of main.

The strategy you pick decides how long that feature branch lives, how it gets reviewed, and what the graph looks like afterwards. The mechanics below are identical whether you adopt GitHub Flow, trunk-based development, or GitFlow, only the branch names, lifetimes, and release discipline change.

How It Works

How It Works

A branch is a movable pointer to a commit, not a copy of the code. That is why creating and switching branches is instantaneous, and why strategies differ only in how long pointers live and how they rejoin main.

Merge joins two histories with a merge commit, preserving the true sequence of events. Rebase instead replays the branch's commits onto the new tip, producing a linear story that reads cleanly but rewrites hashes, which is why rebasing public history breaks collaborators.

The three strategies trade ceremony for safety: GitHub Flow (branch, PR, merge) suits continuous deployment, trunk-based development (tiny branches, fast merges) suits high-velocity teams, and GitFlow (develop/release/hotfix branches) suits scheduled releases. The lab's comparison section maps each to its natural habitat.

Comparing the Three Main Strategies

Before touching the keyboard, understand what you are choosing between. Each strategy is a different answer to the same question: how do we keep main releasable while several people change the code at once?

GitHub Flow is the simplest: one long-lived branch (main) plus short-lived feature branches. You branch, open a pull request, get reviewed, and merge. Anything on main is deployable. It works best for web apps and services that deploy continuously, because there is no separate "release" line to maintain.

Trunk-based development pushes the same idea further: branches live for hours, not days, and some teams commit straight to main behind feature flags. The payoff is near-zero merge pain and constant integration; the cost is that you need strong automated tests and discipline, because there is no long review window catching mistakes.

GitFlow adds structure: a develop branch where features collect, release branches for stabilization, and hotfix branches for production emergencies. It suits software with versioned releases (mobile apps, shipped binaries, on-prem products) where "what is in production" and "what is in development" are genuinely different things. For most continuous-deploy web teams it is more ceremony than value.

Which one should you pick? A quick decision guide:

  • Deploying a web service many times a day? GitHub Flow or trunk-based, the fewer long-lived branches, the better.
  • Small team with solid automated tests? Trunk-based, integrate daily, hide unfinished work behind feature flags.
  • Versioned releases that customers install and you must patch later? GitFlow, you need release and hotfix lines.
  • Unsure? Start with GitHub Flow. It is the least process that still keeps main releasable, and you can grow into the others.

In this lab you will practice the shared mechanical core of all three: branch, integrate with merge, integrate with rebase, and keep the integration surface small.

Environment Setup

Prerequisites

  • Git 2.4x and a repository with a couple of branches
  • Comfort with commit, branch, and merge basics (see Git Fundamentals)
  • The golden rule memorized: never rebase commits others have pulled
  • Assumed skill: reading git log --graph output

You will need: Git and a repo with a couple of branches.

Before you begin: never rebase commits that others have already pulled.

That rule deserves emphasis because it is the one that actually hurts people. A rebase rewrites commit hashes; if a teammate has based work on the old hashes, you have just forked their reality. Rebase is for cleaning up your local, un-pushed commits. Once a branch is shared, prefer merge, or coordinate explicitly before rewriting it.

Confirm your Git version and scaffold a sandbox repo for the exercises. Git 2.23+ is required for git switch; anything in the 2.4x series is fine.

CHECK GIT AND CREATE THE SANDBOX

git --version
mkdir -p ~/labs/branching-demo && cd ~/labs/branching-demo
git init -b main
$ git --version
git version 2.46.0
$ git init -b main
Initialized empty Git repository in /Users/taki/labs/branching-demo/.git/

Set a local identity (scoped to this repo only, so it cannot leak into your other projects) and create an initial commit on main so there is something to branch from. The demo project is a tiny fake API, two files are enough to make merges and conflicts realistic.

SEED THE REPO WITH AN INITIAL COMMIT

git config user.name "Taki Sadik"
git config user.email "[email protected]"
printf '# branching-demo\n' > README.md
printf 'def health():\n    return {"status": "ok"}\n' > api.py
git add README.md api.py
git commit -m "chore: initial commit"
$ git commit -m "chore: initial commit"
[main (root-commit) a1b2c3d] chore: initial commit
2 files changed, 3 insertions(+)
create mode 100644 README.md
create mode 100644 api.py

Verify: git log --oneline shows exactly one commit, and git status reports a clean tree on branch main. If git switch errors later, your Git is older than 2.23, use git checkout -b instead.

Finally, create a local "remote" so the push, fetch, and force-with-lease commands later in the lab behave exactly as they would against GitHub. A bare repository in a sibling directory is a perfect stand-in, it speaks the real Git protocol and costs nothing.

CREATE A LOCAL BARE REMOTE AND PUSH MAIN

git init --bare ~/labs/branching-demo-remote.git
git remote add origin ~/labs/branching-demo-remote.git
git push -u origin main
$ git push -u origin main
To /Users/taki/labs/branching-demo-remote.git
* [new branch] main -> main
branch 'main' set up to track 'origin/main'.

How to verify: git remote -v lists the bare repo for fetch and push, and git branch -vv shows [origin/main] next to main. To simulate a teammate pushing, clone the bare repo to a third directory, commit, and push from there, your sandbox then has genuinely divergent history to reconcile.

Step-by-Step Execution

01
Create a short-lived feature branch

What: branch off main and commit your feature work there. Why: isolating work keeps main releasable at every commit, and keeping the branch short-lived (hours to a couple of days, not weeks) is the single biggest factor in painless integration. The longer a branch lives, the more main moves under it and the worse the eventual merge becomes. Name the branch after the change, feature/api here, so its purpose is obvious in listings and pull requests.

git switch -c feature/api && git commit -am "add endpoint"

In the sandbox, make a real change so the commit has content: add a users() function to api.py, then commit. Two or three small commits on the branch will make the graphs in step 04 much more instructive than one giant commit.

MAKE TWO SMALL COMMITS ON THE BRANCH

git switch -c feature/api
printf '\ndef users():\n    return ["taki", "sam"]\n' >> api.py
git commit -am "feat: add users endpoint"
printf '\ndef version():\n    return "0.1.0"\n' >> api.py
git commit -am "feat: add version endpoint"
$ git switch -c feature/api
Switched to a new branch 'feature/api'
$ git log --oneline
c7d8e9f (HEAD -> feature/api) feat: add version endpoint
b4c5d6e feat: add users endpoint
a1b2c3d (main) chore: initial commit

Common mistake: doing the work on main and branching afterwards. If that happens, the fix is easy because branches are just pointers: git branch feature/api to mark the current commit, then git switch main and git reset --hard origin/main (or the earlier hash) to move main back. Verify with git log --oneline --graph --all that the commits sit on the feature branch, not main.

Naming note: pick a convention and stick to it, feature/<topic>, fix/<issue-id>, and chore/<task> cover nearly everything. Consistent prefixes let you filter branches with git branch --list "feature/*" and let CI or automation apply rules per branch type.

What this teaches: Branches are cheap pointers, so creating one per change is nearly free; that cheapness is what makes short-lived branches the default unit of work.

02
Integrate with merge (preserves history)

What: switch back to main and merge the feature branch with --no-ff (no fast-forward). Why: without that flag, when main has not moved since you branched, Git simply slides the main pointer forward, a "fast-forward", and the fact that a branch ever existed is erased from the graph. Forcing a merge commit keeps a visible record that this work arrived as one reviewed unit, which is exactly what teams using GitHub Flow want when they look back at history or need to revert a whole feature with a single git revert -m 1.

git switch main && git merge --no-ff feature/api

Git opens an editor for the merge commit message; the default Merge branch 'feature/api' is fine, or write something more descriptive like Merge branch 'feature/api': users and version endpoints. Save and close to complete the merge.

$ git switch main && git merge --no-ff feature/api
Switched to branch 'main'
Merge made by the 'ort' strategy.
api.py | 6 ++++++
1 file changed, 6 insertions(+)

How to verify: git log --oneline --graph shows a branch-and-join shape: the merge commit on main with two parents, and the two feature commits on the side line. git show --no-patch --pretty=%P HEAD prints two parent hashes, confirming it is a true merge commit.

Common mistake: merging the wrong direction. It is always feature into main, done while standing on main. If you accidentally merged main into your feature branch instead, nothing is lost, the feature branch just contains main's commits, but do not confuse that with integration; main still does not have your work until you merge the other way.

What this teaches: Merge preserves the full story: the feature branch stays visible in history, which helps future archaeology.

03
Or rebase for a linear history

What: instead of merging, replay the feature commits on top of the latest main. Why: a rebase rewrites the branch so it looks as if you started from today's main all along, no merge commits, just a straight line. Teams that value a readable, bisect-friendly history (and trunk-based teams especially) prefer this. The trade-off is that the original commits are replaced by new copies with new hashes, which is why rebase is reserved for local, un-pushed work.

To see the contrast in the sandbox, first undo the merge commit from step 02, then give main an independent commit of its own. That second part matters: without a new commit on main, the feature branch would already sit on main's tip and git rebase main would just report "up to date" without replaying anything, and the new-hashes check below would fail.

UNDO THE MERGE, ADVANCE MAIN, THEN REBASE

git switch main
git reset --hard HEAD~1          # undo the step-02 merge; safe, nothing pushed
printf '# API notes\n' > docs.md
git add docs.md && git commit -m "docs: add API notes"
git switch feature/api
git rebase main
$ git rebase main
Successfully rebased and updated refs/heads/feature/api.
$ git log --oneline
e8f9a0b (HEAD -> feature/api) feat: add version endpoint
f0a1b2c feat: add users endpoint
d4e5f6a (main) docs: add API notes
a1b2c3d chore: initial commit

FINISH THE INTEGRATION AFTER A REBASE

git switch main
git merge --ff-only feature/api

The --ff-only flag is the key detail: after a rebase, main can simply fast-forward to the replayed commits, and the flag makes Git refuse to do anything else, so if the rebase was incomplete, you get an error instead of an accidental merge commit.

$ git merge --ff-only feature/api
Updating d4e5f6a..e8f9a0b
Fast-forward
api.py | 6 ++++++
1 file changed, 6 insertions(+)

How to verify: git log --oneline is a straight line with no merge commit, and the two feature commits have different hashes than before the rebase, proof they were replayed. If you want your old hashes back, git reflog lists every previous position of HEAD, and git reset --hard HEAD@{1} can undo the whole rebase.

What this teaches: Rebase replays commits onto a new base for a clean line, but only on private branches nobody else has pulled.

04
Read both histories side by side

What: inspect the graph each strategy produced. Why: the difference between merge and rebase is easiest to internalize visually. Run the comparison twice, once after a --no-ff merge, once after a rebase, and the shapes make the trade-off obvious: preserved context versus a clean line.

AFTER A MERGE: BRANCH-AND-JOIN GRAPH

$ git log --oneline --graph --all
*   d2e3f4a (HEAD -> main) Merge branch 'feature/api'
|\
| * c7d8e9f (feature/api) feat: add version endpoint
| * b4c5d6e feat: add users endpoint
|/
* a1b2c3d chore: initial commit

AFTER A REBASE: STRAIGHT LINE

$ git log --oneline --graph --all
* e8f9a0b (HEAD -> main, feature/api) feat: add version endpoint
* f0a1b2c feat: add users endpoint
* d4e5f6a docs: add API notes
* a1b2c3d chore: initial commit

How to interpret: in the merge graph you can still see that two commits arrived together as a unit; in the rebase graph you cannot, but every commit sits directly on main, which makes git bisect and blame archaeology simpler. Neither is "correct", the team picks one and applies it consistently.

For a mechanical check that goes beyond reading the graph, inspect the parent count of the tip commit. A merge commit has two parents; a rebased tip has exactly one:

COUNT PARENTS OF THE TIP COMMIT

git show --no-patch --pretty=%P HEAD | wc -w   # 2 = merge commit, 1 = linear

This one-liner is handy in scripts and CI, for example, a check that rejects direct commits to main can assert that every commit landing on main is a two-parent merge from a reviewed pull request.

What this teaches: Comparing git log --graph output teaches you to read history shape, the skill behind every merge vs rebase decision.

05
Sync a feature branch with a moving main

What: practice the routine you will use daily: while your branch is open, main gains commits, and you need to incorporate them before integrating. Why: integrating a stale branch is where conflicts and broken builds come from. Syncing early and often keeps the final integration trivial, this is the entire philosophy behind short-lived branches and trunk-based development.

Simulate it: cut a fresh feature branch with one commit, feature/api was fully merged in step 03, so rebasing it again would be a no-op. Then, while the new branch is open, main gains a commit, a CI config file. Rebase the branch onto the updated main to incorporate it.

MOVE MAIN FORWARD, THEN SYNC THE FEATURE

git switch -c feature/auth main
printf 'token expiry: 15m\n' > auth.md
git add auth.md && git commit -m "feat: draft auth notes"
git switch main
printf 'name: ci\non: [push]\n' > ci.yml
git add ci.yml && git commit -m "ci: add workflow stub"
git switch feature/auth
git rebase main
$ git rebase main
Successfully rebased and updated refs/heads/feature/auth.
$ git log --oneline
9c0d1e2 (HEAD -> feature/auth) feat: draft auth notes
7a8b9c0 (main) ci: add workflow stub
e8f9a0b feat: add version endpoint
f0a1b2c feat: add users endpoint
d4e5f6a docs: add API notes
a1b2c3d chore: initial commit

Against a real remote the same routine is one command: git pull --rebase origin main while on your feature branch. Set git config --global pull.rebase true if you want that to be the default everywhere.

Common mistake: syncing with git merge main into the feature branch repeatedly. It works, but each sync adds a merge commit, and after a week the branch history is half merge noise. For a branch only you work on, rebase keeps it clean; reserve merge-syncs for shared branches.

What this teaches: Rebasing onto an updated main keeps the branch current without a merge commit; conflicts get resolved once, at rebase time.

06
Handle a conflict during integration

What: deliberately create overlapping edits and walk through the resolution. Why: the first time you see CONFLICT during a rebase it looks like an error; it is actually Git pausing to ask you a question. Knowing the rhythm, edit, stage, continue, removes the fear. (The Merge Conflict Resolution lab goes deeper; here you only need the workflow in context.)

Trigger it: on main, change the health() return value to {"status": "healthy"} and commit; on a fresh feature branch off the old point, change the same line to {"status": "up"}. Rebasing the feature onto main now conflicts.

$ git rebase main
Auto-merging api.py
CONFLICT (content): Merge conflict in api.py
error: could not apply 8b9c0d1... feat: add users endpoint

Open api.py, find the conflict markers, keep the lines you want, and delete the markers. Then stage and continue:

THE CONFLICT MARKERS GIT WRITES

<<<<<<< HEAD
    return {"status": "healthy"}
=======
    return {"status": "up"}
>>>>>>> 8b9c0d1 (feat: add users endpoint)

RESOLVE, STAGE, CONTINUE

# edit api.py, then:
git add api.py
git rebase --continue

How to verify: git status during the pause says interactive rebase in progress and lists unmerged paths; afterwards it says clean, and git log --oneline shows the replayed commit on top of main. If the rebase goes sideways, git rebase --abort returns the branch to exactly where it was before you started, it is always safe to bail out and retry.

What this teaches: Conflicts are just two edits to the same lines; reading the markers and choosing deliberately is the whole skill.

07
Clean up merged branches

What: delete feature branches once integrated, locally and on the remote. Why: stale branches are clutter that hides the branches that actually matter, and a merged branch has no unique information, its commits are reachable from main, so deleting it loses nothing. Keeping the branch list short is part of keeping main the single source of truth.

DELETE MERGED BRANCHES LOCALLY AND REMOTELY

git switch main
git branch -d feature/api          # safe delete: refuses if unmerged
git branch -D feature/auth        # force: the step-05 sync exercise branch, never merged
git push origin --delete feature/api
git fetch --prune                  # drop stale remote-tracking refs
$ git branch -d feature/api
Deleted branch feature/api (was e8f9a0b).
$ git branch --merged main
* main

Note the lowercase -d: it is the safe delete that refuses when the branch contains unmerged work. The uppercase -D forces deletion, useful, but check first with git log main..feature/api, which lists commits the branch has that main does not. If that output is empty, nothing is lost.

Common mistake: deleting the wrong branch. Recovery is straightforward if you act soon: git reflog (or the hash in the "Deleted branch ... (was e8f9a0b)" message) gives you the tip commit, and git branch feature/api e8f9a0b recreates the branch exactly where it was.

What this teaches: Deleting merged branches keeps the branch list honest; the commits survive on main, so nothing is lost.

08
Write down the workflow your team chose

What: finish the lab by turning the decision into a short, explicit policy. Why: branching strategies fail by ambiguity, not by tooling, half the team rebasing and half merging produces the worst of both. A five-line policy in CONTRIBUTING.md (or the repo wiki) settles branch naming, integration method, and branch lifetime so nobody has to guess.

CONTRIBUTING.md WORKFLOW EXCERPT

## Branching workflow
- main is always releasable; all work happens on short-lived branches.
- Branch names: feature/<topic>, fix/<issue>. Lifetime: 2 days max.
- Sync daily with `git pull --rebase origin main`; never rebase pushed commits.
- Integrate via pull request with --no-ff merge; delete the branch after merge.
- main is protected: CI must pass and one review is required.

This particular policy is GitHub Flow with merge integration, a sensible default for a small web team. A trunk-based team would shorten lifetimes to hours and drop the merge commit; a GitFlow team would add develop and release branch rules. The point of the lab is not which policy you wrote, but that every line of it is something you have now done by hand.

How to verify: the policy is enforceable, not aspirational. GitHub branch protection rules (required reviews, required status checks) make the last line mechanical, the GitHub Actions lab covers wiring CI into that gate.

What this teaches: A branching strategy only works if it is written down and agreed; the document is the real deliverable of this lab.

Progress So Far

The diagram tracks the core integration skills; steps 04-08 build the daily habits, reading history, syncing, resolving, cleaning up, and documenting, on top of them.

Testing & Validation

git log --oneline --graph --all

A merge shows a branch-and-join graph; a rebase shows a straight line. Pick the one your team prefers and keep main green.

Validate the whole workflow end to end with four checks:

FINAL VALIDATION CHECKS

git log --oneline --graph --all   # history matches your chosen strategy
git status                        # clean tree, on main
git branch -vv                    # only main remains, tracking origin/main
git diff main@{1}..main --stat    # sanity: what the last integration changed

The strongest validation is behavioral: main should build and pass tests at every commit, not just the tip. Spot-check with git checkout <older-hash> and run the project tests, if an old commit on main is broken, your strategy is not keeping main releasable, no matter how pretty the graph looks. Return to the tip afterwards with git switch main.

Success criteria: you can explain, from the graph alone, which commits were merged and which were rebased; your branch list contains only main; and you could hand the CONTRIBUTING.md excerpt to a teammate and have them follow it without asking questions.

Teardown: when you are done experimenting, the sandbox is disposable, rm -rf ~/labs/branching-demo ~/labs/branching-demo-remote.git removes the repo and its local remote. Nothing in this lab touched any real repository.

Troubleshooting
  • Rebase rewrote shared history: only rebase local, un-pushed work; otherwise merge. If you already rebased a branch others pulled, coordinate the recovery: each collaborator saves their work on a backup branch (git branch backup-name), fetches, then resets or rebases only their own unique commits onto the rewritten branch. A bare git pull --rebase is not a safe shortcut here, it can silently drop or duplicate commits when both sides rewrote history. Never force-push a shared branch without warning the team first.
  • Long-lived branches diverge: keep branches short to avoid painful integrations. If you inherit a stale branch, do not merge it in one heroic session, rebase it onto main in small pieces (git rebase --onto main <old-base> feature/x) or split the work into several smaller branches.
  • Mid-rebase confusion: abort safely with git rebase --abort. The branch returns to its pre-rebase state, so experimenting is free. Use git status at any point to see whether a rebase is in progress and which step you are on.
  • Push rejected after a rebase: the remote still has your old commits, so a normal push is refused with non-fast-forward. Use git push --force-with-lease, which only overwrites the remote if nobody else pushed in the meantime, strictly safer than plain --force.
  • Pull created an unexpected merge commit: your local main and origin/main diverged and the default pull merged them. Set git config --global pull.rebase true (or pull.ff only) so pulls replay your local commits instead of weaving in merge noise.
  • Deleted a branch by mistake: find the tip hash in git reflog or in the "Deleted branch ... (was <hash>)" message, then recreate it with git branch <name> <hash>. Commits are not garbage-collected for weeks, so recovery is almost always possible.

Troubleshooting

SymptomLikely causeFix
rebase rewrote a teammate's commitsrebased a branch others had pulledstop, communicate, and have everyone reset to the rewritten branch; never rebase shared history again
merge commit for every tiny changelong-lived branches merged repeatedlykeep branches short-lived and merge once, or squash on merge
branch diverged after a rebasethe remote still has the pre-rebase commitspush with --force-with-lease (never plain --force) on your own private branch only
cannot tell which strategy a repo usesno documented workflowread recent merge commits and branch names, then write the strategy down
hotfix needed while a feature is half-mergedlong-lived branches block urgent workbranch hotfixes from main directly; another argument for short-lived branches

Extension Ideas

  • Gate merges with reviews in Pull Requests.
  • Resolve conflicts confidently in Merge Conflict Resolution.
  • Protect main with required CI in GitHub Actions.
  • Try trunk-based development for a week: branches under a day, integrate behind feature flags, and measure how merge pain changes.
  • Practice an interactive rebase (git rebase -i) to squash typo commits before opening a pull request.

Practice Challenges

Challenge 1: Strategy memo

Goal: Write a one-page branching policy for a 5-person team shipping weekly, choosing one strategy and justifying it.

Hint: Name the branch lifetime limit, the merge method, and who approves the PR.

Challenge 2: Rebase rescue

Goal: Deliberately rebase a shared test branch, then recover the team using reflog and --force-with-lease.

Hint: Do it in a sandbox remote; the recovery is the lesson, not the breakage.

Key Results

  • Integrated a feature with both merge and rebase.
  • Produced a readable history matching the chosen strategy.
  • Kept main releasable with short-lived branches.
  • Understood when rebase is safe versus dangerous.

Key Takeaways

  • Main is always releasable; all risk lives on short-lived branches.
  • Merge (--no-ff) preserves context; rebase gives a clean line. Pick one per team and be consistent.
  • Never rebase commits others have pulled; rewrite only local, unpushed work.
  • Sync with main early and often; small integrations rarely conflict badly.
  • --force-with-lease after a rebase, --ff-only to finish one, git reflog when anything goes wrong.
  • A written workflow policy plus branch protection beats good intentions.