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

@@ -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()
{

View File

@@ -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;