Expand description
Reading git’s index, and making git look at a file again after it was rewritten in place.
Rewriting a working-tree file in place is not enough to leave git status
clean, and the reason is a shortcut inside git. The index caches the stat
of every file next to the object id of its cleaned content. When the
cached size differs from the size on disk, git concludes the content changed
and stops there — it never runs the clean filter to check. For an unfiltered
file that shortcut is sound: a different size is a different file. For a
filtered one it is not, and unlock hits it head on, because a clone checked
out without a key recorded the size of the ciphertext, and the file is now
its plaintext, 38 bytes shorter. lock hits the same wall going the other
way.
The object ids the index stores are read here too, by staged_ids. That is
what lets lock answer “is this content already a blob in this repository”
without opening the object database: the index records the id of every
tracked path’s cleaned content, and encryption is deterministic, so hashing
what the clean path would produce and comparing is exact.
Paths are matched as raw bytes, and callers supply them from read_dir.
On a case-insensitive filesystem (core.ignorecase, the default on macOS and
Windows) and under core.precomposeunicode, the same file has two spellings:
git keeps the one it was added under, the directory keeps the one on disk.
Measured on git 2.55/APFS — a file added as secret.env and renamed to
SECRET.env reads as untracked here, and an NFD name on disk does not match
the NFC name in the index. Both callers notice when a name they know is
tracked does not match. This gap survived open decision 13, settled on
2026-08-05: pattern matching now folds ASCII case, so a declaration reaches
every spelling of a name — but the question here is a different one, whether
an index entry and a directory entry are the same file, and neither the index
nor read_dir folds anything. What changed is which refusal fires first: the
walk below now recognises Secrets/db.env as declared, so lock stops on
“declared and not tracked under this name” rather than on “the index names a
path this walk never saw”. Same state, same exit code, and the message now
names the spelling on disk, which is the one the user has to act on.
Correction, 2026-08-05: the consequences were not “on the safe side for
lock, which then refuses rather than proceeds”, as this comment claimed
until now. Measured on git 2.55 and APFS: after mv secrets Secrets the
index still said secrets/db.env, git status was clean, the working-tree
walk selected nothing, and lock --yes printed “no file here is declared for
encryption”, exited 0 and deleted the key over a readable plaintext secret —
the interactive path did the same after a typed yes. lock now proves the
opposite by content rather than assuming it: see
commands::lock::refuse_if_a_declared_file_is_still_open, which reads this
module’s listing and refuses while any declared tracked path still holds
plain text on disk.
Measured on git 2.55, in a clone unlocked with the right key:
git hash-object --path secrets/db.env -- secrets/db.env → b51d5ac… (matches the index)
git update-index --refresh → "needs update"
git status --porcelain → " M secrets/db.env"The content is right, the blob is right, and git still reports a change —
permanently, since the refresh never succeeds and so never rewrites the entry.
Zeroing the cached size flips it: git’s own comment in read-cache.c says
that a zero length means “we have never even read the lstat information
once”, so it has to go to the filesystem and compare content. Measured, same
repository: after zeroing, refresh exits 0 and git status is clean.
So this module patches bytes rather than rebuilding the index: writing it out
through a library would silently drop the extensions that library does not
know how to write — the split-index link above all, whose loss is not a slow
git status but a destroyed index. Patching in place preserves every byte we
did not mean to change, and the trailing checksum is verified before the edit
and recomputed after it, so a file that is not shaped the way we think is
left alone rather than mangled.
forget_stat touches four bytes per affected entry. restage also
replaces the object id, and therefore has to drop the TREE cache — see its
own comment for the measured reason, which is a commit that quietly stored
the plaintext again.
Structs§
- Tracked
- One tracked path, as the index records it.
Enums§
- Listed
- Every stage-0 entry in the index, or why it could not be read.
- Outcome
- What the index looked like, and what was done to it.
- Restaged
- What
restagedid. - Staged
- What the index says about a set of paths.
Functions§
- blob_id
- The object id git stores for
contentas a blob. - forget_
stat - Makes git re-read
pathsby forgetting the size it cached for them. - list
- Lists what the index records, without being told the paths in advance.
- object_
hash - The hash a repository’s index is checksummed with.
- restage
- Points index entries at different blobs, and forgets their cached size.
- staged_
ids - The object ids the index records for
paths.