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 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, // 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? RemovedSymbols = null); public sealed record RemovedSymbolDto(string Name, string File); 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); // 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 Tasks, IReadOnlyList Overlaps, IReadOnlyList Subsets, IReadOnlyList SymbolWarnings); 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. 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 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? 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 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(); var repoPathByTask = new Dictionary(); var baseRefByTask = new Dictionary(); var headRefByTask = new Dictionary(); var diffLinesByTask = new Dictionary>(); var removedSymbolsByTask = new Dictionary>(); 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(), 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 FindSymbolWarnings( Dictionary> removedSymbolsByTask, Dictionary numbersByTask, Dictionary> diffLinesByTask) { var warnings = new List(); 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> ComputeOverlapsAsync( Dictionary> filesByTask, Dictionary numbersByTask, Dictionary repoPathByTask, Dictionary baseRefByTask, Dictionary headRefByTask, CancellationToken ct) { var overlaps = new List(); 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 taskIds, Dictionary 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?> FindJsonKeyCollisionTasksAsync( string file, List taskIds, Dictionary repoPathByTask, Dictionary baseRefByTask, Dictionary headRefByTask, CancellationToken ct) { var keysByTask = new Dictionary>(); 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(); 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 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 changedPaths) { changedPaths = new HashSet(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 flattened) { flattened = new Dictionary(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 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/ b/` 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 ParseDiffLines(string diff) { var result = new List(); 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+(?[A-Za-z_]\w*)" + @"|event\s+[\w<>\[\],.?]+\s+(?[A-Za-z_]\w*)" + @"|[\w<>\[\],.?]+\??\s+(?[A-Za-z_]\w*)\s*(?:\(|\{|=>|;|=))", RegexOptions.Compiled); private static List ExtractRemovedSymbols(List diffLines) { var seen = new HashSet<(string Name, string File)>(); var result = new List(); 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 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, bool ExpectsNoDiff, string RepoPath, string BaseRef, string? HeadRef)> 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, 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() : 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, 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 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}"); } }