diff --git a/internal/polecat/manager.go b/internal/polecat/manager.go index 23389c3a..6e93d50e 100644 --- a/internal/polecat/manager.go +++ b/internal/polecat/manager.go @@ -322,6 +322,13 @@ func (m *Manager) AddWithOptions(name string, opts AddOptions) (*Polecat, error) fmt.Printf("Warning: could not copy overlay files: %v\n", err) } + // Run setup hooks from .runtime/setup-hooks/. + // These hooks can inject local git config, copy secrets, or perform other setup tasks. + if err := rig.RunSetupHooks(m.rig.Path, clonePath); err != nil { + // Non-fatal - log warning but continue + fmt.Printf("Warning: could not run setup hooks: %v\n", err) + } + // NOTE: Slash commands (.claude/commands/) are provisioned at town level by gt install. // All agents inherit them via Claude's directory traversal - no per-workspace copies needed. diff --git a/internal/rig/setuphooks.go b/internal/rig/setuphooks.go new file mode 100644 index 00000000..12e68cf7 --- /dev/null +++ b/internal/rig/setuphooks.go @@ -0,0 +1,114 @@ +// Package rig provides rig-level configuration and utilities. +package rig + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" +) + +// RunSetupHooks executes setup hooks found in /.runtime/setup-hooks/. +// These hooks run in the context of the newly created worktree and can inject +// local configurations, run custom scripts, or perform other setup tasks. +// +// Hook Execution Order: +// Hooks are executed in alphabetical order by filename. Each hook is run +// with the worktree path as its working directory. +// +// Hook Requirements: +// - Hooks must be executable (chmod +x) +// - Hooks can be shell scripts, binaries, or any executable file +// - Non-executable files are skipped with a warning +// - Hook failures are logged as warnings but don't stop worktree creation +// +// Directory Structure: +// +// rig/ +// .runtime/ +// setup-hooks/ +// 01-git-config.sh <- Run first +// 02-copy-secrets.sh <- Run second +// 99-finalize.sh <- Run last +// +// Returns nil if the setup-hooks directory doesn't exist (nothing to run). +// Individual hook failures are logged as warnings but don't fail the overall operation. +func RunSetupHooks(rigPath, worktreePath string) error { + hooksDir := filepath.Join(rigPath, ".runtime", "setup-hooks") + + // Check if setup-hooks directory exists + entries, err := os.ReadDir(hooksDir) + if err != nil { + if os.IsNotExist(err) { + // No setup-hooks directory - not an error, just nothing to run + return nil + } + return fmt.Errorf("reading setup-hooks dir: %w", err) + } + + if len(entries) == 0 { + // Directory exists but is empty - nothing to run + return nil + } + + // Sort hooks alphabetically for consistent execution order + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + + // Execute each hook + for _, entry := range entries { + if entry.IsDir() { + // Skip subdirectories + continue + } + + hookPath := filepath.Join(hooksDir, entry.Name()) + + // Check if file is executable + info, err := entry.Info() + if err != nil { + fmt.Printf("Warning: could not stat hook %s: %v\n", entry.Name(), err) + continue + } + + // Skip non-executable files (warn user) + if info.Mode().Perm()&0111 == 0 { + fmt.Printf("Warning: skipping non-executable hook %s (use chmod +x to make it executable)\n", entry.Name()) + continue + } + + // Execute the hook + if err := runHook(hookPath, worktreePath); err != nil { + // Log warning but continue - don't fail spawn for hook failures + fmt.Printf("Warning: setup hook %s failed: %v\n", entry.Name(), err) + continue + } + + fmt.Printf("Ran setup hook: %s\n", entry.Name()) + } + + return nil +} + +// runHook executes a single hook script in the context of the worktree. +// The hook is run with: +// - Working directory set to worktreePath +// - Environment variable GT_WORKTREE_PATH pointing to the worktree +// - Environment variable GT_RIG_PATH pointing to the rig +func runHook(hookPath, worktreePath string) error { + // Get the rig path from the hook path (strip .runtime/setup-hooks/) + rigPath := filepath.Dir(filepath.Dir(filepath.Dir(hookPath))) + + cmd := exec.Command(hookPath) + cmd.Dir = worktreePath + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = append(os.Environ(), + fmt.Sprintf("GT_WORKTREE_PATH=%s", worktreePath), + fmt.Sprintf("GT_RIG_PATH=%s", rigPath), + ) + + return cmd.Run() +}