Skip to content

Commit cd3816d

Browse files
CJKayAdeAttwood
andauthored
feat(rules): add trailer-exists rule (#2578)
This new rule behaves similarly to the existing `signed-off-by` rule, but introduces what would otherwise be breaking changes to the parser in order to resolve some issues. Rather than attempting to parse the trailers manually, this rule will use Git's `interpret-trailers` subcommand to parse trailers according to Git's rules. This allows us to properly detect all valid trailers, and does not require that the trailer to search for be the last line of the commit message (as is required by the `signed-off-by` rule). One downside to this approach is that Git will not detect trailers if they are grouped alongside other trailers with whitespace in the token (e.g. `BREAKING CHANGE`). Projects that wish to use this rule may use `BREAKING-CHANGE`, which is synonymous with `BREAKING CHANGE` but is considered a valid trailer. Co-authored-by: Ade Attwood <[email protected]>
1 parent 4db4ba1 commit cd3816d

File tree

6 files changed

+179
-1
lines changed

6 files changed

+179
-1
lines changed

@commitlint/rules/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@
4444
"@commitlint/ensure": "^12.1.4",
4545
"@commitlint/message": "^12.1.4",
4646
"@commitlint/to-lines": "^12.1.4",
47-
"@commitlint/types": "^12.1.4"
47+
"@commitlint/types": "^12.1.4",
48+
"execa": "^5.0.0"
4849
},
4950
"gitHead": "70f7f4688b51774e7ac5e40e896cdaa3f132b2bc"
5051
}

@commitlint/rules/src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {subjectEmpty} from './subject-empty';
2626
import {subjectFullStop} from './subject-full-stop';
2727
import {subjectMaxLength} from './subject-max-length';
2828
import {subjectMinLength} from './subject-min-length';
29+
import {trailerExists} from './trailer-exists';
2930
import {typeCase} from './type-case';
3031
import {typeEmpty} from './type-empty';
3132
import {typeEnum} from './type-enum';
@@ -61,6 +62,7 @@ export default {
6162
'subject-full-stop': subjectFullStop,
6263
'subject-max-length': subjectMaxLength,
6364
'subject-min-length': subjectMinLength,
65+
'trailer-exists': trailerExists,
6466
'type-case': typeCase,
6567
'type-empty': typeEmpty,
6668
'type-enum': typeEnum,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import parse from '@commitlint/parse';
2+
import {trailerExists} from './trailer-exists';
3+
4+
const messages = {
5+
empty: 'test:\n',
6+
with: `test: subject\n\nbody\n\nfooter\n\nSigned-off-by:\n\n`,
7+
without: `test: subject\n\nbody\n\nfooter\n\n`,
8+
inSubject: `test: subject Signed-off-by:\n\nbody\n\nfooter\n\n`,
9+
inBody: `test: subject\n\nbody Signed-off-by:\n\nfooter\n\n`,
10+
withSignoffAndNoise: `test: subject
11+
12+
message body
13+
14+
Arbitrary-trailer:
15+
Signed-off-by:
16+
Another-arbitrary-trailer:
17+
18+
# Please enter the commit message for your changes. Lines starting
19+
# with '#' will be ignored, and an empty message aborts the commit.
20+
`,
21+
};
22+
23+
const parsed = {
24+
empty: parse(messages.empty),
25+
with: parse(messages.with),
26+
without: parse(messages.without),
27+
inSubject: parse(messages.inSubject),
28+
inBody: parse(messages.inBody),
29+
withSignoffAndNoise: parse(messages.withSignoffAndNoise),
30+
};
31+
32+
test('empty against "always trailer-exists" should fail', async () => {
33+
const [actual] = trailerExists(
34+
await parsed.empty,
35+
'always',
36+
'Signed-off-by:'
37+
);
38+
39+
const expected = false;
40+
expect(actual).toEqual(expected);
41+
});
42+
43+
test('empty against "never trailer-exists" should succeed', async () => {
44+
const [actual] = trailerExists(await parsed.empty, 'never', 'Signed-off-by:');
45+
const expected = true;
46+
expect(actual).toEqual(expected);
47+
});
48+
49+
test('with against "always trailer-exists" should succeed', async () => {
50+
const [actual] = trailerExists(await parsed.with, 'always', 'Signed-off-by:');
51+
const expected = true;
52+
expect(actual).toEqual(expected);
53+
});
54+
55+
test('with against "never trailer-exists" should fail', async () => {
56+
const [actual] = trailerExists(await parsed.with, 'never', 'Signed-off-by:');
57+
const expected = false;
58+
expect(actual).toEqual(expected);
59+
});
60+
61+
test('without against "always trailer-exists" should fail', async () => {
62+
const [actual] = trailerExists(
63+
await parsed.without,
64+
'always',
65+
'Signed-off-by:'
66+
);
67+
68+
const expected = false;
69+
expect(actual).toEqual(expected);
70+
});
71+
72+
test('without against "never trailer-exists" should succeed', async () => {
73+
const [actual] = trailerExists(
74+
await parsed.without,
75+
'never',
76+
'Signed-off-by:'
77+
);
78+
79+
const expected = true;
80+
expect(actual).toEqual(expected);
81+
});
82+
83+
test('comments and other trailers should be ignored', async () => {
84+
const [actual] = trailerExists(
85+
await parsed.withSignoffAndNoise,
86+
'always',
87+
'Signed-off-by:'
88+
);
89+
90+
const expected = true;
91+
expect(actual).toEqual(expected);
92+
});
93+
94+
test('inSubject against "always trailer-exists" should fail', async () => {
95+
const [actual] = trailerExists(
96+
await parsed.inSubject,
97+
'always',
98+
'Signed-off-by:'
99+
);
100+
101+
const expected = false;
102+
expect(actual).toEqual(expected);
103+
});
104+
105+
test('inSubject against "never trailer-exists" should succeed', async () => {
106+
const [actual] = trailerExists(
107+
await parsed.inSubject,
108+
'never',
109+
'Signed-off-by:'
110+
);
111+
112+
const expected = true;
113+
expect(actual).toEqual(expected);
114+
});
115+
116+
test('inBody against "always trailer-exists" should fail', async () => {
117+
const [actual] = trailerExists(
118+
await parsed.inBody,
119+
'always',
120+
'Signed-off-by:'
121+
);
122+
123+
const expected = false;
124+
expect(actual).toEqual(expected);
125+
});
126+
127+
test('inBody against "never trailer-exists" should succeed', async () => {
128+
const [actual] = trailerExists(
129+
await parsed.inBody,
130+
'never',
131+
'Signed-off-by:'
132+
);
133+
134+
const expected = true;
135+
expect(actual).toEqual(expected);
136+
});
+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import execa from 'execa';
2+
import message from '@commitlint/message';
3+
import toLines from '@commitlint/to-lines';
4+
import {SyncRule} from '@commitlint/types';
5+
6+
export const trailerExists: SyncRule<string> = (
7+
parsed,
8+
when = 'always',
9+
value = ''
10+
) => {
11+
const trailers = execa.sync('git', ['interpret-trailers', '--parse'], {
12+
input: parsed.raw,
13+
}).stdout;
14+
15+
const matches = toLines(trailers).filter((ln) => ln.startsWith(value)).length;
16+
17+
const negated = when === 'never';
18+
const hasTrailer = matches > 0;
19+
20+
return [
21+
negated ? !hasTrailer : hasTrailer,
22+
message([
23+
'message',
24+
negated ? 'must not' : 'must',
25+
'have `' + value + '` trailer',
26+
]),
27+
];
28+
};

@commitlint/types/src/rules.ts

+1
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ export type RulesConfig<V = RuleConfigQuality.User> = {
115115
'subject-full-stop': RuleConfig<V, string>;
116116
'subject-max-length': LengthRuleConfig<V>;
117117
'subject-min-length': LengthRuleConfig<V>;
118+
'trailer-exists': RuleConfig<V, string>;
118119
'type-case': CaseRuleConfig<V>;
119120
'type-empty': RuleConfig<V>;
120121
'type-enum': EnumRuleConfig<V>;

docs/reference-rules.md

+10
Original file line numberDiff line numberDiff line change
@@ -402,3 +402,13 @@ Infinity
402402
```
403403
'Signed-off-by:'
404404
```
405+
406+
#### trailer-exists
407+
408+
- **condition**: `message` has trailer `value`
409+
- **rule**: `always`
410+
- **value**
411+
412+
```
413+
'Signed-off-by:'
414+
```

0 commit comments

Comments
 (0)