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 bisectstill works cleanly. Downside: lots of merge commits can cluttergit 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
switchandbranch - ✓ 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.