Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/External/_worktree_bodies.txt
T
Mika Kuns 7a0b97df59 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
2026-09-02 13:02:10 +02:00

332 lines
13 KiB
Plaintext

[Fact]
public async Task GetTaskWorktree_NoWorktree_Throws()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
var sut = BuildSut(CreateQueue());
await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.GetTaskWorktree(task.Id, CancellationToken.None));
}
[Fact]
public async Task GetTaskWorktree_ReturnsBranchAndBaseCommit()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, _, wt) = await SeedWorktreeAsync();
var sut = BuildSut(CreateQueue());
var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None);
Assert.Equal(wt.BranchName, info.Branch);
Assert.Equal(wt.BaseCommit, info.BaseCommit);
Assert.Equal(0, info.Ahead);
Assert.False(info.IsDirty);
}
[Fact]
public async Task GetTaskWorktree_BeforeMerge_MergeCommitIsNull()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, _, _) = await SeedWorktreeAsync();
var sut = BuildSut(CreateQueue());
var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None);
Assert.Null(info.MergeCommit);
}
[Fact]
public async Task GetTaskWorktree_AfterMerge_ExposesMergeCommit()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done);
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "new\n");
GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A");
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file");
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
var sut = BuildSut(CreateQueue());
await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None);
var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None);
var expected = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim();
Assert.Equal(expected, info.MergeCommit);
}
[Fact]
public async Task GetTaskDiff_NoWorktree_Throws()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
var sut = BuildSut(CreateQueue());
await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.GetTaskDiff(task.Id, false, null, CancellationToken.None));
}
[Fact]
public async Task GetTaskDiff_AfterCommit_ListsChangedFile()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, wt) = await SeedWorktreeAsync();
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var diff = await sut.GetTaskDiff(task.Id, false, null, CancellationToken.None);
Assert.Contains("added.txt", diff.Files);
Assert.False(diff.Truncated);
}
[Fact]
public async Task GetTaskDiff_WithPathsFilter_ReturnsOnlyRequestedFile()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, wt) = await SeedWorktreeAsync();
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
File.WriteAllText(Path.Combine(wt.WorktreePath, "other.txt"), "other content");
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var diff = await sut.GetTaskDiff(task.Id, false, ["added.txt"], CancellationToken.None);
Assert.Equal(new[] { "added.txt" }, diff.Files);
Assert.DoesNotContain("other.txt", diff.Content);
}
[Fact]
public async Task GetTaskDiff_StatMode_WithPathsFilter_ReturnsOnlyRequestedFile()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, wt) = await SeedWorktreeAsync();
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
File.WriteAllText(Path.Combine(wt.WorktreePath, "other.txt"), "other content");
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var diff = await sut.GetTaskDiff(task.Id, true, ["other.txt"], CancellationToken.None);
Assert.Equal(new[] { "other.txt" }, diff.Files);
Assert.DoesNotContain("added.txt", diff.Content);
}
[Fact]
public async Task GetTaskDiff_WorktreeLessHandlerTask_UsesHandlerCommitRangeOverListWorkingDir()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
// No WorktreeEntity for this task -- it's a worktree-less list-handler host task
// (Mission Control's "Let Claude handle it"): the handler merged its own commit
// straight into the list's working dir after HandlerBaseCommit was stamped.
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
using (var ctx = _db.CreateContext())
{
var t = await ctx.Tasks.FindAsync(task.Id);
t!.HandlerBaseCommit = repo.BaseCommit;
t.HandlerHeadCommit = headCommit;
await ctx.SaveChangesAsync();
}
var sut = BuildSut(CreateQueue());
var diff = await sut.GetTaskDiff(task.Id, false, null, CancellationToken.None);
Assert.Contains("handled.txt", diff.Files);
Assert.False(diff.Truncated);
}
[Fact]
public async Task GetTaskDiff_WorktreeLessHandlerTask_StatMode_UsesHandlerCommitRange()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
using (var ctx = _db.CreateContext())
{
var t = await ctx.Tasks.FindAsync(task.Id);
t!.HandlerBaseCommit = repo.BaseCommit;
t.HandlerHeadCommit = headCommit;
await ctx.SaveChangesAsync();
}
var sut = BuildSut(CreateQueue());
var diff = await sut.GetTaskDiff(task.Id, true, null, CancellationToken.None);
Assert.Contains("handled.txt", diff.Content);
}
[Fact]
public async Task GetTaskDiff_ReportsProgressWhileDiffing()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var original = ExternalMcpService.ProgressReportInterval;
ExternalMcpService.ProgressReportInterval = TimeSpan.Zero;
try
{
var (task, list, wt) = await SeedWorktreeAsync();
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var progress = new SyncProgress<ProgressNotificationValue>();
var diff = await sut.GetTaskDiff(task.Id, false, null, CancellationToken.None, progress);
Assert.Contains("added.txt", diff.Files);
Assert.NotEmpty(progress.Reports);
Assert.All(progress.Reports, r => Assert.Contains("computing diff", r.Message));
}
finally
{
ExternalMcpService.ProgressReportInterval = original;
}
}
[Fact]
public async Task ListWorktrees_Empty_ReturnsEmpty()
{
var sut = BuildSut(CreateQueue());
var rows = await sut.ListWorktrees(CancellationToken.None);
Assert.Empty(rows);
}
[Fact]
public async Task ListWorktrees_ReturnsCreatedWorktree()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, _, _) = await SeedWorktreeAsync();
var sut = BuildSut(CreateQueue());
var rows = await sut.ListWorktrees(CancellationToken.None);
Assert.Contains(rows, r => r.TaskId == task.Id);
}
[Fact]
public async Task ListWorktrees_ReportsProgressWhileSurveying()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var original = ExternalMcpService.ProgressReportInterval;
ExternalMcpService.ProgressReportInterval = TimeSpan.Zero;
try
{
await SeedWorktreeAsync();
var sut = BuildSut(CreateQueue());
var progress = new SyncProgress<ProgressNotificationValue>();
var rows = await sut.ListWorktrees(CancellationToken.None, progress);
Assert.NotEmpty(rows);
Assert.NotEmpty(progress.Reports);
}
finally
{
ExternalMcpService.ProgressReportInterval = original;
}
}
[Fact]
public async Task CleanupTaskWorktree_NotFound_Throws()
{
var sut = BuildSut(CreateQueue());
await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.CleanupTaskWorktree("does-not-exist", false, CancellationToken.None));
}
[Fact]
public async Task CleanupTaskWorktree_Running_Throws()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Running);
var sut = BuildSut(CreateQueue());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None));
Assert.Contains("running", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task CleanupTaskWorktree_CleanWorktree_Removes()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done);
var sut = BuildSut(CreateQueue());
var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None);
Assert.True(result.Removed);
Assert.False(Directory.Exists(wt.WorktreePath));
}
[Fact]
public async Task CleanupTaskWorktree_ReportsProgressWhileRemoving()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var original = ExternalMcpService.ProgressReportInterval;
ExternalMcpService.ProgressReportInterval = TimeSpan.Zero;
try
{
var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done);
var sut = BuildSut(CreateQueue());
var progress = new SyncProgress<ProgressNotificationValue>();
var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None, progress);
Assert.True(result.Removed);
Assert.False(Directory.Exists(wt.WorktreePath));
Assert.NotEmpty(progress.Reports);
}
finally
{
ExternalMcpService.ProgressReportInterval = original;
}
}