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
+26
View File
@@ -376,6 +376,32 @@ public sealed class GitService
throw new InvalidOperationException($"git add '{path}' failed (exit {exitCode}): {stderr}");
}
/// <summary>
/// Stages <paramref name="path"/> as deleted (`git rm -f`), removing it from both the index
/// and, if present, the working tree. Used to resolve a merge conflict to "keep the
/// deletion" — unlike <see cref="AddPathAsync"/>, which would stage an empty file as a
/// tracked, zero-byte file rather than actually removing it.
/// </summary>
public async Task RemovePathAsync(string repoDir, string path, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["rm", "-f", "--", path], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git rm '{path}' failed (exit {exitCode}): {stderr}");
}
/// <summary>
/// Content of <paramref name="path"/> at a merge stage (1=base, 2=ours, 3=theirs) via
/// `git show :&lt;stage&gt;:&lt;path&gt;`. Null when that stage doesn't exist for the path —
/// the conventional way to tell a modify/delete or rename/delete conflict apart from an
/// ordinary content conflict (git never writes `&lt;&lt;&lt;&lt;&lt;&lt;&lt;` markers for those,
/// it just leaves whichever side wasn't deleted in the working tree).
/// </summary>
public async Task<string?> ShowConflictStageAsync(string repoDir, int stage, string path, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $":{stage}:{path}"], ct, trimOutput: false);
return exitCode == 0 ? stdout : null;
}
/// <summary>
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
/// loose objects — the working tree, index, and refs are left untouched.