Branching, Merging, and Collaboration with GitHub
From the Make notes on docker an GitHub curriculum
TL;DR
You'll learn how Git branches let you work on changes in isolation, how merging and rebasing bring that work back together, and how pull requests turn this into a team workflow on GitHub. You'll see exactly what a merge conflict looks like and how to resolve one by hand. By the end, you can confidently branch, merge, rebase, and open a clean pull request.
1. The Mental Model
A branch is just a movable pointer to a commit — nothing more. Creating a branch doesn't copy your files; it creates a tiny label you can move independently. Merging is the act of combining two label-histories back into one. Branches are cheap, disposable experiments — commit often, branch freely, and never fear deleting one.
2. The Core Material
2.1 Branches: creation, switching, and what's actually happening

Photo by Brett Sayles on Pexels
Every commit in Git has one or more parent commits, forming a graph. A branch name (like main or feature/login) is a pointer to one specific commit in that graph. HEAD is a pointer to "where you currently are" — usually it points at a branch, which points at a commit.
git branch feature/login # create a new branch pointer at current commit
git switch feature/login # move HEAD to point at that branch
# or combined:
git switch -c feature/login
When you commit on feature/login, only that branch's pointer moves forward. main stays exactly where it was. This is why branches are cheap: you're not duplicating the repository, you're just adding a new label and letting history diverge.
git log --oneline --graph --all # visualize all branch pointers and their history
2.2 Merging: fast-forward vs. three-way merge

Photo by dh tang on Pexels
When you merge, Git looks at the relationship between the two branches' histories.
Fast-forward merge: if main hasn't moved since you branched off it, Git just slides the main pointer forward to match your feature branch. No new commit is created — history stays linear.
Three-way merge: if both branches have new commits since they diverged, Git needs a real merge commit. It uses three points: the common ancestor, and the tip of each branch, to compute what changed on each side and combine them.
git switch main
git merge feature/login
If the merge is clean, Git creates a new commit with two parents — this is the merge commit, and it's how Git remembers that two lines of work came together.
graph LR
A["C1 (common ancestor)"] --> B["C2 (main)"]
A --> C["C3 (feature/login)"]
B --> D["Merge commit M"]
C --> D
2.3 Rebasing: rewriting history for a linear story

Photo by Suzy Hazelwood on Pexels
Rebase takes the commits from your branch and replays them, one by one, on top of a different base commit — usually the latest main. The result looks like you'd branched off recently and never needed a merge commit at all.
git switch feature/login
git rebase main
This rewrites the SHA hashes of every commit on feature/login (because their parent changed), which means never rebase commits that others have already pulled — you'd be creating duplicate, conflicting history for them. The safe rule: rebase local/private branches, merge shared/public branches.
graph TD
subgraph "Before rebase"
A1["C1"] --> A2["C2 (main)"]
A1 --> A3["C3 (feature)"]
end
subgraph "After rebase"
B1["C1"] --> B2["C2 (main)"]
B2 --> B3["C3' (feature, replayed)"]
end
2.4 Merge conflicts: what they are and how to fix them

Photo by Saifee Art on Pexels
A conflict happens when the same lines of the same file were changed differently on both branches, and Git can't decide which version is correct. Git pauses the merge/rebase and marks the file:
<<<<<<< HEAD
const PORT = 3000;
=======
const PORT = process.env.PORT || 8080;
>>>>>>> feature/login
Everything between <<<<<<< HEAD and ======= is your current branch's version; everything between ======= and >>>>>>> feature/login is the incoming branch's version. You manually edit the file to keep what's correct (often a combination), delete the conflict markers, then:
git add server.js
git commit # finishes a merge conflict resolution
# or, if you were rebasing:
git rebase --continue
If things go badly, you can always bail out cleanly:
git merge --abort
git rebase --abort
2.5 GitHub collaboration: forks, pull requests, and code review
On a team, the typical flow is: branch → commit → push → open a pull request (PR) → review → merge.
git push -u origin feature/login
Then on GitHub, you open a PR comparing feature/login into main. This gives your team a diff view, a place for comments, and (usually) required status checks like CI tests before merging is allowed. For open-source projects where you don't have write access, you fork the repo (your own copy on GitHub), push your branch to your fork, and open a PR from your fork back to the original repo.
GitHub offers three merge strategies for a PR:
- Merge commit — keeps full history, adds a merge commit (same as
git merge). - Squash and merge — combines all PR commits into one clean commit on
main. Great for messy "WIP" commit histories. - Rebase and merge — replays PR commits individually onto
main, keeping them separate but linear.
flowchart LR
A["Fork or branch repo"] --> B["Create feature branch"]
B --> C["Commit changes locally"]
C --> D["git push origin feature-branch"]
D --> E["Open Pull Request"]
E --> F{"CI passes & review approved?"}
F -->|No| C
F -->|Yes| G["Merge / Squash / Rebase into main"]
G --> H["Delete feature branch"]
3. Worked Example
Let's say you're adding a dark-mode toggle to a small web app. The repo has main with one file, app.js, containing a function renderPage().
git switch -c feature/dark-mode
You edit app.js, adding a toggleTheme() function, and commit:
git add app.js
git commit -m "Add toggleTheme function"
Meanwhile a teammate merges a PR into main that also touches app.js, adding a logAnalytics() call right near where you added your function. You push your branch and open a PR. GitHub shows: "This branch has conflicts that must be resolved."
You update your local main and try to merge it into your feature branch to deal with the conflict locally first:
git switch main
git pull origin main
git switch feature/dark-mode
git merge main
Git shows:
Auto-merging app.js
CONFLICT (content): Merge conflict in app.js
Automatic merge failed; fix conflicts and then commit the result.
Opening app.js, you see:
function renderPage() {
<<<<<<< HEAD
toggleTheme();
=======
logAnalytics("page_view");
>>>>>>> main
drawLayout();
}
Since both changes are needed, you keep both lines, delete the markers:
function renderPage() {
toggleTheme();
logAnalytics("page_view");
drawLayout();
}
Then:
git add app.js
git commit -m "Merge main into feature/dark-mode, resolve conflict"
git push origin feature/dark-mode
GitHub's PR now shows "no conflicts" and your checks run. A teammate reviews, leaves a comment ("add a comment explaining toggleTheme"), you push one more small commit, they approve, and you click Squash and merge — collapsing your 3 messy commits into one clean entry in main's history. You finish by deleting the branch, both locally and on GitHub:
git branch -d feature/dark-mode
git push origin --delete feature/dark-mode
4. Production Pitfalls & Best Practices
4.1 Real-World Best Practices
- Branch naming conventions — use prefixes like
feature/,bugfix/,hotfix/so your team and CI tooling can filter and automate by branch type. - Keep branches short-lived — merge within days, not weeks. Long-lived branches drift far from
mainand produce painful conflicts. - Protect
main— enable GitHub branch protection rules requiring PR review and passing CI before merge; this stops accidental direct pushes. - Pull before you push — always
git pull --rebase origin mainon your feature branch before pushing, to catch conflicts early and locally. - Write small, focused PRs — a 50-line PR gets reviewed carefully; a 2,000-line PR gets rubber-stamped. Smaller PRs mean fewer conflicts too.
- Use draft PRs — open a PR as a draft early to get CI feedback and show intent, then mark it "Ready for review" once done.
- Squash messy WIP commits — use
git rebase -i HEAD~5(interactive rebase) to clean up "fix typo", "oops", "wip" commits before opening a PR.
4.2 Common Bugs & Anti-Patterns
Bad: force-pushing a shared branch after rebase, wiping teammates' work
git rebase main
git push --force origin feature/shared-branch # dangerous on shared branches
Fixed: use force-with-lease, and only on branches you own
git rebase main
git push --force-with-lease origin feature/my-own-branch
--force-with-lease refuses to push if someone else has added commits you haven't seen — a safety check plain --force lacks.
Bad: resolving a conflict by blindly keeping "ours" without reading the other side
git checkout --ours app.js
git add app.js
This silently discards your teammate's work. Fixed: open the file, read both sides, merge the intent manually, then git add only after you've actually reconciled the logic.
Bad: committing directly to main "just this once"
git switch main
git commit -am "quick fix"
git push origin main
Fixed: always branch, even for tiny fixes
git switch -c hotfix/typo-fix
git commit -am "Fix typo in README"
git push -u origin hotfix/typo-fix
# then open a PR
This keeps main protected and gives every change a review trail.
Bad: merging a stale feature branch without updating it first, causing a huge conflict wall during the PR merge itself. Fixed: regularly sync your feature branch with main throughout development:
git switch feature/login
git fetch origin
git rebase origin/main
Doing this every day or two means you resolve small conflicts incrementally instead of one giant one at the end.
4.3 When to Use What (Decision Matrix)
| Situation | Approach |
|---|---|
| Your branch is private, not yet pushed/shared | Rebase freely to clean history |
| Branch is already pushed and others are using it | Merge only — never rebase shared history |
| PR has messy "wip"/"fix typo" commits | Squash and merge |
You want to preserve individual commit granularity on main |
Rebase and merge |
main moved since you branched, no conflicts yet |
Fast-forward merge happens automatically |
| Two people edited the same lines differently | Manual conflict resolution, read both sides |
5. Now Try It
Create a test repo locally: git init git-practice && cd git-practice, add a file notes.txt with one line of text, commit it on main. Now create two branches off that same commit, branch-a and branch-b. On branch-a, change line 1 of notes.txt and commit. On branch-b, change that same line 1 differently and commit. Merge branch-a into main (should be clean), then try merging branch-b into main — you should get a real conflict. Resolve it by hand, combining both edits sensibly, and complete the merge commit.
Success looks like: git log --oneline --graph --all shows a merge commit with two parents, and notes.txt contains content from both branches with no leftover <<<<<<< markers.
Frequently asked about Branching, Merging, and Collaboration with GitHub
Study this next
Get the full Make notes on docker an GitHub curriculum
Clone the complete plan to your dashboard for unlimited AI-generated notes, practice quizzes, and a personalised revision schedule.
Save this course free