nx
Version:
485 lines (484 loc) โข 18.2 kB
JavaScript
Object.defineProperty(exports, "__esModule", { value: true });
exports.getLatestGitTagForPattern = getLatestGitTagForPattern;
exports.getGitDiff = getGitDiff;
exports.gitAdd = gitAdd;
exports.gitCommit = gitCommit;
exports.gitTag = gitTag;
exports.gitPush = gitPush;
exports.parseCommits = parseCommits;
exports.parseConventionalCommitsMessage = parseConventionalCommitsMessage;
exports.parseGitCommit = parseGitCommit;
exports.getCommitHash = getCommitHash;
exports.getFirstGitCommit = getFirstGitCommit;
/**
* Special thanks to changelogen for the original inspiration for many of these utilities:
* https://github.com/unjs/changelogen
*/
const node_path_1 = require("node:path");
const minimatch_1 = require("minimatch");
const utils_1 = require("../../../tasks-runner/utils");
const workspace_root_1 = require("../../../utils/workspace-root");
const exec_command_1 = require("./exec-command");
function escapeRegExp(string) {
return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
}
// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
const SEMVER_REGEX = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/g;
async function getLatestGitTagForPattern(releaseTagPattern, additionalInterpolationData = {}, checkAllBranchesWhen) {
/**
* By default, we will try and resolve the latest match for the releaseTagPattern from the current branch,
* falling back to all branches if no match is found on the current branch.
*
* - If checkAllBranchesWhen is true it will cause us to ALWAYS check all branches for the latest match.
* - If checkAllBranchesWhen is explicitly set to false it will cause us to ONLY check the current branch for the latest match.
* - If checkAllBranchesWhen is an array of strings it will cause us to check all branches WHEN the current branch is one of the strings in the array.
*/
let alwaysCheckAllBranches = false;
if (typeof checkAllBranchesWhen !== 'undefined') {
if (typeof checkAllBranchesWhen === 'boolean') {
alwaysCheckAllBranches = checkAllBranchesWhen;
}
else if (Array.isArray(checkAllBranchesWhen)) {
/**
* Get the current git branch and determine whether to check all branches based on the checkAllBranchesWhen parameter
*/
const currentBranch = await (0, exec_command_1.execCommand)('git', [
'rev-parse',
'--abbrev-ref',
'HEAD',
]).then((r) => r.trim());
// Check exact match first
alwaysCheckAllBranches = checkAllBranchesWhen.includes(currentBranch);
// Check if any glob pattern matches next
if (!alwaysCheckAllBranches) {
alwaysCheckAllBranches = checkAllBranchesWhen.some((pattern) => {
const r = minimatch_1.minimatch.makeRe(pattern, { dot: true });
if (!r) {
return false;
}
return r.test(currentBranch);
});
}
}
}
const defaultGitArgs = [
// Apply git config to take version suffixes into account when sorting, e.g. 1.0.0 is newer than 1.0.0-beta.1
'-c',
'versionsort.suffix=-',
'tag',
'--sort',
'-v:refname',
];
try {
let tags;
tags = await (0, exec_command_1.execCommand)('git', [
...defaultGitArgs,
...(alwaysCheckAllBranches ? [] : ['--merged']),
]).then((r) => r
.trim()
.split('\n')
.map((t) => t.trim())
.filter(Boolean));
if (
// Do not run this fallback if the user explicitly set checkAllBranchesWhen to false
checkAllBranchesWhen !== false &&
!tags.length &&
// There is no point in running this fallback if we already checked against all branches
!alwaysCheckAllBranches) {
// try again, but include all tags on the repo instead of just --merged ones
tags = await (0, exec_command_1.execCommand)('git', defaultGitArgs).then((r) => r
.trim()
.split('\n')
.map((t) => t.trim())
.filter(Boolean));
}
if (!tags.length) {
return null;
}
const interpolatedTagPattern = (0, utils_1.interpolate)(releaseTagPattern, {
version: '%v%',
projectName: '%p%',
...additionalInterpolationData,
});
const tagRegexp = `^${escapeRegExp(interpolatedTagPattern)
.replace('%v%', '(.+)')
.replace('%p%', '(.+)')}`;
const matchingSemverTags = tags.filter((tag) =>
// Do the match against SEMVER_REGEX to ensure that we skip tags that aren't valid semver versions
!!tag.match(tagRegexp) &&
tag.match(tagRegexp).some((r) => r.match(SEMVER_REGEX)));
if (!matchingSemverTags.length) {
return null;
}
const [latestMatchingTag, ...rest] = matchingSemverTags[0].match(tagRegexp);
const version = rest.filter((r) => {
return r.match(SEMVER_REGEX);
})[0];
return {
tag: latestMatchingTag,
extractedVersion: version,
};
}
catch {
return null;
}
}
async function getGitDiff(from, to = 'HEAD') {
let range = '';
if (!from || from === to) {
range = to;
}
else {
range = `${from}..${to}`;
}
// Use unique enough separators that we can be relatively certain will not occur within the commit message itself
const commitMetadataSeparator = 'ยงยงยง';
const commitsSeparator = '|@-------@|';
// https://git-scm.com/docs/pretty-formats
const args = [
'--no-pager',
'log',
range,
`--pretty="${commitsSeparator}%n%s${commitMetadataSeparator}%h${commitMetadataSeparator}%an${commitMetadataSeparator}%ae%n%b"`,
'--name-status',
];
// Support cases where the nx workspace root is located at a nested path within the git repo
const relativePath = await getGitRootRelativePath();
if (relativePath) {
args.push(`--relative=${relativePath}`);
}
const r = await (0, exec_command_1.execCommand)('git', args);
return r
.split(`${commitsSeparator}\n`)
.splice(1)
.map((line) => {
const [firstLine, ..._body] = line.split('\n');
const [message, shortHash, authorName, authorEmail] = firstLine.split(commitMetadataSeparator);
const r = {
message,
shortHash,
author: { name: authorName, email: authorEmail },
body: _body.join('\n'),
};
return r;
});
}
async function getChangedTrackedFiles(cwd) {
const result = await (0, exec_command_1.execCommand)('git', ['status', '--porcelain'], {
cwd,
});
const lines = result.split('\n').filter((l) => l.trim().length > 0);
return new Set(lines.map((l) => l.substring(3)));
}
async function gitAdd({ changedFiles, deletedFiles, dryRun, verbose, logFn, cwd, }) {
logFn = logFn || console.log;
// Default to running git add related commands from the workspace root
cwd = cwd || workspace_root_1.workspaceRoot;
let ignoredFiles = [];
let filesToAdd = [];
for (const f of changedFiles ?? []) {
const isFileIgnored = await isIgnored(f, cwd);
if (isFileIgnored) {
ignoredFiles.push(f);
}
else {
filesToAdd.push(f);
}
}
if (deletedFiles?.length > 0) {
const changedTrackedFiles = await getChangedTrackedFiles(cwd);
for (const f of deletedFiles ?? []) {
const isFileIgnored = await isIgnored(f, cwd);
if (isFileIgnored) {
ignoredFiles.push(f);
// git add will fail if trying to add an untracked file that doesn't exist
}
else if (changedTrackedFiles.has(f) || dryRun) {
filesToAdd.push(f);
}
}
}
if (verbose && ignoredFiles.length) {
logFn(`Will not add the following files because they are ignored by git:`);
ignoredFiles.forEach((f) => logFn(f));
}
if (!filesToAdd.length) {
if (!dryRun) {
logFn('\nNo files to stage. Skipping git add.');
}
// if this is a dry run, it's possible that there would have been actual files to add, so it's deceptive to say "No files to stage".
return;
}
const commandArgs = ['add', ...filesToAdd];
const message = dryRun
? `Would stage files in git with the following command, but --dry-run was set:`
: `Staging files in git with the following command:`;
if (verbose) {
logFn(message);
logFn(`git ${commandArgs.join(' ')}`);
}
if (dryRun) {
return;
}
return (0, exec_command_1.execCommand)('git', commandArgs, {
cwd,
});
}
async function isIgnored(filePath, cwd) {
try {
// This command will error if the file is not ignored
await (0, exec_command_1.execCommand)('git', ['check-ignore', filePath], {
cwd,
});
return true;
}
catch {
return false;
}
}
async function gitCommit({ messages, additionalArgs, dryRun, verbose, logFn, }) {
logFn = logFn || console.log;
const commandArgs = ['commit'];
for (const message of messages) {
commandArgs.push('--message', message);
}
if (additionalArgs) {
if (Array.isArray(additionalArgs)) {
commandArgs.push(...additionalArgs);
}
else {
commandArgs.push(...additionalArgs.split(' '));
}
}
if (verbose) {
logFn(dryRun
? `Would commit all previously staged files in git with the following command, but --dry-run was set:`
: `Committing files in git with the following command:`);
logFn(`git ${commandArgs.join(' ')}`);
}
if (dryRun) {
return;
}
let hasStagedFiles = false;
try {
// This command will error if there are staged changes
await (0, exec_command_1.execCommand)('git', ['diff-index', '--quiet', 'HEAD', '--cached']);
}
catch {
hasStagedFiles = true;
}
if (!hasStagedFiles) {
logFn('\nNo staged files found. Skipping commit.');
return;
}
return (0, exec_command_1.execCommand)('git', commandArgs);
}
async function gitTag({ tag, message, additionalArgs, dryRun, verbose, logFn, }) {
logFn = logFn || console.log;
const commandArgs = [
'tag',
// Create an annotated tag (recommended for releases here: https://git-scm.com/docs/git-tag)
'--annotate',
tag,
'--message',
message || tag,
];
if (additionalArgs) {
if (Array.isArray(additionalArgs)) {
commandArgs.push(...additionalArgs);
}
else {
commandArgs.push(...additionalArgs.split(' '));
}
}
if (verbose) {
logFn(dryRun
? `Would tag the current commit in git with the following command, but --dry-run was set:`
: `Tagging the current commit in git with the following command:`);
logFn(`git ${commandArgs.join(' ')}`);
}
if (dryRun) {
return;
}
try {
return await (0, exec_command_1.execCommand)('git', commandArgs);
}
catch (err) {
throw new Error(`Unexpected error when creating tag ${tag}:\n\n${err}`);
}
}
async function gitPush({ gitRemote, dryRun, verbose, }) {
const commandArgs = [
'push',
// NOTE: It's important we use --follow-tags, and not --tags, so that we are precise about what we are pushing
'--follow-tags',
'--no-verify',
'--atomic',
// Set custom git remote if provided
...(gitRemote ? [gitRemote] : []),
];
if (verbose) {
console.log(dryRun
? `Would push the current branch to the remote with the following command, but --dry-run was set:`
: `Pushing the current branch to the remote with the following command:`);
console.log(`git ${commandArgs.join(' ')}`);
}
if (dryRun) {
return;
}
try {
await (0, exec_command_1.execCommand)('git', commandArgs);
}
catch (err) {
throw new Error(`Unexpected git push error: ${err}`);
}
}
function parseCommits(commits) {
return commits.map((commit) => parseGitCommit(commit)).filter(Boolean);
}
function parseConventionalCommitsMessage(message) {
const match = message.match(ConventionalCommitRegex);
if (!match) {
return {
type: '__INVALID__',
scope: '',
description: message,
breaking: false,
};
}
return {
type: match.groups.type || '',
scope: match.groups.scope || '',
description: match.groups.description || '',
breaking: Boolean(match.groups.breaking),
};
}
function extractReferencesFromCommitMessage(message, shortHash) {
const references = [];
for (const m of message.matchAll(PullRequestRE)) {
references.push({ type: 'pull-request', value: m[1] });
}
for (const m of message.matchAll(IssueRE)) {
if (!references.some((i) => i.value === m[1])) {
references.push({ type: 'issue', value: m[1] });
}
}
references.push({ value: shortHash, type: 'hash' });
return references;
}
function getAllAuthorsForCommit(commit) {
const authors = [commit.author];
// Additional authors can be specified in the commit body (depending on the VCS provider)
for (const match of commit.body.matchAll(CoAuthoredByRegex)) {
authors.push({
name: (match.groups.name || '').trim(),
email: (match.groups.email || '').trim(),
});
}
return authors;
}
// https://www.conventionalcommits.org/en/v1.0.0/
// https://regex101.com/r/FSfNvA/1
const ConventionalCommitRegex = /(?<type>[a-z]+)(\((?<scope>.+)\))?(?<breaking>!)?: (?<description>.+)/i;
const CoAuthoredByRegex = /co-authored-by:\s*(?<name>.+)(<(?<email>.+)>)/gim;
const PullRequestRE = /\([ a-z]*(#\d+)\s*\)/gm;
const IssueRE = /(#\d+)/gm;
const ChangedFileRegex = /(A|M|D|R\d*|C\d*)\t([^\t\n]*)\t?(.*)?/gm;
const RevertHashRE = /This reverts commit (?<hash>[\da-f]{40})./gm;
function parseGitCommit(commit, isVersionPlanCommit = false) {
// For version plans, we do not require conventional commits and therefore cannot extract data based on that format
if (isVersionPlanCommit) {
return {
...commit,
description: commit.message,
type: '',
scope: '',
references: extractReferencesFromCommitMessage(commit.message, commit.shortHash),
// The commit message is not the source of truth for a breaking (major) change in version plans, so the value is not relevant
// TODO(v20): Make the current GitCommit interface more clearly tied to conventional commits
isBreaking: false,
authors: getAllAuthorsForCommit(commit),
// Not applicable to version plans
affectedFiles: [],
// Not applicable, a version plan cannot have been added in a commit that also reverts another commit
revertedHashes: [],
};
}
const parsedMessage = parseConventionalCommitsMessage(commit.message);
if (!parsedMessage) {
return null;
}
const scope = parsedMessage.scope;
const isBreaking = parsedMessage.breaking || commit.body.includes('BREAKING CHANGE:');
let description = parsedMessage.description;
// Extract references from message
const references = extractReferencesFromCommitMessage(description, commit.shortHash);
// Remove references and normalize
description = description.replace(PullRequestRE, '').trim();
let type = parsedMessage.type;
// Extract any reverted hashes, if applicable
const revertedHashes = [];
const matchedHashes = commit.body.matchAll(RevertHashRE);
for (const matchedHash of matchedHashes) {
revertedHashes.push(matchedHash.groups.hash);
}
if (revertedHashes.length) {
type = 'revert';
description = commit.message;
}
// Find all authors
const authors = getAllAuthorsForCommit(commit);
// Extract file changes from commit body
const affectedFiles = Array.from(commit.body.matchAll(ChangedFileRegex)).reduce((prev, [fullLine, changeType, file1, file2]) =>
// file2 only exists for some change types, such as renames
file2 ? [...prev, file1, file2] : [...prev, file1], []);
return {
...commit,
authors,
description,
type,
scope,
references,
isBreaking,
revertedHashes,
affectedFiles,
};
}
async function getCommitHash(ref) {
try {
return (await (0, exec_command_1.execCommand)('git', ['rev-parse', ref])).trim();
}
catch (e) {
throw new Error(`Unknown revision: ${ref}`);
}
}
async function getFirstGitCommit() {
try {
return (await (0, exec_command_1.execCommand)('git', [
'rev-list',
'--max-parents=0',
'HEAD',
'--first-parent',
])).trim();
}
catch (e) {
throw new Error(`Unable to find first commit in git history`);
}
}
async function getGitRoot() {
try {
return (await (0, exec_command_1.execCommand)('git', ['rev-parse', '--show-toplevel'])).trim();
}
catch (e) {
throw new Error('Unable to find git root');
}
}
let gitRootRelativePath;
async function getGitRootRelativePath() {
if (!gitRootRelativePath) {
const gitRoot = await getGitRoot();
gitRootRelativePath = (0, node_path_1.relative)(gitRoot, workspace_root_1.workspaceRoot);
}
return gitRootRelativePath;
}
;