Skip to content

Commit 3b124a7

Browse files
mrt181Martin T.
andauthored
feat(cz-commitlint): add exclamation mark support for breaking changes (#4655)
Opt-in via prompt.settings.useExclamationMark (default: false). This reserves one header character for '!' during subject length validation, regardless of the isBreaking answer. See: https://www.conventionalcommits.org/en/v1.0.0/#summary Co-authored-by: Martin T. <mrt181@gmail.com>
1 parent 6755e6f commit 3b124a7

File tree

5 files changed

+144
-4
lines changed

5 files changed

+144
-4
lines changed

@commitlint/cz-commitlint/src/SectionHeader.test.ts

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ import { setRules } from "./store/rules.js";
1111

1212
beforeEach(() => {
1313
setRules({});
14-
setPromptConfig({});
14+
setPromptConfig({
15+
settings: {
16+
scopeEnumSeparator: ",",
17+
enableMultipleScopes: false,
18+
useExclamationMark: false,
19+
},
20+
});
1521
});
1622
describe("getQuestions", () => {
1723
test("should contain 'type','scope','subject'", () => {
@@ -120,6 +126,88 @@ describe("combineCommitMessage", () => {
120126
});
121127
expect(commitMessage).toBe("build(typescript)");
122128
});
129+
test("should add ! after type when isBreaking and useExclamationMark is enabled", () => {
130+
setPromptConfig({
131+
settings: {
132+
useExclamationMark: true,
133+
},
134+
});
135+
const commitMessage = combineCommitMessage({
136+
type: "feat",
137+
subject: "add new api",
138+
isBreaking: true,
139+
});
140+
expect(commitMessage).toBe("feat!: add new api");
141+
});
142+
143+
test("should add ! after scope when isBreaking and useExclamationMark is enabled", () => {
144+
setPromptConfig({
145+
settings: {
146+
useExclamationMark: true,
147+
},
148+
});
149+
const commitMessage = combineCommitMessage({
150+
type: "feat",
151+
scope: "api",
152+
subject: "add new endpoint",
153+
isBreaking: true,
154+
});
155+
expect(commitMessage).toBe("feat(api)!: add new endpoint");
156+
});
157+
158+
test("should not add ! when isBreaking but useExclamationMark is disabled (default)", () => {
159+
setPromptConfig({
160+
settings: {
161+
useExclamationMark: false,
162+
},
163+
});
164+
const commitMessage = combineCommitMessage({
165+
type: "feat",
166+
subject: "add new api",
167+
isBreaking: true,
168+
});
169+
expect(commitMessage).toBe("feat: add new api");
170+
});
171+
172+
test("should not add ! when useExclamationMark is enabled but not breaking", () => {
173+
setPromptConfig({
174+
settings: {
175+
useExclamationMark: true,
176+
},
177+
});
178+
const commitMessage = combineCommitMessage({
179+
type: "feat",
180+
subject: "add new api",
181+
});
182+
expect(commitMessage).toBe("feat: add new api");
183+
});
184+
185+
test("should add ! without subject when isBreaking and useExclamationMark is enabled", () => {
186+
setPromptConfig({
187+
settings: {
188+
useExclamationMark: true,
189+
},
190+
});
191+
const commitMessage = combineCommitMessage({
192+
type: "feat",
193+
scope: "api",
194+
isBreaking: true,
195+
});
196+
expect(commitMessage).toBe("feat(api)!");
197+
});
198+
199+
test("should not add ! when type and scope are both empty", () => {
200+
setPromptConfig({
201+
settings: {
202+
useExclamationMark: true,
203+
},
204+
});
205+
const commitMessage = combineCommitMessage({
206+
isBreaking: true,
207+
subject: "drop support",
208+
});
209+
expect(commitMessage).toBe("drop support");
210+
});
123211
});
124212

125213
describe("HeaderQuestion", () => {
@@ -153,4 +241,45 @@ describe("HeaderQuestion", () => {
153241
"subject: subject over limit 6",
154242
);
155243
});
244+
245+
test("should reserve 1 char for '!' when useExclamationMark is enabled", () => {
246+
const headerMaxLength = 20;
247+
const type = "refactor";
248+
const scope = "config";
249+
// "refactor(config)" = 16 chars
250+
const charsUsed = `${type}(${scope})`.length; // 16
251+
const charsAvailable = headerMaxLength - charsUsed - 1; // -1 for '!'
252+
setRules({
253+
"header-max-length": [
254+
RuleConfigSeverity.Error,
255+
"always",
256+
headerMaxLength,
257+
],
258+
"subject-max-length": [RuleConfigSeverity.Error, "always", 10],
259+
});
260+
setPromptConfig({
261+
settings: {
262+
useExclamationMark: true,
263+
},
264+
messages: {
265+
skip: "(press enter to skip)",
266+
max: "upper %d chars",
267+
min: "%d chars at least",
268+
emptyWarning: "%s can not be empty",
269+
upperLimitWarning: "%s: %s over limit %d",
270+
lowerLimitWarning: "%s: %s below limit %d",
271+
},
272+
});
273+
const questions = getQuestions();
274+
const answers = { type, scope };
275+
const subject = questions[2];
276+
(subject.message as any)(answers);
277+
278+
expect("fix".length).toBeLessThanOrEqual(charsAvailable);
279+
expect(subject?.validate?.("fix", answers)).toBe(true);
280+
expect("test".length).toBeGreaterThan(charsAvailable);
281+
expect(subject?.validate?.("test", answers)).toBe(
282+
"subject: subject over limit 1",
283+
);
284+
});
156285
});

@commitlint/cz-commitlint/src/SectionHeader.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,22 @@ export class HeaderQuestion extends Question {
2121
beforeQuestionStart(answers: Answers): void {
2222
const headerRemainLength =
2323
this.headerMaxLength - combineCommitMessage(answers).length;
24-
this.maxLength = Math.min(this.maxLength, headerRemainLength);
24+
// Reserve 1 char for '!' when useExclamationMark is enabled.
25+
const reservedLength = getPromptSettings()["useExclamationMark"] ? 1 : 0;
26+
const remainingLength = Math.max(0, headerRemainLength - reservedLength);
27+
this.maxLength = Math.min(this.maxLength, remainingLength);
2528
this.minLength = Math.min(this.minLength, this.headerMinLength);
2629
}
2730
}
2831

2932
export function combineCommitMessage(answers: Answers): string {
30-
const { type = "", scope = "", subject = "" } = answers;
31-
const prefix = `${type}${scope ? `(${scope})` : ""}`;
33+
const { type = "", scope = "", subject = "", isBreaking } = answers;
34+
const hasPrefix = Boolean(type || scope);
35+
const breakingMark =
36+
hasPrefix && isBreaking && getPromptSettings()["useExclamationMark"]
37+
? "!"
38+
: "";
39+
const prefix = `${type}${scope ? `(${scope})` : ""}${breakingMark}`;
3240

3341
if (subject) {
3442
return ((prefix ? prefix + ": " : "") + subject).trim();

@commitlint/cz-commitlint/src/store/defaultPromptConfigs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export default {
22
settings: {
33
scopeEnumSeparator: ",",
44
enableMultipleScopes: false,
5+
useExclamationMark: false,
56
},
67
messages: {
78
skip: "(press enter to skip)",

@commitlint/types/src/prompt.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export type PromptConfig = {
1919
settings: {
2020
scopeEnumSeparator: string;
2121
enableMultipleScopes: boolean;
22+
useExclamationMark: boolean;
2223
};
2324
messages: PromptMessages;
2425
questions: Partial<

docs/reference/prompt.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Set optional options.
1010

1111
- `enableMultipleScopes`: `(boolean)` Enable multiple scopes, select scope with a radio list, disabled by default.
1212
- `scopeEnumSeparator`: `(string)` Commitlint supports [multiple scopes](/concepts/commit-conventions#multiple-scopes), you can specify the delimiter. It is applied when `enableMultipleScopes` set true.
13+
- `useExclamationMark`: `(boolean)` Append `!` after the type/scope in the commit header when a breaking change is indicated, disabled by default.
1314

1415
## `messages`
1516

0 commit comments

Comments
 (0)