Skip to content

Fix NullReferenceException when cloning into a non-empty directory - #2065

Open
tyrielv wants to merge 2 commits into
microsoft:masterfrom
tyrielv:tyrielv/fix-clone-nonempty-nre
Open

Fix NullReferenceException when cloning into a non-empty directory#2065
tyrielv wants to merge 2 commits into
microsoft:masterfrom
tyrielv:tyrielv/fix-clone-nonempty-nre

Conversation

@tyrielv

@tyrielv tyrielv commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Note

This PR is stacked on #2066 ("Share transient libgit2 config lookup helper"). #2066 must merge first; this branch will then be a clean single-commit diff against master.

This PR is a single commit332b4fc "Fix NullReferenceException when cloning into a non-empty directory" is this PR's entire contribution. Everything else visible in the diff (the LibGit2Repo.GetConfigBoolOrDefault helper this commit depends on) belongs to #2066 and is under review there.

Both are stabilization/bug-fix work for the current shippable line (targets master, not vnext), so I'd rather not block either on waiting for the other — happy to have them reviewed in parallel.

Summary

gvfs clone <url> <target> into a non-empty <target> directory throws an unhandled NullReferenceException instead of reporting the expected error:

Cannot clone @ <target>: System.NullReferenceException: Object reference not set to an instance of an object.
   at GVFS.CommandLine.CloneVerb.Execute() + 0x9fc

v1.0 behavior (expected):

Cannot clone @ <target>
Error: Clone directory '<target>' exists and is not empty

Root cause

CloneVerb.Execute() unconditionally read enlistment.WorkingDirectoryBackingRoot to determine trustPackIndexes, even when TryCreateEnlistment failed (e.g. because the target directory already exists and is not empty). In that failure path enlistment is null, so the dereference throws.

Fix

Extracted the lookup into internal bool GetTrustPackIndexes(ITracer, Result, GVFSEnlistment), which only touches enlistment when cloneResult.Success is true; otherwise it returns the default without dereferencing enlistment. Delegates to the shared LibGit2Repo.GetConfigBoolOrDefault helper added in #2066.

Testing

GVFS.UnitTests.CommandLine.CloneVerbTests:

  • TryCreateEnlistmentFailsWithoutEnlistmentWhenTargetDirectoryIsNotEmpty — confirms the failure precondition (null enlistment on non-empty target dir).
  • TryCreateEnlistmentDoesNotFailForEmptyTargetDirectory — boundary case: an empty target directory does not trigger the "exists and is not empty" error.
  • TryCreateEnlistmentReportsNormalizedPathWhenItDiffersFromFullPath — covers the divergent full-vs-normalized-path error message branch.
  • GetTrustPackIndexesDoesNotThrowWhenCloneFailedAndEnlistmentIsNull — the actual regression test: drives the exact failed-clone/null-enlistment composition that used to throw. Verified by temporarily removing the cloneResult.Success gate — the test failed with the original NullReferenceException, then passed again once the gate was restored.

Review

Reviewed with an internal 6-lens review-swarm pass. The main finding: an earlier draft of the regression test only proved TryCreateEnlistment's own contract (already true pre-fix) without exercising the actual Execute() null-dereference — addressed by the extraction and new test above.

Validation

Full unit test suite: 895 passed, 0 failed (11 pre-existing skips, unrelated to this change).

@tyrielv
tyrielv marked this pull request as ready for review July 15, 2026 18:37
@tyrielv
tyrielv enabled auto-merge July 15, 2026 18:37
@tyrielv
tyrielv force-pushed the tyrielv/fix-clone-nonempty-nre branch from 37377d1 to f4a4a25 Compare July 15, 2026 19:11
@tyrielv
tyrielv marked this pull request as draft July 15, 2026 19:55
auto-merge was automatically disabled July 15, 2026 19:55

Pull request was converted to draft

@tyrielv
tyrielv force-pushed the tyrielv/fix-clone-nonempty-nre branch from f4a4a25 to 72ce378 Compare August 7, 2026 22:19
@tyrielv
tyrielv marked this pull request as ready for review August 7, 2026 22:47
tyrielv added 2 commits August 7, 2026 15:51
Add LibGit2Repo.GetConfigBoolOrDefault(...) (instance + static overloads) for
one-off boolean config reads, replacing scattered short-lived
LibGit2Repo/LibGit2RepoInvoker usage at 4 call sites:

- GVFS/CommandLine/CloneVerb.cs (gvfs.trust-pack-indexes)
- GVFS.Hooks/Program.cs (gvfs.show-hydration-status)
- GVFS.Mount/InProcessMount.cs (gvfs.background-cache-auth)
- GVFS/CommandLine/PrefetchVerb.cs (gvfs.prefetch-offload)

LibGit2RepoInvoker.InitializeSharedRepo() intentionally forces an
object-store probe so long-lived/shared callers can amortize object-store
load costs. That is wasted work for one-off config reads that immediately
dispose the repo.

The helper methods live directly on LibGit2Repo rather than a separate
extension class, matching the repo.GetConfigBoolOrDefault(name, default)
convention already documented in AGENTS.md, and avoiding unnecessary
indirection for a class the team owns in the same assembly. Both methods
fall back to defaultValue and log a RelatedWarning on any failure, matching
the "default on any failure" contract each call site previously implemented
independently.

Added a protected LibGit2Repo(ITracer tracer) constructor to support test
doubles that inject a mock tracer without opening a real repo.

Surveyed master for other short-lived config-only LibGit2Repo/
LibGit2RepoInvoker usage; PrefetchStep.cs, GitStatusCache.cs, and
GitRepo.cs were left alone because they use shared/long-lived repo access,
not the transient anti-pattern this change addresses.

Reviewed with an internal 6-lens review-swarm pass (correctness, security,
design, tests, async-parallelism, risk-rollout); addressed all actionable
findings:
- Widened the shared helper's exception handling to a plain catch
  (Exception), restoring the "default on any failure" guarantee
  InProcessMount/PrefetchVerb relied on before this refactor.
- Fixed a double-RelatedWarning log on the repo-open-failure path.
- Replaced a hardcoded, non-portable "Z:\..." path in a unit test with a
  GUID-suffixed temp path.
- Added test coverage for the unset-key (null-coalescing) branch and the
  InvalidDataException catch arm.
- Simplified the parameterless constructor to delegate to the
  tracer-accepting one.

Full unit test suite: 891 passed, 0 failed, 11 skipped (pre-existing,
unrelated).

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
`gvfs clone <url> <target>` into a non-empty <target> directory throws an
unhandled NullReferenceException instead of reporting the expected error:

  Cannot clone @ <target>: System.NullReferenceException: Object
  reference not set to an instance of an object.
     at GVFS.CommandLine.CloneVerb.Execute() + 0x9fc

v1.0 behavior (expected):

  Cannot clone @ <target>
  Error: Clone directory '<target>' exists and is not empty

Root cause: CloneVerb.Execute() unconditionally read
enlistment.WorkingDirectoryBackingRoot to determine trustPackIndexes, even
when TryCreateEnlistment failed (e.g. because the target directory already
exists and is not empty). In that failure path enlistment is null, so the
dereference throws.

Extract the lookup into internal bool GetTrustPackIndexes(ITracer, Result,
GVFSEnlistment), which only touches enlistment when cloneResult.Success is
true; otherwise it returns the default without dereferencing enlistment.
Delegates to the shared LibGit2Repo.GetConfigBoolOrDefault helper.

Widen TryCreateEnlistment/Result from private to internal so they are
directly unit-testable (GVFS assembly already grants InternalsVisibleTo to
GVFS.UnitTests).

Add GVFS.UnitTests.CommandLine.CloneVerbTests:
- TryCreateEnlistmentFailsWithoutEnlistmentWhenTargetDirectoryIsNotEmpty:
  confirms the failure precondition (null enlistment on non-empty target
  dir).
- TryCreateEnlistmentDoesNotFailForEmptyTargetDirectory: boundary case, an
  empty target directory does not trigger the "exists and is not empty"
  error.
- TryCreateEnlistmentReportsNormalizedPathWhenItDiffersFromFullPath: covers
  the divergent full-vs-normalized-path error message branch.
- GetTrustPackIndexesDoesNotThrowWhenCloneFailedAndEnlistmentIsNull: the
  actual regression test, driving the exact failed-clone/null-enlistment
  composition that used to throw. Verified by temporarily removing the
  cloneResult.Success gate: the test failed with the original
  NullReferenceException, then passed again once the gate was restored.

Reviewed with an internal 6-lens review-swarm pass; the main finding was
that an earlier draft of the regression test only proved
TryCreateEnlistment's own contract (already true pre-fix) without
exercising the actual Execute() null-dereference, addressed by the
extraction and test above.

Full unit test suite: 895 passed, 0 failed, 11 skipped (pre-existing,
unrelated).

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
@tyrielv
tyrielv force-pushed the tyrielv/fix-clone-nonempty-nre branch from 72ce378 to 332b4fc Compare August 7, 2026 22:53
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