JenkinsのPipelineを利用してリモートブランチの整理ツールを作ってみた話

f:id:sumzap_engineer_blog:20210215193926j:plain

はじめに

サムザップでUnityエンジニアをしている樋口です。

長期間開発しているプロジェクトや大人数のチームでは、リモートブランチをこまめに整理したりマージ時の自動削除設定をしていないとリモートブランチの量が膨大となり、事故や効率の低下に繋がります。

今回は、一定期間コミットされていないブランチの自動削除ツールと、反映済みのリソースブランチが現行の開発ブランチにマージされていない場合の促進ツールの2つを紹介したいと思います。

背景

『この素晴らしい世界に祝福を!ファンタスティックデイズ』(略称:『このファン』)という弊社の運用タイトルは2020年の2月にリリースされたのですが、リリース以前に作業していたものを含め数百件の不要なブランチがリモート上に残っていました。基本はGitHubの設定でマージ時に自動的に削除する設定を入れているのですが、その設定以前に作られたブランチや、テスト用に作っていたブランチなどが残っていることがあるためいい加減整理しないと今後本番にリリースしていくにあたり何が必要で何が不要なのか直感的に分からない等開発の効率に影響する状況になっていました。
また、イベント直前など適切な時期にアセットをデプロイするためのリソースブランチが別途設けられているのですが、反映した後に現行の開発ブランチへマージしないと整合性が取れなくなったり、本番で動いているのに現行の開発ブランチではうまく反映されてない等事故に繋がることがありました。

そこで、今回紹介するツールを実装しました。

前提

これから紹介する2つのツールはどちらも以下の対応のみで実現させることができます。

  • Jenkinsにジョブを作成し、groovyを用いたパイプラインスクリプトを実行

  • Slackへ実行結果を通知(上記のスクリプトに記述)

古いリモートブランチの自動削除ツール

古いブランチといっても、期間や削除しないブランチ名などいくつかルールを決めておく必要があります。
今回は、以下のルールで設定しました。

  • 60日以上コミットがないリモートブランチを削除

  • 週一で実行

  • クライアント、サーバそれぞれで削除対象外となるブランチを正規表現で設定(masterや、各バージョンブランチv-*-*-*等)

  • 今後追加するブランチを削除対象外にしたい場合はprotectのプレフィックスを付ける

  • マージされていないブランチの場合は最終コミット者とその日時をリストアップする

  • 最悪復元できるように、ビルドマシンのローカル環境に削除対象のブランチをチェックアウトしておく

  • 確認用に削除せずリストアップするだけのモードもつけておく

  • 次週削除対象となるブランチの最終コミット者とその日時を予告リストとして通知に含める

  • 削除対象外ブランチ、削除したブランチ、予告リストをSlackで通知する

少々ルールが多めですが、ブランチの削除は開発に大きく影響する部分であるため、細かく設定しておいた方が安全です。
こちらが、実装したgroovyスクリプトのうち削除対象のブランチの検索と削除の部分をピックアップしたものになります。

// 削除対象のブランチを検索するメソッド
def search = { String repository, String noDeletePattern ->
    dir(repository) {
        String result = sh script: 'git branch -r | grep origin/', returnStdout: true
        def branchList = result.split("\n")
            
        // 現在時刻から削除対象の基準となる日時を取得
        def limit = LocalDateTime.now().minusDays(lastCommitDayLimit)
        Long limitValue = limit.format(DateTimeFormatter.ofPattern('yyyyMMddHHmmss')) as Long

        // リスト生成
        List targetList = []
        List nextTargetList = []
        List infoList = []
        List nextTargetInfoList = []
        List excludeList = []
            
        // 削除対象のブランチリスト生成
        branchList.each { branch -> 
            String branchName = branch.trim()
                
            // 除外対象に入っていたらスルー
            if (branchName ==~ noDeletePattern) {
                excludeList << branchName
                return
            }
                
            // 削除対象となり得るブランチの最終コミット情報を取得
            def lastCommit = getDeleteTargetLastCommit(branchName, limitValue)
            if (lastCommit == null) {
                return
            }

            // 次回削除対象となるブランチか
            if (lastCommit['nextTarget']) {
                nextTargetList << branchName
                nextTargetInfoList << "最終コミット: ${lastCommit['date']} ${lastCommit['author']}"
                return
            }
            targetList << branchName
            infoList << "最終コミット: ${lastCommit['date']} ${lastCommit['author']}"
        }

        return [list: targetList, nextList: nextTargetList, infoList: infoList, nextInfoList: nextTargetInfoList, excludeList: excludeList]
    }
}

// 削除メソッド
def delete = { String repository, List targetList ->
    dir(repository) {
        // エラーで削除できない場合もあるため、削除完了したブランチでリストを再構成する
        List successDeleteList = []
    
        // 削除日時を取得
        String now = sh script: "date '+%Y%m%d%H%M%S'", returnStdout: true
        String nowStr = now.trim()
        targetList.each {
            String branchName = it.trim()
                
            // 誤操作防止のためローカルにチェックアウトしておく
            String localName = branchName.replaceFirst(/origin\//, "")
            sh script: "git checkout -B ${localName}_${nowStr} ${branchName}", returnStdout: true
                
            // 削除
            try {
                sh script: "git push origin :refs/heads/${localName}", returnStdout: true
            }
            catch(Exception exception) {
                // プロテクトされているブランチなどで正常に実行できなかった場合はログだけ出して無視する
                print "Couldn't delete ${branchName}!"
                return
            }
                
            successDeleteList << it.trim()
        }
            
        return successDeleteList
    }
}

こちらがジョブのパラメータ設定画面です。 f:id:sumzap_engineer_blog:20210208110408p:plain

反映済みリソースブランチのマージ促進ツール

こちらも、自動削除ツール同様ルールを設けて対象となるブランチがある場合に通知を行うようにしています。
今回は、以下のルールで設定しました。

  • リソースブランチと判断するための命名にしておき、必ず反映する日付を入れる
    (もともとorigin/resources/*-{反映する日付8桁}という規則があったためこれに沿った実装にしています。アスタリスクの部分は、対象となるバージョン名や機能を入れることが多いです)

  • 反映日が現在の日付より前のリソースブランチを対象とする

  • 反映後に現行ブランチへマージされていないのか、削除されてないだけなのかを可視化できるようにマージ済みかどうかを表示に含める

  • 上記で対象となるリソースブランチがあった場合、反映担当者にメンション付きでSlackに通知する

こちらが、実装したgroovyスクリプトのうち、対象の検索と通知テキストの設定部分になります。

stage('Search') {
    dir(clientRepositoryDir) {
        String result = sh script: 'git branch -r | grep origin/resources/', returnStdout: true
        String noMergedResult = sh script: 'git branch -r --no-merged | grep origin/resources/', returnStdout: true
        def branchList = result.split("\n");
        def noMergedBranchList = noMergedResult.split("\n");

        // 今日の日付を取得(ブランチの後ろ8桁が日付となっているためそこと比較する)
        def ldt = LocalDateTime.now()
        def formatter = DateTimeFormatter.ofPattern('yyyyMMdd')
        int today = Integer.parseInt(ldt.format(formatter))
        branchList.each {
            int date
            try {
                date = Integer.parseInt(it.substring(it.length() - 8))
            }
            catch (Exception e) {
                print e
                if (noMergedBranchList.contains(it)) {
                    unableParseBranches.push(it.trim())
                }
                return
            }

            if (today > date) {
                oldBranchList += it.trim()
                if (!noMergedBranchList.contains(it)) {
                    oldBranchList += "(マージ済)"
                }
                oldBranchList += "\n"
            }
        }

        if (oldBranchList != "") {
            notifyMessage += """:cw_light: * `${clientRepositoryDir}` に配信日を過ぎたリソースブランチが残っています* :cw_light: \n\n"""
            notifyTargetList.each {
                notifyMessage += "<@${it}> "
            }
            notifyMessage += "\n"
            notifyMessage += """*現行アプリバージョンのブランチにマージし削除を行ってください*\n"""
        }
    }
}

こちらはパラメータの設定は不要で、Jenkinsからビルドを実行するだけでできます。

導入したことによる所感

もともと致命的な課題感ではありませんでしたが、まさにかゆいところに手が届くといったような安定化ツールとなっているような気がします。 Git GUIでパッと見ただけでもリモートブランチが整理されていて探しやすくなっていますし、リソースブランチの通知もまれに通知が飛んでおり、現行ブランチへのマージ漏れが防げて大活躍してくれています。
実装当初はgroovyスクリプトを書いた経験はなく初めてこの言語を勉強した上で実装を行いましたが、ドキュメントは多くありルールが多くあるように見えて単純な条件式でできることが多かったので、実装における導入コストは低い感じがしました。(一番苦戦したのはgitのコマンドでした(笑))

おわりに

JenkinsやGASなど、自動化することで安定化や効率化に繋がるものは多くあると思います。
各プロジェクトで、職種ごとに使える効率化ツールを用意していることはよくあるかと思いますが、プロジェクトの立ち上げの度に今回紹介したような開発以外の部分で必要な箇所を実装するのはもったいないと思います。理想は、こういったツールを会社単位で使いまわせることですが、理想と現実をうまく擦り合わせて将来的にどんどん開発が楽になると良いですね。