Merge claudedo/f319b602707f4dfdb6727e9fae5ac757
This commit is contained in:
@@ -25,10 +25,13 @@ public sealed record MergeSegment
|
||||
/// <summary>"Theirs" side (the incoming branch) when <see cref="IsConflict"/> is true.</summary>
|
||||
public string Theirs { get; init; } = "";
|
||||
|
||||
public static MergeSegment Stable(string text) => new() { Text = text };
|
||||
/// <summary>1-based line number in the file where this segment starts (the marker line for a conflict).</summary>
|
||||
public int StartLine { get; init; } = 1;
|
||||
|
||||
public static MergeSegment Conflict(string ours, string? @base, string theirs) =>
|
||||
new() { IsConflict = true, Ours = ours, Base = @base, Theirs = theirs };
|
||||
public static MergeSegment Stable(string text, int startLine = 1) => new() { Text = text, StartLine = startLine };
|
||||
|
||||
public static MergeSegment Conflict(string ours, string? @base, string theirs, int startLine = 1) =>
|
||||
new() { IsConflict = true, Ours = ours, Base = @base, Theirs = theirs, StartLine = startLine };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -49,22 +52,25 @@ public static class ConflictMarkerParser
|
||||
var segments = new List<MergeSegment>();
|
||||
var lines = SplitKeepLineEndings(fileText);
|
||||
var stable = new StringBuilder();
|
||||
var stableStartLine = 1;
|
||||
var i = 0;
|
||||
|
||||
while (i < lines.Count)
|
||||
{
|
||||
if (!IsMarker(lines[i], OursMarker))
|
||||
{
|
||||
if (stable.Length == 0) stableStartLine = i + 1;
|
||||
stable.Append(lines[i++]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stable.Length > 0)
|
||||
{
|
||||
segments.Add(MergeSegment.Stable(stable.ToString()));
|
||||
segments.Add(MergeSegment.Stable(stable.ToString(), stableStartLine));
|
||||
stable.Clear();
|
||||
}
|
||||
|
||||
var conflictStartLine = i + 1;
|
||||
i++; // consume "<<<<<<<"
|
||||
var ours = new StringBuilder();
|
||||
while (i < lines.Count && !IsMarker(lines[i], BaseMarker) && !IsMarker(lines[i], SepMarker))
|
||||
@@ -88,11 +94,11 @@ public static class ConflictMarkerParser
|
||||
|
||||
if (i < lines.Count && IsMarker(lines[i], TheirsMarker)) i++; // consume ">>>>>>>"
|
||||
|
||||
segments.Add(MergeSegment.Conflict(ours.ToString(), @base, theirs.ToString()));
|
||||
segments.Add(MergeSegment.Conflict(ours.ToString(), @base, theirs.ToString(), conflictStartLine));
|
||||
}
|
||||
|
||||
if (stable.Length > 0)
|
||||
segments.Add(MergeSegment.Stable(stable.ToString()));
|
||||
segments.Add(MergeSegment.Stable(stable.ToString(), stableStartLine));
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
+113
@@ -88,6 +88,18 @@ public sealed record MergeContinuationResultDto(
|
||||
bool Merged, string TaskStatus, IReadOnlyList<string> Conflicts,
|
||||
string? RepoPath, string? Message);
|
||||
|
||||
public sealed record ConflictHunkDto(
|
||||
int Index, int StartLine, string Ours, string? Base, string Theirs);
|
||||
|
||||
public sealed record ConflictFileHunksDto(
|
||||
string Path, bool IsBinary, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||
|
||||
public sealed record GetMergeConflictsResultDto(
|
||||
IReadOnlyList<ConflictFileHunksDto> Files, int RemainingHunks, string ConflictStyleNote = McpToolDocs.Diff3Note);
|
||||
|
||||
public sealed record ResolveConflictHunkResultDto(
|
||||
bool Resolved, int RemainingHunks, string ConflictStyleNote = McpToolDocs.Diff3Note);
|
||||
|
||||
// IsEmpty = the review range contributed nothing (worktree ahead-of-base is empty, or a
|
||||
// worktree-less handler task's HandlerBaseCommit == HandlerHeadCommit) -- distinguishable from
|
||||
// a merge that is merely small, so an empty branch can't be misread as "changedFileCount: 0
|
||||
@@ -938,6 +950,107 @@ public sealed class ExternalMcpService
|
||||
return ToRefDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"List every conflict hunk left in a paused merge (started via merge_task/review_task with " +
|
||||
"leaveConflictsInTree=true), so you can resolve them with resolve_conflict_hunk instead of parsing marker " +
|
||||
"text yourself. Each hunk carries the file's path, its index within that file (what resolve_conflict_hunk " +
|
||||
"expects back), and its startLine. remainingHunks is the total across every file — call continue_merge once " +
|
||||
"it reaches 0, or abort_merge to cancel. Throws if the task has no in-progress merge." + " " + McpToolDocs.Diff3Note)]
|
||||
public async Task<GetMergeConflictsResultDto> GetMergeConflicts(string taskId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
|
||||
var docs = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
|
||||
var files = docs.Files.Select(ToConflictFileHunksDto).ToList();
|
||||
var remaining = files.Sum(f => f.Hunks.Count);
|
||||
return new GetMergeConflictsResultDto(files, remaining);
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Resolve exactly one conflict hunk from get_merge_conflicts, identified by file path and its index within " +
|
||||
"that file. Writes only that hunk — every other hunk (in this file or another) is left exactly as it was, " +
|
||||
"so call get_merge_conflicts again afterward: resolved hunks disappear and every remaining hunk's index " +
|
||||
"shifts down to stay zero-based. This never decides FOR you which side wins — resolution must be 'ours', " +
|
||||
"'theirs', 'base' (only valid when that hunk has a diff3 base), or literal replacement text (used exactly " +
|
||||
"as given, including any trailing newline the surrounding file needs). remainingHunks is the total across " +
|
||||
"every file; call continue_merge once it reaches 0. Throws if the file/index don't match a current hunk." +
|
||||
" " + McpToolDocs.Diff3Note)]
|
||||
public async Task<ResolveConflictHunkResultDto> ResolveConflictHunk(
|
||||
string taskId,
|
||||
[Description("File path exactly as returned by get_merge_conflicts, relative to the repo root.")]
|
||||
string file,
|
||||
[Description("Zero-based hunk index within that file, as returned by get_merge_conflicts.")]
|
||||
int index,
|
||||
[Description("'ours', 'theirs', 'base', or literal text to use for this hunk.")]
|
||||
string resolution,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(resolution))
|
||||
throw new InvalidOperationException("resolution is required.");
|
||||
|
||||
var docs = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
|
||||
var doc = docs.Files.FirstOrDefault(f => string.Equals(f.Path, file, StringComparison.Ordinal))
|
||||
?? throw new InvalidOperationException($"File '{file}' has no conflict for task {taskId}.");
|
||||
if (doc.IsBinary)
|
||||
throw new InvalidOperationException($"File '{file}' is binary; resolve it directly in the working tree.");
|
||||
|
||||
var hunkCount = doc.Segments.Count(s => s.IsConflict);
|
||||
if (index < 0 || index >= hunkCount)
|
||||
throw new InvalidOperationException(
|
||||
$"Hunk index {index} is out of range for '{file}' ({hunkCount} hunk(s) remaining).");
|
||||
|
||||
var counter = -1;
|
||||
var composed = ConflictMarkerParser.Compose(doc.Segments, seg =>
|
||||
{
|
||||
counter++;
|
||||
if (counter != index) return ReconstructConflictMarkers(seg);
|
||||
|
||||
var choice = resolution.Trim().ToLowerInvariant();
|
||||
return choice switch
|
||||
{
|
||||
"ours" => seg.Ours,
|
||||
"theirs" => seg.Theirs,
|
||||
"base" => seg.Base ?? throw new InvalidOperationException(
|
||||
$"Hunk {index} in '{file}' has no diff3 base to resolve to."),
|
||||
_ => resolution,
|
||||
};
|
||||
});
|
||||
|
||||
await _merge.WriteConflictFileAsync(taskId, file, composed, cancellationToken);
|
||||
|
||||
var refreshed = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
|
||||
var remaining = refreshed.Files.Sum(f => f.Segments.Count(s => s.IsConflict));
|
||||
return new ResolveConflictHunkResultDto(true, remaining);
|
||||
}
|
||||
|
||||
private static ConflictFileHunksDto ToConflictFileHunksDto(ConflictDocumentContent f)
|
||||
{
|
||||
var hunks = new List<ConflictHunkDto>();
|
||||
foreach (var seg in f.Segments)
|
||||
{
|
||||
if (!seg.IsConflict) continue;
|
||||
hunks.Add(new ConflictHunkDto(hunks.Count, seg.StartLine, seg.Ours, seg.Base, seg.Theirs));
|
||||
}
|
||||
return new ConflictFileHunksDto(f.Path, f.IsBinary, hunks);
|
||||
}
|
||||
|
||||
// Re-renders a still-unresolved hunk as valid (unlabeled) conflict markers so it round-trips through
|
||||
// ConflictMarkerParser.Parse again untouched, and git still refuses continue_merge while it stands. The
|
||||
// original marker labels (e.g. "<<<<<<< HEAD") aren't recoverable from a parsed MergeSegment and aren't
|
||||
// needed for either check -- ConflictMarkerParser.IsMarker and ContinueMergeAsync's rescan only match the
|
||||
// marker prefixes.
|
||||
private static string ReconstructConflictMarkers(MergeSegment seg)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("<<<<<<<\n").Append(seg.Ours);
|
||||
if (seg.Base is not null)
|
||||
sb.Append("|||||||\n").Append(seg.Base);
|
||||
sb.Append("=======\n").Append(seg.Theirs);
|
||||
sb.Append(">>>>>>>\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Check whether a task would merge cleanly before touching anything — `git merge-tree --write-tree`, so the " +
|
||||
"working tree, index and HEAD are untouched. status is 'clean' or 'conflict' (conflictFiles lists where git " +
|
||||
|
||||
+11
@@ -25,4 +25,15 @@ internal static class McpToolDocs
|
||||
|
||||
/// <summary>Mutations that refuse to touch a task while its agent is running.</summary>
|
||||
public const string NotWhileRunning = " Refused while the task is Running — cancel it first.";
|
||||
|
||||
/// <summary>
|
||||
/// Explains this project's diff3 conflict-marker layout so a caller grepping only for
|
||||
/// "<<<<<<</=======/>>>>>>>" doesn't mistake the "|||||||" base section for one of the two sides.
|
||||
/// No leading space (unlike the other consts here) since it also doubles as a DTO field default value.
|
||||
/// </summary>
|
||||
public const string Diff3Note =
|
||||
"Conflicts are parsed in git's diff3 style: each hunk has an 'ours' side (the target branch) and a " +
|
||||
"'theirs' side (the branch being merged in), plus an optional 'base' — the common-ancestor text for that " +
|
||||
"hunk, shown between a third '|||||||' marker and the '=======' separator. base is null when git recorded " +
|
||||
"none for that hunk.";
|
||||
}
|
||||
|
||||
@@ -492,6 +492,24 @@ public sealed class TaskMergeService
|
||||
await _git.AddPathAsync(list.WorkingDir, path, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a conflicted file's content without staging it. Unlike <see cref="WriteResolutionAsync"/> this must
|
||||
/// NOT run `git add` — that marks the path resolved in git's index regardless of its content, so a partially
|
||||
/// resolved hunk (markers still present elsewhere in the file) would drop out of
|
||||
/// <see cref="GetConflictDocumentsAsync"/>'s conflicted-file list and <see cref="ContinueMergeAsync"/>'s
|
||||
/// pre-stage marker scan, letting a still-conflicted file slip into a commit. Staging happens once, for
|
||||
/// everything, inside <see cref="ContinueMergeAsync"/>.
|
||||
/// </summary>
|
||||
public async Task WriteConflictFileAsync(string taskId, string path, string content, CancellationToken ct)
|
||||
{
|
||||
var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct);
|
||||
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||
throw new InvalidOperationException("list has no working directory");
|
||||
|
||||
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
|
||||
await File.WriteAllTextAsync(full, content, ct);
|
||||
}
|
||||
|
||||
public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct);
|
||||
|
||||
@@ -68,6 +68,20 @@ public class ConflictMarkerParserTests
|
||||
Assert.Equal("Y\n", segments[1].Theirs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Segments_ExposeStartLine()
|
||||
{
|
||||
const string text =
|
||||
"line1\nline2\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\nline3\n";
|
||||
|
||||
var segments = ConflictMarkerParser.Parse(text);
|
||||
|
||||
Assert.Equal(3, segments.Count);
|
||||
Assert.Equal(1, segments[0].StartLine); // "line1\nline2\n"
|
||||
Assert.Equal(3, segments[1].StartLine); // the "<<<<<<<" line
|
||||
Assert.Equal(8, segments[2].StartLine); // "line3\n"
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleConflicts_AreEachCaptured()
|
||||
{
|
||||
|
||||
@@ -1164,6 +1164,49 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
return (task, list, wt);
|
||||
}
|
||||
|
||||
// Three edits spread far enough apart in a 20-line file that git's 3-way merge keeps them as three
|
||||
// separate, non-adjacent hunks instead of folding them into one.
|
||||
private static readonly int[] MultiHunkEditLines = [3, 9, 15];
|
||||
|
||||
private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedMultiHunkConflictingWorktreeAsync()
|
||||
{
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
|
||||
var baseLines = Enumerable.Range(1, 20).Select(n => $"line{n}").ToArray();
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), string.Join('\n', baseLines) + "\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "README.md");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "multi-line base");
|
||||
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
var list = new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow };
|
||||
await _lists.AddAsync(list);
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
||||
|
||||
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
||||
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
||||
var wt = await mgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_worktreeCleanups.Add((repo.RepoDir, wt.WorktreePath));
|
||||
|
||||
WriteMultiHunkVariant(wt.WorktreePath, baseLines, "WT");
|
||||
GitRepoFixture.RunGit(wt.WorktreePath, "add", "README.md");
|
||||
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "worktree edit");
|
||||
|
||||
WriteMultiHunkVariant(list.WorkingDir!, baseLines, "MAIN");
|
||||
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
|
||||
GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "main edit");
|
||||
|
||||
return (task, list, wt);
|
||||
}
|
||||
|
||||
private static void WriteMultiHunkVariant(string dir, string[] baseLines, string tag)
|
||||
{
|
||||
var lines = (string[])baseLines.Clone();
|
||||
foreach (var lineNum in MultiHunkEditLines)
|
||||
lines[lineNum - 1] = $"{tag}-{lineNum}";
|
||||
File.WriteAllText(Path.Combine(dir, "README.md"), string.Join('\n', lines) + "\n");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeTask_LeaveConflictsInTree_LeavesMarkersAndKeepsRepoMidMerge()
|
||||
{
|
||||
@@ -1336,6 +1379,130 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
Assert.Contains("mid-merge", ex.Message);
|
||||
}
|
||||
|
||||
// ── get_merge_conflicts / resolve_conflict_hunk ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetMergeConflicts_ReportsHunksWithStartLineAndDiff3Base()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, CancellationToken.None);
|
||||
|
||||
var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.RemainingHunks);
|
||||
var file = Assert.Single(result.Files);
|
||||
Assert.Equal("README.md", file.Path);
|
||||
Assert.False(file.IsBinary);
|
||||
var hunk = Assert.Single(file.Hunks);
|
||||
Assert.Equal(0, hunk.Index);
|
||||
Assert.True(hunk.StartLine >= 1);
|
||||
Assert.Equal("# from main", hunk.Ours.TrimEnd('\r', '\n'));
|
||||
Assert.Equal("# from worktree", hunk.Theirs.TrimEnd('\r', '\n'));
|
||||
Assert.NotEmpty(result.ConflictStyleNote);
|
||||
|
||||
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMergeConflicts_NoMergeInProgress_ReturnsEmpty()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.RemainingHunks);
|
||||
Assert.Empty(result.Files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveConflictHunk_OursTheirsAndText_ResolveEachHunkAndTrackRemainingCount()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync();
|
||||
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var merge = await sut.MergeTask(task.Id, target, true, dryRun: false,
|
||||
allowWaitingForReview: true, leaveConflictsInTree: true, CancellationToken.None);
|
||||
Assert.True(merge.ConflictsInTree);
|
||||
|
||||
var before = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
||||
Assert.Equal(3, before.RemainingHunks);
|
||||
Assert.Equal(3, Assert.Single(before.Files).Hunks.Count);
|
||||
|
||||
// Resolve hunk 0 three times in a row: each resolution removes a hunk from the front, so the
|
||||
// next still-open hunk always slides into index 0.
|
||||
var r1 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None);
|
||||
Assert.Equal(2, r1.RemainingHunks);
|
||||
|
||||
var r2 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "theirs", CancellationToken.None);
|
||||
Assert.Equal(1, r2.RemainingHunks);
|
||||
|
||||
var r3 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "CUSTOM-15\n", CancellationToken.None);
|
||||
Assert.Equal(0, r3.RemainingHunks);
|
||||
|
||||
var finalConflicts = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
||||
Assert.Equal(0, finalConflicts.RemainingHunks);
|
||||
|
||||
var continueResult = await sut.ContinueMerge(task.Id, CancellationToken.None);
|
||||
Assert.True(continueResult.Merged);
|
||||
Assert.Equal("Done", continueResult.TaskStatus);
|
||||
|
||||
var mergedText = File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"));
|
||||
Assert.Contains("MAIN-3", mergedText); // hunk 1 resolved "ours" (the target branch)
|
||||
Assert.Contains("WT-9", mergedText); // hunk 2 resolved "theirs" (the incoming branch)
|
||||
Assert.Contains("CUSTOM-15", mergedText); // hunk 3 resolved to literal text
|
||||
Assert.DoesNotContain("<<<<<<<", mergedText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveConflictHunk_LeavesOtherHunksUntouchedUntilContinueMerge()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync();
|
||||
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
await sut.MergeTask(task.Id, target, true, dryRun: false,
|
||||
allowWaitingForReview: true, leaveConflictsInTree: true, CancellationToken.None);
|
||||
|
||||
// Resolve only one of the three hunks.
|
||||
await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None);
|
||||
|
||||
// continue_merge must still refuse: writing one hunk must not have staged the file as resolved.
|
||||
var result = await sut.ContinueMerge(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Merged);
|
||||
Assert.Contains("README.md", result.Conflicts);
|
||||
Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
||||
|
||||
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveConflictHunk_UnknownFileOrOutOfRangeIndex_Throws()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, CancellationToken.None);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sut.ResolveConflictHunk(task.Id, "not-a-real-file.md", 0, "ours", CancellationToken.None));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sut.ResolveConflictHunk(task.Id, "README.md", 5, "ours", CancellationToken.None));
|
||||
|
||||
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
||||
}
|
||||
|
||||
// ── RevertMerge ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user