feat(mcp): paths filter for get_task_diff, subset hint for preview_merge_set

get_task_diff gained an optional paths param (git pathspec, both stat and
full-diff modes) so a large multi-file task can be narrowed to the files
actually in question instead of shipping the whole diff.

preview_merge_set now also reports Subsets: pairs where one task's changed
files are a proper subset of another's in the same set, the strongest
available post-hoc signal that a task may be redundant with another.
This commit is contained in:
mika kuns
2026-08-10 13:39:55 +02:00
parent 6a2a19cc9e
commit c715c96af9
6 changed files with 174 additions and 30 deletions
+55 -13
View File
@@ -100,8 +100,15 @@ public sealed record MergePreviewSetEntryDto(
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds);
// TaskId's changed-file list is a non-empty PROPER subset of SupersetTaskId's -- e.g. one task
// deletes two files and another deletes just one of those same two. Stronger than an Overlaps
// entry (which only says some files are shared): a real subset is the best available machine
// signal that TaskId's work may already be entirely covered by SupersetTaskId.
public sealed record SubsetRelationDto(string TaskId, string SupersetTaskId);
public sealed record MergePreviewSetResultDto(
IReadOnlyList<MergePreviewSetEntryDto> Tasks, IReadOnlyList<FileOverlapDto> Overlaps);
IReadOnlyList<MergePreviewSetEntryDto> Tasks, IReadOnlyList<FileOverlapDto> Overlaps,
IReadOnlyList<SubsetRelationDto> Subsets);
public sealed record WorktreeListItemDto(
string? TaskId, string Path, string Branch,
@@ -620,7 +627,7 @@ public sealed class ExternalMcpService
{
if (!Directory.Exists(child.Worktree.Path)) return false;
var files = ParseDiffStatFileNames(
await _git.DiffStatAsync(child.Worktree.Path, child.Worktree.BaseCommit, "HEAD", ct));
await _git.DiffStatAsync(child.Worktree.Path, child.Worktree.BaseCommit, "HEAD", ct: ct));
return files.Count == 0;
}
if (child.HandlerBaseCommit is { Length: > 0 } handlerBase && child.HandlerHeadCommit is { Length: > 0 } handlerHead)
@@ -714,6 +721,10 @@ public sealed class ExternalMcpService
[Description("false (default): the full unified diff, capped at 200 KB. true: a --stat summary with " +
"per-file insertion/deletion counts — start here when the diff may be large.")]
bool stat = false,
[Description("Restrict the diff to these paths (relative to the repo root), e.g. after a --stat pass or a " +
"conflict report already narrowed down which files matter. Omit/empty for every changed file " +
"— the default and the only prior behavior. Works in both stat and full-diff mode.")]
IReadOnlyList<string>? paths = null,
CancellationToken cancellationToken = default)
{
var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken);
@@ -722,13 +733,13 @@ public sealed class ExternalMcpService
if (stat)
{
var diffStat = await _git.DiffStatAsync(repoPath, baseCommit, headCommit ?? "HEAD", cancellationToken);
var diffStat = await _git.DiffStatAsync(repoPath, baseCommit, headCommit ?? "HEAD", paths, cancellationToken);
return new TaskDiffDto(diffStat, ParseDiffStatFileNames(diffStat), false, diffStat.Length);
}
var diff = headCommit is null
? await _git.GetBranchDiffAsync(repoPath, baseCommit, cancellationToken)
: await _git.GetCommitRangeDiffAsync(repoPath, baseCommit, headCommit, cancellationToken);
? await _git.GetBranchDiffAsync(repoPath, baseCommit, paths, cancellationToken)
: await _git.GetCommitRangeDiffAsync(repoPath, baseCommit, headCommit, paths, cancellationToken);
var files = ParseDiffFileNames(diff);
if (diff.Length <= maxBytes)
@@ -949,11 +960,13 @@ public sealed class ExternalMcpService
[McpServerTool, Description(
"Plan a batch merge: preview_merge for several tasks against the same targetBranch, plus a file-overlap " +
"check between them. Per entry you get preview_merge's fields, or error instead when that task could not " +
"be previewed (it is then left out of the overlap computation). overlaps names, for each file touched by " +
"MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields no overlaps. " +
"IMPORTANT: overlap is a HINT and its absence is not safety — two tasks touching entirely different files " +
"(one deleting a symbol, another still referencing it) can still collide unflagged, and as with " +
"preview_merge a clean result does not mean the merge builds.")]
"be previewed (it is then left out of the overlap/subset computation). overlaps names, for each file " +
"touched by MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields no " +
"overlaps. subsets flags a stronger relation: TaskId's changed files are a PROPER subset of " +
"SupersetTaskId's — the strongest hint you get post-hoc that TaskId may be redundant with SupersetTaskId, " +
"worth checking before merging both. IMPORTANT: neither overlap nor subset is a safety guarantee — two " +
"tasks touching entirely different files (one deleting a symbol, another still referencing it) can still " +
"collide unflagged, and as with preview_merge a clean result does not mean the merge builds.")]
public async Task<MergePreviewSetResultDto> PreviewMergeSet(
IReadOnlyList<string> taskIds,
[Description("Branch to preview every task against; defaults to the repo's current branch.")]
@@ -990,7 +1003,36 @@ public sealed class ExternalMcpService
.OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase)
.ToList();
return new MergePreviewSetResultDto(entries, overlaps);
var subsets = FindSubsetRelations(filesByTask);
return new MergePreviewSetResultDto(entries, overlaps, subsets);
}
// A task's changed-file set is flagged only against another task's STRICTLY LARGER set that
// contains every one of its files -- an empty set (isEmpty task) never qualifies, since that
// case is already surfaced via IsEmpty and would otherwise match every other task trivially.
private static List<SubsetRelationDto> FindSubsetRelations(Dictionary<string, IReadOnlyList<string>> filesByTask)
{
var fileSets = filesByTask.ToDictionary(
kv => kv.Key,
kv => new HashSet<string>(kv.Value, StringComparer.OrdinalIgnoreCase));
var subsets = new List<SubsetRelationDto>();
foreach (var (taskId, fileSet) in fileSets)
{
if (fileSet.Count == 0) continue;
foreach (var (otherTaskId, otherFileSet) in fileSets)
{
if (otherTaskId == taskId || otherFileSet.Count <= fileSet.Count) continue;
if (fileSet.IsSubsetOf(otherFileSet))
subsets.Add(new SubsetRelationDto(taskId, otherTaskId));
}
}
return subsets
.OrderBy(s => s.TaskId, StringComparer.OrdinalIgnoreCase)
.ThenBy(s => s.SupersetTaskId, StringComparer.OrdinalIgnoreCase)
.ToList();
}
// Shared core for PreviewMerge/PreviewMergeSet: throws a clear InvalidOperationException instead of
@@ -1029,7 +1071,7 @@ public sealed class ExternalMcpService
var behind = await GitRevListCountAsync(list.WorkingDir, $"{wt.BranchName}..{target}", ct);
var changedFiles = Directory.Exists(wt.Path)
? ParseDiffStatFileNames(await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", ct))
? ParseDiffStatFileNames(await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", ct: ct))
: Array.Empty<string>();
return (preview, behind, changedFiles, changedFiles.Count == 0);
@@ -1043,7 +1085,7 @@ public sealed class ExternalMcpService
var isEmpty = string.Equals(handlerBase, handlerHead, StringComparison.Ordinal);
var changedFiles = isEmpty
? Array.Empty<string>()
: ParseDiffStatFileNames(await _git.DiffStatAsync(list.WorkingDir, handlerBase, handlerHead, ct));
: ParseDiffStatFileNames(await _git.DiffStatAsync(list.WorkingDir, handlerBase, handlerHead, ct: ct));
var preview = new MergePreviewResult(TaskMergeService.PreviewClean, Array.Empty<string>(), changedFiles.Count);
return (preview, 0, changedFiles, isEmpty);