LoginSignup
1
1

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-09-29

週刊 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でした。

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に各種オプションを設定して、call()を呼びます。

その他のコマンドも同様

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

  1. gitコマンドに相当するクラスのインスタンスを取得する
  2. オプションを設定する
  3. 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()のようなメソッドも存在しません。
ソースを見ても、最終的に、ファイルを書き込んで、パーミッションを設定しているだけのようでした。

//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があります。
そもそも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がついたものとして解釈されます。
cr.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なんてやってないんでしょうか。
それならそれで自分で作る意味が出てくるというものです。

編集後記

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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1