diff --git a/README.md b/README.md index 03adf33..1f1be51 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ git gtr new my-feature # Create worktree folder: my-feature git gtr new my-feature --editor # Create and open in editor git gtr new my-feature --ai # Create and start AI tool git gtr new my-feature -e -a # Create, open editor, then start AI +git gtr pr 123 # Create worktree for pull request #123 git gtr editor my-feature # Open in cursor git gtr ai my-feature # Start claude @@ -101,6 +102,7 @@ git gtr run my-feature npm test # Run tests # Navigate to worktree gtr new my-feature --cd # Create and cd (requires shell integration) +gtr pr 123 --cd # Create a PR worktree and cd gtr cd # Interactive picker (requires fzf + shell integration) gtr cd my-feature # Requires shell integration (see below) cd "$(git gtr go my-feature)" # Alternative without shell integration @@ -123,6 +125,7 @@ While `git worktree` is powerful, it's verbose and manual. `git gtr` adds qualit | ----------------- | ------------------------------------------ | ---------------------------------------- | | Create worktree | `git worktree add ../repo-feature feature` | `git gtr new feature` | | Create + open | `git worktree add ... && cursor .` | `git gtr new feature --editor` | +| Checkout PR | `gh pr checkout 123` | `git gtr pr 123` | | Open in editor | `cd ../repo-feature && cursor .` | `git gtr editor feature` | | Start AI tool | `cd ../repo-feature && claude` | `git gtr ai feature` | | Copy config files | Manual copy/paste | Auto-copy via `gtr.copy.include` | @@ -184,6 +187,32 @@ git gtr new my-feature --name descriptive-variant - `--ai`, `-a`: Start AI tool after creation - `--yes`: Non-interactive mode +### `git gtr pr [options]` + +Create a worktree from a GitHub pull request. Requires GitHub CLI (`gh`) for PR lookup. +The default local branch is the PR head branch so GitHub CLI can infer the PR from inside the worktree. + +```bash +git gtr pr 123 # Branch/folder from PR head branch +git gtr pr 123 --branch review/fix # Custom local branch +git gtr pr 123 --folder review # Custom folder name +gtr pr 123 --cd # Create and cd with shell integration +``` + +**Options:** + +- `--branch `, `-b`: Local branch name to use (default: PR head branch) +- `--repo `, `-R`: Repository for `gh pr view` +- `--remote `: Override remote/repository used to fetch `refs/pull//head` +- `--no-copy`: Skip file copying +- `--no-hooks`: Skip post-create hooks +- `--force`: Allow same branch in multiple worktrees (**requires --name or --folder**) +- `--name `: Custom folder name suffix +- `--folder `: Custom folder name +- `--editor`, `-e`: Open in editor after creation +- `--ai`, `-a`: Start AI tool after creation +- `--yes`: Non-interactive mode + ### `git gtr editor [--editor ]` Open worktree in editor (uses `gtr.editor.default` or `--editor` flag). @@ -218,21 +247,22 @@ cd "$(git gtr go 1)" # Navigate to main repo ```bash # Bash (add to ~/.bashrc) _gtr_init="${XDG_CACHE_HOME:-$HOME/.cache}/gtr/init-gtr.bash" -[[ -f "$_gtr_init" ]] || eval "$(git gtr init bash)" || true +[[ -f "$_gtr_init" ]] && head -n 1 "$_gtr_init" | grep -q ' init=6 ' || eval "$(git gtr init bash)" || true source "$_gtr_init" 2>/dev/null || true; unset _gtr_init # Zsh (add to ~/.zshrc) _gtr_init="${XDG_CACHE_HOME:-$HOME/.cache}/gtr/init-gtr.zsh" -[[ -f "$_gtr_init" ]] || eval "$(git gtr init zsh)" || true +[[ -f "$_gtr_init" ]] && head -n 1 "$_gtr_init" | grep -q ' init=6 ' || eval "$(git gtr init zsh)" || true source "$_gtr_init" 2>/dev/null || true; unset _gtr_init # Fish (add to ~/.config/fish/config.fish) set -l _gtr_init (test -n "$XDG_CACHE_HOME" && echo $XDG_CACHE_HOME || echo $HOME/.cache)/gtr/init-gtr.fish -test -f "$_gtr_init"; or git gtr init fish >/dev/null 2>&1 +test -f "$_gtr_init"; and head -n 1 "$_gtr_init" | string match -q '* init=6 *'; or git gtr init fish >/dev/null 2>&1 source "$_gtr_init" 2>/dev/null # Then navigate with: gtr new my-feature --cd # Create and land in the new worktree +gtr pr 123 --cd # Create PR worktree and land in it gtr cd # Interactive worktree picker (requires fzf) gtr cd my-feature gtr cd 1 @@ -390,7 +420,7 @@ 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 or gtr new --cd (runs in current shell) +# Re-source environment after gtr cd, gtr new --cd, or gtr pr --cd (runs in current shell) git gtr config add gtr.hook.postCd "source ./vars.sh" # Disable color output (or use "always" to force it) diff --git a/bin/git-gtr b/bin/git-gtr index 5355787..9a05c4c 100755 --- a/bin/git-gtr +++ b/bin/git-gtr @@ -58,6 +58,9 @@ main() { new) cmd_create "$@" ;; + pr) + cmd_pr "$@" + ;; rm) cmd_remove "$@" ;; diff --git a/completions/_git-gtr b/completions/_git-gtr index ac3c6b5..1700a14 100644 --- a/completions/_git-gtr +++ b/completions/_git-gtr @@ -29,6 +29,7 @@ _git-gtr() { local -a commands commands=( 'new:Create a new worktree' + 'pr:Create a pull request worktree' 'go:Navigate to worktree' 'run:Execute command in worktree' 'copy:Copy files between worktrees' @@ -78,6 +79,28 @@ _git-gtr() { return fi + # Early handler for `pr` command + if (( CURRENT >= 4 )) && [[ $words[3] == pr ]]; then + _arguments \ + '1:pull request:' \ + '--branch[Local branch name]:branch:' \ + '-b[Local branch name]:branch:' \ + '--repo[GitHub repository]:repo:' \ + '-R[GitHub repository]:repo:' \ + '--remote[Remote used to fetch PR ref]:remote:' \ + '--no-copy[Skip file copying]' \ + '--no-hooks[Skip post-create hooks]' \ + '--force[Allow same branch in multiple worktrees]' \ + '--name[Custom folder name suffix]:name:' \ + '--folder[Custom folder name (replaces default)]:folder:' \ + '--yes[Non-interactive mode]' \ + '--editor[Open in editor after creation]' \ + '-e[Open in editor after creation]' \ + '--ai[Start AI tool after creation]' \ + '-a[Start AI tool after creation]' + return + fi + # Early handler for `clean` command if (( CURRENT >= 4 )) && [[ $words[3] == clean ]]; then _arguments \ diff --git a/completions/git-gtr.fish b/completions/git-gtr.fish index ca9e464..ed8ecf9 100644 --- a/completions/git-gtr.fish +++ b/completions/git-gtr.fish @@ -35,6 +35,7 @@ end # Commands complete -f -c git -n '__fish_git_gtr_needs_command' -a new -d 'Create a new worktree' +complete -f -c git -n '__fish_git_gtr_needs_command' -a pr -d 'Create a pull request worktree' complete -f -c git -n '__fish_git_gtr_needs_command' -a go -d 'Navigate to worktree' complete -f -c git -n '__fish_git_gtr_needs_command' -a run -d 'Execute command in worktree' complete -f -c git -n '__fish_git_gtr_needs_command' -a rm -d 'Remove worktree(s)' @@ -73,6 +74,19 @@ complete -c git -n '__fish_git_gtr_using_command new' -l yes -d 'Non-interactive complete -c git -n '__fish_git_gtr_using_command new' -s e -l editor -d 'Open in editor after creation' complete -c git -n '__fish_git_gtr_using_command new' -s a -l ai -d 'Start AI tool after creation' +# Pull request command options +complete -c git -n '__fish_git_gtr_using_command pr' -s b -l branch -d 'Local branch name' -r +complete -c git -n '__fish_git_gtr_using_command pr' -s R -l repo -d 'GitHub repository' -r +complete -c git -n '__fish_git_gtr_using_command pr' -l remote -d 'Remote used to fetch PR ref' -r +complete -c git -n '__fish_git_gtr_using_command pr' -l no-copy -d 'Skip file copying' +complete -c git -n '__fish_git_gtr_using_command pr' -l no-hooks -d 'Skip post-create hooks' +complete -c git -n '__fish_git_gtr_using_command pr' -l force -d 'Allow same branch in multiple worktrees' +complete -c git -n '__fish_git_gtr_using_command pr' -l name -d 'Custom folder name suffix' -r +complete -c git -n '__fish_git_gtr_using_command pr' -l folder -d 'Custom folder name (replaces default)' -r +complete -c git -n '__fish_git_gtr_using_command pr' -l yes -d 'Non-interactive mode' +complete -c git -n '__fish_git_gtr_using_command pr' -s e -l editor -d 'Open in editor after creation' +complete -c git -n '__fish_git_gtr_using_command pr' -s a -l ai -d 'Start AI tool after creation' + # Remove command options complete -c git -n '__fish_git_gtr_using_command rm' -l delete-branch -d 'Delete branch' complete -c git -n '__fish_git_gtr_using_command rm' -l force -d 'Force removal even if worktree has uncommitted changes or untracked files' diff --git a/completions/gtr.bash b/completions/gtr.bash index 329b06f..90fea77 100644 --- a/completions/gtr.bash +++ b/completions/gtr.bash @@ -25,7 +25,7 @@ _git_gtr() { # If we're completing the first argument after 'git gtr' if [ "$cword" -eq 2 ]; then - COMPREPLY=($(compgen -W "new go run copy editor ai rm mv rename ls list clean doctor adapter config completion init trust help version" -- "$cur")) + COMPREPLY=($(compgen -W "new pr go run copy editor ai rm mv rename ls list clean doctor adapter config completion init trust help version" -- "$cur")) return 0 fi @@ -104,6 +104,11 @@ _git_gtr() { COMPREPLY=($(compgen -W "auto remote local none" -- "$cur")) fi ;; + pr) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--branch -b --repo -R --remote --no-copy --no-hooks --force --name --folder --yes --editor -e --ai -a" -- "$cur")) + fi + ;; completion) # Complete with shell names if [ "$cword" -eq 3 ]; then diff --git a/docs/configuration.md b/docs/configuration.md index 25c81b5..9789478 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -302,7 +302,7 @@ 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 or gtr new --cd, in current shell) +# Post-cd hooks (run after gtr cd, gtr new --cd, or gtr pr --cd, in current shell) git gtr config add gtr.hook.postCd "source ./vars.sh" ``` @@ -313,11 +313,11 @@ git gtr config add gtr.hook.postCd "source ./vars.sh" | `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` or `gtr new --cd` changes directory | Re-source environment, update shell context | +| `postCd` | After `gtr cd`, `gtr new --cd`, or `gtr pr --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 shell integration (`gtr cd`, `gtr new --cd`), not raw `git gtr` commands or `git gtr go`. Failures warn but don't undo the directory change. +> **Note:** `postCd` hooks run in the **current shell** (not a subshell) so they can modify environment variables. They only run via shell integration (`gtr cd`, `gtr new --cd`, `gtr pr --cd`), not raw `git gtr` commands or `git gtr go`. Failures warn but don't undo the directory change. **Environment variables available in hooks:** diff --git a/lib/commands/create.sh b/lib/commands/create.sh index 917861a..ce41020 100644 --- a/lib/commands/create.sh +++ b/lib/commands/create.sh @@ -158,13 +158,7 @@ cmd_create() { # Construct folder name for display local folder_name - if [ -n "$folder_override" ]; then - folder_name=$(sanitize_branch_name "$folder_override") - elif [ -n "$custom_name" ]; then - folder_name="$(sanitize_branch_name "$branch_name")-${custom_name}" - else - folder_name=$(sanitize_branch_name "$branch_name") - fi + folder_name=$(_resolve_folder_name "$branch_name" "$custom_name" "$folder_override") || exit 1 log_step "Creating worktree: $folder_name" echo "Location: $base_dir/${prefix}${folder_name}" diff --git a/lib/commands/help.sh b/lib/commands/help.sh index 9af8711..2e62414 100644 --- a/lib/commands/help.sh +++ b/lib/commands/help.sh @@ -41,6 +41,38 @@ Examples: EOF } +_help_pr() { + cat <<'EOF' +git gtr pr - Create a worktree for a GitHub pull request + +Usage: git gtr pr [options] + +Creates a worktree from a GitHub pull request, similar to gh pr checkout. +The default local branch is the PR head branch so GitHub CLI can infer the PR +from inside the worktree. Requires GitHub CLI (gh) for PR lookup. + +Options: + -b, --branch Local branch name to use (default: PR head branch) + -R, --repo Select GitHub repository for gh pr view + --remote Override remote/repository used to fetch refs/pull//head + --no-copy Skip file copying (gtr.copy.include patterns) + --no-hooks Skip post-create hooks + --force Allow same branch in multiple worktrees + (requires --name or --folder to distinguish worktrees) + --name Custom folder name suffix (appended after branch name) + --folder Custom folder name (replaces default entirely) + --yes Non-interactive mode (skip prompts) + -e, --editor Open in editor after creation + -a, --ai Start AI tool after creation + +Examples: + git gtr pr 123 # Branch/folder from PR head branch + git gtr pr 123 --branch review/fix # Custom local branch + git gtr pr https://github.com/OWNER/REPO/pull/123 --folder review + gtr pr 123 --cd # With shell integration +EOF +} + _help_editor() { cat <<'EOF' git gtr editor - Open worktree in editor @@ -367,8 +399,9 @@ git gtr init - Generate shell integration Usage: git gtr init [--as ] -Generates shell functions for enhanced features like 'gtr cd ' -and 'gtr new --cd', which can change the current shell directory. +Generates shell functions for enhanced features like 'gtr cd ', +'gtr new --cd', and 'gtr pr --cd', which can change the +current shell directory. Add to your shell configuration. Output is cached to ~/.cache/gtr/ for fast shell startup (~1ms vs ~60ms). @@ -385,17 +418,17 @@ Options: Setup (sources cached output directly for fast startup): # Bash (add to ~/.bashrc) _gtr_init="${XDG_CACHE_HOME:-$HOME/.cache}/gtr/init-gtr.bash" - [[ -f "$_gtr_init" ]] || eval "$(git gtr init bash)" || true + [[ -f "$_gtr_init" ]] && head -n 1 "$_gtr_init" | grep -q ' init=6 ' || eval "$(git gtr init bash)" || true source "$_gtr_init" 2>/dev/null || true; unset _gtr_init # Zsh (add to ~/.zshrc) _gtr_init="${XDG_CACHE_HOME:-$HOME/.cache}/gtr/init-gtr.zsh" - [[ -f "$_gtr_init" ]] || eval "$(git gtr init zsh)" || true + [[ -f "$_gtr_init" ]] && head -n 1 "$_gtr_init" | grep -q ' init=6 ' || eval "$(git gtr init zsh)" || true source "$_gtr_init" 2>/dev/null || true; unset _gtr_init # Fish (add to ~/.config/fish/config.fish) set -l _gtr_init (test -n "$XDG_CACHE_HOME" && echo $XDG_CACHE_HOME || echo $HOME/.cache)/gtr/init-gtr.fish - test -f "$_gtr_init"; or git gtr init fish >/dev/null 2>&1 + test -f "$_gtr_init"; and head -n 1 "$_gtr_init" | string match -q '* init=6 *'; or git gtr init fish >/dev/null 2>&1 source "$_gtr_init" 2>/dev/null # Custom function name (avoids conflict with coreutils gtr) @@ -403,6 +436,7 @@ Setup (sources cached output directly for fast startup): After setup: gtr new my-feature --cd # create and cd into worktree + gtr pr 123 --cd # create PR worktree and cd gtr cd my-feature # cd to worktree gtr cd 1 # cd to main repo gtr cd # interactive picker (requires fzf) @@ -526,6 +560,20 @@ CORE COMMANDS (daily workflow): -e, --editor: open in editor after creation -a, --ai: start AI tool after creation + pr [options] + Create a worktree from a GitHub pull request (requires gh) + -b, --branch : local branch name (default: PR head branch) + -R, --repo : repository for gh pr view + --remote : override remote/repository used to fetch refs/pull//head + --no-copy: skip file copying + --no-hooks: skip post-create hooks + --force: allow same branch in multiple worktrees (requires --name or --folder) + --name : custom folder name suffix + --folder : custom folder name + --yes: non-interactive mode + -e, --editor: open in editor after creation + -a, --ai: start AI tool after creation + editor [--editor ] Open worktree in editor (uses gtr.editor.default or --editor) Special: use '1' to open repo root @@ -626,7 +674,7 @@ SETUP & MAINTENANCE: Usage: eval "$(git gtr completion zsh)" init [--as ] - Generate shell integration for gtr cd and gtr new --cd (bash, zsh, fish) + Generate shell integration for gtr cd, gtr new --cd, and gtr pr --cd (bash, zsh, fish) --as : custom function name (default: gtr) Output is cached for fast startup (refreshes when 'git gtr init' runs) See git gtr help init for recommended setup @@ -646,6 +694,7 @@ WORKFLOW EXAMPLES: # Daily workflow git gtr new feature/user-auth # Create worktree (folder: feature-user-auth) + git gtr pr 123 # Create worktree for pull request #123 git gtr editor feature/user-auth # Open in editor git gtr ai feature/user-auth # Start AI tool @@ -655,6 +704,7 @@ WORKFLOW EXAMPLES: # Navigate to worktree directory gtr new hotfix --cd # Create and cd into worktree (with shell integration) + gtr pr 123 --cd # Create PR worktree and cd gtr cd # Interactive picker (requires fzf) gtr cd feature/user-auth # With shell integration (git gtr init) cd "$(git gtr go feature/user-auth)" # Without shell integration @@ -706,7 +756,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, gtr cd / gtr new --cd only) + gtr.hook.postCd Post-cd hooks (multi-valued, gtr cd / gtr new --cd / gtr pr --cd only) gtr.ui.color Color output mode (auto, always, never; default: auto) ──────────────────────────────────────────────────────────────────────────────── diff --git a/lib/commands/init.sh b/lib/commands/init.sh index dc9b854..e526744 100644 --- a/lib/commands/init.sh +++ b/lib/commands/init.sh @@ -68,7 +68,7 @@ cmd_init() { # Generate output (cached to ~/.cache/gtr/, auto-invalidates on shell integration changes) local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/gtr" local cache_file="$cache_dir/init-${func_name}.${shell}" - local cache_schema="${GTR_INIT_CACHE_VERSION:-5}" + local cache_schema="${GTR_INIT_CACHE_VERSION:-6}" local cache_stamp="# gtr-cache: version=${GTR_VERSION:-unknown} init=${cache_schema} func=$func_name shell=$shell" # Return cached output if version matches @@ -334,21 +334,21 @@ __FUNC__() { dir="$(command git gtr go "$@")" || return $? fi __FUNC___run_post_cd_hooks "$dir" - elif [ "$#" -gt 0 ] && [ "$1" = "new" ]; then - local -a _gtr_original_args=("$@") _gtr_new_args=() - local _gtr_arg _gtr_new_cd=0 _gtr_before_paths _gtr_after_paths + elif [ "$#" -gt 0 ] && { [ "$1" = "new" ] || [ "$1" = "pr" ]; }; then + local -a _gtr_original_args=("$@") _gtr_command_args=() + local _gtr_command="$1" _gtr_arg _gtr_cd=0 _gtr_before_paths _gtr_after_paths local _gtr_path _gtr_new_dir="" _gtr_new_count=0 _gtr_status shift for _gtr_arg in "$@"; do if [ "$_gtr_arg" = "--cd" ]; then - _gtr_new_cd=1 + _gtr_cd=1 else - _gtr_new_args+=("$_gtr_arg") + _gtr_command_args+=("$_gtr_arg") fi done - if [ "$_gtr_new_cd" -eq 1 ]; then + if [ "$_gtr_cd" -eq 1 ]; then _gtr_before_paths="$(command git worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')" - command git gtr new "${_gtr_new_args[@]}" + command git gtr "$_gtr_command" "${_gtr_command_args[@]}" _gtr_status=$? [ "$_gtr_status" -ne 0 ] && return "$_gtr_status" _gtr_after_paths="$(command git worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')" @@ -392,13 +392,13 @@ ___FUNC___completion() { if [ "$COMP_CWORD" -eq 1 ]; then # First argument: cd + all git-gtr subcommands - COMPREPLY=($(compgen -W "cd new go run copy editor ai rm mv rename ls list clean doctor adapter config completion init trust help version" -- "$cur")) + COMPREPLY=($(compgen -W "cd new pr go run copy editor ai rm mv rename ls list clean doctor adapter config completion init trust help version" -- "$cur")) elif [ "${COMP_WORDS[1]}" = "cd" ] && [ "$COMP_CWORD" -eq 2 ]; then # Worktree names for cd local worktrees worktrees="1 $(git gtr list --porcelain 2>/dev/null | cut -f2 | tr '\n' ' ')" COMPREPLY=($(compgen -W "$worktrees" -- "$cur")) - elif [ "${COMP_WORDS[1]}" = "new" ] && [[ "$cur" == -* ]]; then + elif { [ "${COMP_WORDS[1]}" = "new" ] || [ "${COMP_WORDS[1]}" = "pr" ]; } && [[ "$cur" == -* ]]; then if type _git_gtr &>/dev/null; then ___FUNC___delegate_completion fi @@ -520,23 +520,23 @@ __FUNC__() { dir="$(command git gtr go "$@")" || return $? fi __FUNC___run_post_cd_hooks "$dir" - elif [ "$#" -gt 0 ] && [ "$1" = "new" ]; then - local -a _gtr_original_args _gtr_new_args - local _gtr_arg _gtr_new_cd=0 _gtr_before_paths _gtr_after_paths + elif [ "$#" -gt 0 ] && { [ "$1" = "new" ] || [ "$1" = "pr" ]; }; then + local -a _gtr_original_args _gtr_command_args + local _gtr_command="$1" _gtr_arg _gtr_cd=0 _gtr_before_paths _gtr_after_paths local _gtr_path _gtr_new_dir="" _gtr_new_count=0 _gtr_status _gtr_original_args=("$@") - _gtr_new_args=() + _gtr_command_args=() shift for _gtr_arg in "$@"; do if [ "$_gtr_arg" = "--cd" ]; then - _gtr_new_cd=1 + _gtr_cd=1 else - _gtr_new_args+=("$_gtr_arg") + _gtr_command_args+=("$_gtr_arg") fi done - if [ "$_gtr_new_cd" -eq 1 ]; then + if [ "$_gtr_cd" -eq 1 ]; then _gtr_before_paths="$(command git worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')" - command git gtr new "${_gtr_new_args[@]}" + command git gtr "$_gtr_command" "${_gtr_command_args[@]}" _gtr_status=$? [ "$_gtr_status" -ne 0 ] && return "$_gtr_status" _gtr_after_paths="$(command git worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')" @@ -584,7 +584,7 @@ ___FUNC___completion() { _git-gtr fi - if [[ "${words[2]}" == "new" && "$current_word" == -* ]]; then + if [[ "${words[2]}" == (new|pr) && "$current_word" == -* ]]; then compadd -- --cd fi @@ -712,19 +712,20 @@ function __FUNC__ or return $status end __FUNC___run_post_cd_hooks "$dir" - else if test (count $argv) -gt 0; and test "$argv[1]" = "new" - set -l _gtr_new_cd 0 - set -l _gtr_new_args + else if test (count $argv) -gt 0; and contains -- "$argv[1]" new pr + set -l _gtr_command "$argv[1]" + set -l _gtr_cd 0 + set -l _gtr_command_args for _gtr_arg in $argv[2..-1] if test "$_gtr_arg" = "--cd" - set _gtr_new_cd 1 + set _gtr_cd 1 else - set -a _gtr_new_args "$_gtr_arg" + set -a _gtr_command_args "$_gtr_arg" end end - if test "$_gtr_new_cd" = "1" + if test "$_gtr_cd" = "1" set -l _gtr_before_paths (command git worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p') - command git gtr new $_gtr_new_args + command git gtr $_gtr_command $_gtr_command_args set -l _gtr_status $status test $_gtr_status -ne 0; and return $_gtr_status set -l _gtr_after_paths (command git worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p') @@ -768,6 +769,7 @@ end # Subcommands (cd + all git gtr commands) complete -f -c __FUNC__ -n '___FUNC___needs_subcommand' -a cd -d 'Change directory to worktree' complete -f -c __FUNC__ -n '___FUNC___needs_subcommand' -a new -d 'Create a new worktree' +complete -f -c __FUNC__ -n '___FUNC___needs_subcommand' -a pr -d 'Create a pull request worktree' complete -f -c __FUNC__ -n '___FUNC___needs_subcommand' -a go -d 'Navigate to worktree' complete -f -c __FUNC__ -n '___FUNC___needs_subcommand' -a run -d 'Execute command in worktree' complete -f -c __FUNC__ -n '___FUNC___needs_subcommand' -a copy -d 'Copy files between worktrees' @@ -790,6 +792,6 @@ complete -f -c __FUNC__ -n '___FUNC___needs_subcommand' -a help -d 'Show help' # Worktree name completions for cd complete -f -c __FUNC__ -n '___FUNC___using_subcommand cd' -a '(echo 1; git gtr list --porcelain 2>/dev/null | cut -f2)' -complete -f -c __FUNC__ -n '___FUNC___using_subcommand new' -l cd -d 'Create and cd into the new worktree' +complete -f -c __FUNC__ -n '___FUNC___using_subcommand new pr' -l cd -d 'Create and cd into the new worktree' FISH } diff --git a/lib/commands/pr.sh b/lib/commands/pr.sh new file mode 100644 index 0000000..bc0747c --- /dev/null +++ b/lib/commands/pr.sh @@ -0,0 +1,338 @@ +#!/usr/bin/env bash + +# Pull request command +# Creates a worktree for a GitHub pull request, similar to gh pr checkout. +# shellcheck disable=SC2154 # _arg_* _pa_* set by parse_args, _ctx_* set by resolve_* + +declare _ctx_pr_number _ctx_pr_head_ref _ctx_pr_head_owner _ctx_pr_head_repo _ctx_pr_url + +_pr_resolve() { + local selector="$1" repo_arg="$2" + + if ! command -v gh >/dev/null 2>&1; then + log_error "gh is required for 'git gtr pr'" + log_info "Install GitHub CLI: https://cli.github.com/" + return 1 + fi + + local output gh_stderr err_file + local json_fields="number,headRefName,headRepositoryOwner,headRepository,url" + local template='{{.number}}{{"\t"}}{{.headRefName}}{{"\t"}}{{.headRepositoryOwner.login}}{{"\t"}}{{.headRepository.name}}{{"\t"}}{{.url}}' + err_file=$(mktemp "${TMPDIR:-/tmp}/gtr-gh.XXXXXX") || { + log_error "Could not create temporary file for gh output" + return 1 + } + + if [ -n "$repo_arg" ]; then + output=$(gh pr view "$selector" --repo "$repo_arg" --json "$json_fields" --template "$template" 2>"$err_file") || { + gh_stderr=$(cat "$err_file" 2>/dev/null || true) + rm -f "$err_file" + log_error "Could not resolve pull request: $selector" + [ -n "$gh_stderr" ] && log_info "$gh_stderr" + return 1 + } + else + output=$(gh pr view "$selector" --json "$json_fields" --template "$template" 2>"$err_file") || { + gh_stderr=$(cat "$err_file" 2>/dev/null || true) + rm -f "$err_file" + log_error "Could not resolve pull request: $selector" + [ -n "$gh_stderr" ] && log_info "$gh_stderr" + return 1 + } + fi + rm -f "$err_file" + + local old_ifs="$IFS" + IFS="$(printf '\t')" + read -r _ctx_pr_number _ctx_pr_head_ref _ctx_pr_head_owner _ctx_pr_head_repo _ctx_pr_url <"|"null") return 1 ;; + esac + case "$head_repo" in + ""|"null") return 1 ;; + esac + + local host_url="$pr_url" + host_url="${host_url%%/pull/*}" + host_url="${host_url%/*/*}" + _pr_url_with_git_suffix "$host_url/$head_owner/$head_repo" +} + +_pr_normalize_repo_url() { + local repo_url="$1" + repo_url="${repo_url%/}" + repo_url="${repo_url%.git}" + + case "$repo_url" in + git@*:*) + repo_url="${repo_url#git@}" + repo_url="${repo_url/:/\/}" + ;; + *://*) + repo_url="${repo_url#*://}" + repo_url="${repo_url#*@}" + ;; + esac + + printf "%s" "$repo_url" +} + +_pr_remote_for_url() { + local repo_url="$1" + local wanted + wanted=$(_pr_normalize_repo_url "$repo_url") + + local remote_name remote_url remote_norm + while IFS= read -r remote_name; do + [ -z "$remote_name" ] && continue + remote_url=$(git remote get-url "$remote_name" 2>/dev/null) || continue + remote_norm=$(_pr_normalize_repo_url "$remote_url") + if [ "$remote_norm" = "$wanted" ]; then + printf "%s" "$remote_name" + return 0 + fi + done </dev/null) +EOF + + return 1 +} + +_pr_configure_base_remote_for_gh() { + local pr_number="$1" base_repo_url="$2" + + [ -z "$base_repo_url" ] && return 0 + + local remote_name + remote_name=$(_pr_remote_for_url "$base_repo_url") || remote_name="" + + if [ -z "$remote_name" ]; then + local candidate="gtr-pr-$pr_number-base" suffix=1 + remote_name="$candidate" + while git remote get-url "$remote_name" >/dev/null 2>&1; do + remote_name="$candidate-$suffix" + suffix=$((suffix + 1)) + done + git remote add "$remote_name" "$base_repo_url" || return 1 + fi + + git config "remote.$remote_name.gh-resolved" base +} + +_pr_config_set() { + local branch_name="$1" key="$2" value="$3" preserve_existing="$4" + local config_key="branch.$branch_name.$key" + + if [ "$preserve_existing" -eq 1 ] && git config --get "$config_key" >/dev/null 2>&1; then + return 0 + fi + + git config "$config_key" "$value" +} + +_pr_configure_branch_for_gh() { + local branch_name="$1" head_ref="$2" head_repo_url="$3" preserve_existing="${4:-0}" + + [ -z "$head_ref" ] && return 0 + [ -z "$head_repo_url" ] && return 0 + + _pr_config_set "$branch_name" remote "$head_repo_url" "$preserve_existing" || return 1 + _pr_config_set "$branch_name" pushRemote "$head_repo_url" "$preserve_existing" || return 1 + _pr_config_set "$branch_name" merge "refs/heads/$head_ref" "$preserve_existing" || return 1 +} + +_pr_branch_is_checked_out() { + local branch_name="$1" + local line + + while IFS= read -r line; do + case "$line" in + "branch refs/heads/$branch_name") return 0 ;; + esac + done </dev/null) +EOF + + return 1 +} + +cmd_pr() { + local _spec + _spec="--branch|-b: value +--repo|-R: value +--remote: value +--no-copy +--no-hooks +--yes +--force +--name: value +--folder: value +--editor|-e +--ai|-a" + parse_args "$_spec" "$@" + + local selector="${_pa_positional[0]:-}" + local branch_name="${_arg_branch:-}" + local repo_arg="${_arg_repo:-}" + local remote_arg="${_arg_remote:-}" + local remote="${remote_arg:-$(resolve_default_remote)}" + local skip_copy="${_arg_no_copy:-0}" + local skip_hooks="${_arg_no_hooks:-0}" + local yes_mode="${_arg_yes:-0}" + local force="${_arg_force:-0}" + local custom_name="${_arg_name:-}" + local folder_override="${_arg_folder:-}" + local open_editor="${_arg_editor:-0}" + local start_ai="${_arg_ai:-0}" + + if [ -z "$selector" ]; then + if [ "$yes_mode" -eq 1 ]; then + log_error "Pull request selector required in non-interactive mode" + exit 1 + fi + selector=$(prompt_input "Enter pull request number, URL, or branch:") + if [ -z "$selector" ]; then + log_error "Pull request selector required" + exit 1 + fi + fi + + if [ -n "$folder_override" ] && [ -n "$custom_name" ]; then + log_error "--folder and --name cannot be used together" + exit 1 + fi + + if [ "$force" -eq 1 ] && [ -z "$custom_name" ] && [ -z "$folder_override" ]; then + log_error "--force requires --name or --folder to distinguish worktrees" + echo "Example: git gtr pr $selector --force --name review" >&2 + echo " or: git gtr pr $selector --force --folder pr-review" >&2 + exit 1 + fi + + resolve_repo_context || exit 1 + local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix" + + _pr_resolve "$selector" "$repo_arg" || exit 1 + local pr_number="$_ctx_pr_number" pr_head_ref="$_ctx_pr_head_ref" + local pr_head_owner="$_ctx_pr_head_owner" pr_head_repo="$_ctx_pr_head_repo" pr_url="$_ctx_pr_url" + local head_repo_url base_repo_url fetch_source + head_repo_url=$(_pr_head_repo_url "$pr_url" "$pr_head_owner" "$pr_head_repo") || head_repo_url="" + base_repo_url=$(_pr_base_repo_url "$pr_url") || base_repo_url="" + fetch_source="$remote" + if [ -z "$remote_arg" ] && [ -n "$base_repo_url" ]; then + fetch_source="$base_repo_url" + fi + if [ -z "$branch_name" ]; then + branch_name="$pr_head_ref" + if [ -z "$branch_name" ]; then + branch_name="pr/$pr_number" + fi + fi + + local folder_name + folder_name=$(_resolve_folder_name "$branch_name" "$custom_name" "$folder_override") || exit 1 + + local expected_worktree_path="$base_dir/${prefix}${folder_name}" + if [ -d "$expected_worktree_path" ]; then + log_error "Worktree $folder_name already exists at $expected_worktree_path" + exit 1 + fi + + log_step "Fetching pull request #$pr_number..." + if ! git fetch "$fetch_source" "refs/pull/$pr_number/head"; then + log_error "Could not fetch pull request #$pr_number from $fetch_source" + exit 1 + fi + + local track_mode="none" branch_preexisted=0 + if git show-ref --verify --quiet "refs/heads/$branch_name"; then + branch_preexisted=1 + local branch_oid fetch_oid + branch_oid=$(git rev-parse --verify "refs/heads/$branch_name^{commit}" 2>/dev/null) || branch_oid="" + fetch_oid=$(git rev-parse --verify "FETCH_HEAD^{commit}" 2>/dev/null) || fetch_oid="" + if [ -z "$branch_oid" ] || [ "$branch_oid" != "$fetch_oid" ]; then + log_error "Local branch $branch_name already exists and differs from pull request #$pr_number" + log_info "Use --branch to choose a different local branch name" + exit 1 + fi + track_mode="local" + if _pr_branch_is_checked_out "$branch_name"; then + log_warn "Local branch $branch_name is already checked out; using it without resetting" + fi + fi + + log_step "Creating worktree: $folder_name" + echo "Location: $base_dir/${prefix}${folder_name}" + echo "Pull request: #$pr_number" + [ -n "$pr_head_ref" ] && echo "Head branch: $pr_head_ref" + echo "Local branch: $branch_name" + + local worktree_path + if ! worktree_path=$(create_worktree "$base_dir" "$prefix" "$branch_name" "FETCH_HEAD" "$track_mode" "1" "$force" "$custom_name" "$folder_override" "$remote"); then + exit 1 + fi + + if ! _pr_configure_base_remote_for_gh "$pr_number" "$base_repo_url"; then + log_warn "Could not configure base repository metadata for gh pr view" + fi + if ! _pr_configure_branch_for_gh "$branch_name" "$pr_head_ref" "$head_repo_url" "$branch_preexisted"; then + log_warn "Could not configure branch metadata for gh pr view" + fi + + if [ "$skip_copy" -eq 0 ]; then + _post_create_copy "$repo_root" "$worktree_path" + fi + + if [ "$skip_hooks" -eq 0 ]; then + run_hooks_in postCreate "$worktree_path" \ + REPO_ROOT="$repo_root" \ + WORKTREE_PATH="$worktree_path" \ + BRANCH="$branch_name" \ + PR_NUMBER="$pr_number" \ + PR_HEAD_REF="$pr_head_ref" + fi + + echo "" + log_info "Worktree created: $worktree_path" + + [ "$open_editor" -eq 1 ] && { _auto_launch_editor "$worktree_path" || true; } + [ "$start_ai" -eq 1 ] && { _auto_launch_ai "$worktree_path" "$repo_root" "$branch_name" || true; } + if [ "$open_editor" -eq 0 ] && [ "$start_ai" -eq 0 ]; then + _post_create_next_steps "$branch_name" "$folder_name" "$folder_override" "$repo_root" "$base_dir" "$prefix" + fi +} diff --git a/lib/core.sh b/lib/core.sh index 4f5bdad..3ff4d66 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -534,7 +534,13 @@ _resolve_folder_name() { if [ -n "$folder_override" ]; then sanitized_name=$(sanitize_branch_name "$folder_override") elif [ -n "$custom_name" ]; then - sanitized_name="$(sanitize_branch_name "$branch_name")-${custom_name}" + local sanitized_custom + sanitized_custom=$(sanitize_branch_name "$custom_name") + if [ -z "$sanitized_custom" ] || [ "$sanitized_custom" = "." ] || [ "$sanitized_custom" = ".." ]; then + log_error "Invalid --name value: $custom_name" + return 1 + fi + sanitized_name="$(sanitize_branch_name "$branch_name")-${sanitized_custom}" else sanitized_name=$(sanitize_branch_name "$branch_name") fi diff --git a/scripts/generate-completions.sh b/scripts/generate-completions.sh index f324d54..940016d 100755 --- a/scripts/generate-completions.sh +++ b/scripts/generate-completions.sh @@ -115,7 +115,7 @@ _git_gtr() { # If we're completing the first argument after 'git gtr' if [ "$cword" -eq 2 ]; then - COMPREPLY=($(compgen -W "new go run copy editor ai rm mv rename ls list clean doctor adapter config completion init trust help version" -- "$cur")) + COMPREPLY=($(compgen -W "new pr go run copy editor ai rm mv rename ls list clean doctor adapter config completion init trust help version" -- "$cur")) return 0 fi @@ -198,6 +198,11 @@ MIDDLE1 COMPREPLY=($(compgen -W "auto remote local none" -- "$cur")) fi ;; + pr) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--branch -b --repo -R --remote --no-copy --no-hooks --force --name --folder --yes --editor -e --ai -a" -- "$cur")) + fi + ;; completion) # Complete with shell names if [ "$cword" -eq 3 ]; then @@ -288,6 +293,7 @@ _git-gtr() { local -a commands commands=( 'new:Create a new worktree' + 'pr:Create a pull request worktree' 'go:Navigate to worktree' 'run:Execute command in worktree' 'copy:Copy files between worktrees' @@ -337,6 +343,28 @@ _git-gtr() { return fi + # Early handler for `pr` command + if (( CURRENT >= 4 )) && [[ $words[3] == pr ]]; then + _arguments \ + '1:pull request:' \ + '--branch[Local branch name]:branch:' \ + '-b[Local branch name]:branch:' \ + '--repo[GitHub repository]:repo:' \ + '-R[GitHub repository]:repo:' \ + '--remote[Remote used to fetch PR ref]:remote:' \ + '--no-copy[Skip file copying]' \ + '--no-hooks[Skip post-create hooks]' \ + '--force[Allow same branch in multiple worktrees]' \ + '--name[Custom folder name suffix]:name:' \ + '--folder[Custom folder name (replaces default)]:folder:' \ + '--yes[Non-interactive mode]' \ + '--editor[Open in editor after creation]' \ + '-e[Open in editor after creation]' \ + '--ai[Start AI tool after creation]' \ + '-a[Start AI tool after creation]' + return + fi + # Early handler for `clean` command if (( CURRENT >= 4 )) && [[ $words[3] == clean ]]; then _arguments \ @@ -514,6 +542,7 @@ end # Commands complete -f -c git -n '__fish_git_gtr_needs_command' -a new -d 'Create a new worktree' +complete -f -c git -n '__fish_git_gtr_needs_command' -a pr -d 'Create a pull request worktree' complete -f -c git -n '__fish_git_gtr_needs_command' -a go -d 'Navigate to worktree' complete -f -c git -n '__fish_git_gtr_needs_command' -a run -d 'Execute command in worktree' complete -f -c git -n '__fish_git_gtr_needs_command' -a rm -d 'Remove worktree(s)' @@ -552,6 +581,19 @@ complete -c git -n '__fish_git_gtr_using_command new' -l yes -d 'Non-interactive complete -c git -n '__fish_git_gtr_using_command new' -s e -l editor -d 'Open in editor after creation' complete -c git -n '__fish_git_gtr_using_command new' -s a -l ai -d 'Start AI tool after creation' +# Pull request command options +complete -c git -n '__fish_git_gtr_using_command pr' -s b -l branch -d 'Local branch name' -r +complete -c git -n '__fish_git_gtr_using_command pr' -s R -l repo -d 'GitHub repository' -r +complete -c git -n '__fish_git_gtr_using_command pr' -l remote -d 'Remote used to fetch PR ref' -r +complete -c git -n '__fish_git_gtr_using_command pr' -l no-copy -d 'Skip file copying' +complete -c git -n '__fish_git_gtr_using_command pr' -l no-hooks -d 'Skip post-create hooks' +complete -c git -n '__fish_git_gtr_using_command pr' -l force -d 'Allow same branch in multiple worktrees' +complete -c git -n '__fish_git_gtr_using_command pr' -l name -d 'Custom folder name suffix' -r +complete -c git -n '__fish_git_gtr_using_command pr' -l folder -d 'Custom folder name (replaces default)' -r +complete -c git -n '__fish_git_gtr_using_command pr' -l yes -d 'Non-interactive mode' +complete -c git -n '__fish_git_gtr_using_command pr' -s e -l editor -d 'Open in editor after creation' +complete -c git -n '__fish_git_gtr_using_command pr' -s a -l ai -d 'Start AI tool after creation' + # Remove command options complete -c git -n '__fish_git_gtr_using_command rm' -l delete-branch -d 'Delete branch' complete -c git -n '__fish_git_gtr_using_command rm' -l force -d 'Force removal even if worktree has uncommitted changes or untracked files' diff --git a/tests/cmd_help.bats b/tests/cmd_help.bats index ad52c9a..f666a68 100644 --- a/tests/cmd_help.bats +++ b/tests/cmd_help.bats @@ -40,6 +40,13 @@ teardown() { [[ "$output" != *"QUICK START"* ]] } +@test "cmd_help pr shows pull request help" { + run cmd_help pr + [ "$status" -eq 0 ] + [[ "$output" == *"git gtr pr"* ]] + [[ "$output" == *"--branch"* ]] +} + @test "cmd_help editor shows editor help" { run cmd_help editor [ "$status" -eq 0 ] @@ -92,10 +99,11 @@ teardown() { [[ "$output" == *"--dry-run"* ]] } -@test "cmd_help init mentions gtr new --cd" { +@test "cmd_help init mentions create-and-cd commands" { run cmd_help init [ "$status" -eq 0 ] [[ "$output" == *"gtr new my-feature --cd"* ]] + [[ "$output" == *"gtr pr 123 --cd"* ]] } # ── Alias mapping ──────────────────────────────────────────────────────────── diff --git a/tests/cmd_pr.bats b/tests/cmd_pr.bats new file mode 100644 index 0000000..ff71395 --- /dev/null +++ b/tests/cmd_pr.bats @@ -0,0 +1,132 @@ +#!/usr/bin/env bats +# Integration tests for cmd_pr in lib/commands/pr.sh + +load test_helper + +setup() { + setup_integration_repo + source_gtr_commands + TEST_REMOTE_REPO=$(mktemp -d) + TEST_OTHER_REMOTE_ROOT=$(mktemp -d) + git -C "$TEST_REMOTE_REPO" init --bare --quiet + git remote add origin "$TEST_REMOTE_REPO" + git push origin HEAD:refs/heads/main --quiet + git commit --allow-empty -m "pr head" --quiet + TEST_PR_SHA=$(git rev-parse HEAD) + git push origin HEAD:refs/pull/123/head --quiet + TEST_MOCK_BIN=$(mktemp -d) + cat > "$TEST_MOCK_BIN/gh" <<'SCRIPT' +#!/usr/bin/env bash +printf '123\tfeature/from-pr\tocto\texample\thttps://github.com/base/example/pull/123\n' +SCRIPT + chmod +x "$TEST_MOCK_BIN/gh" + export PATH="$TEST_MOCK_BIN:$PATH" +} + +teardown() { + teardown_integration_repo + rm -rf "$TEST_REMOTE_REPO" "$TEST_OTHER_REMOTE_ROOT" "$TEST_MOCK_BIN" +} + +@test "cmd_pr creates worktree from GitHub pull request ref" { + cmd_pr 123 --remote origin --no-copy --no-hooks --yes + + [ -d "$TEST_WORKTREES_DIR/feature-from-pr" ] + [ "$(git -C "$TEST_WORKTREES_DIR/feature-from-pr" rev-parse HEAD)" = "$TEST_PR_SHA" ] + [ "$(git -C "$TEST_WORKTREES_DIR/feature-from-pr" rev-parse --abbrev-ref HEAD)" = "feature/from-pr" ] + [ "$(git config branch.feature/from-pr.remote)" = "https://github.com/octo/example.git" ] + [ "$(git config branch.feature/from-pr.pushRemote)" = "https://github.com/octo/example.git" ] + [ "$(git config branch.feature/from-pr.merge)" = "refs/heads/feature/from-pr" ] +} + +@test "cmd_pr supports custom local branch and folder" { + cmd_pr 123 --branch review/pr-123 --folder review-123 --remote origin --no-copy --no-hooks --yes + + [ -d "$TEST_WORKTREES_DIR/review-123" ] + [ "$(git -C "$TEST_WORKTREES_DIR/review-123" rev-parse HEAD)" = "$TEST_PR_SHA" ] + [ "$(git -C "$TEST_WORKTREES_DIR/review-123" rev-parse --abbrev-ref HEAD)" = "review/pr-123" ] + [ "$(git config branch.review/pr-123.merge)" = "refs/heads/feature/from-pr" ] +} + +@test "cmd_pr reuses ssh remote matching resolved PR base repository" { + git remote add upstream git@github.com:base/example.git + + [ "$(_pr_remote_for_url https://github.com/base/example.git)" = "upstream" ] +} + +@test "cmd_pr fetches the resolved PR base repository by default" { + local other_remote other_sha + other_remote="$TEST_OTHER_REMOTE_ROOT/other.git" + git init --bare "$other_remote" --quiet + git commit --allow-empty -m "other pr head" --quiet + other_sha=$(git rev-parse HEAD) + git push "$other_remote" HEAD:refs/pull/999/head --quiet + git push origin HEAD~1:refs/pull/999/head --quiet + cat > "$TEST_MOCK_BIN/gh" <