feat(worker): build/test the merge preview, not just the post-merge merge
preview_merge could only see file-overlap cleanliness via git merge-tree, never whether the result compiles -- the two costliest findings of the 2026-08-06 batch run were both merge-tree-clean but build-broken. When a list has a verify command configured, a clean preview is now additionally materialized (via a commit-tree + detached scratch worktree, outside the real repo, always cleaned up) and built/tested there, without ever touching the real working tree. preview_merge always attempts a verify run when a command is configured; preview_merge_set only does when its new runVerify parameter is set (default off), so a set preview never starts N builds unasked. The post-merge verify gate is unchanged.
This commit is contained in:
@@ -10,6 +10,7 @@ using ClaudeDo.Worker.Planning;
|
||||
using ClaudeDo.Worker.Queue;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using ClaudeDo.Worker.Tests.Services;
|
||||
using ClaudeDo.Worker.Usage;
|
||||
using ClaudeDo.Worker.Worktrees;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
@@ -123,13 +124,15 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
return task;
|
||||
}
|
||||
|
||||
private ExternalMcpService BuildSut(QueueService queue)
|
||||
private ExternalMcpService BuildSut(QueueService queue) => BuildSut(queue, new VerifyCommandRunner());
|
||||
|
||||
private ExternalMcpService BuildSut(QueueService queue, IVerifyCommandRunner verify)
|
||||
{
|
||||
var git = new GitService();
|
||||
var factory = _db.CreateFactory();
|
||||
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||
var state = TaskStateServiceBuilder.Build(factory).State;
|
||||
var merge = new TaskMergeService(factory, git, _broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
|
||||
var merge = new TaskMergeService(factory, git, _broadcaster, state, verify, NullLogger<TaskMergeService>.Instance);
|
||||
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
|
||||
var planningMerge = new PlanningMergeOrchestrator(
|
||||
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
||||
@@ -1609,6 +1612,36 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
Assert.Contains("working directory", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMerge_VerifyCommandConfigured_RunsInScratchWorktreeAndDoesNotTouchTarget()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
|
||||
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
||||
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
||||
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
|
||||
|
||||
using (var ctx = _db.CreateContext())
|
||||
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });
|
||||
|
||||
var git = new GitService();
|
||||
var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
||||
var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
|
||||
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
|
||||
var sut = BuildSut(CreateQueue(), fakeVerify);
|
||||
|
||||
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
||||
Assert.Equal(0, result.VerifyExitCode);
|
||||
Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
|
||||
Assert.NotEqual(list.WorkingDir, fakeVerify.CapturedWorkingDir);
|
||||
Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None));
|
||||
Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMergeSet_TwoTasksSharedFile_ReportsOverlap()
|
||||
{
|
||||
@@ -1627,7 +1660,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
await CreateActiveWorktreeAsync(repo, taskB.Id, "shared.txt", "b change\n");
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", CancellationToken.None);
|
||||
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, result.Tasks.Count);
|
||||
Assert.All(result.Tasks, t => Assert.Null(t.Error));
|
||||
@@ -1648,7 +1681,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var result = await sut.PreviewMergeSet([task.Id], target, CancellationToken.None);
|
||||
var result = await sut.PreviewMergeSet([task.Id], target, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Tasks);
|
||||
Assert.Empty(result.Overlaps);
|
||||
@@ -1665,7 +1698,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var result = await sut.PreviewMergeSet([task.Id, noWorktreeTask.Id], target, CancellationToken.None);
|
||||
var result = await sut.PreviewMergeSet([task.Id, noWorktreeTask.Id], target, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, result.Tasks.Count);
|
||||
var ok = result.Tasks.Single(t => t.TaskId == task.Id);
|
||||
@@ -1710,7 +1743,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
await CreateActiveWorktreeAsync(repo, fullTask.Id, "full.txt", "content\n");
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.PreviewMergeSet([emptyTask.Id, fullTask.Id], "main", CancellationToken.None);
|
||||
var result = await sut.PreviewMergeSet([emptyTask.Id, fullTask.Id], "main", cancellationToken: CancellationToken.None);
|
||||
|
||||
var emptyEntry = result.Tasks.Single(t => t.TaskId == emptyTask.Id);
|
||||
var fullEntry = result.Tasks.Single(t => t.TaskId == fullTask.Id);
|
||||
@@ -1720,6 +1753,54 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
Assert.False(fullEntry.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMergeSet_DefaultRunVerifyFalse_NeverInvokesRunnerEvenWithCommandConfigured()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||
using (var ctx = _db.CreateContext())
|
||||
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" });
|
||||
var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
||||
await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n");
|
||||
|
||||
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
|
||||
var sut = BuildSut(CreateQueue(), fakeVerify);
|
||||
|
||||
var result = await sut.PreviewMergeSet([task.Id], "main", cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Null(result.Tasks.Single().VerifyExitCode);
|
||||
Assert.Null(fakeVerify.CapturedCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMergeSet_RunVerifyTrue_InvokesRunnerPerTask()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||
using (var ctx = _db.CreateContext())
|
||||
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" });
|
||||
var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
||||
await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n");
|
||||
|
||||
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
|
||||
var sut = BuildSut(CreateQueue(), fakeVerify);
|
||||
|
||||
var result = await sut.PreviewMergeSet([task.Id], "main", runVerify: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.Tasks.Single().VerifyExitCode);
|
||||
Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
|
||||
}
|
||||
|
||||
// ── AddTask model override ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user