From 0885518a687212df6bec86184f0b561188381b6b Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 22 Apr 2026 09:16:38 +0200 Subject: [PATCH] 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. --- .../plans/2026-04-22-worktree-merge.md | 1955 +++++++++++++++++ 1 file changed, 1955 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-22-worktree-merge.md diff --git a/docs/superpowers/plans/2026-04-22-worktree-merge.md b/docs/superpowers/plans/2026-04-22-worktree-merge.md new file mode 100644 index 0000000..53674c6 --- /dev/null +++ b/docs/superpowers/plans/2026-04-22-worktree-merge.md @@ -0,0 +1,1955 @@ +# 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.cs` — `ShowMergeModal` hook +- `src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs` — `MergeCommand` + 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`: + +```csharp +using ClaudeDo.Data.Git; +using ClaudeDo.Worker.Tests.Infrastructure; + +namespace ClaudeDo.Worker.Tests.Runner; + +public class GitServiceMergeTests : IDisposable +{ + private readonly List _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`: + +```csharp +public async Task 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** + +```bash +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`: + +```csharp +[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`: + +```csharp +public async Task> 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** + +```bash +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`: + +```csharp +[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: + +```csharp +public async Task 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** + +```bash +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`: + +```csharp +[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`: + +```csharp +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`: + +```csharp +[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** + +```bash +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`: + +```csharp +[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`: + +```csharp +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** + +```bash +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`: + +```csharp +[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`: + +```csharp +public async Task> 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** + +```bash +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`: + +```csharp +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 ConflictFiles, + string? ErrorMessage); + +public sealed record MergeTargets( + string DefaultBranch, + IReadOnlyList LocalBranches); + +public sealed class TaskMergeService +{ + public const string StatusMerged = "merged"; + public const string StatusConflict = "conflict"; + public const string StatusBlocked = "blocked"; + + private readonly IDbContextFactory _dbFactory; + private readonly GitService _git; + private readonly HubBroadcaster _broadcaster; + private readonly ILogger _logger; + + public TaskMergeService( + IDbContextFactory dbFactory, + GitService git, + HubBroadcaster broadcaster, + ILogger logger) + { + _dbFactory = dbFactory; + _git = git; + _broadcaster = broadcaster; + _logger = logger; + } + + public async Task 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 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()); + + 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(), reason); +} +``` + +- [ ] **Step 2: Write the failing pre-flight tests** + +Create `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`: + +```csharp +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 _dbs = new(); + private readonly List _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.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.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 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 excludedConnectionIds) => AllProxy; + public IClientProxy Client(string connectionId) => AllProxy; + public IClientProxy Clients(IReadOnlyList connectionIds) => AllProxy; + public IClientProxy Group(string groupName) => AllProxy; + public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => AllProxy; + public IClientProxy Groups(IReadOnlyList groupNames) => AllProxy; + public IClientProxy User(string userId) => AllProxy; + public IClientProxy Users(IReadOnlyList userIds) => AllProxy; +} + +internal sealed class MergeRecordingHubContext : IHubContext +{ + 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** + +```bash +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`: + +```csharp +[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: + +```csharp + var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct); + if (exitCode != 0) + { + List 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(), $"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(), cleanupWarning); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run the same filter as Step 2. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +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: + +```csharp +[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** + +```bash +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: + +```csharp +[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** + +```bash +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: + +```csharp +[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** + +```bash +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: + +```csharp +[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** + +```bash +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();` and insert immediately after: + +```csharp +builder.Services.AddSingleton(); +``` + +- [ ] **Step 2: Build to confirm** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +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: + +```csharp +public record MergeResultDto(string Status, IReadOnlyList ConflictFiles, string? ErrorMessage); +public record MergeTargetsDto(string DefaultBranch, IReadOnlyList LocalBranches); +``` + +- [ ] **Step 2: Inject `TaskMergeService` into the hub** + +Add a private field and constructor parameter. Update `WorkerHub`'s constructor: + +```csharp +private readonly TaskMergeService _mergeService; + +public WorkerHub( + QueueService queue, + AgentFileService agentService, + HubBroadcaster broadcaster, + IDbContextFactory 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: + +```csharp +public async Task 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 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: + +```csharp +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** + +```bash +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): + +```csharp +public record MergeResultDto(string Status, IReadOnlyList ConflictFiles, string? ErrorMessage); +public record MergeTargetsDto(string DefaultBranch, IReadOnlyList 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`: + +```csharp +public async Task MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) +{ + return await _hub.InvokeAsync( + "MergeTask", taskId, targetBranch, removeWorktree, commitMessage); +} + +public async Task GetMergeTargetsAsync(string taskId) +{ + try + { + return await _hub.InvokeAsync("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** + +```bash +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`: + +```csharp +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 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 _conflictFiles = Array.Empty(); + + 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** + +```bash +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`: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +