32 Commits

Author SHA1 Message Date
Mika Kuns
b378fbf550 chore(settings): allow context-mode MCP tools locally 2026-04-22 11:03:43 +02:00
Mika Kuns
cb43bcdd10 docs(plans): add 2026-04-21 open-items consolidation 2026-04-22 11:03:40 +02:00
Mika Kuns
31420574db feat(ui): show status messages and real diff-stats in DiffModal
- Count additions/deletions per file as lines are parsed.
- Surface load failures and empty-diff states via StatusMessage.
- Pass the worktree base commit so diffs render against the branch
  base, not just the working-tree HEAD.
2026-04-22 11:03:37 +02:00
Mika Kuns
07dee31847 fix(data): use UTF-8 encoding for git process stdio
Ensures non-ASCII git output (branch names, paths, commit messages) is
read and written without locale-dependent corruption.
2026-04-22 11:03:24 +02:00
Mika Kuns
4debd5ce09 fix(ui): disable Merge button after worktree is no longer Active
Add WorktreeStateLabel observable property populated from
entity.Worktree?.State.ToString() in both BindAsync and
RefreshWorktreeAsync. CanMerge now requires WorktreeStateLabel == "Active"
so the button disables after a successful merge with removeWorktree:false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 10:36:09 +02:00
Mika Kuns
1495c63e3d fix(worker): return Blocked when MergeAbortAsync fails to avoid stuck repo
If git merge --abort throws, the repo is left mid-merge. Previously the
code logged a warning and returned a conflict result, giving the UI a
stale file list. Now it returns Blocked with an explicit message so the
caller knows manual resolution is required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 10:30:44 +02:00
Mika Kuns
953d93179d fix(worker): honour targetBranch in MergeAsync by checking out before merge
Add GitService.CheckoutBranchAsync; compare targetBranch to current HEAD
before MergeNoFfAsync and switch when they differ. Returns Blocked if the
branch does not exist. Add three new tests (two service, one GitService).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 10:25:35 +02:00
Mika Kuns
1bc7fcc609 feat(ui): add Merge button to DiffModal 2026-04-22 09:53:11 +02:00
Mika Kuns
c911717a3b feat(ui): add Merge command to DiffModal 2026-04-22 09:53:07 +02:00
Mika Kuns
949911f6c8 feat(ui): attach MergeModal to DetailsIsland 2026-04-22 09:53:03 +02:00
Mika Kuns
f3a58a6515 feat(ui): wire DetailsIsland ApproveMerge through MergeModal 2026-04-22 09:52:59 +02:00
Mika Kuns
ee4cd706ef chore(app): register MergeModalViewModel 2026-04-22 09:47:19 +02:00
Mika Kuns
e11b01951e feat(ui): add MergeModalView 2026-04-22 09:46:33 +02:00
Mika Kuns
3d0cc4ffed feat(ui): add MergeModalViewModel 2026-04-22 09:46:20 +02:00
Mika Kuns
4585b20f80 feat(ui): add MergeTaskAsync and GetMergeTargetsAsync to WorkerClient 2026-04-22 09:44:48 +02:00
Mika Kuns
c53b5878cf feat(worker): expose MergeTask and GetMergeTargets on WorkerHub 2026-04-22 09:44:22 +02:00
Mika Kuns
c13ae437f7 chore(worker): register TaskMergeService 2026-04-22 09:43:34 +02:00
Mika Kuns
5780879629 test(worker): cover GetTargetsAsync and dirty-tree block 2026-04-22 09:41:31 +02:00
Mika Kuns
2bcd5ef9bd test(worker): cover merge conflict auto-abort 2026-04-22 09:41:31 +02:00
Mika Kuns
63eb860e40 test(worker): cover diverged non-conflicting merge 2026-04-22 09:41:30 +02:00
Mika Kuns
e80ac7de49 test(worker): cover TaskMergeService removeWorktree path 2026-04-22 09:41:30 +02:00
Mika Kuns
3331c24898 feat(worker): implement TaskMergeService happy path 2026-04-22 09:37:35 +02:00
Mika Kuns
1c20d8f846 feat(worker): scaffold TaskMergeService with pre-flight checks 2026-04-22 09:36:16 +02:00
Mika Kuns
77a1460e3a feat(git): add ListConflictedFilesAsync 2026-04-22 09:31:36 +02:00
Mika Kuns
21a1870fd7 feat(git): add MergeAbortAsync 2026-04-22 09:29:24 +02:00
Mika Kuns
3ebbdb3f6e feat(git): add MergeNoFfAsync returning (exitCode, stderr) 2026-04-22 09:27:47 +02:00
Mika Kuns
535d0c5558 feat(git): add IsMidMergeAsync 2026-04-22 09:25:10 +02:00
Mika Kuns
2d807aa606 feat(git): add ListLocalBranchesAsync 2026-04-22 09:23:35 +02:00
Mika Kuns
93ee7b72d5 feat(git): add GetCurrentBranchAsync 2026-04-22 09:22:41 +02:00
Mika Kuns
32ef1b389a docs: clarify merged-with-cleanup-warning result shape 2026-04-22 09:17:43 +02:00
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
Mika Kuns
944d3bd3e8 docs: add worktree merge design spec
Captures decisions for merging a task worktree into a target branch:
merge-commit strategy, dual UI entry (Details island + DiffModal),
per-merge cleanup checkbox, pre-flight + abort-on-conflict.
2026-04-22 09:08:44 +02:00
19 changed files with 3555 additions and 23 deletions

View File

@@ -2,7 +2,9 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(git add:*)", "Bash(git add:*)",
"Bash(git commit:*)" "Bash(git commit:*)",
"mcp__plugin_context-mode_context-mode__batch_execute",
"mcp__plugin_context-mode_context-mode__execute"
] ]
} }
} }

View File

@@ -0,0 +1,189 @@
# Open Items Consolidation — 2026-04-21
Consolidates everything still open from `docs/open.md`, `docs/improvement-plan.md`, and the 2026-04-16 subtask-tree spec. Today's plans/specs (stream-formatter, settings-modal, continue-and-reset) are explicitly out of scope — those are tracked separately.
Grouped by priority and sorted by UX impact vs. effort. Each item lists **Soll**, **Dateien**, **Aufwand**, **Risiko**.
---
## P1 — UX blockers and robustness
### 1. Auto-Reconnect (ex IP-1)
**Soll:** `HubConnectionBuilder.WithAutomaticReconnect(...)` + event handlers for `Reconnecting`/`Reconnected`/`Closed`. Exponential backoff.
**Dateien:** `src/ClaudeDo.Ui/Services/WorkerClient.cs`
**Aufwand:** klein (~30 Zeilen)
**Risiko:** klein
### 2. Reconnect-State in StatusBar (ex IP-7)
**Soll:** States `connected | connecting | reconnecting | offline`, farb-codiert. Depends on #1.
**Dateien:** `src/ClaudeDo.Ui/ViewModels/StatusBarViewModel.cs`, StatusBar view
**Aufwand:** klein
**Risiko:** klein
### 3. Folder-Picker für Working Directory (ex open.md 2.1)
**Soll:** Button neben Pfad-TextBox → `IStorageProvider.OpenFolderPickerAsync`.
**Dateien:** `src/ClaudeDo.Ui/Views/ListEditorView.axaml(.cs)`, `ViewModels/ListEditorViewModel.cs`
**Aufwand:** klein (~30 Zeilen)
**Risiko:** klein
### 4. Markdown-Rendering für Result/Description (ex open.md 2.3)
**Soll:** `Markdown.Avalonia` Paket einbinden, `MarkdownScrollViewer` statt readonly `TextBox` in Details-Island.
**Dateien:** `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`, `Views/Islands/DetailsIslandView.axaml`
**Aufwand:** mittel (Theme-Integration kann zicken)
**Risiko:** kleinmittel
### 5. Live-Log Auto-Scroll (ex open.md 2.4)
**Soll:** Sticky-Bottom-Pattern: `ScrollToEnd()` auf neue Zeilen, außer User hat manuell hochgescrollt.
**Dateien:** `src/ClaudeDo.Ui/Views/Islands/SessionTerminalView.axaml(.cs)`
**Aufwand:** klein
**Risiko:** klein
### 6. CLI-Preflight beim Worker-Start (ex open.md 3.1)
**Soll:** Startup-Check `claude --version` + Login-Status. Wenn fehlt → laut failen mit Hinweis, nicht still in Queue idlen.
**Dateien:** `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs`, Worker startup (Program.cs / HostedService)
**Aufwand:** klein
**Risiko:** klein
---
## P2 — Daten & Features
### 7. Notes-Mode (ex IP-2)
**Soll:** Neue Spalte `lists.kind` (`agent` | `notes`). Worker filtert auf `kind = 'agent'`. UI versteckt Run/Schedule/Worktree-Felder für `notes`.
**Dateien:**
- Migration + `TaskList` Entity + `IEntityTypeConfiguration`
- `src/ClaudeDo.Worker/Queue/QueueService.cs` (Filter)
- `ViewModels/ListEditorViewModel.cs`, `Views/Islands/*` (conditional visibility)
**Aufwand:** mittel
**Risiko:** mittel (Default für bestehende Listen = `agent`)
### 8. Subtask-Tree im TaskList-Island (ex 2026-04-16 spec)
**Soll:** Indented Subtasks unter Parent-Task, Expand/Collapse, Chevron-Spalte, Count-Indikator. Batch-Query `GetCountsByTaskIdsAsync`.
**Dateien:** `ViewModels/TaskItemViewModel.cs` (+ `Subtasks`, `IsExpanded`, `HasSubtasks`), `Views/Islands/TaskListView.axaml`, `Data/Repositories/SubtaskRepository.cs`
**Aufwand:** mittel
**Risiko:** kleinmittel (spec ist fertig)
### 9. Tag-Repository `GetAllKnownTagsAsync` (ex IP-8)
**Soll:** Distinct-Query über alle Tags. Voraussetzung für #10.
**Dateien:** neuer `Data/Repositories/TagRepository.cs` (oder in bestehendem Repo)
**Aufwand:** klein
**Risiko:** klein
### 10. Tag Multi-Select Control (ex IP-4)
**Soll:** AutoCompleteBox / Chips statt Freitext. Datenquelle aus #9.
**Dateien:** `Views/Islands/DetailsIslandView.axaml` (Tag-Sektion), ggf. neues `TagPickerControl`
**Aufwand:** kleinmittel
**Risiko:** klein
### 11. Worktree-Cleanup bei Anlegefehler (ex open.md 3.2)
**Soll:** Wenn `git worktree add` teilweise anlegt dann failed → best-effort `git worktree remove --force` + DB-Row nicht persistieren.
**Dateien:** `src/ClaudeDo.Data/Services/GitService.cs`, `src/ClaudeDo.Worker/Runner/TaskRunner.cs`
**Aufwand:** klein
**Risiko:** klein
### 12. Tag-Negation / Exclusion (ex open.md 3.4)
**Soll:** Queue respektiert `task_tag_exclusions` laut Plan. Aktuell nur `agent`-Include.
**Dateien:** `src/ClaudeDo.Worker/Queue/QueueService.cs`
**Aufwand:** klein
**Risiko:** klein
---
## P3 — Tests, CI, Docs
### 13. Gitea-Actions CI Pipeline (ex open.md 5.1)
**Soll:** `.gitea/workflows/ci.yml`: restore → build → test auf push/PR. Nur `release.yml` existiert bisher.
**Dateien:** neu — `.gitea/workflows/ci.yml`
**Aufwand:** klein
**Risiko:** klein
### 14. SignalR Roundtrip-Test (ex open.md 5.2)
**Soll:** `WebApplicationFactory` + `HubConnectionBuilder` testen `Ping`, `GetActive`, `RunNow`-Throw-Verhalten.
**Dateien:** neu — `tests/ClaudeDo.Worker.Tests/Hub/WorkerHubTests.cs`
**Aufwand:** mittel
**Risiko:** klein
### 15. Claude-CLI Smoke-Test (ex open.md 5.3)
**Soll:** `[Fact(Skip=...)]` Real-CLI-Test, aktiviert nur wenn `CLAUDE_AUTHENTICATED=1`.
**Dateien:** neu — `tests/ClaudeDo.Worker.Tests/Runner/ClaudeProcessSmokeTest.cs`
**Aufwand:** klein
**Risiko:** klein
### 16. README ausbauen (ex open.md 6.1)
**Soll:** Ist 107 Zeilen. Ergänzen: Screenshots, Quickstart (Worker + UI starten), Konfiguration, Troubleshooting.
**Dateien:** `README.md`
**Aufwand:** klein
**Risiko:** keiner
### 17. `docs/architecture.md` herausziehen (ex open.md 6.2)
**Soll:** Architektur-Sektion aus `plan.md` in eigenes Dokument.
**Dateien:** neu — `docs/architecture.md`
**Aufwand:** klein
**Risiko:** keiner
### 18. ADRs für Kern-Entscheidungen (ex open.md 6.3)
**Soll:** Kurze ADRs (1 Seite) für: SignalR vs. SQLite-Polling; Worktree pro Task; SignalR über Loopback ohne Auth; EF Core statt Dapper.
**Dateien:** neu — `docs/adr/0001-*.md``0004-*.md`
**Aufwand:** klein
**Risiko:** keiner
### 19. Strukturiertes Logging (ex open.md 3.3)
**Soll:** `Console.WriteLine` / manuelle Log-Zeilen durch `ILogger<T>` ersetzen. Log-Levels, Scope für TaskId.
**Dateien:** `src/ClaudeDo.Worker/**` (query-able via `grep Console.Write`)
**Aufwand:** mittel
**Risiko:** klein
---
## P4 — Service-Deployment (später)
### 20. Windows-Service Hosting in Code (ex open.md 4.1)
**Soll:** `.UseWindowsService()` + `Microsoft.Extensions.Hosting.WindowsServices` Paket.
**Aufwand:** klein
### 21. Absolute Pfad-Auflösung (ex open.md 4.2)
**Soll:** Config-Pfade immer absolut auflösen (Service läuft in `C:\Windows\System32`).
**Aufwand:** klein
### 22. Install-Skripte / Doku (ex open.md 4.3)
**Soll:** `sc create` / PowerShell-Skript, Doku in README.
**Aufwand:** klein
---
## Empfohlene Reihenfolge
**Block 1 — Sofortige UX-Wins (1 Session):**
1. → 2. (Auto-Reconnect + StatusBar) — zusammenhängend, klein
3. Folder-Picker
5. Log Auto-Scroll
**Block 2 — Content-Qualität (1 Session):**
4. Markdown-Rendering
6. CLI-Preflight
**Block 3 — Daten-Features (2 Sessions):**
7. Notes-Mode (mit Migration)
8. Subtask-Tree
9. → 10. Tag-Repo + Multi-Select
**Block 4 — Worker-Robustheit:**
11. Worktree-Cleanup
12. Tag-Exclusion
19. Strukturiertes Logging
**Block 5 — Tests + CI + Docs:**
13. CI Pipeline
14. → 15. Hub-Tests + Smoke-Test
16. → 17. → 18. README + architecture.md + ADRs
**Block 6 — Service-Deployment (wenn gewünscht):**
20. → 21. → 22.
---
## Nicht im Plan
- Alles aus heute (2026-04-21): Stream-Formatter-Rewrite, Settings-Modal, Continue-and-Reset — sind eigene Plans.
- UI-Rewrite-Islands, UI-Polish-Design-Parity, UX-Redesign, Worker-CLI-Modernization, EF-Core-Migration, Installer-Download-Mode, Logic-Bug-Fixes — bereits gemerged (siehe git log).
- IP-3 (Doppelklick) und IP-5 (Kontextmenü) — vermutlich im Zuge des UI-Rewrites erledigt; falls nicht, trivial nachzuziehen.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,208 @@
# Worktree merge into target branch — design
Date: 2026-04-22
Status: Approved (pending user review)
## Problem
`WorktreeState.Merged` exists but nothing sets it. `GitService.MergeFfOnlyAsync` exists but is unused. `DetailsIslandViewModel.ApproveMergeAsync` is a stub (`// TODO: call worker merge hub method when available`). Users have no way to merge a task's worktree back into a target branch; the only post-task options today are Discard (via Reset) or leave it Active.
## Goals
- Allow merging a task worktree's `claudedo/{id}` branch into a chosen local branch of the list's `WorkingDir`.
- Preserve merge history via a real merge commit.
- Never leave the target branch in a broken state.
- Reuse existing patterns: `TaskResetService`, maintenance sweeper, dialog factory.
## Non-goals
- Remote push after merge (user does this manually).
- Pull/fetch before merge.
- Rebasing the task branch onto a moved target (done via Continue prompt or manually).
- Merging across repos or handling submodules.
- Automated UI tests (project has none).
## Decisions
| Decision | Choice |
| --- | --- |
| Target branch | Default to current `HEAD` branch of `WorkingDir`; user may override via dropdown. |
| Merge strategy | Always `git merge --no-ff -m <msg> claudedo/{id}` — explicit merge commit. |
| Post-merge cleanup | Per-merge checkbox in the dialog, default on: remove worktree dir + delete branch. |
| Conflicts | Pre-flight guard (worktree/branch state checks); on conflict during merge, `git merge --abort` and return conflicted files to UI. |
| UI entry points | Details island agent strip (wires existing stub) **and** DiffModal "Merge" button — both open the same modal. |
## Architecture
### New backend service
`src/ClaudeDo.Worker/Services/TaskMergeService.cs` — mirrors `TaskResetService`.
```
public sealed class TaskMergeService
{
Task<MergeResult> MergeAsync(
string taskId,
string targetBranch,
bool removeWorktree,
string commitMessage,
CancellationToken ct);
Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct);
}
public sealed record MergeResult(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
// Status ∈ "merged" | "conflict" | "blocked"
public sealed record MergeTargets(string DefaultBranch, IReadOnlyList<string> LocalBranches);
```
Pre-flight checks (all must pass):
1. Task exists, status not `Running`.
2. Worktree exists, state == `Active`.
3. `list.WorkingDir` is set and is a git repo.
4. Target working tree is clean (`HasChangesAsync == false`).
5. Target repo is not mid-merge (`IsMidMergeAsync == false`).
Failures short-circuit to `MergeResult("blocked", [], reason)` before any git write.
Success path:
1. `GitService.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct)`.
2. If `removeWorktree`:
- `WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: false, ct)` — reuse existing method.
- `BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: false, ct)`.
3. `WorktreeRepository.SetStateAsync(taskId, WorktreeState.Merged, ct)`.
4. `HubBroadcaster.BroadcastWorktreeUpdated(taskId)`.
5. Log info; return `MergeResult("merged", [], null)`.
Conflict path (merge invoked, git returns non-zero with `CONFLICT` on stderr/stdout):
1. Collect conflicted files: `git diff --name-only --diff-filter=U`.
2. `GitService.MergeAbortAsync(list.WorkingDir, ct)`.
3. Worktree state stays `Active`; no broadcast (nothing changed).
4. Return `MergeResult("conflict", files, null)`.
### GitService additions
```
Task<string> GetCurrentBranchAsync(string repoDir, CancellationToken ct); // git symbolic-ref --short HEAD
Task<List<string>> ListLocalBranchesAsync(string repoDir, CancellationToken ct); // git branch --format=%(refname:short)
Task MergeNoFfAsync(string repoDir, string sourceBranch, string message, CancellationToken ct); // git merge --no-ff -m <msg> <src>
Task MergeAbortAsync(string repoDir, CancellationToken ct); // git merge --abort
Task<bool> IsMidMergeAsync(string repoDir, CancellationToken ct); // File.Exists($"{gitDir}/MERGE_HEAD")
Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct); // git diff --name-only --diff-filter=U
```
`MergeNoFfAsync` must NOT throw on non-zero — it must return the exit code/stderr so the caller can distinguish conflict from other failures. Two ways:
- Overload to return `(int ExitCode, string Stderr)`; or
- Throw a dedicated `GitMergeConflictException` vs `InvalidOperationException`.
**Pick:** expose a tuple-returning variant for `MergeNoFfAsync` only — keeps other methods consistent, avoids exception-for-control-flow.
### Hub surface
`src/ClaudeDo.Worker/Hub/WorkerHub.cs` gains:
```
Task<MergeResultDto> MergeTask(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
Task<MergeTargetsDto> GetMergeTargets(string taskId);
```
DTOs mirror the service records. Unexpected exceptions are re-wrapped as `HubException` (same pattern as `ResetTask`). Expected conditions (blocked, conflict) travel via the result DTO, not exceptions.
### WorkerClient
`src/ClaudeDo.Ui/Services/WorkerClient.cs`:
```
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
Task<MergeTargetsDto> GetMergeTargetsAsync(string taskId);
```
### UI
**New modal:** `src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml` + `MergeModalViewModel.cs`.
Dialog fields:
- **Target branch** combobox (source: `GetMergeTargetsAsync.LocalBranches`; default: `DefaultBranch`).
- **Remove worktree after merge** checkbox (default: checked).
- **Commit message** text field (default: `Merge task: {Task.Title}`).
- OK / Cancel buttons.
Post-submit UI states (rendered inside the modal, not a second dialog):
- `merged` → brief success line, modal closes after 12s; parent refreshes.
- `conflict` → red inline panel listing files; OK button hidden, only Close remains.
- `blocked` → orange inline panel with the reason; OK button hidden, only Close remains.
**Wiring:**
- `DetailsIslandViewModel.ApproveMergeAsync` opens `MergeModalView` (factory injected through `MainWindowViewModel`'s existing dialog pattern).
- `DiffModalView` gains a Merge button in its command strip; click opens the same modal with the current task's id.
- Both entry points are only visible/enabled when `Task.Worktree?.State == Active` (same predicate as the existing Reset/Continue visibility logic — extend `ShowFailedActions`-style gating with a new flag `CanMerge`).
`MergeModalViewModel` depends only on `WorkerClient`. It does not touch `GitService` directly — all git access stays worker-side.
## Data flow
```
User clicks Merge (Details island or DiffModal)
→ DetailsIslandViewModel / DiffModalViewModel opens MergeModalView
→ MergeModalViewModel.InitializeAsync
→ WorkerClient.GetMergeTargetsAsync(taskId)
→ Hub.GetMergeTargets
→ TaskMergeService.GetTargetsAsync
→ GitService.GetCurrentBranchAsync + ListLocalBranchesAsync
→ Combobox populated, default selected
User edits fields, clicks OK
→ WorkerClient.MergeTaskAsync(taskId, branch, remove, msg)
→ Hub.MergeTask
→ TaskMergeService.MergeAsync
→ pre-flight checks
→ GitService.MergeNoFfAsync → (success | conflict)
→ on success: optional remove + branch delete, SetState(Merged), broadcast
→ on conflict: MergeAbortAsync, return conflict DTO
→ MergeModalViewModel renders result
```
## Error handling
| Case | Surfaced as |
| --- | --- |
| Task running | `MergeResult("blocked", [], "task is running")` |
| Worktree not Active | `("blocked", [], "worktree state is {state}")` |
| Working dir dirty | `("blocked", [], "target branch has uncommitted changes")` |
| Target mid-merge | `("blocked", [], "target branch is mid-merge")` |
| `list.WorkingDir` null | `("blocked", [], "list has no working directory")` |
| Merge conflict | `("conflict", [files], null)` — target auto-restored |
| Unknown git failure | `HubException` with stderr |
| Post-merge cleanup fails | Log a warning; merge already succeeded, state already `Merged`. Return `("merged", [], "cleanup: {reason}")``Status=="merged"` with a non-null `ErrorMessage` means the merge went through but the worktree couldn't be removed. UI surfaces this as a yellow note, not a failure. |
## Testing
`tests/ClaudeDo.Worker.Tests/TaskMergeServiceTests.cs` (real SQLite + real git, matching existing test conventions):
1. Happy path, ff-able history → one merge commit, state Merged, broadcast fired.
2. Happy path, diverged non-conflicting → merge commit created.
3. Conflict path → conflicted files returned, target branch HEAD matches pre-merge, `MERGE_HEAD` absent, worktree state still Active.
4. Pre-flight: worktree Merged/Discarded → blocked.
5. Pre-flight: dirty working tree → blocked.
6. Pre-flight: mid-merge target → blocked.
7. `removeWorktree=true` → worktree dir gone, branch deleted, state Merged.
8. `removeWorktree=false` → worktree + branch survive, state Merged.
9. Task Running → blocked.
`tests/ClaudeDo.Worker.Tests/GitServiceMergeTests.cs` (narrow tests for new GitService methods): `MergeNoFfAsync` success/conflict tuple semantics, `MergeAbortAsync` clears MERGE_HEAD, `IsMidMergeAsync` true/false, `ListLocalBranchesAsync` returns expected set, `GetCurrentBranchAsync` on fresh repo.
Manual UI checklist captured in the implementation plan, not automated.
## Implementation order (sketch)
1. GitService additions + their tests.
2. `TaskMergeService` + its tests (hub/UI not yet wired).
3. Hub methods + `WorkerClient` methods.
4. `MergeModalView` + `MergeModalViewModel`.
5. Wire `DetailsIslandViewModel.ApproveMergeAsync`.
6. Wire DiffModal Merge button.
7. Manual UI walkthrough against the checklist.
## Open items
None.

View File

@@ -78,6 +78,7 @@ sealed class Program
// ViewModels // ViewModels
sc.AddTransient<WorktreeModalViewModel>(); sc.AddTransient<WorktreeModalViewModel>();
sc.AddTransient<SettingsModalViewModel>(); sc.AddTransient<SettingsModalViewModel>();
sc.AddTransient<MergeModalViewModel>();
// Islands shell VMs // Islands shell VMs
sc.AddSingleton<ListsIslandViewModel>(sp => sc.AddSingleton<ListsIslandViewModel>(sp =>

View File

@@ -151,6 +151,73 @@ public sealed class GitService
throw new InvalidOperationException($"git branch {flag} failed (exit {exitCode}): {stderr}"); throw new InvalidOperationException($"git branch {flag} failed (exit {exitCode}): {stderr}");
} }
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();
}
public async Task CheckoutBranchAsync(string repoDir, string branchName, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["checkout", branchName], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git checkout '{branchName}' failed (exit {exitCode}): {stderr}");
}
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();
}
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"));
}
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);
}
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}");
}
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();
}
public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default) public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default)
{ {
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct); var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct);
@@ -169,6 +236,9 @@ public sealed class GitService
RedirectStandardInput = stdinData is not null, RedirectStandardInput = stdinData is not null,
UseShellExecute = false, UseShellExecute = false,
CreateNoWindow = true, CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
StandardInputEncoding = stdinData is not null ? Encoding.UTF8 : null,
}; };
psi.ArgumentList.Add("-C"); psi.ArgumentList.Add("-C");
psi.ArgumentList.Add(workDir); psi.ArgumentList.Add(workDir);

View File

@@ -179,6 +179,24 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
await _hub.InvokeAsync("ResetTask", taskId); await _hub.InvokeAsync("ResetTask", taskId);
} }
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;
}
}
public async Task CancelTaskAsync(string taskId) public async Task CancelTaskAsync(string taskId)
{ {
await _hub.InvokeAsync("CancelTask", taskId); await _hub.InvokeAsync("CancelTask", taskId);
@@ -298,3 +316,5 @@ public sealed record AppSettingsDto(
public sealed record WorktreeCleanupDto(int Removed); public sealed record WorktreeCleanupDto(int Removed);
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks); public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);

View File

@@ -58,6 +58,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
[ObservableProperty] private string? _model; [ObservableProperty] private string? _model;
[ObservableProperty] private string? _worktreePath; [ObservableProperty] private string? _worktreePath;
[ObservableProperty] private string? _worktreeBaseCommit; [ObservableProperty] private string? _worktreeBaseCommit;
[ObservableProperty] private string? _worktreeStateLabel;
[ObservableProperty] private string? _branchLine; [ObservableProperty] private string? _branchLine;
[ObservableProperty] private int _turns; [ObservableProperty] private int _turns;
[ObservableProperty] private int _tokens; [ObservableProperty] private int _tokens;
@@ -106,6 +107,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
// Set by the view so OpenWorktreeCommand can show the modal as a dialog // Set by the view so OpenWorktreeCommand can show the modal as a dialog
public Func<WorktreeModalViewModel, System.Threading.Tasks.Task>? ShowWorktreeModal { get; set; } public Func<WorktreeModalViewModel, System.Threading.Tasks.Task>? ShowWorktreeModal { get; set; }
// Set by the view so ApproveMergeCommand can show the modal as a dialog
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting // Set by the view so DeleteTaskCommand can prompt yes/no before deleting
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; } public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
@@ -126,6 +130,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
RunNowCommand.NotifyCanExecuteChanged(); RunNowCommand.NotifyCanExecuteChanged();
ContinueCommand.NotifyCanExecuteChanged(); ContinueCommand.NotifyCanExecuteChanged();
ResetCommand.NotifyCanExecuteChanged(); ResetCommand.NotifyCanExecuteChanged();
ApproveMergeCommand.NotifyCanExecuteChanged();
} }
}; };
@@ -227,6 +232,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
Notes = ""; Notes = "";
Model = null; Model = null;
WorktreePath = null; WorktreePath = null;
WorktreeStateLabel = null;
BranchLine = null; BranchLine = null;
AgentStatusLabel = "Idle"; AgentStatusLabel = "Idle";
LatestRunSessionId = null; LatestRunSessionId = null;
@@ -256,6 +262,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
Notes = entity.Notes ?? ""; Notes = entity.Notes ?? "";
Model = entity.Model; Model = entity.Model;
WorktreePath = entity.Worktree?.Path; WorktreePath = entity.Worktree?.Path;
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
WorktreeStateLabel = entity.Worktree?.State.ToString();
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null; BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
AgentStatusLabel = entity.Status.ToString(); AgentStatusLabel = entity.Status.ToString();
@@ -288,6 +296,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
WorktreePath = entity.Worktree?.Path; WorktreePath = entity.Worktree?.Path;
WorktreeBaseCommit = entity.Worktree?.BaseCommit; WorktreeBaseCommit = entity.Worktree?.BaseCommit;
WorktreeStateLabel = entity.Worktree?.State.ToString();
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null; BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
AgentStatusLabel = entity.Status.ToString(); AgentStatusLabel = entity.Status.ToString();
if (Task is { } row && entity.Worktree?.DiffStat is { } stat) if (Task is { } row && entity.Worktree?.DiffStat is { } stat)
@@ -304,6 +313,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
{ {
WorktreePath = WorktreePath, WorktreePath = WorktreePath,
BaseRef = WorktreeBaseCommit, BaseRef = WorktreeBaseCommit,
TaskId = Task?.Id,
TaskTitle = Task?.Title ?? "",
ShowMergeModal = ShowMergeModal,
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
}; };
await diffVm.LoadAsync(); await diffVm.LoadAsync();
await ShowDiffModal(diffVm); await ShowDiffModal(diffVm);
@@ -332,6 +345,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
{ {
OpenDiffCommand.NotifyCanExecuteChanged(); OpenDiffCommand.NotifyCanExecuteChanged();
OpenWorktreeCommand.NotifyCanExecuteChanged(); OpenWorktreeCommand.NotifyCanExecuteChanged();
ApproveMergeCommand.NotifyCanExecuteChanged();
}
partial void OnWorktreeStateLabelChanged(string? value)
{
ApproveMergeCommand.NotifyCanExecuteChanged();
} }
[RelayCommand] [RelayCommand]
@@ -379,14 +398,18 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
await repo.UpdateAsync(entity); await repo.UpdateAsync(entity);
} }
[RelayCommand] [RelayCommand(CanExecute = nameof(CanMerge))]
private async System.Threading.Tasks.Task ApproveMergeAsync() private async System.Threading.Tasks.Task ApproveMergeAsync()
{ {
if (Task == null) return; if (Task == null || ShowMergeModal == null) return;
// TODO: call worker merge hub method when available var vm = _services.GetRequiredService<MergeModalViewModel>();
await System.Threading.Tasks.Task.CompletedTask; await vm.InitializeAsync(Task.Id, Task.Title);
await ShowMergeModal(vm);
} }
private bool CanMerge() =>
Task != null && _worker.IsConnected && WorktreePath != null && WorktreeStateLabel == "Active";
[RelayCommand] [RelayCommand]
private async System.Threading.Tasks.Task StopAsync() private async System.Threading.Tasks.Task StopAsync()
{ {

View File

@@ -31,8 +31,8 @@ public sealed class DiffLineViewModel
public sealed class DiffFileViewModel public sealed class DiffFileViewModel
{ {
public required string Path { get; init; } public required string Path { get; init; }
public int Additions { get; init; } public int Additions { get; set; }
public int Deletions { get; init; } public int Deletions { get; set; }
public ObservableCollection<DiffLineViewModel> Lines { get; } = new(); public ObservableCollection<DiffLineViewModel> Lines { get; } = new();
} }
@@ -42,10 +42,15 @@ public sealed partial class DiffModalViewModel : ViewModelBase
public required string WorktreePath { get; init; } public required string WorktreePath { get; init; }
public string? BaseRef { get; init; } public string? BaseRef { get; init; }
public string? TaskId { get; init; }
public string TaskTitle { get; init; } = "";
public Func<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
public ObservableCollection<DiffFileViewModel> Files { get; } = new(); public ObservableCollection<DiffFileViewModel> Files { get; } = new();
[ObservableProperty] private DiffFileViewModel? _selectedFile; [ObservableProperty] private DiffFileViewModel? _selectedFile;
[ObservableProperty] private string? _statusMessage;
// Injected action to close the owning Window // Injected action to close the owning Window
public Action? CloseAction { get; set; } public Action? CloseAction { get; set; }
@@ -58,9 +63,24 @@ public sealed partial class DiffModalViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private void Close() => CloseAction?.Invoke(); private void Close() => CloseAction?.Invoke();
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);
}
public async Task LoadAsync(CancellationToken ct = default) public async Task LoadAsync(CancellationToken ct = default)
{ {
Files.Clear(); Files.Clear();
StatusMessage = null;
string raw; string raw;
try try
@@ -69,9 +89,17 @@ public sealed partial class DiffModalViewModel : ViewModelBase
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct) ? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
: await _git.GetDiffAsync(WorktreePath, ct); : await _git.GetDiffAsync(WorktreePath, ct);
} }
catch { return; } catch (Exception ex)
{
StatusMessage = $"Failed to load diff: {ex.Message}";
return;
}
if (string.IsNullOrWhiteSpace(raw)) return; if (string.IsNullOrWhiteSpace(raw))
{
StatusMessage = "No changes to show.";
return;
}
// Parse unified diff — state machine over lines // Parse unified diff — state machine over lines
DiffFileViewModel? current = null; DiffFileViewModel? current = null;
@@ -116,7 +144,7 @@ public sealed partial class DiffModalViewModel : ViewModelBase
NewNo = newLine++, NewNo = newLine++,
Text = line.Length > 1 ? line[1..] : "", Text = line.Length > 1 ? line[1..] : "",
}); });
// Count additions on the file VM current.Additions++;
} }
else if (line.StartsWith('-')) else if (line.StartsWith('-'))
{ {
@@ -126,6 +154,7 @@ public sealed partial class DiffModalViewModel : ViewModelBase
OldNo = oldLine++, OldNo = oldLine++,
Text = line.Length > 1 ? line[1..] : "", Text = line.Length > 1 ? line[1..] : "",
}); });
current.Deletions++;
} }
else if (line.StartsWith(' ')) else if (line.StartsWith(' '))
{ {
@@ -140,6 +169,7 @@ public sealed partial class DiffModalViewModel : ViewModelBase
} }
SelectedFile = Files.Count > 0 ? Files[0] : null; SelectedFile = Files.Count > 0 ? Files[0] : null;
if (Files.Count == 0) StatusMessage = "No changes to show.";
} }
private static void ParseHunkHeader(string header, out int oldStart, out int newStart) private static void ParseHunkHeader(string header, out int oldStart, out int newStart)

View File

@@ -0,0 +1,117 @@
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();
}

View File

@@ -36,6 +36,14 @@ public partial class DetailsIslandView : UserControl
await modal.ShowDialog(owner); await modal.ShowDialog(owner);
}; };
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);
};
vm.ConfirmAsync = ShowConfirmAsync; vm.ConfirmAsync = ShowConfirmAsync;
} }
} }

View File

@@ -63,12 +63,15 @@
FontFamily="{StaticResource MonoFamily}" FontFamily="{StaticResource MonoFamily}"
FontSize="12" FontSize="12"
Foreground="{StaticResource TextDimBrush}"/> Foreground="{StaticResource TextDimBrush}"/>
<Button Grid.Column="1" <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
Classes="icon-btn" <Button Content="Merge…"
Command="{Binding MergeCommand}"
Margin="0,0,4,0" />
<Button Classes="icon-btn"
Content="✕" Content="✕"
FontSize="12" FontSize="12"
Command="{Binding CloseCommand}" Command="{Binding CloseCommand}" />
VerticalAlignment="Center"/> </StackPanel>
</Grid> </Grid>
</Border> </Border>
@@ -93,7 +96,7 @@
FontFamily="{StaticResource MonoFamily}" FontFamily="{StaticResource MonoFamily}"
FontSize="11" FontSize="11"
Foreground="{StaticResource TextDimBrush}" Foreground="{StaticResource TextDimBrush}"
TextTrimming="LeadingEllipsis"/> TextTrimming="PrefixCharacterEllipsis"/>
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<Border Classes="chip" Padding="5,2"> <Border Classes="chip" Padding="5,2">
<TextBlock Foreground="{StaticResource MossBrightBrush}" <TextBlock Foreground="{StaticResource MossBrightBrush}"
@@ -116,10 +119,16 @@
</Border> </Border>
<!-- Diff content --> <!-- Diff content -->
<ScrollViewer Grid.Column="1" <Grid Grid.Column="1" Background="{StaticResource VoidBrush}">
HorizontalScrollBarVisibility="Auto" <TextBlock Text="{Binding StatusMessage}"
VerticalScrollBarVisibility="Auto" IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Background="{StaticResource VoidBrush}"> HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="{StaticResource TextDimBrush}"
FontFamily="{StaticResource MonoFamily}"
FontSize="12"/>
<ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding SelectedFile.Lines}"> <ItemsControl ItemsSource="{Binding SelectedFile.Lines}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DiffLineViewModel"> <DataTemplate x:DataType="vm:DiffLineViewModel">
@@ -166,5 +175,6 @@
</ScrollViewer> </ScrollViewer>
</Grid> </Grid>
</Grid> </Grid>
</Grid>
</Border> </Border>
</Window> </Window>

View File

@@ -0,0 +1,80 @@
<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>

View File

@@ -0,0 +1,19 @@
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();
}
};
}
}

View File

@@ -22,6 +22,8 @@ public record AppSettingsDto(
public record WorktreeCleanupDto(int Removed); public record WorktreeCleanupDto(int Removed);
public record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks); public record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
{ {
@@ -34,6 +36,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeMaintenanceService _wtMaintenance; private readonly WorktreeMaintenanceService _wtMaintenance;
private readonly TaskResetService _resetService; private readonly TaskResetService _resetService;
private readonly TaskMergeService _mergeService;
public WorkerHub( public WorkerHub(
QueueService queue, QueueService queue,
@@ -41,7 +44,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
HubBroadcaster broadcaster, HubBroadcaster broadcaster,
IDbContextFactory<ClaudeDoDbContext> dbFactory, IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService wtMaintenance, WorktreeMaintenanceService wtMaintenance,
TaskResetService resetService) TaskResetService resetService,
TaskMergeService mergeService)
{ {
_queue = queue; _queue = queue;
_agentService = agentService; _agentService = agentService;
@@ -49,6 +53,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_dbFactory = dbFactory; _dbFactory = dbFactory;
_wtMaintenance = wtMaintenance; _wtMaintenance = wtMaintenance;
_resetService = resetService; _resetService = resetService;
_mergeService = mergeService;
} }
public string Ping() => $"pong v{Version}"; public string Ping() => $"pong v{Version}";
@@ -160,4 +165,44 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var result = await _wtMaintenance.ResetAllAsync(); var result = await _wtMaintenance.ResetAllAsync();
return new WorktreeResetDto(result.Removed, result.TasksAffected, result.Blocked, result.RunningTasks); return new WorktreeResetDto(result.Removed, result.TasksAffected, result.Blocked, result.RunningTasks);
} }
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);
}
}
} }

View File

@@ -31,6 +31,7 @@ builder.Services.AddSingleton<ClaudeArgsBuilder>();
builder.Services.AddSingleton<TaskRunner>(); builder.Services.AddSingleton<TaskRunner>();
builder.Services.AddSingleton<WorktreeMaintenanceService>(); builder.Services.AddSingleton<WorktreeMaintenanceService>();
builder.Services.AddSingleton<TaskResetService>(); builder.Services.AddSingleton<TaskResetService>();
builder.Services.AddSingleton<TaskMergeService>();
// Agent file management. // Agent file management.
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents"); var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");

View File

@@ -0,0 +1,165 @@
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");
var currentBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal))
{
try { await _git.CheckoutBranchAsync(list.WorkingDir, targetBranch, ct); }
catch (Exception ex) { return Blocked($"failed to switch target branch: {ex.Message}"); }
}
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(); }
// If abort fails the repo is left mid-merge; the caller must resolve manually.
// Return Blocked (not conflict) so the UI does not offer a stale conflict list.
try { await _git.MergeAbortAsync(list.WorkingDir, ct); }
catch (Exception ex)
{
_logger.LogError(ex, "git merge --abort failed after conflict — repo is mid-merge");
return Blocked($"merge conflict and abort failed: {ex.Message} — repo is mid-merge, resolve manually");
}
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);
}
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);
}

View File

@@ -0,0 +1,197 @@
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}'");
}
[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"));
}
[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));
}
[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.
string defaultBranch;
try { defaultBranch = GitRepoFixture.RunGit(repo.RepoDir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD").Trim().Replace("origin/", ""); }
catch { defaultBranch = "main"; }
if (string.IsNullOrEmpty(defaultBranch)) defaultBranch = "main";
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}'");
}
[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);
}
[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));
}
[Fact]
public async Task CheckoutBranchAsync_ExistingBranch_SwitchesBranch()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "feature/checkout-test");
var git = new GitService();
await git.CheckoutBranchAsync(repo.RepoDir, "feature/checkout-test");
var current = await git.GetCurrentBranchAsync(repo.RepoDir);
Assert.Equal("feature/checkout-test", current);
}
[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);
}
}

View File

@@ -0,0 +1,392 @@
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 ?? "");
}
[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")));
}
[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);
}
[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}'");
}
[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");
}
[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 ?? "");
}
[Fact]
public async Task MergeAsync_TargetBranchDifferentFromHead_ChecksOutBeforeMerging()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
// Create a feature branch in the repo (from current HEAD).
GitRepoFixture.RunGit(repo.RepoDir, "branch", "feature/target");
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, "feat.txt"), "data\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
// Repo is currently on main/master; request merge into feature/target.
var result = await svc.MergeAsync(task.Id, "feature/target", removeWorktree: false,
commitMessage: "Merge into feature", ct: CancellationToken.None);
Assert.Equal("merged", result.Status);
// HEAD must now be feature/target.
var head = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
Assert.Equal("feature/target", head);
// The merged file must exist on feature/target.
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "feat.txt")));
}
[Fact]
public async Task MergeAsync_TargetBranchDoesNotExist_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));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "x.txt"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var result = await svc.MergeAsync(task.Id, "nonexistent/branch", removeWorktree: false,
commitMessage: "Merge", ct: CancellationToken.None);
Assert.Equal("blocked", result.Status);
Assert.Contains("switch target branch", 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