Learn git, with practice after every lesson
8 lessons, about 124 minutes of reading, and 24 multiple-choice questions. Each lesson names the mistake that most often costs people the interview, because that is where the hours actually go. Part of the free coding course.
Commits, and what they really are
BasicsGit saves snapshots of your whole project, not edits to single files. Each snapshot is a commit, and every commit knows which one came before it. That chain is the entire model β once it clicks, most of Git stops feeling arbitrary and starts feeling obvious.
The part beginners miss is the staging area. A file you have edited is not automatically part of the next commit; you choose what goes in with git add. That indirection seems like a nuisance until the day you have fixed two unrelated things in one sitting and want them recorded as two separate commits, which is exactly when it becomes indispensable.
Three states describe every file: modified (edited), staged (chosen for the next commit), and committed (saved). git status names which one each file is in, and reading it before every commit is the habit that prevents the most common accident in this material β running git add . without looking and committing a secret, a 200 MB export, or a page of debug prints.
Syntax
git status # what has changed, and what is staged
git add report.py # stage one file
git add . # stage everything changed
git commit -m "Fix the date filter losing the last day"
git log --oneline -5 # the last five commits
# a3f9c21 Fix the date filter losing the last day
# 88b1e04 Add weekly summary export
# 1c77aa9 Initial commit
git diff # unstaged changes
git diff --staged # what the next commit will contain
Key points
- Three states: modified (edited), staged (chosen for the next commit), committed (saved). git status names which one every file is in.
- Write the message as what the change does, not what you did: 'Fix the date filter losing the last day' beats 'updates'. You are writing for whoever bisects this in a year.
- Commit small and often. A commit containing one fix can be reverted; a commit containing four cannot be untangled.
Practice challenge
You fixed a date bug in report.py and, separately, added debug prints to loader.py that you do not want committed. Write the commands to commit only the bug fix, with a message that says what the change does.
git add report.py
git commit -m "Fix the date filter losing the last day"
$ ______
$ ______
Show a hint
- git add takes a path, not just a dot
- The message should describe the change, not the act of changing
Check yourself
1. What does git add do?
Show answer
B. Stages a change for the next commit
2. Why commit small and often?
Show answer
B. A small commit can be reverted cleanly
3. You committed a password by mistake. Does deleting the file fix it?
Show answer
B. No, the earlier commit still contains it
Branches and pull requests
Working levelA branch is a movable label pointing at a commit. Making one costs nothing β no files are copied β which is why teams make one per piece of work rather than treating branches as a big decision. Understanding that a branch is a pointer rather than a copy explains most of Git's behaviour that otherwise looks strange.
This is the daily rhythm of almost every engineering job, and it is what a hiring manager is checking when they ask how you work: branch, commit, push, open a pull request, get review comments, address them, merge. Being able to describe that loop naturally matters more in an interview than knowing any individual command.
Two habits make it go smoothly. Pull main before you merge into it, so you are not merging onto a stale base and re-solving a conflict somebody already fixed. And treat the pull request as a conversation rather than a formality β reviewers are the cheapest bug detection a team has, and a candidate who describes review as something they value reads very differently from one who describes it as a hurdle.
Syntax
git switch -c fix/date-filter # create and move onto a branch
# ...edit, add, commit...
git push -u origin fix/date-filter
# open a pull request on GitHub, get review, then:
git switch main
git pull # take other people's merged work first
git merge fix/date-filter
git branch -d fix/date-filter # tidy up after merging
# if main moved on while you worked, replay your work on top:
git switch fix/date-filter
git rebase main
Key points
- Branch names that say what and why β fix/date-filter, add/csv-export β make a busy repository readable at a glance.
- Pull main before you merge into it. Merging stale work is how a conflict you already solved comes back.
- A pull request is a conversation, not a formality. Reviewers are the cheapest bug detection a team has.
Practice challenge
Write the sequence to create a branch for a CSV export feature, push it so a pull request can be opened, then after review merge it into main β including the step that stops you merging onto a stale base.
git switch -c add/csv-export
git push -u origin add/csv-export
git switch main
git pull
git merge add/csv-export
$ ______
(work, commit)
$ ______
$ ______
$ ______
$ ______
Show a hint
- The stale-base step happens after switching to main and before merging
- -u sets the upstream so later pushes need no arguments
Check yourself
1. What does a branch actually cost to create?
Show answer
B. Almost nothing β it is a label on a commit
2. Why pull main before merging into it?
Show answer
B. So you are not merging onto a stale base
3. What does git rebase main do to your branch?
Show answer
B. Replays your commits on top of the current main
Undoing things without panic
AdvancedMost Git fear is fear of losing work. Almost nothing committed is ever truly lost: Git keeps a log of every position each branch has held, so a bad reset is usually recoverable in one command. Knowing that changes how confidently you work, because the cost of experimenting drops to nearly nothing.
The distinction that matters is whether the commit has been pushed. Rewriting history you have kept to yourself is free β amend, rebase, reorder as you like. Rewriting history other people have already pulled forces them to repair their own copies, which is why force-pushing a shared branch is treated as a serious mistake rather than a preference.
That gives a simple rule. On a shared branch use revert, which adds a new commit undoing an old one and leaves history intact. Keep reset for local work. And know that reflog exists before you need it: it is the undo button for the undo button, and the only thing it cannot recover is uncommitted changes destroyed by reset --hard, which is the one genuine way to lose work in Git.
Syntax
git restore report.py # throw away uncommitted edits to a file
git restore --staged report.py # unstage, keep the edits
git commit --amend -m "Better message" # fix the LAST commit (unpushed only)
git revert a3f9c21 # new commit that undoes an old one β safe when shared
git reset --soft HEAD~1 # undo last commit, keep changes staged
git reset --hard HEAD~1 # undo last commit AND discard changes
# the safety net β every position HEAD has held:
git reflog
# a3f9c21 HEAD@{0}: reset: moving to HEAD~1
# 88b1e04 HEAD@{1}: commit: Add weekly summary export
git reset --hard 88b1e04 # go back to before the mistake
Key points
- revert is safe on shared branches because it adds a commit. reset rewrites history and should stay local.
- reflog is the undo button for the undo button. Before assuming work is gone, look there.
- --hard is the only one of these that destroys uncommitted work. Everything else is recoverable.
Practice challenge
Commit a3f9c21 broke production. It was pushed three days ago and four colleagues have pulled it. Give the command you would use, and say in one line why the other obvious option is wrong here.
git revert a3f9c21
reset rewrites history others already have, forcing them to repair their own copies; revert adds a new commit and leaves history intact
Command: ______
Why not the other: ______
Show a hint
- The deciding fact is that other people already pulled it
- One of these two is safe on shared branches and one is not
Check yourself
1. Which is safe to use on a branch others have pulled?
Show answer
B. git revert
2. What is git reflog for?
Show answer
B. Seeing every position HEAD has held, to recover from a bad reset
3. What does git reset --hard destroy that reflog cannot recover?
Show answer
B. Uncommitted changes
Work on a team without breaking main
Job-readyEverything you have learned so far assumed you were alone. On a team the commands barely change and the discipline around them is the whole job: main must always be deployable, your branch must be small enough to review, and a conflict is a conversation rather than an emergency.
Small branches are the single biggest lever on how fast your work gets merged. A 40-line pull request gets a careful review in ten minutes; an 800-line one gets "looks good to me" three days later, which is not a review at all. If a change is genuinely large, split it into a series where each piece is safe on its own β a refactor with no behaviour change, then the behaviour change.
Conflicts are not a sign anything went wrong. They mean two people edited nearby lines, and Git is correctly refusing to guess. Pull main into your branch often so you meet conflicts in ones and twos rather than in a wall at the end. When you resolve one, read both sides and understand why the other person made their change before you keep yours.
Syntax
# Start from a current main, always
git switch main && git pull
git switch -c fix/duplicate-invoice-rows
# ... small, focused commits ...
git add -p # stage hunks, not whole files
git commit -m "Dedupe invoice rows on (client_id, period)"
# Keep up with main WHILE you work, not at the end
git fetch origin
git merge origin/main # conflicts arrive in ones, not in a wall
git push -u origin fix/duplicate-invoice-rows
# open the PR, describe WHAT CHANGED and WHY, link the ticket
# After review, if history is messy, tidy before merge:
git rebase -i origin/main # squash "fix typo" into its parent
git push --force-with-lease # never plain --force on a shared branch
Key points
- Keep the branch small. Review quality falls off a cliff somewhere around 200 changed lines, and an unreviewed merge is the thing branches exist to prevent.
- Merge main into your branch regularly. Conflicts met early are minutes; conflicts met at the end are hours.
- --force-with-lease, never --force. It refuses if someone else pushed in the meantime, which is exactly the case where forcing destroys their work.
Practice challenge
You branched from main two weeks ago and never pulled. Merging now produces 40 conflicts. Say what you should have done differently, how you resolve this safely now, and why 'take mine everywhere' is dangerous specifically here.
Should have: merged origin/main into the branch regularly, so conflicts arrived in ones and twos
Resolve now: merge main in, work through conflicts file by file, and for each read BOTH sides to understand the other change before choosing; run the tests after
Why not take-mine: it silently reverts two weeks of colleagues' work. The tests may still pass, because reverting a change usually compiles fine - which is what makes it dangerous
Should have: ______
Resolve now by: ______
Why not take-mine: ______
Show a hint
- The danger is not that it breaks the build - it is that it does not
- Frequency is the fix; forty conflicts is twenty conflicts you postponed twice
Check yourself
1. Why keep pull requests small?
Show answer
B. Large PRs get rubber-stamped rather than reviewed
2. What does --force-with-lease add over --force?
Show answer
B. It refuses if someone else has pushed since you fetched
3. A merge conflict means:
Show answer
B. Two people changed nearby lines and Git will not guess
What must never go in a repository
BasicsA repository is public history even when the repo is private, because private repos get shared, forked, made public by an admin who did not think, or handed to a contractor for a week. So the question is never 'is this repo private', it is 'am I willing for this to exist forever in a place I do not control'. Three things fail that test: secrets, generated files, and large binaries.
Secrets are the one that ends careers. An API key committed and then deleted in the next commit is still in the history, still readable by anyone who clones, and still valid until somebody rotates it. GitHub scans public pushes and bots scrape them within seconds β the measured time from pushing an AWS key to it being used for crypto mining is often under five minutes. The fix is not deleting the file, it is rotating the key, because you must assume it is already gone.
Generated files and large binaries are less dramatic but they are what makes a repo miserable to work in. node_modules, build output, a 200 MB CSV export, a compiled .jar: none of them are source, all of them can be produced from source, and every one of them permanently inflates the repo for every person who ever clones it. .gitignore is how you say so once, at the start, before the first commit rather than after.
Syntax
# .gitignore β write this BEFORE the first commit
.env # secrets: never, under any circumstances
.env.local
*.pem
*.key
node_modules/ # generated: reinstallable from package.json
dist/
build/
__pycache__/
*.pyc
data/*.csv # large: keep the loader, not the export
*.xlsx
.DS_Store # noise from your machine, not the project
.vscode/
# Already tracked before you ignored it? .gitignore will NOT help.
git rm --cached .env # stop tracking, keep your local copy
git commit -m "Stop tracking .env"
# Check what you are about to commit, every time:
git status
git diff --staged
Key points
- A secret committed once is compromised forever. Deleting the file does not help β the old commit still contains it. Rotate the key; that is the only real fix.
- .gitignore only stops files that are not already tracked. If you committed it first you need git rm --cached before the ignore takes effect.
- Commit source, not things built from source. If a file can be regenerated by a command, ignore it and write the command down instead.
Practice challenge
You committed and pushed .env two weeks ago, then added it to .gitignore, and it is still showing as tracked. Write the commands to stop tracking it, and say what you must do about the key inside it.
$ git rm --cached .env
$ git commit -m "Stop tracking .env"
Why: .gitignore only affects files that are NOT already tracked; .env was tracked, so it kept being tracked
The key: rotate it. It is in the pushed history and in every clone. Deleting the file does not remove it from the earlier commits.
$ ______
$ ______
Why .gitignore did not help: ______
What must happen to the key: ______
Show a hint
- --cached removes it from the index but keeps your local copy
- Ask what a person who cloned the repo last week already has
Check yourself
1. You committed an API key, then deleted the file and committed again. Is the key safe?
Show answer
B. No β the earlier commit still contains it, so rotate the key
2. You added node_modules/ to .gitignore but it is still being tracked. Why?
Show answer
B. .gitignore only affects untracked files β use git rm --cached
3. Why keep build output out of the repository?
Show answer
B. It is regenerable from source and permanently inflates every clone
Merge, rebase, and reading a conflict
Working levelMerge and rebase both answer 'my branch is behind main, now what', and they differ only in the history they leave. Merge takes the two lines of work and ties them together with a merge commit; your branch keeps the commits you actually made, in the order you made them, and the history shows that two things happened in parallel, which is true. Rebase instead replays your commits on top of the current main, one at a time, as if you had started this morning.
Rebase produces a straight, readable history and is the reason many teams prefer it. The rule that comes with it is absolute: never rebase a branch other people have pulled. Rebasing rewrites commits β same changes, new identities β so anyone who already has the old ones now has a branch that has silently diverged from yours, and the recovery is unpleasant. Rebase your own unpushed work freely; leave shared branches alone.
A conflict is not an error, it is Git declining to guess. It happens when two branches changed the same lines, and Git marks both versions in the file for you to choose between. The markers are mechanical: everything between <<<<<<< and ======= is what is on the branch you are merging into, everything between ======= and >>>>>>> is what is coming in. You edit the file until it is what you want, remove every marker, then git add it to say you have resolved it.
Syntax
# bring your branch up to date with main
git checkout my-feature
git fetch origin
git merge origin/main # safe on shared branches
# or
git rebase origin/main # only if nobody else has this branch
# a conflict looks like this inside the file:
<<<<<<< HEAD
timeout = 30
=======
timeout = 60
>>>>>>> origin/main
# decide, delete all three markers, leave the line you want:
timeout = 60
git add config.py # 'I have resolved this file'
git commit # for a merge
git rebase --continue # for a rebase
git merge --abort # back out, nothing lost
git rebase --abort
Key points
- Merge keeps true parallel history and is always safe. Rebase makes history linear but rewrites commits, so never rebase anything someone else has pulled.
- HEAD is your side, the part below ======= is what is coming in. Resolving means editing to what you actually want, not mechanically keeping one side.
- You can always back out with git merge --abort or git rebase --abort. Nothing is lost, so a conflict is never a reason to panic or to re-clone.
Practice challenge
Merging main into your branch produced the conflict below in config.py. Your branch raised the timeout to 60 because of a slow supplier API; main lowered it to 15 for an unrelated health check. Write what the resolved file should contain and explain your reasoning in one line.
Resolved: neither value blindly. The two changes serve different callers, so one shared constant is the actual bug -
supplier_timeout = 60
health_check_timeout = 15
Reasoning: accepting either side silently breaks the other team's change; the conflict revealed that one setting was doing two jobs
Command: git add config.py
<<<<<<< HEAD
timeout = 60
=======
timeout = 15
>>>>>>> origin/main
Resolved file: ______
Reasoning: ______
Command after editing: ______
Show a hint
- Both sides are correct for their own caller - that is why it conflicted
- Picking a winner here loses somebody's work and the tests will still pass
Check yourself
1. When must you not rebase?
Show answer
B. When other people have already pulled the branch
2. In a conflict, what is between <<<<<<< HEAD and =======?
Show answer
B. The version on the branch you are currently on
3. A merge conflict has appeared and you are not ready to deal with it. What can you do?
Show answer
B. git merge --abort, which puts everything back
Finding the commit that broke it
Advanced'It worked last month' is a solvable problem, and solving it from history is faster than reading code. Three tools cover almost every case. git log -S searches history for when a string appeared or disappeared, which finds the commit that removed a check or changed a constant. git blame annotates each line of a file with the commit that last touched it, which turns 'why is this here' into a commit message and an author you can ask.
blame has a bad name it does not deserve. You are not looking for a culprit, you are looking for context: the commit that introduced a line usually has a message and often a pull request explaining why, and that is frequently the whole answer. The trap is that a reformat or a rename shows up as the last change to every line, hiding the real one β git blame -w ignores whitespace, and following the commit before the reformat gets you past it.
When the change is not obvious from any single file, git bisect finds it by binary search. You tell Git one commit where the bug exists and one where it does not; Git checks out the midpoint and asks you whether it is broken; you answer good or bad, and it halves the remaining range each time. Across a thousand commits that is about ten tests to find the exact one, and if you can express the test as a script, git bisect run does it without you.
Syntax
# when did this string arrive or leave?
git log -S "retry_limit" --oneline
# a3f9c21 Drop retry limit for the batch job
# who last touched each line, ignoring reformatting
git blame -w app/loader.py
git blame -w -L 40,60 app/loader.py # just lines 40-60
# what exactly did that commit change?
git show a3f9c21
# binary search for the breaking commit
git bisect start
git bisect bad # now is broken
git bisect good v1.4.0 # this tag was fine
# Git checks out the midpoint; you test and answer:
git bisect good # ...or: git bisect bad
# repeat ~10 times for 1000 commits
git bisect reset # back to where you started
# automate it: exit 0 = good, non-zero = bad
git bisect run pytest tests/test_dates.py
Key points
- git log -S finds the commit where a string appeared or disappeared. It is the fastest way to answer 'when did this constant change'.
- git blame -w ignores whitespace, so a reformat does not mask the commit you are looking for. Without -w the last reformat blames every line.
- git bisect is binary search over history: about ten steps for a thousand commits, and fully automatic with git bisect run plus a test that exits non-zero on failure.
Practice challenge
A retry limit used to be 5 and is now 1, and nobody remembers changing it. Write the command that finds the commit where the string retry_limit changed, the command to see exactly what that commit did, and the command to find who last touched those lines without a whitespace-only reformat masking it.
Find the commit: git log -S "retry_limit" --oneline
See the change: git show <hash>
Blame the lines: git blame -w app/client.py
(-w ignores whitespace so a reformat does not blame every line)
Find the commit: ______
See the change: ______
Blame the lines: ______
Show a hint
- -S searches history for when a string appeared or disappeared
- One flag on blame ignores whitespace-only changes
Check yourself
1. Which command finds the commit where the string 'retry_limit' was removed?
Show answer
B. git log -S "retry_limit"
2. Why use git blame -w?
Show answer
B. It ignores whitespace, so a reformat does not hide the real change
3. About how many steps does bisect need across 1,000 commits?
Show answer
C. About 10
Reviewing someone else's pull request
Job-readyReviewing is the part of Git work that is judged most and taught least, and it is the fastest way for a new joiner to build a reputation. A review is not a search for style violations β a formatter should own those β it is you taking partial responsibility for this code working. Start by reading the description and asking what the change is meant to do, then read the tests, because the tests tell you what the author believes the change does. A change with no test for its main claim is the single most useful thing to point out.
Read for the things a machine cannot see. Does this handle the empty case, the duplicate, the second call? Is the error swallowed? Does the name say what the function does? Is there a migration that must run first, and does the deploy order matter? Those are review questions. Whether the brace is on the same line is not, and spending your review on that trains people to ignore your reviews.
How you write the comment decides whether it lands. Separate what blocks merge from what is a preference and say which is which, because a reviewer who marks everything as blocking gets routed around. Ask rather than instruct when you are not sure β 'what happens if items is empty here?' is both easier to receive and genuinely more likely to be right than 'this crashes on empty'. And approve when it is good enough: a review that never ends is a review that stops being requested.
Syntax
# review locally when the diff is not enough
git fetch origin pull/482/head:review-482
git checkout review-482
git diff main...review-482 # three dots: only their changes
# does it actually pass?
pytest -q
# read the commits, not just the combined diff
git log main..review-482 --oneline
# --- comments that land ---
# BLOCKING: parse_dates() returns None when the file is empty, and
# line 88 calls .strftime() on it. tests/test_loader.py has no
# empty-file case; can we add one?
#
# Non-blocking: `d2` reads as a temp name β maybe `parsed_dates`?
#
# Question: if this deploys before the migration, does the old
# column still exist? Wondering about deploy order, not the code.
Key points
- Read the tests first. They state what the author thinks the change does, and a missing test for the main claim is the highest-value comment you can leave.
- Label blocking versus preference explicitly. A reviewer who blocks on taste gets worked around, and then nobody catches the real bugs.
- Ask instead of assert when unsure. A question is easier to receive, and it is more often correct than a confident claim about code you have read once.
Practice challenge
A pull request adds parse_dates(), which returns None for an empty file, and line 88 calls .strftime() on the result. There is no test for the empty case. A variable is named d2. The change also needs a database migration. Write one blocking comment, one non-blocking comment and one question, each labelled.
BLOCKING: parse_dates() returns None on an empty file and line 88 calls .strftime() on it, which will raise. There is no empty-file case in the tests - can we add one?
Non-blocking: d2 reads as a temporary name; parsed_dates would be clearer. Not a merge blocker.
Question: if this deploys before the migration runs, does the old column still exist? Asking about deploy order rather than the code itself.
BLOCKING: ______
Non-blocking: ______
Question: ______
Show a hint
- The blocking one should be the thing that breaks in production, not the naming
- A question is easier to receive than an assertion, and more often correct
Check yourself
1. What should you read first in a pull request?
Show answer
B. The description and the tests
2. Why label a comment as non-blocking?
Show answer
B. So the author knows what must change to merge and what is preference
3. You have only reviewed part of a large PR but it is urgent. Best action?
Show answer
B. Say exactly which parts you reviewed and which you did not
Common questions
Do I need any background to start Git?
No. This track begins at its own beginning and assumes nothing, and the first lesson explains what the thing is before showing you any syntax.
How long does the Git track take?
About 124 minutes of reading across 8 lessons, plus the practice challenges and 24 multiple-choice questions, which is where the time actually goes.
Is it free?
Yes, and there is no account. Everything runs in your browser.
More: all 15 tracks · what employers actually ask for · the full syllabus