From 220e7684eaaae2da8affca3ad4bcd8ec08b719f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 07:14:13 +0000 Subject: [PATCH 1/2] docs: point users to KtsuBuild for per-repo release automation Documents the deliberate split between cross-repo orchestration (this tool) and per-repo release automation (KtsuBuild) so users know where to look. Per the recommendation on #9. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index b541fc4..c446bc2 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,7 @@ ktools [options] ``` Run `ktools --help` for a list of available commands. + +## Related tools + +For per-repo release automation (semver bumps, changelog, publish, package manifest emission), see [KtsuBuild](https://github.com/ktsu-dev/KtsuBuild). KtsuTools focuses on cross-repo orchestration; KtsuBuild handles the inside-one-repo release workflow. The two are complementary and intentionally not merged. From 6540d64cdf30f381d055e81dbf6b7fecab69ce85 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 07:20:23 +0000 Subject: [PATCH 2/2] feat(merge): add saved batch CRUD and --batch invocation (#36) Adds a MergeBatchService backed by AppData persistence so users can save named bundles of merge inputs and re-run them with `--batch`. New top-level branch `merge-batch` exposes save/list/show/delete; the existing `merge ` shape is unchanged. Per the audit on #15 (sub-task A). Note the issue spec called for `merge batch `; Spectre.Console.Cli doesn't easily support a default command on a branch alongside subcommands, so this lands as a sibling top-level branch `merge-batch` instead. CLI shape can be revisited later without breaking storage. --- KtsuTools.Merge/MergeBatchService.cs | 45 ++++++++++ KtsuTools.Merge/MergeBatchSettings.cs | 20 +++++ KtsuTools.Test/MergeBatchServiceTests.cs | 87 +++++++++++++++++++ KtsuTools/Commands/MergeBatchDeleteCommand.cs | 39 +++++++++ KtsuTools/Commands/MergeBatchListCommand.cs | 48 ++++++++++ KtsuTools/Commands/MergeBatchSaveCommand.cs | 52 +++++++++++ KtsuTools/Commands/MergeBatchShowCommand.cs | 40 +++++++++ KtsuTools/Commands/MergeCommand.cs | 69 +++++++++++++-- KtsuTools/Program.cs | 25 +++++- 9 files changed, 417 insertions(+), 8 deletions(-) create mode 100644 KtsuTools.Merge/MergeBatchService.cs create mode 100644 KtsuTools.Merge/MergeBatchSettings.cs create mode 100644 KtsuTools.Test/MergeBatchServiceTests.cs create mode 100644 KtsuTools/Commands/MergeBatchDeleteCommand.cs create mode 100644 KtsuTools/Commands/MergeBatchListCommand.cs create mode 100644 KtsuTools/Commands/MergeBatchSaveCommand.cs create mode 100644 KtsuTools/Commands/MergeBatchShowCommand.cs diff --git a/KtsuTools.Merge/MergeBatchService.cs b/KtsuTools.Merge/MergeBatchService.cs new file mode 100644 index 0000000..a51ad85 --- /dev/null +++ b/KtsuTools.Merge/MergeBatchService.cs @@ -0,0 +1,45 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace KtsuTools.Merge; + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using KtsuTools.Core.Services.Settings; + +public class MergeBatchService(ISettingsService settingsService) +{ + private readonly ISettingsService _settings = settingsService; + private MergeBatchSettings? _store; + + public IReadOnlyDictionary List() => GetStore().Batches; + + public MergeBatchEntry? Get(string name) => + GetStore().Batches.TryGetValue(name, out MergeBatchEntry? entry) ? entry : null; + + public async Task SaveAsync(string name, MergeBatchEntry entry, CancellationToken ct = default) + { + Ensure.NotNull(name); + Ensure.NotNull(entry); + MergeBatchSettings store = GetStore(); + store.Batches[name] = entry; + await _settings.SaveAsync(store).ConfigureAwait(false); + } + + public async Task DeleteAsync(string name, CancellationToken ct = default) + { + Ensure.NotNull(name); + MergeBatchSettings store = GetStore(); + if (!store.Batches.Remove(name)) + { + return false; + } + + await _settings.SaveAsync(store).ConfigureAwait(false); + return true; + } + + private MergeBatchSettings GetStore() => _store ??= _settings.LoadOrCreate(); +} diff --git a/KtsuTools.Merge/MergeBatchSettings.cs b/KtsuTools.Merge/MergeBatchSettings.cs new file mode 100644 index 0000000..e699c56 --- /dev/null +++ b/KtsuTools.Merge/MergeBatchSettings.cs @@ -0,0 +1,20 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace KtsuTools.Merge; + +using System.Collections.Generic; +using ktsu.AppDataStorage; + +public sealed record MergeBatchEntry +{ + public required string Directory { get; init; } + public required string Filename { get; init; } + public string? DiffStyle { get; init; } +} + +public class MergeBatchSettings : AppData +{ + public Dictionary Batches { get; init; } = []; +} diff --git a/KtsuTools.Test/MergeBatchServiceTests.cs b/KtsuTools.Test/MergeBatchServiceTests.cs new file mode 100644 index 0000000..1155f91 --- /dev/null +++ b/KtsuTools.Test/MergeBatchServiceTests.cs @@ -0,0 +1,87 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace KtsuTools.Test; + +using KtsuTools.Core.Services.Settings; +using KtsuTools.Merge; +using Moq; + +[TestClass] +public class MergeBatchServiceTests +{ + private static (MergeBatchService Service, MergeBatchSettings Store, Mock SettingsMock) BuildService() + { + MergeBatchSettings store = new(); + Mock settings = new(); + settings.Setup(s => s.LoadOrCreate()).Returns(store); + settings.Setup(s => s.SaveAsync(It.IsAny())).Returns(Task.CompletedTask); + MergeBatchService service = new(settings.Object); + return (service, store, settings); + } + + [TestMethod] + public async Task SaveListShowDeleteRoundTrip() + { + (MergeBatchService service, MergeBatchSettings store, Mock settings) = BuildService(); + + MergeBatchEntry entry = new() + { + Directory = "/tmp/repos", + Filename = "*.yml", + DiffStyle = "side-by-side", + }; + + await service.SaveAsync("ci-yaml", entry).ConfigureAwait(false); + + Assert.AreEqual(1, service.List().Count); + Assert.IsTrue(service.List().ContainsKey("ci-yaml")); + + MergeBatchEntry? loaded = service.Get("ci-yaml"); + Assert.IsNotNull(loaded); + Assert.AreEqual("/tmp/repos", loaded.Directory); + Assert.AreEqual("*.yml", loaded.Filename); + Assert.AreEqual("side-by-side", loaded.DiffStyle); + + bool removed = await service.DeleteAsync("ci-yaml").ConfigureAwait(false); + Assert.IsTrue(removed); + Assert.AreEqual(0, service.List().Count); + Assert.IsNull(service.Get("ci-yaml")); + + settings.Verify(s => s.SaveAsync(store), Times.Exactly(2)); + } + + [TestMethod] + public async Task DeleteUnknownReturnsFalseAndDoesNotPersist() + { + (MergeBatchService service, _, Mock settings) = BuildService(); + + bool removed = await service.DeleteAsync("missing").ConfigureAwait(false); + + Assert.IsFalse(removed); + settings.Verify(s => s.SaveAsync(It.IsAny()), Times.Never); + } + + [TestMethod] + public void GetUnknownReturnsNull() + { + (MergeBatchService service, _, _) = BuildService(); + Assert.IsNull(service.Get("nope")); + } + + [TestMethod] + public async Task SaveOverwritesExistingEntry() + { + (MergeBatchService service, _, _) = BuildService(); + + await service.SaveAsync("name", new MergeBatchEntry { Directory = "a", Filename = "b" }).ConfigureAwait(false); + await service.SaveAsync("name", new MergeBatchEntry { Directory = "c", Filename = "d", DiffStyle = "git" }).ConfigureAwait(false); + + MergeBatchEntry? loaded = service.Get("name"); + Assert.IsNotNull(loaded); + Assert.AreEqual("c", loaded.Directory); + Assert.AreEqual("d", loaded.Filename); + Assert.AreEqual("git", loaded.DiffStyle); + } +} diff --git a/KtsuTools/Commands/MergeBatchDeleteCommand.cs b/KtsuTools/Commands/MergeBatchDeleteCommand.cs new file mode 100644 index 0000000..74eac6c --- /dev/null +++ b/KtsuTools/Commands/MergeBatchDeleteCommand.cs @@ -0,0 +1,39 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace KtsuTools.Commands; + +using System.ComponentModel; +using KtsuTools.Core.UI; +using KtsuTools.Merge; +using Spectre.Console; +using Spectre.Console.Cli; + +public sealed class MergeBatchDeleteCommand(MergeBatchService batchService) : AsyncCommand +{ + private readonly MergeBatchService batchService = batchService; + + public sealed class Settings : CommandSettings + { + [CommandArgument(0, "")] + [Description("Name of the batch to delete")] + public required string Name { get; init; } + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + Ensure.NotNull(settings); + using CtrlCScope scope = new(); + + bool removed = await batchService.DeleteAsync(settings.Name, scope.Token).ConfigureAwait(false); + if (!removed) + { + AnsiConsole.MarkupLine($"[red]No batch named '{settings.Name.EscapeMarkup()}'.[/]"); + return 1; + } + + AnsiConsole.MarkupLine($"[green]Deleted batch '{settings.Name.EscapeMarkup()}'.[/]"); + return 0; + } +} diff --git a/KtsuTools/Commands/MergeBatchListCommand.cs b/KtsuTools/Commands/MergeBatchListCommand.cs new file mode 100644 index 0000000..5fe3d76 --- /dev/null +++ b/KtsuTools/Commands/MergeBatchListCommand.cs @@ -0,0 +1,48 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace KtsuTools.Commands; + +using System.Collections.Generic; +using KtsuTools.Merge; +using Spectre.Console; +using Spectre.Console.Cli; + +public sealed class MergeBatchListCommand(MergeBatchService batchService) : Command +{ + private readonly MergeBatchService batchService = batchService; + + public sealed class Settings : CommandSettings + { + } + + public override int Execute(CommandContext context, Settings settings) + { + IReadOnlyDictionary batches = batchService.List(); + + if (batches.Count == 0) + { + AnsiConsole.MarkupLine("[dim]No saved batches. Use 'merge-batch save ' to create one.[/]"); + return 0; + } + + Table table = new(); + table.AddColumn("Name"); + table.AddColumn("Directory"); + table.AddColumn("Filename"); + table.AddColumn("Diff Style"); + + foreach (KeyValuePair kvp in batches) + { + table.AddRow( + kvp.Key.EscapeMarkup(), + kvp.Value.Directory.EscapeMarkup(), + kvp.Value.Filename.EscapeMarkup(), + (kvp.Value.DiffStyle ?? "(default)").EscapeMarkup()); + } + + AnsiConsole.Write(table); + return 0; + } +} diff --git a/KtsuTools/Commands/MergeBatchSaveCommand.cs b/KtsuTools/Commands/MergeBatchSaveCommand.cs new file mode 100644 index 0000000..c7af4f5 --- /dev/null +++ b/KtsuTools/Commands/MergeBatchSaveCommand.cs @@ -0,0 +1,52 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace KtsuTools.Commands; + +using System.ComponentModel; +using KtsuTools.Core.UI; +using KtsuTools.Merge; +using Spectre.Console; +using Spectre.Console.Cli; + +public sealed class MergeBatchSaveCommand(MergeBatchService batchService) : AsyncCommand +{ + private readonly MergeBatchService batchService = batchService; + + public sealed class Settings : CommandSettings + { + [CommandArgument(0, "")] + [Description("Name to save this batch under")] + public required string Name { get; init; } + + [CommandArgument(1, "")] + [Description("Directory containing files to merge")] + public required string Directory { get; init; } + + [CommandArgument(2, "")] + [Description("Filename pattern to merge")] + public required string Filename { get; init; } + + [CommandOption("--diff-style