Files
ClaudeDo/src/ClaudeDo.Data/Git/ConflictMarkerParser.cs
T
mika kuns 5653daca6e 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.
2026-08-10 13:49:07 +02:00

141 lines
5.5 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace ClaudeDo.Data.Git;
/// <summary>
/// One piece of a conflicted file: either common ("stable") text both sides agree on,
/// or a conflict region holding the two — or, with diff3 markers, three — competing versions.
/// </summary>
public sealed record MergeSegment
{
public bool IsConflict { get; init; }
/// <summary>Stable text (verbatim, line endings preserved) when <see cref="IsConflict"/> is false.</summary>
public string Text { get; init; } = "";
/// <summary>"Ours" side (the target branch) when <see cref="IsConflict"/> is true.</summary>
public string Ours { get; init; } = "";
/// <summary>Merge base, present only when the merge used diff3 conflict style; null otherwise.</summary>
public string? Base { get; init; }
/// <summary>"Theirs" side (the incoming branch) when <see cref="IsConflict"/> is true.</summary>
public string Theirs { get; init; } = "";
/// <summary>1-based line number in the file where this segment starts (the marker line for a conflict).</summary>
public int StartLine { get; init; } = 1;
public static MergeSegment Stable(string text, int startLine = 1) => new() { Text = text, StartLine = startLine };
public static MergeSegment Conflict(string ours, string? @base, string theirs, int startLine = 1) =>
new() { IsConflict = true, Ours = ours, Base = @base, Theirs = theirs, StartLine = startLine };
}
/// <summary>
/// Parses a conflicted file's text into ordered stable / conflict segments and reassembles it.
/// Reads git conflict markers verbatim, so a file with no markers yields a single stable
/// segment, and reassembling the stable text plus one chosen resolution per conflict
/// round-trips the file exactly (line endings included).
/// </summary>
public static class ConflictMarkerParser
{
private const string OursMarker = "<<<<<<<";
private const string BaseMarker = "|||||||";
private const string SepMarker = "=======";
private const string TheirsMarker = ">>>>>>>";
public static IReadOnlyList<MergeSegment> Parse(string fileText)
{
var segments = new List<MergeSegment>();
var lines = SplitKeepLineEndings(fileText);
var stable = new StringBuilder();
var stableStartLine = 1;
var i = 0;
while (i < lines.Count)
{
if (!IsMarker(lines[i], OursMarker))
{
if (stable.Length == 0) stableStartLine = i + 1;
stable.Append(lines[i++]);
continue;
}
if (stable.Length > 0)
{
segments.Add(MergeSegment.Stable(stable.ToString(), stableStartLine));
stable.Clear();
}
var conflictStartLine = i + 1;
i++; // consume "<<<<<<<"
var ours = new StringBuilder();
while (i < lines.Count && !IsMarker(lines[i], BaseMarker) && !IsMarker(lines[i], SepMarker))
ours.Append(lines[i++]);
string? @base = null;
if (i < lines.Count && IsMarker(lines[i], BaseMarker))
{
i++; // consume "|||||||"
var baseText = new StringBuilder();
while (i < lines.Count && !IsMarker(lines[i], SepMarker))
baseText.Append(lines[i++]);
@base = baseText.ToString();
}
if (i < lines.Count && IsMarker(lines[i], SepMarker)) i++; // consume "======="
var theirs = new StringBuilder();
while (i < lines.Count && !IsMarker(lines[i], TheirsMarker))
theirs.Append(lines[i++]);
if (i < lines.Count && IsMarker(lines[i], TheirsMarker)) i++; // consume ">>>>>>>"
segments.Add(MergeSegment.Conflict(ours.ToString(), @base, theirs.ToString(), conflictStartLine));
}
if (stable.Length > 0)
segments.Add(MergeSegment.Stable(stable.ToString(), stableStartLine));
return segments;
}
/// <summary>True when the file still contains an opening conflict marker.</summary>
public static bool HasConflicts(string fileText) =>
SplitKeepLineEndings(fileText).Any(l => IsMarker(l, OursMarker));
/// <summary>
/// Reassembles a file from its segments. Stable segments emit their text verbatim;
/// each conflict segment emits whatever <paramref name="resolveConflict"/> returns for it.
/// </summary>
public static string Compose(
IEnumerable<MergeSegment> segments, Func<MergeSegment, string> resolveConflict) =>
string.Concat(segments.Select(s => s.IsConflict ? resolveConflict(s) : s.Text));
// A marker line starts with exactly the 7-char marker, then end-of-line or whitespace/label.
private static bool IsMarker(string line, string marker)
{
if (!line.StartsWith(marker, StringComparison.Ordinal)) return false;
if (line.Length == marker.Length) return true;
return line[marker.Length] is ' ' or '\t' or '\r' or '\n';
}
// Splits into physical lines, each retaining its trailing "\n" (and "\r" if present).
private static List<string> SplitKeepLineEndings(string s)
{
var lines = new List<string>();
var i = 0;
while (i < s.Length)
{
var nl = s.IndexOf('\n', i);
if (nl < 0) { lines.Add(s[i..]); break; }
lines.Add(s[i..(nl + 1)]);
i = nl + 1;
}
return lines;
}
}