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.State; using Microsoft.EntityFrameworkCore; 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); // 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); 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"; public const string StatusUntrackedCollision = "untracked_collision"; public const string StatusReverted = "reverted"; public const string StatusConflictAborted = "conflict_aborted"; 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); // 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) { if (string.IsNullOrWhiteSpace(verifyCommand)) return null; VerifyCommandResult result; try { result = await _verify.RunAsync(workingDir, verifyCommand, VerifyTimeout, ct); } 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(); } foreach (var candidate in candidates) await RebaseOneIfOverlappingAsync(candidate, 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) { 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"); 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 (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, 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); var verifyFailure = await RunVerifyGateAsync(verifyCommand, list.WorkingDir, ct); 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) { 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 with markers still in it would otherwise get // staged (and committed) as-is. Check text content for markers first; binary files // can't carry markers, so they're left to the post-stage index check below. var unresolved = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); var stillConflicted = new List(); foreach (var path in unresolved) { var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar)); string text; try { text = await File.ReadAllTextAsync(full, ct); } catch { continue; } if (!LooksBinary(text) && ConflictMarkerParser.HasConflicts(text)) stillConflicted.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 unresolved) await _git.AddPathAsync(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); var verifyFailure = await RunVerifyGateAsync(verifyCommand, list.WorkingDir, ct); 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. /// 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 text; try { text = await File.ReadAllTextAsync(full, ct); } catch { text = ""; } if (LooksBinary(text)) { result.Add(new ConflictDocumentContent(path, true, Array.Empty())); continue; } result.Add(new ConflictDocumentContent(path, false, ConflictMarkerParser.Parse(text))); } return new ConflictDocuments(taskId, result); } // 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"); 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 (_, list, _, _) = await LoadMergeContextAsync(taskId, ct); if (string.IsNullOrWhiteSpace(list.WorkingDir)) return new MergeTargets("", Array.Empty()); var current = await _git.GetCurrentBranchAsync(list.WorkingDir, ct); var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct); return new MergeTargets(current, branches); } 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) { 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); 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) { 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 _verify.RunAsync(scratchPath, verifyCommand, VerifyTimeout, ct); } 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) { 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)) { var verifyGate = GetMergeGate(list.WorkingDir!); await verifyGate.WaitAsync(ct); try { var failed = await RunVerifyGateAsync(verifyCommand, list.WorkingDir!, ct); 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, $"Merge {wt.BranchName}", leaveConflictsInTree, ct); } private static MergeResult Blocked(string reason) => new(StatusBlocked, Array.Empty(), reason); private static RevertResult RevertBlocked(string reason) => new(StatusBlocked, null, Array.Empty(), reason); }