UNPKG

nx

Version:

The core Nx plugin contains the core functionality of Nx like the project graph, nx commands and task orchestration.

485 lines (484 loc) โ€ข 18.2 kB
"use strict"; 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; }