Merge branch 'claudedo/99732497092746d193b80b8296804374'

This commit is contained in:
mika kuns
2026-08-05 11:21:12 +02:00
4 changed files with 285 additions and 3 deletions
+109
View File
@@ -52,6 +52,17 @@ public sealed record MergeContinuationResultDto(
bool Merged, string TaskStatus, IReadOnlyList<string> Conflicts,
string? RepoPath, string? Message);
public sealed record MergePreviewToolDto(
string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind);
public sealed record MergePreviewSetEntryDto(
string TaskId, string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, string? Error);
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds);
public sealed record MergePreviewSetResultDto(
IReadOnlyList<MergePreviewSetEntryDto> Tasks, IReadOnlyList<FileOverlapDto> Overlaps);
public sealed record WorktreeListItemDto(
string? TaskId, string Path, string Branch,
string HeadCommit, bool IsDirty, bool MergedIntoMain);
@@ -729,6 +740,104 @@ public sealed class ExternalMcpService
return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
}
[McpServerTool, Description(
"Non-destructive merge preview for a task's worktree branch against targetBranch (default: the repo's " +
"current branch), via `git merge-tree --write-tree` — does NOT touch the working tree, index, or HEAD. " +
"status: 'clean' (mergeable; changedFileCount is the size of that merge) or 'conflict' (conflictFiles " +
"lists the paths git would stop on). behind = commits on targetBranch not yet on this task's branch, so " +
"you can spot a stale branch even when the preview itself is clean. " +
"IMPORTANT: a clean preview says nothing about whether the merged result compiles or passes tests — git " +
"can merge two changes cleanly (e.g. one file deletes a symbol another file still references) and still " +
"break the build. " +
"Throws a clear error if the task has no worktree, the worktree is not Active, or the list's working " +
"directory is missing from disk.")]
public async Task<MergePreviewToolDto> PreviewMerge(
string taskId,
string? targetBranch = null,
CancellationToken cancellationToken = default)
{
var (preview, behind, _) = await PreviewMergeCoreAsync(taskId, targetBranch, cancellationToken);
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind);
}
[McpServerTool, Description(
"Merge preview plus file-overlap check across several tasks at once, all previewed against the same " +
"targetBranch (default: the repo's current branch). For each taskId returns the same fields as " +
"preview_merge (status/conflictFiles/changedFileCount/behind; error is set instead if that task could not " +
"be previewed, and it is then excluded from the overlap computation). overlaps lists, for each file " +
"touched by MORE THAN ONE of the given tasks (via each task's own diff, not the merge preview itself), " +
"which tasks touch it — passing a single taskId always yields an empty overlaps list. " +
"IMPORTANT: file-name overlap is a HINT, not a guarantee of a real collision, and its absence is not a " +
"guarantee of safety — two tasks touching different files entirely (e.g. one deletes a symbol, another " +
"still references it elsewhere) can still collide, and this tool will not flag that case.")]
public async Task<MergePreviewSetResultDto> PreviewMergeSet(
IReadOnlyList<string> taskIds,
string? targetBranch = null,
CancellationToken cancellationToken = default)
{
if (taskIds is null || taskIds.Count == 0)
throw new InvalidOperationException("taskIds must contain at least one task id.");
var entries = new List<MergePreviewSetEntryDto>();
var filesByTask = new Dictionary<string, IReadOnlyList<string>>();
foreach (var taskId in taskIds)
{
try
{
var (preview, behind, changedFiles) = await PreviewMergeCoreAsync(taskId, targetBranch, cancellationToken);
entries.Add(new MergePreviewSetEntryDto(
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null));
filesByTask[taskId] = changedFiles;
}
catch (InvalidOperationException ex)
{
entries.Add(new MergePreviewSetEntryDto(
taskId, TaskMergeService.PreviewUnavailable, Array.Empty<string>(), 0, 0, ex.Message));
}
}
var overlaps = filesByTask
.SelectMany(kv => kv.Value.Select(f => (File: f, TaskId: kv.Key)))
.GroupBy(x => x.File, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Select(x => x.TaskId).Distinct().Count() > 1)
.Select(g => new FileOverlapDto(g.Key, g.Select(x => x.TaskId).Distinct().ToList()))
.OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase)
.ToList();
return new MergePreviewSetResultDto(entries, overlaps);
}
// Shared core for PreviewMerge/PreviewMergeSet: throws a clear InvalidOperationException instead of
// TaskMergeService.PreviewAsync's silent "unavailable" status, and adds `behind` + the task's own
// changed-file list (via diff-stat, not the merge-tree preview) for overlap detection.
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles)> PreviewMergeCoreAsync(
string taskId, string? targetBranch, CancellationToken ct)
{
var (_, list, wt) = await LoadWorktreeContextAsync(taskId, ct);
if (wt.State != WorktreeState.Active)
throw new InvalidOperationException(
$"Worktree state must be Active to preview a merge (current: {wt.State}).");
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
throw new InvalidOperationException("The list's working directory no longer exists.");
var preview = await _merge.PreviewAsync(taskId, targetBranch ?? "", ct);
if (preview.Status == TaskMergeService.PreviewUnavailable)
throw new InvalidOperationException(
"Merge preview unavailable for this task (worktree inactive or repo is not a git repository).");
var target = string.IsNullOrWhiteSpace(targetBranch)
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
: targetBranch;
var behind = await GitRevListCountAsync(list.WorkingDir, $"{wt.BranchName}..{target}", ct);
var changedFiles = Directory.Exists(wt.Path)
? ParseDiffStatFileNames(await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", ct))
: Array.Empty<string>();
return (preview, behind, changedFiles);
}
[McpServerTool, Description(
"List all ClaudeDo-tracked worktrees. " +
"Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " +