diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs new file mode 100644 index 0000000..f1e7662 --- /dev/null +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -0,0 +1,123 @@ +using System.Diagnostics; +using System.Text; + +namespace ClaudeDo.Data.Git; + +public sealed class GitService +{ + public async Task IsGitRepoAsync(string dir, CancellationToken ct = default) + { + var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--git-dir"], ct); + return exitCode == 0; + } + + public async Task RevParseHeadAsync(string dir, CancellationToken ct = default) + { + var (exitCode, stdout, stderr) = await RunGitAsync(dir, ["rev-parse", "HEAD"], ct); + if (exitCode != 0) + throw new InvalidOperationException($"git rev-parse HEAD failed (exit {exitCode}): {stderr}"); + return stdout.Trim(); + } + + public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default) + { + var (exitCode, _, stderr) = await RunGitAsync(repoDir, + ["worktree", "add", "-b", branchName, worktreePath, baseCommit], ct); + if (exitCode != 0) + throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}"); + } + + public async Task HasChangesAsync(string worktreePath, CancellationToken ct = default) + { + var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct); + if (exitCode != 0) + throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}"); + return !string.IsNullOrWhiteSpace(stdout); + } + + public async Task AddAllAsync(string worktreePath, CancellationToken ct = default) + { + var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["add", "-A"], ct); + if (exitCode != 0) + throw new InvalidOperationException($"git add -A failed (exit {exitCode}): {stderr}"); + } + + public async Task CommitAsync(string worktreePath, string message, CancellationToken ct = default) + { + // Use -F - (read message from stdin) to handle multi-line messages safely. + var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["commit", "-F", "-"], ct, stdinData: message); + if (exitCode != 0) + throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}"); + } + + public async Task DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default) + { + var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, + ["diff", "--stat", $"{baseCommit}..{headCommit}"], ct); + if (exitCode != 0) + throw new InvalidOperationException($"git diff --stat failed (exit {exitCode}): {stderr}"); + return stdout.Trim(); + } + + public async Task WorktreeRemoveAsync(string repoDir, string worktreePath, bool force = false, CancellationToken ct = default) + { + var args = new List { "worktree", "remove" }; + if (force) args.Add("--force"); + args.Add(worktreePath); + + var (exitCode, _, stderr) = await RunGitAsync(repoDir, args, ct); + if (exitCode != 0) + throw new InvalidOperationException($"git worktree remove failed (exit {exitCode}): {stderr}"); + } + + public async Task BranchDeleteAsync(string repoDir, string branchName, bool force = false, CancellationToken ct = default) + { + var flag = force ? "-D" : "-d"; + var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["branch", flag, branchName], ct); + if (exitCode != 0) + throw new InvalidOperationException($"git branch {flag} failed (exit {exitCode}): {stderr}"); + } + + public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default) + { + var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct); + if (exitCode != 0) + throw new InvalidOperationException($"Fast-forward merge of '{branchName}' failed. Manual merge required. git stderr: {stderr}"); + } + + private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync( + string workDir, IEnumerable args, CancellationToken ct, string? stdinData = null) + { + var psi = new ProcessStartInfo + { + FileName = "git", + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = stdinData is not null, + UseShellExecute = false, + CreateNoWindow = true, + }; + psi.ArgumentList.Add("-C"); + psi.ArgumentList.Add(workDir); + foreach (var a in args) psi.ArgumentList.Add(a); + + using var proc = new Process { StartInfo = psi }; + proc.Start(); + + if (stdinData is not null) + { + await proc.StandardInput.WriteAsync(stdinData.AsMemory(), ct); + proc.StandardInput.Close(); + } + + var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct); + var stderrTask = proc.StandardError.ReadToEndAsync(ct); + + await proc.WaitForExitAsync(ct); + + var stdout = await stdoutTask; + var stderr = await stderrTask; + + return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd()); + } +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 0f3d109..75b8171 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -1,4 +1,5 @@ using ClaudeDo.Data; +using ClaudeDo.Data.Git; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; @@ -25,6 +26,8 @@ builder.Services.AddSignalR(); // Runner stack. builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); // QueueService: singleton + hosted service (same instance). diff --git a/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs b/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs new file mode 100644 index 0000000..ca5318a --- /dev/null +++ b/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs @@ -0,0 +1,46 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace ClaudeDo.Worker.Runner; + +public static class CommitMessageBuilder +{ + public static string Build(string commitType, string listName, string taskTitle, string? taskDescription, string taskId) + { + var slug = ToSlug(listName); + var title = Truncate(taskTitle, 60); + var header = $"{commitType}({slug}): {title}"; + + var sb = new StringBuilder(); + sb.Append(header); + + var hasDescription = !string.IsNullOrWhiteSpace(taskDescription); + if (hasDescription) + { + sb.Append("\n\n"); + sb.Append(Truncate(taskDescription!.Trim(), 400)); + } + + // Trailer is always included. + sb.Append("\n\n"); + sb.Append($"ClaudeDo-Task: {taskId}"); + + return sb.ToString(); + } + + public static string ToSlug(string name) + { + var lower = name.ToLowerInvariant(); + // Replace whitespace runs with a single dash. + var dashed = Regex.Replace(lower, @"\s+", "-"); + // Remove all non-alphanumeric-and-dash characters. + var cleaned = Regex.Replace(dashed, @"[^a-z0-9\-]", ""); + // Collapse multiple dashes. + var collapsed = Regex.Replace(cleaned, @"-{2,}", "-"); + // Trim leading/trailing dashes. + return collapsed.Trim('-'); + } + + private static string Truncate(string value, int maxLength) => + value.Length <= maxLength ? value : value[..maxLength]; +} diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index 93f3f0e..1b9f517 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -10,6 +10,7 @@ public sealed class TaskRunner private readonly TaskRepository _taskRepo; private readonly ListRepository _listRepo; private readonly HubBroadcaster _broadcaster; + private readonly WorktreeManager _wtManager; private readonly WorkerConfig _cfg; private readonly ILogger _logger; @@ -18,6 +19,7 @@ public sealed class TaskRunner TaskRepository taskRepo, ListRepository listRepo, HubBroadcaster broadcaster, + WorktreeManager wtManager, WorkerConfig cfg, ILogger logger) { @@ -25,6 +27,7 @@ public sealed class TaskRunner _taskRepo = taskRepo; _listRepo = listRepo; _broadcaster = broadcaster; + _wtManager = wtManager; _cfg = cfg; _logger = logger; } @@ -40,16 +43,30 @@ public sealed class TaskRunner return; } - // Slice D: worktree mode not yet implemented. + // Determine working directory: worktree or sandbox. + WorktreeContext? wtCtx = null; + string runDir; + if (list.WorkingDir is not null) { - await MarkFailed(task.Id, slot, "Worktree mode not implemented yet (Slice E)"); - return; + try + { + wtCtx = await _wtManager.CreateAsync(task, list, ct); + runDir = wtCtx.WorktreePath; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create worktree for task {TaskId}", task.Id); + await MarkFailed(task.Id, slot, $"Worktree creation failed: {ex.Message}"); + return; + } + } + else + { + // Non-worktree sandbox path. + runDir = Path.Combine(_cfg.SandboxRoot, task.Id); + Directory.CreateDirectory(runDir); } - - // Non-worktree sandbox path. - var sandboxDir = Path.Combine(_cfg.SandboxRoot, task.Id); - Directory.CreateDirectory(sandboxDir); var logPath = Path.Combine(_cfg.LogRoot, $"{task.Id}.ndjson"); @@ -67,7 +84,7 @@ public sealed class TaskRunner var result = await _claude.RunAsync( prompt, - sandboxDir, + runDir, logPath, task.Id, async line => @@ -81,12 +98,21 @@ public sealed class TaskRunner if (result.IsSuccess) { + // Auto-commit if worktree mode and run succeeded. + if (wtCtx is not null) + { + var committed = await _wtManager.CommitIfChangedAsync(wtCtx, task, list, ct); + if (committed) + await _broadcaster.WorktreeUpdated(task.Id); + } + await _taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, ct); await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt); _logger.LogInformation("Task {TaskId} completed successfully", task.Id); } else { + // Failed run: do NOT commit. Worktree row stays active for inspection. await _taskRepo.MarkFailedAsync(task.Id, finishedAt, result.ErrorMarkdown, ct); await _broadcaster.TaskFinished(slot, task.Id, "failed", finishedAt); _logger.LogWarning("Task {TaskId} failed: {Error}", task.Id, result.ErrorMarkdown); diff --git a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs new file mode 100644 index 0000000..ceabbc6 --- /dev/null +++ b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs @@ -0,0 +1,93 @@ +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Config; + +namespace ClaudeDo.Worker.Runner; + +public sealed record WorktreeContext(string WorktreePath, string BranchName, string BaseCommit); + +public sealed class WorktreeManager +{ + private readonly GitService _git; + private readonly WorktreeRepository _wtRepo; + private readonly WorkerConfig _cfg; + private readonly ILogger _logger; + + public WorktreeManager(GitService git, WorktreeRepository wtRepo, WorkerConfig cfg, ILogger logger) + { + _git = git; + _wtRepo = wtRepo; + _cfg = cfg; + _logger = logger; + } + + public async Task CreateAsync(TaskEntity task, ListEntity list, CancellationToken ct) + { + var workingDir = list.WorkingDir + ?? throw new InvalidOperationException("list.WorkingDir is null"); + + if (!await _git.IsGitRepoAsync(workingDir, ct)) + throw new InvalidOperationException($"working_dir is not a git repository: {workingDir}"); + + var baseCommit = await _git.RevParseHeadAsync(workingDir, ct); + var shortId = task.Id.Length >= 8 ? task.Id[..8] : task.Id; + var branchName = $"claudedo/{shortId}"; + var slug = CommitMessageBuilder.ToSlug(list.Name); + + var worktreePath = _cfg.WorktreeRootStrategy.Equals("central", StringComparison.OrdinalIgnoreCase) + ? Path.Combine(_cfg.CentralWorktreeRoot, slug, task.Id) + : Path.Combine(Path.GetDirectoryName(workingDir)!, ".claudedo-worktrees", slug, task.Id); + + worktreePath = Path.GetFullPath(worktreePath); + + // Ensure parent directory exists. + Directory.CreateDirectory(Path.GetDirectoryName(worktreePath)!); + + // Create the worktree (this also creates the directory). + await _git.WorktreeAddAsync(workingDir, branchName, worktreePath, baseCommit, ct); + + // Insert worktrees row AFTER git succeeds — if git throws, no row is created. + await _wtRepo.AddAsync(new WorktreeEntity + { + TaskId = task.Id, + Path = worktreePath, + BranchName = branchName, + BaseCommit = baseCommit, + HeadCommit = null, + DiffStat = null, + State = WorktreeState.Active, + CreatedAt = DateTime.UtcNow, + }, ct); + + _logger.LogInformation("Created worktree for task {TaskId} at {Path} (branch {Branch}, base {Base})", + task.Id, worktreePath, branchName, baseCommit); + + return new WorktreeContext(worktreePath, branchName, baseCommit); + } + + /// true if a commit was made; false if no changes. + public async Task CommitIfChangedAsync(WorktreeContext ctx, TaskEntity task, ListEntity list, CancellationToken ct) + { + if (!await _git.HasChangesAsync(ctx.WorktreePath, ct)) + { + _logger.LogInformation("No changes in worktree for task {TaskId}, skipping commit", task.Id); + return false; + } + + await _git.AddAllAsync(ctx.WorktreePath, ct); + + var message = CommitMessageBuilder.Build( + task.CommitType, list.Name, task.Title, task.Description, task.Id); + + await _git.CommitAsync(ctx.WorktreePath, message, ct); + + var head = await _git.RevParseHeadAsync(ctx.WorktreePath, ct); + var diffStat = await _git.DiffStatAsync(ctx.WorktreePath, ctx.BaseCommit, head, ct); + + await _wtRepo.UpdateHeadAsync(task.Id, head, diffStat, ct); + + _logger.LogInformation("Committed changes for task {TaskId}: {Head}", task.Id, head); + return true; + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs b/tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs new file mode 100644 index 0000000..e7c788a --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs @@ -0,0 +1,90 @@ +using System.Diagnostics; + +namespace ClaudeDo.Worker.Tests.Infrastructure; + +public sealed class GitRepoFixture : IDisposable +{ + public string RepoDir { get; } + public string BaseCommit { get; } + + public GitRepoFixture() + { + RepoDir = Path.Combine(Path.GetTempPath(), $"claudedo_gittest_{Guid.NewGuid():N}"); + Directory.CreateDirectory(RepoDir); + + RunGit(RepoDir, "init"); + RunGit(RepoDir, "config", "user.name", "test"); + RunGit(RepoDir, "config", "user.email", "test@example.com"); + + File.WriteAllText(Path.Combine(RepoDir, "README.md"), "# test repo"); + RunGit(RepoDir, "add", "-A"); + RunGit(RepoDir, "commit", "-m", "initial commit"); + + BaseCommit = RunGit(RepoDir, "rev-parse", "HEAD").Trim(); + } + + public void Dispose() + { + try + { + // Force-remove read-only .git objects on Windows. + ForceDeleteDirectory(RepoDir); + } + catch { /* best effort */ } + } + + public static bool IsGitAvailable() + { + try + { + var psi = new ProcessStartInfo("git", "--version") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + using var p = Process.Start(psi)!; + p.WaitForExit(5000); + return p.ExitCode == 0; + } + catch + { + return false; + } + } + + internal static string RunGit(string workDir, params string[] args) + { + var psi = new ProcessStartInfo("git") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + psi.ArgumentList.Add("-C"); + psi.ArgumentList.Add(workDir); + foreach (var a in args) psi.ArgumentList.Add(a); + + using var proc = Process.Start(psi)!; + var stdout = proc.StandardOutput.ReadToEnd(); + var stderr = proc.StandardError.ReadToEnd(); + proc.WaitForExit(); + + if (proc.ExitCode != 0) + throw new InvalidOperationException($"git {string.Join(' ', args)} failed: {stderr}"); + + return stdout; + } + + private static void ForceDeleteDirectory(string path) + { + if (!Directory.Exists(path)) return; + foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(file, FileAttributes.Normal); + } + Directory.Delete(path, true); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs new file mode 100644 index 0000000..8fabb70 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs @@ -0,0 +1,77 @@ +using ClaudeDo.Worker.Runner; + +namespace ClaudeDo.Worker.Tests.Runner; + +public class CommitMessageBuilderTests +{ + [Fact] + public void Slug_FromListName() + { + Assert.Equal("lager-app", CommitMessageBuilder.ToSlug("Lager App")); + } + + [Fact] + public void Slug_SpecialCharsStripped() + { + Assert.Equal("my-list", CommitMessageBuilder.ToSlug("My! @List#")); + } + + [Fact] + public void Slug_CollapsesDashes() + { + Assert.Equal("a-b", CommitMessageBuilder.ToSlug("a -- b")); + } + + [Fact] + public void Slug_TrimsLeadingTrailingDashes() + { + Assert.Equal("abc", CommitMessageBuilder.ToSlug("--abc--")); + } + + [Fact] + public void Title_TruncatedTo60() + { + var longTitle = new string('x', 80); + var msg = CommitMessageBuilder.Build("feat", "My List", longTitle, null, "task-id-123"); + var header = msg.Split('\n')[0]; + // Header format: feat(my-list): <60 chars> + var titlePart = header.Split(": ", 2)[1]; + Assert.Equal(60, titlePart.Length); + } + + [Fact] + public void NoDescription_TrailerStillPresent() + { + var msg = CommitMessageBuilder.Build("chore", "Test List", "do something", null, "abc-123"); + var lines = msg.Split('\n'); + + // header \n \n trailer = 3 lines (no description block, just blank separator). + Assert.Equal(3, lines.Length); + Assert.Equal("chore(test-list): do something", lines[0]); + Assert.Equal("", lines[1]); + Assert.Equal("ClaudeDo-Task: abc-123", lines[2]); + } + + [Fact] + public void WithDescription_IncludedAboveTrailer() + { + var msg = CommitMessageBuilder.Build("feat", "Lager App", "add scan", "Detailed description here", "id-456"); + var lines = msg.Split('\n'); + + Assert.Equal("feat(lager-app): add scan", lines[0]); + Assert.Equal("", lines[1]); // blank after header + Assert.Equal("Detailed description here", lines[2]); + Assert.Equal("", lines[3]); // blank before trailer + Assert.Equal("ClaudeDo-Task: id-456", lines[4]); + } + + [Fact] + public void Description_TruncatedTo400() + { + var longDesc = new string('d', 500); + var msg = CommitMessageBuilder.Build("fix", "X", "title", longDesc, "id"); + var lines = msg.Split('\n'); + // lines[2] is the description line. + Assert.Equal(400, lines[2].Length); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs new file mode 100644 index 0000000..97803aa --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs @@ -0,0 +1,186 @@ +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, WorktreeRepository wtRepo)> 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. + var listRepo = new ListRepository(db.Factory); + var taskRepo = new TaskRepository(db.Factory); + await listRepo.AddAsync(list); + await taskRepo.AddAsync(task); + + var wtRepo = new WorktreeRepository(db.Factory); + var cfg = new WorkerConfig + { + WorktreeRootStrategy = strategy, + }; + if (centralRoot is not null) + cfg.CentralWorktreeRoot = centralRoot; + + var mgr = new WorktreeManager( + new GitService(), wtRepo, cfg, NullLogger.Instance); + return (mgr, wtRepo); + } + + [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, wtRepo) = 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[..8]}", ctx.BranchName); + Assert.Equal(repo.BaseCommit, ctx.BaseCommit); + + 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, wtRepo) = 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); + 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, wtRepo) = 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); + 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); + var listRepo = new ListRepository(db.Factory); + var taskRepo = new TaskRepository(db.Factory); + await listRepo.AddAsync(list); + await taskRepo.AddAsync(task); + + var wtRepo = new WorktreeRepository(db.Factory); + var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; + var mgr = new WorktreeManager( + new GitService(), wtRepo, cfg, NullLogger.Instance); + + var ex = await Assert.ThrowsAsync( + () => mgr.CreateAsync(task, list, CancellationToken.None)); + Assert.Contains("not a git repository", ex.Message); + + 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 { } + } + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs index 7a4e7e6..e01653d 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs @@ -1,3 +1,4 @@ +using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; @@ -46,7 +47,9 @@ public sealed class QueueServiceTests : IDisposable { var fake = new FakeClaudeProcess(handler); var broadcaster = new HubBroadcaster(new FakeHubContext()); - var runner = new TaskRunner(fake, _taskRepo, _listRepo, broadcaster, _cfg, + var wtRepo = new WorktreeRepository(_db.Factory); + var wtManager = new WorktreeManager(new GitService(), wtRepo, _cfg, NullLogger.Instance); + var runner = new TaskRunner(fake, _taskRepo, _listRepo, broadcaster, wtManager, _cfg, NullLogger.Instance); var service = new QueueService(_taskRepo, runner, _cfg, NullLogger.Instance); return (service, fake);