1525 lines
64 KiB
C#
1525 lines
64 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.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.Usage;
|
|
using ClaudeDo.Worker.Worktrees;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.External;
|
|
|
|
internal sealed class ExternalRecordingHubClients : IHubClients
|
|
{
|
|
public ExternalRecordingClientProxy Proxy { get; } = new();
|
|
public IClientProxy All => Proxy;
|
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
|
public IClientProxy Client(string connectionId) => Proxy;
|
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy;
|
|
public IClientProxy Group(string groupName) => Proxy;
|
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy;
|
|
public IClientProxy User(string userId) => Proxy;
|
|
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy;
|
|
}
|
|
|
|
internal sealed class ExternalRecordingClientProxy : IClientProxy
|
|
{
|
|
public List<(string Method, object?[] Args)> Calls { get; } = new();
|
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
|
{
|
|
Calls.Add((method, args));
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
internal sealed class ExternalFakeHubContext : IHubContext<WorkerHub>
|
|
{
|
|
public ExternalRecordingHubClients RecordingClients { get; } = new();
|
|
public IHubClients Clients => RecordingClients;
|
|
public IGroupManager Groups => throw new NotImplementedException();
|
|
}
|
|
|
|
public sealed class ExternalMcpServiceTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly ClaudeDoDbContext _ctx;
|
|
private readonly TaskRepository _tasks;
|
|
private readonly ListRepository _lists;
|
|
private readonly ExternalFakeHubContext _hub = new();
|
|
private readonly HubBroadcaster _broadcaster;
|
|
private readonly List<GitRepoFixture> _repos = new();
|
|
private readonly List<(string repoDir, string wtPath)> _worktreeCleanups = new();
|
|
|
|
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
|
|
|
|
public ExternalMcpServiceTests()
|
|
{
|
|
_ctx = _db.CreateContext();
|
|
_tasks = new TaskRepository(_ctx);
|
|
_lists = new ListRepository(_ctx);
|
|
_broadcaster = new HubBroadcaster(_hub);
|
|
}
|
|
|
|
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",
|
|
};
|
|
await _tasks.AddAsync(task);
|
|
return task;
|
|
}
|
|
|
|
private ExternalMcpService BuildSut(QueueService queue)
|
|
{
|
|
var git = new GitService();
|
|
var factory = _db.CreateFactory();
|
|
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
|
var state = TaskStateServiceBuilder.Build(factory).State;
|
|
var merge = new TaskMergeService(factory, git, _broadcaster, state, 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, maintenance, merge, planningMerge);
|
|
}
|
|
|
|
private QueueService CreateQueue()
|
|
{
|
|
var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_ext_{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());
|
|
var waker = new ClaudeDo.Worker.Queue.QueueWaker();
|
|
var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
|
|
var runCancels = new RunCancellationRegistry();
|
|
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, runCancels);
|
|
return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state, runCancels,
|
|
new FakeUsageGate(), new UsageState(), broadcaster);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SeededListAndTask_AreRetrievable()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
Assert.NotNull(await _tasks.GetByIdAsync(task.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTask_PatchesNonNullFieldsOnly()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, "old title");
|
|
var queue = CreateQueue();
|
|
var sut = BuildSut(queue);
|
|
|
|
var dto = await sut.UpdateTask(task.Id, "new title", null, null, CancellationToken.None);
|
|
|
|
Assert.Equal("new title", dto.Title);
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal("new title", loaded!.Title);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTask_OnRunning_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
|
|
var queue = CreateQueue();
|
|
var sut = BuildSut(queue);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.UpdateTask(task.Id, "x", null, null, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTask_NotFound_Throws()
|
|
{
|
|
var queue = CreateQueue();
|
|
var sut = BuildSut(queue);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.UpdateTask("does-not-exist", "x", null, null, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReviewTask_Approve_SetsDone()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal("Done", result.Task.Status);
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReviewTask_RejectRerun_WithoutFeedback_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.ReviewTask(task.Id, "reject_rerun", null, null, cancellationToken: CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReviewTask_RejectRerun_QueuesAndStoresFeedback()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.ReviewTask(task.Id, "reject_rerun", "fix it", null, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal("Queued", result.Task.Status);
|
|
var loaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
|
|
Assert.Equal("fix it", loaded!.ReviewFeedback);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReviewTask_UnknownDecision_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.ReviewTask(task.Id, "bogus", null, null, cancellationToken: CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReviewTask_Approve_WithActiveWorktree_MergesIntoTargetBranch()
|
|
{
|
|
if (!GitAvailable) return;
|
|
var (task, list, wt) = await SeedWorktreeAsync(status: TaskStatus.WaitingForReview);
|
|
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x\n");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "add", "feature.txt");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "add feature");
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
var row = ctx.Worktrees.Single(w => w.TaskId == task.Id);
|
|
row.HeadCommit = GitRepoFixture.RunGit(wt.WorktreePath, "rev-parse", "HEAD").Trim();
|
|
ctx.SaveChanges();
|
|
}
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
|
|
Assert.Equal("Done", result.Task.Status);
|
|
Assert.True(File.Exists(Path.Combine(list.WorkingDir!, "feature.txt")));
|
|
using var verify = _db.CreateContext();
|
|
Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == task.Id).State);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReviewTask_Approve_ParentWithChildren_RunsUnitMerge()
|
|
{
|
|
if (!GitAvailable) return;
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
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 });
|
|
ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
|
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1 });
|
|
|
|
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, "child.txt"), "c\n");
|
|
GitRepoFixture.RunGit(wtPath, "add", "child.txt");
|
|
GitRepoFixture.RunGit(wtPath, "commit", "-m", "add child.txt");
|
|
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 sut = BuildSut(CreateQueue());
|
|
var result = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
|
|
Assert.Equal("Done", result.Task.Status);
|
|
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "child.txt")));
|
|
using var verify = _db.CreateContext();
|
|
Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteTask_RemovesTask()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var queue = CreateQueue();
|
|
var sut = BuildSut(queue);
|
|
|
|
await sut.DeleteTask(task.Id, CancellationToken.None);
|
|
|
|
Assert.Null(await _tasks.GetByIdAsync(task.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteTask_OnRunning_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
|
|
var queue = CreateQueue();
|
|
var sut = BuildSut(queue);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.DeleteTask(task.Id, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteTask_NotFound_Throws()
|
|
{
|
|
var queue = CreateQueue();
|
|
var sut = BuildSut(queue);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.DeleteTask("does-not-exist", CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteTask_LastNonTerminalChild_AdvancesParentToWaitingForReview()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var parent = await SeedTaskAsync(listId, status: TaskStatus.WaitingForChildren);
|
|
await SeedTaskAsync(listId, status: TaskStatus.Done, parentId: parent.Id);
|
|
var lastChild = await SeedTaskAsync(listId, status: TaskStatus.Idle, parentId: parent.Id);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
await sut.DeleteTask(lastChild.Id, CancellationToken.None);
|
|
|
|
var reloadedParent = await _tasks.GetByIdAsync(parent.Id);
|
|
Assert.Equal(TaskStatus.WaitingForReview, reloadedParent!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTaskStatus_Cancelled_CancelsAnIdleTask()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.UpdateTaskStatus(task.Id, "Cancelled", CancellationToken.None);
|
|
|
|
Assert.Equal("Cancelled", dto.Status);
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Cancelled, loaded!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTaskStatus_WaitingForReview_StillRejected()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.UpdateTaskStatus(task.Id, "WaitingForReview", CancellationToken.None));
|
|
Assert.Contains("not settable externally", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTaskStatus_Done_NoWorktree_SetsDoneAndFinishedAt()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None);
|
|
|
|
Assert.Equal("Done", dto.Status);
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Done, loaded!.Status);
|
|
Assert.NotNull(loaded.FinishedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTaskStatus_Done_WithActiveWorktree_Throws()
|
|
{
|
|
if (!GitAvailable) return;
|
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None));
|
|
|
|
Assert.Contains("active worktree", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.WaitingForReview, loaded!.Status);
|
|
}
|
|
|
|
private ExternalMcpService NewService() => BuildSut(CreateQueue());
|
|
|
|
private async Task<string> SeedIdleTask(string title = "t")
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, title, TaskStatus.Idle);
|
|
return task.Id;
|
|
}
|
|
|
|
private async Task SeedAppSettingsAsync(string? reportExcludedPaths, int dailyPrepMaxTasks = 5)
|
|
{
|
|
var settings = new AppSettingsEntity
|
|
{
|
|
Id = AppSettingsEntity.SingletonId,
|
|
ReportExcludedPaths = reportExcludedPaths,
|
|
DailyPrepMaxTasks = dailyPrepMaxTasks,
|
|
};
|
|
// Upsert via AppSettingsRepository
|
|
await using var ctx = _db.CreateContext();
|
|
var repo = new AppSettingsRepository(ctx);
|
|
await repo.UpdateAsync(settings);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDailyPrepCandidates_filters_by_status_block_and_excluded_repo()
|
|
{
|
|
// List 1: included repo D:\work\repo — 4 tasks seeded
|
|
var listId1 = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId1, Name = "Work", WorkingDir = @"D:\work\repo", CreatedAt = DateTime.UtcNow });
|
|
|
|
// idle, not blocked, not MyDay → should be candidate
|
|
var idleUnblocked = new TaskEntity
|
|
{
|
|
Id = "idle-unblocked",
|
|
ListId = listId1,
|
|
Title = "Idle unblocked",
|
|
Status = TaskStatus.Idle,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "chore",
|
|
};
|
|
await _tasks.AddAsync(idleUnblocked);
|
|
|
|
// idle but blocked → excluded (BlockedByTaskId references idle-unblocked as its predecessor)
|
|
var idleBlocked = new TaskEntity
|
|
{
|
|
Id = "idle-blocked",
|
|
ListId = listId1,
|
|
Title = "Idle blocked",
|
|
Status = TaskStatus.Idle,
|
|
BlockedByTaskId = "idle-unblocked",
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "chore",
|
|
};
|
|
await _tasks.AddAsync(idleBlocked);
|
|
|
|
// Done → excluded
|
|
var doneTask = new TaskEntity
|
|
{
|
|
Id = "done-task",
|
|
ListId = listId1,
|
|
Title = "Done task",
|
|
Status = TaskStatus.Done,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "chore",
|
|
};
|
|
await _tasks.AddAsync(doneTask);
|
|
|
|
// idle, IsMyDay → goes into currentMyDay, not candidates
|
|
var myDayTask = new TaskEntity
|
|
{
|
|
Id = "myday-task",
|
|
ListId = listId1,
|
|
Title = "MyDay task",
|
|
Status = TaskStatus.Idle,
|
|
IsMyDay = true,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "chore",
|
|
};
|
|
await _tasks.AddAsync(myDayTask);
|
|
|
|
// List 2: excluded repo C:\Private\secret
|
|
var listId2 = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId2, Name = "Secret", WorkingDir = @"C:\Private\secret", CreatedAt = DateTime.UtcNow });
|
|
|
|
var excludedRepoTask = new TaskEntity
|
|
{
|
|
Id = "excluded-repo-task",
|
|
ListId = listId2,
|
|
Title = "Excluded repo",
|
|
Status = TaskStatus.Idle,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "chore",
|
|
};
|
|
await _tasks.AddAsync(excludedRepoTask);
|
|
|
|
// List 3: no WorkingDir → excluded
|
|
var listId3 = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId3, Name = "NoRepo", WorkingDir = null, CreatedAt = DateTime.UtcNow });
|
|
|
|
var noRepoTask = new TaskEntity
|
|
{
|
|
Id = "no-repo-task",
|
|
ListId = listId3,
|
|
Title = "No repo",
|
|
Status = TaskStatus.Idle,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "chore",
|
|
};
|
|
await _tasks.AddAsync(noRepoTask);
|
|
|
|
await SeedAppSettingsAsync(@"[""C:\\Private""]", dailyPrepMaxTasks: 5);
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.GetDailyPrepCandidates(CancellationToken.None);
|
|
|
|
Assert.Single(result.Candidates);
|
|
Assert.Equal("idle-unblocked", result.Candidates[0].Id);
|
|
Assert.Single(result.CurrentMyDay);
|
|
Assert.Equal("myday-task", result.CurrentMyDay[0].Id);
|
|
Assert.Equal(5, result.MaxTasks);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetMyDay_sets_flag_and_sort_order()
|
|
{
|
|
var svc = NewService();
|
|
var id = await SeedIdleTask("My task");
|
|
|
|
var dto = await svc.SetMyDay(id, isMyDay: true, sortOrder: 3, CancellationToken.None);
|
|
|
|
Assert.True(dto.IsMyDay);
|
|
Assert.Equal(3, dto.SortOrder);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetMyDay_rejects_when_cap_reached()
|
|
{
|
|
await SeedAppSettingsAsync(null, dailyPrepMaxTasks: 1);
|
|
var svc = NewService();
|
|
var first = await SeedIdleTask("a");
|
|
var second = await SeedIdleTask("b");
|
|
await svc.SetMyDay(first, true, null, CancellationToken.None);
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => svc.SetMyDay(second, true, null, CancellationToken.None));
|
|
Assert.Contains("limit", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetMyDay_unset_is_always_allowed()
|
|
{
|
|
var svc = NewService();
|
|
var id = await SeedIdleTask("a");
|
|
await svc.SetMyDay(id, true, null, CancellationToken.None);
|
|
|
|
var dto = await svc.SetMyDay(id, false, null, CancellationToken.None);
|
|
Assert.False(dto.IsMyDay);
|
|
}
|
|
|
|
// ── GetTaskWorktree ────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task GetTaskWorktree_NoWorktree_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.GetTaskWorktree(task.Id, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskWorktree_ReturnsBranchAndBaseCommit()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, wt) = await SeedWorktreeAsync();
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(wt.BranchName, info.Branch);
|
|
Assert.Equal(wt.BaseCommit, info.BaseCommit);
|
|
Assert.Equal(0, info.Ahead);
|
|
Assert.False(info.IsDirty);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskWorktree_BeforeMerge_MergeCommitIsNull()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, _) = await SeedWorktreeAsync();
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None);
|
|
|
|
Assert.Null(info.MergeCommit);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskWorktree_AfterMerge_ExposesMergeCommit()
|
|
{
|
|
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);
|
|
|
|
var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None);
|
|
|
|
var expected = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim();
|
|
Assert.Equal(expected, info.MergeCommit);
|
|
}
|
|
|
|
// ── GetTaskDiff ────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task GetTaskDiff_NoWorktree_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.GetTaskDiff(task.Id, false, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskDiff_AfterCommit_ListsChangedFile()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, wt) = await SeedWorktreeAsync();
|
|
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 sut = BuildSut(CreateQueue());
|
|
var diff = await sut.GetTaskDiff(task.Id, false, CancellationToken.None);
|
|
|
|
Assert.Contains("added.txt", diff.Files);
|
|
Assert.False(diff.Truncated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskDiff_WorktreeLessHandlerTask_UsesHandlerCommitRangeOverListWorkingDir()
|
|
{
|
|
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);
|
|
// No WorktreeEntity for this task -- it's a worktree-less list-handler host task
|
|
// (Mission Control's "Let Claude handle it"): the handler merged its own commit
|
|
// straight into the list's working dir after HandlerBaseCommit was stamped.
|
|
|
|
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 diff = await sut.GetTaskDiff(task.Id, false, CancellationToken.None);
|
|
|
|
Assert.Contains("handled.txt", diff.Files);
|
|
Assert.False(diff.Truncated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskDiff_WorktreeLessHandlerTask_StatMode_UsesHandlerCommitRange()
|
|
{
|
|
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 diff = await sut.GetTaskDiff(task.Id, true, CancellationToken.None);
|
|
|
|
Assert.Contains("handled.txt", diff.Content);
|
|
}
|
|
|
|
// ── MergeTask ──────────────────────────────────────────────────────────────
|
|
|
|
[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);
|
|
}
|
|
|
|
// ── ListWorktrees ──────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task ListWorktrees_Empty_ReturnsEmpty()
|
|
{
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var rows = await sut.ListWorktrees(CancellationToken.None);
|
|
|
|
Assert.Empty(rows);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListWorktrees_ReturnsCreatedWorktree()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, _) = await SeedWorktreeAsync();
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var rows = await sut.ListWorktrees(CancellationToken.None);
|
|
|
|
Assert.Contains(rows, r => r.TaskId == task.Id);
|
|
}
|
|
|
|
// ── CleanupTaskWorktree ────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task CleanupTaskWorktree_NotFound_Throws()
|
|
{
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.CleanupTaskWorktree("does-not-exist", false, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CleanupTaskWorktree_Running_Throws()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Running);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None));
|
|
Assert.Contains("running", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CleanupTaskWorktree_CleanWorktree_Removes()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None);
|
|
|
|
Assert.True(result.Removed);
|
|
Assert.False(Directory.Exists(wt.WorktreePath));
|
|
}
|
|
|
|
// ── GetTaskConfig ─────────────────────────────────────────────────────────
|
|
|
|
private ConfigMcpTools BuildConfigSut() => new(_lists, _tasks, _broadcaster);
|
|
|
|
[Fact]
|
|
public async Task GetTaskConfig_NotFound_Throws()
|
|
{
|
|
var sut = BuildConfigSut();
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.GetTaskConfig("does-not-exist", CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskConfig_NoOverrides_ReturnsNull()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = BuildConfigSut();
|
|
|
|
var result = await sut.GetTaskConfig(task.Id, CancellationToken.None);
|
|
|
|
Assert.False(result.Found);
|
|
Assert.Null(result.Config);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskConfig_WithOverrides_ReturnsValues()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
await _tasks.UpdateAgentSettingsAsync(task.Id, "claude-sonnet-4-6", "be concise", null, 10, ct: CancellationToken.None);
|
|
var sut = BuildConfigSut();
|
|
|
|
var result = await sut.GetTaskConfig(task.Id, CancellationToken.None);
|
|
|
|
Assert.True(result.Found);
|
|
Assert.NotNull(result.Config);
|
|
Assert.Equal("claude-sonnet-4-6", result.Config!.Model);
|
|
Assert.Equal("be concise", result.Config.SystemPrompt);
|
|
Assert.Null(result.Config.AgentPath);
|
|
Assert.Equal(10, result.Config.MaxTurns);
|
|
}
|
|
|
|
// ── GetTaskStatusValues ───────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task GetTaskStatusValues_ContainsAllStatuses()
|
|
{
|
|
var sut = NewService();
|
|
var values = await sut.GetTaskStatusValues();
|
|
var names = values.Select(v => v.Status).ToHashSet();
|
|
|
|
foreach (var status in Enum.GetValues<TaskStatus>())
|
|
Assert.Contains(status.ToString(), names);
|
|
}
|
|
|
|
// ── ListTasks status filter ───────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task ListTasks_FilterByWaitingForReview_ReturnsMatchingTasks()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
await SeedTaskAsync(listId, "wfr", TaskStatus.WaitingForReview);
|
|
await SeedTaskAsync(listId, "idle", TaskStatus.Idle);
|
|
var sut = NewService();
|
|
|
|
var result = await sut.ListTasks(listId, null, "WaitingForReview", CancellationToken.None);
|
|
|
|
Assert.Single(result);
|
|
Assert.Equal("WaitingForReview", result[0].Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListTasks_FilterByWaitingForChildren_ReturnsMatchingTasks()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
await SeedTaskAsync(listId, "wfc", TaskStatus.WaitingForChildren);
|
|
await SeedTaskAsync(listId, "done", TaskStatus.Done);
|
|
var sut = NewService();
|
|
|
|
var result = await sut.ListTasks(listId, null, "WaitingForChildren", CancellationToken.None);
|
|
|
|
Assert.Single(result);
|
|
Assert.Equal("WaitingForChildren", result[0].Status);
|
|
}
|
|
|
|
// ── MergeTask allowWaitingForReview ───────────────────────────────────────
|
|
|
|
[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);
|
|
}
|
|
|
|
// ── leaveConflictsInTree ──────────────────────────────────────────────────
|
|
|
|
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);
|
|
}
|
|
|
|
[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.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.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 ReviewTask_Approve_LeaveConflictsInTree_LeavesMarkersAndReportsRepoPath()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.ReviewTask(task.Id, "approve", null, null,
|
|
leaveConflictsInTree: true, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusConflict, result.MergeStatus);
|
|
Assert.Equal(list.WorkingDir, result.RepoPath);
|
|
Assert.Contains("continue_merge", result.MergeMessage);
|
|
Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
|
|
Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
|
Assert.Equal("WaitingForReview", result.Task.Status);
|
|
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReviewTask_Approve_ConflictWithoutLeaveFlag_KeepsOldBehaviour()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusConflict, result.MergeStatus);
|
|
Assert.Null(result.RepoPath);
|
|
Assert.Contains("ClaudeDo UI", result.MergeMessage);
|
|
Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
|
|
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
|
Assert.Equal("WaitingForReview", result.Task.Status);
|
|
}
|
|
|
|
// ── continue_merge / abort_merge ──────────────────────────────────────────
|
|
|
|
[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 sut = BuildSut(CreateQueue());
|
|
await sut.ReviewTask(task.Id, "approve", null, null,
|
|
leaveConflictsInTree: true, 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 result = await sut.ContinueMerge(task.Id, 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_MarkersStillPresent_ReportsConflicts()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
await sut.ReviewTask(task.Id, "approve", null, null,
|
|
leaveConflictsInTree: true, CancellationToken.None);
|
|
|
|
// Markers never resolved — continue must refuse and list the files.
|
|
var result = await sut.ContinueMerge(task.Id, 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 sut = BuildSut(CreateQueue());
|
|
await sut.ReviewTask(task.Id, "approve", null, null,
|
|
leaveConflictsInTree: true, CancellationToken.None);
|
|
|
|
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.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);
|
|
}
|
|
|
|
// ── RevertMerge ────────────────────────────────────────────────────────────
|
|
|
|
[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 });
|
|
ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
|
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1 });
|
|
|
|
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 sut = BuildSut(CreateQueue());
|
|
var review = await sut.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.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);
|
|
}
|
|
|
|
// ── PreviewMerge / PreviewMergeSet ────────────────────────────────────────
|
|
|
|
private async Task<string> CreateActiveWorktreeAsync(
|
|
GitRepoFixture repo, string taskId, string fileName, string content)
|
|
{
|
|
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, repo.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 = repo.BaseCommit,
|
|
HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
|
|
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
return wtPath;
|
|
}
|
|
|
|
[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.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
|
Assert.Empty(result.ConflictFiles);
|
|
Assert.Equal(1, result.ChangedFileCount);
|
|
Assert.Equal(0, result.Behind);
|
|
Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None));
|
|
Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
|
|
}
|
|
|
|
[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.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.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.None));
|
|
Assert.Contains("working directory", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[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.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));
|
|
}
|
|
|
|
[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.None);
|
|
|
|
Assert.Single(result.Tasks);
|
|
Assert.Empty(result.Overlaps);
|
|
}
|
|
|
|
[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.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);
|
|
}
|
|
|
|
// ── AddTask model override ────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task AddTask_NoModel_LeavesModelNull()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var sut = NewService();
|
|
|
|
var dto = await sut.AddTask(listId, "t", cancellationToken: CancellationToken.None);
|
|
|
|
var loaded = await _tasks.GetByIdAsync(dto.Id);
|
|
Assert.Null(loaded!.Model);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTask_PersistsNormalizedModel()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var sut = NewService();
|
|
|
|
var dto = await sut.AddTask(listId, "t", model: "HAIKU", cancellationToken: CancellationToken.None);
|
|
|
|
var loaded = await _tasks.GetByIdAsync(dto.Id);
|
|
Assert.Equal("haiku", loaded!.Model);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTask_RejectsUnknownModel()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var sut = NewService();
|
|
|
|
await Assert.ThrowsAsync<ArgumentException>(
|
|
() => sut.AddTask(listId, "t", model: "gpt4", cancellationToken: CancellationToken.None));
|
|
}
|
|
|
|
// ── ContinueTask validation ───────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task ContinueTask_EmptyPrompt_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = NewService();
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.ContinueTask(task.Id, " ", CancellationToken.None));
|
|
}
|
|
}
|