diff --git a/CHANGELOG.md b/CHANGELOG.md index 1df7848..7fc2d02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Fix N+1 queries in `blame`, `stale`, `stats`, and `log` commands - Configuration via git config: `pkgs.ecosystems`, `pkgs.ignoredDirs`, `pkgs.ignoredFiles` - `git pkgs info --ecosystems` to show available ecosystems and their status +- `git pkgs completions` command for bash/zsh tab completion - `-q, --quiet` flag to suppress informational messages - `git pkgs diff` now supports `commit..commit` range syntax - `--git-dir` and `--work-tree` global options (also respects `GIT_WORK_TREE` env var) diff --git a/README.md b/README.md index e70421c..9b6f8ee 100644 --- a/README.md +++ b/README.md @@ -371,6 +371,21 @@ diff --git a/Gemfile.lock b/Gemfile.lock Use `git diff --no-textconv` to see the raw lockfile diff. To remove: `git pkgs diff-driver --uninstall` +### Shell completions + +Enable tab completion for commands: + +```bash +# Bash: add to ~/.bashrc +eval "$(git pkgs completions bash)" + +# Zsh: add to ~/.zshrc +eval "$(git pkgs completions zsh)" + +# Or auto-install to standard completion directories +git pkgs completions install +``` + ## Configuration git-pkgs respects [standard git configuration](https://git-scm.com/docs/git-config). diff --git a/lib/git/pkgs.rb b/lib/git/pkgs.rb index b6127d5..c0943a5 100644 --- a/lib/git/pkgs.rb +++ b/lib/git/pkgs.rb @@ -36,6 +36,7 @@ require_relative "pkgs/commands/upgrade" require_relative "pkgs/commands/schema" require_relative "pkgs/commands/diff_driver" +require_relative "pkgs/commands/completions" module Git module Pkgs diff --git a/lib/git/pkgs/cli.rb b/lib/git/pkgs/cli.rb index dbac189..2453ae2 100644 --- a/lib/git/pkgs/cli.rb +++ b/lib/git/pkgs/cli.rb @@ -14,7 +14,8 @@ class CLI "info" => "Show database size and row counts", "branch" => "Manage tracked branches", "schema" => "Show database schema", - "diff-driver" => "Install git textconv driver for lockfile diffs" + "diff-driver" => "Install git textconv driver for lockfile diffs", + "completions" => "Generate shell completions" }, "Query" => { "list" => "List dependencies at a commit", diff --git a/lib/git/pkgs/commands/completions.rb b/lib/git/pkgs/commands/completions.rb new file mode 100644 index 0000000..e511e3b --- /dev/null +++ b/lib/git/pkgs/commands/completions.rb @@ -0,0 +1,234 @@ +# frozen_string_literal: true + +module Git + module Pkgs + module Commands + class Completions + COMMANDS = CLI::COMMANDS + SUBCOMMAND_OPTIONS = { + "hooks" => %w[--install --uninstall], + "branch" => %w[--add --remove --list], + "diff" => %w[--format], + "list" => %w[--format --type], + "tree" => %w[--format], + "history" => %w[--format --limit], + "search" => %w[--format --limit], + "blame" => %w[--format], + "stale" => %w[--days --format], + "stats" => %w[--format], + "log" => %w[--limit --format], + "show" => %w[--format], + "where" => %w[--format], + "why" => %w[--format] + }.freeze + + BASH_SCRIPT = <<~'BASH' + _git_pkgs() { + local cur prev commands + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + + commands="init update hooks info list tree history search where why blame stale stats diff branch show log upgrade schema completions" + + if [[ ${COMP_CWORD} -eq 2 && ${COMP_WORDS[1]} == "pkgs" ]]; then + COMPREPLY=( $(compgen -W "${commands}" -- ${cur}) ) + return 0 + fi + + if [[ ${COMP_CWORD} -eq 1 ]]; then + COMPREPLY=( $(compgen -W "${commands}" -- ${cur}) ) + return 0 + fi + + case "${prev}" in + hooks) + COMPREPLY=( $(compgen -W "--install --uninstall --help" -- ${cur}) ) + ;; + branch) + COMPREPLY=( $(compgen -W "--add --remove --list --help" -- ${cur}) ) + ;; + completions) + COMPREPLY=( $(compgen -W "bash zsh install --help" -- ${cur}) ) + ;; + diff|list|tree|history|search|blame|stale|stats|log|show|where|why) + COMPREPLY=( $(compgen -W "--format --help" -- ${cur}) ) + ;; + esac + + return 0 + } + + # Support both 'git pkgs' and 'git-pkgs' invocations + complete -F _git_pkgs git-pkgs + + # For 'git pkgs' subcommand completion + if declare -F _git >/dev/null 2>&1; then + _git_pkgs_git_wrapper() { + if [[ ${COMP_WORDS[1]} == "pkgs" ]]; then + _git_pkgs + fi + } + fi + BASH + + ZSH_SCRIPT = <<~'ZSH' + #compdef git-pkgs + + _git-pkgs() { + local -a commands + commands=( + 'init:Initialize the package database' + 'update:Update the database with new commits' + 'hooks:Manage git hooks for auto-updating' + 'info:Show database size and row counts' + 'branch:Manage tracked branches' + 'list:List dependencies at a commit' + 'tree:Show dependency tree grouped by type' + 'history:Show the history of a package' + 'search:Find a dependency across all history' + 'where:Show where a package appears in manifest files' + 'why:Explain why a dependency exists' + 'blame:Show who added each dependency' + 'stale:Show dependencies that have not been updated' + 'stats:Show dependency statistics' + 'diff:Show dependency changes between commits' + 'show:Show dependency changes in a commit' + 'log:List commits with dependency changes' + 'upgrade:Upgrade database after git-pkgs update' + 'schema:Show database schema' + 'completions:Generate shell completions' + ) + + _arguments -C \ + '1: :->command' \ + '*:: :->args' + + case $state in + command) + _describe -t commands 'git-pkgs commands' commands + ;; + args) + case $words[1] in + hooks) + _arguments \ + '--install[Install git hooks]' \ + '--uninstall[Remove git hooks]' \ + '--help[Show help]' + ;; + branch) + _arguments \ + '--add[Add a branch to track]' \ + '--remove[Remove a tracked branch]' \ + '--list[List tracked branches]' \ + '--help[Show help]' + ;; + completions) + _arguments '1:shell:(bash zsh install)' + ;; + diff|list|tree|history|search|blame|stale|stats|log|show|where|why) + _arguments \ + '--format[Output format]:format:(table json csv)' \ + '--help[Show help]' + ;; + esac + ;; + esac + } + + _git-pkgs "$@" + ZSH + + def initialize(args) + @args = args + end + + def run + shell = @args.first + + case shell + when "bash" + puts BASH_SCRIPT + when "zsh" + puts ZSH_SCRIPT + when "install" + install_completions + when "-h", "--help", nil + print_help + else + $stderr.puts "Unknown shell: #{shell}" + $stderr.puts "Supported: bash, zsh, install" + exit 1 + end + end + + def install_completions + shell = detect_shell + + case shell + when "zsh" + install_zsh_completions + when "bash" + install_bash_completions + else + $stderr.puts "Could not detect shell. Please run one of:" + $stderr.puts " eval \"$(git pkgs completions bash)\"" + $stderr.puts " eval \"$(git pkgs completions zsh)\"" + exit 1 + end + end + + def detect_shell + shell_env = ENV["SHELL"] || "" + if shell_env.include?("zsh") + "zsh" + elsif shell_env.include?("bash") + "bash" + end + end + + def install_bash_completions + dir = File.expand_path("~/.local/share/bash-completion/completions") + FileUtils.mkdir_p(dir) + path = File.join(dir, "git-pkgs") + File.write(path, BASH_SCRIPT) + puts "Installed bash completions to #{path}" + puts "Restart your shell or run: source #{path}" + end + + def install_zsh_completions + dir = File.expand_path("~/.zsh/completions") + FileUtils.mkdir_p(dir) + path = File.join(dir, "_git-pkgs") + File.write(path, ZSH_SCRIPT) + puts "Installed zsh completions to #{path}" + puts "" + puts "Add to your ~/.zshrc if not already present:" + puts " fpath=(~/.zsh/completions $fpath)" + puts " autoload -Uz compinit && compinit" + puts "" + puts "Then restart your shell or run: source ~/.zshrc" + end + + def print_help + puts <<~HELP + Usage: git pkgs completions + + Generate shell completion scripts. + + Shells: + bash Output bash completion script + zsh Output zsh completion script + install Auto-install completions for your shell + + Examples: + git pkgs completions bash > ~/.local/share/bash-completion/completions/git-pkgs + git pkgs completions zsh > ~/.zsh/completions/_git-pkgs + eval "$(git pkgs completions bash)" + git pkgs completions install + HELP + end + end + end + end +end diff --git a/test/git/pkgs/test_completions.rb b/test/git/pkgs/test_completions.rb new file mode 100644 index 0000000..96f7ac1 --- /dev/null +++ b/test/git/pkgs/test_completions.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require "test_helper" +require "stringio" +require "tmpdir" + +class Git::Pkgs::TestCompletionsCommand < Minitest::Test + def test_bash_output + output = capture_stdout do + Git::Pkgs::Commands::Completions.new(["bash"]).run + end + + assert_includes output, "_git_pkgs()" + assert_includes output, "COMPREPLY" + assert_includes output, "compgen" + assert_includes output, "complete -F _git_pkgs git-pkgs" + end + + def test_zsh_output + output = capture_stdout do + Git::Pkgs::Commands::Completions.new(["zsh"]).run + end + + assert_includes output, "#compdef git-pkgs" + assert_includes output, "_git-pkgs()" + assert_includes output, "_describe" + assert_includes output, "_arguments" + end + + def test_help_output + output = capture_stdout do + Git::Pkgs::Commands::Completions.new(["--help"]).run + end + + assert_includes output, "Usage: git pkgs completions" + assert_includes output, "bash" + assert_includes output, "zsh" + assert_includes output, "install" + end + + def test_no_args_shows_help + output = capture_stdout do + Git::Pkgs::Commands::Completions.new([]).run + end + + assert_includes output, "Usage: git pkgs completions" + end + + def test_unknown_shell_exits_with_error + assert_raises(SystemExit) do + capture_stderr do + Git::Pkgs::Commands::Completions.new(["fish"]).run + end + end + end + + def test_bash_includes_all_commands + output = capture_stdout do + Git::Pkgs::Commands::Completions.new(["bash"]).run + end + + # Check that main commands are included + assert_includes output, "init" + assert_includes output, "update" + assert_includes output, "history" + assert_includes output, "completions" + end + + def test_zsh_includes_all_commands + output = capture_stdout do + Git::Pkgs::Commands::Completions.new(["zsh"]).run + end + + assert_includes output, "'init:Initialize the package database'" + assert_includes output, "'completions:Generate shell completions'" + end + + def test_install_creates_bash_completions + Dir.mktmpdir do |tmpdir| + ENV["HOME"] = tmpdir + ENV["SHELL"] = "/bin/bash" + + output = capture_stdout do + Git::Pkgs::Commands::Completions.new(["install"]).run + end + + completion_file = File.join(tmpdir, ".local/share/bash-completion/completions/git-pkgs") + assert File.exist?(completion_file), "Completion file should exist" + assert_includes File.read(completion_file), "_git_pkgs()" + assert_includes output, "Installed bash completions" + end + ensure + ENV["HOME"] = Dir.home + ENV.delete("SHELL") + end + + def test_install_creates_zsh_completions + Dir.mktmpdir do |tmpdir| + ENV["HOME"] = tmpdir + ENV["SHELL"] = "/bin/zsh" + + output = capture_stdout do + Git::Pkgs::Commands::Completions.new(["install"]).run + end + + completion_file = File.join(tmpdir, ".zsh/completions/_git-pkgs") + assert File.exist?(completion_file), "Completion file should exist" + assert_includes File.read(completion_file), "#compdef git-pkgs" + assert_includes output, "Installed zsh completions" + end + ensure + ENV["HOME"] = Dir.home + ENV.delete("SHELL") + end + + def capture_stdout + original = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = original + end + + def capture_stderr + original = $stderr + $stderr = StringIO.new + yield + $stderr.string + ensure + $stderr = original + end +end