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:
90
tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs
Normal file
90
tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Infrastructure;
|
||||
|
||||
public sealed class GitRepoFixture : IDisposable
|
||||
{
|
||||
public string RepoDir { get; }
|
||||
public string BaseCommit { get; }
|
||||
|
||||
public GitRepoFixture()
|
||||
{
|
||||
RepoDir = Path.Combine(Path.GetTempPath(), $"claudedo_gittest_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(RepoDir);
|
||||
|
||||
RunGit(RepoDir, "init");
|
||||
RunGit(RepoDir, "config", "user.name", "test");
|
||||
RunGit(RepoDir, "config", "user.email", "test@example.com");
|
||||
|
||||
File.WriteAllText(Path.Combine(RepoDir, "README.md"), "# test repo");
|
||||
RunGit(RepoDir, "add", "-A");
|
||||
RunGit(RepoDir, "commit", "-m", "initial commit");
|
||||
|
||||
BaseCommit = RunGit(RepoDir, "rev-parse", "HEAD").Trim();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Force-remove read-only .git objects on Windows.
|
||||
ForceDeleteDirectory(RepoDir);
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
|
||||
public static bool IsGitAvailable()
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo("git", "--version")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
using var p = Process.Start(psi)!;
|
||||
p.WaitForExit(5000);
|
||||
return p.ExitCode == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string RunGit(string workDir, params string[] args)
|
||||
{
|
||||
var psi = new ProcessStartInfo("git")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
psi.ArgumentList.Add("-C");
|
||||
psi.ArgumentList.Add(workDir);
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
|
||||
using var proc = Process.Start(psi)!;
|
||||
var stdout = proc.StandardOutput.ReadToEnd();
|
||||
var stderr = proc.StandardError.ReadToEnd();
|
||||
proc.WaitForExit();
|
||||
|
||||
if (proc.ExitCode != 0)
|
||||
throw new InvalidOperationException($"git {string.Join(' ', args)} failed: {stderr}");
|
||||
|
||||
return stdout;
|
||||
}
|
||||
|
||||
private static void ForceDeleteDirectory(string path)
|
||||
{
|
||||
if (!Directory.Exists(path)) return;
|
||||
foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
File.SetAttributes(file, FileAttributes.Normal);
|
||||
}
|
||||
Directory.Delete(path, true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user