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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user