Files
ClaudeDo/docs/superpowers/plans/2026-04-22-worktree-merge.md
Mika Kuns 0885518a68 docs: add worktree merge implementation plan
23 TDD-sized tasks covering GitService additions, TaskMergeService,
SignalR surface, MergeModal view/vm, and wiring into DetailsIsland
plus DiffModal. Each task: failing test -> implement -> green -> commit.
2026-04-22 09:16:38 +02:00

65 KiB

Worktree merge into target branch — 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: Let users merge a task's claudedo/{id} worktree branch into a chosen local branch of the list's WorkingDir, via a new modal reachable from both the Details island and the DiffModal.

Architecture: New TaskMergeService in the Worker mirrors TaskResetService: pre-flight checks → git merge --no-ff → optional cleanup → DB state flip to Merged → broadcast. New Hub methods MergeTask and GetMergeTargets expose it to the UI. New MergeModalViewModel/MergeModalView host the dialog (target branch dropdown, remove-worktree checkbox, commit message). The existing stub DetailsIslandViewModel.ApproveMergeAsync and a new button in DiffModalView both open the modal.

Tech Stack: .NET 8, ASP.NET Core SignalR, EF Core (SQLite), Avalonia 12 (Fluent), CommunityToolkit.Mvvm, xUnit + real SQLite + real git via DbFixture/GitRepoFixture.


File structure

Create:

  • src/ClaudeDo.Worker/Services/TaskMergeService.cs
  • src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs
  • src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml
  • src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml.cs
  • tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
  • tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs

Modify:

  • src/ClaudeDo.Data/Git/GitService.cs — new git methods
  • src/ClaudeDo.Worker/Hub/WorkerHub.cs — new DTOs + hub methods
  • src/ClaudeDo.Worker/Program.cs — register TaskMergeService
  • src/ClaudeDo.Ui/Services/WorkerClient.cs — new client methods + DTOs
  • src/ClaudeDo.App/Program.cs — register MergeModalViewModel transient
  • src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs — wire ApproveMergeAsync, expose CanMerge
  • src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.csShowMergeModal hook
  • src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.csMergeCommand + factory hook
  • src/ClaudeDo.Ui/Views/Modals/DiffModalView.axaml — Merge button
  • src/ClaudeDo.Ui/Views/Modals/DiffModalView.axaml.cs — wire ShowMergeModal

Nothing else.

Build/test commands: dotnet build individual csproj files (not .slnx). Test command: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj.


Task 1: GitService — GetCurrentBranchAsync

Files:

  • Modify: src/ClaudeDo.Data/Git/GitService.cs

  • Test: tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs

  • Step 1: Write the failing test — create the new test file

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

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

namespace ClaudeDo.Worker.Tests.Runner;

public class GitServiceMergeTests : 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 GetCurrentBranchAsync_FreshRepo_ReturnsDefaultBranch()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var repo = NewRepo();
        var git = new GitService();

        var branch = await git.GetCurrentBranchAsync(repo.RepoDir);

        Assert.False(string.IsNullOrWhiteSpace(branch));
        // Default branch is either "main" or "master" depending on git config.
        Assert.True(branch == "main" || branch == "master",
            $"Expected main or master, got '{branch}'");
    }
}
  • Step 2: Run the test to verify it fails

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~GitServiceMergeTests.GetCurrentBranchAsync_FreshRepo_ReturnsDefaultBranch"

Expected: FAIL — GitService does not contain a method GetCurrentBranchAsync.

  • Step 3: Add the method to GitService

In src/ClaudeDo.Data/Git/GitService.cs, insert before RunGitAsync:

public async Task<string> GetCurrentBranchAsync(string repoDir, CancellationToken ct = default)
{
    var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
        ["symbolic-ref", "--short", "HEAD"], ct);
    if (exitCode != 0)
        throw new InvalidOperationException($"git symbolic-ref --short HEAD failed (exit {exitCode}): {stderr}");
    return stdout.Trim();
}
  • Step 4: Run the test to verify it passes

Run the same command as Step 2. Expected: PASS.

  • Step 5: Commit
git add src/ClaudeDo.Data/Git/GitService.cs tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs
git commit -m "feat(git): add GetCurrentBranchAsync"

Task 2: GitService — ListLocalBranchesAsync

Files:

  • Modify: src/ClaudeDo.Data/Git/GitService.cs

  • Test: tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs

  • Step 1: Write the failing test

Append to GitServiceMergeTests:

[Fact]
public async Task ListLocalBranchesAsync_AfterCreatingSecondBranch_ReturnsBoth()
{
    if (!GitRepoFixture.IsGitAvailable()) return;

    var repo = NewRepo();
    GitRepoFixture.RunGit(repo.RepoDir, "branch", "feature/x");

    var git = new GitService();
    var branches = await git.ListLocalBranchesAsync(repo.RepoDir);

    Assert.Contains("feature/x", branches);
    Assert.True(branches.Any(b => b == "main" || b == "master"));
}
  • Step 2: Run the test to verify it fails

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~GitServiceMergeTests.ListLocalBranchesAsync"

Expected: FAIL — no such method.

  • Step 3: Add the method to GitService

Insert next to GetCurrentBranchAsync in src/ClaudeDo.Data/Git/GitService.cs:

public async Task<List<string>> ListLocalBranchesAsync(string repoDir, CancellationToken ct = default)
{
    var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
        ["branch", "--format=%(refname:short)"], ct);
    if (exitCode != 0)
        throw new InvalidOperationException($"git branch --format failed (exit {exitCode}): {stderr}");

    return stdout
        .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
        .Where(s => s.Length > 0)
        .ToList();
}
  • Step 4: Run the test to verify it passes

Run the filter from Step 2. Expected: PASS.

  • Step 5: Commit
git add src/ClaudeDo.Data/Git/GitService.cs tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs
git commit -m "feat(git): add ListLocalBranchesAsync"

Task 3: GitService — IsMidMergeAsync

Files:

  • Modify: src/ClaudeDo.Data/Git/GitService.cs

  • Test: tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs

  • Step 1: Write the failing test

Append to GitServiceMergeTests:

[Fact]
public async Task IsMidMergeAsync_FreshRepo_ReturnsFalse()
{
    if (!GitRepoFixture.IsGitAvailable()) return;
    var repo = NewRepo();
    var git = new GitService();

    Assert.False(await git.IsMidMergeAsync(repo.RepoDir));
}

[Fact]
public async Task IsMidMergeAsync_MergeHeadPresent_ReturnsTrue()
{
    if (!GitRepoFixture.IsGitAvailable()) return;
    var repo = NewRepo();
    // Simulate a mid-merge state by dropping a MERGE_HEAD file.
    var mergeHead = Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD");
    File.WriteAllText(mergeHead, "0000000000000000000000000000000000000000\n");

    var git = new GitService();
    Assert.True(await git.IsMidMergeAsync(repo.RepoDir));
}
  • Step 2: Run the tests to verify they fail

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~GitServiceMergeTests.IsMidMergeAsync"

Expected: FAIL — no such method.

  • Step 3: Add the method

In src/ClaudeDo.Data/Git/GitService.cs, near the other branch/worktree methods:

public async Task<bool> IsMidMergeAsync(string repoDir, CancellationToken ct = default)
{
    var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["rev-parse", "--git-dir"], ct);
    if (exitCode != 0) return false;
    var gitDir = stdout.Trim();
    if (!Path.IsPathRooted(gitDir))
        gitDir = Path.Combine(repoDir, gitDir);
    return File.Exists(Path.Combine(gitDir, "MERGE_HEAD"));
}
  • Step 4: Run the tests to verify they pass

Run the filter from Step 2. Expected: PASS (both cases).

  • Step 5: Commit
git add src/ClaudeDo.Data/Git/GitService.cs tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs
git commit -m "feat(git): add IsMidMergeAsync"

Task 4: GitService — MergeNoFfAsync (tuple-returning)

This method MUST NOT throw on non-zero exit — the caller needs to distinguish conflict from hard failure.

Files:

  • Modify: src/ClaudeDo.Data/Git/GitService.cs

  • Test: tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs

  • Step 1: Write the failing test — success path

Append to GitServiceMergeTests:

[Fact]
public async Task MergeNoFfAsync_DivergedNonConflicting_ReturnsZero_AndCreatesMergeCommit()
{
    if (!GitRepoFixture.IsGitAvailable()) return;
    var repo = NewRepo();

    // Create a feature branch with one new file.
    GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature/merge");
    File.WriteAllText(Path.Combine(repo.RepoDir, "feature.txt"), "hello\n");
    GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
    GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat: add feature.txt");

    // Back to default and add a non-overlapping file so history diverges.
    var defaultBranch = GitRepoFixture.RunGit(repo.RepoDir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD").Trim();
    // If the above fails (no remote), we fall back: the default is whatever the fixture created.
    defaultBranch = string.IsNullOrEmpty(defaultBranch) ? "main" : defaultBranch.Replace("origin/", "");
    try { GitRepoFixture.RunGit(repo.RepoDir, "checkout", defaultBranch); }
    catch { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "master"); defaultBranch = "master"; }
    File.WriteAllText(Path.Combine(repo.RepoDir, "other.txt"), "other\n");
    GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
    GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: add other.txt");

    var git = new GitService();
    var (exitCode, stderr) = await git.MergeNoFfAsync(repo.RepoDir, "feature/merge", "Merge feature/merge");

    Assert.Equal(0, exitCode);
    // Confirm merge commit exists (two parents on HEAD).
    var parents = GitRepoFixture.RunGit(repo.RepoDir, "rev-list", "--parents", "-n", "1", "HEAD").Trim();
    Assert.True(parents.Split(' ').Length >= 3, $"Expected merge commit (3 tokens), got: '{parents}'");
}
  • Step 2: Run the test to verify it fails

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~GitServiceMergeTests.MergeNoFfAsync_DivergedNonConflicting"

Expected: FAIL — no such method.

  • Step 3: Add the method

In src/ClaudeDo.Data/Git/GitService.cs, near MergeFfOnlyAsync:

public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync(
    string repoDir, string sourceBranch, string message, CancellationToken ct = default)
{
    var (exitCode, _, stderr) = await RunGitAsync(repoDir,
        ["merge", "--no-ff", "-m", message, sourceBranch], ct);
    return (exitCode, stderr);
}
  • Step 4: Run the test to verify it passes

Run the filter from Step 2. Expected: PASS.

  • Step 5: Write the failing test — conflict path

Append to GitServiceMergeTests:

[Fact]
public async Task MergeNoFfAsync_Conflict_ReturnsNonZero()
{
    if (!GitRepoFixture.IsGitAvailable()) return;
    var repo = NewRepo();

    // Both branches modify README.md — guaranteed conflict.
    GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature/conflict");
    File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# feature side\n");
    GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
    GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat: feature edit");

    string defaultBranch = "main";
    try { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "main"); }
    catch { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "master"); defaultBranch = "master"; }
    File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main side\n");
    GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
    GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: main edit");

    var git = new GitService();
    var (exitCode, _) = await git.MergeNoFfAsync(repo.RepoDir, "feature/conflict", "merge");

    Assert.NotEqual(0, exitCode);
}
  • Step 6: Run the test to verify it passes

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~GitServiceMergeTests.MergeNoFfAsync_Conflict"

Expected: PASS (the method already exists; this test just confirms tuple semantics).

  • Step 7: Commit
git add src/ClaudeDo.Data/Git/GitService.cs tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs
git commit -m "feat(git): add MergeNoFfAsync returning (exitCode, stderr)"

Task 5: GitService — MergeAbortAsync

Files:

  • Modify: src/ClaudeDo.Data/Git/GitService.cs

  • Test: tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs

  • Step 1: Write the failing test

Append to GitServiceMergeTests:

[Fact]
public async Task MergeAbortAsync_AfterConflict_ClearsMergeState()
{
    if (!GitRepoFixture.IsGitAvailable()) return;
    var repo = NewRepo();

    GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature/abort");
    File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# feat side\n");
    GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
    GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "edit");

    try { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "main"); }
    catch { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "master"); }
    File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main side\n");
    GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
    GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "edit");

    var git = new GitService();
    await git.MergeNoFfAsync(repo.RepoDir, "feature/abort", "merge"); // will conflict

    Assert.True(await git.IsMidMergeAsync(repo.RepoDir));
    await git.MergeAbortAsync(repo.RepoDir);
    Assert.False(await git.IsMidMergeAsync(repo.RepoDir));
}
  • Step 2: Run the test to verify it fails

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~GitServiceMergeTests.MergeAbortAsync"

Expected: FAIL — no such method.

  • Step 3: Add the method

In src/ClaudeDo.Data/Git/GitService.cs:

public async Task MergeAbortAsync(string repoDir, CancellationToken ct = default)
{
    var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--abort"], ct);
    if (exitCode != 0)
        throw new InvalidOperationException($"git merge --abort failed (exit {exitCode}): {stderr}");
}
  • Step 4: Run the test to verify it passes

Run the filter from Step 2. Expected: PASS.

  • Step 5: Commit
git add src/ClaudeDo.Data/Git/GitService.cs tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs
git commit -m "feat(git): add MergeAbortAsync"

Task 6: GitService — ListConflictedFilesAsync

Files:

  • Modify: src/ClaudeDo.Data/Git/GitService.cs

  • Test: tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs

  • Step 1: Write the failing test

Append to GitServiceMergeTests:

[Fact]
public async Task ListConflictedFilesAsync_MidConflict_ReturnsConflictedFile()
{
    if (!GitRepoFixture.IsGitAvailable()) return;
    var repo = NewRepo();

    GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature/cflist");
    File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# feat\n");
    GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
    GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "edit");

    try { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "main"); }
    catch { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "master"); }
    File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main\n");
    GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
    GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "edit");

    var git = new GitService();
    await git.MergeNoFfAsync(repo.RepoDir, "feature/cflist", "merge");

    var files = await git.ListConflictedFilesAsync(repo.RepoDir);
    Assert.Contains("README.md", files);

    await git.MergeAbortAsync(repo.RepoDir);
}
  • Step 2: Run the test to verify it fails

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~GitServiceMergeTests.ListConflictedFilesAsync"

Expected: FAIL — no such method.

  • Step 3: Add the method

In src/ClaudeDo.Data/Git/GitService.cs:

public async Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default)
{
    var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
        ["diff", "--name-only", "--diff-filter=U"], ct);
    if (exitCode != 0)
        throw new InvalidOperationException($"git diff --diff-filter=U failed (exit {exitCode}): {stderr}");

    return stdout
        .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
        .Where(s => s.Length > 0)
        .ToList();
}
  • Step 4: Run the test to verify it passes

Run the filter from Step 2. Expected: PASS.

  • Step 5: Commit
git add src/ClaudeDo.Data/Git/GitService.cs tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs
git commit -m "feat(git): add ListConflictedFilesAsync"

Task 7: TaskMergeService skeleton + pre-flight blocking

Files:

  • Create: src/ClaudeDo.Worker/Services/TaskMergeService.cs

  • Create: tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs

  • Step 1: Create the service skeleton

Create src/ClaudeDo.Worker/Services/TaskMergeService.cs:

using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.Services;

public sealed record MergeResult(
    string Status,
    IReadOnlyList<string> ConflictFiles,
    string? ErrorMessage);

public sealed record MergeTargets(
    string DefaultBranch,
    IReadOnlyList<string> LocalBranches);

public sealed class TaskMergeService
{
    public const string StatusMerged   = "merged";
    public const string StatusConflict = "conflict";
    public const string StatusBlocked  = "blocked";

    private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
    private readonly GitService _git;
    private readonly HubBroadcaster _broadcaster;
    private readonly ILogger<TaskMergeService> _logger;

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

    public async Task<MergeResult> MergeAsync(
        string taskId,
        string targetBranch,
        bool removeWorktree,
        string commitMessage,
        CancellationToken ct)
    {
        TaskEntity task;
        ListEntity list;
        WorktreeEntity? wt;

        using (var ctx = _dbFactory.CreateDbContext())
        {
            task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
                ?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
            list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
                ?? throw new InvalidOperationException("List not found.");
            wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
        }

        if (task.Status == TaskStatus.Running)
            return Blocked("task is running");
        if (wt is null)
            return Blocked("task has no worktree");
        if (wt.State != WorktreeState.Active)
            return Blocked($"worktree state is {wt.State}");
        if (string.IsNullOrWhiteSpace(list.WorkingDir))
            return Blocked("list has no working directory");
        if (!await _git.IsGitRepoAsync(list.WorkingDir, ct))
            return Blocked("working directory is not a git repository");
        if (await _git.IsMidMergeAsync(list.WorkingDir, ct))
            return Blocked("target working directory is mid-merge");
        if (await _git.HasChangesAsync(list.WorkingDir, ct))
            return Blocked("target working tree has uncommitted changes");

        // Body added in later tasks.
        throw new NotImplementedException();
    }

    public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct)
    {
        TaskEntity task;
        ListEntity list;
        using (var ctx = _dbFactory.CreateDbContext())
        {
            task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
                ?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
            list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
                ?? throw new InvalidOperationException("List not found.");
        }

        if (string.IsNullOrWhiteSpace(list.WorkingDir))
            return new MergeTargets("", Array.Empty<string>());

        var current  = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
        var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
        return new MergeTargets(current, branches);
    }

    private static MergeResult Blocked(string reason) =>
        new(StatusBlocked, Array.Empty<string>(), reason);
}
  • Step 2: Write the failing pre-flight tests

Create tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs:

using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Services;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.Tests.Services;

public class TaskMergeServiceTests : IDisposable
{
    private readonly List<DbFixture> _dbs = new();
    private readonly List<GitRepoFixture> _repos = new();
    private readonly List<(string repoDir, string wtPath)> _wtCleanups = new();

    private DbFixture NewDb() { var d = new DbFixture(); _dbs.Add(d); return d; }
    private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }

    public void Dispose()
    {
        foreach (var (repoDir, wtPath) in _wtCleanups)
        {
            try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { }
        }
        foreach (var d in _dbs) try { d.Dispose(); } catch { }
        foreach (var r in _repos) try { r.Dispose(); } catch { }
    }

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

    private static WorktreeManager BuildWorktreeManager(DbFixture db)
    {
        return new WorktreeManager(
            new GitService(),
            db.CreateFactory(),
            new ClaudeDo.Worker.Config.WorkerConfig { WorktreeRootStrategy = "sibling" },
            NullLogger<WorktreeManager>.Instance);
    }

    private static async Task<(ListEntity list, TaskEntity task)> SeedListAndTask(
        DbFixture db, string workingDir, TaskStatus status)
    {
        var list = new ListEntity
        {
            Id = Guid.NewGuid().ToString(),
            Name = "merge-test",
            WorkingDir = workingDir,
            DefaultCommitType = "feat",
            CreatedAt = DateTime.UtcNow,
        };
        var task = new TaskEntity
        {
            Id = Guid.NewGuid().ToString(),
            ListId = list.Id,
            Title = "merge-task",
            Status = status,
            CreatedAt = DateTime.UtcNow,
        };
        using var ctx = db.CreateContext();
        await new ListRepository(ctx).AddAsync(list);
        await new TaskRepository(ctx).AddAsync(task);
        return (list, task);
    }

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

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

        Assert.Equal("blocked", result.Status);
        Assert.Contains("running", result.ErrorMessage ?? "");
        Assert.Empty(proxy.Calls);
    }

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

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

        Assert.Equal("blocked", result.Status);
        Assert.Contains("no worktree", result.ErrorMessage ?? "");
    }
}

#region Test doubles

internal sealed record MergeHubCall(string Method, object?[] Args);

internal sealed class MergeRecordingClientProxy : IClientProxy
{
    public readonly List<MergeHubCall> Calls = new();
    public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
    {
        Calls.Add(new MergeHubCall(method, args));
        return Task.CompletedTask;
    }
}

internal sealed class MergeRecordingHubClients : IHubClients
{
    public MergeRecordingClientProxy AllProxy { get; } = new();
    public IClientProxy All => AllProxy;
    public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => AllProxy;
    public IClientProxy Client(string connectionId) => AllProxy;
    public IClientProxy Clients(IReadOnlyList<string> connectionIds) => AllProxy;
    public IClientProxy Group(string groupName) => AllProxy;
    public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => AllProxy;
    public IClientProxy Groups(IReadOnlyList<string> groupNames) => AllProxy;
    public IClientProxy User(string userId) => AllProxy;
    public IClientProxy Users(IReadOnlyList<string> userIds) => AllProxy;
}

internal sealed class MergeRecordingHubContext : IHubContext<ClaudeDo.Worker.Hub.WorkerHub>
{
    private readonly MergeRecordingHubClients _clients = new();
    public MergeRecordingClientProxy Proxy => _clients.AllProxy;
    public IHubClients Clients => _clients;
    public IGroupManager Groups => throw new NotImplementedException();
}

#endregion
  • Step 3: Run the tests to verify they pass

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~TaskMergeServiceTests"

Expected: PASS (both pre-flight tests).

  • Step 4: Commit
git add src/ClaudeDo.Worker/Services/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
git commit -m "feat(worker): scaffold TaskMergeService with pre-flight checks"

Task 8: TaskMergeService — happy path (ff-able, remove=false)

Files:

  • Modify: src/ClaudeDo.Worker/Services/TaskMergeService.cs

  • Modify: tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs

  • Step 1: Write the failing happy-path test

Append to TaskMergeServiceTests:

[Fact]
public async Task MergeAsync_FfAble_KeepWorktree_SetsMergedAndBroadcasts()
{
    if (!GitRepoFixture.IsGitAvailable()) return;

    var repo = NewRepo();
    var db = NewDb();
    var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

    // Create worktree and make a real commit inside it.
    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, proxy) = BuildService(db);
    var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

    var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
        commitMessage: "Merge task", ct: CancellationToken.None);

    Assert.Equal("merged", result.Status);
    Assert.Empty(result.ConflictFiles);

    // Worktree state now Merged, dir and branch still present.
    using var ctx = db.CreateContext();
    var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
    Assert.NotNull(wt);
    Assert.Equal(WorktreeState.Merged, wt!.State);
    Assert.True(Directory.Exists(wtCtx.WorktreePath));

    // Broadcast fired.
    Assert.Contains(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == task.Id);

    // added.txt is now on the main branch of the repo.
    Assert.True(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));
}
  • Step 2: Run the test to verify it fails

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~TaskMergeServiceTests.MergeAsync_FfAble_KeepWorktree"

Expected: FAIL — NotImplementedException from the scaffold.

  • Step 3: Implement the body

In src/ClaudeDo.Worker/Services/TaskMergeService.cs, replace the throw new NotImplementedException(); line in MergeAsync with:

        var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct);
        if (exitCode != 0)
        {
            List<string> files;
            try { files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); }
            catch { files = new(); }
            try { await _git.MergeAbortAsync(list.WorkingDir, ct); }
            catch (Exception ex) { _logger.LogWarning(ex, "git merge --abort failed after conflict"); }

            if (files.Count == 0)
            {
                // Non-conflict failure (e.g. unrelated histories).
                return new MergeResult(StatusBlocked, Array.Empty<string>(), $"merge failed: {stderr}");
            }

            return new MergeResult(StatusConflict, files, null);
        }

        string? cleanupWarning = null;
        if (removeWorktree)
        {
            try
            {
                await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: false, ct);
                try { await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: false, ct); }
                catch (Exception ex)
                {
                    _logger.LogWarning(ex, "branch delete failed for {Branch}", wt.BranchName);
                    cleanupWarning = $"worktree removed, branch delete failed: {ex.Message}";
                }
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "worktree remove failed for {Path}", wt.Path);
                cleanupWarning = $"worktree remove failed: {ex.Message}";
            }
        }

        using (var ctx = _dbFactory.CreateDbContext())
        {
            await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Merged, ct);
        }
        await _broadcaster.WorktreeUpdated(taskId);

        _logger.LogInformation(
            "Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
            taskId, wt.BranchName, targetBranch, removeWorktree);

        return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
  • Step 4: Run the test to verify it passes

Run the same filter as Step 2. Expected: PASS.

  • Step 5: Commit
git add src/ClaudeDo.Worker/Services/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
git commit -m "feat(worker): implement TaskMergeService happy path"

Task 9: TaskMergeService — happy path with worktree removal

Files:

  • Modify: tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs

  • Step 1: Write the failing test

Append:

[Fact]
public async Task MergeAsync_FfAble_RemoveWorktree_CleansEverything()
{
    if (!GitRepoFixture.IsGitAvailable()) return;

    var repo = NewRepo();
    var db = NewDb();
    var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

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

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

    var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true,
        commitMessage: "Merge", ct: CancellationToken.None);

    Assert.Equal("merged", result.Status);
    Assert.False(Directory.Exists(wtCtx.WorktreePath));

    // Branch must be gone.
    var branches = await new GitService().ListLocalBranchesAsync(repo.RepoDir);
    Assert.DoesNotContain(wtCtx.BranchName, branches);

    // DB state still Merged.
    using var ctx = db.CreateContext();
    var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
    Assert.Equal(WorktreeState.Merged, wt!.State);
}
  • Step 2: Run the test to verify it passes

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~TaskMergeServiceTests.MergeAsync_FfAble_RemoveWorktree"

Expected: PASS (implementation from Task 8 already handles removeWorktree=true).

  • Step 3: Commit
git add tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
git commit -m "test(worker): cover TaskMergeService removeWorktree path"

Task 10: TaskMergeService — diverged non-conflicting history

Files:

  • Modify: tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs

  • Step 1: Write the failing test

Append:

[Fact]
public async Task MergeAsync_DivergedNonConflicting_ProducesMergeCommit()
{
    if (!GitRepoFixture.IsGitAvailable()) return;

    var repo = NewRepo();
    var db = NewDb();
    var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

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

    // Advance main by adding a different file.
    File.WriteAllText(Path.Combine(repo.RepoDir, "main-only.txt"), "main\n");
    GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
    GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: main moved");

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

    var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
        commitMessage: "Merge diverged", ct: CancellationToken.None);

    Assert.Equal("merged", result.Status);
    // HEAD must be a merge commit (two parents).
    var parents = GitRepoFixture.RunGit(repo.RepoDir, "rev-list", "--parents", "-n", "1", "HEAD").Trim();
    Assert.True(parents.Split(' ').Length >= 3, $"Expected merge commit, got '{parents}'");
}
  • Step 2: Run the test to verify it passes

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~TaskMergeServiceTests.MergeAsync_DivergedNonConflicting"

Expected: PASS.

  • Step 3: Commit
git add tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
git commit -m "test(worker): cover diverged non-conflicting merge"

Task 11: TaskMergeService — conflict path with auto-abort

Files:

  • Modify: tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs

  • Step 1: Write the failing test

Append:

[Fact]
public async Task MergeAsync_Conflict_AbortsAndReturnsConflictedFiles()
{
    if (!GitRepoFixture.IsGitAvailable()) return;

    var repo = NewRepo();
    var db = NewDb();
    var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

    var wtMgr = BuildWorktreeManager(db);
    var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
    _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));

    // Worktree edits README.md
    File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
    await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

    // Main also edits README.md (conflicting).
    File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
    GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
    GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: main edit");
    var mainHeadBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();

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

    var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true,
        commitMessage: "Merge", ct: CancellationToken.None);

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

    // Main branch must be restored exactly.
    var mainHeadAfter = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
    Assert.Equal(mainHeadBefore, mainHeadAfter);
    Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));

    // Worktree state stays Active (no broadcast).
    using var ctx = db.CreateContext();
    var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
    Assert.Equal(WorktreeState.Active, wt!.State);
    Assert.DoesNotContain(proxy.Calls, c => c.Method == "WorktreeUpdated");
}
  • Step 2: Run the test to verify it passes

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~TaskMergeServiceTests.MergeAsync_Conflict"

Expected: PASS (implementation from Task 8 already handles conflicts).

  • Step 3: Commit
git add tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
git commit -m "test(worker): cover merge conflict auto-abort"

Task 12: TaskMergeService — GetTargetsAsync + dirty-tree/mid-merge pre-flight tests

Files:

  • Modify: tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs

  • Step 1: Write failing tests

Append:

[Fact]
public async Task GetTargetsAsync_ReturnsCurrentAndLocalBranches()
{
    if (!GitRepoFixture.IsGitAvailable()) return;
    var repo = NewRepo();
    GitRepoFixture.RunGit(repo.RepoDir, "branch", "feature/extra");
    var db = NewDb();
    var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

    var (svc, _) = BuildService(db);
    var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None);

    Assert.False(string.IsNullOrWhiteSpace(targets.DefaultBranch));
    Assert.Contains("feature/extra", targets.LocalBranches);
    Assert.Contains(targets.DefaultBranch, targets.LocalBranches);
}

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

    var wtMgr = BuildWorktreeManager(db);
    var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
    _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));

    // Dirty the target working dir.
    File.WriteAllText(Path.Combine(repo.RepoDir, "dirt.txt"), "dirty\n");

    var (svc, _) = BuildService(db);
    var result = await svc.MergeAsync(task.Id, "main", false, "Merge", CancellationToken.None);

    Assert.Equal("blocked", result.Status);
    Assert.Contains("uncommitted", result.ErrorMessage ?? "");
}
  • Step 2: Run the tests to verify they pass

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~TaskMergeServiceTests"

Expected: all tests PASS.

  • Step 3: Commit
git add tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
git commit -m "test(worker): cover GetTargetsAsync and dirty-tree block"

Task 13: Register TaskMergeService in Worker DI

Files:

  • Modify: src/ClaudeDo.Worker/Program.cs

  • Step 1: Edit Program.cs

In src/ClaudeDo.Worker/Program.cs, find the existing line builder.Services.AddSingleton<TaskResetService>(); and insert immediately after:

builder.Services.AddSingleton<TaskMergeService>();
  • Step 2: Build to confirm

Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj

Expected: build succeeds.

  • Step 3: Commit
git add src/ClaudeDo.Worker/Program.cs
git commit -m "chore(worker): register TaskMergeService"

Task 14: Hub MergeTask + GetMergeTargets methods + DTOs

Files:

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

  • Step 1: Add DTOs and fields

In src/ClaudeDo.Worker/Hub/WorkerHub.cs, near the other public record DTOs (around the top), add:

public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
  • Step 2: Inject TaskMergeService into the hub

Add a private field and constructor parameter. Update WorkerHub's constructor:

private readonly TaskMergeService _mergeService;

public WorkerHub(
    QueueService queue,
    AgentFileService agentService,
    HubBroadcaster broadcaster,
    IDbContextFactory<ClaudeDoDbContext> dbFactory,
    WorktreeMaintenanceService wtMaintenance,
    TaskResetService resetService,
    TaskMergeService mergeService)
{
    _queue = queue;
    _agentService = agentService;
    _broadcaster = broadcaster;
    _dbFactory = dbFactory;
    _wtMaintenance = wtMaintenance;
    _resetService = resetService;
    _mergeService = mergeService;
}
  • Step 3: Add the hub methods

Add at the end of the class, before the closing brace:

public async Task<MergeResultDto> MergeTask(
    string taskId, string targetBranch, bool removeWorktree, string commitMessage)
{
    try
    {
        var r = await _mergeService.MergeAsync(
            taskId,
            targetBranch ?? "",
            removeWorktree,
            string.IsNullOrWhiteSpace(commitMessage) ? "Merge task" : commitMessage,
            CancellationToken.None);
        return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
    }
    catch (KeyNotFoundException)
    {
        throw new HubException("task not found");
    }
    catch (InvalidOperationException ex)
    {
        throw new HubException(ex.Message);
    }
}

public async Task<MergeTargetsDto> GetMergeTargets(string taskId)
{
    try
    {
        var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None);
        return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches);
    }
    catch (KeyNotFoundException)
    {
        throw new HubException("task not found");
    }
    catch (InvalidOperationException ex)
    {
        throw new HubException(ex.Message);
    }
}
  • Step 4: Add using for the service namespace if not present

Ensure the top of WorkerHub.cs contains:

using ClaudeDo.Worker.Services;

(Likely already present from TaskResetService.)

  • Step 5: Build to confirm

Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj

Expected: build succeeds.

  • Step 6: Commit
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs
git commit -m "feat(worker): expose MergeTask and GetMergeTargets on WorkerHub"

Task 15: WorkerClient.MergeTaskAsync + GetMergeTargetsAsync + DTOs

Files:

  • Modify: src/ClaudeDo.Ui/Services/WorkerClient.cs

  • Step 1: Add DTOs

In src/ClaudeDo.Ui/Services/WorkerClient.cs, near other DTOs (find ActiveTaskDto or similar public record declarations in the file):

public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);

If a similar set of DTOs (e.g. AppSettingsDto) already lives in this file, add these next to them. If DTOs live only server-side so far and the client redefines them, follow that pattern here.

  • Step 2: Add methods to WorkerClient

Next to ResetTaskAsync:

public async Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage)
{
    return await _hub.InvokeAsync<MergeResultDto>(
        "MergeTask", taskId, targetBranch, removeWorktree, commitMessage);
}

public async Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
{
    try
    {
        return await _hub.InvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
    }
    catch
    {
        return null;
    }
}
  • Step 3: Build to confirm

Run: dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj

Expected: build succeeds.

  • Step 4: Commit
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
git commit -m "feat(ui): add MergeTaskAsync and GetMergeTargetsAsync to WorkerClient"

Task 16: MergeModalViewModel

Files:

  • Create: src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs

  • Step 1: Create the view-model

Create src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs:

using System.Collections.ObjectModel;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace ClaudeDo.Ui.ViewModels.Modals;

public sealed partial class MergeModalViewModel : ViewModelBase
{
    private readonly WorkerClient _worker;

    public string TaskId { get; set; } = "";
    public string TaskTitle { get; set; } = "";

    public ObservableCollection<string> Branches { get; } = new();

    [ObservableProperty] private string? _selectedBranch;
    [ObservableProperty] private bool _removeWorktree = true;
    [ObservableProperty] private string _commitMessage = "";

    [ObservableProperty] private bool _isBusy;
    [ObservableProperty] private string? _errorMessage;
    [ObservableProperty] private string? _warningMessage;
    [ObservableProperty] private string? _successMessage;
    [ObservableProperty] private bool _hasConflict;
    [ObservableProperty] private IReadOnlyList<string> _conflictFiles = Array.Empty<string>();

    public Action? CloseAction { get; set; }

    public MergeModalViewModel(WorkerClient worker)
    {
        _worker = worker;
    }

    public async Task InitializeAsync(string taskId, string taskTitle)
    {
        TaskId = taskId;
        TaskTitle = taskTitle;
        CommitMessage = $"Merge task: {taskTitle}";

        IsBusy = true;
        try
        {
            var targets = await _worker.GetMergeTargetsAsync(taskId);
            Branches.Clear();
            if (targets is null)
            {
                ErrorMessage = "Worker offline — cannot list branches.";
                return;
            }
            foreach (var b in targets.LocalBranches) Branches.Add(b);
            SelectedBranch = Branches.Contains(targets.DefaultBranch)
                ? targets.DefaultBranch
                : Branches.FirstOrDefault();
        }
        catch (Exception ex)
        {
            ErrorMessage = $"Failed to load branches: {ex.Message}";
        }
        finally { IsBusy = false; }
    }

    private bool CanSubmit() =>
        !IsBusy && !HasConflict && !string.IsNullOrWhiteSpace(SelectedBranch);

    [RelayCommand(CanExecute = nameof(CanSubmit))]
    private async Task SubmitAsync()
    {
        if (string.IsNullOrWhiteSpace(SelectedBranch)) return;
        IsBusy = true;
        ErrorMessage = null;
        WarningMessage = null;
        SuccessMessage = null;
        try
        {
            var result = await _worker.MergeTaskAsync(
                TaskId, SelectedBranch!, RemoveWorktree, CommitMessage);

            switch (result.Status)
            {
                case "merged":
                    SuccessMessage = result.ErrorMessage is not null
                        ? $"Merged with warning: {result.ErrorMessage}"
                        : "Merged.";
                    // Auto-close after a short delay.
                    _ = Task.Run(async () =>
                    {
                        await Task.Delay(1200);
                        Avalonia.Threading.Dispatcher.UIThread.Post(() => CloseAction?.Invoke());
                    });
                    break;
                case "conflict":
                    HasConflict = true;
                    ConflictFiles = result.ConflictFiles;
                    ErrorMessage = "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.";
                    break;
                case "blocked":
                    ErrorMessage = $"Blocked: {result.ErrorMessage}";
                    break;
                default:
                    ErrorMessage = $"Unknown status: {result.Status}";
                    break;
            }
        }
        catch (Exception ex)
        {
            ErrorMessage = $"Merge failed: {ex.Message}";
        }
        finally
        {
            IsBusy = false;
        }
    }

    [RelayCommand]
    private void Cancel() => CloseAction?.Invoke();
}
  • Step 2: Build to confirm

Run: dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj

Expected: build succeeds.

  • Step 3: Commit
git add src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs
git commit -m "feat(ui): add MergeModalViewModel"

Task 17: MergeModalView XAML + code-behind

Files:

  • Create: src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml

  • Create: src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml.cs

  • Step 1: Create the view XAML

Create src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml:

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="clr-namespace:ClaudeDo.Ui.ViewModels.Modals"
        x:Class="ClaudeDo.Ui.Views.Modals.MergeModalView"
        x:DataType="vm:MergeModalViewModel"
        Title="Merge worktree"
        Width="560" Height="420"
        CanResize="False"
        WindowStartupLocation="CenterOwner">
  <Grid Margin="20" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*,Auto">

    <TextBlock Grid.Row="0"
               Text="{Binding TaskTitle, StringFormat='Merging: {0}'}"
               FontWeight="SemiBold" Margin="0,0,0,12" />

    <StackPanel Grid.Row="1" Orientation="Vertical" Margin="0,0,0,8">
      <TextBlock Text="Target branch" Margin="0,0,0,4" />
      <ComboBox ItemsSource="{Binding Branches}"
                SelectedItem="{Binding SelectedBranch}"
                HorizontalAlignment="Stretch"
                IsEnabled="{Binding !IsBusy}" />
    </StackPanel>

    <CheckBox Grid.Row="2"
              Content="Remove worktree after merge"
              IsChecked="{Binding RemoveWorktree}"
              IsEnabled="{Binding !IsBusy}"
              Margin="0,0,0,8" />

    <StackPanel Grid.Row="3" Orientation="Vertical" Margin="0,0,0,8">
      <TextBlock Text="Commit message" Margin="0,0,0,4" />
      <TextBox Text="{Binding CommitMessage}"
               AcceptsReturn="True"
               TextWrapping="Wrap"
               Height="70"
               IsEnabled="{Binding !IsBusy}" />
    </StackPanel>

    <TextBlock Grid.Row="4"
               Text="{Binding ErrorMessage}"
               Foreground="IndianRed"
               TextWrapping="Wrap"
               IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}"
               Margin="0,0,0,8" />

    <Border Grid.Row="5"
            BorderBrush="IndianRed"
            BorderThickness="1"
            Padding="8"
            IsVisible="{Binding HasConflict}">
      <StackPanel>
        <TextBlock Text="Conflicted files:" FontWeight="SemiBold" Margin="0,0,0,4" />
        <ItemsControl ItemsSource="{Binding ConflictFiles}">
          <ItemsControl.ItemTemplate>
            <DataTemplate>
              <TextBlock Text="{Binding}" />
            </DataTemplate>
          </ItemsControl.ItemTemplate>
        </ItemsControl>
      </StackPanel>
    </Border>

    <StackPanel Grid.Row="6" Orientation="Horizontal"
                HorizontalAlignment="Right" Margin="0,12,0,0">
      <TextBlock Text="{Binding SuccessMessage}"
                 Foreground="SeaGreen"
                 VerticalAlignment="Center"
                 Margin="0,0,12,0"
                 IsVisible="{Binding SuccessMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
      <Button Content="Cancel"
              Command="{Binding CancelCommand}"
              Margin="0,0,8,0" />
      <Button Content="Merge"
              Command="{Binding SubmitCommand}"
              IsDefault="True"
              Classes="accent" />
    </StackPanel>

  </Grid>
</Window>
  • Step 2: Create the code-behind

Create src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml.cs:

using Avalonia.Controls;
using ClaudeDo.Ui.ViewModels.Modals;

namespace ClaudeDo.Ui.Views.Modals;

public partial class MergeModalView : Window
{
    public MergeModalView()
    {
        InitializeComponent();
        DataContextChanged += (_, _) =>
        {
            if (DataContext is MergeModalViewModel vm)
            {
                vm.CloseAction = () => Close();
            }
        };
    }
}
  • Step 3: Build to confirm

Run: dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj

Expected: build succeeds.

  • Step 4: Commit
git add src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml.cs
git commit -m "feat(ui): add MergeModalView"

Task 18: Register MergeModalViewModel in UI DI

Files:

  • Modify: src/ClaudeDo.App/Program.cs

  • Step 1: Edit Program.cs

Find the line sc.AddTransient<SettingsModalViewModel>(); in src/ClaudeDo.App/Program.cs and insert immediately after:

sc.AddTransient<MergeModalViewModel>();

Ensure the file has using ClaudeDo.Ui.ViewModels.Modals; at the top (it likely already does).

  • Step 2: Build to confirm

Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj

Expected: build succeeds.

  • Step 3: Commit
git add src/ClaudeDo.App/Program.cs
git commit -m "chore(app): register MergeModalViewModel"

Task 19: Wire DetailsIslandViewModel.ApproveMergeAsync + CanMerge

Files:

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

  • Step 1: Add factory property near the other ShowXxxModal properties

Around the line defining ShowDiffModal (approx line 104 per earlier exploration), add:

public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }

Ensure using ClaudeDo.Ui.ViewModels.Modals; is present at the top.

  • Step 2: Replace the stub ApproveMergeAsync body

Find:

[RelayCommand]
private async System.Threading.Tasks.Task ApproveMergeAsync()
{
    if (Task == null) return;
    // TODO: call worker merge hub method when available
    await System.Threading.Tasks.Task.CompletedTask;
}

Replace [RelayCommand] with [RelayCommand(CanExecute = nameof(CanMerge))] and replace the body:

[RelayCommand(CanExecute = nameof(CanMerge))]
private async System.Threading.Tasks.Task ApproveMergeAsync()
{
    if (Task == null || ShowMergeModal == null) return;
    var vm = _services.GetRequiredService<MergeModalViewModel>();
    await vm.InitializeAsync(Task.Id, Task.Title);
    await ShowMergeModal(vm);
}

private bool CanMerge() =>
    Task != null
    && _worker.IsConnected
    && Task.Worktree is { State: Data.Models.WorktreeState.Active };
  • Step 3: Hook the observable change so CanMerge re-evaluates

DetailsIslandViewModel likely already re-notifies related commands when Task or _worker.IsConnected changes (e.g. for CanReset). Find the partial method(s) that notify ResetCommand/ContinueCommand of CanExecute changes and add ApproveMergeCommand.NotifyCanExecuteChanged(); there.

If no such partial method exists for Task, add one:

partial void OnTaskChanged(TaskItemViewModel? value)
{
    ApproveMergeCommand.NotifyCanExecuteChanged();
}

(Name of the task property may be different — use whatever [ObservableProperty] private X _task; generates. If a similar partial void already exists, add the line inside it instead of creating a duplicate.)

  • Step 4: Build to confirm

Run: dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj

Expected: build succeeds.

  • Step 5: Commit
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
git commit -m "feat(ui): wire DetailsIsland ApproveMerge through MergeModal"

Task 20: Hook ShowMergeModal in DetailsIslandView

Files:

  • Modify: src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs

  • Step 1: Hook the factory in OnDataContextChanged

Find the block that sets vm.ShowDiffModal and vm.ShowWorktreeModal (around line 23-38 per earlier exploration). Below them, inside the same if (DataContext is DetailsIslandViewModel vm) block, add:

vm.ShowMergeModal = async (mergeVm) =>
{
    var owner = TopLevel.GetTopLevel(this) as Window;
    if (owner == null) return;
    var modal = new MergeModalView { DataContext = mergeVm };
    await modal.ShowDialog(owner);
};
  • Step 2: Build to confirm

Run: dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj

Expected: build succeeds.

  • Step 3: Commit
git add src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs
git commit -m "feat(ui): attach MergeModal to DetailsIsland"

Task 21: Add Merge command to DiffModalViewModel

The DiffModal already knows WorktreePath but not the task id. We need to pass the task id in.

Files:

  • Modify: src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs

  • Modify: src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs (pass TaskId when creating diff VM)

  • Step 1: Add TaskId, TaskTitle, and ShowMergeModal hooks to DiffModalViewModel

In src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs, near the top of the DiffModalViewModel class alongside WorktreePath and BaseRef:

public string? TaskId { get; init; }
public string TaskTitle { get; init; } = "";
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }

Add using ClaudeDo.Ui.ViewModels.Modals; if needed — the type is in the same namespace, so no import is required.

  • Step 2: Add the MergeAsync relay command

Inside DiffModalViewModel, alongside the existing Close command:

private bool CanMerge() =>
    !string.IsNullOrEmpty(TaskId)
    && ShowMergeModal is not null
    && ResolveMergeVm is not null;

[RelayCommand(CanExecute = nameof(CanMerge))]
private async Task MergeAsync()
{
    if (TaskId is null || ShowMergeModal is null || ResolveMergeVm is null) return;
    var vm = ResolveMergeVm();
    await vm.InitializeAsync(TaskId, TaskTitle);
    await ShowMergeModal(vm);
}
  • Step 3: Populate the new fields from the DetailsIsland when opening the DiffModal

In src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs around line 303-310 (the block that constructs DiffModalViewModel), update the object initializer:

var diffVm = new DiffModalViewModel(_services.GetRequiredService<ClaudeDo.Data.Git.GitService>())
{
    WorktreePath = WorktreePath!,
    BaseRef = BaseRef,                           // if already there, leave
    TaskId = Task?.Id,
    TaskTitle = Task?.Title ?? "",
    ShowMergeModal = ShowMergeModal,
    ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
};

Keep any properties already in the existing initializer. Adjust the names if the existing code uses different member names — the principle is: pass Task.Id, Task.Title, and the same ShowMergeModal factory that the DetailsIsland already has.

  • Step 4: Build to confirm

Run: dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj

Expected: build succeeds.

  • Step 5: Commit
git add src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
git commit -m "feat(ui): add Merge command to DiffModal"

Task 22: Add Merge button to DiffModalView

Files:

  • Modify: src/ClaudeDo.Ui/Views/Modals/DiffModalView.axaml

  • Step 1: Find the existing button bar

In src/ClaudeDo.Ui/Views/Modals/DiffModalView.axaml, locate the existing Close button (likely inside a StackPanel with Orientation="Horizontal" near the bottom, bound to CloseCommand).

  • Step 2: Insert a Merge button before the Close button

Add immediately before the Close button, inside the same parent panel. Avalonia's Button.Command binding auto-disables the button when CanExecute returns false, so no explicit IsEnabled is needed:

<Button Content="Merge…"
        Command="{Binding MergeCommand}"
        Margin="0,0,8,0" />
  • Step 3: Build to confirm

Run: dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj

Expected: build succeeds.

  • Step 4: Commit
git add src/ClaudeDo.Ui/Views/Modals/DiffModalView.axaml
git commit -m "feat(ui): add Merge button to DiffModal"

Task 23: Full-suite run + manual UI verification

Files: none modified; this is a verification step.

  • Step 1: Run the full test suite

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj

Expected: all tests PASS, including existing ones (no regressions).

  • Step 2: Build the whole solution

Run each (per the .slnx caveat in memory):

dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj

Expected: all four succeed.

  • Step 3: Manual UI checklist

Launch the Worker and then the App. Create a list pointing at a scratch git repo, add a task, let it run and produce a worktree commit, then:

  1. From the Details island, click Merge (formerly the stub).
  2. Modal opens, dropdown shows local branches, default matches the repo's current branch.
  3. Edit the commit message, leave "Remove worktree after merge" checked, click Merge.
  4. Modal shows "Merged." and closes after ~1s. Details island refreshes; worktree section hides or shows Merged.
  5. Open a new task in the same list, advance main with a conflicting commit, then click Merge.
  6. Modal shows red panel listing the conflicted file. Main branch is not mid-merge (git status clean).
  7. Click Cancel on a fresh merge dialog → no change.
  8. Open DiffModal for a task with active worktree → Merge button visible → clicking opens the same modal → works as above.

If any step fails, capture the failure and debug before committing.

  • Step 4: Commit the plan-complete marker

No code change required. Report back: "All tasks passed, manual checklist complete."


Notes for implementers

  • Never skip pre-flight — the contract is that git merge --no-ff is only invoked after the 5 pre-flight checks pass.
  • Worktree remove uses force: false intentionally — after a clean merge the worktree has no uncommitted work. If it unexpectedly fails, the service surfaces a cleanup warning rather than masking the problem.
  • DB update happens after successful merge, not after cleanup. If cleanup fails the merge has still succeeded and the worktree row must show Merged.
  • WorktreeUpdated broadcast fires only on the success path. Conflicts and blocked states change no persistent state, so no broadcast.
  • Tests must skip when git is unavailable via if (!GitRepoFixture.IsGitAvailable()) return; — same convention as TaskResetServiceTests.