UNPKG

np

Version:
314 lines (259 loc) 9.26 kB
import path from 'node:path'; import {execa} from 'execa'; import escapeStringRegexp from 'escape-string-regexp'; import ignoreWalker from 'ignore-walk'; import semver from 'semver'; import * as util from './util.js'; const gitNetworkTimeout = 120_000; // 2 minutes for remote git operations export const latestTag = async () => { const {stdout} = await execa('git', ['describe', '--abbrev=0', '--tags']); return stdout; }; export const root = async () => { const {stdout} = await execa('git', ['rev-parse', '--show-toplevel']); return stdout; }; export const newFilesSinceLastRelease = async rootDirectory => { try { const {stdout} = await execa('git', ['diff', '--name-only', '--diff-filter=A', await latestTag(), 'HEAD']); if (stdout.trim().length === 0) { return []; } const result = stdout.trim().split('\n').map(row => row.trim()); return result; } catch { // Get all files under version control return ignoreWalker({ path: rootDirectory, ignoreFiles: ['.gitignore'], }); } }; export const readFileFromLastRelease = async file => { const rootPath = await root(); const filePathFromRoot = path.relative(rootPath, path.resolve(rootPath, file)); const {stdout: oldFile} = await execa('git', ['show', `${await latestTag()}:${filePathFromRoot}`]); return oldFile; }; /** Returns an array of all tags. */ const tagList = async () => { const {stdout} = await execa('git', ['tag']); return stdout ? stdout.split('\n') : []; }; /** Returns an array of tags sorted by semver in ascending order. Non-semver tags are excluded. */ const tagListSortedBySemver = async () => { const tags = await tagList(); return tags .filter(tag => semver.valid(tag)) .sort((a, b) => semver.compare(a, b)); }; const firstCommit = async () => { const {stdout} = await execa('git', ['rev-list', '--max-parents=0', 'HEAD']); // Repository may have multiple initial commits (e.g., from merging unrelated histories). // Return just the first one. return stdout.split('\n')[0]; }; export const previousTagOrFirstCommit = async () => { const tags = await tagListSortedBySemver(); if (tags.length === 0) { return; } if (tags.length === 1) { return firstCommit(); } try { // Return the tag before the latest one (sorted by semver). const latest = await latestTag(); const index = tags.indexOf(latest); if (index === -1 || index === 0) { return firstCommit(); } return tags[index - 1]; } catch { // Fallback to the first commit. return firstCommit(); } }; export const latestTagOrFirstCommit = async () => { let latest; try { // In case a previous tag exists, we use it to compare the current repo status to. latest = await latestTag(); } catch { // Otherwise, we fallback to using the first commit for comparison. latest = await firstCommit(); } return latest; }; export const hasUpstream = async () => { const escapedCurrentBranch = escapeStringRegexp(await getCurrentBranch()); const {stdout} = await execa('git', ['status', '--short', '--branch', '--porcelain']); return new RegExp(String.raw`^## ${escapedCurrentBranch}\.\.\..+\/${escapedCurrentBranch}`).test(stdout); }; export const getCurrentBranch = async () => { const {stdout} = await execa('git', ['symbolic-ref', '--short', 'HEAD']); return stdout; }; export const verifyCurrentBranchIsReleaseBranch = async releaseBranch => { const currentBranch = await getCurrentBranch(); if (currentBranch !== releaseBranch) { throw new Error(`Not on \`${releaseBranch}\` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.`); } }; export const isHeadDetached = async () => { try { // Command will fail with code 1 if the HEAD is detached. await execa('git', ['symbolic-ref', '--quiet', 'HEAD']); return false; } catch { return true; } }; const isWorkingTreeClean = async () => { try { const {stdout: status} = await execa('git', ['status', '--porcelain']); if (status !== '') { return false; } return true; } catch { return false; } }; export const verifyWorkingTreeIsClean = async () => { if (!(await isWorkingTreeClean())) { throw new Error('Unclean working tree. Commit or stash changes first.'); } }; const hasRemote = async () => { try { await execa('git', ['rev-parse', '@{u}']); } catch { // Has no remote if command fails return false; } return true; }; const hasUnfetchedChangesFromRemote = async () => { // Inherit stdin to allow SSH password prompts for password-protected keys const {stdout: possibleNewChanges} = await execa('git', ['fetch', '--dry-run'], {stdin: 'inherit', timeout: gitNetworkTimeout}); // There are unfetched changes if output is not empty. return Boolean(possibleNewChanges); }; const isRemoteHistoryClean = async () => { const {stdout: history} = await execa('git', ['rev-list', '--count', '--left-only', '@{u}...HEAD']); // Remote history is clean if there are 0 revisions. return history === '0'; }; export const verifyRemoteHistoryIsClean = async () => { if (!(await hasRemote())) { return; } if (await hasUnfetchedChangesFromRemote()) { throw new Error('Remote history differs. Please run `git fetch` and pull changes.'); } if (!(await isRemoteHistoryClean())) { throw new Error('Remote history differs. Please pull changes.'); } }; export const verifyRemoteIsValid = async remote => { try { // Inherit stdin to allow SSH password prompts for password-protected keys await execa('git', ['ls-remote', remote ?? 'origin', 'HEAD'], {stdin: 'inherit', timeout: gitNetworkTimeout}); } catch (error) { throw new Error(error.stderr.replace('fatal:', 'Git fatal error:')); } }; export const fetch = async () => { // Inherit stdin to allow SSH password prompts for password-protected keys await execa('git', ['fetch'], {stdin: 'inherit', timeout: gitNetworkTimeout}); }; const hasLocalBranch = async branch => { try { await execa('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]); return true; } catch { return false; } }; export const defaultBranch = async () => { for (const branch of ['main', 'master', 'gh-pages']) { // eslint-disable-next-line no-await-in-loop if (await hasLocalBranch(branch)) { return branch; } } throw new Error('Could not infer the default Git branch. Please specify one with the --branch flag or with a np config.'); }; // Checks local refs after a prior `git fetch`. This is intentional over `git ls-remote` — the fetch // syncs all refs and also catches local-only tags that haven't been pushed yet. const tagExistsOnRemote = async tagName => { try { const {stdout: revInfo} = await execa('git', ['rev-parse', '--quiet', '--verify', `refs/tags/${tagName}`]); if (revInfo) { return true; } return false; } catch (error) { // Command fails with code 1 and no output if the tag does not exist, even though `--quiet` is provided // https://github.com/sindresorhus/np/pull/73#discussion_r72385685 if (error.stdout === '' && error.stderr === '') { return false; } throw error; } }; export const verifyTagDoesNotExistOnRemote = async tagName => { if (await tagExistsOnRemote(tagName)) { throw new Error(`Git tag \`${tagName}\` already exists.`); } }; export const commitLogFromRevision = async revision => { const {stdout} = await execa('git', ['log', '--format=%s %h', `${revision}..HEAD`]); return stdout; }; const push = async (remote, tagArgument = '--follow-tags') => { // Inherit stdin to allow SSH password prompts for password-protected keys await execa('git', ['push', ...(remote ? [remote] : []), tagArgument], {stdin: 'inherit', timeout: gitNetworkTimeout}); }; export const pushGraceful = async (remoteIsOnGitHub, remote) => { try { await push(remote); } catch (error) { if (remoteIsOnGitHub && error.stderr && error.stderr.includes('GH006')) { // Try to push tags only, when commits can't be pushed due to branch protection await push(remote, '--tags'); return {pushed: 'tags', reason: 'Branch protection: np can`t push the commits. Push them manually.'}; } throw error; } }; export const deleteTag = async tagName => { await execa('git', ['tag', '--delete', tagName]); }; export const removeLastCommit = async () => { await execa('git', ['reset', '--hard', 'HEAD~1']); }; const gitVersion = async () => { const {stdout} = await execa('git', ['version']); const match = /git version (?<version>\d+\.\d+\.\d+).*/.exec(stdout); return match && match.groups.version; }; export const verifyRecentGitVersion = async () => { const installedVersion = await gitVersion(); util.validateEngineVersionSatisfies('git', installedVersion); }; export const verifyUserConfigIsSet = async () => { const [nameResult, emailResult] = await Promise.allSettled([ execa('git', ['config', 'user.name']), execa('git', ['config', 'user.email']), ]); if (nameResult.status !== 'fulfilled' || !nameResult.value.stdout || emailResult.status !== 'fulfilled' || !emailResult.value.stdout) { throw new Error([ 'Git user configuration is not set.', '', 'Please set your git user name and email:', ' git config --global user.name "Your Name"', ' git config --global user.email "you@example.com"', ].join('\n')); } };