merge(layer-c): inline conflict resolver + worker conflict plumbing

This commit is contained in:
mika kuns
2026-06-05 11:09:02 +02:00
15 changed files with 1518 additions and 3 deletions

View File

@@ -0,0 +1,104 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Conflicts;
using Xunit;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class ConflictResolverViewModelTests
{
private sealed class FakeWorker : StubWorkerClient
{
public string? WrittenPath;
public string? WrittenContent;
public bool Continued;
public bool Aborted;
public string ContinueStatus = "merged";
public override Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
=> Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null));
public override Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
=> Task.FromResult(new MergeConflictsDto(taskId, new[]
{
new ConflictFileDto("README.md", new[] { new ConflictHunkDto("ours\n", "theirs\n", "base\n") })
}));
public override Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
{
WrittenPath = path; WrittenContent = resolvedContent; return Task.CompletedTask;
}
public override Task<MergeResultDto> ContinueMergeAsync(string taskId)
{
Continued = true;
return Task.FromResult(new MergeResultDto(ContinueStatus, System.Array.Empty<string>(), null));
}
public override Task AbortMergeAsync(string taskId) { Aborted = true; return Task.CompletedTask; }
}
[Fact]
public async Task OpenAsync_LoadsConflicts_AndBlocksContinueUntilResolved()
{
var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
var hasConflicts = await vm.OpenAsync("main");
Assert.True(hasConflicts);
var file = Assert.Single(vm.Files);
Assert.Equal("README.md", file.Path);
Assert.False(vm.CanContinue);
file.Hunks[0].AcceptIncomingCommand.Execute(null);
Assert.True(vm.CanContinue);
}
[Fact]
public async Task Continue_WritesComposedResolution_AndClosesOnMerged()
{
var worker = new FakeWorker();
var vm = new ConflictResolverViewModel(worker, "task-1");
var closed = false;
vm.CloseRequested = () => closed = true;
await vm.OpenAsync("main");
vm.Files[0].Hunks[0].AcceptCurrentCommand.Execute(null);
await vm.ContinueCommand.ExecuteAsync(null);
Assert.Equal("README.md", worker.WrittenPath);
Assert.Equal("ours\n", worker.WrittenContent);
Assert.True(worker.Continued);
Assert.True(closed);
}
[Fact]
public async Task Continue_StaysOpenAndReportsError_WhenStillConflicted()
{
var worker = new FakeWorker { ContinueStatus = "conflict" };
var vm = new ConflictResolverViewModel(worker, "task-1");
var closed = false;
vm.CloseRequested = () => closed = true;
await vm.OpenAsync("main");
vm.Files[0].Hunks[0].AcceptBothCommand.Execute(null);
await vm.ContinueCommand.ExecuteAsync(null);
Assert.False(closed);
Assert.NotNull(vm.Error);
}
[Fact]
public async Task Abort_CallsWorkerAndCloses()
{
var worker = new FakeWorker();
var vm = new ConflictResolverViewModel(worker, "task-1");
var closed = false;
vm.CloseRequested = () => closed = true;
await vm.AbortCommand.ExecuteAsync(null);
Assert.True(worker.Aborted);
Assert.True(closed);
}
}

View File

@@ -621,6 +621,72 @@ public class TaskMergeServiceTests : IDisposable
// Cleanup
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
}
[Fact]
public async Task GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPath));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c1", wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
await SeedWorktree(db, task.Id, wtPath, "claudedo/c1", repo.BaseCommit);
var (svc, _) = BuildService(db);
var start = await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, start.Status);
var conflicts = await svc.GetConflictsAsync(task.Id, CancellationToken.None);
Assert.Equal(task.Id, conflicts.TaskId);
var file = Assert.Single(conflicts.Files);
Assert.Equal("README.md", file.Path);
Assert.Contains("main change", file.Ours);
Assert.Contains("branch change", file.Theirs);
Assert.NotNull(file.Base);
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
}
[Fact]
public async Task WriteResolutionAsync_ThenContinue_CompletesMerge()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPath));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c2", wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
await SeedWorktree(db, task.Id, wtPath, "claudedo/c2", repo.BaseCommit);
var (svc, _) = BuildService(db);
await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
await svc.WriteResolutionAsync(task.Id, "README.md", "# resolved by user\n", CancellationToken.None);
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.Equal("# resolved by user\n", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
}
}
#region Test doubles