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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user