From a62b731f03b079de9a522372028cf0f3a2dc2b8d Mon Sep 17 00:00:00 2001 From: Hans Elizaga Date: Mon, 5 Jan 2026 16:18:27 -0800 Subject: [PATCH] feat: enhance config command with list action and improved scope handling - Add `git gtr config list` action to display all gtr.* configuration with source origins (local/.gtrconfig/global/system) - Add --local and --system flags to complement existing --global flag - Implement cfg_list() function with proper multi-value support and origin tracking - Improve argument validation with warnings for unexpected extra arguments - Add cfg_map_to_file_key() for automatic .gtrconfig key mapping - Update all shell completions (bash/zsh/fish) with new list action and scope flags - Restrict --system flag to read operations only (write requires root) - Change default scope to "auto" for merged config views, resolves to "local" for writes - Add comprehensive inline documentation for config precedence and behavior Co-authored-by: Vladislav Dobromyslov --- bin/gtr | 102 +++++++++++++++---- completions/_git-gtr | 46 +++++++-- completions/git-gtr.fish | 31 +++++- completions/gtr.bash | 36 ++++++- lib/config.sh | 207 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 390 insertions(+), 32 deletions(-) diff --git a/bin/gtr b/bin/gtr index a2024c5..f5ac904 100755 --- a/bin/gtr +++ b/bin/gtr @@ -1131,17 +1131,26 @@ cmd_adapter() { # Config command cmd_config() { - local scope="local" + local scope="auto" local action="" key="" value="" + local extra_args="" - # Parse args flexibly: action, key, value, and --global anywhere + # Parse args flexibly: action, key, value, and --global/--local anywhere while [ $# -gt 0 ]; do case "$1" in --global|global) scope="global" shift ;; - get|set|unset|add) + --local|local) + scope="local" + shift + ;; + --system|system) + scope="system" + shift + ;; + get|set|unset|add|list) action="$1" shift ;; @@ -1153,51 +1162,101 @@ cmd_config() { value="$1" shift else - # Unknown extra token + # Track extra tokens for validation (add space only if not first) + extra_args="${extra_args:+$extra_args }$1" shift fi ;; esac done - # Default action is get - action="${action:-get}" + # 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 [--global]" + 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 [--global]" + log_error "Usage: git gtr config set [--local|--global]" exit 1 fi - cfg_set "$key" "$value" "$scope" - log_info "Config set: $key = $value ($scope)" + # 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 [--global]" + log_error "Usage: git gtr config add [--local|--global]" exit 1 fi - cfg_add "$key" "$value" "$scope" - log_info "Config added: $key = $value ($scope)" + # 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 [--global]" + log_error "Usage: git gtr config unset [--local|--global]" exit 1 fi - cfg_unset "$key" "$scope" - log_info "Config unset: $key ($scope)" + # 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 {get|set|add|unset} [value] [--global]" + 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 @@ -1349,12 +1408,17 @@ CORE COMMANDS (daily workflow): SETUP & MAINTENANCE: - config {get|set|add|unset} [value] [--global] + config [list] [--local|--global|--system] + config get [--local|--global|--system] + config {set|add|unset} [value] [--local|--global] Manage configuration - - get: read a config value + - 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 configs like hooks, copy patterns) - unset: remove a config value + Without scope flag, list/get show merged config from all sources + Use --local/--global to target a specific scope for write operations doctor Health check (verify git, editors, AI tools) diff --git a/completions/_git-gtr b/completions/_git-gtr index d56a35a..ac9dc72 100644 --- a/completions/_git-gtr +++ b/completions/_git-gtr @@ -76,7 +76,8 @@ _git-gtr() { _describe 'branch names' all_options ;; config) - _values 'config action' get set add unset + # Complete action or scope flags + _values 'config action' list get set add unset --local --global --system ;; esac # Complete subsequent arguments @@ -107,13 +108,42 @@ _git-gtr() { esac ;; config) - case "$words[4]" in - get|set|add|unset) - _arguments \ - '--global[Use global git config]' \ - '*:config key:(gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.editor.default gtr.ai.default gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove)' - ;; - esac + # Find action by scanning all config args (handles flexible flag positioning) + # Use offset 3 to start from words[4] (first arg after 'config') + # Zsh uses 0-based offset: ${array[@]:3} = elements starting from 4th position + local config_action="" + local arg + for arg in "${words[@]:3}"; do + case "$arg" in + list|get|set|add|unset) + [[ -z "$config_action" ]] && config_action="$arg" + ;; + esac + done + + if [[ -z "$config_action" ]]; then + # Still need action or scope + _values 'config action' list get set add unset --local --global --system + else + case "$config_action" in + list|get) + # Read operations support all scopes including --system + _arguments \ + '--local[Use local git config]' \ + '--global[Use global git config]' \ + '--system[Use system git config]' \ + '*:config key:(gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.editor.default gtr.ai.default gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove)' + ;; + set|add|unset) + # Write operations only support --local and --global + # (--system may require root or appropriate file permissions) + _arguments \ + '--local[Use local git config]' \ + '--global[Use global git config]' \ + '*:config key:(gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.editor.default gtr.ai.default gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove)' + ;; + esac + fi ;; esac fi diff --git a/completions/git-gtr.fish b/completions/git-gtr.fish index 0d9eb02..d360c2e 100644 --- a/completions/git-gtr.fish +++ b/completions/git-gtr.fish @@ -68,7 +68,36 @@ complete -c git -n '__fish_git_gtr_using_command copy' -s a -l all -d 'Copy to a complete -c git -n '__fish_git_gtr_using_command copy' -l from -d 'Source worktree' -r # Config command -complete -f -c git -n '__fish_git_gtr_using_command config' -a 'get set add unset' +complete -f -c git -n '__fish_git_gtr_using_command config' -a 'list get set add unset' + +# Helper to check if config action is a read operation (list or get) +function __fish_git_gtr_config_is_read + set -l cmd (commandline -opc) + for i in $cmd + if test "$i" = "list" -o "$i" = "get" + return 0 + end + end + return 1 +end + +# Helper to check if config action is a write operation (set, add, unset) +function __fish_git_gtr_config_is_write + set -l cmd (commandline -opc) + for i in $cmd + if test "$i" = "set" -o "$i" = "add" -o "$i" = "unset" + return 0 + end + end + return 1 +end + +# Scope flags for config command +# --local and --global available for all operations +complete -f -c git -n '__fish_git_gtr_using_command config' -l local -d 'Use local git config' +complete -f -c git -n '__fish_git_gtr_using_command config' -l global -d 'Use global git config' +# --system only for read operations (list, get) - write requires root +complete -f -c git -n '__fish_git_gtr_using_command config; and __fish_git_gtr_config_is_read' -l system -d 'Use system git config' complete -f -c git -n '__fish_git_gtr_using_command config' -a " gtr.worktrees.dir\t'Worktrees base directory' gtr.worktrees.prefix\t'Worktree folder prefix' diff --git a/completions/gtr.bash b/completions/gtr.bash index 41c9185..0bec445 100644 --- a/completions/gtr.bash +++ b/completions/gtr.bash @@ -65,10 +65,38 @@ _git_gtr() { fi ;; config) - if [ "$cword" -eq 3 ]; then - COMPREPLY=($(compgen -W "get set add unset" -- "$cur")) - elif [ "$cword" -eq 4 ]; then - COMPREPLY=($(compgen -W "gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.editor.default gtr.ai.default gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove" -- "$cur")) + # Find action by scanning all config args (handles flexible flag positioning) + local config_action="" + local i + for (( i=3; i < cword; i++ )); do + case "${words[i]}" in + list|get|set|add|unset) config_action="${words[i]}" ;; + esac + done + + if [ -z "$config_action" ]; then + # Still need to complete action or scope + COMPREPLY=($(compgen -W "list get set add unset --local --global --system" -- "$cur")) + else + # Have action, complete based on it + case "$config_action" in + list|get) + # Read operations support all scopes including --system + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--local --global --system" -- "$cur")) + else + COMPREPLY=($(compgen -W "gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.editor.default gtr.ai.default gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove" -- "$cur")) + fi + ;; + set|add|unset) + # Write operations only support --local and --global (--system requires root) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--local --global" -- "$cur")) + else + COMPREPLY=($(compgen -W "gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.editor.default gtr.ai.default gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove" -- "$cur")) + fi + ;; + esac fi ;; esac diff --git a/lib/config.sh b/lib/config.sh index f3ae9c0..a144caa 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -77,9 +77,32 @@ cfg_get() { git config $flag --get "$key" 2>/dev/null || true } +# Map a gtr.* config key to its .gtrconfig equivalent +# Usage: cfg_map_to_file_key +# Returns: mapped key for .gtrconfig or empty if no mapping exists +cfg_map_to_file_key() { + local key="$1" + case "$key" in + gtr.copy.include) echo "copy.include" ;; + gtr.copy.exclude) echo "copy.exclude" ;; + gtr.copy.includeDirs) echo "copy.includeDirs" ;; + gtr.copy.excludeDirs) echo "copy.excludeDirs" ;; + gtr.hook.postCreate) echo "hooks.postCreate" ;; + gtr.hook.preRemove) echo "hooks.preRemove" ;; + gtr.hook.postRemove) echo "hooks.postRemove" ;; + gtr.editor.default) echo "defaults.editor" ;; + gtr.ai.default) echo "defaults.ai" ;; + gtr.worktrees.dir) echo "worktrees.dir" ;; + gtr.worktrees.prefix) echo "worktrees.prefix" ;; + gtr.defaultBranch) echo "defaults.branch" ;; + *) echo "" ;; + esac +} + # Get all values for a multi-valued config key # Usage: cfg_get_all key [file_key] [scope] # file_key: optional key name in .gtrconfig (e.g., "copy.include" for gtr.copy.include) +# If empty and key starts with "gtr.", auto-maps to .gtrconfig key # scope: auto (default), local, global, or system # auto merges local + .gtrconfig + global + system and deduplicates cfg_get_all() { @@ -87,6 +110,11 @@ cfg_get_all() { local file_key="${2:-}" local scope="${3:-auto}" + # Auto-map file_key if not provided and key is a gtr.* key + if [ -z "$file_key" ] && [[ "$key" == gtr.* ]]; then + file_key=$(cfg_map_to_file_key "$key") + fi + case "$scope" in local) git config --local --get-all "$key" 2>/dev/null || true @@ -186,6 +214,185 @@ cfg_unset() { git config $flag --unset-all "$key" 2>/dev/null || true } +# List all gtr.* config values +# Usage: cfg_list [scope] +# scope: auto (default), local, global, system +# auto shows merged config from all sources with origin labels +# Returns formatted key = value output, or message if empty +# Note: Shows ALL values for multi-valued keys (copy patterns, hooks, etc.) +cfg_list() { + local scope="${1:-auto}" + local output="" + local config_file + config_file=$(_gtrconfig_path) + + case "$scope" in + local) + output=$(git config --local --get-regexp '^gtr\.' 2>/dev/null || true) + ;; + global) + output=$(git config --global --get-regexp '^gtr\.' 2>/dev/null || true) + ;; + system) + output=$(git config --system --get-regexp '^gtr\.' 2>/dev/null || true) + ;; + auto) + # Merge all sources with origin labels + # Deduplicates by key+value combo, preserving all multi-values from highest priority source + local seen_keys="" + local result="" + local key value line + + # Set up cleanup trap for helper function (protects against early exit/return) + trap 'unset -f _cfg_list_add_entry 2>/dev/null' RETURN + + # Helper function to add entries with origin (inline to avoid Bash 3.2 nameref issues) + # Uses Unit Separator ($'\x1f') as delimiter to avoid conflicts with any values + _cfg_list_add_entry() { + local origin="$1" + local entry_key="$2" + local entry_value="$3" + + # For multi-valued keys: check if key+value combo already seen + # This allows multiple values for the same key from the same source + # Use Unit Separator as delimiter in seen_keys to avoid collision with any value content + local id=$'\x1f'"${entry_key}=${entry_value}"$'\x1f' + # Use [[ ]] for literal string matching (no glob interpretation) + if [[ "$seen_keys" == *"$id"* ]]; then + return 0 + fi + + seen_keys="${seen_keys}${id}" + # Use Unit Separator ($'\x1f') as delimiter - won't appear in normal values + result="${result}${entry_key}"$'\x1f'"${entry_value}"$'\x1f'"${origin}"$'\n' + } + + # Process in priority order: local > .gtrconfig > global > system + local local_entries global_entries system_entries + + # 1. Local git config (highest priority) + local_entries=$(git config --local --get-regexp '^gtr\.' 2>/dev/null || true) + while IFS= read -r line; do + [ -z "$line" ] && continue + key="${line%% *}" + # Handle empty values (no space in line means value is empty) + if [[ "$line" == *" "* ]]; then + value="${line#* }" + else + value="" + fi + _cfg_list_add_entry "local" "$key" "$value" + done <<< "$local_entries" + + # 2. .gtrconfig file (team defaults) + if [ -n "$config_file" ] && [ -f "$config_file" ]; then + while IFS= read -r line; do + [ -z "$line" ] && continue + local fkey fvalue mapped_key + fkey="${line%% *}" + # Handle empty values (no space in line means value is empty) + if [[ "$line" == *" "* ]]; then + fvalue="${line#* }" + else + fvalue="" + fi + # Map .gtrconfig keys to gtr.* format + case "$fkey" in + copy.include) mapped_key="gtr.copy.include" ;; + copy.exclude) mapped_key="gtr.copy.exclude" ;; + copy.includeDirs) mapped_key="gtr.copy.includeDirs" ;; + copy.excludeDirs) mapped_key="gtr.copy.excludeDirs" ;; + hooks.postCreate) mapped_key="gtr.hook.postCreate" ;; + hooks.preRemove) mapped_key="gtr.hook.preRemove" ;; + hooks.postRemove) mapped_key="gtr.hook.postRemove" ;; + defaults.editor) mapped_key="gtr.editor.default" ;; + defaults.ai) mapped_key="gtr.ai.default" ;; + defaults.branch) mapped_key="gtr.defaultBranch" ;; + worktrees.dir) mapped_key="gtr.worktrees.dir" ;; + worktrees.prefix) mapped_key="gtr.worktrees.prefix" ;; + gtr.*) mapped_key="$fkey" ;; + *) continue ;; # Skip unmapped keys + esac + _cfg_list_add_entry ".gtrconfig" "$mapped_key" "$fvalue" + done < <(git config -f "$config_file" --get-regexp '.' 2>/dev/null || true) + fi + + # 3. Global git config + global_entries=$(git config --global --get-regexp '^gtr\.' 2>/dev/null || true) + while IFS= read -r line; do + [ -z "$line" ] && continue + key="${line%% *}" + # Handle empty values (no space in line means value is empty) + if [[ "$line" == *" "* ]]; then + value="${line#* }" + else + value="" + fi + _cfg_list_add_entry "global" "$key" "$value" + done <<< "$global_entries" + + # 4. System git config (lowest priority) + system_entries=$(git config --system --get-regexp '^gtr\.' 2>/dev/null || true) + while IFS= read -r line; do + [ -z "$line" ] && continue + key="${line%% *}" + # Handle empty values (no space in line means value is empty) + if [[ "$line" == *" "* ]]; then + value="${line#* }" + else + value="" + fi + _cfg_list_add_entry "system" "$key" "$value" + done <<< "$system_entries" + + # Clean up helper function and clear trap (trap handles early exit cases) + unset -f _cfg_list_add_entry + trap - RETURN + + output="$result" + ;; + *) + # Unknown scope - warn and fall back to auto + log_warn "Unknown scope '$scope', using 'auto'" + cfg_list "auto" + return $? + ;; + esac + + # Format and display output + if [ -z "$output" ]; then + echo "No gtr configuration found" + return 0 + fi + + # Format output with alignment + # Use printf '%s\n' instead of echo for safety with special characters + printf '%s\n' "$output" | while IFS= read -r line; do + [ -z "$line" ] && continue + + local key value origin rest + # Check if line uses Unit Separator delimiter (auto mode with origin) + if [[ "$line" == *$'\x1f'* ]]; then + # Format: keyvalueorigin + key="${line%%$'\x1f'*}" + rest="${line#*$'\x1f'}" + value="${rest%%$'\x1f'*}" + origin="${rest#*$'\x1f'}" + printf "%-35s = %-25s [%s]\n" "$key" "$value" "$origin" + else + # Format: key value (no origin, for scoped queries) + key="${line%% *}" + # Handle empty values (no space in line means value is empty) + if [[ "$line" == *" "* ]]; then + value="${line#* }" + else + value="" + fi + printf "%-35s = %s\n" "$key" "$value" + fi + done +} + # Get config value with environment variable fallback # Usage: cfg_default key env_name fallback_value [file_key] # file_key: optional key name in .gtrconfig (e.g., "defaults.editor" for gtr.editor.default)