Path.GetDirectoryName returned workingDir itself instead of its parent whenever list.WorkingDir ended in a directory separator, placing .claudedo-worktrees inside the target repo's own working tree. Add WorktreeRootResolver to normalize the trailing separator before deriving the sibling root and to guard that the resulting worktree path never lands inside workingDir, used by both WorktreeManager and PlanningSessionManager. Add a startup sweep (LegacyWorktreeFolderRecovery) that warns when a list's working dir already contains a leftover .claudedo-worktrees folder from before the fix.
280 lines
10 KiB
C#
280 lines
10 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Config;
|
|
using ClaudeDo.Worker.Runner;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Runner;
|
|
|
|
public class WorktreeManagerTests : IDisposable
|
|
{
|
|
private readonly List<GitRepoFixture> _fixtures = new();
|
|
private readonly List<DbFixture> _dbFixtures = new();
|
|
private readonly List<string> _tempDirs = new();
|
|
private readonly List<(string repoDir, string wtPath)> _worktreeCleanups = new();
|
|
|
|
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
|
|
|
|
private GitRepoFixture CreateRepo()
|
|
{
|
|
var f = new GitRepoFixture();
|
|
_fixtures.Add(f);
|
|
return f;
|
|
}
|
|
|
|
private async Task<(WorktreeManager mgr, DbFixture db)> CreateManagerAsync(
|
|
TaskEntity task, ListEntity list, string strategy = "sibling", string? centralRoot = null)
|
|
{
|
|
var db = new DbFixture();
|
|
_dbFixtures.Add(db);
|
|
|
|
// Seed the DB with list and task so FK constraints pass.
|
|
using var seedCtx = db.CreateContext();
|
|
var listRepo = new ListRepository(seedCtx);
|
|
var taskRepo = new TaskRepository(seedCtx);
|
|
await listRepo.AddAsync(list);
|
|
await taskRepo.AddAsync(task);
|
|
|
|
var cfg = new WorkerConfig
|
|
{
|
|
WorktreeRootStrategy = strategy,
|
|
};
|
|
if (centralRoot is not null)
|
|
cfg.CentralWorktreeRoot = centralRoot;
|
|
|
|
var mgr = new WorktreeManager(
|
|
new GitService(), db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
|
return (mgr, db);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_Succeeds_InGitRepo()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = CreateRepo();
|
|
var (task, list) = MakeEntities(repo.RepoDir);
|
|
var (mgr, db) = await CreateManagerAsync(task, list);
|
|
|
|
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
|
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
|
|
|
Assert.NotNull(ctx);
|
|
Assert.True(Directory.Exists(ctx.WorktreePath));
|
|
Assert.Equal($"claudedo/{task.Id.Replace("-", "")}", ctx.BranchName);
|
|
Assert.Equal(repo.BaseCommit, ctx.BaseCommit);
|
|
|
|
using var readCtx = db.CreateContext();
|
|
var wtRepo = new WorktreeRepository(readCtx);
|
|
var row = await wtRepo.GetByTaskIdAsync(task.Id);
|
|
Assert.NotNull(row);
|
|
Assert.Equal(WorktreeState.Active, row!.State);
|
|
Assert.Equal(ctx.BaseCommit, row.BaseCommit);
|
|
Assert.Null(row.HeadCommit);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CommitIfChangedAsync_NoChanges_HeadCommitStaysNull()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = CreateRepo();
|
|
var (task, list) = MakeEntities(repo.RepoDir);
|
|
var (mgr, db) = await CreateManagerAsync(task, list);
|
|
|
|
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
|
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
|
|
|
var committed = await mgr.CommitIfChangedAsync(ctx, task, list, CancellationToken.None);
|
|
|
|
Assert.False(committed);
|
|
using var readCtx = db.CreateContext();
|
|
var wtRepo = new WorktreeRepository(readCtx);
|
|
var row = await wtRepo.GetByTaskIdAsync(task.Id);
|
|
Assert.Null(row!.HeadCommit);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CommitIfChangedAsync_WithNewFile_HeadCommitSet()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = CreateRepo();
|
|
var (task, list) = MakeEntities(repo.RepoDir);
|
|
var (mgr, db) = await CreateManagerAsync(task, list);
|
|
|
|
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
|
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
|
|
|
File.WriteAllText(Path.Combine(ctx.WorktreePath, "hello.txt"), "hello world");
|
|
|
|
var committed = await mgr.CommitIfChangedAsync(ctx, task, list, CancellationToken.None);
|
|
|
|
Assert.True(committed);
|
|
using var readCtx = db.CreateContext();
|
|
var wtRepo = new WorktreeRepository(readCtx);
|
|
var row = await wtRepo.GetByTaskIdAsync(task.Id);
|
|
Assert.NotNull(row!.HeadCommit);
|
|
Assert.NotEqual(ctx.BaseCommit, row.HeadCommit);
|
|
Assert.NotNull(row.DiffStat);
|
|
Assert.Contains("hello.txt", row.DiffStat);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_NonGitDir_Throws_NoRow()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var tmpDir = Path.Combine(Path.GetTempPath(), $"claudedo_nogit_{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(tmpDir);
|
|
_tempDirs.Add(tmpDir);
|
|
|
|
var (task, list) = MakeEntities(tmpDir);
|
|
|
|
var db = new DbFixture();
|
|
_dbFixtures.Add(db);
|
|
using (var seedCtx = db.CreateContext())
|
|
{
|
|
var listRepo = new ListRepository(seedCtx);
|
|
var taskRepo = new TaskRepository(seedCtx);
|
|
await listRepo.AddAsync(list);
|
|
await taskRepo.AddAsync(task);
|
|
}
|
|
|
|
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
|
var mgr = new WorktreeManager(
|
|
new GitService(), db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => mgr.CreateAsync(task, list, CancellationToken.None));
|
|
Assert.Contains("not a git repository", ex.Message);
|
|
|
|
using var readCtx = db.CreateContext();
|
|
var wtRepo = new WorktreeRepository(readCtx);
|
|
var row = await wtRepo.GetByTaskIdAsync(task.Id);
|
|
Assert.Null(row);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_DbInsertFails_RemovesOrphanedWorktreeAndBranch()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = CreateRepo();
|
|
var (task, list) = MakeEntities(repo.RepoDir);
|
|
|
|
// Seed the list but NOT the task: the worktrees-row insert references
|
|
// tasks(task_id) and fails the FK after `git worktree add` has succeeded.
|
|
var db = new DbFixture();
|
|
_dbFixtures.Add(db);
|
|
using (var seedCtx = db.CreateContext())
|
|
await new ListRepository(seedCtx).AddAsync(list);
|
|
|
|
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
|
var mgr = new WorktreeManager(
|
|
new GitService(), db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
|
|
|
await Assert.ThrowsAnyAsync<Exception>(
|
|
() => mgr.CreateAsync(task, list, CancellationToken.None));
|
|
|
|
var branchName = $"claudedo/{task.Id.Replace("-", "")}";
|
|
var branchList = GitRepoFixture.RunGit(repo.RepoDir, "branch", "--list", branchName);
|
|
Assert.True(string.IsNullOrWhiteSpace(branchList),
|
|
$"orphaned branch {branchName} should be cleaned up, got: {branchList}");
|
|
|
|
var worktreeList = GitRepoFixture.RunGit(repo.RepoDir, "worktree", "list");
|
|
Assert.DoesNotContain(task.Id, worktreeList);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_TrailingSeparatorOnWorkingDir_PlacesWorktreeOutsideRepo()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = CreateRepo();
|
|
var workingDirWithSeparator = repo.RepoDir + Path.DirectorySeparatorChar;
|
|
var (task, list) = MakeEntities(workingDirWithSeparator);
|
|
var (mgr, db) = await CreateManagerAsync(task, list);
|
|
|
|
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
|
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
|
|
|
var repoDirFull = Path.GetFullPath(repo.RepoDir);
|
|
Assert.False(
|
|
ctx.WorktreePath.StartsWith(repoDirFull + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase),
|
|
$"worktree path {ctx.WorktreePath} should not be nested inside the repo dir {repoDirFull}");
|
|
Assert.True(Directory.Exists(ctx.WorktreePath));
|
|
}
|
|
|
|
private static (TaskEntity task, ListEntity list) MakeEntities(string workingDir)
|
|
{
|
|
var listId = Guid.NewGuid().ToString();
|
|
var taskId = Guid.NewGuid().ToString();
|
|
var task = new TaskEntity
|
|
{
|
|
Id = taskId,
|
|
ListId = listId,
|
|
Title = "test task",
|
|
Description = "a description",
|
|
CommitType = "chore",
|
|
CreatedAt = DateTime.UtcNow,
|
|
};
|
|
var list = new ListEntity
|
|
{
|
|
Id = listId,
|
|
Name = "Test List",
|
|
WorkingDir = workingDir,
|
|
CreatedAt = DateTime.UtcNow,
|
|
};
|
|
return (task, list);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscardAsync_RemovesWorktreeAndBranch_AndSetsStateDiscarded()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = CreateRepo();
|
|
var (task, list) = MakeEntities(repo.RepoDir);
|
|
var (mgr, db) = await CreateManagerAsync(task, list);
|
|
|
|
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
|
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
|
var worktreePath = ctx.WorktreePath;
|
|
|
|
WorktreeEntity wt;
|
|
using (var readCtx = db.CreateContext())
|
|
wt = (await new WorktreeRepository(readCtx).GetByTaskIdAsync(task.Id))!;
|
|
|
|
await mgr.DiscardAsync(wt, list.WorkingDir!, CancellationToken.None);
|
|
|
|
Assert.False(Directory.Exists(worktreePath), "worktree directory should be gone");
|
|
|
|
var branchList = GitRepoFixture.RunGit(repo.RepoDir, "branch", "--list", ctx.BranchName);
|
|
Assert.True(string.IsNullOrWhiteSpace(branchList),
|
|
$"branch {ctx.BranchName} should be deleted, got: {branchList}");
|
|
|
|
using var readCtx2 = db.CreateContext();
|
|
var row = await new WorktreeRepository(readCtx2).GetByTaskIdAsync(task.Id);
|
|
Assert.NotNull(row);
|
|
Assert.Equal(WorktreeState.Discarded, row!.State);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var (repoDir, wtPath) in _worktreeCleanups)
|
|
{
|
|
try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { }
|
|
}
|
|
foreach (var f in _fixtures) f.Dispose();
|
|
foreach (var db in _dbFixtures) db.Dispose();
|
|
foreach (var d in _tempDirs)
|
|
{
|
|
try { Directory.Delete(d, true); } catch { }
|
|
}
|
|
}
|
|
}
|