e86b6655a3
* fix(worktree): handle submodules in worktree creation Repos with git submodules failed during worktree creation when git-crypt was also present. The `git checkout HEAD -- .` command in unlockGitCryptAndCheckout() tried to process submodule gitlink entries, but git resolved their git dirs relative to the worktree-specific git dir instead of the common dir, causing "not a git repository" errors. - Detect submodule paths via `git ls-tree -r HEAD` (mode 160000) - Exclude submodule paths from git-crypt checkout using :(exclude) pathspecs - Initialize submodules after worktree creation via `git submodule update --init` (non-fatal — unreachable URLs are logged as warnings) - Add submodule init to all worktree creation paths (git-crypt and non-git-crypt) * fix(worktree): address review — recursive submodule init, consistent call sites - Add --recursive to git submodule update --init for nested submodules - Call initSubmodules unconditionally in git-crypt path (matching manager.go) * fix(worktree): restore submodule index entries after git-crypt checkout The :(exclude) pathspec used to skip submodules during git checkout also prevents their gitlink entries from being written to the index. This leaves git status showing them as staged deletions. After checkout, run git reset HEAD for each excluded submodule path to copy the gitlink entry from HEAD back into the index. * test(worktree): add git status assertion to submodule unlock test Validate that the index reset after :(exclude) checkout leaves no staged deletions for submodule paths. * fix(worktree): use literal pathspec for submodule exclusion Use :(literal,exclude) instead of :(exclude) to prevent glob metacharacters in submodule paths from being interpreted as patterns. * fix(worktree): fix initSubmodules docstring to match --recursive flag
1537 lines
51 KiB
Go
1537 lines
51 KiB
Go
package worktree
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
)
|
|
|
|
const (
|
|
defaultGitFetchTimeout = 90 * time.Second
|
|
defaultGitPullTimeout = 60 * time.Second
|
|
gitNoTags = "--no-tags"
|
|
)
|
|
|
|
// repoLockEntry tracks a repository lock and its reference count.
|
|
type repoLockEntry struct {
|
|
mu *sync.Mutex
|
|
refCount int
|
|
}
|
|
|
|
// Manager handles Git worktree operations for concurrent agent execution.
|
|
type Manager struct {
|
|
config Config
|
|
logger *logger.Logger
|
|
store Store
|
|
worktrees map[string]*Worktree // sessionID -> worktree (in-memory cache)
|
|
mu sync.RWMutex // Protects worktrees map
|
|
repoLocks map[string]*repoLockEntry
|
|
repoLockMu sync.Mutex
|
|
|
|
// Optional dependencies for script execution
|
|
repoProvider RepositoryProvider
|
|
scriptMsgHandler ScriptMessageHandler
|
|
|
|
// Timeouts for best-effort remote sync before creating a worktree.
|
|
fetchTimeout time.Duration
|
|
pullTimeout time.Duration
|
|
}
|
|
|
|
// ScriptMessageHandler provides script execution and message streaming.
|
|
type ScriptMessageHandler interface {
|
|
ExecuteSetupScript(ctx context.Context, req ScriptExecutionRequest) error
|
|
ExecuteCleanupScript(ctx context.Context, req ScriptExecutionRequest) error
|
|
}
|
|
|
|
// Store is the interface for worktree persistence.
|
|
type Store interface {
|
|
// CreateWorktree persists a new worktree record.
|
|
CreateWorktree(ctx context.Context, wt *Worktree) error
|
|
// GetWorktreeByID retrieves a worktree by its unique ID.
|
|
GetWorktreeByID(ctx context.Context, id string) (*Worktree, error)
|
|
// GetWorktreeBySessionID retrieves the worktree by session ID.
|
|
GetWorktreeBySessionID(ctx context.Context, sessionID string) (*Worktree, error)
|
|
// GetWorktreesByTaskID retrieves all worktrees for a task (used for cleanup on task deletion).
|
|
GetWorktreesByTaskID(ctx context.Context, taskID string) ([]*Worktree, error)
|
|
// GetWorktreesByRepositoryID retrieves all worktrees for a repository.
|
|
GetWorktreesByRepositoryID(ctx context.Context, repoID string) ([]*Worktree, error)
|
|
// UpdateWorktree updates an existing worktree record.
|
|
UpdateWorktree(ctx context.Context, wt *Worktree) error
|
|
// DeleteWorktree removes a worktree record.
|
|
DeleteWorktree(ctx context.Context, id string) error
|
|
// ListActiveWorktrees returns all active worktrees.
|
|
ListActiveWorktrees(ctx context.Context) ([]*Worktree, error)
|
|
}
|
|
|
|
// NewManager creates a new worktree manager.
|
|
func NewManager(cfg Config, store Store, log *logger.Logger) (*Manager, error) {
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, fmt.Errorf("invalid config: %w", err)
|
|
}
|
|
|
|
if log == nil {
|
|
log = logger.Default()
|
|
}
|
|
|
|
// Ensure base directory exists
|
|
basePath, err := cfg.ExpandedBasePath()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to expand base path: %w", err)
|
|
}
|
|
if err := os.MkdirAll(basePath, 0755); err != nil {
|
|
return nil, fmt.Errorf("failed to create worktree base directory: %w", err)
|
|
}
|
|
|
|
// Ensure tasks base directory exists (if configured)
|
|
if cfg.TasksBasePath != "" {
|
|
tasksBase, err := cfg.ExpandedTasksBasePath()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to expand tasks base path: %w", err)
|
|
}
|
|
if err := os.MkdirAll(tasksBase, 0755); err != nil {
|
|
return nil, fmt.Errorf("failed to create tasks base directory: %w", err)
|
|
}
|
|
}
|
|
|
|
fetchTimeout := defaultGitFetchTimeout
|
|
if cfg.FetchTimeoutSeconds > 0 {
|
|
fetchTimeout = time.Duration(cfg.FetchTimeoutSeconds) * time.Second
|
|
}
|
|
|
|
pullTimeout := defaultGitPullTimeout
|
|
if cfg.PullTimeoutSeconds > 0 {
|
|
pullTimeout = time.Duration(cfg.PullTimeoutSeconds) * time.Second
|
|
}
|
|
|
|
return &Manager{
|
|
config: cfg,
|
|
logger: log.WithFields(zap.String("component", "worktree-manager")),
|
|
store: store,
|
|
worktrees: make(map[string]*Worktree),
|
|
repoLocks: make(map[string]*repoLockEntry),
|
|
fetchTimeout: fetchTimeout,
|
|
pullTimeout: pullTimeout,
|
|
}, nil
|
|
}
|
|
|
|
// SetRepositoryProvider sets the repository provider for fetching repository information.
|
|
func (m *Manager) SetRepositoryProvider(provider RepositoryProvider) {
|
|
m.repoProvider = provider
|
|
}
|
|
|
|
// SetScriptMessageHandler sets the script message handler for executing setup/cleanup scripts.
|
|
func (m *Manager) SetScriptMessageHandler(handler ScriptMessageHandler) {
|
|
m.scriptMsgHandler = handler
|
|
}
|
|
|
|
// getRepoLock returns a mutex for the given repository path and increments its reference count.
|
|
func (m *Manager) getRepoLock(repoPath string) *sync.Mutex {
|
|
m.repoLockMu.Lock()
|
|
defer m.repoLockMu.Unlock()
|
|
|
|
if entry, exists := m.repoLocks[repoPath]; exists {
|
|
entry.refCount++
|
|
return entry.mu
|
|
}
|
|
|
|
entry := &repoLockEntry{
|
|
mu: &sync.Mutex{},
|
|
refCount: 1,
|
|
}
|
|
m.repoLocks[repoPath] = entry
|
|
return entry.mu
|
|
}
|
|
|
|
// releaseRepoLock decrements the reference count for a repository lock.
|
|
// If the count reaches zero, the lock is removed from the map to prevent memory leaks.
|
|
func (m *Manager) releaseRepoLock(repoPath string) {
|
|
m.repoLockMu.Lock()
|
|
defer m.repoLockMu.Unlock()
|
|
|
|
entry, exists := m.repoLocks[repoPath]
|
|
if !exists {
|
|
return
|
|
}
|
|
|
|
entry.refCount--
|
|
if entry.refCount <= 0 {
|
|
delete(m.repoLocks, repoPath)
|
|
m.logger.Debug("released repository lock",
|
|
zap.String("repository_path", repoPath))
|
|
}
|
|
}
|
|
|
|
// IsEnabled returns whether worktree mode is enabled.
|
|
func (m *Manager) IsEnabled() bool {
|
|
return m.config.Enabled
|
|
}
|
|
|
|
// Create creates a new worktree for a session, or returns an existing one.
|
|
// Each session gets its own worktree for isolation. Checks by SessionID first,
|
|
// then by WorktreeID if provided (for session resumption).
|
|
// Only creates a new worktree if none exists for the session.
|
|
func (m *Manager) Create(ctx context.Context, req CreateRequest) (*Worktree, error) {
|
|
if err := req.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// First, check if a worktree already exists for this session
|
|
if req.SessionID != "" {
|
|
existing, err := m.GetBySessionID(ctx, req.SessionID)
|
|
if err == nil && existing != nil {
|
|
if m.IsValid(existing.Path) {
|
|
m.logger.Debug("reusing existing worktree by session ID",
|
|
zap.String("worktree_id", existing.ID),
|
|
zap.String("session_id", req.SessionID),
|
|
zap.String("task_id", req.TaskID),
|
|
zap.String("path", existing.Path))
|
|
return existing, nil
|
|
}
|
|
// Worktree record exists but directory is invalid - recreate
|
|
m.logger.Warn("worktree directory invalid, recreating",
|
|
zap.String("worktree_id", existing.ID),
|
|
zap.String("session_id", req.SessionID),
|
|
zap.String("task_id", req.TaskID))
|
|
return m.recreate(ctx, existing, req)
|
|
}
|
|
}
|
|
|
|
// If WorktreeID is provided, try to reuse that specific worktree (session resumption)
|
|
if req.WorktreeID != "" {
|
|
existing, err := m.GetByID(ctx, req.WorktreeID)
|
|
if err == nil && existing != nil {
|
|
if m.IsValid(existing.Path) {
|
|
m.logger.Info("reusing existing worktree by ID",
|
|
zap.String("worktree_id", req.WorktreeID),
|
|
zap.String("session_id", req.SessionID),
|
|
zap.String("task_id", req.TaskID),
|
|
zap.String("path", existing.Path))
|
|
return existing, nil
|
|
}
|
|
// Worktree record exists but directory is invalid - recreate
|
|
m.logger.Warn("worktree directory invalid, recreating",
|
|
zap.String("worktree_id", req.WorktreeID),
|
|
zap.String("session_id", req.SessionID),
|
|
zap.String("task_id", req.TaskID))
|
|
return m.recreate(ctx, existing, req)
|
|
}
|
|
// WorktreeID provided but not found - fall through to create new
|
|
m.logger.Warn("worktree ID not found, creating new worktree",
|
|
zap.String("worktree_id", req.WorktreeID),
|
|
zap.String("session_id", req.SessionID),
|
|
zap.String("task_id", req.TaskID))
|
|
}
|
|
|
|
// Check repository is a git repo
|
|
if !m.isGitRepo(req.RepositoryPath) {
|
|
return nil, ErrRepoNotGit
|
|
}
|
|
|
|
// Get repository lock for safe concurrent access
|
|
repoLock := m.getRepoLock(req.RepositoryPath)
|
|
repoLock.Lock()
|
|
defer func() {
|
|
repoLock.Unlock()
|
|
m.releaseRepoLock(req.RepositoryPath)
|
|
}()
|
|
|
|
baseRef := req.BaseBranch
|
|
if req.PullBeforeWorktree {
|
|
baseRef = m.pullBaseBranch(ctx, req.RepositoryPath, req.BaseBranch, req.OnSyncProgress)
|
|
}
|
|
|
|
// Check base branch exists
|
|
if !m.branchExists(req.RepositoryPath, baseRef) {
|
|
return nil, fmt.Errorf("%w: %s", ErrInvalidBaseBranch, baseRef)
|
|
}
|
|
|
|
// Task-dir mode: place worktree inside ~/.kandev/tasks/{taskDir}/{repo}/
|
|
if req.TaskDirName != "" && req.RepoName != "" {
|
|
return m.createInTaskDir(ctx, req, baseRef)
|
|
}
|
|
|
|
return m.createWorktree(ctx, req, baseRef)
|
|
}
|
|
|
|
// createWorktree performs the actual git worktree creation.
|
|
func (m *Manager) createWorktree(ctx context.Context, req CreateRequest, baseRef string) (*Worktree, error) {
|
|
worktreeDirName, branchName := m.buildWorktreeNames(req)
|
|
startPoint := baseRef
|
|
|
|
// If a checkout branch is specified (e.g. a PR's head branch), fetch it
|
|
// and try to check it out directly. If the branch is already checked out
|
|
// in another worktree, fall back to a unique branch with a random suffix.
|
|
var fetchResult *FetchBranchResult
|
|
if req.CheckoutBranch != "" {
|
|
var err error
|
|
fetchResult, err = m.fetchBranchToLocal(ctx, req.RepositoryPath, req.CheckoutBranch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if fetchResult.StartPoint != "" {
|
|
startPoint = fetchResult.StartPoint
|
|
} else {
|
|
startPoint = req.CheckoutBranch
|
|
}
|
|
}
|
|
|
|
worktreePath, err := m.config.WorktreePath(worktreeDirName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get worktree path: %w", err)
|
|
}
|
|
|
|
worktreeID, branchName, err := m.addWorktreeForBranch(ctx, req, worktreePath, branchName, startPoint, baseRef)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
wt := m.buildWorktreeRecord(worktreeID, req, worktreePath, branchName)
|
|
if fetchResult != nil {
|
|
wt.FetchWarning = fetchResult.Warning
|
|
wt.FetchWarningDetail = fetchResult.WarningDetail
|
|
}
|
|
|
|
if err := m.persistAndCacheWorktree(ctx, wt, req, worktreePath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := m.runWorktreeSetupScript(ctx, wt, req.RepositoryPath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
m.logger.Info("created worktree",
|
|
zap.String("session_id", req.SessionID),
|
|
zap.String("task_id", req.TaskID),
|
|
zap.String("path", worktreePath),
|
|
zap.String("branch", wt.Branch))
|
|
|
|
return wt, nil
|
|
}
|
|
|
|
// createInTaskDir creates a worktree inside the task directory structure:
|
|
// ~/.kandev/tasks/{taskDirName}/{repoName}/
|
|
func (m *Manager) createInTaskDir(ctx context.Context, req CreateRequest, baseRef string) (*Worktree, error) {
|
|
worktreePath, err := m.config.TaskWorktreePath(req.TaskDirName, req.RepoName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get task worktree path: %w", err)
|
|
}
|
|
|
|
// Ensure parent task directory exists
|
|
taskDir := filepath.Dir(worktreePath)
|
|
if err := os.MkdirAll(taskDir, 0755); err != nil {
|
|
return nil, fmt.Errorf("failed to create task directory: %w", err)
|
|
}
|
|
|
|
_, branchName := m.buildWorktreeNames(req)
|
|
startPoint := baseRef
|
|
|
|
var fetchResult *FetchBranchResult
|
|
if req.CheckoutBranch != "" {
|
|
fetchResult, err = m.fetchBranchToLocal(ctx, req.RepositoryPath, req.CheckoutBranch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if fetchResult.StartPoint != "" {
|
|
startPoint = fetchResult.StartPoint
|
|
} else {
|
|
startPoint = req.CheckoutBranch
|
|
}
|
|
}
|
|
|
|
worktreeID, branchName, err := m.addWorktreeForBranch(ctx, req, worktreePath, branchName, startPoint, baseRef)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
wt := m.buildWorktreeRecord(worktreeID, req, worktreePath, branchName)
|
|
if fetchResult != nil {
|
|
wt.FetchWarning = fetchResult.Warning
|
|
wt.FetchWarningDetail = fetchResult.WarningDetail
|
|
}
|
|
|
|
if err := m.persistAndCacheWorktree(ctx, wt, req, worktreePath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := m.runWorktreeSetupScript(ctx, wt, req.RepositoryPath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
m.logger.Info("created worktree in task directory",
|
|
zap.String("session_id", req.SessionID),
|
|
zap.String("task_id", req.TaskID),
|
|
zap.String("task_dir", req.TaskDirName),
|
|
zap.String("repo_name", req.RepoName),
|
|
zap.String("path", worktreePath),
|
|
zap.String("branch", wt.Branch))
|
|
|
|
return wt, nil
|
|
}
|
|
|
|
// addWorktreeForBranch creates the git worktree, trying the checkout branch directly first
|
|
// and falling back to a suffixed branch if the checkout branch is already in use.
|
|
// When a checkout branch is specified, it sets the upstream tracking branch to
|
|
// origin/<checkout-branch> so ahead/behind counts are relative to the PR's remote branch.
|
|
func (m *Manager) addWorktreeForBranch(ctx context.Context, req CreateRequest, worktreePath, fallbackBranch, startPoint, baseRef string) (string, string, error) {
|
|
if req.CheckoutBranch == "" {
|
|
id, err := m.gitAddWorktree(ctx, req.RepositoryPath, fallbackBranch, worktreePath, baseRef)
|
|
return id, fallbackBranch, err
|
|
}
|
|
|
|
// Try checking out the PR branch directly (common case: single task per PR).
|
|
id, err := m.gitAddWorktreeExisting(ctx, req.RepositoryPath, req.CheckoutBranch, worktreePath)
|
|
if err == nil {
|
|
m.setUpstreamIfExists(ctx, worktreePath, req.CheckoutBranch, req.CheckoutBranch)
|
|
return id, req.CheckoutBranch, nil
|
|
}
|
|
if !errors.Is(err, ErrBranchCheckedOut) {
|
|
return "", "", err
|
|
}
|
|
|
|
// Branch is in use by another worktree — create a unique fallback branch
|
|
// using the original branch name with a random suffix.
|
|
suffixed := req.CheckoutBranch + "-" + SmallSuffix(3)
|
|
id, err = m.gitAddWorktree(ctx, req.RepositoryPath, suffixed, worktreePath, startPoint)
|
|
if err == nil {
|
|
m.setUpstreamIfExists(ctx, worktreePath, suffixed, req.CheckoutBranch)
|
|
}
|
|
return id, suffixed, err
|
|
}
|
|
|
|
// setUpstreamIfExists sets the upstream tracking branch for a worktree branch
|
|
// to origin/<remoteBranch> if the remote-tracking ref exists. Non-fatal on failure.
|
|
func (m *Manager) setUpstreamIfExists(ctx context.Context, worktreePath, localBranch, remoteBranch string) {
|
|
upstream := "origin/" + remoteBranch
|
|
// Verify the remote-tracking ref exists
|
|
verifyCmd := exec.CommandContext(ctx, "git", "rev-parse", "--verify", upstream)
|
|
verifyCmd.Dir = worktreePath
|
|
if err := verifyCmd.Run(); err != nil {
|
|
return
|
|
}
|
|
cmd := exec.CommandContext(ctx, "git", "branch", "--set-upstream-to="+upstream, localBranch)
|
|
cmd.Dir = worktreePath
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
m.logger.Debug("failed to set upstream (non-fatal)",
|
|
zap.String("branch", localBranch),
|
|
zap.String("upstream", upstream),
|
|
zap.String("output", string(out)),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// FetchBranchResult holds the outcome of a fetchBranchToLocal call.
|
|
type FetchBranchResult struct {
|
|
StartPoint string // Ref to use as worktree start point (e.g., "origin/branch"); empty = use local branch
|
|
Warning string // User-friendly warning (non-empty when fell back to local)
|
|
WarningDetail string // Raw git command output for debugging
|
|
}
|
|
|
|
// fetchBranchToLocal ensures a branch exists locally and is up-to-date.
|
|
// It first tries to fetch from origin to get the latest commits. If the fetch
|
|
// fails (no remote, auth issue, offline), it falls back to the local branch.
|
|
// Returns a FetchBranchResult with warning info and an error if the branch
|
|
// doesn't exist anywhere.
|
|
func (m *Manager) fetchBranchToLocal(ctx context.Context, repoPath, branch string) (*FetchBranchResult, error) {
|
|
m.logger.Info("syncing checkout branch",
|
|
zap.String("branch", branch),
|
|
zap.String("repo_path", repoPath))
|
|
|
|
// Try to fetch from origin to get the latest version.
|
|
fetchCtx, cancelFetch := context.WithTimeout(ctx, m.fetchTimeout)
|
|
defer cancelFetch()
|
|
|
|
fetchCmd := m.newNonInteractiveGitCmd(fetchCtx, repoPath, "fetch", gitNoTags, "origin", branch+":"+branch)
|
|
if output, err := fetchCmd.CombinedOutput(); err != nil {
|
|
outputStr := string(output)
|
|
|
|
// If the branch is checked out in another worktree, git refuses to update
|
|
// the local ref. Retry by fetching only the remote-tracking ref (origin/branch),
|
|
// which is always safe regardless of worktree state.
|
|
if isFetchRefusedCheckedOut(outputStr) {
|
|
retryCmd := m.newNonInteractiveGitCmd(fetchCtx, repoPath, "fetch", gitNoTags, "origin", branch)
|
|
if _, retryErr := retryCmd.CombinedOutput(); retryErr == nil {
|
|
m.logger.Info("fetched via remote-tracking ref (branch checked out elsewhere)",
|
|
zap.String("branch", branch))
|
|
return &FetchBranchResult{StartPoint: "origin/" + branch}, nil
|
|
}
|
|
}
|
|
|
|
m.logger.Warn("fetch from origin failed, checking local branch",
|
|
zap.String("branch", branch),
|
|
zap.String("output", outputStr),
|
|
zap.Error(err))
|
|
|
|
// Fall back to local branch if it exists.
|
|
if !m.branchExists(repoPath, branch) {
|
|
return nil, fmt.Errorf("branch %q not found locally or on remote: %s", branch, outputStr)
|
|
}
|
|
|
|
reason := classifyGitFallbackReason(err, outputStr, fetchCtx.Err())
|
|
warning := fmt.Sprintf("Could not fetch latest from origin (%s). Using local version of branch %q which may be outdated.", reason, branch)
|
|
m.logger.Info("using local branch (fetch failed)",
|
|
zap.String("branch", branch),
|
|
zap.String("warning", warning))
|
|
return &FetchBranchResult{
|
|
Warning: warning,
|
|
WarningDetail: strings.TrimSpace(outputStr),
|
|
}, nil
|
|
}
|
|
|
|
return &FetchBranchResult{}, nil
|
|
}
|
|
|
|
// gitAddWorktreeExisting creates a worktree that checks out an existing local branch.
|
|
// If the branch is already checked out in a stale worktree (directory no longer exists),
|
|
// it automatically prunes and retries. If the repository uses git-crypt, it creates
|
|
// the worktree without checkout, then unlocks git-crypt and performs the checkout.
|
|
func (m *Manager) gitAddWorktreeExisting(ctx context.Context, repoPath, branchName, worktreePath string) (string, error) {
|
|
worktreeID := uuid.New().String()
|
|
usesGitCrypt := m.usesGitCrypt(repoPath)
|
|
|
|
// Build worktree add command
|
|
args := []string{"worktree", "add"}
|
|
if usesGitCrypt {
|
|
args = append(args, "--no-checkout")
|
|
}
|
|
args = append(args, worktreePath, branchName)
|
|
|
|
cmd := exec.CommandContext(ctx, "git", args...)
|
|
cmd.Dir = repoPath
|
|
output, err := cmd.CombinedOutput()
|
|
if err == nil {
|
|
if usesGitCrypt {
|
|
if unlockErr := m.unlockGitCryptAndCheckout(ctx, worktreePath); unlockErr != nil {
|
|
_ = m.removeWorktreeDir(ctx, worktreePath, repoPath)
|
|
return "", unlockErr
|
|
}
|
|
} else {
|
|
m.initSubmodules(ctx, worktreePath)
|
|
}
|
|
return worktreeID, nil
|
|
}
|
|
|
|
outStr := string(output)
|
|
|
|
// Check for git-crypt smudge error and retry with --no-checkout
|
|
if isGitCryptSmudgeError(outStr) && !usesGitCrypt {
|
|
m.logger.Warn("git-crypt smudge error detected, retrying with --no-checkout",
|
|
zap.String("output", outStr))
|
|
return m.gitAddWorktreeExistingWithGitCrypt(ctx, repoPath, branchName, worktreePath)
|
|
}
|
|
|
|
if !isBranchCheckedOutError(outStr) {
|
|
m.logger.Error("git worktree add (existing branch) failed",
|
|
zap.String("output", outStr), zap.Error(err))
|
|
return "", ClassifyGitError(outStr, err)
|
|
}
|
|
|
|
if recoveryErr := m.tryRecoverCheckedOutBranch(ctx, repoPath, branchName, outStr); recoveryErr != nil {
|
|
m.logger.Warn("branch is checked out in active worktree",
|
|
zap.String("branch", branchName), zap.Error(recoveryErr))
|
|
return "", ErrBranchCheckedOut
|
|
}
|
|
|
|
// Retry after pruning stale worktree
|
|
return m.retryWorktreeExisting(ctx, repoPath, branchName, worktreePath, usesGitCrypt)
|
|
}
|
|
|
|
// retryWorktreeExisting retries worktree creation after pruning stale worktrees.
|
|
func (m *Manager) retryWorktreeExisting(ctx context.Context, repoPath, branchName, worktreePath string, usesGitCrypt bool) (string, error) {
|
|
worktreeID := uuid.New().String()
|
|
|
|
args := []string{"worktree", "add"}
|
|
if usesGitCrypt {
|
|
args = append(args, "--no-checkout")
|
|
}
|
|
args = append(args, worktreePath, branchName)
|
|
|
|
retryCmd := exec.CommandContext(ctx, "git", args...)
|
|
retryCmd.Dir = repoPath
|
|
retryOutput, retryErr := retryCmd.CombinedOutput()
|
|
if retryErr != nil {
|
|
retryOutStr := string(retryOutput)
|
|
if isBranchCheckedOutError(retryOutStr) {
|
|
return "", ErrBranchCheckedOut
|
|
}
|
|
m.logger.Error("git worktree add retry failed",
|
|
zap.String("output", retryOutStr), zap.Error(retryErr))
|
|
return "", ClassifyGitError(retryOutStr, retryErr)
|
|
}
|
|
|
|
if usesGitCrypt {
|
|
if err := m.unlockGitCryptAndCheckout(ctx, worktreePath); err != nil {
|
|
_ = m.removeWorktreeDir(ctx, worktreePath, repoPath)
|
|
return "", err
|
|
}
|
|
} else {
|
|
m.initSubmodules(ctx, worktreePath)
|
|
}
|
|
|
|
m.logger.Info("recovered from stale worktree checkout", zap.String("branch", branchName))
|
|
return worktreeID, nil
|
|
}
|
|
|
|
// gitAddWorktreeExistingWithGitCrypt creates a worktree for an existing branch
|
|
// using --no-checkout, then unlocks git-crypt. Used as fallback when smudge error detected.
|
|
func (m *Manager) gitAddWorktreeExistingWithGitCrypt(ctx context.Context, repoPath, branchName, worktreePath string) (string, error) {
|
|
worktreeID := uuid.New().String()
|
|
|
|
cmd := exec.CommandContext(ctx, "git", "worktree", "add", "--no-checkout", worktreePath, branchName)
|
|
cmd.Dir = repoPath
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
outStr := string(output)
|
|
if isBranchCheckedOutError(outStr) {
|
|
return "", ErrBranchCheckedOut
|
|
}
|
|
m.logger.Error("git worktree add (--no-checkout, existing) failed",
|
|
zap.String("output", outStr), zap.Error(err))
|
|
return "", ClassifyGitError(outStr, err)
|
|
}
|
|
|
|
if err := m.unlockGitCryptAndCheckout(ctx, worktreePath); err != nil {
|
|
_ = m.removeWorktreeDir(ctx, worktreePath, repoPath)
|
|
return "", err
|
|
}
|
|
|
|
return worktreeID, nil
|
|
}
|
|
|
|
// tryRecoverCheckedOutBranch attempts to recover from "branch is already checked out"
|
|
// by pruning stale worktrees. Returns nil if recovery succeeded, error otherwise.
|
|
func (m *Manager) tryRecoverCheckedOutBranch(ctx context.Context, repoPath, branchName, gitOutput string) error {
|
|
// Parse the path from git output: "fatal: 'branch' is already checked out at '/path/to/worktree'"
|
|
checkedOutPath := parseCheckedOutPath(gitOutput)
|
|
if checkedOutPath == "" {
|
|
return fmt.Errorf("could not parse worktree path from git output")
|
|
}
|
|
|
|
// Check if the worktree directory still exists on disk.
|
|
if _, err := os.Stat(checkedOutPath); err == nil {
|
|
// Directory exists — worktree is genuinely in use, can't recover.
|
|
m.logger.Warn("branch checked out in active worktree, cannot recover",
|
|
zap.String("branch", branchName),
|
|
zap.String("worktree_path", checkedOutPath))
|
|
return fmt.Errorf("worktree at %s is still active", checkedOutPath)
|
|
}
|
|
|
|
// Directory is gone — prune stale worktree references.
|
|
m.logger.Info("pruning stale worktree reference",
|
|
zap.String("branch", branchName),
|
|
zap.String("stale_path", checkedOutPath))
|
|
|
|
pruneCmd := exec.CommandContext(ctx, "git", "worktree", "prune")
|
|
pruneCmd.Dir = repoPath
|
|
if output, err := pruneCmd.CombinedOutput(); err != nil {
|
|
m.logger.Error("git worktree prune failed",
|
|
zap.String("output", string(output)),
|
|
zap.Error(err))
|
|
return fmt.Errorf("worktree prune failed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseCheckedOutPath extracts the worktree path from git's error message.
|
|
// Handles both "is already checked out at '/path'" and "is already used by worktree at '/path'".
|
|
func parseCheckedOutPath(gitOutput string) string {
|
|
for _, marker := range []string{"checked out at '", "used by worktree at '"} {
|
|
_, after, found := strings.Cut(gitOutput, marker)
|
|
if found {
|
|
path, _, ok := strings.Cut(after, "'")
|
|
if ok {
|
|
return path
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// buildWorktreeNames derives the filesystem directory name and git branch name for a new worktree.
|
|
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)
|
|
sanitizedTitle := SanitizeForBranch(req.TaskTitle, 20)
|
|
if sanitizedTitle == "" {
|
|
sanitizedTitle = SanitizeForBranch(req.TaskID, 20)
|
|
}
|
|
branchName = prefix + sanitizedTitle + "-" + branchSuffix
|
|
} else {
|
|
// Fallback to task ID based naming
|
|
dirName = req.TaskID + "_" + dirSuffix
|
|
branchName = prefix + SanitizeForBranch(req.TaskID, 20) + "-" + branchSuffix
|
|
}
|
|
return dirName, branchName
|
|
}
|
|
|
|
// gitAddWorktree runs "git worktree add" and returns the new worktree UUID.
|
|
// If the repository uses git-crypt, it creates the worktree without checkout,
|
|
// then unlocks git-crypt and performs the checkout separately.
|
|
func (m *Manager) gitAddWorktree(ctx context.Context, repoPath, branchName, worktreePath, baseRef string) (string, error) {
|
|
worktreeID := uuid.New().String()
|
|
usesGitCrypt := m.usesGitCrypt(repoPath)
|
|
|
|
// Build worktree add command.
|
|
// Use -c branch.autoSetupMerge=false to prevent git from automatically
|
|
// setting upstream tracking when the base ref is a remote-tracking branch
|
|
// (e.g. origin/feature/foo). New task branches should start with no
|
|
// upstream until the user explicitly pushes.
|
|
args := []string{"-c", "branch.autoSetupMerge=false", "worktree", "add", "-b", branchName}
|
|
if usesGitCrypt {
|
|
args = append(args, "--no-checkout")
|
|
}
|
|
args = append(args, worktreePath, baseRef)
|
|
|
|
cmd := exec.CommandContext(ctx, "git", args...)
|
|
cmd.Dir = repoPath
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
outStr := string(output)
|
|
// Check if this is a git-crypt error we didn't anticipate
|
|
if isGitCryptSmudgeError(outStr) {
|
|
m.logger.Warn("git-crypt smudge error detected, retrying with --no-checkout",
|
|
zap.String("output", outStr))
|
|
return m.gitAddWorktreeWithGitCrypt(ctx, repoPath, branchName, worktreePath, baseRef)
|
|
}
|
|
m.logger.Error("git worktree add failed",
|
|
zap.String("output", outStr),
|
|
zap.Error(err))
|
|
return "", fmt.Errorf("%w: %s", ErrGitCommandFailed, outStr)
|
|
}
|
|
|
|
// If we used --no-checkout, we need to unlock git-crypt and checkout
|
|
if usesGitCrypt {
|
|
if err := m.unlockGitCryptAndCheckout(ctx, worktreePath); err != nil {
|
|
// Cleanup the worktree on failure
|
|
_ = m.removeWorktreeDir(ctx, worktreePath, repoPath)
|
|
return "", err
|
|
}
|
|
} else {
|
|
m.initSubmodules(ctx, worktreePath)
|
|
}
|
|
|
|
return worktreeID, nil
|
|
}
|
|
|
|
// gitAddWorktreeWithGitCrypt creates a worktree using --no-checkout and then
|
|
// unlocks git-crypt. This is used as a fallback when we detect a git-crypt
|
|
// smudge filter error.
|
|
func (m *Manager) gitAddWorktreeWithGitCrypt(ctx context.Context, repoPath, branchName, worktreePath, baseRef string) (string, error) {
|
|
worktreeID := uuid.New().String()
|
|
|
|
// Create worktree without checkout.
|
|
// Use -c branch.autoSetupMerge=false to prevent git from automatically
|
|
// setting upstream tracking when the base ref is a remote-tracking branch.
|
|
cmd := exec.CommandContext(ctx, "git",
|
|
"-c", "branch.autoSetupMerge=false",
|
|
"worktree", "add",
|
|
"-b", branchName,
|
|
"--no-checkout",
|
|
worktreePath,
|
|
baseRef)
|
|
cmd.Dir = repoPath
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
m.logger.Error("git worktree add (--no-checkout) failed",
|
|
zap.String("output", string(output)),
|
|
zap.Error(err))
|
|
return "", fmt.Errorf("%w: %s", ErrGitCommandFailed, string(output))
|
|
}
|
|
|
|
// Unlock git-crypt and checkout
|
|
if err := m.unlockGitCryptAndCheckout(ctx, worktreePath); err != nil {
|
|
_ = m.removeWorktreeDir(ctx, worktreePath, repoPath)
|
|
return "", err
|
|
}
|
|
|
|
return worktreeID, nil
|
|
}
|
|
|
|
// buildWorktreeRecord constructs an in-memory Worktree value from a completed git worktree add.
|
|
func (m *Manager) buildWorktreeRecord(worktreeID string, req CreateRequest, worktreePath, branchName string) *Worktree {
|
|
now := time.Now()
|
|
return &Worktree{
|
|
ID: worktreeID,
|
|
SessionID: req.SessionID,
|
|
TaskID: req.TaskID,
|
|
RepositoryID: req.RepositoryID,
|
|
RepositoryPath: req.RepositoryPath,
|
|
Path: worktreePath,
|
|
Branch: branchName,
|
|
BaseBranch: req.BaseBranch,
|
|
Status: StatusActive,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
}
|
|
|
|
// persistAndCacheWorktree saves the worktree to the store and updates the in-memory cache.
|
|
func (m *Manager) persistAndCacheWorktree(ctx context.Context, wt *Worktree, req CreateRequest, worktreePath string) error {
|
|
if m.store != nil {
|
|
if err := m.persistWorktree(ctx, wt, req, worktreePath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Update cache (keyed by sessionID)
|
|
if req.SessionID != "" {
|
|
m.mu.Lock()
|
|
m.worktrees[req.SessionID] = wt
|
|
m.mu.Unlock()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// persistWorktree writes the worktree to persistent storage, logging a warning when
|
|
// session_id is missing and cleaning up the git worktree directory on failure.
|
|
func (m *Manager) persistWorktree(ctx context.Context, wt *Worktree, req CreateRequest, worktreePath string) error {
|
|
if req.SessionID == "" {
|
|
m.logger.Warn("skipping worktree persistence: missing session_id",
|
|
zap.String("task_id", req.TaskID),
|
|
zap.String("worktree_id", wt.ID))
|
|
return nil
|
|
}
|
|
if err := m.store.CreateWorktree(ctx, wt); err != nil {
|
|
// Cleanup git worktree on store failure
|
|
if cleanupErr := m.removeWorktreeDir(ctx, worktreePath, req.RepositoryPath); cleanupErr != nil {
|
|
m.logger.Warn("failed to cleanup worktree after persist failure", zap.Error(cleanupErr))
|
|
}
|
|
return fmt.Errorf("failed to persist worktree: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetBySessionID returns the worktree for a session, if it exists.
|
|
func (m *Manager) GetBySessionID(ctx context.Context, sessionID string) (*Worktree, error) {
|
|
// Check cache first (keyed by sessionID)
|
|
m.mu.RLock()
|
|
if wt, ok := m.worktrees[sessionID]; ok {
|
|
m.mu.RUnlock()
|
|
return wt, nil
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
// Check store
|
|
if m.store != nil {
|
|
wt, err := m.store.GetWorktreeBySessionID(ctx, sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if wt != nil {
|
|
// Update cache
|
|
m.mu.Lock()
|
|
m.worktrees[sessionID] = wt
|
|
m.mu.Unlock()
|
|
return wt, nil
|
|
}
|
|
}
|
|
|
|
return nil, ErrWorktreeNotFound
|
|
}
|
|
|
|
// GetByID returns a worktree by its unique ID.
|
|
func (m *Manager) GetByID(ctx context.Context, worktreeID string) (*Worktree, error) {
|
|
if m.store == nil {
|
|
return nil, ErrWorktreeNotFound
|
|
}
|
|
|
|
wt, err := m.store.GetWorktreeByID(ctx, worktreeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if wt == nil {
|
|
return nil, ErrWorktreeNotFound
|
|
}
|
|
return wt, nil
|
|
}
|
|
|
|
// GetAllByTaskID returns all worktrees for a task.
|
|
func (m *Manager) GetAllByTaskID(ctx context.Context, taskID string) ([]*Worktree, error) {
|
|
if m.store == nil {
|
|
return nil, nil
|
|
}
|
|
return m.store.GetWorktreesByTaskID(ctx, taskID)
|
|
}
|
|
|
|
// IsValid checks if a worktree directory is valid and usable.
|
|
func (m *Manager) IsValid(path string) bool {
|
|
// Check directory exists
|
|
info, err := os.Stat(path)
|
|
if err != nil || !info.IsDir() {
|
|
return false
|
|
}
|
|
|
|
// Check .git file exists (worktrees have .git file, not directory)
|
|
gitFile := filepath.Join(path, ".git")
|
|
content, err := os.ReadFile(gitFile)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// .git file should contain "gitdir: <path>"
|
|
if !strings.HasPrefix(string(content), "gitdir:") {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// RemoveByID removes a specific worktree by its ID and optionally its branch.
|
|
func (m *Manager) RemoveByID(ctx context.Context, worktreeID string, removeBranch bool) error {
|
|
wt, err := m.GetByID(ctx, worktreeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return m.removeWorktree(ctx, wt, removeBranch)
|
|
}
|
|
|
|
// removeWorktree performs the actual removal of a worktree.
|
|
func (m *Manager) removeWorktree(ctx context.Context, wt *Worktree, removeBranch bool) error {
|
|
// Get repository lock
|
|
repoLock := m.getRepoLock(wt.RepositoryPath)
|
|
repoLock.Lock()
|
|
defer func() {
|
|
repoLock.Unlock()
|
|
m.releaseRepoLock(wt.RepositoryPath)
|
|
}()
|
|
|
|
// Execute cleanup script BEFORE removing directory
|
|
m.runWorktreeCleanupScript(ctx, wt)
|
|
|
|
// Remove worktree directory
|
|
if err := m.removeWorktreeDir(ctx, wt.Path, wt.RepositoryPath); err != nil {
|
|
m.logger.Warn("failed to remove worktree directory",
|
|
zap.String("path", wt.Path),
|
|
zap.Error(err))
|
|
}
|
|
|
|
// Optionally remove the branch from the main repository
|
|
if removeBranch {
|
|
m.logger.Info("deleting branch from main repository",
|
|
zap.String("branch", wt.Branch),
|
|
zap.String("repository_path", wt.RepositoryPath))
|
|
|
|
cmd := exec.CommandContext(ctx, "git", "branch", "-D", wt.Branch)
|
|
cmd.Dir = wt.RepositoryPath
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
m.logger.Warn("failed to delete branch from main repository",
|
|
zap.String("branch", wt.Branch),
|
|
zap.String("repository_path", wt.RepositoryPath),
|
|
zap.String("output", string(output)),
|
|
zap.Error(err))
|
|
} else {
|
|
m.logger.Info("successfully deleted branch from main repository",
|
|
zap.String("branch", wt.Branch),
|
|
zap.String("repository_path", wt.RepositoryPath))
|
|
}
|
|
}
|
|
|
|
// Update store
|
|
if m.store != nil {
|
|
now := time.Now()
|
|
wt.Status = StatusDeleted
|
|
wt.DeletedAt = &now
|
|
wt.UpdatedAt = now
|
|
if err := m.store.UpdateWorktree(ctx, wt); err != nil {
|
|
// Record may already be deleted by another cleanup path (e.g. task deletion).
|
|
// This is expected and harmless - only log at debug level.
|
|
m.logger.Debug("failed to update worktree status (may already be deleted)",
|
|
zap.String("worktree_id", wt.ID),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// Update cache
|
|
m.mu.Lock()
|
|
if wt.SessionID != "" {
|
|
delete(m.worktrees, wt.SessionID)
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
m.logger.Info("removed worktree",
|
|
zap.String("task_id", wt.TaskID),
|
|
zap.String("worktree_id", wt.ID),
|
|
zap.String("path", wt.Path),
|
|
zap.Bool("branch_removed", removeBranch))
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) runWorktreeSetupScript(ctx context.Context, wt *Worktree, repositoryPath string) error {
|
|
if m.scriptMsgHandler == nil || m.repoProvider == nil {
|
|
return nil
|
|
}
|
|
if wt.RepositoryID == "" {
|
|
// Nothing to set up without a linked repository; upstream may not
|
|
// always populate this field.
|
|
return nil
|
|
}
|
|
repo, err := m.repoProvider.GetRepository(ctx, wt.RepositoryID)
|
|
if err != nil {
|
|
m.logger.Warn("failed to fetch repository for setup script",
|
|
zap.String("repository_id", wt.RepositoryID),
|
|
zap.Error(err))
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(repo.SetupScript) == "" {
|
|
return nil
|
|
}
|
|
m.logger.Info("executing setup script for worktree",
|
|
zap.String("worktree_id", wt.ID),
|
|
zap.String("repository_id", wt.RepositoryID))
|
|
scriptReq := ScriptExecutionRequest{
|
|
SessionID: wt.SessionID,
|
|
TaskID: wt.TaskID,
|
|
RepositoryID: wt.RepositoryID,
|
|
Script: repo.SetupScript,
|
|
WorkingDir: wt.Path,
|
|
ScriptType: "setup",
|
|
}
|
|
if err := m.scriptMsgHandler.ExecuteSetupScript(ctx, scriptReq); err != nil {
|
|
m.logger.Error("setup script failed, cleaning up worktree",
|
|
zap.String("worktree_id", wt.ID),
|
|
zap.Error(err))
|
|
m.cleanupWorktreeOnSetupFailure(ctx, wt, repositoryPath)
|
|
return fmt.Errorf("setup script failed: %w", err)
|
|
}
|
|
m.logger.Info("setup script completed successfully", zap.String("worktree_id", wt.ID))
|
|
return nil
|
|
}
|
|
|
|
// cleanupWorktreeOnSetupFailure removes the in-memory cache entry, deletes the worktree
|
|
// directory, and marks the worktree as deleted in the store after a setup script failure.
|
|
func (m *Manager) cleanupWorktreeOnSetupFailure(ctx context.Context, wt *Worktree, repositoryPath string) {
|
|
if wt.SessionID != "" {
|
|
m.mu.Lock()
|
|
delete(m.worktrees, wt.SessionID)
|
|
m.mu.Unlock()
|
|
}
|
|
if cleanupErr := m.removeWorktreeDir(ctx, wt.Path, repositoryPath); cleanupErr != nil {
|
|
m.logger.Warn("failed to cleanup worktree after setup failure", zap.Error(cleanupErr))
|
|
}
|
|
if m.store == nil {
|
|
return
|
|
}
|
|
now := time.Now()
|
|
wt.Status = StatusDeleted
|
|
wt.DeletedAt = &now
|
|
wt.UpdatedAt = now
|
|
if updateErr := m.store.UpdateWorktree(ctx, wt); updateErr != nil {
|
|
m.logger.Warn("failed to update worktree status", zap.Error(updateErr))
|
|
}
|
|
}
|
|
|
|
// runWorktreeCleanupScript executes the repository cleanup script for a worktree before removal.
|
|
func (m *Manager) runWorktreeCleanupScript(ctx context.Context, wt *Worktree) {
|
|
if m.scriptMsgHandler == nil || m.repoProvider == nil {
|
|
return
|
|
}
|
|
repo, err := m.repoProvider.GetRepository(ctx, wt.RepositoryID)
|
|
if err != nil {
|
|
m.logger.Warn("failed to fetch repository for cleanup script",
|
|
zap.String("repository_id", wt.RepositoryID),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
if strings.TrimSpace(repo.CleanupScript) == "" {
|
|
return
|
|
}
|
|
m.logger.Info("executing cleanup script for worktree",
|
|
zap.String("worktree_id", wt.ID),
|
|
zap.String("repository_id", wt.RepositoryID))
|
|
scriptReq := ScriptExecutionRequest{
|
|
SessionID: wt.SessionID,
|
|
TaskID: wt.TaskID,
|
|
RepositoryID: wt.RepositoryID,
|
|
Script: repo.CleanupScript,
|
|
WorkingDir: wt.Path,
|
|
ScriptType: "cleanup",
|
|
}
|
|
if err := m.scriptMsgHandler.ExecuteCleanupScript(ctx, scriptReq); err != nil {
|
|
m.logger.Warn("cleanup script failed, proceeding with deletion",
|
|
zap.String("worktree_id", wt.ID),
|
|
zap.Error(err))
|
|
} else {
|
|
m.logger.Info("cleanup script completed successfully",
|
|
zap.String("worktree_id", wt.ID))
|
|
}
|
|
}
|
|
|
|
// CleanupWorktrees removes provided worktrees without re-fetching from the store.
|
|
func (m *Manager) CleanupWorktrees(ctx context.Context, worktrees []*Worktree) error {
|
|
if len(worktrees) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var lastErr error
|
|
for _, wt := range worktrees {
|
|
if wt == nil {
|
|
continue
|
|
}
|
|
if err := m.removeWorktree(ctx, wt, true); err != nil {
|
|
m.logger.Warn("failed to remove worktree on task deletion",
|
|
zap.String("task_id", wt.TaskID),
|
|
zap.String("worktree_id", wt.ID),
|
|
zap.Error(err))
|
|
lastErr = err
|
|
}
|
|
}
|
|
|
|
m.mu.Lock()
|
|
for _, wt := range worktrees {
|
|
if wt == nil {
|
|
continue
|
|
}
|
|
if wt.SessionID != "" {
|
|
delete(m.worktrees, wt.SessionID)
|
|
}
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
return lastErr
|
|
}
|
|
|
|
// OnTaskDeleted cleans up all worktrees for a task when it is deleted.
|
|
func (m *Manager) OnTaskDeleted(ctx context.Context, taskID string) error {
|
|
// Get all worktrees for this task
|
|
worktrees, err := m.GetAllByTaskID(ctx, taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return m.CleanupWorktrees(ctx, worktrees)
|
|
}
|
|
|
|
// Reconcile syncs worktree state with active tasks on startup.
|
|
func (m *Manager) Reconcile(ctx context.Context, activeTasks []string) error {
|
|
basePath, err := m.config.ExpandedBasePath()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to expand base path: %w", err)
|
|
}
|
|
|
|
// Create a set of active task IDs
|
|
activeSet := make(map[string]bool)
|
|
for _, taskID := range activeTasks {
|
|
activeSet[taskID] = true
|
|
}
|
|
|
|
// Scan worktree directories
|
|
entries, err := os.ReadDir(basePath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil // No worktrees directory yet
|
|
}
|
|
return fmt.Errorf("failed to read worktree directory: %w", err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
taskID := entry.Name()
|
|
worktreePath := filepath.Join(basePath, taskID)
|
|
|
|
if !activeSet[taskID] {
|
|
// Orphaned worktree - no matching active task
|
|
m.logger.Info("cleaning up orphaned worktree",
|
|
zap.String("task_id", taskID),
|
|
zap.String("path", worktreePath))
|
|
|
|
// Remove directory
|
|
if err := os.RemoveAll(worktreePath); err != nil {
|
|
m.logger.Warn("failed to remove orphaned worktree",
|
|
zap.String("path", worktreePath),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also scan the tasks directory for orphaned task directories
|
|
m.reconcileTasksDir(ctx)
|
|
|
|
return nil
|
|
}
|
|
|
|
// reconcileTasksDir scans ~/.kandev/tasks/ for orphaned task directories
|
|
// whose worktrees are no longer referenced by the store.
|
|
func (m *Manager) reconcileTasksDir(ctx context.Context) {
|
|
tasksBase, err := m.config.ExpandedTasksBasePath()
|
|
if err != nil || tasksBase == "" {
|
|
return
|
|
}
|
|
|
|
entries, err := os.ReadDir(tasksBase)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
m.logger.Debug("failed to read tasks directory for reconciliation",
|
|
zap.String("path", tasksBase), zap.Error(err))
|
|
}
|
|
return
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
taskDirPath := filepath.Join(tasksBase, entry.Name())
|
|
if m.isOrphanedTaskDir(ctx, taskDirPath) {
|
|
m.logger.Info("cleaning up orphaned task directory",
|
|
zap.String("path", taskDirPath))
|
|
if err := os.RemoveAll(taskDirPath); err != nil {
|
|
m.logger.Warn("failed to remove orphaned task directory",
|
|
zap.String("path", taskDirPath), zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// isOrphanedTaskDir checks if a task directory contains no valid worktrees.
|
|
func (m *Manager) isOrphanedTaskDir(ctx context.Context, taskDirPath string) bool {
|
|
subEntries, err := os.ReadDir(taskDirPath)
|
|
if err != nil {
|
|
return true // Can't read it, treat as orphaned
|
|
}
|
|
for _, sub := range subEntries {
|
|
if sub.IsDir() && m.IsValid(filepath.Join(taskDirPath, sub.Name())) {
|
|
return false // At least one valid worktree
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// isGitRepo checks if a path is a Git repository.
|
|
func (m *Manager) isGitRepo(path string) bool {
|
|
gitDir := filepath.Join(path, ".git")
|
|
info, err := os.Stat(gitDir)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
// .git can be either a directory (regular repo) or a file (worktree)
|
|
return info.IsDir() || info.Mode().IsRegular()
|
|
}
|
|
|
|
// branchExists checks if a branch exists in the repository.
|
|
func (m *Manager) branchExists(repoPath, branch string) bool {
|
|
cmd := exec.Command("git", "rev-parse", "--verify", branch)
|
|
cmd.Dir = repoPath
|
|
err := cmd.Run()
|
|
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))
|
|
}
|
|
|
|
func (m *Manager) newNonInteractiveGitCmd(ctx context.Context, repoPath string, args ...string) *exec.Cmd {
|
|
cmd := exec.CommandContext(ctx, "git", args...)
|
|
cmd.Dir = repoPath
|
|
cmd.Env = append(os.Environ(),
|
|
"GIT_TERMINAL_PROMPT=0",
|
|
"GCM_INTERACTIVE=Never",
|
|
"GIT_ASKPASS=echo",
|
|
"SSH_ASKPASS=/bin/false",
|
|
"GIT_SSH_COMMAND=ssh -oBatchMode=yes",
|
|
)
|
|
// After the context cancels and the process is killed, child processes
|
|
// (e.g. credential helpers) may still hold stdout/stderr pipes open.
|
|
// WaitDelay bounds how long CombinedOutput waits for those pipes to close.
|
|
cmd.WaitDelay = 500 * time.Millisecond
|
|
return cmd
|
|
}
|
|
|
|
func classifyGitFallbackReason(cmdErr error, cmdOutput string, ctxErr error) string {
|
|
if errors.Is(ctxErr, context.DeadlineExceeded) || errors.Is(cmdErr, context.DeadlineExceeded) {
|
|
return "timeout"
|
|
}
|
|
|
|
if containsAuthFailure(strings.ToLower(cmdOutput)) {
|
|
return "non_interactive_auth_failed"
|
|
}
|
|
|
|
return "git_command_failed"
|
|
}
|
|
|
|
// 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/<branch> instead
|
|
//
|
|
// On fetch/pull failure, errors are logged but the function continues with the best available ref.
|
|
func (m *Manager) pullBaseBranch(ctx context.Context, repoPath, baseBranch string, onProgress SyncProgressCallback) string {
|
|
localBranch := strings.TrimPrefix(baseBranch, "origin/")
|
|
isRemoteRef := localBranch != baseBranch
|
|
stepName := "Sync base branch"
|
|
|
|
m.reportSyncProgress(onProgress, SyncProgressEvent{
|
|
StepName: stepName,
|
|
Status: SyncProgressRunning,
|
|
Output: fmt.Sprintf("Fetching latest changes for %s", baseBranch),
|
|
})
|
|
|
|
// Fetch branch from origin in non-interactive mode.
|
|
fetchCtx, cancelFetch := context.WithTimeout(ctx, m.fetchTimeout)
|
|
defer cancelFetch()
|
|
|
|
fetchArgs := []string{"fetch", gitNoTags, "origin"}
|
|
if localBranch != "" {
|
|
fetchArgs = append(fetchArgs, localBranch)
|
|
}
|
|
fetchCmd := m.newNonInteractiveGitCmd(fetchCtx, repoPath, fetchArgs...)
|
|
if output, err := fetchCmd.CombinedOutput(); err != nil {
|
|
return m.handleFetchFallback(baseBranch, stepName, onProgress, fetchCtx.Err(), output, err)
|
|
}
|
|
|
|
if isRemoteRef {
|
|
resolved := "origin/" + localBranch
|
|
m.reportSyncCompleted(stepName, onProgress, fmt.Sprintf("Synced and using %s", resolved), "")
|
|
return resolved
|
|
}
|
|
|
|
return m.resolveLocalBaseRef(ctx, repoPath, baseBranch, localBranch, stepName, onProgress)
|
|
}
|
|
|
|
func (m *Manager) reportSyncProgress(cb SyncProgressCallback, event SyncProgressEvent) {
|
|
if cb != nil {
|
|
cb(event)
|
|
}
|
|
}
|
|
|
|
func (m *Manager) reportSyncCompleted(stepName string, onProgress SyncProgressCallback, output, errOutput string) {
|
|
m.reportSyncProgress(onProgress, SyncProgressEvent{
|
|
StepName: stepName,
|
|
Status: SyncProgressCompleted,
|
|
Output: output,
|
|
Error: strings.TrimSpace(errOutput),
|
|
})
|
|
}
|
|
|
|
func (m *Manager) handleFetchFallback(baseBranch, stepName string, onProgress SyncProgressCallback, ctxErr error, output []byte, cmdErr error) string {
|
|
reason := classifyGitFallbackReason(cmdErr, string(output), ctxErr)
|
|
m.logger.Warn("git fetch failed before worktree creation; continuing with fallback ref",
|
|
zap.String("branch", baseBranch),
|
|
zap.String("reason", reason),
|
|
zap.String("fallback_ref", baseBranch),
|
|
zap.String("output", string(output)),
|
|
zap.Error(cmdErr))
|
|
m.reportSyncCompleted(stepName, onProgress, fmt.Sprintf("Fetch %s; using fallback ref %s", reason, baseBranch), string(output))
|
|
return baseBranch
|
|
}
|
|
|
|
func (m *Manager) resolveLocalBaseRef(
|
|
ctx context.Context, repoPath, baseBranch, localBranch, stepName string,
|
|
onProgress SyncProgressCallback,
|
|
) string {
|
|
remoteRef := "origin/" + localBranch
|
|
if m.currentBranch(repoPath) == baseBranch {
|
|
return m.pullCurrentBranchOrFallback(ctx, repoPath, baseBranch, remoteRef, stepName, onProgress)
|
|
}
|
|
if m.branchExists(repoPath, remoteRef) {
|
|
m.reportSyncCompleted(stepName, onProgress, fmt.Sprintf("Synced and using %s", remoteRef), "")
|
|
return remoteRef
|
|
}
|
|
m.reportSyncCompleted(stepName, onProgress, fmt.Sprintf("Remote ref not found; using %s", baseBranch), "")
|
|
return baseBranch
|
|
}
|
|
|
|
func (m *Manager) pullCurrentBranchOrFallback(
|
|
ctx context.Context, repoPath, baseBranch, remoteRef, stepName string,
|
|
onProgress SyncProgressCallback,
|
|
) string {
|
|
pullCtx, cancelPull := context.WithTimeout(ctx, m.pullTimeout)
|
|
defer cancelPull()
|
|
|
|
pullCmd := m.newNonInteractiveGitCmd(pullCtx, repoPath, "pull", "--ff-only", "origin", baseBranch)
|
|
if output, err := pullCmd.CombinedOutput(); err != nil {
|
|
reason := classifyGitFallbackReason(err, string(output), pullCtx.Err())
|
|
m.logger.Warn("git pull failed before worktree creation; continuing with remote ref",
|
|
zap.String("branch", baseBranch),
|
|
zap.String("reason", reason),
|
|
zap.String("remote_ref", remoteRef),
|
|
zap.String("output", string(output)),
|
|
zap.Error(err))
|
|
m.reportSyncCompleted(stepName, onProgress, fmt.Sprintf("Pull %s; using %s", reason, remoteRef), string(output))
|
|
return remoteRef
|
|
}
|
|
m.reportSyncCompleted(stepName, onProgress, fmt.Sprintf("Synced and using %s", baseBranch), "")
|
|
return baseBranch
|
|
}
|
|
|
|
// removeWorktreeDir removes a worktree directory using git worktree remove.
|
|
func (m *Manager) removeWorktreeDir(ctx context.Context, worktreePath, repoPath string) error {
|
|
// First try git worktree remove
|
|
cmd := exec.CommandContext(ctx, "git", "worktree", "remove", "--force", worktreePath)
|
|
cmd.Dir = repoPath
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
m.logger.Debug("git worktree remove failed, falling back to rm",
|
|
zap.String("output", string(output)),
|
|
zap.Error(err))
|
|
|
|
if err := m.forceRemoveDir(ctx, worktreePath); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Prune stale worktree entries
|
|
pruneCmd := exec.CommandContext(ctx, "git", "worktree", "prune")
|
|
pruneCmd.Dir = repoPath
|
|
if err := pruneCmd.Run(); err != nil {
|
|
m.logger.Debug("git worktree prune failed", zap.Error(err))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// forceRemoveDir removes a directory, retrying on transient failures.
|
|
// On macOS, os.RemoveAll can fail with "directory not empty" when files
|
|
// have special attributes or were recently released by other processes
|
|
// (e.g. .next/dev build cache). Falls back to rm -rf as a last resort.
|
|
func (m *Manager) forceRemoveDir(ctx context.Context, dir string) error {
|
|
const maxRetries = 3
|
|
const retryDelay = 200 * time.Millisecond
|
|
|
|
for i := range maxRetries {
|
|
err := os.RemoveAll(dir)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if i < maxRetries-1 {
|
|
m.logger.Debug("os.RemoveAll failed, retrying",
|
|
zap.String("path", dir),
|
|
zap.Int("attempt", i+1),
|
|
zap.Error(err))
|
|
time.Sleep(retryDelay)
|
|
}
|
|
}
|
|
|
|
// Last resort: shell out to rm -rf which handles macOS edge cases better
|
|
cmd := exec.CommandContext(ctx, "rm", "-rf", dir)
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("rm -rf failed: %w (output: %s)", err, string(output))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// recreate recreates a worktree from stored metadata.
|
|
func (m *Manager) recreate(ctx context.Context, existing *Worktree, req CreateRequest) (*Worktree, error) {
|
|
// Clean up existing directory if present
|
|
if existing.Path != "" {
|
|
if err := os.RemoveAll(existing.Path); err != nil {
|
|
m.logger.Debug("failed to remove existing worktree path", zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// Remove from git worktree list
|
|
cmd := exec.CommandContext(ctx, "git", "worktree", "prune")
|
|
cmd.Dir = req.RepositoryPath
|
|
if err := cmd.Run(); err != nil {
|
|
m.logger.Debug("git worktree prune failed", zap.Error(err))
|
|
}
|
|
|
|
// Get repository lock
|
|
repoLock := m.getRepoLock(req.RepositoryPath)
|
|
repoLock.Lock()
|
|
defer func() {
|
|
repoLock.Unlock()
|
|
m.releaseRepoLock(req.RepositoryPath)
|
|
}()
|
|
|
|
worktreePath, err := m.config.WorktreePath(req.TaskID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get worktree path: %w", err)
|
|
}
|
|
|
|
// Try to add worktree using existing branch
|
|
usesGitCrypt := m.usesGitCrypt(req.RepositoryPath)
|
|
args := []string{"worktree", "add"}
|
|
if usesGitCrypt {
|
|
args = append(args, "--no-checkout")
|
|
}
|
|
args = append(args, worktreePath, existing.Branch)
|
|
|
|
cmd = exec.CommandContext(ctx, "git", args...)
|
|
cmd.Dir = req.RepositoryPath
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
outStr := string(output)
|
|
// If git-crypt smudge error and we didn't detect git-crypt, retry with --no-checkout
|
|
if isGitCryptSmudgeError(outStr) && !usesGitCrypt {
|
|
m.logger.Warn("git-crypt smudge error during recreate, retrying with --no-checkout",
|
|
zap.String("output", outStr))
|
|
retryCmd := exec.CommandContext(ctx, "git", "worktree", "add", "--no-checkout", worktreePath, existing.Branch)
|
|
retryCmd.Dir = req.RepositoryPath
|
|
if retryOutput, retryErr := retryCmd.CombinedOutput(); retryErr != nil {
|
|
m.logger.Error("failed to recreate worktree (--no-checkout)",
|
|
zap.String("output", string(retryOutput)),
|
|
zap.Error(retryErr))
|
|
return nil, fmt.Errorf("%w: %s", ErrGitCommandFailed, string(retryOutput))
|
|
}
|
|
usesGitCrypt = true // Force unlock/checkout
|
|
} else {
|
|
m.logger.Error("failed to recreate worktree",
|
|
zap.String("output", outStr),
|
|
zap.Error(err))
|
|
return nil, fmt.Errorf("%w: %s", ErrGitCommandFailed, outStr)
|
|
}
|
|
}
|
|
|
|
// If using git-crypt, unlock and checkout
|
|
if usesGitCrypt {
|
|
if err := m.unlockGitCryptAndCheckout(ctx, worktreePath); err != nil {
|
|
_ = m.removeWorktreeDir(ctx, worktreePath, req.RepositoryPath)
|
|
return nil, err
|
|
}
|
|
} else {
|
|
m.initSubmodules(ctx, worktreePath)
|
|
}
|
|
|
|
// Update record
|
|
now := time.Now()
|
|
existing.Path = worktreePath
|
|
existing.Status = StatusActive
|
|
existing.UpdatedAt = now
|
|
|
|
if m.store != nil {
|
|
if err := m.store.UpdateWorktree(ctx, existing); err != nil {
|
|
return nil, fmt.Errorf("failed to update worktree record: %w", err)
|
|
}
|
|
}
|
|
|
|
// Update cache (keyed by sessionID)
|
|
if req.SessionID != "" {
|
|
m.mu.Lock()
|
|
m.worktrees[req.SessionID] = existing
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
m.logger.Info("recreated worktree",
|
|
zap.String("session_id", req.SessionID),
|
|
zap.String("task_id", req.TaskID),
|
|
zap.String("path", worktreePath))
|
|
|
|
return existing, nil
|
|
}
|