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 Conflicts, bool ConflictsInTree = false, string? RepoPath = null); public sealed record MergeContinuationResultDto( bool Merged, string TaskStatus, IReadOnlyList 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 Hunks); public sealed record GetMergeConflictsResultDto( IReadOnlyList 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 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? StaleFiles = null); public sealed record MergePreviewSetEntryDto( string TaskId, string Status, IReadOnlyList ConflictFiles, int ChangedFileCount, int Behind, string? Error, bool IsEmpty = false, int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null, IReadOnlyList? StaleFiles = null, int? Number = null); public sealed record FileOverlapDto(string File, IReadOnlyList TaskIds, IReadOnlyList 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 Tasks, IReadOnlyList Overlaps, IReadOnlyList Subsets); public sealed record RevertMergeResultDto( bool Reverted, string? RevertCommit, IReadOnlyList 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 _dbFactory; private readonly TaskMergeService _merge; private readonly PlanningMergeOrchestrator _planningMerge; public MergeMcpTools( TaskRepository tasks, ListRepository lists, HubBroadcaster broadcaster, GitService git, IDbContextFactory 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 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? 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 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? 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 conflicts = Array.Empty(); 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(); 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 VerifyMerges( [Description("Every task merged with skipVerify=true in this batch (accepts #numbers or ids).")] string[] taskIds, CancellationToken cancellationToken = default, IProgress? 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(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 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 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 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(); 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 PreviewMerge( string taskId, [Description("Branch to preview against; defaults to the repo's current branch.")] string? targetBranch = null, CancellationToken cancellationToken = default, IProgress? 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 PreviewMergeSet( IReadOnlyList 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? 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(); var filesByTask = new Dictionary>(); var numbersByTask = new Dictionary(); 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(), 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 FindSubsetRelations( Dictionary> filesByTask, Dictionary numbersByTask) { var fileSets = filesByTask.ToDictionary( kv => kv.Key, kv => new HashSet(kv.Value, StringComparer.OrdinalIgnoreCase)); var subsets = new List(); 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 ChangedFiles, bool IsEmpty, IReadOnlyList StaleFiles, int Number)> PreviewMergeCoreAsync( string taskId, string? targetBranch, bool runVerify, CancellationToken ct, IProgress? 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(); // 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() : ExternalMcpService.ParseDiffStatFileNames(await _git.DiffStatAsync(list.WorkingDir, handlerBase, handlerHead, ct: ct)); var preview = new MergePreviewResult(TaskMergeService.PreviewClean, Array.Empty(), 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(), 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 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(), null); } if (result.Status == TaskMergeService.StatusConflictAborted) return new RevertMergeResultDto(false, null, result.ConflictFiles, result.ErrorMessage); throw new InvalidOperationException(result.ErrorMessage ?? $"Revert blocked: {result.Status}"); } }