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.
1956 lines
65 KiB
Markdown
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`.
|