Repository navigation
sparse-index: do not expand the index during the working-tree walk under a virtual filesystem - #1024
Draft
Tyrie Vella (tyrielv) wants to merge 1 commit into
Conversation
Tyrie Vella (tyrielv)
force-pushed
the
tyrielv/sparse-index/vfs-walk-no-expand
branch
from
November 14, 2026 00:49
26dd712 to
83464de
Compare
Tyrie Vella (tyrielv)
force-pushed
the
tyrielv/sparse-index/vfs-walk-no-expand
branch
from
November 14, 2026 07:51
83464de to
72b6717
Compare
A sparse index collapses each directory outside the sparse-checkout cone
into one sparse-directory entry. Several lookups in the working-tree walk
use index_name_pos(). When the path is inside a sparse-directory entry,
index_name_pos() expands the whole index to answer a question about one
path.
A plain sparse checkout does not get to these lookups. The files outside
the cone are not on disk, so the walk does not go into those directories.
Under core.virtualFileSystem, the working tree shows the full tree while
the index stays sparse, and the walk goes into them. In a repository with
about 2.2 million index entries, an earlier version of this change made
"git status" go from about 20 seconds to less than half a second. When
the command writes the index, it expands the index a second time
("re-expanding after write").
Five lookups are involved. read_skip_worktree_file_from_index() reads a
.gitignore from the index when the file is not on disk. add_patterns()
looks up the object ID of a .gitignore on disk for the untracked cache.
directory_exists_in_index() decides if a directory is tracked.
get_index_dtype() gives the type of a path to resolve_dtype() when the
caller does not know it. index_name_is_other() removes tracked paths
from the untracked list in "git status" and "git clean".
Add vfs_sparse_dir_containing(). If core.virtualFileSystem is set, the
index is sparse, and a sparse-directory entry contains the path, it
returns that entry without an expansion. Otherwise it returns NULL. Each
of the five lookups calls it first. Under core.virtualFileSystem, a NULL
result means that index_name_pos() cannot expand the index for that
path, so the existing code runs. Without core.virtualFileSystem, the
helper always returns NULL, and the behavior does not change.
When a sparse-directory entry contains the path, each lookup gives the
answer of the full index. Everything inside a sparse-directory entry has
the skip-worktree bit, so read_skip_worktree_file_from_index() reads the
blob from the tree of that entry. The untracked-cache lookup computes the
object ID from the file on disk, as it does for any path that has no
usable index entry. directory_exists_in_index() and
index_name_is_other() look up the path in the tree. An index has entries
for files only, so index_name_is_other() reports a directory in the tree
as "other". directory_exists_in_index() does its lookup before the
case-insensitive lookup, because the name hash does not contain the
directories inside a sparse-directory entry. get_index_dtype() returns
DT_UNKNOWN, and the caller uses lstat(). An index that was just expanded
returns DT_UNKNOWN too, because expansion does not mark the new entries
up to date.
Add sparse_dir_get_tree_entry() for the tree lookups. If core.ignoreCase
is set, directory_exists_in_index() and index_name_is_other() compare
names without case, because with a full index the walk finds tracked
paths with case-insensitive lookups. If a tree cannot be read, the
helper dies. A failure to read must not make a tracked path look
untracked, because "git clean" could then delete it.
For these two lookups, vfs_sparse_dir_containing() also finds the
sparse-directory entry when its name has a different case on disk, for
example "OUT/a" for the entry "out/". The index sorts names with case,
so such a path does not sort next to its sparse-directory entry, and
the name hash does not contain the paths inside the entry. Without this,
"git status" reports the tracked files below "OUT/" as untracked, and
"git clean -f -d" deletes them. The helper looks up each leading
directory of the path in the case-insensitive dir_hash, which contains
the directory of each sparse-directory entry, and checks if the name
that the index uses for it is a sparse-directory entry. In a test
repository with about 41,000 paths on disk, this made the walk of "git
status -uall" with core.ignoreCase take 0.245 seconds instead of 0.22.
The other three lookups compare with case, as index_name_pos() does.
Add t1094, which puts files on disk inside a sparse directory and uses a
virtual file system hook. The tests compare "git status", "git clean
-n", and "git add --ignore-missing" with the results in a clone that has
a full index. They also use trace2 to make sure that the index does not
expand and stays sparse. Other tests check directories spelled with a
different case on disk (inside a sparse directory, the sparse directory
itself, and a nested sparse directory), and an unreadable tree. A last
test shows that without a virtual file system, the walk still expands
the index.
Assisted-by: Claude Opus 5.5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Tyrie Vella (tyrielv)
force-pushed
the
tyrielv/sparse-index/vfs-walk-no-expand
branch
from
November 14, 2026 08:58
72b6717 to
7846ae3
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem and Context
A sparse index collapses each directory outside the sparse-checkout cone into one sparse-directory entry. Several lookups in the working-tree walk use
index_name_pos(). If a sparse-directory entry contains the path,index_name_pos()expands the whole index.A plain sparse checkout does not get to these lookups, because the files outside the cone are not on disk. Under
core.virtualFileSystem, the working tree shows the full tree while the index stays sparse. Thus the walk goes into the collapsed directories, and the first lookup there expands the index. In a repository with about 2.2 million index entries, an earlier version of this change madegit statusgo from about 20 seconds to less than half a second. When the command writes the index, it expands the index a second time ("re-expanding after write").This change applies only when
core.virtualFileSystemis set and the index is sparse. Without a virtual file system, the code paths do not change.After this change, these commands leave the index sparse under a virtual file system. Before, the first expansion made git write a full index.
Changes
vfs_sparse_dir_containing()tosparse-index.c. Ifcore.virtualFileSystemis set, the index is sparse, and a sparse-directory entry contains the path, it returns that entry. Otherwise it returnsNULL. It never expands the index. Undercore.virtualFileSystem, aNULLresult means thatindex_name_pos()cannot expand the index for that path.sparse_dir_get_tree_entry(), which finds the rest of the path in the tree of a sparse-directory entry. It can compare names without case. If a tree cannot be read, it dies, so that an unreadable tree cannot make a tracked path look untracked togit clean.vfs_sparse_dir_containing()first in each lookup that the walk uses. When it returnsNULL, the existing code runs. When it returns an entry, the lookup gives the answer of the full index:read_skip_worktree_file_from_index()reads a missing.gitignorefrom the tree.add_patterns()hashes the.gitignoreon disk, as it does for any path without a usable index entry.directory_exists_in_index()uses the tree, before the case-insensitive lookup. The name hash does not contain directories inside a sparse-directory entry, so without this, a tracked directory looks untracked whencore.ignoreCaseis set.get_index_dtype()returnsDT_UNKNOWN, so the caller useslstat(). An index that was just expanded also returnsDT_UNKNOWN, because expansion does not mark the new entries up to date.index_name_is_other()uses the tree, and reports a directory as "other", as the full index does.directory_exists_in_index()andindex_name_is_other()compare without case whencore.ignoreCaseis set, because with a full index the walk finds tracked paths case-insensitively.vfs_sparse_dir_containing()also finds the sparse-directory entry when its name has a different case on disk (for exampleOUT/afor the entryout/). The index sorts names with case, so such a path does not sort next to its entry. The helper looks up each leading directory of the path in the case-insensitivedir_hash, which contains the directory of each sparse-directory entry. Without this,git statusreports the tracked files belowOUT/as untracked, andgit clean -f -ddeletes them. In a test repository with about 41,000 paths on disk, the walk ofgit status -ualltook 0.245 seconds withcore.ignoreCaseinstead of 0.22.t1094-virtualfilesystem-sparse-walk.sh. The setup makes a cone-mode sparse index, puts the out-of-cone files and some untracked and ignored files back on disk, and installs a virtual file system hook. A second clone with the same working tree and a full index is the reference.git status(several-uand--ignoredmodes, withcore.ignoreCasefalse and true, and with the untracked cache),git clean -n, andgit add --ignore-missingwith the full-index clone. Each test also uses trace2 to make sure that the index does not expand and stays sparse.git clean -n -d -xoutput.git statusandgit clean -n -dgive the results of the full index: a directory inside the sparse directory, the sparse directory itself (out/toOUT/), and a nested sparse directory (out/deep/toOUT/Deep/, without/in the cone).git clean -n -dfails and lists nothing.Known gaps and deliberate decisions
apply_virtualfilesystem()still usesindex_name_pos()for each path that the hook lists, so a hook row inside a sparse directory still expands the index. A separate change handles that function. The new tests list only the exact sparse-directory nameout/.git statusdoes not report changes to them. The virtual file system layer must bring a changed path into the cone. Before this change, the first expansion wrote a full index, which removed the benefit of the sparse index.core.virtualFileSystem, a sparse-directory name with a different case on disk (for exampleOUT/for the entryout/) still makes tracked files below it look untracked. This is not new, and it is rare without a virtual file system, because a plain sparse checkout does not put those files on disk. This PR changes only the virtual file system case. No follow-up is planned in this PR.get_index_dtype()returnsDT_UNKNOWN, and the untracked cache hashes the.gitignoreon disk. A full index thatgit statusrefreshed would answer from the index instead. The answers differ only when a skip-worktree path on disk differs from the index, and then the new answer describes the disk.