feat(worker): auto-rebase overlapping WaitingForReview branches after a merge
After a merge lands on the target branch, TaskMergeService now rebases every other WaitingForReview task's active worktree onto the new tip -- but only when that branch actually touches a file the merge just changed, computed via GitService.GetChangedFileNamesAsync (both the merge's landed files and each candidate's own diff). A branch merely behind is left alone. On conflict or any other failure the rebase is aborted and the branch is left exactly as it was, with a WorkerLog warning naming the task; a successful rebase updates the worktree's recorded BaseCommit/HeadCommit and broadcasts WorktreeUpdated.
This commit is contained in:
@@ -161,6 +161,89 @@ public sealed class TaskMergeService
|
||||
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.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,
|
||||
@@ -198,6 +281,7 @@ public sealed class TaskMergeService
|
||||
catch (Exception ex) { return Blocked($"failed to switch target branch: {ex.Message}"); }
|
||||
}
|
||||
|
||||
var oldTargetTip = await _git.RevParseHeadAsync(list.WorkingDir, ct);
|
||||
var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct);
|
||||
if (exitCode != 0)
|
||||
{
|
||||
@@ -251,6 +335,7 @@ public sealed class TaskMergeService
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -295,6 +380,10 @@ public sealed class TaskMergeService
|
||||
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
|
||||
@@ -327,6 +416,9 @@ public sealed class TaskMergeService
|
||||
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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user