docs(plan): unit branch part 1 - naming and per-list base branch
This commit is contained in:
@@ -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 `<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
|
||||
```
|
||||
Reference in New Issue
Block a user