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:
@@ -81,7 +81,6 @@ public sealed class AddSubtaskToolTests : IDisposable
|
||||
var queue = new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state, runCancels,
|
||||
new FakeUsageGate(), usageState, broadcaster,
|
||||
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 aggregator = new PlanningAggregator(dbFactory, git, NullLogger<PlanningAggregator>.Instance);
|
||||
var planningMerge = new PlanningMergeOrchestrator(
|
||||
@@ -89,7 +88,7 @@ public sealed class AddSubtaskToolTests : IDisposable
|
||||
return new ExternalMcpService(
|
||||
_tasks, _lists, queue, broadcaster,
|
||||
state,
|
||||
git, dbFactory, maintenance, merge, planningMerge,
|
||||
git, dbFactory, merge, planningMerge,
|
||||
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,6 @@ public sealed class BatchMcpToolsTests : IDisposable
|
||||
{
|
||||
var git = new GitService();
|
||||
var factory = _db.CreateFactory();
|
||||
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||
var state = TaskStateServiceBuilder.Build(factory).State;
|
||||
var merge = new TaskMergeService(factory, git, _broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
|
||||
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
|
||||
@@ -88,7 +87,7 @@ public sealed class BatchMcpToolsTests : IDisposable
|
||||
var svc = new ExternalMcpService(
|
||||
_tasks, _lists, CreateQueue(), _broadcaster,
|
||||
state,
|
||||
git, factory, maintenance, merge, planningMerge,
|
||||
git, factory, merge, planningMerge,
|
||||
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
|
||||
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 state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||
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 aggregator = new PlanningAggregator(dbFactory, git, NullLogger<PlanningAggregator>.Instance);
|
||||
var planningMerge = new PlanningMergeOrchestrator(
|
||||
dbFactory, merge, aggregator, broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
||||
var svc = new ExternalMcpService(
|
||||
_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));
|
||||
var reviewSubmission = new InteractiveReviewSubmissionService(dbFactory, git, state, wtManager);
|
||||
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