fix(merge): pass the stash consent to the conflict resolver so it opens on a dirty target

The resolver restarts the merge via StartConflictMerge, which had no autostash.
After a stash-retry approve reported 'conflict', the restart hit the same dirty
target, got 'dirty_target' and the resolver silently never opened. Thread the
consent through the hub, client, coordinator and resolver; surface a refusal in
the footer instead of dropping it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-10-02 17:25:52 +02:00
co-authored by Claude Sonnet 5.5
parent ff7197c410
commit feb0afa33a
16 changed files with 173 additions and 20 deletions
@@ -1567,6 +1567,50 @@ public class TaskMergeServiceTests : IDisposable
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
}
[Fact]
public async Task MergeAsync_LeaveConflicts_DirtyTarget_NeedsAutostashAndAbortRestoresTheChanges()
{
// The resolver restarts the merge via leaveConflictsInTree; on a dirty target that only
// works with the caller's stash consent, and git hands the stash back on abort.
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");
File.WriteAllText(Path.Combine(repo.RepoDir, "other.txt"), "tracked\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "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.WaitingForReview);
await SeedWorktree(db, task.Id, wtPath, "claudedo/t1", repo.BaseCommit);
File.WriteAllText(Path.Combine(repo.RepoDir, "other.txt"), "dirty\n");
var (svc, _) = BuildService(db);
var refused = await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusDirtyTarget, refused.Status);
var result = await svc.MergeAsync(
task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None, autostash: true);
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
Assert.Contains("README.md", result.ConflictFiles);
Assert.True(await new GitService().IsMidMergeAsync(repo.RepoDir));
await svc.AbortMergeAsync(task.Id, CancellationToken.None);
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
Assert.Equal("dirty\n", File.ReadAllText(Path.Combine(repo.RepoDir, "other.txt")).Replace("\r\n", "\n"));
}
[Fact]
public async Task WriteResolutionAsync_ThenContinue_CompletesMerge()
{