Git — Fundamentals
The Three Trees: Working Directory, Staging Area, Repository
Git tracks changes through three distinct states, and most early confusion about "why didn't my commit include that change" comes from not having a clear mental model of them:
git add moves changes from the working directory into staging; nothing else does.
.git/) — the committed history. git commit takes whatever is currently staged and turns it into a permanent snapshot.
A very common mistake: editing a file after git add-ing it, then committing, and being surprised the new edits aren't in the commit — git add staged a snapshot at that moment, not a live link to the file. Re-run git add after any further edits, or git commit -a to auto-stage all tracked-file changes at commit time (this does not pick up brand-new untracked files).
Branches Are Just Pointers
A branch in Git is not a copy of the codebase — it's a lightweight, movable pointer to a specific commit. This is why creating a branch (git branch feature/x) is instant regardless of repository size: it's writing one small pointer file, not copying anything. HEAD is a pointer to whichever branch (or commit, in detached-HEAD state) you currently have checked out — git log walks backward from wherever HEAD currently points.
Merge vs. Rebase — Same Goal, Different History Shape
Both bring changes from one branch into another, but they produce genuinely different history:
git merge feature/x (while on main) creates a new merge commit with two parents, preserving the exact sequence of commits from both branches exactly as they happened. Non-destructive — never rewrites existing commits.
git rebase main (while on feature/x) replays your branch's commits one by one on top of the current tip of main, producing a linear history with no merge commit — but it rewrites your branch's commit hashes in the process.
The rule that actually matters in practice: never rebase commits that have already been pushed and that anyone else might have based work on. Rebasing rewrites commit hashes — if someone else already pulled the old commits, their history and yours diverge in a way that's genuinely painful to reconcile. Rebasing your own local, not-yet-pushed commits (to clean up history before opening a PR) is safe and common; rebasing a shared branch after others have pulled it is the mistake that causes real team pain.
Undoing Things — Pick the Right Tool for What You Actually Want
Git has several "undo" commands that look similar but do meaningfully different things — using the wrong one is a common source of either lost work or a messy shared history:
revert vs reset: revert is the right tool once something has been pushed and others may already have it — it never rewrites history, just adds a compensating commit. reset rewrites what your branch points to and is only safe for commits that are still purely local.
Stashing — Temporarily Set Aside Uncommitted Work
The common use case: you're mid-way through changes, need to switch branches to fix something urgent, but aren't ready to commit the in-progress work. git stash clears the working directory so the switch is clean, without losing anything or forcing a half-finished commit.
Tags — Marking a Specific Point in History
Use annotated tags (-a) for anything meant to represent a real release — they carry metadata (who tagged it, when, why) that a lightweight tag doesn't, which matters when someone's trying to understand release history months later.

