diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index f197a8a9..5b09fdc6 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -102,78 +102,6 @@ public sealed record PossibleDuplicateDto(string TaskId, int Number, string Titl public sealed record AddTaskResult(TaskRefDto Task, IReadOnlyList PossibleDuplicates); -public sealed record WorktreeInfoDto( - string Path, string Branch, string HeadCommit, string BaseCommit, - int Ahead, int Behind, bool IsDirty, string? MergeCommit = null); - -public sealed record TaskDiffDto( - string Content, IReadOnlyList Files, bool Truncated, int TotalBytes); - -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 WorktreeListItemDto( - string? TaskId, int? Number, string Path, string Branch, - string HeadCommit, bool IsDirty, bool MergedIntoMain); - -public sealed record CleanupWorktreeResult( - bool Removed, string WorktreePath, bool BranchDeleted, int? Number = null); - -public sealed record RevertMergeResultDto( - bool Reverted, string? RevertCommit, IReadOnlyList Conflicts, string? Message); - public sealed record DailyPrepCandidateDto( string Id, int Number, string ListId, string ListName, string Title, string? Description, bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt); @@ -199,7 +127,6 @@ public sealed class ExternalMcpService private readonly ITaskStateService _state; private readonly GitService _git; private readonly IDbContextFactory _dbFactory; - private readonly WorktreeMaintenanceService _maintenance; private readonly TaskMergeService _merge; private readonly PlanningMergeOrchestrator _planningMerge; private readonly BaseDirtyChecker _baseDirtyChecker; @@ -212,7 +139,6 @@ public sealed class ExternalMcpService ITaskStateService state, GitService git, IDbContextFactory dbFactory, - WorktreeMaintenanceService maintenance, TaskMergeService merge, PlanningMergeOrchestrator planningMerge, BaseDirtyChecker baseDirtyChecker) @@ -224,7 +150,6 @@ public sealed class ExternalMcpService _state = state; _git = git; _dbFactory = dbFactory; - _maintenance = maintenance; _merge = merge; _planningMerge = planningMerge; _baseDirtyChecker = baseDirtyChecker; @@ -912,732 +837,6 @@ public sealed class ExternalMcpService return new DeleteTaskResult(true, taskId, task.Number); } - // ── Worktree / git tools ────────────────────────────────────────────────── - - [McpServerTool, Description( - "Get a task's git worktree state — path, branch, base/head commit, ahead/behind counts, isDirty, and the " + - "mergeCommit its branch produced once merged. behind is 0 when the 'main' ref is unreachable, so do not " + - "read 0 as \"up to date\" without checking. A null mergeCommit means revert_merge cannot act on this task. " + - "Throws if the task or its worktree does not exist.")] - public async Task GetTaskWorktree(string taskId, CancellationToken cancellationToken) - { - taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); - var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken); - - var headCommit = !string.IsNullOrWhiteSpace(wt.HeadCommit) - ? wt.HeadCommit - : await TryRunGitAsync(wt.Path, ["rev-parse", "HEAD"], cancellationToken) ?? wt.BaseCommit; - - var isDirty = Directory.Exists(wt.Path) && await _git.HasChangesAsync(wt.Path, cancellationToken); - var ahead = await GitRevListCountAsync(wt.Path, $"{wt.BaseCommit}..HEAD", cancellationToken); - var behind = await GitRevListCountAsync(wt.Path, "HEAD..main", cancellationToken); - - return new WorktreeInfoDto(wt.Path, wt.BranchName, headCommit!, wt.BaseCommit, ahead, behind, isDirty, wt.MergeCommit); - } - - [McpServerTool, Description( - "Read what a task actually changed — the diff of its worktree against its base commit (for a worktree-less " + - "list-handler host task, the fixed HandlerBaseCommit..HandlerHeadCommit range over the list's working dir " + - "instead). files lists the changed paths in either mode; truncated=true means the diff was capped and " + - "totalBytes holds its real size. Throws if the task has no worktree/review range, or the relevant " + - "directory is missing from disk.")] - public async Task GetTaskDiff( - string taskId, - [Description("false (default): the full unified diff, capped at 200 KB. true: a --stat summary with " + - "per-file insertion/deletion counts — start here when the diff may be large.")] - bool stat = false, - [Description("Restrict the diff to these paths (relative to the repo root), e.g. after a --stat pass or a " + - "conflict report already narrowed down which files matter. Omit/empty for every changed file " + - "— the default and the only prior behavior. Works in both stat and full-diff mode.")] - IReadOnlyList? paths = null, - CancellationToken cancellationToken = default, - IProgress? progress = null) - { - taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); - var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken); - - const int maxBytes = 200 * 1024; - - if (stat) - { - var diffStat = await ProgressReporter.RunAsync( - _git.DiffStatAsync(repoPath, baseCommit, headCommit ?? "HEAD", paths, cancellationToken), - ProgressReportInterval, progress, "computing diff stat"); - return new TaskDiffDto(diffStat, ParseDiffStatFileNames(diffStat), false, diffStat.Length); - } - - var diff = headCommit is null - ? await ProgressReporter.RunAsync( - _git.GetBranchDiffAsync(repoPath, baseCommit, paths, cancellationToken), ProgressReportInterval, progress, "computing diff") - : await ProgressReporter.RunAsync( - _git.GetCommitRangeDiffAsync(repoPath, baseCommit, headCommit, paths, cancellationToken), ProgressReportInterval, progress, "computing diff"); - var files = ParseDiffFileNames(diff); - - if (diff.Length <= maxBytes) - return new TaskDiffDto(diff, files, false, diff.Length); - - return new TaskDiffDto(diff[..maxBytes], files, true, diff.Length); - } - - // Resolves where a task's diff lives: a live worktree (repo path = worktree path, diffed - // against HEAD) or, for a worktree-less list-handler host task, the fixed - // HandlerBaseCommit..HandlerHeadCommit range over the list's working dir (headCommit - // non-null signals "fixed range" to the caller instead of "diff against live HEAD"). - private async Task<(string RepoPath, string BaseCommit, string? HeadCommit)> LoadDiffRangeAsync( - string taskId, CancellationToken ct) - { - using var ctx = _dbFactory.CreateDbContext(); - var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct) - ?? throw new InvalidOperationException($"Task {taskId} not found."); - var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct); - - if (wt is not null) - { - if (!Directory.Exists(wt.Path)) - throw new InvalidOperationException($"Worktree directory does not exist on disk: {wt.Path}"); - return (wt.Path, wt.BaseCommit, null); - } - - if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead) - { - var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct) - ?? throw new InvalidOperationException("List not found."); - if (string.IsNullOrEmpty(list.WorkingDir) || !Directory.Exists(list.WorkingDir)) - throw new InvalidOperationException("The list's working directory no longer exists."); - return (list.WorkingDir, handlerBase, handlerHead); - } - - throw new InvalidOperationException($"Task {taskId} has no worktree."); - } - - [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 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 GitRevListCountAsync(list.WorkingDir, $"{wt.BranchName}..{target}", ct); - - var changedFiles = Directory.Exists(wt.Path) - ? 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() - : 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}"); - } - - [McpServerTool, Description( - "Survey every worktree ClaudeDo tracks — use it to find leftovers to clean up. Only worktrees recorded in " + - "the ClaudeDo database appear here, and headCommit is empty when the path is missing from disk." + - McpToolDocs.ProgressHint)] - public async Task> ListWorktrees( - CancellationToken cancellationToken = default, - IProgress? progress = null) - { - var rows = await _maintenance.GetOverviewAsync(null, cancellationToken); - // One git status + one rev-parse per row, run concurrently -- with many tracked worktrees - // (this tool's whole point is surfacing leftovers nobody cleaned up) that can still take a - // while, so this reports on the same elapsed-time cadence as a single long git call rather - // than per-row (rows finish out of order under Task.WhenAll, so there's no natural i/n). - var results = await ProgressReporter.RunAsync( - Task.WhenAll(rows.Select(async row => - { - var isDirty = row.PathExistsOnDisk && await TryGetIsDirtyAsync(row.Path, cancellationToken); - var headCommit = row.PathExistsOnDisk - ? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "") - : ""; - return new WorktreeListItemDto( - row.TaskId, row.TaskNumber, row.Path, row.BranchName, headCommit, - isDirty, row.State == WorktreeState.Merged); - })), - ProgressReportInterval, progress, "surveying worktrees"); - return results; - } - - [McpServerTool, Description( - "Remove a task's worktree directory and delete its git branch. branchDeleted reports whether the branch " + - "went too." + McpToolDocs.NotWhileRunning)] - public async Task CleanupTaskWorktree( - string taskId, - [Description("false (default): refuse a worktree with uncommitted changes. true: remove it anyway, losing " + - "those changes.")] - bool force = false, - CancellationToken cancellationToken = default, - IProgress? progress = null) - { - taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); - - using var ctx = _dbFactory.CreateDbContext(); - var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken) - ?? throw new InvalidOperationException($"Task {taskId} not found."); - var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken) - ?? throw new InvalidOperationException($"Task {taskId} has no worktree."); - - if (task.Status == TaskStatus.Running) - throw new InvalidOperationException("Cannot remove worktree of a running task."); - - if (!force && Directory.Exists(wt.Path)) - { - var isDirty = await ProgressReporter.RunAsync( - _git.HasChangesAsync(wt.Path, cancellationToken), ProgressReportInterval, progress, "checking worktree for changes"); - if (isDirty) - throw new InvalidOperationException( - "Worktree has uncommitted changes. Use force=true to remove anyway (changes will be lost)."); - } - - var path = wt.Path; - var result = await ProgressReporter.RunAsync( - _maintenance.ForceRemoveAsync(taskId, cancellationToken), ProgressReportInterval, progress, "removing worktree"); - return new CleanupWorktreeResult(result.Removed, path, result.BranchDeleted, task.Number); - } - [McpServerTool, Description( "Send a follow-up prompt to a task's existing Claude session instead of starting a fresh run — the agent " + "resumes via --resume with the session id from the task's last run, so its prior context is kept. Uses the " + @@ -1752,69 +951,10 @@ public sealed class ExternalMcpService // ── Private helpers ─────────────────────────────────────────────────────── - private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity Wt)> LoadWorktreeContextAsync( - string taskId, CancellationToken ct) - { - 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) - ?? throw new InvalidOperationException($"Task {taskId} has no worktree."); - return (task, list, wt); - } - - private async Task TryGetIsDirtyAsync(string path, CancellationToken ct) - { - try { return await _git.HasChangesAsync(path, ct); } - catch { return false; } - } - - // Minimal git runner for operations not covered by GitService (rev-list --count, rev-parse from worktree). - private static async Task TryRunGitAsync(string dir, string[] args, CancellationToken ct) - { - try - { - var psi = new ProcessStartInfo("git") - { - UseShellExecute = false, - RedirectStandardOutput = true, - CreateNoWindow = true, - }; - psi.ArgumentList.Add("-C"); - psi.ArgumentList.Add(dir); - foreach (var a in args) psi.ArgumentList.Add(a); - using var proc = Process.Start(psi)!; - await using var _ = ct.Register(() => { try { proc.Kill(entireProcessTree: true); } catch { } }); - var stdout = await proc.StandardOutput.ReadToEndAsync(); - await proc.WaitForExitAsync(CancellationToken.None); - ct.ThrowIfCancellationRequested(); - return proc.ExitCode == 0 ? stdout.Trim() : null; - } - catch (OperationCanceledException) { throw; } - catch { return null; } - } - - private static async Task GitRevListCountAsync(string dir, string range, CancellationToken ct) - { - var result = await TryRunGitAsync(dir, ["rev-list", "--count", range], ct); - return int.TryParse(result, out var n) ? n : 0; - } - - private static IReadOnlyList ParseDiffFileNames(string diff) - { - var files = new List(); - foreach (var line in diff.Split('\n')) - { - var s = line.TrimEnd('\r'); - if (s.StartsWith("+++ b/", StringComparison.Ordinal)) - files.Add(s[6..]); - } - return files; - } - - private static IReadOnlyList ParseDiffStatFileNames(string stat) + // internal: also used by MergeMcpTools/WorktreeMcpTools' diff-stat parsing, which need it via + // an ExternalMcpService.ParseDiffStatFileNames(...) call since a private member of one class + // isn't visible from another, even in the same namespace. + internal static IReadOnlyList ParseDiffStatFileNames(string stat) { var files = new List(); foreach (var line in stat.Split('\n')) @@ -1848,7 +988,8 @@ public sealed class ExternalMcpService blockedReason, t.IsManual); - private static TaskRefDto ToRefDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new( + // internal: AbortMerge in MergeMcpTools also needs this to build its TaskRefDto result. + internal static TaskRefDto ToRefDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new( t.Id, t.Number, t.ListId, diff --git a/src/ClaudeDo.Worker/External/MergeMcpTools.cs b/src/ClaudeDo.Worker/External/MergeMcpTools.cs new file mode 100644 index 00000000..11231ed9 --- /dev/null +++ b/src/ClaudeDo.Worker/External/MergeMcpTools.cs @@ -0,0 +1,667 @@ +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}"); + } +} diff --git a/src/ClaudeDo.Worker/External/WorktreeMcpTools.cs b/src/ClaudeDo.Worker/External/WorktreeMcpTools.cs new file mode 100644 index 00000000..32b475d3 --- /dev/null +++ b/src/ClaudeDo.Worker/External/WorktreeMcpTools.cs @@ -0,0 +1,276 @@ +using System.ComponentModel; +using System.Diagnostics; +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Git; +using ClaudeDo.Worker.Worktrees; +using ModelContextProtocol; +using ModelContextProtocol.Server; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.External; + +public sealed record WorktreeInfoDto( + string Path, string Branch, string HeadCommit, string BaseCommit, + int Ahead, int Behind, bool IsDirty, string? MergeCommit = null); + +public sealed record TaskDiffDto( + string Content, IReadOnlyList Files, bool Truncated, int TotalBytes); + +public sealed record WorktreeListItemDto( + string? TaskId, int? Number, string Path, string Branch, + string HeadCommit, bool IsDirty, bool MergedIntoMain); + +public sealed record CleanupWorktreeResult( + bool Removed, string WorktreePath, bool BranchDeleted, int? Number = null); + +[McpServerToolType] +public sealed class WorktreeMcpTools +{ + // Mirrors TaskMergeService.ProgressReportInterval / TaskWaitMcpTools.ProgressReportInterval: + // a separate field (not shared) so shrinking one for a test can't race another's tests. Used + // by the single-element long-runners here (GetTaskDiff, CleanupTaskWorktree) that wrap a bare + // GitService/WorktreeMaintenanceService call with no natural i/n to report instead. + internal static TimeSpan ProgressReportInterval = TimeSpan.FromSeconds(30); + + private readonly TaskRepository _tasks; + private readonly GitService _git; + private readonly IDbContextFactory _dbFactory; + private readonly WorktreeMaintenanceService _maintenance; + + public WorktreeMcpTools( + TaskRepository tasks, + GitService git, + IDbContextFactory dbFactory, + WorktreeMaintenanceService maintenance) + { + _tasks = tasks; + _git = git; + _dbFactory = dbFactory; + _maintenance = maintenance; + } + + [McpServerTool, Description( + "Get a task's git worktree state — path, branch, base/head commit, ahead/behind counts, isDirty, and the " + + "mergeCommit its branch produced once merged. behind is 0 when the 'main' ref is unreachable, so do not " + + "read 0 as \"up to date\" without checking. A null mergeCommit means revert_merge cannot act on this task. " + + "Throws if the task or its worktree does not exist.")] + public async Task GetTaskWorktree(string taskId, CancellationToken cancellationToken) + { + taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); + var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken); + + var headCommit = !string.IsNullOrWhiteSpace(wt.HeadCommit) + ? wt.HeadCommit + : await TryRunGitAsync(wt.Path, ["rev-parse", "HEAD"], cancellationToken) ?? wt.BaseCommit; + + var isDirty = Directory.Exists(wt.Path) && await _git.HasChangesAsync(wt.Path, cancellationToken); + var ahead = await GitRevListCountAsync(wt.Path, $"{wt.BaseCommit}..HEAD", cancellationToken); + var behind = await GitRevListCountAsync(wt.Path, "HEAD..main", cancellationToken); + + return new WorktreeInfoDto(wt.Path, wt.BranchName, headCommit!, wt.BaseCommit, ahead, behind, isDirty, wt.MergeCommit); + } + + [McpServerTool, Description( + "Read what a task actually changed — the diff of its worktree against its base commit (for a worktree-less " + + "list-handler host task, the fixed HandlerBaseCommit..HandlerHeadCommit range over the list's working dir " + + "instead). files lists the changed paths in either mode; truncated=true means the diff was capped and " + + "totalBytes holds its real size. Throws if the task has no worktree/review range, or the relevant " + + "directory is missing from disk.")] + public async Task GetTaskDiff( + string taskId, + [Description("false (default): the full unified diff, capped at 200 KB. true: a --stat summary with " + + "per-file insertion/deletion counts — start here when the diff may be large.")] + bool stat = false, + [Description("Restrict the diff to these paths (relative to the repo root), e.g. after a --stat pass or a " + + "conflict report already narrowed down which files matter. Omit/empty for every changed file " + + "— the default and the only prior behavior. Works in both stat and full-diff mode.")] + IReadOnlyList? paths = null, + CancellationToken cancellationToken = default, + IProgress? progress = null) + { + taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); + var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken); + + const int maxBytes = 200 * 1024; + + if (stat) + { + var diffStat = await ProgressReporter.RunAsync( + _git.DiffStatAsync(repoPath, baseCommit, headCommit ?? "HEAD", paths, cancellationToken), + ProgressReportInterval, progress, "computing diff stat"); + return new TaskDiffDto(diffStat, ExternalMcpService.ParseDiffStatFileNames(diffStat), false, diffStat.Length); + } + + var diff = headCommit is null + ? await ProgressReporter.RunAsync( + _git.GetBranchDiffAsync(repoPath, baseCommit, paths, cancellationToken), ProgressReportInterval, progress, "computing diff") + : await ProgressReporter.RunAsync( + _git.GetCommitRangeDiffAsync(repoPath, baseCommit, headCommit, paths, cancellationToken), ProgressReportInterval, progress, "computing diff"); + var files = ParseDiffFileNames(diff); + + if (diff.Length <= maxBytes) + return new TaskDiffDto(diff, files, false, diff.Length); + + return new TaskDiffDto(diff[..maxBytes], files, true, diff.Length); + } + + // Resolves where a task's diff lives: a live worktree (repo path = worktree path, diffed + // against HEAD) or, for a worktree-less list-handler host task, the fixed + // HandlerBaseCommit..HandlerHeadCommit range over the list's working dir (headCommit + // non-null signals "fixed range" to the caller instead of "diff against live HEAD"). + private async Task<(string RepoPath, string BaseCommit, string? HeadCommit)> LoadDiffRangeAsync( + string taskId, CancellationToken ct) + { + using var ctx = _dbFactory.CreateDbContext(); + var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct); + + if (wt is not null) + { + if (!Directory.Exists(wt.Path)) + throw new InvalidOperationException($"Worktree directory does not exist on disk: {wt.Path}"); + return (wt.Path, wt.BaseCommit, null); + } + + if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead) + { + var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct) + ?? throw new InvalidOperationException("List not found."); + if (string.IsNullOrEmpty(list.WorkingDir) || !Directory.Exists(list.WorkingDir)) + throw new InvalidOperationException("The list's working directory no longer exists."); + return (list.WorkingDir, handlerBase, handlerHead); + } + + throw new InvalidOperationException($"Task {taskId} has no worktree."); + } + + [McpServerTool, Description( + "Survey every worktree ClaudeDo tracks — use it to find leftovers to clean up. Only worktrees recorded in " + + "the ClaudeDo database appear here, and headCommit is empty when the path is missing from disk." + + McpToolDocs.ProgressHint)] + public async Task> ListWorktrees( + CancellationToken cancellationToken = default, + IProgress? progress = null) + { + var rows = await _maintenance.GetOverviewAsync(null, cancellationToken); + // One git status + one rev-parse per row, run concurrently -- with many tracked worktrees + // (this tool's whole point is surfacing leftovers nobody cleaned up) that can still take a + // while, so this reports on the same elapsed-time cadence as a single long git call rather + // than per-row (rows finish out of order under Task.WhenAll, so there's no natural i/n). + var results = await ProgressReporter.RunAsync( + Task.WhenAll(rows.Select(async row => + { + var isDirty = row.PathExistsOnDisk && await TryGetIsDirtyAsync(row.Path, cancellationToken); + var headCommit = row.PathExistsOnDisk + ? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "") + : ""; + return new WorktreeListItemDto( + row.TaskId, row.TaskNumber, row.Path, row.BranchName, headCommit, + isDirty, row.State == WorktreeState.Merged); + })), + ProgressReportInterval, progress, "surveying worktrees"); + return results; + } + + [McpServerTool, Description( + "Remove a task's worktree directory and delete its git branch. branchDeleted reports whether the branch " + + "went too." + McpToolDocs.NotWhileRunning)] + public async Task CleanupTaskWorktree( + string taskId, + [Description("false (default): refuse a worktree with uncommitted changes. true: remove it anyway, losing " + + "those changes.")] + bool force = false, + CancellationToken cancellationToken = default, + IProgress? progress = null) + { + taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); + + using var ctx = _dbFactory.CreateDbContext(); + var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} has no worktree."); + + if (task.Status == TaskStatus.Running) + throw new InvalidOperationException("Cannot remove worktree of a running task."); + + if (!force && Directory.Exists(wt.Path)) + { + var isDirty = await ProgressReporter.RunAsync( + _git.HasChangesAsync(wt.Path, cancellationToken), ProgressReportInterval, progress, "checking worktree for changes"); + if (isDirty) + throw new InvalidOperationException( + "Worktree has uncommitted changes. Use force=true to remove anyway (changes will be lost)."); + } + + var path = wt.Path; + var result = await ProgressReporter.RunAsync( + _maintenance.ForceRemoveAsync(taskId, cancellationToken), ProgressReportInterval, progress, "removing worktree"); + return new CleanupWorktreeResult(result.Removed, path, result.BranchDeleted, task.Number); + } + + private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity Wt)> LoadWorktreeContextAsync( + string taskId, CancellationToken ct) + { + 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) + ?? throw new InvalidOperationException($"Task {taskId} has no worktree."); + return (task, list, wt); + } + + private async Task TryGetIsDirtyAsync(string path, CancellationToken ct) + { + try { return await _git.HasChangesAsync(path, ct); } + catch { return false; } + } + + // Minimal git runner for operations not covered by GitService (rev-list --count, rev-parse from worktree). + private static async Task TryRunGitAsync(string dir, string[] args, CancellationToken ct) + { + try + { + var psi = new ProcessStartInfo("git") + { + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true, + }; + psi.ArgumentList.Add("-C"); + psi.ArgumentList.Add(dir); + foreach (var a in args) psi.ArgumentList.Add(a); + using var proc = Process.Start(psi)!; + await using var _ = ct.Register(() => { try { proc.Kill(entireProcessTree: true); } catch { } }); + var stdout = await proc.StandardOutput.ReadToEndAsync(); + await proc.WaitForExitAsync(CancellationToken.None); + ct.ThrowIfCancellationRequested(); + return proc.ExitCode == 0 ? stdout.Trim() : null; + } + catch (OperationCanceledException) { throw; } + catch { return null; } + } + + internal static async Task GitRevListCountAsync(string dir, string range, CancellationToken ct) + { + var result = await TryRunGitAsync(dir, ["rev-list", "--count", range], ct); + return int.TryParse(result, out var n) ? n : 0; + } + + private static IReadOnlyList ParseDiffFileNames(string diff) + { + var files = new List(); + foreach (var line in diff.Split('\n')) + { + var s = line.TrimEnd('\r'); + if (s.StartsWith("+++ b/", StringComparison.Ordinal)) + files.Add(s[6..]); + } + return files; + } +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 3c9f2bd9..942e2d83 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -345,6 +345,8 @@ if (cfg.ExternalMcpPort > 0) externalBuilder.Services.AddSingleton(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); @@ -363,6 +365,8 @@ if (cfg.ExternalMcpPort > 0) .WithHttpTransport(o => o.Stateless = true) .WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap)) .WithTools() + .WithTools() + .WithTools() .WithTools() .WithTools() .WithTools() diff --git a/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs b/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs index cd7bbd96..bf9d1205 100644 --- a/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs @@ -81,7 +81,6 @@ public sealed class AddSubtaskToolTests : IDisposable var queue = new QueueService(dbFactory, runner, cfg, NullLogger.Instance, waker, picker, overrideSlot, state, runCancels, new FakeUsageGate(), usageState, broadcaster, new UsageLimitAutoContinueCoordinator(dbFactory, usageState, overrideSlot, broadcaster, NullLogger.Instance)); - var maintenance = new WorktreeMaintenanceService(dbFactory, git, NullLogger.Instance); var merge = new TaskMergeService(dbFactory, git, broadcaster, state, new VerifyCommandRunner(), NullLogger.Instance); var aggregator = new PlanningAggregator(dbFactory, git, NullLogger.Instance); var planningMerge = new PlanningMergeOrchestrator( @@ -89,7 +88,7 @@ public sealed class AddSubtaskToolTests : IDisposable return new ExternalMcpService( _tasks, _lists, queue, broadcaster, state, - git, dbFactory, maintenance, merge, planningMerge, + git, dbFactory, merge, planningMerge, new BaseDirtyChecker(git, NullLogger.Instance)); } diff --git a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs index db6e02f9..cedcc68b 100644 --- a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs @@ -79,7 +79,6 @@ public sealed class BatchMcpToolsTests : IDisposable { var git = new GitService(); var factory = _db.CreateFactory(); - var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger.Instance); var state = TaskStateServiceBuilder.Build(factory).State; var merge = new TaskMergeService(factory, git, _broadcaster, state, new VerifyCommandRunner(), NullLogger.Instance); var aggregator = new PlanningAggregator(factory, git, NullLogger.Instance); @@ -88,7 +87,7 @@ public sealed class BatchMcpToolsTests : IDisposable var svc = new ExternalMcpService( _tasks, _lists, CreateQueue(), _broadcaster, state, - git, factory, maintenance, merge, planningMerge, + git, factory, merge, planningMerge, new BaseDirtyChecker(git, NullLogger.Instance)); return new BatchMcpTools(svc); } diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 247ef8eb..481bd52e 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -142,7 +142,6 @@ public sealed class ExternalMcpServiceTests : IDisposable { var git = new GitService(); var factory = _db.CreateFactory(); - var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger.Instance); var state = TaskStateServiceBuilder.Build(factory).State; var merge = new TaskMergeService(factory, git, _broadcaster, state, verify, NullLogger.Instance); var aggregator = new PlanningAggregator(factory, git, NullLogger.Instance); @@ -151,7 +150,7 @@ public sealed class ExternalMcpServiceTests : IDisposable return new ExternalMcpService( _tasks, _lists, queue, _broadcaster, state, - git, factory, maintenance, merge, planningMerge, + git, factory, merge, planningMerge, new BaseDirtyChecker(git, NullLogger.Instance)); } @@ -1190,384 +1189,6 @@ public sealed class ExternalMcpServiceTests : IDisposable Assert.False(dto.IsMyDay); } - // ── GetTaskWorktree ──────────────────────────────────────────────────────── - - [Fact] - public async Task GetTaskWorktree_NoWorktree_Throws() - { - var listId = await SeedListAsync(); - var task = await SeedTaskAsync(listId); - var sut = BuildSut(CreateQueue()); - - await Assert.ThrowsAsync( - () => sut.GetTaskWorktree(task.Id, CancellationToken.None)); - } - - [Fact] - public async Task GetTaskWorktree_ReturnsBranchAndBaseCommit() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, _, wt) = await SeedWorktreeAsync(); - var sut = BuildSut(CreateQueue()); - - var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); - - Assert.Equal(wt.BranchName, info.Branch); - Assert.Equal(wt.BaseCommit, info.BaseCommit); - Assert.Equal(0, info.Ahead); - Assert.False(info.IsDirty); - } - - [Fact] - public async Task GetTaskWorktree_BeforeMerge_MergeCommitIsNull() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, _, _) = await SeedWorktreeAsync(); - var sut = BuildSut(CreateQueue()); - - var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); - - Assert.Null(info.MergeCommit); - } - - [Fact] - public async Task GetTaskWorktree_AfterMerge_ExposesMergeCommit() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done); - File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "new\n"); - GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A"); - GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file"); - - var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); - var sut = BuildSut(CreateQueue()); - await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None); - - var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); - - var expected = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim(); - Assert.Equal(expected, info.MergeCommit); - } - - // ── GetTaskDiff ──────────────────────────────────────────────────────────── - - [Fact] - public async Task GetTaskDiff_NoWorktree_Throws() - { - var listId = await SeedListAsync(); - var task = await SeedTaskAsync(listId); - var sut = BuildSut(CreateQueue()); - - await Assert.ThrowsAsync( - () => sut.GetTaskDiff(task.Id, false, null, CancellationToken.None)); - } - - [Fact] - public async Task GetTaskDiff_AfterCommit_ListsChangedFile() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, wt) = await SeedWorktreeAsync(); - File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); - - var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; - var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); - await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); - - var sut = BuildSut(CreateQueue()); - var diff = await sut.GetTaskDiff(task.Id, false, null, CancellationToken.None); - - Assert.Contains("added.txt", diff.Files); - Assert.False(diff.Truncated); - } - - [Fact] - public async Task GetTaskDiff_WithPathsFilter_ReturnsOnlyRequestedFile() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, wt) = await SeedWorktreeAsync(); - File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); - File.WriteAllText(Path.Combine(wt.WorktreePath, "other.txt"), "other content"); - - var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; - var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); - await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); - - var sut = BuildSut(CreateQueue()); - var diff = await sut.GetTaskDiff(task.Id, false, ["added.txt"], CancellationToken.None); - - Assert.Equal(new[] { "added.txt" }, diff.Files); - Assert.DoesNotContain("other.txt", diff.Content); - } - - [Fact] - public async Task GetTaskDiff_StatMode_WithPathsFilter_ReturnsOnlyRequestedFile() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, wt) = await SeedWorktreeAsync(); - File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); - File.WriteAllText(Path.Combine(wt.WorktreePath, "other.txt"), "other content"); - - var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; - var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); - await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); - - var sut = BuildSut(CreateQueue()); - var diff = await sut.GetTaskDiff(task.Id, true, ["other.txt"], CancellationToken.None); - - Assert.Equal(new[] { "other.txt" }, diff.Files); - Assert.DoesNotContain("added.txt", diff.Content); - } - - [Fact] - public async Task GetTaskDiff_WorktreeLessHandlerTask_UsesHandlerCommitRangeOverListWorkingDir() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var repo = new GitRepoFixture(); - _repos.Add(repo); - var listId = Guid.NewGuid().ToString(); - await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); - var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); - // No WorktreeEntity for this task -- it's a worktree-less list-handler host task - // (Mission Control's "Let Claude handle it"): the handler merged its own commit - // straight into the list's working dir after HandlerBaseCommit was stamped. - - File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content"); - GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); - GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge"); - var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); - - using (var ctx = _db.CreateContext()) - { - var t = await ctx.Tasks.FindAsync(task.Id); - t!.HandlerBaseCommit = repo.BaseCommit; - t.HandlerHeadCommit = headCommit; - await ctx.SaveChangesAsync(); - } - - var sut = BuildSut(CreateQueue()); - var diff = await sut.GetTaskDiff(task.Id, false, null, CancellationToken.None); - - Assert.Contains("handled.txt", diff.Files); - Assert.False(diff.Truncated); - } - - [Fact] - public async Task GetTaskDiff_WorktreeLessHandlerTask_StatMode_UsesHandlerCommitRange() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var repo = new GitRepoFixture(); - _repos.Add(repo); - var listId = Guid.NewGuid().ToString(); - await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); - var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); - - File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content"); - GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); - GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge"); - var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); - - using (var ctx = _db.CreateContext()) - { - var t = await ctx.Tasks.FindAsync(task.Id); - t!.HandlerBaseCommit = repo.BaseCommit; - t.HandlerHeadCommit = headCommit; - await ctx.SaveChangesAsync(); - } - - var sut = BuildSut(CreateQueue()); - var diff = await sut.GetTaskDiff(task.Id, true, null, CancellationToken.None); - - Assert.Contains("handled.txt", diff.Content); - } - - // get_task_diff has no natural i/n (it's one git process, not a loop over items), so it - // reports on a timer like merge_task's verify gate. Shrinking the interval to zero forces a - // report on every poll of the still-running git process instead of waiting out a real - // interval -- Task.Delay(TimeSpan.Zero) resolves synchronously, so the diff process (a real - // subprocess, never already complete when polled) always loses the race at least once. - [Fact] - public async Task GetTaskDiff_ReportsProgressWhileDiffing() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var original = ExternalMcpService.ProgressReportInterval; - ExternalMcpService.ProgressReportInterval = TimeSpan.Zero; - try - { - var (task, list, wt) = await SeedWorktreeAsync(); - File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); - var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; - var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); - await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); - - var sut = BuildSut(CreateQueue()); - var progress = new SyncProgress(); - - var diff = await sut.GetTaskDiff(task.Id, false, null, CancellationToken.None, progress); - - Assert.Contains("added.txt", diff.Files); - Assert.NotEmpty(progress.Reports); - Assert.All(progress.Reports, r => Assert.Contains("computing diff", r.Message)); - } - finally - { - ExternalMcpService.ProgressReportInterval = original; - } - } - - // ── MergeTask ────────────────────────────────────────────────────────────── - - [Fact] - public async Task MergeTask_NotDone_Throws() - { - var listId = await SeedListAsync(); - var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); - var sut = BuildSut(CreateQueue()); - - var ex = await Assert.ThrowsAsync( - () => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None)); - Assert.Contains("Done", ex.Message); - } - - [Fact] - public async Task MergeTask_DryRun_DoesNotMerge() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done); - var sut = BuildSut(CreateQueue()); - - var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, cancellationToken: CancellationToken.None); - - Assert.False(result.Merged); - Assert.Null(result.MergeCommit); - } - - // ── ListWorktrees ────────────────────────────────────────────────────────── - - [Fact] - public async Task ListWorktrees_Empty_ReturnsEmpty() - { - var sut = BuildSut(CreateQueue()); - - var rows = await sut.ListWorktrees(CancellationToken.None); - - Assert.Empty(rows); - } - - [Fact] - public async Task ListWorktrees_ReturnsCreatedWorktree() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, _, _) = await SeedWorktreeAsync(); - var sut = BuildSut(CreateQueue()); - - var rows = await sut.ListWorktrees(CancellationToken.None); - - Assert.Contains(rows, r => r.TaskId == task.Id); - } - - // list_worktrees fans out one git status + one rev-parse per tracked worktree under - // Task.WhenAll -- no natural i/n since rows finish out of order, so it reports on the same - // elapsed-time cadence as CleanupTaskWorktree above. - [Fact] - public async Task ListWorktrees_ReportsProgressWhileSurveying() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var original = ExternalMcpService.ProgressReportInterval; - ExternalMcpService.ProgressReportInterval = TimeSpan.Zero; - try - { - await SeedWorktreeAsync(); - var sut = BuildSut(CreateQueue()); - var progress = new SyncProgress(); - - var rows = await sut.ListWorktrees(CancellationToken.None, progress); - - Assert.NotEmpty(rows); - Assert.NotEmpty(progress.Reports); - } - finally - { - ExternalMcpService.ProgressReportInterval = original; - } - } - - // ── CleanupTaskWorktree ──────────────────────────────────────────────────── - - [Fact] - public async Task CleanupTaskWorktree_NotFound_Throws() - { - var sut = BuildSut(CreateQueue()); - - await Assert.ThrowsAsync( - () => sut.CleanupTaskWorktree("does-not-exist", false, CancellationToken.None)); - } - - [Fact] - public async Task CleanupTaskWorktree_Running_Throws() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Running); - var sut = BuildSut(CreateQueue()); - - var ex = await Assert.ThrowsAsync( - () => sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None)); - Assert.Contains("running", ex.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task CleanupTaskWorktree_CleanWorktree_Removes() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done); - var sut = BuildSut(CreateQueue()); - - var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None); - - Assert.True(result.Removed); - Assert.False(Directory.Exists(wt.WorktreePath)); - } - - // cleanup_task_worktree has no natural i/n either (dirty check + one removal, not a loop), so - // it reports on the same timer/zero-interval technique as GetTaskDiff above. - [Fact] - public async Task CleanupTaskWorktree_ReportsProgressWhileRemoving() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var original = ExternalMcpService.ProgressReportInterval; - ExternalMcpService.ProgressReportInterval = TimeSpan.Zero; - try - { - var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done); - var sut = BuildSut(CreateQueue()); - var progress = new SyncProgress(); - - var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None, progress); - - Assert.True(result.Removed); - Assert.False(Directory.Exists(wt.WorktreePath)); - Assert.NotEmpty(progress.Reports); - } - finally - { - ExternalMcpService.ProgressReportInterval = original; - } - } - // ── GetTaskConfig ───────────────────────────────────────────────────────── private ConfigMcpTools BuildConfigSut() => new(_lists, _tasks, _broadcaster, _db.CreateFactory()); @@ -1681,55 +1302,6 @@ public sealed class ExternalMcpServiceTests : IDisposable Assert.Equal(task.Number, result.TasksFull![0].Number); } - // ── MergeTask allowWaitingForReview ─────────────────────────────────────── - - [Fact] - public async Task MergeTask_WaitingForReview_WithoutFlag_Throws() - { - var listId = await SeedListAsync(); - var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); - var sut = BuildSut(CreateQueue()); - - var ex = await Assert.ThrowsAsync( - () => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None)); - Assert.Contains("Done", ex.Message); - } - - [Fact] - public async Task MergeTask_WaitingForReview_WithFlag_DryRun_Succeeds() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); - var sut = BuildSut(CreateQueue()); - - var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, allowWaitingForReview: true, cancellationToken: CancellationToken.None); - - Assert.False(result.Merged); - Assert.Null(result.MergeCommit); - } - - [Fact] - public async Task MergeTask_WaitingForReview_WithFlag_MarksTaskDone() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); - File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); - var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; - var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); - await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); - - var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir, CancellationToken.None); - var sut = BuildSut(CreateQueue()); - - var result = await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, cancellationToken: CancellationToken.None); - - Assert.True(result.Merged); - var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id); - Assert.Equal(TaskStatus.Done, reloaded!.Status); - } - // ── leaveConflictsInTree ────────────────────────────────────────────────── private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedConflictingWorktreeAsync( @@ -1748,93 +1320,6 @@ public sealed class ExternalMcpServiceTests : IDisposable return (task, list, wt); } - // Three edits spread far enough apart in a 20-line file that git's 3-way merge keeps them as three - // separate, non-adjacent hunks instead of folding them into one. - private static readonly int[] MultiHunkEditLines = [3, 9, 15]; - - private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedMultiHunkConflictingWorktreeAsync() - { - var repo = new GitRepoFixture(); - _repos.Add(repo); - - var baseLines = Enumerable.Range(1, 20).Select(n => $"line{n}").ToArray(); - File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), string.Join('\n', baseLines) + "\n"); - GitRepoFixture.RunGit(repo.RepoDir, "add", "README.md"); - GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "multi-line base"); - - var listId = Guid.NewGuid().ToString(); - var list = new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }; - await _lists.AddAsync(list); - var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); - - var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; - var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); - var wt = await mgr.CreateAsync(task, list, CancellationToken.None); - _worktreeCleanups.Add((repo.RepoDir, wt.WorktreePath)); - - WriteMultiHunkVariant(wt.WorktreePath, baseLines, "WT"); - GitRepoFixture.RunGit(wt.WorktreePath, "add", "README.md"); - GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "worktree edit"); - - WriteMultiHunkVariant(list.WorkingDir!, baseLines, "MAIN"); - GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md"); - GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "main edit"); - - return (task, list, wt); - } - - private static void WriteMultiHunkVariant(string dir, string[] baseLines, string tag) - { - var lines = (string[])baseLines.Clone(); - foreach (var lineNum in MultiHunkEditLines) - lines[lineNum - 1] = $"{tag}-{lineNum}"; - File.WriteAllText(Path.Combine(dir, "README.md"), string.Join('\n', lines) + "\n"); - } - - [Fact] - public async Task MergeTask_LeaveConflictsInTree_LeavesMarkersAndKeepsRepoMidMerge() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); - var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!); - var sut = BuildSut(CreateQueue()); - - var result = await sut.MergeTask(task.Id, target, true, dryRun: false, - allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); - - Assert.False(result.Merged); - Assert.True(result.ConflictsInTree); - Assert.Equal(list.WorkingDir, result.RepoPath); - Assert.Contains("README.md", result.Conflicts); - Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"))); - Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!)); - var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id); - Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status); - - GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); - } - - [Fact] - public async Task MergeTask_ConflictWithoutLeaveFlag_AbortsCleanly() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); - var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!); - var sut = BuildSut(CreateQueue()); - - var result = await sut.MergeTask(task.Id, target, true, dryRun: false, - allowWaitingForReview: true, leaveConflictsInTree: false, cancellationToken: CancellationToken.None); - - Assert.False(result.Merged); - Assert.False(result.ConflictsInTree); - Assert.Null(result.RepoPath); - Assert.Contains("README.md", result.Conflicts); - Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"))); - Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!)); - } - [Fact] public async Task ReviewTask_Approve_LeaveConflictsInTree_LeavesMarkersAndReportsRepoPath() { @@ -1874,920 +1359,6 @@ public sealed class ExternalMcpServiceTests : IDisposable Assert.Equal("WaitingForReview", result.Task.Status); } - // ── continue_merge / abort_merge ────────────────────────────────────────── - - [Fact] - public async Task ContinueMerge_AfterResolvingMarkers_MergesAndSetsDone() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); - var sut = BuildSut(CreateQueue()); - await sut.ReviewTask(task.Id, "approve", null, null, - leaveConflictsInTree: true, cancellationToken: CancellationToken.None); - - // Resolve the conflict on disk and stage it. - File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n"); - GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md"); - - var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None); - - Assert.True(result.Merged); - Assert.Equal("Done", result.TaskStatus); - Assert.Empty(result.Conflicts); - Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!)); - } - - // Regression coverage for the same class of incident as ReviewTask_ApproveWithSlowVerifyCommand - // above, but for the resume path: continue_merge re-runs the verify gate too, and until this - // fix it never forwarded its progress token into RunVerifyGateAsync at all. - [Fact] - public async Task ContinueMerge_SlowVerifyCommand_ReportsProgressWellBeforeIdleTimeout() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var original = TaskMergeService.ProgressReportInterval; - TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50); - try - { - var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); - var plainSut = BuildSut(CreateQueue()); - await plainSut.ReviewTask(task.Id, "approve", null, null, - leaveConflictsInTree: true, cancellationToken: CancellationToken.None); - - File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n"); - GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md"); - - using (var ctx = _db.CreateContext()) - await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" }); - - var fakeVerify = new FakeVerifyCommandRunner - { - Result = new VerifyCommandResult(0, false, "ok"), - Delay = TimeSpan.FromMilliseconds(300), - }; - var sut = BuildSut(CreateQueue(), fakeVerify); - - var reports = new List(); - var progress = new Progress(reports.Add); - - var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None, progress: progress); - - Assert.True(result.Merged); - Assert.Equal("Done", result.TaskStatus); - await Task.Delay(200); - Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running")); - } - finally - { - TaskMergeService.ProgressReportInterval = original; - } - } - - [Fact] - public async Task ContinueMerge_MarkersStillPresent_ReportsConflicts() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); - var sut = BuildSut(CreateQueue()); - await sut.ReviewTask(task.Id, "approve", null, null, - leaveConflictsInTree: true, cancellationToken: CancellationToken.None); - - // Markers never resolved — continue must refuse and list the files. - var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None); - - Assert.False(result.Merged); - Assert.Contains("README.md", result.Conflicts); - Assert.Equal(list.WorkingDir, result.RepoPath); - Assert.Equal("WaitingForReview", result.TaskStatus); - Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!)); - - GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); - } - - [Fact] - public async Task AbortMerge_RestoresCleanTreeAndKeepsWaitingForReview() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); - var sut = BuildSut(CreateQueue()); - await sut.ReviewTask(task.Id, "approve", null, null, - leaveConflictsInTree: true, cancellationToken: CancellationToken.None); - - var dto = await sut.AbortMerge(task.Id, CancellationToken.None); - - Assert.Equal("WaitingForReview", dto.Status); - Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"))); - Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!)); - } - - [Fact] - public async Task ContinueMerge_NoMergeInProgress_Throws() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); - var sut = BuildSut(CreateQueue()); - - var ex = await Assert.ThrowsAsync( - () => sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None)); - Assert.Contains("mid-merge", ex.Message); - } - - [Fact] - public async Task AbortMerge_NoMergeInProgress_Throws() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); - var sut = BuildSut(CreateQueue()); - - var ex = await Assert.ThrowsAsync( - () => sut.AbortMerge(task.Id, CancellationToken.None)); - Assert.Contains("mid-merge", ex.Message); - } - - // ── get_merge_conflicts / resolve_conflict_hunk ───────────────────────────── - - [Fact] - public async Task GetMergeConflicts_ReportsHunksWithStartLineAndDiff3Base() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); - var sut = BuildSut(CreateQueue()); - await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); - - var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None); - - Assert.Equal(1, result.RemainingHunks); - var file = Assert.Single(result.Files); - Assert.Equal("README.md", file.Path); - Assert.False(file.IsBinary); - var hunk = Assert.Single(file.Hunks); - Assert.Equal(0, hunk.Index); - Assert.True(hunk.StartLine >= 1); - Assert.Equal("# from main", hunk.Ours.TrimEnd('\r', '\n')); - Assert.Equal("# from worktree", hunk.Theirs.TrimEnd('\r', '\n')); - Assert.NotEmpty(result.ConflictStyleNote); - - GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); - } - - [Fact] - public async Task GetMergeConflicts_NoMergeInProgress_ReturnsEmpty() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); - var sut = BuildSut(CreateQueue()); - - var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None); - - Assert.Equal(0, result.RemainingHunks); - Assert.Empty(result.Files); - } - - [Fact] - public async Task ResolveConflictHunk_OursTheirsAndText_ResolveEachHunkAndTrackRemainingCount() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync(); - var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!); - var sut = BuildSut(CreateQueue()); - - var merge = await sut.MergeTask(task.Id, target, true, dryRun: false, - allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); - Assert.True(merge.ConflictsInTree); - - var before = await sut.GetMergeConflicts(task.Id, CancellationToken.None); - Assert.Equal(3, before.RemainingHunks); - Assert.Equal(3, Assert.Single(before.Files).Hunks.Count); - - // Resolve hunk 0 three times in a row: each resolution removes a hunk from the front, so the - // next still-open hunk always slides into index 0. - var r1 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None); - Assert.Equal(2, r1.RemainingHunks); - - var r2 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "theirs", CancellationToken.None); - Assert.Equal(1, r2.RemainingHunks); - - var r3 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "CUSTOM-15\n", CancellationToken.None); - Assert.Equal(0, r3.RemainingHunks); - - var finalConflicts = await sut.GetMergeConflicts(task.Id, CancellationToken.None); - Assert.Equal(0, finalConflicts.RemainingHunks); - - var continueResult = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None); - Assert.True(continueResult.Merged); - Assert.Equal("Done", continueResult.TaskStatus); - - var mergedText = File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")); - Assert.Contains("MAIN-3", mergedText); // hunk 1 resolved "ours" (the target branch) - Assert.Contains("WT-9", mergedText); // hunk 2 resolved "theirs" (the incoming branch) - Assert.Contains("CUSTOM-15", mergedText); // hunk 3 resolved to literal text - Assert.DoesNotContain("<<<<<<<", mergedText); - } - - [Fact] - public async Task ResolveConflictHunk_LeavesOtherHunksUntouchedUntilContinueMerge() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync(); - var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!); - var sut = BuildSut(CreateQueue()); - await sut.MergeTask(task.Id, target, true, dryRun: false, - allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); - - // Resolve only one of the three hunks. - await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None); - - // continue_merge must still refuse: writing one hunk must not have staged the file as resolved. - var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None); - - Assert.False(result.Merged); - Assert.Contains("README.md", result.Conflicts); - Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!)); - - GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); - } - - [Fact] - public async Task ResolveConflictHunk_UnknownFileOrOutOfRangeIndex_Throws() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); - var sut = BuildSut(CreateQueue()); - await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); - - await Assert.ThrowsAsync( - () => sut.ResolveConflictHunk(task.Id, "not-a-real-file.md", 0, "ours", CancellationToken.None)); - await Assert.ThrowsAsync( - () => sut.ResolveConflictHunk(task.Id, "README.md", 5, "ours", CancellationToken.None)); - - GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); - } - - // ── RevertMerge ──────────────────────────────────────────────────────────── - - [Fact] - public async Task RevertMerge_NotMerged_Throws() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done); - var sut = BuildSut(CreateQueue()); - - var ex = await Assert.ThrowsAsync( - () => sut.RevertMerge(task.Id, "main", CancellationToken.None)); - Assert.Contains("expected Merged", ex.Message); - } - - [Fact] - public async Task RevertMerge_AfterCleanMerge_RevertsAndReturnsTaskToWaitingForReview() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done); - File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "new\n"); - GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A"); - GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file"); - - var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); - var sut = BuildSut(CreateQueue()); - await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None); - Assert.True(File.Exists(Path.Combine(list.WorkingDir!, "added.txt"))); - - var result = await sut.RevertMerge(task.Id, target, CancellationToken.None); - - Assert.True(result.Reverted); - Assert.False(string.IsNullOrWhiteSpace(result.RevertCommit)); - Assert.Empty(result.Conflicts); - Assert.False(File.Exists(Path.Combine(list.WorkingDir!, "added.txt"))); - - var reloaded = await _tasks.GetByIdAsync(task.Id); - Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status); - } - - [Fact] - public async Task RevertMerge_ConflictingRevert_ReturnsRevertedFalseWithConflictsAndAbortsCleanly() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done); - File.WriteAllText(Path.Combine(wt.WorktreePath, "README.md"), "# test repo\nfeature\n"); - GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A"); - GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: append feature line"); - - var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); - var sut = BuildSut(CreateQueue()); - await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None); - - // A later commit on main edits the exact line the merge introduced, so the revert conflicts. - File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# test repo\npost-merge edit\n"); - GitRepoFixture.RunGit(list.WorkingDir!, "add", "-A"); - GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "chore: edit the same line"); - var headBefore = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim(); - - var result = await sut.RevertMerge(task.Id, target, CancellationToken.None); - - Assert.False(result.Reverted); - Assert.Contains("README.md", result.Conflicts); - Assert.False(await new GitService().IsMidRevertAsync(list.WorkingDir!)); - Assert.Equal(headBefore, GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim()); - - var reloaded = await _tasks.GetByIdAsync(task.Id); - Assert.Equal(TaskStatus.Done, reloaded!.Status); - } - - [Fact] - public async Task ContinueMerge_ParentUnitMergeConflict_RoutesToOrchestratorAndCompletes() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var repo = new GitRepoFixture(); - _repos.Add(repo); - GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); - - // main edits README.md so the child's edit conflicts. - File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n"); - GitRepoFixture.RunGit(repo.RepoDir, "add", "README.md"); - GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit"); - - var listId = Guid.NewGuid().ToString(); - var parentId = Guid.NewGuid().ToString(); - var childId = Guid.NewGuid().ToString(); - using (var ctx = _db.CreateContext()) - { - ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); - ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow, - Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized, Number = ++_numberSeed }); - ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow, - ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed }); - - var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); - _worktreeCleanups.Add((repo.RepoDir, wtPath)); - var branch = $"claudedo/{childId[..8]}"; - GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit); - File.WriteAllText(Path.Combine(wtPath, "README.md"), "# child change\n"); - GitRepoFixture.RunGit(wtPath, "add", "README.md"); - GitRepoFixture.RunGit(wtPath, "commit", "-m", "child edit"); - ctx.Worktrees.Add(new WorktreeEntity - { - TaskId = childId, Path = wtPath, BranchName = branch, - BaseCommit = repo.BaseCommit, - HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(), - State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, - }); - await ctx.SaveChangesAsync(); - } - - var sut = BuildSut(CreateQueue()); - var review = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None); - Assert.Equal(TaskMergeService.StatusConflict, review.MergeStatus); - Assert.Equal(repo.RepoDir, review.RepoPath); - Assert.Contains("continue_merge", review.MergeMessage); - - // Resolve the conflict on disk. - File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n"); - - var result = await sut.ContinueMerge(parentId, cancellationToken: CancellationToken.None); - - Assert.True(result.Merged); - Assert.Equal("Done", result.TaskStatus); - Assert.Empty(result.Conflicts); - Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir)); - using var verify = _db.CreateContext(); - Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State); - } - - // ── PreviewMerge / PreviewMergeSet ──────────────────────────────────────── - - private async Task CreateActiveWorktreeAsync( - GitRepoFixture repo, string taskId, string fileName, string content) - { - var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); - _worktreeCleanups.Add((repo.RepoDir, wtPath)); - var branch = $"claudedo/{taskId[..8]}"; - GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit); - File.WriteAllText(Path.Combine(wtPath, fileName), content); - GitRepoFixture.RunGit(wtPath, "add", fileName); - GitRepoFixture.RunGit(wtPath, "commit", "-m", $"edit {fileName}"); - - using var ctx = _db.CreateContext(); - ctx.Worktrees.Add(new WorktreeEntity - { - TaskId = taskId, Path = wtPath, BranchName = branch, - BaseCommit = repo.BaseCommit, - HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(), - State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, - }); - await ctx.SaveChangesAsync(); - return wtPath; - } - - [Fact] - public async Task PreviewMerge_CleanMerge_ReturnsCleanStatusAndDoesNotChangeRepo() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); - File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); - var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; - var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); - await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); - - var git = new GitService(); - var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); - var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None); - var sut = BuildSut(CreateQueue()); - - var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None); - - Assert.Equal(TaskMergeService.PreviewClean, result.Status); - Assert.Empty(result.ConflictFiles); - Assert.Equal(1, result.ChangedFileCount); - Assert.Equal(0, result.Behind); - Assert.False(result.IsEmpty); - Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None)); - Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None)); - } - - [Fact] - public async Task PreviewMerge_EmptyWorktree_ReturnsIsEmptyTrue() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - // Worktree created but nothing committed beyond base -- e.g. a child that reported - // CLAUDEDO_BLOCKED before writing any code. - var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); - var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); - var sut = BuildSut(CreateQueue()); - - var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None); - - Assert.Equal(TaskMergeService.PreviewClean, result.Status); - Assert.Equal(0, result.ChangedFileCount); - Assert.True(result.IsEmpty); - } - - [Fact] - public async Task PreviewMerge_WorktreeLessHandlerTask_EmptyRange_ReturnsIsEmptyTrue() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var repo = new GitRepoFixture(); - _repos.Add(repo); - var listId = Guid.NewGuid().ToString(); - await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); - var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); - // Worktree-less list-handler host task whose handler range never actually committed - // anything: HandlerBaseCommit == HandlerHeadCommit, not "no worktree -> unknown". - using (var ctx = _db.CreateContext()) - { - var t = await ctx.Tasks.FindAsync(task.Id); - t!.HandlerBaseCommit = repo.BaseCommit; - t.HandlerHeadCommit = repo.BaseCommit; - await ctx.SaveChangesAsync(); - } - - var sut = BuildSut(CreateQueue()); - var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None); - - Assert.Equal(TaskMergeService.PreviewClean, result.Status); - Assert.Equal(0, result.ChangedFileCount); - Assert.True(result.IsEmpty); - } - - [Fact] - public async Task PreviewMerge_WorktreeLessHandlerTask_NonEmptyRange_ReturnsIsEmptyFalse() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var repo = new GitRepoFixture(); - _repos.Add(repo); - var listId = Guid.NewGuid().ToString(); - await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); - var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); - - File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content"); - GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); - GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge"); - var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); - - using (var ctx = _db.CreateContext()) - { - var t = await ctx.Tasks.FindAsync(task.Id); - t!.HandlerBaseCommit = repo.BaseCommit; - t.HandlerHeadCommit = headCommit; - await ctx.SaveChangesAsync(); - } - - var sut = BuildSut(CreateQueue()); - var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None); - - Assert.Equal(TaskMergeService.PreviewClean, result.Status); - Assert.Equal(1, result.ChangedFileCount); - Assert.False(result.IsEmpty); - } - - [Fact] - public async Task PreviewMerge_ConflictingBranches_ReturnsConflictStatusAndDoesNotChangeRepo() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); - var git = new GitService(); - var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); - var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None); - var sut = BuildSut(CreateQueue()); - - var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None); - - Assert.Equal(TaskMergeService.PreviewConflict, result.Status); - Assert.Contains("README.md", result.ConflictFiles); - Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None)); - Assert.False(await git.IsMidMergeAsync(list.WorkingDir!)); - Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None)); - } - - [Fact] - public async Task PreviewMerge_NoWorktree_Throws() - { - var listId = await SeedListAsync(); - var task = await SeedTaskAsync(listId); - var sut = BuildSut(CreateQueue()); - - await Assert.ThrowsAsync( - () => sut.PreviewMerge(task.Id, null, CancellationToken.None)); - } - - [Fact] - public async Task PreviewMerge_MissingWorkingDir_ThrowsClearError() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); - using (var ctx = _db.CreateContext()) - { - var row = await ctx.Lists.FindAsync(list.Id); - row!.WorkingDir = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}"); - await ctx.SaveChangesAsync(); - } - - var sut = BuildSut(CreateQueue()); - var ex = await Assert.ThrowsAsync( - () => sut.PreviewMerge(task.Id, null, CancellationToken.None)); - Assert.Contains("working directory", ex.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task PreviewMerge_VerifyCommandConfigured_RunsInScratchWorktreeAndDoesNotTouchTarget() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); - File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); - var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; - var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); - await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); - - using (var ctx = _db.CreateContext()) - await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" }); - - var git = new GitService(); - var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); - var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None); - var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") }; - var sut = BuildSut(CreateQueue(), fakeVerify); - - var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None); - - Assert.Equal(TaskMergeService.PreviewClean, result.Status); - Assert.Equal(0, result.VerifyExitCode); - Assert.Equal("dotnet build", fakeVerify.CapturedCommand); - Assert.NotEqual(list.WorkingDir, fakeVerify.CapturedWorkingDir); - Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None)); - Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None)); - } - - [Fact] - public async Task PreviewMerge_TargetChangedSameFileSinceFork_ReportsStaleFiles() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var repo = new GitRepoFixture(); - _repos.Add(repo); - GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); - - var listId = Guid.NewGuid().ToString(); - await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); - var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); - await CreateActiveWorktreeAsync(repo, task.Id, "shared.txt", "from branch\n"); - - // The target branch itself moved on and touched the same file after the fork point. - File.WriteAllText(Path.Combine(repo.RepoDir, "shared.txt"), "from main\n"); - GitRepoFixture.RunGit(repo.RepoDir, "add", "shared.txt"); - GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main also touched shared.txt"); - - var sut = BuildSut(CreateQueue()); - var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None); - - Assert.Equal(1, result.Behind); - Assert.NotNull(result.StaleFiles); - Assert.Contains("shared.txt", result.StaleFiles!); - } - - [Fact] - public async Task PreviewMerge_TargetBehindButUnrelatedFile_ReportsNoStaleFiles() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var repo = new GitRepoFixture(); - _repos.Add(repo); - GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); - - var listId = Guid.NewGuid().ToString(); - await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); - var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); - await CreateActiveWorktreeAsync(repo, task.Id, "branch-only.txt", "from branch\n"); - - // Target moved on (behind > 0) but touched an entirely different file -- a stale - // branch that still collides with nothing. - File.WriteAllText(Path.Combine(repo.RepoDir, "unrelated.txt"), "from main\n"); - GitRepoFixture.RunGit(repo.RepoDir, "add", "unrelated.txt"); - GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main touched something else"); - - var sut = BuildSut(CreateQueue()); - var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None); - - Assert.Equal(1, result.Behind); - Assert.NotNull(result.StaleFiles); - Assert.Empty(result.StaleFiles!); - } - - [Fact] - public async Task PreviewMerge_WorktreeLessHandlerTask_ReportsNoStaleFiles() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var repo = new GitRepoFixture(); - _repos.Add(repo); - var listId = Guid.NewGuid().ToString(); - await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); - var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); - - File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content"); - GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); - GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge"); - var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); - - using (var ctx = _db.CreateContext()) - { - var t = await ctx.Tasks.FindAsync(task.Id); - t!.HandlerBaseCommit = repo.BaseCommit; - t.HandlerHeadCommit = headCommit; - await ctx.SaveChangesAsync(); - } - - var sut = BuildSut(CreateQueue()); - var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None); - - Assert.NotNull(result.StaleFiles); - Assert.Empty(result.StaleFiles!); - } - - [Fact] - public async Task PreviewMergeSet_TwoTasksSharedFile_ReportsOverlap() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var repo = new GitRepoFixture(); - _repos.Add(repo); - GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); - - var listId = Guid.NewGuid().ToString(); - await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); - var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); - var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview); - - await CreateActiveWorktreeAsync(repo, taskA.Id, "shared.txt", "a change\n"); - await CreateActiveWorktreeAsync(repo, taskB.Id, "shared.txt", "b change\n"); - - var sut = BuildSut(CreateQueue()); - var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None); - - Assert.Equal(2, result.Tasks.Count); - Assert.All(result.Tasks, t => Assert.Null(t.Error)); - Assert.All(result.Tasks, t => Assert.Equal(TaskMergeService.PreviewClean, t.Status)); - var overlap = Assert.Single(result.Overlaps); - Assert.Equal("shared.txt", overlap.File); - Assert.Equal( - new[] { taskA.Id, taskB.Id }.OrderBy(x => x), - overlap.TaskIds.OrderBy(x => x)); - // Equal file sets overlap but neither is a PROPER subset of the other. - Assert.Empty(result.Subsets); - } - - [Fact] - public async Task PreviewMergeSet_TaskFilesAreProperSubsetOfAnother_ReportsSubset() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var repo = new GitRepoFixture(); - _repos.Add(repo); - GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); - - var listId = Guid.NewGuid().ToString(); - await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); - var subsetTask = await SeedTaskAsync(listId, "subset", TaskStatus.WaitingForReview); - var supersetTask = await SeedTaskAsync(listId, "superset", TaskStatus.WaitingForReview); - - await CreateActiveWorktreeAsync(repo, subsetTask.Id, "shared.txt", "shared change\n"); - - var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); - _worktreeCleanups.Add((repo.RepoDir, wtPath)); - var branch = $"claudedo/{supersetTask.Id[..8]}"; - GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit); - File.WriteAllText(Path.Combine(wtPath, "shared.txt"), "shared change too\n"); - File.WriteAllText(Path.Combine(wtPath, "extra.txt"), "extra\n"); - GitRepoFixture.RunGit(wtPath, "add", "-A"); - GitRepoFixture.RunGit(wtPath, "commit", "-m", "edit shared+extra"); - using (var ctx = _db.CreateContext()) - { - ctx.Worktrees.Add(new WorktreeEntity - { - TaskId = supersetTask.Id, Path = wtPath, BranchName = branch, - BaseCommit = repo.BaseCommit, - HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(), - State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, - }); - await ctx.SaveChangesAsync(); - } - - var sut = BuildSut(CreateQueue()); - var result = await sut.PreviewMergeSet([subsetTask.Id, supersetTask.Id], "main", cancellationToken: CancellationToken.None); - - var relation = Assert.Single(result.Subsets); - Assert.Equal(subsetTask.Id, relation.TaskId); - Assert.Equal(supersetTask.Id, relation.SupersetTaskId); - } - - [Fact] - public async Task PreviewMergeSet_SingleTaskId_ReturnsEmptyOverlap() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); - var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); - var sut = BuildSut(CreateQueue()); - - var result = await sut.PreviewMergeSet([task.Id], target, cancellationToken: CancellationToken.None); - - Assert.Single(result.Tasks); - Assert.Empty(result.Overlaps); - } - - [Fact] - public async Task PreviewMergeSet_ReportsProgressPerTask() - { - var listId = await SeedListAsync(); - var t1 = await SeedTaskAsync(listId, "a"); - var t2 = await SeedTaskAsync(listId, "b"); - var sut = BuildSut(CreateQueue()); - var progress = new SyncProgress(); - - // Neither task has a worktree -- each entry fails individually (PreviewUnavailable), but - // the loop still reports progress per task since a per-task InvalidOperationException - // doesn't abort the batch. - await sut.PreviewMergeSet( - [t1.Id, t2.Id], "main", cancellationToken: CancellationToken.None, progress: progress); - - Assert.Equal(2, progress.Reports.Count); - Assert.Equal("Previewing merges (1/2)", progress.Reports[0].Message); - Assert.Equal("Previewing merges (2/2)", progress.Reports[1].Message); - Assert.Equal(2, progress.Reports[1].Total); - } - - [Fact] - public async Task PreviewMergeSet_OneTaskWithoutWorktree_ReportsErrorWithoutAbortingOthers() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); - var listId2 = await SeedListAsync("no-worktree-list"); - var noWorktreeTask = await SeedTaskAsync(listId2, "b", TaskStatus.Idle); - var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); - var sut = BuildSut(CreateQueue()); - - var result = await sut.PreviewMergeSet([task.Id, noWorktreeTask.Id], target, cancellationToken: CancellationToken.None); - - Assert.Equal(2, result.Tasks.Count); - var ok = result.Tasks.Single(t => t.TaskId == task.Id); - Assert.Null(ok.Error); - Assert.Equal(TaskMergeService.PreviewClean, ok.Status); - var failed = result.Tasks.Single(t => t.TaskId == noWorktreeTask.Id); - Assert.NotNull(failed.Error); - Assert.Equal(TaskMergeService.PreviewUnavailable, failed.Status); - Assert.Empty(result.Overlaps); - } - - [Fact] - public async Task PreviewMergeSet_MixedEmptyAndNonEmpty_ReportsIsEmptyPerEntry() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var repo = new GitRepoFixture(); - _repos.Add(repo); - GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); - - var listId = Guid.NewGuid().ToString(); - await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); - var emptyTask = await SeedTaskAsync(listId, "empty", TaskStatus.WaitingForReview); - var fullTask = await SeedTaskAsync(listId, "full", TaskStatus.WaitingForReview); - - var emptyBranch = $"claudedo/{emptyTask.Id[..8]}"; - var emptyWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); - _worktreeCleanups.Add((repo.RepoDir, emptyWtPath)); - GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", emptyBranch, emptyWtPath, repo.BaseCommit); - using (var ctx = _db.CreateContext()) - { - ctx.Worktrees.Add(new WorktreeEntity - { - TaskId = emptyTask.Id, Path = emptyWtPath, BranchName = emptyBranch, - BaseCommit = repo.BaseCommit, - HeadCommit = repo.BaseCommit, - State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, - }); - await ctx.SaveChangesAsync(); - } - - await CreateActiveWorktreeAsync(repo, fullTask.Id, "full.txt", "content\n"); - - var sut = BuildSut(CreateQueue()); - var result = await sut.PreviewMergeSet([emptyTask.Id, fullTask.Id], "main", cancellationToken: CancellationToken.None); - - var emptyEntry = result.Tasks.Single(t => t.TaskId == emptyTask.Id); - var fullEntry = result.Tasks.Single(t => t.TaskId == fullTask.Id); - Assert.Null(emptyEntry.Error); - Assert.True(emptyEntry.IsEmpty); - Assert.Null(fullEntry.Error); - Assert.False(fullEntry.IsEmpty); - } - - [Fact] - public async Task PreviewMergeSet_DefaultRunVerifyFalse_NeverInvokesRunnerEvenWithCommandConfigured() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var repo = new GitRepoFixture(); - _repos.Add(repo); - GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); - var listId = Guid.NewGuid().ToString(); - await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); - using (var ctx = _db.CreateContext()) - await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" }); - var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); - await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n"); - - var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") }; - var sut = BuildSut(CreateQueue(), fakeVerify); - - var result = await sut.PreviewMergeSet([task.Id], "main", cancellationToken: CancellationToken.None); - - Assert.Null(result.Tasks.Single().VerifyExitCode); - Assert.Null(fakeVerify.CapturedCommand); - } - - [Fact] - public async Task PreviewMergeSet_RunVerifyTrue_InvokesRunnerPerTask() - { - if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - - var repo = new GitRepoFixture(); - _repos.Add(repo); - GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); - var listId = Guid.NewGuid().ToString(); - await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); - using (var ctx = _db.CreateContext()) - await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" }); - var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); - await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n"); - - var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") }; - var sut = BuildSut(CreateQueue(), fakeVerify); - - var result = await sut.PreviewMergeSet([task.Id], "main", runVerify: true, cancellationToken: CancellationToken.None); - - Assert.Equal(0, result.Tasks.Single().VerifyExitCode); - Assert.Equal("dotnet build", fakeVerify.CapturedCommand); - } - [Fact] public async Task AddTask_ReturnsAllocatedTaskNumber() { diff --git a/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs index 568c7375..683eec55 100644 --- a/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs @@ -57,14 +57,13 @@ public sealed class LifecycleMcpToolsTests : IDisposable var wtManager = new WorktreeManager(git, dbFactory, cfg, NullLogger.Instance); var state = TaskStateServiceBuilder.Build(dbFactory).State; var reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger.Instance); - var maintenance = new WorktreeMaintenanceService(dbFactory, git, NullLogger.Instance); var merge = new TaskMergeService(dbFactory, git, broadcaster, state, new VerifyCommandRunner(), NullLogger.Instance); var aggregator = new PlanningAggregator(dbFactory, git, NullLogger.Instance); var planningMerge = new PlanningMergeOrchestrator( dbFactory, merge, aggregator, broadcaster, git, state, NullLogger.Instance); var svc = new ExternalMcpService( _tasks, _lists, CreateQueue(dbFactory, wtManager, state, broadcaster), broadcaster, - state, git, dbFactory, maintenance, merge, planningMerge, + state, git, dbFactory, merge, planningMerge, new BaseDirtyChecker(git, NullLogger.Instance)); var reviewSubmission = new InteractiveReviewSubmissionService(dbFactory, git, state, wtManager); return new LifecycleMcpTools(_tasks, reset, svc, reviewSubmission); diff --git a/tests/ClaudeDo.Worker.Tests/External/_extract.py b/tests/ClaudeDo.Worker.Tests/External/_extract.py new file mode 100644 index 00000000..56a2d681 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/_extract.py @@ -0,0 +1,136 @@ +import re, sys, json + +path = r"C:\Private\.claudedo-worktrees\claude-do\1862e160-ffde-4a50-aa03-bb7251f56088\tests\ClaudeDo.Worker.Tests\External\ExternalMcpServiceTests.cs" +with open(path, encoding='utf-8') as f: + lines = f.readlines() + +# Find all [Fact] blocks: (start_idx, end_idx, name) where start_idx is the [Fact] line (0-based) +# and end_idx is the line with the closing brace (inclusive), 0-based. +blocks = {} +i = 0 +n = len(lines) +while i < n: + if lines[i].strip() == '[Fact]': + fact_start = i + # next line should be method signature + sig_line = lines[i+1] + m = re.search(r'public async Task (\w+)\(', sig_line) + assert m, f"no method name at line {i+2}: {sig_line}" + name = m.group(1) + # find opening brace - it's on the line after signature (possibly signature spans multiple lines) + j = i + 1 + # advance until we find a line that is just '{' (with indentation) + while lines[j].strip() != '{': + j += 1 + brace_line = j + depth = 0 + k = brace_line + while True: + depth += lines[k].count('{') - lines[k].count('}') + if depth == 0: + break + k += 1 + end_idx = k + blocks[name] = (fact_start, end_idx) + i = end_idx + 1 + else: + i += 1 + +print(f"Found {len(blocks)} [Fact] blocks", file=sys.stderr) + +merge_names = """MergeTask_NotDone_Throws +MergeTask_DryRun_DoesNotMerge +MergeTask_WaitingForReview_WithoutFlag_Throws +MergeTask_WaitingForReview_WithFlag_DryRun_Succeeds +MergeTask_WaitingForReview_WithFlag_MarksTaskDone +MergeTask_LeaveConflictsInTree_LeavesMarkersAndKeepsRepoMidMerge +MergeTask_ConflictWithoutLeaveFlag_AbortsCleanly +ContinueMerge_AfterResolvingMarkers_MergesAndSetsDone +ContinueMerge_SlowVerifyCommand_ReportsProgressWellBeforeIdleTimeout +ContinueMerge_MarkersStillPresent_ReportsConflicts +AbortMerge_RestoresCleanTreeAndKeepsWaitingForReview +ContinueMerge_NoMergeInProgress_Throws +AbortMerge_NoMergeInProgress_Throws +GetMergeConflicts_ReportsHunksWithStartLineAndDiff3Base +GetMergeConflicts_NoMergeInProgress_ReturnsEmpty +ResolveConflictHunk_OursTheirsAndText_ResolveEachHunkAndTrackRemainingCount +ResolveConflictHunk_LeavesOtherHunksUntouchedUntilContinueMerge +ResolveConflictHunk_UnknownFileOrOutOfRangeIndex_Throws +RevertMerge_NotMerged_Throws +RevertMerge_AfterCleanMerge_RevertsAndReturnsTaskToWaitingForReview +RevertMerge_ConflictingRevert_ReturnsRevertedFalseWithConflictsAndAbortsCleanly +ContinueMerge_ParentUnitMergeConflict_RoutesToOrchestratorAndCompletes +PreviewMerge_CleanMerge_ReturnsCleanStatusAndDoesNotChangeRepo +PreviewMerge_EmptyWorktree_ReturnsIsEmptyTrue +PreviewMerge_WorktreeLessHandlerTask_EmptyRange_ReturnsIsEmptyTrue +PreviewMerge_WorktreeLessHandlerTask_NonEmptyRange_ReturnsIsEmptyFalse +PreviewMerge_ConflictingBranches_ReturnsConflictStatusAndDoesNotChangeRepo +PreviewMerge_NoWorktree_Throws +PreviewMerge_MissingWorkingDir_ThrowsClearError +PreviewMerge_VerifyCommandConfigured_RunsInScratchWorktreeAndDoesNotTouchTarget +PreviewMerge_TargetChangedSameFileSinceFork_ReportsStaleFiles +PreviewMerge_TargetBehindButUnrelatedFile_ReportsNoStaleFiles +PreviewMerge_WorktreeLessHandlerTask_ReportsNoStaleFiles +PreviewMergeSet_TwoTasksSharedFile_ReportsOverlap +PreviewMergeSet_TaskFilesAreProperSubsetOfAnother_ReportsSubset +PreviewMergeSet_SingleTaskId_ReturnsEmptyOverlap +PreviewMergeSet_ReportsProgressPerTask +PreviewMergeSet_OneTaskWithoutWorktree_ReportsErrorWithoutAbortingOthers +PreviewMergeSet_MixedEmptyAndNonEmpty_ReportsIsEmptyPerEntry +PreviewMergeSet_DefaultRunVerifyFalse_NeverInvokesRunnerEvenWithCommandConfigured +PreviewMergeSet_RunVerifyTrue_InvokesRunnerPerTask""".split() + +worktree_names = """GetTaskWorktree_NoWorktree_Throws +GetTaskWorktree_ReturnsBranchAndBaseCommit +GetTaskWorktree_BeforeMerge_MergeCommitIsNull +GetTaskWorktree_AfterMerge_ExposesMergeCommit +GetTaskDiff_NoWorktree_Throws +GetTaskDiff_AfterCommit_ListsChangedFile +GetTaskDiff_WithPathsFilter_ReturnsOnlyRequestedFile +GetTaskDiff_StatMode_WithPathsFilter_ReturnsOnlyRequestedFile +GetTaskDiff_WorktreeLessHandlerTask_UsesHandlerCommitRangeOverListWorkingDir +GetTaskDiff_WorktreeLessHandlerTask_StatMode_UsesHandlerCommitRange +GetTaskDiff_ReportsProgressWhileDiffing +ListWorktrees_Empty_ReturnsEmpty +ListWorktrees_ReturnsCreatedWorktree +ListWorktrees_ReportsProgressWhileSurveying +CleanupTaskWorktree_NotFound_Throws +CleanupTaskWorktree_Running_Throws +CleanupTaskWorktree_CleanWorktree_Removes +CleanupTaskWorktree_ReportsProgressWhileRemoving""".split() + +missing_merge = [n for n in merge_names if n not in blocks] +missing_worktree = [n for n in worktree_names if n not in blocks] +print("missing merge:", missing_merge, file=sys.stderr) +print("missing worktree:", missing_worktree, file=sys.stderr) +overlap = set(merge_names) & set(worktree_names) +print("overlap:", overlap, file=sys.stderr) + +def extract(names): + out = [] + for name in names: + s, e = blocks[name] + out.append(''.join(lines[s:e+1])) + return out + +with open(r"C:\Private\.claudedo-worktrees\claude-do\1862e160-ffde-4a50-aa03-bb7251f56088\tests\ClaudeDo.Worker.Tests\External\_merge_bodies.txt", 'w', encoding='utf-8') as f: + f.write('\n'.join(extract(merge_names))) + +with open(r"C:\Private\.claudedo-worktrees\claude-do\1862e160-ffde-4a50-aa03-bb7251f56088\tests\ClaudeDo.Worker.Tests\External\_worktree_bodies.txt", 'w', encoding='utf-8') as f: + f.write('\n'.join(extract(worktree_names))) + +# Build set of all line ranges to remove (sorted), then write remainder file for inspection +all_names = merge_names + worktree_names +ranges = sorted([blocks[n] for n in all_names]) +# merge overlapping/adjacent not needed; just mark lines +remove_lines = set() +for s, e in ranges: + for x in range(s, e+1): + remove_lines.add(x) + +remainder = [lines[i] for i in range(n) if i not in remove_lines] +with open(r"C:\Private\.claudedo-worktrees\claude-do\1862e160-ffde-4a50-aa03-bb7251f56088\tests\ClaudeDo.Worker.Tests\External\_remainder.cs", 'w', encoding='utf-8') as f: + f.writelines(remainder) + +print(f"Total lines removed: {len(remove_lines)}", file=sys.stderr) +print(f"Remainder line count: {len(remainder)}", file=sys.stderr) diff --git a/tests/ClaudeDo.Worker.Tests/External/_finalize_remainder.py b/tests/ClaudeDo.Worker.Tests/External/_finalize_remainder.py new file mode 100644 index 00000000..7519fc72 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/_finalize_remainder.py @@ -0,0 +1,54 @@ +import re + +path = r"C:\Private\.claudedo-worktrees\claude-do\1862e160-ffde-4a50-aa03-bb7251f56088\tests\ClaudeDo.Worker.Tests\External\_remainder.cs" +with open(path, encoding='utf-8') as f: + lines = f.readlines() + +def idx(marker, start=0): + for i in range(start, len(lines)): + if marker in lines[i]: + return i + raise AssertionError(f"marker not found: {marker!r}") + +# Each deletion described as (start_marker, end_marker_exclusive) -- delete from the line +# containing start_marker up to (but not including) the line containing end_marker. +deletions = [ + ("// ── GetTaskWorktree ──", "// ── GetTaskConfig ──"), + ("// ── MergeTask allowWaitingForReview ──", "// ── leaveConflictsInTree ──"), + ("// Three edits spread far enough apart", "[Fact]"), # multi-hunk helpers, up to next [Fact] + ("// ── continue_merge / abort_merge ──", "// ── PreviewMerge / PreviewMergeSet ──"), + ("private async Task CreateActiveWorktreeAsync(", "[Fact]"), +] + +# Apply deletions from bottom to top so earlier indices stay valid. +spans = [] +for start_marker, end_marker in deletions: + s = idx(start_marker) + e = idx(end_marker, s + 1) + spans.append((s, e)) + +spans.sort(reverse=True) +for s, e in spans: + del lines[s:e] + +text = ''.join(lines) + +# Drop the now-unused `maintenance` local + ctor arg in BuildSut(QueueService, IVerifyCommandRunner). +before = text +text = text.replace( + " var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger.Instance);\n", + "", 1) +assert text != before, "maintenance local not removed" +before = text +text = text.replace( + " git, factory, maintenance, merge, planningMerge,\n", + " git, factory, merge, planningMerge,\n", 1) +assert text != before, "maintenance ctor arg not removed" + +# Collapse any remaining runs of 2+ blank lines down to a single blank line (cosmetic only). +text = re.sub(r'\n{3,}', '\n\n', text) + +with open(path, 'w', encoding='utf-8') as f: + f.write(text) + +print("final line count:", len(text.splitlines())) diff --git a/tests/ClaudeDo.Worker.Tests/External/_merge_bodies.txt b/tests/ClaudeDo.Worker.Tests/External/_merge_bodies.txt new file mode 100644 index 00000000..53ef1d10 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/_merge_bodies.txt @@ -0,0 +1,996 @@ + [Fact] + public async Task MergeTask_NotDone_Throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var sut = BuildSut(CreateQueue()); + + var ex = await Assert.ThrowsAsync( + () => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None)); + Assert.Contains("Done", ex.Message); + } + + [Fact] + public async Task MergeTask_DryRun_DoesNotMerge() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done); + var sut = BuildSut(CreateQueue()); + + var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, cancellationToken: CancellationToken.None); + + Assert.False(result.Merged); + Assert.Null(result.MergeCommit); + } + + [Fact] + public async Task MergeTask_WaitingForReview_WithoutFlag_Throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); + var sut = BuildSut(CreateQueue()); + + var ex = await Assert.ThrowsAsync( + () => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None)); + Assert.Contains("Done", ex.Message); + } + + [Fact] + public async Task MergeTask_WaitingForReview_WithFlag_DryRun_Succeeds() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); + var sut = BuildSut(CreateQueue()); + + var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, allowWaitingForReview: true, cancellationToken: CancellationToken.None); + + Assert.False(result.Merged); + Assert.Null(result.MergeCommit); + } + + [Fact] + public async Task MergeTask_WaitingForReview_WithFlag_MarksTaskDone() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); + File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); + var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; + var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); + await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); + + var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var result = await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, cancellationToken: CancellationToken.None); + + Assert.True(result.Merged); + var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Done, reloaded!.Status); + } + + [Fact] + public async Task MergeTask_LeaveConflictsInTree_LeavesMarkersAndKeepsRepoMidMerge() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); + var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!); + var sut = BuildSut(CreateQueue()); + + var result = await sut.MergeTask(task.Id, target, true, dryRun: false, + allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); + + Assert.False(result.Merged); + Assert.True(result.ConflictsInTree); + Assert.Equal(list.WorkingDir, result.RepoPath); + Assert.Contains("README.md", result.Conflicts); + Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"))); + Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!)); + var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status); + + GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); + } + + [Fact] + public async Task MergeTask_ConflictWithoutLeaveFlag_AbortsCleanly() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); + var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!); + var sut = BuildSut(CreateQueue()); + + var result = await sut.MergeTask(task.Id, target, true, dryRun: false, + allowWaitingForReview: true, leaveConflictsInTree: false, cancellationToken: CancellationToken.None); + + Assert.False(result.Merged); + Assert.False(result.ConflictsInTree); + Assert.Null(result.RepoPath); + Assert.Contains("README.md", result.Conflicts); + Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"))); + Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!)); + } + + [Fact] + public async Task ContinueMerge_AfterResolvingMarkers_MergesAndSetsDone() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); + var sut = BuildSut(CreateQueue()); + await sut.ReviewTask(task.Id, "approve", null, null, + leaveConflictsInTree: true, cancellationToken: CancellationToken.None); + + // Resolve the conflict on disk and stage it. + File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n"); + GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md"); + + var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None); + + Assert.True(result.Merged); + Assert.Equal("Done", result.TaskStatus); + Assert.Empty(result.Conflicts); + Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!)); + } + + [Fact] + public async Task ContinueMerge_SlowVerifyCommand_ReportsProgressWellBeforeIdleTimeout() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var original = TaskMergeService.ProgressReportInterval; + TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50); + try + { + var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); + var plainSut = BuildSut(CreateQueue()); + await plainSut.ReviewTask(task.Id, "approve", null, null, + leaveConflictsInTree: true, cancellationToken: CancellationToken.None); + + File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n"); + GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md"); + + using (var ctx = _db.CreateContext()) + await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" }); + + var fakeVerify = new FakeVerifyCommandRunner + { + Result = new VerifyCommandResult(0, false, "ok"), + Delay = TimeSpan.FromMilliseconds(300), + }; + var sut = BuildSut(CreateQueue(), fakeVerify); + + var reports = new List(); + var progress = new Progress(reports.Add); + + var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None, progress: progress); + + Assert.True(result.Merged); + Assert.Equal("Done", result.TaskStatus); + await Task.Delay(200); + Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running")); + } + finally + { + TaskMergeService.ProgressReportInterval = original; + } + } + + [Fact] + public async Task ContinueMerge_MarkersStillPresent_ReportsConflicts() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); + var sut = BuildSut(CreateQueue()); + await sut.ReviewTask(task.Id, "approve", null, null, + leaveConflictsInTree: true, cancellationToken: CancellationToken.None); + + // Markers never resolved — continue must refuse and list the files. + var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None); + + Assert.False(result.Merged); + Assert.Contains("README.md", result.Conflicts); + Assert.Equal(list.WorkingDir, result.RepoPath); + Assert.Equal("WaitingForReview", result.TaskStatus); + Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!)); + + GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); + } + + [Fact] + public async Task AbortMerge_RestoresCleanTreeAndKeepsWaitingForReview() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); + var sut = BuildSut(CreateQueue()); + await sut.ReviewTask(task.Id, "approve", null, null, + leaveConflictsInTree: true, cancellationToken: CancellationToken.None); + + var dto = await sut.AbortMerge(task.Id, CancellationToken.None); + + Assert.Equal("WaitingForReview", dto.Status); + Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"))); + Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!)); + } + + [Fact] + public async Task ContinueMerge_NoMergeInProgress_Throws() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); + var sut = BuildSut(CreateQueue()); + + var ex = await Assert.ThrowsAsync( + () => sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None)); + Assert.Contains("mid-merge", ex.Message); + } + + [Fact] + public async Task AbortMerge_NoMergeInProgress_Throws() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); + var sut = BuildSut(CreateQueue()); + + var ex = await Assert.ThrowsAsync( + () => sut.AbortMerge(task.Id, CancellationToken.None)); + Assert.Contains("mid-merge", ex.Message); + } + + [Fact] + public async Task GetMergeConflicts_ReportsHunksWithStartLineAndDiff3Base() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); + var sut = BuildSut(CreateQueue()); + await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); + + var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None); + + Assert.Equal(1, result.RemainingHunks); + var file = Assert.Single(result.Files); + Assert.Equal("README.md", file.Path); + Assert.False(file.IsBinary); + var hunk = Assert.Single(file.Hunks); + Assert.Equal(0, hunk.Index); + Assert.True(hunk.StartLine >= 1); + Assert.Equal("# from main", hunk.Ours.TrimEnd('\r', '\n')); + Assert.Equal("# from worktree", hunk.Theirs.TrimEnd('\r', '\n')); + Assert.NotEmpty(result.ConflictStyleNote); + + GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); + } + + [Fact] + public async Task GetMergeConflicts_NoMergeInProgress_ReturnsEmpty() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); + var sut = BuildSut(CreateQueue()); + + var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None); + + Assert.Equal(0, result.RemainingHunks); + Assert.Empty(result.Files); + } + + [Fact] + public async Task ResolveConflictHunk_OursTheirsAndText_ResolveEachHunkAndTrackRemainingCount() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync(); + var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!); + var sut = BuildSut(CreateQueue()); + + var merge = await sut.MergeTask(task.Id, target, true, dryRun: false, + allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); + Assert.True(merge.ConflictsInTree); + + var before = await sut.GetMergeConflicts(task.Id, CancellationToken.None); + Assert.Equal(3, before.RemainingHunks); + Assert.Equal(3, Assert.Single(before.Files).Hunks.Count); + + // Resolve hunk 0 three times in a row: each resolution removes a hunk from the front, so the + // next still-open hunk always slides into index 0. + var r1 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None); + Assert.Equal(2, r1.RemainingHunks); + + var r2 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "theirs", CancellationToken.None); + Assert.Equal(1, r2.RemainingHunks); + + var r3 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "CUSTOM-15\n", CancellationToken.None); + Assert.Equal(0, r3.RemainingHunks); + + var finalConflicts = await sut.GetMergeConflicts(task.Id, CancellationToken.None); + Assert.Equal(0, finalConflicts.RemainingHunks); + + var continueResult = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None); + Assert.True(continueResult.Merged); + Assert.Equal("Done", continueResult.TaskStatus); + + var mergedText = File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")); + Assert.Contains("MAIN-3", mergedText); // hunk 1 resolved "ours" (the target branch) + Assert.Contains("WT-9", mergedText); // hunk 2 resolved "theirs" (the incoming branch) + Assert.Contains("CUSTOM-15", mergedText); // hunk 3 resolved to literal text + Assert.DoesNotContain("<<<<<<<", mergedText); + } + + [Fact] + public async Task ResolveConflictHunk_LeavesOtherHunksUntouchedUntilContinueMerge() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync(); + var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!); + var sut = BuildSut(CreateQueue()); + await sut.MergeTask(task.Id, target, true, dryRun: false, + allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); + + // Resolve only one of the three hunks. + await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None); + + // continue_merge must still refuse: writing one hunk must not have staged the file as resolved. + var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None); + + Assert.False(result.Merged); + Assert.Contains("README.md", result.Conflicts); + Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!)); + + GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); + } + + [Fact] + public async Task ResolveConflictHunk_UnknownFileOrOutOfRangeIndex_Throws() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); + var sut = BuildSut(CreateQueue()); + await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); + + await Assert.ThrowsAsync( + () => sut.ResolveConflictHunk(task.Id, "not-a-real-file.md", 0, "ours", CancellationToken.None)); + await Assert.ThrowsAsync( + () => sut.ResolveConflictHunk(task.Id, "README.md", 5, "ours", CancellationToken.None)); + + GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); + } + + [Fact] + public async Task RevertMerge_NotMerged_Throws() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done); + var sut = BuildSut(CreateQueue()); + + var ex = await Assert.ThrowsAsync( + () => sut.RevertMerge(task.Id, "main", CancellationToken.None)); + Assert.Contains("expected Merged", ex.Message); + } + + [Fact] + public async Task RevertMerge_AfterCleanMerge_RevertsAndReturnsTaskToWaitingForReview() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done); + File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "new\n"); + GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A"); + GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file"); + + var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None); + Assert.True(File.Exists(Path.Combine(list.WorkingDir!, "added.txt"))); + + var result = await sut.RevertMerge(task.Id, target, CancellationToken.None); + + Assert.True(result.Reverted); + Assert.False(string.IsNullOrWhiteSpace(result.RevertCommit)); + Assert.Empty(result.Conflicts); + Assert.False(File.Exists(Path.Combine(list.WorkingDir!, "added.txt"))); + + var reloaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status); + } + + [Fact] + public async Task RevertMerge_ConflictingRevert_ReturnsRevertedFalseWithConflictsAndAbortsCleanly() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done); + File.WriteAllText(Path.Combine(wt.WorktreePath, "README.md"), "# test repo\nfeature\n"); + GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A"); + GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: append feature line"); + + var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None); + + // A later commit on main edits the exact line the merge introduced, so the revert conflicts. + File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# test repo\npost-merge edit\n"); + GitRepoFixture.RunGit(list.WorkingDir!, "add", "-A"); + GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "chore: edit the same line"); + var headBefore = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim(); + + var result = await sut.RevertMerge(task.Id, target, CancellationToken.None); + + Assert.False(result.Reverted); + Assert.Contains("README.md", result.Conflicts); + Assert.False(await new GitService().IsMidRevertAsync(list.WorkingDir!)); + Assert.Equal(headBefore, GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim()); + + var reloaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Done, reloaded!.Status); + } + + [Fact] + public async Task ContinueMerge_ParentUnitMergeConflict_RoutesToOrchestratorAndCompletes() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = new GitRepoFixture(); + _repos.Add(repo); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + + // main edits README.md so the child's edit conflicts. + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "README.md"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit"); + + var listId = Guid.NewGuid().ToString(); + var parentId = Guid.NewGuid().ToString(); + var childId = Guid.NewGuid().ToString(); + using (var ctx = _db.CreateContext()) + { + ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow, + Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized, Number = ++_numberSeed }); + ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow, + ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed }); + + var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); + _worktreeCleanups.Add((repo.RepoDir, wtPath)); + var branch = $"claudedo/{childId[..8]}"; + GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit); + File.WriteAllText(Path.Combine(wtPath, "README.md"), "# child change\n"); + GitRepoFixture.RunGit(wtPath, "add", "README.md"); + GitRepoFixture.RunGit(wtPath, "commit", "-m", "child edit"); + ctx.Worktrees.Add(new WorktreeEntity + { + TaskId = childId, Path = wtPath, BranchName = branch, + BaseCommit = repo.BaseCommit, + HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(), + State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + + var sut = BuildSut(CreateQueue()); + var review = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None); + Assert.Equal(TaskMergeService.StatusConflict, review.MergeStatus); + Assert.Equal(repo.RepoDir, review.RepoPath); + Assert.Contains("continue_merge", review.MergeMessage); + + // Resolve the conflict on disk. + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n"); + + var result = await sut.ContinueMerge(parentId, cancellationToken: CancellationToken.None); + + Assert.True(result.Merged); + Assert.Equal("Done", result.TaskStatus); + Assert.Empty(result.Conflicts); + Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir)); + using var verify = _db.CreateContext(); + Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State); + } + + [Fact] + public async Task PreviewMerge_CleanMerge_ReturnsCleanStatusAndDoesNotChangeRepo() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); + File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); + var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; + var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); + await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); + + var git = new GitService(); + var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); + var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None); + + Assert.Equal(TaskMergeService.PreviewClean, result.Status); + Assert.Empty(result.ConflictFiles); + Assert.Equal(1, result.ChangedFileCount); + Assert.Equal(0, result.Behind); + Assert.False(result.IsEmpty); + Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None)); + Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None)); + } + + [Fact] + public async Task PreviewMerge_EmptyWorktree_ReturnsIsEmptyTrue() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + // Worktree created but nothing committed beyond base -- e.g. a child that reported + // CLAUDEDO_BLOCKED before writing any code. + var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); + var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None); + + Assert.Equal(TaskMergeService.PreviewClean, result.Status); + Assert.Equal(0, result.ChangedFileCount); + Assert.True(result.IsEmpty); + } + + [Fact] + public async Task PreviewMerge_WorktreeLessHandlerTask_EmptyRange_ReturnsIsEmptyTrue() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = new GitRepoFixture(); + _repos.Add(repo); + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); + // Worktree-less list-handler host task whose handler range never actually committed + // anything: HandlerBaseCommit == HandlerHeadCommit, not "no worktree -> unknown". + using (var ctx = _db.CreateContext()) + { + var t = await ctx.Tasks.FindAsync(task.Id); + t!.HandlerBaseCommit = repo.BaseCommit; + t.HandlerHeadCommit = repo.BaseCommit; + await ctx.SaveChangesAsync(); + } + + var sut = BuildSut(CreateQueue()); + var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None); + + Assert.Equal(TaskMergeService.PreviewClean, result.Status); + Assert.Equal(0, result.ChangedFileCount); + Assert.True(result.IsEmpty); + } + + [Fact] + public async Task PreviewMerge_WorktreeLessHandlerTask_NonEmptyRange_ReturnsIsEmptyFalse() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = new GitRepoFixture(); + _repos.Add(repo); + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); + + File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge"); + var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); + + using (var ctx = _db.CreateContext()) + { + var t = await ctx.Tasks.FindAsync(task.Id); + t!.HandlerBaseCommit = repo.BaseCommit; + t.HandlerHeadCommit = headCommit; + await ctx.SaveChangesAsync(); + } + + var sut = BuildSut(CreateQueue()); + var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None); + + Assert.Equal(TaskMergeService.PreviewClean, result.Status); + Assert.Equal(1, result.ChangedFileCount); + Assert.False(result.IsEmpty); + } + + [Fact] + public async Task PreviewMerge_ConflictingBranches_ReturnsConflictStatusAndDoesNotChangeRepo() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); + var git = new GitService(); + var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); + var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None); + + Assert.Equal(TaskMergeService.PreviewConflict, result.Status); + Assert.Contains("README.md", result.ConflictFiles); + Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None)); + Assert.False(await git.IsMidMergeAsync(list.WorkingDir!)); + Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None)); + } + + [Fact] + public async Task PreviewMerge_NoWorktree_Throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = BuildSut(CreateQueue()); + + await Assert.ThrowsAsync( + () => sut.PreviewMerge(task.Id, null, CancellationToken.None)); + } + + [Fact] + public async Task PreviewMerge_MissingWorkingDir_ThrowsClearError() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); + using (var ctx = _db.CreateContext()) + { + var row = await ctx.Lists.FindAsync(list.Id); + row!.WorkingDir = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}"); + await ctx.SaveChangesAsync(); + } + + var sut = BuildSut(CreateQueue()); + var ex = await Assert.ThrowsAsync( + () => sut.PreviewMerge(task.Id, null, CancellationToken.None)); + Assert.Contains("working directory", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task PreviewMerge_VerifyCommandConfigured_RunsInScratchWorktreeAndDoesNotTouchTarget() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); + File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); + var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; + var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); + await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); + + using (var ctx = _db.CreateContext()) + await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" }); + + var git = new GitService(); + var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); + var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None); + var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") }; + var sut = BuildSut(CreateQueue(), fakeVerify); + + var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None); + + Assert.Equal(TaskMergeService.PreviewClean, result.Status); + Assert.Equal(0, result.VerifyExitCode); + Assert.Equal("dotnet build", fakeVerify.CapturedCommand); + Assert.NotEqual(list.WorkingDir, fakeVerify.CapturedWorkingDir); + Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None)); + Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None)); + } + + [Fact] + public async Task PreviewMerge_TargetChangedSameFileSinceFork_ReportsStaleFiles() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = new GitRepoFixture(); + _repos.Add(repo); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); + await CreateActiveWorktreeAsync(repo, task.Id, "shared.txt", "from branch\n"); + + // The target branch itself moved on and touched the same file after the fork point. + File.WriteAllText(Path.Combine(repo.RepoDir, "shared.txt"), "from main\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "shared.txt"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main also touched shared.txt"); + + var sut = BuildSut(CreateQueue()); + var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None); + + Assert.Equal(1, result.Behind); + Assert.NotNull(result.StaleFiles); + Assert.Contains("shared.txt", result.StaleFiles!); + } + + [Fact] + public async Task PreviewMerge_TargetBehindButUnrelatedFile_ReportsNoStaleFiles() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = new GitRepoFixture(); + _repos.Add(repo); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); + await CreateActiveWorktreeAsync(repo, task.Id, "branch-only.txt", "from branch\n"); + + // Target moved on (behind > 0) but touched an entirely different file -- a stale + // branch that still collides with nothing. + File.WriteAllText(Path.Combine(repo.RepoDir, "unrelated.txt"), "from main\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "unrelated.txt"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main touched something else"); + + var sut = BuildSut(CreateQueue()); + var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None); + + Assert.Equal(1, result.Behind); + Assert.NotNull(result.StaleFiles); + Assert.Empty(result.StaleFiles!); + } + + [Fact] + public async Task PreviewMerge_WorktreeLessHandlerTask_ReportsNoStaleFiles() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = new GitRepoFixture(); + _repos.Add(repo); + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); + + File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge"); + var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); + + using (var ctx = _db.CreateContext()) + { + var t = await ctx.Tasks.FindAsync(task.Id); + t!.HandlerBaseCommit = repo.BaseCommit; + t.HandlerHeadCommit = headCommit; + await ctx.SaveChangesAsync(); + } + + var sut = BuildSut(CreateQueue()); + var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None); + + Assert.NotNull(result.StaleFiles); + Assert.Empty(result.StaleFiles!); + } + + [Fact] + public async Task PreviewMergeSet_TwoTasksSharedFile_ReportsOverlap() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = new GitRepoFixture(); + _repos.Add(repo); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); + var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview); + + await CreateActiveWorktreeAsync(repo, taskA.Id, "shared.txt", "a change\n"); + await CreateActiveWorktreeAsync(repo, taskB.Id, "shared.txt", "b change\n"); + + var sut = BuildSut(CreateQueue()); + var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None); + + Assert.Equal(2, result.Tasks.Count); + Assert.All(result.Tasks, t => Assert.Null(t.Error)); + Assert.All(result.Tasks, t => Assert.Equal(TaskMergeService.PreviewClean, t.Status)); + var overlap = Assert.Single(result.Overlaps); + Assert.Equal("shared.txt", overlap.File); + Assert.Equal( + new[] { taskA.Id, taskB.Id }.OrderBy(x => x), + overlap.TaskIds.OrderBy(x => x)); + // Equal file sets overlap but neither is a PROPER subset of the other. + Assert.Empty(result.Subsets); + } + + [Fact] + public async Task PreviewMergeSet_TaskFilesAreProperSubsetOfAnother_ReportsSubset() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = new GitRepoFixture(); + _repos.Add(repo); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + var subsetTask = await SeedTaskAsync(listId, "subset", TaskStatus.WaitingForReview); + var supersetTask = await SeedTaskAsync(listId, "superset", TaskStatus.WaitingForReview); + + await CreateActiveWorktreeAsync(repo, subsetTask.Id, "shared.txt", "shared change\n"); + + var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); + _worktreeCleanups.Add((repo.RepoDir, wtPath)); + var branch = $"claudedo/{supersetTask.Id[..8]}"; + GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit); + File.WriteAllText(Path.Combine(wtPath, "shared.txt"), "shared change too\n"); + File.WriteAllText(Path.Combine(wtPath, "extra.txt"), "extra\n"); + GitRepoFixture.RunGit(wtPath, "add", "-A"); + GitRepoFixture.RunGit(wtPath, "commit", "-m", "edit shared+extra"); + using (var ctx = _db.CreateContext()) + { + ctx.Worktrees.Add(new WorktreeEntity + { + TaskId = supersetTask.Id, Path = wtPath, BranchName = branch, + BaseCommit = repo.BaseCommit, + HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(), + State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + + var sut = BuildSut(CreateQueue()); + var result = await sut.PreviewMergeSet([subsetTask.Id, supersetTask.Id], "main", cancellationToken: CancellationToken.None); + + var relation = Assert.Single(result.Subsets); + Assert.Equal(subsetTask.Id, relation.TaskId); + Assert.Equal(supersetTask.Id, relation.SupersetTaskId); + } + + [Fact] + public async Task PreviewMergeSet_SingleTaskId_ReturnsEmptyOverlap() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); + var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var result = await sut.PreviewMergeSet([task.Id], target, cancellationToken: CancellationToken.None); + + Assert.Single(result.Tasks); + Assert.Empty(result.Overlaps); + } + + [Fact] + public async Task PreviewMergeSet_ReportsProgressPerTask() + { + var listId = await SeedListAsync(); + var t1 = await SeedTaskAsync(listId, "a"); + var t2 = await SeedTaskAsync(listId, "b"); + var sut = BuildSut(CreateQueue()); + var progress = new SyncProgress(); + + // Neither task has a worktree -- each entry fails individually (PreviewUnavailable), but + // the loop still reports progress per task since a per-task InvalidOperationException + // doesn't abort the batch. + await sut.PreviewMergeSet( + [t1.Id, t2.Id], "main", cancellationToken: CancellationToken.None, progress: progress); + + Assert.Equal(2, progress.Reports.Count); + Assert.Equal("Previewing merges (1/2)", progress.Reports[0].Message); + Assert.Equal("Previewing merges (2/2)", progress.Reports[1].Message); + Assert.Equal(2, progress.Reports[1].Total); + } + + [Fact] + public async Task PreviewMergeSet_OneTaskWithoutWorktree_ReportsErrorWithoutAbortingOthers() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); + var listId2 = await SeedListAsync("no-worktree-list"); + var noWorktreeTask = await SeedTaskAsync(listId2, "b", TaskStatus.Idle); + var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var result = await sut.PreviewMergeSet([task.Id, noWorktreeTask.Id], target, cancellationToken: CancellationToken.None); + + Assert.Equal(2, result.Tasks.Count); + var ok = result.Tasks.Single(t => t.TaskId == task.Id); + Assert.Null(ok.Error); + Assert.Equal(TaskMergeService.PreviewClean, ok.Status); + var failed = result.Tasks.Single(t => t.TaskId == noWorktreeTask.Id); + Assert.NotNull(failed.Error); + Assert.Equal(TaskMergeService.PreviewUnavailable, failed.Status); + Assert.Empty(result.Overlaps); + } + + [Fact] + public async Task PreviewMergeSet_MixedEmptyAndNonEmpty_ReportsIsEmptyPerEntry() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = new GitRepoFixture(); + _repos.Add(repo); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + var emptyTask = await SeedTaskAsync(listId, "empty", TaskStatus.WaitingForReview); + var fullTask = await SeedTaskAsync(listId, "full", TaskStatus.WaitingForReview); + + var emptyBranch = $"claudedo/{emptyTask.Id[..8]}"; + var emptyWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); + _worktreeCleanups.Add((repo.RepoDir, emptyWtPath)); + GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", emptyBranch, emptyWtPath, repo.BaseCommit); + using (var ctx = _db.CreateContext()) + { + ctx.Worktrees.Add(new WorktreeEntity + { + TaskId = emptyTask.Id, Path = emptyWtPath, BranchName = emptyBranch, + BaseCommit = repo.BaseCommit, + HeadCommit = repo.BaseCommit, + State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + + await CreateActiveWorktreeAsync(repo, fullTask.Id, "full.txt", "content\n"); + + var sut = BuildSut(CreateQueue()); + var result = await sut.PreviewMergeSet([emptyTask.Id, fullTask.Id], "main", cancellationToken: CancellationToken.None); + + var emptyEntry = result.Tasks.Single(t => t.TaskId == emptyTask.Id); + var fullEntry = result.Tasks.Single(t => t.TaskId == fullTask.Id); + Assert.Null(emptyEntry.Error); + Assert.True(emptyEntry.IsEmpty); + Assert.Null(fullEntry.Error); + Assert.False(fullEntry.IsEmpty); + } + + [Fact] + public async Task PreviewMergeSet_DefaultRunVerifyFalse_NeverInvokesRunnerEvenWithCommandConfigured() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = new GitRepoFixture(); + _repos.Add(repo); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + using (var ctx = _db.CreateContext()) + await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" }); + var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); + await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n"); + + var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") }; + var sut = BuildSut(CreateQueue(), fakeVerify); + + var result = await sut.PreviewMergeSet([task.Id], "main", cancellationToken: CancellationToken.None); + + Assert.Null(result.Tasks.Single().VerifyExitCode); + Assert.Null(fakeVerify.CapturedCommand); + } + + [Fact] + public async Task PreviewMergeSet_RunVerifyTrue_InvokesRunnerPerTask() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = new GitRepoFixture(); + _repos.Add(repo); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + using (var ctx = _db.CreateContext()) + await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" }); + var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); + await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n"); + + var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") }; + var sut = BuildSut(CreateQueue(), fakeVerify); + + var result = await sut.PreviewMergeSet([task.Id], "main", runVerify: true, cancellationToken: CancellationToken.None); + + Assert.Equal(0, result.Tasks.Single().VerifyExitCode); + Assert.Equal("dotnet build", fakeVerify.CapturedCommand); + } diff --git a/tests/ClaudeDo.Worker.Tests/External/_remainder.cs b/tests/ClaudeDo.Worker.Tests/External/_remainder.cs new file mode 100644 index 00000000..481bd52e --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/_remainder.cs @@ -0,0 +1,1544 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Git; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Lifecycle; +using ClaudeDo.Worker.Planning; +using ClaudeDo.Worker.Queue; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using ClaudeDo.Worker.Tests.Services; +using ClaudeDo.Worker.Usage; +using ClaudeDo.Worker.Worktrees; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.External; + +internal sealed class ExternalRecordingHubClients : IHubClients +{ + public ExternalRecordingClientProxy Proxy { get; } = new(); + public IClientProxy All => Proxy; + public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => Proxy; + public IClientProxy Client(string connectionId) => Proxy; + public IClientProxy Clients(IReadOnlyList connectionIds) => Proxy; + public IClientProxy Group(string groupName) => Proxy; + public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => Proxy; + public IClientProxy Groups(IReadOnlyList groupNames) => Proxy; + public IClientProxy User(string userId) => Proxy; + public IClientProxy Users(IReadOnlyList userIds) => Proxy; +} + +internal sealed class ExternalRecordingClientProxy : IClientProxy +{ + public List<(string Method, object?[] Args)> Calls { get; } = new(); + public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) + { + Calls.Add((method, args)); + return Task.CompletedTask; + } +} + +internal sealed class ExternalFakeHubContext : IHubContext +{ + public ExternalRecordingHubClients RecordingClients { get; } = new(); + public IHubClients Clients => RecordingClients; + public IGroupManager Groups => throw new NotImplementedException(); +} + +// A synchronously-collecting IProgress: Progress marshals through the SynchronizationContext +// captured at construction, which is unreliable to assert on immediately in a test. +file sealed class SyncProgress : IProgress +{ + public readonly List Reports = new(); + public void Report(T value) => Reports.Add(value); +} + +public sealed class ExternalMcpServiceTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + private readonly ExternalFakeHubContext _hub = new(); + private readonly HubBroadcaster _broadcaster; + private readonly List _repos = new(); + private readonly List<(string repoDir, string wtPath)> _worktreeCleanups = new(); + private int _numberSeed; + + private static bool GitAvailable => GitRepoFixture.IsGitAvailable(); + + public ExternalMcpServiceTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + _broadcaster = new HubBroadcaster(_hub); + } + + public void Dispose() + { + foreach (var (repoDir, wtPath) in _worktreeCleanups) + { + try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { } + } + foreach (var r in _repos) r.Dispose(); + _ctx.Dispose(); + _db.Dispose(); + } + + private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedWorktreeAsync( + TaskStatus status = TaskStatus.Done) + { + var repo = new GitRepoFixture(); + _repos.Add(repo); + + var listId = Guid.NewGuid().ToString(); + var list = new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }; + await _lists.AddAsync(list); + var task = await SeedTaskAsync(listId, status: status); + + var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; + var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); + var wt = await mgr.CreateAsync(task, list, CancellationToken.None); + _worktreeCleanups.Add((repo.RepoDir, wt.WorktreePath)); + return (task, list, wt); + } + + private async Task SeedListAsync(string name = "L") + { + var id = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = id, Name = name, CreatedAt = DateTime.UtcNow }); + return id; + } + + private async Task SeedTaskAsync( + string listId, string title = "t", TaskStatus status = TaskStatus.Idle, string? parentId = null) + { + var task = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = title, + Status = status, + ParentTaskId = parentId, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + Number = ++_numberSeed, + }; + await _tasks.AddAsync(task); + return task; + } + + private ExternalMcpService BuildSut(QueueService queue) => BuildSut(queue, new VerifyCommandRunner()); + + private ExternalMcpService BuildSut(QueueService queue, IVerifyCommandRunner verify) + { + var git = new GitService(); + var factory = _db.CreateFactory(); + var state = TaskStateServiceBuilder.Build(factory).State; + var merge = new TaskMergeService(factory, git, _broadcaster, state, verify, NullLogger.Instance); + var aggregator = new PlanningAggregator(factory, git, NullLogger.Instance); + var planningMerge = new PlanningMergeOrchestrator( + factory, merge, aggregator, _broadcaster, git, state, NullLogger.Instance); + return new ExternalMcpService( + _tasks, _lists, queue, _broadcaster, + state, + git, factory, merge, planningMerge, + new BaseDirtyChecker(git, NullLogger.Instance)); + } + + private QueueService CreateQueue() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_ext_{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + var cfg = new WorkerConfig + { + SandboxRoot = Path.Combine(tempDir, "sandbox"), + LogRoot = Path.Combine(tempDir, "logs"), + QueueBackstopIntervalMs = 50, + }; + var fake = new FakeClaudeProcess(); + var hubCtx = new CapturingHubContext(); + var broadcaster = new HubBroadcaster(hubCtx); + var dbFactory = _db.CreateFactory(); + var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger.Instance); + var argsBuilder = new ClaudeArgsBuilder(); + var state = TaskStateServiceBuilder.Build(dbFactory).State; + var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg, + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); + var waker = new ClaudeDo.Worker.Queue.QueueWaker(); + var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory); + var runCancels = new RunCancellationRegistry(NullLogger.Instance); + var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, runCancels); + var usageState = new UsageState(); + return new QueueService(dbFactory, runner, cfg, NullLogger.Instance, waker, picker, overrideSlot, state, runCancels, + new FakeUsageGate(), usageState, broadcaster, + new UsageLimitAutoContinueCoordinator(dbFactory, usageState, overrideSlot, broadcaster, NullLogger.Instance)); + } + + [Fact] + public async Task SeededListAndTask_AreRetrievable() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + Assert.NotNull(await _tasks.GetByIdAsync(task.Id)); + } + + [Fact] + public async Task UpdateTask_PatchesNonNullFieldsOnly() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, "old title"); + var queue = CreateQueue(); + var sut = BuildSut(queue); + + var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, null, CancellationToken.None); + + Assert.Equal("new title", dto.Title); + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal("new title", loaded!.Title); + } + + [Fact] + public async Task UpdateTask_ReturnsLeanReference_NotFullTaskDto() + { + // TaskRefDto has no Description/Result properties -- a writing tool that echoes it back + // would re-send the (possibly long) description the caller just sent. + Assert.DoesNotContain(nameof(TaskDto.Description), typeof(TaskRefDto).GetProperties().Select(p => p.Name)); + Assert.DoesNotContain("Result", typeof(TaskRefDto).GetProperties().Select(p => p.Name)); + + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, "old title"); + task.Description = "a long description the caller already has"; + await _tasks.UpdateAsync(task, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, null, CancellationToken.None); + + Assert.Equal(task.Id, dto.Id); + Assert.Equal(listId, dto.ListId); + Assert.Equal("new title", dto.Title); + } + + [Fact] + public async Task UpdateTask_SetsIsManualTrue() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.UpdateTask(task.Id, isManual: true, cancellationToken: CancellationToken.None); + + Assert.True(dto.IsManual); + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.True(loaded!.IsManual); + } + + [Fact] + public async Task UpdateTask_SetsIsManualFalse_ClearsExistingFlag() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + task.IsManual = true; + await _tasks.UpdateAsync(task, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.UpdateTask(task.Id, isManual: false, cancellationToken: CancellationToken.None); + + Assert.False(dto.IsManual); + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.False(loaded!.IsManual); + } + + [Fact] + public async Task UpdateTask_IsManualNull_LeavesUnchanged() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + task.IsManual = true; + await _tasks.UpdateAsync(task, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.UpdateTask(task.Id, title: "new title", cancellationToken: CancellationToken.None); + + Assert.True(dto.IsManual); + } + + [Fact] + public async Task GetTask_ReturnsTaskNumber() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.GetTask(task.Id, CancellationToken.None); + + Assert.Equal(task.Number, dto.Number); + } + + [Fact] + public async Task GetTask_ByHashNumber_ResolvesToTask() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.GetTask($"#{task.Number}", CancellationToken.None); + + Assert.Equal(task.Id, dto.Id); + } + + [Fact] + public async Task GetTask_ByBareNumber_ResolvesToTask() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.GetTask(task.Number.ToString(), CancellationToken.None); + + Assert.Equal(task.Id, dto.Id); + } + + [Fact] + public async Task GetTask_UnknownNumber_ThrowsWithNumberInMessage() + { + var sut = BuildSut(CreateQueue()); + + var ex = await Assert.ThrowsAsync(() => + sut.GetTask("#999999", CancellationToken.None)); + + Assert.Contains("999999", ex.Message); + } + + [Fact] + public async Task GetTask_ReturnsFullDtoIncludingDescription() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, "with description"); + task.Description = "the full description text"; + await _tasks.UpdateAsync(task, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.GetTask(task.Id, CancellationToken.None); + + Assert.Equal("the full description text", dto.Description); + } + + [Fact] + public async Task GetTask_Failed_ReturnsClassifiedFailureReason() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Failed); + task.FailureReason = "max_turns"; + task.FailureTurnsUsed = 55; + task.FailureMaxTurns = 60; + await _tasks.UpdateAsync(task, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.GetTask(task.Id, CancellationToken.None); + + Assert.Equal("max_turns", dto.FailureReason); + Assert.Equal(55, dto.FailureTurnsUsed); + Assert.Equal(60, dto.FailureMaxTurns); + } + + [Fact] + public async Task GetTask_FailedWithoutStoredReason_ReturnsUnknownNotError() + { + // A Failed task written before this field existed has FailureReason=null in the DB — + // the MCP surface must still hand back a defined value, not null or a throw. + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Failed); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.GetTask(task.Id, CancellationToken.None); + + Assert.Equal("unknown", dto.FailureReason); + Assert.Null(dto.FailureTurnsUsed); + Assert.Null(dto.FailureMaxTurns); + } + + [Fact] + public async Task GetTask_NotFailed_FailureReasonIsNull() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.GetTask(task.Id, CancellationToken.None); + + Assert.Null(dto.FailureReason); + } + + [Fact] + public async Task GetTaskRefAsync_Failed_ReturnsClassifiedFailureReason() + { + // GetTaskRefAsync backs batch_get_tasks' default (lean) path — the same field must be + // present there, not just on the full get_task DTO. + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Failed); + task.FailureReason = "error"; + await _tasks.UpdateAsync(task, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.GetTaskRefAsync(task.Id, CancellationToken.None); + + Assert.Equal("error", dto.FailureReason); + } + + [Fact] + public async Task GetTask_QueuedWithUnmetDependsOn_ReportsBlockedTrueAndReason() + { + var listId = await SeedListAsync(); + var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); + task.DependsOnTaskId = predecessor.Id; + await _tasks.UpdateAsync(task, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.GetTask(task.Id, CancellationToken.None); + + Assert.True(dto.Blocked); + Assert.Contains(predecessor.Id, dto.BlockedReason); + } + + [Fact] + public async Task GetTask_QueuedWithDoneDependsOn_ReportsBlockedFalse() + { + var listId = await SeedListAsync(); + var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Done); + var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); + task.DependsOnTaskId = predecessor.Id; + await _tasks.UpdateAsync(task, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.GetTask(task.Id, CancellationToken.None); + + Assert.False(dto.Blocked); + Assert.Null(dto.BlockedReason); + } + + [Fact] + public async Task GetTask_IdleWithDependsOnTaskId_ReportsBlockedFalse_NotYetQueued() + { + // Blocking is only meaningful once the picker would actually be gating the task -- + // an Idle task hasn't been queued at all, so DependsOnTaskId doesn't apply yet. + var listId = await SeedListAsync(); + var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); + task.DependsOnTaskId = predecessor.Id; + await _tasks.UpdateAsync(task, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.GetTask(task.Id, CancellationToken.None); + + Assert.False(dto.Blocked); + } + + [Fact] + public async Task ListTasks_ReportsBlockedPerTask() + { + var listId = await SeedListAsync(); + var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle); + var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued); + blocked.DependsOnTaskId = predecessor.Id; + await _tasks.UpdateAsync(blocked, CancellationToken.None); + var unblocked = await SeedTaskAsync(listId, "unblocked", TaskStatus.Queued); + var sut = BuildSut(CreateQueue()); + + var result = await sut.ListTasks(listId, cancellationToken: CancellationToken.None); + + var blockedRef = result.Tasks!.Single(t => t.Id == blocked.Id); + var unblockedRef = result.Tasks!.Single(t => t.Id == unblocked.Id); + Assert.True(blockedRef.Blocked); + Assert.Contains(predecessor.Id, blockedRef.BlockedReason); + Assert.False(unblockedRef.Blocked); + } + + [Fact] + public async Task ListTasks_IncludeDescriptionTrue_ReportsBlockedOnFullDto() + { + var listId = await SeedListAsync(); + var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle); + var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued); + blocked.DependsOnTaskId = predecessor.Id; + await _tasks.UpdateAsync(blocked, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var result = await sut.ListTasks(listId, includeDescription: true, cancellationToken: CancellationToken.None); + + var dto = result.TasksFull!.Single(t => t.Id == blocked.Id); + Assert.True(dto.Blocked); + } + + [Fact] + public async Task UpdateTask_OnRunning_Throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Running); + var queue = CreateQueue(); + var sut = BuildSut(queue); + + await Assert.ThrowsAsync(() => + sut.UpdateTask(task.Id, "x", null, null, null, null, CancellationToken.None)); + } + + [Fact] + public async Task UpdateTask_NotFound_Throws() + { + var queue = CreateQueue(); + var sut = BuildSut(queue); + + await Assert.ThrowsAsync(() => + sut.UpdateTask("does-not-exist", "x", null, null, null, null, CancellationToken.None)); + } + + [Fact] + public async Task UpdateTask_WithDependsOnTaskId_SetsLinkAndReportsBlocked() + { + var listId = await SeedListAsync(); + var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: predecessor.Id, cancellationToken: CancellationToken.None); + + Assert.Equal(predecessor.Id, dto.DependsOnTaskId); + Assert.True(dto.Blocked); + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(predecessor.Id, loaded!.DependsOnTaskId); + } + + [Fact] + public async Task UpdateTask_WithEmptyStringDependsOnTaskId_ClearsExistingLink() + { + var listId = await SeedListAsync(); + var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); + var sut = BuildSut(CreateQueue()); + await sut.UpdateTask(task.Id, dependsOnTaskId: predecessor.Id, cancellationToken: CancellationToken.None); + + var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: "", cancellationToken: CancellationToken.None); + + Assert.Null(dto.DependsOnTaskId); + Assert.False(dto.Blocked); + } + + [Fact] + public async Task UpdateTask_WithCyclicDependsOnTaskId_Throws() + { + var listId = await SeedListAsync(); + var a = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var b = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var sut = BuildSut(CreateQueue()); + await sut.UpdateTask(b.Id, dependsOnTaskId: a.Id, cancellationToken: CancellationToken.None); + + await Assert.ThrowsAsync(() => + sut.UpdateTask(a.Id, dependsOnTaskId: b.Id, cancellationToken: CancellationToken.None)); + } + + [Fact] + public async Task ReviewTask_Approve_SetsDone() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); + var sut = BuildSut(CreateQueue()); + + var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None); + + Assert.Equal("Done", result.Task.Status); + Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus); + } + + [Fact] + public async Task ReviewTask_RejectRerun_WithoutFeedback_Throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); + var sut = BuildSut(CreateQueue()); + + await Assert.ThrowsAsync(() => + sut.ReviewTask(task.Id, "reject_rerun", null, null, cancellationToken: CancellationToken.None)); + } + + [Fact] + public async Task ReviewTask_RejectRerun_QueuesAndStoresFeedback() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); + var sut = BuildSut(CreateQueue()); + + var result = await sut.ReviewTask(task.Id, "reject_rerun", "fix it", null, cancellationToken: CancellationToken.None); + + Assert.Equal("Queued", result.Task.Status); + var loaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id); + Assert.Equal("fix it", loaded!.ReviewFeedback); + } + + [Fact] + public async Task ReviewTask_UnknownDecision_Throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); + var sut = BuildSut(CreateQueue()); + + await Assert.ThrowsAsync(() => + sut.ReviewTask(task.Id, "bogus", null, null, cancellationToken: CancellationToken.None)); + } + + [Fact] + public async Task ReviewTask_Approve_WithActiveWorktree_MergesIntoTargetBranch() + { + if (!GitAvailable) return; + var (task, list, wt) = await SeedWorktreeAsync(status: TaskStatus.WaitingForReview); + + File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x\n"); + GitRepoFixture.RunGit(wt.WorktreePath, "add", "feature.txt"); + GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "add feature"); + using (var ctx = _db.CreateContext()) + { + var row = ctx.Worktrees.Single(w => w.TaskId == task.Id); + row.HeadCommit = GitRepoFixture.RunGit(wt.WorktreePath, "rev-parse", "HEAD").Trim(); + ctx.SaveChanges(); + } + + var sut = BuildSut(CreateQueue()); + var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus); + Assert.Equal("Done", result.Task.Status); + Assert.True(File.Exists(Path.Combine(list.WorkingDir!, "feature.txt"))); + using var verify = _db.CreateContext(); + Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == task.Id).State); + } + + // Regression coverage for the incident this fixes: an approve whose post-merge verify gate + // runs long enough gets no MCP traffic at all until RunVerifyGateAsync returns, so Claude + // Code's ~300s idle-silence abort kills the call -- the merge had already landed and + // committed, but the task never reached Done and every dependsOn successor stayed blocked + // until someone noticed and force-set the status by hand. Shrinks TaskMergeService's own + // progress interval (not TaskWaitMcpTools') to observe a report without waiting 30s real time. + [Fact] + public async Task ReviewTask_ApproveWithSlowVerifyCommand_ReportsProgressWellBeforeIdleTimeout() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var original = TaskMergeService.ProgressReportInterval; + TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50); + try + { + var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); + File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x\n"); + var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; + var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); + await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); + + using (var ctx = _db.CreateContext()) + await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" }); + + var fakeVerify = new FakeVerifyCommandRunner + { + Result = new VerifyCommandResult(0, false, "ok"), + Delay = TimeSpan.FromMilliseconds(300), + }; + var sut = BuildSut(CreateQueue(), fakeVerify); + + var reports = new List(); + var progress = new Progress(reports.Add); + + var result = await sut.ReviewTask( + task.Id, "approve", null, null, cancellationToken: CancellationToken.None, progress: progress); + + Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus); + Assert.Equal("Done", result.Task.Status); + // Progress marshals via the SynchronizationContext captured at construction; give + // any queued callbacks a beat to run before asserting on `reports`. + await Task.Delay(200); + Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running")); + } + finally + { + TaskMergeService.ProgressReportInterval = original; + } + } + + [Fact] + public async Task ReviewTask_ApproveWithVerifyCommand_NoProgressToken_DoesNotThrow() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); + File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x\n"); + var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; + var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); + await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); + + using (var ctx = _db.CreateContext()) + await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" }); + + var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") }; + var sut = BuildSut(CreateQueue(), fakeVerify); + + var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus); + Assert.Equal("Done", result.Task.Status); + } + + [Fact] + public async Task ReviewTask_Approve_ParentWithChildren_RunsUnitMerge() + { + if (!GitAvailable) return; + var repo = new GitRepoFixture(); + _repos.Add(repo); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + + var listId = Guid.NewGuid().ToString(); + var parentId = Guid.NewGuid().ToString(); + var childId = Guid.NewGuid().ToString(); + using (var ctx = _db.CreateContext()) + { + ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow, + Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized, Number = ++_numberSeed }); + ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow, + ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed }); + + var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); + _worktreeCleanups.Add((repo.RepoDir, wtPath)); + var branch = $"claudedo/{childId[..8]}"; + GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit); + File.WriteAllText(Path.Combine(wtPath, "child.txt"), "c\n"); + GitRepoFixture.RunGit(wtPath, "add", "child.txt"); + GitRepoFixture.RunGit(wtPath, "commit", "-m", "add child.txt"); + ctx.Worktrees.Add(new WorktreeEntity + { + TaskId = childId, Path = wtPath, BranchName = branch, + BaseCommit = repo.BaseCommit, + HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(), + State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + + var sut = BuildSut(CreateQueue()); + var result = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus); + Assert.Equal("Done", result.Task.Status); + Assert.True(File.Exists(Path.Combine(repo.RepoDir, "child.txt"))); + using var verify = _db.CreateContext(); + Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State); + } + + // Regression coverage for the parent/children unit-merge path specifically: PlanningMergeOrchestrator + // used to drain every child's merge (and verify gate) without ever forwarding review_task's own + // progress token, so a multi-child approve with a slow verify command reported only the single + // "review_task started" tick and then went silent for as long as every child's gate took combined. + [Fact] + public async Task ReviewTask_Approve_ParentWithChildren_SlowVerifyCommand_ReportsProgress() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var original = TaskMergeService.ProgressReportInterval; + TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50); + try + { + var repo = new GitRepoFixture(); + _repos.Add(repo); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + + var listId = Guid.NewGuid().ToString(); + var parentId = Guid.NewGuid().ToString(); + var childId = Guid.NewGuid().ToString(); + using (var ctx = _db.CreateContext()) + { + ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow, + Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized, Number = ++_numberSeed }); + ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow, + ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed }); + + var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); + _worktreeCleanups.Add((repo.RepoDir, wtPath)); + var branch = $"claudedo/{childId[..8]}"; + GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit); + File.WriteAllText(Path.Combine(wtPath, "child.txt"), "c\n"); + GitRepoFixture.RunGit(wtPath, "add", "child.txt"); + GitRepoFixture.RunGit(wtPath, "commit", "-m", "add child.txt"); + ctx.Worktrees.Add(new WorktreeEntity + { + TaskId = childId, Path = wtPath, BranchName = branch, + BaseCommit = repo.BaseCommit, + HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(), + State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, + }); + await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" }); + await ctx.SaveChangesAsync(); + } + + var fakeVerify = new FakeVerifyCommandRunner + { + Result = new VerifyCommandResult(0, false, "ok"), + Delay = TimeSpan.FromMilliseconds(300), + }; + var sut = BuildSut(CreateQueue(), fakeVerify); + + var reports = new List(); + var progress = new Progress(reports.Add); + + var result = await sut.ReviewTask( + parentId, "approve", null, "main", cancellationToken: CancellationToken.None, progress: progress); + + Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus); + Assert.Equal("Done", result.Task.Status); + await Task.Delay(200); + Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running")); + } + finally + { + TaskMergeService.ProgressReportInterval = original; + } + } + + [Fact] + public async Task ReviewTask_Approve_ParentWithChildren_ReportsEmptyChildByName() + { + // A blocked child (CLAUDEDO_BLOCKED, committed nothing) still gets an active worktree at + // its base commit -- TaskRunner creates one before the run starts -- so "empty" shows up + // as zero commits ahead of base, not as a missing worktree. + if (!GitAvailable) return; + var repo = new GitRepoFixture(); + _repos.Add(repo); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + + var listId = Guid.NewGuid().ToString(); + var parentId = Guid.NewGuid().ToString(); + var normalChildId = Guid.NewGuid().ToString(); + var blockedChildId = Guid.NewGuid().ToString(); + using (var ctx = _db.CreateContext()) + { + ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow, + Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized, Number = ++_numberSeed }); + ctx.Tasks.Add(new TaskEntity { Id = normalChildId, ListId = listId, Title = "normal child", CreatedAt = DateTime.UtcNow, + ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed }); + ctx.Tasks.Add(new TaskEntity { Id = blockedChildId, ListId = listId, Title = "blocked child", CreatedAt = DateTime.UtcNow, + ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 2, RoadblockCount = 1, Number = ++_numberSeed }); + + var normalWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); + _worktreeCleanups.Add((repo.RepoDir, normalWtPath)); + var normalBranch = $"claudedo/{normalChildId[..8]}"; + GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", normalBranch, normalWtPath, repo.BaseCommit); + File.WriteAllText(Path.Combine(normalWtPath, "child.txt"), "c\n"); + GitRepoFixture.RunGit(normalWtPath, "add", "child.txt"); + GitRepoFixture.RunGit(normalWtPath, "commit", "-m", "add child.txt"); + ctx.Worktrees.Add(new WorktreeEntity + { + TaskId = normalChildId, Path = normalWtPath, BranchName = normalBranch, + BaseCommit = repo.BaseCommit, + HeadCommit = GitRepoFixture.RunGit(normalWtPath, "rev-parse", "HEAD").Trim(), + State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, + }); + + var blockedWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); + _worktreeCleanups.Add((repo.RepoDir, blockedWtPath)); + var blockedBranch = $"claudedo/{blockedChildId[..8]}"; + GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", blockedBranch, blockedWtPath, repo.BaseCommit); + ctx.Worktrees.Add(new WorktreeEntity + { + TaskId = blockedChildId, Path = blockedWtPath, BranchName = blockedBranch, + BaseCommit = repo.BaseCommit, + HeadCommit = repo.BaseCommit, + State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + + var sut = BuildSut(CreateQueue()); + var result = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus); + Assert.Equal("Done", result.Task.Status); + Assert.NotNull(result.EmptyChildren); + var empty = Assert.Single(result.EmptyChildren!); + Assert.Equal(blockedChildId, empty.Id); + Assert.DoesNotContain(result.EmptyChildren!, c => c.Id == normalChildId); + } + + [Fact] + public async Task DeleteTask_RemovesTask() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var queue = CreateQueue(); + var sut = BuildSut(queue); + + await sut.DeleteTask(task.Id, CancellationToken.None); + + Assert.Null(await _tasks.GetByIdAsync(task.Id)); + } + + [Fact] + public async Task DeleteTask_OnRunning_Throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Running); + var queue = CreateQueue(); + var sut = BuildSut(queue); + + await Assert.ThrowsAsync(() => + sut.DeleteTask(task.Id, CancellationToken.None)); + } + + [Fact] + public async Task DeleteTask_WithChildren_ThrowsUnderstandableMessage() + { + var listId = await SeedListAsync(); + var parent = await SeedTaskAsync(listId, status: TaskStatus.WaitingForChildren); + await SeedTaskAsync(listId, status: TaskStatus.Idle, parentId: parent.Id); + var sut = BuildSut(CreateQueue()); + + var ex = await Assert.ThrowsAsync(() => + sut.DeleteTask(parent.Id, CancellationToken.None)); + + Assert.Contains("child task", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(await _tasks.GetByIdAsync(parent.Id)); + } + + [Fact] + public async Task DeleteTask_NotFound_Throws() + { + var queue = CreateQueue(); + var sut = BuildSut(queue); + + await Assert.ThrowsAsync(() => + sut.DeleteTask("does-not-exist", CancellationToken.None)); + } + + [Fact] + public async Task DeleteTask_LastNonTerminalChild_AdvancesParentToWaitingForReview() + { + var listId = await SeedListAsync(); + var parent = await SeedTaskAsync(listId, status: TaskStatus.WaitingForChildren); + await SeedTaskAsync(listId, status: TaskStatus.Done, parentId: parent.Id); + var lastChild = await SeedTaskAsync(listId, status: TaskStatus.Idle, parentId: parent.Id); + var sut = BuildSut(CreateQueue()); + + await sut.DeleteTask(lastChild.Id, CancellationToken.None); + + var reloadedParent = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.WaitingForReview, reloadedParent!.Status); + } + + [Fact] + public async Task UpdateTaskStatus_Cancelled_CancelsAnIdleTask() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.UpdateTaskStatus(task.Id, "Cancelled", CancellationToken.None); + + Assert.Equal("Cancelled", dto.Status); + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Cancelled, loaded!.Status); + } + + [Fact] + public async Task UpdateTaskStatus_WaitingForReview_StillRejected() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var sut = BuildSut(CreateQueue()); + + var ex = await Assert.ThrowsAsync( + () => sut.UpdateTaskStatus(task.Id, "WaitingForReview", CancellationToken.None)); + Assert.Contains("not settable externally", ex.Message); + } + + [Fact] + public async Task UpdateTaskStatus_Done_NoWorktree_SetsDoneAndFinishedAt() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None); + + Assert.Equal("Done", dto.Status); + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Done, loaded!.Status); + Assert.NotNull(loaded.FinishedAt); + } + + [Fact] + public async Task UpdateTaskStatus_Done_WithActiveWorktree_Throws() + { + if (!GitAvailable) return; + var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); + var sut = BuildSut(CreateQueue()); + + var ex = await Assert.ThrowsAsync( + () => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None)); + + Assert.Contains("active worktree", ex.Message, StringComparison.OrdinalIgnoreCase); + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.WaitingForReview, loaded!.Status); + } + + [Fact] + public async Task UpdateTaskStatus_Idle_OnRunningTask_Throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Running); + var sut = BuildSut(CreateQueue()); + + await Assert.ThrowsAsync( + () => sut.UpdateTaskStatus(task.Id, "Idle", CancellationToken.None)); + + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Running, loaded!.Status); + } + + [Fact] + public async Task UpdateTaskStatus_Done_OnRunningTask_Throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Running); + var sut = BuildSut(CreateQueue()); + + var ex = await Assert.ThrowsAsync( + () => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None)); + + Assert.Contains("running", ex.Message, StringComparison.OrdinalIgnoreCase); + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Running, loaded!.Status); + } + + private ExternalMcpService NewService() => BuildSut(CreateQueue()); + + private async Task SeedIdleTask(string title = "t") + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, title, TaskStatus.Idle); + return task.Id; + } + + private async Task SeedAppSettingsAsync(string? reportExcludedPaths, int dailyPrepMaxTasks = 5) + { + var settings = new AppSettingsEntity + { + Id = AppSettingsEntity.SingletonId, + ReportExcludedPaths = reportExcludedPaths, + DailyPrepMaxTasks = dailyPrepMaxTasks, + }; + // Upsert via AppSettingsRepository + await using var ctx = _db.CreateContext(); + var repo = new AppSettingsRepository(ctx); + await repo.UpdateAsync(settings); + } + + [Fact] + public async Task GetDailyPrepCandidates_filters_by_status_block_and_excluded_repo() + { + // List 1: included repo D:\work\repo — 4 tasks seeded + var listId1 = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId1, Name = "Work", WorkingDir = @"D:\work\repo", CreatedAt = DateTime.UtcNow }); + + // idle, not blocked, not MyDay → should be candidate + var idleUnblocked = new TaskEntity + { + Id = "idle-unblocked", + ListId = listId1, + Title = "Idle unblocked", + Status = TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + Number = ++_numberSeed, + }; + await _tasks.AddAsync(idleUnblocked); + + // idle but blocked → excluded (BlockedByTaskId references idle-unblocked as its predecessor) + var idleBlocked = new TaskEntity + { + Id = "idle-blocked", + ListId = listId1, + Title = "Idle blocked", + Status = TaskStatus.Idle, + BlockedByTaskId = "idle-unblocked", + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + Number = ++_numberSeed, + }; + await _tasks.AddAsync(idleBlocked); + + // Done → excluded + var doneTask = new TaskEntity + { + Id = "done-task", + ListId = listId1, + Title = "Done task", + Status = TaskStatus.Done, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + Number = ++_numberSeed, + }; + await _tasks.AddAsync(doneTask); + + // idle, IsMyDay → goes into currentMyDay, not candidates + var myDayTask = new TaskEntity + { + Id = "myday-task", + ListId = listId1, + Title = "MyDay task", + Status = TaskStatus.Idle, + IsMyDay = true, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + Number = ++_numberSeed, + }; + await _tasks.AddAsync(myDayTask); + + // List 2: excluded repo C:\Private\secret + var listId2 = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId2, Name = "Secret", WorkingDir = @"C:\Private\secret", CreatedAt = DateTime.UtcNow }); + + var excludedRepoTask = new TaskEntity + { + Id = "excluded-repo-task", + ListId = listId2, + Title = "Excluded repo", + Status = TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + Number = ++_numberSeed, + }; + await _tasks.AddAsync(excludedRepoTask); + + // List 3: no WorkingDir → excluded + var listId3 = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId3, Name = "NoRepo", WorkingDir = null, CreatedAt = DateTime.UtcNow }); + + var noRepoTask = new TaskEntity + { + Id = "no-repo-task", + ListId = listId3, + Title = "No repo", + Status = TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + Number = ++_numberSeed, + }; + await _tasks.AddAsync(noRepoTask); + + await SeedAppSettingsAsync(@"[""C:\\Private""]", dailyPrepMaxTasks: 5); + + var sut = BuildSut(CreateQueue()); + var result = await sut.GetDailyPrepCandidates(CancellationToken.None); + + Assert.Single(result.Candidates); + Assert.Equal("idle-unblocked", result.Candidates[0].Id); + Assert.Single(result.CurrentMyDay); + Assert.Equal("myday-task", result.CurrentMyDay[0].Id); + Assert.Equal(5, result.MaxTasks); + } + + [Fact] + public async Task SetMyDay_sets_flag_and_sort_order() + { + var svc = NewService(); + var id = await SeedIdleTask("My task"); + + var dto = await svc.SetMyDay(id, isMyDay: true, sortOrder: 3, CancellationToken.None); + + Assert.True(dto.IsMyDay); + Assert.Equal(3, dto.SortOrder); + } + + [Fact] + public async Task SetMyDay_rejects_when_cap_reached() + { + await SeedAppSettingsAsync(null, dailyPrepMaxTasks: 1); + var svc = NewService(); + var first = await SeedIdleTask("a"); + var second = await SeedIdleTask("b"); + await svc.SetMyDay(first, true, null, CancellationToken.None); + + var ex = await Assert.ThrowsAsync( + () => svc.SetMyDay(second, true, null, CancellationToken.None)); + Assert.Contains("limit", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task SetMyDay_unset_is_always_allowed() + { + var svc = NewService(); + var id = await SeedIdleTask("a"); + await svc.SetMyDay(id, true, null, CancellationToken.None); + + var dto = await svc.SetMyDay(id, false, null, CancellationToken.None); + Assert.False(dto.IsMyDay); + } + + // ── GetTaskConfig ───────────────────────────────────────────────────────── + + private ConfigMcpTools BuildConfigSut() => new(_lists, _tasks, _broadcaster, _db.CreateFactory()); + + [Fact] + public async Task GetTaskConfig_NotFound_Throws() + { + var sut = BuildConfigSut(); + await Assert.ThrowsAsync( + () => sut.GetTaskConfig("does-not-exist", CancellationToken.None)); + } + + [Fact] + public async Task GetTaskConfig_NoOverrides_ReturnsNull() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = BuildConfigSut(); + + var result = await sut.GetTaskConfig(task.Id, CancellationToken.None); + + Assert.False(result.Found); + Assert.Null(result.Config); + } + + [Fact] + public async Task GetTaskConfig_WithOverrides_ReturnsValues() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + await _tasks.UpdateAgentSettingsAsync(task.Id, "claude-sonnet-4-6", "be concise", null, 10, ct: CancellationToken.None); + var sut = BuildConfigSut(); + + var result = await sut.GetTaskConfig(task.Id, CancellationToken.None); + + Assert.True(result.Found); + Assert.NotNull(result.Config); + Assert.Equal("claude-sonnet-4-6", result.Config!.Model); + Assert.Equal("be concise", result.Config.SystemPrompt); + Assert.Null(result.Config.AgentPath); + Assert.Equal(10, result.Config.MaxTurns); + } + + // ── ListTasks status filter ─────────────────────────────────────────────── + + [Fact] + public async Task ListTasks_FilterByWaitingForReview_ReturnsMatchingTasks() + { + var listId = await SeedListAsync(); + await SeedTaskAsync(listId, "wfr", TaskStatus.WaitingForReview); + await SeedTaskAsync(listId, "idle", TaskStatus.Idle); + var sut = NewService(); + + var result = await sut.ListTasks(listId, null, "WaitingForReview", cancellationToken: CancellationToken.None); + + Assert.NotNull(result.Tasks); + Assert.Single(result.Tasks!); + Assert.Equal("WaitingForReview", result.Tasks![0].Status); + } + + [Fact] + public async Task ListTasks_FilterByWaitingForChildren_ReturnsMatchingTasks() + { + var listId = await SeedListAsync(); + await SeedTaskAsync(listId, "wfc", TaskStatus.WaitingForChildren); + await SeedTaskAsync(listId, "done", TaskStatus.Done); + var sut = NewService(); + + var result = await sut.ListTasks(listId, null, "WaitingForChildren", cancellationToken: CancellationToken.None); + + Assert.NotNull(result.Tasks); + Assert.Single(result.Tasks!); + Assert.Equal("WaitingForChildren", result.Tasks![0].Status); + } + + [Fact] + public async Task ListTasks_Default_ReturnsLeanReferences_NoDescriptionOrTasksFull() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, "with desc"); + task.Description = "a long description that should not come back by default"; + await _tasks.UpdateAsync(task); + var sut = NewService(); + + var result = await sut.ListTasks(listId, cancellationToken: CancellationToken.None); + + Assert.False(result.IncludeDescription); + Assert.NotNull(result.Tasks); + Assert.Null(result.TasksFull); + Assert.Single(result.Tasks!); + Assert.Equal(task.Id, result.Tasks![0].Id); + Assert.Equal(task.Number, result.Tasks![0].Number); + } + + [Fact] + public async Task ListTasks_IncludeDescriptionTrue_ReturnsFullTasks() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, "with desc"); + task.Description = "the full description"; + await _tasks.UpdateAsync(task); + var sut = NewService(); + + var result = await sut.ListTasks(listId, includeDescription: true, cancellationToken: CancellationToken.None); + + Assert.True(result.IncludeDescription); + Assert.Null(result.Tasks); + Assert.NotNull(result.TasksFull); + Assert.Single(result.TasksFull!); + Assert.Equal("the full description", result.TasksFull![0].Description); + Assert.Equal(task.Number, result.TasksFull![0].Number); + } + + // ── leaveConflictsInTree ────────────────────────────────────────────────── + + private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedConflictingWorktreeAsync( + TaskStatus status = TaskStatus.WaitingForReview) + { + var (task, list, wt) = await SeedWorktreeAsync(status); + + File.WriteAllText(Path.Combine(wt.WorktreePath, "README.md"), "# from worktree\n"); + GitRepoFixture.RunGit(wt.WorktreePath, "add", "README.md"); + GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "worktree edit"); + + File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# from main\n"); + GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md"); + GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "main edit"); + + return (task, list, wt); + } + + [Fact] + public async Task ReviewTask_Approve_LeaveConflictsInTree_LeavesMarkersAndReportsRepoPath() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); + var sut = BuildSut(CreateQueue()); + + var result = await sut.ReviewTask(task.Id, "approve", null, null, + leaveConflictsInTree: true, cancellationToken: CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusConflict, result.MergeStatus); + Assert.Equal(list.WorkingDir, result.RepoPath); + Assert.Contains("continue_merge", result.MergeMessage); + Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"))); + Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!)); + Assert.Equal("WaitingForReview", result.Task.Status); + + GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); + } + + [Fact] + public async Task ReviewTask_Approve_ConflictWithoutLeaveFlag_KeepsOldBehaviour() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); + var sut = BuildSut(CreateQueue()); + + var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusConflict, result.MergeStatus); + Assert.Null(result.RepoPath); + Assert.Contains("ClaudeDo UI", result.MergeMessage); + Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"))); + Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!)); + Assert.Equal("WaitingForReview", result.Task.Status); + } + + [Fact] + public async Task AddTask_ReturnsAllocatedTaskNumber() + { + var listId = await SeedListAsync(); + var sut = NewService(); + + var dto = await sut.AddTask(listId, "t", cancellationToken: CancellationToken.None); + + var loaded = await _tasks.GetByIdAsync(dto.Task.Id); + Assert.True(dto.Task.Number > 0); + Assert.Equal(loaded!.Number, dto.Task.Number); + } + + // ── AddTask model override ──────────────────────────────────────────────── + + [Fact] + public async Task AddTask_NoModel_LeavesModelNull() + { + var listId = await SeedListAsync(); + var sut = NewService(); + + var dto = await sut.AddTask(listId, "t", cancellationToken: CancellationToken.None); + + var loaded = await _tasks.GetByIdAsync(dto.Task.Id); + Assert.Null(loaded!.Model); + } + + [Fact] + public async Task AddTask_PersistsNormalizedModel() + { + var listId = await SeedListAsync(); + var sut = NewService(); + + var dto = await sut.AddTask(listId, "t", model: "HAIKU", cancellationToken: CancellationToken.None); + + var loaded = await _tasks.GetByIdAsync(dto.Task.Id); + Assert.Equal("haiku", loaded!.Model); + } + + [Fact] + public async Task AddTask_RejectsUnknownModel() + { + var listId = await SeedListAsync(); + var sut = NewService(); + + await Assert.ThrowsAsync( + () => sut.AddTask(listId, "t", model: "gpt4", cancellationToken: CancellationToken.None)); + } + + [Fact] + public async Task AddTask_WithDependsOnTaskId_PersistsLinkAndReportsBlocked() + { + var listId = await SeedListAsync(); + var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var sut = NewService(); + + var dto = await sut.AddTask( + listId, "t", dependsOnTaskId: predecessor.Id, queueImmediately: true, cancellationToken: CancellationToken.None); + + Assert.Equal(predecessor.Id, dto.Task.DependsOnTaskId); + Assert.True(dto.Task.Blocked); + Assert.Contains(predecessor.Id, dto.Task.BlockedReason); + var loaded = await _tasks.GetByIdAsync(dto.Task.Id); + Assert.Equal(predecessor.Id, loaded!.DependsOnTaskId); + } + + [Fact] + public async Task AddTask_WithSelfReferencingDependsOnTaskId_Throws() + { + // Can't reference its own not-yet-known id, so this exercises the not-found path -- + // an unknown dependsOnTaskId is rejected the same way a self-reference would be. + var listId = await SeedListAsync(); + var sut = NewService(); + + await Assert.ThrowsAsync( + () => sut.AddTask(listId, "t", dependsOnTaskId: "does-not-exist", cancellationToken: CancellationToken.None)); + } + + // ── AddTask possible-duplicate check ──────────────────────────────────────── + + [Fact] + public async Task AddTask_SimilarOpenTitleInSameList_IsReportedAsPossibleDuplicate() + { + // The exact pair that motivated this check (bf4cd901 vs f3718cd8, see task write-up). + var listId = await SeedListAsync(); + var existing = await SeedTaskAsync( + listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.WaitingForReview); + var sut = NewService(); + + var result = await sut.AddTask( + listId, "Settings: MaxTurnsCeiling editierbar machen", cancellationToken: CancellationToken.None); + + var dup = Assert.Single(result.PossibleDuplicates); + Assert.Equal(existing.Id, dup.TaskId); + Assert.Equal(existing.Title, dup.Title); + Assert.Equal("WaitingForReview", dup.Status); + } + + [Fact] + public async Task AddTask_StillCreatesTheTask_EvenWhenAPossibleDuplicateIsFound() + { + var listId = await SeedListAsync(); + await SeedTaskAsync(listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.Idle); + var sut = NewService(); + + var result = await sut.AddTask( + listId, "Settings: MaxTurnsCeiling editierbar machen", cancellationToken: CancellationToken.None); + + Assert.NotEmpty(result.PossibleDuplicates); + Assert.NotNull(await _tasks.GetByIdAsync(result.Task.Id)); + } + + [Fact] + public async Task AddTask_SimilarTitle_ButExistingTaskIsTerminal_IsNotReported() + { + var listId = await SeedListAsync(); + await SeedTaskAsync(listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.Done); + var sut = NewService(); + + var result = await sut.AddTask( + listId, "Settings: MaxTurnsCeiling editierbar machen", cancellationToken: CancellationToken.None); + + Assert.Empty(result.PossibleDuplicates); + } + + [Fact] + public async Task AddTask_ClearlyDifferentTitle_IsNotReported() + { + var listId = await SeedListAsync(); + await SeedTaskAsync(listId, "Refactor GitService worktree cleanup", TaskStatus.Idle); + var sut = NewService(); + + var result = await sut.AddTask( + listId, "Add dark mode toggle to the settings page", cancellationToken: CancellationToken.None); + + Assert.Empty(result.PossibleDuplicates); + } + + [Fact] + public async Task AddTask_TitlesSharingOnlyOneWordPlusTheGermanStopwordFuer_AreNotReportedAsDuplicates() + { + // Regression for the "fur" (never-transliterated) stopword typo: NormalizeTitleWords + // transliterates "für" -> "fuer" before the stopword check, so a stopword list containing + // "fur" can never filter it. Before the fix these two titles shared "report" and "fuer" + // (2 words, 50% of the shorter title) and cleared the duplicate threshold undeservedly; + // with "fuer" correctly filtered as filler only "report" remains shared (1 word). + var listId = await SeedListAsync(); + await SeedTaskAsync(listId, "Neuer Report für Bestand", TaskStatus.Idle); + var sut = NewService(); + + var result = await sut.AddTask( + listId, "Anderer Report für Kunden", cancellationToken: CancellationToken.None); + + Assert.Empty(result.PossibleDuplicates); + } + + [Fact] + public async Task AddTask_MoreThanThreeMatches_CapsPossibleDuplicatesAtThree() + { + var listId = await SeedListAsync(); + for (var i = 0; i < 5; i++) + await SeedTaskAsync(listId, $"Fix flaky queue picker test number {i}", TaskStatus.Idle); + var sut = NewService(); + + var result = await sut.AddTask( + listId, "Fix flaky queue picker test number extra", cancellationToken: CancellationToken.None); + + Assert.Equal(3, result.PossibleDuplicates.Count); + } + + // ── ContinueTask validation ─────────────────────────────────────────────── + + [Fact] + public async Task ContinueTask_EmptyPrompt_Throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = NewService(); + + await Assert.ThrowsAsync( + () => sut.ContinueTask(task.Id, " ", CancellationToken.None)); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/External/_worktree_bodies.txt b/tests/ClaudeDo.Worker.Tests/External/_worktree_bodies.txt new file mode 100644 index 00000000..6d6319cd --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/_worktree_bodies.txt @@ -0,0 +1,331 @@ + [Fact] + public async Task GetTaskWorktree_NoWorktree_Throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = BuildSut(CreateQueue()); + + await Assert.ThrowsAsync( + () => sut.GetTaskWorktree(task.Id, CancellationToken.None)); + } + + [Fact] + public async Task GetTaskWorktree_ReturnsBranchAndBaseCommit() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, wt) = await SeedWorktreeAsync(); + var sut = BuildSut(CreateQueue()); + + var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); + + Assert.Equal(wt.BranchName, info.Branch); + Assert.Equal(wt.BaseCommit, info.BaseCommit); + Assert.Equal(0, info.Ahead); + Assert.False(info.IsDirty); + } + + [Fact] + public async Task GetTaskWorktree_BeforeMerge_MergeCommitIsNull() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, _) = await SeedWorktreeAsync(); + var sut = BuildSut(CreateQueue()); + + var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); + + Assert.Null(info.MergeCommit); + } + + [Fact] + public async Task GetTaskWorktree_AfterMerge_ExposesMergeCommit() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done); + File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "new\n"); + GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A"); + GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file"); + + var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None); + + var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); + + var expected = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim(); + Assert.Equal(expected, info.MergeCommit); + } + + [Fact] + public async Task GetTaskDiff_NoWorktree_Throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = BuildSut(CreateQueue()); + + await Assert.ThrowsAsync( + () => sut.GetTaskDiff(task.Id, false, null, CancellationToken.None)); + } + + [Fact] + public async Task GetTaskDiff_AfterCommit_ListsChangedFile() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, wt) = await SeedWorktreeAsync(); + File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); + + var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; + var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); + await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); + + var sut = BuildSut(CreateQueue()); + var diff = await sut.GetTaskDiff(task.Id, false, null, CancellationToken.None); + + Assert.Contains("added.txt", diff.Files); + Assert.False(diff.Truncated); + } + + [Fact] + public async Task GetTaskDiff_WithPathsFilter_ReturnsOnlyRequestedFile() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, wt) = await SeedWorktreeAsync(); + File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); + File.WriteAllText(Path.Combine(wt.WorktreePath, "other.txt"), "other content"); + + var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; + var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); + await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); + + var sut = BuildSut(CreateQueue()); + var diff = await sut.GetTaskDiff(task.Id, false, ["added.txt"], CancellationToken.None); + + Assert.Equal(new[] { "added.txt" }, diff.Files); + Assert.DoesNotContain("other.txt", diff.Content); + } + + [Fact] + public async Task GetTaskDiff_StatMode_WithPathsFilter_ReturnsOnlyRequestedFile() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, wt) = await SeedWorktreeAsync(); + File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); + File.WriteAllText(Path.Combine(wt.WorktreePath, "other.txt"), "other content"); + + var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; + var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); + await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); + + var sut = BuildSut(CreateQueue()); + var diff = await sut.GetTaskDiff(task.Id, true, ["other.txt"], CancellationToken.None); + + Assert.Equal(new[] { "other.txt" }, diff.Files); + Assert.DoesNotContain("added.txt", diff.Content); + } + + [Fact] + public async Task GetTaskDiff_WorktreeLessHandlerTask_UsesHandlerCommitRangeOverListWorkingDir() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = new GitRepoFixture(); + _repos.Add(repo); + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); + // No WorktreeEntity for this task -- it's a worktree-less list-handler host task + // (Mission Control's "Let Claude handle it"): the handler merged its own commit + // straight into the list's working dir after HandlerBaseCommit was stamped. + + File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge"); + var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); + + using (var ctx = _db.CreateContext()) + { + var t = await ctx.Tasks.FindAsync(task.Id); + t!.HandlerBaseCommit = repo.BaseCommit; + t.HandlerHeadCommit = headCommit; + await ctx.SaveChangesAsync(); + } + + var sut = BuildSut(CreateQueue()); + var diff = await sut.GetTaskDiff(task.Id, false, null, CancellationToken.None); + + Assert.Contains("handled.txt", diff.Files); + Assert.False(diff.Truncated); + } + + [Fact] + public async Task GetTaskDiff_WorktreeLessHandlerTask_StatMode_UsesHandlerCommitRange() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = new GitRepoFixture(); + _repos.Add(repo); + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); + + File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge"); + var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); + + using (var ctx = _db.CreateContext()) + { + var t = await ctx.Tasks.FindAsync(task.Id); + t!.HandlerBaseCommit = repo.BaseCommit; + t.HandlerHeadCommit = headCommit; + await ctx.SaveChangesAsync(); + } + + var sut = BuildSut(CreateQueue()); + var diff = await sut.GetTaskDiff(task.Id, true, null, CancellationToken.None); + + Assert.Contains("handled.txt", diff.Content); + } + + [Fact] + public async Task GetTaskDiff_ReportsProgressWhileDiffing() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var original = ExternalMcpService.ProgressReportInterval; + ExternalMcpService.ProgressReportInterval = TimeSpan.Zero; + try + { + var (task, list, wt) = await SeedWorktreeAsync(); + File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); + var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; + var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); + await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); + + var sut = BuildSut(CreateQueue()); + var progress = new SyncProgress(); + + var diff = await sut.GetTaskDiff(task.Id, false, null, CancellationToken.None, progress); + + Assert.Contains("added.txt", diff.Files); + Assert.NotEmpty(progress.Reports); + Assert.All(progress.Reports, r => Assert.Contains("computing diff", r.Message)); + } + finally + { + ExternalMcpService.ProgressReportInterval = original; + } + } + + [Fact] + public async Task ListWorktrees_Empty_ReturnsEmpty() + { + var sut = BuildSut(CreateQueue()); + + var rows = await sut.ListWorktrees(CancellationToken.None); + + Assert.Empty(rows); + } + + [Fact] + public async Task ListWorktrees_ReturnsCreatedWorktree() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, _) = await SeedWorktreeAsync(); + var sut = BuildSut(CreateQueue()); + + var rows = await sut.ListWorktrees(CancellationToken.None); + + Assert.Contains(rows, r => r.TaskId == task.Id); + } + + [Fact] + public async Task ListWorktrees_ReportsProgressWhileSurveying() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var original = ExternalMcpService.ProgressReportInterval; + ExternalMcpService.ProgressReportInterval = TimeSpan.Zero; + try + { + await SeedWorktreeAsync(); + var sut = BuildSut(CreateQueue()); + var progress = new SyncProgress(); + + var rows = await sut.ListWorktrees(CancellationToken.None, progress); + + Assert.NotEmpty(rows); + Assert.NotEmpty(progress.Reports); + } + finally + { + ExternalMcpService.ProgressReportInterval = original; + } + } + + [Fact] + public async Task CleanupTaskWorktree_NotFound_Throws() + { + var sut = BuildSut(CreateQueue()); + + await Assert.ThrowsAsync( + () => sut.CleanupTaskWorktree("does-not-exist", false, CancellationToken.None)); + } + + [Fact] + public async Task CleanupTaskWorktree_Running_Throws() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Running); + var sut = BuildSut(CreateQueue()); + + var ex = await Assert.ThrowsAsync( + () => sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None)); + Assert.Contains("running", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CleanupTaskWorktree_CleanWorktree_Removes() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done); + var sut = BuildSut(CreateQueue()); + + var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None); + + Assert.True(result.Removed); + Assert.False(Directory.Exists(wt.WorktreePath)); + } + + [Fact] + public async Task CleanupTaskWorktree_ReportsProgressWhileRemoving() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var original = ExternalMcpService.ProgressReportInterval; + ExternalMcpService.ProgressReportInterval = TimeSpan.Zero; + try + { + var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done); + var sut = BuildSut(CreateQueue()); + var progress = new SyncProgress(); + + var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None, progress); + + Assert.True(result.Removed); + Assert.False(Directory.Exists(wt.WorktreePath)); + Assert.NotEmpty(progress.Reports); + } + finally + { + ExternalMcpService.ProgressReportInterval = original; + } + }