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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user