なにをやったの
zsh には簡単に使い始められて強力な補完システムがあります
autoload -U compinit && compinit
この補完システムは、入力中のコマンドによってよしなに補完対象を選んでくれます
$ # cd ならディレクトリ名
$ cd ~/[.emacs.d]
[.emacs.d]
.ssh
.zshrc
Documents
Downloads
$ # git checkout ならブランチ名
$ git checkout [my-awesome-branch]
[my-awesome-branch]
another-awesome-branch
master
devel
が、この補完システム、 git checkout
時にリモートブランチまですべて補完しようとしてしまうので、リモートブランチが大量にある(いちいちリモートブランチを削除する運用をしていない)リポジトリで TAB を押すと固まります。
これが残念だったので、「 git checkout
ではローカルブランチしか補完しない」設定をしてみました。
設定が結構面倒だった(というかもっといい方法ありそう)だったので、記事にしてみました。こうすれば一撃だよ!みたいなツッコミあればぜひ教えてください👀
zsh の git 補完の実装
zsh の git ブランチ名補完をいじるために、まずは元の実装を当たってみます。
zsh には PATH
のほかに FPATH
という環境変数があって、 zsh 内で使われる関数類の定義はこのディレクトリから探索されます。この中のどこかでgit checkout
の補完に使われる関数が定義されているはずです。
環境にもよると思うのですが、自分の環境では /usr/local/share/zsh/site-functions/git-complete.bash
にいました (バージョンによって多少実装は違うかもしれません)。
_git_checkout ()
{
__git_has_doubledash && return
case "$cur" in
(諸々のオプション名の補完... 省略)
*)
# check if --track, --no-track, or --no-guess was specified
# if so, disable DWIM mode
local flags="--track --no-track --no-guess" track=1
if [ -n "$(__git_find_on_cmdline "$flags")" ]; then
track=''
fi
__gitcomp_nl "$(__git_refs '' $track)"
;;
esac
}
細かいことはよくわからないけど、 __git_refs
がブランチ名の一覧を取ってきていそうな雰囲気があります。
一方、同じファイルにある、 git branch
の補完の実装 (こちらはデフォルトでローカルブランチだけが補完されます) を見てみると、
_git_branch ()
{
(... 中略)
*)
if [ $only_local_ref = "y" -a $has_r = "n" ]; then
__gitcomp_nl "$(__git_heads)"
else
__gitcomp_nl "$(__git_refs)"
fi
;;
esac
}
どうやら、 __git_refs
の代わりに __git_heads
を呼んでやればローカルブランチだけが補完されそうな雰囲気があります。
最初に試したこと
方針は立ったので、まずは組み込みの _git_checkout
の実装を直接書き換えてみます。
_git_checkout ()
{
__git_has_doubledash && return
case "$cur" in
(諸々のオプション名の補完... 省略)
*)
- # check if --track, --no-track, or --no-guess was specified
- # if so, disable DWIM mode
- local flags="--track --no-track --no-guess" track=1
- if [ -n "$(__git_find_on_cmdline "$flags")" ]; then
- track=''
- fi
- __gitcomp_nl "$(__git_refs '' $track)"
+ __gitcomp_nl "$(__git_heads)"
;;
esac
}
zsh を再起動して確かめてみると、確かにローカルブランチだけが補完されます。
ひとまず目的は達成できました。
設定ファイルの中にねじ込みたい
一応の目的は達成できたわけですが、 zsh に一緒についてきたスクリプトを直接変更したので、このままだと zsh のアップデートのタイミングとかで元に戻ってしまったりしそうだし、端末間で設定を共有するのも難しいので、できれば設定ファイルの中で完結したいです。
最初に考えたのは、 .zshrc
の中に上の _git_checkout
の定義をベタ書きして、元の定義を上書きすることでした。が、これはうまくいきません。
なぜなら、 zsh には autoload という仕組みがあって、関数の実装は実際に使われるタイミングまでロードされないからです。
$ which _git_checkout
_git_checkout ()
{
(僕らがカスタムした定義... 省略)
}
$ git checkout [my-branch] # ここで TAB 補完
[my-branch]
another-branch
yet-another-branch
$ which _git_checkout # 元の定義に戻っている
_git_checkout ()
{
(オリジナルの定義... 省略)
}
むしろ僕らの定義した _git_checkout
が、 autoload の走るタイミングでオリジナルの _git_checkout
の定義に上書きされてしまう格好になってしまいました。
どうやら、 git-complete.bash
の読み込まれた直後に定義の上書をしなければならなそうです。
こんな時に僕がよく使うのは、次のような方法です:ダミーの定義を用意して、その中で本家の定義を読み込みつつ、読み込み完了時にやりたい処理を一緒にやる。
_git
のダミーを作る
さて、ダミーを作らなければいけないのは、実は git-complete.bash
本体ではありません。
僕らが zsh の補完を有効にするときに呼び出す、 compinit
関数の実装を見てみます。
(... 省略)
for _i_dir in $fpath; do
[[ $_i_dir = . ]] && continue
(( $_i_wdirs[(I)$_i_dir] )) && continue
for _i_file in $_i_dir/^([^_]*|*~|*.zwc)(N); do
_i_name="${_i_file:t}"
(( $+_i_test[$_i_name] + $_i_wfiles[(I)$_i_file] )) && continue
_i_test[$_i_name]=yes
IFS=$' \t' read -rA _i_line < $_i_file
_i_tag=$_i_line[1]
shift _i_line
case $_i_tag in
(\#compdef)
if [[ $_i_line[1] = -[pPkK](n|) ]]; then
compdef ${_i_line[1]}na "${_i_name}" "${(@)_i_line[2,-1]}"
else
compdef -na "${_i_name}" "${_i_line[@]}"
fi
;;
(\#autoload)
autoload -Uz "$_i_line[@]" ${_i_name}
[[ "$_i_line" != \ # ]] && _compautos[${_i_name}]="$_i_line"
;;
esac
done
done
(... 省略)
ちょっとややこしいのですが、 FPATH
にある、 #compdef
という行から始まるファイルに片っ端から compdef
関数をかけています。 compdef
関数の実装も同じファイルにあって、冒頭のコメントを見てみると、
# `#compdef <names ...>'
# If the first line looks like this, the file is autoloaded as a
# function and that function will be called to generate the matches
# when completing for one of the commands whose <names> are given.
autoload しつつ、補完エンジンとして登録するような処理をする関数なのが見て取れます。
立ち返って git-complete.bash
を見てみると、このファイルには #compdef
キーワードがありません。したがって、このファイルは compinit
で autoload されているファイルではありません。
同じディレクトリ内をあさってみると、 _git
という別のファイルが見つかります。このファイルの冒頭には #compdef
キーワードがあり、
#compdef git gitk
(... 省略)
どうやらこのファイルの中で、本命の git-complete.bash
も読み込まれているっぽいことがわかります。
(... 省略)
zstyle -s ":completion:*:*:git:*" script script
if [ -z "$script" ]; then
local -a locations
local e
locations=(
$(dirname ${funcsourcetrace[1]%:*})/git-completion.bash
'/etc/bash_completion.d/git' # fedora, old debian
'/usr/share/bash-completion/completions/git' # arch, ubuntu, new debian
'/usr/share/bash-completion/git' # gentoo
)
for e in $locations; do
test -f $e && script="$e" && break
done
fi
(... 省略)
そんなこんなで、僕らがニセモノを作ってオーバーライドしなければいけないのは、 _git
だということがわかりました。
満を持して、こんなファイルを _git
という名前で作って:
#compdef git gitk
# 本家 _git を読み込む
source /usr/local/share/zsh/site-functions/_git
# _git_checkout の定義を差し替えて、ローカルブランチだけを補完対象にする
_git_checkout () {
__git_has_doubledash && return
case "$cur" in
--conflict=*)
__gitcomp "diff3 merge" "" "${cur##--conflict=}"
;;
--*)
__gitcomp "
--quiet --ours --theirs --track --no-track --merge
--conflict= --orphan --patch
"
;;
*)
__gitcomp_nl "$(__git_heads)"
;;
esac
}
_git
適当なフォルダ (たとえば、 ~/.zsh.d/functions
) に置き、 FPATH
を通して本家より先に読まれるようにします。
export FPATH="$HOME/.zsh.d/functions:$FPATH"
これでめでたく git checkout
でローカルブランチだけが補完されるようになります。
本家のファイルを直接編集したわけでもないので、設定ファイルとダミーの _git
をまとめてコピーしてあげれば他の環境でも同じ設定が使えるし、アップデートなどで上書きされてしまうこともなさそうです。