# 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