- ContentInMain: HEAD ancestor of the default branch or squash-merged
(merge-tree == target tree), against local origin/<default> without fetch;
list_worktrees mergedIntoMain and the overview modal ("merged (external)")
use it, "Cleanup finished" takes clean Active rows that are in main
- cleanup_task_worktree skips the dirty check for a non-git directory
- prune / worktree remove / branch -D run in the repo owning the worktree
(git-common-dir), not the list's working dir; branch already gone reports
branchDeleted=true
534 lines
22 KiB
C#
534 lines
22 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Config;
|
|
using ClaudeDo.Worker.External;
|
|
using ClaudeDo.Worker.Git;
|
|
using ClaudeDo.Worker.Hub;
|
|
using ClaudeDo.Worker.Lifecycle;
|
|
using ClaudeDo.Worker.Planning;
|
|
using ClaudeDo.Worker.Queue;
|
|
using ClaudeDo.Worker.Runner;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using ClaudeDo.Worker.Tests.Services;
|
|
using ClaudeDo.Worker.Usage;
|
|
using ClaudeDo.Worker.Worktrees;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ModelContextProtocol;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.External;
|
|
|
|
// A synchronously-collecting IProgress<T>: Progress<T> marshals through the SynchronizationContext
|
|
// captured at construction, which is unreliable to assert on immediately in a test.
|
|
file sealed class SyncProgress<T> : IProgress<T>
|
|
{
|
|
public readonly List<T> Reports = new();
|
|
public void Report(T value) => Reports.Add(value);
|
|
}
|
|
|
|
public sealed class WorktreeMcpToolsTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly ClaudeDoDbContext _ctx;
|
|
private readonly TaskRepository _tasks;
|
|
private readonly ListRepository _lists;
|
|
private readonly HubBroadcaster _broadcaster;
|
|
private readonly List<GitRepoFixture> _repos = new();
|
|
private readonly List<(string repoDir, string wtPath)> _worktreeCleanups = new();
|
|
private int _numberSeed;
|
|
|
|
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
|
|
|
|
public WorktreeMcpToolsTests()
|
|
{
|
|
_ctx = _db.CreateContext();
|
|
_tasks = new TaskRepository(_ctx);
|
|
_lists = new ListRepository(_ctx);
|
|
_broadcaster = new HubBroadcaster(new CapturingHubContext());
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var (repoDir, wtPath) in _worktreeCleanups)
|
|
{
|
|
try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { }
|
|
}
|
|
foreach (var r in _repos) r.Dispose();
|
|
_ctx.Dispose();
|
|
_db.Dispose();
|
|
}
|
|
|
|
private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedWorktreeAsync(
|
|
TaskStatus status = TaskStatus.Done)
|
|
{
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
|
|
var listId = Guid.NewGuid().ToString();
|
|
var list = new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow };
|
|
await _lists.AddAsync(list);
|
|
var task = await SeedTaskAsync(listId, status: status);
|
|
|
|
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
|
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
|
var wt = await mgr.CreateAsync(task, list, CancellationToken.None);
|
|
_worktreeCleanups.Add((repo.RepoDir, wt.WorktreePath));
|
|
return (task, list, wt);
|
|
}
|
|
|
|
private async Task<string> SeedListAsync(string name = "L")
|
|
{
|
|
var id = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = id, Name = name, CreatedAt = DateTime.UtcNow });
|
|
return id;
|
|
}
|
|
|
|
private async Task<TaskEntity> SeedTaskAsync(
|
|
string listId, string title = "t", TaskStatus status = TaskStatus.Idle, string? parentId = null)
|
|
{
|
|
var task = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = title,
|
|
Status = status,
|
|
ParentTaskId = parentId,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "chore",
|
|
Number = ++_numberSeed,
|
|
};
|
|
await _tasks.AddAsync(task);
|
|
return task;
|
|
}
|
|
|
|
// queue is accepted (and ignored) so every extracted test body can keep calling
|
|
// BuildSut(CreateQueue()) unchanged -- WorktreeMcpTools itself has no QueueService dependency.
|
|
private WorktreeMcpTools BuildSut(QueueService queue)
|
|
{
|
|
var git = new GitService();
|
|
var factory = _db.CreateFactory();
|
|
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
|
return new WorktreeMcpTools(_tasks, git, factory, maintenance);
|
|
}
|
|
|
|
// GetTaskWorktree_AfterMerge_ExposesMergeCommit needs a real merge first -- MergeTask lives on
|
|
// MergeMcpTools now, not here. A fresh instance is fine (TaskMergeService/GitService are
|
|
// git-disk- and DB-backed, no shared in-memory state to worry about for a plain merge).
|
|
private MergeMcpTools BuildMergeSut()
|
|
{
|
|
var git = new GitService();
|
|
var factory = _db.CreateFactory();
|
|
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);
|
|
var planningMerge = new PlanningMergeOrchestrator(
|
|
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
|
return new MergeMcpTools(_tasks, _lists, _broadcaster, git, factory, merge, planningMerge);
|
|
}
|
|
|
|
private QueueService CreateQueue()
|
|
{
|
|
var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_worktree_{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(tempDir);
|
|
var cfg = new WorkerConfig
|
|
{
|
|
SandboxRoot = Path.Combine(tempDir, "sandbox"),
|
|
LogRoot = Path.Combine(tempDir, "logs"),
|
|
QueueBackstopIntervalMs = 50,
|
|
};
|
|
var fake = new FakeClaudeProcess();
|
|
var hubCtx = new CapturingHubContext();
|
|
var broadcaster = new HubBroadcaster(hubCtx);
|
|
var dbFactory = _db.CreateFactory();
|
|
var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
|
|
var argsBuilder = new ClaudeArgsBuilder();
|
|
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
|
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg,
|
|
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
|
|
var waker = new ClaudeDo.Worker.Queue.QueueWaker();
|
|
var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
|
|
var runCancels = new RunCancellationRegistry(NullLogger<RunCancellationRegistry>.Instance);
|
|
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, runCancels);
|
|
var usageState = new UsageState();
|
|
return 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));
|
|
}
|
|
|
|
[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 mergeSut = BuildMergeSut();
|
|
await mergeSut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None);
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
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 = WorktreeMcpTools.ProgressReportInterval;
|
|
WorktreeMcpTools.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
|
|
{
|
|
WorktreeMcpTools.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 = WorktreeMcpTools.ProgressReportInterval;
|
|
WorktreeMcpTools.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
|
|
{
|
|
WorktreeMcpTools.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 = WorktreeMcpTools.ProgressReportInterval;
|
|
WorktreeMcpTools.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
|
|
{
|
|
WorktreeMcpTools.ProgressReportInterval = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CleanupTaskWorktree_EmptyDirectory_RemovesDbRow()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done);
|
|
// Leave an empty, non-git directory behind where the worktree was.
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "worktree", "remove", "--force", wt.WorktreePath);
|
|
Directory.CreateDirectory(wt.WorktreePath);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var rows = await sut.ListWorktrees(CancellationToken.None);
|
|
Assert.Equal("", rows.Single(r => r.TaskId == task.Id).HeadCommit);
|
|
|
|
var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None);
|
|
|
|
Assert.True(result.Removed);
|
|
Assert.False(Directory.Exists(wt.WorktreePath));
|
|
using var ctx = _db.CreateContext();
|
|
Assert.Null(await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListWorktrees_SquashMergedBranch_ReportsMergedIntoMain()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done);
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feature");
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--squash", wt.BranchName);
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "squash");
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var row = (await sut.ListWorktrees(CancellationToken.None)).Single(r => r.TaskId == task.Id);
|
|
|
|
Assert.True(row.ContentInMain);
|
|
Assert.True(row.MergedIntoMain);
|
|
}
|
|
}
|