diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index 9b836b1659..69d469f4af 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -79,6 +79,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch RepositoryPath: req.RepositoryPath, BaseBranch: req.BaseBranch, WorktreeBranchPrefix: req.WorktreeBranchPrefix, + PullBeforeWorktree: req.PullBeforeWorktree, } // Create the agentctl execution (does NOT start agent process) diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 37e223d554..a36475fde9 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -468,6 +468,7 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work return execution, nil } + // Start starts the lifecycle manager background tasks func (m *Manager) Start(ctx context.Context) error { if m.runtimeRegistry == nil { @@ -982,6 +983,7 @@ func (m *Manager) getOrCreateWorktree(ctx context.Context, req *LaunchRequest) ( RepositoryPath: req.RepositoryPath, BaseBranch: req.BaseBranch, WorktreeBranchPrefix: req.WorktreeBranchPrefix, + PullBeforeWorktree: req.PullBeforeWorktree, WorktreeID: worktreeID, // If set, will try to reuse this worktree } @@ -1769,9 +1771,10 @@ func (m *Manager) UpdateStatus(executionID string, status v1.AgentStatus) error // - After cancelling an agent turn (to allow new prompts) // // State Machine Transitions: -// Starting -> Ready (after initialization) -// Running -> Ready (after prompt completion) -// Any -> Ready (after cancel) +// +// Starting -> Ready (after initialization) +// Running -> Ready (after prompt completion) +// Any -> Ready (after cancel) // // Publishes an AgentReady event to notify subscribers (frontend, orchestrator). // @@ -1812,8 +1815,9 @@ func (m *Manager) MarkReady(executionID string) error { // - errorMessage: Human-readable error description (empty string if no error) // // State Machine: -// This is a terminal state transition - no further state changes are expected after this. -// Typical flow: Starting -> Running -> Ready -> ... -> Completed/Failed +// +// This is a terminal state transition - no further state changes are expected after this. +// Typical flow: Starting -> Running -> Ready -> ... -> Completed/Failed // // Publishes either AgentCompleted or AgentFailed event depending on final status. // @@ -1855,9 +1859,9 @@ func (m *Manager) MarkCompleted(executionID string, exitCode int, errorMessage s // RemoveExecution removes an execution from tracking. // // ⚠️ WARNING: This is a potentially dangerous operation that should only be called when: -// 1. The agent process has been fully stopped (via StopAgent) -// 2. All cleanup operations have completed (worktree cleanup, container removal) -// 3. The execution is in a terminal state (Completed, Failed, or Cancelled) +// 1. The agent process has been fully stopped (via StopAgent) +// 2. All cleanup operations have completed (worktree cleanup, container removal) +// 3. The execution is in a terminal state (Completed, Failed, or Cancelled) // // This method: // - Removes the execution from the in-memory store @@ -1889,8 +1893,8 @@ func (m *Manager) RemoveExecution(executionID string) { // - When IsAgentRunningForSession returns false but execution exists // // This method performs cleanup: -// 1. Closes the agentctl HTTP client connection -// 2. Removes the execution from the in-memory tracking store +// 1. Closes the agentctl HTTP client connection +// 2. Removes the execution from the in-memory tracking store // // What this does NOT do: // - Stop the agent process (assumed already stopped) @@ -2005,7 +2009,7 @@ func (m *Manager) performCleanup(ctx context.Context) { // - pendingID: Unique ID of the permission request (from permission request event) // - optionID: The user-selected option ID (from the permission request's options array) // - cancelled: If true, indicates user cancelled/rejected the permission request. -// When cancelled=true, optionID is ignored. +// When cancelled=true, optionID is ignored. // // Response Semantics: // - cancelled=false, optionID="approve" → User approved the action diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index 2e58221203..99ee63ed00 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -28,12 +28,12 @@ type AgentExecution struct { ACPSessionID string // ACP session ID to resume, if available AgentCommand string // Command to start the agent subprocess RuntimeName string // Name of the runtime used (e.g., "docker", "standalone") - Status v1.AgentStatus - StartedAt time.Time - FinishedAt *time.Time - ExitCode *int - ErrorMessage string - Metadata map[string]interface{} + Status v1.AgentStatus + StartedAt time.Time + FinishedAt *time.Time + ExitCode *int + ErrorMessage string + Metadata map[string]interface{} // agentctl client for this execution agentctl *agentctl.Client @@ -107,6 +107,7 @@ type LaunchRequest struct { RepositoryPath string // Path to the main repository (for worktree creation) BaseBranch string // Base branch for the worktree (e.g., "main") WorktreeBranchPrefix string // Branch prefix for worktree branches + PullBeforeWorktree bool // Whether to pull from remote before creating the worktree } // CredentialsManager interface for credential retrieval diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 0313e873bb..68333bcbfa 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -95,6 +95,7 @@ type LaunchAgentRequest struct { RepositoryPath string // Path to the main repository (for worktree creation) BaseBranch string // Base branch for the worktree (e.g., "main") WorktreeBranchPrefix string // Branch prefix for worktree branches + PullBeforeWorktree bool // Whether to pull from remote before creating the worktree } // LaunchAgentResponse contains the result of launching an agent @@ -230,6 +231,7 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP var repositoryID string var baseBranch string var worktreeBranchPrefix string + var pullBeforeWorktree bool // Get the primary repository for this task primaryTaskRepo, err := e.repo.GetPrimaryTaskRepository(ctx, task.ID) @@ -254,6 +256,7 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP } repositoryPath = repository.LocalPath worktreeBranchPrefix = repository.WorktreeBranchPrefix + pullBeforeWorktree = repository.PullBeforeWorktree if repositoryPath != "" { req.RepositoryURL = repositoryPath } @@ -276,6 +279,7 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP req.BaseBranch = "main" } req.WorktreeBranchPrefix = worktreeBranchPrefix + req.PullBeforeWorktree = pullBeforeWorktree } // Resolve agent profile to get model and other settings for snapshot @@ -556,6 +560,8 @@ func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSessio repositoryID := session.RepositoryID var repositoryPath string + var worktreeBranchPrefix string + var pullBeforeWorktree bool if repositoryID == "" && len(task.Repositories) > 0 { repositoryID = task.Repositories[0].RepositoryID } @@ -569,6 +575,8 @@ func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSessio return nil, err } repositoryPath = repository.LocalPath + worktreeBranchPrefix = repository.WorktreeBranchPrefix + pullBeforeWorktree = repository.PullBeforeWorktree if repositoryPath != "" { req.RepositoryURL = repositoryPath } @@ -591,6 +599,8 @@ func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSessio } else { req.BaseBranch = "main" } + req.WorktreeBranchPrefix = worktreeBranchPrefix + req.PullBeforeWorktree = pullBeforeWorktree } if running, err := e.repo.GetExecutorRunningBySessionID(ctx, session.ID); err == nil && running != nil { diff --git a/apps/backend/internal/task/controller/repository.go b/apps/backend/internal/task/controller/repository.go index 81bbbfe065..3524cd7445 100644 --- a/apps/backend/internal/task/controller/repository.go +++ b/apps/backend/internal/task/controller/repository.go @@ -51,6 +51,7 @@ func (c *RepositoryController) CreateRepository(ctx context.Context, req dto.Cre ProviderName: req.ProviderName, DefaultBranch: req.DefaultBranch, WorktreeBranchPrefix: req.WorktreeBranchPrefix, + PullBeforeWorktree: req.PullBeforeWorktree, SetupScript: req.SetupScript, CleanupScript: req.CleanupScript, DevScript: req.DevScript, @@ -72,6 +73,7 @@ func (c *RepositoryController) UpdateRepository(ctx context.Context, req dto.Upd ProviderName: req.ProviderName, DefaultBranch: req.DefaultBranch, WorktreeBranchPrefix: req.WorktreeBranchPrefix, + PullBeforeWorktree: req.PullBeforeWorktree, SetupScript: req.SetupScript, CleanupScript: req.CleanupScript, DevScript: req.DevScript, diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 7a8a94b7af..de0f4a3526 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -52,6 +52,7 @@ type RepositoryDTO struct { 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"` @@ -305,6 +306,7 @@ func FromRepository(repository *models.Repository) RepositoryDTO { ProviderName: repository.ProviderName, DefaultBranch: repository.DefaultBranch, WorktreeBranchPrefix: repository.WorktreeBranchPrefix, + PullBeforeWorktree: repository.PullBeforeWorktree, SetupScript: repository.SetupScript, CleanupScript: repository.CleanupScript, DevScript: repository.DevScript, diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index 027d3f96d5..037a750042 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -100,6 +100,7 @@ type CreateRepositoryRequest struct { ProviderName string DefaultBranch string WorktreeBranchPrefix string + PullBeforeWorktree *bool SetupScript string CleanupScript string DevScript string @@ -116,6 +117,7 @@ type UpdateRepositoryRequest struct { ProviderName *string DefaultBranch *string WorktreeBranchPrefix *string + PullBeforeWorktree *bool SetupScript *string CleanupScript *string DevScript *string diff --git a/apps/backend/internal/task/handlers/repository_handlers.go b/apps/backend/internal/task/handlers/repository_handlers.go index b086a109de..3b1b96b8b3 100644 --- a/apps/backend/internal/task/handlers/repository_handlers.go +++ b/apps/backend/internal/task/handlers/repository_handlers.go @@ -144,6 +144,7 @@ type httpCreateRepositoryRequest struct { 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"` @@ -170,6 +171,7 @@ func (h *RepositoryHandlers) httpCreateRepository(c *gin.Context) { ProviderName: body.ProviderName, DefaultBranch: body.DefaultBranch, WorktreeBranchPrefix: body.WorktreeBranchPrefix, + PullBeforeWorktree: body.PullBeforeWorktree, SetupScript: body.SetupScript, CleanupScript: body.CleanupScript, DevScript: body.DevScript, @@ -219,6 +221,7 @@ type httpUpdateRepositoryRequest struct { 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"` @@ -241,6 +244,7 @@ func (h *RepositoryHandlers) httpUpdateRepository(c *gin.Context) { ProviderName: body.ProviderName, DefaultBranch: body.DefaultBranch, WorktreeBranchPrefix: body.WorktreeBranchPrefix, + PullBeforeWorktree: body.PullBeforeWorktree, SetupScript: body.SetupScript, CleanupScript: body.CleanupScript, DevScript: body.DevScript, diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 5f37afce18..3a32f7ae9a 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -283,6 +283,7 @@ type Repository struct { 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"` diff --git a/apps/backend/internal/task/repository/sqlite.go b/apps/backend/internal/task/repository/sqlite.go index dcb594b877..d392f3c28b 100644 --- a/apps/backend/internal/task/repository/sqlite.go +++ b/apps/backend/internal/task/repository/sqlite.go @@ -169,6 +169,7 @@ func (r *sqliteRepository) initSchema() error { provider_name TEXT DEFAULT '', default_branch TEXT DEFAULT '', worktree_branch_prefix TEXT DEFAULT 'feature/', + pull_before_worktree INTEGER NOT NULL DEFAULT 1, setup_script TEXT DEFAULT '', cleanup_script TEXT DEFAULT '', dev_script TEXT DEFAULT '', @@ -315,6 +316,9 @@ func (r *sqliteRepository) initSchema() error { if err := r.ensureColumn("repositories", "worktree_branch_prefix", "TEXT DEFAULT 'feature/'"); err != nil { return err } + if err := r.ensureColumn("repositories", "pull_before_worktree", "INTEGER NOT NULL DEFAULT 1"); err != nil { + return err + } if err := r.ensureColumn("repositories", "dev_script", "TEXT DEFAULT ''"); err != nil { return err } @@ -1602,7 +1606,6 @@ func (r *sqliteRepository) UpdateMessage(ctx context.Context, message *models.Me return nil } - // Repository operations // CreateRepository creates a new repository @@ -1617,11 +1620,11 @@ func (r *sqliteRepository) CreateRepository(ctx context.Context, repository *mod _, err := r.db.ExecContext(ctx, ` INSERT INTO repositories ( id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, 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, - repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt) + boolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt) return err } @@ -1632,12 +1635,12 @@ func (r *sqliteRepository) GetRepository(ctx context.Context, id string) (*model err := r.db.QueryRowContext(ctx, ` SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at + provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, 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.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, + &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, ) if err == sql.ErrNoRows { @@ -1653,11 +1656,11 @@ func (r *sqliteRepository) UpdateRepository(ctx context.Context, repository *mod result, err := r.db.ExecContext(ctx, ` UPDATE repositories SET name = ?, source_type = ?, local_path = ?, provider = ?, provider_repo_id = ?, provider_owner = ?, - provider_name = ?, default_branch = ?, worktree_branch_prefix = ?, setup_script = ?, cleanup_script = ?, dev_script = ?, updated_at = ? + provider_name = ?, default_branch = ?, worktree_branch_prefix = ?, pull_before_worktree = ?, setup_script = ?, cleanup_script = ?, dev_script = ?, 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, repository.SetupScript, - repository.CleanupScript, repository.DevScript, repository.UpdatedAt, repository.ID) + repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, boolToInt(repository.PullBeforeWorktree), + repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.UpdatedAt, repository.ID) if err != nil { return err } @@ -1689,7 +1692,7 @@ func (r *sqliteRepository) DeleteRepository(ctx context.Context, id string) erro func (r *sqliteRepository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) { rows, err := r.db.QueryContext(ctx, ` SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at + provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, 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 { @@ -1703,7 +1706,7 @@ func (r *sqliteRepository) ListRepositories(ctx context.Context, workspaceID str 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.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, + &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, ) if err != nil { return nil, err @@ -1797,7 +1800,6 @@ func (r *sqliteRepository) ListRepositoryScripts(ctx context.Context, repository return result, rows.Err() } - // Executor operations func (r *sqliteRepository) CreateExecutor(ctx context.Context, executor *models.Executor) error { diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index 3b15a3d65b..ad5b500d88 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -17,8 +17,6 @@ func NewWithDB(dbConn *sql.DB) (*Repository, error) { return newRepository(dbConn, false) } - - func newRepository(dbConn *sql.DB, ownsDB bool) (*Repository, error) { repo := &Repository{db: dbConn, ownsDB: ownsDB} if err := repo.initSchema(); err != nil { @@ -101,8 +99,6 @@ func (r *Repository) ensureWorkspaceIndexes() error { return nil } - - // initSchema creates the database tables if they don't exist func (r *Repository) initSchema() error { schema := ` @@ -232,6 +228,7 @@ func (r *Repository) initSchema() error { provider_name TEXT DEFAULT '', default_branch TEXT DEFAULT '', worktree_branch_prefix TEXT DEFAULT 'feature/', + pull_before_worktree INTEGER NOT NULL DEFAULT 1, setup_script TEXT DEFAULT '', cleanup_script TEXT DEFAULT '', dev_script TEXT DEFAULT '', @@ -433,6 +430,9 @@ func (r *Repository) initSchema() error { if err := r.ensureColumn("repositories", "worktree_branch_prefix", "TEXT DEFAULT 'feature/'"); err != nil { return err } + if err := r.ensureColumn("repositories", "pull_before_worktree", "INTEGER NOT NULL DEFAULT 1"); err != nil { + return err + } if err := r.ensureColumn("repositories", "dev_script", "TEXT DEFAULT ''"); err != nil { return err } diff --git a/apps/backend/internal/task/repository/sqlite/repository_entity.go b/apps/backend/internal/task/repository/sqlite/repository_entity.go index 8befbbaa7c..9ee3937bf6 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_entity.go +++ b/apps/backend/internal/task/repository/sqlite/repository_entity.go @@ -23,11 +23,11 @@ func (r *Repository) CreateRepository(ctx context.Context, repository *models.Re _, err := r.db.ExecContext(ctx, ` INSERT INTO repositories ( id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, 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, - repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt) + boolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt) return err } @@ -38,12 +38,12 @@ func (r *Repository) GetRepository(ctx context.Context, id string) (*models.Repo err := r.db.QueryRowContext(ctx, ` SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at + provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, 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.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, + &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, ) if err == sql.ErrNoRows { @@ -59,11 +59,11 @@ func (r *Repository) UpdateRepository(ctx context.Context, repository *models.Re result, err := r.db.ExecContext(ctx, ` UPDATE repositories SET name = ?, source_type = ?, local_path = ?, provider = ?, provider_repo_id = ?, provider_owner = ?, - provider_name = ?, default_branch = ?, worktree_branch_prefix = ?, setup_script = ?, cleanup_script = ?, dev_script = ?, updated_at = ? + provider_name = ?, default_branch = ?, worktree_branch_prefix = ?, pull_before_worktree = ?, setup_script = ?, cleanup_script = ?, dev_script = ?, 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, repository.SetupScript, - repository.CleanupScript, repository.DevScript, repository.UpdatedAt, repository.ID) + repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, boolToInt(repository.PullBeforeWorktree), + repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.UpdatedAt, repository.ID) if err != nil { return err } @@ -95,7 +95,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.db.QueryContext(ctx, ` SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at + provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, 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 { @@ -109,7 +109,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.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, + &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, ) if err != nil { return nil, err @@ -136,8 +136,6 @@ func (r *Repository) CreateRepositoryScript(ctx context.Context, script *models. return err } - - // GetRepositoryScript retrieves a repository script by ID func (r *Repository) GetRepositoryScript(ctx context.Context, id string) (*models.RepositoryScript, error) { script := &models.RepositoryScript{} diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 1fa492d906..c338759eb9 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -199,6 +199,7 @@ type CreateRepositoryRequest struct { 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"` @@ -215,6 +216,7 @@ type UpdateRepositoryRequest struct { 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"` @@ -1032,6 +1034,10 @@ func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryReq if prefix == "" { prefix = worktree.DefaultBranchPrefix } + pullBeforeWorktree := true + if req.PullBeforeWorktree != nil { + pullBeforeWorktree = *req.PullBeforeWorktree + } repository := &models.Repository{ ID: uuid.New().String(), WorkspaceID: req.WorkspaceID, @@ -1044,6 +1050,7 @@ func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryReq ProviderName: req.ProviderName, DefaultBranch: req.DefaultBranch, WorktreeBranchPrefix: prefix, + PullBeforeWorktree: pullBeforeWorktree, SetupScript: req.SetupScript, CleanupScript: req.CleanupScript, DevScript: req.DevScript, @@ -1099,6 +1106,9 @@ func (s *Service) UpdateRepository(ctx context.Context, id string, req *UpdateRe } repository.WorktreeBranchPrefix = prefix } + if req.PullBeforeWorktree != nil { + repository.PullBeforeWorktree = *req.PullBeforeWorktree + } if req.SetupScript != nil { repository.SetupScript = *req.SetupScript } @@ -2108,6 +2118,7 @@ func (s *Service) publishRepositoryEvent(ctx context.Context, eventType string, "provider_name": repository.ProviderName, "default_branch": repository.DefaultBranch, "worktree_branch_prefix": repository.WorktreeBranchPrefix, + "pull_before_worktree": repository.PullBeforeWorktree, "setup_script": repository.SetupScript, "cleanup_script": repository.CleanupScript, "created_at": repository.CreatedAt.Format(time.RFC3339), diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 8150c2cb89..bc7f9fee84 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -7,12 +7,12 @@ import ( "testing" "time" - "github.com/kandev/kandev/internal/worktree" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" + "github.com/kandev/kandev/internal/worktree" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -170,6 +170,9 @@ func TestService_CreateRepository_DefaultWorktreeBranchPrefix(t *testing.T) { if created.WorktreeBranchPrefix != worktree.DefaultBranchPrefix { t.Fatalf("expected default prefix %q, got %q", worktree.DefaultBranchPrefix, created.WorktreeBranchPrefix) } + if !created.PullBeforeWorktree { + t.Fatalf("expected pull_before_worktree to default to true") + } stored, err := repo.GetRepository(ctx, created.ID) if err != nil { @@ -178,6 +181,37 @@ func TestService_CreateRepository_DefaultWorktreeBranchPrefix(t *testing.T) { if stored.WorktreeBranchPrefix != worktree.DefaultBranchPrefix { t.Fatalf("expected stored prefix %q, got %q", worktree.DefaultBranchPrefix, stored.WorktreeBranchPrefix) } + if !stored.PullBeforeWorktree { + t.Fatalf("expected stored pull_before_worktree to default to true") + } +} + +func TestService_CreateRepository_PullBeforeWorktreeFalse(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + + pullFalse := false + created, err := svc.CreateRepository(ctx, &CreateRepositoryRequest{ + WorkspaceID: "ws-1", + Name: "Test Repo", + PullBeforeWorktree: &pullFalse, + }) + if err != nil { + t.Fatalf("CreateRepository failed: %v", err) + } + if created.PullBeforeWorktree { + t.Fatalf("expected pull_before_worktree to be false when explicitly set") + } + + stored, err := repo.GetRepository(ctx, created.ID) + if err != nil { + t.Fatalf("GetRepository failed: %v", err) + } + if stored.PullBeforeWorktree { + t.Fatalf("expected stored pull_before_worktree to be false") + } } func TestService_GetTask(t *testing.T) { diff --git a/apps/backend/internal/worktree/manager.go b/apps/backend/internal/worktree/manager.go index d1d2ace085..662cbebc78 100644 --- a/apps/backend/internal/worktree/manager.go +++ b/apps/backend/internal/worktree/manager.go @@ -175,11 +175,6 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*Worktree, err return nil, ErrRepoNotGit } - // Check base branch exists - if !m.branchExists(req.RepositoryPath, req.BaseBranch) { - return nil, fmt.Errorf("%w: %s", ErrInvalidBaseBranch, req.BaseBranch) - } - // Check worktree limit count, err := m.countWorktreesForRepo(ctx, req.RepositoryID) if err != nil { @@ -194,11 +189,21 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*Worktree, err repoLock.Lock() defer repoLock.Unlock() - return m.createWorktree(ctx, req) + baseRef := req.BaseBranch + if req.PullBeforeWorktree { + baseRef = m.pullBaseBranch(req.RepositoryPath, req.BaseBranch) + } + + // Check base branch exists + if !m.branchExists(req.RepositoryPath, baseRef) { + return nil, fmt.Errorf("%w: %s", ErrInvalidBaseBranch, baseRef) + } + + return m.createWorktree(ctx, req, baseRef) } // createWorktree performs the actual git worktree creation. -func (m *Manager) createWorktree(ctx context.Context, req CreateRequest) (*Worktree, error) { +func (m *Manager) createWorktree(ctx context.Context, req CreateRequest, baseRef string) (*Worktree, error) { // Generate a unique worktree ID and random suffix worktreeID := uuid.New().String() dirSuffix := worktreeID[:8] // Use first 8 chars of UUID for worktree dir uniqueness @@ -233,7 +238,7 @@ func (m *Manager) createWorktree(ctx context.Context, req CreateRequest) (*Workt cmd := exec.CommandContext(ctx, "git", "worktree", "add", "-b", branchName, worktreePath, - req.BaseBranch) + baseRef) cmd.Dir = req.RepositoryPath output, err := cmd.CombinedOutput() @@ -640,6 +645,77 @@ func (m *Manager) branchExists(repoPath, branch string) bool { return err == nil } +func (m *Manager) currentBranch(repoPath string) string { + cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") + cmd.Dir = repoPath + output, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +} + +// pullBaseBranch fetches the latest changes from origin and returns the best ref to use +// for creating a new worktree. The function handles three scenarios: +// +// 1. baseBranch is already a remote ref (e.g., "origin/main"): fetch and use it directly +// 2. baseBranch is a local branch and we're currently on it: pull --ff-only to update +// 3. baseBranch is a local branch but we're on a different branch: use origin/ instead +// +// On fetch/pull failure, errors are logged but the function continues with the best available ref. +func (m *Manager) pullBaseBranch(repoPath, baseBranch string) string { + // Normalize branch name - strip "origin/" prefix if present + localBranch := strings.TrimPrefix(baseBranch, "origin/") + isRemoteRef := localBranch != baseBranch + + // Fetch the branch from origin (30s timeout for fetch + potential pull) + fetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + fetchArgs := []string{"fetch", "origin"} + if localBranch != "" { + fetchArgs = append(fetchArgs, localBranch) + } + fetchCmd := exec.CommandContext(fetchCtx, "git", fetchArgs...) + fetchCmd.Dir = repoPath + if output, err := fetchCmd.CombinedOutput(); err != nil { + m.logger.Error("git fetch failed before worktree creation", + zap.String("branch", baseBranch), + zap.String("output", string(output)), + zap.Error(err)) + return baseBranch + } + + // If the original ref was already a remote ref, use it directly + if isRemoteRef { + return "origin/" + localBranch + } + + // For local branches: try to update if we're on that branch, otherwise use origin/ + remoteRef := "origin/" + localBranch + currentBranch := m.currentBranch(repoPath) + + if currentBranch == baseBranch { + // We're on the target branch - try to pull (fast-forward only) + pullCmd := exec.CommandContext(fetchCtx, "git", "pull", "--ff-only", "origin", baseBranch) + pullCmd.Dir = repoPath + if output, err := pullCmd.CombinedOutput(); err != nil { + m.logger.Error("git pull failed before worktree creation", + zap.String("branch", baseBranch), + zap.String("output", string(output)), + zap.Error(err)) + } + return baseBranch + } + + // Not on the target branch - use the remote ref if it exists + if m.branchExists(repoPath, remoteRef) { + return remoteRef + } + + return baseBranch +} + // countWorktreesForRepo counts active worktrees for a repository. func (m *Manager) countWorktreesForRepo(ctx context.Context, repoID string) (int, error) { if m.store == nil { diff --git a/apps/backend/internal/worktree/worktree.go b/apps/backend/internal/worktree/worktree.go index 047c8e4061..3740fbbb92 100644 --- a/apps/backend/internal/worktree/worktree.go +++ b/apps/backend/internal/worktree/worktree.go @@ -74,6 +74,9 @@ type CreateRequest struct { // If empty, the default prefix is used. WorktreeBranchPrefix string + // PullBeforeWorktree indicates whether to pull from remote before creating the worktree. + PullBeforeWorktree bool + // WorktreeID is the ID of an existing worktree to reuse (optional). // If provided and valid, the existing worktree is returned instead of creating a new one. WorktreeID string diff --git a/apps/packages/types/src/http.ts b/apps/packages/types/src/http.ts index f482bdc049..57151adfae 100644 --- a/apps/packages/types/src/http.ts +++ b/apps/packages/types/src/http.ts @@ -51,6 +51,7 @@ export type Repository = { provider_name: string; default_branch: string; worktree_branch_prefix: string; + pull_before_worktree: boolean; setup_script: string; cleanup_script: string; created_at: string; diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index 36b102f789..15d44678d8 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -184,6 +184,7 @@ export async function createRepositoryAction(payload: { provider_name: string; default_branch: string; worktree_branch_prefix: string; + pull_before_worktree: boolean; setup_script: string; cleanup_script: string; dev_script: string; @@ -200,6 +201,7 @@ export async function createRepositoryAction(payload: { provider_name: payload.provider_name, default_branch: payload.default_branch, worktree_branch_prefix: payload.worktree_branch_prefix, + pull_before_worktree: payload.pull_before_worktree, setup_script: payload.setup_script, cleanup_script: payload.cleanup_script, dev_script: payload.dev_script, diff --git a/apps/web/app/settings/workspace/workspace-repositories-client.tsx b/apps/web/app/settings/workspace/workspace-repositories-client.tsx index b0dc79349e..e05d47a670 100644 --- a/apps/web/app/settings/workspace/workspace-repositories-client.tsx +++ b/apps/web/app/settings/workspace/workspace-repositories-client.tsx @@ -97,6 +97,7 @@ export function WorkspaceRepositoriesClient({ 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 @@ -199,6 +200,7 @@ export function WorkspaceRepositoriesClient({ provider_name: '', default_branch: selectedRepo?.default_branch || '', worktree_branch_prefix: 'feature/', + pull_before_worktree: true, setup_script: '', cleanup_script: '', dev_script: '', @@ -272,6 +274,7 @@ export function WorkspaceRepositoriesClient({ provider_name: repo.provider_name, default_branch: repo.default_branch, worktree_branch_prefix: repo.worktree_branch_prefix, + pull_before_worktree: repo.pull_before_worktree, setup_script: repo.setup_script, cleanup_script: repo.cleanup_script, dev_script: repo.dev_script, @@ -302,6 +305,7 @@ export function WorkspaceRepositoriesClient({ provider_name: repo.provider_name, default_branch: repo.default_branch, worktree_branch_prefix: repo.worktree_branch_prefix, + pull_before_worktree: repo.pull_before_worktree, setup_script: repo.setup_script, cleanup_script: repo.cleanup_script, dev_script: repo.dev_script, diff --git a/apps/web/components/settings/repository-card.tsx b/apps/web/components/settings/repository-card.tsx index de7f5fc71a..f15c75860b 100644 --- a/apps/web/components/settings/repository-card.tsx +++ b/apps/web/components/settings/repository-card.tsx @@ -5,6 +5,7 @@ import { IconEdit, IconGitBranch, IconPlus, IconTrash, IconX } from '@tabler/ico import { Badge } from '@kandev/ui/badge'; import { Card, CardContent } from '@kandev/ui/card'; import { Button } from '@kandev/ui/button'; +import { Checkbox } from '@kandev/ui/checkbox'; import { Input } from '@kandev/ui/input'; import { Label } from '@kandev/ui/label'; import { Textarea } from '@kandev/ui/textarea'; @@ -57,6 +58,7 @@ export function RepositoryCard({ const repositoryName = repository.name ?? ''; const repositoryLocalPath = repository.local_path ?? ''; const worktreeBranchPrefix = repository.worktree_branch_prefix ?? ''; + const pullBeforeWorktree = repository.pull_before_worktree ?? true; const setupScript = repository.setup_script ?? ''; const cleanupScript = repository.cleanup_script ?? ''; const devScript = repository.dev_script ?? ''; @@ -167,6 +169,29 @@ export function RepositoryCard({ Branches are generated as {'{prefix}{sanitized-title}-{rand}'}.

+
+ +
+ + onUpdate(repository.id, { pull_before_worktree: checked === true }) + } + /> +
+ +

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

+
+
+
diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 082d8b7bba..96cbe87c6f 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -63,6 +63,7 @@ export type Repository = { provider_name: string; default_branch: string; worktree_branch_prefix: string; + pull_before_worktree: boolean; setup_script: string; cleanup_script: string; dev_script: string;