Edited at

zsh の git ブランチ名補完でローカルブランチだけ補完する

More than 1 year has passed since last update.


なにをやったの

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 をまとめてコピーしてあげれば他の環境でも同じ設定が使えるし、アップデートなどで上書きされてしまうこともなさそうです。