using System.Collections.Concurrent; using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.State; using Microsoft.EntityFrameworkCore; using ModelContextProtocol; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Lifecycle; public sealed record MergeResult( string Status, IReadOnlyList ConflictFiles, string? ErrorMessage); public sealed record MergeTargets( string DefaultBranch, IReadOnlyList LocalBranches, string DefaultCommitMessage); // VerifyExitCode/VerifyDurationMs/VerifyOutputTail are only populated when the caller asked for // a verify run (runVerify=true) AND the list has a verify command configured AND the preview came // back clean — a conflicting or unavailable preview never reaches the verify step. -1 is used for // VerifyExitCode when the command timed out or failed to start (mirrors the post-merge gate). public sealed record MergePreviewResult( string Status, IReadOnlyList ConflictFiles, int ChangedFileCount, int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null); public sealed record ConflictDocuments( string TaskId, IReadOnlyList Files); public sealed record ConflictDocumentContent( string Path, bool IsBinary, IReadOnlyList Segments); public sealed record RevertResult( string Status, string? RevertCommit, IReadOnlyList ConflictFiles, string? ErrorMessage); // Per-task outcome of a batch verify (RunListVerifyAsync): Promoted=true means the task reached // Done; otherwise Reason says why it was left alone (invalid candidate, or the verify failed). public sealed record ListVerifyTaskOutcome(string TaskId, int Number, bool Promoted, string? Reason); public sealed record ListVerifyResult( string Status, IReadOnlyList Tasks, string? ErrorMessage); public sealed class TaskMergeService { public const string StatusMerged = "merged"; public const string StatusConflict = "conflict"; public const string StatusBlocked = "blocked"; public const string StatusAborted = "aborted"; public const string StatusVerifyFailed = "verify_failed"; // Batch mode (skipVerify): the merge landed but the verify gate was deliberately deferred -- // the task stays out of Done until RunListVerifyAsync passes and promotes it. public const string StatusMergedVerifyPending = "merged_verify_pending"; public const string StatusVerifyPassed = "verify_passed"; public const string StatusUntrackedCollision = "untracked_collision"; public const string StatusReverted = "reverted"; public const string StatusConflictAborted = "conflict_aborted"; // Phase tokens for the OperationProgress broadcast — stable identifiers, localized by the UI. public const string PhaseMerging = "merging"; public const string PhaseVerifying = "verifying"; public const string PhaseRebasing = "rebasing"; public const string PreviewClean = "clean"; public const string PreviewConflict = "conflict"; public const string PreviewUnavailable = "unavailable"; public const string PreviewUntrackedCollision = "untracked_collision"; // The verify command is a trusted, list-owner-configured build/test invocation (not // per-request user input), so a generous fixed timeout is enough — no need for a // per-list configurable value on top of what the spec calls for. private static readonly TimeSpan VerifyTimeout = TimeSpan.FromMinutes(10); // Mirrors TaskWaitMcpTools.ProgressReportInterval (External/TaskWaitMcpTools.cs): a verify // run can take up to VerifyTimeout, well past Claude Code's ~300s MCP idle-silence abort, so // ProgressReporter.RunAsync reports on this cadence to keep the calling review_task/ // merge_task/preview_merge* call alive. Not readonly -- tests shrink it to observe a report // without waiting 30s. A separate field from TaskWaitMcpTools' own (rather than sharing it) // so shrinking one for a test can't race the other's tests. internal static TimeSpan ProgressReportInterval = TimeSpan.FromSeconds(30); // Serializes merge (+ verify) against the same repo working dir: a verify command running // in list.WorkingDir must not see a second merge land mid-build. Keyed by working dir since // TaskMergeService is a process-wide singleton and merges across different lists are independent. private static readonly ConcurrentDictionary MergeGates = new(StringComparer.OrdinalIgnoreCase); private static SemaphoreSlim GetMergeGate(string workingDir) => MergeGates.GetOrAdd(workingDir, static _ => new SemaphoreSlim(1, 1)); private readonly IDbContextFactory _dbFactory; private readonly GitService _git; private readonly HubBroadcaster _broadcaster; private readonly ITaskStateService _state; private readonly IVerifyCommandRunner _verify; private readonly ILogger _logger; public TaskMergeService( IDbContextFactory dbFactory, GitService git, HubBroadcaster broadcaster, ITaskStateService state, IVerifyCommandRunner verify, ILogger logger) { _dbFactory = dbFactory; _git = git; _broadcaster = broadcaster; _state = state; _verify = verify; _logger = logger; } private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity? Worktree, string? VerifyCommand)> LoadMergeContextAsync( string taskId, CancellationToken ct) { using var ctx = _dbFactory.CreateDbContext(); var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct) ?? throw new KeyNotFoundException($"Task '{taskId}' not found."); var listRepo = new ListRepository(ctx); var list = await listRepo.GetByIdAsync(task.ListId, ct) ?? throw new InvalidOperationException("List not found."); var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct); var config = await listRepo.GetConfigAsync(task.ListId, ct); return (task, list, wt, config?.VerifyCommand); } /// /// Runs the list's configured verify command (if any) in after /// a successful merge. Returns null when there is nothing to gate on (identical to today's /// behavior); otherwise returns the terminal to report instead of /// merged (the merge itself is left in place either way — see the design notes in Worker's /// CLAUDE.md — only the Done transition is withheld). /// private async Task RunVerifyGateAsync( string? verifyCommand, string workingDir, CancellationToken ct, IProgress? progress = null, Action? onTick = null) { if (string.IsNullOrWhiteSpace(verifyCommand)) return null; VerifyCommandResult result; try { result = await ProgressReporter.RunAsync( _verify.RunAsync(workingDir, verifyCommand, VerifyTimeout, ct), ProgressReportInterval, progress, "verify gate running", onTick); } catch (Exception ex) { _logger.LogWarning(ex, "verify command failed to start: {Command}", verifyCommand); return new MergeResult(StatusVerifyFailed, Array.Empty(), $"verify command failed to start: {ex.Message}"); } if (!result.TimedOut && result.ExitCode == 0) return null; var reason = result.TimedOut ? $"verify command timed out after {VerifyTimeout.TotalMinutes:0} min: {verifyCommand}" : $"verify command failed (exit {result.ExitCode}): {verifyCommand}"; return new MergeResult(StatusVerifyFailed, Array.Empty(), $"{reason}\n{TailOutput(result.Output)}"); } private static string TailOutput(string output, int maxChars = 4000) { var trimmed = output.Trim(); return trimmed.Length <= maxChars ? trimmed : trimmed[^maxChars..]; } /// /// Guards against a merge silently overwriting a file that sits untracked in the target /// working directory: git already refuses that itself while the path is still untracked at /// merge time, but a path that only became trackable in between (e.g. an unrelated conflict /// resolution's `git add -A` sweeping it up) loses that protection. Compares the paths /// newly added since its merge base with /// against what's currently untracked in — reusing /// (run against the branch's own worktree, where /// HEAD is the branch tip) and rather than adding /// new git plumbing. Returns null when there's nothing to flag. /// private async Task CheckUntrackedCollisionsAsync( string workingDir, string targetRef, string worktreePath, string branchName, CancellationToken ct) { var mergeBase = await _git.MergeBaseAsync(workingDir, targetRef, branchName, ct); if (mergeBase is null) return null; List addedByBranch; try { addedByBranch = ParseAddedPaths(await _git.GetCommittedFilesAsync(worktreePath, mergeBase, ct)); } catch { return null; } // worktree missing — nothing to compare, let the merge report its own outcome if (addedByBranch.Count == 0) return null; var untracked = ParseUntrackedPaths(await _git.GetStatusPorcelainAsync(workingDir, ct)); if (untracked.Count == 0) return null; var untrackedSet = new HashSet(untracked, StringComparer.OrdinalIgnoreCase); var collisions = addedByBranch.Where(untrackedSet.Contains).ToList(); if (collisions.Count == 0) return null; var details = collisions.Select(p => $"{p} ({DescribeSize(workingDir, p)})"); return new MergeResult(StatusUntrackedCollision, collisions, "merge would overwrite untracked file(s) that exist in the target working directory: " + string.Join(", ", details)); } private static string DescribeSize(string workingDir, string relativePath) { try { var full = Path.Combine(workingDir, relativePath.Replace('/', Path.DirectorySeparatorChar)); return $"{new FileInfo(full).Length} bytes"; } catch { return "size unknown"; } } // Only "A" (pure add) entries — a path the branch modifies but that already exists on the // target's own history is a normal merge, not a collision with something untracked. private static List ParseAddedPaths(string nameStatus) { var result = new List(); foreach (var raw in nameStatus.Split('\n', StringSplitOptions.RemoveEmptyEntries)) { var line = raw.TrimEnd('\r'); if (line.Length < 2 || line[0] != 'A') continue; var tab = line.IndexOf('\t'); if (tab >= 0) result.Add(line[(tab + 1)..].Trim()); } return result; } private static List ParseUntrackedPaths(string porcelain) { var result = new List(); foreach (var raw in porcelain.Split('\n', StringSplitOptions.RemoveEmptyEntries)) { var line = raw.TrimEnd('\r'); if (line.StartsWith("?? ", StringComparison.Ordinal)) result.Add(line[3..].Trim().Trim('"')); } return result; } private async Task MarkWorktreeMergedAsync(string taskId, string mergeCommitSha, CancellationToken ct) { using (var ctx = _dbFactory.CreateDbContext()) { await new WorktreeRepository(ctx).SetMergedAsync(taskId, mergeCommitSha, ct); } await _broadcaster.WorktreeUpdated(taskId); } private async Task ApproveIfWaitingForReviewAsync(TaskEntity task, CancellationToken ct) { // A merged worktree means the work is integrated, so the task must reach Done. // MarkWorktreeMergedAsync only flips the worktree state; transition the task // itself when it was still awaiting review (a Done task is already terminal). if (task.Status == TaskStatus.WaitingForReview) await _state.ApproveReviewAsync(task.Id, ct); } /// /// After a merge lands on , rebases every other WaitingForReview /// task's active worktree in the same list onto the new tip -- but only ones that actually /// touch a file the merge just changed. A branch merely behind (no overlap) is left alone: a /// rebase is disruptive busywork when there's nothing to gain from it. Best-effort throughout -- /// this runs after the merge that matters has already succeeded, so a failure here must never /// surface as a failure of that merge. /// private async Task RebaseOthersAfterMergeAsync( TaskEntity mergedTask, ListEntity list, string targetBranch, string oldTargetTip, string newTargetTip, CancellationToken ct) { if (string.Equals(oldTargetTip, newTargetTip, StringComparison.Ordinal)) return; IReadOnlyList landedFiles; try { landedFiles = await _git.GetChangedFileNamesAsync(list.WorkingDir!, oldTargetTip, newTargetTip, ct); } catch (Exception ex) { _logger.LogWarning(ex, "Auto-rebase: could not compute files landed by merging task {TaskId}", mergedTask.Id); return; } if (landedFiles.Count == 0) return; List candidates; using (var ctx = _dbFactory.CreateDbContext()) { var tasks = await new TaskRepository(ctx).GetByListIdAsync(list.Id, ct); candidates = tasks.Where(t => t.Id != mergedTask.Id && t.Status == TaskStatus.WaitingForReview).ToList(); } if (candidates.Count == 0) return; // Runs where the stalled "Merging…" phase would otherwise sit unchanged in the UI while // this loop does real (if best-effort) work — see the correction note in the task spec. for (var i = 0; i < candidates.Count; i++) { await _broadcaster.OperationProgress(mergedTask.Id, PhaseRebasing, i + 1, candidates.Count); await RebaseOneIfOverlappingAsync(candidates[i], targetBranch, newTargetTip, landedFiles, ct); } } private async Task RebaseOneIfOverlappingAsync( TaskEntity candidate, string targetBranch, string newTargetTip, IReadOnlyList landedFiles, CancellationToken ct) { WorktreeEntity? wt; using (var ctx = _dbFactory.CreateDbContext()) wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(candidate.Id, ct); if (wt is null || wt.State != WorktreeState.Active || !Directory.Exists(wt.Path)) return; IReadOnlyList ownFiles; try { if (await _git.HasChangesAsync(wt.Path, includeUntracked: false, ct)) return; ownFiles = await _git.GetChangedFileNamesAsync(wt.Path, wt.BaseCommit, "HEAD", ct); } catch (Exception ex) { _logger.LogWarning(ex, "Auto-rebase: could not inspect worktree for task {TaskId}", candidate.Id); return; } if (!landedFiles.Intersect(ownFiles, StringComparer.OrdinalIgnoreCase).Any()) return; // merely behind var (exitCode, stderr, conflictFiles) = await _git.RebaseAsync(wt.Path, targetBranch, ct); if (exitCode != 0) { var detail = conflictFiles.Count > 0 ? $"conflicts in {string.Join(", ", conflictFiles)}" : stderr; _logger.LogWarning( "Auto-rebase of task {TaskId} branch {Branch} onto {Target} failed, left as-is: {Detail}", candidate.Id, wt.BranchName, targetBranch, detail); await _broadcaster.WorkerLog( $"Auto-rebase failed for #{candidate.Number} \"{candidate.Title}\" onto {targetBranch} — left as-is ({detail})", WorkerLogLevel.Warn, DateTime.UtcNow); return; } var newHead = await _git.RevParseHeadAsync(wt.Path, ct); using (var ctx = _dbFactory.CreateDbContext()) await new WorktreeRepository(ctx).SetRebasedAsync(candidate.Id, newTargetTip, newHead, ct); await _broadcaster.WorktreeUpdated(candidate.Id); _logger.LogInformation( "Auto-rebased task {TaskId} branch {Branch} onto {Target}", candidate.Id, wt.BranchName, targetBranch); } public async Task MergeAsync( string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool leaveConflictsInTree, CancellationToken ct, IProgress? progress = null, bool skipVerify = false) { var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct); if (task.Status == TaskStatus.Running) return Blocked("task is running"); if (wt is null) return Blocked("task has no worktree"); if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}"); if (string.IsNullOrWhiteSpace(list.WorkingDir)) return Blocked("list has no working directory"); // Announced before the gate wait: another merge holding the repo is itself a reason the // caller sees nothing happen, and a UI waiting on this call needs a phase to show at once. await _broadcaster.OperationProgress(taskId, PhaseMerging, 0, 0); var gate = GetMergeGate(list.WorkingDir); await gate.WaitAsync(ct); try { if (!await _git.IsGitRepoAsync(list.WorkingDir, ct)) return Blocked("working directory is not a git repository"); if (await _git.IsMidMergeAsync(list.WorkingDir, ct)) return Blocked("target working directory is mid-merge"); if (await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct)) return Blocked("target working tree has uncommitted changes"); var currentBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct); if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal)) { try { await _git.CheckoutBranchAsync(list.WorkingDir, targetBranch, ct); } catch (Exception ex) { return Blocked($"failed to switch target branch: {ex.Message}"); } } var collision = await CheckUntrackedCollisionsAsync(list.WorkingDir, "HEAD", wt.Path, wt.BranchName, ct); if (collision is not null) return collision; var oldTargetTip = await _git.RevParseHeadAsync(list.WorkingDir, ct); var message = string.IsNullOrWhiteSpace(commitMessage) ? DefaultMergeMessage(task, list) : commitMessage; var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, message, ct); if (exitCode != 0) { List files; try { files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); } catch { files = new(); } if (leaveConflictsInTree && files.Count > 0) { return new MergeResult(StatusConflict, files, null); } // If abort fails the repo is left mid-merge; the caller must resolve manually. // Return Blocked (not conflict) so the UI does not offer a stale conflict list. try { await _git.MergeAbortAsync(list.WorkingDir, ct); } catch (Exception ex) { _logger.LogError(ex, "git merge --abort failed after conflict — repo is mid-merge"); return Blocked($"merge conflict and abort failed: {ex.Message} — repo is mid-merge, resolve manually"); } if (files.Count == 0) { // Non-conflict failure (e.g. unrelated histories). return new MergeResult(StatusBlocked, Array.Empty(), $"merge failed: {stderr}"); } return new MergeResult(StatusConflict, files, null); } var mergeSha = await _git.RevParseHeadAsync(list.WorkingDir, ct); string? cleanupWarning = null; if (removeWorktree) { try { await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: false, ct); try { await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: false, ct); } catch (Exception ex) { _logger.LogWarning(ex, "branch delete failed for {Branch}", wt.BranchName); cleanupWarning = $"worktree removed, branch delete failed: {ex.Message}"; } } catch (Exception ex) { _logger.LogWarning(ex, "worktree remove failed for {Path}", wt.Path); cleanupWarning = $"worktree remove failed: {ex.Message}"; } } await MarkWorktreeMergedAsync(taskId, mergeSha, ct); await RebaseOthersAfterMergeAsync(task, list, targetBranch, oldTargetTip, mergeSha, ct); // The merge itself is instant; the verify gate is what makes this call take minutes. // Tell every client (the waiting modal and the footer log strip) that it started — // silence here is what makes a working merge look like a dead button. if (!string.IsNullOrWhiteSpace(verifyCommand)) { if (skipVerify) { // Batch mode: the caller defers the gate to one RunListVerifyAsync at the end. // The Done transition is withheld with it — same invariant as verify_failed. await _broadcaster.WorkerLog( $"Merged #{task.Number} \"{task.Title}\" into {targetBranch} — verify deferred to batch", WorkerLogLevel.Info, DateTime.UtcNow); return new MergeResult(StatusMergedVerifyPending, Array.Empty(), cleanupWarning); } await _broadcaster.OperationProgress(taskId, PhaseVerifying, 0, 0); await _broadcaster.WorkerLog( $"Verify command running after merging #{task.Number} \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Info, DateTime.UtcNow); } var verifyFailure = await RunVerifyGateAsync( verifyCommand, list.WorkingDir, ct, progress, elapsed => _ = _broadcaster.OperationProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds, 0)); if (verifyFailure is not null) { _logger.LogWarning("Verify command failed after merging task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage); await _broadcaster.WorkerLog($"Verify failed for #{task.Number} \"{task.Title}\" after merge into {targetBranch}", WorkerLogLevel.Warn, DateTime.UtcNow); return verifyFailure; } await ApproveIfWaitingForReviewAsync(task, ct); _logger.LogInformation( "Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})", taskId, wt.BranchName, targetBranch, removeWorktree); await _broadcaster.WorkerLog($"Merged #{task.Number} \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Success, DateTime.UtcNow); return new MergeResult(StatusMerged, Array.Empty(), cleanupWarning); } finally { gate.Release(); } } public Task MergeAsync( string taskId, string targetBranch, bool removeWorktree, string commitMessage, CancellationToken ct) => MergeAsync(taskId, targetBranch, removeWorktree, commitMessage, leaveConflictsInTree: false, ct); public async Task ContinueMergeAsync( string taskId, CancellationToken ct, IProgress? progress = null, bool skipVerify = false) { var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct); if (wt is null) return Blocked("task has no worktree"); if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}"); if (string.IsNullOrWhiteSpace(list.WorkingDir)) return Blocked("list has no working directory"); var gate = GetMergeGate(list.WorkingDir); await gate.WaitAsync(ct); try { if (!await _git.IsMidMergeAsync(list.WorkingDir, ct)) return Blocked("repo is not mid-merge"); // HEAD still points at the pre-merge tip here: a conflicted `git merge` never moves it, // only MERGE_HEAD plus the working tree/index change. var oldTargetTip = await _git.RevParseHeadAsync(list.WorkingDir, ct); // Validate BEFORE staging: `git add` marks a conflicted path resolved regardless of // its content, so an unresolved file needs a positively-checked resolution, not just // "whatever happens to be on disk". For an ordinary content conflict, git always // starts the file with markers, so their absence reliably means someone resolved it // (in-app or in an external editor). A modify/delete or rename/delete conflict never // gets markers at all (GetConflictDocumentsAsync) — the same marker-less state also // describes a file nobody has touched — so those are additionally checked against // what git itself left on disk by default (whichever side wasn't deleted); still // matching that default means still unresolved. var unresolved = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); var stillConflicted = new List(); var toAdd = new List(); var toRemove = new List(); foreach (var path in unresolved) { var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar)); string? text = null; if (File.Exists(full)) { try { text = await File.ReadAllTextAsync(full, ct); } catch { /* treated as unresolved below */ } } if (text is not null && (LooksBinary(text) || ConflictMarkerParser.HasConflicts(text))) { stillConflicted.Add(path); continue; } var oursStage = await _git.ShowConflictStageAsync(list.WorkingDir, 2, path, ct); var theirsStage = await _git.ShowConflictStageAsync(list.WorkingDir, 3, path, ct); var isModifyDeleteStyle = oursStage is null || theirsStage is null; if (isModifyDeleteStyle && NormalizeLineEndings(text ?? "") == NormalizeLineEndings(oursStage ?? theirsStage ?? "")) { stillConflicted.Add(path); continue; } if (text is null || text.Length == 0) toRemove.Add(path); else toAdd.Add(path); } if (stillConflicted.Count > 0) return new MergeResult(StatusConflict, stillConflicted, "conflicts not fully resolved"); // Closes the window between the original `git merge` (which already refused any // untracked collision at that point) and this call: an untracked file matching one // of the branch's own added paths could have appeared in the meantime, leaving the // index and the working tree disagreeing about that path — committing then would // either lose the local content or silently drop the branch's own added file. var collision = await CheckUntrackedCollisionsAsync(list.WorkingDir, "HEAD", wt.Path, wt.BranchName, ct); if (collision is not null) return collision; // Stage exactly the resolved conflict paths — never `git add -A`, which would sweep // untracked/unrelated changes left by other sessions into this merge commit (the // target working dir is shared). foreach (var path in toAdd) await _git.AddPathAsync(list.WorkingDir, path, ct); foreach (var path in toRemove) await _git.RemovePathAsync(list.WorkingDir, path, ct); var remaining = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); if (remaining.Count > 0) return new MergeResult(StatusConflict, remaining, "conflicts not fully resolved"); try { await _git.CommitAsync(list.WorkingDir, $"Merge branch '{wt.BranchName}'", ct); } catch (Exception ex) { return Blocked($"commit failed: {ex.Message}"); } var mergeSha = await _git.RevParseHeadAsync(list.WorkingDir, ct); await MarkWorktreeMergedAsync(taskId, mergeSha, ct); var targetBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct); await RebaseOthersAfterMergeAsync(task, list, targetBranch, oldTargetTip, mergeSha, ct); if (!string.IsNullOrWhiteSpace(verifyCommand) && skipVerify) { await _broadcaster.WorkerLog( $"Merged #{task.Number} \"{task.Title}\" (conflict resolved) — verify deferred to batch", WorkerLogLevel.Info, DateTime.UtcNow); return new MergeResult(StatusMergedVerifyPending, Array.Empty(), null); } var verifyFailure = await RunVerifyGateAsync(verifyCommand, list.WorkingDir, ct, progress); if (verifyFailure is not null) { _logger.LogWarning("Verify command failed after continuing merge of task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage); await _broadcaster.WorkerLog($"Verify failed for #{task.Number} \"{task.Title}\" after merge", WorkerLogLevel.Warn, DateTime.UtcNow); return verifyFailure; } await ApproveIfWaitingForReviewAsync(task, ct); _logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName); return new MergeResult(StatusMerged, Array.Empty(), null); } finally { gate.Release(); } } public async Task AbortMergeAsync(string taskId, CancellationToken ct) { var (_, list, wt, _) = await LoadMergeContextAsync(taskId, ct); if (wt is null) return Blocked("task has no worktree"); if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}"); if (string.IsNullOrWhiteSpace(list.WorkingDir)) return Blocked("list has no working directory"); if (!await _git.IsMidMergeAsync(list.WorkingDir, ct)) return Blocked("repo is not mid-merge"); try { await _git.MergeAbortAsync(list.WorkingDir, ct); } catch (Exception ex) { return Blocked($"abort failed: {ex.Message}"); } _logger.LogInformation("Aborted merge of task {TaskId}", taskId); return new MergeResult(StatusAborted, Array.Empty(), null); } /// /// Reverts the merge commit recorded for this task () /// via `git revert -m 1`, a new commit that undoes the merge without rewriting history — the /// target working directory is shared with other sessions, so a reset/rebase is never an option. /// On success the task returns to WaitingForReview so it can be reconsidered, and the worktree /// state moves to Kept: Merged/Discarded are swept by WorktreeMaintenanceService, and by the time /// a merge can be reverted its worktree directory and branch are typically already gone (removed /// during the original merge cleanup), so Active — which implies a live, resumable worktree — /// would be misleading. A conflicting revert is aborted immediately (`git revert --abort`); no /// partial/half-resolved state is ever left in the tree. /// public async Task RevertMergeAsync(string taskId, string targetBranch, CancellationToken ct) { var (task, list, wt, _) = await LoadMergeContextAsync(taskId, ct); if (task.Status != TaskStatus.Done) return RevertBlocked("task is not Done; only a merged task's revert can be undone"); if (wt is null) return RevertBlocked("task has no worktree"); if (wt.State != WorktreeState.Merged) return RevertBlocked($"worktree state is {wt.State}, expected Merged"); if (string.IsNullOrWhiteSpace(wt.MergeCommit)) return RevertBlocked("no merge commit recorded for this task; cannot revert"); if (string.IsNullOrWhiteSpace(list.WorkingDir)) return RevertBlocked("list has no working directory"); if (!await _git.IsGitRepoAsync(list.WorkingDir, ct)) return RevertBlocked("working directory is not a git repository"); if (await _git.IsMidMergeAsync(list.WorkingDir, ct)) return RevertBlocked("target working directory is mid-merge"); if (await _git.IsMidRevertAsync(list.WorkingDir, ct)) return RevertBlocked("target working directory is mid-revert"); if (await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct)) return RevertBlocked("target working tree has uncommitted changes"); var currentBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct); if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal)) { try { await _git.CheckoutBranchAsync(list.WorkingDir, targetBranch, ct); } catch (Exception ex) { return RevertBlocked($"failed to switch target branch: {ex.Message}"); } } var (exitCode, stderr) = await _git.RevertMergeCommitAsync(list.WorkingDir, wt.MergeCommit!, ct); if (exitCode != 0) { List files; try { files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); } catch { files = new(); } try { await _git.RevertAbortAsync(list.WorkingDir, ct); } catch (Exception ex) { _logger.LogError(ex, "git revert --abort failed after conflict — repo is mid-revert"); return RevertBlocked($"revert conflict and abort failed: {ex.Message} — repo is mid-revert, resolve manually"); } if (files.Count == 0) return RevertBlocked($"revert failed: {stderr}"); return new RevertResult(StatusConflictAborted, null, files, "revert conflicted; aborted cleanly, no changes made"); } var revertSha = await _git.RevParseHeadAsync(list.WorkingDir, ct); using (var ctx = _dbFactory.CreateDbContext()) { await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Kept, ct); } await _broadcaster.WorktreeUpdated(taskId); await _state.ForceSetStatusAsync(taskId, TaskStatus.WaitingForReview, ct); _logger.LogInformation( "Reverted merge of task {TaskId} (merge commit {MergeSha}) via revert commit {RevertSha}", taskId, wt.MergeCommit, revertSha); await _broadcaster.WorkerLog($"Reverted merge of #{task.Number} \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow); return new RevertResult(StatusReverted, revertSha, Array.Empty(), null); } /// /// Reads each conflicted working-tree file and parses its conflict markers into line-level /// segments (with the diff3 merge base when present). Binary files are flagged and skipped. /// A path with no text markers on disk is either an ordinary content conflict that's already /// been resolved (git keeps both index stages until the path is staged, whether or not the /// working tree still looks conflicted — reads as a single stable segment, 0 hunks left), or /// a modify/delete or rename/delete conflict — git never writes markers for those at all, it /// just leaves whichever side wasn't deleted sitting in the working tree, so the same /// "no markers" state also describes an untouched file. The index tells the two apart: a /// modify/delete-style conflict only ever populates ONE of the ours/theirs stages. Those are /// synthesized into a single whole-file conflict block straight from the stages (empty side /// = that side deleted the path) so the resolver still shows a real choice. /// public async Task GetConflictDocumentsAsync(string taskId, CancellationToken ct) { var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct); if (string.IsNullOrWhiteSpace(list.WorkingDir)) throw new InvalidOperationException("list has no working directory"); var files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); var result = new List(files.Count); foreach (var path in files) { var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar)); string? diskText = null; if (File.Exists(full)) { try { diskText = await File.ReadAllTextAsync(full, ct); } catch { /* unreadable — fall through to the index stages below */ } } if (diskText is not null && LooksBinary(diskText)) { result.Add(new ConflictDocumentContent(path, true, Array.Empty())); continue; } if (diskText is not null && ConflictMarkerParser.HasConflicts(diskText)) { result.Add(new ConflictDocumentContent(path, false, ConflictMarkerParser.Parse(diskText))); continue; } var ours = await _git.ShowConflictStageAsync(list.WorkingDir, 2, path, ct); var theirs = await _git.ShowConflictStageAsync(list.WorkingDir, 3, path, ct); if (ours is not null && theirs is not null) { // Ordinary two-sided conflict, already resolved (no markers left) but not yet staged. result.Add(new ConflictDocumentContent(path, false, new[] { MergeSegment.Stable(diskText ?? "") })); continue; } if (LooksBinary(ours ?? "") || LooksBinary(theirs ?? "")) { result.Add(new ConflictDocumentContent(path, true, Array.Empty())); continue; } var @base = await _git.ShowConflictStageAsync(list.WorkingDir, 1, path, ct); result.Add(new ConflictDocumentContent( path, false, new[] { MergeSegment.Conflict(ours ?? "", @base, theirs ?? "") })); } return new ConflictDocuments(taskId, result); } // Working-tree checkouts can go through autocrlf while `git show :stage:path` never does — // normalize before comparing the two or an untouched file reads as "resolved" on a machine // with autocrlf enabled. private static string NormalizeLineEndings(string text) => text.Replace("\r\n", "\n").Replace('\r', '\n'); // A NUL byte in the head of the file is the conventional binary sniff. private static bool LooksBinary(string text) { var n = Math.Min(text.Length, 8000); for (var i = 0; i < n; i++) if (text[i] == '\0') return true; return false; } public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct) { var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct); if (string.IsNullOrWhiteSpace(list.WorkingDir)) throw new InvalidOperationException("list has no working directory"); if (content.Length == 0) { // An empty resolution for a whole-file conflict (modify/delete, rename/delete) means // "keep the deletion" — `git add` on an empty file would instead stage it as a // tracked, zero-byte file, which is not what accepting the deleted side means. await _git.RemovePathAsync(list.WorkingDir, path, ct); return; } var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar)); await File.WriteAllTextAsync(full, content, ct); await _git.AddPathAsync(list.WorkingDir, path, ct); } /// /// Writes a conflicted file's content without staging it. Unlike this must /// NOT run `git add` — that marks the path resolved in git's index regardless of its content, so a partially /// resolved hunk (markers still present elsewhere in the file) would drop out of /// 's conflicted-file list and 's /// pre-stage marker scan, letting a still-conflicted file slip into a commit. Staging happens once, for /// everything, inside . /// public async Task WriteConflictFileAsync(string taskId, string path, string content, CancellationToken ct) { var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct); if (string.IsNullOrWhiteSpace(list.WorkingDir)) throw new InvalidOperationException("list has no working directory"); var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar)); await File.WriteAllTextAsync(full, content, ct); } public async Task GetTargetsAsync(string taskId, CancellationToken ct) { var (task, list, _, _) = await LoadMergeContextAsync(taskId, ct); var defaultMessage = DefaultMergeMessage(task, list); if (string.IsNullOrWhiteSpace(list.WorkingDir)) return new MergeTargets("", Array.Empty(), defaultMessage); var current = await _git.GetCurrentBranchAsync(list.WorkingDir, ct); var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct); return new MergeTargets(current, branches, defaultMessage); } /// The commit message a merge uses when the caller passes none. Built here rather than in the /// UI/MCP callers because only this layer knows the task's commit type and its list's name. private static string DefaultMergeMessage(TaskEntity task, ListEntity list) => CommitMessageBuilder.BuildMerge(task.CommitType, list.Name, task.Title, task.Id); public Task PreviewAsync(string taskId, string targetBranch, CancellationToken ct) => PreviewAsync(taskId, targetBranch, runVerify: false, ct); /// /// Non-destructive merge preview via . When /// is true and the list has a verify command configured, a clean /// preview is additionally materialized into a scratch worktree (outside the target repo, always /// cleaned up) and built/tested there — the real working tree is never touched. No verify /// command configured, or runVerify=false, reproduces the pre-existing behavior exactly: no /// delay, no scratch worktree. /// public async Task PreviewAsync( string taskId, string targetBranch, bool runVerify, CancellationToken ct, IProgress? progress = null) { var (_, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct); if (wt is null || wt.State != WorktreeState.Active) return new MergePreviewResult(PreviewUnavailable, Array.Empty(), 0); if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct)) return new MergePreviewResult(PreviewUnavailable, Array.Empty(), 0); var target = string.IsNullOrWhiteSpace(targetBranch) ? await _git.GetCurrentBranchAsync(list.WorkingDir, ct) : targetBranch; // merge-tree (below) is a pure object-level check — it has no idea the working directory // has an untracked file that would block (or, worse, silently be lost by) the real merge. var collision = await CheckUntrackedCollisionsAsync(list.WorkingDir, target, wt.Path, wt.BranchName, ct); if (collision is not null) return new MergePreviewResult(PreviewUntrackedCollision, collision.ConflictFiles, 0); var preview = await _git.PreviewMergeAsync(list.WorkingDir, target, wt.BranchName, ct); if (!preview.Supported) return new MergePreviewResult(PreviewUnavailable, Array.Empty(), 0); if (!preview.Clean) return new MergePreviewResult(PreviewConflict, preview.ConflictFiles, 0); var count = await _git.CountChangedFilesAsync(list.WorkingDir, target, wt.BranchName, ct); if (!runVerify || string.IsNullOrWhiteSpace(verifyCommand) || preview.TreeOid is null) return new MergePreviewResult(PreviewClean, Array.Empty(), count); var (exitCode, durationMs, outputTail) = await RunPreviewVerifyAsync( list.WorkingDir, target, preview.TreeOid, verifyCommand, ct, progress); return new MergePreviewResult(PreviewClean, Array.Empty(), count, exitCode, durationMs, outputTail); } /// /// Builds the tree a clean merge-tree preview would produce into a scratch, detached-HEAD /// worktree (never a branch, never the real working tree) and runs the verify command there. /// The scratch worktree lives under the OS temp dir and is always removed, even on failure or /// cancellation. /// private async Task<(int ExitCode, long DurationMs, string OutputTail)> RunPreviewVerifyAsync( string repoDir, string targetBranch, string treeOid, string verifyCommand, CancellationToken ct, IProgress? progress = null) { var sw = System.Diagnostics.Stopwatch.StartNew(); var scratchPath = Path.Combine(Path.GetTempPath(), "claudedo-preview-verify", Guid.NewGuid().ToString("N")); try { var parentSha = await _git.RevParseAsync(repoDir, targetBranch, ct); var commitSha = await _git.CommitTreeAsync(repoDir, treeOid, parentSha, "claudedo preview verify", ct); Directory.CreateDirectory(Path.GetDirectoryName(scratchPath)!); await _git.WorktreeAddDetachedAsync(repoDir, scratchPath, commitSha, ct); VerifyCommandResult result; try { result = await ProgressReporter.RunAsync( _verify.RunAsync(scratchPath, verifyCommand, VerifyTimeout, ct), ProgressReportInterval, progress, "merge preview verify running"); } catch (Exception ex) { return (-1, sw.ElapsedMilliseconds, $"verify command failed to start: {ex.Message}"); } var exitCode = result.TimedOut ? -1 : result.ExitCode; var tail = exitCode == 0 ? "" : TailOutput(result.Output); return (exitCode, sw.ElapsedMilliseconds, tail); } catch (Exception ex) { return (-1, sw.ElapsedMilliseconds, $"failed to prepare merge preview for verification: {ex.Message}"); } finally { // Best-effort cleanup on the un-cancelled token — a caller-cancelled ct must not // leave the scratch worktree behind. try { await _git.WorktreeRemoveAsync(repoDir, scratchPath, force: true, CancellationToken.None); } catch { /* nothing more we can do */ } try { if (Directory.Exists(scratchPath)) Directory.Delete(scratchPath, recursive: true); } catch { /* best-effort */ } } } public Task ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct) => ApproveAndMergeAsync(taskId, targetBranch, leaveConflictsInTree: false, ct); public async Task ApproveAndMergeAsync( string taskId, string targetBranch, bool leaveConflictsInTree, CancellationToken ct, IProgress? progress = null, bool skipVerify = false) { var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct); if (task.Status != TaskStatus.WaitingForReview) return Blocked("task is not waiting for review"); if (wt is null || wt.State != WorktreeState.Active) { // There is nothing left to merge -- a sandbox run, or a list-handler task that // committed straight into the list's working dir. The verify command still has to // pass before the task may reach Done: skipping it here would exempt exactly the // runs that land the most on the target branch at once. Same working dir and same // per-repo gate as the merge path, so a concurrent merge can't land mid-verify. if (!string.IsNullOrWhiteSpace(verifyCommand) && !string.IsNullOrWhiteSpace(list.WorkingDir)) { if (skipVerify) return new MergeResult(StatusMergedVerifyPending, Array.Empty(), null); var verifyGate = GetMergeGate(list.WorkingDir!); await _broadcaster.OperationProgress(taskId, PhaseVerifying, 0, 0); await verifyGate.WaitAsync(ct); try { // Same reason as the post-merge gate: this holds the approve call for minutes. var failed = await RunVerifyGateAsync( verifyCommand, list.WorkingDir!, ct, progress, elapsed => _ = _broadcaster.OperationProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds, 0)); if (failed is not null) return failed; } finally { verifyGate.Release(); } } var done = await _state.ApproveReviewAsync(taskId, ct); return done.Ok ? new MergeResult(StatusMerged, Array.Empty(), null) : Blocked(done.Reason ?? "approve failed"); } if (string.IsNullOrWhiteSpace(list.WorkingDir)) return Blocked("list has no working directory"); var target = string.IsNullOrWhiteSpace(targetBranch) ? await _git.GetCurrentBranchAsync(list.WorkingDir, ct) : targetBranch; // MergeAsync transitions the task WaitingForReview -> Done on a successful merge. // Remove the worktree on approve (matching the unit-merge path) so merged // worktrees don't pile up; the merge commit on the target branch is the record. return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify); } /// /// The batch counterpart to the per-merge verify gate: runs the list's verify command ONCE /// (under the same per-repo gate) and, on success, promotes every listed task to Done via /// ApproveReviewAsync. Tasks were merged with skipVerify=true and sit in WaitingForReview. /// A task is only eligible when its work verifiably landed: worktree state Merged, or no /// worktree at all (sandbox/list-handler run) -- an Active worktree means the branch was /// never merged, and promoting it would produce a Done task with an unmerged branch. /// No verify command configured = no gate (same rule as everywhere else): promote directly. /// public async Task RunListVerifyAsync( string listId, IReadOnlyList taskIds, CancellationToken ct, IProgress? progress = null) { if (taskIds.Count == 0) return new ListVerifyResult(StatusBlocked, Array.Empty(), "no task ids given"); ListEntity? list; string? verifyCommand; var candidates = new List<(TaskEntity Task, string? IneligibleReason)>(); using (var ctx = _dbFactory.CreateDbContext()) { var listRepo = new ListRepository(ctx); list = await listRepo.GetByIdAsync(listId, ct); if (list is null) return new ListVerifyResult(StatusBlocked, Array.Empty(), $"list '{listId}' not found"); verifyCommand = (await listRepo.GetConfigAsync(listId, ct))?.VerifyCommand; var taskRepo = new TaskRepository(ctx); var wtRepo = new WorktreeRepository(ctx); foreach (var id in taskIds.Distinct(StringComparer.OrdinalIgnoreCase)) { var task = await taskRepo.GetByIdAsync(id, ct); if (task is null) return new ListVerifyResult(StatusBlocked, Array.Empty(), $"task '{id}' not found"); var reason = task.ListId != list.Id ? "task belongs to a different list" : task.Status != TaskStatus.WaitingForReview ? $"task is {task.Status}, not WaitingForReview" : (await wtRepo.GetByTaskIdAsync(task.Id, ct)) is { } wt && wt.State != WorktreeState.Merged ? $"worktree state is {wt.State}, not Merged — its branch never landed" : null; candidates.Add((task, reason)); } } if (string.IsNullOrWhiteSpace(list.WorkingDir)) return new ListVerifyResult(StatusBlocked, Array.Empty(), "list has no working directory"); if (!string.IsNullOrWhiteSpace(verifyCommand)) { var gate = GetMergeGate(list.WorkingDir); await _broadcaster.OperationProgress(listId, PhaseVerifying, 0, 0); await gate.WaitAsync(ct); MergeResult? failure; try { await _broadcaster.WorkerLog( $"Batch verify running for {candidates.Count} merged task(s) in \"{list.Name}\"", WorkerLogLevel.Info, DateTime.UtcNow); failure = await RunVerifyGateAsync( verifyCommand, list.WorkingDir, ct, progress, elapsed => _ = _broadcaster.OperationProgress(listId, PhaseVerifying, (int)elapsed.TotalSeconds, 0)); } finally { gate.Release(); } if (failure is not null) { await _broadcaster.WorkerLog( $"Batch verify failed in \"{list.Name}\" — {candidates.Count} task(s) stay out of Done", WorkerLogLevel.Warn, DateTime.UtcNow); var failed = candidates .Select(c => new ListVerifyTaskOutcome(c.Task.Id, c.Task.Number, false, c.IneligibleReason ?? "verify failed")) .ToList(); return new ListVerifyResult(StatusVerifyFailed, failed, failure.ErrorMessage); } } var outcomes = new List(candidates.Count); foreach (var (task, ineligibleReason) in candidates) { if (ineligibleReason is not null) { outcomes.Add(new ListVerifyTaskOutcome(task.Id, task.Number, false, ineligibleReason)); continue; } var result = await _state.ApproveReviewAsync(task.Id, ct); outcomes.Add(new ListVerifyTaskOutcome(task.Id, task.Number, result.Ok, result.Ok ? null : result.Reason)); } var promoted = outcomes.Count(o => o.Promoted); await _broadcaster.WorkerLog( $"Batch verify passed in \"{list.Name}\" — {promoted} task(s) promoted to Done", WorkerLogLevel.Success, DateTime.UtcNow); return new ListVerifyResult(StatusVerifyPassed, outcomes, null); } private static MergeResult Blocked(string reason) => new(StatusBlocked, Array.Empty(), reason); private static RevertResult RevertBlocked(string reason) => new(StatusBlocked, null, Array.Empty(), reason); }