Version Control · From Zero to Fluent

Git, learned properly.
Snapshots, branches, and the shortcuts that make it fast.

Git is a time machine for your project. Every time you tell it to, it takes a snapshot of all your files — and it never forgets one. Once you understand three ideas — the snapshot, the three areas, and the branch — everything else is vocabulary. This guide builds those three ideas in order, with live playgrounds, then hands you the alias kit that makes daily Git nearly effortless.

Commit a1f9e2c

The mental model: snapshots, not diffs

Most beginners imagine Git as a pile of "changes." It isn't. Every commit is a complete snapshot of your entire project at one moment, plus a little metadata: who made it, when, a message explaining why, and a pointer to the snapshot that came before it. Chain those pointers together and you get your project's history — a line of commits stretching back to the very first one.

Each commit gets a unique ID — a 40-character SHA-1 hash like a1f9e2c…. You almost never type the whole thing; the first 7 characters are enough. Those little hex labels in the sidebar? That's what commit IDs look like in the wild.

Three facts to internalize before touching the keyboard:

  • Git is local. The entire history lives in a hidden .git folder inside your project. Committing, branching, viewing history — none of it needs the network. GitHub is an optional copy of your repository, not the repository itself.
  • Commits are permanent-ish. Once something is committed, it is astonishingly hard to lose. Even "deleted" commits linger for weeks and can be recovered (see Undoing things). The corollary: commit early, commit often — a commit is a save point, and save points are free.
  • Branches are just labels. A branch is a 41-byte file containing a commit ID. That's it. Creating one is instant and costs nothing, which is why Git users branch constantly and casually.
IDEA

The save-point analogy. If you've ever played a game with manual saves, you already understand Git: git commit is "save game," branches are separate save slots, and git checkout loads one. The only new idea is that Git keeps every save you ever made, forever.

Commit b7d3041

Install & one-time setup

On macOS, Git ships with Apple's Command Line Tools. If you've ever compiled anything, you already have it. Otherwise, one command triggers the installer:

terminal · macOS
# Check whether git is present (and its version)
git --version
git version 2.50.1 (Apple Git-155)

# If missing, this pops up the Command Line Tools installer
xcode-select --install

# Prefer a newer Git than Apple's? Homebrew has it:
brew install git

Tell Git who you are

Every commit is stamped with an author. Set your identity once, globally, and Git uses it for every repository on the machine. While you're in there, set two more defaults that save friction later:

one-time configuration
git config --global user.name "Stephen"
git config --global user.email "you@example.com"

# Name new repos' first branch "main" (matches GitHub)
git config --global init.defaultBranch main

# Pick your editor for commit messages (nano is beginner-friendly)
git config --global core.editor "nano"
# ...or, since you're an Emacs person:
git config --global core.editor "emacs"

# Colorized output everywhere (usually on by default)
git config --global color.ui auto

# See everything you've configured
git config --global --list
TIP

All of this lands in a plain-text file at ~/.gitconfig. You can edit it directly — and later in this guide you'll paste an alias block into it that turns long commands into two-letter shortcuts.

Commit c2e88af

Your first repository: the core loop

Ninety percent of daily Git is a four-command loop: statusaddcommit → repeat. Let's build a repository around your actual project — say, the static site in ~/Sites/mysite — and run the loop once, slowly.

step 1 · create the repository
cd ~/Sites/mysite
git init
Initialized empty Git repository in /Users/stephen/Sites/mysite/.git/

That's the whole ceremony. Git created a hidden .git directory — the database where every snapshot will live. Your files are untouched; Git is simply watching now. Ask it what it sees:

step 2 · ask for status (you will run this constantly)
git status
On branch main
No commits yet
Untracked files:
  (use "git add <file>..." to include in what will be committed)
        index.html
        style.css

Untracked means Git has never been told these files matter. Stage them — that is, put them in the box that the next snapshot will photograph:

step 3 · stage files
git add index.html style.css
# or stage everything in the current directory at once:
git add .

git status
Changes to be committed:
        new file:   index.html
        new file:   style.css
step 4 · commit (take the snapshot)
git commit -m "Initial commit: site skeleton"
[main (root-commit) c2e88af] Initial commit: site skeleton
 2 files changed, 48 insertions(+)

Done — snapshot c2e88af exists forever. Now the loop: edit style.css, and git status reports it as modified. Stage it, commit it, and you have a second save point. That rhythm — work, status, add, commit — is the heartbeat of everything else in this guide.

TIP

The shortcut you'll use most: git commit -am "message" stages every already-tracked modified file and commits in one step. It does not pick up brand-new files — those still need an explicit git add once.

CARE

If you run git commit without -m, Git opens your editor for the message. In vim (a common default) that surprises people: type your message, then press Esc and type :wq to save and exit. Setting core.editor to nano or emacs in the setup section avoids the ambush entirely.

Commit d94c1b6

The three areas: why add exists at all

Newcomers often ask why committing takes two steps. The answer is Git's most distinctive design decision: between your files and the permanent history sits a middle layer called the staging area (also "the index"). It's a loading dock where you assemble exactly the snapshot you want, before making it permanent.

Working directory

Your real files, as they are right now. Messy. In progress.

git add

Staging area

The next snapshot, under construction. You choose what goes in.

git commit

Repository

Permanent history. Snapshots that can always be recovered.

Why bother? Because real work is messy. You fixed a bug and half-finished a new feature in the same afternoon. The staging area lets you add just the bug-fix files and commit them as one clean, focused snapshot — leaving the half-finished feature uncommitted until it's ready. Focused commits are what make history readable and reversible.

Try it below. Edit files, stage some of them, commit, and watch changes flow left to right through the three areas.

Playground · The three areas

interactive
Working directory
Staging area
Repository

Seeing what's where: the two diffs

With three areas come two gaps, and Git gives you a diff for each:

inspecting the gaps
# Working directory vs. staging area — "what have I changed but not staged?"
git diff

# Staging area vs. last commit — "what exactly will my next commit contain?"
git diff --staged

# Unstage a file you added by mistake (file itself is untouched)
git restore --staged style.css
Commit e5a7d90

Reading history

A history you can't read is a history you can't use. git log is the raw tool; the trick is knowing the three or four flag combinations that turn its firehose into something scannable.

the log, four ways
# Full detail: hash, author, date, message — one screen per commit
git log

# One line per commit — the everyday view
git log --oneline
e5a7d90 Add contact page
d94c1b6 Style the navigation bar
c2e88af Initial commit: site skeleton

# The full picture: every branch, drawn as an ASCII graph
# (this becomes indispensable once you branch — alias it!)
git log --oneline --graph --all --decorate

# Only what touched one file, with the actual changes shown
git log -p --follow style.css
inspecting a single commit
# Show everything about one commit (message + full diff)
git show d94c1b6

# Who last touched each line of a file, and in which commit?
git blame index.html
TIP

HEAD, which you'll see everywhere, simply means "the commit you're standing on right now" — almost always the latest commit of your current branch. HEAD~1 is its parent, HEAD~2 its grandparent, and so on.

Commit f1b62e4 · branch: feature

Branching & merging: Git's superpower

A branch lets you leave the main line of history untouched while you experiment on a parallel one. Redesigning your site's CSS? Branch. Trying a risky refactor of a Perl script? Branch. If it works, merge it back; if it doesn't, delete the branch and main never knew. Because a branch is just a movable label pointing at a commit, all of this is instant.

the branching workflow
# Create a branch AND switch to it (the everyday command)
git switch -c new-navbar

# ...edit, add, commit as usual. Commits now land on new-navbar...
git commit -am "Rebuild navbar as flexbox"

# See all branches (* marks where you are)
git branch
  main
* new-navbar

# Jump back to main — your working files instantly change to match!
git switch main

# Happy with the branch? Merge it into main, then tidy up
git merge new-navbar
git branch -d new-navbar
IDEA

Older tutorials use git checkout -b name and git checkout main for these jobs. They still work — switch (2019+) is just the clearer, modern spelling. checkout survives because it also does other, unrelated things, which is exactly why it was split up.

The two shapes of a merge

  • Fast-forward. If main hasn't moved since you branched, Git just slides the main label forward to your branch's tip. No new commit; history stays a straight line.
  • Merge commit. If both branches gained commits, Git weaves them together with a new commit that has two parents. The graph shows a genuine fork and rejoin.

When both branches edit the same line: conflicts

Conflicts sound scary; they're actually just Git refusing to guess. It pauses the merge and marks the disputed lines directly in the file:

what a conflict looks like — and how to resolve it
git merge new-navbar
CONFLICT (content): Merge conflict in style.css
Automatic merge failed; fix conflicts and then commit the result.

# Inside style.css you'll find markers like this:
<<<<<<< HEAD
  nav { background: #14161a; }      ← main's version
=======
  nav { background: #1c1f26; }      ← the branch's version
>>>>>>> new-navbar

# 1. Edit the file: keep what you want, DELETE all marker lines
# 2. Then tell Git it's resolved:
git add style.css
git commit            # Git supplies a merge message

# Panicking? Abort and return to exactly before the merge:
git merge --abort

Now drive the graph yourself. Commit, branch, switch, and merge below — the DAG (directed acyclic graph, Git's actual data structure) redraws live, and the merge button demonstrates both fast-forward and true merges depending on the shape you've built.

Playground · Commit graph

interactive
HEAD → main
branches
last action
repo initialized
Commit 0a9fc37 · branch: feature

Undoing things: the safety net

This is the section that turns anxiety into confidence. Git has a graduated set of undo tools, from "fix a typo in my last commit message" to "recover a branch I deleted an hour ago." Learn them in order of severity.

You want to…CommandDanger level
Discard unstaged edits to a filegit restore file.css⚠ destroys uncommitted work in that file
Unstage a file (keep the edits)git restore --staged file.csssafe
Fix the last commit's message / add a forgotten filegit commit --amendsafe locally; avoid after pushing
Undo a commit publicly (make a new commit that reverses it)git revert a1f9e2csafe — history only grows
Move the branch back, keep changes stagedgit reset --soft HEAD~1safe — nothing is lost
Move back, keep changes as unstaged edits (default)git reset HEAD~1safe — nothing is lost
Erase the last commit and all its changesgit reset --hard HEAD~1⚠⚠ working files overwritten
Recover after any disastergit reflogread-only — pure rescue

Amend: the polish tool

fixing the most recent commit
# Typo in the message?
git commit --amend -m "Style the navigation bar"

# Forgot to include a file? Stage it, then fold it in silently:
git add forgotten.css
git commit --amend --no-edit

Reflog: the true safety net

Here is the fact that makes Git nearly loss-proof: the reflog records every place HEAD has ever been on your machine — every commit, switch, reset, and merge — for ~90 days, even for commits no branch points to anymore. "Destroyed" work is usually one reflog lookup away:

recovering from a bad reset
git reflog
3b91c60 HEAD@{0}: reset: moving to HEAD~1     ← the mistake
0a9fc37 HEAD@{1}: commit: Add contact form    ← the "lost" commit!
e5a7d90 HEAD@{2}: commit: Add contact page

# Point the branch right back at it — fully restored
git reset --hard 0a9fc37
CARE

The one real rule: never rewrite history you've already pushed and others may have pulled (--amend, reset on shared branches). For anything public, use git revert — it undoes by adding a commit, so everyone's history stays consistent. Solo repos: rewrite freely.

Commit 1c48d5b · branch: feature

Remotes & GitHub

Everything so far lived on your Mac. A remote is a copy of the repository somewhere else — GitHub, GitLab, another machine — that you synchronize with. By convention your primary remote is named origin. Two verbs move commits across the wire: push (send yours up) and pull (bring theirs down).

Starting from either direction

direction 1 · existing project → up to GitHub
# On github.com: New Repository → name it → create (add nothing to it)
# Then connect and push:
git remote add origin git@github.com:stephen/mysite.git
git push -u origin main
# -u links local main ↔ origin/main; afterwards plain `git push` suffices
direction 2 · GitHub project → down to your Mac
git clone https://github.com/octocat/Spoon-Knife.git
cd Spoon-Knife
# clone = copy the entire history + set up origin automatically

The daily sync

the rhythm of a synced repo
# Before starting work: get the latest
git pull

# ...work, add, commit locally as many times as you like...

# When ready to share:
git push

# pull = fetch (download) + merge (integrate). To only look first:
git fetch
git log --oneline main..origin/main   # what's new upstream?
TIP

Auth note: GitHub no longer accepts account passwords over HTTPS. Either use SSH keys (generate with ssh-keygen -t ed25519, paste the .pub file into GitHub → Settings → SSH keys) or install the GitHub CLI and run gh auth login, which handles everything.

IDEA

The classic collaboration flow — a pull request — is just branching over the network: push a feature branch (git push -u origin new-navbar), open a PR on GitHub, discuss, and merge there. Your branching skills transfer one-to-one.

Commit 2ef07a8 · branch: feature

Stash & .gitignore: the two conveniences

git stash — the pocket

Mid-edit with a messy working directory, and you suddenly need to switch branches? stash sweeps all uncommitted changes into a pocket, leaving the directory clean. Pop them back out whenever:

shelving work-in-progress
git stash                     # pocket everything; directory now clean
git switch main              # go do the urgent thing…
git switch new-navbar        # …come back
git stash pop                 # changes restored, stash removed

git stash list                # stashes stack — see them all
git stash push -m "half-done navbar"   # label one for later

.gitignore — the bouncer

Some files should never be committed: OS litter, build output, editor droppings, secrets. List patterns in a file named .gitignore at the repo root and Git stops even mentioning them. A solid starter for Mac-based web work:

.gitignore
# macOS
.DS_Store

# Editors
*~
\#*\#
.vscode/

# Dependencies & build output
node_modules/
dist/
*.log

# Secrets — never commit credentials
.env

Commit the .gitignore itself — it's part of the project. If a file was already committed before you ignored it, remove it from tracking (keeping the file on disk) with git rm --cached .DS_Store, then commit.

Commit 3b91c60 · branch: rescue

Shortcuts & aliases: making Git fast

Fluent Git users don't type faster — they type less. Three layers of shortcuts stack on each other: Git's built-in abbreviations, Git aliases, and shell aliases.

Layer 1 · built-in abbreviations you already have

ShortcutExpands toNotes
git commit -am "msg"add all tracked + committhe workhorse; skips new files
git switch -switch to previous branchlike cd - — toggle between two branches
git add -pstage hunks interactivelyy/n through each change; surgical commits
git commit --amend --no-editfold staged changes into last committhe "oops, one more thing"
HEAD~1, HEAD~21, 2 commits before HEADworks anywhere a commit ID does
a1f9e2cfull 40-char hashany unique prefix (≥4 chars) works
git log -3last 3 commits onlyany number

Layer 2 · Git aliases (the big one)

Git lets you define your own subcommands. Paste this block into ~/.gitconfig — it's a curated, battle-tested kit, and every alias below is used in anger by working developers:

~/.gitconfig · paste under a new [alias] heading
[alias]
    # — the daily four —
    st  = status -sb                  # compact status w/ branch info
    co  = switch                       # git co main
    cm  = commit -m                    # git cm "message"
    aa  = add -A                       # stage everything, incl. new files

    # — history, readable —
    lg  = log --oneline --graph --decorate -15
    lga = log --oneline --graph --decorate --all
    last = log -1 HEAD --stat          # what did I just commit?

    # — branches —
    br  = branch
    new = switch -c                    # git new feature-x

    # — safety & fixing —
    unstage = restore --staged
    oops    = commit --amend --no-edit
    undo    = reset --soft HEAD~1      # un-commit, keep everything staged

    # — remotes —
    pu  = push
    pl  = pull --ff-only               # pull, but never create surprise merges

Or install any single alias from the command line, no editing required:

one-liner installation
git config --global alias.lg "log --oneline --graph --decorate -15"

The payoff, in one before/after:

a day's work, abbreviated
# Before                                    # After
git status                                  git st
git switch -c fix-footer                    git new fix-footer
git add -A                                  git aa
git commit -m "Fix footer overflow"         git cm "Fix footer overflow"
git log --oneline --graph --decorate        git lg
git switch main && git merge fix-footer     git co - && git merge fix-footer

Layer 3 · shell aliases (zsh)

For the absolute maximum laziness, alias at the shell level in ~/.zshrc so even the word git disappears:

~/.zshrc
alias g='git'
alias gs='git status -sb'
alias gl='git log --oneline --graph --decorate -15'
alias gp='git push'
# Reload: source ~/.zshrc — now it's just  g cm "message"
TIP

macOS's zsh ships with a huge git tab-completion system. Type git sw⇥ to complete switch, or git switch ⇥⇥ to list branch names. If completion seems inert, add autoload -Uz compinit && compinit to ~/.zshrc.

Drill it: command reflexes

Fluency is recognition speed. Ten quick situations — pick the command you'd reach for:

Playground · What do you type?

interactive
Commit 4d20e9f · branch: rescue

Good habits: the taste layer

  • Commit one idea at a time. "Fix navbar overflow" is a commit. "Fix navbar, add contact page, tweak fonts" is three. Small commits make revert, blame, and code review all work dramatically better. git add -p exists precisely to untangle mixed work.
  • Write messages in the imperative: "Add contact form", not "added contact form". Convention: a summary line ≤ 50 characters; if more explanation is needed, a blank line and then paragraphs. The summary answers what; the body answers why.
  • Run git status before and after everything. It's free, it's instant, and it always tells you exactly where you stand — most beginner confusion evaporates on contact with status.
  • Branch for anything nontrivial. The cost is one command; the benefit is that main always works.
  • Pull before you push on shared repos; integrate others' work locally, where conflicts are easy to fix.
  • Never commit secrets. API keys and passwords live in .env-style files listed in .gitignore. History is forever — a pushed secret must be treated as leaked and rotated.
IDEA

Where to go deeper: the free official book Pro Git (chapters 1–3 cover everything here with more theory), and git help <command> for any command's full manual — try git help log.

Commit 5f6ab12 · branch: rescue

Cheat sheet

The whole guide on one screen. Print-worthy.

Core loop

git init
start tracking this folder
git status
where am I? (run constantly)
git add <f> / .
stage file(s) for next snapshot
git add -p
stage change-by-change
git commit -m ""
take the snapshot
git commit -am ""
stage tracked + commit
git diff / --staged
unstaged / staged changes

Branch & merge

git branch
list branches (* = current)
git switch -c x
create branch x, jump to it
git switch x / -
go to x / toggle previous
git merge x
fold x into current branch
git merge --abort
back out of a conflict
git branch -d x
delete merged branch
git stash / pop
pocket / restore WIP

Undo & rescue

git restore f
discard unstaged edits ⚠
git restore --staged f
unstage, keep edits
git commit --amend
fix last commit
git revert <id>
safe public undo
git reset HEAD~1
un-commit, keep edits
git reset --hard <id>
jump branch here ⚠⚠
git reflog
everywhere HEAD has been

History & remotes

git log --oneline
compact history
… --graph --all
full branch picture
git show <id>
one commit in detail
git clone <url>
copy a remote repo
git remote add origin
connect to GitHub
git push -u origin main
first push (links branches)
git pull / push
sync down / up
git fetch
download without merging