Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ internal/ui/ - Terminal UI components

**Worktree naming convention** - Worktrees are created as `<repo-name>-<branch>` (e.g., `wt-feature-branch`). The repo name is extracted from git origin URL.

**Path handling** - Worktree placement is controlled by `checkout.worktree_format` (template with `{repo}`, `{branch}`, `{origin}` placeholders). Repos are explicitly registered via `wt repo add` and tracked in the registry (`~/.wt/repos.json`).
**Path handling** - Worktree placement is controlled by `checkout.worktree_format` (template with `{repo}`, `{branch}` placeholders). Repos are explicitly registered via `wt repo add` and tracked in the registry (`~/.wt/repos.json`).

**MR/PR status** - Uses `gh pr list` or `glab mr list` to fetch merge request info (auto-detected). States: merged, open, closed.

Expand Down
69 changes: 37 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,22 +46,9 @@ Requires `git` in PATH. For GitHub repos: `gh` CLI. For GitLab repos: `glab` CLI

## Getting Started

### 1. Shell Completions
### 1. Shell Integration

Completions are installed automatically when using Homebrew. For manual installs:

```bash
# Fish
wt completion fish > ~/.config/fish/completions/wt.fish

# Bash
wt completion bash > ~/.local/share/bash-completion/completions/wt

# Zsh — ensure ~/.zfunc exists and is on fpath, then generate
mkdir -p ~/.zfunc
echo 'fpath=(~/.zfunc $fpath)' >> ~/.zshrc # add once, before compinit
wt completion zsh > ~/.zfunc/_wt
```
Set up shell completions and the shell wrapper so that `wt cd` can change your directory. See [Shell Integration](#shell-integration) for instructions.

### 2. Create Config

Expand Down Expand Up @@ -459,7 +446,6 @@ Hooks without `on` only run when invoked explicitly via `wt hook <name>` or `--h
| `{repo-dir}` | Absolute path to the main repo (bare root or `.git` parent) |
| `{branch}` | Branch name |
| `{repo}` | Repo name (as registered in `wt repo list`) |
| `{origin}` | Folder name of the git repo (from path) |
| `{trigger}` | Command that triggered the hook (`checkout`, `prune`, `merge`, `run`) |
| `{action}` | Checkout subtype: `create`, `open`, `pr`, or `manual` (for `wt hook`) |
| `{phase}` | Hook timing: `before` or `after` |
Expand Down Expand Up @@ -644,7 +630,7 @@ command = "code '{worktree-dir}'"

> **Note:** Single quotes protect against spaces and most special characters, but not against values containing literal single quotes. This is a limitation of raw text substitution.

The same applies to all placeholders (`{repo-dir}`, `{branch}`, `{repo}`, `{origin}`, `{trigger}`) and custom `--arg` variables:
The same applies to all placeholders (`{repo-dir}`, `{branch}`, `{repo}`, `{trigger}`) and custom `--arg` variables:

```toml
[hooks.claude]
Expand Down Expand Up @@ -709,6 +695,40 @@ Multiple keys can read from the same stdin (all keys receive identical content):
cat spec.md | wt hook claude --arg prompt=- --arg context=-
```

## Shell Integration

### Shell Wrapper

`wt cd` prints the worktree path to stdout but can't change your shell's directory on its own. `wt init` outputs a shell wrapper that intercepts `wt cd` and performs the actual `cd`.

```bash
# Fish - add to ~/.config/fish/config.fish
wt init fish | source

# Bash - add to ~/.bashrc
eval "$(wt init bash)"

# Zsh - add to ~/.zshrc
eval "$(wt init zsh)"
```

### Shell Completions

Completions are installed automatically when using Homebrew. For manual installs:

```bash
# Fish
wt completion fish > ~/.config/fish/completions/wt.fish

# Bash
wt completion bash > ~/.local/share/bash-completion/completions/wt

# Zsh — ensure ~/.zfunc exists and is on fpath, then generate
mkdir -p ~/.zfunc
echo 'fpath=(~/.zfunc $fpath)' >> ~/.zshrc # add once, before compinit
wt completion zsh > ~/.zfunc/_wt
```

## Integration with gh-dash

`wt` works great with [gh-dash](https://github.com/dlvhdr/gh-dash). Add a keybinding to checkout PRs as worktrees:
Expand All @@ -723,21 +743,6 @@ keybindings:

Press `O` to checkout PR → hooks auto-open your editor.

## Shell Integration

`wt cd` prints the worktree path to stdout but can't change your shell's directory on its own. `wt init` outputs a shell wrapper that intercepts `wt cd` and performs the actual `cd`.

```bash
# Bash - add to ~/.bashrc
eval "$(wt init bash)"

# Zsh - add to ~/.zshrc
eval "$(wt init zsh)"

# Fish - add to ~/.config/fish/config.fish
wt init fish | source
```

## Development

```bash
Expand Down
1 change: 0 additions & 1 deletion cmd/wt/checkout_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,6 @@ func checkoutInRepo(ctx context.Context, repo registry.Repo, branch string, newB
RepoDir: repo.Path,
Branch: branch,
Repo: repo.Name,
Origin: git.GetRepoDisplayName(repo.Path),
Trigger: string(hooks.CommandCheckout),
Action: action,
Env: hookEnv,
Expand Down
2 changes: 0 additions & 2 deletions cmd/wt/hook_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,6 @@ func runHookInRepo(ctx context.Context, repo registry.Repo, hookName string, env
RepoDir: repo.Path,
Branch: branch,
Repo: repo.Name,
Origin: repo.Name,
Trigger: string(hooks.CommandRun),
Action: hooks.ActionManual,
Phase: hooks.PhaseAfter,
Expand Down Expand Up @@ -164,7 +163,6 @@ func runHookInTargets(ctx context.Context, reg *registry.Registry, hookName stri
RepoDir: wt.RepoPath,
Branch: wt.Branch,
Repo: wt.RepoName,
Origin: wt.RepoName,
Trigger: string(hooks.CommandRun),
Action: hooks.ActionManual,
Phase: hooks.PhaseAfter,
Expand Down
2 changes: 0 additions & 2 deletions cmd/wt/pr_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,6 @@ Use --clone-mode to control whether the repo is cloned as bare or regular.`,
RepoDir: repoPath,
Branch: branch,
Repo: repo.Name,
Origin: repo.Name,
Trigger: string(hooks.CommandCheckout),
Action: hooks.ActionPR,
Env: hookEnv,
Expand Down Expand Up @@ -542,7 +541,6 @@ Merges the PR, removes the worktree (if applicable), and deletes the local branc
RepoDir: res.repo.Path,
Branch: res.branch,
Repo: res.repo.Name,
Origin: res.repo.Name,
Trigger: string(hooks.CommandMerge),
Env: hookEnv,
}
Expand Down
5 changes: 2 additions & 3 deletions cmd/wt/prune_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -420,20 +420,20 @@ func pruneWorktrees(ctx context.Context, toRemove []git.Worktree, opts pruneOpts
beforeMatches, err := hooks.SelectHooks(effCfg.Hooks, opts.HookNames, opts.NoHook, hooks.CommandPrune, "", hooks.PhaseBefore)
if err != nil {
l.Printf("Warning: failed to select before hooks for %s: %v\n", wt.RepoName, err)
continue
}
if len(beforeMatches) > 0 {
beforeHookCtx := hooks.Context{
WorktreeDir: wt.Path,
RepoDir: wt.RepoPath,
Branch: wt.Branch,
Repo: filepath.Base(wt.RepoPath),
Origin: wt.RepoName,
Trigger: string(hooks.CommandPrune),
Phase: hooks.PhaseBefore,
Env: hookEnv,
}
if err := hooks.RunBeforeHooks(ctx, beforeMatches, beforeHookCtx, wt.Path); err != nil {
l.Printf("Skipping %s: before-hook aborted\n", wt.Branch)
l.Printf("Skipping %s: before-hook aborted: %v\n", wt.Branch, err)
continue
}
}
Expand Down Expand Up @@ -485,7 +485,6 @@ func pruneWorktrees(ctx context.Context, toRemove []git.Worktree, opts pruneOpts
RepoDir: wt.RepoPath,
Branch: wt.Branch,
Repo: filepath.Base(wt.RepoPath),
Origin: wt.RepoName,
Trigger: string(hooks.CommandPrune),
Phase: hooks.PhaseAfter,
Env: hookEnv,
Expand Down
5 changes: 1 addition & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -462,10 +462,8 @@ const defaultConfig = `# wt configuration
[checkout]
# Worktree folder naming format
# Available placeholders:
# {repo} - folder name of git repo
# {repo} - registered repo name (as shown in wt repo list)
# {branch} - the branch name as provided
# {origin} - repo name from git origin URL (falls back to {repo})
# Example: "{origin}_{branch}" creates "origin-name_feature-branch"
worktree_format = "{repo}-{branch}"

# Base ref mode for new branches (wt checkout -b)
Expand Down Expand Up @@ -524,7 +522,6 @@ worktree_format = "{repo}-{branch}"
# {repo-dir} - absolute main repo path
# {branch} - branch name
# {repo} - registered repo name
# {origin} - folder name of git repo
# {trigger} - command trigger (checkout, prune, merge, run)
# {action} - checkout subtype (create, open, pr, manual)
# {phase} - hook timing (before, after)
Expand Down
9 changes: 6 additions & 3 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -883,7 +883,7 @@ default_labels = ["team-a", "team-b"]
mode = "regular"

[checkout]
worktree_format = "{origin}-{branch}"
worktree_format = "{repo}-{branch}"
base_ref = "local"
auto_fetch = true
set_upstream = true
Expand Down Expand Up @@ -1005,8 +1005,8 @@ on = ["checkout"]
if len(cfg.DefaultLabels) != 2 {
t.Errorf("len(DefaultLabels) = %d, want 2", len(cfg.DefaultLabels))
}
if cfg.Checkout.WorktreeFormat != "{origin}-{branch}" {
t.Errorf("WorktreeFormat = %q, want %q", cfg.Checkout.WorktreeFormat, "{origin}-{branch}")
if cfg.Checkout.WorktreeFormat != "{repo}-{branch}" {
t.Errorf("WorktreeFormat = %q, want %q", cfg.Checkout.WorktreeFormat, "{repo}-{branch}")
}
if cfg.Checkout.BaseRef != "local" {
t.Errorf("BaseRef = %q, want %q", cfg.Checkout.BaseRef, "local")
Expand Down Expand Up @@ -1047,6 +1047,8 @@ on = ["checkout"]
}

func TestValidateHookTriggers(t *testing.T) {
t.Parallel()

tests := []struct {
name string
hooks map[string]Hook
Expand Down Expand Up @@ -1100,6 +1102,7 @@ func TestValidateHookTriggers(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := ValidateHookTriggers(tt.hooks)
if tt.wantErr {
if err == nil {
Expand Down
7 changes: 4 additions & 3 deletions internal/hooks/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@
// - {worktree-dir}: Absolute worktree path
// - {repo-dir}: Absolute main repo path
// - {branch}: Branch name
// - {repo}: Folder name of git repo
// - {origin}: Repository name from git origin (falls back to {repo})
// - {repo}: Registered repo name (as shown in wt repo list)
// - {trigger}: Command that triggered the hook (checkout, prune, merge, run)
// - {action}: Checkout subtype: create, open, pr, or manual (for wt hook)
// - {phase}: Hook timing: before or after
Expand All @@ -54,7 +53,9 @@
// - Main repo path for after:prune hooks (worktree is deleted)
// - Repo root for merge hooks (both before and after)
//
// Before hooks abort the operation on failure. After hook failures are logged
// Before hooks abort the current item on failure. For checkout and merge, this
// stops the command. For prune batches, the individual worktree is skipped but
// remaining worktrees are still processed. After hook failures are logged
// but don't stop batch operations ([RunForEach]).
// Use [RunSingle] for individual hook execution where errors are returned to the caller.
//
Expand Down
32 changes: 22 additions & 10 deletions internal/hooks/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package hooks

import (
"context"
"errors"
"fmt"
"io"
"os"
Expand All @@ -13,6 +14,7 @@ import (
"github.com/mattn/go-isatty"
"github.com/raphi011/wt/internal/config"
"github.com/raphi011/wt/internal/log"
"github.com/raphi011/wt/internal/ui/styles"
)

// CommandType identifies which command is triggering the hook
Expand Down Expand Up @@ -45,8 +47,7 @@ type Context struct {
RepoDir string // absolute main repo path
Branch string // branch name
Repo string // registered repo name (as shown in wt repo list)
Origin string // folder name of the git repo (filepath.Base of repo path)
Trigger string // command that triggered the hook (checkout, prune, merge)
Trigger string // command that triggered the hook (checkout, prune, merge, run)
Action string // checkout subtype: create, open, pr, manual (for wt hook)
Phase string // "before" or "after"
Env map[string]string // custom variables from --arg key=value flags
Expand All @@ -68,8 +69,13 @@ func SelectHooks(cfg config.HooksConfig, hookNames []string, noHook bool, cmdTyp
return nil, nil
}

// If explicit hooks specified, use them directly (ignores "on" condition)
// If explicit hooks specified, use them directly (ignores "on" condition).
// Only return explicit hooks in the "after" phase to avoid running them twice
// (once in before-hooks and once in after-hooks).
if len(hookNames) > 0 {
if phase == PhaseBefore {
return nil, nil
}
var matches []HookMatch
for _, hookName := range hookNames {
hook, exists := cfg.Hooks[hookName]
Expand Down Expand Up @@ -179,7 +185,11 @@ func runHook(goCtx context.Context, name string, hook *config.Hook, ctx Context,
return nil
}

l.Printf("Running hook '%s'...\n", name)
desc := hook.Description
if desc == "" {
desc = name
}
l.Printf("%s\n", styles.PrimaryStyle.Render(fmt.Sprintf("Running %s...", desc)))

shell, args := shellCommand(cmd)
shellCmd := exec.Command(shell, args...)
Expand All @@ -189,12 +199,15 @@ func runHook(goCtx context.Context, name string, hook *config.Hook, ctx Context,
shellCmd.Stderr = os.Stderr

if err := shellCmd.Run(); err != nil {
return fmt.Errorf("command failed: %s", cmd)
exitCode := 1
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitCode = exitErr.ExitCode()
}
return fmt.Errorf("command failed (exit %d): %s", exitCode, cmd)
}

if hook.Description != "" {
l.Printf(" ✓ %s\n", hook.Description)
}
l.Debug("hook completed", "name", name)
return nil
}

Expand Down Expand Up @@ -284,7 +297,7 @@ var envPlaceholderRegex = regexp.MustCompile(`\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([-+

// SubstitutePlaceholders replaces {placeholder} with values from Context.
//
// Static placeholders: {worktree-dir}, {repo-dir}, {branch}, {repo}, {origin}, {trigger}
// Static placeholders: {worktree-dir}, {repo-dir}, {branch}, {repo}, {trigger}
// Env placeholders (from Context.Env via --arg key=value or --arg key):
// - {key} - value from --arg key=value
// - {key:-default} - value with default if key not set
Expand All @@ -296,7 +309,6 @@ func SubstitutePlaceholders(command string, ctx Context) string {
"{repo-dir}": ctx.RepoDir,
"{branch}": ctx.Branch,
"{repo}": ctx.Repo,
"{origin}": ctx.Origin,
"{trigger}": ctx.Trigger,
"{action}": ctx.Action,
"{phase}": ctx.Phase,
Expand Down
Loading
Loading