Merge claudedo/f319b602707f4dfdb6727e9fae5ac757

This commit is contained in:
mika kuns
2026-08-10 14:58:58 +02:00
6 changed files with 335 additions and 6 deletions
+113
View File
@@ -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
@@ -938,6 +950,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 " +
+11
View File
@@ -25,4 +25,15 @@ internal static class McpToolDocs
/// <summary>Mutations that refuse to touch a task while its agent is running.</summary>
public const string NotWhileRunning = " Refused while the task is Running — cancel it first.";
/// <summary>
/// Explains this project's diff3 conflict-marker layout so a caller grepping only for
/// "&lt;&lt;&lt;&lt;&lt;&lt;&lt;/=======/&gt;&gt;&gt;&gt;&gt;&gt;&gt;" doesn't mistake the "|||||||" base section for one of the two sides.
/// No leading space (unlike the other consts here) since it also doubles as a DTO field default value.
/// </summary>
public const string Diff3Note =
"Conflicts are parsed in git's diff3 style: each hunk has an 'ours' side (the target branch) and a " +
"'theirs' side (the branch being merged in), plus an optional 'base' — the common-ancestor text for that " +
"hunk, shown between a third '|||||||' marker and the '=======' separator. base is null when git recorded " +
"none for that hunk.";
}