Merge branch 'claudedo/295d5d409c194ba28dbb95ab13630832'

This commit is contained in:
mika kuns
2026-08-10 15:04:46 +02:00
9 changed files with 272 additions and 15 deletions
@@ -53,6 +53,7 @@ public sealed class TaskMergeService
public const string StatusBlocked = "blocked";
public const string StatusAborted = "aborted";
public const string StatusVerifyFailed = "verify_failed";
public const string StatusUntrackedCollision = "untracked_collision";
public const string StatusReverted = "reverted";
public const string StatusConflictAborted = "conflict_aborted";
@@ -60,6 +61,7 @@ public sealed class TaskMergeService
public const string PreviewClean = "clean";
public const string PreviewConflict = "conflict";
public const string PreviewUnavailable = "unavailable";
public const string PreviewUntrackedCollision = "untracked_collision";
// The verify command is a trusted, list-owner-configured build/test invocation (not
// per-request user input), so a generous fixed timeout is enough — no need for a
@@ -150,6 +152,82 @@ public sealed class TaskMergeService
return trimmed.Length <= maxChars ? trimmed : trimmed[^maxChars..];
}
/// <summary>
/// Guards against a merge silently overwriting a file that sits untracked in the target
/// working directory: git already refuses that itself while the path is still untracked at
/// merge time, but a path that only became trackable in between (e.g. an unrelated conflict
/// resolution's `git add -A` sweeping it up) loses that protection. Compares the paths
/// <paramref name="branchName"/> newly added since its merge base with <paramref name="targetRef"/>
/// against what's currently untracked in <paramref name="workingDir"/> — reusing
/// <see cref="GitService.GetCommittedFilesAsync"/> (run against the branch's own worktree, where
/// HEAD is the branch tip) and <see cref="GitService.GetStatusPorcelainAsync"/> rather than adding
/// new git plumbing. Returns null when there's nothing to flag.
/// </summary>
private async Task<MergeResult?> CheckUntrackedCollisionsAsync(
string workingDir, string targetRef, string worktreePath, string branchName, CancellationToken ct)
{
var mergeBase = await _git.MergeBaseAsync(workingDir, targetRef, branchName, ct);
if (mergeBase is null) return null;
List<string> addedByBranch;
try
{
addedByBranch = ParseAddedPaths(await _git.GetCommittedFilesAsync(worktreePath, mergeBase, ct));
}
catch { return null; } // worktree missing — nothing to compare, let the merge report its own outcome
if (addedByBranch.Count == 0) return null;
var untracked = ParseUntrackedPaths(await _git.GetStatusPorcelainAsync(workingDir, ct));
if (untracked.Count == 0) return null;
var untrackedSet = new HashSet<string>(untracked, StringComparer.OrdinalIgnoreCase);
var collisions = addedByBranch.Where(untrackedSet.Contains).ToList();
if (collisions.Count == 0) return null;
var details = collisions.Select(p => $"{p} ({DescribeSize(workingDir, p)})");
return new MergeResult(StatusUntrackedCollision, collisions,
"merge would overwrite untracked file(s) that exist in the target working directory: " +
string.Join(", ", details));
}
private static string DescribeSize(string workingDir, string relativePath)
{
try
{
var full = Path.Combine(workingDir, relativePath.Replace('/', Path.DirectorySeparatorChar));
return $"{new FileInfo(full).Length} bytes";
}
catch { return "size unknown"; }
}
// Only "A" (pure add) entries — a path the branch modifies but that already exists on the
// target's own history is a normal merge, not a collision with something untracked.
private static List<string> ParseAddedPaths(string nameStatus)
{
var result = new List<string>();
foreach (var raw in nameStatus.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var line = raw.TrimEnd('\r');
if (line.Length < 2 || line[0] != 'A') continue;
var tab = line.IndexOf('\t');
if (tab >= 0) result.Add(line[(tab + 1)..].Trim());
}
return result;
}
private static List<string> ParseUntrackedPaths(string porcelain)
{
var result = new List<string>();
foreach (var raw in porcelain.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var line = raw.TrimEnd('\r');
if (line.StartsWith("?? ", StringComparison.Ordinal))
result.Add(line[3..].Trim().Trim('"'));
}
return result;
}
private async Task MarkWorktreeMergedAsync(string taskId, string mergeCommitSha, CancellationToken ct)
{
using (var ctx = _dbFactory.CreateDbContext())
@@ -205,6 +283,9 @@ public sealed class TaskMergeService
catch (Exception ex) { return Blocked($"failed to switch target branch: {ex.Message}"); }
}
var collision = await CheckUntrackedCollisionsAsync(list.WorkingDir, "HEAD", wt.Path, wt.BranchName, ct);
if (collision is not null) return collision;
var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct);
if (exitCode != 0)
{
@@ -322,6 +403,14 @@ public sealed class TaskMergeService
if (stillConflicted.Count > 0)
return new MergeResult(StatusConflict, stillConflicted, "conflicts not fully resolved");
// Closes the window between the original `git merge` (which already refused any
// untracked collision at that point) and this call: an untracked file matching one
// of the branch's own added paths could have appeared in the meantime, leaving the
// index and the working tree disagreeing about that path — committing then would
// either lose the local content or silently drop the branch's own added file.
var collision = await CheckUntrackedCollisionsAsync(list.WorkingDir, "HEAD", wt.Path, wt.BranchName, ct);
if (collision is not null) return collision;
// 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).
@@ -554,6 +643,12 @@ public sealed class TaskMergeService
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
: targetBranch;
// merge-tree (below) is a pure object-level check — it has no idea the working directory
// has an untracked file that would block (or, worse, silently be lost by) the real merge.
var collision = await CheckUntrackedCollisionsAsync(list.WorkingDir, target, wt.Path, wt.BranchName, ct);
if (collision is not null)
return new MergePreviewResult(PreviewUntrackedCollision, collision.ConflictFiles, 0);
var preview = await _git.PreviewMergeAsync(list.WorkingDir, target, wt.BranchName, ct);
if (!preview.Supported)
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);