From ff2647bf13ad8d25cf0985e63ff82018f09f18fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 9 Jul 2026 19:59:29 +0100 Subject: [PATCH] feat: add configurable branch names (#1615) --- .../internal/agent/handlers/git_handlers.go | 13 + .../agent/runtime/lifecycle/env_preparer.go | 62 ++--- .../runtime/lifecycle/env_preparer_docker.go | 11 + .../lifecycle/env_preparer_worktree.go | 36 +-- .../agent/runtime/lifecycle/manager_launch.go | 76 +++--- .../internal/agent/runtime/lifecycle/types.go | 80 +++--- apps/backend/internal/backendapp/adapters.go | 56 +++-- .../backendapp/branch_materializer.go | 28 ++- apps/backend/internal/backendapp/gateway.go | 13 + apps/backend/internal/backendapp/helpers.go | 32 +++ .../internal/backendapp/helpers_test.go | 76 ++++++ .../orchestrator/executor/executor.go | 52 ++-- .../executor/executor_environment_reuse.go | 28 ++- .../orchestrator/executor/executor_execute.go | 20 +- .../orchestrator/executor/executor_resume.go | 25 +- apps/backend/internal/task/dto/dto.go | 76 +++--- apps/backend/internal/task/dto/requests.go | 62 ++--- .../task/handlers/repository_handlers.go | 232 +++++++++--------- apps/backend/internal/task/models/models.go | 29 +-- .../internal/task/repository/interface.go | 1 + .../task/repository/sqlite/base_migrations.go | 2 + .../task/repository/sqlite/base_schema.go | 1 + .../repository/sqlite/repository_entity.go | 22 +- .../task/repository/sqlite/session.go | 17 ++ .../task/repository/sqlite/session_test.go | 47 ++++ .../internal/task/service/service_requests.go | 60 ++--- .../task/service/service_resources.go | 44 ++-- .../internal/task/service/service_test.go | 59 +++++ .../internal/worktree/branch_template_test.go | 112 +++++++++ apps/backend/internal/worktree/config.go | 177 ++++++++++++- .../internal/worktree/manager_lifecycle.go | 20 +- .../internal/worktree/manager_names_test.go | 20 ++ apps/backend/internal/worktree/worktree.go | 7 + apps/web/app/actions/workspaces.ts | 2 + .../workspace-repositories-client.tsx | 4 + .../workspace-repositories-dirty.test.ts | 14 ++ .../workspace/workspace-repositories-dirty.ts | 38 +-- .../automations/automation-payload.ts | 2 + .../repository-branch-template-help.tsx | 63 +++++ .../components/settings/repository-card.tsx | 37 ++- .../components/task/changes-panel-header.tsx | 138 ++++++++++- apps/web/components/task/changes-panel.tsx | 1 + .../task/mobile/mobile-changes-panel.tsx | 1 + .../session/session-resume-dedup.spec.ts | 33 ++- .../hooks/domains/session/use-session-git.ts | 2 +- apps/web/hooks/use-git-operations.ts | 9 +- apps/web/lib/types/http.ts | 1 + apps/web/lib/worktree-branch-template.ts | 1 + ...0032-configurable-worktree-branch-names.md | 44 ++++ docs/decisions/INDEX.md | 1 + 50 files changed, 1460 insertions(+), 527 deletions(-) create mode 100644 apps/backend/internal/worktree/branch_template_test.go create mode 100644 apps/web/components/settings/repository-branch-template-help.tsx create mode 100644 apps/web/lib/worktree-branch-template.ts create mode 100644 docs/decisions/0032-configurable-worktree-branch-names.md diff --git a/apps/backend/internal/agent/handlers/git_handlers.go b/apps/backend/internal/agent/handlers/git_handlers.go index e9f7a7a8f3..b629c3b6f4 100644 --- a/apps/backend/internal/agent/handlers/git_handlers.go +++ b/apps/backend/internal/agent/handlers/git_handlers.go @@ -38,6 +38,10 @@ type PRCreatedCallback func(ctx context.Context, sessionID, taskID, prURL, branc // Parameters: ctx, sessionID, taskID, operation name, error output. type GitOperationFailedCallback func(ctx context.Context, sessionID, taskID, operation, errorOutput string) +// BranchRenamedCallback is called after a branch is successfully renamed. +// Parameters: ctx, sessionID, new branch name, repo subpath. +type BranchRenamedCallback func(ctx context.Context, sessionID, newName, repo string) + // ExecutionLookup provides access to running agent executions by session ID. type ExecutionLookup interface { GetExecutionBySessionID(sessionID string) (*lifecycle.AgentExecution, bool) @@ -74,6 +78,7 @@ type GitHandlers struct { logger *logger.Logger onPRCreated PRCreatedCallback onGitOperationFailed GitOperationFailedCallback + onBranchRenamed BranchRenamedCallback commitsGroup singleflight.Group diffGroup singleflight.Group } @@ -105,6 +110,11 @@ func (h *GitHandlers) SetOnGitOperationFailed(cb GitOperationFailedCallback) { h.onGitOperationFailed = cb } +// SetOnBranchRenamed sets a callback invoked after a branch is successfully renamed. +func (h *GitHandlers) SetOnBranchRenamed(cb BranchRenamedCallback) { + h.onBranchRenamed = cb +} + // notifyGitOperationFailed fires the failure callback asynchronously if the result indicates failure. func (h *GitHandlers) notifyGitOperationFailed(sessionID, operation string, result *client.GitOperationResult) { if result == nil || result.Success || h.onGitOperationFailed == nil { @@ -447,6 +457,9 @@ func (h *GitHandlers) wsRenameBranch(ctx context.Context, msg *ws.Message) (*ws. if err != nil { return nil, fmt.Errorf("rename branch failed: %w", err) } + if result.Success && h.onBranchRenamed != nil { + h.onBranchRenamed(ctx, req.SessionID, req.NewName, req.Repo) + } return ws.NewResponse(msg.ID, msg.Action, result) } diff --git a/apps/backend/internal/agent/runtime/lifecycle/env_preparer.go b/apps/backend/internal/agent/runtime/lifecycle/env_preparer.go index adb388db38..2a13c4bcc6 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/env_preparer.go +++ b/apps/backend/internal/agent/runtime/lifecycle/env_preparer.go @@ -26,18 +26,20 @@ const ( // carried at the top level. When EnvPrepareRequest.Repositories is non-empty, // each entry produces one prepared worktree under the shared TaskDirName. type RepoPrepareSpec struct { - RepositoryID string - RepositoryPath string - RepoName string - BaseBranch string - DefaultBranch string // Repository's default_branch, used as fallback when BaseBranch is missing - CheckoutBranch string - PRNumber int // GitHub PR number when CheckoutBranch is a PR head; enables refs/pull//head fetch for fork PRs. - WorktreeID string - WorktreeBranch string - WorktreeBranchPrefix string - PullBeforeWorktree bool - RepoSetupScript string + RepositoryID string + RepositoryPath string + RepoName string + BaseBranch string + DefaultBranch string // Repository's default_branch, used as fallback when BaseBranch is missing + CheckoutBranch string + PRNumber int // GitHub PR number when CheckoutBranch is a PR head; enables refs/pull//head fetch for fork PRs. + WorktreeID string + WorktreeBranch string + WorktreeBranchPrefix string + WorktreeBranchTemplate string + WorktreeBranchTicket string + PullBeforeWorktree bool + RepoSetupScript string // BranchSlug, when set, suffixes the worktree path as // {RepoName}-{BranchSlug} so two specs sharing a RepositoryID don't collide // on disk. @@ -68,8 +70,10 @@ type EnvPrepareRequest struct { WorktreeID string WorktreeBranch string - WorktreeBranchPrefix string - PullBeforeWorktree bool + WorktreeBranchPrefix string + WorktreeBranchTemplate string + WorktreeBranchTicket string + PullBeforeWorktree bool TaskDirName string // Per-task directory name within the workspace (e.g. "task-abc123") RepoName string // Repository slug used with TaskDirName to locate checkouts @@ -100,20 +104,22 @@ func (r *EnvPrepareRequest) RepoSpecs() []RepoPrepareSpec { return nil } return []RepoPrepareSpec{{ - RepositoryID: r.RepositoryID, - RepositoryPath: r.RepositoryPath, - RepoName: r.RepoName, - BaseBranch: r.BaseBranch, - DefaultBranch: r.DefaultBranch, - CheckoutBranch: r.CheckoutBranch, - PRNumber: r.PRNumber, - WorktreeID: r.WorktreeID, - WorktreeBranch: r.WorktreeBranch, - WorktreeBranchPrefix: r.WorktreeBranchPrefix, - PullBeforeWorktree: r.PullBeforeWorktree, - RepoSetupScript: r.RepoSetupScript, - BranchSlug: r.BranchSlug, - BranchIdentitySlug: r.BranchIdentitySlug, + RepositoryID: r.RepositoryID, + RepositoryPath: r.RepositoryPath, + RepoName: r.RepoName, + BaseBranch: r.BaseBranch, + DefaultBranch: r.DefaultBranch, + CheckoutBranch: r.CheckoutBranch, + PRNumber: r.PRNumber, + WorktreeID: r.WorktreeID, + WorktreeBranch: r.WorktreeBranch, + WorktreeBranchPrefix: r.WorktreeBranchPrefix, + WorktreeBranchTemplate: r.WorktreeBranchTemplate, + WorktreeBranchTicket: r.WorktreeBranchTicket, + PullBeforeWorktree: r.PullBeforeWorktree, + RepoSetupScript: r.RepoSetupScript, + BranchSlug: r.BranchSlug, + BranchIdentitySlug: r.BranchIdentitySlug, }} } diff --git a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_docker.go b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_docker.go index 0ffc5986f5..7a046d9a35 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_docker.go +++ b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_docker.go @@ -60,5 +60,16 @@ func nonWorktreeTaskBranch(req *EnvPrepareRequest) string { if len(suffix) > 6 { suffix = suffix[:6] } + if req.WorktreeBranchTemplate != "" { + if branch, err := worktree.RenderTaskBranchName(worktree.BranchNameTemplateInput{ + Template: req.WorktreeBranchTemplate, + TaskID: req.TaskID, + Title: req.TaskTitle, + Ticket: req.WorktreeBranchTicket, + Suffix: suffix, + }); err == nil { + return branch + } + } return worktree.TaskBranchNameWithSuffix(req.TaskTitle, req.TaskID, req.WorktreeBranchPrefix, suffix) } diff --git a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree.go b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree.go index e49327760a..2a2e6f1e04 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree.go +++ b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree.go @@ -247,22 +247,24 @@ func (p *WorktreePreparer) createWorktreeWithSync( // manager's CreateRequest. Progress callbacks are wired by the caller. func buildWorktreeCreateRequest(req *EnvPrepareRequest) worktree.CreateRequest { return worktree.CreateRequest{ - TaskID: req.TaskID, - SessionID: req.SessionID, - TaskTitle: req.TaskTitle, - RepositoryID: req.RepositoryID, - RepositoryPath: req.RepositoryPath, - BaseBranch: req.BaseBranch, - FallbackBaseBranch: req.DefaultBranch, - CheckoutBranch: req.CheckoutBranch, - PRNumber: req.PRNumber, - WorktreeBranchPrefix: req.WorktreeBranchPrefix, - PullBeforeWorktree: req.PullBeforeWorktree, - WorktreeID: req.WorktreeID, - TaskDirName: req.TaskDirName, - RepoName: req.RepoName, - BranchSlug: req.BranchSlug, - BranchIdentitySlug: req.BranchIdentitySlug, + TaskID: req.TaskID, + SessionID: req.SessionID, + TaskTitle: req.TaskTitle, + RepositoryID: req.RepositoryID, + RepositoryPath: req.RepositoryPath, + BaseBranch: req.BaseBranch, + FallbackBaseBranch: req.DefaultBranch, + CheckoutBranch: req.CheckoutBranch, + PRNumber: req.PRNumber, + WorktreeBranchPrefix: req.WorktreeBranchPrefix, + WorktreeBranchTemplate: req.WorktreeBranchTemplate, + WorktreeBranchTicket: req.WorktreeBranchTicket, + PullBeforeWorktree: req.PullBeforeWorktree, + WorktreeID: req.WorktreeID, + TaskDirName: req.TaskDirName, + RepoName: req.RepoName, + BranchSlug: req.BranchSlug, + BranchIdentitySlug: req.BranchIdentitySlug, } } @@ -453,6 +455,8 @@ func (p *WorktreePreparer) prepareOneRepo( subReq.PRNumber = spec.PRNumber subReq.WorktreeID = spec.WorktreeID subReq.WorktreeBranchPrefix = spec.WorktreeBranchPrefix + subReq.WorktreeBranchTemplate = spec.WorktreeBranchTemplate + subReq.WorktreeBranchTicket = spec.WorktreeBranchTicket subReq.PullBeforeWorktree = spec.PullBeforeWorktree subReq.BranchSlug = spec.BranchSlug subReq.BranchIdentitySlug = repoBranchIdentitySlug(spec) diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index f45f1ca7f3..8843778a67 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -639,29 +639,31 @@ func (m *Manager) runEnvironmentPreparerWithProgress( func buildEnvPrepareRequest(req *LaunchRequest, workspacePath string, execName executor.Name) *EnvPrepareRequest { repoSetupScript, _ := req.Metadata[MetadataKeyRepoSetupScript].(string) prepReq := &EnvPrepareRequest{ - TaskID: req.TaskID, - SessionID: req.SessionID, - TaskTitle: req.TaskTitle, - ExecutorType: execName, - WorkspacePath: workspacePath, - RepositoryPath: req.RepositoryPath, - RepositoryID: req.RepositoryID, - UseWorktree: req.UseWorktree, - WorktreeID: req.WorktreeID, - SetupScript: req.SetupScript, - RepoSetupScript: repoSetupScript, - BaseBranch: req.BaseBranch, - DefaultBranch: req.DefaultBranch, - CheckoutBranch: req.CheckoutBranch, - PRNumber: req.PRNumber, - WorktreeBranch: getMetadataString(req.Metadata, MetadataKeyWorktreeBranch), - WorktreeBranchPrefix: req.WorktreeBranchPrefix, - PullBeforeWorktree: req.PullBeforeWorktree, - TaskDirName: req.TaskDirName, - RepoName: req.RepoName, - BranchSlug: req.BranchSlug, - BranchIdentitySlug: req.BranchIdentitySlug, - Env: req.Env, + TaskID: req.TaskID, + SessionID: req.SessionID, + TaskTitle: req.TaskTitle, + ExecutorType: execName, + WorkspacePath: workspacePath, + RepositoryPath: req.RepositoryPath, + RepositoryID: req.RepositoryID, + UseWorktree: req.UseWorktree, + WorktreeID: req.WorktreeID, + SetupScript: req.SetupScript, + RepoSetupScript: repoSetupScript, + BaseBranch: req.BaseBranch, + DefaultBranch: req.DefaultBranch, + CheckoutBranch: req.CheckoutBranch, + PRNumber: req.PRNumber, + WorktreeBranch: getMetadataString(req.Metadata, MetadataKeyWorktreeBranch), + WorktreeBranchPrefix: req.WorktreeBranchPrefix, + WorktreeBranchTemplate: req.WorktreeBranchTemplate, + WorktreeBranchTicket: req.WorktreeBranchTicket, + PullBeforeWorktree: req.PullBeforeWorktree, + TaskDirName: req.TaskDirName, + RepoName: req.RepoName, + BranchSlug: req.BranchSlug, + BranchIdentitySlug: req.BranchIdentitySlug, + Env: req.Env, } // Multi-repo: forward the repo list when the launch request carries one. // Each per-repo entry inherits the request-level RepoSetupScript when its @@ -674,19 +676,21 @@ func buildEnvPrepareRequest(req *LaunchRequest, workspacePath string, execName e setup = repoSetupScript } specs = append(specs, RepoPrepareSpec{ - RepositoryID: r.RepositoryID, - RepositoryPath: r.RepositoryPath, - RepoName: r.RepoName, - BaseBranch: r.BaseBranch, - DefaultBranch: r.DefaultBranch, - CheckoutBranch: r.CheckoutBranch, - PRNumber: r.PRNumber, - WorktreeID: r.WorktreeID, - WorktreeBranchPrefix: r.WorktreeBranchPrefix, - PullBeforeWorktree: r.PullBeforeWorktree, - RepoSetupScript: setup, - BranchSlug: r.BranchSlug, - BranchIdentitySlug: r.BranchIdentitySlug, + RepositoryID: r.RepositoryID, + RepositoryPath: r.RepositoryPath, + RepoName: r.RepoName, + BaseBranch: r.BaseBranch, + DefaultBranch: r.DefaultBranch, + CheckoutBranch: r.CheckoutBranch, + PRNumber: r.PRNumber, + WorktreeID: r.WorktreeID, + WorktreeBranchPrefix: r.WorktreeBranchPrefix, + WorktreeBranchTemplate: r.WorktreeBranchTemplate, + WorktreeBranchTicket: r.WorktreeBranchTicket, + PullBeforeWorktree: r.PullBeforeWorktree, + RepoSetupScript: setup, + BranchSlug: r.BranchSlug, + BranchIdentitySlug: r.BranchIdentitySlug, }) } prepReq.Repositories = specs diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index 7701903723..ce2f2215f7 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -294,20 +294,22 @@ func (ae *AgentExecution) EndSessionSpan() { // the top level. When LaunchRequest.Repositories is set, each entry produces // one prepared worktree under the shared TaskDirName. type RepoLaunchSpec struct { - RepositoryID string - RepositoryPath string - RepositoryURL string // Clone URL for remote executors that need to clone - RepoName string // Repository name used as subdirectory inside TaskDirName - BaseBranch string - DefaultBranch string // Repository's default_branch, used as fallback when BaseBranch is missing - CheckoutBranch string - PRNumber int // GitHub PR number when CheckoutBranch is a PR head; enables refs/pull//head fetch for fork PRs. - WorktreeID string // Existing worktree ID to reuse (skip creation if set) - WorktreeBranchPrefix string - PullBeforeWorktree bool - RepoSetupScript string // Repository-level setup script (optional) - RepoCleanupScript string // Repository-level cleanup script (optional) - CopyFiles string // Comma-separated paths/globs to copy from the source repo (gitignored .env / config files) + RepositoryID string + RepositoryPath string + RepositoryURL string // Clone URL for remote executors that need to clone + RepoName string // Repository name used as subdirectory inside TaskDirName + BaseBranch string + DefaultBranch string // Repository's default_branch, used as fallback when BaseBranch is missing + CheckoutBranch string + PRNumber int // GitHub PR number when CheckoutBranch is a PR head; enables refs/pull//head fetch for fork PRs. + WorktreeID string // Existing worktree ID to reuse (skip creation if set) + WorktreeBranchPrefix string + WorktreeBranchTemplate string + WorktreeBranchTicket string + PullBeforeWorktree bool + RepoSetupScript string // Repository-level setup script (optional) + RepoCleanupScript string // Repository-level cleanup script (optional) + CopyFiles string // Comma-separated paths/globs to copy from the source repo (gitignored .env / config files) // BranchSlug, when set, suffixes the worktree directory as // {RepoName}-{BranchSlug} so multi-branch tasks (same repo, multiple // branches) don't collide. @@ -378,16 +380,18 @@ type LaunchRequest struct { CopyFiles string // Worktree configuration - UseWorktree bool // Whether to use a Git worktree for isolation - WorktreeID string // Existing worktree ID to reuse (skip creation if set) - RepositoryID string // Repository ID for worktree tracking - RepositoryPath string // Path to the main repository (for worktree creation) - BaseBranch string // Base branch for the worktree (e.g., "main") - DefaultBranch string // Repository's default_branch, used as fallback when BaseBranch is missing - CheckoutBranch string // Branch to fetch and checkout after worktree creation (e.g., PR head branch) - PRNumber int // GitHub PR number when CheckoutBranch is a PR head; enables refs/pull//head fetch for fork PRs. - WorktreeBranchPrefix string // Branch prefix for worktree branches - PullBeforeWorktree bool // Whether to pull from remote before creating the worktree + UseWorktree bool // Whether to use a Git worktree for isolation + WorktreeID string // Existing worktree ID to reuse (skip creation if set) + RepositoryID string // Repository ID for worktree tracking + RepositoryPath string // Path to the main repository (for worktree creation) + BaseBranch string // Base branch for the worktree (e.g., "main") + DefaultBranch string // Repository's default_branch, used as fallback when BaseBranch is missing + CheckoutBranch string // Branch to fetch and checkout after worktree creation (e.g., PR head branch) + PRNumber int // GitHub PR number when CheckoutBranch is a PR head; enables refs/pull//head fetch for fork PRs. + WorktreeBranchPrefix string // Branch prefix for worktree branches + WorktreeBranchTemplate string // Branch name template for worktree branches + WorktreeBranchTicket string // External ticket value for branch templates + PullBeforeWorktree bool // Whether to pull from remote before creating the worktree // Task directory mode: place worktree at ~/.kandev/tasks/{TaskDirName}/{RepoName}/ TaskDirName string // Semantic task directory name (e.g. "fix-bug_ab12") @@ -416,19 +420,21 @@ func (r *LaunchRequest) RepoSpecs() []RepoLaunchSpec { return nil } return []RepoLaunchSpec{{ - RepositoryID: r.RepositoryID, - RepositoryPath: r.RepositoryPath, - RepoName: r.RepoName, - BaseBranch: r.BaseBranch, - DefaultBranch: r.DefaultBranch, - CheckoutBranch: r.CheckoutBranch, - PRNumber: r.PRNumber, - WorktreeID: r.WorktreeID, - WorktreeBranchPrefix: r.WorktreeBranchPrefix, - PullBeforeWorktree: r.PullBeforeWorktree, - CopyFiles: r.CopyFiles, - BranchSlug: r.BranchSlug, - BranchIdentitySlug: r.BranchIdentitySlug, + RepositoryID: r.RepositoryID, + RepositoryPath: r.RepositoryPath, + RepoName: r.RepoName, + BaseBranch: r.BaseBranch, + DefaultBranch: r.DefaultBranch, + CheckoutBranch: r.CheckoutBranch, + PRNumber: r.PRNumber, + WorktreeID: r.WorktreeID, + WorktreeBranchPrefix: r.WorktreeBranchPrefix, + WorktreeBranchTemplate: r.WorktreeBranchTemplate, + WorktreeBranchTicket: r.WorktreeBranchTicket, + PullBeforeWorktree: r.PullBeforeWorktree, + CopyFiles: r.CopyFiles, + BranchSlug: r.BranchSlug, + BranchIdentitySlug: r.BranchIdentitySlug, }} } diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go index a44e7b4bf6..57cbc6edc8 100644 --- a/apps/backend/internal/backendapp/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -109,16 +109,18 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch SetupScript: req.SetupScript, CopyFiles: req.CopyFiles, // Worktree configuration for concurrent agent execution - UseWorktree: req.UseWorktree, - WorktreeID: req.WorktreeID, - RepositoryID: req.RepositoryID, - RepositoryPath: req.RepositoryPath, - BaseBranch: req.BaseBranch, - DefaultBranch: req.DefaultBranch, - CheckoutBranch: req.CheckoutBranch, - PRNumber: req.PRNumber, - WorktreeBranchPrefix: req.WorktreeBranchPrefix, - PullBeforeWorktree: req.PullBeforeWorktree, + UseWorktree: req.UseWorktree, + WorktreeID: req.WorktreeID, + RepositoryID: req.RepositoryID, + RepositoryPath: req.RepositoryPath, + BaseBranch: req.BaseBranch, + DefaultBranch: req.DefaultBranch, + CheckoutBranch: req.CheckoutBranch, + PRNumber: req.PRNumber, + WorktreeBranchPrefix: req.WorktreeBranchPrefix, + WorktreeBranchTemplate: req.WorktreeBranchTemplate, + WorktreeBranchTicket: req.WorktreeBranchTicket, + PullBeforeWorktree: req.PullBeforeWorktree, // Task directory mode TaskDirName: req.TaskDirName, RepoName: req.RepoName, @@ -142,22 +144,24 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch specs := make([]lifecycle.RepoLaunchSpec, 0, len(req.Repositories)) for _, r := range req.Repositories { specs = append(specs, lifecycle.RepoLaunchSpec{ - RepositoryID: r.RepositoryID, - RepositoryPath: r.RepositoryPath, - RepositoryURL: r.RepositoryURL, - RepoName: r.RepoName, - BaseBranch: r.BaseBranch, - DefaultBranch: r.DefaultBranch, - CheckoutBranch: r.CheckoutBranch, - PRNumber: r.PRNumber, - WorktreeID: r.WorktreeID, - WorktreeBranchPrefix: r.WorktreeBranchPrefix, - PullBeforeWorktree: r.PullBeforeWorktree, - RepoSetupScript: r.RepoSetupScript, - RepoCleanupScript: r.RepoCleanupScript, - CopyFiles: r.CopyFiles, - BranchSlug: r.BranchSlug, - BranchIdentitySlug: r.BranchIdentitySlug, + RepositoryID: r.RepositoryID, + RepositoryPath: r.RepositoryPath, + RepositoryURL: r.RepositoryURL, + RepoName: r.RepoName, + BaseBranch: r.BaseBranch, + DefaultBranch: r.DefaultBranch, + CheckoutBranch: r.CheckoutBranch, + PRNumber: r.PRNumber, + WorktreeID: r.WorktreeID, + WorktreeBranchPrefix: r.WorktreeBranchPrefix, + WorktreeBranchTemplate: r.WorktreeBranchTemplate, + WorktreeBranchTicket: r.WorktreeBranchTicket, + PullBeforeWorktree: r.PullBeforeWorktree, + RepoSetupScript: r.RepoSetupScript, + RepoCleanupScript: r.RepoCleanupScript, + CopyFiles: r.CopyFiles, + BranchSlug: r.BranchSlug, + BranchIdentitySlug: r.BranchIdentitySlug, }) } launchReq.Repositories = specs diff --git a/apps/backend/internal/backendapp/branch_materializer.go b/apps/backend/internal/backendapp/branch_materializer.go index 38edad84d8..c07b2fc7d0 100644 --- a/apps/backend/internal/backendapp/branch_materializer.go +++ b/apps/backend/internal/backendapp/branch_materializer.go @@ -135,19 +135,21 @@ func (b *branchMaterializer) prepareMaterializeRequest( } slug := deriveBranchSlugForRow(tr) req := worktree.CreateRequest{ - TaskID: taskID, - SessionID: session.ID, - TaskTitle: task.Title, - RepositoryID: repo.ID, - RepositoryPath: repo.LocalPath, - BaseBranch: tr.BaseBranch, - FallbackBaseBranch: repo.DefaultBranch, - CheckoutBranch: tr.CheckoutBranch, - WorktreeBranchPrefix: repo.WorktreeBranchPrefix, - PullBeforeWorktree: repo.PullBeforeWorktree, - TaskDirName: env.TaskDirName, - RepoName: repo.Name, - BranchSlug: slug, + TaskID: taskID, + SessionID: session.ID, + TaskTitle: task.Title, + RepositoryID: repo.ID, + RepositoryPath: repo.LocalPath, + BaseBranch: tr.BaseBranch, + FallbackBaseBranch: repo.DefaultBranch, + CheckoutBranch: tr.CheckoutBranch, + WorktreeBranchPrefix: repo.WorktreeBranchPrefix, + WorktreeBranchTemplate: repo.WorktreeBranchTemplate, + WorktreeBranchTicket: worktree.TicketForBranchName(task.Identifier, task.Metadata), + PullBeforeWorktree: repo.PullBeforeWorktree, + TaskDirName: env.TaskDirName, + RepoName: repo.Name, + BranchSlug: slug, } return req, env, session, slug, true, nil } diff --git a/apps/backend/internal/backendapp/gateway.go b/apps/backend/internal/backendapp/gateway.go index 361b199e15..65f6c337c0 100644 --- a/apps/backend/internal/backendapp/gateway.go +++ b/apps/backend/internal/backendapp/gateway.go @@ -172,6 +172,19 @@ func provideGateway( githubSvc.AssociatePRByURL(ctx, sessionID, taskID, repositoryID, prURL, branch) }) } + gitHandlers.SetOnBranchRenamed(func(ctx context.Context, sessionID, newName, repo string) { + repositoryID := resolveRepositoryIDForSessionSubpath(ctx, taskRepo, sessionID, repo, log) + if repositoryID == "" { + return + } + if err := taskRepo.UpdateTaskSessionWorktreeBranchByRepository(ctx, sessionID, repositoryID, newName); err != nil { + log.Warn("failed to update renamed branch snapshot", + zap.String("session_id", sessionID), + zap.String("repository_id", repositoryID), + zap.String("branch", newName), + zap.Error(err)) + } + }) gitHandlers.SetOnGitOperationFailed(func(ctx context.Context, sessionID, taskID, operation, errorOutput string) { fixPrompt := fmt.Sprintf("The git %s command failed with the following error:\n\n```\n%s\n```\n\nPlease fix the issues reported above.", operation, errorOutput) if _, err := taskSvc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 6343c20a4a..acf7183c8d 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -743,6 +743,38 @@ func resolveRepositoryIDForSubpath(ctx context.Context, taskRepo *sqliterepo.Rep return "" } +func resolveRepositoryIDForSessionSubpath(ctx context.Context, taskRepo *sqliterepo.Repository, sessionID, subpath string, log *logger.Logger) string { + worktrees, err := taskRepo.ListTaskSessionWorktrees(ctx, sessionID) + if err != nil { + log.Warn("session worktrees lookup failed", + zap.String("session_id", sessionID), zap.Error(err)) + return "" + } + if subpath == "" { + if len(worktrees) == 1 { + return worktrees[0].RepositoryID + } + log.Warn("branch rename did not specify repo for multi-repo session", + zap.String("session_id", sessionID), zap.Int("worktree_count", len(worktrees))) + return "" + } + for _, wt := range worktrees { + // Multi-repo sessions are small today and repository lookup is only on + // branch rename, so keep this direct until the repository interface grows + // a batch lookup. + repo, err := taskRepo.GetRepository(ctx, wt.RepositoryID) + if err != nil || repo == nil { + continue + } + if repo.Name == subpath || worktree.SanitizeRepoDirName(repo.Name) == subpath { + return wt.RepositoryID + } + } + log.Warn("no session worktree repository matches subpath", + zap.String("session_id", sessionID), zap.String("subpath", subpath)) + return "" +} + // registerTaskRoutes registers all task-related HTTP and WebSocket routes. func registerTaskRoutes(p routeParams, planService *taskservice.PlanService, handoffSvc *taskservice.HandoffService) { taskhandlers.RegisterWorkspaceRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) diff --git a/apps/backend/internal/backendapp/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go index 094e1a3249..e4a2c3e2bd 100644 --- a/apps/backend/internal/backendapp/helpers_test.go +++ b/apps/backend/internal/backendapp/helpers_test.go @@ -118,6 +118,82 @@ func TestAppendAgentctlStatusMessage_IncludesWorkspacePathForReload(t *testing.T } } +func TestResolveRepositoryIDForSessionSubpathMatchesSanitizedRepositoryName(t *testing.T) { + ctx := context.Background() + dbPath := filepath.Join(t.TempDir(), "backendapp-session.db") + dbConn, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + repo, err := sqlitetaskrepo.NewWithDB(sqlxDB, sqlxDB, nil) + if err != nil { + t.Fatalf("new task repo: %v", err) + } + t.Cleanup(func() { _ = sqlxDB.Close() }) + + log, err := logger.NewLogger(logger.LoggingConfig{ + Level: "error", + Format: "console", + OutputPath: "stdout", + }) + if err != nil { + t.Fatalf("NewLogger: %v", err) + } + + now := time.Now().UTC() + if _, err := sqlxDB.Exec(sqlxDB.Rebind(` + INSERT INTO workspaces (id, name, created_at, updated_at) + VALUES (?, ?, ?, ?) + `), "workspace-1", "Workspace", now, now); err != nil { + t.Fatalf("seed workspace: %v", err) + } + if _, err := sqlxDB.Exec(sqlxDB.Rebind(` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `), "task-1", "workspace-1", "Test task", now, now); err != nil { + t.Fatalf("seed task: %v", err) + } + if _, err := sqlxDB.Exec(sqlxDB.Rebind(` + INSERT INTO task_sessions (id, task_id, started_at, updated_at) + VALUES (?, ?, ?, ?) + `), "session-1", "task-1", now, now); err != nil { + t.Fatalf("seed session: %v", err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-1", + WorkspaceID: "workspace-1", + Name: "kdlbs/kandev", + SourceType: "remote", + Provider: "github", + ProviderOwner: "kdlbs", + ProviderName: "kandev", + DefaultBranch: "main", + WorktreeBranchPrefix: "feature/", + WorktreeBranchTemplate: "feature/{title}-{suffix}", + PullBeforeWorktree: true, + }); err != nil { + t.Fatalf("CreateRepository: %v", err) + } + if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ + ID: "session-worktree-1", + SessionID: "session-1", + WorktreeID: "worktree-1", + RepositoryID: "repo-1", + WorktreePath: "/tmp/worktree", + WorktreeBranch: "feature/test", + BranchSlug: "test", + Position: 0, + }); err != nil { + t.Fatalf("CreateTaskSessionWorktree: %v", err) + } + + got := resolveRepositoryIDForSessionSubpath(ctx, repo, "session-1", "kdlbs-kandev", log) + if got != "repo-1" { + t.Fatalf("resolveRepositoryIDForSessionSubpath = %q, want repo-1", got) + } +} + func TestStopLifecycleManagerAllowsAgentctlInstanceCleanupWindow(t *testing.T) { log, err := logger.NewLogger(logger.LoggingConfig{ Level: "error", diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 3ca2eb0b7d..1fec1fa4bd 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -299,16 +299,18 @@ type LaunchAgentRequest struct { CopyFiles string // Worktree configuration for concurrent agent execution - UseWorktree bool // Whether to use a Git worktree for isolation - WorktreeID string // Existing worktree ID to reuse (skip creation if set) - RepositoryID string // Repository ID for worktree tracking - RepositoryPath string // Path to the main repository (for worktree creation) - BaseBranch string // Base branch for the worktree (e.g., "main") - DefaultBranch string // Repository's default_branch, used as a fallback when BaseBranch is missing - CheckoutBranch string // Branch to fetch and checkout after worktree creation (e.g., PR head branch) - PRNumber int // GitHub PR number when CheckoutBranch is a PR head; enables refs/pull//head fetch for fork PRs. - WorktreeBranchPrefix string // Branch prefix for worktree branches - PullBeforeWorktree bool // Whether to pull from remote before creating the worktree + UseWorktree bool // Whether to use a Git worktree for isolation + WorktreeID string // Existing worktree ID to reuse (skip creation if set) + RepositoryID string // Repository ID for worktree tracking + RepositoryPath string // Path to the main repository (for worktree creation) + BaseBranch string // Base branch for the worktree (e.g., "main") + DefaultBranch string // Repository's default_branch, used as a fallback when BaseBranch is missing + CheckoutBranch string // Branch to fetch and checkout after worktree creation (e.g., PR head branch) + PRNumber int // GitHub PR number when CheckoutBranch is a PR head; enables refs/pull//head fetch for fork PRs. + WorktreeBranchPrefix string // Branch prefix for worktree branches + WorktreeBranchTemplate string // Branch name template for worktree branches + WorktreeBranchTicket string // External ticket value for branch templates + PullBeforeWorktree bool // Whether to pull from remote before creating the worktree // Task directory mode: place worktree at ~/.kandev/tasks/{TaskDirName}/{RepoName}/ TaskDirName string // Semantic task directory name (e.g. "fix-bug_ab12") @@ -336,20 +338,22 @@ type LaunchAgentRequest struct { // the orchestrator package does not need to import lifecycle types into its // public API. type RepoSpec struct { - RepositoryID string - RepositoryPath string - RepositoryURL string - RepoName string - BaseBranch string - DefaultBranch string // Repository's default_branch, used as fallback when BaseBranch is missing - CheckoutBranch string - PRNumber int // GitHub PR number when CheckoutBranch is a PR head; enables refs/pull//head fetch for fork PRs. - WorktreeID string - WorktreeBranchPrefix string - PullBeforeWorktree bool - RepoSetupScript string - RepoCleanupScript string - CopyFiles string + RepositoryID string + RepositoryPath string + RepositoryURL string + RepoName string + BaseBranch string + DefaultBranch string // Repository's default_branch, used as fallback when BaseBranch is missing + CheckoutBranch string + PRNumber int // GitHub PR number when CheckoutBranch is a PR head; enables refs/pull//head fetch for fork PRs. + WorktreeID string + WorktreeBranchPrefix string + WorktreeBranchTemplate string + WorktreeBranchTicket string + PullBeforeWorktree bool + RepoSetupScript string + RepoCleanupScript string + CopyFiles string // BranchSlug, when non-empty, suffixes the repo dir so the same repo can // host multiple branch worktrees as siblings within one task. Set by the // orchestrator when buildRepoSpecs detects multiple rows sharing a diff --git a/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go b/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go index 006bf8a960..3426d9a299 100644 --- a/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go +++ b/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go @@ -202,19 +202,21 @@ func topLevelLaunchRepoSpec(req *LaunchAgentRequest) (RepoSpec, bool) { return RepoSpec{}, false } return RepoSpec{ - RepositoryID: req.RepositoryID, - RepositoryPath: req.RepositoryPath, - RepositoryURL: req.RepositoryURL, - RepoName: req.RepoName, - BaseBranch: req.BaseBranch, - DefaultBranch: req.DefaultBranch, - CheckoutBranch: req.CheckoutBranch, - PRNumber: req.PRNumber, - WorktreeID: req.WorktreeID, - WorktreeBranchPrefix: req.WorktreeBranchPrefix, - PullBeforeWorktree: req.PullBeforeWorktree, - CopyFiles: req.CopyFiles, - BranchIdentitySlug: topLevelBranchIdentitySlug(req), + RepositoryID: req.RepositoryID, + RepositoryPath: req.RepositoryPath, + RepositoryURL: req.RepositoryURL, + RepoName: req.RepoName, + BaseBranch: req.BaseBranch, + DefaultBranch: req.DefaultBranch, + CheckoutBranch: req.CheckoutBranch, + PRNumber: req.PRNumber, + WorktreeID: req.WorktreeID, + WorktreeBranchPrefix: req.WorktreeBranchPrefix, + WorktreeBranchTemplate: req.WorktreeBranchTemplate, + WorktreeBranchTicket: req.WorktreeBranchTicket, + PullBeforeWorktree: req.PullBeforeWorktree, + CopyFiles: req.CopyFiles, + BranchIdentitySlug: topLevelBranchIdentitySlug(req), }, true } diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 4daac8adaf..dd8bdcc23c 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -800,6 +800,7 @@ func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, s if executorNeedsResolvedCredentials(execConfig.ExecutorType) { e.applyContainerCredentials(ctx, req, metadata) } + req.WorktreeBranchTicket = worktree.TicketForBranchName(task.Identifier, metadata) metadata, err := e.applyRepositoryConfig(req, task, repoInfo, execConfig, metadata) if err != nil { @@ -812,6 +813,9 @@ func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, s // (mirroring the primary) for downstream code that has not been migrated. if len(allRepos) > 1 { req.Repositories = buildRepoSpecs(allRepos) + for i := range req.Repositories { + req.Repositories[i].WorktreeBranchTicket = req.WorktreeBranchTicket + } } // Activate config-mode MCP tools when config_mode is set in session metadata. @@ -858,13 +862,14 @@ func buildRepoSpecs(allRepos []*repoInfo) []RepoSpec { out := make([]RepoSpec, 0, len(allRepos)) for _, info := range allRepos { spec := RepoSpec{ - RepositoryID: info.RepositoryID, - RepositoryPath: info.RepositoryPath, - BaseBranch: info.BaseBranch, - CheckoutBranch: info.CheckoutBranch, - PRNumber: info.PRNumber, - WorktreeBranchPrefix: info.WorktreeBranchPrefix, - PullBeforeWorktree: info.PullBeforeWorktree, + RepositoryID: info.RepositoryID, + RepositoryPath: info.RepositoryPath, + BaseBranch: info.BaseBranch, + CheckoutBranch: info.CheckoutBranch, + PRNumber: info.PRNumber, + WorktreeBranchPrefix: info.WorktreeBranchPrefix, + WorktreeBranchTemplate: info.WorktreeBranchTemplate, + PullBeforeWorktree: info.PullBeforeWorktree, } if info.Repository != nil { spec.RepoName = info.Repository.Name @@ -1015,6 +1020,7 @@ func (e *Executor) applyRepositoryConfig(req *LaunchAgentRequest, task *v1.Task, req.CheckoutBranch = repoInfo.CheckoutBranch req.PRNumber = repoInfo.PRNumber req.WorktreeBranchPrefix = repoInfo.WorktreeBranchPrefix + req.WorktreeBranchTemplate = repoInfo.WorktreeBranchTemplate req.PullBeforeWorktree = repoInfo.PullBeforeWorktree if repoInfo.Repository != nil { req.DefaultBranch = repoInfo.Repository.DefaultBranch diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index a7d85add64..fea3a2ca87 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -35,15 +35,16 @@ func isTerminalSessionState(state models.TaskSessionState) bool { // repoInfo holds resolved repository details for agent launch. type repoInfo struct { - RepositoryID string - RepositoryPath string - BaseBranch string - CheckoutBranch string - PRNumber int // GitHub PR number when CheckoutBranch is a PR head; sourced from task_repositories.metadata["pr_number"]. - Position int - WorktreeBranchPrefix string - PullBeforeWorktree bool - Repository *models.Repository + RepositoryID string + RepositoryPath string + BaseBranch string + CheckoutBranch string + PRNumber int // GitHub PR number when CheckoutBranch is a PR head; sourced from task_repositories.metadata["pr_number"]. + Position int + WorktreeBranchPrefix string + WorktreeBranchTemplate string + PullBeforeWorktree bool + Repository *models.Repository } // resolvePrimaryRepoInfo fetches and resolves the primary repository info for a task. @@ -130,6 +131,7 @@ func (e *Executor) resolveTaskRepoInfo(ctx context.Context, tr *models.TaskRepos info.Repository = repo info.RepositoryPath = repo.LocalPath info.WorktreeBranchPrefix = repo.WorktreeBranchPrefix + info.WorktreeBranchTemplate = repo.WorktreeBranchTemplate info.PullBeforeWorktree = repo.PullBeforeWorktree if info.BaseBranch == "" && repo.DefaultBranch != "" { info.BaseBranch = repo.DefaultBranch @@ -543,6 +545,7 @@ func (e *Executor) buildResumeRequest(ctx context.Context, task *v1.Task, sessio if len(session.Worktrees) > 0 && session.Worktrees[0].WorktreeID != "" { metadata["worktree_id"] = session.Worktrees[0].WorktreeID } + req.WorktreeBranchTicket = worktree.TicketForBranchName(task.Identifier, metadata) execConfig := e.applyExecutorConfigToResumeRequest(ctx, req, task, session, metadata) @@ -776,6 +779,9 @@ func (e *Executor) applyResumeMultiRepoConfig(ctx context.Context, task *v1.Task } if len(allRepos) > 1 { req.Repositories = buildRepoSpecs(allRepos) + for i := range req.Repositories { + req.Repositories[i].WorktreeBranchTicket = req.WorktreeBranchTicket + } req.TaskDirName = resolveResumeTaskDirName(existingEnv, task) } return nil @@ -807,6 +813,7 @@ func (e *Executor) applyResumeWorktreeConfig( req.PRNumber = prNumberFromMetadata(primaryTaskRepo.Metadata) } req.WorktreeBranchPrefix = repository.WorktreeBranchPrefix + req.WorktreeBranchTemplate = repository.WorktreeBranchTemplate req.PullBeforeWorktree = repository.PullBeforeWorktree // Worktree manager requires TaskDirName and RepoName. Mirror the // initial-launch path (applyRepositoryConfig) so resumes of single-repo diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index b9343a761b..46272c96ae 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -40,25 +40,26 @@ type WorkspaceDTO struct { } type RepositoryDTO struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id"` - Name string `json:"name"` - SourceType string `json:"source_type"` - LocalPath string `json:"local_path"` - Provider string `json:"provider"` - ProviderRepoID string `json:"provider_repo_id"` - ProviderOwner string `json:"provider_owner"` - ProviderName string `json:"provider_name"` - DefaultBranch string `json:"default_branch"` - WorktreeBranchPrefix string `json:"worktree_branch_prefix"` - PullBeforeWorktree bool `json:"pull_before_worktree"` - SetupScript string `json:"setup_script"` - CleanupScript string `json:"cleanup_script"` - DevScript string `json:"dev_script"` - CopyFiles string `json:"copy_files"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Scripts []RepositoryScriptDTO `json:"scripts,omitempty"` + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + SourceType string `json:"source_type"` + LocalPath string `json:"local_path"` + Provider string `json:"provider"` + ProviderRepoID string `json:"provider_repo_id"` + ProviderOwner string `json:"provider_owner"` + ProviderName string `json:"provider_name"` + DefaultBranch string `json:"default_branch"` + WorktreeBranchPrefix string `json:"worktree_branch_prefix"` + WorktreeBranchTemplate string `json:"worktree_branch_template"` + PullBeforeWorktree bool `json:"pull_before_worktree"` + SetupScript string `json:"setup_script"` + CleanupScript string `json:"cleanup_script"` + DevScript string `json:"dev_script"` + CopyFiles string `json:"copy_files"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Scripts []RepositoryScriptDTO `json:"scripts,omitempty"` } type RepositoryScriptDTO struct { @@ -435,24 +436,25 @@ func FromWorkspace(workspace *models.Workspace) WorkspaceDTO { func FromRepository(repository *models.Repository) RepositoryDTO { return RepositoryDTO{ - ID: repository.ID, - WorkspaceID: repository.WorkspaceID, - Name: repository.Name, - SourceType: repository.SourceType, - LocalPath: repository.LocalPath, - Provider: repository.Provider, - ProviderRepoID: repository.ProviderRepoID, - ProviderOwner: repository.ProviderOwner, - ProviderName: repository.ProviderName, - DefaultBranch: repository.DefaultBranch, - WorktreeBranchPrefix: repository.WorktreeBranchPrefix, - PullBeforeWorktree: repository.PullBeforeWorktree, - SetupScript: repository.SetupScript, - CleanupScript: repository.CleanupScript, - DevScript: repository.DevScript, - CopyFiles: repository.CopyFiles, - CreatedAt: repository.CreatedAt, - UpdatedAt: repository.UpdatedAt, + ID: repository.ID, + WorkspaceID: repository.WorkspaceID, + Name: repository.Name, + SourceType: repository.SourceType, + LocalPath: repository.LocalPath, + Provider: repository.Provider, + ProviderRepoID: repository.ProviderRepoID, + ProviderOwner: repository.ProviderOwner, + ProviderName: repository.ProviderName, + DefaultBranch: repository.DefaultBranch, + WorktreeBranchPrefix: repository.WorktreeBranchPrefix, + WorktreeBranchTemplate: repository.WorktreeBranchTemplate, + PullBeforeWorktree: repository.PullBeforeWorktree, + SetupScript: repository.SetupScript, + CleanupScript: repository.CleanupScript, + DevScript: repository.DevScript, + CopyFiles: repository.CopyFiles, + CreatedAt: repository.CreatedAt, + UpdatedAt: repository.UpdatedAt, } } diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index bef6c6d846..f6ce2897fe 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -68,39 +68,41 @@ type GetRepositoryRequest struct { } type CreateRepositoryRequest struct { - WorkspaceID string - Name string - SourceType string - LocalPath string - Provider string - ProviderRepoID string - ProviderOwner string - ProviderName string - DefaultBranch string - WorktreeBranchPrefix string - PullBeforeWorktree *bool - SetupScript string - CleanupScript string - DevScript string - CopyFiles string + WorkspaceID string + Name string + SourceType string + LocalPath string + Provider string + ProviderRepoID string + ProviderOwner string + ProviderName string + DefaultBranch string + WorktreeBranchPrefix string + WorktreeBranchTemplate string + PullBeforeWorktree *bool + SetupScript string + CleanupScript string + DevScript string + CopyFiles string } type UpdateRepositoryRequest struct { - ID string - Name *string - SourceType *string - LocalPath *string - Provider *string - ProviderRepoID *string - ProviderOwner *string - ProviderName *string - DefaultBranch *string - WorktreeBranchPrefix *string - PullBeforeWorktree *bool - SetupScript *string - CleanupScript *string - DevScript *string - CopyFiles *string + ID string + Name *string + SourceType *string + LocalPath *string + Provider *string + ProviderRepoID *string + ProviderOwner *string + ProviderName *string + DefaultBranch *string + WorktreeBranchPrefix *string + WorktreeBranchTemplate *string + PullBeforeWorktree *bool + SetupScript *string + CleanupScript *string + DevScript *string + CopyFiles *string } type DeleteRepositoryRequest struct { diff --git a/apps/backend/internal/task/handlers/repository_handlers.go b/apps/backend/internal/task/handlers/repository_handlers.go index cb1c65c333..58e93c7c6f 100644 --- a/apps/backend/internal/task/handlers/repository_handlers.go +++ b/apps/backend/internal/task/handlers/repository_handlers.go @@ -254,20 +254,21 @@ func (h *RepositoryHandlers) httpLocalRepositoryStatus(c *gin.Context) { } type httpCreateRepositoryRequest struct { - Name string `json:"name"` - SourceType string `json:"source_type"` - LocalPath string `json:"local_path"` - Provider string `json:"provider"` - ProviderRepoID string `json:"provider_repo_id"` - ProviderOwner string `json:"provider_owner"` - ProviderName string `json:"provider_name"` - DefaultBranch string `json:"default_branch"` - WorktreeBranchPrefix string `json:"worktree_branch_prefix"` - PullBeforeWorktree *bool `json:"pull_before_worktree"` - SetupScript string `json:"setup_script"` - CleanupScript string `json:"cleanup_script"` - DevScript string `json:"dev_script"` - CopyFiles string `json:"copy_files"` + Name string `json:"name"` + SourceType string `json:"source_type"` + LocalPath string `json:"local_path"` + Provider string `json:"provider"` + ProviderRepoID string `json:"provider_repo_id"` + ProviderOwner string `json:"provider_owner"` + ProviderName string `json:"provider_name"` + DefaultBranch string `json:"default_branch"` + WorktreeBranchPrefix string `json:"worktree_branch_prefix"` + WorktreeBranchTemplate string `json:"worktree_branch_template"` + PullBeforeWorktree *bool `json:"pull_before_worktree"` + SetupScript string `json:"setup_script"` + CleanupScript string `json:"cleanup_script"` + DevScript string `json:"dev_script"` + CopyFiles string `json:"copy_files"` } func (h *RepositoryHandlers) httpCreateRepository(c *gin.Context) { @@ -281,21 +282,22 @@ func (h *RepositoryHandlers) httpCreateRepository(c *gin.Context) { return } repository, err := h.service.CreateRepository(c.Request.Context(), &service.CreateRepositoryRequest{ - WorkspaceID: c.Param("id"), - Name: body.Name, - SourceType: body.SourceType, - LocalPath: body.LocalPath, - Provider: body.Provider, - ProviderRepoID: body.ProviderRepoID, - ProviderOwner: body.ProviderOwner, - ProviderName: body.ProviderName, - DefaultBranch: body.DefaultBranch, - WorktreeBranchPrefix: body.WorktreeBranchPrefix, - PullBeforeWorktree: body.PullBeforeWorktree, - SetupScript: body.SetupScript, - CleanupScript: body.CleanupScript, - DevScript: body.DevScript, - CopyFiles: body.CopyFiles, + WorkspaceID: c.Param("id"), + Name: body.Name, + SourceType: body.SourceType, + LocalPath: body.LocalPath, + Provider: body.Provider, + ProviderRepoID: body.ProviderRepoID, + ProviderOwner: body.ProviderOwner, + ProviderName: body.ProviderName, + DefaultBranch: body.DefaultBranch, + WorktreeBranchPrefix: body.WorktreeBranchPrefix, + WorktreeBranchTemplate: body.WorktreeBranchTemplate, + PullBeforeWorktree: body.PullBeforeWorktree, + SetupScript: body.SetupScript, + CleanupScript: body.CleanupScript, + DevScript: body.DevScript, + CopyFiles: body.CopyFiles, }) if err != nil { if errors.Is(err, service.ErrInvalidRepositorySettings) { @@ -391,20 +393,21 @@ func (h *RepositoryHandlers) currentBranchAtPath(ctx context.Context, localPath } type httpUpdateRepositoryRequest struct { - Name *string `json:"name"` - SourceType *string `json:"source_type"` - LocalPath *string `json:"local_path"` - Provider *string `json:"provider"` - ProviderRepoID *string `json:"provider_repo_id"` - ProviderOwner *string `json:"provider_owner"` - ProviderName *string `json:"provider_name"` - DefaultBranch *string `json:"default_branch"` - WorktreeBranchPrefix *string `json:"worktree_branch_prefix"` - PullBeforeWorktree *bool `json:"pull_before_worktree"` - SetupScript *string `json:"setup_script"` - CleanupScript *string `json:"cleanup_script"` - DevScript *string `json:"dev_script"` - CopyFiles *string `json:"copy_files"` + Name *string `json:"name"` + SourceType *string `json:"source_type"` + LocalPath *string `json:"local_path"` + Provider *string `json:"provider"` + ProviderRepoID *string `json:"provider_repo_id"` + ProviderOwner *string `json:"provider_owner"` + ProviderName *string `json:"provider_name"` + DefaultBranch *string `json:"default_branch"` + WorktreeBranchPrefix *string `json:"worktree_branch_prefix"` + WorktreeBranchTemplate *string `json:"worktree_branch_template"` + PullBeforeWorktree *bool `json:"pull_before_worktree"` + SetupScript *string `json:"setup_script"` + CleanupScript *string `json:"cleanup_script"` + DevScript *string `json:"dev_script"` + CopyFiles *string `json:"copy_files"` } func (h *RepositoryHandlers) httpUpdateRepository(c *gin.Context) { @@ -414,20 +417,21 @@ func (h *RepositoryHandlers) httpUpdateRepository(c *gin.Context) { return } repository, err := h.service.UpdateRepository(c.Request.Context(), c.Param("id"), &service.UpdateRepositoryRequest{ - Name: body.Name, - SourceType: body.SourceType, - LocalPath: body.LocalPath, - Provider: body.Provider, - ProviderRepoID: body.ProviderRepoID, - ProviderOwner: body.ProviderOwner, - ProviderName: body.ProviderName, - DefaultBranch: body.DefaultBranch, - WorktreeBranchPrefix: body.WorktreeBranchPrefix, - PullBeforeWorktree: body.PullBeforeWorktree, - SetupScript: body.SetupScript, - CleanupScript: body.CleanupScript, - DevScript: body.DevScript, - CopyFiles: body.CopyFiles, + Name: body.Name, + SourceType: body.SourceType, + LocalPath: body.LocalPath, + Provider: body.Provider, + ProviderRepoID: body.ProviderRepoID, + ProviderOwner: body.ProviderOwner, + ProviderName: body.ProviderName, + DefaultBranch: body.DefaultBranch, + WorktreeBranchPrefix: body.WorktreeBranchPrefix, + WorktreeBranchTemplate: body.WorktreeBranchTemplate, + PullBeforeWorktree: body.PullBeforeWorktree, + SetupScript: body.SetupScript, + CleanupScript: body.CleanupScript, + DevScript: body.DevScript, + CopyFiles: body.CopyFiles, }) if err != nil { if errors.Is(err, service.ErrInvalidRepositorySettings) { @@ -490,20 +494,21 @@ func (h *RepositoryHandlers) wsListRepositories(ctx context.Context, msg *ws.Mes } type wsCreateRepositoryRequest struct { - WorkspaceID string `json:"workspace_id"` - Name string `json:"name"` - SourceType string `json:"source_type"` - LocalPath string `json:"local_path"` - Provider string `json:"provider"` - ProviderRepoID string `json:"provider_repo_id"` - ProviderOwner string `json:"provider_owner"` - ProviderName string `json:"provider_name"` - DefaultBranch string `json:"default_branch"` - WorktreeBranchPrefix string `json:"worktree_branch_prefix"` - SetupScript string `json:"setup_script"` - CleanupScript string `json:"cleanup_script"` - DevScript string `json:"dev_script"` - CopyFiles string `json:"copy_files"` + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + SourceType string `json:"source_type"` + LocalPath string `json:"local_path"` + Provider string `json:"provider"` + ProviderRepoID string `json:"provider_repo_id"` + ProviderOwner string `json:"provider_owner"` + ProviderName string `json:"provider_name"` + DefaultBranch string `json:"default_branch"` + WorktreeBranchPrefix string `json:"worktree_branch_prefix"` + WorktreeBranchTemplate string `json:"worktree_branch_template"` + SetupScript string `json:"setup_script"` + CleanupScript string `json:"cleanup_script"` + DevScript string `json:"dev_script"` + CopyFiles string `json:"copy_files"` } func (h *RepositoryHandlers) wsCreateRepository(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -515,20 +520,21 @@ func (h *RepositoryHandlers) wsCreateRepository(ctx context.Context, msg *ws.Mes return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workspace_id and name are required", nil) } repository, err := h.service.CreateRepository(ctx, &service.CreateRepositoryRequest{ - WorkspaceID: req.WorkspaceID, - Name: req.Name, - SourceType: req.SourceType, - LocalPath: req.LocalPath, - Provider: req.Provider, - ProviderRepoID: req.ProviderRepoID, - ProviderOwner: req.ProviderOwner, - ProviderName: req.ProviderName, - DefaultBranch: req.DefaultBranch, - WorktreeBranchPrefix: req.WorktreeBranchPrefix, - SetupScript: req.SetupScript, - CleanupScript: req.CleanupScript, - DevScript: req.DevScript, - CopyFiles: req.CopyFiles, + WorkspaceID: req.WorkspaceID, + Name: req.Name, + SourceType: req.SourceType, + LocalPath: req.LocalPath, + Provider: req.Provider, + ProviderRepoID: req.ProviderRepoID, + ProviderOwner: req.ProviderOwner, + ProviderName: req.ProviderName, + DefaultBranch: req.DefaultBranch, + WorktreeBranchPrefix: req.WorktreeBranchPrefix, + WorktreeBranchTemplate: req.WorktreeBranchTemplate, + SetupScript: req.SetupScript, + CleanupScript: req.CleanupScript, + DevScript: req.DevScript, + CopyFiles: req.CopyFiles, }) if err != nil { if errors.Is(err, service.ErrInvalidRepositorySettings) { @@ -560,20 +566,21 @@ func (h *RepositoryHandlers) wsGetRepository(ctx context.Context, msg *ws.Messag } type wsUpdateRepositoryRequest struct { - ID string `json:"id"` - Name *string `json:"name,omitempty"` - SourceType *string `json:"source_type,omitempty"` - LocalPath *string `json:"local_path,omitempty"` - Provider *string `json:"provider,omitempty"` - ProviderRepoID *string `json:"provider_repo_id,omitempty"` - ProviderOwner *string `json:"provider_owner,omitempty"` - ProviderName *string `json:"provider_name,omitempty"` - DefaultBranch *string `json:"default_branch,omitempty"` - WorktreeBranchPrefix *string `json:"worktree_branch_prefix,omitempty"` - SetupScript *string `json:"setup_script,omitempty"` - CleanupScript *string `json:"cleanup_script,omitempty"` - DevScript *string `json:"dev_script,omitempty"` - CopyFiles *string `json:"copy_files,omitempty"` + ID string `json:"id"` + Name *string `json:"name,omitempty"` + SourceType *string `json:"source_type,omitempty"` + LocalPath *string `json:"local_path,omitempty"` + Provider *string `json:"provider,omitempty"` + ProviderRepoID *string `json:"provider_repo_id,omitempty"` + ProviderOwner *string `json:"provider_owner,omitempty"` + ProviderName *string `json:"provider_name,omitempty"` + DefaultBranch *string `json:"default_branch,omitempty"` + WorktreeBranchPrefix *string `json:"worktree_branch_prefix,omitempty"` + WorktreeBranchTemplate *string `json:"worktree_branch_template,omitempty"` + SetupScript *string `json:"setup_script,omitempty"` + CleanupScript *string `json:"cleanup_script,omitempty"` + DevScript *string `json:"dev_script,omitempty"` + CopyFiles *string `json:"copy_files,omitempty"` } func (h *RepositoryHandlers) wsUpdateRepository(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -585,19 +592,20 @@ func (h *RepositoryHandlers) wsUpdateRepository(ctx context.Context, msg *ws.Mes return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) } repository, err := h.service.UpdateRepository(ctx, req.ID, &service.UpdateRepositoryRequest{ - Name: req.Name, - SourceType: req.SourceType, - LocalPath: req.LocalPath, - Provider: req.Provider, - ProviderRepoID: req.ProviderRepoID, - ProviderOwner: req.ProviderOwner, - ProviderName: req.ProviderName, - DefaultBranch: req.DefaultBranch, - WorktreeBranchPrefix: req.WorktreeBranchPrefix, - SetupScript: req.SetupScript, - CleanupScript: req.CleanupScript, - DevScript: req.DevScript, - CopyFiles: req.CopyFiles, + Name: req.Name, + SourceType: req.SourceType, + LocalPath: req.LocalPath, + Provider: req.Provider, + ProviderRepoID: req.ProviderRepoID, + ProviderOwner: req.ProviderOwner, + ProviderName: req.ProviderName, + DefaultBranch: req.DefaultBranch, + WorktreeBranchPrefix: req.WorktreeBranchPrefix, + WorktreeBranchTemplate: req.WorktreeBranchTemplate, + SetupScript: req.SetupScript, + CleanupScript: req.CleanupScript, + DevScript: req.DevScript, + CopyFiles: req.CopyFiles, }) if err != nil { if errors.Is(err, service.ErrInvalidRepositorySettings) { diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index e7b44596f8..167dd9b2b3 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -770,20 +770,21 @@ type Repository struct { // populated after the repo is cloned/synced on the agent host. LocalPath string `json:"local_path"` // Provider fields describe the upstream source (e.g. github/gitlab) for future syncing. - Provider string `json:"provider"` - ProviderRepoID string `json:"provider_repo_id"` - ProviderOwner string `json:"provider_owner"` - ProviderName string `json:"provider_name"` - DefaultBranch string `json:"default_branch"` - WorktreeBranchPrefix string `json:"worktree_branch_prefix"` - PullBeforeWorktree bool `json:"pull_before_worktree"` - SetupScript string `json:"setup_script"` - CleanupScript string `json:"cleanup_script"` - DevScript string `json:"dev_script"` - CopyFiles string `json:"copy_files"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` + Provider string `json:"provider"` + ProviderRepoID string `json:"provider_repo_id"` + ProviderOwner string `json:"provider_owner"` + ProviderName string `json:"provider_name"` + DefaultBranch string `json:"default_branch"` + WorktreeBranchPrefix string `json:"worktree_branch_prefix"` + WorktreeBranchTemplate string `json:"worktree_branch_template"` + PullBeforeWorktree bool `json:"pull_before_worktree"` + SetupScript string `json:"setup_script"` + CleanupScript string `json:"cleanup_script"` + DevScript string `json:"dev_script"` + CopyFiles string `json:"copy_files"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` } // RepositoryScript represents a custom script for a repository diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 53f176967d..0ecb3de935 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -183,6 +183,7 @@ type SessionRepository interface { type SessionWorktreeRepository interface { CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error UpdateTaskSessionWorktreeBranch(ctx context.Context, sessionID, branch string) error + UpdateTaskSessionWorktreeBranchByRepository(ctx context.Context, sessionID, repositoryID, branch string) error ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) ListWorktreesBySessionIDs(ctx context.Context, sessionIDs []string) (map[string][]*models.TaskSessionWorktree, error) DeleteTaskSessionWorktree(ctx context.Context, id string) error diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index f8ef51b657..ab037e40d3 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -96,6 +96,8 @@ func (r *Repository) runMigrations() error { r.migrate.Apply("workflows.hidden", `ALTER TABLE workflows ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0`) r.migrate.Apply("task_sessions.workspace_path", `ALTER TABLE task_sessions ADD COLUMN workspace_path TEXT DEFAULT ''`) r.migrate.Apply("repositories.copy_files", `ALTER TABLE repositories ADD COLUMN copy_files TEXT DEFAULT ''`) + r.migrate.Apply("repositories.worktree_branch_template", `ALTER TABLE repositories ADD COLUMN worktree_branch_template TEXT DEFAULT 'feature/{title}-{suffix}'`) + r.migrate.Apply("repositories.worktree_branch_template.backfill", `UPDATE repositories SET worktree_branch_template = COALESCE(NULLIF(TRIM(worktree_branch_prefix), ''), 'feature/') || '{title}-{suffix}'`) // Authoritative per-message change signal (chat render-perf). SQLite forbids a // non-constant default on ADD COLUMN, so the column is added nullable and diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index 41c72ac147..18014bd524 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -262,6 +262,7 @@ func (r *Repository) initTaskSchema() error { provider_name TEXT DEFAULT '', default_branch TEXT DEFAULT '', worktree_branch_prefix TEXT DEFAULT 'feature/', + worktree_branch_template TEXT DEFAULT 'feature/{title}-{suffix}', pull_before_worktree INTEGER NOT NULL DEFAULT 1, setup_script TEXT DEFAULT '', cleanup_script TEXT DEFAULT '', diff --git a/apps/backend/internal/task/repository/sqlite/repository_entity.go b/apps/backend/internal/task/repository/sqlite/repository_entity.go index 9ddf860da8..ff4d18ccbf 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_entity.go +++ b/apps/backend/internal/task/repository/sqlite/repository_entity.go @@ -25,11 +25,11 @@ func (r *Repository) CreateRepository(ctx context.Context, repository *models.Re _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO repositories ( id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + provider_name, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), repository.ID, repository.WorkspaceID, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, repository.ProviderRepoID, repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, - dialect.BoolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CopyFiles, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt) + repository.WorktreeBranchTemplate, dialect.BoolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CopyFiles, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt) return err } @@ -40,12 +40,12 @@ func (r *Repository) GetRepository(ctx context.Context, id string) (*models.Repo err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at + provider_name, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at FROM repositories WHERE id = ? AND deleted_at IS NULL `), id).Scan( &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, - &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CopyFiles, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, + &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.WorktreeBranchTemplate, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CopyFiles, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, ) if err == sql.ErrNoRows { @@ -61,10 +61,10 @@ func (r *Repository) UpdateRepository(ctx context.Context, repository *models.Re result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE repositories SET name = ?, source_type = ?, local_path = ?, provider = ?, provider_repo_id = ?, provider_owner = ?, - provider_name = ?, default_branch = ?, worktree_branch_prefix = ?, pull_before_worktree = ?, setup_script = ?, cleanup_script = ?, dev_script = ?, copy_files = ?, updated_at = ? + provider_name = ?, default_branch = ?, worktree_branch_prefix = ?, worktree_branch_template = ?, pull_before_worktree = ?, setup_script = ?, cleanup_script = ?, dev_script = ?, copy_files = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL `), repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, repository.ProviderRepoID, - repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, dialect.BoolToInt(repository.PullBeforeWorktree), + repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, repository.WorktreeBranchTemplate, dialect.BoolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CopyFiles, repository.UpdatedAt, repository.ID) if err != nil { return err @@ -97,7 +97,7 @@ func (r *Repository) DeleteRepository(ctx context.Context, id string) error { func (r *Repository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at + provider_name, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at FROM repositories WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY created_at DESC `), workspaceID) if err != nil { @@ -111,7 +111,7 @@ func (r *Repository) ListRepositories(ctx context.Context, workspaceID string) ( err := rows.Scan( &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, - &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CopyFiles, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, + &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.WorktreeBranchTemplate, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CopyFiles, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, ) if err != nil { return nil, err @@ -127,13 +127,13 @@ func (r *Repository) GetRepositoryByProviderInfo(ctx context.Context, workspaceI repository := &models.Repository{} err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at + provider_name, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at FROM repositories WHERE workspace_id = ? AND provider = ? AND provider_owner = ? AND provider_name = ? AND deleted_at IS NULL `), workspaceID, provider, owner, name).Scan( &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, - &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CopyFiles, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, + &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.WorktreeBranchTemplate, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CopyFiles, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, ) if err == sql.ErrNoRows { return nil, nil diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 1df26bdfcb..b4a3e1de06 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -1143,6 +1143,23 @@ func (r *Repository) UpdateTaskSessionWorktreeBranch(ctx context.Context, sessio return err } +// UpdateTaskSessionWorktreeBranchByRepository updates the cached worktree_branch +// for one repository row in a session. Use this for repo-scoped live git +// operations in multi-repo tasks so sibling repositories keep their branch +// snapshots. +func (r *Repository) UpdateTaskSessionWorktreeBranchByRepository(ctx context.Context, sessionID, repositoryID, branch string) error { + now := time.Now().UTC() + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE task_session_worktrees + SET worktree_branch = ?, updated_at = ? + WHERE session_id = ? + AND repository_id = ? + AND deleted_at IS NULL + AND status = 'active' + `), branch, now, sessionID, repositoryID) + return err +} + func (r *Repository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT diff --git a/apps/backend/internal/task/repository/sqlite/session_test.go b/apps/backend/internal/task/repository/sqlite/session_test.go index aeaf2a8c0d..f261500db5 100644 --- a/apps/backend/internal/task/repository/sqlite/session_test.go +++ b/apps/backend/internal/task/repository/sqlite/session_test.go @@ -176,6 +176,53 @@ func TestListTaskSessionWorktreesFiltersInactiveRows(t *testing.T) { } } +func TestUpdateTaskSessionWorktreeBranchByRepositoryScopesUpdate(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-worktrees", "session-worktrees", "turn-worktrees") + + worktrees := []*models.TaskSessionWorktree{ + { + ID: "wt-repo-1", + SessionID: "session-worktrees", + WorktreeID: "worktree-repo-1", + RepositoryID: "repo-1", + WorktreeBranch: "feature/old-one", + }, + { + ID: "wt-repo-2", + SessionID: "session-worktrees", + WorktreeID: "worktree-repo-2", + RepositoryID: "repo-2", + WorktreeBranch: "feature/old-two", + }, + } + for _, wt := range worktrees { + if err := repo.CreateTaskSessionWorktree(ctx, wt); err != nil { + t.Fatalf("CreateTaskSessionWorktree(%s): %v", wt.ID, err) + } + } + + if err := repo.UpdateTaskSessionWorktreeBranchByRepository(ctx, "session-worktrees", "repo-1", "feature/new-one"); err != nil { + t.Fatalf("UpdateTaskSessionWorktreeBranchByRepository: %v", err) + } + + listed, err := repo.ListTaskSessionWorktrees(ctx, "session-worktrees") + if err != nil { + t.Fatalf("ListTaskSessionWorktrees: %v", err) + } + branches := map[string]string{} + for _, wt := range listed { + branches[wt.RepositoryID] = wt.WorktreeBranch + } + if branches["repo-1"] != "feature/new-one" { + t.Fatalf("repo-1 branch = %q, want feature/new-one", branches["repo-1"]) + } + if branches["repo-2"] != "feature/old-two" { + t.Fatalf("repo-2 branch = %q, want feature/old-two", branches["repo-2"]) + } +} + // TestGetLastAgentMessage_NoMessages verifies that a session with no messages // returns an empty string and sql.ErrNoRows. func TestGetLastAgentMessage_NoMessages(t *testing.T) { diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 8e5b91b320..15c94cd302 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -116,39 +116,41 @@ type FindOrCreateRepositoryRequest struct { // CreateRepositoryRequest contains the data for creating a new repository type CreateRepositoryRequest struct { - WorkspaceID string `json:"workspace_id"` - Name string `json:"name"` - SourceType string `json:"source_type"` - LocalPath string `json:"local_path"` - Provider string `json:"provider"` - ProviderRepoID string `json:"provider_repo_id"` - ProviderOwner string `json:"provider_owner"` - ProviderName string `json:"provider_name"` - DefaultBranch string `json:"default_branch"` - WorktreeBranchPrefix string `json:"worktree_branch_prefix"` - PullBeforeWorktree *bool `json:"pull_before_worktree"` - SetupScript string `json:"setup_script"` - CleanupScript string `json:"cleanup_script"` - DevScript string `json:"dev_script"` - CopyFiles string `json:"copy_files"` + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + SourceType string `json:"source_type"` + LocalPath string `json:"local_path"` + Provider string `json:"provider"` + ProviderRepoID string `json:"provider_repo_id"` + ProviderOwner string `json:"provider_owner"` + ProviderName string `json:"provider_name"` + DefaultBranch string `json:"default_branch"` + WorktreeBranchPrefix string `json:"worktree_branch_prefix"` + WorktreeBranchTemplate string `json:"worktree_branch_template"` + PullBeforeWorktree *bool `json:"pull_before_worktree"` + SetupScript string `json:"setup_script"` + CleanupScript string `json:"cleanup_script"` + DevScript string `json:"dev_script"` + CopyFiles string `json:"copy_files"` } // UpdateRepositoryRequest contains the data for updating a repository type UpdateRepositoryRequest struct { - Name *string `json:"name,omitempty"` - SourceType *string `json:"source_type,omitempty"` - LocalPath *string `json:"local_path,omitempty"` - Provider *string `json:"provider,omitempty"` - ProviderRepoID *string `json:"provider_repo_id,omitempty"` - ProviderOwner *string `json:"provider_owner,omitempty"` - ProviderName *string `json:"provider_name,omitempty"` - DefaultBranch *string `json:"default_branch,omitempty"` - WorktreeBranchPrefix *string `json:"worktree_branch_prefix,omitempty"` - PullBeforeWorktree *bool `json:"pull_before_worktree,omitempty"` - SetupScript *string `json:"setup_script,omitempty"` - CleanupScript *string `json:"cleanup_script,omitempty"` - DevScript *string `json:"dev_script,omitempty"` - CopyFiles *string `json:"copy_files,omitempty"` + Name *string `json:"name,omitempty"` + SourceType *string `json:"source_type,omitempty"` + LocalPath *string `json:"local_path,omitempty"` + Provider *string `json:"provider,omitempty"` + ProviderRepoID *string `json:"provider_repo_id,omitempty"` + ProviderOwner *string `json:"provider_owner,omitempty"` + ProviderName *string `json:"provider_name,omitempty"` + DefaultBranch *string `json:"default_branch,omitempty"` + WorktreeBranchPrefix *string `json:"worktree_branch_prefix,omitempty"` + WorktreeBranchTemplate *string `json:"worktree_branch_template,omitempty"` + PullBeforeWorktree *bool `json:"pull_before_worktree,omitempty"` + SetupScript *string `json:"setup_script,omitempty"` + CleanupScript *string `json:"cleanup_script,omitempty"` + DevScript *string `json:"dev_script,omitempty"` + CopyFiles *string `json:"copy_files,omitempty"` } // CreateExecutorRequest contains the data for creating an executor diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index 792e6b653c..d875bd4a79 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -526,27 +526,32 @@ func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryReq if prefix == "" { prefix = worktree.DefaultBranchPrefix } + template := worktree.NormalizeBranchNameTemplate(req.WorktreeBranchTemplate) + if err := worktree.ValidateBranchNameTemplate(template); err != nil { + return nil, fmt.Errorf("%w: %s", ErrInvalidRepositorySettings, err) + } pullBeforeWorktree := true if req.PullBeforeWorktree != nil { pullBeforeWorktree = *req.PullBeforeWorktree } repository := &models.Repository{ - ID: uuid.New().String(), - WorkspaceID: req.WorkspaceID, - Name: req.Name, - SourceType: sourceType, - LocalPath: req.LocalPath, - Provider: req.Provider, - ProviderRepoID: req.ProviderRepoID, - ProviderOwner: req.ProviderOwner, - ProviderName: req.ProviderName, - DefaultBranch: req.DefaultBranch, - WorktreeBranchPrefix: prefix, - PullBeforeWorktree: pullBeforeWorktree, - SetupScript: req.SetupScript, - CleanupScript: req.CleanupScript, - DevScript: req.DevScript, - CopyFiles: req.CopyFiles, + ID: uuid.New().String(), + WorkspaceID: req.WorkspaceID, + Name: req.Name, + SourceType: sourceType, + LocalPath: req.LocalPath, + Provider: req.Provider, + ProviderRepoID: req.ProviderRepoID, + ProviderOwner: req.ProviderOwner, + ProviderName: req.ProviderName, + DefaultBranch: req.DefaultBranch, + WorktreeBranchPrefix: prefix, + WorktreeBranchTemplate: template, + PullBeforeWorktree: pullBeforeWorktree, + SetupScript: req.SetupScript, + CleanupScript: req.CleanupScript, + DevScript: req.DevScript, + CopyFiles: req.CopyFiles, } // Auto-detect GitHub provider info from git remote if not provided @@ -691,6 +696,13 @@ func applyRepositoryUpdates(repository *models.Repository, req *UpdateRepository } repository.WorktreeBranchPrefix = prefix } + if req.WorktreeBranchTemplate != nil { + template := worktree.NormalizeBranchNameTemplate(*req.WorktreeBranchTemplate) + if err := worktree.ValidateBranchNameTemplate(template); err != nil { + return fmt.Errorf("%w: %s", ErrInvalidRepositorySettings, err) + } + repository.WorktreeBranchTemplate = template + } if req.PullBeforeWorktree != nil { repository.PullBeforeWorktree = *req.PullBeforeWorktree } diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 93aaf1af87..312061bae6 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -852,6 +852,65 @@ func TestService_CreateRepository_DefaultWorktreeBranchPrefix(t *testing.T) { } } +func TestService_CreateRepository_DefaultWorktreeBranchTemplate(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + + created, err := svc.CreateRepository(ctx, &CreateRepositoryRequest{ + WorkspaceID: "ws-1", + Name: "Test Repo", + }) + if err != nil { + t.Fatalf("CreateRepository failed: %v", err) + } + if created.WorktreeBranchTemplate != worktree.DefaultBranchNameTemplate { + t.Fatalf("expected default template %q, got %q", worktree.DefaultBranchNameTemplate, created.WorktreeBranchTemplate) + } + + stored, err := repo.GetRepository(ctx, created.ID) + if err != nil { + t.Fatalf("GetRepository failed: %v", err) + } + if stored.WorktreeBranchTemplate != worktree.DefaultBranchNameTemplate { + t.Fatalf("expected stored template %q, got %q", worktree.DefaultBranchNameTemplate, stored.WorktreeBranchTemplate) + } +} + +func TestService_UpdateRepository_WorktreeBranchTemplate(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + created, err := svc.CreateRepository(ctx, &CreateRepositoryRequest{ + WorkspaceID: "ws-1", + Name: "Test Repo", + }) + if err != nil { + t.Fatalf("CreateRepository failed: %v", err) + } + + template := "feature/{ticket}-{title}" + updated, err := svc.UpdateRepository(ctx, created.ID, &UpdateRepositoryRequest{ + WorktreeBranchTemplate: &template, + }) + if err != nil { + t.Fatalf("UpdateRepository failed: %v", err) + } + if updated.WorktreeBranchTemplate != template { + t.Fatalf("expected template %q, got %q", template, updated.WorktreeBranchTemplate) + } + + stored, err := repo.GetRepository(ctx, created.ID) + if err != nil { + t.Fatalf("GetRepository failed: %v", err) + } + if stored.WorktreeBranchTemplate != template { + t.Fatalf("expected stored template %q, got %q", template, stored.WorktreeBranchTemplate) + } +} + func TestService_CreateRepository_PullBeforeWorktreeFalse(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() diff --git a/apps/backend/internal/worktree/branch_template_test.go b/apps/backend/internal/worktree/branch_template_test.go new file mode 100644 index 0000000000..e66cdee9fd --- /dev/null +++ b/apps/backend/internal/worktree/branch_template_test.go @@ -0,0 +1,112 @@ +package worktree + +import "testing" + +func TestRenderTaskBranchNameUsesTicketAliases(t *testing.T) { + got, err := RenderTaskBranchName(BranchNameTemplateInput{ + Template: "{ticket}-{title}-{suffix}", + TaskID: "task-123", + Title: "Fix GitHub imports", + Ticket: "KAN-42", + Suffix: "abc", + }) + if err != nil { + t.Fatalf("RenderTaskBranchName: %v", err) + } + if got != "kan-42-fix-github-imports-abc" { + t.Fatalf("branch name = %q, want %q", got, "kan-42-fix-github-imports-abc") + } + + got, err = RenderTaskBranchName(BranchNameTemplateInput{ + Template: "{issue_key}-{task_id}", + TaskID: "task-123", + Title: "Fix GitHub imports", + Ticket: "KAN-42", + Suffix: "abc", + }) + if err != nil { + t.Fatalf("RenderTaskBranchName alias: %v", err) + } + if got != "kan-42-task-123" { + t.Fatalf("branch name alias = %q, want %q", got, "kan-42-task-123") + } +} + +func TestRenderTaskBranchNameAllowsTemplateWithoutSuffix(t *testing.T) { + got, err := RenderTaskBranchName(BranchNameTemplateInput{ + Template: "feature/{ticket}-{title}", + TaskID: "task-123", + Title: "Add branch templates", + Ticket: "KAN-42", + Suffix: "abc", + }) + if err != nil { + t.Fatalf("RenderTaskBranchName: %v", err) + } + if got != "feature/kan-42-add-branch-templates" { + t.Fatalf("branch name = %q, want %q", got, "feature/kan-42-add-branch-templates") + } +} + +func TestRenderTaskBranchNameRejectsPrefixPlaceholder(t *testing.T) { + _, err := RenderTaskBranchName(BranchNameTemplateInput{ + Template: "{prefix}{title}-{suffix}", + TaskID: "task-123", + Title: "Fix branch", + Suffix: "abc", + }) + if err == nil { + t.Fatal("expected invalid branch name error for unsupported prefix placeholder") + } +} + +func TestRenderTaskBranchNameRejectsInvalidRenderedName(t *testing.T) { + tests := []struct { + name string + template string + title string + }{ + {name: "parent path", template: "../{title}", title: "Fix branch"}, + {name: "hidden component", template: "feature/.{title}", title: "Fix branch"}, + {name: "trailing dot", template: "feature/{title}.", title: "Fix branch"}, + {name: "lock component", template: "feature.lock/{title}", title: "Fix branch"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := RenderTaskBranchName(BranchNameTemplateInput{ + Template: tt.template, + TaskID: "task-123", + Title: tt.title, + Suffix: "abc", + }) + if err == nil { + t.Fatal("expected invalid branch name error") + } + }) + } +} + +func TestTicketForBranchNameResolvesKnownMetadata(t *testing.T) { + tests := []struct { + name string + identifier string + metadata map[string]any + want string + }{ + {name: "task identifier wins", identifier: "TASK-7", metadata: map[string]any{"jira_issue_key": "KAN-42"}, want: "TASK-7"}, + {name: "jira issue key", metadata: map[string]any{"jira_issue_key": "KAN-42"}, want: "KAN-42"}, + {name: "linear identifier", metadata: map[string]any{"linear_issue_identifier": "LIN-9"}, want: "LIN-9"}, + {name: "github issue", metadata: map[string]any{"issue_repo": "kdlbs/kandev", "issue_number": float64(1610)}, want: "kdlbs-kandev-1610"}, + {name: "github pr", metadata: map[string]any{"pr_repo": "kdlbs/kandev", "pr_number": 37}, want: "kdlbs-kandev-37"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := TicketForBranchName(tt.identifier, tt.metadata) + if got != tt.want { + t.Fatalf("TicketForBranchName() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/worktree/config.go b/apps/backend/internal/worktree/config.go index f32ec59e41..5f7415d62b 100644 --- a/apps/backend/internal/worktree/config.go +++ b/apps/backend/internal/worktree/config.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "unicode" ) @@ -37,6 +38,19 @@ type Config struct { // DefaultBranchPrefix is used when no repository-specific prefix is provided. const DefaultBranchPrefix = "feature/" +// DefaultBranchNameTemplate is the branch template used for new repositories. +const DefaultBranchNameTemplate = "feature/{title}-{suffix}" + +// BranchNameTemplateInput contains the values available to branch name templates. +// Users write literal prefixes directly in Template; {prefix} is not supported. +type BranchNameTemplateInput struct { + Template string + TaskID string + Title string + Ticket string + Suffix string +} + // Validate validates the configuration and returns an error if invalid. func (c *Config) Validate() error { if c.BranchPrefix == "" { @@ -161,18 +175,94 @@ func NormalizeBranchPrefix(prefix string) string { return trimmed } +// NormalizeBranchNameTemplate trims and falls back to the default template. +func NormalizeBranchNameTemplate(template string) string { + trimmed := strings.TrimSpace(template) + if trimmed == "" { + return DefaultBranchNameTemplate + } + return trimmed +} + +// ValidateBranchNameTemplate ensures a branch template can render to a safe git branch. +func ValidateBranchNameTemplate(template string) error { + _, err := RenderTaskBranchName(BranchNameTemplateInput{ + Template: template, + TaskID: "task-123", + Title: "Example task", + Ticket: "TICKET-123", + Suffix: "abc", + }) + return err +} + +// RenderTaskBranchName applies a repository branch-name template and validates +// that the rendered value is safe to pass to git as a local branch name. +func RenderTaskBranchName(input BranchNameTemplateInput) (string, error) { + template := NormalizeBranchNameTemplate(input.Template) + + title := SanitizeForBranch(input.Title, 20) + if title == "" { + title = SanitizeForBranch(input.TaskID, 20) + } + if title == "" { + title = "task" + } + + titleFull := SanitizeForBranch(input.Title, 80) + if titleFull == "" { + titleFull = SanitizeForBranch(input.TaskID, 80) + } + if titleFull == "" { + titleFull = "task" + } + + taskID := SanitizeForBranch(input.TaskID, 80) + ticket := SanitizeForBranch(input.Ticket, 80) + suffix := SanitizeForBranch(input.Suffix, 16) + + replacements := map[string]string{ + "{title}": title, + "{title_full}": titleFull, + "{task_id}": taskID, + "{ticket}": ticket, + "{issue_key}": ticket, + "{suffix}": suffix, + } + rendered := template + for placeholder, value := range replacements { + rendered = strings.ReplaceAll(rendered, placeholder, value) + } + rendered = strings.TrimSpace(rendered) + if !isSafeRenderedBranchName(rendered) { + return "", fmt.Errorf("invalid branch name") + } + return rendered, nil +} + // TaskBranchNameWithSuffix builds the per-task branch name from a caller-supplied suffix. // The caller is responsible for generating the suffix (e.g. SmallSuffix(3)) so that // naming is deterministic when the suffix is derived externally. func TaskBranchNameWithSuffix(taskTitle, taskID, prefix, suffix string) string { - sanitized := SanitizeForBranch(taskTitle, 20) - if sanitized == "" { - sanitized = SanitizeForBranch(taskID, 20) + normalizedPrefix := branchPrefixWithSeparator(prefix) + branch, err := RenderTaskBranchName(BranchNameTemplateInput{ + Template: normalizedPrefix + "{title}-{suffix}", + TaskID: taskID, + Title: taskTitle, + Suffix: suffix, + }) + if err == nil { + return branch } - if sanitized == "" { - sanitized = "task" + return normalizedPrefix + "task-" + suffix +} + +func branchPrefixWithSeparator(prefix string) string { + normalizedPrefix := NormalizeBranchPrefix(prefix) + if !strings.HasSuffix(normalizedPrefix, "/") && !strings.HasSuffix(normalizedPrefix, "-") { + normalizedPrefix += "-" } - return NormalizeBranchPrefix(prefix) + sanitized + "-" + suffix + return normalizedPrefix } // ValidateBranchPrefix ensures a prefix contains only safe branch characters. @@ -193,6 +283,81 @@ func ValidateBranchPrefix(prefix string) error { return nil } +var renderedBranchNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`) + +func isSafeRenderedBranchName(branch string) bool { + if branch == "" || len(branch) > 255 { + return false + } + if strings.Contains(branch, "..") || strings.Contains(branch, "@{") { + return false + } + if strings.Contains(branch, "//") || strings.HasSuffix(branch, "/") { + return false + } + for _, component := range strings.Split(branch, "/") { + if component == "" || + strings.HasPrefix(component, ".") || + strings.HasSuffix(component, ".") || + strings.HasSuffix(component, ".lock") { + return false + } + } + return renderedBranchNameRegex.MatchString(branch) +} + +// TicketForBranchName resolves the stable external ticket value used by branch templates. +func TicketForBranchName(identifier string, metadata map[string]any) string { + if trimmed := strings.TrimSpace(identifier); trimmed != "" { + return trimmed + } + if value := metadataString(metadata, "jira_issue_key"); value != "" { + return value + } + if value := metadataString(metadata, "linear_issue_identifier"); value != "" { + return value + } + if repo := metadataString(metadata, "issue_repo"); repo != "" { + if number := metadataNumberString(metadata, "issue_number"); number != "" { + return SanitizeForBranch(repo+"-"+number, 80) + } + } + if repo := metadataString(metadata, "pr_repo"); repo != "" { + if number := metadataNumberString(metadata, "pr_number"); number != "" { + return SanitizeForBranch(repo+"-"+number, 80) + } + } + return "" +} + +func metadataString(metadata map[string]any, key string) string { + if metadata == nil { + return "" + } + if value, ok := metadata[key].(string); ok { + return strings.TrimSpace(value) + } + return "" +} + +func metadataNumberString(metadata map[string]any, key string) string { + if metadata == nil { + return "" + } + switch value := metadata[key].(type) { + case int: + return strconv.Itoa(value) + case int64: + return strconv.FormatInt(value, 10) + case float64: + return strconv.FormatInt(int64(value), 10) + case string: + return strings.TrimSpace(value) + default: + return "" + } +} + const branchSuffixAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789" // SmallSuffix returns a random suffix capped at 3 characters. diff --git a/apps/backend/internal/worktree/manager_lifecycle.go b/apps/backend/internal/worktree/manager_lifecycle.go index 10696d1b51..5b8c598e00 100644 --- a/apps/backend/internal/worktree/manager_lifecycle.go +++ b/apps/backend/internal/worktree/manager_lifecycle.go @@ -637,16 +637,30 @@ func parseCheckedOutPath(gitOutput string) string { func (m *Manager) buildWorktreeNames(req CreateRequest) (dirName, branchName string) { dirSuffix := uuid.New().String()[:8] // Use first 8 chars of UUID for worktree dir uniqueness branchSuffix := SmallSuffix(3) - prefix := NormalizeBranchPrefix(req.WorktreeBranchPrefix) if req.TaskTitle != "" { // Use semantic naming: {sanitized-title}_{suffix} dirName = SemanticWorktreeName(req.TaskTitle, dirSuffix) - branchName = TaskBranchNameWithSuffix(req.TaskTitle, req.TaskID, prefix, branchSuffix) } else { // Fallback to task ID based naming dirName = req.TaskID + "_" + dirSuffix - branchName = TaskBranchNameWithSuffix("", req.TaskID, prefix, branchSuffix) + } + branchName = TaskBranchNameWithSuffix(req.TaskTitle, req.TaskID, req.WorktreeBranchPrefix, branchSuffix) + if req.WorktreeBranchTemplate != "" { + if rendered, err := RenderTaskBranchName(BranchNameTemplateInput{ + Template: req.WorktreeBranchTemplate, + TaskID: req.TaskID, + Title: req.TaskTitle, + Ticket: req.WorktreeBranchTicket, + Suffix: branchSuffix, + }); err == nil { + branchName = rendered + } else { + m.logger.Warn("worktree branch template render failed; using fallback branch name", + zap.String("task_id", req.TaskID), + zap.String("template", req.WorktreeBranchTemplate), + zap.Error(err)) + } } return dirName, branchName } diff --git a/apps/backend/internal/worktree/manager_names_test.go b/apps/backend/internal/worktree/manager_names_test.go index 2157d718f1..980d59c4d0 100644 --- a/apps/backend/internal/worktree/manager_names_test.go +++ b/apps/backend/internal/worktree/manager_names_test.go @@ -69,6 +69,19 @@ func TestBuildWorktreeNames(t *testing.T) { } }) + t.Run("branch template", func(t *testing.T) { + req := CreateRequest{ + TaskID: "task-1", + TaskTitle: "Add feature", + WorktreeBranchTemplate: "task/{task_id}-{title}-{suffix}", + } + _, branchName := mgr.buildWorktreeNames(req) + + if !strings.HasPrefix(branchName, "task/task-1-add-feature-") { + t.Errorf("branchName = %q, want prefix %q", branchName, "task/task-1-add-feature-") + } + }) + t.Run("title with only special chars falls back to task ID for branch", func(t *testing.T) { req := CreateRequest{ TaskID: "task-fallback", @@ -102,6 +115,13 @@ func TestBuildWorktreeNames(t *testing.T) { } }) + t.Run("invalid title fallback separates custom prefix from task body", func(t *testing.T) { + got := TaskBranchNameWithSuffix("修复登录问题", "task-cjk", "custom", "abc") + if got != "custom-task-cjk-abc" { + t.Fatalf("TaskBranchNameWithSuffix = %q, want %q", got, "custom-task-cjk-abc") + } + }) + t.Run("mixed ASCII and CJK title keeps ASCII parts", func(t *testing.T) { req := CreateRequest{ TaskID: "task-mix", diff --git a/apps/backend/internal/worktree/worktree.go b/apps/backend/internal/worktree/worktree.go index 5841e2257e..89453a172b 100644 --- a/apps/backend/internal/worktree/worktree.go +++ b/apps/backend/internal/worktree/worktree.go @@ -161,6 +161,13 @@ type CreateRequest struct { // If empty, the default prefix is used. WorktreeBranchPrefix string + // WorktreeBranchTemplate is the template to use for the worktree branch name. + // If empty, the default template is used. + WorktreeBranchTemplate string + + // WorktreeBranchTicket is the external ticket value used by branch templates. + WorktreeBranchTicket string + // PullBeforeWorktree indicates whether to pull from remote before creating the worktree. PullBeforeWorktree bool diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index 21774fe250..008c91733b 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -223,6 +223,7 @@ export async function createRepositoryAction(payload: { provider_name: string; default_branch: string; worktree_branch_prefix: string; + worktree_branch_template: string; pull_before_worktree: boolean; setup_script: string; cleanup_script: string; @@ -243,6 +244,7 @@ export async function createRepositoryAction(payload: { provider_name: payload.provider_name, default_branch: payload.default_branch, worktree_branch_prefix: payload.worktree_branch_prefix, + worktree_branch_template: payload.worktree_branch_template, pull_before_worktree: payload.pull_before_worktree, setup_script: payload.setup_script, cleanup_script: payload.cleanup_script, diff --git a/apps/web/app/settings/workspace/workspace-repositories-client.tsx b/apps/web/app/settings/workspace/workspace-repositories-client.tsx index 02a3d4362e..7b0b169210 100644 --- a/apps/web/app/settings/workspace/workspace-repositories-client.tsx +++ b/apps/web/app/settings/workspace/workspace-repositories-client.tsx @@ -40,6 +40,7 @@ import { isRepositoryDirty, type RepositoryWithScripts, } from "@/app/settings/workspace/workspace-repositories-dirty"; +import { defaultWorktreeBranchTemplate } from "@/lib/worktree-branch-template"; type RepositoryItem = RepositoryWithScripts & { __autoOpen?: boolean }; type WorkspaceRepositoriesClientProps = { @@ -68,6 +69,7 @@ function buildDraftRepo( provider_name: "", default_branch: selectedRepo?.default_branch ?? "", worktree_branch_prefix: "feature/", + worktree_branch_template: defaultWorktreeBranchTemplate, pull_before_worktree: true, setup_script: "", cleanup_script: "", @@ -107,6 +109,7 @@ async function saveNewRepository( provider_name: repo.provider_name, default_branch: repo.default_branch, worktree_branch_prefix: repo.worktree_branch_prefix, + worktree_branch_template: repo.worktree_branch_template ?? defaultWorktreeBranchTemplate, pull_before_worktree: repo.pull_before_worktree, setup_script: repo.setup_script, cleanup_script: repo.cleanup_script, @@ -155,6 +158,7 @@ async function saveExistingRepository({ provider_name: repo.provider_name, default_branch: repo.default_branch, worktree_branch_prefix: repo.worktree_branch_prefix, + worktree_branch_template: repo.worktree_branch_template ?? defaultWorktreeBranchTemplate, pull_before_worktree: repo.pull_before_worktree, setup_script: repo.setup_script, cleanup_script: repo.cleanup_script, diff --git a/apps/web/app/settings/workspace/workspace-repositories-dirty.test.ts b/apps/web/app/settings/workspace/workspace-repositories-dirty.test.ts index d84180d79b..f343b92eac 100644 --- a/apps/web/app/settings/workspace/workspace-repositories-dirty.test.ts +++ b/apps/web/app/settings/workspace/workspace-repositories-dirty.test.ts @@ -4,6 +4,7 @@ import { type RepositoryWithScripts, } from "@/app/settings/workspace/workspace-repositories-dirty"; import { repositoryId as toRepositoryId, workspaceId as toWorkspaceId } from "@/lib/types/http"; +import { defaultWorktreeBranchTemplate } from "@/lib/worktree-branch-template"; function makeRepo(overrides: Partial = {}): RepositoryWithScripts { return { @@ -18,6 +19,7 @@ function makeRepo(overrides: Partial = {}): RepositoryWit provider_name: "", default_branch: "main", worktree_branch_prefix: "feature/", + worktree_branch_template: defaultWorktreeBranchTemplate, pull_before_worktree: true, setup_script: "", cleanup_script: "", @@ -43,6 +45,18 @@ describe("isRepositoryDirty", () => { expect(isRepositoryDirty(repo, saved)).toBe(true); }); + it("returns true when worktree branch template differs", () => { + const saved = makeRepo({ worktree_branch_template: defaultWorktreeBranchTemplate }); + const repo = makeRepo({ worktree_branch_template: "feature/{ticket}-{title}" }); + expect(isRepositoryDirty(repo, saved)).toBe(true); + }); + + it("treats an empty worktree branch template as the default", () => { + const saved = makeRepo({ worktree_branch_template: defaultWorktreeBranchTemplate }); + const repo = makeRepo({ worktree_branch_template: "" }); + expect(isRepositoryDirty(repo, saved)).toBe(false); + }); + it("returns true when there is no saved repository", () => { const repo = makeRepo(); expect(isRepositoryDirty(repo, undefined)).toBe(true); diff --git a/apps/web/app/settings/workspace/workspace-repositories-dirty.ts b/apps/web/app/settings/workspace/workspace-repositories-dirty.ts index 22354056a1..d3a3c4e2e7 100644 --- a/apps/web/app/settings/workspace/workspace-repositories-dirty.ts +++ b/apps/web/app/settings/workspace/workspace-repositories-dirty.ts @@ -1,7 +1,29 @@ import type { Repository, RepositoryScript } from "@/lib/types/http"; +import { defaultWorktreeBranchTemplate } from "@/lib/worktree-branch-template"; export type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] }; +const repositoryFields: Array = [ + "name", + "source_type", + "local_path", + "provider", + "provider_repo_id", + "provider_owner", + "provider_name", + "default_branch", + "worktree_branch_prefix", + "pull_before_worktree", + "setup_script", + "cleanup_script", + "dev_script", + "copy_files", +]; + +function branchTemplate(repo: RepositoryWithScripts): string { + return repo.worktree_branch_template || defaultWorktreeBranchTemplate; +} + export function cloneRepository(repo: RepositoryWithScripts): RepositoryWithScripts { return { ...repo, scripts: repo.scripts.map((script) => ({ ...script })) }; } @@ -12,20 +34,8 @@ export function isRepositoryDirty( ): boolean { if (!saved) return true; return ( - repo.name !== saved.name || - repo.source_type !== saved.source_type || - repo.local_path !== saved.local_path || - repo.provider !== saved.provider || - repo.provider_repo_id !== saved.provider_repo_id || - repo.provider_owner !== saved.provider_owner || - repo.provider_name !== saved.provider_name || - repo.default_branch !== saved.default_branch || - repo.worktree_branch_prefix !== saved.worktree_branch_prefix || - repo.pull_before_worktree !== saved.pull_before_worktree || - repo.setup_script !== saved.setup_script || - repo.cleanup_script !== saved.cleanup_script || - repo.dev_script !== saved.dev_script || - repo.copy_files !== saved.copy_files + repositoryFields.some((field) => repo[field] !== saved[field]) || + branchTemplate(repo) !== branchTemplate(saved) ); } diff --git a/apps/web/components/automations/automation-payload.ts b/apps/web/components/automations/automation-payload.ts index 04d7b2450d..01ff786f48 100644 --- a/apps/web/components/automations/automation-payload.ts +++ b/apps/web/components/automations/automation-payload.ts @@ -1,5 +1,6 @@ import { createRepositoryAction } from "@/app/actions/workspaces"; import type { ExecutionMode, TriggerType } from "@/lib/types/automation"; +import { defaultWorktreeBranchTemplate } from "@/lib/worktree-branch-template"; import type { RepositorySelection } from "./config-section"; // Shared form state + pending trigger types used by the editor and its @@ -53,6 +54,7 @@ export async function resolveRepositoryId( provider_name: "", default_branch: selection.defaultBranch, worktree_branch_prefix: "feature/", + worktree_branch_template: defaultWorktreeBranchTemplate, pull_before_worktree: true, setup_script: "", cleanup_script: "", diff --git a/apps/web/components/settings/repository-branch-template-help.tsx b/apps/web/components/settings/repository-branch-template-help.tsx new file mode 100644 index 0000000000..87cc2133de --- /dev/null +++ b/apps/web/components/settings/repository-branch-template-help.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { IconInfoCircle } from "@tabler/icons-react"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card"; + +const branchTemplatePlaceholders = [ + [ + "{title}", + "Task title sanitized to lowercase ASCII, hyphen-separated, max 20 chars. Example: fix-login-flow.", + ], + [ + "{title_full}", + "Same sanitizing as title, but max 80 chars. Example: fix-login-flow-after-session-timeout.", + ], + [ + "{ticket}", + "Task identifier first; otherwise Jira, Linear, GitHub issue, or GitHub PR metadata. Examples: KAN-123, #42.", + ], + ["{issue_key}", "Alias for ticket. Use whichever name reads better in your template."], + [ + "{task_id}", + "Kandev task UUID, sanitized for branch names. Example: 1f1cf094-db3c-4f42-b425-2cc14a2f7c74.", + ], + [ + "{suffix}", + "Short random suffix. Optional, but recommended to avoid branch name clashes. Example: x7p9.", + ], +] as const; + +export function RepositoryBranchTemplateHelp() { + return ( + + + + + +
+

+ Write literal prefixes directly, for example{" "} + + feature/{"{ticket}"}-{"{title}"} + + . +

+
+ {branchTemplatePlaceholders.map(([name, description]) => ( +
+
{name}
+
{description}
+
+ ))} +
+
+
+
+ ); +} diff --git a/apps/web/components/settings/repository-card.tsx b/apps/web/components/settings/repository-card.tsx index d12cfe799b..b4add1b78d 100644 --- a/apps/web/components/settings/repository-card.tsx +++ b/apps/web/components/settings/repository-card.tsx @@ -13,10 +13,12 @@ import { useRequest } from "@/lib/http/use-request"; import { useToast } from "@/components/toast-provider"; import { UnsavedChangesBadge, UnsavedSaveButton } from "@/components/settings/unsaved-indicator"; import { EditableCard } from "@/components/settings/editable-card"; +import { RepositoryBranchTemplateHelp } from "@/components/settings/repository-branch-template-help"; import { DeleteRepositoryDialog } from "@/components/settings/repository-delete-dialog"; import { CopyFilesField } from "@/components/settings/repository-copy-files-help"; import { getRepositoryActiveSessionCountAction } from "@/app/actions/workspaces"; import type { Repository, RepositoryScript } from "@/lib/types/http"; +import { defaultWorktreeBranchTemplate } from "@/lib/worktree-branch-template"; type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] }; @@ -29,7 +31,7 @@ type RepositoryBasicFieldsProps = RepoFieldsBaseProps & { repositoryName: string; repositoryLocalPath: string; sourceType: string; - worktreeBranchPrefix: string; + worktreeBranchTemplate: string; pullBeforeWorktree: boolean; }; @@ -39,7 +41,7 @@ function RepositoryBasicFields({ repositoryName, repositoryLocalPath, sourceType, - worktreeBranchPrefix, + worktreeBranchTemplate, pullBeforeWorktree, }: RepositoryBasicFieldsProps) { return ( @@ -63,19 +65,17 @@ function RepositoryBasicFields({ /> -
- +
+ + +
onUpdate(repositoryId, { worktree_branch_prefix: e.target.value })} - placeholder="feature/" + value={worktreeBranchTemplate} + onChange={(e) => onUpdate(repositoryId, { worktree_branch_template: e.target.value })} + placeholder={defaultWorktreeBranchTemplate} /> -

- Used for new worktree branches. Leave empty to use the default. Branches are generated - as {"{prefix}{sanitized-title}-{rand}"}. -

@@ -94,9 +94,6 @@ function RepositoryBasicFields({ > Always pull before creating a new worktree -

- Keeps the base branch up to date with the remote before spawning new sessions. -

@@ -296,7 +293,9 @@ function RepositoryEditView({ repositoryName={repository.name ?? ""} repositoryLocalPath={repository.local_path ?? ""} sourceType={repository.source_type} - worktreeBranchPrefix={repository.worktree_branch_prefix ?? ""} + worktreeBranchTemplate={ + repository.worktree_branch_template ?? defaultWorktreeBranchTemplate + } pullBeforeWorktree={repository.pull_before_worktree ?? true} /> @@ -368,7 +367,6 @@ function buildRepoScriptsSummary(repository: RepositoryWithScripts) { function buildRepoPreviewData(repository: RepositoryWithScripts) { const repositoryName = repository.name ?? ""; - const worktreeBranchPrefix = repository.worktree_branch_prefix ?? ""; const sourceLabel = repository.source_type === "local" ? "Local" : "Remote"; const subtitle = repository.source_type === "local" @@ -378,7 +376,6 @@ function buildRepoPreviewData(repository: RepositoryWithScripts) { "Remote repository"; return { repositoryName, - worktreeBranchPrefix, sourceLabel, subtitle, ...buildRepoScriptsSummary(repository), @@ -394,7 +391,6 @@ function RepositoryPreview({ }: RepositoryPreviewProps) { const { repositoryName, - worktreeBranchPrefix, scriptsCount, hasSetupScript, hasCleanupScript, @@ -419,11 +415,6 @@ function RepositoryPreview({ {sourceLabel} - {worktreeBranchPrefix.trim() && worktreeBranchPrefix.trim() !== "feature/" ? ( - - {worktreeBranchPrefix.trim()} - - ) : null} {isDirty && }
{subtitle}
diff --git a/apps/web/components/task/changes-panel-header.tsx b/apps/web/components/task/changes-panel-header.tsx index be7e9c91b7..350071a604 100644 --- a/apps/web/components/task/changes-panel-header.tsx +++ b/apps/web/components/task/changes-panel-header.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { IconCloudDownload, IconEye, @@ -9,8 +10,12 @@ import { IconGitMerge, IconArrowRight, IconLoader2, + IconEdit, } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@kandev/ui/dialog"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card"; import { DropdownMenu, @@ -42,6 +47,11 @@ type BranchRow = { repositoryName: string; }; +type RenameBranchResult = { + success: boolean; + error?: string; +}; + /** * Builds per-repo rows for the branch hover card. Returns [] for single-repo * workspaces (callers fall back to the single-row layout); otherwise one row @@ -64,13 +74,101 @@ function buildBranchRows( })); } +function RenameBranchButton({ + branch, + repositoryName, + onRenameBranch, + isRenaming, +}: { + branch: string; + repositoryName: string; + onRenameBranch?: (newName: string, repo: string) => Promise; + isRenaming: boolean; +}) { + const [open, setOpen] = useState(false); + const [newBranchName, setNewBranchName] = useState(branch); + const [error, setError] = useState(null); + const trimmedBranchName = newBranchName.trim(); + const canRename = !!onRenameBranch && trimmedBranchName !== "" && trimmedBranchName !== branch; + const submitRename = async () => { + if (!onRenameBranch || !canRename) return; + setError(null); + try { + const result = await onRenameBranch(trimmedBranchName, repositoryName); + if (!result.success) { + setError(result.error || "Failed to rename branch"); + return; + } + setOpen(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to rename branch"); + } + }; + const openDialog = () => { + setNewBranchName(branch); + setError(null); + setOpen(true); + }; + return ( + <> + + + + + Edit branch + +
+ + setNewBranchName(event.target.value)} + autoFocus + /> + {error &&

{error}

} +
+ + + + +
+
+ + ); +} + function BranchRowView({ repoLabel, branch, baseBranch, repositoryName, taskId, -}: BranchRow & { taskId: string | null }) { + onRenameBranch, + isRenaming, +}: BranchRow & { + taskId: string | null; + onRenameBranch?: (newName: string, repo: string) => Promise; + isRenaming: boolean; +}) { return (
{repoLabel && ( @@ -78,12 +176,20 @@ function BranchRowView({ {repoLabel} )} - - - {branch} + + + {branch} -
- + +
+
+ +
Promise; + isRenaming: boolean; }) { const isMulti = rows && rows.length > 0; const headerLabel = isMulti ? "Your branches:" : "Your code lives in:"; @@ -120,7 +230,7 @@ function BranchHoverCard({ - +
{headerLabel} @@ -129,7 +239,13 @@ function BranchHoverCard({ {isMulti ? (
{rows!.map((row) => ( - + ))}
) : ( @@ -139,6 +255,8 @@ function BranchHoverCard({ baseBranch={baseBranchDisplay} repositoryName="" taskId={taskId} + onRenameBranch={onRenameBranch} + isRenaming={isRenaming} /> )}
@@ -351,6 +469,7 @@ export function ChangesPanelHeader({ onRepoMerge, repoDisplayName, taskId, + onRenameBranch, }: { hasChanges: boolean; hasCommits: boolean; @@ -371,6 +490,7 @@ export function ChangesPanelHeader({ onRepoPull: (repo: string) => void; onRepoRebase: (repo: string) => void; onRepoMerge: (repo: string) => void; + onRenameBranch?: (newName: string, repo: string) => Promise; repoDisplayName?: (repositoryName: string) => string | undefined; /** Active task id; piped into the base-branch picker so it can resolve * the right task_repositories row to PATCH. Null while task data is @@ -401,6 +521,8 @@ export function ChangesPanelHeader({ baseBranchDisplay={baseBranchDisplay} rows={branchRows} taskId={taskId} + onRenameBranch={onRenameBranch} + isRenaming={loadingOperation === "rename_branch"} /> )} diff --git a/apps/web/components/task/mobile/mobile-changes-panel.tsx b/apps/web/components/task/mobile/mobile-changes-panel.tsx index 31771afb76..31eda1b675 100644 --- a/apps/web/components/task/mobile/mobile-changes-panel.tsx +++ b/apps/web/components/task/mobile/mobile-changes-panel.tsx @@ -105,6 +105,7 @@ export const MobileChangesPanel = memo(function MobileChangesPanel({ onRepoPull={data.repoCallbacks.onRepoPull} onRepoRebase={data.repoCallbacks.onRepoRebase} onRepoMerge={data.repoCallbacks.onRepoMerge} + onRenameBranch={data.git.renameBranch} repoDisplayName={data.repoDisplayName} taskId={data.activeTaskId} /> diff --git a/apps/web/e2e/tests/session/session-resume-dedup.spec.ts b/apps/web/e2e/tests/session/session-resume-dedup.spec.ts index b21e1dffb7..01dcc2f009 100644 --- a/apps/web/e2e/tests/session/session-resume-dedup.spec.ts +++ b/apps/web/e2e/tests/session/session-resume-dedup.spec.ts @@ -71,19 +71,30 @@ test.describe("Session resume boot-message dedup", () => { // prompt nor the agent reply ever appear (no reload can recover them // because they were never persisted). Verify the user prompt actually // echoes in the chat; if not, the send was dropped, so re-idle and - // resend once before asserting the reply. + // resend once. If the echo appears but the reply does not, the agent + // turn likely raced the restarted WS subscription; resend once before + // failing the test. const followupPrompt = "/e2e:simple-message"; - await session.sendMessage(followupPrompt); - const followupEcho = session.chat.getByText(followupPrompt, { exact: false }).nth(1); - try { - await expect(followupEcho).toBeVisible({ timeout: 10_000 }); - } catch { - await session.waitForChatIdle({ timeout: 30_000 }); + for (let attempt = 0; attempt < 2; attempt++) { + const followupEchoIndex = await session.chat + .getByText(followupPrompt, { exact: false }) + .count(); await session.sendMessage(followupPrompt); - await expect(followupEcho).toBeVisible({ timeout: 15_000 }); + + try { + await expect( + session.chat.getByText(followupPrompt, { exact: false }).nth(followupEchoIndex), + ).toBeVisible({ + timeout: attempt === 0 ? 10_000 : 15_000, + }); + await session.expectChatResponseVisible("simple mock response", 1, { timeout: 90_000 }); + break; + } catch (error) { + if (attempt === 1) { + throw error; + } + await session.waitForChatIdle({ timeout: 30_000 }); + } } - // After two restart/resume cycles, the follow-up turn can remain "Thinking" - // longer than the default retry window on busy CI runners. - await session.expectChatResponseVisible("simple mock response", 1, { timeout: 90_000 }); }); }); diff --git a/apps/web/hooks/domains/session/use-session-git.ts b/apps/web/hooks/domains/session/use-session-git.ts index 02e4b6ed24..8cfd752ea9 100644 --- a/apps/web/hooks/domains/session/use-session-git.ts +++ b/apps/web/hooks/domains/session/use-session-git.ts @@ -122,7 +122,7 @@ export type SessionGit = { unstageAll: () => Promise; discard: (paths?: string[], repo?: string) => Promise; revertCommit: (commitSHA: string, repo?: string) => Promise; - renameBranch: (newName: string) => Promise; + renameBranch: (newName: string, repo?: string) => Promise; reset: (commitSHA: string, mode: "soft" | "hard", repo?: string) => Promise; createPR: ( title: string, diff --git a/apps/web/hooks/use-git-operations.ts b/apps/web/hooks/use-git-operations.ts index f3817f685f..87aeb2fe29 100644 --- a/apps/web/hooks/use-git-operations.ts +++ b/apps/web/hooks/use-git-operations.ts @@ -42,7 +42,7 @@ interface UseGitOperationsReturn { unstage: (paths?: string[], repo?: string) => Promise; discard: (paths?: string[], repo?: string) => Promise; revertCommit: (commitSHA: string, repo?: string) => Promise; - renameBranch: (newName: string) => Promise; + renameBranch: (newName: string, repo?: string) => Promise; reset: (commitSHA: string, mode: "soft" | "hard", repo?: string) => Promise; createPR: ( title: string, @@ -128,8 +128,11 @@ function buildGitOperationCallbacks(executeOperation: ExecuteOperation) { ...(repo ? { repo } : {}), }); - const renameBranch = async (newName: string) => - executeOperation("worktree.rename_branch", { new_name: newName }); + const renameBranch = async (newName: string, repo?: string) => + executeOperation("worktree.rename_branch", { + new_name: newName, + ...(repo ? { repo } : {}), + }); const reset = async (commitSHA: string, mode: "soft" | "hard", repo?: string) => executeOperation("worktree.reset", { diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index ccca35c9f1..ce379a2141 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -192,6 +192,7 @@ export type Repository = { default_branch: string; scripts?: RepositoryScript[]; worktree_branch_prefix: string; + worktree_branch_template?: string; pull_before_worktree: boolean; setup_script: string; cleanup_script: string; diff --git a/apps/web/lib/worktree-branch-template.ts b/apps/web/lib/worktree-branch-template.ts new file mode 100644 index 0000000000..d0ad4d169f --- /dev/null +++ b/apps/web/lib/worktree-branch-template.ts @@ -0,0 +1 @@ +export const defaultWorktreeBranchTemplate = "feature/{title}-{suffix}"; diff --git a/docs/decisions/0032-configurable-worktree-branch-names.md b/docs/decisions/0032-configurable-worktree-branch-names.md new file mode 100644 index 0000000000..f3212a0f5e --- /dev/null +++ b/docs/decisions/0032-configurable-worktree-branch-names.md @@ -0,0 +1,44 @@ +# 0032: Configurable Worktree Branch Names + +**Status:** accepted +**Date:** 2026-07-07 +**Area:** backend, frontend + +## Context + +Kandev generated worktree branch names from a fixed format: repository branch +prefix, sanitized task title, and a random suffix. Teams need repository-specific +branch policies, such as ticket-first branch names, and users need a visible way +to rename an already-created worktree branch without leaving Kandev. + +## Decision + +Repositories get a persisted `worktree_branch_template` setting. Branch +generation uses a small backend template renderer with documented placeholders: +`{title}`, `{title_full}`, `{task_id}`, `{suffix}`, `{ticket}`, and +`{issue_key}`. Users write literal prefixes directly in the template, such as +`feature/{ticket}-{title}`; `{prefix}` is not a placeholder. `{ticket}` and +`{issue_key}` resolve to the same external reference token from task identifier +or integration metadata. + +The Changes panel exposes branch rename through the existing +`worktree.rename_branch` action. Rename is repo-scoped for multi-repo tasks, and +any persisted branch snapshots must be updated only for the renamed repository. + +## Consequences + +Existing repositories keep the same generated branch names by backfilling the +new template from the legacy `worktree_branch_prefix`. Templates may omit +`{suffix}`, but collisions are then possible and are reported from git instead +of being silently repaired. The backend has a single place to validate template +output before branch names reach git. + +Multi-repo rename requires care because the existing session-wide cached branch +update helper is not safe for a single repository rename. + +## Alternatives Considered + +Keeping only `worktree_branch_prefix` was rejected because it cannot express +ticket-first or username-first policies. Making `{suffix}` mandatory was +rejected because some teams intentionally use deterministic branch names and are +willing to handle collisions. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index d486646bf5..d95fc5c14e 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -37,3 +37,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 0029 | [Release backfill and desktop diagnostics](0029-release-backfill-and-desktop-diagnostics.md) | accepted | infra, workflow | 2026-07-01 | | 0030 | [Workspace-scoped integration settings](0030-workspace-scoped-integration-settings.md) | accepted | backend, frontend | 2026-07-01 | | 0031 | [Office skill reference files](0031-office-skill-reference-files.md) | accepted | backend | 2026-07-06 | +| 0032 | [Configurable worktree branch names](0032-configurable-worktree-branch-names.md) | accepted | backend, frontend | 2026-07-07 |