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 _fixtures = new(); private readonly List _dbFixtures = new(); private readonly List _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.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.Instance); var ex = await Assert.ThrowsAsync( () => 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); } 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); } 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 { } } } }