refactor(claude-do): refactor(worker): move the merge and worktree MCP tools out

`src/ClaudeDo.Worker/External/ExternalMcpService.cs` ist **1897 Zeilen** (nachgezählt 2026-09-02; die Datei ist seit dem Anlegen dieses Tasks von 1696 weiter gewachsen): ~30 DTO-Records oben plus ~25 Tool-Methoden — während **13** Geschwisterdateien `*McpTools.cs` im selben Ordner das Topic-Split-Muster schon tragen (`ListMcpTools`, `ConfigMcpTools`, `BatchMcpTools`, `NotesMcpTools`, `QueueStateMc

ClaudeDo-Task: 1862e160-ffde-4a50-aa03-bb7251f56088
This commit is contained in:
Mika Kuns
2026-09-02 13:28:33 +02:00
parent 7a0b97df59
commit 7bcca3647e
9 changed files with 479 additions and 1761 deletions
+7 -2
View File
@@ -85,8 +85,13 @@ public sealed class BatchMcpTools
}; };
private readonly ExternalMcpService _svc; private readonly ExternalMcpService _svc;
private readonly WorktreeMcpTools _worktree;
public BatchMcpTools(ExternalMcpService svc) => _svc = svc; public BatchMcpTools(ExternalMcpService svc, WorktreeMcpTools worktree)
{
_svc = svc;
_worktree = worktree;
}
[McpServerTool, Description( [McpServerTool, Description(
"Fetch a snapshot of many tasks in one call — use for an overview or polling a fan-out instead of " + "Fetch a snapshot of many tasks in one call — use for an overview or polling a fan-out instead of " +
@@ -365,7 +370,7 @@ public sealed class BatchMcpTools
var id = taskIds[i]; var id = taskIds[i];
try try
{ {
var r = await _svc.CleanupTaskWorktree(id, force, cancellationToken); var r = await _worktree.CleanupTaskWorktree(id, force, cancellationToken);
results.Add(new BatchCleanupResult(id, true, r.Removed, r.BranchDeleted, null, r.Number)); results.Add(new BatchCleanupResult(id, true, r.Removed, r.BranchDeleted, null, r.Number));
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
+1
View File
@@ -9,6 +9,7 @@ using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Planning;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol; using ModelContextProtocol;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+3
View File
@@ -1,10 +1,13 @@
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Git; using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Worktrees; using ClaudeDo.Worker.Worktrees;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol; using ModelContextProtocol;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+3 -1
View File
@@ -89,7 +89,9 @@ public sealed class BatchMcpToolsTests : IDisposable
state, state,
git, factory, merge, planningMerge, git, factory, merge, planningMerge,
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance)); new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
return new BatchMcpTools(svc); var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
var worktree = new WorktreeMcpTools(_tasks, git, factory, maintenance);
return new BatchMcpTools(svc, worktree);
} }
private QueueService CreateQueue() private QueueService CreateQueue()
@@ -1,3 +1,277 @@
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)
{
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;
}
// 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] [Fact]
public async Task MergeTask_NotDone_Throws() public async Task MergeTask_NotDone_Throws()
{ {
@@ -121,14 +395,15 @@
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue()); var reviewSut = BuildReviewSut(CreateQueue());
await sut.ReviewTask(task.Id, "approve", null, null, await reviewSut.ReviewTask(task.Id, "approve", null, null,
leaveConflictsInTree: true, cancellationToken: CancellationToken.None); leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
// Resolve the conflict on disk and stage it. // Resolve the conflict on disk and stage it.
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n"); File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n");
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md"); GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
var sut = BuildSut(CreateQueue());
var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None); var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);
Assert.True(result.Merged); Assert.True(result.Merged);
@@ -147,7 +422,7 @@
try try
{ {
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var plainSut = BuildSut(CreateQueue()); var plainSut = BuildReviewSut(CreateQueue());
await plainSut.ReviewTask(task.Id, "approve", null, null, await plainSut.ReviewTask(task.Id, "approve", null, null,
leaveConflictsInTree: true, cancellationToken: CancellationToken.None); leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
@@ -186,11 +461,12 @@
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue()); var reviewSut = BuildReviewSut(CreateQueue());
await sut.ReviewTask(task.Id, "approve", null, null, await reviewSut.ReviewTask(task.Id, "approve", null, null,
leaveConflictsInTree: true, cancellationToken: CancellationToken.None); leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
// Markers never resolved — continue must refuse and list the files. // Markers never resolved — continue must refuse and list the files.
var sut = BuildSut(CreateQueue());
var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None); var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);
Assert.False(result.Merged); Assert.False(result.Merged);
@@ -208,10 +484,11 @@
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue()); var reviewSut = BuildReviewSut(CreateQueue());
await sut.ReviewTask(task.Id, "approve", null, null, await reviewSut.ReviewTask(task.Id, "approve", null, null,
leaveConflictsInTree: true, cancellationToken: CancellationToken.None); leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
var sut = BuildSut(CreateQueue());
var dto = await sut.AbortMerge(task.Id, CancellationToken.None); var dto = await sut.AbortMerge(task.Id, CancellationToken.None);
Assert.Equal("WaitingForReview", dto.Status); Assert.Equal("WaitingForReview", dto.Status);
@@ -251,9 +528,10 @@
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue()); var reviewSut = BuildReviewSut(CreateQueue());
await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); 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); var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
Assert.Equal(1, result.RemainingHunks); Assert.Equal(1, result.RemainingHunks);
@@ -356,9 +634,10 @@
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue()); var reviewSut = BuildReviewSut(CreateQueue());
await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); await reviewSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
var sut = BuildSut(CreateQueue());
await Assert.ThrowsAsync<InvalidOperationException>( await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.ResolveConflictHunk(task.Id, "not-a-real-file.md", 0, "ours", CancellationToken.None)); () => sut.ResolveConflictHunk(task.Id, "not-a-real-file.md", 0, "ours", CancellationToken.None));
await Assert.ThrowsAsync<InvalidOperationException>( await Assert.ThrowsAsync<InvalidOperationException>(
@@ -479,8 +758,8 @@
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
} }
var sut = BuildSut(CreateQueue()); var (reviewSut, sut) = BuildSharedSuts(CreateQueue());
var review = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None); var review = await reviewSut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, review.MergeStatus); Assert.Equal(TaskMergeService.StatusConflict, review.MergeStatus);
Assert.Equal(repo.RepoDir, review.RepoPath); Assert.Equal(repo.RepoDir, review.RepoPath);
Assert.Contains("continue_merge", review.MergeMessage); Assert.Contains("continue_merge", review.MergeMessage);
@@ -994,3 +1273,4 @@
Assert.Equal(0, result.Tasks.Single().VerifyExitCode); Assert.Equal(0, result.Tasks.Single().VerifyExitCode);
Assert.Equal("dotnet build", fakeVerify.CapturedCommand); Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
} }
}
@@ -1,3 +1,162 @@
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 WorktreeMcpToolsTests : 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 WorktreeMcpToolsTests()
{
_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;
}
// queue is accepted (and ignored) so every extracted test body can keep calling
// BuildSut(CreateQueue()) unchanged -- WorktreeMcpTools itself has no QueueService dependency.
private WorktreeMcpTools BuildSut(QueueService queue)
{
var git = new GitService();
var factory = _db.CreateFactory();
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
return new WorktreeMcpTools(_tasks, git, factory, maintenance);
}
// GetTaskWorktree_AfterMerge_ExposesMergeCommit needs a real merge first -- MergeTask lives on
// MergeMcpTools now, not here. A fresh instance is fine (TaskMergeService/GitService are
// git-disk- and DB-backed, no shared in-memory state to worry about for a plain merge).
private MergeMcpTools BuildMergeSut()
{
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);
return new MergeMcpTools(_tasks, _lists, _broadcaster, git, factory, merge, planningMerge);
}
private QueueService CreateQueue()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_worktree_{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] [Fact]
public async Task GetTaskWorktree_NoWorktree_Throws() public async Task GetTaskWorktree_NoWorktree_Throws()
{ {
@@ -49,9 +208,10 @@
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file"); GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file");
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
var sut = BuildSut(CreateQueue()); var mergeSut = BuildMergeSut();
await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None); await mergeSut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None);
var sut = BuildSut(CreateQueue());
var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None);
var expected = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim(); var expected = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim();
@@ -197,8 +357,8 @@
{ {
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var original = ExternalMcpService.ProgressReportInterval; var original = WorktreeMcpTools.ProgressReportInterval;
ExternalMcpService.ProgressReportInterval = TimeSpan.Zero; WorktreeMcpTools.ProgressReportInterval = TimeSpan.Zero;
try try
{ {
var (task, list, wt) = await SeedWorktreeAsync(); var (task, list, wt) = await SeedWorktreeAsync();
@@ -218,7 +378,7 @@
} }
finally finally
{ {
ExternalMcpService.ProgressReportInterval = original; WorktreeMcpTools.ProgressReportInterval = original;
} }
} }
@@ -250,8 +410,8 @@
{ {
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var original = ExternalMcpService.ProgressReportInterval; var original = WorktreeMcpTools.ProgressReportInterval;
ExternalMcpService.ProgressReportInterval = TimeSpan.Zero; WorktreeMcpTools.ProgressReportInterval = TimeSpan.Zero;
try try
{ {
await SeedWorktreeAsync(); await SeedWorktreeAsync();
@@ -265,7 +425,7 @@
} }
finally finally
{ {
ExternalMcpService.ProgressReportInterval = original; WorktreeMcpTools.ProgressReportInterval = original;
} }
} }
@@ -310,8 +470,8 @@
{ {
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var original = ExternalMcpService.ProgressReportInterval; var original = WorktreeMcpTools.ProgressReportInterval;
ExternalMcpService.ProgressReportInterval = TimeSpan.Zero; WorktreeMcpTools.ProgressReportInterval = TimeSpan.Zero;
try try
{ {
var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done); var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done);
@@ -326,6 +486,7 @@
} }
finally finally
{ {
ExternalMcpService.ProgressReportInterval = original; WorktreeMcpTools.ProgressReportInterval = original;
} }
} }
}
-136
View File
@@ -1,136 +0,0 @@
import re, sys, json
path = r"C:\Private\.claudedo-worktrees\claude-do\1862e160-ffde-4a50-aa03-bb7251f56088\tests\ClaudeDo.Worker.Tests\External\ExternalMcpServiceTests.cs"
with open(path, encoding='utf-8') as f:
lines = f.readlines()
# Find all [Fact] blocks: (start_idx, end_idx, name) where start_idx is the [Fact] line (0-based)
# and end_idx is the line with the closing brace (inclusive), 0-based.
blocks = {}
i = 0
n = len(lines)
while i < n:
if lines[i].strip() == '[Fact]':
fact_start = i
# next line should be method signature
sig_line = lines[i+1]
m = re.search(r'public async Task (\w+)\(', sig_line)
assert m, f"no method name at line {i+2}: {sig_line}"
name = m.group(1)
# find opening brace - it's on the line after signature (possibly signature spans multiple lines)
j = i + 1
# advance until we find a line that is just '{' (with indentation)
while lines[j].strip() != '{':
j += 1
brace_line = j
depth = 0
k = brace_line
while True:
depth += lines[k].count('{') - lines[k].count('}')
if depth == 0:
break
k += 1
end_idx = k
blocks[name] = (fact_start, end_idx)
i = end_idx + 1
else:
i += 1
print(f"Found {len(blocks)} [Fact] blocks", file=sys.stderr)
merge_names = """MergeTask_NotDone_Throws
MergeTask_DryRun_DoesNotMerge
MergeTask_WaitingForReview_WithoutFlag_Throws
MergeTask_WaitingForReview_WithFlag_DryRun_Succeeds
MergeTask_WaitingForReview_WithFlag_MarksTaskDone
MergeTask_LeaveConflictsInTree_LeavesMarkersAndKeepsRepoMidMerge
MergeTask_ConflictWithoutLeaveFlag_AbortsCleanly
ContinueMerge_AfterResolvingMarkers_MergesAndSetsDone
ContinueMerge_SlowVerifyCommand_ReportsProgressWellBeforeIdleTimeout
ContinueMerge_MarkersStillPresent_ReportsConflicts
AbortMerge_RestoresCleanTreeAndKeepsWaitingForReview
ContinueMerge_NoMergeInProgress_Throws
AbortMerge_NoMergeInProgress_Throws
GetMergeConflicts_ReportsHunksWithStartLineAndDiff3Base
GetMergeConflicts_NoMergeInProgress_ReturnsEmpty
ResolveConflictHunk_OursTheirsAndText_ResolveEachHunkAndTrackRemainingCount
ResolveConflictHunk_LeavesOtherHunksUntouchedUntilContinueMerge
ResolveConflictHunk_UnknownFileOrOutOfRangeIndex_Throws
RevertMerge_NotMerged_Throws
RevertMerge_AfterCleanMerge_RevertsAndReturnsTaskToWaitingForReview
RevertMerge_ConflictingRevert_ReturnsRevertedFalseWithConflictsAndAbortsCleanly
ContinueMerge_ParentUnitMergeConflict_RoutesToOrchestratorAndCompletes
PreviewMerge_CleanMerge_ReturnsCleanStatusAndDoesNotChangeRepo
PreviewMerge_EmptyWorktree_ReturnsIsEmptyTrue
PreviewMerge_WorktreeLessHandlerTask_EmptyRange_ReturnsIsEmptyTrue
PreviewMerge_WorktreeLessHandlerTask_NonEmptyRange_ReturnsIsEmptyFalse
PreviewMerge_ConflictingBranches_ReturnsConflictStatusAndDoesNotChangeRepo
PreviewMerge_NoWorktree_Throws
PreviewMerge_MissingWorkingDir_ThrowsClearError
PreviewMerge_VerifyCommandConfigured_RunsInScratchWorktreeAndDoesNotTouchTarget
PreviewMerge_TargetChangedSameFileSinceFork_ReportsStaleFiles
PreviewMerge_TargetBehindButUnrelatedFile_ReportsNoStaleFiles
PreviewMerge_WorktreeLessHandlerTask_ReportsNoStaleFiles
PreviewMergeSet_TwoTasksSharedFile_ReportsOverlap
PreviewMergeSet_TaskFilesAreProperSubsetOfAnother_ReportsSubset
PreviewMergeSet_SingleTaskId_ReturnsEmptyOverlap
PreviewMergeSet_ReportsProgressPerTask
PreviewMergeSet_OneTaskWithoutWorktree_ReportsErrorWithoutAbortingOthers
PreviewMergeSet_MixedEmptyAndNonEmpty_ReportsIsEmptyPerEntry
PreviewMergeSet_DefaultRunVerifyFalse_NeverInvokesRunnerEvenWithCommandConfigured
PreviewMergeSet_RunVerifyTrue_InvokesRunnerPerTask""".split()
worktree_names = """GetTaskWorktree_NoWorktree_Throws
GetTaskWorktree_ReturnsBranchAndBaseCommit
GetTaskWorktree_BeforeMerge_MergeCommitIsNull
GetTaskWorktree_AfterMerge_ExposesMergeCommit
GetTaskDiff_NoWorktree_Throws
GetTaskDiff_AfterCommit_ListsChangedFile
GetTaskDiff_WithPathsFilter_ReturnsOnlyRequestedFile
GetTaskDiff_StatMode_WithPathsFilter_ReturnsOnlyRequestedFile
GetTaskDiff_WorktreeLessHandlerTask_UsesHandlerCommitRangeOverListWorkingDir
GetTaskDiff_WorktreeLessHandlerTask_StatMode_UsesHandlerCommitRange
GetTaskDiff_ReportsProgressWhileDiffing
ListWorktrees_Empty_ReturnsEmpty
ListWorktrees_ReturnsCreatedWorktree
ListWorktrees_ReportsProgressWhileSurveying
CleanupTaskWorktree_NotFound_Throws
CleanupTaskWorktree_Running_Throws
CleanupTaskWorktree_CleanWorktree_Removes
CleanupTaskWorktree_ReportsProgressWhileRemoving""".split()
missing_merge = [n for n in merge_names if n not in blocks]
missing_worktree = [n for n in worktree_names if n not in blocks]
print("missing merge:", missing_merge, file=sys.stderr)
print("missing worktree:", missing_worktree, file=sys.stderr)
overlap = set(merge_names) & set(worktree_names)
print("overlap:", overlap, file=sys.stderr)
def extract(names):
out = []
for name in names:
s, e = blocks[name]
out.append(''.join(lines[s:e+1]))
return out
with open(r"C:\Private\.claudedo-worktrees\claude-do\1862e160-ffde-4a50-aa03-bb7251f56088\tests\ClaudeDo.Worker.Tests\External\_merge_bodies.txt", 'w', encoding='utf-8') as f:
f.write('\n'.join(extract(merge_names)))
with open(r"C:\Private\.claudedo-worktrees\claude-do\1862e160-ffde-4a50-aa03-bb7251f56088\tests\ClaudeDo.Worker.Tests\External\_worktree_bodies.txt", 'w', encoding='utf-8') as f:
f.write('\n'.join(extract(worktree_names)))
# Build set of all line ranges to remove (sorted), then write remainder file for inspection
all_names = merge_names + worktree_names
ranges = sorted([blocks[n] for n in all_names])
# merge overlapping/adjacent not needed; just mark lines
remove_lines = set()
for s, e in ranges:
for x in range(s, e+1):
remove_lines.add(x)
remainder = [lines[i] for i in range(n) if i not in remove_lines]
with open(r"C:\Private\.claudedo-worktrees\claude-do\1862e160-ffde-4a50-aa03-bb7251f56088\tests\ClaudeDo.Worker.Tests\External\_remainder.cs", 'w', encoding='utf-8') as f:
f.writelines(remainder)
print(f"Total lines removed: {len(remove_lines)}", file=sys.stderr)
print(f"Remainder line count: {len(remainder)}", file=sys.stderr)
@@ -1,54 +0,0 @@
import re
path = r"C:\Private\.claudedo-worktrees\claude-do\1862e160-ffde-4a50-aa03-bb7251f56088\tests\ClaudeDo.Worker.Tests\External\_remainder.cs"
with open(path, encoding='utf-8') as f:
lines = f.readlines()
def idx(marker, start=0):
for i in range(start, len(lines)):
if marker in lines[i]:
return i
raise AssertionError(f"marker not found: {marker!r}")
# Each deletion described as (start_marker, end_marker_exclusive) -- delete from the line
# containing start_marker up to (but not including) the line containing end_marker.
deletions = [
("// ── GetTaskWorktree ──", "// ── GetTaskConfig ──"),
("// ── MergeTask allowWaitingForReview ──", "// ── leaveConflictsInTree ──"),
("// Three edits spread far enough apart", "[Fact]"), # multi-hunk helpers, up to next [Fact]
("// ── continue_merge / abort_merge ──", "// ── PreviewMerge / PreviewMergeSet ──"),
("private async Task<string> CreateActiveWorktreeAsync(", "[Fact]"),
]
# Apply deletions from bottom to top so earlier indices stay valid.
spans = []
for start_marker, end_marker in deletions:
s = idx(start_marker)
e = idx(end_marker, s + 1)
spans.append((s, e))
spans.sort(reverse=True)
for s, e in spans:
del lines[s:e]
text = ''.join(lines)
# Drop the now-unused `maintenance` local + ctor arg in BuildSut(QueueService, IVerifyCommandRunner).
before = text
text = text.replace(
" var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);\n",
"", 1)
assert text != before, "maintenance local not removed"
before = text
text = text.replace(
" git, factory, maintenance, merge, planningMerge,\n",
" git, factory, merge, planningMerge,\n", 1)
assert text != before, "maintenance ctor arg not removed"
# Collapse any remaining runs of 2+ blank lines down to a single blank line (cosmetic only).
text = re.sub(r'\n{3,}', '\n\n', text)
with open(path, 'w', encoding='utf-8') as f:
f.write(text)
print("final line count:", len(text.splitlines()))
File diff suppressed because it is too large Load Diff