GitGroundworkGit
3 / 18 written chapters

G3 · Part 1 · Foundations

The three areas (plus the remote)

Your files, the index and the repository. Most commands just copy content from one of these to another.

9 min read3 diagrams6 sections
06On this page
  1. What the index really is
  2. The states a file moves through
  3. Three different diffs
  4. Why staging exists at all
  5. Going backwards: restore versus checkout
  6. .gitignore
Fresher Mid Senior

Almost every basic command is just moving content between these places. Learn which direction each command copies, and you can predict what it will do to your files before you press enter.

AreaWhat it isYou arrive with
Working directoryThe actual files on your disk that you edit. Also called the working tree.Editing files
Staging area (index)A draft of your next commit. Lets you commit some changes and not others.git add
Local repositoryThe permanent history in .git, on your machine.git commit
Remote repositoryThe shared copy on GitHub, GitLab, Bitbucket, or a server.git push
git add git commit git push Working dir files you edit Index .git/index Local repo .git objects + refs Remote origin git restore git restore --staged git fetch
Forward along the top, back along the bottom. git fetch only updates your remote-tracking branches such as origin/main; nothing reaches your files until you merge, rebase or switch. git pull is fetch plus one of those.
In one minute

You edit files in your folder. git add copies the changes you want into a waiting area called the index. git commit saves exactly what is in the index as a new commit. git push sends your commits to the shared copy. Run git status any time to see which files are in which place.

What the index really is

The index is easy to picture wrongly as "a list of changes I have staged". It is actually a complete list of every tracked file, each with the blob ID of the version that will go into the next commit. Right after a commit, the index matches HEAD exactly, so there is nothing "staged", but the index is not empty. It is a full proposed snapshot.

$ git ls-files --stage
100644 ce013625030ba8dba906f756967f9e9ca394464a 0	README.md
100644 0a5b761ba428dcd2cf481221b5d9a0df20c3c715 0	src/cart.js

With that picture, the core commands become precise:

  • git add file hashes the file, writes the blob into .git/objects right now, and points the index entry at it. Staged content is already in the database before you commit.
  • git commit turns the index into trees, writes a commit pointing at the root tree, and moves the branch. It never looks at your working directory.
  • git status runs two comparisons: HEAD against the index ("Changes to be committed") and the index against your files ("Changes not staged").

The first point has a useful side effect. If you staged a file and then lost it with a bad reset --hard, the blob is still in the object store. git fsck --lost-found writes dangling blobs into .git/lost-found/other/, where you can read them back. Changes you never staged have no such safety net.

The index also caches file-system details (size, modification time, inode) for each entry. git status uses them to skip hashing files whose stats have not changed, which is why it stays fast in a large repository. That cache is also why git status sometimes rewrites .git/index even though you did not change anything: it is refreshing stats.

For seniors: two index flags people confuse

Each index entry can carry flags that change how Git treats the file. git update-index --assume-unchanged f is a performance promise: "I will not edit this, so do not check it". Git may drop the flag or overwrite your edits whenever it rewrites that entry, so it is the wrong tool for keeping a local config change out of commits. --skip-worktree says "keep my version of this file"; Git leaves it alone and refuses to overwrite it if upstream changes it, which forces you to deal with the clash. Sparse-checkout is built on the same skip-worktree bit. For a local-only config tweak, a better answer is still a template file in the repository and the real file in .gitignore. git ls-files -v shows the flags: a lower-case letter means assume-unchanged, S means skip-worktree.

The states a file moves through

From Git's point of view each file is in one of four states. git status is simply a report of which files are in which state.

Untracked Unmodified Modified Staged git add (start tracking) edit the file git add git rm --cached git commit git restore (discard)
"Tracked" means "has an entry in the index". A file can be in two states at once: stage part of an edit and keep editing, and it is both staged and modified. That is the MM you sometimes see in git status -s.
$ git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	modified:   README.md

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   src/cart.js

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

$ git status -sb
## main
M  README.md
 M src/cart.js
?? notes.txt

In the short format each line has two status columns. The left column is index versus HEAD (staged), the right is working directory versus index (unstaged). M  is staged only,  M is unstaged only, MM is both, A  is a newly added file, D is deleted, R is renamed, ?? is untracked and UU is a merge conflict. -b adds the branch line with ahead/behind counts.

Three different diffs

Because there are three places your content can be, there are three useful comparisons, and plain git diff is probably not the one you think.

Working dir your files Index what you staged HEAD the last commit git diff git diff --staged git diff HEAD
Plain git diff shows what you have not staged yet. Once you git add everything it prints nothing, which surprises people. --cached is an older spelling of --staged.
$ git diff --stat
 src/cart.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

$ git diff --staged --stat
 README.md | 1 +
 1 file changed, 1 insertion(+)

$ git diff HEAD --stat
 README.md   | 1 +
 src/cart.js | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

Read git diff --staged before every commit. It is exactly the change you are about to record, nothing more. Useful variations: --stat for a summary, --name-only for file names, --word-diff for prose where a whole paragraph is one line, and -w to ignore whitespace changes.

Why staging exists at all

New users find the staging area annoying: an extra step before committing. Its value shows up the day you fix a bug and, along the way, also rename a variable and delete some dead code. Staging lets you split that into three clean commits instead of one messy one. git add -p takes it further: it walks you through your changes hunk by hunk and asks what belongs in this commit.

$ git add -p
diff --git a/src/cart.js b/src/cart.js
index dde824d..31099d6 100644
--- a/src/cart.js
+++ b/src/cart.js
@@ -1,2 +1,2 @@
-export const total = 1;
+export const total = 2;
 export const currency = "INR";
(1/1) Stage this hunk [y,n,q,a,d,e,p,P,?]?
KeyDoes
y / nStage this hunk / skip it.
sSplit the hunk into smaller ones, when there is unchanged text between the edits.
eOpen the hunk in your editor and choose individual lines.
a / dStage / skip this hunk and every later one in the same file.
qQuit. Everything you already said yes to stays staged.
?Explain every key. The exact list changes with the hunk and your Git version.

The same patch mode exists elsewhere: git restore -p discards hunks, git restore --staged -p takes them out of the index, git stash -p stashes some, and git commit -p stages and commits in one go. Brand-new files do not show up in add -p because they have no index entry to compare with. git add -N newfile.js ("intent to add") creates an empty entry, after which the file appears in git diff and git add -p like any other.

Habit worth building

Run git add -p instead of git add . for a week. It forces you to read your own diff before committing, and it catches an astonishing number of stray console.log lines and commented-out experiments.

Going backwards: restore versus checkout

For years git checkout did two unrelated jobs: switching branches and overwriting files. The same command could move you to a branch or silently throw away your edits, depending on whether its argument happened to be a branch name or a path. Git 2.23 split it into git switch for branches and git restore for files. restore asks two questions: where from (--source, default the index, or HEAD when you use --staged) and where to (--worktree, --staged, or both).

GoalModernOlder equivalent
Discard unstaged edits to a filegit restore filegit checkout -- file
Unstage, keep the editsgit restore --staged filegit reset file
Throw away staged and unstaged editsgit restore --staged --worktree filegit checkout HEAD -- file
Get a file as it was two commits agogit restore --source=HEAD~2 filegit checkout HEAD~2 -- file
Switch branchgit switch maingit checkout main

Two differences are worth knowing. restore --source writes only to your working directory unless you also pass --staged, while checkout HEAD~2 -- file updates both the index and the working directory. And if you do use checkout with paths, always include the --. Without it, git checkout main in a repository that has a file named main is ambiguous.

The one command that really loses work

Discarding working-directory changes with git restore file (or checkout -- file, or reset --hard) overwrites content Git never stored. There is no reflog for it and no undo. If there is any doubt, git stash first. A stash is a real commit, so it is recoverable.

.gitignore

A list of patterns Git should not track: node_modules/, build output, .env, IDE folders, log files. One catch that trips everyone up: .gitignore only affects untracked files. If a file is already tracked, adding it to .gitignore changes nothing. You have to untrack it first:

a file you should never have committed

git rm --cached .env
echo ".env" >> .gitignore
git commit -m "chore: stop tracking local env file"

--cached removes the file from the index but leaves it on your disk. This stops tracking it from now on; the file is still in every old commit. If it held real secrets, rotate them, then read Danger zone about rewriting history.

PatternMatches
*.logAny .log file in any folder.
build/A directory called build anywhere, not a file with that name.
/buildOnly build at the root of this .gitignore's folder.
docs/**/*.pdfPDFs at any depth under docs/.
!keep.logRe-include something an earlier pattern excluded.

Patterns come from several places. From highest precedence to lowest: .gitignore files in the repository (a deeper one overrides a shallower one), then .git/info/exclude for patterns private to your clone, then your global ignore file from core.excludesFile. One rule catches everyone: you cannot re-include a file with ! if its parent directory is excluded, because Git never looks inside an excluded directory. Write logs/* and !logs/keep.log, not logs/. When a file is ignored and you cannot see why, ask:

$ git check-ignore -v dist/app.js
.gitignore:4:dist/	dist/app.js

That names the file, the line number and the pattern responsible. Add --no-index to check a file that is already tracked.