feat(worker,data): add git worktree support and conventional commits
GitService (in ClaudeDo.Data so the UI can reuse it) wraps the git CLI:
IsGitRepo, RevParseHead, WorktreeAdd/Remove, HasChanges, AddAll, Commit
(multi-line via -F -), DiffStat, BranchDelete, MergeFfOnly. Throws with
stderr on failure.
WorktreeManager owns the per-task lifecycle: validate working_dir is a
git repo (throws if not, no DB row written), create the worktree at
<repo>/../.claudedo-worktrees/<slug>/<id>/ (or central root per config),
insert the worktrees row. CommitIfChangedAsync skips when there are no
changes, otherwise commits and updates head_commit + diff_stat.
CommitMessageBuilder produces "{type}({list-slug}): {title<=60}" with a
blank-line-separated description (truncated to 400) and a permanent
"ClaudeDo-Task: <id>" trailer. Slug normalises whitespace + strips
non-alphanumerics. Newlines hard-coded to \n so git on Windows doesn't
choke on \r\n.
TaskRunner branches on list.WorkingDir: worktree path runs Claude in the
worktree, commits on success, broadcasts WorktreeUpdated; failure leaves
the worktree row active for inspection. Sandbox path unchanged.
Tests: 38 pass (12 new). GitRepoFixture spins up a real temp repo with a
seed commit; tests skip gracefully if `git` isn't on PATH.
CommitMessageBuilder fully unit-tested. WorktreeManager covers create,
no-change skip, real-commit, and non-repo failure.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
123
src/ClaudeDo.Data/Git/GitService.cs
Normal file
123
src/ClaudeDo.Data/Git/GitService.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
|
||||
namespace ClaudeDo.Data.Git;
|
||||
|
||||
public sealed class GitService
|
||||
{
|
||||
public async Task<bool> IsGitRepoAsync(string dir, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--git-dir"], ct);
|
||||
return exitCode == 0;
|
||||
}
|
||||
|
||||
public async Task<string> 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<bool> 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<string> 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<string> { "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<string> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<IClaudeProcess, ClaudeProcess>();
|
||||
builder.Services.AddSingleton<HubBroadcaster>();
|
||||
builder.Services.AddSingleton<GitService>();
|
||||
builder.Services.AddSingleton<WorktreeManager>();
|
||||
builder.Services.AddSingleton<TaskRunner>();
|
||||
|
||||
// QueueService: singleton + hosted service (same instance).
|
||||
|
||||
46
src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs
Normal file
46
src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs
Normal file
@@ -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];
|
||||
}
|
||||
@@ -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<TaskRunner> _logger;
|
||||
|
||||
@@ -18,6 +19,7 @@ public sealed class TaskRunner
|
||||
TaskRepository taskRepo,
|
||||
ListRepository listRepo,
|
||||
HubBroadcaster broadcaster,
|
||||
WorktreeManager wtManager,
|
||||
WorkerConfig cfg,
|
||||
ILogger<TaskRunner> 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)");
|
||||
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.
|
||||
var sandboxDir = Path.Combine(_cfg.SandboxRoot, task.Id);
|
||||
Directory.CreateDirectory(sandboxDir);
|
||||
runDir = Path.Combine(_cfg.SandboxRoot, task.Id);
|
||||
Directory.CreateDirectory(runDir);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
93
src/ClaudeDo.Worker/Runner/WorktreeManager.cs
Normal file
93
src/ClaudeDo.Worker/Runner/WorktreeManager.cs
Normal file
@@ -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<WorktreeManager> _logger;
|
||||
|
||||
public WorktreeManager(GitService git, WorktreeRepository wtRepo, WorkerConfig cfg, ILogger<WorktreeManager> logger)
|
||||
{
|
||||
_git = git;
|
||||
_wtRepo = wtRepo;
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<WorktreeContext> 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);
|
||||
}
|
||||
|
||||
/// <returns>true if a commit was made; false if no changes.</returns>
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
90
tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs
Normal file
90
tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
186
tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs
Normal file
186
tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs
Normal file
@@ -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<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, 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<WorktreeManager>.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<WorktreeManager>.Instance);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => 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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<WorktreeManager>.Instance);
|
||||
var runner = new TaskRunner(fake, _taskRepo, _listRepo, broadcaster, wtManager, _cfg,
|
||||
NullLogger<TaskRunner>.Instance);
|
||||
var service = new QueueService(_taskRepo, runner, _cfg, NullLogger<QueueService>.Instance);
|
||||
return (service, fake);
|
||||
|
||||
Reference in New Issue
Block a user