Recognize linked worktree .git files in QuarantineTools - #19157
Recognize linked worktree .git files in QuarantineTools#19157Adam Ratzman (adamint) wants to merge 19 commits into
Conversation
QuarantineTools located the repository root by walking up the directory tree looking for a `.git` *directory*. In a linked git worktree `.git` is a regular file holding a `gitdir:` pointer, so the walk stepped over the worktree root and kept climbing. When the worktree was nested inside another checkout of the same repository, the walk terminated on the outer checkout's real `.git` directory and the tool rewrote test sources in the wrong tree. The failure was silent: the edit succeeded and the tool reported success with a repo-relative path, which looks identical to a correct run. Against a file with uncommitted work the stray edit would merge into it unnoticed. Ask `git rev-parse --show-toplevel` instead, and keep the directory walk as a fallback for when git is unavailable - now matching `.git` as a file or a directory so it stops at the worktree root too. Also refuse to run when the resolved root is not the working directory or one of its ancestors, which turns a silent wrong-tree write into an error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 19157Or
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 19157" |
There was a problem hiding this comment.
Pull request overview
Resolves QuarantineTools repository roots through Git, preventing edits to an outer checkout from nested worktrees.
Changes:
- Adds Git-based root discovery with timeout and fallback.
- Adds ancestor safety validation.
- Adds linked-worktree regression tests.
Show a summary per file
| File | Description |
|---|---|
tools/QuarantineTools/QuarantineTools.csproj |
Exposes internals to tests. |
tools/QuarantineTools/Quarantine.cs |
Implements root discovery and validation. |
tests/QuarantineTools.Tests/RepoRootTests.cs |
Tests worktree and path scenarios. |
tests/QuarantineTools.Tests/QuarantineTools.Tests.csproj |
References the production tool. |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 2
- Review effort level: Balanced
Two fixes in the repo-root resolution added by this PR. IsSameOrAncestorDirectory decided case sensitivity from the operating system, which is not a reliable proxy for the volume: macOS APFS can be formatted case-sensitive, Windows exposes a per-directory case-sensitivity flag that WSL sets, and Linux can mount case-insensitive volumes. Folding case on such a volume lets two genuinely different trees whose paths differ only by case satisfy the guard, which is the one outcome it exists to prevent. Comparing ordinally everywhere is not the answer either, because the Windows current directory keeps whatever casing the process was given while git canonicalizes --show-toplevel, so an ordinary run could then be refused over a meaningless difference. Probe the volume instead. TryGetGitTopLevelAsync linked the probe timeout to the caller's token and then caught every OperationCanceledException, so Ctrl+C was reported as an ordinary probe failure. FindRepoRootAsync then ran the fallback walk and ExecuteAsync enumerated the whole tests tree before cancellation was next observed. Re-throw caller cancellation; fall back only for the timeout. Both are covered by tests that fail without the change: the case test returns True instead of False, and the cancellation test throws nothing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (1)
tools/QuarantineTools/Quarantine.cs:517
- The linked-worktree tests always have
gitavailable, so they return from the primary probe and never exercise this fallback. Reverting onlyFile.Exists(gitPath)would therefore leave the suite green and reintroduce wrong-tree writes whenever git is unavailable. Add a regression test that forces the git probe to fail and verifies the nested worktree's.gitfile is selected instead of the outer checkout.
if (Directory.Exists(gitPath) || File.Exists(gitPath))
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Follow-up to the repo-root fix, from review feedback: - Return a distinct exit code (4) when refusing to edit a tree the caller is not standing in. 2 already means "tests folder not found", so reusing it made the two indistinguishable to a caller that branches on the exit code. - Resolve symlinks before giving up on the ancestor check. A Windows junction or `subst` drive lets the caller's directory read as `D:\src\aspire` while git reports `C:/src/aspire`, and refusing that would block a legitimate run. This runs only on the failure path, so a gap in it can only rescue a run that was already being refused, never block one that was about to succeed. Resolution proceeds one hop at a time because a link's stored target may be relative and may itself route through another link, and is depth-capped for cycles. - Extract the marker walk as FindRepoRootByMarker and test it directly. The git probe answers first, so the `.git`-as-a-file branch - the line the bug lived on - was not otherwise reachable from a test. - Assert the refusal message names both the resolved root and the working directory, so a later reword cannot quietly drop one. - Skip the git-dependent tests when git is absent instead of hard-failing, and make test cleanup non-throwing so a mid-setup failure cannot mask the real assertion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (1)
tools/QuarantineTools/Quarantine.cs:690
remainingDepthis decremented for every ordinary parent component, not only when following a link. A valid path with more than 40 components therefore exhausts the link budget before reaching a symlinked ancestor, leaves that ancestor unresolved, and makes the new wrong-tree guard reject a legitimate run. Keep the budget unchanged while walking parents; only decrement it when substituting a link target.
? Path.Combine(CanonicalizeCore(parent.FullName, remainingDepth - 1), info.Name)
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Balanced
The existing coverage pins the ExitCodeWrongTree constant, but a constant-pinning test cannot fail when the `return` changes, so the exit-code contract was effectively untested. This runs the built tool as a child process with a stale GIT_DIR/GIT_WORK_TREE pointing at a second repository and asserts the exit code, that the message names both paths, and that the other tree's file is byte identical afterwards. ExitCodeWrongTree becomes internal so the test binds to the contract instead of repeating the literal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (2)
tools/QuarantineTools/Quarantine.cs:775
- This probe still accepts a wrong tree when a case-sensitive parent contains two checkouts named with inverse casing (for example,
OUTERandouter). The flipped path exists because it is a distinct sibling, so this reports case-insensitive behavior;IsSameOrAncestorDirectorythen approvesOUTERas an ancestor ofouter/toolsand permits the cross-tree write. The new rejection test bypasses this probe by passingcaseSensitive: true. Distinguish an alternate lookup of the same directory from a distinct sibling, or conservatively reject this ambiguous case.
// A case-sensitive volume that happens to hold a real sibling differing only by case is read as
// case-insensitive here. That is acceptable: it only restores the behavior this guard had
// before the probe existed, and such a pair is not something a checkout layout produces.
return !Directory.Exists(Path.Combine(parent, flipped));
tools/QuarantineTools/Quarantine.cs:691
remainingDepthis intended to cap symlink hops, but this branch decrements it for every ordinary parent component. From a path deeper than 40 segments, canonicalization can stop before reaching a symlink near the root, leaving equivalent real/link spellings different and incorrectly returning exit code 4. Keep the counter unchanged while walking non-link parents; only decrement it when resolving a link.
return info.Parent is { } parent
? Path.Combine(CanonicalizeCore(parent.FullName, remainingDepth - 1), info.Name)
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (1)
tools/QuarantineTools/Quarantine.cs:602
Trim()removes valid whitespace from the repository path, not just git's line terminator. On Unix a checkout directory can end in a space, so this resolves a different path and the new guard incorrectly refuses a valid run. Remove only CR/LF terminators.
var trimmed = standardOutputTask.Result.Trim();
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Balanced
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (2)
tools/QuarantineTools/Quarantine.cs:693
remainingDepthis decremented for every ordinary parent component, not only when a link is followed. A path deeper than 40 components therefore exhausts the link budget before reaching a symlink near the root, leaving the two spellings uncanonicalized and incorrectly refusing a valid run. Preserve the budget while walking ordinary parents; only decrement it when resolving a link.
return info.Parent is { } parent
? Path.Combine(CanonicalizeCore(parent.FullName, remainingDepth - 1), info.Name)
: Path.TrimEndingDirectorySeparator(info.FullName);
tools/QuarantineTools/Quarantine.cs:821
- This new Windows-native case-sensitivity path is not exercised by CI:
QuarantineTools.Tests.csprojexplicitly disables both Windows and macOS runs, while the delegate-based test only verifies dispatch and the filesystem test runs on Linux. Enable platform runs for this project or move focused tests to platform-enabled projects so the P/Invoke behavior and macOS fallback/symlink behavior are covered.
using var handle = CreateFile(
- Files reviewed: 4/4 changed files
- Comments generated: 2
- Review effort level: Balanced
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
ResolveLinkTarget reports reparse points, so Canonicalize covers symlinks and junctions but not DOS device mappings, which are per-session drive aliases. The comment listed `subst` alongside junctions as a case it handles, which is wrong. Records what is actually covered, why the gap is not currently reachable (a process under a mapped drive sees the mapped spelling from getcwd and git reports the same spelling, so the plain comparison already matches), and that the failure mode is a refusal to run rather than a wrong-tree write. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 487b899d-d79d-4349-a84b-a026861bde7f
There was a problem hiding this comment.
Review details
Suppressed comments (1)
tests/QuarantineTools.Tests/RepoRootTests.cs:440
- This summary claims the guard handles
subst, butProgram.Canonicalizeexplicitly does not resolve DOS device mappings (Quarantine.cs:663-669), and this test only creates POSIX symlinks. Limit the summary to the link aliases this regression actually covers so it does not imply nonexistentsubstcoverage.
/// A junction or `subst` drive on Windows lets the caller's directory and git's answer name the same
/// tree with different spellings. Refusing that would block a legitimate run, so the guard resolves
/// links before it gives up. Symlink creation needs elevation on Windows, so this is POSIX-only.
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
There was a problem hiding this comment.
Review details
Suppressed comments (2)
tests/QuarantineTools.Tests/RepoRootTests.cs:441
- This summary still implies that the canonicalization path handles
subst, butCanonicalizeonly resolves symlinks/junctions and the production comment attools/QuarantineTools/Quarantine.cs:663-669explicitly documentssubstas unsupported. This test is POSIX-only and exercises symlinks, so remove thesubstclaim to avoid documenting behavior that is neither implemented nor tested.
/// A junction or `subst` drive on Windows lets the caller's directory and git's answer name the same
/// tree with different spellings. Refusing that would block a legitimate run, so the guard resolves
/// links before it gives up. Symlink creation needs elevation on Windows, so this is POSIX-only.
tests/QuarantineTools.Tests/RepoRootTests.cs:532
- This expectation contradicts the PR's verification claim that stale
GIT_DIR/GIT_WORK_TREEexercises the wrong-tree guard, exits with code 4, and names both paths. The implementation now strips those variables and this test correctly observes the ordinary missing-tests exit code 2 instead. Update the PR description to describe the current behavior; if an end-to-end exit-4 regression is still required, it needs a different setup.
Assert.Equal(2, exitCode);
Assert.Contains(Path.Combine(here, "tests"), standardError, StringComparison.Ordinal);
Assert.Contains(here, standardError, StringComparison.Ordinal);
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0d1cf760-b1bc-46ba-a6d4-628354b00f2c
There was a problem hiding this comment.
Review details
Suppressed comments (1)
tools/QuarantineTools/Quarantine.cs:484
- The implementation in this hunk does not match the PR's stated fix: it still resolves the root solely by walking for a
.gitmarker. There is nogit rev-parse --show-toplevelprobe, timeout/concurrent stream draining, ancestor safety check, or distinct exit code 4 anywhere inQuarantine.cs. As a result, the behavior and verification described in the PR—including the real git-backed worktree scenarios and wrong-tree refusal—are not present in the submitted changes. Please either restore the described implementation and tests or update the PR scope and description to reflect this narrower marker-file fix.
var gitPath = Path.Combine(dir.FullName, ".git");
if (Directory.Exists(gitPath) || File.Exists(gitPath))
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3253974d-4f18-486f-863c-281a607656d4
There was a problem hiding this comment.
Review details
Suppressed comments (2)
tools/QuarantineTools/Quarantine.cs:635
- None of the new end-to-end tests sets
GIT_DIRorGIT_WORK_TREE; thecore.worktreetests exercise a different redirect path. Without a regression test, removing either environment cleanup reintroduces the outer-checkout write this guard prevents. Add a nested-worktree test that points both variables at the outer checkout and verifies only the nested file is changed.
foreach (var name in s_gitRepositoryLocationEnvironmentVariables)
{
startInfo.Environment.Remove(name);
tools/QuarantineTools/Quarantine.cs:816
RepoRootTestsonly calls this method with identically-cased path segments, so this casing-dependent safety branch is never exercised. A regression here can treat case-distinct checkouts as the same directory and permit edits in the wrong tree. Add focused tests through the injected casing delegate for sensitive and insensitive parents, including the case-only-sibling and unknown-probe outcomes.
if (!string.Equals(ancestorSegments[i], currentSegments[i], StringComparison.Ordinal)
&& (isCaseSensitiveDirectory(parent)
|| !string.Equals(ancestorSegments[i], currentSegments[i], StringComparison.OrdinalIgnoreCase)))
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Adds a nested linked-worktree test that runs the tool with GIT_DIR and GIT_WORK_TREE pointed at the outer repository. GIT_TRACE2 is used to assert the tool does not forward those overrides to its own git invocations, which is what previously made it resolve the outer root and rewrite the wrong sources. Recovered from an uncommitted working tree after the authoring session was killed by a CLI out-of-memory crash on 2026-08-11. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f1bd51d5-cb9f-4370-a07b-c94416baad61
There was a problem hiding this comment.
Review details
Suppressed comments (2)
tools/QuarantineTools/Quarantine.cs:816
- The case-sensitive wrong-tree guard has no regression coverage in
RepoRootTests: the current ancestry theory uses only lowercase paths, and no test calls the injected casing overloads or child-probe seam. A regression that treats a case-sensitive parent or distinct case-flipped siblings as insensitive would therefore let a wrong checkout through without failing the suite. Restore focused tests for per-parent casing rules, the Windows metadata/fail-closed branches, and the child probe's distinct-sibling and unknown outcomes.
if (!string.Equals(ancestorSegments[i], currentSegments[i], StringComparison.Ordinal)
&& (isCaseSensitiveDirectory(parent)
|| !string.Equals(ancestorSegments[i], currentSegments[i], StringComparison.OrdinalIgnoreCase)))
tools/QuarantineTools/Quarantine.cs:579
- The caller-cancellation test uses an already-canceled token, so it never exercises this internal timeout or the process-termination path. Removing
CancelAfter, failing to kill the child, or incorrectly skipping marker fallback would still leave the suite green despite the PR's bounded-probe guarantee. Add a fakegitprocess that blocks, use an injectable short timeout, and assert that the probe terminates it and falls back within the bound.
This issue also appears on line 814 of the same file.
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(s_gitProbeTimeout);
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
Description
Resolve the repository root through bounded
git rev-parse --show-toplevelinstead of trusting an ancestor.gitmarker alone.The command drains stdout and stderr concurrently, has a bounded timeout, and accepts the result only when its canonical path matches the nearest
.gitmarker root. This supports normal checkouts and linked worktrees while refusing malformed or redirected metadata that points at another tree. Repository-root validation failures return exit code 4 rather than editing a different checkout.Verification
core.worktreeredirection.Checklist