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

1956 lines
65 KiB
Markdown

# 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<GitRepoFixture> _repos = new();
private GitRepoFixture NewRepo()
{
var r = new GitRepoFixture();
_repos.Add(r);
return r;
}
public void Dispose()
{
foreach (var r in _repos) try { r.Dispose(); } catch { }
}
[Fact]
public async Task GetCurrentBranchAsync_FreshRepo_ReturnsDefaultBranch()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var git = new GitService();
var branch = await git.GetCurrentBranchAsync(repo.RepoDir);
Assert.False(string.IsNullOrWhiteSpace(branch));
// Default branch is either "main" or "master" depending on git config.
Assert.True(branch == "main" || branch == "master",
$"Expected main or master, got '{branch}'");
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~GitServiceMergeTests.GetCurrentBranchAsync_FreshRepo_ReturnsDefaultBranch"`
Expected: FAIL — `GitService` does not contain a method `GetCurrentBranchAsync`.
- [ ] **Step 3: Add the method to `GitService`**
In `src/ClaudeDo.Data/Git/GitService.cs`, insert before `RunGitAsync`:
```csharp
public async Task<string> GetCurrentBranchAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
["symbolic-ref", "--short", "HEAD"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git symbolic-ref --short HEAD failed (exit {exitCode}): {stderr}");
return stdout.Trim();
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run the same command as Step 2. Expected: PASS.
- [ ] **Step 5: Commit**
```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<List<string>> ListLocalBranchesAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
["branch", "--format=%(refname:short)"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git branch --format failed (exit {exitCode}): {stderr}");
return stdout
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(s => s.Length > 0)
.ToList();
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run the filter from Step 2. Expected: PASS.
- [ ] **Step 5: Commit**
```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<bool> IsMidMergeAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["rev-parse", "--git-dir"], ct);
if (exitCode != 0) return false;
var gitDir = stdout.Trim();
if (!Path.IsPathRooted(gitDir))
gitDir = Path.Combine(repoDir, gitDir);
return File.Exists(Path.Combine(gitDir, "MERGE_HEAD"));
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run the filter from Step 2. Expected: PASS (both cases).
- [ ] **Step 5: Commit**
```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<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
["diff", "--name-only", "--diff-filter=U"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git diff --diff-filter=U failed (exit {exitCode}): {stderr}");
return stdout
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(s => s.Length > 0)
.ToList();
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run the filter from Step 2. Expected: PASS.
- [ ] **Step 5: Commit**
```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<string> ConflictFiles,
string? ErrorMessage);
public sealed record MergeTargets(
string DefaultBranch,
IReadOnlyList<string> LocalBranches);
public sealed class TaskMergeService
{
public const string StatusMerged = "merged";
public const string StatusConflict = "conflict";
public const string StatusBlocked = "blocked";
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly GitService _git;
private readonly HubBroadcaster _broadcaster;
private readonly ILogger<TaskMergeService> _logger;
public TaskMergeService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
GitService git,
HubBroadcaster broadcaster,
ILogger<TaskMergeService> logger)
{
_dbFactory = dbFactory;
_git = git;
_broadcaster = broadcaster;
_logger = logger;
}
public async Task<MergeResult> MergeAsync(
string taskId,
string targetBranch,
bool removeWorktree,
string commitMessage,
CancellationToken ct)
{
TaskEntity task;
ListEntity list;
WorktreeEntity? wt;
using (var ctx = _dbFactory.CreateDbContext())
{
task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
}
if (task.Status == TaskStatus.Running)
return Blocked("task is running");
if (wt is null)
return Blocked("task has no worktree");
if (wt.State != WorktreeState.Active)
return Blocked($"worktree state is {wt.State}");
if (string.IsNullOrWhiteSpace(list.WorkingDir))
return Blocked("list has no working directory");
if (!await _git.IsGitRepoAsync(list.WorkingDir, ct))
return Blocked("working directory is not a git repository");
if (await _git.IsMidMergeAsync(list.WorkingDir, ct))
return Blocked("target working directory is mid-merge");
if (await _git.HasChangesAsync(list.WorkingDir, ct))
return Blocked("target working tree has uncommitted changes");
// Body added in later tasks.
throw new NotImplementedException();
}
public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct)
{
TaskEntity task;
ListEntity list;
using (var ctx = _dbFactory.CreateDbContext())
{
task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
}
if (string.IsNullOrWhiteSpace(list.WorkingDir))
return new MergeTargets("", Array.Empty<string>());
var current = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
return new MergeTargets(current, branches);
}
private static MergeResult Blocked(string reason) =>
new(StatusBlocked, Array.Empty<string>(), reason);
}
```
- [ ] **Step 2: Write the failing pre-flight tests**
Create `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`:
```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<DbFixture> _dbs = new();
private readonly List<GitRepoFixture> _repos = new();
private readonly List<(string repoDir, string wtPath)> _wtCleanups = new();
private DbFixture NewDb() { var d = new DbFixture(); _dbs.Add(d); return d; }
private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }
public void Dispose()
{
foreach (var (repoDir, wtPath) in _wtCleanups)
{
try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { }
}
foreach (var d in _dbs) try { d.Dispose(); } catch { }
foreach (var r in _repos) try { r.Dispose(); } catch { }
}
private static (TaskMergeService svc, MergeRecordingClientProxy proxy) BuildService(DbFixture db)
{
var fakeHub = new MergeRecordingHubContext();
var broadcaster = new HubBroadcaster(fakeHub);
var svc = new TaskMergeService(
db.CreateFactory(),
new GitService(),
broadcaster,
NullLogger<TaskMergeService>.Instance);
return (svc, fakeHub.Proxy);
}
private static WorktreeManager BuildWorktreeManager(DbFixture db)
{
return new WorktreeManager(
new GitService(),
db.CreateFactory(),
new ClaudeDo.Worker.Config.WorkerConfig { WorktreeRootStrategy = "sibling" },
NullLogger<WorktreeManager>.Instance);
}
private static async Task<(ListEntity list, TaskEntity task)> SeedListAndTask(
DbFixture db, string workingDir, TaskStatus status)
{
var list = new ListEntity
{
Id = Guid.NewGuid().ToString(),
Name = "merge-test",
WorkingDir = workingDir,
DefaultCommitType = "feat",
CreatedAt = DateTime.UtcNow,
};
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = list.Id,
Title = "merge-task",
Status = status,
CreatedAt = DateTime.UtcNow,
};
using var ctx = db.CreateContext();
await new ListRepository(ctx).AddAsync(list);
await new TaskRepository(ctx).AddAsync(task);
return (list, task);
}
[Fact]
public async Task MergeAsync_RunningTask_ReturnsBlocked()
{
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Running);
var (svc, proxy) = BuildService(db);
var result = await svc.MergeAsync(task.Id, "main", false, "msg", CancellationToken.None);
Assert.Equal("blocked", result.Status);
Assert.Contains("running", result.ErrorMessage ?? "");
Assert.Empty(proxy.Calls);
}
[Fact]
public async Task MergeAsync_NoWorktree_ReturnsBlocked()
{
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done);
var (svc, _) = BuildService(db);
var result = await svc.MergeAsync(task.Id, "main", false, "msg", CancellationToken.None);
Assert.Equal("blocked", result.Status);
Assert.Contains("no worktree", result.ErrorMessage ?? "");
}
}
#region Test doubles
internal sealed record MergeHubCall(string Method, object?[] Args);
internal sealed class MergeRecordingClientProxy : IClientProxy
{
public readonly List<MergeHubCall> Calls = new();
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
Calls.Add(new MergeHubCall(method, args));
return Task.CompletedTask;
}
}
internal sealed class MergeRecordingHubClients : IHubClients
{
public MergeRecordingClientProxy AllProxy { get; } = new();
public IClientProxy All => AllProxy;
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => AllProxy;
public IClientProxy Client(string connectionId) => AllProxy;
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => AllProxy;
public IClientProxy Group(string groupName) => AllProxy;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => AllProxy;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => AllProxy;
public IClientProxy User(string userId) => AllProxy;
public IClientProxy Users(IReadOnlyList<string> userIds) => AllProxy;
}
internal sealed class MergeRecordingHubContext : IHubContext<ClaudeDo.Worker.Hub.WorkerHub>
{
private readonly MergeRecordingHubClients _clients = new();
public MergeRecordingClientProxy Proxy => _clients.AllProxy;
public IHubClients Clients => _clients;
public IGroupManager Groups => throw new NotImplementedException();
}
#endregion
```
- [ ] **Step 3: Run the tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~TaskMergeServiceTests"`
Expected: PASS (both pre-flight tests).
- [ ] **Step 4: Commit**
```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<string> files;
try { files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); }
catch { files = new(); }
try { await _git.MergeAbortAsync(list.WorkingDir, ct); }
catch (Exception ex) { _logger.LogWarning(ex, "git merge --abort failed after conflict"); }
if (files.Count == 0)
{
// Non-conflict failure (e.g. unrelated histories).
return new MergeResult(StatusBlocked, Array.Empty<string>(), $"merge failed: {stderr}");
}
return new MergeResult(StatusConflict, files, null);
}
string? cleanupWarning = null;
if (removeWorktree)
{
try
{
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: false, ct);
try { await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: false, ct); }
catch (Exception ex)
{
_logger.LogWarning(ex, "branch delete failed for {Branch}", wt.BranchName);
cleanupWarning = $"worktree removed, branch delete failed: {ex.Message}";
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "worktree remove failed for {Path}", wt.Path);
cleanupWarning = $"worktree remove failed: {ex.Message}";
}
}
using (var ctx = _dbFactory.CreateDbContext())
{
await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Merged, ct);
}
await _broadcaster.WorktreeUpdated(taskId);
_logger.LogInformation(
"Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
taskId, wt.BranchName, targetBranch, removeWorktree);
return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
```
- [ ] **Step 4: Run the test to verify it passes**
Run the same filter as Step 2. Expected: PASS.
- [ ] **Step 5: Commit**
```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<TaskResetService>();` and insert immediately after:
```csharp
builder.Services.AddSingleton<TaskMergeService>();
```
- [ ] **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<string> ConflictFiles, string? ErrorMessage);
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
```
- [ ] **Step 2: Inject `TaskMergeService` into the hub**
Add a private field and constructor parameter. Update `WorkerHub`'s constructor:
```csharp
private readonly TaskMergeService _mergeService;
public WorkerHub(
QueueService queue,
AgentFileService agentService,
HubBroadcaster broadcaster,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService wtMaintenance,
TaskResetService resetService,
TaskMergeService mergeService)
{
_queue = queue;
_agentService = agentService;
_broadcaster = broadcaster;
_dbFactory = dbFactory;
_wtMaintenance = wtMaintenance;
_resetService = resetService;
_mergeService = mergeService;
}
```
- [ ] **Step 3: Add the hub methods**
Add at the end of the class, before the closing brace:
```csharp
public async Task<MergeResultDto> MergeTask(
string taskId, string targetBranch, bool removeWorktree, string commitMessage)
{
try
{
var r = await _mergeService.MergeAsync(
taskId,
targetBranch ?? "",
removeWorktree,
string.IsNullOrWhiteSpace(commitMessage) ? "Merge task" : commitMessage,
CancellationToken.None);
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
}
catch (KeyNotFoundException)
{
throw new HubException("task not found");
}
catch (InvalidOperationException ex)
{
throw new HubException(ex.Message);
}
}
public async Task<MergeTargetsDto> GetMergeTargets(string taskId)
{
try
{
var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None);
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches);
}
catch (KeyNotFoundException)
{
throw new HubException("task not found");
}
catch (InvalidOperationException ex)
{
throw new HubException(ex.Message);
}
}
```
- [ ] **Step 4: Add `using` for the service namespace if not present**
Ensure the top of `WorkerHub.cs` contains:
```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<string> ConflictFiles, string? ErrorMessage);
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
```
If a similar set of DTOs (e.g. `AppSettingsDto`) already lives in this file, add these next to them. If DTOs live only server-side so far and the client redefines them, follow that pattern here.
- [ ] **Step 2: Add methods to `WorkerClient`**
Next to `ResetTaskAsync`:
```csharp
public async Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage)
{
return await _hub.InvokeAsync<MergeResultDto>(
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage);
}
public async Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
{
try
{
return await _hub.InvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
}
catch
{
return null;
}
}
```
- [ ] **Step 3: Build to confirm**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds.
- [ ] **Step 4: Commit**
```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<string> Branches { get; } = new();
[ObservableProperty] private string? _selectedBranch;
[ObservableProperty] private bool _removeWorktree = true;
[ObservableProperty] private string _commitMessage = "";
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string? _errorMessage;
[ObservableProperty] private string? _warningMessage;
[ObservableProperty] private string? _successMessage;
[ObservableProperty] private bool _hasConflict;
[ObservableProperty] private IReadOnlyList<string> _conflictFiles = Array.Empty<string>();
public Action? CloseAction { get; set; }
public MergeModalViewModel(WorkerClient worker)
{
_worker = worker;
}
public async Task InitializeAsync(string taskId, string taskTitle)
{
TaskId = taskId;
TaskTitle = taskTitle;
CommitMessage = $"Merge task: {taskTitle}";
IsBusy = true;
try
{
var targets = await _worker.GetMergeTargetsAsync(taskId);
Branches.Clear();
if (targets is null)
{
ErrorMessage = "Worker offline — cannot list branches.";
return;
}
foreach (var b in targets.LocalBranches) Branches.Add(b);
SelectedBranch = Branches.Contains(targets.DefaultBranch)
? targets.DefaultBranch
: Branches.FirstOrDefault();
}
catch (Exception ex)
{
ErrorMessage = $"Failed to load branches: {ex.Message}";
}
finally { IsBusy = false; }
}
private bool CanSubmit() =>
!IsBusy && !HasConflict && !string.IsNullOrWhiteSpace(SelectedBranch);
[RelayCommand(CanExecute = nameof(CanSubmit))]
private async Task SubmitAsync()
{
if (string.IsNullOrWhiteSpace(SelectedBranch)) return;
IsBusy = true;
ErrorMessage = null;
WarningMessage = null;
SuccessMessage = null;
try
{
var result = await _worker.MergeTaskAsync(
TaskId, SelectedBranch!, RemoveWorktree, CommitMessage);
switch (result.Status)
{
case "merged":
SuccessMessage = result.ErrorMessage is not null
? $"Merged with warning: {result.ErrorMessage}"
: "Merged.";
// Auto-close after a short delay.
_ = Task.Run(async () =>
{
await Task.Delay(1200);
Avalonia.Threading.Dispatcher.UIThread.Post(() => CloseAction?.Invoke());
});
break;
case "conflict":
HasConflict = true;
ConflictFiles = result.ConflictFiles;
ErrorMessage = "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.";
break;
case "blocked":
ErrorMessage = $"Blocked: {result.ErrorMessage}";
break;
default:
ErrorMessage = $"Unknown status: {result.Status}";
break;
}
}
catch (Exception ex)
{
ErrorMessage = $"Merge failed: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private void Cancel() => CloseAction?.Invoke();
}
```
- [ ] **Step 2: Build to confirm**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds.
- [ ] **Step 3: Commit**
```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
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:ClaudeDo.Ui.ViewModels.Modals"
x:Class="ClaudeDo.Ui.Views.Modals.MergeModalView"
x:DataType="vm:MergeModalViewModel"
Title="Merge worktree"
Width="560" Height="420"
CanResize="False"
WindowStartupLocation="CenterOwner">
<Grid Margin="20" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*,Auto">
<TextBlock Grid.Row="0"
Text="{Binding TaskTitle, StringFormat='Merging: {0}'}"
FontWeight="SemiBold" Margin="0,0,0,12" />
<StackPanel Grid.Row="1" Orientation="Vertical" Margin="0,0,0,8">
<TextBlock Text="Target branch" Margin="0,0,0,4" />
<ComboBox ItemsSource="{Binding Branches}"
SelectedItem="{Binding SelectedBranch}"
HorizontalAlignment="Stretch"
IsEnabled="{Binding !IsBusy}" />
</StackPanel>
<CheckBox Grid.Row="2"
Content="Remove worktree after merge"
IsChecked="{Binding RemoveWorktree}"
IsEnabled="{Binding !IsBusy}"
Margin="0,0,0,8" />
<StackPanel Grid.Row="3" Orientation="Vertical" Margin="0,0,0,8">
<TextBlock Text="Commit message" Margin="0,0,0,4" />
<TextBox Text="{Binding CommitMessage}"
AcceptsReturn="True"
TextWrapping="Wrap"
Height="70"
IsEnabled="{Binding !IsBusy}" />
</StackPanel>
<TextBlock Grid.Row="4"
Text="{Binding ErrorMessage}"
Foreground="IndianRed"
TextWrapping="Wrap"
IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}"
Margin="0,0,0,8" />
<Border Grid.Row="5"
BorderBrush="IndianRed"
BorderThickness="1"
Padding="8"
IsVisible="{Binding HasConflict}">
<StackPanel>
<TextBlock Text="Conflicted files:" FontWeight="SemiBold" Margin="0,0,0,4" />
<ItemsControl ItemsSource="{Binding ConflictFiles}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<StackPanel Grid.Row="6" Orientation="Horizontal"
HorizontalAlignment="Right" Margin="0,12,0,0">
<TextBlock Text="{Binding SuccessMessage}"
Foreground="SeaGreen"
VerticalAlignment="Center"
Margin="0,0,12,0"
IsVisible="{Binding SuccessMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Content="Cancel"
Command="{Binding CancelCommand}"
Margin="0,0,8,0" />
<Button Content="Merge"
Command="{Binding SubmitCommand}"
IsDefault="True"
Classes="accent" />
</StackPanel>
</Grid>
</Window>
```
- [ ] **Step 2: Create the code-behind**
Create `src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml.cs`:
```csharp
using Avalonia.Controls;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class MergeModalView : Window
{
public MergeModalView()
{
InitializeComponent();
DataContextChanged += (_, _) =>
{
if (DataContext is MergeModalViewModel vm)
{
vm.CloseAction = () => Close();
}
};
}
}
```
- [ ] **Step 3: Build to confirm**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml.cs
git commit -m "feat(ui): add MergeModalView"
```
---
## Task 18: Register `MergeModalViewModel` in UI DI
**Files:**
- Modify: `src/ClaudeDo.App/Program.cs`
- [ ] **Step 1: Edit Program.cs**
Find the line `sc.AddTransient<SettingsModalViewModel>();` in `src/ClaudeDo.App/Program.cs` and insert immediately after:
```csharp
sc.AddTransient<MergeModalViewModel>();
```
Ensure the file has `using ClaudeDo.Ui.ViewModels.Modals;` at the top (it likely already does).
- [ ] **Step 2: Build to confirm**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.App/Program.cs
git commit -m "chore(app): register MergeModalViewModel"
```
---
## Task 19: Wire `DetailsIslandViewModel.ApproveMergeAsync` + `CanMerge`
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
- [ ] **Step 1: Add factory property near the other `ShowXxxModal` properties**
Around the line defining `ShowDiffModal` (approx line 104 per earlier exploration), add:
```csharp
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
```
Ensure `using ClaudeDo.Ui.ViewModels.Modals;` is present at the top.
- [ ] **Step 2: Replace the stub `ApproveMergeAsync` body**
Find:
```csharp
[RelayCommand]
private async System.Threading.Tasks.Task ApproveMergeAsync()
{
if (Task == null) return;
// TODO: call worker merge hub method when available
await System.Threading.Tasks.Task.CompletedTask;
}
```
Replace `[RelayCommand]` with `[RelayCommand(CanExecute = nameof(CanMerge))]` and replace the body:
```csharp
[RelayCommand(CanExecute = nameof(CanMerge))]
private async System.Threading.Tasks.Task ApproveMergeAsync()
{
if (Task == null || ShowMergeModal == null) return;
var vm = _services.GetRequiredService<MergeModalViewModel>();
await vm.InitializeAsync(Task.Id, Task.Title);
await ShowMergeModal(vm);
}
private bool CanMerge() =>
Task != null
&& _worker.IsConnected
&& Task.Worktree is { State: Data.Models.WorktreeState.Active };
```
- [ ] **Step 3: Hook the observable change so `CanMerge` re-evaluates**
`DetailsIslandViewModel` likely already re-notifies related commands when `Task` or `_worker.IsConnected` changes (e.g. for `CanReset`). Find the partial method(s) that notify `ResetCommand`/`ContinueCommand` of CanExecute changes and add `ApproveMergeCommand.NotifyCanExecuteChanged();` there.
If no such partial method exists for `Task`, add one:
```csharp
partial void OnTaskChanged(TaskItemViewModel? value)
{
ApproveMergeCommand.NotifyCanExecuteChanged();
}
```
(Name of the task property may be different — use whatever `[ObservableProperty] private X _task;` generates. If a similar `partial void` already exists, add the line inside it instead of creating a duplicate.)
- [ ] **Step 4: Build to confirm**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
git commit -m "feat(ui): wire DetailsIsland ApproveMerge through MergeModal"
```
---
## Task 20: Hook `ShowMergeModal` in `DetailsIslandView`
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs`
- [ ] **Step 1: Hook the factory in `OnDataContextChanged`**
Find the block that sets `vm.ShowDiffModal` and `vm.ShowWorktreeModal` (around line 23-38 per earlier exploration). Below them, inside the same `if (DataContext is DetailsIslandViewModel vm)` block, add:
```csharp
vm.ShowMergeModal = async (mergeVm) =>
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return;
var modal = new MergeModalView { DataContext = mergeVm };
await modal.ShowDialog(owner);
};
```
- [ ] **Step 2: Build to confirm**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs
git commit -m "feat(ui): attach MergeModal to DetailsIsland"
```
---
## Task 21: Add Merge command to `DiffModalViewModel`
The DiffModal already knows `WorktreePath` but not the task id. We need to pass the task id in.
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` (pass TaskId when creating diff VM)
- [ ] **Step 1: Add `TaskId`, `TaskTitle`, and `ShowMergeModal` hooks to `DiffModalViewModel`**
In `src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs`, near the top of the `DiffModalViewModel` class alongside `WorktreePath` and `BaseRef`:
```csharp
public string? TaskId { get; init; }
public string TaskTitle { get; init; } = "";
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
```
Add `using ClaudeDo.Ui.ViewModels.Modals;` if needed — the type is in the same namespace, so no import is required.
- [ ] **Step 2: Add the `MergeAsync` relay command**
Inside `DiffModalViewModel`, alongside the existing `Close` command:
```csharp
private bool CanMerge() =>
!string.IsNullOrEmpty(TaskId)
&& ShowMergeModal is not null
&& ResolveMergeVm is not null;
[RelayCommand(CanExecute = nameof(CanMerge))]
private async Task MergeAsync()
{
if (TaskId is null || ShowMergeModal is null || ResolveMergeVm is null) return;
var vm = ResolveMergeVm();
await vm.InitializeAsync(TaskId, TaskTitle);
await ShowMergeModal(vm);
}
```
- [ ] **Step 3: Populate the new fields from the DetailsIsland when opening the DiffModal**
In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` around line 303-310 (the block that constructs `DiffModalViewModel`), update the object initializer:
```csharp
var diffVm = new DiffModalViewModel(_services.GetRequiredService<ClaudeDo.Data.Git.GitService>())
{
WorktreePath = WorktreePath!,
BaseRef = BaseRef, // if already there, leave
TaskId = Task?.Id,
TaskTitle = Task?.Title ?? "",
ShowMergeModal = ShowMergeModal,
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
};
```
Keep any properties already in the existing initializer. Adjust the names if the existing code uses different member names — the principle is: pass `Task.Id`, `Task.Title`, and the same `ShowMergeModal` factory that the DetailsIsland already has.
- [ ] **Step 4: Build to confirm**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
git commit -m "feat(ui): add Merge command to DiffModal"
```
---
## Task 22: Add Merge button to `DiffModalView`
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Modals/DiffModalView.axaml`
- [ ] **Step 1: Find the existing button bar**
In `src/ClaudeDo.Ui/Views/Modals/DiffModalView.axaml`, locate the existing Close button (likely inside a `StackPanel` with `Orientation="Horizontal"` near the bottom, bound to `CloseCommand`).
- [ ] **Step 2: Insert a Merge button before the Close button**
Add immediately before the Close button, inside the same parent panel. Avalonia's `Button.Command` binding auto-disables the button when `CanExecute` returns false, so no explicit `IsEnabled` is needed:
```xml
<Button Content="Merge…"
Command="{Binding MergeCommand}"
Margin="0,0,8,0" />
```
- [ ] **Step 3: Build to confirm**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Modals/DiffModalView.axaml
git commit -m "feat(ui): add Merge button to DiffModal"
```
---
## Task 23: Full-suite run + manual UI verification
**Files:** none modified; this is a verification step.
- [ ] **Step 1: Run the full test suite**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj`
Expected: all tests PASS, including existing ones (no regressions).
- [ ] **Step 2: Build the whole solution**
Run each (per the `.slnx` caveat in memory):
```bash
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
```
Expected: all four succeed.
- [ ] **Step 3: Manual UI checklist**
Launch the Worker and then the App. Create a list pointing at a scratch git repo, add a task, let it run and produce a worktree commit, then:
1. From the Details island, click **Merge** (formerly the stub).
2. Modal opens, dropdown shows local branches, default matches the repo's current branch.
3. Edit the commit message, leave "Remove worktree after merge" checked, click Merge.
4. Modal shows "Merged." and closes after ~1s. Details island refreshes; worktree section hides or shows Merged.
5. Open a new task in the same list, advance `main` with a conflicting commit, then click Merge.
6. Modal shows red panel listing the conflicted file. Main branch is not mid-merge (`git status` clean).
7. Click Cancel on a fresh merge dialog → no change.
8. Open DiffModal for a task with active worktree → Merge button visible → clicking opens the same modal → works as above.
If any step fails, capture the failure and debug before committing.
- [ ] **Step 4: Commit the plan-complete marker**
No code change required. Report back: "All tasks passed, manual checklist complete."
---
## Notes for implementers
- **Never skip pre-flight** — the contract is that `git merge --no-ff` is only invoked after the 5 pre-flight checks pass.
- **Worktree remove uses `force: false`** intentionally — after a clean merge the worktree has no uncommitted work. If it unexpectedly fails, the service surfaces a cleanup warning rather than masking the problem.
- **DB update happens after successful merge, not after cleanup.** If cleanup fails the merge has still succeeded and the worktree row must show `Merged`.
- **`WorktreeUpdated` broadcast fires only on the success path.** Conflicts and blocked states change no persistent state, so no broadcast.
- **Tests must skip when git is unavailable** via `if (!GitRepoFixture.IsGitAvailable()) return;` — same convention as `TaskResetServiceTests`.