feat(worker): add continue_merge and abort_merge MCP tools
This commit is contained in:
@@ -925,6 +925,156 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
Assert.Equal("WaitingForReview", result.Task.Status);
|
||||
}
|
||||
|
||||
// ── continue_merge / abort_merge ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ContinueMerge_AfterResolvingMarkers_MergesAndSetsDone()
|
||||
{
|
||||
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);
|
||||
|
||||
// Resolve the conflict on disk and stage it.
|
||||
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n");
|
||||
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
|
||||
|
||||
var result = await sut.ContinueMerge(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Merged);
|
||||
Assert.Equal("Done", result.TaskStatus);
|
||||
Assert.Empty(result.Conflicts);
|
||||
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ContinueMerge_MarkersStillPresent_ReportsConflicts()
|
||||
{
|
||||
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);
|
||||
|
||||
// Markers never resolved — continue must refuse and list the files.
|
||||
var result = await sut.ContinueMerge(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Merged);
|
||||
Assert.Contains("README.md", result.Conflicts);
|
||||
Assert.Equal(list.WorkingDir, result.RepoPath);
|
||||
Assert.Equal("WaitingForReview", result.TaskStatus);
|
||||
Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
||||
|
||||
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AbortMerge_RestoresCleanTreeAndKeepsWaitingForReview()
|
||||
{
|
||||
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 dto = await sut.AbortMerge(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal("WaitingForReview", dto.Status);
|
||||
Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
|
||||
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ContinueMerge_NoMergeInProgress_Throws()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sut.ContinueMerge(task.Id, CancellationToken.None));
|
||||
Assert.Contains("mid-merge", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AbortMerge_NoMergeInProgress_Throws()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sut.AbortMerge(task.Id, CancellationToken.None));
|
||||
Assert.Contains("mid-merge", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ContinueMerge_ParentUnitMergeConflict_RoutesToOrchestratorAndCompletes()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
|
||||
// main edits README.md so the child's edit conflicts.
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "README.md");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
|
||||
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
var parentId = Guid.NewGuid().ToString();
|
||||
var childId = Guid.NewGuid().ToString();
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||
ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
|
||||
Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized });
|
||||
ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
|
||||
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1 });
|
||||
|
||||
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||
_worktreeCleanups.Add((repo.RepoDir, wtPath));
|
||||
var branch = $"claudedo/{childId[..8]}";
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
|
||||
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# child change\n");
|
||||
GitRepoFixture.RunGit(wtPath, "add", "README.md");
|
||||
GitRepoFixture.RunGit(wtPath, "commit", "-m", "child edit");
|
||||
ctx.Worktrees.Add(new WorktreeEntity
|
||||
{
|
||||
TaskId = childId, Path = wtPath, BranchName = branch,
|
||||
BaseCommit = repo.BaseCommit,
|
||||
HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
|
||||
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var review = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None);
|
||||
Assert.Equal(TaskMergeService.StatusConflict, review.MergeStatus);
|
||||
Assert.Equal(repo.RepoDir, review.RepoPath);
|
||||
Assert.Contains("continue_merge", review.MergeMessage);
|
||||
|
||||
// Resolve the conflict on disk.
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");
|
||||
|
||||
var result = await sut.ContinueMerge(parentId, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Merged);
|
||||
Assert.Equal("Done", result.TaskStatus);
|
||||
Assert.Empty(result.Conflicts);
|
||||
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
||||
using var verify = _db.CreateContext();
|
||||
Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State);
|
||||
}
|
||||
|
||||
// ── AddTask model override ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user