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! On the right you have a code editor (top) and a real Linux terminal (bottom). Files you edit are automatically synced to the VM.
Imagine working on a project and saving files like
report_final_v2_REALLY_final.txt. Version control solves this chaos.
It lets you:
Git is the most widely used version control system in the world. Let’s learn it by building a small Python 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 packing a shipping box:
Your Git identity has already been configured for you (as Steven
Strange). 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.
Now let’s create our first Python file. A file in your working directory starts as untracked — Git doesn’t know about it yet.
The editor shows calculator.py — a simple calculator module.
It has already been saved to the VM. Now run:
git status
You should see calculator.py listed as an untracked file in red.
Git sees the file but isn’t tracking it yet.
Move the file from the Working Directory to the Staging Area:
git add calculator.py
Now run git status again. The file should appear in green under
“Changes to be committed”. It’s on the loading dock, ready to ship!
Save this snapshot permanently to the repository:
git commit -m "Add calculator module with add and subtract"
The -m flag lets you write a message describing what and why.
Good commit messages help your future self (and teammates) understand
the history.
Run git status one more time — it should say “nothing to commit,
working tree clean”. Your file is safely stored!
"""A simple calculator module."""
def add(a, b):
"""Return the sum of a and b."""
return a + b
def subtract(a, b):
"""Return the difference of a and b."""
return a - b
Git now tracks calculator.py. When you edit a tracked file, Git
notices the difference between what’s in your working directory and
what was last committed.
Open calculator.py in the editor and add this function at the
bottom of the file:
def multiply(a, b):
"""Return the product of a and b."""
return a * b
Save the file (Ctrl+S), then run in the terminal:
git status
You’ll see calculator.py is now listed as modified (in red).
The file is tracked, but your new changes haven’t been staged yet.
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 calculator.py
git commit -m "Add multiply function to calculator"
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.
The staging area lets you carefully choose exactly which changes become part of each commit. This is powerful — you can make multiple changes to your working directory but commit them in logical groups.
Several files have been added to your project — explore them using
the editor tabs above. Then run git status to see them all:
git status
Stage just one specific file:
git add README.md
git status
Notice: README.md is green (staged), while the others are still
red (untracked). You have precise control!
Stage all Python test files at once using a glob pattern:
git add test_*.py
git status
Both test_calc.py and test_utils.py should now be staged.
To add all untracked and modified files at once, use:
git add .
The . means “current directory and everything in it”. This stages
notes.txt too. Run git status to confirm everything is green.
git commit -m "Add test files, README, and project notes"
Now you’ve seen four ways to stage:
git add README.mdgit add test_*.pygit add .git add --all (or -A)"""Tests for calculator."""
"""Tests for utilities."""
# My Calculator Project A simple calculator
TODO: add division DONE: add multiply
What if you stage a file by accident? Or make changes you want to throw away? Git has tools for both.
Let’s edit a file and then undo our staging:
echo "BROKEN CODE" >> calculator.py
git add calculator.py
git status
You’ll see calculator.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 calculator.py
git status
The file is now modified but unstaged (red again). Your edit is
still in the working directory — git restore --staged only moves it
off the loading dock; it doesn’t delete anything.
Now let’s throw away the change entirely and restore the file to its last committed version:
git restore calculator.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 (move off loading dock, keep edits) |
git restore <file> |
Discard working directory changes (permanent!) |
git reset --hard |
Discard ALL uncommitted changes (nuclear option) |
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__/calculator.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
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.
The .gitignore file itself should be committed — it’s a project
configuration that all contributors benefit from:
git add .gitignore
git commit -m "Add .gitignore to exclude compiled and secret files"
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 always refers to the latest commit. 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
Branches let you develop features in isolation without affecting the main codebase. A branch is just a lightweight pointer to a commit — creating one is nearly instant and costs almost nothing.
Before branching:
main: [C1] ← [C2] ← [C3] ← HEAD
After creating feature branch:
main: [C1] ← [C2] ← [C3]
↑
feature: (points here too) ← HEAD
The branch is just a sticky note (pointer) on a commit — not a copy of your entire project!
git branch
You should see * main. The * indicates which
branch HEAD is currently pointing to.
git checkout -b feature-divide
This creates a new branch called feature-divide and switches to it.
(-b means “create the branch”). Run git branch to confirm you’re
on the new branch.
Add a divide function to calculator.py. Open it in the editor and
add at the bottom:
def divide(a, b):
"""Return the quotient of a and b."""
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
Save, then stage and commit:
git add calculator.py
git commit -m "Add divide function with zero-division check"
git checkout main
Now look at calculator.py in the terminal:
cat calculator.py
The divide function is gone! It only exists on the
feature-divide branch. Your main branch is untouched. This is
the power of branching.
Switch back to see it again:
git checkout feature-divide
cat calculator.py
The function is back. Each branch is a separate timeline.
When a feature is complete, you merge it back into the main branch. This combines the histories of both branches.
Before merge:
main: [C1] ← [C2] ← [C3]
\
feature-divide: [C4] (divide function)
After merge:
main: [C1] ← [C2] ← [C3] ← [C4] ← HEAD
\ /
feature-divide: [C4]
First, switch to the branch you want to merge into (main):
git checkout main
Now merge the feature branch:
git merge feature-divide
Check that the divide function is now on main:
cat calculator.py
git log --oneline
You should see the divide function in the file and the commit from
feature-divide 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-divide
The -d flag safely deletes a branch only if it’s been fully merged.
This keeps your branch list tidy.
A merge conflict happens when two branches modify the same lines of the same file. Git can’t decide which version to keep, so it asks you — the human — to resolve it.
This is not an error. It’s Git being careful and asking for help. Let’s create this situation intentionally so you know exactly how to handle it.
git checkout -b update-add-function
Now open calculator.py in the editor and change the add
function to include a type check:
def add(a, b):
"""Return the sum of a and b (integers only)."""
if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
raise TypeError("Arguments must be numbers")
return a + b
Save, stage, and commit:
git add calculator.py
git commit -m "Add type checking to add function"
git checkout main
Verify that main still has the original add function
(without type checking):
head -8 calculator.py
Important: Stay on main and proceed to the next step. In the
next step, we’ll make a different change to the same add function
on main, setting up a conflict!
In the previous step, you changed the add function on the
update-add-function branch. Now we’ll make a different change to
the same function on main, creating a conflict.
Make sure you’re on main:
git checkout main
Open calculator.py in the editor and change the add function to
log its inputs:
def add(a, b):
"""Return the sum of a and b (with logging)."""
print(f"Adding {a} + {b}")
return a + b
Save, stage, and commit:
git add calculator.py
git commit -m "Add logging to add function"
Now try to merge the other branch:
git merge update-add-function
Git will report a CONFLICT! It found that both branches changed
the same lines in calculator.py and can’t automatically combine
them.
Open calculator.py in the editor (or run cat calculator.py).
You’ll see something like:
<<<<<<< HEAD
"""Return the sum of a and b (with logging)."""
print(f"Adding {a} + {b}")
return a + b
=======
"""Return the sum of a and b (integers only)."""
if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
raise TypeError("Arguments must be numbers")
return a + b
>>>>>>> update-add-function
<<<<<<< HEAD — your current branch’s version (main)======= — separator>>>>>>> update-add-function — the incoming branch’s versionEdit calculator.py to combine both changes. Remove ALL conflict
markers (<<<<<<<, =======, >>>>>>>) and write the merged
version you want to keep. For example, keep both the logging and the
type check:
def add(a, b):
"""Return the sum of a and b (with type checking and logging)."""
if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
raise TypeError("Arguments must be numbers")
print(f"Adding {a} + {b}")
return a + b
After editing, mark the conflict as resolved and commit:
git add calculator.py
git commit -m "Merge update-add-function: combine type checking and logging"
Congratulations — you’ve resolved a merge conflict! This is a skill that even experienced developers practice regularly.
You’ve learned the fundamental Git workflow. Let’s review everything you’ve mastered:
| 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 changes |
git branch |
List/create branches |
git checkout -b |
Create and switch branch |
git merge |
Combine branch histories |
.gitignore early — set it up before your first commit.env filesTake a look at everything you’ve built:
git log --oneline --graph --all
The --graph flag draws an ASCII art representation of your branch
structure. The --all flag shows all branches, not just the current
one.
cat calculator.py
From an empty folder to a version-controlled Python project with branching and merge conflict resolution — well done!