Skip to content

Commit 5be9230

Browse files
committed
ci: start using custom commit linting
1 parent ecc48ce commit 5be9230

File tree

6 files changed

+311
-1
lines changed

6 files changed

+311
-1
lines changed

.github/workflows/lint.yml

+14
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,20 @@ 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+
steps:
44+
- name: Install Neovim
45+
uses: rhysd/action-setup-vim@v1
46+
with:
47+
neovim: true
48+
- uses: actions/checkout@v4
49+
with:
50+
fetch-depth: 0
51+
- name: Lint new commits
52+
run: make --silent lintcommit-ci
53+
4054
case-sensitivity:
4155
name: File case sensitivity
4256
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

+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_subject = 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+
-- Subject 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,
93+
'Use "Resolve #" GitHub keyword to resolve issue/PR '
94+
.. '(not "Fix(/es/ed)", not "Close(/s/d)", not "Resolve(s/d)").'
95+
end
96+
return true, nil
97+
end
98+
99+
local validate_commit_msg = function(lines)
100+
-- Ignore Git comments
101+
lines = vim.tbl_filter(function(l) return l:find('^%s*#') == nil end, lines)
102+
local is_valid, err_msg
103+
104+
-- Allow all lines to be empty to abort commiting
105+
local all_empty = true
106+
for _, l in ipairs(lines) do
107+
if l ~= '' then all_empty = false end
108+
end
109+
if all_empty then return true, nil end
110+
111+
-- Validate subject (first line)
112+
is_valid, err_msg = validate_subject(lines[1])
113+
if not is_valid then return is_valid, err_msg end
114+
115+
-- Validate body
116+
is_valid, err_msg = validate_body(lines)
117+
if not is_valid then return is_valid, err_msg end
118+
119+
-- No validation for footer
120+
121+
-- Should not contain bad wording
122+
for _, l in ipairs(lines) do
123+
is_valid, err_msg = validate_bad_wording(l)
124+
if not is_valid then return is_valid, err_msg end
125+
end
126+
127+
return true, nil
128+
end
129+
130+
local validate_commit_msg_from_file = function(path)
131+
local ok, lines = pcall(vim.fn.readfile, path)
132+
if not ok then return false, 'Could not read file ' .. path end
133+
return validate_commit_msg(lines)
134+
end
135+
136+
-- Actual validation
137+
local exit_code = 0
138+
for i = 0, vim.fn.argc(-1) - 1 do
139+
local path = vim.fn.argv(i, -1)
140+
io.write('Commit message of ' .. vim.fn.fnamemodify(path, ':t') .. ':\n')
141+
local is_valid, err_msg = validate_commit_msg_from_file(path)
142+
io.write((is_valid and 'OK' or err_msg) .. '\n\n')
143+
if not is_valid then exit_code = 1 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+
-- Subject
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 very looong subject'] = 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 looong body 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)