Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/wtp/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
10 changes: 10 additions & 0 deletions cmd/wtp/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions cmd/wtp/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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) {
Expand Down
58 changes: 58 additions & 0 deletions cmd/wtp/remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})
Expand All @@ -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 {
Expand All @@ -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()
Expand Down
189 changes: 189 additions & 0 deletions cmd/wtp/remove_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 =====
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading