diff --git a/README.md b/README.md index 922e1a7..83cf5b8 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,9 @@ First, create a configuration file `mgit.json`: (Keys of the `dependencies` object are package names and values are repository URLs (GitHub identifiers in this case). Read more about the [`dependencies` option](#the-dependencies-option).) -And run `mgit bootstrap` to clone all the repositories. By default, they will be cloned to `/packages/` directory: +And run `mgit sync` to clone all the repositories. By default, they will be cloned to `/packages/` directory: -``` +```bash packages/ ckeditor5-engine/ mgit/ @@ -52,59 +52,53 @@ packages/ CLI options: ``` ---recursive Whether to install dependencies recursively. - Needs to be used together with --repository-include. Only packages - matching these patterns will be cloned recursively. +--branch For "save" command: whether to save branch names. + For "checkout" command: name of branch that would be created. - Default: false. +--hash Whether to save current commit hashes. Used only by "save" command. ---packages Directory to which all repositories will be cloned. +--ignore Ignores packages which names match the given glob pattern. E.g.: + > mgit exec --ignore="foo*" "git status" - Default: '/packages/' + Will ignore all packages which names start from "foo". + Default: null ---resolver-path Path to a custom repository resolver function. +--message Message that will be used as an option for git command. Required for "commit" + command but it is also used by "close" command (append the message to the default). - Default: '@mgit2/lib/default-resolver.js'. +--packages Directory to which all repositories will be cloned or are already installed. + Default: '/packages/' ---resolver-url-template Template used to generate repository URL out of a - simplified 'organization/repository' format of the dependencies option. +--recursive Whether to install dependencies recursively. Used only by "sync" command. - Default: 'git@github.com:${ path }.git'. +--resolver-path Path to a custom repository resolver function. + Default: '@mgit2/lib/default-resolver.js' ---resolver-directory-name Defines how the target directory (where the repository will be cloned) - is resolved. Supported options are: 'git' (default), 'npm'. +--resolver-url-template Template used to generate repository URL out of a + simplified 'organization/repository' format of the dependencies option. + Default: 'git@github.com:${ path }.git'. - * If 'git' was specified, then the directory name will be extracted from - the git URL (e.g. for 'git@github.com:a/b.git' it will be 'b'). - * If 'npm' was specified, then the package name will be used as a directory name. +--resolver-directory-name Defines how the target directory (where the repository will be cloned) + is resolved. Supported options are: 'git' (default), 'npm'. - This option can be useful when scoped npm packages are used and one wants to decide - whether the repository will be cloned to packages/@scope/pkgname' or 'packages/pkgname'. + * If 'git' was specified, then the directory name will be extracted from + the git URL (e.g. for 'git@github.com:a/b.git' it will be 'b'). + * If 'npm' was specified, then the package name will be used as a directory name. - Default: 'git' + This option can be useful when scoped npm packages are used and one wants to decide + whether the repository will be cloned to packages/@scope/pkgname' or 'packages/pkgname'. + Default: 'git' --resolver-default-branch The branch name to use if not specified in mgit.json dependencies. - - Default: 'master' - ---ignore Ignores packages which names match the given glob pattern. - - For example: - - > mgit exec --ignore="foo*" "git st" - - Will ignore all packages which names start from "foo". - - Default: null + Default: master --scope Restricts the command to packages which names match the given glob pattern. - - Default: null + Default: null ``` All these options can also be specified in `mgit.json` (options passed through CLI takes precedence): -```js +```json { "packages": "/workspace/modules", "resolverDirectoryName": "npm", @@ -163,13 +157,13 @@ const parseRepositoryUrl = require( 'mgit2/lib/utils/parserepositoryurl' ); * Resolves repository URL for a given package name. * * @param {String} packageName Package name. - * @param {Options} data.options The options object. + * @param {Options} options The options object. * @returns {Repository|null} */ module.exports = function resolver( packageName, options ) { // If package name starts with '@ckeditor/ckeditor5-*' clone it from 'ckeditor/ckeditor5-*'. if ( packageName.startsWith( '@ckeditor/ckeditor5-' ) ) { - repositoryUrl = packageName.slice( 1 ); + const repositoryUrl = packageName.slice( 1 ); return parseRepositoryUrl( repositoryUrl ); } @@ -185,49 +179,77 @@ You can also check the [default resolver](https://github.com/cksource/mgit2/blob CI servers, such as Travis, can't clone repositories using Git URLs (such as `git@github.com:cksource/mgit.git`). By default, mgit uses Git URLs because it assumes that you'll want to commit to these repositories (and don't want to be asked for a password every time). -If you need to run mgit on a CI server, then configure it to use HTTP URLs: +If you need to run mgit on a CI server, then configure it to use HTTPS URLs: ```bash mgit --resolver-url-template="https://github.com/\${ path }.git" ``` -You can also use full HTTPs URLs to configure `dependencies` in your `mgit.json`. +You can also use full HTTPS URLs to configure `dependencies` in your `mgit.json`. ## Commands +```bash +$ mgit [command] ``` -mgit [command] + +For displaying help screen for specified command, type: + +```bash +$ mgit [command] --help ``` -### bootstrap +### sync -Installs missing packages (i.e. clone them) and check them out to correct branches. +Updates dependencies. Switches repositories to correct branches (specified in `mgit.json`) and pulls changes. -This command will not change existing repositories, so you can always safely use it. It's useful to bootstrap the project initially, but later you'll rather want to use `mgit update`. +If any dependency is missing, the command will install this dependency as well. -Example: +This command does not touch repositories in which there are uncommitted changes. + +If in the packages directory will be located some directories that are not specified in `mgit.json`, paths to these directories +will be printed out on the screen. + +Examples: ```bash -mgit bootstrap --recursive --resolver=path ./dev/custom-repository-resolver.js +mgit sync --recursive ``` -### update +### pull -Updates dependencies. Switches repositories to correct branches (specified in `mgit.json`) and pulls changes. +Pulls changes in existing repositories. It does not change branches in the repositories and pull the changes even if +the repository contains uncommitted changes. -If any dependency is missing, the command will install it too. +Examples: -This command does not touch repositories in which there are uncommitted changes. +```bash +mgit pull +``` + +### push + +Pushes changes in existing repositories. + +Examples: + +```bash +mgit push +``` + +### fetch + +Fetches changes in existing repositories. Examples: ```bash -mgit update --recursive +mgit fetch ``` ### exec -For every cloned repository executes the specified shell command. +Executes specified shell command in existing repositories. Example: @@ -246,14 +268,53 @@ mgit exec 'echo `pwd`' # /home/mgit/packages/organization/repository-2 ``` -### save-hashes +### commit (alias: `ci`) + +For every repository that contains changes which can be committed, makes a commit with these files. +You need to specify the message for the commit. + +Example: + +```bash +mgit commit --message 'Introduce PULL_REQUEST_TEMPLATE.md.' + +# Executes `git commit --message 'Introduce PULL_REQUEST_TEMPLATE.md.'` command on each repository. +# Commit will be made in repositories that "git status" returns a list if changed files (these files must be tracked by Git). +``` + +### close + +Requires a second argument which is a branch name that will be merged to current one. You can also specify the message +which will be added to the default git-merge message. + +Repositories which do not have specified branch will be ignored. + +After merging, the merged branch will be removed from the remote and the local registry. + +Example: + +```bash +# Assumptions: we are on "master" branch and "develop" branch exists. +mgit merge develop --message 'These changes are required for the future release.' + +# Branch "develop" will be merged into "master". +# Branch "develop" will be removed from the origin. +``` + +### save Saves hashes of packages in `mgit.json`. It allows to easily fix project to a specific state. Example: ```bash -mgit save-hashes +mgit save +``` + +If you would like to save name of branches instead of current commit, you can use an option `--branch`: + +```bash +mgit save --branch ``` ### status (alias: `st`) @@ -311,14 +372,30 @@ mgit diff -- master...HEAD ### checkout (alias: `co`) -Changes branches in repositories according to the configuration file. It does not pull the changes and hance is much faster than `mgit update`. The command is useful for bisecting if your main repository contain a revision log like CKEditor 5's [`master-revision`](https://github.com/ckeditor/ckeditor5/commits/master-revisions) branch. - +Changes branches in repositories according to the configuration file. It does not pull the changes and hance is much faster than `mgit sync`. +The command is useful for bisecting if your main repository contain a revision log like CKEditor 5's [`master-revision`](https://github.com/ckeditor/ckeditor5/commits/master-revisions) branch. + ```bash mgit checkout # or mgit co ``` +If specified an argument, specified branch will be used instead of default or saved in `mgit.json` file. + +```bash +# Checkout all repositories to "stable" branch. +mgit checkout stable +``` + +Also you can specify the `--branch` option which means that mgit creates a new branches in repositories that contains changes (that could be committed). +It works on the same terms like `mgit commit`. + +```bash +# Create the branch "t/foo" in repositories where "git status" returns a list if changed files (these files must be tracked by Git). +mgit checkout --branch t/foo +``` + ## Projects using mgit2 * [CKEditor 5](https://github.com/ckeditor/ckeditor5) diff --git a/index.js b/index.js index fa3cf7f..7cdd65c 100755 --- a/index.js +++ b/index.js @@ -7,18 +7,33 @@ 'use strict'; +const chalk = require( 'chalk' ); const meow = require( 'meow' ); const mgit = require( './lib/index' ); - -const meowOptions = { - flags: { - version: { - alias: 'v' +const getCommandInstance = require( './lib/utils/getcommandinstance' ); + +handleCli(); + +function handleCli() { + const meowOptions = { + autoHelp: false, + flags: { + version: { + alias: 'v' + }, + help: { + alias: 'h' + }, + branch: { + alias: 'b' + }, + message: { + alias: 'm' + } } - } -}; + }; -const cli = meow( ` + const mgitLogo = ` _ _ (_) | _ __ ___ __ _ _| |_ @@ -27,40 +42,62 @@ const cli = meow( ` |_| |_| |_|\\__, |_|\\__| __/ | |___/ +`; + + const { + cyan: c, + gray: g, + magenta: m, + underline: u, + yellow: y, + } = chalk; + + const cli = meow( `${ mgitLogo } + ${ u( 'Usage:' ) } + $ mgit ${ c( 'command' ) } ${ y( '[--options]' ) } -- ${ m( '[--git-options]' ) } + + ${ u( 'Commands:' ) } + ${ c( 'checkout' ) } Changes branches in repositories according to the configuration file. + ${ c( 'close' ) } Merges specified branch with the current one and remove merged branch from the remote. + ${ c( 'commit' ) } Commits all changes. A shorthand for "mgit exec 'git commit -a'". + ${ c( 'diff' ) } Prints changes from packages where something has changed. + ${ c( 'exec' ) } Executes shell command in each package. + ${ c( 'fetch' ) } Fetches existing repositories. + ${ c( 'pull' ) } Pulls changes in existing repositories. + ${ c( 'push' ) } Pushes changes in existing repositories to remotes. + ${ c( 'save' ) } Saves hashes of packages in mgit.json. It allows to easily fix project to a specific state. + ${ c( 'status' ) } Prints a table which contains useful information about the status of repositories. + ${ c( 'sync' ) } Updates packages to the latest versions or install missing ones. + + + ${ u( 'Options:' ) } + ${ y( '--branch' ) } For "${ u( 'save' ) }" command: whether to save branch names. + For "${ u( 'checkout' ) }" command: name of branch that would be created. + + ${ y( '--hash' ) } Whether to save current commit hashes. Used only by "${ u( 'save' ) }" command. + + ${ y( '--ignore' ) } Ignores packages which names match the given glob pattern. E.g.: + ${ g( '> mgit exec --ignore="foo*" "git status"' ) } - Usage: - $ mgit [command] - - Commands: - bootstrap Installs packages (i.e. clone dependent repositories). - exec Executes shell command in each package. - update Updates packages to the latest versions (i.e. pull changes). - save-hashes Saves hashes of packages in mgit.json. It allows to easily fix project to a specific state. - status Prints a table which contains useful information about the status of repositories. - diff Prints changes from packages where something has changed. - checkout Changes branches in repositories according to the configuration file. - - Options: - --recursive Whether to install dependencies recursively. - Needs to be used together with --repository-include. Only packages - matching these patterns will be cloned recursively. - - Default: false. - - --packages Directory to which all repositories will be cloned. + Will ignore all packages which names start from "foo". + ${ g( 'Default: null' ) } + + ${ y( '--message' ) } Message that will be used as an option for git command. Required for "${ u( 'commit' ) }" + command but it is also used by "${ u( 'close' ) }" command (append the message to the default). - Default: '/packages/' + ${ y( '--packages' ) } Directory to which all repositories will be cloned or are already installed. + ${ g( 'Default: \'/packages/\'' ) } - --resolver-path Path to a custom repository resolver function. + ${ y( '--recursive' ) } Whether to install dependencies recursively. Used only by "${ u( 'sync' ) }" command. - Default: '@mgit2/lib/default-resolver.js'. + ${ y( '--resolver-path' ) } Path to a custom repository resolver function. + ${ g( 'Default: \'@mgit2/lib/default-resolver.js\'' ) } - --resolver-url-template Template used to generate repository URL out of a + ${ y( '--resolver-url-template' ) } Template used to generate repository URL out of a simplified 'organization/repository' format of the dependencies option. + ${ g( 'Default: \'git@github.com:${ path }.git\'.' ) } - Default: 'git@github.com:\${ path }.git'. - - --resolver-directory-name Defines how the target directory (where the repository will be cloned) + ${ y( '--resolver-directory-name' ) } Defines how the target directory (where the repository will be cloned) is resolved. Supported options are: 'git' (default), 'npm'. * If 'git' was specified, then the directory name will be extracted from @@ -69,30 +106,42 @@ const cli = meow( ` This option can be useful when scoped npm packages are used and one wants to decide whether the repository will be cloned to packages/@scope/pkgname' or 'packages/pkgname'. + ${ g( 'Default: \'git\'' ) } - Default: 'git' + ${ y( '--resolver-default-branch' ) } The branch name to use if not specified in mgit.json dependencies. + ${ g( 'Default: master' ) } - --resolver-default-branch The branch name to use if not specified in mgit.json dependencies. + ${ y( '--scope' ) } Restricts the command to packages which names match the given glob pattern. + ${ g( 'Default: null' ) } - Default: 'master' - - --ignore Ignores packages which names match the given glob pattern. + ${ u( 'Git Options:' ) } + Git options are supported by the following commands: commit, diff, fetch, push. + Type "mgit [command] -h" in order to see which options are supported. +`, meowOptions ); - For example: + const commandName = cli.input[ 0 ]; - > mgit exec --ignore="foo*" "git st" + // If user specified a command and `--help` flag wasn't active. + if ( commandName && !cli.flags.help ) { + return mgit( cli.input, cli.flags ); + } - Will ignore all packages which names start from "foo". + // A user wants to see "help" screen. + // Missing command. Displays help screen for the entire Mgit. + if ( !commandName ) { + return cli.showHelp( 0 ); + } - Default: null + const commandInstance = getCommandInstance( commandName ); - --scope Restricts the command to packages which names match the given glob pattern. + if ( !commandInstance ) { + process.errorCode = 1; - Default: null -`, meowOptions ); + return; + } -if ( cli.input.length === 0 ) { - cli.showHelp(); -} else { - mgit( cli.input, cli.flags ); + // Specified command is is available, displays the command's help. + console.log( mgitLogo ); + console.log( ` ${ u( 'Command:' ) } ${ c( commandInstance.name || commandName ) } ` ); + console.log( commandInstance.helpMessage ); } diff --git a/lib/commands/bootstrap.js b/lib/commands/bootstrap.js deleted file mode 100644 index f7cd5b5..0000000 --- a/lib/commands/bootstrap.js +++ /dev/null @@ -1,112 +0,0 @@ -/** - * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. - * For licensing, see LICENSE.md. - */ - -'use strict'; - -const fs = require( 'fs' ); -const path = require( 'upath' ); -const chalk = require( 'chalk' ); -const exec = require( '../utils/exec' ); - -const command = { - /** - * @param {Object} data - * @param {String} data.packageName Name of current package to process. - * @param {Options} data.options The options object. - * @param {Repository} data.repository - * @param {Boolean} [data.doNotTryAgain=false] If set to `true`, bootstrap command won't be executed again. - * @returns {Promise} - */ - execute( data ) { - const log = require( '../utils/log' )(); - const destinationPath = path.join( data.options.packages, data.repository.directory ); - - let promise; - - // Package is already cloned. - if ( fs.existsSync( destinationPath ) ) { - log.info( `Package "${ data.packageName }" is already cloned.` ); - - promise = Promise.resolve(); - } else { - const command = [ - `git clone --progress "${ data.repository.url }" "${ destinationPath }"`, - `cd "${ destinationPath }"`, - `git checkout --quiet ${ data.repository.branch }` - ].join( ' && ' ); - - promise = exec( command ); - } - - return promise - .then( output => { - log.info( output ); - - const commandOutput = { - logs: log.all() - }; - - if ( data.options.recursive ) { - const packageJson = require( path.join( destinationPath, 'package.json' ) ); - const packages = []; - - if ( packageJson.dependencies ) { - packages.push( ...Object.keys( packageJson.dependencies ) ); - } - - if ( packageJson.devDependencies ) { - packages.push( ...Object.keys( packageJson.devDependencies ) ); - } - - commandOutput.packages = packages; - } - - return Promise.resolve( commandOutput ); - } ) - .catch( error => { - if ( isRemoteHungUpError( error ) && !data.doNotTryAgain ) { - const newData = Object.assign( {}, data, { - doNotTryAgain: true - } ); - - return delay( 5000 ).then( () => { - return command.execute( newData ); - } ); - } - - log.error( error ); - - return Promise.reject( { logs: log.all() } ); - } ); - }, - - /** - * @param {Set} processedPackages Collection of processed packages. - */ - afterExecute( processedPackages ) { - console.log( chalk.cyan( `${ processedPackages.size } packages have been processed.` ) ); - } -}; - -module.exports = command; - -// See: #87 and #92. -function isRemoteHungUpError( error ) { - if ( typeof error != 'string' ) { - error = error.toString(); - } - - const fatalErrors = error.split( '\n' ) - .filter( message => message.startsWith( 'fatal:' ) ) - .map( message => message.trim() ); - - return fatalErrors[ 0 ] && fatalErrors[ 0 ].match( /fatal: the remote end hung up unexpectedly/i ); -} - -function delay( ms ) { - return new Promise( resolve => { - setTimeout( resolve, ms ); - } ); -} diff --git a/lib/commands/checkout.js b/lib/commands/checkout.js index aec92ed..fc18015 100644 --- a/lib/commands/checkout.js +++ b/lib/commands/checkout.js @@ -5,28 +5,93 @@ 'use strict'; +const chalk = require( 'chalk' ); +const execCommand = require( './exec' ); +const gitStatusParser = require( '../utils/gitstatusparser' ); + module.exports = { + name: 'checkout', + + get helpMessage() { + const { + gray: g, + underline: u, + yellow: y + } = chalk; + + return ` + ${ u( 'Description:' ) } + Checks out the repository to specified branch or commit saved in "mgit.json" file. + + If specified a branch as an argument for "checkout" command, mgit will use the branch + instead of data saved in "mgit.json". E.g "${ g( 'mgit checkout master' ) }" will check out + all branches to "master". + + You can also call "${ g( 'mgit checkout .' ) }" in order to restore files before changes. + + ${ u( 'Options:' ) } + ${ y( '--branch' ) } (-b) If specified, mgit will create given branch in all repositories + that contain changes that could be committed. + ${ g( '> mgit checkout --branch develop' ) } + `; + }, + /** - * @param {Object} data - * @param {Object} data.repository - * @param {String} data.repository.branch Name of branch (or commit hash) saved in `mgit.json` file. + * @param {CommandData} data * @returns {Promise} */ execute( data ) { - const execCommand = require( './exec' ); - const checkoutCommand = `git checkout ${ data.repository.branch }`; + // Used `--branch` option. + if ( data.mgitOptions.branch ) { + return this._createAndCheckout( data.mgitOptions.branch, data ); + } + + const branch = data.arguments[ 0 ] || data.repository.branch; + const checkoutCommand = `git checkout ${ branch }`; + + return execCommand.execute( this._getExecData( checkoutCommand, data ) ); + }, - return execCommand.execute( getExecData( checkoutCommand ) ) + /** + * Executes "git checkout -b `branch`" command if a repository contains changes which could be committed. + * + * @private + * @param {String} branch + * @param {CommandData} data + * @returns {Promise} + */ + _createAndCheckout( branch, data ) { + const log = require( '../utils/log' )(); + + return execCommand.execute( this._getExecData( 'git status --branch --porcelain', data ) ) .then( execResponse => { - execResponse.logs.info = execResponse.logs.info[ 0 ].split( '\n' ).slice( -1 ); + const status = gitStatusParser( execResponse.logs.info[ 0 ] ); - return Promise.resolve( execResponse ); - } ); + if ( !status.anythingToCommit ) { + log.info( 'Repository does not contain changes to commit. New branch was not created.' ); + + return { + logs: log.all() + }; + } + + const checkoutCommand = `git checkout -b ${ branch }`; - function getExecData( command ) { - return Object.assign( {}, data, { - arguments: [ command ] + return execCommand.execute( this._getExecData( checkoutCommand, data ) ); } ); - } + }, + + /** + * Prepares new configuration object for "execute" command which is called inside this command. + * + * @private + * @param {String} command + * @param {CommandData} data + * @returns {CommandData} + */ + _getExecData( command, data ) { + return Object.assign( {}, data, { + arguments: [ command ] + } ); } }; diff --git a/lib/commands/close.js b/lib/commands/close.js new file mode 100644 index 0000000..216d76d --- /dev/null +++ b/lib/commands/close.js @@ -0,0 +1,141 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +'use strict'; + +const chalk = require( 'chalk' ); +const buildOptions = require( 'minimist-options' ); +const minimist = require( 'minimist' ); + +module.exports = { + get helpMessage() { + const { + italic: i, + gray: g, + magenta: m, + underline: u + } = chalk; + + return ` + ${ u( 'Description:' ) } + Merges specified branch with the current which on the repository is checked out. + + Merge is executed only on repositories where specified branch exists. + + The merge commit will be made using following message: "${ i( 'Merge branch \'branch-name\'' ) }". + + After merging, specified branch will be removed from the remote and local registry. + + ${ u( 'Options:' ) } + ${ m( '--message' ) } (-m) An additional description for merge commit. It will be + appended to the default message. E.g.: + ${ g( '> mgit merge develop -- -m "Some description about merged changes."' ) } + `; + }, + + beforeExecute( args ) { + if ( !args[ 1 ] ) { + throw new Error( 'Missing branch to merge. Use: mgit close [branch].' ); + } + }, + + /** + * @param {CommandData} data + * @returns {Promise} + */ + execute( data ) { + const log = require( '../utils/log' )(); + const execCommand = require( './exec' ); + const branch = data.arguments[ 0 ]; + + return execCommand.execute( getExecData( `git branch --list ${ branch }` ) ) + .then( execResponse => { + const branchExists = Boolean( execResponse.logs.info[ 0 ] ); + + if ( !branchExists ) { + log.info( 'Branch does not exist.' ); + + return { + logs: log.all() + }; + } + + const mergeMessage = this._getMergeMessage( data.mgitOptions, data.arguments ); + const commitTitle = `Merge branch '${ branch }'`; + + let mergeCommand = `git merge ${ branch } --no-ff -m "${ commitTitle }"`; + + if ( mergeMessage.length ) { + mergeCommand += ' ' + mergeMessage.map( message => `-m "${ message }"` ).join( ' ' ); + } + + return execCommand.execute( getExecData( mergeCommand ) ) + .then( execResponse => { + log.concat( execResponse.logs ); + log.info( `Removing "${ branch }" branch from the local registry.` ); + + return execCommand.execute( getExecData( `git branch -d ${ branch }` ) ); + } ) + .then( execResponse => { + log.concat( execResponse.logs ); + log.info( `Removing "${ branch }" branch from the remote.` ); + + return execCommand.execute( getExecData( `git push origin :${ branch }` ) ); + } ) + .then( execResponse => { + log.concat( execResponse.logs ); + + return { logs: log.all() }; + } ); + } ); + + function getExecData( command ) { + return Object.assign( {}, data, { + arguments: [ command ] + } ); + } + }, + + /** + * @private + * @param {options} mgitOptions Options resolved by mgit. + * @param {Array.>} argv List of arguments provided by the user via CLI. + * @returns {Array.} + */ + _getMergeMessage( mgitOptions, argv ) { + const cliOptions = this._parseArguments( argv ); + + let message; + + if ( mgitOptions.message ) { + message = mgitOptions.message; + } else if ( cliOptions.message ) { + message = cliOptions.message; + } else { + return []; + } + + /* istanbul ignore else */ + if ( !Array.isArray( message ) ) { + message = [ message ].filter( Boolean ); + } + + return message; + }, + + /** + * @private + * @param {Array.} argv List of arguments provided by the user via CLI. + * @returns {Object} + */ + _parseArguments( argv ) { + return minimist( argv, buildOptions( { + message: { + type: 'string', + alias: 'm', + } + } ) ); + } +}; diff --git a/lib/commands/commit.js b/lib/commands/commit.js new file mode 100644 index 0000000..5eafb69 --- /dev/null +++ b/lib/commands/commit.js @@ -0,0 +1,155 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +'use strict'; + +const chalk = require( 'chalk' ); +const buildOptions = require( 'minimist-options' ); +const minimist = require( 'minimist' ); +const gitStatusParser = require( '../utils/gitstatusparser' ); + +module.exports = { + name: 'commit', + + get helpMessage() { + const { + italic: i, + gray: g, + magenta: m, + underline: u, + yellow: y, + } = chalk; + + return ` + ${ u( 'Description:' ) } + Makes a commit in every repository that contains tracked files that have changed. + This command is a shorthand for: "${ i( 'mgit exec \'git commit -a\'' ) }". + + ${ u( 'Options:' ) } + ${ y( '--message' ) } (-m) Required. A message for the commit. It can be specified more then once, e.g.: + ${ g( '> mgit commit --message "Title of the commit." --message "Additional description."' ) } + + ${ u( 'Git Options:' ) } + ${ m( '--no-verify' ) } (-n) Whether to skip pre-commit and commit-msg hooks. + ${ g( '> mgit commit -m "Title of the commit." -- -n' ) } + `; + }, + + /** + * @param {Array.} args Arguments and options that a user provided calling the command. + * @param {Options} mgitOptions Options resolved by mgit. + */ + beforeExecute( args, mgitOptions ) { + const cliOptions = this._parseArguments( args ); + const commitMessage = this._getCommitMessage( mgitOptions, cliOptions ); + + if ( !commitMessage.length ) { + throw new Error( 'Missing --message (-m) option. Call "mgit commit -h" in order to read more.' ); + } + }, + + /** + * @param {CommandData} data + * @returns {Promise} + */ + execute( data ) { + const log = require( '../utils/log' )(); + const execCommand = require( './exec' ); + + return execCommand.execute( getExecData( 'git status --branch --porcelain' ) ) + .then( execResponse => { + const status = gitStatusParser( execResponse.logs.info[ 0 ] ); + + if ( !status.anythingToCommit ) { + log.info( 'Nothing to commit.' ); + + return { + logs: log.all() + }; + } + + const cliOptions = this._parseArguments( data.arguments ); + const commitCommand = this._buildCliCommand( data.mgitOptions, cliOptions ); + + return execCommand.execute( getExecData( commitCommand ) ); + } ); + + function getExecData( command ) { + return Object.assign( {}, data, { + arguments: [ command ] + } ); + } + }, + + /** + * @private + * @param {options} mgitOptions Options resolved by mgit. + * @param {Object} cliOptions Parsed arguments provided by the user via CLI. + * @returns {String} + */ + _buildCliCommand( mgitOptions, cliOptions ) { + const commitMessage = this._getCommitMessage( mgitOptions, cliOptions ); + let command = 'git commit -a'; + + command += ' ' + commitMessage.map( message => `-m "${ message }"` ).join( ' ' ); + + if ( cliOptions[ 'no-verify' ] ) { + command += ' -n'; + } + + return command; + }, + + /** + * @private + * @param {Options} mgitOptions Options resolved by mgit. + * @param {Object} cliOptions Parsed arguments provided by the user via CLI. + * @returns {Array.} + */ + _getCommitMessage( mgitOptions, cliOptions ) { + let message; + + if ( mgitOptions.message ) { + message = mgitOptions.message; + } else if ( cliOptions.message ) { + message = cliOptions.message; + } else { + return []; + } + + /* istanbul ignore else */ + if ( !Array.isArray( message ) ) { + message = [ message ].filter( Boolean ); + } + + return message; + }, + + /** + * @private + * @param {Array.} argv List of arguments provided by the user via CLI. + * @returns {Object} + */ + _parseArguments( argv ) { + const options = minimist( argv, buildOptions( { + message: { + type: 'string', + alias: 'm', + }, + 'no-verify': { + type: 'boolean', + alias: 'n', + default: false + } + } ) ); + + /* istanbul ignore else */ + if ( !Array.isArray( options.message ) ) { + options.message = [ options.message ].filter( Boolean ); + } + + return options; + } +}; diff --git a/lib/commands/diff.js b/lib/commands/diff.js index 9c1ff3e..9e27fb4 100644 --- a/lib/commands/diff.js +++ b/lib/commands/diff.js @@ -8,9 +8,35 @@ const chalk = require( 'chalk' ); module.exports = { + skipCounter: true, + + get helpMessage() { + const { + italic: i, + gray: g, + magenta: m, + underline: u + } = chalk; + + return ` + ${ u( 'Description:' ) } + Shows changes between commits, commit and working tree, etc. Works the same as "${ i( 'git diff' ) }" command. By default a flag + "${ m( '--color' ) }" is adding. You can cancel it using option "${ m( '--no-color' ) }". + + ${ u( 'Git Options:' ) } + All options accepted by "${ i( 'git diff' ) }" are supported by mgit. Everything specified after "--" is passed directly to the + "${ i( 'git diff' ) }" command. + + E.g.: "${ g( 'mgit diff -- origin/master..master' ) }" will execute "${ i( 'git diff --color origin/master..master' ) }" + `; + }, + + beforeExecute() { + console.log( chalk.blue( 'Collecting changes...' ) ); + }, + /** - * @param {Object} data - * @param {Array.} data.arguments The rest of arguments provided by the user. These options will modify the `git diff` command. + * @param {CommandData} data * @returns {Promise} */ execute( data ) { @@ -20,12 +46,10 @@ module.exports = { return execCommand.execute( getExecData( diffCommand ) ) .then( execResponse => { if ( !execResponse.logs.info.length ) { - return Promise.resolve( {} ); + return {}; } - return Promise.resolve( { - logs: execResponse.logs - } ); + return execResponse; } ); function getExecData( command ) { diff --git a/lib/commands/exec.js b/lib/commands/exec.js index 93bc7f9..3d2fc1b 100644 --- a/lib/commands/exec.js +++ b/lib/commands/exec.js @@ -7,9 +7,25 @@ const fs = require( 'fs' ); const path = require( 'upath' ); -const exec = require( '../utils/exec' ); +const chalk = require( 'chalk' ); +const shell = require( '../utils/shell' ); module.exports = { + get helpMessage() { + const { + italic: i, + gray: g, + underline: u + } = chalk; + + return ` + ${ u( 'Description:' ) } + Requires a command that will be executed on all repositories. E.g. "${ g( 'mgit exec pwd' ) }" will execute "${ i( 'pwd' ) }" + command in every repository. Commands that contain spaces must be wrapped in quotation marks, + e.g.: "${ g( 'mgit exec "git remote"' ) }". + `; + }, + /** * @param {Array.} args Arguments that user provided calling the mgit. */ @@ -20,38 +36,34 @@ module.exports = { }, /** - * @param {Object} data - * @param {Object} data.arguments Arguments that user provided calling the mgit. - * @param {String} data.packageName Name of current package to process. - * @param {Options} data.options The options object. - * @param {Repository} data.repository + * @param {CommandData} data * @returns {Promise} */ execute( data ) { const log = require( '../utils/log' )(); return new Promise( ( resolve, reject ) => { - const newCwd = path.join( data.options.packages, data.repository.directory ); + const newCwd = path.join( data.mgitOptions.packages, data.repository.directory ); // Package does not exist. if ( !fs.existsSync( newCwd ) ) { - log.error( `Package "${ data.packageName }" is not available. Run "mgit bootstrap" in order to download the package.` ); + log.error( `Package "${ data.packageName }" is not available. Run "mgit sync" in order to download the package.` ); return reject( { logs: log.all() } ); } process.chdir( newCwd ); - exec( data.arguments[ 0 ] ) + shell( data.arguments[ 0 ] ) .then( stdout => { - process.chdir( data.options.cwd ); + process.chdir( data.mgitOptions.cwd ); log.info( stdout ); resolve( { logs: log.all() } ); } ) .catch( error => { - process.chdir( data.options.cwd ); + process.chdir( data.mgitOptions.cwd ); log.error( error ); diff --git a/lib/commands/fetch.js b/lib/commands/fetch.js new file mode 100644 index 0000000..ad47c70 --- /dev/null +++ b/lib/commands/fetch.js @@ -0,0 +1,97 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +'use strict'; + +const fs = require( 'fs' ); +const path = require( 'upath' ); +const chalk = require( 'chalk' ); +const buildOptions = require( 'minimist-options' ); +const minimist = require( 'minimist' ); + +module.exports = { + skipCounter: true, + + get helpMessage() { + const { + italic: i, + gray: g, + magenta: m, + underline: u + } = chalk; + + return ` + ${ u( 'Description:' ) } + Download objects and refs from the remote repository. If some package is missed, the command will not be executed. + For cloned repositories this command is a shorthand for: "${ i( 'mgit exec \'git fetch\'' ) }". + + ${ u( 'Git Options:' ) } + ${ m( '--prune' ) } (-p) Before fetching, remove any remote-tracking references that + no longer exist on the remote. + ${ g( 'Default: false' ) } + `; + }, + + /** + * @param {CommandData} data + * @returns {Promise} + */ + execute( data ) { + const log = require( '../utils/log' )(); + const execCommand = require( './exec' ); + + const destinationPath = path.join( data.mgitOptions.packages, data.repository.directory ); + + // Package is not cloned. + if ( !fs.existsSync( destinationPath ) ) { + return Promise.resolve( {} ); + } + + const options = this._parseArguments( data.arguments ); + let command = 'git fetch'; + + if ( options.prune ) { + command += ' -p'; + } + + return execCommand.execute( getExecData( command ) ) + .then( execResponse => { + if ( execResponse.logs.info.length ) { + return execResponse; + } + + log.info( 'Repository is up to date.' ); + + return { logs: log.all() }; + } ); + + function getExecData( command ) { + return Object.assign( {}, data, { + arguments: [ command ] + } ); + } + }, + + /** + * @param {Set} parsedPackages Collection of processed packages. + */ + afterExecute( parsedPackages ) { + console.log( chalk.cyan( `${ parsedPackages.size } packages have been processed.` ) ); + }, + + /** + * @private + * @param {Array.} argv List of arguments provided by the user via CLI. + * @returns {Object} + */ + _parseArguments( argv ) { + return minimist( argv, buildOptions( { + prune: { + type: 'boolean', + alias: 'p', + } + } ) ); + } +}; diff --git a/lib/commands/pull.js b/lib/commands/pull.js new file mode 100644 index 0000000..d84e376 --- /dev/null +++ b/lib/commands/pull.js @@ -0,0 +1,57 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +'use strict'; + +const fs = require( 'fs' ); +const path = require( 'upath' ); +const chalk = require( 'chalk' ); + +module.exports = { + skipCounter: true, + + get helpMessage() { + const { + italic: i, + underline: u + } = chalk; + + return ` + ${ u( 'Description:' ) } + Pull changes in all packages. If some package is missed, the command will not be executed. + For cloned repositories this command is a shorthand for: "${ i( 'mgit exec \'git pull\'' ) }". + `; + }, + + /** + * @param {CommandData} data + * @returns {Promise} + */ + execute( data ) { + const execCommand = require( './exec' ); + + const destinationPath = path.join( data.mgitOptions.packages, data.repository.directory ); + + // Package is not cloned. + if ( !fs.existsSync( destinationPath ) ) { + return Promise.resolve( {} ); + } + + return execCommand.execute( getExecData( 'git pull' ) ); + + function getExecData( command ) { + return Object.assign( {}, data, { + arguments: [ command ] + } ); + } + }, + + /** + * @param {Set} parsedPackages Collection of processed packages. + */ + afterExecute( parsedPackages ) { + console.log( chalk.cyan( `${ parsedPackages.size } packages have been processed.` ) ); + } +}; diff --git a/lib/commands/push.js b/lib/commands/push.js new file mode 100644 index 0000000..7b519c3 --- /dev/null +++ b/lib/commands/push.js @@ -0,0 +1,66 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +'use strict'; + +const fs = require( 'fs' ); +const path = require( 'upath' ); +const chalk = require( 'chalk' ); + +module.exports = { + skipCounter: true, + + get helpMessage() { + const { + italic: i, + gray: g, + underline: u + } = chalk; + + return ` + ${ u( 'Description:' ) } + Push changes in all packages. If some package is missed, the command will not be executed. + For cloned repositories this command is a shorthand for: "${ i( 'mgit exec \'git push\'' ) }". + + ${ u( 'Git Options:' ) } + All options accepted by "${ i( 'git push' ) }" are supported by mgit. Everything specified after "--" is passed directly to the + "${ i( 'git push' ) }" command. + + E.g.: "${ g( 'mgit push -- --verbose --all' ) }" will execute "${ i( 'git push --verbose --all' ) }" + `; + }, + + /** + * @param {CommandData} data + * @returns {Promise} + */ + execute( data ) { + const execCommand = require( './exec' ); + + const destinationPath = path.join( data.mgitOptions.packages, data.repository.directory ); + + // Package is not cloned. + if ( !fs.existsSync( destinationPath ) ) { + return Promise.resolve( {} ); + } + + const pushCommand = ( 'git push ' + data.arguments.join( ' ' ) ).trim(); + + return execCommand.execute( getExecData( pushCommand ) ); + + function getExecData( command ) { + return Object.assign( {}, data, { + arguments: [ command ] + } ); + } + }, + + /** + * @param {Set} parsedPackages Collection of processed packages. + */ + afterExecute( parsedPackages ) { + console.log( chalk.cyan( `${ parsedPackages.size } packages have been processed.` ) ); + } +}; diff --git a/lib/commands/save.js b/lib/commands/save.js new file mode 100644 index 0000000..5687918 --- /dev/null +++ b/lib/commands/save.js @@ -0,0 +1,122 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +'use strict'; + +const path = require( 'upath' ); +const chalk = require( 'chalk' ); +const updateJsonFile = require( '../utils/updatejsonfile' ); +const gitStatusParser = require( '../utils/gitstatusparser' ); + +module.exports = { + get helpMessage() { + const { + gray: g, + underline: u, + yellow: y + } = chalk; + + return ` + ${ u( 'Description:' ) } + Saves hashes of commits or branches which repositories are checked out in "mgit.json" file. + + ${ u( 'Options:' ) } + ${ y( '--hash' ) } Whether to save hashes (id of last commit) on current branch. + ${ g( 'Default: true' ) } + ${ y( '--branch' ) } (-b) Whether to save names of current branches instead of commit ids. + ${ g( 'Default: false' ) } + ${ g( '> mgit save --branch' ) } + `; + }, + + /** + * @param {Array.} args Arguments and options that a user provided calling the command. + * @param {Options} mgitOptions Options resolved by mgit. + */ + beforeExecute( args, mgitOptions ) { + if ( !mgitOptions.branch && !mgitOptions.hash ) { + mgitOptions.hash = true; + mgitOptions.branch = false; + } + + if ( mgitOptions.hash && mgitOptions.branch ) { + throw new Error( 'Cannot use "hash" and "branch" options at the same time.' ); + } + }, + + /** + * @param {CommandData} data + * @returns {Promise} + */ + execute( data ) { + const log = require( '../utils/log' )(); + const execCommand = require( './exec' ); + + let promise; + + /* istanbul ignore else */ + if ( data.mgitOptions.branch ) { + promise = execCommand.execute( getExecData( 'git status --branch --porcelain' ) ) + .then( execResponse => gitStatusParser( execResponse.logs.info[ 0 ] ).branch ); + } else if ( data.mgitOptions.hash ) { + promise = execCommand.execute( getExecData( 'git rev-parse HEAD' ) ) + .then( execResponse => execResponse.logs.info[ 0 ].slice( 0, 7 ) ); + } + + return promise + .then( dataToSave => { + const commandResponse = { + packageName: data.packageName, + data: dataToSave, + branch: data.mgitOptions.branch, + hash: data.mgitOptions.hash + }; + + /* istanbul ignore else */ + if ( data.mgitOptions.branch ) { + log.info( `Branch: "${ dataToSave }".` ); + } else if ( data.mgitOptions.hash ) { + log.info( `Commit: "${ dataToSave }".` ); + } + + return { + response: commandResponse, + logs: log.all() + }; + } ); + + function getExecData( command ) { + return Object.assign( {}, data, { + arguments: [ command ] + } ); + } + }, + + /** + * Saves collected hashes to configuration file. + * + * @param {Set} processedPackages Collection of processed packages. + * @param {Set} commandResponses Results of executed command for each package. + */ + afterExecute( processedPackages, commandResponses ) { + const cwd = require( '../utils/getcwd' )(); + const mgitJsonPath = path.join( cwd, 'mgit.json' ); + + updateJsonFile( mgitJsonPath, json => { + for ( const response of commandResponses.values() ) { + const repository = json.dependencies[ response.packageName ].split( '#' )[ 0 ]; + + // If returned branch is equal to 'master', save only the repository path. + if ( response.branch && response.data === 'master' ) { + json.dependencies[ response.packageName ] = repository; + } else { + json.dependencies[ response.packageName ] = `${ repository }#${ response.data }`; + } + } + + return json; + } ); + } +}; diff --git a/lib/commands/savehashes.js b/lib/commands/savehashes.js deleted file mode 100644 index af375c0..0000000 --- a/lib/commands/savehashes.js +++ /dev/null @@ -1,65 +0,0 @@ -/** - * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. - * For licensing, see LICENSE.md. - */ - -'use strict'; - -const path = require( 'upath' ); -const updateJsonFile = require( '../utils/updatejsonfile' ); - -module.exports = { - /** - * @param {Object} data - * @param {String} data.packageName Name of current package to process. - * @returns {Promise} - */ - execute( data ) { - const log = require( '../utils/log' )(); - const execCommand = require( './exec' ); - - return execCommand.execute( getExecData( 'git rev-parse HEAD' ) ) - .then( execResponse => { - const commitHash = execResponse.logs.info[ 0 ]; - - const commandResponse = { - packageName: data.packageName, - commit: commitHash.slice( 0, 7 ) // Short version of the commit hash. - }; - - log.info( `Commit: "${ commitHash }".` ); - - return Promise.resolve( { - response: commandResponse, - logs: log.all() - } ); - } ); - - function getExecData( command ) { - return Object.assign( {}, data, { - arguments: [ command ] - } ); - } - }, - - /** - * Saves collected hashes to configuration file. - * - * @param {Set} processedPackages Collection of processed packages. - * @param {Set} commandResponses Results of executed command for each package. - */ - afterExecute( processedPackages, commandResponses ) { - const cwd = require( '../utils/getcwd' )(); - const mgitJsonPath = path.join( cwd, 'mgit.json' ); - - updateJsonFile( mgitJsonPath, json => { - for ( const response of commandResponses.values() ) { - const repository = json.dependencies[ response.packageName ].split( '#' )[ 0 ]; - - json.dependencies[ response.packageName ] = `${ repository }#${ response.commit }`; - } - - return json; - } ); - } -}; diff --git a/lib/commands/status.js b/lib/commands/status.js index 97f843c..6cf80f8 100644 --- a/lib/commands/status.js +++ b/lib/commands/status.js @@ -10,16 +10,31 @@ const Table = require( 'cli-table' ); const gitStatusParser = require( '../utils/gitstatusparser' ); module.exports = { + name: 'status', + + get helpMessage() { + const { + underline: u + } = chalk; + + return ` + ${ u( 'Description:' ) } + Prints a useful table that contains status of every repository. It displays: + + * current branch, + * whether current branch is equal to specified in "mgit.json" file, + * whether current branch is behind or ahead with the remote, + * current commit short hash, + * how many files is staged, modified and untracked. + `; + }, + beforeExecute() { console.log( chalk.blue( 'Collecting statuses...' ) ); }, /** - * @param {Object} data - * @param {Options} data.options Mgit options. - * @param {String} data.packageName Name of current package to process. - * @param {Object} data.repository - * @param {String} data.repository.branch Name of branch (or commit hash) saved in `mgit.json` file. + * @param {CommandData} data * @returns {Promise} */ execute( data ) { @@ -34,8 +49,8 @@ module.exports = { .then( ( [ hashResponse, statusResponse ] ) => { let packageName = data.packageName; - if ( data.options.packagesPrefix ) { - packageName = packageName.replace( data.options.packagesPrefix, '' ); + if ( data.mgitOptions.packagesPrefix ) { + packageName = packageName.replace( data.mgitOptions.packagesPrefix, '' ); } const commandResponse = { @@ -45,9 +60,7 @@ module.exports = { mgitBranch: data.repository.branch }; - return Promise.resolve( { - response: commandResponse - } ); + return { response: commandResponse }; } ); function getExecData( command ) { @@ -77,12 +90,14 @@ module.exports = { const packagesResponses = Array.from( commandResponses.values() ) .sort( ( a, b ) => { + /* istanbul ignore else */ if ( a.packageName < b.packageName ) { return -1; } else if ( a.packageName > b.packageName ) { return 1; } + /* istanbul ignore next */ return 0; } ); diff --git a/lib/commands/sync.js b/lib/commands/sync.js new file mode 100644 index 0000000..35884a4 --- /dev/null +++ b/lib/commands/sync.js @@ -0,0 +1,254 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +'use strict'; + +const fs = require( 'fs' ); +const path = require( 'upath' ); +const chalk = require( 'chalk' ); +const shell = require( '../utils/shell' ); + +module.exports = { + get helpMessage() { + const { + gray: g, + magenta: m, + underline: u + } = chalk; + + return ` + ${ u( 'Description:' ) } + Updates all packages. For packages that contain uncommitted changes, the update process is aborted. + If some package is missed, it will be installed automatically. + + The update process executes following commands: + + * Checks whether repository can be updated. If the repository contains uncommitted changes, + the process is aborted. + * Fetches changes from the remote. + * Checks out on the branch or particular commit that is specified in "mgit.json" file. + * Pulls the changes if the repository is not detached at some commit. + + ${ u( 'Options:' ) } + ${ m( '--recursive' ) } (-r) Whether to install dependencies recursively. Only packages matching these + patterns will be cloned recursively. + ${ g( 'Default: false' ) } + `; + }, + + /** + * @param {CommandData} data + * @returns {Promise} + */ + execute( data ) { + const log = require( '../utils/log' )(); + const execCommand = require( './exec' ); + + const destinationPath = path.join( data.mgitOptions.packages, data.repository.directory ); + + // Package is not cloned. + if ( !fs.existsSync( destinationPath ) ) { + log.info( `Package "${ data.packageName }" was not found. Cloning...` ); + + return this._clonePackage( { + path: destinationPath, + name: data.packageName, + url: data.repository.url, + branch: data.repository.branch + }, data.mgitOptions, { log } ); + } + + return execCommand.execute( getExecData( 'git status -s' ) ) + .then( response => { + const stdout = response.logs.info.join( '\n' ).trim(); + + if ( stdout ) { + throw new Error( `Package "${ data.packageName }" has uncommitted changes. Aborted.` ); + } + + return execCommand.execute( getExecData( 'git fetch' ) ); + } ) + .then( response => { + log.concat( response.logs ); + } ) + .then( () => { + return execCommand.execute( getExecData( `git checkout ${ data.repository.branch }` ) ); + } ) + .then( response => { + log.concat( response.logs ); + } ) + .then( () => { + return execCommand.execute( getExecData( 'git branch' ) ); + } ) + .then( response => { + const stdout = response.logs.info.join( '\n' ).trim(); + const isOnBranchRegexp = /HEAD detached at+/; + + // If on a detached commit, mgit must not pull the changes. + if ( isOnBranchRegexp.test( stdout ) ) { + log.info( `Package "${ data.packageName }" is on a detached commit.` ); + + return { logs: log.all() }; + } + + return execCommand.execute( getExecData( `git pull origin ${ data.repository.branch }` ) ) + .then( response => { + log.concat( response.logs ); + + return { logs: log.all() }; + } ); + } ) + .catch( commandResponseOrError => { + if ( commandResponseOrError instanceof Error ) { + log.error( commandResponseOrError.message ); + } else { + log.concat( commandResponseOrError.logs ); + } + + return Promise.reject( { logs: log.all() } ); + } ); + + function getExecData( command ) { + return Object.assign( {}, data, { + arguments: [ command ] + } ); + } + }, + + /** + * @param {Set} parsedPackages Collection of processed packages. + */ + afterExecute( parsedPackages, commandResponses, mgitOptions ) { + console.log( chalk.cyan( `${ parsedPackages.size } packages have been processed.` ) ); + + const repositoryResolver = require( mgitOptions.resolverPath ); + + const repositoryDirectories = Object.keys( mgitOptions.dependencies ) + .map( packageName => { + const repository = repositoryResolver( packageName, mgitOptions ); + + return path.join( mgitOptions.packages, repository.directory ); + } ); + + const skippedPackages = fs.readdirSync( mgitOptions.packages ) + .map( directoryName => { + const absolutePath = path.join( mgitOptions.packages, directoryName ); + + if ( !directoryName.startsWith( '@' ) ) { + return absolutePath; + } + + return fs.readdirSync( absolutePath ).map( directoryName => path.join( absolutePath, directoryName ) ); + } ) + // TODO: Array.prototype.flat would be awesome here... But it isn't supported in Node yet. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flat + .reduce( ( pathsCollection, pathOrArrayPaths ) => { + if ( Array.isArray( pathOrArrayPaths ) ) { + pathsCollection.push( ...pathOrArrayPaths ); + } else { + pathsCollection.push( pathOrArrayPaths ); + } + + return pathsCollection; + }, [] ) + .filter( pathOrDirectory => { + if ( !fs.lstatSync( pathOrDirectory ).isDirectory() ) { + return false; + } + + return !repositoryDirectories.includes( pathOrDirectory ); + } ); + + if ( skippedPackages.length ) { + console.log( + chalk.yellow( 'Paths to directories listed below are skipped by mgit because they are not defined in "mgit.json":' ) + ); + + skippedPackages.forEach( absolutePath => { + console.log( chalk.yellow( ` - ${ absolutePath }` ) ); + } ); + } + }, + + /** + * @private + * @param {Object} packageDetails + * @param {String} packageDetails.name A name of the package. + * @param {String} packageDetails.url A url that will be cloned. + * @param {String} packageDetails.path An absolute path where the package should be cloned. + * @param {String} packageDetails.branch A branch on which the repository will be checked out after cloning. + * @param {Options} mgitOptions Options resolved by mgit. + * @param {Object} options Additional options which aren't related to mgit. + * @param {Logger} options.log Logger + * @param {Boolean} [options.doNotTryAgain=false] If set to `true`, bootstrap command won't be executed again. + * @returns {Promise} + */ + _clonePackage( packageDetails, mgitOptions, options ) { + const log = options.log; + + const command = [ + `git clone --progress "${ packageDetails.url }" "${ packageDetails.path }"`, + `cd "${ packageDetails.path }"`, + `git checkout --quiet ${ packageDetails.branch }` + ].join( ' && ' ); + + return shell( command ) + .then( output => { + log.info( output ); + + const commandOutput = { + logs: log.all() + }; + + if ( mgitOptions.recursive ) { + const packageJson = require( path.join( packageDetails.path, 'package.json' ) ); + const packages = []; + + if ( packageJson.dependencies ) { + packages.push( ...Object.keys( packageJson.dependencies ) ); + } + + if ( packageJson.devDependencies ) { + packages.push( ...Object.keys( packageJson.devDependencies ) ); + } + + commandOutput.packages = packages; + } + + return commandOutput; + } ) + .catch( error => { + /* istanbul ignore else */ + if ( isRemoteHungUpError( error ) && !options.doNotTryAgain ) { + return delay( 5000 ).then( () => { + return this._clonePackage( packageDetails, mgitOptions, { log, doNotTryAgain: true } ); + } ); + } + + log.error( error ); + + return Promise.reject( { logs: log.all() } ); + } ); + } +}; + +// See: https://github.com/cksource/mgit2/issues/87 +function isRemoteHungUpError( error ) { + if ( typeof error != 'string' ) { + error = error.toString(); + } + + const fatalErrors = error.split( '\n' ) + .filter( message => message.startsWith( 'fatal:' ) ) + .map( message => message.trim() ); + + return fatalErrors[ 0 ] && fatalErrors[ 0 ].match( /fatal: the remote end hung up unexpectedly/i ); +} + +function delay( ms ) { + return new Promise( resolve => { + setTimeout( resolve, ms ); + } ); +} diff --git a/lib/commands/update.js b/lib/commands/update.js deleted file mode 100644 index 1f660be..0000000 --- a/lib/commands/update.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. - * For licensing, see LICENSE.md. - */ - -'use strict'; - -const fs = require( 'fs' ); -const path = require( 'upath' ); -const chalk = require( 'chalk' ); - -module.exports = { - /** - * @param {Object} data - * @param {String} data.packageName Name of current package to process. - * @param {Options} data.options The options object. - * @param {Repository} data.repository - * @returns {Promise} - */ - execute( data ) { - const log = require( '../utils/log' )(); - const bootstrapCommand = require( './bootstrap' ); - const execCommand = require( './exec' ); - - const destinationPath = path.join( data.options.packages, data.repository.directory ); - - // Package is not cloned. - if ( !fs.existsSync( destinationPath ) ) { - log.info( `Package "${ data.packageName }" was not found. Cloning...` ); - - const bootstrapOptions = { - options: data.options, - packageName: data.packageName, - repository: data.repository - }; - - return bootstrapCommand.execute( bootstrapOptions ) - .then( response => { - log.concat( response.logs ); - - response.logs = log.all(); - - return Promise.resolve( response ); - } ); - } - - return execCommand.execute( getExecData( 'git status -s' ) ) - .then( response => { - const stdout = response.logs.info.join( '\n' ).trim(); - - if ( stdout ) { - throw new Error( `Package "${ data.packageName }" has uncommitted changes. Aborted.` ); - } - - return execCommand.execute( getExecData( 'git fetch' ) ); - } ) - .then( response => { - log.concat( response.logs ); - } ) - .then( () => { - return execCommand.execute( getExecData( `git checkout ${ data.repository.branch }` ) ); - } ) - .then( response => { - log.concat( response.logs ); - } ) - .then( () => { - return execCommand.execute( getExecData( 'git branch' ) ); - } ) - .then( response => { - const stdout = response.logs.info.join( '\n' ).trim(); - const isOnBranchRegexp = /HEAD detached at+/; - - // If on a detached commit, mgit must not pull the changes. - if ( isOnBranchRegexp.test( stdout ) ) { - log.info( `Package "${ data.packageName }" is on a detached commit.` ); - - return Promise.resolve( { logs: log.all() } ); - } - - return execCommand.execute( getExecData( `git pull origin ${ data.repository.branch }` ) ) - .then( response => { - log.concat( response.logs ); - - return Promise.resolve( { logs: log.all() } ); - } ); - } ) - .catch( commandResponseOrError => { - if ( commandResponseOrError instanceof Error ) { - log.error( commandResponseOrError.message ); - } else { - log.concat( commandResponseOrError.logs ); - } - - return Promise.reject( { logs: log.all() } ); - } ); - - function getExecData( command ) { - return Object.assign( {}, data, { - arguments: [ command ] - } ); - } - }, - - /** - * @param {Set} parsedPackages Collection of processed packages. - */ - afterExecute( parsedPackages ) { - console.log( chalk.cyan( `${ parsedPackages.size } packages have been processed.` ) ); - } -}; diff --git a/lib/default-resolver.js b/lib/default-resolver.js index d901d85..e1bdd38 100644 --- a/lib/default-resolver.js +++ b/lib/default-resolver.js @@ -11,7 +11,7 @@ const parseRepositoryUrl = require( './utils/parserepositoryurl' ); * Resolves repository URL for a given package name. * * @param {String} packageName Package name. - * @param {Options} data.options The options object. + * @param {Options} options The options object. * @returns {Repository|null} */ module.exports = function resolver( packageName, options ) { diff --git a/lib/index.js b/lib/index.js index 7b086ee..b083ecc 100644 --- a/lib/index.js +++ b/lib/index.js @@ -5,60 +5,57 @@ 'use strict'; -const path = require( 'upath' ); +const chalk = require( 'chalk' ); const createForkPool = require( './utils/createforkpool' ); -const logDisplay = require( './utils/displaylog' ); +const displayLog = require( './utils/displaylog' ); const getOptions = require( './utils/getoptions' ); const getPackageNames = require( './utils/getpackagenames' ); -const chalk = require( 'chalk' ); +const getCommandInstance = require( './utils/getcommandinstance' ); +const getCwd = require( './utils/getcwd' ); -const aliases = { - st: 'status', - co: 'checkout' -}; +const CHILD_PROCESS_PATH = require.resolve( './utils/child-process' ); /** * @param {Array.} args Arguments that the user provided. * @param {Options} options The options object. It will be extended with the default options. */ module.exports = function( args, options ) { - const startTime = process.hrtime(); - const forkPool = createForkPool( path.join( __dirname, 'utils', 'child-process.js' ) ); + const command = getCommandInstance( args[ 0 ] ); - options = getOptions( options, require( './utils/getcwd' )() ); - - const repositoryResolver = require( options.resolverPath ); - - // If used an alias in order to call the command - replace the alias with a full name. - if ( args[ 0 ] in aliases ) { - args[ 0 ] = aliases[ args[ 0 ] ]; + if ( !command ) { + return; } - // Remove all dashes from command name. - args[ 0 ] = args[ 0 ].replace( /-/g, '' ); + const startTime = process.hrtime(); + const mgitOptions = getOptions( options, getCwd() ); + const repositoryResolver = require( mgitOptions.resolverPath ); + const forkPool = createForkPool( CHILD_PROCESS_PATH ); - const commandPath = path.join( __dirname, 'commands', args[ 0 ] ); - const command = require( commandPath ); + if ( command.beforeExecute ) { + try { + command.beforeExecute( args, mgitOptions ); + } catch ( error ) { + console.log( chalk.red( error.message ) ); + process.exit( 1 ); - if ( typeof command.beforeExecute == 'function' ) { - command.beforeExecute( args ); + return; + } } const processedPackages = new Set(); const commandResponses = new Set(); const packagesWithError = new Set(); - const packageNames = getPackageNames( options ); + const packageNames = getPackageNames( mgitOptions ); let allPackagesNumber = packageNames.length; + let donePackagesNumber = 0; if ( allPackagesNumber === 0 ) { - console.log( chalk.red( 'No packages found that match the given criteria.' ) ); + console.log( chalk.yellow( 'No packages found that match to specified criteria.' ) ); return onDone(); } - let donePackagesNumber = 0; - for ( const item of packageNames ) { enqueue( item ); } @@ -71,11 +68,11 @@ module.exports = function( args, options ) { processedPackages.add( packageName ); const data = { - command: commandPath, - arguments: args.slice( 1 ), - options, packageName, - repository: repositoryResolver( packageName, options ) + mgitOptions, + commandPath: command.path, + arguments: args.slice( 1 ), + repository: repositoryResolver( packageName, mgitOptions ) }; forkPool.enqueue( data ) @@ -102,10 +99,11 @@ module.exports = function( args, options ) { packagesWithError.add( packageName ); } - logDisplay( packageName, returnedData.logs, { + displayLog( packageName, returnedData.logs, { current: donePackagesNumber, all: allPackagesNumber, - command: args[ 0 ] + skipCounter: command.skipCounter, + colorizeOutput: command.colorizeOutput } ); } @@ -123,8 +121,8 @@ module.exports = function( args, options ) { function onDone() { return forkPool.killAll() .then( () => { - if ( typeof command.afterExecute === 'function' ) { - command.afterExecute( processedPackages, commandResponses ); + if ( command.afterExecute ) { + command.afterExecute( processedPackages, commandResponses, mgitOptions ); } const endTime = process.hrtime( startTime ); diff --git a/lib/utils/child-process.js b/lib/utils/child-process.js index ab357d8..7b6d5c6 100644 --- a/lib/utils/child-process.js +++ b/lib/utils/child-process.js @@ -8,10 +8,7 @@ process.on( 'message', onMessage ); /** - * @param {Object} data - * @param {Object} data.parameters Additional arguments provided by the user. - * @param {String} data.packageName Name of current package to process. - * @param {Options} data.options The options object. + * @param {CommandData} data */ function onMessage( data ) { const log = require( './log' )(); @@ -22,7 +19,7 @@ function onMessage( data ) { return process.send( { logs: log.all() } ); } - const command = require( data.command ); + const command = require( data.commandPath ); command.execute( data ) .then( returnedData => { diff --git a/lib/utils/displaylog.js b/lib/utils/displaylog.js index be3c82d..83e9542 100644 --- a/lib/utils/displaylog.js +++ b/lib/utils/displaylog.js @@ -11,16 +11,14 @@ const chalk = require( 'chalk' ); * Formats the logs and writes them to the console. * * @param {String} packageName - * @param {Object} logs - * @param {Array} logs.info - * @param {Array} logs.errors + * @param {Logs} logs * @param {Object} options * @param {Number} options.current Number of packages that have been processed. * @param {Number} options.all Number of all packages that will be processed. - * @param {Number} options.command Name of executed command. + * @param {Boolean} [options.skipCounter=false] A flag that allows hiding the progress bar. */ module.exports = function displayLog( packageName, logs, options ) { - let infoLogs = logs.info.filter( l => l.length ).join( '\n' ).trim(); + const infoLogs = logs.info.filter( l => l.length ).join( '\n' ).trim(); const errorLogs = logs.error.filter( l => l.length ).join( '\n' ).trim(); const progressPercentage = Math.round( ( options.current / options.all ) * 100 ); @@ -29,12 +27,7 @@ module.exports = function displayLog( packageName, logs, options ) { console.log( chalk.inverse( getPackageHeader() ) ); if ( infoLogs ) { - // For the `diff` command we do not want to modify the output (do not change the colors). - if ( options.command !== 'diff' ) { - infoLogs = chalk.gray( infoLogs ); - } - - console.log( infoLogs ); + console.log( chalk.gray( infoLogs ) ); } if ( errorLogs ) { @@ -51,9 +44,7 @@ module.exports = function displayLog( packageName, logs, options ) { ' ' ]; - // For the `diff` command we do not want to show the progress (counter) - // because we will show the output only if the command returned the changes. - if ( options.command === 'diff' ) { + if ( options.skipCounter ) { headerParts.push( ' '.repeat( progressBar.length ) ); } else { headerParts.push( progressBar ); diff --git a/lib/utils/getcommandinstance.js b/lib/utils/getcommandinstance.js new file mode 100644 index 0000000..3f9642d --- /dev/null +++ b/lib/utils/getcommandinstance.js @@ -0,0 +1,89 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +'use strict'; + +const chalk = require( 'chalk' ); + +const COMMAND_ALIASES = { + ci: 'commit', + co: 'checkout', + st: 'status' +}; + +/** + * @param {String} commandName An alias or fully command name that should be used. + * @returns {Command|null} + */ +module.exports = function getCommandInstance( commandName ) { + try { + // Find full command name if used an alias or just use specified name. + const resolvedCommandName = ( COMMAND_ALIASES[ commandName ] || commandName ).replace( /-/g, '' ); + + const commandPath = require.resolve( '../commands/' + resolvedCommandName ); + const commandInstance = require( commandPath ); + + commandInstance.path = commandPath; + + return commandInstance; + } catch ( err ) { + const message = `Command "${ commandName }" does not exist. Type: "mgit --help" in order to see available commands.`; + + console.error( chalk.red( message ) ); + } + + process.exitCode = 1; + + return null; +}; + +/** + * @typedef {Object} Command + * + * @property {String} path An absolute path to the file that keeps the command. + * + * @property {String} helpMessage A message that explains how to use specified command. + * + * @property {Function} execute A function that is called on every repository that match to specified criteria. It receives an object + * as an argument that contains following properties + * + * @property {Boolean} [skipCounter=false] A flag that allows hiding the progress bar (number of package and number of all + * packages to process) on the screen. + * + * @property {String} [name] A name of the command. It's useful if specified command has defined an alias. + * + * @property {Function} [beforeExecute] A function that is called by mgit automatically before executing the main command's method. + * This function is called once. It receives two parameters: + * - an array of arguments typed by a user (including called command name). + * - an options object (`Options`) which contains options resolved by mgit. + * + * @property {Function} execute The main function of command. + * It receives single argument (`CommandData`) that represents an input provided by a user. + * It must returns an instance of `Promise`. The promise must resolve an object that can contains following properties: + * - `logs` - an object that matches to `Logs` object definition. + * - `response` - the entire `response` object is added to a collection that will be passed as second argument to `#afterExecute` + * function. + * - `packages` - an array of packages that mgit should process as well. + * + * @property {Function} [afterExecute] A function that is called by mgit automatically after executing the main command's method. + * This function is called once. It receives three parameters: + * - a collection (`Set`) that contains all processed packages by mgit. + * - a collection (`Set`) that contains responses returned by `#execute` function. + * - an options object (`Options`) which contains options resolved by mgit. + */ + +/** + * @typedef {Object} CommandData + * + * @property {String} packageName A name of package. + * + * @propery {Options} mgitOptions Options resolved by mgit. + * + * @property {String} commandPath An absolute path to the file that keeps the command. + * + * @property {Array.} arguments Arguments provided by the user via CLI. + * + * @property {Repository|null} repository An object that keeps data about repository for specified package. + */ diff --git a/lib/utils/getcwd.js b/lib/utils/getcwd.js index 0e5fb6b..d65ad36 100644 --- a/lib/utils/getcwd.js +++ b/lib/utils/getcwd.js @@ -9,7 +9,7 @@ const fs = require( 'fs' ); const path = require( 'upath' ); /** - * Returns an absolute path to the directory with configuration file. + * Returns an absolute path to the directory that contains a configuration file. * * It scans directory tree up for `mgit.json` file. If the file won't be found, * an exception should be thrown. diff --git a/lib/utils/getoptions.js b/lib/utils/getoptions.js index 4b379d8..31af1bd 100644 --- a/lib/utils/getoptions.js +++ b/lib/utils/getoptions.js @@ -9,7 +9,8 @@ const fs = require( 'fs' ); const path = require( 'upath' ); /** - * @param {Object} Call options. + * @param {Object} callOptions Call options. + * @param {String} cwd An absolute path to the directory where `mgit.json` is available. * @returns {Options} The options object. */ module.exports = function cwdResolver( callOptions, cwd ) { @@ -19,8 +20,7 @@ module.exports = function cwdResolver( callOptions, cwd ) { let options = { cwd, packages: 'packages', - recursive: false, - resolverPath: path.resolve( __dirname, '../default-resolver.js' ), + resolverPath: path.resolve( __dirname, '..', 'default-resolver.js' ), resolverUrlTemplate: 'git@github.com:${ path }.git', resolverTargetDirectory: 'git', resolverDefaultBranch: 'master', @@ -40,11 +40,9 @@ module.exports = function cwdResolver( callOptions, cwd ) { }; /** - * @typedef Options + * @typedef {Object} Options * - * @property {Boolean} [recursive=false] Whether to install dependencies recursively. - * Needs to be used together with --repository-include. Only packages - * matching these patterns will be cloned recursively. + * @property {String} cwd An absolute path to the directory which contains `mgit.json` file. * * @property {String} [packages='/packages/'] Directory to which all repositories will be cloned. * @@ -68,4 +66,11 @@ module.exports = function cwdResolver( callOptions, cwd ) { * @property {String|null} [ignore=null] Ignores packages with names matching the given glob. * * @property {String|null} [scope=null] Restricts the scope to package names matching the given glob. + * + * @property {Boolean|undefined} [recursive=undefined] Whether to install dependencies recursively. + * + * @property {Boolean|String|undefined} [branch=undefined] If a bool: whether to use branch names as an input data. + * If a string: name of branch that should be created. + * + * @property {Boolean|undefined} [hash=undefined] Whether to use current commit hashes as an input data. */ diff --git a/lib/utils/gitstatusparser.js b/lib/utils/gitstatusparser.js index cbcea13..cedec8a 100644 --- a/lib/utils/gitstatusparser.js +++ b/lib/utils/gitstatusparser.js @@ -27,19 +27,30 @@ const UNTRACKED_SYMBOL = '??'; /** * @param {String} response An output returned by `git status -sb` command. * @returns {Object} data + * @returns {Boolean} data.anythingToCommit Returns true if any changed file could be committed using command `git commit -a`. + * @returns {String} data.branch Current branch. + * @returns {Number|null} data.behind Number of commits that branch is behind the remote upstream. + * @returns {Number|null} data.ahead Number of commits that branch is ahead the remote upstream. + * @returns {Array.} data.added List of files created files (untracked files are tracked now). + * @returns {Array.} data.modified List of tracked files that have changed. + * @returns {Array.} data.deleted List of tracked files that have deleted. + * @returns {Array.} data.renamed List of tracked files that have moved (or renamed). + * @returns {Array.} data.unmerged List of tracked files that contain (unresolved) conflicts. + * @returns {Array.} data.untracked List of untracked files which won't be committed using command `git commit -a`. + * @returns {Array.} data.staged List of files that their changes are ready to commit. */ module.exports = function gitStatusParser( response ) { const responseAsArray = response.split( '\n' ); const branchData = responseAsArray.shift(); const branch = branchData.split( '...' )[ 0 ].match( /## (.*)$/ )[ 1 ]; - const added = findFiles( [ ADDED_STAGED_SYMBOL ] ); - const modified = findFiles( [ MODIFIED_NOT_STAGED_SYMBOL, MODIFIED_STAGED_AND_NOT_STAGED_SYMBOL, DELETE_NOT_STAGED_SYMBOL ] ); - const deleted = findFiles( [ DELETE_STAGED_SYMBOL, DELETE_NOT_STAGED_SYMBOL ] ); - const renamed = findFiles( [ RENAMED_STAGED_SYMBOL ] ); - const unmerged = findFiles( UNMERGED_SYMBOLS ); - const untracked = findFiles( [ UNTRACKED_SYMBOL ] ); - const staged = findFiles( [ + const added = filterFiles( [ ADDED_STAGED_SYMBOL ] ); + const modified = filterFiles( [ MODIFIED_NOT_STAGED_SYMBOL, MODIFIED_STAGED_AND_NOT_STAGED_SYMBOL, DELETE_NOT_STAGED_SYMBOL ] ); + const deleted = filterFiles( [ DELETE_STAGED_SYMBOL, DELETE_NOT_STAGED_SYMBOL ] ); + const renamed = filterFiles( [ RENAMED_STAGED_SYMBOL ] ); + const unmerged = filterFiles( UNMERGED_SYMBOLS ); + const untracked = filterFiles( [ UNTRACKED_SYMBOL ] ); + const staged = filterFiles( [ ADDED_STAGED_SYMBOL, DELETE_STAGED_SYMBOL, MODIFIED_STAGED_SYMBOL, @@ -59,6 +70,10 @@ module.exports = function gitStatusParser( response ) { } return { + get anythingToCommit() { + return [ added, modified, deleted, renamed, unmerged, staged ].some( collection => collection.length ); + }, + branch, behind, ahead, @@ -71,7 +86,7 @@ module.exports = function gitStatusParser( response ) { staged }; - function findFiles( prefixes ) { + function filterFiles( prefixes ) { return responseAsArray .filter( line => prefixes.some( prefix => prefix === line.substring( 0, 2 ) ) ) .map( line => line.slice( 2 ).trim() ); diff --git a/lib/utils/log.js b/lib/utils/log.js index 2c98df0..3d91063 100644 --- a/lib/utils/log.js +++ b/lib/utils/log.js @@ -31,6 +31,7 @@ module.exports = function log() { msg = msg.trim(); + /* istanbul ignore if */ if ( !msg ) { return; } @@ -44,6 +45,9 @@ module.exports = function log() { responseLogs.error.forEach( err => logger.error( err ) ); }, + /** + * @returns {Logs} + */ all() { return { error: logs.get( 'error' ), @@ -54,3 +58,19 @@ module.exports = function log() { return logger; }; + +/** + * @typedef {Object} Logger + * + * @property {Function} info A function that informs about process. + * + * @property {Function} error A function that informs about errors. + */ + +/** + * @typedef {Object} Logs + * + * @propery {Array.} error An error messages. + * + * @propery {Array.} info An information messages. + */ diff --git a/lib/utils/exec.js b/lib/utils/shell.js similarity index 100% rename from lib/utils/exec.js rename to lib/utils/shell.js diff --git a/package.json b/package.json index 58fd388..82e58fb 100644 --- a/package.json +++ b/package.json @@ -17,21 +17,23 @@ "generic-pool": "^3.4.2", "meow": "^5.0.0", "minimatch": "^3.0.4", + "minimist": "^1.2.0", + "minimist-options": "^3.0.2", "shelljs": "^0.8.2", - "upath": "^1.0.5" + "upath": "^1.1.0" }, "devDependencies": { - "@ckeditor/ckeditor5-dev-env": "^9.0.1", + "@ckeditor/ckeditor5-dev-env": "^11.1.1", "@ckeditor/ckeditor5-dev-lint": "^3.1.4", "chai": "^4.1.2", - "eslint": "^4.19.1", + "eslint": "^5.3.0", "eslint-config-ckeditor5": "^1.0.8", "husky": "^0.14.3", "istanbul": "^0.4.5", - "lint-staged": "^7.1.0", - "mocha": "^5.1.1", + "lint-staged": "^7.2.0", + "mocha": "^5.2.0", "mockery": "^2.1.0", - "sinon": "^5.0.7" + "sinon": "^6.1.4" }, "repository": { "type": "git", diff --git a/tests/commands/bootstrap.js b/tests/commands/bootstrap.js deleted file mode 100644 index b2bce76..0000000 --- a/tests/commands/bootstrap.js +++ /dev/null @@ -1,247 +0,0 @@ -/** - * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. - * For licensing, see LICENSE.md. - */ - -/* jshint mocha:true */ - -'use strict'; - -const fs = require( 'fs' ); -const path = require( 'upath' ); -const sinon = require( 'sinon' ); -const mockery = require( 'mockery' ); -const expect = require( 'chai' ).expect; - -describe( 'commands/bootstrap', () => { - let bootstrapCommand, stubs, data; - - beforeEach( () => { - mockery.enable( { - useCleanCache: true, - warnOnReplace: false, - warnOnUnregistered: false - } ); - - stubs = { - exec: sinon.stub(), - fs: { - existsSync: sinon.stub( fs, 'existsSync' ) - }, - path: { - join: sinon.stub( path, 'join' ).callsFake( ( ...chunks ) => chunks.join( '/' ) ) - } - }; - - data = { - packageName: 'test-package', - options: { - cwd: __dirname, - packages: 'packages' - }, - repository: { - directory: 'test-package', - url: 'git@github.com/organization/test-package.git', - branch: 'master' - } - }; - - mockery.registerMock( '../utils/exec', stubs.exec ); - - bootstrapCommand = require( '../../lib/commands/bootstrap' ); - } ); - - afterEach( () => { - sinon.restore(); - mockery.disable(); - } ); - - describe( 'execute()', () => { - it( 'rejects promise if something went wrong', () => { - const error = new Error( 'Unexpected error.' ); - - stubs.fs.existsSync.returns( false ); - stubs.exec.returns( Promise.reject( error ) ); - - return bootstrapCommand.execute( data ) - .then( - () => { - throw new Error( 'Supposed to be rejected.' ); - }, - response => { - expect( response.logs.error[ 0 ].split( '\n' )[ 0 ] ).to.equal( `Error: ${ error.message }` ); - } - ); - } ); - - it( 'clones a repository if is not available', () => { - stubs.fs.existsSync.returns( false ); - stubs.exec.returns( Promise.resolve( 'Git clone log.' ) ); - - return bootstrapCommand.execute( data ) - .then( response => { - expect( stubs.exec.calledOnce ).to.equal( true ); - - const cloneCommand = stubs.exec.firstCall.args[ 0 ].split( ' && ' ); - - // Clone the repository. - expect( cloneCommand[ 0 ] ) - .to.equal( 'git clone --progress "git@github.com/organization/test-package.git" "packages/test-package"' ); - // Change the directory to cloned package. - expect( cloneCommand[ 1 ] ).to.equal( 'cd "packages/test-package"' ); - // And check out to proper branch. - expect( cloneCommand[ 2 ] ).to.equal( 'git checkout --quiet master' ); - - expect( response.logs.info[ 0 ] ).to.equal( 'Git clone log.' ); - } ); - } ); - - it( 'does not clone a repository if is available', () => { - stubs.fs.existsSync.returns( true ); - - return bootstrapCommand.execute( data ) - .then( response => { - expect( stubs.exec.called ).to.equal( false ); - - expect( response.logs.info[ 0 ] ).to.equal( 'Package "test-package" is already cloned.' ); - } ); - } ); - - it( 'installs dependencies of cloned package', () => { - data.options.recursive = true; - data.options.packages = __dirname + '/../fixtures'; - data.repository.directory = 'project-a'; - - stubs.fs.existsSync.returns( true ); - - return bootstrapCommand.execute( data ) - .then( response => { - expect( response.packages ).is.an( 'array' ); - expect( response.packages ).to.deep.equal( [ 'test-foo' ] ); - } ); - } ); - - it( 'installs devDependencies of cloned package', () => { - data.options.recursive = true; - data.options.packages = __dirname + '/../fixtures'; - data.repository.directory = 'project-with-options-in-mgitjson'; - - stubs.fs.existsSync.returns( true ); - - return bootstrapCommand.execute( data ) - .then( response => { - expect( response.packages ).is.an( 'array' ); - expect( response.packages ).to.deep.equal( [ 'test-bar' ] ); - } ); - } ); - - it( 'tries to install missing packages once again if git ends with unexpected error (#1)', function() { - this.timeout( 5500 ); - - stubs.fs.existsSync.returns( false ); - - stubs.exec.onFirstCall().returns( Promise.reject( [ - 'exec: Cloning into \'/some/path\'...', - 'remote: Enumerating objects: 6, done.', - 'remote: Counting objects: 100% (6/6), done.', - 'remote: Compressing objects: 100% (6/6), done.', - 'packet_write_wait: Connection to 000.00.000.000 port 22: Broken pipe', - 'fatal: The remote end hung up unexpectedly', - 'fatal: early EOF', - 'fatal: index-pack failed' - ].join( '\n' ) ) ); - - stubs.exec.onSecondCall().returns( Promise.resolve( 'Git clone log.' ) ); - - return bootstrapCommand.execute( data ) - .then( response => { - expect( stubs.exec.calledTwice ).to.equal( true ); - - const firstCommand = stubs.exec.firstCall.args[ 0 ].split( ' && ' ); - - // Clone the repository for the first time. It failed. - expect( firstCommand[ 0 ] ) - .to.equal( 'git clone --progress "git@github.com/organization/test-package.git" "packages/test-package"' ); - // Change the directory to cloned package. - expect( firstCommand[ 1 ] ).to.equal( 'cd "packages/test-package"' ); - // And check out to proper branch. - expect( firstCommand[ 2 ] ).to.equal( 'git checkout --quiet master' ); - - const secondCommand = stubs.exec.secondCall.args[ 0 ].split( ' && ' ); - - // Clone the repository for the second time. It succeed. - expect( secondCommand[ 0 ] ) - .to.equal( 'git clone --progress "git@github.com/organization/test-package.git" "packages/test-package"' ); - // Change the directory to cloned package. - expect( secondCommand[ 1 ] ).to.equal( 'cd "packages/test-package"' ); - // And check out to proper branch. - expect( secondCommand[ 2 ] ).to.equal( 'git checkout --quiet master' ); - - expect( response.logs.info[ 0 ] ).to.equal( 'Git clone log.' ); - } ); - } ); - - it( 'tries to install missing packages once again if git ends with unexpected error (#2)', function() { - this.timeout( 5500 ); - - stubs.fs.existsSync.returns( false ); - - stubs.exec.onFirstCall().returns( Promise.reject( [ - 'exec: Cloning into \'/some/path\'...', - 'remote: Enumerating objects: 6, done.', - 'remote: Counting objects: 100% (6/6), done.', - 'remote: Compressing objects: 100% (6/6), done.', - 'packet_write_wait: Connection to 000.00.000.000 port 22: Broken pipe', - 'fatal: the remote end hung up unexpectedly', - 'fatal: early EOF', - 'fatal: index-pack failed' - ].join( '\n' ) ) ); - - stubs.exec.onSecondCall().returns( Promise.resolve( 'Git clone log.' ) ); - - return bootstrapCommand.execute( data ) - .then( response => { - expect( stubs.exec.calledTwice ).to.equal( true ); - - const firstCommand = stubs.exec.firstCall.args[ 0 ].split( ' && ' ); - - // Clone the repository for the first time. It failed. - expect( firstCommand[ 0 ] ) - .to.equal( 'git clone --progress "git@github.com/organization/test-package.git" "packages/test-package"' ); - // Change the directory to cloned package. - expect( firstCommand[ 1 ] ).to.equal( 'cd "packages/test-package"' ); - // And check out to proper branch. - expect( firstCommand[ 2 ] ).to.equal( 'git checkout --quiet master' ); - - const secondCommand = stubs.exec.secondCall.args[ 0 ].split( ' && ' ); - - // Clone the repository for the second time. It succeed. - expect( secondCommand[ 0 ] ) - .to.equal( 'git clone --progress "git@github.com/organization/test-package.git" "packages/test-package"' ); - // Change the directory to cloned package. - expect( secondCommand[ 1 ] ).to.equal( 'cd "packages/test-package"' ); - // And check out to proper branch. - expect( secondCommand[ 2 ] ).to.equal( 'git checkout --quiet master' ); - - expect( response.logs.info[ 0 ] ).to.equal( 'Git clone log.' ); - } ); - } ); - } ); - - describe( 'afterExecute()', () => { - it( 'informs about number of processed packages', () => { - const consoleLog = sinon.stub( console, 'log' ); - - const processedPackages = new Set(); - processedPackages.add( 'package-1' ); - processedPackages.add( 'package-2' ); - - bootstrapCommand.afterExecute( processedPackages ); - - expect( consoleLog.calledOnce ).to.equal( true ); - expect( consoleLog.firstCall.args[ 0 ] ).to.match( /2 packages have been processed\./ ); - - consoleLog.restore(); - } ); - } ); -} ); diff --git a/tests/commands/checkout.js b/tests/commands/checkout.js index 09ed10e..3b80f23 100644 --- a/tests/commands/checkout.js +++ b/tests/commands/checkout.js @@ -12,7 +12,7 @@ const mockery = require( 'mockery' ); const expect = require( 'chai' ).expect; describe( 'commands/checkout', () => { - let checkoutCommand, stubs, data; + let checkoutCommand, stubs, commandData, mgitOptions; beforeEach( () => { mockery.enable( { @@ -24,25 +24,44 @@ describe( 'commands/checkout', () => { stubs = { execCommand: { execute: sinon.stub() - } + }, + gitStatusParser: sinon.stub() }; - data = { + mgitOptions = {}; + + commandData = { + arguments: [], repository: { branch: 'master' - } + }, + mgitOptions }; mockery.registerMock( './exec', stubs.execCommand ); + mockery.registerMock( '../utils/gitstatusparser', stubs.gitStatusParser ); checkoutCommand = require( '../../lib/commands/checkout' ); } ); afterEach( () => { sinon.restore(); + mockery.deregisterAll(); mockery.disable(); } ); + describe( '#helpMessage', () => { + it( 'defines help screen', () => { + expect( checkoutCommand.helpMessage ).is.a( 'string' ); + } ); + } ); + + describe( '#name', () => { + it( 'returns a full name of executed command', () => { + expect( checkoutCommand.name ).is.a( 'string' ); + } ); + } ); + describe( 'execute()', () => { it( 'rejects promise if called command returned an error', () => { const error = new Error( 'Unexpected error.' ); @@ -53,7 +72,7 @@ describe( 'commands/checkout', () => { } } ); - return checkoutCommand.execute( data ) + return checkoutCommand.execute( commandData ) .then( () => { throw new Error( 'Supposed to be rejected.' ); @@ -67,23 +86,136 @@ describe( 'commands/checkout', () => { it( 'checkouts to the correct branch', () => { stubs.execCommand.execute.resolves( { logs: { - info: [ 'Already on \'master\'\nYour branch is up-to-date with \'origin/master\'.' ] + info: [ + 'Already on \'master\'', + 'Already on \'master\'\nYour branch is up-to-date with \'origin/master\'.' + ] + } + } ); + + return checkoutCommand.execute( commandData ) + .then( commandResponse => { + expect( stubs.execCommand.execute.calledOnce ).to.equal( true ); + expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git checkout master' ], + mgitOptions + } ); + + expect( commandResponse.logs.info ).to.deep.equal( [ + 'Already on \'master\'', + 'Already on \'master\'\nYour branch is up-to-date with \'origin/master\'.' + ] ); + } ); + } ); + + it( 'checkouts to specified branch', () => { + commandData.arguments.push( 'develop' ); + + stubs.execCommand.execute.resolves( { + logs: { + info: [ + 'Switched to branch \'develop\'', + 'Your branch is up to date with \'origin/develop\'.' + ] + } + } ); + + return checkoutCommand.execute( commandData ) + .then( commandResponse => { + expect( stubs.execCommand.execute.calledOnce ).to.equal( true ); + expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git checkout develop' ], + mgitOptions + } ); + + expect( commandResponse.logs.info ).to.deep.equal( [ + 'Switched to branch \'develop\'', + 'Your branch is up to date with \'origin/develop\'.' + ] ); + } ); + } ); + + it( 'creates a new branch if a repository has changes that could be committed and specified --branch option', () => { + mgitOptions.branch = 'develop'; + + stubs.execCommand.execute.onFirstCall().resolves( { + logs: { + info: [ + 'Response returned by "git status" command.' + ] + } + } ); + + stubs.execCommand.execute.onSecondCall().resolves( { + logs: { + info: [ + 'Switched to a new branch \'develop\'' + ] + } + } ); + + stubs.gitStatusParser.returns( { anythingToCommit: true } ); + + return checkoutCommand.execute( commandData ) + .then( commandResponse => { + expect( stubs.execCommand.execute.calledTwice ).to.equal( true ); + + expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git status --branch --porcelain' ], + mgitOptions + } ); + + expect( stubs.execCommand.execute.secondCall.args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git checkout -b develop' ], + mgitOptions + } ); + + expect( commandResponse.logs.info ).to.deep.equal( [ + 'Switched to a new branch \'develop\'' + ] ); + } ); + } ); + + it( 'does not create a branch if a repository has no-changes that could be committed when specified --branch option', () => { + mgitOptions.branch = 'develop'; + + stubs.execCommand.execute.onFirstCall().resolves( { + logs: { + info: [ + 'Response returned by "git status" command.' + ] } } ); - return checkoutCommand.execute( data ) + stubs.gitStatusParser.returns( { anythingToCommit: false } ); + + return checkoutCommand.execute( commandData ) .then( commandResponse => { expect( stubs.execCommand.execute.calledOnce ).to.equal( true ); + expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { repository: { branch: 'master' }, - arguments: [ 'git checkout master' ] + arguments: [ 'git status --branch --porcelain' ], + mgitOptions } ); - expect( commandResponse.logs.info[ 0 ] ).to.equal( - 'Your branch is up-to-date with \'origin/master\'.' - ); + expect( commandResponse.logs.info ).to.deep.equal( [ + 'Repository does not contain changes to commit. New branch was not created.' + ] ); } ); } ); } ); diff --git a/tests/commands/close.js b/tests/commands/close.js new file mode 100644 index 0000000..0d4b56a --- /dev/null +++ b/tests/commands/close.js @@ -0,0 +1,403 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* jshint mocha:true */ + +'use strict'; + +const sinon = require( 'sinon' ); +const mockery = require( 'mockery' ); +const expect = require( 'chai' ).expect; + +describe( 'commands/close', () => { + let closeCommand, stubs, commandData, mgitOptions; + + beforeEach( () => { + mockery.enable( { + useCleanCache: true, + warnOnReplace: false, + warnOnUnregistered: false + } ); + + stubs = { + execCommand: { + execute: sinon.stub() + } + }; + + mgitOptions = {}; + + commandData = { + arguments: [], + repository: { + branch: 'master' + }, + mgitOptions + }; + + mockery.registerMock( './exec', stubs.execCommand ); + + closeCommand = require( '../../lib/commands/close' ); + } ); + + afterEach( () => { + sinon.restore(); + mockery.deregisterAll(); + mockery.disable(); + } ); + + describe( '#helpMessage', () => { + it( 'defines help screen', () => { + expect( closeCommand.helpMessage ).is.a( 'string' ); + } ); + } ); + + describe( 'beforeExecute()', () => { + it( 'throws an error if command to execute is not specified', () => { + expect( () => { + closeCommand.beforeExecute( [ 'merge' ] ); + } ).to.throw( Error, 'Missing branch to merge. Use: mgit close [branch].' ); + } ); + + it( 'does nothing if branch to merge is specified', () => { + expect( () => { + closeCommand.beforeExecute( [ 'merge', 'develop' ] ); + } ).to.not.throw( Error ); + } ); + } ); + + describe( 'execute()', () => { + it( 'rejects promise if called command returned an error', () => { + const error = new Error( 'Unexpected error.' ); + + stubs.execCommand.execute.rejects( { + logs: { + error: [ error.stack ] + } + } ); + + return closeCommand.execute( commandData ) + .then( + () => { + throw new Error( 'Supposed to be rejected.' ); + }, + response => { + expect( response.logs.error[ 0 ].split( '\n' )[ 0 ] ).to.equal( `Error: ${ error.message }` ); + } + ); + } ); + + it( 'merges specified branch and remove it from local and remote', () => { + commandData.arguments.push( 'develop' ); + + stubs.execCommand.execute.onCall( 0 ).resolves( { + logs: { + info: [ + '* develop' + ], + error: [] + } + } ); + + stubs.execCommand.execute.onCall( 1 ).resolves( { + logs: { + info: [ + 'Merge made by the \'recursive\' strategy.' + ], + error: [] + } + } ); + + stubs.execCommand.execute.onCall( 2 ).resolves( { + logs: { + info: [ + 'Deleted branch develop (was e6bda2e9).' + ], + error: [] + } + } ); + + stubs.execCommand.execute.onCall( 3 ).resolves( { + logs: { + info: [ + 'To github.com:foo/bar.git\n' + + ' - [deleted] develop' + ], + error: [] + } + } ); + + return closeCommand.execute( commandData ) + .then( commandResponse => { + expect( stubs.execCommand.execute.callCount ).to.equal( 4 ); + + expect( stubs.execCommand.execute.getCall( 0 ).args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git branch --list develop' ], + mgitOptions + } ); + + expect( stubs.execCommand.execute.getCall( 1 ).args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git merge develop --no-ff -m "Merge branch \'develop\'"' ], + mgitOptions + } ); + + expect( stubs.execCommand.execute.getCall( 2 ).args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git branch -d develop' ], + mgitOptions + } ); + + expect( stubs.execCommand.execute.getCall( 3 ).args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git push origin :develop' ], + mgitOptions + } ); + + expect( commandResponse.logs.info ).to.deep.equal( [ + 'Merge made by the \'recursive\' strategy.', + + 'Removing "develop" branch from the local registry.', + + 'Deleted branch develop (was e6bda2e9).', + + 'Removing "develop" branch from the remote.', + + 'To github.com:foo/bar.git\n' + + ' - [deleted] develop' + ] ); + } ); + } ); + + // mgit close develop -- --message "Test." + it( 'merges specified branch using specified message', () => { + commandData.arguments.push( 'develop' ); + commandData.arguments.push( '--message' ); + commandData.arguments.push( 'Test.' ); + + stubs.execCommand.execute.onCall( 0 ).resolves( { + logs: { + info: [ + '* develop' + ], + error: [] + } + } ); + + stubs.execCommand.execute.onCall( 1 ).resolves( { + logs: { + info: [ + 'Merge made by the \'recursive\' strategy.' + ], + error: [] + } + } ); + + stubs.execCommand.execute.onCall( 2 ).resolves( { + logs: { + info: [ + 'Deleted branch develop (was e6bda2e9).' + ], + error: [] + } + } ); + + stubs.execCommand.execute.onCall( 3 ).resolves( { + logs: { + info: [ + 'To github.com:foo/bar.git\n' + + ' - [deleted] develop' + ], + error: [] + } + } ); + + return closeCommand.execute( commandData ) + .then( commandResponse => { + expect( stubs.execCommand.execute.callCount ).to.equal( 4 ); + + expect( stubs.execCommand.execute.getCall( 0 ).args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git branch --list develop' ], + mgitOptions + } ); + + expect( stubs.execCommand.execute.getCall( 1 ).args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git merge develop --no-ff -m "Merge branch \'develop\'" -m "Test."' ], + mgitOptions + } ); + + expect( stubs.execCommand.execute.getCall( 2 ).args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git branch -d develop' ], + mgitOptions + } ); + + expect( stubs.execCommand.execute.getCall( 3 ).args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git push origin :develop' ], + mgitOptions + } ); + + expect( commandResponse.logs.info ).to.deep.equal( [ + 'Merge made by the \'recursive\' strategy.', + + 'Removing "develop" branch from the local registry.', + + 'Deleted branch develop (was e6bda2e9).', + + 'Removing "develop" branch from the remote.', + + 'To github.com:foo/bar.git\n' + + ' - [deleted] develop' + ] ); + } ); + } ); + + // mgit close develop --message "Test." + it( 'merges specified branch using specified message when specified as a param of mgit', () => { + commandData.arguments.push( 'develop' ); + + mgitOptions.message = 'Test.'; + + stubs.execCommand.execute.onCall( 0 ).resolves( { + logs: { + info: [ + '* develop' + ], + error: [] + } + } ); + + stubs.execCommand.execute.onCall( 1 ).resolves( { + logs: { + info: [ + 'Merge made by the \'recursive\' strategy.' + ], + error: [] + } + } ); + + stubs.execCommand.execute.onCall( 2 ).resolves( { + logs: { + info: [ + 'Deleted branch develop (was e6bda2e9).' + ], + error: [] + } + } ); + + stubs.execCommand.execute.onCall( 3 ).resolves( { + logs: { + info: [ + 'To github.com:foo/bar.git\n' + + ' - [deleted] develop' + ], + error: [] + } + } ); + + return closeCommand.execute( commandData ) + .then( commandResponse => { + expect( stubs.execCommand.execute.callCount ).to.equal( 4 ); + + expect( stubs.execCommand.execute.getCall( 0 ).args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git branch --list develop' ], + mgitOptions + } ); + + expect( stubs.execCommand.execute.getCall( 1 ).args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git merge develop --no-ff -m "Merge branch \'develop\'" -m "Test."' ], + mgitOptions + } ); + + expect( stubs.execCommand.execute.getCall( 2 ).args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git branch -d develop' ], + mgitOptions + } ); + + expect( stubs.execCommand.execute.getCall( 3 ).args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git push origin :develop' ], + mgitOptions + } ); + + expect( commandResponse.logs.info ).to.deep.equal( [ + 'Merge made by the \'recursive\' strategy.', + + 'Removing "develop" branch from the local registry.', + + 'Deleted branch develop (was e6bda2e9).', + + 'Removing "develop" branch from the remote.', + + 'To github.com:foo/bar.git\n' + + ' - [deleted] develop' + ] ); + } ); + } ); + + it( 'does not merge branch if it does not exist', () => { + commandData.arguments.push( 'develop' ); + commandData.arguments.push( '--message' ); + commandData.arguments.push( 'Test.' ); + + stubs.execCommand.execute.onFirstCall().resolves( { + logs: { + info: [ + '' + ], + error: [] + } + } ); + + return closeCommand.execute( commandData ) + .then( commandResponse => { + expect( stubs.execCommand.execute.calledOnce ).to.equal( true ); + + expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git branch --list develop' ], + mgitOptions + } ); + + expect( commandResponse.logs.info ).to.deep.equal( [ + 'Branch does not exist.' + ] ); + } ); + } ); + } ); +} ); diff --git a/tests/commands/commit.js b/tests/commands/commit.js new file mode 100644 index 0000000..993875e --- /dev/null +++ b/tests/commands/commit.js @@ -0,0 +1,384 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* jshint mocha:true */ + +'use strict'; + +const sinon = require( 'sinon' ); +const mockery = require( 'mockery' ); +const expect = require( 'chai' ).expect; + +describe( 'commands/commit', () => { + let commitCommand, stubs, commandData, mgitOptions; + + beforeEach( () => { + mockery.enable( { + useCleanCache: true, + warnOnReplace: false, + warnOnUnregistered: false + } ); + + stubs = { + execCommand: { + execute: sinon.stub() + }, + gitStatusParser: sinon.stub() + }; + + mgitOptions = {}; + + commandData = { + arguments: [], + repository: { + branch: 'master' + }, + mgitOptions + }; + + mockery.registerMock( './exec', stubs.execCommand ); + mockery.registerMock( '../utils/gitstatusparser', stubs.gitStatusParser ); + + commitCommand = require( '../../lib/commands/commit' ); + } ); + + afterEach( () => { + sinon.restore(); + mockery.deregisterAll(); + mockery.disable(); + } ); + + describe( '#helpMessage', () => { + it( 'defines help screen', () => { + expect( commitCommand.helpMessage ).is.a( 'string' ); + } ); + } ); + + describe( '#name', () => { + it( 'returns a full name of executed command', () => { + expect( commitCommand.name ).is.a( 'string' ); + } ); + } ); + + describe( 'beforeExecute()', () => { + it( 'throws an error if merge message is missing', () => { + sinon.stub( commitCommand, '_parseArguments' ).returns( {} ); + + expect( () => { + commitCommand.beforeExecute( [ 'commit' ], {} ); + } ).to.throw( Error, 'Missing --message (-m) option. Call "mgit commit -h" in order to read more.' ); + } ); + + it( 'does nothing if specified message for commit (as git option)', () => { + expect( () => { + commitCommand.beforeExecute( [ 'commit', '--message', 'Test' ], {} ); + } ).to.not.throw( Error ); + } ); + + it( 'does nothing if specified message for commit (as mgit option)', () => { + expect( () => { + commitCommand.beforeExecute( [ 'commit' ], { message: 'Test.' } ); + } ).to.not.throw( Error ); + } ); + } ); + + describe( 'execute()', () => { + it( 'rejects promise if called command returned an error', () => { + const error = new Error( 'Unexpected error.' ); + + stubs.execCommand.execute.rejects( { + logs: { + error: [ error.stack ] + } + } ); + + return commitCommand.execute( commandData ) + .then( + () => { + throw new Error( 'Supposed to be rejected.' ); + }, + response => { + expect( response.logs.error[ 0 ].split( '\n' )[ 0 ] ).to.equal( `Error: ${ error.message }` ); + } + ); + } ); + + it( 'commits all changes', () => { + mgitOptions.message = 'Test.'; + + stubs.execCommand.execute.onFirstCall().resolves( { + logs: { + info: [ + 'Response returned by "git status" command.' + ] + } + } ); + + stubs.execCommand.execute.onSecondCall().resolves( { + logs: { + info: [ + '[master a89f9ee] Test.' + ] + } + } ); + + stubs.gitStatusParser.returns( { anythingToCommit: true } ); + + return commitCommand.execute( commandData ) + .then( commandResponse => { + expect( stubs.execCommand.execute.calledTwice ).to.equal( true ); + + expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git status --branch --porcelain' ], + mgitOptions + } ); + + expect( stubs.execCommand.execute.secondCall.args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git commit -a -m "Test."' ], + mgitOptions + } ); + + expect( commandResponse.logs.info ).to.deep.equal( [ + '[master a89f9ee] Test.' + ] ); + } ); + } ); + + it( 'commits all changes (message was specified as a git option)', () => { + commandData.arguments.push( '--message' ); + commandData.arguments.push( 'Test.' ); + + stubs.execCommand.execute.onFirstCall().resolves( { + logs: { + info: [ + 'Response returned by "git status" command.' + ] + } + } ); + + stubs.execCommand.execute.onSecondCall().resolves( { + logs: { + info: [ + '[master a89f9ee] Test.' + ] + } + } ); + + stubs.gitStatusParser.returns( { anythingToCommit: true } ); + + return commitCommand.execute( commandData ) + .then( commandResponse => { + expect( stubs.execCommand.execute.calledTwice ).to.equal( true ); + + expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git status --branch --porcelain' ], + mgitOptions + } ); + + expect( stubs.execCommand.execute.secondCall.args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git commit -a -m "Test."' ], + mgitOptions + } ); + + expect( commandResponse.logs.info ).to.deep.equal( [ + '[master a89f9ee] Test.' + ] ); + } ); + } ); + + it( 'accepts `--no-verify` option', () => { + commandData.arguments.push( '-n' ); + commandData.arguments.push( '--message' ); + commandData.arguments.push( 'Test' ); + + stubs.execCommand.execute.onFirstCall().resolves( { + logs: { + info: [ + 'Response returned by "git status" command.' + ] + } + } ); + + stubs.execCommand.execute.onSecondCall().resolves( { + logs: { + info: [ + '[master a89f9ee] Test' + ] + } + } ); + + stubs.gitStatusParser.returns( { anythingToCommit: true } ); + + return commitCommand.execute( commandData ) + .then( commandResponse => { + expect( stubs.execCommand.execute.calledTwice ).to.equal( true ); + + expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git status --branch --porcelain' ], + mgitOptions + } ); + + expect( stubs.execCommand.execute.secondCall.args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git commit -a -m "Test" -n' ], + mgitOptions + } ); + + expect( commandResponse.logs.info ).to.deep.equal( [ + '[master a89f9ee] Test' + ] ); + } ); + } ); + + it( 'accepts duplicated `--message` option', () => { + mgitOptions.message = [ + 'Test.', + 'Foo.' + ]; + + stubs.execCommand.execute.onFirstCall().resolves( { + logs: { + info: [ + 'Response returned by "git status" command.' + ] + } + } ); + + stubs.execCommand.execute.onSecondCall().resolves( { + logs: { + info: [ + '[master a89f9ee] Test.' + ] + } + } ); + + stubs.gitStatusParser.returns( { anythingToCommit: true } ); + + return commitCommand.execute( commandData ) + .then( commandResponse => { + expect( stubs.execCommand.execute.calledTwice ).to.equal( true ); + + expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git status --branch --porcelain' ], + mgitOptions + } ); + + expect( stubs.execCommand.execute.secondCall.args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git commit -a -m "Test." -m "Foo."' ], + mgitOptions + } ); + + expect( commandResponse.logs.info ).to.deep.equal( [ + '[master a89f9ee] Test.' + ] ); + } ); + } ); + + it( 'accepts duplicated `--message` option (messages were specified as a git option)', () => { + commandData.arguments.push( '--message' ); + commandData.arguments.push( 'Test.' ); + commandData.arguments.push( '-m' ); + commandData.arguments.push( 'Foo.' ); + + stubs.execCommand.execute.onFirstCall().resolves( { + logs: { + info: [ + 'Response returned by "git status" command.' + ] + } + } ); + + stubs.execCommand.execute.onSecondCall().resolves( { + logs: { + info: [ + '[master a89f9ee] Test.' + ] + } + } ); + + stubs.gitStatusParser.returns( { anythingToCommit: true } ); + + return commitCommand.execute( commandData ) + .then( commandResponse => { + expect( stubs.execCommand.execute.calledTwice ).to.equal( true ); + + expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git status --branch --porcelain' ], + mgitOptions + } ); + + expect( stubs.execCommand.execute.secondCall.args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git commit -a -m "Test." -m "Foo."' ], + mgitOptions + } ); + + expect( commandResponse.logs.info ).to.deep.equal( [ + '[master a89f9ee] Test.' + ] ); + } ); + } ); + + it( 'does not commit if there is no changes', () => { + commandData.arguments.push( '--message' ); + commandData.arguments.push( 'Test.' ); + + stubs.execCommand.execute.onFirstCall().resolves( { + logs: { + info: [ + 'Response returned by "git status" command.' + ] + } + } ); + + stubs.gitStatusParser.returns( { anythingToCommit: false } ); + + return commitCommand.execute( commandData ) + .then( commandResponse => { + expect( stubs.execCommand.execute.calledOnce ).to.equal( true ); + + expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { + repository: { + branch: 'master' + }, + arguments: [ 'git status --branch --porcelain' ], + mgitOptions + } ); + + expect( commandResponse.logs.info ).to.deep.equal( [ + 'Nothing to commit.' + ] ); + } ); + } ); + } ); +} ); diff --git a/tests/commands/diff.js b/tests/commands/diff.js index 3003fec..d20c6f5 100644 --- a/tests/commands/diff.js +++ b/tests/commands/diff.js @@ -12,7 +12,7 @@ const mockery = require( 'mockery' ); const expect = require( 'chai' ).expect; describe( 'commands/diff', () => { - let diffCommand, stubs, data; + let diffCommand, stubs, commandData; beforeEach( () => { mockery.enable( { @@ -27,7 +27,7 @@ describe( 'commands/diff', () => { } }; - data = { + commandData = { arguments: [] }; @@ -38,9 +38,29 @@ describe( 'commands/diff', () => { afterEach( () => { sinon.restore(); + mockery.deregisterAll(); mockery.disable(); } ); + describe( '#helpMessage', () => { + it( 'defines help screen', () => { + expect( diffCommand.helpMessage ).is.a( 'string' ); + } ); + } ); + + describe( 'beforeExecute()', () => { + it( 'informs about starting the process', () => { + const consoleLog = sinon.stub( console, 'log' ); + + diffCommand.beforeExecute(); + + expect( consoleLog.calledOnce ).to.equal( true ); + expect( consoleLog.firstCall.args[ 0 ] ).to.match( /Collecting changes\.\.\./ ); + + consoleLog.restore(); + } ); + } ); + describe( 'execute()', () => { it( 'rejects promise if called command returned an error', () => { const error = new Error( 'Unexpected error.' ); @@ -51,7 +71,7 @@ describe( 'commands/diff', () => { } } ); - return diffCommand.execute( data ) + return diffCommand.execute( commandData ) .then( () => { throw new Error( 'Supposed to be rejected.' ); @@ -79,7 +99,7 @@ describe( 'commands/diff', () => { } } ); - return diffCommand.execute( data ) + return diffCommand.execute( commandData ) .then( diffResponse => { expect( stubs.execCommand.execute.calledOnce ).to.equal( true ); expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { @@ -93,7 +113,7 @@ describe( 'commands/diff', () => { it( 'does not return the logs when repository has not changed', () => { stubs.execCommand.execute.resolves( { logs: { info: [] } } ); - return diffCommand.execute( data ) + return diffCommand.execute( commandData ) .then( diffResponse => { expect( stubs.execCommand.execute.calledOnce ).to.equal( true ); @@ -104,12 +124,12 @@ describe( 'commands/diff', () => { it( 'allows modifying the "git diff" command', () => { stubs.execCommand.execute.resolves( { logs: { info: [] } } ); - data.arguments = [ + commandData.arguments = [ '--stat', '--staged' ]; - return diffCommand.execute( data ) + return diffCommand.execute( commandData ) .then( diffResponse => { expect( stubs.execCommand.execute.calledOnce ).to.equal( true ); expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { diff --git a/tests/commands/exec.js b/tests/commands/exec.js index e62c141..18552bf 100644 --- a/tests/commands/exec.js +++ b/tests/commands/exec.js @@ -14,7 +14,7 @@ const mockery = require( 'mockery' ); const expect = require( 'chai' ).expect; describe( 'commands/exec', () => { - let execCommand, stubs, data; + let execCommand, stubs, commandData; beforeEach( () => { mockery.enable( { @@ -24,7 +24,7 @@ describe( 'commands/exec', () => { } ); stubs = { - exec: sinon.stub(), + shell: sinon.stub(), fs: { existsSync: sinon.stub( fs, 'existsSync' ) }, @@ -36,11 +36,12 @@ describe( 'commands/exec', () => { } }; - data = { - // `execute` is called without the "exec" command (`mgit exec first-cmd other-cmd` => [ 'first-cmd', 'other-cmd' ]). + commandData = { + // Command `#execute` function is called without the "exec" command. + // `mgit exec pwd` => [ 'pwd' ] arguments: [ 'pwd' ], packageName: 'test-package', - options: { + mgitOptions: { cwd: __dirname, packages: 'packages' }, @@ -49,16 +50,23 @@ describe( 'commands/exec', () => { } }; - mockery.registerMock( '../utils/exec', stubs.exec ); + mockery.registerMock( '../utils/shell', stubs.shell ); execCommand = require( '../../lib/commands/exec' ); } ); afterEach( () => { sinon.restore(); + mockery.deregisterAll(); mockery.disable(); } ); + describe( '#helpMessage', () => { + it( 'defines help screen', () => { + expect( execCommand.helpMessage ).is.a( 'string' ); + } ); + } ); + describe( 'beforeExecute()', () => { it( 'throws an error if command to execute is not specified', () => { expect( () => { @@ -78,13 +86,13 @@ describe( 'commands/exec', () => { it( 'does not execute the command if package is not available', () => { stubs.fs.existsSync.returns( false ); - return execCommand.execute( data ) + return execCommand.execute( commandData ) .then( () => { throw new Error( 'Supposed to be rejected.' ); }, response => { - const err = 'Package "test-package" is not available. Run "mgit bootstrap" in order to download the package.'; + const err = 'Package "test-package" is not available. Run "mgit sync" in order to download the package.'; expect( response.logs.error[ 0 ] ).to.equal( err ); } ); @@ -94,9 +102,9 @@ describe( 'commands/exec', () => { const error = new Error( 'Unexpected error.' ); stubs.fs.existsSync.returns( true ); - stubs.exec.returns( Promise.reject( error ) ); + stubs.shell.returns( Promise.reject( error ) ); - return execCommand.execute( data ) + return execCommand.execute( commandData ) .then( () => { throw new Error( 'Supposed to be rejected.' ); @@ -113,9 +121,9 @@ describe( 'commands/exec', () => { it( 'resolves promise if command has been executed', () => { const pwd = '/packages/test-package'; stubs.fs.existsSync.returns( true ); - stubs.exec.returns( Promise.resolve( pwd ) ); + stubs.shell.returns( Promise.resolve( pwd ) ); - return execCommand.execute( data ) + return execCommand.execute( commandData ) .then( response => { expect( stubs.process.chdir.calledTwice ).to.equal( true ); expect( stubs.process.chdir.firstCall.args[ 0 ] ).to.equal( 'packages/test-package' ); diff --git a/tests/commands/fetch.js b/tests/commands/fetch.js new file mode 100644 index 0000000..9c9b411 --- /dev/null +++ b/tests/commands/fetch.js @@ -0,0 +1,173 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* jshint mocha:true */ + +'use strict'; + +const fs = require( 'fs' ); +const path = require( 'upath' ); +const sinon = require( 'sinon' ); +const mockery = require( 'mockery' ); +const expect = require( 'chai' ).expect; + +describe( 'commands/fetch', () => { + let fetchCommand, stubs, commandData; + + beforeEach( () => { + mockery.enable( { + useCleanCache: true, + warnOnReplace: false, + warnOnUnregistered: false + } ); + + stubs = { + exec: sinon.stub(), + fs: { + existsSync: sinon.stub( fs, 'existsSync' ) + }, + path: { + join: sinon.stub( path, 'join' ).callsFake( ( ...chunks ) => chunks.join( '/' ) ) + }, + execCommand: { + execute: sinon.stub() + } + }; + + commandData = { + arguments: [], + packageName: 'test-package', + mgitOptions: { + cwd: __dirname, + packages: 'packages' + }, + repository: { + directory: 'test-package', + url: 'git@github.com/organization/test-package.git', + branch: 'master' + } + }; + + mockery.registerMock( './exec', stubs.execCommand ); + + fetchCommand = require( '../../lib/commands/fetch' ); + } ); + + afterEach( () => { + sinon.restore(); + mockery.deregisterAll(); + mockery.disable(); + } ); + + describe( '#helpMessage', () => { + it( 'defines help screen', () => { + expect( fetchCommand.helpMessage ).is.a( 'string' ); + } ); + } ); + + describe( 'execute()', () => { + it( 'skips a package if is not available', () => { + stubs.fs.existsSync.returns( false ); + + return fetchCommand.execute( commandData ) + .then( response => { + expect( response ).to.deep.equal( {} ); + } ); + } ); + + it( 'resolves promise after pushing the changes', () => { + stubs.fs.existsSync.returns( true ); + + const exec = stubs.execCommand.execute; + + exec.returns( Promise.resolve( { + logs: getCommandLogs( 'remote: Counting objects: 254, done.' ) + } ) ); + + return fetchCommand.execute( commandData ) + .then( response => { + expect( exec.callCount ).to.equal( 1 ); + expect( exec.firstCall.args[ 0 ].arguments[ 0 ] ).to.equal( 'git fetch' ); + + expect( response.logs.info ).to.deep.equal( [ + 'remote: Counting objects: 254, done.' + ] ); + } ); + } ); + + it( 'allows removing remote-tracking references that no longer exist', () => { + commandData.arguments.push( '--prune' ); + stubs.fs.existsSync.returns( true ); + + const exec = stubs.execCommand.execute; + + exec.returns( Promise.resolve( { + logs: getCommandLogs( 'remote: Counting objects: 254, done.' ) + } ) ); + + return fetchCommand.execute( commandData ) + .then( response => { + expect( exec.callCount ).to.equal( 1 ); + expect( exec.firstCall.args[ 0 ].arguments[ 0 ] ).to.equal( 'git fetch -p' ); + + expect( response.logs.info ).to.deep.equal( [ + 'remote: Counting objects: 254, done.' + ] ); + } ); + } ); + + it( 'prints a log if repository is up-to-date', () => { + stubs.fs.existsSync.returns( true ); + + const exec = stubs.execCommand.execute; + + exec.returns( Promise.resolve( { + logs: { info: [] } + } ) ); + + return fetchCommand.execute( commandData ) + .then( response => { + expect( exec.callCount ).to.equal( 1 ); + expect( exec.firstCall.args[ 0 ].arguments[ 0 ] ).to.equal( 'git fetch' ); + + expect( response.logs.info ).to.deep.equal( [ + 'Repository is up to date.' + ] ); + } ); + } ); + } ); + + describe( 'afterExecute()', () => { + it( 'informs about number of processed packages', () => { + const consoleLog = sinon.stub( console, 'log' ); + + const processedPackages = new Set(); + processedPackages.add( 'package-1' ); + processedPackages.add( 'package-2' ); + + fetchCommand.afterExecute( processedPackages ); + + expect( consoleLog.calledOnce ).to.equal( true ); + expect( consoleLog.firstCall.args[ 0 ] ).to.match( /2 packages have been processed\./ ); + + consoleLog.restore(); + } ); + } ); + + function getCommandLogs( msg, isError = false ) { + const logs = { + error: [], + info: [] + }; + + if ( isError ) { + logs.error.push( msg ); + } else { + logs.info.push( msg ); + } + + return logs; + } +} ); diff --git a/tests/commands/pull.js b/tests/commands/pull.js new file mode 100644 index 0000000..930bc9e --- /dev/null +++ b/tests/commands/pull.js @@ -0,0 +1,136 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* jshint mocha:true */ + +'use strict'; + +const fs = require( 'fs' ); +const path = require( 'upath' ); +const sinon = require( 'sinon' ); +const mockery = require( 'mockery' ); +const expect = require( 'chai' ).expect; + +describe( 'commands/pull', () => { + let pullCommand, stubs, commandData; + + beforeEach( () => { + mockery.enable( { + useCleanCache: true, + warnOnReplace: false, + warnOnUnregistered: false + } ); + + stubs = { + exec: sinon.stub(), + fs: { + existsSync: sinon.stub( fs, 'existsSync' ) + }, + path: { + join: sinon.stub( path, 'join' ).callsFake( ( ...chunks ) => chunks.join( '/' ) ) + }, + bootstrapCommand: { + execute: sinon.stub() + }, + execCommand: { + execute: sinon.stub() + } + }; + + commandData = { + arguments: [], + packageName: 'test-package', + mgitOptions: { + cwd: __dirname, + packages: 'packages' + }, + repository: { + directory: 'test-package', + url: 'git@github.com/organization/test-package.git', + branch: 'master' + } + }; + + mockery.registerMock( './exec', stubs.execCommand ); + mockery.registerMock( './bootstrap', stubs.bootstrapCommand ); + + pullCommand = require( '../../lib/commands/pull' ); + } ); + + afterEach( () => { + sinon.restore(); + mockery.deregisterAll(); + mockery.disable(); + } ); + + describe( '#helpMessage', () => { + it( 'defines help screen', () => { + expect( pullCommand.helpMessage ).is.a( 'string' ); + } ); + } ); + + describe( 'execute()', () => { + it( 'skips a package if is not available', () => { + stubs.fs.existsSync.returns( false ); + + return pullCommand.execute( commandData ) + .then( response => { + expect( response ).to.deep.equal( {} ); + } ); + } ); + + it( 'resolves promise after pulling the changes', () => { + stubs.fs.existsSync.returns( true ); + + const exec = stubs.execCommand.execute; + + exec.returns( Promise.resolve( { + logs: getCommandLogs( 'Already up-to-date.' ) + } ) ); + + return pullCommand.execute( commandData ) + .then( response => { + expect( exec.callCount ).to.equal( 1 ); + expect( exec.firstCall.args[ 0 ].arguments[ 0 ] ).to.equal( 'git pull' ); + + expect( response.logs.info ).to.deep.equal( [ + 'Already up-to-date.' + ] ); + } ); + } ); + } ); + + describe( 'afterExecute()', () => { + it( 'informs about number of processed packages', () => { + const consoleLog = sinon.stub( console, 'log' ); + + const processedPackages = new Set(); + processedPackages.add( 'package-1' ); + processedPackages.add( 'package-2' ); + + pullCommand.afterExecute( processedPackages ); + + expect( consoleLog.calledOnce ).to.equal( true ); + expect( consoleLog.firstCall.args[ 0 ] ).to.match( /2 packages have been processed\./ ); + + consoleLog.restore(); + } ); + } ); + + function getCommandLogs( msg, isError = false ) { + const logs = { + error: [], + info: [] + }; + + if ( isError ) { + logs.error.push( msg ); + } else { + logs.info.push( msg ); + } + + return logs; + } +} ); diff --git a/tests/commands/push.js b/tests/commands/push.js new file mode 100644 index 0000000..4c41204 --- /dev/null +++ b/tests/commands/push.js @@ -0,0 +1,154 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* jshint mocha:true */ + +'use strict'; + +const fs = require( 'fs' ); +const path = require( 'upath' ); +const sinon = require( 'sinon' ); +const mockery = require( 'mockery' ); +const expect = require( 'chai' ).expect; + +describe( 'commands/push', () => { + let pushCommand, stubs, commandData; + + beforeEach( () => { + mockery.enable( { + useCleanCache: true, + warnOnReplace: false, + warnOnUnregistered: false + } ); + + stubs = { + exec: sinon.stub(), + fs: { + existsSync: sinon.stub( fs, 'existsSync' ) + }, + path: { + join: sinon.stub( path, 'join' ).callsFake( ( ...chunks ) => chunks.join( '/' ) ) + }, + execCommand: { + execute: sinon.stub() + } + }; + + commandData = { + arguments: [], + packageName: 'test-package', + mgitOptions: { + cwd: __dirname, + packages: 'packages' + }, + repository: { + directory: 'test-package', + url: 'git@github.com/organization/test-package.git', + branch: 'master' + } + }; + + mockery.registerMock( './exec', stubs.execCommand ); + + pushCommand = require( '../../lib/commands/push' ); + } ); + + afterEach( () => { + sinon.restore(); + mockery.deregisterAll(); + mockery.disable(); + } ); + + describe( '#helpMessage', () => { + it( 'defines help screen', () => { + expect( pushCommand.helpMessage ).is.a( 'string' ); + } ); + } ); + + describe( 'execute()', () => { + it( 'skips a package if is not available', () => { + stubs.fs.existsSync.returns( false ); + + return pushCommand.execute( commandData ) + .then( response => { + expect( response ).to.deep.equal( {} ); + } ); + } ); + + it( 'resolves promise after pushing the changes', () => { + stubs.fs.existsSync.returns( true ); + + const exec = stubs.execCommand.execute; + + exec.returns( Promise.resolve( { + logs: getCommandLogs( 'Everything up-to-date' ) + } ) ); + + return pushCommand.execute( commandData ) + .then( response => { + expect( exec.callCount ).to.equal( 1 ); + expect( exec.firstCall.args[ 0 ].arguments[ 0 ] ).to.equal( 'git push' ); + + expect( response.logs.info ).to.deep.equal( [ + 'Everything up-to-date' + ] ); + } ); + } ); + + it( 'allows modifying the "git push" command', () => { + commandData.arguments.push( '--verbose' ); + commandData.arguments.push( '--all' ); + stubs.fs.existsSync.returns( true ); + + const exec = stubs.execCommand.execute; + + exec.returns( Promise.resolve( { + logs: getCommandLogs( 'Everything up-to-date' ) + } ) ); + + return pushCommand.execute( commandData ) + .then( response => { + expect( exec.callCount ).to.equal( 1 ); + expect( exec.firstCall.args[ 0 ].arguments[ 0 ] ).to.equal( 'git push --verbose --all' ); + + expect( response.logs.info ).to.deep.equal( [ + 'Everything up-to-date' + ] ); + } ); + } ); + } ); + + describe( 'afterExecute()', () => { + it( 'informs about number of processed packages', () => { + const consoleLog = sinon.stub( console, 'log' ); + + const processedPackages = new Set(); + processedPackages.add( 'package-1' ); + processedPackages.add( 'package-2' ); + + pushCommand.afterExecute( processedPackages ); + + expect( consoleLog.calledOnce ).to.equal( true ); + expect( consoleLog.firstCall.args[ 0 ] ).to.match( /2 packages have been processed\./ ); + + consoleLog.restore(); + } ); + } ); + + function getCommandLogs( msg, isError = false ) { + const logs = { + error: [], + info: [] + }; + + if ( isError ) { + logs.error.push( msg ); + } else { + logs.info.push( msg ); + } + + return logs; + } +} ); diff --git a/tests/commands/save.js b/tests/commands/save.js new file mode 100644 index 0000000..145afd4 --- /dev/null +++ b/tests/commands/save.js @@ -0,0 +1,293 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* jshint mocha:true */ + +'use strict'; + +const path = require( 'upath' ); +const sinon = require( 'sinon' ); +const mockery = require( 'mockery' ); +const expect = require( 'chai' ).expect; + +describe( 'commands/save', () => { + let saveCommand, stubs, commandData, mgitOptions, mgitJsonPath, updateFunction; + + beforeEach( () => { + mockery.enable( { + useCleanCache: true, + warnOnReplace: false, + warnOnUnregistered: false + } ); + + stubs = { + execCommand: { + execute: sinon.stub() + }, + path: { + join: sinon.stub( path, 'join' ).callsFake( ( ...chunks ) => chunks.join( '/' ) ) + }, + gitStatusParser: sinon.stub() + }; + + mgitOptions = {}; + + commandData = { + packageName: 'test-package', + arguments: [], + mgitOptions + }; + + mockery.registerMock( './exec', stubs.execCommand ); + mockery.registerMock( '../utils/updatejsonfile', ( pathToFile, callback ) => { + mgitJsonPath = pathToFile; + updateFunction = callback; + } ); + mockery.registerMock( '../utils/getcwd', () => { + return __dirname; + } ); + mockery.registerMock( '../utils/gitstatusparser', stubs.gitStatusParser ); + + saveCommand = require( '../../lib/commands/save' ); + } ); + + afterEach( () => { + sinon.restore(); + mockery.deregisterAll(); + mockery.disable(); + } ); + + describe( '#helpMessage', () => { + it( 'defines help screen', () => { + expect( saveCommand.helpMessage ).is.a( 'string' ); + } ); + } ); + + describe( 'beforeExecute()', () => { + it( 'defined which type of data should be saved', () => { + saveCommand.beforeExecute( [], mgitOptions ); + expect( mgitOptions.hash ).to.equal( true ); + } ); + + it( 'throws an error if used both options', () => { + const errorMessage = 'Cannot use "hash" and "branch" options at the same time.'; + + mgitOptions.branch = true; + mgitOptions.hash = true; + + expect( () => { + saveCommand.beforeExecute( [], mgitOptions ); + } ).to.throw( Error, errorMessage ); + } ); + } ); + + describe( 'execute()', () => { + it( 'rejects promise if called command returned an error', () => { + mgitOptions.hash = true; + + const error = new Error( 'Unexpected error.' ); + + stubs.execCommand.execute.returns( Promise.reject( { + logs: { + error: [ error.stack ] + } + } ) ); + + return saveCommand.execute( commandData ) + .then( + () => { + throw new Error( 'Supposed to be rejected.' ); + }, + response => { + expect( response.logs.error[ 0 ].split( '\n' )[ 0 ] ).to.equal( `Error: ${ error.message }` ); + } + ); + } ); + + it( 'resolves promise with last commit id', () => { + mgitOptions.hash = true; + + const execCommandResponse = { + logs: { + info: [ '584f341' ] + } + }; + + stubs.execCommand.execute.returns( Promise.resolve( execCommandResponse ) ); + + return saveCommand.execute( commandData ) + .then( commandResponse => { + expect( stubs.execCommand.execute.calledOnce ).to.equal( true ); + expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { + packageName: commandData.packageName, + arguments: [ 'git rev-parse HEAD' ], + mgitOptions: { + hash: true, + } + } ); + + expect( commandResponse.response ).to.deep.equal( { + packageName: commandData.packageName, + data: '584f341', + hash: true, + branch: undefined + } ); + + expect( commandResponse.logs.info[ 0 ] ).to.equal( 'Commit: "584f341".' ); + } ); + } ); + + it( 'resolves promise with a name of current branch if called with --branch option', () => { + const execCommandResponse = { + logs: { + info: [ '## master...origin/master' ] + } + }; + + mgitOptions.branch = true; + + stubs.gitStatusParser.returns( { branch: 'master' } ); + stubs.execCommand.execute.returns( Promise.resolve( execCommandResponse ) ); + + return saveCommand.execute( commandData ) + .then( commandResponse => { + expect( stubs.execCommand.execute.calledOnce ).to.equal( true ); + expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { + packageName: commandData.packageName, + arguments: [ 'git status --branch --porcelain' ], + mgitOptions: { + branch: true, + } + } ); + + expect( commandResponse.response ).to.deep.equal( { + packageName: commandData.packageName, + data: 'master', + branch: true, + hash: undefined + } ); + + expect( commandResponse.logs.info[ 0 ] ).to.equal( 'Branch: "master".' ); + } ); + } ); + } ); + + describe( 'afterExecute()', () => { + it( 'updates collected hashes in "mgit.json" (--hash option)', () => { + const processedPackages = new Set(); + const commandResponses = new Set(); + + processedPackages.add( 'test-package' ); + processedPackages.add( 'package-test' ); + + commandResponses.add( { + packageName: 'test-package', + data: '584f341', + hash: true, + branch: false + } ); + commandResponses.add( { + packageName: 'package-test', + data: '52910fe', + hash: true, + branch: false + } ); + + saveCommand.afterExecute( processedPackages, commandResponses ); + + let json = { + dependencies: { + 'test-package': 'organization/test-package', + 'package-test': 'organization/package-test', + 'other-package': 'organization/other-package' + } + }; + + expect( mgitJsonPath ).to.equal( __dirname + '/mgit.json' ); + expect( updateFunction ).to.be.a( 'function' ); + + json = updateFunction( json ); + + expect( json.dependencies ).to.deep.equal( { + 'test-package': 'organization/test-package#584f341', + 'package-test': 'organization/package-test#52910fe', + 'other-package': 'organization/other-package' + } ); + } ); + + it( 'updates collected branches in "mgit.json" (--branch option)', () => { + const processedPackages = new Set(); + const commandResponses = new Set(); + + processedPackages.add( 'test-package' ); + processedPackages.add( 'package-test' ); + + commandResponses.add( { + packageName: 'test-package', + data: 'develop', + hash: false, + branch: true + } ); + commandResponses.add( { + packageName: 'package-test', + data: 'develop', + hash: false, + branch: true + } ); + + saveCommand.afterExecute( processedPackages, commandResponses ); + + let json = { + dependencies: { + 'test-package': 'organization/test-package', + 'package-test': 'organization/package-test', + 'other-package': 'organization/other-package' + } + }; + + expect( mgitJsonPath ).to.equal( __dirname + '/mgit.json' ); + expect( updateFunction ).to.be.a( 'function' ); + + json = updateFunction( json ); + + expect( json.dependencies ).to.deep.equal( { + 'test-package': 'organization/test-package#develop', + 'package-test': 'organization/package-test#develop', + 'other-package': 'organization/other-package' + } ); + } ); + + it( 'does not save "#master" branch because it is default branch', () => { + const processedPackages = new Set(); + const commandResponses = new Set(); + + processedPackages.add( 'test-package' ); + + commandResponses.add( { + packageName: 'test-package', + data: 'master', + hash: false, + branch: true + } ); + + saveCommand.afterExecute( processedPackages, commandResponses ); + + let json = { + dependencies: { + 'test-package': 'organization/test-package#some-branch', + } + }; + + expect( mgitJsonPath ).to.equal( __dirname + '/mgit.json' ); + expect( updateFunction ).to.be.a( 'function' ); + + json = updateFunction( json ); + + expect( json.dependencies ).to.deep.equal( { + 'test-package': 'organization/test-package', + } ); + } ); + } ); +} ); diff --git a/tests/commands/savehashes.js b/tests/commands/savehashes.js deleted file mode 100644 index 7e873e1..0000000 --- a/tests/commands/savehashes.js +++ /dev/null @@ -1,142 +0,0 @@ -/** - * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. - * For licensing, see LICENSE.md. - */ - -/* jshint mocha:true */ - -'use strict'; - -const path = require( 'upath' ); -const sinon = require( 'sinon' ); -const mockery = require( 'mockery' ); -const expect = require( 'chai' ).expect; - -describe( 'commands/savehashes', () => { - let saveHashesCommand, stubs, data, mgitJsonPath, updateFunction; - - beforeEach( () => { - mockery.enable( { - useCleanCache: true, - warnOnReplace: false, - warnOnUnregistered: false - } ); - - stubs = { - execCommand: { - execute: sinon.stub() - }, - path: { - join: sinon.stub( path, 'join' ).callsFake( ( ...chunks ) => chunks.join( '/' ) ) - } - }; - - data = { - packageName: 'test-package', - }; - - mockery.registerMock( './exec', stubs.execCommand ); - mockery.registerMock( '../utils/updatejsonfile', ( pathToFile, callback ) => { - mgitJsonPath = pathToFile; - updateFunction = callback; - } ); - mockery.registerMock( '../utils/getcwd', () => { - return __dirname; - } ); - - saveHashesCommand = require( '../../lib/commands/savehashes' ); - } ); - - afterEach( () => { - sinon.restore(); - mockery.disable(); - } ); - - describe( 'execute()', () => { - it( 'rejects promise if called command returned an error', () => { - const error = new Error( 'Unexpected error.' ); - - stubs.execCommand.execute.returns( Promise.reject( { - logs: { - error: [ error.stack ] - } - } ) ); - - return saveHashesCommand.execute( data ) - .then( - () => { - throw new Error( 'Supposed to be rejected.' ); - }, - response => { - expect( response.logs.error[ 0 ].split( '\n' )[ 0 ] ).to.equal( `Error: ${ error.message }` ); - } - ); - } ); - - it( 'resolves promise with last commit id', () => { - const execCommandResponse = { - logs: { - info: [ '584f341' ] - } - }; - - stubs.execCommand.execute.returns( Promise.resolve( execCommandResponse ) ); - - return saveHashesCommand.execute( data ) - .then( commandResponse => { - expect( stubs.execCommand.execute.calledOnce ).to.equal( true ); - expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { - packageName: data.packageName, - arguments: [ 'git rev-parse HEAD' ] - } ); - - expect( commandResponse.response ).to.deep.equal( { - packageName: data.packageName, - commit: '584f341' - } ); - - expect( commandResponse.logs.info[ 0 ] ).to.equal( 'Commit: "584f341".' ); - } ); - } ); - } ); - - describe( 'afterExecute()', () => { - it( 'updates collected hashes in "mgit.json"', () => { - const processedPackages = new Set(); - const commandResponses = new Set(); - - processedPackages.add( 'test-package' ); - processedPackages.add( 'package-test' ); - - commandResponses.add( { - packageName: 'test-package', - commit: '584f341' - } ); - commandResponses.add( { - packageName: 'package-test', - commit: '52910fe' - } ); - - saveHashesCommand.afterExecute( processedPackages, commandResponses ); - - let json = { - dependencies: { - 'test-package': 'organization/test-package', - 'package-test': 'organization/package-test', - 'other-package': 'organization/other-package' - } - }; - - expect( mgitJsonPath ).to.equal( __dirname + '/mgit.json' ); - expect( updateFunction ).to.be.a( 'function' ); - - json = updateFunction( json ); - - expect( json.dependencies ).to.deep.equal( { - 'test-package': 'organization/test-package#584f341', - 'package-test': 'organization/package-test#52910fe', - 'other-package': 'organization/other-package' - } ); - } ); - } ); -} ); diff --git a/tests/commands/status.js b/tests/commands/status.js index fd22b70..82cca4e 100644 --- a/tests/commands/status.js +++ b/tests/commands/status.js @@ -12,7 +12,7 @@ const mockery = require( 'mockery' ); const expect = require( 'chai' ).expect; describe( 'commands/status', () => { - let statusCommand, stubs, data; + let statusCommand, stubs, commandData; beforeEach( () => { mockery.enable( { @@ -42,8 +42,8 @@ describe( 'commands/status', () => { } }; - data = { - options: { + commandData = { + mgitOptions: { packagesPrefix: '@ckeditor/ckeditor5-' }, repository: { @@ -55,13 +55,14 @@ describe( 'commands/status', () => { // Do not modify the color. mockery.registerMock( 'chalk', { - cyan: stubs.chalk.cyan.callsFake( chalkCallsFake() ), - bold: stubs.chalk.bold.callsFake( chalkCallsFake() ), - yellow: stubs.chalk.yellow.callsFake( chalkCallsFake() ), - green: stubs.chalk.green.callsFake( chalkCallsFake() ), - red: stubs.chalk.red.callsFake( chalkCallsFake() ), - blue: stubs.chalk.blue.callsFake( chalkCallsFake() ), - magenta: stubs.chalk.magenta.callsFake( chalkCallsFake() ) + cyan: stubs.chalk.cyan.callsFake( msg => msg ), + bold: stubs.chalk.bold.callsFake( msg => msg ), + yellow: stubs.chalk.yellow.callsFake( msg => msg ), + green: stubs.chalk.green.callsFake( msg => msg ), + red: stubs.chalk.red.callsFake( msg => msg ), + blue: stubs.chalk.blue.callsFake( msg => msg ), + magenta: stubs.chalk.magenta.callsFake( msg => msg ), + underline: stubs.chalk.magenta.callsFake( msg => msg ) } ); mockery.registerMock( 'cli-table', class Table { constructor( ...args ) { @@ -80,17 +81,26 @@ describe( 'commands/status', () => { mockery.registerMock( '../utils/gitstatusparser', stubs.gitStatusParser ); statusCommand = require( '../../lib/commands/status' ); - - function chalkCallsFake() { - return ( ...args ) => args.join( ',' ); - } } ); afterEach( () => { sinon.restore(); + mockery.deregisterAll(); mockery.disable(); } ); + describe( '#helpMessage', () => { + it( 'defines help screen', () => { + expect( statusCommand.helpMessage ).is.a( 'string' ); + } ); + } ); + + describe( '#name', () => { + it( 'returns a full name of executed command', () => { + expect( statusCommand.name ).is.a( 'string' ); + } ); + } ); + describe( 'beforeExecute()', () => { it( 'should describe why logs are not display in "real-time"', () => { const logStub = sinon.stub( console, 'log' ); @@ -113,7 +123,7 @@ describe( 'commands/status', () => { } } ); - return statusCommand.execute( data ) + return statusCommand.execute( commandData ) .then( () => { throw new Error( 'Supposed to be rejected.' ); @@ -138,7 +148,7 @@ describe( 'commands/status', () => { stubs.gitStatusParser.returns( { response: 'Parsed response.' } ); - return statusCommand.execute( data ) + return statusCommand.execute( commandData ) .then( statusResponse => { expect( stubs.execCommand.execute.calledTwice ).to.equal( true ); expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( @@ -160,14 +170,14 @@ describe( 'commands/status', () => { } ); function getCommandArguments( command ) { - return Object.assign( {}, data, { + return Object.assign( {}, commandData, { arguments: [ command ] } ); } } ); it( 'does not modify the package name if "packagesPrefix" option is not specified', () => { - delete data.options.packagesPrefix; + delete commandData.mgitOptions.packagesPrefix; stubs.execCommand.execute.onFirstCall().resolves( { logs: { @@ -182,7 +192,7 @@ describe( 'commands/status', () => { stubs.gitStatusParser.returns( { response: 'Parsed response.' } ); - return statusCommand.execute( data ) + return statusCommand.execute( commandData ) .then( statusResponse => { expect( statusResponse.response ).to.deep.equal( { packageName: '@ckeditor/ckeditor5-test-package', diff --git a/tests/commands/sync.js b/tests/commands/sync.js new file mode 100644 index 0000000..255e0b6 --- /dev/null +++ b/tests/commands/sync.js @@ -0,0 +1,621 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* jshint mocha:true */ + +'use strict'; + +const fs = require( 'fs' ); +const path = require( 'upath' ); +const sinon = require( 'sinon' ); +const mockery = require( 'mockery' ); +const expect = require( 'chai' ).expect; + +describe( 'commands/sync', () => { + let syncCommand, stubs, mgitOptions, commandData; + + beforeEach( () => { + mockery.enable( { + useCleanCache: true, + warnOnReplace: false, + warnOnUnregistered: false + } ); + + stubs = { + shell: sinon.stub(), + exec: sinon.stub(), + fs: { + existsSync: sinon.stub( fs, 'existsSync' ), + readdirSync: sinon.stub( fs, 'readdirSync' ) + }, + path: { + join: sinon.stub( path, 'join' ).callsFake( ( ...chunks ) => chunks.join( '/' ) ) + }, + bootstrapCommand: { + execute: sinon.stub() + }, + execCommand: { + execute: sinon.stub() + }, + repositoryResolver: sinon.stub() + }; + + mgitOptions = { + cwd: '/tmp', + packages: '/tmp/packages', + resolverPath: 'PATH_TO_RESOLVER' + }; + + commandData = { + arguments: [], + packageName: 'test-package', + mgitOptions, + repository: { + directory: 'test-package', + url: 'git@github.com/organization/test-package.git', + branch: 'master' + } + }; + + mockery.registerMock( './exec', stubs.execCommand ); + mockery.registerMock( '../utils/shell', stubs.shell ); + mockery.registerMock( 'PATH_TO_RESOLVER', stubs.repositoryResolver ); + + syncCommand = require( '../../lib/commands/sync' ); + } ); + + afterEach( () => { + sinon.restore(); + mockery.deregisterAll(); + mockery.disable(); + } ); + + describe( '#helpMessage', () => { + it( 'defines help screen', () => { + expect( syncCommand.helpMessage ).is.a( 'string' ); + } ); + } ); + + describe( 'execute()', () => { + describe( 'first call on a package', () => { + it( 'clones the package if is not available', () => { + stubs.fs.existsSync.returns( false ); + stubs.shell.returns( Promise.resolve( 'Git clone log.' ) ); + + return syncCommand.execute( commandData ) + .then( response => { + expect( stubs.shell.calledOnce ).to.equal( true ); + + const cloneCommand = stubs.shell.firstCall.args[ 0 ].split( ' && ' ); + + // Clone the repository. + expect( cloneCommand[ 0 ] ).to.equal( + 'git clone --progress "git@github.com/organization/test-package.git" "/tmp/packages/test-package"' + ); + // Change the directory to cloned package. + expect( cloneCommand[ 1 ] ).to.equal( 'cd "/tmp/packages/test-package"' ); + // And check out to proper branch. + expect( cloneCommand[ 2 ] ).to.equal( 'git checkout --quiet master' ); + + expect( response.logs.info ).to.deep.equal( [ + 'Package "test-package" was not found. Cloning...', + 'Git clone log.' + ] ); + } ); + } ); + + it( 'clones dependencies of installed package', () => { + mgitOptions.recursive = true; + commandData.mgitOptions.packages = __dirname + '/../fixtures'; + commandData.repository.directory = 'project-a'; + + stubs.fs.existsSync.returns( false ); + stubs.shell.returns( Promise.resolve( 'Git clone log.' ) ); + + return syncCommand.execute( commandData ) + .then( response => { + expect( response.packages ).is.an( 'array' ); + expect( response.packages ).to.deep.equal( [ 'test-foo' ] ); + } ); + } ); + + it( 'clones dev-dependencies of installed package', () => { + mgitOptions.recursive = true; + commandData.mgitOptions.packages = __dirname + '/../fixtures'; + commandData.repository.directory = 'project-with-options-in-mgitjson'; + + stubs.fs.existsSync.returns( false ); + stubs.shell.returns( Promise.resolve( 'Git clone log.' ) ); + + return syncCommand.execute( commandData ) + .then( response => { + expect( response.packages ).is.an( 'array' ); + expect( response.packages ).to.deep.equal( [ 'test-bar' ] ); + } ); + } ); + + describe( 'repeat installation process', function() { + this.timeout( 5500 ); + + const cloneCommand = 'git clone --progress "git@github.com/organization/test-package.git" "/tmp/packages/test-package"'; + + it( 'for errors with capital letters', () => { + stubs.fs.existsSync.returns( false ); + + stubs.shell.onFirstCall().returns( Promise.reject( [ + 'exec: Cloning into \'/some/path\'...', + 'remote: Enumerating objects: 6, done.', + 'remote: Counting objects: 100% (6/6), done.', + 'remote: Compressing objects: 100% (6/6), done.', + 'packet_write_wait: Connection to 000.00.000.000 port 22: Broken pipe', + 'fatal: The remote end hung up unexpectedly', + 'fatal: early EOF', + 'fatal: index-pack failed' + ].join( '\n' ) ) ); + + stubs.shell.onSecondCall().returns( Promise.resolve( 'Git clone log.' ) ); + + return syncCommand.execute( commandData ) + .then( response => { + expect( stubs.shell.calledTwice ).to.equal( true ); + + const firstCommand = stubs.shell.firstCall.args[ 0 ].split( ' && ' ); + + // Clone the repository for the first time. It failed. + expect( firstCommand[ 0 ] ) + .to.equal( cloneCommand ); + // Change the directory to cloned package. + expect( firstCommand[ 1 ] ).to.equal( 'cd "/tmp/packages/test-package"' ); + // And check out to proper branch. + expect( firstCommand[ 2 ] ).to.equal( 'git checkout --quiet master' ); + + const secondCommand = stubs.shell.secondCall.args[ 0 ].split( ' && ' ); + + // Clone the repository for the second time. It succeed. + expect( secondCommand[ 0 ] ) + .to.equal( cloneCommand ); + // Change the directory to cloned package. + expect( secondCommand[ 1 ] ).to.equal( 'cd "/tmp/packages/test-package"' ); + // And check out to proper branch. + expect( secondCommand[ 2 ] ).to.equal( 'git checkout --quiet master' ); + + expect( response.logs.info ).to.deep.equal( [ + 'Package "test-package" was not found. Cloning...', + 'Git clone log.' + ] ); + } ); + } ); + + it( 'for errors with small letters', () => { + stubs.fs.existsSync.returns( false ); + + stubs.shell.onFirstCall().returns( Promise.reject( [ + 'exec: Cloning into \'/some/path\'...', + 'remote: Enumerating objects: 6, done.', + 'remote: Counting objects: 100% (6/6), done.', + 'remote: Compressing objects: 100% (6/6), done.', + 'packet_write_wait: Connection to 000.00.000.000 port 22: Broken pipe', + 'fatal: the remote end hung up unexpectedly', + 'fatal: early EOF', + 'fatal: index-pack failed' + ].join( '\n' ) ) ); + + stubs.shell.onSecondCall().returns( Promise.resolve( 'Git clone log.' ) ); + + return syncCommand.execute( commandData ) + .then( response => { + expect( stubs.shell.calledTwice ).to.equal( true ); + + const firstCommand = stubs.shell.firstCall.args[ 0 ].split( ' && ' ); + + // Clone the repository for the first time. It failed. + expect( firstCommand[ 0 ] ) + .to.equal( cloneCommand ); + // Change the directory to cloned package. + expect( firstCommand[ 1 ] ).to.equal( 'cd "/tmp/packages/test-package"' ); + // And check out to proper branch. + expect( firstCommand[ 2 ] ).to.equal( 'git checkout --quiet master' ); + + const secondCommand = stubs.shell.secondCall.args[ 0 ].split( ' && ' ); + + // Clone the repository for the second time. It succeed. + expect( secondCommand[ 0 ] ) + .to.equal( cloneCommand ); + // Change the directory to cloned package. + expect( secondCommand[ 1 ] ).to.equal( 'cd "/tmp/packages/test-package"' ); + // And check out to proper branch. + expect( secondCommand[ 2 ] ).to.equal( 'git checkout --quiet master' ); + + expect( response.logs.info ).to.deep.equal( [ + 'Package "test-package" was not found. Cloning...', + 'Git clone log.' + ] ); + } ); + } ); + + it( 'returns an error if command failed twice', () => { + stubs.fs.existsSync.returns( false ); + + const errorMessage = [ + 'exec: Cloning into \'/some/path\'...', + 'remote: Enumerating objects: 6, done.', + 'remote: Counting objects: 100% (6/6), done.', + 'remote: Compressing objects: 100% (6/6), done.', + 'packet_write_wait: Connection to 000.00.000.000 port 22: Broken pipe', + 'fatal: the remote end hung up unexpectedly', + 'fatal: early EOF', + 'fatal: index-pack failed' + ].join( '\n' ); + + stubs.shell.onFirstCall().returns( Promise.reject( new Error( errorMessage ) ) ); + + // Can't use `.returns()` because it generates `UnhandledPromiseRejectionWarning` in the console. + stubs.shell.onSecondCall().callsFake( () => { + return Promise.reject( errorMessage ); + } ); + + return syncCommand.execute( commandData ) + .then( + () => { + throw new Error( 'Expected that the Promise fails.' ); + }, + response => { + expect( stubs.shell.calledTwice ).to.equal( true ); + + expect( response.logs.info ).to.deep.equal( [ + 'Package "test-package" was not found. Cloning...', + ] ); + + expect( response.logs.error ).to.deep.equal( [ + errorMessage + ] ); + } + ); + } ); + } ); + } ); + + describe( 'the package is already installed', () => { + it( 'resolves promise after pulling the changes', () => { + stubs.fs.existsSync.returns( true ); + + const exec = stubs.execCommand.execute; + + exec.onCall( 0 ).returns( Promise.resolve( { + logs: getCommandLogs( '' ) + } ) ); + + exec.onCall( 1 ).returns( Promise.resolve( { + logs: getCommandLogs( '' ) + } ) ); + + exec.onCall( 2 ).returns( Promise.resolve( { + logs: getCommandLogs( 'Already on \'master\'.' ) + } ) ); + + exec.onCall( 3 ).returns( Promise.resolve( { + logs: getCommandLogs( '* master\n remotes/origin/master' ) + } ) ); + + exec.onCall( 4 ).returns( Promise.resolve( { + logs: getCommandLogs( 'Already up-to-date.' ) + } ) ); + + return syncCommand.execute( commandData ) + .then( response => { + expect( exec.getCall( 0 ).args[ 0 ].arguments[ 0 ] ).to.equal( 'git status -s' ); + expect( exec.getCall( 1 ).args[ 0 ].arguments[ 0 ] ).to.equal( 'git fetch' ); + expect( exec.getCall( 2 ).args[ 0 ].arguments[ 0 ] ).to.equal( 'git checkout master' ); + expect( exec.getCall( 3 ).args[ 0 ].arguments[ 0 ] ).to.equal( 'git branch' ); + expect( exec.getCall( 4 ).args[ 0 ].arguments[ 0 ] ).to.equal( 'git pull origin master' ); + + expect( response.logs.info ).to.deep.equal( [ + 'Already on \'master\'.', + 'Already up-to-date.' + ] ); + + expect( exec.callCount ).to.equal( 5 ); + } ); + } ); + + it( 'aborts if package has uncommitted changes', () => { + stubs.fs.existsSync.returns( true ); + + const exec = stubs.execCommand.execute; + + exec.returns( Promise.resolve( { + logs: getCommandLogs( ' M first-file.js\n ?? second-file.js' ) + } ) ); + + return syncCommand.execute( commandData ) + .then( + () => { + throw new Error( 'Supposed to be rejected.' ); + }, + response => { + const errMsg = 'Package "test-package" has uncommitted changes. Aborted.'; + + expect( response.logs.error[ 0 ].split( '\n' )[ 0 ] ).to.equal( errMsg ); + expect( exec.firstCall.args[ 0 ].arguments[ 0 ] ).to.equal( 'git status -s' ); + } + ); + } ); + + it( 'does not pull the changes if detached on a commit or a tag', () => { + stubs.fs.existsSync.returns( true ); + + commandData.repository.branch = '1a0ff0a'; + + const exec = stubs.execCommand.execute; + + exec.onCall( 0 ).returns( Promise.resolve( { + logs: getCommandLogs( '' ) + } ) ); + + exec.onCall( 1 ).returns( Promise.resolve( { + logs: getCommandLogs( '' ) + } ) ); + + exec.onCall( 2 ).returns( Promise.resolve( { + logs: getCommandLogs( 'Note: checking out \'1a0ff0a\'.' ) + } ) ); + + exec.onCall( 3 ).returns( Promise.resolve( { + logs: getCommandLogs( [ + '* (HEAD detached at 1a0ff0a)', + ' master', + ' remotes/origin/master' + ].join( '\n' ) ) + } ) ); + + return syncCommand.execute( commandData ) + .then( response => { + expect( response.logs.info ).to.deep.equal( [ + 'Note: checking out \'1a0ff0a\'.', + 'Package "test-package" is on a detached commit.' + ] ); + + expect( exec.callCount ).to.equal( 4 ); + } ); + } ); + + it( 'aborts if user wants to pull changes from non-existing branch', () => { + stubs.fs.existsSync.returns( true ); + + commandData.repository.branch = 'develop'; + + const exec = stubs.execCommand.execute; + + exec.onCall( 0 ).returns( Promise.resolve( { + logs: getCommandLogs( '' ) + } ) ); + + exec.onCall( 1 ).returns( Promise.resolve( { + logs: getCommandLogs( '' ) + } ) ); + + exec.onCall( 2 ).returns( Promise.resolve( { + logs: getCommandLogs( 'Already on \'develop\'.' ) + } ) ); + + exec.onCall( 3 ).returns( Promise.resolve( { + logs: getCommandLogs( '* develop' ) + } ) ); + + exec.onCall( 4 ).returns( Promise.reject( { + logs: getCommandLogs( 'fatal: Couldn\'t find remote ref develop', true ) + } ) ); + + return syncCommand.execute( commandData ) + .then( + () => { + throw new Error( 'Supposed to be rejected.' ); + }, + response => { + expect( response.logs.info ).to.deep.equal( [ + 'Already on \'develop\'.' + ] ); + + const errMsg = 'fatal: Couldn\'t find remote ref develop'; + expect( response.logs.error[ 0 ].split( '\n' )[ 0 ] ).to.equal( errMsg ); + + expect( exec.callCount ).to.equal( 5 ); + } + ); + } ); + + it( 'aborts if user wants to check out to non-existing branch', () => { + stubs.fs.existsSync.returns( true ); + + commandData.repository.branch = 'non-existing-branch'; + + const exec = stubs.execCommand.execute; + + exec.onCall( 0 ).returns( Promise.resolve( { + logs: getCommandLogs( '' ) + } ) ); + + exec.onCall( 1 ).returns( Promise.resolve( { + logs: getCommandLogs( '' ) + } ) ); + + exec.onCall( 2 ).returns( Promise.reject( { + logs: getCommandLogs( 'error: pathspec \'ggdfgd\' did not match any file(s) known to git.', true ), + } ) ); + + return syncCommand.execute( commandData ) + .then( + () => { + throw new Error( 'Supposed to be rejected.' ); + }, + response => { + const errMsg = 'error: pathspec \'ggdfgd\' did not match any file(s) known to git.'; + expect( response.logs.error[ 0 ].split( '\n' )[ 0 ] ).to.equal( errMsg ); + + expect( exec.callCount ).to.equal( 3 ); + } + ); + } ); + } ); + } ); + + describe( 'afterExecute()', () => { + it( 'informs about number of processed packages and differences between packages in directory and defined in mgit.json', () => { + stubs.fs.lstatSync = sinon.stub( fs, 'lstatSync' ); + + const consoleLog = sinon.stub( console, 'log' ); + + const processedPackages = new Set(); + processedPackages.add( 'package-1' ); + processedPackages.add( 'package-2' ); + + mgitOptions.dependencies = { + 'package-1': 'foo/package-1', + 'package-2': 'foo/package-2', + }; + + stubs.repositoryResolver.onFirstCall().returns( { directory: 'package-1' } ); + stubs.repositoryResolver.onSecondCall().returns( { directory: 'package-2' } ); + + stubs.fs.readdirSync.returns( [ + 'package-1', + 'package-2', + 'package-3', + '.DS_Store' + ] ); + + stubs.fs.lstatSync.returns( { + isDirectory() { + return true; + } + } ); + + stubs.fs.lstatSync.withArgs( '/tmp/packages/.DS_Store' ).returns( { + isDirectory() { + return false; + } + } ); + + syncCommand.afterExecute( processedPackages, null, mgitOptions ); + consoleLog.restore(); + + expect( consoleLog.callCount ).to.equal( 3 ); + expect( consoleLog.firstCall.args[ 0 ] ).to.match( /2 packages have been processed\./ ); + expect( consoleLog.secondCall.args[ 0 ] ).to.match( + /Paths to directories listed below are skipped by mgit because they are not defined in "mgit\.json":/ + ); + expect( consoleLog.thirdCall.args[ 0 ] ).to.match( / {2}- .*\/packages\/package-3/ ); + } ); + + it( 'informs about differences between packages in directory and defined in mgit.json for scopes packages', () => { + stubs.fs.lstatSync = sinon.stub( fs, 'lstatSync' ); + + const consoleLog = sinon.stub( console, 'log' ); + + const processedPackages = new Set(); + processedPackages.add( 'package-1' ); + processedPackages.add( 'package-2' ); + + mgitOptions.dependencies = { + 'package-1': 'foo/package-1', + 'package-2': 'foo/package-2', + }; + + stubs.repositoryResolver.onFirstCall().returns( { directory: 'package-1' } ); + stubs.repositoryResolver.onSecondCall().returns( { directory: 'package-2' } ); + + stubs.fs.readdirSync.onFirstCall().returns( [ + 'package-1', + 'package-2', + '@foo', + '.DS_Store' + ] ); + + stubs.fs.readdirSync.onSecondCall().returns( [ + '.DS_Store', + 'package-3' + ] ); + + stubs.fs.lstatSync.returns( { + isDirectory() { + return true; + } + } ); + + stubs.fs.lstatSync.withArgs( '/tmp/packages/@foo/.DS_Store' ).returns( { + isDirectory() { + return false; + } + } ); + + stubs.fs.lstatSync.withArgs( '/tmp/packages/.DS_Store' ).returns( { + isDirectory() { + return false; + } + } ); + + syncCommand.afterExecute( processedPackages, null, mgitOptions ); + consoleLog.restore(); + + expect( consoleLog.callCount ).to.equal( 3 ); + expect( consoleLog.firstCall.args[ 0 ] ).to.match( /2 packages have been processed\./ ); + expect( consoleLog.secondCall.args[ 0 ] ).to.match( + /Paths to directories listed below are skipped by mgit because they are not defined in "mgit\.json":/ + ); + expect( consoleLog.thirdCall.args[ 0 ] ).to.match( / {2}- .*\/packages\/@foo\/package-3/ ); + } ); + + it( 'does not inform about differences between packages in directory and defined in mgit.json if everything seems to be ok', () => { + stubs.fs.lstatSync = sinon.stub( fs, 'lstatSync' ); + + const consoleLog = sinon.stub( console, 'log' ); + + const processedPackages = new Set(); + processedPackages.add( 'package-1' ); + processedPackages.add( 'package-2' ); + + mgitOptions.dependencies = { + 'package-1': 'foo/package-1', + 'package-2': 'foo/package-2', + }; + + stubs.repositoryResolver.onFirstCall().returns( { directory: 'package-1' } ); + stubs.repositoryResolver.onSecondCall().returns( { directory: 'package-2' } ); + + stubs.fs.readdirSync.returns( [ + 'package-1', + 'package-2', + ] ); + + stubs.fs.lstatSync.returns( { + isDirectory() { + return true; + } + } ); + + syncCommand.afterExecute( processedPackages, null, mgitOptions ); + + expect( consoleLog.callCount ).to.equal( 1 ); + expect( consoleLog.firstCall.args[ 0 ] ).to.match( /2 packages have been processed\./ ); + + consoleLog.restore(); + } ); + } ); + + function getCommandLogs( msg, isError = false ) { + const logs = { + error: [], + info: [] + }; + + if ( isError ) { + logs.error.push( msg ); + } else { + logs.info.push( msg ); + } + + return logs; + } +} ); diff --git a/tests/commands/update.js b/tests/commands/update.js deleted file mode 100644 index 0f8224f..0000000 --- a/tests/commands/update.js +++ /dev/null @@ -1,297 +0,0 @@ -/** - * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. - * For licensing, see LICENSE.md. - */ - -/* jshint mocha:true */ - -'use strict'; - -const fs = require( 'fs' ); -const path = require( 'upath' ); -const sinon = require( 'sinon' ); -const mockery = require( 'mockery' ); -const expect = require( 'chai' ).expect; - -describe( 'commands/update', () => { - let updateCommand, stubs, data; - - beforeEach( () => { - mockery.enable( { - useCleanCache: true, - warnOnReplace: false, - warnOnUnregistered: false - } ); - - stubs = { - exec: sinon.stub(), - fs: { - existsSync: sinon.stub( fs, 'existsSync' ) - }, - path: { - join: sinon.stub( path, 'join' ).callsFake( ( ...chunks ) => chunks.join( '/' ) ) - }, - bootstrapCommand: { - execute: sinon.stub() - }, - execCommand: { - execute: sinon.stub() - } - }; - - data = { - packageName: 'test-package', - options: { - cwd: __dirname, - packages: 'packages' - }, - repository: { - directory: 'test-package', - url: 'git@github.com/organization/test-package.git', - branch: 'master' - } - }; - - mockery.registerMock( './exec', stubs.execCommand ); - mockery.registerMock( './bootstrap', stubs.bootstrapCommand ); - - updateCommand = require( '../../lib/commands/update' ); - } ); - - afterEach( () => { - sinon.restore(); - mockery.disable(); - } ); - - describe( 'execute()', () => { - it( 'clones a package if is not available', () => { - stubs.fs.existsSync.returns( false ); - stubs.bootstrapCommand.execute.returns( Promise.resolve( { - logs: getCommandLogs( 'Cloned.' ) - } ) ); - - return updateCommand.execute( data ) - .then( response => { - expect( response.logs.info ).to.deep.equal( [ - 'Package "test-package" was not found. Cloning...', - 'Cloned.' - ] ); - - expect( stubs.bootstrapCommand.execute.calledOnce ).to.equal( true ); - } ); - } ); - - it( 'resolves promise after pulling the changes', () => { - stubs.fs.existsSync.returns( true ); - - const exec = stubs.execCommand.execute; - - exec.onCall( 0 ).returns( Promise.resolve( { - logs: getCommandLogs( '' ) - } ) ); - - exec.onCall( 1 ).returns( Promise.resolve( { - logs: getCommandLogs( '' ) - } ) ); - - exec.onCall( 2 ).returns( Promise.resolve( { - logs: getCommandLogs( 'Already on \'master\'.' ) - } ) ); - - exec.onCall( 3 ).returns( Promise.resolve( { - logs: getCommandLogs( '* master\n remotes/origin/master' ) - } ) ); - - exec.onCall( 4 ).returns( Promise.resolve( { - logs: getCommandLogs( 'Already up-to-date.' ) - } ) ); - - return updateCommand.execute( data ) - .then( response => { - expect( exec.getCall( 0 ).args[ 0 ].arguments[ 0 ] ).to.equal( 'git status -s' ); - expect( exec.getCall( 1 ).args[ 0 ].arguments[ 0 ] ).to.equal( 'git fetch' ); - expect( exec.getCall( 2 ).args[ 0 ].arguments[ 0 ] ).to.equal( 'git checkout master' ); - expect( exec.getCall( 3 ).args[ 0 ].arguments[ 0 ] ).to.equal( 'git branch' ); - expect( exec.getCall( 4 ).args[ 0 ].arguments[ 0 ] ).to.equal( 'git pull origin master' ); - - expect( response.logs.info ).to.deep.equal( [ - 'Already on \'master\'.', - 'Already up-to-date.' - ] ); - - expect( exec.callCount ).to.equal( 5 ); - } ); - } ); - - it( 'aborts if package has uncommitted changes', () => { - stubs.fs.existsSync.returns( true ); - - const exec = stubs.execCommand.execute; - - exec.returns( Promise.resolve( { - logs: getCommandLogs( ' M first-file.js\n ?? second-file.js' ) - } ) ); - - return updateCommand.execute( data ) - .then( - () => { - throw new Error( 'Supposed to be rejected.' ); - }, - response => { - const errMsg = 'Package "test-package" has uncommitted changes. Aborted.'; - - expect( response.logs.error[ 0 ].split( '\n' )[ 0 ] ).to.equal( errMsg ); - expect( exec.firstCall.args[ 0 ].arguments[ 0 ] ).to.equal( 'git status -s' ); - } - ); - } ); - - it( 'does not pull the changes if detached on a commit or a tag', () => { - stubs.fs.existsSync.returns( true ); - - data.repository.branch = '1a0ff0a2ee60549656177cd2a18b057764ec2146'; - - const exec = stubs.execCommand.execute; - - exec.onCall( 0 ).returns( Promise.resolve( { - logs: getCommandLogs( '' ) - } ) ); - - exec.onCall( 1 ).returns( Promise.resolve( { - logs: getCommandLogs( '' ) - } ) ); - - exec.onCall( 2 ).returns( Promise.resolve( { - logs: getCommandLogs( 'Note: checking out \'1a0ff0a2ee60549656177cd2a18b057764ec2146\'.' ) - } ) ); - - exec.onCall( 3 ).returns( Promise.resolve( { - logs: getCommandLogs( [ - '* (HEAD detached at 1a0ff0a2ee60549656177cd2a18b057764ec2146)', - ' master', - ' remotes/origin/master' - ].join( '\n' ) ) - } ) ); - - return updateCommand.execute( data ) - .then( response => { - expect( response.logs.info ).to.deep.equal( [ - 'Note: checking out \'1a0ff0a2ee60549656177cd2a18b057764ec2146\'.', - 'Package "test-package" is on a detached commit.' - ] ); - - expect( exec.callCount ).to.equal( 4 ); - } ); - } ); - - it( 'aborts if user wants to pull changes from non-existing branch', () => { - stubs.fs.existsSync.returns( true ); - - data.repository.branch = 'develop'; - - const exec = stubs.execCommand.execute; - - exec.onCall( 0 ).returns( Promise.resolve( { - logs: getCommandLogs( '' ) - } ) ); - - exec.onCall( 1 ).returns( Promise.resolve( { - logs: getCommandLogs( '' ) - } ) ); - - exec.onCall( 2 ).returns( Promise.resolve( { - logs: getCommandLogs( 'Already on \'develop\'.' ) - } ) ); - - exec.onCall( 3 ).returns( Promise.resolve( { - logs: getCommandLogs( '* develop' ) - } ) ); - - exec.onCall( 4 ).returns( Promise.reject( { - logs: getCommandLogs( 'fatal: Couldn\'t find remote ref develop', true ) - } ) ); - - return updateCommand.execute( data ) - .then( - () => { - throw new Error( 'Supposed to be rejected.' ); - }, - response => { - expect( response.logs.info ).to.deep.equal( [ - 'Already on \'develop\'.' - ] ); - - const errMsg = 'fatal: Couldn\'t find remote ref develop'; - expect( response.logs.error[ 0 ].split( '\n' )[ 0 ] ).to.equal( errMsg ); - - expect( exec.callCount ).to.equal( 5 ); - } - ); - } ); - - it( 'aborts if user wants to check out to non-existing branch', () => { - stubs.fs.existsSync.returns( true ); - - data.repository.branch = 'non-existing-branch'; - - const exec = stubs.execCommand.execute; - - exec.onCall( 0 ).returns( Promise.resolve( { - logs: getCommandLogs( '' ) - } ) ); - - exec.onCall( 1 ).returns( Promise.resolve( { - logs: getCommandLogs( '' ) - } ) ); - - exec.onCall( 2 ).returns( Promise.reject( { - logs: getCommandLogs( 'error: pathspec \'ggdfgd\' did not match any file(s) known to git.', true ), - } ) ); - - return updateCommand.execute( data ) - .then( - () => { - throw new Error( 'Supposed to be rejected.' ); - }, - response => { - const errMsg = 'error: pathspec \'ggdfgd\' did not match any file(s) known to git.'; - expect( response.logs.error[ 0 ].split( '\n' )[ 0 ] ).to.equal( errMsg ); - - expect( exec.callCount ).to.equal( 3 ); - } - ); - } ); - } ); - - describe( 'afterExecute()', () => { - it( 'informs about number of processed packages', () => { - const consoleLog = sinon.stub( console, 'log' ); - - const processedPackages = new Set(); - processedPackages.add( 'package-1' ); - processedPackages.add( 'package-2' ); - - updateCommand.afterExecute( processedPackages ); - - expect( consoleLog.calledOnce ).to.equal( true ); - expect( consoleLog.firstCall.args[ 0 ] ).to.match( /2 packages have been processed\./ ); - - consoleLog.restore(); - } ); - } ); - - function getCommandLogs( msg, isError = false ) { - const logs = { - error: [], - info: [] - }; - - if ( isError ) { - logs.error.push( msg ); - } else { - logs.info.push( msg ); - } - - return logs; - } -} ); diff --git a/tests/fixtures/project-with-options-in-mgitjson/mgit.json b/tests/fixtures/project-with-options-in-mgitjson/mgit.json index a7fcdaa..34b3394 100644 --- a/tests/fixtures/project-with-options-in-mgitjson/mgit.json +++ b/tests/fixtures/project-with-options-in-mgitjson/mgit.json @@ -1,6 +1,5 @@ { "packages": "foo", - "recursive": true, "dependencies": { "simple-package": "a/b" } diff --git a/tests/utils/getoptions.js b/tests/utils/getoptions.js index d4dcefd..e5a389e 100644 --- a/tests/utils/getoptions.js +++ b/tests/utils/getoptions.js @@ -24,7 +24,6 @@ describe( 'utils', () => { expect( options ).to.deep.equal( { cwd, packages: path.resolve( cwd, 'packages' ), - recursive: false, resolverPath: path.resolve( __dirname, '../../lib/default-resolver.js' ), resolverUrlTemplate: 'git@github.com:${ path }.git', resolverTargetDirectory: 'git', @@ -48,7 +47,6 @@ describe( 'utils', () => { expect( options ).to.deep.equal( { cwd, packages: path.resolve( cwd, 'packages' ), - recursive: false, resolverPath: path.resolve( __dirname, '../../lib/default-resolver.js' ), resolverUrlTemplate: 'git@github.com:${ path }.git', resolverTargetDirectory: 'git', @@ -68,7 +66,6 @@ describe( 'utils', () => { }, cwd, packages: path.resolve( cwd, 'foo' ), - recursive: true, resolverPath: path.resolve( __dirname, '../../lib/default-resolver.js' ), resolverUrlTemplate: 'git@github.com:${ path }.git', resolverTargetDirectory: 'git', @@ -91,7 +88,6 @@ describe( 'utils', () => { }, cwd, packages: path.resolve( cwd, 'bar' ), - recursive: true, resolverPath: path.resolve( __dirname, '../../lib/default-resolver.js' ), resolverUrlTemplate: 'a/b/c', resolverTargetDirectory: 'git', diff --git a/tests/utils/gitstatusparser.js b/tests/utils/gitstatusparser.js index 695e54b..ed55195 100644 --- a/tests/utils/gitstatusparser.js +++ b/tests/utils/gitstatusparser.js @@ -35,6 +35,43 @@ const gitStatusResponse = [ describe( 'utils', () => { describe( 'gitStatusParser()', () => { + describe( '#anythingToCommit', () => { + it( 'returns false for untracked files', () => { + const gitStatusResponse = [ + '## master...origin/master', + '?? README.md', + '?? .eslintrc.js' + ].join( '\n' ); + + const status = gitStatusParser( gitStatusResponse ); + + expect( status.anythingToCommit ).to.equal( false ); + } ); + + it( 'returns true for any tracked file', () => { + const gitStatusResponse = [ + '## master...origin/master', + ' M lib/index.js' + ].join( '\n' ); + + const status = gitStatusParser( gitStatusResponse ); + + expect( status.anythingToCommit ).to.equal( true ); + } ); + + it( 'returns true for any tracked file and some untracked', () => { + const gitStatusResponse = [ + '## master...origin/master', + ' M lib/index.js', + '?? README.md', + ].join( '\n' ); + + const status = gitStatusParser( gitStatusResponse ); + + expect( status.anythingToCommit ).to.equal( true ); + } ); + } ); + it( 'returns branch name for freshly created', () => { const status = gitStatusParser( '## master' );