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