hub-pull.js (117 lines of code) (raw):
// Copyright (c) 2022 EPAM Systems, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
const fs = require('fs');
const url = require('url');
const yaml = require('js-yaml');
const {difference, get, isEmpty, kebabCase, partition, trimEnd, uniq, uniqBy} = require('lodash');
function usage(code = 1) {
console.log('`hubctl pull -h` for help');
process.exit(code);
}
function parseArgs() {
const known = ['components', 'show', 'debug', 'trace', 'force'];
const argv = [];
const opts = {};
let skip = false;
process.argv.slice(2).forEach((arg, i, args) => {
if (arg.startsWith('--')) {
const [k, v = true] = arg.substr(2).split('=');
opts[k] = v;
} else if (arg.startsWith('-')) {
let k = arg.substr(1);
k = known.find((w) => w.startsWith(k)) || k;
let v = args[i + 1];
skip = true;
if (!v || (v && v.startsWith('-'))) v = true;
opts[k] = v;
} else {
if (!skip) argv.push(arg);
skip = false;
}
});
const extra = difference(Object.keys(opts), known);
if (extra.length) {
console.log(`error: unknown command-line argument: ${extra.join(' ')}`);
usage();
}
if (opts.trace) opts.debug = true;
if (opts.components) opts.components = opts.components.split(',');
return {argv, opts};
}
const {argv, opts} = parseArgs();
if (opts.trace) {
console.log(argv);
console.log(opts);
}
const manifestFilename = argv[0] || 'hub.yaml';
const worktree = argv[1];
const tmpDir = process.env.TMPDIR;
const worktreePrefix = 'hub-pull';
const worktreeDirPrefix = `${trimEnd(tmpDir, '/') || '/var/tmp'}/${worktreePrefix}`;
const worktreeTemplate = `${worktreeDirPrefix}.XXXXXX`;
const manifest = yaml.load(fs.readFileSync(manifestFilename));
const remoteName = (remote) => {
const u = url.parse(remote);
return kebabCase(`${u.host}/${u.path}`);
};
const remoteBranchName = (remote, ref) => `${remoteName(remote)}/${ref}`;
const localBranchName = (remote, ref) => `upstream/${remoteName(remote)}-${ref}`;
const splitBranchName = (componentName) => `split/${kebabCase(componentName)}`;
const {components = []} = manifest;
const sources = (opts.components ? components.filter(({name}) => opts.components.includes(name)) : components)
.map(({name, source}) => ({...source, name}))
.filter(({name, dir, git}) => name && dir && get(git, 'remote'));
const remotes = uniq(sources.map(({git: {remote}}) => remote));
const remoteBranches = uniqBy(
sources.map(({git: {remote, ref = 'master'}}) => ({remote, ref})),
({remote, ref}) => `${remote}|${ref}`);
const [splits, singles] = partition(sources, ({git: {subDir}}) => subDir);
let cmds = ['set -xe'];
if (!opts.debug) cmds.push('\nif ! test -t 1; then subtree_flags=-q; fi');
cmds.push('\n# add upstream remotes');
cmds = cmds.concat(remotes.map((remote) =>
`if ! git remote | grep -E '^${remoteName(remote)}$'; then\n\tgit remote add ${remoteName(remote)} ${remote}; fi`));
cmds.push('\n# fetch upstream branches with updates');
cmds = cmds.concat(remoteBranches.map(({remote, ref}) =>
`git fetch ${remoteName(remote)} ${ref}`));
if (!isEmpty(splits)) {
cmds.push('\n# need a worktree for subtree split');
if (worktree) {
cmds.push(`if ! git worktree list | grep -E '^${worktree} '; then`);
cmds.push(`\tgit worktree add ${worktree} --detach`);
cmds.push('fi');
cmds.push(`pushd ${worktree}`);
} else {
cmds.push(`if git worktree list | grep -F '/${worktreePrefix}'; then`);
cmds.push(`\tworktree=$(git worktree list | grep -F '/${worktreePrefix}' | head -1 | cut -d' ' -f1)`);
cmds.push('\tif ! test -d "$worktree"; then unset worktree; fi');
cmds.push('fi');
cmds.push('if ! test -n "$worktree"; then');
cmds.push(`\tworktree=$(mktemp -d ${worktreeTemplate})`);
cmds.push('\tgit worktree add $worktree --detach');
cmds.push('fi');
cmds.push('pushd $worktree');
}
cmds.push('\n# extract components sources from subdirectories into `split` branches');
// TODO optimize for a single `git checkout` per remote+ref combination
cmds = cmds.concat(splits.map(({name, git: {remote, ref, subDir}}) =>
`git checkout -B ${localBranchName(remote, ref)} ${remoteBranchName(remote, ref)}\n`
+ `git subtree $subtree_flags split --prefix=${subDir} -b ${splitBranchName(name)}`));
cmds.push('popd');
if (!worktree) cmds.push('git worktree remove -f $worktree');
}
cmds.push('\n# stash worktree before subtree merge');
cmds.push('git stash save -u');
cmds.push('\n# need to run subtree command from the toplevel of the working tree');
// eslint-disable-next-line no-template-curly-in-string, max-len
cmds.push('git_top=$(git rev-parse --show-toplevel); if test "$PWD" != "$git_top"; then dir_prefix="${PWD#$git_top/}/"; cd "$git_top"; fi');
if (!isEmpty(splits)) {
cmds.push('\n# merge `split` branches');
cmds = cmds.concat(splits.map(({name, dir}) =>
`if test -d \${dir_prefix}${dir}; then verb=merge; else verb=add; fi\n`
+ `git subtree $subtree_flags $verb --squash -m "${name} $verb" --prefix=\${dir_prefix}${dir} ${splitBranchName(name)}`));
}
if (!isEmpty(singles)) {
cmds.push('\n# merge changes from repositiories with component source on top level');
cmds = cmds.concat(singles.map(({dir, git: {remote, ref}}) =>
`if test -d \${dir_prefix}${dir}; then verb=merge; else verb=add; fi\n`
+ `git subtree $subtree_flags $verb --squash -m "${dir} $verb" --prefix=\${dir_prefix}${dir} ${remoteBranchName(remote, ref)}`));
}
cmds.push('git stash pop');
console.log(cmds.join('\n'));