diff --git a/.github/workflows/homebrew.yml b/.github/workflows/homebrew.yml new file mode 100644 index 0000000..9636d59 --- /dev/null +++ b/.github/workflows/homebrew.yml @@ -0,0 +1,25 @@ +name: Update Homebrew Formula + +on: + release: + types: [published] + +jobs: + homebrew: + name: Bump Homebrew formula + runs-on: ubuntu-latest + if: ${{ !github.event.release.prerelease }} + steps: + - uses: mislav/bump-homebrew-formula-action@v3 + with: + formula-name: git-gtr + formula-path: Formula/git-gtr.rb + homebrew-tap: coderabbitai/homebrew-tap + tag-name: ${{ github.event.release.tag_name }} + create-pullrequest: false + commit-message: | + {{formulaName}} {{version}} + + Automated update from https://github.com/coderabbitai/git-worktree-runner + env: + COMMITTER_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} diff --git a/README.md b/README.md index 40fa497..ca6277d 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,14 @@ ## Quick Start -**Install:** +**Homebrew (macOS):** + +```bash +brew tap coderabbitai/tap +brew install git-gtr +``` + +**Script installer (macOS / Linux):** ```bash git clone https://github.com/coderabbitai/git-worktree-runner.git @@ -51,13 +58,7 @@ cd git-worktree-runner ```
-Manual installation options - -**macOS (Apple Silicon with Homebrew):** - -```bash -ln -s "$(pwd)/bin/git-gtr" "$(brew --prefix)/bin/git-gtr" -``` +Other installation options **macOS (Intel) / Linux:** @@ -336,6 +337,9 @@ git gtr config add gtr.hook.postCreate "npm install" # Re-source environment after gtr cd (runs in current shell) git gtr config add gtr.hook.postCd "source ./vars.sh" + +# Disable color output (or use "always" to force it) +git gtr config set gtr.ui.color never ``` ### Team Configuration (.gtrconfig) diff --git a/bin/gtr b/bin/gtr index f88a08c..4ab059e 100755 --- a/bin/gtr +++ b/bin/gtr @@ -4,6 +4,11 @@ set -e +# Debug: show file:line:function on set -e failures +if [ -n "${GTR_DEBUG:-}" ]; then + trap 'printf "ERROR at %s:%s in %s()\n" "${BASH_SOURCE[0]}" "$LINENO" "${FUNCNAME[0]:-main}" >&2' ERR +fi + # Version GTR_VERSION="2.2.0" @@ -24,6 +29,7 @@ resolve_script_dir() { . "$GTR_DIR/lib/ui.sh" . "$GTR_DIR/lib/args.sh" . "$GTR_DIR/lib/config.sh" +_ui_apply_color_config . "$GTR_DIR/lib/platform.sh" . "$GTR_DIR/lib/core.sh" . "$GTR_DIR/lib/copy.sh" @@ -44,6 +50,9 @@ main() { local cmd="${1:-help}" shift 2>/dev/null || true + # Set for per-command help (used by show_command_help in ui.sh) + _GTR_CURRENT_COMMAND="$cmd" + case "$cmd" in new) cmd_create "$@" @@ -94,7 +103,7 @@ main() { echo "git gtr version $GTR_VERSION" ;; help|--help|-h) - cmd_help + cmd_help "$@" ;; *) log_error "Unknown command: $cmd" diff --git a/completions/_git-gtr b/completions/_git-gtr index 5639273..f0eda69 100644 --- a/completions/_git-gtr +++ b/completions/_git-gtr @@ -174,7 +174,7 @@ _git-gtr() { '--local[Use local git config]' \ '--global[Use global git config]' \ '--system[Use system git config]' \ - '*:config key:(gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd gtr.editor.default gtr.editor.workspace gtr.ai.default gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.provider)' + '*:config key:(gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd gtr.editor.default gtr.editor.workspace gtr.ai.default gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.provider gtr.ui.color)' ;; set|add|unset) # Write operations only support --local and --global @@ -182,7 +182,7 @@ _git-gtr() { _arguments \ '--local[Use local git config]' \ '--global[Use global git config]' \ - '*:config key:(gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd gtr.editor.default gtr.editor.workspace gtr.ai.default gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.provider)' + '*:config key:(gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd gtr.editor.default gtr.editor.workspace gtr.ai.default gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.provider gtr.ui.color)' ;; esac fi diff --git a/completions/git-gtr.fish b/completions/git-gtr.fish index f58df20..1654a31 100644 --- a/completions/git-gtr.fish +++ b/completions/git-gtr.fish @@ -139,6 +139,7 @@ complete -f -c git -n '__fish_git_gtr_using_command config' -a " gtr.worktrees.prefix 'Worktree folder prefix' gtr.defaultBranch 'Default branch' gtr.provider 'Hosting provider (github, gitlab)' + gtr.ui.color 'Color output mode (auto, always, never)' " # Helper function to get branch names and special '1' for main repo diff --git a/completions/gtr.bash b/completions/gtr.bash index 84a25ee..d2b715c 100644 --- a/completions/gtr.bash +++ b/completions/gtr.bash @@ -131,7 +131,7 @@ _git_gtr() { if [[ "$cur" == -* ]]; then COMPREPLY=($(compgen -W "--local --global --system" -- "$cur")) else - COMPREPLY=($(compgen -W "gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd gtr.editor.default gtr.editor.workspace gtr.ai.default gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.provider" -- "$cur")) + COMPREPLY=($(compgen -W "gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd gtr.editor.default gtr.editor.workspace gtr.ai.default gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.provider gtr.ui.color" -- "$cur")) fi ;; set|add|unset) @@ -139,7 +139,7 @@ _git_gtr() { if [[ "$cur" == -* ]]; then COMPREPLY=($(compgen -W "--local --global" -- "$cur")) else - COMPREPLY=($(compgen -W "gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd gtr.editor.default gtr.editor.workspace gtr.ai.default gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.provider" -- "$cur")) + COMPREPLY=($(compgen -W "gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd gtr.editor.default gtr.editor.workspace gtr.ai.default gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.provider gtr.ui.color" -- "$cur")) fi ;; esac diff --git a/docs/configuration.md b/docs/configuration.md index b419b69..91abc11 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,6 +17,7 @@ - [File Copying](#file-copying) - [Directory Copying](#directory-copying) - [Hooks](#hooks) +- [UI Settings](#ui-settings) - [Shell Completions](#shell-completions) - [Configuration Examples](#configuration-examples) - [Environment Variables](#environment-variables) @@ -337,6 +338,28 @@ git gtr config add gtr.hook.postCreate "cargo build" --- +## UI Settings + +Control color output behavior. + +| Git Config Key | `.gtrconfig` Key | Description | Values | +| -------------- | ---------------- | ----------------- | ----------------------------------- | +| `gtr.ui.color` | `ui.color` | Color output mode | `auto` (default), `always`, `never` | + +```bash +# Disable color output +git gtr config set gtr.ui.color never + +# Force color output (e.g., when piping to a pager) +git gtr config set gtr.ui.color always +``` + +**Precedence**: `NO_COLOR` env (highest) > `GTR_COLOR` env > `gtr.ui.color` config > auto-detect (TTY). + +The `NO_COLOR` environment variable ([no-color.org](https://no-color.org)) always wins regardless of other settings. + +--- + ## Shell Completions Enable tab completion using the built-in `completion` command. @@ -420,15 +443,17 @@ git gtr config set gtr.ai.default claude --global ## Environment Variables -| Variable | Description | Default | -| --------------------- | ------------------------------------------------------ | -------------------------- | -| `GTR_DIR` | Override script directory location | Auto-detected | -| `GTR_WORKTREES_DIR` | Override base worktrees directory | `gtr.worktrees.dir` config | -| `GTR_EDITOR_CMD` | Custom editor command (e.g., `emacs`) | None | -| `GTR_EDITOR_CMD_NAME` | First word of `GTR_EDITOR_CMD` for availability checks | None | -| `GTR_AI_CMD` | Custom AI tool command (e.g., `copilot`) | None | -| `GTR_AI_CMD_NAME` | First word of `GTR_AI_CMD` for availability checks | None | -| `GTR_PROVIDER` | Override hosting provider (`github` or `gitlab`) | Auto-detected from URL | +| Variable | Description | Default | +| --------------------- | -------------------------------------------------------------------- | -------------------------- | +| `GTR_DIR` | Override script directory location | Auto-detected | +| `GTR_WORKTREES_DIR` | Override base worktrees directory | `gtr.worktrees.dir` config | +| `GTR_EDITOR_CMD` | Custom editor command (e.g., `emacs`) | None | +| `GTR_EDITOR_CMD_NAME` | First word of `GTR_EDITOR_CMD` for availability checks | None | +| `GTR_AI_CMD` | Custom AI tool command (e.g., `copilot`) | None | +| `GTR_AI_CMD_NAME` | First word of `GTR_AI_CMD` for availability checks | None | +| `GTR_COLOR` | Override color output (`always`, `never`, `auto`) | `auto` | +| `GTR_PROVIDER` | Override hosting provider (`github` or `gitlab`) | Auto-detected from URL | +| `NO_COLOR` | Disable color output when set ([no-color.org](https://no-color.org)) | Unset | **Hook environment variables** (available in hook scripts): diff --git a/lib/commands/help.sh b/lib/commands/help.sh index c21589b..7e9c3d1 100644 --- a/lib/commands/help.sh +++ b/lib/commands/help.sh @@ -1,7 +1,407 @@ #!/usr/bin/env bash -# Help command +# ── Per-command help functions ─────────────────────────────────────────────── +# Each _help_() provides detailed help for a single command. +# Dispatched by cmd_help when a command argument is provided. + +_help_new() { + cat <<'EOF' +git gtr new - Create a new worktree + +Usage: git gtr new [options] + +Creates a new git worktree with the given branch name. The worktree folder +is named after the branch (slashes and special chars become hyphens, e.g., +feature/user-auth becomes folder "feature-user-auth"). + +Options: + --from Create from a specific ref (default: default branch) + --from-current Create from the current branch (for parallel variants) + --track Branch tracking mode: auto|remote|local|none (default: auto) + auto: tries remote first, then local, then creates new + --no-copy Skip file copying (gtr.copy.include patterns) + --no-fetch Skip git fetch before creating + --no-hooks Skip post-create hooks + --force Allow same branch in multiple worktrees + (requires --name or --folder to distinguish them) + --name Custom folder name suffix (appended after branch name) + --folder Custom folder name (replaces default entirely) + --yes Non-interactive mode (skip prompts) + -e, --editor Open in editor after creation + -a, --ai Start AI tool after creation + +Examples: + git gtr new feature/user-auth # Folder: feature-user-auth + git gtr new hotfix --from v2.0.0 # Branch from tag + git gtr new my-feature --from-current # Branch from current HEAD + git gtr new feature -e -a # Create, open editor + AI + git gtr new feature --force --name backend # Second worktree for same branch + git gtr new feature --folder my-dir # Custom folder name +EOF +} + +_help_editor() { + cat <<'EOF' +git gtr editor - Open worktree in editor + +Usage: git gtr editor [--editor ] + +Opens the specified worktree in your configured editor. Uses the default +editor from gtr.editor.default config, or the --editor flag to override. + +Options: + --editor Override the default editor for this invocation + +Special: + Use '1' to open the main repo root: git gtr editor 1 + +Available editors: + atom, cursor, emacs, idea, nano, nvim, pycharm, sublime, vim, vscode, + webstorm, zed, none (or any command in your PATH) + +Examples: + git gtr editor my-feature # Uses default editor + git gtr editor my-feature --editor vscode # Override with vscode + git gtr editor 1 # Open main repo +EOF +} + +_help_ai() { + cat <<'EOF' +git gtr ai - Start AI coding tool in worktree + +Usage: git gtr ai [--ai ] [-- args...] + +Starts your configured AI coding tool in the specified worktree. Uses the +default AI tool from gtr.ai.default config, or the --ai flag to override. +Arguments after -- are passed through to the AI tool. + +Options: + --ai Override the default AI tool for this invocation + +Special: + Use '1' to start AI in the main repo root: git gtr ai 1 + +Available AI tools: + aider, auggie, claude, codex, continue, copilot, cursor, gemini, + opencode, none (or any command in your PATH) + +Examples: + git gtr ai my-feature # Uses default AI tool + git gtr ai my-feature --ai aider # Override with aider + git gtr ai my-feature -- --verbose # Pass args to AI tool + git gtr ai 1 # AI in main repo +EOF +} + +_help_go() { + cat <<'EOF' +git gtr go - Print worktree path + +Usage: git gtr go + +Prints the absolute path to the specified worktree. Useful for navigation +with cd or for scripting. For direct cd support, use shell integration: + eval "$(git gtr init bash)" # then: gtr cd + +Special: + Use '1' for the main repo root: git gtr go 1 + +Examples: + git gtr go my-feature # Print path + cd "$(git gtr go my-feature)" # Navigate to worktree + gtr cd my-feature # With shell integration +EOF +} + +_help_run() { + cat <<'EOF' +git gtr run - Execute command in worktree + +Usage: git gtr run + +Runs the specified command in the worktree directory. The command and all +its arguments are executed with the worktree as the working directory. + +Special: + Use '1' to run in the main repo root: git gtr run 1 + +Examples: + git gtr run my-feature npm test # Run tests + git gtr run my-feature git status # Check git status + git gtr run my-feature npm run dev # Start dev server + git gtr run 1 npm run build # Build in main repo +EOF +} + +_help_list() { + cat <<'EOF' +git gtr list - List all worktrees + +Usage: git gtr list [--porcelain] + git gtr ls [--porcelain] + +Shows all git worktrees for the current repository in a formatted table. + +Options: + --porcelain Machine-readable output (one worktree per line) + +Examples: + git gtr list # Human-readable table + git gtr ls --porcelain # Machine-readable output +EOF +} + +_help_rm() { + cat <<'EOF' +git gtr rm - Remove worktree(s) + +Usage: git gtr rm [...] [options] + +Removes one or more worktrees by branch name. Runs pre-remove and +post-remove hooks unless --force is used to override hook failures. + +Options: + --delete-branch Also delete the git branch after removing the worktree + --force Force removal even if worktree has uncommitted changes + --yes Skip confirmation prompts + +Examples: + git gtr rm my-feature # Remove worktree + git gtr rm my-feature --delete-branch # Remove worktree + branch + git gtr rm feat-1 feat-2 feat-3 # Remove multiple + git gtr rm my-feature --force --yes # Force, no prompts +EOF +} + +_help_mv() { + cat <<'EOF' +git gtr mv - Rename worktree and branch + +Usage: git gtr mv [options] + git gtr rename [options] + +Renames both the worktree folder and its local branch. The remote branch +is not renamed (push the new branch and delete the old one manually). + +Options: + --force Force move even if worktree is locked + --yes Skip confirmation prompts + +Examples: + git gtr mv feature-wip feature-auth # Rename worktree + branch + git gtr rename old-name new-name # Alias for mv + git gtr mv locked-wt new-name --force # Force move if locked +EOF +} + +_help_copy() { + cat <<'EOF' +git gtr copy - Copy files between worktrees + +Usage: git gtr copy ... [options] [-- ...] + +Copies files from the main repo (or another worktree) to the specified +target worktree(s). Uses gtr.copy.include/exclude config patterns by +default. Patterns after -- override configured patterns. + +Options: + -n, --dry-run Preview what would be copied without copying + -a, --all Copy to all worktrees + --from Copy from a different worktree (default: main repo) + +Patterns: + Glob patterns like ".env*", "*.json", "**/.env*" are supported. + Configure defaults: git gtr config add gtr.copy.include ".env*" + +Examples: + git gtr copy my-feature # Uses configured patterns + git gtr copy my-feature -- ".env*" # Explicit pattern + git gtr copy my-feature -- ".env*" "*.json" # Multiple patterns + git gtr copy -a -- ".env*" # Update all worktrees + git gtr copy my-feature -n -- "**/.env*" # Dry-run preview + git gtr copy feat --from other-feat # Copy between worktrees +EOF +} + +_help_config() { + cat <<'EOF' +git gtr config - Manage configuration + +Usage: git gtr config [list] [--local|--global|--system] + git gtr config get [--local|--global|--system] + git gtr config set [--local|--global] + git gtr config add [--local|--global] + git gtr config unset [--local|--global] + +Manages gtr configuration stored in git config. Supports local (repo), +global (user), and system scopes. Team defaults can also be set in +.gtrconfig (gitconfig syntax, committed to repo). + +Actions: + list Show all gtr.* config values (default when no args) + get Read a config value (merged from all sources by default) + set Set a single value (replaces existing) + add Add a value (for multi-valued keys like hooks, copy patterns) + unset Remove a config value + +Scope flags: + --local Target local git config (.git/config) + --global Target global git config (~/.gitconfig) + --system Read-only for list/get (write requires root) + +Examples: + git gtr config # List all config + git gtr config list --local # List local config only + git gtr config set gtr.editor.default cursor # Set default editor + git gtr config add gtr.copy.include ".env*" # Add copy pattern + git gtr config get gtr.ai.default # Get AI tool setting + git gtr config unset gtr.worktrees.prefix # Remove prefix setting +EOF +} + +_help_doctor() { + cat <<'EOF' +git gtr doctor - Health check + +Usage: git gtr doctor + +Verifies your setup: git installation, repo state, worktrees directory, +configured editor, configured AI tool, OS detection, and hosting provider. +Shows actionable guidance for any issues found. + +Examples: + git gtr doctor # Run health check +EOF +} + +_help_adapter() { + cat <<'EOF' +git gtr adapter - List available adapters + +Usage: git gtr adapter + +Lists all built-in editor and AI tool adapters, along with their availability +on the current system. Any command in your PATH can also be used as an +editor or AI tool without a built-in adapter. + +Examples: + git gtr adapter # Show all adapters +EOF +} + +_help_clean() { + cat <<'EOF' +git gtr clean - Remove stale worktrees + +Usage: git gtr clean [options] + +Removes empty worktree directories and optionally removes worktrees whose +PRs/MRs have been merged. Auto-detects GitHub (gh) or GitLab (glab) from +the remote URL. + +Options: + --merged Also remove worktrees with merged PRs/MRs + --yes, -y Skip confirmation prompts + --dry-run, -n Show what would be removed without removing + +Examples: + git gtr clean # Clean empty directories + git gtr clean --merged # Also clean merged PRs + git gtr clean --merged --dry-run # Preview merged cleanup + git gtr clean --merged --yes # Auto-confirm everything +EOF +} + +_help_completion() { + cat <<'EOF' +git gtr completion - Generate shell completions + +Usage: git gtr completion + +Generates shell completion script for the specified shell. Add to your +shell configuration for tab completion of git gtr commands and options. + +Supported shells: bash, zsh, fish + +Setup: + # Bash (add to ~/.bashrc) + eval "$(git gtr completion bash)" + + # Zsh (add to ~/.zshrc BEFORE compinit) + eval "$(git gtr completion zsh)" + + # Fish (symlink to completions dir) + ln -s /path/to/completions/git-gtr.fish ~/.config/fish/completions/ +EOF +} + +_help_init() { + cat <<'EOF' +git gtr init - Generate shell integration + +Usage: git gtr init + +Generates shell functions for enhanced features like 'gtr cd ' +which changes directory to a worktree. Add to your shell configuration. + +Supported shells: bash, zsh, fish + +Setup: + # Bash (add to ~/.bashrc) + eval "$(git gtr init bash)" + + # Zsh (add to ~/.zshrc) + eval "$(git gtr init zsh)" + + # Fish (add to ~/.config/fish/config.fish) + git gtr init fish | source + +After setup: + gtr cd my-feature # cd to worktree + gtr cd 1 # cd to main repo +EOF +} + +_help_version() { + cat <<'EOF' +git gtr version - Show version + +Usage: git gtr version +EOF +} + +# ── Main help command ──────────────────────────────────────────────────────── + cmd_help() { + local command="${1:-}" + + # No argument: show full help page + if [ -z "$command" ]; then + _help_full + return 0 + fi + + # Map aliases to canonical names + case "$command" in + ls) command="list" ;; + rename) command="mv" ;; + adapters) command="adapter" ;; + esac + + # Dispatch to per-command help function + local help_func="_help_${command//-/_}" + if type "$help_func" >/dev/null 2>&1; then + "$help_func" + else + log_error "No help available for: $command" + echo "Use 'git gtr help' for available commands" >&2 + return 1 + fi +} + +# Full help page (shown when no command is specified) +_help_full() { cat <<'EOF' git gtr - Git worktree runner @@ -25,6 +425,7 @@ KEY CONCEPTS: • Main repo is accessible via special ID '1' (e.g., git gtr go 1, git gtr editor 1) • Commands accept branch names to identify worktrees Example: git gtr editor my-feature, git gtr go feature/user-auth + • Run 'git gtr help ' for detailed help on any command ──────────────────────────────────────────────────────────────────────────────── @@ -212,9 +613,10 @@ CONFIGURATION OPTIONS: gtr.hook.preRemove Pre-remove hooks (multi-valued, abort on failure) gtr.hook.postRemove Post-remove hooks (multi-valued) gtr.hook.postCd Post-cd hooks (multi-valued, shell integration only) + gtr.ui.color Color output mode (auto, always, never; default: auto) ──────────────────────────────────────────────────────────────────────────────── MORE INFO: https://github.com/coderabbitai/git-worktree-runner EOF -} \ No newline at end of file +} diff --git a/lib/config.sh b/lib/config.sh index 369304d..f1935f9 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -95,6 +95,7 @@ _CFG_KEY_MAP=( "gtr.worktrees.prefix|worktrees.prefix" "gtr.defaultBranch|defaults.branch" "gtr.provider|defaults.provider" + "gtr.ui.color|ui.color" ) # Map a gtr.* config key to its .gtrconfig equivalent diff --git a/lib/ui.sh b/lib/ui.sh index b3278df..f89e17d 100644 --- a/lib/ui.sh +++ b/lib/ui.sh @@ -1,29 +1,100 @@ #!/usr/bin/env bash # UI utilities for logging and prompting +# ── Color support ──────────────────────────────────────────────────────────── +# Color variables — empty when disabled, ANSI codes when enabled. +# Pre-computed once at source time; zero per-call overhead. +_UI_GREEN="" _UI_YELLOW="" _UI_RED="" _UI_CYAN="" +_UI_BOLD="" _UI_RESET="" +_UI_BOLD_STDOUT="" _UI_RESET_STDOUT="" + +# Check if color output should be enabled for a file descriptor +# Respects: NO_COLOR (no-color.org), GTR_COLOR env, TTY detection +# Usage: _ui_should_color +_ui_should_color() { + [ -n "${NO_COLOR:-}" ] && return 1 + case "${GTR_COLOR:-auto}" in + always) return 0 ;; + never) return 1 ;; + esac + [ -t "$1" ] +} + +_ui_enable_color() { + _UI_GREEN=$(printf '\033[0;32m') + _UI_YELLOW=$(printf '\033[0;33m') + _UI_RED=$(printf '\033[0;31m') + _UI_CYAN=$(printf '\033[0;36m') + _UI_BOLD=$(printf '\033[1m') + _UI_RESET=$(printf '\033[0m') + _UI_BOLD_STDOUT="$_UI_BOLD" + _UI_RESET_STDOUT="$_UI_RESET" +} + +_ui_disable_color() { + _UI_GREEN="" _UI_YELLOW="" _UI_RED="" _UI_CYAN="" + _UI_BOLD="" _UI_RESET="" + _UI_BOLD_STDOUT="" _UI_RESET_STDOUT="" +} + +# Phase 1: auto-detect color at source time (before config.sh is available) +if _ui_should_color 2; then + _UI_GREEN=$(printf '\033[0;32m') + _UI_YELLOW=$(printf '\033[0;33m') + _UI_RED=$(printf '\033[0;31m') + _UI_CYAN=$(printf '\033[0;36m') + _UI_BOLD=$(printf '\033[1m') + _UI_RESET=$(printf '\033[0m') +fi +if _ui_should_color 1; then + _UI_BOLD_STDOUT=$(printf '\033[1m') + _UI_RESET_STDOUT=$(printf '\033[0m') +fi + +# Phase 2: re-evaluate after config.sh is available +# Called from bin/gtr after config.sh is sourced +_ui_apply_color_config() { + # NO_COLOR always wins, regardless of config + if [ -n "${NO_COLOR:-}" ]; then + _ui_disable_color + return 0 + fi + local color_mode + color_mode=$(cfg_get "gtr.ui.color") || true + [ -z "$color_mode" ] && return 0 + case "$color_mode" in + always) _ui_enable_color ;; + never) _ui_disable_color ;; + esac +} + +# ── Logging functions ──────────────────────────────────────────────────────── + log_info() { - printf "[OK] %s\n" "$*" >&2 + printf "%s[OK]%s %s\n" "$_UI_GREEN" "$_UI_RESET" "$*" >&2 } log_warn() { - printf "[!] %s\n" "$*" >&2 + printf "%s[!]%s %s\n" "$_UI_YELLOW" "$_UI_RESET" "$*" >&2 } log_error() { - printf "[x] %s\n" "$*" >&2 + printf "%s[x]%s %s\n" "$_UI_RED" "$_UI_RESET" "$*" >&2 } log_step() { - printf "==> %s\n" "$*" >&2 + printf "%s==>%s %s\n" "${_UI_BOLD}${_UI_CYAN}" "$_UI_RESET" "$*" >&2 } log_question() { - printf "[?] %s" "$*" + printf "%s[?]%s %s" "$_UI_BOLD_STDOUT" "$_UI_RESET_STDOUT" "$*" } -# Show help and exit (for --help flag in subcommands) +# ── Help and prompts ───────────────────────────────────────────────────────── + +# Show help for the current command and exit (called by parse_args on --help) show_command_help() { - cmd_help + cmd_help "${_GTR_CURRENT_COMMAND:-}" exit 0 } diff --git a/tests/cmd_config.bats b/tests/cmd_config.bats new file mode 100644 index 0000000..de38784 --- /dev/null +++ b/tests/cmd_config.bats @@ -0,0 +1,112 @@ +#!/usr/bin/env bats +# Tests for cmd_config in lib/commands/config.sh + +load test_helper + +setup() { + setup_integration_repo + source_gtr_commands +} + +teardown() { + teardown_integration_repo +} + +# ── Set and get ────────────────────────────────────────────────────────────── + +@test "cmd_config set creates config value" { + cmd_config set gtr.editor.default vim 2>/dev/null + local value + value=$(git config --local --get gtr.editor.default) + [ "$value" = "vim" ] +} + +@test "cmd_config get reads config value" { + git config --local gtr.editor.default "cursor" + run cmd_config get gtr.editor.default + [ "$status" -eq 0 ] + [[ "$output" == *"cursor"* ]] +} + +@test "cmd_config set replaces existing value" { + cmd_config set gtr.editor.default vim 2>/dev/null + cmd_config set gtr.editor.default cursor 2>/dev/null + local value + value=$(git config --local --get gtr.editor.default) + [ "$value" = "cursor" ] +} + +# ── Add and unset ──────────────────────────────────────────────────────────── + +@test "cmd_config add appends to multi-valued key" { + cmd_config add gtr.copy.include ".env*" 2>/dev/null + cmd_config add gtr.copy.include "*.json" 2>/dev/null + local count + count=$(git config --local --get-all gtr.copy.include | wc -l) + [ "$count" -eq 2 ] +} + +@test "cmd_config unset removes config value" { + cmd_config set gtr.editor.default vim 2>/dev/null + cmd_config unset gtr.editor.default 2>/dev/null + run git config --local --get gtr.editor.default + [ "$status" -ne 0 ] +} + +# ── List ───────────────────────────────────────────────────────────────────── + +@test "cmd_config list shows config values" { + cmd_config set gtr.editor.default cursor 2>/dev/null + run cmd_config list + [ "$status" -eq 0 ] + [[ "$output" == *"gtr.editor.default"* ]] + [[ "$output" == *"cursor"* ]] +} + +@test "cmd_config with no args defaults to list" { + cmd_config set gtr.editor.default vim 2>/dev/null + run cmd_config + [ "$status" -eq 0 ] + [[ "$output" == *"gtr.editor.default"* ]] +} + +@test "cmd_config list shows message when empty" { + run cmd_config list + [ "$status" -eq 0 ] + [[ "$output" == *"No gtr configuration found"* ]] +} + +# ── Scope flags ────────────────────────────────────────────────────────────── + +@test "cmd_config set --global writes to global config" { + cmd_config set gtr.editor.default zed --global 2>/dev/null + local value + value=$(git config --global --get gtr.editor.default 2>/dev/null || true) + [ "$value" = "zed" ] + # Clean up global + git config --global --unset gtr.editor.default 2>/dev/null || true +} + +@test "cmd_config list --local shows only local config" { + cmd_config set gtr.editor.default vim 2>/dev/null + run cmd_config list --local + [ "$status" -eq 0 ] + [[ "$output" == *"gtr.editor.default"* ]] +} + +# ── Validation ─────────────────────────────────────────────────────────────── + +@test "cmd_config get without key fails" { + run cmd_config get + [ "$status" -eq 1 ] +} + +@test "cmd_config set without value fails" { + run cmd_config set gtr.editor.default + [ "$status" -eq 1 ] +} + +@test "cmd_config rejects --system for write operations" { + run cmd_config set gtr.editor.default vim --system + [ "$status" -eq 1 ] +} diff --git a/tests/cmd_copy.bats b/tests/cmd_copy.bats new file mode 100644 index 0000000..dcdf813 --- /dev/null +++ b/tests/cmd_copy.bats @@ -0,0 +1,62 @@ +#!/usr/bin/env bats +# Tests for cmd_copy in lib/commands/copy.sh + +load test_helper + +setup() { + setup_integration_repo + source_gtr_commands + create_test_worktree "copy-target" + # Create a source file in main repo + echo "secret=value" > "$TEST_REPO/.env" +} + +teardown() { + teardown_integration_repo +} + +# ── Basic copy ─────────────────────────────────────────────────────────────── + +@test "cmd_copy copies files matching explicit pattern" { + run cmd_copy copy-target -- ".env" + [ "$status" -eq 0 ] + [ -f "$TEST_WORKTREES_DIR/copy-target/.env" ] +} + +@test "cmd_copy dry-run does not copy files" { + run cmd_copy copy-target --dry-run -- ".env" + [ "$status" -eq 0 ] + [ ! -f "$TEST_WORKTREES_DIR/copy-target/.env" ] +} + +@test "cmd_copy copies multiple patterns" { + echo "data" > "$TEST_REPO/config.json" + run cmd_copy copy-target -- ".env" "config.json" + [ "$status" -eq 0 ] + [ -f "$TEST_WORKTREES_DIR/copy-target/.env" ] + [ -f "$TEST_WORKTREES_DIR/copy-target/config.json" ] +} + +# ── --all flag ─────────────────────────────────────────────────────────────── + +@test "cmd_copy --all copies to all worktrees" { + create_test_worktree "copy-target-2" + run cmd_copy --all -- ".env" + [ "$status" -eq 0 ] + [ -f "$TEST_WORKTREES_DIR/copy-target/.env" ] + [ -f "$TEST_WORKTREES_DIR/copy-target-2/.env" ] +} + +# ── Error cases ────────────────────────────────────────────────────────────── + +@test "cmd_copy fails with no arguments" { + run cmd_copy + [ "$status" -eq 1 ] +} + +@test "cmd_copy for unknown branch warns but succeeds" { + # cmd_copy skips unknown targets with || continue, then warns + run cmd_copy nonexistent -- ".env" + [ "$status" -eq 0 ] + [[ "$output" == *"No files copied"* ]] +} diff --git a/tests/cmd_help.bats b/tests/cmd_help.bats new file mode 100644 index 0000000..73a6fb8 --- /dev/null +++ b/tests/cmd_help.bats @@ -0,0 +1,118 @@ +#!/usr/bin/env bats +# Tests for cmd_help and per-command help in lib/commands/help.sh + +load test_helper + +setup() { + setup_integration_repo + source_gtr_commands +} + +teardown() { + teardown_integration_repo +} + +# ── Full help ──────────────────────────────────────────────────────────────── + +@test "cmd_help with no args shows full help" { + run cmd_help + [ "$status" -eq 0 ] + [[ "$output" == *"git gtr - Git worktree runner"* ]] + [[ "$output" == *"QUICK START"* ]] + [[ "$output" == *"CONFIGURATION OPTIONS"* ]] +} + +@test "cmd_help full page includes gtr.ui.color" { + run cmd_help + [ "$status" -eq 0 ] + [[ "$output" == *"gtr.ui.color"* ]] +} + +# ── Per-command help ───────────────────────────────────────────────────────── + +@test "cmd_help new shows new command help" { + run cmd_help new + [ "$status" -eq 0 ] + [[ "$output" == *"git gtr new"* ]] + [[ "$output" == *"--from"* ]] + [[ "$output" == *"--track"* ]] + # Should NOT contain full help sections + [[ "$output" != *"QUICK START"* ]] +} + +@test "cmd_help editor shows editor help" { + run cmd_help editor + [ "$status" -eq 0 ] + [[ "$output" == *"git gtr editor"* ]] + [[ "$output" == *"--editor"* ]] +} + +@test "cmd_help ai shows ai help" { + run cmd_help ai + [ "$status" -eq 0 ] + [[ "$output" == *"git gtr ai"* ]] + [[ "$output" == *"--ai"* ]] +} + +@test "cmd_help rm shows rm help" { + run cmd_help rm + [ "$status" -eq 0 ] + [[ "$output" == *"git gtr rm"* ]] + [[ "$output" == *"--delete-branch"* ]] +} + +@test "cmd_help go shows go help" { + run cmd_help go + [ "$status" -eq 0 ] + [[ "$output" == *"git gtr go"* ]] +} + +@test "cmd_help config shows config help" { + run cmd_help config + [ "$status" -eq 0 ] + [[ "$output" == *"git gtr config"* ]] + [[ "$output" == *"list"* ]] + [[ "$output" == *"get"* ]] + [[ "$output" == *"set"* ]] +} + +@test "cmd_help clean shows clean help" { + run cmd_help clean + [ "$status" -eq 0 ] + [[ "$output" == *"git gtr clean"* ]] + [[ "$output" == *"--merged"* ]] +} + +@test "cmd_help copy shows copy help" { + run cmd_help copy + [ "$status" -eq 0 ] + [[ "$output" == *"git gtr copy"* ]] + [[ "$output" == *"--dry-run"* ]] +} + +# ── Alias mapping ──────────────────────────────────────────────────────────── + +@test "cmd_help ls maps to list help" { + run cmd_help ls + [ "$status" -eq 0 ] + [[ "$output" == *"git gtr list"* ]] +} + +@test "cmd_help rename maps to mv help" { + run cmd_help rename + [ "$status" -eq 0 ] + [[ "$output" == *"git gtr mv"* ]] +} + +@test "cmd_help adapters maps to adapter help" { + run cmd_help adapters + [ "$status" -eq 0 ] + [[ "$output" == *"git gtr adapter"* ]] +} + +# ── Error cases ────────────────────────────────────────────────────────────── + +@test "cmd_help unknown command returns error" { + run cmd_help nonexistent + [ "$status" -eq 1 ] +} diff --git a/tests/cmd_rename.bats b/tests/cmd_rename.bats new file mode 100644 index 0000000..354285e --- /dev/null +++ b/tests/cmd_rename.bats @@ -0,0 +1,70 @@ +#!/usr/bin/env bats +# Tests for cmd_rename in lib/commands/rename.sh + +load test_helper + +setup() { + setup_integration_repo + source_gtr_commands +} + +teardown() { + teardown_integration_repo +} + +# ── Basic rename ───────────────────────────────────────────────────────────── + +@test "cmd_rename renames worktree and branch" { + create_test_worktree "old-name" + cmd_rename old-name new-name --yes 2>/dev/null + [ -d "$TEST_WORKTREES_DIR/new-name" ] + [ ! -d "$TEST_WORKTREES_DIR/old-name" ] +} + +@test "cmd_rename updates branch name" { + create_test_worktree "rename-branch" + cmd_rename rename-branch renamed-branch --yes 2>/dev/null + # New branch should exist + run git -C "$TEST_REPO" show-ref --verify "refs/heads/renamed-branch" + [ "$status" -eq 0 ] + # Old branch should not exist + run git -C "$TEST_REPO" show-ref --verify "refs/heads/rename-branch" + [ "$status" -ne 0 ] +} + +# ── Error cases ────────────────────────────────────────────────────────────── + +@test "cmd_rename fails with insufficient args" { + run cmd_rename + [ "$status" -eq 1 ] +} + +@test "cmd_rename fails with only one arg" { + run cmd_rename old-name + [ "$status" -eq 1 ] +} + +@test "cmd_rename cannot rename main repo" { + run cmd_rename 1 something --yes + [ "$status" -eq 1 ] +} + +@test "cmd_rename fails if target branch already exists" { + create_test_worktree "src-branch" + create_test_worktree "dst-branch" + run cmd_rename src-branch dst-branch --yes + [ "$status" -eq 1 ] +} + +@test "cmd_rename fails if target folder already exists" { + create_test_worktree "src-wt" + mkdir -p "$TEST_WORKTREES_DIR/target-wt" + touch "$TEST_WORKTREES_DIR/target-wt/placeholder" + run cmd_rename src-wt target-wt --yes + [ "$status" -eq 1 ] +} + +@test "cmd_rename fails for unknown worktree" { + run cmd_rename nonexistent new-name --yes + [ "$status" -eq 1 ] +} diff --git a/tests/cmd_run.bats b/tests/cmd_run.bats new file mode 100644 index 0000000..b41f051 --- /dev/null +++ b/tests/cmd_run.bats @@ -0,0 +1,63 @@ +#!/usr/bin/env bats +# Tests for cmd_run in lib/commands/run.sh + +load test_helper + +setup() { + setup_integration_repo + source_gtr_commands + create_test_worktree "run-test" +} + +teardown() { + teardown_integration_repo +} + +# ── Basic execution ────────────────────────────────────────────────────────── + +@test "cmd_run executes command in worktree" { + run cmd_run run-test pwd + [ "$status" -eq 0 ] + [[ "$output" == *"$TEST_WORKTREES_DIR/run-test"* ]] +} + +@test "cmd_run passes arguments to command" { + run cmd_run run-test echo hello world + [ "$status" -eq 0 ] + [[ "$output" == *"hello world"* ]] +} + +@test "cmd_run ID 1 runs in main repo" { + run cmd_run 1 pwd + [ "$status" -eq 0 ] + [[ "$output" == *"$TEST_REPO"* ]] +} + +@test "cmd_run propagates command exit code" { + run cmd_run run-test false + [ "$status" -ne 0 ] +} + +# ── Error cases ────────────────────────────────────────────────────────────── + +@test "cmd_run fails with no arguments" { + run cmd_run + [ "$status" -eq 1 ] +} + +@test "cmd_run fails with only branch (no command)" { + run cmd_run run-test + [ "$status" -eq 1 ] +} + +@test "cmd_run fails for unknown branch" { + run cmd_run nonexistent echo hi + [ "$status" -eq 1 ] +} + +# ── Flag-like args in command ──────────────────────────────────────────────── + +@test "cmd_run passes flags to inner command" { + run cmd_run run-test git status --short + [ "$status" -eq 0 ] +} diff --git a/tests/ui_color.bats b/tests/ui_color.bats new file mode 100644 index 0000000..648a82d --- /dev/null +++ b/tests/ui_color.bats @@ -0,0 +1,124 @@ +#!/usr/bin/env bats +# Tests for color support in lib/ui.sh + +load test_helper + +# Source ui.sh directly (not via test_helper which stubs log functions) +_source_ui() { + . "$PROJECT_ROOT/lib/ui.sh" +} + +# ── _ui_should_color ───────────────────────────────────────────────────────── + +@test "_ui_should_color returns 1 when NO_COLOR is set" { + _source_ui + NO_COLOR=1 run _ui_should_color 2 + [ "$status" -ne 0 ] +} + +@test "_ui_should_color returns 1 when GTR_COLOR=never" { + _source_ui + unset NO_COLOR + GTR_COLOR=never run _ui_should_color 2 + [ "$status" -ne 0 ] +} + +@test "_ui_should_color returns 0 with GTR_COLOR=always" { + _source_ui + unset NO_COLOR + GTR_COLOR=always run _ui_should_color 2 + [ "$status" -eq 0 ] +} + +@test "_ui_should_color returns 1 with GTR_COLOR=never" { + _source_ui + unset NO_COLOR + GTR_COLOR=never run _ui_should_color 2 + [ "$status" -ne 0 ] +} + +# ── Color variable state ───────────────────────────────────────────────────── + +@test "color variables are empty when NO_COLOR is set" { + NO_COLOR=1 _source_ui + [ -z "$_UI_GREEN" ] + [ -z "$_UI_RED" ] + [ -z "$_UI_YELLOW" ] + [ -z "$_UI_CYAN" ] + [ -z "$_UI_BOLD" ] + [ -z "$_UI_RESET" ] +} + +@test "color variables are set with GTR_COLOR=always" { + unset NO_COLOR + GTR_COLOR=always _source_ui + [ -n "$_UI_GREEN" ] + [ -n "$_UI_RED" ] + [ -n "$_UI_RESET" ] +} + +@test "_ui_disable_color clears all variables" { + unset NO_COLOR + GTR_COLOR=always _source_ui + _ui_disable_color + [ -z "$_UI_GREEN" ] + [ -z "$_UI_RED" ] + [ -z "$_UI_YELLOW" ] + [ -z "$_UI_RESET" ] + [ -z "$_UI_BOLD_STDOUT" ] +} + +@test "_ui_enable_color sets all variables" { + NO_COLOR=1 _source_ui + [ -z "$_UI_GREEN" ] + _ui_enable_color + [ -n "$_UI_GREEN" ] + [ -n "$_UI_RED" ] + [ -n "$_UI_RESET" ] + [ -n "$_UI_BOLD_STDOUT" ] +} + +# ── Log output format ──────────────────────────────────────────────────────── + +@test "log_info output contains no ANSI when NO_COLOR is set" { + NO_COLOR=1 _source_ui + local output + output=$(log_info "test message" 2>&1) + [[ "$output" != *$'\033'* ]] + [[ "$output" == *"[OK]"* ]] + [[ "$output" == *"test message"* ]] +} + +@test "log_error output contains no ANSI when NO_COLOR is set" { + NO_COLOR=1 _source_ui + local output + output=$(log_error "bad thing" 2>&1) + [[ "$output" != *$'\033'* ]] + [[ "$output" == *"[x]"* ]] + [[ "$output" == *"bad thing"* ]] +} + +@test "log_warn output contains no ANSI when NO_COLOR is set" { + NO_COLOR=1 _source_ui + local output + output=$(log_warn "caution" 2>&1) + [[ "$output" != *$'\033'* ]] + [[ "$output" == *"[!]"* ]] +} + +@test "log_step output contains no ANSI when NO_COLOR is set" { + NO_COLOR=1 _source_ui + local output + output=$(log_step "doing thing" 2>&1) + [[ "$output" != *$'\033'* ]] + [[ "$output" == *"==>"* ]] +} + +@test "log_info output contains ANSI when GTR_COLOR=always" { + unset NO_COLOR + GTR_COLOR=always _source_ui + local output + output=$(log_info "test message" 2>&1) + [[ "$output" == *$'\033['* ]] + [[ "$output" == *"[OK]"* ]] +}