Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/External/MergeMcpToolsTests.cs
T
Mika Kuns 4ee1d05f06 fix(worker): carry ExpectsNoDiff into the relocated merge MCP tools
Conflict resolution for the #266 merge: #247 had moved PreviewMerge/PreviewMergeSet
and MergePreviewSetEntryDto out of ExternalMcpService into MergeMcpTools, so #266's
in-place edits to those had to be re-applied at the new location, together with the
PreviewMergeSet_ReportsExpectsNoDiffPerTask test.
2026-09-02 13:59:22 +02:00

1293 lines
61 KiB
C#

using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using ClaudeDo.Worker.Tests.Services;
using ClaudeDo.Worker.Usage;
using ClaudeDo.Worker.Worktrees;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.External;
// A synchronously-collecting IProgress<T>: Progress<T> marshals through the SynchronizationContext
// captured at construction, which is unreliable to assert on immediately in a test.
file sealed class SyncProgress<T> : IProgress<T>
{
public readonly List<T> Reports = new();
public void Report(T value) => Reports.Add(value);
}
public sealed class MergeMcpToolsTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly HubBroadcaster _broadcaster;
private readonly List<GitRepoFixture> _repos = new();
private readonly List<(string repoDir, string wtPath)> _worktreeCleanups = new();
private int _numberSeed;
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
public MergeMcpToolsTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
_broadcaster = new HubBroadcaster(new CapturingHubContext());
}
public void Dispose()
{
foreach (var (repoDir, wtPath) in _worktreeCleanups)
{
try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { }
}
foreach (var r in _repos) r.Dispose();
_ctx.Dispose();
_db.Dispose();
}
private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedWorktreeAsync(
TaskStatus status = TaskStatus.Done)
{
var repo = new GitRepoFixture();
_repos.Add(repo);
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: status);
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));
return (task, list, wt);
}
private async Task<string> SeedListAsync(string name = "L")
{
var id = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = id, Name = name, CreatedAt = DateTime.UtcNow });
return id;
}
private async Task<TaskEntity> SeedTaskAsync(
string listId, string title = "t", TaskStatus status = TaskStatus.Idle, string? parentId = null)
{
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = title,
Status = status,
ParentTaskId = parentId,
CreatedAt = DateTime.UtcNow,
CommitType = "chore",
Number = ++_numberSeed,
};
await _tasks.AddAsync(task);
return task;
}
private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedConflictingWorktreeAsync(
TaskStatus status = TaskStatus.WaitingForReview)
{
var (task, list, wt) = await SeedWorktreeAsync(status);
File.WriteAllText(Path.Combine(wt.WorktreePath, "README.md"), "# from worktree\n");
GitRepoFixture.RunGit(wt.WorktreePath, "add", "README.md");
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "worktree edit");
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# from main\n");
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "main edit");
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");
}
private async Task<string> CreateActiveWorktreeAsync(
GitRepoFixture repo, string taskId, string fileName, string content)
{
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_worktreeCleanups.Add((repo.RepoDir, wtPath));
var branch = $"claudedo/{taskId[..8]}";
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, fileName), content);
GitRepoFixture.RunGit(wtPath, "add", fileName);
GitRepoFixture.RunGit(wtPath, "commit", "-m", $"edit {fileName}");
using var ctx = _db.CreateContext();
ctx.Worktrees.Add(new WorktreeEntity
{
TaskId = taskId, Path = wtPath, BranchName = branch,
BaseCommit = repo.BaseCommit,
HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
return wtPath;
}
// queue is accepted (and ignored) so every extracted test body can keep calling
// BuildSut(CreateQueue()) unchanged -- MergeMcpTools itself has no QueueService dependency.
private MergeMcpTools BuildSut(QueueService queue) => BuildSut(queue, new VerifyCommandRunner());
private MergeMcpTools BuildSut(QueueService queue, IVerifyCommandRunner verify)
{
var git = new GitService();
var factory = _db.CreateFactory();
var state = TaskStateServiceBuilder.Build(factory).State;
var merge = new TaskMergeService(factory, git, _broadcaster, state, verify, NullLogger<TaskMergeService>.Instance);
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
var planningMerge = new PlanningMergeOrchestrator(
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
return new MergeMcpTools(_tasks, _lists, _broadcaster, git, factory, merge, planningMerge);
}
// ReviewTask (approve) still lives on ExternalMcpService. Several tests here approve a task
// first, then act on the resulting in-progress merge via MergeMcpTools -- for a plain (childless)
// task that's safe with two independently-built instances (TaskMergeService/GitService are
// git-disk- and DB-backed, not instance-stateful). Only the parent/children unit-merge path
// (PlanningMergeOrchestrator's in-memory active-merge tracking) needs the two SUTs to share the
// same underlying objects -- see BuildSharedSuts below.
private ExternalMcpService BuildReviewSut(QueueService queue) => BuildReviewSut(queue, new VerifyCommandRunner());
private ExternalMcpService BuildReviewSut(QueueService queue, IVerifyCommandRunner verify)
{
var git = new GitService();
var factory = _db.CreateFactory();
var state = TaskStateServiceBuilder.Build(factory).State;
var merge = new TaskMergeService(factory, git, _broadcaster, state, verify, NullLogger<TaskMergeService>.Instance);
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
var planningMerge = new PlanningMergeOrchestrator(
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
return new ExternalMcpService(
_tasks, _lists, queue, _broadcaster, state, git, factory, merge, planningMerge,
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
}
// Parent/children unit merges route through PlanningMergeOrchestrator's in-memory _states
// dictionary (ContinueMerge/AbortMerge check HasActiveMerge on it) -- a review that starts the
// unit merge and a later continue/abort must observe the SAME orchestrator instance, so this
// shares git/factory/state/merge/planningMerge between the two SUTs instead of building fresh.
private (ExternalMcpService ReviewSut, MergeMcpTools MergeSut) BuildSharedSuts(QueueService queue)
{
var git = new GitService();
var factory = _db.CreateFactory();
var state = TaskStateServiceBuilder.Build(factory).State;
var merge = new TaskMergeService(factory, git, _broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
var planningMerge = new PlanningMergeOrchestrator(
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
var reviewSut = new ExternalMcpService(
_tasks, _lists, queue, _broadcaster, state, git, factory, merge, planningMerge,
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
var mergeSut = new MergeMcpTools(_tasks, _lists, _broadcaster, git, factory, merge, planningMerge);
return (reviewSut, mergeSut);
}
private QueueService CreateQueue()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_merge_{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
var cfg = new WorkerConfig
{
SandboxRoot = Path.Combine(tempDir, "sandbox"),
LogRoot = Path.Combine(tempDir, "logs"),
QueueBackstopIntervalMs = 50,
};
var fake = new FakeClaudeProcess();
var hubCtx = new CapturingHubContext();
var broadcaster = new HubBroadcaster(hubCtx);
var dbFactory = _db.CreateFactory();
var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
var argsBuilder = new ClaudeArgsBuilder();
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg,
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
var waker = new ClaudeDo.Worker.Queue.QueueWaker();
var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
var runCancels = new RunCancellationRegistry(NullLogger<RunCancellationRegistry>.Instance);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, runCancels);
var usageState = new UsageState();
return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state, runCancels,
new FakeUsageGate(), usageState, broadcaster,
new UsageLimitAutoContinueCoordinator(dbFactory, usageState, overrideSlot, broadcaster, NullLogger<UsageLimitAutoContinueCoordinator>.Instance));
}
[Fact]
public async Task MergeTask_NotDone_Throws()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
var sut = BuildSut(CreateQueue());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None));
Assert.Contains("Done", ex.Message);
}
[Fact]
public async Task MergeTask_DryRun_DoesNotMerge()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done);
var sut = BuildSut(CreateQueue());
var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, cancellationToken: CancellationToken.None);
Assert.False(result.Merged);
Assert.Null(result.MergeCommit);
}
[Fact]
public async Task MergeTask_WaitingForReview_WithoutFlag_Throws()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None));
Assert.Contains("Done", ex.Message);
}
[Fact]
public async Task MergeTask_WaitingForReview_WithFlag_DryRun_Succeeds()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, allowWaitingForReview: true, cancellationToken: CancellationToken.None);
Assert.False(result.Merged);
Assert.Null(result.MergeCommit);
}
[Fact]
public async Task MergeTask_WaitingForReview_WithFlag_MarksTaskDone()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var result = await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, cancellationToken: CancellationToken.None);
Assert.True(result.Merged);
var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, reloaded!.Status);
}
[Fact]
public async Task MergeTask_LeaveConflictsInTree_LeavesMarkersAndKeepsRepoMidMerge()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
var sut = BuildSut(CreateQueue());
var result = await sut.MergeTask(task.Id, target, true, dryRun: false,
allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
Assert.False(result.Merged);
Assert.True(result.ConflictsInTree);
Assert.Equal(list.WorkingDir, result.RepoPath);
Assert.Contains("README.md", result.Conflicts);
Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status);
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
}
[Fact]
public async Task MergeTask_ConflictWithoutLeaveFlag_AbortsCleanly()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
var sut = BuildSut(CreateQueue());
var result = await sut.MergeTask(task.Id, target, true, dryRun: false,
allowWaitingForReview: true, leaveConflictsInTree: false, cancellationToken: CancellationToken.None);
Assert.False(result.Merged);
Assert.False(result.ConflictsInTree);
Assert.Null(result.RepoPath);
Assert.Contains("README.md", result.Conflicts);
Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
}
[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 reviewSut = BuildReviewSut(CreateQueue());
await reviewSut.ReviewTask(task.Id, "approve", null, null,
leaveConflictsInTree: true, cancellationToken: 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 sut = BuildSut(CreateQueue());
var result = await sut.ContinueMerge(task.Id, cancellationToken: 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_SlowVerifyCommand_ReportsProgressWellBeforeIdleTimeout()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var original = TaskMergeService.ProgressReportInterval;
TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50);
try
{
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var plainSut = BuildReviewSut(CreateQueue());
await plainSut.ReviewTask(task.Id, "approve", null, null,
leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n");
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
using (var ctx = _db.CreateContext())
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });
var fakeVerify = new FakeVerifyCommandRunner
{
Result = new VerifyCommandResult(0, false, "ok"),
Delay = TimeSpan.FromMilliseconds(300),
};
var sut = BuildSut(CreateQueue(), fakeVerify);
var reports = new List<ProgressNotificationValue>();
var progress = new Progress<ProgressNotificationValue>(reports.Add);
var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None, progress: progress);
Assert.True(result.Merged);
Assert.Equal("Done", result.TaskStatus);
await Task.Delay(200);
Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running"));
}
finally
{
TaskMergeService.ProgressReportInterval = original;
}
}
[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 reviewSut = BuildReviewSut(CreateQueue());
await reviewSut.ReviewTask(task.Id, "approve", null, null,
leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
// Markers never resolved — continue must refuse and list the files.
var sut = BuildSut(CreateQueue());
var result = await sut.ContinueMerge(task.Id, cancellationToken: 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 reviewSut = BuildReviewSut(CreateQueue());
await reviewSut.ReviewTask(task.Id, "approve", null, null,
leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
var sut = BuildSut(CreateQueue());
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: 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 GetMergeConflicts_ReportsHunksWithStartLineAndDiff3Base()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var reviewSut = BuildReviewSut(CreateQueue());
await reviewSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
var sut = BuildSut(CreateQueue());
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: 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: 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: 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: 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 reviewSut = BuildReviewSut(CreateQueue());
await reviewSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
var sut = BuildSut(CreateQueue());
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");
}
[Fact]
public async Task RevertMerge_NotMerged_Throws()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done);
var sut = BuildSut(CreateQueue());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.RevertMerge(task.Id, "main", CancellationToken.None));
Assert.Contains("expected Merged", ex.Message);
}
[Fact]
public async Task RevertMerge_AfterCleanMerge_RevertsAndReturnsTaskToWaitingForReview()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done);
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "new\n");
GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A");
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file");
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
var sut = BuildSut(CreateQueue());
await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None);
Assert.True(File.Exists(Path.Combine(list.WorkingDir!, "added.txt")));
var result = await sut.RevertMerge(task.Id, target, CancellationToken.None);
Assert.True(result.Reverted);
Assert.False(string.IsNullOrWhiteSpace(result.RevertCommit));
Assert.Empty(result.Conflicts);
Assert.False(File.Exists(Path.Combine(list.WorkingDir!, "added.txt")));
var reloaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status);
}
[Fact]
public async Task RevertMerge_ConflictingRevert_ReturnsRevertedFalseWithConflictsAndAbortsCleanly()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done);
File.WriteAllText(Path.Combine(wt.WorktreePath, "README.md"), "# test repo\nfeature\n");
GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A");
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: append feature line");
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
var sut = BuildSut(CreateQueue());
await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None);
// A later commit on main edits the exact line the merge introduced, so the revert conflicts.
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# test repo\npost-merge edit\n");
GitRepoFixture.RunGit(list.WorkingDir!, "add", "-A");
GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "chore: edit the same line");
var headBefore = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim();
var result = await sut.RevertMerge(task.Id, target, CancellationToken.None);
Assert.False(result.Reverted);
Assert.Contains("README.md", result.Conflicts);
Assert.False(await new GitService().IsMidRevertAsync(list.WorkingDir!));
Assert.Equal(headBefore, GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim());
var reloaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, reloaded!.Status);
}
[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, Number = ++_numberSeed });
ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed });
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 (reviewSut, sut) = BuildSharedSuts(CreateQueue());
var review = await reviewSut.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: 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);
}
[Fact]
public async Task PreviewMerge_CleanMerge_ReturnsCleanStatusAndDoesNotChangeRepo()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
var git = new GitService();
var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
Assert.Empty(result.ConflictFiles);
Assert.Equal(1, result.ChangedFileCount);
Assert.Equal(0, result.Behind);
Assert.False(result.IsEmpty);
Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None));
Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
}
[Fact]
public async Task PreviewMerge_EmptyWorktree_ReturnsIsEmptyTrue()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
// Worktree created but nothing committed beyond base -- e.g. a child that reported
// CLAUDEDO_BLOCKED before writing any code.
var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
Assert.Equal(0, result.ChangedFileCount);
Assert.True(result.IsEmpty);
}
[Fact]
public async Task PreviewMerge_WorktreeLessHandlerTask_EmptyRange_ReturnsIsEmptyTrue()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
// Worktree-less list-handler host task whose handler range never actually committed
// anything: HandlerBaseCommit == HandlerHeadCommit, not "no worktree -> unknown".
using (var ctx = _db.CreateContext())
{
var t = await ctx.Tasks.FindAsync(task.Id);
t!.HandlerBaseCommit = repo.BaseCommit;
t.HandlerHeadCommit = repo.BaseCommit;
await ctx.SaveChangesAsync();
}
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
Assert.Equal(0, result.ChangedFileCount);
Assert.True(result.IsEmpty);
}
[Fact]
public async Task PreviewMerge_WorktreeLessHandlerTask_NonEmptyRange_ReturnsIsEmptyFalse()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
using (var ctx = _db.CreateContext())
{
var t = await ctx.Tasks.FindAsync(task.Id);
t!.HandlerBaseCommit = repo.BaseCommit;
t.HandlerHeadCommit = headCommit;
await ctx.SaveChangesAsync();
}
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
Assert.Equal(1, result.ChangedFileCount);
Assert.False(result.IsEmpty);
}
[Fact]
public async Task PreviewMerge_ConflictingBranches_ReturnsConflictStatusAndDoesNotChangeRepo()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var git = new GitService();
var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewConflict, result.Status);
Assert.Contains("README.md", result.ConflictFiles);
Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None));
Assert.False(await git.IsMidMergeAsync(list.WorkingDir!));
Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
}
[Fact]
public async Task PreviewMerge_NoWorktree_Throws()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
var sut = BuildSut(CreateQueue());
await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.PreviewMerge(task.Id, null, CancellationToken.None));
}
[Fact]
public async Task PreviewMerge_MissingWorkingDir_ThrowsClearError()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
using (var ctx = _db.CreateContext())
{
var row = await ctx.Lists.FindAsync(list.Id);
row!.WorkingDir = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}");
await ctx.SaveChangesAsync();
}
var sut = BuildSut(CreateQueue());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.PreviewMerge(task.Id, null, CancellationToken.None));
Assert.Contains("working directory", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task PreviewMerge_VerifyCommandConfigured_RunsInScratchWorktreeAndDoesNotTouchTarget()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
using (var ctx = _db.CreateContext())
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });
var git = new GitService();
var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
var sut = BuildSut(CreateQueue(), fakeVerify);
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
Assert.Equal(0, result.VerifyExitCode);
Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
Assert.NotEqual(list.WorkingDir, fakeVerify.CapturedWorkingDir);
Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None));
Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
}
[Fact]
public async Task PreviewMerge_TargetChangedSameFileSinceFork_ReportsStaleFiles()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
await CreateActiveWorktreeAsync(repo, task.Id, "shared.txt", "from branch\n");
// The target branch itself moved on and touched the same file after the fork point.
File.WriteAllText(Path.Combine(repo.RepoDir, "shared.txt"), "from main\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "shared.txt");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main also touched shared.txt");
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None);
Assert.Equal(1, result.Behind);
Assert.NotNull(result.StaleFiles);
Assert.Contains("shared.txt", result.StaleFiles!);
}
[Fact]
public async Task PreviewMerge_TargetBehindButUnrelatedFile_ReportsNoStaleFiles()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
await CreateActiveWorktreeAsync(repo, task.Id, "branch-only.txt", "from branch\n");
// Target moved on (behind > 0) but touched an entirely different file -- a stale
// branch that still collides with nothing.
File.WriteAllText(Path.Combine(repo.RepoDir, "unrelated.txt"), "from main\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "unrelated.txt");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main touched something else");
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None);
Assert.Equal(1, result.Behind);
Assert.NotNull(result.StaleFiles);
Assert.Empty(result.StaleFiles!);
}
[Fact]
public async Task PreviewMerge_WorktreeLessHandlerTask_ReportsNoStaleFiles()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
using (var ctx = _db.CreateContext())
{
var t = await ctx.Tasks.FindAsync(task.Id);
t!.HandlerBaseCommit = repo.BaseCommit;
t.HandlerHeadCommit = headCommit;
await ctx.SaveChangesAsync();
}
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
Assert.NotNull(result.StaleFiles);
Assert.Empty(result.StaleFiles!);
}
[Fact]
public async Task PreviewMergeSet_TwoTasksSharedFile_ReportsOverlap()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);
await CreateActiveWorktreeAsync(repo, taskA.Id, "shared.txt", "a change\n");
await CreateActiveWorktreeAsync(repo, taskB.Id, "shared.txt", "b change\n");
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);
Assert.Equal(2, result.Tasks.Count);
Assert.All(result.Tasks, t => Assert.Null(t.Error));
Assert.All(result.Tasks, t => Assert.Equal(TaskMergeService.PreviewClean, t.Status));
var overlap = Assert.Single(result.Overlaps);
Assert.Equal("shared.txt", overlap.File);
Assert.Equal(
new[] { taskA.Id, taskB.Id }.OrderBy(x => x),
overlap.TaskIds.OrderBy(x => x));
// Equal file sets overlap but neither is a PROPER subset of the other.
Assert.Empty(result.Subsets);
}
[Fact]
public async Task PreviewMergeSet_TaskFilesAreProperSubsetOfAnother_ReportsSubset()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var subsetTask = await SeedTaskAsync(listId, "subset", TaskStatus.WaitingForReview);
var supersetTask = await SeedTaskAsync(listId, "superset", TaskStatus.WaitingForReview);
await CreateActiveWorktreeAsync(repo, subsetTask.Id, "shared.txt", "shared change\n");
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_worktreeCleanups.Add((repo.RepoDir, wtPath));
var branch = $"claudedo/{supersetTask.Id[..8]}";
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "shared.txt"), "shared change too\n");
File.WriteAllText(Path.Combine(wtPath, "extra.txt"), "extra\n");
GitRepoFixture.RunGit(wtPath, "add", "-A");
GitRepoFixture.RunGit(wtPath, "commit", "-m", "edit shared+extra");
using (var ctx = _db.CreateContext())
{
ctx.Worktrees.Add(new WorktreeEntity
{
TaskId = supersetTask.Id, 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 result = await sut.PreviewMergeSet([subsetTask.Id, supersetTask.Id], "main", cancellationToken: CancellationToken.None);
var relation = Assert.Single(result.Subsets);
Assert.Equal(subsetTask.Id, relation.TaskId);
Assert.Equal(supersetTask.Id, relation.SupersetTaskId);
}
[Fact]
public async Task PreviewMergeSet_SingleTaskId_ReturnsEmptyOverlap()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMergeSet([task.Id], target, cancellationToken: CancellationToken.None);
Assert.Single(result.Tasks);
Assert.Empty(result.Overlaps);
}
[Fact]
public async Task PreviewMergeSet_ReportsExpectsNoDiffPerTask()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
task.ExpectsNoDiff = true;
await _tasks.UpdateAsync(task, CancellationToken.None);
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMergeSet([task.Id], target, cancellationToken: CancellationToken.None);
Assert.True(Assert.Single(result.Tasks).ExpectsNoDiff);
}
[Fact]
public async Task PreviewMergeSet_ReportsProgressPerTask()
{
var listId = await SeedListAsync();
var t1 = await SeedTaskAsync(listId, "a");
var t2 = await SeedTaskAsync(listId, "b");
var sut = BuildSut(CreateQueue());
var progress = new SyncProgress<ProgressNotificationValue>();
// Neither task has a worktree -- each entry fails individually (PreviewUnavailable), but
// the loop still reports progress per task since a per-task InvalidOperationException
// doesn't abort the batch.
await sut.PreviewMergeSet(
[t1.Id, t2.Id], "main", cancellationToken: CancellationToken.None, progress: progress);
Assert.Equal(2, progress.Reports.Count);
Assert.Equal("Previewing merges (1/2)", progress.Reports[0].Message);
Assert.Equal("Previewing merges (2/2)", progress.Reports[1].Message);
Assert.Equal(2, progress.Reports[1].Total);
}
[Fact]
public async Task PreviewMergeSet_OneTaskWithoutWorktree_ReportsErrorWithoutAbortingOthers()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
var listId2 = await SeedListAsync("no-worktree-list");
var noWorktreeTask = await SeedTaskAsync(listId2, "b", TaskStatus.Idle);
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMergeSet([task.Id, noWorktreeTask.Id], target, cancellationToken: CancellationToken.None);
Assert.Equal(2, result.Tasks.Count);
var ok = result.Tasks.Single(t => t.TaskId == task.Id);
Assert.Null(ok.Error);
Assert.Equal(TaskMergeService.PreviewClean, ok.Status);
var failed = result.Tasks.Single(t => t.TaskId == noWorktreeTask.Id);
Assert.NotNull(failed.Error);
Assert.Equal(TaskMergeService.PreviewUnavailable, failed.Status);
Assert.Empty(result.Overlaps);
}
[Fact]
public async Task PreviewMergeSet_MixedEmptyAndNonEmpty_ReportsIsEmptyPerEntry()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var emptyTask = await SeedTaskAsync(listId, "empty", TaskStatus.WaitingForReview);
var fullTask = await SeedTaskAsync(listId, "full", TaskStatus.WaitingForReview);
var emptyBranch = $"claudedo/{emptyTask.Id[..8]}";
var emptyWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_worktreeCleanups.Add((repo.RepoDir, emptyWtPath));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", emptyBranch, emptyWtPath, repo.BaseCommit);
using (var ctx = _db.CreateContext())
{
ctx.Worktrees.Add(new WorktreeEntity
{
TaskId = emptyTask.Id, Path = emptyWtPath, BranchName = emptyBranch,
BaseCommit = repo.BaseCommit,
HeadCommit = repo.BaseCommit,
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
}
await CreateActiveWorktreeAsync(repo, fullTask.Id, "full.txt", "content\n");
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMergeSet([emptyTask.Id, fullTask.Id], "main", cancellationToken: CancellationToken.None);
var emptyEntry = result.Tasks.Single(t => t.TaskId == emptyTask.Id);
var fullEntry = result.Tasks.Single(t => t.TaskId == fullTask.Id);
Assert.Null(emptyEntry.Error);
Assert.True(emptyEntry.IsEmpty);
Assert.Null(fullEntry.Error);
Assert.False(fullEntry.IsEmpty);
}
[Fact]
public async Task PreviewMergeSet_DefaultRunVerifyFalse_NeverInvokesRunnerEvenWithCommandConfigured()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
using (var ctx = _db.CreateContext())
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" });
var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n");
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
var sut = BuildSut(CreateQueue(), fakeVerify);
var result = await sut.PreviewMergeSet([task.Id], "main", cancellationToken: CancellationToken.None);
Assert.Null(result.Tasks.Single().VerifyExitCode);
Assert.Null(fakeVerify.CapturedCommand);
}
[Fact]
public async Task PreviewMergeSet_RunVerifyTrue_InvokesRunnerPerTask()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
using (var ctx = _db.CreateContext())
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" });
var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n");
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
var sut = BuildSut(CreateQueue(), fakeVerify);
var result = await sut.PreviewMergeSet([task.Id], "main", runVerify: true, cancellationToken: CancellationToken.None);
Assert.Equal(0, result.Tasks.Single().VerifyExitCode);
Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
}
}