Overlap detection was file-name-only: 9/15 tasks touching en.json/de.json in run #316 all got flagged even though only one pair actually collided on a key, and a removed method with no textual file conflict (#297 vs #286) went unflagged entirely. - removedSymbols per task entry: a line-pattern scan of `-` diff lines for C# method/property/field/type/event declarations (skips .axaml/.json). - symbolWarnings at the set level: cross-references those names against every other task's `+` lines, catching same-symbol collisions across files that overlaps/subsets (both purely file-based) can't see. - overlaps narrows to key-path level for .json files (base commit vs. task branch head, both sides parsed and flattened) so two tasks adding different locale keys to the same file no longer collide; unparsable JSON on either side falls back to the old file-level check. - GitService.ShowFileAsync: `git show ref:path`, used to read a task branch's json content at its base/head commit. Out of scope per the task: no Roslyn/parser, no AXAML analysis, and the merge-helper prompt in PromptFiles.cs doesn't mention the new fields yet. Build: dotnet build src/ClaudeDo.App (pulls in Ui+Data) + src/ClaudeDo.Worker, both clean. Tests: dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~MergeMcpTools|FullyQualifiedName~ExternalMcpToolSchema" -- 51 passed, 0 failed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
939 lines
51 KiB
C#
939 lines
51 KiB
C#
using System.ComponentModel;
|
|
using System.Diagnostics;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Git;
|
|
using ClaudeDo.Worker.Hub;
|
|
using ClaudeDo.Worker.Lifecycle;
|
|
using ClaudeDo.Worker.Planning;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ModelContextProtocol;
|
|
using ModelContextProtocol.Server;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.External;
|
|
|
|
public sealed record MergeTaskResultDto(
|
|
bool Merged, string? MergeCommit, IReadOnlyList<string> Conflicts,
|
|
bool ConflictsInTree = false, string? RepoPath = null);
|
|
|
|
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
|
|
// means tiny" when it actually means "nothing to review".
|
|
// VerifyExitCode/VerifyDurationMs/VerifyOutputTail are null unless a verify run was attempted
|
|
// (see PreviewMerge/PreviewMergeSet descriptions) -- 0 exit means the merge-tree result built/
|
|
// tested clean; a non-zero or -1 (timeout/failed to start) exit means it doesn't, with the tail
|
|
// of its output in VerifyOutputTail.
|
|
public sealed record MergePreviewToolDto(
|
|
string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, bool IsEmpty = false,
|
|
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null,
|
|
// Files this branch touches that the target branch ALSO touched since the branch's fork
|
|
// point -- an honest staleness signal `behind` alone can't give (a branch can be far behind
|
|
// yet touch nothing the target changed, or close behind yet collide on the one file that
|
|
// matters). Empty for a worktree-less handler task, which commits straight onto the list's
|
|
// working dir and has no fork point to compare against.
|
|
IReadOnlyList<string>? StaleFiles = null);
|
|
|
|
public sealed record MergePreviewSetEntryDto(
|
|
string TaskId, string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, string? Error, bool IsEmpty = false,
|
|
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null,
|
|
IReadOnlyList<string>? StaleFiles = null, int? Number = null,
|
|
// Mirrors TaskEntity.ExpectsNoDiff -- lets the merge helper tell a deliberately empty diff
|
|
// (verification/audit/research task) apart from a task that silently failed to commit, both
|
|
// of which otherwise show IsEmpty=true with no other distinguishing signal.
|
|
bool ExpectsNoDiff = false,
|
|
// Top-level C# declarations (methods/properties/fields/types/events) this task's diff removes
|
|
// -- a line-pattern scan over `-` lines, not a parser: false positives are possible, a missed
|
|
// removal is not the intent. Skips .axaml/.json. Null for an entry with Error set. A name
|
|
// another task in the same set still touches on a `+` line surfaces in SymbolWarnings below.
|
|
IReadOnlyList<RemovedSymbolDto>? RemovedSymbols = null);
|
|
|
|
public sealed record RemovedSymbolDto(string Name, string File);
|
|
|
|
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds, IReadOnlyList<int> Numbers);
|
|
|
|
// TaskId's changed-file list is a non-empty PROPER subset of SupersetTaskId's -- e.g. one task
|
|
// deletes two files and another deletes just one of those same two. Stronger than an Overlaps
|
|
// entry (which only says some files are shared): a real subset is the best available machine
|
|
// signal that TaskId's work may already be entirely covered by SupersetTaskId.
|
|
public sealed record SubsetRelationDto(string TaskId, int Number, string SupersetTaskId, int SupersetNumber);
|
|
|
|
// RemovedByTaskId's diff removes Symbol (in RemovedInFile); ReferencedByTaskId's diff still touches
|
|
// that name on a `+` line elsewhere -- no textual file conflict, so Overlaps alone misses it (e.g.
|
|
// one task deletes a method, a sibling task's edit in a different file still calls it).
|
|
public sealed record SymbolReferenceWarningDto(
|
|
string Symbol, string RemovedInFile, string RemovedByTaskId, int RemovedByNumber,
|
|
string ReferencedByTaskId, int ReferencedByNumber, string ReferencedInFile);
|
|
|
|
public sealed record MergePreviewSetResultDto(
|
|
IReadOnlyList<MergePreviewSetEntryDto> Tasks, IReadOnlyList<FileOverlapDto> Overlaps,
|
|
IReadOnlyList<SubsetRelationDto> Subsets, IReadOnlyList<SymbolReferenceWarningDto> SymbolWarnings);
|
|
|
|
public sealed record RevertMergeResultDto(
|
|
bool Reverted, string? RevertCommit, IReadOnlyList<string> Conflicts, string? Message);
|
|
|
|
[McpServerToolType]
|
|
public sealed class MergeMcpTools
|
|
{
|
|
private readonly TaskRepository _tasks;
|
|
private readonly ListRepository _lists;
|
|
private readonly HubBroadcaster _broadcaster;
|
|
private readonly GitService _git;
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly TaskMergeService _merge;
|
|
private readonly PlanningMergeOrchestrator _planningMerge;
|
|
|
|
public MergeMcpTools(
|
|
TaskRepository tasks,
|
|
ListRepository lists,
|
|
HubBroadcaster broadcaster,
|
|
GitService git,
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
TaskMergeService merge,
|
|
PlanningMergeOrchestrator planningMerge)
|
|
{
|
|
_tasks = tasks;
|
|
_lists = lists;
|
|
_broadcaster = broadcaster;
|
|
_git = git;
|
|
_dbFactory = dbFactory;
|
|
_merge = merge;
|
|
_planningMerge = planningMerge;
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Merge a Done task's worktree branch into targetBranch. For a task still in WaitingForReview prefer " +
|
|
"review_task, which merges as part of approving. merged=true carries the new mergeCommit SHA; on conflict " +
|
|
"merged=false and conflicts lists the affected files." + McpToolDocs.ProgressHint)]
|
|
public async Task<MergeTaskResultDto> MergeTask(
|
|
string taskId,
|
|
string targetBranch = "main",
|
|
[Description("true (default): always create a merge commit (--no-ff).")]
|
|
bool noFf = true,
|
|
[Description("true: validate preconditions only and do not merge — merged=false then means \"not attempted\".")]
|
|
bool dryRun = false,
|
|
[Description("true: also allow merging a task in WaitingForReview; false (default) allows Done only.")]
|
|
bool allowWaitingForReview = false,
|
|
[Description("What to do on conflict. false (default): abort cleanly, leaving no half-merged state. true: " +
|
|
"leave the conflict markers in the working tree at repoPath (conflictsInTree=true) so you can " +
|
|
"resolve them there and call continue_merge, or abort_merge to cancel.")]
|
|
bool leaveConflictsInTree = false,
|
|
[Description("Batch mode: defer the list's verify command instead of running it after this one merge " +
|
|
"(see review_task's skipVerify). Call verify_merges once after the batch.")]
|
|
bool skipVerify = false,
|
|
CancellationToken cancellationToken = default,
|
|
IProgress<ProgressNotificationValue>? progress = null)
|
|
{
|
|
progress?.Report(new ProgressNotificationValue { Progress = 0, Message = "merge_task started" });
|
|
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
var canMerge = task.Status == TaskStatus.Done ||
|
|
(allowWaitingForReview && task.Status == TaskStatus.WaitingForReview);
|
|
if (!canMerge)
|
|
throw new InvalidOperationException(
|
|
$"Task must be Done to merge (current status: {task.Status}). " +
|
|
"Pass allowWaitingForReview=true to also merge a WaitingForReview task.");
|
|
|
|
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
|
|
|
if (dryRun)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
|
|
if (wt.State != WorktreeState.Active)
|
|
throw new InvalidOperationException(
|
|
$"Worktree state must be Active to merge (current: {wt.State}).");
|
|
return new MergeTaskResultDto(false, null, []);
|
|
}
|
|
|
|
// Blank on purpose: TaskMergeService builds the conventional default message.
|
|
var result = await _merge.MergeAsync(
|
|
taskId, targetBranch, removeWorktree: false, commitMessage: "", leaveConflictsInTree, cancellationToken, progress, skipVerify);
|
|
|
|
// merged_verify_pending: the branch IS merged (that's what this tool reports on) — only
|
|
// the task's own Done transition is deferred to verify_merges.
|
|
if (result.Status is TaskMergeService.StatusMerged or TaskMergeService.StatusMergedVerifyPending)
|
|
{
|
|
string? mergeCommit = null;
|
|
try
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(list?.WorkingDir) && Directory.Exists(list.WorkingDir))
|
|
mergeCommit = await _git.RevParseHeadAsync(list.WorkingDir, cancellationToken);
|
|
}
|
|
catch { /* mergeCommit is optional */ }
|
|
return new MergeTaskResultDto(true, mergeCommit, []);
|
|
}
|
|
|
|
if (result.Status == TaskMergeService.StatusConflict)
|
|
return leaveConflictsInTree
|
|
? new MergeTaskResultDto(false, null, result.ConflictFiles,
|
|
ConflictsInTree: true, RepoPath: list?.WorkingDir)
|
|
: new MergeTaskResultDto(false, null, result.ConflictFiles);
|
|
|
|
throw new InvalidOperationException(result.ErrorMessage ?? $"Merge blocked: {result.Status}");
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Finish an in-progress conflicted merge once you have resolved the conflict markers in the working tree " +
|
|
"(repoPath from merge_task/review_task). Pass the PARENT task id to continue a parent/children unit merge. " +
|
|
"merged=false with conflicts listed means markers are still present — resolve them and call again. " +
|
|
"Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead." +
|
|
McpToolDocs.ProgressHint)]
|
|
public async Task<MergeContinuationResultDto> ContinueMerge(
|
|
string taskId,
|
|
[Description("Batch mode: defer the list's verify command (see review_task's skipVerify). Pass the same " +
|
|
"value you approved with — a unit merge remembers its own flag and ignores this one.")]
|
|
bool skipVerify = false,
|
|
CancellationToken cancellationToken = default,
|
|
IProgress<ProgressNotificationValue>? progress = null)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
|
var workingDir = list?.WorkingDir;
|
|
|
|
bool merged;
|
|
IReadOnlyList<string> conflicts = Array.Empty<string>();
|
|
string? repoPath = null;
|
|
string? message = null;
|
|
|
|
if (_planningMerge.HasActiveMerge(taskId))
|
|
{
|
|
var unitResult = await _planningMerge.ContinueAsync(taskId, cancellationToken, progress);
|
|
var parent = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
|
if (unitResult.Status == TaskMergeService.StatusMergedVerifyPending)
|
|
{
|
|
merged = true;
|
|
message = "unit merged; verify deferred — the parent stays WaitingForReview until " +
|
|
"verify_merges (with this parent's id) passes";
|
|
}
|
|
else if (parent.Status == TaskStatus.Done)
|
|
{
|
|
merged = true;
|
|
}
|
|
else
|
|
{
|
|
var remaining = !string.IsNullOrWhiteSpace(workingDir)
|
|
? await _git.ListConflictedFilesAsync(workingDir, cancellationToken)
|
|
: new List<string>();
|
|
merged = false;
|
|
if (remaining.Count > 0)
|
|
{
|
|
conflicts = remaining;
|
|
repoPath = workingDir;
|
|
message = "conflicts remain — resolve and call continue_merge again";
|
|
}
|
|
else
|
|
{
|
|
message = "unit merge did not complete — the orchestrator aborted or was blocked; " +
|
|
"check the parent task and approve again to restart the merge";
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var r = await _merge.ContinueMergeAsync(taskId, cancellationToken, progress, skipVerify);
|
|
if (r.Status == TaskMergeService.StatusMerged)
|
|
{
|
|
merged = true;
|
|
}
|
|
else if (r.Status == TaskMergeService.StatusMergedVerifyPending)
|
|
{
|
|
merged = true;
|
|
message = "merged; verify deferred — the task stays WaitingForReview until " +
|
|
"verify_merges (with its id) passes";
|
|
}
|
|
else if (r.Status == TaskMergeService.StatusConflict)
|
|
{
|
|
merged = false;
|
|
conflicts = r.ConflictFiles;
|
|
repoPath = workingDir;
|
|
message = r.ErrorMessage;
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException(r.ErrorMessage ?? "continue failed");
|
|
}
|
|
}
|
|
|
|
var reloaded = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
return new MergeContinuationResultDto(merged, reloaded.Status.ToString(), conflicts, repoPath, message);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Run the list's verify command ONCE for a batch of merges made with skipVerify=true, then promote the " +
|
|
"listed tasks to Done. Call it a single time after the LAST merge of the batch instead of paying the " +
|
|
"verify per merge. All tasks must belong to the same list; each must be WaitingForReview with its merge " +
|
|
"already landed (mergeStatus merged_verify_pending). status verify_passed: check each entry's promoted " +
|
|
"flag (an ineligible task is skipped with a reason, the rest still promote). status verify_failed: the " +
|
|
"merges stay in place but NO task reaches Done; the command's output tail is in errorMessage — report it, " +
|
|
"don't fix it yourself. A list without a verify command promotes directly." + McpToolDocs.ProgressHint)]
|
|
public async Task<ListVerifyResult> VerifyMerges(
|
|
[Description("Every task merged with skipVerify=true in this batch (accepts #numbers or ids).")]
|
|
string[] taskIds,
|
|
CancellationToken cancellationToken = default,
|
|
IProgress<ProgressNotificationValue>? progress = null)
|
|
{
|
|
if (taskIds.Length == 0)
|
|
throw new InvalidOperationException("taskIds must not be empty.");
|
|
// Same rationale as review_task's first report: the verify itself reports periodically,
|
|
// but the wait on the per-repo merge gate before it would otherwise sit silent.
|
|
progress?.Report(new ProgressNotificationValue { Progress = 0, Message = "verify_merges started" });
|
|
|
|
string? listId = null;
|
|
var resolved = new List<string>(taskIds.Length);
|
|
foreach (var raw in taskIds)
|
|
{
|
|
var id = await TaskIdResolver.ResolveAsync(_tasks, raw, cancellationToken);
|
|
var task = await _tasks.GetByIdAsync(id, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {raw} not found.");
|
|
listId ??= task.ListId;
|
|
if (task.ListId != listId)
|
|
throw new InvalidOperationException(
|
|
"All tasks must belong to the same list — one verify command gates one repo.");
|
|
resolved.Add(id);
|
|
}
|
|
|
|
var result = await _merge.RunListVerifyAsync(listId!, resolved, cancellationToken, progress);
|
|
if (result.Status == TaskMergeService.StatusBlocked)
|
|
throw new InvalidOperationException(result.ErrorMessage ?? "verify blocked");
|
|
return result;
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Abort an in-progress conflicted merge, discarding the conflict markers and restoring a clean working tree. " +
|
|
"Pass the PARENT task id to abort a parent/children unit merge. The task keeps its pre-merge status " +
|
|
"(e.g. WaitingForReview). Throws if there is no in-progress merge for the task." + McpToolDocs.LeanTaskRef)]
|
|
public async Task<TaskRefDto> AbortMerge(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
|
|
if (_planningMerge.HasActiveMerge(taskId))
|
|
{
|
|
await _planningMerge.AbortAsync(taskId, cancellationToken);
|
|
}
|
|
else
|
|
{
|
|
var r = await _merge.AbortMergeAsync(taskId, cancellationToken);
|
|
if (r.Status == TaskMergeService.StatusBlocked)
|
|
throw new InvalidOperationException(r.ErrorMessage ?? "abort failed");
|
|
}
|
|
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
return ExternalMcpService.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)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
_ = 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.");
|
|
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
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', 'conflict' (conflictFiles lists where git " +
|
|
"would stop), or 'untracked_collision' (conflictFiles lists a path the branch adds that already exists, " +
|
|
"untracked, in the target working directory — merge-tree can't see the working tree at all, so this is the " +
|
|
"only way to catch it before a real merge either refuses or, if that path became tracked in the meantime, " +
|
|
"silently overwrites it); behind counts commits on targetBranch not yet on this branch, which flags a stale " +
|
|
"branch even when the preview is clean. runVerify=false (default) never builds. runVerify=true additionally " +
|
|
"builds/tests a clean preview in a scratch worktree (still without touching the real working tree), if the " +
|
|
"list has a verify command configured — verifyExitCode 0 means it built clean, non-zero or -1 " +
|
|
"(timeout/failed to start) means it doesn't, with the tail of its output in verifyOutputTail; " +
|
|
"verifyExitCode/verifyDurationMs/verifyOutputTail stay null without runVerify=true or without a configured " +
|
|
"verify command. isEmpty=true means the task's review range contributed nothing; check that flag rather " +
|
|
"than reading a small changedFileCount as empty. staleFiles lists files this branch touches that the " +
|
|
"target branch ALSO changed since this branch's fork point — a more honest staleness signal than `behind` " +
|
|
"alone, since a branch can be far behind yet touch nothing the target changed, or barely behind yet " +
|
|
"collide on the one file that matters (always empty for a worktree-less handler task, which has no fork " +
|
|
"point). Throws if the task has neither an active worktree nor a handler commit range, or the list's " +
|
|
"working directory is missing from disk." + McpToolDocs.ProgressHint)]
|
|
public async Task<MergePreviewToolDto> PreviewMerge(
|
|
string taskId,
|
|
[Description("Branch to preview against; defaults to the repo's current branch.")]
|
|
string? targetBranch = null,
|
|
[Description("true: also run the verify command (if configured), building in a scratch worktree. " +
|
|
"false (default): no build.")]
|
|
bool runVerify = false,
|
|
CancellationToken cancellationToken = default,
|
|
IProgress<ProgressNotificationValue>? progress = null)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
var (preview, behind, _, isEmpty, staleFiles, _, _, _, _, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
|
|
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty,
|
|
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Plan a batch merge: preview_merge for several tasks against the same targetBranch, plus a file-overlap " +
|
|
"check between them. Per entry you get preview_merge's fields, or error instead when that task could not " +
|
|
"be previewed (it is then left out of the overlap/subset/symbol computation). overlaps names, for each " +
|
|
"file touched by MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields " +
|
|
"no overlaps; for a .json file this is narrowed to nested-key-path level (parsing both the base commit " +
|
|
"and the task branch's head), so two tasks adding different keys to the same locale file are not flagged " +
|
|
"and only a genuinely shared key is — unparsable JSON on either side falls back to the plain file-level " +
|
|
"check. subsets flags a stronger relation: TaskId's changed files are a PROPER subset of " +
|
|
"SupersetTaskId's — the strongest hint you get post-hoc that TaskId may be redundant with SupersetTaskId, " +
|
|
"worth checking before merging both. removedSymbols (per task entry) lists C# methods/properties/fields/" +
|
|
"types/events that task's diff deletes (a line-pattern scan, not a parser — false positives possible, " +
|
|
"skips .axaml/.json); symbolWarnings then cross-references those names against every OTHER task's added " +
|
|
"lines in the same set, catching a same-symbol collision even when the two tasks touch entirely different " +
|
|
"files. IMPORTANT: none of overlaps/subsets/symbolWarnings is a safety guarantee, and as with preview_merge " +
|
|
"a clean result does not mean the merge builds. runVerify=false (default) never builds — set it true to " +
|
|
"also run each task's list's verify command in a scratch worktree per entry (same fields as preview_merge); " +
|
|
"this can take a long time across many tasks, since builds run one at a time." + McpToolDocs.ProgressHint)]
|
|
public async Task<MergePreviewSetResultDto> PreviewMergeSet(
|
|
IReadOnlyList<string> taskIds,
|
|
[Description("Branch to preview every task against; defaults to the repo's current branch.")]
|
|
string? targetBranch = null,
|
|
[Description("true: also run the verify command (if configured) for each task, one build at a time. " +
|
|
"false (default): no builds, however many tasks are given.")]
|
|
bool runVerify = false,
|
|
CancellationToken cancellationToken = default,
|
|
IProgress<ProgressNotificationValue>? progress = null)
|
|
{
|
|
if (taskIds is null || taskIds.Count == 0)
|
|
throw new InvalidOperationException("taskIds must contain at least one task id.");
|
|
|
|
taskIds = await TaskIdResolver.ResolveManyAsync(_tasks, taskIds, cancellationToken);
|
|
|
|
var entries = new List<MergePreviewSetEntryDto>();
|
|
var filesByTask = new Dictionary<string, IReadOnlyList<string>>();
|
|
var numbersByTask = new Dictionary<string, int>();
|
|
var repoPathByTask = new Dictionary<string, string>();
|
|
var baseRefByTask = new Dictionary<string, string>();
|
|
var headRefByTask = new Dictionary<string, string?>();
|
|
var diffLinesByTask = new Dictionary<string, List<DiffLine>>();
|
|
var removedSymbolsByTask = new Dictionary<string, IReadOnlyList<RemovedSymbolDto>>();
|
|
|
|
for (var i = 0; i < taskIds.Count; i++)
|
|
{
|
|
var taskId = taskIds[i];
|
|
try
|
|
{
|
|
var (preview, behind, changedFiles, isEmpty, staleFiles, number, expectsNoDiff, repoPath, baseRef, headRef) =
|
|
await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
|
|
filesByTask[taskId] = changedFiles;
|
|
numbersByTask[taskId] = number;
|
|
repoPathByTask[taskId] = repoPath;
|
|
baseRefByTask[taskId] = baseRef;
|
|
headRefByTask[taskId] = headRef;
|
|
|
|
var diff = headRef is null
|
|
? await _git.GetBranchDiffAsync(repoPath, baseRef, ct: cancellationToken)
|
|
: await _git.GetCommitRangeDiffAsync(repoPath, baseRef, headRef, ct: cancellationToken);
|
|
var diffLines = ParseDiffLines(diff);
|
|
diffLinesByTask[taskId] = diffLines;
|
|
var removedSymbols = ExtractRemovedSymbols(diffLines);
|
|
removedSymbolsByTask[taskId] = removedSymbols;
|
|
|
|
entries.Add(new MergePreviewSetEntryDto(
|
|
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty,
|
|
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number,
|
|
expectsNoDiff, removedSymbols));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken);
|
|
entries.Add(new MergePreviewSetEntryDto(
|
|
taskId, TaskMergeService.PreviewUnavailable, Array.Empty<string>(), 0, 0, ex.Message,
|
|
Number: task?.Number, ExpectsNoDiff: task?.ExpectsNoDiff ?? false));
|
|
}
|
|
|
|
ProgressReporter.ReportItem(progress, "Previewing merges", i + 1, taskIds.Count);
|
|
}
|
|
|
|
var overlaps = await ComputeOverlapsAsync(filesByTask, numbersByTask, repoPathByTask, baseRefByTask, headRefByTask, cancellationToken);
|
|
var subsets = FindSubsetRelations(filesByTask, numbersByTask);
|
|
var symbolWarnings = FindSymbolWarnings(removedSymbolsByTask, numbersByTask, diffLinesByTask);
|
|
|
|
return new MergePreviewSetResultDto(entries, overlaps, subsets, symbolWarnings);
|
|
}
|
|
|
|
// Cross-references each task's removed symbols (Teil 1) against every OTHER task's added
|
|
// lines in the same set -- a same-named symbol collision that overlaps/subsets (both purely
|
|
// file-based) can never catch, since the two tasks may not share a single file.
|
|
private static List<SymbolReferenceWarningDto> FindSymbolWarnings(
|
|
Dictionary<string, IReadOnlyList<RemovedSymbolDto>> removedSymbolsByTask,
|
|
Dictionary<string, int> numbersByTask,
|
|
Dictionary<string, List<DiffLine>> diffLinesByTask)
|
|
{
|
|
var warnings = new List<SymbolReferenceWarningDto>();
|
|
foreach (var (taskId, removed) in removedSymbolsByTask)
|
|
{
|
|
foreach (var symbol in removed)
|
|
{
|
|
foreach (var (otherTaskId, diffLines) in diffLinesByTask)
|
|
{
|
|
if (otherTaskId == taskId) continue;
|
|
var hit = diffLines.FirstOrDefault(l => l.Sign == '+' && ContainsWord(l.Text, symbol.Name));
|
|
if (hit.File is not null)
|
|
warnings.Add(new SymbolReferenceWarningDto(
|
|
symbol.Name, symbol.File, taskId, numbersByTask[taskId],
|
|
otherTaskId, numbersByTask[otherTaskId], hit.File));
|
|
}
|
|
}
|
|
}
|
|
|
|
return warnings
|
|
.OrderBy(w => w.Symbol, StringComparer.Ordinal)
|
|
.ThenBy(w => w.RemovedByTaskId, StringComparer.Ordinal)
|
|
.ThenBy(w => w.ReferencedByTaskId, StringComparer.Ordinal)
|
|
.ToList();
|
|
}
|
|
|
|
// File-level overlap for everything except .json; for .json, narrows to nested-key-path
|
|
// collisions between the tasks that touch it (Teil 2) -- unless either side fails to parse,
|
|
// in which case it falls back to the plain file-level check for that file.
|
|
private async Task<List<FileOverlapDto>> ComputeOverlapsAsync(
|
|
Dictionary<string, IReadOnlyList<string>> filesByTask, Dictionary<string, int> numbersByTask,
|
|
Dictionary<string, string> repoPathByTask, Dictionary<string, string> baseRefByTask,
|
|
Dictionary<string, string?> headRefByTask, CancellationToken ct)
|
|
{
|
|
var overlaps = new List<FileOverlapDto>();
|
|
|
|
var byFile = filesByTask
|
|
.SelectMany(kv => kv.Value.Select(f => (File: f, TaskId: kv.Key)))
|
|
.GroupBy(x => x.File, StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var group in byFile)
|
|
{
|
|
var taskIds = group.Select(x => x.TaskId).Distinct().ToList();
|
|
if (taskIds.Count <= 1) continue;
|
|
|
|
if (!group.Key.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
overlaps.Add(MakeOverlap(group.Key, taskIds, numbersByTask));
|
|
continue;
|
|
}
|
|
|
|
var colliding = await FindJsonKeyCollisionTasksAsync(
|
|
group.Key, taskIds, repoPathByTask, baseRefByTask, headRefByTask, ct);
|
|
if (colliding is null)
|
|
overlaps.Add(MakeOverlap(group.Key, taskIds, numbersByTask)); // unparsable -- fall back
|
|
else if (colliding.Count > 1)
|
|
overlaps.Add(MakeOverlap(group.Key, colliding, numbersByTask));
|
|
}
|
|
|
|
return overlaps.OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase).ToList();
|
|
}
|
|
|
|
private static FileOverlapDto MakeOverlap(string file, IReadOnlyList<string> taskIds, Dictionary<string, int> numbersByTask)
|
|
=> new(file, taskIds, taskIds.Select(id => numbersByTask[id]).ToList());
|
|
|
|
// Null = give up and fall back to file-level (JSON unparsable on some side for some task).
|
|
// Otherwise: the subset of taskIds whose changed/added/removed key paths in `file` intersect
|
|
// with at least one other task's -- two tasks touching disjoint keys in the same file never appear.
|
|
private async Task<List<string>?> FindJsonKeyCollisionTasksAsync(
|
|
string file, List<string> taskIds,
|
|
Dictionary<string, string> repoPathByTask, Dictionary<string, string> baseRefByTask,
|
|
Dictionary<string, string?> headRefByTask, CancellationToken ct)
|
|
{
|
|
var keysByTask = new Dictionary<string, HashSet<string>>();
|
|
foreach (var taskId in taskIds)
|
|
{
|
|
var baseContent = await ReadFileContentAsync(repoPathByTask[taskId], baseRefByTask[taskId], file, ct);
|
|
var headContent = await ReadFileContentAsync(repoPathByTask[taskId], headRefByTask[taskId], file, ct);
|
|
if (!TryDiffJsonKeyPaths(baseContent, headContent, out var keys))
|
|
return null;
|
|
keysByTask[taskId] = keys;
|
|
}
|
|
|
|
var colliding = new HashSet<string>();
|
|
foreach (var a in taskIds)
|
|
foreach (var b in taskIds)
|
|
{
|
|
if (a == b) continue;
|
|
if (keysByTask[a].Overlaps(keysByTask[b]))
|
|
{
|
|
colliding.Add(a);
|
|
colliding.Add(b);
|
|
}
|
|
}
|
|
|
|
return taskIds.Where(colliding.Contains).ToList();
|
|
}
|
|
|
|
// gitRef null = read the live working tree off disk (mirrors GetBranchDiffAsync's own base-vs-
|
|
// working-tree comparison); non-null = `git show ref:path` (handler task's fixed commit range).
|
|
private async Task<string?> ReadFileContentAsync(string repoPath, string? gitRef, string relativePath, CancellationToken ct)
|
|
{
|
|
if (gitRef is null)
|
|
{
|
|
var full = Path.Combine(repoPath, relativePath);
|
|
return File.Exists(full) ? await File.ReadAllTextAsync(full, ct) : null;
|
|
}
|
|
return await _git.ShowFileAsync(repoPath, gitRef, relativePath, ct);
|
|
}
|
|
|
|
private static bool TryDiffJsonKeyPaths(string? baseContent, string? headContent, out HashSet<string> changedPaths)
|
|
{
|
|
changedPaths = new HashSet<string>(StringComparer.Ordinal);
|
|
if (!TryFlattenJson(baseContent, out var baseMap) || !TryFlattenJson(headContent, out var headMap))
|
|
return false;
|
|
|
|
foreach (var (path, value) in headMap)
|
|
if (!baseMap.TryGetValue(path, out var baseValue) || baseValue != value)
|
|
changedPaths.Add(path);
|
|
foreach (var path in baseMap.Keys)
|
|
if (!headMap.ContainsKey(path))
|
|
changedPaths.Add(path);
|
|
|
|
return true;
|
|
}
|
|
|
|
// Missing content (new/deleted file, either side) is an empty object, not a parse failure --
|
|
// only actually-malformed JSON triggers the file-level fallback.
|
|
private static bool TryFlattenJson(string? content, out Dictionary<string, string> flattened)
|
|
{
|
|
flattened = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
if (string.IsNullOrWhiteSpace(content))
|
|
return true;
|
|
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(content);
|
|
FlattenJson(doc.RootElement, "", flattened);
|
|
return true;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static void FlattenJson(JsonElement element, string prefix, Dictionary<string, string> into)
|
|
{
|
|
if (element.ValueKind == JsonValueKind.Object)
|
|
{
|
|
foreach (var prop in element.EnumerateObject())
|
|
FlattenJson(prop.Value, prefix.Length == 0 ? prop.Name : $"{prefix}.{prop.Name}", into);
|
|
}
|
|
else
|
|
{
|
|
into[prefix] = element.GetRawText();
|
|
}
|
|
}
|
|
|
|
// One line of a unified diff, tagged with the file it belongs to ('-'/'+' only; context lines
|
|
// are dropped). File is set from the `diff --git a/<path> b/<path>` header, which (unlike
|
|
// `+++ b/...`) is present and names the real path even for a deleted file (`+++ /dev/null`).
|
|
private readonly record struct DiffLine(string? File, char Sign, string Text);
|
|
|
|
private static List<DiffLine> ParseDiffLines(string diff)
|
|
{
|
|
var result = new List<DiffLine>();
|
|
string? currentFile = null;
|
|
foreach (var raw in diff.Split('\n'))
|
|
{
|
|
var line = raw.TrimEnd('\r');
|
|
if (line.StartsWith("diff --git a/", StringComparison.Ordinal))
|
|
{
|
|
var rest = line["diff --git a/".Length..];
|
|
var sep = rest.IndexOf(" b/", StringComparison.Ordinal);
|
|
currentFile = sep >= 0 ? rest[..sep] : rest;
|
|
continue;
|
|
}
|
|
if (currentFile is null) continue;
|
|
if (line.StartsWith("---", StringComparison.Ordinal) || line.StartsWith("+++", StringComparison.Ordinal) ||
|
|
line.StartsWith("@@", StringComparison.Ordinal) || line.StartsWith("index ", StringComparison.Ordinal))
|
|
continue;
|
|
|
|
if (line.Length > 0 && line[0] == '-')
|
|
result.Add(new DiffLine(currentFile, '-', line[1..]));
|
|
else if (line.Length > 0 && line[0] == '+')
|
|
result.Add(new DiffLine(currentFile, '+', line[1..]));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Deliberately a line-pattern scan, not a Roslyn parse: requires an explicit access modifier
|
|
// (this codebase's style never omits one on a real member/type), which is also what keeps a
|
|
// removed local variable or statement inside a method body from matching.
|
|
private static readonly Regex RemovedDeclarationRegex = new(
|
|
@"^\s*(?:\[[^\]]*\]\s*)*(?:public|private|protected|internal)(?:\s+(?:static|readonly|sealed|abstract|virtual|override|async|partial|const|new|extern|unsafe|required))*\s+" +
|
|
@"(?:(?:class|struct|interface|enum|record)\s+(?<name>[A-Za-z_]\w*)" +
|
|
@"|event\s+[\w<>\[\],.?]+\s+(?<name>[A-Za-z_]\w*)" +
|
|
@"|[\w<>\[\],.?]+\??\s+(?<name>[A-Za-z_]\w*)\s*(?:\(|\{|=>|;|=))",
|
|
RegexOptions.Compiled);
|
|
|
|
private static List<RemovedSymbolDto> ExtractRemovedSymbols(List<DiffLine> diffLines)
|
|
{
|
|
var seen = new HashSet<(string Name, string File)>();
|
|
var result = new List<RemovedSymbolDto>();
|
|
foreach (var line in diffLines)
|
|
{
|
|
if (line.Sign != '-' || line.File is null) continue;
|
|
if (line.File.EndsWith(".axaml", StringComparison.OrdinalIgnoreCase) ||
|
|
line.File.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
|
|
var match = RemovedDeclarationRegex.Match(line.Text);
|
|
if (!match.Success) continue;
|
|
|
|
if (seen.Add((match.Groups["name"].Value, line.File)))
|
|
result.Add(new RemovedSymbolDto(match.Groups["name"].Value, line.File));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static bool ContainsWord(string text, string word)
|
|
=> Regex.IsMatch(text, $@"\b{Regex.Escape(word)}\b");
|
|
|
|
// A task's changed-file set is flagged only against another task's STRICTLY LARGER set that
|
|
// contains every one of its files -- an empty set (isEmpty task) never qualifies, since that
|
|
// case is already surfaced via IsEmpty and would otherwise match every other task trivially.
|
|
private static List<SubsetRelationDto> FindSubsetRelations(
|
|
Dictionary<string, IReadOnlyList<string>> filesByTask, Dictionary<string, int> numbersByTask)
|
|
{
|
|
var fileSets = filesByTask.ToDictionary(
|
|
kv => kv.Key,
|
|
kv => new HashSet<string>(kv.Value, StringComparer.OrdinalIgnoreCase));
|
|
|
|
var subsets = new List<SubsetRelationDto>();
|
|
foreach (var (taskId, fileSet) in fileSets)
|
|
{
|
|
if (fileSet.Count == 0) continue;
|
|
foreach (var (otherTaskId, otherFileSet) in fileSets)
|
|
{
|
|
if (otherTaskId == taskId || otherFileSet.Count <= fileSet.Count) continue;
|
|
if (fileSet.IsSubsetOf(otherFileSet))
|
|
subsets.Add(new SubsetRelationDto(taskId, numbersByTask[taskId], otherTaskId, numbersByTask[otherTaskId]));
|
|
}
|
|
}
|
|
|
|
return subsets
|
|
.OrderBy(s => s.TaskId, StringComparer.OrdinalIgnoreCase)
|
|
.ThenBy(s => s.SupersetTaskId, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
}
|
|
|
|
// Shared core for PreviewMerge/PreviewMergeSet: throws a clear InvalidOperationException instead of
|
|
// TaskMergeService.PreviewAsync's silent "unavailable" status, and adds `behind` + the task's own
|
|
// changed-file list (via diff-stat, not the merge-tree preview) for overlap detection, plus
|
|
// `isEmpty`. A worktree-less list-handler host task has no branch to merge-tree-preview at all
|
|
// (its commits already sit on the list's working dir) — falls back to the fixed
|
|
// HandlerBaseCommit..HandlerHeadCommit range, reporting a synthetic "clean" preview of that
|
|
// range's own diff-stat instead of throwing "has no worktree".
|
|
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty, IReadOnlyList<string> StaleFiles, int Number, bool ExpectsNoDiff, string RepoPath, string BaseRef, string? HeadRef)> PreviewMergeCoreAsync(
|
|
string taskId, string? targetBranch, bool runVerify, CancellationToken ct,
|
|
IProgress<ProgressNotificationValue>? progress = null)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
|
|
?? throw new InvalidOperationException("List not found.");
|
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
|
|
|
|
if (wt is not null)
|
|
{
|
|
if (wt.State != WorktreeState.Active)
|
|
throw new InvalidOperationException(
|
|
$"Worktree state must be Active to preview a merge (current: {wt.State}).");
|
|
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
|
|
throw new InvalidOperationException("The list's working directory no longer exists.");
|
|
|
|
var preview = await _merge.PreviewAsync(taskId, targetBranch ?? "", runVerify, ct, progress);
|
|
if (preview.Status == TaskMergeService.PreviewUnavailable)
|
|
throw new InvalidOperationException(
|
|
"Merge preview unavailable for this task (worktree inactive or repo is not a git repository).");
|
|
|
|
var target = string.IsNullOrWhiteSpace(targetBranch)
|
|
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
|
|
: targetBranch;
|
|
var behind = await WorktreeMcpTools.GitRevListCountAsync(list.WorkingDir, $"{wt.BranchName}..{target}", ct);
|
|
|
|
var changedFiles = Directory.Exists(wt.Path)
|
|
? ExternalMcpService.ParseDiffStatFileNames(await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", ct: ct))
|
|
: Array.Empty<string>();
|
|
|
|
// What the target itself picked up since this branch's fork point, so `behind`
|
|
// (a commit count) doesn't have to stand in for "does this collide" -- a branch can
|
|
// be far behind but touch nothing the target changed, or close behind and collide on
|
|
// the one file that matters.
|
|
var targetChangedSinceFork = await _git.GetChangedFileNamesAsync(list.WorkingDir, wt.BaseCommit, target, ct);
|
|
var staleFiles = changedFiles
|
|
.Intersect(targetChangedSinceFork, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number, task.ExpectsNoDiff,
|
|
wt.Path, wt.BaseCommit, null);
|
|
}
|
|
|
|
if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
|
|
throw new InvalidOperationException("The list's working directory no longer exists.");
|
|
|
|
var isEmpty = string.Equals(handlerBase, handlerHead, StringComparison.Ordinal);
|
|
var changedFiles = isEmpty
|
|
? Array.Empty<string>()
|
|
: ExternalMcpService.ParseDiffStatFileNames(await _git.DiffStatAsync(list.WorkingDir, handlerBase, handlerHead, ct: ct));
|
|
|
|
var preview = new MergePreviewResult(TaskMergeService.PreviewClean, Array.Empty<string>(), changedFiles.Count);
|
|
// No fork point to diff against: a handler task commits straight onto the list's
|
|
// working dir instead of a branch, so there is nothing else that could have "changed
|
|
// in the target since the fork".
|
|
return (preview, 0, changedFiles, isEmpty, Array.Empty<string>(), task.Number, task.ExpectsNoDiff,
|
|
list.WorkingDir, handlerBase, handlerHead);
|
|
}
|
|
|
|
throw new InvalidOperationException($"Task {taskId} has no worktree.");
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Undo a merged task by reverting its merge commit — `git revert -m 1`, always a new commit and never a " +
|
|
"reset/rewrite, since the target working directory is shared with other sessions. Requires the task to be " +
|
|
"Done with a Merged worktree that has a recorded merge commit (check get_task_worktree's mergeCommit " +
|
|
"first). On success the task returns to WaitingForReview so it can be reconsidered. On conflict the revert " +
|
|
"is aborted immediately and conflicts lists the files. Throws if there is no recorded merge commit, the " +
|
|
"repo is mid-merge/mid-revert, or the target working tree has uncommitted changes from another session.")]
|
|
public async Task<RevertMergeResultDto> RevertMerge(
|
|
string taskId,
|
|
[Description("Branch carrying the merge commit; defaults to main.")]
|
|
string targetBranch = "main",
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
var result = await _merge.RevertMergeAsync(taskId, targetBranch, cancellationToken);
|
|
|
|
if (result.Status == TaskMergeService.StatusReverted)
|
|
{
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
return new RevertMergeResultDto(true, result.RevertCommit, Array.Empty<string>(), null);
|
|
}
|
|
|
|
if (result.Status == TaskMergeService.StatusConflictAborted)
|
|
return new RevertMergeResultDto(false, null, result.ConflictFiles, result.ErrorMessage);
|
|
|
|
throw new InvalidOperationException(result.ErrorMessage ?? $"Revert blocked: {result.Status}");
|
|
}
|
|
}
|