Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
T
Mika Kuns b8caa27027 feat(worker): add generic OperationProgress channel, port MergeProgress onto it
Merge/verify phases now broadcast over a generic (opKey, phase, current,
total) Hub event instead of a merge-specific one, so future producers
(worktree cleanup, startup recovery, planning integration) can reuse it.
IWorkerClient.MergeProgressEvent stays as a thin forwarder for existing
consumers (elapsed seconds riding in the generic "current" slot).
2026-08-13 08:31:23 +02:00

1803 lines
84 KiB
C#

using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Services;
public class TaskMergeServiceTests : IDisposable
{
private readonly List<DbFixture> _dbs = new();
private readonly List<GitRepoFixture> _repos = new();
private readonly List<(string repoDir, string wtPath)> _wtCleanups = new();
private DbFixture NewDb() { var d = new DbFixture(); _dbs.Add(d); return d; }
private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }
public void Dispose()
{
foreach (var (repoDir, wtPath) in _wtCleanups)
{
try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { }
}
foreach (var d in _dbs) try { d.Dispose(); } catch { }
foreach (var r in _repos) try { r.Dispose(); } catch { }
}
private static (TaskMergeService svc, MergeRecordingClientProxy proxy) BuildService(
DbFixture db, IVerifyCommandRunner? verify = null)
{
var fakeHub = new MergeRecordingHubContext();
var broadcaster = new HubBroadcaster(fakeHub);
var state = TaskStateServiceBuilder.Build(db.CreateFactory()).State;
var svc = new TaskMergeService(
db.CreateFactory(),
new GitService(),
broadcaster,
state,
verify ?? new VerifyCommandRunner(),
NullLogger<TaskMergeService>.Instance);
return (svc, fakeHub.Proxy);
}
private static async Task SeedVerifyCommand(DbFixture db, string listId, string command)
{
using var ctx = db.CreateContext();
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = command });
}
private static WorktreeManager BuildWorktreeManager(DbFixture db)
{
return new WorktreeManager(
new GitService(),
db.CreateFactory(),
new ClaudeDo.Worker.Config.WorkerConfig { WorktreeRootStrategy = "sibling" },
NullLogger<WorktreeManager>.Instance);
}
private static async Task SeedWorktree(
DbFixture db, string taskId, string path, string branchName, string baseCommit,
WorktreeState state = WorktreeState.Active, string? mergeCommit = null)
{
var wt = new WorktreeEntity
{
TaskId = taskId,
Path = path,
BranchName = branchName,
BaseCommit = baseCommit,
State = state,
MergeCommit = mergeCommit,
CreatedAt = DateTime.UtcNow,
};
using var ctx = db.CreateContext();
await new WorktreeRepository(ctx).AddAsync(wt);
}
private static async Task<(ListEntity list, TaskEntity task)> SeedListAndTask(
DbFixture db, string workingDir, TaskStatus status)
{
var list = new ListEntity
{
Id = Guid.NewGuid().ToString(),
Name = "merge-test",
WorkingDir = workingDir,
DefaultCommitType = "feat",
CreatedAt = DateTime.UtcNow,
};
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = list.Id,
Title = "merge-task",
Status = status,
CreatedAt = DateTime.UtcNow,
};
using var ctx = db.CreateContext();
await new ListRepository(ctx).AddAsync(list);
await new TaskRepository(ctx).AddAsync(task);
return (list, task);
}
[Fact]
public async Task MergeAsync_RunningTask_ReturnsBlocked()
{
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Running);
var (svc, proxy) = BuildService(db);
var result = await svc.MergeAsync(task.Id, "main", false, "msg", CancellationToken.None);
Assert.Equal("blocked", result.Status);
Assert.Contains("running", result.ErrorMessage ?? "");
Assert.Empty(proxy.Calls);
}
[Fact]
public async Task MergeAsync_NoWorktree_ReturnsBlocked()
{
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done);
var (svc, _) = BuildService(db);
var result = await svc.MergeAsync(task.Id, "main", false, "msg", CancellationToken.None);
Assert.Equal("blocked", result.Status);
Assert.Contains("no worktree", result.ErrorMessage ?? "");
}
[Fact]
public async Task MergeAsync_FfAble_KeepWorktree_SetsMergedAndBroadcasts()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
// Create worktree and make a real commit inside it.
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, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, proxy) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
commitMessage: "Merge task", ct: CancellationToken.None);
Assert.Equal("merged", result.Status);
Assert.Empty(result.ConflictFiles);
// Worktree state now Merged, dir and branch still present.
using var ctx = db.CreateContext();
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.NotNull(wt);
Assert.Equal(WorktreeState.Merged, wt!.State);
Assert.True(Directory.Exists(wtCtx.WorktreePath));
// Broadcast fired.
Assert.Contains(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == task.Id);
// added.txt is now on the main branch of the repo.
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));
}
[Fact]
public async Task MergeAsync_FfAble_RemoveWorktree_CleansEverything()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
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, "feature.txt"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true,
commitMessage: "Merge", ct: CancellationToken.None);
Assert.Equal("merged", result.Status);
Assert.False(Directory.Exists(wtCtx.WorktreePath));
// Branch must be gone.
var branches = await new GitService().ListLocalBranchesAsync(repo.RepoDir);
Assert.DoesNotContain(wtCtx.BranchName, branches);
// DB state still Merged.
using var ctx = db.CreateContext();
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Merged, wt!.State);
}
[Fact]
public async Task MergeAsync_DivergedNonConflicting_ProducesMergeCommit()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
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, "feature.txt"), "feat\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
// Advance main by adding a different file.
File.WriteAllText(Path.Combine(repo.RepoDir, "main-only.txt"), "main\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: main moved");
var (svc, _) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
commitMessage: "Merge diverged", ct: CancellationToken.None);
Assert.Equal("merged", result.Status);
// HEAD must be a merge commit (two parents).
var parents = GitRepoFixture.RunGit(repo.RepoDir, "rev-list", "--parents", "-n", "1", "HEAD").Trim();
Assert.True(parents.Split(' ').Length >= 3, $"Expected merge commit, got '{parents}'");
}
[Fact]
public async Task MergeAsync_Conflict_AbortsAndReturnsConflictedFiles()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
// Worktree edits README.md
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
// Main also edits README.md (conflicting).
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: main edit");
var mainHeadBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var (svc, proxy) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true,
commitMessage: "Merge", ct: CancellationToken.None);
Assert.Equal("conflict", result.Status);
Assert.Contains("README.md", result.ConflictFiles);
// Main branch must be restored exactly.
var mainHeadAfter = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
Assert.Equal(mainHeadBefore, mainHeadAfter);
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
// Worktree state stays Active (no broadcast).
using var ctx = db.CreateContext();
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Active, wt!.State);
Assert.DoesNotContain(proxy.Calls, c => c.Method == "WorktreeUpdated");
}
[Fact]
public async Task MergeAsync_BlankCommitMessage_UsesConventionalDefault()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
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, "feature.txt"), "feat\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
commitMessage: "", ct: CancellationToken.None);
Assert.Equal("merged", result.Status);
var subject = GitRepoFixture.RunGit(repo.RepoDir, "log", "-1", "--format=%s").Trim();
// task.CommitType (not the list default) — same type the task's own commits used.
Assert.Equal($"{task.CommitType}(merge-test): merge merge-task", subject);
var body = GitRepoFixture.RunGit(repo.RepoDir, "log", "-1", "--format=%b");
Assert.Contains($"ClaudeDo-Task: {task.Id}", body);
}
[Fact]
public async Task MergeAsync_VerifyGate_BroadcastsMergingAndVerifyingPhases()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
await SeedVerifyCommand(db, list.Id, "echo ok");
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, "feature.txt"), "feat\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, proxy) = BuildService(db, new FakeVerifyCommandRunner());
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
commitMessage: "", ct: CancellationToken.None);
Assert.Equal("merged", result.Status);
Assert.Contains(proxy.Calls, c => c.Method == "OperationProgress"
&& (string?)c.Args[0] == task.Id && (string?)c.Args[1] == TaskMergeService.PhaseMerging);
Assert.Contains(proxy.Calls, c => c.Method == "OperationProgress"
&& (string?)c.Args[0] == task.Id && (string?)c.Args[1] == TaskMergeService.PhaseVerifying);
Assert.Contains(proxy.Calls, c => c.Method == "WorkerLog"
&& c.Args[0] is string s && s.Contains("Verify command running"));
}
[Fact]
public async Task GetTargetsAsync_ReturnsDefaultCommitMessage()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
var (svc, _) = BuildService(db);
var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None);
Assert.Equal($"{task.CommitType}(merge-test): merge merge-task", targets.DefaultCommitMessage.Split('\n')[0]);
}
[Fact]
public async Task GetTargetsAsync_ReturnsCurrentAndLocalBranches()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "feature/extra");
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
var (svc, _) = BuildService(db);
var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None);
Assert.False(string.IsNullOrWhiteSpace(targets.DefaultBranch));
Assert.Contains("feature/extra", targets.LocalBranches);
Assert.Contains(targets.DefaultBranch, targets.LocalBranches);
}
[Fact]
public async Task MergeAsync_DirtyWorkingTree_ReturnsBlocked()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
// Modify a tracked file in the target working dir (real uncommitted change).
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "dirty\n");
var (svc, _) = BuildService(db);
var result = await svc.MergeAsync(task.Id, "main", false, "Merge", CancellationToken.None);
Assert.Equal("blocked", result.Status);
Assert.Contains("uncommitted", result.ErrorMessage ?? "");
}
[Fact]
public async Task MergeAsync_UntrackedFileInTargetWorkingTree_DoesNotBlock()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
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, "feature.txt"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
// An untracked file (e.g. left behind by a concurrent session) must not block the merge.
File.WriteAllText(Path.Combine(repo.RepoDir, "untracked.txt"), "stray\n");
var (svc, _) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
commitMessage: "Merge", ct: CancellationToken.None);
Assert.Equal("merged", result.Status);
}
[Fact]
public async Task MergeAsync_BranchAddsPathUntrackedInTarget_RefusesAndLeavesLocalContentIntact()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
// The task branch creates a brand new file at a path it has never seen tracked.
Directory.CreateDirectory(Path.Combine(wtCtx.WorktreePath, "docs"));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "docs", "PLUGIN.md"), "agent-written replacement\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
// The same path already exists, untracked, in the target working directory — never
// committed, not ignored — carrying content unrelated to the task branch.
Directory.CreateDirectory(Path.Combine(repo.RepoDir, "docs"));
var localPath = Path.Combine(repo.RepoDir, "docs", "PLUGIN.md");
const string localContent = "local plugin docs — must survive\n";
File.WriteAllText(localPath, localContent);
var (svc, proxy) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
commitMessage: "Merge task", ct: CancellationToken.None);
Assert.Equal(TaskMergeService.StatusUntrackedCollision, result.Status);
Assert.Contains("docs/PLUGIN.md", result.ConflictFiles);
Assert.Contains("docs/PLUGIN.md", result.ErrorMessage ?? "");
Assert.Contains($"{System.Text.Encoding.UTF8.GetByteCount(localContent)} bytes", result.ErrorMessage ?? "");
// Nothing landed: no merge was attempted, the local file is untouched, worktree unaffected.
Assert.Equal(localContent, File.ReadAllText(localPath));
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
Assert.DoesNotContain(proxy.Calls, c => c.Method == "WorktreeUpdated");
using var ctx = db.CreateContext();
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Active, wt!.State);
}
[Fact]
public async Task MergeAsync_TargetBranchDifferentFromHead_ChecksOutBeforeMerging()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
// Create a feature branch in the repo (from current HEAD).
GitRepoFixture.RunGit(repo.RepoDir, "branch", "feature/target");
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, "feat.txt"), "data\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
// Repo is currently on main/master; request merge into feature/target.
var result = await svc.MergeAsync(task.Id, "feature/target", removeWorktree: false,
commitMessage: "Merge into feature", ct: CancellationToken.None);
Assert.Equal("merged", result.Status);
// HEAD must now be feature/target.
var head = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
Assert.Equal("feature/target", head);
// The merged file must exist on feature/target.
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "feat.txt")));
}
[Fact]
public async Task MergeAsync_TargetBranchDoesNotExist_ReturnsBlocked()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
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, "x.txt"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var result = await svc.MergeAsync(task.Id, "nonexistent/branch", removeWorktree: false,
commitMessage: "Merge", ct: CancellationToken.None);
Assert.Equal("blocked", result.Status);
Assert.Contains("switch target branch", result.ErrorMessage ?? "");
}
[Fact]
public async Task ContinueMergeAsync_AfterUserResolves_CompletesMergeAndSetsWorktreeMerged()
{
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/t2", 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.Done);
await SeedWorktree(db, task.Id, wtPath, "claudedo/t2", repo.BaseCommit);
var (svc, _) = BuildService(db);
var first = await svc.MergeAsync(task.Id, "main", false, "msg",
leaveConflictsInTree: true, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, first.Status);
// Simulate the user resolving the conflict.
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.False(File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD")));
using var ctx = db.CreateContext();
var wt = ctx.Worktrees.Single(w => w.TaskId == task.Id);
Assert.Equal(WorktreeState.Merged, wt.State);
}
[Fact]
public async Task ContinueMergeAsync_UnrelatedChangesInSharedCheckout_AreNotStagedOrCommitted()
{
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");
File.WriteAllText(Path.Combine(repo.RepoDir, "tracked.txt"), "tracked v1\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "tracked.txt");
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/t5", 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.Done);
await SeedWorktree(db, task.Id, wtPath, "claudedo/t5", repo.BaseCommit);
var (svc, _) = BuildService(db);
var first = await svc.MergeAsync(task.Id, "main", false, "msg",
leaveConflictsInTree: true, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, first.Status);
// Simulate the user resolving the conflict.
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");
// A concurrent session leaves stray changes in this shared checkout while the merge is
// paused — these must survive continue_merge untouched and never enter the merge commit.
File.WriteAllText(Path.Combine(repo.RepoDir, "untracked.txt"), "from another session\n");
File.WriteAllText(Path.Combine(repo.RepoDir, "tracked.txt"), "tracked v2 (unrelated edit)\n");
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "untracked.txt")));
Assert.Equal("from another session\n", File.ReadAllText(Path.Combine(repo.RepoDir, "untracked.txt")));
Assert.Equal("tracked v2 (unrelated edit)\n", File.ReadAllText(Path.Combine(repo.RepoDir, "tracked.txt")));
var status = GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain");
Assert.Contains("?? untracked.txt", status);
Assert.Contains(" M tracked.txt", status);
var committedFiles = GitRepoFixture.RunGit(repo.RepoDir, "show", "--name-only", "--pretty=format:", "HEAD");
Assert.DoesNotContain("untracked.txt", committedFiles);
Assert.DoesNotContain("tracked.txt", committedFiles);
}
[Fact]
public async Task ContinueMergeAsync_UnresolvedConflictMarkersLeft_RefusesAndDoesNotCommit()
{
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/t4", 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.Done);
await SeedWorktree(db, task.Id, wtPath, "claudedo/t4", repo.BaseCommit);
var (svc, _) = BuildService(db);
var first = await svc.MergeAsync(task.Id, "main", false, "msg",
leaveConflictsInTree: true, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, first.Status);
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
// User never resolves the conflict — README.md still contains "<<<<<<<" markers.
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
Assert.Contains("README.md", result.ConflictFiles);
Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));
// No commit must have happened, and the repo must still be mid-merge so Abort can clean it up.
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
Assert.True(await new GitService().IsMidMergeAsync(repo.RepoDir));
var abort = await svc.AbortMergeAsync(task.Id, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusAborted, abort.Status);
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain")));
}
[Fact]
public async Task ContinueMergeAsync_PathBecomesUntrackedDuringResolution_RefusesBeforeAddAll()
{
// git's own preflight already refuses a merge whose incoming branch collides with a path
// that is untracked in the target *at merge-start* (verified against real git while
// investigating this guard). The only path left uncovered is one that becomes untracked
// again *during* conflict resolution, after the initial merge already staged it cleanly —
// this reproduces exactly that narrow window, right before `git add -A` would sweep it
// back in unnoticed.
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/t-untracked", wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
File.WriteAllText(Path.Combine(wtPath, "extra.md"), "agent content\n");
GitRepoFixture.RunGit(wtPath, "add", "-A");
GitRepoFixture.RunGit(wtPath, "commit", "-m", "branch change + add extra.md");
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
await SeedWorktree(db, task.Id, wtPath, "claudedo/t-untracked", repo.BaseCommit);
var (svc, _) = BuildService(db);
var first = await svc.MergeAsync(task.Id, "main", false, "msg",
leaveConflictsInTree: true, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, first.Status);
// extra.md landed clean already (git stages any non-conflicting file the moment the
// merge starts, even while README.md is left conflicted) -- confirm that, then unstage
// it and drop in different, untracked local content, reproducing "became untracked
// again mid-resolution".
Assert.Equal("A", GitRepoFixture.RunGit(repo.RepoDir, "diff", "--name-status", "--cached", "--", "extra.md").Trim()[..1]);
GitRepoFixture.RunGit(repo.RepoDir, "rm", "--cached", "extra.md");
const string localContent = "important local content that must survive\n";
File.WriteAllText(Path.Combine(repo.RepoDir, "extra.md"), localContent);
// Resolve the real (unrelated) conflict.
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusUntrackedCollision, result.Status);
Assert.Contains("extra.md", result.ConflictFiles);
Assert.Equal(localContent, File.ReadAllText(Path.Combine(repo.RepoDir, "extra.md")));
// Nothing committed; still mid-merge so abort/continue can still resolve it.
Assert.True(await new GitService().IsMidMergeAsync(repo.RepoDir));
using var ctx = db.CreateContext();
var wt = ctx.Worktrees.Single(w => w.TaskId == task.Id);
Assert.Equal(WorktreeState.Active, wt.State);
}
[Fact]
public async Task AbortMergeAsync_AfterConflict_RestoresCleanStateAndLeavesWorktreeActive()
{
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/t3", 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.Done);
await SeedWorktree(db, task.Id, wtPath, "claudedo/t3", repo.BaseCommit);
var (svc, _) = BuildService(db);
await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
var result = await svc.AbortMergeAsync(task.Id, CancellationToken.None);
Assert.Equal("aborted", result.Status);
Assert.False(File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD")));
using var ctx = db.CreateContext();
var wt = ctx.Worktrees.Single(w => w.TaskId == task.Id);
Assert.Equal(WorktreeState.Active, wt.State);
}
[Fact]
public async Task PreviewAsync_CleanWorktree_ReturnsClean()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
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, "added.txt"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
Assert.True(preview.ChangedFileCount >= 1);
}
[Fact]
public async Task PreviewAsync_Conflict_ReturnsConflictFiles()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
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"), "# from worktree\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\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 preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewConflict, preview.Status);
Assert.Contains("README.md", preview.ConflictFiles);
}
[Fact]
public async Task PreviewAsync_BranchAddsPathUntrackedInTarget_ReturnsUntrackedCollision()
{
// merge-tree (the real preview mechanism) never touches the working directory, so
// without this check the preview would say "clean" for a merge that can't actually land.
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
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, "PLUGIN.md"), "agent content\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
File.WriteAllText(Path.Combine(repo.RepoDir, "PLUGIN.md"), "local content\n");
var (svc, _) = BuildService(db);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewUntrackedCollision, preview.Status);
Assert.Contains("PLUGIN.md", preview.ConflictFiles);
}
[Fact]
public async Task PreviewAsync_NoActiveWorktree_ReturnsUnavailable()
{
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
var (svc, _) = BuildService(db);
var preview = await svc.PreviewAsync(task.Id, "main", CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewUnavailable, preview.Status);
}
[Fact]
public async Task PreviewAsync_RunVerifyTrue_NoVerifyCommandConfigured_NeverInvokesRunner()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
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, "added.txt"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
var (svc, _) = BuildService(db, fakeVerify);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var preview = await svc.PreviewAsync(task.Id, target, runVerify: true, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
Assert.Null(preview.VerifyExitCode);
Assert.Null(fakeVerify.CapturedCommand);
}
[Fact]
public async Task PreviewAsync_RunVerifyFalse_VerifyCommandConfigured_NeverInvokesRunner()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
await SeedVerifyCommand(db, list.Id, "dotnet build");
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, "added.txt"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
var (svc, _) = BuildService(db, fakeVerify);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
Assert.Null(preview.VerifyExitCode);
Assert.Null(fakeVerify.CapturedCommand);
}
[Fact]
public async Task PreviewAsync_RunVerifyTrue_VerifyPasses_ReturnsCleanBuildAndLeavesTargetUntouched()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
await SeedVerifyCommand(db, list.Id, "dotnet build");
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, "added.txt"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner
{
Result = new VerifyCommandResult(0, false, "build ok"),
FileToCheck = "added.txt",
};
var (svc, _) = BuildService(db, fakeVerify);
var git = new GitService();
var target = await git.GetCurrentBranchAsync(repo.RepoDir);
var statusBefore = await git.GetStatusPorcelainAsync(repo.RepoDir);
var headBefore = await git.RevParseHeadAsync(repo.RepoDir);
var preview = await svc.PreviewAsync(task.Id, target, runVerify: true, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
Assert.Equal(0, preview.VerifyExitCode);
Assert.NotNull(preview.VerifyDurationMs);
Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
Assert.NotEqual(repo.RepoDir, fakeVerify.CapturedWorkingDir);
Assert.True(fakeVerify.WorkingDirHadFile, "the merged file must be present in the scratch worktree at verify time");
// Scratch worktree removed afterward; the real target working tree never changed.
Assert.False(Directory.Exists(fakeVerify.CapturedWorkingDir));
Assert.Equal(statusBefore, await git.GetStatusPorcelainAsync(repo.RepoDir));
Assert.Equal(headBefore, await git.RevParseHeadAsync(repo.RepoDir));
}
[Fact]
public async Task PreviewAsync_RunVerifyTrue_VerifyFails_ReturnsExitCodeAndOutputTailWithoutTouchingTarget()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
await SeedVerifyCommand(db, list.Id, "dotnet build");
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, "added.txt"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "build broke: CS0103") };
var (svc, _) = BuildService(db, fakeVerify);
var git = new GitService();
var target = await git.GetCurrentBranchAsync(repo.RepoDir);
var headBefore = await git.RevParseHeadAsync(repo.RepoDir);
var preview = await svc.PreviewAsync(task.Id, target, runVerify: true, CancellationToken.None);
// The merge-tree result itself is still clean -- only the build/test of it failed.
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
Assert.Equal(1, preview.VerifyExitCode);
Assert.Contains("build broke: CS0103", preview.VerifyOutputTail);
Assert.False(Directory.Exists(fakeVerify.CapturedWorkingDir));
Assert.Equal(headBefore, await git.RevParseHeadAsync(repo.RepoDir));
}
[Fact]
public async Task PreviewAsync_RunVerifyTrue_CleansUpScratchWorktreeRegistration()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
await SeedVerifyCommand(db, list.Id, "dotnet build");
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, "added.txt"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
var (svc, _) = BuildService(db, fakeVerify);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
await svc.PreviewAsync(task.Id, target, runVerify: true, CancellationToken.None);
var worktreeListing = GitRepoFixture.RunGit(repo.RepoDir, "worktree", "list", "--porcelain");
Assert.DoesNotContain(fakeVerify.CapturedWorkingDir!, worktreeListing);
}
[Fact]
public async Task ApproveAndMergeAsync_CleanWorktree_MergesAndMarksDone()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
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, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, updated!.Status);
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Merged, wt!.State);
// Approve removes the worktree so merged worktrees don't pile up.
Assert.False(Directory.Exists(wtCtx.WorktreePath));
}
[Fact]
public async Task ApproveAndMergeAsync_Conflict_LeavesTaskWaitingForReview()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
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"), "# from worktree\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var (svc, _) = BuildService(db);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
Assert.Contains("README.md", result.ConflictFiles);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Active, wt!.State);
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
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()
{
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
var (svc, _) = BuildService(db);
var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, updated!.Status);
}
// A list-handler task owns no worktree -- it commits straight into the list's working dir --
// so the approve path short-circuits past the merge. The verify gate must still apply, or the
// run that lands the most on the target branch would be the one run nothing checks.
[Fact]
public async Task ApproveAndMergeAsync_NoWorktreeAndVerifyFails_StaysOutOfDone()
{
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
await SeedVerifyCommand(db, list.Id, "dotnet test");
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "2 failed") };
var (svc, _) = BuildService(db, fakeVerify);
var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);
Assert.Equal(TaskMergeService.StatusVerifyFailed, result.Status);
Assert.Contains("2 failed", result.ErrorMessage ?? "");
Assert.Equal("/tmp", fakeVerify.CapturedWorkingDir);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
}
[Fact]
public async Task ApproveAndMergeAsync_NoWorktreeAndVerifyPasses_MarksDone()
{
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
await SeedVerifyCommand(db, list.Id, "dotnet test");
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
var (svc, _) = BuildService(db, fakeVerify);
var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.Equal("dotnet test", fakeVerify.CapturedCommand);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, updated!.Status);
}
[Fact]
public async Task ApproveAndMergeAsync_NoVerifyCommandConfigured_NeverInvokesRunnerAndMarksDone()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
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, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
var (svc, _) = BuildService(db, fakeVerify);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.Null(fakeVerify.CapturedCommand);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, updated!.Status);
}
[Fact]
public async Task ApproveAndMergeAsync_VerifyCommandSucceeds_RunsInListWorkingDirAndMarksDone()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
await SeedVerifyCommand(db, list.Id, "dotnet test");
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, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "all green") };
var (svc, _) = BuildService(db, fakeVerify);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.Equal("dotnet test", fakeVerify.CapturedCommand);
Assert.Equal(repo.RepoDir, fakeVerify.CapturedWorkingDir);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, updated!.Status);
}
[Fact]
public async Task ApproveAndMergeAsync_VerifyCommandFails_KeepsMergeButNotDone()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
await SeedVerifyCommand(db, list.Id, "dotnet test");
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, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "build broke: CS0103") };
var (svc, _) = BuildService(db, fakeVerify);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusVerifyFailed, result.Status);
Assert.Contains("build broke: CS0103", result.ErrorMessage);
// The git merge itself is left in place — main already has the merged content.
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Merged, wt!.State);
}
[Fact]
public async Task ApproveAndMergeAsync_VerifyCommandTimesOut_ReturnsVerifyFailedWithTimeoutMessage()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
await SeedVerifyCommand(db, list.Id, "dotnet test");
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, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(-1, true, "") };
var (svc, _) = BuildService(db, fakeVerify);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusVerifyFailed, result.Status);
Assert.Contains("timed out", result.ErrorMessage ?? "", StringComparison.OrdinalIgnoreCase);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
}
[Fact]
public async Task MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles()
{
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/t1", 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.Done);
await SeedWorktree(db, task.Id, wtPath, "claudedo/t1", repo.BaseCommit);
var (svc, _) = BuildService(db);
var result = await svc.MergeAsync(
task.Id, "main", removeWorktree: false, "msg",
leaveConflictsInTree: true,
CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
Assert.Contains("README.md", result.ConflictFiles);
var midMerge = File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD"));
Assert.True(midMerge, "repo should be left in mid-merge state");
// Cleanup
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));
}
[Fact]
public async Task MergeAsync_Success_RecordsMergeCommitOnWorktree()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
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, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
commitMessage: "Merge task", ct: CancellationToken.None);
Assert.Equal("merged", result.Status);
var headSha = (await new GitService().RevParseHeadAsync(repo.RepoDir)).Trim();
using var ctx = db.CreateContext();
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(headSha, wt!.MergeCommit);
}
[Fact]
public async Task RevertMergeAsync_NoWorktree_ReturnsBlocked()
{
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done);
var (svc, _) = BuildService(db);
var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None);
Assert.Equal(TaskMergeService.StatusBlocked, result.Status);
Assert.Contains("no worktree", result.ErrorMessage ?? "");
}
[Fact]
public async Task RevertMergeAsync_TaskNotDone_ReturnsBlocked()
{
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
await SeedWorktree(db, task.Id, "/tmp/wt", "claudedo/x", "deadbeef",
state: WorktreeState.Merged, mergeCommit: "cafebabe");
var (svc, _) = BuildService(db);
var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None);
Assert.Equal(TaskMergeService.StatusBlocked, result.Status);
Assert.Contains("not Done", result.ErrorMessage ?? "");
}
[Fact]
public async Task RevertMergeAsync_NoMergeCommitRecorded_ReturnsBlocked()
{
// Simulates a worktree merged before this feature existed — Merged state, no SHA.
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done);
await SeedWorktree(db, task.Id, "/tmp/wt", "claudedo/x", "deadbeef",
state: WorktreeState.Merged, mergeCommit: null);
var (svc, _) = BuildService(db);
var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None);
Assert.Equal(TaskMergeService.StatusBlocked, result.Status);
Assert.Contains("no merge commit recorded", result.ErrorMessage ?? "");
}
[Fact]
public async Task RevertMergeAsync_WorktreeNotMerged_ReturnsBlocked()
{
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done);
await SeedWorktree(db, task.Id, "/tmp/wt", "claudedo/x", "deadbeef",
state: WorktreeState.Active, mergeCommit: null);
var (svc, _) = BuildService(db);
var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None);
Assert.Equal(TaskMergeService.StatusBlocked, result.Status);
Assert.Contains("expected Merged", result.ErrorMessage ?? "");
}
[Fact]
public async Task RevertMergeAsync_AfterCleanMerge_CreatesRevertCommitAndResetsState()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
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, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, proxy) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var mergeResult = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true,
commitMessage: "Merge task", ct: CancellationToken.None);
Assert.Equal("merged", mergeResult.Status);
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));
var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusReverted, revertResult.Status);
Assert.False(string.IsNullOrWhiteSpace(revertResult.RevertCommit));
Assert.Empty(revertResult.ConflictFiles);
Assert.False(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));
using var ctx = db.CreateContext();
var updatedTask = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, updatedTask!.Status);
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Kept, wt!.State);
Assert.Contains(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == task.Id);
}
[Fact]
public async Task RevertMergeAsync_UncommittedChangesInTarget_ReturnsBlocked()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
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, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var mergeResult = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true,
commitMessage: "Merge task", ct: CancellationToken.None);
Assert.Equal("merged", mergeResult.Status);
// A concurrent session leaves an uncommitted, tracked-file change in the shared checkout.
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "dirty from another session\n");
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusBlocked, revertResult.Status);
Assert.Contains("uncommitted", revertResult.ErrorMessage ?? "");
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
using var ctx = db.CreateContext();
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Merged, wt!.State);
}
[Fact]
public async Task RevertMergeAsync_ConflictingRevert_AbortsCleanlyAndReportsConflictFiles()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
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"), "# test repo\nfeature\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var mergeResult = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true,
commitMessage: "Merge task", ct: CancellationToken.None);
Assert.Equal("merged", mergeResult.Status);
// A later commit on the target edits the exact line the merge introduced, so the revert conflicts.
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# test repo\npost-merge edit\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: edit the same line");
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflictAborted, revertResult.Status);
Assert.Contains("README.md", revertResult.ConflictFiles);
Assert.False(await new GitService().IsMidRevertAsync(repo.RepoDir));
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain")));
// Task/worktree state must not change on a conflicted, aborted revert.
using var ctx = db.CreateContext();
var updatedTask = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, updatedTask!.Status);
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Merged, wt!.State);
}
[Fact]
public async Task MergeAsync_OverlappingWaitingForReviewBranch_GetsRebasedOntoNewTip()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "shared.txt"), "line1\nline2\nline3\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "seed shared file");
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var (list, taskA) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
var wtPathA = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPathA));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/reba-a", wtPathA, baseCommit);
File.WriteAllText(Path.Combine(wtPathA, "shared.txt"), "line1-changed\nline2\nline3\n");
GitRepoFixture.RunGit(wtPathA, "commit", "-am", "A changes line1");
await SeedWorktree(db, taskA.Id, wtPathA, "claudedo/reba-a", baseCommit);
// Task B is WaitingForReview and touches the same file A just changed, but a different
// line, so rebasing it onto the new tip should apply cleanly.
var taskB = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = list.Id,
Title = "task-b",
Status = TaskStatus.WaitingForReview,
CreatedAt = DateTime.UtcNow,
};
using (var ctx = db.CreateContext())
await new TaskRepository(ctx).AddAsync(taskB);
var wtPathB = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPathB));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/reba-b", wtPathB, baseCommit);
File.WriteAllText(Path.Combine(wtPathB, "shared.txt"), "line1\nline2\nline3-changed\n");
GitRepoFixture.RunGit(wtPathB, "commit", "-am", "B changes line3");
await SeedWorktree(db, taskB.Id, wtPathB, "claudedo/reba-b", baseCommit);
var (svc, proxy) = BuildService(db);
var result = await svc.MergeAsync(taskA.Id, "main", removeWorktree: false, "Merge A", CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
// B's worktree must now carry both changes -- rebased cleanly onto the new main tip.
Assert.Equal("line1-changed\nline2\nline3-changed\n",
File.ReadAllText(Path.Combine(wtPathB, "shared.txt")).Replace("\r\n", "\n"));
var mainHead = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "main").Trim();
using var ctx2 = db.CreateContext();
var wtB = await new WorktreeRepository(ctx2).GetByTaskIdAsync(taskB.Id);
Assert.Equal(mainHead, wtB!.BaseCommit);
Assert.Equal(GitRepoFixture.RunGit(wtPathB, "rev-parse", "HEAD").Trim(), wtB.HeadCommit);
Assert.Contains(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == taskB.Id);
}
[Fact]
public async Task MergeAsync_NonOverlappingWaitingForReviewBranch_IsLeftAlone()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var baseCommit = repo.BaseCommit;
var (list, taskA) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
var wtPathA = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPathA));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/noov-a", wtPathA, baseCommit);
File.WriteAllText(Path.Combine(wtPathA, "a-only.txt"), "a\n");
GitRepoFixture.RunGit(wtPathA, "add", "-A");
GitRepoFixture.RunGit(wtPathA, "commit", "-m", "A adds a-only.txt");
await SeedWorktree(db, taskA.Id, wtPathA, "claudedo/noov-a", baseCommit);
// Task B touches a completely different file -- merely behind, not worth disturbing.
var taskB = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = list.Id,
Title = "task-b",
Status = TaskStatus.WaitingForReview,
CreatedAt = DateTime.UtcNow,
};
using (var ctx = db.CreateContext())
await new TaskRepository(ctx).AddAsync(taskB);
var wtPathB = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPathB));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/noov-b", wtPathB, baseCommit);
File.WriteAllText(Path.Combine(wtPathB, "b-only.txt"), "b\n");
GitRepoFixture.RunGit(wtPathB, "add", "-A");
GitRepoFixture.RunGit(wtPathB, "commit", "-m", "B adds b-only.txt");
var headBBefore = GitRepoFixture.RunGit(wtPathB, "rev-parse", "HEAD").Trim();
await SeedWorktree(db, taskB.Id, wtPathB, "claudedo/noov-b", baseCommit);
var (svc, proxy) = BuildService(db);
var result = await svc.MergeAsync(taskA.Id, "main", removeWorktree: false, "Merge A", CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
// B is untouched: same head, same recorded base commit, no broadcast for B.
Assert.Equal(headBBefore, GitRepoFixture.RunGit(wtPathB, "rev-parse", "HEAD").Trim());
using var ctx2 = db.CreateContext();
var wtB = await new WorktreeRepository(ctx2).GetByTaskIdAsync(taskB.Id);
Assert.Equal(baseCommit, wtB!.BaseCommit);
Assert.DoesNotContain(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == taskB.Id);
}
[Fact]
public async Task MergeAsync_OverlappingButConflictingRebase_LeavesBranchAsIsAndWarns()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "shared.txt"), "line1\nline2\nline3\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "seed shared file");
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var (list, taskA) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
var wtPathA = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPathA));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/conf-a", wtPathA, baseCommit);
File.WriteAllText(Path.Combine(wtPathA, "shared.txt"), "line1\nA-line2\nline3\n");
GitRepoFixture.RunGit(wtPathA, "commit", "-am", "A changes line2");
await SeedWorktree(db, taskA.Id, wtPathA, "claudedo/conf-a", baseCommit);
// Task B changes the exact same line differently -- rebasing must conflict.
var taskB = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = list.Id,
Title = "task-b",
Status = TaskStatus.WaitingForReview,
CreatedAt = DateTime.UtcNow,
};
using (var ctx = db.CreateContext())
await new TaskRepository(ctx).AddAsync(taskB);
var wtPathB = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPathB));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/conf-b", wtPathB, baseCommit);
File.WriteAllText(Path.Combine(wtPathB, "shared.txt"), "line1\nB-line2\nline3\n");
GitRepoFixture.RunGit(wtPathB, "commit", "-am", "B changes line2");
var headBBefore = GitRepoFixture.RunGit(wtPathB, "rev-parse", "HEAD").Trim();
await SeedWorktree(db, taskB.Id, wtPathB, "claudedo/conf-b", baseCommit);
var (svc, proxy) = BuildService(db);
var result = await svc.MergeAsync(taskA.Id, "main", removeWorktree: false, "Merge A", CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
// B is left exactly as it was: same head commit, same content, no mid-rebase state, base
// commit unchanged in the DB, and a clear warning went out instead of silent partial work.
Assert.Equal(headBBefore, GitRepoFixture.RunGit(wtPathB, "rev-parse", "HEAD").Trim());
Assert.Equal("line1\nB-line2\nline3\n",
File.ReadAllText(Path.Combine(wtPathB, "shared.txt")).Replace("\r\n", "\n"));
Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(wtPathB, "status", "--porcelain")));
using var ctx2 = db.CreateContext();
var wtB = await new WorktreeRepository(ctx2).GetByTaskIdAsync(taskB.Id);
Assert.Equal(baseCommit, wtB!.BaseCommit);
Assert.Contains(proxy.Calls, c => c.Method == "WorkerLog"
&& c.Args[0] is string s && s.Contains("Auto-rebase failed") && s.Contains("task-b"));
}
}
#region Test doubles
internal sealed class FakeVerifyCommandRunner : IVerifyCommandRunner
{
public VerifyCommandResult Result { get; set; } = new(0, false, "");
public string? CapturedWorkingDir { get; private set; }
public string? CapturedCommand { get; private set; }
// Set before the call to snapshot, at call time, whether this file exists in workingDir --
// lets a preview-verify test prove the scratch worktree actually contains the merged content.
public string? FileToCheck { get; set; }
public bool? WorkingDirHadFile { get; private set; }
// Simulates a slow build/test run so a progress-reporting test can observe at least one
// report without the real verify command ever taking that long.
public TimeSpan? Delay { get; set; }
public async Task<VerifyCommandResult> RunAsync(string workingDir, string command, TimeSpan timeout, CancellationToken ct)
{
CapturedWorkingDir = workingDir;
CapturedCommand = command;
if (FileToCheck is not null)
WorkingDirHadFile = File.Exists(Path.Combine(workingDir, FileToCheck));
if (Delay is { } delay)
await Task.Delay(delay, ct);
return Result;
}
}
internal sealed record MergeHubCall(string Method, object?[] Args);
internal sealed class MergeRecordingClientProxy : IClientProxy
{
public readonly List<MergeHubCall> Calls = new();
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
Calls.Add(new MergeHubCall(method, args));
return Task.CompletedTask;
}
}
internal sealed class MergeRecordingHubClients : IHubClients
{
public MergeRecordingClientProxy AllProxy { get; } = new();
public IClientProxy All => AllProxy;
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => AllProxy;
public IClientProxy Client(string connectionId) => AllProxy;
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => AllProxy;
public IClientProxy Group(string groupName) => AllProxy;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => AllProxy;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => AllProxy;
public IClientProxy User(string userId) => AllProxy;
public IClientProxy Users(IReadOnlyList<string> userIds) => AllProxy;
}
internal sealed class MergeRecordingHubContext : IHubContext<ClaudeDo.Worker.Hub.WorkerHub>
{
private readonly MergeRecordingHubClients _clients = new();
public MergeRecordingClientProxy Proxy => _clients.AllProxy;
public IHubClients Clients => _clients;
public IGroupManager Groups => throw new NotImplementedException();
}
#endregion