fix(worker): make modify/delete and rename/delete merge conflicts visible instead of silently staging them

ConflictMarkerParser only recognizes text-marker conflicts, so a modify/delete or
rename/delete conflict (git never writes markers for those) was invisible to the
resolver: GetConflictDocumentsAsync read it as an already-resolved stable file, and
ContinueMergeAsync's marker scan let it through, so `git add` on the unmerged path
silently kept whichever side wasn't deleted.

GetConflictDocumentsAsync now tells a modify/delete-style conflict (one index stage
missing) apart from an ordinary already-resolved file (both stages present) and
synthesizes a real whole-file conflict block from the index stages so it shows up in
the resolver. ContinueMergeAsync compares such a path against git's own default
checkout content before treating it as resolved, so an untouched file blocks the
merge instead of being silently staged. Resolving to the deleted side (empty content)
now runs `git rm` instead of staging a tracked, zero-byte file.
This commit is contained in:
Mika Kuns
2026-08-26 15:25:42 +02:00
parent c4928b4def
commit 533a287eb9
3 changed files with 176 additions and 13 deletions
@@ -1109,6 +1109,63 @@ public class TaskMergeServiceTests : IDisposable
Assert.Contains(file.Segments, s => !s.IsConflict && s.Text.Contains("line1"));
}
// git never writes <<<<<<< markers for a modify/delete conflict -- it just leaves the
// modifying side's content sitting in the working tree, which used to read as an
// already-resolved stable file (no conflict at all) to both GetConflictDocumentsAsync and
// ContinueMergeAsync's marker scan, so `continue_merge` would silently keep the modification.
[Fact]
public async Task ModifyDeleteConflict_IsSynthesizedAndCannotBeSilentlyStaged()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPath));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/modify-delete", wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# modified by branch\n");
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch modifies README");
GitRepoFixture.RunGit(repo.RepoDir, "rm", "README.md");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main deletes README");
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
await SeedWorktree(db, task.Id, wtPath, "claudedo/modify-delete", repo.BaseCommit);
var (svc, _) = BuildService(db);
var merge = await svc.MergeAsync(task.Id, "main", removeWorktree: false, "msg",
leaveConflictsInTree: true, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, merge.Status);
// No <<<<<<< markers anywhere -- git left the branch's modification as-is.
Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));
var docs = await svc.GetConflictDocumentsAsync(task.Id, CancellationToken.None);
var file = Assert.Single(docs.Files);
Assert.EndsWith("README.md", file.Path.Replace('\\', '/'));
Assert.False(file.IsBinary);
var conflict = Assert.Single(file.Segments.Where(s => s.IsConflict).ToList());
Assert.Equal("", conflict.Ours); // ours (main) deleted it
Assert.Contains("modified by branch", conflict.Theirs); // theirs (branch) modified it
// Nobody resolved it — continue must refuse rather than silently keeping the modification.
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var refused = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, refused.Status);
Assert.Contains("README.md", refused.ConflictFiles);
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
Assert.True(await new GitService().IsMidMergeAsync(repo.RepoDir));
// Explicitly resolving to the deleted (ours) side must `git rm`, not stage an empty file.
await svc.WriteResolutionAsync(task.Id, "README.md", "", CancellationToken.None);
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.False(File.Exists(Path.Combine(repo.RepoDir, "README.md")));
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
}
[Fact]
public async Task ApproveAndMergeAsync_NoWorktree_MarksDone()
{