From 4098f7f34134f64fe845f16dacbf0622005f32b3 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 4 Jun 2026 23:18:54 +0200 Subject: [PATCH] feat(git): add non-destructive merge-tree conflict probe --- src/ClaudeDo.Data/Git/GitService.cs | 45 +++++++++++++ .../Runner/GitServicePreviewMergeTests.cs | 64 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs index 29c34e6..636fc3a 100644 --- a/src/ClaudeDo.Data/Git/GitService.cs +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -3,6 +3,8 @@ using System.Text; namespace ClaudeDo.Data.Git; +public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList ConflictFiles); + public sealed class GitService { public async Task IsGitRepoAsync(string dir, CancellationToken ct = default) @@ -236,6 +238,49 @@ public sealed class GitService .ToList(); } + /// + /// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only + /// loose objects — the working tree, index, and refs are left untouched. + /// + public async Task PreviewMergeAsync( + string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default) + { + var (exitCode, stdout, _) = await RunGitAsync(repoDir, + ["merge-tree", "--write-tree", "--name-only", targetBranch, sourceBranch], ct); + + if (exitCode == 0) + return new MergePreview(true, true, Array.Empty()); + + if (exitCode == 1) + { + // stdout: \n\n...\n\n + var lines = stdout.Split('\n'); + var files = new List(); + for (int i = 1; i < lines.Length; i++) + { + var line = lines[i].TrimEnd('\r'); + if (string.IsNullOrWhiteSpace(line)) break; + files.Add(line.Trim()); + } + return new MergePreview(true, false, files); + } + + // Any other exit (e.g. git too old: "unknown option --write-tree"). + return new MergePreview(false, false, Array.Empty()); + } + + /// Count of files that differ on since its merge base with the target. + public async Task CountChangedFilesAsync( + string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default) + { + var (exitCode, stdout, _) = await RunGitAsync(repoDir, + ["diff", "--name-only", $"{targetBranch}...{sourceBranch}"], ct); + if (exitCode != 0) return 0; + return stdout + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Count(s => s.Length > 0); + } + public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default) { var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct); diff --git a/tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs new file mode 100644 index 0000000..88c5ef2 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs @@ -0,0 +1,64 @@ +using ClaudeDo.Data.Git; +using ClaudeDo.Worker.Tests.Infrastructure; + +namespace ClaudeDo.Worker.Tests.Runner; + +public class GitServicePreviewMergeTests : IDisposable +{ + private readonly List _repos = new(); + private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; } + public void Dispose() { foreach (var r in _repos) try { r.Dispose(); } catch { } } + + [Fact] + public async Task PreviewMergeAsync_NonConflicting_ReportsCleanWithChangedCount() + { + if (!GitRepoFixture.IsGitAvailable()) return; + var repo = NewRepo(); + var git = new GitService(); + var baseBranch = await git.GetCurrentBranchAsync(repo.RepoDir); + + GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature"); + File.WriteAllText(Path.Combine(repo.RepoDir, "newfile.txt"), "x\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat"); + GitRepoFixture.RunGit(repo.RepoDir, "checkout", baseBranch); + + var preview = await git.PreviewMergeAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None); + + Assert.True(preview.Supported); + Assert.True(preview.Clean); + Assert.Empty(preview.ConflictFiles); + + var count = await git.CountChangedFilesAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None); + Assert.Equal(1, count); + } + + [Fact] + public async Task PreviewMergeAsync_Conflicting_ReportsFilesAndDoesNotMutateTree() + { + if (!GitRepoFixture.IsGitAvailable()) return; + var repo = NewRepo(); + var git = new GitService(); + var baseBranch = await git.GetCurrentBranchAsync(repo.RepoDir); + + GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature"); + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from feature\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat readme"); + GitRepoFixture.RunGit(repo.RepoDir, "checkout", baseBranch); + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from base\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "base readme"); + + var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); + + var preview = await git.PreviewMergeAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None); + + Assert.True(preview.Supported); + Assert.False(preview.Clean); + Assert.Contains("README.md", preview.ConflictFiles); + + Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim()); + Assert.False(await git.IsMidMergeAsync(repo.RepoDir)); + } +}