feat(worker): record merge commit SHA and add revert_merge tool

Persists the merge commit SHA on WorktreeEntity for every successful
single-task and unit merge, and adds a TaskMergeService.RevertMergeAsync
+ revert_merge MCP tool that undoes a merged task's merge via
`git revert -m 1` (never reset/rewrite, since the target checkout is
shared). Rejects cleanly when there's no recorded SHA, the repo is
mid-merge/mid-revert, or the target has foreign uncommitted changes;
a conflicting revert aborts immediately. Also exposes the new
mergeCommit field via get_task_worktree.
This commit is contained in:
mika kuns
2026-08-05 11:46:51 +02:00
parent 6c5acd09b9
commit 10e561f336
14 changed files with 1452 additions and 14 deletions
@@ -32,6 +32,12 @@ public sealed record ConflictDocumentContent(
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";
@@ -39,6 +45,9 @@ public sealed class TaskMergeService
public const string StatusBlocked = "blocked";
public const string StatusAborted = "aborted";
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";
@@ -75,11 +84,11 @@ public sealed class TaskMergeService
return (task, list, wt);
}
private async Task MarkWorktreeMergedAsync(string taskId, CancellationToken ct)
private async Task MarkWorktreeMergedAsync(string taskId, string mergeCommitSha, CancellationToken ct)
{
using (var ctx = _dbFactory.CreateDbContext())
{
await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Merged, ct);
await new WorktreeRepository(ctx).SetMergedAsync(taskId, mergeCommitSha, ct);
}
await _broadcaster.WorktreeUpdated(taskId);
}
@@ -155,6 +164,8 @@ public sealed class TaskMergeService
return new MergeResult(StatusConflict, files, null);
}
var mergeSha = await _git.RevParseHeadAsync(list.WorkingDir, ct);
string? cleanupWarning = null;
if (removeWorktree)
{
@@ -175,7 +186,7 @@ public sealed class TaskMergeService
}
}
await MarkWorktreeMergedAsync(taskId, ct);
await MarkWorktreeMergedAsync(taskId, mergeSha, ct);
await ApproveIfWaitingForReviewAsync(task, ct);
_logger.LogInformation(
@@ -233,7 +244,8 @@ public sealed class TaskMergeService
try { await _git.CommitAsync(list.WorkingDir, $"Merge branch '{wt.BranchName}'", ct); }
catch (Exception ex) { return Blocked($"commit failed: {ex.Message}"); }
await MarkWorktreeMergedAsync(taskId, ct);
var mergeSha = await _git.RevParseHeadAsync(list.WorkingDir, ct);
await MarkWorktreeMergedAsync(taskId, mergeSha, ct);
await ApproveIfWaitingForReviewAsync(task, ct);
_logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName);
@@ -257,6 +269,84 @@ public sealed class TaskMergeService
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.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.
@@ -376,4 +466,7 @@ public sealed class TaskMergeService
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);
}