1540 lines
53 KiB
Go
1540 lines
53 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
|
|
defaultGitInspectTimeout = 10 * 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 is the in-memory cache keyed by cacheKey(sessionID, repositoryID).
|
|
// For legacy single-repo writes the repositoryID may be empty, in which
|
|
// case the cache key collapses to "{sessionID}|" — still distinct from
|
|
// any per-repo entry the same session might gain later.
|
|
worktrees map[string]*Worktree
|
|
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
|
|
// Bound for cheap git ref-inspection commands (branchExists, currentBranch).
|
|
inspectTimeout 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 a single active worktree by session ID.
|
|
// For multi-repo sessions, returns the first one found (typically the
|
|
// primary/first-created). Use GetWorktreesBySessionID when the caller
|
|
// needs all of them.
|
|
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)
|
|
}
|
|
|
|
// MultiRepoStore is an optional capability some stores implement to support
|
|
// multi-repo task sessions (one worktree per repository per session). The
|
|
// Manager checks at runtime whether its Store satisfies this interface and
|
|
// uses the multi-repo lookups when available.
|
|
type MultiRepoStore interface {
|
|
// GetWorktreesBySessionID returns all active worktrees for the session.
|
|
GetWorktreesBySessionID(ctx context.Context, sessionID string) ([]*Worktree, error)
|
|
// GetWorktreeBySessionAndRepository returns the active worktree for the
|
|
// given (session, repository) pair, or nil if none exists.
|
|
GetWorktreeBySessionAndRepository(ctx context.Context, sessionID, repositoryID string) (*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 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,
|
|
inspectTimeout: defaultGitInspectTimeout,
|
|
}, 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, repository) pair.
|
|
// Multi-repo sessions can host multiple worktrees concurrently, so we must
|
|
// scope the lookup by RepositoryID rather than session alone — otherwise
|
|
// the second repo's Create would return the first repo's worktree.
|
|
if req.SessionID != "" {
|
|
existing, err := m.GetBySessionAndRepo(ctx, req.SessionID, req.RepositoryID)
|
|
if err == nil && existing != nil {
|
|
if m.IsValid(existing.Path) {
|
|
m.logger.Debug("reusing existing worktree by session+repo",
|
|
zap.String("worktree_id", existing.ID),
|
|
zap.String("session_id", req.SessionID),
|
|
zap.String("repository_id", req.RepositoryID),
|
|
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("repository_id", req.RepositoryID),
|
|
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()
|
|
lockAcquired := time.Now()
|
|
defer func() {
|
|
repoLock.Unlock()
|
|
m.logger.Debug("released worktree repo lock",
|
|
zap.String("repository_path", req.RepositoryPath),
|
|
zap.Duration("held", time.Since(lockAcquired)))
|
|
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(ctx, req.RepositoryPath, baseRef) {
|
|
return nil, fmt.Errorf("%w: %s", ErrInvalidBaseBranch, baseRef)
|
|
}
|
|
|
|
// Worktrees are always placed under ~/.kandev/tasks/{taskDir}/{repo}/.
|
|
// Callers must populate TaskDirName and RepoName; the legacy flat layout
|
|
// has been removed, so a missing field is a programming error.
|
|
if req.TaskDirName == "" || req.RepoName == "" {
|
|
return nil, ErrTaskDirRequired
|
|
}
|
|
return m.createInTaskDir(ctx, req, baseRef)
|
|
}
|
|
|
|
// createInTaskDir creates a worktree inside the task directory structure:
|
|
// ~/.kandev/tasks/{taskDirName}/{repoName}/
|
|
//
|
|
// RepoName is sanitized to a single path segment so display names like
|
|
// "owner/repo" don't produce a nested subdirectory — that would push the
|
|
// worktree one level below the task root and break agentctl's sibling-based
|
|
// multi-repo detection.
|
|
func (m *Manager) createInTaskDir(ctx context.Context, req CreateRequest, baseRef string) (*Worktree, error) {
|
|
repoDir := SanitizeRepoDirName(req.RepoName)
|
|
if repoDir == "" {
|
|
return nil, ErrInvalidRepoName
|
|
}
|
|
worktreePath, err := m.config.TaskWorktreePath(req.TaskDirName, repoDir)
|
|
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. Use the non-interactive helper so
|
|
// this cannot hang on a credential prompt while Create holds repoLock.
|
|
verifyCmd := m.newNonInteractiveGitCmd(ctx, worktreePath, "rev-parse", "--verify", upstream)
|
|
if err := verifyCmd.Run(); err != nil {
|
|
return
|
|
}
|
|
cmd := m.newNonInteractiveGitCmd(ctx, worktreePath, "branch", "--set-upstream-to="+upstream, localBranch)
|
|
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(ctx, 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)
|
|
branchName = TaskBranchNameWithSuffix(req.TaskTitle, req.TaskID, prefix, branchSuffix)
|
|
} else {
|
|
// Fallback to task ID based naming
|
|
dirName = req.TaskID + "_" + dirSuffix
|
|
branchName = TaskBranchNameWithSuffix("", req.TaskID, prefix, branchSuffix)
|
|
}
|
|
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, repositoryID) so multi-repo sessions
|
|
// can hold multiple entries.
|
|
if req.SessionID != "" {
|
|
m.mu.Lock()
|
|
m.worktrees[cacheKey(req.SessionID, req.RepositoryID)] = 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
|
|
}
|
|
|
|
// cacheKey computes the cache key for a (sessionID, repositoryID) pair.
|
|
// Used by all read/write paths against m.worktrees so multi-repo sessions
|
|
// can hold multiple worktrees concurrently.
|
|
func cacheKey(sessionID, repositoryID string) string {
|
|
return sessionID + "|" + repositoryID
|
|
}
|
|
|
|
// firstCacheEntryForSession scans the cache for any entry belonging to
|
|
// sessionID. Returns the first match or nil. Caller must hold m.mu.
|
|
func (m *Manager) firstCacheEntryForSession(sessionID string) *Worktree {
|
|
prefix := sessionID + "|"
|
|
for k, wt := range m.worktrees {
|
|
if strings.HasPrefix(k, prefix) {
|
|
return wt
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetBySessionID returns one worktree for the session. For multi-repo sessions
|
|
// it returns whichever worktree happens to come first; callers that need a
|
|
// specific repo's worktree should use GetBySessionAndRepo, and callers that
|
|
// need them all should use GetAllBySessionID.
|
|
func (m *Manager) GetBySessionID(ctx context.Context, sessionID string) (*Worktree, error) {
|
|
// Check cache first
|
|
m.mu.RLock()
|
|
if wt := m.firstCacheEntryForSession(sessionID); wt != nil {
|
|
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 under the wt's repository key (or empty for legacy
|
|
// records that never had one).
|
|
m.mu.Lock()
|
|
m.worktrees[cacheKey(sessionID, wt.RepositoryID)] = wt
|
|
m.mu.Unlock()
|
|
return wt, nil
|
|
}
|
|
}
|
|
|
|
return nil, ErrWorktreeNotFound
|
|
}
|
|
|
|
// GetAllBySessionID returns all active worktrees for a session. Empty slice
|
|
// (not error) if the session has none.
|
|
func (m *Manager) GetAllBySessionID(ctx context.Context, sessionID string) ([]*Worktree, error) {
|
|
if m.store == nil {
|
|
// Cache-only path: scan our in-memory map.
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
prefix := sessionID + "|"
|
|
var out []*Worktree
|
|
for k, wt := range m.worktrees {
|
|
if strings.HasPrefix(k, prefix) {
|
|
out = append(out, wt)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
if multi, ok := m.store.(MultiRepoStore); ok {
|
|
wts, err := multi.GetWorktreesBySessionID(ctx, sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Refresh cache while we're at it.
|
|
m.mu.Lock()
|
|
for _, wt := range wts {
|
|
m.worktrees[cacheKey(sessionID, wt.RepositoryID)] = wt
|
|
}
|
|
m.mu.Unlock()
|
|
return wts, nil
|
|
}
|
|
// Single-repo store fallback: at most one worktree per session.
|
|
wt, err := m.store.GetWorktreeBySessionID(ctx, sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if wt == nil {
|
|
return nil, nil
|
|
}
|
|
return []*Worktree{wt}, nil
|
|
}
|
|
|
|
// GetBySessionAndRepo returns the active worktree for the (session, repo)
|
|
// pair, or ErrWorktreeNotFound if none exists. Falls back to GetBySessionID
|
|
// when the store does not implement MultiRepoStore (legacy single-repo).
|
|
func (m *Manager) GetBySessionAndRepo(ctx context.Context, sessionID, repositoryID string) (*Worktree, error) {
|
|
if sessionID == "" {
|
|
return nil, ErrWorktreeNotFound
|
|
}
|
|
// Check cache first.
|
|
m.mu.RLock()
|
|
if wt, ok := m.worktrees[cacheKey(sessionID, repositoryID)]; ok {
|
|
m.mu.RUnlock()
|
|
return wt, nil
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
if m.store == nil {
|
|
return nil, ErrWorktreeNotFound
|
|
}
|
|
if multi, ok := m.store.(MultiRepoStore); ok {
|
|
wt, err := multi.GetWorktreeBySessionAndRepository(ctx, sessionID, repositoryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if wt == nil {
|
|
return nil, ErrWorktreeNotFound
|
|
}
|
|
m.mu.Lock()
|
|
m.worktrees[cacheKey(sessionID, repositoryID)] = wt
|
|
m.mu.Unlock()
|
|
return wt, nil
|
|
}
|
|
// Legacy single-repo store: best we can do is the session lookup.
|
|
wt, err := m.store.GetWorktreeBySessionID(ctx, sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if wt == nil || (repositoryID != "" && wt.RepositoryID != repositoryID) {
|
|
return nil, ErrWorktreeNotFound
|
|
}
|
|
return wt, nil
|
|
}
|
|
|
|
// 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: delete the (session, repo) entry. Removing a worktree only
|
|
// affects its own repo; siblings on other repos must remain cached.
|
|
m.mu.Lock()
|
|
if wt.SessionID != "" {
|
|
delete(m.worktrees, cacheKey(wt.SessionID, wt.RepositoryID))
|
|
}
|
|
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, cacheKey(wt.SessionID, wt.RepositoryID))
|
|
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, cacheKey(wt.SessionID, wt.RepositoryID))
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
|
|
// 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.
|
|
// Bounded by m.inspectTimeout so a hung git (credential prompt, stuck filter,
|
|
// filesystem stall) cannot deadlock the caller while holding repoLock. When
|
|
// the bound fires, the ctx error is logged so the root cause is visible in
|
|
// logs rather than surfacing only as a misleading "branch not found".
|
|
func (m *Manager) branchExists(ctx context.Context, repoPath, branch string) bool {
|
|
inspectCtx, cancel := context.WithTimeout(ctx, m.inspectTimeout)
|
|
defer cancel()
|
|
cmd := m.newNonInteractiveGitCmd(inspectCtx, repoPath, "rev-parse", "--verify", branch)
|
|
if err := cmd.Run(); err != nil {
|
|
if ctxErr := inspectCtx.Err(); ctxErr != nil {
|
|
m.logger.Warn("branchExists bounded by context",
|
|
zap.String("repository_path", repoPath),
|
|
zap.String("branch", branch),
|
|
zap.Error(ctxErr))
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (m *Manager) currentBranch(ctx context.Context, repoPath string) string {
|
|
inspectCtx, cancel := context.WithTimeout(ctx, m.inspectTimeout)
|
|
defer cancel()
|
|
cmd := m.newNonInteractiveGitCmd(inspectCtx, repoPath, "rev-parse", "--abbrev-ref", "HEAD")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
if ctxErr := inspectCtx.Err(); ctxErr != nil {
|
|
m.logger.Warn("currentBranch bounded by context",
|
|
zap.String("repository_path", repoPath),
|
|
zap.Error(ctxErr))
|
|
}
|
|
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(ctx, repoPath) == baseBranch {
|
|
return m.pullCurrentBranchOrFallback(ctx, repoPath, baseBranch, remoteRef, stepName, onProgress)
|
|
}
|
|
if m.branchExists(ctx, 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)
|
|
}()
|
|
|
|
// Reuse the original on-disk path so the worktree is recreated in the
|
|
// same task-dir slot it was first created in.
|
|
worktreePath := existing.Path
|
|
if worktreePath == "" {
|
|
return nil, fmt.Errorf("cannot recreate worktree: existing record has no path")
|
|
}
|
|
|
|
// 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, repositoryID).
|
|
if req.SessionID != "" {
|
|
m.mu.Lock()
|
|
m.worktrees[cacheKey(req.SessionID, req.RepositoryID)] = 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
|
|
}
|