diff --git a/docs/superpowers/plans/2026-06-05-layer-c-inline-conflict-resolver.md b/docs/superpowers/plans/2026-06-05-layer-c-inline-conflict-resolver.md new file mode 100644 index 0000000..88104f9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-layer-c-inline-conflict-resolver.md @@ -0,0 +1,920 @@ +# Layer C — Inline Conflict Resolver Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the worker-side conflict plumbing (5 frozen hub methods + GitService reads) and a VSCode-style in-app inline conflict resolver UI for ClaudeDo's merge rework. + +**Architecture:** The worker performs a real merge that leaves conflicts in the list's working tree (`leaveConflictsInTree:true`), exposes ours/theirs/base per conflicted file via `git show :2:/:3:/:1:`, accepts written resolutions, and finishes via the existing `ContinueMergeAsync`/`AbortMergeAsync`. The UI presents each conflicted file's hunk with Accept Current/Incoming/Both/Edit-manually controls plus a free-text merged box, then writes resolutions and continues. + +**Tech Stack:** .NET 8, ASP.NET Core SignalR (WorkerHub), EF Core/SQLite, Avalonia MVVM (CommunityToolkit), xUnit + real git/SQLite fixtures. + +**Frozen client contract (already shipped in foundation commit `2dfc455`, DO NOT edit):** +- `IWorkerClient` / `WorkerClient.cs` already call hub methods by name: `StartConflictMerge`, `GetMergeConflicts`, `WriteConflictResolution`, `ContinueMerge`, `AbortMerge`. +- Client DTOs already exist in `WorkerClient.cs`: `MergeConflictsDto(string TaskId, IReadOnlyList Files)`, `ConflictFileDto(string Path, IReadOnlyList Hunks)`, `ConflictHunkDto(string Ours, string Theirs, string? Base)`, plus existing `MergeResultDto(string Status, IReadOnlyList ConflictFiles, string? ErrorMessage)`. +- Worker-side DTOs must serialize identically (same record shape) and live in `WorkerHub.cs`. + +**Do NOT touch:** `WorkerClient.cs`, `Interfaces/IWorkerClient.cs`, `WorkConsole.axaml`, `DetailsIslandViewModel.cs`, `WorktreesOverviewModalView/VM`, `WorktreeModalView`. Test fakes for `IWorkerClient` already implement the 5 methods as no-op stubs (`StubWorkerClient` is `virtual` in Ui.Tests) — subclass/override, never edit the interface. + +**Build/test commands (.NET 8 — running Worker locks `Debug`, always `-c Release`):** +```bash +dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release +dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release +dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release +``` + +--- + +## File Structure + +**Worker / Data (create + modify):** +- Modify `src/ClaudeDo.Data/Git/GitService.cs` — add `ShowStageAsync` (untrimmed blob read) + `AddPathAsync`; add `trimOutput` param to `RunGitAsync`. +- Modify `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` — add records `MergeConflicts`/`ConflictFileContent`; add `GetConflictsAsync` + `WriteResolutionAsync`. +- Modify `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add DTOs `MergeConflictsDto`/`ConflictFileDto`/`ConflictHunkDto` + 5 hub methods. + +**UI (create new only):** +- Create `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs` — `ConflictFile`, `ConflictHunk`. +- Create `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs`. +- Create `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml` + `.axaml.cs`. + +**Wiring (modify):** +- Modify `src/ClaudeDo.App/Program.cs` — register `ConflictResolverViewModel` + `Func`. +- Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — additive seam (`ConflictResolverFactory`, `ShowConflictResolver`, `RequestConflictResolutionAsync`). +- Modify `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs` — wire `ShowConflictResolver` dialog delegate. +- Modify `src/ClaudeDo.Localization/locales/en.json` + `de.json` — `conflictResolver.*` keys (parity enforced by Localization.Tests). + +**Tests (create + modify):** +- Modify `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` — conflict-read / write-resolution / round-trip tests. +- Create `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs`. + +--- + +## Task 1: GitService conflict-blob reads + +**Files:** +- Modify: `src/ClaudeDo.Data/Git/GitService.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` (GitService exercised here via real repo; add focused tests in Task 2 round-trip) + +- [ ] **Step 1: Add `trimOutput` param to `RunGitAsync`** so blob reads keep exact bytes. + +In `RunGitAsync` signature add `bool trimOutput = true`, and change the return to: +```csharp +return (proc.ExitCode, trimOutput ? stdout.TrimEnd() : stdout, stderr.TrimEnd()); +``` +(All existing callers keep the default `true`.) + +- [ ] **Step 2: Add `ShowStageAsync` + `AddPathAsync`** (place after `ListConflictedFilesAsync`): + +```csharp +/// +/// Reads a conflicted file's blob at a merge stage: 1=base, 2=ours, 3=theirs. +/// Returns null when the stage doesn't exist (e.g. add/add conflict has no base). +/// Output is NOT trimmed so file content round-trips exactly. +/// +public async Task ShowStageAsync(string repoDir, int stage, string path, CancellationToken ct = default) +{ + var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $":{stage}:{path}"], ct, trimOutput: false); + return exitCode == 0 ? stdout : null; +} + +public async Task AddPathAsync(string repoDir, string path, CancellationToken ct = default) +{ + var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["add", "--", path], ct); + if (exitCode != 0) + throw new InvalidOperationException($"git add '{path}' failed (exit {exitCode}): {stderr}"); +} +``` + +- [ ] **Step 3: Build the Data + Worker projects to verify compilation.** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Data/Git/GitService.cs +git commit -m "feat(git): add conflict-stage blob reads and single-path staging" +``` + +--- + +## Task 2: TaskMergeService conflict reads + resolution writes + +**Files:** +- Modify: `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` + +- [ ] **Step 1: Write failing tests** (append inside `TaskMergeServiceTests`, before `#region Test doubles`). Reuse the existing helpers `SeedListAndTask`, `SeedWorktree`, `BuildService`, and the `GitRepoFixture` conflict setup pattern from `ContinueMergeAsync_AfterUserResolves...`. + +```csharp +[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); // ours = target (main) side after checkout + Assert.Contains("branch change", file.Theirs); // theirs = merged-in branch + 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)); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** (no such methods). + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs|WriteResolutionAsync_ThenContinue_CompletesMerge"` +Expected: compile error / FAIL (methods don't exist). + +- [ ] **Step 3: Add records + methods to `TaskMergeService.cs`.** + +Add records beside `MergeResult` (top of file, after the existing record declarations): +```csharp +public sealed record MergeConflicts( + string TaskId, + IReadOnlyList Files); + +public sealed record ConflictFileContent( + string Path, + string Ours, + string Theirs, + string? Base); +``` + +Add methods inside the class (after `AbortMergeAsync`): +```csharp +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); +} +``` +(Note: `Path` is `System.IO.Path` — the file already uses it via other helpers; the record property `Path` does not shadow it inside these methods because it's accessed as a static type, not an instance member.) + +- [ ] **Step 4: Run the tests to verify they pass.** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs|WriteResolutionAsync_ThenContinue_CompletesMerge"` +Expected: PASS (2 tests). If git unavailable they no-op. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +git commit -m "feat(merge): read conflict stages and write user resolutions" +``` + +--- + +## Task 3: WorkerHub conflict methods + DTOs + +**Files:** +- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs` + +- [ ] **Step 1: Add DTOs** beside the existing merge DTOs (after `public record MergeTargetsDto(...)`): + +```csharp +public record MergeConflictsDto(string TaskId, IReadOnlyList Files); +public record ConflictFileDto(string Path, IReadOnlyList Hunks); +public record ConflictHunkDto(string Ours, string Theirs, string? Base); +``` + +- [ ] **Step 2: Add the 5 hub methods** (after `PreviewMerge`). Names/params/returns MUST match the frozen client calls. + +```csharp +public Task StartConflictMerge(string taskId, string targetBranch) + => HubGuard(async () => + { + var r = await _mergeService.MergeAsync( + taskId, targetBranch ?? "", removeWorktree: false, "Merge task", + leaveConflictsInTree: true, CancellationToken.None); + if (r.Status == TaskMergeService.StatusBlocked) + throw new HubException(r.ErrorMessage ?? "merge blocked"); + return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage); + }); + +public Task GetMergeConflicts(string taskId) + => HubGuard(async () => + { + var c = await _mergeService.GetConflictsAsync(taskId, CancellationToken.None); + return new MergeConflictsDto( + c.TaskId, + c.Files.Select(f => new ConflictFileDto( + f.Path, + new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList()); + }); + +public Task WriteConflictResolution(string taskId, string path, string resolvedContent) + => HubGuard(() => _mergeService.WriteResolutionAsync( + taskId, path, resolvedContent ?? "", CancellationToken.None)); + +public Task ContinueMerge(string taskId) + => HubGuard(async () => + { + var r = await _mergeService.ContinueMergeAsync(taskId, CancellationToken.None); + if (r.Status == TaskMergeService.StatusBlocked) + throw new HubException(r.ErrorMessage ?? "continue failed"); + return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage); + }); + +public Task AbortMerge(string taskId) + => HubGuard(async () => + { + var r = await _mergeService.AbortMergeAsync(taskId, CancellationToken.None); + if (r.Status == TaskMergeService.StatusBlocked) + throw new HubException(r.ErrorMessage ?? "abort failed"); + }); +``` + +- [ ] **Step 3: Build the Worker project to verify compilation.** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Worker/Hub/WorkerHub.cs +git commit -m "feat(hub): expose conflict-resolution merge methods" +``` + +--- + +## Task 4: Conflict UI model + +**Files:** +- Create: `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs` +- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs` (model tests added here in Task 5; this task is build-verified) + +- [ ] **Step 1: Create the model file.** Shaped so a 3-way pane needs no model change (`Base` retained per hunk). + +```csharp +using System.Collections.Generic; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClaudeDo.Ui.ViewModels.Conflicts; + +public sealed partial class ConflictHunk : ObservableObject +{ + public string Ours { get; } + public string Theirs { get; } + public string? Base { get; } + + [ObservableProperty] private string? _resolution; + + public bool IsResolved => Resolution is not null; + + public ConflictHunk(string ours, string theirs, string? @base) + { + Ours = ours; + Theirs = theirs; + Base = @base; + } + + partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved)); + + [RelayCommand] private void AcceptCurrent() => Resolution = Ours; + [RelayCommand] private void AcceptIncoming() => Resolution = Theirs; + [RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs; + [RelayCommand] private void EditManually() => Resolution ??= Ours; +} + +public sealed class ConflictFile +{ + public string Path { get; } + public IReadOnlyList Hunks { get; } + + public ConflictFile(string path, IReadOnlyList hunks) + { + Path = path; + Hunks = hunks; + } + + public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.IsResolved); + + /// The merged file content: concatenation of each hunk's resolution + /// (single whole-file hunk today; concatenation keeps it correct for multi-hunk later). + public string ComposeResolvedContent() => string.Concat(Hunks.Select(h => h.Resolution)); +} +``` + +- [ ] **Step 2: Build the Ui project to verify compilation.** + +Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs +git commit -m "feat(ui): add inline conflict model (file/hunk with resolution)" +``` + +--- + +## Task 5: ConflictResolverViewModel + +**Files:** +- Create: `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs` +- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs` + +- [ ] **Step 1: Write failing tests.** Subclass the existing `StubWorkerClient` (its conflict methods are `virtual`). + +```csharp +using System.Collections.Generic; +using System.Threading.Tasks; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Conflicts; +using Xunit; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public class ConflictResolverViewModelTests +{ + private sealed class FakeWorker : StubWorkerClient + { + public string? WrittenPath; + public string? WrittenContent; + public bool Continued; + public bool Aborted; + public string ContinueStatus = "merged"; + + public override Task StartConflictMergeAsync(string taskId, string targetBranch) + => Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null)); + + public override Task GetMergeConflictsAsync(string taskId) + => Task.FromResult(new MergeConflictsDto(taskId, new[] + { + new ConflictFileDto("README.md", new[] { new ConflictHunkDto("ours\n", "theirs\n", "base\n") }) + })); + + public override Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) + { + WrittenPath = path; WrittenContent = resolvedContent; return Task.CompletedTask; + } + + public override Task ContinueMergeAsync(string taskId) + { + Continued = true; + return Task.FromResult(new MergeResultDto(ContinueStatus, System.Array.Empty(), null)); + } + + public override Task AbortMergeAsync(string taskId) { Aborted = true; return Task.CompletedTask; } + } + + [Fact] + public async Task OpenAsync_LoadsConflicts_AndBlocksContinueUntilResolved() + { + var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1"); + var hasConflicts = await vm.OpenAsync("main"); + + Assert.True(hasConflicts); + var file = Assert.Single(vm.Files); + Assert.Equal("README.md", file.Path); + Assert.False(vm.CanContinue); // nothing resolved yet + + file.Hunks[0].AcceptIncomingCommand.Execute(null); + Assert.True(vm.CanContinue); // every hunk resolved + } + + [Fact] + public async Task Continue_WritesComposedResolution_AndClosesOnMerged() + { + var worker = new FakeWorker(); + var vm = new ConflictResolverViewModel(worker, "task-1"); + var closed = false; + vm.CloseRequested = () => closed = true; + + await vm.OpenAsync("main"); + vm.Files[0].Hunks[0].AcceptCurrentCommand.Execute(null); // resolution = "ours\n" + await vm.ContinueCommand.ExecuteAsync(null); + + Assert.Equal("README.md", worker.WrittenPath); + Assert.Equal("ours\n", worker.WrittenContent); + Assert.True(worker.Continued); + Assert.True(closed); + } + + [Fact] + public async Task Continue_StaysOpenAndReportsError_WhenStillConflicted() + { + var worker = new FakeWorker { ContinueStatus = "conflict" }; + var vm = new ConflictResolverViewModel(worker, "task-1"); + var closed = false; + vm.CloseRequested = () => closed = true; + + await vm.OpenAsync("main"); + vm.Files[0].Hunks[0].AcceptBothCommand.Execute(null); + await vm.ContinueCommand.ExecuteAsync(null); + + Assert.False(closed); + Assert.NotNull(vm.Error); + } + + [Fact] + public async Task Abort_CallsWorkerAndCloses() + { + var worker = new FakeWorker(); + var vm = new ConflictResolverViewModel(worker, "task-1"); + var closed = false; + vm.CloseRequested = () => closed = true; + + await vm.AbortCommand.ExecuteAsync(null); + + Assert.True(worker.Aborted); + Assert.True(closed); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** (VM not defined). + +Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "ConflictResolverViewModelTests"` +Expected: compile error / FAIL. + +- [ ] **Step 3: Implement the ViewModel.** + +```csharp +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ClaudeDo.Ui.Services; + +namespace ClaudeDo.Ui.ViewModels.Conflicts; + +public sealed partial class ConflictResolverViewModel : ObservableObject +{ + private readonly IWorkerClient _worker; + private readonly string _taskId; + + public ObservableCollection Files { get; } = new(); + + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private string? _error; + [ObservableProperty] private bool _canContinue; + + public string TaskId => _taskId; + public Action? CloseRequested { get; set; } + + public ConflictResolverViewModel(IWorkerClient worker, string taskId) + { + _worker = worker; + _taskId = taskId; + } + + /// Starts the conflict merge and loads ours/theirs/base per file. + /// Returns true when there are conflicts to resolve (caller should show the dialog). + public async Task OpenAsync(string targetBranch) + { + IsBusy = true; + Error = null; + try + { + var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch); + if (!string.Equals(start.Status, "conflict", StringComparison.Ordinal)) + { + if (string.Equals(start.Status, "blocked", StringComparison.Ordinal)) + Error = start.ErrorMessage; + return false; + } + + var conflicts = await _worker.GetMergeConflictsAsync(_taskId); + Files.Clear(); + foreach (var f in conflicts.Files) + { + var hunks = f.Hunks.Select(h => + { + var hk = new ConflictHunk(h.Ours, h.Theirs, h.Base); + hk.PropertyChanged += OnHunkChanged; + return hk; + }).ToList(); + Files.Add(new ConflictFile(f.Path, hunks)); + } + RecomputeCanContinue(); + return Files.Count > 0; + } + catch (Exception ex) + { + Error = ex.Message; + return false; + } + finally { IsBusy = false; } + } + + private void OnHunkChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(ConflictHunk.IsResolved) or nameof(ConflictHunk.Resolution)) + RecomputeCanContinue(); + } + + private void RecomputeCanContinue() + => CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved); + + [RelayCommand] + private async Task ContinueAsync() + { + if (!CanContinue) return; + IsBusy = true; + Error = null; + try + { + foreach (var file in Files) + await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent()); + + var result = await _worker.ContinueMergeAsync(_taskId); + if (string.Equals(result.Status, "merged", StringComparison.Ordinal)) + CloseRequested?.Invoke(); + else + Error = result.ErrorMessage ?? "Conflicts not fully resolved — review and retry."; + } + catch (Exception ex) + { + Error = ex.Message; + } + finally { IsBusy = false; } + } + + [RelayCommand] + private async Task AbortAsync() + { + IsBusy = true; + try { await _worker.AbortMergeAsync(_taskId); } + catch (Exception ex) { Error = ex.Message; } + finally + { + IsBusy = false; + CloseRequested?.Invoke(); + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass.** + +Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "ConflictResolverViewModelTests"` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs +git commit -m "feat(ui): add inline conflict resolver view-model" +``` + +--- + +## Task 6: ConflictResolverView + localization + +**Files:** +- Create: `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml` +- Create: `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs` +- Modify: `src/ClaudeDo.Localization/locales/en.json` +- Modify: `src/ClaudeDo.Localization/locales/de.json` + +- [ ] **Step 1: Add localization keys** to `en.json` as a new top-level section (sibling of `"planning"`): + +```json + "conflictResolver": { + "windowTitle": "Resolve merge conflicts", + "modalTitle": "RESOLVE CONFLICTS", + "loading": "Loading conflicts…", + "current": "Current (ours)", + "incoming": "Incoming (theirs)", + "mergedResult": "Merged result", + "acceptCurrent": "Accept Current", + "acceptIncoming": "Accept Incoming", + "acceptBoth": "Accept Both", + "editManually": "Edit manually", + "continue": "Resolve & continue", + "abort": "Abort merge" + }, +``` + +- [ ] **Step 2: Add the SAME keys to `de.json`** (German values, identical key set — parity enforced by Localization.Tests): + +```json + "conflictResolver": { + "windowTitle": "Merge-Konflikte lösen", + "modalTitle": "KONFLIKTE LÖSEN", + "loading": "Konflikte werden geladen…", + "current": "Aktuell (unsere)", + "incoming": "Eingehend (ihre)", + "mergedResult": "Zusammengeführtes Ergebnis", + "acceptCurrent": "Aktuelle übernehmen", + "acceptIncoming": "Eingehende übernehmen", + "acceptBoth": "Beide übernehmen", + "editManually": "Manuell bearbeiten", + "continue": "Lösen & fortfahren", + "abort": "Merge abbrechen" + }, +``` + +- [ ] **Step 3: Create the View** (`ConflictResolverView.axaml`). A `Window` using `ModalShell`, mirroring `ConflictResolutionView.axaml`. Two stacked read-only boxes (ours/theirs), a button row, and a two-way merged-result box per hunk. + +```xml + + + + + + + + + +