diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index 18db0bd..e8e578a 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -3,6 +3,7 @@ using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.State; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -17,6 +18,11 @@ public sealed record MergeTargets( string DefaultBranch, IReadOnlyList LocalBranches); +public sealed record MergePreviewResult( + string Status, + IReadOnlyList ConflictFiles, + int ChangedFileCount); + public sealed class TaskMergeService { public const string StatusMerged = "merged"; @@ -24,20 +30,27 @@ public sealed class TaskMergeService public const string StatusBlocked = "blocked"; public const string StatusAborted = "aborted"; + public const string PreviewClean = "clean"; + public const string PreviewConflict = "conflict"; + public const string PreviewUnavailable = "unavailable"; + private readonly IDbContextFactory _dbFactory; private readonly GitService _git; private readonly HubBroadcaster _broadcaster; + private readonly ITaskStateService _state; private readonly ILogger _logger; public TaskMergeService( IDbContextFactory dbFactory, GitService git, HubBroadcaster broadcaster, + ITaskStateService state, ILogger logger) { _dbFactory = dbFactory; _git = git; _broadcaster = broadcaster; + _state = state; _logger = logger; } @@ -216,6 +229,56 @@ public sealed class TaskMergeService return new MergeTargets(current, branches); } + public async Task PreviewAsync(string taskId, string targetBranch, CancellationToken ct) + { + var (_, list, wt) = await LoadMergeContextAsync(taskId, ct); + + if (wt is null || wt.State != WorktreeState.Active) + return new MergePreviewResult(PreviewUnavailable, Array.Empty(), 0); + if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct)) + return new MergePreviewResult(PreviewUnavailable, Array.Empty(), 0); + + var target = string.IsNullOrWhiteSpace(targetBranch) + ? await _git.GetCurrentBranchAsync(list.WorkingDir, ct) + : targetBranch; + + var preview = await _git.PreviewMergeAsync(list.WorkingDir, target, wt.BranchName, ct); + if (!preview.Supported) + return new MergePreviewResult(PreviewUnavailable, Array.Empty(), 0); + if (!preview.Clean) + return new MergePreviewResult(PreviewConflict, preview.ConflictFiles, 0); + + var count = await _git.CountChangedFilesAsync(list.WorkingDir, target, wt.BranchName, ct); + return new MergePreviewResult(PreviewClean, Array.Empty(), count); + } + + public async Task ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct) + { + var (task, list, wt) = await LoadMergeContextAsync(taskId, ct); + + if (task.Status != TaskStatus.WaitingForReview) + return Blocked("task is not waiting for review"); + + if (wt is null || wt.State != WorktreeState.Active) + { + var done = await _state.ApproveReviewAsync(taskId, ct); + return done.Ok + ? new MergeResult(StatusMerged, Array.Empty(), null) + : Blocked(done.Reason ?? "approve failed"); + } + + var target = string.IsNullOrWhiteSpace(targetBranch) + ? await _git.GetCurrentBranchAsync(list.WorkingDir, ct) + : targetBranch; + + var merge = await MergeAsync(taskId, target, removeWorktree: false, $"Merge {wt.BranchName}", ct); + if (merge.Status != StatusMerged) + return merge; + + var approve = await _state.ApproveReviewAsync(taskId, ct); + return approve.Ok ? merge : Blocked(approve.Reason ?? "approve failed"); + } + 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 index 5efbea5..ac5ddd8 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -34,10 +34,12 @@ public class TaskMergeServiceTests : IDisposable { 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, NullLogger.Instance); return (svc, fakeHub.Proxy); } @@ -442,6 +444,146 @@ public class TaskMergeServiceTests : IDisposable 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_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 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); + } + + [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 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); + } + [Fact] public async Task MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles() {