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 @@
-
-
+
+