diff --git a/src/ClaudeDo.Worker/Services/TaskMergeService.cs b/src/ClaudeDo.Worker/Services/TaskMergeService.cs index 4884095..e8700e3 100644 --- a/src/ClaudeDo.Worker/Services/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Services/TaskMergeService.cs @@ -22,6 +22,7 @@ public sealed class TaskMergeService public const string StatusMerged = "merged"; public const string StatusConflict = "conflict"; public const string StatusBlocked = "blocked"; + public const string StatusAborted = "aborted"; private readonly IDbContextFactory _dbFactory; private readonly GitService _git; @@ -195,6 +196,32 @@ public sealed class TaskMergeService return new MergeResult(StatusMerged, Array.Empty(), null); } + public async Task AbortMergeAsync(string taskId, CancellationToken ct) + { + ListEntity list; + WorktreeEntity? wt; + + using (var ctx = _dbFactory.CreateDbContext()) + { + var 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 (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.IsMidMergeAsync(list.WorkingDir, ct)) + return Blocked("repo is not mid-merge"); + + await _git.MergeAbortAsync(list.WorkingDir, ct); + _logger.LogInformation("Aborted merge of task {TaskId}", taskId); + + return new MergeResult(StatusAborted, 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 421e76c..33963d0 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -408,6 +408,40 @@ public class TaskMergeServiceTests : IDisposable Assert.Equal(WorktreeState.Merged, 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 MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles() {