Files
kandev/apps/backend/internal/worktree/manager.go
T
2026-01-27 00:18:42 +00:00

838 lines
25 KiB
Go

package worktree
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
)
// 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]*sync.Mutex
repoLockMu sync.Mutex
// Optional dependencies for script execution
repoProvider RepositoryProvider
scriptMsgHandler ScriptMessageHandler
}
// 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)
}
return &Manager{
config: cfg,
logger: log.WithFields(zap.String("component", "worktree-manager")),
store: store,
worktrees: make(map[string]*Worktree),
repoLocks: make(map[string]*sync.Mutex),
}, 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.
func (m *Manager) getRepoLock(repoPath string) *sync.Mutex {
m.repoLockMu.Lock()
defer m.repoLockMu.Unlock()
if lock, exists := m.repoLocks[repoPath]; exists {
return lock
}
lock := &sync.Mutex{}
m.repoLocks[repoPath] = lock
return lock
}
// 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
}
// Check worktree limit
count, err := m.countWorktreesForRepo(ctx, req.RepositoryID)
if err != nil {
return nil, fmt.Errorf("failed to count worktrees: %w", err)
}
if count >= m.config.MaxPerRepo {
return nil, fmt.Errorf("%w: %d", ErrMaxWorktrees, m.config.MaxPerRepo)
}
// Get repository lock for safe concurrent access
repoLock := m.getRepoLock(req.RepositoryPath)
repoLock.Lock()
defer repoLock.Unlock()
baseRef := req.BaseBranch
if req.PullBeforeWorktree {
baseRef = m.pullBaseBranch(req.RepositoryPath, req.BaseBranch)
}
// Check base branch exists
if !m.branchExists(req.RepositoryPath, baseRef) {
return nil, fmt.Errorf("%w: %s", ErrInvalidBaseBranch, baseRef)
}
return m.createWorktree(ctx, req, baseRef)
}
// createWorktree performs the actual git worktree creation.
func (m *Manager) createWorktree(ctx context.Context, req CreateRequest, baseRef string) (*Worktree, error) {
// Generate a unique worktree ID and random suffix
worktreeID := uuid.New().String()
dirSuffix := worktreeID[:8] // Use first 8 chars of UUID for worktree dir uniqueness
branchSuffix := SmallSuffix(3)
prefix := NormalizeBranchPrefix(req.WorktreeBranchPrefix)
// Generate semantic name from task title if available
var worktreeDirName, branchName string
if req.TaskTitle != "" {
// Use semantic naming: {sanitized-title}_{suffix}
semanticName := SemanticWorktreeName(req.TaskTitle, dirSuffix)
worktreeDirName = semanticName
// For branch, use sanitized title (20 chars max) + suffix
sanitizedTitle := SanitizeForBranch(req.TaskTitle, 20)
if sanitizedTitle == "" {
sanitizedTitle = SanitizeForBranch(req.TaskID, 20)
}
branchName = prefix + sanitizedTitle + "-" + branchSuffix
} else {
// Fallback to task ID based naming
worktreeDirName = req.TaskID + "_" + dirSuffix
branchName = prefix + SanitizeForBranch(req.TaskID, 20) + "-" + branchSuffix
}
worktreePath, err := m.config.WorktreePath(worktreeDirName)
if err != nil {
return nil, fmt.Errorf("failed to get worktree path: %w", err)
}
// Create worktree with new branch
// git worktree add -b <branch> <path> <base-branch>
cmd := exec.CommandContext(ctx, "git", "worktree", "add",
"-b", branchName,
worktreePath,
baseRef)
cmd.Dir = req.RepositoryPath
output, err := cmd.CombinedOutput()
if err != nil {
m.logger.Error("git worktree add failed",
zap.String("output", string(output)),
zap.Error(err))
return nil, fmt.Errorf("%w: %s", ErrGitCommandFailed, string(output))
}
now := time.Now()
wt := &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,
}
// Persist to store
if m.store != nil {
if req.SessionID == "" {
m.logger.Warn("skipping worktree persistence: missing session_id",
zap.String("task_id", req.TaskID),
zap.String("worktree_id", wt.ID))
} else {
if err := m.store.CreateWorktree(ctx, wt); err != nil {
// Cleanup on 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 nil, fmt.Errorf("failed to persist worktree: %w", err)
}
}
}
// Update cache (keyed by sessionID)
if req.SessionID != "" {
m.mu.Lock()
m.worktrees[req.SessionID] = wt
m.mu.Unlock()
}
// Execute setup script if configured
if m.scriptMsgHandler != nil && m.repoProvider != 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))
} else if strings.TrimSpace(repo.SetupScript) != "" {
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 {
// Setup failed - cleanup worktree
m.logger.Error("setup script failed, cleaning up worktree",
zap.String("worktree_id", wt.ID),
zap.Error(err))
// Remove from cache
if wt.SessionID != "" {
m.mu.Lock()
delete(m.worktrees, wt.SessionID)
m.mu.Unlock()
}
// Remove directory
if cleanupErr := m.removeWorktreeDir(ctx, wt.Path, req.RepositoryPath); cleanupErr != nil {
m.logger.Warn("failed to cleanup worktree after setup failure",
zap.Error(cleanupErr))
}
// Mark as deleted in database
if m.store != nil {
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))
}
}
return nil, fmt.Errorf("setup script failed: %w", err)
}
m.logger.Info("setup script completed successfully",
zap.String("worktree_id", wt.ID))
}
}
m.logger.Info("created worktree",
zap.String("session_id", req.SessionID),
zap.String("task_id", req.TaskID),
zap.String("path", worktreePath),
zap.String("branch", branchName))
return wt, 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 repoLock.Unlock()
// Execute cleanup script BEFORE removing directory
if m.scriptMsgHandler != nil && m.repoProvider != nil {
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))
} else if strings.TrimSpace(repo.CleanupScript) != "" {
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 {
// Log error but continue with deletion
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))
}
}
}
// 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 {
m.logger.Warn("failed to update worktree status",
zap.Error(err))
}
}
// Update cache
m.mu.Lock()
delete(m.worktrees, wt.TaskID)
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
}
// 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
}
delete(m.worktrees, wt.TaskID)
}
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))
}
}
}
return nil
}
// 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))
}
// 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(repoPath, baseBranch string) string {
// Normalize branch name - strip "origin/" prefix if present
localBranch := strings.TrimPrefix(baseBranch, "origin/")
isRemoteRef := localBranch != baseBranch
// Fetch the branch from origin (30s timeout for fetch + potential pull)
fetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
fetchArgs := []string{"fetch", "origin"}
if localBranch != "" {
fetchArgs = append(fetchArgs, localBranch)
}
fetchCmd := exec.CommandContext(fetchCtx, "git", fetchArgs...)
fetchCmd.Dir = repoPath
if output, err := fetchCmd.CombinedOutput(); err != nil {
m.logger.Error("git fetch failed before worktree creation",
zap.String("branch", baseBranch),
zap.String("output", string(output)),
zap.Error(err))
return baseBranch
}
// If the original ref was already a remote ref, use it directly
if isRemoteRef {
return "origin/" + localBranch
}
// For local branches: try to update if we're on that branch, otherwise use origin/<branch>
remoteRef := "origin/" + localBranch
currentBranch := m.currentBranch(repoPath)
if currentBranch == baseBranch {
// We're on the target branch - try to pull (fast-forward only)
pullCmd := exec.CommandContext(fetchCtx, "git", "pull", "--ff-only", "origin", baseBranch)
pullCmd.Dir = repoPath
if output, err := pullCmd.CombinedOutput(); err != nil {
m.logger.Error("git pull failed before worktree creation",
zap.String("branch", baseBranch),
zap.String("output", string(output)),
zap.Error(err))
}
return baseBranch
}
// Not on the target branch - use the remote ref if it exists
if m.branchExists(repoPath, remoteRef) {
return remoteRef
}
return baseBranch
}
// countWorktreesForRepo counts active worktrees for a repository.
func (m *Manager) countWorktreesForRepo(ctx context.Context, repoID string) (int, error) {
if m.store == nil {
// Count from cache if no store
m.mu.RLock()
defer m.mu.RUnlock()
count := 0
for _, wt := range m.worktrees {
if wt.RepositoryID == repoID && wt.Status == StatusActive {
count++
}
}
return count, nil
}
worktrees, err := m.store.GetWorktreesByRepositoryID(ctx, repoID)
if err != nil {
return 0, err
}
count := 0
for _, wt := range worktrees {
if wt.Status == StatusActive {
count++
}
}
return count, nil
}
// 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))
// Fallback to direct removal
if err := os.RemoveAll(worktreePath); err != nil {
return err
}
// Prune stale worktree entries
cmd = exec.CommandContext(ctx, "git", "worktree", "prune")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
m.logger.Debug("git worktree prune failed", zap.Error(err))
}
}
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 repoLock.Unlock()
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
cmd = exec.CommandContext(ctx, "git", "worktree", "add",
worktreePath,
existing.Branch)
cmd.Dir = req.RepositoryPath
if output, err := cmd.CombinedOutput(); err != nil {
m.logger.Error("failed to recreate worktree",
zap.String("output", string(output)),
zap.Error(err))
return nil, fmt.Errorf("%w: %s", ErrGitCommandFailed, string(output))
}
// 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
}