diff --git a/dev-infra/commit-message/validate-file.ts b/dev-infra/commit-message/validate-file.ts index 50b8b72afc..830d33ab2d 100644 --- a/dev-infra/commit-message/validate-file.ts +++ b/dev-infra/commit-message/validate-file.ts @@ -9,19 +9,25 @@ import {readFileSync} from 'fs'; import {resolve} from 'path'; import {getRepoBaseDir} from '../utils/config'; -import {info} from '../utils/console'; +import {error, green, info, red} from '../utils/console'; import {deleteCommitMessageDraft, saveCommitMessageDraft} from './commit-message-draft'; -import {validateCommitMessage} from './validate'; +import {printValidationErrors, validateCommitMessage} from './validate'; /** Validate commit message at the provided file path. */ export function validateFile(filePath: string) { const commitMessage = readFileSync(resolve(getRepoBaseDir(), filePath), 'utf8'); - if (validateCommitMessage(commitMessage)) { - info('√ Valid commit message'); + const {valid, errors} = validateCommitMessage(commitMessage); + if (valid) { + info(`${green('√')} Valid commit message`); deleteCommitMessageDraft(filePath); return; } + + error(`${red('✘')} Invalid commit message`); + printValidationErrors(errors); + error('Aborting commit attempt due to invalid commit message.'); + // On all invalid commit messages, the commit message should be saved as a draft to be // restored on the next commit attempt. saveCommitMessageDraft(filePath, commitMessage); diff --git a/dev-infra/commit-message/validate-range.ts b/dev-infra/commit-message/validate-range.ts index 2b29cf8382..a6fa8d4e0b 100644 --- a/dev-infra/commit-message/validate-range.ts +++ b/dev-infra/commit-message/validate-range.ts @@ -5,11 +5,11 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ -import {info} from '../utils/console'; +import {error, info} from '../utils/console'; import {exec} from '../utils/shelljs'; import {parseCommitMessage} from './parse'; -import {validateCommitMessage, ValidateCommitMessageOptions} from './validate'; +import {printValidationErrors, validateCommitMessage, ValidateCommitMessageOptions} from './validate'; // Whether the provided commit is a fixup commit. const isNonFixup = (m: string) => !parseCommitMessage(m).isFixup; @@ -19,11 +19,20 @@ const extractCommitHeader = (m: string) => parseCommitMessage(m).header; /** Validate all commits in a provided git commit range. */ export function validateCommitRange(range: string) { - // A random value is used as a string to allow for a definite split point in the git log result. + /** + * A random value is used as a string to allow for a definite split point in the git log result. + */ const randomValueSeparator = `${Math.random()}`; - // Custom git log format that provides the commit header and body, separated as expected with - // the custom separator as the trailing value. + /** + * Custom git log format that provides the commit header and body, separated as expected with the + * custom separator as the trailing value. + */ const gitLogFormat = `%s%n%n%b${randomValueSeparator}`; + /** + * A list of tuples containing a commit header string and the list of error messages for the + * commit. + */ + const errors: [commitHeader: string, errors: string[]][] = []; // Retrieve the commits in the provided range. const result = exec(`git log --reverse --format=${gitLogFormat} ${range}`); @@ -45,12 +54,22 @@ export function validateCommitRange(range: string) { undefined : commits.slice(0, i).filter(isNonFixup).map(extractCommitHeader) }; - return validateCommitMessage(m, options); + const {valid, errors: localErrors, commit} = validateCommitMessage(m, options); + if (localErrors.length) { + errors.push([commit.header, localErrors]); + } + return valid; }); if (allCommitsInRangeValid) { info('√ All commit messages in range valid.'); } else { + error('✘ Invalid commit message'); + errors.forEach(([header, validationErrors]) => { + error.group(header); + printValidationErrors(validationErrors); + error.groupEnd(); + }); // Exit with a non-zero exit code if invalid commit messages have // been discovered. process.exit(1); diff --git a/dev-infra/commit-message/validate.spec.ts b/dev-infra/commit-message/validate.spec.ts index 238a7909a9..24fb996698 100644 --- a/dev-infra/commit-message/validate.spec.ts +++ b/dev-infra/commit-message/validate.spec.ts @@ -8,7 +8,7 @@ // Imports import * as validateConfig from './config'; -import {validateCommitMessage} from './validate'; +import {validateCommitMessage, ValidateCommitMessageResult} from './validate'; type CommitMessageConfig = validateConfig.CommitMessageConfig; @@ -31,44 +31,35 @@ const SCOPES = config.commitMessage.scopes.join(', '); const INVALID = false; const VALID = true; +function expectValidationResult( + validationResult: ValidateCommitMessageResult, valid: boolean, errors: string[] = []) { + expect(validationResult).toEqual(jasmine.objectContaining({valid, errors})); +} + // TODO(josephperrott): Clean up tests to test script rather than for // specific commit messages we want to use. describe('validate-commit-message.js', () => { - let lastError: string = ''; - beforeEach(() => { - lastError = ''; - - spyOn(console, 'error').and.callFake((msg: string) => lastError = msg); spyOn(validateConfig, 'getCommitMessageConfig') .and.returnValue(config as ReturnType); }); describe('validateMessage()', () => { it('should be valid', () => { - expect(validateCommitMessage('feat(packaging): something')).toBe(VALID); - expect(lastError).toBe(''); - - expect(validateCommitMessage('fix(packaging): something')).toBe(VALID); - expect(lastError).toBe(''); - - expect(validateCommitMessage('fixup! fix(packaging): something')).toBe(VALID); - expect(lastError).toBe(''); - - expect(validateCommitMessage('squash! fix(packaging): something')).toBe(VALID); - expect(lastError).toBe(''); - - expect(validateCommitMessage('Revert: "fix(packaging): something"')).toBe(VALID); - expect(lastError).toBe(''); + expectValidationResult(validateCommitMessage('feat(packaging): something'), VALID); + expectValidationResult(validateCommitMessage('fix(packaging): something'), VALID); + expectValidationResult(validateCommitMessage('fixup! fix(packaging): something'), VALID); + expectValidationResult(validateCommitMessage('squash! fix(packaging): something'), VALID); + expectValidationResult(validateCommitMessage('Revert: "fix(packaging): something"'), VALID); }); it('should validate max length', () => { const msg = 'fix(compiler): something super mega extra giga tera long, maybe even longer and longer and longer and longer and longer and longer...'; - expect(validateCommitMessage(msg)).toBe(INVALID); - expect(lastError).toContain(`The commit message header is longer than ${ - config.commitMessage.maxLineLength} characters`); + expectValidationResult(validateCommitMessage(msg), INVALID, [ + `The commit message header is longer than ${config.commitMessage.maxLineLength} characters` + ]); }); it('should skip max length limit for URLs', () => { @@ -77,49 +68,56 @@ describe('validate-commit-message.js', () => { 'limit. For more details see the following super long URL:\n\n' + 'https://github.com/angular/components/commit/e2ace018ddfad10608e0e32932c43dcfef4095d7#diff-9879d6db96fd29134fc802214163b95a'; - expect(validateCommitMessage(msg)).toBe(VALID); + expectValidationResult(validateCommitMessage(msg), VALID); }); it('should validate "(): " format', () => { const msg = 'not correct format'; - expect(validateCommitMessage(msg)).toBe(INVALID); - expect(lastError).toContain(`The commit message header does not match the expected format.`); + expectValidationResult( + validateCommitMessage(msg), INVALID, + [`The commit message header does not match the expected format.`]); }); it('should fail when type is invalid', () => { const msg = 'weird(core): something'; - expect(validateCommitMessage(msg)).toBe(INVALID); - expect(lastError).toContain(`'weird' is not an allowed type.\n => TYPES: ${TYPES}`); + expectValidationResult( + validateCommitMessage(msg), INVALID, + [`'weird' is not an allowed type.\n => TYPES: ${TYPES}`]); }); it('should fail when scope is invalid', () => { const errorMessageFor = (scope: string, header: string) => `'${scope}' is not an allowed scope.\n => SCOPES: ${SCOPES}`; - expect(validateCommitMessage('fix(Compiler): something')).toBe(INVALID); - expect(lastError).toContain(errorMessageFor('Compiler', 'fix(Compiler): something')); + expectValidationResult( + validateCommitMessage('fix(Compiler): something'), INVALID, + [errorMessageFor('Compiler', 'fix(Compiler): something')]); - expect(validateCommitMessage('feat(bah): something')).toBe(INVALID); - expect(lastError).toContain(errorMessageFor('bah', 'feat(bah): something')); + expectValidationResult( + validateCommitMessage('feat(bah): something'), INVALID, + [errorMessageFor('bah', 'feat(bah): something')]); - expect(validateCommitMessage('fix(webworker): something')).toBe(INVALID); - expect(lastError).toContain(errorMessageFor('webworker', 'fix(webworker): something')); + expectValidationResult( + validateCommitMessage('fix(webworker): something'), INVALID, + [errorMessageFor('webworker', 'fix(webworker): something')]); - expect(validateCommitMessage('refactor(security): something')).toBe(INVALID); - expect(lastError).toContain(errorMessageFor('security', 'refactor(security): something')); + expectValidationResult( + validateCommitMessage('refactor(security): something'), INVALID, + [errorMessageFor('security', 'refactor(security): something')]); - expect(validateCommitMessage('refactor(docs): something')).toBe(INVALID); - expect(lastError).toContain(errorMessageFor('docs', 'refactor(docs): something')); + expectValidationResult( + validateCommitMessage('refactor(docs): something'), INVALID, + [errorMessageFor('docs', 'refactor(docs): something')]); - expect(validateCommitMessage('feat(angular): something')).toBe(INVALID); - expect(lastError).toContain(errorMessageFor('angular', 'feat(angular): something')); + expectValidationResult( + validateCommitMessage('feat(angular): something'), INVALID, + [errorMessageFor('angular', 'feat(angular): something')]); }); it('should allow empty scope', () => { - expect(validateCommitMessage('build: blablabla')).toBe(VALID); - expect(lastError).toBe(''); + expectValidationResult(validateCommitMessage('build: blablabla'), VALID); }); // We do not want to allow WIP. It is OK to fail the PR build in this case to show that there is @@ -127,30 +125,25 @@ describe('validate-commit-message.js', () => { it('should not allow "WIP: ..." syntax', () => { const msg = 'WIP: fix: something'; - expect(validateCommitMessage(msg)).toBe(INVALID); - expect(lastError).toContain(`'WIP' is not an allowed type.\n => TYPES: ${TYPES}`); + expectValidationResult( + validateCommitMessage(msg), INVALID, + [`'WIP' is not an allowed type.\n => TYPES: ${TYPES}`]); }); describe('(revert)', () => { it('should allow valid "revert: ..." syntaxes', () => { - expect(validateCommitMessage('revert: anything')).toBe(VALID); - expect(lastError).toBe(''); - - expect(validateCommitMessage('Revert: "anything"')).toBe(VALID); - expect(lastError).toBe(''); - - expect(validateCommitMessage('revert anything')).toBe(VALID); - expect(lastError).toBe(''); - - expect(validateCommitMessage('rEvErT anything')).toBe(VALID); - expect(lastError).toBe(''); + expectValidationResult(validateCommitMessage('revert: anything'), VALID); + expectValidationResult(validateCommitMessage('Revert: "anything"'), VALID); + expectValidationResult(validateCommitMessage('revert anything'), VALID); + expectValidationResult(validateCommitMessage('rEvErT anything'), VALID); }); it('should not allow "revert(scope): ..." syntax', () => { const msg = 'revert(compiler): reduce generated code payload size by 65%'; - expect(validateCommitMessage(msg)).toBe(INVALID); - expect(lastError).toContain(`'revert' is not an allowed type.\n => TYPES: ${TYPES}`); + expectValidationResult( + validateCommitMessage(msg), INVALID, + [`'revert' is not an allowed type.\n => TYPES: ${TYPES}`]); }); // https://github.com/angular/angular/issues/23479 @@ -158,28 +151,26 @@ describe('validate-commit-message.js', () => { const msg = 'Revert "fix(compiler): Pretty print object instead of [Object object] (#22689)" (#23442)'; - expect(validateCommitMessage(msg)).toBe(VALID); - expect(lastError).toBe(''); + expectValidationResult(validateCommitMessage(msg), VALID); }); }); describe('(squash)', () => { describe('without `disallowSquash`', () => { it('should return commits as valid', () => { - expect(validateCommitMessage('squash! feat(core): add feature')).toBe(VALID); - expect(validateCommitMessage('squash! fix: a bug')).toBe(VALID); - expect(validateCommitMessage('squash! fix a typo')).toBe(VALID); + expectValidationResult(validateCommitMessage('squash! feat(core): add feature'), VALID); + expectValidationResult(validateCommitMessage('squash! fix: a bug'), VALID); + expectValidationResult(validateCommitMessage('squash! fix a typo'), VALID); }); }); describe('with `disallowSquash`', () => { it('should fail', () => { - expect(validateCommitMessage('fix(core): something', {disallowSquash: true})).toBe(VALID); - expect(validateCommitMessage('squash! fix(core): something', { - disallowSquash: true - })).toBe(INVALID); - expect(lastError).toContain( - 'The commit must be manually squashed into the target commit'); + expectValidationResult( + validateCommitMessage('fix(core): something', {disallowSquash: true}), VALID); + expectValidationResult( + validateCommitMessage('squash! fix(core): something', {disallowSquash: true}), + INVALID, ['The commit must be manually squashed into the target commit']); }); }); }); @@ -187,9 +178,9 @@ describe('validate-commit-message.js', () => { describe('(fixup)', () => { describe('without `nonFixupCommitHeaders`', () => { it('should return commits as valid', () => { - expect(validateCommitMessage('fixup! feat(core): add feature')).toBe(VALID); - expect(validateCommitMessage('fixup! fix: a bug')).toBe(VALID); - expect(validateCommitMessage('fixup! fixup! fix: a bug')).toBe(VALID); + expectValidationResult(validateCommitMessage('fixup! feat(core): add feature'), VALID); + expectValidationResult(validateCommitMessage('fixup! fix: a bug'), VALID); + expectValidationResult(validateCommitMessage('fixup! fixup! fix: a bug'), VALID); }); }); @@ -197,36 +188,39 @@ describe('validate-commit-message.js', () => { it('should check that the fixup commit matches a non-fixup one', () => { const msg = 'fixup! foo'; - expect(validateCommitMessage( - msg, {disallowSquash: false, nonFixupCommitHeaders: ['foo', 'bar', 'baz']})) - .toBe(VALID); - expect(validateCommitMessage( - msg, {disallowSquash: false, nonFixupCommitHeaders: ['bar', 'baz', 'foo']})) - .toBe(VALID); - expect(validateCommitMessage( - msg, {disallowSquash: false, nonFixupCommitHeaders: ['baz', 'foo', 'bar']})) - .toBe(VALID); + expectValidationResult( + validateCommitMessage( + msg, {disallowSquash: false, nonFixupCommitHeaders: ['foo', 'bar', 'baz']}), + VALID); + expectValidationResult( + validateCommitMessage( + msg, {disallowSquash: false, nonFixupCommitHeaders: ['bar', 'baz', 'foo']}), + VALID); + expectValidationResult( + validateCommitMessage( + msg, {disallowSquash: false, nonFixupCommitHeaders: ['baz', 'foo', 'bar']}), + VALID); - expect(validateCommitMessage( - msg, {disallowSquash: false, nonFixupCommitHeaders: ['qux', 'quux', 'quuux']})) - .toBe(INVALID); - expect(lastError).toContain( - 'Unable to find match for fixup commit among prior commits: \n' + - ' qux\n' + - ' quux\n' + - ' quuux'); + expectValidationResult( + validateCommitMessage( + msg, {disallowSquash: false, nonFixupCommitHeaders: ['qux', 'quux', 'quuux']}), + INVALID, + ['Unable to find match for fixup commit among prior commits: \n' + + ' qux\n' + + ' quux\n' + + ' quuux']); }); it('should fail if `nonFixupCommitHeaders` is empty', () => { - expect(validateCommitMessage('refactor(core): make reactive', { - disallowSquash: false, - nonFixupCommitHeaders: [] - })).toBe(VALID); - expect(validateCommitMessage( - 'fixup! foo', {disallowSquash: false, nonFixupCommitHeaders: []})) - .toBe(INVALID); - expect(lastError).toContain( - `Unable to find match for fixup commit among prior commits: -`); + expectValidationResult( + validateCommitMessage( + 'refactor(core): make reactive', + {disallowSquash: false, nonFixupCommitHeaders: []}), + VALID); + expectValidationResult( + validateCommitMessage( + 'fixup! foo', {disallowSquash: false, nonFixupCommitHeaders: []}), + INVALID, [`Unable to find match for fixup commit among prior commits: -`]); }); }); }); @@ -246,24 +240,27 @@ describe('validate-commit-message.js', () => { }); it('should fail validation if the body is shorter than `minBodyLength`', () => { - expect(validateCommitMessage( - 'fix(core): something\n\n Explanation of the motivation behind this change')) - .toBe(VALID); - expect(validateCommitMessage('fix(core): something\n\n too short')).toBe(INVALID); - expect(lastError).toContain( - 'The commit message body does not meet the minimum length of 30 characters'); - expect(validateCommitMessage('fix(core): something')).toBe(INVALID); - expect(lastError).toContain( - 'The commit message body does not meet the minimum length of 30 characters'); + expectValidationResult( + validateCommitMessage( + 'fix(core): something\n\n Explanation of the motivation behind this change'), + VALID); + expectValidationResult( + validateCommitMessage('fix(core): something\n\n too short'), INVALID, + ['The commit message body does not meet the minimum length of 30 characters']); + expectValidationResult(validateCommitMessage('fix(core): something'), INVALID, [ + + 'The commit message body does not meet the minimum length of 30 characters' + ]); }); it('should pass validation if the body is shorter than `minBodyLength` but the commit type is in the `minBodyLengthTypeExclusions` list', () => { - expect(validateCommitMessage('docs: just fixing a typo')).toBe(VALID); - expect(validateCommitMessage('docs(core): just fixing a typo')).toBe(VALID); - expect(validateCommitMessage( - 'docs(core): just fixing a typo\n\nThis was just a silly typo.')) - .toBe(VALID); + expectValidationResult(validateCommitMessage('docs: just fixing a typo'), VALID); + expectValidationResult(validateCommitMessage('docs(core): just fixing a typo'), VALID); + expectValidationResult( + validateCommitMessage( + 'docs(core): just fixing a typo\n\nThis was just a silly typo.'), + VALID); }); }); }); diff --git a/dev-infra/commit-message/validate.ts b/dev-infra/commit-message/validate.ts index 1c10ada900..e66679b2b9 100644 --- a/dev-infra/commit-message/validate.ts +++ b/dev-infra/commit-message/validate.ts @@ -8,7 +8,7 @@ import {error} from '../utils/console'; import {COMMIT_TYPES, getCommitMessageConfig, ScopeRequirement} from './config'; -import {parseCommitMessage} from './parse'; +import {parseCommitMessage, ParsedCommitMessage} from './parse'; /** Options for commit message validation. */ export interface ValidateCommitMessageOptions { @@ -16,133 +16,147 @@ export interface ValidateCommitMessageOptions { nonFixupCommitHeaders?: string[]; } +/** The result of a commit message validation check. */ +export interface ValidateCommitMessageResult { + valid: boolean; + errors: string[]; + commit: ParsedCommitMessage; +} + /** Regex matching a URL for an entire commit body line. */ const COMMIT_BODY_URL_LINE_RE = /^https?:\/\/.*$/; /** Validate a commit message against using the local repo's config. */ export function validateCommitMessage( - commitMsg: string, options: ValidateCommitMessageOptions = {}) { - function printError(errorMessage: string) { - error( - `INVALID COMMIT MSG: \n` + - `${'─'.repeat(40)}\n` + - `${commitMsg}\n` + - `${'─'.repeat(40)}\n` + - `ERROR: \n` + - ` ${errorMessage}` + - `\n\n` + - `The expected format for a commit is: \n` + - `(): \n\n`); - } - + commitMsg: string, options: ValidateCommitMessageOptions = {}): ValidateCommitMessageResult { const config = getCommitMessageConfig().commitMessage; const commit = parseCommitMessage(commitMsg); + const errors: string[] = []; - //////////////////////////////////// - // Checking revert, squash, fixup // - //////////////////////////////////// + /** Perform the validation checks against the parsed commit. */ + function validateCommitAndCollectErrors() { + // TODO(josephperrott): Remove early return calls when commit message errors are found - // All revert commits are considered valid. - if (commit.isRevert) { - return true; - } + //////////////////////////////////// + // Checking revert, squash, fixup // + //////////////////////////////////// - // All squashes are considered valid, as the commit will be squashed into another in - // the git history anyway, unless the options provided to not allow squash commits. - if (commit.isSquash) { - if (options.disallowSquash) { - printError('The commit must be manually squashed into the target commit'); + // All revert commits are considered valid. + if (commit.isRevert) { + return true; + } + + // All squashes are considered valid, as the commit will be squashed into another in + // the git history anyway, unless the options provided to not allow squash commits. + if (commit.isSquash) { + if (options.disallowSquash) { + errors.push('The commit must be manually squashed into the target commit'); + return false; + } + return true; + } + + // Fixups commits are considered valid, unless nonFixupCommitHeaders are provided to check + // against. If `nonFixupCommitHeaders` is not empty, we check whether there is a corresponding + // non-fixup commit (i.e. a commit whose header is identical to this commit's header after + // stripping the `fixup! ` prefix), otherwise we assume this verification will happen in another + // check. + if (commit.isFixup) { + if (options.nonFixupCommitHeaders && !options.nonFixupCommitHeaders.includes(commit.header)) { + errors.push( + 'Unable to find match for fixup commit among prior commits: ' + + (options.nonFixupCommitHeaders.map(x => `\n ${x}`).join('') || '-')); + return false; + } + + return true; + } + + //////////////////////////// + // Checking commit header // + //////////////////////////// + if (commit.header.length > config.maxLineLength) { + errors.push(`The commit message header is longer than ${config.maxLineLength} characters`); return false; } - return true; - } - // Fixups commits are considered valid, unless nonFixupCommitHeaders are provided to check - // against. If `nonFixupCommitHeaders` is not empty, we check whether there is a corresponding - // non-fixup commit (i.e. a commit whose header is identical to this commit's header after - // stripping the `fixup! ` prefix), otherwise we assume this verification will happen in another - // check. - if (commit.isFixup) { - if (options.nonFixupCommitHeaders && !options.nonFixupCommitHeaders.includes(commit.header)) { - printError( - 'Unable to find match for fixup commit among prior commits: ' + - (options.nonFixupCommitHeaders.map(x => `\n ${x}`).join('') || '-')); + if (!commit.type) { + errors.push(`The commit message header does not match the expected format.`); + return false; + } + + if (COMMIT_TYPES[commit.type] === undefined) { + errors.push(`'${commit.type}' is not an allowed type.\n => TYPES: ${ + Object.keys(COMMIT_TYPES).join(', ')}`); + return false; + } + + /** The scope requirement level for the provided type of the commit message. */ + const scopeRequirementForType = COMMIT_TYPES[commit.type].scope; + + if (scopeRequirementForType === ScopeRequirement.Forbidden && commit.scope) { + errors.push(`Scopes are forbidden for commits with type '${commit.type}', but a scope of '${ + commit.scope}' was provided.`); + return false; + } + + if (scopeRequirementForType === ScopeRequirement.Required && !commit.scope) { + errors.push( + `Scopes are required for commits with type '${commit.type}', but no scope was provided.`); + return false; + } + + if (commit.scope && !config.scopes.includes(commit.scope)) { + errors.push( + `'${commit.scope}' is not an allowed scope.\n => SCOPES: ${config.scopes.join(', ')}`); + return false; + } + + // Commits with the type of `release` do not require a commit body. + if (commit.type === 'release') { + return true; + } + + ////////////////////////// + // Checking commit body // + ////////////////////////// + + if (!config.minBodyLengthTypeExcludes?.includes(commit.type) && + commit.bodyWithoutLinking.trim().length < config.minBodyLength) { + errors.push(`The commit message body does not meet the minimum length of ${ + config.minBodyLength} characters`); + return false; + } + + const bodyByLine = commit.body.split('\n'); + const lineExceedsMaxLength = bodyByLine.some(line => { + // Check if any line exceeds the max line length limit. The limit is ignored for + // lines that just contain an URL (as these usually cannot be wrapped or shortened). + return line.length > config.maxLineLength && !COMMIT_BODY_URL_LINE_RE.test(line); + }); + + if (lineExceedsMaxLength) { + errors.push( + `The commit message body contains lines greater than ${config.maxLineLength} characters`); return false; } return true; } - //////////////////////////// - // Checking commit header // - //////////////////////////// - if (commit.header.length > config.maxLineLength) { - printError(`The commit message header is longer than ${config.maxLineLength} characters`); - return false; - } - - if (!commit.type) { - printError(`The commit message header does not match the expected format.`); - return false; - } - - - - if (COMMIT_TYPES[commit.type] === undefined) { - printError(`'${commit.type}' is not an allowed type.\n => TYPES: ${ - Object.keys(COMMIT_TYPES).join(', ')}`); - return false; - } - - /** The scope requirement level for the provided type of the commit message. */ - const scopeRequirementForType = COMMIT_TYPES[commit.type].scope; - - if (scopeRequirementForType === ScopeRequirement.Forbidden && commit.scope) { - printError(`Scopes are forbidden for commits with type '${commit.type}', but a scope of '${ - commit.scope}' was provided.`); - return false; - } - - if (scopeRequirementForType === ScopeRequirement.Required && !commit.scope) { - printError( - `Scopes are required for commits with type '${commit.type}', but no scope was provided.`); - return false; - } - - if (commit.scope && !config.scopes.includes(commit.scope)) { - printError( - `'${commit.scope}' is not an allowed scope.\n => SCOPES: ${config.scopes.join(', ')}`); - return false; - } - - // Commits with the type of `release` do not require a commit body. - if (commit.type === 'release') { - return true; - } - - ////////////////////////// - // Checking commit body // - ////////////////////////// - - if (!config.minBodyLengthTypeExcludes?.includes(commit.type) && - commit.bodyWithoutLinking.trim().length < config.minBodyLength) { - printError(`The commit message body does not meet the minimum length of ${ - config.minBodyLength} characters`); - return false; - } - - const bodyByLine = commit.body.split('\n'); - const lineExceedsMaxLength = bodyByLine.some(line => { - // Check if any line exceeds the max line length limit. The limit is ignored for - // lines that just contain an URL (as these usually cannot be wrapped or shortened). - return line.length > config.maxLineLength && !COMMIT_BODY_URL_LINE_RE.test(line); - }); - - if (lineExceedsMaxLength) { - printError( - `The commit message body contains lines greater than ${config.maxLineLength} characters`); - return false; - } - - return true; + return {valid: validateCommitAndCollectErrors(), errors, commit}; +} + + +/** Print the error messages from the commit message validation to the console. */ +export function printValidationErrors(errors: string[], print = error) { + print.group(`Error${errors.length === 1 ? '' : 's'}:`); + errors.forEach(line => print(line)); + print.groupEnd(); + print(); + print('The expected format for a commit is: '); + print('(): '); + print(); + print(''); + print(); }