Version Control with Git — Sample Solutions
Version Control with Git — Sample Solutions
These are reference solutions for each exercise in the interactive tutorial. For Git steps, the “solution” is the sequence of terminal commands to run. Each solution explains why the commands are correct.
Step 1: Your First Repository
# Initialize a new repository in a directory called myproject
git init myproject
# Navigate into it
cd myproject
# Explore what was created
ls -la
Why this is correct:
git init myproject: Creates a new directorymyproject/and initializes a.git/folder inside it. The.git/folder is the entire repository — it stores all history, branches, and configuration. Without it, the directory is just a regular folder.- The tests check: (1)
git config user.namereturns a non-empty value (already configured by the tutorial setup), (2)git config user.emailreturns a value, and (3)/tutorial/myproject/.gitexists as a directory. - Plumbing vs. porcelain:
git initis a porcelain command. Internally it creates low-level object store directories (objects/,refs/) — the plumbing that all other commands build on.
Step 2: Your First Commit
# Make sure you're in the myproject directory
cd /tutorial/myproject
# Check the status — calculator.py should appear as untracked (red)
git status
# Stage the file (move to staging area / loading dock)
git add calculator.py
# Check status again — calculator.py should now be green (staged)
git status
# Commit the staged snapshot with a descriptive message
git commit -m "Add calculator module with add and subtract"
# Verify — should say "nothing to commit, working tree clean"
git status
Why this is correct:
git add calculator.py: Moves the file from the Working Directory to the Staging Area. Beforegit add, the file is “untracked” — Git sees it but doesn’t track it. After, it’s “staged” (green ingit status).git commit -m "Add calculator module with add and subtract": Creates a permanent snapshot. The test checksgit log --oneline | head -1 | grep -qi 'calc\|add'— so the commit message must contain “calc” or “add” (case-insensitive).- The test also verifies
git log --oneline -- calculator.py | grep -q '.'—calculator.pymust appear in at least one commit’s history. - Why the two-step add/commit? The Staging Area lets you precisely control what goes into each commit. You can edit 10 files but commit only 3 as one logical change.
Step 3: The Edit-Stage-Commit Cycle
# Open calculator.py in the editor and add this function at the bottom:
# def multiply(a, b):
# """Return the product of a and b."""
# return a * b
# After saving (Ctrl+S), check status — calculator.py should be "modified" (red)
git status
# Preview what changed before staging (working directory vs. staging area)
git diff
# Stage and commit
git add calculator.py
git commit -m "Add multiply function to calculator"
# Review your history
git log
The completed calculator.py:
"""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
def multiply(a, b):
"""Return the product of a and b."""
return a * b
Why this is correct:
- Test 1:
grep -q 'def multiply' calculator.py— themultiplyfunction must exist in the file. - Test 2:
git log --oneline | grep -qi 'multiply'— the commit message must contain “multiply” (case-insensitive). The sample message"Add multiply function to calculator"satisfies this. - Test 3:
[ $(git log --oneline | wc -l) -ge 2 ]— the repository must have at least 2 commits total. git diffbefore staging: Compares the Working Directory to the Staging Area. Since nothing is staged yet, the staging area still matches the last commit — sogit diffshows yourmultiplyfunction as new lines with+.
Step 4: Staging Strategies
# See all untracked files
git status
# Stage only README.md
git add README.md
git status # README.md is green; others still red
# Stage test files using a glob pattern
git add test_*.py
git status # test_calc.py and test_utils.py are now green
# Stage everything remaining (notes.txt)
git add .
git status # All files are green
# Commit all staged files
git commit -m "Add test files, README, and project notes"
Why this is correct:
git add README.md: Stages onlyREADME.md. The test verifiesgit log --all --oneline -- README.md | grep -q '.'.git add test_*.py: The shell glob expands totest_calc.py test_utils.py. Both are staged. Tests verify both files appear in history.git add .: Stages everything in the current directory and subdirectories — includingnotes.txt. The test verifiesnotes.txtis in history.- Four staging strategies: Individual file (
git add README.md), wildcard (git add test_*.py), current directory (git add .), all tracked+untracked (git add --all). All achieve the same end result here but give different levels of control.
Step 5: Unstaging and Undoing Changes
# Add broken content and stage it
echo "BROKEN CODE" >> calculator.py
git add calculator.py
git status # calculator.py is staged (green)
# Unstage it — move off the loading dock WITHOUT losing the edit
git restore --staged calculator.py
git status # calculator.py is now modified but unstaged (red)
# Discard the working directory change entirely
git restore calculator.py
git status # "nothing to commit, working tree clean"
Why this is correct:
- Test 1:
! grep -q 'BROKEN CODE' calculator.py— the “BROKEN CODE” line must NOT be in the file.git restore calculator.pyrestores it to the last committed version. - Test 2:
git diff --quiet && git diff --cached --quiet— both the working directory and the staging area must be clean (no uncommitted changes). git restore --staged: Moves the file from staged → modified-but-unstaged. Edits are preserved — they stay in the working directory.git restore(without--staged): Discards working directory changes permanently. There is no undo — the file was never committed, so Git has no record of the “BROKEN CODE” version.- Warning:
git reset --hardwould discard ALL uncommitted changes across all files — the nuclear option. Use it only when you’re sure.
Step 6: Ignoring Files with .gitignore
# Simulate problem files
mkdir -p __pycache__
echo "bytecode" > __pycache__/calculator.cpython-311.pyc
echo "SECRET_KEY=abc123" > .env
echo "debug log" > debug.log
# See them appearing in git status
git status
# Open .gitignore in the editor and add these patterns:
# __pycache__/
# *.pyc
# .env
# *.log
# After saving, verify the files vanish from status
git status # Only .gitignore itself appears as untracked
# Commit the .gitignore
git add .gitignore
git commit -m "Add .gitignore to exclude compiled and secret files"
The completed .gitignore:
__pycache__/
*.pyc
.env
*.log
Why this is correct:
- Tests verify each pattern:
grep -q '__pycache__' .gitignore,grep -q '.env' .gitignore,grep -q '\*.pyc' .gitignore. .gitignoreis committed:git log --oneline -- .gitignore | grep -q '.'— the file must appear in history..envis not tracked:! git ls-files --cached | grep -q '.env'— the secret file must never have been staged or committed.__pycache__/: The trailing/matches only directories named__pycache__, not a hypothetical file with that name.*.pyc: A glob that matches any file ending in.pycin any subdirectory.- Why commit
.gitignore? Sharing it ensures all contributors automatically get the same ignore rules — including protection against accidentally committing.envsecrets.
Step 7: Inspecting History
# View full commit log (press q to exit)
git log
# Compact one-line view — easier to scan
git log --oneline
# Inspect the most recent commit (full diff)
git show HEAD
# Compare previous commit to latest
git diff HEAD~1 HEAD
Why this is correct:
- Test:
[ $(git log --oneline | wc -l) -ge 3 ]— the repository must have at least 3 commits. By this step, you have: initial commit (Step 2), multiply commit (Step 3), and gitignore commit (Step 6). git log: Shows hash, author, date, and message for each commit. The hash is a 40-character SHA-1 identifier for each snapshot.git show HEAD: Displays the metadata plus the complete diff of the most recent commit.HEADis a symbolic reference that always points to the currently checked-out commit.HEAD~1: Relative syntax for “one commit before HEAD”.HEAD~2is two commits back, etc.git diffvariants to know:git diff— Working Directory vs. Staging Area (unstaged changes)git diff HEAD— Working Directory vs. Last Commit (all uncommitted changes)git diff --staged— Staging Area vs. Last Commit (what would be committed)git diff HEAD~1 HEAD— Previous commit vs. latest commit
Step 8: Branching
# List current branches
git branch # shows "* main"
# Create and switch to a new branch
git switch -c feature-divide
# Confirm you're on the new branch
git branch # shows "* feature-divide"
# Open calculator.py 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
# Stage and commit on the feature branch
git add calculator.py
git commit -m "Add divide function with zero-division check"
# Switch back to main — the divide function disappears
git switch main
cat calculator.py # no divide function here
# Switch back to feature to verify
git switch feature-divide
cat calculator.py # divide function is back
# Explore the branch pointer files
cat .git/refs/heads/main
cat .git/refs/heads/feature-divide
cat .git/HEAD
# Inspect an old commit in detached HEAD state, then return
git switch --detach HEAD~1
git switch feature-divide
Why this is correct:
- Test 1:
git branch | grep -q 'feature-divide'— the branch must exist. - Test 2:
git show feature-divide:calculator.py | grep -q 'def divide'— the divide function must exist on the feature branch. - Test 3:
git log feature-divide --oneline | grep -qi 'divide'— the commit message must reference “divide”. - Test 4:
git branch --show-current | grep -q 'feature-divide'— you must finish on thefeature-dividebranch (after returning from detached HEAD). git switch -c feature-divide:-ccreates and switches in one command. A branch is just a 41-byte file in.git/refs/heads/containing the current commit’s SHA.- Disappearing divide function: When you
git switch main, Git updates your working directory to match the snapshot thatmainpoints to — thedividefunction was never committed tomain, so it vanishes. This is the power of branches as separate timelines.
Step 9: Merging Branches
# Switch to the branch you want to merge INTO
git switch main
# Merge the feature branch (fast-forward — main has no new commits)
git merge feature-divide
# Verify the divide function is now on main
cat calculator.py
git log --oneline # feature commit should now appear on main
# Optional cleanup: delete the feature branch
git branch -d feature-divide
Why this is correct:
- Test 1:
git branch --show-current | grep -q 'main'— you must be on main. - Test 2:
grep -q 'def divide' calculator.py— the divide function must be in the working file on main after the merge. - Test 3:
git log main --oneline | grep -qi 'divide'— the divide commit must be in main’s history. - Fast-forward merge: Because
mainhad no new commits sincefeature-dividewas created, Git simply slides themainpointer forward to the same commit asfeature-divide. No merge commit is created; the history stays perfectly linear. git branch -d feature-divide: The-dflag safely deletes only if the branch is fully merged. Its work is now part ofmain, so this is tidy cleanup.
Step 10: Preparing for a Merge Conflict
# Create the update-add-function branch and switch to it
git switch -c update-add-function
# Open calculator.py and change the add function to:
# 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
# Stage and commit
git add calculator.py
git commit -m "Add type checking to add function"
# Switch back to main (STAY here — conflict setup continues in next step)
git switch main
# Verify main still has the original add function
head -8 calculator.py
Why this is correct:
- Test 1:
git branch | grep -q 'update-add-function'— the branch must exist. - Test 2:
git log update-add-function --oneline | grep -qi 'type\|check\|add'— the commit message must reference “type”, “check”, or “add”. - Test 3:
git branch --show-current | grep -q 'main'— you must end onmain. - Why this creates a conflict: The
update-add-functionbranch changed theaddfunction. In the next step, you’ll make a different change to the same function onmain. When you then merge, both branches have diverging changes to the same lines — triggering a conflict.
Step 11: Resolving a Merge Conflict
# Make sure you're on main
git switch main
# Open calculator.py and change add to:
# def add(a, b):
# """Return the sum of a and b (with logging)."""
# print(f"Adding {a} + {b}")
# return a + b
# Stage and commit this change on main
git add calculator.py
git commit -m "Add logging to add function"
# Attempt the merge — this triggers a CONFLICT
git merge update-add-function
# Open calculator.py — you'll see conflict markers:
# <<<<<<< 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
# Edit calculator.py to combine both versions — remove ALL markers:
# 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
# Stage the resolved file (this marks the conflict as resolved)
git add calculator.py
# Complete the merge commit
git commit -m "Merge update-add-function: combine type checking and logging"
The resolved calculator.py add function:
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
Why this is correct:
- Test 1:
! grep -q '<<<<<<<\|=======\|>>>>>>>' calculator.py— all conflict markers must be removed. Leaving even one marker in the file is a bug. - Test 2:
! git status | grep -q 'Unmerged\|both modified'— no unmerged paths remain. - Test 3:
grep -q 'isinstance' calculator.py— the type-checking code fromupdate-add-functionmust be present. - Test 4:
grep -q 'print' calculator.py— the logging code frommainmust be present. - Conflict markers explained:
<<<<<<< HEADis your current branch’s version;=======is the separator;>>>>>>> branch-nameis the incoming version. You must edit the file to the version you want and remove all three marker types. git addafter resolution: Signals to Git that the conflict is resolved AND stages the content. Without it,git commitrefuses with “unmerged paths”. This is the samegit addas always — it just takes on this extra role during a merge.
Step 12: Safe Undo with git revert
# Introduce a deliberate "bad" commit
echo "print('debug: this should not be here')" >> calculator.py
git add calculator.py
git commit -m "Accidentally add debug print"
# View the history — bad commit is at the top
git log --oneline
# Revert it safely — creates a new "anti-commit" that undoes the bad one
git revert HEAD --no-edit
# Verify: the debug line is gone, but BOTH commits are still in history
git log --oneline
cat calculator.py
Why this is correct:
- Test 1:
git log --oneline | grep -qi 'revert'— a revert commit must exist in the log (Git’s default message is “Revert ‘…’”). - Test 2:
! grep -q 'debug: this should not be here' calculator.py— the debug line must be gone from the file. - Test 3:
[ $(git log --oneline | wc -l) -ge 8 ]— the repository must have at least 8 commits by now. git revert HEAD --no-edit: Creates a new commit that applies the exact inverse ofHEAD.--no-editaccepts the default message without opening a text editor.- Why NOT
git reset --hard:reset --harddestroys commits by moving the branch pointer backward — rewriting history. On a shared branch where teammates have already pulled, this would cause severe conflicts and require a force-push.git revertis always safe because it only adds new commits and never changes existing history.
Step 13: Review and Best Practices
# View the complete history as a graph
git log --oneline --graph --all
# Final check — view the current state of calculator.py
cat calculator.py
Why this is correct:
- Test 1:
[ $(git log --oneline | wc -l) -ge 10 ]— at least 10 commits in total. - Test 2:
grep -q 'def add' calculator.py && grep -q 'def subtract' calculator.py && grep -q 'def multiply' calculator.py && grep -q 'def divide' calculator.py— all four functions must be present in the finalcalculator.py. - Test 3:
.gitignoremust be in the commit history.
The final calculator.py:
"""A simple calculator module."""
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
def subtract(a, b):
"""Return the difference of a and b."""
return a - b
def multiply(a, b):
"""Return the product of a and b."""
return a * b
def divide(a, b):
"""Return the quotient of a and b."""
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
Git workflow summary: The complete workflow practiced in this tutorial:
git init → create repository
git add → stage changes (loading dock)
git commit → save snapshot (truck drives away)
git status → check current state
git log → view history
git diff → see what changed
git switch -c → create and switch to branch
git merge → combine branches
git restore → discard uncommitted changes
git revert → safely undo a committed change