166 lines
5.1 KiB
TypeScript
166 lines
5.1 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google Inc. All Rights Reserved.
|
|
*
|
|
* 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 {error} from '../utils/console';
|
|
|
|
import {getCommitMessageConfig} from './config';
|
|
|
|
/** Options for commit message validation. */
|
|
export interface ValidateCommitMessageOptions {
|
|
disallowSquash?: boolean;
|
|
nonFixupCommitHeaders?: string[];
|
|
}
|
|
|
|
const FIXUP_PREFIX_RE = /^fixup! /i;
|
|
const GITHUB_LINKING_RE = /((closed?s?)|(fix(es)?(ed)?)|(resolved?s?))\s\#(\d+)/ig;
|
|
const SQUASH_PREFIX_RE = /^squash! /i;
|
|
const REVERT_PREFIX_RE = /^revert:? /i;
|
|
const TYPE_SCOPE_RE = /^(\w+)(?:\(([^)]+)\))?\:\s(.+)$/;
|
|
const COMMIT_HEADER_RE = /^(.*)/i;
|
|
const COMMIT_BODY_RE = /^.*\n\n([\s\S]*)$/;
|
|
|
|
/** Parse a full commit message into its composite parts. */
|
|
export function parseCommitMessage(commitMsg: string) {
|
|
let header = '';
|
|
let body = '';
|
|
let bodyWithoutLinking = '';
|
|
let type = '';
|
|
let scope = '';
|
|
let subject = '';
|
|
|
|
if (COMMIT_HEADER_RE.test(commitMsg)) {
|
|
header = COMMIT_HEADER_RE.exec(commitMsg)![1]
|
|
.replace(FIXUP_PREFIX_RE, '')
|
|
.replace(SQUASH_PREFIX_RE, '');
|
|
}
|
|
if (COMMIT_BODY_RE.test(commitMsg)) {
|
|
body = COMMIT_BODY_RE.exec(commitMsg)![1];
|
|
bodyWithoutLinking = body.replace(GITHUB_LINKING_RE, '');
|
|
}
|
|
|
|
if (TYPE_SCOPE_RE.test(header)) {
|
|
const parsedCommitHeader = TYPE_SCOPE_RE.exec(header)!;
|
|
type = parsedCommitHeader[1];
|
|
scope = parsedCommitHeader[2];
|
|
subject = parsedCommitHeader[3];
|
|
}
|
|
return {
|
|
header,
|
|
body,
|
|
bodyWithoutLinking,
|
|
type,
|
|
scope,
|
|
subject,
|
|
isFixup: FIXUP_PREFIX_RE.test(commitMsg),
|
|
isSquash: SQUASH_PREFIX_RE.test(commitMsg),
|
|
isRevert: REVERT_PREFIX_RE.test(commitMsg),
|
|
};
|
|
}
|
|
|
|
/** 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` +
|
|
`<type>(<scope>): <subject>\n\n<body>`);
|
|
}
|
|
|
|
const config = getCommitMessageConfig().commitMessage;
|
|
const commit = parseCommitMessage(commitMsg);
|
|
|
|
////////////////////////////////////
|
|
// Checking revert, squash, fixup //
|
|
////////////////////////////////////
|
|
|
|
// 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) {
|
|
printError('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)) {
|
|
printError(
|
|
'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) {
|
|
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 (!config.types.includes(commit.type)) {
|
|
printError(`'${commit.type}' is not an allowed type.\n => TYPES: ${config.types.join(', ')}`);
|
|
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 (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');
|
|
if (bodyByLine.some(line => line.length > config.maxLineLength)) {
|
|
printError(
|
|
`The commit messsage body contains lines greater than ${config.maxLineLength} characters`);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|