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:
134
src/ClaudeDo.Data/Git/ConflictMarkerParser.cs
Normal file
134
src/ClaudeDo.Data/Git/ConflictMarkerParser.cs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -252,8 +252,11 @@ public sealed class GitService
|
|||||||
public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync(
|
public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync(
|
||||||
string repoDir, string sourceBranch, string message, CancellationToken ct = default)
|
string repoDir, string sourceBranch, string message, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
// diff3 conflict style writes the merge base (|||||||) into conflict markers so the
|
||||||
|
// in-app resolver can show a true three-way view. It only enriches conflicted hunks;
|
||||||
|
// clean merges are unaffected.
|
||||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||||
["merge", "--no-ff", "-m", message, sourceBranch], ct);
|
["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "-m", message, sourceBranch], ct);
|
||||||
return (exitCode, stderr);
|
return (exitCode, stderr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
// ── Conflict resolution (worker hub side implemented by Layer C) ──
|
// ── Conflict resolution (worker hub side implemented by Layer C) ──
|
||||||
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
|
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
|
||||||
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
|
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
|
||||||
|
Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId);
|
||||||
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
|
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
|
||||||
Task<MergeResultDto> ContinueConflictMergeAsync(string taskId);
|
Task<MergeResultDto> ContinueConflictMergeAsync(string taskId);
|
||||||
Task AbortConflictMergeAsync(string taskId);
|
Task AbortConflictMergeAsync(string taskId);
|
||||||
|
|||||||
@@ -275,6 +275,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
|
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
|
||||||
=> _hub.InvokeAsync<MergeConflictsDto>("GetMergeConflicts", taskId);
|
=> _hub.InvokeAsync<MergeConflictsDto>("GetMergeConflicts", taskId);
|
||||||
|
|
||||||
|
public Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId)
|
||||||
|
=> _hub.InvokeAsync<MergeConflictDocumentsDto>("GetMergeConflictDocuments", taskId);
|
||||||
|
|
||||||
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
||||||
=> _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent);
|
=> _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent);
|
||||||
|
|
||||||
@@ -559,6 +562,9 @@ public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalB
|
|||||||
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
||||||
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||||
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
||||||
|
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
||||||
|
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
||||||
|
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
|
||||||
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
||||||
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||||
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalB
|
|||||||
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
||||||
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||||
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
||||||
|
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
||||||
|
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
||||||
|
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
|
||||||
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
||||||
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||||
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||||
@@ -383,6 +386,18 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList());
|
new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public Task<MergeConflictDocumentsDto> GetMergeConflictDocuments(string taskId)
|
||||||
|
=> HubGuard(async () =>
|
||||||
|
{
|
||||||
|
var c = await _mergeService.GetConflictDocumentsAsync(taskId, CancellationToken.None);
|
||||||
|
return new MergeConflictDocumentsDto(
|
||||||
|
c.TaskId,
|
||||||
|
c.Files.Select(f => new ConflictDocumentDto(
|
||||||
|
f.Path, f.IsBinary,
|
||||||
|
f.Segments.Select(s => new MergeSegmentDto(
|
||||||
|
s.IsConflict, s.Text, s.Ours, s.Base, s.Theirs)).ToList())).ToList());
|
||||||
|
});
|
||||||
|
|
||||||
public Task WriteConflictResolution(string taskId, string path, string resolvedContent)
|
public Task WriteConflictResolution(string taskId, string path, string resolvedContent)
|
||||||
=> HubGuard(() => _mergeService.WriteResolutionAsync(
|
=> HubGuard(() => _mergeService.WriteResolutionAsync(
|
||||||
taskId, path, resolvedContent ?? "", CancellationToken.None));
|
taskId, path, resolvedContent ?? "", CancellationToken.None));
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ public sealed record ConflictFileContent(
|
|||||||
string Theirs,
|
string Theirs,
|
||||||
string? Base);
|
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 sealed class TaskMergeService
|
||||||
{
|
{
|
||||||
public const string StatusMerged = "merged";
|
public const string StatusMerged = "merged";
|
||||||
@@ -256,6 +265,45 @@ public sealed class TaskMergeService
|
|||||||
return new MergeConflicts(taskId, result);
|
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)
|
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
|||||||
123
tests/ClaudeDo.Data.Tests/ConflictMarkerParserTests.cs
Normal file
123
tests/ClaudeDo.Data.Tests/ConflictMarkerParserTests.cs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Tests;
|
||||||
|
|
||||||
|
public class ConflictMarkerParserTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void NoMarkers_YieldsSingleStableSegment_AndRoundTrips()
|
||||||
|
{
|
||||||
|
const string text = "just\nsome\nplain\nlines\n";
|
||||||
|
|
||||||
|
var segments = ConflictMarkerParser.Parse(text);
|
||||||
|
|
||||||
|
var only = Assert.Single(segments);
|
||||||
|
Assert.False(only.IsConflict);
|
||||||
|
Assert.Equal(text, ConflictMarkerParser.Compose(segments, c => c.Ours));
|
||||||
|
Assert.False(ConflictMarkerParser.HasConflicts(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SimpleConflict_SplitsIntoStable_Conflict_Stable()
|
||||||
|
{
|
||||||
|
const string text =
|
||||||
|
"line1\n<<<<<<< HEAD\nours line\n=======\ntheirs line\n>>>>>>> branch\nline2\n";
|
||||||
|
|
||||||
|
var segments = ConflictMarkerParser.Parse(text);
|
||||||
|
|
||||||
|
Assert.Equal(3, segments.Count);
|
||||||
|
Assert.Equal("line1\n", segments[0].Text);
|
||||||
|
Assert.True(segments[1].IsConflict);
|
||||||
|
Assert.Equal("ours line\n", segments[1].Ours);
|
||||||
|
Assert.Equal("theirs line\n", segments[1].Theirs);
|
||||||
|
Assert.Null(segments[1].Base);
|
||||||
|
Assert.Equal("line2\n", segments[2].Text);
|
||||||
|
Assert.True(ConflictMarkerParser.HasConflicts(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AcceptingOurs_Or_Theirs_ProducesTheResolvedFile()
|
||||||
|
{
|
||||||
|
const string text =
|
||||||
|
"line1\n<<<<<<< HEAD\nours line\n=======\ntheirs line\n>>>>>>> branch\nline2\n";
|
||||||
|
|
||||||
|
var segments = ConflictMarkerParser.Parse(text);
|
||||||
|
|
||||||
|
Assert.Equal("line1\nours line\nline2\n",
|
||||||
|
ConflictMarkerParser.Compose(segments, c => c.Ours));
|
||||||
|
Assert.Equal("line1\ntheirs line\nline2\n",
|
||||||
|
ConflictMarkerParser.Compose(segments, c => c.Theirs));
|
||||||
|
// "Accept both" = ours followed by theirs.
|
||||||
|
Assert.Equal("line1\nours line\ntheirs line\nline2\n",
|
||||||
|
ConflictMarkerParser.Compose(segments, c => c.Ours + c.Theirs));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Diff3Style_CapturesTheMergeBase()
|
||||||
|
{
|
||||||
|
const string text =
|
||||||
|
"a\n<<<<<<< HEAD\nX\n||||||| base\nB\n=======\nY\n>>>>>>> branch\nz\n";
|
||||||
|
|
||||||
|
var segments = ConflictMarkerParser.Parse(text);
|
||||||
|
|
||||||
|
Assert.Equal(3, segments.Count);
|
||||||
|
Assert.Equal("X\n", segments[1].Ours);
|
||||||
|
Assert.Equal("B\n", segments[1].Base);
|
||||||
|
Assert.Equal("Y\n", segments[1].Theirs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MultipleConflicts_AreEachCaptured()
|
||||||
|
{
|
||||||
|
const string text =
|
||||||
|
"<<<<<<< HEAD\nA\n=======\nB\n>>>>>>> br\nmid\n<<<<<<< HEAD\nC\n=======\nD\n>>>>>>> br\n";
|
||||||
|
|
||||||
|
var segments = ConflictMarkerParser.Parse(text);
|
||||||
|
|
||||||
|
Assert.Equal(3, segments.Count);
|
||||||
|
Assert.True(segments[0].IsConflict);
|
||||||
|
Assert.Equal("A\n", segments[0].Ours);
|
||||||
|
Assert.Equal("mid\n", segments[1].Text);
|
||||||
|
Assert.True(segments[2].IsConflict);
|
||||||
|
Assert.Equal("D\n", segments[2].Theirs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CrlfLineEndings_ArePreserved()
|
||||||
|
{
|
||||||
|
const string text =
|
||||||
|
"a\r\n<<<<<<< HEAD\r\nX\r\n=======\r\nY\r\n>>>>>>> br\r\nb\r\n";
|
||||||
|
|
||||||
|
var segments = ConflictMarkerParser.Parse(text);
|
||||||
|
|
||||||
|
Assert.Equal("a\r\nX\r\nb\r\n", ConflictMarkerParser.Compose(segments, c => c.Ours));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConflictAtEndOfFile_WithoutTrailingNewline_IsParsed()
|
||||||
|
{
|
||||||
|
const string text = "a\n<<<<<<< HEAD\nX\n=======\nY\n>>>>>>> br";
|
||||||
|
|
||||||
|
var segments = ConflictMarkerParser.Parse(text);
|
||||||
|
|
||||||
|
Assert.Equal(2, segments.Count);
|
||||||
|
Assert.Equal("a\n", segments[0].Text);
|
||||||
|
Assert.True(segments[1].IsConflict);
|
||||||
|
Assert.Equal("X\n", segments[1].Ours);
|
||||||
|
Assert.Equal("Y\n", segments[1].Theirs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SevenEqualsInOrdinaryText_IsNotTreatedAsAConflict()
|
||||||
|
{
|
||||||
|
const string text = "title\n=======\nbody\n";
|
||||||
|
|
||||||
|
var segments = ConflictMarkerParser.Parse(text);
|
||||||
|
|
||||||
|
var only = Assert.Single(segments);
|
||||||
|
Assert.False(only.IsConflict);
|
||||||
|
Assert.Equal(text, ConflictMarkerParser.Compose(segments, c => c.Ours));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
public virtual Task CancelReviewAsync(string taskId) => Task.CompletedTask;
|
public virtual Task CancelReviewAsync(string taskId) => Task.CompletedTask;
|
||||||
public virtual Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty<string>(), null));
|
public virtual Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty<string>(), null));
|
||||||
public virtual Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty<ConflictFileDto>()));
|
public virtual Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty<ConflictFileDto>()));
|
||||||
|
public virtual Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId) => Task.FromResult(new MergeConflictDocumentsDto(taskId, System.Array.Empty<ConflictDocumentDto>()));
|
||||||
public virtual Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask;
|
public virtual Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask;
|
||||||
public virtual Task<MergeResultDto> ContinueConflictMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
public virtual Task<MergeResultDto> ContinueConflictMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||||
public virtual Task AbortConflictMergeAsync(string taskId) => Task.CompletedTask;
|
public virtual Task AbortConflictMergeAsync(string taskId) => Task.CompletedTask;
|
||||||
|
|||||||
@@ -571,6 +571,51 @@ public class TaskMergeServiceTests : IDisposable
|
|||||||
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetConflictDocumentsAsync_ParsesLineLevelHunksWithDiff3Base()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
var repo = NewRepo();
|
||||||
|
var db = NewDb();
|
||||||
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
||||||
|
|
||||||
|
// A committed README gives the conflict a common ancestor, so diff3 records a base.
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "line1\nbase\nline3\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "base readme");
|
||||||
|
|
||||||
|
var wtMgr = BuildWorktreeManager(db);
|
||||||
|
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||||
|
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "line1\nWORKTREE\nline3\n");
|
||||||
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "line1\nMAIN\nline3\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||||
|
|
||||||
|
var merge = await svc.MergeAsync(
|
||||||
|
task.Id, target, removeWorktree: false, "merge",
|
||||||
|
leaveConflictsInTree: true, CancellationToken.None);
|
||||||
|
Assert.Equal(TaskMergeService.StatusConflict, merge.Status);
|
||||||
|
|
||||||
|
var docs = await svc.GetConflictDocumentsAsync(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
var file = Assert.Single(docs.Files);
|
||||||
|
Assert.EndsWith("README.md", file.Path.Replace('\\', '/'));
|
||||||
|
Assert.False(file.IsBinary);
|
||||||
|
|
||||||
|
var conflict = Assert.Single(file.Segments.Where(s => s.IsConflict).ToList());
|
||||||
|
Assert.Contains("MAIN", conflict.Ours); // ours = current branch (merge target)
|
||||||
|
Assert.Contains("WORKTREE", conflict.Theirs); // theirs = incoming worktree branch
|
||||||
|
Assert.NotNull(conflict.Base);
|
||||||
|
Assert.Contains("base", conflict.Base!); // diff3 merge base
|
||||||
|
Assert.Contains(file.Segments, s => !s.IsConflict && s.Text.Contains("line1"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ApproveAndMergeAsync_NoWorktree_MarksDone()
|
public async Task ApproveAndMergeAsync_NoWorktree_MarksDone()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ sealed class FakeWorkerClient : IWorkerClient
|
|||||||
public Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
public Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||||
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty<string>(), null));
|
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty<string>(), null));
|
||||||
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty<ConflictFileDto>()));
|
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty<ConflictFileDto>()));
|
||||||
|
public Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId) => Task.FromResult(new MergeConflictDocumentsDto(taskId, System.Array.Empty<ConflictDocumentDto>()));
|
||||||
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask;
|
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask;
|
||||||
public Task<MergeResultDto> ContinueConflictMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
public Task<MergeResultDto> ContinueConflictMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||||
public Task AbortConflictMergeAsync(string taskId) => Task.CompletedTask;
|
public Task AbortConflictMergeAsync(string taskId) => Task.CompletedTask;
|
||||||
|
|||||||
Reference in New Issue
Block a user