diff --git a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs index 976b7167..8d19efe2 100644 --- a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs +++ b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs @@ -5,6 +5,7 @@ using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Git; using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Tickets; using Microsoft.EntityFrameworkCore; 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); 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); string strategy; @@ -94,16 +97,16 @@ public sealed class WorktreeManager ? Path.Combine(centralRoot ?? _cfg.CentralWorktreeRoot, 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, - // which the self-heal below would force-delete — the id suffix keeps it untouched. + // Numbers restart after a DB reset, and without the claudedo/ prefix a taken name may also be + // 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)) - || (await _git.ListLocalBranchesAsync(workingDir, ct)).Contains($"claudedo/{name}")) + || (await _git.ListLocalBranchesAsync(workingDir, ct)).Contains($"{branchType}/{name}")) { var id = task.Id.Replace("-", ""); name = $"{name}-{id[..Math.Min(8, id.Length)]}"; } - var branchName = $"claudedo/{name}"; + var branchName = $"{branchType}/{name}"; var worktreePath = Path.GetFullPath(Path.Combine(parentDir, name)); 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 // 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. + /// Ticket-Id des Tasks, sonst die seines Parents (Children erben das Ticket ihrer Unit). + private async Task 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 ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct) { if (task.ParentTaskId is not null) diff --git a/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs index 934e42ed..3f6a811f 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs @@ -66,7 +66,7 @@ public class WorktreeManagerTests : IDisposable Assert.NotNull(ctx); Assert.True(Directory.Exists(ctx.WorktreePath)); // 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.Equal(repo.BaseCommit, ctx.BaseCommit); @@ -147,14 +147,14 @@ public class WorktreeManagerTests : IDisposable var (mgr, _) = await CreateManagerAsync(task, list); // 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); var ctx = await mgr.CreateAsync(task, list, CancellationToken.None); _worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath)); 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.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 // `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}-{task.Id.Replace("-", "")[..8]}", repo.BaseCommit); @@ -330,7 +330,7 @@ public class WorktreeManagerTests : IDisposable await Assert.ThrowsAnyAsync( () => 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); Assert.True(string.IsNullOrWhiteSpace(branchList), $"orphaned branch {branchName} should be cleaned up, got: {branchList}"); @@ -359,6 +359,72 @@ public class WorktreeManagerTests : IDisposable 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.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) { var listId = Guid.NewGuid().ToString();