feat(worktree): semantic naming and branch cleanup on task deletion (#11)

This commit is contained in:
João Salavisa
2026-01-13 02:19:35 +00:00
committed by GitHub
parent 6d9f78b7b7
commit bfa22616f0
7 changed files with 217 additions and 7 deletions
+1
View File
@@ -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,
@@ -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,
@@ -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
}
@@ -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 <branch> <path> <base-branch>
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))
}
}
@@ -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)
}
})
}
}
@@ -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
@@ -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,