fix(worker): make re-approve after revert_merge do a real merge, not a silent no-op
ApproveAndMergeAsync treated any non-Active worktree (including Kept, the
state RevertMergeAsync leaves behind) the same as "never had a worktree",
so re-approving a reverted task ran only the verify gate and marked the
task Done without merging anything back.
Now a Kept worktree is distinguished: if its branch is gone (the common
case, since approve normally deletes it), approve returns Blocked instead
of a fake "merged". If the branch survives, its tip is already an ancestor
of the target (from the original merge), so a plain `git merge` would
silently no-op ("Already up to date."); cherry-picking the original merge
commit's own diff (-m 1) is what actually restores the content, recorded
as a fresh MergeCommit.
This commit is contained in:
@@ -356,6 +356,38 @@ public sealed class GitService
|
||||
throw new InvalidOperationException($"git revert --abort failed (exit {exitCode}): {stderr}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-applies a merge commit's own diff (against its first parent, `-m 1`) as a new commit on
|
||||
/// top of HEAD. Needed to redo a merge that was previously reverted: once a branch tip is an
|
||||
/// ancestor of HEAD, `git merge` on that branch again is a silent "Already up to date." no-op
|
||||
/// (ancestry, not content, is what merge checks) -- cherry-picking the original merge commit's
|
||||
/// diff is what actually restores the content.
|
||||
/// </summary>
|
||||
public async Task<(int ExitCode, string Stderr)> CherryPickMergeCommitAsync(
|
||||
string repoDir, string mergeCommitSha, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||
["-c", "merge.conflictStyle=diff3", "cherry-pick", "-m", "1", "--no-edit", mergeCommitSha], ct);
|
||||
return (exitCode, stderr);
|
||||
}
|
||||
|
||||
public async Task<bool> IsMidCherryPickAsync(string repoDir, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["rev-parse", "--git-dir"], ct);
|
||||
if (exitCode != 0) return false;
|
||||
var gitDir = stdout.Trim();
|
||||
if (!Path.IsPathRooted(gitDir))
|
||||
gitDir = Path.Combine(repoDir, gitDir);
|
||||
return File.Exists(Path.Combine(gitDir, "CHERRY_PICK_HEAD"));
|
||||
}
|
||||
|
||||
public async Task CherryPickAbortAsync(string repoDir, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["cherry-pick", "--abort"], ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"git cherry-pick --abort failed (exit {exitCode}): {stderr}");
|
||||
}
|
||||
|
||||
public async Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
|
||||
|
||||
@@ -986,6 +986,48 @@ public sealed class TaskMergeService
|
||||
if (task.Status != TaskStatus.WaitingForReview)
|
||||
return Blocked("task is not waiting for review");
|
||||
|
||||
if (wt is not null && wt.State == WorktreeState.Kept)
|
||||
{
|
||||
// Kept means either RevertMergeAsync undid a previous merge, or the user manually
|
||||
// parked an active worktree -- either way its branch may still exist. Re-running the
|
||||
// real merge (not the "nothing to merge" no-op below) is the only way approve can be
|
||||
// trusted after a revert; if the branch is truly gone (the common case once the
|
||||
// original approve already deleted it via removeWorktree:true) there is nothing left
|
||||
// to merge and that must come back as Blocked, never a silent fake "merged".
|
||||
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct))
|
||||
return Blocked("list has no working directory");
|
||||
|
||||
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
|
||||
if (!branches.Contains(wt.BranchName, StringComparer.Ordinal))
|
||||
return Blocked(
|
||||
$"worktree was reverted and its branch '{wt.BranchName}' no longer exists — cannot re-approve; discard the task or start a fresh worktree instead.");
|
||||
|
||||
var reviveTarget = string.IsNullOrWhiteSpace(targetBranch)
|
||||
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
|
||||
: targetBranch;
|
||||
|
||||
// A branch that was already merged once (reverted or not) is already an ancestor of
|
||||
// the target tip -- `git merge` on it again is a silent "Already up to date." no-op,
|
||||
// since merge only checks ancestry, never content. Detect that case up front and
|
||||
// reapply the original merge commit's own diff instead of falling into the normal
|
||||
// merge path below, which would report success without actually restoring anything.
|
||||
var branchTip = await _git.RevParseAsync(list.WorkingDir, wt.BranchName, ct);
|
||||
var targetTip = await _git.RevParseAsync(list.WorkingDir, reviveTarget, ct);
|
||||
if (await _git.IsAncestorAsync(list.WorkingDir, branchTip, targetTip, ct) == true)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(wt.MergeCommit))
|
||||
return Blocked("worktree branch has no new commits to merge and no merge commit is recorded to reapply");
|
||||
|
||||
return await ReapplyRevertedMergeAsync(
|
||||
task, list, wt, reviveTarget, verifyCommand, ct, progress, skipVerify);
|
||||
}
|
||||
|
||||
using (var ctx = _dbFactory.CreateDbContext())
|
||||
await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Active, ct);
|
||||
await _broadcaster.WorktreeUpdated(taskId);
|
||||
wt.State = WorktreeState.Active;
|
||||
}
|
||||
|
||||
if (wt is null || wt.State != WorktreeState.Active)
|
||||
{
|
||||
// There is nothing left to merge -- a sandbox run, or a list-handler task that
|
||||
@@ -1031,6 +1073,107 @@ public sealed class TaskMergeService
|
||||
return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redoes a merge that <see cref="RevertMergeAsync"/> undid (or that predates it, if the branch
|
||||
/// was already merged before its worktree reached <see cref="WorktreeState.Kept"/>), by
|
||||
/// cherry-picking the original merge commit's own diff onto <paramref name="targetBranch"/> --
|
||||
/// see <see cref="GitService.CherryPickMergeCommitAsync"/> for why a plain re-merge can't be
|
||||
/// used once the branch tip is already an ancestor of the target. Mirrors <see cref="MergeAsync"/>'s
|
||||
/// shape (gate, dirty/mid-op checks, cleanup, verify gate, approve) but produces a cherry-pick
|
||||
/// commit instead of a merge commit, and <see cref="WorktreeRepository.SetMergedAsync"/> still
|
||||
/// records it as this task's <see cref="WorktreeEntity.MergeCommit"/>.
|
||||
/// </summary>
|
||||
private async Task<MergeResult> ReapplyRevertedMergeAsync(
|
||||
TaskEntity task, ListEntity list, WorktreeEntity wt, string targetBranch, string? verifyCommand,
|
||||
CancellationToken ct, IProgress<ProgressNotificationValue>? progress, bool skipVerify)
|
||||
{
|
||||
var taskId = task.Id;
|
||||
var workingDir = list.WorkingDir!;
|
||||
await _broadcaster.OperationProgress(taskId, PhaseMerging, 0, 0);
|
||||
|
||||
var gate = GetMergeGate(workingDir);
|
||||
await gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (await _git.IsMidMergeAsync(workingDir, ct))
|
||||
return Blocked("target working directory is mid-merge");
|
||||
if (await _git.IsMidCherryPickAsync(workingDir, ct))
|
||||
return Blocked("target working directory is mid-cherry-pick");
|
||||
if (await _git.HasChangesAsync(workingDir, includeUntracked: false, ct))
|
||||
return Blocked("target working tree has uncommitted changes");
|
||||
|
||||
var currentBranch = await _git.GetCurrentBranchAsync(workingDir, ct);
|
||||
if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal))
|
||||
{
|
||||
try { await _git.CheckoutBranchAsync(workingDir, targetBranch, ct); }
|
||||
catch (Exception ex) { return Blocked($"failed to switch target branch: {ex.Message}"); }
|
||||
}
|
||||
|
||||
var oldTargetTip = await _git.RevParseHeadAsync(workingDir, ct);
|
||||
|
||||
var (exitCode, stderr) = await _git.CherryPickMergeCommitAsync(workingDir, wt.MergeCommit!, ct);
|
||||
if (exitCode != 0)
|
||||
{
|
||||
try { await _git.CherryPickAbortAsync(workingDir, ct); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "git cherry-pick --abort failed after conflict — repo is mid-cherry-pick");
|
||||
return Blocked($"reapply conflict and abort failed: {ex.Message} — repo is mid-cherry-pick, resolve manually");
|
||||
}
|
||||
return Blocked($"reapplying the reverted merge conflicted and was aborted: {stderr}");
|
||||
}
|
||||
|
||||
var mergeSha = await _git.RevParseHeadAsync(workingDir, ct);
|
||||
|
||||
string? cleanupWarning = null;
|
||||
try
|
||||
{
|
||||
await _git.WorktreeRemoveAsync(workingDir, wt.Path, force: false, ct);
|
||||
try { await _git.BranchDeleteAsync(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);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(verifyCommand) && skipVerify)
|
||||
{
|
||||
await _broadcaster.WorkerLog(
|
||||
$"Re-merged #{task.Number} \"{task.Title}\" into {targetBranch} after a revert — verify deferred to batch",
|
||||
WorkerLogLevel.Info, DateTime.UtcNow);
|
||||
return new MergeResult(StatusMergedVerifyPending, Array.Empty<string>(), cleanupWarning);
|
||||
}
|
||||
|
||||
var verifyFailure = await RunVerifyGateAsync(
|
||||
verifyCommand, workingDir, ct, progress,
|
||||
elapsed => _ = _broadcaster.OperationProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds, 0));
|
||||
if (verifyFailure is not null)
|
||||
{
|
||||
_logger.LogWarning("Verify command failed after reapplying reverted merge for task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
|
||||
await _broadcaster.WorkerLog($"Verify failed for #{task.Number} \"{task.Title}\" after re-merge into {targetBranch}", WorkerLogLevel.Warn, DateTime.UtcNow);
|
||||
return verifyFailure;
|
||||
}
|
||||
|
||||
await ApproveIfWaitingForReviewAsync(task, ct);
|
||||
_logger.LogInformation(
|
||||
"Reapplied reverted merge for task {TaskId} branch {Branch} into {Target}",
|
||||
taskId, wt.BranchName, targetBranch);
|
||||
await _broadcaster.WorkerLog($"Re-merged #{task.Number} \"{task.Title}\" into {targetBranch} after a revert", WorkerLogLevel.Success, DateTime.UtcNow);
|
||||
|
||||
return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
|
||||
}
|
||||
finally { gate.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
||||
Reference in New Issue
Block a user