using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Services; 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 _dbs = new(); private readonly List _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) { var fakeHub = new MergeRecordingHubContext(); var broadcaster = new HubBroadcaster(fakeHub); var svc = new TaskMergeService( db.CreateFactory(), new GitService(), broadcaster, NullLogger.Instance); return (svc, fakeHub.Proxy); } private static WorktreeManager BuildWorktreeManager(DbFixture db) { return new WorktreeManager( new GitService(), db.CreateFactory(), new ClaudeDo.Worker.Config.WorkerConfig { WorktreeRootStrategy = "sibling" }, NullLogger.Instance); } 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 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)); // Dirty the target working dir. File.WriteAllText(Path.Combine(repo.RepoDir, "dirt.txt"), "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 ?? ""); } } #region Test doubles internal sealed record MergeHubCall(string Method, object?[] Args); internal sealed class MergeRecordingClientProxy : IClientProxy { public readonly List 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 excludedConnectionIds) => AllProxy; public IClientProxy Client(string connectionId) => AllProxy; public IClientProxy Clients(IReadOnlyList connectionIds) => AllProxy; public IClientProxy Group(string groupName) => AllProxy; public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => AllProxy; public IClientProxy Groups(IReadOnlyList groupNames) => AllProxy; public IClientProxy User(string userId) => AllProxy; public IClientProxy Users(IReadOnlyList userIds) => AllProxy; } internal sealed class MergeRecordingHubContext : IHubContext { private readonly MergeRecordingHubClients _clients = new(); public MergeRecordingClientProxy Proxy => _clients.AllProxy; public IHubClients Clients => _clients; public IGroupManager Groups => throw new NotImplementedException(); } #endregion