diff --git a/src/ClaudeDo.Worker/External/MergeMcpTools.cs b/src/ClaudeDo.Worker/External/MergeMcpTools.cs index 95d977bd..56a48574 100644 --- a/src/ClaudeDo.Worker/External/MergeMcpTools.cs +++ b/src/ClaudeDo.Worker/External/MergeMcpTools.cs @@ -865,9 +865,7 @@ public sealed class MergeMcpTools throw new InvalidOperationException( "Merge preview unavailable for this task (worktree inactive or repo is not a git repository)."); - var target = string.IsNullOrWhiteSpace(targetBranch) - ? await _git.GetCurrentBranchAsync(list.WorkingDir, ct) - : targetBranch; + var target = await _merge.ResolveTargetAsync(list, targetBranch, ct); var behind = await WorktreeMcpTools.GitRevListCountAsync(list.WorkingDir, $"{wt.BranchName}..{target}", ct); var changedFiles = Directory.Exists(wt.Path) diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index 7adee6b5..af3ba7bb 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -411,6 +411,7 @@ public sealed class TaskMergeService return new MergeResult(StatusDirtyTarget, Array.Empty(), "target working tree has uncommitted changes"); + targetBranch = await ResolveTargetAsync(list, targetBranch, ct); var currentBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct); if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal)) { @@ -874,12 +875,26 @@ public sealed class TaskMergeService if (string.IsNullOrWhiteSpace(list.WorkingDir)) return new MergeTargets("", Array.Empty(), defaultMessage); - var current = await _git.GetCurrentBranchAsync(list.WorkingDir, ct); + var current = await ResolveTargetAsync(list, null, ct); var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct); var dirty = await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct); return new MergeTargets(current, branches, defaultMessage, dirty); } + /// Leeres Ziel → list_config.base_branch, sonst der ausgecheckte Branch des Main-Checkouts. + public async Task ResolveTargetAsync(ListEntity list, string? targetBranch, CancellationToken ct) + { + if (!string.IsNullOrWhiteSpace(targetBranch)) return targetBranch; + using var ctx = _dbFactory.CreateDbContext(); + var baseBranch = await ctx.ListConfigs.AsNoTracking() + .Where(c => c.ListId == list.Id) + .Select(c => c.BaseBranch) + .FirstOrDefaultAsync(ct); + return string.IsNullOrWhiteSpace(baseBranch) + ? await _git.GetCurrentBranchAsync(list.WorkingDir!, ct) + : baseBranch; + } + /// The commit message a merge uses when the caller passes none. Built here rather than in the /// UI/MCP callers because only this layer knows the task's commit type and its list's name. private static string DefaultMergeMessage(TaskEntity task, ListEntity list) => @@ -907,9 +922,7 @@ public sealed class TaskMergeService if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct)) return new MergePreviewResult(PreviewUnavailable, Array.Empty(), 0); - var target = string.IsNullOrWhiteSpace(targetBranch) - ? await _git.GetCurrentBranchAsync(list.WorkingDir, ct) - : targetBranch; + var target = await ResolveTargetAsync(list, targetBranch, ct); // merge-tree (below) is a pure object-level check — it has no idea the working directory // has an untracked file that would block (or, worse, silently be lost by) the real merge. @@ -1013,9 +1026,7 @@ public sealed class TaskMergeService return Blocked( $"worktree was reverted and its branch '{wt.BranchName}' no longer exists — cannot re-approve; discard the task or start a fresh worktree instead."); - var reviveTarget = string.IsNullOrWhiteSpace(targetBranch) - ? await _git.GetCurrentBranchAsync(list.WorkingDir, ct) - : targetBranch; + var reviveTarget = await ResolveTargetAsync(list, targetBranch, ct); // A branch that was already merged once (reverted or not) is already an ancestor of // the target tip -- `git merge` on it again is a silent "Already up to date." no-op, @@ -1074,9 +1085,7 @@ public sealed class TaskMergeService if (string.IsNullOrWhiteSpace(list.WorkingDir)) return Blocked("list has no working directory"); - var target = string.IsNullOrWhiteSpace(targetBranch) - ? await _git.GetCurrentBranchAsync(list.WorkingDir, ct) - : targetBranch; + var target = await ResolveTargetAsync(list, targetBranch, ct); // MergeAsync transitions the task WaitingForReview -> Done on a successful merge. // Remove the worktree on approve (matching the unit-merge path) so merged diff --git a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs index 8d19efe2..ca8762a3 100644 --- a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs +++ b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs @@ -274,6 +274,13 @@ public sealed class WorktreeManager return parentHead; } } - return await _git.RevParseHeadAsync(workingDir, ct); + 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); } } diff --git a/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs index 3f6a811f..e3ffac27 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs @@ -425,6 +425,30 @@ public class WorktreeManagerTests : IDisposable Assert.StartsWith("chore/77-test-task", ctx.BranchName); } + [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); + } + private static (TaskEntity task, ListEntity list) MakeEntities(string workingDir) { var listId = Guid.NewGuid().ToString(); diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs index 13cb7791..966e9842 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -1092,6 +1092,74 @@ public class TaskMergeServiceTests : IDisposable Assert.False(Directory.Exists(wtCtx.WorktreePath)); } + [Fact] + public async Task GetTargetsAsync_prefers_list_base_branch() + { + if (!GitRepoFixture.IsGitAvailable()) return; + var repo = NewRepo(); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "dev"); + var db = NewDb(); + var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done); + using (var ctx = db.CreateContext()) + await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, BaseBranch = "dev" }); + + var (svc, _) = BuildService(db); + var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None); + + Assert.NotEqual("dev", await new GitService().GetCurrentBranchAsync(repo.RepoDir)); + Assert.Equal("dev", targets.DefaultBranch); + } + + [Fact] + public async Task ApproveAndMergeAsync_EmptyTarget_MergesIntoListBaseBranch() + { + if (!GitRepoFixture.IsGitAvailable()) return; + var repo = NewRepo(); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "dev"); + var original = await new GitService().GetCurrentBranchAsync(repo.RepoDir); + var db = NewDb(); + var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview); + using (var ctx0 = db.CreateContext()) + await new ListRepository(ctx0).SetConfigAsync(new ListConfigEntity { ListId = list.Id, BaseBranch = "dev" }); + + var wtMgr = BuildWorktreeManager(db); + var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None); + _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath)); + File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n"); + await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None); + + var (svc, _) = BuildService(db); + var result = await svc.ApproveAndMergeAsync(task.Id, "", CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusMerged, result.Status); + Assert.Contains("added.txt", GitRepoFixture.RunGit(repo.RepoDir, "ls-tree", "--name-only", "dev")); + Assert.Equal(repo.BaseCommit, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", original).Trim()); + } + + [Fact] + public async Task MergeAsync_EmptyTarget_MergesIntoCheckedOutBranch() + { + // PlanningMergeOrchestrator.DrainAsync passes an empty target straight through — that used + // to end in `git checkout ""` -> Blocked. + if (!GitRepoFixture.IsGitAvailable()) return; + var repo = NewRepo(); + var current = await new GitService().GetCurrentBranchAsync(repo.RepoDir); + var db = NewDb(); + var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview); + + var wtMgr = BuildWorktreeManager(db); + var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None); + _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath)); + File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n"); + await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None); + + var (svc, _) = BuildService(db); + var result = await svc.MergeAsync(task.Id, "", false, "msg", CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusMerged, result.Status); + Assert.Contains("added.txt", GitRepoFixture.RunGit(repo.RepoDir, "ls-tree", "--name-only", current)); + } + [Fact] public async Task ApproveAndMergeAsync_Conflict_LeavesTaskWaitingForReview() {