Skip to content

Commit b1939b5

Browse files
committed
ci: start using custom commit linting
1 parent 3d90b1f commit b1939b5

File tree

6 files changed

+336
-1
lines changed

6 files changed

+336
-1
lines changed

.github/workflows/lint.yml

+16
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ jobs:
3737
- name: Check for changes
3838
run: if [[ -n $(git status -s) ]]; then exit 1; fi
3939

40+
lintcommit:
41+
name: Lint commits
42+
runs-on: ubuntu-latest
43+
env:
44+
LINTCOMMIT_FAIL_ON_FIXUP: true
45+
steps:
46+
- name: Install Neovim
47+
uses: rhysd/action-setup-vim@v1
48+
with:
49+
neovim: true
50+
- uses: actions/checkout@v4
51+
with:
52+
fetch-depth: 0
53+
- name: Lint new commits
54+
run: make --silent lintcommit-ci
55+
4056
case-sensitivity:
4157
name: File case sensitivity
4258
runs-on: ubuntu-latest

.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']

CONTRIBUTING.md

+5
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ feat(hues)!: update verbatim text to be distinctive
8181
test(ALL): update screenshots to work on Nightly
8282
```
8383

84+
### Automated commit linting
85+
86+
- To lint messages of already done commits, execute `scripts/lintcommit-ci.sh <git-log-range>`. For example, to lint currently latest commit use `scripts/lintcommit-ci.sh HEAD~..HEAD`.
87+
- To lint commit message before doing commit, [install pre-commit](https://pre-commit.com/#install) and enable it with `pre-commit install --hook-type commit-msg` (from the root directory). NOTE: requires `nvim` executable. If it throws (usually descriptive) error - recommit with proper message.
88+
8489
## Generating help file
8590

8691
If your contribution updates annotations used to generate help file, please regenerate it. You can make this with one of the following (assuming current directory being project root):

Makefile

+3
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ test_file:
2424
documentation:
2525
$(NVIM_EXEC) --headless --noplugin -u ./scripts/minimal_init.lua -c "lua require('mini.doc').generate()" -c "qa!"
2626

27+
lintcommit-ci:
28+
chmod u+x scripts/lintcommit-ci.sh && scripts/lintcommit-ci.sh
29+
2730
basic_setup:
2831
$(NVIM_EXEC) --headless --noplugin -u ./scripts/basic-setup_init.lua
2932

scripts/lintcommit-ci.sh

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/usr/bin/env bash
2+
3+
msg_file_dir='lintcommit-msg-files/'
4+
mkdir -p $msg_file_dir
5+
function cleanup {
6+
rm -rf $msg_file_dir
7+
}
8+
trap cleanup EXIT
9+
10+
range="${1:-origin/sync..HEAD}"
11+
msg_files=()
12+
for commit in $( git rev-list --reverse $range -- ); do \
13+
file="$msg_file_dir$commit" ; \
14+
git log -1 --pretty=format:%B $commit > $file ; \
15+
msg_files+=($file) ; \
16+
done
17+
18+
nvim --headless --noplugin -u ./scripts/lintcommit.lua -- ${msg_files[*]}

scripts/lintcommit.lua

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

0 commit comments

Comments
 (0)