feat(merge): real conflict-hunk parsing pipeline (chunk 2 backend)

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
This commit is contained in:
Mika Kuns
2026-06-18 16:22:56 +02:00
parent 4847c5c0a4
commit e779e13654
10 changed files with 378 additions and 1 deletions

View File

@@ -33,6 +33,15 @@ public sealed record ConflictFileContent(
string Theirs,
string? Base);
public sealed record ConflictDocuments(
string TaskId,
IReadOnlyList<ConflictDocumentContent> Files);
public sealed record ConflictDocumentContent(
string Path,
bool IsBinary,
IReadOnlyList<MergeSegment> Segments);
public sealed class TaskMergeService
{
public const string StatusMerged = "merged";
@@ -256,6 +265,45 @@ public sealed class TaskMergeService
return new MergeConflicts(taskId, result);
}
/// <summary>
/// Reads each conflicted working-tree file and parses its conflict markers into line-level
/// segments (with the diff3 merge base when present). Binary files are flagged and skipped.
/// </summary>
public async Task<ConflictDocuments> GetConflictDocumentsAsync(string taskId, CancellationToken ct)
{
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
if (string.IsNullOrWhiteSpace(list.WorkingDir))
throw new InvalidOperationException("list has no working directory");
var files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
var result = new List<ConflictDocumentContent>(files.Count);
foreach (var path in files)
{
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
string text;
try { text = await File.ReadAllTextAsync(full, ct); }
catch { text = ""; }
if (LooksBinary(text))
{
result.Add(new ConflictDocumentContent(path, true, Array.Empty<MergeSegment>()));
continue;
}
result.Add(new ConflictDocumentContent(path, false, ConflictMarkerParser.Parse(text)));
}
return new ConflictDocuments(taskId, result);
}
// A NUL byte in the head of the file is the conventional binary sniff.
private static bool LooksBinary(string text)
{
var n = Math.Min(text.Length, 8000);
for (var i = 0; i < n; i++)
if (text[i] == '\0') return true;
return false;
}
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
{
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);