From 8ab2d56517282489f80e79571d1bd3b495cb087b Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Wed, 11 Feb 2026 15:15:31 -0800 Subject: [PATCH 01/19] refactor: standardize AI and editor adapter initialization Consolidate common logic for AI and editor adapters into reusable functions. Introduce _ai_define_standard and _editor_define_standard to streamline adapter setup, reducing redundancy across multiple adapter scripts. This refactor enhances maintainability and clarity in the codebase. --- adapters/ai/aider.sh | 29 +---- adapters/ai/auggie.sh | 30 +---- adapters/ai/codex.sh | 33 ++---- adapters/ai/continue.sh | 29 +---- adapters/ai/copilot.sh | 35 ++---- adapters/ai/gemini.sh | 33 ++---- adapters/ai/opencode.sh | 30 +---- adapters/editor/atom.sh | 20 +--- adapters/editor/cursor.sh | 27 +---- adapters/editor/idea.sh | 20 +--- adapters/editor/pycharm.sh | 20 +--- adapters/editor/sublime.sh | 20 +--- adapters/editor/vscode.sh | 27 +---- adapters/editor/webstorm.sh | 20 +--- adapters/editor/zed.sh | 20 +--- bin/gtr | 202 +++++++++++++++++++------------- lib/config.sh | 224 +++++++++++++++--------------------- lib/copy.sh | 40 ++++--- lib/core.sh | 30 +++-- 19 files changed, 326 insertions(+), 563 deletions(-) diff --git a/adapters/ai/aider.sh b/adapters/ai/aider.sh index 191c3d6..bff7290 100644 --- a/adapters/ai/aider.sh +++ b/adapters/ai/aider.sh @@ -1,28 +1,7 @@ #!/usr/bin/env bash # Aider AI coding assistant adapter -# Check if Aider is available -ai_can_start() { - command -v aider >/dev/null 2>&1 -} - -# Start Aider in a directory -# Usage: ai_start path [args...] -ai_start() { - local path="$1" - shift - - if ! ai_can_start; then - log_error "Aider not found. Install with: pip install aider-chat" - log_info "See https://aider.chat for more information" - return 1 - fi - - if [ ! -d "$path" ]; then - log_error "Directory not found: $path" - return 1 - fi - - # Change to the directory and run aider with any additional arguments - (cd "$path" && aider "$@") -} +_AI_CMD="aider" +_AI_ERR_MSG="Aider not found. Install with: pip install aider-chat" +_AI_INFO_LINES=("See https://aider.chat for more information") +_ai_define_standard diff --git a/adapters/ai/auggie.sh b/adapters/ai/auggie.sh index 274a1f2..4f64093 100644 --- a/adapters/ai/auggie.sh +++ b/adapters/ai/auggie.sh @@ -1,29 +1,7 @@ #!/usr/bin/env bash # Auggie CLI AI adapter -# Check if Auggie is available -ai_can_start() { - command -v auggie >/dev/null 2>&1 -} - -# Start Auggie in a directory -# Usage: ai_start path [args...] -ai_start() { - local path="$1" - shift - - if ! ai_can_start; then - log_error "Auggie CLI not found. Install with: npm install -g @augmentcode/auggie" - log_info "See https://www.augmentcode.com/product/CLI for more information" - return 1 - fi - - if [ ! -d "$path" ]; then - log_error "Directory not found: $path" - return 1 - fi - - # Change to the directory and run auggie with any additional arguments - (cd "$path" && auggie "$@") -} - +_AI_CMD="auggie" +_AI_ERR_MSG="Auggie CLI not found. Install with: npm install -g @augmentcode/auggie" +_AI_INFO_LINES=("See https://www.augmentcode.com/product/CLI for more information") +_ai_define_standard diff --git a/adapters/ai/codex.sh b/adapters/ai/codex.sh index 582f8c3..2a3278f 100644 --- a/adapters/ai/codex.sh +++ b/adapters/ai/codex.sh @@ -1,29 +1,10 @@ #!/usr/bin/env bash # OpenAI Codex CLI adapter -# Check if Codex is available -ai_can_start() { - command -v codex >/dev/null 2>&1 -} - -# Start Codex in a directory -# Usage: ai_start path [args...] -ai_start() { - local path="$1" - shift - - if ! ai_can_start; then - log_error "Codex CLI not found. Install with: npm install -g @openai/codex" - log_info "Or: brew install codex" - log_info "See https://github.com/openai/codex for more info" - return 1 - fi - - if [ ! -d "$path" ]; then - log_error "Directory not found: $path" - return 1 - fi - - # Change to the directory and run codex with any additional arguments - (cd "$path" && codex "$@") -} +_AI_CMD="codex" +_AI_ERR_MSG="Codex CLI not found. Install with: npm install -g @openai/codex" +_AI_INFO_LINES=( + "Or: brew install codex" + "See https://github.com/openai/codex for more info" +) +_ai_define_standard diff --git a/adapters/ai/continue.sh b/adapters/ai/continue.sh index a4672c9..eef2a52 100644 --- a/adapters/ai/continue.sh +++ b/adapters/ai/continue.sh @@ -1,28 +1,7 @@ #!/usr/bin/env bash # Continue CLI adapter -# Check if Continue is available -ai_can_start() { - command -v cn >/dev/null 2>&1 -} - -# Start Continue in a directory -# Usage: ai_start path [args...] -ai_start() { - local path="$1" - shift - - if ! ai_can_start; then - log_error "Continue CLI not found. Install from https://continue.dev" - log_info "See https://docs.continue.dev/cli/install for installation" - return 1 - fi - - if [ ! -d "$path" ]; then - log_error "Directory not found: $path" - return 1 - fi - - # Change to the directory and run cn with any additional arguments - (cd "$path" && cn "$@") -} +_AI_CMD="cn" +_AI_ERR_MSG="Continue CLI not found. Install from https://continue.dev" +_AI_INFO_LINES=("See https://docs.continue.dev/cli/install for installation") +_ai_define_standard diff --git a/adapters/ai/copilot.sh b/adapters/ai/copilot.sh index 16608af..e002742 100644 --- a/adapters/ai/copilot.sh +++ b/adapters/ai/copilot.sh @@ -1,30 +1,11 @@ #!/usr/bin/env bash # GitHub Copilot CLI adapter -# Check if GitHub Copilot CLI is available -ai_can_start() { - command -v copilot >/dev/null 2>&1 -} - -# Start GitHub Copilot CLI in a directory -# Usage: ai_start path [args...] -ai_start() { - local path="$1" - shift - - if ! ai_can_start; then - log_error "GitHub Copilot CLI not found." - log_info "Install with: npm install -g @github/copilot" - log_info "Or: brew install copilot-cli" - log_info "See https://github.com/github/copilot-cli for more information" - return 1 - fi - - if [ ! -d "$path" ]; then - log_error "Directory not found: $path" - return 1 - fi - - # Change to the directory and run copilot with any additional arguments - (cd "$path" && copilot "$@") -} +_AI_CMD="copilot" +_AI_ERR_MSG="GitHub Copilot CLI not found." +_AI_INFO_LINES=( + "Install with: npm install -g @github/copilot" + "Or: brew install copilot-cli" + "See https://github.com/github/copilot-cli for more information" +) +_ai_define_standard diff --git a/adapters/ai/gemini.sh b/adapters/ai/gemini.sh index 2a4d017..4c447ad 100644 --- a/adapters/ai/gemini.sh +++ b/adapters/ai/gemini.sh @@ -1,29 +1,10 @@ #!/usr/bin/env bash # Gemini CLI adapter -# Check if Gemini is available -ai_can_start() { - command -v gemini >/dev/null 2>&1 -} - -# Start Gemini in a directory -# Usage: ai_start path [args...] -ai_start() { - local path="$1" - shift - - if ! ai_can_start; then - log_error "Gemini CLI not found. Install with: npm install -g @google/gemini-cli" - log_info "Or: brew install gemini-cli" - log_info "See https://github.com/google-gemini/gemini-cli for more info" - return 1 - fi - - if [ ! -d "$path" ]; then - log_error "Directory not found: $path" - return 1 - fi - - # Change to the directory and run gemini with any additional arguments - (cd "$path" && gemini "$@") -} \ No newline at end of file +_AI_CMD="gemini" +_AI_ERR_MSG="Gemini CLI not found. Install with: npm install -g @google/gemini-cli" +_AI_INFO_LINES=( + "Or: brew install gemini-cli" + "See https://github.com/google-gemini/gemini-cli for more info" +) +_ai_define_standard diff --git a/adapters/ai/opencode.sh b/adapters/ai/opencode.sh index e801f56..8c5782b 100644 --- a/adapters/ai/opencode.sh +++ b/adapters/ai/opencode.sh @@ -1,29 +1,7 @@ #!/usr/bin/env bash # OpenCode adapter -# Check if OpenCode is available -ai_can_start() { - command -v opencode >/dev/null 2>&1 -} - -# Start OpenCode in a directory -# Usage: ai_start path [args...] -ai_start() { - local path="$1" - shift - - if ! ai_can_start; then - log_error "OpenCode not found. Install from https://opencode.ai" - log_info "Make sure the 'opencode' CLI is available in your PATH" - return 1 - fi - - if [ ! -d "$path" ]; then - log_error "Directory not found: $path" - return 1 - fi - - # Change to the directory and run opencode with any additional arguments - (cd "$path" && opencode "$@") -} - +_AI_CMD="opencode" +_AI_ERR_MSG="OpenCode not found. Install from https://opencode.ai" +_AI_INFO_LINES=("Make sure the 'opencode' CLI is available in your PATH") +_ai_define_standard diff --git a/adapters/editor/atom.sh b/adapters/editor/atom.sh index 4e0acea..98fef7e 100755 --- a/adapters/editor/atom.sh +++ b/adapters/editor/atom.sh @@ -1,20 +1,6 @@ #!/usr/bin/env bash # Atom editor adapter -# Check if Atom is available -editor_can_open() { - command -v atom >/dev/null 2>&1 -} - -# Open a directory in Atom -# Usage: editor_open path -editor_open() { - local path="$1" - - if ! editor_can_open; then - log_error "Atom not found. Install from https://atom.io" - return 1 - fi - - atom "$path" -} +_EDITOR_CMD="atom" +_EDITOR_ERR_MSG="Atom not found. Install from https://atom.io" +_editor_define_standard diff --git a/adapters/editor/cursor.sh b/adapters/editor/cursor.sh index 3c5712e..a6f20bd 100644 --- a/adapters/editor/cursor.sh +++ b/adapters/editor/cursor.sh @@ -1,26 +1,7 @@ #!/usr/bin/env bash # Cursor editor adapter -# Check if Cursor is available -editor_can_open() { - command -v cursor >/dev/null 2>&1 -} - -# Open a directory or workspace file in Cursor -# Usage: editor_open path [workspace_file] -editor_open() { - local path="$1" - local workspace="${2:-}" - - if ! editor_can_open; then - log_error "Cursor not found. Install from https://cursor.com or enable the shell command." - return 1 - fi - - # Open workspace file if provided, otherwise open directory - if [ -n "$workspace" ] && [ -f "$workspace" ]; then - cursor "$workspace" - else - cursor "$path" - fi -} +_EDITOR_CMD="cursor" +_EDITOR_ERR_MSG="Cursor not found. Install from https://cursor.com or enable the shell command." +_EDITOR_WORKSPACE=1 +_editor_define_standard diff --git a/adapters/editor/idea.sh b/adapters/editor/idea.sh index c6dc4d4..e5369fe 100755 --- a/adapters/editor/idea.sh +++ b/adapters/editor/idea.sh @@ -1,20 +1,6 @@ #!/usr/bin/env bash # IntelliJ IDEA editor adapter -# Check if IntelliJ IDEA is available -editor_can_open() { - command -v idea >/dev/null 2>&1 -} - -# Open a directory in IntelliJ IDEA -# Usage: editor_open path -editor_open() { - local path="$1" - - if ! editor_can_open; then - log_error "IntelliJ IDEA 'idea' command not found. Enable shell launcher in Tools > Create Command-line Launcher" - return 1 - fi - - idea "$path" -} +_EDITOR_CMD="idea" +_EDITOR_ERR_MSG="IntelliJ IDEA 'idea' command not found. Enable shell launcher in Tools > Create Command-line Launcher" +_editor_define_standard diff --git a/adapters/editor/pycharm.sh b/adapters/editor/pycharm.sh index 74c5499..b10c7e5 100755 --- a/adapters/editor/pycharm.sh +++ b/adapters/editor/pycharm.sh @@ -1,20 +1,6 @@ #!/usr/bin/env bash # PyCharm editor adapter -# Check if PyCharm is available -editor_can_open() { - command -v pycharm >/dev/null 2>&1 -} - -# Open a directory in PyCharm -# Usage: editor_open path -editor_open() { - local path="$1" - - if ! editor_can_open; then - log_error "PyCharm 'pycharm' command not found. Enable shell launcher in Tools > Create Command-line Launcher" - return 1 - fi - - pycharm "$path" -} +_EDITOR_CMD="pycharm" +_EDITOR_ERR_MSG="PyCharm 'pycharm' command not found. Enable shell launcher in Tools > Create Command-line Launcher" +_editor_define_standard diff --git a/adapters/editor/sublime.sh b/adapters/editor/sublime.sh index dff5c50..8a997b2 100755 --- a/adapters/editor/sublime.sh +++ b/adapters/editor/sublime.sh @@ -1,20 +1,6 @@ #!/usr/bin/env bash # Sublime Text editor adapter -# Check if Sublime Text is available -editor_can_open() { - command -v subl >/dev/null 2>&1 -} - -# Open a directory in Sublime Text -# Usage: editor_open path -editor_open() { - local path="$1" - - if ! editor_can_open; then - log_error "Sublime Text 'subl' command not found. Install from https://www.sublimetext.com" - return 1 - fi - - subl "$path" -} +_EDITOR_CMD="subl" +_EDITOR_ERR_MSG="Sublime Text 'subl' command not found. Install from https://www.sublimetext.com" +_editor_define_standard diff --git a/adapters/editor/vscode.sh b/adapters/editor/vscode.sh index 5015ae1..aac5719 100644 --- a/adapters/editor/vscode.sh +++ b/adapters/editor/vscode.sh @@ -1,26 +1,7 @@ #!/usr/bin/env bash # VS Code editor adapter -# Check if VS Code is available -editor_can_open() { - command -v code >/dev/null 2>&1 -} - -# Open a directory or workspace file in VS Code -# Usage: editor_open path [workspace_file] -editor_open() { - local path="$1" - local workspace="${2:-}" - - if ! editor_can_open; then - log_error "VS Code 'code' command not found. Install from https://code.visualstudio.com" - return 1 - fi - - # Open workspace file if provided, otherwise open directory - if [ -n "$workspace" ] && [ -f "$workspace" ]; then - code "$workspace" - else - code "$path" - fi -} +_EDITOR_CMD="code" +_EDITOR_ERR_MSG="VS Code 'code' command not found. Install from https://code.visualstudio.com" +_EDITOR_WORKSPACE=1 +_editor_define_standard diff --git a/adapters/editor/webstorm.sh b/adapters/editor/webstorm.sh index ea55c5c..29ba7b9 100755 --- a/adapters/editor/webstorm.sh +++ b/adapters/editor/webstorm.sh @@ -1,20 +1,6 @@ #!/usr/bin/env bash # WebStorm editor adapter -# Check if WebStorm is available -editor_can_open() { - command -v webstorm >/dev/null 2>&1 -} - -# Open a directory in WebStorm -# Usage: editor_open path -editor_open() { - local path="$1" - - if ! editor_can_open; then - log_error "WebStorm 'webstorm' command not found. Enable shell launcher in Tools > Create Command-line Launcher" - return 1 - fi - - webstorm "$path" -} +_EDITOR_CMD="webstorm" +_EDITOR_ERR_MSG="WebStorm 'webstorm' command not found. Enable shell launcher in Tools > Create Command-line Launcher" +_editor_define_standard diff --git a/adapters/editor/zed.sh b/adapters/editor/zed.sh index 8664e8b..983a8a7 100644 --- a/adapters/editor/zed.sh +++ b/adapters/editor/zed.sh @@ -1,20 +1,6 @@ #!/usr/bin/env bash # Zed editor adapter -# Check if Zed is available -editor_can_open() { - command -v zed >/dev/null 2>&1 -} - -# Open a directory in Zed -# Usage: editor_open path -editor_open() { - local path="$1" - - if ! editor_can_open; then - log_error "Zed not found. Install from https://zed.dev" - return 1 - fi - - zed "$path" -} +_EDITOR_CMD="zed" +_EDITOR_ERR_MSG="Zed not found. Install from https://zed.dev" +_editor_define_standard diff --git a/bin/gtr b/bin/gtr index 57847a9..e830f7e 100755 --- a/bin/gtr +++ b/bin/gtr @@ -64,6 +64,53 @@ ai_start() { (cd "$path" && eval "$GTR_AI_CMD \"\$@\"") } +# Standard AI adapter builder — used by adapter files that follow the common pattern +# Sets globals then call this: _AI_CMD, _AI_ERR_MSG, _AI_INFO_LINES (array) +_ai_define_standard() { + ai_can_start() { + command -v "$_AI_CMD" >/dev/null 2>&1 + } + + ai_start() { + local path="$1"; shift + if ! ai_can_start; then + log_error "$_AI_ERR_MSG" + local _line + for _line in "${_AI_INFO_LINES[@]}"; do + log_info "$_line" + done + return 1 + fi + if [ ! -d "$path" ]; then + log_error "Directory not found: $path" + return 1 + fi + (cd "$path" && "$_AI_CMD" "$@") + } +} + +# Standard editor adapter builder — used by adapter files that follow the common pattern +# Sets globals then call this: _EDITOR_CMD, _EDITOR_ERR_MSG, _EDITOR_WORKSPACE (optional, 0 or 1) +_editor_define_standard() { + editor_can_open() { + command -v "$_EDITOR_CMD" >/dev/null 2>&1 + } + + editor_open() { + local path="$1" + local workspace="${2:-}" + if ! editor_can_open; then + log_error "$_EDITOR_ERR_MSG" + return 1 + fi + if [ "${_EDITOR_WORKSPACE:-0}" = "1" ] && [ -n "$workspace" ] && [ -f "$workspace" ]; then + "$_EDITOR_CMD" "$workspace" + else + "$_EDITOR_CMD" "$path" + fi + } +} + # Main dispatcher main() { local cmd="${1:-help}" @@ -200,6 +247,60 @@ _post_create_next_steps() { echo " cd \"\$(git gtr go $next_steps_id)\" # Navigate to worktree" } +# Determine the base ref for worktree creation +# Usage: _create_resolve_from_ref +# Prints: resolved ref +_create_resolve_from_ref() { + local from_ref="$1" from_current="$2" repo_root="$3" + + if [ -z "$from_ref" ]; then + if [ "$from_current" -eq 1 ]; then + from_ref=$(get_current_branch) + if [ -z "$from_ref" ] || [ "$from_ref" = "HEAD" ]; then + log_warn "Currently in detached HEAD state - falling back to default branch" + from_ref=$(resolve_default_branch "$repo_root") + else + log_info "Creating from current branch: $from_ref" + fi + else + from_ref=$(resolve_default_branch "$repo_root") + fi + fi + + printf "%s" "$from_ref" +} + +# Auto-launch editor for a worktree +_auto_launch_editor() { + local worktree_path="$1" + local editor + editor=$(cfg_default gtr.editor.default GTR_EDITOR_DEFAULT "none" defaults.editor) + if [ "$editor" != "none" ]; then + load_editor_adapter "$editor" + local workspace_file + workspace_file=$(resolve_workspace_file "$worktree_path") + log_step "Opening in $editor..." + editor_open "$worktree_path" "$workspace_file" + else + open_in_gui "$worktree_path" + log_info "Opened in file browser (no editor configured)" + fi +} + +# Auto-launch AI tool for a worktree +_auto_launch_ai() { + local worktree_path="$1" + local ai_tool + ai_tool=$(cfg_default gtr.ai.default GTR_AI_DEFAULT "none" defaults.ai) + if [ "$ai_tool" = "none" ]; then + log_warn "No AI tool configured. Set with: git gtr config set gtr.ai.default claude" + else + load_ai_adapter "$ai_tool" + log_step "Starting $ai_tool..." + ai_start "$worktree_path" + fi +} + cmd_create() { local branch_name="" local from_ref="" @@ -316,25 +417,7 @@ cmd_create() { fi # Determine from_ref with precedence: --from > --from-current > default - if [ -z "$from_ref" ]; then - if [ "$from_current" -eq 1 ]; then - # Get current branch (try modern git first, then fallback) - from_ref=$(git branch --show-current 2>/dev/null) - if [ -z "$from_ref" ]; then - from_ref=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) - fi - - # Handle detached HEAD state - if [ -z "$from_ref" ] || [ "$from_ref" = "HEAD" ]; then - log_warn "Currently in detached HEAD state - falling back to default branch" - from_ref=$(resolve_default_branch "$repo_root") - else - log_info "Creating from current branch: $from_ref" - fi - else - from_ref=$(resolve_default_branch "$repo_root") - fi - fi + from_ref=$(_create_resolve_from_ref "$from_ref" "$from_current" "$repo_root") # Construct folder name for display local folder_name @@ -371,36 +454,9 @@ cmd_create() { echo "" log_info "Worktree created: $worktree_path" - # Auto-launch editor if requested - if [ "$open_editor" -eq 1 ]; then - local editor - editor=$(cfg_default gtr.editor.default GTR_EDITOR_DEFAULT "none" defaults.editor) - if [ "$editor" != "none" ]; then - load_editor_adapter "$editor" - local workspace_file - workspace_file=$(resolve_workspace_file "$worktree_path") - log_step "Opening in $editor..." - editor_open "$worktree_path" "$workspace_file" - else - open_in_gui "$worktree_path" - log_info "Opened in file browser (no editor configured)" - fi - fi - - # Auto-launch AI tool if requested - if [ "$start_ai" -eq 1 ]; then - local ai_tool - ai_tool=$(cfg_default gtr.ai.default GTR_AI_DEFAULT "none" defaults.ai) - if [ "$ai_tool" = "none" ]; then - log_warn "No AI tool configured. Set with: git gtr config set gtr.ai.default claude" - else - load_ai_adapter "$ai_tool" - log_step "Starting $ai_tool..." - ai_start "$worktree_path" - fi - fi - - # Show next steps only if no auto-launch flags were used + # Auto-launch editor/AI or show next steps + [ "$open_editor" -eq 1 ] && _auto_launch_editor "$worktree_path" + [ "$start_ai" -eq 1 ] && _auto_launch_ai "$worktree_path" 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 @@ -449,9 +505,8 @@ cmd_remove() { for identifier in $identifiers; do # Resolve target branch - local target is_main worktree_path branch_name - target=$(resolve_target "$identifier" "$repo_root" "$base_dir" "$prefix") || continue - unpack_target "$target" + local is_main worktree_path branch_name + resolve_worktree "$identifier" "$repo_root" "$base_dir" "$prefix" || continue is_main="$_ctx_is_main" worktree_path="$_ctx_worktree_path" branch_name="$_ctx_branch" # Cannot remove main repository @@ -544,9 +599,8 @@ cmd_rename() { local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix" # Resolve old worktree - local target is_main old_path old_branch - target=$(resolve_target "$old_identifier" "$repo_root" "$base_dir" "$prefix") || exit 1 - unpack_target "$target" + local is_main old_path old_branch + resolve_worktree "$old_identifier" "$repo_root" "$base_dir" "$prefix" || exit 1 is_main="$_ctx_is_main" old_path="$_ctx_worktree_path" old_branch="$_ctx_branch" # Cannot rename main repository @@ -629,9 +683,8 @@ cmd_go() { local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix" # Resolve target branch - local target is_main worktree_path branch - target=$(resolve_target "$identifier" "$repo_root" "$base_dir" "$prefix") || exit 1 - unpack_target "$target" + local is_main worktree_path branch + resolve_worktree "$identifier" "$repo_root" "$base_dir" "$prefix" || exit 1 is_main="$_ctx_is_main" worktree_path="$_ctx_worktree_path" branch="$_ctx_branch" # Human messages to stderr so stdout can be used in command substitution @@ -686,9 +739,8 @@ cmd_run() { local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix" # Resolve target branch - local target is_main worktree_path branch - target=$(resolve_target "$identifier" "$repo_root" "$base_dir" "$prefix") || exit 1 - unpack_target "$target" + local is_main worktree_path branch + resolve_worktree "$identifier" "$repo_root" "$base_dir" "$prefix" || exit 1 is_main="$_ctx_is_main" worktree_path="$_ctx_worktree_path" branch="$_ctx_branch" # Human messages to stderr (like cmd_go) @@ -762,9 +814,8 @@ cmd_copy() { local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix" # Resolve source path - local src_target src_path - src_target=$(resolve_target "$source" "$repo_root" "$base_dir" "$prefix") || exit 1 - unpack_target "$src_target" + local src_path + resolve_worktree "$source" "$repo_root" "$base_dir" "$prefix" || exit 1 src_path="$_ctx_worktree_path" # Get patterns (flag > config) @@ -804,9 +855,8 @@ cmd_copy() { # Process each target local copied_any=0 for target_id in $targets; do - local dst_target dst_path dst_branch - dst_target=$(resolve_target "$target_id" "$repo_root" "$base_dir" "$prefix") || continue - unpack_target "$dst_target" + local dst_path dst_branch + resolve_worktree "$target_id" "$repo_root" "$base_dir" "$prefix" || continue dst_path="$_ctx_worktree_path" dst_branch="$_ctx_branch" # Skip if source == destination @@ -866,9 +916,8 @@ cmd_editor() { local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix" # Resolve target branch - local target worktree_path branch - target=$(resolve_target "$identifier" "$repo_root" "$base_dir" "$prefix") || exit 1 - unpack_target "$target" + local worktree_path branch + resolve_worktree "$identifier" "$repo_root" "$base_dir" "$prefix" || exit 1 worktree_path="$_ctx_worktree_path" branch="$_ctx_branch" if [ "$editor" = "none" ]; then @@ -940,9 +989,8 @@ cmd_ai() { local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix" # Resolve target branch - local target worktree_path branch - target=$(resolve_target "$identifier" "$repo_root" "$base_dir" "$prefix") || exit 1 - unpack_target "$target" + local worktree_path branch + resolve_worktree "$identifier" "$repo_root" "$base_dir" "$prefix" || exit 1 worktree_path="$_ctx_worktree_path" branch="$_ctx_branch" log_step "Starting $ai_tool for: $branch" @@ -978,9 +1026,7 @@ cmd_list() { if [ "$porcelain" -eq 1 ]; then # Output: pathbranchstatus local branch status - # Try --show-current (Git 2.22+), fallback to rev-parse for older Git - branch=$(git -C "$repo_root" branch --show-current 2>/dev/null) - [ -z "$branch" ] && branch=$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null) + branch=$(get_current_branch "$repo_root") [ -z "$branch" ] || [ "$branch" = "HEAD" ] && branch="(detached)" status=$(worktree_status "$repo_root") printf "%s\t%s\t%s\n" "$repo_root" "$branch" "$status" @@ -1009,9 +1055,7 @@ cmd_list() { # Always show repo root first local branch - # Try --show-current (Git 2.22+), fallback to rev-parse for older Git - branch=$(git -C "$repo_root" branch --show-current 2>/dev/null) - [ -z "$branch" ] && branch=$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null) + branch=$(get_current_branch "$repo_root") [ -z "$branch" ] || [ "$branch" = "HEAD" ] && branch="(detached)" printf "%-30s %s\n" "$branch [main repo]" "$repo_root" diff --git a/lib/config.sh b/lib/config.sh index d289704..d68bcfc 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -77,54 +77,52 @@ cfg_get() { git config $flag --get "$key" 2>/dev/null || true } +# Single source of truth for gtr.* <-> .gtrconfig key mapping +# Format: "gtr_key|file_key" — add new config keys here only +_CFG_KEY_MAP=( + "gtr.copy.include|copy.include" + "gtr.copy.exclude|copy.exclude" + "gtr.copy.includeDirs|copy.includeDirs" + "gtr.copy.excludeDirs|copy.excludeDirs" + "gtr.hook.postCreate|hooks.postCreate" + "gtr.hook.preRemove|hooks.preRemove" + "gtr.hook.postRemove|hooks.postRemove" + "gtr.hook.postCd|hooks.postCd" + "gtr.editor.default|defaults.editor" + "gtr.editor.workspace|editor.workspace" + "gtr.ai.default|defaults.ai" + "gtr.worktrees.dir|worktrees.dir" + "gtr.worktrees.prefix|worktrees.prefix" + "gtr.defaultBranch|defaults.branch" + "gtr.provider|defaults.provider" +) + # Map a gtr.* config key to its .gtrconfig equivalent # Usage: cfg_map_to_file_key # Returns: mapped key for .gtrconfig or empty if no mapping exists cfg_map_to_file_key() { - local key="$1" - case "$key" in - gtr.copy.include) echo "copy.include" ;; - gtr.copy.exclude) echo "copy.exclude" ;; - gtr.copy.includeDirs) echo "copy.includeDirs" ;; - gtr.copy.excludeDirs) echo "copy.excludeDirs" ;; - 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" ;; - gtr.worktrees.dir) echo "worktrees.dir" ;; - gtr.worktrees.prefix) echo "worktrees.prefix" ;; - gtr.defaultBranch) echo "defaults.branch" ;; - gtr.provider) echo "defaults.provider" ;; - *) echo "" ;; - esac + local pair + for pair in "${_CFG_KEY_MAP[@]}"; do + if [ "${pair%%|*}" = "$1" ]; then + echo "${pair#*|}" + return + fi + done } # Map a .gtrconfig key to its gtr.* config equivalent (reverse of cfg_map_to_file_key) # Usage: cfg_map_from_file_key # Returns: mapped gtr.* key, or empty if no mapping exists cfg_map_from_file_key() { - case "$1" in - copy.include) echo "gtr.copy.include" ;; - copy.exclude) echo "gtr.copy.exclude" ;; - copy.includeDirs) echo "gtr.copy.includeDirs" ;; - copy.excludeDirs) echo "gtr.copy.excludeDirs" ;; - hooks.postCreate) echo "gtr.hook.postCreate" ;; - hooks.preRemove) echo "gtr.hook.preRemove" ;; - hooks.postRemove) echo "gtr.hook.postRemove" ;; - hooks.postCd) echo "gtr.hook.postCd" ;; - defaults.editor) echo "gtr.editor.default" ;; - editor.workspace) echo "gtr.editor.workspace" ;; - defaults.ai) echo "gtr.ai.default" ;; - worktrees.dir) echo "gtr.worktrees.dir" ;; - worktrees.prefix) echo "gtr.worktrees.prefix" ;; - defaults.branch) echo "gtr.defaultBranch" ;; - defaults.provider) echo "gtr.provider" ;; - gtr.*) echo "$1" ;; - *) echo "" ;; - esac + local pair + for pair in "${_CFG_KEY_MAP[@]}"; do + if [ "${pair#*|}" = "$1" ]; then + echo "${pair%%|*}" + return + fi + done + # Passthrough for gtr.* keys already in canonical form + case "$1" in gtr.*) echo "$1" ;; esac } # Get all values for a multi-valued config key @@ -192,54 +190,42 @@ cfg_bool() { esac } -# Set a config value -# Usage: cfg_set key value [--global] -cfg_set() { - local key="$1" - local value="$2" - local scope="${3:-local}" - local flag="" - - case "$scope" in - --global|global) flag="--global" ;; - --system|system) flag="--system" ;; - --local|local|*) flag="--local" ;; +# Convert scope name to git config flag +# Usage: _cfg_scope_flag +# Returns: --local, --global, or --system +_cfg_scope_flag() { + case "${1:-local}" in + --global|global) echo "--global" ;; + --system|system) echo "--system" ;; + *) echo "--local" ;; esac +} - git config $flag "$key" "$value" +# Set a config value +# Usage: cfg_set key value [scope] +cfg_set() { + local flag + flag=$(_cfg_scope_flag "${3:-local}") + # shellcheck disable=SC2086 + git config $flag "$1" "$2" } # Add a value to a multi-valued config key -# Usage: cfg_add key value [--global] +# Usage: cfg_add key value [scope] cfg_add() { - local key="$1" - local value="$2" - local scope="${3:-local}" - local flag="" - - case "$scope" in - --global|global) flag="--global" ;; - --system|system) flag="--system" ;; - --local|local|*) flag="--local" ;; - esac - - git config $flag --add "$key" "$value" + local flag + flag=$(_cfg_scope_flag "${3:-local}") + # shellcheck disable=SC2086 + git config $flag --add "$1" "$2" } # Unset a config value -# Usage: cfg_unset key [--global] +# Usage: cfg_unset key [scope] cfg_unset() { - local key="$1" - local scope="${2:-local}" - local flag="" - - case "$scope" in - --global|global) flag="--global" ;; - --system|system) flag="--system" ;; - --local|local|*) flag="--local" ;; - esac - - git config $flag --unset-all "$key" 2>/dev/null || true + local flag + flag=$(_cfg_scope_flag "${2:-local}") + # shellcheck disable=SC2086 + git config $flag --unset-all "$1" 2>/dev/null || true } # List all gtr.* config values @@ -271,8 +257,8 @@ cfg_list() { local result="" local key value line - # Set up cleanup trap for helper function (protects against early exit/return) - trap 'unset -f _cfg_list_add_entry 2>/dev/null' RETURN + # Set up cleanup trap for helper functions (protects against early exit/return) + trap 'unset -f _cfg_list_add_entry _cfg_list_parse_entries 2>/dev/null' RETURN # Helper function to add entries with origin (inline to avoid Bash 3.2 nameref issues) # Uses Unit Separator ($'\x1f') as delimiter to avoid conflicts with any values @@ -295,72 +281,50 @@ cfg_list() { result="${result}${entry_key}"$'\x1f'"${entry_value}"$'\x1f'"${origin}"$'\n' } + # Parse get-regexp output and add each entry with an origin label + _cfg_list_parse_entries() { + local origin="$1" + local entries="$2" + while IFS= read -r line; do + [ -z "$line" ] && continue + key="${line%% *}" + if [[ "$line" == *" "* ]]; then + value="${line#* }" + else + value="" + fi + _cfg_list_add_entry "$origin" "$key" "$value" + done <<< "$entries" + } + # Process in priority order: local > .gtrconfig > global > system - local local_entries global_entries system_entries - - # 1. Local git config (highest priority) - local_entries=$(git config --local --get-regexp '^gtr\.' 2>/dev/null || true) - while IFS= read -r line; do - [ -z "$line" ] && continue - key="${line%% *}" - # Handle empty values (no space in line means value is empty) - if [[ "$line" == *" "* ]]; then - value="${line#* }" - else - value="" - fi - _cfg_list_add_entry "local" "$key" "$value" - done <<< "$local_entries" + _cfg_list_parse_entries "local" \ + "$(git config --local --get-regexp '^gtr\.' 2>/dev/null || true)" - # 2. .gtrconfig file (team defaults) + # .gtrconfig needs key remapping from file format to gtr.* format if [ -n "$config_file" ] && [ -f "$config_file" ]; then while IFS= read -r line; do [ -z "$line" ] && continue - local fkey fvalue mapped_key + local fkey mapped_key fkey="${line%% *}" - # Handle empty values (no space in line means value is empty) if [[ "$line" == *" "* ]]; then - fvalue="${line#* }" + value="${line#* }" else - fvalue="" + value="" fi - # Map .gtrconfig keys to gtr.* format mapped_key=$(cfg_map_from_file_key "$fkey") - [ -z "$mapped_key" ] && continue # Skip unmapped keys - _cfg_list_add_entry ".gtrconfig" "$mapped_key" "$fvalue" + [ -z "$mapped_key" ] && continue + _cfg_list_add_entry ".gtrconfig" "$mapped_key" "$value" done < <(git config -f "$config_file" --get-regexp '.' 2>/dev/null || true) fi - # 3. Global git config - global_entries=$(git config --global --get-regexp '^gtr\.' 2>/dev/null || true) - while IFS= read -r line; do - [ -z "$line" ] && continue - key="${line%% *}" - # Handle empty values (no space in line means value is empty) - if [[ "$line" == *" "* ]]; then - value="${line#* }" - else - value="" - fi - _cfg_list_add_entry "global" "$key" "$value" - done <<< "$global_entries" - - # 4. System git config (lowest priority) - system_entries=$(git config --system --get-regexp '^gtr\.' 2>/dev/null || true) - while IFS= read -r line; do - [ -z "$line" ] && continue - key="${line%% *}" - # Handle empty values (no space in line means value is empty) - if [[ "$line" == *" "* ]]; then - value="${line#* }" - else - value="" - fi - _cfg_list_add_entry "system" "$key" "$value" - done <<< "$system_entries" + _cfg_list_parse_entries "global" \ + "$(git config --global --get-regexp '^gtr\.' 2>/dev/null || true)" + _cfg_list_parse_entries "system" \ + "$(git config --system --get-regexp '^gtr\.' 2>/dev/null || true)" - # Clean up helper function and clear trap (trap handles early exit cases) - unset -f _cfg_list_add_entry + # Clean up helper functions and clear trap (trap handles early exit cases) + unset -f _cfg_list_add_entry _cfg_list_parse_entries trap - RETURN output="$result" diff --git a/lib/copy.sh b/lib/copy.sh index b2c79c0..80fc3e7 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -1,6 +1,16 @@ #!/usr/bin/env bash # File copying utilities with pattern matching +# Check if a path/pattern is unsafe (absolute or contains directory traversal) +# Usage: _is_unsafe_path "pattern" +# Returns: 0 if unsafe, 1 if safe +_is_unsafe_path() { + case "$1" in + /*|*/../*|../*|*/..|..) return 0 ;; + esac + return 1 +} + # Check if a path matches any exclude pattern # Usage: is_excluded "path" "excludes_newline_separated" # Returns: 0 if excluded, 1 if not @@ -83,12 +93,10 @@ copy_patterns() { [ -z "$pattern" ] && continue # Security: reject absolute paths and parent directory traversal - case "$pattern" in - /*|*/../*|../*|*/..|..) - log_warn "Skipping unsafe pattern (absolute path or '..' path segment): $pattern" - continue - ;; - esac + if _is_unsafe_path "$pattern"; then + log_warn "Skipping unsafe pattern (absolute path or '..' path segment): $pattern" + continue + fi # Detect if pattern uses ** (requires globstar) if [ "$have_globstar" -eq 0 ] && echo "$pattern" | grep -q '\*\*'; then @@ -215,12 +223,10 @@ copy_directories() { [ -z "$pattern" ] && continue # Security: reject absolute paths and parent directory traversal - case "$pattern" in - /*|*/../*|../*|*/..|..) - log_warn "Skipping unsafe pattern: $pattern" - continue - ;; - esac + if _is_unsafe_path "$pattern"; then + log_warn "Skipping unsafe pattern: $pattern" + continue + fi # Find directories matching the pattern # Use -path for patterns with slashes (e.g., vendor/bundle), -name for basenames @@ -255,12 +261,10 @@ copy_directories() { [ -z "$exclude_pattern" ] && continue # Security: reject absolute paths and parent directory traversal in excludes - case "$exclude_pattern" in - /*|*/../*|../*|*/..|..) - log_warn "Skipping unsafe exclude pattern: $exclude_pattern" - continue - ;; - esac + if _is_unsafe_path "$exclude_pattern"; then + log_warn "Skipping unsafe exclude pattern: $exclude_pattern" + continue + fi # Check if pattern applies to this copied directory # Supports patterns like: diff --git a/lib/core.sh b/lib/core.sh index e60f906..cf2e3d6 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -139,21 +139,28 @@ resolve_default_branch() { fi } -# Get the current branch of a worktree +# Get current branch name with Git 2.22+ fallback +# Usage: get_current_branch [directory] +# Returns: branch name, "HEAD" if detached, or empty +get_current_branch() { + local dir_flag="" + # shellcheck disable=SC2086 + [ -n "${1:-}" ] && dir_flag="-C $1" + git $dir_flag branch --show-current 2>/dev/null || + git $dir_flag rev-parse --abbrev-ref HEAD 2>/dev/null +} + +# Get the current branch of a worktree (with detached HEAD normalization) # Usage: current_branch worktree_path current_branch() { local worktree_path="$1" - local branch if [ ! -d "$worktree_path" ]; then return 1 fi - # Try --show-current (Git 2.22+), fallback to rev-parse for older Git - branch=$(cd "$worktree_path" && git branch --show-current 2>/dev/null) - if [ -z "$branch" ]; then - branch=$(cd "$worktree_path" && git rev-parse --abbrev-ref HEAD 2>/dev/null) - fi + local branch + branch=$(get_current_branch "$worktree_path") # Normalize detached HEAD if [ "$branch" = "HEAD" ]; then @@ -280,6 +287,15 @@ unpack_target() { read _ctx_is_main _ctx_worktree_path _ctx_branch <<< "$1" } +# Resolve an identifier to a worktree and set _ctx_* variables in one step +# Usage: resolve_worktree +# Sets: _ctx_is_main, _ctx_worktree_path, _ctx_branch +resolve_worktree() { + local target + target=$(resolve_target "$1" "$2" "$3" "$4") || return 1 + unpack_target "$target" +} + # Create a new git worktree # Usage: create_worktree base_dir prefix branch_name from_ref track_mode [skip_fetch] [force] [custom_name] [folder_override] # track_mode: auto, remote, local, or none From 1aa5b20a841d68073d1da9a468aff2783f793534 Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Wed, 11 Feb 2026 15:33:44 -0800 Subject: [PATCH 02/19] refactor: streamline editor adapter scripts with terminal definition Consolidate the logic for checking and opening editors (Emacs, Neovim, Vim) into a single terminal editor adapter function. This refactor reduces redundancy in the editor scripts and enhances maintainability by utilizing shared global variables for command execution and error messaging. --- adapters/editor/emacs.sh | 22 ++++---------------- adapters/editor/nvim.sh | 21 +++---------------- adapters/editor/vim.sh | 21 +++---------------- bin/gtr | 44 +++++++++++++++++++++++++++++++++------- lib/config.sh | 11 ++++++++++ 5 files changed, 58 insertions(+), 61 deletions(-) diff --git a/adapters/editor/emacs.sh b/adapters/editor/emacs.sh index 473a79f..d0ba404 100755 --- a/adapters/editor/emacs.sh +++ b/adapters/editor/emacs.sh @@ -1,21 +1,7 @@ #!/usr/bin/env bash # Emacs editor adapter -# Check if Emacs is available -editor_can_open() { - command -v emacs >/dev/null 2>&1 -} - -# Open a directory in Emacs -# Usage: editor_open path -editor_open() { - local path="$1" - - if ! editor_can_open; then - log_error "Emacs not found. Install from https://www.gnu.org/software/emacs/" - return 1 - fi - - # Open emacs with the directory - emacs "$path" & -} +_EDITOR_CMD="emacs" +_EDITOR_ERR_MSG="Emacs not found. Install from https://www.gnu.org/software/emacs/" +_EDITOR_BACKGROUND=1 +_editor_define_terminal diff --git a/adapters/editor/nvim.sh b/adapters/editor/nvim.sh index d845cae..61193df 100755 --- a/adapters/editor/nvim.sh +++ b/adapters/editor/nvim.sh @@ -1,21 +1,6 @@ #!/usr/bin/env bash # Neovim editor adapter -# Check if Neovim is available -editor_can_open() { - command -v nvim >/dev/null 2>&1 -} - -# Open a directory in Neovim -# Usage: editor_open path -editor_open() { - local path="$1" - - if ! editor_can_open; then - log_error "Neovim not found. Install from https://neovim.io" - return 1 - fi - - # Open neovim in the directory - (cd "$path" && nvim .) -} +_EDITOR_CMD="nvim" +_EDITOR_ERR_MSG="Neovim not found. Install from https://neovim.io" +_editor_define_terminal diff --git a/adapters/editor/vim.sh b/adapters/editor/vim.sh index 2f1b0e4..3b41b1c 100755 --- a/adapters/editor/vim.sh +++ b/adapters/editor/vim.sh @@ -1,21 +1,6 @@ #!/usr/bin/env bash # Vim editor adapter -# Check if Vim is available -editor_can_open() { - command -v vim >/dev/null 2>&1 -} - -# Open a directory in Vim -# Usage: editor_open path -editor_open() { - local path="$1" - - if ! editor_can_open; then - log_error "Vim not found. Install via your package manager." - return 1 - fi - - # Open vim in the directory - (cd "$path" && vim .) -} +_EDITOR_CMD="vim" +_EDITOR_ERR_MSG="Vim not found. Install via your package manager." +_editor_define_terminal diff --git a/bin/gtr b/bin/gtr index e830f7e..cd9f892 100755 --- a/bin/gtr +++ b/bin/gtr @@ -111,6 +111,27 @@ _editor_define_standard() { } } +# Terminal editor adapter builder — for editors that run in the current terminal +# Sets globals then call this: _EDITOR_CMD, _EDITOR_ERR_MSG, _EDITOR_BACKGROUND (optional, 0 or 1) +_editor_define_terminal() { + editor_can_open() { + command -v "$_EDITOR_CMD" >/dev/null 2>&1 + } + + editor_open() { + local path="$1" + if ! editor_can_open; then + log_error "$_EDITOR_ERR_MSG" + return 1 + fi + if [ "${_EDITOR_BACKGROUND:-0}" = "1" ]; then + "$_EDITOR_CMD" "$path" & + else + (cd "$path" && "$_EDITOR_CMD" .) + fi + } +} + # Main dispatcher main() { local cmd="${1:-help}" @@ -276,7 +297,7 @@ _auto_launch_editor() { local editor editor=$(cfg_default gtr.editor.default GTR_EDITOR_DEFAULT "none" defaults.editor) if [ "$editor" != "none" ]; then - load_editor_adapter "$editor" + load_editor_adapter "$editor" || return 1 local workspace_file workspace_file=$(resolve_workspace_file "$worktree_path") log_step "Opening in $editor..." @@ -295,7 +316,7 @@ _auto_launch_ai() { if [ "$ai_tool" = "none" ]; then log_warn "No AI tool configured. Set with: git gtr config set gtr.ai.default claude" else - load_ai_adapter "$ai_tool" + load_ai_adapter "$ai_tool" || return 1 log_step "Starting $ai_tool..." ai_start "$worktree_path" fi @@ -455,8 +476,8 @@ cmd_create() { log_info "Worktree created: $worktree_path" # Auto-launch editor/AI or show next steps - [ "$open_editor" -eq 1 ] && _auto_launch_editor "$worktree_path" - [ "$start_ai" -eq 1 ] && _auto_launch_ai "$worktree_path" + [ "$open_editor" -eq 1 ] && { _auto_launch_editor "$worktree_path" || true; } + [ "$start_ai" -eq 1 ] && { _auto_launch_ai "$worktree_path" || 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 @@ -926,7 +947,7 @@ cmd_editor() { log_info "Opened in file browser" else # Load editor adapter and open - load_editor_adapter "$editor" + load_editor_adapter "$editor" || exit 1 local workspace_file workspace_file=$(resolve_workspace_file "$worktree_path") log_step "Opening in $editor..." @@ -983,7 +1004,7 @@ cmd_ai() { fi # Load AI adapter - load_ai_adapter "$ai_tool" + load_ai_adapter "$ai_tool" || exit 1 resolve_repo_context || exit 1 local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix" @@ -1525,6 +1546,9 @@ cmd_config() { if [ -n "$extra_args" ]; then log_warn "set action: ignoring extra arguments: $extra_args" fi + if ! _cfg_is_known_key "$key"; then + log_warn "Unknown config key: $key (not a recognized gtr.* key)" + fi cfg_set "$key" "$value" "$resolved_scope" log_info "Config set: $key = $value ($resolved_scope)" ;; @@ -1537,6 +1561,9 @@ cmd_config() { if [ -n "$extra_args" ]; then log_warn "add action: ignoring extra arguments: $extra_args" fi + if ! _cfg_is_known_key "$key"; then + log_warn "Unknown config key: $key (not a recognized gtr.* key)" + fi cfg_add "$key" "$value" "$resolved_scope" log_info "Config added: $key = $value ($resolved_scope)" ;; @@ -1549,6 +1576,9 @@ cmd_config() { if [ -n "$value" ] || [ -n "$extra_args" ]; then log_warn "unset action: ignoring extra arguments: ${value}${value:+ }${extra_args}" fi + if ! _cfg_is_known_key "$key"; then + log_warn "Unknown config key: $key (not a recognized gtr.* key)" + fi cfg_unset "$key" "$resolved_scope" log_info "Config unset: $key ($resolved_scope)" ;; @@ -1878,7 +1908,7 @@ _load_adapter() { log_error "$label '$name' not found" log_info "Built-in adapters: $builtin_list" log_info "Or use any $label command available in your PATH (e.g., $path_hint)" - exit 1 + return 1 fi # Set globals for generic adapter functions diff --git a/lib/config.sh b/lib/config.sh index d68bcfc..3ba06e4 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -125,6 +125,17 @@ cfg_map_from_file_key() { case "$1" in gtr.*) echo "$1" ;; esac } +# Check if a key is a recognized gtr.* config key +# Usage: _cfg_is_known_key +# Returns: 0 if known, 1 if not +_cfg_is_known_key() { + local pair + for pair in "${_CFG_KEY_MAP[@]}"; do + [ "${pair%%|*}" = "$1" ] && return 0 + done + return 1 +} + # Get all values for a multi-valued config key # Usage: cfg_get_all key [file_key] [scope] # file_key: optional key name in .gtrconfig (e.g., "copy.include" for gtr.copy.include) From 9cd81a43fb8fd9cc89c92ea64a16db297c3cc49f Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Wed, 11 Feb 2026 15:35:22 -0800 Subject: [PATCH 03/19] refactor: simplify get_current_branch function logic Refactor the get_current_branch function to improve clarity and maintainability. The updated implementation uses conditional statements to handle directory input more cleanly, eliminating redundancy in the command execution for both specified and current directories. --- lib/core.sh | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/core.sh b/lib/core.sh index cf2e3d6..9907530 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -143,11 +143,13 @@ resolve_default_branch() { # Usage: get_current_branch [directory] # Returns: branch name, "HEAD" if detached, or empty get_current_branch() { - local dir_flag="" - # shellcheck disable=SC2086 - [ -n "${1:-}" ] && dir_flag="-C $1" - git $dir_flag branch --show-current 2>/dev/null || - git $dir_flag rev-parse --abbrev-ref HEAD 2>/dev/null + if [ -n "${1:-}" ]; then + git -C "$1" branch --show-current 2>/dev/null || + git -C "$1" rev-parse --abbrev-ref HEAD 2>/dev/null + else + git branch --show-current 2>/dev/null || + git rev-parse --abbrev-ref HEAD 2>/dev/null + fi } # Get the current branch of a worktree (with detached HEAD normalization) From 06c2301c8bcb1b247b8cad8dd55af5b2dee53b48 Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Wed, 11 Feb 2026 15:53:40 -0800 Subject: [PATCH 04/19] feat: add CI workflow for linting and testing Introduce a GitHub Actions workflow to automate linting with ShellCheck and testing with BATS. The workflow triggers on pushes and pull requests to the main branch, ensuring code quality and functionality through automated checks. Additionally, refactor the copy function in lib/copy.sh to improve maintainability by extracting the file copying logic into a dedicated helper function. --- .github/workflows/lint.yml | 33 +++++++++++ bin/gtr | 15 ++++- lib/copy.sh | 102 ++++++++++++++------------------ lib/core.sh | 12 ++-- lib/hooks.sh | 1 + lib/platform.sh | 25 ++++++-- tests/copy_safety.bats | 84 ++++++++++++++++++++++++++ tests/resolve_base_dir.bats | 40 +++++++++++++ tests/sanitize_branch_name.bats | 51 ++++++++++++++++ tests/test_helper.bash | 17 ++++++ 10 files changed, 310 insertions(+), 70 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 tests/copy_safety.bats create mode 100644 tests/resolve_base_dir.bats create mode 100644 tests/sanitize_branch_name.bats create mode 100644 tests/test_helper.bash diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..e24cfa9 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + shellcheck: + name: ShellCheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install ShellCheck + run: sudo apt-get install -y shellcheck + + - name: Run ShellCheck + run: | + shellcheck bin/gtr bin/git-gtr lib/*.sh adapters/editor/*.sh adapters/ai/*.sh + + test: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install BATS + run: sudo apt-get install -y bats + + - name: Run tests + run: bats tests/ diff --git a/bin/gtr b/bin/gtr index cd9f892..23ee89d 100755 --- a/bin/gtr +++ b/bin/gtr @@ -2,6 +2,8 @@ # gtr - Git worktree runner # Portable, cross-platform git worktree management +# shellcheck disable=SC2329 # Functions defined inside adapter builders are invoked indirectly + set -e # Version @@ -1048,7 +1050,9 @@ cmd_list() { # Output: pathbranchstatus local branch status branch=$(get_current_branch "$repo_root") - [ -z "$branch" ] || [ "$branch" = "HEAD" ] && branch="(detached)" + if [ -z "$branch" ] || [ "$branch" = "HEAD" ]; then + branch="(detached)" + fi status=$(worktree_status "$repo_root") printf "%s\t%s\t%s\n" "$repo_root" "$branch" "$status" @@ -1077,7 +1081,9 @@ cmd_list() { # Always show repo root first local branch branch=$(get_current_branch "$repo_root") - [ -z "$branch" ] || [ "$branch" = "HEAD" ] && branch="(detached)" + if [ -z "$branch" ] || [ "$branch" = "HEAD" ]; then + branch="(detached)" + fi printf "%-30s %s\n" "$branch [main repo]" "$repo_root" # Show worktrees sorted by branch name @@ -1329,6 +1335,7 @@ cmd_doctor() { # Check if adapter exists local editor_adapter="$GTR_DIR/adapters/editor/${editor}.sh" if [ -f "$editor_adapter" ]; then + # shellcheck disable=SC1090 . "$editor_adapter" if editor_can_open 2>/dev/null; then echo "[OK] Editor: $editor (found)" @@ -1349,6 +1356,7 @@ cmd_doctor() { # Check if adapter exists local adapter_file="$GTR_DIR/adapters/ai/${ai_tool}.sh" if [ -f "$adapter_file" ]; then + # shellcheck disable=SC1090 . "$adapter_file" if ai_can_start 2>/dev/null; then echo "[OK] AI tool: $ai_tool (found)" @@ -1419,6 +1427,7 @@ cmd_adapter() { if [ -f "$adapter_file" ]; then local adapter_name adapter_name=$(basename "$adapter_file" .sh) + # shellcheck disable=SC1090 . "$adapter_file" if editor_can_open 2>/dev/null; then @@ -1440,6 +1449,7 @@ cmd_adapter() { if [ -f "$adapter_file" ]; then local adapter_name adapter_name=$(basename "$adapter_file" .sh) + # shellcheck disable=SC1090 . "$adapter_file" if ai_can_start 2>/dev/null; then @@ -1896,6 +1906,7 @@ _load_adapter() { # Try loading explicit adapter first (allows special handling) if [ -f "$adapter_file" ]; then + # shellcheck disable=SC1090 . "$adapter_file" return 0 fi diff --git a/lib/copy.sh b/lib/copy.sh index 80fc3e7..14f34aa 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -48,6 +48,48 @@ parse_pattern_file() { grep -v '^#' "$file_path" 2>/dev/null | grep -v '^[[:space:]]*$' || true } +# Copy a single file to destination, handling exclusion, path preservation, and dry-run +# Usage: _copy_pattern_file file dst_root excludes preserve_paths dry_run +# Returns: 0 if file was copied (or would be in dry-run), 1 if skipped/failed +_copy_pattern_file() { + local file="$1" + local dst_root="$2" + local excludes="$3" + local preserve_paths="$4" + local dry_run="$5" + + # Remove leading ./ + file="${file#./}" + + # Skip if excluded + is_excluded "$file" "$excludes" && return 1 + + # Determine destination path + local dest_file + if [ "$preserve_paths" = "true" ]; then + dest_file="$dst_root/$file" + else + dest_file="$dst_root/$(basename "$file")" + fi + + # Copy the file (or show what would be copied in dry-run mode) + if [ "$dry_run" = "true" ]; then + log_info "[dry-run] Would copy: $file" + return 0 + fi + + local dest_dir + dest_dir=$(dirname "$dest_file") + mkdir -p "$dest_dir" + if cp "$file" "$dest_file" 2>/dev/null; then + log_info "Copied $file" + return 0 + else + log_warn "Failed to copy $file" + return 1 + fi +} + # Copy files matching patterns from source to destination # Usage: copy_patterns src_root dst_root includes excludes [preserve_paths] [dry_run] # includes: newline-separated glob patterns to include @@ -102,36 +144,8 @@ copy_patterns() { if [ "$have_globstar" -eq 0 ] && echo "$pattern" | grep -q '\*\*'; then # Fallback to find for ** patterns on Bash 3.2 while IFS= read -r file; do - # Remove leading ./ - file="${file#./}" - - # Skip if excluded - is_excluded "$file" "$excludes" && continue - - # Determine destination path - local dest_file - if [ "$preserve_paths" = "true" ]; then - dest_file="$dst_root/$file" - else - dest_file="$dst_root/$(basename "$file")" - fi - - # Create destination directory (skip in dry-run mode) - local dest_dir - dest_dir=$(dirname "$dest_file") - - # Copy the file (or show what would be copied in dry-run mode) - if [ "$dry_run" = "true" ]; then - log_info "[dry-run] Would copy: $file" + if _copy_pattern_file "$file" "$dst_root" "$excludes" "$preserve_paths" "$dry_run"; then copied_count=$((copied_count + 1)) - else - mkdir -p "$dest_dir" - if cp "$file" "$dest_file" 2>/dev/null; then - log_info "Copied $file" - copied_count=$((copied_count + 1)) - else - log_warn "Failed to copy $file" - fi fi done </dev/null) @@ -142,36 +156,8 @@ EOF # Skip if not a file [ -f "$file" ] || continue - # Remove leading ./ - file="${file#./}" - - # Skip if excluded - is_excluded "$file" "$excludes" && continue - - # Determine destination path - local dest_file - if [ "$preserve_paths" = "true" ]; then - dest_file="$dst_root/$file" - else - dest_file="$dst_root/$(basename "$file")" - fi - - # Create destination directory (skip in dry-run mode) - local dest_dir - dest_dir=$(dirname "$dest_file") - - # Copy the file (or show what would be copied in dry-run mode) - if [ "$dry_run" = "true" ]; then - log_info "[dry-run] Would copy: $file" + if _copy_pattern_file "$file" "$dst_root" "$excludes" "$preserve_paths" "$dry_run"; then copied_count=$((copied_count + 1)) - else - mkdir -p "$dest_dir" - if cp "$file" "$dest_file" 2>/dev/null; then - log_info "Copied $file" - copied_count=$((copied_count + 1)) - else - log_warn "Failed to copy $file" - fi fi done fi diff --git a/lib/core.sh b/lib/core.sh index 9907530..e7a2b2f 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -54,10 +54,12 @@ resolve_base_dir() { # Default: -worktrees next to the repo base_dir="$(dirname "$repo_root")/${repo_name}-worktrees" else - # Expand tilde to home directory + # Expand literal tilde to home directory + # Patterns must quote ~ to prevent bash tilde expansion in case arms + # shellcheck disable=SC2088 case "$base_dir" in - ~/*) base_dir="$HOME/${base_dir#~/}" ;; - ~) base_dir="$HOME" ;; + "~/"*) base_dir="$HOME/${base_dir#"~/"}" ;; + "~") base_dir="$HOME" ;; esac # Check if absolute or relative @@ -89,7 +91,7 @@ resolve_base_dir() { # Warn if worktree dir is inside repo (but not a sibling) if [[ "$base_dir" == "$canonical_repo_root"/* ]]; then - local rel_path="${base_dir#$canonical_repo_root/}" + local rel_path="${base_dir#"$canonical_repo_root"/}" # Check if .gitignore exists and whether it includes the worktree directory if [ -f "$canonical_repo_root/.gitignore" ]; then if ! grep -qE "^/?${rel_path}/?\$|^/?${rel_path}/\*?\$" "$canonical_repo_root/.gitignore" 2>/dev/null; then @@ -234,7 +236,7 @@ resolve_target() { local repo_root="$2" local base_dir="$3" local prefix="$4" - local id path branch sanitized_name + local path branch sanitized_name # Special case: ID 1 is always the repo root if [ "$identifier" = "1" ]; then diff --git a/lib/hooks.sh b/lib/hooks.sh index e6682df..c291974 100644 --- a/lib/hooks.sh +++ b/lib/hooks.sh @@ -36,6 +36,7 @@ run_hooks() { if ( # Export each KEY=VALUE exactly as passed, safely quoted for kv in "${envs[@]}"; do + # shellcheck disable=SC2163 export "$kv" done # Execute the hook diff --git a/lib/platform.sh b/lib/platform.sh index 5aaa386..e4e89b3 100644 --- a/lib/platform.sh +++ b/lib/platform.sh @@ -68,6 +68,15 @@ open_in_gui() { esac } +# Escape a string for safe interpolation into AppleScript double-quoted strings +# Handles backslashes and double quotes that would break AppleScript syntax +_escape_applescript() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + printf '%s' "$s" +} + # Spawn a new terminal window/tab in a directory # Usage: spawn_terminal_in path title [command] # Note: Best-effort implementation, may not work on all systems @@ -81,6 +90,12 @@ spawn_terminal_in() { case "$os" in darwin) + # Escape variables for AppleScript string interpolation + local safe_path safe_title safe_cmd + safe_path=$(_escape_applescript "$path") + safe_title=$(_escape_applescript "$title") + safe_cmd=$(_escape_applescript "$cmd") + # Try iTerm2 first, then Terminal.app if osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true' 2>/dev/null | grep -q "iTerm"; then osascript <<-EOF 2>/dev/null || true @@ -88,9 +103,9 @@ spawn_terminal_in() { tell current window create tab with default profile tell current session - write text "cd \"$path\"" - set name to "$title" - $([ -n "$cmd" ] && echo "write text \"$cmd\"") + write text "cd \"$safe_path\"" + set name to "$safe_title" + $([ -n "$safe_cmd" ] && echo "write text \"$safe_cmd\"") end tell end tell end tell @@ -98,8 +113,8 @@ spawn_terminal_in() { else osascript <<-EOF 2>/dev/null || true tell application "Terminal" - do script "cd \"$path\"; $cmd" - set custom title of front window to "$title" + do script "cd \"$safe_path\"; $safe_cmd" + set custom title of front window to "$safe_title" end tell EOF fi diff --git a/tests/copy_safety.bats b/tests/copy_safety.bats new file mode 100644 index 0000000..2ce9a4b --- /dev/null +++ b/tests/copy_safety.bats @@ -0,0 +1,84 @@ +#!/usr/bin/env bats + +setup() { + load test_helper + source "$PROJECT_ROOT/lib/copy.sh" +} + +# --- _is_unsafe_path tests --- + +@test "absolute path is unsafe" { + _is_unsafe_path "/etc/passwd" +} + +@test "relative path is safe" { + ! _is_unsafe_path "src/main.js" +} + +@test "parent traversal at start is unsafe" { + _is_unsafe_path "../secret" +} + +@test "parent traversal in middle is unsafe" { + _is_unsafe_path "foo/../../etc/passwd" +} + +@test "parent traversal at end is unsafe" { + _is_unsafe_path "foo/.." +} + +@test "bare double-dot is unsafe" { + _is_unsafe_path ".." +} + +@test "dotfile is safe" { + ! _is_unsafe_path ".env" +} + +@test "nested relative path is safe" { + ! _is_unsafe_path "src/lib/utils.js" +} + +@test "glob pattern is safe" { + ! _is_unsafe_path "*.txt" +} + +@test "double-star glob is safe" { + ! _is_unsafe_path "**/*.js" +} + +# --- is_excluded tests --- + +@test "exact match is excluded" { + is_excluded "node_modules" "node_modules" +} + +@test "non-matching path is not excluded" { + ! is_excluded "src/index.js" "node_modules" +} + +@test "glob pattern excludes matching path" { + is_excluded "build/output.js" "build/*" +} + +@test "empty excludes means nothing excluded" { + ! is_excluded "anything" "" +} + +@test "multiple excludes work" { + local excludes + excludes=$(printf '%s\n' "*.log" "dist/*" "node_modules") + is_excluded "error.log" "$excludes" +} + +@test "multiple excludes check all patterns" { + local excludes + excludes=$(printf '%s\n' "*.log" "dist/*" "node_modules") + is_excluded "dist/bundle.js" "$excludes" +} + +@test "non-matching against multiple excludes" { + local excludes + excludes=$(printf '%s\n' "*.log" "dist/*") + ! is_excluded "src/app.js" "$excludes" +} diff --git a/tests/resolve_base_dir.bats b/tests/resolve_base_dir.bats new file mode 100644 index 0000000..8fca8ad --- /dev/null +++ b/tests/resolve_base_dir.bats @@ -0,0 +1,40 @@ +#!/usr/bin/env bats + +setup() { + load test_helper + source "$PROJECT_ROOT/lib/core.sh" + + # Create a temporary git repo for testing + TEST_REPO=$(mktemp -d) + git -C "$TEST_REPO" init --quiet + git -C "$TEST_REPO" commit --allow-empty -m "init" --quiet +} + +teardown() { + rm -rf "$TEST_REPO" +} + +@test "default base dir is repo-worktrees sibling" { + result=$(resolve_base_dir "$TEST_REPO") + expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-worktrees" + [ "$result" = "$expected" ] +} + +@test "absolute path config is used as-is" { + # Override cfg_default to return an absolute path + cfg_default() { printf "/custom/worktrees"; } + result=$(resolve_base_dir "$TEST_REPO") + [ "$result" = "/custom/worktrees" ] +} + +@test "tilde in path is expanded" { + cfg_default() { printf "~/my-worktrees"; } + result=$(resolve_base_dir "$TEST_REPO") + [ "$result" = "$HOME/my-worktrees" ] +} + +@test "relative path is resolved from repo root" { + cfg_default() { printf ".worktrees"; } + result=$(resolve_base_dir "$TEST_REPO") + [ "$result" = "$TEST_REPO/.worktrees" ] +} diff --git a/tests/sanitize_branch_name.bats b/tests/sanitize_branch_name.bats new file mode 100644 index 0000000..6aa3352 --- /dev/null +++ b/tests/sanitize_branch_name.bats @@ -0,0 +1,51 @@ +#!/usr/bin/env bats + +setup() { + load test_helper + source "$PROJECT_ROOT/lib/core.sh" +} + +@test "simple branch name passes through unchanged" { + result=$(sanitize_branch_name "my-feature") + [ "$result" = "my-feature" ] +} + +@test "slashes are replaced with hyphens" { + result=$(sanitize_branch_name "feature/user-auth") + [ "$result" = "feature-user-auth" ] +} + +@test "nested slashes are replaced" { + result=$(sanitize_branch_name "org/team/feature") + [ "$result" = "org-team-feature" ] +} + +@test "spaces are replaced with hyphens" { + result=$(sanitize_branch_name "my feature branch") + [ "$result" = "my-feature-branch" ] +} + +@test "colons are replaced with hyphens" { + result=$(sanitize_branch_name "fix:bug") + [ "$result" = "fix-bug" ] +} + +@test "leading hyphens are stripped" { + result=$(sanitize_branch_name "/leading-slash") + [ "$result" = "leading-slash" ] +} + +@test "trailing hyphens are stripped" { + result=$(sanitize_branch_name "trailing-slash/") + [ "$result" = "trailing-slash" ] +} + +@test "multiple special chars produce single hyphens" { + result=$(sanitize_branch_name "a//b") + [ "$result" = "a--b" ] +} + +@test "backslashes are replaced" { + result=$(sanitize_branch_name 'feature\auth') + [ "$result" = "feature-auth" ] +} diff --git a/tests/test_helper.bash b/tests/test_helper.bash new file mode 100644 index 0000000..e7a1ffe --- /dev/null +++ b/tests/test_helper.bash @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Shared test helper — sources libs with minimal stubs for isolated testing + +TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$TESTS_DIR/.." && pwd)" + +# Stubs for ui.sh functions (avoid log output in tests) +log_info() { :; } +log_warn() { :; } +log_error() { :; } +log_step() { :; } +export -f log_info log_warn log_error log_step + +# Stubs for config.sh functions (tests that need real config should source it explicitly) +cfg_default() { printf "%s" "${3:-}"; } +cfg_get_all() { :; } +export -f cfg_default cfg_get_all From 5368d32531bcc7b34e67c00a7b0d50371e40c74b Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Wed, 11 Feb 2026 16:22:21 -0800 Subject: [PATCH 05/19] fix: address CI failures and review feedback - Add apt-get update before installs in CI workflow - Suppress SC2317 on nested adapter functions in bin/gtr - Replace SC2015 pattern with if-statement in lib/copy.sh - Pre-compute AppleScript cmd line to avoid set -e abort in heredoc - Add git identity config in test setup for CI environments - Fix misleading test description for special char replacement --- .github/workflows/lint.yml | 4 ++-- bin/gtr | 6 ++++++ lib/copy.sh | 4 +++- lib/platform.sh | 8 +++++++- tests/resolve_base_dir.bats | 2 ++ tests/sanitize_branch_name.bats | 2 +- 6 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e24cfa9..ba9c881 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v4 - name: Install ShellCheck - run: sudo apt-get install -y shellcheck + run: sudo apt-get update && sudo apt-get install -y shellcheck - name: Run ShellCheck run: | @@ -27,7 +27,7 @@ jobs: - uses: actions/checkout@v4 - name: Install BATS - run: sudo apt-get install -y bats + run: sudo apt-get update && sudo apt-get install -y bats - name: Run tests run: bats tests/ diff --git a/bin/gtr b/bin/gtr index 23ee89d..4393306 100755 --- a/bin/gtr +++ b/bin/gtr @@ -69,10 +69,12 @@ ai_start() { # Standard AI adapter builder — used by adapter files that follow the common pattern # Sets globals then call this: _AI_CMD, _AI_ERR_MSG, _AI_INFO_LINES (array) _ai_define_standard() { + # shellcheck disable=SC2317 # Functions are called indirectly via adapter dispatch ai_can_start() { command -v "$_AI_CMD" >/dev/null 2>&1 } + # shellcheck disable=SC2317 ai_start() { local path="$1"; shift if ! ai_can_start; then @@ -94,10 +96,12 @@ _ai_define_standard() { # Standard editor adapter builder — used by adapter files that follow the common pattern # Sets globals then call this: _EDITOR_CMD, _EDITOR_ERR_MSG, _EDITOR_WORKSPACE (optional, 0 or 1) _editor_define_standard() { + # shellcheck disable=SC2317 # Functions are called indirectly via adapter dispatch editor_can_open() { command -v "$_EDITOR_CMD" >/dev/null 2>&1 } + # shellcheck disable=SC2317 editor_open() { local path="$1" local workspace="${2:-}" @@ -116,10 +120,12 @@ _editor_define_standard() { # Terminal editor adapter builder — for editors that run in the current terminal # Sets globals then call this: _EDITOR_CMD, _EDITOR_ERR_MSG, _EDITOR_BACKGROUND (optional, 0 or 1) _editor_define_terminal() { + # shellcheck disable=SC2317 # Functions are called indirectly via adapter dispatch editor_can_open() { command -v "$_EDITOR_CMD" >/dev/null 2>&1 } + # shellcheck disable=SC2317 editor_open() { local path="$1" if ! editor_can_open; then diff --git a/lib/copy.sh b/lib/copy.sh index 14f34aa..accd4e2 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -299,7 +299,9 @@ copy_directories() { cd "$exclude_old_pwd" || true # Log only if we actually removed something - [ "$removed_any" -eq 1 ] && log_info "Excluded subdirectory $exclude_pattern" || true + if [ "$removed_any" -eq 1 ]; then + log_info "Excluded subdirectory $exclude_pattern" + fi ;; esac ;; diff --git a/lib/platform.sh b/lib/platform.sh index e4e89b3..eea44d6 100644 --- a/lib/platform.sh +++ b/lib/platform.sh @@ -96,6 +96,12 @@ spawn_terminal_in() { safe_title=$(_escape_applescript "$title") safe_cmd=$(_escape_applescript "$cmd") + # Pre-compute optional AppleScript write-text line (avoids set -e abort in heredoc) + local iterm_cmd_line="" + if [ -n "$safe_cmd" ]; then + iterm_cmd_line="write text \"$safe_cmd\"" + fi + # Try iTerm2 first, then Terminal.app if osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true' 2>/dev/null | grep -q "iTerm"; then osascript <<-EOF 2>/dev/null || true @@ -105,7 +111,7 @@ spawn_terminal_in() { tell current session write text "cd \"$safe_path\"" set name to "$safe_title" - $([ -n "$safe_cmd" ] && echo "write text \"$safe_cmd\"") + $iterm_cmd_line end tell end tell end tell diff --git a/tests/resolve_base_dir.bats b/tests/resolve_base_dir.bats index 8fca8ad..8a4fede 100644 --- a/tests/resolve_base_dir.bats +++ b/tests/resolve_base_dir.bats @@ -7,6 +7,8 @@ setup() { # Create a temporary git repo for testing TEST_REPO=$(mktemp -d) git -C "$TEST_REPO" init --quiet + git -C "$TEST_REPO" config user.name "Test User" + git -C "$TEST_REPO" config user.email "test@example.com" git -C "$TEST_REPO" commit --allow-empty -m "init" --quiet } diff --git a/tests/sanitize_branch_name.bats b/tests/sanitize_branch_name.bats index 6aa3352..69e89c9 100644 --- a/tests/sanitize_branch_name.bats +++ b/tests/sanitize_branch_name.bats @@ -40,7 +40,7 @@ setup() { [ "$result" = "trailing-slash" ] } -@test "multiple special chars produce single hyphens" { +@test "multiple special chars are each replaced" { result=$(sanitize_branch_name "a//b") [ "$result" = "a--b" ] } From 25ede699673f808b688dcf711874c8e3ba51130c Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Wed, 11 Feb 2026 16:26:18 -0800 Subject: [PATCH 06/19] refactor: improve error handling in copy_directories function Update the copy_directories function in lib/copy.sh to enhance error handling by using a conditional statement for the removal of matched paths. This change ensures that the removal status is only set when the command succeeds, improving code clarity and maintainability. --- lib/copy.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/copy.sh b/lib/copy.sh index accd4e2..9ed56dd 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -290,7 +290,9 @@ copy_directories() { for matched_path in $pattern_suffix; do # Check if glob matched anything (avoid literal pattern if no match) if [ -e "$matched_path" ]; then - rm -rf "$matched_path" 2>/dev/null && removed_any=1 || true + if rm -rf "$matched_path" 2>/dev/null; then + removed_any=1 + fi fi done From 98858e3feb1d12f4659d6df87473e1f7c58bf9a3 Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Wed, 11 Feb 2026 16:31:59 -0800 Subject: [PATCH 07/19] refactor: enhance current_branch function for better detection Update the current_branch function to handle cases where the branch is either empty or set to HEAD, normalizing the output to indicate a detached state. Additionally, refactor cmd_list to utilize the updated current_branch function, improving code clarity and maintainability. --- bin/gtr | 10 ++-------- lib/core.sh | 4 ++-- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/bin/gtr b/bin/gtr index 4393306..64e7064 100755 --- a/bin/gtr +++ b/bin/gtr @@ -1055,10 +1055,7 @@ cmd_list() { if [ "$porcelain" -eq 1 ]; then # Output: pathbranchstatus local branch status - branch=$(get_current_branch "$repo_root") - if [ -z "$branch" ] || [ "$branch" = "HEAD" ]; then - branch="(detached)" - fi + branch=$(current_branch "$repo_root") status=$(worktree_status "$repo_root") printf "%s\t%s\t%s\n" "$repo_root" "$branch" "$status" @@ -1086,10 +1083,7 @@ cmd_list() { # Always show repo root first local branch - branch=$(get_current_branch "$repo_root") - if [ -z "$branch" ] || [ "$branch" = "HEAD" ]; then - branch="(detached)" - fi + branch=$(current_branch "$repo_root") printf "%-30s %s\n" "$branch [main repo]" "$repo_root" # Show worktrees sorted by branch name diff --git a/lib/core.sh b/lib/core.sh index e7a2b2f..4663361 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -166,8 +166,8 @@ current_branch() { local branch branch=$(get_current_branch "$worktree_path") - # Normalize detached HEAD - if [ "$branch" = "HEAD" ]; then + # Normalize detached HEAD or empty (failed detection) + if [ -z "$branch" ] || [ "$branch" = "HEAD" ]; then branch="(detached)" fi From e56424d8997f9b1aa5831e361b3f32dee464f2ee Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Wed, 11 Feb 2026 16:58:35 -0800 Subject: [PATCH 08/19] refactor: enhance adapter management and command structure - Introduced a new adapter loading infrastructure in `lib/adapters.sh`, consolidating editor and AI adapter definitions into registries for improved maintainability. - Updated the `bin/gtr` script to source command handlers dynamically, streamlining command execution. - Added new commands for listing available adapters and starting AI tools, enhancing user interaction and functionality. - Removed outdated individual adapter scripts, transitioning to a registry-based approach for standard adapters, which simplifies the addition of new tools. - Improved documentation in various instruction files to clarify when to use registry entries versus custom adapter files. --- .github/instructions/ai.instructions.md | 26 +- .github/instructions/editor.instructions.md | 30 +- .github/instructions/lib.instructions.md | 2 +- .github/workflows/lint.yml | 2 +- CLAUDE.md | 38 +- CONTRIBUTING.md | 83 +- adapters/ai/aider.sh | 7 - adapters/ai/auggie.sh | 7 - adapters/ai/codex.sh | 10 - adapters/ai/continue.sh | 7 - adapters/ai/copilot.sh | 11 - adapters/ai/gemini.sh | 10 - adapters/ai/opencode.sh | 7 - adapters/editor/atom.sh | 6 - adapters/editor/cursor.sh | 7 - adapters/editor/emacs.sh | 7 - adapters/editor/idea.sh | 6 - adapters/editor/nvim.sh | 6 - adapters/editor/pycharm.sh | 6 - adapters/editor/sublime.sh | 6 - adapters/editor/vim.sh | 6 - adapters/editor/vscode.sh | 7 - adapters/editor/webstorm.sh | 6 - adapters/editor/zed.sh | 6 - bin/gtr | 2077 +------------------ lib/adapters.sh | 324 +++ lib/commands/adapter.sh | 87 + lib/commands/ai.sh | 67 + lib/commands/clean.sh | 187 ++ lib/commands/completion.sh | 63 + lib/commands/config.sh | 143 ++ lib/commands/copy.sh | 122 ++ lib/commands/create.sh | 304 +++ lib/commands/doctor.sh | 116 ++ lib/commands/editor.sh | 52 + lib/commands/go.sh | 29 + lib/commands/help.sh | 220 ++ lib/commands/init.sh | 196 ++ lib/commands/list.sh | 75 + lib/commands/remove.sh | 95 + lib/commands/rename.sh | 116 ++ lib/commands/run.sh | 58 + lib/core.sh | 7 +- 43 files changed, 2368 insertions(+), 2279 deletions(-) delete mode 100644 adapters/ai/aider.sh delete mode 100644 adapters/ai/auggie.sh delete mode 100644 adapters/ai/codex.sh delete mode 100644 adapters/ai/continue.sh delete mode 100644 adapters/ai/copilot.sh delete mode 100644 adapters/ai/gemini.sh delete mode 100644 adapters/ai/opencode.sh delete mode 100755 adapters/editor/atom.sh delete mode 100644 adapters/editor/cursor.sh delete mode 100755 adapters/editor/emacs.sh delete mode 100755 adapters/editor/idea.sh delete mode 100755 adapters/editor/nvim.sh delete mode 100755 adapters/editor/pycharm.sh delete mode 100755 adapters/editor/sublime.sh delete mode 100755 adapters/editor/vim.sh delete mode 100644 adapters/editor/vscode.sh delete mode 100755 adapters/editor/webstorm.sh delete mode 100644 adapters/editor/zed.sh create mode 100644 lib/adapters.sh create mode 100644 lib/commands/adapter.sh create mode 100644 lib/commands/ai.sh create mode 100644 lib/commands/clean.sh create mode 100644 lib/commands/completion.sh create mode 100644 lib/commands/config.sh create mode 100644 lib/commands/copy.sh create mode 100644 lib/commands/create.sh create mode 100644 lib/commands/doctor.sh create mode 100644 lib/commands/editor.sh create mode 100644 lib/commands/go.sh create mode 100644 lib/commands/help.sh create mode 100644 lib/commands/init.sh create mode 100644 lib/commands/list.sh create mode 100644 lib/commands/remove.sh create mode 100644 lib/commands/rename.sh create mode 100644 lib/commands/run.sh diff --git a/.github/instructions/ai.instructions.md b/.github/instructions/ai.instructions.md index bde7500..51d8eee 100644 --- a/.github/instructions/ai.instructions.md +++ b/.github/instructions/ai.instructions.md @@ -2,11 +2,26 @@ applyTo: adapters/ai/**/*.sh --- -# AI Instructions +# AI Adapter Instructions -## Adding Features +## When to Use a File vs Registry -### New AI Tool Adapter (`adapters/ai/.sh`) +Most AI tools are defined as **registry entries** in `lib/adapters.sh` — no adapter file needed. +Only create an adapter file in `adapters/ai/` for tools that need **custom behavior** beyond the standard builder (see `claude.sh` or `cursor.sh` for examples). + +## Adding a Standard AI Tool (Registry) + +Add a line to `_AI_REGISTRY` in `lib/adapters.sh`: + +``` +yourname|yourcmd|ToolName not found. Install with: ...|Extra info;More info +``` + +Format: `name|cmd|err_msg|info_lines` (info lines are semicolon-separated) + +## Adding a Custom AI Tool (File Override) + +Create `adapters/ai/.sh` implementing: ```bash #!/usr/bin/env bash @@ -27,7 +42,9 @@ ai_start() { } ``` -**Also update**: Same as editor adapters (README, completions, help text) +File-based adapters take precedence over registry entries of the same name. + +**Also update**: README, completions (bash/zsh/fish), help text in `lib/commands/help.sh` ## Contract & Guidelines @@ -38,5 +55,4 @@ ai_start() { - Accept extra args after `--`: preserve ordering (`ai_start` receives already-shifted args). - Prefer fast startup; heavy initialization belongs in hooks (`postCreate`), not adapters. - When adding adapter: update `cmd_help`, README tool list, and completions (bash/zsh/fish). -- Test manually: `bash -c 'source adapters/ai/.sh && ai_can_start && echo OK'`. - Inspect function definition if needed: `declare -f ai_start`. diff --git a/.github/instructions/editor.instructions.md b/.github/instructions/editor.instructions.md index 6bd84ba..7bc3c3d 100644 --- a/.github/instructions/editor.instructions.md +++ b/.github/instructions/editor.instructions.md @@ -2,11 +2,28 @@ applyTo: adapters/editor/**/*.sh --- -# Editor Instructions +# Editor Adapter Instructions -## Adding Features +## When to Use a File vs Registry -### New Editor Adapter (`adapters/editor/.sh`) +Most editors are defined as **registry entries** in `lib/adapters.sh` — no adapter file needed. +Only create an adapter file in `adapters/editor/` for editors that need **custom behavior** beyond what the standard/terminal builders provide (see `nano.sh` for an example). + +## Adding a Standard Editor (Registry) + +Add a line to `_EDITOR_REGISTRY` in `lib/adapters.sh`: + +``` +yourname|yourcmd|standard|EditorName not found. Install from https://...|flags +``` + +Format: `name|cmd|type|err_msg|flags` +- `type`: `standard` (GUI app) or `terminal` (runs in terminal) +- `flags`: comma-separated — `workspace` (supports .code-workspace), `background` (terminal bg) + +## Adding a Custom Editor (File Override) + +Create `adapters/editor/.sh` implementing: ```bash #!/usr/bin/env bash @@ -26,11 +43,13 @@ editor_open() { } ``` +File-based adapters take precedence over registry entries of the same name. + **Also update**: - README.md (setup instructions) - All three completion files: `completions/gtr.bash`, `completions/_git-gtr`, `completions/gtr.fish` -- Help text in `bin/gtr` (`cmd_help` function) +- Help text in `lib/commands/help.sh` (`cmd_help` function) ## Contract & Guidelines @@ -38,8 +57,7 @@ editor_open() { - Quote all paths; support spaces. Avoid changing PWD globally—no subshell needed (editor opens path). - Use `log_error` with actionable install guidance if command missing. - Keep adapter lean: no project scans, no blocking prompts. -- Naming: file name = tool name (`zed.sh` → `zed` flag). Avoid uppercase. +- Naming: file/registry name = tool name (`zed` → `zed` flag). Avoid uppercase. - Update: README editor list, completions (bash/zsh/fish), help (`Available editors:`), optional screenshots. -- Manual test: `bash -c 'source adapters/editor/.sh && editor_can_open && editor_open . || echo fail'`. - Fallback behavior: if editor absent, fail clearly; do NOT silently defer to file browser. - Inspect function definition if needed: `declare -f editor_open`. diff --git a/.github/instructions/lib.instructions.md b/.github/instructions/lib.instructions.md index d44ed19..717a759 100644 --- a/.github/instructions/lib.instructions.md +++ b/.github/instructions/lib.instructions.md @@ -21,7 +21,7 @@ applyTo: lib/**/*.sh ## Change Guidelines -- Preserve adapter contracts; do not rename exported functions used by `bin/gtr`. +- Preserve adapter contracts; do not rename exported functions used by command handlers in `lib/commands/`. - Add new config keys with `gtr.` prefix; avoid collisions. - For performance-sensitive loops (e.g. directory scans) prefer built-ins (`find`, `grep`) with minimal subshells. - Any new Git command: add fallback for older versions or guard with detection. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ba9c881..f7dfeac 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,7 +18,7 @@ jobs: - name: Run ShellCheck run: | - shellcheck bin/gtr bin/git-gtr lib/*.sh adapters/editor/*.sh adapters/ai/*.sh + shellcheck bin/gtr bin/git-gtr lib/*.sh lib/commands/*.sh adapters/editor/*.sh adapters/ai/*.sh test: name: Tests diff --git a/CLAUDE.md b/CLAUDE.md index 1f3184d..3cf71c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,28 +39,30 @@ mkdir -p /tmp/gtr-test && cd /tmp/gtr-test && git init && git commit --allow-emp ### Binary Structure - `bin/git-gtr` — Thin wrapper enabling `git gtr` subcommand invocation -- `bin/gtr` — Main script (~1960 lines), contains `main()` dispatcher + all `cmd_*` handlers +- `bin/gtr` — Entry point (~105 lines): sources libraries and commands, contains `main()` dispatcher ### Module Structure -| File | Purpose | -| ----------------- | ----------------------------------------------------------------------------------------------------------- | -| `lib/core.sh` | Worktree CRUD: `create_worktree`, `remove_worktree`, `list_worktrees`, `resolve_target`, `resolve_base_dir` | -| `lib/config.sh` | Git config wrapper with precedence: `cfg_get`, `cfg_default`, `cfg_get_all` | -| `lib/copy.sh` | File/directory copying with glob patterns: `copy_patterns`, `copy_directories` | -| `lib/hooks.sh` | Hook execution: `run_hooks_in` for postCreate/preRemove/postRemove | -| `lib/ui.sh` | Logging (`log_error`, `log_info`, `log_warn`), prompts, formatting | -| `lib/platform.sh` | OS detection, GUI helpers | +| File | Purpose | +| ------------------ | ----------------------------------------------------------------------------------------------------------- | +| `lib/core.sh` | Worktree CRUD: `create_worktree`, `remove_worktree`, `list_worktrees`, `resolve_target`, `resolve_base_dir` | +| `lib/config.sh` | Git config wrapper with precedence: `cfg_get`, `cfg_default`, `cfg_get_all` | +| `lib/copy.sh` | File/directory copying with glob patterns: `copy_patterns`, `copy_directories` | +| `lib/hooks.sh` | Hook execution: `run_hooks_in` for postCreate/preRemove/postRemove | +| `lib/ui.sh` | Logging (`log_error`, `log_info`, `log_warn`), prompts, formatting | +| `lib/platform.sh` | OS detection, GUI helpers | +| `lib/adapters.sh` | Adapter registry, builder functions, generic fallbacks, loader functions | +| `lib/commands/*.sh` | One file per subcommand: `cmd_create`, `cmd_remove`, etc. (16 files) | ### Adapters -Editor adapters in `adapters/editor/` and AI adapters in `adapters/ai/` are dynamically sourced via `load_editor_adapter()` and `load_ai_adapter()` in `bin/gtr`. +Most adapters are defined declaratively in the **adapter registry** (`lib/adapters.sh`) using pipe-delimited entries. Custom adapters that need special logic remain as override files in `adapters/editor/` and `adapters/ai/`. -**Editor adapters** (atom, cursor, emacs, idea, nano, nvim, pycharm, sublime, vim, vscode, webstorm, zed): implement `editor_can_open()` + `editor_open(path)`. +**Registry-defined adapters**: atom, cursor, emacs, idea, nvim, pycharm, sublime, vim, vscode, webstorm, zed (editors) and aider, auggie, codex, continue, copilot, gemini, opencode (AI). -**AI adapters** (aider, auggie, claude, codex, continue, copilot, cursor, gemini, opencode): implement `ai_can_start()` + `ai_start(path, args...)`. +**Custom adapter files**: `adapters/editor/nano.sh`, `adapters/ai/claude.sh`, `adapters/ai/cursor.sh` — these implement `editor_can_open()`/`editor_open()` or `ai_can_start()`/`ai_start()` directly. -**Generic fallback**: `GTR_EDITOR_CMD` / `GTR_AI_CMD` env vars allow custom tools without adapter files. +**Loading order**: file override → registry → generic PATH fallback. `GTR_EDITOR_CMD` / `GTR_AI_CMD` env vars allow custom tools without adapters. ### Command Flow @@ -106,15 +108,17 @@ cmd_editor() → resolve_target() → load_editor_adapter() → editor_open() ### Adding a New Command -1. Add `cmd_()` function in `bin/gtr` -2. Add case entry in `main()` dispatcher (around line 71) -3. Add help text in `cmd_help()` +1. Create `lib/commands/.sh` with `cmd_()` function +2. Add case entry in `main()` dispatcher in `bin/gtr` +3. Add help text in `lib/commands/help.sh` 4. Update all three completion files: `completions/gtr.bash`, `completions/_git-gtr`, `completions/git-gtr.fish` 5. Update README.md ### Adding an Adapter -Create `adapters/{editor,ai}/.sh` implementing the two required functions (see existing adapters for patterns). Then update: help text in `cmd_help()` and `load_*_adapter()`, all three completions, README.md. +**Standard adapters** (just a command name + error message): Add an entry to `_EDITOR_REGISTRY` or `_AI_REGISTRY` in `lib/adapters.sh`. Then update: help text in `lib/commands/help.sh`, all three completions, README.md. + +**Custom adapters** (special logic needed): Create `adapters/{editor,ai}/.sh` implementing the two required functions (see `adapters/ai/claude.sh` for an example). File-based adapters take priority over registry entries. ### Updating the Version diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1594849..5c0b5ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,17 +32,25 @@ We welcome feature suggestions! Please: ``` git-worktree-runner/ -├── bin/gtr # Main executable dispatcher +├── bin/gtr # Main executable (~105 lines: sources libs, dispatches commands) ├── lib/ # Core functionality -│ ├── core.sh # Git worktree operations +│ ├── ui.sh # User interface (logging, prompts) │ ├── config.sh # Configuration (git-config wrapper) │ ├── platform.sh # OS-specific utilities -│ ├── ui.sh # User interface (logging, prompts) +│ ├── core.sh # Git worktree operations │ ├── copy.sh # File copying logic -│ └── hooks.sh # Hook execution -├── adapters/ # Pluggable integrations -│ ├── editor/ # Editor adapters (cursor, vscode, zed) -│ └── ai/ # AI tool adapters (aider) +│ ├── hooks.sh # Hook execution +│ ├── provider.sh # GitHub/GitLab provider detection +│ ├── adapters.sh # Adapter registries, builders, and loaders +│ └── commands/ # Command handlers (one file per command) +│ ├── create.sh # cmd_create + helpers +│ ├── remove.sh # cmd_remove +│ ├── rename.sh # cmd_rename +│ ├── ... # (16 files total, one per command) +│ └── help.sh # cmd_help +├── adapters/ # Custom adapter overrides (non-registry) +│ ├── editor/nano.sh # nano (custom terminal behavior) +│ └── ai/ # claude.sh, cursor.sh (custom logic) ├── completions/ # Shell completions (bash, zsh, fish) └── templates/ # Example configs and scripts ``` @@ -92,60 +100,43 @@ do_something() { #### Adding an Editor Adapter -1. Create `adapters/editor/yourname.sh`: +Most editors follow a standard pattern and can be added as a **registry entry** in `lib/adapters.sh` (no separate file needed). -```bash -#!/usr/bin/env bash -# YourEditor adapter +**Option A: Registry entry** (preferred for standard editors) -editor_can_open() { - command -v yourcommand >/dev/null 2>&1 -} +Add a line to `_EDITOR_REGISTRY` in `lib/adapters.sh`: -editor_open() { - local path="$1" +``` +yourname|yourcmd|standard|YourEditor not found. Install from https://...|flags +``` - if ! editor_can_open; then - log_error "YourEditor not found. Install from https://..." - return 1 - fi +Format: `name|cmd|type|err_msg|flags` where: +- `type`: `standard` (GUI app) or `terminal` (runs in current terminal) +- `flags`: comma-separated — `workspace` (supports .code-workspace files), `background` (run in background) - yourcommand "$path" -} -``` +**Option B: Custom adapter file** (for editors with non-standard behavior) + +Create `adapters/editor/yourname.sh` implementing `editor_can_open()` and `editor_open()`. See `adapters/editor/nano.sh` for an example. -2. Update README.md with setup instructions -3. Update completions to include new editor -4. Test on macOS, Linux, and Windows if possible +**Also update**: README.md, all three completion files, help text in `lib/commands/help.sh`. #### Adding an AI Tool Adapter -1. Create `adapters/ai/yourtool.sh`: +**Option A: Registry entry** (preferred for standard CLI tools) -```bash -#!/usr/bin/env bash -# YourTool AI adapter +Add a line to `_AI_REGISTRY` in `lib/adapters.sh`: -ai_can_start() { - command -v yourtool >/dev/null 2>&1 -} +``` +yourname|yourcmd|YourTool not found. Install with: ...|Extra info line 1;Extra info line 2 +``` -ai_start() { - local path="$1" - shift +Format: `name|cmd|err_msg|info_lines` (info lines are semicolon-separated). - if ! ai_can_start; then - log_error "YourTool not found. Install with: ..." - return 1 - fi +**Option B: Custom adapter file** (for tools with non-standard behavior) - (cd "$path" && yourtool "$@") -} -``` +Create `adapters/ai/yourname.sh` implementing `ai_can_start()` and `ai_start()`. See `adapters/ai/claude.sh` for an example. -2. Update README.md -3. Update completions -4. Add example usage +**Also update**: README.md, completions, help text. #### Adding Core Features diff --git a/adapters/ai/aider.sh b/adapters/ai/aider.sh deleted file mode 100644 index bff7290..0000000 --- a/adapters/ai/aider.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -# Aider AI coding assistant adapter - -_AI_CMD="aider" -_AI_ERR_MSG="Aider not found. Install with: pip install aider-chat" -_AI_INFO_LINES=("See https://aider.chat for more information") -_ai_define_standard diff --git a/adapters/ai/auggie.sh b/adapters/ai/auggie.sh deleted file mode 100644 index 4f64093..0000000 --- a/adapters/ai/auggie.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -# Auggie CLI AI adapter - -_AI_CMD="auggie" -_AI_ERR_MSG="Auggie CLI not found. Install with: npm install -g @augmentcode/auggie" -_AI_INFO_LINES=("See https://www.augmentcode.com/product/CLI for more information") -_ai_define_standard diff --git a/adapters/ai/codex.sh b/adapters/ai/codex.sh deleted file mode 100644 index 2a3278f..0000000 --- a/adapters/ai/codex.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -# OpenAI Codex CLI adapter - -_AI_CMD="codex" -_AI_ERR_MSG="Codex CLI not found. Install with: npm install -g @openai/codex" -_AI_INFO_LINES=( - "Or: brew install codex" - "See https://github.com/openai/codex for more info" -) -_ai_define_standard diff --git a/adapters/ai/continue.sh b/adapters/ai/continue.sh deleted file mode 100644 index eef2a52..0000000 --- a/adapters/ai/continue.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -# Continue CLI adapter - -_AI_CMD="cn" -_AI_ERR_MSG="Continue CLI not found. Install from https://continue.dev" -_AI_INFO_LINES=("See https://docs.continue.dev/cli/install for installation") -_ai_define_standard diff --git a/adapters/ai/copilot.sh b/adapters/ai/copilot.sh deleted file mode 100644 index e002742..0000000 --- a/adapters/ai/copilot.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -# GitHub Copilot CLI adapter - -_AI_CMD="copilot" -_AI_ERR_MSG="GitHub Copilot CLI not found." -_AI_INFO_LINES=( - "Install with: npm install -g @github/copilot" - "Or: brew install copilot-cli" - "See https://github.com/github/copilot-cli for more information" -) -_ai_define_standard diff --git a/adapters/ai/gemini.sh b/adapters/ai/gemini.sh deleted file mode 100644 index 4c447ad..0000000 --- a/adapters/ai/gemini.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -# Gemini CLI adapter - -_AI_CMD="gemini" -_AI_ERR_MSG="Gemini CLI not found. Install with: npm install -g @google/gemini-cli" -_AI_INFO_LINES=( - "Or: brew install gemini-cli" - "See https://github.com/google-gemini/gemini-cli for more info" -) -_ai_define_standard diff --git a/adapters/ai/opencode.sh b/adapters/ai/opencode.sh deleted file mode 100644 index 8c5782b..0000000 --- a/adapters/ai/opencode.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -# OpenCode adapter - -_AI_CMD="opencode" -_AI_ERR_MSG="OpenCode not found. Install from https://opencode.ai" -_AI_INFO_LINES=("Make sure the 'opencode' CLI is available in your PATH") -_ai_define_standard diff --git a/adapters/editor/atom.sh b/adapters/editor/atom.sh deleted file mode 100755 index 98fef7e..0000000 --- a/adapters/editor/atom.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -# Atom editor adapter - -_EDITOR_CMD="atom" -_EDITOR_ERR_MSG="Atom not found. Install from https://atom.io" -_editor_define_standard diff --git a/adapters/editor/cursor.sh b/adapters/editor/cursor.sh deleted file mode 100644 index a6f20bd..0000000 --- a/adapters/editor/cursor.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -# Cursor editor adapter - -_EDITOR_CMD="cursor" -_EDITOR_ERR_MSG="Cursor not found. Install from https://cursor.com or enable the shell command." -_EDITOR_WORKSPACE=1 -_editor_define_standard diff --git a/adapters/editor/emacs.sh b/adapters/editor/emacs.sh deleted file mode 100755 index d0ba404..0000000 --- a/adapters/editor/emacs.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -# Emacs editor adapter - -_EDITOR_CMD="emacs" -_EDITOR_ERR_MSG="Emacs not found. Install from https://www.gnu.org/software/emacs/" -_EDITOR_BACKGROUND=1 -_editor_define_terminal diff --git a/adapters/editor/idea.sh b/adapters/editor/idea.sh deleted file mode 100755 index e5369fe..0000000 --- a/adapters/editor/idea.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -# IntelliJ IDEA editor adapter - -_EDITOR_CMD="idea" -_EDITOR_ERR_MSG="IntelliJ IDEA 'idea' command not found. Enable shell launcher in Tools > Create Command-line Launcher" -_editor_define_standard diff --git a/adapters/editor/nvim.sh b/adapters/editor/nvim.sh deleted file mode 100755 index 61193df..0000000 --- a/adapters/editor/nvim.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -# Neovim editor adapter - -_EDITOR_CMD="nvim" -_EDITOR_ERR_MSG="Neovim not found. Install from https://neovim.io" -_editor_define_terminal diff --git a/adapters/editor/pycharm.sh b/adapters/editor/pycharm.sh deleted file mode 100755 index b10c7e5..0000000 --- a/adapters/editor/pycharm.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -# PyCharm editor adapter - -_EDITOR_CMD="pycharm" -_EDITOR_ERR_MSG="PyCharm 'pycharm' command not found. Enable shell launcher in Tools > Create Command-line Launcher" -_editor_define_standard diff --git a/adapters/editor/sublime.sh b/adapters/editor/sublime.sh deleted file mode 100755 index 8a997b2..0000000 --- a/adapters/editor/sublime.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -# Sublime Text editor adapter - -_EDITOR_CMD="subl" -_EDITOR_ERR_MSG="Sublime Text 'subl' command not found. Install from https://www.sublimetext.com" -_editor_define_standard diff --git a/adapters/editor/vim.sh b/adapters/editor/vim.sh deleted file mode 100755 index 3b41b1c..0000000 --- a/adapters/editor/vim.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -# Vim editor adapter - -_EDITOR_CMD="vim" -_EDITOR_ERR_MSG="Vim not found. Install via your package manager." -_editor_define_terminal diff --git a/adapters/editor/vscode.sh b/adapters/editor/vscode.sh deleted file mode 100644 index aac5719..0000000 --- a/adapters/editor/vscode.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -# VS Code editor adapter - -_EDITOR_CMD="code" -_EDITOR_ERR_MSG="VS Code 'code' command not found. Install from https://code.visualstudio.com" -_EDITOR_WORKSPACE=1 -_editor_define_standard diff --git a/adapters/editor/webstorm.sh b/adapters/editor/webstorm.sh deleted file mode 100755 index 29ba7b9..0000000 --- a/adapters/editor/webstorm.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -# WebStorm editor adapter - -_EDITOR_CMD="webstorm" -_EDITOR_ERR_MSG="WebStorm 'webstorm' command not found. Enable shell launcher in Tools > Create Command-line Launcher" -_editor_define_standard diff --git a/adapters/editor/zed.sh b/adapters/editor/zed.sh deleted file mode 100644 index 983a8a7..0000000 --- a/adapters/editor/zed.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -# Zed editor adapter - -_EDITOR_CMD="zed" -_EDITOR_ERR_MSG="Zed not found. Install from https://zed.dev" -_editor_define_standard diff --git a/bin/gtr b/bin/gtr index 64e7064..4eefd26 100755 --- a/bin/gtr +++ b/bin/gtr @@ -2,8 +2,6 @@ # gtr - Git worktree runner # Portable, cross-platform git worktree management -# shellcheck disable=SC2329 # Functions defined inside adapter builders are invoked indirectly - set -e # Version @@ -30,115 +28,13 @@ resolve_script_dir() { . "$GTR_DIR/lib/copy.sh" . "$GTR_DIR/lib/hooks.sh" . "$GTR_DIR/lib/provider.sh" +. "$GTR_DIR/lib/adapters.sh" -# Generic adapter functions (used when no explicit adapter file exists) -# These will be overridden if an adapter file is sourced -# Globals set by load_editor_adapter: GTR_EDITOR_CMD, GTR_EDITOR_CMD_NAME -editor_can_open() { - command -v "$GTR_EDITOR_CMD_NAME" >/dev/null 2>&1 -} - -editor_open() { - local path="$1" - local workspace="${2:-}" - local target="$path" - - # Use workspace file if provided and exists - if [ -n "$workspace" ] && [ -f "$workspace" ]; then - target="$workspace" - fi - - # $GTR_EDITOR_CMD may contain arguments (e.g., "code --wait") - # Using eval here is necessary to handle multi-word commands properly - eval "$GTR_EDITOR_CMD \"\$target\"" -} - -# Globals set by load_ai_adapter: GTR_AI_CMD, GTR_AI_CMD_NAME -ai_can_start() { - command -v "$GTR_AI_CMD_NAME" >/dev/null 2>&1 -} - -ai_start() { - local path="$1" - shift - # $GTR_AI_CMD may contain arguments (e.g., "bunx @github/copilot@latest") - # Using eval here is necessary to handle multi-word commands properly - (cd "$path" && eval "$GTR_AI_CMD \"\$@\"") -} - -# Standard AI adapter builder — used by adapter files that follow the common pattern -# Sets globals then call this: _AI_CMD, _AI_ERR_MSG, _AI_INFO_LINES (array) -_ai_define_standard() { - # shellcheck disable=SC2317 # Functions are called indirectly via adapter dispatch - ai_can_start() { - command -v "$_AI_CMD" >/dev/null 2>&1 - } - - # shellcheck disable=SC2317 - ai_start() { - local path="$1"; shift - if ! ai_can_start; then - log_error "$_AI_ERR_MSG" - local _line - for _line in "${_AI_INFO_LINES[@]}"; do - log_info "$_line" - done - return 1 - fi - if [ ! -d "$path" ]; then - log_error "Directory not found: $path" - return 1 - fi - (cd "$path" && "$_AI_CMD" "$@") - } -} - -# Standard editor adapter builder — used by adapter files that follow the common pattern -# Sets globals then call this: _EDITOR_CMD, _EDITOR_ERR_MSG, _EDITOR_WORKSPACE (optional, 0 or 1) -_editor_define_standard() { - # shellcheck disable=SC2317 # Functions are called indirectly via adapter dispatch - editor_can_open() { - command -v "$_EDITOR_CMD" >/dev/null 2>&1 - } - - # shellcheck disable=SC2317 - editor_open() { - local path="$1" - local workspace="${2:-}" - if ! editor_can_open; then - log_error "$_EDITOR_ERR_MSG" - return 1 - fi - if [ "${_EDITOR_WORKSPACE:-0}" = "1" ] && [ -n "$workspace" ] && [ -f "$workspace" ]; then - "$_EDITOR_CMD" "$workspace" - else - "$_EDITOR_CMD" "$path" - fi - } -} - -# Terminal editor adapter builder — for editors that run in the current terminal -# Sets globals then call this: _EDITOR_CMD, _EDITOR_ERR_MSG, _EDITOR_BACKGROUND (optional, 0 or 1) -_editor_define_terminal() { - # shellcheck disable=SC2317 # Functions are called indirectly via adapter dispatch - editor_can_open() { - command -v "$_EDITOR_CMD" >/dev/null 2>&1 - } - - # shellcheck disable=SC2317 - editor_open() { - local path="$1" - if ! editor_can_open; then - log_error "$_EDITOR_ERR_MSG" - return 1 - fi - if [ "${_EDITOR_BACKGROUND:-0}" = "1" ]; then - "$_EDITOR_CMD" "$path" & - else - (cd "$path" && "$_EDITOR_CMD" .) - fi - } -} +# Source command handlers +for _cmd_file in "$GTR_DIR"/lib/commands/*.sh; do + . "$_cmd_file" +done +unset _cmd_file # Main dispatcher main() { @@ -205,1964 +101,5 @@ main() { esac } -# Create command -# Copy files and directories to newly created worktree -# Usage: _post_create_copy repo_root worktree_path -_post_create_copy() { - local repo_root="$1" - local worktree_path="$2" - - local includes excludes file_includes - includes=$(cfg_get_all gtr.copy.include copy.include) - excludes=$(cfg_get_all gtr.copy.exclude copy.exclude) - - # Read .worktreeinclude file if exists - file_includes=$(parse_pattern_file "$repo_root/.worktreeinclude") - - # Merge patterns (newline-separated) - if [ -n "$file_includes" ]; then - if [ -n "$includes" ]; then - includes="$includes"$'\n'"$file_includes" - else - includes="$file_includes" - fi - fi - - if [ -n "$includes" ]; then - log_step "Copying files..." - copy_patterns "$repo_root" "$worktree_path" "$includes" "$excludes" - fi - - # Copy directories (typically git-ignored dirs like node_modules, .venv) - local dir_includes dir_excludes - dir_includes=$(cfg_get_all gtr.copy.includeDirs copy.includeDirs) - dir_excludes=$(cfg_get_all gtr.copy.excludeDirs copy.excludeDirs) - - if [ -n "$dir_includes" ]; then - log_step "Copying directories..." - copy_directories "$repo_root" "$worktree_path" "$dir_includes" "$dir_excludes" - fi -} - -# Show next steps after worktree creation (resolves collision for --folder overrides) -# Usage: _post_create_next_steps branch_name folder_name folder_override repo_root base_dir prefix -_post_create_next_steps() { - local branch_name="$1" folder_name="$2" folder_override="$3" - local repo_root="$4" base_dir="$5" prefix="$6" - - local next_steps_id - if [ -n "$folder_override" ]; then - # Check if folder_name would resolve to main repo (collision with current branch) - local resolve_result - if resolve_result=$(resolve_target "$folder_name" "$repo_root" "$base_dir" "$prefix" 2>/dev/null); then - unpack_target "$resolve_result" - if [ "$_ctx_is_main" = "1" ]; then - # Collision: folder name matches current branch, use branch name instead - next_steps_id="$branch_name" - else - next_steps_id="$folder_name" - fi - else - next_steps_id="$folder_name" - fi - else - next_steps_id="$branch_name" - fi - - echo "" - echo "Next steps:" - echo " git gtr editor $next_steps_id # Open in editor" - echo " git gtr ai $next_steps_id # Start AI tool" - echo " cd \"\$(git gtr go $next_steps_id)\" # Navigate to worktree" -} - -# Determine the base ref for worktree creation -# Usage: _create_resolve_from_ref -# Prints: resolved ref -_create_resolve_from_ref() { - local from_ref="$1" from_current="$2" repo_root="$3" - - if [ -z "$from_ref" ]; then - if [ "$from_current" -eq 1 ]; then - from_ref=$(get_current_branch) - if [ -z "$from_ref" ] || [ "$from_ref" = "HEAD" ]; then - log_warn "Currently in detached HEAD state - falling back to default branch" - from_ref=$(resolve_default_branch "$repo_root") - else - log_info "Creating from current branch: $from_ref" - fi - else - from_ref=$(resolve_default_branch "$repo_root") - fi - fi - - printf "%s" "$from_ref" -} - -# Auto-launch editor for a worktree -_auto_launch_editor() { - local worktree_path="$1" - local editor - editor=$(cfg_default gtr.editor.default GTR_EDITOR_DEFAULT "none" defaults.editor) - if [ "$editor" != "none" ]; then - load_editor_adapter "$editor" || return 1 - local workspace_file - workspace_file=$(resolve_workspace_file "$worktree_path") - log_step "Opening in $editor..." - editor_open "$worktree_path" "$workspace_file" - else - open_in_gui "$worktree_path" - log_info "Opened in file browser (no editor configured)" - fi -} - -# Auto-launch AI tool for a worktree -_auto_launch_ai() { - local worktree_path="$1" - local ai_tool - ai_tool=$(cfg_default gtr.ai.default GTR_AI_DEFAULT "none" defaults.ai) - if [ "$ai_tool" = "none" ]; then - log_warn "No AI tool configured. Set with: git gtr config set gtr.ai.default claude" - else - load_ai_adapter "$ai_tool" || return 1 - log_step "Starting $ai_tool..." - ai_start "$worktree_path" - fi -} - -cmd_create() { - local branch_name="" - local from_ref="" - local from_current=0 - local track_mode="auto" - local skip_copy=0 - local skip_fetch=0 - local skip_hooks=0 - local yes_mode=0 - local force=0 - local custom_name="" - local folder_override="" - local open_editor=0 - local start_ai=0 - - # Parse flags and arguments - while [ $# -gt 0 ]; do - case "$1" in - --from) - from_ref="$2" - shift 2 - ;; - --from-current) - from_current=1 - shift - ;; - --track) - track_mode="$2" - shift 2 - ;; - --no-copy) - skip_copy=1 - shift - ;; - --no-fetch) - skip_fetch=1 - shift - ;; - --no-hooks) - skip_hooks=1 - shift - ;; - --yes) - yes_mode=1 - shift - ;; - --force) - force=1 - shift - ;; - --name) - custom_name="$2" - shift 2 - ;; - --folder) - folder_override="$2" - shift 2 - ;; - --editor|-e) - open_editor=1 - shift - ;; - --ai|-a) - start_ai=1 - shift - ;; - -*) - log_error "Unknown flag: $1" - exit 1 - ;; - *) - # Positional argument: treat as branch name - if [ -z "$branch_name" ]; then - branch_name="$1" - fi - shift - ;; - esac - done - - # Validate flag combinations - 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" - if [ -n "$branch_name" ]; then - echo "Example: git gtr new $branch_name --force --name backend" >&2 - echo " or: git gtr new $branch_name --force --folder my-folder" >&2 - else - echo "Example: git gtr new feature-auth --force --name backend" >&2 - echo " or: git gtr new feature-auth --force --folder my-folder" >&2 - fi - exit 1 - fi - - # Get repo info - resolve_repo_context || exit 1 - local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix" - - # Get branch name if not provided - if [ -z "$branch_name" ]; then - if [ "$yes_mode" -eq 1 ]; then - log_error "Branch name required in non-interactive mode" - exit 1 - fi - branch_name=$(prompt_input "Enter branch name:") - if [ -z "$branch_name" ]; then - log_error "Branch name required" - exit 1 - fi - fi - - # Determine from_ref with precedence: --from > --from-current > default - from_ref=$(_create_resolve_from_ref "$from_ref" "$from_current" "$repo_root") - - # 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 - - log_step "Creating worktree: $folder_name" - echo "Location: $base_dir/${prefix}${folder_name}" - echo "Branch: $branch_name" - - # Create the worktree - if ! worktree_path=$(create_worktree "$base_dir" "$prefix" "$branch_name" "$from_ref" "$track_mode" "$skip_fetch" "$force" "$custom_name" "$folder_override"); then - exit 1 - fi - - # Copy files based on patterns - if [ "$skip_copy" -eq 0 ]; then - _post_create_copy "$repo_root" "$worktree_path" - fi - - # Run post-create hooks (unless --no-hooks) - if [ "$skip_hooks" -eq 0 ]; then - run_hooks_in postCreate "$worktree_path" \ - REPO_ROOT="$repo_root" \ - WORKTREE_PATH="$worktree_path" \ - BRANCH="$branch_name" - fi - - echo "" - log_info "Worktree created: $worktree_path" - - # Auto-launch editor/AI or show next steps - [ "$open_editor" -eq 1 ] && { _auto_launch_editor "$worktree_path" || true; } - [ "$start_ai" -eq 1 ] && { _auto_launch_ai "$worktree_path" || 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 -} - -# Remove command -cmd_remove() { - local delete_branch=0 - local yes_mode=0 - local force=0 - local identifiers="" - - # Parse flags - while [ $# -gt 0 ]; do - case "$1" in - --delete-branch) - delete_branch=1 - shift - ;; - --yes) - yes_mode=1 - shift - ;; - --force) - force=1 - shift - ;; - -*) - log_error "Unknown flag: $1" - exit 1 - ;; - *) - identifiers="$identifiers $1" - shift - ;; - esac - done - - if [ -z "$identifiers" ]; then - log_error "Usage: git gtr rm [...] [--delete-branch] [--force] [--yes]" - exit 1 - fi - - resolve_repo_context || exit 1 - local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix" - - for identifier in $identifiers; do - # Resolve target branch - local is_main worktree_path branch_name - resolve_worktree "$identifier" "$repo_root" "$base_dir" "$prefix" || continue - is_main="$_ctx_is_main" worktree_path="$_ctx_worktree_path" branch_name="$_ctx_branch" - - # Cannot remove main repository - if [ "$is_main" = "1" ]; then - log_error "Cannot remove main repository" - continue - fi - - log_step "Removing worktree: $(basename "$worktree_path")" - - # Run pre-remove hooks (abort on failure unless --force) - if ! run_hooks_in preRemove "$worktree_path" \ - REPO_ROOT="$repo_root" \ - WORKTREE_PATH="$worktree_path" \ - BRANCH="$branch_name"; then - if [ "$force" -eq 0 ]; then - log_error "Pre-remove hook failed for $branch_name. Use --force to skip hooks." - continue - else - log_warn "Pre-remove hook failed, continuing due to --force" - fi - fi - - # Remove the worktree - if ! remove_worktree "$worktree_path" "$force"; then - continue - fi - - # Handle branch deletion - if [ -n "$branch_name" ]; then - if [ "$delete_branch" -eq 1 ]; then - if [ "$yes_mode" -eq 1 ] || prompt_yes_no "Also delete branch '$branch_name'?"; then - if git branch -D "$branch_name" 2>/dev/null; then - log_info "Branch deleted: $branch_name" - else - log_warn "Could not delete branch: $branch_name" - fi - fi - fi - fi - - # Run post-remove hooks - run_hooks postRemove \ - REPO_ROOT="$repo_root" \ - WORKTREE_PATH="$worktree_path" \ - BRANCH="$branch_name" - done -} - -# Rename command (rename worktree and branch) -cmd_rename() { - local old_identifier="" - local new_name="" - local force=0 - local yes_mode=0 - - # Parse flags and arguments - while [ $# -gt 0 ]; do - case "$1" in - --force) - force=1 - shift - ;; - --yes) - yes_mode=1 - shift - ;; - -*) - log_error "Unknown flag: $1" - exit 1 - ;; - *) - if [ -z "$old_identifier" ]; then - old_identifier="$1" - elif [ -z "$new_name" ]; then - new_name="$1" - fi - shift - ;; - esac - done - - # Validate arguments - if [ -z "$old_identifier" ] || [ -z "$new_name" ]; then - log_error "Usage: git gtr mv [--force] [--yes]" - exit 1 - fi - - resolve_repo_context || exit 1 - local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix" - - # Resolve old worktree - local is_main old_path old_branch - resolve_worktree "$old_identifier" "$repo_root" "$base_dir" "$prefix" || exit 1 - is_main="$_ctx_is_main" old_path="$_ctx_worktree_path" old_branch="$_ctx_branch" - - # Cannot rename main repository - if [ "$is_main" = "1" ]; then - log_error "Cannot rename main repository" - exit 1 - fi - - # Sanitize new name and construct new path - local new_sanitized new_path - new_sanitized=$(sanitize_branch_name "$new_name") - new_path="$base_dir/${prefix}${new_sanitized}" - - # Check if new path already exists - if [ -d "$new_path" ]; then - log_error "Worktree already exists at: $new_path" - exit 1 - fi - - # Check if new branch name already exists - if git -C "$repo_root" show-ref --verify --quiet "refs/heads/$new_name"; then - log_error "Branch '$new_name' already exists" - exit 1 - fi - - log_step "Renaming worktree" - echo "Branch: $old_branch → $new_name" - echo "Folder: $(basename "$old_path") → ${prefix}${new_sanitized}" - - # Confirm unless --yes - if [ "$yes_mode" -eq 0 ]; then - if ! prompt_yes_no "Proceed with rename?"; then - log_info "Cancelled" - exit 0 - fi - fi - - # Rename the branch first - if ! git -C "$repo_root" branch -m "$old_branch" "$new_name"; then - log_error "Failed to rename branch" - exit 1 - fi - - # Move the worktree - local move_args=() - if [ "$force" -eq 1 ]; then - move_args+=(--force) - fi - - if ! git -C "$repo_root" worktree move "$old_path" "$new_path" "${move_args[@]}"; then - # Rollback: rename branch back - log_warn "Worktree move failed, rolling back branch rename..." - git -C "$repo_root" branch -m "$new_name" "$old_branch" 2>/dev/null || true - log_error "Failed to move worktree" - exit 1 - fi - - echo "" - log_info "Renamed: $old_branch → $new_name" - log_info "Location: $new_path" - - # Check if remote tracking branch exists and warn - if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/$old_branch"; then - echo "" - log_warn "Remote branch 'origin/$old_branch' still exists" - log_info "To update remote, run:" - echo " git push origin :$old_branch $new_name" - fi -} - -# Go command (navigate to worktree - prints path for shell integration) -cmd_go() { - if [ $# -ne 1 ]; then - log_error "Usage: git gtr go " - exit 1 - fi - - local identifier="$1" - resolve_repo_context || exit 1 - local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix" - - # Resolve target branch - local is_main worktree_path branch - resolve_worktree "$identifier" "$repo_root" "$base_dir" "$prefix" || exit 1 - is_main="$_ctx_is_main" worktree_path="$_ctx_worktree_path" branch="$_ctx_branch" - - # Human messages to stderr so stdout can be used in command substitution - if [ "$is_main" = "1" ]; then - echo "Main repo" >&2 - else - echo "Worktree: $branch" >&2 - fi - echo "Branch: $branch" >&2 - - # Print path to stdout for shell integration: cd "$(gtr go my-feature)" - printf "%s\n" "$worktree_path" -} - -# Run command (execute command in worktree directory) -cmd_run() { - local identifier="" - local -a run_args=() - - # Parse arguments - while [ $# -gt 0 ]; do - case "$1" in - -*) - log_error "Unknown flag: $1" - exit 1 - ;; - *) - if [ -z "$identifier" ]; then - identifier="$1" - shift - else - run_args=("$@") # Capture all remaining args as the command - break - fi - ;; - esac - done - - # Validation - if [ -z "$identifier" ]; then - log_error "Usage: git gtr run " - exit 1 - fi - - if [ ${#run_args[@]} -eq 0 ]; then - log_error "Usage: git gtr run " - log_error "No command specified" - exit 1 - fi - - resolve_repo_context || exit 1 - local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix" - - # Resolve target branch - local is_main worktree_path branch - resolve_worktree "$identifier" "$repo_root" "$base_dir" "$prefix" || exit 1 - is_main="$_ctx_is_main" worktree_path="$_ctx_worktree_path" branch="$_ctx_branch" - - # Human messages to stderr (like cmd_go) - if [ "$is_main" = "1" ]; then - log_step "Running in: main repo" - else - log_step "Running in: $branch" - fi - echo "Command: ${run_args[*]}" >&2 - echo "" >&2 - - # Execute command in worktree directory (exit code propagates) - (cd "$worktree_path" && "${run_args[@]}") -} - -# Copy command (copy files between worktrees) -cmd_copy() { - local source="1" # Default: main repo - local targets="" - local patterns="" - local all_mode=0 - local dry_run=0 - - # Parse arguments (patterns come after -- separator, like git pathspec) - while [ $# -gt 0 ]; do - case "$1" in - --from) - source="$2" - shift 2 - ;; - -n|--dry-run) - dry_run=1 - shift - ;; - -a|--all) - all_mode=1 - shift - ;; - --) - shift - # Remaining args are patterns (like git pathspec) - while [ $# -gt 0 ]; do - if [ -n "$patterns" ]; then - patterns="$patterns"$'\n'"$1" - else - patterns="$1" - fi - shift - done - break - ;; - -*) - log_error "Unknown flag: $1" - exit 1 - ;; - *) - targets="$targets $1" - shift - ;; - esac - done - - # Validation - if [ "$all_mode" -eq 0 ] && [ -z "$targets" ]; then - log_error "Usage: git gtr copy ... [-n] [-a] [--from ] [-- ...]" - exit 1 - fi - - # Get repo context - resolve_repo_context || exit 1 - local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix" - - # Resolve source path - local src_path - resolve_worktree "$source" "$repo_root" "$base_dir" "$prefix" || exit 1 - src_path="$_ctx_worktree_path" - - # Get patterns (flag > config) - if [ -z "$patterns" ]; then - patterns=$(cfg_get_all gtr.copy.include copy.include) - # Also check .worktreeinclude - if [ -f "$repo_root/.worktreeinclude" ]; then - local file_patterns - file_patterns=$(parse_pattern_file "$repo_root/.worktreeinclude") - if [ -n "$file_patterns" ]; then - if [ -n "$patterns" ]; then - patterns="$patterns"$'\n'"$file_patterns" - else - patterns="$file_patterns" - fi - fi - fi - fi - - if [ -z "$patterns" ]; then - log_error "No patterns specified. Use '-- ...' or configure gtr.copy.include" - exit 1 - fi - - local excludes - excludes=$(cfg_get_all gtr.copy.exclude copy.exclude) - - # Build target list for --all mode - if [ "$all_mode" -eq 1 ]; then - targets=$(list_worktree_branches "$base_dir" "$prefix") - if [ -z "$targets" ]; then - log_error "No worktrees found" - exit 1 - fi - fi - - # Process each target - local copied_any=0 - for target_id in $targets; do - local dst_path dst_branch - resolve_worktree "$target_id" "$repo_root" "$base_dir" "$prefix" || continue - dst_path="$_ctx_worktree_path" dst_branch="$_ctx_branch" - - # Skip if source == destination - [ "$src_path" = "$dst_path" ] && continue - - if [ "$dry_run" -eq 1 ]; then - log_step "[dry-run] Would copy to: $dst_branch" - copy_patterns "$src_path" "$dst_path" "$patterns" "$excludes" "true" "true" - else - log_step "Copying to: $dst_branch" - copy_patterns "$src_path" "$dst_path" "$patterns" "$excludes" "true" - fi - copied_any=1 - done - - if [ "$copied_any" -eq 0 ]; then - log_warn "No files copied (source and target may be the same)" - fi -} - -# Editor command -cmd_editor() { - local identifier="" - local editor="" - - # Parse flags - while [ $# -gt 0 ]; do - case "$1" in - --editor) - editor="$2" - shift 2 - ;; - -*) - log_error "Unknown flag: $1" - exit 1 - ;; - *) - if [ -z "$identifier" ]; then - identifier="$1" - fi - shift - ;; - esac - done - - if [ -z "$identifier" ]; then - log_error "Usage: git gtr editor [--editor ]" - exit 1 - fi - - # Get editor from flag or config (with .gtrconfig support) - if [ -z "$editor" ]; then - editor=$(cfg_default gtr.editor.default GTR_EDITOR_DEFAULT "none" defaults.editor) - fi - - resolve_repo_context || exit 1 - local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix" - - # Resolve target branch - local worktree_path branch - resolve_worktree "$identifier" "$repo_root" "$base_dir" "$prefix" || exit 1 - worktree_path="$_ctx_worktree_path" branch="$_ctx_branch" - - if [ "$editor" = "none" ]; then - # Just open in GUI file browser - open_in_gui "$worktree_path" - log_info "Opened in file browser" - else - # Load editor adapter and open - load_editor_adapter "$editor" || exit 1 - local workspace_file - workspace_file=$(resolve_workspace_file "$worktree_path") - log_step "Opening in $editor..." - editor_open "$worktree_path" "$workspace_file" - fi -} - -# AI command -cmd_ai() { - local identifier="" - local ai_tool="" - local -a ai_args=() - - # Parse arguments - while [ $# -gt 0 ]; do - case "$1" in - --ai) - ai_tool="$2" - shift 2 - ;; - --) - shift - ai_args=("$@") - break - ;; - -*) - log_error "Unknown flag: $1" - exit 1 - ;; - *) - if [ -z "$identifier" ]; then - identifier="$1" - fi - shift - ;; - esac - done - - if [ -z "$identifier" ]; then - log_error "Usage: git gtr ai [--ai ] [-- args...]" - exit 1 - fi - - # Get AI tool from flag or config (with .gtrconfig support) - if [ -z "$ai_tool" ]; then - ai_tool=$(cfg_default gtr.ai.default GTR_AI_DEFAULT "none" defaults.ai) - fi - - # Check if AI tool is configured - if [ "$ai_tool" = "none" ]; then - log_error "No AI tool configured" - log_info "Set default: git gtr config set gtr.ai.default claude" - exit 1 - fi - - # Load AI adapter - load_ai_adapter "$ai_tool" || exit 1 - - resolve_repo_context || exit 1 - local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix" - - # Resolve target branch - local worktree_path branch - resolve_worktree "$identifier" "$repo_root" "$base_dir" "$prefix" || exit 1 - worktree_path="$_ctx_worktree_path" branch="$_ctx_branch" - - log_step "Starting $ai_tool for: $branch" - echo "Directory: $worktree_path" - echo "Branch: $branch" - - ai_start "$worktree_path" "${ai_args[@]}" -} - -# List command -cmd_list() { - local porcelain=0 - - # Parse flags - while [ $# -gt 0 ]; do - case "$1" in - --porcelain) - porcelain=1 - shift - ;; - *) - shift - ;; - esac - done - - local repo_root base_dir prefix - repo_root=$(discover_repo_root) 2>/dev/null || return 0 - base_dir=$(resolve_base_dir "$repo_root") - prefix=$(cfg_default gtr.worktrees.prefix GTR_WORKTREES_PREFIX "") - - # Machine-readable output (porcelain) - if [ "$porcelain" -eq 1 ]; then - # Output: pathbranchstatus - local branch status - branch=$(current_branch "$repo_root") - status=$(worktree_status "$repo_root") - printf "%s\t%s\t%s\n" "$repo_root" "$branch" "$status" - - if [ -d "$base_dir" ]; then - # Find all worktree directories and output: pathbranchstatus - # Exclude the base directory itself to avoid matching when prefix is empty - find "$base_dir" -maxdepth 1 -type d -name "${prefix}*" 2>/dev/null | while IFS= read -r dir; do - # Skip the base directory itself - [ "$dir" = "$base_dir" ] && continue - local branch status - branch=$(current_branch "$dir") - [ -z "$branch" ] && branch="(detached)" - status=$(worktree_status "$dir") - printf "%s\t%s\t%s\n" "$dir" "$branch" "$status" - done | LC_ALL=C sort -k2,2 - fi - return 0 - fi - - # Human-readable output - table format - echo "Git Worktrees" - echo "" - printf "%-30s %s\n" "BRANCH" "PATH" - printf "%-30s %s\n" "------" "----" - - # Always show repo root first - local branch - branch=$(current_branch "$repo_root") - printf "%-30s %s\n" "$branch [main repo]" "$repo_root" - - # Show worktrees sorted by branch name - if [ -d "$base_dir" ]; then - find "$base_dir" -maxdepth 1 -type d -name "${prefix}*" 2>/dev/null | while IFS= read -r dir; do - # Skip the base directory itself - [ "$dir" = "$base_dir" ] && continue - local branch - branch=$(current_branch "$dir") - [ -z "$branch" ] && branch="(detached)" - printf "%-30s %s\n" "$branch" "$dir" - done | LC_ALL=C sort -k1,1 - fi - - echo "" - echo "" - echo "Tip: Use 'git gtr list --porcelain' for machine-readable output" -} - -# Clean command (remove prunable worktrees) -# Remove worktrees whose PRs/MRs are merged (handles squash merges) -# Usage: _clean_merged repo_root base_dir prefix yes_mode dry_run -_clean_merged() { - local repo_root="$1" base_dir="$2" prefix="$3" yes_mode="$4" dry_run="$5" - - log_step "Checking for worktrees with merged PRs/MRs..." - - # Detect hosting provider (GitHub, GitLab, etc.) - local provider - provider=$(detect_provider) || true - if [ -z "$provider" ]; then - local remote_url - remote_url=$(git remote get-url origin 2>/dev/null || true) - if [ -z "$remote_url" ]; then - log_error "No remote URL configured for 'origin'" - else - # Sanitize URL to avoid leaking embedded credentials (e.g., https://token@host/...) - local safe_url="${remote_url%%@*}" - if [ "$safe_url" != "$remote_url" ]; then - safe_url="@${remote_url#*@}" - fi - log_error "Could not detect hosting provider from remote URL: $safe_url" - log_info "Set manually: git gtr config set gtr.provider github (or gitlab)" - fi - exit 1 - fi - - # Ensure provider CLI is available and authenticated - ensure_provider_cli "$provider" || exit 1 - - # Fetch latest from origin - log_step "Fetching from origin..." - git fetch origin --prune 2>/dev/null || log_warn "Could not fetch from origin" - - local removed=0 - local skipped=0 - - # Get main repo branch to exclude it - local main_branch - main_branch=$(current_branch "$repo_root") - - # Iterate through worktree directories - for dir in "$base_dir/${prefix}"*; do - [ -d "$dir" ] || continue - - local branch - branch=$(current_branch "$dir") - - if [ -z "$branch" ] || [ "$branch" = "(detached)" ]; then - log_warn "Skipping $dir (detached HEAD)" - skipped=$((skipped + 1)) - continue - fi - - # Skip if same as main repo branch - if [ "$branch" = "$main_branch" ]; then - continue - fi - - # Check if worktree has uncommitted changes - if ! git -C "$dir" diff --quiet 2>/dev/null || \ - ! git -C "$dir" diff --cached --quiet 2>/dev/null; then - log_warn "Skipping $branch (has uncommitted changes)" - skipped=$((skipped + 1)) - continue - fi - - # Check for untracked files - if [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]; then - log_warn "Skipping $branch (has untracked files)" - skipped=$((skipped + 1)) - continue - fi - - # Check if branch has a merged PR/MR - if check_branch_merged "$provider" "$branch"; then - if [ "$dry_run" -eq 1 ]; then - log_info "[dry-run] Would remove: $branch ($dir)" - removed=$((removed + 1)) - elif [ "$yes_mode" -eq 1 ] || prompt_yes_no "Remove worktree and delete branch '$branch'?"; then - log_step "Removing worktree: $branch" - local remove_output - if remove_output=$(git worktree remove "$dir" 2>&1); then - # Also delete the local branch - git branch -d "$branch" 2>/dev/null || git branch -D "$branch" 2>/dev/null || true - log_info "Removed: $branch" - removed=$((removed + 1)) - else - if [ -n "$remove_output" ]; then - log_error "Failed to remove worktree: $remove_output" - else - log_error "Failed to remove worktree: $branch" - fi - fi - else - log_warn "Skipped: $branch (user declined)" - skipped=$((skipped + 1)) - fi - fi - # Branches without merged PRs are silently skipped (this is the normal case) - done - - echo "" - if [ "$dry_run" -eq 1 ]; then - log_info "Dry run complete. Would remove: $removed, Skipped: $skipped" - else - log_info "Merged cleanup complete. Removed: $removed, Skipped: $skipped" - fi -} - -cmd_clean() { - local merged_mode=0 - local yes_mode=0 - local dry_run=0 - - # Parse flags - while [ $# -gt 0 ]; do - case "$1" in - --merged) - merged_mode=1 - shift - ;; - --yes|-y) - yes_mode=1 - shift - ;; - --dry-run|-n) - dry_run=1 - shift - ;; - -*) - log_error "Unknown flag: $1" - exit 1 - ;; - *) - shift - ;; - esac - done - - log_step "Cleaning up stale worktrees..." - - # Run git worktree prune - if git worktree prune 2>/dev/null; then - log_info "Pruned stale worktree administrative files" - fi - - resolve_repo_context || exit 1 - local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix" - - if [ ! -d "$base_dir" ]; then - log_info "No worktrees directory to clean" - return 0 - fi - - # Find and remove empty directories - local cleaned=0 - local empty_dirs - empty_dirs=$(find "$base_dir" -maxdepth 1 -type d -empty 2>/dev/null | grep -v "^${base_dir}$" || true) - - if [ -n "$empty_dirs" ]; then - while IFS= read -r dir; do - if [ -n "$dir" ]; then - if rmdir "$dir" 2>/dev/null; then - cleaned=$((cleaned + 1)) - log_info "Removed empty directory: $(basename "$dir")" - fi - fi - done </dev/null 2>&1; then - local git_version - git_version=$(git --version) - echo "[OK] Git: $git_version" - else - echo "[x] Git: not found" - issues=$((issues + 1)) - fi - - # Check repo - local repo_root - if repo_root=$(discover_repo_root 2>/dev/null); then - echo "[OK] Repository: $repo_root" - - # Check worktree base dir - local base_dir prefix - base_dir=$(resolve_base_dir "$repo_root") - prefix=$(cfg_default gtr.worktrees.prefix GTR_WORKTREES_PREFIX "") - - if [ -d "$base_dir" ]; then - local count - count=$(find "$base_dir" -maxdepth 1 -type d -name "${prefix}*" 2>/dev/null | wc -l | tr -d ' ') - echo "[OK] Worktrees directory: $base_dir ($count worktrees)" - else - echo "[i] Worktrees directory: $base_dir (not created yet)" - fi - else - echo "[x] Not in a git repository" - issues=$((issues + 1)) - fi - - # Check configured editor (with .gtrconfig support) - local editor - editor=$(cfg_default gtr.editor.default GTR_EDITOR_DEFAULT "none" defaults.editor) - if [ "$editor" != "none" ]; then - # Check if adapter exists - local editor_adapter="$GTR_DIR/adapters/editor/${editor}.sh" - if [ -f "$editor_adapter" ]; then - # shellcheck disable=SC1090 - . "$editor_adapter" - if editor_can_open 2>/dev/null; then - echo "[OK] Editor: $editor (found)" - else - echo "[!] Editor: $editor (configured but not found in PATH)" - fi - else - echo "[!] Editor: $editor (adapter not found)" - fi - else - echo "[i] Editor: none configured" - fi - - # Check configured AI tool (with .gtrconfig support) - local ai_tool - ai_tool=$(cfg_default gtr.ai.default GTR_AI_DEFAULT "none" defaults.ai) - if [ "$ai_tool" != "none" ]; then - # Check if adapter exists - local adapter_file="$GTR_DIR/adapters/ai/${ai_tool}.sh" - if [ -f "$adapter_file" ]; then - # shellcheck disable=SC1090 - . "$adapter_file" - if ai_can_start 2>/dev/null; then - echo "[OK] AI tool: $ai_tool (found)" - else - echo "[!] AI tool: $ai_tool (configured but not found in PATH)" - fi - else - echo "[!] AI tool: $ai_tool (adapter not found)" - fi - else - echo "[i] AI tool: none configured" - fi - - # Check OS - local os - os=$(detect_os) - echo "[OK] Platform: $os" - - # Check hosting provider - if [ -n "$repo_root" ]; then - local provider - provider=$(detect_provider 2>/dev/null) - if [ -n "$provider" ]; then - echo "[OK] Provider: $provider" - case "$provider" in - github) - if command -v gh >/dev/null 2>&1; then - echo "[OK] GitHub CLI: $(gh --version 2>/dev/null | head -1)" - else - echo "[!] GitHub CLI: not found (needed for: clean --merged)" - fi - ;; - gitlab) - if command -v glab >/dev/null 2>&1; then - echo "[OK] GitLab CLI: $(glab --version 2>/dev/null | head -1)" - else - echo "[!] GitLab CLI: not found (needed for: clean --merged)" - fi - ;; - esac - else - echo "[i] Provider: unknown (set gtr.provider for clean --merged)" - fi - fi - - echo "" - if [ "$issues" -eq 0 ]; then - echo "Everything looks good!" - return 0 - else - echo "[!] Found $issues issue(s)" - return 1 - fi -} - -# Adapter command (list available adapters) -cmd_adapter() { - echo "Available Adapters" - echo "" - - # Editor adapters - echo "Editor Adapters:" - echo "" - printf "%-15s %-15s %s\n" "NAME" "STATUS" "NOTES" - printf "%-15s %-15s %s\n" "---------------" "---------------" "-----" - - for adapter_file in "$GTR_DIR"/adapters/editor/*.sh; do - if [ -f "$adapter_file" ]; then - local adapter_name - adapter_name=$(basename "$adapter_file" .sh) - # shellcheck disable=SC1090 - . "$adapter_file" - - if editor_can_open 2>/dev/null; then - printf "%-15s %-15s %s\n" "$adapter_name" "[ready]" "" - else - printf "%-15s %-15s %s\n" "$adapter_name" "[missing]" "Not found in PATH" - fi - fi - done - - echo "" - echo "" - echo "AI Tool Adapters:" - echo "" - printf "%-15s %-15s %s\n" "NAME" "STATUS" "NOTES" - printf "%-15s %-15s %s\n" "---------------" "---------------" "-----" - - for adapter_file in "$GTR_DIR"/adapters/ai/*.sh; do - if [ -f "$adapter_file" ]; then - local adapter_name - adapter_name=$(basename "$adapter_file" .sh) - # shellcheck disable=SC1090 - . "$adapter_file" - - if ai_can_start 2>/dev/null; then - printf "%-15s %-15s %s\n" "$adapter_name" "[ready]" "" - else - printf "%-15s %-15s %s\n" "$adapter_name" "[missing]" "Not found in PATH" - fi - fi - done - - echo "" - echo "" - echo "Tip: Set defaults with:" - echo " git gtr config set gtr.editor.default " - echo " git gtr config set gtr.ai.default " -} - -# Config command -cmd_config() { - local scope="auto" - local action="" key="" value="" - local extra_args="" - - # Parse args flexibly: action, key, value, and --global/--local anywhere - while [ $# -gt 0 ]; do - case "$1" in - --global|global) - scope="global" - shift - ;; - --local|local) - scope="local" - shift - ;; - --system|system) - scope="system" - shift - ;; - get|set|unset|add|list) - action="$1" - shift - ;; - *) - if [ -z "$key" ]; then - key="$1" - shift - elif [ -z "$value" ] && { [ "$action" = "set" ] || [ "$action" = "add" ]; }; then - value="$1" - shift - else - # Track extra tokens for validation (add space only if not first) - extra_args="${extra_args:+$extra_args }$1" - shift - fi - ;; - esac - done - - # Default action: list if no action and no key, otherwise get - if [ -z "$action" ]; then - if [ -z "$key" ]; then - action="list" - else - action="get" - fi - fi - - # Resolve "auto" scope to "local" for set/add/unset operations (they need explicit scope) - # This ensures log messages show the actual scope being used - local resolved_scope="$scope" - if [ "$scope" = "auto" ] && [ "$action" != "list" ] && [ "$action" != "get" ]; then - resolved_scope="local" - fi - - # Reject --system for write operations (requires root, not commonly useful) - if [ "$scope" = "system" ]; then - case "$action" in - set|add|unset) - log_error "--system is not supported for write operations (requires root privileges)" - log_error "Use --local or --global instead" - exit 1 - ;; - esac - fi - - case "$action" in - get) - if [ -z "$key" ]; then - log_error "Usage: git gtr config get [--local|--global|--system]" - exit 1 - fi - # Warn on unexpected extra arguments - if [ -n "$extra_args" ]; then - log_warn "get action: ignoring extra arguments: $extra_args" - fi - cfg_get_all "$key" "" "$scope" - ;; - set) - if [ -z "$key" ] || [ -z "$value" ]; then - log_error "Usage: git gtr config set [--local|--global]" - exit 1 - fi - # Warn on unexpected extra arguments - if [ -n "$extra_args" ]; then - log_warn "set action: ignoring extra arguments: $extra_args" - fi - if ! _cfg_is_known_key "$key"; then - log_warn "Unknown config key: $key (not a recognized gtr.* key)" - fi - cfg_set "$key" "$value" "$resolved_scope" - log_info "Config set: $key = $value ($resolved_scope)" - ;; - add) - if [ -z "$key" ] || [ -z "$value" ]; then - log_error "Usage: git gtr config add [--local|--global]" - exit 1 - fi - # Warn on unexpected extra arguments - if [ -n "$extra_args" ]; then - log_warn "add action: ignoring extra arguments: $extra_args" - fi - if ! _cfg_is_known_key "$key"; then - log_warn "Unknown config key: $key (not a recognized gtr.* key)" - fi - cfg_add "$key" "$value" "$resolved_scope" - log_info "Config added: $key = $value ($resolved_scope)" - ;; - unset) - if [ -z "$key" ]; then - log_error "Usage: git gtr config unset [--local|--global]" - exit 1 - fi - # Warn on unexpected extra arguments (including value which unset doesn't use) - if [ -n "$value" ] || [ -n "$extra_args" ]; then - log_warn "unset action: ignoring extra arguments: ${value}${value:+ }${extra_args}" - fi - if ! _cfg_is_known_key "$key"; then - log_warn "Unknown config key: $key (not a recognized gtr.* key)" - fi - cfg_unset "$key" "$resolved_scope" - log_info "Config unset: $key ($resolved_scope)" - ;; - list) - # Warn on unexpected extra arguments - if [ -n "$key" ] || [ -n "$extra_args" ]; then - log_warn "list action doesn't accept additional arguments (ignoring: ${key}${key:+ }${extra_args})" - fi - # Use cfg_list for proper formatting and .gtrconfig support - cfg_list "$scope" - ;; - *) - log_error "Unknown config action: $action" - log_error "Usage: git gtr config [list] [--local|--global|--system]" - log_error " git gtr config {get|set|add|unset} [value] [--local|--global]" - exit 1 - ;; - esac -} - -# Completion command (generate shell completions) -cmd_completion() { - local shell="${1:-}" - - case "$shell" in - bash) - cat "$GTR_DIR/completions/gtr.bash" - ;; - zsh) - # Output zstyle registration + completion loading - # The zstyle MUST run before compinit to register gtr as a git subcommand - cat <" - echo "" - echo "Shells:" - echo " bash Generate Bash completions" - echo " zsh Generate Zsh completions" - echo " fish Generate Fish completions" - echo "" - echo "Examples:" - echo " # Bash: add to ~/.bashrc" - echo " source <(git gtr completion bash)" - echo "" - echo " # Zsh: add to ~/.zshrc (BEFORE any existing compinit call)" - echo " eval \"\$(git gtr completion zsh)\"" - echo "" - echo " # Fish: save to completions directory" - echo " git gtr completion fish > ~/.config/fish/completions/git-gtr.fish" - return 0 - ;; - *) - log_error "Unknown shell: $shell" - log_error "Supported shells: bash, zsh, fish" - log_info "Run 'git gtr completion --help' for usage" - return 1 - ;; - esac -} - -# Init command (generate shell integration for cd support) -cmd_init() { - local shell="${1:-}" - - case "$shell" in - bash) - cat <<'BASH' -# git-gtr shell integration -# Add to ~/.bashrc: -# eval "$(git gtr init bash)" - -gtr() { - if [ "$#" -gt 0 ] && [ "$1" = "cd" ]; then - shift - local 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 -} - -# Forward completions to gtr wrapper -if type _git_gtr &>/dev/null; then - complete -F _git_gtr gtr -fi -BASH - ;; - zsh) - cat <<'ZSH' -# git-gtr shell integration -# Add to ~/.zshrc: -# eval "$(git gtr init zsh)" - -gtr() { - if [ "$#" -gt 0 ] && [ "$1" = "cd" ]; then - shift - local 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 -} - -# Forward completions to gtr wrapper -if (( $+functions[_git-gtr] )); then - compdef gtr=git-gtr -fi -ZSH - ;; - fish) - cat <<'FISH' -# git-gtr shell integration -# Add to ~/.config/fish/config.fish: -# git gtr init fish | source - -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 -end - -# Forward completions to gtr wrapper -complete -c gtr -w git-gtr -FISH - ;; - ""|--help|-h) - echo "Generate shell integration for git gtr" - echo "" - echo "Usage: git gtr init " - echo "" - echo "This outputs a gtr() shell function that enables:" - echo " gtr cd Change directory to worktree" - echo " gtr Passes through to git gtr" - echo "" - echo "Shells:" - echo " bash Generate Bash integration" - echo " zsh Generate Zsh integration" - echo " fish Generate Fish integration" - echo "" - echo "Examples:" - echo " # Bash: add to ~/.bashrc" - echo " eval \"\$(git gtr init bash)\"" - echo "" - echo " # Zsh: add to ~/.zshrc" - echo " eval \"\$(git gtr init zsh)\"" - echo "" - echo " # Fish: add to ~/.config/fish/config.fish" - echo " git gtr init fish | source" - return 0 - ;; - *) - log_error "Unknown shell: $shell" - log_error "Supported shells: bash, zsh, fish" - log_info "Run 'git gtr init --help' for usage" - return 1 - ;; - esac -} - -# Resolve workspace file for VS Code/Cursor editors -# Returns the workspace file path if found, empty otherwise -resolve_workspace_file() { - local worktree_path="$1" - - # Check config first (gtr.editor.workspace or editor.workspace in .gtrconfig) - local configured - configured=$(cfg_default gtr.editor.workspace "" "" editor.workspace) - - # Opt-out: "none" disables workspace lookup entirely - if [ "$configured" = "none" ]; then - return 0 - fi - - if [ -n "$configured" ]; then - local full_path="$worktree_path/$configured" - if [ -f "$full_path" ]; then - echo "$full_path" - fi - # Explicit config set - don't fall through to auto-detect - return 0 - fi - - # Auto-detect: find first .code-workspace in worktree root - local ws_file - ws_file=$(find "$worktree_path" -maxdepth 1 -name "*.code-workspace" -type f 2>/dev/null | head -1) - if [ -n "$ws_file" ]; then - echo "$ws_file" - fi -} - -# Load editor adapter -# Load an adapter by type (shared implementation for editor and AI adapters) -# Usage: _load_adapter