From 4a9cff554309530f94b2ccb70fc08de9fb29fbb0 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 1 Oct 2026 10:30:51 +0200 Subject: [PATCH] feat(worker): branch type and number rules for task branches --- .../Runner/CommitMessageBuilder.cs | 4 +- src/ClaudeDo.Worker/Runner/WorktreeManager.cs | 25 ++++++++++-- .../Runner/CommitMessageBuilderTests.cs | 6 +++ .../Runner/WorktreeManagerTests.cs | 39 ++++++++++++++----- 4 files changed, 59 insertions(+), 15 deletions(-) diff --git a/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs b/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs index 9e33cd5a..ab2b7dba 100644 --- a/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs +++ b/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs @@ -48,7 +48,9 @@ public static class CommitMessageBuilder public static string ToSlug(string name) { - var lower = name.ToLowerInvariant(); + // Umlaute auflösen statt strippen: "Löschen" -> "loeschen", nicht "lschen". + var lower = name.ToLowerInvariant() + .Replace("ä", "ae").Replace("ö", "oe").Replace("ü", "ue").Replace("ß", "ss"); // Replace whitespace runs with a single dash. var dashed = Regex.Replace(lower, @"\s+", "-"); // Remove all non-alphanumeric-and-dash characters. diff --git a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs index 9fe83a29..976b7167 100644 --- a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs +++ b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs @@ -38,12 +38,29 @@ public sealed class WorktreeManager private Task BroadcastCreating(string taskId) => _broadcaster?.OperationProgress(taskId, PhaseCreatingWorktree, 0, 0) ?? Task.CompletedTask; - /// Branch leaf and folder name for a task worktree: {number}-{title-slug}, unique via the task number. - public static string NameFor(TaskEntity task) + /// + /// Branch leaf and folder name: {nummer}-{slug}. Nummer = Ticket-Id, sonst die Task-Nummer — + /// außer im Firmen-Repo ohne Ticket, dort sähe sie wie eine Ticket-Id aus und entfällt. + /// + public static string NameFor(TaskEntity task, int? ticketId, bool isCompanyRepo) { var titleSlug = CommitMessageBuilder.ToSlug(task.Title); if (titleSlug.Length > 40) titleSlug = titleSlug[..40].TrimEnd('-'); - return titleSlug.Length == 0 ? task.Number.ToString() : $"{task.Number}-{titleSlug}"; + var number = ticketId?.ToString() ?? (isCompanyRepo ? null : task.Number.ToString()); + if (titleSlug.Length == 0) return number ?? task.Number.ToString(); + return number is null ? titleSlug : $"{number}-{titleSlug}"; + } + + /// Branch-Präfix nach work-conventions: feat → feature, Typen ohne eigenes Präfix → chore. + public static string BranchTypeFor(string? commitType) + { + var t = (commitType ?? "").Trim().ToLowerInvariant(); + return t switch + { + "feat" => "feature", + "fix" or "refactor" or "docs" or "test" or "chore" => t, + _ => "chore", + }; } public async Task CreateAsync(TaskEntity task, ListEntity list, CancellationToken ct) @@ -59,7 +76,7 @@ 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); + var name = NameFor(task, null, false); var slug = CommitMessageBuilder.ToSlug(list.Name); string strategy; diff --git a/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs index 0e4b6529..5984c9b3 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs @@ -28,6 +28,12 @@ public class CommitMessageBuilderTests Assert.Equal("abc", CommitMessageBuilder.ToSlug("--abc--")); } + [Fact] + public void ToSlug_transliterates_umlauts() + { + Assert.Equal("aerger-ueber-groesse-strasse", CommitMessageBuilder.ToSlug("Ärger über Größe Straße")); + } + [Fact] public void Title_TruncatedTo60() { diff --git a/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs index eec0c552..934e42ed 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs @@ -80,14 +80,33 @@ public class WorktreeManagerTests : IDisposable } [Theory] - [InlineData("Fix: Löschen des Tasks!", "7-fix-lschen-des-tasks")] - [InlineData("äöü", "7")] - [InlineData("a very long title that goes well beyond the forty char cap", "7-a-very-long-title-that-goes-well-beyond")] - public void NameFor_NumberPlusCappedTitleSlug(string title, string expected) + [InlineData("Fix: Löschen des Tasks!", null, false, "7-fix-loeschen-des-tasks")] + [InlineData("äöü", null, false, "7-aeoeue")] + [InlineData("a very long title that goes well beyond the forty char cap", null, false, "7-a-very-long-title-that-goes-well-beyond")] + [InlineData("test task", 412, false, "412-test-task")] + [InlineData("test task", 412, true, "412-test-task")] + [InlineData("test task", null, true, "test-task")] + [InlineData("!!!", null, true, "7")] + public void NameFor_NumberRules(string title, int? ticketId, bool isCompany, string expected) { - Assert.Equal(expected, WorktreeManager.NameFor(new TaskEntity { Id = "x", ListId = "y", Title = title, Number = 7, CreatedAt = DateTime.UtcNow })); + var task = new TaskEntity { Id = "x", ListId = "y", Title = title, Number = 7, CreatedAt = DateTime.UtcNow }; + Assert.Equal(expected, WorktreeManager.NameFor(task, ticketId, isCompany)); } + [Theory] + [InlineData("feat", "feature")] + [InlineData("fix", "fix")] + [InlineData("refactor", "refactor")] + [InlineData("docs", "docs")] + [InlineData("test", "test")] + [InlineData("chore", "chore")] + [InlineData("ci", "chore")] + [InlineData("perf", "chore")] + [InlineData(" FEAT ", "feature")] + [InlineData("", "chore")] + public void BranchTypeFor_maps_commit_types(string commitType, string expected) + => Assert.Equal(expected, WorktreeManager.BranchTypeFor(commitType)); + [Fact] public async Task CreateAsync_BroadcastsCreatingWorktreePhase() { @@ -128,13 +147,13 @@ 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)}"; + var foreign = $"claudedo/{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)}-{task.Id.Replace("-", "")[..8]}"; + var suffixed = $"{WorktreeManager.NameFor(task, null, false)}-{task.Id.Replace("-", "")[..8]}"; Assert.Equal($"claudedo/{suffixed}", ctx.BranchName); Assert.Equal(suffixed, Path.GetFileName(ctx.WorktreePath)); Assert.False(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "branch", "--list", foreign))); @@ -157,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)}"; + var branchName = $"claudedo/{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); @@ -311,13 +330,13 @@ public class WorktreeManagerTests : IDisposable await Assert.ThrowsAnyAsync( () => mgr.CreateAsync(task, list, CancellationToken.None)); - var branchName = $"claudedo/{WorktreeManager.NameFor(task)}"; + var branchName = $"claudedo/{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}"); var worktreeList = GitRepoFixture.RunGit(repo.RepoDir, "worktree", "list"); - Assert.DoesNotContain(WorktreeManager.NameFor(task), worktreeList); + Assert.DoesNotContain(WorktreeManager.NameFor(task, null, false), worktreeList); } [Fact]