Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs
Mika Kuns 01235d986f 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>
2026-04-13 13:29:26 +02:00

91 lines
2.6 KiB
C#

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