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:
@@ -89,7 +89,9 @@ public sealed class BatchMcpToolsTests : IDisposable
|
||||
state,
|
||||
git, factory, merge, planningMerge,
|
||||
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()
|
||||
|
||||
+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]
|
||||
public async Task MergeTask_NotDone_Throws()
|
||||
{
|
||||
@@ -121,14 +395,15 @@
|
||||
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,
|
||||
var reviewSut = BuildReviewSut(CreateQueue());
|
||||
await reviewSut.ReviewTask(task.Id, "approve", null, null,
|
||||
leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Resolve the conflict on disk and stage it.
|
||||
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n");
|
||||
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.True(result.Merged);
|
||||
@@ -147,7 +422,7 @@
|
||||
try
|
||||
{
|
||||
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||
var plainSut = BuildSut(CreateQueue());
|
||||
var plainSut = BuildReviewSut(CreateQueue());
|
||||
await plainSut.ReviewTask(task.Id, "approve", null, null,
|
||||
leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
@@ -186,11 +461,12 @@
|
||||
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,
|
||||
var reviewSut = BuildReviewSut(CreateQueue());
|
||||
await reviewSut.ReviewTask(task.Id, "approve", null, null,
|
||||
leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Markers never resolved — continue must refuse and list the files.
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.False(result.Merged);
|
||||
@@ -208,10 +484,11 @@
|
||||
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,
|
||||
var reviewSut = BuildReviewSut(CreateQueue());
|
||||
await reviewSut.ReviewTask(task.Id, "approve", null, null,
|
||||
leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var dto = await sut.AbortMerge(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal("WaitingForReview", dto.Status);
|
||||
@@ -251,9 +528,10 @@
|
||||
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: CancellationToken.None);
|
||||
var reviewSut = BuildReviewSut(CreateQueue());
|
||||
await reviewSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.RemainingHunks);
|
||||
@@ -356,9 +634,10 @@
|
||||
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: CancellationToken.None);
|
||||
var reviewSut = BuildReviewSut(CreateQueue());
|
||||
await reviewSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sut.ResolveConflictHunk(task.Id, "not-a-real-file.md", 0, "ours", CancellationToken.None));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
@@ -479,8 +758,8 @@
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var review = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None);
|
||||
var (reviewSut, sut) = BuildSharedSuts(CreateQueue());
|
||||
var review = await reviewSut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None);
|
||||
Assert.Equal(TaskMergeService.StatusConflict, review.MergeStatus);
|
||||
Assert.Equal(repo.RepoDir, review.RepoPath);
|
||||
Assert.Contains("continue_merge", review.MergeMessage);
|
||||
@@ -994,3 +1273,4 @@
|
||||
Assert.Equal(0, result.Tasks.Single().VerifyExitCode);
|
||||
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]
|
||||
public async Task GetTaskWorktree_NoWorktree_Throws()
|
||||
{
|
||||
@@ -49,9 +208,10 @@
|
||||
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 mergeSut = BuildMergeSut();
|
||||
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 expected = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim();
|
||||
@@ -197,8 +357,8 @@
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var original = ExternalMcpService.ProgressReportInterval;
|
||||
ExternalMcpService.ProgressReportInterval = TimeSpan.Zero;
|
||||
var original = WorktreeMcpTools.ProgressReportInterval;
|
||||
WorktreeMcpTools.ProgressReportInterval = TimeSpan.Zero;
|
||||
try
|
||||
{
|
||||
var (task, list, wt) = await SeedWorktreeAsync();
|
||||
@@ -218,7 +378,7 @@
|
||||
}
|
||||
finally
|
||||
{
|
||||
ExternalMcpService.ProgressReportInterval = original;
|
||||
WorktreeMcpTools.ProgressReportInterval = original;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,8 +410,8 @@
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var original = ExternalMcpService.ProgressReportInterval;
|
||||
ExternalMcpService.ProgressReportInterval = TimeSpan.Zero;
|
||||
var original = WorktreeMcpTools.ProgressReportInterval;
|
||||
WorktreeMcpTools.ProgressReportInterval = TimeSpan.Zero;
|
||||
try
|
||||
{
|
||||
await SeedWorktreeAsync();
|
||||
@@ -265,7 +425,7 @@
|
||||
}
|
||||
finally
|
||||
{
|
||||
ExternalMcpService.ProgressReportInterval = original;
|
||||
WorktreeMcpTools.ProgressReportInterval = original;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,8 +470,8 @@
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var original = ExternalMcpService.ProgressReportInterval;
|
||||
ExternalMcpService.ProgressReportInterval = TimeSpan.Zero;
|
||||
var original = WorktreeMcpTools.ProgressReportInterval;
|
||||
WorktreeMcpTools.ProgressReportInterval = TimeSpan.Zero;
|
||||
try
|
||||
{
|
||||
var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done);
|
||||
@@ -326,6 +486,7 @@
|
||||
}
|
||||
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