[SKIP TO CONTENT]
LAST_UPDATE: 30 September 2026
Go back
BY Junrong Li

> Git in a Team: A Hands-On Lab with Two Computers

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

⚠️ 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:

RoleMachineGitHub account
Alex, tech lead and owner of the repositoryPC 1Account A
Sam, developerPC 2Account 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

GitHub first, or local first?

There are two common ways to create a repository:

ApproachStepsWhen to use it
GitHub first1. 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 first1. git init and commit locally. 2. Create an empty repo on GitHub. 3. git remote add origin <url>. 4. git push -u origin mainWhen 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 .gitignore by hand? Yes. It’s a plain text file named exactly .gitignore in 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 .gitignore before your first commit. It only affects files Git isn’t tracking yet. If a file is already committed, adding it to .gitignore won’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:

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.

SettingValue
Ruleset nameprotect-main
Enforcement statusActive
Bypass listLeave empty, so the rules apply to Alex too
Target branchesAdd target → Include default branch

Tick these rules:

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 init on 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


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

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 of git 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.

Step 5: Review (Alex, on GitHub)

💡 How do you actually review a PR?

  1. Open the PR from the Pull requests tab.
  2. Go to the Files changed tab. You see every changed line: green for added, red for removed.
  3. 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.
  4. Click Review changes (or Submit review) at the top right and choose one:
OptionMeaningEffect
CommentGeneral feedbackDoesn’t count as approval
ApproveLooks goodCounts toward the required approval
Request changesMust be fixed firstBlocks merging until the reviewer approves

Alex spots two problems and comments on the lines:

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-page automatically 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:

  1. Reply to each comment (e.g. “Fixed in the latest commit”) and click Resolve conversation.
  2. 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 pull before 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 main now. But a branch can exist in three forms, and each is cleaned up separately:

FormWhere it livesRemove it with
feature/1-about-page on GitHubGitHubThe Delete branch button on the merged PR
feature/1-about-page, a local branchA PC where someone worked on itgit branch -d feature/1-about-page
origin/feature/1-about-pageEach PC’s memory of GitHubgit 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:

Check with:

git branch -a      # local and remote-tracking branches together

✅ Checkpoint


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

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
CommandWhat it doesChanges your files?
git fetchDownloads new commits and updates origin/...❌ No
git pullfetch + 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>&copy; 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/footer without 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 old main. 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 main points at Alex’s commit X:

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 on X. Nothing is copied:

A ── X          ← main, feature/page-title
 \
  S             ← origin/main

Step 2 (git branch -f main origin/main) moves only the main label:

A ── X          ← feature/page-title   ✅ X is still reachable
 \
  S             ← main, origin/main

In the wrong order (reset main first), X is left with no label at all. It disappears from git 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 with git 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


Scenario 3: The merge conflict

The story

Two tasks are worked on at the same time:

Both touch the same <h1> line in index.html.

What you’ll learn

💡 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>
MarkerMeaning
<<<<<<< HEAD to =======Your version (the branch you’re on: Sam’s)
======= to >>>>>>> origin/mainTheir 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:

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 --abort puts everything back to before the merge.

Part E: Resolve with rebase instead

Repeat with a new pair of changes to the footer:

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.

MergeRebase
HistoryKeeps everything and adds a merge commitStraight line
Rewrites commits?NoYes, so it needs a force push
Safe on a branch others use?✅ Always⚠️ Only on branches nobody else works on
Good for beginners✅ Start hereOnce 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:

  1. On the PR, click Resolve conflicts.
  2. Edit the file in the browser. The same markers appear.
  3. 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

✅ Checkpoint


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

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 switch vs git switch -c makes all the difference here.

CommandWhat 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 pull did nothing, because there was no link, and my branch even started from the wrong place. git branch -vv shows 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 run git switch feature/contact-form without -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:

  1. Use git push in 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.
  2. 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:

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. If git branch -vv shows the link, plain git pull --rebase is 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 labelDuring a mergeDuring a rebase
Current (HEAD)Your branchSam’s commit, already on GitHub
IncomingThe other branchYour 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 --continue needed? Staging the file only tells Git “this conflict is fixed.” The rebase is still paused. --continue creates your replayed commit, replays any other commits waiting, and ends the rebase. Use git status to 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 :wq and press Enter to save and exit. To use VS Code for this from now on:

git config --global core.editor "code --wait"
Escape hatchUse it when
git rebase --abortYou want to undo everything and go back to before the pull
git rebase --skipYou 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-lease would overwrite it anyway. The fully safe form is:

git push --force-with-lease --force-if-includes
SituationForce 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


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

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

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:

ChangeExample
PATCH: a bug fixv1.0 → v1.0.1
MINOR: a new featurev1.0.1 → v1.1.0
MAJOR: a breaking change or redesignv1.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 to support@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 -u matters. A plain git stash only saves changes to files Git already tracks. The new file testimonials.css would be left behind in your folder and could slip into the hotfix. -u also 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 -A would 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 run git status. Your edit is there, on main.

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 commitStash
Saved whereOn the branch, as a normal commitIn 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 -u
Easy to forgetNo, it’s in git logYes, stashes pile up quietly
Clutters historyYes, unless you undo it laterNo

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 main and popped my stash, but the hotfix isn’t in my feature branch. Why? Pulling main only updates main. Every branch changes only when you change it. feature/testimonials was created before the hotfix existed, and stash pop only 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 B

To bring the hotfix in, you merge main into 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


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 itWhy
❌ No, it’s only on my PCRewrite history (amend, reset)Nobody else has it, so it’s safe
✅ Yes, it’s on GitHubAdd 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 1 mean? A merge commit has two parents: the old main and the PR’s branch. When reverting a merge, Git needs to know which side to keep. -m 1 means “keep parent 1 (main) and undo what the PR brought in.” On main it’s almost always 1.

  • 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 -m on 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:

#ActionWhy
1Revoke 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.
2Remove the file and add it to .gitignorePrevents it happening again
3(Optional) Rewrite history with tools like git filter-repo and force pushDisruptive (everyone must re-clone), and copies that already exist elsewhere keep the key. It never replaces step 1.

Prevention:

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)
MistakeFixWhat it does
Typo in the last commit messagegit commit --amend -m "Correct message"Replaces the last commit
Forgot a file in the last commitgit add forgotten.html, then git commit --amend --no-editAdds it to the last commit, keeping the message
Committed too earlygit reset --soft HEAD~1Undoes the commit; the changes stay staged
Staged a file by mistakegit restore --staged file.htmlUnstages it; your edits stay
Messed up a file, want the last committed versiongit restore file.html⚠️ Discards your uncommitted edits to that file
Deleted a file (not committed)git restore about.htmlBrings it back
Deleted a file and committed the deletiongit restore --source=HEAD~1 about.htmlGets it back from the previous commit
Want to throw the last commit away completelygit reset --hard HEAD~1⚠️ Deletes the commit and its changes

What each reset keeps:

CommandLast commitStagingYour files
git reset --soft HEAD~1removedchanges kept stagedkept
git reset HEAD~1removedunstagedkept
git reset --hard HEAD~1removedcleared⚠️ 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 on main or a shared branch. Use revert there.

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:

✅ Checkpoint


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

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:

✅ Checkpoint


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

  1. Pull before you start. Most “weird” problems begin with an out-of-date main.
  2. Never work directly on main. Branch first, always.
  3. One branch per task, one PR per branch. Keep them small.
  4. Run git status before switching branches. If it isn’t clean, commit or stash.
  5. Joining someone’s branch? git switch <name>, not git switch -c <name>. Check the link with git branch -vv.
  6. Rejected push? Pull (preferably --rebase) and push again. Don’t force.
  7. Conflicts are normal. Read both sides, combine what each person meant, and check the result in the browser.
  8. Pushed mistakes get revert; local mistakes get reset or amend.
  9. A leaked secret must be rotated. Deleting the file isn’t enough.
  10. 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 anythinggit fetch, then git log --oneline main..origin/main
Update my current branchgit pull (or git pull --rebase)
Start a taskgit switch main, git pull, git switch -c <branch>
Work on a teammate’s branchgit switch <their-branch>
Check which remote branch mine is linked togit branch -vv
Bring main’s latest changes into my branchgit merge main (after pulling main)
Set work asidegit stash push -u -m "WIP", later git stash pop
Move a commit off main onto a branchgit switch -c <branch>, then git branch -f main origin/main
Undo a pushed changegit revert <hash> (merge commit: -m 1)
Undo my last local commit, keep the changesgit reset --soft HEAD~1
Find a “lost” commitgit reflog
Mark a releasegit tag -a v1.0 -m "...", then git push origin v1.0
Clean up after a mergegit branch -d <branch>, then git fetch --prune
See the whole picturegit 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!

TAGGED_NODES: