diff --git a/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs b/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs index 73c90b72..b9ab7f42 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs @@ -133,15 +133,29 @@ public sealed class PlanningAggregator var current = await _git.GetCurrentBranchAsync(repoDir, ct); if (string.Equals(current, branch, StringComparison.Ordinal)) { - var branches = await _git.ListLocalBranchesAsync(repoDir, ct); - var target = branches.FirstOrDefault(b => b != branch && !b.StartsWith("claudedo/", StringComparison.Ordinal)) ?? "main"; - await _git.CheckoutBranchAsync(repoDir, target, ct); + await _git.CheckoutBranchAsync(repoDir, await ResolveReturnBranchAsync(planning, repoDir, ct), ct); } try { await _git.BranchDeleteAsync(repoDir, branch, force: true, ct); } catch { /* already gone — idempotent */ } } + /// + /// Rücksprung vom Integration-Branch: list_config.base_branch, sonst main (bzw. master). + /// Nie aus der Branchliste raten — die enthält auch Task-Branches, die in Worktrees ausgecheckt sind. + /// + private async Task ResolveReturnBranchAsync(TaskEntity planning, string repoDir, CancellationToken ct) + { + using var ctx = _dbFactory.CreateDbContext(); + var baseBranch = await ctx.ListConfigs.AsNoTracking() + .Where(c => c.ListId == planning.ListId) + .Select(c => c.BaseBranch) + .FirstOrDefaultAsync(ct); + if (!string.IsNullOrWhiteSpace(baseBranch)) return baseBranch; + var branches = await _git.ListLocalBranchesAsync(repoDir, ct); + return !branches.Contains("main") && branches.Contains("master") ? "master" : "main"; + } + private async Task<(TaskEntity planning, string repoDir, IReadOnlyList children)> LoadPlanningContextAsync(string planningTaskId, CancellationToken ct) { diff --git a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs index ca8762a3..7b6540a2 100644 --- a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs +++ b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs @@ -243,9 +243,6 @@ public sealed class WorktreeManager _logger.LogInformation("Discarded worktree for task {TaskId} (branch {Branch})", wt.TaskId, wt.BranchName); } - // Improvement children (parent is a non-planning task with its own worktree) branch - // from the parent's recorded HEAD so they build on the parent's not-yet-merged work. - // Planning children and standalone tasks base off the list's current HEAD. /// Ticket-Id des Tasks, sonst die seines Parents (Children erben das Ticket ihrer Unit). private async Task ResolveTicketIdAsync(TaskEntity task, CancellationToken ct) { @@ -259,6 +256,9 @@ public sealed class WorktreeManager return TicketStatusMap.ParseBandelTicketId(parentRef); } + // Improvement children (parent is a non-planning task with its own worktree) branch + // from the parent's recorded HEAD so they build on the parent's not-yet-merged work. + // Planning children and standalone tasks base off list_config.base_branch, else the current HEAD. private async Task ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct) { if (task.ParentTaskId is not null) diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs index a1ccaea8..299c1864 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs @@ -214,6 +214,39 @@ public class PlanningAggregatorTests : IDisposable Assert.DoesNotContain(branches, b => b == ok.Value.IntegrationBranch); } + [Theory] + [InlineData(null, "main")] + [InlineData("dev", "dev")] + public async Task CleanupIntegrationBranchAsync_ReturnsToBaseBranchOrMain_NotATaskBranch(string? baseBranch, string expected) + { + var db = NewDb(); + var repo = NewRepo(); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "dev"); + var (parentId, _, _) = await SeedPlanningWithTwoChildrenAsync(db, repo); + + // Sorts before main and is checked out in a worktree — the old guess picked it and the checkout threw. + var taskWt = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); + _wtCleanups.Add((repo.RepoDir, taskWt)); + GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "chore/1-task", taskWt, repo.BaseCommit); + + if (baseBranch is not null) + { + using var ctx = db.CreateContext(); + var listId = (await new TaskRepository(ctx).GetByIdAsync(parentId))!.ListId; + await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, BaseBranch = baseBranch }); + } + + var git = new GitService(); + var svc = new PlanningAggregator(db.CreateFactory(), git, NullLogger.Instance); + var built = Assert.IsType(await svc.BuildIntegrationBranchAsync(parentId, "main", CancellationToken.None)); + Assert.Equal(built.Value.IntegrationBranch, await git.GetCurrentBranchAsync(repo.RepoDir)); + + await svc.CleanupIntegrationBranchAsync(parentId, CancellationToken.None); + + Assert.Equal(expected, await git.GetCurrentBranchAsync(repo.RepoDir)); + } + private void SeedWorktreeWithFile(ClaudeDoDbContext ctx, GitRepoFixture repo, string taskId, string filename, string content) { var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");