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 new file mode 100644 index 0000000..f7dfeac --- /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 update && sudo apt-get install -y shellcheck + + - name: Run ShellCheck + run: | + shellcheck bin/gtr bin/git-gtr lib/*.sh lib/commands/*.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 update && sudo apt-get install -y bats + + - name: Run tests + run: bats tests/ diff --git a/CLAUDE.md b/CLAUDE.md index 1f3184d..1604f17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,9 +12,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Development/testing**: `./bin/gtr ` (direct execution) - **User-facing docs**: Always reference `git gtr`, never `./bin/gtr` -## CRITICAL: No Automated Tests +## Testing -This project has **no test suite**. All testing is manual. After any change, run the relevant smoke tests: +This project uses **BATS tests** for core functions and **manual smoke tests** for end-to-end workflows. CI runs ShellCheck + BATS automatically on PRs (`.github/workflows/lint.yml`). + +1. Run automated tests: `bats tests/` +2. Run a single test file: `bats tests/config.bats` +3. Run a single test by name: `bats tests/config.bats --filter "cfg_map_to_file_key"` +4. Run relevant manual smoke tests: ```bash ./bin/gtr new test-feature # Create worktree @@ -25,7 +30,9 @@ This project has **no test suite**. All testing is manual. After any change, run ./bin/gtr rm test-feature # Clean up ``` -For exhaustive testing (hooks, copy patterns, adapters, `--force`, `--from-current`, etc.), see the full checklist in CONTRIBUTING.md or `.github/instructions/testing.instructions.md`. +For exhaustive manual testing (hooks, copy patterns, adapters, `--force`, `--from-current`, etc.), see the full checklist in CONTRIBUTING.md or `.github/instructions/testing.instructions.md`. + +**Test files**: `adapters`, `config`, `copy_safety`, `integration_lifecycle`, `parse_args`, `provider`, `resolve_base_dir`, `sanitize_branch_name` (all in `tests/`). Shared fixtures in `tests/test_helper.bash`. **Tip**: Use a disposable repo for testing to avoid polluting your working tree: @@ -39,28 +46,35 @@ 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: 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/ui.sh` | Logging (`log_error`, `log_info`, `log_warn`), prompts, formatting | +| `lib/args.sh` | Shared argument parser: flag specs (`--flag`, `--flag: val`, aliases), populates `_arg_*` vars | +| `lib/config.sh` | Git config wrapper with precedence: `cfg_get`, `cfg_default`, `cfg_get_all` | +| `lib/platform.sh` | OS detection, GUI helpers | +| `lib/core.sh` | Worktree CRUD: `create_worktree`, `remove_worktree`, `list_worktrees`, `resolve_target`, `resolve_base_dir` | +| `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/provider.sh` | Remote hosting detection (GitHub/GitLab) and CLI integration for `clean --merged` | +| `lib/adapters.sh` | Adapter registry, builder functions, generic fallbacks, loader functions | +| `lib/launch.sh` | Editor/AI launch orchestration: `_open_editor`, `_auto_launch_editor`, `_auto_launch_ai` | +| `lib/commands/*.sh` | One file per subcommand: `cmd_create`, `cmd_remove`, etc. (16 files) | + +Libraries are sourced in the order listed above (ui → args → config → ... → launch → commands/\*.sh glob). ### 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 +120,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 @@ -128,6 +144,17 @@ When adding commands or flags, update all three files: - `completions/_git-gtr` (Zsh) - `completions/git-gtr.fish` (Fish) +## Critical Gotcha: `set -e` + +`bin/gtr` runs with `set -e`. Any unguarded non-zero return silently exits the entire script. When calling functions that may `return 1`, guard with `|| true`: + +```bash +result=$(my_func) || true # Prevents silent exit +if my_func; then ...; fi # Also safe (if guards the return) +``` + +This is the most common source of subtle bugs in this codebase. + ## Code Style - Shebang: `#!/usr/bin/env bash` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1594849..33ca58e 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 @@ -159,7 +150,20 @@ For changes to core functionality (`lib/*.sh`): ### Testing -Currently, testing is manual. Please test your changes on: +#### Automated Tests (BATS) + +Run the test suite before submitting PRs: + +```bash +bats tests/ # Run all tests +bats tests/copy_safety.bats # Run a specific test file +``` + +CI runs ShellCheck + BATS automatically on all PRs (`.github/workflows/lint.yml`). + +#### Manual Testing + +Please also test your changes manually on: 1. **macOS** (if available) 2. **Linux** (Ubuntu, Fedora, or Arch recommended) diff --git a/adapters/ai/aider.sh b/adapters/ai/aider.sh deleted file mode 100644 index 191c3d6..0000000 --- a/adapters/ai/aider.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/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 "$@") -} diff --git a/adapters/ai/auggie.sh b/adapters/ai/auggie.sh deleted file mode 100644 index 274a1f2..0000000 --- a/adapters/ai/auggie.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/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 "$@") -} - diff --git a/adapters/ai/codex.sh b/adapters/ai/codex.sh deleted file mode 100644 index 582f8c3..0000000 --- a/adapters/ai/codex.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/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 "$@") -} diff --git a/adapters/ai/continue.sh b/adapters/ai/continue.sh deleted file mode 100644 index a4672c9..0000000 --- a/adapters/ai/continue.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/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 "$@") -} diff --git a/adapters/ai/copilot.sh b/adapters/ai/copilot.sh deleted file mode 100644 index 16608af..0000000 --- a/adapters/ai/copilot.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/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 "$@") -} diff --git a/adapters/ai/gemini.sh b/adapters/ai/gemini.sh deleted file mode 100644 index 2a4d017..0000000 --- a/adapters/ai/gemini.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/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 diff --git a/adapters/ai/opencode.sh b/adapters/ai/opencode.sh deleted file mode 100644 index e801f56..0000000 --- a/adapters/ai/opencode.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/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 "$@") -} - diff --git a/adapters/editor/atom.sh b/adapters/editor/atom.sh deleted file mode 100755 index 4e0acea..0000000 --- a/adapters/editor/atom.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/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" -} diff --git a/adapters/editor/cursor.sh b/adapters/editor/cursor.sh deleted file mode 100644 index 3c5712e..0000000 --- a/adapters/editor/cursor.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/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 -} diff --git a/adapters/editor/emacs.sh b/adapters/editor/emacs.sh deleted file mode 100755 index 473a79f..0000000 --- a/adapters/editor/emacs.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/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" & -} diff --git a/adapters/editor/idea.sh b/adapters/editor/idea.sh deleted file mode 100755 index c6dc4d4..0000000 --- a/adapters/editor/idea.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/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" -} diff --git a/adapters/editor/nvim.sh b/adapters/editor/nvim.sh deleted file mode 100755 index d845cae..0000000 --- a/adapters/editor/nvim.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/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 .) -} diff --git a/adapters/editor/pycharm.sh b/adapters/editor/pycharm.sh deleted file mode 100755 index 74c5499..0000000 --- a/adapters/editor/pycharm.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/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" -} diff --git a/adapters/editor/sublime.sh b/adapters/editor/sublime.sh deleted file mode 100755 index dff5c50..0000000 --- a/adapters/editor/sublime.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/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" -} diff --git a/adapters/editor/vim.sh b/adapters/editor/vim.sh deleted file mode 100755 index 2f1b0e4..0000000 --- a/adapters/editor/vim.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/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 .) -} diff --git a/adapters/editor/vscode.sh b/adapters/editor/vscode.sh deleted file mode 100644 index 5015ae1..0000000 --- a/adapters/editor/vscode.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/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 -} diff --git a/adapters/editor/webstorm.sh b/adapters/editor/webstorm.sh deleted file mode 100755 index ea55c5c..0000000 --- a/adapters/editor/webstorm.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/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" -} diff --git a/adapters/editor/zed.sh b/adapters/editor/zed.sh deleted file mode 100644 index 8664e8b..0000000 --- a/adapters/editor/zed.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/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" -} diff --git a/bin/gtr b/bin/gtr index 57847a9..f88a08c 100755 --- a/bin/gtr +++ b/bin/gtr @@ -22,47 +22,22 @@ resolve_script_dir() { # Source library files . "$GTR_DIR/lib/ui.sh" +. "$GTR_DIR/lib/args.sh" . "$GTR_DIR/lib/config.sh" . "$GTR_DIR/lib/platform.sh" . "$GTR_DIR/lib/core.sh" . "$GTR_DIR/lib/copy.sh" . "$GTR_DIR/lib/hooks.sh" . "$GTR_DIR/lib/provider.sh" +. "$GTR_DIR/lib/adapters.sh" +. "$GTR_DIR/lib/launch.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 \"\$@\"") -} +# Source command handlers +for _cmd_file in "$GTR_DIR"/lib/commands/*.sh; do + # shellcheck disable=SC1090 + . "$_cmd_file" +done +unset _cmd_file # Main dispatcher main() { @@ -129,1955 +104,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" -} - -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 - 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 - - # 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 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 - 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 target is_main worktree_path branch_name - target=$(resolve_target "$identifier" "$repo_root" "$base_dir" "$prefix") || continue - unpack_target "$target" - 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 target is_main old_path old_branch - target=$(resolve_target "$old_identifier" "$repo_root" "$base_dir" "$prefix") || exit 1 - unpack_target "$target" - 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 target is_main worktree_path branch - target=$(resolve_target "$identifier" "$repo_root" "$base_dir" "$prefix") || exit 1 - unpack_target "$target" - 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 target is_main worktree_path branch - target=$(resolve_target "$identifier" "$repo_root" "$base_dir" "$prefix") || exit 1 - unpack_target "$target" - 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_target src_path - src_target=$(resolve_target "$source" "$repo_root" "$base_dir" "$prefix") || exit 1 - unpack_target "$src_target" - 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_target dst_path dst_branch - dst_target=$(resolve_target "$target_id" "$repo_root" "$base_dir" "$prefix") || continue - unpack_target "$dst_target" - 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 target worktree_path branch - target=$(resolve_target "$identifier" "$repo_root" "$base_dir" "$prefix") || exit 1 - unpack_target "$target" - 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" - 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" - - resolve_repo_context || exit 1 - 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" - 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 - # 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) - [ -z "$branch" ] || [ "$branch" = "HEAD" ] && branch="(detached)" - 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 - # 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) - [ -z "$branch" ] || [ "$branch" = "HEAD" ] && branch="(detached)" - 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 - . "$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 - . "$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) - . "$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) - . "$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 - 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 - 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 - 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