The Three States: Working Directory, Staging, Repository

Every file in a Git-tracked project exists in one of three places at any given moment: your working directory, Git’s staging area (also called the index), or the repository itself, stored inside the .git directory. Understanding these three states, and exactly which commands move a file between them, is the single most important mental model in Git. Almost every confusing Git moment — “why isn’t my new file being committed,” “why did git commit -a skip my file,” “why does git diff show nothing when I know I changed something” — traces back to not knowing which of the three states a change currently sits in.

Overview: How the Three States Work

Git is not just a way to save file history; it is a small content-addressable database sitting inside the .git folder at the root of your project, plus a working copy of files on disk that you actually edit. Between those two things sits a staging area that lets you build up a commit piece by piece before you finalize it. The three states are:

State Where it lives What it represents How to inspect it
Working directory The actual files on your disk Whatever you currently see in your editor — may differ from both the staged snapshot and the last commit git status, git diff
Staging area (index) .git/index (a single binary file) A snapshot of exactly what will go into the next commit git status, git diff --staged
Repository .git/objects and .git/refs The permanent, immutable history of every commit ever made git log, git show

The Object Model in Brief

The repository stores three kinds of objects, each identified by the SHA-1 hash of its own content. A blob is the compressed content of a single file — just bytes, with no filename attached. A tree is a snapshot of a directory: a list of entries mapping names to either blob hashes (files) or other tree hashes (subdirectories). A commit is a small object that points to one root tree (the complete snapshot of the project at that moment), points to its parent commit(s), and records the author, committer, timestamp, and message. Because objects are named by the hash of their content, two files with identical content anywhere in your history share the exact same blob — Git never stores the same content twice.

A branch, such as main, is nothing more than a small file in .git/refs/heads/ containing a single commit hash. HEAD is a pointer that normally points at a branch (a “symbolic ref”), which in turn points at a commit. When you commit, the branch pointer moves forward to the new commit; the commit objects themselves are never edited, only added.

The Index Is Not a Copy of Your Files

The staging area is easy to misunderstand. It is not a folder full of copies of your files — it’s a compact list of index entries (file path, mode, and blob hash) that describes what the next commit’s tree will look like. When you run git add, Git immediately compresses and stores the file’s current content as a blob object in the repository’s object database and records that blob’s hash in the index. This happens before you ever commit. That’s why staged content is a frozen snapshot: if you edit the file again after staging it, the working directory now holds a third version that differs from both the staged snapshot and the last commit.

Syntax

These are the core commands that move content between the three states:

git status
git add "<file>"
git add .
git commit -m "<message>"
git diff
git diff --staged
git restore "<file>"
git restore --staged "<file>"
Command Direction of movement Description
git status none (read-only) Shows which files differ between working directory, staging area, and last commit
git add <file> Working directory → Staging area Stores the file’s current content as a blob and records it in the index
git commit -m <message> Staging area → Repository Builds tree objects from the index and writes a new commit object; moves the branch pointer
git diff compares Working directory vs Staging area Shows unstaged edits — changes not yet included in the next commit
git diff --staged compares Staging area vs Repository (last commit) Shows exactly what will be committed if you run git commit now
git restore <file> Staging area/Repository → Working directory Discards working directory edits, restoring the file to its staged (or last committed) version
git restore --staged <file> Repository → Staging area Unstages a file without touching the working directory
git checkout <file> Staging area/Repository → Working directory The older equivalent of git restore <file>; still very common but overloaded (also switches branches)

git restore was introduced to split the old, overloaded git checkout command into two clearer commands: git switch for changing branches and git restore for discarding or unstaging changes. Both forms work in modern Git, but git restore is less ambiguous and is the version taught throughout this course.

Examples

Example 1: A file’s full journey through all three states

Start with a brand-new file. It exists only in the working directory; Git knows nothing about its content yet.

echo "# My Project" > README.md
git status

Output:

On branch main
No commits yet

Untracked files:
  (use "git add <file>..." to include in what will be committed)
	README.md

nothing added to commit but untracked files present (use "git add" to track)

Git labels README.md as untracked: it exists in the working directory but has no corresponding entry in the index or in any commit. Now stage it:

git add README.md
git status

Output:

On branch main
No commits yet

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
	new file:   README.md

git add wrote the file’s content as a blob object into .git/objects and added an entry for it in the index. The file has moved from the working directory into the staging area. Finally, commit it:

git commit -m "docs: add project README"
git status

Output:

[main (root-commit) a1b2c3d] docs: add project README
 1 file changed, 1 insertion(+)
 create mode 100644 README.md
On branch main
nothing to commit, working tree clean

git commit built a tree object from the current index, wrapped it in a commit object, and moved the main branch pointer to that new commit. All three states now agree, which is exactly what “working tree clean” means.

Example 2: Why git diff and git diff --staged show different things

Staging is a snapshot taken at the moment you run git add — further edits are not staged automatically.

echo "console.log('hello');" >> app.js
git add app.js
echo "console.log('goodbye');" >> app.js
git diff
git diff --staged

Output:

diff --git a/app.js b/app.js
index e69de29..3f2c1a7 100644
--- a/app.js
+++ b/app.js
@@ -1 +1,2 @@
 console.log('hello');
+console.log('goodbye');

diff --git a/app.js b/app.js
index 8d1f0aa..e69de29 100644
--- a/app.js
+++ b/app.js
@@ -0,0 +1 @@
+console.log('hello');

After the first echo and git add, the index holds a blob containing only the 'hello' line. The second echo changes the working directory again without touching the index. git diff (no flag) compares the working directory against the index, so it shows only the newly added 'goodbye' line. git diff --staged compares the index against the last commit, so it shows only the 'hello' line that was actually staged. If you ran git commit right now, only the staged 'hello' line would be saved — the 'goodbye' line would still be sitting, unstaged, in the working directory.

Example 3: Partial staging and unstaging with git restore --staged

Real commits are rarely “stage everything.” Suppose app.js has an unrelated in-progress change, while styles.css and a new notes.txt are ready to go:

git add styles.css notes.txt
git status

Output:

On branch main
Changes to be committed:
	new file:   notes.txt
	modified:   styles.css

Changes not staged for commit:
	modified:   app.js

On reflection, notes.txt was just a scratch file that shouldn’t be committed yet. Unstage it without losing it:

git restore --staged notes.txt
git status

Output:

On branch main
Changes to be committed:
	modified:   styles.css

Changes not staged for commit:
	modified:   app.js

Untracked files:
	notes.txt

git restore --staged only removes the index entry — it never touches the working directory, so notes.txt still exists on disk with its content intact, just back to being untracked. Now commit only what’s ready:

git commit -m "style: update button color"

Output:

[main 7c9e4d2] style: update button color
 1 file changed, 1 insertion(+), 1 deletion(-)

Only styles.css was included, because it was the only file whose blob was present in the index at commit time. This is exactly how the staging area lets you build focused, logical commits out of a messy working directory.

How It Works Step by Step

It helps to see that the staging area isn’t a metaphor — the blob it creates is a real, inspectable object, independent of any commit:

git add README.md
git ls-files -s

Output:

100644 3b18e512dba79e4c8300dd08aeb37f8e728b8dad 0	README.md

git ls-files -s prints the raw index entries: file mode, the SHA-1 hash of the blob currently staged for that path, a stage number (0 means “no conflict”), and the filename. You can read that blob directly, before any commit exists that references it:

git cat-file -p 3b18e512dba79e4c8300dd08aeb37f8e728b8dad

Output:

# My Project

Step by step, here is what each command actually does under the hood:

  • git add <file> reads the file from the working directory, compresses it, computes its SHA-1 hash, writes it as a blob object into .git/objects (if an identical blob doesn’t already exist), and updates the corresponding entry in .git/index to point at that hash. The working directory and the last commit are both untouched.
  • git commit reads the current index, recursively builds tree objects that mirror the staged directory structure (writing new tree objects for any directory whose contents changed, reusing existing ones otherwise), creates a commit object pointing at the root tree and at the current commit as its parent, writes that commit object into .git/objects, and finally updates the ref that HEAD points to (typically refs/heads/main) so it points at the new commit. Nothing in the working directory changes.
  • git restore <file> reads the blob for that file out of the index (or, with --source, out of a given commit) and overwrites the working directory copy with it, discarding uncommitted edits.
  • git restore --staged <file> replaces the index entry for that file with the version from the last commit (or removes it entirely if the file was new), leaving the working directory exactly as it was.

Common Mistakes

Mistake 1: Forgetting to stage before committing

echo "fix typo" >> app.js
git commit -m "fix: correct typo in app.js"

Because the new edit to app.js was never passed to git add, the index still holds the old version of the file. If nothing at all is staged, Git refuses outright with nothing to commit, working tree clean (if truly clean) or no changes added to commit (use "git add" ...). If something else happened to be staged, that unrelated content gets committed instead of the intended fix. The fix is to stage the file first:

echo "fix typo" >> app.js
git add app.js
git commit -m "fix: correct typo in app.js"

Mistake 2: Assuming git commit -a stages new files

touch config.local.json
git commit -am "chore: add local config"

The -a flag is a shortcut that stages modifications and deletions of files Git already tracks — it does not stage brand-new, untracked files. config.local.json is silently left out of the commit and remains untracked afterward, which is a common source of “but I definitely committed that file!” confusion. New files always need an explicit git add:

touch config.local.json
git add config.local.json
git commit -m "chore: add local config"

Mistake 3: Editing again after staging and forgetting to re-add

git add app.js
# ...more edits made to app.js here, but never re-staged...
git commit -m "feat: add validation logic"

As shown in Example 2, staging captures a snapshot at the moment git add runs. Any edits made afterward sit only in the working directory and are excluded from the commit, even though git status would have warned you they were “not staged for commit.” Always re-stage after further edits, or check git diff --staged immediately before committing to confirm exactly what will be included:

git add app.js
# ...more edits made to app.js here...
git add app.js
git commit -m "feat: add validation logic"

Best Practices

  • Run git status constantly — it’s free, read-only, and tells you exactly which state every changed file is in.
  • Run git diff --staged right before git commit to confirm precisely what will be recorded, especially after staging in multiple steps.
  • Avoid reflexively running git add . on a messy working directory; review changes first so you don’t accidentally stage debug code, generated files, or secrets.
  • Use git add -p (patch mode) to stage part of a file’s changes hunk by hunk when a single file mixes unrelated edits.
  • Write commit messages that describe why, following a consistent style such as Conventional Commits (feat:, fix:, docs:, chore:, refactor:).
  • Keep a .gitignore up to date so build output, dependencies, and local config never become untracked clutter you have to remember not to stage.
  • Prefer many small, logically-scoped commits over one giant commit — the staging area exists specifically to make this easy.
  • Use git restore rather than manually re-editing a file back to its previous state when you want to discard a mistake.

Practice Exercises

  • Initialize a new repository, create two files, stage and commit only one of them, then use git status and git diff --staged at each step to confirm what state each file is in before and after the commit.
  • Create a tracked file, commit it, then modify it twice in a row with a git add in between. Predict what git diff and git diff --staged will show before running them, then check your prediction.
  • Stage three files at once, then use git restore --staged to unstage just one of them without discarding its content. Verify with git status that the other two remain staged and the unstaged file’s edits are still present on disk.

Summary

  • Every tracked file lives in one of three states: the working directory (what you edit), the staging area / index (what will be in the next commit), or the repository (permanent, committed history).
  • The repository stores content-addressed objects: blobs (file content), trees (directory snapshots), and commits (a tree plus parent pointer and metadata); a branch is just a movable pointer to a commit.
  • git add moves content from the working directory into the staging area by writing a blob and updating the index; it does not touch the repository.
  • git commit moves the staged snapshot into the repository by writing tree and commit objects and advancing the branch pointer; it does not touch the working directory.
  • git diff compares working directory vs. staging area; git diff --staged compares staging area vs. the last commit.
  • git restore and git restore --staged move content back out of the staging area or repository into the working directory or index respectively, without rewriting history.