From 47a02fd18e486c8379d2f22a2d6ad43dda5bbe0e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?=
Date: Mon, 26 Jan 2026 19:09:55 +0000
Subject: [PATCH] feat: git pull before worktree creation (#100)
---
apps/backend/cmd/kandev/adapters.go | 1 +
.../internal/agent/lifecycle/manager.go | 26 +++---
.../backend/internal/agent/lifecycle/types.go | 13 +--
.../orchestrator/executor/executor.go | 10 ++
.../internal/task/controller/repository.go | 2 +
apps/backend/internal/task/dto/dto.go | 2 +
apps/backend/internal/task/dto/requests.go | 2 +
.../task/handlers/repository_handlers.go | 4 +
apps/backend/internal/task/models/models.go | 1 +
.../internal/task/repository/sqlite.go | 26 +++---
.../internal/task/repository/sqlite/base.go | 8 +-
.../repository/sqlite/repository_entity.go | 22 ++---
apps/backend/internal/task/service/service.go | 11 +++
.../internal/task/service/service_test.go | 36 +++++++-
apps/backend/internal/worktree/manager.go | 92 +++++++++++++++++--
apps/backend/internal/worktree/worktree.go | 3 +
apps/packages/types/src/http.ts | 1 +
apps/web/app/actions/workspaces.ts | 2 +
.../workspace-repositories-client.tsx | 4 +
.../components/settings/repository-card.tsx | 25 +++++
apps/web/lib/types/http.ts | 1 +
21 files changed, 238 insertions(+), 54 deletions(-)
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;