diff --git a/GitBranchStateCache.Tests/Git/GitRunnerTests.cs b/GitBranchStateCache.Tests/Git/GitRunnerTests.cs index 4571b42..be1e8c4 100644 --- a/GitBranchStateCache.Tests/Git/GitRunnerTests.cs +++ b/GitBranchStateCache.Tests/Git/GitRunnerTests.cs @@ -168,6 +168,34 @@ public void BuildEnvironment_SetsTheFlagsThatKeepARunPredictable() Assert.IsFalse(environment.ContainsKey("PATH")); } + [TestMethod] + public void BuildEnvironment_WithAWorkingDirectory_StopsDiscoveryAtIt() + { + string mirror = Path.Combine(TempRoot, "github", "studio", "game"); + + Dictionary environment = GitRunner.BuildEnvironment( + new GitInvocation { WorkingDirectory = mirror, Arguments = ["for-each-ref"], Timeout = TimeSpan.FromSeconds(1) }, + new GitBranchStateCacheOptions { MirrorRoot = TempRoot }, + new Hashtable { ["GIT_CEILING_DIRECTORIES"] = "/somewhere/inherited" }); + + // git still finds the repository the directory itself holds, but a directory that is no longer + // one fails instead of being answered from an enclosing repository. + Assert.AreEqual(Path.GetDirectoryName(Path.GetFullPath(mirror)), environment["GIT_CEILING_DIRECTORIES"]); + } + + [TestMethod] + public void BuildEnvironment_WithoutAWorkingDirectory_SetsNoCeiling() + { + Dictionary environment = GitRunner.BuildEnvironment( + new GitInvocation { Arguments = ["--version"], Timeout = TimeSpan.FromSeconds(1) }, + new GitBranchStateCacheOptions { MirrorRoot = TempRoot }, + new Hashtable { ["GIT_CEILING_DIRECTORIES"] = "/somewhere/inherited" }); + + // Dropped like every other inherited GIT_* variable, and nothing is put in its place. + Assert.IsTrue(environment.TryGetValue("GIT_CEILING_DIRECTORIES", out string? ceiling)); + Assert.IsNull(ceiling); + } + [TestMethod] public void BuildEnvironment_WithACredential_ScopesItToTheUpstream() { diff --git a/GitBranchStateCache.Tests/Git/RealRepositoryTests.cs b/GitBranchStateCache.Tests/Git/RealRepositoryTests.cs index 1a75c3a..a264efa 100644 --- a/GitBranchStateCache.Tests/Git/RealRepositoryTests.cs +++ b/GitBranchStateCache.Tests/Git/RealRepositoryTests.cs @@ -109,6 +109,21 @@ public async Task RefResolver_ReadsEveryBranchWithItsTip() Assert.IsTrue(branches!.Any(branch => branch.Name == "origin/feature/ui" && branch.Tip == _featureTip)); } + [TestMethod] + public async Task RefResolver_ABrokenMirrorInsideAnotherRepository_ReadsNothing() + { + // A mirror directory left holding only .last-used (the reaper racing MarkUsed), under a mirror + // root that sits inside a checkout. Git must not walk up and answer from the checkout. + string broken = Path.Combine(_workTree, "mirrors", "github", "studio", "other", "mirror.git"); + Directory.CreateDirectory(broken); + await File.WriteAllTextAsync(Path.Combine(broken, ".last-used"), string.Empty); + + IReadOnlyList? branches = await new RefResolver(Runner, Options.Create(Settings)) + .ListAsync(broken, CancellationToken.None); + + Assert.IsNull(branches, $"Read {branches?.Count} branches from the enclosing repository."); + } + [TestMethod] public async Task MirrorQueries_FindsTheForkPointAsTheMergeBase() { diff --git a/GitBranchStateCache/Git/GitRunner.cs b/GitBranchStateCache/Git/GitRunner.cs index 8f4cfce..c833211 100644 --- a/GitBranchStateCache/Git/GitRunner.cs +++ b/GitBranchStateCache/Git/GitRunner.cs @@ -186,6 +186,18 @@ public async Task RunAsync(GitInvocation invocation, CancellationToke // unplanned fetch in production. environment["GIT_NO_LAZY_FETCH"] = "1"; + // Discovery stops at the directory the run was given. Without a ceiling, a mirror directory that + // is no longer a repository (a partial delete, the reaper racing MarkUsed) does not fail: git + // walks up to the first enclosing repository and answers from that, which serves one + // repository's branches to a caller admitted only for another (ktsu-dev/GitBranchStateCache#104). + // A ceiling rather than GIT_DIR, because a clone and its staging directory run before they are + // repositories, and the ceiling still lets git find the repository the directory itself holds. + if (invocation.WorkingDirectory is not null + && Path.GetDirectoryName(Path.GetFullPath(invocation.WorkingDirectory)) is { Length: > 0 } ceiling) + { + environment["GIT_CEILING_DIRECTORIES"] = ceiling; + } + ApplyConfigEnvironment(environment, invocation); return environment; }