feat(worker): use the list base branch as worktree base and default merge target

This commit is contained in:
mika kuns
2026-10-01 10:46:21 +02:00
parent 3fe1feadf9
commit 69c7803bdf
5 changed files with 120 additions and 14 deletions
+1 -3
View File
@@ -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)
@@ -411,6 +411,7 @@ public sealed class TaskMergeService
return new MergeResult(StatusDirtyTarget, Array.Empty<string>(),
"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<string>(), 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);
}
/// <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;
}
/// 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<string>(), 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
@@ -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);
}
}
@@ -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();
@@ -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()
{