Most Git tutorials teach you to work alone: init, add, commit, push, done. Then you join a team and suddenly your push is rejected, a pull request says “This branch has conflicts”, someone asks you to “rebase onto main”, and a colleague’s work vanishes after a force push.
I write this article to help you understand how Git and GitHub actually work in a development team. I used two computers, one per team member, and a tiny website, and played through the situations that happen in real teams every week.
This article walks you through the whole lab, step by step. It’s written for people who know a little Git (you’ve committed and pushed before) but have never worked with other people on the same code. It may take a couple of hours to follow the lab, but it’s definitely worth the time if you’re about to join a development team.
Table of contents
- What you need
- The project and the team
- Five ideas to understand first
- Scenario 0: Setting up the team repository
- Scenario 1: The everyday workflow: issue, branch, pull request, review
- Scenario 2: Staying in sync with the team
- Scenario 3: The merge conflict
- Scenario 4: Two people, one branch: the rejected push
- Scenario 5: The production hotfix
- Scenario 6: Oops: recovering from mistakes
- Scenario 7: Team standards: templates and code owners
- Wrap-up: the habits that matter
What you need
- Git installed on both computers (version 2.23 or newer, for the
git switchcommand; check withgit --version) - Two GitHub accounts, one per team member. This matters: I have a set-up that GitHub doesn’t let you approve your own pull request, so with one account you can’t practise code review.
- Two computers. If you only have one, you can use two folders instead, but you’ll have to keep switching GitHub logins, which gets confusing (But you can use a Chrome and a Firefox brower to amend this.). Overall, two machines make the “two people” feeling real.
- VS Code (optional, but its conflict editor is used in a few places)
⚠️ Make the repository public (it’s only a sample site). On a free GitHub plan, branch protection rules are only enforced on public repositories. On a private repo, GitHub lets you create the rules but doesn’t apply them unless you have a paid Github plan, and several scenarios depend on them.
The project and the team
The “product” is a deliberately tiny static website, so we can focus 100% on Git and not on the code.
index.html
<html lang="en">
<head>
<title>My Website</title>
</head>
<body>
<h1>Welcome to My Website</h1>
<p>This is a simple HTML page.</p>
<p><a href="contact.html">Contact Us</a></p>
</body>
</html>
contact.html
<html lang="en">
<head>
<title>My Website</title>
</head>
<body>
<h1>Contact Us</h1>
<p>If you have any questions, please reach out to us at contact@mywebsite.com</p>
<p>We would love to hear from you!</p>
</body>
</html>
The team has two members:
| Role | Machine | GitHub account |
|---|---|---|
| Alex, tech lead and owner of the repository | PC 1 | Account A |
| Sam, developer | PC 2 | Account B |
Throughout the article, every block of commands says who runs it. When you see Alex (PC 1), go to the first computer; when you see Sam (PC 2), go to the second.
Five ideas to understand first
These five ideas explain almost every “why” in the rest of the article.
1. There are two copies of the repository: yours and GitHub’s.
Your PC has a full repository (the local repo). GitHub has another (the remote, usually called origin). They don’t sync automatically. push sends your commits up; fetch and pull bring others’ commits down.
2. origin/main is your PC’s memory of GitHub.
Your PC keeps a note of where GitHub’s branches were the last time you talked to GitHub. That note is called origin/main. It’s only updated when you fetch or pull, so it can be out of date.
3. A branch is just a label pointing at a commit. A branch isn’t a folder or a copy of the code. It’s a movable name tag stuck on one commit. When you commit, the label moves forward to the new commit. This one idea explains most of Scenario 2.
4. In a team, nobody pushes straight to main.
main is the shared, “always working” version. Changes reach it through a pull request (PR): a request on GitHub that says “please review my branch and merge it into main”.
5. Your work lives in three areas on your PC.
Working folder ──git add──► Staging area ──git commit──► Commits (history)
(files you edit) (the next commit)
Many commands in this article, especially in Scenario 6, just move changes between these three areas.
Scenario 0: Setting up the team repository
The story
Alex has some website code on PC 1 and wants to put it under version control on GitHub, invite Sam, and set one team rule: nobody pushes directly to main.
What you’ll learn
- The two ways to start a repository, and which one to use when
.gitignore- Protecting
mainwith a ruleset - Joining a project with
git clone
GitHub first, or local first?
There are two common ways to create a repository:
| Approach | Steps | When to use it |
|---|---|---|
| GitHub first | 1. Create the repo on GitHub. 2. git clone <url> | New projects. clone sets up the connection to GitHub for you, so there’s nothing more to configure. |
| Local first | 1. git init and commit locally. 2. Create an empty repo on GitHub. 3. git remote add origin <url>. 4. git push -u origin main | When you already have code on your PC |
In a real team, the repository usually already exists when you join, so you’ll almost always start with git clone, not git init.
Alex already has code, so Alex uses local first. Sam joins later, so Sam uses clone. The lab covers both.
Step 1: Create a .gitignore (Alex, PC 1)
A .gitignore file lists files Git should never track: OS junk, editor settings and, most importantly, secrets.
💡 Do people write
.gitignoreby hand? Yes. It’s a plain text file named exactly.gitignorein the project root. Most people start from a template: GitHub’s collection at github/gitignore, or a generator like gitignore.io. When you create a repo on GitHub, you can also choose a template, but only do that with the “GitHub first” approach.
For our site:
# OS files
Thumbs.db
Desktop.ini
.DS_Store
# Editor settings
.vscode/
# Secrets / local config (never commit these)
.env
config.local.js
# Dependencies, if tooling is added later
node_modules/
⚠️ Windows tip: create the file in VS Code (New File →
.gitignore). If you use Notepad’s “Save As”, it may silently save the file as.gitignore.txt, and Git will ignore it.
⚠️ Create
.gitignorebefore your first commit. It only affects files Git isn’t tracking yet. If a file is already committed, adding it to.gitignorewon’t remove it.
Step 2: The first commit and push (Alex, PC 1)
First create the repository on GitHub: New repository, give it a name, and leave README, .gitignore and license unticked. The repo must be completely empty. Your local repo already has history, and if GitHub creates its own first commit, the two histories won’t match and your first push will be rejected.
Then, on PC 1:
git init
git add .
git commit -m "Initial commit: homepage and contact page"
git branch -M main
git remote add origin https://github.com/<alex-account>/<repo>.git
git push -u origin main
What each line does:
git branch -M main: older Git versions call the first branchmaster. This renames it tomainto match GitHub’s default.git remote add origin <url>: tells your repo where GitHub’s copy lives and names itorigin.git push -u origin main: pushes, and-u(“upstream”) links your localmaintoorigin/main. After this, plaingit pushandgit pullknow where to go.
Step 3: Protect main (Alex, on GitHub)
Go to Settings → Rules → Rulesets → New ruleset → New branch ruleset.
💡 Ruleset or classic branch protection rule? GitHub offers both. Use a ruleset: it’s the newer system and the one GitHub recommends. A ruleset applies to everyone, including admins, unless you add them to a bypass list, and you can switch it on and off without deleting it.
| Setting | Value |
|---|---|
| Ruleset name | protect-main |
| Enforcement status | Active |
| Bypass list | Leave empty, so the rules apply to Alex too |
| Target branches | Add target → Include default branch |
Tick these rules:
- ✅ Restrict deletions
- ✅ Block force pushes
- ✅ Require a pull request before merging, with Required approvals: 1
- Also tick Dismiss stale pull request approvals when new commits are pushed. If a PR changes after approval, it must be approved again.
Step 4: Invite Sam (Alex, on GitHub)
Settings → Collaborators → Add people, and enter Sam’s GitHub username. Sam accepts the invitation by email or on GitHub.
Step 5: Sam joins (Sam, PC 2)
⚠️ If Sam’s PC already has a copy of the code, don’t run
git initon it. Two separately initialised repos have unrelated histories and won’t combine cleanly. Rename or delete that folder and clone fresh.
git clone https://github.com/<alex-account>/<repo>.git
cd <repo>
git config --global user.name "Sam"
git config --global user.email "<sam's GitHub email>"
clone downloads the full history, sets up origin, and links main to origin/main. When Git asks you to sign in (on Windows, a browser window opens), make sure you sign in as Sam’s account, not Alex’s.
Step 6: Test the rule
Sam makes a small change, commits it on main, and runs git push. It’s rejected:
remote: error: GH013: Repository rule violations found for refs/heads/main.
remote: - Changes must be made through a pull request.
The rule works, and it applies to Alex too.
Sam’s test commit is still on Sam’s local main, so clean it up now or it will cause trouble later:
git reset --hard origin/main
This moves Sam’s main back to match GitHub and throws the test commit away.
The first real problem: Alex forgot to include .gitignore
In my own run, I created .gitignore after the first push, committed it on main, and my push was rejected. The ruleset had just locked me, the owner, out of main. That’s actually the perfect first lesson: even the owner goes through a pull request.
If you’re in the same situation, with a commit on local main that GitHub won’t accept:
git switch -c chore/add-gitignore # 1. create a branch; it takes the commit with it
git branch -f main origin/main # 2. move local main back to match GitHub
git push -u origin chore/add-gitignore # 3. push the branch
Then open a pull request. You’ll learn how in Scenario 1. Scenario 2 explains why step 1 must come before step 2.
✅ Checkpoint
- Both PCs have the repository; Sam’s came from
clone. - Pushing directly to
mainis rejected for both Alex and Sam. git log --oneline --graph --allshows the same history on both PCs.
Scenario 1: The everyday workflow: issue, branch, pull request, review
The story
Alex opens a task on GitHub: “Add an About page, linked from the homepage.” Sam builds it; Alex reviews it, asks for changes, and finally approves it.
What you’ll learn
This is the loop you’ll repeat every day in a team:
Issue → Branch → Commits → Push → Pull request → Review → Merge → Clean up
Step 1: Create an issue (Alex, on GitHub)
Issues → New issue
- Title:
Add an About page - Description:
Create about.html and link to it from the homepage. - Assignee: Sam
It gets a number, say #1. Issues are the team’s to-do list.
Step 2: Create a branch (Sam, PC 2)
Always start from an up-to-date main:
git switch main
git pull
git switch -c feature/1-about-page
git switch -c creates a new branch and switches to it. A name like feature/1-about-page (type / issue number / short description) tells everyone what the branch is for.
💡 On older Git versions,
git checkout -b <name>is the equivalent ofgit switch -c <name>.
Step 3: Do the work and push (Sam, PC 2)
Create about.html:
<html lang="en">
<head>
<title>About Us - My Website</title>
</head>
<body>
<h1>About Us</h1>
<p>We are a small team that builds simple websites.</p>
</body>
</html>
And add a link on index.html, below the contact link:
<p><a href="about.html">About Us</a></p>
Commit and push the branch:
git add about.html index.html
git commit -m "Add About page and link it from the homepage"
git push -u origin feature/1-about-page
Step 4: Open a pull request (Sam, on GitHub)
GitHub shows a yellow banner: Compare & pull request. Click it.
- base:
main← compare:feature/1-about-page - Title:
Add About page - Description:
Closes #1. This special phrase links the PR to the issue and closes the issue automatically when the PR is merged.Fixes #1andResolves #1also work. - Reviewers: Alex
- Click Create pull request.
Step 5: Review (Alex, on GitHub)
💡 How do you actually review a PR?
- Open the PR from the Pull requests tab.
- Go to the Files changed tab. You see every changed line: green for added, red for removed.
- To comment on a specific line, hover over it, click the blue +, write your comment and click Start a review. This collects your comments into one review rather than sending them one by one.
- Click Review changes (or Submit review) at the top right and choose one:
Option Meaning Effect Comment General feedback Doesn’t count as approval Approve Looks good Counts toward the required approval Request changes Must be fixed first Blocks merging until the reviewer approves
Alex spots two problems and comments on the lines:
- “Please add
<!DOCTYPE html>as the first line.” - “Add a link back to the homepage.”
Then Alex submits the review with Request changes.
Step 6: Fix and push a follow-up commit (Sam, PC 2)
💡 Does Sam need a new pull request for the fixes? No. A PR follows its branch. Anything Sam pushes to
feature/1-about-pageautomatically shows up in the same PR.
Sam fixes about.html locally, on the same branch:
git switch feature/1-about-page
# add <!DOCTYPE html> and <p><a href="index.html">Back to home</a></p>
git add about.html
git commit -m "Add DOCTYPE and back-to-home link to About page"
git push
A plain git push is enough, because the first push with -u already linked this branch.
Good team manners after fixing review comments:
- Reply to each comment (e.g. “Fixed in the latest commit”) and click Resolve conversation.
- Click the 🔄 icon next to Alex’s name under Reviewers to re-request review, which notifies Alex.
On the Files changed tab, Alex can click “View changes since your last review” to see only the fix.
⚠️ If the reviewer suggests an edit (GitHub’s “suggestion” feature) and Sam clicks Commit suggestion, that commit is created on GitHub. Sam’s local branch is now behind, so Sam must
git pullbefore making more changes locally.
Step 7: Approve and merge (Alex, on GitHub)
Alex reviews again, chooses Approve and submits. The green Merge pull request button becomes active. Click it and Confirm merge. Issue #1 closes automatically.
Step 8: Clean up (both)
💡 Should I delete the branch? Where? Yes, the work is safely in
mainnow. But a branch can exist in three forms, and each is cleaned up separately:
Form Where it lives Remove it with feature/1-about-pageon GitHubGitHub The Delete branch button on the merged PR feature/1-about-page, a local branchA PC where someone worked on it git branch -d feature/1-about-pageorigin/feature/1-about-pageEach PC’s memory of GitHub git fetch --prune
On GitHub: click Delete branch on the merged PR. To have this done automatically for every future PR, turn on Settings → General → Automatically delete head branches.
Sam (PC 2), who has the local branch:
git switch main # you can't delete the branch you're on
git pull # bring the merged work into local main
git branch -d feature/1-about-page
git fetch --prune
Alex (PC 1), who never had a local copy of the branch:
git switch main
git pull
git fetch --prune
Some points I found confusing at first:
git fetch --prunedoesn’t delete local branches. It only removesorigin/...notes for branches that no longer exist on GitHub. Alex’s PC seemed to “lose the branch” with--prune, but Alex only ever had theorigin/...note, never a real local branch.git branch -ddoesn’t remove theorigin/...note. You need both commands, or run this once per PC so every fetch and pull prunes automatically:git config --global fetch.prune true-dvs-D:-drefuses to delete a branch Git thinks isn’t merged. After a Squash and merge or Rebase and merge, Git can’t tell that it was merged (GitHub created new commits). Check that the PR really was merged, then use-Dto force it.- Git never deletes local branches automatically, because they might hold work you haven’t pushed.
Check with:
git branch -a # local and remote-tracking branches together
✅ Checkpoint
- Issue #1 is closed and linked to the merged PR.
about.htmlis inmain, on both PCs.git branch -ashows onlymainandremotes/origin/main.
Scenario 2: Staying in sync with the team
The story
Sam changes the contact page and it’s merged. Alex hasn’t pulled since yesterday and starts new work directly on an out-of-date main.
What you’ll learn
fetchvspull- Rescuing uncommitted work started on the wrong branch (
stash) - Rescuing a commit made on
mainby mistake - The habit that prevents both problems
In this scenario, Alex and Sam deliberately edit different files, so there are no conflicts. Conflicts come in Scenario 3.
Part A: main moves on without Alex knowing
Sam (PC 2) makes a small change through the normal flow:
git switch main
git pull
git switch -c fix/contact-wording
In contact.html, change We would love to hear from you! to We usually reply within 24 hours.
git add contact.html
git commit -m "Clarify contact page response time"
git push -u origin fix/contact-wording
Sam opens a PR, Alex approves it on GitHub, and it’s merged.
⚠️ Alex doesn’t pull. Alex’s local main is now behind GitHub, and Alex doesn’t know it.
Part B: Checking what changed, without touching your files
Alex (PC 1):
git status
It says “Your branch is up to date with ‘origin/main’”, which is wrong. Remember idea #2: origin/main is only your PC’s memory of GitHub, and that memory is old.
git fetch
git status
Now it says “Your branch is behind ‘origin/main’ by 2 commits”: Sam’s commit plus the PR’s merge commit.
fetch downloaded the news without changing any of Alex’s files. Alex can look before deciding:
git log --oneline main..origin/main # commits on GitHub that I don't have
git diff main origin/main # the actual line changes
| Command | What it does | Changes your files? |
|---|---|---|
git fetch | Downloads new commits and updates origin/... | ❌ No |
git pull | fetch + merges origin/main into your current branch | ✅ Yes |
Part C: Uncommitted work on an old main
Alex, still on the old main, starts a new task without creating a branch, adding a footer inside <body> in index.html:
<footer>
<p>© 2026 My Website</p>
</footer>
Alex saves but doesn’t commit, then realises: “I’m on main, and it’s out of date.”
The fix:
git stash # 1. set the uncommitted changes aside; the folder is clean again
git pull # 2. update main (Sam's change arrives)
git switch -c feature/footer # 3. create the proper branch from the new main
git stash pop # 4. bring the footer changes back
git stash list between steps 1 and 4 shows the work is saved, not lost. After step 4, Alex commits, pushes and opens a PR as usual.
💡 Couldn’t Alex just run
git switch -c feature/footerwithout stashing? Yes. Uncommitted changes travel with you when you switch branches (more on this in Scenario 5). But the new branch would start from the oldmain. Pulling first means your branch starts from the latest code.
Part D: A commit made on main by mistake
This is the most common version of the mistake, and it’s what happened to me with .gitignore in Scenario 0.
Setup: Alex runs git pull to catch up. Then Sam merges another small PR, e.g. adding a phone number to contact.html, and Alex doesn’t pull again.
Alex changes the <title> in index.html to Home - My Website and commits on main:
git add index.html
git commit -m "Improve homepage title"
git push # ❌ rejected
Alex’s local main now has a commit GitHub doesn’t have, and GitHub’s main has a commit Alex doesn’t have. They have diverged.
The fix:
git switch -c feature/page-title # 1. the new branch keeps Alex's commit
git fetch
git branch -f main origin/main # 2. move local main back to GitHub's version
git merge origin/main # 3. bring Sam's new work into the feature branch
git push -u origin feature/page-title
Then Alex opens a PR as usual.
💡 Why must the branch be created before resetting
main? Remember idea #3: a branch is only a label pointing at a commit. A commit stays visible only while some label points to it.Before the fix, only
mainpoints at Alex’s commitX:A ── X ← main (Alex's local) \ S ← origin/main (Sam's commit on GitHub)Step 1 (
git switch -c feature/page-title) sticks a second label onX. Nothing is copied:A ── X ← main, feature/page-title \ S ← origin/mainStep 2 (
git branch -f main origin/main) moves only themainlabel:A ── X ← feature/page-title ✅ X is still reachable \ S ← main, origin/mainIn the wrong order (reset
mainfirst),Xis left with no label at all. It disappears fromgit log, from VS Code and from every branch, and Git eventually deletes it. It isn’t immediately destroyed, and Scenario 6 shows how to get it back withgit reflog, but it’s much easier not to lose it in the first place.Commits aren’t lost, labels are. Always put a new label on your commit before moving the old one.
Part E: The habit
Every time you start a new task:
git switch main
git pull
git switch -c <type>/<issue-number>-<short-name>
If git status ever shows commits on main that aren’t on GitHub, stop and use Part D.
✅ Checkpoint
- Both PCs show the same history after
git switch mainandgit pull. - You can explain fetch vs pull, what stash does, and why the branch comes before the reset.
Scenario 3: The merge conflict
The story
Two tasks are worked on at the same time:
- Alex: “Rebrand: the heading should say Welcome to Acme Corp.”
- Sam: “Add a navigation bar above the heading, and give the heading a CSS class for styling.”
Both touch the same <h1> line in index.html.
What you’ll learn
- Why two PRs can each look fine, and still clash
- How to read conflict markers
- How to combine two people’s changes correctly
- Three ways to resolve: merge locally, rebase locally, and on GitHub
💡 When does Git produce a conflict? Only when two branches change the same lines (or lines right next to each other) in the same file. Changes to different files, or to distant parts of a file, are merged automatically. That’s why Scenario 2 had no conflicts.
Part A: Both start from the same main
Create two issues on GitHub. Then both PCs:
git switch main
git pull
Alex: git switch -c feature/rebrand-heading
Sam: git switch -c feature/navbar
Part B: Make overlapping changes
Alex changes the heading to:
<h1>Welcome to Acme Corp</h1>
Sam replaces the heading line with:
<nav>
<a href="index.html">Home</a> |
<a href="about.html">About</a> |
<a href="contact.html">Contact</a>
</nav>
<h1 class="page-title">Welcome to My Website</h1>
Both commit, push and open a PR:
git add index.html
git commit -m "<your message>"
git push -u origin <your-branch>
Look at both PRs: each says ✅ “This branch has no conflicts with the base branch.” That’s because each PR is compared only against the current main, not against the other PR.
Part C: The first merge creates the conflict
Sam approves Alex’s PR and it’s merged. Now refresh Sam’s PR:
⚠️ This branch has conflicts that must be resolved
The merge button is disabled.
Whose fault is it? Nobody’s. Two people changed the same line for different reasons, and Git can’t know which meaning is right. Only a human can decide.
Part D: Resolve locally with merge
Sam (PC 2):
git switch feature/navbar
git fetch
git merge origin/main
CONFLICT (content): Merge conflict in index.html
Automatic merge failed; fix conflicts and then commit the result.
git status shows index.html as both modified. Git is paused in the middle of the merge, waiting for Sam.
Open index.html:
<body>
<<<<<<< HEAD
<nav>
<a href="index.html">Home</a> |
<a href="about.html">About</a> |
<a href="contact.html">Contact</a>
</nav>
<h1 class="page-title">Welcome to My Website</h1>
=======
<h1>Welcome to Acme Corp</h1>
>>>>>>> origin/main
<p>This is a simple HTML page.</p>
| Marker | Meaning |
|---|---|
<<<<<<< HEAD to ======= | Your version (the branch you’re on: Sam’s) |
======= to >>>>>>> origin/main | Their version (what you’re merging in: Alex’s) |
VS Code shows buttons above the conflict: Accept Current Change, Accept Incoming Change, Accept Both Changes. There’s also Resolve in Merge Editor, which opens a three-pane view.
The important lesson is that the simple buttons are often wrong:
- Accept Current loses Alex’s rebrand.
- Accept Incoming loses Sam’s nav bar.
- Accept Both stacks both versions and produces two
<h1>headings.
The correct result combines what each person meant:
<body>
<nav>
<a href="index.html">Home</a> |
<a href="about.html">About</a> |
<a href="contact.html">Contact</a>
</nav>
<h1 class="page-title">Welcome to Acme Corp</h1>
<p>This is a simple HTML page.</p>
💡 What about “Accept Combination” in VS Code’s merge editor? It’s a smarter option that tries to merge both sides line by line. When the two changes touch different parts of the conflict, as with Sam adding lines above the heading while Alex edited the heading text, it often gets it right. It’s still a guess. When both sides changed the same line, it can keep both versions, just like “Accept Both”. Any button is fine, as long as you check the result. Real code can combine cleanly and still be wrong together.
Finish the merge:
git grep -n "<<<<<<<" # should print nothing, so no markers are left
git add index.html # "add" means "this conflict is resolved"
git commit # Git proposes a merge message; accept it
git push
If you used VS Code’s merge editor, Complete Merge does the git add for you. Also open the page in a browser: a file can be free of markers and still broken.
On GitHub, the conflict warning is gone. Alex reviews again, paying attention to the resolution (“Did my rebrand survive?”), approves, and it’s merged.
💡 Escape hatch: if the resolution goes wrong,
git merge --abortputs everything back to before the merge.
Part E: Resolve with rebase instead
Repeat with a new pair of changes to the footer:
- Alex:
© 2026 Acme Corp - Sam:
© 2026 My Website. All rights reserved.
Merge Alex’s PR first. Then Sam:
git fetch
git rebase origin/main
Resolve the conflict the same way, then:
git add index.html
git rebase --continue # NOT git commit
git push --force-with-lease
What rebase does: it takes Sam’s commits off, moves the branch to the tip of the new main, and replays Sam’s commits on top, as if Sam had started working after Alex’s change was merged. The history becomes a straight line, with no merge commit.
Why a force push? Replaying creates new commits with new IDs, so GitHub’s copy of Sam’s branch no longer matches, and a normal push is rejected. --force-with-lease replaces it, but refuses if someone else pushed to the branch in the meantime. The ruleset only blocks force pushes on main, so this is allowed on a feature branch.
⚠️ The labels flip during a rebase. During a merge, “Current” is your branch. During a rebase, “Current” is
main’s version and “Incoming” is your own commit being replayed. It confuses everyone the first time.
| Merge | Rebase | |
|---|---|---|
| History | Keeps everything and adds a merge commit | Straight line |
| Rewrites commits? | No | Yes, so it needs a force push |
| Safe on a branch others use? | ✅ Always | ⚠️ Only on branches nobody else works on |
| Good for beginners | ✅ Start here | Once you’re comfortable |
💡 Escape hatch:
git rebase --abort.
Part F: Resolve on GitHub
For small conflicts you don’t even need your PC. Do one more round, e.g. both change the <title>, and merge Alex’s PR first. Then Sam, on GitHub:
- On the PR, click Resolve conflicts.
- Edit the file in the browser. The same markers appear.
- Click Mark as resolved, then Commit merge.
⚠️ GitHub just created a commit on Sam’s branch, so Sam must git pull locally before continuing. This button only appears for simple conflicts; anything bigger must be resolved locally.
How teams reduce conflicts
- Keep PRs small and short-lived.
- Update your branch from
mainoften. - Talk: “I’m reworking the header today.”
✅ Checkpoint
index.htmlonmainhas the nav bar,class="page-title", “Acme Corp”, the agreed footer, and no leftover markers.- You can explain
<<<<<<<,=======,>>>>>>>, and why “Accept Both” isn’t always right.
Scenario 4: Two people, one branch: the rejected push
The story
Until now, each person had their own branch. But sometimes two people work on the same branch: pair programming, taking over a colleague’s task, or helping finish a PR. The task is “Add a contact form”: Alex does the form fields and Sam does the page text and the submit button.
What you’ll learn
- Why a push gets rejected with “fetch first”
git pull(merge) vsgit pull --rebase- Resolving a conflict during a rebase
- Why
git push --forceis dangerous on a shared branch
Part A: Share one branch
Alex (PC 1) creates the branch with an empty form in contact.html, below the paragraphs:
git switch main
git pull
git switch -c feature/contact-form
<form>
</form>
git add contact.html
git commit -m "Add empty contact form"
git push -u origin feature/contact-form
Sam (PC 2) joins Alex’s branch:
git switch main
git pull # this also downloads the new branch
git switch feature/contact-form # no -c!
git branch -vv # must show [origin/feature/contact-form]
⚠️
git switchvsgit switch -cmakes all the difference here.
Command What it does git switch feature/contact-formFinds the branch on GitHub, creates a local copy linked to it, and switches to it ✅ git switch -c feature/contact-formCreates a brand-new, unrelated branch that just happens to have the same name ❌ I made exactly this mistake. With
-c, my branch wasn’t connected to my teammate’s.git pulldid nothing, because there was no link, and my branch even started from the wrong place.git branch -vvshows the link in square brackets. If there’s nothing in brackets, your branch isn’t tracking anything.If it happens to you: rename your branch out of the way (
git branch -m feature/contact-form my-old-attempt), then rungit switch feature/contact-formwithout-c.
Both PCs are now on the same branch, at the same commit.
Part B: The rejected push
Both edit different lines of contact.html, so there’s no conflict yet.
Alex adds fields inside the form, commits and pushes first:
<form>
<label>Name <input type="text" name="name"></label>
<label>Email <input type="email" name="email"></label>
</form>
git commit -am "Add name and email fields"
git push # ✅
Sam, without pulling, changes the <title> to Contact - My Website:
git commit -am "Improve contact page title"
git push # ❌
! [rejected] feature/contact-form -> feature/contact-form (fetch first)
error: failed to push some refs
hint: Updates were rejected because the remote contains work that you do not have locally.
Why? Each side has a commit the other doesn’t:
A1 ← GitHub (Alex's commit)
/
... ── F
\
S1 ← Sam's local branch
A push is only allowed to move a branch forward (a “fast-forward”). If GitHub accepted Sam’s push, Alex’s A1 would disappear from the branch. Git refuses rather than lose someone’s work.
This rejection is different from the one in Scenario 0. That one came from the ruleset (no pushing to main). This one comes from Git itself and can happen on any branch.
💡 Trying to reproduce this and it isn’t happening? Check two things:
- Use
git pushin the terminal, not VS Code’s “Sync Changes” button. That button pulls and pushes in one go, so it silently fixes the problem before you ever see it.- Don’t pull between Alex’s push and Sam’s push. VS Code’s background fetch is harmless, because fetch doesn’t change your branch. A pull brings Alex’s commit in, and then Sam’s push succeeds.
Part C: Fix it with git pull (merge)
Sam:
git pull
Newer Git versions may stop with:
fatal: Need to specify how to reconcile divergent branches.
Git is asking merge or rebase? For now, choose merge:
git pull --no-rebase
git push # ✅
Look at git log --oneline --graph:
* M Merge branch 'feature/contact-form' of https://github.com/...
|\
| * A1 Add name and email fields
* | S1 Improve contact page title
|/
* F Add empty contact form
It works, but the merge commit contains no real work. It only records that two people pushed at about the same time. On a busy shared branch these pile up and make history hard to read.
Part D: Fix it with git pull --rebase (the usual habit)
Swap roles and repeat with different lines:
- Sam adds a submit button inside the form and pushes first ✅
<button type="submit">Send</button> - Alex changes the paragraph text, commits and pushes ❌
Alex:
git pull --rebase
git push # ✅
--rebase sets Alex’s unpushed commit aside, updates the branch to match GitHub, and replays Alex’s commit on top. Sam’s change is now in Alex’s files, Alex’s own change is kept, and the history stays a straight line with no merge commit.
💡 I ran
git pull origin feature/contact-form --rebase. Is that different? It does the same thing, but names the remote and branch explicitly, so it also works when your branch isn’t linked to a remote one. Ifgit branch -vvshows the link, plaingit pull --rebaseis enough.
💡 Scenario 3 warned about rebasing. Why is this safe? Alex only rewrites their own commit that hasn’t been pushed yet. Nobody else has it, so nobody is affected, and no force push is needed.
To make rebase the default for every git pull (on both PCs):
git config --global pull.rebase true
Part E: A conflict during pull --rebase
Now both edit the same line: Alex changes the button text to Send message, Sam to Submit. Sam pushes first; Alex’s push is rejected.
Alex:
git pull --rebase
CONFLICT (content): Merge conflict in contact.html
error: could not apply a1b2c3d... Change button text
Open contact.html:
<<<<<<< HEAD
<button type="submit">Submit</button>
=======
<button type="submit">Send message</button>
>>>>>>> a1b2c3d (Change button text)
Remember the flipped labels:
| VS Code label | During a merge | During a rebase |
|---|---|---|
Current (HEAD) | Your branch | Sam’s commit, already on GitHub |
| Incoming | The other branch | Your own commit being replayed |
When both sides changed the same line, no button can decide for you. Choose one wording, or write a new one together; that conversation is the real point of the exercise. In VS Code’s merge editor you can type directly in the Result pane.
Then finish the rebase:
git add contact.html # or click "Complete Merge" in the merge editor
git rebase --continue # NOT git commit
git push
💡 Why is
git rebase --continueneeded? Staging the file only tells Git “this conflict is fixed.” The rebase is still paused.--continuecreates your replayed commit, replays any other commits waiting, and ends the rebase. Usegit statusto check: if it still says “rebase in progress”, you’re not done. In VS Code, the Continue button in the Source Control panel does the same; don’t click Commit there.
💡 A strange editor opened after
--continue! Git wants you to confirm the commit message, and the default editor is often Vim. Type:wqand press Enter to save and exit. To use VS Code for this from now on:git config --global core.editor "code --wait"
| Escape hatch | Use it when |
|---|---|
git rebase --abort | You want to undo everything and go back to before the pull |
git rebase --skip | You want to drop your own commit and keep only the remote version. Your change is gone, so use it carefully. |
Part F: Why --force is dangerous (a demonstration)
Try this once so you’ve seen it happen. Alex commits and pushes. Sam commits something else, gets rejected, and, instead of pulling, runs:
git push --force
It succeeds. Open the branch on GitHub: Alex’s commit is gone. Sam’s version replaced it.
Alex can still recover, because the commit is on Alex’s PC (git pull --rebase, then git push). But if Alex had deleted the branch, or never noticed, the work would be lost for the team.
Now repeat the setup, but Sam uses:
git push --force-with-lease
! [rejected] feature/contact-form (stale info)
--force-with-lease means “overwrite only if GitHub still has what I last saw.” Alex pushed something Sam hasn’t seen, so the push is refused.
⚠️ One catch: VS Code fetches in the background. If it already downloaded Alex’s commit, Git counts it as “seen”, and
--force-with-leasewould overwrite it anyway. The fully safe form is:git push --force-with-lease --force-if-includes
| Situation | Force push? |
|---|---|
main | ❌ Never (the ruleset blocks it anyway) |
| A branch others work on | ❌ No. Use git pull --rebase and push normally. |
| Your own branch, after a rebase | ✅ Yes, with --force-with-lease |
✅ Checkpoint
- The contact form is merged into
mainthrough a PR. - You can explain: why a push is rejected, merge-pull vs rebase-pull, what
git rebase --continuedoes, and why--forceis dangerous on a shared branch.
Scenario 5: The production hotfix
The story
Version 1.0 of the website is live. Sam is in the middle of a new feature when a customer reports that the contact email on the live site is wrong. Sam has to drop everything, fix it, release a new version, and then continue with the feature.
What you’ll learn
- Tags and releases
- Setting unfinished work aside with
stash, and why that’s necessary - The hotfix flow
- Bringing the fix into your feature branch
Part A: Release v1.0 (Alex)
The team decides the current main is version 1.0. A tag is a permanent name for one specific commit.
git switch main
git pull
git tag -a v1.0 -m "First public release"
git push origin v1.0
-acreates an annotated tag, which records who tagged it, when, and a message. Use it for releases.- Tags aren’t pushed by
git push. You push them explicitly, as above, or all at once withgit push --tags. - The ruleset only protects branches, so the tag push works without a PR.
Sam gets the tag with git fetch --tags, and git tag lists it.
💡 After pushing the tag, GitHub shows it with a zip file. Did I upload something? No. GitHub automatically offers Source code (zip) and (tar.gz) downloads for every tag: a snapshot of the files at that version, without the Git history. They’re generated on the fly, and you can’t remove them.
Now turn the tag into a Release: Releases → Draft a new release → choose tag v1.0 → title v1.0 – First public release → click Generate release notes (GitHub lists the merged PRs) → Publish release.
💡 Is a release just a record? For a small site, mostly yes: a readable page with notes on top of the tag. Deleting a release doesn’t touch the tag or the code. In bigger projects, releases also distribute files (installers and apps attached as assets), notify people watching the repo, and often trigger automation, e.g. deploying to production when a release is published. The tag is the Git part (“this commit is v1.0”); the release is the GitHub part (“here’s what’s in it and what changed”).
Version numbers usually follow semantic versioning, MAJOR.MINOR.PATCH:
| Change | Example |
|---|---|
| PATCH: a bug fix | v1.0 → v1.0.1 |
| MINOR: a new feature | v1.0.1 → v1.1.0 |
| MAJOR: a breaking change or redesign | v1.1.0 → v2.0.0 |
Part B: Sam is halfway through a feature
Sam (PC 2):
git switch main
git pull
git switch -c feature/testimonials
Sam adds a testimonials section to index.html and creates a new file, testimonials.css. Nothing is committed, because the work is only half done.
Part C: The urgent bug
Alex opens an issue with the label bug:
The contact email on the live site is wrong. It says
contact@mywebsite.com, but emails go tosupport@mywebsite.com. Customers can’t reach us, so please fix it urgently.
Step 1: set the unfinished work aside (Sam).
git stash push -u -m "WIP testimonials"
git status # "working tree clean"
git stash list # stash@{0}: On feature/testimonials: WIP testimonials
(WIP = work in progress.)
⚠️ The
-umatters. A plaingit stashonly saves changes to files Git already tracks. The new filetestimonials.csswould be left behind in your folder and could slip into the hotfix.-ualso includes untracked (new) files.
💡 Why stash at all? If I switch to a new branch, won’t my changes stay behind on the feature branch? No, and this surprised me. Uncommitted changes don’t belong to any branch. They live in your working folder, and when you switch branches, they come with you.
Situation when you run git switchWhat happens The file you changed is identical in both branches (apart from your edit) ✅ The switch works, and your changes come along The file you changed is different in the target branch ❌ Git refuses to switch: “Your local changes would be overwritten… commit or stash them” New, untracked files ✅ Always come along So without stashing, your half-finished testimonials would follow you into the hotfix branch. One careless
git add -Awould ship unfinished work to production, and even if you’re careful, you’d be testing the hotfix together with unfinished code.Try it: edit a file on a feature branch without committing,
git switch main, and rungit status. Your edit is there, onmain.Committed changes are different: they stay on their branch.
💡 Isn’t a commit the same as a stash? Both clear your working folder so you can switch safely, but:
WIP commit Stash Saved where On the branch, as a normal commit In a separate stash list, on no branch Can be pushed as a backup ✅ ❌ Only on this PC Includes new files ✅ with git add -AOnly with -uEasy to forget No, it’s in git logYes, stashes pile up quietly Clutters history Yes, unless you undo it later No To undo a WIP commit when you return, keeping the changes:
git reset --soft HEAD~1(only if you haven’t pushed it). Short interruption on one PC → stash. Long interruption, or you might continue on another PC → WIP commit and push.On GitHub, the equivalent for a whole PR is a Draft pull request: visible to the team, but it can’t be merged until you click Ready for review.
Step 2: start the hotfix from the latest main (Sam).
git switch main
git pull
git switch -c hotfix/contact-email
Step 3: fix only the bug. In contact.html, change the email to support@mywebsite.com.
git commit -am "Fix contact email address"
git push -u origin hotfix/contact-email
Step 4: open a PR titled “Hotfix: correct contact email”, with Fixes #<issue number> in the description. A hotfix PR should be tiny, one fix and nothing else, so it can be reviewed in a minute. Alex approves and merges, and the issue closes itself.
Part D: Release v1.0.1 (Alex)
git switch main
git pull
git tag -a v1.0.1 -m "Fix contact email"
git push origin v1.0.1
Publish a GitHub Release for v1.0.1; its generated notes list only the hotfix.
Useful commands:
git tag # v1.0, v1.0.1
git show v1.0.1 # who tagged it, when, and which commit
git diff v1.0 v1.0.1 # exactly what changed between the two releases
Part E: Back to the feature (Sam)
💡 I pulled
mainand popped my stash, but the hotfix isn’t in my feature branch. Why? Pullingmainonly updatesmain. Every branch changes only when you change it.feature/testimonialswas created before the hotfix existed, andstash poponly restores your unfinished edits. It doesn’t update the branch.H (hotfix) ← main (pulled, up to date) / ... ── B \ (your WIP) ← feature/testimonials, still based on BTo bring the hotfix in, you merge
maininto your feature branch.
The cleanest order is to merge first, then pop, because Git may refuse to merge while you have uncommitted changes:
git switch feature/testimonials
git merge main # main is already up to date, since you pulled it in Step 2
git stash pop # the WIP returns, including testimonials.css
git stash list # empty: the stash was used up
Why update the feature branch at all? The hotfix is already in main, so strictly you don’t have to. But it means you test your feature against what’s really live, and if your feature touched the same lines, you find the conflict now, on your own branch, instead of later in the PR.
Sam finishes the feature and opens a PR. It becomes part of v1.1.0 (a new feature, so the MINOR number goes up).
Part F: What if main isn’t ready to release?
Imagine main already contains an unfinished feature when the bug appears. A hotfix branched from main would ship that feature too. The solution is to branch from the released tag instead:
git switch -c hotfix/1.0.2 v1.0.1 # start from the released code
# fix, commit, tag v1.0.2 from this branch, release it
Then copy the fix into main as well, e.g. with a PR from the hotfix branch. Larger teams keep dedicated release/... branches for this. For a small team that releases from main, the simple flow in Parts C and D is enough.
✅ Checkpoint
git tagshowsv1.0andv1.0.1, and GitHub’s Releases page shows both.git diff v1.0 v1.0.1shows only the email change.- The feature branch contains both the testimonials work and the hotfix.
- You can explain: why
stash -u, why the hotfix starts frommainand not from the feature branch, and why tags are pushed separately.
Scenario 6: Oops: recovering from mistakes
What you’ll learn
Everyone makes mistakes with Git. What matters is choosing the right way to undo them, and one question decides it:
| Has the mistake been pushed? | How to fix it | Why |
|---|---|---|
| ❌ No, it’s only on my PC | Rewrite history (amend, reset) | Nobody else has it, so it’s safe |
| ✅ Yes, it’s on GitHub | Add a new commit that undoes it (revert) | Others may already have pulled it. Rewriting would break their copies. |
Part A: A bad change reached main → git revert
The story: Sam’s PR contains a typo in a link, <a href="abuot.html">About</a>. Nobody notices in review, it’s merged, and the live site has a broken link.
The wrong fix: git reset --hard HEAD~1 on main, then git push --force. The ruleset blocks it anyway. Even without the ruleset, anyone who already pulled would now have a history GitHub no longer has, and their next push would bring the bad commit back, or cause a mess.
The right fix: revert. git revert creates a new commit that does the exact opposite of the bad one. History only moves forward: the mistake stays in the record, followed by its correction.
Option 1: GitHub’s button (easiest). Open the merged PR and click Revert. GitHub creates a branch and opens a new PR that undoes the change. Review it, merge it, and everyone pulls.
Option 2: the command line.
git switch main
git pull
git switch -c revert/broken-link
git log --oneline --merges -n 5 # find the bad PR's merge commit, e.g. a1b2c3d
git show a1b2c3d --stat # confirm it's the right one
git revert -m 1 a1b2c3d
git push -u origin revert/broken-link
Then open a PR and merge it. The revert goes through review like any other change.
💡 What does
-m 1mean? A merge commit has two parents: the oldmainand the PR’s branch. When reverting a merge, Git needs to know which side to keep.-m 1means “keep parent 1 (main) and undo what the PR brought in.” Onmainit’s almost always1.
- Reverting a normal commit (not a merge)? Just
git revert <hash>, without-m.- If the PR was merged with Squash and merge, there’s no merge commit. Revert the single squashed commit without
-m.- Forgot
-mon a merge commit? Git stops with “is a merge but no -m option was given”, so you can’t get it wrong silently.
⚠️ Gotcha: once a merged branch has been reverted, re-merging the same branch won’t bring the changes back, because Git considers those commits already merged. Make the corrected version on a new branch from the current
main.
Part B: A secret was committed and pushed
The story: Sam creates config.js with an API key (use a fake one in the lab!), commits it, and it’s merged:
const API_KEY = "demo-key-12345-NOT-REAL";
Alex notices: “There’s an API key in our public repo!”
The obvious fix that isn’t enough: delete the file in a new PR.
git rm config.js
git commit -m "Remove config.js"
Then check:
git log --oneline -- config.js # the commits that touched this file
git show <commit-that-added-it> # the key is fully visible
Deleting a file removes it from now, not from history. Anyone can open that old commit on GitHub and read the key.
The real fix, in priority order:
| # | Action | Why |
|---|---|---|
| 1 | Revoke or rotate the key at the provider (AWS, Stripe, etc.) | ✅ The only fix that actually works. Assume the key was copied the moment it was pushed; automated bots scan GitHub for keys within minutes. |
| 2 | Remove the file and add it to .gitignore | Prevents it happening again |
| 3 | (Optional) Rewrite history with tools like git filter-repo and force push | Disruptive (everyone must re-clone), and copies that already exist elsewhere keep the key. It never replaces step 1. |
Prevention:
- Add
config.jsto.gitignore. Our.gitignorealready ignored.envandconfig.local.js, but notconfig.js. Ignore rules only protect you from the file names you thought of. - Commit a template instead, e.g.
config.example.jswithconst API_KEY = "your-key-here";. Each developer copies it toconfig.jslocally. - In Settings → Code security, turn on Secret scanning and Push protection. Push protection blocks pushes containing real-looking keys (AWS, Stripe, GitHub tokens…). A made-up string like the one above isn’t recognised, which is a good reminder that tools only catch known key formats.
Part C: Local mistakes (not pushed yet)
For mistakes that are only on your PC, rewriting is safe. Practise on a throwaway branch (git switch -c practice/oops).
Every command below moves changes between the three areas from idea #5:
Working folder ──git add──► Staging area ──git commit──► Commits
◄─git restore─ ◄──reset --soft──
◄─────── git restore --staged ───┘
◄──────────────── reset --hard ─────────────── (⚠️ discards)
| Mistake | Fix | What it does |
|---|---|---|
| Typo in the last commit message | git commit --amend -m "Correct message" | Replaces the last commit |
| Forgot a file in the last commit | git add forgotten.html, then git commit --amend --no-edit | Adds it to the last commit, keeping the message |
| Committed too early | git reset --soft HEAD~1 | Undoes the commit; the changes stay staged |
| Staged a file by mistake | git restore --staged file.html | Unstages it; your edits stay |
| Messed up a file, want the last committed version | git restore file.html | ⚠️ Discards your uncommitted edits to that file |
| Deleted a file (not committed) | git restore about.html | Brings it back |
| Deleted a file and committed the deletion | git restore --source=HEAD~1 about.html | Gets it back from the previous commit |
| Want to throw the last commit away completely | git reset --hard HEAD~1 | ⚠️ Deletes the commit and its changes |
What each reset keeps:
| Command | Last commit | Staging | Your files |
|---|---|---|---|
git reset --soft HEAD~1 | removed | changes kept staged | kept |
git reset HEAD~1 | removed | unstaged | kept |
git reset --hard HEAD~1 | removed | cleared | ⚠️ discarded |
After each exercise, run git status and git log --oneline -n 3 to see exactly what changed.
⚠️ Amend and reset rewrite history. After a push, fixing with them means force pushing. That’s acceptable on your own feature branch (with
--force-with-lease), but never onmainor a shared branch. Userevertthere.
Part D: “I lost my commit!” → git reflog
git reflog is Git’s diary: it records every place your branch pointer (HEAD) has been, including commits no branch points to any more.
Case 1: a reset --hard went too far.
git reset --hard HEAD~2 # oops, one commit too many
git reflog
e4f5a6b HEAD@{0}: reset: moving to HEAD~2
9c8d7e6 HEAD@{1}: commit: Add testimonials ← the state before the mistake
git reset --hard 9c8d7e6 # back to the state before the mistake
Case 2: a branch with unmerged work was deleted with -D.
git branch -D practice/oops
# Deleted branch practice/oops (was 9c8d7e6).
git branch practice/oops 9c8d7e6 # recreate it from that hash
Case 3: the Scenario 2 mistake in the wrong order (resetting main before creating the branch). Find the “lost” commit in git reflog, then put a label back on it with git branch <name> <hash>.
Limits of reflog:
- It’s local to one PC. Sam’s reflog can’t recover Alex’s commits.
- It only knows about committed work. Uncommitted edits lost to
reset --hardorrestoreare gone for good, which is another reason to commit (or stash) often. - Entries expire after a while (90 days by default, 30 for unreachable commits).
✅ Checkpoint
maincontains the bad commit and its revert, and the link works.config.jsis ignored, andconfig.example.jsis committed.- You recovered
practice/oopsafter deleting it. - You can answer: pushed or not pushed, and why does it matter? and why isn’t deleting a secret file enough?
Scenario 7: Team standards: templates and code owners
The story
The team is growing, and some things keep going wrong: PR descriptions are empty, bug reports lack details, and the wrong person gets asked to review. Alex decides the repo itself should guide everyone.
As always, Alex makes these changes through a PR:
git switch main
git pull
git switch -c chore/team-standards
Files in a .github/ folder are special: GitHub reads them to configure the repository.
Part A: Pull request template
Create .github/pull_request_template.md:
## What does this PR do?
<!-- One or two sentences -->
## Related issue
Fixes #
## How was it tested?
- [ ] Opened the changed pages in a browser
- [ ] Links work
## Checklist
- [ ] Branch is up to date with main
- [ ] No secrets or config files committed
Every new PR description is now pre-filled with this text. The - [ ] lines become clickable checkboxes, and the PR list shows progress like “2 of 4 tasks”.
Part B: Issue templates
Create .github/ISSUE_TEMPLATE/bug_report.md:
---
name: Bug report
about: Something is broken on the site
labels: bug
---
**What's wrong?**
**Which page?**
**Steps to reproduce**
1.
**Expected vs actual**
and .github/ISSUE_TEMPLATE/feature_request.md:
---
name: Feature request
about: Suggest a new page or improvement
labels: enhancement
---
**What should be added?**
**Why is it useful?**
Now New issue asks “Bug report or Feature request?”, pre-fills the form, and applies the label automatically.
Part C: CODEOWNERS
Create .github/CODEOWNERS (use your real GitHub usernames):
# Default owners for everything
* @alex-account @sam-account
# The homepage belongs to Alex; the contact page to Sam
index.html @alex-account
contact.html @sam-account
# Only Alex handles repository configuration
/.github/ @alex-account
- For each file, the last matching line wins, so
index.htmlis owned by Alex only. - When a PR changes a file, GitHub automatically requests a review from its owner.
- Owners need write access to the repository.
Commit, push, open the PR, and have Sam review and merge it:
git add .github
git commit -m "Add PR and issue templates and CODEOWNERS"
git push -u origin chore/team-standards
Try it: Sam opens a PR that changes index.html. The description is pre-filled from the template, and Alex is added as reviewer automatically.
Enforcing it (optional): in the protect-main ruleset, under Require a pull request before merging, tick Require review from Code Owners.
💡 What does “Require review from Code Owners” actually mean? A PR can only be merged after an owner of the changed files approves it. Approval from anyone else doesn’t count.
- If a line lists several owners, approval from any one of them is enough.
- If the PR changes files with different owners, it needs an approval from an owner of each.
- Files with no owner only need the normal approval.
CODEOWNERS file only + “Require review from Code Owners” Owners automatically requested as reviewers ✅ ✅ Owner’s approval required to merge ❌ Anyone’s approval counts ✅
⚠️ The two-person trap. Nobody can approve their own PR. If Alex opens a PR changing
index.html, and Alex is its only owner, no code owner can approve it, so the PR is stuck. Real teams avoid this by having at least two owners per area. In a two-person lab, either list both people on every line, or use CODEOWNERS only for automatic review requests and leave enforcement off.
Part D: Useful repository settings
In Settings → General → Pull Requests:
- Automatically delete head branches: merged branches are removed from GitHub automatically.
- Allow squash merging only (untick the others): each PR becomes one clean commit on
main. History is easier to read, and reverting a PR is a simplegit revert <hash>with no-m 1. - Always suggest updating pull request branches: shows an Update branch button when a PR falls behind
main.
✅ Checkpoint
- A new PR is pre-filled with the template.
- New issue offers Bug report and Feature request, with labels.
- A PR touching
contact.htmlautomatically requests Sam as reviewer.
Wrap-up: the habits that matter
After going through all of this, I realised team Git comes down to a handful of habits.
The daily loop
git switch main
git pull # 1. start from the latest code
git switch -c feature/<issue>-<name> # 2. one branch per task
# ...work, commit often with clear messages...
git push -u origin feature/<issue>-<name> # 3. share the branch
# 4. open a PR with "Fixes #<issue>", get a review, merge
git switch main
git pull
git branch -d feature/<issue>-<name> # 5. clean up
Ten rules I’d give my past self
- Pull before you start. Most “weird” problems begin with an out-of-date
main. - Never work directly on
main. Branch first, always. - One branch per task, one PR per branch. Keep them small.
- Run
git statusbefore switching branches. If it isn’t clean, commit or stash. - Joining someone’s branch?
git switch <name>, notgit switch -c <name>. Check the link withgit branch -vv. - Rejected push? Pull (preferably
--rebase) and push again. Don’t force. - Conflicts are normal. Read both sides, combine what each person meant, and check the result in the browser.
- Pushed mistakes get
revert; local mistakes getresetoramend. - A leaked secret must be rotated. Deleting the file isn’t enough.
- Commits are hard to lose, uncommitted work is easy to lose. Commit often, and remember
git reflog.
Quick reference
| I want to… | Command |
|---|---|
| See what’s new on GitHub without changing anything | git fetch, then git log --oneline main..origin/main |
| Update my current branch | git pull (or git pull --rebase) |
| Start a task | git switch main, git pull, git switch -c <branch> |
| Work on a teammate’s branch | git switch <their-branch> |
| Check which remote branch mine is linked to | git branch -vv |
Bring main’s latest changes into my branch | git merge main (after pulling main) |
| Set work aside | git stash push -u -m "WIP", later git stash pop |
Move a commit off main onto a branch | git switch -c <branch>, then git branch -f main origin/main |
| Undo a pushed change | git revert <hash> (merge commit: -m 1) |
| Undo my last local commit, keep the changes | git reset --soft HEAD~1 |
| Find a “lost” commit | git reflog |
| Mark a release | git tag -a v1.0 -m "...", then git push origin v1.0 |
| Clean up after a merge | git branch -d <branch>, then git fetch --prune |
| See the whole picture | git log --oneline --graph --all |
If you have two computers (or a friend) and an afternoon, I strongly recommend running this lab yourself. Reading about a merge conflict is one thing; seeing your teammate’s push rejected on the other screen is what makes it stick.
In a follow-up I’d like to cover what’s not in this article: automated checks on every PR with GitHub Actions, contributing to other people’s projects through forks, and tools like cherry-pick and bisect. Stay tuned!