Skip to content

Commit e2b5791

Browse files
authored
feat: add generic pre/post command hooks (#50)
* feat: add generic pre/post command hooks Add configurable hooks that run before/after wt operations (create, checkout, remove, pr, mr). Pre-hooks abort on failure; post-hooks warn only. Hooks receive environment variables (WT_PATH, WT_BRANCH, WT_MAIN, WT_REPO_NAME, WT_REPO_HOST, WT_REPO_OWNER) and can be disabled via WT_HOOKS_DISABLED=1. - Add Hooks struct and [hooks] config section in config.toml - Implement runHooks(), getHooks(), buildHookEnv() helpers - Wire pre/post hooks into create, checkout, pr, mr, remove commands - Update 'wt info' to display configured hooks - Add unit tests and E2E scenarios * feat: add .env copy example with conditional hook pattern Update default config template to use safe conditional pattern: test -f $WT_MAIN/.env && cp $WT_MAIN/.env $WT_PATH/.env || true Add E2E tests verifying .env is copied when present in main checkout, and silently skipped when absent. * docs: add hooks section to README * docs: only show .env copy on post_create, not post_checkout The .env copy is a one-time setup step — it belongs on post_create only. Using it on post_checkout would overwrite locally modified .env files when a worktree is re-created for the same branch. * fix: explicitly discard post-hook return values to satisfy errcheck
1 parent 7bbcc13 commit e2b5791

5 files changed

Lines changed: 593 additions & 0 deletions

File tree

README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Inspired by [haacked/dotfiles/tree-me](https://github.com/haacked/dotfiles/blob/
1717
- **Interactive selection menus** for checkout, remove, pr, and mr commands
1818
- GitHub PR support via `wt pr` command (uses `gh` CLI) — checks out the PR's actual branch name
1919
- GitLab MR support via `wt mr` command (uses `glab` CLI) — checks out the MR's actual branch name
20+
- **Pre/post command hooks** — run custom scripts (e.g. copy `.env`, install deps) on create/checkout/remove
2021
- Shell integration with auto-cd functionality
2122
- Tab completion for Bash and Zsh
2223

@@ -363,6 +364,62 @@ pattern = "{.repo.Main}/../{.repo.Name}/{.branch}"
363364

364365
Run `wt info` to see the active strategy, pattern, and available variables.
365366

367+
### Hooks
368+
369+
Hooks let you run custom commands before or after `wt` operations. Define them in the `[hooks]` section of your config file:
370+
371+
```toml
372+
# ~/.config/wt/config.toml
373+
[hooks]
374+
post_create = ["test -f $WT_MAIN/.env && cp $WT_MAIN/.env $WT_PATH/.env || true"]
375+
post_checkout = ["cd $WT_PATH && npm install"]
376+
pre_remove = ["echo Removing worktree at $WT_PATH"]
377+
```
378+
379+
**Available hooks:**
380+
381+
| Hook | When it runs |
382+
| --- | --- |
383+
| `pre_create` / `post_create` | Before/after `wt create` |
384+
| `pre_checkout` / `post_checkout` | Before/after `wt checkout` (alias `wt co`) |
385+
| `pre_remove` / `post_remove` | Before/after `wt remove` (alias `wt rm`) |
386+
| `pre_pr` / `post_pr` | Before/after `wt pr` |
387+
| `pre_mr` / `post_mr` | Before/after `wt mr` |
388+
389+
Hooks only run when a **new worktree is actually created** (or removed). If a worktree already exists, the early-return path skips hooks entirely.
390+
391+
**Environment variables** available in hook commands:
392+
393+
| Variable | Description |
394+
| --- | --- |
395+
| `$WT_PATH` | Worktree path being created/removed |
396+
| `$WT_BRANCH` | Branch name |
397+
| `$WT_MAIN` | Path to the main worktree |
398+
| `$WT_REPO_NAME` | Repository name |
399+
| `$WT_REPO_HOST` | Git host (e.g. `github.com`) |
400+
| `$WT_REPO_OWNER` | Repository owner/group |
401+
402+
**Behavior:**
403+
404+
- **Pre-hooks** abort the operation if any command exits non-zero
405+
- **Post-hooks** print a warning on failure but do not fail the `wt` command
406+
- Each hook is a list of shell commands executed via `sh -c` (or `cmd /c` on Windows)
407+
- Set `WT_HOOKS_DISABLED=1` to skip all hooks (useful for scripting or CI)
408+
409+
**Common patterns:**
410+
411+
```toml
412+
[hooks]
413+
# Copy .env file to new worktrees (only if it exists in main)
414+
post_create = ["test -f $WT_MAIN/.env && cp $WT_MAIN/.env $WT_PATH/.env || true"]
415+
416+
# Install dependencies after checkout
417+
post_checkout = ["cd $WT_PATH && npm install"]
418+
419+
# Run cleanup before removing a worktree
420+
pre_remove = ["cd $WT_PATH && npm run clean"]
421+
```
422+
366423
### Example: Task spanning multiple repositories
367424

368425
When a task or story requires changes across multiple repositories (e.g. a shared library and a main application), you can organize worktrees by feature instead of by repo using a custom pattern:

config.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@ type Config struct {
1616
Strategy string `toml:"strategy"`
1717
Pattern string `toml:"pattern"`
1818
Separator string `toml:"separator"`
19+
Hooks Hooks `toml:"hooks"`
20+
}
21+
22+
// Hooks holds pre/post command hook commands.
23+
type Hooks struct {
24+
PreCreate []string `toml:"pre_create"`
25+
PostCreate []string `toml:"post_create"`
26+
PreCheckout []string `toml:"pre_checkout"`
27+
PostCheckout []string `toml:"post_checkout"`
28+
PreRemove []string `toml:"pre_remove"`
29+
PostRemove []string `toml:"post_remove"`
30+
PrePR []string `toml:"pre_pr"`
31+
PostPR []string `toml:"post_pr"`
32+
PreMR []string `toml:"pre_mr"`
33+
PostMR []string `toml:"post_mr"`
1934
}
2035

2136
// configSource tracks where each config value came from.
@@ -35,6 +50,9 @@ var configFileFound bool
3550
// configSources tracks the origin of each resolved value.
3651
var configSources configSource
3752

53+
// worktreeHooks holds the loaded hook configuration.
54+
var worktreeHooks Hooks
55+
3856
// configFlag is the --config flag value (set by cobra).
3957
var configFlag string
4058

@@ -65,6 +83,17 @@ const defaultConfigTemplate = `# wt configuration file
6583
# Example: group worktrees by a FEATURE environment variable
6684
# strategy = "custom"
6785
# pattern = "{.worktreeRoot}/{.env.FEATURE}/{.repo.Name}"
86+
87+
# Hooks — run commands before/after wt operations
88+
# Available env vars in hooks: $WT_PATH, $WT_BRANCH, $WT_MAIN,
89+
# $WT_REPO_NAME, $WT_REPO_HOST, $WT_REPO_OWNER
90+
# Pre-hooks abort on failure; post-hooks warn only.
91+
# Set WT_HOOKS_DISABLED=1 to skip all hooks.
92+
#
93+
# [hooks]
94+
# post_create = ["test -f $WT_MAIN/.env && cp $WT_MAIN/.env $WT_PATH/.env || true"]
95+
# post_checkout = ["cd $WT_PATH && npm install"]
96+
# pre_remove = ["echo Removing $WT_PATH"]
6897
`
6998

7099
// configDir returns the directory where wt config files are stored.
@@ -112,6 +141,9 @@ func loadWorktreeConfig() {
112141
Separator: "default",
113142
}
114143

144+
// Reset hooks
145+
worktreeHooks = Hooks{}
146+
115147
// 2. Load config file
116148
configFilePath = resolveConfigPath(configFlag)
117149
configFileFound = false
@@ -136,6 +168,7 @@ func loadWorktreeConfig() {
136168
worktreeSeparator = cfg.Separator
137169
configSources.Separator = "config file"
138170
}
171+
worktreeHooks = cfg.Hooks
139172
}
140173
}
141174

e2e/scenarios/hooks.yaml

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# E2E tests for pre/post command hooks
2+
name: hooks
3+
description: Test pre/post command hook functionality
4+
5+
scenarios:
6+
- name: post_create_hook_runs
7+
description: Post-create hook executes after worktree creation
8+
skip_os: [windows]
9+
skip_shellenv: true
10+
steps:
11+
# Write a config file with a post_create hook, then create worktree
12+
- run: mkdir -p "$TEST_DIR/config" && printf '[hooks]\npost_create = ["touch $WT_PATH/.hook-ran"]\n' > "$TEST_DIR/config/config.toml" && WT_CONFIG="$TEST_DIR/config/config.toml" $WT_BIN create hook-test
13+
expect:
14+
exit_code: 0
15+
- run: test -f "$WORKTREE_ROOT/test-repo/hook-test/.hook-ran" && echo "HOOK_RAN" || echo "HOOK_MISSING"
16+
expect:
17+
output_contains: "HOOK_RAN"
18+
19+
- name: pre_create_hook_aborts
20+
description: Pre-create hook failure prevents worktree creation
21+
skip_shellenv: true
22+
skip_os: [windows]
23+
steps:
24+
- run: mkdir -p "$TEST_DIR/config" && printf '[hooks]\npre_create = ["false"]\n' > "$TEST_DIR/config/config.toml" && WT_CONFIG="$TEST_DIR/config/config.toml" $WT_BIN create blocked-branch
25+
expect:
26+
exit_code: 1
27+
- run: $WT_BIN list
28+
expect:
29+
output_not_contains: "blocked-branch"
30+
31+
- name: hooks_disabled_env
32+
description: WT_HOOKS_DISABLED=1 skips all hooks
33+
skip_os: [windows]
34+
skip_shellenv: true
35+
steps:
36+
- run: mkdir -p "$TEST_DIR/config" && printf '[hooks]\npre_create = ["false"]\n' > "$TEST_DIR/config/config.toml" && WT_CONFIG="$TEST_DIR/config/config.toml" WT_HOOKS_DISABLED=1 $WT_BIN create should-succeed
37+
expect:
38+
exit_code: 0
39+
40+
- name: post_checkout_hook_runs
41+
description: Post-checkout hook executes after worktree checkout
42+
skip_os: [windows]
43+
skip_shellenv: true
44+
setup:
45+
- create_branch: hook-co-branch
46+
steps:
47+
- run: mkdir -p "$TEST_DIR/config" && printf '[hooks]\npost_checkout = ["touch $WT_PATH/.checkout-hook-ran"]\n' > "$TEST_DIR/config/config.toml" && WT_CONFIG="$TEST_DIR/config/config.toml" $WT_BIN checkout hook-co-branch
48+
expect:
49+
exit_code: 0
50+
- run: test -f "$WORKTREE_ROOT/test-repo/hook-co-branch/.checkout-hook-ran" && echo "HOOK_RAN" || echo "HOOK_MISSING"
51+
expect:
52+
output_contains: "HOOK_RAN"
53+
54+
- name: pre_remove_hook_runs
55+
description: Pre-remove hook executes before worktree removal
56+
skip_os: [windows]
57+
skip_shellenv: true
58+
setup:
59+
- create_branch: rm-hook-branch
60+
steps:
61+
- run: $WT_BIN checkout rm-hook-branch
62+
expect:
63+
exit_code: 0
64+
- cd: $REPO_DIR
65+
- run: mkdir -p "$TEST_DIR/config" && printf '[hooks]\npre_remove = ["echo HOOK_PRE_REMOVE"]\n' > "$TEST_DIR/config/config.toml" && WT_CONFIG="$TEST_DIR/config/config.toml" $WT_BIN remove rm-hook-branch
66+
expect:
67+
exit_code: 0
68+
output_contains: "HOOK_PRE_REMOVE"
69+
70+
- name: copy_env_file_when_exists
71+
description: Post-create hook copies .env from main to worktree when it exists
72+
skip_os: [windows]
73+
skip_shellenv: true
74+
setup:
75+
- create_file:
76+
path: .env
77+
content: "SECRET_KEY=abc123"
78+
- git_add: .env
79+
- git_commit: "add .env file"
80+
steps:
81+
- run: mkdir -p "$TEST_DIR/config" && printf '[hooks]\npost_create = ["test -f $WT_MAIN/.env && cp $WT_MAIN/.env $WT_PATH/.env || true"]\n' > "$TEST_DIR/config/config.toml" && WT_CONFIG="$TEST_DIR/config/config.toml" $WT_BIN create env-branch
82+
expect:
83+
exit_code: 0
84+
- run: cat "$WORKTREE_ROOT/test-repo/env-branch/.env"
85+
expect:
86+
output_contains: "SECRET_KEY=abc123"
87+
88+
- name: copy_env_file_skipped_when_missing
89+
description: Post-create hook silently skips when .env does not exist in main
90+
skip_os: [windows]
91+
skip_shellenv: true
92+
steps:
93+
- run: mkdir -p "$TEST_DIR/config" && printf '[hooks]\npost_create = ["test -f $WT_MAIN/.env && cp $WT_MAIN/.env $WT_PATH/.env || true"]\n' > "$TEST_DIR/config/config.toml" && WT_CONFIG="$TEST_DIR/config/config.toml" $WT_BIN create no-env-branch
94+
expect:
95+
exit_code: 0
96+
- run: test -f "$WORKTREE_ROOT/test-repo/no-env-branch/.env" && echo "ENV_EXISTS" || echo "ENV_MISSING"
97+
expect:
98+
output_contains: "ENV_MISSING"

0 commit comments

Comments
 (0)