diff --git a/ProjectDirector.Test/SimilarReposTests.cs b/ProjectDirector.Test/SimilarReposTests.cs
new file mode 100644
index 0000000..34d6bc0
--- /dev/null
+++ b/ProjectDirector.Test/SimilarReposTests.cs
@@ -0,0 +1,415 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.ProjectDirector.Test;
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+
+using DiffPlex.Model;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+using Semantics.Paths;
+
+///
+/// Covers comparing a repository against its siblings, which moved off the render thread.
+///
+///
+/// The comparison is a pair of git subprocesses and a whole-file diff per sibling, so it runs on a
+/// background task now. That makes two things worth pinning: which answer is on screen while a run
+/// is in flight, and which run wins when several are. Both live on as
+/// plain methods so they can be driven without a live ImGui context, as the repository's other
+/// decision logic already is.
+///
+[TestClass]
+public sealed class SimilarReposTests
+{
+ private static GitHubRepository Repository(string localPath, string repoName) => new()
+ {
+ OwnerName = GitHubOwnerName.Create("ktsu-dev"),
+ RepoName = GitHubRepoName.Create(repoName),
+ LocalPath = FullyQualifiedLocalRepoPath.Create(localPath),
+ };
+
+ private static FullyQualifiedGitHubRepoName Name(string repoName) =>
+ FullyQualifiedGitHubRepoName.Create($"ktsu-dev.{repoName}");
+
+ private static Dictionary> DiffsNaming(string repoName) =>
+ new() { [Name(repoName)] = [] };
+
+ [TestMethod]
+ public void ARepositoryIsNotPendingBeforeAnythingIsAsked()
+ {
+ GitHubRepository repo = Repository("/tmp/a", "A");
+
+ Assert.IsFalse(repo.SimilarReposPending);
+ Assert.IsEmpty(repo.SimilarRepoDiffs);
+ }
+
+ [TestMethod]
+ public void RequestingAComparisonClearsTheLastAnswerAndReportsPending()
+ {
+ GitHubRepository repo = Repository("/tmp/a", "A");
+ Assert.IsTrue(repo.TryApplySimilarRepoDiffs(repo.RequestSimilarRepoDiffs(), DiffsNaming("B")));
+
+ _ = repo.RequestSimilarRepoDiffs();
+
+ // A comparison describes whichever repository was selected when it ran, so leaving the
+ // previous one on screen while the next is computed would be actively misleading.
+ Assert.IsEmpty(repo.SimilarRepoDiffs, "The superseded answer stays on screen.");
+ Assert.IsTrue(repo.SimilarReposPending);
+ }
+
+ [TestMethod]
+ public void ApplyingAComparisonPublishesItAndClearsPending()
+ {
+ GitHubRepository repo = Repository("/tmp/a", "A");
+
+ int token = repo.RequestSimilarRepoDiffs();
+ bool applied = repo.TryApplySimilarRepoDiffs(token, DiffsNaming("B"));
+
+ Assert.IsTrue(applied);
+ Assert.IsFalse(repo.SimilarReposPending);
+ Assert.IsTrue(repo.SimilarRepoDiffs.ContainsKey(Name("B")));
+ }
+
+ [TestMethod]
+ public void ASupersededComparisonCannotPublish()
+ {
+ GitHubRepository repo = Repository("/tmp/a", "A");
+
+ int first = repo.RequestSimilarRepoDiffs();
+ int second = repo.RequestSimilarRepoDiffs();
+
+ // Clicking through several repositories leaves that many runs in flight, and they do not
+ // finish in the order they started. The stale one must not land on the newer selection.
+ Assert.IsFalse(repo.TryApplySimilarRepoDiffs(first, DiffsNaming("Stale")), "An older run published over a newer request.");
+ Assert.IsEmpty(repo.SimilarRepoDiffs);
+ Assert.IsTrue(repo.SimilarReposPending, "The newer request is still outstanding.");
+
+ Assert.IsTrue(repo.TryApplySimilarRepoDiffs(second, DiffsNaming("Fresh")));
+ Assert.IsTrue(repo.SimilarRepoDiffs.ContainsKey(Name("Fresh")));
+ }
+
+ [TestMethod]
+ public void ALateComparisonCannotOverwriteAnAlreadyPublishedNewerOne()
+ {
+ GitHubRepository repo = Repository("/tmp/a", "A");
+
+ int first = repo.RequestSimilarRepoDiffs();
+ int second = repo.RequestSimilarRepoDiffs();
+ Assert.IsTrue(repo.TryApplySimilarRepoDiffs(second, DiffsNaming("Fresh")));
+
+ Assert.IsFalse(repo.TryApplySimilarRepoDiffs(first, DiffsNaming("Stale")));
+ Assert.IsTrue(repo.SimilarRepoDiffs.ContainsKey(Name("Fresh")), "A late run replaced the answer the user is looking at.");
+ }
+
+ [TestMethod]
+ public void ComparingAgainstSiblingsKeysEveryOneOfThemEvenWhenNothingIsShared()
+ {
+ string a = CreateRepository([("shared.txt", "one\n"), ("only-a.txt", "a\n")]);
+ string b = CreateRepository([("shared.txt", "two\n")]);
+ string c = CreateRepository([("unrelated.txt", "c\n")]);
+
+ try
+ {
+ GitHubRepository repoA = Repository(a, "A");
+ Dictionary> diffs =
+ ProjectDirector.DiffAgainstAll(
+ repoA,
+ [
+ new(Name("B"), Repository(b, "B")),
+ new(Name("C"), Repository(c, "C")),
+ ]);
+
+ Assert.AreEqual(2, diffs.Count);
+
+ // Only the tracked files both repositories carry are diffed.
+ Assert.AreEqual(1, diffs[Name("B")].Count);
+ Assert.IsTrue(diffs[Name("B")].TryGetValue(RelativeFilePath.Create("shared.txt"), out DiffResult? shared));
+ Assert.IsNotEmpty(shared.DiffBlocks, "shared.txt differs between the two.");
+
+ // A sibling sharing nothing still gets an entry, because the similar-repos table counts
+ // its matches and would otherwise have no row to report zero on.
+ Assert.IsEmpty(diffs[Name("C")]);
+ }
+ finally
+ {
+ TryDeleteDirectory(a);
+ TryDeleteDirectory(b);
+ TryDeleteDirectory(c);
+ }
+ }
+
+ [TestMethod]
+ public void IdenticalSharedFilesDiffToNoBlocks()
+ {
+ string a = CreateRepository([("shared.txt", "same\n")]);
+ string b = CreateRepository([("shared.txt", "same\n")]);
+
+ try
+ {
+ Dictionary> diffs =
+ ProjectDirector.DiffAgainstAll(Repository(a, "A"), [new(Name("B"), Repository(b, "B"))]);
+
+ // This is what the "Exact" column counts.
+ Assert.IsEmpty(diffs[Name("B")][RelativeFilePath.Create("shared.txt")].DiffBlocks);
+ }
+ finally
+ {
+ TryDeleteDirectory(a);
+ TryDeleteDirectory(b);
+ }
+ }
+
+ [TestMethod]
+ public void ARepositoryThatIsNotCheckedOutComparesToNothingRatherThanThrowing()
+ {
+ string b = CreateRepository([("shared.txt", "one\n")]);
+ string missing = Path.Join(Path.GetTempPath(), $"ktsu_pd_absent_{Guid.NewGuid():N}");
+
+ try
+ {
+ Dictionary> diffs =
+ ProjectDirector.DiffAgainstAll(Repository(missing, "Missing"), [new(Name("B"), Repository(b, "B"))]);
+
+ // Repositories are listed before they are cloned, so this is an ordinary state rather
+ // than an error — and it now runs on a background task, where a throw goes unobserved.
+ Assert.IsEmpty(diffs[Name("B")]);
+ }
+ finally
+ {
+ TryDeleteDirectory(b);
+ }
+ }
+
+ [TestMethod]
+ public void ComparingAgainstNoSiblingsProducesNoEntries()
+ {
+ string a = CreateRepository([("shared.txt", "one\n")]);
+
+ try
+ {
+ Assert.IsEmpty(ProjectDirector.DiffAgainstAll(Repository(a, "A"), []));
+ }
+ finally
+ {
+ TryDeleteDirectory(a);
+ }
+ }
+
+ [TestMethod]
+ public void PairingSiblingsSkipsTheRepositoryBeingComparedAndNamesTheRest()
+ {
+ GitHubRepository repoA = Repository("/tmp/a", "A");
+ GitHubRepository repoB = Repository("/tmp/b", "B");
+
+ Collection> pairs =
+ ProjectDirector.PairSiblings(repoA, [repoA, repoB]);
+
+ Assert.AreEqual(1, pairs.Count, "A repository is not its own sibling.");
+ Assert.AreEqual(Name("B"), pairs[0].Key);
+ Assert.AreSame(repoB, pairs[0].Value);
+ }
+
+ [TestMethod]
+ public void PairingSiblingsRejectsARepositoryThatIsNotOnGitHub()
+ {
+ GitHubRepository repoA = Repository("/tmp/a", "A");
+ AzureDevOpsRepository other = new() { LocalPath = FullyQualifiedLocalRepoPath.Create("/tmp/b") };
+
+ // The throw belongs here, on the calling thread, rather than inside the background task
+ // where nothing would observe it.
+ _ = Assert.ThrowsExactly(() => ProjectDirector.PairSiblings(repoA, [repoA, other]));
+ }
+
+ [TestMethod]
+ public async Task ComparingSiblingsPublishesOffTheCallingThreadAndReportsWhatItDid()
+ {
+ string a = CreateRepository([("shared.txt", "one\n")]);
+ string b = CreateRepository([("shared.txt", "two\n")]);
+
+ try
+ {
+ GitHubRepository repoA = Repository(a, "A");
+ GitHubRepository repoB = Repository(b, "B");
+ ConcurrentQueue log = [];
+
+ Task task = ProjectDirector.CompareSiblingsAsync(repoA, [repoA, repoB], log.Enqueue);
+
+ // The answer is not there yet, which is what the pending state renders instead of the
+ // comparison for whichever repository was selected before.
+ await task.ConfigureAwait(false);
+
+ Assert.IsFalse(repoA.SimilarReposPending);
+ Assert.IsTrue(repoA.SimilarRepoDiffs.ContainsKey(Name("B")));
+ Assert.HasCount(1, log);
+ StringAssert.Contains(log.Single(), "1 other repository", "One sibling is reported in the singular.");
+ }
+ finally
+ {
+ TryDeleteDirectory(a);
+ TryDeleteDirectory(b);
+ }
+ }
+
+ [TestMethod]
+ public async Task ComparingSeveralSiblingsReportsThemInThePlural()
+ {
+ string a = CreateRepository([("shared.txt", "one\n")]);
+ string b = CreateRepository([("shared.txt", "two\n")]);
+ string c = CreateRepository([("shared.txt", "three\n")]);
+
+ try
+ {
+ GitHubRepository repoA = Repository(a, "A");
+ ConcurrentQueue log = [];
+
+ await ProjectDirector.CompareSiblingsAsync(repoA, [repoA, Repository(b, "B"), Repository(c, "C")], log.Enqueue).ConfigureAwait(false);
+
+ Assert.AreEqual(2, repoA.SimilarRepoDiffs.Count);
+ StringAssert.Contains(log.Single(), "2 other repositories");
+ }
+ finally
+ {
+ TryDeleteDirectory(a);
+ TryDeleteDirectory(b);
+ TryDeleteDirectory(c);
+ }
+ }
+
+ [TestMethod]
+ public async Task NoDiffIsFoundWhileAComparisonIsStillRunning()
+ {
+ string a = CreateRepository([("shared.txt", "one\n")]);
+ string b = CreateRepository([("shared.txt", "two\n")]);
+ RelativeFilePath shared = RelativeFilePath.Create("shared.txt");
+
+ try
+ {
+ GitHubRepository repoA = Repository(a, "A");
+
+ // Nothing has been compared, so the render paths must find nothing rather than throw
+ // the KeyNotFoundException that indexing straight into the dictionary would.
+ Assert.IsNull(ProjectDirector.FindDiff(repoA, Name("B"), shared));
+
+ await ProjectDirector.CompareSiblingsAsync(repoA, [repoA, Repository(b, "B")], _ => { }).ConfigureAwait(false);
+
+ Assert.IsNotNull(ProjectDirector.FindDiff(repoA, Name("B"), shared));
+ Assert.IsNull(ProjectDirector.FindDiff(repoA, Name("B"), RelativeFilePath.Create("absent.txt")), "A file the two do not share has no diff.");
+ Assert.IsNull(ProjectDirector.FindDiff(repoA, Name("Unknown"), shared), "A repository that was never compared has no diffs at all.");
+ }
+ finally
+ {
+ TryDeleteDirectory(a);
+ TryDeleteDirectory(b);
+ }
+ }
+
+ [TestMethod]
+ public async Task RefreshingOneFileUpdatesThatEntryAndLeavesTheRestAlone()
+ {
+ string a = CreateRepository([("shared.txt", "one\n"), ("other.txt", "x\n")]);
+ string b = CreateRepository([("shared.txt", "two\n"), ("other.txt", "y\n")]);
+ RelativeFilePath shared = RelativeFilePath.Create("shared.txt");
+
+ try
+ {
+ GitHubRepository repoA = Repository(a, "A");
+ GitHubRepository repoB = Repository(b, "B");
+ await ProjectDirector.CompareSiblingsAsync(repoA, [repoA, repoB], _ => { }).ConfigureAwait(false);
+
+ Assert.IsNotEmpty(ProjectDirector.FindDiff(repoA, Name("B"), shared)!.DiffBlocks);
+
+ await File.WriteAllTextAsync(Path.Join(b, "shared.txt"), "one\n").ConfigureAwait(false);
+ ProjectDirector.RefreshFileDiff(repoA, repoB, shared);
+
+ Assert.IsEmpty(ProjectDirector.FindDiff(repoA, Name("B"), shared)!.DiffBlocks, "The re-diff should see the file the apply button just wrote.");
+ Assert.IsNotNull(ProjectDirector.FindDiff(repoA, Name("B"), RelativeFilePath.Create("other.txt")), "The rest of the comparison is untouched.");
+ }
+ finally
+ {
+ TryDeleteDirectory(a);
+ TryDeleteDirectory(b);
+ }
+ }
+
+ [TestMethod]
+ public void RefreshingOneFileIsDroppedWhenAWholeComparisonHasSupersededIt()
+ {
+ GitHubRepository repoA = Repository("/tmp/a", "A");
+ GitHubRepository repoB = Repository("/tmp/b", "B");
+
+ // A comparison can publish between the frame deciding to draw a file and the apply button
+ // being pressed, replacing the dictionary this was about to write into.
+ _ = repoA.RequestSimilarRepoDiffs();
+
+ ProjectDirector.RefreshFileDiff(repoA, repoB, RelativeFilePath.Create("shared.txt"));
+
+ Assert.IsEmpty(repoA.SimilarRepoDiffs, "Dropping the update beats reinstating an entry the newer run left out.");
+ }
+
+ [TestMethod]
+ public void RefreshingOneFileRejectsARepositoryThatIsNotOnGitHub()
+ {
+ GitHubRepository repoA = Repository("/tmp/a", "A");
+ AzureDevOpsRepository other = new() { LocalPath = FullyQualifiedLocalRepoPath.Create("/tmp/b") };
+
+ _ = Assert.ThrowsExactly(
+ () => ProjectDirector.RefreshFileDiff(repoA, other, RelativeFilePath.Create("shared.txt")));
+ }
+
+ private static string CreateRepository(IEnumerable<(string RelativePath, string Contents)> files)
+ {
+ string root = Path.Join(Path.GetTempPath(), $"ktsu_pd_similar_{Guid.NewGuid():N}");
+ _ = Directory.CreateDirectory(root);
+
+ Assert.IsTrue(GitCli.Run("init", root).Succeeded, "git init failed.");
+
+ // Scope identity to this throwaway repository so the test neither depends on nor disturbs
+ // whatever global configuration the machine happens to carry.
+ Assert.IsTrue(GitCli.RunIn(root, "config", "user.name", "ProjectDirector").Succeeded);
+ Assert.IsTrue(GitCli.RunIn(root, "config", "user.email", "ProjectDirector@ktsu.dev").Succeeded);
+
+ foreach ((string relativePath, string contents) in files)
+ {
+ File.WriteAllText(Path.Join(root, relativePath), contents);
+ }
+
+ Assert.IsTrue(GitCli.RunIn(root, "add", "--all").Succeeded, "git add failed.");
+
+ GitResult committed = GitCli.RunIn(root, "commit", "-m", "Add files");
+ Assert.IsTrue(committed.Succeeded, $"git commit failed: {committed.FailureText}");
+
+ return root;
+ }
+
+ private static void TryDeleteDirectory(string path)
+ {
+ try
+ {
+ // Git marks objects read-only, which blocks a plain recursive delete on Windows.
+ foreach (string file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
+ {
+ File.SetAttributes(file, FileAttributes.Normal);
+ }
+
+ Directory.Delete(path, recursive: true);
+ }
+ catch (IOException)
+ {
+ // Covers a missing directory too. A best-effort cleanup of a temp directory is not
+ // worth failing a test over.
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Same best-effort rationale as above: a temp directory that will not delete is not
+ // worth failing a test over.
+ }
+ }
+}
diff --git a/ProjectDirector/GitRepository.cs b/ProjectDirector/GitRepository.cs
index 6bf6a13..25ba9b5 100644
--- a/ProjectDirector/GitRepository.cs
+++ b/ProjectDirector/GitRepository.cs
@@ -21,8 +21,65 @@ public abstract class GitRepository
public bool IsCloned => Directory.Exists(LocalPath);
+ private Dictionary> similarRepoDiffs = [];
+ private int similarRepoRequested;
+ private int similarRepoApplied;
+
+ ///
+ /// The last completed comparison of this repository against its siblings.
+ ///
+ ///
+ /// Published as a whole by rather than filled in place,
+ /// because the comparison runs on a background task while the render thread reads this. A
+ /// reader therefore sees either the previous answer or the next one, never a half-built
+ /// dictionary. The inner dictionaries stay writable so a single-file re-diff can update one
+ /// entry without redoing the whole run.
+ ///
+ [JsonIgnore]
+ public IReadOnlyDictionary> SimilarRepoDiffs => Volatile.Read(ref similarRepoDiffs);
+
+ ///
+ /// Whether a comparison has been asked for and has not yet published its answer.
+ ///
[JsonIgnore]
- public Dictionary> SimilarRepoDiffs { get; } = [];
+ public bool SimilarReposPending => Volatile.Read(ref similarRepoApplied) != Volatile.Read(ref similarRepoRequested);
+
+ ///
+ /// Discards the current comparison and returns a token identifying the new request.
+ ///
+ /// The token to hand back to .
+ ///
+ /// The old answer is dropped rather than left on screen: it describes whichever repository was
+ /// selected before, so showing it while the next one is computed is worse than showing nothing.
+ ///
+ internal int RequestSimilarRepoDiffs()
+ {
+ Volatile.Write(ref similarRepoDiffs, []);
+ return Interlocked.Increment(ref similarRepoRequested);
+ }
+
+ ///
+ /// Publishes the result of a comparison, unless a newer one has since been requested.
+ ///
+ /// The token from .
+ /// The comparison to publish.
+ /// when the result was published, when a newer request superseded it.
+ ///
+ /// Clicking through several repositories leaves that many comparisons in flight, and they do
+ /// not finish in the order they started. Only the newest may publish, so a slow earlier run
+ /// cannot overwrite the answer for the repository the user is actually looking at.
+ ///
+ internal bool TryApplySimilarRepoDiffs(int token, Dictionary> diffs)
+ {
+ if (Volatile.Read(ref similarRepoRequested) != token)
+ {
+ return false;
+ }
+
+ Volatile.Write(ref similarRepoDiffs, diffs);
+ Volatile.Write(ref similarRepoApplied, token);
+ return true;
+ }
public static GitRepository? Create(GitRemotePath remotePath, FullyQualifiedLocalRepoPath localPath)
{
diff --git a/ProjectDirector/ProjectDirector.cs b/ProjectDirector/ProjectDirector.cs
index 783008c..405e120 100644
--- a/ProjectDirector/ProjectDirector.cs
+++ b/ProjectDirector/ProjectDirector.cs
@@ -25,6 +25,12 @@ namespace ktsu.ProjectDirector;
internal sealed class ProjectDirector
{
+ ///
+ /// Drawn wherever a comparison is outstanding, in place of the answer for whichever repository
+ /// was selected before.
+ ///
+ private const string PendingComparisonMessage = "Comparing repositories...";
+
internal ProjectDirectorOptions Options { get; }
private static float FieldWidth => ImGui.GetIO().DisplaySize.X * 0.15f;
private DateTime LastSaveOptionsTime { get; set; } = DateTime.MinValue;
@@ -78,15 +84,13 @@ public ProjectDirector()
{
GitRepository repoA = Options.Repos[Options.BaseRepo];
GitRepository repoB = Options.Repos[Options.CompareRepo];
- DiffResult diff = repoA.SimilarRepoDiffs[Options.CompareRepo][Options.CompareFile];
- ShowDiffLeft(repoA, repoB, diff);
+ ShowDiffLeft(repoA, repoB, FindDiff(repoA, Options.CompareRepo, Options.CompareFile));
});
DividerDiff.Add("Right", 0.50f, (dt) =>
{
GitRepository repoA = Options.Repos[Options.BaseRepo];
GitRepository repoB = Options.Repos[Options.CompareRepo];
- DiffResult diff = repoA.SimilarRepoDiffs[Options.CompareRepo][Options.CompareFile];
- ShowDiffRight(repoA, repoB, diff);
+ ShowDiffRight(repoA, repoB, FindDiff(repoA, Options.CompareRepo, Options.CompareFile));
});
RestoreDividerStates();
@@ -636,7 +640,15 @@ private void ShowTopPanel(float dt)
}
});
- if (!Options.CompareFile.IsEmpty())
+ // One comparison feeds all three of these panels, and exactly one of them draws, so the
+ // pending state is decided here rather than repeated inside each. Until the comparison
+ // publishes there is nothing to show but the previous repository's answer, which is
+ // worse than showing nothing.
+ if (repo.SimilarReposPending)
+ {
+ ShowCollapsiblePanel($"Similar Repos", () => ImGui.TextUnformatted(PendingComparisonMessage));
+ }
+ else if (!Options.CompareFile.IsEmpty())
{
ShowCollapsiblePanel($"Compare File", () => ShowComparedFile(dt, repo));
}
@@ -1115,27 +1127,109 @@ private void ScanRemoteAccountsForRepos()
private static FullyQualifiedLocalRepoPath MakeFullyQualifyLocalRepoPath(AbsoluteDirectoryPath localPath) => FullyQualifiedLocalRepoPath.Create(Path.GetFullPath(localPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
- private void UpdateSimilarRepos(GitRepository repo)
+ ///
+ /// Compares a repository against every sibling, on a background task.
+ ///
+ ///
+ /// This runs a pair of git subprocesses and a whole-file diff per sibling, so at the "dozens of
+ /// sibling repositories" this tool is for it is far too much work to do between frames. Fetch,
+ /// pull, push and clone all background their git calls for the same reason; this path did not,
+ /// and it sits on the most frequent interaction there is, selecting a repository.
+ ///
+ private void UpdateSimilarRepos(GitRepository repo) => _ = CompareSiblingsAsync(repo, Options.Repos.Values, QueueLog);
+
+ ///
+ /// Pairs each sibling of with the name a comparison is keyed by.
+ ///
+ /// The repository being compared, which is not its own sibling.
+ /// Every known repository.
+ /// The siblings, paired with their fully qualified names.
+ /// A repository that is not a GitHub repository.
+ ///
+ /// Called before the background task starts rather than inside it, so the work never enumerates
+ /// Options.Repos while the UI can add to or remove from it, and so the unsupported-repository
+ /// throw lands on the calling thread where it is still observable rather than on a task nobody awaits.
+ ///
+ internal static Collection> PairSiblings(GitRepository repo, IEnumerable repos)
{
- foreach ((FullyQualifiedGitHubRepoName _, GitRepository otherRepo) in Options.Repos)
+ Ensure.NotNull(repos);
+
+ Collection> others = [];
+ foreach (GitRepository otherRepo in repos.Where(otherRepo => repo != otherRepo))
{
- if (repo != otherRepo)
+ others.Add(otherRepo is GitHubRepository gitHubRepo
+ ? new(GetFullyQualifiedRepoName(gitHubRepo.OwnerName, gitHubRepo.RepoName), otherRepo)
+ : throw new InvalidOperationException("Only GitHub Repos are supported at this time"));
+ }
+
+ return others;
+ }
+
+ ///
+ /// Starts a comparison of a repository against every sibling, on a background task.
+ ///
+ /// The repository to compare.
+ /// Every known repository.
+ /// Reports the finished comparison, on the background thread.
+ /// The task running the comparison, so a caller that cares can wait for it.
+ ///
+ /// The comparison runs a pair of git subprocesses and a whole-file diff per sibling, so at the
+ /// "dozens of sibling repositories" this tool is for it is far too much work to do between
+ /// frames. Fetch, pull, push and clone all background their git calls for the same reason; this
+ /// path did not, and it sits on the most frequent interaction there is, selecting a repository.
+ /// The render thread discards the task, but returning it keeps the work observable.
+ ///
+ internal static Task CompareSiblingsAsync(GitRepository repo, IEnumerable repos, Action log)
+ {
+ Ensure.NotNull(repo);
+ Ensure.NotNull(log);
+
+ Collection> others = PairSiblings(repo, repos);
+
+ int token = repo.RequestSimilarRepoDiffs();
+ Task task = new(() =>
+ {
+ Dictionary> diffs = DiffAgainstAll(repo, others);
+ if (repo.TryApplySimilarRepoDiffs(token, diffs))
{
- Dictionary diffs = DiffRepos(repo, otherRepo);
- if (otherRepo is GitHubRepository gitHubRepo)
- {
- FullyQualifiedGitHubRepoName otherRepoName = GetFullyQualifiedRepoName(gitHubRepo.OwnerName, gitHubRepo.RepoName);
- repo.SimilarRepoDiffs[otherRepoName] = diffs;
- }
- else
- {
- throw new InvalidOperationException("Only GitHub Repos are supported at this time");
- }
+ log($"[{DateTimeOffset.Now}] Compared {repo.RemotePath} against {others.Count} other {(others.Count == 1 ? "repository" : "repositories")}");
}
+ });
+
+ task.Start();
+ return task;
+ }
+
+ ///
+ /// Compares one repository against a set of siblings, returning the diffs keyed by sibling.
+ ///
+ /// The repository every sibling is compared against.
+ /// The siblings, paired with the names to key the result by.
+ /// The diffs for each sibling.
+ ///
+ /// The base repository's tracked-file list is read once for the whole run rather than once per
+ /// sibling, which is what DiffRepos used to do. Across N siblings that is N + 1 calls to
+ /// git ls-files where there were 2N, and the list cannot go stale mid-run because it does
+ /// not outlive one.
+ ///
+ internal static Dictionary> DiffAgainstAll(
+ GitRepository repo,
+ IEnumerable> others)
+ {
+ Collection trackedFiles = GitCli.IsRepository(repo.LocalPath)
+ ? GitCli.ListTrackedFiles(repo.LocalPath)
+ : [];
+
+ Dictionary> diffs = [];
+ foreach ((FullyQualifiedGitHubRepoName otherRepoName, GitRepository otherRepo) in others)
+ {
+ diffs[otherRepoName] = DiffRepos(repo, trackedFiles, otherRepo);
}
+
+ return diffs;
}
- private static Dictionary DiffRepos(GitRepository repoA, GitRepository repoB)
+ private static Dictionary DiffRepos(GitRepository repoA, IEnumerable trackedFilesA, GitRepository repoB)
{
Dictionary diffs = [];
@@ -1144,7 +1238,7 @@ private static Dictionary DiffRepos(GitRepository
return diffs;
}
- Collection matches = GitCli.ListTrackedFiles(repoA.LocalPath)
+ Collection matches = trackedFilesA
.Intersect(GitCli.ListTrackedFiles(repoB.LocalPath))
.ToCollection();
@@ -1179,6 +1273,28 @@ private static string ReadFileOrEmpty(string repoPath, string relativePath)
}
}
+ ///
+ /// Looks up one file's diff against a sibling repository.
+ ///
+ /// The base repository holding the comparison.
+ /// The sibling to compare against.
+ /// The file whose diff is wanted.
+ /// The diff, or when none has been computed.
+ ///
+ /// Every caller of this is a render path, and a comparison now runs in the background, so there
+ /// is a window in which no diff exists yet. Asking rather than indexing is what keeps a refresh
+ /// from taking the window down while the user is looking at a file.
+ ///
+ internal static DiffResult? FindDiff(GitRepository repo, FullyQualifiedGitHubRepoName otherRepoName, RelativeFilePath filePath)
+ {
+ Ensure.NotNull(repo);
+
+ return repo.SimilarRepoDiffs.TryGetValue(otherRepoName, out Dictionary? diffs)
+ && diffs.TryGetValue(filePath, out DiffResult? found)
+ ? found
+ : null;
+ }
+
private static DiffResult DiffSingleFile(GitRepository repoA, GitRepository repoB, RelativeFilePath filePath)
{
if (repoA == repoB || !GitCli.IsRepository(repoA.LocalPath) || !GitCli.IsRepository(repoB.LocalPath))
@@ -1192,13 +1308,27 @@ private static DiffResult DiffSingleFile(GitRepository repoA, GitRepository repo
return Differ.Instance.CreateLineDiffs(fileContents, otherFileContents, ignoreWhitespace: false, ignoreCase: false);
}
- private static void RefreshFileDiff(GitRepository repoA, GitRepository repoB, RelativeFilePath filePath)
+ ///
+ /// Re-diffs one file after its content changed, leaving the rest of the comparison alone.
+ ///
+ /// The base repository holding the comparison.
+ /// The sibling the file is compared against.
+ /// The file to re-diff.
+ internal static void RefreshFileDiff(GitRepository repoA, GitRepository repoB, RelativeFilePath filePath)
{
DiffResult diff = DiffSingleFile(repoA, repoB, filePath);
if (repoB is GitHubRepository gitHubRepo)
{
FullyQualifiedGitHubRepoName otherRepoName = GetFullyQualifiedRepoName(gitHubRepo.OwnerName, gitHubRepo.RepoName);
- repoA.SimilarRepoDiffs[otherRepoName][filePath] = diff;
+
+ // A whole-repository comparison can publish between the frame deciding to draw this
+ // file and the button being pressed, replacing the dictionary this was about to write
+ // into. The new comparison already read the file from disk, so dropping this update is
+ // the right answer rather than reinstating an entry that run left out.
+ if (repoA.SimilarRepoDiffs.TryGetValue(otherRepoName, out Dictionary? diffs))
+ {
+ diffs[filePath] = diff;
+ }
}
else
{
@@ -1326,7 +1456,12 @@ private void ShowComparedFile(float dt, GitRepository repo)
ImGui.TextUnformatted($"Comparing {Options.BaseRepo} vs {Options.CompareRepo}");
ImGui.SameLine();
- DiffResult diff = repo.SimilarRepoDiffs[Options.CompareRepo][Options.CompareFile];
+ if (FindDiff(repo, Options.CompareRepo, Options.CompareFile) is not DiffResult diff)
+ {
+ ImGui.TextUnformatted(PendingComparisonMessage);
+ return;
+ }
+
ShowWholeDiffSummary(diff);
ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(0, 0));