週刊 git GUIクライアントを作る [1] stage/unstage基礎知識編の続きです。
まだGUIの話はしません
JGitを使い始めて諦めるまでの軌跡です。
JGitの入門としても読めるかもしれません。
まだGUIの話はしません。
libgit2とLanguage Bindings
libgit2というCのライブラリがあります。
Cとの連携(?)ができる言語であれば、
それを使ってgitアプリケーションやライブラリを作ることができます。
https://github.com/libgit2/libgit2#language-bindings
使いたい言語で良い感じのライブラリがあれば、
自分のアプリケーションで使う、あるいは、それを参考にlibgitを組み込む、ということになります。
A2.2 Appendix B: Gitをあなたのアプリケーションに組み込む - Libgit2を使う方法
環境と戦わない
例えば、私はiOS/Androidアプリ開発者の端くれで、
JavaScriptとTypeScriptをハイブリッドアプリで少々、という感じです。
TypeScriptを書きたかったのでnodegitを試しましたが、
手元の環境だとElectronで動かせなかったので諦めました。
Atomの git-utils も同じくElectronで動かせず。
というわけで、Javaにします。
AndroiderなのでJava8というだけで感動できそうです。
上述のlanguage-bindingsにはjaggedがありますが、
This is very experimental, very mediocre JNI bindings for libgit2.
JNIにも慣れていないので、ここは大人しくJGitを使うことにしました。
JGit
結論から言うと、いろいろ足りません。
また、libgit2へのbindingではなく独自実装のようで、gitコマンドとは挙動に違いがあります。
JGitに限らず、各言語のライブラリが必要なgitの機能を全て持っているとは限りませんが、
「Diffを取得して表示する」等、GUIの枠組みを作るまでならだいたい大丈夫だろうと思います。
なお、利用したバージョンは4.8.0.201706111038-rでした。
dependencies {
// https://mvnrepository.com/artifact/org.eclipse.jgit/org.eclipse.jgit
compile group: 'org.eclipse.jgit', name: 'org.eclipse.jgit', version: '4.8.0.201706111038-r'
}
##diff
テキストとして出力
diffをテキストとして取得するには、以下の通りです。
FileRepositoryBuilder.create()に渡すのはレポジトリのトップではなく、.git
ディレクトリです。
final File gitDir; // .gitディレクトリ
final Repository repository = FileRepositoryBuilder.create(gitDir)
final Git git = new Git(repository);
final ByteArrayOutputStream bos = new ByteArrayOutputStream();
final List<DiffEntry> entryList = git.diff()
.setCached(cached)
.setOutputStream(bos)
.call();
final String text = new String(bos.toByteArray(), StandardCharsets.UTF_8)
git.diff()
が返すDiffCommandに各種オプションを設定して、call()
を呼びます。
###その他のコマンドも同様
diff以外のコマンドも同様のインターフェイスとなっています。
- gitコマンドに相当するクラスのインスタンスを取得する
- オプションを設定する
-
call()
メソッドで結果を取得する
※ただし、call()
の結果がgitコマンドの結果や出力に相当するわけではありません。
###DiffFormatter
git.diff().call()
でDiffEntryを取得してから改めてOutputStreamに吐き出すこともできます。DiffFormatterというクラスを利用します。 (DiffCommandにOutputStreamを設定した場合も、call()
メソッドの内部でDiffFormatterが使われています。)
DiffFormatterとDiffEntryを使う場合には、注意する点があります。
その変更をstageしていない(indexに登録されていない)ワークスペースのdiffを見る場合です。
(もちろん、ワークスペースのdiffを見る場合、つまりgit diff
では、stageしていないのが普通の状況です。)
final Git git = new Git(repository);
final ByteArrayOutputStream bos = new ByteArrayOutputStream();
final List<DiffEntry> entryList = git.diff()
.setCached(false)
// .setOutputStream(bos)
.call();
final DiffFormatter fmt = new DiffFormatter(bos);
fmt.setRepository(repository);
fmt.format(entryList);
final String text = new String(bos.toByteArray(), StandardCharsets.UTF_8);
これだとorg.eclipse.jgit.errors.MissingObjectExceptionが吐かれます。
git add --cached
に相当するgit.diff().setCached(true).call()
の場合は問題はありません。
次に原因と対応方法を書きますが、実用上のメリットはありません。
上記のようなコードを書いて困っていたひと、困るひとに捧げます。
diffの対象は2種類ある
gitにおいてdiffの対象は2種類あります。
- ワークスペースのファイル
- blobオブジェクト
gitのデータベースは、ファイルを圧縮したものをファイルのハッシュ値で取り出せるようになっていて、それがblobオブジェクトです。git add
つまりstageすると、変更後のファイルから計算・作成され、indexに登録されます。また、git hash-object -w
コマンドで作ることもできます。
DiffFormatterはdiffの対象をContentSourceの対であるContentSource.Pairとして持ちます。
diffの対象の2種類に応じて、ContentSourceの作成方法も2つあります。
static ContentSource create(ObjectReader reader)
static ContentSource create(WorkingTreeIterator iterator)
ところが、format(DiffEntry ent)
した際には、ContentSource.Pairはどちらもblobオブジェクト用のContentSourceとなっています。
そして、stageしていないワークスペースの変更(変更後のファイル)は、gitのデータベース上にはblobオブジェクトとして存在しません。
その結果がorg.eclipse.jgit.errors.MissingObjectExceptionです。
これはFileHeader toFileHeader(DiffEntry ent)
メソッドの場合でも同様です。
このような場合に、git.diff().call()
内部では、(判定その他を取り除くと)以下のようなコードになっています。
これと同じことをすれば、正常にOutputStreamに出力されます。
fmt.setRepository(repository)
final AbstractTreeIterator oldTree = new DirCacheIterator(repository.readDirCache());
final AbstractTreeIterator newTree = new FileTreeIterator(repository);
fmt.scan(oldTree, newTree);
このList<DiffEntry> scan(AbstractTreeIterator a, AbstractTreeIterator b)
メソッドは、この目的のために存在しているわけではないのですが、これ以外にDiffFormatterのContentSourceを外部から変更する方法がありません。
さて、git.diff().call()
の返り値のDiffEntryからdiffを出力するには、DiffFormatterのscanメソッドとformatメソッドを使うということになりました。
しかし、scanメソッドの返り値もDiffEntryなので、git.diff().call()
は不要です、おつかれさまでした。
untracked files
意図した結果なのか、未実装なのか分かりませんが、JGitのDiffCommandではuntracked filesもdiffに含まれます。
つまり、git diff
相当のコマンドであるはずのgit.diff().call()
では、indexに登録されているuntracked filelobオブジェクトと、ワークスペースのファイルをそのまま比較しています。
コマンド | old | new |
---|---|---|
git diff |
ワークスペースのファイル(untracked filesを除く) | indexに登録されているblobオブジェクト |
git.diff().call() by JGit |
ワークスペースのファイル(untracked filesを含む) | indexに登録されているblobオブジェクト |
diffを見たいときにuntracked filesを見たくないときはあんまり無いと思うので、JGitの挙動はそんなに問題無いように思います。オプションで指定できるのがベストでしょうけど。
gitコマンドでuntracked filesのdiffを出すのは少し面倒です。
git ls-files -o --exclude-standard
で一覧を取得して、次のコマンドでdiffを取得できます。
git diff --no-index /dev/null [untracked file name]
Windowsでも /dev/null で比較してくれるので安心してください。
差異を見るだけなら新規ファイルなのでファイルそのものを見ればいいんですが、
今の文脈で欲しいのはapplyできるpatch形式のdiffですので、ここで紹介しました。
ワークスペース?ワークツリー?
「ワークスぺースのdiff」と表現すると、diffにuntracked filesが含まれないのは奇妙な感じがします。
しかし、そもそもgit diff
は「ワークスペース」とindexのdiffなんでしょうか?
少なくとも「ワークスペース」はgitの用語ではありませんので、
おそらく、勝手な言葉を使って、勝手に戸惑っている、というのが実情でしょう。
公式ドキュメントではworkspaceという言葉はほぼ使われず、
https://git-scm.com/search/results?search=workspace
https://git-scm.com/search/results?search=work%20space
worktreeという言葉がたくさん使われています。
https://git-scm.com/search/results?search=worktree
https://git-scm.com/search/results?search=work%20tree
worktreeという言葉で正確に何が意味されているか把握していないので、これは課題です。
apply
ApplyCommandとgit apply --cached
**「行単位でstageするには行単位の変更を反映したpatchを作ってgit apply --cached
する」**というのが前号で得た結論でした。
(行単位のunstageのためにはgit apply -R --cached
です。)
diffに対するDiffCommandのように、applyにはApplyCommandがあります。
そして、JavaDocのクラスの説明には、こうあります。
Apply a patch to files and/or to the index.
しかし、ApplyCommandはindexにpatchをapplyすることができません。canのnotです。
DiffCommandと違ってsetCached()のようなメソッドも存在しません。
ソースを見ても、最終的に、ファイルを書き込んで、パーミッションを設定しているだけのようでした。
FileWriter fw = new FileWriter(f);
fw.write(sb.toString());
fw.close();
getRepository().getFS().setExecute(f, fh.getNewMode() == FileMode.EXECUTABLE_FILE);
困りましたね。
ここまでの総集編としての git apply
以下の手順で、「Apply a patch to files」だけならできることは確認しました。
- ファイルを変更する
- patchを作成する
- ファイルの変更を戻す
- ApplyCommandに対しpatchを渡す
この2.でDiffCommandを使ってファイルに出力し、4.でApplyCommandにファイルを渡します。
ここまでの総集編ですね。
AddCommandとResetCommand
ファイル単位のstageを行うgit add
、これに相当するAddCommandがあります。
そもそもgit add
以上の機能を持っているとは考えづらいですが、
JGitはgitを完コピしたライブラリではありませんので、そこに期待しつつメソッド一覧を見てみると、
AddCommand setWorkingTreeIterator(WorkingTreeIterator f)
に渡すWorkingTreeIteratorのチカラワザで何とかなるかもしれないと微かに希望が湧いてきます。
しかし、ResetCommandには同様のメソッドがありませんので、深追いはやめましょう。
それなりに真剣に、それなりに適当に、それっぽいファイル探してみましたが、見つけられませんでした。
短い付き合いでしたが、そろそろJGitとはお別れのようです。
CRLF
お別れの前にもう一点。
普通(?)Windows環境だとCRLFで改行します。
そしてautocrlf機能をtrueにして、LFでコミットし、リモートからはCRLFでフェッチする、
というのが公式には推奨されているはずです。インストーラでrecommendとあった気がします。
(ただし、LFのままフェッチしたい場合もあるので悩ましいですよね。)
JGitはapplyする前にワークスペースとpatchが前提している状況が一致しているかどうかチェックします。
この比較の際に、CRLFを考慮していないようで、各行は末尾にCRがついたものとして解釈されます。
そして各行が一致しないためorg.eclipse.jgit.api.errors.PatchApplyExceptionを吐きます。
なお、patch自体をCRLFにすると、有効なpatchとして認識されません。
これはgitコマンドでも同様で、
CLが正しいと考えていいようです。
次号予告
git.exe
至高のライブラリ探索の旅に出るという道もありますが、
JGitに限らず、libgit2へのbindingその他のライブラリでも、
機能が足りないという問題が出てくる可能性があります。
そこで、次号からは、最高の機能を誇るgitアプリケーションであるgit.exeを使います。
別プロセスを扱う点において課題が出てくるかもしれませんが、
他言語や他環境においても通じる知識を身に着けることができそうです。
workaround
意外にも(?)バグなのか仕様なのか分かりませんが、
gitコマンドでも、git本体に同梱されているgit-guiでも、
正常にstage/unstageできないパターンが、私が気づいただけでもいくつかあります。
次号は主にこうした問題とworkaroundの紹介となります。
ひょっとして世間様は行単位のstage/unstageなんてやってないんでしょうか。
それならそれで自分で作る意味が出てくるというものです。
編集後記
前号の「基礎知識編」を書く前にも感じたんですが、
gitコマンドを使えば使うほど、CLIに慣れてしまいます。
人によってはgit GUIクライアントを作る動機を失ってしまいますので注意しましょう。