diff --git a/KtsuTools.FileDedupe/FileDedupeService.cs b/KtsuTools.FileDedupe/FileDedupeService.cs
new file mode 100644
index 0000000..b60ea4c
--- /dev/null
+++ b/KtsuTools.FileDedupe/FileDedupeService.cs
@@ -0,0 +1,172 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace KtsuTools.FileDedupe;
+
+using System.Collections.Concurrent;
+using System.Collections.ObjectModel;
+using System.Security.Cryptography;
+using ktsu.Semantics.Paths;
+using Spectre.Console;
+
+///
+/// A set of files with byte-identical content, identified by SHA256.
+///
+public sealed record DuplicateGroup(string Hash, long FileSize, Collection Files);
+
+///
+/// Result of a dedupe planning pass.
+///
+public sealed record DedupePlan(
+ IReadOnlyList Groups,
+ IReadOnlyList Keepers,
+ IReadOnlyList Removals)
+{
+ public long WastedBytes { get; } = ComputeWastedBytes(Groups);
+
+ private static long ComputeWastedBytes(IReadOnlyList groups)
+ {
+ long total = 0;
+ foreach (DuplicateGroup g in groups)
+ {
+ total += g.FileSize * (g.Files.Count - 1);
+ }
+
+ return total;
+ }
+}
+
+public sealed record DedupeStats(
+ int FilesScanned,
+ int DuplicateGroups,
+ int RedundantFiles,
+ long WastedBytes,
+ Dictionary CountByExtension);
+
+///
+/// Scans a directory tree, groups files by SHA256, and applies (or previews)
+/// "shortest filename wins" deduplication.
+///
+public class FileDedupeService
+{
+#pragma warning disable CA1822 // instance method required for DI injection
+ public async Task PlanAsync(AbsoluteDirectoryPath path, CancellationToken ct = default)
+#pragma warning restore CA1822
+ {
+ Ensure.NotNull(path);
+
+ string root = path.ToString();
+ if (!Directory.Exists(root))
+ {
+ AnsiConsole.MarkupLine($"[red]Error: Directory '{root.EscapeMarkup()}' does not exist.[/]");
+ return new DedupePlan([], [], []);
+ }
+
+ string[] files = Directory.GetFiles(root, "*", SearchOption.AllDirectories);
+
+ ConcurrentDictionary> byHash = new();
+
+ await Parallel.ForEachAsync(
+ files,
+ new ParallelOptions { CancellationToken = ct, MaxDegreeOfParallelism = Environment.ProcessorCount },
+ async (file, token) =>
+ {
+ try
+ {
+ await using FileStream stream = File.OpenRead(file);
+ byte[] hashBytes = await SHA256.HashDataAsync(stream, token).ConfigureAwait(false);
+ string hash = Convert.ToHexString(hashBytes);
+ long size = new FileInfo(file).Length;
+
+ ConcurrentBag<(string Path, long Size)> bag = byHash.GetOrAdd(hash, _ => []);
+ bag.Add((file, size));
+ }
+ catch (IOException)
+ {
+ // Skip unreadable files (locked, deleted mid-scan, etc.).
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+ }).ConfigureAwait(false);
+
+ List groups = [];
+ List keepers = [];
+ List removals = [];
+
+ foreach ((string hash, ConcurrentBag<(string Path, long Size)> entries) in byHash)
+ {
+ if (entries.Count < 2)
+ {
+ continue;
+ }
+
+ List paths = [.. entries.Select(e => e.Path)
+ .OrderBy(p => Path.GetFileName(p).Length)
+ .ThenBy(p => p, StringComparer.OrdinalIgnoreCase)];
+
+ long size = entries.First().Size;
+ groups.Add(new DuplicateGroup(hash, size, [.. paths]));
+
+ keepers.Add(paths[0]);
+ removals.AddRange(paths.Skip(1));
+ }
+
+ return new DedupePlan(groups, keepers, removals);
+ }
+
+#pragma warning disable CA1822
+ public DedupeStats Summarize(DedupePlan plan, int filesScanned)
+#pragma warning restore CA1822
+ {
+ Ensure.NotNull(plan);
+
+ Dictionary byExt = new(StringComparer.OrdinalIgnoreCase);
+ int redundant = 0;
+
+ foreach (DuplicateGroup group in plan.Groups)
+ {
+ redundant += group.Files.Count - 1;
+ foreach (string file in group.Files)
+ {
+ string ext = Path.GetExtension(file);
+ if (string.IsNullOrEmpty(ext))
+ {
+ ext = "(none)";
+ }
+
+ byExt[ext] = byExt.TryGetValue(ext, out int c) ? c + 1 : 1;
+ }
+ }
+
+ return new DedupeStats(filesScanned, plan.Groups.Count, redundant, plan.WastedBytes, byExt);
+ }
+
+#pragma warning disable CA1822
+ public int DeleteRedundant(DedupePlan plan)
+#pragma warning restore CA1822
+ {
+ Ensure.NotNull(plan);
+
+ int deleted = 0;
+ foreach (string path in plan.Removals)
+ {
+ try
+ {
+ File.Delete(path);
+ deleted++;
+ }
+ catch (IOException ex)
+ {
+ AnsiConsole.MarkupLine($" [yellow]skip[/] {path.EscapeMarkup()}: {ex.Message.EscapeMarkup()}");
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ AnsiConsole.MarkupLine($" [yellow]skip[/] {path.EscapeMarkup()}: {ex.Message.EscapeMarkup()}");
+ }
+ }
+
+ return deleted;
+ }
+}
diff --git a/KtsuTools.FileDedupe/KtsuTools.FileDedupe.csproj b/KtsuTools.FileDedupe/KtsuTools.FileDedupe.csproj
new file mode 100644
index 0000000..a5832d2
--- /dev/null
+++ b/KtsuTools.FileDedupe/KtsuTools.FileDedupe.csproj
@@ -0,0 +1,19 @@
+
+
+
+
+
+ net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
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.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/FileDedupeServiceTests.cs b/KtsuTools.Test/FileDedupeServiceTests.cs
new file mode 100644
index 0000000..a2c4704
--- /dev/null
+++ b/KtsuTools.Test/FileDedupeServiceTests.cs
@@ -0,0 +1,115 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace KtsuTools.Test;
+
+using System.IO;
+using System.Linq;
+using ktsu.Semantics.Paths;
+using KtsuTools.FileDedupe;
+
+[TestClass]
+public class FileDedupeServiceTests
+{
+ [TestMethod]
+ public async Task PlanAsyncMissingDirectoryReturnsEmptyPlan()
+ {
+ FileDedupeService service = new();
+ string missing = Path.Combine(Path.GetTempPath(), $"ktsu_dedup_missing_{Guid.NewGuid():N}");
+ AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(missing);
+ DedupePlan plan = await service.PlanAsync(path).ConfigureAwait(false);
+ Assert.AreEqual(0, plan.Groups.Count);
+ Assert.AreEqual(0, plan.Removals.Count);
+ }
+
+ [TestMethod]
+ public async Task PlanAsyncGroupsByContentAndPicksShortestFilenameAsKeeper()
+ {
+ string root = Path.Combine(Path.GetTempPath(), $"ktsu_dedup_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(root);
+ try
+ {
+ string shortName = Path.Combine(root, "a.txt");
+ string mediumName = Path.Combine(root, "aaa.txt");
+ string longName = Path.Combine(root, "aaaaa.txt");
+ string unique = Path.Combine(root, "b.txt");
+
+ await File.WriteAllTextAsync(shortName, "same").ConfigureAwait(false);
+ await File.WriteAllTextAsync(mediumName, "same").ConfigureAwait(false);
+ await File.WriteAllTextAsync(longName, "same").ConfigureAwait(false);
+ await File.WriteAllTextAsync(unique, "different").ConfigureAwait(false);
+
+ FileDedupeService service = new();
+ AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(root);
+ DedupePlan plan = await service.PlanAsync(path).ConfigureAwait(false);
+
+ Assert.AreEqual(1, plan.Groups.Count, "Only the three duplicates form a group; the unique file is excluded.");
+ DuplicateGroup group = plan.Groups[0];
+ Assert.AreEqual(3, group.Files.Count);
+ Assert.AreEqual(shortName, plan.Keepers.Single(), "Keeper is the shortest filename.");
+ CollectionAssert.AreEquivalent(new[] { mediumName, longName }, plan.Removals.ToArray());
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [TestMethod]
+ public async Task PlanAsyncIgnoresSingletonGroups()
+ {
+ string root = Path.Combine(Path.GetTempPath(), $"ktsu_dedup_solo_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(root);
+ try
+ {
+ await File.WriteAllTextAsync(Path.Combine(root, "x.txt"), "x").ConfigureAwait(false);
+ await File.WriteAllTextAsync(Path.Combine(root, "y.txt"), "y").ConfigureAwait(false);
+
+ FileDedupeService service = new();
+ AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(root);
+ DedupePlan plan = await service.PlanAsync(path).ConfigureAwait(false);
+
+ Assert.AreEqual(0, plan.Groups.Count);
+ Assert.AreEqual(0, plan.WastedBytes);
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [TestMethod]
+ public async Task DeleteRedundantRemovesAllButTheKeeper()
+ {
+ string root = Path.Combine(Path.GetTempPath(), $"ktsu_dedup_del_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(root);
+ try
+ {
+ string keeper = Path.Combine(root, "a.txt");
+ string dup1 = Path.Combine(root, "aaa.txt");
+ string dup2 = Path.Combine(root, "aaaaa.txt");
+ await File.WriteAllTextAsync(keeper, "same").ConfigureAwait(false);
+ await File.WriteAllTextAsync(dup1, "same").ConfigureAwait(false);
+ await File.WriteAllTextAsync(dup2, "same").ConfigureAwait(false);
+
+ FileDedupeService service = new();
+ AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(root);
+ DedupePlan plan = await service.PlanAsync(path).ConfigureAwait(false);
+
+ int deleted = service.DeleteRedundant(plan);
+
+ Assert.AreEqual(2, deleted);
+ Assert.IsTrue(File.Exists(keeper));
+ Assert.IsFalse(File.Exists(dup1));
+ Assert.IsFalse(File.Exists(dup2));
+ }
+ finally
+ {
+ if (Directory.Exists(root))
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+ }
+}
diff --git a/KtsuTools.Test/KtsuTools.Test.csproj b/KtsuTools.Test/KtsuTools.Test.csproj
index f8fe6ec..f35a7fd 100644
--- a/KtsuTools.Test/KtsuTools.Test.csproj
+++ b/KtsuTools.Test/KtsuTools.Test.csproj
@@ -26,6 +26,7 @@
+
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.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, [], []);
+ }
+ }
}
diff --git a/KtsuTools.slnx b/KtsuTools.slnx
index b661b30..929f794 100644
--- a/KtsuTools.slnx
+++ b/KtsuTools.slnx
@@ -2,6 +2,7 @@
+
diff --git a/KtsuTools/Commands/DedupDeleteCommand.cs b/KtsuTools/Commands/DedupDeleteCommand.cs
new file mode 100644
index 0000000..3081b82
--- /dev/null
+++ b/KtsuTools/Commands/DedupDeleteCommand.cs
@@ -0,0 +1,63 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace KtsuTools.Commands;
+
+using System.ComponentModel;
+using System.IO;
+using ktsu.Semantics.Paths;
+using KtsuTools.Core.UI;
+using KtsuTools.FileDedupe;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+public sealed class DedupDeleteCommand(FileDedupeService dedupeService) : AsyncCommand
+{
+ private readonly FileDedupeService dedupeService = dedupeService;
+
+ public sealed class Settings : CommandSettings
+ {
+ [CommandOption("-p|--path ")]
+ [Description("Directory to scan for duplicates")]
+ public required string Path { get; init; }
+
+ [CommandOption("-y|--yes")]
+ [Description("Skip confirmation prompt")]
+ [DefaultValue(false)]
+ public bool AssumeYes { get; init; }
+ }
+
+ public override async Task ExecuteAsync(CommandContext context, Settings settings)
+ {
+ Ensure.NotNull(settings);
+ using CtrlCScope scope = new();
+
+ AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(Path.GetFullPath(settings.Path));
+ AnsiConsole.MarkupLine($"[bold]Dedup delete[/] - {path.ToString().EscapeMarkup()}");
+
+ DedupePlan plan = await dedupeService.PlanAsync(path, scope.Token).ConfigureAwait(false);
+
+ if (plan.Removals.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[green]Nothing to delete.[/]");
+ return 0;
+ }
+
+ AnsiConsole.MarkupLine($"[yellow]Will delete {plan.Removals.Count} file(s); shortest-filename winners are kept.[/]");
+
+ if (!settings.AssumeYes)
+ {
+ bool ok = AnsiConsole.Confirm("Proceed with deletion?", defaultValue: false);
+ if (!ok)
+ {
+ AnsiConsole.MarkupLine("[dim]Cancelled.[/]");
+ return 0;
+ }
+ }
+
+ int deleted = dedupeService.DeleteRedundant(plan);
+ AnsiConsole.MarkupLine($"[green]Deleted {deleted} file(s).[/]");
+ return 0;
+ }
+}
diff --git a/KtsuTools/Commands/DedupDryRunCommand.cs b/KtsuTools/Commands/DedupDryRunCommand.cs
new file mode 100644
index 0000000..28890cb
--- /dev/null
+++ b/KtsuTools/Commands/DedupDryRunCommand.cs
@@ -0,0 +1,56 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace KtsuTools.Commands;
+
+using System.ComponentModel;
+using System.IO;
+using ktsu.Semantics.Paths;
+using KtsuTools.Core.UI;
+using KtsuTools.FileDedupe;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+public sealed class DedupDryRunCommand(FileDedupeService dedupeService) : AsyncCommand
+{
+ private readonly FileDedupeService dedupeService = dedupeService;
+
+ public sealed class Settings : CommandSettings
+ {
+ [CommandOption("-p|--path ")]
+ [Description("Directory to scan for duplicates")]
+ public required string Path { get; init; }
+ }
+
+ public override async Task ExecuteAsync(CommandContext context, Settings settings)
+ {
+ Ensure.NotNull(settings);
+ using CtrlCScope scope = new();
+
+ AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(Path.GetFullPath(settings.Path));
+ AnsiConsole.MarkupLine($"[bold]Dedup dry-run[/] - {path.ToString().EscapeMarkup()}");
+
+ DedupePlan plan = await dedupeService.PlanAsync(path, scope.Token).ConfigureAwait(false);
+
+ if (plan.Removals.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[green]Nothing to delete.[/]");
+ return 0;
+ }
+
+ AnsiConsole.MarkupLine("[yellow]Would delete (shortest-filename winners are kept):[/]");
+ foreach (string keeper in plan.Keepers)
+ {
+ AnsiConsole.MarkupLine($" [green]keep[/] {keeper.EscapeMarkup()}");
+ }
+
+ foreach (string removal in plan.Removals)
+ {
+ AnsiConsole.MarkupLine($" [red]drop[/] {removal.EscapeMarkup()}");
+ }
+
+ AnsiConsole.MarkupLine($"[blue]{plan.Removals.Count} file(s), {plan.WastedBytes} byte(s) would be reclaimed.[/]");
+ return 0;
+ }
+}
diff --git a/KtsuTools/Commands/DedupScanCommand.cs b/KtsuTools/Commands/DedupScanCommand.cs
new file mode 100644
index 0000000..5e59ae9
--- /dev/null
+++ b/KtsuTools/Commands/DedupScanCommand.cs
@@ -0,0 +1,54 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace KtsuTools.Commands;
+
+using System.ComponentModel;
+using System.IO;
+using ktsu.Semantics.Paths;
+using KtsuTools.Core.UI;
+using KtsuTools.FileDedupe;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+public sealed class DedupScanCommand(FileDedupeService dedupeService) : AsyncCommand
+{
+ private readonly FileDedupeService dedupeService = dedupeService;
+
+ public sealed class Settings : CommandSettings
+ {
+ [CommandOption("-p|--path ")]
+ [Description("Directory to scan for duplicates")]
+ public required string Path { get; init; }
+ }
+
+ public override async Task ExecuteAsync(CommandContext context, Settings settings)
+ {
+ Ensure.NotNull(settings);
+ using CtrlCScope scope = new();
+
+ AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(Path.GetFullPath(settings.Path));
+ AnsiConsole.MarkupLine($"[bold]Dedup scan[/] - {path.ToString().EscapeMarkup()}");
+
+ DedupePlan plan = await dedupeService.PlanAsync(path, scope.Token).ConfigureAwait(false);
+
+ if (plan.Groups.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[green]No duplicate groups found.[/]");
+ return 0;
+ }
+
+ foreach (DuplicateGroup group in plan.Groups)
+ {
+ AnsiConsole.MarkupLine($"[yellow]{group.Files.Count}[/] files, {group.FileSize} bytes each ({group.Hash[..12]})");
+ foreach (string file in group.Files)
+ {
+ AnsiConsole.MarkupLine($" {file.EscapeMarkup()}");
+ }
+ }
+
+ AnsiConsole.MarkupLine($"[blue]{plan.Groups.Count} duplicate group(s), {plan.WastedBytes} wasted byte(s).[/]");
+ return 0;
+ }
+}
diff --git a/KtsuTools/Commands/DedupStatsCommand.cs b/KtsuTools/Commands/DedupStatsCommand.cs
new file mode 100644
index 0000000..22d6060
--- /dev/null
+++ b/KtsuTools/Commands/DedupStatsCommand.cs
@@ -0,0 +1,62 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace KtsuTools.Commands;
+
+using System.ComponentModel;
+using System.IO;
+using System.Linq;
+using ktsu.Semantics.Paths;
+using KtsuTools.Core.UI;
+using KtsuTools.FileDedupe;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+public sealed class DedupStatsCommand(FileDedupeService dedupeService) : AsyncCommand
+{
+ private readonly FileDedupeService dedupeService = dedupeService;
+
+ public sealed class Settings : CommandSettings
+ {
+ [CommandOption("-p|--path ")]
+ [Description("Directory to summarize")]
+ public required string Path { get; init; }
+ }
+
+ public override async Task ExecuteAsync(CommandContext context, Settings settings)
+ {
+ Ensure.NotNull(settings);
+ using CtrlCScope scope = new();
+
+ AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(Path.GetFullPath(settings.Path));
+ AnsiConsole.MarkupLine($"[bold]Dedup stats[/] - {path.ToString().EscapeMarkup()}");
+
+ int filesScanned = Directory.Exists(path.ToString())
+ ? Directory.GetFiles(path.ToString(), "*", SearchOption.AllDirectories).Length
+ : 0;
+
+ DedupePlan plan = await dedupeService.PlanAsync(path, scope.Token).ConfigureAwait(false);
+ DedupeStats stats = dedupeService.Summarize(plan, filesScanned);
+
+ Table table = new();
+ table.AddColumn("Metric");
+ table.AddColumn("Value");
+ table.AddRow("Files scanned", stats.FilesScanned.ToString());
+ table.AddRow("Duplicate groups", stats.DuplicateGroups.ToString());
+ table.AddRow("Redundant files", stats.RedundantFiles.ToString());
+ table.AddRow("Wasted bytes", stats.WastedBytes.ToString());
+ AnsiConsole.Write(table);
+
+ if (stats.CountByExtension.Count > 0)
+ {
+ AnsiConsole.MarkupLine("[bold]Duplicate files by extension:[/]");
+ foreach ((string ext, int count) in stats.CountByExtension.OrderByDescending(kv => kv.Value))
+ {
+ AnsiConsole.MarkupLine($" {ext.EscapeMarkup()}: {count}");
+ }
+ }
+
+ return 0;
+ }
+}
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