Learn Git through hands-on practice — from your first commit to resolving merge conflicts, all inside an interactive Linux environment
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.
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:
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!
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.
1. In Git’s three-state model, what is the purpose of the Staging Area?
The Staging Area (post editor) is Git’s way of letting you precisely control what goes into each commit. You can stage some changes but not others, creating clean, focused snapshots.
2. What does git init create inside your project directory?
git init creates a hidden .git/ directory containing the full version history database, configuration, and branch pointers. This is the entire repository — no network access required.
3. Which problems does version control solve? (Select all that apply) (select all that apply)
Version control tracks history, enables rollbacks, and supports parallel work. It does NOT fix bugs — that part is still up to you!
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.pyto disk but haven’t told Git about it yet. Willgit statusshow it as tracked or untracked? What color do you expect? Form your answer, then continue:
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.
git status outputgit 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.
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!
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.
"""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
1. What does git status show for a file that exists in your working directory but has never been added to Git?
An untracked file is one Git has never been told to follow. It shows up in red under ‘Untracked files’. Once you run git add, it moves to the staging area and Git begins tracking it.
2. [Interleaved: Revisit Step 1] You run git add hero_registry.py in a freshly created directory and get: fatal: not a git repository (or any of the parent directories): .git. What is the root cause, and what is the fix?
The error not a git repository means Git cannot find a .git directory in the current folder or any parent. As Step 1 showed, git init creates that directory. Without it, no Git commands work — git add, git commit, and git log all require an initialized repository.
3. Which sequence of commands correctly stages and commits a new file called app.py?
The correct two-step flow is add (move to staging area) then commit (save the snapshot). git commit only commits what’s in the staging area, so you must git add first.
4. Which of the following are characteristics of a good commit message? (Select all that apply) (select all that apply)
Good commit messages are descriptive, explain intent, and accompany small, focused changes. Cryptic single-letter messages make history useless for debugging and code review.
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.
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.
Before you run:
git diffcompares two areas. You’ve modifiedhero_registry.pybut haven’t staged it yet. Which two areas will it compare — working directory vs. staging area, or staging area vs. last commit? Will your newpower_upfunction 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.
Now complete the cycle:
git add hero_registry.py
git commit -m "Add power_up function to hero registry"
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 diffand saw lines marked with+. Without looking back, explain to yourself: what two things didgit diffcompare to produce that output? If you’re unsure, re-read the explanation above — this distinction matters in every future step.
1. You modified hero_registry.py but have NOT yet run git add. What does git diff compare?
git diff (with no arguments) compares your working directory to the staging area. Since nothing is staged yet, the staging area still matches the last commit, so you see all your unstaged modifications.
2. [Interleaved: Revisit Step 2] If you run git commit without running git add first on a new file, what happens?
Git only commits what is currently in the staging area. New files must be explicitly added with git add to be included in a commit snapshot.
3. [Interleaved: Revisit Step 1] You accidentally delete the .git/ folder from your project. What is the consequence?
As Step 1 established, .git/ is the repository — it contains every commit, branch pointer, and config entry. Deleting it destroys all history and leaves you with an untracked folder.
4. In git diff output, what does a line starting with + indicate?
In diff output, lines starting with + are additions and lines starting with - are deletions. Unchanged context lines have no prefix symbol.
5. After committing hero_registry.py, you add a new function and run git diff — you see your new lines marked with +. You then run git add hero_registry.py. What will git diff (no arguments) show now?
git diff compares the working directory to the staging area. Once you stage your changes with git add, both areas match — so git diff reports nothing. The changes still exist in the staging area waiting to be committed; they’re just no longer different from the working directory.
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.
Before you run: The project now has four new files:
README.md,test_heroes.py,test_registry.py, andnotes.txt. You are about to stage onlyREADME.md. Aftergit add README.mdandgit 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.
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”.
You now know several ways to stage:
git add README.mdgit add test_*.pygit add .git add --all (or -A)-am shortcut — and its hidden catchOnce 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 add → git commit) remains the safest default whenever new
files are involved.
"""Tests for heroes."""
"""Tests for registry."""
# Hero Registry
Track your superhero squad
TODO: add team_up DONE: add power_up
1. You have three modified files: main.py, test_main.py, and config.json. You only want to commit the test file. Which command stages only test_main.py?
Naming the file explicitly (git add test_main.py) stages only that file. git add . and git add --all would stage everything, making it impossible to create a focused commit.
2. [Interleaved: Revisit Step 3] You edited a tracked file but have NOT staged it yet. What does git diff (with no arguments) compare?
As we practiced in Step 3, git diff compares your working directory to the staging area. Since nothing new is staged, the staging area still matches the last commit — so you see all your unstaged modifications.
3. [Interleaved: Revisit Step 2] A teammate always runs git add . before every commit, saying ‘it’s simpler.’ What is the most significant hidden risk of this habit?
The staging area exists precisely to give you fine-grained control. git add . bypasses that control: it stages everything in the working directory, including generated files, half-finished changes, or (critically) secrets. As Step 2 showed, the two-step add/commit flow gives you a deliberate checkpoint to review exactly what enters each commit.
4. What is the key advantage of Git’s staging area over a simple ‘save everything’ commit model?
The staging area gives you fine-grained control: you can make many edits in your working directory, then assemble them into clean, focused commits that each represent one logical change. This keeps history readable and makes it easier to find bugs later.
5. Which staging commands match their descriptions? (Select all correct pairs) (select all that apply)
git add . stages ALL changes in the current directory — including new untracked files, modifications, and deletions. It is NOT limited to tracked files. git add --all does the same but from any working directory location.
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.
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”!
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.
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.
| 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) |
1. You accidentally staged config.py with git add. Which command removes it from the staging area without discarding your edits?
git restore --staged <file> unstages the file — it moves it off the post editor back to the working directory. Your edits are preserved. Without --staged, git restore would discard the edits entirely.
2. [Interleaved: Revisit Step 2] You edited main.py, test_main.py, and debug.log in one sitting. Your next commit should contain only the test file. Which Git feature makes this possible without reverting the other edits?
The staging area lets you cherry-pick which edits form the next commit while keeping other in-progress work safe in the working directory — the defining advantage of the two-step add/commit flow.
3. You run git restore hero_registry.py (without --staged). What happens to your unsaved edits?
git restore <file> replaces the working directory version with the last committed version. Because the changes were never committed, Git has no record of them — they are gone permanently with no way to recover them.
4. Which statements about git reset --hard are true? (Select all that apply)
(select all that apply)
git reset --hard discards all uncommitted changes in both the working directory and staging area. It is the ‘nuclear option’ — any work that was never committed is permanently lost. It does not create a revert commit (that’s a different tool you’ll learn later), and it affects both the staging area and the working directory.
Real-world note: In professional projects, you’d create
.gitignorebefore 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:
.pyc, __pycache__/) — generated from source.env) — contain secrets like API keys.DS_Store, Thumbs.db) — system clutternode_modules/, venv/) — downloaded, not authoredLet’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.
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
.gitignorewith the four patterns above. After runninggit 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.
.gitignore has no retroactive effect on tracked filesThere’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.
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.
1. Which type of file is the most dangerous to accidentally commit to a public repository?
Committing a .env file exposes secrets (API keys, passwords, tokens) to anyone who can see the repository — even after deletion, the secret remains in Git history. The others are wasteful but not security risks.
2. After adding *.log to .gitignore, you run git status. Which statement is true?
.gitignore tells Git to pretend matching files don’t exist for tracking purposes. The files remain on disk — they simply won’t appear in git status as untracked, and git add . won’t stage them.
3. [Interleaved: Revisit Steps 4–5] You ran git add . and accidentally staged app.py, style.css, AND secrets.env. You only want app.py in this commit. What is the correct recovery sequence?
git restore --staged <file> is the surgical undo for git add: it moves a file off the staging area without touching your working-directory edits. After running it for both unwanted files, only app.py remains staged — ready for a clean, focused commit. git restore (without --staged) would permanently discard the edits, which is not what you want here.
4. [Interleaved: Revisit Step 1] Is a Git commit better described as a ‘backup diff’ or a ‘permanent snapshot’?
Git stores snapshots, not just deltas. If a file hasn’t changed, Git simply links to the version it already has. This makes operations like branching and switching extremely fast.
5. A colleague says: ‘I’ll add .gitignore after I get the project working — setup files just slow me down right now.’ Evaluate this approach.
.gitignore has no retroactive effect: it cannot remove files already committed. If .env or a binary is accidentally committed before the ignore file exists, it lives in history forever — accessible to anyone with git clone. The safe approach is to create .gitignore as the very first file before any other git add.
6. Why should the .gitignore file itself be committed to the repository? (Select all that apply)
(select all that apply)
Committing .gitignore shares the ignore rules with the whole team and every future clone. This prevents accidental secret commits by anyone and keeps the repo free of generated/OS clutter. It does not delete files from anyone’s filesystem.
Git’s log is a detailed journal of every snapshot you’ve saved. Let’s learn to read it effectively.
git log
Press q to exit. Each entry shows:
For a summary, use:
git log --oneline
This shows just the first 7 characters of the hash and the message. Much easier to scan!
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.
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.
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
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.
1. You run git show on your first commit and see every line of every file listed as an addition (+). Which explanation is correct?
Git stores snapshots, not deltas. git show compares a commit to its parent. The first commit has no parent, so every line appears as a new addition — not a special case, but a natural consequence of the snapshot model introduced in Step 1.
2. What does HEAD~2 refer to in a Git command like git diff HEAD~2 HEAD?
HEAD points to the latest commit. HEAD~1 is one commit before it, HEAD~2 is two commits back, and so on. This relative notation lets you reference commits without copying their hash.
3. [Interleaved: Revisit Step 5] You staged config.py and app.py. You then realise config.py contains a half-finished change that shouldn’t be in this commit. You want to keep your edits to config.py in the working directory. What do you run?
git restore --staged <file> is the surgical ‘undo’ for git add: it moves the file off the post editor without touching the working directory. app.py stays staged; your config.py edits are preserved but excluded from the next commit.
4. You want to see the full diff of what changed in the latest commit (not comparing to working directory). Which command is correct?
git show HEAD displays the commit metadata plus the complete diff of that commit. git diff HEAD compares your working directory to the last commit — it would show your uncommitted changes, not the committed diff.
5. You ran git add hero_registry.py. Which command shows you the exact lines that will be in your next commit — without touching the working directory?
git diff --staged (also written --cached) compares the staging area to the last commit — showing precisely what git commit would snapshot. git diff (no flags) compares working directory to staging, so it would show nothing once you’ve staged. git show HEAD inspects the already-committed latest snapshot, not the pending one.
6. Which pieces of information does git log display for each commit? (Select all that apply)
(select all that apply)
git log shows the hash, author, date, and commit message for each commit. It does not show the file diffs — for that you need git show <hash> or git diff.
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.
A colleague left the repository below in a bad state before going on holiday. Your job:
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).
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.
Neither *.log files nor scratch.py should ever be tracked.
Add the appropriate patterns to .gitignore, then commit
.gitignore with the message "Add .gitignore".
Verify your work: run git status — the output should say
“nothing to commit, working tree clean”.
git restore --help to find the command variant that targets the
staging area without touching the working directory.
git restore --help to find the command variant that discards uncommitted edits to a file.
git help gitignore to find the rules for writing ignore patterns.
# EXPERIMENTAL — do not commit
x = [i**2 for i in range(100)]
"""A module that needs fixing."""
def broken_function():
return 42
2024-01-01 ERROR: something went wrong
__pycache__/ *.pyc .env
1. You completed the capstone without instructions. Which single git command gives you the fastest overview of whether anything is still staged or modified?
git status is the ‘dashboard’ command — it shows staged changes, unstaged modifications, and untracked files at a glance. It should be your first command whenever you’re unsure about the repository state.
2. After completing the capstone, a classmate says: ‘I just ran git reset --hard to clean everything up in one shot — same result, simpler.’ Evaluate their approach compared to the targeted steps you used.
git reset --hard is the nuclear option: it wipes everything — both the changes you wanted to discard AND any in-progress work you wanted to keep. The targeted approach (restore --staged + restore) lets you be surgical. Understanding the trade-offs is the mark of a confident Git user.
3. [Interleaved: Revisit Step 6] You added scratch.py to .gitignore and committed it. The file still shows up when you run ls. Why?
.gitignore tells Git to ignore a file for tracking purposes; the file remains untouched on disk. If you want to delete an untracked file, that is a filesystem operation (rm scratch.py), not a Git operation.
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.
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.
git branch
You should see * main. The * indicates which
branch HEAD is currently pointing to.
📊 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
mainandfeature-team-upare pointing to the same commit. They are two pointers to the same commit. HEAD is now pointing tofeature-team-upmeaning that every new commit will be added to this 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.
mainis still on the old commit, whilefeature-team-uphas moved to the new commit with the team_up function. The two branches are now on different commits, showing that they have diverged timelines.
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 theteam_upfunction still be visible inhero_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 switchwould 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 alsogit stashfor 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.
1. You’re on feature-x and have staged (but not committed) a change to app.py. You run git switch main. What happens to your staged change?
The staging area is not per-branch — it’s a shared workspace. git switch carries staged changes to the target branch if no conflict arises with files that must change during the switch. If there is a conflict, Git refuses and asks you to save your work first. This reinforces the three-state model from Step 1.
2. You are on feature-x and run git switch main. What happens to the files in your working directory?
When you switch branches, Git updates your working directory to match the commit that the target branch points to. Files unique to feature-x disappear; files in main (but not feature-x) reappear. This is why branches feel like separate timelines.
3. Your teammate says: ‘Branches are just copies of the project, so creating too many wastes disk space.’ Is this correct? Why or why not?
This is a common misconception. A branch is just a tiny pointer file, not a copy. You can create hundreds of branches with negligible disk cost. Understanding this changes how you think about branching strategy — branches should be cheap and frequent, not rare and expensive.
4. [Interleaved: Revisit Step 5] Before running git switch feature-team-up, you notice you have unstaged edits to hero_registry.py. What is the safest approach?
As Step 5 established, the cleanest workflow is to leave your working directory in a known state before switching contexts. Committing gives you a named, recoverable checkpoint. Running git switch with uncommitted changes may carry them across branches — or fail with a conflict warning — depending on whether those files differ between the two branches. When in doubt, commit first.
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.
git merge --no-ffBy 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
mainpointer forward? Look at the diagrams above and think about whethermainhas diverged fromfeature-team-up. Form your prediction, then try it.
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
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!
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.
1. Before merging feature-x into main, you want to see exactly which changes will be introduced. Which command is correct?
git diff main..feature-x shows all changes that are in feature-x but NOT yet in main — precisely what the merge would introduce. git diff (no args) only compares working directory to staging area, not branches. git log shows commits, not file diffs.
2. When does Git perform a fast-forward merge instead of creating a merge commit?
A fast-forward merge is possible only when the target branch hasn’t diverged — it’s directly ‘behind’ the feature branch in history. Git simply slides the pointer forward. No merge commit is created and the history stays linear.
3. In a three-way merge, what are the three ‘points’ Git compares?
Git finds the common ancestor (the commit where the two branches diverged), then compares both branch tips against it. This three-point comparison lets Git automatically combine non-overlapping changes and flag conflicts only where the same lines were changed.
4. After merging feature-team-up, you run git branch -d feature-team-up. The command succeeds. What does the -d flag’s success guarantee?
-d (lowercase) is a safety flag: Git only deletes the branch if its commits are already reachable from the current branch — meaning the branch is fully merged. If you try git branch -d on a branch with unmerged commits, Git refuses with a warning. -D (uppercase) force-deletes regardless. This is why git branch -d after a confirmed merge is safe cleanup — it cannot accidentally discard unmerged work.
5. Which statements about merging are correct? (Select all that apply) (select all that apply)
You always merge into your current branch, so switch first. Fast-forwards keep history linear; three-way merges create a merge commit with two parents. Deleting the feature branch after merging is optional (tidy but not required).
6. Your team lead says: ‘We should always use git merge --no-ff (no fast-forward) even when a fast-forward is possible, so every feature leaves a merge commit in the log.’ What is the trade-off?
This is a real professional debate. --no-ff forces a merge commit even when Git could fast-forward, preserving the fact that work happened on a branch. The trade-off is a busier log. Many teams prefer this for feature branches but not for trivial changes. There is no single correct answer — it depends on team workflow.
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.
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.
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!
1. What is the root cause of a merge conflict in Git?
A conflict occurs when Git cannot automatically reconcile two changes because they touch the exact same lines in a file. If different lines were changed, Git merges them silently without any conflict.
2. [Interleaved: Revisit Step 8] You’re on main with two modified files you haven’t committed yet. Your lead asks you to start work on update-recruit immediately. What should you do first, and why does the order matter?
You can branch with uncommitted changes (Git will carry them), but this creates ambiguity: those unrelated changes now appear to belong to the new feature branch. The professional habit is to start every branch from a known committed state. This is exactly the pattern Step 8 established — always commit your work before switching contexts.
3. You are setting up a merge conflict scenario. You made changes on update-recruit and are now on main. What is the correct next step to trigger the conflict?
To create a conflict, both branches must have diverging changes to the same lines. If you merge without making a competing change on main, Git will just fast-forward. Making a different edit to the same lines on main sets up a true three-way conflict.
4. Which scenarios will definitely cause a merge conflict? (Select all that apply) (select all that apply)
Conflicts happen when the same lines are changed differently on two branches. Adding different content at the same location (both adding to end of file) can also conflict if they overlap. Adding different files or editing completely separate files never conflicts.
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.
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.
Before you run: One branch added safety protocols; the other added mission logging — both to the same
recruitfunction. 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-recruitandmainas 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.
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
<<<<<<< HEAD — your current branch’s version (main)======= — separator>>>>>>> update-recruit — the incoming branch’s versionChallenge — 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"}
git merge --abortgit 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.
-X ours and -X theirsgit 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.
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 commitwithout-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:wqand 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
mainwith two parent edges — one coming frommainand one fromupdate-recruit. That diamond shape is the visual signature of a successful merge: two diverging histories reunited into one.
1. After editing hero_registry.py to remove all conflict markers, why do you run git add hero_registry.py BEFORE git commit?
git add <file> after a conflict serves a dual role: it stages the resolved content AND clears Git’s internal ‘unresolved conflict’ flag for that file. Without it, git commit refuses with ‘You have unmerged paths’. This is the same git add from Step 2 — it just takes on this extra responsibility during a merge.
2. In conflict markers, what does the section between <<<<<<< HEAD and ======= represent?
The <<<<<<< HEAD section shows your current branch’s version. The section after ======= (up to >>>>>>>) shows the incoming branch’s version. You must choose between them, combine them, or write something entirely new — then remove all markers.
3. After manually editing a file to resolve a conflict, what is the correct sequence of commands to complete the merge?
After editing the conflict away, you mark it resolved with git add <file> (which tells Git the conflict in that file is fixed), then git commit to create the merge commit. There is no git resolve command.
4. Which statements about merge conflicts are true? (Select all that apply) (select all that apply)
Conflicts are not errors — they are Git’s deliberate safety mechanism asking for human judgment. You must remove all markers (leaving them in is a bug). The resolution can be either version, a combination, or even entirely new code.
5. [Interleaved: Revisit Step 2] During a merge, git status shows hero_registry.py as ‘both modified’. After you edit the file and remove all conflict markers, what does git add hero_registry.py signal to Git — and why is this the same command you used in Step 2?
git add has the same meaning here as in Step 2: move content into the staging area. During a merge it also clears Git’s ‘unresolved conflict’ flag for that file. It is not a special merge command — just the familiar loading-dock action wearing an extra hat.
6. Your team frequently has merge conflicts. A teammate suggests: ‘Let’s all work on one branch to avoid conflicts.’ Evaluate this suggestion.
Merge conflicts are a feature, not a bug — they prevent silent data loss. Working on one branch means no isolation: any commit immediately affects everyone, broken code blocks the whole team, and parallel feature development becomes impossible. The real fix is to merge more often (keep branches short-lived) and communicate about who’s editing which files.
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)
Git gives you two tools for undoing committed work — think of them as the scalpel and the sledgehammer:
git revert (scalpel) — makes a precise cut: creates a new
commit that surgically reverses a specific change. History is
preserved. Everyone stays in sync. Safe for shared branches.
git reset --hard (sledgehammer) — smashes commits by
moving the branch pointer backward, destroying everything in its
path. History is rewritten. Teammates who already pulled the
deleted commits are left with broken repositories. Never use
this on shared branches.
| 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 |
git refloggit 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:
gc.reflogExpire).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.
Before you run: Will
git revert HEADremove 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.
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.
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 HEADresets your working directory to exactly match the latest commit. HEAD is the commit you just made — which includesstudy_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.
1. A bug was committed 3 commits ago (hash a1b2c3) to a shared main branch that 5 teammates have already pulled. Which approach is safe?
On a shared branch, only git revert is safe — it adds a new anti-commit without touching existing history. git reset --hard rewrites history and would require a force-push, breaking everyone who already pulled. git restore without committing is also incomplete. This contrasts directly with the uncommitted-change scenario in Step 5 where git restore was the right tool.
2. What does git revert HEAD do?
git revert creates an anti-commit — a new commit that exactly undoes the target commit’s changes. The original bad commit remains in history. This is safe because it never rewrites history.
3. [Interleaved: Revisit Step 7] Before running git revert HEAD --no-edit you have 4 commits in your log. After the command finishes, how many commits are in the log, and what does the new entry look like?
git revert never removes commits — it appends a new one whose message starts with ‘Revert “…”’. You now have 5 commits: the original 3, the bad commit (still visible), and the new anti-commit. The full audit trail — including the mistake and its fix — is preserved. This is what makes git revert safe on shared branches: no history is rewritten.
4. Why is git revert safer than git reset --hard when working on a shared branch?
git reset --hard rewrites history by destroying commits. If teammates already pulled those commits, a force-push would cause severe conflicts. git revert adds a new commit without touching existing history, so everyone stays in sync.
5. Which statements correctly describe git revert? (Select all that apply)
(select all that apply)
git revert always adds an anti-commit, leaving the full history intact. You can revert any commit by hash — not just HEAD. The bad commit remains in the log, which is actually useful for auditing. This makes it the standard safe-undo tool for shared branches.
6. A colleague used git reset --hard HEAD~3 on the shared main branch and force-pushed. Three commits are gone from the remote. What is the impact and how would you recover?
Force-pushing rewrites remote history. Every teammate who already pulled those commits now has a diverged local copy. Recovery is possible if someone still has the commits (via git reflog or their local branch), but it requires coordination. This is why git revert is always preferred on shared branches — it never rewrites history.
7. In Task 4 you ran git add study_notes.txt then rm study_notes.txt, leaving the file staged but deleted from disk. Which commands, if run before git commit, would have ensured the deletion was what got committed — so the file stays gone after git reset --hard HEAD? (Select all that apply)
(select all that apply)
All three correct options converge on the same goal: make the index (staging area) reflect the absence of study_notes.txt before committing. git add <deleted-file> tells Git ‘stage what the working tree shows — nothing’; git rm --cached removes directly from the index; git restore --staged resets the index entry to HEAD’s state (no file). The distractor, git restore study_notes.txt (without --staged), does the opposite: it copies the staged version back to disk, recreating the file — which would cause the commit to add the file, not delete it.
8. Construct the command that moves notes.txt out of the staging area while leaving your working-directory edits untouched.
(arrange in order)
gitrestore--stagednotes.txtrm--cachedaddresetgit restore --staged <file> copies the version of the file from the last commit back into the index, effectively removing it from staging. Your working-directory edits are completely untouched. Without --staged, git restore would discard your working-directory edits instead.
9. Construct the command that removes notes.txt from the index only (staging the deletion) without deleting anything from the filesystem.
(arrange in order)
gitrm--cachednotes.txtrestore--stagedadd-fgit rm --cached <file> removes a file from the index (staging area) while leaving the file on disk. The next commit will record the file as deleted. This is the complement of git restore --staged: both manipulate the index without touching the working tree, but in opposite directions.
10. Construct the command that resets your working directory to exactly match the latest commit, restoring any files that were deleted from disk. (arrange in order)
gitreset--hardHEADrestore--softHEAD~1revertgit reset --hard HEAD synchronises both the index and the working directory with the tip of the current branch. Any files present in HEAD but missing from disk (like notes.txt after rm) are restored. Never use this on uncommitted work you want to keep — --hard discards all unstaged and staged changes permanently.
11. Construct the command that safely undoes the last commit on a shared branch by creating a new inverse commit, without opening an editor. (arrange in order)
gitrevertHEAD--no-editreset--hardHEAD~1restoregit revert HEAD --no-edit creates a new commit that exactly inverts the changes in HEAD, preserving the full history. --no-edit accepts Git’s default revert message without opening a text editor. The distractors (reset --hard HEAD~1) represent the dangerous alternative: it destroys commits rather than adding a safe inverse.
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:
┌──────────────┐ git add/commit ┌──────────────┐ git push ┌──────────────┐
│ Working │ ───────────────▶ │ Local │ ─────────────▶ │ Remote │
│ Directory │ ◀─────────────── │ Repo │ ◀───────────── │ Repo │
└──────────────┘ git restore └──────────────┘ git pull └──────────────┘
(your machine) (e.g. GitHub)
git clone <url> — Download a full copy of a remote repository
(including its entire history) to your machinegit push — Upload your local commits to the remote repositorygit pull — Download and merge new commits from the remote into
your local branchWe 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
cd /tutorial/myproject
git remote add origin /tutorial/remote-repo.git
origin is the conventional name for your primary remote.
Before you run: Think about what
git pushwill 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.
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
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.
1. What does git clone do?
git clone creates a complete, independent copy of the repository — including every commit, branch, and tag. You get the full history, not just the latest snapshot.
2. What is the difference between git push and git pull?
git push sends your local commits upstream. git pull fetches new commits from the remote and merges them into your local branch. Together, they keep your local and remote repositories in sync.
3. [Interleaved: Revisit Step 12] A colleague pushed a broken commit to main. Which command should you use to undo it safely on the shared branch?
git revert creates a safe anti-commit. On shared branches, never use git reset --hard + force-push — it rewrites history and breaks every teammate’s local copy.
4. Your team has a choice: everyone pushes directly to main, or everyone works on feature branches and merges via pull requests. What are the trade-offs?
Feature branches + pull requests are the industry standard because they provide isolation (broken code doesn’t affect main), enable code review before merging, and create a clear history of what was reviewed and approved. The trade-off is process overhead, which is worth it for most teams.
5. During a large merge, you know that all conflicting lines should be resolved in favour of the incoming feature branch. Which command avoids manual conflict resolution while still combining non-conflicting changes normally?
-X theirs tells Git to automatically resolve every conflict by keeping the incoming branch’s version. Non-conflicting changes from both branches are still combined normally. -X ours does the opposite — keeps the current branch’s version. These flags are useful when one side is clearly authoritative, saving you from resolving each conflict marker by hand.
Seriously, nice work. You’ve gone from zero to a solid Git workflow. Let’s review everything you’ve picked up:
| 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 |
.gitignore early — set it up before your first commit.env filesTime 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.
feature-power-surgepower_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!"
mainfeature-power-surge into mainPush 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.
git switch flag that creates a
branch and immediately switches to it in one command.
git add <file> then
git commit -m "message". Use a descriptive message.
git merge. Preview changes first with
git diff main..feature-power-surge.
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.
<<<<<<<,
=======,
>>>>>>>), 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.
1. Which scenarios call for git revert rather than git restore? (Select all that apply)
(select all that apply)
git revert is the tool for safely undoing committed, shared history. git restore --staged handles accidentally staged files. git restore <file> discards uncommitted working-directory edits. git reset --hard on a shared branch rewrites history and would break teammates who already pulled.
2. You want to see the full history graph including all branches in one compact view. Which command is correct?
--graph draws ASCII art showing branch structure and merge points. --all includes all branches, not just the current one. --oneline keeps it readable. Together they give the most complete overview of your repository’s entire history.
3. A colleague shares a project folder via USB — it has source files but no .git directory. git status reports ‘not a git repository’. What is the single command needed before you can start tracking changes?
Without .git/, the folder is not a repository — git init creates it. git clone only works with a remote URL. git add requires a repository to already exist. The error ‘not a git repository’ always means git init (or git clone) needs to run first.
4. You’ve modified 5 files but only want 2 of them in your next commit. Which staging approach gives you the most precise control?
Staging files by name is the most direct way to control what enters each commit — the core lesson from Step 4. While git add . followed by git restore --staged would also work, naming files explicitly is simpler and less error-prone.
5. You ran git add . and accidentally staged secrets.env alongside your real changes. You need to unstage only that file while keeping everything else staged and your edits intact. What do you run?
git restore --staged <file> is surgical: it moves one file off the post editor while leaving the rest of your staged changes untouched. Without --staged, git restore would also discard the working-directory edits — a destructive difference.
6. Without a staging area, git commit would have to snapshot every modified file at once. What capability would you lose?
The staging area is the mechanism that decouples ‘what you’re working on’ from ‘what you’re ready to commit’. Without it, every commit would be an all-or-nothing snapshot, making it impossible to create clean, single-purpose history entries from a working directory in flux.
7. You want to see the line-by-line differences of what you’ve modified but not yet staged. Which command do you use?
git diff compares the working directory to the staging area.
8. Which command shows a chronological list of all commits, their authors, and their unique SHA-1 hashes?
git log prints the full chain of snapshots — each entry shows the unique commit hash, author, timestamp, and message. Add --oneline to compress to one line per commit, --graph to draw ASCII branch structure, and --all to include every branch. You used this in Step 7 to inspect commits and in Steps 10–11 to verify merges and track history.
9. When you merge two branches that have diverged (both have unique commits), what kind of commit does Git create to combine them?
When two branches have diverged (each has unique commits since the split), Git finds their common ancestor commit, then compares both tips against it. Changes that don’t overlap are combined automatically; lines changed differently by both branches become a conflict. The result is a merge commit with two parents — visible as a join point in git log --oneline --graph. You set this up and experienced it in Steps 10–11.
10. A teammate asks: ‘Can I use git merge --abort to cancel the whole merge after I’ve already fixed half the conflicts?’ What do you tell them?
git merge --abort cancels an in-progress merge at any point — even mid-resolution — restoring your working directory and staging area to the state before git merge was run. It is the safe escape hatch if you decide the merge strategy needs rethinking.
11. Which command is the safest way to undo a mistake that has already been committed and potentially shared with a team?
git revert is the safe undo for committed, shared work: it creates a new commit that applies the exact inverse of the target commit, leaving all existing history untouched. git reset --hard, by contrast, destroys commits by moving the branch pointer backward and requires a force-push on shared branches — breaking every teammate who already pulled. You practiced this distinction directly in Step 12.
12. Six months ago, .env containing database credentials was accidentally committed to main. You’ve since added .env to .gitignore and committed. Is the secret safe from someone who clones the repository today?
.gitignore only affects future git add and git status behaviour — it never rewrites history. A cloned repository receives the full commit history including the commit that added .env. This is why Step 6 emphasised creating .gitignore before your first commit.
13. Why does git switch sometimes change the files you see in your file explorer?
Git’s ‘Time Machine’ capability replaces your files with the versions from the target snapshot.
14. You staged app.py with git add. Which command shows you exactly what will be in the next commit — before you actually commit?
git diff --staged compares the staging area to the last commit — showing precisely what git commit would snapshot. git diff without flags shows only unstaged changes (which would be nothing here). git show HEAD inspects what was already committed.
15. You are about to run git merge feature from main. Select the things you should check first. (Select all that apply)
(select all that apply)
Before merging: (1) be on the right branch, (2) preview the incoming changes, (3) start from a clean working directory so you don’t mix in-progress work with conflict resolution. Pushing first is unrelated to the merge — you push after the merge is complete.
16. Arrange the steps of the local Git workflow in the correct order, from editing a file to having it permanently saved in history. (arrange in order)
Edit file in working directorygit add git commit -m 'message'git pushgit pullThe local workflow is edit → stage → commit. git push uploads to a remote and is a separate step that happens after committing. git pull downloads remote changes — it is not part of the local save workflow. A commit is permanent in the local repository regardless of whether you ever push.
17. A teammate always commits directly to main without creating feature branches. Which professional best practice does this violate, and what does the team lose?
Feature branches provide isolation: your in-progress work never touches the stable shared branch until it is ready and reviewed. Without branching, one broken commit immediately affects every teammate. Branches also enable pull-request code review and make reverting a logical unit of work trivial — as you practiced throughout Steps 8–13.
18. Which of the following are best practices for professional Git usage covered in this tutorial? (Select all that apply) (select all that apply)
git push -f rewrites shared history and breaks every teammate who already pulled — the opposite of a best practice on shared branches. The other three were explicitly taught throughout this tutorial: descriptive messages (Step 2), .gitignore first (Step 6), and safe undo with git revert (Step 12).
19. After running git push -u origin main, a teammate clones the repository and makes two commits. You run git pull. What does git pull actually do under the hood?
git pull is shorthand for two operations: git fetch downloads new commits from the remote without touching your working directory, then git merge integrates those commits 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.
20. You run git push and get ! [rejected] ... (fetch first). What does this mean and what should you do?
A rejected push means the remote is ahead of your local branch — someone pushed while you were working. Git refuses your push to prevent you from overwriting their work. The fix: git pull (download and merge), resolve conflicts if any, then git push. Never use --force on shared branches.
21. A colleague suggests using git push --force whenever a regular push is rejected. Why is this dangerous on a shared branch?
git push --force replaces the remote’s history with yours, permanently deleting any commits that only existed on the remote. Every teammate who already pulled those commits now has a diverged local copy. This is why the safe workflow is always pull → resolve → push.
22. Arrange the correct workflow when git push is rejected because the remote has new commits.
(arrange in order)
git pullResolve any merge conflicts in the editorgit add git commitgit pushgit push --forcegit reset --hard origin/maingit cloneWhen a push is rejected: (1) git pull downloads and attempts to merge the remote commits, (2) if there are conflicts, resolve them manually, (3) git add marks them resolved, (4) git commit completes the merge, (5) git push now succeeds because your branch includes both your work and the remote’s. The distractors are all dangerous or unnecessary — --force overwrites the remote, reset --hard destroys your local work, and clone starts over entirely.
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.