Claude Media
Claude Codeでマージ済みブランチを一括削除する手順 — squash mergeはghで補う

Claude Codeでマージ済みブランチを一括削除する手順 — squash mergeはghで補う

git branch --mergedで消せるものと、squash mergeで空振りするものをghで補う手順です。ローカルとリモートの削除をClaude Codeの許可ルールで分けます。

マージ済みブランチの掃除は、git branch --mergedだけでは終わりません。squash mergeを使うリポジトリでは、元のブランチがmainの履歴に載らず、--mergedの一覧にそのブランチが出てこないからです。Claude Codeに任せるなら、候補の洗い出しはgh pr listで補い、削除はローカルとリモートで許可ルールを分けます。この記事では、その手順を順に示します。

--mergedが拾うもの、拾わないもの

git branch --merged <commit>は、先端のコミットが指定したコミットから辿れるブランチを列挙します。コミットを省くとHEADが基準です。-dで消せる条件も同じ考え方で、上流ブランチ(上流が未設定ならHEAD)に完全にマージ済みであることが求められます。

squash mergeは、ブランチの変更を1つの新しいコミットにまとめてmainへ載せます。元のコミットはmainから辿れません。そのため、PRはマージ済みなのに、gitから見ると未マージのブランチになります。

使い捨てのリポジトリで再現した結果です(git 2.50.1)。feat/squashedはsquash、feat/normalは--no-ffでマージしています。

git branch --merged
git branch -d feat/squashed
git branch -d feat/normal

出力は次のとおりでした。

  feat/normal
* main
error: the branch 'feat/squashed' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D feat/squashed'
Deleted branch feat/normal (was b5fcfea).

--mergedにはsquashしたブランチが出ず、-dも拒否されます。ヒントが示す-Dは、マージ状態を見ずに消す強制削除です。ここで「gitが -Dを勧めているから」と流すと、本当に未マージの作業まで消えます。squashされたことをgit以外の情報で確かめてから、-Dを使います。

手順: 候補を出し、PRで裏を取り、消す

流れは4段です。

手順

マージ済みブランチを掃除する流れ

  1. 1

    リモートの状態を取り込む

    git fetch --pruneで、リモートから消えたブランチの追跡参照を取り除きます。

  2. 2

    gitで言えるマージ済みを出す

    git branch --merged origin/mainの一覧が、-dで消せる候補です。

  3. 3

    squash済みをghで補う

    マージ済みPRのheadコミットとローカルの先端が一致するものだけを候補に足します。

  4. 4

    一覧を見てから消す

    ローカル、リモートの順に、名前を明示して削除します。

1. 追跡参照を取り除き、gitで言える候補を出す

git fetch --pruneは、取得の前に、リモートに無くなった追跡参照を取り除きます。タグは既定では対象外です。

git fetch --prune origin
git branch --merged origin/main --format='%(refname:short)'

基準をHEADにせずorigin/mainにしているのは、作業中のブランチにいても結果がぶれないようにするためです。一覧にはmain自身も入るので、削除対象から外します。通常のgit branchの表示では、別のworktreeでチェックアウト中のブランチに+が付きます。そうしたブランチは、他の作業が使っている可能性があります。

なお-dは上流ブランチかHEADを基準に判定するため、上流が未設定でHEADがmain以外のときは、origin/mainにマージ済みでも拒否される場合があります。

2. squash済みのブランチをghで探す

gh pr list --state mergedで、マージ済みPRを列挙できます。ブランチ名はPRのheadRefName、PRのheadコミットはheadRefOidで取れます。

gh pr list --state merged --base main --limit 300 \
  --json headRefName,headRefOid \
  --jq '.[] | [.headRefName, .headRefOid] | @tsv' > merged-prs.tsv
 
while IFS=$'\t' read -r name oid; do
  tip=$(git rev-parse --verify -q "refs/heads/$name") || continue
  [ "$tip" = "$oid" ] && echo "$name"
done < merged-prs.tsv

ここで見ているのは、ブランチ名の一致ではなく、先端のコミットの一致です。ブランチ名が同じでも、マージ後に新しいコミットを積んでいれば先端は一致せず、候補から外れます。マージ後にも同じ名前のブランチで作業を続ける人がいるリポジトリでは、この条件が未公開の作業を守ります。

--limitの既定は30件です。省くと古いPRが一覧から落ち、掃除が途中で止まったように見えます。マージ済みPRが多いリポジトリは、件数を広げて取ります。

3. 消す

-dで消せるものと、squashを確認したものを分けて削除します。-dに拒否されたブランチは、いきなり-Dに回さず、手順2の突き合わせに戻します。拒否は、未マージの作業が残っているという合図でもあるからです。

git branch -d feat/login feat/search
git branch -D feat/squashed-a feat/squashed-b
git push origin --delete feat/login feat/squashed-a

git push --deleteは、列挙したrefをリモートから削除します。コロンを前置きした形(git push origin :feat/login)と同じ操作です。どちらの書き方でも消えるので、次の節の許可ルールでは両方を想定します。

許可ルールをローカルとリモートで分ける

Claude Codeの許可ルールは、deny、ask、allowの順に評価され、最初に当たったものが結果を決めます。細かい指定が優先されることはありません。この性質を使って、掃除の操作を3段に分けます。

操作例扱い
読み取りと取得例gh pr list、git fetch --prune扱いallow
ローカルの安全な削除例git branch -d扱いallow
強制削除とリモート削除例git branch -D、git push扱いask

設定の例です。

{
  "permissions": {
    "allow": [
      "Bash(git fetch --prune *)",
      "Bash(gh pr list *)",
      "Bash(git branch -d *)"
    ],
    "ask": [
      "Bash(git branch -D *)",
      "Bash(git push *)"
    ]
  }
}

Bashルールはコマンド文字列全体に対して*で照合し、末尾の*は引数なしの形にも一致します。-dは未マージなら失敗するため、allowに置いても事故の幅は小さいままです。一方、-Dは未マージの作業を消せるので、askで毎回確認します。

リモートの削除をBash(git push origin --delete *)のように書かず、git push全体をaskにしているのは理由があります。--deleteと同じ操作はgit push origin :ブランチ名でも書けるため、--deleteを含む形だけを狙ったルールでは、後者がすり抜けます。掃除の間だけ、すべてのpushで確認が出るほうが安全です。

ルールの外にある形

Bashルールが照合するのは、Claudeが書いたコマンドの文字列です。git -C . push origin mainのように、同じ操作を別の形で書いたものは、Bash(git push *)に一致しません。deny/askルールは、Claudeが普通に出す形を止めるもので、セキュリティの境界ではないと説明されています。確実に止めたいときは、サンドボックスか、コマンド全文を検査するPreToolUseフックを使います。

複合コマンドにも注意が要ります。;、&&、|などで区切られたコマンドは、すべての部分が許可されないとプロンプトなしでは実行されません。先ほどのwhileループのような長いコマンドを毎回Claudeに組ませると、途中で確認が出ます。候補を出す処理(手順2のwhileループ)をscripts/list-merged-branches.shとして保存し、Bash(bash scripts/list-merged-branches.sh)のように、実行コマンドを固定するほうが扱いやすくなります。スクリプトの中身は、人が一度読んでから許可します。

消してよいかをブランチの状態で決める

候補は出揃いましたが、すべてを同じ重さで消すわけではありません。ブランチの状態ごとに、扱いを決めておきます。

ブランチの状態見分け方扱い
mainに完全にマージ済み見分け方git branch --merged origin/mainに出る扱い-dで削除
squashされたPRのheadと先端が一致見分け方マージ済みPRのheadRefOidと一致扱い確認のうえ-D
同名のPRはあるが先端が違う見分け方PRのマージ後にコミットが増えている扱い消さない
PRがクローズのみ見分け方--state closedには出て、mergedには出ない扱い消さない(不採用の作業かもしれない)
PRが存在しない見分け方gh pr list --head <名前> --state allが空扱い消さない

3行目以降は、ghの結果とgitの結果が食い違う場合です。食い違いは自動で片付けず、人が見る一覧に回します。gh pr listは、--headでブランチ名を、--stateでopen、closed、merged、allを絞り込めます。たとえば次のように、1つのブランチの履歴を確かめられます。

gh pr list --head feat/search --state all \
  --json number,state,mergedAt,headRefOid

マージ済みのPRが1件あり、headRefOidがローカルのgit rev-parse feat/searchと一致すれば、そのブランチはPRの内容だけを持っています。一致しなければ、PRに入らなかったコミットを抱えています。

削除の前に、名前と先端のSHAを残す

ブランチを削除すると、そのブランチのreflogも一緒に消えます。誤って消した場合に備え、消す前に名前と先端のSHAをファイルに残します。

git for-each-ref --format='%(refname:short) %(objectname)' \
  refs/heads > branches-before-cleanup.txt

後から取り戻すときは、記録したSHAを使ってgit branch <名前> <SHA>でブランチを作り直します。コミットそのものが残っている間に限った話ですが、記録があれば、探す手間はほとんどありません。Claudeへの頼み方としても、「削除の前にこのファイルを作る」と一言添えておけば、実行の順序が固定されます。

リモート側もPRのheadと照らし合わせる

ローカルを片付けても、originには古いブランチが残ります。リモートの先端は、git ls-remoteで取れます。

while IFS=$'\t' read -r name oid; do
  remote=$(git ls-remote --heads origin "$name" | cut -f1)
  [ -n "$remote" ] && [ "$remote" = "$oid" ] && echo "$name"
done < merged-prs.tsv

git fetch --pruneの直後ならrefs/remotes/origin/<名前>でも比べられますが、取得していない状態ではls-remoteがリモートを直接見るぶん正確です。

出力に出たブランチだけが、マージ済みPRのheadと同じ先端を持つ、リモートの削除候補です。出力を見て、人が確認したものについてgit push origin --deleteを実行します。この確認がaskルールの役目です。askルールにしておけば、Claudeが一覧を作り終えた時点で、削除の1件ごと(または名前を並べた1回ごと)に承認の画面が出ます。

Claudeへの頼み方

CLAUDE.mdに掃除の約束を書いておくと、毎回の指示が短くなります。

## ブランチの掃除
- 削除の前に、候補を一覧で見せて確認を取る
- `git branch --merged origin/main` の結果と、`scripts/list-merged-branches.sh` の結果を分けて見せる
- main、現在のブランチ、`+` が付いたブランチは候補にしない
- 削除コマンドはブランチ名を明示する(`xargs` や glob で渡さない)

CLAUDE.mdは、Claudeが何を試みるかを方向づけるだけで、許可を変えるものではありません。強制力を持つのは前節のルールです。そのうえで、プロンプトは「候補を一覧にして。消すのは私が確認してから」とだけ書けば足ります。

つまずきやすい点

  • xargsで渡すと照合が変わる: フラグ無しのxargsだけは取り除かれて照合されます。xargs -n1のようにフラグが付くと別のコマンドとして扱われるので、git branch -d *では覆われません。ブランチ名を明示した削除のほうが、ルールとも噛み合います。
  • 現在のブランチと別worktreeのブランチ: 一覧に出ても消せないことがあります。+付きは、他のworktreeが使っています。

PRの運用を整える側の話はrelease-pleaseのリリースPRに、マージ後のブランチ保護の見直しはgh apiによるブランチ保護の棚卸しにあります。

まとめ

git branch --mergedは、squash mergeでは空振りします。その穴は、マージ済みPRのheadコミットとローカルの先端を突き合わせて埋めます。削除は-d、-D、git pushの順に確認の重さを上げ、ルールでそれを固定します。Claudeには一覧を作らせ、消すかどうかは人が決めます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/permissions
  2. git-scm.com/docs/git-branch
  3. git-scm.com/docs/git-push
  4. git-scm.com/docs/git-fetch
  5. cli.github.com/manual/gh_pr_list
この記事を共有:XはてブLinkedIn