1

Your First Repository

Welcome to the Git Tutorial! You’ve got a code editor (top) and a real Linux terminal (bottom) on the right. Files you edit are automatically synced to the VM. Let’s get into it.

Why version control?

We’ve all been there — saving files like report_final_v2_REALLY_final.txt and praying we remember which one is actually final. Version control ends that chaos for good. It lets you:

Imagine you and a teammate are both editing the same file hero_registry.py. You add a power_up ability while they rewrite the recruit function. Without version control, whoever saves last silently overwrites the other’s work. Git solves this — it lets both changes coexist on separate branches and helps you combine them safely. We’ll see exactly how later in this tutorial.

Git is the most widely used version control system in the world. Let’s learn it by building a small Python hero registry project.

Before we start, understand Git’s core architecture — every file lives in one of three states:

┌──────────────┐     git add     ┌──────────────┐   git commit  ┌──────────────┐
│   Working    │ ──────────────▶ │   Staging    │ ────────────▶ │    Local     │
│  Directory   │                 │  Area (Index)│               │  Repository  │
└──────────────┘                 └──────────────┘               └──────────────┘
      You edit                     You review                    Permanently
     files here                  what will be in                  saved as a
                                the next snapshot                  snapshot

Think of it like posting on social media:

Task 1: Initialize a repository

Your Git identity has already been configured for you. You can verify this anytime with git config user.name.

Now create a new Git repository:

git init myproject
cd myproject

git init creates a hidden .git folder that stores all version history. You now have an empty repository!

Task 2: Explore what was created

Run this command to see the hidden .git directory:

ls -la

You should see a .git/ folder — this is where Git stores everything. Your working directory is clean and empty, ready for your first file.

2

Your First Commit

Creating and tracking files

Unlike other version control systems that track “Deltas” (changes between versions), Git takes Snapshots. Every commit is a full picture of what all your files looked like at that moment. You’ll see this in action when you make your first commit below.

Now let’s create our first Python file. A file in your working directory starts as untracked — Git doesn’t know about it yet.

Before you run: We’ve saved hero_registry.py to disk but haven’t told Git about it yet. Will git status show it as tracked or untracked? What color do you expect? Form your answer, then continue:

Task 1: Create a file and check status

The editor shows hero_registry.py — a module to track your superhero squad. It has already been saved to the VM. Now run:

git status

You should see hero_registry.py listed as an untracked file in red. Git sees the file but isn’t tracking it yet.

Reading git status output

git status is the command you’ll run most often. Learn to read its three sections:

Section heading Color Meaning
Changes to be committed Green Staged — will be in the next commit
Changes not staged for commit Red Modified tracked files — not yet staged
Untracked files Red Brand new files Git has never seen

Right now you should see the third section: hero_registry.py as an untracked file. After staging, it will move to the first section.

If the staging area feels confusing — you’re not alone. Even Git’s own designers have acknowledged that some of its concepts could be clearer (Perez De Rosso & Jackson, 2016). The two-step add/commit flow exists because it gives you fine-grained control over exactly what goes into each snapshot. That power is worth the initial learning curve.

Task 2: Stage the file

Move the file from the Working Directory to the Staging Area:

git add hero_registry.py

Now run git status again. The file should appear in green under “Changes to be committed”. It’s in the post editor, ready to publish!

Task 3: Commit the snapshot

Save this snapshot permanently to the repository:

git commit -m "Add hero registry module"

The -m flag lets you write a message describing what and why. Good commit messages help your future self (and teammates) understand the history. Your latest commit is now what Git calls HEAD — a pointer to the most recent commit on your current branch. You’ll use HEAD extensively starting in Step 7.

Run git status one more time — it should say “nothing to commit, working tree clean”. Your file is safely stored!

Self-check: In your own words, explain the difference between the Working Directory, the Staging Area, and the Repository. If you can describe the social media analogy from Step 1 without looking back, you’ve got it.

Starter files
myproject/hero_registry.py
"""Hero Registry — track your superhero squad."""

def recruit(name, power):
    """Add a new hero to the squad."""
    return {"name": name, "power": power, "status": "active"}

def retire(hero):
    """Retire a hero from active duty."""
    hero["status"] = "retired"
    return hero
3

The Edit-Stage-Commit Cycle

Modifying tracked files

Git now tracks hero_registry.py. When you edit a tracked file, Git notices the difference between what’s in your working directory and what was last committed.

Task 1: Add a power_up function

Open hero_registry.py in the editor and add this function at the bottom of the file:

def power_up(hero, multiplier):
    """Boost a hero's power level permanently."""
    hero["power"] = hero["power"] * multiplier
    return hero

Save the file (Ctrl+S), then run in the terminal:

git status

You’ll see hero_registry.py is now listed as modified (in red). The file is tracked, but your new changes haven’t been staged yet.

Task 2: See exactly what changed

Before you run: git diff compares two areas. You’ve modified hero_registry.py but haven’t staged it yet. Which two areas will it compare — working directory vs. staging area, or staging area vs. last commit? Will your new power_up function appear with a + or -?

Before staging, review your changes:

git diff

git diff compares your working directory to the staging area. Lines starting with + are additions; - are removals. This is your chance to review before committing.

Task 3: Stage and commit

Now complete the cycle:

git add hero_registry.py
git commit -m "Add power_up function to hero registry"

Task 4: Review your history

See all your commits so far:

git log

Each commit shows: a unique hash (ID), the author, date, and your message. Press q to exit the log viewer.

Self-check: You just ran git diff and saw lines marked with +. Without looking back, explain to yourself: what two things did git diff compare to produce that output? If you’re unsure, re-read the explanation above — this distinction matters in every future step.

4

Staging Strategies

Controlling what goes into a commit

The staging area lets you carefully choose exactly which changes become part of each commit. Several new files have been added to your project — run git status to see them.

Task 1: Stage files selectively

Before you run: The project now has four new files: README.md, test_heroes.py, test_registry.py, and notes.txt. You are about to stage only README.md. After git add README.md and git status, predict: which file(s) will appear green (staged), and which will remain red (unstaged)?

Stage just one specific file and check the result:

git add README.md
git status

Notice: README.md is green (staged), while the others are still red (untracked). You have precise control! You can also stage by pattern — try git add test_*.py to stage both test files at once.

Task 2: Stage everything and commit

Stage all remaining files and commit:

git add .
git commit -m "Add test files, README, and project notes"

The . means “current directory and everything in it”.

Staging reference

You now know several ways to stage:

The -am shortcut — and its hidden catch

Once files are tracked, there is a popular shortcut that collapses git add and git commit into one command:

git commit -am "Your message here"

The two flags combined:

Flag What it does
-a Automatically stages every already-tracked modified file
-m Attaches the commit message inline

-a has one strict rule: it only works on tracked files. Any brand-new file that has never been through git add is completely invisible to it.

Let’s prove this. After your commit above, modify the tracked notes.txt and create a brand-new untracked file at the same time:

echo "IDEA: add power_surge ability" >> notes.txt
echo "customer feedback output" > feedback.log
git status

You will see notes.txt as modified (red, tracked) and feedback.log as untracked (red, new). Now try the shortcut:

git commit -am "Update notes and add feedback log"

Run git status one more time. feedback.log is still untracked-a staged and committed notes.txt automatically but silently ignored the new file, even though the commit message implied it was included.

To bring feedback.log into a commit you must git add feedback.log explicitly first. This is why the full two-step flow (git addgit commit) remains the safest default whenever new files are involved.

Starter files
myproject/test_heroes.py
"""Tests for heroes."""
myproject/test_registry.py
"""Tests for registry."""
myproject/README.md
# Hero Registry
Track your superhero squad
myproject/notes.txt
TODO: add team_up
DONE: add power_up
5

Unstaging and Undoing Changes

Ctrl+Z for Git (kind of)

Accidentally staged the wrong file? Made changes you want to yeet into oblivion? Don’t panic — Git has your back.

Challenge — try before you learn: You’re about to stage a broken change by accident. Before reading ahead, think: if you needed to unstage a file (move it back from green to red in git status), what command might you try? What about discarding changes entirely? Take a guess — even a wrong guess makes the answer stick better when you see it.

Task 1: Make a change and stage it

Let’s edit a file and then undo our staging:

echo "BROKEN CODE" >> hero_registry.py

Now stage the file and confirm it is staged — use the two-step workflow you’ve practised since Step 2. You should see hero_registry.py listed in green before moving on.

You’ll see hero_registry.py is staged (green). But wait — we don’t actually want to commit “BROKEN CODE”!

Task 2: Unstage the file

Remove the file from the staging area without losing your edits:

git restore --staged hero_registry.py
git status

The file is now modified but unstaged (red again). Your edit is still in the working directory — git restore --staged just pulls it out of the post editor; it doesn’t delete anything.

Task 3: Discard working directory changes

Now let’s throw away the change entirely and restore the file to its last committed version:

git restore hero_registry.py
git status

The “BROKEN CODE” line is gone. The file matches the last commit.

Warning: git restore (without --staged) permanently discards uncommitted changes. There is no undo for this — the changes were never committed, so Git has no record of them.

Summary

Command Effect
git restore --staged <file> Unstage (remove from post editor, keep edits)
git restore <file> Discard working directory changes (permanent!)
git reset --hard Discard ALL uncommitted changes (nuclear option)
6

Ignoring Files with .gitignore

Not everything belongs in version control

Real-world note: In professional projects, you’d create .gitignore before your very first commit — so secrets and generated files are never tracked, even accidentally. We deferred it here to focus on the core workflow first.

Some files should never be committed:

Task 1: See the problem

Let’s simulate what happens without a .gitignore:

mkdir -p __pycache__
echo "bytecode" > __pycache__/hero_registry.cpython-311.pyc
echo "SECRET_KEY=abc123" > .env
echo "debug log" > debug.log
git status

Git wants to track all of these! Committing .env would expose your secrets to anyone who can see the repository.

Task 2: Create a .gitignore file

Open the .gitignore file in the editor and add the following patterns. Each line is a pattern that tells Git to pretend matching files don’t exist:

__pycache__/
*.pyc
.env
*.log

Before you run: You have just saved .gitignore with the four patterns above. After running git status, predict: which of the files you created in Task 1 (__pycache__/, .env, debug.log) will disappear from the output, and which will remain visible?

Save the file, then check the status:

git status

The ignored files have vanished from the status output! Only .gitignore itself appears as a new untracked file.

Important: .gitignore has no retroactive effect on tracked files

There’s a catch worth knowing: if a file was already committed (i.e., Git is already tracking it), adding it to .gitignore does not stop Git from tracking future changes to it. The ignore rules only apply to files that Git has never seen before.

For example, imagine you committed secrets.env by accident in a previous commit, and now you add .env to .gitignore. Git will still notice and stage any future changes to secrets.env — because it is already tracked.

The fix is git rm --cached:

git rm --cached secrets.env

git rm --cached <file> removes the file from Git’s index (the staging area / tracking list) without deleting it from your filesystem. After running this command and committing the removal, Git will treat the file as untracked — and your .gitignore pattern will correctly prevent it from being staged again.

Concrete example:

# File is already tracked — .gitignore alone won't help
git rm --cached secrets.env
git commit -m "Stop tracking secrets.env"
# secrets.env still exists on disk, but Git ignores future changes to it

Important warning: git rm --cached only stops Git from tracking the file going forward. The file still exists in all previous commits — anyone who clones the repository can see the version that was committed. To truly scrub a secret from history, you need tools like git filter-repo or BFG Repo Cleaner. .gitignore + git rm --cached only prevents future tracking — it is not a substitute for rotating compromised credentials.

Task 3: Commit the .gitignore

The .gitignore file itself should be committed — it’s a project configuration that all contributors benefit from. Stage and commit it using the workflow from Steps 2–4. Use the message "Add .gitignore to exclude compiled and secret files".

Hint: Which file do you need to stage? Just .gitignore — not the ignored files themselves.

Starter files
myproject/.gitignore

        
      
7

Inspecting History

Reading the story of your project

Git’s log is a detailed journal of every snapshot you’ve saved. Let’s learn to read it effectively.

Task 1: View the commit log

git log

Press q to exit. Each entry shows:

Task 2: Compact log view

For a summary, use:

git log --oneline

This shows just the first 7 characters of the hash and the message. Much easier to scan!

Task 3: See what a commit changed

Pick any commit hash from the log and inspect it:

git show HEAD

HEAD is a pointer to your current branch, which in turn points to that branch’s latest commit. So HEAD always resolves to the most recent commit on whatever branch you have checked out. git show displays the full diff of what changed in that commit.

Task 4: Compare commits

See what changed between the second-to-last commit and the latest:

git diff HEAD~1 HEAD

HEAD~1 means “one commit before HEAD”. You can use HEAD~2 for two commits back, and so on.

Understanding git diff variants

git diff              → Working Directory vs. Staging Area
git diff HEAD         → Working Directory vs. Last Commit
git diff HEAD~1 HEAD  → Previous Commit vs. Last Commit
git diff --staged     → Staging Area vs. Last Commit

Visualizing your history

Try this command to see an ASCII art graph of your commit history:

git log --oneline --graph --all

This visual representation becomes essential once you start branching. As you work through the rest of this tutorial, consider running this command after each git commit or git merge to watch the history graph grow.

8

Mini-Capstone: Clean Up a Messy Repository

Boss level: no hand-holding

You’ve learned the core Git workflow: init, stage, commit, undo, ignore, and inspect. Now it’s time to prove you actually get it. Here’s a broken repository — fix it on your own.

No commands are provided. Go back to earlier steps if you need a refresher. The tests tell you what the end state must look like, not how to get there. This is how real Git work goes — you figure out the “how” yourself.

The scenario

A colleague left the repository below in a bad state before going on holiday. Your job:

  1. The file scratch.py was staged by accident — it contains unfinished experimental code and must not be in the next commit. Unstage it (keep the file on disk).

  2. The file broken.py contains a line DEBUG = True that was accidentally appended. Discard that working-directory change so broken.py matches the last commit.

  3. Neither *.log files nor scratch.py should ever be tracked. Add the appropriate patterns to .gitignore, then commit .gitignore with the message "Add .gitignore".

  4. Verify your work: run git status — the output should say “nothing to commit, working tree clean”.

Hints (expand only if stuck)

Hint 1 — unstaging a file Run git restore --help to find the command variant that targets the staging area without touching the working directory.
Hint 2 — discarding a working-directory change Run git restore --help to find the command variant that discards uncommitted edits to a file.
Hint 3 — .gitignore patterns Run git help gitignore to find the rules for writing ignore patterns.
Starter files
myproject/scratch.py
# EXPERIMENTAL — do not commit
x = [i**2 for i in range(100)]
myproject/broken.py
"""A module that needs fixing."""

def broken_function():
    return 42
myproject/debug.log
2024-01-01 ERROR: something went wrong
myproject/.gitignore
__pycache__/
*.pyc
.env
9

Branching

Parallel universes for your code

Branches let you work on new features without touching the main codebase. Think of them like alternate timelines — you can experiment freely, and if things go wrong, the main timeline is completely unaffected.

What is a branch?

A branch is nothing more than a pointer to a commit. It has a name (like main or feature-team-up) and it points to one specific commit. That’s it — the entire branch is just that pointer.

Creating a branch? Git writes a new pointer to the current commit. Committing on a branch? Git moves the pointer from the old commit to the new one. Deleting a branch? Git removes the pointer — the commits it pointed to are still there.

Because a pointer is tiny (~41 bytes on disk), creating a branch is nearly instant. You can have hundreds of branches without any performance impact.

Before branching:

  main:  [C1] ← [C2] ← [C3]  ← HEAD
                          ↑
                     pointer: "main"

After creating feature branch:

  main:  [C1] ← [C2] ← [C3]
                          ↑
                    pointer: "main"
                    pointer: "feature-team-up"  ← HEAD

Two pointers to the same commit — not a copy of your entire project! When you make a new commit on feature-team-up, Git moves that pointer from C3 to the new commit C4, while main stays on C3.

Task 1: See your current branch

git branch

You should see * main. The * indicates which branch HEAD is currently pointing to.

Task 2: Create and switch to a new branch

📊 Check the Git Graph — click the Git Graph tab (top right). We will now create a new branch and watch the graph update in real time. What do you you expect to see when we create the new branch? Make a prediction, then watch it happen.

git switch -c feature-team-up

This creates a new branch called feature-team-up and switches to it. (-c means “create the branch”). Run git branch to confirm you’re on the new branch.

📊 Git Graph — Was this what you expected? It does not look like a branch, does it? That’s because both main and feature-team-up are pointing to the same commit. They are two pointers to the same commit. HEAD is now pointing to feature-team-up meaning that every new commit will be added to this branch.

Task 3: Make changes on the feature branch

Add a team_up function to hero_registry.py. Open it in the editor and add at the bottom:

def team_up(hero1, hero2):
    """Combine two heroes for a mission."""
    if hero1 is None or hero2 is None:
        raise ValueError("Cannot team up with an absent hero")
    return f"{hero1['name']} and {hero2['name']} unite!"

📊 Check the Git Graph — We will now commit our changes. What do you expect will happen? Make a prediction, then watch it happen.

Save, then stage and commit using the workflow from Steps 2–4. Use the message "Add team_up function with absent-hero check" (the test checks for “team” in the commit message).

📊 Git Graph — Was this what you expected? Now we see the changes diverge. main is still on the old commit, while feature-team-up has moved to the new commit with the team_up function. The two branches are now on different commits, showing that they have diverged timelines.

Task 4: Switch back to main

Before you run: When you switch back to main, what will happen to your Git graph? Think about what a branch pointer actually represents, predict your answer, then check it by running this command:

git switch main

📊 Check the Git Graph — HEAD has jumped back to main. The two branch labels now sit on different commits, showing the diverged timelines.

Before you continue: Now after switching back to main, will the team_up function still be visible in hero_registry.py? Why or why not? Check your answer by running this command:

Now look at hero_registry.py in the terminal:

cat hero_registry.py

The team_up function is gone! It only exists on the feature-team-up branch. Your main branch is untouched. This is the power of branching.

What about uncommitted changes? In this exercise you committed before switching — which is the recommended workflow. If you had staged or modified files without committing, git switch would carry those changes to the new branch, as long as they don’t conflict with files that differ between branches. When in doubt, always commit before switching. (There’s also git stash for temporarily shelving changes, but committing is the safer habit to start with.)

Switch back to see it again:

git switch feature-team-up
cat hero_registry.py

The function is back. Each branch is a separate timeline.

📊 Check the Git Graph one last time — HEAD is back on feature-team-up. You’ve now seen all four graph states: shared commit → new label → diverged timelines → HEAD switching sides.

10

Merging Branches

Integrating your work

When a feature is complete, you merge it back into the main branch. Git has two strategies depending on the history.

Fast-forward merge — when main has no new commits since the branch was created, Git simply slides the main pointer forward. No merge commit is created; the history stays linear:

Before:

  main:  [C1] ← [C2] ← [C3]
                          ↑ main
                          └── feature-team-up: [C4]

After fast-forward merge:

  main:  [C1] ← [C2] ← [C3] ← [C4]  ← HEAD (main, feature-team-up)

Three-way merge — when both branches have diverged (each has new commits the other doesn’t), Git compares both branch tips against their common ancestor and creates a new merge commit with two parents:

Before:

  main:           [C1] ← [C2] ← [C3] ← [C5]
                                   \
  feature:                          [C4]

After three-way merge:

  main:           [C1] ← [C2] ← [C3] ← [C5] ← [M]  ← HEAD
                                   \              ↑
  feature:                          [C4] ─────────┘

You’ll see a three-way merge in action in the next few steps, where we’ll intentionally create diverging changes on two branches. Understanding the difference matters when you learn git rebase, which replays commits to produce a clean linear history instead of a merge commit.

Controlling merge behaviour: git merge --no-ff

By default, Git uses a fast-forward whenever it can — the branch pointer simply slides forward and no merge commit is created, keeping history linear.

The --no-ff flag (“no fast-forward”) forces Git to always create a merge commit, even when a fast-forward would have been possible:

git merge --no-ff <branch>

This leaves an explicit join point in the history, so you can always see that a feature branch existed and when it was integrated:

With default fast-forward:

  main:  [C1] ← [C2] ← [C3] ← [C4]  ← HEAD
                               (feature commit, no trace of the branch)

With --no-ff:

  main:  [C1] ← [C2] ← [C3] ← [M]  ← HEAD
                           \   /
  feature:                 [C4]

Trade-off: --no-ff preserves explicit branch history — you and your team can always tell that a piece of work lived on a feature branch. The cost is a busier log with extra merge commits. The default fast-forward gives a cleaner, more linear history but loses the “this was a feature branch” context. Many teams use --no-ff for feature branches but not for trivial one-liner fixes — pick whatever convention your team agrees on.

The merge in this step will be a fast-forward since main has no new commits since we branched off.

Before you run: Will this merge create a new merge commit, or will Git just slide the main pointer forward? Look at the diagrams above and think about whether main has diverged from feature-team-up. Form your prediction, then try it.

Task 1: Switch to main and merge

First, switch to the branch you want to merge into (main):

git switch main

Before merging, preview what the incoming branch will introduce:

git diff main..feature-team-up

The .. syntax shows all changes in feature-team-up that aren’t in main yet — useful reconnaissance before any merge.

Now merge the feature branch:

git merge feature-team-up

Task 2: Verify the merge

Check that the team_up function is now on main:

cat hero_registry.py
git log --oneline

You should see the team_up function in the file and the commit from feature-team-up in your log. The feature has been integrated!

Task 3: Clean up

After merging, you can optionally delete the feature branch since its work is now part of main:

git branch -d feature-team-up

The -d flag safely deletes a branch only if it’s been fully merged. This keeps your branch list tidy.

11

Preparing for a Merge Conflict

Merge conflicts: scary name, totally normal

A merge conflict happens when two branches modify the same lines of the same file. Git doesn’t just pick one and hope for the best — it asks you to decide.

Think of it like two teammates editing the same paragraph of a shared Google Doc simultaneously. If you each change different sentences, Docs merges them silently. If you both rewrite the same sentence in different ways, Docs can’t guess which version to keep — it highlights both and asks a human. Git works the same way.

This is not an error or a sign you did something wrong. Even senior devs deal with merge conflicts regularly. Let’s create one on purpose so when it happens for real, you’ll handle it like a pro.

Task 1: Create a new branch and modify hero_registry.py

git switch -c update-recruit

Now open hero_registry.py in the editor and change the recruit function to add safety protocols — verify the hero’s name is valid before registering them:

def recruit(name, power):
    """Add a new hero to the squad (with safety protocols)."""
    if not isinstance(name, str):
        raise TypeError("Hero name must be a string")
    return {"name": name, "power": power, "status": "active"}

Save, then stage and commit. The test checks for “safety”, “protocol”, or “recruit” in the commit message — write something descriptive.

Task 2: Switch back to main

git switch main

Verify that main still has the original recruit function (without safety protocols):

head -8 hero_registry.py

Important: Stay on main and proceed to the next step. In the next step, we’ll add mission logging to the same recruit function on main, setting up a conflict!

12

Resolving a Merge Conflict

The conflict

In the previous step, you added safety protocols to the recruit function on the update-recruit branch. Now we’ll add mission logging to the same function on main, creating a conflict.

Task 1: Add mission logging to recruit on main

Make sure you’re on main:

git switch main

Open hero_registry.py in the editor and change the recruit function to add mission logging — track every recruitment for the squad’s records:

def recruit(name, power):
    """Add a new hero to the squad (with mission logging)."""
    print(f"Recruiting {name} with power: {power}")
    return {"name": name, "power": power, "status": "active"}

Save, then stage and commit. The test checks for ‘logging’, ‘log’, or ‘recruit’ in the commit message — write something descriptive. You’ve done this workflow many times; no command list provided.

🔀 Check the Git Graph: After your commit, click Git Graph in the toggle above the editor. You’ll see a new commit appear at the top of main — a visual record that your mission-logging change now lives on the branch. Switch back to Editor when you’re ready to continue.

Task 2: Attempt the merge

Before you run: One branch added safety protocols; the other added mission logging — both to the same recruit function. What do you think will happen when you try to merge? Will Git combine them automatically, or will it need your help? Why?

Now try to merge the other branch:

git merge update-recruit

Git will report a CONFLICT! It found that both branches changed the same lines in hero_registry.py and can’t automatically combine them.

🔀 Check the Git Graph: Click Git Graph now. You’ll see update-recruit and main as two separate branches diverging from a common ancestor — exactly the situation that caused the conflict. This is what a “not yet merged” state looks like in the graph. Switch back to Editor to resolve the conflict.

Task 3: Read the conflict markers

Open hero_registry.py in the editor (or run cat hero_registry.py). You’ll see something like:

<<<<<<< HEAD
    """Add a new hero to the squad (with mission logging)."""
    print(f"Recruiting {name} with power: {power}")
    return {"name": name, "power": power, "status": "active"}
=======
    """Add a new hero to the squad (with safety protocols)."""
    if not isinstance(name, str):
        raise TypeError("Hero name must be a string")
    return {"name": name, "power": power, "status": "active"}
>>>>>>> update-recruit

Task 4: Resolve the conflict

Challenge — try before reading the solution: Look at the two versions above. Can you figure out how to combine them into one function that has both the safety protocols AND the mission logging? Try writing the merged version yourself before looking at the example below.

Edit hero_registry.py to combine both changes. Remove ALL conflict markers (<<<<<<<, =======, >>>>>>>) and write the merged version you want to keep. For example, keep both the safety protocols and the mission logging:

def recruit(name, power):
    """Add a new hero to the squad (with safety protocols and mission logging)."""
    if not isinstance(name, str):
        raise TypeError("Hero name must be a string")
    print(f"Recruiting {name} with power: {power}")
    return {"name": name, "power": power, "status": "active"}
Sidebar: Escape hatch — git merge --abort Sometimes you start a merge and quickly realise it's more complex than expected — maybe there are dozens of conflicts, or you merged the wrong branch, or you just want a moment to think before committing. Git gives you a clean escape hatch:
git merge --abort
`git merge --abort` cancels the in-progress merge at **any point** — even after you have already partially resolved some conflicts — and restores both your working directory and the staging area to the exact state they were in **before** you ran `git merge`. It's as if the merge never started. **When to use it:** When you realise mid-merge that you need to step back, consult a teammate, or approach the integration differently. There is no shame in aborting — it's far better than committing a half-resolved mess. **Note:** `git merge --abort` only works while a merge is still in progress (i.e., Git has left conflict markers in your files and is waiting for you to resolve them). Once you have run `git commit` to finish the merge, the merge is complete and cannot be aborted — you would use `git revert` instead.
Sidebar: Auto-resolving conflicts — -X ours and -X theirs Sometimes you know in advance that one side should always win. Git lets you express this with the `-X` (strategy option) flag:
git merge feature -X ours    # always keep current branch's version on conflict
git merge feature -X theirs  # always keep incoming branch's version on conflict
| Flag | Which version wins on conflict | |---|---| | `-X ours` | The current branch (the one you're on) | | `-X theirs` | The incoming branch (the one being merged in) | **Important:** These flags only affect lines that actually conflict — non-conflicting changes from both branches are still combined normally. They are a convenience for cases where you've already decided one side is authoritative, so you don't have to resolve each conflict marker by hand.

For this step, resolve the conflict manually — it’s the skill you need most often in practice.

Task 5: Complete the merge

After editing, mark the conflict as resolved (using git add) and create the merge commit. You’ve done both of these before.

Heads up — VI/VIM editor: Unlike your previous commits, this time you’ll run git commit without -m "...". Git will open the VI/VIM text editor with a pre-filled merge commit message. You don’t need to change anything — just save and exit by typing :wq and pressing Enter. If you accidentally enter insert mode (text starts appearing), press Escape first, then type :wq.

You just resolved a merge conflict! That’s genuinely a flex — this is a skill that trips up even experienced developers.

🔀 Check the Git Graph: Click Git Graph one last time. You’ll now see a merge commit at the top of main with two parent edges — one coming from main and one from update-recruit. That diamond shape is the visual signature of a successful merge: two diverging histories reunited into one.

13

Safe Undo with git revert

Undoing committed mistakes safely

git restore only works on uncommitted changes. What if you’ve already committed a mistake — or even merged it into main? You need a different tool: git revert.

git revert creates a new commit that applies the exact inverse of a previous commit, neutralising its changes while keeping the full history intact. Think of it like replying to your own message with “ignore that last message” — the original is still there, but everyone knows it’s been corrected.

Before revert:

  main:  [C1] ← [C2] ← [C3]  ← HEAD
                        (bad commit)

After git revert HEAD:

  main:  [C1] ← [C2] ← [C3] ← [C4]  ← HEAD
                        (bad)   (anti-commit: undoes C3)

Scalpel vs. Sledgehammer

Git gives you two tools for undoing committed work — think of them as the scalpel and the sledgehammer:

Tool Command Effect Safe on shared branches?
Scalpel git revert <hash> New commit that undoes the target Yes
Sledgehammer git reset --hard <hash> Destroys commits, rewrites history Never

Your safety net: git reflog

git reflog records every movement of HEAD — commits, resets, checkouts, and rebases — as a local-only log. It’s the ultimate safety net for recovering commits that appear “lost” after a destructive operation like git reset --hard.

git reflog

The output lists recent HEAD positions with short hashes and descriptions, newest first. A typical entry looks like:

a1b2c3d HEAD@{0}: reset: moving to HEAD~1
e4f5g6h HEAD@{1}: commit: Add power_up function

Recovery workflow: if you accidentally reset away some commits, run git reflog to find the SHA of the lost commit, then restore it:

git reset --hard <sha>   # jump your branch back to that commit
# or
git checkout <sha>       # inspect that commit (changes to "detached HEAD state")

Two important limitations to keep in mind:

Task 1: Introduce a bug commit

echo "print('debug: this should not be here')" >> hero_registry.py

Now stage and commit using the workflow you know — no command list provided. Then run git log --oneline to confirm the bad commit is at the top.

Task 2: Revert it

Before you run: Will git revert HEAD remove the bad commit from history, or will it add something new? Think about the “ignore that last message” analogy above, then check your answer.

Undo the last commit safely:

git revert HEAD --no-edit

--no-edit accepts the default commit message without opening an editor. Git creates a new commit that reverses the debug line.

git revert is not limited to HEAD — you can target any commit by its hash. Find the hash with git log --oneline, then run git revert <hash>. Git will create a new commit that is the exact inverse of the targeted commit, undoing its specific changes regardless of how far back in history it is.

Task 3: Verify the result

git log --oneline
cat hero_registry.py

You’ll see two new commits in the log: the bad commit and the revert commit. The debug line is gone from the file, but the full history of what happened is preserved — exactly as it should be.

Task 4: The snapshot lives on — predict the outcome

Git commits the staged version of a file, not what happens to be on disk at the moment you type git commit. Let’s prove this with a predict-before-run experiment.

Create a new file and stage it:

echo "Study notes for the exam" > study_notes.txt
git add study_notes.txt

Now delete the file from the filesystem before committing:

rm study_notes.txt

Run git status. You’ll see study_notes.txt listed as deleted in the working directory — but Git still has the staged version in its index.

Now commit:

git commit -m "Add study notes file"

Verify the file is missing from disk:

ls

study_notes.txt is not there. The commit succeeded (Git used the staged snapshot), but the working directory is out of sync with HEAD.

Before you run: git reset --hard HEAD resets your working directory to exactly match the latest commit. HEAD is the commit you just made — which includes study_notes.txt. Will the file appear, disappear, or stay gone? Form your prediction, then run:

git reset --hard HEAD
ls

The file is back. Git’s staging area captured a real snapshot of the file at git add time. The commit preserved it. And git reset --hard HEAD restored the working directory to match — proving that once something is committed, Git can always bring it back.

14

Working with Remotes

Time to go online

Everything so far has been local — just you and your machine. But in the real world, code lives on remote repositories like GitHub, GitLab, or Bitbucket. This is where collaboration happens: pull requests, code reviews, and shipping to production.

The remote workflow adds three key commands to what you already know:

The remote workflow

┌──────────────┐  git add/commit  ┌──────────────┐    git push    ┌──────────────┐
│   Working    │ ───────────────▶ │    Local     │ ─────────────▶ │    Remote    │
│  Directory   │ ◀─────────────── │     Repo     │ ◀───────────── │     Repo     │
└──────────────┘   git restore    └──────────────┘    git pull    └──────────────┘
                                    (your machine)                  (e.g. GitHub)

Task 1: Simulate a remote with a bare repository

We can simulate a remote repository right here using a “bare” repo (a repository with no working directory — just the .git data):

cd /tutorial
git init --bare remote-repo.git

Task 2: Connect your project to the remote

cd /tutorial/myproject
git remote add origin /tutorial/remote-repo.git

origin is the conventional name for your primary remote.

Task 3: Push your work

Before you run: Think about what git push will do. Will it send only the latest commit, or the entire branch history?

git push -u origin main

The -u flag sets origin/main as the upstream tracking branch, so future pushes only need git push.

Task 4: Simulate a colleague’s change

Clone the remote into a separate directory (like a teammate would):

cd /tutorial
git clone remote-repo.git colleague-copy
cd colleague-copy

Make a change as your “colleague”:

echo "# Contributing Guide" > CONTRIBUTING.md
git add CONTRIBUTING.md
git commit -m "Add contributing guide"
git push

Task 5: Pull your colleague’s changes

Switch back to your original project and pull:

cd /tutorial/myproject
git pull

git pull is actually shorthand for two operations: git fetch (download new commits from the remote) followed by git merge (integrate them into your current branch). Understanding this two-step process helps when you need finer control — for example, running git fetch first to inspect incoming changes before merging.

Check that the new file arrived:

ls CONTRIBUTING.md
git log --oneline -3

You now have your colleague’s work in your local repository. That’s the complete Git collaboration cycle: branch → commit → push → pull → merge. This is literally how teams at every tech company ship code every day.

15

Captsone Git Project and Review & Best Practices

You made it to the Final Boss!

Seriously, nice work. You’ve gone from zero to a solid Git workflow. Let’s review everything you’ve picked up:

Commands you now know

Command Purpose
git init Create a new repository
git config Set your identity
git add <file> Stage specific files
git add . Stage all changes
git commit -m "msg" Save a snapshot
git status Check what’s changed
git log View commit history
git diff See uncommitted changes
git show Inspect a commit
git restore --staged Unstage a file
git restore Discard working-directory changes
git branch List branches
git switch <branch> Switch to an existing branch
git switch -c <branch> Create and switch to a new branch
git merge Combine branch histories
git revert <hash> Safely undo a commit (adds anti-commit)
git remote add Register a remote repository
git push Upload local commits to a remote
git pull Download and merge remote commits
git clone <url> Download a full copy of a remote repository

Best practices for professional use

  1. Write meaningful commit messages — explain what and why, not just “fix” or “update”
  2. Commit small and often — each commit should be one logical change
  3. Use .gitignore early — set it up before your first commit
  4. Never commit secrets — no API keys, passwords, or .env files
  5. Pull frequently — fetch remote changes early to avoid big conflicts

Capstone challenge: Put it all together

Time to prove your skills! Complete this mini-project using everything you’ve learned — without step-by-step instructions. Refer back to earlier steps if you get stuck.

  1. Create a new branch called feature-power-surge
  2. Add a power_surge function to hero_registry.py:
    def power_surge(hero, boost):
        """Apply a power surge to a hero."""
        return f"{hero['name']} surges with {boost} extra power!"
    
  3. Commit your change with a meaningful message
  4. Switch back to main
  5. Merge feature-power-surge into main
  6. Verify by running checking the Git Graph
  7. Push your merged work to the remote: git push

    Wait — that didn’t work. Read the error message carefully.

    While you were working on your feature branch, your colleague pushed their own change to the remote. Git rejected your push to protect their work. This is the most common collaboration hiccup in professional development — and you already know how to handle it.

  8. Fix it — pull the remote changes, resolve any conflicts (keep both your function and your colleague’s function), and complete the merge
  9. Push again — it should succeed this time
Hint 1 — creating a branch and switching to it Revisit Step 8: there is a single git switch flag that creates a branch and immediately switches to it in one command.
Hint 2 — staging and committing the change Revisit Steps 2–4: the two-step workflow is git add <file> then git commit -m "message". Use a descriptive message.
Hint 3 — merging back into main Revisit Step 9: switch to the branch you want to merge into before running git merge. Preview changes first with git diff main..feature-power-surge.
Hint 4 — push rejected? The remote has commits you don't have locally. Run git pull to download and merge them. If both sides changed the same part of a file, you'll get a merge conflict — just like Step 12.
Hint 5 — resolving the remote conflict Open the conflicted file, remove the conflict markers (<<<<<<<, =======, >>>>>>>), and keep both functions. Then git add the file and git commit to complete the merge. After that, git push should work.

This exercises branching, committing, merging, remote push/pull, and conflict resolution — all without scaffolding. If you can do this independently, you’re ready for real-world Git usage.

cat hero_registry.py

From an empty folder to a version-controlled Python hero registry with branching, merge conflict resolution, remote collaboration, and independent feature work — that’s a whole journey. You should feel good about this.

16

Git Mastery — Final Review

Congratulations — you’ve completed the Git tutorial!

From an empty folder to a version-controlled Python project with branching, merge conflict resolution, remote collaboration, and independent feature work — that’s a serious achievement.

Take a moment to appreciate what you can now do:

Note — first-time Git setup on a new machine: Before you can make commits on your own computer, you must tell Git who you are. Run these two commands once (replacing with your real name and email):

git config --global user.name "Your Name"
git config --global user.email "you@example.com"

This tutorial’s VM had these pre-configured, but on a fresh machine Git will refuse to commit until they are set.