ConflictMarkerParser only recognizes text-marker conflicts, so a modify/delete or rename/delete conflict (git never writes markers for those) was invisible to the resolver: GetConflictDocumentsAsync read it as an already-resolved stable file, and ContinueMergeAsync's marker scan let it through, so `git add` on the unmerged path silently kept whichever side wasn't deleted. GetConflictDocumentsAsync now tells a modify/delete-style conflict (one index stage missing) apart from an ordinary already-resolved file (both stages present) and synthesizes a real whole-file conflict block from the index stages so it shows up in the resolver. ContinueMergeAsync compares such a path against git's own default checkout content before treating it as resolved, so an untouched file blocks the merge instead of being silently staged. Resolving to the deleted side (empty content) now runs `git rm` instead of staging a tracked, zero-byte file.
1868 lines
88 KiB
C#
1868 lines
88 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 == "OperationProgress"
|
|
&& (string?)c.Args[0] == task.Id && (string?)c.Args[1] == TaskMergeService.PhaseMerging);
|
|
Assert.Contains(proxy.Calls, c => c.Method == "OperationProgress"
|
|
&& (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"));
|
|
}
|
|
|
|
// git never writes <<<<<<< markers for a modify/delete conflict -- it just leaves the
|
|
// modifying side's content sitting in the working tree, which used to read as an
|
|
// already-resolved stable file (no conflict at all) to both GetConflictDocumentsAsync and
|
|
// ContinueMergeAsync's marker scan, so `continue_merge` would silently keep the modification.
|
|
[Fact]
|
|
public async Task ModifyDeleteConflict_IsSynthesizedAndCannotBeSilentlyStaged()
|
|
{
|
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
|
|
var db = NewDb();
|
|
var repo = NewRepo();
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/modify-delete", wtPath, repo.BaseCommit);
|
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# modified by branch\n");
|
|
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch modifies README");
|
|
|
|
GitRepoFixture.RunGit(repo.RepoDir, "rm", "README.md");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main deletes README");
|
|
|
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
|
await SeedWorktree(db, task.Id, wtPath, "claudedo/modify-delete", repo.BaseCommit);
|
|
|
|
var (svc, _) = BuildService(db);
|
|
var merge = await svc.MergeAsync(task.Id, "main", removeWorktree: false, "msg",
|
|
leaveConflictsInTree: true, CancellationToken.None);
|
|
Assert.Equal(TaskMergeService.StatusConflict, merge.Status);
|
|
|
|
// No <<<<<<< markers anywhere -- git left the branch's modification as-is.
|
|
Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));
|
|
|
|
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.Equal("", conflict.Ours); // ours (main) deleted it
|
|
Assert.Contains("modified by branch", conflict.Theirs); // theirs (branch) modified it
|
|
|
|
// Nobody resolved it — continue must refuse rather than silently keeping the modification.
|
|
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
var refused = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
|
|
Assert.Equal(TaskMergeService.StatusConflict, refused.Status);
|
|
Assert.Contains("README.md", refused.ConflictFiles);
|
|
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
|
|
Assert.True(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
|
|
|
// Explicitly resolving to the deleted (ours) side must `git rm`, not stage an empty file.
|
|
await svc.WriteResolutionAsync(task.Id, "README.md", "", CancellationToken.None);
|
|
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
|
Assert.False(File.Exists(Path.Combine(repo.RepoDir, "README.md")));
|
|
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
|
}
|
|
|
|
[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);
|
|
|
|
// The rebase phase must be visible on the wire while it runs -- otherwise the UI still
|
|
// shows the stalled "Merging…" phase for the whole (best-effort) rebase loop.
|
|
Assert.Contains(proxy.Calls, c => c.Method == "OperationProgress"
|
|
&& c.Args[0] is string opKey && opKey == taskA.Id
|
|
&& c.Args[1] is string phase && phase == TaskMergeService.PhaseRebasing
|
|
&& c.Args[2] is int current && current == 1
|
|
&& c.Args[3] is int total && total == 1);
|
|
}
|
|
|
|
[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
|