diff --git a/Directory.Packages.props b/Directory.Packages.props index 83d9ded..6011140 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -43,6 +43,7 @@ + diff --git a/KtsuTools.Core/KtsuTools.Core.csproj b/KtsuTools.Core/KtsuTools.Core.csproj index 653a7ff..185e564 100644 --- a/KtsuTools.Core/KtsuTools.Core.csproj +++ b/KtsuTools.Core/KtsuTools.Core.csproj @@ -13,6 +13,7 @@ + diff --git a/KtsuTools.Core/Services/Credentials/CredentialService.cs b/KtsuTools.Core/Services/Credentials/CredentialService.cs deleted file mode 100644 index d09eb6b..0000000 --- a/KtsuTools.Core/Services/Credentials/CredentialService.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) ktsu.dev -// All rights reserved. -// Licensed under the MIT license. - -namespace KtsuTools.Core.Services.Credentials; - -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using ktsu.AppDataStorage; -using KtsuTools.Core.Services.Settings; -using Spectre.Console; - -public class CredentialStore : AppData -{ - public Dictionary Credentials { get; init; } = []; -} - -public class CredentialService(ISettingsService settingsService) : ICredentialService -{ - private readonly ISettingsService _settings = settingsService; - private CredentialStore? _store; - - public async Task GetCredentialAsync(string key, string prompt, bool isSecret = true, CancellationToken ct = default) - { - CredentialStore store = GetStore(); - - if (store.Credentials.TryGetValue(key, out string? existing)) - { - return existing; - } - - TextPrompt textPrompt = new(prompt); - if (isSecret) - { - textPrompt.Secret(); - } - - string value = AnsiConsole.Prompt(textPrompt); - - if (!string.IsNullOrWhiteSpace(value)) - { - store.Credentials[key] = value; - await _settings.SaveAsync(store).ConfigureAwait(false); - } - - return value; - } - - public Task SaveCredentialAsync(string key, string value, CancellationToken ct = default) - { - CredentialStore store = GetStore(); - store.Credentials[key] = value; - return _settings.SaveAsync(store); - } - - public Task HasCredentialAsync(string key, CancellationToken ct = default) - { - CredentialStore store = GetStore(); - return Task.FromResult(store.Credentials.ContainsKey(key)); - } - - private CredentialStore GetStore() => _store ??= _settings.LoadOrCreate(); -} diff --git a/KtsuTools.Core/Services/Credentials/ICredentialService.cs b/KtsuTools.Core/Services/Credentials/ICredentialService.cs deleted file mode 100644 index 86b3deb..0000000 --- a/KtsuTools.Core/Services/Credentials/ICredentialService.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) ktsu.dev -// All rights reserved. -// Licensed under the MIT license. - -namespace KtsuTools.Core.Services.Credentials; - -using System.Threading; -using System.Threading.Tasks; - -public interface ICredentialService -{ - public Task GetCredentialAsync(string key, string prompt, bool isSecret = true, CancellationToken ct = default); - public Task SaveCredentialAsync(string key, string value, CancellationToken ct = default); - public Task HasCredentialAsync(string key, CancellationToken ct = default); -} diff --git a/KtsuTools.Core/Services/DependencyInjection.cs b/KtsuTools.Core/Services/DependencyInjection.cs index 25e48e1..b60782c 100644 --- a/KtsuTools.Core/Services/DependencyInjection.cs +++ b/KtsuTools.Core/Services/DependencyInjection.cs @@ -4,7 +4,6 @@ namespace KtsuTools.Core.Services; -using KtsuTools.Core.Services.Credentials; using KtsuTools.Core.Services.Git; using KtsuTools.Core.Services.GitHub; using KtsuTools.Core.Services.Process; @@ -19,7 +18,6 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); return services; } diff --git a/KtsuTools.Merge/MergeHistoryService.cs b/KtsuTools.Merge/MergeHistoryService.cs new file mode 100644 index 0000000..eb81db9 --- /dev/null +++ b/KtsuTools.Merge/MergeHistoryService.cs @@ -0,0 +1,57 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace KtsuTools.Merge; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using KtsuTools.Core.Services.Settings; + +public class MergeHistoryService(ISettingsService settingsService) +{ + public const int MaxEntries = 50; + + private readonly ISettingsService _settings = settingsService; + private MergeHistorySettings? _store; + + public IReadOnlyList List() + { + MergeHistorySettings store = GetStore(); + // Most-recent first. + return [.. store.Entries.OrderByDescending(e => e.Timestamp)]; + } + + public async Task RecordAsync(MergeHistoryEntry entry, CancellationToken ct = default) + { + Ensure.NotNull(entry); + MergeHistorySettings store = GetStore(); + store.Entries.Add(entry); + + while (store.Entries.Count > MaxEntries) + { + // Evict the oldest. + MergeHistoryEntry oldest = store.Entries.OrderBy(e => e.Timestamp).First(); + store.Entries.Remove(oldest); + } + + await _settings.SaveAsync(store).ConfigureAwait(false); + } + + public async Task ClearAsync(CancellationToken ct = default) + { + MergeHistorySettings store = GetStore(); + if (store.Entries.Count == 0) + { + return; + } + + store.Entries.Clear(); + await _settings.SaveAsync(store).ConfigureAwait(false); + } + + private MergeHistorySettings GetStore() => _store ??= _settings.LoadOrCreate(); +} diff --git a/KtsuTools.Merge/MergeHistorySettings.cs b/KtsuTools.Merge/MergeHistorySettings.cs new file mode 100644 index 0000000..e4b8678 --- /dev/null +++ b/KtsuTools.Merge/MergeHistorySettings.cs @@ -0,0 +1,24 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace KtsuTools.Merge; + +using System; +using System.Collections.Generic; +using ktsu.AppDataStorage; + +public sealed record MergeHistoryEntry +{ + public required DateTimeOffset Timestamp { get; init; } + public required string Directory { get; init; } + public required string Filename { get; init; } + public required string DiffStyle { get; init; } + public string? BatchName { get; init; } + public required int ExitCode { get; init; } +} + +public class MergeHistorySettings : AppData +{ + public List Entries { get; init; } = []; +} diff --git a/KtsuTools.Test/MergeHistoryServiceTests.cs b/KtsuTools.Test/MergeHistoryServiceTests.cs new file mode 100644 index 0000000..f581f67 --- /dev/null +++ b/KtsuTools.Test/MergeHistoryServiceTests.cs @@ -0,0 +1,101 @@ +// 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 MergeHistoryServiceTests +{ + private static (MergeHistoryService Service, MergeHistorySettings Store, Mock SettingsMock) BuildService() + { + MergeHistorySettings store = new(); + Mock settings = new(); + settings.Setup(s => s.LoadOrCreate()).Returns(store); + settings.Setup(s => s.SaveAsync(It.IsAny())).Returns(Task.CompletedTask); + MergeHistoryService service = new(settings.Object); + return (service, store, settings); + } + + private static MergeHistoryEntry MakeEntry(DateTimeOffset ts, int exit = 0, string? batch = null) => new() + { + Timestamp = ts, + Directory = "/tmp/repos", + Filename = ".editorconfig", + DiffStyle = "side-by-side", + BatchName = batch, + ExitCode = exit, + }; + + [TestMethod] + public async Task RecordRoundTripsAndListReturnsMostRecentFirst() + { + (MergeHistoryService service, _, _) = BuildService(); + + DateTimeOffset t0 = new(2026, 5, 11, 8, 0, 0, TimeSpan.Zero); + await service.RecordAsync(MakeEntry(t0)).ConfigureAwait(false); + await service.RecordAsync(MakeEntry(t0.AddMinutes(1))).ConfigureAwait(false); + await service.RecordAsync(MakeEntry(t0.AddMinutes(2))).ConfigureAwait(false); + + IReadOnlyList entries = service.List(); + + Assert.AreEqual(3, entries.Count); + Assert.AreEqual(t0.AddMinutes(2), entries[0].Timestamp); + Assert.AreEqual(t0.AddMinutes(1), entries[1].Timestamp); + Assert.AreEqual(t0, entries[2].Timestamp); + } + + [TestMethod] + public async Task RecordCapsAtMaxEntriesAndEvictsOldest() + { + (MergeHistoryService service, MergeHistorySettings store, _) = BuildService(); + + DateTimeOffset t0 = new(2026, 5, 11, 8, 0, 0, TimeSpan.Zero); + for (int i = 0; i < MergeHistoryService.MaxEntries + 1; i++) + { + await service.RecordAsync(MakeEntry(t0.AddSeconds(i))).ConfigureAwait(false); + } + + Assert.AreEqual(MergeHistoryService.MaxEntries, store.Entries.Count, "Store is capped at MaxEntries."); + + // The original first entry (i=0) should have been evicted. + Assert.IsFalse(store.Entries.Exists(e => e.Timestamp == t0), "Oldest entry was evicted."); + Assert.IsTrue(store.Entries.Exists(e => e.Timestamp == t0.AddSeconds(MergeHistoryService.MaxEntries)), + "Newest entry is retained."); + } + + [TestMethod] + public async Task ClearEmptiesTheStoreAndIsIdempotent() + { + (MergeHistoryService service, MergeHistorySettings store, Mock settings) = BuildService(); + + await service.RecordAsync(MakeEntry(DateTimeOffset.UtcNow)).ConfigureAwait(false); + Assert.AreEqual(1, store.Entries.Count); + + await service.ClearAsync().ConfigureAwait(false); + Assert.AreEqual(0, store.Entries.Count); + + // A second clear on an empty store should not re-persist. + settings.Invocations.Clear(); + await service.ClearAsync().ConfigureAwait(false); + settings.Verify(s => s.SaveAsync(It.IsAny()), Times.Never, + "Clearing an already-empty store must be a no-op."); + } + + [TestMethod] + public async Task RecordIncludesFailedRunsByDefault() + { + (MergeHistoryService service, _, _) = BuildService(); + + await service.RecordAsync(MakeEntry(DateTimeOffset.UtcNow, exit: 0)).ConfigureAwait(false); + await service.RecordAsync(MakeEntry(DateTimeOffset.UtcNow, exit: 1)).ConfigureAwait(false); + + IReadOnlyList entries = service.List(); + Assert.AreEqual(2, entries.Count); + Assert.IsTrue(entries.Any(e => e.ExitCode == 1), "Failures are recorded too (the gate lives at the call site)."); + } +} diff --git a/KtsuTools/Commands/MergeCommand.cs b/KtsuTools/Commands/MergeCommand.cs index 7442bf5..75ce0d4 100644 --- a/KtsuTools/Commands/MergeCommand.cs +++ b/KtsuTools/Commands/MergeCommand.cs @@ -4,6 +4,7 @@ namespace KtsuTools.Commands; +using System; using System.ComponentModel; using System.IO; using ktsu.Semantics.Paths; @@ -12,10 +13,11 @@ namespace KtsuTools.Commands; using Spectre.Console; using Spectre.Console.Cli; -public sealed class MergeCommand(MergeService mergeService, MergeBatchService batchService) : AsyncCommand +public sealed class MergeCommand(MergeService mergeService, MergeBatchService batchService, MergeHistoryService historyService) : AsyncCommand { private readonly MergeService mergeService = mergeService; private readonly MergeBatchService batchService = batchService; + private readonly MergeHistoryService historyService = historyService; public sealed class Settings : CommandSettings { @@ -106,10 +108,29 @@ public override async Task ExecuteAsync(CommandContext context, Settings se } AbsoluteDirectoryPath directory = AbsoluteDirectoryPath.Create(Path.GetFullPath(directoryArg)); - return await mergeService.RunMergeAsync( + int exitCode = await mergeService.RunMergeAsync( directory, filenameArg, diffStyle, scope.Token).ConfigureAwait(false); + + bool isBatch = !string.IsNullOrWhiteSpace(settings.BatchName); + // Direct invocations record every run; batch dispatches only record on success + // (a failed batch usually means the saved config is stale, not user input worth recalling). + if (!isBatch || exitCode == 0) + { + MergeHistoryEntry historyEntry = new() + { + Timestamp = DateTimeOffset.UtcNow, + Directory = directoryArg, + Filename = filenameArg, + DiffStyle = DiffStyleParser.ToCanonicalString(diffStyle), + BatchName = isBatch ? settings.BatchName : null, + ExitCode = exitCode, + }; + await historyService.RecordAsync(historyEntry, scope.Token).ConfigureAwait(false); + } + + return exitCode; } } diff --git a/KtsuTools/Commands/MergeHistoryCommand.cs b/KtsuTools/Commands/MergeHistoryCommand.cs new file mode 100644 index 0000000..cf23ba3 --- /dev/null +++ b/KtsuTools/Commands/MergeHistoryCommand.cs @@ -0,0 +1,71 @@ +// 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 MergeHistoryCommand(MergeHistoryService historyService) : AsyncCommand +{ + private readonly MergeHistoryService historyService = historyService; + + public sealed class Settings : CommandSettings + { + [CommandOption("--clear")] + [Description("Truncate the merge history instead of listing it")] + [DefaultValue(false)] + public bool Clear { get; init; } + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + Ensure.NotNull(settings); + using CtrlCScope scope = new(); + + if (settings.Clear) + { + await historyService.ClearAsync(scope.Token).ConfigureAwait(false); + AnsiConsole.MarkupLine("[green]Merge history cleared.[/]"); + return 0; + } + + IReadOnlyList entries = historyService.List(); + if (entries.Count == 0) + { + AnsiConsole.MarkupLine("[dim]No merge runs recorded yet.[/]"); + return 0; + } + + Table table = new(); + table.AddColumn("When"); + table.AddColumn("Directory"); + table.AddColumn("Filename"); + table.AddColumn("Diff"); + table.AddColumn("Batch"); + table.AddColumn("Exit"); + table.Border = TableBorder.Rounded; + + foreach (MergeHistoryEntry entry in entries) + { + string exit = entry.ExitCode == 0 + ? $"[green]{entry.ExitCode}[/]" + : $"[red]{entry.ExitCode}[/]"; + + table.AddRow( + entry.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm").EscapeMarkup(), + entry.Directory.EscapeMarkup(), + entry.Filename.EscapeMarkup(), + entry.DiffStyle.EscapeMarkup(), + (entry.BatchName ?? "-").EscapeMarkup(), + exit); + } + + AnsiConsole.Write(table); + return 0; + } +} diff --git a/KtsuTools/Program.cs b/KtsuTools/Program.cs index dfa0f28..205cf3b 100644 --- a/KtsuTools/Program.cs +++ b/KtsuTools/Program.cs @@ -34,6 +34,7 @@ private static int Main(string[] args) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -84,6 +85,11 @@ private static int Main(string[] args) .WithExample("merge-batch", "delete", "editorconfig-sync"); }); + config.AddCommand("merge-history") + .WithDescription("Show recent ktsu merge runs (most-recent first); --clear truncates") + .WithExample("merge-history") + .WithExample("merge-history", "--clear"); + config.AddCommand("codegen") .WithDescription("Generate code from AST/YAML definitions") .WithExample("codegen", "--input", "ast.yaml", "--lang", "python");