`src/ClaudeDo.Worker/External/ExternalMcpService.cs` ist **1897 Zeilen** (nachgezählt 2026-09-02; die Datei ist seit dem Anlegen dieses Tasks von 1696 weiter gewachsen): ~30 DTO-Records oben plus ~25 Tool-Methoden — während **13** Geschwisterdateien `*McpTools.cs` im selben Ordner das Topic-Split-Muster schon tragen (`ListMcpTools`, `ConfigMcpTools`, `BatchMcpTools`, `NotesMcpTools`, `QueueStateMc ClaudeDo-Task: 1862e160-ffde-4a50-aa03-bb7251f56088
668 lines
37 KiB
C#
668 lines
37 KiB
C#
using System.ComponentModel;
|
|
using System.Diagnostics;
|
|
using System.Text;
|
|
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 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);
|
|
|
|
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);
|
|
|
|
public sealed record MergePreviewSetResultDto(
|
|
IReadOnlyList<MergePreviewSetEntryDto> Tasks, IReadOnlyList<FileOverlapDto> Overlaps,
|
|
IReadOnlyList<SubsetRelationDto> Subsets);
|
|
|
|
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. If the list has a verify command configured, a clean preview is " +
|
|
"additionally built/tested in a scratch worktree (still without touching the real working tree) — " +
|
|
"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 stays null when no verify command is configured. " +
|
|
"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,
|
|
CancellationToken cancellationToken = default,
|
|
IProgress<ProgressNotificationValue>? progress = null)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
var (preview, behind, _, isEmpty, staleFiles, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, 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 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. 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. IMPORTANT: neither overlap nor subset is a safety guarantee — two " +
|
|
"tasks touching entirely different files (one deleting a symbol, another still referencing it) can still " +
|
|
"collide unflagged, 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>();
|
|
|
|
for (var i = 0; i < taskIds.Count; i++)
|
|
{
|
|
var taskId = taskIds[i];
|
|
try
|
|
{
|
|
var (preview, behind, changedFiles, isEmpty, staleFiles, number) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
|
|
entries.Add(new MergePreviewSetEntryDto(
|
|
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty,
|
|
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number));
|
|
filesByTask[taskId] = changedFiles;
|
|
numbersByTask[taskId] = number;
|
|
}
|
|
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));
|
|
}
|
|
|
|
ProgressReporter.ReportItem(progress, "Previewing merges", i + 1, taskIds.Count);
|
|
}
|
|
|
|
var overlaps = filesByTask
|
|
.SelectMany(kv => kv.Value.Select(f => (File: f, TaskId: kv.Key)))
|
|
.GroupBy(x => x.File, StringComparer.OrdinalIgnoreCase)
|
|
.Where(g => g.Select(x => x.TaskId).Distinct().Count() > 1)
|
|
.Select(g =>
|
|
{
|
|
var ids = g.Select(x => x.TaskId).Distinct().ToList();
|
|
return new FileOverlapDto(g.Key, ids, ids.Select(id => numbersByTask[id]).ToList());
|
|
})
|
|
.OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
var subsets = FindSubsetRelations(filesByTask, numbersByTask);
|
|
|
|
return new MergePreviewSetResultDto(entries, overlaps, subsets);
|
|
}
|
|
|
|
// 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)> 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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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}");
|
|
}
|
|
}
|