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>
91 lines
2.6 KiB
C#
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);
|
|
}
|
|
}
|