Skip to content

The working tree

The files you edit are the working tree (or working directory). Git compares them with the last commit through a third copy in between, the index. Most of what git status, git add and git checkout do is keep these three in step.

Three boxes: HEAD's tree, the index and the working directory. Between HEAD and the index is the staged diff; between the index and the working directory is the unstaged diff. HEAD's treelast commitindex.git/indexworking directoryfiles on diskSTAGEDUNSTAGEDgit addgit commit
Status compares HEAD with the index (staged changes) and the index with the working directory (unstaged changes). git add copies working files into the index; git commit turns the index into a commit.

The index, also called the staging area, is the binary file .git/index. It lists every tracked path, sorted, with its mode, the ID of the blob staged for it, and stat data from the file system: modification time, size, inode and a few other fields. git ls-files --stage prints the paths, modes and IDs. git add hashes a file, writes the blob and updates its index entry; git commit writes trees from the index and a commit pointing at them.

The stat data is what makes Git fast. If a file’s stat data still matches its index entry, Git assumes the file is unchanged and doesn’t read it. Only files whose stat data changed get rehashed.

During a merge conflict a path has up to three entries instead of one, in stages 1 (the merge base’s version), 2 (ours) and 3 (theirs). Resolving the conflict replaces them with a single stage-0 entry.

The index format has optional extensions:

  • Cache-tree stores the tree ID for each directory whose entries haven’t changed since it was last computed, so committing only rehashes the directories you touched.
  • Split index keeps a small file of recent changes over a large shared base, so updating a huge index doesn’t rewrite all of it.
  • Resolve-undo remembers the conflict stages of resolved paths so git checkout -m can recreate the conflict.
  • Sparse directory entries are described next.

girtSupportedSparse directory entries are expanded explicitly; configured status applies intent-to-add, assume-unchanged and skip-worktree flags (#635).API docs

Sparse checkout writes only some directories to disk (git sparse-checkout set src/app). The rest are still in every commit but marked skip-worktree in the index. In cone mode, which works on whole directories, the index can go further and replace everything under a skipped directory with one sparse directory entry pointing at that directory’s tree. In a monorepo with a million files where you work on a thousand, the index stays small.

Some operations need every path, such as a merge that touches skipped directories. They expand the sparse entries back into full entries by reading the trees first.

girtPartialSparse directory entries can be read and expanded; no sparse-checkout patterns, configured status honors skip-worktree; checkout still refuses it.issue #500

git status makes two comparisons and one scan:

  1. HEAD’s tree against the index gives the staged changes, the ones the next commit would make.
  2. The index against the working files gives the unstaged changes. Stat data lets Git skip files that haven’t been touched.
  3. A scan of the working tree finds untracked files, those not in the index, filtered by ignore rules.

Stat data has a blind spot called racy Git. If a file is modified within the same timestamp tick in which the index was written, its stat data can match while its content differs. Git treats any entry whose modification time is not older than the index file itself as suspect and rehashes it.

A full status also has to apply the same filters checkout used, such as line-ending conversion, before comparing contents, or every converted file looks modified. Git only does that when it reads a file: a file whose size no longer matches the size recorded in the index is reported as modified without conversion, so changing only a file’s line endings shows up in git status even though git diff is empty.

girtPartialmacOS/Linux only. Filters apply through Repository::status_with_filters (#496); applies index flags, core.fileMode, core.symlinks and filesystem name policy (#635); no attr: pathspecs or monitor caches; still refuses sparse directories.issue #534

Untracked files are filtered by ignore rules from per-directory .gitignore files, the repository’s .git/info/exclude and the file named by core.excludesFile (by default ~/.config/git/ignore).

  • *.log matches in any directory; /build matches only at the top of the .gitignore’s directory.
  • ** matches any number of directories: docs/**/*.png.
  • A trailing / matches directories only: target/.
  • A leading ! re-includes something an earlier rule excluded: !keep.log.

Later rules override earlier ones, and a deeper .gitignore overrides a shallower one. One rule surprises people: a file inside an excluded directory can’t be re-included, because Git doesn’t look inside excluded directories at all. Ignore rules only affect untracked files; a tracked file stays tracked whatever .gitignore says. git check-ignore -v path shows which rule matched.

girtPartialPatterns and precedence match Git; named-user home paths in core.excludesFile are refused.issue #555

Checking out a tree makes the working tree and index match it: write files that are new or changed, delete files that were removed, and record fresh stat data in the index. git switch, git checkout and the last step of git clone all do this.

The hard part is safety. Checkout must refuse to overwrite local changes it would lose, so it compares each affected path’s working file with the index before touching it. It also has to order its work so a failure halfway, such as a full disk or a file another program holds open, leaves a state that can be recovered rather than a mix of two commits. Directory and file swaps (a was a file, now it is a directory), symbolic links and executable bits add more cases.

girtPartialmacOS and Linux only; filters through Repository::checkout_tree_with_filters (#496); refuses skip-worktree and intent-to-add entries and case-only renames on case-insensitive volumes (#620); configured checkout writes symlink placeholders (#578).issue #501