diff --git a/CLAUDE.md b/CLAUDE.md index e5421bb..1ce0781 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,7 @@ internal/ui/ - Terminal UI components **Worktree naming convention** - Worktrees are created as `-` (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. diff --git a/README.md b/README.md index 84f1c80..1cd24fd 100644 --- a/README.md +++ b/README.md @@ -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 @@ -459,7 +446,6 @@ Hooks without `on` only run when invoked explicitly via `wt hook ` 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` | @@ -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] @@ -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: @@ -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 diff --git a/cmd/wt/checkout_cmd.go b/cmd/wt/checkout_cmd.go index 17e4b41..6e11b3d 100644 --- a/cmd/wt/checkout_cmd.go +++ b/cmd/wt/checkout_cmd.go @@ -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, diff --git a/cmd/wt/hook_cmd.go b/cmd/wt/hook_cmd.go index b2d285e..7523e9c 100644 --- a/cmd/wt/hook_cmd.go +++ b/cmd/wt/hook_cmd.go @@ -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, @@ -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, diff --git a/cmd/wt/pr_cmd.go b/cmd/wt/pr_cmd.go index d87c155..0c1ee8f 100644 --- a/cmd/wt/pr_cmd.go +++ b/cmd/wt/pr_cmd.go @@ -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, @@ -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, } diff --git a/cmd/wt/prune_cmd.go b/cmd/wt/prune_cmd.go index 00d1c54..98bad50 100644 --- a/cmd/wt/prune_cmd.go +++ b/cmd/wt/prune_cmd.go @@ -420,6 +420,7 @@ 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{ @@ -427,13 +428,12 @@ 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.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 } } @@ -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, diff --git a/internal/config/config.go b/internal/config/config.go index 0b33881..b1ceb54 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) @@ -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) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 48b3350..366bb5d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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 @@ -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") @@ -1047,6 +1047,8 @@ on = ["checkout"] } func TestValidateHookTriggers(t *testing.T) { + t.Parallel() + tests := []struct { name string hooks map[string]Hook @@ -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 { diff --git a/internal/hooks/doc.go b/internal/hooks/doc.go index 9ab48fd..2c88238 100644 --- a/internal/hooks/doc.go +++ b/internal/hooks/doc.go @@ -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 @@ -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. // diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index 08ce032..8f7051b 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -2,6 +2,7 @@ package hooks import ( "context" + "errors" "fmt" "io" "os" @@ -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 @@ -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 @@ -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] @@ -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...) @@ -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 } @@ -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 @@ -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, diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go index 2baa2c8..3bcfd4a 100644 --- a/internal/hooks/hooks_test.go +++ b/internal/hooks/hooks_test.go @@ -16,7 +16,6 @@ func TestSubstitutePlaceholders(t *testing.T) { RepoDir: "/home/user/repo", Branch: "feature-branch", Repo: "repo", - Origin: "myrepo", Trigger: "checkout", Action: "pr", Phase: "after", @@ -39,8 +38,8 @@ func TestSubstitutePlaceholders(t *testing.T) { }, { name: "all placeholders", - command: "{worktree-dir} {branch} {repo} {origin} {repo-dir} {trigger} {action} {phase}", - expected: "/home/user/worktrees/repo-branch feature-branch repo myrepo /home/user/repo checkout pr after", + command: "{worktree-dir} {branch} {repo} {repo-dir} {trigger} {action} {phase}", + expected: "/home/user/worktrees/repo-branch feature-branch repo /home/user/repo checkout pr after", }, { name: "no placeholders", @@ -257,6 +256,34 @@ func TestSelectHooks_NoOnCondition(t *testing.T) { } } +func TestSelectHooks_ExplicitHooksSkipBeforePhase(t *testing.T) { + t.Parallel() + + hooksConfig := config.HooksConfig{ + Hooks: map[string]config.Hook{ + "guard": {Command: "echo guard", On: []string{"before:checkout"}}, + }, + } + + // Explicit hooks should NOT run in before phase (to avoid double execution) + matches, err := SelectHooks(hooksConfig, []string{"guard"}, false, CommandCheckout, "", PhaseBefore) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(matches) != 0 { + t.Errorf("expected no hooks in before phase with explicit --hook, got %d", len(matches)) + } + + // Explicit hooks should run in after phase + matches, err = SelectHooks(hooksConfig, []string{"guard"}, false, CommandCheckout, "", PhaseAfter) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(matches) != 1 { + t.Errorf("expected 1 hook in after phase with explicit --hook, got %d", len(matches)) + } +} + func TestSelectHooks_EmptyConfig(t *testing.T) { hooksConfig := config.HooksConfig{ Hooks: map[string]config.Hook{}, @@ -906,11 +933,8 @@ func TestRunSingle_Success(t *testing.T) { } out := buf.String() - if !strings.Contains(out, "Running hook 'test-hook'") { - t.Errorf("output = %q, want to contain running message", out) - } - if !strings.Contains(out, "Say hello") { - t.Errorf("output = %q, want to contain description", out) + if !strings.Contains(out, "Running Say hello...") { + t.Errorf("output = %q, want to contain running message with description", out) } } @@ -936,7 +960,7 @@ func TestRunSingle_DryRun(t *testing.T) { if !strings.Contains(out, "[dry-run]") { t.Errorf("output = %q, want to contain [dry-run]", out) } - if strings.Contains(out, "Running hook") { + if strings.Contains(out, "Running") { t.Errorf("output = %q, should not contain running message in dry-run", out) } } @@ -957,8 +981,8 @@ func TestRunSingle_Failure(t *testing.T) { if err == nil { t.Error("RunSingle(failing command) = nil, want error") } - if !strings.Contains(err.Error(), "command failed") { - t.Errorf("error = %q, want to contain 'command failed'", err.Error()) + if !strings.Contains(err.Error(), "command failed (exit 1)") { + t.Errorf("error = %q, want to contain 'command failed (exit 1)'", err.Error()) } } @@ -995,7 +1019,7 @@ func TestRunAllNonFatal_WithFailures(t *testing.T) { if !strings.Contains(out, "Warning: hook \"failing\" failed") { t.Errorf("output = %q, want warning for failing hook", out) } - if !strings.Contains(out, "Running hook 'passing'") { + if !strings.Contains(out, "Running OK...") { t.Errorf("output = %q, want running message for passing hook", out) } }