docs(merge): add approve-merge + conflict-preview design spec
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
# Approve = Merge → Done, plus Conflict Preview — Design
|
||||
|
||||
**Date:** 2026-06-04
|
||||
**Status:** Approved (autonomous — user on break, authorized to continue)
|
||||
**Author:** brainstormed from issue "Make merge/diff real"
|
||||
|
||||
## Problem
|
||||
|
||||
Approving a `WaitingForReview` task flips it straight to `Done`
|
||||
(`TaskStateService.ApproveReviewAsync`) and **never merges** its worktree — the
|
||||
worktree stays `Active`. The user approved three component tasks expecting them
|
||||
to merge; none did. Separately, there is **no way to see whether a task's
|
||||
worktree merges cleanly** before acting, and a standalone task has no direct
|
||||
**Merge** button (single-task merge is only reachable from inside the Diff
|
||||
modal).
|
||||
|
||||
What is already real (verified): `WorkerHub.MergeTask → TaskMergeService.MergeAsync`
|
||||
performs a real `git merge --no-ff`, aborts on conflict, and marks the worktree
|
||||
`Merged`. **Open Diff** opens a real in-app diff. **Merge All Subtasks**
|
||||
(planning) is real. So the gaps are narrow.
|
||||
|
||||
## Scope decisions (autonomous)
|
||||
|
||||
- **Tab location:** keep the **single "Session" tab** that the recent commit
|
||||
`ac9bae9` deliberately consolidated. All new controls go in its existing
|
||||
`MERGE & WORKTREE` block (`WorkConsole.axaml:196`). Do **not** re-introduce a
|
||||
separate "Actions" tab.
|
||||
- **Approve target:** Approve merges into the UI-selected merge target
|
||||
(`SelectedMergeTarget`); when blank, the worker resolves to the repo's current
|
||||
branch.
|
||||
- **On conflict:** task stays in `WaitingForReview` (no new status). The conflict
|
||||
is surfaced inline. No automatic state change to a "blocked" status.
|
||||
- **Worktree removal on approve:** do **not** remove — merge marks the worktree
|
||||
`Merged` and existing auto-cleanup handles disposal (matches the single-task
|
||||
merge default `removeWorktree:false`).
|
||||
- **Applies to:** standalone leaf tasks with an active worktree. A
|
||||
`WaitingForReview` task with **no** active worktree (e.g. ran in a sandbox, or
|
||||
an improvement parent whose children own the worktrees) is just marked `Done`
|
||||
— current behavior preserved. Planning parents keep "Merge All Subtasks".
|
||||
|
||||
## Acceptance (restated)
|
||||
|
||||
1. Approve a clean-merging task → worktree merged into target, worktree `Merged`,
|
||||
task `Done`.
|
||||
2. Approve a conflicting task → task **not** `Done`, conflict surfaced.
|
||||
3. Opening a Done/WaitingForReview task shows clean/conflict status **without
|
||||
mutating** the tree (use `git merge-tree`, not a real merge).
|
||||
|
||||
## Architecture
|
||||
|
||||
Three layers, each single-purpose; the only new cross-dependency is
|
||||
`TaskMergeService → ITaskStateService` (one-way; verify no DI cycle).
|
||||
|
||||
### 1. GitService — non-destructive conflict probe (`ClaudeDo.Data`)
|
||||
|
||||
New method:
|
||||
|
||||
```csharp
|
||||
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
|
||||
|
||||
public async Task<MergePreview> PreviewMergeAsync(
|
||||
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
|
||||
```
|
||||
|
||||
- Runs `git merge-tree --write-tree --name-only <target> <source>` from `repoDir`.
|
||||
`merge-tree` computes the merge base itself and writes only loose objects — it
|
||||
does **not** touch the working tree, index, or refs.
|
||||
- Exit code `0` → `Clean = true`, no conflict files.
|
||||
- Exit code `1` → `Clean = false`; conflicted paths are the lines after the
|
||||
first (tree-OID) line, up to the first blank line.
|
||||
- Any other outcome (e.g. git too old → "unknown option") → `Supported = false`
|
||||
(UI shows "mergeability unknown").
|
||||
|
||||
New helper for the "· N files" count (clean case):
|
||||
`git diff --name-only <target>...<source>` (three-dot = changes on source since
|
||||
the merge base); count non-empty lines. May reuse/extend existing diff helpers.
|
||||
|
||||
### 2. TaskMergeService — preview + approve orchestration (`ClaudeDo.Worker`)
|
||||
|
||||
Inject `ITaskStateService` (verify `PlanningChainCoordinator` has no back-edge to
|
||||
`TaskMergeService`; if a cycle exists, fall back to orchestrating in the hub).
|
||||
|
||||
```csharp
|
||||
public sealed record MergePreviewResult(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
||||
// Status: "clean" | "conflict" | "unavailable"
|
||||
public Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct);
|
||||
|
||||
public Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct);
|
||||
```
|
||||
|
||||
**PreviewAsync:** load context. If no active worktree → `"unavailable"`. Resolve
|
||||
`targetBranch` (blank → current branch). Call `GitService.PreviewMergeAsync`; map
|
||||
`Supported=false` → `"unavailable"`, else clean/conflict (+ ChangedFileCount on
|
||||
clean).
|
||||
|
||||
**ApproveAndMergeAsync:** load context; require `task.Status == WaitingForReview`
|
||||
(else `Blocked`). Resolve target (blank → current branch).
|
||||
- **No active worktree** → `_state.ApproveReviewAsync(taskId)` → return
|
||||
`MergeResult(StatusMerged, [], null)` ("approved, nothing to merge").
|
||||
- **Active worktree** → `MergeAsync(taskId, target, removeWorktree:false,
|
||||
"Merge {branch}", ct)`. On `StatusMerged` → `_state.ApproveReviewAsync(taskId)`
|
||||
then return the merged result. On `StatusConflict`/`StatusBlocked` → return as-is;
|
||||
**do not** flip status (task stays `WaitingForReview`).
|
||||
|
||||
`TaskStateService.ApproveReviewAsync` is unchanged (still the sole Status writer;
|
||||
still runs `OnChildTerminalAsync`).
|
||||
|
||||
### 3. WorkerHub — signatures (`ClaudeDo.Worker`)
|
||||
|
||||
```csharp
|
||||
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
||||
|
||||
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch); // new
|
||||
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch); // CHANGED: was void(taskId)
|
||||
```
|
||||
|
||||
`ApproveReview` returns the orchestration result so the UI can react to conflicts.
|
||||
`MergeTask` / `GetMergeTargets` unchanged.
|
||||
|
||||
### 4. UI (`ClaudeDo.Ui`)
|
||||
|
||||
`IWorkerClient` (+ `WorkerClient` + **both test-project fakes** — see memory:
|
||||
changing `IWorkerClient` breaks hand-rolled fakes):
|
||||
- Change `Task ApproveReviewAsync(string)` → `Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)`.
|
||||
- Add `Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)`.
|
||||
- Add `Task<MergeResultDto> MergeTaskAsync(...)` to the **interface** (already on
|
||||
the concrete client) so the single-task Merge button can use `_worker`.
|
||||
|
||||
`DetailsIslandViewModel`:
|
||||
- **Load merge targets whenever a worktree exists.** In `BindAsync`, when
|
||||
`entity.Worktree != null` and the task is not a planning parent, call
|
||||
`GetMergeTargetsAsync(taskId)` and set `SelectedMergeTarget = DefaultBranch`
|
||||
(fixes the standalone-task gap where targets were never loaded).
|
||||
- **Mergeability indicator** properties: `MergePreviewText` (string),
|
||||
`MergeIsClean` / `MergeIsConflict` (bool, for color). Compute via
|
||||
`PreviewMergeAsync` when the merge section is shown for an **Active** worktree;
|
||||
recompute on `SelectedMergeTarget` change. If worktree state is
|
||||
`Merged/Discarded/Kept`, show that label instead of probing. Text examples:
|
||||
"Merges cleanly · 7 files" / "Conflicts in a.cs, b.cs" / "Mergeability unknown".
|
||||
- **Approve** (`ApproveReviewAsync`): pass `SelectedMergeTarget ?? ""`; inspect
|
||||
result — on `"conflict"` set the conflict indicator + a short notice
|
||||
("Approve blocked — resolve conflicts first"); success path relies on the
|
||||
existing `TaskUpdated` broadcast to refresh.
|
||||
- **Single-task Merge** (`MergeCommand`): `MergeTaskAsync(taskId,
|
||||
SelectedMergeTarget ?? "", removeWorktree:false, "Merge task")`; on `"conflict"`
|
||||
show the conflict indicator. Shown for non-planning tasks with an active
|
||||
worktree (planning parents keep "Merge All Subtasks").
|
||||
|
||||
`WorkConsole.axaml` (Session tab, `MERGE & WORKTREE` block):
|
||||
- Add a status line above the button row bound to `MergePreviewText`, colored
|
||||
green (`MossBrush`) when `MergeIsClean`, red (`BloodBrush`) when
|
||||
`MergeIsConflict`, muted otherwise. Use existing tokens/classes only.
|
||||
- Add a **Merge** button (`MergeCommand`) beside **Open Diff** for the
|
||||
single-task path.
|
||||
|
||||
## Testing (git-backed, no real Claude)
|
||||
|
||||
In `ClaudeDo.Worker.Tests` (real temp git repos + real SQLite), and/or
|
||||
`ClaudeDo.Data.Tests` for the pure git probe:
|
||||
- `GitService.PreviewMergeAsync`: clean branches → `Clean=true`; a real
|
||||
edit-conflict on the same lines → `Clean=false` with the expected file in
|
||||
`ConflictFiles`.
|
||||
- `ApproveAndMergeAsync`: clean worktree → returns `merged`, task is `Done`,
|
||||
worktree state `Merged`. Conflicting worktree → returns `conflict`, task still
|
||||
`WaitingForReview`, worktree still `Active`, target branch unmodified
|
||||
(HEAD unchanged, no `MERGE_HEAD`).
|
||||
- No-worktree `WaitingForReview` task → returns `merged`, task `Done`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
External difftools, new task statuses, auto-removing worktrees on approve,
|
||||
re-splitting the console into separate tabs, conflict resolution UI (the existing
|
||||
`ContinueMerge`/`AbortMerge` paths remain as-is for mid-merge cases).
|
||||
Reference in New Issue
Block a user