Compare commits
32 Commits
fb89e02b02
...
b378fbf550
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b378fbf550 | ||
|
|
cb43bcdd10 | ||
|
|
31420574db | ||
|
|
07dee31847 | ||
|
|
4debd5ce09 | ||
|
|
1495c63e3d | ||
|
|
953d93179d | ||
|
|
1bc7fcc609 | ||
|
|
c911717a3b | ||
|
|
949911f6c8 | ||
|
|
f3a58a6515 | ||
|
|
ee4cd706ef | ||
|
|
e11b01951e | ||
|
|
3d0cc4ffed | ||
|
|
4585b20f80 | ||
|
|
c53b5878cf | ||
|
|
c13ae437f7 | ||
|
|
5780879629 | ||
|
|
2bcd5ef9bd | ||
|
|
63eb860e40 | ||
|
|
e80ac7de49 | ||
|
|
3331c24898 | ||
|
|
1c20d8f846 | ||
|
|
77a1460e3a | ||
|
|
21a1870fd7 | ||
|
|
3ebbdb3f6e | ||
|
|
535d0c5558 | ||
|
|
2d807aa606 | ||
|
|
93ee7b72d5 | ||
|
|
32ef1b389a | ||
|
|
0885518a68 | ||
|
|
944d3bd3e8 |
@@ -2,7 +2,9 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)"
|
||||
"Bash(git commit:*)",
|
||||
"mcp__plugin_context-mode_context-mode__batch_execute",
|
||||
"mcp__plugin_context-mode_context-mode__execute"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
189
docs/superpowers/plans/2026-04-21-open-items-consolidation.md
Normal file
189
docs/superpowers/plans/2026-04-21-open-items-consolidation.md
Normal 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:** klein–mittel
|
||||
|
||||
### 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:** klein–mittel (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:** klein–mittel
|
||||
**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.
|
||||
1955
docs/superpowers/plans/2026-04-22-worktree-merge.md
Normal file
1955
docs/superpowers/plans/2026-04-22-worktree-merge.md
Normal file
File diff suppressed because it is too large
Load Diff
208
docs/superpowers/specs/2026-04-22-worktree-merge-design.md
Normal file
208
docs/superpowers/specs/2026-04-22-worktree-merge-design.md
Normal 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 1–2s; 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.
|
||||
@@ -78,6 +78,7 @@ sealed class Program
|
||||
// ViewModels
|
||||
sc.AddTransient<WorktreeModalViewModel>();
|
||||
sc.AddTransient<SettingsModalViewModel>();
|
||||
sc.AddTransient<MergeModalViewModel>();
|
||||
|
||||
// Islands shell VMs
|
||||
sc.AddSingleton<ListsIslandViewModel>(sp =>
|
||||
|
||||
@@ -151,6 +151,73 @@ public sealed class GitService
|
||||
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)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct);
|
||||
@@ -169,6 +236,9 @@ public sealed class GitService
|
||||
RedirectStandardInput = stdinData is not null,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
StandardInputEncoding = stdinData is not null ? Encoding.UTF8 : null,
|
||||
};
|
||||
psi.ArgumentList.Add("-C");
|
||||
psi.ArgumentList.Add(workDir);
|
||||
|
||||
@@ -179,6 +179,24 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
||||
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)
|
||||
{
|
||||
await _hub.InvokeAsync("CancelTask", taskId);
|
||||
@@ -298,3 +316,5 @@ public sealed record AppSettingsDto(
|
||||
|
||||
public sealed record WorktreeCleanupDto(int Removed);
|
||||
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);
|
||||
|
||||
@@ -58,6 +58,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
[ObservableProperty] private string? _model;
|
||||
[ObservableProperty] private string? _worktreePath;
|
||||
[ObservableProperty] private string? _worktreeBaseCommit;
|
||||
[ObservableProperty] private string? _worktreeStateLabel;
|
||||
[ObservableProperty] private string? _branchLine;
|
||||
[ObservableProperty] private int _turns;
|
||||
[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
|
||||
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
|
||||
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
|
||||
|
||||
@@ -126,6 +130,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
RunNowCommand.NotifyCanExecuteChanged();
|
||||
ContinueCommand.NotifyCanExecuteChanged();
|
||||
ResetCommand.NotifyCanExecuteChanged();
|
||||
ApproveMergeCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -227,6 +232,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
Notes = "";
|
||||
Model = null;
|
||||
WorktreePath = null;
|
||||
WorktreeStateLabel = null;
|
||||
BranchLine = null;
|
||||
AgentStatusLabel = "Idle";
|
||||
LatestRunSessionId = null;
|
||||
@@ -256,6 +262,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
Notes = entity.Notes ?? "";
|
||||
Model = entity.Model;
|
||||
WorktreePath = entity.Worktree?.Path;
|
||||
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
||||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
|
||||
AgentStatusLabel = entity.Status.ToString();
|
||||
|
||||
@@ -288,6 +296,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
|
||||
WorktreePath = entity.Worktree?.Path;
|
||||
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
||||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
|
||||
AgentStatusLabel = entity.Status.ToString();
|
||||
if (Task is { } row && entity.Worktree?.DiffStat is { } stat)
|
||||
@@ -304,6 +313,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
{
|
||||
WorktreePath = WorktreePath,
|
||||
BaseRef = WorktreeBaseCommit,
|
||||
TaskId = Task?.Id,
|
||||
TaskTitle = Task?.Title ?? "",
|
||||
ShowMergeModal = ShowMergeModal,
|
||||
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
|
||||
};
|
||||
await diffVm.LoadAsync();
|
||||
await ShowDiffModal(diffVm);
|
||||
@@ -332,6 +345,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
{
|
||||
OpenDiffCommand.NotifyCanExecuteChanged();
|
||||
OpenWorktreeCommand.NotifyCanExecuteChanged();
|
||||
ApproveMergeCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
partial void OnWorktreeStateLabelChanged(string? value)
|
||||
{
|
||||
ApproveMergeCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -379,14 +398,18 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
await repo.UpdateAsync(entity);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
[RelayCommand(CanExecute = nameof(CanMerge))]
|
||||
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;
|
||||
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 && WorktreePath != null && WorktreeStateLabel == "Active";
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task StopAsync()
|
||||
{
|
||||
|
||||
@@ -31,8 +31,8 @@ public sealed class DiffLineViewModel
|
||||
public sealed class DiffFileViewModel
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
public int Additions { get; init; }
|
||||
public int Deletions { get; init; }
|
||||
public int Additions { get; set; }
|
||||
public int Deletions { get; set; }
|
||||
public ObservableCollection<DiffLineViewModel> Lines { get; } = new();
|
||||
}
|
||||
|
||||
@@ -42,10 +42,15 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
||||
|
||||
public required string WorktreePath { 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();
|
||||
|
||||
[ObservableProperty] private DiffFileViewModel? _selectedFile;
|
||||
[ObservableProperty] private string? _statusMessage;
|
||||
|
||||
// Injected action to close the owning Window
|
||||
public Action? CloseAction { get; set; }
|
||||
@@ -58,9 +63,24 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
||||
[RelayCommand]
|
||||
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)
|
||||
{
|
||||
Files.Clear();
|
||||
StatusMessage = null;
|
||||
|
||||
string raw;
|
||||
try
|
||||
@@ -69,9 +89,17 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
||||
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, 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
|
||||
DiffFileViewModel? current = null;
|
||||
@@ -116,7 +144,7 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
||||
NewNo = newLine++,
|
||||
Text = line.Length > 1 ? line[1..] : "",
|
||||
});
|
||||
// Count additions on the file VM
|
||||
current.Additions++;
|
||||
}
|
||||
else if (line.StartsWith('-'))
|
||||
{
|
||||
@@ -126,6 +154,7 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
||||
OldNo = oldLine++,
|
||||
Text = line.Length > 1 ? line[1..] : "",
|
||||
});
|
||||
current.Deletions++;
|
||||
}
|
||||
else if (line.StartsWith(' '))
|
||||
{
|
||||
@@ -140,6 +169,7 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
117
src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs
Normal file
117
src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs
Normal 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();
|
||||
}
|
||||
@@ -36,6 +36,14 @@ public partial class DetailsIslandView : UserControl
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,12 +63,15 @@
|
||||
FontFamily="{StaticResource MonoFamily}"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource TextDimBrush}"/>
|
||||
<Button Grid.Column="1"
|
||||
Classes="icon-btn"
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||
<Button Content="Merge…"
|
||||
Command="{Binding MergeCommand}"
|
||||
Margin="0,0,4,0" />
|
||||
<Button Classes="icon-btn"
|
||||
Content="✕"
|
||||
FontSize="12"
|
||||
Command="{Binding CloseCommand}"
|
||||
VerticalAlignment="Center"/>
|
||||
Command="{Binding CloseCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -93,7 +96,7 @@
|
||||
FontFamily="{StaticResource MonoFamily}"
|
||||
FontSize="11"
|
||||
Foreground="{StaticResource TextDimBrush}"
|
||||
TextTrimming="LeadingEllipsis"/>
|
||||
TextTrimming="PrefixCharacterEllipsis"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Border Classes="chip" Padding="5,2">
|
||||
<TextBlock Foreground="{StaticResource MossBrightBrush}"
|
||||
@@ -116,10 +119,16 @@
|
||||
</Border>
|
||||
|
||||
<!-- Diff content -->
|
||||
<ScrollViewer Grid.Column="1"
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
Background="{StaticResource VoidBrush}">
|
||||
<Grid Grid.Column="1" Background="{StaticResource VoidBrush}">
|
||||
<TextBlock Text="{Binding StatusMessage}"
|
||||
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{StaticResource TextDimBrush}"
|
||||
FontFamily="{StaticResource MonoFamily}"
|
||||
FontSize="12"/>
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding SelectedFile.Lines}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:DiffLineViewModel">
|
||||
@@ -166,5 +175,6 @@
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
|
||||
80
src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml
Normal file
80
src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml
Normal 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>
|
||||
19
src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml.cs
Normal file
19
src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml.cs
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ public record AppSettingsDto(
|
||||
|
||||
public record WorktreeCleanupDto(int Removed);
|
||||
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
|
||||
{
|
||||
@@ -34,6 +36,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly WorktreeMaintenanceService _wtMaintenance;
|
||||
private readonly TaskResetService _resetService;
|
||||
private readonly TaskMergeService _mergeService;
|
||||
|
||||
public WorkerHub(
|
||||
QueueService queue,
|
||||
@@ -41,7 +44,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
HubBroadcaster broadcaster,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
WorktreeMaintenanceService wtMaintenance,
|
||||
TaskResetService resetService)
|
||||
TaskResetService resetService,
|
||||
TaskMergeService mergeService)
|
||||
{
|
||||
_queue = queue;
|
||||
_agentService = agentService;
|
||||
@@ -49,6 +53,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
_dbFactory = dbFactory;
|
||||
_wtMaintenance = wtMaintenance;
|
||||
_resetService = resetService;
|
||||
_mergeService = mergeService;
|
||||
}
|
||||
|
||||
public string Ping() => $"pong v{Version}";
|
||||
@@ -160,4 +165,44 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
var result = await _wtMaintenance.ResetAllAsync();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ builder.Services.AddSingleton<ClaudeArgsBuilder>();
|
||||
builder.Services.AddSingleton<TaskRunner>();
|
||||
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
||||
builder.Services.AddSingleton<TaskResetService>();
|
||||
builder.Services.AddSingleton<TaskMergeService>();
|
||||
|
||||
// Agent file management.
|
||||
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
|
||||
|
||||
165
src/ClaudeDo.Worker/Services/TaskMergeService.cs
Normal file
165
src/ClaudeDo.Worker/Services/TaskMergeService.cs
Normal 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);
|
||||
}
|
||||
197
tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs
Normal file
197
tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
392
tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
Normal file
392
tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
Normal 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
|
||||
Reference in New Issue
Block a user