Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
<PackageVersion Include="ktsu.Frontmatter" Version="1.2.10" />
<PackageVersion Include="ktsu.TextFilter" Version="1.5.9" />
<PackageVersion Include="ktsu.Semantics.Paths" Version="1.0.21" />
<PackageVersion Include="ktsu.CredentialCache" Version="1.2.3" />

<!-- SDK-required packages -->
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.203" />
Expand Down
1 change: 1 addition & 0 deletions KtsuTools.Core/KtsuTools.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<PackageReference Include="LibGit2Sharp" />
<PackageReference Include="Octokit" />
<PackageReference Include="ktsu.AppDataStorage" />
<PackageReference Include="ktsu.CredentialCache" />
<PackageReference Include="ktsu.Extensions" />
<PackageReference Include="ktsu.RunCommand" />
</ItemGroup>
Expand Down
64 changes: 0 additions & 64 deletions KtsuTools.Core/Services/Credentials/CredentialService.cs

This file was deleted.

15 changes: 0 additions & 15 deletions KtsuTools.Core/Services/Credentials/ICredentialService.cs

This file was deleted.

2 changes: 0 additions & 2 deletions KtsuTools.Core/Services/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,7 +18,6 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service
services.AddSingleton<IGitHubService, GitHubService>();
services.AddSingleton<IProcessService, ProcessService>();
services.AddSingleton<ISettingsService, SettingsService>();
services.AddSingleton<ICredentialService, CredentialService>();

return services;
}
Expand Down
57 changes: 57 additions & 0 deletions KtsuTools.Merge/MergeHistoryService.cs
Original file line number Diff line number Diff line change
@@ -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<MergeHistoryEntry> 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<MergeHistorySettings>();
}
24 changes: 24 additions & 0 deletions KtsuTools.Merge/MergeHistorySettings.cs
Original file line number Diff line number Diff line change
@@ -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<MergeHistorySettings>
{
public List<MergeHistoryEntry> Entries { get; init; } = [];
}
101 changes: 101 additions & 0 deletions KtsuTools.Test/MergeHistoryServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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<ISettingsService> SettingsMock) BuildService()
{
MergeHistorySettings store = new();
Mock<ISettingsService> settings = new();
settings.Setup(s => s.LoadOrCreate<MergeHistorySettings>()).Returns(store);
settings.Setup(s => s.SaveAsync(It.IsAny<MergeHistorySettings>())).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<MergeHistoryEntry> 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<ISettingsService> 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<MergeHistorySettings>()), 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<MergeHistoryEntry> 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).");
}
}
25 changes: 23 additions & 2 deletions KtsuTools/Commands/MergeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace KtsuTools.Commands;

using System;
using System.ComponentModel;
using System.IO;
using ktsu.Semantics.Paths;
Expand All @@ -12,10 +13,11 @@ namespace KtsuTools.Commands;
using Spectre.Console;
using Spectre.Console.Cli;

public sealed class MergeCommand(MergeService mergeService, MergeBatchService batchService) : AsyncCommand<MergeCommand.Settings>
public sealed class MergeCommand(MergeService mergeService, MergeBatchService batchService, MergeHistoryService historyService) : AsyncCommand<MergeCommand.Settings>
{
private readonly MergeService mergeService = mergeService;
private readonly MergeBatchService batchService = batchService;
private readonly MergeHistoryService historyService = historyService;

public sealed class Settings : CommandSettings
{
Expand Down Expand Up @@ -106,10 +108,29 @@ public override async Task<int> ExecuteAsync(CommandContext context, Settings se
}

AbsoluteDirectoryPath directory = AbsoluteDirectoryPath.Create<AbsoluteDirectoryPath>(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;
}
}
Loading
Loading