Source: clone.js

'use strict';

// node and npm modules
const Git = require('nodegit');
const fs = require('fs-extra');
const path = require('path');
const dir = require('node-dir');

const utils = require('./utils.js');
const analyse = require('./analyse.js');
const mkLogger = require('./log.js');
const logger = mkLogger({label: __filename});


// Returns true if the file path is in an excluded directory,
// otherwise false
function isInExcludedDir(relativePath, excludedDirs) {
    for (const excludedDir of excludedDirs) {
        if (relativePath.startsWith(excludedDir)) {
            return true;
        }
    }
    return false;
}

// Returns true if the file name has an excluded extension,
// otherwise false
function hasExcludedExt(relativePath, excludedExts) {
    for (const excludedExt of excludedExts) {
        if (relativePath.endsWith(excludedExt)) {
            return true;
        }
    }
    return false;
}

/**
 *  @class The Clone class to clone and manage the Git repository
 */
class Clone {
    /**
     *  Repository Cloning options
     *  @typedef {object} CloneOptions
     *  @property {string} [root=path.join(__dirname, 'repos')]
     *      the root of the path to clone to
     *  @property {string} [clonePath='']
     *      the relative path to clone the repository to
     */
    /**
     *  Initialize and return an instance of the {@link Clone} class.
     *  @param {string} url - A valid URL to a Git repository.
     *  @param {CloneOptions} [options] - cloning options
     *
     *  @return {Promise<Clone>} A promise to a {@link Clone} instance.
     */
    static async init(url, {
            root = path.join(__dirname, 'repos', '0'),
            clonePath = '',
        } = {}) {
        logger.debug(`Initializing repository Clone from: '${url}'`);

        // if clone path is not set, create a new path from URL
        if (!clonePath) {
            logger.debug('clonePath not passed, creating from URL')
            const { owner, name } = utils.parseURL(url);
            clonePath = path.join(root, owner, name);
        }

        // check if repository was already cloned,
        // if so pull changes
        if (fs.existsSync(clonePath)) {
            logger.debug(`Clone directory found at: '${clonePath}'`);
            // See: https://github.com/nodegit/nodegit/blob/master/examples/pull.js
            let repo;
            logger.debug('Pulling latest changes...');
            await Git.Repository.open(clonePath)
                .then(r => {
                    repo = r;
                    repo.fetchAll({
                        callbacks: {
                            credentials: (url, user) => Git.Cred.sshKeyFromAgent(user),
                            certificateCheck: () => 0
                        }
                    })
                })
                .then(() => repo.mergeBranches('master', 'origin/master'))
            logger.debug('Changes successfuly pulled!');
            return new Clone(clonePath, repo);
        } else {
            // if repository not found, create directory and clone repository
            logger.debug(`Clone directory not found, creating new directory: '${clonePath}'`);
            fs.ensureDirSync(clonePath);
            logger.debug('Cloning git repository...');
            const repo = await Git.Clone(url, clonePath);
            logger.debug('Successfully cloned!');
            return new Clone(clonePath, repo);
        }
    }

    static async fromPath(path) {
        const repo = await Git.Repository.open(path);
        return new Clone(path, repo);
    }

    /**
     *  Constructs a {@link Clone} object.
     *  <br>WARNING: Not to be instantiated directly, see [init]{@link Clone.init}
     *  @param {string} path - Path to the repository location on drive
     *  @param {Git.Clone} repo
     *      A [Git.Clone]{@link https://www.nodegit.org/api/clone/} instance
     */
    constructor(path, repo) {
        if (path === undefined || repo === undefined) {
            throw Error('Cannot be called directly!');
        }
        this.path = path;
        this.repo = repo;
    }

    /**
     *    Gets the commit history from the HEAD commit
     *
     *    @return {Promise<Array<Commit>>}
     *        The commit history. See [Commit]{@link https://www.nodegit.org/api/commit/}.
     */
    async headCommitHistory() {
        const walker = this.repo.createRevWalk();
        const head = await this.repo.getHeadCommit();
        const sleep = ms => new Promise(res => {setTimeout(res, ms)});
        let done = false;
        let commits = [];
        walker.walk(head.id(), (error, commit) => {
            if (error && error.errno == Git.Error.CODE.ITEROVER) {
                done = true;
            } else {
                commits.push(commit);
            }
        });
        while (!done) {
            await sleep(1000);
        }
        return commits;
    }

    async commitsAfter(date, quick) {
        // get all commits
        let commits = (await this.headCommitHistory()).reverse();

        // if date is provided, only keep new commits
        if (date) commits = commits.filter(commit => commit.date() > date);

        // if quick analyze selected, return roughly 100 commits
        // TODO: add more flexibility to the behaviour of quick analyze???  just hardcoded to 100 commits for now lol
        if (quick && commits.length > 100) {
            let n = Math.round(commits.length / 100);
            commits = commits.filter((commit, index) => index % n === 0);
        }

        return commits;
    }

    async analyseCommits({commits = [], commit_ids = []} = {}) {
        if (commits.length === 0) {
            let promises = commit_ids.map(id => Git.Commit.lookup(this.repo, id));
            commits = await Promise.all(promises);
        }

        const analyser = async (commit, index) => {
            return {
                commit_id: commit.id().tostrS(),
                commit_date: commit.date(),
                // have to wait for analysis to finish before
                // checking out next commit
                valuesByExt: await this.staticAnalysis(),
            };
        };

        const catcher = (commit, error, index) => {
            console.log(error);
            return {
                commit_id: commit.id().tostrS(),
                commit_date: commit.date(),
                valuesByExt: {},
            }
        };

        return this.foreachCommit(commits, analyser, catcher);
    }

    /**
     *    @callback commitActionFunction
     *    @param {Commit} commit - The commit it's applied to.
     *    @param {number} index - The index of the commit, 1-based.
     *
     *    @return T - The return value.
     *    @template T
     */

    /**
     *    @callback commitCatcherFunction
     *    @param {Commit} commit - The commit it was applied to.
     *    @param {Error} error - The error thrown.
     *    @param {number} index
     *        The index of the commit it was applied to, 1 - based.
     *
     *    @return T - The return value.
     *    @template T
     */

    /**
     *    Maps an action function to all the commits provided. If the action
     *    function throws an error, applies the catcher funtion to that commit.
     *    Then returns the results in the order given.
     *    @param {Array<Commit>} commits
     *        The list of [Commit]{@link https://www.nodegit.org/api/commit/}.
     *    @param {commitActionFunction<T>} action
     *        The function to apply to each commit
     *    @param {commitCatcherFunction<S>} catcher
     *        The function to catch errors if the action function throws.
     *
     *    @return {Array<T|S>} - The results
     *    @template T
     *    @template S
     */
    async foreachCommit(commits, action, catcher) {
        let results = [];
        let i = 1;
        for (const commit of commits) {
            await Git.Reset.reset(
                this.repo, commit, Git.Reset.TYPE.HARD);
            const result = await action(commit, i)
                .catch(e => catcher(commit, e, i));
            results.push(result);
            i++;
        }
        return results;
    }

    /**
     *  A summary of a file extension
     *  @typedef {object} ExtensionSummary
     *  @property {number} numberOfFiles - The number of files with that extension
     *  @property {number} numberOfLines - The lines of code with that extension
     */

    /**
     *  Performs static analysis of code on disk and returns a report of the results.
     *  @param {Object} [options] - The options
     *  @param {Array<string>} [options.excludedDirs=['.git']]
     *      A list of directories to be excluded. Only '.git' by default.
     *  @param {Array<string>} [options.excludedExts=[]]
     *      A list of extensions to be excluded.
     *
     *  @return {Object<string, ExtensionSummary>}
     *      An object with file extensions as keys and an object with
     *      all static analyses results as the value.
     */
    async staticAnalysis({
        excludedDirs = ['.git'],
        excludedExts = []} = {}) {

    // get all files in directory
    let filepaths = await dir.promiseFiles(this.path);

    // TODO: cycle through files only ONCE if both are provided
    // filter unwanted files
    if (excludedDirs.length) {
        filepaths = filepaths.filter(f => !isInExcludedDir(f.slice(this.path.length + 1), excludedDirs));
    }
    
    if (excludedExts.length) {
        filepaths = filepaths.filter(f => !hasExcludedExt(f.slice(this.path.length + 1), excludedExts));
    }

    // group files by extension
    let fileByExt = {};
    for (const filepath of filepaths) {
        const ext = path.extname(filepath);

        fileByExt[ext] = fileByExt[ext] || [];
        fileByExt[ext].push(filepath);
    }

    // analyse files by extension
    let promises = {};
    for (const [ext, files] of Object.entries(fileByExt)) {
        switch (ext) {
            case '.js':
                promises[ext] = analyse.javascript(files);
                break;
            case '.py':
                promises[ext] = analyse.python(files);
                break;
            default:
                promises[ext] = analyse.generic(files);
                break;
        }
    }

    // wait for all extension analyses to complete
    let output = {};
    for (const [ext, promise] of Object.entries(promises)) {
        output[ext] = await promise;
    }

    return output;
}

}

module.exports = {
    Clone: Clone
};