fix(claude-do): merge Modify/Delete- und Rename-Konflikte im Merge sichtbar machen

ClaudeDo-Task: 059bcf98-34d5-4ca0-822d-317980fadf8d
This commit is contained in:
Mika Kuns
2026-08-26 15:46:51 +02:00
3 changed files with 176 additions and 13 deletions
@@ -545,20 +545,48 @@ public sealed class TaskMergeService
var oldTargetTip = await _git.RevParseHeadAsync(list.WorkingDir, ct);
// Validate BEFORE staging: `git add` marks a conflicted path resolved regardless of
// its content, so an unresolved file with markers still in it would otherwise get
// staged (and committed) as-is. Check text content for markers first; binary files
// can't carry markers, so they're left to the post-stage index check below.
// its content, so an unresolved file needs a positively-checked resolution, not just
// "whatever happens to be on disk". For an ordinary content conflict, git always
// starts the file with markers, so their absence reliably means someone resolved it
// (in-app or in an external editor). A modify/delete or rename/delete conflict never
// gets markers at all (GetConflictDocumentsAsync) — the same marker-less state also
// describes a file nobody has touched — so those are additionally checked against
// what git itself left on disk by default (whichever side wasn't deleted); still
// matching that default means still unresolved.
var unresolved = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
var stillConflicted = new List<string>();
var toAdd = new List<string>();
var toRemove = new List<string>();
foreach (var path in unresolved)
{
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
string text;
try { text = await File.ReadAllTextAsync(full, ct); }
catch { continue; }
string? text = null;
if (File.Exists(full))
{
try { text = await File.ReadAllTextAsync(full, ct); }
catch { /* treated as unresolved below */ }
}
if (!LooksBinary(text) && ConflictMarkerParser.HasConflicts(text))
if (text is not null && (LooksBinary(text) || ConflictMarkerParser.HasConflicts(text)))
{
stillConflicted.Add(path);
continue;
}
var oursStage = await _git.ShowConflictStageAsync(list.WorkingDir, 2, path, ct);
var theirsStage = await _git.ShowConflictStageAsync(list.WorkingDir, 3, path, ct);
var isModifyDeleteStyle = oursStage is null || theirsStage is null;
if (isModifyDeleteStyle &&
NormalizeLineEndings(text ?? "") == NormalizeLineEndings(oursStage ?? theirsStage ?? ""))
{
stillConflicted.Add(path);
continue;
}
if (text is null || text.Length == 0)
toRemove.Add(path);
else
toAdd.Add(path);
}
if (stillConflicted.Count > 0)
@@ -575,8 +603,10 @@ public sealed class TaskMergeService
// Stage exactly the resolved conflict paths — never `git add -A`, which would sweep
// untracked/unrelated changes left by other sessions into this merge commit (the
// target working dir is shared).
foreach (var path in unresolved)
foreach (var path in toAdd)
await _git.AddPathAsync(list.WorkingDir, path, ct);
foreach (var path in toRemove)
await _git.RemovePathAsync(list.WorkingDir, path, ct);
var remaining = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
if (remaining.Count > 0)
@@ -713,6 +743,15 @@ public sealed class TaskMergeService
/// <summary>
/// Reads each conflicted working-tree file and parses its conflict markers into line-level
/// segments (with the diff3 merge base when present). Binary files are flagged and skipped.
/// A path with no text markers on disk is either an ordinary content conflict that's already
/// been resolved (git keeps both index stages until the path is staged, whether or not the
/// working tree still looks conflicted — reads as a single stable segment, 0 hunks left), or
/// a modify/delete or rename/delete conflict — git never writes markers for those at all, it
/// just leaves whichever side wasn't deleted sitting in the working tree, so the same
/// "no markers" state also describes an untouched file. The index tells the two apart: a
/// modify/delete-style conflict only ever populates ONE of the ours/theirs stages. Those are
/// synthesized into a single whole-file conflict block straight from the stages (empty side
/// = that side deleted the path) so the resolver still shows a real choice.
/// </summary>
public async Task<ConflictDocuments> GetConflictDocumentsAsync(string taskId, CancellationToken ct)
{
@@ -725,21 +764,53 @@ public sealed class TaskMergeService
foreach (var path in files)
{
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
string text;
try { text = await File.ReadAllTextAsync(full, ct); }
catch { text = ""; }
string? diskText = null;
if (File.Exists(full))
{
try { diskText = await File.ReadAllTextAsync(full, ct); }
catch { /* unreadable — fall through to the index stages below */ }
}
if (LooksBinary(text))
if (diskText is not null && LooksBinary(diskText))
{
result.Add(new ConflictDocumentContent(path, true, Array.Empty<MergeSegment>()));
continue;
}
result.Add(new ConflictDocumentContent(path, false, ConflictMarkerParser.Parse(text)));
if (diskText is not null && ConflictMarkerParser.HasConflicts(diskText))
{
result.Add(new ConflictDocumentContent(path, false, ConflictMarkerParser.Parse(diskText)));
continue;
}
var ours = await _git.ShowConflictStageAsync(list.WorkingDir, 2, path, ct);
var theirs = await _git.ShowConflictStageAsync(list.WorkingDir, 3, path, ct);
if (ours is not null && theirs is not null)
{
// Ordinary two-sided conflict, already resolved (no markers left) but not yet staged.
result.Add(new ConflictDocumentContent(path, false,
new[] { MergeSegment.Stable(diskText ?? "") }));
continue;
}
if (LooksBinary(ours ?? "") || LooksBinary(theirs ?? ""))
{
result.Add(new ConflictDocumentContent(path, true, Array.Empty<MergeSegment>()));
continue;
}
var @base = await _git.ShowConflictStageAsync(list.WorkingDir, 1, path, ct);
result.Add(new ConflictDocumentContent(
path, false, new[] { MergeSegment.Conflict(ours ?? "", @base, theirs ?? "") }));
}
return new ConflictDocuments(taskId, result);
}
// Working-tree checkouts can go through autocrlf while `git show :stage:path` never does —
// normalize before comparing the two or an untouched file reads as "resolved" on a machine
// with autocrlf enabled.
private static string NormalizeLineEndings(string text) => text.Replace("\r\n", "\n").Replace('\r', '\n');
// A NUL byte in the head of the file is the conventional binary sniff.
private static bool LooksBinary(string text)
{
@@ -755,6 +826,15 @@ public sealed class TaskMergeService
if (string.IsNullOrWhiteSpace(list.WorkingDir))
throw new InvalidOperationException("list has no working directory");
if (content.Length == 0)
{
// An empty resolution for a whole-file conflict (modify/delete, rename/delete) means
// "keep the deletion" — `git add` on an empty file would instead stage it as a
// tracked, zero-byte file, which is not what accepting the deleted side means.
await _git.RemovePathAsync(list.WorkingDir, path, ct);
return;
}
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
await File.WriteAllTextAsync(full, content, ct);
await _git.AddPathAsync(list.WorkingDir, path, ct);