aureooms/rejuvenate

View on GitHub
src/main.js

Summary

Maintainability
C
1 day
Test Coverage
// eslint-disable-next-line unicorn/prefer-node-protocol
import assert from 'assert'; // Using node:* is incompatible with power-assert.

import _commandExists from 'command-exists';
import Listr from 'listr';
import {zip} from '@iterable-iterator/zip';

import renderer from './ui/renderer.js';
import parse from './parse.js';
import {fetchTransforms, transformToTask} from './transforms.js';
import chcwd from './util/chcwd.js';
import logger from './util/logger.js';

async function ensureCommandExists(exe) {
    try {
        await _commandExists(exe);
    } catch {
        // NB: _error is null
        // See:
        //   - https://github.com/mathisonian/command-exists/issues/22#issuecomment-473941461
        //   - https://github.com/mathisonian/command-exists/blob/742a73d75e6ff737c35aa7c88d0828cbb0455811/lib/command-exists.js#L32
        //   - https://github.com/mathisonian/command-exists/blob/742a73d75e6ff737c35aa7c88d0828cbb0455811/lib/command-exists.js#L54
        throw new Error(`Command \`${exe}\` does not exist.`);
    }
}

/**
 * Main.
 *
 * @param {Array} argv
 */
export default function main(argv) {
    const {command, options, isDefault} = parse(argv.slice(2));

    const globals = {
        tasks: undefined,
        assert,
        ...chcwd(options),
        ...logger(options),
    };

    globals.debug({command, options});

    const {git} = globals;

    const listrOptions =
        options.loglevel > globals.DEBUG
            ? {renderer: 'verbose'}
            : {
                    renderer,
                    collapse: (level) => globals.INFO + level > options.loglevel,
                    maxSubtasks: (level) =>
                        globals.INFO + level <= options.loglevel
                            ? Number.POSITIVE_INFINITY
                            : globals.WARN + level <= options.loglevel
                                ? options.maxSubtasks
                                : 0,
                };

    let requiredExecutables = ['git', 'npm', 'yarn'];
    if (!options.install) {
        requiredExecutables = [
            ...requiredExecutables,
            'xo',
            'fixpack',
            'babel',
            'microbundle',
        ];
    }

    const tasks = new Listr(
        [
            {
                title: 'Checking for required executables',
                task: () =>
                    new Listr(
                        requiredExecutables.map((exe) => ({
                            title: exe,
                            task: () => ensureCommandExists(exe),
                        })),
                        {concurrent: true},
                    ),
            },
            {
                title: 'Retrieving git status',
                task: (ctx) =>
                    git.status().then((status) => {
                        ctx.status = status;
                    }),
            },
            {
                title: 'Checking that repo is clean',
                enabled: () => options.status,
                task: (ctx) => checkStatus(ctx.status, options, globals),
            },
            {
                title: 'Pulling from remote',
                enabled: () => options.pull,
                task: () => git.pull(),
            },
            {
                title: 'Setting up temporary branch',
                async task() {
                    const localBranches = await git.branchLocal();
                    return new Listr([
                        {
                            title: 'Delete existing local branch',
                            enabled: () => localBranches.all.includes(options.branch),
                            task: () => git.deleteLocalBranch(options.branch, true),
                        },
                        {
                            title: 'Checking out local branch',
                            task: () => git.checkoutLocalBranch(options.branch),
                        },
                    ]);
                },
            },
            {
                title: 'Computing the dependency graph',
                async task(ctx) {
                    ctx.transforms = [];
                    for await (const transform of fetchTransforms(
                        options.transformDir,
                        options.transforms,
                    )) {
                        ctx.transforms.push(transform);
                    }
                },
            },
            {
                title: 'Applying transforms',
                task(ctx) {
                    const schedule = new Listr(
                        ctx.transforms.map((t) => transformToTask(t, options, globals)),
                    );
                    const tasks = {};
                    for (const [transform, task] of zip(ctx.transforms, schedule.tasks)) {
                        tasks[transform.name] = task;
                    }

                    globals.tasks = tasks;
                    return schedule;
                },
            },
            {
                title: 'Checking out current branch',
                task: (ctx) => git.checkout(ctx.status.current),
            },
            {
                title: 'Rebasing current branch on temporary branch',
                enabled: () => options.rebase,
                task: () => git.rebase([options.branch]),
            },
            {
                title: 'Pushing current branch to remote',
                enabled: () => options.rebase && options.push,
                task: () => git.push(),
            },
            {
                title: 'Pushing temporary branch to remote',
                enabled: () => options.keep && !isDefault('branch') && options.push,
                async task(ctx) {
                    await git.checkout(options.branch);
                    await git.push(options.remote, options.branch, ['--force']);
                    await git.checkout(ctx.status.current);
                },
            },
            {
                title: 'Deleting temporary branch',
                enabled: () => !options.keep,
                task: () => git.deleteLocalBranch(options.branch, true),
            },
        ],
        listrOptions,
    );

    return tasks.run();
}

/**
 * CheckStatus.
 *
 * @param {any} status
 * @param {Object} options
 * @param {Object} globals
 */
function checkStatus(status, options, globals) {
    globals.debug({status});
    const {tracking, current, behind} = status;
    if (!current) throw new Error('No current branch');
    if (!tracking) throw new Error('Not tracking any remote branch');
    const [, remote, remoteBranch] = tracking.match('(.*)/(.*)');
    if (remote !== options.remote)
        throw new Error(
            `The remote of the tracked branch does not correspond to the remote specified through options (${remote} ~ ${options.remote})`,
        );
    if (current !== remoteBranch)
        throw new Error(
            `Current branch does not correspond to tracking branch (${current} ~ ${tracking})`,
        );
    if (behind !== 0)
        throw new Error(`Current branch is behind (${behind}) tracked branch`);

    const changes = [
        'not_added',
        'conflicted',
        'created',
        'deleted',
        'modified',
        'renamed',
        'files',
        'staged',
    ];

    // TODO could use status.isClean() instead.
    for (const change of changes) {
        if (status[change]?.length) throw new Error(`Not clean (${change})`);
    }
}