Git and GitHub as a team sport: snapshots, branches, pull requests and merge conflicts
Roadmap task: Git and GitHub properly: branches, pull requests, resolving a real merge conflict
In this lesson: 10 ideas
- The mental model: commits are snapshots, branches are pointers
- The three places your work lives
- The everyday workflow
- Commit messages that explain themselves
- Pull requests and review etiquette
- and 5 more
- 150 min
- to read and practise
- 16 steps
- one idea at a time
- 2 visuals
- diagrams and hands-on
- 9 code
- 1 you can run here
- 5 checks
- test yourself
On a client engagement nobody emails code around. Every change you make goes through Git, lands on a branch, and reaches the shared codebase through a pull request that a client engineer reviews. If you cannot branch cleanly, write a commit message that explains itself, resolve a conflict without losing a teammate's work, or undo a mistake calmly, you slow down the whole team and lose their trust in your first week. Worse, one careless git add . can push an API key to a repository the whole company can read. Git feels like a list of magic commands until you learn the model underneath: snapshots and pointers. Once you see that model, every command in this lesson becomes predictable, including the scary ones.
The mental model: commits are snapshots, branches are pointers
A commit is a full snapshot of your project at one moment, plus a little metadata: author, time, message, and a pointer to its parent commit (the one before it). Git does not store "what changed"; it stores "what everything looked like", and computes diffs on demand by comparing two snapshots. Files that did not change are not copied again, because Git stores content under the hash of that content: identical content gets an identical name and is stored once.
A branch is nothing more than a name pointing at one commit. Creating a branch writes a 41-byte file. When you commit, Git creates a new snapshot whose parent is the commit your branch points to, then moves the branch pointer forward to the new commit. HEAD is a pointer to the branch you are on right now.
That is the whole model. History is a graph of snapshots linked by parent pointers, and branches are sticky notes you move around on it. Here it is in plain Python:
import hashlib
def object_id(kind: str, content: bytes) -> str:
"""How Git names every object: SHA-1 of a small header plus the content."""
return hashlib.sha1(f"{kind} {len(content)}\0".encode() + content).hexdigest()
# Same answer as running: printf 'hello\n' | git hash-object --stdin
print(object_id("blob", b"hello\n"))
commits: dict[str, dict] = {}
def commit(snapshot: dict[str, str], parents: list[str], message: str) -> str:
body = repr((sorted(snapshot.items()), parents, message)).encode()
cid = object_id("commit", body)[:7]
commits[cid] = {"snapshot": snapshot, "parents": parents, "message": message}
return cid
branches: dict[str, str] = {}
base = commit({"pricing.py": "return 40.0"}, [], "Add shipping fee rule")
branches["main"] = base
branches["feature"] = base # a new branch is just a new pointer
ours = commit({"pricing.py": "return 59.0"}, [branches["feature"]], "Raise flat fee")
branches["feature"] = ours # committing moves YOUR branch only
theirs = commit({"pricing.py": "return 40.0 + surcharge"}, [branches["main"]], "Add surcharge")
branches["main"] = theirs # a teammate's work moved main
def ancestors(cid: str) -> list[str]:
seen, stack = [], [cid]
while stack:
c = stack.pop()
if c not in seen:
seen.append(c)
stack.extend(commits[c]["parents"])
return seen
def merge_base(a: str, b: str) -> str:
in_b = set(ancestors(b))
return next(c for c in ancestors(a) if c in in_b)
mb = merge_base(branches["feature"], branches["main"])
print("branches:", branches)
print("merge base:", mb, "-", commits[mb]["message"])
for label, cid in [("base", mb), ("ours", branches["feature"]), ("theirs", branches["main"])]:
print(f"{label:>6}: {commits[cid]['snapshot']['pricing.py']}")What will this print? Guess first, then check.Show output
ce013625030ba8dba906f756967f9e9ca394464a
branches: {'main': '2942d2f', 'feature': 'bce5c40'}
merge base: 0a85403 - Add shipping fee rule
base: return 40.0
ours: return 59.0
theirs: return 40.0 + surchargeThe last three lines are exactly what Git looks at when you merge: the merge base (the most recent common ancestor), your version and their version. For each part of each file, if only one side changed it from the base, Git takes that side. If both sides changed the same lines differently, Git cannot guess, and that is a merge conflict.
The three places your work lives
Every file is in one of three places: the working tree (files on disk you are editing), the staging area or index (what will go into the next commit), and the repository (commits). git add copies working-tree changes into staging; git commit turns staging into a snapshot. git status tells you what is where, and you should run it constantly. git diff shows unstaged changes; git diff --staged shows what you are about to commit.
- Working treethe files you are editing
- Staging area
git addputs changes here - Local repository
git commitmakes a snapshot - GitHub
git pushshares it with the team
The everyday workflow
git switch main
git pull --ff-only # update main; refuse to create surprise merges
git switch -c feature/raise-free-shipping # new branch from up-to-date main
# ... edit, run tests ...
git status
git diff
git add -p # stage hunk by hunk, reading each one
git commit # opens your editor for a proper message
git push -u origin feature/raise-free-shipping
gh pr create --fill # or open the PR on github.comgit add -p is the habit that separates careful engineers from everyone else: you review your own change line by line before anyone else sees it, and debug prints never sneak in.
Commit messages that explain themselves
A commit message is written for someone running git log or git blame a year from now, wondering why a line exists. The widely followed convention: a short subject line in the imperative mood (about 50 characters, never more than 72), a blank line, then a body wrapped at 72 characters that explains what and why, not how. The diff already shows how.
Raise free-shipping threshold to Rs 999 and flat fee to Rs 59
Finance found that orders between Rs 499 and Rs 999 lose money on
shipping after the courier's October rate change. The new numbers
come from the pricing review on 10 Oct (ticket SHOP-214).Compare that with fixed stuff or changes. One small, logical change per commit makes review, git revert and git bisect all work.
Pull requests and review etiquette
A pull request (PR) asks: "please merge my branch into main". It is where review, automated tests (CI) and discussion happen. As the author:
- Keep PRs small and focused on one thing. A few hundred changed lines is a reasonable ceiling; past that, reviewers skim.
- Write a description: what changed, why, how you tested it, and anything risky. Link the ticket.
- Review your own diff on GitHub before requesting review. You will find something.
- Open a draft PR (
gh pr create --draft) to get early feedback on direction. - Answer every comment, either with a change or a reason. Do not silently resolve threads.
As a reviewer: comment on the code, not the person; ask questions ("what happens if the order total is exactly 999?") rather than issuing orders; mark optional suggestions with "nit:"; and approve only what you actually understand.
A real merge conflict, step by step
Here is a real session. You branched from main and changed the fee rules. Meanwhile your teammate Priya merged a PR into main that adds a weight surcharge to the same function. (Your commit hashes will differ.)
$ git log --oneline --graph --all
* 23953e2 Raise free-shipping threshold to Rs 999 and flat fee to Rs 59
| * 2eafcfb Add Rs 30 surcharge for parcels over 5 kg
|/
* b0d18f1 Add shipping fee rule
$ git merge main
Auto-merging pricing.py
CONFLICT (content): Merge conflict in pricing.py
Automatic merge failed; fix conflicts and then commit the result.
$ git status
On branch feature/raise-free-shipping
You have unmerged paths.
(fix conflicts and run "git commit")
(use "git merge --abort" to abort the merge)
Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: pricing.pyOpen the file:
FREE_SHIPPING_ABOVE = 999 # rupees
def shipping_fee(order_total: float, weight_kg: float = 0.0) -> float:
if order_total >= FREE_SHIPPING_ABOVE:
return 0.0
<<<<<<< HEAD
return 59.0
=======
return 40.0 + (30.0 if weight_kg > 5 else 0.0)
>>>>>>> mainRead it slowly. Git already merged what it safely could: your threshold change on line 1 and Priya's new weight_kg parameter on line 3 are both in. Only the last line conflicts. Between <<<<<<< HEAD and ======= is your side (HEAD, the branch you are on); between ======= and >>>>>>> main is the side you are merging in.
Resolving is not "pick one". Picking yours would silently delete Priya's surcharge; picking hers would undo your fee change. The correct answer combines the intent of both, so you need to understand both changes, and ask the other author when you do not. To see the base version as well, run git config --global merge.conflictStyle zdiff3 once; conflicts then also show a ||||||| section with the original line, which makes intent much easier to read.
Edit the file to the combined version, delete every marker line, then check the result:
$ git diff
...
- return 59.0
- return 40.0 + (30.0 if weight_kg > 5 else 0.0)
++ return 59.0 + (30.0 if weight_kg > 5 else 0.0)
$ python -c "from pricing import shipping_fee as f; print(f(1200), f(300), f(300, weight_kg=8))"
0.0 59.0 89.0
$ git add pricing.py
$ git commit --no-edit
[feature/raise-free-shipping 0e2fe79] Merge branch 'main' into feature/raise-free-shipping
$ git log --oneline --graph
* 0e2fe79 Merge branch 'main' into feature/raise-free-shipping
|\
| * 2eafcfb Add Rs 30 surcharge for parcels over 5 kg
* | 23953e2 Raise free-shipping threshold to Rs 999 and flat fee to Rs 59
|/
* b0d18f1 Add shipping fee rulegit add on a conflicted file means "resolved". Before that, always run the tests, because a merge with no conflict markers can still be wrong: Git merges text, not meaning. If you get lost halfway, git merge --abort returns you to where you started. GitHub can show the same conflict in the browser, but learn the terminal version first.
Rebase versus merge
Merging keeps history exactly as it happened and adds a merge commit with two parents. Rebasing replays your commits on top of the latest main, producing new commits with new hashes and a straight line of history:
$ git rebase main
Auto-merging pricing.py
CONFLICT (content): Merge conflict in pricing.py
error: could not apply 23953e2... Raise free-shipping threshold to Rs 999 and flat fee to Rs 59
...
$ # fix pricing.py exactly as before, then:
$ git add pricing.py
$ git rebase --continue
Successfully rebased and updated refs/heads/feature/raise-free-shipping.
$ git log --oneline --graph
* 2a9a3c9 Raise free-shipping threshold to Rs 999 and flat fee to Rs 59
* 2eafcfb Add Rs 30 surcharge for parcels over 5 kg
* b0d18f1 Add shipping fee ruleOne trap: during a rebase the sides are swapped. HEAD is now main's version, because Git is replaying your commit onto it, so <<<<<<< HEAD shows Priya's line and the bottom section shows yours.
The rule a junior needs: rebase only commits nobody else has built on. Rebasing your own local feature branch onto main is fine. Rebasing a branch a teammate has pulled rewrites history under them. If you rebase a branch you already pushed, update it with git push --force-with-lease, never plain --force, which can overwrite a teammate's push. Many teams avoid the question by using "squash and merge" on GitHub, which turns each PR into one commit on main. Follow the team's convention.
Undoing mistakes
| Situation | Command |
|---|---|
| Throw away unstaged edits to a file | git restore pricing.py |
| Unstage a file, keep the edits | git restore --staged pricing.py |
| Fix the last commit (not yet pushed) | git commit --amend |
| Undo the last commit, keep its changes | git reset --soft HEAD~1 |
| Undo a commit that is already pushed | git revert <hash> (adds a new commit that reverses it) |
| "I reset and lost my work" | git reflog, then git reset --hard <hash> |
The dividing line is whether others have the commit. reset and --amend rewrite history, so use them only on unpushed work. revert adds history, so it is always safe on shared branches.
The reflog is your safety net. It records every position HEAD has been at, so even a commit "deleted" by reset --hard is recoverable for weeks:
$ git reset --hard HEAD~1
HEAD is now at 23953e2 Raise free-shipping threshold to Rs 999 and flat fee to Rs 59
$ git reflog -2
23953e2 HEAD@{0}: reset: moving to HEAD~1
0e2fe79 HEAD@{1}: commit (merge): Merge branch 'main' into feature/raise-free-shipping
$ git reset --hard 0e2fe79 # and the merge is backThe one thing the reflog cannot save is uncommitted work destroyed by git restore or reset --hard. Commit early; you can always tidy up later.
.gitignore and secrets
A .gitignore file lists paths Git should never track. Create it in your first commit:
.env
.venv/
__pycache__/
*.db
.pytest_cache/Commit a .env.example with variable names and fake values instead, so others know what to set. .gitignore only affects untracked files; if you already committed .env, run git rm --cached .env to stop tracking it while keeping your local copy, and git check-ignore -v .env to confirm the rule matches.
If a secret ever reaches a remote, treat it as leaked even if you delete it a minute later: history keeps it, and bots scan public GitHub for keys. Revoke and rotate the key first, then tell the team. Cleaning history comes after, and is optional once the key is dead.
How this shows up in FDE work
You are two weeks into deploying a support agent for a Bengaluru fintech. Their platform team protects main: every change needs a PR, a green CI run and one approval from their engineer. You open a PR adding the agent's retrieval step; overnight, their engineer merges a refactor of the same config loader. Your PR now shows "This branch has conflicts". You pull main, merge it into your branch, resolve the conflict by keeping their new loader and re-adding your two settings, run the tests, and push. In the PR description you write which side you kept and why. That note is what makes a client engineer comfortable approving code from an outsider.
- Running
git add .without looking. Debug prints,.envfiles and 200 MB model weights end up in the commit. Usegit statusandgit add -p. - Resolving a conflict by keeping "my side" for the whole file. You silently delete a teammate's change; nobody notices until production does.
- Committing with the conflict markers still in the file. Search for
<<<<<<<before yougit add, and run the tests. - Rebasing or force-pushing a shared branch. Teammates' history no longer matches; use
--force-with-leaseand only on your own branches. - Deleting a leaked key from the latest commit and assuming it is gone. It is still in history and possibly already scraped. Rotate it.
- Huge PRs that mix a refactor, a feature and formatting changes. Reviewers cannot see the risky part; split them.
Answer in your head or on paper first, then open the answer and be honest with yourself.
What actually happens on disk when you create a new branch, and why is it so cheap?
Git writes a small file containing the hash of the commit the branch points to. No files are copied; branches are pointers into the existing commit graph.
In a conflict, Git has already merged some of the lines of the file. How did it decide those were safe?
It compared each side with the merge base. Where only one side changed a region, it took that change; it only stops where both sides changed the same region differently.
During git rebase main, which side does <<<<<<< HEAD show, and why is that the reverse of a merge?
It shows main's version. Rebase checks out main and replays your commits on top, so HEAD is the upstream side and your commit is the one being applied.
You pushed a commit that broke the build on a shared branch. Should you use git reset or git revert, and why?
git revert. It adds a new commit that undoes the change without rewriting history others already have; reset would force everyone to reconcile rewritten history.
You committed an API key, pushed, and deleted it in the next commit five minutes later. Are you safe?
No. The key is still in the earlier commit and may already be copied. Revoke and rotate the key first; cleaning history is secondary.
AI assistants are useful for Git in two ways: explaining what a command or a confusing state means, and drafting commit messages and PR descriptions from a diff. They are dangerous when you paste "how do I fix this?" and run whatever comes back, because the quickest fix they suggest is often reset --hard or push --force. For conflicts, resolve the first few by hand so you learn to read both sides; then use AI to check your reasoning, not to choose a side for you.
You are a patient senior engineer helping me understand the state of my Git repository.
I will paste the output of several commands. Do NOT suggest any command that rewrites
history or discards work (reset --hard, push --force, checkout -- ., clean -fd) unless I
explicitly ask, and if you think one is needed, explain what it would destroy first.
1. Explain in plain English where my branch is relative to its remote and to main.
2. Point out anything unusual (detached HEAD, a merge or rebase in progress, unpushed commits).
3. Suggest the safest next step and explain what it changes in the commit graph.
git status:
[PASTE]
git log --oneline --graph --all -20:
[PASTE]
What I am trying to do:
[e.g. "update my feature branch with the latest main before opening a PR"]I resolved a merge conflict and want a second opinion before I commit.
Below are the base version, my version, their version and my resolution of the same
region of the file. Please:
1. Describe in one sentence each what my change intended and what their change intended.
2. Say whether my resolution preserves BOTH intents. If it drops or alters either, quote
the exact line and explain the behavioural difference.
3. Suggest one or two test cases that would catch a wrong resolution.
Do not rewrite the code unless my resolution is wrong.
Base (from the ||||||| section, or git show :1:[FILE]):
[PASTE]
Mine (git show :2:[FILE] for a merge):
[PASTE]
Theirs (git show :3:[FILE] for a merge):
[PASTE]
My resolution:
[PASTE]Write a Git commit message and a pull request description for the staged diff below.
Commit message rules: imperative subject line of about 50 characters (72 at most), no trailing period,
blank line, body wrapped at 72 characters explaining WHAT changed and WHY (not how).
Use only the facts in the diff and my notes; if the "why" is not clear, write
[WHY?] instead of guessing.
PR description sections: Summary, Why, How I tested it, Risks / things to review closely.
My notes on why I made this change:
[PASTE YOUR NOTES OR TICKET LINK]
git diff --staged:
[PASTE]AI tools regularly suggest git push --force (instead of --force-with-lease), git reset --hard as a casual fix, and git checkout -- file from older tutorials where git restore is now the clearer command. When you paste a conflict without the base version, an assistant may "resolve" it by picking one side and confidently dropping the other change, so always give it all three versions and check its answer against tests. They also invent flags; run git help <command> to confirm anything unfamiliar. And never paste a diff that contains a real key, customer data or a client's proprietary code into a tool your client has not approved.