feat(mcp): expose the conflict resolver's hunk API over MCP
get_merge_conflicts and resolve_conflict_hunk let an MCP caller resolve a leaveConflictsInTree merge hunk-by-hunk (ours/theirs/base/literal text) instead of grep-parsing diff3 markers itself. Reuses the existing TaskMergeService/ConflictMarkerParser machinery (adds MergeSegment.StartLine and a non-staging TaskMergeService.WriteConflictFileAsync) rather than a second parser. A partially resolved file is written without git add so continue_merge keeps refusing while any hunk still has markers.
This commit is contained in:
@@ -68,6 +68,20 @@ public class ConflictMarkerParserTests
|
||||
Assert.Equal("Y\n", segments[1].Theirs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Segments_ExposeStartLine()
|
||||
{
|
||||
const string text =
|
||||
"line1\nline2\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\nline3\n";
|
||||
|
||||
var segments = ConflictMarkerParser.Parse(text);
|
||||
|
||||
Assert.Equal(3, segments.Count);
|
||||
Assert.Equal(1, segments[0].StartLine); // "line1\nline2\n"
|
||||
Assert.Equal(3, segments[1].StartLine); // the "<<<<<<<" line
|
||||
Assert.Equal(8, segments[2].StartLine); // "line3\n"
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleConflicts_AreEachCaptured()
|
||||
{
|
||||
|
||||
@@ -1124,6 +1124,49 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
return (task, list, wt);
|
||||
}
|
||||
|
||||
// Three edits spread far enough apart in a 20-line file that git's 3-way merge keeps them as three
|
||||
// separate, non-adjacent hunks instead of folding them into one.
|
||||
private static readonly int[] MultiHunkEditLines = [3, 9, 15];
|
||||
|
||||
private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedMultiHunkConflictingWorktreeAsync()
|
||||
{
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
|
||||
var baseLines = Enumerable.Range(1, 20).Select(n => $"line{n}").ToArray();
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), string.Join('\n', baseLines) + "\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "README.md");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "multi-line base");
|
||||
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
var list = new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow };
|
||||
await _lists.AddAsync(list);
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
||||
|
||||
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
||||
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
||||
var wt = await mgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_worktreeCleanups.Add((repo.RepoDir, wt.WorktreePath));
|
||||
|
||||
WriteMultiHunkVariant(wt.WorktreePath, baseLines, "WT");
|
||||
GitRepoFixture.RunGit(wt.WorktreePath, "add", "README.md");
|
||||
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "worktree edit");
|
||||
|
||||
WriteMultiHunkVariant(list.WorkingDir!, baseLines, "MAIN");
|
||||
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
|
||||
GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "main edit");
|
||||
|
||||
return (task, list, wt);
|
||||
}
|
||||
|
||||
private static void WriteMultiHunkVariant(string dir, string[] baseLines, string tag)
|
||||
{
|
||||
var lines = (string[])baseLines.Clone();
|
||||
foreach (var lineNum in MultiHunkEditLines)
|
||||
lines[lineNum - 1] = $"{tag}-{lineNum}";
|
||||
File.WriteAllText(Path.Combine(dir, "README.md"), string.Join('\n', lines) + "\n");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeTask_LeaveConflictsInTree_LeavesMarkersAndKeepsRepoMidMerge()
|
||||
{
|
||||
@@ -1296,6 +1339,130 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
Assert.Contains("mid-merge", ex.Message);
|
||||
}
|
||||
|
||||
// ── get_merge_conflicts / resolve_conflict_hunk ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetMergeConflicts_ReportsHunksWithStartLineAndDiff3Base()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, CancellationToken.None);
|
||||
|
||||
var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.RemainingHunks);
|
||||
var file = Assert.Single(result.Files);
|
||||
Assert.Equal("README.md", file.Path);
|
||||
Assert.False(file.IsBinary);
|
||||
var hunk = Assert.Single(file.Hunks);
|
||||
Assert.Equal(0, hunk.Index);
|
||||
Assert.True(hunk.StartLine >= 1);
|
||||
Assert.Equal("# from main", hunk.Ours.TrimEnd('\r', '\n'));
|
||||
Assert.Equal("# from worktree", hunk.Theirs.TrimEnd('\r', '\n'));
|
||||
Assert.NotEmpty(result.ConflictStyleNote);
|
||||
|
||||
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMergeConflicts_NoMergeInProgress_ReturnsEmpty()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.RemainingHunks);
|
||||
Assert.Empty(result.Files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveConflictHunk_OursTheirsAndText_ResolveEachHunkAndTrackRemainingCount()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync();
|
||||
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var merge = await sut.MergeTask(task.Id, target, true, dryRun: false,
|
||||
allowWaitingForReview: true, leaveConflictsInTree: true, CancellationToken.None);
|
||||
Assert.True(merge.ConflictsInTree);
|
||||
|
||||
var before = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
||||
Assert.Equal(3, before.RemainingHunks);
|
||||
Assert.Equal(3, Assert.Single(before.Files).Hunks.Count);
|
||||
|
||||
// Resolve hunk 0 three times in a row: each resolution removes a hunk from the front, so the
|
||||
// next still-open hunk always slides into index 0.
|
||||
var r1 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None);
|
||||
Assert.Equal(2, r1.RemainingHunks);
|
||||
|
||||
var r2 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "theirs", CancellationToken.None);
|
||||
Assert.Equal(1, r2.RemainingHunks);
|
||||
|
||||
var r3 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "CUSTOM-15\n", CancellationToken.None);
|
||||
Assert.Equal(0, r3.RemainingHunks);
|
||||
|
||||
var finalConflicts = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
||||
Assert.Equal(0, finalConflicts.RemainingHunks);
|
||||
|
||||
var continueResult = await sut.ContinueMerge(task.Id, CancellationToken.None);
|
||||
Assert.True(continueResult.Merged);
|
||||
Assert.Equal("Done", continueResult.TaskStatus);
|
||||
|
||||
var mergedText = File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"));
|
||||
Assert.Contains("MAIN-3", mergedText); // hunk 1 resolved "ours" (the target branch)
|
||||
Assert.Contains("WT-9", mergedText); // hunk 2 resolved "theirs" (the incoming branch)
|
||||
Assert.Contains("CUSTOM-15", mergedText); // hunk 3 resolved to literal text
|
||||
Assert.DoesNotContain("<<<<<<<", mergedText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveConflictHunk_LeavesOtherHunksUntouchedUntilContinueMerge()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync();
|
||||
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
await sut.MergeTask(task.Id, target, true, dryRun: false,
|
||||
allowWaitingForReview: true, leaveConflictsInTree: true, CancellationToken.None);
|
||||
|
||||
// Resolve only one of the three hunks.
|
||||
await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None);
|
||||
|
||||
// continue_merge must still refuse: writing one hunk must not have staged the file as resolved.
|
||||
var result = await sut.ContinueMerge(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Merged);
|
||||
Assert.Contains("README.md", result.Conflicts);
|
||||
Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
||||
|
||||
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveConflictHunk_UnknownFileOrOutOfRangeIndex_Throws()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, CancellationToken.None);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sut.ResolveConflictHunk(task.Id, "not-a-real-file.md", 0, "ours", CancellationToken.None));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sut.ResolveConflictHunk(task.Id, "README.md", 5, "ours", CancellationToken.None));
|
||||
|
||||
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
||||
}
|
||||
|
||||
// ── RevertMerge ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user