From e779e136547cb970a0e57b77aec8f0c14b046475 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Thu, 18 Jun 2026 16:22:56 +0200 Subject: [PATCH] feat(merge): real conflict-hunk parsing pipeline (chunk 2 backend) Replace the whole-file conflict model with line-level hunks, the foundation for the full in-app merge editor. - ConflictMarkerParser: parses git conflict markers (incl. diff3 base) into ordered stable/conflict MergeSegments; exact round-trip + Compose - GitService.MergeNoFfAsync passes -c merge.conflictStyle=diff3 so the working tree carries the merge base in conflict markers - TaskMergeService.GetConflictDocumentsAsync: reads each conflicted file, parses into segments, flags binary files - hub GetMergeConflictDocuments + DTOs (MergeConflictDocumentsDto/ ConflictDocumentDto/MergeSegmentDto), IWorkerClient + both fakes - tests: 8 parser unit tests + a real-git integration test asserting line-level hunks with a diff3 base --- src/ClaudeDo.Data/Git/ConflictMarkerParser.cs | 134 ++++++++++++++++++ src/ClaudeDo.Data/Git/GitService.cs | 5 +- .../Services/Interfaces/IWorkerClient.cs | 1 + src/ClaudeDo.Ui/Services/WorkerClient.cs | 6 + src/ClaudeDo.Worker/Hub/WorkerHub.cs | 15 ++ .../Lifecycle/TaskMergeService.cs | 48 +++++++ .../ConflictMarkerParserTests.cs | 123 ++++++++++++++++ tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 1 + .../Services/TaskMergeServiceTests.cs | 45 ++++++ .../UiVm/TasksIslandViewModelPlanningTests.cs | 1 + 10 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 src/ClaudeDo.Data/Git/ConflictMarkerParser.cs create mode 100644 tests/ClaudeDo.Data.Tests/ConflictMarkerParserTests.cs diff --git a/src/ClaudeDo.Data/Git/ConflictMarkerParser.cs b/src/ClaudeDo.Data/Git/ConflictMarkerParser.cs new file mode 100644 index 0000000..6238acb --- /dev/null +++ b/src/ClaudeDo.Data/Git/ConflictMarkerParser.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace ClaudeDo.Data.Git; + +/// +/// One piece of a conflicted file: either common ("stable") text both sides agree on, +/// or a conflict region holding the two — or, with diff3 markers, three — competing versions. +/// +public sealed record MergeSegment +{ + public bool IsConflict { get; init; } + + /// Stable text (verbatim, line endings preserved) when is false. + public string Text { get; init; } = ""; + + /// "Ours" side (the target branch) when is true. + public string Ours { get; init; } = ""; + + /// Merge base, present only when the merge used diff3 conflict style; null otherwise. + public string? Base { get; init; } + + /// "Theirs" side (the incoming branch) when is true. + public string Theirs { get; init; } = ""; + + public static MergeSegment Stable(string text) => new() { Text = text }; + + public static MergeSegment Conflict(string ours, string? @base, string theirs) => + new() { IsConflict = true, Ours = ours, Base = @base, Theirs = theirs }; +} + +/// +/// Parses a conflicted file's text into ordered stable / conflict segments and reassembles it. +/// Reads git conflict markers verbatim, so a file with no markers yields a single stable +/// segment, and reassembling the stable text plus one chosen resolution per conflict +/// round-trips the file exactly (line endings included). +/// +public static class ConflictMarkerParser +{ + private const string OursMarker = "<<<<<<<"; + private const string BaseMarker = "|||||||"; + private const string SepMarker = "======="; + private const string TheirsMarker = ">>>>>>>"; + + public static IReadOnlyList Parse(string fileText) + { + var segments = new List(); + var lines = SplitKeepLineEndings(fileText); + var stable = new StringBuilder(); + var i = 0; + + while (i < lines.Count) + { + if (!IsMarker(lines[i], OursMarker)) + { + stable.Append(lines[i++]); + continue; + } + + if (stable.Length > 0) + { + segments.Add(MergeSegment.Stable(stable.ToString())); + stable.Clear(); + } + + i++; // consume "<<<<<<<" + var ours = new StringBuilder(); + while (i < lines.Count && !IsMarker(lines[i], BaseMarker) && !IsMarker(lines[i], SepMarker)) + ours.Append(lines[i++]); + + string? @base = null; + if (i < lines.Count && IsMarker(lines[i], BaseMarker)) + { + i++; // consume "|||||||" + var baseText = new StringBuilder(); + while (i < lines.Count && !IsMarker(lines[i], SepMarker)) + baseText.Append(lines[i++]); + @base = baseText.ToString(); + } + + if (i < lines.Count && IsMarker(lines[i], SepMarker)) i++; // consume "=======" + + var theirs = new StringBuilder(); + while (i < lines.Count && !IsMarker(lines[i], TheirsMarker)) + theirs.Append(lines[i++]); + + if (i < lines.Count && IsMarker(lines[i], TheirsMarker)) i++; // consume ">>>>>>>" + + segments.Add(MergeSegment.Conflict(ours.ToString(), @base, theirs.ToString())); + } + + if (stable.Length > 0) + segments.Add(MergeSegment.Stable(stable.ToString())); + + return segments; + } + + /// True when the file still contains an opening conflict marker. + public static bool HasConflicts(string fileText) => + SplitKeepLineEndings(fileText).Any(l => IsMarker(l, OursMarker)); + + /// + /// Reassembles a file from its segments. Stable segments emit their text verbatim; + /// each conflict segment emits whatever returns for it. + /// + public static string Compose( + IEnumerable segments, Func resolveConflict) => + string.Concat(segments.Select(s => s.IsConflict ? resolveConflict(s) : s.Text)); + + // A marker line starts with exactly the 7-char marker, then end-of-line or whitespace/label. + private static bool IsMarker(string line, string marker) + { + if (!line.StartsWith(marker, StringComparison.Ordinal)) return false; + if (line.Length == marker.Length) return true; + return line[marker.Length] is ' ' or '\t' or '\r' or '\n'; + } + + // Splits into physical lines, each retaining its trailing "\n" (and "\r" if present). + private static List SplitKeepLineEndings(string s) + { + var lines = new List(); + var i = 0; + while (i < s.Length) + { + var nl = s.IndexOf('\n', i); + if (nl < 0) { lines.Add(s[i..]); break; } + lines.Add(s[i..(nl + 1)]); + i = nl + 1; + } + return lines; + } +} diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs index fc1edd5..b07c77c 100644 --- a/src/ClaudeDo.Data/Git/GitService.cs +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -252,8 +252,11 @@ public sealed class GitService public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync( string repoDir, string sourceBranch, string message, CancellationToken ct = default) { + // diff3 conflict style writes the merge base (|||||||) into conflict markers so the + // in-app resolver can show a true three-way view. It only enriches conflicted hunks; + // clean merges are unaffected. var (exitCode, _, stderr) = await RunGitAsync(repoDir, - ["merge", "--no-ff", "-m", message, sourceBranch], ct); + ["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "-m", message, sourceBranch], ct); return (exitCode, stderr); } diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index 3b7590b..8e317d4 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -55,6 +55,7 @@ public interface IWorkerClient : INotifyPropertyChanged // ── Conflict resolution (worker hub side implemented by Layer C) ── Task StartConflictMergeAsync(string taskId, string targetBranch); Task GetMergeConflictsAsync(string taskId); + Task GetMergeConflictDocumentsAsync(string taskId); Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent); Task ContinueConflictMergeAsync(string taskId); Task AbortConflictMergeAsync(string taskId); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 10b20ab..623d6a9 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -275,6 +275,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public Task GetMergeConflictsAsync(string taskId) => _hub.InvokeAsync("GetMergeConflicts", taskId); + public Task GetMergeConflictDocumentsAsync(string taskId) + => _hub.InvokeAsync("GetMergeConflictDocuments", taskId); + public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent); @@ -559,6 +562,9 @@ public record MergeTargetsDto(string DefaultBranch, IReadOnlyList LocalB public record MergeConflictsDto(string TaskId, IReadOnlyList Files); public record ConflictFileDto(string Path, IReadOnlyList Hunks); public record ConflictHunkDto(string Ours, string Theirs, string? Base); +public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList Files); +public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList Segments); +public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs); public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType); public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 61f45fd..9979357 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -61,6 +61,9 @@ public record MergeTargetsDto(string DefaultBranch, IReadOnlyList LocalB public record MergeConflictsDto(string TaskId, IReadOnlyList Files); public record ConflictFileDto(string Path, IReadOnlyList Hunks); public record ConflictHunkDto(string Ours, string Theirs, string? Base); +public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList Files); +public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList Segments); +public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs); public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType); public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); @@ -383,6 +386,18 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList()); }); + public Task GetMergeConflictDocuments(string taskId) + => HubGuard(async () => + { + var c = await _mergeService.GetConflictDocumentsAsync(taskId, CancellationToken.None); + return new MergeConflictDocumentsDto( + c.TaskId, + c.Files.Select(f => new ConflictDocumentDto( + f.Path, f.IsBinary, + f.Segments.Select(s => new MergeSegmentDto( + s.IsConflict, s.Text, s.Ours, s.Base, s.Theirs)).ToList())).ToList()); + }); + public Task WriteConflictResolution(string taskId, string path, string resolvedContent) => HubGuard(() => _mergeService.WriteResolutionAsync( taskId, path, resolvedContent ?? "", CancellationToken.None)); diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index 0d46ae1..6e3dfcf 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -33,6 +33,15 @@ public sealed record ConflictFileContent( string Theirs, string? Base); +public sealed record ConflictDocuments( + string TaskId, + IReadOnlyList Files); + +public sealed record ConflictDocumentContent( + string Path, + bool IsBinary, + IReadOnlyList Segments); + public sealed class TaskMergeService { public const string StatusMerged = "merged"; @@ -256,6 +265,45 @@ public sealed class TaskMergeService return new MergeConflicts(taskId, result); } + /// + /// Reads each conflicted working-tree file and parses its conflict markers into line-level + /// segments (with the diff3 merge base when present). Binary files are flagged and skipped. + /// + public async Task GetConflictDocumentsAsync(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 full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar)); + string text; + try { text = await File.ReadAllTextAsync(full, ct); } + catch { text = ""; } + + if (LooksBinary(text)) + { + result.Add(new ConflictDocumentContent(path, true, Array.Empty())); + continue; + } + + result.Add(new ConflictDocumentContent(path, false, ConflictMarkerParser.Parse(text))); + } + return new ConflictDocuments(taskId, result); + } + + // A NUL byte in the head of the file is the conventional binary sniff. + private static bool LooksBinary(string text) + { + var n = Math.Min(text.Length, 8000); + for (var i = 0; i < n; i++) + if (text[i] == '\0') return true; + return false; + } + public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct) { var (_, list, _) = await LoadMergeContextAsync(taskId, ct); diff --git a/tests/ClaudeDo.Data.Tests/ConflictMarkerParserTests.cs b/tests/ClaudeDo.Data.Tests/ConflictMarkerParserTests.cs new file mode 100644 index 0000000..149a888 --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/ConflictMarkerParserTests.cs @@ -0,0 +1,123 @@ +using System.Linq; +using ClaudeDo.Data.Git; +using Xunit; + +namespace ClaudeDo.Data.Tests; + +public class ConflictMarkerParserTests +{ + [Fact] + public void NoMarkers_YieldsSingleStableSegment_AndRoundTrips() + { + const string text = "just\nsome\nplain\nlines\n"; + + var segments = ConflictMarkerParser.Parse(text); + + var only = Assert.Single(segments); + Assert.False(only.IsConflict); + Assert.Equal(text, ConflictMarkerParser.Compose(segments, c => c.Ours)); + Assert.False(ConflictMarkerParser.HasConflicts(text)); + } + + [Fact] + public void SimpleConflict_SplitsIntoStable_Conflict_Stable() + { + const string text = + "line1\n<<<<<<< HEAD\nours line\n=======\ntheirs line\n>>>>>>> branch\nline2\n"; + + var segments = ConflictMarkerParser.Parse(text); + + Assert.Equal(3, segments.Count); + Assert.Equal("line1\n", segments[0].Text); + Assert.True(segments[1].IsConflict); + Assert.Equal("ours line\n", segments[1].Ours); + Assert.Equal("theirs line\n", segments[1].Theirs); + Assert.Null(segments[1].Base); + Assert.Equal("line2\n", segments[2].Text); + Assert.True(ConflictMarkerParser.HasConflicts(text)); + } + + [Fact] + public void AcceptingOurs_Or_Theirs_ProducesTheResolvedFile() + { + const string text = + "line1\n<<<<<<< HEAD\nours line\n=======\ntheirs line\n>>>>>>> branch\nline2\n"; + + var segments = ConflictMarkerParser.Parse(text); + + Assert.Equal("line1\nours line\nline2\n", + ConflictMarkerParser.Compose(segments, c => c.Ours)); + Assert.Equal("line1\ntheirs line\nline2\n", + ConflictMarkerParser.Compose(segments, c => c.Theirs)); + // "Accept both" = ours followed by theirs. + Assert.Equal("line1\nours line\ntheirs line\nline2\n", + ConflictMarkerParser.Compose(segments, c => c.Ours + c.Theirs)); + } + + [Fact] + public void Diff3Style_CapturesTheMergeBase() + { + const string text = + "a\n<<<<<<< HEAD\nX\n||||||| base\nB\n=======\nY\n>>>>>>> branch\nz\n"; + + var segments = ConflictMarkerParser.Parse(text); + + Assert.Equal(3, segments.Count); + Assert.Equal("X\n", segments[1].Ours); + Assert.Equal("B\n", segments[1].Base); + Assert.Equal("Y\n", segments[1].Theirs); + } + + [Fact] + public void MultipleConflicts_AreEachCaptured() + { + const string text = + "<<<<<<< HEAD\nA\n=======\nB\n>>>>>>> br\nmid\n<<<<<<< HEAD\nC\n=======\nD\n>>>>>>> br\n"; + + var segments = ConflictMarkerParser.Parse(text); + + Assert.Equal(3, segments.Count); + Assert.True(segments[0].IsConflict); + Assert.Equal("A\n", segments[0].Ours); + Assert.Equal("mid\n", segments[1].Text); + Assert.True(segments[2].IsConflict); + Assert.Equal("D\n", segments[2].Theirs); + } + + [Fact] + public void CrlfLineEndings_ArePreserved() + { + const string text = + "a\r\n<<<<<<< HEAD\r\nX\r\n=======\r\nY\r\n>>>>>>> br\r\nb\r\n"; + + var segments = ConflictMarkerParser.Parse(text); + + Assert.Equal("a\r\nX\r\nb\r\n", ConflictMarkerParser.Compose(segments, c => c.Ours)); + } + + [Fact] + public void ConflictAtEndOfFile_WithoutTrailingNewline_IsParsed() + { + const string text = "a\n<<<<<<< HEAD\nX\n=======\nY\n>>>>>>> br"; + + var segments = ConflictMarkerParser.Parse(text); + + Assert.Equal(2, segments.Count); + Assert.Equal("a\n", segments[0].Text); + Assert.True(segments[1].IsConflict); + Assert.Equal("X\n", segments[1].Ours); + Assert.Equal("Y\n", segments[1].Theirs); + } + + [Fact] + public void SevenEqualsInOrdinaryText_IsNotTreatedAsAConflict() + { + const string text = "title\n=======\nbody\n"; + + var segments = ConflictMarkerParser.Parse(text); + + var only = Assert.Single(segments); + Assert.False(only.IsConflict); + Assert.Equal(text, ConflictMarkerParser.Compose(segments, c => c.Ours)); + } +} diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 3cb7f2f..092e1f9 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -66,6 +66,7 @@ public abstract class StubWorkerClient : IWorkerClient public virtual Task CancelReviewAsync(string taskId) => Task.CompletedTask; public virtual Task StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty(), null)); public virtual Task GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty())); + public virtual Task GetMergeConflictDocumentsAsync(string taskId) => Task.FromResult(new MergeConflictDocumentsDto(taskId, System.Array.Empty())); public virtual Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask; public virtual Task ContinueConflictMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty(), null)); public virtual Task AbortConflictMergeAsync(string taskId) => Task.CompletedTask; diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs index 3b6dbe5..4c63d46 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -571,6 +571,51 @@ public class TaskMergeServiceTests : IDisposable Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir)); } + [Fact] + public async Task GetConflictDocumentsAsync_ParsesLineLevelHunksWithDiff3Base() + { + if (!GitRepoFixture.IsGitAvailable()) return; + var repo = NewRepo(); + var db = NewDb(); + var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview); + + // A committed README gives the conflict a common ancestor, so diff3 records a base. + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "line1\nbase\nline3\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "base readme"); + + var wtMgr = BuildWorktreeManager(db); + var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None); + _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath)); + File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "line1\nWORKTREE\nline3\n"); + await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None); + + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "line1\nMAIN\nline3\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit"); + + var (svc, _) = BuildService(db); + var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir); + + var merge = await svc.MergeAsync( + task.Id, target, removeWorktree: false, "merge", + leaveConflictsInTree: true, CancellationToken.None); + Assert.Equal(TaskMergeService.StatusConflict, merge.Status); + + var docs = await svc.GetConflictDocumentsAsync(task.Id, CancellationToken.None); + + var file = Assert.Single(docs.Files); + Assert.EndsWith("README.md", file.Path.Replace('\\', '/')); + Assert.False(file.IsBinary); + + var conflict = Assert.Single(file.Segments.Where(s => s.IsConflict).ToList()); + Assert.Contains("MAIN", conflict.Ours); // ours = current branch (merge target) + Assert.Contains("WORKTREE", conflict.Theirs); // theirs = incoming worktree branch + Assert.NotNull(conflict.Base); + Assert.Contains("base", conflict.Base!); // diff3 merge base + Assert.Contains(file.Segments, s => !s.IsConflict && s.Text.Contains("line1")); + } + [Fact] public async Task ApproveAndMergeAsync_NoWorktree_MarksDone() { diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index 638cc06..96bbbe3 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -53,6 +53,7 @@ sealed class FakeWorkerClient : IWorkerClient public Task MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty(), null)); public Task StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty(), null)); public Task GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty())); + public Task GetMergeConflictDocumentsAsync(string taskId) => Task.FromResult(new MergeConflictDocumentsDto(taskId, System.Array.Empty())); public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask; public Task ContinueConflictMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty(), null)); public Task AbortConflictMergeAsync(string taskId) => Task.CompletedTask;