pre-commitへPull Request送ったときの調査方法

pre-commitって何?という人は、こちらを先に見てください

takitake.hatenablog.com

もしや、バグ?

ignoreできないファイルがある

普段から、pre-commitを愛用していますが、中にはチェックして欲しくないファイルもあります。そんなときは、.pre_commit.ignoreにファイルのパターンを記載するのですが、どうしてもignoreできないケースがありました。

# .pre_commit.ignore
foo/*/*.yml

fooディレクトリ以下にある、YAMLファイルは対象外

# foo/bar/baz.yml
key: val

実際、余計な改行が入っている

$ pre-commit run
pre-commit: Stopping commit because of errors.
foo/bar/baz.yml:2: new blank line at EOF.

無視されず、怒られた orz

ちょっとコードを追って見る

rbenvでRubyを管理しているので、.rbenvディレクトリ以下にGemがインストールされています。RubyのVersionは適宜読み替えて下さい。

$ cd ~/.rbenv/versions/2.0.0-p598/lib/ruby/gems/2.0.0/gems/pre-commit-0.22.1/

bin以下に実行ファイルがある

# bin/pre-commit
#!/usr/bin/env ruby

require 'pre-commit/cli'

if !File.exists?(".git")
  abort "No .git directory found."
end

PreCommit::Cli.new(*ARGV).execute or exit 1

ARGVで引数を渡している

module PreCommit
  class Cli

    def initialize(*args)
      @args = args
    end

    def execute()
      action_name = @args.shift or 'help'
      action = "execute_#{action_name}".to_sym
      if respond_to?(action)
      then send(action, *@args)
      else execute_help(action_name, *@args)
      end
    end

    def execute_run(*args)
      require 'pre-commit/runner'
      PreCommit::Runner.new.run(*args).tap { |ok| puts "No failed checks." if ok }
    end
  end
end

action_name 今回は、runなので execute_run が呼ばれる

# lib/pre-commit/runner.rb
module PreCommit
  class Runner
    include PreCommit::Utils::StagedFiles

    def run(*args)
      set_staged_files(*args)
      run_single(:warnings)
      run_single(:checks  ) or return false
      true
    end
  end
end

runメソッドでは、まずはcommit対象のファイルを探してそう。

set_staged_filesメソッドは、このクラスに無いので

include PreCommit::Utils::StagedFiles

で、読み込んでるのかな?

そろそろ、追うの大変になってきたので、binding.pryを仕込む

# lib/pre-commit/runner.rb
require 'beybug'
require 'pry-beybug'

module PreCommit
  class Runner
    include PreCommit::Utils::StagedFiles

    def run(*args)
      binding.pry
      set_staged_files(*args)
      run_single(:warnings)
      run_single(:checks  ) or return false
      true
    end
  end
end

beybugとpry-beybugをrequireして、止めたい箇所に binding.pry を挿入

$ pre-commit run
Frame number: 0/5

From: /Users/takeshi.takizawa/.rbenv/versions/2.0.0-p598/lib/ruby/gems/2.0.0/gems/pre-commit-0.22.1/lib/pre-commit/runner.rb @ line 28 PreCommit::Runner#run:

    26: def run(*args)
    27:   binding.pry
 => 28:   set_staged_files(*args)
    29:   run_single(:warnings)
    30:   run_single(:checks  ) or return false
    31:   true
    32: end

step実行で先に進めていく

From: /Users/takeshi.takizawa/.rbenv/versions/2.0.0-p598/lib/ruby/gems/2.0.0/gems/pre-commit-0.22.1/lib/pre-commit/utils/staged_files.rb @ line 48 PreCommit::Utils::StagedFiles#filter_files:

    40: def filter_files(files)
    41:   files.reject do |file|
    42:     !File.exists?(file) ||
    43:     File.directory?(file) ||
    44:     (
    45:       size = File.size(file)
    46:       size > 1_000_000 || (size > 20 && binary?(file))
    47:     ) ||
 => 48:     repo_ignores.any? { |ignore| File.fnmatch?(ignore, file) }
    49:   end
    50: end

[4] pry(#<PreCommit::Runner>)> repo_ignores.any? { |ignore| File.fnmatch?(ignore, file) }
=> true
[5] pry(#<PreCommit::Runner>)> file
=> "foo/bar/baz.yml"

ちゃんと、ignore対象になっているみたい。ignoreファイルの書き方はあってそう。

ソースコードに戻ると、listをひとつひとつ実行して、エラーが一つでもあれば show_output でエラー表示しつつ false 返している。

    def run_single(name)
      show_output(name, execute(list_to_run(name)))
    end

    def show_output(name, list)
      if list.any?
        @stderr.puts send(name, list)
        return false
      end
      true
    end

    def execute(list)
      list.map do |cmd|
        result = nil

        seconds = Benchmark.realtime do
          result = cmd.new(pluginator, config, list).call(staged_files.dup)
        end

        puts "#{cmd} #{seconds*1000}ms" if debug

        result
      end.compact
    end

どのチェックに引っかかったか分かるように、デバッグメッセージ埋め込む。

    def execute(list)
      list.map do |cmd|
        result = nil

        seconds = Benchmark.realtime do
          result = cmd.new(pluginator, config, list).call(staged_files.dup)
        end

        puts "#{cmd} #{seconds*1000}ms" if debug

        unless result.nil
          puts "=" * 30
          puts "#{cmd} #{result}"
          puts "=" * 30
        end

        result
      end.compact
    end
$ pre-commit run
==============================
PreCommit::Checks::Whitespace foo/bar/baz.yml:2: new blank line at EOF.
==============================
pre-commit: Stopping commit because of errors.
foo/bar/baz.yml:2: new blank line at EOF.

pre-commit: You can bypass this check using `git commit -n`

PreCommit::Checks::WhitespaceでCheckが通らなかった事がわかった。

"class Whitespace"でgrepして定義位置探す。

# lib/plugins/pre_commit/checks/whitespace.rb
module PreCommit
  module Checks
    class Whitespace < Plugin
      def call(staged_files)
        binding.pry
        errors = `git diff-index --check --cached HEAD -- #{files_string(staged_files)} 2>&1`
        return if $?.success?

        # Initial commit: diff against the empty tree object
        if errors =~ /fatal: bad revision 'HEAD'/
          errors = `git diff-index --check --cached 4b825dc642cb6eb9a060e54bf8d69288fbee4904 -- 2>&1`
          return if $?.success?
        end

        errors
      end
    end
  end
end

call直後で止めてみる

$ pre-commit run

Frame number: 0/11

From: /Users/takeshi.takizawa/.rbenv/versions/2.0.0-p598/lib/ruby/gems/2.0.0/gems/pre-commit-0.22.1/lib/plugins/pre_commit/checks/whitespace.rb @ line 27 PreCommit::Checks::Whitespace#call:

    25: def call(staged_files)
    26:   binding.pry
 => 27:   errors = `git diff-index --check --cached HEAD -- #{files_string(staged_files)} 2>&1`
    28:   return if $?.success?
    29: 
    30:   # Initial commit: diff against the empty tree object
    31:   if errors =~ /fatal: bad revision 'HEAD'/
    32:     errors = `git diff-index --check --cached 4b825dc642cb6eb9a060e54bf8d69288fbee4904 -- 2>&1`
    33:     return if $?.success?
    34:   end
    35: 
    36:   errors
    37: end

[1] pry(#<PreCommit::Checks::Whitespace>)> staged_files
=> []
[2] pry(#<PreCommit::Checks::Whitespace>)> "git diff-index --check --cached HEAD -- #{files_string(staged_files)} 2>&1"
=> "git diff-index --check --cached HEAD --  2>&1"

あぁ、本来は対象のファイルを指定して git diff-index --check するのだけれど、staged_filesが空の場合はファイル指定無し、つまり、全ファイルがチェック対象になっちゃうのね。

空の時はチェックしなければ良いじゃない

github.com

後始末

$ gem pristine pre-commit
Restoring gems to pristine condition...
Restored pre-commit-0.22.1

pre-commitを初期状態に戻す。