Skip to content

Release M275#1988

Merged
tyrielv merged 37 commits into
releases/shippedfrom
milestones/m275
May 27, 2026
Merged

Release M275#1988
tyrielv merged 37 commits into
releases/shippedfrom
milestones/m275

Conversation

@tyrielv
Copy link
Copy Markdown
Contributor

@tyrielv tyrielv commented May 26, 2026

Changes:

erickulcyk and others added 30 commits May 11, 2026 14:13
Previously, FastFetch detected case-only renames (e.g. TestFolder -> testfolder)
but suppressed them with a warning, leaving the working directory with stale
casing. This change makes FastFetch propagate case renames to disk, matching
the behavior of regular git checkout.

Changes:
- DiffTreeResult: Add SourcePath property to carry old-cased path for
  directory case renames
- DiffHelper: Detect case-only renames and flow them through the pipeline
  instead of suppressing. Track case-renamed directories to properly filter
  child operations in FlushStagedQueues
- CheckoutStage: Perform two-step directory rename (old -> temp -> new) to
  work around Directory.Move() failing on case-only renames on Windows
- Update unit tests to verify SourcePath, delete counts, and directory
  operation filtering for case renames
- Update functional test to verify casing with ignoreCase: false

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Context:
EnqueueOperationsFromDiffTreeLine handles the case where adding a new
DiffTreeResult to stagedDirectoryOperations collides with an already-
staged entry under the case-insensitive DiffTreeByNameComparer. When
the collision happens, the code needs to find the existing staged
entry (by case-insensitive path equality) so it can capture the old
casing for a directory case rename.

Today the Modify/Add branch is the only collision branch that performs
that lookup, and the lookup is written inline as a foreach scan over
the staged set. A symmetric Delete-after-Add ordering also needs the
same lookup, so the inline pattern is about to be duplicated.

Justification:
Extracting the lookup before introducing the second caller keeps the
upcoming bugfix focused on the behavior change. The helper has a
single responsibility — find a staged DiffTreeResult by case-
insensitive TargetPath — which is meaningful on its own and easy to
test in isolation if needed.

Mutating an item already in the HashSet remains safe because
DiffTreeByNameComparer keys on TargetPath only; SourcePath (the field
the callers will mutate next) is not part of the hash or equality.

Implementation:
Adds private FindStagedDirectoryOperation(string targetPath) which
returns the first staged DiffTreeResult whose TargetPath matches under
GVFSPlatform.Instance.Constants.PathComparison, or null if none. The
existing inline foreach in the Modify/Add collision branch is replaced
with a call to this helper. No observable behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Context:
Branch eric-pr added handling for case-only directory renames in
FastFetch on Windows: when diff-tree reports a Delete and an Add for
the same case-insensitive path, DiffHelper collapses the pair into a
single Add carrying the old-cased path on SourcePath, and CheckoutStage
performs a two-step Directory.Move to apply the casing change on disk.

That logic lives in the Modify/Add collision branch of
EnqueueOperationsFromDiffTreeLine: when an Add arrives and finds a
Delete already staged for the same path, it captures the Delete's
TargetPath onto SourcePath. The symmetric Delete branch was a no-op
with a comment claiming "diff-tree outputs Deletes before Adds".

That assumption is wrong. git diff-tree compares tree entries by byte
order of the path name, so for a case-only rename the Add or Delete
can appear first depending on which casing sorts lower. For example
"GVFLT_*" -> "GVFlt_*" emits the Delete (uppercase 'L', 0x4C) before
the Add (lowercase 'l', 0x6C), but the reverse rename "GVFlt_*" ->
"GVFLT_*" emits the Add first. In the latter case the Delete branch
fires, the staged Add is left without a SourcePath, and CheckoutStage
falls back to plain Directory.CreateDirectory — the original-cased
directory is never renamed and remains on disk.

Justification:
Two approaches were considered: (a) buffer the entire diff-tree
output and sort it before parsing, forcing a stable ordering invariant;
(b) make the staging code order-independent so it handles either
arrival order correctly.

Option (a) regresses memory: FastFetch streams diff-tree line-by-line
today and is run against multi-million-file repos, so buffering the
whole output is meaningful. Option (a) also relies on a git emit-order
invariant that git does not document — making the C# layer robust is
the right place to defend against future diff-tree changes.

Option (b) is the smaller, more localized fix and is implemented here.

Implementation:
EnqueueOperationsFromDiffTreeLine's Delete-collision branch now mirrors
the Modify/Add branch: if a HashSet collision under
DiffTreeByNameComparer indicates an Add was already staged for the
same case-insensitive path, find that Add via
FindStagedDirectoryOperation and — when the existing TargetPath
differs from the incoming TargetPath under ordinal comparison —
annotate the staged Add's SourcePath with the old (incoming Delete's)
casing, and register the old-cased path in caseRenamedDirectoryDeletes
so FlushStagedQueues can filter children of the renamed directory.

The staged entry is mutated in place. This is safe because
DiffTreeByNameComparer hashes and compares only on TargetPath; the
SourcePath field is a runtime annotation that the comparer does not
see, so mutation does not corrupt the HashSet.

A new fixture caseChangeAddFirst.txt exercises the reverse-direction
rename ("GVFlt_*" -> "GVFLT_*") with the Add lines emitted before the
Delete lines, and the matching test ParsesCaseChangesWhenAddPrecedesDelete
asserts the staged directory operation carries the correct SourcePath
and that child operations are filtered exactly as in the Delete-first
case. Without this fix, the test fails because SourcePath is null.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Context:
CheckoutStage performs case-only directory renames via a two-step
Directory.Move: source -> temp (a sibling of the target named with
a random GUID suffix), then temp -> target. The two-step is required
on Windows because Directory.Move throws IOException when the source
and destination differ only in case.

If the first move succeeds but the second fails — for example because
an antivirus or indexer has opened a handle on the moved tree, the
target path has unexpected ACLs, or transient I/O fails the second
call — the existing catch handler logs the error, sets HasFailures,
and leaves the directory stranded at the temp path. A subsequent
FastFetch run sees no source at the original casing and falls through
to Directory.CreateDirectory on the target path. The end state is an
empty target directory next to a "*_caseRename_<guid>" directory
holding the user's actual content, with no automatic recovery.

Justification:
The cleanest recovery is to undo the half-applied rename: if the
second move throws, attempt to move the tree back from the temp path
to the original source casing. A subsequent run then sees the same
state as before the failed attempt and can retry deterministically.

The restoration attempt itself can fail (same underlying I/O issue
that broke the second move) so it is best-effort and swallows
exceptions, leaving the outer catch handler to log the original
failure. Compared to alternatives — staging the rename under .git/
instead of as a sibling, or running a cleanup pass for stranded temp
directories on startup — this is the smallest change that addresses
the data-loss path without introducing new state.

Implementation:
The two Directory.Move calls are wrapped so that a failure of the
second Move triggers a best-effort Directory.Move(tempPath,
trimmedSourcePath). Guarded by Directory.Exists checks so we never
clobber a partially-restored source. After the recovery attempt, the
original exception is re-thrown so the outer catch logs the actual
failure (not a synthetic restore-success). The trimmed source and
target paths are now hoisted to locals to keep the inner block
readable.

No behavior change on the success path or for non-rename Adds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Context:
EnqueueFileAddOperation iterates stagedFileDeletes to decide whether
a staged delete with a matching path differs from the incoming add
only in case. The loop captures the matched delete path into a local
named existingDeletePath, but nothing after the loop reads that local
— only exactMatch is consumed.

Justification:
Dead state is misleading: a future reader expects an unused capture
to be the seed for a planned use (logging the old casing, returning
it from a helper) and may build new code around it. Removing the
local makes the loop's actual contract — set exactMatch, then break —
self-evident.

Implementation:
Drops the existingDeletePath declaration and the one assignment that
populated it. No behavior change; tests continue to pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Context:
DiffHelper accumulates diff results into internal staged sets
(stagedDirectoryOperations, stagedFileDeletes, filesAdded, and the
new caseRenamedDirectoryDeletes) and into four public output
collections: DirectoryOperations, FileDeleteOperations,
FileAddOperations, and RequiredBlobs.

FlushStagedQueues calls RequiredBlobs.CompleteAdding() at the end of
each diff, which permanently closes the BlockingCollection. A second
call to PerformDiff or ParseDiffFile on the same instance would also
flow values into the now-frozen internal staged sets, which were
never cleared between runs. caseRenamedDirectoryDeletes in particular
would carry forward old-cased paths from the first diff and
mis-filter children in the second.

Today every caller in the tree constructs a fresh DiffHelper per
diff, so the bug is latent. But the class never declared this
constraint, the field initializers and the BlockingCollection
together silently assume single-use, and a future caller could
re-enter the methods expecting a usable result.

Justification:
Two approaches were considered: (a) reset internal staged sets at
the start of each call and reinstantiate the output collections, or
(b) detect reuse and throw at the entry points.

Option (a) would invalidate any external code holding references to
the output collections (they are exposed as public properties and
consumed concurrently by other prefetch stages), so it would silently
change semantics for anyone observing the queues across the boundary.
Option (b) preserves the existing single-use intent, surfaces misuse
loudly and early instead of via an obscure BlockingCollection
exception deep in parsing, and requires no behavior change for
current callers.

Implementation:
Adds a diffPerformed bool, set at the start of EnsureSingleUse — the
new private helper called at the top of PerformDiff(string, string)
and ParseDiffFile. A second invocation throws InvalidOperationException
with a message that names the class and suggests constructing a new
instance. The one-arg PerformDiff overload delegates to the two-arg
overload and so is covered transitively.

A new unit test DiffHelperThrowsOnReuse calls ParseDiffFile twice on
the same instance and asserts the second call throws.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Context:
The internal HashSet introduced for case-rename support was named
caseRenamedDirectoryDeletes, but it does not store Delete operations
or paths of directories slated for deletion. It stores the old-cased
TargetPath of each directory whose Delete entry was collapsed into a
case-rename Add — paths that FlushStagedQueues uses to recognize and
filter out child operations that would otherwise look like they
belong to a deleted directory.

The mismatch between the name and the contents made the two read
sites — FlushStagedQueues's parent-path check and the Modify/Add and
Delete collision branches — harder to follow on first reading. The
field is also referenced in the comment chain of two new commits, so
readers chasing context bounce between "deletes" in the name and
"replaced by a rename" in the comments.

Justification:
Renaming clarifies what the set represents at every call site. The
new name "directoriesReplacedByCaseRename" reads as a description of
its contents and aligns with the comment that already explains it.
The XML-comment block above the field is also expanded so the
contract — *old-cased paths, used to suppress child operations* —
is documented in one place instead of being implied across three
unrelated branches.

Implementation:
Mechanical rename of all three references (the field declaration,
the UnionWith call in FlushStagedQueues, the Add calls in the
Modify/Add and Delete collision branches). No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Context:
DiffTreeResult.SourcePath was introduced as a parser annotation:
DiffHelper sets it when it collapses a Delete+Add pair under the
case-insensitive comparer into a single case-rename Add, and
CheckoutStage reads it to perform the rename on disk. The setter
was declared public, which exposed a runtime-only field as part of
the type's external contract.

Public mutability invites future code outside DiffHelper to set the
field on operations the parser would never have annotated — for
example on a Delete or a Modify entry, or on a file operation — and
CheckoutStage's branches would then behave in ways that were never
validated. The XML doc previously said only "Used on case-
insensitive file systems to carry the old-cased path for directory
renames," which did not communicate the intended single-producer
contract.

Justification:
Restricting the setter to the assembly keeps the producer
(DiffHelper) able to write it while ruling out external producers.
The getter stays public because consumers in other assemblies
(CheckoutStage in FastFetch, the existing DiffHelperTests
assertions) need to read it. The single-producer rule is also
documented in an expanded XML comment that names the parser and
describes when the field is null versus non-null.

Implementation:
Changes "public string SourcePath { get; set; }" to
"public string SourcePath { get; internal set; }" and rewrites the
XML doc to describe the contract: who sets it, who reads it, when
it is null. No build-time consumer outside the assembly was writing
to it (only DiffHelper writes; CheckoutStage and the unit tests
read), so this is a non-breaking visibility tightening.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…fHelper

Context:
Case-rename detection needs both case-insensitive containment (do we
have *some* entry matching this path under PathComparer?) and
case-sensitive comparison (does the stored casing exactly match the
incoming path?). HashSet provides the first in O(1) but the second
required a foreach scan of the entire set because HashSet has no API
to retrieve the stored value from a case-insensitive lookup.

Three diff-tree code paths inherited that scan:

  - EnqueueFileAddOperation, scanning stagedFileDeletes when an add
    arrives whose path matches a staged delete case-insensitively
  - EnqueueFileDeleteOperation, scanning filesAdded for the same
    reason on the inverse direction
  - The Modify/Add and Delete collision branches in
    EnqueueOperationsFromDiffTreeLine, scanning stagedDirectoryOperations
    via FindStagedDirectoryOperation to recover the staged
    DiffTreeResult and its TargetPath casing

These scans are gated by a case-insensitive Contains check, so the
inner loop only runs when there is actually a collision — but on each
collision it walks every staged entry. The staged sets can reach
millions of paths on first checkouts of large repositories (the
Windows monorepo, Office, etc.), so a worst-case parse with many
collisions degrades from O(N) to O(N^2).

Justification:
Dictionary<string, T> keyed by GVFSPlatform.Instance.Constants.PathComparer
solves both needs at once: the comparer drives O(1) case-insensitive
key matching, and TryGetValue returns the originally stored value so
callers can compare its casing ordinally without iterating. The
storage shape lines up exactly with what the existing logic needs.

DiffTreeByNameComparer becomes unused after stagedDirectoryOperations
is rekeyed by TargetPath string, so it is removed in this commit
along with FindStagedDirectoryOperation (now a single TryGetValue
call). No public surface area changes.

Implementation:
  - filesAdded, stagedDirectoryOperations, and stagedFileDeletes are
    declared as Dictionary instances. The two string-valued
    dictionaries map a path under the case-insensitive comparer to
    its original casing; the directory-valued dictionary maps the
    case-insensitive path to its staged DiffTreeResult.
  - HashSet.Add returning false becomes Dictionary.TryAdd returning
    false at every call site. The Remove+Add idiom used to "replace"
    a staged entry becomes a single indexer assignment.
  - Two foreach scans collapse into TryGetValue + ordinal compare;
    the collision branches in EnqueueOperationsFromDiffTreeLine
    consult the dictionary directly via the indexer because the
    immediately preceding TryAdd has already proven the key is
    present.
  - FlushStagedQueues iterates .Values for both stagedDirectoryOperations
    and stagedFileDeletes.
  - The dead helper FindStagedDirectoryOperation and the
    DiffTreeByNameComparer inner class are deleted.

All tests continue to pass; no behavior change is intended beyond
the performance characteristic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Context:
The original case-rename commit on this branch removed three
RelatedEvent(Warning, "CaseConflict", ...) calls from the diff-tree
parsing path. Those traces had two jobs: signaling that a case-only
rename had been observed, and signaling a true duplicate diff entry
for the same path. After removal, both outcomes were silent — case
renames now happened invisibly, and any genuine diff anomaly that
collapsed into a "case rename" was undetectable in logs.

Silent success is mildly annoying for an operator; silent anomaly is
a debuggability hole, because the same code path now handles two
distinct conditions and the original logs no longer distinguish them.

Justification:
The two conditions deserve different log levels: a case rename is
expected on case-insensitive filesystems and should be Informational
so the operator can count them per fetch without alarm; a true
duplicate (incoming and staged paths match ordinally) is rare and
suggests a malformed diff stream, so it should be Warning so it
surfaces in normal triage.

The original "CaseConflict" event name conflated both. Replacing it
with two distinct events ("CaseRename" and "DuplicateDiffEntry")
gives each its own grep target without breaking the surviving
ls-tree path's "CaseConflict" event (which keeps its original
meaning — two sibling entries in the same tree differing only in
case).

Implementation:
Two small static helpers — TraceCaseRename and TraceDuplicate —
build EventMetadata with the kind ("File" or "Directory"), the
operation, and the old/new paths or the conflicting path. Each
collision branch in EnqueueOperationsFromDiffTreeLine and the
case-rename branches in EnqueueFileAddOperation /
EnqueueFileDeleteOperation now call one of the helpers depending
on whether the comparison is ordinally equal or not.

No behavior change beyond observability: the staged data structures,
the FlushStagedQueues output, and the existing unit tests are
unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…yRename

Context:
HandleAllDirectoryOperations dequeues directory operations from the
diff and dispatches them by Operation kind. The Modify/Add arm grew
to a ~50-line inline body covering three subcases: a case-only
rename (two-step Directory.Move through a temp path, plus rollback
if the second move fails), a fallback for missing source (a parent
rename already moved this child), and the plain Add path.

That body sits inside a try/catch that also handles the plain Add,
and the catch block walks the same treeOp.SourcePath branches to
shape its EventMetadata. As the rename logic grew (rollback on
failure, separate trimmed-path locals, nested try/catch), the
overall switch arm became hard to scan: a reader has to mentally
peel four levels of nesting to find the simple "is this an Add or
a rename?" decision.

Justification:
The rename has a clear contract — given a DiffTreeResult with
SourcePath set, apply it on disk — that fits naturally into a
private method. Extracting it leaves the switch arm at one level of
nesting and keeps the catch block at the call site so its
EventMetadata shaping does not duplicate inside the helper.

The behavior of the existing branches is preserved exactly,
including the rollback-then-rethrow path so the outer catch still
sees the original exception (not a synthesized restore failure).

Implementation:
ApplyCaseOnlyDirectoryRename takes the DiffTreeResult and the
already-computed absoluteTargetPath. It returns early after a
Directory.CreateDirectory fallback when the source is missing, and
otherwise performs the trimmed-path two-step Move with the inner
rollback try/catch. The caller's switch arm now reads as:

    if (treeOp.SourcePath != null)
    {
        this.ApplyCaseOnlyDirectoryRename(treeOp, absoluteTargetPath);
    }
    else
    {
        Directory.CreateDirectory(absoluteTargetPath);
    }

No tests were added; all 796 existing tests continue to pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Context:
Case-rename detection lives in three places in DiffHelper:
EnqueueOperationsFromDiffTreeLine's directory branches,
EnqueueFileAddOperation, and EnqueueFileDeleteOperation. The
existing fixtures (caseChange.txt, caseChangeAddFirst.txt) exercise
only the directory branches — their test files all sit inside a
case-renamed directory, so the file-level case-rename paths are
indirectly suppressed by the parent-directory filtering in
FlushStagedQueues.

That leaves the standalone "rename foo.txt to FOO.txt with no
directory casing changes" path completely uncovered. Both
EnqueueFileAddOperation's case-rename branch and
EnqueueFileDeleteOperation's case-rename branch ship without a
test that exercises them end-to-end.

Justification:
A two-line fixture isolates the file-level path: one Delete entry
for the old casing, one Add entry for the new casing, no directory
operations. The resulting test pins the contract that DiffHelper
both stages the delete (old casing, so the on-disk file is removed)
and stages the add (new casing, so the replacement file is
written), and that neither is filtered out as a duplicate.

Implementation:
New fixture Data/fileCaseChange.txt with a Delete for "foo.txt"
followed by an Add for "FOO.txt" (the typical diff-tree emit order
for that direction since "F" sorts before "f" byte-wise). New unit
test ParsesFileOnlyCaseRename asserts RequiredBlobs has one entry,
FileAddOperations and FileDeleteOperations each have one entry, and
the casings flow through correctly: the delete carries "foo.txt"
and the add carries "FOO.txt". The test is gated to case-insensitive
filesystems because on a case-sensitive filesystem the two paths
are distinct files and no case-rename detection runs (the existing
ParsesCaseChangesAsRenames test covers the case-sensitive
behavior). The csproj is updated so the fixture is copied to the
test output directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Context:
The existing case-rename fixtures change only the top-level
directory's casing. Their children sit inside the renamed parent but
retain the parent's old casing in the diff. Nothing in the existing
test suite exercises a diff where both an outer directory and an
inner directory change casing in the same operation.

That scenario is reachable in production whenever a contributor
renames an entire path's casing (e.g., "Docs/API/" to "docs/api/")
and is the most interesting interaction between the case-rename
detection in EnqueueOperationsFromDiffTreeLine and the parent-path
filter in FlushStagedQueues: the inner rename's parent path is in
directoriesReplacedByCaseRename, so its Add must be suppressed
because the outer rename's Directory.Move already carries the child
on disk. If that suppression breaks, FastFetch would attempt to
CreateDirectory the inner path after the outer rename has moved
the entire subtree — at best a no-op, at worst a layered race.

Justification:
A six-line fixture (three Deletes, three Adds) is the smallest input
that captures the nested case where both renames need to be detected
*and* the inner one needs to be filtered. Asserting all of
FileAddOperations, FileDeleteOperations, TotalFileDeletes,
TotalDirectoryOperations, the enqueued DirectoryOperations count,
and the SourcePath/TargetPath on the surviving op pins the contract
end-to-end.

Implementation:
New Data/nestedCaseChange.txt with a "Outer/Inner/test" source tree
and an "outer/inner/test" target tree. New unit test
ParsesNestedCaseChanges asserts:

  - One blob is required (the file under the renamed subtree)
  - FileAddOperations carries the new-cased path
  - The file delete is filtered out by parent-path matching
  - TotalDirectoryOperations is 2 (both case-renames are staged)
  - DirectoryOperations.Count is 1 (only the outermost survives
    the parent-path filter)
  - The surviving directory op carries the old casing on SourcePath

The test is gated to case-insensitive filesystems because on a
case-sensitive filesystem the two paths are independent and no
case-rename detection runs. The csproj is updated so the fixture is
copied to the test output directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address review of case-rename support: ordering, rollback, observability, performance, tests
Replace the LibGit2Sharp.NativeBinaries NuGet package (which shipped a
dynamic git2-*.dll) with a vcpkg-built static git2.lib that the NativeAOT
linker embeds directly into the executable. This eliminates libgit2
DLL-missing crashes we see in telemetry.

A side benefit of this approach is that we can apply patches to libgit2
that haven't been included in an official release yet, via the vcpkg
overlay port in overlays/libgit2/.

How it works:
  - vcpkg manifest (vcpkg.json) declares libgit2 as a dependency
  - Custom triplet (x64-windows-static-aot) builds with static CRT and
    library linkage, producing git2.lib instead of git2.dll
  - Overlay port (overlays/libgit2/) pins libgit2 v1.9.3 and can carry
    patches ahead of upstream releases
  - Directory.Build.props wires <DirectPInvoke> and <NativeLibrary>
    items so the NativeAOT linker resolves P/Invoke calls at link time
    and embeds git2 + transitive deps (pcre, zlib, http-parser)

To rebuild vcpkg libs locally:
  vcpkg install --triplet x64-windows-static-aot \
    --overlay-triplets=triplets --overlay-ports=overlays

Changes:
  - Directory.Build.props: DirectPInvoke + NativeLibrary for AOT link,
    unconditional libgit2_filename=git2 (was git2-a418d9d from NuGet)
  - Directory.Packages.props: remove LibGit2Sharp.NativeBinaries
  - GVFS.Common.csproj, GVFS.Hooks.csproj: remove NuGet package ref
  - Readme.md: reference THIRD-PARTY-NOTICES.md from license section
  - THIRD-PARTY-NOTICES.md: document libgit2 (GPLv2 + linking exception),
    SQLite (public domain), SQLitePCLRaw (Apache 2.0)
  - vcpkg.json, vcpkg-configuration.json: vcpkg manifest + baseline
  - triplets/x64-windows-static-aot.cmake: static linkage triplet
  - overlays/libgit2/: port overlay (v1.9.3) from devprod.razzletools
  - .gitignore: exclude vcpkg_installed/

License: libgit2's COPYING includes an explicit linking exception that
permits static linking without imposing GPL on the consuming program.

Assisted-by: Claude Opus 4.6
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Build script improvements:
- Add vcpkg install to Build.bat with vswhere-based vcpkg discovery
- Add _RestoreVcpkgDependencies MSBuild target (modeled after official
  vcpkg VcpkgInstallManifestDependencies) that auto-runs vcpkg install
  on first VS/dotnet build — uses stamp file for incrementality
- Scope vcpkg restore target to GVFS.Common to avoid parallel conflicts
- Move vcpkg output from src/vcpkg_installed/ to out/vcpkg_installed/
- Declare overlay-ports and overlay-triplets in vcpkg-configuration.json
- Restore explicit CI vcpkg step using preinstalled VCPKG_INSTALLATION_ROOT
- Exclude GVFS.MSBuild from libgit2 Content items (IncludeBuildOutput=false)
- Remove vcpkg_installed/ from .gitignore (no longer in git root)

Code fix:
- Update giterr_last P/Invoke to git_error_last (deprecated since libgit2 0.28)

Assisted-by: Claude Opus 4.6
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Statically link libgit2 into NativeAOT executables via vcpkg
The installer replaces GVFS.Service.exe in-place even during a staged
upgrade, so the running service is always the new version. Add a
PendingUpgradeMonitor that registers Process.Exited callbacks on
GVFS.Mount processes and applies the staged upgrade once all have
exited. This is event-driven (no polling) and works regardless of
which version of gvfs.exe triggered the unmount.

Previously only 'gvfs unmount' triggered the upgrade check (via
UnregisterRepoRequest). 'gvfs service --unmount-all' sets
SkipUnregister=true to preserve automount registration, so no message
reached the service and staged upgrades sat in PendingUpgrade\
indefinitely.

Changes:
- PendingUpgradeHandler: return UpgradeResult enum instead of void,
  add concurrency lock, add IsPending() helper
- PendingUpgradeMonitor: new class — monitors mount process exits,
  debounces (5s), retries on new mounts, self-stops on completion
- GVFSService: start monitor when startup upgrade is deferred,
  dispose on shutdown
- upgrade-tests.yaml: add unmount-all-triggers-upgrade CI scenario

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

Apply staged upgrade when mount processes exit
.NET 9+ introduced System.Threading.Lock as a dedicated, lightweight
synchronization primitive. Migrate all private lock fields from
`object` + `new object()` to `Lock` + `new Lock()`.

Also replace Monitor.IsEntered() assertions in GitStatusCache with
Lock.IsHeldByCurrentThread, since Lock is not compatible with Monitor
APIs.

20 fields across 19 files updated. No Monitor.Wait/Pulse usage found
in the codebase, so all instances are safe to migrate.

Assisted-by: Claude Opus 4.6
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Replace object locks with System.Threading.Lock
The fileHandle obtained from CreateFileW was never closed after
calling GetFinalPathNameByHandleW, leaking a kernel handle on
every invocation. Add CloseHandle(fileHandle) on both the success
path and the error path before die().

Assisted-by: Claude Opus 4.6
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
…river code

The ProjFS filter driver and native library are no longer bundled with
the GVFS installer — ProjFS is required as a Windows Optional Feature
(available since Windows 10 1809, our minimum supported OS).

Fix FilterAttach ACCESS_DENIED (bug 62349777):
- Add TryAttachToVolume() that tolerates ACCESS_DENIED when the ProjFS
  service is already running (the filter is already attached to the
  volume but the caller lacks SE_LOAD_DRIVER_PRIVILEGE).
- The service-side EnableAndAttachProjFSHandler.Run() now uses
  TryAttachToVolume instead of raw TryAttach, matching the tolerance
  already present in the client-side IsReady() path.
- Fix Run() to preserve the first error from TryEnablePrjFlt instead
  of potentially overwriting it with a subsequent attach error.

Remove dead non-inbox ProjFS code paths:
- Remove TryInstallProjFSViaINF (referenced {app}\Filter\prjflt.inf
  which is no longer shipped).
- Remove TryCopyNativeLibIfDriverVersionsMatch and supporting methods
  (referenced {app}\Filter\prjflt.sys and {app}\ProjFS\ProjectedFSLib.dll
  which are no longer shipped).
- Simplify TryEnableOrInstallDriver to always use the Windows Optional
  Feature path; keep build number logging as best-effort telemetry.
- Simplify IsNativeLibInstalled to require System32; warn if stale
  app-local DLL found from a legacy non-inbox install.

Improve error messaging:
- When ProjFS native library is missing, error now includes the
  PowerShell command to enable the optional feature.
- When ProjFS cannot be enabled, error directs user to the optional
  feature instead of referencing non-existent bundled files.

Assisted-by: Claude Opus 4.6
Signed-off-by: Tyler Vella <tyrielv@gmail.com>
Handle case-only file and directory renames in FastFetch on Windows
Add patch from libgit2/libgit2#7200 to vcpkg overlay. Allows
non-elevated processes run by Administrators group members to be
considered the owner of repositories owned by that group.

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

TryUpdateHooks (called at mount time) updated the 3 native hooks
(read-object, virtual-filesystem, post-index-change) but skipped the
pre-command.exe and post-command.exe copies of GitHooksLoader.exe.
Those were only deployed at clone time via InstallHooks, so upgrading
GVFS and remounting left stale loader binaries in the enlistment.

Refactor TryUpdateHook into an overload that takes explicit source and
target paths, then call it from TryUpdateHooks for both command hook
loaders using GitHooksLoader.exe as the source. The HookData overload
becomes a thin wrapper that delegates to the new one.

Add Assert-HookVersionsMatch to the staging-upgrade and clean-upgrade
test scenarios: after upgrade completes, remount and verify all 5 hook
binaries match the installed versions.

Assisted-by: Claude Opus 4.6
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Transient failures in actions/download-artifact are a frequent source of
flaky CI runs. Add a retry step after each download that triggers only
when the first attempt fails (continue-on-error + outcome check).

Applied to both functional-tests.yaml and upgrade-tests.yaml.

Assisted-by: Claude Opus 4.6
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
When GVFS.Mount.exe fails due to authentication (credential fetch
failure or 401/403 after credentialed retry), it now exits with
ReturnCode.AuthenticationError (9) instead of GenericError (3).

The gvfs.exe mount verb captures the GVFS.Mount.exe process handle
and propagates its exit code on mount failure, so callers of
'gvfs mount' can distinguish auth failures from other errors.

Changes:
- Add ReturnCode.AuthenticationError = 9
- Add 'out bool isAuthFailure' to TryInitializeAndQueryGVFSConfig
  to explicitly classify auth failures (credential fetch failure
  or 401/403 on credentialed retry)
- InProcessMount uses isAuthFailure to select the exit code
- StartBackgroundVFS4GProcess returns Process so MountVerb can
  read the mount process exit code after failure

Resolves AB#61375690

Assisted-by: Claude Opus 4.6
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
upgrade-tests: verify hook binary versions after upgrade+remount
Add non-elevated admin owner patch to libgit2 overlay
tyrielv added 7 commits May 26, 2026 07:34
…lpathname

Fix handle leak in GetFinalPathName
GVFS.Service runs as SYSTEM and cannot read the user's global git
config (where gvfs.telemetry-pipe is set) at startup.  This meant
TelemetryDaemonEventListener was never created, and all service
telemetry events (PendingUpgradeHandler, etc.) were silently lost.

Add two new classes in GVFS.Common.Tracing:

- BufferingTelemetryListener: an EventListener that buffers telemetry
  messages in a bounded ConcurrentQueue, then replays them to a real
  listener on demand.

- DeferredTelemetryAttacher: manages the lifecycle of deferred
  telemetry pipe attachment.  Adds a BufferingTelemetryListener to
  the tracer, then periodically retries creating the real daemon
  listener (exponential backoff: 10s, 30s, 1m, 5m steady state).
  On success, replays buffered messages and stops the timer.
  Checks HasTelemetryDaemonListener to prevent duplicate listeners
  when the JsonTracer constructor already attached one.
  Designed for reuse by both GVFS.Service and GVFS.Mount.

TelemetryDaemonEventListener gains a globalConfigPath parameter so
callers can read a specific .gitconfig file via --file instead of
--global.  This lets GVFSService read the logged-on user's config
without mutating the process-wide HOME environment variable.

GVFSService creates a DeferredTelemetryAttacher at startup and calls
TryAttach on session logon events, passing the user's .gitconfig
path resolved via RunImpersonated.

JsonTracer gains only HasTelemetryDaemonListener (one-liner property)
to support the duplicate-listener guard.

Assisted-by: Claude Opus 4.6
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Test BufferingTelemetryListener: buffer and replay, stop after
replay, bounded cap, second replay returns zero.

Test DeferredTelemetryAttacher: null gitBinRoot handling, retry
interval exponential backoff values.

Assisted-by: Claude Opus 4.6
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Add unique exit code for mount authentication failures
ProjFS: tolerate ACCESS_DENIED on FilterAttach, remove dead bundled-driver code
GVFS.Service: defer and retry telemetry pipe attachment
@tyrielv tyrielv merged commit 342205c into releases/shipped May 27, 2026
161 of 162 checks passed
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.

4 participants