diff --git a/GitBranchStateCache.Tests/Mirrors/MirrorMaintenanceServiceTests.cs b/GitBranchStateCache.Tests/Mirrors/MirrorMaintenanceServiceTests.cs index 4a00d55..4462271 100644 --- a/GitBranchStateCache.Tests/Mirrors/MirrorMaintenanceServiceTests.cs +++ b/GitBranchStateCache.Tests/Mirrors/MirrorMaintenanceServiceTests.cs @@ -227,6 +227,49 @@ public void Sweep_AMirrorFetchedRecentlyButNotYetRecordedAsUsed_IsKept() Assert.IsTrue(store.Exists(directory)); } + [TestMethod] + public void Sweep_ARepositoryNamedMirrorStillBeingQueried_IsKept() + { + // studio/mirror is stored at studio/mirror.git/mirror.git. The outer directory never carries a + // marker, so if it were treated as a mirror it would look idle from the day it was created and + // be reaped along with the live mirror inside it. + (MirrorMaintenanceService service, MirrorStore store, MockFileSystem fileSystem, FakeTimeProvider time) = + Build(TimeSpan.FromDays(30)); + + string directory = Seed(store, fileSystem, "studio/mirror.git"); + + // The mock filesystem stamps creation times from the real clock, so pin the path segment's to + // the fake one or it never looks old enough to be reaped. + fileSystem.Directory.SetCreationTimeUtc(fileSystem.Path.GetDirectoryName(directory)!, time.GetUtcNow().UtcDateTime); + time.Advance(TimeSpan.FromDays(40)); + store.MarkFetched(directory); + store.MarkUsed(directory); + + service.Sweep(); + + Assert.IsTrue(store.Exists(directory)); + } + + [TestMethod] + public void Sweep_AnIdleMirrorHoldingALiveMirror_IsKept() + { + // studio is mirrored at studio/mirror.git and studio/mirror inside it. Reaping the idle outer + // mirror would delete the inner one too. + (MirrorMaintenanceService service, MirrorStore store, MockFileSystem fileSystem, FakeTimeProvider time) = + Build(TimeSpan.FromDays(30)); + + string outer = Seed(store, fileSystem, "studio"); + store.MarkUsed(outer); + string inner = Seed(store, fileSystem, "studio/mirror.git"); + time.Advance(TimeSpan.FromDays(40)); + store.MarkUsed(inner); + + service.Sweep(); + + Assert.IsTrue(store.Exists(outer)); + Assert.IsTrue(store.Exists(inner)); + } + [TestMethod] public void Sweep_LeavesTheMirrorsThatAreStillWanted() { diff --git a/GitBranchStateCache.Tests/Mirrors/MirrorStoreTests.cs b/GitBranchStateCache.Tests/Mirrors/MirrorStoreTests.cs index 336f88b..229f53c 100644 --- a/GitBranchStateCache.Tests/Mirrors/MirrorStoreTests.cs +++ b/GitBranchStateCache.Tests/Mirrors/MirrorStoreTests.cs @@ -111,6 +111,39 @@ public void Enumerate_FindsEveryMirrorAtAnyDepth() Assert.HasCount(2, store.Enumerate()); } + [TestMethod] + public void Enumerate_ARepositoryNamedMirror_ListsOnlyTheMirrorAndNotItsPathSegment() + { + // Clients send repository paths with a .git suffix, so studio/mirror lives at + // studio/mirror.git/mirror.git. The outer directory is a path segment, not a mirror. + (MirrorStore store, MockFileSystem fileSystem, _) = Build(); + + Assert.IsTrue(store.TryResolve(new MirrorKey("github", "studio/mirror.git"), out string? directory)); + fileSystem.Directory.CreateDirectory(directory!); + fileSystem.File.WriteAllText(fileSystem.Path.Combine(directory!, "HEAD"), "ref: refs/heads/main"); + + IReadOnlyList mirrors = store.Enumerate(); + + Assert.HasCount(1, mirrors); + Assert.AreEqual(directory, mirrors[0]); + } + + [TestMethod] + public void Enumerate_AMirrorHoldingAnotherMirror_ListsBoth() + { + // studio is mirrored at studio/mirror.git, and studio/mirror at studio/mirror.git/mirror.git. + // The outer one is a real bare repository, so it has files of its own and is still a mirror. + (MirrorStore store, MockFileSystem fileSystem, _) = Build(); + + Assert.IsTrue(store.TryResolve(new MirrorKey("github", "studio"), out string? outer)); + Assert.IsTrue(store.TryResolve(new MirrorKey("github", "studio/mirror.git"), out string? inner)); + fileSystem.Directory.CreateDirectory(inner!); + fileSystem.File.WriteAllText(fileSystem.Path.Combine(outer!, "HEAD"), "ref: refs/heads/main"); + fileSystem.File.WriteAllText(fileSystem.Path.Combine(inner!, "HEAD"), "ref: refs/heads/main"); + + Assert.HasCount(2, store.Enumerate()); + } + [TestMethod] public void Enumerate_WhenTheRootIsMissing_IsEmpty() { diff --git a/GitBranchStateCache/Mirrors/MirrorMaintenanceService.cs b/GitBranchStateCache/Mirrors/MirrorMaintenanceService.cs index cf09038..297e74a 100644 --- a/GitBranchStateCache/Mirrors/MirrorMaintenanceService.cs +++ b/GitBranchStateCache/Mirrors/MirrorMaintenanceService.cs @@ -70,7 +70,13 @@ internal void Sweep() foreach (string directory in directories) { + // A mirror can sit inside another when a repository path runs through a mirror.git segment + // of a repository that is itself mirrored. Deleting the outer one would take the inner one + // with it, however recently that was used, so it waits until the inner one has gone. + string[] nested = [.. directories.Where(other => MirrorStore.IsInside(other, directory))]; + if (settings.MirrorIdleMaxAge > TimeSpan.Zero + && nested.Length == 0 && LastTouched(directory) is DateTimeOffset touched && now - touched > settings.MirrorIdleMaxAge) { @@ -78,7 +84,7 @@ internal void Sweep() continue; } - bytes += Measure(directory); + bytes += Measure(directory, nested); kept++; } @@ -139,12 +145,14 @@ private void Reap(string directory, DateTimeOffset lastUsed) } } - private long Measure(string directory) + private long Measure(string directory, string[] nested) { try { + // Files belonging to a mirror nested inside this one are counted when that mirror is. return fileSystem.Directory .GetFiles(directory, "*", SearchOption.AllDirectories) + .Where(file => !nested.Any(inner => MirrorStore.IsInside(file, inner))) .Sum(file => fileSystem.FileInfo.New(file).Length); } catch (Exception failure) when (failure is IOException or UnauthorizedAccessException) diff --git a/GitBranchStateCache/Mirrors/MirrorStore.cs b/GitBranchStateCache/Mirrors/MirrorStore.cs index 0f0a2af..08f4031 100644 --- a/GitBranchStateCache/Mirrors/MirrorStore.cs +++ b/GitBranchStateCache/Mirrors/MirrorStore.cs @@ -88,15 +88,40 @@ public bool TryResolve(MirrorKey key, out string? directory) public DateTimeOffset? LastUsedAt(string directory) => ReadMarker(directory, UsedMarker); /// + /// + /// A directory named mirror.git is not necessarily a mirror. Clients send repository paths + /// with a .git suffix, so the repository studio/mirror lives at + /// studio/mirror.git/mirror.git, and its parent is only a path segment. Such a directory is + /// recognised by holding another candidate while having no files of its own: a real mirror is a + /// bare repository and always has at least HEAD. Left in, the parent would never carry a + /// marker, would look idle from the day it was created, and would be reaped along with the live + /// mirror inside it. + /// public IReadOnlyList Enumerate() { string root = options.Value.MirrorRoot; - return fileSystem.Directory.Exists(root) - ? fileSystem.Directory.GetDirectories(root, MirrorDirectoryName, SearchOption.AllDirectories) - : []; + if (!fileSystem.Directory.Exists(root)) + { + return []; + } + + string[] candidates = fileSystem.Directory.GetDirectories(root, MirrorDirectoryName, SearchOption.AllDirectories); + + return [.. candidates.Where(candidate => !IsRepositoryPathSegment(candidate, candidates))]; } + /// + /// Reports whether a path lies strictly inside a directory. + /// + /// The path that may be inside. + /// The directory that may contain it. + /// when is below . + internal static bool IsInside(string path, string directory) => + path.Length > directory.Length + 1 + && path.StartsWith(directory, StringComparison.Ordinal) + && path[directory.Length] is '/' or '\\'; + /// public void Delete(string directory) { @@ -138,6 +163,10 @@ private static bool IsSafeSegment(string segment) char.IsAsciiLetterOrDigit(character) || character is '.' or '_' or '-'); } + private bool IsRepositoryPathSegment(string candidate, string[] candidates) => + candidates.Any(other => IsInside(other, candidate)) + && fileSystem.Directory.GetFiles(candidate).Length == 0; + private DateTimeOffset? ReadMarker(string directory, string marker) { string path = fileSystem.Path.Combine(directory, marker);