`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
332 lines
13 KiB
Plaintext
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;
|
|
}
|
|
}
|