🐷

「個人開発×Claude Code」 ナポレオン開発日記 #4 github便利集

に公開

個人開発×Claude Code — ナポレオン開発日記 #X GitHub便利集(自動ブランチ掃除スクリプト)

こんにちは。今回は、開発中に便利だったGitHub関連の自動化スクリプトを紹介します。特に、マージ済みブランチを定期的にクリーンアップするポーリングスクリプトを書いたので、コードとともに紹介します。


背景:ブランチがどんどん溜まる問題

個人開発でも、feature/*fix/* のようなブランチを大量に切ると、数日でローカルがごちゃごちゃになってしまいます。

GitHub 上ではマージされたブランチが自動で削除される設定もできますが、ローカルブランチは残ったまま。結果、以下のようなストレスがありました:

  • 誤って古いブランチで作業を開始してしまう
    • その後コミットしてしまいdevelopとの差分が出てコンフリクト解消が面倒
  • git branch の一覧が見づらい
  • 古いブランチが残ったままになってしまう

そこで、マージされたブランチを定期的に検出して、自動で削除するポーリングスクリプトを作成しました。


スクリプトの概要

今回作ったスクリプトは Node.js 製で、以下の特徴があります:

  • GitHub CLI (gh) を使って最近マージされた PR を取得
  • ローカルブランチに存在すれば自動で削除
  • ベースブランチ(develop, main)に切り替えてから安全に削除
  • 3分間隔(デフォルト)でポーリング、常駐可能

コード(AutoPollingCleanup)

#!/usr/bin/env node

/**
 * Auto Polling Branch Cleanup
 * 定期的にGitHub APIをポーリングしてマージ済みブランチを自動削除
 */

const { exec } = require('node:child_process')
const { promisify } = require('node:util')

const execAsync = promisify(exec)

class AutoPollingCleanup {
  constructor(options = {}) {
    this.owner = [ユーザー名]
    this.repo = [プロジェクト名]
    this.baseBranches = ['develop', 'main']
    this.pollInterval = options.pollInterval || 3 * 60 * 1000 // 3分
    this.lastCheck = new Date()
  }

  start() {
    console.log(`🔄 Starting auto-polling cleanup (every ${this.pollInterval / 1000}s)`) 
    this.checkForMergedBranches()
    this.intervalId = setInterval(() => this.checkForMergedBranches(), this.pollInterval)
  }

  stop() {
    if (this.intervalId) clearInterval(this.intervalId)
    console.log('🛑 Auto-polling cleanup stopped')
  }

  async checkForMergedBranches() {
    try {
      console.log(`🔍 Checking for merged PRs... (${new Date().toLocaleTimeString()})`)
      const since = this.lastCheck.toISOString().split('T')[0]
      const mergedPRs = await this.getRecentlyMergedPRs(since)
      if (mergedPRs.length === 0) {
        console.log('ℹ️  No recently merged PRs found')
        this.lastCheck = new Date()
        return
      }

      for (const pr of mergedPRs) {
        const branchName = pr.head.ref
        if (!this.baseBranches.includes(branchName) && await this.localBranchExists(branchName)) {
          console.log(`🔄 Cleaning up ${branchName}`)
          await this.cleanupBranch(branchName, pr.base.ref)
        }
      }

      this.lastCheck = new Date()
    } catch (error) {
      console.error('❌ Polling check failed:', error.message)
    }
  }

  async getRecentlyMergedPRs(since) {
    try {
      const query = `is:merged base:develop merged:>=${since}`
      const { stdout } = await execAsync(`gh pr list --repo ${this.owner}/${this.repo} --search "${query}" --state merged --json number,title,headRefName,baseRefName,mergedAt --limit 10`)
      const prs = JSON.parse(stdout)
      return prs.map(pr => ({ number: pr.number, title: pr.title, head: { ref: pr.headRefName }, base: { ref: pr.baseRefName }, merged_at: pr.mergedAt }))
    } catch (error) {
      console.error('GitHub CLI error:', error.message)
      return []
    }
  }

  async localBranchExists(branchName) {
    try {
      await execAsync(`git rev-parse --verify ${branchName}`)
      return true
    } catch { return false }
  }

  async cleanupBranch(branchName, baseBranch) {
    try {
      const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD')
      if (currentBranch.trim() === branchName) await execAsync(`git checkout ${baseBranch}`)
      await execAsync(`git pull origin ${baseBranch}`)
      try { await execAsync(`git branch -d ${branchName}`) } catch { await execAsync(`git branch -D ${branchName}`) }
      await execAsync('git remote prune origin')
      console.log(`✅ Deleted local branch: ${branchName}`)
    } catch (error) {
      console.error(`❌ Failed to cleanup branch ${branchName}:`, error.message)
    }
  }
}

if (require.main === module) {
  const pollInterval = parseInt(process.argv[2], 10) || 3 * 60 * 1000
  const cleanup = new AutoPollingCleanup({ pollInterval })
  cleanup.start()
  process.on('SIGINT', () => { cleanup.stop(); process.exit(0) })
  process.on('SIGTERM', () => { cleanup.stop(); process.exit(0) })
}

実際の使い方

  1. 上記スクリプトを scripts/auto-cleanup.js に保存
  2. 実行権限を付与: chmod +x scripts/auto-cleanup.js
  3. 起動: ./scripts/auto-cleanup.js
  4. Ctrl+C で停止

開発中にずっと常駐させておくと、マージ後のブランチが自動で掃除されるので手動で削除する手間がゼロになります。


学び

  • gh pr list--json オプションが便利(GraphQLを直接叩かずに済む)
  • フォールバック処理(git remote prune)を入れるとトラブル時も安全
  • 長時間常駐させる場合は SIGINT, SIGTERM ハンドラを入れておくと安心

課題

  • リモートでマージされたタイミング即時でブランチを切り替えるスクリプトも可能らしい
  • ngrokを使って構築を試みたがパスワード忘れて入れずに断念…

自動化の仕組みを色々入れてミスの少ない作業を実施出来ればと思いました。

Discussion