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:
mika kuns
2026-08-10 12:10:17 +02:00
parent 6a2a19cc9e
commit effece3a41
8 changed files with 475 additions and 28 deletions
@@ -589,6 +589,154 @@ public class TaskMergeServiceTests : IDisposable
Assert.Equal(TaskMergeService.PreviewUnavailable, preview.Status);
}
[Fact]
public async Task PreviewAsync_RunVerifyTrue_NoVerifyCommandConfigured_NeverInvokesRunner()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
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"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
var (svc, _) = BuildService(db, fakeVerify);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var preview = await svc.PreviewAsync(task.Id, target, runVerify: true, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
Assert.Null(preview.VerifyExitCode);
Assert.Null(fakeVerify.CapturedCommand);
}
[Fact]
public async Task PreviewAsync_RunVerifyFalse_VerifyCommandConfigured_NeverInvokesRunner()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
await SeedVerifyCommand(db, list.Id, "dotnet build");
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"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
var (svc, _) = BuildService(db, fakeVerify);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
Assert.Null(preview.VerifyExitCode);
Assert.Null(fakeVerify.CapturedCommand);
}
[Fact]
public async Task PreviewAsync_RunVerifyTrue_VerifyPasses_ReturnsCleanBuildAndLeavesTargetUntouched()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
await SeedVerifyCommand(db, list.Id, "dotnet build");
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"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner
{
Result = new VerifyCommandResult(0, false, "build ok"),
FileToCheck = "added.txt",
};
var (svc, _) = BuildService(db, fakeVerify);
var git = new GitService();
var target = await git.GetCurrentBranchAsync(repo.RepoDir);
var statusBefore = await git.GetStatusPorcelainAsync(repo.RepoDir);
var headBefore = await git.RevParseHeadAsync(repo.RepoDir);
var preview = await svc.PreviewAsync(task.Id, target, runVerify: true, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
Assert.Equal(0, preview.VerifyExitCode);
Assert.NotNull(preview.VerifyDurationMs);
Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
Assert.NotEqual(repo.RepoDir, fakeVerify.CapturedWorkingDir);
Assert.True(fakeVerify.WorkingDirHadFile, "the merged file must be present in the scratch worktree at verify time");
// Scratch worktree removed afterward; the real target working tree never changed.
Assert.False(Directory.Exists(fakeVerify.CapturedWorkingDir));
Assert.Equal(statusBefore, await git.GetStatusPorcelainAsync(repo.RepoDir));
Assert.Equal(headBefore, await git.RevParseHeadAsync(repo.RepoDir));
}
[Fact]
public async Task PreviewAsync_RunVerifyTrue_VerifyFails_ReturnsExitCodeAndOutputTailWithoutTouchingTarget()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
await SeedVerifyCommand(db, list.Id, "dotnet build");
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"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "build broke: CS0103") };
var (svc, _) = BuildService(db, fakeVerify);
var git = new GitService();
var target = await git.GetCurrentBranchAsync(repo.RepoDir);
var headBefore = await git.RevParseHeadAsync(repo.RepoDir);
var preview = await svc.PreviewAsync(task.Id, target, runVerify: true, CancellationToken.None);
// The merge-tree result itself is still clean -- only the build/test of it failed.
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
Assert.Equal(1, preview.VerifyExitCode);
Assert.Contains("build broke: CS0103", preview.VerifyOutputTail);
Assert.False(Directory.Exists(fakeVerify.CapturedWorkingDir));
Assert.Equal(headBefore, await git.RevParseHeadAsync(repo.RepoDir));
}
[Fact]
public async Task PreviewAsync_RunVerifyTrue_CleansUpScratchWorktreeRegistration()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
await SeedVerifyCommand(db, list.Id, "dotnet build");
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"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
var (svc, _) = BuildService(db, fakeVerify);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
await svc.PreviewAsync(task.Id, target, runVerify: true, CancellationToken.None);
var worktreeListing = GitRepoFixture.RunGit(repo.RepoDir, "worktree", "list", "--porcelain");
Assert.DoesNotContain(fakeVerify.CapturedWorkingDir!, worktreeListing);
}
[Fact]
public async Task ApproveAndMergeAsync_CleanWorktree_MergesAndMarksDone()
{
@@ -1159,10 +1307,17 @@ internal sealed class FakeVerifyCommandRunner : IVerifyCommandRunner
public string? CapturedWorkingDir { get; private set; }
public string? CapturedCommand { get; private set; }
// Set before the call to snapshot, at call time, whether this file exists in workingDir --
// lets a preview-verify test prove the scratch worktree actually contains the merged content.
public string? FileToCheck { get; set; }
public bool? WorkingDirHadFile { get; private set; }
public Task<VerifyCommandResult> RunAsync(string workingDir, string command, TimeSpan timeout, CancellationToken ct)
{
CapturedWorkingDir = workingDir;
CapturedCommand = command;
if (FileToCheck is not null)
WorkingDirHadFile = File.Exists(Path.Combine(workingDir, FileToCheck));
return Task.FromResult(Result);
}
}