AI EngineeringZero to ProductionHome·About·What’s new·Contact
Developer Foundations · Chapter DF3

Branching, PRs & collaboration

How teams ship: branches, conflicts, the PR workflow, reviewing well, automating PR quality checks in Python, branching strategies, and protecting main as a lead.

⏱️ ~3 hours🧪 8 labs🎯 Beginner→Tech-lead

Learning objectives

  • Branch, merge, and resolve conflicts confidently.
  • Run the full GitHub pull-request workflow.
  • Automate PR quality checks in Python.
  • Own a branching strategy and protected-branch policy.
▶ Runnable companionCode saved under code/df3-git-collab/. Python runs offline; configs are ready to use.

1 · Branches — parallel work essential

Picture the day this goes wrong. You and a teammate both edit the same shared copy of the code at the same time. You're halfway through a risky change — a function that doesn't even compile yet — when your teammate pulls the code to ship an urgent fix. They get your broken, half-finished work, the build goes red, and now two people are blocked on a mess neither of them meant to create. This is the exact disaster branches exist to prevent, and it's why every professional team lives on them.

Here's the everyday analogy: a branch is like taking a photocopy of a shared document to scribble on. The original — main — stays clean on the shelf for everyone else. You scribble, cross things out, try three versions, make a total mess of your copy — and none of it touches the original until you walk over and say "okay, fold my changes in." The team never sees your rough drafts, only the finished edit.

Technically, a branch is astonishingly cheap: it's not a copy of your files at all, just a movable pointer to a commit (a single 40-character line in a hidden file). That's why branching in Git is instant and why the culture is "branch for everything, even a one-line fix" — you're not duplicating the project, you're just marking a new line of history. When you commit on a branch, the pointer slides forward to your new commit; main's pointer doesn't move. That separation is the whole game.

The mistake almost everyone makes early: committing straight to main "because it's just a small change." It feels faster, and it is — right up until that small change breaks the build for the whole team, or you need to ship something else urgently and now there's unfinished work sitting on the branch everyone deploys from. The discipline is simple: main is sacred and always shippable; your work-in-progress lives on a branch and merges back only when it's genuinely done. Learn it as a reflex now, on a solo repo, so it's automatic the day you're on a team of twenty.

A branch is an independent line of commits. Build each feature on its own branch so main stays stable and shippable; merge when ready.

main stable feature branch isolated commits your work merge → main integrated
🗺️ How to read this diagram

This is the whole idea of team collaboration with Git in one line. main is the shared, always-working copy of the code. Instead of editing it directly (and risking breaking everyone else), you make a private copy called a branch, do your work there, then fold it back in when it's ready.

  • The first box, main ("stable"), is the official version of the project that the whole team shares. It should always work.
  • The arrow to the second box, "feature branch" ("isolated"), is you running git switch -c feature/…. A branch is your own separate line of work — changes here do not touch main or anyone else until you decide to merge.
  • The third box, "commits" ("your work"), is you saving snapshots as you go (each git commit). You can make as many as you like, safely, on your branch.
  • The last arrow into "merge → main" ("integrated") is git merge: Git combines your finished work back into the shared main so the rest of the team gets it.

In short: Read it left to right as a round trip — you leave main to work in private, then come back and merge. main stays shippable the whole time because your half-done work lived on the branch, not on main.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
shell · branch → commit → merge
branch.shgit switch -c feature/login       # create + switch
echo "def login(): ..." > auth.py
git add auth.py && git commit -m "feat: add login"
git switch main
git merge feature/login           # fast-forward or merge commit
git branch -d feature/login       # delete the merged branch
▶ How this works

These six commands are the complete life of a feature branch: create it, do work on it, and fold it back into main. This is the single most common thing you'll do as a developer.

  1. git switch -c feature/login creates a new branch called feature/login and switches you onto it in one step (-c means "create"). From now on your work lands here, not on main.
  2. The echo … > auth.py line just makes a file to have something to commit. Then git add auth.py stages it and git commit -m "feat: add login" saves a snapshot with a short message describing the change.
  3. git switch main moves you back onto the shared branch. Your feature work is still safely stored on feature/login — switching branches just changes which version of the files you see.
  4. git merge feature/login brings your branch's commits into main. Finally git branch -d feature/login deletes the now-merged branch to keep things tidy (-d = delete, and Git only allows it because the work is safely merged).

What the output means: Nothing dramatic prints, but afterwards main contains your login work and the temporary branch is gone. The comment "fast-forward or merge commit" just names the two ways Git can join the histories.

Try this: Run git branch at each step to watch which branch you're on (the * marks it). Try git log --oneline after the merge to see your commit now sitting on main.

2 · Merge conflicts — routine, not scary essential

The first merge conflict you hit will make your stomach drop. You run git merge, expecting it to just work, and instead Git spits out CONFLICT and your file is suddenly full of angry arrows — <<<<<<<, =======, >>>>>>> — that look like the tool has corrupted your code. Almost every beginner's instinct at this moment is panic: "Did I break the repo? Did I lose my work?" You didn't. Nothing is lost, nothing is broken. This section exists to turn that stomach-drop into a shrug.

Here's the honest truth about what a conflict is: Git is a brilliant clerk but a terrible editor. When two branches change different parts of a file, Git happily stitches them together for you — that's a normal, silent merge, and it handles the overwhelming majority of cases. But when two branches change the very same lines, Git refuses to guess. It has no idea whether your teammate's version or yours is "correct," and it would rather stop and ask a human than silently pick wrong and ship a bug. A conflict isn't Git failing — it's Git being careful.

The analogy that makes it click: imagine you and a co-author both edit the same sentence in a shared essay. Your editor can't merge "The price is $10" and "The price is $12" into one true sentence — only a person who knows the actual price can. So it shows you both versions, side by side, wrapped in markers, and waits. That's exactly what those arrows are: <<<<<<< HEAD down to ======= is your side, and ======= down to >>>>>>> is the incoming side. Your job is to write the one correct sentence and delete all the scaffolding.

The classic mistake is leaving a marker behind — deleting the version you didn't want but forgetting to remove the ======= line, then committing. Now those seven equals-signs are sitting in your source code, and the file won't run. So the rule is: when you're done, the file must look like ordinary code with no arrows anywhere. And if you ever get lost mid-fix, git merge --abort rewinds everything to before the merge — a completely safe undo button. Knowing that escape hatch exists is what lets you approach conflicts calmly instead of fearfully.

When two branches change the same lines, Git can't auto-merge and marks a conflict. You just pick the correct final result.

the conflicted file — Git wrote both sides in <<<<<<< HEAD PRICE = 10 · your side (main) ======= PRICE = 12 · incoming (feature) >>>>>>> feature/pricing you decide + delete all 3 markers PRICE = 12 clean code · then git add + git commit A conflict is a question, not a break. Git stacks both candidate lines between three markers and waits; you keep the one true line, delete every marker, and the file is ordinary code again.
🗺️ How to read this diagram

Read it top to bottom: the boxed block at the top is exactly what Git wrote into your file when the merge stopped; the single green box at the bottom is what it should look like after you fix it.

  • The three grey marker lines — <<<<<<< HEAD, =======, and >>>>>>> feature/pricing — are scaffolding Git inserts, not real code.
  • The green band between HEAD and ======= is your current side (PRICE = 10 from main); the amber band between ======= and >>>>>>> is the incoming side (PRICE = 12).
  • The down arrow is the human decision: pick the correct value and delete all three marker lines. The lone green box at the bottom is the payoff — plain code, no arrows — ready to git add and git commit.

In short: Two versions wrapped in markers → you choose one and strip the scaffolding → the file must read like normal code before you finish the merge.

shell · resolve a conflict
conflict.shgit merge feature/pricing
# CONFLICT in config.py — Git inserts markers:
# <<<<<<< HEAD
# PRICE = 10          (main)
# =======
# PRICE = 12          (feature/pricing)
# >>>>>>> feature/pricing
# edit to the correct value, delete the markers, then:
git add config.py && git commit
▶ How this works

A merge conflict happens when two branches changed the same lines of the same file. Git can't guess which version is right, so it stops and asks you to choose. This is routine — not an error you broke something with.

  1. git merge feature/pricing tries to combine that branch into your current one. Because both branches edited the price in config.py, Git reports a CONFLICT and pauses the merge.
  2. Git edits the file for you and inserts conflict markers. Everything between <<<<<<< HEAD and ======= is your current side (here PRICE = 10 from main); everything between ======= and >>>>>>> feature/pricing is the incoming side (PRICE = 12).
  3. You open the file, decide the correct final value, and delete all three marker lines plus the version you don't want. The file should read like normal code again when you're done.
  4. git add config.py tells Git "this conflict is resolved," and git commit finishes the paused merge.

What the output means: After the commit, the merge is complete and config.py holds exactly the value you chose. The conflict markers only ever exist between the failed merge and your fix.

Try this: If you get confused mid-conflict, git merge --abort puts everything back the way it was so you can start over. Nothing is lost.

Pull often to keep conflicts tinyConflicts scale with how long a branch lives and how far it drifts from main. Short-lived branches + frequent git pull of main = small, trivial conflicts.

3 · The pull-request workflow intermediate

Think about the last time someone pushed a change nobody looked at. Maybe it was you, at 6pm on a Friday: a "tiny, obviously-safe" tweak straight to main, no review, no second pair of eyes. Then it turned out the tweak wasn't obviously safe, and the whole team spent Monday morning untangling it. The pull request — the PR — is the ritual that makes "nobody looked at it" impossible. It's the single most important workflow in professional software, and it's the reason two engineers who've never spoken can safely ship into the same codebase.

So what is a pull request, really? The name confuses everyone at first — you're not pulling anything. A PR is a formal proposal: "I have some commits on my branch; I'm requesting that you review them and pull them into main." It's a doorway with a guard. Your branch sits outside main, visible to the whole team, and it can't get in until the checks pass and a human says yes. That's the entire idea — a review gate between your work and the shared code.

The analogy that lands: a PR is like submitting a pull request to merge into a shared essay, with track-changes on. Reviewers see exactly what lines you added and removed, they leave comments in the margin ("why 30 seconds and not 60?"), and you respond by pushing more commits — which don't open a new proposal, they update the same one in place. This is the detail beginners miss most often: a PR is a living conversation attached to a branch, not a one-shot snapshot. Push a fix, and the PR quietly updates; the reviewer just refreshes and sees your latest.

The common early mistake is treating the push and the PR as the same act — thinking git push "sends it for review." It doesn't. git push only uploads your branch to the shared server (origin); the PR is a separate step you open in the browser (or with gh pr create) that points at that pushed branch. Get that mental split right and the whole flow below reads cleanly: you push code to origin, then you open a request to merge it. Everything after — CI running tests, reviewers commenting, you addressing feedback — hangs off that one open request until it's approved and merged.

The professional PR flow

  1. Branch off up-to-date main.
  2. Commit; git push -u origin feature/x.
  3. Open a PR; describe what changed and why.
  4. CI runs tests+scans (TQ/CD); reviewers comment.
  5. Address feedback, get approval, squash-merge, delete the branch.
your machine git push branch → origin the review gate (PR) open PR proposes merge CI: tests + scans reviewer comments merge → main green + approved both must pass before the gate opens The PR is a gate between your branch and main. git push only uploads the branch; the PR is a separate step, and it can't merge until CI is green and a human approves.
🗺️ How to read this diagram

Follow the arrows left to right — a change travels from your laptop, through a gate, into the shared main.

  • The first box, "git push", uploads your branch to origin (the shared server). This alone does not request review — it just makes the branch visible.
  • The second box, "open PR", is the separate step that proposes merging your branch. This is the doorway with a guard.
  • The two amber boxes are the gate's conditions: automated CI (tests and scans) and a human reviewer. Both must be satisfied.
  • Only then does the green "merge → main" box happen — your work joins the shared code.

In short: push ≠ review. You push to origin, then open a PR; nothing reaches main until CI is green and someone approves.

shell · the commands around a PR
pr.shgit switch main && git pull        # start from latest
git switch -c feature/search
# ...work, commit...
git push -u origin feature/search  # then open the PR on github.com
# after merge:
git switch main && git pull && git branch -d feature/search
▶ How this works

A pull request (PR) is how teams merge on GitHub instead of merging locally. You push your branch to the shared server ("origin"), then open a PR asking teammates to review it before it goes into main. These commands are the local half of that flow.

  1. git switch main && git pull starts you from the latest shared code. git pull downloads everyone else's recent changes so your new branch isn't built on a stale version. (&& just means "run the next command only if the first succeeded".)
  2. git switch -c feature/search creates your feature branch, then you work and commit as usual.
  3. git push -u origin feature/search uploads your branch to origin (the shared GitHub copy). -u links your local branch to the remote one so future push/pull know where to go. Now you open the PR in your browser.
  4. After teammates approve and the PR is merged, the last line syncs your local main (git pull) and deletes the finished branch.

What the output means: No output to read here — the payoff is on github.com, where your pushed branch becomes a PR that others can review, comment on, and approve.

Try this: "origin" is just the nickname Git gives the shared server copy. Run git remote -v to see the actual URL it points to.

4 · Advanced — code review that helps advanced

We've all received the review that helped nothing. Twenty comments, nineteen of them "add a space here," "move this brace," "you used single quotes" — and not one word about the off-by-one error that would've paged someone at 3am. Or the opposite: a two-second "LGTM 👍" on a 600-line change that nobody actually read. Both feel like review. Neither is. Learning the difference — and which side of it your own comments land on — is what turns you from a developer who gets reviewed into one whose reviews people are grateful for.

Here's the reframe that changes everything: a review is not a gate you defend, it's a gift you give. The goal isn't to catch the author being wrong or to prove you read carefully — it's to make the code better and the author smarter. That single shift in intent changes your tone, what you comment on, and what you let slide. You stop scoring points and start helping ship.

The analogy: think of the difference between an editor and a spell-checker. A spell-checker flags every typo and cares nothing for whether the argument holds together — that's the reviewer drowning a PR in formatting nits. An editor reads for meaning: does this actually work, is the tricky path handled, will the next person understand it in six months? You want to be the editor. The spell-checking? Hand it to a machine — a formatter and a linter in CI catch style automatically, so no human ever has to type "fix indentation" again. That frees your entire attention for the things only a human can judge: correctness, clarity, and risk.

The most common mistake in review is vagueness. "This is wrong" or "I don't like this" gives the author nothing to act on — it just starts an argument. Compare "this loop is O(n²) on the request path; a set lookup makes it O(n) — want me to sketch it?" That comment is specific (names the exact problem), constructive (offers a fix), and kind (it's about the code, not the coder). And be honest about severity: a genuine blocker and a passing "nit:" suggestion should never carry the same weight, or reviewers become the boy who cried wolf. The table below contrasts the weak reflexes with the strong habits — read each row as a small choice you make on every single PR.

spell-checker weak review "fix formatting" ×20 "LGTM" rubber-stamp vague · "this is wrong" machine could do this editor strong review auto-format in CI traces the tricky path specific + suggests a fix only a human can do this Be the editor, not the spell-checker. Hand style to a machine (red, left); spend your scarce human attention on correctness, clarity, and risk (green, right).
🗺️ How to read this diagram

Two stacks of comments, side by side, split by the dashed line — the same PR reviewed two different ways.

  • The left red stack ("weak review") chases mechanical trivia: formatting nits, a lazy "LGTM", vague "this is wrong". The label underneath is the giveaway — a machine could do this.
  • The right green stack ("strong review") pushes the machine work into CI (auto-format) and spends human effort on the tricky path and specific, fixable suggestions.
  • The dashed divider is the whole point: the value isn't in catching typos, it's in the judgment only a person can offer — labelled only a human can do this.

In short: Let a linter/formatter handle style; reserve your review for correctness, clarity, and risk — the things no tool can judge.

Weak reviewStrong review
"fix formatting" (× 20)auto-format in CI; review logic
"looks good" rubber-stampactually traces the tricky path
vague ("this is wrong")specific + suggests a fix
nitpick tonekind, focused on the code

5 · Advanced — automate PR quality advanced

Imagine being the person who has to say, on every single PR, "please rename your branch," "please write a real commit message," "this is too big to review — split it." You'd be exhausted, resented, and inconsistent — you'd enforce the rule when you had energy and wave it through when you didn't. Worse, the rules would live only in your head, so the day you're on holiday, standards quietly slip. Any policy that depends on a tired human remembering to check it is a policy that will be skipped. The fix is to hand the boring, mechanical checks to a machine.

The principle here is one of the most valuable in all of engineering: if a rule can be checked by a program, a human should never check it by hand. Machines don't get tired, don't play favourites, and apply the rule identically at 9am Monday and 6pm Friday. A person's judgment is precious and scarce — spend it on "is this design right?", not on "does this branch name match the pattern?" Automating the mechanical frees the human for the meaningful.

The analogy: think of the metal detector at an airport. No security officer frisks every passenger for keys and coins — the detector does the tedious, uniform screening so the humans can focus their attention on the genuinely suspicious. An automated PR check is that detector for your codebase: it runs on every pull request, flags the obvious violations (bad branch name, sloppy commit message, a diff so huge nobody can review it honestly), and lets your reviewers spend their energy on logic and risk. This is exactly the kind of job a CI — continuous integration — system runs automatically the moment a PR opens.

The trap to avoid is over-automating: don't try to make a script judge whether the code is good — it can't, and you'll just produce noisy false alarms that people learn to ignore. Automate only the objective, mechanical rules where "pass/fail" is unambiguous. The lab below is a tiny but real example: a pure-Python function that takes a PR's branch name, its commit messages, and its size, and returns a clean yes/no plus a list of exactly what's wrong. Read it as a template — this is precisely the shape of check a real CI job invokes on every PR, no human required.

PR facts (input) branch name commit messages files changed pr_mergeable() mechanical rules True · merge allowed False · list of problems bad name · bad commit · too big objective pass/fail only — never "is the code good?" Automate the mechanical, not the judgment. Objective facts about a PR go in; a clean yes, or an exact list of what's wrong, comes out — the shape a CI job runs on every PR.
🗺️ How to read this diagram

Read left to right: raw facts about the PR flow into one function, which branches to one of two outcomes.

  • The three left boxes are the inputs the check receives: the branch name, the commit messages, and how many files changed — all objective facts, no opinions.
  • The middle box, pr_mergeable(), applies only mechanical rules (name pattern, conventional-commit prefix, size limit).
  • The green branch is the happy path — True, merge allowed. The red branch is False plus an exact list of what's wrong, so the author knows precisely what to fix.
  • The footer is the guardrail: the check judges only unambiguous pass/fail rules, never the subjective "is this good code?" — that stays with the human reviewer.

In short: Objective PR facts in → pass, or a precise problem list out. Machines police the mechanical rules so humans review logic and risk.

Python · validate a PR before merge (runs)
pr_check.pyimport re
CONV = re.compile(r"^(feat|fix|docs|refactor|test|chore)(\(.+\))?: ")

def pr_mergeable(branch, commits, files_changed):
    problems = []
    if not re.match(r"^(feature|fix|chore)/[a-z0-9-]+$", branch):
        problems.append(f"bad branch name: {branch}")
    for c in commits:
        if not CONV.match(c): problems.append(f"non-conventional commit: {c!r}")
    if files_changed > 400:
        problems.append(f"PR too large ({files_changed} files) — split it")
    return (not problems), problems

ok, _ = pr_mergeable("feature/login", ["feat: add login", "test: cover login"], 12)
print("mergeable:", ok)
ok2, why = pr_mergeable("mystuff", ["did things"], 900)
print("mergeable:", ok2, "|", why)
mergeable: True
mergeable: False | ['bad branch name: mystuff', "non-conventional commit: 'did things'", 'PR too large (900 files) — split it']
▶ How this works

This is a small Python program that acts like an automated reviewer: given a PR's branch name, its commits, and how many files it touches, it decides whether the PR follows the team's rules. A CI system would run a check like this on every pull request so a human doesn't have to police it by hand.

  1. CONV = re.compile(r"^(feat|fix|docs|refactor|test|chore)(\(.+\))?: ") builds a regular expression (a text pattern). It matches commit messages that start with an approved word like feat: or fix: — the "conventional commits" style.
  2. Inside pr_mergeable(...), problems = [] starts an empty list of complaints. The first if not re.match(...) flags the PR if the branch name doesn't look like feature/something.
  3. The for c in commits: loop checks each commit message against the CONV pattern and records any that don't fit. The if files_changed > 400 check flags PRs that are too big to review well.
  4. return (not problems), problems hands back two things: True if the problems list is empty (a clean PR), plus the list itself so the caller can see exactly what's wrong.
  5. The two calls at the bottom test it: a tidy PR (good branch name, proper commits, 12 files) and a messy one (bad name, vague commit, 900 files).

What the output means: First line prints mergeable: True — the good PR passes. The second prints mergeable: False followed by the three reasons the bad PR was rejected: bad branch name, a non-conventional commit, and being too large.

Try this: Change "mystuff" to "feature/search" and fix the commit to "feat: add search", then re-run — watch the reasons disappear one by one until the PR is mergeable.

6 · Professional — branching strategies professional

Here's a trap dressed up as best practice. A team reads a famous 2010 blog post about "Git Flow," sees the impressive diagram with develop, release, and hotfix branches all weaving together, and adopts the whole thing — because it looks professional and thorough. Six months later they're deploying continuously to a web app, drowning in ceremony, cherry-picking hotfixes across four branches, and wondering why shipping a one-line change takes two days. They cargo-culted a strategy built for a completely different problem. Choosing a branching model is a real engineering judgment, and getting it wrong quietly taxes every change you ever make.

A branching strategy is just the team's agreed answer to one question: "how does work travel from a developer's laptop into production?" There's no universally correct answer — the right one depends entirely on how often you ship. That's the insight that cuts through all the diagrams: match the model to your release cadence, and everything else follows.

The analogy: think of the difference between a food truck and a cruise-ship kitchen. The food truck (a web app deploying ten times a day) needs to serve fast, keep the line moving, and never let a big backlog build up — so it uses tiny, quick orders: short-lived branches, or even trunk-based development where changes land on main many times a day behind feature flags that hide unfinished work. The cruise-ship kitchen (versioned software shipped every few months — think installed desktop apps or firmware) plans elaborate multi-course meals well in advance, needs release and hotfix lanes to maintain old versions, and can absorb the ceremony because it ships rarely. Neither is "better" — they're solving different problems.

The mistake almost every team makes is the same one: long-lived branches. It feels safe to keep a big feature isolated on its own branch for three weeks — but merge pain grows with branch age. The longer your branch drifts from main, the more the world moves underneath it, and the more brutal the eventual conflict. That's why the modern default — GitHub Flow (branch off main, PR, CI, merge, done, all within a day or two) — beats heavier models for most teams: it keeps branches short and diffs small, so conflicts stay trivial. The table below lays out the three common strategies against exactly this axis — shape, and who each one is genuinely for.

how often you ship decides the model ship many times/day ship every few months Trunk-based tiny commits + flags high-velocity CI GitHub Flow branch → PR → main most teams Git Flow develop/release/hotfix versioned releases left = fast + light · right = ceremony that only pays off when releases are rare Match the model to your release cadence. Ship continuously → trunk-based or GitHub Flow (short branches); ship rarely in versions → Git Flow's heavier lanes earn their ceremony.
🗺️ How to read this diagram

The horizontal arrow is a single axis: how often you ship, from many times a day on the left to every few months on the right. Each strategy sits where it fits.

  • Trunk-based (far left, green) — tiny commits straight to main behind feature flags; needs mature, fast CI and suits continuous delivery.
  • GitHub Flow (centre) — branch → PR → merge, all within a day or two. The short-branch default that fits most teams.
  • Git Flow (far right, blue) — long-lived develop/release/hotfix lanes; the ceremony only pays off for versioned software shipped rarely.
  • The dashed drop-lines pin each model to its place on the cadence axis — position is the whole message.

In short: Faster shipping → lighter, shorter-branch models (left); rarer versioned releases → heavier Git Flow (right). Pick by cadence, not by which diagram looks impressive.

StrategyShapeBest for
GitHub Flowbranch → PR → merge to mainmost teams
Trunk-basedtiny commits to main + flagshigh-velocity, mature CI
Git Flowlong-lived develop/release branchesversioned releases (heavier)

7 · Tech-lead — protect main & own the flow tech-lead

You can write the best collaboration guidelines in the world, and someone will still push straight to main at midnight. Not out of malice — they were in a hurry, the guideline lived in a wiki nobody reads, and Git let them. Here's the hard-won lesson every tech lead learns: a rule that lives in a document is a suggestion; a rule enforced by the platform is a rule. If bypassing the process is even possible, then under deadline pressure it will happen — and the one time it happens is the time it breaks production.

So the tech lead's real job here isn't to write policy — it's to make the safe path the only path. This is what branch protection does: it's a set of switches on the platform (GitHub, GitLab, and the rest) that make it technically impossible to do the dangerous thing. No direct pushes to main. No merging without an approving review. No merging while CI is red. The rules stop being something people have to remember and become something the system simply won't let them violate.

The analogy: it's the difference between a "please don't enter" sign and a locked door. The sign depends on everyone reading it, agreeing with it, and choosing to obey it every single time — which they won't, forever. The locked door doesn't care about anyone's intentions or memory; it's just shut. Branch protection is the locked door. Note the subtlety that separates a good lead from a great one: strong policies enable enforce_admins, so the rules apply to the lead too. The protection you can personally bypass is a protection you'll eventually bypass "just this once" — and that's the crack the whole wall fails through.

The common mistake is treating protection as a one-time setup you flip on and forget. Settings drift: someone loosens a rule to unblock a release and never restores it, a new required check gets added but not enforced, an old one silently disappears. That's why the mature move is to treat the policy as data — a JSON config — and write a small check that audits it, so CI itself fails when protection weakens. The same config that sets the rules can drive the check that guards them. The labs below show both halves: a policy expressed as data, and a pure-Python audit that reports exactly which guardrails are missing.

"please don't" sign direct push main broken anyone can bypass locked door any push PR + review CI green · no force-push main · safe platform enforces · incl. admins A sign vs a locked door. Unprotected, a rushed direct push lands on main and breaks it; protected, every path is forced through PR + review + green CI — enforced by the platform, admins included.
🗺️ How to read this diagram

Two scenarios split by the dashed line: the same team, with branch protection off (left) and on (right).

  • On the left, a direct push goes straight into main and the red box shows the result — broken. The label says it all: anyone can bypass a rule that's only a wiki page.
  • On the right, any push is forced through the amber gate — a PR with review and green CI, no force-push — before it can reach the green main.
  • The arrows converge on the gate: there is no path around it. The footer's incl. admins is the subtle bit — enforce_admins means the lead can't bypass it either.

In short: A sign relies on memory and will be ignored; a locked door (branch protection) makes the unsafe push technically impossible — for everyone, admins included.

Python · a branch-protection policy check (runs)
protection.pydef protection_ok(rules):
    required = {"require_pr", "require_review", "require_ci_green", "no_direct_push"}
    missing = [r for r in required if not rules.get(r)]
    return (not missing), missing

strong = protection_ok({"require_pr":True,"require_review":True,"require_ci_green":True,"no_direct_push":True})
weak   = protection_ok({"require_pr":True,"require_review":False,"require_ci_green":False,"no_direct_push":False})
print("main protected:", strong)
print("weak config:", weak)
main protected: (True, [])
weak config: (False, ['require_review', 'require_ci_green', 'no_direct_push'])
▶ How this works

This check answers a tech-lead question: is main properly protected? Protection means the shared branch can't be changed carelessly — every change must go through a reviewed, tested pull request. The function confirms all the required guardrails are switched on.

  1. required = {"require_pr", "require_review", "require_ci_green", "no_direct_push"} is the set of rules that must all be enabled: a PR is required, someone must review it, tests (CI) must pass, and nobody can push straight to main.
  2. missing = [r for r in required if not rules.get(r)] walks the required rules and keeps any that are off in the supplied rules. rules.get(r) looks up each rule; if it's missing or False, it goes in the missing list.
  3. return (not missing), missing gives back True when nothing is missing (fully protected) plus the list of any gaps.
  4. strong turns every rule on; weak leaves three off — so you can compare a safe policy against a risky one.

What the output means: main protected: (True, []) means the strong config passes with no gaps. weak config: (False, [...]) lists the three disabled protections (require_review, require_ci_green, no_direct_push) that make it unsafe.

Try this: Flip one rule in weak to True and re-run — that rule drops out of the "missing" list. Turn them all on and it becomes (True, []), just like strong.

The PR is the junction of everythingTesting (TQ) runs in CI on the PR, Containers/CD build & deploy on merge, and review happens here. Owning this workflow — protected main, required checks, small PRs — is a core tech-lead responsibility and the backbone the rest of the professional stack hangs on.

Exercise DF3.1 — Run & guard a team flow

Context: This is the full life of a change on a real team: it travels down a branch, through a PR and a conflict, and the whole flow is guarded so no one can skip the rules. Owning that end to end is the tech-lead deliverable.

Your task: Create a feature branch, open a PR, and resolve a deliberate conflict on merge — then write pr_mergeable as a check a CI job could run and document your branch-protection rules as if onboarding a new hire.

Requirements:

  • Branch, open a PR, and resolve an intentional merge conflict end to end
  • Write pr_mergeable as a check a CI job could invoke
  • Confirm the protection rules are complete by running protection_ok
  • Document the branch-protection policy clearly enough to onboard a new hire
  • Show the rules are enforced by the platform, not by reviewer memory

💡 Hint: Treat the protection policy as data the audit consumes — that way the same JSON both configures the platform and drives your protection_ok check.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Branch, commit, mergeBeginner

Context: A branch is just a cheap, movable pointer to a commit, so you branch per unit of work and merge it back when it's done. This create-work-merge loop is the heart of collaborating in Git.

Your task: Create a feature branch, make a commit on it, switch back to main, and merge the feature in — then confirm from the log that the work landed.

Requirements:

  • Create and switch in one step with git switch -c feature/<name>
  • Make a change and commit it on the feature branch
  • Return to main with git switch main
  • Merge the branch in with git merge feature/<name>
  • Verify with git log --oneline --graph that the commit is now on main

💡 Hint: If main hasn't moved since you branched, the merge is a simple fast-forward — the pointer just slides to your latest commit.

Show solution
git switch -c feature/greeting     # create + switch in one step
echo "hi there" > greet.txt
git add greet.txt
git commit -m "Add greeting file"
git switch main
git merge feature/greeting          # fast-forward if main did not move
git log --oneline --graph

A branch is just a movable pointer to a commit; creating one is cheap, so branch per unit of work.

Exercise 2 · Resolve a merge conflictIntermediate

Context: When two branches edit the same line, Git can't guess who's right, so it stops and asks a human. Conflicts are routine, not errors — being calm about the markers is a core collaboration skill.

Your task: Two branches edited the same line of app.py. Merge them, and when Git stops with a conflict, resolve it by hand and complete the merge — explaining what the conflict markers mean.

Requirements:

  • Start the merge and observe the CONFLICT (content) message
  • Read the three markers: <<<<<<< HEAD (your side), =======, and >>>>>>> branch (incoming)
  • Edit the file down to the single correct version and delete all three markers
  • Stage the resolved file with git add
  • Complete the merge with git commit

💡 Hint: The markers wrap both candidate versions — your job is to produce the one true line, then remove every marker before staging.

Show solution
git merge feature/rename
# Auto-merging app.py
# CONFLICT (content): Merge conflict in app.py

Git writes both versions into the file:

<<<<<<< HEAD
port = 8080          # your side (main)
=======
port = 9090          # incoming side (feature/rename)
>>>>>>> feature/rename

Edit to the single correct version, delete all three markers, then:

git add app.py
git commit            # completes the merge (default message is fine)

Conflicts are routine, not errors — Git is asking a human to decide which change wins.

Exercise 3 · The pull-request workflowAdvanced

Context: Real teams don't merge to main directly — they open a pull request as a review gate. Pushing more commits to the branch updates the same PR in place, which is the whole rhythm of code review.

Your task: Do a complete PR loop from the terminal: branch, push with upstream tracking, open a PR, address a review comment with a follow-up commit, and confirm the branch is up to date.

Requirements:

  • Create the branch and make the fix, then commit
  • Push with git push -u origin <branch> so tracking is set for later bare pushes
  • Open the PR from the current branch (e.g. gh pr create --fill)
  • Address a review comment with a follow-up commit and git push to update the same PR
  • Keep the branch focused so the diff stays small and reviewable

💡 Hint: A PR is a review gate, not a second copy of your code — every push to the branch flows into the open PR automatically.

Show solution
git switch -c fix/timeout
# ...make the fix...
git commit -am "Raise HTTP client timeout to 30s"
git push -u origin fix/timeout        # -u sets tracking so later `git push` suffices

gh pr create --fill                   # open PR from current branch (GitHub CLI)

# reviewer asks for a test -> add it, then:
git commit -am "Add test for timeout override"
git push                              # updates the same PR

The PR is a review gate, not a second copy — pushing more commits to the branch updates it in place. Keep the branch focused so the diff stays small.

Exercise 4 · Rebase to keep history linearExpert

Context: A tangled merge history is hard to read; rebasing replays your commits on top of the latest main for a clean, linear line. Doing it safely — and knowing when NOT to — is what separates confident contributors from dangerous ones.

Your task: Your feature branch is a few commits behind main and you want a clean, linear history before merging. Rebase onto the latest main and explain the golden rule of rebasing.

Requirements:

  • Fetch first, then git rebase origin/main to replay your commits on top
  • Resolve any conflicts and continue with git rebase --continue
  • Update the remote branch with git push --force-with-lease, not plain --force
  • Explain why --force-with-lease is safer (it refuses if someone else pushed)
  • State the golden rule: only rebase commits that live on your own branch, never shared main

💡 Hint: If you'd rather avoid the force-push risk entirely, a squash-merge of the PR gives you the same clean history without rebasing.

Show solution
git switch feature/api
git fetch origin
git rebase origin/main       # replays your commits on top of latest main
# resolve any conflicts, then:
git rebase --continue
git push --force-with-lease  # branch history rewritten; safe force

--force-with-lease refuses to overwrite if someone else pushed meanwhile (safer than --force). Golden rule: only rebase commits that live on your branch and were not pulled by others. Never rebase shared main. Squash-merge PRs get the same clean history without the rebase risk.

Exercise 5 · Pick a branching strategyProfessional

Context: The right branching model depends on how often you ship. Matching the strategy to release cadence — rather than cargo-culting GitFlow — is a judgment call teams look to a senior engineer to make.

Your task: A 6-person team ships to production several times a day behind feature flags. Recommend a branching strategy and justify it in terms of merge cost and lead time.

Requirements:

  • Recommend trunk-based development for this continuous-delivery team
  • Argue that short-lived branches merged within a day keep diffs small and conflicts rare
  • Explain that feature flags let incomplete work ship dark so main stays releasable
  • Contrast with GitFlow's develop/release/hotfix ceremony and why it lengthens lead time here
  • Land the rule of thumb: match the model to release cadence

💡 Hint: Merge cost grows with branch age — the core argument for short-lived branches is that conflicts and integration pain compound the longer a branch lives.

Show solution

Recommendation: trunk-based development.

  • Short-lived branches off main, merged within a day, keep diffs small and conflicts rare — merge cost grows with branch age.
  • Feature flags let incomplete work ship dark, so main is always releasable without long release branches.
  • GitFlow (develop + release + hotfix branches) adds ceremony that pays off only for versioned, infrequent releases (e.g. installed desktop software) — it lengthens lead time for a team deploying continuously.

Rule of thumb: match the branching model to release cadence. Continuous delivery → trunk-based; scheduled versioned releases → GitFlow.

Exercise 6 · Protect main and own the flowIndustry scenario

Context: Rules that live in a wiki get bypassed; rules encoded in the platform cannot be. As tech lead you make the collaboration policy enforceable rather than remembered.

Your task: Codify the team's rules so they can't be bypassed — no direct pushes to main, PRs need one approval and green CI, history stays linear — as a branch-protection policy plus a Python audit of a repo's config.

Requirements:

  • Express protection as data: required reviews, required status checks, no force pushes, linear history
  • Require at least one approving review and a passing CI context
  • Disallow force pushes to main and require linear history
  • Write a pure-Python audit(protection) that returns the list of policy gaps
  • Have the audit flag missing approval, missing CI, and allowed force pushes

💡 Hint: Encoding the policy as JSON plus a checker means CI can fail when protection drifts — the platform enforces the rule instead of reviewers remembering it.

Show solution

Branch-protection settings (GitHub) — set once via the API/UI:

{
  "required_pull_request_reviews": {"required_approving_review_count": 1},
  "required_status_checks": {"strict": true, "contexts": ["ci/test"]},
  "enforce_admins": true,
  "allow_force_pushes": false,
  "required_linear_history": true
}

An offline audit that fails CI if protection drifts (pure Python, feed it the JSON above):

def audit(protection):
    problems = []
    pr = protection.get("required_pull_request_reviews", {})
    if pr.get("required_approving_review_count", 0) < 1:
        problems.append("main allows merging without approval")
    if not protection.get("required_status_checks", {}).get("contexts"):
        problems.append("main does not require green CI")
    if protection.get("allow_force_pushes"):
        problems.append("force pushes to main are allowed")
    return problems

cfg = {"required_pull_request_reviews": {"required_approving_review_count": 1},
       "required_status_checks": {"contexts": ["ci/test"]},
       "allow_force_pushes": False}
print(audit(cfg) or "policy OK")

Encoding the policy as data + a check means the rule is enforced by the platform, not by reviewers remembering it.

✓ Checkpoint — you can move on when you can…

  • Branch, merge, and resolve conflicts.
  • Run the full PR workflow and review well.
  • Automate PR/branch quality checks in Python.
  • Choose a strategy and protect main as a lead.

Knowledge check check yourself

✓ Knowledge check

Why build each feature on its own branch instead of committing directly to main?

Show answer
A branch is an isolated line of commits, so your half-done work never touches the shared main — main stays stable and shippable — and you merge only when the feature is ready.
✓ Knowledge check

When resolving a merge conflict, what are the <<<<<<< / ======= / >>>>>>> markers, and how do you finish the merge?

Show answer
Git inserts them because two branches changed the same lines: the text between <<<<<<< HEAD and ======= is your current side and between ======= and >>>>>>> is the incoming side. You edit to the correct result, delete all three marker lines, then git add the file and git commit.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in