@atlaskit/build-utils
Version:
Collection of utilities to used during the release process of Atlaskit
434 lines (391 loc) • 11.8 kB
JavaScript
// @flow
// $FlowFixMe - There is a type issue with projector spawn.
const spawn = require('projector-spawn');
const path = require('path');
const parseChangesetCommit = require('./parseChangesetCommit');
async function getCommitsSince(
ref /*: string */,
spawnOpts /*: Object */ = {},
) {
const gitCmd = await spawn(
'git',
['rev-list', '--no-merges', '--abbrev-commit', `${ref}..HEAD`],
spawnOpts,
);
return gitCmd.stdout.trim().split('\n');
}
async function getChangedFilesSince(
ref /*: string */,
fullPath /*:boolean*/ = false,
spawnOpts /*: Object */ = {},
) {
// First we need to find the commit where we diverged from `ref` at using `git merge-base`
let cmd = await spawn('git', ['merge-base', ref, 'HEAD'], spawnOpts);
const divergedAt = cmd.stdout.trim();
// Now we can find which files we added
cmd = await spawn('git', ['diff', '--name-only', divergedAt], spawnOpts);
const files = cmd.stdout.trim().split('\n');
if (!fullPath) return files;
return files.map(file => path.resolve(file));
}
async function getChangedChangesetFilesSinceBranch(
branchName /*:string*/,
fullPath /*:boolean*/ = false,
spawnOpts /*: Object */ = {},
) {
const ref = await getRef(branchName, spawnOpts);
// Now we can find which files we added
const cmd = await spawn(
'git',
['diff', '--name-only', '--diff-filter=d', ref],
spawnOpts,
);
const files = cmd.stdout
.trim()
.split('\n')
// TODO: This needs to be updated to handle changesets v2
.filter(file => !file.includes('README.md') && file.startsWith('changes'));
if (!fullPath) return files;
return files.map(file => path.resolve(file));
}
async function getChangesetFiles(spawnOpts /*: Object */ = {}) {
const base = await getBaseBranch('HEAD', spawnOpts);
return getChangedChangesetFilesSinceBranch(base, false, spawnOpts);
}
async function getBranchName(spawnOpts /*: Object */ = {}) {
const gitCmd = await spawn(
'git',
['rev-parse', '--abbrev-ref', 'HEAD'],
spawnOpts,
);
return gitCmd.stdout.trim();
}
function getOriginBranchName(branchName /*: string */) {
return branchName.startsWith('origin/') ? branchName : `origin/${branchName}`;
}
async function getRef(branchName /*:string*/, spawnOpts /*: Object */ = {}) {
const gitCmd = await spawn(
'git',
['rev-parse', getOriginBranchName(branchName)],
spawnOpts,
);
return gitCmd.stdout.trim().split('\n')[0];
}
async function add(pathToFile /*: string */, spawnOpts /*: Object */ = {}) {
const gitCmd = await spawn('git', ['add', pathToFile], spawnOpts);
return gitCmd.code === 0;
}
async function branch(branchName /*: string */, spawnOpts /*: Object */ = {}) {
const gitCmd = await spawn('git', ['checkout', '-b', branchName], spawnOpts);
return gitCmd.code === 0;
}
async function checkout(
pathToFile /*: string */,
spawnOpts /*: Object */ = {},
) {
const gitCmd = await spawn('git', ['checkout', pathToFile], spawnOpts);
return gitCmd.code === 0;
}
async function commit(message /*: string */, spawnOpts /*: Object */ = {}) {
const gitCmd = await spawn(
'git',
['commit', '-m', message, '--allow-empty'],
spawnOpts,
);
return gitCmd.code === 0;
}
async function fetch(spawnOpts /*: Object */ = {}) {
const gitCmd = await spawn('git', ['fetch'], spawnOpts);
return gitCmd.code === 0;
}
async function init(spawnOpts /*: Object */ = {}) {
const gitCmd = await spawn('git', ['init'], spawnOpts);
return gitCmd.code === 0;
}
async function merge(branchName /*: string */, spawnOpts /*: Object */ = {}) {
const gitCmd = await spawn('git', ['merge', `${branchName}`], spawnOpts);
return gitCmd.code === 0;
}
async function push(args /*: Array<any>*/ = [], spawnOpts /*: Object */ = {}) {
const gitCmd = await spawn('git', ['push', ...args], spawnOpts);
return gitCmd.code === 0;
}
async function remote(
name /*: string */,
url /*: string */,
spawnOpts /*: Object */ = {},
) {
const gitCmd = await spawn(
'git',
['remote', 'add', `${name}`, `${url}`],
spawnOpts,
);
return gitCmd.code === 0;
}
// used to create a single tag at a time for the current head only
async function tag(tagStr /*: string */, spawnOpts /*: Object */ = {}) {
// NOTE: it's important we use the -m flag otherwise 'git push --follow-tags' wont actually push
// the tags
const gitCmd = await spawn('git', ['tag', tagStr, '-m', tagStr], spawnOpts);
return gitCmd.code === 0;
}
async function rebase(
maxAttempts /*: number*/ = 3,
spawnOpts /*: Object */ = {},
) {
let attempts = 0;
let rebased = false;
let lastError = {};
while (!rebased) {
attempts++;
try {
await spawn('git', ['pull', '--rebase'], spawnOpts);
rebased = true;
} catch (e) {
lastError = e;
if (attempts >= maxAttempts) {
break;
}
}
}
if (!rebased) {
throw new Error(
`Failed to rebase after ${maxAttempts} attempts\n${JSON.stringify(
lastError,
)}`,
);
}
}
// We expose this as a combined command because we want to be able to do both commands
// atomically
async function rebaseAndPush(
maxAttempts /*: number*/ = 3,
spawnOpts /*: Object */ = {},
) {
let attempts = 0;
let pushed = false;
let lastError = {};
while (!pushed) {
attempts++;
try {
await spawn('git', ['pull', '--rebase'], spawnOpts);
await spawn('git', ['push', '--follow-tags'], spawnOpts);
pushed = true;
} catch (e) {
lastError = e;
if (attempts >= maxAttempts) {
break;
}
}
}
if (!pushed) {
throw new Error(
`Failed to push after ${maxAttempts} attempts.\n${JSON.stringify(
lastError,
)}`,
);
}
}
// helper method for getAllReleaseCommits and getAllChangesetCommits as they are almost identical
async function getAndParseJsonFromCommitsStartingWith(str, since, spawnOpts) {
// --grep lets us pass a regex, -z splits commits using NUL instead of newlines
const cmdArgs = ['log', '--grep', `^${str}`, '-z', '--no-merges'];
if (since) {
cmdArgs.push(`${since}..`);
}
const gitCmd = await spawn('git', cmdArgs, spawnOpts);
const result = gitCmd.stdout
.trim()
.split('\0')
.filter(Boolean);
if (result.length === 0) return [];
const parsedCommits = result
.map(parseFullCommit)
// unfortunately, we have left some test data in the repo, which wont parse properly, so we
// need to manually pull it out here.
.filter(parsed => parsed.message.includes('---'))
.map(parsedCommit => {
// eslint-disable-next-line no-shadow
const { commit } = parsedCommit;
const changeset = parseChangesetCommit(parsedCommit.message);
if (!changeset) return undefined;
// we only care about the changeset and the commit
return { ...changeset, commit };
})
// this filter is for the same reason as above due to some unparsable JSON strings
.filter(parsed => !!parsed);
return parsedCommits;
}
// TODO: Don't parse these, just return the commits
// LB: BETTER DO THIS SOON
async function getAllReleaseCommits(
since /*: any */,
spawnOpts /*: Object */ = {},
) {
return getAndParseJsonFromCommitsStartingWith(
'RELEASING: ',
since,
spawnOpts,
);
}
async function getAllChangesetCommits(
since /*: any */,
spawnOpts /*: Object */ = {},
) {
return getAndParseJsonFromCommitsStartingWith(
'CHANGESET: ',
since,
spawnOpts,
);
}
// TODO: This function could be a lot cleaner, simpler and less error prone if we played with
// the pretty format stuff from `git log` to make sure things will always be as we expect
// (i.e this function breaks if you dont put '--no-merges' in the git log command)
function parseFullCommit(commitStr) {
const lines = commitStr.trim().split('\n');
const hash = lines
.shift()
.replace('commit ', '')
.substring(0, 7);
const author = lines.shift().replace('Author: ', '');
const date = new Date(
lines
.shift()
.replace('Date: ', '')
.trim(),
);
// remove the extra padding added by git show
const message = lines
.map(line => line.replace(' ', ''))
.join('\n')
.trim(); // There is one more extra line added by git
return {
commit: hash,
author,
date,
message,
};
}
async function getLastPublishCommit(spawnOpts /*: Object */ = {}) {
const gitCmd = await spawn(
'git',
[
'log',
'--grep',
'^RELEASING: ',
'--no-merges',
'--max-count=1',
'--format="%H"',
],
spawnOpts,
);
// eslint-disable-next-line no-shadow
const commit = gitCmd.stdout.trim().replace(/"/g, '');
return commit;
}
async function getCommitThatAddsFile(
pathTo /*: string */,
spawnOpts /*: Object */ = {},
) {
const gitCmd = await spawn(
'git',
['log', '--reverse', '--max-count=1', '--pretty=format:%h', '-p', pathTo],
spawnOpts,
);
// For reasons I do not understand, passing pretty format through this is not working
// The slice below is aimed at achieving the same thing.
// eslint-disable-next-line no-shadow
const commit = gitCmd.stdout.split('\n')[0];
return commit;
}
async function getUnpublishedChangesetCommits(
since /*: any */,
spawnOpts /*: Object */ = {},
) {
// Start one commit before the "since" if it's passed in so that we can find that commit if required
const releaseCommits = await getAllReleaseCommits(
since ? `${since}~1` : undefined,
spawnOpts,
);
const changesetCommits = await getAllChangesetCommits(since, spawnOpts);
// to find unpublished commits, we'll go through them one by one and compare them to all release
// commits and see if there are any that dont have a release commit that matches them
// $FlowFixMe - because of the type of changeset commits
const unpublishedCommits = changesetCommits.filter(cs => {
return !releaseCommits.find(publishCommit => {
// release commits have references to the changesets that they come from
return publishCommit.changesets.find(changeset => {
return changeset.commit === cs.commit;
});
});
});
return unpublishedCommits;
}
async function getMergeBase(
branchName /*: string */,
reference /*: string */,
spawnOpts /*: Object */ = {},
) {
const gitCmd = await spawn(
'git',
['merge-base', getOriginBranchName(branchName), reference],
spawnOpts,
);
// eslint-disable-next-line no-shadow
const commit = gitCmd.stdout.split('\n')[0];
return commit;
}
async function branchContainsCommit(
commitHash /*:string */,
branchName /*:string */,
spawnOpts /*: Object */ = {},
) {
const gitCmd = await spawn(
'git',
['branch', '-r', '--contains', `${commitHash}`],
spawnOpts,
);
// eslint-disable-next-line no-shadow
const output = gitCmd.stdout.split('\n');
// We are coercing to a boolean if we find the branchname.
return !!output.find(b => b.includes(getOriginBranchName(branchName)));
}
async function getBaseBranch(
ref /*: string */ = 'HEAD',
spawnOpts /*: Object */ = {},
) {
await fetch(spawnOpts);
const developMergeBase = await getMergeBase('develop', ref, spawnOpts);
const isOriginMaster = await branchContainsCommit(
developMergeBase,
'master',
spawnOpts,
);
return isOriginMaster ? 'master' : 'develop';
}
module.exports = {
getCommitThatAddsFile,
getCommitsSince,
getChangedFilesSince,
getBranchName,
getRef,
add,
branch,
checkout,
commit,
fetch,
init,
merge,
push,
remote,
tag,
rebase,
rebaseAndPush,
getUnpublishedChangesetCommits,
getChangedChangesetFilesSinceBranch,
getChangesetFiles,
getAllReleaseCommits,
getAllChangesetCommits,
getLastPublishCommit,
getBaseBranch,
};