aboutsummaryrefslogtreecommitdiffstats
path: root/git-hooks/git-commit-msg.rb
blob: 86f2eb2e90bcd2f49a5cf064510f9a81992ab735 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
#!/usr/bin/env ruby
# frozen_string_literal: true

require 'rainbow'
require 'fileutils'
require File.expand_path('../git_utils', __dir__)

# Check if given git messages respect an expected style
class CommitMsgChecker # rubocop:disable Metrics/ClassLength
  include GitUtils

  attr_reader :summary, :errors, :warnings

  def initialize(msg)
    @errors = []
    @warnings = []
    @lines = File.readlines(msg)
    @summary = parse_summary(@lines.shift.strip)
    @lines.delete_if { |line| line[0] == '#' }
    @style = find_git_message_style
  end

  def check
    check_summary && check_body
  end

  def pass?
    @errors.empty?
  end

  def fails?
    @errors.any?
  end

  def warnings?
    @warnings.any?
  end

  def format_warnings
    content = [format_summary, '']
    @warnings.each do |w|
      content << "  #{Rainbow('WARNING:').yellow.bright} #{w}"
    end
    content.join("\n") + "\n"
  end

  def format_errors
    content = [format_summary, '']
    @errors.each do |e|
      content << "  #{Rainbow('ERROR:').red.bright} #{e}"
    end
    content.join("\n") + "\n"
  end

  private

  def find_git_message_style
    s = GitUtils.config('lint.format')
    return :odoo if s == 'odoo'
    return :angular if %w[angular conventional conv].include?(s)
    nil
  end

  def format_summary
    Rainbow(':: ').blue.bright + Rainbow(@summary[:summary]).bright
  end

  def check_body
    return true if @lines.empty?

    success = true
    if @lines[0].strip != ''
      @errors << 'The commit body MUST begin with a blank line.'
      success = false
    end

    success && check_body_line_length
  end

  def check_summary
    success = summary_has_tag?
    if @style && @style != @summary[:style]
      success = false
      @errors << format_summary_tag_error
    end
    success && check_summary_length && summary_is_capitalized? \
            && summary_end_with_period?
  end

  def summary_has_tag?
    # If summary type is :any, a tag is not required
    return true if @style.nil?

    return true unless @summary[:tag].nil?

    @errors << 'The commit summary MUST be prefixed with a tag.'
    false
  end

  def summary_is_capitalized?
    if @summary[:summary][0].downcase == @summary[:summary][0]
      @errors << 'The summary MUST be capitalized.'
      return false
    end
    true
  end

  def summary_end_with_period?
    return true unless @summary[:summary][-1] == '.'

    @errors << 'The summary MUST NOT end with a period.'
    false
  end

  def check_body_line_length
    lerr = []
    @lines.each_with_index do |l, i|
      # The following help to note links as references, because they may
      # exceed 72 chars.
      ref_rx = /^(?:[¹²³⁴⁵⁶⁷⁸⁹⁰]+|(?:\[[0-9]+\])|(?:Fixes\:)|(?:See\:)) .*$/
      next if l == '' || l[0] == '#' || ref_rx =~ l
      # Must not exceed 73, because the 72sd char is allowed to be an
      # end-of-line character (\n)
      next if l.length <= 73

      # +2 because we remove the first body line (summary) and index
      # starts at 0, not 1.
      lerr << i + 2
    end
    return true if lerr.empty?

    @errors << format_body_line_length_error(lerr)
    false
  end

  def check_summary_length
    len = @summary[:summary].length
    if len > 72
      @errors << "The summary length MUST NOT exceed 72 chars (here #{len})."
      return false
    end
    return true if len <= 50

    @warnings << "The summary length SHOULD NOT exceed 50 chars (here #{len})."
    true
  end

  def format_body_line_length_error(lerr)
    err = 'The commit body lines MUST NOT exceed 72 chars'
    return err + " (l. #{lerr[0]})." if lerr.length == 1

    err + " (lines #{lerr.join(', ')})."
  end

  def format_summary_tag_error
    err_msg = 'The summary tag and optional scope are malformed, ' \
              'or the summary does not begin with a known tag.'
    if @style == :odoo
      all_tags = odoo_tags.map { |t| "[#{t}]" }.join(', ')
    else
      all_tags = angular_tags.map { |t| "#{t}:" }.join(', ')
    end
    err_msg + "\nKnown tags are #{all_tags}"
  end
end

COMMIT_MSG = ARGV[0]
exit 1 if COMMIT_MSG.nil? || COMMIT_MSG == ''
exit 1 unless File.exist?(COMMIT_MSG)

cc = CommitMsgChecker.new(COMMIT_MSG)

if cc.check
  warn cc.format_warnings if cc.warnings?

else
  # Backup commit msg
  FileUtils.cp COMMIT_MSG, '/tmp/GIT_COMMIT_MSG_BACKUP'

  warn cc.format_errors
  warn cc.format_warnings if cc.warnings?
  warn
  warn 'Read more on:'
  warn '- https://chris.beams.io/posts/git-commit/'
  warn '- https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html'
  warn '- https://conventionalcommits.org/'
  warn '- https://www.odoo.com/documentation/11.0/reference/guidelines.html#git'
  warn
  exit 1
end