diff --git a/cucumber.yml b/cucumber.yml new file mode 100644 index 0000000000000000000000000000000000000000..1971268e711bebd1065b7f7ae3887a32579cbe94 --- /dev/null +++ b/cucumber.yml @@ -0,0 +1,2 @@ +default: --format progress -t ~@completion +completion: --format pretty -t @completion diff --git a/etc/hub.bash_completion.sh b/etc/hub.bash_completion.sh index 5143214b84e1b703c97d5b081fb58f0a9e59f49c..b3ce32d27d2d558fe8ac40316d21a985ff727da1 100755 --- a/etc/hub.bash_completion.sh +++ b/etc/hub.bash_completion.sh @@ -16,6 +16,7 @@ fork create browse compare +ci-status EOF __git_list_all_commands_without_hub } diff --git a/etc/hub.zsh_completion b/etc/hub.zsh_completion index 31bffc3db483d7137e05e3d8bff4d8a3a7af013e..df09ace1a077439e175a34e94819e195f9a33d38 100755 --- a/etc/hub.zsh_completion +++ b/etc/hub.zsh_completion @@ -1,22 +1,160 @@ -# hub tab-completion script for zsh. -# This script complements the completion script that ships with git. +#compdef hub + +# Zsh will source this file when attempting to autoload the "_hub" function, +# typically on the first attempt to complete the hub command. We define two new +# setup helper routines (one for the zsh-distributed version, one for the +# git-distributed, bash-based version). Then we redefine the "_hub" function to +# call "_git" after some other interception. # -# vim: ft=zsh sw=2 ts=2 et - -# Autoload _git completion functions -if declare -f _git > /dev/null; then - _git -fi - -if declare -f _git_commands > /dev/null; then - _hub_commands=( - 'alias:show shell instructions for wrapping git' - 'pull-request:open a pull request on GitHub' - 'fork:fork origin repo on GitHub' - 'create:create new repo on GitHub for the current project' - 'browse:browse the project on GitHub' - 'compare:open GitHub compare view' - ) - # Extend the '_git_commands' function with hub commands - eval "$(declare -f _git_commands | sed -e 's/base_commands=(/base_commands=(${_hub_commands} /')" -fi +# This is pretty fragile, if you think about it. Any number of implementation +# changes in the "_git" scripts could cause problems down the road. It would be +# better if the stock git completions were just a bit more permissive about how +# it allowed third-party commands to be added. + +(( $+functions[__hub_setup_zsh_fns] )) || +__hub_setup_zsh_fns () { + (( $+functions[_git-alias] )) || + _git-alias () { + _arguments \ + '-s[output shell script suitable for eval]' \ + '1::shell:(zsh bash csh)' + } + + (( $+functions[_git-browse] )) || + _git-browse () { + _arguments \ + '-u[output the URL]' \ + '2::subpage:(wiki commits issues)' + } + + (( $+functions[_git-compare] )) || + _git-compare () { + _arguments \ + '-u[output the URL]' \ + ':[start...]end range:' + } + + (( $+functions[_git-create] )) || + _git-create () { + _arguments \ + '::name (REPOSITORY or ORGANIZATION/REPOSITORY):' \ + '-p[make repository private]' \ + '-d[description]:description' \ + '-h[home page]:repository home page URL:_urls' + } + + (( $+functions[_git-fork] )) || + _git-fork () { + _arguments \ + '--no-remote[do not add a remote for the new fork]' + } + + (( $+functions[_git-pull-request] )) || + _git-pull-request () { + _arguments \ + '-f[force (skip check for local commits)]' \ + '-b[base]:base ("branch", "owner\:branch", "owner/repo\:branch"):' \ + '-h[head]:head ("branch", "owner\:branch", "owner/repo\:branch"):' \ + - set1 \ + '-m[message]' \ + '-F[file]' \ + - set2 \ + '-i[issue]:issue number:' \ + - set3 \ + '::issue-url:_urls' + } + + # stash the "real" command for later + functions[_hub_orig_git_commands]=$functions[_git_commands] + + # Replace it with our own wrapper. + declare -f _git_commands >& /dev/null && unfunction _git_commands + _git_commands () { + local ret=1 + # call the original routine + _call_function ret _hub_orig_git_commands + + # Effectively "append" our hub commands to the behavior of the original + # _git_commands function. Using this wrapper function approach ensures + # that we only offer the user the hub subcommands when the user is + # actually trying to complete subcommands. + hub_commands=( + alias:'show shell instructions for wrapping git' + pull-request:'open a pull request on GitHub' + fork:'fork origin repo on GitHub' + create:'create new repo on GitHub for the current project' + browse:'browse the project on GitHub' + compare:'open GitHub compare view' + ci-status:'lookup commit in GitHub Status API' + ) + _describe -t hub-commands 'hub command' hub_commands && ret=0 + + return ret + } +} + +(( $+functions[__hub_setup_bash_fns] )) || +__hub_setup_bash_fns () { + # TODO more bash-style fns needed here to complete subcommand args. They take + # the form "_git_CMD" where "CMD" is something like "pull-request". + + # Duplicate and rename the 'list_all_commands' function + eval "$(declare -f __git_list_all_commands | \ + sed 's/__git_list_all_commands/__git_list_all_commands_without_hub/')" + + # Wrap the 'list_all_commands' function with extra hub commands + __git_list_all_commands() { + cat <<-EOF +alias +pull-request +fork +create +browse +compare +ci-status +EOF + __git_list_all_commands_without_hub + } + + # Ensure cached commands are cleared + __git_all_commands="" +} + +# redefine _hub to a much smaller function in the steady state +_hub () { + # only attempt to intercept the normal "_git" helper functions once + (( $+__hub_func_replacement_done )) || + () { + # At this stage in the shell's execution the "_git" function has not yet + # been autoloaded, so the "_git_commands" or "__git_list_all_commands" + # functions will not be defined. Call it now (with a bogus no-op service + # to prevent premature completion) so that we can wrap them. + if declare -f _git >& /dev/null ; then + _hub_noop () { __hub_zsh_provided=1 } # zsh-provided will call this one + __hub_noop_main () { __hub_git_provided=1 } # git-provided will call this one + local service=hub_noop + _git + unfunction _hub_noop + unfunction __hub_noop_main + service=git + fi + + if (( $__hub_zsh_provided )) ; then + __hub_setup_zsh_fns + elif (( $__hub_git_provided )) ; then + __hub_setup_bash_fns + fi + + __hub_func_replacement_done=1 + } + + # Now perform the actual completion, allowing the "_git" function to call our + # replacement "_git_commands" function as needed. Both versions expect + # service=git or they will call nonexistent routines or end up in an infinite + # loop. + service=git + declare -f _git >& /dev/null && _git +} + +# make sure we actually attempt to complete on the first "tab" from the user +_hub diff --git a/features/alias.feature b/features/alias.feature index b0c4995a838a6f624f2996103eb1a22c863ef590..13d70c1e58775d4f52ae6b763aa1d139cfbbccc7 100644 --- a/features/alias.feature +++ b/features/alias.feature @@ -33,10 +33,7 @@ Feature: hub alias When I successfully run `hub alias -s` Then the output should contain exactly: """ - alias git=hub - if type compdef >/dev/null; then - compdef hub=git - fi\n + alias git=hub\n """ Scenario: unsupported shell diff --git a/features/bash_completion.feature b/features/bash_completion.feature new file mode 100644 index 0000000000000000000000000000000000000000..15a1b494135d49439ba8ed91f5682d7dd564c7ff --- /dev/null +++ b/features/bash_completion.feature @@ -0,0 +1,23 @@ +@completion +Feature: bash tab-completion + + Scenario: "pu" matches multiple commands including "pull-request" + Given my shell is bash + And I'm using git-distributed base git completions + When I type "git pu" and press + Then the command should not expand + When I press again + Then the completion menu should offer "pull pull-request push" + + Scenario: "ci-" expands to "ci-status" + Given my shell is bash + And I'm using git-distributed base git completions + When I type "git ci-" and press + Then the command should expand to "git ci-status" + + # In this combination, zsh uses completion support from a bash script. + Scenario: "ci-" expands to "ci-status" + Given my shell is zsh + And I'm using git-distributed base git completions + When I type "git ci-" and press + Then the command should expand to "git ci-status" diff --git a/features/support/completion.rb b/features/support/completion.rb new file mode 100644 index 0000000000000000000000000000000000000000..068eb239676b9bbd67040949e79372e2418175c3 --- /dev/null +++ b/features/support/completion.rb @@ -0,0 +1,193 @@ +# Driver for completion tests executed via a separate tmux pane in which we +# spawn an interactive shell, send keystrokes to and inspect the outcome of +# tab-completion. +# +# Prerequisites: +# - tmux +# - bash +# - zsh +# - git + +require 'fileutils' +require 'rspec/expectations' +require 'pathname' + +tmpdir = Pathname.new(ENV.fetch('TMPDIR', '/tmp')) + 'hub-test' +cpldir = tmpdir + 'completion' +zsh_completion = File.expand_path('../../../etc/hub.zsh_completion', __FILE__) +bash_completion = File.expand_path('../../../etc/hub.bash_completion.sh', __FILE__) + +_git_prefix = nil + +git_prefix = lambda { + _git_prefix ||= begin + git_core = Pathname.new(`git --exec-path`.chomp) + git_core.dirname.dirname + end +} + +git_distributed_zsh_completion = lambda { + git_prefix.call + 'share/zsh/site-functions/_git' +} + +git_distributed_bash_completion = lambda { + git_prefix.call + 'etc/bash_completion.d/git-completion.bash' +} + +link_completion = lambda { |from, name = nil| + name ||= from.basename + raise ArgumentError, from.to_s unless File.exist?(from) + FileUtils.ln_s(from, cpldir + name) +} + +setup_tmp_home = lambda { |shell| + FileUtils.rm_rf(tmpdir) + FileUtils.mkdir_p(cpldir) + + case shell + when 'zsh' + File.open(File.join(tmpdir, '.zshrc'), 'w') do |zshrc| + zshrc.write <<-SH + PS1='$ ' + for site_fn in /usr/{local/,}share/zsh/site-functions; do + fpath=(${fpath#\$site_fn}) + done + fpath=('#{cpldir}' $fpath) + alias git=hub + autoload -U compinit + compinit -i + SH + end + when 'bash' + File.open(File.join(tmpdir, '.bashrc'), 'w') do |bashrc| + bashrc.write <<-SH + PS1='$ ' + alias git=hub + . '#{git_distributed_bash_completion.call}' + . '#{bash_completion}' + SH + end + end +} + +After('@completion') do + tmux_kill_pane +end + +World Module.new { + attr_reader :shell + + def set_shell(shell) + @shell = shell + end + + define_method(:tmux_pane) do + return @tmux_pane if tmux_pane? + @tmux_pane = `tmux new-window -dP -n test -c "#{tmpdir}" 'env HOME="#{tmpdir}" #{shell}'`.chomp + end + + def tmux_pane? + defined?(@tmux_pane) && @tmux_pane + end + + def tmux_pane_contents + `tmux capture-pane -p -t #{tmux_pane}`.rstrip + end + + def tmux_send_keys(*keys) + system 'tmux', 'send-keys', '-t', tmux_pane, *keys + end + + def tmux_kill_pane + system 'tmux', 'kill-pane', '-t', tmux_pane if tmux_pane? + end + + def tmux_wait_for_prompt + num_waited = 0 + while tmux_pane_contents !~ /\$\Z/ + sleep 0.01 + num_waited += 1 + raise "timeout while waiting for shell prompt" if num_waited > 100 + end + end + + def tmux_wait_for_completion + # bash can be pretty slow + sleep 0.4 + end + + def tmux_completion_menu + tmux_wait_for_completion + hash = {} + tmux_pane_contents.split("\n").grep(/^[^\$].+ -- /).each do |line| + item, description = line.split(/ +-- +/, 2) + hash[item] = description + end + hash + end + + def tmux_completion_menu_basic + tmux_wait_for_completion + tmux_pane_contents.split("\n").grep(/^[^\$]/).map {|line| + line.split(/\s+/) + }.flatten + end +} + +Given /^my shell is (\w+)$/ do |shell| + set_shell(shell) + setup_tmp_home.call(shell) +end + +Given /^I'm using ((?:zsh|git)-distributed) base git completions$/ do |type| + link_completion.call(zsh_completion, '_hub') + case type + when 'zsh-distributed' + raise "this combination makes no sense!" if 'bash' == shell + (cpldir + '_git').exist?.should be_false + when 'git-distributed' + if 'zsh' == shell + link_completion.call(git_distributed_zsh_completion.call) + link_completion.call(git_distributed_bash_completion.call) + end + else + raise ArgumentError, type + end +end + +When /^I type "(.+?)" and press $/ do |string| + tmux_wait_for_prompt + @last_command = string + tmux_send_keys(string) + tmux_send_keys('Tab') +end + +When /^I press again$/ do + tmux_send_keys('Tab') +end + +Then /^the completion menu should offer "([^"]+?)"$/ do |items| + menu = tmux_completion_menu_basic + menu.join(' ').should eq(items) +end + +Then /^the completion menu should offer "(.+?)" with description "(.+?)"$/ do |item, description| + menu = tmux_completion_menu + menu.keys.should include(item) + menu[item].should eq(description) +end + +Then /^the completion menu should offer:/ do |table| + menu = tmux_completion_menu + menu.should eq(table.rows_hash) +end + +Then /^the command should expand to "(.+?)"$/ do |cmd| + tmux_wait_for_completion + tmux_pane_contents.should match(/^\$ #{cmd}$/) +end + +Then /^the command should not expand$/ do + tmux_wait_for_completion { false } + tmux_pane_contents.should match(/^\$ #{@last_command}$/) +end diff --git a/features/zsh_completion.feature b/features/zsh_completion.feature new file mode 100644 index 0000000000000000000000000000000000000000..62e569fcd3d8a25b0d932585ea3ff54b31b6d396 --- /dev/null +++ b/features/zsh_completion.feature @@ -0,0 +1,36 @@ +@completion +Feature: zsh tab-completion + + Background: + Given my shell is zsh + And I'm using zsh-distributed base git completions + + Scenario: "pu" expands to "pull-request" after "pull" + When I type "git pu" and press + Then the completion menu should offer "pull-request" with description "open a pull request on GitHub" + When I press again + Then the command should expand to "git pull" + When I press again + Then the command should expand to "git pull-request" + + Scenario: "ci-" expands to "ci-status" + When I type "git ci-" and press + Then the command should expand to "git ci-status" + + Scenario: Completion of pull-request arguments + When I type "git pull-request -" and press + Then the completion menu should offer: + | -b | base | + | -h | head | + | -m | message | + | -F | file | + | -i | issue | + | -f | force (skip check for local commits) | + + Scenario: Completion of fork arguments + When I type "git fork -" and press + Then the command should expand to "git fork --no-remote" + + Scenario: Completion of 2nd browse argument + When I type "git browse -- i" and press + Then the command should expand to "git browse -- issues" diff --git a/lib/hub/commands.rb b/lib/hub/commands.rb index 4c6a9c32d43233c0b71d64ebb8818e58813bf903..735dfca904c353d4b5373a09cb4553a7774a0916 100644 --- a/lib/hub/commands.rb +++ b/lib/hub/commands.rb @@ -726,11 +726,6 @@ module Hub if script puts "alias git=hub" - if 'zsh' == shell - puts "if type compdef >/dev/null; then" - puts " compdef hub=git" - puts "fi" - end else profile = case shell when 'bash' then '~/.bash_profile' @@ -764,10 +759,16 @@ module Hub if command == 'hub' || custom_command?(command) puts hub_manpage exit - elsif command.nil? && !args.has_flag?('-a', '--all') - ENV['GIT_PAGER'] = '' unless args.has_flag?('-p', '--paginate') # Use `cat`. - puts improved_help_text - exit + elsif command.nil? + if args.has_flag?('-a', '--all') + # Add the special hub commands to the end of "git help -a" output. + args.after 'echo', ["\nhub custom commands\n"] + args.after 'echo', CUSTOM_COMMANDS.map {|cmd| " #{cmd}" } + else + ENV['GIT_PAGER'] = '' unless args.has_flag?('-p', '--paginate') # Use `cat`. + puts improved_help_text + exit + end end end alias_method "--help", :help