Files
ClaudeDo/src/ClaudeDo.Worker/External/WorktreeMcpTools.cs
T
Mika Kuns 7bcca3647e 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:28:33 +02:00

280 lines
14 KiB
C#

using System.ComponentModel;
using System.Diagnostics;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Worktrees;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol;
using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.External;
public sealed record WorktreeInfoDto(
string Path, string Branch, string HeadCommit, string BaseCommit,
int Ahead, int Behind, bool IsDirty, string? MergeCommit = null);
public sealed record TaskDiffDto(
string Content, IReadOnlyList<string> Files, bool Truncated, int TotalBytes);
public sealed record WorktreeListItemDto(
string? TaskId, int? Number, string Path, string Branch,
string HeadCommit, bool IsDirty, bool MergedIntoMain);
public sealed record CleanupWorktreeResult(
bool Removed, string WorktreePath, bool BranchDeleted, int? Number = null);
[McpServerToolType]
public sealed class WorktreeMcpTools
{
// Mirrors TaskMergeService.ProgressReportInterval / TaskWaitMcpTools.ProgressReportInterval:
// a separate field (not shared) so shrinking one for a test can't race another's tests. Used
// by the single-element long-runners here (GetTaskDiff, CleanupTaskWorktree) that wrap a bare
// GitService/WorktreeMaintenanceService call with no natural i/n to report instead.
internal static TimeSpan ProgressReportInterval = TimeSpan.FromSeconds(30);
private readonly TaskRepository _tasks;
private readonly GitService _git;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeMaintenanceService _maintenance;
public WorktreeMcpTools(
TaskRepository tasks,
GitService git,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService maintenance)
{
_tasks = tasks;
_git = git;
_dbFactory = dbFactory;
_maintenance = maintenance;
}
[McpServerTool, Description(
"Get a task's git worktree state — path, branch, base/head commit, ahead/behind counts, isDirty, and the " +
"mergeCommit its branch produced once merged. behind is 0 when the 'main' ref is unreachable, so do not " +
"read 0 as \"up to date\" without checking. A null mergeCommit means revert_merge cannot act on this task. " +
"Throws if the task or its worktree does not exist.")]
public async Task<WorktreeInfoDto> GetTaskWorktree(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken);
var headCommit = !string.IsNullOrWhiteSpace(wt.HeadCommit)
? wt.HeadCommit
: await TryRunGitAsync(wt.Path, ["rev-parse", "HEAD"], cancellationToken) ?? wt.BaseCommit;
var isDirty = Directory.Exists(wt.Path) && await _git.HasChangesAsync(wt.Path, cancellationToken);
var ahead = await GitRevListCountAsync(wt.Path, $"{wt.BaseCommit}..HEAD", cancellationToken);
var behind = await GitRevListCountAsync(wt.Path, "HEAD..main", cancellationToken);
return new WorktreeInfoDto(wt.Path, wt.BranchName, headCommit!, wt.BaseCommit, ahead, behind, isDirty, wt.MergeCommit);
}
[McpServerTool, Description(
"Read what a task actually changed — the diff of its worktree against its base commit (for a worktree-less " +
"list-handler host task, the fixed HandlerBaseCommit..HandlerHeadCommit range over the list's working dir " +
"instead). files lists the changed paths in either mode; truncated=true means the diff was capped and " +
"totalBytes holds its real size. Throws if the task has no worktree/review range, or the relevant " +
"directory is missing from disk.")]
public async Task<TaskDiffDto> GetTaskDiff(
string taskId,
[Description("false (default): the full unified diff, capped at 200 KB. true: a --stat summary with " +
"per-file insertion/deletion counts — start here when the diff may be large.")]
bool stat = false,
[Description("Restrict the diff to these paths (relative to the repo root), e.g. after a --stat pass or a " +
"conflict report already narrowed down which files matter. Omit/empty for every changed file " +
"— the default and the only prior behavior. Works in both stat and full-diff mode.")]
IReadOnlyList<string>? paths = null,
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken);
const int maxBytes = 200 * 1024;
if (stat)
{
var diffStat = await ProgressReporter.RunAsync(
_git.DiffStatAsync(repoPath, baseCommit, headCommit ?? "HEAD", paths, cancellationToken),
ProgressReportInterval, progress, "computing diff stat");
return new TaskDiffDto(diffStat, ExternalMcpService.ParseDiffStatFileNames(diffStat), false, diffStat.Length);
}
var diff = headCommit is null
? await ProgressReporter.RunAsync(
_git.GetBranchDiffAsync(repoPath, baseCommit, paths, cancellationToken), ProgressReportInterval, progress, "computing diff")
: await ProgressReporter.RunAsync(
_git.GetCommitRangeDiffAsync(repoPath, baseCommit, headCommit, paths, cancellationToken), ProgressReportInterval, progress, "computing diff");
var files = ParseDiffFileNames(diff);
if (diff.Length <= maxBytes)
return new TaskDiffDto(diff, files, false, diff.Length);
return new TaskDiffDto(diff[..maxBytes], files, true, diff.Length);
}
// Resolves where a task's diff lives: a live worktree (repo path = worktree path, diffed
// against HEAD) or, for a worktree-less list-handler host task, the fixed
// HandlerBaseCommit..HandlerHeadCommit range over the list's working dir (headCommit
// non-null signals "fixed range" to the caller instead of "diff against live HEAD").
private async Task<(string RepoPath, string BaseCommit, string? HeadCommit)> LoadDiffRangeAsync(
string taskId, CancellationToken ct)
{
using var ctx = _dbFactory.CreateDbContext();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
if (wt is not null)
{
if (!Directory.Exists(wt.Path))
throw new InvalidOperationException($"Worktree directory does not exist on disk: {wt.Path}");
return (wt.Path, wt.BaseCommit, null);
}
if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead)
{
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
if (string.IsNullOrEmpty(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
throw new InvalidOperationException("The list's working directory no longer exists.");
return (list.WorkingDir, handlerBase, handlerHead);
}
throw new InvalidOperationException($"Task {taskId} has no worktree.");
}
[McpServerTool, Description(
"Survey every worktree ClaudeDo tracks — use it to find leftovers to clean up. Only worktrees recorded in " +
"the ClaudeDo database appear here, and headCommit is empty when the path is missing from disk." +
McpToolDocs.ProgressHint)]
public async Task<IReadOnlyList<WorktreeListItemDto>> ListWorktrees(
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
var rows = await _maintenance.GetOverviewAsync(null, cancellationToken);
// One git status + one rev-parse per row, run concurrently -- with many tracked worktrees
// (this tool's whole point is surfacing leftovers nobody cleaned up) that can still take a
// while, so this reports on the same elapsed-time cadence as a single long git call rather
// than per-row (rows finish out of order under Task.WhenAll, so there's no natural i/n).
var results = await ProgressReporter.RunAsync(
Task.WhenAll(rows.Select(async row =>
{
var isDirty = row.PathExistsOnDisk && await TryGetIsDirtyAsync(row.Path, cancellationToken);
var headCommit = row.PathExistsOnDisk
? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "")
: "";
return new WorktreeListItemDto(
row.TaskId, row.TaskNumber, row.Path, row.BranchName, headCommit,
isDirty, row.State == WorktreeState.Merged);
})),
ProgressReportInterval, progress, "surveying worktrees");
return results;
}
[McpServerTool, Description(
"Remove a task's worktree directory and delete its git branch. branchDeleted reports whether the branch " +
"went too." + McpToolDocs.NotWhileRunning)]
public async Task<CleanupWorktreeResult> CleanupTaskWorktree(
string taskId,
[Description("false (default): refuse a worktree with uncommitted changes. true: remove it anyway, losing " +
"those changes.")]
bool force = false,
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
using var ctx = _dbFactory.CreateDbContext();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot remove worktree of a running task.");
if (!force && Directory.Exists(wt.Path))
{
var isDirty = await ProgressReporter.RunAsync(
_git.HasChangesAsync(wt.Path, cancellationToken), ProgressReportInterval, progress, "checking worktree for changes");
if (isDirty)
throw new InvalidOperationException(
"Worktree has uncommitted changes. Use force=true to remove anyway (changes will be lost).");
}
var path = wt.Path;
var result = await ProgressReporter.RunAsync(
_maintenance.ForceRemoveAsync(taskId, cancellationToken), ProgressReportInterval, progress, "removing worktree");
return new CleanupWorktreeResult(result.Removed, path, result.BranchDeleted, task.Number);
}
private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity Wt)> LoadWorktreeContextAsync(
string taskId, CancellationToken ct)
{
using var ctx = _dbFactory.CreateDbContext();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
return (task, list, wt);
}
private async Task<bool> TryGetIsDirtyAsync(string path, CancellationToken ct)
{
try { return await _git.HasChangesAsync(path, ct); }
catch { return false; }
}
// Minimal git runner for operations not covered by GitService (rev-list --count, rev-parse from worktree).
private static async Task<string?> TryRunGitAsync(string dir, string[] args, CancellationToken ct)
{
try
{
var psi = new ProcessStartInfo("git")
{
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true,
};
psi.ArgumentList.Add("-C");
psi.ArgumentList.Add(dir);
foreach (var a in args) psi.ArgumentList.Add(a);
using var proc = Process.Start(psi)!;
await using var _ = ct.Register(() => { try { proc.Kill(entireProcessTree: true); } catch { } });
var stdout = await proc.StandardOutput.ReadToEndAsync();
await proc.WaitForExitAsync(CancellationToken.None);
ct.ThrowIfCancellationRequested();
return proc.ExitCode == 0 ? stdout.Trim() : null;
}
catch (OperationCanceledException) { throw; }
catch { return null; }
}
internal static async Task<int> GitRevListCountAsync(string dir, string range, CancellationToken ct)
{
var result = await TryRunGitAsync(dir, ["rev-list", "--count", range], ct);
return int.TryParse(result, out var n) ? n : 0;
}
private static IReadOnlyList<string> ParseDiffFileNames(string diff)
{
var files = new List<string>();
foreach (var line in diff.Split('\n'))
{
var s = line.TrimEnd('\r');
if (s.StartsWith("+++ b/", StringComparison.Ordinal))
files.Add(s[6..]);
}
return files;
}
}