fix(merge): refuse a merge that would overwrite an untracked file in the target

Before merging (or staging a conflict resolution's git add -A), compare the
branch's newly-added paths against what's currently untracked in the target
working directory. A collision aborts with a new untracked_collision status
naming the path and size, surfaced through merge_task/review_task,
preview_merge/preview_merge_set (which merge-tree alone can't see), and the
UI merge paths via FlashFooterError/ShowErrorAsync instead of a silent
catch{}. git's own preflight already refuses this while the path stays
untracked at merge time; this closes the gap once a path becomes trackable
in between (e.g. an unrelated conflict resolution's git add -A) or across
the continue_merge TOCTOU window.
This commit is contained in:
mika kuns
2026-08-10 12:15:41 +02:00
parent 6a2a19cc9e
commit afe1b68f46
9 changed files with 269 additions and 13 deletions
+14 -1
View File
@@ -42,6 +42,16 @@ public sealed class GitService
};
}
/// <summary>
/// The merge base of two refs, or null when git can't find one (e.g. an unresolvable ref) —
/// callers treat that as "can't evaluate", not "no common history".
/// </summary>
public async Task<string?> MergeBaseAsync(string repoDir, string refA, string refB, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["merge-base", refA, refB], ct);
return exitCode == 0 ? stdout.Trim() : null;
}
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
{
await WorktreeAddGate.WaitAsync(ct);
@@ -70,9 +80,12 @@ public sealed class GitService
}
}
// --untracked-files=all: without it, a brand-new untracked directory collapses into a single
// "?? dir/" entry instead of listing the files inside it — callers matching against specific
// paths (e.g. the untracked-collision guard) need the individual files.
public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(workingDirectory, ["status", "--porcelain"], ct);
var (exitCode, stdout, stderr) = await RunGitAsync(workingDirectory, ["status", "--porcelain", "--untracked-files=all"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}");
return stdout;