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:
@@ -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);
|
||||
}
|
||||
}
|
||||
186
tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs
Normal file
186
tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs
Normal 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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user