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:
+6
-865
@@ -102,78 +102,6 @@ public sealed record PossibleDuplicateDto(string TaskId, int Number, string Titl
|
|||||||
public sealed record AddTaskResult(TaskRefDto Task, IReadOnlyList<PossibleDuplicateDto> PossibleDuplicates);
|
public sealed record AddTaskResult(TaskRefDto Task, IReadOnlyList<PossibleDuplicateDto> PossibleDuplicates);
|
||||||
|
|
||||||
|
|
||||||
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 MergeTaskResultDto(
|
|
||||||
bool Merged, string? MergeCommit, IReadOnlyList<string> Conflicts,
|
|
||||||
bool ConflictsInTree = false, string? RepoPath = null);
|
|
||||||
|
|
||||||
public sealed record MergeContinuationResultDto(
|
|
||||||
bool Merged, string TaskStatus, IReadOnlyList<string> Conflicts,
|
|
||||||
string? RepoPath, string? Message);
|
|
||||||
|
|
||||||
public sealed record ConflictHunkDto(
|
|
||||||
int Index, int StartLine, string Ours, string? Base, string Theirs);
|
|
||||||
|
|
||||||
public sealed record ConflictFileHunksDto(
|
|
||||||
string Path, bool IsBinary, IReadOnlyList<ConflictHunkDto> Hunks);
|
|
||||||
|
|
||||||
public sealed record GetMergeConflictsResultDto(
|
|
||||||
IReadOnlyList<ConflictFileHunksDto> Files, int RemainingHunks, string ConflictStyleNote = McpToolDocs.Diff3Note);
|
|
||||||
|
|
||||||
public sealed record ResolveConflictHunkResultDto(
|
|
||||||
bool Resolved, int RemainingHunks, string ConflictStyleNote = McpToolDocs.Diff3Note);
|
|
||||||
|
|
||||||
// IsEmpty = the review range contributed nothing (worktree ahead-of-base is empty, or a
|
|
||||||
// worktree-less handler task's HandlerBaseCommit == HandlerHeadCommit) -- distinguishable from
|
|
||||||
// a merge that is merely small, so an empty branch can't be misread as "changedFileCount: 0
|
|
||||||
// means tiny" when it actually means "nothing to review".
|
|
||||||
// VerifyExitCode/VerifyDurationMs/VerifyOutputTail are null unless a verify run was attempted
|
|
||||||
// (see PreviewMerge/PreviewMergeSet descriptions) -- 0 exit means the merge-tree result built/
|
|
||||||
// tested clean; a non-zero or -1 (timeout/failed to start) exit means it doesn't, with the tail
|
|
||||||
// of its output in VerifyOutputTail.
|
|
||||||
public sealed record MergePreviewToolDto(
|
|
||||||
string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, bool IsEmpty = false,
|
|
||||||
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null,
|
|
||||||
// Files this branch touches that the target branch ALSO touched since the branch's fork
|
|
||||||
// point -- an honest staleness signal `behind` alone can't give (a branch can be far behind
|
|
||||||
// yet touch nothing the target changed, or close behind yet collide on the one file that
|
|
||||||
// matters). Empty for a worktree-less handler task, which commits straight onto the list's
|
|
||||||
// working dir and has no fork point to compare against.
|
|
||||||
IReadOnlyList<string>? StaleFiles = null);
|
|
||||||
|
|
||||||
public sealed record MergePreviewSetEntryDto(
|
|
||||||
string TaskId, string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, string? Error, bool IsEmpty = false,
|
|
||||||
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null,
|
|
||||||
IReadOnlyList<string>? StaleFiles = null, int? Number = null);
|
|
||||||
|
|
||||||
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds, IReadOnlyList<int> Numbers);
|
|
||||||
|
|
||||||
// TaskId's changed-file list is a non-empty PROPER subset of SupersetTaskId's -- e.g. one task
|
|
||||||
// deletes two files and another deletes just one of those same two. Stronger than an Overlaps
|
|
||||||
// entry (which only says some files are shared): a real subset is the best available machine
|
|
||||||
// signal that TaskId's work may already be entirely covered by SupersetTaskId.
|
|
||||||
public sealed record SubsetRelationDto(string TaskId, int Number, string SupersetTaskId, int SupersetNumber);
|
|
||||||
|
|
||||||
public sealed record MergePreviewSetResultDto(
|
|
||||||
IReadOnlyList<MergePreviewSetEntryDto> Tasks, IReadOnlyList<FileOverlapDto> Overlaps,
|
|
||||||
IReadOnlyList<SubsetRelationDto> Subsets);
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
public sealed record RevertMergeResultDto(
|
|
||||||
bool Reverted, string? RevertCommit, IReadOnlyList<string> Conflicts, string? Message);
|
|
||||||
|
|
||||||
public sealed record DailyPrepCandidateDto(
|
public sealed record DailyPrepCandidateDto(
|
||||||
string Id, int Number, string ListId, string ListName, string Title, string? Description,
|
string Id, int Number, string ListId, string ListName, string Title, string? Description,
|
||||||
bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt);
|
bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt);
|
||||||
@@ -199,7 +127,6 @@ public sealed class ExternalMcpService
|
|||||||
private readonly ITaskStateService _state;
|
private readonly ITaskStateService _state;
|
||||||
private readonly GitService _git;
|
private readonly GitService _git;
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly WorktreeMaintenanceService _maintenance;
|
|
||||||
private readonly TaskMergeService _merge;
|
private readonly TaskMergeService _merge;
|
||||||
private readonly PlanningMergeOrchestrator _planningMerge;
|
private readonly PlanningMergeOrchestrator _planningMerge;
|
||||||
private readonly BaseDirtyChecker _baseDirtyChecker;
|
private readonly BaseDirtyChecker _baseDirtyChecker;
|
||||||
@@ -212,7 +139,6 @@ public sealed class ExternalMcpService
|
|||||||
ITaskStateService state,
|
ITaskStateService state,
|
||||||
GitService git,
|
GitService git,
|
||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
WorktreeMaintenanceService maintenance,
|
|
||||||
TaskMergeService merge,
|
TaskMergeService merge,
|
||||||
PlanningMergeOrchestrator planningMerge,
|
PlanningMergeOrchestrator planningMerge,
|
||||||
BaseDirtyChecker baseDirtyChecker)
|
BaseDirtyChecker baseDirtyChecker)
|
||||||
@@ -224,7 +150,6 @@ public sealed class ExternalMcpService
|
|||||||
_state = state;
|
_state = state;
|
||||||
_git = git;
|
_git = git;
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_maintenance = maintenance;
|
|
||||||
_merge = merge;
|
_merge = merge;
|
||||||
_planningMerge = planningMerge;
|
_planningMerge = planningMerge;
|
||||||
_baseDirtyChecker = baseDirtyChecker;
|
_baseDirtyChecker = baseDirtyChecker;
|
||||||
@@ -912,732 +837,6 @@ public sealed class ExternalMcpService
|
|||||||
return new DeleteTaskResult(true, taskId, task.Number);
|
return new DeleteTaskResult(true, taskId, task.Number);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Worktree / git tools ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[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, 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(
|
|
||||||
"Merge a Done task's worktree branch into targetBranch. For a task still in WaitingForReview prefer " +
|
|
||||||
"review_task, which merges as part of approving. merged=true carries the new mergeCommit SHA; on conflict " +
|
|
||||||
"merged=false and conflicts lists the affected files." + McpToolDocs.ProgressHint)]
|
|
||||||
public async Task<MergeTaskResultDto> MergeTask(
|
|
||||||
string taskId,
|
|
||||||
string targetBranch = "main",
|
|
||||||
[Description("true (default): always create a merge commit (--no-ff).")]
|
|
||||||
bool noFf = true,
|
|
||||||
[Description("true: validate preconditions only and do not merge — merged=false then means \"not attempted\".")]
|
|
||||||
bool dryRun = false,
|
|
||||||
[Description("true: also allow merging a task in WaitingForReview; false (default) allows Done only.")]
|
|
||||||
bool allowWaitingForReview = false,
|
|
||||||
[Description("What to do on conflict. false (default): abort cleanly, leaving no half-merged state. true: " +
|
|
||||||
"leave the conflict markers in the working tree at repoPath (conflictsInTree=true) so you can " +
|
|
||||||
"resolve them there and call continue_merge, or abort_merge to cancel.")]
|
|
||||||
bool leaveConflictsInTree = false,
|
|
||||||
[Description("Batch mode: defer the list's verify command instead of running it after this one merge " +
|
|
||||||
"(see review_task's skipVerify). Call verify_merges once after the batch.")]
|
|
||||||
bool skipVerify = false,
|
|
||||||
CancellationToken cancellationToken = default,
|
|
||||||
IProgress<ProgressNotificationValue>? progress = null)
|
|
||||||
{
|
|
||||||
progress?.Report(new ProgressNotificationValue { Progress = 0, Message = "merge_task started" });
|
|
||||||
|
|
||||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
||||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
||||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
||||||
var canMerge = task.Status == TaskStatus.Done ||
|
|
||||||
(allowWaitingForReview && task.Status == TaskStatus.WaitingForReview);
|
|
||||||
if (!canMerge)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Task must be Done to merge (current status: {task.Status}). " +
|
|
||||||
"Pass allowWaitingForReview=true to also merge a WaitingForReview task.");
|
|
||||||
|
|
||||||
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
|
||||||
|
|
||||||
if (dryRun)
|
|
||||||
{
|
|
||||||
using var ctx = _dbFactory.CreateDbContext();
|
|
||||||
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken)
|
|
||||||
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
|
|
||||||
if (wt.State != WorktreeState.Active)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Worktree state must be Active to merge (current: {wt.State}).");
|
|
||||||
return new MergeTaskResultDto(false, null, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blank on purpose: TaskMergeService builds the conventional default message.
|
|
||||||
var result = await _merge.MergeAsync(
|
|
||||||
taskId, targetBranch, removeWorktree: false, commitMessage: "", leaveConflictsInTree, cancellationToken, progress, skipVerify);
|
|
||||||
|
|
||||||
// merged_verify_pending: the branch IS merged (that's what this tool reports on) — only
|
|
||||||
// the task's own Done transition is deferred to verify_merges.
|
|
||||||
if (result.Status is TaskMergeService.StatusMerged or TaskMergeService.StatusMergedVerifyPending)
|
|
||||||
{
|
|
||||||
string? mergeCommit = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(list?.WorkingDir) && Directory.Exists(list.WorkingDir))
|
|
||||||
mergeCommit = await _git.RevParseHeadAsync(list.WorkingDir, cancellationToken);
|
|
||||||
}
|
|
||||||
catch { /* mergeCommit is optional */ }
|
|
||||||
return new MergeTaskResultDto(true, mergeCommit, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.Status == TaskMergeService.StatusConflict)
|
|
||||||
return leaveConflictsInTree
|
|
||||||
? new MergeTaskResultDto(false, null, result.ConflictFiles,
|
|
||||||
ConflictsInTree: true, RepoPath: list?.WorkingDir)
|
|
||||||
: new MergeTaskResultDto(false, null, result.ConflictFiles);
|
|
||||||
|
|
||||||
throw new InvalidOperationException(result.ErrorMessage ?? $"Merge blocked: {result.Status}");
|
|
||||||
}
|
|
||||||
|
|
||||||
[McpServerTool, Description(
|
|
||||||
"Finish an in-progress conflicted merge once you have resolved the conflict markers in the working tree " +
|
|
||||||
"(repoPath from merge_task/review_task). Pass the PARENT task id to continue a parent/children unit merge. " +
|
|
||||||
"merged=false with conflicts listed means markers are still present — resolve them and call again. " +
|
|
||||||
"Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead." +
|
|
||||||
McpToolDocs.ProgressHint)]
|
|
||||||
public async Task<MergeContinuationResultDto> ContinueMerge(
|
|
||||||
string taskId,
|
|
||||||
[Description("Batch mode: defer the list's verify command (see review_task's skipVerify). Pass the same " +
|
|
||||||
"value you approved with — a unit merge remembers its own flag and ignores this one.")]
|
|
||||||
bool skipVerify = false,
|
|
||||||
CancellationToken cancellationToken = default,
|
|
||||||
IProgress<ProgressNotificationValue>? progress = null)
|
|
||||||
{
|
|
||||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
||||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
||||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
||||||
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
|
||||||
var workingDir = list?.WorkingDir;
|
|
||||||
|
|
||||||
bool merged;
|
|
||||||
IReadOnlyList<string> conflicts = Array.Empty<string>();
|
|
||||||
string? repoPath = null;
|
|
||||||
string? message = null;
|
|
||||||
|
|
||||||
if (_planningMerge.HasActiveMerge(taskId))
|
|
||||||
{
|
|
||||||
var unitResult = await _planningMerge.ContinueAsync(taskId, cancellationToken, progress);
|
|
||||||
var parent = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
|
||||||
if (unitResult.Status == TaskMergeService.StatusMergedVerifyPending)
|
|
||||||
{
|
|
||||||
merged = true;
|
|
||||||
message = "unit merged; verify deferred — the parent stays WaitingForReview until " +
|
|
||||||
"verify_merges (with this parent's id) passes";
|
|
||||||
}
|
|
||||||
else if (parent.Status == TaskStatus.Done)
|
|
||||||
{
|
|
||||||
merged = true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var remaining = !string.IsNullOrWhiteSpace(workingDir)
|
|
||||||
? await _git.ListConflictedFilesAsync(workingDir, cancellationToken)
|
|
||||||
: new List<string>();
|
|
||||||
merged = false;
|
|
||||||
if (remaining.Count > 0)
|
|
||||||
{
|
|
||||||
conflicts = remaining;
|
|
||||||
repoPath = workingDir;
|
|
||||||
message = "conflicts remain — resolve and call continue_merge again";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
message = "unit merge did not complete — the orchestrator aborted or was blocked; " +
|
|
||||||
"check the parent task and approve again to restart the merge";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var r = await _merge.ContinueMergeAsync(taskId, cancellationToken, progress, skipVerify);
|
|
||||||
if (r.Status == TaskMergeService.StatusMerged)
|
|
||||||
{
|
|
||||||
merged = true;
|
|
||||||
}
|
|
||||||
else if (r.Status == TaskMergeService.StatusMergedVerifyPending)
|
|
||||||
{
|
|
||||||
merged = true;
|
|
||||||
message = "merged; verify deferred — the task stays WaitingForReview until " +
|
|
||||||
"verify_merges (with its id) passes";
|
|
||||||
}
|
|
||||||
else if (r.Status == TaskMergeService.StatusConflict)
|
|
||||||
{
|
|
||||||
merged = false;
|
|
||||||
conflicts = r.ConflictFiles;
|
|
||||||
repoPath = workingDir;
|
|
||||||
message = r.ErrorMessage;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(r.ErrorMessage ?? "continue failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var reloaded = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
|
||||||
return new MergeContinuationResultDto(merged, reloaded.Status.ToString(), conflicts, repoPath, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
[McpServerTool, Description(
|
|
||||||
"Run the list's verify command ONCE for a batch of merges made with skipVerify=true, then promote the " +
|
|
||||||
"listed tasks to Done. Call it a single time after the LAST merge of the batch instead of paying the " +
|
|
||||||
"verify per merge. All tasks must belong to the same list; each must be WaitingForReview with its merge " +
|
|
||||||
"already landed (mergeStatus merged_verify_pending). status verify_passed: check each entry's promoted " +
|
|
||||||
"flag (an ineligible task is skipped with a reason, the rest still promote). status verify_failed: the " +
|
|
||||||
"merges stay in place but NO task reaches Done; the command's output tail is in errorMessage — report it, " +
|
|
||||||
"don't fix it yourself. A list without a verify command promotes directly." + McpToolDocs.ProgressHint)]
|
|
||||||
public async Task<ListVerifyResult> VerifyMerges(
|
|
||||||
[Description("Every task merged with skipVerify=true in this batch (accepts #numbers or ids).")]
|
|
||||||
string[] taskIds,
|
|
||||||
CancellationToken cancellationToken = default,
|
|
||||||
IProgress<ProgressNotificationValue>? progress = null)
|
|
||||||
{
|
|
||||||
if (taskIds.Length == 0)
|
|
||||||
throw new InvalidOperationException("taskIds must not be empty.");
|
|
||||||
// Same rationale as review_task's first report: the verify itself reports periodically,
|
|
||||||
// but the wait on the per-repo merge gate before it would otherwise sit silent.
|
|
||||||
progress?.Report(new ProgressNotificationValue { Progress = 0, Message = "verify_merges started" });
|
|
||||||
|
|
||||||
string? listId = null;
|
|
||||||
var resolved = new List<string>(taskIds.Length);
|
|
||||||
foreach (var raw in taskIds)
|
|
||||||
{
|
|
||||||
var id = await TaskIdResolver.ResolveAsync(_tasks, raw, cancellationToken);
|
|
||||||
var task = await _tasks.GetByIdAsync(id, cancellationToken)
|
|
||||||
?? throw new InvalidOperationException($"Task {raw} not found.");
|
|
||||||
listId ??= task.ListId;
|
|
||||||
if (task.ListId != listId)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"All tasks must belong to the same list — one verify command gates one repo.");
|
|
||||||
resolved.Add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await _merge.RunListVerifyAsync(listId!, resolved, cancellationToken, progress);
|
|
||||||
if (result.Status == TaskMergeService.StatusBlocked)
|
|
||||||
throw new InvalidOperationException(result.ErrorMessage ?? "verify blocked");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
[McpServerTool, Description(
|
|
||||||
"Abort an in-progress conflicted merge, discarding the conflict markers and restoring a clean working tree. " +
|
|
||||||
"Pass the PARENT task id to abort a parent/children unit merge. The task keeps its pre-merge status " +
|
|
||||||
"(e.g. WaitingForReview). Throws if there is no in-progress merge for the task." + McpToolDocs.LeanTaskRef)]
|
|
||||||
public async Task<TaskRefDto> AbortMerge(string taskId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
||||||
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
||||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
||||||
|
|
||||||
if (_planningMerge.HasActiveMerge(taskId))
|
|
||||||
{
|
|
||||||
await _planningMerge.AbortAsync(taskId, cancellationToken);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var r = await _merge.AbortMergeAsync(taskId, cancellationToken);
|
|
||||||
if (r.Status == TaskMergeService.StatusBlocked)
|
|
||||||
throw new InvalidOperationException(r.ErrorMessage ?? "abort failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
|
||||||
return ToRefDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
|
|
||||||
}
|
|
||||||
|
|
||||||
[McpServerTool, Description(
|
|
||||||
"List every conflict hunk left in a paused merge (started via merge_task/review_task with " +
|
|
||||||
"leaveConflictsInTree=true), so you can resolve them with resolve_conflict_hunk instead of parsing marker " +
|
|
||||||
"text yourself. Each hunk carries the file's path, its index within that file (what resolve_conflict_hunk " +
|
|
||||||
"expects back), and its startLine. remainingHunks is the total across every file — call continue_merge once " +
|
|
||||||
"it reaches 0, or abort_merge to cancel. Throws if the task has no in-progress merge." + " " + McpToolDocs.Diff3Note)]
|
|
||||||
public async Task<GetMergeConflictsResultDto> GetMergeConflicts(string taskId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
||||||
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
||||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
||||||
|
|
||||||
var docs = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
|
|
||||||
var files = docs.Files.Select(ToConflictFileHunksDto).ToList();
|
|
||||||
var remaining = files.Sum(f => f.Hunks.Count);
|
|
||||||
return new GetMergeConflictsResultDto(files, remaining);
|
|
||||||
}
|
|
||||||
|
|
||||||
[McpServerTool, Description(
|
|
||||||
"Resolve exactly one conflict hunk from get_merge_conflicts, identified by file path and its index within " +
|
|
||||||
"that file. Writes only that hunk — every other hunk (in this file or another) is left exactly as it was, " +
|
|
||||||
"so call get_merge_conflicts again afterward: resolved hunks disappear and every remaining hunk's index " +
|
|
||||||
"shifts down to stay zero-based. This never decides FOR you which side wins — resolution must be 'ours', " +
|
|
||||||
"'theirs', 'base' (only valid when that hunk has a diff3 base), or literal replacement text (used exactly " +
|
|
||||||
"as given, including any trailing newline the surrounding file needs). remainingHunks is the total across " +
|
|
||||||
"every file; call continue_merge once it reaches 0. Throws if the file/index don't match a current hunk." +
|
|
||||||
" " + McpToolDocs.Diff3Note)]
|
|
||||||
public async Task<ResolveConflictHunkResultDto> ResolveConflictHunk(
|
|
||||||
string taskId,
|
|
||||||
[Description("File path exactly as returned by get_merge_conflicts, relative to the repo root.")]
|
|
||||||
string file,
|
|
||||||
[Description("Zero-based hunk index within that file, as returned by get_merge_conflicts.")]
|
|
||||||
int index,
|
|
||||||
[Description("'ours', 'theirs', 'base', or literal text to use for this hunk.")]
|
|
||||||
string resolution,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(resolution))
|
|
||||||
throw new InvalidOperationException("resolution is required.");
|
|
||||||
|
|
||||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
||||||
var docs = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
|
|
||||||
var doc = docs.Files.FirstOrDefault(f => string.Equals(f.Path, file, StringComparison.Ordinal))
|
|
||||||
?? throw new InvalidOperationException($"File '{file}' has no conflict for task {taskId}.");
|
|
||||||
if (doc.IsBinary)
|
|
||||||
throw new InvalidOperationException($"File '{file}' is binary; resolve it directly in the working tree.");
|
|
||||||
|
|
||||||
var hunkCount = doc.Segments.Count(s => s.IsConflict);
|
|
||||||
if (index < 0 || index >= hunkCount)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Hunk index {index} is out of range for '{file}' ({hunkCount} hunk(s) remaining).");
|
|
||||||
|
|
||||||
var counter = -1;
|
|
||||||
var composed = ConflictMarkerParser.Compose(doc.Segments, seg =>
|
|
||||||
{
|
|
||||||
counter++;
|
|
||||||
if (counter != index) return ReconstructConflictMarkers(seg);
|
|
||||||
|
|
||||||
var choice = resolution.Trim().ToLowerInvariant();
|
|
||||||
return choice switch
|
|
||||||
{
|
|
||||||
"ours" => seg.Ours,
|
|
||||||
"theirs" => seg.Theirs,
|
|
||||||
"base" => seg.Base ?? throw new InvalidOperationException(
|
|
||||||
$"Hunk {index} in '{file}' has no diff3 base to resolve to."),
|
|
||||||
_ => resolution,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
await _merge.WriteConflictFileAsync(taskId, file, composed, cancellationToken);
|
|
||||||
|
|
||||||
var refreshed = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
|
|
||||||
var remaining = refreshed.Files.Sum(f => f.Segments.Count(s => s.IsConflict));
|
|
||||||
return new ResolveConflictHunkResultDto(true, remaining);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ConflictFileHunksDto ToConflictFileHunksDto(ConflictDocumentContent f)
|
|
||||||
{
|
|
||||||
var hunks = new List<ConflictHunkDto>();
|
|
||||||
foreach (var seg in f.Segments)
|
|
||||||
{
|
|
||||||
if (!seg.IsConflict) continue;
|
|
||||||
hunks.Add(new ConflictHunkDto(hunks.Count, seg.StartLine, seg.Ours, seg.Base, seg.Theirs));
|
|
||||||
}
|
|
||||||
return new ConflictFileHunksDto(f.Path, f.IsBinary, hunks);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-renders a still-unresolved hunk as valid (unlabeled) conflict markers so it round-trips through
|
|
||||||
// ConflictMarkerParser.Parse again untouched, and git still refuses continue_merge while it stands. The
|
|
||||||
// original marker labels (e.g. "<<<<<<< HEAD") aren't recoverable from a parsed MergeSegment and aren't
|
|
||||||
// needed for either check -- ConflictMarkerParser.IsMarker and ContinueMergeAsync's rescan only match the
|
|
||||||
// marker prefixes.
|
|
||||||
private static string ReconstructConflictMarkers(MergeSegment seg)
|
|
||||||
{
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.Append("<<<<<<<\n").Append(seg.Ours);
|
|
||||||
if (seg.Base is not null)
|
|
||||||
sb.Append("|||||||\n").Append(seg.Base);
|
|
||||||
sb.Append("=======\n").Append(seg.Theirs);
|
|
||||||
sb.Append(">>>>>>>\n");
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
[McpServerTool, Description(
|
|
||||||
"Check whether a task would merge cleanly before touching anything — `git merge-tree --write-tree`, so the " +
|
|
||||||
"working tree, index and HEAD are untouched. status is 'clean', 'conflict' (conflictFiles lists where git " +
|
|
||||||
"would stop), or 'untracked_collision' (conflictFiles lists a path the branch adds that already exists, " +
|
|
||||||
"untracked, in the target working directory — merge-tree can't see the working tree at all, so this is the " +
|
|
||||||
"only way to catch it before a real merge either refuses or, if that path became tracked in the meantime, " +
|
|
||||||
"silently overwrites it); behind counts commits on targetBranch not yet on this branch, which flags a stale " +
|
|
||||||
"branch even when the preview is clean. If the list has a verify command configured, a clean preview is " +
|
|
||||||
"additionally built/tested in a scratch worktree (still without touching the real working tree) — " +
|
|
||||||
"verifyExitCode 0 means it built clean, non-zero or -1 (timeout/failed to start) means it doesn't, with the " +
|
|
||||||
"tail of its output in verifyOutputTail; verifyExitCode stays null when no verify command is configured. " +
|
|
||||||
"isEmpty=true means the task's review range contributed nothing; check that flag rather than reading a " +
|
|
||||||
"small changedFileCount as empty. staleFiles lists files this branch touches that the target branch ALSO " +
|
|
||||||
"changed since this branch's fork point — a more honest staleness signal than `behind` alone, since a " +
|
|
||||||
"branch can be far behind yet touch nothing the target changed, or barely behind yet collide on the one " +
|
|
||||||
"file that matters (always empty for a worktree-less handler task, which has no fork point). Throws if the " +
|
|
||||||
"task has neither an active worktree nor a handler commit range, or the list's working directory is missing " +
|
|
||||||
"from disk." + McpToolDocs.ProgressHint)]
|
|
||||||
public async Task<MergePreviewToolDto> PreviewMerge(
|
|
||||||
string taskId,
|
|
||||||
[Description("Branch to preview against; defaults to the repo's current branch.")]
|
|
||||||
string? targetBranch = null,
|
|
||||||
CancellationToken cancellationToken = default,
|
|
||||||
IProgress<ProgressNotificationValue>? progress = null)
|
|
||||||
{
|
|
||||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
||||||
var (preview, behind, _, isEmpty, staleFiles, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, cancellationToken, progress);
|
|
||||||
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty,
|
|
||||||
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
[McpServerTool, Description(
|
|
||||||
"Plan a batch merge: preview_merge for several tasks against the same targetBranch, plus a file-overlap " +
|
|
||||||
"check between them. Per entry you get preview_merge's fields, or error instead when that task could not " +
|
|
||||||
"be previewed (it is then left out of the overlap/subset computation). overlaps names, for each file " +
|
|
||||||
"touched by MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields no " +
|
|
||||||
"overlaps. subsets flags a stronger relation: TaskId's changed files are a PROPER subset of " +
|
|
||||||
"SupersetTaskId's — the strongest hint you get post-hoc that TaskId may be redundant with SupersetTaskId, " +
|
|
||||||
"worth checking before merging both. IMPORTANT: neither overlap nor subset is a safety guarantee — two " +
|
|
||||||
"tasks touching entirely different files (one deleting a symbol, another still referencing it) can still " +
|
|
||||||
"collide unflagged, and as with preview_merge a clean result does not mean the merge builds. " +
|
|
||||||
"runVerify=false (default) never builds — set it true to also run each task's list's verify command in a " +
|
|
||||||
"scratch worktree per entry (same fields as preview_merge); this can take a long time across many tasks, " +
|
|
||||||
"since builds run one at a time." + McpToolDocs.ProgressHint)]
|
|
||||||
public async Task<MergePreviewSetResultDto> PreviewMergeSet(
|
|
||||||
IReadOnlyList<string> taskIds,
|
|
||||||
[Description("Branch to preview every task against; defaults to the repo's current branch.")]
|
|
||||||
string? targetBranch = null,
|
|
||||||
[Description("true: also run the verify command (if configured) for each task, one build at a time. " +
|
|
||||||
"false (default): no builds, however many tasks are given.")]
|
|
||||||
bool runVerify = false,
|
|
||||||
CancellationToken cancellationToken = default,
|
|
||||||
IProgress<ProgressNotificationValue>? progress = null)
|
|
||||||
{
|
|
||||||
if (taskIds is null || taskIds.Count == 0)
|
|
||||||
throw new InvalidOperationException("taskIds must contain at least one task id.");
|
|
||||||
|
|
||||||
taskIds = await TaskIdResolver.ResolveManyAsync(_tasks, taskIds, cancellationToken);
|
|
||||||
|
|
||||||
var entries = new List<MergePreviewSetEntryDto>();
|
|
||||||
var filesByTask = new Dictionary<string, IReadOnlyList<string>>();
|
|
||||||
var numbersByTask = new Dictionary<string, int>();
|
|
||||||
|
|
||||||
for (var i = 0; i < taskIds.Count; i++)
|
|
||||||
{
|
|
||||||
var taskId = taskIds[i];
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var (preview, behind, changedFiles, isEmpty, staleFiles, number) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
|
|
||||||
entries.Add(new MergePreviewSetEntryDto(
|
|
||||||
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty,
|
|
||||||
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number));
|
|
||||||
filesByTask[taskId] = changedFiles;
|
|
||||||
numbersByTask[taskId] = number;
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException ex)
|
|
||||||
{
|
|
||||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken);
|
|
||||||
entries.Add(new MergePreviewSetEntryDto(
|
|
||||||
taskId, TaskMergeService.PreviewUnavailable, Array.Empty<string>(), 0, 0, ex.Message,
|
|
||||||
Number: task?.Number));
|
|
||||||
}
|
|
||||||
|
|
||||||
ProgressReporter.ReportItem(progress, "Previewing merges", i + 1, taskIds.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 =>
|
|
||||||
{
|
|
||||||
var ids = g.Select(x => x.TaskId).Distinct().ToList();
|
|
||||||
return new FileOverlapDto(g.Key, ids, ids.Select(id => numbersByTask[id]).ToList());
|
|
||||||
})
|
|
||||||
.OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var subsets = FindSubsetRelations(filesByTask, numbersByTask);
|
|
||||||
|
|
||||||
return new MergePreviewSetResultDto(entries, overlaps, subsets);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A task's changed-file set is flagged only against another task's STRICTLY LARGER set that
|
|
||||||
// contains every one of its files -- an empty set (isEmpty task) never qualifies, since that
|
|
||||||
// case is already surfaced via IsEmpty and would otherwise match every other task trivially.
|
|
||||||
private static List<SubsetRelationDto> FindSubsetRelations(
|
|
||||||
Dictionary<string, IReadOnlyList<string>> filesByTask, Dictionary<string, int> numbersByTask)
|
|
||||||
{
|
|
||||||
var fileSets = filesByTask.ToDictionary(
|
|
||||||
kv => kv.Key,
|
|
||||||
kv => new HashSet<string>(kv.Value, StringComparer.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
var subsets = new List<SubsetRelationDto>();
|
|
||||||
foreach (var (taskId, fileSet) in fileSets)
|
|
||||||
{
|
|
||||||
if (fileSet.Count == 0) continue;
|
|
||||||
foreach (var (otherTaskId, otherFileSet) in fileSets)
|
|
||||||
{
|
|
||||||
if (otherTaskId == taskId || otherFileSet.Count <= fileSet.Count) continue;
|
|
||||||
if (fileSet.IsSubsetOf(otherFileSet))
|
|
||||||
subsets.Add(new SubsetRelationDto(taskId, numbersByTask[taskId], otherTaskId, numbersByTask[otherTaskId]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return subsets
|
|
||||||
.OrderBy(s => s.TaskId, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ThenBy(s => s.SupersetTaskId, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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, plus
|
|
||||||
// `isEmpty`. A worktree-less list-handler host task has no branch to merge-tree-preview at all
|
|
||||||
// (its commits already sit on the list's working dir) — falls back to the fixed
|
|
||||||
// HandlerBaseCommit..HandlerHeadCommit range, reporting a synthetic "clean" preview of that
|
|
||||||
// range's own diff-stat instead of throwing "has no worktree".
|
|
||||||
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty, IReadOnlyList<string> StaleFiles, int Number)> PreviewMergeCoreAsync(
|
|
||||||
string taskId, string? targetBranch, bool runVerify, CancellationToken ct,
|
|
||||||
IProgress<ProgressNotificationValue>? progress = null)
|
|
||||||
{
|
|
||||||
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);
|
|
||||||
|
|
||||||
if (wt is not null)
|
|
||||||
{
|
|
||||||
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 ?? "", runVerify, ct, progress);
|
|
||||||
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: ct))
|
|
||||||
: Array.Empty<string>();
|
|
||||||
|
|
||||||
// What the target itself picked up since this branch's fork point, so `behind`
|
|
||||||
// (a commit count) doesn't have to stand in for "does this collide" -- a branch can
|
|
||||||
// be far behind but touch nothing the target changed, or close behind and collide on
|
|
||||||
// the one file that matters.
|
|
||||||
var targetChangedSinceFork = await _git.GetChangedFileNamesAsync(list.WorkingDir, wt.BaseCommit, target, ct);
|
|
||||||
var staleFiles = changedFiles
|
|
||||||
.Intersect(targetChangedSinceFork, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
|
|
||||||
throw new InvalidOperationException("The list's working directory no longer exists.");
|
|
||||||
|
|
||||||
var isEmpty = string.Equals(handlerBase, handlerHead, StringComparison.Ordinal);
|
|
||||||
var changedFiles = isEmpty
|
|
||||||
? Array.Empty<string>()
|
|
||||||
: ParseDiffStatFileNames(await _git.DiffStatAsync(list.WorkingDir, handlerBase, handlerHead, ct: ct));
|
|
||||||
|
|
||||||
var preview = new MergePreviewResult(TaskMergeService.PreviewClean, Array.Empty<string>(), changedFiles.Count);
|
|
||||||
// No fork point to diff against: a handler task commits straight onto the list's
|
|
||||||
// working dir instead of a branch, so there is nothing else that could have "changed
|
|
||||||
// in the target since the fork".
|
|
||||||
return (preview, 0, changedFiles, isEmpty, Array.Empty<string>(), task.Number);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new InvalidOperationException($"Task {taskId} has no worktree.");
|
|
||||||
}
|
|
||||||
|
|
||||||
[McpServerTool, Description(
|
|
||||||
"Undo a merged task by reverting its merge commit — `git revert -m 1`, always a new commit and never a " +
|
|
||||||
"reset/rewrite, since the target working directory is shared with other sessions. Requires the task to be " +
|
|
||||||
"Done with a Merged worktree that has a recorded merge commit (check get_task_worktree's mergeCommit " +
|
|
||||||
"first). On success the task returns to WaitingForReview so it can be reconsidered. On conflict the revert " +
|
|
||||||
"is aborted immediately and conflicts lists the files. Throws if there is no recorded merge commit, the " +
|
|
||||||
"repo is mid-merge/mid-revert, or the target working tree has uncommitted changes from another session.")]
|
|
||||||
public async Task<RevertMergeResultDto> RevertMerge(
|
|
||||||
string taskId,
|
|
||||||
[Description("Branch carrying the merge commit; defaults to main.")]
|
|
||||||
string targetBranch = "main",
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
||||||
var result = await _merge.RevertMergeAsync(taskId, targetBranch, cancellationToken);
|
|
||||||
|
|
||||||
if (result.Status == TaskMergeService.StatusReverted)
|
|
||||||
{
|
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
|
||||||
return new RevertMergeResultDto(true, result.RevertCommit, Array.Empty<string>(), null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.Status == TaskMergeService.StatusConflictAborted)
|
|
||||||
return new RevertMergeResultDto(false, null, result.ConflictFiles, result.ErrorMessage);
|
|
||||||
|
|
||||||
throw new InvalidOperationException(result.ErrorMessage ?? $"Revert blocked: {result.Status}");
|
|
||||||
}
|
|
||||||
|
|
||||||
[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);
|
|
||||||
}
|
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Send a follow-up prompt to a task's existing Claude session instead of starting a fresh run — the agent " +
|
"Send a follow-up prompt to a task's existing Claude session instead of starting a fresh run — the agent " +
|
||||||
"resumes via --resume with the session id from the task's last run, so its prior context is kept. Uses the " +
|
"resumes via --resume with the session id from the task's last run, so its prior context is kept. Uses the " +
|
||||||
@@ -1752,69 +951,10 @@ public sealed class ExternalMcpService
|
|||||||
|
|
||||||
// ── Private helpers ───────────────────────────────────────────────────────
|
// ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity Wt)> LoadWorktreeContextAsync(
|
// internal: also used by MergeMcpTools/WorktreeMcpTools' diff-stat parsing, which need it via
|
||||||
string taskId, CancellationToken ct)
|
// an ExternalMcpService.ParseDiffStatFileNames(...) call since a private member of one class
|
||||||
{
|
// isn't visible from another, even in the same namespace.
|
||||||
using var ctx = _dbFactory.CreateDbContext();
|
internal static IReadOnlyList<string> ParseDiffStatFileNames(string stat)
|
||||||
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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IReadOnlyList<string> ParseDiffStatFileNames(string stat)
|
|
||||||
{
|
{
|
||||||
var files = new List<string>();
|
var files = new List<string>();
|
||||||
foreach (var line in stat.Split('\n'))
|
foreach (var line in stat.Split('\n'))
|
||||||
@@ -1848,7 +988,8 @@ public sealed class ExternalMcpService
|
|||||||
blockedReason,
|
blockedReason,
|
||||||
t.IsManual);
|
t.IsManual);
|
||||||
|
|
||||||
private static TaskRefDto ToRefDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new(
|
// internal: AbortMerge in MergeMcpTools also needs this to build its TaskRefDto result.
|
||||||
|
internal static TaskRefDto ToRefDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new(
|
||||||
t.Id,
|
t.Id,
|
||||||
t.Number,
|
t.Number,
|
||||||
t.ListId,
|
t.ListId,
|
||||||
|
|||||||
+667
@@ -0,0 +1,667 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Git;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Lifecycle;
|
||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
|
using ModelContextProtocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.External;
|
||||||
|
|
||||||
|
public sealed record MergeTaskResultDto(
|
||||||
|
bool Merged, string? MergeCommit, IReadOnlyList<string> Conflicts,
|
||||||
|
bool ConflictsInTree = false, string? RepoPath = null);
|
||||||
|
|
||||||
|
public sealed record MergeContinuationResultDto(
|
||||||
|
bool Merged, string TaskStatus, IReadOnlyList<string> Conflicts,
|
||||||
|
string? RepoPath, string? Message);
|
||||||
|
|
||||||
|
public sealed record ConflictHunkDto(
|
||||||
|
int Index, int StartLine, string Ours, string? Base, string Theirs);
|
||||||
|
|
||||||
|
public sealed record ConflictFileHunksDto(
|
||||||
|
string Path, bool IsBinary, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||||
|
|
||||||
|
public sealed record GetMergeConflictsResultDto(
|
||||||
|
IReadOnlyList<ConflictFileHunksDto> Files, int RemainingHunks, string ConflictStyleNote = McpToolDocs.Diff3Note);
|
||||||
|
|
||||||
|
public sealed record ResolveConflictHunkResultDto(
|
||||||
|
bool Resolved, int RemainingHunks, string ConflictStyleNote = McpToolDocs.Diff3Note);
|
||||||
|
|
||||||
|
// IsEmpty = the review range contributed nothing (worktree ahead-of-base is empty, or a
|
||||||
|
// worktree-less handler task's HandlerBaseCommit == HandlerHeadCommit) -- distinguishable from
|
||||||
|
// a merge that is merely small, so an empty branch can't be misread as "changedFileCount: 0
|
||||||
|
// means tiny" when it actually means "nothing to review".
|
||||||
|
// VerifyExitCode/VerifyDurationMs/VerifyOutputTail are null unless a verify run was attempted
|
||||||
|
// (see PreviewMerge/PreviewMergeSet descriptions) -- 0 exit means the merge-tree result built/
|
||||||
|
// tested clean; a non-zero or -1 (timeout/failed to start) exit means it doesn't, with the tail
|
||||||
|
// of its output in VerifyOutputTail.
|
||||||
|
public sealed record MergePreviewToolDto(
|
||||||
|
string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, bool IsEmpty = false,
|
||||||
|
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null,
|
||||||
|
// Files this branch touches that the target branch ALSO touched since the branch's fork
|
||||||
|
// point -- an honest staleness signal `behind` alone can't give (a branch can be far behind
|
||||||
|
// yet touch nothing the target changed, or close behind yet collide on the one file that
|
||||||
|
// matters). Empty for a worktree-less handler task, which commits straight onto the list's
|
||||||
|
// working dir and has no fork point to compare against.
|
||||||
|
IReadOnlyList<string>? StaleFiles = null);
|
||||||
|
|
||||||
|
public sealed record MergePreviewSetEntryDto(
|
||||||
|
string TaskId, string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, string? Error, bool IsEmpty = false,
|
||||||
|
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null,
|
||||||
|
IReadOnlyList<string>? StaleFiles = null, int? Number = null);
|
||||||
|
|
||||||
|
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds, IReadOnlyList<int> Numbers);
|
||||||
|
|
||||||
|
// TaskId's changed-file list is a non-empty PROPER subset of SupersetTaskId's -- e.g. one task
|
||||||
|
// deletes two files and another deletes just one of those same two. Stronger than an Overlaps
|
||||||
|
// entry (which only says some files are shared): a real subset is the best available machine
|
||||||
|
// signal that TaskId's work may already be entirely covered by SupersetTaskId.
|
||||||
|
public sealed record SubsetRelationDto(string TaskId, int Number, string SupersetTaskId, int SupersetNumber);
|
||||||
|
|
||||||
|
public sealed record MergePreviewSetResultDto(
|
||||||
|
IReadOnlyList<MergePreviewSetEntryDto> Tasks, IReadOnlyList<FileOverlapDto> Overlaps,
|
||||||
|
IReadOnlyList<SubsetRelationDto> Subsets);
|
||||||
|
|
||||||
|
public sealed record RevertMergeResultDto(
|
||||||
|
bool Reverted, string? RevertCommit, IReadOnlyList<string> Conflicts, string? Message);
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public sealed class MergeMcpTools
|
||||||
|
{
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
private readonly HubBroadcaster _broadcaster;
|
||||||
|
private readonly GitService _git;
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly TaskMergeService _merge;
|
||||||
|
private readonly PlanningMergeOrchestrator _planningMerge;
|
||||||
|
|
||||||
|
public MergeMcpTools(
|
||||||
|
TaskRepository tasks,
|
||||||
|
ListRepository lists,
|
||||||
|
HubBroadcaster broadcaster,
|
||||||
|
GitService git,
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
TaskMergeService merge,
|
||||||
|
PlanningMergeOrchestrator planningMerge)
|
||||||
|
{
|
||||||
|
_tasks = tasks;
|
||||||
|
_lists = lists;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
|
_git = git;
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_merge = merge;
|
||||||
|
_planningMerge = planningMerge;
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Merge a Done task's worktree branch into targetBranch. For a task still in WaitingForReview prefer " +
|
||||||
|
"review_task, which merges as part of approving. merged=true carries the new mergeCommit SHA; on conflict " +
|
||||||
|
"merged=false and conflicts lists the affected files." + McpToolDocs.ProgressHint)]
|
||||||
|
public async Task<MergeTaskResultDto> MergeTask(
|
||||||
|
string taskId,
|
||||||
|
string targetBranch = "main",
|
||||||
|
[Description("true (default): always create a merge commit (--no-ff).")]
|
||||||
|
bool noFf = true,
|
||||||
|
[Description("true: validate preconditions only and do not merge — merged=false then means \"not attempted\".")]
|
||||||
|
bool dryRun = false,
|
||||||
|
[Description("true: also allow merging a task in WaitingForReview; false (default) allows Done only.")]
|
||||||
|
bool allowWaitingForReview = false,
|
||||||
|
[Description("What to do on conflict. false (default): abort cleanly, leaving no half-merged state. true: " +
|
||||||
|
"leave the conflict markers in the working tree at repoPath (conflictsInTree=true) so you can " +
|
||||||
|
"resolve them there and call continue_merge, or abort_merge to cancel.")]
|
||||||
|
bool leaveConflictsInTree = false,
|
||||||
|
[Description("Batch mode: defer the list's verify command instead of running it after this one merge " +
|
||||||
|
"(see review_task's skipVerify). Call verify_merges once after the batch.")]
|
||||||
|
bool skipVerify = false,
|
||||||
|
CancellationToken cancellationToken = default,
|
||||||
|
IProgress<ProgressNotificationValue>? progress = null)
|
||||||
|
{
|
||||||
|
progress?.Report(new ProgressNotificationValue { Progress = 0, Message = "merge_task started" });
|
||||||
|
|
||||||
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||||
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
var canMerge = task.Status == TaskStatus.Done ||
|
||||||
|
(allowWaitingForReview && task.Status == TaskStatus.WaitingForReview);
|
||||||
|
if (!canMerge)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Task must be Done to merge (current status: {task.Status}). " +
|
||||||
|
"Pass allowWaitingForReview=true to also merge a WaitingForReview task.");
|
||||||
|
|
||||||
|
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
||||||
|
|
||||||
|
if (dryRun)
|
||||||
|
{
|
||||||
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
|
||||||
|
if (wt.State != WorktreeState.Active)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Worktree state must be Active to merge (current: {wt.State}).");
|
||||||
|
return new MergeTaskResultDto(false, null, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blank on purpose: TaskMergeService builds the conventional default message.
|
||||||
|
var result = await _merge.MergeAsync(
|
||||||
|
taskId, targetBranch, removeWorktree: false, commitMessage: "", leaveConflictsInTree, cancellationToken, progress, skipVerify);
|
||||||
|
|
||||||
|
// merged_verify_pending: the branch IS merged (that's what this tool reports on) — only
|
||||||
|
// the task's own Done transition is deferred to verify_merges.
|
||||||
|
if (result.Status is TaskMergeService.StatusMerged or TaskMergeService.StatusMergedVerifyPending)
|
||||||
|
{
|
||||||
|
string? mergeCommit = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(list?.WorkingDir) && Directory.Exists(list.WorkingDir))
|
||||||
|
mergeCommit = await _git.RevParseHeadAsync(list.WorkingDir, cancellationToken);
|
||||||
|
}
|
||||||
|
catch { /* mergeCommit is optional */ }
|
||||||
|
return new MergeTaskResultDto(true, mergeCommit, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.Status == TaskMergeService.StatusConflict)
|
||||||
|
return leaveConflictsInTree
|
||||||
|
? new MergeTaskResultDto(false, null, result.ConflictFiles,
|
||||||
|
ConflictsInTree: true, RepoPath: list?.WorkingDir)
|
||||||
|
: new MergeTaskResultDto(false, null, result.ConflictFiles);
|
||||||
|
|
||||||
|
throw new InvalidOperationException(result.ErrorMessage ?? $"Merge blocked: {result.Status}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Finish an in-progress conflicted merge once you have resolved the conflict markers in the working tree " +
|
||||||
|
"(repoPath from merge_task/review_task). Pass the PARENT task id to continue a parent/children unit merge. " +
|
||||||
|
"merged=false with conflicts listed means markers are still present — resolve them and call again. " +
|
||||||
|
"Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead." +
|
||||||
|
McpToolDocs.ProgressHint)]
|
||||||
|
public async Task<MergeContinuationResultDto> ContinueMerge(
|
||||||
|
string taskId,
|
||||||
|
[Description("Batch mode: defer the list's verify command (see review_task's skipVerify). Pass the same " +
|
||||||
|
"value you approved with — a unit merge remembers its own flag and ignores this one.")]
|
||||||
|
bool skipVerify = false,
|
||||||
|
CancellationToken cancellationToken = default,
|
||||||
|
IProgress<ProgressNotificationValue>? progress = null)
|
||||||
|
{
|
||||||
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||||
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
||||||
|
var workingDir = list?.WorkingDir;
|
||||||
|
|
||||||
|
bool merged;
|
||||||
|
IReadOnlyList<string> conflicts = Array.Empty<string>();
|
||||||
|
string? repoPath = null;
|
||||||
|
string? message = null;
|
||||||
|
|
||||||
|
if (_planningMerge.HasActiveMerge(taskId))
|
||||||
|
{
|
||||||
|
var unitResult = await _planningMerge.ContinueAsync(taskId, cancellationToken, progress);
|
||||||
|
var parent = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||||
|
if (unitResult.Status == TaskMergeService.StatusMergedVerifyPending)
|
||||||
|
{
|
||||||
|
merged = true;
|
||||||
|
message = "unit merged; verify deferred — the parent stays WaitingForReview until " +
|
||||||
|
"verify_merges (with this parent's id) passes";
|
||||||
|
}
|
||||||
|
else if (parent.Status == TaskStatus.Done)
|
||||||
|
{
|
||||||
|
merged = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var remaining = !string.IsNullOrWhiteSpace(workingDir)
|
||||||
|
? await _git.ListConflictedFilesAsync(workingDir, cancellationToken)
|
||||||
|
: new List<string>();
|
||||||
|
merged = false;
|
||||||
|
if (remaining.Count > 0)
|
||||||
|
{
|
||||||
|
conflicts = remaining;
|
||||||
|
repoPath = workingDir;
|
||||||
|
message = "conflicts remain — resolve and call continue_merge again";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
message = "unit merge did not complete — the orchestrator aborted or was blocked; " +
|
||||||
|
"check the parent task and approve again to restart the merge";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var r = await _merge.ContinueMergeAsync(taskId, cancellationToken, progress, skipVerify);
|
||||||
|
if (r.Status == TaskMergeService.StatusMerged)
|
||||||
|
{
|
||||||
|
merged = true;
|
||||||
|
}
|
||||||
|
else if (r.Status == TaskMergeService.StatusMergedVerifyPending)
|
||||||
|
{
|
||||||
|
merged = true;
|
||||||
|
message = "merged; verify deferred — the task stays WaitingForReview until " +
|
||||||
|
"verify_merges (with its id) passes";
|
||||||
|
}
|
||||||
|
else if (r.Status == TaskMergeService.StatusConflict)
|
||||||
|
{
|
||||||
|
merged = false;
|
||||||
|
conflicts = r.ConflictFiles;
|
||||||
|
repoPath = workingDir;
|
||||||
|
message = r.ErrorMessage;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(r.ErrorMessage ?? "continue failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var reloaded = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||||
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
|
return new MergeContinuationResultDto(merged, reloaded.Status.ToString(), conflicts, repoPath, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Run the list's verify command ONCE for a batch of merges made with skipVerify=true, then promote the " +
|
||||||
|
"listed tasks to Done. Call it a single time after the LAST merge of the batch instead of paying the " +
|
||||||
|
"verify per merge. All tasks must belong to the same list; each must be WaitingForReview with its merge " +
|
||||||
|
"already landed (mergeStatus merged_verify_pending). status verify_passed: check each entry's promoted " +
|
||||||
|
"flag (an ineligible task is skipped with a reason, the rest still promote). status verify_failed: the " +
|
||||||
|
"merges stay in place but NO task reaches Done; the command's output tail is in errorMessage — report it, " +
|
||||||
|
"don't fix it yourself. A list without a verify command promotes directly." + McpToolDocs.ProgressHint)]
|
||||||
|
public async Task<ListVerifyResult> VerifyMerges(
|
||||||
|
[Description("Every task merged with skipVerify=true in this batch (accepts #numbers or ids).")]
|
||||||
|
string[] taskIds,
|
||||||
|
CancellationToken cancellationToken = default,
|
||||||
|
IProgress<ProgressNotificationValue>? progress = null)
|
||||||
|
{
|
||||||
|
if (taskIds.Length == 0)
|
||||||
|
throw new InvalidOperationException("taskIds must not be empty.");
|
||||||
|
// Same rationale as review_task's first report: the verify itself reports periodically,
|
||||||
|
// but the wait on the per-repo merge gate before it would otherwise sit silent.
|
||||||
|
progress?.Report(new ProgressNotificationValue { Progress = 0, Message = "verify_merges started" });
|
||||||
|
|
||||||
|
string? listId = null;
|
||||||
|
var resolved = new List<string>(taskIds.Length);
|
||||||
|
foreach (var raw in taskIds)
|
||||||
|
{
|
||||||
|
var id = await TaskIdResolver.ResolveAsync(_tasks, raw, cancellationToken);
|
||||||
|
var task = await _tasks.GetByIdAsync(id, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"Task {raw} not found.");
|
||||||
|
listId ??= task.ListId;
|
||||||
|
if (task.ListId != listId)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"All tasks must belong to the same list — one verify command gates one repo.");
|
||||||
|
resolved.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _merge.RunListVerifyAsync(listId!, resolved, cancellationToken, progress);
|
||||||
|
if (result.Status == TaskMergeService.StatusBlocked)
|
||||||
|
throw new InvalidOperationException(result.ErrorMessage ?? "verify blocked");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Abort an in-progress conflicted merge, discarding the conflict markers and restoring a clean working tree. " +
|
||||||
|
"Pass the PARENT task id to abort a parent/children unit merge. The task keeps its pre-merge status " +
|
||||||
|
"(e.g. WaitingForReview). Throws if there is no in-progress merge for the task." + McpToolDocs.LeanTaskRef)]
|
||||||
|
public async Task<TaskRefDto> AbortMerge(string taskId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||||
|
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
|
||||||
|
if (_planningMerge.HasActiveMerge(taskId))
|
||||||
|
{
|
||||||
|
await _planningMerge.AbortAsync(taskId, cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var r = await _merge.AbortMergeAsync(taskId, cancellationToken);
|
||||||
|
if (r.Status == TaskMergeService.StatusBlocked)
|
||||||
|
throw new InvalidOperationException(r.ErrorMessage ?? "abort failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
|
return ExternalMcpService.ToRefDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"List every conflict hunk left in a paused merge (started via merge_task/review_task with " +
|
||||||
|
"leaveConflictsInTree=true), so you can resolve them with resolve_conflict_hunk instead of parsing marker " +
|
||||||
|
"text yourself. Each hunk carries the file's path, its index within that file (what resolve_conflict_hunk " +
|
||||||
|
"expects back), and its startLine. remainingHunks is the total across every file — call continue_merge once " +
|
||||||
|
"it reaches 0, or abort_merge to cancel. Throws if the task has no in-progress merge." + " " + McpToolDocs.Diff3Note)]
|
||||||
|
public async Task<GetMergeConflictsResultDto> GetMergeConflicts(string taskId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||||
|
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
|
||||||
|
var docs = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
|
||||||
|
var files = docs.Files.Select(ToConflictFileHunksDto).ToList();
|
||||||
|
var remaining = files.Sum(f => f.Hunks.Count);
|
||||||
|
return new GetMergeConflictsResultDto(files, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Resolve exactly one conflict hunk from get_merge_conflicts, identified by file path and its index within " +
|
||||||
|
"that file. Writes only that hunk — every other hunk (in this file or another) is left exactly as it was, " +
|
||||||
|
"so call get_merge_conflicts again afterward: resolved hunks disappear and every remaining hunk's index " +
|
||||||
|
"shifts down to stay zero-based. This never decides FOR you which side wins — resolution must be 'ours', " +
|
||||||
|
"'theirs', 'base' (only valid when that hunk has a diff3 base), or literal replacement text (used exactly " +
|
||||||
|
"as given, including any trailing newline the surrounding file needs). remainingHunks is the total across " +
|
||||||
|
"every file; call continue_merge once it reaches 0. Throws if the file/index don't match a current hunk." +
|
||||||
|
" " + McpToolDocs.Diff3Note)]
|
||||||
|
public async Task<ResolveConflictHunkResultDto> ResolveConflictHunk(
|
||||||
|
string taskId,
|
||||||
|
[Description("File path exactly as returned by get_merge_conflicts, relative to the repo root.")]
|
||||||
|
string file,
|
||||||
|
[Description("Zero-based hunk index within that file, as returned by get_merge_conflicts.")]
|
||||||
|
int index,
|
||||||
|
[Description("'ours', 'theirs', 'base', or literal text to use for this hunk.")]
|
||||||
|
string resolution,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(resolution))
|
||||||
|
throw new InvalidOperationException("resolution is required.");
|
||||||
|
|
||||||
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||||
|
var docs = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
|
||||||
|
var doc = docs.Files.FirstOrDefault(f => string.Equals(f.Path, file, StringComparison.Ordinal))
|
||||||
|
?? throw new InvalidOperationException($"File '{file}' has no conflict for task {taskId}.");
|
||||||
|
if (doc.IsBinary)
|
||||||
|
throw new InvalidOperationException($"File '{file}' is binary; resolve it directly in the working tree.");
|
||||||
|
|
||||||
|
var hunkCount = doc.Segments.Count(s => s.IsConflict);
|
||||||
|
if (index < 0 || index >= hunkCount)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Hunk index {index} is out of range for '{file}' ({hunkCount} hunk(s) remaining).");
|
||||||
|
|
||||||
|
var counter = -1;
|
||||||
|
var composed = ConflictMarkerParser.Compose(doc.Segments, seg =>
|
||||||
|
{
|
||||||
|
counter++;
|
||||||
|
if (counter != index) return ReconstructConflictMarkers(seg);
|
||||||
|
|
||||||
|
var choice = resolution.Trim().ToLowerInvariant();
|
||||||
|
return choice switch
|
||||||
|
{
|
||||||
|
"ours" => seg.Ours,
|
||||||
|
"theirs" => seg.Theirs,
|
||||||
|
"base" => seg.Base ?? throw new InvalidOperationException(
|
||||||
|
$"Hunk {index} in '{file}' has no diff3 base to resolve to."),
|
||||||
|
_ => resolution,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await _merge.WriteConflictFileAsync(taskId, file, composed, cancellationToken);
|
||||||
|
|
||||||
|
var refreshed = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
|
||||||
|
var remaining = refreshed.Files.Sum(f => f.Segments.Count(s => s.IsConflict));
|
||||||
|
return new ResolveConflictHunkResultDto(true, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ConflictFileHunksDto ToConflictFileHunksDto(ConflictDocumentContent f)
|
||||||
|
{
|
||||||
|
var hunks = new List<ConflictHunkDto>();
|
||||||
|
foreach (var seg in f.Segments)
|
||||||
|
{
|
||||||
|
if (!seg.IsConflict) continue;
|
||||||
|
hunks.Add(new ConflictHunkDto(hunks.Count, seg.StartLine, seg.Ours, seg.Base, seg.Theirs));
|
||||||
|
}
|
||||||
|
return new ConflictFileHunksDto(f.Path, f.IsBinary, hunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-renders a still-unresolved hunk as valid (unlabeled) conflict markers so it round-trips through
|
||||||
|
// ConflictMarkerParser.Parse again untouched, and git still refuses continue_merge while it stands. The
|
||||||
|
// original marker labels (e.g. "<<<<<<< HEAD") aren't recoverable from a parsed MergeSegment and aren't
|
||||||
|
// needed for either check -- ConflictMarkerParser.IsMarker and ContinueMergeAsync's rescan only match the
|
||||||
|
// marker prefixes.
|
||||||
|
private static string ReconstructConflictMarkers(MergeSegment seg)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append("<<<<<<<\n").Append(seg.Ours);
|
||||||
|
if (seg.Base is not null)
|
||||||
|
sb.Append("|||||||\n").Append(seg.Base);
|
||||||
|
sb.Append("=======\n").Append(seg.Theirs);
|
||||||
|
sb.Append(">>>>>>>\n");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Check whether a task would merge cleanly before touching anything — `git merge-tree --write-tree`, so the " +
|
||||||
|
"working tree, index and HEAD are untouched. status is 'clean', 'conflict' (conflictFiles lists where git " +
|
||||||
|
"would stop), or 'untracked_collision' (conflictFiles lists a path the branch adds that already exists, " +
|
||||||
|
"untracked, in the target working directory — merge-tree can't see the working tree at all, so this is the " +
|
||||||
|
"only way to catch it before a real merge either refuses or, if that path became tracked in the meantime, " +
|
||||||
|
"silently overwrites it); behind counts commits on targetBranch not yet on this branch, which flags a stale " +
|
||||||
|
"branch even when the preview is clean. If the list has a verify command configured, a clean preview is " +
|
||||||
|
"additionally built/tested in a scratch worktree (still without touching the real working tree) — " +
|
||||||
|
"verifyExitCode 0 means it built clean, non-zero or -1 (timeout/failed to start) means it doesn't, with the " +
|
||||||
|
"tail of its output in verifyOutputTail; verifyExitCode stays null when no verify command is configured. " +
|
||||||
|
"isEmpty=true means the task's review range contributed nothing; check that flag rather than reading a " +
|
||||||
|
"small changedFileCount as empty. staleFiles lists files this branch touches that the target branch ALSO " +
|
||||||
|
"changed since this branch's fork point — a more honest staleness signal than `behind` alone, since a " +
|
||||||
|
"branch can be far behind yet touch nothing the target changed, or barely behind yet collide on the one " +
|
||||||
|
"file that matters (always empty for a worktree-less handler task, which has no fork point). Throws if the " +
|
||||||
|
"task has neither an active worktree nor a handler commit range, or the list's working directory is missing " +
|
||||||
|
"from disk." + McpToolDocs.ProgressHint)]
|
||||||
|
public async Task<MergePreviewToolDto> PreviewMerge(
|
||||||
|
string taskId,
|
||||||
|
[Description("Branch to preview against; defaults to the repo's current branch.")]
|
||||||
|
string? targetBranch = null,
|
||||||
|
CancellationToken cancellationToken = default,
|
||||||
|
IProgress<ProgressNotificationValue>? progress = null)
|
||||||
|
{
|
||||||
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||||
|
var (preview, behind, _, isEmpty, staleFiles, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, cancellationToken, progress);
|
||||||
|
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty,
|
||||||
|
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Plan a batch merge: preview_merge for several tasks against the same targetBranch, plus a file-overlap " +
|
||||||
|
"check between them. Per entry you get preview_merge's fields, or error instead when that task could not " +
|
||||||
|
"be previewed (it is then left out of the overlap/subset computation). overlaps names, for each file " +
|
||||||
|
"touched by MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields no " +
|
||||||
|
"overlaps. subsets flags a stronger relation: TaskId's changed files are a PROPER subset of " +
|
||||||
|
"SupersetTaskId's — the strongest hint you get post-hoc that TaskId may be redundant with SupersetTaskId, " +
|
||||||
|
"worth checking before merging both. IMPORTANT: neither overlap nor subset is a safety guarantee — two " +
|
||||||
|
"tasks touching entirely different files (one deleting a symbol, another still referencing it) can still " +
|
||||||
|
"collide unflagged, and as with preview_merge a clean result does not mean the merge builds. " +
|
||||||
|
"runVerify=false (default) never builds — set it true to also run each task's list's verify command in a " +
|
||||||
|
"scratch worktree per entry (same fields as preview_merge); this can take a long time across many tasks, " +
|
||||||
|
"since builds run one at a time." + McpToolDocs.ProgressHint)]
|
||||||
|
public async Task<MergePreviewSetResultDto> PreviewMergeSet(
|
||||||
|
IReadOnlyList<string> taskIds,
|
||||||
|
[Description("Branch to preview every task against; defaults to the repo's current branch.")]
|
||||||
|
string? targetBranch = null,
|
||||||
|
[Description("true: also run the verify command (if configured) for each task, one build at a time. " +
|
||||||
|
"false (default): no builds, however many tasks are given.")]
|
||||||
|
bool runVerify = false,
|
||||||
|
CancellationToken cancellationToken = default,
|
||||||
|
IProgress<ProgressNotificationValue>? progress = null)
|
||||||
|
{
|
||||||
|
if (taskIds is null || taskIds.Count == 0)
|
||||||
|
throw new InvalidOperationException("taskIds must contain at least one task id.");
|
||||||
|
|
||||||
|
taskIds = await TaskIdResolver.ResolveManyAsync(_tasks, taskIds, cancellationToken);
|
||||||
|
|
||||||
|
var entries = new List<MergePreviewSetEntryDto>();
|
||||||
|
var filesByTask = new Dictionary<string, IReadOnlyList<string>>();
|
||||||
|
var numbersByTask = new Dictionary<string, int>();
|
||||||
|
|
||||||
|
for (var i = 0; i < taskIds.Count; i++)
|
||||||
|
{
|
||||||
|
var taskId = taskIds[i];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (preview, behind, changedFiles, isEmpty, staleFiles, number) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
|
||||||
|
entries.Add(new MergePreviewSetEntryDto(
|
||||||
|
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty,
|
||||||
|
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number));
|
||||||
|
filesByTask[taskId] = changedFiles;
|
||||||
|
numbersByTask[taskId] = number;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken);
|
||||||
|
entries.Add(new MergePreviewSetEntryDto(
|
||||||
|
taskId, TaskMergeService.PreviewUnavailable, Array.Empty<string>(), 0, 0, ex.Message,
|
||||||
|
Number: task?.Number));
|
||||||
|
}
|
||||||
|
|
||||||
|
ProgressReporter.ReportItem(progress, "Previewing merges", i + 1, taskIds.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 =>
|
||||||
|
{
|
||||||
|
var ids = g.Select(x => x.TaskId).Distinct().ToList();
|
||||||
|
return new FileOverlapDto(g.Key, ids, ids.Select(id => numbersByTask[id]).ToList());
|
||||||
|
})
|
||||||
|
.OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var subsets = FindSubsetRelations(filesByTask, numbersByTask);
|
||||||
|
|
||||||
|
return new MergePreviewSetResultDto(entries, overlaps, subsets);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A task's changed-file set is flagged only against another task's STRICTLY LARGER set that
|
||||||
|
// contains every one of its files -- an empty set (isEmpty task) never qualifies, since that
|
||||||
|
// case is already surfaced via IsEmpty and would otherwise match every other task trivially.
|
||||||
|
private static List<SubsetRelationDto> FindSubsetRelations(
|
||||||
|
Dictionary<string, IReadOnlyList<string>> filesByTask, Dictionary<string, int> numbersByTask)
|
||||||
|
{
|
||||||
|
var fileSets = filesByTask.ToDictionary(
|
||||||
|
kv => kv.Key,
|
||||||
|
kv => new HashSet<string>(kv.Value, StringComparer.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
var subsets = new List<SubsetRelationDto>();
|
||||||
|
foreach (var (taskId, fileSet) in fileSets)
|
||||||
|
{
|
||||||
|
if (fileSet.Count == 0) continue;
|
||||||
|
foreach (var (otherTaskId, otherFileSet) in fileSets)
|
||||||
|
{
|
||||||
|
if (otherTaskId == taskId || otherFileSet.Count <= fileSet.Count) continue;
|
||||||
|
if (fileSet.IsSubsetOf(otherFileSet))
|
||||||
|
subsets.Add(new SubsetRelationDto(taskId, numbersByTask[taskId], otherTaskId, numbersByTask[otherTaskId]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return subsets
|
||||||
|
.OrderBy(s => s.TaskId, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(s => s.SupersetTaskId, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, plus
|
||||||
|
// `isEmpty`. A worktree-less list-handler host task has no branch to merge-tree-preview at all
|
||||||
|
// (its commits already sit on the list's working dir) — falls back to the fixed
|
||||||
|
// HandlerBaseCommit..HandlerHeadCommit range, reporting a synthetic "clean" preview of that
|
||||||
|
// range's own diff-stat instead of throwing "has no worktree".
|
||||||
|
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty, IReadOnlyList<string> StaleFiles, int Number)> PreviewMergeCoreAsync(
|
||||||
|
string taskId, string? targetBranch, bool runVerify, CancellationToken ct,
|
||||||
|
IProgress<ProgressNotificationValue>? progress = null)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (wt is not null)
|
||||||
|
{
|
||||||
|
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 ?? "", runVerify, ct, progress);
|
||||||
|
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 WorktreeMcpTools.GitRevListCountAsync(list.WorkingDir, $"{wt.BranchName}..{target}", ct);
|
||||||
|
|
||||||
|
var changedFiles = Directory.Exists(wt.Path)
|
||||||
|
? ExternalMcpService.ParseDiffStatFileNames(await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", ct: ct))
|
||||||
|
: Array.Empty<string>();
|
||||||
|
|
||||||
|
// What the target itself picked up since this branch's fork point, so `behind`
|
||||||
|
// (a commit count) doesn't have to stand in for "does this collide" -- a branch can
|
||||||
|
// be far behind but touch nothing the target changed, or close behind and collide on
|
||||||
|
// the one file that matters.
|
||||||
|
var targetChangedSinceFork = await _git.GetChangedFileNamesAsync(list.WorkingDir, wt.BaseCommit, target, ct);
|
||||||
|
var staleFiles = changedFiles
|
||||||
|
.Intersect(targetChangedSinceFork, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
|
||||||
|
throw new InvalidOperationException("The list's working directory no longer exists.");
|
||||||
|
|
||||||
|
var isEmpty = string.Equals(handlerBase, handlerHead, StringComparison.Ordinal);
|
||||||
|
var changedFiles = isEmpty
|
||||||
|
? Array.Empty<string>()
|
||||||
|
: ExternalMcpService.ParseDiffStatFileNames(await _git.DiffStatAsync(list.WorkingDir, handlerBase, handlerHead, ct: ct));
|
||||||
|
|
||||||
|
var preview = new MergePreviewResult(TaskMergeService.PreviewClean, Array.Empty<string>(), changedFiles.Count);
|
||||||
|
// No fork point to diff against: a handler task commits straight onto the list's
|
||||||
|
// working dir instead of a branch, so there is nothing else that could have "changed
|
||||||
|
// in the target since the fork".
|
||||||
|
return (preview, 0, changedFiles, isEmpty, Array.Empty<string>(), task.Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"Task {taskId} has no worktree.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Undo a merged task by reverting its merge commit — `git revert -m 1`, always a new commit and never a " +
|
||||||
|
"reset/rewrite, since the target working directory is shared with other sessions. Requires the task to be " +
|
||||||
|
"Done with a Merged worktree that has a recorded merge commit (check get_task_worktree's mergeCommit " +
|
||||||
|
"first). On success the task returns to WaitingForReview so it can be reconsidered. On conflict the revert " +
|
||||||
|
"is aborted immediately and conflicts lists the files. Throws if there is no recorded merge commit, the " +
|
||||||
|
"repo is mid-merge/mid-revert, or the target working tree has uncommitted changes from another session.")]
|
||||||
|
public async Task<RevertMergeResultDto> RevertMerge(
|
||||||
|
string taskId,
|
||||||
|
[Description("Branch carrying the merge commit; defaults to main.")]
|
||||||
|
string targetBranch = "main",
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||||
|
var result = await _merge.RevertMergeAsync(taskId, targetBranch, cancellationToken);
|
||||||
|
|
||||||
|
if (result.Status == TaskMergeService.StatusReverted)
|
||||||
|
{
|
||||||
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
|
return new RevertMergeResultDto(true, result.RevertCommit, Array.Empty<string>(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.Status == TaskMergeService.StatusConflictAborted)
|
||||||
|
return new RevertMergeResultDto(false, null, result.ConflictFiles, result.ErrorMessage);
|
||||||
|
|
||||||
|
throw new InvalidOperationException(result.ErrorMessage ?? $"Revert blocked: {result.Status}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+276
@@ -0,0 +1,276 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Git;
|
||||||
|
using ClaudeDo.Worker.Worktrees;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -345,6 +345,8 @@ if (cfg.ExternalMcpPort > 0)
|
|||||||
externalBuilder.Services.AddSingleton<HandoffRoundTracker>();
|
externalBuilder.Services.AddSingleton<HandoffRoundTracker>();
|
||||||
externalBuilder.Services.AddScoped<FindingsStoreLocator>();
|
externalBuilder.Services.AddScoped<FindingsStoreLocator>();
|
||||||
externalBuilder.Services.AddScoped<ExternalMcpService>();
|
externalBuilder.Services.AddScoped<ExternalMcpService>();
|
||||||
|
externalBuilder.Services.AddScoped<MergeMcpTools>();
|
||||||
|
externalBuilder.Services.AddScoped<WorktreeMcpTools>();
|
||||||
externalBuilder.Services.AddScoped<BatchMcpTools>();
|
externalBuilder.Services.AddScoped<BatchMcpTools>();
|
||||||
externalBuilder.Services.AddScoped<ListMcpTools>();
|
externalBuilder.Services.AddScoped<ListMcpTools>();
|
||||||
externalBuilder.Services.AddScoped<ConfigMcpTools>();
|
externalBuilder.Services.AddScoped<ConfigMcpTools>();
|
||||||
@@ -363,6 +365,8 @@ if (cfg.ExternalMcpPort > 0)
|
|||||||
.WithHttpTransport(o => o.Stateless = true)
|
.WithHttpTransport(o => o.Stateless = true)
|
||||||
.WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap))
|
.WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap))
|
||||||
.WithTools<ExternalMcpService>()
|
.WithTools<ExternalMcpService>()
|
||||||
|
.WithTools<MergeMcpTools>()
|
||||||
|
.WithTools<WorktreeMcpTools>()
|
||||||
.WithTools<BatchMcpTools>()
|
.WithTools<BatchMcpTools>()
|
||||||
.WithTools<ListMcpTools>()
|
.WithTools<ListMcpTools>()
|
||||||
.WithTools<ConfigMcpTools>()
|
.WithTools<ConfigMcpTools>()
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ public sealed class AddSubtaskToolTests : IDisposable
|
|||||||
var queue = new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state, runCancels,
|
var queue = new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state, runCancels,
|
||||||
new FakeUsageGate(), usageState, broadcaster,
|
new FakeUsageGate(), usageState, broadcaster,
|
||||||
new UsageLimitAutoContinueCoordinator(dbFactory, usageState, overrideSlot, broadcaster, NullLogger<UsageLimitAutoContinueCoordinator>.Instance));
|
new UsageLimitAutoContinueCoordinator(dbFactory, usageState, overrideSlot, broadcaster, NullLogger<UsageLimitAutoContinueCoordinator>.Instance));
|
||||||
var maintenance = new WorktreeMaintenanceService(dbFactory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
|
||||||
var merge = new TaskMergeService(dbFactory, git, broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
|
var merge = new TaskMergeService(dbFactory, git, broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
|
||||||
var aggregator = new PlanningAggregator(dbFactory, git, NullLogger<PlanningAggregator>.Instance);
|
var aggregator = new PlanningAggregator(dbFactory, git, NullLogger<PlanningAggregator>.Instance);
|
||||||
var planningMerge = new PlanningMergeOrchestrator(
|
var planningMerge = new PlanningMergeOrchestrator(
|
||||||
@@ -89,7 +88,7 @@ public sealed class AddSubtaskToolTests : IDisposable
|
|||||||
return new ExternalMcpService(
|
return new ExternalMcpService(
|
||||||
_tasks, _lists, queue, broadcaster,
|
_tasks, _lists, queue, broadcaster,
|
||||||
state,
|
state,
|
||||||
git, dbFactory, maintenance, merge, planningMerge,
|
git, dbFactory, merge, planningMerge,
|
||||||
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
|
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ public sealed class BatchMcpToolsTests : IDisposable
|
|||||||
{
|
{
|
||||||
var git = new GitService();
|
var git = new GitService();
|
||||||
var factory = _db.CreateFactory();
|
var factory = _db.CreateFactory();
|
||||||
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
|
||||||
var state = TaskStateServiceBuilder.Build(factory).State;
|
var state = TaskStateServiceBuilder.Build(factory).State;
|
||||||
var merge = new TaskMergeService(factory, git, _broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
|
var merge = new TaskMergeService(factory, git, _broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
|
||||||
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
|
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
|
||||||
@@ -88,7 +87,7 @@ public sealed class BatchMcpToolsTests : IDisposable
|
|||||||
var svc = new ExternalMcpService(
|
var svc = new ExternalMcpService(
|
||||||
_tasks, _lists, CreateQueue(), _broadcaster,
|
_tasks, _lists, CreateQueue(), _broadcaster,
|
||||||
state,
|
state,
|
||||||
git, factory, maintenance, merge, planningMerge,
|
git, factory, merge, planningMerge,
|
||||||
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
|
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
|
||||||
return new BatchMcpTools(svc);
|
return new BatchMcpTools(svc);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1430
File diff suppressed because it is too large
Load Diff
@@ -57,14 +57,13 @@ public sealed class LifecycleMcpToolsTests : IDisposable
|
|||||||
var wtManager = new WorktreeManager(git, dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
|
var wtManager = new WorktreeManager(git, dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
|
||||||
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||||
var reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger<TaskResetService>.Instance);
|
var reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger<TaskResetService>.Instance);
|
||||||
var maintenance = new WorktreeMaintenanceService(dbFactory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
|
||||||
var merge = new TaskMergeService(dbFactory, git, broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
|
var merge = new TaskMergeService(dbFactory, git, broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
|
||||||
var aggregator = new PlanningAggregator(dbFactory, git, NullLogger<PlanningAggregator>.Instance);
|
var aggregator = new PlanningAggregator(dbFactory, git, NullLogger<PlanningAggregator>.Instance);
|
||||||
var planningMerge = new PlanningMergeOrchestrator(
|
var planningMerge = new PlanningMergeOrchestrator(
|
||||||
dbFactory, merge, aggregator, broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
dbFactory, merge, aggregator, broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
||||||
var svc = new ExternalMcpService(
|
var svc = new ExternalMcpService(
|
||||||
_tasks, _lists, CreateQueue(dbFactory, wtManager, state, broadcaster), broadcaster,
|
_tasks, _lists, CreateQueue(dbFactory, wtManager, state, broadcaster), broadcaster,
|
||||||
state, git, dbFactory, maintenance, merge, planningMerge,
|
state, git, dbFactory, merge, planningMerge,
|
||||||
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
|
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
|
||||||
var reviewSubmission = new InteractiveReviewSubmissionService(dbFactory, git, state, wtManager);
|
var reviewSubmission = new InteractiveReviewSubmissionService(dbFactory, git, state, wtManager);
|
||||||
return new LifecycleMcpTools(_tasks, reset, svc, reviewSubmission);
|
return new LifecycleMcpTools(_tasks, reset, svc, reviewSubmission);
|
||||||
|
|||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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()))
|
||||||
@@ -0,0 +1,996 @@
|
|||||||
|
[Fact]
|
||||||
|
public async Task MergeTask_NotDone_Throws()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync();
|
||||||
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None));
|
||||||
|
Assert.Contains("Done", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergeTask_DryRun_DoesNotMerge()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.Merged);
|
||||||
|
Assert.Null(result.MergeCommit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergeTask_WaitingForReview_WithoutFlag_Throws()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync();
|
||||||
|
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None));
|
||||||
|
Assert.Contains("Done", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergeTask_WaitingForReview_WithFlag_DryRun_Succeeds()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, allowWaitingForReview: true, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.Merged);
|
||||||
|
Assert.Null(result.MergeCommit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergeTask_WaitingForReview_WithFlag_MarksTaskDone()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
|
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 target = await new GitService().GetCurrentBranchAsync(list.WorkingDir, CancellationToken.None);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var result = await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Merged);
|
||||||
|
var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.Done, reloaded!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergeTask_LeaveConflictsInTree_LeavesMarkersAndKeepsRepoMidMerge()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var result = await sut.MergeTask(task.Id, target, true, dryRun: false,
|
||||||
|
allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.Merged);
|
||||||
|
Assert.True(result.ConflictsInTree);
|
||||||
|
Assert.Equal(list.WorkingDir, result.RepoPath);
|
||||||
|
Assert.Contains("README.md", result.Conflicts);
|
||||||
|
Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
|
||||||
|
Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
||||||
|
var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status);
|
||||||
|
|
||||||
|
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergeTask_ConflictWithoutLeaveFlag_AbortsCleanly()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var result = await sut.MergeTask(task.Id, target, true, dryRun: false,
|
||||||
|
allowWaitingForReview: true, leaveConflictsInTree: false, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.Merged);
|
||||||
|
Assert.False(result.ConflictsInTree);
|
||||||
|
Assert.Null(result.RepoPath);
|
||||||
|
Assert.Contains("README.md", result.Conflicts);
|
||||||
|
Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
|
||||||
|
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContinueMerge_AfterResolvingMarkers_MergesAndSetsDone()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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 result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Merged);
|
||||||
|
Assert.Equal("Done", result.TaskStatus);
|
||||||
|
Assert.Empty(result.Conflicts);
|
||||||
|
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContinueMerge_SlowVerifyCommand_ReportsProgressWellBeforeIdleTimeout()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var original = TaskMergeService.ProgressReportInterval;
|
||||||
|
TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
|
var plainSut = BuildSut(CreateQueue());
|
||||||
|
await plainSut.ReviewTask(task.Id, "approve", null, null,
|
||||||
|
leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n");
|
||||||
|
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
|
||||||
|
|
||||||
|
using (var ctx = _db.CreateContext())
|
||||||
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });
|
||||||
|
|
||||||
|
var fakeVerify = new FakeVerifyCommandRunner
|
||||||
|
{
|
||||||
|
Result = new VerifyCommandResult(0, false, "ok"),
|
||||||
|
Delay = TimeSpan.FromMilliseconds(300),
|
||||||
|
};
|
||||||
|
var sut = BuildSut(CreateQueue(), fakeVerify);
|
||||||
|
|
||||||
|
var reports = new List<ProgressNotificationValue>();
|
||||||
|
var progress = new Progress<ProgressNotificationValue>(reports.Add);
|
||||||
|
|
||||||
|
var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None, progress: progress);
|
||||||
|
|
||||||
|
Assert.True(result.Merged);
|
||||||
|
Assert.Equal("Done", result.TaskStatus);
|
||||||
|
await Task.Delay(200);
|
||||||
|
Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running"));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TaskMergeService.ProgressReportInterval = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContinueMerge_MarkersStillPresent_ReportsConflicts()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Markers never resolved — continue must refuse and list the files.
|
||||||
|
var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.Merged);
|
||||||
|
Assert.Contains("README.md", result.Conflicts);
|
||||||
|
Assert.Equal(list.WorkingDir, result.RepoPath);
|
||||||
|
Assert.Equal("WaitingForReview", result.TaskStatus);
|
||||||
|
Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
||||||
|
|
||||||
|
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AbortMerge_RestoresCleanTreeAndKeepsWaitingForReview()
|
||||||
|
{
|
||||||
|
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 dto = await sut.AbortMerge(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("WaitingForReview", dto.Status);
|
||||||
|
Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
|
||||||
|
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContinueMerge_NoMergeInProgress_Throws()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None));
|
||||||
|
Assert.Contains("mid-merge", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AbortMerge_NoMergeInProgress_Throws()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => sut.AbortMerge(task.Id, CancellationToken.None));
|
||||||
|
Assert.Contains("mid-merge", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMergeConflicts_ReportsHunksWithStartLineAndDiff3Base()
|
||||||
|
{
|
||||||
|
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 result = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.RemainingHunks);
|
||||||
|
var file = Assert.Single(result.Files);
|
||||||
|
Assert.Equal("README.md", file.Path);
|
||||||
|
Assert.False(file.IsBinary);
|
||||||
|
var hunk = Assert.Single(file.Hunks);
|
||||||
|
Assert.Equal(0, hunk.Index);
|
||||||
|
Assert.True(hunk.StartLine >= 1);
|
||||||
|
Assert.Equal("# from main", hunk.Ours.TrimEnd('\r', '\n'));
|
||||||
|
Assert.Equal("# from worktree", hunk.Theirs.TrimEnd('\r', '\n'));
|
||||||
|
Assert.NotEmpty(result.ConflictStyleNote);
|
||||||
|
|
||||||
|
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMergeConflicts_NoMergeInProgress_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, result.RemainingHunks);
|
||||||
|
Assert.Empty(result.Files);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveConflictHunk_OursTheirsAndText_ResolveEachHunkAndTrackRemainingCount()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync();
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var merge = await sut.MergeTask(task.Id, target, true, dryRun: false,
|
||||||
|
allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||||
|
Assert.True(merge.ConflictsInTree);
|
||||||
|
|
||||||
|
var before = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
||||||
|
Assert.Equal(3, before.RemainingHunks);
|
||||||
|
Assert.Equal(3, Assert.Single(before.Files).Hunks.Count);
|
||||||
|
|
||||||
|
// Resolve hunk 0 three times in a row: each resolution removes a hunk from the front, so the
|
||||||
|
// next still-open hunk always slides into index 0.
|
||||||
|
var r1 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None);
|
||||||
|
Assert.Equal(2, r1.RemainingHunks);
|
||||||
|
|
||||||
|
var r2 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "theirs", CancellationToken.None);
|
||||||
|
Assert.Equal(1, r2.RemainingHunks);
|
||||||
|
|
||||||
|
var r3 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "CUSTOM-15\n", CancellationToken.None);
|
||||||
|
Assert.Equal(0, r3.RemainingHunks);
|
||||||
|
|
||||||
|
var finalConflicts = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
||||||
|
Assert.Equal(0, finalConflicts.RemainingHunks);
|
||||||
|
|
||||||
|
var continueResult = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);
|
||||||
|
Assert.True(continueResult.Merged);
|
||||||
|
Assert.Equal("Done", continueResult.TaskStatus);
|
||||||
|
|
||||||
|
var mergedText = File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"));
|
||||||
|
Assert.Contains("MAIN-3", mergedText); // hunk 1 resolved "ours" (the target branch)
|
||||||
|
Assert.Contains("WT-9", mergedText); // hunk 2 resolved "theirs" (the incoming branch)
|
||||||
|
Assert.Contains("CUSTOM-15", mergedText); // hunk 3 resolved to literal text
|
||||||
|
Assert.DoesNotContain("<<<<<<<", mergedText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveConflictHunk_LeavesOtherHunksUntouchedUntilContinueMerge()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync();
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
await sut.MergeTask(task.Id, target, true, dryRun: false,
|
||||||
|
allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
// Resolve only one of the three hunks.
|
||||||
|
await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None);
|
||||||
|
|
||||||
|
// continue_merge must still refuse: writing one hunk must not have staged the file as resolved.
|
||||||
|
var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.Merged);
|
||||||
|
Assert.Contains("README.md", result.Conflicts);
|
||||||
|
Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
||||||
|
|
||||||
|
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveConflictHunk_UnknownFileOrOutOfRangeIndex_Throws()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => sut.ResolveConflictHunk(task.Id, "not-a-real-file.md", 0, "ours", CancellationToken.None));
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => sut.ResolveConflictHunk(task.Id, "README.md", 5, "ours", CancellationToken.None));
|
||||||
|
|
||||||
|
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RevertMerge_NotMerged_Throws()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => sut.RevertMerge(task.Id, "main", CancellationToken.None));
|
||||||
|
Assert.Contains("expected Merged", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RevertMerge_AfterCleanMerge_RevertsAndReturnsTaskToWaitingForReview()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
Assert.True(File.Exists(Path.Combine(list.WorkingDir!, "added.txt")));
|
||||||
|
|
||||||
|
var result = await sut.RevertMerge(task.Id, target, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Reverted);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(result.RevertCommit));
|
||||||
|
Assert.Empty(result.Conflicts);
|
||||||
|
Assert.False(File.Exists(Path.Combine(list.WorkingDir!, "added.txt")));
|
||||||
|
|
||||||
|
var reloaded = await _tasks.GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RevertMerge_ConflictingRevert_ReturnsRevertedFalseWithConflictsAndAbortsCleanly()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done);
|
||||||
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "README.md"), "# test repo\nfeature\n");
|
||||||
|
GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A");
|
||||||
|
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: append feature line");
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// A later commit on main edits the exact line the merge introduced, so the revert conflicts.
|
||||||
|
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# test repo\npost-merge edit\n");
|
||||||
|
GitRepoFixture.RunGit(list.WorkingDir!, "add", "-A");
|
||||||
|
GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "chore: edit the same line");
|
||||||
|
var headBefore = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim();
|
||||||
|
|
||||||
|
var result = await sut.RevertMerge(task.Id, target, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.Reverted);
|
||||||
|
Assert.Contains("README.md", result.Conflicts);
|
||||||
|
Assert.False(await new GitService().IsMidRevertAsync(list.WorkingDir!));
|
||||||
|
Assert.Equal(headBefore, GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim());
|
||||||
|
|
||||||
|
var reloaded = await _tasks.GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.Done, reloaded!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContinueMerge_ParentUnitMergeConflict_RoutesToOrchestratorAndCompletes()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = new GitRepoFixture();
|
||||||
|
_repos.Add(repo);
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
|
||||||
|
// main edits README.md so the child's edit conflicts.
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "README.md");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
|
||||||
|
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
var parentId = Guid.NewGuid().ToString();
|
||||||
|
var childId = Guid.NewGuid().ToString();
|
||||||
|
using (var ctx = _db.CreateContext())
|
||||||
|
{
|
||||||
|
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||||
|
ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
|
||||||
|
Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized, Number = ++_numberSeed });
|
||||||
|
ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
|
||||||
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed });
|
||||||
|
|
||||||
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||||
|
_worktreeCleanups.Add((repo.RepoDir, wtPath));
|
||||||
|
var branch = $"claudedo/{childId[..8]}";
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
|
||||||
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# child change\n");
|
||||||
|
GitRepoFixture.RunGit(wtPath, "add", "README.md");
|
||||||
|
GitRepoFixture.RunGit(wtPath, "commit", "-m", "child edit");
|
||||||
|
ctx.Worktrees.Add(new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = childId, Path = wtPath, BranchName = branch,
|
||||||
|
BaseCommit = repo.BaseCommit,
|
||||||
|
HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
|
||||||
|
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
var review = await sut.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);
|
||||||
|
|
||||||
|
// Resolve the conflict on disk.
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");
|
||||||
|
|
||||||
|
var result = await sut.ContinueMerge(parentId, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Merged);
|
||||||
|
Assert.Equal("Done", result.TaskStatus);
|
||||||
|
Assert.Empty(result.Conflicts);
|
||||||
|
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
||||||
|
using var verify = _db.CreateContext();
|
||||||
|
Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMerge_CleanMerge_ReturnsCleanStatusAndDoesNotChangeRepo()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
|
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 git = new GitService();
|
||||||
|
var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
||||||
|
var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
||||||
|
Assert.Empty(result.ConflictFiles);
|
||||||
|
Assert.Equal(1, result.ChangedFileCount);
|
||||||
|
Assert.Equal(0, result.Behind);
|
||||||
|
Assert.False(result.IsEmpty);
|
||||||
|
Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None));
|
||||||
|
Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMerge_EmptyWorktree_ReturnsIsEmptyTrue()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
// Worktree created but nothing committed beyond base -- e.g. a child that reported
|
||||||
|
// CLAUDEDO_BLOCKED before writing any code.
|
||||||
|
var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
||||||
|
Assert.Equal(0, result.ChangedFileCount);
|
||||||
|
Assert.True(result.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMerge_WorktreeLessHandlerTask_EmptyRange_ReturnsIsEmptyTrue()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
// Worktree-less list-handler host task whose handler range never actually committed
|
||||||
|
// anything: HandlerBaseCommit == HandlerHeadCommit, not "no worktree -> unknown".
|
||||||
|
using (var ctx = _db.CreateContext())
|
||||||
|
{
|
||||||
|
var t = await ctx.Tasks.FindAsync(task.Id);
|
||||||
|
t!.HandlerBaseCommit = repo.BaseCommit;
|
||||||
|
t.HandlerHeadCommit = repo.BaseCommit;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
||||||
|
Assert.Equal(0, result.ChangedFileCount);
|
||||||
|
Assert.True(result.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMerge_WorktreeLessHandlerTask_NonEmptyRange_ReturnsIsEmptyFalse()
|
||||||
|
{
|
||||||
|
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 result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
||||||
|
Assert.Equal(1, result.ChangedFileCount);
|
||||||
|
Assert.False(result.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMerge_ConflictingBranches_ReturnsConflictStatusAndDoesNotChangeRepo()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
|
var git = new GitService();
|
||||||
|
var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
||||||
|
var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.PreviewConflict, result.Status);
|
||||||
|
Assert.Contains("README.md", result.ConflictFiles);
|
||||||
|
Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None));
|
||||||
|
Assert.False(await git.IsMidMergeAsync(list.WorkingDir!));
|
||||||
|
Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMerge_NoWorktree_Throws()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync();
|
||||||
|
var task = await SeedTaskAsync(listId);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => sut.PreviewMerge(task.Id, null, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMerge_MissingWorkingDir_ThrowsClearError()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
|
using (var ctx = _db.CreateContext())
|
||||||
|
{
|
||||||
|
var row = await ctx.Lists.FindAsync(list.Id);
|
||||||
|
row!.WorkingDir = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}");
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => sut.PreviewMerge(task.Id, null, CancellationToken.None));
|
||||||
|
Assert.Contains("working directory", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMerge_VerifyCommandConfigured_RunsInScratchWorktreeAndDoesNotTouchTarget()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
|
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);
|
||||||
|
|
||||||
|
using (var ctx = _db.CreateContext())
|
||||||
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });
|
||||||
|
|
||||||
|
var git = new GitService();
|
||||||
|
var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
||||||
|
var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
|
||||||
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
|
||||||
|
var sut = BuildSut(CreateQueue(), fakeVerify);
|
||||||
|
|
||||||
|
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
||||||
|
Assert.Equal(0, result.VerifyExitCode);
|
||||||
|
Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
|
||||||
|
Assert.NotEqual(list.WorkingDir, fakeVerify.CapturedWorkingDir);
|
||||||
|
Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None));
|
||||||
|
Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMerge_TargetChangedSameFileSinceFork_ReportsStaleFiles()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = new GitRepoFixture();
|
||||||
|
_repos.Add(repo);
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
|
||||||
|
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, "a", TaskStatus.WaitingForReview);
|
||||||
|
await CreateActiveWorktreeAsync(repo, task.Id, "shared.txt", "from branch\n");
|
||||||
|
|
||||||
|
// The target branch itself moved on and touched the same file after the fork point.
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "shared.txt"), "from main\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "shared.txt");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main also touched shared.txt");
|
||||||
|
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.Behind);
|
||||||
|
Assert.NotNull(result.StaleFiles);
|
||||||
|
Assert.Contains("shared.txt", result.StaleFiles!);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMerge_TargetBehindButUnrelatedFile_ReportsNoStaleFiles()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = new GitRepoFixture();
|
||||||
|
_repos.Add(repo);
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
|
||||||
|
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, "a", TaskStatus.WaitingForReview);
|
||||||
|
await CreateActiveWorktreeAsync(repo, task.Id, "branch-only.txt", "from branch\n");
|
||||||
|
|
||||||
|
// Target moved on (behind > 0) but touched an entirely different file -- a stale
|
||||||
|
// branch that still collides with nothing.
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "unrelated.txt"), "from main\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "unrelated.txt");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main touched something else");
|
||||||
|
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.Behind);
|
||||||
|
Assert.NotNull(result.StaleFiles);
|
||||||
|
Assert.Empty(result.StaleFiles!);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMerge_WorktreeLessHandlerTask_ReportsNoStaleFiles()
|
||||||
|
{
|
||||||
|
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 result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(result.StaleFiles);
|
||||||
|
Assert.Empty(result.StaleFiles!);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMergeSet_TwoTasksSharedFile_ReportsOverlap()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = new GitRepoFixture();
|
||||||
|
_repos.Add(repo);
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||||
|
var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
||||||
|
var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);
|
||||||
|
|
||||||
|
await CreateActiveWorktreeAsync(repo, taskA.Id, "shared.txt", "a change\n");
|
||||||
|
await CreateActiveWorktreeAsync(repo, taskB.Id, "shared.txt", "b change\n");
|
||||||
|
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Tasks.Count);
|
||||||
|
Assert.All(result.Tasks, t => Assert.Null(t.Error));
|
||||||
|
Assert.All(result.Tasks, t => Assert.Equal(TaskMergeService.PreviewClean, t.Status));
|
||||||
|
var overlap = Assert.Single(result.Overlaps);
|
||||||
|
Assert.Equal("shared.txt", overlap.File);
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { taskA.Id, taskB.Id }.OrderBy(x => x),
|
||||||
|
overlap.TaskIds.OrderBy(x => x));
|
||||||
|
// Equal file sets overlap but neither is a PROPER subset of the other.
|
||||||
|
Assert.Empty(result.Subsets);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMergeSet_TaskFilesAreProperSubsetOfAnother_ReportsSubset()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = new GitRepoFixture();
|
||||||
|
_repos.Add(repo);
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||||
|
var subsetTask = await SeedTaskAsync(listId, "subset", TaskStatus.WaitingForReview);
|
||||||
|
var supersetTask = await SeedTaskAsync(listId, "superset", TaskStatus.WaitingForReview);
|
||||||
|
|
||||||
|
await CreateActiveWorktreeAsync(repo, subsetTask.Id, "shared.txt", "shared change\n");
|
||||||
|
|
||||||
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||||
|
_worktreeCleanups.Add((repo.RepoDir, wtPath));
|
||||||
|
var branch = $"claudedo/{supersetTask.Id[..8]}";
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
|
||||||
|
File.WriteAllText(Path.Combine(wtPath, "shared.txt"), "shared change too\n");
|
||||||
|
File.WriteAllText(Path.Combine(wtPath, "extra.txt"), "extra\n");
|
||||||
|
GitRepoFixture.RunGit(wtPath, "add", "-A");
|
||||||
|
GitRepoFixture.RunGit(wtPath, "commit", "-m", "edit shared+extra");
|
||||||
|
using (var ctx = _db.CreateContext())
|
||||||
|
{
|
||||||
|
ctx.Worktrees.Add(new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = supersetTask.Id, Path = wtPath, BranchName = branch,
|
||||||
|
BaseCommit = repo.BaseCommit,
|
||||||
|
HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
|
||||||
|
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
var result = await sut.PreviewMergeSet([subsetTask.Id, supersetTask.Id], "main", cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
var relation = Assert.Single(result.Subsets);
|
||||||
|
Assert.Equal(subsetTask.Id, relation.TaskId);
|
||||||
|
Assert.Equal(supersetTask.Id, relation.SupersetTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMergeSet_SingleTaskId_ReturnsEmptyOverlap()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var result = await sut.PreviewMergeSet([task.Id], target, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Single(result.Tasks);
|
||||||
|
Assert.Empty(result.Overlaps);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMergeSet_ReportsProgressPerTask()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync();
|
||||||
|
var t1 = await SeedTaskAsync(listId, "a");
|
||||||
|
var t2 = await SeedTaskAsync(listId, "b");
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
var progress = new SyncProgress<ProgressNotificationValue>();
|
||||||
|
|
||||||
|
// Neither task has a worktree -- each entry fails individually (PreviewUnavailable), but
|
||||||
|
// the loop still reports progress per task since a per-task InvalidOperationException
|
||||||
|
// doesn't abort the batch.
|
||||||
|
await sut.PreviewMergeSet(
|
||||||
|
[t1.Id, t2.Id], "main", cancellationToken: CancellationToken.None, progress: progress);
|
||||||
|
|
||||||
|
Assert.Equal(2, progress.Reports.Count);
|
||||||
|
Assert.Equal("Previewing merges (1/2)", progress.Reports[0].Message);
|
||||||
|
Assert.Equal("Previewing merges (2/2)", progress.Reports[1].Message);
|
||||||
|
Assert.Equal(2, progress.Reports[1].Total);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMergeSet_OneTaskWithoutWorktree_ReportsErrorWithoutAbortingOthers()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
|
var listId2 = await SeedListAsync("no-worktree-list");
|
||||||
|
var noWorktreeTask = await SeedTaskAsync(listId2, "b", TaskStatus.Idle);
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var result = await sut.PreviewMergeSet([task.Id, noWorktreeTask.Id], target, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Tasks.Count);
|
||||||
|
var ok = result.Tasks.Single(t => t.TaskId == task.Id);
|
||||||
|
Assert.Null(ok.Error);
|
||||||
|
Assert.Equal(TaskMergeService.PreviewClean, ok.Status);
|
||||||
|
var failed = result.Tasks.Single(t => t.TaskId == noWorktreeTask.Id);
|
||||||
|
Assert.NotNull(failed.Error);
|
||||||
|
Assert.Equal(TaskMergeService.PreviewUnavailable, failed.Status);
|
||||||
|
Assert.Empty(result.Overlaps);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMergeSet_MixedEmptyAndNonEmpty_ReportsIsEmptyPerEntry()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = new GitRepoFixture();
|
||||||
|
_repos.Add(repo);
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||||
|
var emptyTask = await SeedTaskAsync(listId, "empty", TaskStatus.WaitingForReview);
|
||||||
|
var fullTask = await SeedTaskAsync(listId, "full", TaskStatus.WaitingForReview);
|
||||||
|
|
||||||
|
var emptyBranch = $"claudedo/{emptyTask.Id[..8]}";
|
||||||
|
var emptyWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||||
|
_worktreeCleanups.Add((repo.RepoDir, emptyWtPath));
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", emptyBranch, emptyWtPath, repo.BaseCommit);
|
||||||
|
using (var ctx = _db.CreateContext())
|
||||||
|
{
|
||||||
|
ctx.Worktrees.Add(new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = emptyTask.Id, Path = emptyWtPath, BranchName = emptyBranch,
|
||||||
|
BaseCommit = repo.BaseCommit,
|
||||||
|
HeadCommit = repo.BaseCommit,
|
||||||
|
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await CreateActiveWorktreeAsync(repo, fullTask.Id, "full.txt", "content\n");
|
||||||
|
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
var result = await sut.PreviewMergeSet([emptyTask.Id, fullTask.Id], "main", cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
var emptyEntry = result.Tasks.Single(t => t.TaskId == emptyTask.Id);
|
||||||
|
var fullEntry = result.Tasks.Single(t => t.TaskId == fullTask.Id);
|
||||||
|
Assert.Null(emptyEntry.Error);
|
||||||
|
Assert.True(emptyEntry.IsEmpty);
|
||||||
|
Assert.Null(fullEntry.Error);
|
||||||
|
Assert.False(fullEntry.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMergeSet_DefaultRunVerifyFalse_NeverInvokesRunnerEvenWithCommandConfigured()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = new GitRepoFixture();
|
||||||
|
_repos.Add(repo);
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||||
|
using (var ctx = _db.CreateContext())
|
||||||
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" });
|
||||||
|
var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
||||||
|
await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n");
|
||||||
|
|
||||||
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
|
||||||
|
var sut = BuildSut(CreateQueue(), fakeVerify);
|
||||||
|
|
||||||
|
var result = await sut.PreviewMergeSet([task.Id], "main", cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Null(result.Tasks.Single().VerifyExitCode);
|
||||||
|
Assert.Null(fakeVerify.CapturedCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMergeSet_RunVerifyTrue_InvokesRunnerPerTask()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = new GitRepoFixture();
|
||||||
|
_repos.Add(repo);
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||||
|
using (var ctx = _db.CreateContext())
|
||||||
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" });
|
||||||
|
var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
||||||
|
await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n");
|
||||||
|
|
||||||
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
|
||||||
|
var sut = BuildSut(CreateQueue(), fakeVerify);
|
||||||
|
|
||||||
|
var result = await sut.PreviewMergeSet([task.Id], "main", runVerify: true, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, result.Tasks.Single().VerifyExitCode);
|
||||||
|
Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
|
||||||
|
}
|
||||||
+1544
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,331 @@
|
|||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user