Slice 4/5 of task-numbers: TaskRowViewModel.Number renders as a dimmed "#123" before the row title; DetailsIslandViewModel.TaskIdBadge now shows "#123" instead of the unusable "#T<guid-prefix>" handle; and the curated WorkerLog business events in TaskRunner, TaskMergeService, and TaskResetService prefix their quoted title with "#<Number>".
862 lines
41 KiB
C#
862 lines
41 KiB
C#
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<string> ConflictFiles,
|
|
string? ErrorMessage);
|
|
|
|
public sealed record MergeTargets(
|
|
string DefaultBranch,
|
|
IReadOnlyList<string> 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<string> ConflictFiles,
|
|
int ChangedFileCount,
|
|
int? VerifyExitCode = null,
|
|
long? VerifyDurationMs = null,
|
|
string? VerifyOutputTail = null);
|
|
|
|
public sealed record ConflictDocuments(
|
|
string TaskId,
|
|
IReadOnlyList<ConflictDocumentContent> Files);
|
|
|
|
public sealed record ConflictDocumentContent(
|
|
string Path,
|
|
bool IsBinary,
|
|
IReadOnlyList<MergeSegment> Segments);
|
|
|
|
public sealed record RevertResult(
|
|
string Status,
|
|
string? RevertCommit,
|
|
IReadOnlyList<string> 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<string, SemaphoreSlim> MergeGates =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
private static SemaphoreSlim GetMergeGate(string workingDir) =>
|
|
MergeGates.GetOrAdd(workingDir, static _ => new SemaphoreSlim(1, 1));
|
|
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly GitService _git;
|
|
private readonly HubBroadcaster _broadcaster;
|
|
private readonly ITaskStateService _state;
|
|
private readonly IVerifyCommandRunner _verify;
|
|
private readonly ILogger<TaskMergeService> _logger;
|
|
|
|
public TaskMergeService(
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
GitService git,
|
|
HubBroadcaster broadcaster,
|
|
ITaskStateService state,
|
|
IVerifyCommandRunner verify,
|
|
ILogger<TaskMergeService> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs the list's configured verify command (if any) in <paramref name="workingDir"/> after
|
|
/// a successful merge. Returns null when there is nothing to gate on (identical to today's
|
|
/// behavior); otherwise returns the terminal <see cref="MergeResult"/> 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).
|
|
/// </summary>
|
|
private async Task<MergeResult?> 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<string>(),
|
|
$"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<string>(), $"{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..];
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <paramref name="branchName"/> newly added since its merge base with <paramref name="targetRef"/>
|
|
/// against what's currently untracked in <paramref name="workingDir"/> — reusing
|
|
/// <see cref="GitService.GetCommittedFilesAsync"/> (run against the branch's own worktree, where
|
|
/// HEAD is the branch tip) and <see cref="GitService.GetStatusPorcelainAsync"/> rather than adding
|
|
/// new git plumbing. Returns null when there's nothing to flag.
|
|
/// </summary>
|
|
private async Task<MergeResult?> 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<string> 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<string>(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<string> ParseAddedPaths(string nameStatus)
|
|
{
|
|
var result = new List<string>();
|
|
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<string> ParseUntrackedPaths(string porcelain)
|
|
{
|
|
var result = new List<string>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// After a merge lands on <paramref name="targetBranch"/>, 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.
|
|
/// </summary>
|
|
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<string> 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<TaskEntity> 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<string> 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<string> 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<MergeResult> 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<string> 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<string>(), $"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<string>(), cleanupWarning);
|
|
}
|
|
finally { gate.Release(); }
|
|
}
|
|
|
|
public Task<MergeResult> MergeAsync(
|
|
string taskId,
|
|
string targetBranch,
|
|
bool removeWorktree,
|
|
string commitMessage,
|
|
CancellationToken ct)
|
|
=> MergeAsync(taskId, targetBranch, removeWorktree, commitMessage, leaveConflictsInTree: false, ct);
|
|
|
|
public async Task<MergeResult> 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<string>();
|
|
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<string>(), null);
|
|
}
|
|
finally { gate.Release(); }
|
|
}
|
|
|
|
public async Task<MergeResult> 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<string>(), null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reverts the merge commit recorded for this task (<see cref="WorktreeEntity.MergeCommit"/>)
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<RevertResult> 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<string> 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<string>(), null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<ConflictDocuments> 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<ConflictDocumentContent>(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<MergeSegment>()));
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes a conflicted file's content without staging it. Unlike <see cref="WriteResolutionAsync"/> 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
|
|
/// <see cref="GetConflictDocumentsAsync"/>'s conflicted-file list and <see cref="ContinueMergeAsync"/>'s
|
|
/// pre-stage marker scan, letting a still-conflicted file slip into a commit. Staging happens once, for
|
|
/// everything, inside <see cref="ContinueMergeAsync"/>.
|
|
/// </summary>
|
|
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<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct)
|
|
{
|
|
var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct);
|
|
|
|
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
|
return new MergeTargets("", Array.Empty<string>());
|
|
|
|
var current = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
|
|
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
|
|
return new MergeTargets(current, branches);
|
|
}
|
|
|
|
public Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct)
|
|
=> PreviewAsync(taskId, targetBranch, runVerify: false, ct);
|
|
|
|
/// <summary>
|
|
/// Non-destructive merge preview via <see cref="GitService.PreviewMergeAsync"/>. When
|
|
/// <paramref name="runVerify"/> 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.
|
|
/// </summary>
|
|
public async Task<MergePreviewResult> 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<string>(), 0);
|
|
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct))
|
|
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 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<string>(), 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<string>(), count);
|
|
|
|
var (exitCode, durationMs, outputTail) = await RunPreviewVerifyAsync(
|
|
list.WorkingDir, target, preview.TreeOid, verifyCommand, ct);
|
|
return new MergePreviewResult(PreviewClean, Array.Empty<string>(), count, exitCode, durationMs, outputTail);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct)
|
|
=> ApproveAndMergeAsync(taskId, targetBranch, leaveConflictsInTree: false, ct);
|
|
|
|
public async Task<MergeResult> 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<string>(), 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<string>(), reason);
|
|
|
|
private static RevertResult RevertBlocked(string reason) =>
|
|
new(StatusBlocked, null, Array.Empty<string>(), reason);
|
|
}
|