Files
ClaudeDo/docs/superpowers/plans/2026-06-04-approve-merge-and-conflict-preview.md
2026-06-04 23:16:11 +02:00

42 KiB

Approve = Merge → Done + Conflict Preview — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Approving a WaitingForReview task merges its worktree into the target branch first and only marks the task Done on a clean merge; conflicts keep it in review and are surfaced. Add a non-destructive "merges cleanly / conflicts" indicator and a direct single-task Merge button.

Architecture: A new GitService.PreviewMergeAsync probes mergeability via git merge-tree --write-tree (no working-tree mutation). TaskMergeService gains PreviewAsync and ApproveAndMergeAsync (merge first, then delegate the Done flip to ITaskStateService). WorkerHub exposes PreviewMerge and a result-returning ApproveReview(taskId, targetBranch). The UI loads merge targets whenever a worktree exists, shows the preview, and reacts to conflict results.

Tech Stack: .NET 8, Avalonia, EF Core/SQLite, SignalR, xUnit with real git (GitRepoFixture) and real SQLite (DbFixture).

Conventions for the implementer:

  • Use the sonnet model.
  • Stage files explicitly by path — never git add -A (parallel sessions leave unrelated WIP).
  • Build with -c Release (a running Worker locks Debug output).
  • Conventional Commit messages: type(scope): description.
  • New UI strings use plain English literals to match the surrounding merge controls (no loc:Tr) — this avoids Localization.Tests parity churn.
  • Ignore anything under .claude/worktrees/ — those are stale worktrees, not the build tree.

File map

File Change
src/ClaudeDo.Data/Git/GitService.cs Add MergePreview record + PreviewMergeAsync + CountChangedFilesAsync
src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs Inject ITaskStateService; add MergePreviewResult + PreviewAsync + ApproveAndMergeAsync
src/ClaudeDo.Worker/Hub/WorkerHub.cs Add MergePreviewDto + PreviewMerge; change ApproveReview to (taskId, targetBranch) → MergeResultDto
src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs Change ApproveReviewAsync; add PreviewMergeAsync, MergeTaskAsync
src/ClaudeDo.Ui/Services/WorkerClient.cs Implement the above; add UI MergePreviewDto record
src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs New pure presenter (text + color flags)
src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs Load targets for worktree tasks; preview props; approve conflict handling; MergeCommand
src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs Update the list-level approve call to new signature
src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml Mergeability status line + Merge button
tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs New — git-backed preview tests
tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs Update BuildService; add preview + approve-merge tests
tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs Update FakeWorkerClient
tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs Update fake
tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs Update the ApproveReviewAsync override
tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs New — presenter unit tests

Task 1: GitService non-destructive merge probe

Files:

  • Modify: src/ClaudeDo.Data/Git/GitService.cs
  • Test: tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs (create)

Behaviour verified on git 2.50: git merge-tree --write-tree --name-only <target> <source> exits 0 when clean (stdout = a single tree-OID line) and 1 on conflict (stdout = tree-OID line, then conflicted file names, then a blank line, then informational messages). It writes only loose objects — the working tree, index, and refs are untouched.

  • Step 1: Write the failing tests

Create tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs:

using ClaudeDo.Data.Git;
using ClaudeDo.Worker.Tests.Infrastructure;

namespace ClaudeDo.Worker.Tests.Runner;

public class GitServicePreviewMergeTests : IDisposable
{
    private readonly List<GitRepoFixture> _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);

        // Non-destructive: HEAD unchanged, no mid-merge state.
        Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
        Assert.False(await git.IsMidMergeAsync(repo.RepoDir));
    }
}
  • Step 2: Run the tests, verify they fail to compile

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~GitServicePreviewMergeTests Expected: build error — PreviewMergeAsync/CountChangedFilesAsync do not exist.

  • Step 3: Implement the probe

In src/ClaudeDo.Data/Git/GitService.cs, add this record just under namespace ClaudeDo.Data.Git;:

public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);

Add these methods inside the GitService class (e.g. after ListConflictedFilesAsync):

/// <summary>
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
/// loose objects — the working tree, index, and refs are left untouched.
/// </summary>
public async Task<MergePreview> 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<string>());

    if (exitCode == 1)
    {
        // stdout: <tree-oid>\n<file>\n...\n\n<informational messages>
        var lines = stdout.Split('\n');
        var files = new List<string>();
        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<string>());
}

/// <summary>Count of files that differ on <paramref name="sourceBranch"/> since its merge base with the target.</summary>
public async Task<int> 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);
}
  • Step 4: Run the tests, verify they pass

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~GitServicePreviewMergeTests Expected: PASS (2 tests).

  • Step 5: Commit
git add src/ClaudeDo.Data/Git/GitService.cs tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs
git commit -m "feat(git): add non-destructive merge-tree conflict probe"

Task 2: TaskMergeService preview + approve-merge orchestration

Files:

  • Modify: src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs
  • Test: tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs

ApproveAndMergeAsync merges first (reusing MergeAsync, removeWorktree:false) and only then delegates the Done flip to ITaskStateService.ApproveReviewAsync (the sole owner of Status writes). Conflicts/blocks return without flipping status. No DI cycle: TaskStateService and PlanningChainCoordinator do not depend on TaskMergeService.

  • Step 1: Update BuildService and add failing tests

In tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs, replace the BuildService helper so it also constructs a real TaskStateService (existing merge tests still pass — they only inspect the merge service's own broadcaster proxy):

private static (TaskMergeService svc, MergeRecordingClientProxy proxy) BuildService(DbFixture db)
{
    var fakeHub = new MergeRecordingHubContext();
    var broadcaster = new HubBroadcaster(fakeHub);
    var state = TaskStateServiceBuilder.Build(db.CreateFactory()).State;
    var svc = new TaskMergeService(
        db.CreateFactory(),
        new GitService(),
        broadcaster,
        state,
        NullLogger<TaskMergeService>.Instance);
    return (svc, fakeHub.Proxy);
}

Add these tests to the class:

[Fact]
public async Task PreviewAsync_CleanWorktree_ReturnsClean()
{
    if (!GitRepoFixture.IsGitAvailable()) return;
    var repo = NewRepo();
    var db = NewDb();
    var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);

    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, "added.txt"), "x\n");
    await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

    var (svc, _) = BuildService(db);
    var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

    var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);

    Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
    Assert.True(preview.ChangedFileCount >= 1);
}

[Fact]
public async Task PreviewAsync_Conflict_ReturnsConflictFiles()
{
    if (!GitRepoFixture.IsGitAvailable()) return;
    var repo = NewRepo();
    var db = NewDb();
    var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);

    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"), "# from worktree\n");
    await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

    File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\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 preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);

    Assert.Equal(TaskMergeService.PreviewConflict, preview.Status);
    Assert.Contains("README.md", preview.ConflictFiles);
}

[Fact]
public async Task PreviewAsync_NoActiveWorktree_ReturnsUnavailable()
{
    var db = NewDb();
    var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
    var (svc, _) = BuildService(db);

    var preview = await svc.PreviewAsync(task.Id, "main", CancellationToken.None);

    Assert.Equal(TaskMergeService.PreviewUnavailable, preview.Status);
}

[Fact]
public async Task ApproveAndMergeAsync_CleanWorktree_MergesAndMarksDone()
{
    if (!GitRepoFixture.IsGitAvailable()) return;
    var repo = NewRepo();
    var db = NewDb();
    var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);

    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, "added.txt"), "new\n");
    await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

    var (svc, _) = BuildService(db);
    var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

    var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);

    Assert.Equal(TaskMergeService.StatusMerged, result.Status);
    using var ctx = db.CreateContext();
    var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
    Assert.Equal(TaskStatus.Done, updated!.Status);
    var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
    Assert.Equal(WorktreeState.Merged, wt!.State);
}

[Fact]
public async Task ApproveAndMergeAsync_Conflict_LeavesTaskWaitingForReview()
{
    if (!GitRepoFixture.IsGitAvailable()) return;
    var repo = NewRepo();
    var db = NewDb();
    var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);

    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"), "# from worktree\n");
    await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

    File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
    GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
    GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
    var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();

    var (svc, _) = BuildService(db);
    var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

    var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);

    Assert.Equal(TaskMergeService.StatusConflict, result.Status);
    Assert.Contains("README.md", result.ConflictFiles);

    using var ctx = db.CreateContext();
    var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
    Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
    var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
    Assert.Equal(WorktreeState.Active, wt!.State);
    Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
    Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
}

[Fact]
public async Task ApproveAndMergeAsync_NoWorktree_MarksDone()
{
    var db = NewDb();
    var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
    var (svc, _) = BuildService(db);

    var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);

    Assert.Equal(TaskMergeService.StatusMerged, result.Status);
    using var ctx = db.CreateContext();
    var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
    Assert.Equal(TaskStatus.Done, updated!.Status);
}
  • Step 2: Run the tests, verify they fail

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TaskMergeServiceTests Expected: build error — ITaskStateService ctor arg, PreviewAsync, ApproveAndMergeAsync, PreviewClean/PreviewConflict/PreviewUnavailable do not exist.

  • Step 3: Implement in TaskMergeService

In src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs:

Add using ClaudeDo.Worker.State; to the usings.

Add the preview-result record beside MergeTargets:

public sealed record MergePreviewResult(
    string Status,
    IReadOnlyList<string> ConflictFiles,
    int ChangedFileCount);

Add the status constants beside the existing StatusMerged etc.:

public const string PreviewClean       = "clean";
public const string PreviewConflict    = "conflict";
public const string PreviewUnavailable = "unavailable";

Add the field and constructor param (inject ITaskStateService):

private readonly ITaskStateService _state;

public TaskMergeService(
    IDbContextFactory<ClaudeDoDbContext> dbFactory,
    GitService git,
    HubBroadcaster broadcaster,
    ITaskStateService state,
    ILogger<TaskMergeService> logger)
{
    _dbFactory = dbFactory;
    _git = git;
    _broadcaster = broadcaster;
    _state = state;
    _logger = logger;
}

Add the two methods (e.g. after GetTargetsAsync):

public async Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct)
{
    var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);

    if (wt is null || wt.State != WorktreeState.Active)
        return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
    if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct))
        return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);

    var target = string.IsNullOrWhiteSpace(targetBranch)
        ? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
        : targetBranch;

    var preview = await _git.PreviewMergeAsync(list.WorkingDir, target, wt.BranchName, ct);
    if (!preview.Supported)
        return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
    if (!preview.Clean)
        return new MergePreviewResult(PreviewConflict, preview.ConflictFiles, 0);

    var count = await _git.CountChangedFilesAsync(list.WorkingDir, target, wt.BranchName, ct);
    return new MergePreviewResult(PreviewClean, Array.Empty<string>(), count);
}

public async Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct)
{
    var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);

    if (task.Status != TaskStatus.WaitingForReview)
        return Blocked("task is not waiting for review");

    // No worktree to merge (sandbox run, or an improvement parent whose children own
    // the worktrees) — approve straight to Done.
    if (wt is null || wt.State != WorktreeState.Active)
    {
        var done = await _state.ApproveReviewAsync(taskId, ct);
        return done.Ok
            ? new MergeResult(StatusMerged, Array.Empty<string>(), null)
            : Blocked(done.Reason ?? "approve failed");
    }

    var target = string.IsNullOrWhiteSpace(targetBranch)
        ? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
        : targetBranch;

    var merge = await MergeAsync(taskId, target, removeWorktree: false, $"Merge {wt.BranchName}", ct);
    if (merge.Status != StatusMerged)
        return merge; // conflict or blocked — leave the task in WaitingForReview

    var approve = await _state.ApproveReviewAsync(taskId, ct);
    return approve.Ok ? merge : Blocked(approve.Reason ?? "approve failed");
}
  • Step 4: Run the tests, verify they pass

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TaskMergeServiceTests Expected: PASS (all existing + 6 new).

  • Step 5: Commit
git add src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
git commit -m "feat(worker): approve merges worktree before marking task done"

Task 3: WorkerHub — PreviewMerge + result-returning ApproveReview

Files:

  • Modify: src/ClaudeDo.Worker/Hub/WorkerHub.cs

This is SignalR wiring (no unit test); verify by building the Worker.

  • Step 1: Add the DTO

Beside the existing MergeResultDto/MergeTargetsDto records (around line 56):

public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
  • Step 2: Add PreviewMerge and replace ApproveReview

Add a PreviewMerge method beside GetMergeTargets:

public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
    => HubGuard(async () =>
    {
        var p = await _mergeService.PreviewAsync(taskId, targetBranch ?? "", CancellationToken.None);
        return new MergePreviewDto(p.Status, p.ConflictFiles, p.ChangedFileCount);
    });

Replace the existing ApproveReview method (currently lines ~383-387, delegating to _state.ApproveReviewAsync) with:

public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
    => HubGuard(async () =>
    {
        var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None);
        if (r.Status == TaskMergeService.StatusBlocked)
            throw new HubException(r.ErrorMessage ?? "approve failed");
        return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
    });

(Conflicts are returned, not thrown, so the UI can display the conflicting files; only hard blocks throw.)

  • Step 3: Build the Worker, verify green

Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release Expected: Build succeeded. (DI resolves the new ITaskStateService dependency of TaskMergeService automatically — it is already registered.)

  • Step 4: Commit
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs
git commit -m "feat(worker): expose PreviewMerge hub method and merge-on-approve"

Task 4: UI client + interface + test fakes

Files:

  • Modify: src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs
  • Modify: src/ClaudeDo.Ui/Services/WorkerClient.cs
  • Modify: src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs (caller at line 648)
  • Modify: tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs (FakeWorkerClient)
  • Modify: tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
  • Modify: tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs (override)

Note: DetailsIslandViewModel.ApproveReviewAsync (line 1368) is updated in Task 5, not here — but the interface change forces it to compile, so Task 5 must follow before the Ui project builds. To keep this task self-contained and green on its own, update that call site here too (the conflict-handling logic lands in Task 5).

  • Step 1: Add the UI DTO

In src/ClaudeDo.Ui/Services/WorkerClient.cs, beside the existing MergeResultDto/MergeTargetsDto records (lines 521-522):

public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
  • Step 2: Update the interface

In src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs, replace Task ApproveReviewAsync(string taskId); (line 40) with:

Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);

(MergeTaskAsync already exists on the concrete WorkerClient — this only adds it to the interface.)

  • Step 3: Update the concrete client

In src/ClaudeDo.Ui/Services/WorkerClient.cs, replace the existing ApproveReviewAsync (line ~389) and add PreviewMergeAsync. Mirror the existing GetMergeTargetsAsync pattern (it uses the TryInvokeAsync<T> helper which returns null when disconnected):

public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
    => TryInvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);

public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
    => TryInvokeAsync<MergePreviewDto>("PreviewMerge", taskId, targetBranch);

Ensure the existing public async Task<MergeResultDto> MergeTaskAsync(...) signature matches the interface exactly (params: string taskId, string targetBranch, bool removeWorktree, string commitMessage). Leave its body as-is.

  • Step 4: Update the two callers

src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs line 648 — the list-level quick approve has no merge-target selector, so it merges into the repo's current branch (empty string resolves server-side):

try { await _worker.ApproveReviewAsync(row.Id, ""); }

src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs line 1368 — update to the new signature for now (full conflict handling is added in Task 5):

try { await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? ""); }
  • Step 5: Update the three test fakes

tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs line 53 — replace and add:

public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
public virtual Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
public virtual Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));

tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs line 45 (FakeWorkerClient) — replace and add:

public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
public Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));

(Confirm whether FakeWorkerClient already implements MergeTaskAsync; if so, only change ApproveReviewAsync and add PreviewMergeAsync. Add using for the DTO namespace if needed — same namespace as IWorkerClient.)

tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs line 77 — update the override signature:

public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
    /* keep whatever recording/behavior this override had, now returning Task<MergeResultDto?> */
    Task.FromResult<MergeResultDto?>(null);

(Preserve any side effect the existing override performed — e.g. recording the call — just change the signature and return type.)

  • Step 6: Build UI + run both UI-touching test projects

Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release Run: dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TasksIslandViewModelPlanning Expected: all green.

  • Step 7: Commit
git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
git commit -m "feat(ui): wire merge-aware approve and preview into the worker client"

Task 5: Mergeability presenter + DetailsIslandViewModel wiring

Files:

  • Create: src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs

  • Modify: src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs

  • Test: tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs (create)

  • Step 1: Write the failing presenter tests

Create tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs:

using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;

namespace ClaudeDo.Ui.Tests.ViewModels;

public class MergePreviewPresenterTests
{
    [Fact]
    public void Clean_Plural()
    {
        var (text, clean, conflict) = MergePreviewPresenter.Describe(
            new MergePreviewDto("clean", System.Array.Empty<string>(), 3));
        Assert.Equal("Merges cleanly · 3 files", text);
        Assert.True(clean);
        Assert.False(conflict);
    }

    [Fact]
    public void Clean_Singular()
    {
        var (text, _, _) = MergePreviewPresenter.Describe(
            new MergePreviewDto("clean", System.Array.Empty<string>(), 1));
        Assert.Equal("Merges cleanly · 1 file", text);
    }

    [Fact]
    public void Conflict_ListsUpToThree()
    {
        var (text, clean, conflict) = MergePreviewPresenter.Describe(
            new MergePreviewDto("conflict", new[] { "a.cs", "b.cs" }, 0));
        Assert.Equal("Conflicts in a.cs, b.cs", text);
        Assert.False(clean);
        Assert.True(conflict);
    }

    [Fact]
    public void Conflict_TruncatesWithMore()
    {
        var (text, _, _) = MergePreviewPresenter.Describe(
            new MergePreviewDto("conflict", new[] { "a", "b", "c", "d", "e" }, 0));
        Assert.Equal("Conflicts in a, b, c (+2 more)", text);
    }

    [Fact]
    public void Unavailable_IsMuted()
    {
        var (text, clean, conflict) = MergePreviewPresenter.Describe(
            new MergePreviewDto("unavailable", System.Array.Empty<string>(), 0));
        Assert.Equal("Mergeability unknown", text);
        Assert.False(clean);
        Assert.False(conflict);
    }

    [Fact]
    public void Null_IsEmpty()
    {
        var (text, clean, conflict) = MergePreviewPresenter.Describe(null);
        Assert.Equal("", text);
        Assert.False(clean);
        Assert.False(conflict);
    }
}
  • Step 2: Run, verify it fails to compile

Run: dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter FullyQualifiedName~MergePreviewPresenterTests Expected: build error — MergePreviewPresenter does not exist.

  • Step 3: Create the presenter

Create src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs:

using System.Linq;
using ClaudeDo.Ui.Services;

namespace ClaudeDo.Ui.ViewModels.Islands;

/// Pure mapping from a merge-preview DTO to display text + color flags.
public static class MergePreviewPresenter
{
    public static (string Text, bool IsClean, bool IsConflict) Describe(MergePreviewDto? dto)
    {
        if (dto is null) return ("", false, false);

        switch (dto.Status)
        {
            case "clean":
                var unit = dto.ChangedFileCount == 1 ? "file" : "files";
                return ($"Merges cleanly · {dto.ChangedFileCount} {unit}", true, false);

            case "conflict":
                var names = string.Join(", ", dto.ConflictFiles.Take(3));
                var more = dto.ConflictFiles.Count > 3 ? $" (+{dto.ConflictFiles.Count - 3} more)" : "";
                return ($"Conflicts in {names}{more}", false, true);

            default:
                return ("Mergeability unknown", false, false);
        }
    }
}
  • Step 4: Run, verify the presenter tests pass

Run: dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter FullyQualifiedName~MergePreviewPresenterTests Expected: PASS (6 tests).

  • Step 5: Wire the presenter into DetailsIslandViewModel

In src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:

(a) Add observable properties (near the other merge properties, ~line 334):

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
private string _mergePreviewText = "";

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
private bool _mergeIsClean;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
private bool _mergeIsConflict;

public bool ShowMergePreviewMuted =>
    !MergeIsClean && !MergeIsConflict && !string.IsNullOrEmpty(MergePreviewText);

public bool ShowSingleMerge =>
    WorktreePath != null && Task?.IsPlanningParent != true;

(b) Add the refresh method:

private async System.Threading.Tasks.Task RefreshMergePreviewAsync()
{
    if (Task is null || WorktreePath is null)
    {
        MergePreviewText = ""; MergeIsClean = false; MergeIsConflict = false;
        return;
    }
    // Only probe Active worktrees; terminal states show their label instead.
    if (WorktreeStateLabel is { } label && label != "Active")
    {
        MergePreviewText = label; MergeIsClean = false; MergeIsConflict = false;
        return;
    }
    var dto = await _worker.PreviewMergeAsync(Task.Id, SelectedMergeTarget ?? "");
    var (text, clean, conflict) = MergePreviewPresenter.Describe(dto);
    MergePreviewText = text; MergeIsClean = clean; MergeIsConflict = conflict;
}

(c) Recompute when the merge target changes — add (or extend) the generated partial:

partial void OnSelectedMergeTargetChanged(string? value)
{
    _ = RefreshMergePreviewAsync();
}

(d) Notify ShowSingleMerge when the worktree path changes. In the existing OnWorktreePathChanged (line ~1141) add:

OnPropertyChanged(nameof(ShowSingleMerge));

(e) Load merge targets for standalone worktree tasks. In BindAsync, after the if (entity.PlanningPhase != None) {...} else {...} block (~line 814), add:

if (entity.Worktree != null
    && entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None
    && MergeTargetBranches.Count == 0)
{
    var targets = await _worker.GetMergeTargetsAsync(row.Id);
    if (targets != null)
    {
        MergeTargetBranches.Clear();
        foreach (var b in targets.LocalBranches) MergeTargetBranches.Add(b);
        SelectedMergeTarget = targets.DefaultBranch; // triggers OnSelectedMergeTargetChanged → preview
    }
}
await RefreshMergePreviewAsync();

(f) Replace the body of ApproveReviewAsync (line ~1362) to surface conflicts:

[RelayCommand]
private async System.Threading.Tasks.Task ApproveReviewAsync()
{
    if (Task is null || !_worker.IsConnected) return;
    try
    {
        var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
        if (result?.Status == "conflict")
        {
            var (text, _, _) = MergePreviewPresenter.Describe(
                new MergePreviewDto("conflict", result.ConflictFiles, 0));
            MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
        }
    }
    catch { /* stale review action; broadcast reconciles */ }
}

(g) Add the single-task MergeCommand (place near OpenDiffAsync):

[RelayCommand]
private async System.Threading.Tasks.Task MergeAsync()
{
    if (Task is null || WorktreePath is null || !_worker.IsConnected) return;
    try
    {
        var result = await _worker.MergeTaskAsync(Task.Id, SelectedMergeTarget ?? "", false, "Merge task");
        if (result.Status == "conflict")
        {
            var (text, _, _) = MergePreviewPresenter.Describe(
                new MergePreviewDto("conflict", result.ConflictFiles, 0));
            MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
        }
        else
        {
            await RefreshMergePreviewAsync();
        }
    }
    catch { /* broadcast reconciles */ }
}
  • Step 6: Build UI + run the UI tests

Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release Run: dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release Expected: green. (If OnSelectedMergeTargetChanged already exists, merge the new line into it instead of duplicating.)

  • Step 7: Commit
git add src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs
git commit -m "feat(ui): show mergeability and surface approve conflicts in the work console"

Task 6: WorkConsole — status line + Merge button

Files:

  • Modify: src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml

No unit test (XAML); verified by build + manual visual check in Task 7.

  • Step 1: Add the mergeability status line and the Merge button

In the MERGE & WORKTREE StackPanel (starts line 196), insert the status line between the merge-target StackPanel (ends line 203) and the <WrapPanel> (line 204). Three single-line TextBlocks, one visible at a time by color:

<StackPanel Spacing="0">
  <TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
             Foreground="{DynamicResource MossBrush}"
             IsVisible="{Binding MergeIsClean}" />
  <TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
             Foreground="{DynamicResource BloodBrush}"
             IsVisible="{Binding MergeIsConflict}" />
  <TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
             Foreground="{DynamicResource TextMuteBrush}"
             IsVisible="{Binding ShowMergePreviewMuted}" />
</StackPanel>

In the <WrapPanel> (line 204), add a Merge button immediately after the "Open Diff" button (line 206):

<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
        Command="{Binding MergeCommand}"
        IsVisible="{Binding ShowSingleMerge}" />
  • Step 2: Build UI, verify green

Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release Expected: Build succeeded (XAML compiles; all bound members exist from Task 5).

  • Step 3: Commit
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
git commit -m "feat(ui): add mergeability indicator and Merge button to work console"

Task 7: Full build, full test, manual verification

Files: none (verification only)

  • Step 1: Build the whole app + worker

Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release Expected: both succeed.

  • Step 2: Run all touched test projects

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release Run: dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release Expected: all green.

  • Step 3: Manual verification (cannot be automated — no real Claude in tests)

Start the Worker, then the App. Pick a list whose WorkingDir is a real git repo and use a task that already has an Active worktree (or create one).

Verify each acceptance criterion:

  1. Clean approve: Open a WaitingForReview task whose worktree merges cleanly → the Session tab shows green "Merges cleanly · N files". Click Approve → the worktree merges into the target, the task becomes Done, and the worktree state becomes Merged (check the worktree overview).
  2. Conflicting approve: Open a task whose worktree conflicts with the target → the Session tab shows red "Conflicts in …". Click Approve → the task stays WaitingForReview (NOT Done), the conflict line remains, and the target branch is unchanged.
  3. Done task preview: Open a previously-Done task that was never merged (worktree still Active) → the merge/conflict status appears without any tree mutation; the Merge button merges it on demand.

Report the result of each check explicitly. If any visual issue appears (colors, layout, missing controls), note it for the user — do not claim the UI works without running it.


Self-review notes

  • Spec coverage: Approve-merge (Task 2/3/5), conflict-keeps-review (Task 2 test + Task 5 surfacing), non-destructive preview (Task 1/2 + indicator in Task 5/6), real single-task Merge button (Task 5/6), standalone target-loading gap (Task 5e). All spec sections map to a task.
  • Type consistency: MergePreview (Data) → MergePreviewResult (Worker service) → MergePreviewDto (hub + UI). Status strings clean/conflict/unavailable and merge statuses merged/conflict/blocked are used consistently across worker, client, presenter, and VM.
  • No new statuses, no DB migration, no localization keys (literals match the surrounding controls).
  • External MCP unchanged: ExternalMcpService.ReviewTask keeps calling TaskStateService.ApproveReviewAsync directly (its documented scope excludes merges); that method's signature is unchanged.