diff --git a/@commitlint/cli/fixtures/no-config/helpurl-only.js b/@commitlint/cli/fixtures/no-config/helpurl-only.js new file mode 100644 index 0000000000..a382d7a17c --- /dev/null +++ b/@commitlint/cli/fixtures/no-config/helpurl-only.js @@ -0,0 +1,5 @@ +// A shareable config that contributes no rules — used to verify that +// --default-config keeps user-supplied --extends configs when it falls back. +module.exports = { + helpUrl: "https://example.com/no-rules", +}; diff --git a/@commitlint/cli/fixtures/no-config/package.json b/@commitlint/cli/fixtures/no-config/package.json new file mode 100644 index 0000000000..98dc375f0a --- /dev/null +++ b/@commitlint/cli/fixtures/no-config/package.json @@ -0,0 +1,4 @@ +{ + "name": "no-config-fixture", + "private": true +} diff --git a/@commitlint/cli/package.json b/@commitlint/cli/package.json index 97eff3f46e..d4ee83cb17 100644 --- a/@commitlint/cli/package.json +++ b/@commitlint/cli/package.json @@ -37,6 +37,7 @@ "pkg": "pkg-check" }, "dependencies": { + "@commitlint/config-conventional": "workspace:^", "@commitlint/format": "workspace:^", "@commitlint/lint": "workspace:^", "@commitlint/load": "workspace:^", diff --git a/@commitlint/cli/src/cli.test.ts b/@commitlint/cli/src/cli.test.ts index b4772b1c0c..1f812301b9 100644 --- a/@commitlint/cli/src/cli.test.ts +++ b/@commitlint/cli/src/cli.test.ts @@ -214,6 +214,66 @@ test("should succeed for input from stdin with rules", async () => { expect(result.exitCode).toBe(ExitCode.CommitlintDefault); }); +test("should fail without config file and without --default-config flag", async () => { + const cwd = await gitBootstrap("fixtures/no-config"); + const result = cli([], { cwd })("feat: this should not work"); + const output = await result; + expect(output.stdout.trim()).toContain("Please add rules"); + expect(result.exitCode).toBe(ExitCode.CommitlintInvalidArgument); +}); + +test("should succeed for conventional input from stdin without config file with --default-config flag", async () => { + const cwd = await gitBootstrap("fixtures/no-config"); + const result = cli(["--default-config"], { cwd })("feat: this should work"); + const output = await result; + expect(output.stderr).toEqual(""); + expect(result.exitCode).toBe(ExitCode.CommitlintDefault); +}); + +test("should fail for non-conventional input from stdin without config file with --default-config flag", async () => { + const cwd = await gitBootstrap("fixtures/no-config"); + const result = cli(["--default-config"], { cwd })("this is not a conventional commit"); + const output = await result; + expect(output.stdout.trim()).toContain("type may not be empty"); + expect(output.stdout.trim()).toContain("subject may not be empty"); + expect(result.exitCode).toBe(ExitCode.CommitlintErrorDefault); +}); + +test("should prefer config file over --default-config flag", async () => { + const cwd = await gitBootstrap("fixtures/default"); + // "type: bar" passes the fixture config (type-enum never [foo]) but would + // fail @commitlint/config-conventional (type "type" is not allowed there) + const result = cli(["--default-config"], { cwd })("type: bar"); + await result; + expect(result.exitCode).toBe(ExitCode.CommitlintDefault); +}); + +test("should fall back to default config with --default-config flag for a config file without rules", async () => { + const cwd = await gitBootstrap("fixtures/empty"); + const result = cli(["--default-config"], { cwd })("feat: this should work"); + await result; + expect(result.exitCode).toBe(ExitCode.CommitlintDefault); +}); + +test("should keep --extends configs when the --default-config fallback applies", async () => { + const cwd = await gitBootstrap("fixtures/no-config"); + // ./helpurl-only contributes a helpUrl but no rules, so the fallback + // still applies and must not drop the user-supplied extends + const result = cli(["--default-config", "--extends", "./helpurl-only"], { cwd })("foo bar"); + const output = await result; + expect(output.stdout.trim()).toContain("type may not be empty"); + expect(output.stdout.trim()).toContain("https://example.com/no-rules"); + expect(result.exitCode).toBe(ExitCode.CommitlintErrorDefault); +}); + +test("should point to the --default-config flag when no rules are found", async () => { + const cwd = await gitBootstrap("fixtures/empty"); + const result = cli([], { cwd })("foo: bar"); + const output = await result; + expect(output.stdout.trim()).toContain("--default-config"); + expect(result.exitCode).toBe(ExitCode.CommitlintInvalidArgument); +}); + test("should fail for input from stdin with rule from rc", async () => { const cwd = await gitBootstrap("fixtures/simple"); const result = cli([], { cwd })("foo: bar"); @@ -609,28 +669,29 @@ test("should print help", async () => { [input] reads from stdin if --edit, --env, --from and --to are omitted Options: - -c, --color toggle colored output [boolean] [default: true] - -g, --config path to the config file; result code 9 if config is missing [string] - --print-config print resolved config [string] [choices: "", "text", "json"] - -d, --cwd directory to execute in [string] [default: (Working Directory)] - -e, --edit read last commit message from the specified file or fallbacks to ./.git/COMMIT_EDITMSG [string] - -E, --env check message in the file at path given by environment variable value [string] - -x, --extends array of shareable configurations to extend [array] - -H, --help-url help url in error message [string] - -f, --from lower end of the commit range to lint; applies if edit=false [string] - --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] - --git-log-args additional git log arguments as space separated string, example '--first-parent --cherry-pick' [string] - -l, --last just analyze the last commit; applies if edit=false [boolean] - -o, --format output format of the results [string] - -p, --parser-preset configuration preset to use for conventional-commits-parser [string] - -q, --quiet toggle console output [boolean] [default: false] - -t, --to upper end of the commit range to lint; applies if edit=false [string] - -V, --verbose enable verbose output for reports without problems [boolean] - --legacy-output use the legacy input output format (single-line 'input: ...') [boolean] - -s, --strict enable strict mode; result code 2 for warnings, 3 for errors [boolean] - --options path to a JSON file or Common.js module containing CLI options - -v, --version display version information [boolean] - -h, --help Show help [boolean]" + -c, --color toggle colored output [boolean] [default: true] + -g, --config path to the config file; result code 9 if config is missing [string] + --default-config use built-in default config (@commitlint/config-conventional) when no rules are found [boolean] + --print-config print resolved config [string] [choices: "", "text", "json"] + -d, --cwd directory to execute in [string] [default: (Working Directory)] + -e, --edit read last commit message from the specified file or fallbacks to ./.git/COMMIT_EDITMSG [string] + -E, --env check message in the file at path given by environment variable value [string] + -x, --extends array of shareable configurations to extend [array] + -H, --help-url help url in error message [string] + -f, --from lower end of the commit range to lint; applies if edit=false [string] + --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] + --git-log-args additional git log arguments as space separated string, example '--first-parent --cherry-pick' [string] + -l, --last just analyze the last commit; applies if edit=false [boolean] + -o, --format output format of the results [string] + -p, --parser-preset configuration preset to use for conventional-commits-parser [string] + -q, --quiet toggle console output [boolean] [default: false] + -t, --to upper end of the commit range to lint; applies if edit=false [string] + -V, --verbose enable verbose output for reports without problems [boolean] + --legacy-output use the legacy input output format (single-line 'input: ...') [boolean] + -s, --strict enable strict mode; result code 2 for warnings, 3 for errors [boolean] + --options path to a JSON file or Common.js module containing CLI options + -v, --version display version information [boolean] + -h, --help Show help [boolean]" `); }); @@ -698,6 +759,15 @@ describe("should print config", () => { `"{"extends":[],"formatter":"@commitlint/format","plugins":{},"rules":{"type-enum":[2,"never",["foo"]]},"helpUrl":"https://github.com/conventional-changelog/commitlint/#what-is-commitlint","prompt":{}}"`, ); }); + + test("should print default config with --default-config flag when no config file is found", async () => { + const cwd = await gitBootstrap("fixtures/no-config"); + const result = cli(["--print-config=json", "--no-color", "--default-config"], { cwd })(); + const output = await result; + const printed = JSON.parse(output.stdout.trim()); + expect(printed.rules).toHaveProperty("type-enum"); + expect(printed.rules["type-enum"][2]).toContain("feat"); + }); }); async function writePkg(payload: unknown, options: TestOptions) { diff --git a/@commitlint/cli/src/cli.ts b/@commitlint/cli/src/cli.ts index 70e106306d..a228abe67a 100644 --- a/@commitlint/cli/src/cli.ts +++ b/@commitlint/cli/src/cli.ts @@ -36,6 +36,8 @@ const pkg: typeof import("../package.json") = require("../package.json"); const gitDefaultCommentChar = "#"; +const defaultConfig = "@commitlint/config-conventional"; + const cli = yargs(process.argv.slice(2)) .options({ color: { @@ -49,6 +51,11 @@ const cli = yargs(process.argv.slice(2)) description: "path to the config file; result code 9 if config is missing", type: "string", }, + "default-config": { + description: + "use built-in default config (@commitlint/config-conventional) when no rules are found", + type: "boolean", + }, "print-config": { choices: ["", "text", "json"], description: "print resolved config", @@ -221,10 +228,7 @@ async function main(args: MainArgs): Promise { } if (typeof options["print-config"] === "string") { - const loaded = await load(getSeed(flags), { - cwd: flags.cwd, - file: flags.config, - }); + const loaded = await loadConfig(flags); switch (options["print-config"]) { case "json": @@ -291,10 +295,7 @@ async function main(args: MainArgs): Promise { throw err; } - const loaded = await load(getSeed(flags), { - cwd: flags.cwd, - file: flags.config, - }); + const loaded = await loadConfig(flags); const parserOpts = selectParserOpts(loaded.parserPreset); const opts: LintOptions & { parserOpts: Options } = { parserOpts: {}, @@ -351,6 +352,7 @@ async function main(args: MainArgs): Promise { "Please add rules to your `commitlint.config.js`", " - Getting started guide: https://commitlint.js.org/guides/getting-started", " - Example config: https://github.com/conventional-changelog/commitlint/blob/master/%40commitlint/config-conventional/src/index.ts", + " - Or run commitlint with the built-in default config: commitlint --default-config", ].join("\n"), }, ], @@ -484,6 +486,42 @@ function getEditValue(flags: CliFlags) { return edit; } +async function loadConfig(flags: CliFlags): Promise { + const loaded = await load(getSeed(flags), { + cwd: flags.cwd, + file: flags.config, + }); + + // `--default-config` falls back to the built-in default config when + // config resolution yields no rules (e.g. no config file was found). + // The default config is prepended so user-supplied --extends configs + // keep precedence over it. + if (flags["default-config"] && Object.keys(loaded.rules).length === 0) { + const extendsWithDefault = [resolveDefaultConfig(flags), ...(flags.extends || [])]; + return load(getSeed({ ...flags, extends: extendsWithDefault }), { + cwd: flags.cwd, + file: flags.config, + }); + } + + return loaded; +} + +function resolveDefaultConfig(flags: CliFlags): string { + // Resolve from the cli package itself first so the fallback works without + // @commitlint/config-conventional being installed in the linted project + // (e.g. `npx commitlint --default-config` or strictly isolated node_modules). + return resolveModulePath(defaultConfig, flags) || defaultConfig; +} + +function resolveModulePath(moduleName: string, flags: CliFlags): string | undefined { + return ( + resolveFromSilent(moduleName, __dirname) || + resolveFromSilent(moduleName, flags.cwd) || + resolveGlobalSilent(moduleName) + ); +} + function getSeed(flags: CliFlags): UserConfig { const n = (flags.extends || []).filter((i): i is string => typeof i === "string"); return n.length > 0 @@ -505,10 +543,7 @@ function selectParserOpts(parserPreset: ParserPreset | undefined) { function loadFormatter(config: QualifiedConfig, flags: CliFlags): Promise { const moduleName = flags.format || config.formatter || "@commitlint/format"; - const modulePath = - resolveFromSilent(moduleName, __dirname) || - resolveFromSilent(moduleName, flags.cwd) || - resolveGlobalSilent(moduleName); + const modulePath = resolveModulePath(moduleName, flags); if (modulePath) { return dynamicImport(modulePath); diff --git a/@commitlint/cli/src/types.ts b/@commitlint/cli/src/types.ts index 818772ab9e..309eb1cedb 100644 --- a/@commitlint/cli/src/types.ts +++ b/@commitlint/cli/src/types.ts @@ -2,6 +2,7 @@ export interface CliFlags { color: boolean; config?: string; cwd: string; + "default-config"?: boolean; edit?: string | boolean; env?: string; extends?: (string | number)[]; diff --git a/docs/guides/local-setup.md b/docs/guides/local-setup.md index b97f573622..0b45890ec3 100644 --- a/docs/guides/local-setup.md +++ b/docs/guides/local-setup.md @@ -263,6 +263,15 @@ deno task --eval commitlint --from HEAD~1 --to HEAD --verbose This will check your last commit and return an error if invalid or a positive output if valid. +::: tip +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)): + +```sh +echo "feat: add new feature" | npx commitlint --default-config +``` + +::: + ### Test the hook You can test the hook by simply committing. You should see something like this if everything works. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index efc0656a62..5db4dc0ddf 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -13,39 +13,63 @@ [input] reads from stdin if --edit, --env, --from and --to are omitted Options: - -c, --color toggle colored output [boolean] [default: true] - -g, --config path to the config file; result code 9 if config is - missing [string] - --print-config print resolved config + -c, --color toggle colored output [boolean] [default: true] + -g, --config path to the config file; result code 9 if config is + missing [string] + --default-config use built-in default config + (@commitlint/config-conventional) when no rules are + found [boolean] + --print-config print resolved config [string] [choices: "", "text", "json"] - -d, --cwd directory to execute in + -d, --cwd directory to execute in [string] [default: (Working Directory)] - -e, --edit read last commit message from the specified file or - fallbacks to ./.git/COMMIT_EDITMSG [string] - -E, --env check message in the file at path given by environment - variable value [string] - -x, --extends array of shareable configurations to extend [array] - -H, --help-url help url in error message [string] - -f, --from lower end of the commit range to lint; applies if - edit=false [string] - --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] - --git-log-args additional git log arguments as space separated string, - example '--first-parent --cherry-pick' [string] - -l, --last just analyze the last commit; applies if edit=false + -e, --edit read last commit message from the specified file or + fallbacks to ./.git/COMMIT_EDITMSG [string] + -E, --env check message in the file at path given by environment + variable value [string] + -x, --extends array of shareable configurations to extend [array] + -H, --help-url help url in error message [string] + -f, --from lower end of the commit range to lint; applies if + edit=false [string] + --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] - -o, --format output format of the results [string] - -p, --parser-preset configuration preset to use for - conventional-commits-parser [string] - -q, --quiet toggle console output [boolean] [default: false] - -t, --to upper end of the commit range to lint; applies if - edit=false [string] - -V, --verbose enable verbose output for reports without problems + --git-log-args additional git log arguments as space separated string, + example '--first-parent --cherry-pick' [string] + -l, --last just analyze the last commit; applies if edit=false [boolean] - -s, --strict enable strict mode; result code 2 for warnings, 3 for - errors [boolean] - --options path to a JSON file or Common.js module containing CLI - options - -v, --version display version information [boolean] - -h, --help Show help [boolean] + -o, --format output format of the results [string] + -p, --parser-preset configuration preset to use for + conventional-commits-parser [string] + -q, --quiet toggle console output [boolean] [default: false] + -t, --to upper end of the commit range to lint; applies if + edit=false [string] + -V, --verbose enable verbose output for reports without problems + [boolean] + --legacy-output use the legacy input output format (single-line 'input: + ...') [boolean] + -s, --strict enable strict mode; result code 2 for warnings, 3 for + errors [boolean] + --options path to a JSON file or Common.js module containing CLI + options + -v, --version display version information [boolean] + -h, --help Show help [boolean] +``` + +## Lint without a config file + +By default commitlint requires a configuration with rules to run — without one +it exits with an error (result code 9). Pass `--default-config` to fall back to +the built-in default config +([@commitlint/config-conventional](https://www.npmjs.com/package/@commitlint/config-conventional)) +when no rules are found, so commitlint can run without any setup: + +```sh +echo "feat: add new feature" | npx commitlint --default-config ``` + +This is useful for one-off checks or server-side hooks (e.g. `pre-receive`) +where creating a `commitlint.config.js` and installing a shareable config is +not practical. A configuration file with rules always takes precedence over +`--default-config`, and configs passed via `--extends` are kept and override +the default config when the fallback applies. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2504af15fd..7927382bb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,6 +124,9 @@ importers: '@commitlint/cli': dependencies: + '@commitlint/config-conventional': + specifier: workspace:^ + version: link:../config-conventional '@commitlint/format': specifier: workspace:^ version: link:../format