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:
Mika Kuns
2026-04-13 13:29:26 +02:00
parent e5038d7e16
commit 01235d986f
9 changed files with 656 additions and 9 deletions

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

View File

@@ -0,0 +1,77 @@
using ClaudeDo.Worker.Runner;
namespace ClaudeDo.Worker.Tests.Runner;
public class CommitMessageBuilderTests
{
[Fact]
public void Slug_FromListName()
{
Assert.Equal("lager-app", CommitMessageBuilder.ToSlug("Lager App"));
}
[Fact]
public void Slug_SpecialCharsStripped()
{
Assert.Equal("my-list", CommitMessageBuilder.ToSlug("My! @List#"));
}
[Fact]
public void Slug_CollapsesDashes()
{
Assert.Equal("a-b", CommitMessageBuilder.ToSlug("a -- b"));
}
[Fact]
public void Slug_TrimsLeadingTrailingDashes()
{
Assert.Equal("abc", CommitMessageBuilder.ToSlug("--abc--"));
}
[Fact]
public void Title_TruncatedTo60()
{
var longTitle = new string('x', 80);
var msg = CommitMessageBuilder.Build("feat", "My List", longTitle, null, "task-id-123");
var header = msg.Split('\n')[0];
// Header format: feat(my-list): <60 chars>
var titlePart = header.Split(": ", 2)[1];
Assert.Equal(60, titlePart.Length);
}
[Fact]
public void NoDescription_TrailerStillPresent()
{
var msg = CommitMessageBuilder.Build("chore", "Test List", "do something", null, "abc-123");
var lines = msg.Split('\n');
// header \n \n trailer = 3 lines (no description block, just blank separator).
Assert.Equal(3, lines.Length);
Assert.Equal("chore(test-list): do something", lines[0]);
Assert.Equal("", lines[1]);
Assert.Equal("ClaudeDo-Task: abc-123", lines[2]);
}
[Fact]
public void WithDescription_IncludedAboveTrailer()
{
var msg = CommitMessageBuilder.Build("feat", "Lager App", "add scan", "Detailed description here", "id-456");
var lines = msg.Split('\n');
Assert.Equal("feat(lager-app): add scan", lines[0]);
Assert.Equal("", lines[1]); // blank after header
Assert.Equal("Detailed description here", lines[2]);
Assert.Equal("", lines[3]); // blank before trailer
Assert.Equal("ClaudeDo-Task: id-456", lines[4]);
}
[Fact]
public void Description_TruncatedTo400()
{
var longDesc = new string('d', 500);
var msg = CommitMessageBuilder.Build("fix", "X", "title", longDesc, "id");
var lines = msg.Split('\n');
// lines[2] is the description line.
Assert.Equal(400, lines[2].Length);
}
}

View File

@@ -0,0 +1,186 @@
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
namespace ClaudeDo.Worker.Tests.Runner;
public class WorktreeManagerTests : IDisposable
{
private readonly List<GitRepoFixture> _fixtures = new();
private readonly List<DbFixture> _dbFixtures = new();
private readonly List<string> _tempDirs = new();
private readonly List<(string repoDir, string wtPath)> _worktreeCleanups = new();
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
private GitRepoFixture CreateRepo()
{
var f = new GitRepoFixture();
_fixtures.Add(f);
return f;
}
private async Task<(WorktreeManager mgr, WorktreeRepository wtRepo)> CreateManagerAsync(
TaskEntity task, ListEntity list, string strategy = "sibling", string? centralRoot = null)
{
var db = new DbFixture();
_dbFixtures.Add(db);
// Seed the DB with list and task so FK constraints pass.
var listRepo = new ListRepository(db.Factory);
var taskRepo = new TaskRepository(db.Factory);
await listRepo.AddAsync(list);
await taskRepo.AddAsync(task);
var wtRepo = new WorktreeRepository(db.Factory);
var cfg = new WorkerConfig
{
WorktreeRootStrategy = strategy,
};
if (centralRoot is not null)
cfg.CentralWorktreeRoot = centralRoot;
var mgr = new WorktreeManager(
new GitService(), wtRepo, cfg, NullLogger<WorktreeManager>.Instance);
return (mgr, wtRepo);
}
[Fact]
public async Task CreateAsync_Succeeds_InGitRepo()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, wtRepo) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
Assert.NotNull(ctx);
Assert.True(Directory.Exists(ctx.WorktreePath));
Assert.Equal($"claudedo/{task.Id[..8]}", ctx.BranchName);
Assert.Equal(repo.BaseCommit, ctx.BaseCommit);
var row = await wtRepo.GetByTaskIdAsync(task.Id);
Assert.NotNull(row);
Assert.Equal(WorktreeState.Active, row!.State);
Assert.Equal(ctx.BaseCommit, row.BaseCommit);
Assert.Null(row.HeadCommit);
}
[Fact]
public async Task CommitIfChangedAsync_NoChanges_HeadCommitStaysNull()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, wtRepo) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
var committed = await mgr.CommitIfChangedAsync(ctx, task, list, CancellationToken.None);
Assert.False(committed);
var row = await wtRepo.GetByTaskIdAsync(task.Id);
Assert.Null(row!.HeadCommit);
}
[Fact]
public async Task CommitIfChangedAsync_WithNewFile_HeadCommitSet()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, wtRepo) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
File.WriteAllText(Path.Combine(ctx.WorktreePath, "hello.txt"), "hello world");
var committed = await mgr.CommitIfChangedAsync(ctx, task, list, CancellationToken.None);
Assert.True(committed);
var row = await wtRepo.GetByTaskIdAsync(task.Id);
Assert.NotNull(row!.HeadCommit);
Assert.NotEqual(ctx.BaseCommit, row.HeadCommit);
Assert.NotNull(row.DiffStat);
Assert.Contains("hello.txt", row.DiffStat);
}
[Fact]
public async Task CreateAsync_NonGitDir_Throws_NoRow()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var tmpDir = Path.Combine(Path.GetTempPath(), $"claudedo_nogit_{Guid.NewGuid():N}");
Directory.CreateDirectory(tmpDir);
_tempDirs.Add(tmpDir);
var (task, list) = MakeEntities(tmpDir);
var db = new DbFixture();
_dbFixtures.Add(db);
var listRepo = new ListRepository(db.Factory);
var taskRepo = new TaskRepository(db.Factory);
await listRepo.AddAsync(list);
await taskRepo.AddAsync(task);
var wtRepo = new WorktreeRepository(db.Factory);
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(
new GitService(), wtRepo, cfg, NullLogger<WorktreeManager>.Instance);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => mgr.CreateAsync(task, list, CancellationToken.None));
Assert.Contains("not a git repository", ex.Message);
var row = await wtRepo.GetByTaskIdAsync(task.Id);
Assert.Null(row);
}
private static (TaskEntity task, ListEntity list) MakeEntities(string workingDir)
{
var listId = Guid.NewGuid().ToString();
var taskId = Guid.NewGuid().ToString();
var task = new TaskEntity
{
Id = taskId,
ListId = listId,
Title = "test task",
Description = "a description",
CommitType = "chore",
CreatedAt = DateTime.UtcNow,
};
var list = new ListEntity
{
Id = listId,
Name = "Test List",
WorkingDir = workingDir,
CreatedAt = DateTime.UtcNow,
};
return (task, list);
}
public void Dispose()
{
foreach (var (repoDir, wtPath) in _worktreeCleanups)
{
try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { }
}
foreach (var f in _fixtures) f.Dispose();
foreach (var db in _dbFixtures) db.Dispose();
foreach (var d in _tempDirs)
{
try { Directory.Delete(d, true); } catch { }
}
}
}

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
@@ -46,7 +47,9 @@ public sealed class QueueServiceTests : IDisposable
{
var fake = new FakeClaudeProcess(handler);
var broadcaster = new HubBroadcaster(new FakeHubContext());
var runner = new TaskRunner(fake, _taskRepo, _listRepo, broadcaster, _cfg,
var wtRepo = new WorktreeRepository(_db.Factory);
var wtManager = new WorktreeManager(new GitService(), wtRepo, _cfg, NullLogger<WorktreeManager>.Instance);
var runner = new TaskRunner(fake, _taskRepo, _listRepo, broadcaster, wtManager, _cfg,
NullLogger<TaskRunner>.Instance);
var service = new QueueService(_taskRepo, runner, _cfg, NullLogger<QueueService>.Instance);
return (service, fake);