週刊 git GUIクライアントを作る [3] stage/unstageパッチ編集編


# 前回までのまとめ

週刊になってないので、おさらいします。

## [ \[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を使おうというお話でした。

> 至高のライブラリ探索の旅に出るという道もありますが、
> JGitに限らず、libgit2へのbindingその他のライブラリでも、
> 機能が足りないという問題は出てくる可能性があります。
> そこで、次号からは、最高の機能を誇るgitアプリケーションであるgit.exeを使います。

# 今回は

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

上記について、git-guiのコードをJavaに移植したコード辺をお披露目して、
そのままだと残ってしまう課題についての対応策を扱います。

# 確認環境

| tool | version |
| :--  | :-- |
| OS | Windows 10 Home |
| git.exe | 2.14.1.windows.1 |
| git-gui | 0.21 |
| SourceTree 2.3.1.0 | Windows版 2.3.1.0 |

# hunkの編集

## git-guiを読む

gitにはGUIアプリケーションとして[git-gui](https://github.com/git/git/tree/master/git-gui)が同梱されています。
このgit-guiでは、hunkの行を選択するとコンテクストメニューから「Stage Lines For Commit」ができます。
これはまさしく我々がやりたいことなので、ここのコードを読んでみましょう。
[diff.tcl](https://github.com/git/git/blob/master/git-gui/lib/diff.tcl)というファイルの[apply_range_or_line](https://github.com/git/git/blob/111ef79afe185f8731920569450f6a65320f5d5f/git-gui/lib/diff.tcl#L643)という関数（？）です。
Tclの基本文法と[Tkのテキストウィジェット](https://www.tcl.tk/man/tcl8.4/TkCmd/text.htm)をチラ見すれば読めます。

## 4J

テキストウィジェットの機能にかなり依存しているため、
慣れていない人間に読みづらいのはともかく、他言語や他環境への移植も面倒です。
というわけで、こちらにJavaへの[翻案](https://gist.github.com/guignol/3267a0a0783f4e03bdf0cd90137f5cb5)を用意しました。
（これぐらい自分でイチから書けよ、と言われそうな気もしますが）

diff.tclとの相違点は、以下の通りです。

+ 複数のhunkを扱わず、1つのhunkのみ扱う
+ コマンドまでは作成せず、パッチのみ作成

前提の説明も兼ねてメソッドの引数だけ説明すると、

```
    /**
     * @param diffHeader diff全体のヘッダー
     * @param hunkHeader hunkのヘッダー
     * @param hunkLines  hunkのボディ
     * @param range      選択範囲
     * @param unstage    unstageかどうか
     * @return 選択範囲を反映したパッチ文字列
     */
    static String edit(final String diffHeader,
                       final String hunkHeader,
                       final List<String> hunkLines,
                       final Range range,
                       final boolean unstage) {
```

> final String diffHeader,
> final String hunkHeader,
> final List<String> hunkLines,

最初の3つは、git diffで出力されたパッチを分解して抽出したものです。
hunkが1つの場合は、単に分解するだけです。
`1. 元になるpatchとしてdiffを出力する`の結果ですね。

> final Range range


選択範囲は、両端ともにinclusiveです。
`2. stage/unstageしたい行を選ぶ`というGUIでの操作に合わせました。

> final boolean unstage

stageするかどうかではなく、unstageかどうかを表すbooleanで扱っているのは、
自然にそうなってしまったんですが、あまり良くない感じはします。

# 課題

さて、hunkの編集は基本的にはgit-guiの真似をするだけなんですが、
それだとうまくいかない場合があります。

なお、先ほど紹介したgistのコードには、こうした課題の解決方法を含めていません。
git-guiを参考にした箇所と、独自の対応を切り離しておきたいからです。


## 末尾に改行が存在しないファイル

### 詳細

失敗する場合と、一見おかしいが正しい挙動の2パターンがあります。
ここでは失敗する場合のみ扱います。

変更前あるいは変更後のファイル末尾に改行が存在しない場合、
diffおよびパッチではファイル末尾行の次の行に`\ No newline at end of file`が挿入されます。

末尾がいろいろあるので混乱しないように注意しましょう。

+ 変更前のファイルの末尾
+ 変更後のファイルの末尾
+ パッチの末尾

例えば`git diff --cached test.txt`で上記のdiffつまりパッチが出力される場合に、

```
diff --git a/test.txt b/test.txt
index 85954ea..b3acb2e 100644
--- a/test.txt
+++ b/test.txt
@@ -1,5 +1,5 @@
-1
+one
 2
 3
 4
-5
\ No newline at end of file
+five
\ No newline at end of file
```

以下の部分だけunstageしたいとします。

```
-1
+one
```

`git reset -p` を経由して編集する場合に出るquick guideを参考にして、

```
# To remove '+' lines, make them ' ' lines (context).
# To remove '-' lines, delete them.
```

何も考えずに編集すると以下のようになりますが、

```
@@ -1,5 +1,5 @@
-1
+one
 2
 3
 4
\ No newline at end of file
 five
\ No newline at end of file
```

パッチにファイル末尾の行が含まれない場合、`\ No newline at end of file`は意味を成さないので削除するのが正解です。

```
@@ -1,5 +1,5 @@
-1
+one
 2
 3
 4
 five
\ No newline at end of file
```

### 他のGUIクライアントでは

git-guiでは、上記のようなunstageの場合、
つまり、**パッチの途中**に不要な`\ No newline at end of file` が存在する場合は失敗しますが、
stageの場合、つまり、**パッチの末尾**に`\ No newline at end of file` が存在する場合は成功します。

SourceTreeでは、stageもunstageも成功します。


### どうやって解決するか

`\ No newline at end of file`の直前にある連続する差分行の塊を、パッチから全て削除する場合、
無意味になってしまう`\ No newline at end of file`も削除します。

stageの場合を例にとります。

まず基本を確認しておくと、
stageしたい範囲に含まれる行は、元となるパッチの表現をそのまま使います。
stageしたい範囲に含まれない行、つまり、stageしない行のうち、「`+`から始まる行」は、
パッチのapply先であるindexに存在しないため、行ごとパッチから削除します。

`\ No newline at end of file`の直前に「`+`から始まる行」が1行だけある場合に、
その行をstageしないのであれば、その行だけでなく`\ No newline at end of file`も削除します。

`\ No newline at end of file`の直前に「`+`から始まる行」が2行以上続いている場合、
その連続する行（以下、「塊」と呼ぶ）の全体がstageしたい範囲に含まれないのであれば、
`\ No newline at end of file`も削除します。
その塊の一部がstageしたい範囲に含まれる場合、`\ No newline at end of file`は有意味なままなので残します。

## 新規ファイルの部分的なstage

### 詳細

hunk編集という意味では何の問題もありませんが、
他のGUIクライアントが対応していないため、ここで触れておきます。

### 他のGUIクライアントでは

git-guiもSourceTreeも対応していません。
どちらも表示内容に`\ No newline at end of file`が含まれないことから
そもそもdiffではなくファイル内容をそのまま表示しているんだろうと思われます。

### どうやって解決するか

[前号で書いたコマンド](https://qiita.com/gitcommitamend/items/3463d8f569c390be6801#untracked-files)でdiffを取得します。

> gitコマンドでuntracked filesのdiffを出すのは少し面倒です。
> ```
> git ls-files -o --exclude-standard
> ```
> 
> で一覧を取得して、次のコマンドでdiffを取得できます。
> 
> ```
> git diff --no-index /dev/null [untracked file name]
> ```

あとは、通常のdiffと同じです。

ところで、`\ No newline at end of file`の扱いをどうするか、という悩みがあります。
例えば以下のdiffに相当する新規ファイルがあるとします。

```
diff --git a/end_without_linebreak/test.txt b/end_without_linebreak/test.txt
new file mode 100644
index 0000000..15c491c
--- /dev/null
+++ b/end_without_linebreak/test.txt
@@ -0,0 +1,5 @@
+first
+second
+third
+forth
+fifth
\ No newline at end of file
```

このうち、次の2行だけstageしたい場合、

```
+first
+second
```

上述の`\ No newline at end of file`の扱いを考慮しつつhunkを編集すると、以下のようになります。

```
@@ -0,0 +1,2 @@
+first
+second
\ No newline at end of file
```

上記hunkを含んだパッチを適用した結果、worktreeには以下のdiffが生まれます。

```
diff --git a/end_without_linebreak/test.txt b/end_without_linebreak/test.txt
index 0bfc124..15c491c 100644
--- a/end_without_linebreak/test.txt
+++ b/end_without_linebreak/test.txt
@@ -1,2 +1,5 @@
 first
-second
\ No newline at end of file
+second
+third
+forth
+fifth
\ No newline at end of file
```

しかし、以下のようにhunkを編集してパッチを作成・適用して、

```
@@ -0,0 +1,2 @@
+first
+second
```

worktreeが以下の状態となるほうが、すっきりします。

```
diff --git a/end_without_linebreak/test.txt b/end_without_linebreak/test.txt
index 66a52ee..15c491c 100644
--- a/end_without_linebreak/test.txt
+++ b/end_without_linebreak/test.txt
@@ -1,2 +1,5 @@
 first
 second
+third
+forth
+fifth
\ No newline at end of file
```

**この挙動だと、ファイル末尾に改行コードが存在しないことが求められる場合には困ります**。
そういう状況自体が不幸なんじゃないかという気がしますが、大人しく諦めましょう。

いずれにせよ、より面倒な「新規ファイルの部分的なunstage」と挙動を合わせたほうがいいかと思います。


## 新規ファイルの部分的なunstage

### 詳細

これはgitのcliでもできません。
`error: new file [ファイル名] depends on old contents`というエラーが出ます
おそらく、/dev/null に対して差分となるパッチを適用する、という形になるからだと思います。

### 他のGUIクライアントでは

git-guiだと、何もメッセージを出さずに失敗します。

SourceTree の場合、Windows版のSourceTree 2.3.1.0で確認した限りでは、
新規ファイルのunstageはファイル単位のみで、
行単位で部分的にunstageする機能は無いようです。

これはgitに無い機能と言えそうなので「対応しない」というのも十分にアリかと思います。

### どうやって解決するか

一度ファイル全体をunstage（`git reset [ファイル名]`）してから、
選択範囲**以外**をstageすることで対応できます。

①hunkを全てunstageする
②選択行の**前**を全てstageする
③選択行の**後**を全てstageする

または

①hunkを全てunstageする
②選択行の**後**を全てstageする
③選択行の**前**を全てstageする

のどちらかになりますが、
いずれにせよ、いろんな面倒があるので、詳しい解説はしません。

# 進捗

というわけで、やってるぞ感だけ見せておきます。
左がworktreeおよびuntracked fileのdiffで
右がindexのdiffです。

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


# あとがき

## 次回予告

次はおそらく、
`1. 元になるpatchとしてdiffを出力する`
の話を書きます。

その次ぐらいに予定している、
`2. stage/unstageしたい行を選ぶ`
の話は主に、SwingのJListでのマウスドラッグによる複数選択について、になると思います。
そこそこ苦労しました。知らないコンポーネントで簡単にできたりしたらどうしよう。

## 「パッチ行をコミット予定に加える」

git-guiのレポジトリには
https://github.com/git/git/tree/master/git-gui/po に各言語ごとのメッセージが定義されていて、
日本語だと`Stage Lines For Commit`は「[パッチ行をコミット予定に加える](https://github.com/git/git/blob/111ef79afe185f8731920569450f6a65320f5d5f/git-gui/po/ja.po#L549)」のようですが、
git-guiの言語切替の方法を私は把握しておらず、未確認です。

[Git for Windows付属のGit GUIを日本語化する方法](https://qiita.com/aoyakimeshi/items/772ca912fe2ff657eea6)によると、

> Ver.2.x.xのバイナリには、多言語表示用のメッセージ定義ファイルが同梱されていない

だそうです。
自分では使わないので調べてませんが、この状況が続いているとするともったいないですね。