マージ済みブランチを安全に削除する git dmb を作った

開発を続けていると、ローカルリポジトリにマージ済みのブランチが溜まっていく。 ひとつずつ確認して削除するのは地味に面倒だが、雑なワンライナーでまとめて消すのも少し怖い。

そこで、マージ済みのローカルブランチを安全に削除する Git サブコマンド、git dmb を作った。dmb は Delete Merged Branches の略だ。

$ git dmb --dry-run
2 merged local branches:
  feature/add-search
  fix/header-layout

実装にあたっては、git-delete-merged-branches の git-dmb マニュアルを参考にした。ただし、そのまま再実装するのではなく、依存のない小さなシェルスクリプトとして、現在の Git が提供するコマンドを活用している。

インストール

スクリプト本体を git-dmb という名前で PATH の通ったディレクトリに置き、実行権限を付ける。

install -m 755 bin/git-dmb ~/.local/bin/git-dmb

Git は PATH 上にある git-名前 形式の実行ファイルをサブコマンドとして認識する。そのため、git-dmb を配置すると次のように呼び出せる。

git dmb

今回のワークスペースでは .mise.tomlbin/ を PATH に追加しているため、その環境に入れば追加のインストールなしで利用できる。

基本的な使い方

まずは --dry-run で削除候補を確認する。

git dmb --dry-run

問題がなければ、オプションなしで実行する。削除前に確認を求められる。

$ git dmb
2 merged local branches:
  feature/add-search
  fix/header-layout
Delete these branches? [y/N] y
Deleted branch feature/add-search (was 0123456).
Deleted branch fix/header-layout (was 789abcd).

CIやスクリプトから実行するなど、確認を省略したい場合は --yes を指定する。

git dmb --yes

デフォルトでは、現在の HEAD にマージ済みのブランチが対象になる。別のブランチを基準にしたい場合は、位置引数または --branch で指定できる。

git dmb main
git dmb --branch release

一時的に削除対象から外したいブランチには --exclude を使う。複数回指定することもできる。

git dmb --exclude demo --exclude next-release

削除事故を防ぐための設計

ブランチ削除コマンドで最も大切なのは、候補をたくさん見つけることよりも、消してはいけないブランチを確実に残すことだ。

git dmb は以下のブランチを自動的に保護する。

  • 現在チェックアウトしているブランチ
  • マージ判定の基準に指定したブランチ
  • mainmasterdevelopdevelopmenttrunk
  • 別の worktree でチェックアウトされているブランチ
  • --exclude で指定されたブランチ
  • dmb.protectedBranch に設定されたブランチ

恒久的に保護したいブランチは Git config に追加できる。

git config --global --add dmb.protectedBranch release
git config --global --add dmb.protectedBranch staging

設定値は複数持てるため、チームや個人の運用に合わせて追加していける。

また、対話的な端末でない場合、確認なしで削除処理に進むことはない。明示的に --yes または --dry-run を指定する必要がある。

git branch の表示を解析しない

よくある実装は、次のようなコマンドの出力を grepsed で加工するものだ。

git branch --merged

人間が確認する用途なら十分だが、git branch の出力には現在のブランチを示す * や、別worktreeで使われていることを示す + など、表示用の情報が含まれる。スクリプトから扱う場合、こうした表示を解析するよりも、refを列挙するためのコマンドを使うほうが堅牢だ。

今回の実装では、候補の列挙に git for-each-ref を使っている。

git for-each-ref \
  --format='%(refname:short)' \
  --merged="$target" \
  refs/heads/

refs/heads/ に範囲を限定しているので、対象になるのはローカルブランチだけだ。リモート追跡ブランチやタグを誤って候補に混ぜることもない。

複数worktreeへの対応

Git worktreeを使っている場合、現在いるディレクトリとは別のworktreeでブランチがチェックアウトされていることがある。そのブランチを削除対象として表示するのは紛らわしい。

そこで、次のコマンドからチェックアウト中のブランチを取得し、候補から除外している。

git worktree list --porcelain

--porcelain はスクリプトによる処理を想定した安定形式だ。通常表示の見た目に依存せず、branch refs/heads/... の行からブランチ名を取得できる。

なぜ削除時に -D を使うのか

候補の削除には、内部的に次のコマンドを使っている。

git branch -D -- "$branch"

一見すると、マージ済みブランチには小文字の -d を使うべきに思える。しかし git branch -d は、ブランチにupstreamが設定されている場合、そのupstreamを基準にマージ状態を再評価する。git dmb release のように利用者が指定した基準と異なる判定になる可能性がある。

そこで、削除候補を列挙する段階で「指定されたコミットへマージ済みである」ことをGit自身に判定させ、その検証を通ったブランチだけを -D で削除している。強制削除を無条件に使っているわけではなく、判定基準を一貫させるための選択だ。

-- を入れているのも重要で、ブランチ名をコマンドラインオプションとして解釈させないための定石である。

用意したオプション

usage: git dmb [options] [<merge-target>]

Options:
  -b, --branch <branch>    Use <branch> as the merge target
  -x, --exclude <branch>  Keep <branch> (repeatable)
  -n, --dry-run           Show branches without deleting them
  -y, --yes               Delete without asking for confirmation
  -h, --help              Show this help

参考にしたツールは、複数リモートへの対応やsquash mergeの検出など、より多機能だ。今回の git dmb はローカルブランチの掃除という日常的な用途に絞り、挙動を理解しやすくしている。

テスト

テストでは一時的なGitリポジトリを作り、次のケースを確認している。

  • マージ済みブランチだけが候補になる
  • 未マージのブランチは残る
  • main は保護される
  • --exclude で指定したブランチは残る
  • 明示したマージ先ブランチは削除候補にならない
  • git dmb というサブコマンド形式で起動できる

実行方法は次のとおり。

test/git-dmb-test.sh

テスト用リポジトリは mktemp で作成し、終了時に削除するため、普段使っているリポジトリには影響しない。

まとめ

git dmb を使うと、日々溜まっていくマージ済みブランチを次の短いコマンドで整理できる。

git dmb

実装自体は小さいが、Gitの表示出力を無理に解析せず、for-each-ref やworktreeのporcelain形式を使うことで、安全性と読みやすさを両立できた。削除系の処理だからこそ、dry-run、確認、保護ブランチという逃げ道を最初から用意しておくのが大切だと思う。

カテゴリ