feat: add configurable branch names (#1615)

This commit is contained in:
Carlos Florêncio
2026-07-09 19:59:29 +01:00
committed by GitHub
parent 7e2a460f59
commit ff2647bf13
50 changed files with 1460 additions and 527 deletions
@@ -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)
}
@@ -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/<N>/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/<N>/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,
}}
}
@@ -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)
}
@@ -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)
@@ -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
@@ -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/<N>/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/<N>/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/<N>/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/<N>/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,
}}
}
+30 -26
View File
@@ -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
@@ -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
}
@@ -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{
@@ -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)
@@ -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",
@@ -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/<N>/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/<N>/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/<N>/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/<N>/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
@@ -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
}
@@ -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
@@ -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
+39 -37
View File
@@ -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,
}
}
+32 -30
View File
@@ -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 {
@@ -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) {
+15 -14
View File
@@ -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
@@ -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
@@ -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
@@ -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 '',
@@ -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
@@ -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
@@ -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) {
@@ -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
@@ -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
}
@@ -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()
@@ -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)
}
})
}
}
+171 -6
View File
@@ -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.
@@ -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
}
@@ -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",
@@ -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
+2
View File
@@ -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,
@@ -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,
@@ -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> = {}): RepositoryWithScripts {
return {
@@ -18,6 +19,7 @@ function makeRepo(overrides: Partial<RepositoryWithScripts> = {}): 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);
@@ -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<keyof RepositoryWithScripts> = [
"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)
);
}
@@ -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: "",
@@ -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 (
<HoverCard openDelay={150} closeDelay={100}>
<HoverCardTrigger asChild>
<button
type="button"
aria-label="Branch template placeholders"
className="cursor-help text-muted-foreground hover:text-foreground"
>
<IconInfoCircle className="h-3.5 w-3.5" />
</button>
</HoverCardTrigger>
<HoverCardContent align="start" className="w-96 text-xs">
<div className="space-y-2">
<p className="text-muted-foreground">
Write literal prefixes directly, for example{" "}
<code className="rounded bg-muted px-1 py-0.5">
feature/{"{ticket}"}-{"{title}"}
</code>
.
</p>
<dl className="space-y-1.5">
{branchTemplatePlaceholders.map(([name, description]) => (
<div key={name} className="grid grid-cols-[5.5rem_1fr] gap-2">
<dt className="font-mono text-foreground">{name}</dt>
<dd className="text-muted-foreground">{description}</dd>
</div>
))}
</dl>
</div>
</HoverCardContent>
</HoverCard>
);
}
@@ -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({
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Worktree Branch Prefix</Label>
<div className="flex items-center gap-1.5">
<Label>Worktree Branch Template</Label>
<RepositoryBranchTemplateHelp />
</div>
<Input
value={worktreeBranchPrefix}
onChange={(e) => onUpdate(repositoryId, { worktree_branch_prefix: e.target.value })}
placeholder="feature/"
value={worktreeBranchTemplate}
onChange={(e) => onUpdate(repositoryId, { worktree_branch_template: e.target.value })}
placeholder={defaultWorktreeBranchTemplate}
/>
<p className="text-xs text-muted-foreground">
Used for new worktree branches. Leave empty to use the default. Branches are generated
as {"{prefix}{sanitized-title}-{rand}"}.
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`repo-pull-before-${repositoryId}`}>Worktree Sync</Label>
@@ -94,9 +94,6 @@ function RepositoryBasicFields({
>
Always pull before creating a new worktree
</Label>
<p className="text-xs text-muted-foreground">
Keeps the base branch up to date with the remote before spawning new sessions.
</p>
</div>
</div>
</div>
@@ -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({
<Badge variant="secondary" className="text-xs">
{sourceLabel}
</Badge>
{worktreeBranchPrefix.trim() && worktreeBranchPrefix.trim() !== "feature/" ? (
<Badge variant="outline" className="text-xs">
{worktreeBranchPrefix.trim()}
</Badge>
) : null}
{isDirty && <UnsavedChangesBadge />}
</div>
<div className="text-xs text-muted-foreground mt-1 truncate">{subtitle}</div>
@@ -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<RenameBranchResult>;
isRenaming: boolean;
}) {
const [open, setOpen] = useState(false);
const [newBranchName, setNewBranchName] = useState(branch);
const [error, setError] = useState<string | null>(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 (
<>
<Button
type="button"
size="icon"
variant="ghost"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
disabled={!onRenameBranch || isRenaming}
aria-label={`Edit branch ${branch}`}
onClick={openDialog}
>
<IconEdit className="h-3.5 w-3.5" />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit branch</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor={`branch-name-${repositoryName || "default"}`}>Branch name</Label>
<Input
id={`branch-name-${repositoryName || "default"}`}
value={newBranchName}
onChange={(event) => setNewBranchName(event.target.value)}
autoFocus
/>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
type="button"
data-dialog-default-action
disabled={!canRename || isRenaming}
onClick={submitRename}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function BranchRowView({
repoLabel,
branch,
baseBranch,
repositoryName,
taskId,
}: BranchRow & { taskId: string | null }) {
onRenameBranch,
isRenaming,
}: BranchRow & {
taskId: string | null;
onRenameBranch?: (newName: string, repo: string) => Promise<RenameBranchResult>;
isRenaming: boolean;
}) {
return (
<div className="flex items-center gap-2">
{repoLabel && (
@@ -78,12 +176,20 @@ function BranchRowView({
{repoLabel}
</span>
)}
<span className="flex items-center gap-1.5 text-foreground font-medium">
<IconGitBranch className="h-3.5 w-3.5 text-muted-foreground" />
{branch}
<span className="flex min-w-0 items-center gap-1.5 text-foreground font-medium">
<IconGitBranch className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{branch}</span>
</span>
<div className="flex-1 border-t border-muted-foreground/20 min-w-8" />
<IconArrowRight className="h-3 w-3 text-muted-foreground/40" />
<RenameBranchButton
branch={branch}
repositoryName={repositoryName}
onRenameBranch={onRenameBranch}
isRenaming={isRenaming}
/>
<div className="flex min-w-8 flex-1 items-center text-muted-foreground/40">
<div className="h-px flex-1 bg-muted-foreground/20" />
<IconArrowRight className="-ml-px h-3 w-3 shrink-0" />
</div>
<BaseBranchPicker
taskId={taskId}
repositoryName={repositoryName}
@@ -98,6 +204,8 @@ function BranchHoverCard({
baseBranchDisplay,
rows,
taskId,
onRenameBranch,
isRenaming,
}: {
displayBranch: string;
baseBranchDisplay: string;
@@ -106,6 +214,8 @@ function BranchHoverCard({
rows?: BranchRow[];
/** Active task id, plumbed into BaseBranchPicker for the PATCH call. */
taskId: string | null;
onRenameBranch?: (newName: string, repo: string) => Promise<RenameBranchResult>;
isRenaming: boolean;
}) {
const isMulti = rows && rows.length > 0;
const headerLabel = isMulti ? "Your branches:" : "Your code lives in:";
@@ -120,7 +230,7 @@ function BranchHoverCard({
<IconGitBranch className="h-3.5 w-3.5" />
</button>
</HoverCardTrigger>
<HoverCardContent side="bottom" align="end" className="w-auto p-3">
<HoverCardContent forceMount side="bottom" align="end" className="w-auto p-3">
<div className="flex flex-col gap-2.5 text-xs">
<div className="flex items-center justify-between gap-6">
<span className="text-muted-foreground/60">{headerLabel}</span>
@@ -129,7 +239,13 @@ function BranchHoverCard({
{isMulti ? (
<div className="flex flex-col gap-1.5">
{rows!.map((row) => (
<BranchRowView key={row.repoLabel ?? row.branch} {...row} taskId={taskId} />
<BranchRowView
key={row.repoLabel ?? row.branch}
{...row}
taskId={taskId}
onRenameBranch={onRenameBranch}
isRenaming={isRenaming}
/>
))}
</div>
) : (
@@ -139,6 +255,8 @@ function BranchHoverCard({
baseBranch={baseBranchDisplay}
repositoryName=""
taskId={taskId}
onRenameBranch={onRenameBranch}
isRenaming={isRenaming}
/>
)}
</div>
@@ -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<RenameBranchResult>;
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"}
/>
)}
<PullDropdown
@@ -42,6 +42,7 @@ const ChangesPanel = memo(function ChangesPanel(props: ChangesPanelProps) {
onRepoPull={data.repoCallbacks.onRepoPull}
onRepoRebase={data.repoCallbacks.onRepoRebase}
onRepoMerge={data.repoCallbacks.onRepoMerge}
onRenameBranch={data.git.renameBranch}
repoDisplayName={data.repoDisplayName}
taskId={data.activeTaskId}
/>
@@ -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}
/>
@@ -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 });
});
});
@@ -122,7 +122,7 @@ export type SessionGit = {
unstageAll: () => Promise<GitOperationResult>;
discard: (paths?: string[], repo?: string) => Promise<GitOperationResult>;
revertCommit: (commitSHA: string, repo?: string) => Promise<GitOperationResult>;
renameBranch: (newName: string) => Promise<GitOperationResult>;
renameBranch: (newName: string, repo?: string) => Promise<GitOperationResult>;
reset: (commitSHA: string, mode: "soft" | "hard", repo?: string) => Promise<GitOperationResult>;
createPR: (
title: string,
+6 -3
View File
@@ -42,7 +42,7 @@ interface UseGitOperationsReturn {
unstage: (paths?: string[], repo?: string) => Promise<GitOperationResult>;
discard: (paths?: string[], repo?: string) => Promise<GitOperationResult>;
revertCommit: (commitSHA: string, repo?: string) => Promise<GitOperationResult>;
renameBranch: (newName: string) => Promise<GitOperationResult>;
renameBranch: (newName: string, repo?: string) => Promise<GitOperationResult>;
reset: (commitSHA: string, mode: "soft" | "hard", repo?: string) => Promise<GitOperationResult>;
createPR: (
title: string,
@@ -128,8 +128,11 @@ function buildGitOperationCallbacks(executeOperation: ExecuteOperation) {
...(repo ? { repo } : {}),
});
const renameBranch = async (newName: string) =>
executeOperation<GitOperationResult>("worktree.rename_branch", { new_name: newName });
const renameBranch = async (newName: string, repo?: string) =>
executeOperation<GitOperationResult>("worktree.rename_branch", {
new_name: newName,
...(repo ? { repo } : {}),
});
const reset = async (commitSHA: string, mode: "soft" | "hard", repo?: string) =>
executeOperation<GitOperationResult>("worktree.reset", {
+1
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
export const defaultWorktreeBranchTemplate = "feature/{title}-{suffix}";
@@ -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.
+1
View File
@@ -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 |