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.
06On this page
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.
| Area | What it is | You arrive with |
|---|---|---|
| Working directory | The 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 repository | The permanent history in .git, on your machine. | git commit |
| Remote repository | The shared copy on GitHub, GitLab, Bitbucket, or a server. | git push |
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.
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 filehashes the file, writes the blob into.git/objectsright now, and points the index entry at it. Staged content is already in the database before you commit. -
git committurns the index into trees, writes a commit pointing at the root tree, and moves the branch. It never looks at your working directory. -
git statusruns two comparisons:HEADagainst 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.
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.
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.
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,?]?
| Key | Does |
|---|---|
y / n | Stage this hunk / skip it. |
s | Split the hunk into smaller ones, when there is unchanged text between the edits. |
e | Open the hunk in your editor and choose individual lines. |
a / d | Stage / skip this hunk and every later one in the same file. |
q | Quit. 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.
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).
| Goal | Modern | Older equivalent |
|---|---|---|
| Discard unstaged edits to a file | git restore file | git checkout -- file |
| Unstage, keep the edits | git restore --staged file | git reset file |
| Throw away staged and unstaged edits | git restore --staged --worktree file | git checkout HEAD -- file |
| Get a file as it was two commits ago | git restore --source=HEAD~2 file | git checkout HEAD~2 -- file |
| Switch branch | git switch main | git 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.
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.
| Pattern | Matches |
|---|---|
*.log | Any .log file in any folder. |
build/ | A directory called build anywhere, not a file with that name. |
/build | Only build at the root of this .gitignore's folder. |
docs/**/*.pdf | PDFs at any depth under docs/. |
!keep.log | Re-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.
Finished reading?
0 of 18 chapters read in this series