diff --git a/src/ClaudeDo.Worker/Services/TaskMergeService.cs b/src/ClaudeDo.Worker/Services/TaskMergeService.cs new file mode 100644 index 0000000..21d65c9 --- /dev/null +++ b/src/ClaudeDo.Worker/Services/TaskMergeService.cs @@ -0,0 +1,104 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Services; + +public sealed record MergeResult( + string Status, + IReadOnlyList ConflictFiles, + string? ErrorMessage); + +public sealed record MergeTargets( + string DefaultBranch, + IReadOnlyList LocalBranches); + +public sealed class TaskMergeService +{ + public const string StatusMerged = "merged"; + public const string StatusConflict = "conflict"; + public const string StatusBlocked = "blocked"; + + private readonly IDbContextFactory _dbFactory; + private readonly GitService _git; + private readonly HubBroadcaster _broadcaster; + private readonly ILogger _logger; + + public TaskMergeService( + IDbContextFactory dbFactory, + GitService git, + HubBroadcaster broadcaster, + ILogger logger) + { + _dbFactory = dbFactory; + _git = git; + _broadcaster = broadcaster; + _logger = logger; + } + + public async Task MergeAsync( + string taskId, + string targetBranch, + bool removeWorktree, + string commitMessage, + CancellationToken ct) + { + TaskEntity task; + ListEntity list; + WorktreeEntity? wt; + + using (var ctx = _dbFactory.CreateDbContext()) + { + task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct) + ?? throw new KeyNotFoundException($"Task '{taskId}' not found."); + list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct) + ?? throw new InvalidOperationException("List not found."); + wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct); + } + + if (task.Status == TaskStatus.Running) + return Blocked("task is running"); + if (wt is null) + return Blocked("task has no worktree"); + if (wt.State != WorktreeState.Active) + return Blocked($"worktree state is {wt.State}"); + if (string.IsNullOrWhiteSpace(list.WorkingDir)) + return Blocked("list has no working directory"); + if (!await _git.IsGitRepoAsync(list.WorkingDir, ct)) + return Blocked("working directory is not a git repository"); + if (await _git.IsMidMergeAsync(list.WorkingDir, ct)) + return Blocked("target working directory is mid-merge"); + if (await _git.HasChangesAsync(list.WorkingDir, ct)) + return Blocked("target working tree has uncommitted changes"); + + // Body added in later tasks. + throw new NotImplementedException(); + } + + public async Task GetTargetsAsync(string taskId, CancellationToken ct) + { + TaskEntity task; + ListEntity list; + using (var ctx = _dbFactory.CreateDbContext()) + { + task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct) + ?? throw new KeyNotFoundException($"Task '{taskId}' not found."); + list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct) + ?? throw new InvalidOperationException("List not found."); + } + + if (string.IsNullOrWhiteSpace(list.WorkingDir)) + return new MergeTargets("", Array.Empty()); + + var current = await _git.GetCurrentBranchAsync(list.WorkingDir, ct); + var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct); + return new MergeTargets(current, branches); + } + + private static MergeResult Blocked(string reason) => + new(StatusBlocked, Array.Empty(), reason); +} diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs new file mode 100644 index 0000000..e690a2d --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -0,0 +1,334 @@ +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