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
というフィーチャーブランチがある時、
foo
がmaster
にSquash and merge
されたかどうかを判定するフローを考えてみる。
もしされていても、squash された以上foo
で作られたコミット自体はmaster
に1つも残っていないが、
代わりにfoo
でなされた修正を全て含む1つのコミットがある事になる。
よってmaster
の履歴にそのようなコミットが見つかれば、
foo
がSquash and merge
されたのだと判断できる。
git-delete-squashed
は以下の方法でそのようなコミットを探している:
foo
とmaster
の分岐点となるコミット (共通の祖先) を探す- その祖先から
foo
の HEAD までを squash した一時コミットを作る - その一時コミットと内容的に同一なコミットを
master
の履歴から探す
重要なのは 3 の処理で、
コミットの SHA-1 ではなく内容でコミットの同一性を判定する方法が必要になる。
ここで使われるのがgit cherry
というコマンドである。
git cherry
によるブランチ比較
例えばmaster
とfoo
の履歴が以下のようになっているものとする。
- コミット
a
からfoo
が分岐している b
とb'
は別コミットだがコミット内容は同じであるmaster
とfoo
の HEAD の内容は異なる
内容は同じなのにコミットは別物という状況は、
cherry-pick
などを使うと簡単に起こる。
この状態でfoo
をcheckout
して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-base
とgit commit-tree
というコマンドを使ってこれを実現している。
merge-base
はそのままで、git merge-base master foo
で共通祖先を見つける事ができる。
後はその祖先コミットからfoo
の HEAD までを squash した一時コミットを作ればいい。
しかし普通にやるとそのコミットはカレントブランチの履歴に追加されてしまう。
そこでgit commit-tree
という内部向けのコマンドが活用される。
これを使うと特定のツリーオブジェクト (リポジトリのファイル構成のスナップショット) に紐づくコミットを新たに作る事ができるのだが、
このコミットは単に作られるだけで、カレントブランチの履歴には追加されない。
そのため履歴を汚さずに一時コミットを作成できる。これでSquash and merge
コミットを再現する事ができた。
ちなみに作られた一時コミットはどのブランチやタグにも紐付いていないため、時間が経てば自動で git が削除してくれるはず。
まとめ
まとめると、まずgit merge-base
で祖先コミットを探し、
次にgit commit-tree
でSquash 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 cherry
やgit commit-tree
を知らなかった自分には、こんな実装自体とても思いつかない。