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:
Mika Kuns
2026-04-13 13:29:26 +02:00
parent e5038d7e16
commit 01235d986f
9 changed files with 656 additions and 9 deletions

View 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());
}
}

View File

@@ -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).

View 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];
}

View File

@@ -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)");
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);

View 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;
}
}