fix(worker): validate conflict markers before staging in ContinueMergeAsync

git add -A resolves the index U-stage regardless of file content, so a
conflicted file whose markers were never removed still got staged and
committed as-is. Check previously-conflicted files for leftover
<<<<<<< markers before staging; refuse with a conflict result if any
remain, leaving the repo mid-merge so Abort still cleans up.

Adds a regression test that leaves one conflict unresolved and asserts
ContinueMergeAsync refuses (no commit) and AbortMergeAsync still
restores a clean tree.
This commit is contained in:
mika kuns
2026-07-23 17:16:46 +02:00
parent 85c7e650c9
commit 377409e633
3 changed files with 66 additions and 1 deletions
@@ -410,6 +410,52 @@ public class TaskMergeServiceTests : IDisposable
Assert.Equal(WorktreeState.Merged, wt.State);
}
[Fact]
public async Task ContinueMergeAsync_UnresolvedConflictMarkersLeft_RefusesAndDoesNotCommit()
{
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/t4", 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/t4", 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);
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
// User never resolves the conflict — README.md still contains "<<<<<<<" markers.
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
Assert.Contains("README.md", result.ConflictFiles);
Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));
// No commit must have happened, and the repo must still be mid-merge so Abort can clean it up.
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
Assert.True(await new GitService().IsMidMergeAsync(repo.RepoDir));
var abort = await svc.AbortMergeAsync(task.Id, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusAborted, abort.Status);
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain")));
}
[Fact]
public async Task AbortMergeAsync_AfterConflict_RestoresCleanStateAndLeavesWorktreeActive()
{