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