Git Fundamentals to Intermediate

Git is the de facto version control system for modern software. In this guide we'll move from the basic "three trees" mental model all the way to interactive rebase, reflog-based recovery, and hooks. Every command here was verified against current Git documentation (git help <command> on Git 2.40+).

Real-World Scenario

You just rebased the wrong branch.

You meant to clean up your feature branch with git rebase -i, but you accidentally ran it on main after pulling a teammate's work. Commit hashes have changed, the branch tip looks unfamiliar, and you're starting to sweat. By the end of this guide, you'll know exactly how to use git reflog to put everything back where it was. Git almost never truly loses data — you just need to know where to look.

The Three Trees

Almost every Git command operates on (or moves data between) three places. If you internalize this model, the rest of Git stops feeling magical.

  • Working directory — the actual files on disk that you edit.
  • Index (also called the "staging area" or "cache") — a snapshot of what will go into the next commit.
  • HEAD — a pointer to the last commit on the current branch; effectively the snapshot of the previous commit.
  working dir          index (staged)         HEAD (last commit)
  -----------          ---------------         ------------------
   edits  ──▶  git add  ──▶  staged  ──▶  git commit  ──▶  history
   ◀──  git restore     ◀──  git restore --staged  ◀── git reset

Most "I'm confused about Git" moments come down to forgetting which of those three places you're looking at. git status reports all three, which is why we run it constantly.

Basic Workflow

Here is the loop we run dozens of times a day. We'll start in a clean clone:

Inspect, stage, commit, review

# What changed and where?
git status

# Show unstaged changes (working dir vs index)
git diff

# Show staged changes (index vs HEAD) — what will actually be committed
git diff --staged

# Stage specific files (preferred over `git add .` for clarity)
git add src/parser.py tests/test_parser.py

# Commit with a short subject + blank line + body
git commit -m "parser: handle empty input" -m "Return [] instead of raising IndexError."

# View history (graph form is far more useful)
git log --oneline --graph --decorate --all -20

Tip: git add -p walks you through each hunk interactively. It's the single best habit for creating clean, reviewable commits.

Branching

A branch in Git is just a movable pointer to a commit. Creating one is essentially free.

Create, switch, merge

# Create and switch in one step (modern syntax, Git 2.23+)
git switch -c feature/login

# Older but still valid
git checkout -b feature/login

# List branches; the asterisk marks current
git branch

# Switch back to main
git switch main

# Merge feature into the current branch (main)
git merge feature/login

# Delete a merged branch
git branch -d feature/login

# Force-delete a branch that hasn't been merged (use with care)
git branch -D feature/scratch

By default git merge performs a fast-forward when possible (no merge commit) or creates a merge commit when histories have diverged. Force a merge commit with --no-ff if you want every feature branch to be visibly preserved in history.

Rebase vs Merge

Both integrate changes from one branch into another. They produce different histories, and the trade-off matters.

Before:           A───B───C  main
                       \
                        D───E  feature

git merge feature (into main):
                  A───B───C───M  main
                       \     /
                        D───E   (M is a merge commit)

git rebase main (on feature):
                  A───B───C            main
                           \
                            D'───E'   feature  (D, E rewritten on top of C)

Trade-offs:

  • Merge preserves the true history, including the fact that work happened in parallel. git bisect still works cleanly. Downside: lots of merge commits can clutter git log.
  • Rebase produces a linear history that is much easier to read and review. Downside: it rewrites commits (new hashes), which is dangerous on shared branches.

⚠️ The golden rule: Never rebase a branch that other people have pulled. Their copies still point at the old commits; your rewrite makes their next push or merge a mess. Safe targets: your own local feature branches that have not yet been pushed (or that only you use).

Interactive Rebase

Interactive rebase is how we polish a feature branch before opening a PR: squash WIP commits, reword sloppy messages, and reorder logically.

Squash the last 4 commits into a clean series

# Open an editor showing the last 4 commits on the current branch
git rebase -i HEAD~4

You'll see something like this in your editor:

pick a1b2c3d add login form skeleton
pick e4f5g6h fix typo
pick i7j8k9l WIP
pick m0n1o2p actually wire up auth

# Rebase abc1234..m0n1o2p onto abc1234 (4 commands)
#
# Commands:
# p, pick   = use commit
# r, reword = use commit, but edit the message
# e, edit   = use commit, but stop for amending
# s, squash = use commit, but meld into previous commit
# f, fixup  = like squash, but discard this commit's message
# d, drop   = remove commit

Change it to:

pick a1b2c3d add login form skeleton
fixup e4f5g6h fix typo
fixup i7j8k9l WIP
reword m0n1o2p actually wire up auth

Save and close. Git replays the commits in order, squashing the typo and WIP into the first commit, and stopping to let us rewrite the final message. The result: two clean commits instead of four.

If you get into trouble mid-rebase, git rebase --abort returns you to where you started.

Stash

Stash is for "I need to switch context but my work isn't ready to commit." It saves working dir + index to a stack and restores a clean tree.

Stash workflow

# Save current changes with a label
git stash push -m "halfway through refactor"

# List stashes
git stash list

# Show what's in the most recent stash
git stash show -p

# Apply and drop the top stash
git stash pop

# Apply but keep on the stack
git stash apply stash@{1}

# Drop a specific stash
git stash drop stash@{0}

Note: git stash only saves tracked files by default. Use git stash push -u to include untracked files, or -a to include ignored files.

Reset — Soft, Mixed, Hard

git reset moves HEAD (and optionally the index and working dir) to a different commit. The mode determines how much it touches.

  • --soft: move HEAD only. Index and working dir untouched. Your changes are now staged, ready to recommit.
  • --mixed (default): move HEAD and reset the index. Working dir untouched. Your changes are now unstaged.
  • --hard: move HEAD, reset index, and overwrite the working directory. This deletes uncommitted work.

Common reset patterns

# Undo the last commit but keep changes staged (most common)
git reset --soft HEAD~1

# Undo the last commit and unstage everything (still on disk)
git reset --mixed HEAD~1

# Unstage a single file (without touching the working copy)
git reset HEAD path/to/file
# Or the modern equivalent:
git restore --staged path/to/file

⚠️ Destructive command — read before running: git reset --hard permanently discards uncommitted changes in the working directory. There is no undo for unsaved edits. Reach for git stash first if you might want them back.

# Discard everything since the last commit (DANGEROUS)
git reset --hard HEAD

Reflog — Your Safety Net

Even after a "bad" rebase, reset, or branch deletion, the commits usually still exist in your local repo for around 30–90 days (per Git's default GC settings). The reflog records every move HEAD has made, so you can find them.

Worked example: recover from a botched rebase

# We ran `git rebase -i HEAD~10` and accidentally dropped the wrong commits.
# Our branch tip is now wrong. Don't panic.

# 1. Look at every recent HEAD position
git reflog

# Example output:
# 9f2a1c8 HEAD@{0}: rebase (finish): returning to refs/heads/feature
# 9f2a1c8 HEAD@{1}: rebase (pick): apply config changes
# a7b3d4e HEAD@{2}: rebase (start): checkout main
# 4d5e6f7 HEAD@{3}: commit: the commit we just nuked
# 3c2b1a0 HEAD@{4}: pull: Fast-forward

# 2. We want to get back to HEAD@{3}, which is the state before the rebase started
git switch -c rescue 4d5e6f7

# 3. Inspect; if it's what we wanted, move our branch back to it
git switch feature
git reset --hard 4d5e6f7

Branches deleted with git branch -D can be recovered the same way — find the last commit hash in the reflog and recreate the branch pointing at it.

Cherry-pick

git cherry-pick copies a single commit (or range) from one branch onto another. Useful for backporting a bugfix from main to a release branch.

Cherry-pick a fix to a release branch

git switch release/1.4
git cherry-pick a1b2c3d            # copy a single commit
git cherry-pick a1b2c3d^..f9e8d7c  # copy a range (inclusive of both ends)
# If a conflict happens, resolve it then:
git cherry-pick --continue
# Or bail out:
git cherry-pick --abort

Cherry-picked commits get new hashes (they are distinct commits). If you later merge the original branch in, Git is usually smart enough to skip the already-applied changes thanks to patch IDs.

Tags — Lightweight vs Annotated

Tags mark a point in history — typically a release. Two flavors:

  • Lightweight: a bare pointer to a commit. No metadata. Good for personal bookmarks.
  • Annotated: a full object in the database with author, date, message, and (optionally) a GPG signature. Use these for releases.

Tagging

# Lightweight
git tag v1.2.3

# Annotated (recommended for releases)
git tag -a v1.2.3 -m "Release 1.2.3 — adds OIDC support"

# Signed annotated tag
git tag -s v1.2.3 -m "Release 1.2.3"

# List
git tag --list 'v1.*'

# Push tags (they don't go up with normal `git push`)
git push origin v1.2.3
git push origin --tags    # push all tags

# Delete locally and remotely
git tag -d v1.2.3
git push origin :refs/tags/v1.2.3

Remotes — push, pull, fetch

A remote is a named reference to another copy of the repo (usually a server). The default name when you clone is origin.

Remote basics

# Show all configured remotes with URLs
git remote -v

# Add an upstream (e.g. when working on a fork)
git remote add upstream https://github.com/upstream-org/project.git

# Fetch updates without merging
git fetch origin
git fetch upstream

# Pull = fetch + merge (or fetch + rebase with --rebase)
git pull
git pull --rebase            # cleaner history on shared branches you track

# Push the current branch
git push

# Push and set the upstream (the first time a new branch goes up)
git push -u origin feature/login

Upstream tracking: the -u (or --set-upstream) flag tells your local branch which remote branch it tracks. After that, git push and git pull with no arguments know where to go. Check the tracking with git branch -vv.

Git Hooks

Hooks are scripts that Git runs at specific points. They live in .git/hooks/ in every clone (so they're not versioned by default). For team-shareable hooks, use a tool like pre-commit or set core.hooksPath to a tracked directory.

Common hooks:

  • pre-commit — runs before the commit message is written. Block bad commits (failing tests, lint errors, debug statements).
  • commit-msg — runs after the message is written, with the message file as $1. Enforce conventional commits or ticket numbers.
  • pre-push — runs before the push is sent. Last chance to block pushing broken work.

Example pre-commit hook (block committing focused tests)

#!/usr/bin/env bash
# Save as .git/hooks/pre-commit and chmod +x

# Reject any staged Python file that still contains .only or pytest.skip("DO NOT MERGE")
if git diff --cached --name-only --diff-filter=ACM | grep -E '\.py$' | xargs -r grep -nE 'DO NOT MERGE|pdb\.set_trace\(\)'; then
    echo "✗ pre-commit: remove debug markers before committing."
    exit 1
fi

exit 0

Example commit-msg hook (require a ticket prefix)

#!/usr/bin/env bash
# Save as .git/hooks/commit-msg and chmod +x
# $1 is the path to the commit message file.

msg_file="$1"
if ! grep -qE '^(PROJ-[0-9]+|fix|feat|chore|docs|refactor): ' "$msg_file"; then
    echo "✗ commit message must start with PROJ-### or a conventional type (feat/fix/etc)."
    exit 1
fi

To share hooks across the team, commit a hooks/ directory and have each developer run:

git config core.hooksPath hooks

Commands We Run Weekly — Cheat Sheet

The list we keep in our terminal scratchpad

# Inspect
git status -sb                         # short status with branch info
git log --oneline --graph --all -20    # last 20 commits across all branches
git diff --staged                      # what will be committed
git blame -L 40,60 path/to/file        # who changed lines 40-60 and why

# Stage carefully
git add -p                             # interactive hunk staging

# Branching
git switch -c feature/x                # create + switch
git switch -                           # toggle back to previous branch

# Sync
git fetch --all --prune                # update remotes, drop deleted branches
git pull --rebase                      # rebase local commits on top of upstream

# Cleanup before PR
git rebase -i origin/main              # tidy commits
git push --force-with-lease            # safer force-push (won't clobber others' work)

# Undo
git restore --staged FILE              # unstage
git restore FILE                       # discard working-dir changes (destructive)
git reset --soft HEAD~1                # undo last commit, keep changes staged
git reflog                             # find lost commits

# Inspect a remote
git ls-remote origin

One habit worth forming: always prefer --force-with-lease over --force. The "lease" variant refuses to push if the remote has moved since your last fetch, which prevents the classic "I just overwrote my teammate's work" disaster.

Summary

We've covered:

  • ✓ The three trees: working dir, index, HEAD
  • ✓ Daily workflow with status / add / commit / log / diff
  • ✓ Branching with switch and branch
  • ✓ The merge vs rebase trade-off, and the golden rule
  • ✓ Interactive rebase to squash and reword
  • ✓ Stash for context switches
  • ✓ Reset modes and when each is safe
  • ✓ Reflog as the safety net for "lost" commits
  • ✓ Cherry-pick, tags, remotes, and upstream tracking
  • ✓ Hooks for automating quality checks

If you remember nothing else: commits are cheap, history is sacred on shared branches, and git reflog almost always has your back.