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:
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<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<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<MergeResultDto> ContinueConflictMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||
public virtual Task AbortConflictMergeAsync(string taskId) => Task.CompletedTask;
|
||||
|
||||
@@ -571,6 +571,51 @@ public class TaskMergeServiceTests : IDisposable
|
||||
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]
|
||||
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> 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<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<MergeResultDto> ContinueConflictMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||
public Task AbortConflictMergeAsync(string taskId) => Task.CompletedTask;
|
||||
|
||||
Reference in New Issue
Block a user