Resolve IntegrationTests-025: stopBoundary for repo-root walker

ResolveRepositoryRoot accepts an optional stopBoundary parameter that
caps the upward walk; production callers pass null and behavior is
unchanged. The two repository-marker tests now seal their walkers
inside their own temp directories, so a redirected TMP or a co-located
C:\src checkout no longer leaks ambient marker-bearing ancestors into
the assertion.

Regression test ResolveRepositoryRoot_StopBoundary_IsolatesWalkerFromAmbientAncestorMarkers
constructs an outer ancestor that carries src/ + .git, confirms the
walker leaks into it without the boundary, then asserts the same call
throws with the boundary supplied.

Resolved at 2026-05-24; IntegrationTestEnvironmentTests 5/5 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-24 09:29:09 -04:00
parent 6bae5ea3a3
commit 186d03e5cc
3 changed files with 95 additions and 5 deletions
@@ -109,12 +109,26 @@ public static class IntegrationTestEnvironment
/// remains the escape hatch for unusual deployments.
/// </summary>
/// <param name="startDirectory">Starting directory to search from.</param>
/// <param name="stopBoundary">
/// Optional upper bound for the parent walk. When supplied, the walker checks
/// <paramref name="stopBoundary"/> for markers and then stops, ignoring all
/// ancestors above it. Tests pass an isolated boundary so the walker cannot
/// leak into ambient ancestors (a redirected <c>TMP</c>, a co-located checkout
/// at <c>C:\src</c>, an enclosing CI workspace, etc.) that would silently
/// satisfy <see cref="IsRepositoryRoot"/> — see IntegrationTests-025.
/// Production callers pass <see langword="null"/> so the walk continues to the
/// drive root as before.
/// </param>
/// <returns>The repository root path.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when the parent walk exhausts without finding a repository root.
/// </exception>
internal static string ResolveRepositoryRoot(string startDirectory)
internal static string ResolveRepositoryRoot(string startDirectory, string? stopBoundary = null)
{
string? normalizedBoundary = stopBoundary is null
? null
: Path.TrimEndingDirectorySeparator(Path.GetFullPath(stopBoundary));
DirectoryInfo? directory = new(startDirectory);
while (directory is not null)
{
@@ -123,6 +137,15 @@ public static class IntegrationTestEnvironment
return directory.FullName;
}
if (normalizedBoundary is not null
&& string.Equals(
Path.TrimEndingDirectorySeparator(directory.FullName),
normalizedBoundary,
StringComparison.OrdinalIgnoreCase))
{
break;
}
directory = directory.Parent;
}
@@ -33,7 +33,11 @@ public sealed class IntegrationTestEnvironmentTests
Directory.CreateDirectory(Path.Combine(temporaryRoot, "src"));
File.WriteAllText(Path.Combine(temporaryRoot, ".git"), "gitdir: ../.git/worktrees/test");
string repositoryRoot = IntegrationTestEnvironment.ResolveRepositoryRoot(nestedDirectory);
// Pass temporaryRoot as the stop-boundary so the walker can never leak
// into ambient ancestors of Path.GetTempPath() (IntegrationTests-025).
string repositoryRoot = IntegrationTestEnvironment.ResolveRepositoryRoot(
nestedDirectory,
stopBoundary: temporaryRoot);
Assert.Equal(temporaryRoot, repositoryRoot);
}
@@ -52,6 +56,11 @@ public sealed class IntegrationTestEnvironmentTests
/// the walk exhausts without finding a repository root. The previous silent
/// fallback to <c>Directory.GetCurrentDirectory()</c> masked misconfiguration
/// (IntegrationTests-022); operators get a clear, actionable failure instead.
/// The <c>stopBoundary</c> isolates the walker from ambient ancestors of
/// <see cref="Path.GetTempPath"/> (a redirected <c>TMP</c>, a co-located checkout
/// at <c>C:\src</c>, etc.) that could otherwise satisfy
/// <c>IsRepositoryRoot</c> and make this assertion flake on contributor or CI
/// boxes — see IntegrationTests-025.
/// </summary>
[Fact]
public void ResolveRepositoryRoot_NoMarkers_ThrowsInvalidOperationExceptionNamingStartAndMarkers()
@@ -64,7 +73,9 @@ public sealed class IntegrationTestEnvironmentTests
Directory.CreateDirectory(isolatedStart);
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
() => IntegrationTestEnvironment.ResolveRepositoryRoot(isolatedStart));
() => IntegrationTestEnvironment.ResolveRepositoryRoot(
isolatedStart,
stopBoundary: isolatedRoot));
Assert.Contains(isolatedStart, ex.Message, StringComparison.Ordinal);
Assert.Contains(".git", ex.Message, StringComparison.Ordinal);
@@ -82,4 +93,58 @@ public sealed class IntegrationTestEnvironmentTests
}
}
}
/// <summary>
/// Verifies the <c>stopBoundary</c> parameter on
/// <see cref="IntegrationTestEnvironment.ResolveRepositoryRoot"/> isolates the
/// walker from ambient ancestors that happen to satisfy <c>IsRepositoryRoot</c>
/// — the precise failure mode IntegrationTests-025 describes. The test
/// deliberately constructs an outer directory that *does* carry repository-root
/// markers (<c>src/</c> + <c>.git</c>) and an inner isolated chain that does
/// not. Without the boundary the walker would happily stop at the outer
/// directory; with the boundary it must throw because the chain it can see
/// carries no markers. A future refactor that dropped or mis-honored the
/// boundary would surface here as a failed assertion instead of a silent flake
/// in CI.
/// </summary>
[Fact]
public void ResolveRepositoryRoot_StopBoundary_IsolatesWalkerFromAmbientAncestorMarkers()
{
string outerRoot = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
string innerBoundary = Path.Combine(outerRoot, "inner");
string isolatedStart = Path.Combine(innerBoundary, "nested");
try
{
// Outer directory satisfies IsRepositoryRoot — it is the "ambient
// ancestor" the production walker would otherwise stop at.
Directory.CreateDirectory(Path.Combine(outerRoot, "src"));
File.WriteAllText(Path.Combine(outerRoot, ".git"), "gitdir: ../.git/worktrees/test");
// Inner chain carries no markers. The boundary sits between the inner
// chain and the outer marker-bearing ancestor.
Directory.CreateDirectory(isolatedStart);
// Sanity: without the boundary the production walker reaches outerRoot
// and silently returns it — the exact ambient-ancestor leak.
string leakedRoot = IntegrationTestEnvironment.ResolveRepositoryRoot(isolatedStart);
Assert.Equal(outerRoot, leakedRoot);
// With the boundary the walker is sealed inside the inner chain and
// must throw — the marker on outerRoot is invisible to it.
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
() => IntegrationTestEnvironment.ResolveRepositoryRoot(
isolatedStart,
stopBoundary: innerBoundary));
Assert.Contains(isolatedStart, ex.Message, StringComparison.Ordinal);
}
finally
{
if (Directory.Exists(outerRoot))
{
Directory.Delete(outerRoot, recursive: true);
}
}
}
}