feat(mcp): expose the conflict resolver's hunk API over MCP
get_merge_conflicts and resolve_conflict_hunk let an MCP caller resolve a leaveConflictsInTree merge hunk-by-hunk (ours/theirs/base/literal text) instead of grep-parsing diff3 markers itself. Reuses the existing TaskMergeService/ConflictMarkerParser machinery (adds MergeSegment.StartLine and a non-staging TaskMergeService.WriteConflictFileAsync) rather than a second parser. A partially resolved file is written without git add so continue_merge keeps refusing while any hunk still has markers.
This commit is contained in:
+113
@@ -88,6 +88,18 @@ public sealed record MergeContinuationResultDto(
|
||||
bool Merged, string TaskStatus, IReadOnlyList<string> Conflicts,
|
||||
string? RepoPath, string? Message);
|
||||
|
||||
public sealed record ConflictHunkDto(
|
||||
int Index, int StartLine, string Ours, string? Base, string Theirs);
|
||||
|
||||
public sealed record ConflictFileHunksDto(
|
||||
string Path, bool IsBinary, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||
|
||||
public sealed record GetMergeConflictsResultDto(
|
||||
IReadOnlyList<ConflictFileHunksDto> Files, int RemainingHunks, string ConflictStyleNote = McpToolDocs.Diff3Note);
|
||||
|
||||
public sealed record ResolveConflictHunkResultDto(
|
||||
bool Resolved, int RemainingHunks, string ConflictStyleNote = McpToolDocs.Diff3Note);
|
||||
|
||||
// IsEmpty = the review range contributed nothing (worktree ahead-of-base is empty, or a
|
||||
// worktree-less handler task's HandlerBaseCommit == HandlerHeadCommit) -- distinguishable from
|
||||
// a merge that is merely small, so an empty branch can't be misread as "changedFileCount: 0
|
||||
@@ -927,6 +939,107 @@ public sealed class ExternalMcpService
|
||||
return ToRefDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"List every conflict hunk left in a paused merge (started via merge_task/review_task with " +
|
||||
"leaveConflictsInTree=true), so you can resolve them with resolve_conflict_hunk instead of parsing marker " +
|
||||
"text yourself. Each hunk carries the file's path, its index within that file (what resolve_conflict_hunk " +
|
||||
"expects back), and its startLine. remainingHunks is the total across every file — call continue_merge once " +
|
||||
"it reaches 0, or abort_merge to cancel. Throws if the task has no in-progress merge." + " " + McpToolDocs.Diff3Note)]
|
||||
public async Task<GetMergeConflictsResultDto> GetMergeConflicts(string taskId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
|
||||
var docs = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
|
||||
var files = docs.Files.Select(ToConflictFileHunksDto).ToList();
|
||||
var remaining = files.Sum(f => f.Hunks.Count);
|
||||
return new GetMergeConflictsResultDto(files, remaining);
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Resolve exactly one conflict hunk from get_merge_conflicts, identified by file path and its index within " +
|
||||
"that file. Writes only that hunk — every other hunk (in this file or another) is left exactly as it was, " +
|
||||
"so call get_merge_conflicts again afterward: resolved hunks disappear and every remaining hunk's index " +
|
||||
"shifts down to stay zero-based. This never decides FOR you which side wins — resolution must be 'ours', " +
|
||||
"'theirs', 'base' (only valid when that hunk has a diff3 base), or literal replacement text (used exactly " +
|
||||
"as given, including any trailing newline the surrounding file needs). remainingHunks is the total across " +
|
||||
"every file; call continue_merge once it reaches 0. Throws if the file/index don't match a current hunk." +
|
||||
" " + McpToolDocs.Diff3Note)]
|
||||
public async Task<ResolveConflictHunkResultDto> ResolveConflictHunk(
|
||||
string taskId,
|
||||
[Description("File path exactly as returned by get_merge_conflicts, relative to the repo root.")]
|
||||
string file,
|
||||
[Description("Zero-based hunk index within that file, as returned by get_merge_conflicts.")]
|
||||
int index,
|
||||
[Description("'ours', 'theirs', 'base', or literal text to use for this hunk.")]
|
||||
string resolution,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(resolution))
|
||||
throw new InvalidOperationException("resolution is required.");
|
||||
|
||||
var docs = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
|
||||
var doc = docs.Files.FirstOrDefault(f => string.Equals(f.Path, file, StringComparison.Ordinal))
|
||||
?? throw new InvalidOperationException($"File '{file}' has no conflict for task {taskId}.");
|
||||
if (doc.IsBinary)
|
||||
throw new InvalidOperationException($"File '{file}' is binary; resolve it directly in the working tree.");
|
||||
|
||||
var hunkCount = doc.Segments.Count(s => s.IsConflict);
|
||||
if (index < 0 || index >= hunkCount)
|
||||
throw new InvalidOperationException(
|
||||
$"Hunk index {index} is out of range for '{file}' ({hunkCount} hunk(s) remaining).");
|
||||
|
||||
var counter = -1;
|
||||
var composed = ConflictMarkerParser.Compose(doc.Segments, seg =>
|
||||
{
|
||||
counter++;
|
||||
if (counter != index) return ReconstructConflictMarkers(seg);
|
||||
|
||||
var choice = resolution.Trim().ToLowerInvariant();
|
||||
return choice switch
|
||||
{
|
||||
"ours" => seg.Ours,
|
||||
"theirs" => seg.Theirs,
|
||||
"base" => seg.Base ?? throw new InvalidOperationException(
|
||||
$"Hunk {index} in '{file}' has no diff3 base to resolve to."),
|
||||
_ => resolution,
|
||||
};
|
||||
});
|
||||
|
||||
await _merge.WriteConflictFileAsync(taskId, file, composed, cancellationToken);
|
||||
|
||||
var refreshed = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
|
||||
var remaining = refreshed.Files.Sum(f => f.Segments.Count(s => s.IsConflict));
|
||||
return new ResolveConflictHunkResultDto(true, remaining);
|
||||
}
|
||||
|
||||
private static ConflictFileHunksDto ToConflictFileHunksDto(ConflictDocumentContent f)
|
||||
{
|
||||
var hunks = new List<ConflictHunkDto>();
|
||||
foreach (var seg in f.Segments)
|
||||
{
|
||||
if (!seg.IsConflict) continue;
|
||||
hunks.Add(new ConflictHunkDto(hunks.Count, seg.StartLine, seg.Ours, seg.Base, seg.Theirs));
|
||||
}
|
||||
return new ConflictFileHunksDto(f.Path, f.IsBinary, hunks);
|
||||
}
|
||||
|
||||
// Re-renders a still-unresolved hunk as valid (unlabeled) conflict markers so it round-trips through
|
||||
// ConflictMarkerParser.Parse again untouched, and git still refuses continue_merge while it stands. The
|
||||
// original marker labels (e.g. "<<<<<<< HEAD") aren't recoverable from a parsed MergeSegment and aren't
|
||||
// needed for either check -- ConflictMarkerParser.IsMarker and ContinueMergeAsync's rescan only match the
|
||||
// marker prefixes.
|
||||
private static string ReconstructConflictMarkers(MergeSegment seg)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("<<<<<<<\n").Append(seg.Ours);
|
||||
if (seg.Base is not null)
|
||||
sb.Append("|||||||\n").Append(seg.Base);
|
||||
sb.Append("=======\n").Append(seg.Theirs);
|
||||
sb.Append(">>>>>>>\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Check whether a task would merge cleanly before touching anything — `git merge-tree --write-tree`, so the " +
|
||||
"working tree, index and HEAD are untouched. status is 'clean' or 'conflict' (conflictFiles lists where git " +
|
||||
|
||||
Reference in New Issue
Block a user