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:
Mika Kuns
2026-08-28 14:17:38 +02:00
committed by mika kuns
parent 1affb39716
commit 8d597df448
3 changed files with 260 additions and 0 deletions
@@ -1536,6 +1536,91 @@ public class TaskMergeServiceTests : IDisposable
Assert.Contains(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == task.Id);
}
[Fact]
public async Task ApproveAndMergeAsync_AfterRevert_BranchGone_ReturnsBlockedInsteadOfFakeMerge()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
// Approve removes the worktree AND deletes its branch (removeWorktree: true), so after a
// later revert there is genuinely nothing left to re-merge.
var approveResult = await svc.ApproveAndMergeAsync(task.Id, currentBranch, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, approveResult.Status);
var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusReverted, revertResult.Status);
var reapproveResult = await svc.ApproveAndMergeAsync(task.Id, currentBranch, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusBlocked, reapproveResult.Status);
Assert.Contains("branch", reapproveResult.ErrorMessage ?? "", StringComparison.OrdinalIgnoreCase);
Assert.Contains("no longer exists", reapproveResult.ErrorMessage ?? "");
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Kept, wt!.State);
}
[Fact]
public async Task ApproveAndMergeAsync_AfterRevert_BranchStillExists_ReallyMergesAgain()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
// Merge (not approve) with removeWorktree: false, so the branch and worktree dir survive
// the merge -- e.g. a manual merge_task call, or a merge whose cleanup was skipped.
var mergeResult = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
commitMessage: "Merge task", ct: CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, mergeResult.Status);
string? firstMergeCommit;
using (var preRevertCtx = db.CreateContext())
firstMergeCommit = (await new WorktreeRepository(preRevertCtx).GetByTaskIdAsync(task.Id))!.MergeCommit;
var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusReverted, revertResult.Status);
Assert.False(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));
var reapproveResult = await svc.ApproveAndMergeAsync(task.Id, currentBranch, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, reapproveResult.Status);
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, updated!.Status);
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Merged, wt!.State);
Assert.False(string.IsNullOrWhiteSpace(wt.MergeCommit));
Assert.NotEqual(firstMergeCommit, wt.MergeCommit);
}
[Fact]
public async Task RevertMergeAsync_UncommittedChangesInTarget_ReturnsBlocked()
{