From ed7a6a8bbe767f8121da52034114e4022d84775c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 08:49:12 +0000 Subject: [PATCH 1/3] feat(repo): wire up --parallel build flag with exit-code aggregation Closes #35. RepoService.BuildAndTestAsync now actually runs solutions in parallel when the flag is set (Parallel.ForEachAsync with DOP = ProcessorCount/2, since dotnet build already multithreads internally), serialises console writes behind a lock, and aggregates failures into a named summary instead of just a count. --- KtsuTools.Repo/RepoService.cs | 115 ++++++++++++++++++++--------- KtsuTools.Test/RepoServiceTests.cs | 93 +++++++++++++++++++++++ 2 files changed, 174 insertions(+), 34 deletions(-) diff --git a/KtsuTools.Repo/RepoService.cs b/KtsuTools.Repo/RepoService.cs index bc522c4..b418442 100644 --- a/KtsuTools.Repo/RepoService.cs +++ b/KtsuTools.Repo/RepoService.cs @@ -88,7 +88,6 @@ await AnsiConsole.Status() /// public async Task BuildAndTestAsync(AbsoluteDirectoryPath path, bool parallel = false, CancellationToken ct = default) { - _ = parallel; Ensure.NotNull(path); string fullPath = path.ToString(); @@ -102,7 +101,8 @@ public async Task BuildAndTestAsync(AbsoluteDirectoryPath path, bool parall AnsiConsole.MarkupLine($"[blue]Found {solutionFiles.Count} solution(s).[/]"); - int failCount = 0; + ConcurrentBag failedSolutions = []; + object consoleLock = new(); await AnsiConsole.Progress() .AutoClear(false) @@ -111,50 +111,97 @@ await AnsiConsole.Progress() { ProgressTask task = progressContext.AddTask("[green]Building and testing[/]", maxValue: solutionFiles.Count); - foreach (string sln in solutionFiles) + if (parallel) { - ct.ThrowIfCancellationRequested(); - - string slnDir = Path.GetDirectoryName(sln) ?? fullPath; - string slnName = Path.GetFileNameWithoutExtension(sln); - - task.Description = $"[green]Building {slnName.EscapeMarkup()}[/]"; + // dotnet build already uses multiple cores internally; half of ProcessorCount keeps us from thrashing. + int dop = Math.Max(1, Environment.ProcessorCount / 2); + ParallelOptions options = new() { CancellationToken = ct, MaxDegreeOfParallelism = dop }; - // Build - ProcessResult buildResult = await processService.RunAsync(DotnetCommand, "build --nologo -v q", slnDir, ct).ConfigureAwait(false); - - if (buildResult.ExitCode != 0) + await Parallel.ForEachAsync(solutionFiles, options, async (sln, token) => { - AnsiConsole.MarkupLine($" [red]FAIL[/] {slnName.EscapeMarkup()} - build failed"); - failCount++; - task.Increment(1); - continue; - } - - // Test - ProcessResult testResult = await processService.RunAsync(DotnetCommand, "test --nologo --no-build -v q", slnDir, ct).ConfigureAwait(false); - - if (testResult.ExitCode != 0) - { - AnsiConsole.MarkupLine($" [yellow]WARN[/] {slnName.EscapeMarkup()} - build OK, tests failed"); - failCount++; - } - else + await BuildAndTestSolutionAsync(sln, fullPath, task, consoleLock, failedSolutions, token).ConfigureAwait(false); + }).ConfigureAwait(false); + } + else + { + foreach (string sln in solutionFiles) { - AnsiConsole.MarkupLine($" [green]OK[/] {slnName.EscapeMarkup()}"); + ct.ThrowIfCancellationRequested(); + await BuildAndTestSolutionAsync(sln, fullPath, task, consoleLock, failedSolutions, ct).ConfigureAwait(false); } - - task.Increment(1); } }).ConfigureAwait(false); - AnsiConsole.MarkupLine(failCount > 0 - ? $"[yellow]Done. {failCount} solution(s) had failures.[/]" - : "[green]Done. All solutions built and tested successfully.[/]"); + int failCount = failedSolutions.Count; + + if (failCount > 0) + { + AnsiConsole.MarkupLine($"[yellow]Done. {failCount} solution(s) had failures:[/]"); + foreach (string name in failedSolutions.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)) + { + AnsiConsole.MarkupLine($" [red]-[/] {name.EscapeMarkup()}"); + } + } + else + { + AnsiConsole.MarkupLine("[green]Done. All solutions built and tested successfully.[/]"); + } return failCount > 0 ? 1 : 0; } + private async Task BuildAndTestSolutionAsync( + string sln, + string fullPath, + ProgressTask task, + object consoleLock, + ConcurrentBag failedSolutions, + CancellationToken ct) + { + string slnDir = Path.GetDirectoryName(sln) ?? fullPath; + string slnName = Path.GetFileNameWithoutExtension(sln); + + lock (consoleLock) + { + task.Description = $"[green]Building {slnName.EscapeMarkup()}[/]"; + } + + ProcessResult buildResult = await processService.RunAsync(DotnetCommand, "build --nologo -v q", slnDir, ct).ConfigureAwait(false); + + if (buildResult.ExitCode != 0) + { + lock (consoleLock) + { + AnsiConsole.MarkupLine($" [red]FAIL[/] {slnName.EscapeMarkup()} - build failed"); + task.Increment(1); + } + + failedSolutions.Add(slnName); + return; + } + + ProcessResult testResult = await processService.RunAsync(DotnetCommand, "test --nologo --no-build -v q", slnDir, ct).ConfigureAwait(false); + + lock (consoleLock) + { + if (testResult.ExitCode != 0) + { + AnsiConsole.MarkupLine($" [yellow]WARN[/] {slnName.EscapeMarkup()} - build OK, tests failed"); + } + else + { + AnsiConsole.MarkupLine($" [green]OK[/] {slnName.EscapeMarkup()}"); + } + + task.Increment(1); + } + + if (testResult.ExitCode != 0) + { + failedSolutions.Add(slnName); + } + } + /// /// Pulls all repositories under the given path. /// diff --git a/KtsuTools.Test/RepoServiceTests.cs b/KtsuTools.Test/RepoServiceTests.cs index 6334850..7273393 100644 --- a/KtsuTools.Test/RepoServiceTests.cs +++ b/KtsuTools.Test/RepoServiceTests.cs @@ -4,7 +4,11 @@ namespace KtsuTools.Test; +using System.Collections.Generic; +using System.Diagnostics; using System.IO; +using System.Threading; +using System.Threading.Tasks; using ktsu.Semantics.Paths; using KtsuTools.Core.Services.Git; using KtsuTools.Core.Services.Process; @@ -50,4 +54,93 @@ public async Task DiscoverRepositoriesAsyncFindsGitDirectories() Directory.Delete(root, recursive: true); } } + + [TestMethod] + public async Task BuildAndTestAsyncParallelAggregatesExitCodes() + { + string root = Path.Combine(Path.GetTempPath(), $"ktsu_par_{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + // Two top-level solutions in separate subdirs. + string slnAPath = Path.Combine(root, "a", "A.sln"); + string slnBPath = Path.Combine(root, "b", "B.sln"); + Directory.CreateDirectory(Path.GetDirectoryName(slnAPath)!); + Directory.CreateDirectory(Path.GetDirectoryName(slnBPath)!); + await File.WriteAllTextAsync(slnAPath, string.Empty).ConfigureAwait(false); + await File.WriteAllTextAsync(slnBPath, string.Empty).ConfigureAwait(false); + + DelayedFakeProcessService fake = new(delayMs: 50, failBuildInDirNamed: "b"); + RepoService service = new(new Mock().Object, fake); + AbsoluteDirectoryPath rootPath = AbsoluteDirectoryPath.Create(root); + + int exit = await service.BuildAndTestAsync(rootPath, parallel: true).ConfigureAwait(false); + + Assert.AreEqual(1, exit, "Aggregated exit code should be non-zero when any solution fails."); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [TestMethod] + public async Task BuildAndTestAsyncParallelIsFasterThanSequential() + { + string root = Path.Combine(Path.GetTempPath(), $"ktsu_parspeed_{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + for (int i = 0; i < 4; i++) + { + string dir = Path.Combine(root, $"s{i}"); + Directory.CreateDirectory(dir); + await File.WriteAllTextAsync(Path.Combine(dir, $"S{i}.sln"), string.Empty).ConfigureAwait(false); + } + + AbsoluteDirectoryPath rootPath = AbsoluteDirectoryPath.Create(root); + + DelayedFakeProcessService fakeSeq = new(delayMs: 120); + RepoService seqService = new(new Mock().Object, fakeSeq); + Stopwatch swSeq = Stopwatch.StartNew(); + await seqService.BuildAndTestAsync(rootPath, parallel: false).ConfigureAwait(false); + swSeq.Stop(); + + DelayedFakeProcessService fakePar = new(delayMs: 120); + RepoService parService = new(new Mock().Object, fakePar); + Stopwatch swPar = Stopwatch.StartNew(); + await parService.BuildAndTestAsync(rootPath, parallel: true).ConfigureAwait(false); + swPar.Stop(); + + Assert.IsTrue( + swPar.ElapsedMilliseconds < swSeq.ElapsedMilliseconds, + $"Parallel ({swPar.ElapsedMilliseconds} ms) should be faster than sequential ({swSeq.ElapsedMilliseconds} ms)."); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + private sealed class DelayedFakeProcessService(int delayMs, string? failBuildInDirNamed = null) : IProcessService + { + public Task RunAsync(string command, string arguments, string? workingDirectory = null, CancellationToken ct = default) => + RunAsync(command, arguments, workingDirectory, null, ct); + + public async Task RunAsync(string command, string arguments, string? workingDirectory, IDictionary? environmentVariables, CancellationToken ct = default) + { + await Task.Delay(delayMs, ct).ConfigureAwait(false); + + int exit = 0; + if (failBuildInDirNamed is not null && + arguments.StartsWith("build", StringComparison.Ordinal) && + workingDirectory is not null && + string.Equals(Path.GetFileName(workingDirectory), failBuildInDirNamed, StringComparison.OrdinalIgnoreCase)) + { + exit = 1; + } + + return new ProcessResult(exit, [], []); + } + } } From 1434cc809bbc232c33519e52ea23fa30884594dd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 08:49:17 +0000 Subject: [PATCH 2/3] feat(merge): add --diff-style flag (side-by-side default, git unified) Closes #37. Introduces a DiffStyle enum plumbed through MergeService.RunMergeAsync, with a git/unified renderer backed by DiffPlex's InlineDiffBuilder alongside the existing side-by-side renderer (both capped at 50 lines). DiffStyleParser centralises the string<->enum mapping shared by MergeCommand, MergeBatchSaveCommand and the persisted batch JSON. Unknown values are rejected at the Spectre validation layer. --- KtsuTools.Merge/DiffStyleParser.cs | 48 ++++++++++++ KtsuTools.Merge/MergeService.cs | 84 ++++++++++++++++++--- KtsuTools.Test/MergeServiceTests.cs | 34 +++++++++ KtsuTools/Commands/MergeBatchSaveCommand.cs | 20 ++++- KtsuTools/Commands/MergeCommand.cs | 21 ++++++ 5 files changed, 196 insertions(+), 11 deletions(-) create mode 100644 KtsuTools.Merge/DiffStyleParser.cs diff --git a/KtsuTools.Merge/DiffStyleParser.cs b/KtsuTools.Merge/DiffStyleParser.cs new file mode 100644 index 0000000..9018b71 --- /dev/null +++ b/KtsuTools.Merge/DiffStyleParser.cs @@ -0,0 +1,48 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace KtsuTools.Merge; + +using System; + +/// +/// Maps the string form of used in CLI flags and persisted batch +/// configs (e.g. "side-by-side", "git") to the enum and back. +/// +public static class DiffStyleParser +{ + public const string SideBySideName = "side-by-side"; + public const string GitName = "git"; + + public static bool TryParse(string? value, out DiffStyle style) + { + if (string.IsNullOrWhiteSpace(value)) + { + style = DiffStyle.SideBySide; + return true; + } + + switch (value.Trim().ToLowerInvariant()) + { + case SideBySideName: + case "sidebyside": + case "side": + style = DiffStyle.SideBySide; + return true; + case GitName: + case "unified": + style = DiffStyle.Git; + return true; + default: + style = DiffStyle.SideBySide; + return false; + } + } + + public static string ToCanonicalString(DiffStyle style) => style switch + { + DiffStyle.Git => GitName, + _ => SideBySideName, + }; +} diff --git a/KtsuTools.Merge/MergeService.cs b/KtsuTools.Merge/MergeService.cs index 2503e84..d8d6f59 100644 --- a/KtsuTools.Merge/MergeService.cs +++ b/KtsuTools.Merge/MergeService.cs @@ -41,6 +41,18 @@ public enum BlockChoice Skip, } +/// +/// Diff rendering style for conflict display. +/// +public enum DiffStyle +{ + /// DiffPlex side-by-side renderer (default; preserves prior behaviour). + SideBySide, + + /// Git-style unified diff via DiffPlex UnifiedDiffBuilder. + Git, +} + /// /// Service for N-way iterative file merging with interactive conflict resolution. /// @@ -51,10 +63,11 @@ public class MergeService /// /// Absolute root directory under which to search. /// Glob pattern to match against filenames. + /// How to render conflict diffs. /// Cancellation token. /// Exit code (0 for success). #pragma warning disable CA1822 // Mark members as static - instance method required for DI injection - public async Task RunMergeAsync(AbsoluteDirectoryPath directory, string filename, CancellationToken ct = default) + public async Task RunMergeAsync(AbsoluteDirectoryPath directory, string filename, DiffStyle diffStyle = DiffStyle.SideBySide, CancellationToken ct = default) #pragma warning restore CA1822 { Ensure.NotNull(directory); @@ -113,7 +126,7 @@ public async Task RunMergeAsync(AbsoluteDirectoryPath directory, string fil string content2 = await File.ReadAllTextAsync(bestPair.FilePath2, ct).ConfigureAwait(false); // Show diff - ShowDiff(content1, content2, bestPair.FilePath1, bestPair.FilePath2); + ShowDiff(content1, content2, bestPair.FilePath1, bestPair.FilePath2, diffStyle); // Interactive merge string mergedContent = InteractiveMerge(content1, content2); @@ -235,15 +248,34 @@ private static double CalculateSimilarity(string content1, string content2) return Math.Max(0.0, (double)unchangedLines / totalLines); } - private static void ShowDiff(string content1, string content2, string path1, string path2) - { - SideBySideDiffBuilder diffBuilder = new(new Differ()); - SideBySideDiffModel diff = diffBuilder.BuildDiffModel(content1, content2); + private const int DiffLineCap = 50; + private static void ShowDiff(string content1, string content2, string path1, string path2, DiffStyle style) + { AnsiConsole.MarkupLine($"[dim]--- {Path.GetFileName(path1).EscapeMarkup()}[/]"); AnsiConsole.MarkupLine($"[dim]+++ {Path.GetFileName(path2).EscapeMarkup()}[/]"); - int maxLines = Math.Min(50, Math.Max(diff.OldText.Lines.Count, diff.NewText.Lines.Count)); + switch (style) + { + case DiffStyle.Git: + ShowUnifiedDiff(content1, content2); + break; + case DiffStyle.SideBySide: + default: + ShowSideBySideDiff(content1, content2); + break; + } + + AnsiConsole.WriteLine(); + } + + private static void ShowSideBySideDiff(string content1, string content2) + { + SideBySideDiffBuilder diffBuilder = new(new Differ()); + SideBySideDiffModel diff = diffBuilder.BuildDiffModel(content1, content2); + + int total = Math.Max(diff.OldText.Lines.Count, diff.NewText.Lines.Count); + int maxLines = Math.Min(DiffLineCap, total); for (int i = 0; i < maxLines; i++) { @@ -268,12 +300,46 @@ private static void ShowDiff(string content1, string content2, string path1, str } } - if (Math.Max(diff.OldText.Lines.Count, diff.NewText.Lines.Count) > maxLines) + if (total > maxLines) { AnsiConsole.MarkupLine("[dim]... (truncated)[/]"); } + } - AnsiConsole.WriteLine(); + private static void ShowUnifiedDiff(string content1, string content2) + { + DiffPaneModel diff = InlineDiffBuilder.Diff(content1, content2); + + int rendered = 0; + foreach (DiffPiece line in diff.Lines) + { + if (rendered >= DiffLineCap) + { + AnsiConsole.MarkupLine("[dim]... (truncated)[/]"); + return; + } + + string text = line.Text?.EscapeMarkup() ?? string.Empty; + switch (line.Type) + { + case ChangeType.Inserted: + AnsiConsole.MarkupLine($"[green]+{text}[/]"); + break; + case ChangeType.Deleted: + AnsiConsole.MarkupLine($"[red]-{text}[/]"); + break; + case ChangeType.Modified: + AnsiConsole.MarkupLine($"[red]-{text}[/]"); + break; + case ChangeType.Unchanged: + AnsiConsole.MarkupLine($"[dim] {text}[/]"); + break; + case ChangeType.Imaginary: + continue; + } + + rendered++; + } } private static string InteractiveMerge(string content1, string content2) diff --git a/KtsuTools.Test/MergeServiceTests.cs b/KtsuTools.Test/MergeServiceTests.cs index a7638ea..c790070 100644 --- a/KtsuTools.Test/MergeServiceTests.cs +++ b/KtsuTools.Test/MergeServiceTests.cs @@ -62,4 +62,38 @@ public async Task RunMergeAsyncAllMatchesIdenticalReturnsZeroWithoutPrompting() Directory.Delete(root, recursive: true); } } + + [TestMethod] + public void DiffStyleParserAcceptsCanonicalNames() + { + Assert.IsTrue(DiffStyleParser.TryParse("side-by-side", out DiffStyle side)); + Assert.AreEqual(DiffStyle.SideBySide, side); + + Assert.IsTrue(DiffStyleParser.TryParse("git", out DiffStyle git)); + Assert.AreEqual(DiffStyle.Git, git); + } + + [TestMethod] + public void DiffStyleParserDefaultsForNullOrEmpty() + { + Assert.IsTrue(DiffStyleParser.TryParse(null, out DiffStyle a)); + Assert.AreEqual(DiffStyle.SideBySide, a); + + Assert.IsTrue(DiffStyleParser.TryParse(" ", out DiffStyle b)); + Assert.AreEqual(DiffStyle.SideBySide, b); + } + + [TestMethod] + public void DiffStyleParserRejectsUnknownValues() + { + Assert.IsFalse(DiffStyleParser.TryParse("rainbow", out DiffStyle _)); + Assert.IsFalse(DiffStyleParser.TryParse("ColoredDeluxe", out DiffStyle _)); + } + + [TestMethod] + public void DiffStyleParserRoundTripsCanonicalForm() + { + Assert.AreEqual("side-by-side", DiffStyleParser.ToCanonicalString(DiffStyle.SideBySide)); + Assert.AreEqual("git", DiffStyleParser.ToCanonicalString(DiffStyle.Git)); + } } diff --git a/KtsuTools/Commands/MergeBatchSaveCommand.cs b/KtsuTools/Commands/MergeBatchSaveCommand.cs index c7af4f5..e4eea8f 100644 --- a/KtsuTools/Commands/MergeBatchSaveCommand.cs +++ b/KtsuTools/Commands/MergeBatchSaveCommand.cs @@ -29,8 +29,18 @@ public sealed class Settings : CommandSettings public required string Filename { get; init; } [CommandOption("--diff-style