diff --git a/ProjectDirector.Test/FilePropagationTests.cs b/ProjectDirector.Test/FilePropagationTests.cs new file mode 100644 index 0000000..be04a9c --- /dev/null +++ b/ProjectDirector.Test/FilePropagationTests.cs @@ -0,0 +1,332 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.Test; + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; + +using ktsu.Semantics.Strings; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests what propagating one file to several repositories does when one of them refuses the copy. +/// +/// +/// Propagating is a batch the user confirms once, for a list of repositories they picked. It used to +/// run an unguarded per repository, so the first locked +/// destination ended the loop -- every repository after it silently never got the file, and nothing +/// was written to the log panel either way. +/// +/// exists apart from so that rule can +/// be driven against real throwaway directories, the way drives +/// . A destination that is an existing *directory* is the +/// portable way to make a copy fail: both Windows and Linux refuse it, without needing a lock or a +/// permission change the test would then have to undo. +/// +[TestClass] +public sealed class FilePropagationTests +{ + private const string SourceContent = "root = true\n"; + + private static FullyQualifiedGitHubRepoName Repo(string name) => name.As(); + + private static string CreateWorkspace() + { + string root = Path.Join(Path.GetTempPath(), $"ktsu_pd_propagate_{Guid.NewGuid():N}"); + _ = Directory.CreateDirectory(root); + return root; + } + + private static void Cleanup(string root) + { + try + { + Directory.Delete(root, recursive: true); + } + catch (IOException) + { + // A leaked temp directory is not worth failing an otherwise passing test over. + } + catch (UnauthorizedAccessException) + { + // Same. + } + } + + [TestMethod] + public void ARefusedCopyDoesNotStopTheRepositoriesAfterIt() + { + string root = CreateWorkspace(); + try + { + string source = Path.Join(root, "source", ".editorconfig"); + _ = Directory.CreateDirectory(Path.GetDirectoryName(source)!); + File.WriteAllText(source, SourceContent); + + string first = Path.Join(root, "first", ".editorconfig"); + string blocked = Path.Join(root, "blocked", ".editorconfig"); + string last = Path.Join(root, "last", ".editorconfig"); + + // Occupy the middle destination with a directory of the same name, which neither platform + // will let File.Copy overwrite. + _ = Directory.CreateDirectory(blocked); + + // An ordered sequence rather than a dictionary, so "after the failure" means what it says. + KeyValuePair[] destinations = + [ + new(Repo("ktsu-dev/first"), first), + new(Repo("ktsu-dev/blocked"), blocked), + new(Repo("ktsu-dev/last"), last), + ]; + + FilePropagationReport report = FilePropagation.Propagate(source, destinations); + + Assert.IsTrue(File.Exists(first), "The repository before the failure should have the file."); + Assert.AreEqual(SourceContent, File.ReadAllText(first)); + Assert.IsTrue(File.Exists(last), "The repository after the failure should still have been attempted."); + Assert.AreEqual(SourceContent, File.ReadAllText(last)); + + Assert.HasCount(3, report.Results, "Every requested repository should be accounted for."); + Assert.IsTrue(report.Results[0].Succeeded); + Assert.IsFalse(report.Results[1].Succeeded, "The occupied destination should be reported as a failure."); + Assert.IsFalse(string.IsNullOrWhiteSpace(report.Results[1].Failure), "A failure should carry its reason."); + Assert.IsTrue(report.Results[2].Succeeded); + } + finally + { + Cleanup(root); + } + } + + [TestMethod] + public void TheSummaryCountsTheRunAndNamesTheRepositoriesThatMissedOut() + { + string root = CreateWorkspace(); + try + { + string source = Path.Join(root, "source", ".editorconfig"); + _ = Directory.CreateDirectory(Path.GetDirectoryName(source)!); + File.WriteAllText(source, SourceContent); + + string blocked = Path.Join(root, "blocked", ".editorconfig"); + _ = Directory.CreateDirectory(blocked); + + KeyValuePair[] destinations = + [ + new(Repo("ktsu-dev/first"), Path.Join(root, "first", ".editorconfig")), + new(Repo("ktsu-dev/blocked"), blocked), + new(Repo("ktsu-dev/last"), Path.Join(root, "last", ".editorconfig")), + ]; + + Collection lines = FilePropagation.Propagate(source, destinations).Summarize(); + + Assert.Contains("2 of 3", lines[0], StringComparison.Ordinal); + Assert.Contains("ktsu-dev/blocked", lines[0], StringComparison.Ordinal); + Assert.HasCount(2, lines, "One summary line, then one detail line for the single failure."); + Assert.Contains("ktsu-dev/blocked", lines[1], StringComparison.Ordinal); + } + finally + { + Cleanup(root); + } + } + + [TestMethod] + public void ARunWhereEveryCopyWorksSaysSoInOneLine() + { + string root = CreateWorkspace(); + try + { + string source = Path.Join(root, "source", ".editorconfig"); + _ = Directory.CreateDirectory(Path.GetDirectoryName(source)!); + File.WriteAllText(source, SourceContent); + + KeyValuePair[] destinations = + [ + new(Repo("ktsu-dev/first"), Path.Join(root, "first", ".editorconfig")), + new(Repo("ktsu-dev/last"), Path.Join(root, "last", "nested", ".editorconfig")), + ]; + + FilePropagationReport report = FilePropagation.Propagate(source, destinations); + Collection lines = report.Summarize(); + + Assert.IsTrue(report.SourceExists); + Assert.HasCount(1, lines); + Assert.Contains("2 of 2", lines[0], StringComparison.Ordinal); + Assert.IsFalse(lines[0].Contains("failed", StringComparison.Ordinal)); + } + finally + { + Cleanup(root); + } + } + + [TestMethod] + public void ADestinationWhoseParentIsAFileIsReportedRatherThanThrown() + { + string root = CreateWorkspace(); + try + { + string source = Path.Join(root, "source", ".editorconfig"); + _ = Directory.CreateDirectory(Path.GetDirectoryName(source)!); + File.WriteAllText(source, SourceContent); + + // A plain file where the destination expects a directory: creating the containing + // directory fails rather than the copy itself, which is the other half of the guard. + string fileInTheWay = Path.Join(root, "blocked"); + File.WriteAllText(fileInTheWay, "not a directory\n"); + + KeyValuePair[] destinations = + [ + new(Repo("ktsu-dev/blocked"), Path.Join(fileInTheWay, "nested", ".editorconfig")), + new(Repo("ktsu-dev/last"), Path.Join(root, "last", ".editorconfig")), + ]; + + FilePropagationReport report = FilePropagation.Propagate(source, destinations); + + Assert.IsFalse(report.Results[0].Succeeded); + Assert.IsFalse(string.IsNullOrWhiteSpace(report.Results[0].Failure)); + Assert.IsTrue(report.Results[1].Succeeded, "The repository after the failure should still have been attempted."); + } + finally + { + Cleanup(root); + } + } + + [TestMethod] + public void ADestinationWithNoContainingDirectoryIsReportedRatherThanSkipped() + { + string root = CreateWorkspace(); + try + { + string source = Path.Join(root, "source", ".editorconfig"); + _ = Directory.CreateDirectory(Path.GetDirectoryName(source)!); + File.WriteAllText(source, SourceContent); + + // A bare filename has no directory part. The old code silently skipped this case; a + // repository the user checked and heard nothing about is the bug, not the edge case. + KeyValuePair[] destinations = + [ + new(Repo("ktsu-dev/bare"), "nocontainingdirectory.txt"), + ]; + + FilePropagationReport report = FilePropagation.Propagate(source, destinations); + + Assert.HasCount(1, report.Results, "The repository should still be accounted for."); + Assert.IsFalse(report.Results[0].Succeeded); + Assert.Contains("containing directory", report.Results[0].Failure!, StringComparison.Ordinal); + } + finally + { + Cleanup(root); + } + } + + [TestMethod] + public void ADestinationThatIsNotAUsablePathIsReportedRatherThanThrown() + { + string root = CreateWorkspace(); + try + { + string source = Path.Join(root, "source", ".editorconfig"); + _ = Directory.CreateDirectory(Path.GetDirectoryName(source)!); + File.WriteAllText(source, SourceContent); + + // An embedded null is rejected by the path APIs on every platform, which is the + // ArgumentException arm of the guard. + KeyValuePair[] destinations = + [ + new(Repo("ktsu-dev/invalid"), Path.Join(root, "in\0valid", ".editorconfig")), + new(Repo("ktsu-dev/last"), Path.Join(root, "last", ".editorconfig")), + ]; + + FilePropagationReport report = FilePropagation.Propagate(source, destinations); + + Assert.IsFalse(report.Results[0].Succeeded); + Assert.IsTrue(report.Results[1].Succeeded, "The repository after the failure should still have been attempted."); + } + finally + { + Cleanup(root); + } + } + + [TestMethod] + public void OnlyTheCheckedRepositoriesGetADestination() + { + Dictionary repos = new() + { + [Repo("ktsu-dev/first")] = new GitHubRepository { LocalPath = Path.Join("dev", "first").As() }, + [Repo("ktsu-dev/second")] = new GitHubRepository { LocalPath = Path.Join("dev", "second").As() }, + [Repo("ktsu-dev/third")] = new GitHubRepository { LocalPath = Path.Join("dev", "third").As() }, + }; + + KeyValuePair[] selection = + [ + new(Repo("ktsu-dev/first"), true), + new(Repo("ktsu-dev/second"), false), + new(Repo("ktsu-dev/third"), true), + ]; + + Dictionary destinations = + FilePropagation.ResolveDestinations(selection, repos, Path.Join("src", ".editorconfig")); + + Assert.HasCount(2, destinations, "An unchecked repository should not get a destination."); + Assert.IsFalse(destinations.ContainsKey(Repo("ktsu-dev/second"))); + Assert.AreEqual(Path.Join("dev", "first", "src", ".editorconfig"), destinations[Repo("ktsu-dev/first")]); + Assert.AreEqual(Path.Join("dev", "third", "src", ".editorconfig"), destinations[Repo("ktsu-dev/third")]); + } + + [TestMethod] + public void OnlyTheSummaryLineCarriesTheTimestamp() + { + DateTimeOffset at = new(2026, 9, 17, 11, 30, 0, TimeSpan.Zero); + FilePropagationReport report = new( + ".editorconfig", + SourceExists: true, + [ + new(Repo("ktsu-dev/first"), "first", null), + new(Repo("ktsu-dev/blocked"), "blocked", "denied"), + ]); + + Collection lines = FilePropagation.DescribeForLog(report, at); + + Assert.HasCount(2, lines); + Assert.StartsWith($"[{at}] ", lines[0], StringComparison.Ordinal); + Assert.Contains("1 of 2", lines[0], StringComparison.Ordinal); + Assert.StartsWith(" ", lines[1], StringComparison.Ordinal); + Assert.IsFalse(lines[1].Contains($"[{at}]", StringComparison.Ordinal), "Detail lines are indented under the summary, not stamped again."); + } + + [TestMethod] + public void AMissingSourceIsReportedOnceAndLeavesEveryRepositoryAlone() + { + string root = CreateWorkspace(); + try + { + string source = Path.Join(root, "source", ".editorconfig"); + string destination = Path.Join(root, "first", ".editorconfig"); + + KeyValuePair[] destinations = + [ + new(Repo("ktsu-dev/first"), destination), + ]; + + FilePropagationReport report = FilePropagation.Propagate(source, destinations); + Collection lines = report.Summarize(); + + Assert.IsFalse(report.SourceExists); + Assert.IsEmpty(report.Results, "No repository should be touched when there is nothing to copy."); + Assert.IsFalse(Directory.Exists(Path.GetDirectoryName(destination)!), "A failed run should not create destination directories."); + Assert.HasCount(1, lines, "A missing source is one failure, not one per repository."); + Assert.Contains("does not exist", lines[0], StringComparison.Ordinal); + } + finally + { + Cleanup(root); + } + } +} diff --git a/ProjectDirector/FilePropagation.cs b/ProjectDirector/FilePropagation.cs new file mode 100644 index 0000000..1a41d94 --- /dev/null +++ b/ProjectDirector/FilePropagation.cs @@ -0,0 +1,176 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector; + +using System.Collections.ObjectModel; + +/// +/// What copying the propagated file into one repository did. +/// +/// The repository the file was copied into. +/// The full path the file was copied to. +/// Why the copy failed, or null when it succeeded. +internal sealed record FilePropagationResult(FullyQualifiedGitHubRepoName Repo, string Destination, string? Failure) +{ + /// + /// Gets a value indicating whether the file reached this repository. + /// + internal bool Succeeded => Failure is null; +} + +/// +/// The outcome of one propagation run, one entry per repository the user asked for. +/// +/// The file that was propagated. +/// Whether was there to copy. When false no repository was touched. +/// One result per requested repository, in the order they were attempted. +internal sealed record FilePropagationReport(string Source, bool SourceExists, Collection Results) +{ + /// + /// Describes the run for the log panel: a summary line, then one line per failure. + /// + /// At least one line, the first of which answers "did that work" on its own. + /// + /// The summary line carries the counts and names the repositories that missed out, because after + /// a partial run the question is which repositories have the file -- and reconstructing that from + /// fifteen individual lines is the work this is meant to save. + /// + internal Collection Summarize() + { + if (!SourceExists) + { + return [$"Propagating {Source} failed: the source file does not exist"]; + } + + Collection failures = [.. Results.Where(result => !result.Succeeded)]; + int succeeded = Results.Count - failures.Count; + string failed = failures.Count > 0 + ? $"; failed: {string.Join(", ", failures.Select(failure => failure.Repo.WeakString))}" + : string.Empty; + + Collection lines = [$"Propagated {Source} to {succeeded} of {Results.Count} repos{failed}"]; + foreach (FilePropagationResult failure in failures) + { + lines.Add($" {failure.Repo.WeakString}: {failure.Failure}"); + } + + return lines; + } +} + +/// +/// Copies one file into a set of repositories. +/// +/// +/// Separate from so the part with a rule in it can be driven +/// without a live ImGui context, the way is. +/// +/// The rule is that a batch the user confirmed runs to the end. A locked destination in the seventh +/// of fifteen repositories used to end the loop there, so the remaining eight silently never got the +/// file -- and because every other long-running action in this application reports through the log +/// panel, that silence read as success. +/// +internal static class FilePropagation +{ + /// + /// Works out where the propagated file goes in each repository the user checked. + /// + /// Every repository offered, and whether the user checked it. + /// The repositories, by name, as the options carry them. + /// The path being propagated, relative to a repository root. + /// The destination for each checked repository. Unchecked repositories are left out. + /// + /// The file lands at the same relative path in every repository, which is the whole idea: the + /// repositories are similar, and the file being propagated is the one they should share. + /// + internal static Dictionary ResolveDestinations( + IEnumerable> selection, + IReadOnlyDictionary repos, + string relativePath) + { + Ensure.NotNull(selection); + Ensure.NotNull(repos); + + return selection + .Where(kvp => kvp.Value) + .ToDictionary(kvp => kvp.Key, kvp => Path.Combine(repos[kvp.Key].LocalPath, relativePath)); + } + + /// + /// Renders a report as the log panel shows it. + /// + /// The run to describe. + /// When the run finished, which stamps the summary line. + /// The lines to write to the log, summary first. + /// + /// Only the summary is timestamped, with the per-repository detail indented under it. That is the + /// shape already gives a git command and its output, so + /// a propagation reads like everything else in the panel. + /// + internal static Collection DescribeForLog(FilePropagationReport report, DateTimeOffset at) + { + Ensure.NotNull(report); + + Collection lines = report.Summarize(); + lines[0] = $"[{at}] {lines[0]}"; + return lines; + } + + /// + /// Copies to every destination, continuing past a failure. + /// + /// The file to copy. + /// The repository each copy is for, and the full path to copy it to. + /// A report naming what happened to every requested repository. + /// + /// A missing source is checked once, before anything is copied. It is a different failure from a + /// locked destination -- one the user can only have caused by asking for the wrong file -- and + /// reporting it as one failure per repository would bury that. + /// + internal static FilePropagationReport Propagate(string source, IEnumerable> destinations) + { + Ensure.NotNull(destinations); + + Collection results = []; + + if (!File.Exists(source)) + { + return new(source, SourceExists: false, results); + } + + foreach ((FullyQualifiedGitHubRepoName repo, string destination) in destinations) + { + results.Add(Copy(source, repo, destination)); + } + + return new(source, SourceExists: true, results); + } + + private static FilePropagationResult Copy(string source, FullyQualifiedGitHubRepoName repo, string destination) + { + string? directory = Path.GetDirectoryName(destination); + if (string.IsNullOrEmpty(directory)) + { + return new(repo, destination, "the destination has no containing directory"); + } + + try + { + _ = Directory.CreateDirectory(directory); + File.Copy(source, destination, overwrite: true); + return new(repo, destination, null); + } + catch (IOException ex) + { + return new(repo, destination, ex.Message); + } + catch (UnauthorizedAccessException ex) + { + return new(repo, destination, ex.Message); + } + catch (ArgumentException ex) + { + return new(repo, destination, ex.Message); + } + } +} diff --git a/ProjectDirector/PopupPropagateFile.cs b/ProjectDirector/PopupPropagateFile.cs index 4ef6530..586863d 100644 --- a/ProjectDirector/PopupPropagateFile.cs +++ b/ProjectDirector/PopupPropagateFile.cs @@ -16,10 +16,17 @@ internal sealed class PopupPropagateFile private ImGuiPopups.Prompt Prompt { get; } = new(); private bool ShouldClose { get; set; } - public void Open(ProjectDirectorOptions options) + /// + /// Where this popup reports what propagating did, so a batch copy is accounted for in the log + /// panel the same way every git action already is. + /// + private Action Log { get; set; } = _ => { }; + + public void Open(ProjectDirectorOptions options, Action log) { ShouldClose = false; Options = options; + Log = log; Propagation.Clear(); Modal.Open("Propagate File", ShowContent); } @@ -72,19 +79,12 @@ private void Propagate() { GitRepository repo = Options.Repos[Options.BaseRepo]; string from = Path.Combine(repo.LocalPath, Options.PropagatePath); - foreach ((FullyQualifiedGitHubRepoName name, bool shouldPropagate) in Propagation) + Dictionary destinations = FilePropagation.ResolveDestinations(Propagation, Options.Repos, Options.PropagatePath); + FilePropagationReport report = FilePropagation.Propagate(from, destinations); + + foreach (string line in FilePropagation.DescribeForLog(report, DateTimeOffset.Now)) { - if (shouldPropagate) - { - GitRepository otherRepo = Options.Repos[name]; - string to = Path.Combine(otherRepo.LocalPath, Options.PropagatePath); - string? directory = Path.GetDirectoryName(to); - if (!string.IsNullOrEmpty(directory)) - { - _ = Directory.CreateDirectory(directory); - File.Copy(from, to, overwrite: true); - } - } + Log(line); } ShouldClose = true; diff --git a/ProjectDirector/ProjectDirector.cs b/ProjectDirector/ProjectDirector.cs index 7985cac..79d2af9 100644 --- a/ProjectDirector/ProjectDirector.cs +++ b/ProjectDirector/ProjectDirector.cs @@ -1720,7 +1720,7 @@ private void ShowRepoBrowser() if (shouldOpenPopup) { - PopupPropagateFile.Open(Options); + PopupPropagateFile.Open(Options, QueueLog); } _ = PopupPropagateFile.ShowIfOpen(); diff --git a/ProjectDirector/ProjectDirector.csproj b/ProjectDirector/ProjectDirector.csproj index 349308e..6d2ec1b 100644 --- a/ProjectDirector/ProjectDirector.csproj +++ b/ProjectDirector/ProjectDirector.csproj @@ -22,8 +22,8 @@ - - + +