Branch: CS, Computer Science Foundations (quant-code region) · Node CS2.02 · ~12 focused hours · Gate ≥ 85%
Every analyst who writes code eventually loses work. Not to a crashed laptop or a cosmic-ray bit-flip, to themselves. A script that worked yesterday is broken today and nobody remembers what changed. Two people "fix" the same notebook overnight and one set of fixes silently vanishes. A very-confident rm or a very-confident Ctrl+Z-that-doesn't-go-back-far-enough erases an afternoon. The folder fills with backtest.py, backtest_v2.py, backtest_v2_FINAL.py, backtest_v2_FINAL_reallyfinal.py, and six weeks later nobody, including the person who wrote them, can say which one is actually correct, what the difference between them is, or why any particular line exists. This is not a hypothetical. It is the default state of code that nobody puts under version control, and it is exactly as true of a hedge fund's production backtester as it is of a first-year student's homework.
Advertisement
Git exists to make every one of those failures structurally impossible. Not "harder", impossible, by construction, the way a well-designed lock makes a certain class of break-in impossible rather than merely difficult. Every change you commit is kept forever, named, dated, and attributed, and can be compared against any other change with one command. Two people's edits to the same file combine automatically the overwhelming majority of the time, and the rare cases that can't be combined automatically are handed back to a human with the exact lines in dispute clearly marked, never silently guessed at. Almost nothing you do to a Git repository is truly destructive, even the commands that look terrifying (reset --hard, a rewritten history, a branch you thought you deleted) leave a trail that a five-second command can usually recover, for weeks after the fact. This module installs the mental model that makes all of that predictable rather than magical: what a commit actually is, what a branch actually is, and what the handful of commands you'll use every single day actually do to those two things.
Twelve hours is a short module by this branch's standards, and deliberately so. Git rewards depth of habit far more than breadth of trivia. The goal here is not to memorize forty flags. It is to build one durable mental model (three trees, a graph of content-addressed snapshots) precise enough that every command's behavior becomes predictable from the model rather than memorized as an isolated incantation, and to install the two or three habits (commit small, branch freely, never fear git log) that keep a research repository honest for years. By the end, a merge conflict will read as routine text to edit, not an emergency; a bad reset will read as a recoverable inconvenience, not a catastrophe; and every subsequent module in this program, every one of them, will assume you are quietly, automatically committing your work as you go.
Learning objectives
By the end you can:
- State, in one sentence and then in mechanism, what Git fundamentally stores (whole-tree snapshots, named by the content's own hash, not diffs against a previous version) and explain why that single design choice makes branching, history, and integrity checking cheap.
- Describe the three trees (working directory, staging area/index, repository) and say exactly what
git add,git commit,git status, andgit diffeach do to move content between them. - Explain a branch as nothing more than a movable pointer to a commit (never a copy of the files), and use that fact to predict whether a given
git mergewill be a silent fast-forward or require a true three-way merge. - Read, resolve, and correctly finish a real merge conflict, interpreting
<<<<<<</=======/>>>>>>>markers, choosing the right resolution, and completing the merge. - Contrast rebase and merge (what each does to commit history and to commit hashes) and state, and justify, the golden rule of rebasing.
- Distinguish
git reset(--soft/--mixed/--hard),git restore/checkout, andgit revert, and choose the safe option for a shared branch versus your own private work. - Use
git reflogas the practical safety net for "I just did something terrible," and explain why almost nothing in Git is immediately, permanently gone. - Drive the standard remote/collaboration workflow (clone, branch, commit, push, open a pull request, address review, merge) and distinguish
fetchfrompullfrompush. - Write a
.gitignorethat correctly keeps secrets, data dumps, and build artifacts out of history, and explain why removing a secret later does not erase it from a repository's past. - (Productivity objective: R10 duality.) Use an AI assistant to draft commit messages, explain a diff, or propose a fix for a confusing Git error or conflict, while never running an AI-suggested command, especially a destructive one, that you cannot first explain yourself.
Prerequisites & connections
Builds on. CS2.01 (Command Line, Environments & Reproducible Projects). This module assumes the comfort CS2.01 built: a shell prompt, cd/ls, running a small Python script, and (most importantly) why a reproducible project matters in the first place. Git is the other half of that reproducibility story: CS2.01 made your environment reproducible; this module makes your history reproducible. No calculus, no prior computer-science theory, and no finance background are required here, only ordinary comfort typing a command and editing a plain-text file. If you have never touched a terminal, work CS2.01 first; everything below assumes you can open one without hesitation.
Feeds forward. Every module from here on quietly assumes you are keeping your own practice work in a real Git repository, because that habit, once installed, outlasts any single lesson. Two links are load-bearing rather than incidental. CS2.06 (Software Engineering: Testing, Clean Code & AI-Assisted Dev) carries a dedicated unit on reviewing AI-generated code (debugging seeded AI-written code that hallucinates APIs or gets subtly wrong logic past the happy path) and that unit assumes you can create a throwaway branch to try a risky AI-suggested change, inspect it with git diff, and cleanly revert or discard it if it's wrong; without this module's fluency, "just try it on a branch" isn't actually available to you. QD2.04 (Reproducible Research, Deployment & the Quant Stack) builds a scheduled, logged, seeded research pipeline directly on top of the discipline installed here, a pipeline is not reproducible if the code that runs it isn't version-controlled, config-and-all. More loosely, CS2.03 (Databases & SQL) and every DA/QD module assume a learner who commits as a reflex, not an event. Reference this module rather than re-deriving any of it: the Git literacy is taught exactly once, here.
Core teaching content
The problem: a folder is not a history
Before Git, "version control" for most people means a folder full of near-duplicate files, distinguished only by increasingly desperate names: model.py, model_new.py, model_new2.py, model_FINAL.py. This scheme fails in four specific, predictable ways, and naming them precisely is worth doing before any tool enters the picture. First, there is no record of intent, a diff between model.py and model_FINAL.py might be one line or two hundred, and nothing tells you why any particular change happened, only that it did. Second, there is no safe experimentation, trying a risky idea means either overwriting the only copy you have or manually duplicating the entire project, and "just try it and see" becomes expensive enough that people stop doing it. Third, there is no combination, if two people (or two future versions of yourself) each build on model_new.py independently, merging their work back together is a manual, error-prone, line-by-line reconciliation with no tool support at all. Fourth, and most quietly costly, there is no trust, with no record of what changed and when, "is this the version I ran last Tuesday's numbers with?" becomes an unanswerable question, which is precisely the failure mode that makes analytical work unauditable.
Git solves all four at once by replacing "a folder of files" with "a history of snapshots you can name, compare, combine, and return to." Every one of the commands in this module exists to serve exactly one of those four fixes: recording intent (commit messages), safe experimentation (branches), combination (merge/rebase), and trust (log, diff, blame). Keep that four-part list in your head as you read the rest of this module, every feature below is in service of one of these, and naming which one sharpens your understanding of why the feature exists, not just how to invoke it.
Git itself is not a finance tool, a data-science tool, or an analyst tool, it is a general piece of software infrastructure, created in 2005 by Linus Torvalds for an entirely different purpose (keeping the Linux kernel's own source history, after the kernel community's arrangement with a previous proprietary tool, BitKeeper, ended). Within about ten days it was managing its own source code. That history matters for exactly one reason: Git was built by systems programmers to be fast and correct on a codebase with thousands of contributors and millions of lines, which is why its internal model (built entirely around cheap, content-addressed snapshots, described next) is unusually elegant and unusually worth actually understanding rather than treating as a black box of memorized commands.
Git's mental model: snapshots, not diffs
The single fact that makes the rest of this module learnable rather than memorizable is this: Git does not store differences between versions, it stores a complete snapshot of your entire tracked project at every commit. This is the opposite of what most people assume (and the opposite of how some older version-control systems actually worked). When you make a commit, Git does not compute "here is what changed since the last commit and here is a delta to apply." It records, in effect, a photograph of every tracked file's entire content, exactly as it stood at that moment.
This sounds wasteful, surely storing a full copy of every file at every commit balloons a repository's size? In practice it does not, for a beautifully simple reason: if a file's content hasn't changed since the last commit, Git doesn't store a fresh copy of that content at all, it just points the new snapshot at the same, already-stored content it pointed to before. This is only possible because of the second half of the model: everything Git stores is named by a hash of its own content, not by a filename or a version number. Two files (or two versions of the same file) with byte-for-byte identical content are, to Git, the literal same object, referenced from as many snapshots as need it, stored exactly once. A commit, underneath, is a lightweight pointer to a tree of such pointers (cheap to create, cheap to compare, and) this is the payoff you'll feel constantly, cheap to branch, because "create a new line of development" costs nothing more than creating one new small pointer, never copying a project's files.
Content-addressable storage: what a hash actually names
Make the "named by content" claim concrete, because it is not a metaphor, it is the literal mechanism, and the CS2.02 code pack turns it into runnable code you can check against real Git. The simplest object Git stores is a blob: the raw content of one file, with no filename and no metadata attached at all (the filename lives one level up, in a tree object, the same content under two different filenames is still one blob). Git computes a blob's id by building the exact byte string "blob " + <content length in bytes, as decimal digits> + "\0" + <the raw content> and taking the SHA-1 hash of that whole string, rendered as 40 hexadecimal characters. Nothing else goes into the hash, no filename, no timestamp, no author.
Two consequences fall directly out of this, and both are worth internalizing rather than just accepting. First, identical content is always the identical object, no matter where or when it appears, a hundred files in a hundred commits that all happen to contain the string x = 1\n are, to Git, one single stored blob referenced a hundred times, which is exactly why full-snapshot storage doesn't explode in size the way intuition suggests it should. Second, changing a single byte anywhere produces a completely unrelated-looking hash: SHA-1, like any well-built cryptographic hash, has no "similar input, similar output" property, so there is no way to look at two hashes and guess whether the underlying content is "close." This is by design: it means a hash functions as a tamper-evident fingerprint (if even one byte of history were silently altered, every hash downstream of it would visibly change), and it is also why a file with a trailing newline and the same file without one are, correctly, two different blobs, which explains a very common source of "noisy" diffs that confuses newcomers. CS2.02-e01, in the code pack, has you implement this exact hashing rule in Python and check it against real Git's own git hash-object command, by the end of that exercise, "content-addressed" stops being a phrase and becomes something you've computed by hand.
A commit is one more layer up: it points at one tree (the root of the snapshot), records an author, a timestamp, a message, and (this is what turns a pile of snapshots into a history) a pointer to its parent commit (or, for a merge commit, two parent pointers). Follow parent pointers backward from any commit and you walk the entire project's history, one snapshot at a time, all the way to the very first commit, which has no parent at all. A repository, structurally, is nothing more than this: a directed graph of commits, each an immutable, content-addressed snapshot, linked to its predecessor(s). Every feature in the rest of this module (branching, merging, rebasing, bisect, the reflog) is a statement about this graph, and nothing more exotic than that.
The three trees: working directory, staging area, repository
Git deliberately splits "what's on disk" from "what's committed" with an intermediate stop in between, and almost every point of confusion a newcomer has traces back to not having a crisp picture of these three areas.
The working directory is simply the files on your disk, exactly as your editor and your OS see them, the only one of the three areas a human directly edits. The staging area (also called the index) is a snapshot-in-progress: a holding area listing exactly which content, from which files, will go into your next commit. The repository (the object database plus the commit graph described above) is the permanent, committed history, once something is a commit, it is there forever (or at least until you deliberately, explicitly go looking for trouble).
Three commands move content between these three areas, and once you can state precisely what each one touches, an enormous amount of "why didn't my change show up" confusion disappears. Editing a file changes only the working directory. Git has not been told about it yet, and nothing about staging or the repository is affected, no matter how large the edit. git add <file> copies the current content of that file from the working directory into the staging area, a snapshot-in-progress of what the next commit will contain, updated the instant you run add again after any further edit. git commit takes whatever is currently staged (nothing more, nothing less) and permanently records it as a new commit object in the repository, with a new parent pointer back to whatever commit was previously the tip of your branch. Note precisely what this means: if you edit a file after staging it but before committing, the commit will contain the staged (older) version, not your latest edit, until you add again. This is not a bug to work around; it is the entire point of having a staging area, it lets you build up exactly the commit you want, file by file or even hunk by hunk, independent of whatever half-finished edits happen to also be sitting in your working directory.
Two commands let you inspect these three areas without changing anything, and you should run them constantly, not just when something looks wrong. git status reports, for every file, which of the three areas disagree with each other (untracked, modified-but-unstaged, staged-and-ready-to-commit) in plain English. git diff shows the actual line-by-line content difference; run with no arguments it compares working directory against the staging area (what you haven't staged yet), and git diff --staged compares the staging area against the last commit (exactly what your next commit would contain right now). Reading a git status before every commit and a git diff --staged before every commit message is the single cheapest habit in this entire module, it turns "what am I about to commit?" from a guess into a fact you looked at.
.gitignore: deciding, once, what never belongs in history
Not everything in a project folder should become a Git object, and getting this wrong is one of the most common, and most consequential, mistakes an analyst makes with version control. Three categories of file should essentially never be tracked: secrets (API keys, database passwords, .env files, anything that, if it leaked into a public or even semi-shared repository, would need to be rotated, not just deleted); large or regenerable data (a multi-gigabyte CSV dump, a .parquet snapshot you can re-fetch, a virtual environment folder full of installed packages), not because Git can't store them, but because doing so bloats every future clone with content that adds no historical value and, per CS2.01's reproducibility argument, shouldn't live in source control anyway, it should be fetched or rebuilt; and machine-generated noise (__pycache__/, .pyc files, editor swap files) that carries zero information and only ever produces spurious diffs.
A .gitignore file, one per project (conventionally at the repository root), lists patterns Git should simply never offer to track. The rules that matter in practice, and that the CS2.02 code pack's is_ignored exercise (CS2.02-e04) has you implement precisely: patterns are checked in order, and the last matching pattern wins, so a broad exclusion (*.csv) can be selectively re-included for one specific file with a later !-prefixed negation pattern (!important/keep.csv); a pattern ending in / matches a directory name anywhere in the path (data/ excludes data/prices.csv and equally raw/data/x.txt); and an ordinary glob pattern with no / (*.pyc) matches against the filename alone, regardless of which directory it's found in.
The one rule that trips up almost everyone the first time: .gitignore only prevents tracking something that isn't already tracked. If a secret was ever committed even once (even in a commit you've since "fixed" or "removed the file in") it is still sitting, in full, in every clone's history, retrievable by anyone who checks out that old commit or simply searches the object database. Adding the file to .gitignore after the fact does nothing to erase it from the past; removing it from history requires deliberately rewriting history (tools like git filter-repo exist for exactly this, well outside this module's scope) and, for anything genuinely sensitive, treating the credential as compromised and rotating it regardless. The operative discipline is therefore not "clean up secrets when I notice them", it is write .gitignore before your very first commit, so that nothing sensitive is ever staged in the first place.
{
"skill": "Turning a raw `git diff` into a clear, well-scoped commit message (or reading someone else's diff fast)",
"use": "Paste the output of `git diff --staged` into a chat assistant and have it draft a commit message in the imperative mood, then edit the draft yourself — the assistant accelerates the writing; it never decides what the commit is for or invents a reason you can't see in the diff.",
"tools": ["Chat assistants — Claude, ChatGPT", "IDE/editor AI — commit-message generators built into your git client or editor"],
"prompt": "Here is `git diff --staged` output: <PASTE DIFF>. Draft a commit message: a subject line under 50 characters in the imperative mood ('Fix', not 'Fixed' or 'Fixes'), with a short body only if the *why* isn't obvious from the diff alone. Do not invent a reason you cannot see in the diff.",
"verify": "Read the generated message against the actual diff, line by line: does the subject describe what genuinely changed, not what you *meant* to change? Did it invent a rationale not visible in the code? Run `git log -1` after committing and re-read your own message cold, as a reviewer would — a wrong or overclaimed commit message is worse than a terse, honest one.",
"diy": "You must already be able to write a correct commit message by hand for a small diff — subject, imperative mood, why-not-what in an optional body — before you let a tool draft one; otherwise you cannot tell a good draft from a plausible-sounding wrong one (this module's commit-hygiene practice problems, and Common Mistake #2 below).",
"market": "IN"
}
Branches: cheap pointers, not copies
A branch in Git is nothing more than a movable, named pointer to a single commit, literally a small file containing one 40-character hash. It is not a copy of your project's files, and creating one does not duplicate anything on disk beyond that one tiny pointer. When you commit while "on" a branch, Git creates the new commit as usual (pointing back at the old tip as its parent) and then simply moves that branch's pointer forward to the new commit. This is why Git branching is famously described as cheap: git branch feature-x (or git switch -c feature-x, which creates and switches to it in one step) costs essentially nothing, which is precisely why Git culture encourages branching constantly and freely, for every feature, every experiment, every "let me just try this", rather than treating a branch as a heavyweight, rare event.
HEAD is Git's name for "whichever commit your working directory currently reflects", ordinarily this is "the tip of whatever branch you're currently on," and git switch <branch> (or the older git checkout <branch>) moves both HEAD and your working directory's actual file contents to match that branch's tip commit. Two branches that have diverged (each with commits the other doesn't have) are simply two different paths through the same underlying commit graph, nothing is duplicated, nothing is locked; the only thing distinguishing "on branch main" from "on branch feature-x" is which pointer HEAD currently follows.
Merging: fast-forward, three-way, and what decides between them
git merge <branch> brings another branch's commits into your current branch, and, this is the fact the CS2.02 code pack's merge_kind exercise (CS2.02-e02) turns into a checkable algorithm, which of two entirely different things happens is decided purely by the shape of the commit graph, not by anything you choose.
If your current branch's tip is an ancestor of the target branch's tip (walking parent pointers backward from the target eventually reaches your current tip, meaning nothing new has happened on your side since the branches diverged), Git performs a fast-forward: it simply moves your branch's pointer forward to the target's tip. No new commit is created; history stays perfectly linear, as if you had been committing on this branch all along. If your current tip already contains the target's tip as an ancestor, there is nothing to bring in at all ("Already up to date"). Only in the remaining case, genuinely diverged history, where neither tip is an ancestor of the other, because real, different commits happened on both sides since they split, does Git need a true three-way merge: it looks at the common ancestor (the commit where the branches split) plus both tips, combines the changes automatically wherever only one side touched a given piece of content, and creates a new merge commit with two parent pointers recording exactly what was combined.
The three-way name matters: a merge is a comparison of three snapshots (the common ancestor, "ours," and "theirs"), not just two, and that third snapshot (the ancestor) is precisely what lets Git tell "only one side changed this" (no conflict, safe to auto-resolve) apart from "both sides changed this differently" (a genuine conflict, discussed next). This is exactly the logic CS2.02-e02's is_ancestor/merge_kind functions implement and check against a small hand-built commit graph, by the time you've made that exercise's hidden tests pass, "will this be a fast-forward?" is a question you can answer by inspection, not by running the command and hoping.
Merge conflicts: reading and resolving them without fear
A merge conflict happens when the three-way comparison above finds a single piece of content (most often, one line or one contiguous block of lines) that both sides changed, and changed to different things. Git cannot silently choose one side over the other (it has no way to know which is "right"), so it does the only honest thing: it inserts both versions into the file, wrapped in markers, and asks a human to decide.
The markers follow one fixed shape: <<<<<<< opens the block, followed by your current branch's version of the content; ======= separates the two sides; then the other branch's version; then >>>>>>> closes the block. CS2.02-e03, in the code pack, has you implement this exact three-case logic in Python, no conflict when both sides agree, take-theirs when only your side is unchanged from the common ancestor, take-ours when only their side is unchanged, and the marker block in the remaining, genuinely-conflicting case, against real hidden tests on both an India (₹) and a US ($) price-target scenario, so the shape of a conflict stops being scary the first time you meet it for real. Resolving a conflict, mechanically, is always the same three steps: open the file, replace the entire marked block (markers included) with whatever the correct final content should be (which might be exactly one side, exactly the other, a hand-merged combination of both, or something new entirely) then git add the now-resolved file and git commit to complete the merge (Git already knows, from the conflict state, that this commit finishes a merge, and will pre-fill a sensible commit message). The single most common new mistake at this step, worth naming explicitly, is forgetting to delete the marker lines themselves, committing a file that still contains a stray <<<<<<< is a real, syntax-breaking bug, not a merge, and it's exactly the kind of thing a careful git diff --staged before committing catches every time.
Rebase versus merge, and the golden rule
git merge and git rebase both exist to bring one branch's changes onto another, and they are frequently confused because they can produce similar-looking end states, but they do fundamentally different things to the commit graph, and the difference matters enormously in a shared repository. Merge, as above, creates a new commit with two parents, leaving every existing commit, on both branches, completely untouched; the true, non-linear shape of what actually happened (two lines of work that later joined) is preserved forever. Rebase instead takes your branch's commits and replays them, one at a time, on top of a new base commit, the result looks like a single, clean, linear line of history, as if you'd started your work later than you actually did. But replaying a commit is not the same as moving it: each replayed commit gets new content applied against a new parent, which means it gets a brand-new hash, as far as Git's content-addressed model is concerned, a rebased commit is a different object from the original, not the same commit relocated.
That single fact (rebasing rewrites commit identities rather than moving them) is the entire reason for Git's most important piece of received wisdom, the golden rule of rebasing: never rebase commits that other people have already pulled and might have built on. If you rebase your own, still-private branch before ever sharing it, nobody is affected, the old commits simply cease to have existed anywhere anyone can see. But if you rebase a branch after a teammate has already fetched the old version, their copy and your new copy no longer share any commit hashes on the rebased portion, even though the content is nearly identical; Git sees two genuinely diverged histories, your teammate's next pull produces a confusing pile of "duplicate-looking" commits (or an outright rejection), and the "fix" people reach for under pressure, force-pushing your version over the shared branch, silently discards whatever commits existed on the remote that you didn't have locally. Rebase is a genuinely excellent tool for keeping your own, not-yet-shared work tidy before you show it to anyone; it is a genuinely dangerous tool to run on a branch other people are already relying on.
Working with remotes: clone, fetch, pull, push
So far, everything described lives on one machine. A remote is simply another copy of the same repository (typically hosted on a server (GitHub, GitLab, or your own institution's Git host)) that your local repository knows how to talk to, referred to by a short name (conventionally origin). Four commands govern all remote interaction, and they are frequently blurred together despite doing genuinely different things. git clone <url> copies an entire remote repository (every commit, every branch, the whole graph) to your machine as a brand-new local repository, automatically wired to that remote as origin. git fetch downloads any commits that exist on the remote but not locally, updating your local remote-tracking branches (Git's private bookkeeping of "what does origin's main currently look like"), critically, fetch never touches your own working branches at all; it is purely informational and cannot lose or overwrite anything you're working on. git pull is exactly git fetch immediately followed by a merge (or, if configured, a rebase) of the fetched commits into whatever branch you currently have checked out, this is the step that actually changes your working branch, which is why "just fetch first if you want to look before you leap" is a genuinely useful habit. git push uploads your local commits on a branch to the remote, moving the remote's branch pointer to match, and Git will refuse a push that would discard commits on the remote you don't yet have locally (exactly the protection the previous section's force-push danger exists to override, which is why an ordinary push is safe by default and a force push is a deliberate, separate, much more dangerous act).
A branch that Git knows corresponds to a specific remote branch (set up automatically by clone, or explicitly via git push -u origin <branch> the first time you push a new branch) is called a tracking branch, it's what lets a bare git pull or git push, with no arguments, know which remote branch you mean.
Collaboration workflows: feature branches and pull requests
Layer the previous two sections together and you get the workflow that essentially every professional and open-source codebase uses, and that CS2.02-e08's code-pack exercise turns into an orderable procedure: clone (or pull the latest main) to start clean; create a feature branch off main for the one focused thing you're about to do; make small, focused commits on that branch, each independently understandable; push the branch to the remote; open a pull request (sometimes called a merge request), a request, visible to your team, to merge this branch into main, showing the full diff and inviting review; a reviewer reads it, leaves comments, and either approves or asks for changes, which you address with further commits on the same branch (never by rewriting what's already been reviewed, unless your team's convention explicitly calls for a tidy-up rebase before merge); and once approved, the branch is merged into main (as a fast-forward, an ordinary merge commit, or a "squash" that flattens the branch into one commit, depending on team convention) and the now-finished feature branch is deleted. The entire point of this ritual is that main only ever receives reviewed, working code, while all the messy, in-progress, "let me try this" commits live safely on a disposable branch that can be abandoned with zero cost if the idea doesn't pan out, which loops all the way back to this module's opening promise: safe experimentation, at essentially zero cost, is the reason any of this exists.
Undoing things safely: restore, reset, and revert
Git offers three genuinely different ways to "undo," and picking the wrong one for the situation is the single most common source of real, felt panic among people new to the tool, so it is worth being precise about exactly what each one touches, mapped back onto the three trees from earlier.
git restore <file> (the modern name; git checkout -- <file> is the older spelling of the same idea) discards uncommitted changes in your working directory, replacing the file with whatever version is currently staged (or committed, if nothing is staged), it never touches history, because there's no commit to undo; it only ever throws away edits you haven't recorded yet.
git reset moves your current branch's pointer (and HEAD) to a different commit, and comes in three flavors that differ in how much else they touch. --soft moves only the branch pointer, the staging area and working directory are left completely untouched, so whatever content the "undone" commits contained now simply sits staged, ready to be re-committed differently (useful for "squash my last three commits into one better one"). --mixed (the default, if you omit the flag) additionally un-stages everything, so that content now sits only as uncommitted edits in your working directory. --hard additionally overwrites the working directory to match the target commit too, the only one of the three that can make on-disk edits look like they have vanished, which is exactly why it is the one people reach for by accident and immediately regret.
git revert <commit> takes a fundamentally different approach: instead of moving any pointer backward, it creates a brand-new commit that applies the opposite of an earlier commit's changes, the target commit, and everything after it, stay exactly where they are in history; a new commit is simply appended on top that undoes their effect. This is why revert, not reset, is the standard, safe way to undo something on a branch other people have already pulled: nobody's history gets rewritten, nobody needs to force-push, and anyone who pulls afterward just sees one more perfectly ordinary commit. reset rewrites what a branch points at, which is fine on private, not-yet-shared work and dangerous on a shared one, exactly the golden-rule logic from the rebase section above, applied to a different command.
Reflog: the safety net underneath all of this
Here is the fact that should genuinely change how you feel about the commands in the previous section: Git almost never deletes anything immediately. A commit that no branch or tag points to any more (because you reset --hard past it, rebased it away, or deleted the branch that pointed to it) is not erased; it becomes unreachable, sitting in Git's object database, un-pointed-to but fully intact, until Git's garbage collector eventually cleans up unreachable objects (by default, only after they've sat unreachable for a meaningful stretch of time: weeks, not seconds). The practical consequence is that "I just lost an afternoon of work" is, the overwhelming majority of the time, recoverable.
git reflog is your local, personal log of every place HEAD has pointed, newest first, every commit, every checkout, every reset, every rebase step, each entry carrying the exact commit hash HEAD moved to at that moment. Whatever your most recent action was, the entry immediately below the newest one records exactly where you stood one step before it, which is precisely the hash you git reset --hard back to, to undo that most recent action, no matter what it was. CS2.02-e07's code-pack exercise has you implement exactly this "which hash do I recover to" logic against a simulated reflog, and its structured fallback has you order the manual recovery procedure (run git reflog, identify the prior hash, reset --hard to it, confirm with git log), because this is a procedure worth being able to execute from memory, under mild panic, without looking anything up. One caveat worth stating precisely: the reflog is local only, it is never pushed to a remote and reflects only your machine's history of HEAD movements, so it cannot recover something you never had locally in the first place, and it is not a substitute for pushing genuinely important work to a remote.
Inspecting history: log, blame, and bisect
Three commands turn the commit graph from an abstract structure into a tool you actively query. git log walks the graph from your current commit backward, printing each commit's hash, author, date, and message; git log --oneline compresses each to one line (short hash plus subject, exactly the format CS2.02-e05's code-pack exercise has you parse back into structured data), and git log --graph --all additionally draws the branch/merge topology in text art, which is the single best way to see, rather than just imagine, everything this module has described. git blame <file> annotates every line of a file's current content with the hash, author, and date of the commit that last touched it, invaluable for "why on earth does this line exist," which is a question a well-written commit message (see below) can then answer in full.
git bisect answers a specific, extremely common question, "a regression exists somewhere among many commits; which exact one introduced it?", using binary search over the commit graph rather than a linear crawl through history. You mark one known-good commit and one known-bad commit; Git checks out the midpoint of the remaining range and asks you to test it and report good or bad; each answer halves the remaining range. This is exactly the algorithm CS2.02-e06's find_first_bad exercise has you implement directly (its hidden tests reject a linear scan even if it happens to return the right answer, by capping how many times your function is allowed to check a commit), and it is a direct, satisfying payoff of CS1.04's Big-O material: a regression hiding among a thousand commits is found in roughly ten checks, not five hundred, because binary search's cost grows only as the logarithm of the range, not the range itself.
Stashing and tags
Two smaller tools round out the day-to-day toolkit. git stash shelves your current uncommitted changes (both staged and unstaged) and returns your working directory to a clean state matching your last commit, so you can switch context (an urgent fix, a different branch) without either committing half-finished work or losing it; git stash pop restores exactly what you shelved. This is the correct tool for "I'm not done, but I need to switch away right now", resist the temptation to commit a meaningless "WIP" instead, which litters shared history with a broken, uninformative snapshot that can confuse a reviewer or, worse, land squarely in the middle of a future bisect and produce a misleading "good/bad" test result for the wrong reason. git tag attaches a permanent, human-readable name to one specific commit, most often used to mark a release (v1.2.0), and, unlike a branch, a tag's pointer never moves once created; it exists purely to give a fixed, memorable name to a moment in history that you expect to reference again.
{
"skill": "Getting unstuck on a confusing Git error or a merge conflict — without letting an assistant run a destructive command for you",
"use": "Paste the exact error message (or the conflicted file's markers) and ask for a plain-English explanation of what state the repository is in and the safest way out, before typing anything.",
"tools": ["Chat assistants — Claude, ChatGPT", "IDE merge-conflict UI (VS Code, GitKraken, etc.) for visualizing the three-way diff alongside the AI's explanation"],
"prompt": "I ran `<COMMAND>` and got: <PASTE EXACT ERROR OR CONFLICT MARKERS>. Explain what state my repository is in right now, in plain English. Then give me the safest command to resolve it, and a second, non-destructive command I could run first to confirm nothing will be lost (e.g. `git status`, or `git log --oneline --all`).",
"verify": "Never run a suggested command — especially anything with `--hard`, `--force`, or `rebase` — until you can explain in your own words exactly what it will do to your commits. Run the confirmation command first. Check that any suggested flag actually exists (`git <command> --help`) — a hallucinated flag on a destructive command is the single most expensive mistake this callout exists to prevent. If in doubt, `git stash` or make a throwaway branch first, so nothing is unrecoverable while you experiment.",
"verifyNote": "This is the same hallucinated-API risk CS2.06's AI-code-review unit drills directly — a confidently-wrong flag or command is structurally indistinguishable from a correct one until you check it.",
"diy": "You must already understand the three trees and what reset/restore/revert each do to them (this module's Core content) before an AI's suggested fix is something you can evaluate rather than gamble on.",
"market": "US"
}
Commit message hygiene: writing history someone can actually use
A commit's content is only half of what makes history useful, its message is the other half, and a bad one quietly defeats the entire point of git log and git blame. The community convention worth adopting wholesale is the 50/72 rule: a subject line under about 50 characters, written in the imperative mood ("Fix the off-by-one in the bisect range check," not "Fixed" or "Fixes" or "Fixing"), imperative because a commit, applied, does something, and the convention keeps every subject line reading the same grammatical way; and, if a body is needed at all (only when the why isn't obvious from the diff itself), a blank line separating it from the subject, wrapped at roughly 72 characters. Just as important as the format is atomicity: one commit should represent one logical change, independently understandable and independently revertable, resist the temptation to bundle "fixed the bug, also reformatted three unrelated files, also updated the README" into a single commit, because doing so makes git revert useless (you can't undo just the bug fix without also undoing the reformatting) and makes git bisect and git blame far less informative (the line that actually caused a regression is now buried in an unrelated pile of changes). A good history reads, top to bottom, like a clear narrative of decisions; a bad one reads like an apology.
Common mistakes & how experts think differently
- "I'll just keep copies:
backtest_v2_FINAL_reallyfinal.py." This is the folder-of-copies antipattern this module opened with, and it is the single most common thing Git replaces. An expert doesn't version by filename at all, every version lives in one file's history, distinguished by commits, and "which one is correct" is answered bygit log, never by guessing from a name. - Giant, vague commits ("wip", "stuff", "fixed things"). A commit that bundles ten unrelated changes under a meaningless message defeats
revert,blame, andbisectall at once. Experts commit small and often (one logical change per commit, with an honest, specific, imperative subject) treating a commit as a unit of communication to a future reader (very often future-you), not just a save point. - Never writing a
.gitignore, then accidentally committing a secret, a huge data dump, or a virtual environment. By the time you notice, the damage to history is often already done, deleting the file in a later commit does not remove it from earlier commits, and a genuinely leaked secret must be rotated, not merely "cleaned up." Experts write.gitignorebefore the firstgit add, as a reflex, not an afterthought. - Panicking at a merge conflict, as though it means something is broken. A conflict is Git's completely normal way of saying "two people changed the same line differently, you decide," not an error state. Experts read the
<<<<<<</=======/>>>>>>>block as ordinary text to edit, resolve it, and move on in under a minute; the fear response, not the conflict itself, is what causes the real damage (a rushed, careless resolution, or an ill-advised force-push to make the conflict "go away"). - Rebasing, or force-pushing, a branch that other people have already pulled. This is the golden-rule violation from the Core content, and it is the single most reliable way to turn a small inconvenience into a genuinely confusing, multi-person mess. Experts rebase freely on their own private, not-yet-shared commits, and never on a branch anyone else has already fetched.
- Running
git reset --hardin a panic and assuming the discarded work is gone forever. It almost never is, immediately,git reflogalmost always has the exact hash needed to recover it, for weeks. Experts treatreset --hardas "inconvenient but reversible," which is the accurate mental model, rather than as an irreversible catastrophe (a fear that, ironically, sometimes causes people to avoid the exact housekeeping commands that keep a repository clean). - Treating
git resetandgit revertas interchangeable "undo" buttons. They do fundamentally different things to history (one rewrites what a branch points at, one appends a new commit) and usingreseton a branch other people have already pulled is exactly what forces an ugly, avoidable force-push later. Experts ask "is this commit private to me, or has anyone else already seen it?" before choosing between them, every time. - Trusting an AI-suggested Git command (especially anything with
--forceor--hard) without understanding it first. A plausible-looking, confidently-stated command is not the same thing as a correct one, and Git's most dangerous flags are exactly the ones where a hallucinated or subtly-wrong suggestion does real, hard-to-reverse damage on a shared branch. Experts apply this module's Copilot Discipline literally: understand what a command does before running it, use an assistant to explain and accelerate, and always run a non-destructive check first, precisely the theme CS2.06's AI-code-review unit will drill on a larger scale.
Worked examples
Worked example 1: From a folder of scripts to a real repo (India, ₹)
Priya has three files sitting in a folder with no version control at all: fetch_nse.py (downloads a day's NSE price snapshot), clean_nse.py (adjusts for splits), and a scratch file notes.txt. There is also a raw_nse_dump.csv (a large, re-fetchable data file) and a .venv/ folder full of installed packages. She wants this to become a real, trustworthy repository.
Solution. Step 1: ignore before you add. Before touching git add at all, Priya writes a .gitignore containing raw_nse_dump.csv, .venv/, and __pycache__/, nothing regenerable or heavy is ever allowed to enter history, even once. Step 2, initialize and make the first commit. git init creates an empty repository in the folder. git status now shows fetch_nse.py, clean_nse.py, notes.txt, and .gitignore itself as untracked (and, correctly, does not list the ignored files at all). git add fetch_nse.py clean_nse.py .gitignore stages exactly those three; git commit -m "Add NSE fetch and split-adjustment scripts" records the first snapshot. notes.txt is left out of this commit deliberately, it's not code, and Priya decides, after a moment's thought, that it doesn't belong in this repository at all (a legitimate third option beyond "track it" or "ignore it": simply never add it). Step 3, a small edit, inspected before committing. Priya fixes a bug in clean_nse.py. git status now reports it as modified-but-unstaged. git diff shows exactly the one-line change she expects, nothing more, which is the point of checking. git add clean_nse.py && git commit -m "Fix split-ratio sign error in clean_nse.py" records the second snapshot, with its own honest message. Step 4, read the history back. git log --oneline now shows two lines, newest first, a short hash and a subject for each. Priya notices, and confirms with git hash-object clean_nse.py, that the hash Git reports for the current file content is exactly the blob id the Core content described computing by hand.
Read it. Nothing here required more than four commands, but notice what already changed: "which version is correct" is no longer a guess about filenames, it is git log, in order, forever. That's the entire value proposition, delivered in four lines of shell.
Worked example 2: Fast-forward or true merge? (India, ₹)
Continuing Priya's repository: she creates a branch, feature/dividend-adjust, to add dividend-adjustment logic to clean_nse.py, and makes two commits on it. Meanwhile, main has not moved at all since the branch point.
Solution. Step 1, name the shape of the graph. Before running anything, Priya draws it out: main sits at commit C (the second commit from Worked Example 1); feature/dividend-adjust branched from C and now sits two commits further, at E. Walking parent pointers backward from E reaches C, so C is an ancestor of E. Step 2: predict, then run. Because main (at C) is an ancestor of the branch she's merging in, Priya predicts a fast-forward before typing anything. She runs git switch main && git merge feature/dividend-adjust, and Git reports exactly that: "Fast-forward." No merge commit is created; main's pointer simply moves to E, and the two branches' history is now, correctly, one single line. Step 3, the contrasting case. Priya imagines a variant: suppose, while she was working on the feature branch, a teammate had also committed directly to main. Now main's tip is no longer an ancestor of E, nor is E an ancestor of main, the branches have genuinely diverged. The identical git merge command would, this time, need to create a real merge commit with two parents, combining both sides (and surfacing a conflict if the same lines were touched by both).
Read it. "Will this be a fast-forward?" was never actually a mystery, it was fully determined by the shape of the commit graph before the merge command ran, exactly the is_ancestor check CS2.02-e02 has you implement. Once you can answer that question by inspection, git merge stops producing surprises.
Worked example 3: A real merge conflict, resolved by hand (US, $)
Dana and Sam both work on a US equities backtester. Both branch from the same commit of config.py, which contains the line COMMISSION_BPS = 5. On her branch, Dana changes it to COMMISSION_BPS = 7 (a more conservative cost assumption) and commits. On his branch, Sam independently changes the same line to COMMISSION_BPS = 10 and commits. Sam merges Dana's branch into his.
Solution. Step 1, recognize the conflict before it happens. Both sides changed the same line to different values relative to their shared ancestor (5), exactly the one case the three-way merge cannot silently resolve. Sam should expect a conflict the moment he knows this, before even running the merge. Step 2, read the actual output. git merge dana-branch reports a conflict in config.py. Opening the file, Sam sees:
<<<<<<< HEAD
COMMISSION_BPS = 10
=======
COMMISSION_BPS = 7
>>>>>>> dana-branch
HEAD (the section above =======) is Sam's own current value; the section below is Dana's, labeled by her branch name. Step 3: resolve, verify, complete. After a two-line Slack exchange, they agree the more conservative value should win. Sam replaces the entire six-line block, markers included, with the single line COMMISSION_BPS = 7, then runs git diff to confirm no stray <<<<<<< or ======= text survived anywhere in the file, git add config.py, and git commit (accepting Git's pre-filled "Merge branch 'dana-branch'" message, or refining it to note why 7 was chosen).
Read it. Nothing about this required either developer to lose their change. Git preserved both values and handed the decision to the one thing capable of making it, a human with the context to know which cost assumption was right. The markers are not an error message; they are the honest, complete truth of "here is exactly what each side wanted," nothing hidden.
Worked example 4: Rebase, merge, and the golden rule broken (US, $)
Sam's feature branch has fallen three commits behind main (teammates have merged other work in the meantime). He wants his branch's history to look clean before he opens a pull request.
Solution. Step 1, the two options, precisely. git merge main (while on his feature branch) would pull main's three new commits in, creating one merge commit, his own commits stay exactly as they are, hashes unchanged, and the true, slightly-tangled shape of what really happened is preserved. git rebase main instead replays his branch's commits, one at a time, on top of main's new tip, the result is a single clean line with no merge commit at all, but every one of his replayed commits gets a new hash, because each is now built against a different parent than the original. Step 2. Sam rebases, correctly, because the branch is still private. Since nobody else has this branch, rebasing is entirely safe. Sam runs git rebase main, resolves one small conflict that comes up, and pushes the now-linear branch for the first time. No harm done: nobody had the old commits to begin with. Step 3, the mistake, illustrated. A week later, a teammate joins Sam on the same branch to help finish it, running git pull and building two commits of her own on top of what she fetched. Sam, mid-review, decides to "clean up" his commit history again and reruns git rebase main, this time rewriting commits his teammate has already built on top of. Her next pull now sees two completely different sets of hashes covering almost-identical content, Git reports a confusing divergence, and the "fix" Sam is tempted to reach for (git push --force) would silently discard her two commits entirely were she not paying close attention.
Read it. The identical command, git rebase main, was the right call in Step 2 and the golden-rule violation in Step 3, the only thing that changed is whether anyone else had already based work on the commits being rewritten. That single question, "has anyone else already pulled this?", is the entire rule, and it is always answerable before you run the command, never only after.
Worked example 5: Reset, restore, revert, and the reflog safety net (India, ₹)
Arjun is mid-edit on an RBI macro-data ingestion repository. He means to discard just his last, uncommitted edit to one file and get back to his last commit, but, tired, he runs git reset --hard HEAD~2 instead, which moves two real, committed changes' worth of work out of both the repository's branch pointer and his working directory.
Solution. Step 1, diagnose precisely what happened, before reacting. --hard moved the branch pointer back two commits and overwrote the working directory to match, so the two commits' content is gone from what Arjun currently sees on disk, and (this is the detail panic obscures) the branch pointer no longer references them either. Step 2, resist the working-directory panic and check the safety net. Arjun runs git reflog. The newest entry reads something like HEAD@{0} ... reset: moving to HEAD~2; the entry directly below it, HEAD@{1}, carries the exact commit hash his branch pointed to one moment before that reset, which is to say, the tip that still includes both "lost" commits. Step 3: recover, then confirm. git reset --hard <that hash> moves the branch pointer (and working directory) straight back to exactly where it stood before the mistake. git log --oneline confirms both commits are present again, unchanged, with their original hashes intact, nothing was ever actually destroyed, only temporarily unreferenced. Step 4, the counterfactual, if this had already been shared. Had Arjun already pushed these two commits and a teammate had already pulled them, the correct move would not have been a local reset --hard at all, it would have been git revert on the shared branch, adding new commits that undo the unwanted changes without rewriting anything anyone else already has.
Read it. The felt emergency ("I just destroyed two commits") and the mechanical reality ("those two commits are sitting, fully intact, one reflog entry away") are almost always two completely different things in Git. Checking git reflog before assuming anything is unrecoverable is the single habit that converts panic into a thirty-second fix.
Worked example 6: Clone to merged pull request, end to end (US, $)
A new contributor joins a small, US-based open-source project that scrapes and parses SEC filings, and wants to add one capability: retry logic for a flaky fetcher.
Solution. Step 1: start clean. git clone <url> copies the entire project, history included, to her machine, already wired to origin. Step 2: branch, don't edit main directly. git switch -c feature/sec-fetch-retry creates and moves to a new branch off the current main tip, cheap, as always, because a branch is only a pointer. Step 3: small, focused commits. She adds retry logic in one commit ("Add exponential-backoff retry to the EDGAR fetcher") and a corresponding test in a second, deliberately not bundling them with an unrelated formatting pass she noticed and fixed separately. Step 4, push and open the pull request. git push -u origin feature/sec-fetch-retry uploads the branch and sets up tracking in one step (the -u is exactly what makes a future bare git push on this branch know where to go); she opens a pull request comparing her branch against main. Step 5: review, address, merge. A maintainer requests one change (a lower retry ceiling) which she addresses with one further commit on the same branch (not a rewrite of what's already been reviewed). Once approved, the maintainer merges the pull request (a squash merge, per this project's convention, flattening her branch into one clean commit on main) and deletes the now-finished branch.
Read it. Every command in this walkthrough appeared earlier in this module in isolation; the only thing new here is the order, which is exactly what CS2.02-e08's structured exercise asks you to reconstruct (clone, branch, commit, push, open, review, merge) because that order, once internalized, is the shape of essentially all professional collaboration on code.
Practice set
Work each problem fully (write your own answer) before reading its solution.
P1 (guided). The one-sentence problem. In one sentence, state the core problem version control solves; in one more, explain precisely why naming files report_v2_final.py fails to solve it.
Solution. Git records a project's full history as a sequence of inspectable, comparable, returnable-to snapshots (commits), so nothing already committed is ever truly lost and two people's changes can be combined automatically. Filename-versioning fails because there is no structured link between the versions at all (no record of why anything changed, and no tool support for combining two people's edits) every comparison and every merge has to be done by hand, from memory.
P2 (guided). The three trees. What exactly does git add <file> move, and what does git commit move, and what happens if you edit the file again after staging it but before committing?
Solution. git add <file> copies the file's current working-directory content into the staging area, a snapshot-in-progress of what the next commit will contain. git commit takes whatever is currently staged and permanently records it as a new commit; it never looks at the working directory directly. If you edit the file again after staging but before committing, the commit will contain the staged (older) version, not your newest edit, the working directory now disagrees with both the staging area and the last commit, until you git add again.
P3 (guided). The exact bytes that get hashed. Git computes a blob's id by hashing "blob " + len(content) + "\0" + content. For the 5-byte content abcde (no trailing newline), write out the exact string that gets hashed, and explain why adding a single trailing newline (abcde\n, 6 bytes) produces a completely unrelated-looking hash rather than a "close" one.
Solution. The exact string hashed is "blob 5\0abcde", the word blob, a space, the content length in bytes as decimal digits (5), one NUL byte, then the raw content itself, nothing more. With a trailing newline, the header becomes "blob 6\0abcde\n", both the length field and the final byte differ. SHA-1, like any well-built cryptographic hash, has no "similar input, similar output" property, so a one-byte difference anywhere in the input produces a completely unrelated-looking 40-character output. This is exactly why Git treats a file with a trailing newline and the same file without one as two distinct blobs.
P4. Fast-forward, precisely. main sits at commit C. You branched feature from C and made two commits, so feature is now two commits ahead, and nothing new has landed on main since. You run git switch main && git merge feature. What happens, and why, in terms of ancestry?
Solution. Because main (at C) is an ancestor of feature's tip (nothing happened on main's side since the branch point), Git performs a fast-forward: it simply moves main's pointer forward to feature's tip. No new commit is created, and history remains perfectly linear, this is exactly the is_ancestor(current, target) check deciding fast-forward eligibility.
P5. No conflict. Base value is 5. Your side (ours) changes it to 8; the other side (theirs) is untouched, still 5. What does the merge produce, and why is this not a conflict?
Solution. Because theirs still equals base (that side never touched this value) and only ours changed it, the changed value wins with zero conflict, one side changing something and the other side leaving it alone is the textbook non-conflicting case; there is nothing for a human to decide.
P6. A real conflict. Base is 5; ours changes it to 8; theirs changes it to 9. Write the exact conflict markers Git (and this module's three_way_merge) would produce.
Solution.
<<<<<<< ours
8
=======
9
>>>>>>> theirs
Both sides changed the same content to different values, so neither can be silently preferred; a human must pick 8, pick 9, or write a third value, then remove the markers and stage the file to mark the conflict resolved.
P7. Rebase vs. merge, and the rule. In one sentence each, what does merge do to history and what does rebase do to history? State the golden rule of rebasing.
Solution. git merge creates a new commit with two parents, combining two branches while leaving every existing commit on both sides completely untouched, the true, non-linear history is preserved. git rebase replays your branch's commits on top of a new base one at a time, producing clean, linear history, but every replayed commit gets a brand-new hash. Golden rule: never rebase commits that other people have already pulled or built on, because rewriting shared history forces a confusing divergence that is usually "resolved" by a dangerous force-push.
P8. Remotes, one sentence each. Define clone, fetch, pull, and push.
Solution. git clone copies an entire remote repository (every commit and branch) to your machine as a new local repository already wired to that remote. git fetch downloads new commits from the remote into your local remote-tracking branches without touching any branch you're actually working on. git pull is fetch immediately followed by a merge (or rebase) of the fetched commits into your current branch. git push uploads your local commits to the remote, and is refused if the remote has commits you don't have locally yet, exactly the check that stops an ordinary push from silently overwriting someone else's work.
P9. Reset's three flavors. For --soft, --mixed (the default), and --hard, state what changes in the working directory and the staging area (the repository (the branch pointer) moves in all three).
Solution. --soft: staging area and working directory are untouched; the "undone" commits' content now simply sits staged. --mixed (default): additionally un-stages everything, so that content sits only as uncommitted edits in the working directory. --hard: additionally overwrites the working directory too, so the content appears to vanish from your visible files entirely, the only one of the three that touches the working directory, and why it's the one that produces the "did I just lose everything" panic.
P10. Revert vs. reset, on a shared branch. Why is git revert the safer choice than git reset for undoing a commit that other people have already pulled?
Solution. git reset moves the branch pointer backward, effectively rewriting what that branch points at, if pushed, this forces everyone who already pulled the old history into a confusing divergence, typically requiring a force-push. git revert instead adds a brand-new commit that undoes an earlier one's effect, leaving all prior history completely intact; anyone who pulls afterward simply sees one more ordinary commit, no rewriting and no force-push required.
P11. Reflog rescue. Newest first: HEAD@{0} 4f2a1c9 reset: moving to HEAD~2, HEAD@{1} 9b7e3d0 commit: add slippage model, HEAD@{2} 1a2b3c4 commit: add cost model. You just ran that reset and want the slippage-model commit back. What exact command do you run?
Solution. git reset --hard 9b7e3d0. The hash at HEAD@{1} is exactly where the branch tip stood one action ago (immediately before the reset) with the "add slippage model" commit still in place. Nothing was deleted by the ordinary reset; the reflog simply handed back the hash needed to return to it.
P12. .gitignore order matters. Patterns, checked in this order: *.csv, then !keep/important.csv. Is data/prices.csv ignored? Is keep/important.csv ignored?
Solution. data/prices.csv: its basename prices.csv matches *.csv, and nothing un-ignores it: ignored. keep/important.csv: its basename also matches *.csv (ignored so far), but the later, more specific pattern !keep/important.csv explicitly negates exactly that path, not ignored, because the later, more specific rule overrides the earlier general one, exactly mirroring real Git's last-match-wins behavior.
P13. git bisect's cost. A regression sits somewhere among 500 commits (commit 0 known good, commit 499 known bad). Using binary search, what is the maximum number of tests needed to find the exact first-bad commit?
Solution. ceil(log₂ 500) = 9 test steps in the worst case (2⁸ = 256 < 500 ≤ 512 = 2⁹). Each test roughly halves the remaining range, so even 500 commits collapse to the exact culprit in single-digit steps, dramatically fewer than the up-to-500 a linear walk would need.
P14. Stash vs. a "WIP" commit. You're mid-edit, uncommitted, when an urgent request interrupts you. What do you do, and why not just commit "WIP"?
Solution. git stash shelves the uncommitted changes and returns the working directory to a clean state matching your last commit; you switch and handle the urgent work on its own branch/commit; then git stash pop restores your half-finished work exactly as you left it. A "WIP" commit instead litters shared history with a meaningless, likely-broken snapshot that (if pushed) confuses reviewers, and (if landed on inside a future bisect range) can produce a misleading good/bad test result for the wrong reason. The stash is a private, local shelf built exactly for "not done, but must switch context now."
P15. Rewrite a bad commit message. Critique, then rewrite: "fixed stuff, also updated some other things and maybe fixed the bug idk".
Solution. Problems: past tense instead of imperative; no real subject (which "stuff"? which "bug"?); multiple unrelated changes bundled into one commit; open uncertainty ("idk") that should have been resolved before committing at all. A better version splits this into however many logical changes it actually contains, each its own commit with an honest, specific, imperative subject under about 50 characters: e.g. Fix off-by-one in bisect range check as one commit, Add retry logic to the price-feed fetcher as a separate one, each independently revertable, each explaining what changed, with why (if not obvious) in an optional body below a blank line.
P16. The AI-assisted git mistake. A learner pastes a scary Git error into a chat assistant, which confidently replies "just run git push --force," and the learner does, overwriting three of a teammate's commits on the shared main branch. Which step of the Copilot Discipline was skipped, and what should have happened instead?
Solution. The review step (and, further back, understand-it-yourself-first) was skipped: the learner ran a destructive, history-rewriting command on a shared branch without first understanding what --force actually does (unconditionally overwrite the remote's history with the local version, discarding anything on the remote not already present locally) or checking whether anyone else's commits were on that branch. The safe path: read the assistant's explanation of the current state first, run a non-destructive confirmation command (git log --oneline --all, or git fetch followed by a comparison), recognize this is a shared branch, and (per the golden rule) never force-push a branch other people have pulled; the correct fix is almost always git revert or a coordinated, communicated recovery, never a unilateral force-push.
Applied mini-project
Turn a folder of scripts into a real, recoverable research repo.
You will take an unversioned folder of analysis scripts and turn it into a properly versioned Git repository, deliberately triggering, and recovering from, the two events every analyst eventually hits: a merge conflict and a destructive mistake. Use your own small project if you have one, or this illustrative starting point: a folder containing fetch_prices.py, clean_prices.py, backtest.py, notes.md, an API-key file secrets.env, and a large raw_prices.csv dump: nothing yet tracked.
Do this, in order:
- Initialize and ignore first.
git init. Before your firstgit add, write a.gitignoreexcludingsecrets.env,raw_prices.csv,__pycache__/, and any virtual-environment folder, nothing sensitive or regenerable enters history, even once (Common Mistake #3). - Commit atomically. Stage and commit your starting scripts as a logical sequence of small commits, not one giant commit, for instance, one commit per script, each with an honest, imperative-mood subject line.
- Branch, edit, and force a real conflict. Create a feature branch, change one line of
backtest.py(a commission or slippage constant), and commit. Switch back to your main branch, change the same line to a different value, and commit there too. Merge the feature branch in and resolve the conflict by hand, record the exact<<<<<<</=======/>>>>>>>block you saw, and the value you chose, and why. - Break something on purpose, then recover it. Run a
git reset --hardthat goes back further than intended, discarding at least one real commit. Then usegit reflogto find the lost commit andgit reset --hard <hash>back to it, record both the reflog excerpt and the exact recovery command you ran. - Finish clean. Record the output of
git log --oneline --graph --allshowing your final history: atomic commits, one real merge, nothing sensitive ever tracked, and clear evidence that a "disaster" was fully recoverable.
Deliverable and self-check (10 points; pass at 8/10, with no zero on any item):
- 2 pts:
.gitignorewritten before the first commit; secrets, the data dump, and cache files never appear ingit log -- <path>for those files. - 2 pts: commit history is atomic and legible: no single giant "everything" commit, every subject line a clear, imperative, sub-70-character sentence.
- 2 pts: a genuine merge conflict was created, shown (the exact markers recorded), and correctly resolved, with no leftover
<<<<<<</=======/>>>>>>>text accidentally committed (itself a very common real mistake, watch for it). - 2 pts: a real mistake was made and fully recovered using
git reflogplusgit reset --hard, with both the reflog excerpt and the exact recovery command shown. - 2 pts: the final
git log --oneline --graph --allis legible and matches the narrative above.
Lab: open this node's Code tab and clear the CS2.02 code pack, eight exercises that turn the mechanisms behind these commands (content-addressed hashing, the fast-forward/three-way decision, conflict resolution, .gitignore matching, history parsing, bisect's binary search, and reflog recovery) into runnable, hidden-test-graded Python, each with a fully offline structured fallback (fill-in-the-blank, choose-the-correct-implementation, or put-these-in-order) so the entire pack is completable with zero live code execution if you're offline.
Reading & resources
Everything below is an anchor to verify against and build real fluency from, not a substitute for actually running the commands on your own repository.
The core spine:
- Scott Chacon & Ben Straub, Pro Git (free, official, online), the canonical reference for this entire module; its early chapters map almost one-to-one onto this module's Core content. [Free] [Beginner–Advanced]
- Learn Git Branching (
learngitbranching.js.org), an interactive, visual sandbox for branch/merge/rebase; the fastest way to build the graph intuition this module describes in prose. [Free] [Beginner] - Atlassian's Git tutorials, clear, example-driven walkthroughs of branching, merging, and the standard collaboration workflow. [Free] [Beginner–Intermediate]
When something looks scary:
- Oh Shit, Git!?! (
ohshitgit.com), a short, practical, plain-language cheat sheet for exactly the "I did something terrible" moments Worked Example 5 and the reflog section describe. [Free] [Beginner–Intermediate] git help <command>(orman git-<command>), always the current, authoritative description of any command's exact behavior on your installed Git version. [Free] [Beginner–Advanced] (perishable by design, flags and defaults do shift across Git versions; treat any specific flag behavior you rely on as worth re-checking against your owngit --version.)
For the historically minded:
- Linus Torvalds' 2007 Google Tech Talk on Git, the original design rationale, straight from its author, for why Git is built around cheap, content-addressed snapshots rather than centralized diffs. [Free] [Intermediate–Advanced]
Where you'll use this next:
- GitHub's (or your Git host's) own documentation on pull requests and code review, the concrete, platform-specific version of the collaboration workflow this module describes generically. [Free] [Beginner]
- This node's own CS2.02 code pack (
app/public/code-packs/CS2.02.json), eight exercises turning this module's mechanisms into checkable Python.
Do this, not just read: on any small project (even a folder of two files), run git init, make three real commits, create one branch, deliberately create and resolve one merge conflict, and deliberately run one git reset --hard you then recover with git reflog. Ten minutes of doing this once, for real, will install more intuition than an hour of reading about it.
Teach it back & journal
Feynman prompt. Explain to a friend who has only ever used "Save As" and folders full of _v2_final files why Git is not just "a fancier way to save copies." Use the sentence "Git stores content-addressed snapshots, and a branch is just a movable pointer, nothing is ever copied when you branch." Then walk them through a single merge conflict on paper: draw the three-line marker block, explain what each section means, and show that resolving it is nothing more than deciding which lines survive. If your friend can, unprompted, predict that a git reset --hard mistake is probably still recoverable via git reflog, and say why, you've installed the mental model this module exists to teach.
Journal prompt. For one real week of work (your own practice repo is enough), commit as you go (deliberately small, deliberately atomic, with an honest imperative-mood subject line every time) and keep a short log: How many commits did you make? How many times did git status or git diff catch something you didn't expect to be staged? Did you hit a real merge conflict, and how long did resolving it actually take once you stopped being afraid of the markers? Did you ever reach for git reflog, and did it have what you needed? Then write one paragraph on this: version control's real payoff isn't any single command, it's that a well-kept history turns "what happened to this project, and why" from a guess into a fact you can look up. Which single habit from this module: committing atomically, writing .gitignore first, or checking git reflog before panicking, would most change how much you trust your own past work, and what will you do this week to make it automatic?
This module's flashcards and mastery quiz are wired into the app: see the node's Quiz and Reviews.