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

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