Skip to content

Support a cone-mode sparse index under a virtual file system - #1025

Draft
Tyrie Vella (tyrielv) wants to merge 3 commits into
microsoft:vfs-2.56.1from
tyrielv:tyrielv/sparse-index/vfs-cone-aware
Draft

Tyrie Vella (tyrielv) wants to merge 3 commits into
microsoft:vfs-2.56.1from
tyrielv:tyrielv/sparse-index/vfs-cone-aware

Conversation

@tyrielv

@tyrielv Tyrie Vella (tyrielv) commented Nov 14, 2026 •

Copy link
Copy Markdown

Problem and Context

A repository with core.virtualFilesystem cannot use a sparse index today. There are three causes:

  • path_in_sparse_checkout() reports every path as inside the sparse-checkout under a virtual file system. So convert_to_sparse() never collapses an out-of-cone directory into a sparse-directory entry.
  • apply_virtualfilesystem() runs on every index read. When it looks up a reported path inside a sparse directory, index_name_pos() expands the whole index. On a repository with about 2.2 million index entries, this full expansion costs many seconds on every command.
  • After expand_index(), the name-hash cannot find the cache entries that the expansion reuses. With core.ignoreCase, lookups then miss tracked in-cone files. Under a virtual file system, git sparse-checkout add or reapply then deletes in-cone files from the working tree. Without a virtual file system, git status can report a tracked in-cone directory as untracked (see the first commit).

The changes to the virtual file system behavior apply only when core.virtualFilesystem is set. The cone patterns are used only when the repository also uses cone-mode sparse-checkout and sets index.sparse to true in its configuration. The default value of index.sparse (true) does not count, so that a virtual file system that cannot read a sparse index never gets one. A virtual file system repository without these settings behaves as before.

A virtual file system that uses a sparse index must keep modified files inside the cone. Git does not check the working tree for a path inside a sparse-directory entry, even when the hook reports the path.

Changes

The first commit fixes a sparse-index bug that does not depend on a virtual file system. In a plain cone-mode repository with index.sparse, core.ignoreCase, and sparse.expectFilesOutsideOfPatterns, git status reports a tracked in-cone directory as untracked when an untracked file sits in an out-of-cone directory that the walk visits first. The commit can move to a separate pull request if reviewers prefer.

  • sparse-index.c: expand_index() clears CE_HASHED on each reused cache entry, so that the next lazy_init_name_hash() adds it to the new name-hash. It also discards the old name-hash with free_name_hash(), which frees the dir_hash entries that hashmap_clear() leaked. The new git status test exposes that leak. This change is not limited to a virtual file system.
  • t/helper/test-read-cache.c: add a --expand-and-find=<path> option. It looks up a path in the name-hash, expands the index, and looks up the path again.
  • Add t1092 tests that use this option, and that run git status with core.ignoreCase after an expansion.
  • virtualfilesystem.c: clear_ce_flags_virtualfilesystem_1() uses index_name_pos_sparse() instead of index_name_pos(). A reported path inside a sparse directory is not found, so the sparse-directory entry keeps its skip-worktree bit. With a full index the two functions are the same. The directory-wildcard loop skips nested sparse-directory entries.
  • Add t1095. The tests use trace2 regions and expansion-reason data to check that the index does not expand. They check the skip-worktree bits for paths inside sparse directories, for directory wildcards, and for case-insensitive matches.
  • virtualfilesystem.c: add vfs_sparse_index_configured() and vfs_uses_cone_patterns(). The first returns true when the configuration sets cone mode and sets index.sparse to true explicitly. The second also checks the cone-mode pattern file. They run for each path, so they do not read the environment or write the configuration, unlike is_sparse_index_allowed(istate, 0).
  • dir.c: under a virtual file system, path_in_sparse_checkout_1() uses the cone patterns when vfs_uses_cone_patterns() returns true. path_matches_pattern_list() gets a pattern list that is already in cone mode, so it checks only vfs_sparse_index_configured(). This keeps it from loading patterns into the temporary index of expand_index(). For a cone-mode pattern list, path_matches_pattern_list() skips its virtual file system block. That block returns 1 for a directory that the hook does not report, and for the cone list 1 means "in the cone". This also fixes git sparse-checkout reapply on a collapsed index, which died with is_excluded_from_virtualfilesystem passed unhandled dtype.
  • read-cache.c: under a virtual file system, do_read_index() does not collapse a full index. The skip-worktree bits are not correct until apply_virtualfilesystem() sets them, after the read. The index collapses when it is written.
  • Data-loss prevention in builtin/sparse-checkout.c: clean_tracked_sparse_directories() returns early under a virtual file system. After git sparse-checkout set, add, or reapply, this function deletes each out-of-cone directory that has no untracked files. A virtual file system shows these directories in the working tree and owns them. Without this guard, a collapsed index under a virtual file system makes Git delete the projected out-of-cone files.
  • Documentation/config/core.adoc: describe how core.virtualFilesystem works with cone mode and an explicit index.sparse=true.
  • Add t1096. In these tests the out-of-cone files are on disk, as a virtual file system shows them. The tests check that nothing collapses with index.sparse false or with its default value, and that the out-of-cone directory collapses with index.sparse true. They check that git status does not expand the index, and that git sparse-checkout add and reapply keep all files on disk. They also check that a directory with a reported path does not collapse, and that the behavior without a virtual file system or without cone mode does not change.

Known gaps and deliberate decisions

  • The working-tree walk can still expand the index. git status with untracked-file scanning can still expand the index when the walk enters an out-of-cone directory. The tests in this change use --untracked-files=no. A separate change handles the lookups in the working-tree walk.
  • A reported path inside a collapsed directory is ignored. Git does not check the working tree for it, and no trace2 value names it. Keeping modified paths inside the cone is the responsibility of the virtual file system.
  • Per-path cost. vfs_uses_cone_patterns() runs for each path under a virtual file system. It reads only the cached configuration and the loaded patterns. Its cost on a very large repository was not measured separately.
  • Path-walk under a virtual file system. The path-walk API (for example git backfill --sparse) uses a cone-mode pattern list. With the cone patterns in use, it now matches only the patterns, as it does without a virtual file system.

@tyrielv
Tyrie Vella (tyrielv) force-pushed the tyrielv/sparse-index/vfs-cone-aware branch from fff2727 to 95d9b53 Compare November 14, 2026 05:18
expand_index() builds a new cache array that reuses the cache entries of
the sparse index. It then discards the name-hash of the index and leaves
it uninitialized. The reused entries keep the CE_HASHED flag, so the next
lazy_init_name_hash() skips them: hash_index_entry() returns early for an
entry that has CE_HASHED. After the expansion, index_file_exists() and
index_dir_find() cannot find these entries, and the dir_hash does not
contain their directories.

This happens when something initializes the name-hash before the index
expands. With core.ignoreCase, the untracked-file walk of "git status"
does this. For example, take a cone that contains "zin/", with
sparse.expectFilesOutsideOfPatterns set and an untracked file in the
out-of-cone directory "out/". The walk initializes the name-hash, then
the lookup of "out/.gitignore" expands the index. Then the walk cannot
find "zin/" in the dir_hash and reports this tracked directory as
untracked:

	?? out/new
	?? zin/

Clear CE_HASHED on each entry that expand_index() reuses, so that the
next lazy_init_name_hash() adds it to the new name-hash.

The test for the "git status" case also exposes a leak in the same
code. expand_index() discards the dir_hash with hashmap_clear(), which
frees the table but not the dir_entry structs, so they leak whenever
the name-hash was initialized with core.ignoreCase before the
expansion. Use free_name_hash(), which also frees the dir_entry
structs. Without this, the new test fails under the leak checker.

Add a "--expand-and-find=<path>" option to "test-tool read-cache". It
looks up a path in the name-hash, expands the index to a full index, and
looks up the path again. Add a t1092 test that uses it, and a t1092 test
for the "git status" case above. The order in which readdir() returns
the directories can hide the "git status" case on some platforms, so the
test-tool test is the deterministic check.

Assisted-by: Claude Opus 5.5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
apply_virtualfilesystem() runs on every index read. It sets
CE_SKIP_WORKTREE on every cache entry, then clears the bit for each
path that the virtual file system hook reports. To find these paths,
clear_ce_flags_virtualfilesystem_1() uses index_name_pos(), or the
name-hash helpers when core.ignoreCase is set.

In a sparse index, a reported path can be inside a sparse-directory
entry. Such a path has no cache entry of its own, and index_name_pos()
then expands the whole index to a full index. Because this happens on
every index read, a virtual file system repository can never keep a
sparse index. A directory wildcard (for example "dir/") has a second
problem: the loop that clears the bit on all entries below the
directory also clears it on a nested sparse-directory entry.

Use index_name_pos_sparse() instead. It does not expand the index, so a
path inside a sparse-directory entry is not found, and the
sparse-directory entry keeps its CE_SKIP_WORKTREE bit. This is correct:
the contents of the sparse directory are outside of the sparse-checkout
cone. With a full index, index_name_pos_sparse() and index_name_pos()
are the same, so a full index behaves as before. The core.ignoreCase
lookups do not change: the name-hash does not contain sparse-directory
entries, so they do not expand the index. In the directory wildcard
loop, skip sparse-directory entries.

apply_virtualfilesystem() runs only when core.virtualFilesystem is set,
so a repository without a virtual file system behaves as before.

A virtual file system that uses a sparse index must keep modified
paths inside the cone. Git does not look at the working tree for a
path inside a sparse-directory entry.

Add t1095. The tests use the trace2 "ensure_full_index" and
"expand_index" regions and the "expansion-reason" data to assert that
the index does not expand. They check the skip-worktree bits with
"git ls-files -t --sparse" for reported paths inside sparse
directories, for a directory wildcard that contains a nested sparse
directory, and for case-insensitive matches of in-cone paths. They
also check that "git status" reports an in-cone change, and that a
full index keeps the original behavior.

Assisted-by: Claude Opus 5.5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
…system

With core.virtualFilesystem, path_in_sparse_checkout_1() reports every
path as inside the sparse-checkout. convert_to_sparse() collapses a
directory only when path_in_sparse_checkout() reports it as outside, so
an out-of-cone directory never collapses into a sparse-directory entry,
and a virtual file system repository cannot have a sparse index.

When the repository uses cone-mode sparse-checkout and sets index.sparse
to true in its configuration, let the cone patterns decide. The new
helper vfs_sparse_index_configured() checks the configuration, and
vfs_uses_cone_patterns() also checks the pattern file. The default value
of index.sparse is true, but it does not count here: a virtual file
system must turn this on explicitly, because a virtual file system that
cannot read a sparse index must never get one. These helpers run for
each path, so they do not use is_sparse_index_allowed(istate, 0), which
reads the environment and can write index.sparse when
GIT_TEST_SPARSE_INDEX is set.

 - path_in_sparse_checkout_1() no longer returns early under a virtual
   file system when vfs_uses_cone_patterns() is true.

 - path_matches_pattern_list() skips its virtual file system block for
   a cone-mode pattern list when vfs_sparse_index_configured() is true.
   The list is already in cone mode, so this checks only the
   configuration and does not load patterns into the temporary index
   of expand_index(). For a directory that the virtual file
   system does not report, that block returns 1. For the cone patterns,
   1 means "in the cone", so such a directory would still never
   collapse. Pattern lists that do not use cone mode keep the virtual
   file system block. The path-walk API, for example in "git backfill
   --sparse", also uses a cone-mode pattern list. Under a virtual file
   system, it now also matches only the patterns, as it does without a
   virtual file system.
   This change also fixes "git sparse-checkout reapply" on a collapsed
   index, which died with "is_excluded_from_virtualfilesystem passed
   unhandled dtype" when expand_index() passed a sparse directory with
   DT_UNKNOWN to that block.

 - do_read_index() no longer collapses a full index under a virtual
   file system. It calls ensure_correct_sparsity() before
   post_read_index_from() calls apply_virtualfilesystem(), so
   convert_to_sparse() would use the skip-worktree bits that are stored
   in the index file. Under a virtual file system these bits are stale:
   the hook can now report a path that had the bit when the index was
   written. Then the directory collapses, and apply_virtualfilesystem()
   cannot clear the bit of a path inside a sparse directory. The index
   collapses when it is written, after the bits are correct.

 - clean_tracked_sparse_directories() returns early under a virtual
   file system. After "git sparse-checkout set", "add", or "reapply",
   it deletes each out-of-cone directory that has no untracked files.
   A virtual file system shows these directories in the working tree,
   and the virtual file system owns them. Before this change, nothing
   collapsed under a virtual file system, so this function deleted
   nothing.

All new behavior requires core.virtualFilesystem. The cone patterns
are used only when the repository also uses cone mode and sets
index.sparse to true. Without a virtual file system, nothing changes. A
virtual file system repository without these settings also behaves as
before. Update the description of core.virtualFilesystem to match.

Add t1096. The out-of-cone files are on disk, as a virtual file system
shows them. The tests check that nothing collapses with index.sparse
false or with the default value of index.sparse, and that the
out-of-cone directory collapses with index.sparse true. They check that
"git status" reports an in-cone change without an expansion, and that
"git sparse-checkout add" and "reapply" keep all files on disk. They
check that a directory with a reported path does not collapse, that
non-cone mode under a virtual file system does not change, and that
cone mode without a virtual file system does not change.

Assisted-by: Claude Opus 5.5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant