Git GUIでパッチの一部をコミットする機能の日本語対応

# パッチの一部をコミットする機能とは

Git には`git add -p`で変更の一部だけをステージ→コミットする機能がある。
そして、これをGit GUIで行うと直感的に操作することができてとても良い。

### Git GUIでの操作例

以下の例では2箇所の変更がファイル「ふうばあ.md」にあり、その上の部分だけをステージしている。

1. ステージしたいファイルを選び、右ペインの変更箇所を選んで右クリック
2. 「Stage Hunk For Commit」を選ぶか、図の例のように変更を反映したい行を選択した状態で「Stage Lines For Commit」を選ぶ

    <img width="515" alt="step1.png" src="https://qiita-image-store.s3.amazonaws.com/0/44797/b6b98d0a-638f-45c8-9af9-e0bb060b2c27.png">

3. 以下の例のように選択部分だけがインデックスに追加され、選択されなかった部分はUnstage Changesに残る

    <img width="587" alt="step2.png" src="https://qiita-image-store.s3.amazonaws.com/0/44797/22eb4c51-9ed2-10ab-13dd-b3cd4bf98a16.png">


# 問題

Gitの日本語に関する問題はだいぶ緩和したがGit for Windowsではまだ問題がある。

操作対象のファイルにマルチバイト文字を含んでいるとこの機能が失敗する。実際、上の例は対処をしなければ以下のように失敗する。メッセージ中のファイル名がバケバケなのがわかる。

### 失敗の例

<img width="434" alt="error.png" src="https://qiita-image-store.s3.amazonaws.com/0/44797/d3d7e014-0a81-866c-a07b-39f3cca54020.png">

# 対象バージョン

[Git for Windows](https://gitforwindows.org/) version 2.18.0で確認

```console
$ git --version
git version 2.18.0.windows.1
```

# 対処

以下の2種類のパッチを考えた。このどちらかを

`"C:\Program Files (x86)\Git\mingw64\share\git-gui\lib\diff.tcl"`

このファイルに適用することで問題を解消することができる。

### パッチ１

```diff
--- diff.tcl.orig	2018-06-22 11:12:04.000000000 +0900
+++ diff.tcl	2018-07-18 22:49:38.130462100 +0900
@@ -603,11 +603,14 @@
 		set e_lno end
 	}
 
+	set current_diff_header [replace_diff_header $current_diff_header $current_diff_path]
+
 	if {[catch {
 		set enc [get_path_encoding $current_diff_path]
 		set p [eval git_write $apply_cmd]
-		fconfigure $p -translation binary -encoding $enc
+		fconfigure $p -translation binary -encoding utf-8
 		puts -nonewline $p $current_diff_header
+		fconfigure $p -translation binary -encoding $enc
 		puts -nonewline $p [$ui_diff get $s_lno $e_lno]
 		close $p} err]} {
 		error_popup "$failed_msg\n\n$err"
@@ -640,6 +643,21 @@
 	}
 }
 
+proc replace_diff_header {diff_header path_name} {
+
+    set index_line {}
+
+	if {[regexp -line {^(index.*)$} $diff_header - index_line]} {
+		return "diff --git a/$path_name b/$path_name
+$index_line
+--- a/$path_name
++++ b/$path_name
+"
+	} else {
+		return $diff_header
+	}
+}
+
 proc apply_range_or_line {x y} {
 	global current_diff_path current_diff_header current_diff_side
 	global ui_diff ui_index file_states
@@ -822,11 +840,14 @@
 		set first_l [$ui_diff index "$next_l + 1 lines"]
 	}
 
+	set current_diff_header [replace_diff_header $current_diff_header $current_diff_path]
+
 	if {[catch {
 		set enc [get_path_encoding $current_diff_path]
 		set p [eval git_write $apply_cmd]
-		fconfigure $p -translation binary -encoding $enc
+		fconfigure $p -translation binary -encoding utf-8
 		puts -nonewline $p $current_diff_header
+		fconfigure $p -translation binary -encoding $enc
 		puts -nonewline $p $wholepatch
 		close $p} err]} {
 		error_popup "$failed_msg\n\n$err"
```

### パッチ２

```diff
--- diff.tcl.orig	2018-06-22 11:12:04.000000000 +0900
+++ diff.tcl	2018-07-18 22:51:35.600088600 +0900
@@ -362,9 +362,9 @@
 	set ::current_diff_inheader 1
 	fconfigure $fd \
 		-blocking 0 \
-		-encoding [get_path_encoding $path] \
+		-encoding utf-8 \
 		-translation lf
-	fileevent $fd readable [list read_diff $fd $conflict_size $cont_info]
+	fileevent $fd readable [list read_diff $fd $conflict_size $cont_info [get_path_encoding $path]]
 }
 
 proc parse_color_line {line} {
@@ -392,7 +392,7 @@
 	return [list $result $markup]
 }
 
-proc read_diff {fd conflict_size cont_info} {
+proc read_diff {fd conflict_size cont_info diff_enc} {
 	global ui_diff diff_active is_submodule_diff
 	global is_3way_diff is_conflict_diff current_diff_header
 	global current_diff_queue
@@ -410,11 +410,15 @@
 		    || [string match {diff --cc *}       $line]
 		    || [string match {diff --combined *} $line]} {
 			set ::current_diff_inheader 1
+			fconfigure $fd -encoding utf-8
 		}
 
 		# -- Check for end of diff header (any hunk line will do this).
 		#
-		if {[regexp {^@@+ } $line]} {set ::current_diff_inheader 0}
+		if {[regexp {^@@+ } $line]} {
+			set ::current_diff_inheader 0
+			fconfigure $fd -encoding $diff_enc
+		}
 
 		# -- Automatically detect if this is a 3 way diff.
 		#
@@ -429,6 +433,7 @@
 			if {   [string match {Binary files * and * differ} $line]
 			    || [regexp {^\* Unmerged path }                $line]} {
 				set ::current_diff_inheader 0
+				fconfigure $fd -encoding $diff_enc
 			} else {
 				append current_diff_header $line "\n"
 			}
@@ -606,8 +611,9 @@
 	if {[catch {
 		set enc [get_path_encoding $current_diff_path]
 		set p [eval git_write $apply_cmd]
-		fconfigure $p -translation binary -encoding $enc
+		fconfigure $p -translation binary -encoding utf-8
 		puts -nonewline $p $current_diff_header
+		fconfigure $p -translation binary -encoding $enc
 		puts -nonewline $p [$ui_diff get $s_lno $e_lno]
 		close $p} err]} {
 		error_popup "$failed_msg\n\n$err"
@@ -825,8 +831,9 @@
 	if {[catch {
 		set enc [get_path_encoding $current_diff_path]
 		set p [eval git_write $apply_cmd]
-		fconfigure $p -translation binary -encoding $enc
+		fconfigure $p -translation binary -encoding utf-8
 		puts -nonewline $p $current_diff_header
+		fconfigure $p -translation binary -encoding $enc
 		puts -nonewline $p $wholepatch
 		close $p} err]} {
 		error_popup "$failed_msg\n\n$err"
```

# 解説

Git GUIではファイルのエンコーディングを.gitattributesで指定したencodingパラメータの値でデコードしようとする。このデフォルト値は日本版Windowsではcp932でとなっているので何も指定しなければファイルの内容についてはcp932とみなされる。

一方、git diff の出力は

```diff
diff --git a/パス名 b/パス名
index xxxx yyyy 06000
--- a/パス名
+++ b/パス名
 foo
 bar
-baz
+foo
 foo
 bar
```

という形式であるが、最初の4行のヘッダ部分をGit for WindowsのGitはUTF-8で出力する。

そして、Git GUIがパッチの内容をステージするときこのdiffの出力から対象のファイルを決定しようとするが、UTF-8の出力をcp932とみなしてデコードするために文字化けを起こす。

対処に示したパッチ１では、ヘッダ部分を自分で作成し直すことで対処している。パッチ2ではdiffの出力を読むとき最初の4行はUTF-8として読み込み、残りはファイルのエンコーディングで読み込むことで文字化けを起こさない対処をしている。

この解析結果から、ファイルを常にUTF-8で扱うようにすれば本記事のパッチがなくても正しく動作することがわかる。したがって、

```text:.gitattributes
* encoding=utf-8
```

などとファイルのエンコーディングを常にUTF-8であるとして、実際に作成するファイルもUTF-8にすればパッチは必要なくなると思われる。しかし、Windows環境でそのようなことが可能なのか？は疑問。

もうひとつ、後で気づいたのだが

```console
git config core.quotepath true
```

としてても問題は発生しないようだ。しかし、これを行うとgit statusの出力が見づらくなるのでGit GUIの中だけこのような設定が適用できれば一番いいかもしれない。

# その他

* Git GUIでパッチの一部をコミットする機能の使い方と問題点を示した。しかし、今流行のVisual Studio Codeでも同じ操作はできるし、日本語に関する問題も発生しない。

* Git GUIの差分表示に `git diff -w`(空白の差異を表示しない)を使っているとこの記事の対処の有無に関わらず、パッチの一部をステージするときに失敗する。

`git diff -w` を使えないことの対策に、Ignore whitespaceのトグルボタンを追加してみました。(以下は、[git](https://github.com/git/git)に対するパッチになってます）

```patch
diff --git a/git-gui/git-gui.sh b/git-gui/git-gui.sh
index 6de74ce63..3f1300db7 100755
--- a/git-gui/git-gui.sh
+++ b/git-gui/git-gui.sh
@@ -3458,4 +3458,9 @@ trace add variable current_diff_path write trace_current_diff_path
 
 gold_frame .vpane.lower.diff.header
+set is_ignore_whitespace 0
+${NS}::checkbutton .vpane.lower.diff.header.chk \
+	-text [mc "Ignore whitespace"] \
+	-variable is_ignore_whitespace \
+	-command reshow_diff
 tlabel .vpane.lower.diff.header.status \
 	-background gold \
@@ -3474,4 +3479,5 @@ tlabel .vpane.lower.diff.header.path \
 	-anchor w \
 	-justify left
+pack .vpane.lower.diff.header.chk -side left
 pack .vpane.lower.diff.header.status -side left
 pack .vpane.lower.diff.header.file -side left
diff --git a/git-gui/lib/diff.tcl b/git-gui/lib/diff.tcl
index 68c4a6c73..d86f1d4c6 100644
--- a/git-gui/lib/diff.tcl
+++ b/git-gui/lib/diff.tcl
@@ -289,4 +289,5 @@ proc start_show_diff {cont_info {add_opts {}}} {
 	global ui_diff ui_index ui_workdir
 	global current_diff_path current_diff_side current_diff_header
+	global is_ignore_whitespace
 
 	set path $current_diff_path
@@ -330,4 +331,7 @@ proc start_show_diff {cont_info {add_opts {}}} {
 	lappend cmd -p
 	lappend cmd --color
+	if {$is_ignore_whitespace} {
+		lappend cmd -w
+	}
 	set cmd [concat $cmd $repo_config(gui.diffopts)]
 	if {$repo_config(gui.diffcontext) >= 1} {
```

<img width="508" alt="スクリーンショット 2018-08-08 21.51.18.png" src="https://qiita-image-store.s3.amazonaws.com/0/44797/c8c2d6b6-fba5-7bc4-62b2-6a3e91a70950.png">