feat(worktree): semantic naming and branch cleanup on task deletion (#11)
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user