feat(worker): branch type and number rules for task branches
This commit is contained in:
@@ -48,7 +48,9 @@ public static class CommitMessageBuilder
|
|||||||
|
|
||||||
public static string ToSlug(string name)
|
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.
|
// Replace whitespace runs with a single dash.
|
||||||
var dashed = Regex.Replace(lower, @"\s+", "-");
|
var dashed = Regex.Replace(lower, @"\s+", "-");
|
||||||
// Remove all non-alphanumeric-and-dash characters.
|
// Remove all non-alphanumeric-and-dash characters.
|
||||||
|
|||||||
@@ -38,12 +38,29 @@ public sealed class WorktreeManager
|
|||||||
private Task BroadcastCreating(string taskId) =>
|
private Task BroadcastCreating(string taskId) =>
|
||||||
_broadcaster?.OperationProgress(taskId, PhaseCreatingWorktree, 0, 0) ?? Task.CompletedTask;
|
_broadcaster?.OperationProgress(taskId, PhaseCreatingWorktree, 0, 0) ?? Task.CompletedTask;
|
||||||
|
|
||||||
/// <summary>Branch leaf and folder name for a task worktree: <c>{number}-{title-slug}</c>, unique via the task number.</summary>
|
/// <summary>
|
||||||
public static string NameFor(TaskEntity task)
|
/// Branch leaf and folder name: <c>{nummer}-{slug}</c>. 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.
|
||||||
|
/// </summary>
|
||||||
|
public static string NameFor(TaskEntity task, int? ticketId, bool isCompanyRepo)
|
||||||
{
|
{
|
||||||
var titleSlug = CommitMessageBuilder.ToSlug(task.Title);
|
var titleSlug = CommitMessageBuilder.ToSlug(task.Title);
|
||||||
if (titleSlug.Length > 40) titleSlug = titleSlug[..40].TrimEnd('-');
|
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}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Branch-Präfix nach work-conventions: <c>feat</c> → <c>feature</c>, Typen ohne eigenes Präfix → <c>chore</c>.</summary>
|
||||||
|
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<WorktreeContext> CreateAsync(TaskEntity task, ListEntity list, CancellationToken ct)
|
public async Task<WorktreeContext> 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);
|
_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);
|
var name = NameFor(task, null, false);
|
||||||
var slug = CommitMessageBuilder.ToSlug(list.Name);
|
var slug = CommitMessageBuilder.ToSlug(list.Name);
|
||||||
|
|
||||||
string strategy;
|
string strategy;
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ public class CommitMessageBuilderTests
|
|||||||
Assert.Equal("abc", CommitMessageBuilder.ToSlug("--abc--"));
|
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]
|
[Fact]
|
||||||
public void Title_TruncatedTo60()
|
public void Title_TruncatedTo60()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -80,14 +80,33 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("Fix: Löschen des Tasks!", "7-fix-lschen-des-tasks")]
|
[InlineData("Fix: Löschen des Tasks!", null, false, "7-fix-loeschen-des-tasks")]
|
||||||
[InlineData("äöü", "7")]
|
[InlineData("äöü", null, false, "7-aeoeue")]
|
||||||
[InlineData("a very long title that goes well beyond the forty char cap", "7-a-very-long-title-that-goes-well-beyond")]
|
[InlineData("a very long title that goes well beyond the forty char cap", null, false, "7-a-very-long-title-that-goes-well-beyond")]
|
||||||
public void NameFor_NumberPlusCappedTitleSlug(string title, string expected)
|
[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]
|
[Fact]
|
||||||
public async Task CreateAsync_BroadcastsCreatingWorktreePhase()
|
public async Task CreateAsync_BroadcastsCreatingWorktreePhase()
|
||||||
{
|
{
|
||||||
@@ -128,13 +147,13 @@ 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)}";
|
var foreign = $"claudedo/{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)}-{task.Id.Replace("-", "")[..8]}";
|
var suffixed = $"{WorktreeManager.NameFor(task, null, false)}-{task.Id.Replace("-", "")[..8]}";
|
||||||
Assert.Equal($"claudedo/{suffixed}", ctx.BranchName);
|
Assert.Equal($"claudedo/{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)));
|
||||||
@@ -157,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)}";
|
var branchName = $"claudedo/{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);
|
||||||
|
|
||||||
@@ -311,13 +330,13 @@ 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)}";
|
var branchName = $"claudedo/{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}");
|
||||||
|
|
||||||
var worktreeList = GitRepoFixture.RunGit(repo.RepoDir, "worktree", "list");
|
var worktreeList = GitRepoFixture.RunGit(repo.RepoDir, "worktree", "list");
|
||||||
Assert.DoesNotContain(WorktreeManager.NameFor(task), worktreeList);
|
Assert.DoesNotContain(WorktreeManager.NameFor(task, null, false), worktreeList);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user