diff --git a/README.md b/README.md index e05d2c5..40fa497 100644 --- a/README.md +++ b/README.md @@ -333,6 +333,9 @@ git gtr config add gtr.copy.include "**/.env.example" # Run setup after creating worktrees git gtr config add gtr.hook.postCreate "npm install" + +# Re-source environment after gtr cd (runs in current shell) +git gtr config add gtr.hook.postCd "source ./vars.sh" ``` ### Team Configuration (.gtrconfig) diff --git a/bin/gtr b/bin/gtr index 77adb0d..ab739b2 100755 --- a/bin/gtr +++ b/bin/gtr @@ -1606,7 +1606,40 @@ gtr() { if [ "$#" -gt 0 ] && [ "$1" = "cd" ]; then shift local dir - dir="$(command git gtr go "$@")" && cd "$dir" + dir="$(command git gtr go "$@")" && cd "$dir" && { + local _gtr_hooks _gtr_hook _gtr_seen _gtr_config_file + _gtr_hooks="" + _gtr_seen="" + # Read from git config (local > global > system) + _gtr_hooks="$(git config --get-all gtr.hook.postCd 2>/dev/null)" || true + # Read from .gtrconfig if it exists + _gtr_config_file="$(git rev-parse --show-toplevel 2>/dev/null)/.gtrconfig" + if [ -f "$_gtr_config_file" ]; then + local _gtr_file_hooks + _gtr_file_hooks="$(git config -f "$_gtr_config_file" --get-all hooks.postCd 2>/dev/null)" || true + if [ -n "$_gtr_file_hooks" ]; then + if [ -n "$_gtr_hooks" ]; then + _gtr_hooks="$_gtr_hooks"$'\n'"$_gtr_file_hooks" + else + _gtr_hooks="$_gtr_file_hooks" + fi + fi + fi + if [ -n "$_gtr_hooks" ]; then + # Deduplicate while preserving order + _gtr_seen="" + export WORKTREE_PATH="$dir" + export REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" + export BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)" + while IFS= read -r _gtr_hook; do + [ -z "$_gtr_hook" ] && continue + case "$_gtr_seen" in *"|$_gtr_hook|"*) continue ;; esac + _gtr_seen="$_gtr_seen|$_gtr_hook|" + eval "$_gtr_hook" || echo "gtr: postCd hook failed: $_gtr_hook" >&2 + done <<< "$_gtr_hooks" + unset WORKTREE_PATH REPO_ROOT BRANCH + fi + } else command git gtr "$@" fi @@ -1628,7 +1661,40 @@ gtr() { if [ "$#" -gt 0 ] && [ "$1" = "cd" ]; then shift local dir - dir="$(command git gtr go "$@")" && cd "$dir" + dir="$(command git gtr go "$@")" && cd "$dir" && { + local _gtr_hooks _gtr_hook _gtr_seen _gtr_config_file + _gtr_hooks="" + _gtr_seen="" + # Read from git config (local > global > system) + _gtr_hooks="$(git config --get-all gtr.hook.postCd 2>/dev/null)" || true + # Read from .gtrconfig if it exists + _gtr_config_file="$(git rev-parse --show-toplevel 2>/dev/null)/.gtrconfig" + if [ -f "$_gtr_config_file" ]; then + local _gtr_file_hooks + _gtr_file_hooks="$(git config -f "$_gtr_config_file" --get-all hooks.postCd 2>/dev/null)" || true + if [ -n "$_gtr_file_hooks" ]; then + if [ -n "$_gtr_hooks" ]; then + _gtr_hooks="$_gtr_hooks"$'\n'"$_gtr_file_hooks" + else + _gtr_hooks="$_gtr_file_hooks" + fi + fi + fi + if [ -n "$_gtr_hooks" ]; then + # Deduplicate while preserving order + _gtr_seen="" + export WORKTREE_PATH="$dir" + export REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" + export BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)" + while IFS= read -r _gtr_hook; do + [ -z "$_gtr_hook" ] && continue + case "$_gtr_seen" in *"|$_gtr_hook|"*) continue ;; esac + _gtr_seen="$_gtr_seen|$_gtr_hook|" + eval "$_gtr_hook" || echo "gtr: postCd hook failed: $_gtr_hook" >&2 + done <<< "$_gtr_hooks" + unset WORKTREE_PATH REPO_ROOT BRANCH + fi + } else command git gtr "$@" fi @@ -1650,6 +1716,33 @@ function gtr if test (count $argv) -gt 0; and test "$argv[1]" = "cd" set -l dir (command git gtr go $argv[2..]) and cd $dir + and begin + set -l _gtr_hooks + set -l _gtr_seen + # Read from git config (local > global > system) + set -l _gtr_git_hooks (git config --get-all gtr.hook.postCd 2>/dev/null) + # Read from .gtrconfig if it exists + set -l _gtr_config_file (git rev-parse --show-toplevel 2>/dev/null)"/.gtrconfig" + set -l _gtr_file_hooks + if test -f "$_gtr_config_file" + set _gtr_file_hooks (git config -f "$_gtr_config_file" --get-all hooks.postCd 2>/dev/null) + end + # Merge and deduplicate + set _gtr_hooks $_gtr_git_hooks $_gtr_file_hooks + if test (count $_gtr_hooks) -gt 0 + set -lx WORKTREE_PATH "$dir" + set -lx REPO_ROOT (git rev-parse --show-toplevel 2>/dev/null) + set -lx BRANCH (git rev-parse --abbrev-ref HEAD 2>/dev/null) + for _gtr_hook in $_gtr_hooks + if test -n "$_gtr_hook" + if not contains -- "$_gtr_hook" $_gtr_seen + set -a _gtr_seen "$_gtr_hook" + eval "$_gtr_hook"; or echo "gtr: postCd hook failed: $_gtr_hook" >&2 + end + end + end + end + end else command git gtr $argv end @@ -1991,6 +2084,7 @@ CONFIGURATION OPTIONS: gtr.hook.postCreate Post-create hooks (multi-valued) gtr.hook.preRemove Pre-remove hooks (multi-valued, abort on failure) gtr.hook.postRemove Post-remove hooks (multi-valued) + gtr.hook.postCd Post-cd hooks (multi-valued, shell integration only) ──────────────────────────────────────────────────────────────────────────────── diff --git a/completions/_git-gtr b/completions/_git-gtr index d58477f..935ac5a 100644 --- a/completions/_git-gtr +++ b/completions/_git-gtr @@ -172,7 +172,7 @@ _git-gtr() { '--local[Use local git config]' \ '--global[Use global git config]' \ '--system[Use system git config]' \ - '*:config key:(gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.editor.default gtr.ai.default gtr.provider gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove)' + '*:config key:(gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.editor.default gtr.ai.default gtr.provider gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd)' ;; set|add|unset) # Write operations only support --local and --global @@ -180,7 +180,7 @@ _git-gtr() { _arguments \ '--local[Use local git config]' \ '--global[Use global git config]' \ - '*:config key:(gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.editor.default gtr.ai.default gtr.provider gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove)' + '*:config key:(gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.editor.default gtr.ai.default gtr.provider gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd)' ;; esac fi diff --git a/completions/git-gtr.fish b/completions/git-gtr.fish index f0fb8f1..59fdd2e 100644 --- a/completions/git-gtr.fish +++ b/completions/git-gtr.fish @@ -135,6 +135,7 @@ complete -f -c git -n '__fish_git_gtr_using_command config' -a " gtr.hook.postCreate\t'Post-create hook' gtr.hook.preRemove\t'Pre-remove hook (abort on failure)' gtr.hook.postRemove\t'Post-remove hook' + gtr.hook.postCd\t'Post-cd hook (shell integration only)' " # Helper function to get branch names and special '1' for main repo diff --git a/completions/gtr.bash b/completions/gtr.bash index 77d5d09..86369d0 100644 --- a/completions/gtr.bash +++ b/completions/gtr.bash @@ -99,7 +99,7 @@ _git_gtr() { if [[ "$cur" == -* ]]; then COMPREPLY=($(compgen -W "--local --global --system" -- "$cur")) else - COMPREPLY=($(compgen -W "gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.editor.default gtr.ai.default gtr.provider gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove" -- "$cur")) + COMPREPLY=($(compgen -W "gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.editor.default gtr.ai.default gtr.provider gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd" -- "$cur")) fi ;; set|add|unset) @@ -107,7 +107,7 @@ _git_gtr() { if [[ "$cur" == -* ]]; then COMPREPLY=($(compgen -W "--local --global" -- "$cur")) else - COMPREPLY=($(compgen -W "gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.editor.default gtr.ai.default gtr.provider gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove" -- "$cur")) + COMPREPLY=($(compgen -W "gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.editor.default gtr.ai.default gtr.provider gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd" -- "$cur")) fi ;; esac diff --git a/docs/configuration.md b/docs/configuration.md index 30f8eec..b419b69 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -292,17 +292,23 @@ git gtr config add gtr.hook.preRemove "npm run cleanup" # Post-remove hooks git gtr config add gtr.hook.postRemove "echo 'Cleaned up!'" + +# Post-cd hooks (run after gtr cd, in current shell) +git gtr config add gtr.hook.postCd "source ./vars.sh" ``` **Hook execution order:** -| Hook | Timing | Use Case | -| ------------ | ------------------------ | ---------------------------------- | -| `postCreate` | After worktree creation | Setup, install dependencies | -| `preRemove` | Before worktree deletion | Cleanup requiring directory access | -| `postRemove` | After worktree deletion | Notifications, logging | +| Hook | Timing | Use Case | +| ------------ | -------------------------------- | ------------------------------------------- | +| `postCreate` | After worktree creation | Setup, install dependencies | +| `preRemove` | Before worktree deletion | Cleanup requiring directory access | +| `postRemove` | After worktree deletion | Notifications, logging | +| `postCd` | After `gtr cd` changes directory | Re-source environment, update shell context | > **Note:** Pre-remove hooks abort removal on failure. Use `--force` to skip failed hooks. +> +> **Note:** `postCd` hooks run in the **current shell** (not a subshell) so they can modify environment variables. They only run via `gtr cd` (shell integration), not `git gtr go`. Failures warn but don't undo the `cd`. **Environment variables available in hooks:** diff --git a/lib/config.sh b/lib/config.sh index 8e4d14f..1733598 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -90,6 +90,7 @@ cfg_map_to_file_key() { gtr.hook.postCreate) echo "hooks.postCreate" ;; gtr.hook.preRemove) echo "hooks.preRemove" ;; gtr.hook.postRemove) echo "hooks.postRemove" ;; + gtr.hook.postCd) echo "hooks.postCd" ;; gtr.editor.default) echo "defaults.editor" ;; gtr.editor.workspace) echo "editor.workspace" ;; gtr.ai.default) echo "defaults.ai" ;; @@ -307,6 +308,7 @@ cfg_list() { hooks.postCreate) mapped_key="gtr.hook.postCreate" ;; hooks.preRemove) mapped_key="gtr.hook.preRemove" ;; hooks.postRemove) mapped_key="gtr.hook.postRemove" ;; + hooks.postCd) mapped_key="gtr.hook.postCd" ;; defaults.editor) mapped_key="gtr.editor.default" ;; editor.workspace) mapped_key="gtr.editor.workspace" ;; defaults.ai) mapped_key="gtr.ai.default" ;; diff --git a/templates/.gtrconfig.example b/templates/.gtrconfig.example index 4df34b6..46d2f22 100644 --- a/templates/.gtrconfig.example +++ b/templates/.gtrconfig.example @@ -49,6 +49,10 @@ # Commands to run after removing a worktree # postRemove = echo "Removed worktree for branch $BRANCH" + # Commands to run after 'gtr cd' changes directory (shell integration only) + # Runs in the current shell (not a subshell) — can source files and set env vars + # postCd = source ./vars.sh + [defaults] # Default editor for 'git gtr editor' command # Available: cursor, vscode, zed, windsurf, or custom via GTR_EDITOR_CMD