diff --git a/README.md b/README.md index 7bc0e54..6f9a0e8 100644 --- a/README.md +++ b/README.md @@ -211,8 +211,25 @@ hooks: - type: command command: "make db:setup" work_dir: "." + + pre_remove: + # Run before git worktree remove + - type: command + command: "echo before remove" + + post_remove: + # Run after git worktree remove + - type: command + command: "echo after remove" ``` +`pre_remove` and `post_remove` run before and after `git worktree remove`. +`pre_remove` resolves `from` relative to the target worktree and `to` relative +to the repository root. Command hooks in `pre_remove` default to running inside +the worktree (unless `work_dir` is set). +`post_remove` defaults `work_dir` to the repository root, and any relative +`work_dir` is resolved from the repository root because the worktree is gone. + ### Copy Hooks: Main Worktree Reference Copy hooks are designed to help you bootstrap new worktrees using files from diff --git a/cmd/wtp/add.go b/cmd/wtp/add.go index 582dcbe..78880c2 100644 --- a/cmd/wtp/add.go +++ b/cmd/wtp/add.go @@ -333,7 +333,7 @@ Original error: %v`, e.BranchName, e.BranchName, e.BranchName, e.BranchName, e.B } func executePostCreateHooks(w io.Writer, cfg *config.Config, repoPath, workTreePath string) error { - if cfg.HasHooks() { + if cfg.HasPostCreateHooks() { if _, err := fmt.Fprintln(w, "\nExecuting post-create hooks..."); err != nil { return err } diff --git a/cmd/wtp/init.go b/cmd/wtp/init.go index f00b046..2419a9b 100644 --- a/cmd/wtp/init.go +++ b/cmd/wtp/init.go @@ -99,6 +99,16 @@ hooks: # command: npm install # - type: command # command: echo "Created new worktree!" + + # Hooks that run before removing a worktree + pre_remove: + # - type: command + # command: echo "Removing worktree..." + + # Hooks that run after removing a worktree + post_remove: + # - type: command + # command: echo "Removed worktree!" ` if err := ensureWritableDirectory(repo.Path()); err != nil { diff --git a/cmd/wtp/init_test.go b/cmd/wtp/init_test.go index 4ec0571..8a6caef 100644 --- a/cmd/wtp/init_test.go +++ b/cmd/wtp/init_test.go @@ -141,6 +141,8 @@ func TestInitCommand_Success(t *testing.T) { assert.Contains(t, contentStr, "base_dir: ../worktrees") assert.Contains(t, contentStr, "hooks:") assert.Contains(t, contentStr, "post_create:") + assert.Contains(t, contentStr, "pre_remove:") + assert.Contains(t, contentStr, "post_remove:") // Check for example hooks assert.Contains(t, contentStr, "type: copy") @@ -154,6 +156,8 @@ func TestInitCommand_Success(t *testing.T) { assert.Contains(t, contentStr, "# Worktree Plus Configuration") assert.Contains(t, contentStr, "# Default settings for worktrees") assert.Contains(t, contentStr, "# Hooks that run after creating a worktree") + assert.Contains(t, contentStr, "# Hooks that run before removing a worktree") + assert.Contains(t, contentStr, "# Hooks that run after removing a worktree") } func TestInitCommand_DirectoryAccessError(t *testing.T) { diff --git a/cmd/wtp/remove.go b/cmd/wtp/remove.go index 4bddf8c..524f6c0 100644 --- a/cmd/wtp/remove.go +++ b/cmd/wtp/remove.go @@ -16,6 +16,7 @@ import ( "github.com/satococoa/wtp/v2/internal/config" "github.com/satococoa/wtp/v2/internal/errors" "github.com/satococoa/wtp/v2/internal/git" + "github.com/satococoa/wtp/v2/internal/hooks" ) // Variable to allow mocking in tests @@ -130,6 +131,17 @@ func removeCommandWithCommandExecutor( return errors.CannotRemoveCurrentWorktree(worktreeName, absTargetPath) } + mainRepoPath := findMainWorktreePath(worktrees) + cfg, err := config.LoadConfig(mainRepoPath) + if err != nil { + configPath := mainRepoPath + "/" + config.ConfigFileName + return errors.ConfigLoadFailed(configPath, err) + } + + if err := executePreRemoveHooks(w, cfg, mainRepoPath, absTargetPath); err != nil { + return err + } + // Remove worktree using CommandExecutor removeCmd := command.GitWorktreeRemove(targetWorktree.Path, force) result, err = executor.Execute([]command.Command{removeCmd}) @@ -148,6 +160,10 @@ func removeCommandWithCommandExecutor( return err } + if err := executePostRemoveHooks(w, cfg, mainRepoPath); err != nil { + return err + } + // Remove branch if requested if withBranch && targetWorktree.Branch != "" { if err := removeBranchWithCommandExecutor(w, executor, targetWorktree.Branch, forceBranch); err != nil { @@ -158,6 +174,48 @@ func removeCommandWithCommandExecutor( return nil } +func executePreRemoveHooks(w io.Writer, cfg *config.Config, repoPath, worktreePath string) error { + if !cfg.HasPreRemoveHooks() { + return nil + } + + if _, err := fmt.Fprintln(w, "\nExecuting pre-remove hooks..."); err != nil { + return err + } + + executor := hooks.NewExecutor(cfg, repoPath) + if err := executor.ExecutePreRemoveHooks(w, worktreePath); err != nil { + return err + } + + if _, err := fmt.Fprintln(w, "āœ“ All hooks executed successfully"); err != nil { + return err + } + + return nil +} + +func executePostRemoveHooks(w io.Writer, cfg *config.Config, repoPath string) error { + if !cfg.HasPostRemoveHooks() { + return nil + } + + if _, err := fmt.Fprintln(w, "\nExecuting post-remove hooks..."); err != nil { + return err + } + + executor := hooks.NewExecutor(cfg, repoPath) + if err := executor.ExecutePostRemoveHooks(w); err != nil { + return err + } + + if _, err := fmt.Fprintln(w, "āœ“ All hooks executed successfully"); err != nil { + return err + } + + return nil +} + func validateRemoveInput(worktreeName string, withBranch, forceBranch bool) error { if worktreeName == "" { return errors.WorktreeNameRequiredForRemove() diff --git a/cmd/wtp/remove_test.go b/cmd/wtp/remove_test.go index 26f1e2c..6dc1b3d 100644 --- a/cmd/wtp/remove_test.go +++ b/cmd/wtp/remove_test.go @@ -6,12 +6,14 @@ import ( "fmt" "os" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" "github.com/urfave/cli/v3" "github.com/satococoa/wtp/v2/internal/command" + "github.com/satococoa/wtp/v2/internal/config" ) // ===== Command Structure Tests ===== @@ -297,6 +299,165 @@ func TestRemoveCommand_SuccessMessage(t *testing.T) { } } +func TestRemoveCommand_ExecutePreRemoveHooks(t *testing.T) { + tempDir := t.TempDir() + mainRepoPath := filepath.Join(tempDir, "repo") + worktreePath := filepath.Join(tempDir, "worktrees", "feature-hook") + + err := os.MkdirAll(mainRepoPath, 0o755) + assert.NoError(t, err) + err = os.MkdirAll(worktreePath, 0o755) + assert.NoError(t, err) + + configPath := filepath.Join(mainRepoPath, ".wtp.yml") + configContent := `version: "1.0" +defaults: + base_dir: "../worktrees" +hooks: + pre_remove: + - type: command + command: "echo before remove" +` + err = os.WriteFile(configPath, []byte(configContent), 0o644) + assert.NoError(t, err) + + mockExec := &mockRemoveCommandExecutor{ + results: []command.Result{ + { + Output: fmt.Sprintf("worktree %s\nHEAD abc123\nbranch refs/heads/main\n\nworktree %s\nHEAD def456\nbranch refs/heads/feature-hook\n\n", mainRepoPath, worktreePath), + Error: nil, + }, + { + Output: "success", + Error: nil, + }, + }, + } + + cmd := createRemoveTestCLICommand(map[string]any{}, []string{"feature-hook"}) + var buf bytes.Buffer + + err = removeCommandWithCommandExecutor(cmd, &buf, mockExec, mainRepoPath, "feature-hook", false, false, false) + + assert.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "Executing pre-remove hooks") + assert.Contains(t, output, "before remove") + assert.Contains(t, output, "Removed worktree") +} + +func TestRemoveCommand_ExecutePostRemoveHooks(t *testing.T) { + tempDir := t.TempDir() + mainRepoPath := filepath.Join(tempDir, "repo") + worktreePath := filepath.Join(tempDir, "worktrees", "feature-hook") + + err := os.MkdirAll(mainRepoPath, 0o755) + assert.NoError(t, err) + err = os.MkdirAll(worktreePath, 0o755) + assert.NoError(t, err) + + configPath := filepath.Join(mainRepoPath, ".wtp.yml") + configContent := `version: "1.0" +defaults: + base_dir: "../worktrees" +hooks: + post_remove: + - type: command + command: "echo after remove" +` + err = os.WriteFile(configPath, []byte(configContent), 0o644) + assert.NoError(t, err) + + mockExec := &mockRemoveCommandExecutor{ + results: []command.Result{ + { + Output: fmt.Sprintf("worktree %s\nHEAD abc123\nbranch refs/heads/main\n\nworktree %s\nHEAD def456\nbranch refs/heads/feature-hook\n\n", mainRepoPath, worktreePath), + Error: nil, + }, + { + Output: "success", + Error: nil, + }, + }, + } + + cmd := createRemoveTestCLICommand(map[string]any{}, []string{"feature-hook"}) + var buf bytes.Buffer + + err = removeCommandWithCommandExecutor(cmd, &buf, mockExec, mainRepoPath, "feature-hook", false, false, false) + + assert.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "Executing post-remove hooks") + assert.Contains(t, output, "after remove") + assert.Contains(t, output, "Removed worktree") +} + +func TestExecutePostRemoveHooks_DefaultWorkDir(t *testing.T) { + tempDir := t.TempDir() + repoPath := filepath.Join(tempDir, "repo") + err := os.MkdirAll(repoPath, 0o755) + assert.NoError(t, err) + err = os.MkdirAll(filepath.Join(repoPath, "scripts"), 0o755) + assert.NoError(t, err) + + cmdStr := "pwd" + if runtime.GOOS == "windows" { + cmdStr = "cd" + } + + cfg := &config.Config{ + Defaults: config.Defaults{ + BaseDir: "../worktrees", + }, + Hooks: config.Hooks{ + PostRemove: []config.Hook{ + { + Type: config.HookTypeCommand, + Command: cmdStr, + WorkDir: "scripts", + }, + }, + }, + } + + var buf bytes.Buffer + err = executePostRemoveHooks(&buf, cfg, repoPath) + + assert.NoError(t, err) + assert.Contains(t, buf.String(), "Executing post-remove hooks") + assert.Contains(t, buf.String(), filepath.Join(repoPath, "scripts")) +} + +func TestExecutePostRemoveHooks_WorkDirTraversalRejected(t *testing.T) { + tempDir := t.TempDir() + repoPath := filepath.Join(tempDir, "repo") + + cfg := &config.Config{ + Defaults: config.Defaults{ + BaseDir: "../worktrees", + }, + Hooks: config.Hooks{ + PostRemove: []config.Hook{ + { + Type: config.HookTypeCommand, + Command: "echo should-not-run", + WorkDir: filepath.Join("..", ".."), + }, + }, + }, + } + + var buf bytes.Buffer + + err := executePostRemoveHooks(&buf, cfg, repoPath) + + assert.Error(t, err) + assert.EqualError(t, err, fmt.Sprintf("post-remove hook work_dir '%s' escapes repository root", filepath.Join("..", ".."))) + assert.Contains(t, buf.String(), "Executing post-remove hooks") + assert.NotContains(t, buf.String(), "should-not-run") +} + // ===== Error Handling Tests ===== func TestRemoveCommand_ValidationErrors(t *testing.T) { @@ -391,6 +552,34 @@ func TestRemoveCommand_WorktreeNotFound_ShowsConsistentNames(t *testing.T) { assert.Contains(t, err.Error(), "No worktrees found") } +func TestRemoveCommand_ConfigLoadFailure(t *testing.T) { + tempDir := t.TempDir() + mainRepoPath := filepath.Join(tempDir, "repo") + worktreePath := filepath.Join(tempDir, "worktrees", "feature-bad-config") + + err := os.MkdirAll(mainRepoPath, 0o755) + assert.NoError(t, err) + err = os.WriteFile(filepath.Join(mainRepoPath, ".wtp.yml"), []byte("hooks:\n post_create:\n - type: command\n command: \"oops\"\n invalid"), 0o644) + assert.NoError(t, err) + + mockExec := &mockRemoveCommandExecutor{ + results: []command.Result{ + { + Output: fmt.Sprintf("worktree %s\nHEAD abc123\nbranch refs/heads/main\n\nworktree %s\nHEAD def456\nbranch refs/heads/feature-bad-config\n\n", mainRepoPath, worktreePath), + Error: nil, + }, + }, + } + + cmd := createRemoveTestCLICommand(map[string]any{}, []string{"feature-bad-config"}) + var buf bytes.Buffer + + err = removeCommandWithCommandExecutor(cmd, &buf, mockExec, mainRepoPath, "feature-bad-config", false, false, false) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to load configuration") +} + func TestRemoveCommand_FailsWhenRemovingCurrentWorktree(t *testing.T) { targetPath := "/worktrees/feature/foo" mockWorktreeList := fmt.Sprintf( diff --git a/docs/architecture.md b/docs/architecture.md index c4feabd..c552b76 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,13 +113,19 @@ hooks: - type: command command: "npm install" work_dir: "." + pre_remove: + - type: command + command: "echo before remove" + post_remove: + - type: command + command: "echo after remove" ``` ## Hook System ### Design Philosophy -Post-create hooks support: +Hooks support: - File copying (for .env files, etc.) - Command execution diff --git a/internal/config/config.go b/internal/config/config.go index 075690d..4427acd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,9 +21,11 @@ type Defaults struct { BaseDir string `yaml:"base_dir,omitempty"` } -// Hooks represents the post-create hooks configuration +// Hooks represents the hooks configuration type Hooks struct { PostCreate []Hook `yaml:"post_create,omitempty"` + PreRemove []Hook `yaml:"pre_remove,omitempty"` + PostRemove []Hook `yaml:"post_remove,omitempty"` } // Hook represents a single hook configuration @@ -125,12 +127,25 @@ func (c *Config) Validate() error { } // Validate hooks - for i, hook := range c.Hooks.PostCreate { + if err := validateHooks("post_create", c.Hooks.PostCreate); err != nil { + return err + } + if err := validateHooks("pre_remove", c.Hooks.PreRemove); err != nil { + return err + } + if err := validateHooks("post_remove", c.Hooks.PostRemove); err != nil { + return err + } + + return nil +} + +func validateHooks(name string, hooks []Hook) error { + for i, hook := range hooks { if err := hook.Validate(); err != nil { - return fmt.Errorf("invalid hook %d: %w", i+1, err) + return fmt.Errorf("invalid %s hook %d: %w", name, i+1, err) } } - return nil } @@ -158,11 +173,21 @@ func (h *Hook) Validate() error { return nil } -// HasHooks returns true if the configuration has any post-create hooks -func (c *Config) HasHooks() bool { +// HasPostCreateHooks returns true if the configuration has any post-create hooks +func (c *Config) HasPostCreateHooks() bool { return len(c.Hooks.PostCreate) > 0 } +// HasPreRemoveHooks returns true if the configuration has any pre-remove hooks +func (c *Config) HasPreRemoveHooks() bool { + return len(c.Hooks.PreRemove) > 0 +} + +// HasPostRemoveHooks returns true if the configuration has any post-remove hooks +func (c *Config) HasPostRemoveHooks() bool { + return len(c.Hooks.PostRemove) > 0 +} + // ResolveWorktreePath resolves the full path for a worktree given a name func (c *Config) ResolveWorktreePath(repoRoot, worktreeName string) string { baseDir := c.Defaults.BaseDir diff --git a/internal/config/config_test.go b/internal/config/config_test.go index fdac0cb..374eb86 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -37,6 +37,12 @@ hooks: to: ".env" - type: command command: "echo test" + pre_remove: + - type: command + command: "echo before remove" + post_remove: + - type: command + command: "echo after remove" ` err := os.WriteFile(configPath, []byte(configContent), 0644) @@ -60,6 +66,12 @@ hooks: if len(config.Hooks.PostCreate) != 2 { t.Errorf("Expected 2 hooks, got %d", len(config.Hooks.PostCreate)) } + if len(config.Hooks.PreRemove) != 1 { + t.Errorf("Expected 1 pre-remove hook, got %d", len(config.Hooks.PreRemove)) + } + if len(config.Hooks.PostRemove) != 1 { + t.Errorf("Expected 1 post-remove hook, got %d", len(config.Hooks.PostRemove)) + } if config.Hooks.PostCreate[0].Type != HookTypeCopy { t.Errorf("Expected first hook type 'copy', got %s", config.Hooks.PostCreate[0].Type) @@ -161,6 +173,18 @@ func TestConfigValidate(t *testing.T) { To: ".env", }, }, + PreRemove: []Hook{ + { + Type: HookTypeCommand, + Command: "echo before remove", + }, + }, + PostRemove: []Hook{ + { + Type: HookTypeCommand, + Command: "echo after remove", + }, + }, }, }, expectError: false, @@ -201,7 +225,7 @@ func TestConfigValidate(t *testing.T) { config: &Config{ Version: "1.0", Hooks: Hooks{ - PostCreate: []Hook{ + PreRemove: []Hook{ { Type: HookTypeCommand, // Missing Command field - should cause validation error @@ -377,7 +401,7 @@ func TestResolveWorktreePath(t *testing.T) { } } -func TestHasHooks(t *testing.T) { +func TestHasPostCreateHooks(t *testing.T) { tests := []struct { name string config *Config @@ -414,7 +438,75 @@ func TestHasHooks(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := tt.config.HasHooks() + result := tt.config.HasPostCreateHooks() + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestHasPreRemoveHooks(t *testing.T) { + tests := []struct { + name string + config *Config + expected bool + }{ + { + name: "config with pre-remove hooks", + config: &Config{ + Hooks: Hooks{ + PreRemove: []Hook{ + {Type: HookTypeCommand, Command: "echo before remove"}, + }, + }, + }, + expected: true, + }, + { + name: "config without pre-remove hooks", + config: &Config{Hooks: Hooks{}}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.config.HasPreRemoveHooks() + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestHasPostRemoveHooks(t *testing.T) { + tests := []struct { + name string + config *Config + expected bool + }{ + { + name: "config with post-remove hooks", + config: &Config{ + Hooks: Hooks{ + PostRemove: []Hook{ + {Type: HookTypeCommand, Command: "echo after remove"}, + }, + }, + }, + expected: true, + }, + { + name: "config without post-remove hooks", + config: &Config{Hooks: Hooks{}}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.config.HasPostRemoveHooks() if result != tt.expected { t.Errorf("Expected %v, got %v", tt.expected, result) } diff --git a/internal/hooks/executor.go b/internal/hooks/executor.go index 6e90742..4790ef4 100644 --- a/internal/hooks/executor.go +++ b/internal/hooks/executor.go @@ -1,4 +1,4 @@ -// Package hooks handles executing post-create hooks for worktrees. +// Package hooks handles executing hooks for worktrees. package hooks import ( @@ -33,20 +33,88 @@ func NewExecutor(cfg *config.Config, repoRoot string) *Executor { } } -// ExecutePostCreateHooks executes all post-create hooks and streams output to writer +// ExecutePostCreateHooks executes all post-create hooks and streams output to writer. +// Relative paths are resolved from the worktree path. func (e *Executor) ExecutePostCreateHooks(w io.Writer, worktreePath string) error { - if e.config == nil || !e.config.HasHooks() { + if e.config == nil || !e.config.HasPostCreateHooks() { return nil } + return e.executeHooksWithWriter( + w, + e.config.Hooks.PostCreate, + e.repoRoot, // copy source base path + worktreePath, // copy destination base path + worktreePath, // command base path + ) +} + +// ExecutePreRemoveHooks executes all pre-remove hooks and streams output to writer. +// Relative "from" paths resolve from the target worktree, while "to" paths resolve +// from the repository root. +func (e *Executor) ExecutePreRemoveHooks(w io.Writer, worktreePath string) error { + if e.config == nil || !e.config.HasPreRemoveHooks() { + return nil + } + return e.executeHooksWithWriter( + w, + e.config.Hooks.PreRemove, + worktreePath, // copy source base path + e.repoRoot, // copy destination base path (dest) + worktreePath, // command base path (execute in worktree) + ) +} + +// ExecutePostRemoveHooks executes all post-remove hooks and streams output to writer. +// Relative paths are resolved from the repository root unless configured otherwise. +func (e *Executor) ExecutePostRemoveHooks(w io.Writer) error { + if e.config == nil || !e.config.HasPostRemoveHooks() { + return nil + } + + postRemoveHooks := make([]config.Hook, len(e.config.Hooks.PostRemove)) + for i, hook := range e.config.Hooks.PostRemove { + postRemoveHooks[i] = hook + if hook.WorkDir == "" { + postRemoveHooks[i].WorkDir = e.repoRoot + continue + } + if filepath.IsAbs(hook.WorkDir) { + continue + } + + resolvedWorkDir := filepath.Join(e.repoRoot, hook.WorkDir) + cleanWorkDir := filepath.Clean(resolvedWorkDir) + if err := ensureWithinBase(e.repoRoot, cleanWorkDir); err != nil { + return fmt.Errorf("post-remove hook work_dir '%s' escapes repository root", hook.WorkDir) + } + + postRemoveHooks[i].WorkDir = cleanWorkDir + } + + return e.executeHooksWithWriter( + w, + postRemoveHooks, + e.repoRoot, // copy source base path + e.repoRoot, // copy destination base path + e.repoRoot, // command base path + ) +} - totalHooks := len(e.config.Hooks.PostCreate) - for i, hook := range e.config.Hooks.PostCreate { +func (e *Executor) executeHooksWithWriter( + w io.Writer, + hooks []config.Hook, + copySourceBasePath string, + copyDestinationBasePath string, + commandBasePath string, +) error { + totalHooks := len(hooks) + for i, hook := range hooks { // Log which hook is starting if _, err := fmt.Fprintf(w, "\n→ Running hook %d of %d...\n", i+1, totalHooks); err != nil { return err } - if err := e.executeHookWithWriter(w, &hook, worktreePath); err != nil { + if err := e.executeHookWithWriter(w, &hook, copySourceBasePath, copyDestinationBasePath, commandBasePath); err != nil { return fmt.Errorf("failed to execute hook %d: %w", i+1, err) } @@ -60,39 +128,45 @@ func (e *Executor) ExecutePostCreateHooks(w io.Writer, worktreePath string) erro } // executeHookWithWriter executes a single hook with output directed to writer -func (e *Executor) executeHookWithWriter(w io.Writer, hook *config.Hook, worktreePath string) error { +func (e *Executor) executeHookWithWriter( + w io.Writer, + hook *config.Hook, + copySourceBasePath string, + copyDestinationBasePath string, + commandBasePath string, +) error { switch hook.Type { case config.HookTypeCopy: - return e.executeCopyHookWithWriter(w, hook, worktreePath) + return e.executeCopyHookWithWriter(w, hook, copySourceBasePath, copyDestinationBasePath) case config.HookTypeCommand: - return e.executeCommandHookWithWriter(w, hook, worktreePath) + return e.executeCommandHookWithWriter(w, hook, commandBasePath) default: return fmt.Errorf("unknown hook type: %s", hook.Type) } } // executeCopyHookWithWriter executes a copy hook with output directed to writer -func (e *Executor) executeCopyHookWithWriter(w io.Writer, hook *config.Hook, worktreePath string) error { - // Resolve source path (relative to repo root) +func (e *Executor) executeCopyHookWithWriter(w io.Writer, hook *config.Hook, sourceBasePath, destinationBasePath string) error { + // Resolve source path (relative to source base path) srcPath := hook.From if !filepath.IsAbs(srcPath) { - srcPath = filepath.Join(e.repoRoot, srcPath) + srcPath = filepath.Join(sourceBasePath, srcPath) } srcPath = filepath.Clean(srcPath) if !filepath.IsAbs(hook.From) { - if err := ensureWithinBase(e.repoRoot, srcPath); err != nil { + if err := ensureWithinBase(sourceBasePath, srcPath); err != nil { return err } } - // Resolve destination path (relative to worktree) + // Resolve destination path (relative to destination base path) dstPath := hook.To if !filepath.IsAbs(dstPath) { - dstPath = filepath.Join(worktreePath, dstPath) + dstPath = filepath.Join(destinationBasePath, dstPath) } dstPath = filepath.Clean(dstPath) if !filepath.IsAbs(hook.To) { - if err := ensureWithinBase(worktreePath, dstPath); err != nil { + if err := ensureWithinBase(destinationBasePath, dstPath); err != nil { return err } } @@ -110,8 +184,8 @@ func (e *Executor) executeCopyHookWithWriter(w io.Writer, hook *config.Hook, wor } // Log the copy operation to writer - relSrc, _ := filepath.Rel(e.repoRoot, srcPath) - relDst, _ := filepath.Rel(worktreePath, dstPath) + relSrc, _ := filepath.Rel(sourceBasePath, srcPath) + relDst, _ := filepath.Rel(destinationBasePath, dstPath) if _, err := fmt.Fprintf(w, " Copying: %s → %s\n", relSrc, relDst); err != nil { return err } @@ -136,7 +210,7 @@ func ensureWithinBase(base, target string) error { } // executeCommandHookWithWriter executes a command hook with output directed to writer -func (e *Executor) executeCommandHookWithWriter(w io.Writer, hook *config.Hook, worktreePath string) error { +func (e *Executor) executeCommandHookWithWriter(w io.Writer, hook *config.Hook, basePath string) error { // Execute command using shell for unified command format var cmd *exec.Cmd if runtime.GOOS == windowsOS { @@ -150,9 +224,9 @@ func (e *Executor) executeCommandHookWithWriter(w io.Writer, hook *config.Hook, // Set working directory workDir := hook.WorkDir if workDir == "" { - workDir = worktreePath + workDir = basePath } else if !filepath.IsAbs(workDir) { - workDir = filepath.Join(worktreePath, workDir) + workDir = filepath.Join(basePath, workDir) } cmd.Dir = workDir @@ -171,7 +245,7 @@ func (e *Executor) executeCommandHookWithWriter(w io.Writer, hook *config.Hook, // Add worktree-specific environment variables cmd.Env = append(cmd.Env, - fmt.Sprintf("GIT_WTP_WORKTREE_PATH=%s", worktreePath), + fmt.Sprintf("GIT_WTP_WORKTREE_PATH=%s", basePath), fmt.Sprintf("GIT_WTP_REPO_ROOT=%s", e.repoRoot)) // Log the command execution to writer diff --git a/internal/hooks/executor_test.go b/internal/hooks/executor_test.go index 4fa2970..d77bba8 100644 --- a/internal/hooks/executor_test.go +++ b/internal/hooks/executor_test.go @@ -24,6 +24,13 @@ func TestExecutePostCreateHooks_NilConfig(t *testing.T) { assert.NoError(t, err) } +func TestExecutePreRemoveHooks_NilConfig(t *testing.T) { + executor := NewExecutor(nil, "/test/repo") + var buf bytes.Buffer + err := executor.ExecutePreRemoveHooks(&buf, "/test/worktree") + assert.NoError(t, err) +} + func TestExecutePostCreateHooks_NoHooks(t *testing.T) { cfg := &config.Config{ Hooks: config.Hooks{ @@ -36,6 +43,88 @@ func TestExecutePostCreateHooks_NoHooks(t *testing.T) { assert.NoError(t, err) } +func TestExecutePreRemoveHooks_NoHooks(t *testing.T) { + cfg := &config.Config{ + Hooks: config.Hooks{ + PreRemove: []config.Hook{}, + }, + } + executor := NewExecutor(cfg, "/test/repo") + var buf bytes.Buffer + err := executor.ExecutePreRemoveHooks(&buf, "/test/worktree") + assert.NoError(t, err) +} + +func TestExecutePostRemoveHooks_NilConfig(t *testing.T) { + executor := NewExecutor(nil, "/test/repo") + var buf bytes.Buffer + err := executor.ExecutePostRemoveHooks(&buf) + assert.NoError(t, err) +} + +func TestExecutePostRemoveHooks_NoHooks(t *testing.T) { + cfg := &config.Config{ + Hooks: config.Hooks{ + PostRemove: []config.Hook{}, + }, + } + executor := NewExecutor(cfg, "/test/repo") + var buf bytes.Buffer + err := executor.ExecutePostRemoveHooks(&buf) + assert.NoError(t, err) +} + +func TestExecutePreRemoveHooks_ResolveRelativePathsFromWorktree(t *testing.T) { + tempDir := t.TempDir() + repoRoot := filepath.Join(tempDir, "repo") + worktreeDir := filepath.Join(tempDir, "worktree") + scriptsDir := filepath.Join(worktreeDir, "scripts") + + err := os.MkdirAll(repoRoot, directoryPermissions) + require.NoError(t, err) + err = os.MkdirAll(worktreeDir, directoryPermissions) + require.NoError(t, err) + err = os.MkdirAll(scriptsDir, directoryPermissions) + require.NoError(t, err) + + srcFile := filepath.Join(worktreeDir, "original.file") + err = os.WriteFile(srcFile, []byte("test"), 0o644) + require.NoError(t, err) + + command := "pwd" + if runtime.GOOS == windowsOS { + command = "cd" + } + + cfg := &config.Config{ + Hooks: config.Hooks{ + PreRemove: []config.Hook{ + { + Type: config.HookTypeCopy, + From: "original.file", + To: "backup.file", + }, + { + Type: config.HookTypeCommand, + Command: command, + WorkDir: "scripts", + }, + }, + }, + } + + executor := NewExecutor(cfg, repoRoot) + var buf bytes.Buffer + err = executor.ExecutePreRemoveHooks(&buf, worktreeDir) + require.NoError(t, err) + + _, err = os.Stat(filepath.Join(repoRoot, "backup.file")) + require.NoError(t, err) + + expectedWorkDir := filepath.Join(worktreeDir, "scripts") + assert.Contains(t, buf.String(), expectedWorkDir) +} + func TestExecutePostCreateHooks_InvalidHookType(t *testing.T) { cfg := &config.Config{ Hooks: config.Hooks{