週刊 git GUIクライアントを作る [4] diff et al.


週刊とは何だったのか

# 前回までのまとめ

## [ \[1\] stage/unstage基礎知識編](https://qiita.com/gitcommitamend/items/f19d05b8db5390b9b647)

行単位のstage/unstageのためにやるべきこと5つ

`1. 元になるpatchとしてdiffを出力する`
`2. stage/unstageしたい行を選ぶ`
`3. hunkのボディを編集する`
`4. hunkのヘッダーを再計算する`
`5. 最終的なpatchをapplyする`

##  [ \[2\] stage/unstage不完全攻略編](https://qiita.com/gitcommitamend/items/3463d8f569c390be6801)

JGitを使ってみたら無理だったので、git.exeを使おうというお話でした。

## [ \[3\] stage/unstageパッチ編集編](https://qiita.com/gitcommitamend/items/c8d0cea9f6833942eaf2)

`3. hunkのボディを編集する`
`4. hunkのヘッダーを再計算する`

# 今回は

`1. 元になるpatchとしてdiffを出力する`

# diffだけじゃ足りない

diffを出すのは簡単です。

```
git diff -p
git diff -p --cached
```

これだと新規追加したばかりのuntrackedなファイルは含まれません。
なので[少しだけ手間をかけます](https://qiita.com/gitcommitamend/items/3463d8f569c390be6801#untracked-files)

> ``` git ls-files -o --exclude-standard ```
> 
> で一覧を取得して、次のコマンドでdiffを取得できます。
> 
> ``` git diff --no-index /dev/null [untracked file name] ```
> 
> Windowsでも /dev/null で比較してくれるので安心してください。
> 差異を見るだけなら新規ファイルなのでファイルそのものを見ればいいんですが、
> 今の文脈で欲しいのはapplyできるpatch形式のdiffですので、ここで紹介しました。

## diffをパースしてファイル名を取得（しない）

しかし、diffそのものだけでなく、いろんな場面でファイル名が必要になってきます。

### コマンドで取得

```
git diff --name-status 
git diff --name-status --cached
```

ステータス（新規追加、変更、リネーム、削除）も分かると便利なので
`--name-only`オプションより`--name-status`がいいでしょう。

ファイル名はdiffそのものに含まれていますが、困難な場合があります。

### [M] 変更

diffの先頭はこんな感じになっています

```
diff --git a/target.txt b/target.txt
index 0b669b6..5c30060 100644
--- a/target.txt
+++ b/target.txt
```

これならdiffからでも簡単に分かりそうですね。

### [A] 新規追加

新規追加の場合はこんな感じです。

```
diff --git a/end_with_linebreak/with space.txt b/end_with_linebreak/with space.txt
new file mode 100644
index 0000000..093dc04
--- /dev/null
+++ b/end_with_linebreak/with space.txt	
```

この例にあるように、ファイル名にスペースが入ると、1行目から取得するのはちょっと面倒です。
なので、`+++ b/`の行にあるファイル名を使うと楽です。

### [R] リネーム

リネームだとこんな感じで、さらに情報が増えてるので楽勝です。

```
diff --git a/names.txt b/names_.txt
similarity index 96%
rename from names.txt
rename to names_.txt
index bcfb685..f915764 100644
--- a/names.txt
+++ b/names_.txt
```

なお、リネームは基本的にindexにあるファイルのdiffであらわれます。
indexに上がっていないworktree上のファイル、つまり`git diff`コマンドでは

+ 削除（リネーム前のファイル名）
+ 新規追加（リネーム後のファイル名）

という形で現れます。
2つのファイル名を指定して比較すればリネーム扱いのdiffを取れるかもしれませんが、
その2つのファイルをどうやって見出すのか私にはわかりません。
`git diff --cached`の場合は、gitが見つけてくれます。


ちなみにバイナリだとこんな感じです。
`rename from` `rename to`が固定ではなく言語環境に依存してるとアレですが、
特に仕様の確認はしていません。

```
diff --git a/src/main/resources/icon.png b/src/main/resources/icon_white.png
similarity index 100%
rename from src/main/resources/icon.png
rename to src/main/resources/icon_white.png
```

### [D] 削除

削除の場合はこんな感じです。
ついにファイル名が1行目だけになりました。

```
diff --git a/src/main/java/NewView.java b/src/main/java/NewView.java
deleted file mode 100644
index e69de29..0000000
```

### 要するに

+ `+++ b/`があれば`+++ b/`から取るのが簡単
+ `+++ b/`が無くて`rename from` `rename to`があればそこから取る
+ 削除の場合は、どれも無いので`diff --git`の行から取得するしかないが、ファイルおよびディレクトリ名にスペースがある場合に備えてちょっとだけ頭をひねる必要がある

というわけなんですが、頭をひねりたくないので、おとなしくファイル名を取得します。

## 行単位ではないstage/unstage

作りたいのは行単位でstage/unstageできるアプリケーションです。
ですが、全てを行単位でやってると大変です。

### hunk単位のstage/unstage

hunk単位であれば、diffのhunkを抜き出して`git apply`します。
これは行単位でstage/unstageする場合の

`3. hunkのボディを編集する` 
`4. hunkのヘッダーを再計算する`

が省略されただけなので、特に問題はないはずです。
GUIとしても、初めからその機能を用意してました。

![進捗.gif](https://qiita-image-store.s3.amazonaws.com/0/202327/2cf8c186-09ef-f0d1-ab4a-20a6666962f2.gif)

このgifだと、
hunkのヘッダー部分（黒い背景のところ）をクリックすると、

+ 選択中の行が有ればその行を
+ 選択中の行が無ければhunk全体を

それぞれstage/unstageしています。

### ファイル単位でのstage/unstage

このアプリケーション自体のgitレポジトリでドッグフーディングしながら開発してると、
なかなか気づかなかったんですが、
新規プロジェクトだと、最初はどうしてもそれなりのファイル数になりますし、
新規追加ファイルは丸ごとstageしたいことがほとんどです。
ファイルごとに、あるいはファイルまとめて、stageしたくなります。

というわけで、さっき取得したファイル名を使います。
なんの変哲も無いコマンドですが、

```
git add -- [file paths]
git reset -- [file paths]
```

ファイル名には空白が含まれる場合がありますので、
常に1つ1つのファイル名をダブルクォーテーション`""`で挟みます。

### 余談

gitの操作には、いくつかのフェーズがあります。

+ stage/unstageからコミットまで
+ コミット履歴の閲覧、操作
+ ブランチの操作

gitのすべての操作を一つのアプリケーションで担うべきだとは私は（あんまり）考えません。
また、アプリケーション（あるいはその一部）がどういう単位で機能を切り出すべきか、
というのは、それなり（そして、一般に）難しい問題だと思います。

とはいえ、
行単位ではないものを含めてstage/unstageは1つのアプリケーションで行えるべきでしょう。

## 画像

いい感じのdiffを出せるといいですが、それは大変そうなので、
せめて単純に画像を表示したり、並べたりぐらいはしたいですね。

JavaだとImageIO.readが簡単そうでしたので、私はこれを使っています。
InputStreamかFileを渡すとBufferedImageを得られます。

### 存在しないファイル

現在ファイルとして存在してる場合は、単純にそのファイルのFileで問題ありません。

しかし、変更した場合や、画像を削除した場合、
変更前や削除前の画像はファイルとしては存在しません。

gitが変更あるいは削除を検知しているということは、
gitオブジェクトとしては存在するということなので、ハッシュが分かれば取得できます。
そしてハッシュはdiffの中に含まれているので、それを使います。

```
git cat-file -p [hash]
```

このコマンドはgitオブジェクト（圧縮済み）をブラウズするためのもので、
`-p`オプションの場合は、ファイルの内容（展開済み）がそのまま得られます。

私はgitコマンドをこんな感じでJavaから叩いていますが、
このprocess.getInputStream()で得られるInputStremeをImageIO.readに渡すだけでした。

```
final Runtime runtime = Runtime.getRuntime();
Process process = runtime.exec(command, null, workingDir.toFile());
```

### `similarity index 100%`

ところが、単なるリネームの場合、なぜかdiffにハッシュが含まれません。
以下は、先述の例と同じdiffです。

```
diff --git a/src/main/resources/icon.png b/src/main/resources/icon_white.png
similarity index 100%
rename from src/main/resources/icon.png
rename to src/main/resources/icon_white.png
```

そこで、以下のコマンドでファイル名からハッシュを取得してから画像を取得します。

```
git ls-files -s [file path]
```

リネーム後もリネーム前もgitオブジェクトとして存在しているため、
どちらの名前を使っても問題無いはずです。
（なぜか私はリネーム後の名前を使っていました。）

### メモリキャッシュ

例えばstage/unstageの前後で画像は変化しないので、キャッシュしておきたいですね。
何を基準にキャッシュするかというと、もちろんハッシュです。
リネームの場合を除くと、必ずdiffからハッシュを取得できるはずです。

ですが、混乱していたのか、何かの名残か、何も考えてなかったのか、
手元のコードでは、新規追加ファイルの場合にハッシュを取得しています。
参考までに、コマンドを紹介しておきます。

```
git hash-object [file path]
```

開発中のアプリケーションでは、
「選択中のファイルのdiffのみ表示する」という仕様にしています。
なので、キャッシュを使わないと、ファイル選択のたびに画像が読み込まれ作成されます。

手元の環境では、キャッシュを外すと、
外部コマンド待ちのときに出してる砂時計が一瞬見えるぐらいの違いです。
マシンの性能や画像の数によっては、キャッシュしないとつらいかなー、という印象です。

# 進捗

画面は開発中のものです。
実際の仕様とは異なる場合があります。 

![進捗_2.gif](https://qiita-image-store.s3.amazonaws.com/0/202327/3c58ca4b-e5c6-c514-a22b-b743457ce0ce.gif)

# あとがき

## 次回

たぶん `2. stage/unstageしたい行を選ぶ` になると思います。
いろいろ忘れかけてるので、早めに書きたいです。

## Swing

ところでSwingで書いてます。

Diffの表示と選択の部分は、最初はJListを使ってましたが、
JListはセル単位での更新ができないようなので、JTableに変えました。
画像の読み込みが終わったら画像のセルだけ表示と高さを変えたかったんです。

JListでやっていた表示とイベント処理をJTableで全く同じようにするのは、ちょっと手間でしたが、
幸いにも、JListでの画像表示に伴う表示のちらつきみたいな乱れを、JTable版では消せました。
（もったいなさと不安で、JList版にもフラグ1つで切り替えられるようにしていて、
　さっきあらためて動作確認しました。）

## RxJava/RxSwing

あと、僕はRxマンなので、RxJavaも使っています。
RxJava2.0対応のRxSwingが無かったので、Schedulerだけ自分で作りました。
SwingのUIスレッドであるEDT上で実行するSchedulerです。
RxJava1.0用のRxSwingと、RxAndroidを参考にしました。