feat(worker): name task branches <type>/<number>-<slug>
This commit is contained in:
@@ -5,6 +5,7 @@ using ClaudeDo.Data.Repositories;
|
|||||||
using ClaudeDo.Worker.Config;
|
using ClaudeDo.Worker.Config;
|
||||||
using ClaudeDo.Worker.Git;
|
using ClaudeDo.Worker.Git;
|
||||||
using ClaudeDo.Worker.Hub;
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Tickets;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Runner;
|
namespace ClaudeDo.Worker.Runner;
|
||||||
@@ -76,7 +77,9 @@ public sealed class WorktreeManager
|
|||||||
_logger.LogInformation("Repository at {Dir} had no commits; created an empty bootstrap commit", workingDir);
|
_logger.LogInformation("Repository at {Dir} had no commits; created an empty bootstrap commit", workingDir);
|
||||||
|
|
||||||
var baseCommit = await ResolveBaseCommitAsync(task, workingDir, ct);
|
var baseCommit = await ResolveBaseCommitAsync(task, workingDir, ct);
|
||||||
var name = NameFor(task, null, false);
|
var isCompany = await CompanyRepo.IsCompanyAsync(_git, workingDir, _cfg.CompanyRemotePattern, ct);
|
||||||
|
var name = NameFor(task, await ResolveTicketIdAsync(task, ct), isCompany);
|
||||||
|
var branchType = BranchTypeFor(task.CommitType);
|
||||||
var slug = CommitMessageBuilder.ToSlug(list.Name);
|
var slug = CommitMessageBuilder.ToSlug(list.Name);
|
||||||
|
|
||||||
string strategy;
|
string strategy;
|
||||||
@@ -94,16 +97,16 @@ public sealed class WorktreeManager
|
|||||||
? Path.Combine(centralRoot ?? _cfg.CentralWorktreeRoot, slug)
|
? Path.Combine(centralRoot ?? _cfg.CentralWorktreeRoot, slug)
|
||||||
: Path.Combine(WorktreeRootResolver.ResolveSiblingRoot(workingDir), ".claudedo-worktrees", slug);
|
: Path.Combine(WorktreeRootResolver.ResolveSiblingRoot(workingDir), ".claudedo-worktrees", slug);
|
||||||
|
|
||||||
// Numbers restart after a DB reset: a taken name may be another task's unmerged branch,
|
// Numbers restart after a DB reset, and without the claudedo/ prefix a taken name may also be
|
||||||
// which the self-heal below would force-delete — the id suffix keeps it untouched.
|
// the user's own branch — the self-heal below would force-delete it, the id suffix keeps it untouched.
|
||||||
if (Directory.Exists(Path.Combine(parentDir, name))
|
if (Directory.Exists(Path.Combine(parentDir, name))
|
||||||
|| (await _git.ListLocalBranchesAsync(workingDir, ct)).Contains($"claudedo/{name}"))
|
|| (await _git.ListLocalBranchesAsync(workingDir, ct)).Contains($"{branchType}/{name}"))
|
||||||
{
|
{
|
||||||
var id = task.Id.Replace("-", "");
|
var id = task.Id.Replace("-", "");
|
||||||
name = $"{name}-{id[..Math.Min(8, id.Length)]}";
|
name = $"{name}-{id[..Math.Min(8, id.Length)]}";
|
||||||
}
|
}
|
||||||
|
|
||||||
var branchName = $"claudedo/{name}";
|
var branchName = $"{branchType}/{name}";
|
||||||
var worktreePath = Path.GetFullPath(Path.Combine(parentDir, name));
|
var worktreePath = Path.GetFullPath(Path.Combine(parentDir, name));
|
||||||
WorktreeRootResolver.EnsureOutsideWorkingDir(worktreePath, workingDir);
|
WorktreeRootResolver.EnsureOutsideWorkingDir(worktreePath, workingDir);
|
||||||
|
|
||||||
@@ -243,6 +246,19 @@ public sealed class WorktreeManager
|
|||||||
// Improvement children (parent is a non-planning task with its own worktree) branch
|
// Improvement children (parent is a non-planning task with its own worktree) branch
|
||||||
// from the parent's recorded HEAD so they build on the parent's not-yet-merged work.
|
// from the parent's recorded HEAD so they build on the parent's not-yet-merged work.
|
||||||
// Planning children and standalone tasks base off the list's current HEAD.
|
// Planning children and standalone tasks base off the list's current HEAD.
|
||||||
|
/// <summary>Ticket-Id des Tasks, sonst die seines Parents (Children erben das Ticket ihrer Unit).</summary>
|
||||||
|
private async Task<int?> ResolveTicketIdAsync(TaskEntity task, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var own = TicketStatusMap.ParseBandelTicketId(task.TicketRef);
|
||||||
|
if (own is not null || task.ParentTaskId is null) return own;
|
||||||
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
var parentRef = await ctx.Tasks.AsNoTracking()
|
||||||
|
.Where(t => t.Id == task.ParentTaskId)
|
||||||
|
.Select(t => t.TicketRef)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
return TicketStatusMap.ParseBandelTicketId(parentRef);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct)
|
private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (task.ParentTaskId is not null)
|
if (task.ParentTaskId is not null)
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
Assert.NotNull(ctx);
|
Assert.NotNull(ctx);
|
||||||
Assert.True(Directory.Exists(ctx.WorktreePath));
|
Assert.True(Directory.Exists(ctx.WorktreePath));
|
||||||
// StartsWith: a folder leaked into the shared %TEMP% root by another test forces the id suffix.
|
// StartsWith: a folder leaked into the shared %TEMP% root by another test forces the id suffix.
|
||||||
Assert.StartsWith($"claudedo/{task.Number}-test-task", ctx.BranchName);
|
Assert.StartsWith($"chore/{task.Number}-test-task", ctx.BranchName);
|
||||||
Assert.StartsWith($"{task.Number}-test-task", Path.GetFileName(ctx.WorktreePath));
|
Assert.StartsWith($"{task.Number}-test-task", Path.GetFileName(ctx.WorktreePath));
|
||||||
Assert.Equal(repo.BaseCommit, ctx.BaseCommit);
|
Assert.Equal(repo.BaseCommit, ctx.BaseCommit);
|
||||||
|
|
||||||
@@ -147,14 +147,14 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
var (mgr, _) = await CreateManagerAsync(task, list);
|
var (mgr, _) = await CreateManagerAsync(task, list);
|
||||||
|
|
||||||
// A leftover from an earlier DB whose task happened to get the same number and title.
|
// A leftover from an earlier DB whose task happened to get the same number and title.
|
||||||
var foreign = $"claudedo/{WorktreeManager.NameFor(task, null, false)}";
|
var foreign = $"chore/{WorktreeManager.NameFor(task, null, false)}";
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", foreign, repo.BaseCommit);
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", foreign, repo.BaseCommit);
|
||||||
|
|
||||||
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
||||||
|
|
||||||
var suffixed = $"{WorktreeManager.NameFor(task, null, false)}-{task.Id.Replace("-", "")[..8]}";
|
var suffixed = $"{WorktreeManager.NameFor(task, null, false)}-{task.Id.Replace("-", "")[..8]}";
|
||||||
Assert.Equal($"claudedo/{suffixed}", ctx.BranchName);
|
Assert.Equal($"chore/{suffixed}", ctx.BranchName);
|
||||||
Assert.Equal(suffixed, Path.GetFileName(ctx.WorktreePath));
|
Assert.Equal(suffixed, Path.GetFileName(ctx.WorktreePath));
|
||||||
Assert.False(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "branch", "--list", foreign)));
|
Assert.False(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "branch", "--list", foreign)));
|
||||||
}
|
}
|
||||||
@@ -176,7 +176,7 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
|
|
||||||
// Occupy the plain name (-> id-suffixed fallback) and the fallback too, so
|
// Occupy the plain name (-> id-suffixed fallback) and the fallback too, so
|
||||||
// `git worktree add -b` hits the "already exists" self-heal path.
|
// `git worktree add -b` hits the "already exists" self-heal path.
|
||||||
var branchName = $"claudedo/{WorktreeManager.NameFor(task, null, false)}";
|
var branchName = $"chore/{WorktreeManager.NameFor(task, null, false)}";
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", branchName, repo.BaseCommit);
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", branchName, repo.BaseCommit);
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", $"{branchName}-{task.Id.Replace("-", "")[..8]}", repo.BaseCommit);
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", $"{branchName}-{task.Id.Replace("-", "")[..8]}", repo.BaseCommit);
|
||||||
|
|
||||||
@@ -330,7 +330,7 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
await Assert.ThrowsAnyAsync<Exception>(
|
await Assert.ThrowsAnyAsync<Exception>(
|
||||||
() => mgr.CreateAsync(task, list, CancellationToken.None));
|
() => mgr.CreateAsync(task, list, CancellationToken.None));
|
||||||
|
|
||||||
var branchName = $"claudedo/{WorktreeManager.NameFor(task, null, false)}";
|
var branchName = $"chore/{WorktreeManager.NameFor(task, null, false)}";
|
||||||
var branchList = GitRepoFixture.RunGit(repo.RepoDir, "branch", "--list", branchName);
|
var branchList = GitRepoFixture.RunGit(repo.RepoDir, "branch", "--list", branchName);
|
||||||
Assert.True(string.IsNullOrWhiteSpace(branchList),
|
Assert.True(string.IsNullOrWhiteSpace(branchList),
|
||||||
$"orphaned branch {branchName} should be cleaned up, got: {branchList}");
|
$"orphaned branch {branchName} should be cleaned up, got: {branchList}");
|
||||||
@@ -359,6 +359,72 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
Assert.True(Directory.Exists(ctx.WorktreePath));
|
Assert.True(Directory.Exists(ctx.WorktreePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_CompanyRepoWithTicket_UsesTicketIdAndFeaturePrefix()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = CreateRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "remote", "add", "origin", "https://srv.local/Coll/Proj/_git/Repo");
|
||||||
|
var (task, list) = MakeEntities(repo.RepoDir);
|
||||||
|
task.CommitType = "feat";
|
||||||
|
task.TicketRef = "bandel:412";
|
||||||
|
var (mgr, _) = await CreateManagerAsync(task, list);
|
||||||
|
|
||||||
|
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
||||||
|
|
||||||
|
Assert.StartsWith("feature/412-test-task", ctx.BranchName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_CompanyRepoWithoutTicket_OmitsNumber()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = CreateRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "remote", "add", "origin", "https://srv.local/Coll/Proj/_git/Repo");
|
||||||
|
var (task, list) = MakeEntities(repo.RepoDir);
|
||||||
|
var (mgr, _) = await CreateManagerAsync(task, list);
|
||||||
|
|
||||||
|
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
||||||
|
|
||||||
|
Assert.StartsWith("chore/test-task", ctx.BranchName);
|
||||||
|
Assert.DoesNotContain($"/{task.Number}-", ctx.BranchName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_ChildWithoutTicket_UsesParentTicket()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = CreateRepo();
|
||||||
|
var (child, list) = MakeEntities(repo.RepoDir);
|
||||||
|
var parent = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(), ListId = list.Id, Title = "parent", CommitType = "feat",
|
||||||
|
TicketRef = "bandel:77", CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
child.ParentTaskId = parent.Id;
|
||||||
|
|
||||||
|
var db = new DbFixture();
|
||||||
|
_dbFixtures.Add(db);
|
||||||
|
using (var seedCtx = db.CreateContext())
|
||||||
|
{
|
||||||
|
await new ListRepository(seedCtx).AddAsync(list);
|
||||||
|
await new TaskRepository(seedCtx).AddAsync(parent);
|
||||||
|
await new TaskRepository(seedCtx).AddAsync(child);
|
||||||
|
}
|
||||||
|
var mgr = new WorktreeManager(new GitService(), db.CreateFactory(),
|
||||||
|
new WorkerConfig { WorktreeRootStrategy = "sibling" }, NullLogger<WorktreeManager>.Instance);
|
||||||
|
|
||||||
|
var ctx = await mgr.CreateAsync(child, list, CancellationToken.None);
|
||||||
|
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
||||||
|
|
||||||
|
Assert.StartsWith("chore/77-test-task", ctx.BranchName);
|
||||||
|
}
|
||||||
|
|
||||||
private static (TaskEntity task, ListEntity list) MakeEntities(string workingDir)
|
private static (TaskEntity task, ListEntity list) MakeEntities(string workingDir)
|
||||||
{
|
{
|
||||||
var listId = Guid.NewGuid().ToString();
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
|||||||
Reference in New Issue
Block a user