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");