Skip to content

Commit e9d753b

Browse files
committed
ci: start using custom commit linting
1 parent abdfe93 commit e9d753b

File tree

1 file changed

+215
-0
lines changed

1 file changed

+215
-0
lines changed

scripts/lintcommit.lua

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

0 commit comments

Comments
 (0)