diff --git a/src/ClaudeDo.Worker/Services/TaskMergeService.cs b/src/ClaudeDo.Worker/Services/TaskMergeService.cs index 29da55f..ff9a1d5 100644 --- a/src/ClaudeDo.Worker/Services/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Services/TaskMergeService.cs @@ -45,6 +45,7 @@ public sealed class TaskMergeService string targetBranch, bool removeWorktree, string commitMessage, + bool leaveConflictsInTree, CancellationToken ct) { TaskEntity task; @@ -89,6 +90,11 @@ public sealed class TaskMergeService try { files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); } catch { files = new(); } + if (leaveConflictsInTree && files.Count > 0) + { + return new MergeResult(StatusConflict, files, null); + } + // If abort fails the repo is left mid-merge; the caller must resolve manually. // Return Blocked (not conflict) so the UI does not offer a stale conflict list. try { await _git.MergeAbortAsync(list.WorkingDir, ct); } @@ -141,6 +147,14 @@ public sealed class TaskMergeService return new MergeResult(StatusMerged, Array.Empty(), cleanupWarning); } + public Task MergeAsync( + string taskId, + string targetBranch, + bool removeWorktree, + string commitMessage, + CancellationToken ct) + => MergeAsync(taskId, targetBranch, removeWorktree, commitMessage, leaveConflictsInTree: false, ct); + 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 a61867c..484c373 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -51,6 +51,22 @@ public class TaskMergeServiceTests : IDisposable NullLogger.Instance); } + private static async Task SeedWorktree( + DbFixture db, string taskId, string path, string branchName, string baseCommit) + { + var wt = new WorktreeEntity + { + TaskId = taskId, + Path = path, + BranchName = branchName, + BaseCommit = baseCommit, + State = WorktreeState.Active, + CreatedAt = DateTime.UtcNow, + }; + using var ctx = db.CreateContext(); + await new WorktreeRepository(ctx).AddAsync(wt); + } + private static async Task<(ListEntity list, TaskEntity task)> SeedListAndTask( DbFixture db, string workingDir, TaskStatus status) { @@ -351,6 +367,44 @@ public class TaskMergeServiceTests : IDisposable Assert.Equal("blocked", result.Status); Assert.Contains("switch target branch", result.ErrorMessage ?? ""); } + + [Fact] + public async Task MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles() + { + 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/t1", 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/t1", repo.BaseCommit); + + var (svc, _) = BuildService(db); + + var result = await svc.MergeAsync( + task.Id, "main", removeWorktree: false, "msg", + leaveConflictsInTree: true, + CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusConflict, result.Status); + Assert.Contains("README.md", result.ConflictFiles); + + var midMerge = File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD")); + Assert.True(midMerge, "repo should be left in mid-merge state"); + + // Cleanup + GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort"); + } } #region Test doubles