-
Notifications
You must be signed in to change notification settings - Fork 294
Expand file tree
/
Copy pathlintcommit.lua
More file actions
343 lines (285 loc) · 12.1 KB
/
lintcommit.lua
File metadata and controls
343 lines (285 loc) · 12.1 KB
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
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
-- Validate commit message. Designed to be run as pre-commit hook and in CI.
--
-- Each Neovim's argument for when it is opened is assumed to be a path to
-- a file containing commit message to validate
-- Example usage:
-- ```
-- nvim --headless --noplugin -u scripts/lintcommit.lua -- .git/COMMIT_EDITMSG
-- ```
-- Validator functions
local allowed_commit_types = { 'ci', 'docs', 'feat', 'fix', 'refactor', 'style', 'test' }
local allowed_scopes = { 'ALL' }
for _, module in ipairs(vim.fn.readdir('lua/mini')) do
if module ~= 'init.lua' then table.insert(allowed_scopes, module:match('^(.+)%.lua$')) end
end
local validate_subject = function(line)
-- Possibly allow starting with 'fixup' to disable commit linting
if vim.startswith(line, 'fixup') then
local is_strict = vim.loop.os_getenv('LINTCOMMIT_STRICT') ~= nil
local msg = is_strict and 'No "fixup" commits are allowed.' or ''
return not is_strict, msg
end
-- Should match overall conventional commit spec
local commit_type, scope, desc = string.match(line, '^([^(]+)(%b())!?: (.+)$')
if commit_type == nil then
commit_type, desc = string.match(line, '^([^!:]+)!?: (.+)$')
end
if commit_type == nil or desc == nil then
return false,
'First line does not match conventional commit specification of `<type>[optional scope][!]: <description>`: '
.. vim.inspect(line)
end
-- Commit type should be present and be from one of allowed
if not vim.tbl_contains(allowed_commit_types, commit_type) then
local one_of = table.concat(vim.tbl_map(vim.inspect, allowed_commit_types), ', ')
return false, 'Commit type ' .. vim.inspect(commit_type) .. ' is not allowed. Use one of ' .. one_of .. '.'
end
-- Scope, if present, should be from one of allowed
if scope ~= nil then
scope = scope:sub(2, -2)
if not vim.tbl_contains(allowed_scopes, scope) then
local one_of = table.concat(vim.tbl_map(vim.inspect, allowed_scopes), ', ')
return false, 'Scope ' .. vim.inspect(scope) .. ' is not allowed. Use one of ' .. one_of .. '.'
end
end
-- Description should be present and properly formatted
if string.find(desc, '^%w') == nil then
return false, 'Description should start with alphanumeric character: ' .. vim.inspect(desc)
end
if string.find(desc, '^%u%l') ~= nil then
return false, 'Description should not start with capitalized word: ' .. vim.inspect(desc)
end
if string.find(desc, '[.,?!;]$') ~= nil then
return false, 'Description should not end with any of `.,?!;`: ' .. vim.inspect(desc)
end
-- Subject should not be too long
if vim.fn.strdisplaywidth(line) > 72 then
return false, 'First line is longer than 72 characters: ' .. vim.inspect(desc)
end
return true, nil
end
local validate_body = function(parts)
if #parts == 1 then return true, nil end
if parts[2] ~= '' then return false, 'Second line should be empty' end
if parts[3] == nil then return false, 'If first line is not enough, body should be present' end
if string.find(parts[3], '^%S') == nil then return false, 'First body line should not start with whitespace.' end
for i = 3, #parts do
if vim.fn.strdisplaywidth(parts[i]) > 80 and not vim.startswith(parts[i], 'Co-authored-by:') then
return false, 'Body line is longer than 80 characters: ' .. vim.inspect(parts[i])
end
end
if string.find(parts[#parts], '^%s*$') ~= nil then return false, 'Body should not end with blank line.' end
return true, nil
end
local validate_bad_wording = function(msg)
local has_fix = msg:find('[Ff]ix #') or msg:find('[Ff]ixes #') or msg:find('[Ff]ixed #')
local has_bad_close = msg:find('[Cc]lose[sd]? #') ~= nil
local has_bad_resolve = msg:find('[Rr]esolve[sd] #') ~= nil
if has_fix or has_bad_close or has_bad_resolve then
return false,
'Use "Resolve #" GitHub keyword to resolve issue/PR '
.. '(not "Fix(/es/ed)", not "Close(/s/d)", not "Resolve(s/d)").'
end
return true, nil
end
local remove_cleanup_lines = function(lines)
-- Remove lines which are assumed to be later cleaned up by Git itself
-- See `git help commit` for option `--cleanup` (assumes default value)
local res = {}
for _, l in ipairs(lines) do
-- Ignore anything past and including scissors line
if l == '# ------------------------ >8 ------------------------' then break end
-- Ignore comments
if l:find('^%s*#') == nil then table.insert(res, l) end
end
-- Ignore trailing blank lines
for i = #res, 1, -1 do
if res[i]:find('%S') ~= nil then break end
res[i] = nil
end
return res
end
local validate_commit_msg = function(lines)
local is_valid, err_msg
-- If not in strict context, ignore lines which will be later cleaned up
local is_strict = vim.loop.os_getenv('LINTCOMMIT_STRICT') ~= nil
if not is_strict then lines = remove_cleanup_lines(lines) end
-- Allow all lines to be empty to abort committing
local all_empty = true
for _, l in ipairs(lines) do
if l ~= '' then all_empty = false end
end
if all_empty then return true, nil end
-- Validate subject (first line)
is_valid, err_msg = validate_subject(lines[1])
if not is_valid then return is_valid, err_msg end
-- Validate body
is_valid, err_msg = validate_body(lines)
if not is_valid then return is_valid, err_msg end
-- No validation for footer
-- Should not contain bad wording
for _, l in ipairs(lines) do
is_valid, err_msg = validate_bad_wording(l)
if not is_valid then return is_valid, err_msg end
end
return true, nil
end
local validate_commit_msg_from_file = function(path)
local ok, lines = pcall(vim.fn.readfile, path)
if not ok then return false, 'Could not read file ' .. path end
return validate_commit_msg(lines)
end
-- Actual validation
local exit_code = 0
for i = 0, vim.fn.argc(-1) - 1 do
local path = vim.fn.argv(i, -1)
io.write('Commit message of ' .. vim.fn.fnamemodify(path, ':t') .. ':\n')
local is_valid, err_msg = validate_commit_msg_from_file(path)
io.write((is_valid and 'OK' or err_msg) .. '\n\n')
if not is_valid then exit_code = 1 end
end
os.exit(exit_code)
-- Tests to be run interactively: `_G.test_cases_failed` should be empty.
-- NOTE: Comment out previous `os.exit()` call
local test_cases = {
-- Subject
['fixup'] = true,
['fixup: commit message'] = true,
['fixup! commit message'] = true,
['ci: normal message'] = true,
['docs: normal message'] = true,
['feat: normal message'] = true,
['fix: normal message'] = true,
['refactor: normal message'] = true,
['style: normal message'] = true,
['test: normal message'] = true,
['feat(ai): message with scope'] = true,
['feat!: message with breaking change'] = true,
['feat(ai)!: message with scope and breaking change'] = true,
['style(ALL): style all modules'] = true,
['unknown: unknown type'] = false,
['feat(unknown): unknown scope'] = false,
['refactor(): empty scope'] = false,
['ci( ): whitespace as scope'] = false,
['ci no colon after type'] = false,
[': no type before colon 1'] = false,
[' : no type before colon 2'] = false,
[' : no type before colon 3'] = false,
['ci:'] = false,
['ci: '] = false,
['ci: '] = false,
['feat: message with : in it'] = true,
['feat(ai): message with : in it'] = true,
['test: extra space after colon'] = false,
['ci: tab after colon'] = false,
['ci:no space after colon'] = false,
['ci : extra space before colon'] = false,
['ci: bad punctuation at end of sentence.'] = false,
['ci: bad punctuation at end of sentence,'] = false,
['ci: bad punctuation at end of sentence?'] = false,
['ci: bad punctuation at end of sentence!'] = false,
['ci: bad punctuation at end of sentence;'] = false,
['ci: good punctuation at end of sentence:'] = true,
['ci: good punctuation at end of sentence"'] = true,
["ci: good punctuation at end of sentence'"] = true,
['ci: good punctuation at end of sentence)'] = true,
['ci: good punctuation at end of sentence]'] = true,
['ci: Capitalized first word'] = false,
['ci: UPPER_CASE First Word'] = true,
['ci: very very very very very very very very very very very looong subject'] = false,
-- Body
['ci: desc\n\nBody'] = true,
['ci: desc\n\nBody\n\nwith\n \nempty and blank lines'] = true,
['ci: desc\nSecond line is not empty'] = false,
['ci: desc\n\n First body line starts with whitespace'] = false,
-- Line width should be checked only in not cleaned up lines
['ci: desc\n\nBody\nwith\nVery very very very very very very very very very very very very looong body line'] = false,
['ci: desc\n\nBody\nwith\n# Comment with very very very very very very very very very very looong body line'] = true,
['ci: desc\n\nBody\nwith\n# ------------------------ >8 ------------------------\nVery very very very very very very very very very very very very looong body line'] = true,
-- Trailing blank lines are allowed in not strict context
['ci: only two lines\n\n'] = true,
['ci: desc\n\nLast line is empty\n\n'] = true,
['ci: desc\n\nLast line is blank\n '] = true,
-- Footer
-- No validation for footer
-- Bad wordings
['ci: this has Fixed #1'] = false,
['ci: this has fixed #1'] = false,
['ci: this Fixes #1'] = false,
['ci: this fixes #1'] = false,
['ci: this will Fix #1'] = false,
['ci: this will fix #1'] = false,
['ci: this has Closed #1'] = false,
['ci: this has closed #1'] = false,
['ci: this Closes #1'] = false,
['ci: this closes #1'] = false,
['ci: this will Close #1'] = false,
['ci: this will close #1'] = false,
['ci: this has Resolved #1'] = false,
['ci: this has resolved #1'] = false,
['ci: this Resolves #1'] = false,
['ci: this resolves #1'] = false,
['ci: desc\n\nthis has Fixed #1'] = false,
['ci: desc\n\nthis has fixed #1'] = false,
['ci: desc\n\nthis Fixes #1'] = false,
['ci: desc\n\nthis fixes #1'] = false,
['ci: desc\n\nthis will Fix #1'] = false,
['ci: desc\n\nthis will fix #1'] = false,
['ci: desc\n\nthis has Closed #1'] = false,
['ci: desc\n\nthis has closed #1'] = false,
['ci: desc\n\nthis Closes #1'] = false,
['ci: desc\n\nthis closes #1'] = false,
['ci: desc\n\nthis will Close #1'] = false,
['ci: desc\n\nthis will close #1'] = false,
['ci: desc\n\nthis has Resolved #1'] = false,
['ci: desc\n\nthis has resolved #1'] = false,
['ci: desc\n\nthis Resolves #1'] = false,
['ci: desc\n\nthis resolves #1'] = false,
-- Comments are allowed in not strict context
['# Comment\nci: desc'] = true,
[' # Comment\nci: desc'] = true,
['ci: desc\n# Comment\n\nBody'] = true,
-- Allow all empty lines
[''] = true,
['\n'] = true,
['\n# Comment'] = true,
}
_G.test_cases_failed = {}
vim.loop.os_unsetenv('LINTCOMMIT_STRICT')
for message, expected in pairs(test_cases) do
local lines = vim.split(message, '\n')
local is_valid = validate_commit_msg(lines)
if is_valid ~= expected then
table.insert(_G.test_cases_failed, { msg = message, expected = expected, actual = is_valid })
end
end
vim.loop.os_setenv('LINTCOMMIT_STRICT', 'true')
local strict_test_cases = {
-- Fixup commit type is not allowed
['fixup'] = false,
['fixup: should fail'] = false,
['fixup! should fail'] = false,
-- - Should only matter in subject
['ci: desc\n\nfixup'] = true,
-- Do not allow comments outside of commit body
['# Comment\nci: desc'] = false,
[' # Comment\nci: desc'] = false,
['ci: desc\n# Comment\n\nBody'] = false,
['ci: desc\n\nBody\n# Comment in body'] = true,
-- Check line width even in previously ignored contexts
['ci: desc\n\nBody\nwith\n# Comment with very very very very very very very very very very looong body line'] = false,
['ci: desc\n\nBody\nwith\n# ------------------------ >8 ------------------------\nVery very very very very very very very very very very very very looong body line'] = false,
-- Trailing blank lines are not allowed in strict context
['ci: only two lines\n\n'] = false,
['ci: desc\n\nLast line is empty\n\n'] = false,
['ci: desc\n\nLast line is blank\n '] = false,
}
for message, expected in pairs(strict_test_cases) do
local lines = vim.split(message, '\n')
local is_valid = validate_commit_msg(lines)
if is_valid ~= expected then
table.insert(_G.test_cases_failed, { msg = message, expected = expected, actual = is_valid })
end
end
-- Cleanup
vim.loop.os_unsetenv('LINTCOMMIT_STRICT')