feat: add configurable branch names (#1615)
This commit is contained in:
@@ -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,
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user