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:
@@ -1149,6 +1149,181 @@ public class TaskMergeServiceTests : IDisposable
|
||||
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
||||
Assert.Equal(WorktreeState.Merged, wt!.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeAsync_OverlappingWaitingForReviewBranch_GetsRebasedOntoNewTip()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
|
||||
var db = NewDb();
|
||||
var repo = NewRepo();
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "shared.txt"), "line1\nline2\nline3\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "seed shared file");
|
||||
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||
|
||||
var (list, taskA) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
||||
var wtPathA = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||
_wtCleanups.Add((repo.RepoDir, wtPathA));
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/reba-a", wtPathA, baseCommit);
|
||||
File.WriteAllText(Path.Combine(wtPathA, "shared.txt"), "line1-changed\nline2\nline3\n");
|
||||
GitRepoFixture.RunGit(wtPathA, "commit", "-am", "A changes line1");
|
||||
await SeedWorktree(db, taskA.Id, wtPathA, "claudedo/reba-a", baseCommit);
|
||||
|
||||
// Task B is WaitingForReview and touches the same file A just changed, but a different
|
||||
// line, so rebasing it onto the new tip should apply cleanly.
|
||||
var taskB = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = list.Id,
|
||||
Title = "task-b",
|
||||
Status = TaskStatus.WaitingForReview,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
using (var ctx = db.CreateContext())
|
||||
await new TaskRepository(ctx).AddAsync(taskB);
|
||||
|
||||
var wtPathB = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||
_wtCleanups.Add((repo.RepoDir, wtPathB));
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/reba-b", wtPathB, baseCommit);
|
||||
File.WriteAllText(Path.Combine(wtPathB, "shared.txt"), "line1\nline2\nline3-changed\n");
|
||||
GitRepoFixture.RunGit(wtPathB, "commit", "-am", "B changes line3");
|
||||
await SeedWorktree(db, taskB.Id, wtPathB, "claudedo/reba-b", baseCommit);
|
||||
|
||||
var (svc, proxy) = BuildService(db);
|
||||
|
||||
var result = await svc.MergeAsync(taskA.Id, "main", removeWorktree: false, "Merge A", CancellationToken.None);
|
||||
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||
|
||||
// B's worktree must now carry both changes -- rebased cleanly onto the new main tip.
|
||||
Assert.Equal("line1-changed\nline2\nline3-changed\n",
|
||||
File.ReadAllText(Path.Combine(wtPathB, "shared.txt")).Replace("\r\n", "\n"));
|
||||
|
||||
var mainHead = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "main").Trim();
|
||||
using var ctx2 = db.CreateContext();
|
||||
var wtB = await new WorktreeRepository(ctx2).GetByTaskIdAsync(taskB.Id);
|
||||
Assert.Equal(mainHead, wtB!.BaseCommit);
|
||||
Assert.Equal(GitRepoFixture.RunGit(wtPathB, "rev-parse", "HEAD").Trim(), wtB.HeadCommit);
|
||||
|
||||
Assert.Contains(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == taskB.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeAsync_NonOverlappingWaitingForReviewBranch_IsLeftAlone()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
|
||||
var db = NewDb();
|
||||
var repo = NewRepo();
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
var baseCommit = repo.BaseCommit;
|
||||
|
||||
var (list, taskA) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
||||
var wtPathA = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||
_wtCleanups.Add((repo.RepoDir, wtPathA));
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/noov-a", wtPathA, baseCommit);
|
||||
File.WriteAllText(Path.Combine(wtPathA, "a-only.txt"), "a\n");
|
||||
GitRepoFixture.RunGit(wtPathA, "add", "-A");
|
||||
GitRepoFixture.RunGit(wtPathA, "commit", "-m", "A adds a-only.txt");
|
||||
await SeedWorktree(db, taskA.Id, wtPathA, "claudedo/noov-a", baseCommit);
|
||||
|
||||
// Task B touches a completely different file -- merely behind, not worth disturbing.
|
||||
var taskB = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = list.Id,
|
||||
Title = "task-b",
|
||||
Status = TaskStatus.WaitingForReview,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
using (var ctx = db.CreateContext())
|
||||
await new TaskRepository(ctx).AddAsync(taskB);
|
||||
|
||||
var wtPathB = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||
_wtCleanups.Add((repo.RepoDir, wtPathB));
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/noov-b", wtPathB, baseCommit);
|
||||
File.WriteAllText(Path.Combine(wtPathB, "b-only.txt"), "b\n");
|
||||
GitRepoFixture.RunGit(wtPathB, "add", "-A");
|
||||
GitRepoFixture.RunGit(wtPathB, "commit", "-m", "B adds b-only.txt");
|
||||
var headBBefore = GitRepoFixture.RunGit(wtPathB, "rev-parse", "HEAD").Trim();
|
||||
await SeedWorktree(db, taskB.Id, wtPathB, "claudedo/noov-b", baseCommit);
|
||||
|
||||
var (svc, proxy) = BuildService(db);
|
||||
|
||||
var result = await svc.MergeAsync(taskA.Id, "main", removeWorktree: false, "Merge A", CancellationToken.None);
|
||||
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||
|
||||
// B is untouched: same head, same recorded base commit, no broadcast for B.
|
||||
Assert.Equal(headBBefore, GitRepoFixture.RunGit(wtPathB, "rev-parse", "HEAD").Trim());
|
||||
using var ctx2 = db.CreateContext();
|
||||
var wtB = await new WorktreeRepository(ctx2).GetByTaskIdAsync(taskB.Id);
|
||||
Assert.Equal(baseCommit, wtB!.BaseCommit);
|
||||
Assert.DoesNotContain(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == taskB.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeAsync_OverlappingButConflictingRebase_LeavesBranchAsIsAndWarns()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
|
||||
var db = NewDb();
|
||||
var repo = NewRepo();
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "shared.txt"), "line1\nline2\nline3\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "seed shared file");
|
||||
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||
|
||||
var (list, taskA) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
||||
var wtPathA = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||
_wtCleanups.Add((repo.RepoDir, wtPathA));
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/conf-a", wtPathA, baseCommit);
|
||||
File.WriteAllText(Path.Combine(wtPathA, "shared.txt"), "line1\nA-line2\nline3\n");
|
||||
GitRepoFixture.RunGit(wtPathA, "commit", "-am", "A changes line2");
|
||||
await SeedWorktree(db, taskA.Id, wtPathA, "claudedo/conf-a", baseCommit);
|
||||
|
||||
// Task B changes the exact same line differently -- rebasing must conflict.
|
||||
var taskB = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = list.Id,
|
||||
Title = "task-b",
|
||||
Status = TaskStatus.WaitingForReview,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
using (var ctx = db.CreateContext())
|
||||
await new TaskRepository(ctx).AddAsync(taskB);
|
||||
|
||||
var wtPathB = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||
_wtCleanups.Add((repo.RepoDir, wtPathB));
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/conf-b", wtPathB, baseCommit);
|
||||
File.WriteAllText(Path.Combine(wtPathB, "shared.txt"), "line1\nB-line2\nline3\n");
|
||||
GitRepoFixture.RunGit(wtPathB, "commit", "-am", "B changes line2");
|
||||
var headBBefore = GitRepoFixture.RunGit(wtPathB, "rev-parse", "HEAD").Trim();
|
||||
await SeedWorktree(db, taskB.Id, wtPathB, "claudedo/conf-b", baseCommit);
|
||||
|
||||
var (svc, proxy) = BuildService(db);
|
||||
|
||||
var result = await svc.MergeAsync(taskA.Id, "main", removeWorktree: false, "Merge A", CancellationToken.None);
|
||||
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||
|
||||
// B is left exactly as it was: same head commit, same content, no mid-rebase state, base
|
||||
// commit unchanged in the DB, and a clear warning went out instead of silent partial work.
|
||||
Assert.Equal(headBBefore, GitRepoFixture.RunGit(wtPathB, "rev-parse", "HEAD").Trim());
|
||||
Assert.Equal("line1\nB-line2\nline3\n",
|
||||
File.ReadAllText(Path.Combine(wtPathB, "shared.txt")).Replace("\r\n", "\n"));
|
||||
Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(wtPathB, "status", "--porcelain")));
|
||||
|
||||
using var ctx2 = db.CreateContext();
|
||||
var wtB = await new WorktreeRepository(ctx2).GetByTaskIdAsync(taskB.Id);
|
||||
Assert.Equal(baseCommit, wtB!.BaseCommit);
|
||||
|
||||
Assert.Contains(proxy.Calls, c => c.Method == "WorkerLog"
|
||||
&& c.Args[0] is string s && s.Contains("Auto-rebase failed") && s.Contains("task-b"));
|
||||
}
|
||||
}
|
||||
|
||||
#region Test doubles
|
||||
|
||||
Reference in New Issue
Block a user