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

# なにをやったの

zsh には簡単に使い始められて強力な補完システムがあります

```sh
autoload -U compinit && compinit
```

この補完システムは、入力中のコマンドによってよしなに補完対象を選んでくれます

```terminal
$ # cd ならディレクトリ名
$ cd ~/[.emacs.d]
[.emacs.d]
 .ssh
 .zshrc
 Documents
 Downloads
```

```terminal
$ # 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` にいました (バージョンによって多少実装は違うかもしれません)。

```zsh
_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` の補完の実装 (こちらはデフォルトでローカルブランチだけが補完されます) を見てみると、

```zsh
_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` の実装を直接書き換えてみます。

```diff
 _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 という仕組みがあって、関数の実装は実際に使われるタイミングまでロードされないからです。

```terminal
$ 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` 関数の実装を見てみます。

```zsh
(... 省略)

  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` 関数の実装も同じファイルにあって、冒頭のコメントを見てみると、

```zsh
#   `#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` キーワードがあり、

```zsh
#compdef git gitk
(... 省略)
```

どうやらこのファイルの中で、本命の `git-complete.bash` も読み込まれているっぽいことがわかります。

```zsh
(... 省略)

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` という名前で作って：

```zsh
#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` を通して本家より先に読まれるようにします。

```zsh
export FPATH="$HOME/.zsh.d/functions:$FPATH"
```

これでめでたく `git checkout` でローカルブランチだけが補完されるようになります。

本家のファイルを直接編集したわけでもないので、設定ファイルとダミーの `_git` をまとめてコピーしてあげれば他の環境でも同じ設定が使えるし、アップデートなどで上書きされてしまうこともなさそうです。