From dcbf67c63bd74c5e400d7aac8eeda591fc3a6521 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 5 Jun 2026 10:49:07 +0200 Subject: [PATCH] feat(merge): read conflict stages and write user resolutions --- .../Lifecycle/TaskMergeService.cs | 39 +++++++++++ .../Services/TaskMergeServiceTests.cs | 66 +++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index 095bcf4..50e1fe6 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -23,6 +23,16 @@ public sealed record MergePreviewResult( IReadOnlyList ConflictFiles, int ChangedFileCount); +public sealed record MergeConflicts( + string TaskId, + IReadOnlyList Files); + +public sealed record ConflictFileContent( + string Path, + string Ours, + string Theirs, + string? Base); + public sealed class TaskMergeService { public const string StatusMerged = "merged"; @@ -217,6 +227,35 @@ public sealed class TaskMergeService return new MergeResult(StatusAborted, Array.Empty(), null); } + public async Task GetConflictsAsync(string taskId, CancellationToken ct) + { + var (_, list, _) = await LoadMergeContextAsync(taskId, ct); + if (string.IsNullOrWhiteSpace(list.WorkingDir)) + throw new InvalidOperationException("list has no working directory"); + + var files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); + var result = new List(files.Count); + foreach (var path in files) + { + var ours = await _git.ShowStageAsync(list.WorkingDir, 2, path, ct) ?? ""; + var theirs = await _git.ShowStageAsync(list.WorkingDir, 3, path, ct) ?? ""; + var @base = await _git.ShowStageAsync(list.WorkingDir, 1, path, ct); + result.Add(new ConflictFileContent(path, ours, theirs, @base)); + } + return new MergeConflicts(taskId, result); + } + + public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct) + { + var (_, list, _) = await LoadMergeContextAsync(taskId, ct); + if (string.IsNullOrWhiteSpace(list.WorkingDir)) + throw new InvalidOperationException("list has no working directory"); + + var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar)); + await File.WriteAllTextAsync(full, content, ct); + await _git.AddPathAsync(list.WorkingDir, path, ct); + } + public async Task GetTargetsAsync(string taskId, CancellationToken ct) { var (_, list, _) = await LoadMergeContextAsync(taskId, ct); diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs index ac5ddd8..1444249 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -621,6 +621,72 @@ public class TaskMergeServiceTests : IDisposable // Cleanup GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort"); } + [Fact] + public async Task GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs() + { + if (!GitRepoFixture.IsGitAvailable()) return; + + var db = NewDb(); + var repo = NewRepo(); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change"); + + var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); + _wtCleanups.Add((repo.RepoDir, wtPath)); + GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c1", wtPath, repo.BaseCommit); + File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n"); + GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change"); + + var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview); + await SeedWorktree(db, task.Id, wtPath, "claudedo/c1", repo.BaseCommit); + + var (svc, _) = BuildService(db); + var start = await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None); + Assert.Equal(TaskMergeService.StatusConflict, start.Status); + + var conflicts = await svc.GetConflictsAsync(task.Id, CancellationToken.None); + + Assert.Equal(task.Id, conflicts.TaskId); + var file = Assert.Single(conflicts.Files); + Assert.Equal("README.md", file.Path); + Assert.Contains("main change", file.Ours); + Assert.Contains("branch change", file.Theirs); + Assert.NotNull(file.Base); + + GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort"); + } + + [Fact] + public async Task WriteResolutionAsync_ThenContinue_CompletesMerge() + { + if (!GitRepoFixture.IsGitAvailable()) return; + + var db = NewDb(); + var repo = NewRepo(); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change"); + + var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); + _wtCleanups.Add((repo.RepoDir, wtPath)); + GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c2", wtPath, repo.BaseCommit); + File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n"); + GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change"); + + var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview); + await SeedWorktree(db, task.Id, wtPath, "claudedo/c2", repo.BaseCommit); + + var (svc, _) = BuildService(db); + await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None); + + await svc.WriteResolutionAsync(task.Id, "README.md", "# resolved by user\n", CancellationToken.None); + var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusMerged, result.Status); + Assert.Equal("# resolved by user\n", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md"))); + Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir)); + } } #region Test doubles