From bfa22616f0c9a1204a335f7177f7bf3dcd3ccd23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Tue, 13 Jan 2026 02:19:35 +0000 Subject: [PATCH] feat(worktree): semantic naming and branch cleanup on task deletion (#11) --- apps/backend/cmd/kandev/main.go | 1 + .../internal/agent/lifecycle/manager.go | 2 + .../backend/internal/agent/worktree/config.go | 65 ++++++++++ .../internal/agent/worktree/manager.go | 33 +++-- .../internal/agent/worktree/manager_test.go | 116 ++++++++++++++++++ .../internal/agent/worktree/worktree.go | 5 + .../orchestrator/executor/executor.go | 2 + 7 files changed, 217 insertions(+), 7 deletions(-) diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 631685519b..10fd6d3150 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -642,6 +642,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch // If empty, the agent will run without a mounted workspace launchReq := &lifecycle.LaunchRequest{ TaskID: req.TaskID, + TaskTitle: req.TaskTitle, AgentType: req.AgentType, WorkspacePath: req.RepositoryURL, // May be empty - lifecycle manager handles this TaskDescription: req.TaskDescription, diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 9d502d9b47..acff450786 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -60,6 +60,7 @@ type AgentInstance struct { // LaunchRequest contains parameters for launching an agent type LaunchRequest struct { TaskID string + TaskTitle string // Human-readable task title for semantic worktree naming AgentType string WorkspacePath string // Host path to workspace (original repository path) TaskDescription string // Task description to send via ACP prompt @@ -607,6 +608,7 @@ func (m *Manager) getOrCreateWorktree(ctx context.Context, req *LaunchRequest) ( // Create request with optional WorktreeID for resumption createReq := worktree.CreateRequest{ TaskID: req.TaskID, + TaskTitle: req.TaskTitle, RepositoryID: req.RepositoryID, RepositoryPath: req.RepositoryPath, BaseBranch: req.BaseBranch, diff --git a/apps/backend/internal/agent/worktree/config.go b/apps/backend/internal/agent/worktree/config.go index 10f74f149d..960e918578 100644 --- a/apps/backend/internal/agent/worktree/config.go +++ b/apps/backend/internal/agent/worktree/config.go @@ -3,7 +3,9 @@ package worktree import ( "os" "path/filepath" + "regexp" "strings" + "unicode" ) // Config holds configuration for the worktree manager. @@ -77,3 +79,66 @@ func (c *Config) BranchName(taskID, suffix string) string { return c.BranchPrefix + taskID + "_" + suffix } +// SemanticBranchName returns a branch name using a semantic name derived from task title. +// Format: {prefix}{semanticName}_{suffix} e.g. kandev/fix-login-bug_ab12cd34 +func (c *Config) SemanticBranchName(semanticName, suffix string) string { + return c.BranchPrefix + semanticName + "_" + suffix +} + +// SanitizeForBranch converts a task title into a valid git branch name component. +// It: +// - Converts to lowercase +// - Replaces spaces and special characters with hyphens +// - Removes consecutive hyphens +// - Truncates to maxLen characters +// - Removes leading/trailing hyphens +func SanitizeForBranch(title string, maxLen int) string { + if title == "" { + return "" + } + + // Convert to lowercase + result := strings.ToLower(title) + + // Replace any character that's not alphanumeric with a hyphen + // Git branch names allow: a-z, A-Z, 0-9, /, ., _, - + // We'll use only alphanumeric and hyphens for cleaner names + var sb strings.Builder + for _, r := range result { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + sb.WriteRune(r) + } else { + sb.WriteRune('-') + } + } + result = sb.String() + + // Remove consecutive hyphens + re := regexp.MustCompile(`-+`) + result = re.ReplaceAllString(result, "-") + + // Remove leading and trailing hyphens + result = strings.Trim(result, "-") + + // Truncate to maxLen + if len(result) > maxLen { + result = result[:maxLen] + // Remove trailing hyphen after truncation + result = strings.TrimRight(result, "-") + } + + return result +} + +// SemanticWorktreeName generates a semantic worktree directory name from a task title. +// Format: {sanitizedTitle}_{suffix} e.g. fix-login-bug_ab12cd34 +// The title is truncated to 20 characters before adding the suffix. +func SemanticWorktreeName(taskTitle, suffix string) string { + semanticName := SanitizeForBranch(taskTitle, 20) + if semanticName == "" { + // Fallback to just suffix if title is empty or all special chars + return suffix + } + return semanticName + "_" + suffix +} + diff --git a/apps/backend/internal/agent/worktree/manager.go b/apps/backend/internal/agent/worktree/manager.go index 6a2822217f..d3274cddba 100644 --- a/apps/backend/internal/agent/worktree/manager.go +++ b/apps/backend/internal/agent/worktree/manager.go @@ -186,16 +186,26 @@ func (m *Manager) createWorktree(ctx context.Context, req CreateRequest) (*Workt worktreeID := uuid.New().String() suffix := worktreeID[:8] // Use first 8 chars of UUID as suffix - // Worktree path uses the unique ID: ~/.kandev/worktrees/{task_id}_{suffix} - worktreeDirName := req.TaskID + "_" + suffix + // Generate semantic name from task title if available + var worktreeDirName, branchName string + if req.TaskTitle != "" { + // Use semantic naming: {sanitized-title}_{suffix} + semanticName := SemanticWorktreeName(req.TaskTitle, suffix) + worktreeDirName = semanticName + // For branch, use sanitized title (20 chars max) + suffix + sanitizedTitle := SanitizeForBranch(req.TaskTitle, 20) + branchName = m.config.SemanticBranchName(sanitizedTitle, suffix) + } else { + // Fallback to task ID based naming + worktreeDirName = req.TaskID + "_" + suffix + branchName = m.config.BranchName(req.TaskID, suffix) + } + worktreePath, err := m.config.WorktreePath(worktreeDirName) if err != nil { return nil, fmt.Errorf("failed to get worktree path: %w", err) } - // Branch name: kandev/{task_id}_{suffix} - branchName := m.config.BranchName(req.TaskID, suffix) - // Create worktree with new branch // git worktree add -b cmd := exec.CommandContext(ctx, "git", "worktree", "add", @@ -356,15 +366,24 @@ func (m *Manager) removeWorktree(ctx context.Context, wt *Worktree, removeBranch zap.Error(err)) } - // Optionally remove the branch + // Optionally remove the branch from the main repository if removeBranch { + m.logger.Info("deleting branch from main repository", + zap.String("branch", wt.Branch), + zap.String("repository_path", wt.RepositoryPath)) + cmd := exec.CommandContext(ctx, "git", "branch", "-D", wt.Branch) cmd.Dir = wt.RepositoryPath if output, err := cmd.CombinedOutput(); err != nil { - m.logger.Warn("failed to delete branch", + m.logger.Warn("failed to delete branch from main repository", zap.String("branch", wt.Branch), + zap.String("repository_path", wt.RepositoryPath), zap.String("output", string(output)), zap.Error(err)) + } else { + m.logger.Info("successfully deleted branch from main repository", + zap.String("branch", wt.Branch), + zap.String("repository_path", wt.RepositoryPath)) } } diff --git a/apps/backend/internal/agent/worktree/manager_test.go b/apps/backend/internal/agent/worktree/manager_test.go index d5a072ef75..5175e87708 100644 --- a/apps/backend/internal/agent/worktree/manager_test.go +++ b/apps/backend/internal/agent/worktree/manager_test.go @@ -171,3 +171,119 @@ func TestManager_IsValid(t *testing.T) { t.Error("expected true for valid worktree directory") } } + +func TestSanitizeForBranch(t *testing.T) { + tests := []struct { + name string + title string + maxLen int + expected string + }{ + { + name: "simple title", + title: "Fix login bug", + maxLen: 20, + expected: "fix-login-bug", + }, + { + name: "title with special chars", + title: "Fix: bug #123 (urgent!)", + maxLen: 20, + expected: "fix-bug-123-urgent", + }, + { + name: "title exceeding max length", + title: "This is a very long task title that needs truncation", + maxLen: 20, + expected: "this-is-a-very-long", + }, + { + name: "title with consecutive spaces", + title: "Fix multiple spaces", + maxLen: 20, + expected: "fix-multiple-spaces", + }, + { + name: "empty title", + title: "", + maxLen: 20, + expected: "", + }, + { + name: "title starting and ending with special chars", + title: "---Fix bug---", + maxLen: 20, + expected: "fix-bug", + }, + { + name: "title with numbers", + title: "Task 123 done", + maxLen: 20, + expected: "task-123-done", + }, + { + name: "truncation at boundary", + title: "Fix the login page bug", + maxLen: 15, + expected: "fix-the-login-p", + }, + { + name: "truncation at hyphen position removes trailing hyphen", + title: "Fix the login-page bug", + maxLen: 13, + expected: "fix-the-login", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeForBranch(tt.title, tt.maxLen) + if result != tt.expected { + t.Errorf("SanitizeForBranch(%q, %d) = %q, want %q", tt.title, tt.maxLen, result, tt.expected) + } + }) + } +} + +func TestSemanticWorktreeName(t *testing.T) { + tests := []struct { + name string + taskTitle string + suffix string + expected string + }{ + { + name: "normal title with suffix", + taskTitle: "Fix login bug", + suffix: "ab12cd34", + expected: "fix-login-bug_ab12cd34", + }, + { + name: "long title truncated", + taskTitle: "This is a very long task title that needs truncation", + suffix: "ab12cd34", + expected: "this-is-a-very-long_ab12cd34", + }, + { + name: "empty title falls back to suffix only", + taskTitle: "", + suffix: "ab12cd34", + expected: "ab12cd34", + }, + { + name: "title with only special chars", + taskTitle: "!@#$%^&*()", + suffix: "ab12cd34", + expected: "ab12cd34", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SemanticWorktreeName(tt.taskTitle, tt.suffix) + if result != tt.expected { + t.Errorf("SemanticWorktreeName(%q, %q) = %q, want %q", tt.taskTitle, tt.suffix, result, tt.expected) + } + }) + } +} diff --git a/apps/backend/internal/agent/worktree/worktree.go b/apps/backend/internal/agent/worktree/worktree.go index 2be561718a..5843bf0498 100644 --- a/apps/backend/internal/agent/worktree/worktree.go +++ b/apps/backend/internal/agent/worktree/worktree.go @@ -49,6 +49,11 @@ type CreateRequest struct { // TaskID is the unique task identifier (required). TaskID string + // TaskTitle is the human-readable task title (optional). + // If provided, it will be used to generate semantic worktree/branch names. + // The title is sanitized and truncated to 20 characters. + TaskTitle string + // RepositoryID is the repository identifier (required). RepositoryID string diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 9431654ddb..cbf8ca34c0 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -58,6 +58,7 @@ type AgentManagerClient interface { // LaunchAgentRequest contains parameters for launching an agent type LaunchAgentRequest struct { TaskID string + TaskTitle string // Human-readable task title for semantic worktree naming AgentType string RepositoryURL string Branch string @@ -276,6 +277,7 @@ func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, // Create a LaunchAgentRequest from the task req := &LaunchAgentRequest{ TaskID: task.ID, + TaskTitle: task.Title, AgentType: *task.AgentType, TaskDescription: task.Description, Priority: task.Priority,