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:
+7
-2
@@ -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; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+293
-13
@@ -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);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
+172
-11
@@ -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
@@ -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()))
|
|
||||||
-1544
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user