Skip to content

Commit b7226eb

Browse files
committed
ci: start using custom commit linting
1 parent 309d753 commit b7226eb

File tree

2 files changed

+271
-1
lines changed

2 files changed

+271
-1
lines changed

.pre-commit-config.yaml

+7-1
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,10 @@ repos:
1010
name: Gendocs
1111
language: system
1212
entry: make --silent documentation
13-
types: []
13+
types: [lua]
14+
- id: lintcommit
15+
name: LintCommit
16+
language: system
17+
entry: nvim
18+
args: ['--headless', '--noplugin', '-u', 'scripts/lintcommit.lua', '--']
19+
stages: ['commit-msg']

scripts/lintcommit.lua

+264
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
-- Validate commit message. Designed to be run as pre-commit hook and in CI.
2+
--
3+
-- Each Neovim's argument for when it is opened is assumed to be a path to
4+
-- a file containing commit message to validate
5+
-- Example usage:
6+
-- ```
7+
-- nvim --headless --noplugin -u scripts/lintcommit.lua -- .git/COMMIT_EDITMSG
8+
-- ```
9+
10+
-- Validator functions
11+
local allowed_commit_types = { 'ci', 'docs', 'feat', 'fix', 'refactor', 'style', 'test' }
12+
13+
local allowed_scopes = { 'ALL' }
14+
for _, module in ipairs(vim.fn.readdir('lua/mini')) do
15+
if module ~= 'init.lua' then table.insert(allowed_scopes, module:match('^(.+)%.lua$')) end
16+
end
17+
18+
local validate_first_line = function(line)
19+
-- Allow starting with 'fixup' to disable commit linting
20+
if vim.startswith(line, 'fixup') then return true, nil end
21+
22+
-- Should match overall conventional commit spec
23+
local commit_type, scope, desc = string.match(line, '^([^(]+)(%b())!?: (.+)$')
24+
if commit_type == nil then
25+
commit_type, desc = string.match(line, '^([^!:]+)!?: (.+)$')
26+
end
27+
if commit_type == nil or desc == nil then
28+
return false,
29+
'First line does not match conventional commit specification of `<type>[optional scope][!]: <description>`: '
30+
.. vim.inspect(line)
31+
end
32+
33+
-- Commit type should be present and be from one of allowed
34+
if not vim.tbl_contains(allowed_commit_types, commit_type) then
35+
local one_of = table.concat(vim.tbl_map(vim.inspect, allowed_commit_types), ', ')
36+
return false, 'Commit type ' .. vim.inspect(commit_type) .. ' is not allowed. Use one of ' .. one_of .. '.'
37+
end
38+
39+
-- Scope, if present, should be from one of allowed
40+
if scope ~= nil then
41+
scope = scope:sub(2, -2)
42+
if not vim.tbl_contains(allowed_scopes, scope) then
43+
local one_of = table.concat(vim.tbl_map(vim.inspect, allowed_scopes), ', ')
44+
return false, 'Scope ' .. vim.inspect(scope) .. ' is not allowed. Use one of ' .. one_of .. '.'
45+
end
46+
end
47+
48+
-- Description should be present and properly formatted
49+
if string.find(desc, '^%w') == nil then
50+
return false, 'Description should start with alphanumeric character: ' .. vim.inspect(desc)
51+
end
52+
53+
if string.find(desc, '^%u%l') ~= nil then
54+
return false, 'Description should not start with capitalized word: ' .. vim.inspect(desc)
55+
end
56+
57+
if string.find(desc, '%p$') ~= nil then
58+
return false, 'Description should not end with punctuation: ' .. vim.inspect(desc)
59+
end
60+
61+
-- Main title should not be too long
62+
if vim.fn.strdisplaywidth(line) > 72 then
63+
return false, 'First line is longer than 72 characters: ' .. vim.inspect(desc)
64+
end
65+
66+
return true, nil
67+
end
68+
69+
local validate_body = function(parts)
70+
if #parts == 1 then return true, nil end
71+
72+
if parts[2] ~= '' then return false, 'Second line should be empty' end
73+
if parts[3] == nil then return false, 'If first line is not enough, body should be present' end
74+
if string.find(parts[3], '^%S') == nil then return false, 'First body line should not start with whitespace.' end
75+
76+
for i = 3, #parts do
77+
if vim.fn.strdisplaywidth(parts[i]) > 80 then
78+
return false, 'Body line is longer than 80 characters: ' .. vim.inspect(parts[i])
79+
end
80+
end
81+
82+
if string.find(parts[#parts], '^%s*$') ~= nil then return false, 'Body should not end with blank line.' end
83+
84+
return true, nil
85+
end
86+
87+
local validate_bad_wording = function(msg)
88+
local has_fix = msg:find('[Ff]ix #') or msg:find('[Ff]ixes #') or msg:find('[Ff]ixed #')
89+
local has_bad_close = msg:find('[Cc]lose[sd]? #') ~= nil
90+
local has_bad_resolve = msg:find('[Rr]esolve[sd] #') ~= nil
91+
if has_fix or has_bad_close or has_bad_resolve then
92+
return false, 'Commit message should use "Resolve #" GitHub keyword to resolve issue/PR.'
93+
end
94+
return true, nil
95+
end
96+
97+
local validate_commit_msg = function(lines)
98+
-- Ignore Git comments
99+
lines = vim.tbl_filter(function(l) return l:find('^%s*#') == nil end, lines)
100+
local is_valid, err_msg
101+
102+
-- Allow all lines to be empty to abort commiting
103+
local all_empty = true
104+
for _, l in ipairs(lines) do
105+
if l ~= '' then all_empty = false end
106+
end
107+
if all_empty then return true, nil end
108+
109+
-- Validate main (first) line
110+
is_valid, err_msg = validate_first_line(lines[1])
111+
if not is_valid then return is_valid, err_msg end
112+
113+
-- Validate body
114+
is_valid, err_msg = validate_body(lines)
115+
if not is_valid then return is_valid, err_msg end
116+
117+
-- No validation for footer
118+
119+
-- Should not contain bad wording
120+
for _, l in ipairs(lines) do
121+
is_valid, err_msg = validate_bad_wording(l)
122+
if not is_valid then return is_valid, err_msg end
123+
end
124+
125+
return true, nil
126+
end
127+
128+
local validate_commit_msg_from_file = function(path)
129+
local ok, lines = pcall(vim.fn.readfile, path)
130+
if not ok then return false, 'Could not read file ' .. path end
131+
return validate_commit_msg(lines)
132+
end
133+
134+
-- Actual validation
135+
local exit_code = 0
136+
for i = 0, vim.fn.argc(-1) - 1 do
137+
local path = vim.fn.argv(i, -1)
138+
local is_valid, err_msg = validate_commit_msg_from_file(path)
139+
if not is_valid then
140+
exit_code = 1
141+
local basename = vim.fn.fnamemodify(path, ':t')
142+
io.write('Commit message at ' .. basename .. ' is not proper:\n' .. err_msg .. '\n')
143+
end
144+
end
145+
146+
os.exit(exit_code)
147+
148+
-- Tests to be run interactively: `_G.test_cases_failed` should be empty.
149+
-- NOTE: Comment out previous `os.exit()` call
150+
local test_cases = {
151+
-- First line
152+
['fixup'] = true,
153+
['fixup: commit message'] = true,
154+
['fixup! commit message'] = true,
155+
156+
['ci: normal message'] = true,
157+
['docs: normal message'] = true,
158+
['feat: normal message'] = true,
159+
['fix: normal message'] = true,
160+
['refactor: normal message'] = true,
161+
['style: normal message'] = true,
162+
['test: normal message'] = true,
163+
164+
['feat(ai): message with scope'] = true,
165+
['feat!: message with breaking change'] = true,
166+
['feat(ai)!: message with scope and breaking change'] = true,
167+
['style(ALL): style all modules'] = true,
168+
169+
['unknown: unknown type'] = false,
170+
['feat(unknown): unknown scope'] = false,
171+
['refactor(): empty scope'] = false,
172+
['ci( ): whitespace as scope'] = false,
173+
174+
['ci no colon after type'] = false,
175+
[': no type before colon 1'] = false,
176+
[' : no type before colon 2'] = false,
177+
[' : no type before colon 3'] = false,
178+
['ci:'] = false,
179+
['ci: '] = false,
180+
['ci: '] = false,
181+
182+
['feat: message with : in it'] = true,
183+
['feat(ai): message with : in it'] = true,
184+
185+
['test: extra space after colon'] = false,
186+
['ci: tab after colon'] = false,
187+
['ci:no space after colon'] = false,
188+
['ci : extra space before colon'] = false,
189+
190+
['ci: period at end of sentence.'] = false,
191+
['ci: punctuation at end of sentence!'] = false,
192+
['ci: Capitalized first word'] = false,
193+
['ci: UPPER_CASE First Word'] = true,
194+
['ci: very very very very very very very very very very looooong first line'] = false,
195+
196+
-- Body
197+
['ci: desc\n\nBody'] = true,
198+
['ci: desc\n\nBody\n\nwith\n \nempty and blank lines'] = true,
199+
200+
['ci: desc\nSecond line is not empty'] = false,
201+
['ci: desc\n\n First body line starts with whitespace'] = false,
202+
['ci: desc\n\nBody\nwith\nVery very very very very very very very very very very very very loong first line'] = false,
203+
204+
['ci: only two lines\n\n'] = false,
205+
['ci: desc\n\nLast line is empty\n\n'] = false,
206+
['ci: desc\n\nLast line is blank\n '] = false,
207+
208+
-- Footer
209+
-- No validation for footer
210+
211+
-- Bad wordings
212+
['ci: this has Fixed #1'] = false,
213+
['ci: this has fixed #1'] = false,
214+
['ci: this Fixes #1'] = false,
215+
['ci: this fixes #1'] = false,
216+
['ci: this will Fix #1'] = false,
217+
['ci: this will fix #1'] = false,
218+
['ci: this has Closed #1'] = false,
219+
['ci: this has closed #1'] = false,
220+
['ci: this Closes #1'] = false,
221+
['ci: this closes #1'] = false,
222+
['ci: this will Close #1'] = false,
223+
['ci: this will close #1'] = false,
224+
['ci: this has Resolved #1'] = false,
225+
['ci: this has resolved #1'] = false,
226+
['ci: this Resolves #1'] = false,
227+
['ci: this resolves #1'] = false,
228+
229+
['ci: desc\n\nthis has Fixed #1'] = false,
230+
['ci: desc\n\nthis has fixed #1'] = false,
231+
['ci: desc\n\nthis Fixes #1'] = false,
232+
['ci: desc\n\nthis fixes #1'] = false,
233+
['ci: desc\n\nthis will Fix #1'] = false,
234+
['ci: desc\n\nthis will fix #1'] = false,
235+
['ci: desc\n\nthis has Closed #1'] = false,
236+
['ci: desc\n\nthis has closed #1'] = false,
237+
['ci: desc\n\nthis Closes #1'] = false,
238+
['ci: desc\n\nthis closes #1'] = false,
239+
['ci: desc\n\nthis will Close #1'] = false,
240+
['ci: desc\n\nthis will close #1'] = false,
241+
['ci: desc\n\nthis has Resolved #1'] = false,
242+
['ci: desc\n\nthis has resolved #1'] = false,
243+
['ci: desc\n\nthis Resolves #1'] = false,
244+
['ci: desc\n\nthis resolves #1'] = false,
245+
246+
-- Ignore comments
247+
['# Comment\nci: desc'] = true,
248+
[' # Comment\nci: desc'] = true,
249+
['ci: desc\n# Comment\n\nBody'] = true,
250+
251+
-- Allow all empty lines
252+
[''] = true,
253+
['\n'] = true,
254+
['\n# Comment'] = true,
255+
}
256+
257+
_G.test_cases_failed = {}
258+
for message, expected in pairs(test_cases) do
259+
local lines = vim.split(message, '\n')
260+
local is_valid = validate_commit_msg(lines)
261+
if is_valid ~= expected then
262+
table.insert(_G.test_cases_failed, { msg = message, expected = expected, actual = is_valid })
263+
end
264+
end

0 commit comments

Comments
 (0)