Skip to content

[Prototype review] Automatic sparse index for VFS for Git - #26

Draft
tyrielv wants to merge 43 commits into
sparse-index-review-basefrom
feature/sparse-index
Draft

tyrielv wants to merge 43 commits into
sparse-index-review-basefrom
feature/sparse-index

Conversation

@tyrielv

@tyrielv tyrielv commented Sep 17, 2026

Copy link
Copy Markdown
Owner

[Prototype review] Automatic sparse index for VFS for Git

Draft — for code review of the hackathon prototype. Not for merge.

This is the consolidated VFS for Git side of the prototype. The git-side changes
are in a companion draft PR in the microsoft-git fork.

Base is sparse-index-review-base, a branch pinned at the pre-work commit
(d25926da, the upstream/vnext merge). Diffing against master instead would
add ~100 unrelated vnext commits to the review; against this base the diff is
only our work: 38 commits, 76 files.


What it does

VFS for Git virtualizes the working directory, but .git/index still carries
every file — 193 MB and 2,245,934 entries on os.2020. Every index-proportional
git operation pays for all of them.

This keeps the ProjFS projection full (no visible change to the enlistment)
while letting .git/index go sparse, using git's cone-mode sparse index.
The mount manages the cone automatically from the modified-path set; the
pre-command hook widens it when a command names an out-of-cone path and the
post-command hook narrows it back.

Measured on two os.2020 enlistments at the same commit, differing only in
--sparse-index:

Measure Full index Sparse index
Index size 193,034,541 B 6,686 B 28,870x smaller
Index entries 2,245,934 66 34,029x fewer
git status 0.96 s 0.37 s 2.6x faster
git rebase 25.62 s 11.91 s 2.15x faster
First mount (cold) 21.1 s 30.5 s superseded — see below

Clone and first mount are now at parity. The first-mount cost was never new:
writing a 2.2M-entry index during checkout requires reading every tree, so the
baseline does that work inside clone and the sparse arm merely deferred it. The
final commit writes a full index to a temp file concurrently with the
checkout
, and the first mount parses it instead of walking ~500,000 trees cold.

clone first mount total
control (full index) 23.62 s 15.14 s 38.76 s
sparse (mean of 2) 23.20 s 15.44 s 38.64 s

Clone + first mount: +3.4 s (5.3%) -> -0.12 s (-0.3%). First mount alone:
+9.4 s -> +0.30 s. Timed from GVFS's own GVFSClone (Stop) / ExecuteMount (Stop)
durations, not wall clock.

The seed is not free, and should be argued as a trade: a sparse clone used to be
14% faster than a full clone, and is now only 1.8% faster. The seed spent that
saving to buy back the mount penalty. Clone is long and runs once; mount gates
the first usable moment.

Everything is behind gvfs.auto-sparse-index, default false.


Suggested reading order

The commit history is workstream-shaped, not review-shaped (re-cutting it into
reviewable PRs is itself a productization task). To read it by area:

  1. Cone modelGVFS.Common/Sparse/
    ConeBuilder, ConePatternSet, ConeFileWriter, ConePathspecResolver,
    ConeCoverage, ConeDriftDetector, TransientConeState. Pure, no I/O,
    heavily unit-tested. Start here; everything else consumes these.

  2. ProjectionGVFS.Virtualization/Projection/
    GitIndexProjection.SparseDirectoryExpander.cs is the core change: a
    sparse-directory index entry is expanded from its tree so the projection
    stays complete. GitIndexParser gains sparse-entry detection and fail-fast.

  3. Mount-side cone managementGVFS.Mount/AutoSparseIndexConeManager.cs
    Handles widen/narrow requests from the hooks. Note the concurrency comment at
    the top: ordering is coneLock -> sparse-checkout write -> git's index.lock,
    and the projection reparse is deliberately off the reply path.

  4. HooksGVFS.Hooks/Program.ConeManagement.cs, GVFS.Common/GitPathspecParser.cs
    Table-driven git command-line parsing to decide which paths a command names,
    plus the exclusions that skip the IPC entirely when widening cannot help.

  5. CLIGVFS/CommandLine/SparseIndexVerb.cs, CloneVerb.cs
    gvfs sparse-index --enable/--disable/--status, and gvfs clone --sparse-index which builds the cone before the initial checkout so the
    index is never written full.

  6. GuardGVFS.Common/SparseCheckoutCommandGuard.cs
    Blocks the git sparse-checkout subcommands that would fight the mount for
    ownership of the cone file.


Things worth a reviewer's attention

  • AutoSparseIndexConeManager is the most concurrency-sensitive code here.
    It deliberately does not take the GVFS lock (the pre-command hook already
    holds it, so acquiring it would deadlock the very command waiting on the
    reply).
  • SparseDirectoryExpander must never produce a partial projection. It
    fails the build rather than serving an incomplete tree; a partial projection
    would be silent data loss.
  • Prune must dehydrate (see decisions/0014): ordinary and memory-mapped
    index writes do not re-enter ModifiedPathsDatabase, so dropping a
    modified path without converting the file back to a placeholder loses data.
  • The cone granularity heuristic (ConeGranularityOptions) is opt-in and
    default off. It is entry-neutral by construction — it optimises pattern count
    and cone churn, not index size.

Known gaps

Gap Notes
stash push -- <path> 4.9x slower builtin/stash.c expands unconditionally on any pathspec; cone widening cannot fix it. Needs an upstream git change.
Cache replay not implemented Cold projection build 11.5 s, once per clone; warm is 2.61 s, faster than non-sparse.
One uncoverable expansion Rebase conflict paths come from commit contents, not argv, so the hook cannot pre-widen. Self-heals.
Flag default-off Needs field soak.

Verification

  • 1,261 unit tests, 0 failed, 0 skipped, on the merged tip.
  • Functional tests including a live gvfs clone --sparse-index against a real
    mount.
  • End-to-end on os.2020: projection verified byte-identical to
    git ls-tree HEAD at every level checked.
  • ~22 architecture decision records in the coordination folder record why each
    design choice was made, including the ones that were rejected.

Review guidance

This PR is one consolidated diff for reading, not a proposal to merge as-is.
A production breakdown into 12 reviewable PRs — with ordering, dependencies,
branch targeting, and a review-load estimate — is in
presentation/production-pr-plan.md. The two PRs carrying most of the risk are
the git-side non-expanding lookups and the projection tree expansion.

Four gaps must close before any of this is proposed for production: prune must
dehydrate (data-loss risk, ADR 0014), cache replay, the two measured pathspec
regressions, and the one uncoverable rebase expansion. All four are documented
in the plan.

Replace per-file GVFS.Common links with a project reference now that NativeAOT trimming keeps hook startup flat.

Keep Windows shared source links for hook platform helpers.

Assisted-by: GPT-5.6 Sol

Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
A sparse index (git index.sparse) stores a collapsed subtree as one
directory entry with mode 040000 and a trailing '/'. GVFS builds its
projection from the index and cannot expand such an entry. Today the
mount ingests it as an ordinary entry, reports the repository ready,
then crashes about three seconds later inside a ProjFS enumeration
callback, because the trailing slash produces an empty child name. That
projection also silently omits every collapsed subtree. This replaces
the late crash and silent data loss with an early, actionable failure.

The earlier prediction that the parser would reject mode 040000 with
"Invalid file type" is wrong on Windows. The whole mode-validation block
runs only when SupportsFileMode is true, and WindowsFileSystem returns
false, so the parser never reads the mode field. Detection must not
depend on the mode.

Increment 1 - recognise a sparse-directory entry:
- Add FileType.Directory and map mode 040000 (0x4000) to it in
  FileTypeAndMode, so macOS (SupportsFileMode true) stops throwing
  "Invalid file type" and takes the same path as Windows.
- Add the parse-mode switch case that requires mode bits to be zero.
- Add GitIndexEntry.PathEndsInSlash, which detects the entry from the
  trailing '/' in the raw path buffer, with no per-entry allocation and
  no dependence on the mode field.

Increment 2 - fail fast before the mount reports ready:
- FailIfSparseDirectoryEntry throws InvalidDataException with a message
  that names the offending path and tells the user to run
  'git sparse-checkout disable' to expand the index. It runs from both
  ValidateIndexEntry and the projected-entry branch of
  AddIndexEntryToProjection. The exception propagates through
  Initialize and TryStart to FailMountAndExit, which exits before
  "Virtual repo is ready".

The two increments land together on purpose. Recognition alone is
unsafe: it would make macOS reach the same enumeration crash instead of
throwing early, so it must ship with the fail-fast. The fail-fast is a
bug fix for a crash and silent data-loss risk, not a new feature, so it
is not gated behind a config flag, matching the existing unconditional
index-validation checks.

Tests: a self-contained v4 index builder emits entries with arbitrary
mode and trailing-slash paths. Coverage includes FileTypeAndMode
mapping, parse acceptance of 040000 on both platforms, the fail-fast
message, a malformed directory with non-zero mode bits, v4 prefix
compression after a trailing-slash entry, and an unchanged full index.
Full suite: 1008 tests, 997 passed, 0 failed, 11 skipped.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Introduce the pure, testable core for automatic cone management behind a new
default-off git-config flag (gvfs.auto-sparse-index). No runtime behavior
changes while the flag is off.

- ConeBuilder maps a set of modified paths to a cone pattern set: parent-only
  patterns for modified files and their ancestors, recursive patterns for
  modified folders. Patterns covered by a recursive ancestor are removed. A
  single modified file never produces a recursive pattern.
- ConeFileWriter serializes a cone pattern set to git's exact cone-mode
  info/sparse-checkout format, escaping glob-special characters as git does,
  and writes it atomically with a backup for rollback. Legacy /.gitattributes
  content is detected.
- SparseCheckoutPathResolver resolves the per-worktree sparse-checkout file
  path, using the per-worktree git dir for a linked worktree instead of the
  shared git dir.

This is pure logic with no live-enlistment mutation. Unit tests cover the
design worked examples, edge cases, glob escaping, and primary-versus-worktree
path resolution.

Assisted-by: Claude Sonnet 4.5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Add the GVFS.Hooks side of automatic sparse-index support, gated behind
the gvfs.auto-sparse-index config flag (default false). When the flag is
off, only a single cheap parse runs and no new pipe traffic or config
read occurs.

Part 1 - table-driven Git command-line parser.
  GitPathspecParser and ParsedGitCommand (in GVFS.Common) extract the
  working-tree pathspecs a Git command names. That set is the input the
  mount needs to widen the sparse-index cone before Git runs. The parser
  is a per-command grammar table keyed by a positional kind: add, stage,
  rm, mv, restore and commit treat positionals as pathspecs; checkout and
  reset take a leading ref then pathspecs; switch takes none; stash push
  and save take pathspecs; diff, log, show, grep, blame and unknown
  commands only take paths after "--". It handles "--" separators,
  combined short flags, value options in both "--opt value" and
  "--opt=value" forms, --pathspec-from-file, --pathspec-file-nul, a
  leading run of Git globals (-C, -c, --git-dir, --work-tree, ...), and
  filters the --git-pid and --exit_code tokens Git injects into hook
  argument lists. It does not modify the existing GitCommandLineParser,
  UnstageCommandParser or WorktreeCommandParser, which have live callers.

  A table-driven parser was chosen over System.CommandLine after a
  measured comparison (standalone NativeAOT harness, n=200 warm and n=50
  cold, run twice). The table cost +52 KB of binary and no measurable
  startup change; System.CommandLine cost +1.52 MB and +1.5 ms warm /
  +24 ms cold per invocation. GVFS.Hooks.exe runs on every Git command,
  so per-invocation startup is direct user-visible latency.

Part 2 - hook-to-mount cone-management contract (client side).
  NamedPipeMessages.ConeManagement defines the widen and narrow requests
  and their result codes, with NUL-separated body encoding. The hook
  client sends a widen before a path-naming command and a narrow after
  it, keyed by the Git session id. Both are bounded and non-blocking on
  failure: a missed widen only makes Git transiently expand the in-memory
  index and re-collapse it, so every failure path proceeds silently and
  never blocks Git. The mount-side handler is owned by the cone-management
  work and is intentionally not implemented here.

Tests: 46 parser cases and 8 pipe round-trip cases. Full unit suite
1040 passed, 0 failed, 11 skipped.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Add the CLI surface for the built-in sparse index, a feature distinct from
gvfs sparse: gvfs sparse narrows the projection, while gvfs sparse-index keeps
the projection full and lets the on-disk .git/index collapse to cone entries.

New verb gvfs sparse-index (SparseIndexVerb):
- --status (default): reports whether gvfs.auto-sparse-index is enabled, whether
  the on-disk index is sparse, the entry count, and the index size. These
  numbers are the feature's value proposition, so the verb surfaces them.
- --enable: refuses when gvfs sparse has a projection sparse-set (mutual
  exclusion, with a message explaining the projection-vs-index distinction),
  otherwise sets gvfs.auto-sparse-index plus core.sparseCheckoutCone,
  index.sparse, and sparse.expectFilesOutsideOfPatterns.
- --disable: the recovery path. It unmounts if needed, expands the index with
  update-index --force-write-index under neutralized hooks, clears the config,
  and reports the before and after entry count and size.

A bare git sparse-checkout disable fails when the repo is unmounted, because the
pre-command and virtual-filesystem hooks abort with no mount to answer the named
pipe, and disable forces a worktree update that unmounted ProjFS rejects.
GitProcess.ForceExpandSparseIndex expands the index in place without a worktree
update, which is non-destructive.

GitIndexInspector and GitIndexInfo read the DIRC header and scan entries for the
first directory-mode entry to power --status. The scan is version 2, 3, and 4
aware and handles v4 prefix compression.

Config plumbing is gated behind gvfs.auto-sparse-index, default false:
- RequiredGitConfig.GetRequiredSettings gains a bool overload that adds the three
  sparse keys only when enabled; the no-argument overload delegates with false,
  so config is byte-for-byte unchanged for repos that never opt in.
- InProcessMount reads the flag and passes it through.
- gvfs clone --sparse-index writes the flag at clone time.

Unit tests cover config parity (default equals disabled, enabled adds exactly
three keys) and the index parser (full and sparse indexes across v2 and v4).

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

Brings in detection of git sparse-directory index entries (040000, trailing
slash) with an unconditional parse-time fail-fast before the mount reports
ready. See decisions/0003-sparse-index-fail-fast.md.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Adds the gvfs.auto-sparse-index flag (default off), the cone builder, cone file
writer, and sparse-checkout path resolver in GVFS.Common/Sparse. See
decisions/0004-cone-membership-under-vfs.md.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Adds the table-driven GitPathspecParser and ParsedGitCommand in GVFS.Common,
the cone-management named-pipe contract (client side), and the GVFS.Hooks
pre/post-command cone-widen/narrow calls. See
decisions/0006-hooks-parser-table-driven.md.

Conflict resolved: GVFSConstants.cs gvfs.auto-sparse-index constant. W6 and W7
each added it in different regions of GitConfig, so the merge produced two
identical definitions (CS0102 duplicate member). Kept W6's copy (introduced
first, after PrefetchOffload) and removed W7's duplicate block. Key string and
default value were identical, so no reference change was needed.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Adds the gvfs sparse-index verb (--status/--enable/--disable), GitIndexInspector,
GitProcess.ForceExpandSparseIndex, the flag-gated RequiredGitConfig overload,
mount re-assertion of sparse keys, and clone --sparse-index. See
decisions/0007-sparse-index-cli-and-recovery.md.

Conflicts resolved:

1. GVFSConstants.cs gvfs.auto-sparse-index constant (three-way with W6 and W7).
   The constant name and default value were identical across all three; only
   the comment differed. Kept W6's copy (introduced first). W8's separate
   SparseIndex verb-name constant merged cleanly and is retained.

2. W5 recovery message (cross-workstream, per ADR 0007). W8 proved W5's original
   instruction ("git sparse-checkout disable") fails twice over on an unmounted
   GVFS enlistment (pre-command hook abort; then a working-tree update ProjFS
   rejects). Replaced it with the ADR 0007 wording pointing at
   'gvfs sparse-index --disable'. W5 could not apply this itself because
   FailIfSparseDirectoryEntry lived on a sibling branch. Also updated W5's
   SparseIndexParsingTests assertion, which checked for the old text.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Replaces the 29 linked GVFS.Common source files in GVFS.Hooks.csproj with a
single ProjectReference to GVFS.Common, and drops the now-duplicate
_GenerateConstantsFile target (GVFS.Common.csproj already owns it). See
decisions/0002-hooks-common-reference.md.

Conflict resolved (semantic, per the W10 task and ADR 0002): W7 had added three
new linked files to GVFS.Hooks.csproj (GitPathspecParser.cs, ParsedGitCommand.cs,
ConeManagementNamedPipeMessages.cs). W9 removes all links in favor of the
ProjectReference, which already brings those types in from GVFS.Common. Keeping
both would produce duplicate-type / ambiguous-reference errors, so the resolution
takes W9's csproj in full, dropping W7's now-redundant links. GitPathspecParser
and the cone-management messages resolve through the ProjectReference.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Add repeatable trace2 workloads for full-index and sparse-index comparisons.

Capture timing spread, index state, projection size, and expansion reasons.

Assisted-by: GPT-5.6 Sol

Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
A sparse index (git index.sparse) collapses a folder to a single entry with
mode 040000, a tree object id, and a trailing '/'. Earlier work made the
projection recognise such an entry and fail the mount fast, because building a
projection from it silently omitted the collapsed subtree and later crashed
ProjFS enumeration. This change adds real expansion of the collapsed subtree, so
the projection serves the full working directory from a sparse index.

Expansion is gated by the existing gvfs.auto-sparse-index config flag, default
false. When the flag is off the fail-fast is unchanged, so this is a no-op for
every current enlistment.

Increment 1 - local tree reader (GVFS.Common.Git):
- TreeEnumeration.cs: TreeEnumerationResult (Success, MissingTree, CorruptTree,
  NotTree) and a TreeEntryVisitor delegate. The states are distinct so a caller
  can download-then-retry a missing tree but fail fast on corruption or a
  non-tree object.
- LibGit2Repo.EnumerateTree: reads a local tree through the in-process libgit2
  repo, calling the visitor once per entry with the raw UTF-8 name, the 20-byte
  object id, the git file mode, and whether the entry is a subtree. It maps a
  failed open to MissingTree or CorruptTree using ObjectExists, and reports a
  non-tree object as NotTree. Added the git_tree_entry_name interop.
- GitRepo.TryEnumerateTree wraps it through the libgit2 invoker; an unavailable
  repo reports MissingTree so the caller can try a download.

Increment 2 - local tree expansion (GVFS.Virtualization.Projection):
- GitIndexProjection.SparseDirectoryExpander.cs: an iterative (explicit-stack)
  DFS expander. It reads each tree through IProjectionTreeReader and adds the
  children to the projection in the same FolderData / FileData shape the parser
  already produces for a full index. It reuses one visitor delegate and one
  20-byte SHA buffer, so it adds no per-entry allocation. It preserves mode data
  (symlink, gitlink, executable) into nonDefaultFileTypesAndModes on platforms
  that parse the mode field. It carries the gvfs sparse-cone inclusion down the
  walk in O(1) per folder (FrameInclusion), mirroring
  SortedFolderEntries.GetOrAddFolder.
- GitIndexEntry: IsSparseDirectory / ProjectionPathLength and a
  BuildingProjection_ParsePath(int) overload that parses the path without its
  trailing '/'. This is the fix for the original crash: the standard parse of
  "GVFS/" produces an empty final part that ProjFS rejects; the logical-length
  parse produces the collapsed folder name instead. The overload does not touch
  PathBuffer at or beyond the logical length and does not disturb the
  previous-separator state, so index-v4 prefix decompression of the next entry
  is unaffected.
- SortedFolderEntries.AddFolder and FolderData.AddChildFolder insert a new child
  folder by binary search, so git tree order is handled. AddFile and AddFolder
  now reject an empty name, and LazyUTF8String gains an allocation-free IsEmpty.

Increment 3 - missing tree download:
- The expander downloads a missing tree once and retries the read. A permanent
  miss, a corrupt tree, or a non-tree object throws InvalidDataException and
  fails the projection build, so GVFS never serves a partial working directory,
  per the CONTRIBUTING.md rule for data-loss risk. The download is synchronous
  under the projection write lock; see the ADR for that decision.

Cache replay (the persisted-projection reuse that avoids a full tree walk on
every index change) is not in this change. Expansion stays behind the default-
off flag so the slow cold-walk path is never enabled by default.

Tests (GVFS.UnitTests):
- SparseDirectoryExpanderTests: a fake IProjectionTreeReader serves synthetic
  trees. Cases: flat and nested expansion into FolderData / FileData; mode
  recording on and off by platform; sparse-cone inclusion; missing-then-
  download-then-retry; and fail-fast on a permanent miss, a corrupt tree, a
  non-tree object, and an empty entry name.
- GitIndexEntryTests: the logical-length parse strips the trailing '/' for a
  top-level and a nested sparse-directory path, and a regular file is not
  detected as a sparse directory.
- Full suite: 1137 tests, 1126 passed, 0 failed, 11 skipped (the 11 are the
  native PostIndexChangedHook tests that need the built hook exe). Baseline
  1121/1110/0/11 preserved; 16 new tests, all pass. Build: 0 warnings, 0 errors.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Three fixes let the harness run against a collapsed sparse index. They are
timing-neutral for the measured operations.

- Sparse-directory guard: a tracked path inside a collapsed directory is not
  listed by `ls-files --sparse`; only its containing directory appears as a
  sparse-directory entry. Accept the path when it has a sparse-directory
  ancestor, instead of requiring an exact leaf entry that a collapsed index
  never holds.

- Cleanup restore: on a collapsed index `checkout -- <path>` cannot match a
  path inside a sparse directory, and `git add` would force a full-index
  expansion. Restore the tracked fixture path from the base-commit blob with
  `cat-file blob`, which needs no pathspec match and no expansion. Full mode
  keeps the original checkout.

- Fixture builder: the old path round-tripped the whole index (read-tree plus
  write-tree). On a multi-million-entry index that rebuilds the entire
  cache-tree and, in a partial clone, triggers a promisor fetch. Build the
  fixture commit by editing only the tree path that changes, with recursive
  ls-tree plus mktree. It is offline and produces an identical commit.

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

Adds SparseIndexProjectionTests, an end-to-end functional test that proves GVFS
projects a collapsed sparse index correctly when gvfs.auto-sparse-index is on.

Before this change the sparse-directory expansion path had only unit coverage.
The failure it guards against is silent: a collapsed sparse-directory entry
projected a truncated folder, so a file deep inside (GVFS/FastFetch/Program.cs)
read as "does not exist" and the folder listed only a few of its files, with no
crash.

The test provisions its own clean enlistment, collapses the index on the live
mount with `git -c index.sparse=true update-index --force-write-index` (which
rewrites the index in place without touching the working tree, so it works on a
ProjFS enlistment where `sparse-checkout reapply` cannot open the in-cone
placeholders), drops the cached projection, and remounts with the flag on. It
then asserts:

- the on-disk index stays collapsed (read from the raw DIRC header, and via
  ls-files --sparse, to avoid forcing expansion with git ls-files),
- the collapsed directory enumerates its full HEAD-tree child set,
- the deep file reads with content that byte-matches the committed blob,
- the index is still collapsed at read time, and
- the mount stays Ready past the window in which the truncated projection used
  to crash at larger scale.

The clean per-fixture enlistment also avoids the stale ProjFS folder placeholder
that survives a remount once a collapsed folder is enumerated mid-window.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Strengthen the collapsed sparse-index functional test to prove tree-wide
completeness by set equality instead of spot checks.

The test now captures the expected file set from `git ls-tree -r HEAD`, then
after collapsing the index on the live mount and remounting with
gvfs.auto-sparse-index on, does a full recursive enumeration of the projected
working tree and asserts the two sets are identical. On mismatch it reports the
symmetric difference so any missing (silently lost) or extra (spurious) path is
named. This subsumes the earlier FastFetch / Program.cs spot checks and guards
against silent file loss anywhere in the tree.

Also keep the direct content proof (Program.cs byte-matches the committed blob)
and the on-disk index-stays-collapsed assertions at mount, at read, and after
the read, so a projection that came from a re-expanded index is caught.

Result on the test enlistment: index collapses 864 -> 31 entries; the flag-on
remount projects 864 files, exactly equal to HEAD (864). No file is lost.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
During conflict resolution a user names the conflicted file directly, e.g.
"git add out/f.txt" or "git checkout --ours out/f.txt". When that file is
outside the sparse-index cone, plain cone mode refuses the operation and the
index is left fully expanded, so "git rebase --continue" cannot proceed.

The pre-command hook already sends a cone-widen request for any command that
names a path, so the "git add"/"git rm"/"git restore"/"git checkout --
<path>"/"git reset -- <path>" cases are covered. The gap was
"git checkout --ours/--theirs/-p <path>": those flags carry no ref operand, so
the conflicted path is the first positional, but the checkout grammar
(one leading ref) misread it as a branch and never widened for it.

Teach GitPathspecParser a per-command set of ref-suppressing flags. When one is
present before "--", the command has no ref operand and every positional is a
pathspec. Set --ours/--theirs/--patch/-p for checkout. Detection scans the whole
command so flag order does not matter; past "--" a matching token stays a
literal path.

Add unit tests covering each conflict-resolution command, the ref-suppressing
flags, flag-after-path ordering, and the "-- --ours" literal-path edge. New
behaviour stays gated behind gvfs.auto-sparse-index (default false).

Empirical measurements and the git-layer end-to-end proof are recorded in the
project's ADR 0012 and benchmarks/raw/w7-ooc.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
gvfs clone --sparse-index previously only wrote git config; the enlistment
still came up with a full index at its first mount, so a user had to pay the
one-time collapse of a large full index later. That collapse is cheap only
when the index has a valid cache-tree; after any index-mutating command it is
much slower, and undoing a hydrated full enlistment is more expensive still.

Build the sparse index during clone instead. After the full index is written
by ForceCheckout, and gated on --sparse-index, collapse it in place so the
enlistment is already sparse before its first mount:

  1. Build the minimal cone (root files only) from an empty modified-path set
     with the existing ConeBuilder, and write info/sparse-checkout in git's
     cone format with ConeFileWriter.
  2. Set core.sparseCheckout=true. The three sparse-index keys are already
     written by TrySetRequiredGitConfigSettings when --sparse-index is set.
  3. Collapse the full index with 'update-index --force-write-index' and the
     GVFS hooks disabled (no mount is running yet). The fresh index has
     skip-worktree on every entry and a valid cache-tree, so git collapses the
     out-of-cone directories without walking the working tree.

This runs before the ProjFS provider is registered, so it uses the unmounted
incantation: core.virtualfilesystem and core.hookspath are cleared, and
sparse.expectFilesOutsideOfPatterns=true keeps skip-worktree on the present
files so the collapse is not undone. It is the mirror of ForceExpandSparseIndex.

All of this stays behind gvfs.auto-sparse-index (default off). Any failure in
the construction fails the clone loudly; the index remains a valid full index,
so there is no data-loss risk. Adds two unit tests for the collapse command.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Prove 'gvfs clone --sparse-index' constructs a sparse index during the
clone, so the enlistment is already sparse before its first mount, with
no post-hoc collapse.

The functional-test harness already provisions enlistments by running a
real 'gvfs clone'. Extend that path with an optional --sparse-index and
--no-mount, then add a test that clones, reads the on-disk index before
the first mount, mounts, and asserts the mount projects the complete
HEAD tree from the collapsed index.

- GVFSProcess.Clone: add a sparseIndex/noMount overload. The old
  three-argument signature delegates to it, so every existing caller is
  unchanged.
- GVFSFunctionalTestEnlistment: add CloneNoMount, which clones without
  mounting so the caller can inspect the on-disk index before the first
  mount and time the first mount itself.
- CloneSparseIndexTests: an end-to-end test plus a plain-clone control.
  It reads the DIRC header and 'git ls-files --sparse' directly, never
  plain 'git ls-files', which forces the sparse index to expand. It
  asserts the pre-mount index is sparse, the required config is set, the
  first mount reaches and stays Ready, the projected working tree equals
  'git ls-tree -r HEAD' by set equality, and a file inside a collapsed
  directory reads with the committed bytes.

Live result against the test remote: the --sparse-index clone produced a
31-entry index before the first mount versus 864 for a plain clone, its
first mount projected all 864 files with none missing or extra, and it
reached Ready. Unit suite 1139 passed, 0 failed.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
The sparse-index collapse in CloneVerb runs after ForceCheckout, not
before. Explain why in a comment so a future reader does not "optimize"
by moving cone and sparse-checkout config ahead of the checkout: doing so
leaves core.virtualfilesystem active during checkout, apply_virtualfilesystem
re-expands the index in-process, and the resulting full index can no longer
be collapsed. The clean-checkout-then-collapse order is required, not
incidental.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
An earlier comment stated that setting the cone and core.sparseCheckout
before ForceCheckout can never produce a sparse index. That is true only
for a git whose virtualfilesystem force-expands a collapsed index. With a
sparse-index-aware git, checkout writes a sparse index directly. Restate
the comment accurately: checkout-then-collapse is kept because it is robust
across git versions (it yields a sparse index with both), not because
direct construction is impossible. A direct mechanism test confirms the
two-git behavior; see decisions/0015.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
The pre-command hook parses a git command line, extracts the paths it names,
and sends a ConeWiden request to the mount before git runs (ConeNarrow after).
Nothing on the mount answered those requests, so the hook sent widen requests
into the void and the cone builder was never called. This adds the missing
mount-side handler and wires it into the request dispatch.

On widen, the handler resolves the requested pathspecs to repository paths, adds
them as transient entries, recomputes the cone as the union of the modified-path
entries plus all live transient entries, writes it, collapses the on-disk index
in place, reparses the projection, and only then replies so the hook can let git
proceed. On narrow, it drops the session's transient entries and recomputes.

The collapse uses update-index --force-write-index, never sparse-checkout
reapply, which walks the working tree and fails on a live ProjFS mount. It runs
with the virtual filesystem on, the hooks path at its default, and the
read-object hook on; disabling any of the three fails the in-process index write
against the live provider. Recursion is prevented by disabling only the
pre-command hook (COMMAND_HOOK_LOCK), so the collapse git cannot re-enter the
mount pipe.

The handler holds no GVFS lock. The triggering git command already holds it
through the pre-command hook, so acquiring it here would deadlock; the held lock
also satisfies the mount's index-rename gate, which is why the collapse can
write the index. Cone operations are serialized by a process-internal lock.

Everything is behind gvfs.auto-sparse-index (default false); with the flag off
the handler replies NotEnabled and behaviour is unchanged. Every failure is
fail-open: the previous sparse-checkout file is restored and git proceeds,
transiently expanding and re-collapsing the index.

Adds ConePathspecResolver and TransientConeState with unit tests, and a
functional test that widens for an out-of-cone git add and asserts the index
stays partially expanded.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
When gvfs.auto-sparse-index is on, GVFS owns .git/info/sparse-checkout.
A user who runs git's own sparse-checkout tooling, or edits the file by
hand, hits silent overwrites, a full-index degrade, or cryptic failures.

Hook-side guard: extend CheckForLegalCommands to block the mutating
sparse-checkout subcommands (set, add, reapply, init, disable) and redirect
to gvfs sparse-index, naming gvfs sparse-index --disable for disable. Read
the subcommand from GitPathspecParser so global options before the
subcommand resolve correctly. Allow the read-only and working-tree
subcommands (list, check-rules, clean) and any unknown/future subcommand.
Block only when gvfs.auto-sparse-index is true, read once inside the
sparse-checkout case to protect the per-command hook budget. With the flag
off, git sparse-checkout behaves exactly as before.

Mount-side drift detection: at cone recompute, compare the on-disk cone
against what GVFS last wrote with a cheap ordinal compare of the small file.
On drift, warn and overwrite, because GVFS owns the file. Detect the
non-cone-format hand-edit that silently disables the sparse index via git's
is_sparse_index_allowed(), and warn about that cause specifically.

Internal GVFS git calls are not blocked: git skips the pre-command hook when
COMMAND_HOOK_LOCK is set (usePreCommandHook: false), and the mount-side
collapse invokes update-index, not sparse-checkout.

Adds 45 unit tests and one functional test proving set is refused with the
feature on and runs with it off. Full unit suite: 1207 passed, 0 failed.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
The automatic sparse-index cone-widen hook waited 250 ms for the mount's
reply and discarded the result. The mount reparsed the projection
synchronously before replying, putting the O(repo) reparse on the reply
path, so at large-repo scale the reply took seconds and the hook always
timed out -- Git then ran against a not-yet-confirmed cone. The index
collapse alone (~257 ms of the 272.8 ms reply, 95%) also exceeded the old
budget even at small scale, so the "block Git until applied" guarantee was
soft.

The 250 ms budget was modeled on the cached-hydration-status display, a
cosmetic feature whose timeout is genuinely benign; applying it to this
functional feature (whose timeout costs a full index expansion) was the
error, and it also dropped that precedent's result check.

Decouple the reparse from the reply path: the handler now triggers an
asynchronous projection invalidate (RequestIndexProjectionUpdate) instead
of blocking on ForceIndexProjectionUpdate. Widening changes only which
entries the index carries, never what ProjFS projects (GVFS projects the
full HEAD tree regardless of cone), so the reply is correct as soon as the
on-disk index is widened; the background parse thread reconciles
GVFS-internal state. This keeps the bounded hook wait covering only the
O(cone) index collapse at every scale.

Raise the widen wait budget 250 -> 1000 ms (Task.Wait returns as soon as
the reply arrives, so fast widens pay nothing), capture the previously
discarded wait outcome (restoring the fuller result check from the
hydration-status precedent), and emit an over-budget warning so a breach is
visible in the mount log rather than silent. Scope the warning to the
pre-command widen only; the post-command narrow's budget breach is benign,
so narrow keeps its 100 ms budget.

Add phase timing (cone build / write / collapse) to the mount log.
Constants moved to GVFSConstants.ConeManagement so the hook and mount
share one budget. All behind gvfs.auto-sparse-index (default false).

Unit suite 1162/1162/0; functional test
WidenHandlerCoversOutOfConePathsWithoutFullyExpandingTheIndex passes.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
gvfs clone --sparse-index now fails fast when the configured git cannot
produce a sparse index, and constructs the sparse index directly during
checkout instead of writing a full index and collapsing it afterward.

Capability gate:
- Add GitProcess.SupportsVfsSparseIndex / HasVfsSparseIndexCapability.
  They probe 'git version --build-options' for the exact line
  'feature: vfs-sparse-index'. Stock git omits it; a git build with the
  VFS sparse-index changes emits it. The parser is separated from the
  spawn so it is unit-testable without a git binary. The same helper is
  shared so 'gvfs clone --sparse-index' and a future
  'gvfs sparse-index --enable' do not diverge.
- CloneVerb.Execute probes the enlistment's configured git before any
  network work and exits with a clear message when the capability is
  absent, instead of silently producing a full index.

Direct sparse construction (reorder):
- Write the minimal cone (/*, !/*/) and enable cone-mode sparse checkout
  before ForceCheckout, so a capable git materializes only in-cone paths
  and writes a sparse index directly. This removes the full-index write
  and the post-hoc collapse.
- Replace TryConstructSparseIndex (checkout-then-collapse) with
  TryWriteInitialSparseCone (pre-checkout cone write) plus an
  observe-only LogClonedSparseIndex that records the index and warns,
  never fails or rewrites. The capability gate guarantees a capable git
  reaches the checkout, so the collapse step is dead code and is removed.
  GitProcess.CollapseSparseIndex is retained for reuse elsewhere.

Tests:
- Unit: 5 parser tests for HasVfsSparseIndexCapability (present, CRLF,
  absent, near-miss, null/empty).
- Functional: the sparse-construction test asserts a sparse on-disk
  index before first mount and a complete projection; it Assert.Ignores
  when the configured git lacks the capability. A new fail-fast test
  asserts the clone is rejected on an incapable git. The control (plain
  clone) is unchanged.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
The pre-command hook sent a cone-widen request on every command that named a
path, then waited for the reply. Some of those widens cannot help, so the
round-trip and the wait were pure cost. Decide locally, before any IPC, when the
widen is pointless and skip it. Two exclusions, both provable from existing
measurements:

1. Paths already in the cone. The widen exists to cover out-of-cone paths; a
   command naming only in-cone paths makes the mount rebuild an identical cone
   and reply "no change". The new GVFS.Common.Sparse.ConeCoverage helper lets the
   hook reach the same answer from the on-disk sparse-checkout file, using the
   same ConePathspecResolver and cone semantics the mount uses. Because the file
   already equals Serialize(ConeBuilder(existing)) and ConeBuilder is
   deterministic, "all named paths covered" locally is observably identical to a
   no-op reply, so the skip is provably safe. Any case the hook cannot decide
   (non-cone or hand-edited file, pathspec-from-file, -C/--git-dir/--work-tree, a
   linked worktree, a missing file, or any exception) defers to the mount. This
   is applied to the widen only: the cone can change between the separate pre- and
   post-command hook processes, so re-deriving a narrow-skip from the current file
   could leak a transient session.

2. stash. builtin/stash.c expands the index unconditionally when a pathspec is
   present, so widening cannot prevent the expansion, and it was measured
   net-negative (it un-hides the skip-worktree change and gives Git more work).
   The parser already identifies the verb, so stash is skipped on both the widen
   and the narrow.

A per-command allowlist of expand-capable verbs was considered and rejected: the
in-cone check is path-based and therefore command-agnostic, which tracks Git's
actual conditional-expansion behaviour instead of a list that drifts as Git
changes.

A skipped widen was previously indistinguishable from a failed one. An opt-in
one-line stderr diagnostic, emitted only when GIT_TRACE is 1, 2, or true, now
separates a deliberate skip from a budget breach without adding IPC or tracer
setup to the fire-and-forget hook.

Measured: the local in-cone check is 0.3-0.7 ms at realistic modified-directory
counts (<= 1000 dirs), about 10-20x cheaper than the ~5.9 ms warm no-op
round-trip it replaces, and it never touches a busy mount. The cone is
O(modified dirs), not O(repo), so the check stays sub-millisecond at large-repo
scale while the round-trip it replaces can block up to the full wait budget on a
mount busy servicing a prior widen's background reparse.

All behind gvfs.auto-sparse-index (default false). Full unit suite 1186 passed,
0 failed (+24 ConeCoverage tests). The out-of-cone widen functional test still
passes: it names out-of-cone paths, so neither exclusion fires and the full
widen still runs.

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

Assisted-by: Claude Opus 5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Assisted-by: Claude Opus 5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Assisted-by: Claude Opus 5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Assisted-by: Claude Opus 5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Assisted-by: Claude Opus 5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
The clone-time and cone-management branches each added a
CoreSparseCheckoutName constant to GitConfigSetting with the same value.
Git auto-merged both declarations without reporting a conflict, which
produced CS0102 (duplicate definition) at build time.

Keep one declaration and retain both explanatory comments: the sparse
index gating group documents why the setting exists, and the clone-time
note records that clone-time construction writes it while enable and
disable leave it untouched.

Assisted-by: Claude Opus 5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
ConeBuilder is size-unaware: a modified file always contributes a
parent-only ancestor chain. Where a directory is worked wholesale that
chain accumulates many patterns and is rewritten often, even though a
single recursive include would describe the same set of index entries.

Add a post-pass that replaces a parent-only sub-chain with one recursive
include at the shallowest ancestor that qualifies. A directory qualifies
on either gate:

  size + activity  subtree_files(D) <= 250 and modified_under(D) >= 8
  density          modified_under(D) / subtree_files(D) >= 0.5

Both gates are entry neutral by construction. They fire only where a
recursive include costs about the same index entries the parent-only
chain already carries, so index size is unchanged. The payoff is pattern
count and cone churn: up to 458x fewer patterns where a directory is
modified wholesale, and up to 3.5x fewer cone rewrites.

Measurement showed there is no case where recursing a whole top-level
directory wins, because every top-level directory holds tens to hundreds
of thousands of entries. The heuristic therefore only ever collapses at
a deep, small directory, and never at an ancestor of a directory that is
already recursive.

The heuristic needs subtree file counts, which only the mount has, so
ConeBuilder takes an IConeSubtreeFileCounter and stays pure and unit
testable. It needs only a bounded answer, not an exact count, so the
counter caps the walk: the size gate caps at the threshold, and the
density gate caps at modified/density, which is the largest subtree that
could still satisfy the ratio. Both are O(cap) per candidate regardless
of real subtree size.

ProjectionSubtreeFileCounter implements the counter over the in-memory
projection through TryGetProjectedItemsFromMemory, so it does no I/O,
fetches no objects, and never takes the projection write lock. A folder
it cannot resolve returns false, and the heuristic treats false as "do
not collapse", so an unresolvable path always yields the narrower cone.

Gated behind gvfs.sparse-index-cone-granularity, default false. With no
counter supplied the output is byte-for-byte the parent-only cone, which
a test asserts directly.

Assisted-by: Claude Opus 5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Implement the cone granularity heuristic (W12), opt-in and default off
Integrate all sparse-index workstreams into feature/sparse-index
Two independent fixes found while preparing an end-to-end validation run.

Native projects pinned PlatformToolset to v143 in an unconditional
PropertyGroup, so the five C++ projects cannot build on a machine that
has a newer Visual Studio. Visual Studio 2026 ships v145 and no longer
provides v143, so Build.bat fails at the first native project with
MSB8020. An unconditional PropertyGroup also outranks an environment
variable, so there was no way to override it without editing the file;
Build.bat passes no /p:PlatformToolset either.

Guard the assignment with a condition. The default stays v143, so every
existing machine and the build pipeline are unaffected, but a newer
toolset can now be selected without editing project files:

    $env:PlatformToolset = 'v145'
    src\scripts\Build.bat Debug <version>

The benchmark harness supported sixteen operations but not revert, even
though revert is a routine developer operation and appears in about 5%
of real pull requests in a large repository. Revert is also interesting
for the sparse index specifically: it names the paths of a commit that
is already in history, so it can reach outside the cone in ways a
cherry-pick of a foreign commit does not.

Add revert alongside cherry-pick. Its cleanup must be checked before the
cherry-pick cleanup, because both operations use .git/sequencer and an
in-progress revert would otherwise be aborted with the wrong verb.

Assisted-by: Claude Opus 5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
RunFunctionalTests-Dev.ps1 put a hard-coded "C:\Program Files\Git\cmd"
on PATH. That path is only the default, and it is not stable: the Git
for Windows installer relocates an existing installation when it is
pointed at a different directory rather than installing alongside it.
After such a move the hard-coded directory no longer exists, PATH gains
a dead entry, and the script fails its own git prerequisite check even
though git is installed and working.

Resolve the installation the same way the product does, from the
GitForWindows registry key that WindowsGitInstallation already reads,
and fall back to the previous default when the key is absent. When
neither resolves, leave PATH alone so the existing prerequisite check
reports the problem instead of masking it with a dead entry.

Assisted-by: Claude Opus 5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Settings.cs hard-coded C:\Program Files\Git for PathToGit and PathToBash,
the same assumption the dev test script made. The Git for Windows installer
relocates an existing installation rather than installing alongside it, so
after such a move every functional test fails on a missing git rather than
on the behavior under test.

Resolve the install root from the GitForWindows registry key, exactly as
WindowsGitInstallation does, and fall back to the previous default when the
key is absent or unreadable.

Assisted-by: Claude Opus 5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Allow the native PlatformToolset to be overridden, and benchmark revert
GitProcessTests hardcoded C:\Program Files\Git for one case. On a
machine where git is installed anywhere else the test called
Assert.Ignore and reported success, so it had stopped checking anything.
Resolve the path the same way the product does instead.

Assisted-by: Claude Opus 5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
A sparse clone writes a 66-entry index and never descends into the
collapsed trees, so the first mount has to read roughly 500,000 tree
objects cold to build its projection. Measured on a 2.4 million entry
repository that cost 30.5s against 21.1s for a full-index clone.

The work is not new, only relocated: a full-index clone reads exactly
those trees inside checkout, because writing 2,245,934 index entries
requires them. Write the same index during clone and the first mount can
parse it instead of walking the trees again.

Add GitProcess.WriteProjectionSeedIndex, which runs read-tree against
GIT_INDEX_FILE so git reads and writes the seed rather than the
repository index. --index-output is not sufficient: it redirects only
the write, and git still takes .git/index.lock, which fails the
concurrent checkout with "Unable to create index.lock: File exists".
read-tree writes no working-tree files, so there is no working-tree
contention either, and the seed runs alongside the checkout on its own
GitProcess instance. A shared instance cannot be used: GitProcess keeps
the running child in an instance field, so concurrent invocations
overwrite each other and one reads the other's streams.

The seed carries no skip-worktree bits, which is expected rather than a
defect. Under a virtual filesystem those bits are not stored state:
apply_virtualfilesystem() recomputes them on every index read by setting
CE_SKIP_WORKTREE on every entry and clearing it only for the paths the
virtual-filesystem hook reports as present. The parser performs that
same computation when it consumes the seed, using the modified-paths
database as the set of present paths, so the projection it produces
matches one built from an index git wrote. The comparison runs on the
raw path buffer rather than decoding a string per entry, which would
allocate once for every entry in a multi-million entry index.

The seed is consumed at most once and deleted whether or not the parse
succeeds, so a stale or partial seed can never affect a later mount, and
every failure falls back to building the projection from the trees.

Measured on a 2.4 million entry repository, two samples per arm:

  clone + first mount   full index 42.8s   sparse 43.5s   (+0.7s)

before this change the same comparison was +3.4s, and first mount alone
went from +9.4s to +0.33s. The projection built from a seed matches
git ls-tree HEAD exactly at every level checked.

Assisted-by: Claude Opus 5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Seed the first projection from an index written during clone
The seed runs read-tree with no git-level timeout, and clone waited on it
with an unconditional GetResult(). A read-tree that never finished would
therefore hang clone itself. An unbounded wait is defensible for work
clone cannot proceed without, but the seed is only an optimization, so
this turned a speedup into a new way for clone to fail.

Wait at most five minutes, then abandon the seed and let the first mount
build its projection from the trees, which is the behavior without a
seed. Measured seed cost on a 2.4 million entry repository is about ten
seconds, so the bound only engages when something is wrong.

Abandoning is safe because git writes the index to a lock file and
renames it into place: the mount finds either a complete seed or none. It
never finds a truncated one, which matters because the index parser
trusts the entry count in the header and reads recycled page bytes rather
than reporting an error when the file is short. A seed that arrives after
clone stopped waiting is still complete and still describes the same
HEAD, so it is correct whether or not the mount consumes it.

Also delete the lock file when discarding a seed. An abandoned or failed
seed leaves it behind, and while nothing reads it, it would block a later
seed write.

Assisted-by: Claude Opus 5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Bound the clone-time wait for the projection seed
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