Skip to content

Commit 7af27ba

Browse files
escapedcatclaude
andauthored
feat(cli): add --default-config flag to lint without a config file (#4805)
When config resolution yields no rules, --default-config falls back to the built-in default config (@commitlint/config-conventional) instead of failing with empty-rules: echo "feat: add new feature" | npx commitlint --default-config This makes commitlint usable without any setup, e.g. for one-off checks or server-side pre-receive hooks where installing a shareable config is not practical. A configuration file with rules always takes precedence over the flag, and configs passed via --extends are kept and override the default config when the fallback applies. The empty-rules error message now also points to the new flag, and the CLI reference docs are re-synced with the actual --help output (which was missing --legacy-output). Closes #3662 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 36dc150 commit 7af27ba

9 files changed

Lines changed: 217 additions & 65 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// A shareable config that contributes no rules — used to verify that
2+
// --default-config keeps user-supplied --extends configs when it falls back.
3+
module.exports = {
4+
helpUrl: "https://example.com/no-rules",
5+
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "no-config-fixture",
3+
"private": true
4+
}

@commitlint/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"pkg": "pkg-check"
3838
},
3939
"dependencies": {
40+
"@commitlint/config-conventional": "workspace:^",
4041
"@commitlint/format": "workspace:^",
4142
"@commitlint/lint": "workspace:^",
4243
"@commitlint/load": "workspace:^",

@commitlint/cli/src/cli.test.ts

Lines changed: 92 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,66 @@ test("should succeed for input from stdin with rules", async () => {
214214
expect(result.exitCode).toBe(ExitCode.CommitlintDefault);
215215
});
216216

217+
test("should fail without config file and without --default-config flag", async () => {
218+
const cwd = await gitBootstrap("fixtures/no-config");
219+
const result = cli([], { cwd })("feat: this should not work");
220+
const output = await result;
221+
expect(output.stdout.trim()).toContain("Please add rules");
222+
expect(result.exitCode).toBe(ExitCode.CommitlintInvalidArgument);
223+
});
224+
225+
test("should succeed for conventional input from stdin without config file with --default-config flag", async () => {
226+
const cwd = await gitBootstrap("fixtures/no-config");
227+
const result = cli(["--default-config"], { cwd })("feat: this should work");
228+
const output = await result;
229+
expect(output.stderr).toEqual("");
230+
expect(result.exitCode).toBe(ExitCode.CommitlintDefault);
231+
});
232+
233+
test("should fail for non-conventional input from stdin without config file with --default-config flag", async () => {
234+
const cwd = await gitBootstrap("fixtures/no-config");
235+
const result = cli(["--default-config"], { cwd })("this is not a conventional commit");
236+
const output = await result;
237+
expect(output.stdout.trim()).toContain("type may not be empty");
238+
expect(output.stdout.trim()).toContain("subject may not be empty");
239+
expect(result.exitCode).toBe(ExitCode.CommitlintErrorDefault);
240+
});
241+
242+
test("should prefer config file over --default-config flag", async () => {
243+
const cwd = await gitBootstrap("fixtures/default");
244+
// "type: bar" passes the fixture config (type-enum never [foo]) but would
245+
// fail @commitlint/config-conventional (type "type" is not allowed there)
246+
const result = cli(["--default-config"], { cwd })("type: bar");
247+
await result;
248+
expect(result.exitCode).toBe(ExitCode.CommitlintDefault);
249+
});
250+
251+
test("should fall back to default config with --default-config flag for a config file without rules", async () => {
252+
const cwd = await gitBootstrap("fixtures/empty");
253+
const result = cli(["--default-config"], { cwd })("feat: this should work");
254+
await result;
255+
expect(result.exitCode).toBe(ExitCode.CommitlintDefault);
256+
});
257+
258+
test("should keep --extends configs when the --default-config fallback applies", async () => {
259+
const cwd = await gitBootstrap("fixtures/no-config");
260+
// ./helpurl-only contributes a helpUrl but no rules, so the fallback
261+
// still applies and must not drop the user-supplied extends
262+
const result = cli(["--default-config", "--extends", "./helpurl-only"], { cwd })("foo bar");
263+
const output = await result;
264+
expect(output.stdout.trim()).toContain("type may not be empty");
265+
expect(output.stdout.trim()).toContain("https://example.com/no-rules");
266+
expect(result.exitCode).toBe(ExitCode.CommitlintErrorDefault);
267+
});
268+
269+
test("should point to the --default-config flag when no rules are found", async () => {
270+
const cwd = await gitBootstrap("fixtures/empty");
271+
const result = cli([], { cwd })("foo: bar");
272+
const output = await result;
273+
expect(output.stdout.trim()).toContain("--default-config");
274+
expect(result.exitCode).toBe(ExitCode.CommitlintInvalidArgument);
275+
});
276+
217277
test("should fail for input from stdin with rule from rc", async () => {
218278
const cwd = await gitBootstrap("fixtures/simple");
219279
const result = cli([], { cwd })("foo: bar");
@@ -609,28 +669,29 @@ test("should print help", async () => {
609669
[input] reads from stdin if --edit, --env, --from and --to are omitted
610670
611671
Options:
612-
-c, --color toggle colored output [boolean] [default: true]
613-
-g, --config path to the config file; result code 9 if config is missing [string]
614-
--print-config print resolved config [string] [choices: "", "text", "json"]
615-
-d, --cwd directory to execute in [string] [default: (Working Directory)]
616-
-e, --edit read last commit message from the specified file or fallbacks to ./.git/COMMIT_EDITMSG [string]
617-
-E, --env check message in the file at path given by environment variable value [string]
618-
-x, --extends array of shareable configurations to extend [array]
619-
-H, --help-url help url in error message [string]
620-
-f, --from lower end of the commit range to lint; applies if edit=false [string]
621-
--from-last-tag uses the last tag as the lower end of the commit range to lint; applies if edit=false and from is not set [boolean]
622-
--git-log-args additional git log arguments as space separated string, example '--first-parent --cherry-pick' [string]
623-
-l, --last just analyze the last commit; applies if edit=false [boolean]
624-
-o, --format output format of the results [string]
625-
-p, --parser-preset configuration preset to use for conventional-commits-parser [string]
626-
-q, --quiet toggle console output [boolean] [default: false]
627-
-t, --to upper end of the commit range to lint; applies if edit=false [string]
628-
-V, --verbose enable verbose output for reports without problems [boolean]
629-
--legacy-output use the legacy input output format (single-line 'input: ...') [boolean]
630-
-s, --strict enable strict mode; result code 2 for warnings, 3 for errors [boolean]
631-
--options path to a JSON file or Common.js module containing CLI options
632-
-v, --version display version information [boolean]
633-
-h, --help Show help [boolean]"
672+
-c, --color toggle colored output [boolean] [default: true]
673+
-g, --config path to the config file; result code 9 if config is missing [string]
674+
--default-config use built-in default config (@commitlint/config-conventional) when no rules are found [boolean]
675+
--print-config print resolved config [string] [choices: "", "text", "json"]
676+
-d, --cwd directory to execute in [string] [default: (Working Directory)]
677+
-e, --edit read last commit message from the specified file or fallbacks to ./.git/COMMIT_EDITMSG [string]
678+
-E, --env check message in the file at path given by environment variable value [string]
679+
-x, --extends array of shareable configurations to extend [array]
680+
-H, --help-url help url in error message [string]
681+
-f, --from lower end of the commit range to lint; applies if edit=false [string]
682+
--from-last-tag uses the last tag as the lower end of the commit range to lint; applies if edit=false and from is not set [boolean]
683+
--git-log-args additional git log arguments as space separated string, example '--first-parent --cherry-pick' [string]
684+
-l, --last just analyze the last commit; applies if edit=false [boolean]
685+
-o, --format output format of the results [string]
686+
-p, --parser-preset configuration preset to use for conventional-commits-parser [string]
687+
-q, --quiet toggle console output [boolean] [default: false]
688+
-t, --to upper end of the commit range to lint; applies if edit=false [string]
689+
-V, --verbose enable verbose output for reports without problems [boolean]
690+
--legacy-output use the legacy input output format (single-line 'input: ...') [boolean]
691+
-s, --strict enable strict mode; result code 2 for warnings, 3 for errors [boolean]
692+
--options path to a JSON file or Common.js module containing CLI options
693+
-v, --version display version information [boolean]
694+
-h, --help Show help [boolean]"
634695
`);
635696
});
636697

@@ -698,6 +759,15 @@ describe("should print config", () => {
698759
`"{"extends":[],"formatter":"@commitlint/format","plugins":{},"rules":{"type-enum":[2,"never",["foo"]]},"helpUrl":"https://github.com/conventional-changelog/commitlint/#what-is-commitlint","prompt":{}}"`,
699760
);
700761
});
762+
763+
test("should print default config with --default-config flag when no config file is found", async () => {
764+
const cwd = await gitBootstrap("fixtures/no-config");
765+
const result = cli(["--print-config=json", "--no-color", "--default-config"], { cwd })();
766+
const output = await result;
767+
const printed = JSON.parse(output.stdout.trim());
768+
expect(printed.rules).toHaveProperty("type-enum");
769+
expect(printed.rules["type-enum"][2]).toContain("feat");
770+
});
701771
});
702772

703773
async function writePkg(payload: unknown, options: TestOptions) {

@commitlint/cli/src/cli.ts

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ const pkg: typeof import("../package.json") = require("../package.json");
3636

3737
const gitDefaultCommentChar = "#";
3838

39+
const defaultConfig = "@commitlint/config-conventional";
40+
3941
const cli = yargs(process.argv.slice(2))
4042
.options({
4143
color: {
@@ -49,6 +51,11 @@ const cli = yargs(process.argv.slice(2))
4951
description: "path to the config file; result code 9 if config is missing",
5052
type: "string",
5153
},
54+
"default-config": {
55+
description:
56+
"use built-in default config (@commitlint/config-conventional) when no rules are found",
57+
type: "boolean",
58+
},
5259
"print-config": {
5360
choices: ["", "text", "json"],
5461
description: "print resolved config",
@@ -221,10 +228,7 @@ async function main(args: MainArgs): Promise<void> {
221228
}
222229

223230
if (typeof options["print-config"] === "string") {
224-
const loaded = await load(getSeed(flags), {
225-
cwd: flags.cwd,
226-
file: flags.config,
227-
});
231+
const loaded = await loadConfig(flags);
228232

229233
switch (options["print-config"]) {
230234
case "json":
@@ -291,10 +295,7 @@ async function main(args: MainArgs): Promise<void> {
291295
throw err;
292296
}
293297

294-
const loaded = await load(getSeed(flags), {
295-
cwd: flags.cwd,
296-
file: flags.config,
297-
});
298+
const loaded = await loadConfig(flags);
298299
const parserOpts = selectParserOpts(loaded.parserPreset);
299300
const opts: LintOptions & { parserOpts: Options } = {
300301
parserOpts: {},
@@ -351,6 +352,7 @@ async function main(args: MainArgs): Promise<void> {
351352
"Please add rules to your `commitlint.config.js`",
352353
" - Getting started guide: https://commitlint.js.org/guides/getting-started",
353354
" - Example config: https://github.com/conventional-changelog/commitlint/blob/master/%40commitlint/config-conventional/src/index.ts",
355+
" - Or run commitlint with the built-in default config: commitlint --default-config",
354356
].join("\n"),
355357
},
356358
],
@@ -484,6 +486,42 @@ function getEditValue(flags: CliFlags) {
484486
return edit;
485487
}
486488

489+
async function loadConfig(flags: CliFlags): Promise<QualifiedConfig> {
490+
const loaded = await load(getSeed(flags), {
491+
cwd: flags.cwd,
492+
file: flags.config,
493+
});
494+
495+
// `--default-config` falls back to the built-in default config when
496+
// config resolution yields no rules (e.g. no config file was found).
497+
// The default config is prepended so user-supplied --extends configs
498+
// keep precedence over it.
499+
if (flags["default-config"] && Object.keys(loaded.rules).length === 0) {
500+
const extendsWithDefault = [resolveDefaultConfig(flags), ...(flags.extends || [])];
501+
return load(getSeed({ ...flags, extends: extendsWithDefault }), {
502+
cwd: flags.cwd,
503+
file: flags.config,
504+
});
505+
}
506+
507+
return loaded;
508+
}
509+
510+
function resolveDefaultConfig(flags: CliFlags): string {
511+
// Resolve from the cli package itself first so the fallback works without
512+
// @commitlint/config-conventional being installed in the linted project
513+
// (e.g. `npx commitlint --default-config` or strictly isolated node_modules).
514+
return resolveModulePath(defaultConfig, flags) || defaultConfig;
515+
}
516+
517+
function resolveModulePath(moduleName: string, flags: CliFlags): string | undefined {
518+
return (
519+
resolveFromSilent(moduleName, __dirname) ||
520+
resolveFromSilent(moduleName, flags.cwd) ||
521+
resolveGlobalSilent(moduleName)
522+
);
523+
}
524+
487525
function getSeed(flags: CliFlags): UserConfig {
488526
const n = (flags.extends || []).filter((i): i is string => typeof i === "string");
489527
return n.length > 0
@@ -505,10 +543,7 @@ function selectParserOpts(parserPreset: ParserPreset | undefined) {
505543

506544
function loadFormatter(config: QualifiedConfig, flags: CliFlags): Promise<Formatter> {
507545
const moduleName = flags.format || config.formatter || "@commitlint/format";
508-
const modulePath =
509-
resolveFromSilent(moduleName, __dirname) ||
510-
resolveFromSilent(moduleName, flags.cwd) ||
511-
resolveGlobalSilent(moduleName);
546+
const modulePath = resolveModulePath(moduleName, flags);
512547

513548
if (modulePath) {
514549
return dynamicImport<Formatter>(modulePath);

@commitlint/cli/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export interface CliFlags {
22
color: boolean;
33
config?: string;
44
cwd: string;
5+
"default-config"?: boolean;
56
edit?: string | boolean;
67
env?: string;
78
extends?: (string | number)[];

docs/guides/local-setup.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,15 @@ deno task --eval commitlint --from HEAD~1 --to HEAD --verbose
263263

264264
This will check your last commit and return an error if invalid or a positive output if valid.
265265

266+
::: tip
267+
To try commitlint without creating a configuration file first, pipe a message to it and use the built-in default config ([@commitlint/config-conventional](https://www.npmjs.com/package/@commitlint/config-conventional)):
268+
269+
```sh
270+
echo "feat: add new feature" | npx commitlint --default-config
271+
```
272+
273+
:::
274+
266275
### Test the hook
267276

268277
You can test the hook by simply committing. You should see something like this if everything works.

docs/reference/cli.md

Lines changed: 55 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,39 +13,63 @@
1313
[input] reads from stdin if --edit, --env, --from and --to are omitted
1414
1515
Options:
16-
-c, --color toggle colored output [boolean] [default: true]
17-
-g, --config path to the config file; result code 9 if config is
18-
missing [string]
19-
--print-config print resolved config
16+
-c, --color toggle colored output [boolean] [default: true]
17+
-g, --config path to the config file; result code 9 if config is
18+
missing [string]
19+
--default-config use built-in default config
20+
(@commitlint/config-conventional) when no rules are
21+
found [boolean]
22+
--print-config print resolved config
2023
[string] [choices: "", "text", "json"]
21-
-d, --cwd directory to execute in
24+
-d, --cwd directory to execute in
2225
[string] [default: (Working Directory)]
23-
-e, --edit read last commit message from the specified file or
24-
fallbacks to ./.git/COMMIT_EDITMSG [string]
25-
-E, --env check message in the file at path given by environment
26-
variable value [string]
27-
-x, --extends array of shareable configurations to extend [array]
28-
-H, --help-url help url in error message [string]
29-
-f, --from lower end of the commit range to lint; applies if
30-
edit=false [string]
31-
--from-last-tag uses the last tag as the lower end of the commit range to
32-
lint; applies if edit=false and from is not set [boolean]
33-
--git-log-args additional git log arguments as space separated string,
34-
example '--first-parent --cherry-pick' [string]
35-
-l, --last just analyze the last commit; applies if edit=false
26+
-e, --edit read last commit message from the specified file or
27+
fallbacks to ./.git/COMMIT_EDITMSG [string]
28+
-E, --env check message in the file at path given by environment
29+
variable value [string]
30+
-x, --extends array of shareable configurations to extend [array]
31+
-H, --help-url help url in error message [string]
32+
-f, --from lower end of the commit range to lint; applies if
33+
edit=false [string]
34+
--from-last-tag uses the last tag as the lower end of the commit range
35+
to lint; applies if edit=false and from is not set
3636
[boolean]
37-
-o, --format output format of the results [string]
38-
-p, --parser-preset configuration preset to use for
39-
conventional-commits-parser [string]
40-
-q, --quiet toggle console output [boolean] [default: false]
41-
-t, --to upper end of the commit range to lint; applies if
42-
edit=false [string]
43-
-V, --verbose enable verbose output for reports without problems
37+
--git-log-args additional git log arguments as space separated string,
38+
example '--first-parent --cherry-pick' [string]
39+
-l, --last just analyze the last commit; applies if edit=false
4440
[boolean]
45-
-s, --strict enable strict mode; result code 2 for warnings, 3 for
46-
errors [boolean]
47-
--options path to a JSON file or Common.js module containing CLI
48-
options
49-
-v, --version display version information [boolean]
50-
-h, --help Show help [boolean]
41+
-o, --format output format of the results [string]
42+
-p, --parser-preset configuration preset to use for
43+
conventional-commits-parser [string]
44+
-q, --quiet toggle console output [boolean] [default: false]
45+
-t, --to upper end of the commit range to lint; applies if
46+
edit=false [string]
47+
-V, --verbose enable verbose output for reports without problems
48+
[boolean]
49+
--legacy-output use the legacy input output format (single-line 'input:
50+
...') [boolean]
51+
-s, --strict enable strict mode; result code 2 for warnings, 3 for
52+
errors [boolean]
53+
--options path to a JSON file or Common.js module containing CLI
54+
options
55+
-v, --version display version information [boolean]
56+
-h, --help Show help [boolean]
57+
```
58+
59+
## Lint without a config file
60+
61+
By default commitlint requires a configuration with rules to run — without one
62+
it exits with an error (result code 9). Pass `--default-config` to fall back to
63+
the built-in default config
64+
([@commitlint/config-conventional](https://www.npmjs.com/package/@commitlint/config-conventional))
65+
when no rules are found, so commitlint can run without any setup:
66+
67+
```sh
68+
echo "feat: add new feature" | npx commitlint --default-config
5169
```
70+
71+
This is useful for one-off checks or server-side hooks (e.g. `pre-receive`)
72+
where creating a `commitlint.config.js` and installing a shareable config is
73+
not practical. A configuration file with rules always takes precedence over
74+
`--default-config`, and configs passed via `--extends` are kept and override
75+
the default config when the fallback applies.

0 commit comments

Comments
 (0)