週刊 git GUIクライアントを作る [2] stage/unstage不完全攻略編

[週刊 git GUIクライアントを作る [1] stage/unstage基礎知識編](https://qiita.com/gitcommitamend/items/f19d05b8db5390b9b647)の続きです。

# まだGUIの話はしません

JGitを使い始めて諦めるまでの軌跡です。
JGitの入門としても読めるかもしれません。
まだGUIの話はしません。

# libgit2とLanguage Bindings

[libgit2](https://libgit2.github.com/)というCのライブラリがあります。
Cとの連携（？）ができる言語であれば、
それを使ってgitアプリケーションやライブラリを作ることができます。
https://github.com/libgit2/libgit2#language-bindings
使いたい言語で良い感じのライブラリがあれば、
自分のアプリケーションで使う、あるいは、それを参考にlibgitを組み込む、ということになります。
[A2.2 Appendix B: Gitをあなたのアプリケーションに組み込む - Libgit2を使う方法](https://git-scm.com/book/ja/v2/Appendix-B:-Git%E3%82%92%E3%81%82%E3%81%AA%E3%81%9F%E3%81%AE%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%81%AB%E7%B5%84%E3%81%BF%E8%BE%BC%E3%82%80-Libgit2%E3%82%92%E4%BD%BF%E3%81%86%E6%96%B9%E6%B3%95)

# 環境と戦わない

例えば、私はiOS/Androidアプリ開発者の端くれで、
JavaScriptとTypeScriptをハイブリッドアプリで少々、という感じです。
TypeScriptを書きたかったので[nodegit](https://github.com/nodegit/nodegit)を試しましたが、
手元の環境だとElectronで動かせなかったので諦めました。
Atomの [git-utils](https://github.com/atom/git-utils) も同じくElectronで動かせず。


というわけで、Javaにします。
AndroiderなのでJava8というだけで感動できそうです。

上述のlanguage-bindingsには[jagged](https://github.com/ethomson/jagged)がありますが、

>This is very experimental, very mediocre JNI bindings for libgit2.

JNIにも慣れていないので、ここは大人しく[JGit](https://www.eclipse.org/jgit/)を使うことにしました。

# JGit

結論から言うと、いろいろ足りません。
また、libgit2へのbindingではなく独自実装のようで、gitコマンドとは挙動に違いがあります。

JGitに限らず、各言語のライブラリが必要なgitの機能を全て持っているとは限りませんが、
「Diffを取得して表示する」等、GUIの枠組みを作るまでならだいたい大丈夫だろうと思います。

なお、利用したバージョンは[4.8.0.201706111038-r](https://mvnrepository.com/artifact/org.eclipse.jgit/org.eclipse.jgit/4.8.0.201706111038-r)でした。

```build.gradle
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](http://download.eclipse.org/jgit/site/4.8.0.201706111038-r/apidocs/org/eclipse/jgit/api/DiffCommand.html)に各種オプションを設定して、`call()`を呼びます。

###その他のコマンドも同様

diff以外のコマンドも同様のインターフェイスとなっています。

 1. gitコマンドに相当するクラスのインスタンスを取得する
 2. オプションを設定する
 3. `call()`メソッドで結果を取得する

 ※ただし、`call()`の結果がgitコマンドの結果や出力に相当するわけではありません。

###DiffFormatter

`git.diff().call()`で[DiffEntry](http://download.eclipse.org/jgit/site/4.8.0.201706111038-r/apidocs/org/eclipse/jgit/diff/DiffEntry.html)を取得してから改めてOutputStreamに吐き出すこともできます。[DiffFormatter](http://download.eclipse.org/jgit/site/4.8.0.201706111038-r/apidocs/org/eclipse/jgit/diff/DiffFormatter.html)というクラスを利用します。 （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`](https://git-scm.com/docs/git-hash-object/2.6.3)コマンドで作ることもできます。

DiffFormatterはdiffの対象を[ContentSource](http://download.eclipse.org/jgit/site/4.8.0.201706111038-r/apidocs/org/eclipse/jgit/diff/ContentSource.html)の対である[ContentSource.Pair](http://download.eclipse.org/jgit/site/4.8.0.201706111038-r/apidocs/org/eclipse/jgit/diff/ContentSource.Pair.html)として持ちます。
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)`](http://download.eclipse.org/jgit/site/4.8.0.201706111038-r/apidocs/org/eclipse/jgit/diff/DiffFormatter.html#scan-org.eclipse.jgit.treewalk.AbstractTreeIterator-org.eclipse.jgit.treewalk.AbstractTreeIterator-)メソッドは、この目的のために存在しているわけではないのですが、これ以外に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](http://download.eclipse.org/jgit/site/4.8.0.201706111038-r/apidocs/org/eclipse/jgit/api/ApplyCommand.html)があります。
そして、JavaDocのクラスの説明には、こうあります。

> Apply a patch to files and/or to the index.

しかし、ApplyCommandはindexにpatchをapplyすることができません。canのnotです。
DiffCommandと違ってsetCached()のようなメソッドも存在しません。
[ソース](https://github.com/eclipse/jgit/blob/6dab29f4b578bc164acb77083440e4087ed94ff4/org.eclipse.jgit/src/org/eclipse/jgit/api/ApplyCommand.java#L262)を見ても、最終的に、ファイルを書き込んで、パーミッションを設定しているだけのようでした。

```https://github.com/eclipse/jgit/blob/6dab29f4b578bc164acb77083440e4087ed94ff4/org.eclipse.jgit/src/org/eclipse/jgit/api/ApplyCommand.java#L262
		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」だけならできることは確認しました。

1. ファイルを変更する
2. patchを作成する
3. ファイルの変更を戻す
4. ApplyCommandに対しpatchを渡す

この2.でDiffCommandを使ってファイルに出力し、4.でApplyCommandにファイルを渡します。
ここまでの総集編ですね。

### AddCommandとResetCommand

ファイル単位のstageを行う`git add`、これに相当する[AddCommand](http://download.eclipse.org/jgit/site/4.8.0.201706111038-r/apidocs/org/eclipse/jgit/api/AddCommand.html)があります。
そもそも`git add`以上の機能を持っているとは考えづらいですが、
JGitはgitを完コピしたライブラリではありませんので、そこに期待しつつメソッド一覧を見てみると、
`AddCommand setWorkingTreeIterator(WorkingTreeIterator f)`に渡すWorkingTreeIteratorのチカラワザで何とかなるかもしれないと微かに希望が湧いてきます。
しかし、[ResetCommand](http://download.eclipse.org/jgit/site/4.8.0.201706111038-r/apidocs/org/eclipse/jgit/api/ResetCommand.html)には同様のメソッドがありませんので、深追いはやめましょう。

それなりに真剣に、それなりに適当に、それっぽいファイル探してみましたが、見つけられませんでした。
短い付き合いでしたが、そろそろJGitとはお別れのようです。

### CRLF

お別れの前にもう一点。

普通（？）Windows環境だとCRLFで改行します。

そしてautocrlf機能をtrueにして、LFでコミットし、リモートからはCRLFでフェッチする、
というのが公式には推奨されているはずです。インストーラでrecommendとあった気がします。
（ただし、LFのままフェッチしたい場合もあるので悩ましいですよね。）

JGitはapplyする前にワークスペースとpatchが前提している状況が一致しているかどうかチェックします。
この比較の際に、CRLFを考慮していないようで、各行は末尾にCRがついたものとして解釈されます。
![cr.PNG](https://qiita-image-store.s3.amazonaws.com/0/202327/c69b724d-38a3-098c-0378-7c2c7fb79c5c.png)
そして各行が一致しないため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なんてやってないんでしょうか。
それならそれで自分で作る意味が出てくるというものです。

# 編集後記

前号の[「基礎知識編」](https://qiita.com/gitcommitamend/items/f19d05b8db5390b9b647)を書く前にも感じたんですが、
gitコマンドを使えば使うほど、CLIに慣れてしまいます。
人によってはgit GUIクライアントを作る動機を失ってしまいますので注意しましょう。