AI EngineeringZero to ProductionHome·About·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

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

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

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

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

Reviewing well is a skill. Good reviews focus on correctness, clarity, and risk — not style bikes (let a formatter handle those). Be specific and kind; the goal is better code and a stronger team, not gatekeeping.

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

Read branch/commit data from Python to enforce standards a human shouldn't police manually — the kind of check a CI job runs on every PR.

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

Pick a simple, consistent strategy. GitHub Flow (main always deployable, short-lived branches, PR+CI before merge) fits most teams. Avoid long-lived branches — they drift and create painful merges. Trunk-based (tiny frequent merges behind feature flags) is the elite end.

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

A lead makes the safe path the only path: branch protection — no direct pushes to main, required PR + review + green CI, and a linear history. The PR becomes the single quality gate everything passes through.

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