Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions GitBranchStateCache.Tests/Mirrors/MirrorMaintenanceServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
33 changes: 33 additions & 0 deletions GitBranchStateCache.Tests/Mirrors/MirrorStoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> 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()
{
Expand Down
12 changes: 10 additions & 2 deletions GitBranchStateCache/Mirrors/MirrorMaintenanceService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,21 @@ 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)
{
Reap(directory, touched);
continue;
}

bytes += Measure(directory);
bytes += Measure(directory, nested);
kept++;
}

Expand Down Expand Up @@ -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)
Expand Down
35 changes: 32 additions & 3 deletions GitBranchStateCache/Mirrors/MirrorStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,40 @@ public bool TryResolve(MirrorKey key, out string? directory)
public DateTimeOffset? LastUsedAt(string directory) => ReadMarker(directory, UsedMarker);

/// <inheritdoc />
/// <remarks>
/// A directory named <c>mirror.git</c> is not necessarily a mirror. Clients send repository paths
/// with a <c>.git</c> suffix, so the repository <c>studio/mirror</c> lives at
/// <c>studio/mirror.git/mirror.git</c>, 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 <c>HEAD</c>. 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.
/// </remarks>
public IReadOnlyList<string> 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))];
}

/// <summary>
/// Reports whether a path lies strictly inside a directory.
/// </summary>
/// <param name="path">The path that may be inside.</param>
/// <param name="directory">The directory that may contain it.</param>
/// <returns><see langword="true"/> when <paramref name="path"/> is below <paramref name="directory"/>.</returns>
internal static bool IsInside(string path, string directory) =>
path.Length > directory.Length + 1
&& path.StartsWith(directory, StringComparison.Ordinal)
&& path[directory.Length] is '/' or '\\';

/// <inheritdoc />
public void Delete(string directory)
{
Expand Down Expand Up @@ -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);
Expand Down
Loading