@better-builds/lets-version
Version:
A package that reads your conventional commits and git history and recommends (or applies) a SemVer version bump for you
370 lines (369 loc) • 15 kB
JavaScript
import appRootPath from 'app-root-path';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import semver from 'semver';
import { fixCWD } from './cwd.js';
import { exec } from './exec.js';
import { parseToConventional } from './parser.js';
import { GitCommit, GitCommitWithConventionalAndPackageInfo, PublishTagInfo } from './types.js';
import { chunkArray } from './util.js';
let didFetchAll = false;
/**
* Fetches all tracking information from origin.
* Most importantly, this tries to detect whether we're currently
* in a shallow clone.
*/
export async function gitFetchAll(cwd = appRootPath.toString()) {
if (didFetchAll)
return;
const fixedCWD = fixCWD(cwd);
let isShallow = false;
const stat = fs.statSync(path.join(fixedCWD, '.git', 'shallow'), { throwIfNoEntry: false });
isShallow = stat?.isFile() ?? false;
if (isShallow) {
console.warn(`Current git repository is a **SHALLOW CLONE**. Limited git history may be available, which may result in "lets-version" version bump failures due to missing or incomplete git history.`);
}
await exec('git fetch origin', { cwd: fixedCWD, stdio: 'ignore' });
didFetchAll = true;
}
let didFetchAllTags = false;
/**
* Pulls in all tags from origin and forces local to be updated
*/
export async function gitFetchAllTags(cwd = appRootPath.toString()) {
if (didFetchAllTags)
return;
const fixedCWD = fixCWD(cwd);
await exec('git fetch origin --tags --force', { cwd: fixedCWD, stdio: 'ignore' });
didFetchAllTags = true;
}
/**
* Returns commits since a particular git SHA or tag.
* If the "since" parameter isn't provided, all commits
* from the dawn of man are returned
*/
export async function gitCommitsSince(opts) {
const { cwd = appRootPath.toString(), commitDateFormat = 'iso-strict', relPath = '', since = '' } = opts ?? {};
const fixedCWD = fixCWD(cwd);
let cmd = 'git --no-pager log';
const DELIMITER = '~~~***~~~';
const LINE_DELIMITER = '====----====++++====';
cmd += ` --format=${DELIMITER}%H${DELIMITER}%an${DELIMITER}%ae${DELIMITER}%ad${DELIMITER}%B${LINE_DELIMITER}`;
if (commitDateFormat)
cmd += ` --date=${commitDateFormat}`;
if (since)
cmd += ` ${since}..`;
if (relPath)
cmd += ` -- ${relPath}`;
const stdout = await exec(cmd, { cwd: fixedCWD, stdio: 'pipe' });
return (stdout
?.split(LINE_DELIMITER)
.filter(Boolean)
.map(line => {
const trimmed = line.trim();
const [sha = '', author = '', email = '', date = '', message = ''] = trimmed.split(DELIMITER).filter(Boolean);
return new GitCommit(author, date, email, message, sha);
})
.filter(commit => Boolean(commit.sha)) ?? []);
}
let remoteTagsCache = null;
/**
* Grabs the full list of all tags available on upstream
*/
export async function gitRemoteTags(cwd = appRootPath.toString()) {
if (remoteTagsCache)
return remoteTagsCache;
const fixedCWD = fixCWD(cwd);
// since this function may be called multiple times in a workflow,
// we want to avoid accidentally getting different results
remoteTagsCache =
(await exec('git ls-remote --tags origin', { cwd: fixedCWD, stdio: 'pipe' }))
?.trim()
.split(os.EOL)
.filter(Boolean)
.map(t => {
const [sha = '', ref = ''] = t.split(/\s+/);
return [ref.replace('refs/tags/', ''), sha];
}) ?? null;
return remoteTagsCache ?? [];
}
let localTagsCache = null;
/**
* Grabs the full list of all tags available locally
*/
export async function gitLocalTags(cwd = appRootPath.toString()) {
if (localTagsCache)
return localTagsCache;
const fixedCWD = fixCWD(cwd);
try {
// since this function may be called multiple times in a workflow,
// we want to avoid accidentally getting different results
localTagsCache =
(await exec('git show-ref --tags', { cwd: fixedCWD, stdio: 'pipe' }))
.trim()
.split(os.EOL)
.filter(Boolean)
.map(t => {
const [sha = '', ref = ''] = t.split(/\s+/);
return [ref.replace('refs/tags/', ''), sha];
}) ?? null;
return localTagsCache ?? [];
}
catch {
return [];
}
}
/**
* Given a package info object, returns a formatted string
* that can be safely used as a git version tag
*/
export function formatVersionTagForPackage(packageInfo) {
return `${packageInfo.name}@${packageInfo.version}`;
}
/**
* Given a javascript package info object, checks to see if there's
* a git tag for its current version. If it's found, its SHA is returned.
* If one for the current version is not found, all existing tags are scanned
* to find the closest match, and that is returned. If one isn't found, null
* is returned.
*/
export async function gitLastKnownPublishTagInfoForPackage(packageInfo, cwd = appRootPath.toString()) {
const fixedCWD = fixCWD(cwd);
// tag may either be on upstream or local-only. We need to treat both cases as "exists"
const [allRemoteTag, allLocalTags] = await Promise.all([gitRemoteTags(fixedCWD), gitLocalTags(fixedCWD)]);
// newest / largest tags first
const allTags = [...(allRemoteTag ?? []), ...(allLocalTags ?? [])].sort((a, b) => b[0].localeCompare(a[0]));
const allRemoteTagsMap = new Map(allTags);
let tag = formatVersionTagForPackage(packageInfo);
let match = allRemoteTagsMap.get(tag);
if (!match) {
// no dice on a tag match for the latest posted version in the package.json file.
// we now need to scan through all tags and find the "closest" semver match
/** @type {string | null} */
let largestTag = null;
for (const tag of allRemoteTagsMap.keys()) {
if (tag.includes(packageInfo.name)) {
if (!largestTag) {
largestTag = tag;
continue;
}
const tagSemver = semver.coerce(tag);
const largestTagSemver = semver.coerce(largestTag);
if (!tagSemver || !largestTagSemver)
continue;
if (tagSemver.compare(largestTagSemver) > 0)
largestTag = tag;
}
}
if (largestTag) {
tag = formatVersionTagForPackage({ ...packageInfo, version: semver.coerce(largestTag)?.version ?? '' });
match = allRemoteTagsMap.get(tag);
}
}
return match ? new PublishTagInfo(packageInfo.name, tag, match) : null;
}
/**
* Checks to see if there is a Git tag used for the last publish for a list of packages
*/
export async function getLastKnownPublishTagInfoForAllPackages(packages, noFetchTags, cwd = appRootPath.toString()) {
const fixedCWD = fixCWD(cwd);
if (!noFetchTags)
gitFetchAllTags(fixedCWD);
// warm up the tags cache
gitRemoteTags(fixedCWD);
gitLocalTags(fixedCWD);
return Promise.all(packages.map(async (p) => {
const result = await gitLastKnownPublishTagInfoForPackage(p, fixedCWD);
return new PublishTagInfo(p.name, result?.tag ?? null, result?.sha ?? null);
}));
}
/**
* Given a specific git sha, finds all files that have been modified
* since the sha and returns the absolute filepaths
*/
export async function gitAllFilesChangedSinceSha(sha, cwd = appRootPath.toString()) {
const fixedCWD = fixCWD(cwd);
const stdout = await exec(`git --no-pager diff --name-only ${sha}..`, { cwd: fixedCWD, stdio: 'pipe' });
return (stdout
?.trim()
.split(os.EOL)
.filter(Boolean)
.map(fp => path.resolve(path.join(cwd, fp))) ?? []);
}
/**
* Given an input of parsed git tag infos,
* returns all the files that have changed since any of these git tags
* have occured, with duplicates removed
*/
export async function getAllFilesChangedSinceTagInfos(filteredPackages, tagInfos, cwd = appRootPath.toString()) {
const fixedCWD = fixCWD(cwd);
const packageNameLookup = new Map(filteredPackages.map(p => [p.name, p]));
const results = (await Promise.all(tagInfos.map(async (t) => {
if (!t.sha)
return [];
const pkg = packageNameLookup.get(t.packageName);
const results = await gitAllFilesChangedSinceSha(t.sha, fixedCWD);
// This should never happen, but
// we'll guard just to silence the compiler
if (!pkg)
return results;
return results.filter(fp => fp.startsWith(pkg.packagePath));
}))).flat();
return Array.from(new Set(results));
}
/**
* Given an input of the "main" branch name,
* returns all the files that have changed since the current branch was created
*/
export async function getAllFilesChangedSinceBranch(filteredPackages, branch, cwd = appRootPath.toString()) {
const fixedCWD = fixCWD(cwd);
const allFiles = await gitAllFilesChangedSinceSha(branch, fixedCWD);
const results = filteredPackages
.map(pkg => {
return allFiles.filter(fp => fp.startsWith(pkg.packagePath));
})
.flat();
return Array.from(new Set(results));
}
/**
* Gets full git commit, with conventional commits parsed data,
* for a single, parsed package info
*/
export async function gitConventionalForPackage(opts) {
const { packageInfo, noFetchAll = false, cwd = appRootPath.toString(), ...rest } = opts;
const fixedCWD = fixCWD(cwd);
if (!noFetchAll)
gitFetchAll(fixedCWD);
const taginfo = await gitLastKnownPublishTagInfoForPackage(packageInfo, fixedCWD);
const relPackagePath = path.relative(cwd, packageInfo.packagePath);
// in a prior version of lets-version, we used to error out if there wasn't a previous publish at all,
// which wasn't great, as it meant that you needed at least one publish to use this library in your repo.
// now, we take the first commit in the repo (it's the default behavior) and let the process continue
const results = await gitCommitsSince({
...rest,
cwd: fixedCWD,
relPath: relPackagePath,
since: taginfo?.sha ?? undefined,
});
const conventional = parseToConventional(results);
return conventional.map(c => new GitCommitWithConventionalAndPackageInfo(c.author, c.date, c.email, c.message, c.sha, c.conventional, packageInfo));
}
/**
* Gets full git commit, with conventional commits parsed data,
* for all provided packages
*/
export async function gitConventionalForAllPackages(opts) {
const { packageInfos, noFetchAll = false, cwd = appRootPath.toString(), ...rest } = opts;
const fixedCWD = fixCWD(cwd);
return (await Promise.all(packageInfos.map(async (p) => gitConventionalForPackage({
...rest,
cwd: fixedCWD,
noFetchAll,
packageInfo: p,
})))).flat();
}
/**
* Creates a git commit, based on whatever changes are active
*/
export async function gitCommit(header, body, footer, cwd = appRootPath.toString()) {
const fixedCWD = fixCWD(cwd);
// add files silently
await exec('git add .', { cwd, stdio: 'ignore' });
let message = header;
if (body)
message += `${os.EOL}${os.EOL}${body}`;
if (footer)
message += `${os.EOL}${os.EOL}${footer}`;
// write temp file to use as the git commit message
const tempFilePath = path.join(os.tmpdir(), '__lets-version-commit-msg__');
await fs.ensureFile(tempFilePath);
await fs.writeFile(tempFilePath, message, 'utf-8');
// commit silently
await exec(`git commit -F ${tempFilePath} --no-verify`, { cwd: fixedCWD, stdio: 'ignore' });
// remove the commit msg file
await fs.remove(tempFilePath);
}
async function gitCurrentBranchName(cwd = appRootPath.toString()) {
const fixedCWD = fixCWD(cwd);
const result = await exec('git rev-parse --abbrev-ref HEAD', { cwd: fixedCWD, stdio: 'pipe' });
return result.trim();
}
/**
* as the name of this function insists, this
* function will auto push the current branch, using the current
* branch name, to origin if it doesn't already exist there.
*/
async function gitEnsureBranchExistsOnOrigin(cwd = appRootPath.toString()) {
const fixedCWD = fixCWD(cwd);
const currentBranchName = await gitCurrentBranchName(cwd);
if (!currentBranchName) {
throw new Error("gitEnsureBranchExistsOnOrigin() failed because your repository's current branch name could not be properly computed. this is a bug 🐛");
}
// this command will return with *something*, not an empty response, if the branch exists on upstream
const branchExistsOnUpstream = Boolean((await exec(`git ls-remote --heads origin ${currentBranchName}`, {
cwd: fixedCWD,
stdio: 'pipe',
})).trim());
if (!branchExistsOnUpstream) {
await exec(`git push --set-upstream origin ${currentBranchName}`, { cwd: fixedCWD, stdio: 'inherit' });
}
return currentBranchName;
}
/**
* Pushes current local changes to upstream / origin
*/
export async function gitPush(cwd = appRootPath.toString()) {
const fixedCWD = fixCWD(cwd);
await gitEnsureBranchExistsOnOrigin(cwd);
await exec('git push --no-verify', { cwd: fixedCWD, stdio: 'inherit' });
}
/**
* Git pushes a single tag to upstream / origin
*/
export async function gitPushTag(tag, cwd = appRootPath.toString()) {
const fixedCWD = fixCWD(cwd);
await gitEnsureBranchExistsOnOrigin(cwd);
await exec(`git push origin ${tag} --no-verify`, { cwd: fixedCWD, stdio: 'inherit' });
}
/**
* Git pushes multiple tags at the same time
*/
export async function gitPushTags(tags, cwd = appRootPath.toString()) {
const fixedCWD = fixCWD(cwd);
// only push 5 at a time
const chunkedTags = chunkArray(tags);
for (const chunk of chunkedTags) {
await Promise.all(chunk.map(async (tag) => gitPushTag(tag, fixedCWD)));
}
}
/**
* Creates a git tag
*/
export async function gitTag(tag, cwd = appRootPath.toString()) {
const fixedCWD = fixCWD(cwd);
await exec(`git tag ${tag}`, { cwd: fixedCWD, stdio: 'ignore' });
}
/**
* Checks the current repo to see if there are any outstanding changes
*
* @param {string} [cwd=appRootPath.toString()]
*
* @returns {Promise<boolean>}
*/
export async function gitWorkdirUnclean(cwd = appRootPath.toString()) {
const fixedCWD = fixCWD(cwd);
const statusResult = (await exec('git status -s', { cwd: fixedCWD, stdio: 'pipe' })).trim() ?? '';
// split by newlines, just in case
return statusResult.split(os.EOL).filter(Boolean).length > 0;
}
/**
* Gets the current shortened commit SHA
*
* @param {string} [cwd=appRootPath.toString()]
*/
export async function gitCurrentSHA(cwd = appRootPath.toString()) {
const fixedCWD = fixCWD(cwd);
const result = (await exec('git rev-parse --short HEAD', { cwd: fixedCWD, stdio: 'pipe' })).trim() ?? '';
return result;
}