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