かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

git コミットログを綺麗にしたい。fixupとsquash

チーム開発をしているプロダクトで タイポを修正しただけとか、コミットログが本当にただの履歴になっているままPRをだしたりしてmasterにマージされてしまうとmasterブランチに本質ではないコミットというノイズが混ざり、後から遡って見づらくなったりしてしまいます。

PRの際にブランチのコミットを整理するルールや方法はチームやプロダクトによって違うと思いますが、 PRを出してコードレビューで指摘を受けた箇所を直し、最終的に修正部分のコミットを元のコミットに結合してコミットログを修正する方法を例にメモ

e.g.

例えば次のようなジャパリバスを直したPRのコミットログがあるとします

$ git log --oneline
ca284e8 (HEAD -> master) しうんてんをしたよ
30c99a8 バス的なものをボスとつないだよ
bb971d8 いすを取り付けたよ
6d8ee79 ハンドルを取り付けたよ
4917aae でんちを取り付けたよ
4d5b197 でんちを見つけてきたよ
332eb07 タイヤをさがしたよ
cdbe6ff バス的なものの足りないパーツを調べるよ

4917aaeで"でんち"を取り付けたつもりだったけどコードを繋ぎ忘れてたので修正したとします。
そのまま普通に修正コミットをすると

XXXXXX (HEAD -> master) でんちのコードつけわすれてた
ca284e8 しうんてんをしたよ
30c99a8 バス的なものをボスとつないだよ
bb971d8 いすを取り付けたよ
6d8ee79 ハンドルを取り付けたよ
4917aae でんちを取り付けたよ
4d5b197 でんちを見つけてきたよ
332eb07 タイヤをさがしたよ
cdbe6ff バス的なものの足りないパーツを調べるよ

みたいな感じになってしまうので、この修正は4917aae でんちを取り付けたよと一緒にしたい。

--fixup ・rebaseで修正のコミットをまとめる

fixupコミット(修正コミット)を作成する

git commit --fixup <target hash>

修正のコミットする際に、--fixup--fixup= に続けて一緒にしてしまいたいターゲットコミットのハッシュを指定します。
今回の例では4917aae でんちを取り付けたよと一緒にしたいので次のような感じでコミット

git commit --fixup 4917aae

コミットメッセージの入力はなく次のようなコミットが作成されました

(HEAD -> master) fixup! でんちを取り付けたよ

rebase -i --autosquash でコミットをまとめる

--autosquashオプションを付けてrebaseすると先のfixup!な修正コミットが自動的に合体させたいコミットと統合される。rebaseする時は修正するコミットの1つ前のコミットをターゲットに指定するので、今回の場合は4917aae でんちを取り付けたよにコミットを統合するので1つ前の4d5b197 でんちを見つけてきたよを指定する

$ git rebase -i --autosquash 4d5b197

エディタが開き、fixup!コミットがターゲットの下に自動的に移動している事を確認

pick 4917aae でんちを取り付けたよ
fixup 50689a2 fixup! でんちを取り付けたよ
pick 6d8ee79 ハンドルを取り付けたよ
pick bb971d8 いすを取り付けたよ
pick 30c99a8 バス的なものをボスとつないだよ
pick ca284e8 しうんてんをしたよ

:wqでファイルを保存して特に問題がなければrebaseが完了します。
コミットログを確認すると

$ git log --onelin
68155df (HEAD -> master) しうんてんをしたよ
c3fa632 バス的なものをボスとつないだよ
a81f850 いすを取り付けたよ
5e6545e ハンドルを取り付けたよ
9bc5ea3 でんちを取り付けたよ
4d5b197 でんちを見つけてきたよ
332eb07 タイヤをさがしたよ
cdbe6ff バス的なものの足りないパーツを調べるよ

fixup!コミットが消え、変更内容がターゲットにしていた でんちを取り付けたよ にまとめられました。

既にコミットされている場合

既に修正コミットがコミットされてしまっている場合は

$ git log --oneline
5eb844c (HEAD -> master) 修正 でんちコードつなぎわすれ
68155df しうんてんをしたよ
c3fa632 バス的なものをボスとつないだよ
a81f850 いすを取り付けたよ
5e6545e ハンドルを取り付けたよ
9bc5ea3 でんちを取り付けたよ
4d5b197 でんちを見つけてきたよ
332eb07 タイヤをさがしたよ
cdbe6ff バス的なものの足りないパーツを調べるよ

rebase -iで修正コミットをfixupコミットに変更してコミットログをまとめます。
まとめたいターゲットの1つまえのコミットを指定して

$ git rebase -i 4d5b197
  1. エディタが開くので修正するコミットのpickfixup (f)に変更
    pick 9bc5ea3 でんちを取り付けたよ
    pick 5e6545e ハンドルを取り付けたよ
    pick a81f850 いすを取り付けたよ
    pick c3fa632 バス的なものをボスとつないだよ
    pick 68155df しうんてんをしたよ
    fixup 5eb844c 修正 コードつなぎわすれ
    
  2. fixupにしたコミットの行を合体させたいコミットの下に移動 ( Viならddで1行カット、pでペースト )
    pick 9bc5ea3 でんちを取り付けたよ
    fixup 5eb844c 修正 コードつなぎわすれ
    pick 5e6545e ハンドルを取り付けたよ
    pick a81f850 いすを取り付けたよ
    pick c3fa632 バス的なものをボスとつないだよ
    pick 68155df しうんてんをしたよ
    
  3. ファイルを保存。rebaseが実行されコミットがマージされる

ログを確認するとfixupコミットの変更内容がfixupにしたコミットを移動させた上のコミットにマージされ、fixupにしたコミットメッセージは消え、元のコミットメッセージだけが残った状態になりコミットをまとめることが出来ました。

fixup と squash の違い

fixupに似たコミットをまとめられるものにsquashがあります。
rebase時に開くエディタの説明を引用すると、違いはコミットメッセージを残すかどうかということのようです。

  • squash (s): use commit, but meld into previous commit ... コミットメッセージを残し直前のコミットとまとめる
  • fixup (f): like "squash", but discard this commit's log message ... コミットメッセージを削除して直前のコミットとまとめる

e.g.

$ git log --oneline
a19959c (HEAD -> master) 修正 でんちの充電
3609605 しうんてんをしたよ
4a12fdd バス的なものをボスとつないだよ
24961f5 いすを取り付けたよ
6aa20fb ハンドルを取り付けたよ
56de548 でんちを取り付けたよ
4d5b197 でんちを見つけてきたよ
332eb07 タイヤをさがしたよ
cdbe6ff バス的なものの足りないパーツを調べるよ

a19959cのコミットを56de548のコミットにまとめたいと思います。
56de548の1つ前のコミットを指定してrebase

$ git rebase -i 4d5b197

エディタが開くのでa19959cpicksquash (s)に変更して、56de548の下に移動

pick 56de548 でんちを取り付けたよ
s a19959c 修正 でんちの充電
pick 6aa20fb ハンドルを取り付けたよ
pick 24961f5 いすを取り付けたよ
pick 4a12fdd バス的なものをボスとつないだよ
pick 3609605 しうんてんをしたよ

ファイルを保存してrebaseを完了させるとsquashにしたコミットが消えて、修正内容が56de548のコミットに合算されています。
コミットメッセージは

$ git log
...
commit 4d39d57aca4c7ae1a3ba48cc426b506e022db442
Author: KiKiKi 
Date:   Mon Feb 25 15:05:17 2019 +0900
    でんちを取り付けたよ

    修正 でんちの充電
...

fixupと異なり、元のコミットメッセージが合体させたコミットメッセージに追加され残っています。

squashでコミットする場合

fixupと同じようにsquash--squashオプションでコミットすることが出来ます。

$ git log --oneline
3609605 しうんてんをしたよ
4a12fdd バス的なものをボスとつないだよ
24961f5 いすを取り付けたよ
6aa20fb ハンドルを取り付けたよ
56de548 でんちを取り付けたよ
4d5b197 でんちを見つけてきたよ
332eb07 タイヤをさがしたよ
cdbe6ff バス的なものの足りないパーツを調べるよ
$ git commit --squash <target hash>

エディタが開くのでそのまま保存すると、squash! <ターゲットのコミットメッセージ>というコミットが作成されます。

3e22c20 (HEAD -> master) squash! でんちを取り付けたよ

--autosquashオプションを付けてrebaseの実行

$ git rebase -i --autosquash <まとめるコミットの1つ前のコミット>

エディタが開きsquash!のコミットが自動的にターゲットのコミットの下に移動しています

pick 56de548 でんちを取り付けたよ
squash 3e22c20 squash! でんちを取り付けたよ
pick 6aa20fb ハンドルを取り付けたよ
pick 24961f5 いすを取り付けたよ
pick 4a12fdd バス的なものをボスとつないだよ
pick 3609605 しうんてんをしたよ

ファイルを保存するとrebaseが実行される前に、squashで統合されるコミットのメッセージを修正するエディタが起動します。

 This is a combination of 2 commits.
# This is the 1st commit message:

でんちを取り付けたよ

# This is the commit message #2:

squash! でんちを取り付けたよ

squash!で始まるメッセージ部分を修正内容に変更して、でファイルを保存すればrebaseが実行され、先程変更したメッセージのコミットに変更内容が統合されました。

--squashでのrebaseはコミットが移動している所でメッセージをへんこうしても何故か、メッセージの修正エディタが開いて再度メッセージの編集を強いられてしまってメンドーだったので、コミットしてしまってたものをrebaseで手動で移動させるほうが楽な感じでした。

rebase時に自動的に--autosquashを付ける

--autosquashオプションとして長かったり、付けわすれてあれ?ってなったりしがちなので、 rebase -iの時に自動的に--autosquashオプションが付くようにしておくと楽です。

$ git config --global --add rebase.autosquash true

まとめと感想

--squashオプションでのコミットでの挙動的にgit rebase -i --autosquashfixup!squash!キーワードから始まるコミットを、キーワード以降の文字列が一致するコミットの下に移動させているだけなんじゃないかなという印象でした。
fixupなら修正コミットのメッセージが残らないので、--fixupオプションでコミットしてしまいrebaseするのが簡単で良さそうです。コミットメッセージを残すsquashなら残すべきメッセージでコミットしてしまってrebaseの時にpicksに変更し、統合したいコミットの下に移動させる方が楽かなという肌感でした。

git rebaseでコミットログを綺麗にした場合pushするには-fでforce pushしなければならなくなるので、チーム開発のPRなどの修正でも場合複数人がそのPRのブランチに関わっているとか、ステージングにそのブランチpullしたとかあると、rebaseしてのforce pushがあると結構メンドーな事にもなりかねないので、PR出した人がmasterにマージする前にrebaseするとかチームでルールの認識合わせが必要だなーと思いました。

個人的にはミス含め過去の履歴修正主義には否定的だったのですが、チーム開発でコミットの粒度が細かくコミット数の多いプロジェクトなら確かに遡って調べやすくなるように、ノイズになるコミットはrebaseでまとめてしまい読みやすいコミットログを作るってのは確かにアリだなと思いました。個人開発だとコミット量もそんなに増えないし、自分のやったことのログなのであまり他人が見てもわかりやすいログって今まで意識がなかったなーという気づきがありました。


[参考]

MySQL 重複したデータを1件だけ残して削除したい

テーブル table_a

id name typeID
1 星宮いちご 1
2 霧矢あおい 2
3 紫吹蘭 3
4 神崎美月 3
5 星宮いちご 1
6 大空あかり 1
7 藤堂ユリカ 2
8 有栖川おとめ 4
9 星宮いちご 1
10 霧矢あおい 2

このテーブルからnametypeIDが重複されているデータを最初の1件だけ残して削除したいと思います。

重複しているデータ

重複しているデータは下記のクエリで調べることが出来ました

SELECT * FROM table_a
WHERE (typeID, name) IN (
  SELECT typeID, name FROM table_a
  GROUP BY typeID, name
  HAVING COUNT(typeID) >= 2 AND COUNT(name) >= 2
);

+----+-----------------+--------+
| id | name            | typeID |
+----+-----------------+--------+
|  1 | 星宮いちご        |      1 |
|  2 | 霧矢あおい        |      2 |
|  5 | 星宮いちご        |      1 |
|  9 | 星宮いちご        |      1 |
| 10 | 霧矢あおい        |      2 |
+----+-----------------+--------+

このデータをそのまま削除するようにすると、重複しているデータ全てが削除されてしまいます。
グループ化したデータの最初の1件を除くか、最初の1件だけを取得できれば良いのですが…
例えばmin(id)で取得すると

SELECT min(id) FROM table_a
WHERE (typeID, name) IN (
  SELECT typeID, name FROM table_a
  GROUP BY typeID, name
  HAVING COUNT(typeID) >= 2 AND COUNT(name) >= 2
);

+---------+
| min(id) |
+---------+
|       1 |
+---------+

それぞれのグループからではなく、重複している全てのデータから最も小さいidのものと取得するので重複しているデータが複数ある場合は不適切です。
と、良い方法が思いつかなかったので方法を変えます。

重複のないユニークなデータだけのテーブルを作成し、それ以外を削除する

同じテーブルを比較して、nametypeIDが元のテーブルと同じで、idが元のテーブル以上のものが1件ならユニークなデータになりそうです。

SELECT * FROM table_a AS t1
WHERE 1 = (
  SELECT COUNT(*) FROM table_a AS t2
  WHERE t1.name = t2.name
    AND t1.typeID = t2.typeID
    AND t1.id >= t2.id
);

+----+-----------------------+--------+
| id | name                  | typeID |
+----+-----------------------+--------+
|  1 | 星宮いちご              |      1 |
|  2 | 霧矢あおい              |      2 |
|  3 | 紫吹蘭                |      3 |
|  4 | 神崎美月              |      3 |
|  6 | 大空あかり              |      1 |
|  7 | 藤堂ユリカ              |      2 |
|  8 | 有栖川おとめ             |      4 |
+----+-----------------------+--------+

ユニークなデータが取得できました。
このユニークなリスト以外を削除するか、カウントが1件より大きいものが重複しているデータなのでそれを削除するかすれば良さそうです。

ユニークなリスト以外の方が見通しが良さそうなのでそちらで

DELETE FROM table_a
WHERE id NOT IN (
  SELECT id FROM(
    SELECT * FROM table_a AS t1
    WHERE 1 = (
      SELECT COUNT(*) FROM table_a AS t2
      WHERE t1.name = t2.name
        AND t1.typeID = t2.typeID
        AND t1.id >= t2.id
    )
  ) AS tmp
);

> Query OK, 3 rows affected (0.10 sec)

SELECT * FROM table_a;
+----+-----------------------+--------+
| id | name                  | typeID |
+----+-----------------------+--------+
|  1 | 星宮いちご              |      1 |
|  2 | 霧矢あおい              |      2 |
|  3 | 紫吹蘭                |      3 |
|  4 | 神崎美月              |      3 |
|  6 | 大空あかり              |      1 |
|  7 | 藤堂ユリカ              |      2 |
|  8 | 有栖川おとめ             |      4 |
+----+-----------------------+--------+

DELETE, UPDATE時にサブクエリに同じ名前のテーブルがあるとエラーになるので、ユニークなリストを取得するサブクエリをAS tmpで別名扱いにしています。
これで無事重複しているデータはidが最小のもの1件を残して削除することができました!

MySQL 8.0 以上 WITH を使う

MySQL 8.0から使えるようになった WITH Syntax (Common Table Expressions) を使えば、テンポラリーなテーブルを作ることが出来るようなので本来は同じサブクエリーを複数使う時に威力を発揮するのだと思いますが、サブクエリーが入れ子になっているクエリの見通しを良くするのにも使えそうです。(メモリ消費はどうなんだ?って部分はあるかもですが…)

先の例だとユニークなリストを作成する部分をWITHにしてしまえば、見通しが良くなりそうです。

削除対象の重複しているデータを表示

WITH unique_list AS (
  SELECT * FROM table_a AS t1
  WHERE 1 = (
    SELECT COUNT(*) FROM table_a AS t2
    WHERE t1.name = t2.name
      AND t1.typeID = t2.typeID
      AND t1.id >= t2.id
  )
)
SELECT * FROM table_a
WHERE id NOT IN (SELECT id FROM unique_list);

+----+-----------------+--------+
| id | name            | typeID |
+----+-----------------+--------+
|  5 | 星宮いちご        |      1 |
|  9 | 星宮いちご        |      1 |
| 10 | 霧矢あおい        |      2 |
+----+-----------------+--------+

重複を削除

WITH unique_list AS (
  SELECT * FROM table_a AS t1
  WHERE 1 = (
    SELECT COUNT(*) FROM table_a AS t2
    WHERE t1.name = t2.name
      AND t1.typeID = t2.typeID
      AND t1.id >= t2.id
  )
)
DELETE FROM table_a
WHERE id NOT IN (SELECT id FROM unique_list);

> Query OK, 3 rows affected (0.09 sec)

SELECT * FROM table_a;
+----+-----------------------+--------+
| id | name                  | typeID |
+----+-----------------------+--------+
|  1 | 星宮いちご              |      1 |
|  2 | 霧矢あおい              |      2 |
|  3 | 紫吹蘭                |      3 |
|  4 | 神崎美月              |      3 |
|  6 | 大空あかり              |      1 |
|  7 | 藤堂ユリカ              |      2 |
|  8 | 有栖川おとめ             |      4 |
+----+-----------------------+--------+

WITHを使えば既に別名になっているので、DELETE, UPDATE時にサブクエリに同じ名前のテーブルがあるとエラーになる問題の回避も楽になります。それでもNOT IN unique_listとするとエラーになるので(SELECT 〜)しなければならないのですが...

重複しているデータの最後(idが最も大きい)ものを残して重複を削除したい

上記のidが最も小さいものを残すパターンの場合idを比較することで、ユニークなリストを作成できましたが、重複するデータでidが最も大きいものを残そうとした場合は別のアプローチが必要になりそうです。

重複しているデータでidが最大のもの以外

idが最大のものはnametypeIDが同じで、元テーブルのid以上のidの時は1度なので、idが元テーブルのid以上の回数が1回より多い場合がid最大値以外の重複しているデータになりそうです。

SELECT * FROM table_a AS t1
WHERE 1 < (
  SELECT COUNT(*) FROM table_a AS t2
  WHERE t1.name = t2.name
    AND t1.typeID = t2.typeID
    AND t1.id <= t2.id
);

+----+-----------------+--------+
| id | name            | typeID |
+----+-----------------+--------+
|  1 | 星宮いちご        |      1 |
|  2 | 霧矢あおい        |      2 |
|  5 | 星宮いちご        |      1 |
+----+-----------------+--------+

重複しているデータの削除

WITH dup_list AS (
  SELECT * FROM table_a AS t1
  WHERE 1 < (
    SELECT COUNT(*) FROM table_a AS t2
    WHERE t1.name = t2.name
      AND t1.typeID = t2.typeID
      AND t1.id <= t2.id
  )
)
DELETE FROM table_a
WHERE id IN (SELECT id FROM dup_list);

> Query OK, 3 rows affected (0.02 sec)

SELECT * FROM table_a;
+----+--------------------+--------+
| id | name               | typeID |
+----+--------------------+--------+
|  3 | 紫吹蘭            |      3 |
|  4 | 神崎美月          |      3 |
|  6 | 大空あかり          |      1 |
|  7 | 藤堂ユリカ          |      2 |
|  8 | 有栖川おとめ         |      4 |
|  9 | 星宮いちご          |      1 |
| 10 | 霧矢あおい          |      2 |
+----+--------------------+--------+

これで、重複しているものは最後のものだけ残して削除することが出来ました!

感想

最初は、同じテーブル同士で比較するアイディアが思いつかず、UNION ALLとか使ってユニークなデータを作成しようと頑張ってました。
それにしてもWITH便利ですね!MySQL では8.0からしでしか使えないみたいですが、WITHに慣れると使えないとつらみになりそうです。

SQL勉強しなければと思うと同時にMySQLPostgreSQLとか種類によって微妙に使える式が違うのが厄介です… (同じようなSQL文で検索結果に出てくるので、調べて出てきたのがPostgreSQLMySQLで試してみるとシンタックスエラーになったりとかと…つらみ


[参考]

SQLアンチパターン

SQLアンチパターン

MySQL グループ化した条件で取得したデータを削除 / 変更にハマる

テーブル table_a

id name typeID
1 星宮いちご 1
2 霧矢あおい 2
3 紫吹蘭 3
4 大空あかり 1

table_atypeIDカラムのデータが重複してるレコードを削除しようとして次のようなSQLを発行しました。

DELETE FROM table_a
WHERE typeID IN (
  SELECT typeID FROM table_a
  GROUP BY typeID
  HAVING COUNT(*) >= 2
);

You can't specify target table 'table_a' for update in FROM clauseというエラーになってしまいました。

どうやら、DELETE, UPDATEでデータを操作するテーブルと同じ名前がサブクエリ内にあるとダメなようです。

このエラーは、テーブルを変更し、さらにサブクエリーで同じテーブルから選択しようとする次のような場合に発生します。

UPDATE t1 SET column2 = (SELECT MAX(column1) FROM t1);
サブクエリーは SELECT ステートメントだけでなく、UPDATE および DELETE ステートメント内でも正当であるため、UPDATE ステートメント内の割り当てのためにサブクエリーを使用できます。ただし、サブクエリーの FROM 句と更新のターゲットの両方に同じテーブル (この場合は、テーブル t1) を使用することはできません。
出典: [https://dev.mysql.com/doc/refman/5.6/ja/subquery-errors.html:title]

サブクエリで取得してる部分を更にサブクエリにしてASで別名にして取得するようにすればOK

ニホンゴで書くと判りにくいけど、要はこんな感じ

DELETE FROM table_a
WHERE typeID IN (
  SELECT typeID FROM (
    SELECT typeID FROM table_a
    GROUP BY typeID
    HAVING COUNT(*) >= 2
  ) AS tmp
);

サブクエリで取得しているtypeIDカラムのデータが重複している部分が、DELETE する際のクエリではtmp扱いになって、同じテーブルの名が重複しなくなるということっぽい。

ただ上記のクエリでは、実際に削除するために選択しているレコードはこんな感じになります。

mysql >
SELECT * FROM table_a
WHERE typeID IN (
  SELECT typeID FROM table_a
  GROUP BY typeID
  HAVING COUNT(typeID) >= 2
);

+----+-----------------+--------+
| id | name            | typeID |
+----+-----------------+--------+
|  1 | 星宮いちご        |      1 |
|  4 | 大空あかり        |      1 |
+----+-----------------+--------+

なので実際に削除を実行すると…

mysql >
DELETE FROM table_a
WHERE typeID IN (
  SELECT typeID FROM (
    SELECT typeID FROM table_a
    GROUP BY typeID
    HAVING COUNT(*) >= 2
  ) AS tmp
);

Query OK, 2 rows affected (0.10 sec)

mysql > SELECT * FROM table_a;

+----+-----------------+--------+
| id | name            | typeID |
+----+-----------------+--------+
|  2 | 霧矢あおい        |      2 |
|  3 | 紫吹蘭           |      3 |
+----+-----------------+--------+

typeIDが重複しているデータを全て削除してしまいます。
一部のカラムが重複したデータを1件だけ残して削除するには工夫が必要そうです。
(つづく...


[参考]

ビッグデータ分析・活用のためのSQLレシピ

ビッグデータ分析・活用のためのSQLレシピ