Version control in a team
Solo git is "save points for one person": you commit, you branch, you have a private timeline of your own work. From the Git fluency basics you already know how to make a commit and create a branch. This lesson teaches the team layer on top of that — and the team layer is a genuinely different problem. The moment a second person edits the same files, git stops being a personal save system and becomes a protocol for many people changing the same code without overwriting each other. The commands look the same; what changes is the etiquette around shared history, the review flow, and how your branch feeds the build pipeline. Get this right and collaboration is calm. Get it wrong and you spend your week untangling conflicts and recovering history that someone force-pushed over.
Why team git is a different problem
Picture a single document that ten people all edit at once. If they each kept a private copy and the last one to hit "save" simply overwrote the file on the server, nine people's work would silently vanish. That is the core danger of shared code, and git's entire job is to prevent it. Git does this by giving everyone a full copy of the project's history and a disciplined way to combine changes line by line, so two people editing different parts of the same file both keep their work, and the rare case where they edit the same lines gets surfaced to a human instead of being lost.
A handful of plain-English terms make the rest of this lesson readable:
- Remote: a copy of the repository that lives on a shared server (GitHub, GitLab, an internal host) rather than on your laptop. It is the meeting point everyone syncs through.
- Origin: the conventional default name for your remote. When you cloned the project, git named the server you cloned from
origin. "Push to origin" just means "send my commits to that shared server." - main: the primary branch — the canonical, shippable line of history that everyone agrees is "the real project." (Older repos call it
master.) Your job is to get your work safely intomain. - clone: make a full local copy of a remote repository, including all its history, the first time.
- fetch: download new commits from the remote into your local copy without changing your working files. It tells you "here is what changed on the server" but leaves your branch alone.
- pull: fetch and then integrate those new commits into your current branch in one step (a fetch followed by a merge or rebase).
- push: upload your local commits to the remote so teammates can see and build on them.
Hold onto one mental model: there is your copy and there is the shared copy on origin. Most of the discipline in team git is about being careful when you publish to the shared copy, because once something is on origin and a teammate has pulled it, it is no longer yours alone to rewrite.
Branching models: how teams organize parallel work
A branch is an independent line of commits that diverges from main so you can work without disturbing it. Teams adopt a branching model — a shared convention for how branches are created, how long they live, and how they get back into main. The two dominant models sit at opposite ends of a spectrum, and the tradeoff between them is really a tradeoff about one word: drift.
Two terms first. A merge is the act of combining the commits from one branch into another, producing a single history that contains both sets of changes. Drift is what happens to a branch the longer it lives apart from main: every commit other people land on main while your branch is open makes your branch's starting point more and more out of date, so the eventual merge has to reconcile a larger and larger gap. A branch open for an afternoon barely drifts; a branch open for three weeks can drift so far that merging it feels like reattaching a limb.
Each task gets its own branch off main (e.g. feat/checkout-coupons). You work in isolation, commit freely, open a pull request, and merge once it is reviewed and green. Why teams like it: total isolation — broken half-finished work never touches main, and each branch maps cleanly to one task or ticket. The cost: if a branch lives for weeks it drifts, and the longer it drifts the more painful the eventual merge — that is the "long-lived branch merge nightmare" every team has lived through. Simple, ubiquitous, and the right default for most teams; the discipline is keeping branches short.
Everyone integrates into main (the "trunk") constantly — branches live hours, not weeks, and merge many times a day. Because work lands before it is fully finished, the unfinished parts hide behind feature flags: runtime switches (e.g. if flags.newCheckout) that keep new code off in production until it is ready, so half-built features can ship to main safely turned off. Why teams like it: constant tiny integrations mean drift never accumulates, so giant merges disappear. The cost: it demands strong automated testing and discipline, because there is no long isolation period to catch problems — every small merge must keep main shippable. Favored at high-velocity shops; it leans hard on the pipeline you will meet in CI/CD.
Notice the two models are really the same advice from different distances: integrate early and often. Feature branches let you batch a task's worth of work before integrating; trunk-based pushes the batch size toward zero. Both punish you for letting work sit unintegrated, because unintegrated work is exactly what drifts.
The pull request is the unit of work
On a team, you almost never push straight to main. Instead you bundle your branch's changes into a pull request (PR on GitHub; "merge request" on GitLab) and propose them for review. A PR is the unit teams reason about, talk about, and approve.
A PR contains four things:
- A title — a one-line summary of the change.
- A description explaining why the change exists and how to verify it — the context a reviewer needs that the code itself can't convey.
- The diff — the exact lines added and removed across all your commits, the substance under review.
- The automated checks — the results of CI running on your branch (build, tests, linters), shown as a green check or a red X.
The review flow, step by step:
- You push your branch and open the PR.
- CI runs automatically on the PR — it builds the code and runs the test suite against your branch, so a human never has to babysit "does it even compile / do the tests pass." Reviewers can trust the green check and spend their attention on design and correctness instead.
- A reviewer reads the diff, asks questions, and requests changes.
- You push follow-up commits; CI re-runs; the conversation resolves.
- Once it is approved and green, it merges into
main.
The single biggest favor you can do your reviewer is to keep the PR small. This is not a politeness; it is mechanical. A 50-line PR that does one thing gets a genuine, careful read in ten minutes — the reviewer can hold the whole change in their head, trace each edited line, and actually catch a bug. A 2,000-line PR that touches forty files exceeds what anyone can review carefully, so it gets a rubber-stamp "LGTM" ("looks good to me") and ships its bugs straight to production. The art of writing reviewable PRs — and of giving good reviews — is its own craft, covered in code review. The takeaway here: small PRs are reviewable PRs, and reviewable PRs are how bugs get caught before users find them.
Merge vs rebase, in depth
While your branch is open, main keeps moving — teammates land their own PRs. Sooner or later you need to bring main's newer commits into your branch so you are building on current code (and so the final merge is clean). There are two ways to do that, and the difference between them is entirely about what they do to history.
Start from this situation. You branched off main at commit B and made two commits D and E. Meanwhile a teammate landed C on main:
main: A---B---C
\
yours: D---E
Merge ties the two lines together with a new merge commit (M below) that has two parents — your work and the new main. Nothing is rewritten; D and E keep their original identities. The graph records exactly what happened, including the fact that the work was developed in parallel:
# git merge main (run from your branch)
main: A---B---C
\ \
yours: D---E---M # M = merge commit, parents are E and C
Rebase instead replays your commits on top of the latest main, as if you had started your work today from C. D and E are re-created as brand-new commits D' and E' with the same changes but new commit hashes, and the original D and E are discarded. The result is a single straight line — no fork, no merge commit:
# git rebase main (run from your branch)
main: A---B---C
\
yours: D'---E' # D',E' are NEW commits (new hashes)
The tradeoff falls straight out of those pictures. Merge preserves the true history — you can always see that D and E happened in parallel with C — but the graph gets branchy and, across hundreds of features, hard to read. Rebase gives you a clean linear history that reads like a tidy story, but it achieves that by rewriting commits into new ones. The common, sane practice: rebase your own in-progress branch to stay current and keep history tidy, then merge it into main through the PR. You get linear feature history and an honest record of when each feature landed.
D and E to origin and a teammate pulled them and started building on top of E. Now you rebase, turning D,E into new commits D',E' with different hashes, and force-push. From git's point of view D and E no longer exist on the branch — but your teammate's local copy still has them, and their new work is anchored to a commit that the remote has thrown away. Their branch has diverged: when they next pull they get a tangle of "the same change twice" and confusing conflicts, and untangling it is genuinely painful. So: rebase freely on your own unpushed (or unshared) branch, where you are the only owner of those commits. Never rebase main, and never rebase a branch teammates are actively building on.
Resolving conflicts calmly
A conflict happens when two changes touched the same lines of the same file, and git cannot decide on its own which version is correct — so it stops and asks a human. The first thing to internalize: a conflict is not an error and not a verdict on your work. Git merges different parts of a file automatically all day long; a conflict is just the narrow case where the same region was edited two ways, and reconciling that genuinely requires human judgment about intent.
When git hits a conflict it edits the file in place and inserts conflict markers so you can see both versions:
# a conflicted region looks like this in the file:
<<<<<<< HEAD
discount = price * 0.10 # YOUR side (current branch)
=======
discount = price * 0.15 # THEIR side (incoming change)
>>>>>>> main
Read the markers like a sandwich:
<<<<<<< HEADopens your version — everything from here to the divider is what your branch says.=======is the divider between the two versions.>>>>>>> maincloses the incoming version — everything from the divider up to it is what the other side says (here,main).
The steps to resolve:
- Read both sides and decide the correct combined result. Often the right answer is not "pick one" but keep both intents — if you renamed a function and a teammate added a call to it, the resolution needs the rename and the new call.
- Edit the region to that correct result and delete all three marker lines (
<<<,===,>>>). Leaving a marker behind is a classic way to ship broken code. - Run the tests, then tell git you are done:
git addthe file, thengit rebase --continue(orgit commitif you were merging).
The deepest lesson about conflicts is preventive: small, frequent integrations cause small, rare conflicts. A branch you rebase onto main every day only ever has a day's worth of divergence to reconcile, so conflicts are tiny when they appear at all. A branch you let rot for three weeks accumulates three weeks of everyone else's changes, and the merge becomes a marathon. This is the same "don't let work drift" principle from the branching models, viewed from the conflict end.
A clean feature-branch flow, line by line
Here is the whole workflow end to end — start a branch, commit in small atomic pieces, rebase onto the latest main, push, and open the PR. Every line is annotated:
# start fresh from the latest main
git switch main # move onto the main branch
git pull --ff-only # get main's newest commits; --ff-only refuses a messy merge
git switch -c feat/checkout-coupons # create + switch to a new feature branch
# ...do work, in small atomic commits...
git add -p # stage changes hunk-by-hunk, so each commit is one logical change
git commit -m "Apply coupon code at checkout" # imperative summary; the body explains WHY
# main moved while you worked? replay YOUR commits on top of it
git fetch origin # download main's new commits, without touching your files
git rebase origin/main # resolve conflicts, then: git rebase --continue
# publish your (private) branch and open a PR
git push -u origin feat/checkout-coupons # first push sets the upstream; after a rebase use --force-with-lease
# --force-with-lease force-pushes the rewritten branch but REFUSES if a
# teammate pushed in the meantime — a safety net for the golden rule
# CI runs your tests on the PR; reviewer approves; merge to main
Two details worth dwelling on. git pull --ff-only ("fast-forward only") asks git to simply advance your local main to match origin and to refuse if that is not possible — which keeps you from accidentally creating a stray merge commit on main. And after you rebase, your branch's commits have new hashes, so a normal push is rejected; --force-with-lease is the safe way to overwrite your own branch on origin, because it aborts if anyone else has pushed there since you last looked. That is the golden rule enforced by a flag.
Commit hygiene
Commits are not just save points; on a team they are the records other people read to understand, review, and (when something breaks) undo your work. Three habits make them useful:
- Atomic commits — one logical change each. A commit that "fixes a bug and renames a file and adds a feature" can't be reviewed cleanly (the reviewer can't tell which lines belong to which idea), can't be reverted cleanly (undoing the bug fix also undoes the feature), and can't be bisected. Bisect —
git bisect— is a debugging tool that finds which commit introduced a bug by binary-searching the history: it checks out a commit halfway back, you say "broken" or "fine," and it halves the range until it pins the culprit. That only works if each commit is one self-contained change. Usegit add -pto stage in pieces so each commit stays atomic. - Good messages — imperative summary plus a "why" body. The summary line is a short command-form description ("Validate coupon expiry", not "validated" or "fixes stuff"). The body, separated by a blank line, explains why the change exists — the diff already shows what changed line by line; what it can't show is the reasoning, the bug it fixes, or the constraint it satisfies. A future debugger (often you, six months later) reads the message to recover intent.
- Don't commit noise. No commented-out code, no leftover
console.logdebug lines, no unrelated reformatting that churns hundreds of lines and buries the three lines that actually matter. Noise inflates the diff and hides the real change from your reviewer — directly working against the "small, reviewable PR" goal.
Go deeper (optional): the conventions here are written down in well-known references — Chris Beams' "How to Write a Git Commit Message" (the imperative-summary rules), the Conventional Commits spec, and the Pro Git book's chapters on branching, rebasing, and merging. They are worth one read once the moves above feel natural.