Replace the whole-file conflict model with line-level hunks, the foundation for the full in-app merge editor. - ConflictMarkerParser: parses git conflict markers (incl. diff3 base) into ordered stable/conflict MergeSegments; exact round-trip + Compose - GitService.MergeNoFfAsync passes -c merge.conflictStyle=diff3 so the working tree carries the merge base in conflict markers - TaskMergeService.GetConflictDocumentsAsync: reads each conflicted file, parses into segments, flags binary files - hub GetMergeConflictDocuments + DTOs (MergeConflictDocumentsDto/ ConflictDocumentDto/MergeSegmentDto), IWorkerClient + both fakes - tests: 8 parser unit tests + a real-git integration test asserting line-level hunks with a diff3 base
135 lines
5.0 KiB
C#
135 lines
5.0 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; } = "";
|
|
|
|
public static MergeSegment Stable(string text) => new() { Text = text };
|
|
|
|
public static MergeSegment Conflict(string ours, string? @base, string theirs) =>
|
|
new() { IsConflict = true, Ours = ours, Base = @base, Theirs = theirs };
|
|
}
|
|
|
|
/// <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 i = 0;
|
|
|
|
while (i < lines.Count)
|
|
{
|
|
if (!IsMarker(lines[i], OursMarker))
|
|
{
|
|
stable.Append(lines[i++]);
|
|
continue;
|
|
}
|
|
|
|
if (stable.Length > 0)
|
|
{
|
|
segments.Add(MergeSegment.Stable(stable.ToString()));
|
|
stable.Clear();
|
|
}
|
|
|
|
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()));
|
|
}
|
|
|
|
if (stable.Length > 0)
|
|
segments.Add(MergeSegment.Stable(stable.ToString()));
|
|
|
|
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;
|
|
}
|
|
}
|