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));