Overlap detection was file-name-only: 9/15 tasks touching en.json/de.json in run #316 all got flagged even though only one pair actually collided on a key, and a removed method with no textual file conflict (#297 vs #286) went unflagged entirely. - removedSymbols per task entry: a line-pattern scan of `-` diff lines for C# method/property/field/type/event declarations (skips .axaml/.json). - symbolWarnings at the set level: cross-references those names against every other task's `+` lines, catching same-symbol collisions across files that overlaps/subsets (both purely file-based) can't see. - overlaps narrows to key-path level for .json files (base commit vs. task branch head, both sides parsed and flattened) so two tasks adding different locale keys to the same file no longer collide; unparsable JSON on either side falls back to the old file-level check. - GitService.ShowFileAsync: `git show ref:path`, used to read a task branch's json content at its base/head commit. Out of scope per the task: no Roslyn/parser, no AXAML analysis, and the merge-helper prompt in PromptFiles.cs doesn't mention the new fields yet. Build: dotnet build src/ClaudeDo.App (pulls in Ui+Data) + src/ClaudeDo.Worker, both clean. Tests: dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~MergeMcpTools|FullyQualifiedName~ExternalMcpToolSchema" -- 51 passed, 0 failed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1456 lines
70 KiB
C#
1456 lines
70 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Config;
|
|
using ClaudeDo.Worker.External;
|
|
using ClaudeDo.Worker.Git;
|
|
using ClaudeDo.Worker.Hub;
|
|
using ClaudeDo.Worker.Lifecycle;
|
|
using ClaudeDo.Worker.Planning;
|
|
using ClaudeDo.Worker.Queue;
|
|
using ClaudeDo.Worker.Runner;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using ClaudeDo.Worker.Tests.Services;
|
|
using ClaudeDo.Worker.Usage;
|
|
using ClaudeDo.Worker.Worktrees;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ModelContextProtocol;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.External;
|
|
|
|
// A synchronously-collecting IProgress<T>: Progress<T> marshals through the SynchronizationContext
|
|
// captured at construction, which is unreliable to assert on immediately in a test.
|
|
file sealed class SyncProgress<T> : IProgress<T>
|
|
{
|
|
public readonly List<T> Reports = new();
|
|
public void Report(T value) => Reports.Add(value);
|
|
}
|
|
|
|
public sealed class MergeMcpToolsTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly ClaudeDoDbContext _ctx;
|
|
private readonly TaskRepository _tasks;
|
|
private readonly ListRepository _lists;
|
|
private readonly HubBroadcaster _broadcaster;
|
|
private readonly List<GitRepoFixture> _repos = new();
|
|
private readonly List<(string repoDir, string wtPath)> _worktreeCleanups = new();
|
|
private int _numberSeed;
|
|
|
|
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
|
|
|
|
public MergeMcpToolsTests()
|
|
{
|
|
_ctx = _db.CreateContext();
|
|
_tasks = new TaskRepository(_ctx);
|
|
_lists = new ListRepository(_ctx);
|
|
_broadcaster = new HubBroadcaster(new CapturingHubContext());
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var (repoDir, wtPath) in _worktreeCleanups)
|
|
{
|
|
try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { }
|
|
}
|
|
foreach (var r in _repos) r.Dispose();
|
|
_ctx.Dispose();
|
|
_db.Dispose();
|
|
}
|
|
|
|
private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedWorktreeAsync(
|
|
TaskStatus status = TaskStatus.Done)
|
|
{
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
|
|
var listId = Guid.NewGuid().ToString();
|
|
var list = new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow };
|
|
await _lists.AddAsync(list);
|
|
var task = await SeedTaskAsync(listId, status: status);
|
|
|
|
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
|
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
|
var wt = await mgr.CreateAsync(task, list, CancellationToken.None);
|
|
_worktreeCleanups.Add((repo.RepoDir, wt.WorktreePath));
|
|
return (task, list, wt);
|
|
}
|
|
|
|
private async Task<string> SeedListAsync(string name = "L")
|
|
{
|
|
var id = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = id, Name = name, CreatedAt = DateTime.UtcNow });
|
|
return id;
|
|
}
|
|
|
|
private async Task<TaskEntity> SeedTaskAsync(
|
|
string listId, string title = "t", TaskStatus status = TaskStatus.Idle, string? parentId = null)
|
|
{
|
|
var task = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = title,
|
|
Status = status,
|
|
ParentTaskId = parentId,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "chore",
|
|
Number = ++_numberSeed,
|
|
};
|
|
await _tasks.AddAsync(task);
|
|
return task;
|
|
}
|
|
|
|
private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedConflictingWorktreeAsync(
|
|
TaskStatus status = TaskStatus.WaitingForReview)
|
|
{
|
|
var (task, list, wt) = await SeedWorktreeAsync(status);
|
|
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "README.md"), "# from worktree\n");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "add", "README.md");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "worktree edit");
|
|
|
|
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# from main\n");
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "main edit");
|
|
|
|
return (task, list, wt);
|
|
}
|
|
|
|
// Three edits spread far enough apart in a 20-line file that git's 3-way merge keeps them as three
|
|
// separate, non-adjacent hunks instead of folding them into one.
|
|
private static readonly int[] MultiHunkEditLines = [3, 9, 15];
|
|
|
|
private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedMultiHunkConflictingWorktreeAsync()
|
|
{
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
|
|
var baseLines = Enumerable.Range(1, 20).Select(n => $"line{n}").ToArray();
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), string.Join('\n', baseLines) + "\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "README.md");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "multi-line base");
|
|
|
|
var listId = Guid.NewGuid().ToString();
|
|
var list = new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow };
|
|
await _lists.AddAsync(list);
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
|
|
|
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
|
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
|
var wt = await mgr.CreateAsync(task, list, CancellationToken.None);
|
|
_worktreeCleanups.Add((repo.RepoDir, wt.WorktreePath));
|
|
|
|
WriteMultiHunkVariant(wt.WorktreePath, baseLines, "WT");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "add", "README.md");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "worktree edit");
|
|
|
|
WriteMultiHunkVariant(list.WorkingDir!, baseLines, "MAIN");
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "main edit");
|
|
|
|
return (task, list, wt);
|
|
}
|
|
|
|
private static void WriteMultiHunkVariant(string dir, string[] baseLines, string tag)
|
|
{
|
|
var lines = (string[])baseLines.Clone();
|
|
foreach (var lineNum in MultiHunkEditLines)
|
|
lines[lineNum - 1] = $"{tag}-{lineNum}";
|
|
File.WriteAllText(Path.Combine(dir, "README.md"), string.Join('\n', lines) + "\n");
|
|
}
|
|
|
|
private async Task<string> CreateActiveWorktreeAsync(
|
|
GitRepoFixture repo, string taskId, string fileName, string content, string? baseCommit = null)
|
|
{
|
|
baseCommit ??= repo.BaseCommit;
|
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
_worktreeCleanups.Add((repo.RepoDir, wtPath));
|
|
var branch = $"claudedo/{taskId[..8]}";
|
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, baseCommit);
|
|
File.WriteAllText(Path.Combine(wtPath, fileName), content);
|
|
GitRepoFixture.RunGit(wtPath, "add", fileName);
|
|
GitRepoFixture.RunGit(wtPath, "commit", "-m", $"edit {fileName}");
|
|
|
|
using var ctx = _db.CreateContext();
|
|
ctx.Worktrees.Add(new WorktreeEntity
|
|
{
|
|
TaskId = taskId, Path = wtPath, BranchName = branch,
|
|
BaseCommit = baseCommit,
|
|
HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
|
|
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
return wtPath;
|
|
}
|
|
|
|
// queue is accepted (and ignored) so every extracted test body can keep calling
|
|
// BuildSut(CreateQueue()) unchanged -- MergeMcpTools itself has no QueueService dependency.
|
|
private MergeMcpTools BuildSut(QueueService queue) => BuildSut(queue, new VerifyCommandRunner());
|
|
|
|
private MergeMcpTools BuildSut(QueueService queue, IVerifyCommandRunner verify)
|
|
{
|
|
var git = new GitService();
|
|
var factory = _db.CreateFactory();
|
|
var state = TaskStateServiceBuilder.Build(factory).State;
|
|
var merge = new TaskMergeService(factory, git, _broadcaster, state, verify, NullLogger<TaskMergeService>.Instance);
|
|
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
|
|
var planningMerge = new PlanningMergeOrchestrator(
|
|
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
|
return new MergeMcpTools(_tasks, _lists, _broadcaster, git, factory, merge, planningMerge);
|
|
}
|
|
|
|
// ReviewTask (approve) still lives on ExternalMcpService. Several tests here approve a task
|
|
// first, then act on the resulting in-progress merge via MergeMcpTools -- for a plain (childless)
|
|
// task that's safe with two independently-built instances (TaskMergeService/GitService are
|
|
// git-disk- and DB-backed, not instance-stateful). Only the parent/children unit-merge path
|
|
// (PlanningMergeOrchestrator's in-memory active-merge tracking) needs the two SUTs to share the
|
|
// same underlying objects -- see BuildSharedSuts below.
|
|
private ExternalMcpService BuildReviewSut(QueueService queue) => BuildReviewSut(queue, new VerifyCommandRunner());
|
|
|
|
private ExternalMcpService BuildReviewSut(QueueService queue, IVerifyCommandRunner verify)
|
|
{
|
|
var git = new GitService();
|
|
var factory = _db.CreateFactory();
|
|
var state = TaskStateServiceBuilder.Build(factory).State;
|
|
var merge = new TaskMergeService(factory, git, _broadcaster, state, verify, NullLogger<TaskMergeService>.Instance);
|
|
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
|
|
var planningMerge = new PlanningMergeOrchestrator(
|
|
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
|
return new ExternalMcpService(
|
|
_tasks, _lists, queue, _broadcaster, state, git, factory, merge, planningMerge,
|
|
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
|
|
}
|
|
|
|
// Parent/children unit merges route through PlanningMergeOrchestrator's in-memory _states
|
|
// dictionary (ContinueMerge/AbortMerge check HasActiveMerge on it) -- a review that starts the
|
|
// unit merge and a later continue/abort must observe the SAME orchestrator instance, so this
|
|
// shares git/factory/state/merge/planningMerge between the two SUTs instead of building fresh.
|
|
private (ExternalMcpService ReviewSut, MergeMcpTools MergeSut) BuildSharedSuts(QueueService queue)
|
|
{
|
|
var git = new GitService();
|
|
var factory = _db.CreateFactory();
|
|
var state = TaskStateServiceBuilder.Build(factory).State;
|
|
var merge = new TaskMergeService(factory, git, _broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
|
|
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
|
|
var planningMerge = new PlanningMergeOrchestrator(
|
|
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
|
var reviewSut = new ExternalMcpService(
|
|
_tasks, _lists, queue, _broadcaster, state, git, factory, merge, planningMerge,
|
|
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
|
|
var mergeSut = new MergeMcpTools(_tasks, _lists, _broadcaster, git, factory, merge, planningMerge);
|
|
return (reviewSut, mergeSut);
|
|
}
|
|
|
|
private QueueService CreateQueue()
|
|
{
|
|
var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_merge_{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(tempDir);
|
|
var cfg = new WorkerConfig
|
|
{
|
|
SandboxRoot = Path.Combine(tempDir, "sandbox"),
|
|
LogRoot = Path.Combine(tempDir, "logs"),
|
|
QueueBackstopIntervalMs = 50,
|
|
};
|
|
var fake = new FakeClaudeProcess();
|
|
var hubCtx = new CapturingHubContext();
|
|
var broadcaster = new HubBroadcaster(hubCtx);
|
|
var dbFactory = _db.CreateFactory();
|
|
var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
|
|
var argsBuilder = new ClaudeArgsBuilder();
|
|
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
|
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg,
|
|
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
|
|
var waker = new ClaudeDo.Worker.Queue.QueueWaker();
|
|
var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
|
|
var runCancels = new RunCancellationRegistry(NullLogger<RunCancellationRegistry>.Instance);
|
|
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, runCancels);
|
|
var usageState = new UsageState();
|
|
return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state, runCancels,
|
|
new FakeUsageGate(), usageState, broadcaster,
|
|
new UsageLimitAutoContinueCoordinator(dbFactory, usageState, overrideSlot, broadcaster, NullLogger<UsageLimitAutoContinueCoordinator>.Instance));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeTask_NotDone_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None));
|
|
Assert.Contains("Done", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeTask_DryRun_DoesNotMerge()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.False(result.Merged);
|
|
Assert.Null(result.MergeCommit);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeTask_WaitingForReview_WithoutFlag_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None));
|
|
Assert.Contains("Done", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeTask_WaitingForReview_WithFlag_DryRun_Succeeds()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, allowWaitingForReview: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.False(result.Merged);
|
|
Assert.Null(result.MergeCommit);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeTask_WaitingForReview_WithFlag_MarksTaskDone()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
|
|
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
|
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
|
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
|
|
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.True(result.Merged);
|
|
var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Done, reloaded!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeTask_LeaveConflictsInTree_LeavesMarkersAndKeepsRepoMidMerge()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.MergeTask(task.Id, target, true, dryRun: false,
|
|
allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.False(result.Merged);
|
|
Assert.True(result.ConflictsInTree);
|
|
Assert.Equal(list.WorkingDir, result.RepoPath);
|
|
Assert.Contains("README.md", result.Conflicts);
|
|
Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
|
|
Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
|
var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status);
|
|
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeTask_ConflictWithoutLeaveFlag_AbortsCleanly()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.MergeTask(task.Id, target, true, dryRun: false,
|
|
allowWaitingForReview: true, leaveConflictsInTree: false, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.False(result.Merged);
|
|
Assert.False(result.ConflictsInTree);
|
|
Assert.Null(result.RepoPath);
|
|
Assert.Contains("README.md", result.Conflicts);
|
|
Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
|
|
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ContinueMerge_AfterResolvingMarkers_MergesAndSetsDone()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var reviewSut = BuildReviewSut(CreateQueue());
|
|
await reviewSut.ReviewTask(task.Id, "approve", null, null,
|
|
leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
|
|
|
// Resolve the conflict on disk and stage it.
|
|
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n");
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.True(result.Merged);
|
|
Assert.Equal("Done", result.TaskStatus);
|
|
Assert.Empty(result.Conflicts);
|
|
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ContinueMerge_SlowVerifyCommand_ReportsProgressWellBeforeIdleTimeout()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var original = TaskMergeService.ProgressReportInterval;
|
|
TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50);
|
|
try
|
|
{
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var plainSut = BuildReviewSut(CreateQueue());
|
|
await plainSut.ReviewTask(task.Id, "approve", null, null,
|
|
leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
|
|
|
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n");
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
|
|
|
|
using (var ctx = _db.CreateContext())
|
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });
|
|
|
|
var fakeVerify = new FakeVerifyCommandRunner
|
|
{
|
|
Result = new VerifyCommandResult(0, false, "ok"),
|
|
Delay = TimeSpan.FromMilliseconds(300),
|
|
};
|
|
var sut = BuildSut(CreateQueue(), fakeVerify);
|
|
|
|
var reports = new List<ProgressNotificationValue>();
|
|
var progress = new Progress<ProgressNotificationValue>(reports.Add);
|
|
|
|
var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None, progress: progress);
|
|
|
|
Assert.True(result.Merged);
|
|
Assert.Equal("Done", result.TaskStatus);
|
|
await Task.Delay(200);
|
|
Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running"));
|
|
}
|
|
finally
|
|
{
|
|
TaskMergeService.ProgressReportInterval = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ContinueMerge_MarkersStillPresent_ReportsConflicts()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var reviewSut = BuildReviewSut(CreateQueue());
|
|
await reviewSut.ReviewTask(task.Id, "approve", null, null,
|
|
leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
|
|
|
// Markers never resolved — continue must refuse and list the files.
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.False(result.Merged);
|
|
Assert.Contains("README.md", result.Conflicts);
|
|
Assert.Equal(list.WorkingDir, result.RepoPath);
|
|
Assert.Equal("WaitingForReview", result.TaskStatus);
|
|
Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
|
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AbortMerge_RestoresCleanTreeAndKeepsWaitingForReview()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var reviewSut = BuildReviewSut(CreateQueue());
|
|
await reviewSut.ReviewTask(task.Id, "approve", null, null,
|
|
leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var dto = await sut.AbortMerge(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal("WaitingForReview", dto.Status);
|
|
Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
|
|
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ContinueMerge_NoMergeInProgress_Throws()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None));
|
|
Assert.Contains("mid-merge", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AbortMerge_NoMergeInProgress_Throws()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.AbortMerge(task.Id, CancellationToken.None));
|
|
Assert.Contains("mid-merge", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMergeConflicts_ReportsHunksWithStartLineAndDiff3Base()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var reviewSut = BuildReviewSut(CreateQueue());
|
|
await reviewSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(1, result.RemainingHunks);
|
|
var file = Assert.Single(result.Files);
|
|
Assert.Equal("README.md", file.Path);
|
|
Assert.False(file.IsBinary);
|
|
var hunk = Assert.Single(file.Hunks);
|
|
Assert.Equal(0, hunk.Index);
|
|
Assert.True(hunk.StartLine >= 1);
|
|
Assert.Equal("# from main", hunk.Ours.TrimEnd('\r', '\n'));
|
|
Assert.Equal("# from worktree", hunk.Theirs.TrimEnd('\r', '\n'));
|
|
Assert.NotEmpty(result.ConflictStyleNote);
|
|
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMergeConflicts_NoMergeInProgress_ReturnsEmpty()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(0, result.RemainingHunks);
|
|
Assert.Empty(result.Files);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResolveConflictHunk_OursTheirsAndText_ResolveEachHunkAndTrackRemainingCount()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync();
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var merge = await sut.MergeTask(task.Id, target, true, dryRun: false,
|
|
allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
|
Assert.True(merge.ConflictsInTree);
|
|
|
|
var before = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
|
Assert.Equal(3, before.RemainingHunks);
|
|
Assert.Equal(3, Assert.Single(before.Files).Hunks.Count);
|
|
|
|
// Resolve hunk 0 three times in a row: each resolution removes a hunk from the front, so the
|
|
// next still-open hunk always slides into index 0.
|
|
var r1 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None);
|
|
Assert.Equal(2, r1.RemainingHunks);
|
|
|
|
var r2 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "theirs", CancellationToken.None);
|
|
Assert.Equal(1, r2.RemainingHunks);
|
|
|
|
var r3 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "CUSTOM-15\n", CancellationToken.None);
|
|
Assert.Equal(0, r3.RemainingHunks);
|
|
|
|
var finalConflicts = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
|
Assert.Equal(0, finalConflicts.RemainingHunks);
|
|
|
|
var continueResult = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);
|
|
Assert.True(continueResult.Merged);
|
|
Assert.Equal("Done", continueResult.TaskStatus);
|
|
|
|
var mergedText = File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"));
|
|
Assert.Contains("MAIN-3", mergedText); // hunk 1 resolved "ours" (the target branch)
|
|
Assert.Contains("WT-9", mergedText); // hunk 2 resolved "theirs" (the incoming branch)
|
|
Assert.Contains("CUSTOM-15", mergedText); // hunk 3 resolved to literal text
|
|
Assert.DoesNotContain("<<<<<<<", mergedText);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResolveConflictHunk_LeavesOtherHunksUntouchedUntilContinueMerge()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync();
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
|
|
var sut = BuildSut(CreateQueue());
|
|
await sut.MergeTask(task.Id, target, true, dryRun: false,
|
|
allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
|
|
|
// Resolve only one of the three hunks.
|
|
await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None);
|
|
|
|
// continue_merge must still refuse: writing one hunk must not have staged the file as resolved.
|
|
var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.False(result.Merged);
|
|
Assert.Contains("README.md", result.Conflicts);
|
|
Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
|
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResolveConflictHunk_UnknownFileOrOutOfRangeIndex_Throws()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var reviewSut = BuildReviewSut(CreateQueue());
|
|
await reviewSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.ResolveConflictHunk(task.Id, "not-a-real-file.md", 0, "ours", CancellationToken.None));
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.ResolveConflictHunk(task.Id, "README.md", 5, "ours", CancellationToken.None));
|
|
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevertMerge_NotMerged_Throws()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.RevertMerge(task.Id, "main", CancellationToken.None));
|
|
Assert.Contains("expected Merged", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevertMerge_AfterCleanMerge_RevertsAndReturnsTaskToWaitingForReview()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done);
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "new\n");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file");
|
|
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None);
|
|
Assert.True(File.Exists(Path.Combine(list.WorkingDir!, "added.txt")));
|
|
|
|
var result = await sut.RevertMerge(task.Id, target, CancellationToken.None);
|
|
|
|
Assert.True(result.Reverted);
|
|
Assert.False(string.IsNullOrWhiteSpace(result.RevertCommit));
|
|
Assert.Empty(result.Conflicts);
|
|
Assert.False(File.Exists(Path.Combine(list.WorkingDir!, "added.txt")));
|
|
|
|
var reloaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevertMerge_ConflictingRevert_ReturnsRevertedFalseWithConflictsAndAbortsCleanly()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done);
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "README.md"), "# test repo\nfeature\n");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: append feature line");
|
|
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None);
|
|
|
|
// A later commit on main edits the exact line the merge introduced, so the revert conflicts.
|
|
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# test repo\npost-merge edit\n");
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "add", "-A");
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "chore: edit the same line");
|
|
var headBefore = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim();
|
|
|
|
var result = await sut.RevertMerge(task.Id, target, CancellationToken.None);
|
|
|
|
Assert.False(result.Reverted);
|
|
Assert.Contains("README.md", result.Conflicts);
|
|
Assert.False(await new GitService().IsMidRevertAsync(list.WorkingDir!));
|
|
Assert.Equal(headBefore, GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim());
|
|
|
|
var reloaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Done, reloaded!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ContinueMerge_ParentUnitMergeConflict_RoutesToOrchestratorAndCompletes()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
// main edits README.md so the child's edit conflicts.
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "README.md");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
|
|
|
|
var listId = Guid.NewGuid().ToString();
|
|
var parentId = Guid.NewGuid().ToString();
|
|
var childId = Guid.NewGuid().ToString();
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
|
|
Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized, Number = ++_numberSeed });
|
|
ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
|
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed });
|
|
|
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
_worktreeCleanups.Add((repo.RepoDir, wtPath));
|
|
var branch = $"claudedo/{childId[..8]}";
|
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
|
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# child change\n");
|
|
GitRepoFixture.RunGit(wtPath, "add", "README.md");
|
|
GitRepoFixture.RunGit(wtPath, "commit", "-m", "child edit");
|
|
ctx.Worktrees.Add(new WorktreeEntity
|
|
{
|
|
TaskId = childId, Path = wtPath, BranchName = branch,
|
|
BaseCommit = repo.BaseCommit,
|
|
HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
|
|
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var (reviewSut, sut) = BuildSharedSuts(CreateQueue());
|
|
var review = await reviewSut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None);
|
|
Assert.Equal(TaskMergeService.StatusConflict, review.MergeStatus);
|
|
Assert.Equal(repo.RepoDir, review.RepoPath);
|
|
Assert.Contains("continue_merge", review.MergeMessage);
|
|
|
|
// Resolve the conflict on disk.
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");
|
|
|
|
var result = await sut.ContinueMerge(parentId, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.True(result.Merged);
|
|
Assert.Equal("Done", result.TaskStatus);
|
|
Assert.Empty(result.Conflicts);
|
|
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
|
using var verify = _db.CreateContext();
|
|
Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_CleanMerge_ReturnsCleanStatusAndDoesNotChangeRepo()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
|
|
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
|
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
|
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
|
|
|
|
var git = new GitService();
|
|
var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
|
var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
|
Assert.Empty(result.ConflictFiles);
|
|
Assert.Equal(1, result.ChangedFileCount);
|
|
Assert.Equal(0, result.Behind);
|
|
Assert.False(result.IsEmpty);
|
|
Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None));
|
|
Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_EmptyWorktree_ReturnsIsEmptyTrue()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
// Worktree created but nothing committed beyond base -- e.g. a child that reported
|
|
// CLAUDEDO_BLOCKED before writing any code.
|
|
var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
|
Assert.Equal(0, result.ChangedFileCount);
|
|
Assert.True(result.IsEmpty);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_WorktreeLessHandlerTask_EmptyRange_ReturnsIsEmptyTrue()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
|
// Worktree-less list-handler host task whose handler range never actually committed
|
|
// anything: HandlerBaseCommit == HandlerHeadCommit, not "no worktree -> unknown".
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
var t = await ctx.Tasks.FindAsync(task.Id);
|
|
t!.HandlerBaseCommit = repo.BaseCommit;
|
|
t.HandlerHeadCommit = repo.BaseCommit;
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
|
Assert.Equal(0, result.ChangedFileCount);
|
|
Assert.True(result.IsEmpty);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_WorktreeLessHandlerTask_NonEmptyRange_ReturnsIsEmptyFalse()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
|
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
|
|
var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
var t = await ctx.Tasks.FindAsync(task.Id);
|
|
t!.HandlerBaseCommit = repo.BaseCommit;
|
|
t.HandlerHeadCommit = headCommit;
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
|
Assert.Equal(1, result.ChangedFileCount);
|
|
Assert.False(result.IsEmpty);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_ConflictingBranches_ReturnsConflictStatusAndDoesNotChangeRepo()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var git = new GitService();
|
|
var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
|
var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewConflict, result.Status);
|
|
Assert.Contains("README.md", result.ConflictFiles);
|
|
Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None));
|
|
Assert.False(await git.IsMidMergeAsync(list.WorkingDir!));
|
|
Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_NoWorktree_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_MissingWorkingDir_ThrowsClearError()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
var row = await ctx.Lists.FindAsync(list.Id);
|
|
row!.WorkingDir = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}");
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None));
|
|
Assert.Contains("working directory", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_VerifyCommandConfigured_RunsInScratchWorktreeAndDoesNotTouchTarget()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
|
|
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
|
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
|
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
|
|
|
|
using (var ctx = _db.CreateContext())
|
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });
|
|
|
|
var git = new GitService();
|
|
var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
|
var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
|
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
|
|
var sut = BuildSut(CreateQueue(), fakeVerify);
|
|
|
|
var result = await sut.PreviewMerge(task.Id, target, runVerify: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
|
Assert.Equal(0, result.VerifyExitCode);
|
|
Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
|
|
Assert.NotEqual(list.WorkingDir, fakeVerify.CapturedWorkingDir);
|
|
Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None));
|
|
Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_DefaultRunVerifyFalse_NeverInvokesRunnerEvenWithCommandConfigured()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
|
|
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
|
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
|
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
|
|
|
|
using (var ctx = _db.CreateContext())
|
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });
|
|
|
|
var git = new GitService();
|
|
var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
|
|
var sut = BuildSut(CreateQueue(), fakeVerify);
|
|
|
|
var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
|
Assert.Null(result.VerifyExitCode);
|
|
Assert.Null(result.VerifyDurationMs);
|
|
Assert.Null(result.VerifyOutputTail);
|
|
Assert.Null(fakeVerify.CapturedCommand);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_TargetChangedSameFileSinceFork_ReportsStaleFiles()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
|
await CreateActiveWorktreeAsync(repo, task.Id, "shared.txt", "from branch\n");
|
|
|
|
// The target branch itself moved on and touched the same file after the fork point.
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "shared.txt"), "from main\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "shared.txt");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main also touched shared.txt");
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.PreviewMerge(task.Id, "main", cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(1, result.Behind);
|
|
Assert.NotNull(result.StaleFiles);
|
|
Assert.Contains("shared.txt", result.StaleFiles!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_TargetBehindButUnrelatedFile_ReportsNoStaleFiles()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
|
await CreateActiveWorktreeAsync(repo, task.Id, "branch-only.txt", "from branch\n");
|
|
|
|
// Target moved on (behind > 0) but touched an entirely different file -- a stale
|
|
// branch that still collides with nothing.
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "unrelated.txt"), "from main\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "unrelated.txt");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main touched something else");
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.PreviewMerge(task.Id, "main", cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(1, result.Behind);
|
|
Assert.NotNull(result.StaleFiles);
|
|
Assert.Empty(result.StaleFiles!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_WorktreeLessHandlerTask_ReportsNoStaleFiles()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
|
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
|
|
var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
var t = await ctx.Tasks.FindAsync(task.Id);
|
|
t!.HandlerBaseCommit = repo.BaseCommit;
|
|
t.HandlerHeadCommit = headCommit;
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.NotNull(result.StaleFiles);
|
|
Assert.Empty(result.StaleFiles!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_TwoTasksSharedFile_ReportsOverlap()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
|
var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);
|
|
|
|
await CreateActiveWorktreeAsync(repo, taskA.Id, "shared.txt", "a change\n");
|
|
await CreateActiveWorktreeAsync(repo, taskB.Id, "shared.txt", "b change\n");
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(2, result.Tasks.Count);
|
|
Assert.All(result.Tasks, t => Assert.Null(t.Error));
|
|
Assert.All(result.Tasks, t => Assert.Equal(TaskMergeService.PreviewClean, t.Status));
|
|
var overlap = Assert.Single(result.Overlaps);
|
|
Assert.Equal("shared.txt", overlap.File);
|
|
Assert.Equal(
|
|
new[] { taskA.Id, taskB.Id }.OrderBy(x => x),
|
|
overlap.TaskIds.OrderBy(x => x));
|
|
// Equal file sets overlap but neither is a PROPER subset of the other.
|
|
Assert.Empty(result.Subsets);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_TaskFilesAreProperSubsetOfAnother_ReportsSubset()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
var subsetTask = await SeedTaskAsync(listId, "subset", TaskStatus.WaitingForReview);
|
|
var supersetTask = await SeedTaskAsync(listId, "superset", TaskStatus.WaitingForReview);
|
|
|
|
await CreateActiveWorktreeAsync(repo, subsetTask.Id, "shared.txt", "shared change\n");
|
|
|
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
_worktreeCleanups.Add((repo.RepoDir, wtPath));
|
|
var branch = $"claudedo/{supersetTask.Id[..8]}";
|
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
|
|
File.WriteAllText(Path.Combine(wtPath, "shared.txt"), "shared change too\n");
|
|
File.WriteAllText(Path.Combine(wtPath, "extra.txt"), "extra\n");
|
|
GitRepoFixture.RunGit(wtPath, "add", "-A");
|
|
GitRepoFixture.RunGit(wtPath, "commit", "-m", "edit shared+extra");
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
ctx.Worktrees.Add(new WorktreeEntity
|
|
{
|
|
TaskId = supersetTask.Id, Path = wtPath, BranchName = branch,
|
|
BaseCommit = repo.BaseCommit,
|
|
HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
|
|
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.PreviewMergeSet([subsetTask.Id, supersetTask.Id], "main", cancellationToken: CancellationToken.None);
|
|
|
|
var relation = Assert.Single(result.Subsets);
|
|
Assert.Equal(subsetTask.Id, relation.TaskId);
|
|
Assert.Equal(supersetTask.Id, relation.SupersetTaskId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_SingleTaskId_ReturnsEmptyOverlap()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.PreviewMergeSet([task.Id], target, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Single(result.Tasks);
|
|
Assert.Empty(result.Overlaps);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_ReportsExpectsNoDiffPerTask()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
task.ExpectsNoDiff = true;
|
|
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.PreviewMergeSet([task.Id], target, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.True(Assert.Single(result.Tasks).ExpectsNoDiff);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_ReportsProgressPerTask()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var t1 = await SeedTaskAsync(listId, "a");
|
|
var t2 = await SeedTaskAsync(listId, "b");
|
|
var sut = BuildSut(CreateQueue());
|
|
var progress = new SyncProgress<ProgressNotificationValue>();
|
|
|
|
// Neither task has a worktree -- each entry fails individually (PreviewUnavailable), but
|
|
// the loop still reports progress per task since a per-task InvalidOperationException
|
|
// doesn't abort the batch.
|
|
await sut.PreviewMergeSet(
|
|
[t1.Id, t2.Id], "main", cancellationToken: CancellationToken.None, progress: progress);
|
|
|
|
Assert.Equal(2, progress.Reports.Count);
|
|
Assert.Equal("Previewing merges (1/2)", progress.Reports[0].Message);
|
|
Assert.Equal("Previewing merges (2/2)", progress.Reports[1].Message);
|
|
Assert.Equal(2, progress.Reports[1].Total);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_OneTaskWithoutWorktree_ReportsErrorWithoutAbortingOthers()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var listId2 = await SeedListAsync("no-worktree-list");
|
|
var noWorktreeTask = await SeedTaskAsync(listId2, "b", TaskStatus.Idle);
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.PreviewMergeSet([task.Id, noWorktreeTask.Id], target, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(2, result.Tasks.Count);
|
|
var ok = result.Tasks.Single(t => t.TaskId == task.Id);
|
|
Assert.Null(ok.Error);
|
|
Assert.Equal(TaskMergeService.PreviewClean, ok.Status);
|
|
var failed = result.Tasks.Single(t => t.TaskId == noWorktreeTask.Id);
|
|
Assert.NotNull(failed.Error);
|
|
Assert.Equal(TaskMergeService.PreviewUnavailable, failed.Status);
|
|
Assert.Empty(result.Overlaps);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_MixedEmptyAndNonEmpty_ReportsIsEmptyPerEntry()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
var emptyTask = await SeedTaskAsync(listId, "empty", TaskStatus.WaitingForReview);
|
|
var fullTask = await SeedTaskAsync(listId, "full", TaskStatus.WaitingForReview);
|
|
|
|
var emptyBranch = $"claudedo/{emptyTask.Id[..8]}";
|
|
var emptyWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
_worktreeCleanups.Add((repo.RepoDir, emptyWtPath));
|
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", emptyBranch, emptyWtPath, repo.BaseCommit);
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
ctx.Worktrees.Add(new WorktreeEntity
|
|
{
|
|
TaskId = emptyTask.Id, Path = emptyWtPath, BranchName = emptyBranch,
|
|
BaseCommit = repo.BaseCommit,
|
|
HeadCommit = repo.BaseCommit,
|
|
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
await CreateActiveWorktreeAsync(repo, fullTask.Id, "full.txt", "content\n");
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.PreviewMergeSet([emptyTask.Id, fullTask.Id], "main", cancellationToken: CancellationToken.None);
|
|
|
|
var emptyEntry = result.Tasks.Single(t => t.TaskId == emptyTask.Id);
|
|
var fullEntry = result.Tasks.Single(t => t.TaskId == fullTask.Id);
|
|
Assert.Null(emptyEntry.Error);
|
|
Assert.True(emptyEntry.IsEmpty);
|
|
Assert.Null(fullEntry.Error);
|
|
Assert.False(fullEntry.IsEmpty);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_DefaultRunVerifyFalse_NeverInvokesRunnerEvenWithCommandConfigured()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
using (var ctx = _db.CreateContext())
|
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" });
|
|
var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
|
await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n");
|
|
|
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
|
|
var sut = BuildSut(CreateQueue(), fakeVerify);
|
|
|
|
var result = await sut.PreviewMergeSet([task.Id], "main", cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Null(result.Tasks.Single().VerifyExitCode);
|
|
Assert.Null(fakeVerify.CapturedCommand);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_RunVerifyTrue_InvokesRunnerPerTask()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
using (var ctx = _db.CreateContext())
|
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" });
|
|
var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
|
await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n");
|
|
|
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
|
|
var sut = BuildSut(CreateQueue(), fakeVerify);
|
|
|
|
var result = await sut.PreviewMergeSet([task.Id], "main", runVerify: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, result.Tasks.Single().VerifyExitCode);
|
|
Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_RemovedSymbolReferencedByAnotherTask_ReportsSymbolWarning()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "Foo.cs"),
|
|
"public class Foo\n{\n private void RestartWorkerStatus()\n {\n }\n}\n");
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "Bar.cs"), "public class Bar\n{\n}\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add Foo/Bar");
|
|
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
var removerTask = await SeedTaskAsync(listId, "removes", TaskStatus.WaitingForReview);
|
|
var referencerTask = await SeedTaskAsync(listId, "references", TaskStatus.WaitingForReview);
|
|
|
|
await CreateActiveWorktreeAsync(repo, removerTask.Id, "Foo.cs", "public class Foo\n{\n}\n", baseCommit);
|
|
await CreateActiveWorktreeAsync(repo, referencerTask.Id, "Bar.cs",
|
|
"public class Bar\n{\n private void Call()\n {\n RestartWorkerStatus();\n }\n}\n", baseCommit);
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.PreviewMergeSet(
|
|
[removerTask.Id, referencerTask.Id], "main", cancellationToken: CancellationToken.None);
|
|
|
|
var removerEntry = result.Tasks.Single(t => t.TaskId == removerTask.Id);
|
|
Assert.Contains(removerEntry.RemovedSymbols!, s => s.Name == "RestartWorkerStatus" && s.File == "Foo.cs");
|
|
|
|
var warning = Assert.Single(result.SymbolWarnings);
|
|
Assert.Equal("RestartWorkerStatus", warning.Symbol);
|
|
Assert.Equal("Foo.cs", warning.RemovedInFile);
|
|
Assert.Equal(removerTask.Id, warning.RemovedByTaskId);
|
|
Assert.Equal(referencerTask.Id, warning.ReferencedByTaskId);
|
|
Assert.Equal("Bar.cs", warning.ReferencedInFile);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_TwoTasksAddDifferentJsonKeys_ReportsNoOverlap()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "en.json"), "{}\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add en.json");
|
|
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
|
var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);
|
|
|
|
await CreateActiveWorktreeAsync(repo, taskA.Id, "en.json", "{\"a\": {\"one\": \"1\"}}\n", baseCommit);
|
|
await CreateActiveWorktreeAsync(repo, taskB.Id, "en.json", "{\"a\": {\"two\": \"2\"}}\n", baseCommit);
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Empty(result.Overlaps);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_TwoTasksChangeSameJsonKey_ReportsOverlap()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "en.json"), "{}\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add en.json");
|
|
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
|
var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);
|
|
|
|
await CreateActiveWorktreeAsync(repo, taskA.Id, "en.json", "{\"a\": {\"one\": \"1\"}}\n", baseCommit);
|
|
await CreateActiveWorktreeAsync(repo, taskB.Id, "en.json", "{\"a\": {\"one\": \"2\"}}\n", baseCommit);
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);
|
|
|
|
var overlap = Assert.Single(result.Overlaps);
|
|
Assert.Equal("en.json", overlap.File);
|
|
Assert.Equal(
|
|
new[] { taskA.Id, taskB.Id }.OrderBy(x => x),
|
|
overlap.TaskIds.OrderBy(x => x));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_UnparsableJsonOnOneSide_FallsBackToFileOverlap()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "en.json"), "{}\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add en.json");
|
|
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
|
var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);
|
|
|
|
// taskA leaves the file syntactically broken -- both sides must still be flagged since
|
|
// there's no way to tell whether the keys actually collide.
|
|
await CreateActiveWorktreeAsync(repo, taskA.Id, "en.json", "{ not valid json", baseCommit);
|
|
await CreateActiveWorktreeAsync(repo, taskB.Id, "en.json", "{\"a\": {\"one\": \"2\"}}\n", baseCommit);
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);
|
|
|
|
var overlap = Assert.Single(result.Overlaps);
|
|
Assert.Equal("en.json", overlap.File);
|
|
Assert.Equal(
|
|
new[] { taskA.Id, taskB.Id }.OrderBy(x => x),
|
|
overlap.TaskIds.OrderBy(x => x));
|
|
}
|
|
}
|