Git for teams
Branches, pull requests, rebasing and how a three-way merge finds conflicts.
Create a free account Try part of it here
Why it matters
Git on your own is a save button. Git on a team is how dozens of people change one codebase at the same time without losing each other's work. That takes shared habits: small branches, clear pull requests, and knowing what to do when changes collide. Merge conflicts scare beginners because they seem random. Once you know how a merge works inside, they're two edits to the same lines, and you'll build the algorithm that finds them.
What you'll be able to do
- Choose a team workflow: short-lived branches into trunk, with small pull requests
- Know when to merge, rebase or squash, and the one rule about rebasing
- Explain how a three-way merge finds conflicts, and build one
- Use the commands professionals reach for: bisect, revert, blame and log --graph
What it covers
- Team workflows
- Why rebasing changes commit ids
- How a merge works
- A three-way merge, line by line
- Commands worth knowing
- Finding the changes from the base
- Conflicts are questions, not obstacles
- Small pull requests
Team workflows
Think of a dozen people editing one manual. If each of them takes a printed copy away for three weeks, combining the copies is a nightmare. If each changes one page and hands it back the same day, it's easy.
Most teams use trunk-based development: main is always deployable, and each change lives on a short-lived branch, a day or two, merged through a pull request. Unfinished features hide behind feature flags, not long-running branches.
Long-lived branches drift. After three weeks, a branch touches code others have changed many times, and merging it is painful and risky. Small branches merge cleanly because there's little time for others' changes to collide with them.
A good pull request:
- does one thing, small enough to review in 15 minutes (a few hundred lines at most);
- has a description: what changed, why, how it was tested, and anything risky;
- is green in CI before anyone reviews it.
Merge, rebase or squash
Say you kept a travel diary alongside a friend. You could staple the two together, rewrite yours as if you'd left after they got back, or boil your trip down to one paragraph. Those are the three ways to bring a branch back:
| action | what happens | history |
|---|---|---|
| merge | a merge commit joins the two lines of history | true but branchy |
| rebase | your commits are replayed on top of the latest main | straight line; commits get new ids |
| squash merge | the whole PR becomes one commit on main | one tidy commit per change |
Many teams rebase their own branch onto main to stay current, then squash merge the PR. The one rule: never rebase a branch other people have pulled. Rebasing rewrites commit ids, and their copies no longer match yours.
Why rebasing changes commit ids
A commit's id is a hash of its content and its parent. Replay the same change on a new parent and you get a different commit, even though the change is identical.
import hashlib def commit_id(parent, change): return hashlib.sha1(f"{parent}|{change}".encode()).hexdigest()[:7] main_old = "a1b2c3d" main_new = "f9e8d7c" change = "fix the discount rounding" print("before rebase:", commit_id(main_old, change)) print("after rebase: ", commit_id(main_new, change))
This explains the one rule. Anyone who pulled your old commit now has a commit that your branch doesn't, and Git sees two different histories. Rebase your own unshared work freely, and leave shared branches alone.
The one rule: never rebase a branch other people have pulled.
feature/search. You want a cleaner history. Should you rebase it and force-push?Show the answer
No: rebasing a shared branch rewrites commits your teammate already has
Rebase only branches that are yours alone. For shared ones, merge, or agree on it first.
How a merge works
Two editors mark up copies of the same draft. To combine them, you don't compare one copy with the other. You compare each with the original. Git does the same. It finds the merge base: the last commit both branches share. Then it works out two sets of changes:
- base → ours (your branch),
- base → theirs (the branch being merged).
Where only one side changed a region, take that change. Where both made the same change, take it once. Where both changed the same region differently, that's a conflict: Git can't know which you meant, so it writes both with markers:
<<<<<<< ours TIMEOUT = 30 ======= TIMEOUT = 60 >>>>>>> theirs
Resolving means understanding both intents (maybe the answer is 60 with a new retry), editing the file, running the tests, then git add and continuing. Never leave markers in: a CI check that searches for <<<<<<< catches accidents.
In plain words: a conflict isn't an error. It's Git saying two people changed the same lines, and asking you to decide.
A three-way merge, line by line
Compare each side with the base. If only one side changed a line, take that change. If both changed it in different ways, a person has to decide.
def merge(base, ours, theirs): out, conflicts = [], 0 for b, o, t in zip(base, ours, theirs): if o == t: out.append(o) elif o == b: out.append(t) elif t == b: out.append(o) else: out.append(f"CONFLICT({o} / {t})") conflicts += 1 return out, conflicts base = ["price = 10", "tax = 0.2", "name = 'tent'"] ours = ["price = 12", "tax = 0.2", "name = 'tent'"] theirs = ["price = 10", "tax = 0.2", "name = 'big tent'"] print(merge(base, ours, theirs)) theirs = ["price = 15", "tax = 0.2", "name = 'tent'"] print(merge(base, ours, theirs))
In the first merge each side changed a different line, so both changes land with no fuss. In the second, both changed the price and Git can't know who's right. The base is what makes this possible: without it, Git couldn't tell who changed what.
Commands worth knowing
You'll use these more than you expect, especially when something has gone wrong. The one to remember: when a bad commit has reached main and teammates have pulled it, git revert adds a new commit that undoes it, while git reset rewrites history and breaks their copies.
| command | use |
|---|---|
git log --oneline --graph | see how branches split and joined |
git bisect | binary-search history for the commit that broke something |
git revert <commit> | undo a commit by adding a new commit that undoes it; the safe choice once teammates have pulled |
git reset | move your branch pointer; only for commits nobody else has |
git blame <file> | which commit last changed each line, and why |
git stash | set aside uncommitted work |
Secrets: if a key is ever committed, removing it in a new commit isn't enough; it's still in history, like a page you crossed out in a diary that's still in the old copies. Revoke the key at once, then clean history if needed.
Show the answer
Applies both changes, with no conflict
Each region was changed by one side only, so both changes go in. Conflicts need both sides changing the same region differently.
Finding the changes from the base
Python's difflib gives the same kind of edit list Git computes.
from difflib import SequenceMatcher base = ["import os", "TIMEOUT = 30", "RETRIES = 3", "def main():"] ours = ["import os", "TIMEOUT = 60", "RETRIES = 3", "def main():"] for tag, i1, i2, j1, j2 in SequenceMatcher(None, base, ours, autojunk=False).get_opcodes(): if tag != "equal": print(tag, f"base[{i1}:{i2}] -> {ours[j1:j2]}")
One replaced region: base line 1 became "TIMEOUT = 60". A three-way merge compares such regions from both sides.
from difflib import SequenceMatcher base = ["a", "b", "c"] ours = ["a", "B", "c", "d"] print([op[0] for op in SequenceMatcher(None, base, ours, autojunk=False).get_opcodes()])
Show the answer
['equal', 'replace', 'equal', 'insert']
a and c stay the same ('equal'), b became B ('replace'), and d was added at the end ('insert'), which gives ['equal', 'replace', 'equal', 'insert']. A merge compares regions like these from both sides.
Conflicts are questions, not obstacles
When Git marks a conflict, it's telling you two people changed the same lines and it can't decide. The tempting move is to pick one side and carry on. But each side was written for a reason, and picking blindly throws away someone's change. Read both, and work out what the code needs to do now, since the right answer is often a mix. If you don't understand the other change, ask whoever wrote it. Then run the tests, since code can merge cleanly and still be wrong.
Force-pushing shared branches
After a rebase, your branch has new commit ids, so pushing needs force. On your own branch, that's fine. On a branch others use, it rewrites history under them, and their next pull turns into a mess of duplicates. If you must force-push, prefer --force-with-lease, which refuses if someone else has pushed since you last looked.
A deleted secret isn't gone
If a key was committed and you remove it in the next commit, it's still in the history, and anyone with the repository can read it. Bots scan public repositories for keys within minutes. Treat any committed secret as leaked. Revoke it first, then think about cleaning the history. Rewriting history alone doesn't help if someone has already copied it.
Small pull requests
One page handed back the same day rarely clashes with anyone. Small changes merge with fewer conflicts.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
What is the merge base?