diff --git a/docs/superpowers/plans/2026-10-01-unit-branch-1-naming-base-branch.md b/docs/superpowers/plans/2026-10-01-unit-branch-1-naming-base-branch.md new file mode 100644 index 00000000..c2ee5aa4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-unit-branch-1-naming-base-branch.md @@ -0,0 +1,928 @@ +# Unit-Branch Teil 1: Branch-Naming + Basis-Branch pro Liste — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Task-Branches heißen `/-` statt `claudedo/{nr}-{slug}`, Firmen-Repos werden über ein konfigurierbares Remote-Muster erkannt, und jede Liste kann einen Basis-Branch (z.B. `dev`) festlegen, der Basis neuer Worktrees und Default-Merge-Ziel ist. + +**Architecture:** Erkennung = statischer Helper `CompanyRepo` über `git remote get-url origin` + `WorkerConfig.CompanyRemotePattern`. Naming bleibt in `WorktreeManager` (`NameFor` + `BranchTypeFor`). `list_config.base_branch` ist eine neue nullable Spalte, tri-state durch alle Writer gereicht; `WorktreeManager.ResolveBaseCommitAsync` und `TaskMergeService.ResolveTargetAsync` lesen sie. + +**Tech Stack:** .NET 8, EF Core (SQLite), xUnit mit echtem git, Avalonia 12. + +**Spec:** `docs/superpowers/specs/2026-10-01-unit-branch-draft-pr-design.md` (Abschnitt 1 + Basis-Branch). Teil 2 (Unit-Branch/Children) und Teil 3 (Firmen-Modus: Fetch, Push, Draft-PR) folgen als eigene Pläne. + +**Abweichung von der Spec (bewusst):** Der Self-Heal in `WorktreeManager.CreateAsync` bekommt **keinen** zusätzlichen DB-Ownership-Guard. Er feuert nur, wenn auch der Id-suffixte Name schon existiert — und der enthält die Task-Id, gehört also per Konstruktion diesem Task. Ein fremder Branch mit dem Klarnamen wird schon vom Kollisionscheck auf das Suffix umgelenkt. Nötig ist nur, dass der Kollisionscheck den **vollen neuen** Branchnamen prüft (Task 3). + +**Konventionen für jede Task:** +- Build: `dotnet build ClaudeDo.slnx -c Release` (nie Debug — ein laufender Worker lockt es). +- Tests: `dotnet test -c Release --no-build --filter ""` — nie die volle Suite. +- Commit: `git add ` explizit, dann `git commit -m "..." -- ` (geteilter Checkout, nie `git add -A`). Kein Co-Authored-Trailer. + +--- + +## Dateiübersicht + +| Datei | Änderung | +|---|---| +| `src/ClaudeDo.Worker/Config/WorkerConfig.cs` | + `CompanyRemotePattern` (`company_remote_pattern`, Default `/_git/`) | +| `src/ClaudeDo.Data/Git/GitService.cs` | + `GetRemoteUrlAsync` | +| `src/ClaudeDo.Worker/Git/CompanyRepo.cs` | neu: `Matches`, `IsCompanyAsync` | +| `src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs` | `ToSlug` löst Umlaute auf | +| `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` | `NameFor(task, ticketId, isCompany)`, `BranchTypeFor`, neuer Branchname, Ticket-Auflösung, Basis-Branch | +| `src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs` | Pfad- statt Präfix-Filter | +| `src/ClaudeDo.Data/Models/ListConfigEntity.cs`, `Configuration/ListConfigEntityConfiguration.cs`, `Repositories/ListRepository.cs`, Migration | + `BaseBranch` | +| `src/ClaudeDo.Data/Wire.cs` | `UpdateListConfigDto` / `ListConfigDto` + `BaseBranch` | +| `src/ClaudeDo.Worker/Hub/WorkerHub.cs` | `UpdateListConfig` / `GetListConfig` | +| `src/ClaudeDo.Worker/External/ConfigMcpTools.cs` | erhält `BaseBranch` **und** `PowerUseAllowed` (Bestandsbug), `get_list_config` liefert `baseBranch` | +| `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` | `ResolveTargetAsync`, leeres Ziel zentral in `MergeAsync` aufgelöst | +| `src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs`, `ViewModels/Modals/ListSettingsModalViewModel.cs`, `Views/Modals/ListSettingsModalView.axaml`, `locales/*.json` | Basis-Branch-Feld | +| `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` | echte Branchnamen in Dialogen, kein `← main` | +| `src/ClaudeDo.Worker/CLAUDE.md`, `src/ClaudeDo.Data/CLAUDE.md` | Doku | + +--- + +### Task 1: Firmen-Repo-Erkennung + +**Files:** +- Modify: `src/ClaudeDo.Worker/Config/WorkerConfig.cs` (nach `TicketApiBaseUrl`, ~Zeile 71) +- Modify: `src/ClaudeDo.Data/Git/GitService.cs` (neben `ListLocalBranchesAsync`, ~Zeile 404) +- Create: `src/ClaudeDo.Worker/Git/CompanyRepo.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Git/CompanyRepoTests.cs` + +- [ ] **Step 1: Failing test schreiben** + +```csharp +using ClaudeDo.Data.Git; +using ClaudeDo.Worker.Git; +using ClaudeDo.Worker.Tests.Infrastructure; + +namespace ClaudeDo.Worker.Tests.Git; + +public sealed class CompanyRepoTests : IDisposable +{ + private readonly GitRepoFixture _repo = new(); + public void Dispose() => _repo.Dispose(); + + [Theory] + [InlineData("https://srv.local/Coll/Proj/_git/Repo", "/_git/", true)] + [InlineData("https://mika@srv.local/tfs/Coll/Proj/_git/Mein%20Repo.git", "/_git/", true)] + [InlineData("ssh://git@git.kuns.dev:22222/kuns/ClaudeDo.git", "/_git/", false)] + [InlineData(null, "/_git/", false)] + [InlineData("https://srv.local/Coll/Proj/_git/Repo", "", false)] + [InlineData("https://GITHUB.com/acme/repo.git", "github.com/acme", true)] + public void Matches(string? url, string pattern, bool expected) + => Assert.Equal(expected, CompanyRepo.Matches(url, pattern)); + + [Fact] + public async Task IsCompanyAsync_reads_origin() + { + if (!GitRepoFixture.IsGitAvailable()) return; + var git = new GitService(); + Assert.False(await CompanyRepo.IsCompanyAsync(git, _repo.RepoDir, "/_git/", CancellationToken.None)); + + GitRepoFixture.RunGit(_repo.RepoDir, "remote", "add", "origin", "https://srv.local/Coll/Proj/_git/Repo"); + + Assert.True(await CompanyRepo.IsCompanyAsync(git, _repo.RepoDir, "/_git/", CancellationToken.None)); + Assert.Equal("https://srv.local/Coll/Proj/_git/Repo", await git.GetRemoteUrlAsync(_repo.RepoDir)); + } +} +``` + +- [ ] **Step 2: Test laufen lassen — muss am Compiler scheitern** + +Run: `dotnet build ClaudeDo.slnx -c Release` +Expected: FAIL, `CompanyRepo` / `GetRemoteUrlAsync` existieren nicht. + +- [ ] **Step 3: Implementieren** + +`WorkerConfig.cs`, direkt nach der `TicketApiBaseUrl`-Property: + +```csharp + /// Teilstring der origin-URL, an dem ein Firmen-Repo erkannt wird. Nur per Hand editierbar. + [JsonPropertyName("company_remote_pattern")] + public string CompanyRemotePattern { get; set; } = "/_git/"; +``` + +`GitService.cs`, neben `ListLocalBranchesAsync`: + +```csharp + /// URL des Remotes , oder null wenn es keins gibt. + public async Task GetRemoteUrlAsync(string repoDir, string remote = "origin", CancellationToken ct = default) + { + var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["remote", "get-url", remote], ct); + return exitCode == 0 && !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : null; + } +``` + +`src/ClaudeDo.Worker/Git/CompanyRepo.cs`: + +```csharp +using ClaudeDo.Data.Git; + +namespace ClaudeDo.Worker.Git; + +/// Erkennt Firmen-Repos am origin-Remote; das Muster kommt aus WorkerConfig.CompanyRemotePattern. +public static class CompanyRepo +{ + /// True wenn das (nicht-leere) Muster enthält, ohne Groß-/Kleinschreibung. + public static bool Matches(string? remoteUrl, string? pattern) => + !string.IsNullOrWhiteSpace(remoteUrl) && !string.IsNullOrWhiteSpace(pattern) + && remoteUrl.Contains(pattern, StringComparison.OrdinalIgnoreCase); + + /// Liest origin live (kein Cache — ein Remote kann sich jederzeit ändern). + public static async Task IsCompanyAsync(GitService git, string workingDir, string? pattern, CancellationToken ct) => + Matches(await git.GetRemoteUrlAsync(workingDir, "origin", ct), pattern); +} +``` + +- [ ] **Step 4: Build + Test** + +Run: `dotnet build ClaudeDo.slnx -c Release` → 0 Fehler. +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~CompanyRepoTests"` +Expected: PASS (7 Tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Config/WorkerConfig.cs src/ClaudeDo.Data/Git/GitService.cs src/ClaudeDo.Worker/Git/CompanyRepo.cs tests/ClaudeDo.Worker.Tests/Git/CompanyRepoTests.cs +git commit -m "feat(worker): detect company repos by origin remote pattern" -- src/ClaudeDo.Worker/Config/WorkerConfig.cs src/ClaudeDo.Data/Git/GitService.cs src/ClaudeDo.Worker/Git/CompanyRepo.cs tests/ClaudeDo.Worker.Tests/Git/CompanyRepoTests.cs +``` + +--- + +### Task 2: Naming-Bausteine (Typ, Nummer, Umlaute) + +**Files:** +- Modify: `src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs:49-60` (`ToSlug`) +- Modify: `src/ClaudeDo.Worker/Runner/WorktreeManager.cs:41-47` (`NameFor`) + neues `BranchTypeFor` +- Test: `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs:82-89` (Theory ersetzen), `tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs` + +- [ ] **Step 1: Tests anpassen/ergänzen** + +In `WorktreeManagerTests.cs` die Theory `NameFor_NumberPlusCappedTitleSlug` (Zeilen 82-89) ersetzen durch: + +```csharp + [Theory] + [InlineData("Fix: Löschen des Tasks!", null, false, "7-fix-loeschen-des-tasks")] + [InlineData("äöü", null, false, "7-aeoeue")] + [InlineData("a very long title that goes well beyond the forty char cap", null, false, "7-a-very-long-title-that-goes-well-beyond")] + [InlineData("test task", 412, false, "412-test-task")] + [InlineData("test task", 412, true, "412-test-task")] + [InlineData("test task", null, true, "test-task")] + [InlineData("!!!", null, true, "7")] + public void NameFor_NumberRules(string title, int? ticketId, bool isCompany, string expected) + { + var task = new TaskEntity { Id = "x", ListId = "y", Title = title, Number = 7, CreatedAt = DateTime.UtcNow }; + Assert.Equal(expected, WorktreeManager.NameFor(task, ticketId, isCompany)); + } + + [Theory] + [InlineData("feat", "feature")] + [InlineData("fix", "fix")] + [InlineData("refactor", "refactor")] + [InlineData("docs", "docs")] + [InlineData("test", "test")] + [InlineData("chore", "chore")] + [InlineData("ci", "chore")] + [InlineData("perf", "chore")] + [InlineData(" FEAT ", "feature")] + [InlineData("", "chore")] + public void BranchTypeFor_maps_commit_types(string commitType, string expected) + => Assert.Equal(expected, WorktreeManager.BranchTypeFor(commitType)); +``` + +In `CommitMessageBuilderTests.cs` einen Test ergänzen: + +```csharp + [Fact] + public void ToSlug_transliterates_umlauts() + { + Assert.Equal("aerger-ueber-groesse-strasse", CommitMessageBuilder.ToSlug("Ärger über Größe Straße")); + } +``` + +- [ ] **Step 2: Build — muss scheitern** + +Run: `dotnet build ClaudeDo.slnx -c Release` +Expected: FAIL, `NameFor` hat keine 3-Parameter-Überladung, `BranchTypeFor` fehlt. + +- [ ] **Step 3: Implementieren** + +`CommitMessageBuilder.ToSlug` — erste Zeile ersetzen: + +```csharp + // Umlaute auflösen statt strippen: "Löschen" -> "loeschen", nicht "lschen". + var lower = name.ToLowerInvariant() + .Replace("ä", "ae").Replace("ö", "oe").Replace("ü", "ue").Replace("ß", "ss"); +``` + +`WorktreeManager.cs` — `NameFor` (Zeilen 41-47) ersetzen durch: + +```csharp + /// + /// Branch leaf and folder name: {nummer}-{slug}. Nummer = Ticket-Id, sonst die Task-Nummer — + /// außer im Firmen-Repo ohne Ticket, dort sähe sie wie eine Ticket-Id aus und entfällt. + /// + public static string NameFor(TaskEntity task, int? ticketId, bool isCompanyRepo) + { + var titleSlug = CommitMessageBuilder.ToSlug(task.Title); + if (titleSlug.Length > 40) titleSlug = titleSlug[..40].TrimEnd('-'); + var number = ticketId?.ToString() ?? (isCompanyRepo ? null : task.Number.ToString()); + if (titleSlug.Length == 0) return number ?? task.Number.ToString(); + return number is null ? titleSlug : $"{number}-{titleSlug}"; + } + + /// Branch-Präfix nach work-conventions: feat → feature, Typen ohne eigenes Präfix → chore. + public static string BranchTypeFor(string? commitType) + { + var t = (commitType ?? "").Trim().ToLowerInvariant(); + return t switch + { + "feat" => "feature", + "fix" or "refactor" or "docs" or "test" or "chore" => t, + _ => "chore", + }; + } +``` + +In `CreateAsync` vorerst nur den Aufruf kompilierbar machen (Zeile 62): `var name = NameFor(task, null, false);` — die echte Verdrahtung macht Task 3. + +Die übrigen `NameFor(task)`-Aufrufe in `WorktreeManagerTests.cs` (Zeilen 131, 137, 160, 314, 320) auf `WorktreeManager.NameFor(task, null, false)` umstellen — sonst kompiliert das Testprojekt nicht. Die `claudedo/`-Literale dort bleiben in diesem Task stehen (Task 3 ändert sie). + +- [ ] **Step 4: Build + Test** + +Run: `dotnet build ClaudeDo.slnx -c Release` → 0 Fehler. +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~WorktreeManagerTests|FullyQualifiedName~CommitMessageBuilderTests"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs src/ClaudeDo.Worker/Runner/WorktreeManager.cs tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs +git commit -m "feat(worker): branch type and number rules for task branches" -- src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs src/ClaudeDo.Worker/Runner/WorktreeManager.cs tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs +``` + +--- + +### Task 3: `CreateAsync` nutzt das neue Schema + +**Files:** +- Modify: `src/ClaudeDo.Worker/Runner/WorktreeManager.cs:49-90` (`CreateAsync`) + neue private `ResolveTicketIdAsync` +- Test: `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs` + +- [ ] **Step 1: Tests anpassen + neue Tests** + +Bestehende Literale umstellen (der Test-Task hat `CommitType = "chore"`, also Präfix `chore/`): +- Zeile 69: `Assert.StartsWith($"chore/{task.Number}-test-task", ctx.BranchName);` +- Zeile 131: `var foreign = $"chore/{WorktreeManager.NameFor(task, null, false)}";` +- Zeile 138: `Assert.Equal($"chore/{suffixed}", ctx.BranchName);` +- Zeile 160: `var branchName = $"chore/{WorktreeManager.NameFor(task, null, false)}";` +- Zeile 314: `var branchName = $"chore/{WorktreeManager.NameFor(task, null, false)}";` + +Neue Tests in derselben Klasse (vor `MakeEntities`): + +```csharp + [Fact] + public async Task CreateAsync_CompanyRepoWithTicket_UsesTicketIdAndFeaturePrefix() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = CreateRepo(); + GitRepoFixture.RunGit(repo.RepoDir, "remote", "add", "origin", "https://srv.local/Coll/Proj/_git/Repo"); + var (task, list) = MakeEntities(repo.RepoDir); + task.CommitType = "feat"; + task.TicketRef = "bandel:412"; + var (mgr, _) = await CreateManagerAsync(task, list); + + var ctx = await mgr.CreateAsync(task, list, CancellationToken.None); + _worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath)); + + Assert.StartsWith("feature/412-test-task", ctx.BranchName); + } + + [Fact] + public async Task CreateAsync_CompanyRepoWithoutTicket_OmitsNumber() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = CreateRepo(); + GitRepoFixture.RunGit(repo.RepoDir, "remote", "add", "origin", "https://srv.local/Coll/Proj/_git/Repo"); + var (task, list) = MakeEntities(repo.RepoDir); + var (mgr, _) = await CreateManagerAsync(task, list); + + var ctx = await mgr.CreateAsync(task, list, CancellationToken.None); + _worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath)); + + Assert.StartsWith("chore/test-task", ctx.BranchName); + Assert.DoesNotContain($"/{task.Number}-", ctx.BranchName); + } + + [Fact] + public async Task CreateAsync_ChildWithoutTicket_UsesParentTicket() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = CreateRepo(); + var (child, list) = MakeEntities(repo.RepoDir); + var parent = new TaskEntity + { + Id = Guid.NewGuid().ToString(), ListId = list.Id, Title = "parent", CommitType = "feat", + TicketRef = "bandel:77", CreatedAt = DateTime.UtcNow, + }; + child.ParentTaskId = parent.Id; + + var db = new DbFixture(); + _dbFixtures.Add(db); + using (var seedCtx = db.CreateContext()) + { + await new ListRepository(seedCtx).AddAsync(list); + await new TaskRepository(seedCtx).AddAsync(parent); + await new TaskRepository(seedCtx).AddAsync(child); + } + var mgr = new WorktreeManager(new GitService(), db.CreateFactory(), + new WorkerConfig { WorktreeRootStrategy = "sibling" }, NullLogger.Instance); + + var ctx = await mgr.CreateAsync(child, list, CancellationToken.None); + _worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath)); + + Assert.StartsWith("chore/77-test-task", ctx.BranchName); + } +``` + +- [ ] **Step 2: Test laufen lassen — muss scheitern** + +Run: `dotnet build ClaudeDo.slnx -c Release && dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~WorktreeManagerTests"` +Expected: FAIL — Branches heißen noch `claudedo/…`. + +- [ ] **Step 3: Implementieren** + +In `CreateAsync` Zeile 62 (`var name = NameFor(task, null, false);`) ersetzen durch: + +```csharp + var isCompany = await CompanyRepo.IsCompanyAsync(_git, workingDir, _cfg.CompanyRemotePattern, ct); + var name = NameFor(task, await ResolveTicketIdAsync(task, ct), isCompany); + var branchType = BranchTypeFor(task.CommitType); +``` + +Kollisionscheck + Branchname (Zeilen 80-89) ersetzen durch: + +```csharp + // Numbers restart after a DB reset, and without the claudedo/ prefix a taken name may also be + // the user's own branch — the self-heal below would force-delete it, the id suffix keeps it untouched. + if (Directory.Exists(Path.Combine(parentDir, name)) + || (await _git.ListLocalBranchesAsync(workingDir, ct)).Contains($"{branchType}/{name}")) + { + var id = task.Id.Replace("-", ""); + name = $"{name}-{id[..Math.Min(8, id.Length)]}"; + } + + var branchName = $"{branchType}/{name}"; +``` + +Neue private Methode (vor `ResolveBaseCommitAsync`): + +```csharp + /// Ticket-Id des Tasks, sonst die seines Parents (Children erben das Ticket ihrer Unit). + private async Task ResolveTicketIdAsync(TaskEntity task, CancellationToken ct) + { + var own = TicketStatusMap.ParseBandelTicketId(task.TicketRef); + if (own is not null || task.ParentTaskId is null) return own; + using var ctx = _dbFactory.CreateDbContext(); + var parentRef = await ctx.Tasks.AsNoTracking() + .Where(t => t.Id == task.ParentTaskId) + .Select(t => t.TicketRef) + .FirstOrDefaultAsync(ct); + return TicketStatusMap.ParseBandelTicketId(parentRef); + } +``` + +Usings ergänzen: `using ClaudeDo.Worker.Tickets;` (für `TicketStatusMap`). `ClaudeDo.Worker.Git` ist schon importiert. + +- [ ] **Step 4: Build + Test** + +Run: `dotnet build ClaudeDo.slnx -c Release && dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~WorktreeManagerTests|FullyQualifiedName~InteractiveLaunchSpecServiceTests|FullyQualifiedName~TaskRunnerTests"` +Expected: PASS. Schlägt ein Test fehl, der einen `claudedo/{nummer}-…`-Namen **erwartet** (nicht nur als Fixture seedet), auf `chore/…` umstellen und im Bericht nennen. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Runner/WorktreeManager.cs tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs +git commit -m "feat(worker): name task branches /-" -- src/ClaudeDo.Worker/Runner/WorktreeManager.cs tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs +``` + +--- + +### Task 4: UntrackedWorktreeRecovery ohne Präfix + +**Files:** +- Modify: `src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs` (bestehende Datei; per `Glob tests/**/UntrackedWorktreeRecoveryTests.cs` finden und dort ergänzen) + +- [ ] **Step 1: Failing test** + +```csharp + [Theory] + [InlineData(@"C:\src\.claudedo-worktrees\lager-app\412-fix", @"C:\Users\x\.claudeDo\worktrees", true)] + [InlineData(@"C:\src\.claudedo-worktrees\planning\abc123", @"C:\Users\x\.claudeDo\worktrees", false)] + [InlineData(@"C:\Users\x\.claudeDo\worktrees\lager-app\412-fix", @"C:\Users\x\.claudeDo\worktrees", true)] + [InlineData(@"C:\Users\x\.claudeDo\worktrees\planning\abc123", @"C:\Users\x\.claudeDo\worktrees", false)] + [InlineData(@"C:\src\my-own-worktree", @"C:\Users\x\.claudeDo\worktrees", false)] + public void IsTaskWorktreePath(string path, string centralRoot, bool expected) + { + if (!OperatingSystem.IsWindows()) return; + Assert.Equal(expected, UntrackedWorktreeRecovery.IsTaskWorktreePath(path, centralRoot)); + } +``` + +- [ ] **Step 2: Build — scheitert** (`IsTaskWorktreePath` fehlt). + +Run: `dotnet build ClaudeDo.slnx -c Release` → FAIL. + +- [ ] **Step 3: Implementieren** + +Konstante `BranchPrefix` löschen. Konstruktor um `WorkerConfig cfg` erweitern (Feld `_cfg`; `using ClaudeDo.Worker.Config;`). Klassen-Summary auf „ClaudeDo-Task-Worktrees (unter `.claudedo-worktrees` oder dem Central-Root, ohne Planning-Sessions)“ ändern. + +In `StartAsync` nach dem Laden der Listen den Central-Root bestimmen: + +```csharp + var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken); + var centralRoot = !string.IsNullOrWhiteSpace(settings.CentralWorktreeRoot) + ? settings.CentralWorktreeRoot + : _cfg.CentralWorktreeRoot; +``` + +Den Filter `claudedoPaths` ersetzen: + +```csharp + var claudedoPaths = registered + .Where(w => w.Branch is not null && IsTaskWorktreePath(w.Path, centralRoot)) + .Select(w => w.Path); +``` + +Neue statische Methode (neben `FindUntrackedPaths`): + +```csharp + /// True für einen Pfad unter .claudedo-worktrees oder dem Central-Root, außer Planning-Session-Worktrees. + public static bool IsTaskWorktreePath(string path, string centralRoot) + { + var sep = Path.DirectorySeparatorChar; + var full = Normalize(path); + string? rest = null; + + var central = Normalize(centralRoot); + if (full.StartsWith(central + sep, StringComparison.OrdinalIgnoreCase)) + rest = full[(central.Length + 1)..]; + else + { + var marker = $"{sep}.claudedo-worktrees{sep}"; + var idx = full.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (idx >= 0) rest = full[(idx + marker.Length)..]; + } + + return rest is not null && !rest.StartsWith($"planning{sep}", StringComparison.OrdinalIgnoreCase); + } +``` + +- [ ] **Step 4: Build + Test** + +Run: `dotnet build ClaudeDo.slnx -c Release && dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~UntrackedWorktreeRecovery"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs +git commit -m "refactor(worker): detect untracked worktrees by path instead of branch prefix" -- src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs +``` + +(Pfad der Testdatei an den tatsächlichen Fundort anpassen.) + +--- + +### Task 5: `list_config.base_branch` durch alle Writer + +**Files:** +- Modify: `src/ClaudeDo.Data/Models/ListConfigEntity.cs`, `src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs`, `src/ClaudeDo.Data/Repositories/ListRepository.cs:161-180` +- Create: Migration `AddListBaseBranch` (generiert) +- Modify: `src/ClaudeDo.Data/Wire.cs:111,115` +- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:577-647` +- Modify: `src/ClaudeDo.Worker/External/ConfigMcpTools.cs:13,53-59,104-139` +- Test: `tests/ClaudeDo.Worker.Tests/Hub/ListConfigBaseBranchTests.cs` + +- [ ] **Step 1: Failing tests** + +`tests/ClaudeDo.Worker.Tests/Hub/ListConfigBaseBranchTests.cs` — Hub-Aufbau 1:1 aus `tests/ClaudeDo.Worker.Tests/Tickets/ListConfigTicketProjectTests.cs` (gleicher `CreateHub`, `SeedListAsync`, `ReadConfigAsync`): + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Tests.Infrastructure; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Hub; + +/// BaseBranch und PowerUseAllowed sitzen in der SetConfigAsync-kopiert-verbatim-Falle: jeder Writer, +/// der sie nicht durchreicht, setzt sie still zurück. +public sealed class ListConfigBaseBranchTests : IDisposable +{ + private readonly DbFixture _db = new(); + public void Dispose() => _db.Dispose(); + + private WorkerHub CreateHub() + { + var factory = _db.CreateFactory(); + var broadcaster = new HubBroadcaster(new CapturingHubContext()); + var hub = new WorkerHub( + null!, null!, null!, null!, broadcaster, factory, + null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")), + new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); + hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); + hub.Context = new FakeHubCallerContext(); + return hub; + } + + private async Task SeedListAsync(string? baseBranch, bool powerUse = false) + { + var listId = Guid.NewGuid().ToString(); + await using var ctx = _db.CreateContext(); + var repo = new ListRepository(ctx); + await repo.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + await repo.SetConfigAsync(new ListConfigEntity { ListId = listId, BaseBranch = baseBranch, PowerUseAllowed = powerUse }); + return listId; + } + + private async Task ReadConfigAsync(string listId) + { + await using var ctx = _db.CreateContext(); + return await new ListRepository(ctx).GetConfigAsync(listId); + } + + [Fact] + public async Task set_list_config_preserves_BaseBranch_and_PowerUseAllowed() + { + var listId = await SeedListAsync("dev", powerUse: true); + var factory = _db.CreateFactory(); + await using var ctx = factory.CreateDbContext(); + var tool = new ConfigMcpTools(new ListRepository(ctx), new TaskRepository(ctx), + new HubBroadcaster(new CapturingHubContext()), factory); + + await tool.SetListConfig(listId, model: "opus", cancellationToken: CancellationToken.None); + + var cfg = await ReadConfigAsync(listId); + Assert.Equal("dev", cfg!.BaseBranch); + Assert.True(cfg.PowerUseAllowed); + } + + [Fact] + public async Task get_list_config_returns_BaseBranch() + { + var listId = await SeedListAsync("dev"); + var factory = _db.CreateFactory(); + await using var ctx = factory.CreateDbContext(); + var tool = new ConfigMcpTools(new ListRepository(ctx), new TaskRepository(ctx), + new HubBroadcaster(new CapturingHubContext()), factory); + + var result = await tool.GetListConfig(listId, CancellationToken.None); + + Assert.Equal("dev", result.Config!.BaseBranch); + } + + [Fact] + public async Task UpdateListConfig_null_keeps_BaseBranch() + { + var listId = await SeedListAsync("dev"); + await CreateHub().UpdateListConfig(new UpdateListConfigDto(listId, "opus", null, null)); + Assert.Equal("dev", (await ReadConfigAsync(listId))!.BaseBranch); + } + + [Fact] + public async Task UpdateListConfig_empty_clears_BaseBranch() + { + var listId = await SeedListAsync("dev"); + await CreateHub().UpdateListConfig(new UpdateListConfigDto(listId, "opus", null, null, BaseBranch: "")); + Assert.Null((await ReadConfigAsync(listId))!.BaseBranch); + } + + [Fact] + public async Task UpdateListConfig_sets_BaseBranch_and_GetListConfig_returns_it() + { + var listId = await SeedListAsync(null); + var hub = CreateHub(); + await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null, BaseBranch: "dev")); + Assert.Equal("dev", (await hub.GetListConfig(listId))!.BaseBranch); + } +} +``` + +- [ ] **Step 2: Build — scheitert** (`BaseBranch` fehlt). + +- [ ] **Step 3: Implementieren** + +`ListConfigEntity.cs`, nach `PowerUseAllowed`: + +```csharp + /// + /// Basis neuer Worktrees und Default-Merge-/PR-Ziel (z.B. dev). Null = ausgecheckter Branch + /// (privat) bzw. Default-Branch des Remotes (Firma). ⚠️ SetConfigAsync kopiert verbatim. + /// + public string? BaseBranch { get; set; } +``` + +`ListConfigEntityConfiguration.cs`: `builder.Property(c => c.BaseBranch).HasColumnName("base_branch");` + +`ListRepository.SetConfigAsync`, im `else`-Zweig: `existing.BaseBranch = config.BaseBranch;` + +Migration erzeugen: + +```bash +dotnet ef migrations add AddListBaseBranch --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --configuration Release +``` + +Erwartet: neue Migration mit genau einem `AddColumn(name: "base_branch", table: "list_config", nullable: true)`. Steht mehr drin (fremde Snapshot-Drift), abbrechen und melden. + +`Wire.cs` — Kommentar über `UpdateListConfigDto` um eine Zeile ergänzen: `// BaseBranch: null = keep stored, "" = clear, value = set.` Dann beide Records erweitern: + +```csharp +public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool? PowerUseAllowed = null, string? BaseBranch = null); +``` + +```csharp +public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool PowerUseAllowed = false, string? BaseBranch = null); +``` + +`WorkerHub.UpdateListConfig` — nach der `powerUseAllowed`-Zeile: + +```csharp + // Tri-State wie oben: null = behalten, "" = löschen. + var baseBranch = dto.BaseBranch is null ? existing?.BaseBranch : dto.BaseBranch.NullIfBlank(); +``` + +Leer-Prüfung um `&& baseBranch is null` ergänzen, im `new ListConfigEntity { … }` `BaseBranch = baseBranch,` ergänzen. `GetListConfig`: `config.BaseBranch` als letztes Argument an `new ListConfigDto(…)` hängen. + +`ConfigMcpTools.cs`: +- Zeile 13: `public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns, string? VerifyCommand, string? BaseBranch = null);` +- `GetListConfig`: `new ListConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath, cfg.MaxTurns, cfg.VerifyCommand, cfg.BaseBranch)` +- `hasUnrelatedSettings` erweitern: + +```csharp + var hasUnrelatedSettings = existing is not null + && (existing.SessionSkills is not null + || existing.PermissionMode is not null || existing.TicketProjectId is not null + || existing.BaseBranch is not null || existing.PowerUseAllowed); +``` + +- im `SetConfigAsync(new ListConfigEntity { … })` ergänzen: `BaseBranch = existing?.BaseBranch, PowerUseAllowed = existing?.PowerUseAllowed ?? false,` +- `config = allCleared ? null : new ListConfigDto(m, sp, ap, mt, vc, existing?.BaseBranch);` + +- [ ] **Step 4: Build + Test** + +Run: `dotnet build ClaudeDo.slnx -c Release && dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~ListConfigBaseBranchTests|FullyQualifiedName~ListConfigTicketProjectTests|FullyQualifiedName~ListConfigHubTests|FullyQualifiedName~ExternalMcpToolSchemaTests"` +Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~Migration"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Data/Models/ListConfigEntity.cs src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs src/ClaudeDo.Data/Repositories/ListRepository.cs src/ClaudeDo.Data/Migrations src/ClaudeDo.Data/Wire.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/External/ConfigMcpTools.cs tests/ClaudeDo.Worker.Tests/Hub/ListConfigBaseBranchTests.cs +git commit -m "feat(data): per-list base branch, preserved by every config writer" -- src/ClaudeDo.Data/Models/ListConfigEntity.cs src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs src/ClaudeDo.Data/Repositories/ListRepository.cs src/ClaudeDo.Data/Migrations src/ClaudeDo.Data/Wire.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/External/ConfigMcpTools.cs tests/ClaudeDo.Worker.Tests/Hub/ListConfigBaseBranchTests.cs +``` + +--- + +### Task 6: Basis-Branch als Worktree-Basis und Merge-Ziel + +**Files:** +- Modify: `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` (`ResolveBaseCommitAsync`, Fallback-Zeile) +- Modify: `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` (`GetTargetsAsync` ~869, `PreviewAsync` ~910, `ApproveAndMergeAsync` ~1077, `MergeAsync` ~375) +- Test: `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs`, `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` + +- [ ] **Step 1: Failing tests** + +`WorktreeManagerTests.cs`: + +```csharp + [Fact] + public async Task CreateAsync_BaseBranchSet_BasesOnItEvenWhenAnotherBranchIsCheckedOut() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = CreateRepo(); + GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "dev"); + File.WriteAllText(Path.Combine(repo.RepoDir, "dev.txt"), "dev"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "dev work"); + var devHead = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); + GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "elsewhere", repo.BaseCommit); + + var (task, list) = MakeEntities(repo.RepoDir); + var (mgr, db) = await CreateManagerAsync(task, list); + using (var ctx0 = db.CreateContext()) + await new ListRepository(ctx0).SetConfigAsync(new ListConfigEntity { ListId = list.Id, BaseBranch = "dev" }); + + var ctx = await mgr.CreateAsync(task, list, CancellationToken.None); + _worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath)); + + Assert.Equal(devHead, ctx.BaseCommit); + } +``` + +`TaskMergeServiceTests.cs` — am vorhandenen Setup der Klasse orientieren (Repo + `SeedWorktree`-Helper, wie die übrigen Tests der Datei). Ein Test, der zeigt, dass `GetTargetsAsync` `BaseBranch` als Default liefert, auch wenn ein anderer Branch ausgecheckt ist: + +```csharp + [Fact] + public async Task GetTargetsAsync_prefers_list_base_branch() + { + // Setup wie die anderen Tests dieser Datei: Repo, Liste, Task mit Worktree (SeedWorktree). + // Zusätzlich: Branch "dev" anlegen (`git branch dev`), list_config.BaseBranch = "dev" setzen, + // Main-Checkout bleibt auf seinem Default-Branch. + // Assert: targets.DefaultBranch == "dev". + } +``` + +Der Implementer schreibt diesen Test konkret mit dem Fixture-Setup, das in `TaskMergeServiceTests.cs` schon für `GetTargetsAsync`/Merge-Tests verwendet wird (per `Grep "GetTargetsAsync" tests/ClaudeDo.Worker.Tests` den nächstliegenden bestehenden Test kopieren und um `BaseBranch` ergänzen). Ein zweiter Test: `ApproveAndMergeAsync(taskId, "")` mit `BaseBranch = "dev"` merged auf `dev` (`git log dev --oneline` enthält den Merge-Commit, der vorher ausgecheckte Branch nicht). + +- [ ] **Step 2: Tests laufen lassen — scheitern** + +Run: `dotnet build ClaudeDo.slnx -c Release && dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~BaseBranch|FullyQualifiedName~prefers_list_base_branch"` +Expected: FAIL. + +- [ ] **Step 3: Implementieren** + +`WorktreeManager.ResolveBaseCommitAsync` — die letzte Zeile `return await _git.RevParseHeadAsync(workingDir, ct);` ersetzen: + +```csharp + using var cfgCtx = _dbFactory.CreateDbContext(); + var baseBranch = await cfgCtx.ListConfigs.AsNoTracking() + .Where(c => c.ListId == task.ListId) + .Select(c => c.BaseBranch) + .FirstOrDefaultAsync(ct); + return string.IsNullOrWhiteSpace(baseBranch) + ? await _git.RevParseHeadAsync(workingDir, ct) + : await _git.RevParseAsync(workingDir, baseBranch, ct); +``` + +(Ein nicht existierender Branch wirft aus `RevParseAsync` mit „git rev-parse 'dev' failed“ — gewollt, der Lauf scheitert mit lesbarer Meldung.) + +`TaskMergeService` — neue öffentliche Methode (bei `GetTargetsAsync`): + +```csharp + /// Leeres Ziel → list_config.base_branch, sonst der ausgecheckte Branch des Main-Checkouts. + public async Task ResolveTargetAsync(ListEntity list, string? targetBranch, CancellationToken ct) + { + if (!string.IsNullOrWhiteSpace(targetBranch)) return targetBranch; + using var ctx = _dbFactory.CreateDbContext(); + var baseBranch = await ctx.ListConfigs.AsNoTracking() + .Where(c => c.ListId == list.Id) + .Select(c => c.BaseBranch) + .FirstOrDefaultAsync(ct); + return string.IsNullOrWhiteSpace(baseBranch) + ? await _git.GetCurrentBranchAsync(list.WorkingDir!, ct) + : baseBranch; + } +``` + +Aufrufer umstellen: +- `GetTargetsAsync`: `var current = await _git.GetCurrentBranchAsync(...)` → `var current = await ResolveTargetAsync(list, null, ct);` +- `PreviewAsync` (~910) und `ApproveAndMergeAsync` (~1077): die `var target = string.IsNullOrWhiteSpace(targetBranch) ? await _git.GetCurrentBranchAsync(...) : targetBranch;`-Blöcke → `var target = await ResolveTargetAsync(list, targetBranch, ct);` +- `MergeAsync`: direkt nachdem `list.WorkingDir` geprüft ist und **vor** dem `currentBranch`-Vergleich (~Zeile 414) `targetBranch = await ResolveTargetAsync(list, targetBranch, ct);` einfügen. Grund: `PlanningMergeOrchestrator.DrainAsync` reicht ein leeres Ziel (MCP `review_task` ohne `targetBranch`) unverändert durch, was heute in `git checkout ""` → Blocked endet. Mit der zentralen Auflösung ist das für alle Aufrufer behoben. + +- [ ] **Step 4: Build + Test** + +Run: `dotnet build ClaudeDo.slnx -c Release && dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~WorktreeManagerTests|FullyQualifiedName~TaskMergeServiceTests|FullyQualifiedName~PlanningMergeOrchestratorTests"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Runner/WorktreeManager.cs src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +git commit -m "feat(worker): use the list base branch as worktree base and default merge target" -- src/ClaudeDo.Worker/Runner/WorktreeManager.cs src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +``` + +--- + +### Task 7: UI — Basis-Branch-Feld + echte Branchnamen + +**Files:** +- Modify: `src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs:216-231` +- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs` (Property ~57, Load ~276, Save ~375) +- Modify: `src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml` (nach dem WorkingDir-StackPanel, ~Zeile 125) +- Modify: `src/ClaudeDo.Localization/locales/en.json`, `de.json` (Block `modals.listSettings`, nach `workingDirectoryPlaceholder`) +- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:670,933,1223,1385` +- Test: `tests/ClaudeDo.Ui.Tests` — bestehende ListSettingsModal-Tests per `Grep "ListSettingsModalViewModel" tests/ClaudeDo.Ui.Tests` finden + +- [ ] **Step 1: Failing test** + +In der vorhandenen ListSettingsModal-Testklasse (gleiche Fake-`IWorkerClient`-Mechanik wie die dortigen Save-Tests, die `UpdateListConfigDto` mitschneiden): + +```csharp + [Fact] + public async Task Save_sends_BaseBranch_explicitly() + { + // Arrange: VM wie in den Nachbartests laden; BaseBranch = "dev" setzen. + // Act: Save ausführen. + // Assert: der mitgeschnittene UpdateListConfigDto hat BaseBranch == "dev". + // Zweiter Fall: BaseBranch = "" → DTO.BaseBranch == "" (nicht null), damit Leeren wirklich löscht. + } +``` + +Der Implementer schreibt die beiden Fälle konkret gegen den Fake, den die Nachbartests (z.B. der PowerUseAllowed-Save-Test) verwenden. + +- [ ] **Step 2: Build/Test — scheitert** (`BaseBranch` fehlt im VM). + +- [ ] **Step 3: Implementieren** + +`AgentConfigEditorViewModel.SaveAsync`: Signatur um `string? baseBranch = null` erweitern und im List-Zweig durchreichen: + +```csharp + public async System.Threading.Tasks.Task SaveAsync( + string? verifyCommand = null, int? ticketProjectId = null, bool? powerUseAllowed = null, string? baseBranch = null) +``` + +```csharp + await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, permission, ticketProjectId, powerUseAllowed, baseBranch)); +``` + +`ListSettingsModalViewModel`: + +```csharp + // Basis neuer Worktrees und Default-Merge-Ziel; leer = ausgecheckter Branch. + [ObservableProperty] private string _baseBranch = ""; +``` + +Load (nach `VerifyCommand = …`): `BaseBranch = cfg?.BaseBranch ?? "";` +Save: `await Agent.SaveAsync(…, PowerUseAllowed, BaseBranch.Trim());` — immer explizit, `""` löscht (das Modal besitzt das Feld). + +View, nach dem WorkingDir-StackPanel: + +```xml + + + + + +``` + +Locales (beide Dateien, gleiche Keys, direkt nach `workingDirectoryPlaceholder`): + +en.json: +```json + "baseBranch": "Base branch", + "baseBranchPlaceholder": "(checked-out branch)", + "baseBranchHint": "New task branches start here and approved work merges back here, e.g. dev. Leave empty to use whatever branch is checked out.", +``` + +de.json: +```json + "baseBranch": "Basis-Branch", + "baseBranchPlaceholder": "(ausgecheckter Branch)", + "baseBranchHint": "Neue Task-Branches starten hier, und freigegebene Arbeit wird hierhin gemergt, z.B. dev. Leer = der gerade ausgecheckte Branch.", +``` + +Nach dem Editieren beide JSON-Dateien separat parsen (`python -c "import json;json.load(open('src/ClaudeDo.Localization/locales/de.json',encoding='utf-8'))"`), weil ein kaputtes `de.json` vom LocaleStore still geschluckt wird. + +`DetailsIslandViewModel`: +- Zeilen 670 und 933: `BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;` → `BranchLine = entity.Worktree?.BranchName;` (das Ziel ist nicht mehr immer `main`). +- Zeilen 1223 und 1385: `var branchName = $"claudedo/{Task.Id.Replace("-", "")}";` → `var branchName = BranchLine ?? "the task branch";` + +- [ ] **Step 4: Build + Test** + +Run: `dotnet build ClaudeDo.slnx -c Release && dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~ListSettings|FullyQualifiedName~DetailsIsland"` +Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release --no-build` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +git commit -m "feat(ui): base branch field in list settings, real branch names in dialogs" -- +``` + +**Sichtprüfung offen (Mika):** Feld im Listen-Modal, Branchzeile im Detail-Pane. + +--- + +### Task 8: Doku + +**Files:** +- Modify: `src/ClaudeDo.Worker/CLAUDE.md` (Key Components → WorktreeManager; Notes → „Task worktree branches follow…“; External → „Task worktree branches and folders are named…“; Config → `company_remote_pattern`) +- Modify: `src/ClaudeDo.Data/CLAUDE.md` (list_config: `base_branch`) + +- [ ] **Step 1: Texte ersetzen** + +Überall `claudedo/{number}-{title-slug}` → `/-` mit der Regel: Typ aus `WorktreeManager.BranchTypeFor` (`feat`→`feature`, sonst `fix/refactor/docs/test/chore`, Rest `chore`); Nummer = Ticket-Id (eigene oder des Parents), sonst Task-Nummer, im Firmen-Repo ohne Ticket keine. Planning bleibt `claudedo/planning/{guid}`. + +Config-Abschnitt ergänzen: `` `company_remote_pattern` (`/_git/`) — Teilstring der origin-URL, an dem `CompanyRepo` ein Firmen-Repo erkennt; nur per Hand editierbar. `` + +Data/CLAUDE.md: `list_config.base_branch` — Basis neuer Worktrees + Default-Merge-Ziel, tri-state in `UpdateListConfigDto`, ⚠️ verbatim-copy-Falle wie `ticket_project_id`. + +- [ ] **Step 2: Commit** + +```bash +git add src/ClaudeDo.Worker/CLAUDE.md src/ClaudeDo.Data/CLAUDE.md +git commit -m "docs(worker): document new branch naming and per-list base branch" -- src/ClaudeDo.Worker/CLAUDE.md src/ClaudeDo.Data/CLAUDE.md +```