GitHub で Squash merge されたブランチを削除する

TL;DR

https://github.com/not-an-aardvark/git-delete-squashed

git branch -dできない問題

GitHub にはSquash and mergeという機能がある。 これを使うと Pull Request のコミットを1つにまとめてマージする事ができるため、 1コミットだけのわずかな変更でマージコミットを作りたくない時などには便利だ。

しかし squash して新たにコミットを作る以上、 Git のコミット履歴としてはブランチのマージが残らないため、 ローカルに残っているSquash and merge済みのブランチを自動で削除しづらいという問題がある。 例えば普通にマージされたブランチなら、以下のようなコマンドで簡単に一括削除できる。

git branch -d `git branch --merged | grep -v '*'`

だがSquash and mergeされた場合は--mergedでは検出できない。

これの解決方法を自分で考えてもあまりちゃんとしたアイデアが浮かばなかったので、 既存ツールを探してみたところ not-an-aardvark/git-delete-squashed が見つかった。 普段は使わないような git のサブコマンドが活用されており、自分ではまず思いつかない方法が使われていたのでメモしておく。

Squash and mergeされたブランチの検出方法

自分が見つけた時点でのコードはこちら: f36f2c08:bin/git-delete-squashed.js

一言でいうと、ローカルでSquash and mergeと同等のコミットを作成し、 同じコミットがmasterにないかを探すという方法。

例えばmasterというベースブランチとfooというフィーチャーブランチがある時、 foomasterSquash and mergeされたかどうかを判定するフローを考えてみる。 もしされていても、squash された以上fooで作られたコミット自体はmasterに1つも残っていないが、 代わりにfooでなされた修正を全て含む1つのコミットがある事になる。 よってmasterの履歴にそのようなコミットが見つかれば、 fooSquash and mergeされたのだと判断できる。 git-delete-squashedは以下の方法でそのようなコミットを探している:

  1. foomasterの分岐点となるコミット (共通の祖先) を探す
  2. その祖先からfooの HEAD までを squash した一時コミットを作る
  3. その一時コミットと内容的に同一なコミットをmasterの履歴から探す

重要なのは 3 の処理で、 コミットの SHA-1 ではなく内容でコミットの同一性を判定する方法が必要になる。 ここで使われるのがgit cherryというコマンドである。

git cherryによるブランチ比較

例えばmasterfooの履歴が以下のようになっているものとする。

  • コミットaからfooが分岐している
  • bb'は別コミットだがコミット内容は同じである
  • masterfooの HEAD の内容は異なる

内容は同じなのにコミットは別物という状況は、 cherry-pickなどを使うと簡単に起こる。 この状態でfoocheckoutしてgit cherry masterを実行すると、 masterに対するコミット単位の差分を表示できる。

$ git cherry master
- SHA1-of-b'
+ SHA1-of-head

SHA1-of-xxの部分には実際にはコミットの SHA-1 ハッシュが表示される。 -+はそれぞれ以下を意味する。

  • -: 同じ内容のコミットがmasterにもある
  • +: 同じ内容のコミットはmasterにはない

これでb'に関しては同じ内容のコミットを master も持っている、という事がわかる。 このように、git cherryを使うとブランチ同士の内容的な差分をコミット単位で確認する事ができる。

つまり、Squash and mergeされたコミットと同じ内容のコミットを作ってgit cherryで比較すれば、 -になるかどうかでSquash and mergeされたかどうかを判定できる事になる。

Squash and mergeと同じコミットを作る

では、どのようにしてSquash and mergeされたコミットを再現するか。 git-delete-squashedでは、 git merge-basegit commit-treeというコマンドを使ってこれを実現している。

merge-baseはそのままで、git merge-base master fooで共通祖先を見つける事ができる。 後はその祖先コミットからfooの HEAD までを squash した一時コミットを作ればいい。 しかし普通にやるとそのコミットはカレントブランチの履歴に追加されてしまう。 そこでgit commit-treeという内部向けのコマンドが活用される。 これを使うと特定のツリーオブジェクト (リポジトリのファイル構成のスナップショット) に紐づくコミットを新たに作る事ができるのだが、 このコミットは単に作られるだけで、カレントブランチの履歴には追加されない。 そのため履歴を汚さずに一時コミットを作成できる。これでSquash and mergeコミットを再現する事ができた。

ちなみに作られた一時コミットはどのブランチやタグにも紐付いていないため、時間が経てば自動で git が削除してくれるはず。

まとめ

まとめると、まずgit merge-baseで祖先コミットを探し、 次にgit commit-treeSquash and mergeと同等のコミットを作り、 最後にgit cherryでそのコミットと同等のものがmasterにあるかどうかを確認する、という手順になる。 これを各ブランチごとに繰り返して、該当するコミットがあるブランチは削除すれば良い。

git-delete-squashedは Node.js で実装されているが、 README には以上の処理を bash スクリプトで実行する方法も記載されており、 これを使えばgit-delete-squashedをインストールする事さえなく目的を達成できる。 必要な処理は全てこのスクリプトに書かれているので、まとめとしてここにも載せておく。

git checkout -q master && \
  git for-each-ref refs/heads/ "--format=%(refname:short)" | \
  while read branch; do
    mergeBase=$(git merge-base master $branch) && \
      [[ $(git cherry master $(git commit-tree $(git rev-parse $branch^{tree}) -p $mergeBase -m _)) == "-"* ]] && \
      git branch -D $branch;
done

git-delete-squshedが完璧に動作するかは未検証だが、少し試した限りでは期待通りに動いてくれた。 git cherrygit commit-treeを知らなかった自分には、こんな実装自体とても思いつかない。