Files
ClaudeDo/docs/superpowers/specs/2026-10-01-unit-branch-draft-pr-design.md

250 lines
16 KiB
Markdown

# Unit-Branch + Draft-PR — Design
**Datum:** 2026-10-01 · **Status:** abgenommen (mit Basis-Branch pro Liste + Fetch)
**Diagramme:** Obsidian `07 - Plattform & Technik/ClaudeDo/ClaudeDo - Branch und PR Ablauf.md`
**Ersetzt:** die offene Entscheidung aus `2026-08-06-planning-chain-fork-base-design.md`
## Ziel
Aufgaben starten und später zu fertigen PRs zurückkommen. In Firmen-Repos (Azure DevOps Server)
heißen Branches nach dem work-conventions-Schema, und das Review endet in einem **Draft-PR** statt
in einem lokalen Merge auf `main`. Nebenbei sehen Planning-Children endlich die Arbeit ihres
Vorgängers.
## Begriffe
- **Unit:** ein einzelner Task, oder ein Parent (Planning oder Improvement) samt Children.
- **Unit-Branch:** der eine Branch, auf dem die ganze Arbeit einer Unit landet. Er gehört dem
Worktree des Unit-Roots (Einzeltask bzw. Parent).
- **Firmen-Repo:** Die `origin`-URL enthält `company_remote_pattern` (`worker.config.json`,
Default `/_git/`, nur per Hand editierbar). Geprüft wird live über `git remote get-url origin`
und ohne Cache.
## Entscheidungen
| Frage | Entscheidung |
|---|---|
| Branchname | Einheitlich `<typ>/<nummer>-<slug>`. Die Nummer ist die Ticket-Id aus `TicketRef` (beim Child die des Unit-Roots), sonst privat die Task-Nummer. Im Firmen-Repo ohne Ticket entfällt sie (`<typ>/<slug>`). `claudedo/{nr}-…` fällt weg. Planning-Session-Worktrees bleiben `claudedo/planning/{guid}`. |
| Typ | `feat`→`feature`. `fix`, `refactor`, `docs`, `test`, `chore` bleiben, wie sie sind. Alles andere (`ci`, `perf`, `style`, `build`) wird `chore`. |
| Slug | `CommitMessageBuilder.ToSlug` plus Umlaut-Auflösung (`ä→ae`, `ö→oe`, `ü→ue`, `ß→ss`), ≤40 Zeichen wie heute. |
| Children | Starten vom aktuellen Head des Unit-Branches. Ist ein Child fertig, wird der Unit-Branch sofort per **fast-forward** auf den Head des Childs gezogen. Das gilt privat und in Firmen-Repos. |
| Review-Ende privat | Unverändert: Approve merged den Unit-Branch auf `main`, dann läuft Verify auf `main`. |
| Review-Ende Firma | Approve führt Verify im Worktree des Unit-Roots aus, dann Push und Draft-PR. Der Task bleibt `WaitingForReview` mit PR-Link. `Done` setzt Mika per Toggle nach dem ADO-Merge. |
| PR-Automatik | Kein Auto-PR bei einem Statuswechsel. Automatisiert wird über den Merge-Helper, dessen `approve` dann den PR erzeugt. |
| ADO-Zugang | Eigener PAT (Scope Code Read & Write). Er wird per `DpapiTokenStore` in `~/.claudeDo/azdo.pat` gespeichert, ohne Abhängigkeit auf das work-conventions-Plugin. |
| dependsOn über Units | Wartet bewusst auf den echten Merge. Abhängige Arbeit gehört in eine Unit mit einem PR. |
| Basis-Branch | Pro Liste einstellbar (`list_config.base_branch`, z.B. `dev` für die LagerApp). Leer heißt: privat der gerade ausgecheckte Branch des Main-Checkouts (wie heute), Firma der Default-Branch des ADO-Repos. Gilt als Basis des Unit-Branches, als PR-Ziel und als Vorauswahl des privaten Merge-Ziels. |
| Basis aktualisieren | Firmen-Repo: `git fetch origin <basis>`, bevor der Unit-Branch angelegt wird. Der Unit-Branch startet von `origin/<basis>`, der lokale Basis-Branch wird best-effort per fast-forward nachgezogen. |
## 1. Branch-Naming (`WorktreeManager`)
- `NameFor(task, ticketId, isCompany)` liefert den Leaf `<nummer>-<slug>` bzw. `<slug>`, und
`BranchFor` liefert `<typ>/<leaf>`. Der Ordnername des Worktrees bleibt der Leaf.
- **Kollisionsprüfung** (heute `Contains($"claudedo/{name}")`, Zeile 83) prüft den neuen vollen
Branchnamen. Bei einem Treffer kommt das Id-Suffix dran, wie heute.
- **Self-Heal** (`git branch -D` nach „already exists“) läuft nur noch, wenn der Branch in einer
`worktrees`-Zeile steht, deren Task der aktuelle Task ist. Sonst gibt es keinen Delete, sondern
das Id-Suffix, und der Branch des Nutzers bleibt unangetastet. Ohne das Präfix `claudedo/` ist
das Pflicht, sonst löscht ClaudeDo einen fremden `feature/…`-Branch.
- **`UntrackedWorktreeRecovery`** filtert statt nach dem Präfix nach „Worktree-Pfad liegt unter
einer ClaudeDo-Worktree-Root (`.claudedo-worktrees` bzw. Central-Root), aber nicht unter
`planning/`“.
- `PlanningAggregator.CleanupIntegrationBranchAsync:137` (Präfix-Check beim Checkout-Fallback)
wird mit Abschnitt 3 ohnehin hinfällig.
- Bestehende Worktrees behalten ihren Namen. Der Branchname steht in der DB und wird nie neu
abgeleitet, eine Migration ist also nicht nötig.
- **Bugfix nebenbei:** `DetailsIslandViewModel.cs:1223/1385` baut für Dialogtexte
`claudedo/{Task.Id}`, das ist schon heute falsch. Künftig steht dort `worktree.BranchName`. Das
literale `"← main"` (`:670/:933`) zeigt das echte Ziel an.
## 2. Unit-Branch und Children
**Wer besitzt den Unit-Branch?**
- **Einzeltask:** sein eigener Worktree. Am Ablauf ändert sich nichts.
- **Improvement-Parent:** sein eigener Worktree, den er schon heute hat. Seine Children starten
schon heute von `parent.HeadCommit`.
- **Planning-Parent:** hat heute **keinen** Worktree. Neu: `QueuePlanAsync` legt per
`WorktreeManager.CreateAsync` einen Worktree für den Parent an, auf dem Unit-Branch und mit
`main` HEAD als Basis. Damit hat jeder Parent eine `worktrees`-Zeile, die den Unit-Branch und
später die PR-URL trägt.
**Basis eines Childs** (`ResolveBaseCommitAsync`): Das ist immer der aktuelle Head des
Unit-Branches (`rev-parse <unitBranch>`). Der Sonderfall „nur bei `PlanningPhase.None`“ fällt weg.
**Basis eines Unit-Roots** (Einzeltask, Parent):
- **Privat:** `list_config.base_branch`, sonst der ausgecheckte Branch des Main-Checkouts. Heute
ist es `RevParseHeadAsync`. Ist `base_branch` gesetzt, wird es `rev-parse <base_branch>`, auch
wenn gerade ein anderer Branch ausgecheckt ist.
- **Firma:**
- Zuerst `git fetch origin <basis>`. `<basis>` ist `base_branch`, sonst der Default-Branch des
Repos per ADO-API, ersatzweise `git symbolic-ref refs/remotes/origin/HEAD`.
- Der Unit-Branch startet von `origin/<basis>`.
- Dann wird der lokale `<basis>` best-effort nachgezogen:
- nicht ausgecheckt: `git fetch origin <basis>:<basis>` (lehnt einen nicht-fast-forward
selbst ab)
- ausgecheckt und sauber: `git -C <workingDir> merge --ff-only origin/<basis>`
- schmutzig oder divergiert: Warnung ins Log, kein Eingriff in den Checkout
- Schlägt der Fetch fehl (offline, keine Credentials), wird gewarnt und es geht mit dem
lokalen `<basis>` weiter. Der Task scheitert daran nicht.
- Der Fetch nutzt dieselbe PAT-Übergabe wie der Push (Abschnitt 3).
- Der Fetch läuft einmal pro Unit-Root, nicht pro Child. Children starten vom Unit-Branch.
**Spalte `list_config.base_branch`** (nullable string, Migration):
- Sie wird im Listeneinstellungs-Modal gepflegt, als Freitext mit Vorschlägen aus
`ListLocalBranchesAsync`, und in `get_list_config` mitgeliefert.
- ⚠️ `ListRepository.SetConfigAsync` kopiert verbatim (siehe `TicketProjectId`). Jeder Schreiber
muss den Wert durchreichen, sonst setzt ihn z.B. `set_list_config` still zurück:
`WorkerHub.UpdateListConfig`, `ConfigMcpTools`, alle Stellen, die eine frische
`ListConfigEntity` bauen.
- `UpdateListConfigDto` braucht ihn tri-state wie `TicketProjectId`, damit ein Aufrufer, der das
Feld nicht kennt, es nicht löscht.
- **Merge-Ziel privat:** `TaskMergeService.GetTargetsAsync` setzt `DefaultBranch` auf
`base_branch`, falls gesetzt. Das leere Ziel (`ApproveAndMergeAsync:1077`, `PreviewAsync:910`)
löst ebenfalls auf `base_branch` auf, sonst wie heute.
**Absorbieren eines Childs:** Neu ist `UnitBranchService.AbsorbChildAsync(childId)`, das in
`git -C <parentWorktree> merge --ff-only <childBranch>` mündet.
- Die Kette ist seriell (`BlockedByTaskId`), und das Child startet vom Head des Unit-Branches.
Deshalb ist es **immer** ein fast-forward, Konflikte sind ausgeschlossen. Lässt es sich nicht
fast-forwarden, z.B. weil jemand ein Child manuell parallel per „Run now“ gestartet hat, wird
das Child `Failed` mit einer klaren Meldung, ohne Merge-Versuch.
`ponytail: kein Merge-Fallback; einbauen, falls parallele Children ein echter Fall werden.`
- Danach wird der Worktree des Childs entfernt, der Branch gelöscht und die Zeile auf `Merged`
gesetzt (`MergeCommit` = null, denn es gibt keinen Merge-Commit). `revert_merge` greift auf
Child-Ebene deshalb nicht, revertiert wird die ganze Unit.
- Aufgerufen wird es aus `TaskRunner.HandleSuccess` direkt vor `_state.CompleteAsync`. Ein
ff-Fehler wird so zu `FailAsync` statt zu einem Unblock des Nachfolgers.
- Interaktive Children landen in `WaitingForReview`, und ihr Approve geht durch
`TaskMergeService.ApproveAndMergeAsync`. Dort gilt neu: Hat der Task einen Parent mit eigenem
Worktree, wird `AbsorbChildAsync` aufgerufen statt auf das Ziel gemergt.
- Kein Git-Code in `TaskStateService` oder `PlanningChainCoordinator`, die bleiben git-frei.
**Was dabei wegfällt:** Den Unit-Merge Child für Child am Ende gibt es für neue Units nicht mehr.
Ein Parent wird beim Approve wie ein Einzeltask behandelt, über seinen eigenen Worktree in
`TaskMergeService.ApproveAndMergeAsync`. Auch der Integration-Branch
(`PlanningAggregator`, `planning/{slug}-integration`, dafür `checkout -b` im Main-Checkout) ist
für neue Units unnötig, denn der kombinierte Diff ist `unitBase..unitHead`.
**Routing beim Approve** (`WorkerHub.ApproveReview`, `ExternalMcpService.ReviewTask`): Heute
geht jeder Task mit Children an den Orchestrator. Neu gilt: Gibt es ein `Done`-Child mit einem
`Active`-Worktree, also eins, das nicht absorbiert wurde, läuft der Legacy-Orchestrator. Sonst
geht es über `ApproveAndMergeAsync` auf den Worktree des Parents.
**Altbestand:** Parents ohne eigenen Worktree (vor dem Rollout geplant) laufen weiter über
`PlanningMergeOrchestrator` und den bestehenden Aggregator. Diesen Legacy-Pfad entfernen wir in
einem Folge-Task, sobald keine solchen Units mehr offen sind.
## 3. Approve im Firmen-Repo
`TaskMergeService.ApproveAndMergeAsync` verzweigt nach `IsCompanyRepo(list)`:
1. **Verify** im Worktree des Unit-Roots (`IVerifyCommandRunner.RunAsync(worktreePath, …)`), nicht
im Main-Checkout. Ohne Verify-Command entfällt der Schritt. Rot heißt: Ergebnis
`verify_failed`, der Task bleibt `WaitingForReview`, kein Push. `skipVerify` wird im
Firmen-Modus ignoriert, weil kein Merge landet, auf den man das Verify verschieben könnte.
2. **Push** mit `git -C <worktree> push -u origin <unitBranch>`. Der PAT geht als
`http.extraHeader` über `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_0`/`GIT_CONFIG_VALUE_0` in der
Prozess-Umgebung mit. Er steht also nicht in der Kommandozeile, und es gibt keinen
Credential-Manager-Prompt im Hintergrundprozess.
3. **PR**, neuer `AzureDevOpsClient`, portiert aus `pr-create.ps1`:
- Der Remote wird an `/_git/` zerlegt: API-Basis = alles davor, Repo = Segment danach.
Eingebettete Zugangsdaten fliegen raus.
- `GET …/pullrequests?searchCriteria.sourceRefName=refs/heads/<branch>&searchCriteria.status=active`.
Gibt es schon einen PR, wird er wiederverwendet (idempotent, auch wenn der Skill in der
Session schon einen angelegt hat).
- Sonst `POST …/pullrequests` mit `isDraft: true` und `targetRefName = <basis>`. Das ist
`list_config.base_branch`, sonst `defaultBranch` aus `GET …/repositories/<repo>`.
- Die API-Version fällt auf die zurück, die der Server in seiner Fehlermeldung nennt, wie im
Skript.
4. Die **PR-URL** wird gespeichert, in der neuen nullable Spalte `worktrees.pull_request_url`
(Migration). Danach folgen `WorktreeUpdated` und der Rückgabestatus `pr_created` bzw.
`pr_updated`.
5. Der Task bleibt `WaitingForReview`, der Worktree bleibt `Active`, und das Ticket bleibt auf
`InBearbeitung`.
**PR-Inhalt:**
- **Titel:** `<commitType>(<list-slug>): <titel>, Resolves #<ticket>`. Ohne Ticket entfällt
`Resolves`.
- **Beschreibung:**
- `Resolves [#412](<ticket-url>)`. Die Projekt-Id kommt über `BandelTicketClient.GetTicketAsync`
→ `ParentTicketID` (bei Tasks eine Ebene höher). Ohne Id gibt es keinen Link.
- Leerzeile, dann die Task-Beschreibung.
- Leerzeile, dann `Geprüft: <verify command> grün` bzw. `Geprüft: nichts automatisch`.
**Commits im Firmen-Repo:** `CommitMessageBuilder` lässt den Trailer `ClaudeDo-Task:` weg. Er
wird nirgends gelesen, und die Konvention verbietet Signatur-Trailer.
**Rerun nach Änderungswunsch:** Ein normaler Reject mit Feedback, der auf demselben Worktree
weiterläuft. Der nächste Approve pusht nach, und der vorhandene PR aktualisiert sich.
**Fehler:** Fehlt der PAT, wird der Push abgelehnt oder scheitert ein REST-Aufruf, dann landen
Statusmeldung und Footer-Strip (`FlashFooterError`) beim Nutzer, und der Task bleibt
`WaitingForReview`. Scheitert der PR nach einem erfolgreichen Push, sagt die Meldung das genau
so: „gepusht, PR fehlgeschlagen: …“.
**Laufzeit:** Verify plus Push können länger als 5 s dauern. Der MCP-Pfad meldet deshalb Progress
über `ProgressReporter`, sonst bricht der MCP-Client nach 300 s Leerlauf ab.
## 4. Merge-Helper
Die Prompts in `PromptFiles` (merge und merge_final) bekommen einen Firmen-Repo-Absatz:
- `review_task approve` liefert `pr_created`/`pr_updated` mit URL.
- `verify_merges` entfällt, weil das Verify bereits im Approve läuft.
- Die Abschluss-Zusammenfassung listet die PR-Links auf.
- Die Regel „erst mergen, dann neu starten“ bleibt harmlos, da `main` sich nicht bewegt.
## 5. UI
- Der Settings-Reiter „Ticketsystem“ heißt künftig **„Externe Dienste“**. Er bekommt einen
zweiten Abschnitt **Azure DevOps** mit PAT-Feld, Test-Button und Löschen-Button, gespiegelt vom
Ticket-PAT.
- Neue Hub-Methoden: `GetAzureDevOpsSettings`, `SetAzureDevOpsToken`, `ClearAzureDevOpsToken`,
`TestAzureDevOpsConnection`. Neuer DI-Marker `AzureDevOpsPatStore`.
- Der Test braucht ein Firmen-Repo als Kontext: Er nimmt die erste Liste, deren `origin` passt,
und ruft `GET …/repositories/<repo>` auf.
- Im Detail-Pane heißt der Approve-Button im Firmen-Repo **„Draft-PR erstellen“**. Er ist weiter
hinter „Diff öffnen“ gegated, und das Ziel-Dropdown ist ausgeblendet. Ist eine PR-URL gesetzt,
erscheint ein Link und der Button heißt „PR aktualisieren“.
- Locale-Keys kommen in `en.json` und `de.json` gleichzeitig hinzu.
## 6. Bewusst draußen
- PR-Status-Polling bzw. automatisches `Done` nach dem ADO-Merge.
- Andere PR-Provider als Azure DevOps. `company_remote_pattern` schaltet nur das Schema um, die
PR-API ist ADO-spezifisch.
- Aufräumen von Worktree und lokalem Branch, wenn ein Firmen-Task auf `Done` gesetzt wird. Das
geht über die bestehende Worktree-Übersicht.
- `git fetch` in privaten Repos. Die Basis bleibt dort lokal.
- Eine UI für `company_remote_pattern`.
## 7. Tests
Keine Tests mit der echten Claude-CLI und keine gegen das echte ADO.
- **Naming:** Typ-Mapping, Nummernregel (Ticket, privat, Firma ohne Ticket), Umlaute,
Kollision mit einem fremden Branch (der Branch überlebt, das Suffix greift), Self-Heal löscht
nur DB-eigene Branches. Die betroffenen Asserts in `WorktreeManagerTests` (`:69`, `:131/138`,
`:160`, `:314`) passe ich an.
- **Unit-Branch** (echtes git, Worker.Tests):
- Ein Child startet vom Head des Unit-Branches.
- Zwei serielle Children werden per fast-forward absorbiert, und Child 2 sieht die Dateien von
Child 1.
- Ein nicht fast-forwardbares Child wird `Failed`.
- Ein Planning-Parent bekommt seinen Worktree bei `QueuePlanAsync`.
- **Approve Firma:**
- Ein lokales Bare-Repo dient als `origin`, und das Muster wird auf dessen Pfad gesetzt.
- `AzureDevOpsClient` wird gefakt: PR anlegen, vorhandenen PR wiederverwenden, Verify rot
heißt kein Push, PR-Fehler nach dem Push.
- **Remote-Parsing:** die Fälle aus dem `-SelfTest` des Skripts.
- **Basis:**
- `base_branch` gesetzt: Der Unit-Root startet davon, auch wenn ein anderer Branch ausgecheckt
ist.
- Firma: Nach dem Fetch startet der Unit-Root von `origin/<basis>`. Das testet ein Bare-Repo,
das einen Commit voraus ist.
- Der lokale Basis-Branch wird nachgezogen, ein schmutziger Checkout bleibt unangetastet.
- Ein Fetch-Fehler bricht den Task nicht.
- `SetConfigAsync`, `set_list_config` und `UpdateListConfig` erhalten `base_branch`.
- **Manueller Smoke-Test (Mika):** PAT eintragen, Test-Button. Ein Ticket-Task in einem echten
Firmen-Repo bis zum Draft-PR durchspielen, danach eine Planning-Unit mit 2 Children.