Gitのpre-push hookスクリプトをRubyで書き直した

Git操作時に、事故が起きないように書いていたpre-push用のスクリプトbashからRubyに書き換えました。

このスクリプトは、

  • mainやmasterブランチに意図せずpushしてしまう
  • 意図せずforce系のオプションを付けてpushしてしまう

といった事故を防ぐために設定しています。

GitHub側のリポジトリ設定で保護設定を有効化にしておけば、まずこのようなことはする必要ないですし、
そもそもこういった意図せずmainブランチにpushしてしまうといった操作自体も、まず起こりえません。
ですが、稀にあるエッジケースへの対策として私個人が設定しているものになります。

pre-push

スクリプトは、shebangRubyとして実行されるようにしているpre-pushで、内容は以下のようになります。

#!/usr/bin/env ruby

def main_branch?(branch_name)
  /\A(master|main)\z/.match? branch_name
end

def restrict_branches(branch_name)
  return if /\A(y|yes)\z/ =~ ENV['GIT_ALLOW_PUSH_MAIN_BRANCH']

  fail "Don't push default branch!!! (master or main)" if main_branch? branch_name
end

def use_force_option?(command)
  /--force|-f/.match?(command)
end

def restrict_force_push(command)
  return if /\A(y|yes)\z/ =~ ENV['GIT_ALLOW_FORCE_PUSH']

  fail "Don't use --force option!!!" if use_force_option? command
end

def main
  _, _, remote_ref, _ = STDIN.gets.chomp.split
  branch_name = remote_ref.gsub('refs/heads/', '')
  command = `ps -o command= -p #{Process.ppid}`.chomp

  restrict_branches branch_name
  restrict_force_push command
rescue => e
  STDERR.puts e.message
  exit 1
end
main if __FILE__ == $0

通常pre-pushなどのフックでは、ユーザが実行したコマンドのオプションは渡りません。 ですが、pre-pushが動く時の親プロセスが「ユーザが実行したコマンド」という性質を利用して、forceオプションが含まれいてるかどうかを判別しています。

  command = `ps -o command= -p #{Process.ppid}`.chomp

もっと簡単にオプションを知る方法があれば是非教えてほしいです。

テスト

せっかくRubyに移行したので、比較的副作用が強くないようにメソッドの分割して、テストも書くようにしてみました。 rspecなどのGemを別途インストールするのは正直面倒だったので、minitestでそれっぽく書いています。

require_relative "../test_helper"

load "#{git_root_path}/.config/git/hooks/pre-push"

describe 'Git pre-push hook' do
  describe '#main_branch?' do
    context 'branch_name is main' do
      it 'be true' do
        assert main_branch? 'main'
      end
    end

    context 'branch_name is master' do
      it 'be true' do
        assert main_branch? 'master'
      end
    end

    context 'branch_name is develop' do
      it 'be false' do
        assert ! main_branch?('develop')
      end
    end
  end

  describe '#restrict_branches' do
    context 'branch_name is main' do
      it 'be fail' do
        assert_raises RuntimeError do
          restrict_branches 'main'
        end
      end
    end

    context 'branch_name is develop' do
      it 'be nothing' do
        restrict_branches 'develop'
      end
    end

    context 'branch_name is main && GIT_ALLOW_PUSH_MAIN_BRANCH=yes' do
      it 'be nothing' do
        current = ENV['GIT_ALLOW_PUSH_MAIN_BRANCH']
        ENV['GIT_ALLOW_PUSH_MAIN_BRANCH'] = 'yes'

        restrict_branches 'main'

        ENV['GIT_ALLOW_PUSH_MAIN_BRANCH'] = current
      end
    end
  end

  describe '#use_force_option?' do
    context '--force in command' do
      it 'be true' do
        assert use_force_option? 'git push --force origin main'
      end
    end

    context '-f in command' do
      it 'be true' do
        assert use_force_option? 'git push --force origin main'
      end
    end

    context 'not force push command' do
      it 'be false' do
        assert ! use_force_option?('git push origin master')
      end
    end
  end

  describe '#restrict_force_push' do
    context '--force-with-lease in command' do
      it 'be nothing' do
        assert_raises RuntimeError do
          restrict_force_push 'git push --force-with-lease origin master'
        end
      end
    end

    context '-f in command && GIT_ALLOW_FORCE_PUSH=yes' do
      it 'be nothing' do
        current = ENV['GIT_ALLOW_FORCE_PUSH']
        ENV['GIT_ALLOW_FORCE_PUSH'] = 'yes'

        restrict_force_push 'git push -f origin master'

        ENV['GIT_ALLOW_FORCE_PUSH'] = current
      end
    end

    context 'not force push command' do
      it 'be nothing' do
        restrict_force_push 'git push origin master'
      end
    end
  end
end

Minitestを使うのは初めてなので、正直こういう書き方でいいのか、 やっぱりrspecって便利だなと改めて思うところです。