diff --git a/src/ClaudeDo.Worker/Services/TaskMergeService.cs b/src/ClaudeDo.Worker/Services/TaskMergeService.cs index ff9a1d5..005cf68 100644 --- a/src/ClaudeDo.Worker/Services/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Services/TaskMergeService.cs @@ -155,6 +155,44 @@ public sealed class TaskMergeService CancellationToken ct) => MergeAsync(taskId, targetBranch, removeWorktree, commitMessage, leaveConflictsInTree: false, ct); + public async Task ContinueMergeAsync(string taskId, 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 (wt is null) return Blocked("task has no worktree"); + if (string.IsNullOrWhiteSpace(list.WorkingDir)) return Blocked("list has no working directory"); + if (!await _git.IsMidMergeAsync(list.WorkingDir, ct)) + return Blocked("repo is not mid-merge"); + + await _git.AddAllAsync(list.WorkingDir, ct); + + var remaining = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); + if (remaining.Count > 0) + return new MergeResult(StatusConflict, remaining, "conflicts not fully resolved"); + + await _git.CommitAsync(list.WorkingDir, $"Merge branch '{wt.BranchName}'", ct); + + using (var ctx = _dbFactory.CreateDbContext()) + { + await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Merged, ct); + } + await _broadcaster.WorktreeUpdated(taskId); + _logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName); + + return new MergeResult(StatusMerged, Array.Empty(), null); + } + public async Task GetTargetsAsync(string taskId, CancellationToken ct) { TaskEntity task; diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs index 484c373..421e76c 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -368,6 +368,46 @@ public class TaskMergeServiceTests : IDisposable 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 MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles() {