Phase 1 · FOUNDATIONS
Git collaboration — PRs, reviews, tags, releases
By the end of today
- Move commits with fetch, pull and push, and know each apart
- Open a pull request and run a review-and-merge flow
- Tag a release with semantic versioning and push it
Remotes, pull requests & releases
Git so far has been a private diary on your own machine. Real teams share one history over the network, and the object that makes that possible is a remote — a named URL pointing at a shared copy of the repository, conventionally called origin. Three commands move commits between you and that remote, and interviewers love that people confuse them:
git fetchdownloads new commits into your remote-tracking branches (origin/main) but changes nothing in your working tree — it only updates your picture of the server.git pullisfetchplus a merge (or rebase) into your current branch — it downloads and integrates in one step.git pushsends your local commits up so teammates can see them.
The golden rule: pull before you push, so you integrate the team’s work before adding yours and avoid a rejected push.
You almost never push straight to the shared main branch. Instead you push a feature branch and open a pull request (PR) — a proposal to merge your branch into main. The PR is where review happens: a teammate reads the diff, leaves comments, requests changes, and approves; CI (GitHub Actions, in Phase 2) runs the tests on every push. Only when review passes and CI is green does someone click Merge. That branch → push → PR → review → merge cycle is “GitHub flow”, and it is how virtually every team on GitHub ships.
Real world: A pull request is a manuscript sent to an editor. You do not scribble in the published book directly — you submit your chapter, the editor marks it up in the margins, asks for a rewrite, and only signs off once it reads clean. The
mainbranch is the shelved edition everyone trusts; the PR is the editing desk where changes are argued over before they become official.
Tags and releases
A commit hash like 9813fbd is precise but meaningless to humans. A tag is a permanent, human-readable label pinned to one commit — and for releases the convention is semantic versioning: vMAJOR.MINOR.PATCH, e.g. v2.4.1. MAJOR bumps on a breaking change, MINOR on a backward-compatible feature, PATCH on a bug fix. Unlike a branch, a tag never moves — v2.4.1 points at the same commit forever, so anyone can check out exactly what shipped.
Create an annotated tag (the kind releases use — it stores author, date and a message) with git tag -a v1.0.0 -m "...", then git push origin v1.0.0 — because tags are not sent by a normal git push. On GitHub a pushed tag becomes a Release: a page with notes and downloadable artifacts built from that commit.
The Kubernetes project is the textbook example: every release is a semver tag — v1.31.0 for a feature release, v1.31.1 for the patch that follows — and each maps to a GitHub Release with notes. When you run a pinned Kubernetes version in Phase 4, you are running exactly the commit that tag points at. That is the whole team loop: push a branch, open a PR, get reviewed, merge, then tag and release what you shipped.
Hands-On Lab
Budget about 25 minutes. Open your WSL2 Ubuntu 24.04 terminal (if you have not set up WSL2 yet, do Day 0 first). You will play both sides of a team: create a repo, push it to a stand-in “remote”, open the flow a PR represents, pull a teammate’s change, then tag a release. Type every command and read every line of output.
# 1. Tell Git who you are (once per machine), then start a repo with a first commit.
git config --global user.name "Pushkar"
git config --global user.email "pushkar@example.com"
mkdir ~/webapp && cd ~/webapp && git init -b main
echo "v1 homepage" > index.html
git add index.html && git commit -m "Add homepage"
# Output:
# Initialized empty Git repository in /home/pushkar/webapp/.git/
# [main (root-commit) a1b2c3d] Add homepage
# 1 file changed, 1 insertion(+)
# create mode 100644 index.html
# 2. Create a bare repo to act as our remote (a stand-in for GitHub), then register it as "origin".
git init --bare -b main ~/webapp-remote.git
git remote add origin ~/webapp-remote.git
git remote -v
# Output:
# Initialized empty Git repository in /home/pushkar/webapp-remote.git/
# origin /home/pushkar/webapp-remote.git (fetch)
# origin /home/pushkar/webapp-remote.git (push)
# 3. Push main to the remote. -u sets origin/main as the upstream this branch tracks.
git push -u origin main
# Output:
# Enumerating objects: 3, done.
# Counting objects: 100% (3/3), done.
# Writing objects: 100% (3/3), 226 bytes | 226.00 KiB/s, done.
# Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
# To /home/pushkar/webapp-remote.git
# * [new branch] main -> main
# branch 'main' set up to track 'origin/main'.
# 4. Branch for a feature, commit, and push the branch — this is the branch you open a PR from.
git switch -c add-about-page
echo "about us" > about.html
git add about.html && git commit -m "Add about page"
git push -u origin add-about-page
# Output:
# Switched to a new branch 'add-about-page'
# [add-about-page e4f5a6b] Add about page
# 1 file changed, 1 insertion(+)
# create mode 100644 about.html
# ... (object counting lines) ...
# * [new branch] add-about-page -> add-about-page
# branch 'add-about-page' set up to track 'origin/add-about-page'.
# 5. The PR is approved — merge the feature into main and push. main fast-forwards (no new commit).
git switch main && git merge add-about-page
git push origin main
# Output:
# Switched to branch 'main'
# Updating a1b2c3d..e4f5a6b
# Fast-forward
# about.html | 1 +
# 1 file changed, 1 insertion(+)
# create mode 100644 about.html
# To /home/pushkar/webapp-remote.git
# a1b2c3d..e4f5a6b main -> main
# 6. A teammate clones the remote, fixes the homepage, and pushes to main.
git clone ~/webapp-remote.git ~/webapp-teammate && cd ~/webapp-teammate
echo "welcome" > index.html
git commit -am "Fix homepage typo"
git push origin main
# Output:
# Cloning into '/home/pushkar/webapp-teammate'...
# done.
# [main b7c8d9e] Fix homepage typo
# 1 file changed, 1 insertion(+), 1 deletion(-)
# ... (object counting lines) ...
# e4f5a6b..b7c8d9e main -> main
# 7. Back in your own repo, FETCH. It updates origin/main but leaves your working files untouched.
cd ~/webapp && git fetch origin
# Output:
# From /home/pushkar/webapp-remote.git
# e4f5a6b..b7c8d9e main -> origin/main
# 8. Now PULL to integrate the teammate's commit into your local main (a clean fast-forward).
git pull
# Output:
# Updating e4f5a6b..b7c8d9e
# Fast-forward
# index.html | 2 +-
# 1 file changed, 1 insertion(+), 1 deletion(-)
# 9. Tag this commit as a release. -a makes it annotated (stores author, date and message).
git tag -a v1.0.0 -m "First public release"
git tag
# Output:
# v1.0.0
# 10. Tags are NOT sent by a normal push — push it explicitly so the release exists on the remote.
git push origin v1.0.0
# Output:
# ... (object counting lines) ...
# To /home/pushkar/webapp-remote.git
# * [new tag] v1.0.0 -> v1.0.0
# 11. Read the whole history back — one line each, with branches and the tag shown.
git log --oneline --decorate
# Output:
# b7c8d9e (HEAD -> main, tag: v1.0.0, origin/main) Fix homepage typo
# e4f5a6b (origin/add-about-page, add-about-page) Add about page
# a1b2c3d Add homepage
Read that last graph back: you pushed a branch, merged it as a PR would, pulled a teammate’s fix, and pinned a permanent v1.0.0 tag to exactly what shipped — the full collaboration loop on one screen.
Common Errors & Fixes
These three catch almost everyone in their first week working with a shared remote. Read the error text slowly — learning to parse it is the actual skill.
Common error: Pushing to a shared branch that a teammate already advanced —
git push— is rejected:To /home/pushkar/webapp-remote.git ! [rejected] main -> main (fetch first) error: failed to push some refs to '/home/pushkar/webapp-remote.git' hint: Updates were rejected because the remote contains work that you do not hint: have locally. This is usually caused by another repository pushing to hint: the same ref. If you want to integrate the remote changes, use hint: ’git pull’ before pushing again. hint: See the ’Note about fast-forwards’ in ’git push --help’ for details.Why: The remote has commits your local branch does not, so a plain push would strand that work. Git refuses rather than overwrite history — this is the non-fast-forward guard.
Fix: Integrate first:
git pull(fetch + merge, or rebase), resolve any conflicts, re-test, thengit pushagain cleanly. Never reach for--forceon a shared branch — it deletes the teammate’s commits.How you’d spot it in prod: A CI “publish” or deploy step failing with
(fetch first)ornon-fast-forwardmeans an automated job’s local ref is stale — usually two pipelines racing on the same branch. The fix is a pull/rebase step before the push, not a force.
Common error: Creating a tag, then running a normal push and expecting the release to appear —
git push— reports nothing went up:Everything up-to-dateWhy:
git pushsends branch commits, not tags. Your commits were already on the remote, so Git had nothing to send — and the new tag stayed purely local.Fix: Push the tag by name,
git push origin v1.0.0, or push all tags withgit push --tags. Confirm withgit ls-remote --tags originthat the remote now lists it.How you’d spot it in prod: A release pipeline that triggers “on tag” never fires — the build simply doesn’t start — because the tag never reached the remote. Check the remote’s tag list before assuming the pipeline is broken.
Common error: Trying to re-point an existing release tag at a new commit —
git tag -a v1.0.0 -m "redo"— fails:fatal: tag 'v1.0.0' already existsWhy: Tags are meant to be immutable pointers. Git will not silently overwrite one, because a version that means two different commits is worse than useless.
Fix: Cut a new version instead —
v1.0.1for the fix. If a tag genuinely must move (rare, before it is public), delete it deliberately on both sides:git tag -d v1.0.0 && git push origin :refs/tags/v1.0.0, then re-create.How you’d spot it in prod: “Which
v1.0.0is actually deployed?” is the tell — two builds carry the same version but different code because someone force-moved a tag. Immutable tags plus never reusing a released version prevents it.
Git Collaboration Interview Questions
Cover the answers below and say your own version out loud first — explain fetch vs pull, and what a PR gives you that a direct push does not, before you reveal each answer. Recalling before revealing is what makes these stick when an interviewer asks them cold. The questions and answers render right after this note.
Go Deeper
Optional extras if you have ~25 more minutes today:
- 5 min — Run
git remote show originin~/webappto see the full relationship between your local branches and the remote — tracked branches, what’s up to date, and what would be pushed. - 10 min — Read the Semantic Versioning spec (it is short) so you can justify a MAJOR vs MINOR vs PATCH bump in an interview without hand-waving.
- 10 min — Skim GitHub’s About pull requests doc to see how the review, CI checks and merge button map onto the flow you ran locally today.
What is the difference between git fetch and git pull? Both
Both talk to the remote, but fetch is read-only for your working tree. git fetch downloads new commits and updates your remote-tracking branches — origin/main and friends — without changing the branch you have checked out, so it is the safe way to see what landed upstream before you touch anything. git pull is git fetch followed by a merge (or a rebase, if configured) into your current branch, so it downloads and integrates in one step. I fetch when I want to look before I leap, and pull to catch my own branch up. The habit that saves you: pull before you push, so you integrate the team's work before adding yours.
What is a pull request, and why not just push straight to main? Both
A pull request is a proposal to merge one branch into another — usually your feature branch into main — hosted on a platform like GitHub. It creates a place for review: a teammate reads the diff, leaves comments, requests changes, and CI runs the tests automatically on every push to the branch. Nothing merges until review passes and CI is green. Pushing straight to main skips all of that — no second pair of eyes, no gate before untested code sits on the branch everyone deploys from. Most teams protect main so direct pushes are simply rejected. The PR is also a record: you can later see who approved a change and why.
How does semantic versioning work, and how do you cut a release from a commit? Product
Semantic versioning is a three-number scheme, MAJOR.MINOR.PATCH, like v2.4.1. You bump MAJOR for a breaking change, MINOR for a backward-compatible new feature, and PATCH for a bug fix. It lets anyone read a version and know how risky the upgrade is. To cut a release I create an annotated tag on the exact commit that shipped — git tag -a v2.4.1 -m 'message' — because an annotated tag stores the author, date and message, unlike a lightweight one. Then I push it explicitly with git push origin v2.4.1, since a normal push does not send tags. On GitHub that tag can become a Release. A tag never moves, so it permanently points to what went out.
Your push is rejected as non-fast-forward on a shared branch. What do you do? Service
That message means the remote has commits I do not have locally — a teammate pushed while I was working — so Git refuses to overwrite their history. The wrong move is force-pushing, which throws their work away. What I actually do is integrate first: git pull to fetch and merge (or rebase) their changes onto mine, resolve any conflicts, re-run the tests, then push again cleanly. On a busy shared branch this happens constantly, which is why teams use short-lived feature branches and merge through pull requests rather than all pushing one branch. Force-push, if ever needed, belongs only on your own unshared branch, with --force-with-lease.
Mark Day 18 complete
You can ship on demand now — tomorrow you teach the machine to run jobs on a schedule with cron and systemd timers.
Stuck on today’s lab? Ask in Mission 90 Q&A