The merge commit message was hand-rolled per caller ("Merge task: <title>",
"Merge <branch>", "Merge subtask") and ignored the task's commit type. Every
caller now passes a blank message and TaskMergeService fills in
CommitMessageBuilder.BuildMerge -> {commitType}(list-slug): merge <title> plus the
ClaudeDo-Task trailer; the merge modal prefills it from GetMergeTargets.
A merge whose list has a verify command holds the MergeTask call for minutes (5m46s
on this repo), during which the modal only disabled its button - no spinner, no
message, so a landed merge looked like a dead app. TaskMergeService now broadcasts
MergeProgress(taskId, phase, elapsedSeconds) for the merging and verifying phases
(re-reported every 30s) plus a WorkerLog line when verify starts; the modal shows a
spinner and the localized phase.
1803 lines
84 KiB
C#
1803 lines
84 KiB
C#
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Hub;
|
|
using ClaudeDo.Worker.Runner;
|
|
using ClaudeDo.Worker.Lifecycle;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Services;
|
|
|
|
public class TaskMergeServiceTests : IDisposable
|
|
{
|
|
private readonly List<DbFixture> _dbs = new();
|
|
private readonly List<GitRepoFixture> _repos = new();
|
|
private readonly List<(string repoDir, string wtPath)> _wtCleanups = new();
|
|
|
|
private DbFixture NewDb() { var d = new DbFixture(); _dbs.Add(d); return d; }
|
|
private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var (repoDir, wtPath) in _wtCleanups)
|
|
{
|
|
try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { }
|
|
}
|
|
foreach (var d in _dbs) try { d.Dispose(); } catch { }
|
|
foreach (var r in _repos) try { r.Dispose(); } catch { }
|
|
}
|
|
|
|
private static (TaskMergeService svc, MergeRecordingClientProxy proxy) BuildService(
|
|
DbFixture db, IVerifyCommandRunner? verify = null)
|
|
{
|
|
var fakeHub = new MergeRecordingHubContext();
|
|
var broadcaster = new HubBroadcaster(fakeHub);
|
|
var state = TaskStateServiceBuilder.Build(db.CreateFactory()).State;
|
|
var svc = new TaskMergeService(
|
|
db.CreateFactory(),
|
|
new GitService(),
|
|
broadcaster,
|
|
state,
|
|
verify ?? new VerifyCommandRunner(),
|
|
NullLogger<TaskMergeService>.Instance);
|
|
return (svc, fakeHub.Proxy);
|
|
}
|
|
|
|
private static async Task SeedVerifyCommand(DbFixture db, string listId, string command)
|
|
{
|
|
using var ctx = db.CreateContext();
|
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = command });
|
|
}
|
|
|
|
private static WorktreeManager BuildWorktreeManager(DbFixture db)
|
|
{
|
|
return new WorktreeManager(
|
|
new GitService(),
|
|
db.CreateFactory(),
|
|
new ClaudeDo.Worker.Config.WorkerConfig { WorktreeRootStrategy = "sibling" },
|
|
NullLogger<WorktreeManager>.Instance);
|
|
}
|
|
|
|
private static async Task SeedWorktree(
|
|
DbFixture db, string taskId, string path, string branchName, string baseCommit,
|
|
WorktreeState state = WorktreeState.Active, string? mergeCommit = null)
|
|
{
|
|
var wt = new WorktreeEntity
|
|
{
|
|
TaskId = taskId,
|
|
Path = path,
|
|
BranchName = branchName,
|
|
BaseCommit = baseCommit,
|
|
State = state,
|
|
MergeCommit = mergeCommit,
|
|
CreatedAt = DateTime.UtcNow,
|
|
};
|
|
using var ctx = db.CreateContext();
|
|
await new WorktreeRepository(ctx).AddAsync(wt);
|
|
}
|
|
|
|
private static async Task<(ListEntity list, TaskEntity task)> SeedListAndTask(
|
|
DbFixture db, string workingDir, TaskStatus status)
|
|
{
|
|
var list = new ListEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
Name = "merge-test",
|
|
WorkingDir = workingDir,
|
|
DefaultCommitType = "feat",
|
|
CreatedAt = DateTime.UtcNow,
|
|
};
|
|
var task = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = list.Id,
|
|
Title = "merge-task",
|
|
Status = status,
|
|
CreatedAt = DateTime.UtcNow,
|
|
};
|
|
using var ctx = db.CreateContext();
|
|
await new ListRepository(ctx).AddAsync(list);
|
|
await new TaskRepository(ctx).AddAsync(task);
|
|
return (list, task);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeAsync_RunningTask_ReturnsBlocked()
|
|
{
|
|
var db = NewDb();
|
|
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Running);
|
|
var (svc, proxy) = BuildService(db);
|
|
|
|
var result = await svc.MergeAsync(task.Id, "main", false, "msg", CancellationToken.None);
|
|
|
|
Assert.Equal("blocked", result.Status);
|
|
Assert.Contains("running", result.ErrorMessage ?? "");
|
|
Assert.Empty(proxy.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeAsync_NoWorktree_ReturnsBlocked()
|
|
{
|
|
var db = NewDb();
|
|
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done);
|
|
var (svc, _) = BuildService(db);
|
|
|
|
var result = await svc.MergeAsync(task.Id, "main", false, "msg", CancellationToken.None);
|
|
|
|
Assert.Equal("blocked", result.Status);
|
|
Assert.Contains("no worktree", result.ErrorMessage ?? "");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeAsync_FfAble_KeepWorktree_SetsMergedAndBroadcasts()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
|
|
// Create worktree and make a real commit inside it.
|
|
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, proxy) = BuildService(db);
|
|
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
|
|
commitMessage: "Merge task", ct: CancellationToken.None);
|
|
|
|
Assert.Equal("merged", result.Status);
|
|
Assert.Empty(result.ConflictFiles);
|
|
|
|
// Worktree state now Merged, dir and branch still present.
|
|
using var ctx = db.CreateContext();
|
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
|
Assert.NotNull(wt);
|
|
Assert.Equal(WorktreeState.Merged, wt!.State);
|
|
Assert.True(Directory.Exists(wtCtx.WorktreePath));
|
|
|
|
// Broadcast fired.
|
|
Assert.Contains(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == task.Id);
|
|
|
|
// added.txt is now on the main branch of the repo.
|
|
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeAsync_FfAble_RemoveWorktree_CleansEverything()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
|
|
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, "feature.txt"), "x\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
var (svc, _) = BuildService(db);
|
|
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true,
|
|
commitMessage: "Merge", ct: CancellationToken.None);
|
|
|
|
Assert.Equal("merged", result.Status);
|
|
Assert.False(Directory.Exists(wtCtx.WorktreePath));
|
|
|
|
// Branch must be gone.
|
|
var branches = await new GitService().ListLocalBranchesAsync(repo.RepoDir);
|
|
Assert.DoesNotContain(wtCtx.BranchName, branches);
|
|
|
|
// DB state still Merged.
|
|
using var ctx = db.CreateContext();
|
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
|
Assert.Equal(WorktreeState.Merged, wt!.State);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeAsync_DivergedNonConflicting_ProducesMergeCommit()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
|
|
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, "feature.txt"), "feat\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
// Advance main by adding a different file.
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "main-only.txt"), "main\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: main moved");
|
|
|
|
var (svc, _) = BuildService(db);
|
|
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
|
|
commitMessage: "Merge diverged", ct: CancellationToken.None);
|
|
|
|
Assert.Equal("merged", result.Status);
|
|
// HEAD must be a merge commit (two parents).
|
|
var parents = GitRepoFixture.RunGit(repo.RepoDir, "rev-list", "--parents", "-n", "1", "HEAD").Trim();
|
|
Assert.True(parents.Split(' ').Length >= 3, $"Expected merge commit, got '{parents}'");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeAsync_Conflict_AbortsAndReturnsConflictedFiles()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
|
|
var wtMgr = BuildWorktreeManager(db);
|
|
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
|
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
|
|
|
// Worktree edits README.md
|
|
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
// Main also edits README.md (conflicting).
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: main edit");
|
|
var mainHeadBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
|
|
var (svc, proxy) = BuildService(db);
|
|
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true,
|
|
commitMessage: "Merge", ct: CancellationToken.None);
|
|
|
|
Assert.Equal("conflict", result.Status);
|
|
Assert.Contains("README.md", result.ConflictFiles);
|
|
|
|
// Main branch must be restored exactly.
|
|
var mainHeadAfter = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
Assert.Equal(mainHeadBefore, mainHeadAfter);
|
|
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
|
|
|
// Worktree state stays Active (no broadcast).
|
|
using var ctx = db.CreateContext();
|
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
|
Assert.Equal(WorktreeState.Active, wt!.State);
|
|
Assert.DoesNotContain(proxy.Calls, c => c.Method == "WorktreeUpdated");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeAsync_BlankCommitMessage_UsesConventionalDefault()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
|
|
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, "feature.txt"), "feat\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
var (svc, _) = BuildService(db);
|
|
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
|
|
commitMessage: "", ct: CancellationToken.None);
|
|
|
|
Assert.Equal("merged", result.Status);
|
|
var subject = GitRepoFixture.RunGit(repo.RepoDir, "log", "-1", "--format=%s").Trim();
|
|
// task.CommitType (not the list default) — same type the task's own commits used.
|
|
Assert.Equal($"{task.CommitType}(merge-test): merge merge-task", subject);
|
|
var body = GitRepoFixture.RunGit(repo.RepoDir, "log", "-1", "--format=%b");
|
|
Assert.Contains($"ClaudeDo-Task: {task.Id}", body);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeAsync_VerifyGate_BroadcastsMergingAndVerifyingPhases()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
await SeedVerifyCommand(db, list.Id, "echo ok");
|
|
|
|
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, "feature.txt"), "feat\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
var (svc, proxy) = BuildService(db, new FakeVerifyCommandRunner());
|
|
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
|
|
commitMessage: "", ct: CancellationToken.None);
|
|
|
|
Assert.Equal("merged", result.Status);
|
|
Assert.Contains(proxy.Calls, c => c.Method == "MergeProgress"
|
|
&& (string?)c.Args[0] == task.Id && (string?)c.Args[1] == TaskMergeService.PhaseMerging);
|
|
Assert.Contains(proxy.Calls, c => c.Method == "MergeProgress"
|
|
&& (string?)c.Args[0] == task.Id && (string?)c.Args[1] == TaskMergeService.PhaseVerifying);
|
|
Assert.Contains(proxy.Calls, c => c.Method == "WorkerLog"
|
|
&& c.Args[0] is string s && s.Contains("Verify command running"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTargetsAsync_ReturnsDefaultCommitMessage()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
|
|
var (svc, _) = BuildService(db);
|
|
var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal($"{task.CommitType}(merge-test): merge merge-task", targets.DefaultCommitMessage.Split('\n')[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTargetsAsync_ReturnsCurrentAndLocalBranches()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "feature/extra");
|
|
var db = NewDb();
|
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
|
|
var (svc, _) = BuildService(db);
|
|
var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None);
|
|
|
|
Assert.False(string.IsNullOrWhiteSpace(targets.DefaultBranch));
|
|
Assert.Contains("feature/extra", targets.LocalBranches);
|
|
Assert.Contains(targets.DefaultBranch, targets.LocalBranches);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeAsync_DirtyWorkingTree_ReturnsBlocked()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
|
|
var wtMgr = BuildWorktreeManager(db);
|
|
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
|
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
|
|
|
// Modify a tracked file in the target working dir (real uncommitted change).
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "dirty\n");
|
|
|
|
var (svc, _) = BuildService(db);
|
|
var result = await svc.MergeAsync(task.Id, "main", false, "Merge", CancellationToken.None);
|
|
|
|
Assert.Equal("blocked", result.Status);
|
|
Assert.Contains("uncommitted", result.ErrorMessage ?? "");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeAsync_UntrackedFileInTargetWorkingTree_DoesNotBlock()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
|
|
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, "feature.txt"), "x\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
// An untracked file (e.g. left behind by a concurrent session) must not block the merge.
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "untracked.txt"), "stray\n");
|
|
|
|
var (svc, _) = BuildService(db);
|
|
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
|
|
commitMessage: "Merge", ct: CancellationToken.None);
|
|
|
|
Assert.Equal("merged", result.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeAsync_BranchAddsPathUntrackedInTarget_RefusesAndLeavesLocalContentIntact()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
|
|
var wtMgr = BuildWorktreeManager(db);
|
|
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
|
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
|
|
|
// The task branch creates a brand new file at a path it has never seen tracked.
|
|
Directory.CreateDirectory(Path.Combine(wtCtx.WorktreePath, "docs"));
|
|
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "docs", "PLUGIN.md"), "agent-written replacement\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
// The same path already exists, untracked, in the target working directory — never
|
|
// committed, not ignored — carrying content unrelated to the task branch.
|
|
Directory.CreateDirectory(Path.Combine(repo.RepoDir, "docs"));
|
|
var localPath = Path.Combine(repo.RepoDir, "docs", "PLUGIN.md");
|
|
const string localContent = "local plugin docs — must survive\n";
|
|
File.WriteAllText(localPath, localContent);
|
|
|
|
var (svc, proxy) = BuildService(db);
|
|
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
|
|
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
|
|
commitMessage: "Merge task", ct: CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusUntrackedCollision, result.Status);
|
|
Assert.Contains("docs/PLUGIN.md", result.ConflictFiles);
|
|
Assert.Contains("docs/PLUGIN.md", result.ErrorMessage ?? "");
|
|
Assert.Contains($"{System.Text.Encoding.UTF8.GetByteCount(localContent)} bytes", result.ErrorMessage ?? "");
|
|
|
|
// Nothing landed: no merge was attempted, the local file is untouched, worktree unaffected.
|
|
Assert.Equal(localContent, File.ReadAllText(localPath));
|
|
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
|
|
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
|
Assert.DoesNotContain(proxy.Calls, c => c.Method == "WorktreeUpdated");
|
|
|
|
using var ctx = db.CreateContext();
|
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
|
Assert.Equal(WorktreeState.Active, wt!.State);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeAsync_TargetBranchDifferentFromHead_ChecksOutBeforeMerging()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
|
|
// Create a feature branch in the repo (from current HEAD).
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "feature/target");
|
|
|
|
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, "feat.txt"), "data\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
var (svc, _) = BuildService(db);
|
|
// Repo is currently on main/master; request merge into feature/target.
|
|
var result = await svc.MergeAsync(task.Id, "feature/target", removeWorktree: false,
|
|
commitMessage: "Merge into feature", ct: CancellationToken.None);
|
|
|
|
Assert.Equal("merged", result.Status);
|
|
|
|
// HEAD must now be feature/target.
|
|
var head = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
Assert.Equal("feature/target", head);
|
|
|
|
// The merged file must exist on feature/target.
|
|
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "feat.txt")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeAsync_TargetBranchDoesNotExist_ReturnsBlocked()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
|
|
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, "x.txt"), "x\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
var (svc, _) = BuildService(db);
|
|
var result = await svc.MergeAsync(task.Id, "nonexistent/branch", removeWorktree: false,
|
|
commitMessage: "Merge", ct: CancellationToken.None);
|
|
|
|
Assert.Equal("blocked", result.Status);
|
|
Assert.Contains("switch target branch", result.ErrorMessage ?? "");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ContinueMergeAsync_AfterUserResolves_CompletesMergeAndSetsWorktreeMerged()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var db = NewDb();
|
|
var repo = NewRepo();
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
|
|
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t2", wtPath, repo.BaseCommit);
|
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
|
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
|
|
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
await SeedWorktree(db, task.Id, wtPath, "claudedo/t2", repo.BaseCommit);
|
|
|
|
var (svc, _) = BuildService(db);
|
|
|
|
var first = await svc.MergeAsync(task.Id, "main", false, "msg",
|
|
leaveConflictsInTree: true, CancellationToken.None);
|
|
Assert.Equal(TaskMergeService.StatusConflict, first.Status);
|
|
|
|
// Simulate the user resolving the conflict.
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");
|
|
|
|
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
|
Assert.False(File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD")));
|
|
|
|
using var ctx = db.CreateContext();
|
|
var wt = ctx.Worktrees.Single(w => w.TaskId == task.Id);
|
|
Assert.Equal(WorktreeState.Merged, wt.State);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ContinueMergeAsync_UnrelatedChangesInSharedCheckout_AreNotStagedOrCommitted()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var db = NewDb();
|
|
var repo = NewRepo();
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "tracked.txt"), "tracked v1\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "tracked.txt");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
|
|
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t5", wtPath, repo.BaseCommit);
|
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
|
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
|
|
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
await SeedWorktree(db, task.Id, wtPath, "claudedo/t5", repo.BaseCommit);
|
|
|
|
var (svc, _) = BuildService(db);
|
|
|
|
var first = await svc.MergeAsync(task.Id, "main", false, "msg",
|
|
leaveConflictsInTree: true, CancellationToken.None);
|
|
Assert.Equal(TaskMergeService.StatusConflict, first.Status);
|
|
|
|
// Simulate the user resolving the conflict.
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");
|
|
|
|
// A concurrent session leaves stray changes in this shared checkout while the merge is
|
|
// paused — these must survive continue_merge untouched and never enter the merge commit.
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "untracked.txt"), "from another session\n");
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "tracked.txt"), "tracked v2 (unrelated edit)\n");
|
|
|
|
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
|
|
|
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "untracked.txt")));
|
|
Assert.Equal("from another session\n", File.ReadAllText(Path.Combine(repo.RepoDir, "untracked.txt")));
|
|
Assert.Equal("tracked v2 (unrelated edit)\n", File.ReadAllText(Path.Combine(repo.RepoDir, "tracked.txt")));
|
|
|
|
var status = GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain");
|
|
Assert.Contains("?? untracked.txt", status);
|
|
Assert.Contains(" M tracked.txt", status);
|
|
|
|
var committedFiles = GitRepoFixture.RunGit(repo.RepoDir, "show", "--name-only", "--pretty=format:", "HEAD");
|
|
Assert.DoesNotContain("untracked.txt", committedFiles);
|
|
Assert.DoesNotContain("tracked.txt", committedFiles);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ContinueMergeAsync_UnresolvedConflictMarkersLeft_RefusesAndDoesNotCommit()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var db = NewDb();
|
|
var repo = NewRepo();
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
|
|
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t4", wtPath, repo.BaseCommit);
|
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
|
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
|
|
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
await SeedWorktree(db, task.Id, wtPath, "claudedo/t4", repo.BaseCommit);
|
|
|
|
var (svc, _) = BuildService(db);
|
|
|
|
var first = await svc.MergeAsync(task.Id, "main", false, "msg",
|
|
leaveConflictsInTree: true, CancellationToken.None);
|
|
Assert.Equal(TaskMergeService.StatusConflict, first.Status);
|
|
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
|
|
// User never resolves the conflict — README.md still contains "<<<<<<<" markers.
|
|
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
|
|
Assert.Contains("README.md", result.ConflictFiles);
|
|
Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));
|
|
|
|
// No commit must have happened, and the repo must still be mid-merge so Abort can clean it up.
|
|
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
|
|
Assert.True(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
|
|
|
var abort = await svc.AbortMergeAsync(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusAborted, abort.Status);
|
|
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
|
Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ContinueMergeAsync_PathBecomesUntrackedDuringResolution_RefusesBeforeAddAll()
|
|
{
|
|
// git's own preflight already refuses a merge whose incoming branch collides with a path
|
|
// that is untracked in the target *at merge-start* (verified against real git while
|
|
// investigating this guard). The only path left uncovered is one that becomes untracked
|
|
// again *during* conflict resolution, after the initial merge already staged it cleanly —
|
|
// this reproduces exactly that narrow window, right before `git add -A` would sweep it
|
|
// back in unnoticed.
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var db = NewDb();
|
|
var repo = NewRepo();
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
|
|
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t-untracked", wtPath, repo.BaseCommit);
|
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
|
File.WriteAllText(Path.Combine(wtPath, "extra.md"), "agent content\n");
|
|
GitRepoFixture.RunGit(wtPath, "add", "-A");
|
|
GitRepoFixture.RunGit(wtPath, "commit", "-m", "branch change + add extra.md");
|
|
|
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
await SeedWorktree(db, task.Id, wtPath, "claudedo/t-untracked", repo.BaseCommit);
|
|
|
|
var (svc, _) = BuildService(db);
|
|
|
|
var first = await svc.MergeAsync(task.Id, "main", false, "msg",
|
|
leaveConflictsInTree: true, CancellationToken.None);
|
|
Assert.Equal(TaskMergeService.StatusConflict, first.Status);
|
|
|
|
// extra.md landed clean already (git stages any non-conflicting file the moment the
|
|
// merge starts, even while README.md is left conflicted) -- confirm that, then unstage
|
|
// it and drop in different, untracked local content, reproducing "became untracked
|
|
// again mid-resolution".
|
|
Assert.Equal("A", GitRepoFixture.RunGit(repo.RepoDir, "diff", "--name-status", "--cached", "--", "extra.md").Trim()[..1]);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "rm", "--cached", "extra.md");
|
|
const string localContent = "important local content that must survive\n";
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "extra.md"), localContent);
|
|
|
|
// Resolve the real (unrelated) conflict.
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");
|
|
|
|
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusUntrackedCollision, result.Status);
|
|
Assert.Contains("extra.md", result.ConflictFiles);
|
|
Assert.Equal(localContent, File.ReadAllText(Path.Combine(repo.RepoDir, "extra.md")));
|
|
|
|
// Nothing committed; still mid-merge so abort/continue can still resolve it.
|
|
Assert.True(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
|
using var ctx = db.CreateContext();
|
|
var wt = ctx.Worktrees.Single(w => w.TaskId == task.Id);
|
|
Assert.Equal(WorktreeState.Active, wt.State);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AbortMergeAsync_AfterConflict_RestoresCleanStateAndLeavesWorktreeActive()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var db = NewDb();
|
|
var repo = NewRepo();
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
|
|
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t3", wtPath, repo.BaseCommit);
|
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
|
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
|
|
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
await SeedWorktree(db, task.Id, wtPath, "claudedo/t3", repo.BaseCommit);
|
|
|
|
var (svc, _) = BuildService(db);
|
|
await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
|
|
|
|
var result = await svc.AbortMergeAsync(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal("aborted", result.Status);
|
|
Assert.False(File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD")));
|
|
|
|
using var ctx = db.CreateContext();
|
|
var wt = ctx.Worktrees.Single(w => w.TaskId == task.Id);
|
|
Assert.Equal(WorktreeState.Active, wt.State);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_CleanWorktree_ReturnsClean()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, 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"), "x\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
var (svc, _) = BuildService(db);
|
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
|
|
Assert.True(preview.ChangedFileCount >= 1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_Conflict_ReturnsConflictFiles()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, 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, "README.md"), "# from worktree\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
|
|
|
|
var (svc, _) = BuildService(db);
|
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewConflict, preview.Status);
|
|
Assert.Contains("README.md", preview.ConflictFiles);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_BranchAddsPathUntrackedInTarget_ReturnsUntrackedCollision()
|
|
{
|
|
// merge-tree (the real preview mechanism) never touches the working directory, so
|
|
// without this check the preview would say "clean" for a merge that can't actually land.
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, 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, "PLUGIN.md"), "agent content\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "PLUGIN.md"), "local content\n");
|
|
|
|
var (svc, _) = BuildService(db);
|
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewUntrackedCollision, preview.Status);
|
|
Assert.Contains("PLUGIN.md", preview.ConflictFiles);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_NoActiveWorktree_ReturnsUnavailable()
|
|
{
|
|
var db = NewDb();
|
|
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
|
|
var (svc, _) = BuildService(db);
|
|
|
|
var preview = await svc.PreviewAsync(task.Id, "main", CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewUnavailable, preview.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_RunVerifyTrue_NoVerifyCommandConfigured_NeverInvokesRunner()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, 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"), "x\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
|
|
var (svc, _) = BuildService(db, fakeVerify);
|
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var preview = await svc.PreviewAsync(task.Id, target, runVerify: true, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
|
|
Assert.Null(preview.VerifyExitCode);
|
|
Assert.Null(fakeVerify.CapturedCommand);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_RunVerifyFalse_VerifyCommandConfigured_NeverInvokesRunner()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
|
await SeedVerifyCommand(db, list.Id, "dotnet build");
|
|
|
|
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"), "x\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
|
|
var (svc, _) = BuildService(db, fakeVerify);
|
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
|
|
Assert.Null(preview.VerifyExitCode);
|
|
Assert.Null(fakeVerify.CapturedCommand);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_RunVerifyTrue_VerifyPasses_ReturnsCleanBuildAndLeavesTargetUntouched()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
|
await SeedVerifyCommand(db, list.Id, "dotnet build");
|
|
|
|
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"), "x\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
var fakeVerify = new FakeVerifyCommandRunner
|
|
{
|
|
Result = new VerifyCommandResult(0, false, "build ok"),
|
|
FileToCheck = "added.txt",
|
|
};
|
|
var (svc, _) = BuildService(db, fakeVerify);
|
|
var git = new GitService();
|
|
var target = await git.GetCurrentBranchAsync(repo.RepoDir);
|
|
var statusBefore = await git.GetStatusPorcelainAsync(repo.RepoDir);
|
|
var headBefore = await git.RevParseHeadAsync(repo.RepoDir);
|
|
|
|
var preview = await svc.PreviewAsync(task.Id, target, runVerify: true, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
|
|
Assert.Equal(0, preview.VerifyExitCode);
|
|
Assert.NotNull(preview.VerifyDurationMs);
|
|
Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
|
|
Assert.NotEqual(repo.RepoDir, fakeVerify.CapturedWorkingDir);
|
|
Assert.True(fakeVerify.WorkingDirHadFile, "the merged file must be present in the scratch worktree at verify time");
|
|
|
|
// Scratch worktree removed afterward; the real target working tree never changed.
|
|
Assert.False(Directory.Exists(fakeVerify.CapturedWorkingDir));
|
|
Assert.Equal(statusBefore, await git.GetStatusPorcelainAsync(repo.RepoDir));
|
|
Assert.Equal(headBefore, await git.RevParseHeadAsync(repo.RepoDir));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_RunVerifyTrue_VerifyFails_ReturnsExitCodeAndOutputTailWithoutTouchingTarget()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
|
await SeedVerifyCommand(db, list.Id, "dotnet build");
|
|
|
|
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"), "x\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "build broke: CS0103") };
|
|
var (svc, _) = BuildService(db, fakeVerify);
|
|
var git = new GitService();
|
|
var target = await git.GetCurrentBranchAsync(repo.RepoDir);
|
|
var headBefore = await git.RevParseHeadAsync(repo.RepoDir);
|
|
|
|
var preview = await svc.PreviewAsync(task.Id, target, runVerify: true, CancellationToken.None);
|
|
|
|
// The merge-tree result itself is still clean -- only the build/test of it failed.
|
|
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
|
|
Assert.Equal(1, preview.VerifyExitCode);
|
|
Assert.Contains("build broke: CS0103", preview.VerifyOutputTail);
|
|
Assert.False(Directory.Exists(fakeVerify.CapturedWorkingDir));
|
|
Assert.Equal(headBefore, await git.RevParseHeadAsync(repo.RepoDir));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_RunVerifyTrue_CleansUpScratchWorktreeRegistration()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
|
await SeedVerifyCommand(db, list.Id, "dotnet build");
|
|
|
|
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"), "x\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
|
|
var (svc, _) = BuildService(db, fakeVerify);
|
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
await svc.PreviewAsync(task.Id, target, runVerify: true, CancellationToken.None);
|
|
|
|
var worktreeListing = GitRepoFixture.RunGit(repo.RepoDir, "worktree", "list", "--porcelain");
|
|
Assert.DoesNotContain(fakeVerify.CapturedWorkingDir!, worktreeListing);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApproveAndMergeAsync_CleanWorktree_MergesAndMarksDone()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, 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 target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
|
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);
|
|
// Approve removes the worktree so merged worktrees don't pile up.
|
|
Assert.False(Directory.Exists(wtCtx.WorktreePath));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApproveAndMergeAsync_Conflict_LeavesTaskWaitingForReview()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, 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, "README.md"), "# from worktree\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
|
|
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
|
|
var (svc, _) = BuildService(db);
|
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
|
|
Assert.Contains("README.md", result.ConflictFiles);
|
|
|
|
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.Active, wt!.State);
|
|
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
|
|
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetConflictDocumentsAsync_ParsesLineLevelHunksWithDiff3Base()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
|
|
|
// A committed README gives the conflict a common ancestor, so diff3 records a base.
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "line1\nbase\nline3\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "base readme");
|
|
|
|
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, "README.md"), "line1\nWORKTREE\nline3\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "line1\nMAIN\nline3\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
|
|
|
|
var (svc, _) = BuildService(db);
|
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var merge = await svc.MergeAsync(
|
|
task.Id, target, removeWorktree: false, "merge",
|
|
leaveConflictsInTree: true, CancellationToken.None);
|
|
Assert.Equal(TaskMergeService.StatusConflict, merge.Status);
|
|
|
|
var docs = await svc.GetConflictDocumentsAsync(task.Id, CancellationToken.None);
|
|
|
|
var file = Assert.Single(docs.Files);
|
|
Assert.EndsWith("README.md", file.Path.Replace('\\', '/'));
|
|
Assert.False(file.IsBinary);
|
|
|
|
var conflict = Assert.Single(file.Segments.Where(s => s.IsConflict).ToList());
|
|
Assert.Contains("MAIN", conflict.Ours); // ours = current branch (merge target)
|
|
Assert.Contains("WORKTREE", conflict.Theirs); // theirs = incoming worktree branch
|
|
Assert.NotNull(conflict.Base);
|
|
Assert.Contains("base", conflict.Base!); // diff3 merge base
|
|
Assert.Contains(file.Segments, s => !s.IsConflict && s.Text.Contains("line1"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApproveAndMergeAsync_NoWorktree_MarksDone()
|
|
{
|
|
var db = NewDb();
|
|
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
|
|
var (svc, _) = BuildService(db);
|
|
|
|
var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
|
using var ctx = db.CreateContext();
|
|
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Done, updated!.Status);
|
|
}
|
|
|
|
// A list-handler task owns no worktree -- it commits straight into the list's working dir --
|
|
// so the approve path short-circuits past the merge. The verify gate must still apply, or the
|
|
// run that lands the most on the target branch would be the one run nothing checks.
|
|
[Fact]
|
|
public async Task ApproveAndMergeAsync_NoWorktreeAndVerifyFails_StaysOutOfDone()
|
|
{
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
|
|
await SeedVerifyCommand(db, list.Id, "dotnet test");
|
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "2 failed") };
|
|
var (svc, _) = BuildService(db, fakeVerify);
|
|
|
|
var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusVerifyFailed, result.Status);
|
|
Assert.Contains("2 failed", result.ErrorMessage ?? "");
|
|
Assert.Equal("/tmp", fakeVerify.CapturedWorkingDir);
|
|
using var ctx = db.CreateContext();
|
|
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApproveAndMergeAsync_NoWorktreeAndVerifyPasses_MarksDone()
|
|
{
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
|
|
await SeedVerifyCommand(db, list.Id, "dotnet test");
|
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
|
|
var (svc, _) = BuildService(db, fakeVerify);
|
|
|
|
var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
|
Assert.Equal("dotnet test", fakeVerify.CapturedCommand);
|
|
using var ctx = db.CreateContext();
|
|
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Done, updated!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApproveAndMergeAsync_NoVerifyCommandConfigured_NeverInvokesRunnerAndMarksDone()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, 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 fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
|
|
var (svc, _) = BuildService(db, fakeVerify);
|
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
|
Assert.Null(fakeVerify.CapturedCommand);
|
|
using var ctx = db.CreateContext();
|
|
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Done, updated!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApproveAndMergeAsync_VerifyCommandSucceeds_RunsInListWorkingDirAndMarksDone()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
|
await SeedVerifyCommand(db, list.Id, "dotnet test");
|
|
|
|
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 fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "all green") };
|
|
var (svc, _) = BuildService(db, fakeVerify);
|
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
|
Assert.Equal("dotnet test", fakeVerify.CapturedCommand);
|
|
Assert.Equal(repo.RepoDir, fakeVerify.CapturedWorkingDir);
|
|
using var ctx = db.CreateContext();
|
|
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Done, updated!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApproveAndMergeAsync_VerifyCommandFails_KeepsMergeButNotDone()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
|
await SeedVerifyCommand(db, list.Id, "dotnet test");
|
|
|
|
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 fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "build broke: CS0103") };
|
|
var (svc, _) = BuildService(db, fakeVerify);
|
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusVerifyFailed, result.Status);
|
|
Assert.Contains("build broke: CS0103", result.ErrorMessage);
|
|
|
|
// The git merge itself is left in place — main already has the merged content.
|
|
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.WaitingForReview, updated!.Status);
|
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
|
Assert.Equal(WorktreeState.Merged, wt!.State);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApproveAndMergeAsync_VerifyCommandTimesOut_ReturnsVerifyFailedWithTimeoutMessage()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
|
await SeedVerifyCommand(db, list.Id, "dotnet test");
|
|
|
|
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 fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(-1, true, "") };
|
|
var (svc, _) = BuildService(db, fakeVerify);
|
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusVerifyFailed, result.Status);
|
|
Assert.Contains("timed out", result.ErrorMessage ?? "", StringComparison.OrdinalIgnoreCase);
|
|
|
|
using var ctx = db.CreateContext();
|
|
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var db = NewDb();
|
|
var repo = NewRepo();
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
|
|
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t1", wtPath, repo.BaseCommit);
|
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
|
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
|
|
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
await SeedWorktree(db, task.Id, wtPath, "claudedo/t1", repo.BaseCommit);
|
|
|
|
var (svc, _) = BuildService(db);
|
|
|
|
var result = await svc.MergeAsync(
|
|
task.Id, "main", removeWorktree: false, "msg",
|
|
leaveConflictsInTree: true,
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
|
|
Assert.Contains("README.md", result.ConflictFiles);
|
|
|
|
var midMerge = File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD"));
|
|
Assert.True(midMerge, "repo should be left in mid-merge state");
|
|
|
|
// Cleanup
|
|
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteResolutionAsync_ThenContinue_CompletesMerge()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var db = NewDb();
|
|
var repo = NewRepo();
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
|
|
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c2", wtPath, repo.BaseCommit);
|
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
|
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
|
|
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
|
|
await SeedWorktree(db, task.Id, wtPath, "claudedo/c2", repo.BaseCommit);
|
|
|
|
var (svc, _) = BuildService(db);
|
|
await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
|
|
|
|
await svc.WriteResolutionAsync(task.Id, "README.md", "# resolved by user\n", CancellationToken.None);
|
|
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
|
Assert.Equal("# resolved by user\n", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));
|
|
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeAsync_Success_RecordsMergeCommitOnWorktree()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
|
|
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);
|
|
|
|
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
|
|
commitMessage: "Merge task", ct: CancellationToken.None);
|
|
Assert.Equal("merged", result.Status);
|
|
|
|
var headSha = (await new GitService().RevParseHeadAsync(repo.RepoDir)).Trim();
|
|
|
|
using var ctx = db.CreateContext();
|
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
|
Assert.Equal(headSha, wt!.MergeCommit);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevertMergeAsync_NoWorktree_ReturnsBlocked()
|
|
{
|
|
var db = NewDb();
|
|
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done);
|
|
var (svc, _) = BuildService(db);
|
|
|
|
var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusBlocked, result.Status);
|
|
Assert.Contains("no worktree", result.ErrorMessage ?? "");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevertMergeAsync_TaskNotDone_ReturnsBlocked()
|
|
{
|
|
var db = NewDb();
|
|
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
|
|
await SeedWorktree(db, task.Id, "/tmp/wt", "claudedo/x", "deadbeef",
|
|
state: WorktreeState.Merged, mergeCommit: "cafebabe");
|
|
var (svc, _) = BuildService(db);
|
|
|
|
var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusBlocked, result.Status);
|
|
Assert.Contains("not Done", result.ErrorMessage ?? "");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevertMergeAsync_NoMergeCommitRecorded_ReturnsBlocked()
|
|
{
|
|
// Simulates a worktree merged before this feature existed — Merged state, no SHA.
|
|
var db = NewDb();
|
|
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done);
|
|
await SeedWorktree(db, task.Id, "/tmp/wt", "claudedo/x", "deadbeef",
|
|
state: WorktreeState.Merged, mergeCommit: null);
|
|
var (svc, _) = BuildService(db);
|
|
|
|
var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusBlocked, result.Status);
|
|
Assert.Contains("no merge commit recorded", result.ErrorMessage ?? "");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevertMergeAsync_WorktreeNotMerged_ReturnsBlocked()
|
|
{
|
|
var db = NewDb();
|
|
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done);
|
|
await SeedWorktree(db, task.Id, "/tmp/wt", "claudedo/x", "deadbeef",
|
|
state: WorktreeState.Active, mergeCommit: null);
|
|
var (svc, _) = BuildService(db);
|
|
|
|
var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusBlocked, result.Status);
|
|
Assert.Contains("expected Merged", result.ErrorMessage ?? "");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevertMergeAsync_AfterCleanMerge_CreatesRevertCommitAndResetsState()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
|
|
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, proxy) = BuildService(db);
|
|
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var mergeResult = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true,
|
|
commitMessage: "Merge task", ct: CancellationToken.None);
|
|
Assert.Equal("merged", mergeResult.Status);
|
|
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));
|
|
|
|
var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusReverted, revertResult.Status);
|
|
Assert.False(string.IsNullOrWhiteSpace(revertResult.RevertCommit));
|
|
Assert.Empty(revertResult.ConflictFiles);
|
|
Assert.False(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));
|
|
|
|
using var ctx = db.CreateContext();
|
|
var updatedTask = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.WaitingForReview, updatedTask!.Status);
|
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
|
Assert.Equal(WorktreeState.Kept, wt!.State);
|
|
|
|
Assert.Contains(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == task.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevertMergeAsync_UncommittedChangesInTarget_ReturnsBlocked()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
|
|
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);
|
|
|
|
var mergeResult = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true,
|
|
commitMessage: "Merge task", ct: CancellationToken.None);
|
|
Assert.Equal("merged", mergeResult.Status);
|
|
|
|
// A concurrent session leaves an uncommitted, tracked-file change in the shared checkout.
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "dirty from another session\n");
|
|
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
|
|
var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusBlocked, revertResult.Status);
|
|
Assert.Contains("uncommitted", revertResult.ErrorMessage ?? "");
|
|
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
|
|
|
|
using var ctx = db.CreateContext();
|
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
|
Assert.Equal(WorktreeState.Merged, wt!.State);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevertMergeAsync_ConflictingRevert_AbortsCleanlyAndReportsConflictFiles()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var repo = NewRepo();
|
|
var db = NewDb();
|
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
|
|
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, "README.md"), "# test repo\nfeature\n");
|
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
|
|
var (svc, _) = BuildService(db);
|
|
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
|
|
var mergeResult = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true,
|
|
commitMessage: "Merge task", ct: CancellationToken.None);
|
|
Assert.Equal("merged", mergeResult.Status);
|
|
|
|
// A later commit on the target edits the exact line the merge introduced, so the revert conflicts.
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# test repo\npost-merge edit\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: edit the same line");
|
|
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
|
|
var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusConflictAborted, revertResult.Status);
|
|
Assert.Contains("README.md", revertResult.ConflictFiles);
|
|
Assert.False(await new GitService().IsMidRevertAsync(repo.RepoDir));
|
|
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
|
|
Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain")));
|
|
|
|
// Task/worktree state must not change on a conflicted, aborted revert.
|
|
using var ctx = db.CreateContext();
|
|
var updatedTask = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Done, updatedTask!.Status);
|
|
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
|
|
|
|
internal sealed class FakeVerifyCommandRunner : IVerifyCommandRunner
|
|
{
|
|
public VerifyCommandResult Result { get; set; } = new(0, false, "");
|
|
public string? CapturedWorkingDir { get; private set; }
|
|
public string? CapturedCommand { get; private set; }
|
|
|
|
// Set before the call to snapshot, at call time, whether this file exists in workingDir --
|
|
// lets a preview-verify test prove the scratch worktree actually contains the merged content.
|
|
public string? FileToCheck { get; set; }
|
|
public bool? WorkingDirHadFile { get; private set; }
|
|
|
|
// Simulates a slow build/test run so a progress-reporting test can observe at least one
|
|
// report without the real verify command ever taking that long.
|
|
public TimeSpan? Delay { get; set; }
|
|
|
|
public async Task<VerifyCommandResult> RunAsync(string workingDir, string command, TimeSpan timeout, CancellationToken ct)
|
|
{
|
|
CapturedWorkingDir = workingDir;
|
|
CapturedCommand = command;
|
|
if (FileToCheck is not null)
|
|
WorkingDirHadFile = File.Exists(Path.Combine(workingDir, FileToCheck));
|
|
if (Delay is { } delay)
|
|
await Task.Delay(delay, ct);
|
|
return Result;
|
|
}
|
|
}
|
|
|
|
internal sealed record MergeHubCall(string Method, object?[] Args);
|
|
|
|
internal sealed class MergeRecordingClientProxy : IClientProxy
|
|
{
|
|
public readonly List<MergeHubCall> Calls = new();
|
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
|
{
|
|
Calls.Add(new MergeHubCall(method, args));
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
internal sealed class MergeRecordingHubClients : IHubClients
|
|
{
|
|
public MergeRecordingClientProxy AllProxy { get; } = new();
|
|
public IClientProxy All => AllProxy;
|
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => AllProxy;
|
|
public IClientProxy Client(string connectionId) => AllProxy;
|
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => AllProxy;
|
|
public IClientProxy Group(string groupName) => AllProxy;
|
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => AllProxy;
|
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => AllProxy;
|
|
public IClientProxy User(string userId) => AllProxy;
|
|
public IClientProxy Users(IReadOnlyList<string> userIds) => AllProxy;
|
|
}
|
|
|
|
internal sealed class MergeRecordingHubContext : IHubContext<ClaudeDo.Worker.Hub.WorkerHub>
|
|
{
|
|
private readonly MergeRecordingHubClients _clients = new();
|
|
public MergeRecordingClientProxy Proxy => _clients.AllProxy;
|
|
public IHubClients Clients => _clients;
|
|
public IGroupManager Groups => throw new NotImplementedException();
|
|
}
|
|
|
|
#endregion
|