diff --git a/src/ClaudeDo.Worker/External/MergeMcpTools.cs b/src/ClaudeDo.Worker/External/MergeMcpTools.cs index 1c9a1951..3cca7122 100644 --- a/src/ClaudeDo.Worker/External/MergeMcpTools.cs +++ b/src/ClaudeDo.Worker/External/MergeMcpTools.cs @@ -57,7 +57,11 @@ public sealed record MergePreviewToolDto( public sealed record MergePreviewSetEntryDto( string TaskId, string Status, IReadOnlyList ConflictFiles, int ChangedFileCount, int Behind, string? Error, bool IsEmpty = false, int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null, - IReadOnlyList? StaleFiles = null, int? Number = null); + IReadOnlyList? StaleFiles = null, int? Number = null, + // Mirrors TaskEntity.ExpectsNoDiff -- lets the merge helper tell a deliberately empty diff + // (verification/audit/research task) apart from a task that silently failed to commit, both + // of which otherwise show IsEmpty=true with no other distinguishing signal. + bool ExpectsNoDiff = false); public sealed record FileOverlapDto(string File, IReadOnlyList TaskIds, IReadOnlyList Numbers); @@ -461,7 +465,7 @@ public sealed class MergeMcpTools IProgress? progress = null) { taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); - var (preview, behind, _, isEmpty, staleFiles, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, cancellationToken, progress); + var (preview, behind, _, isEmpty, staleFiles, _, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, cancellationToken, progress); return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty, preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles); } @@ -503,10 +507,10 @@ public sealed class MergeMcpTools var taskId = taskIds[i]; try { - var (preview, behind, changedFiles, isEmpty, staleFiles, number) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress); + var (preview, behind, changedFiles, isEmpty, staleFiles, number, expectsNoDiff) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress); entries.Add(new MergePreviewSetEntryDto( taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty, - preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number)); + preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number, expectsNoDiff)); filesByTask[taskId] = changedFiles; numbersByTask[taskId] = number; } @@ -515,7 +519,7 @@ public sealed class MergeMcpTools var task = await _tasks.GetByIdAsync(taskId, cancellationToken); entries.Add(new MergePreviewSetEntryDto( taskId, TaskMergeService.PreviewUnavailable, Array.Empty(), 0, 0, ex.Message, - Number: task?.Number)); + Number: task?.Number, ExpectsNoDiff: task?.ExpectsNoDiff ?? false)); } ProgressReporter.ReportItem(progress, "Previewing merges", i + 1, taskIds.Count); @@ -573,7 +577,7 @@ public sealed class MergeMcpTools // (its commits already sit on the list's working dir) — falls back to the fixed // HandlerBaseCommit..HandlerHeadCommit range, reporting a synthetic "clean" preview of that // range's own diff-stat instead of throwing "has no worktree". - private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList ChangedFiles, bool IsEmpty, IReadOnlyList StaleFiles, int Number)> PreviewMergeCoreAsync( + private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList ChangedFiles, bool IsEmpty, IReadOnlyList StaleFiles, int Number, bool ExpectsNoDiff)> PreviewMergeCoreAsync( string taskId, string? targetBranch, bool runVerify, CancellationToken ct, IProgress? progress = null) { @@ -615,7 +619,7 @@ public sealed class MergeMcpTools .Intersect(targetChangedSinceFork, StringComparer.OrdinalIgnoreCase) .ToList(); - return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number); + return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number, task.ExpectsNoDiff); } if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead) @@ -632,7 +636,7 @@ public sealed class MergeMcpTools // No fork point to diff against: a handler task commits straight onto the list's // working dir instead of a branch, so there is nothing else that could have "changed // in the target since the fork". - return (preview, 0, changedFiles, isEmpty, Array.Empty(), task.Number); + return (preview, 0, changedFiles, isEmpty, Array.Empty(), task.Number, task.ExpectsNoDiff); } throw new InvalidOperationException($"Task {taskId} has no worktree."); diff --git a/tests/ClaudeDo.Worker.Tests/External/MergeMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/MergeMcpToolsTests.cs index 41e27ef7..0b7b3e5a 100644 --- a/tests/ClaudeDo.Worker.Tests/External/MergeMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/MergeMcpToolsTests.cs @@ -1139,6 +1139,22 @@ public sealed class MergeMcpToolsTests : IDisposable Assert.Empty(result.Overlaps); } + [Fact] + public async Task PreviewMergeSet_ReportsExpectsNoDiffPerTask() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); + task.ExpectsNoDiff = true; + await _tasks.UpdateAsync(task, CancellationToken.None); + var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var result = await sut.PreviewMergeSet([task.Id], target, cancellationToken: CancellationToken.None); + + Assert.True(Assert.Single(result.Tasks).ExpectsNoDiff); + } + [Fact] public async Task PreviewMergeSet_ReportsProgressPerTask() {