Gitブランチ間の変更ファイル一覧をQuickFixリストに出力するvimプラグインを作った

この記事はVim Advent Calendar 2012の187日目の記事です。
186日目は id:osyo-manga さんによる Vim で URLのタイトルをバルーンで表示するプラグインつくった - C++でゲームプログラミング でした。

AdventCalendar初参加です。よろしくおねがいします。
クリスマスがとても待ち遠しいですね!

はじめに

この記事では、次の2つの話を書きました。

  • 作ったプラグイン「gitquick.vim」の紹介
  • プラグインを作る過程でわからなかったことと、どこを見て解決したのか

gitquick.vim というプラグインをつくってみました

このプラグインは、Gitリポジトリの中での2つのbranchやtag間を比較して、
追加変更があったファイルの一覧をVimのQuickFixリストを使って見るというコマンドを追加します。

このプラグインができたきっかけ

こんなときがありました。

  • HTMLとCSSを書くWebデザイナーと、プログラムを書くWebエンジニアがGitを使って開発をしている
  • おなじリポジトリに、HTMLモックアップCSSプログラマが書くソースが入っている
  • ある機能を開発することになり、デザイナーエンジニア共通の作業ブランチを切った
  • 先にデザイナーがHTMLモックアップを作る作業を終えてブランチをプッシュした
  • そして、これからエンジニアがHTMLモックアップを組み込む

そこで、

「masterと比較して新画面のHTML一覧がQuickFixリストに入ってきたら便利だよねー」

との日々改善を試みるVimmerの一声が、このプラグインを書くきっかけとなりました。

gitquick.vimの使い方

Git管理下のディレクトリに中で、次のコマンドを実行します。

:Gitquick ブランチ名1 ブランチ名2
  • ブランチ1とブランチ2の間での追加・変更されたファイルをQuickFixリストに入れます。
  • Aが追加、Mが編集があったファイルです。
  • ブランチ名1、2は<TAB>で入力補完することができます。

引数を一つに省いた場合は、 カレントのブランチと引数のブランチで比較します。

:Gitquick ブランチ名

上記は、次を実行した場合と同じ結果です。

:Gitquick カレントブランチ名 ブランチ名

gitquick.vimを作る課程の紹介

ここからは、このプラグインを作る過程の話を書いてみたいと思います。

  • はじめに. 開発の流れ
  • 1. プラグインのファイルをつくる
  • 2. プラグインを書くときの決まりごと
  • 3. ユーザコマンドをつくる
  • 4. ユーザコマンドに引数を渡す
  • 5. QuickFixリストを使ってファイル一覧を表示する
  • 6. 関数をautoloadに寄せる


このプラグインの仕組みはとても簡単で、gitの

git diff ブランチ1..ブランチ2 --name-status --diff-filter=AM 

の結果を取得して、 QuickFixリストに入れているだけです。

この記事では、 こんな風にプラグインを作りましたという流れと
その間に参照したヘルプやページを加えて綴ってみたいと思います。

(振り返ると :help usr_41 見てねで終了かもしれませんが
最初はヘルプを引くのもハードル高かったですし、
そこに気づくまでを少し掘り下げてみたいと思います。)

間違いやもっと便利な作り方あるよ!というご指摘をいただけましたら幸いです。

はじめに. 開発の流れ

Vimでソースを書く → 別のVimを起動して動かしてみる」
の繰り返しです。

デバッグは、とりあえずなんでもechoでVimの画面下部に出力することで確認しました。
変数はechoすれば文字列だろうが辞書型だろうが中身を見ることができます。
途中まで動いたかどうかもechoで目印となる文字列をだして確認しながら実装していきました。

1. プラグインのファイルをつくる

まず、プラグインの名前決めです。 今回は「gitquick」です。
開発用のディレクトリを作成し、 このディレクトリで開発するvimscriptがVim起動時に読み込まれるように .vimrcでランタイムパスを通します。
開発ディレクトリは~/works/gitquick.vim/にしました。

mkdir -p ~/works/gitquick.vim/
vi ~/.vimrc

" 以下、ランタイムパスに開発ディレクトリを追加します。
set runtimepath+=~/works/gitquick.vim

プラグインのvimスクリプトファイルは plugin/プラグイン名.vim としてつくります。
プラグインとして機能させるファイルは、「plugin」の下に置きます。
ランタイムパスに通してあるディレクトリ配下のディレクトリは、その名前によって役割が決まっています。


plugin/gitquick.vimに次のコードを書いて保存し、別Vimを起動してみます。

echo 'Hello, Vim Plugin!'
$ vi
Hello, Vim Plugin!
続けるにはENTERを押すかコマンドを入力してください

と出たらOKです。
無事、ディレクトリはパスが通り、プラグインのファイルが読み込まれています。

わからなかったこと 参考になったページやVimのヘルプ
プラグインのファイルを置く場所とディレクトリ構成 http://vim-users.jp/2009/06/hack34/
ファイルの読まれる順番 :help runtimepath, :help scriptnames

2. プラグインを書くときの決まりごと

実装を進めていきます。

  • plugin/gitquick.vim
if exists('g:loaded_gitquick_vim')              " (1)
  finish
endif
let g:loaded_gitquick_vim = 1

let s:save_cpo = &cpo                           " (2)
set cpo&vim


let &cpo = s:save_cpo                           " (2)
unlet s:save_cpo
" vim:set fenc=utf-8 ff=unix ft=vim fdm=marker: " (3)

上の(1)(2)のブロックは、プラグインを書くときの決まりごとみたいなものです。

  • (1) プラグインが2重に読み込むのを防止しています。変数の前についているg:は、変数が有効なスコープ
  • (2) Vimの設定による環境依存を避けるためのおまじないみたいなものです。
  • (3) このソースをVimで開いたときに、文字コードや改行コード等をどう表示するのかの指定です(プラグイン作成には直接関係はないです)。
わからなかったこと 参考になったページやVimのヘルプ
Vimスクリプトの文法 Vimスクリプト基礎文法最速マスター - 永遠に未完成
変数とスコープ :help internal-variables
&cpoの謎 :help use-cpo-save
変数の前についている「&」 :help expr-option
モードライン :help modeline
ソースの折りたたみについて :help usr_28, :help fold

これでプラグインの基本的なかたちができました。

3. ユーザコマンドをつくる

次に、Vimで 独自のコマンドが動くようにします。
とりあえず単純に実行したらなにか文字を出すコマンドをつくってみます。

  • plugin/gitquick.vim
if exists('g:loaded_gitquick_vim')
  finish
endif
let g:loaded_gitquick_vim = 1

let s:save_cpo = &cpo
set cpo&vim

function! s:Test()                           " (2)
  echo 'user command!'
endfunction

command! Test call s:Test()                  " (1)

let &cpo = s:save_cpo
unlet s:save_cpo
" vim:set fenc=utf-8 ff=unix ft=vim fdm=marker:
  • (1) :Testというユーザ定義コマンドを定義しています。このコマンドを実行したときにはプラグイン内のs:Test()という関数が実行されます。コマンドの最初の文字は大文字にする必要があります。
  • (2) Test()というプラグイン内だけで使えるスコープ(s:)の関数を定義しています。

別のVimを立ち上げ、:Test というコマンドが実行でき、「user command!」と表示されたら成功です。
これでVimから呼べるユーザ定義コマンドができました。

わからなかったこと 参考になったページやVimのヘルプ
コマンドの作り方 :help user-commands

4. ユーザコマンドに引数を渡す

ブランチ名を引数に渡せるコマンドを作ります。
また、入力するブランチ名が長くなるとめんどうなので、入力補完できるようにしてみます。

  • plugin/gitquick.vim
if exists('g:loaded_gitquick_vim')
  finish
endif
let g:loaded_gitquick_vim = 1

let s:save_cpo = &cpo
set cpo&vim

function! s:GetBranches(ArgLead, CmdLine, CursorPos) "{{{                             " (2)
  let list = []
  for branch in split(system('git branch'), "\n")                                     " (3)
    call add(list, matchstr(branch, '\([\*\s]*\)\zs[0-9A-Za-z/_.-]\+\ze\($\)'))       " (4)
  endfor
  return filter(list, 'v:val =~ "^'. fnameescape(a:ArgLead) . '"')                    " (5)
endfunction "}}}

function! s:DiffBranch(...) "{{{                                                      " (6)
  echo a:000
endfunction "}}}

command! -n=* -complete=customlist,s:GetBranches Gitquick call s:DiffBranch(<f-args>) " (1)

" function! s:Test()
"   echo 'user command!'
" endfunction

" command! Test call s:Test()

let &cpo = s:save_cpo
unlet s:save_cpo
" vim:set fenc=utf-8 ff=unix ft=vim fdm=marker:
  • (1) s:DiffBranch()を実行する:Gitquickというコマンドを定義しています。
    • 「-n=*」 は、このコマンドが引数をいくつでも(0個以上)とることができるという指定です。
    • 」は、コマンドに渡す引数を、スペースやタブで区切って複数に分けるという指定です。
    • 「-complete=customlist,s:GetBranches」は、s:GetBranches関数で返すリストを補完候補とする指定です。
  • (2) ユーザ定義コマンドを補完する関数の引数は、必ず3つの引数ArgLead, CmdLine, CursorPosをもらうように決まっています。
  • (3) git branchコマンドを実行した結果の標準出力を改行文字で区切ってリストにして、for文でまわしています。
  • (4) ループの中で、Vimスクリプトが最初から用意している組み込み関数matchstr()を使い、正規表現にマッチするブランチ名を抜き出してリストlistに追加しています。
  • (5) すでに入力されている補完対象の文字列ArgLeadにマッチする補完候補のみフィルタリングして、補完候補を絞ったリストを返しています。
  • (6) 引数「...」は、引数が1個でも2個でも大丈夫なように、可変長の引数を受け取る指定です。

:Gitquickコマンドに引数を渡して実行すると、その内容が表示されるところまできました。

わからなかったこと 参考になったページやVimのヘルプ
ユーザ定義コマンドでの引数の扱い方 :help command-nargs
複数の引数を渡す方法 :help
補完候補リストの作り方 :help command-completion, :help command-completion-custom
関数の引数を扱う方法 Vimスクリプト基礎文法最速マスター - 永遠に未完成
Vimスクリプトの組み込み関数 :help eval
シェルコマンドの実行方法 :help eval, :help system()
外部コマンドの実行方法 :help command-completion, :help command-completion-custom
Vim正規表現 :help regexp
可変長の引数を受け取る方法 :help a:000

5. QuickFixリストを使ってファイル一覧を表示する

引数で受け取ったブランチ名を使い、git diffの結果をQuickFixリストに流し込みます。
s:DiffBranch()の中身を実装します。

  • plugin/gitquick.vim
if exists('g:loaded_gitquick_vim')
  finish
endif
let g:loaded_gitquick_vim = 1

let s:save_cpo = &cpo
set cpo&vim

set errorformat+=%m\	%f                                                          " (2)

function! s:GetBranches(ArgLead, CmdLine, CursorPos) "{{{
  let list = []
  for branch in split(system('git branch'), "\n")
    call add(list, matchstr(branch, '\([\*\s]*\)\zs[0-9A-Za-z/_.-]\+\ze\($\)'))
  endfor
  return filter(list, 'v:val =~ "^'. fnameescape(a:ArgLead) . '"')
endfunction "}}}

function! s:DiffBranch(...) "{{{
  " echo a:000
  if a:0 == 0 || !executable('git')
    return
  endif

  if a:0 == 1
    let rhs = a:1
    let lhs = substitute(system('git rev-parse --abbrev-ref HEAD'), "\n$", "", "")
  else
    let lhs = a:1
    let rhs = a:2
  endif

  let cmd = printf('git diff %s..%s --name-status --diff-filter=AM', lhs, rhs)
  cexpr system(cmd)                                                              " (1)
  cwindow
endfunction "}}}

command! -n=* -complete=customlist,s:GetBranches Gitquick call s:DiffBranch(<f-args>)

" function! s:Test()
"   echo 'user command!'
" endfunction

" command! Test call s:Test()

let &cpo = s:save_cpo
unlet s:save_cpo
" vim:set fenc=utf-8 ff=unix ft=vim fdm=marker:
  • (1) 上の行で作ったgit diff コマンドを実行した結果を、(2)で指定したフォーマットに入れてQuickFixリストでファイル一覧をつくります。
わからなかったこと 参考になったページやVimのヘルプ
QuickFixリストとは? :help quickfix
QuickFixリストへの流し込み方 :help cexpr
独自のエラーフォーマットを追加する方法 :help errorformat

ひととおりプラグインの機能が実装できました!

6. 関数をautoloadに寄せる

ここで、すこし工夫してみます。
plugin/xxx.vimの行数が増えてくるとプラグインを読み込むのに時間がかかり
いくら早いVimといえど、起動速度が気になるようになってしまいます。
今回は小さい規模のプラグインなのでほぼ影響がないですが、
pluginファイルは小さい方がよいです。

あと、せっかくなのでブランチの一覧を取得する関数はほかのプラグインからも呼べると便利かもしれません。


これらを満たすために「autoload」という仕組みを使ってみたいと思います。
autoloadというディレクトリの下に、gitquick.vimというファイルを置き、
ブランチ一覧を取得するgitquick#get_branches()関数を作ります。

  • autoload/gitquick.vim
let s:save_cpo = &cpo
set cpo&vim

function! gitquick#get_branches() "{{{
  let branches = []
  for branch in split(system('git branch'), "\n")
    call add(branches, matchstr(branch, '\([\*\s]*\)\zs[0-9A-Za-z/_.-]\+\ze\($\)'))
  endfor
  return branches
endfunction "}}}

let &cpo = s:save_cpo
unlet s:save_cpo
" vim:set fenc=utf-8 ff=unix ft=vim fdm=marker:
  • plugin/gitquick.vim
if exists('g:loaded_gitquick_vim')
  finish
endif
let g:loaded_gitquick_vim = 1

let s:save_cpo = &cpo
set cpo&vim

set errorformat+=%m\	%f

function! s:GetBranches(ArgLead, CmdLine, CursorPos) "{{{
  let list = gitquick#get_branches()
  return filter(list, 'v:val =~ "^'. fnameescape(a:ArgLead) . '"')
endfunction "}}}

function! s:DiffBranch(...) "{{{
  if a:0 == 0 || !executable('git')
    return
  endif

  if a:0 == 1
    let rhs = a:1
    let lhs = substitute(system('git rev-parse --abbrev-ref HEAD'), "\n$", "", "")
  else
    let lhs = a:1
    let rhs = a:2
  endif

  let cmd = printf('git diff %s..%s --name-status --diff-filter=AM', lhs, rhs)
  cexpr system(cmd)
  cwindow
endfunction "}}}

command! -n=* -complete=customlist,s:GetBranches Gitquick call s:DiffBranch(<f-args>)

" function! s:Test()
  " echo 'user command!'
" endfunction

" command! Test call s:Test()

let &cpo = s:save_cpo
unlet s:save_cpo
" vim:set fenc=utf-8 ff=unix ft=vim fdm=marker:

autoloadの関数名はディレクトリ名とファイル名によって決まります。
autoload/gitquick.vim のなかの get_branches() なので、gitquick#get_branches() になります。
autoload/hoge/foo.vim のなかの bar() だと、hoge#foo#bar() になります。

わからなかったこと 参考になったページやVimのヘルプ
autoloadとは :help write-library-script, :help autoload

これですっきり整理できました!

おわりに

ながながと書いてしまいましたが、プラグインを書くのにどのあたりのヘルプを見たのかを綴ってみました。
これが誰かのVimプラグインを書くきっかけになったらとてもうれしいです。

あとはプラグインのヘルプを書いていないので、id:cohamaさんのはじめてプラグインを作ってみた。それとhelpの書き方など - 反省はしても後悔はしない。を参考にされるとよいと思います。


僕は英語が読み書きが満足にできないので、日本語のヘルプに頼りました。
NeoBundleでしたらプラグインと同じようにすぐにインストールできるので、
同じような方がいらっしゃったら是非インストールされることをおすすめします。

NeoBundle 'vim-jp/vimdoc-ja'

Vim documentation: helpでブラウザ上でも閲覧できますが、
オフラインですぐにヘルプで引くことができるのは便利です。
Vimキーバインドでジャンプして辿れますし、
ヘルプ中の例文をヤンクして :@" コマンドで実際に実行し確認することもできます。

Vimのヘルプは勉強になることばかりですね!


次回は @ さんです。