Files
ClaudeDo/docs/superpowers/plans/2026-10-01-unit-branch-1-naming-base-branch.md
T

929 lines
46 KiB
Markdown

# 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 `<typ>/<nummer>-<slug>` 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 <projekt> -c Release --no-build --filter "<filter>"` — nie die volle Suite.
- Commit: `git add <pfade>` explizit, dann `git commit -m "..." -- <pfade>` (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
/// <summary>Teilstring der <c>origin</c>-URL, an dem ein Firmen-Repo erkannt wird. Nur per Hand editierbar.</summary>
[JsonPropertyName("company_remote_pattern")]
public string CompanyRemotePattern { get; set; } = "/_git/";
```
`GitService.cs`, neben `ListLocalBranchesAsync`:
```csharp
/// <summary>URL des Remotes <paramref name="remote"/>, oder null wenn es keins gibt.</summary>
public async Task<string?> 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;
/// <summary>Erkennt Firmen-Repos am <c>origin</c>-Remote; das Muster kommt aus <c>WorkerConfig.CompanyRemotePattern</c>.</summary>
public static class CompanyRepo
{
/// <summary>True wenn <paramref name="remoteUrl"/> das (nicht-leere) Muster enthält, ohne Groß-/Kleinschreibung.</summary>
public static bool Matches(string? remoteUrl, string? pattern) =>
!string.IsNullOrWhiteSpace(remoteUrl) && !string.IsNullOrWhiteSpace(pattern)
&& remoteUrl.Contains(pattern, StringComparison.OrdinalIgnoreCase);
/// <summary>Liest <c>origin</c> live (kein Cache — ein Remote kann sich jederzeit ändern).</summary>
public static async Task<bool> 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
/// <summary>
/// Branch leaf and folder name: <c>{nummer}-{slug}</c>. 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.
/// </summary>
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}";
}
/// <summary>Branch-Präfix nach work-conventions: <c>feat</c> → <c>feature</c>, Typen ohne eigenes Präfix → <c>chore</c>.</summary>
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<WorktreeManager>.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
/// <summary>Ticket-Id des Tasks, sonst die seines Parents (Children erben das Ticket ihrer Unit).</summary>
private async Task<int?> 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 <type>/<number>-<slug>" -- 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
/// <summary>True für einen Pfad unter <c>.claudedo-worktrees</c> oder dem Central-Root, außer Planning-Session-Worktrees.</summary>
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<string> 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<ListConfigEntity?> 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
/// <summary>
/// Basis neuer Worktrees und Default-Merge-/PR-Ziel (z.B. <c>dev</c>). Null = ausgecheckter Branch
/// (privat) bzw. Default-Branch des Remotes (Firma). ⚠️ SetConfigAsync kopiert verbatim.
/// </summary>
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<string>(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<string>? 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<string>? 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
/// <summary>Leeres Ziel → <c>list_config.base_branch</c>, sonst der ausgecheckte Branch des Main-Checkouts.</summary>
public async Task<string> 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
<StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.baseBranch}"/>
<TextBox Text="{Binding BaseBranch, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
PlaceholderText="{loc:Tr modals.listSettings.baseBranchPlaceholder}"/>
<TextBlock Text="{loc:Tr modals.listSettings.baseBranchHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
```
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 <ui-testdatei>
git commit -m "feat(ui): base branch field in list settings, real branch names in dialogs" -- <dieselben pfade>
```
**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}` → `<typ>/<nummer>-<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
```