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:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user