All Guides
gitgitversion-controlworkflowfundamentals

Using Git

Most developers memorize Git commands without understanding what's happening underneath. This guide breaks down commits, branches, HEAD, reset, rebase, and recovery so you can think in graphs instead of praying commands work.

Ryan VerWey
2026-03-15
15 min read

Git is a Database of Snapshots

Git's fundamental unit is the commit: a complete snapshot of every file in your project at one moment in time. Not a diff. Not a changeset. The full state.

Every commit stores three things:

  • A snapshot of the entire project
  • Metadata: author, timestamp, message
  • A pointer to its parent commit

This creates a chain stretching back to the first commit, which is the only one with no parent.

The DAG: What Your History Actually Looks Like

A straight line works fine until you branch. In practice, commits diverge (parallel branches), then converge (merges). The result is a DAG: Directed Acyclic Graph.

  • Directed: relationships go one way only, child to parent
  • Acyclic: no loops, a commit can't be its own ancestor
  • Graph: nodes (commits) connected by edges (parent pointers)

Because every commit is a full snapshot, you can jump to any node and see the project exactly as it was at that moment. No reconstruction required.

Branches Are Pointers, Not Copies

A branch is a text file containing one thing: a commit hash. Nothing more.

# .git/refs/heads/feature/login
a1b23c4f8e9d12b456789...

Commits have no awareness of branches. Branches point at commits. When you make a new commit on a branch, Git writes the commit, then moves the pointer forward. That's all branching is. Creating a branch is instant because nothing is copied.

main is not special. It's just the pointer the community agreed to call primary.

HEAD: Where You Are

HEAD is Git's cursor. It almost always points at a branch, which then points at a commit.

HEAD -> main -> C3

Detached HEAD

Check out a commit directly and HEAD points at it with no branch involved:

git checkout a1b23c4   # detached HEAD

You can still commit, but nothing tracks those commits. Switch away and they become orphaned, eventually garbage collected.

If you commit in detached HEAD, create a branch before leaving or the work is orphaned:

git checkout -b fix/thing-i-just-found

The Three Areas

Your code lives in three places simultaneously. Understanding this is the key to understanding every "undo" command.

AreaWhat It Is
Working DirectoryFiles on your disk, as seen in your editor
Staging Area (Index)Changes queued for the next commit
RepositoryThe permanent chain of commits

The flow: edit a file, git add it to staging, git commit it into the repository. Each step is deliberate.

checkout vs. reset vs. revert

Three commands that look similar but operate on completely different things.

checkout: Moves HEAD Only

git checkout main       # HEAD points to main
git checkout a1b23c4    # HEAD points at commit (detached)

No commits change. No branches move. You're navigating the graph, not modifying it. Always safe.

reset: Moves the Branch

git reset a1b23c4   # current branch pointer moves here

Commits ahead of the target become orphaned. Reset has three modes that determine how far the change reaches:

ModeBranchStagingWorking Directory
--softMovedUnchangedUnchanged
--mixed (default)MovedClearedUnchanged
--hardMovedClearedReset to match commit
# Squash commits: undone changes land back in staging
git reset --soft HEAD~3 && git commit -m "clean commit"

# Unstage a commit: changes stay in files, just unstaged
git reset HEAD~1

# Abandon everything: no recovery for uncommitted work
git reset --hard HEAD~1  # ⚠️

--hard is permanent for uncommitted changes. Orphaned commits can be recovered via reflog. Unsaved work cannot.

revert: Adds a Corrective Commit

git revert a1b23c4

Creates a new commit that inverts the target. History stays intact. The original commit still exists. This is the only safe option when the commit has already been pushed and pulled by others.

Rule: never rewrite shared history. Add to it instead.

Quick Reference

CommandWhat changesSafe?Use for
checkoutHEAD onlyAlwaysNavigating history
reset --softBranch pointerLocal onlySquashing commits
reset --mixedBranch + stagingLocal onlyReorganizing staged changes
reset --hardBranch + staging + filesWith cautionDiscarding all local work
revertNothing (new commit added)AlwaysUndoing pushed changes

Rebase: Why It Rewrites History

You're on a feature branch with commits B and C. main has moved forward to Y. You want to integrate.

Merge creates a commit with two parents. Accurate history, messy graph.

Rebase replays your commits on top of the updated main. But Git can't move commits because a commit's identity is its hash, derived from content, metadata, and parent pointer. Change the parent and you get a new commit.

So rebase creates new commits B' and C' (same changes, new parents) and moves your branch to point at C'. The originals are orphaned.

# Before
main:    A - X - Y
feature: A - B - C

# After: git rebase main
main:    A - X - Y
feature:         Y - B' - C'

Never rebase commits others have already pulled. Their Git has B and C. Yours has B' and C'. Git treats them as unrelated work. Merging produces duplicates and conflicts.

On local, unshared branches: rebase is great for keeping history clean and linear.

The Reflog: Your Recovery Net

Something went wrong. Run this first:

git reflog

The reflog logs every position HEAD has occupied: every checkout, commit, reset, and rebase. Those "lost" commits are almost certainly still here.

a1b23c4 HEAD@{0}: reset: moving to HEAD~3
f4e5d6c HEAD@{1}: commit: add user authentication
2b3c4d5 HEAD@{2}: commit: fix login form validation

Find the hash, rescue it with a branch:

git checkout -b recovery/rescued-work f4e5d6c

Entries expire after 90 days (reachable) or 30 days (unreachable). Don't wait.

The Mental Model

ConceptWhat it is
CommitFull project snapshot with a parent pointer
BranchA pointer to one commit
HEADA pointer to your current branch (or commit)
Staging areaA buffer between edits and commits
checkoutMoves HEAD, nothing else
resetMoves a branch pointer
revertAdds an inverting commit
rebaseRecreates commits with new parents
reflogLocal log of everywhere HEAD has been

Learn the data structure and the commands follow naturally.

A commit is a snapshot: a complete photograph of your entire project at one moment in time. Not a diff. Not a list of changes. The full state of every single file.

Each commit stores three things:

  1. A snapshot pointer — the complete state of every file as it existed at that moment
  2. Metadata — author, timestamp, and commit message
  3. A parent pointer — a reference to the commit that came directly before it

Every new commit records the full project state and chains backward to its predecessor. This creates a linked history stretching all the way back to the very first commit, which is the only one with no parent.

Key insight: Commits point backward, always. A child knows its parent, but a parent has no knowledge of what commits come after it.

When you commit on a branch, Git creates the new commit (which points back to the previous one), then moves the branch pointer forward to the new commit. That's it. That's all branching is. This is why creating a branch takes zero time and zero disk space. You're placing a label, not duplicating a codebase.

main isn't special either. It's just another label that the community has agreed to treat as the canonical line of work.

HEAD: Your Current Location

If branches are labels on commits, HEAD is the label on you. It tracks where you currently are in the repository, and it almost always points at a branch rather than a commit directly.

HEAD -> main -> commit C3

Switch branches and HEAD follows:

git checkout feature
# HEAD -> feature -> commit B2

Detached HEAD State

Things get interesting when you check out a commit directly instead of a branch:

git checkout a1b23c4

Now HEAD points straight at a commit with no branch involved. Git calls this detached HEAD state. The warning looks alarming. The concept isn't.

You can still browse, edit, and commit in this state. The problem is that no branch is tracking your new commits. The moment you switch elsewhere, those commits become orphaned. Nothing in Git points to them, so they're invisible and eventually get cleaned up by garbage collection.

This plays out badly in a scenario that has burned a lot of developers:

A developer checks out an old commit to investigate a bug. They find it, fix it, and commit. They run git checkout main to bring the fix in. The fix is gone. It lived in detached HEAD, never attached to any branch, and is now orphaned.

Git warns you in detached HEAD because it's trying to prevent exactly this. If you do work in that state and want to keep it, create a branch before you leave:

git checkout -b fix/thing-i-just-found

The Three Areas Where Code Lives

Git is not just a commit database. Your code exists in three distinct areas at any given time, and understanding each one is what makes the "undo" commands finally make sense.

AreaAlso Known AsWhat It Contains
Working Directory(none)The actual files on your disk, as you see them in your editor
Staging AreaIndexChanges you've prepared and earmarked for the next commit
RepositoryHistoryThe permanent chain of commits

The progression looks like this:

  1. Edit a file. It changes in your working directory. Git sees the difference but takes no action.
  2. git add copies those changes into the staging area. You're saying: include this in my next commit.
  3. git commit takes everything staged and permanently writes it into the repository as a new commit.

Why does this three-step model matter? Because reset, checkout, and revert each operate on these layers in different ways. Get that model wrong and you'll misread what those commands are actually doing.

checkout vs. reset vs. revert

These three commands are the source of most Git confusion. They look like they do similar things. They don't.

checkout: Moving Your View

git checkout moves HEAD. Nothing else.

git checkout main       # HEAD points to main
git checkout a1b23c4    # HEAD points directly to that commit (detached)

Your working directory updates to reflect the target commit's snapshot. But no commits are changed, no branches are moved, and history is completely untouched. You're navigating the graph without modifying it. This command is always safe.

reset: Moving the Branch

git reset moves the branch pointer itself, which is a fundamentally different operation.

git reset a1b23c4   # the current branch now points here

Any commits that were ahead of this point still exist in the database, but they're now unreferenced. No branch points to them. They're orphans waiting for garbage collection.

Reset has three modes, each reaching further into the three areas:

--soft moves the branch pointer. Staging and working directory are untouched. Any changes from the undone commits are sitting staged and ready to recommit.

# Collapse three messy commits into one clean one
git reset --soft HEAD~3
git commit -m "feat: add payment flow"

--mixed (the default) moves the branch pointer and clears staging. Your changes are still in your files, just no longer staged.

# Undo a commit so you can restructure what gets staged
git reset HEAD~1
# files still have the changes, nothing is staged yet

--hard moves the branch pointer, clears staging, and rewrites your working directory to match. Your files change. Anything not committed is deleted from disk.

git reset --hard HEAD~1  # ⚠️ uncommitted work is permanently gone

--hard is where careers go sideways for an afternoon. Orphaned commits can sometimes be recovered via the reflog, but changes you never committed are gone for good.

revert: Correcting Without Erasing

git revert operates on a completely different principle. It doesn't move anything or remove anything. Instead, it creates a brand new commit that applies the inverse of a previous commit.

git revert a1b23c4
# adds a new commit that undoes whatever a1b23c4 changed

The original commit stays in history. The record is clear: something was added, and later it was deliberately undone. This is the right tool when you need to undo work that other people have already pulled.

Never rewrite shared history. Add to it instead.

Quick Reference

CommandWhat changesSafe to useBest for
checkoutHEAD position onlyAlwaysNavigating history, switching branches
reset --softBranch pointerOn local branchesSquashing commits before pushing
reset --mixedBranch pointer + stagingOn local branchesReorganizing what gets committed
reset --hardBranch pointer + staging + filesWith cautionDiscarding all local work entirely
revertNothing (adds a commit)AlwaysUndoing already-pushed changes

Rebase: What "Rewriting History" Actually Means

Rebase is the command with the most mythology around it. Here's what it literally does.

You're on a feature branch. You've made commits B and C. While you were working, main moved forward with commits X and Y. You want your feature branch to include that new work.

Merge would create a new commit with two parents, accurately recording that two lines of work converged.

Rebase does something different. It replays your commits on top of the updated main. But to understand why this is significant, you need to understand one thing about commit identity.

A commit's identity is its hash. That hash is computed from the content, the metadata, and the parent pointer. Change any of those inputs and you get a different hash and therefore a different commit.

Git cannot move commits. So rebase does the next best thing:

  1. Inspects commit B and calculates what it changed relative to its parent.
  2. Creates a new commit B' applying those same changes, but with Y as its parent.
  3. Does the same for commit C, producing C' on top of B'.
  4. Moves the feature branch to point at C'.
  5. The original B and C are now unreferenced and will eventually be collected.
# Before
main:    A - X - Y
feature:     A - B - C

# After: git rebase main (from the feature branch)
main:    A - X - Y
feature:         Y - B' - C'

The content is the same. The commits are not. This is what "rewriting history" means in practice.

This is also why rebasing commits that others have already pulled is a serious problem. Their Git has the originals B and C. Your Git has the replacements B' and C'. Git treats them as unrelated commits with different identities. Merging produces duplicate changes, unexpected conflicts, and a history that looks like a logic puzzle.

Rebase is a legitimate tool on branches you haven't shared. It produces clean, linear history that's easier to read and bisect. The trade-off is straightforward: a cleaner story in exchange for an accurate one.

The Reflog: Git's Black Box Recorder

Something went wrong. A hard reset removed commits. A rebase produced the wrong result. The branch pointer is somewhere it shouldn't be.

Before you panic, run:

git reflog

The reflog is a local log of every position HEAD has occupied, ordered by most recent. Every checkout, commit, reset, rebase, and merge gets an entry.

a1b23c4 HEAD@{0}: reset: moving to HEAD~3
f4e5d6c HEAD@{1}: commit: add user authentication
2b3c4d5 HEAD@{2}: commit: fix login form validation
9e8f7a6 HEAD@{3}: commit: initial login page

Those commits you thought were gone? They're almost certainly in this list. Find the hash you want, create a branch pointing to it, and you've got your work back:

git checkout -b recovery/rescued-work f4e5d6c

Git rarely deletes anything the moment it becomes unreferenced. It orphans it. The reflog is the index to those orphans.

One real limit: reflog entries expire. Reachable commits stick around for 90 days; unreachable ones for 30. If something went wrong in the last hour, you're almost certainly safe. If it went wrong two months ago, less so.

Putting It All Together

Git makes a lot more sense when you stop reading the docs and start thinking about the data structure:

  • Commits are complete snapshots chained backward through time, forming a graph
  • Branches and HEAD are lightweight pointers, labels that tell Git what's current and what matters
  • checkout repositions your view in the graph without changing anything
  • reset moves a branch pointer, with optional reach into staging and the working directory
  • revert adds new commits that invert previous ones, leaving history intact
  • rebase reconstructs commits with new parents, producing a cleaner graph at the cost of new commit identities
  • reflog is the recovery log that makes most "I lost my work" situations fixable

Learn the shape of the data and the commands stop feeling like guesswork. You'll know what each one changes, what it leaves alone, and how to get back to where you were if something goes sideways.

Ryan VerWey

Written by

Ryan VerWey

Ryan VerWey is a full-stack developer building tools and writing practical guides for working developers.