fix(worktrees): detect external merges, clean up empty dirs, prune in owning repo
- 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
This commit is contained in:
@@ -489,4 +489,45 @@ public sealed class WorktreeMcpToolsTests : IDisposable
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,4 +531,131 @@ public class WorktreeMaintenanceServiceTests : IDisposable
|
||||
Assert.Equal(1, progressCalls[0].Args[2]);
|
||||
Assert.Equal(2, progressCalls[1].Args[2]);
|
||||
}
|
||||
|
||||
private static async Task SeedRowAsync(DbFixture db, ListEntity? list, TaskEntity task, string path, string branch,
|
||||
string baseCommit, WorktreeState state = WorktreeState.Active)
|
||||
{
|
||||
using var ctx = db.CreateContext();
|
||||
if (list is not null) await new ListRepository(ctx).AddAsync(list);
|
||||
await new TaskRepository(ctx).AddAsync(task);
|
||||
await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity
|
||||
{
|
||||
TaskId = task.Id, Path = path, BranchName = branch,
|
||||
BaseCommit = baseCommit, State = state, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
private static void CommitFile(string dir, string file)
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, file), file);
|
||||
GitRepoFixture.RunGit(dir, "add", "-A");
|
||||
GitRepoFixture.RunGit(dir, "commit", "-m", $"add {file}");
|
||||
}
|
||||
|
||||
private static void SquashMerge(string repoDir, string branch)
|
||||
{
|
||||
GitRepoFixture.RunGit(repoDir, "merge", "--squash", branch);
|
||||
GitRepoFixture.RunGit(repoDir, "commit", "-m", $"squash {branch}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverview_ContentInMain_True_For_SquashMerged_False_For_Unmerged_Commit()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = NewRepo();
|
||||
var git = new GitService();
|
||||
var db = NewDb();
|
||||
var (list, squashed) = MakeEntities(repo.RepoDir);
|
||||
var unmerged = MakeTaskForList(list.Id, ClaudeDo.Data.Models.TaskStatus.Done);
|
||||
|
||||
var squashedWt = await CreateWorktreeAsync(git, repo.RepoDir, squashed.Id);
|
||||
var unmergedWt = await CreateWorktreeAsync(git, repo.RepoDir, unmerged.Id);
|
||||
CommitFile(squashedWt, "squashed.txt");
|
||||
CommitFile(unmergedWt, "unmerged.txt");
|
||||
SquashMerge(repo.RepoDir, $"test/{squashed.Id}");
|
||||
|
||||
await SeedRowAsync(db, list, squashed, squashedWt, $"test/{squashed.Id}", repo.BaseCommit);
|
||||
await SeedRowAsync(db, null, unmerged, unmergedWt, $"test/{unmerged.Id}", repo.BaseCommit);
|
||||
|
||||
var svc = new WorktreeMaintenanceService(db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||
var rows = await svc.GetOverviewAsync(null);
|
||||
|
||||
Assert.True(rows.Single(r => r.TaskId == squashed.Id).ContentInMain);
|
||||
Assert.False(rows.Single(r => r.TaskId == unmerged.Id).ContentInMain);
|
||||
|
||||
try { await git.WorktreeRemoveAsync(repo.RepoDir, squashedWt, force: true); } catch { }
|
||||
try { await git.WorktreeRemoveAsync(repo.RepoDir, unmergedWt, force: true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanupFinished_Removes_Active_Worktree_Squash_Merged_Outside_ClaudeDo()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = NewRepo();
|
||||
var git = new GitService();
|
||||
var db = NewDb();
|
||||
var (list, squashed) = MakeEntities(repo.RepoDir);
|
||||
var unmerged = MakeTaskForList(list.Id, ClaudeDo.Data.Models.TaskStatus.Done);
|
||||
|
||||
var squashedWt = await CreateWorktreeAsync(git, repo.RepoDir, squashed.Id);
|
||||
var unmergedWt = await CreateWorktreeAsync(git, repo.RepoDir, unmerged.Id);
|
||||
CommitFile(squashedWt, "squashed.txt");
|
||||
CommitFile(unmergedWt, "unmerged.txt");
|
||||
SquashMerge(repo.RepoDir, $"test/{squashed.Id}");
|
||||
|
||||
await SeedRowAsync(db, list, squashed, squashedWt, $"test/{squashed.Id}", repo.BaseCommit);
|
||||
await SeedRowAsync(db, null, unmerged, unmergedWt, $"test/{unmerged.Id}", repo.BaseCommit);
|
||||
|
||||
var svc = new WorktreeMaintenanceService(db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||
var result = await svc.CleanupFinishedAsync();
|
||||
|
||||
Assert.Equal(new[] { squashed.Id }, result.RemovedTaskIds);
|
||||
Assert.False(Directory.Exists(squashedWt));
|
||||
Assert.True(Directory.Exists(unmergedWt));
|
||||
|
||||
try { await git.WorktreeRemoveAsync(repo.RepoDir, unmergedWt, force: true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForceRemove_Prunes_And_Deletes_Branch_In_Owning_Repo_Not_List_WorkingDir()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var owner = NewRepo();
|
||||
var other = NewRepo();
|
||||
var git = new GitService();
|
||||
var db = NewDb();
|
||||
// The list points at a different repo than the one the worktree was created in.
|
||||
var (list, task) = MakeEntities(other.RepoDir);
|
||||
var wt = await CreateWorktreeAsync(git, owner.RepoDir, task.Id);
|
||||
await SeedRowAsync(db, list, task, wt, $"test/{task.Id}", owner.BaseCommit);
|
||||
|
||||
var svc = new WorktreeMaintenanceService(db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||
var result = await svc.ForceRemoveAsync(task.Id);
|
||||
|
||||
Assert.True(result.Removed);
|
||||
Assert.True(result.BranchDeleted);
|
||||
Assert.DoesNotContain($"test/{task.Id}", GitRepoFixture.RunGit(owner.RepoDir, "branch", "--list"));
|
||||
Assert.DoesNotContain(Path.GetFileName(wt), GitRepoFixture.RunGit(owner.RepoDir, "worktree", "list"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForceRemove_Reports_BranchDeleted_When_Branch_Was_Already_Gone()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = NewRepo();
|
||||
var db = NewDb();
|
||||
var (list, task) = MakeEntities(repo.RepoDir);
|
||||
var phantomPath = Path.Combine(Path.GetTempPath(), $"wt_phantom_{Guid.NewGuid():N}");
|
||||
await SeedRowAsync(db, list, task, phantomPath, $"test/{task.Id}-never-existed", repo.BaseCommit);
|
||||
|
||||
var svc = new WorktreeMaintenanceService(db.CreateFactory(), new GitService(), NullLogger<WorktreeMaintenanceService>.Instance);
|
||||
var result = await svc.ForceRemoveAsync(task.Id);
|
||||
|
||||
Assert.True(result.Removed);
|
||||
Assert.True(result.BranchDeleted);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user