├── .changelog └── .gitkeep ├── .circleci └── config.yml ├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── 1-bug-report.md │ ├── 2-feature-request.md │ ├── 3-improvement.md │ ├── 5-task.md │ ├── 6-docs-issue.md │ └── config.yml └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .husky └── pre-commit ├── CHANGELOG.md ├── README.md ├── codecov.yml ├── eslint.config.mjs ├── index.js ├── lib ├── commands │ ├── checkout.js │ ├── close.js │ ├── commit.js │ ├── diff.js │ ├── exec.js │ ├── fetch.js │ ├── pull.js │ ├── push.js │ ├── save.js │ ├── status.js │ └── sync.js ├── default-resolver.js ├── index.js └── utils │ ├── child-process.js │ ├── createforkpool.js │ ├── displaylog.js │ ├── getcommandinstance.js │ ├── getcwd.js │ ├── getoptions.js │ ├── getpackagenames.js │ ├── gitstatusparser.js │ ├── log.js │ ├── parserepositoryurl.js │ ├── rootrepositoryutils.js │ ├── shell.js │ └── updatejsonfile.js ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── scripts ├── bump-year.mjs ├── ci │ └── is-project-ready-to-release.mjs ├── postinstall.js ├── preparechangelog.mjs ├── preparepackages.mjs ├── publishpackages.mjs └── utils │ ├── constants.mjs │ ├── getlistroptions.mjs │ └── parsearguments.mjs └── tests ├── commands ├── checkout.js ├── close.js ├── commit.js ├── diff.js ├── exec.js ├── fetch.js ├── pull.js ├── push.js ├── save.js ├── status.js └── sync.js ├── default-resolver.js ├── fixtures ├── project-a │ ├── mrgit.json │ └── package.json ├── project-with-custom-config │ └── mrgit-custom.json ├── project-with-defined-root │ └── mrgit.json ├── project-with-options-in-mrgitjson │ ├── mrgit.json │ └── package.json └── project-with-presets │ └── mrgit.json └── utils ├── getcwd.js ├── getoptions.js ├── getpackagenames.js ├── gitstatusparser.js ├── log.js ├── parserepositoryurl.js └── updatejsonfile.js /.changelog/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cksource/mrgit/de9e7c394ea38b3bc91a8f4c1a027ada6156dcc4/.changelog/.gitkeep -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | codecov: codecov/codecov@5.4.3 5 | 6 | parameters: 7 | triggerCommitHash: 8 | type: string 9 | default: "" 10 | isNightly: 11 | type: boolean 12 | default: false 13 | isRelease: 14 | type: boolean 15 | default: false 16 | 17 | commands: 18 | bootstrap_repository_command: 19 | description: "Bootstrap the repository" 20 | steps: 21 | - install_ssh_keys_command 22 | - run: 23 | name: Disable Corepack 24 | command: sudo corepack disable 25 | - run: 26 | name: Install pnpm 27 | command: sudo npm i -g pnpm@^10 28 | - run: 29 | name: Install dependencies 30 | command: pnpm install 31 | 32 | install_ssh_keys_command: 33 | description: "Install SSH keys" 34 | steps: 35 | - add_ssh_keys: 36 | fingerprints: 37 | - "a0:41:a2:56:c8:7d:3f:29:41:d1:87:92:fd:50:2b:6b" 38 | 39 | npm_login_command: 40 | description: "Enable interacting with `npm` using an auth token" 41 | steps: 42 | - run: 43 | name: Login to the npm registry using '.npmrc' file 44 | command: echo "//registry.npmjs.org/:_authToken=\${CKE5_NPM_TOKEN}" > ~/.npmrc 45 | 46 | gpg_credentials_command: 47 | description: "Setup GPG configuration" 48 | steps: 49 | - run: 50 | name: Setup GPG configuration 51 | command: | 52 | #!/bin/bash 53 | 54 | echo "$CKE5_GPG_KEY" | base64 --decode | gpg --import --quiet 55 | 56 | git_credentials_command: 57 | description: "Setup git configuration" 58 | steps: 59 | - gpg_credentials_command 60 | - run: 61 | name: Setup git configuration 62 | command: | 63 | git config --global user.email "ckeditor-bot@cksource.com" 64 | git config --global user.name "CKEditorBot" 65 | git config --global user.signingkey 3F615B600F38B27B 66 | git config --global commit.gpgsign true 67 | git config --global tag.gpgSign true 68 | 69 | jobs: 70 | notify_ci_failure: 71 | docker: 72 | - image: cimg/node:22.12.0 73 | parameters: 74 | hideAuthor: 75 | type: string 76 | default: "false" 77 | steps: 78 | - checkout 79 | - bootstrap_repository_command 80 | - run: 81 | # In the PRs that comes from forked repositories, we do not share secret variables. 82 | # Hence, some of the scripts will not be executed. 83 | name: 👤 Verify if the build was triggered by community - Check if the build should continue 84 | command: | 85 | #!/bin/bash 86 | if [[ -z ${CODECOV_TOKEN} ]]; 87 | then 88 | circleci-agent step halt 89 | fi 90 | - run: 91 | environment: 92 | CKE5_SLACK_NOTIFY_HIDE_AUTHOR: << parameters.hideAuthor >> 93 | CKE5_PIPELINE_NUMBER: << pipeline.number >> 94 | name: Waiting for other jobs to finish and sending notification on failure 95 | command: pnpm ckeditor5-dev-ci-circle-workflow-notifier 96 | no_output_timeout: 1h 97 | 98 | validate_and_tests: 99 | docker: 100 | - image: cimg/node:22.12.0 101 | steps: 102 | - checkout 103 | - bootstrap_repository_command 104 | - run: 105 | name: Execute ESLint 106 | command: pnpm run lint 107 | - run: 108 | name: Run unit tests 109 | command: pnpm run coverage 110 | - unless: 111 | # Upload the code coverage results for non-nightly builds only. 112 | condition: << pipeline.parameters.isNightly >> 113 | steps: 114 | - run: 115 | # In the PRs that comes from forked repositories, we do not share secret variables. 116 | # Hence, some of the scripts will not be executed. 117 | name: 👤 Verify if the build was triggered by community - Check if the build should continue 118 | command: | 119 | #!/bin/bash 120 | 121 | if [[ -z ${CODECOV_TOKEN} ]]; 122 | then 123 | circleci-agent step halt 124 | fi 125 | - codecov/upload: 126 | files: coverage/lcov.info 127 | disable_search: true 128 | 129 | release_prepare: 130 | docker: 131 | - image: cimg/node:22.12.0 132 | steps: 133 | - checkout 134 | - bootstrap_repository_command 135 | - run: 136 | name: Check if packages are ready to be released 137 | command: pnpm run release:prepare-packages --verbose --compile-only 138 | 139 | trigger_release_process: 140 | docker: 141 | - image: cimg/node:22.12.0 142 | steps: 143 | - checkout 144 | - bootstrap_repository_command 145 | - run: 146 | name: Verify if the project is ready to release 147 | command: | 148 | #!/bin/bash 149 | 150 | # Do not fail if the Node script ends with non-zero exit code. 151 | set +e 152 | 153 | node scripts/ci/is-project-ready-to-release.mjs 154 | EXIT_CODE=$( echo $? ) 155 | 156 | if [ ${EXIT_CODE} -eq 1 ]; 157 | then 158 | circleci-agent step halt 159 | fi 160 | - run: 161 | name: Trigger the release pipeline 162 | environment: 163 | CKE5_GITHUB_RELEASE_BRANCH: master 164 | CKE5_GITHUB_REPOSITORY_SLUG: cksource/mrgit 165 | CKE5_COMMIT_SHA1: << pipeline.git.revision >> 166 | command: pnpm ckeditor5-dev-ci-trigger-circle-build 167 | 168 | release_project: 169 | docker: 170 | - image: cimg/node:22.12.0 171 | steps: 172 | - checkout 173 | - bootstrap_repository_command 174 | - run: 175 | name: Verify the trigger commit from the repository 176 | command: | 177 | #!/bin/bash 178 | 179 | CKE5_LATEST_COMMIT_HASH=$( git log -n 1 --pretty=format:%H origin/master ) 180 | CKE5_TRIGGER_COMMIT_HASH=<< pipeline.parameters.triggerCommitHash >> 181 | 182 | if [[ "${CKE5_LATEST_COMMIT_HASH}" != "${CKE5_TRIGGER_COMMIT_HASH}" ]]; then 183 | echo "There is a newer commit in the repository on the \`#master\` branch. Use its build to start the release." 184 | circleci-agent step halt 185 | fi 186 | - npm_login_command 187 | - git_credentials_command 188 | - run: 189 | name: Verify if a releaser triggered the job 190 | environment: 191 | CKE5_CIRCLE_APPROVAL_JOB_NAME: release_approval 192 | CKE5_GITHUB_ORGANIZATION: ckeditor 193 | command: | 194 | #!/bin/bash 195 | 196 | # Do not fail if the Node script ends with non-zero exit code. 197 | set +e 198 | pnpm ckeditor5-dev-ci-is-job-triggered-by-member 199 | EXIT_CODE=$( echo $? ) 200 | 201 | if [ ${EXIT_CODE} -ne 0 ]; 202 | then 203 | echo "Aborting the release due to failed verification of the approver (no rights to release)." 204 | circleci-agent step halt 205 | fi 206 | - run: 207 | name: Disable the redundant workflows option 208 | environment: 209 | CKE5_GITHUB_ORGANIZATION: cksource 210 | CKE5_GITHUB_REPOSITORY: mrgit 211 | command: pnpm ckeditor5-dev-ci-circle-disable-auto-cancel-builds 212 | - run: 213 | name: Prepare the new version to release 214 | command: npm run release:prepare-packages -- --verbose 215 | - run: 216 | name: Publish the packages 217 | command: npm run release:publish-packages -- --verbose 218 | - run: 219 | name: Enable the redundant workflows option 220 | environment: 221 | CKE5_GITHUB_ORGANIZATION: cksource 222 | CKE5_GITHUB_REPOSITORY: mrgit 223 | command: pnpm ckeditor5-dev-ci-circle-enable-auto-cancel-builds 224 | when: always 225 | - run: 226 | name: Pack the "release/" directory (in case of failure) 227 | command: | 228 | zip -r ./release.zip ./release 229 | when: always 230 | - store_artifacts: 231 | path: ./release.zip 232 | when: always 233 | 234 | workflows: 235 | version: 2 236 | main: 237 | when: 238 | and: 239 | - equal: [ false, << pipeline.parameters.isNightly >> ] 240 | - equal: [ false, << pipeline.parameters.isRelease >> ] 241 | jobs: 242 | - validate_and_tests 243 | - release_prepare 244 | - trigger_release_process: 245 | requires: 246 | - validate_and_tests 247 | - release_prepare 248 | filters: 249 | branches: 250 | only: 251 | - master 252 | - notify_ci_failure: 253 | filters: 254 | branches: 255 | only: 256 | - master 257 | 258 | release: 259 | when: 260 | and: 261 | - equal: [ false, << pipeline.parameters.isNightly >> ] 262 | - equal: [ true, << pipeline.parameters.isRelease >> ] 263 | jobs: 264 | - release_approval: 265 | type: approval 266 | - release_project: 267 | requires: 268 | - release_approval 269 | 270 | nightly: 271 | when: 272 | and: 273 | - equal: [ true, << pipeline.parameters.isNightly >> ] 274 | - equal: [ false, << pipeline.parameters.isRelease >> ] 275 | jobs: 276 | - validate_and_tests 277 | - notify_ci_failure: 278 | hideAuthor: "true" 279 | filters: 280 | branches: 281 | only: 282 | - master 283 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Configurations to normalize the IDE behavior. 2 | # http://editorconfig.org/ 3 | 4 | root = true 5 | 6 | [*] 7 | indent_style = tab 8 | tab_width = 4 9 | charset = utf-8 10 | end_of_line = lf 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [*.json] 15 | indent_style = space 16 | tab_width = 2 17 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | *.htaccess eol=lf 4 | *.cgi eol=lf 5 | *.sh eol=lf 6 | 7 | *.css text 8 | *.htm text 9 | *.html text 10 | *.js text 11 | *.json text 12 | *.php text 13 | *.txt text 14 | *.md text 15 | 16 | *.png -text 17 | *.gif -text 18 | *.jpg -text 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41B Bug report" 3 | about: Report that something isn't working as expected. 4 | title: '' 5 | labels: type:bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 📝 Provide detailed reproduction steps (if any) 11 | 12 | 1. … 13 | 2. … 14 | 3. … 15 | 16 | ### ✔️ Expected result 17 | 18 | _What is the expected result of the above steps?_ 19 | 20 | ### ❌ Actual result 21 | 22 | _What is the actual result of the above steps?_ 23 | 24 | ### ❓ Possible solution 25 | 26 | _If you have ideas, you can list them here. Otherwise, you can delete this section._ 27 | 28 | ## 📃 Other details 29 | 30 | * Browser: … 31 | * OS: … 32 | * First affected CKEditor version: … 33 | * Installed CKEditor plugins: … 34 | 35 | --- 36 | 37 | If you'd like to see this fixed sooner, add a 👍 reaction to this post. 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "⭐ Feature request" 3 | about: Propose something new. 4 | title: '' 5 | labels: type:feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 📝 Provide a description of the new feature 11 | 12 | _What is the expected behavior of the proposed feature?_ 13 | 14 | --- 15 | 16 | If you'd like to see this feature implemented, add a 👍 reaction to this post. 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-improvement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "💅 Improvement" 3 | about: Improve an existing functionality. 4 | title: '' 5 | labels: type:improvement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 📝 Provide a description of the improvement 11 | 12 | _How the feature works now and what you'd like to change_? 13 | 14 | ## 📃 Other details 15 | 16 | * Browser: … 17 | * OS: … 18 | * CKEditor version: … 19 | * Installed CKEditor plugins: … 20 | 21 | --- 22 | 23 | If you'd like to see this improvement implemented, add a 👍 reaction to this post. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/5-task.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F477 Task" 3 | about: It's neither a bug nor a feature request. 4 | title: '' 5 | labels: type:task 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Provide a description of the task 11 | 12 | _What steps should be taken to fulfill the task?_ 13 | 14 | ## 📃 Other details 15 | 16 | * Browser: … 17 | * OS: … 18 | * CKEditor version: … 19 | * Installed CKEditor plugins: … 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/6-docs-issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F4DA Docs issue" 3 | about: Report an issue related to documentation. 4 | title: '' 5 | labels: type:docs 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 📝 Provide a description of requested docs changes 11 | 12 | _What is the purpose and what should be changed?_ 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 13 | 14 | ### 🚀 Summary 15 | 16 | *A brief summary of what this PR changes.* 17 | 18 | --- 19 | 20 | ### 📌 Related issues 21 | 22 | 27 | 28 | * Closes #000 29 | 30 | --- 31 | 32 | ### 💡 Additional information 33 | 34 | *Optional: Notes on decisions, edge cases, or anything helpful for reviewers.* 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .nyc_output 2 | coverage/ 3 | node_modules/ 4 | .idea 5 | yarn.lock 6 | 7 | # Prepared files to publish to npm. 8 | /release/ 9 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 4 | # For licensing, see LICENSE.md. 5 | 6 | . "$(dirname -- "$0")/_/husky.sh" 7 | 8 | pnpm lint-staged 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | ## [4.1.0](https://github.com/cksource/mrgit/compare/v4.0.0...v4.1.0) (September 16, 2025) 5 | 6 | ### Features 7 | 8 | * Introduced `--config` CLI parameter which allows providing custom configuration filename that will be used instead of the default `mrgit.json` file. Closes [#189](https://github.com/cksource/mrgit/issues/189). 9 | 10 | 11 | ## [4.0.0](https://github.com/cksource/mrgit/compare/v3.0.0...v4.0.0) (June 26, 2025) 12 | 13 | ### BREAKING CHANGES 14 | 15 | * Updated the required version of Node.js to 22 after bumping all `@ckeditor/ckeditor5-dev-*` packages to the latest `^50.0.0` version. 16 | 17 | ### Bug fixes 18 | 19 | * The `mrgit status` command should not print an error when processing a repository without tags or with a partially cloned history that causes tags to be assigned to non-existing commits. Closes [#179](https://github.com/cksource/mrgit/issues/179). 20 | 21 | 22 | ## [3.0.0](https://github.com/cksource/mrgit/compare/v2.1.0...v3.0.0) (2025-03-14) 23 | 24 | ### BREAKING CHANGES 25 | 26 | * Upgraded the minimal version of Node.js to 20.0.0 due to the end of LTS. 27 | 28 | ### Other changes 29 | 30 | * Updated the required version of Node.js to 20. ([commit](https://github.com/cksource/mrgit/commit/1f598905e2da7b7fe9fdf9fdfea22d43d9ae9cc3)) 31 | 32 | 33 | ## [2.1.0](https://github.com/cksource/mrgit/compare/v2.0.3...v2.1.0) (2023-08-28) 34 | 35 | ### Features 36 | 37 | * Added support for executing commands in the root repository. Closes [#160](https://github.com/cksource/mrgit/issues/160). ([commit](https://github.com/cksource/mrgit/commit/2271a029d30cba2abd7209888361e2fde646e748)) 38 | 39 | Add [the `$rootRepository` option](https://github.com/cksource/mrgit/#the-rootrepository-option) to the `mrgit.json` configuration file to enable this feature. Its value should be a repository GitHub identifier (the same as defining the `dependencies` values). You can also define the option within [the preset feature](https://github.com/cksource/mrgit/#the-presets-option). 40 | 41 | Below, you can find a list of supported commands that take into consideration the root repository if specified: 42 | 43 | * `checkout` 44 | * `commit` 45 | * `diff` 46 | * `exec` 47 | * `fetch` 48 | * `pull` 49 | * `push` 50 | * `status` 51 | * `sync` 52 | 53 | To disable executing a command in the root repository without modifying the configuration file, you can add the `--skip-root` modifier to mrgit. Example: `mrgit status --skip-root`. 54 | 55 | 56 | ## [2.0.3](https://github.com/cksource/mrgit/compare/v2.0.2...v2.0.3) (2023-06-05) 57 | 58 | Internal changes only (updated dependencies, documentation, etc.). 59 | 60 | --- 61 | 62 | To see all releases, visit the [release page](https://github.com/cksource/mrgit/releases). 63 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | import globals from 'globals'; 7 | import { defineConfig } from 'eslint/config'; 8 | import ckeditor5Rules from 'eslint-plugin-ckeditor5-rules'; 9 | import ckeditor5Config from 'eslint-config-ckeditor5'; 10 | 11 | export default defineConfig( [ 12 | { 13 | ignores: [ 14 | 'build/**', 15 | 'external/**', 16 | 'coverage/**', 17 | 'release/**' 18 | ] 19 | }, 20 | { 21 | extends: ckeditor5Config, 22 | languageOptions: { 23 | ecmaVersion: 'latest', 24 | sourceType: 'module', 25 | globals: { 26 | ...globals.node 27 | } 28 | }, 29 | 30 | linterOptions: { 31 | reportUnusedDisableDirectives: 'warn', 32 | reportUnusedInlineConfigs: 'warn' 33 | }, 34 | 35 | plugins: { 36 | 'ckeditor5-rules': ckeditor5Rules 37 | }, 38 | 39 | rules: { 40 | 'ckeditor5-rules/license-header': [ 'error', { headerLines: [ 41 | '/**', 42 | ' * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.', 43 | ' * For licensing, see LICENSE.md.', 44 | ' */' 45 | ] } ] 46 | } 47 | } 48 | ] ); 49 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 5 | * For licensing, see LICENSE.md. 6 | */ 7 | 8 | 'use strict'; 9 | 10 | const chalk = require( 'chalk' ); 11 | const meow = require( 'meow' ); 12 | const mrgit = require( './lib/index' ); 13 | const getCommandInstance = require( './lib/utils/getcommandinstance' ); 14 | 15 | handleCli(); 16 | 17 | function handleCli() { 18 | const meowOptions = { 19 | autoHelp: false, 20 | flags: { 21 | version: { 22 | alias: 'v' 23 | }, 24 | help: { 25 | alias: 'h' 26 | }, 27 | branch: { 28 | alias: 'b' 29 | }, 30 | message: { 31 | alias: 'm' 32 | } 33 | } 34 | }; 35 | 36 | const logo = ` 37 | _ _ 38 | (_) | 39 | _ __ ___ _ __ __ _ _| |_ 40 | | '_ \` _ \\| '__| / _\` | | __| 41 | | | | | | | | _ | (_| | | |_ 42 | |_| |_| |_|_|(_) \\__, |_|\\__| 43 | __/ | 44 | |___/ 45 | `; 46 | 47 | const { 48 | cyan: c, 49 | gray: g, 50 | magenta: m, 51 | underline: u, 52 | yellow: y 53 | } = chalk; 54 | 55 | const cli = meow( `${ logo } 56 | ${ u( 'Usage:' ) } 57 | $ mrgit ${ c( 'command' ) } ${ y( '[--options]' ) } -- ${ m( '[--git-options]' ) } 58 | 59 | ${ u( 'Commands:' ) } 60 | ${ c( 'checkout' ) } Changes branches in repositories according to the configuration file. 61 | ${ c( 'close' ) } Merges specified branch with the current one and remove merged branch from the remote. 62 | ${ c( 'commit' ) } Commits all changes. A shorthand for "mrgit exec 'git commit -a'". 63 | ${ c( 'diff' ) } Prints changes from packages where something has changed. 64 | ${ c( 'exec' ) } Executes shell command in each package. 65 | ${ c( 'fetch' ) } Fetches existing repositories. 66 | ${ c( 'pull' ) } Pulls changes in existing repositories. 67 | ${ c( 'push' ) } Pushes changes in existing repositories to remotes. 68 | ${ c( 'save' ) } Saves hashes of packages in a configuration file. 69 | Useful for restoring the project to a specific state. 70 | ${ c( 'status' ) } Prints a table which contains useful information about the status of repositories. 71 | ${ c( 'sync' ) } Updates packages to the latest versions or install missing ones. 72 | 73 | 74 | ${ u( 'Options:' ) } 75 | ${ y( '--config' ) } Name or path to custom configuration file. 76 | ${ g( 'Default: \'/mrgit.json\'' ) } 77 | 78 | ${ y( '--branch' ) } For "${ u( 'save' ) }" command: whether to save branch names. 79 | For "${ u( 'checkout' ) }" command: name of branch that would be created. 80 | 81 | ${ y( '--hash' ) } Whether to save current commit hashes. Used only by "${ u( 'save' ) }" command. 82 | 83 | ${ y( '--ignore' ) } Ignores packages which names match the given glob pattern. E.g.: 84 | ${ g( '> mrgit exec --ignore="foo*" "git status"' ) } 85 | 86 | Will ignore all packages which names start from "foo". 87 | ${ g( 'Default: null' ) } 88 | 89 | ${ y( '--message' ) } Message that will be used as an option for git command. Required for "${ u( 'commit' ) }" 90 | command but it is also used by "${ u( 'close' ) }" command (append the message to the default). 91 | 92 | ${ y( '--packages' ) } Directory to which all repositories will be cloned or are already installed. 93 | ${ g( 'Default: \'/packages/\'' ) } 94 | 95 | ${ y( '--recursive' ) } Whether to install dependencies recursively. Used only by "${ u( 'sync' ) }" command. 96 | 97 | ${ y( '--resolver-path' ) } Path to a custom repository resolver function. 98 | ${ g( 'Default: \'@mrgit/lib/default-resolver.js\'' ) } 99 | 100 | ${ y( '--resolver-url-template' ) } Template used to generate repository URL out of a 101 | simplified 'organization/repository' format of the dependencies option. 102 | ${ g( 'Default: \'git@github.com:${ path }.git\'.' ) } 103 | 104 | ${ y( '--resolver-directory-name' ) } Defines how the target directory (where the repository will be cloned) 105 | is resolved. Supported options are: 'git' (default), 'npm'. 106 | 107 | * If 'git' was specified, then the directory name will be extracted from 108 | the git URL (e.g. for 'git@github.com:a/b.git' it will be 'b'). 109 | * If 'npm' was specified, then the package name will be used as a directory name. 110 | 111 | This option can be useful when scoped npm packages are used and one wants to decide 112 | whether the repository will be cloned to packages/@scope/pkgname' or 'packages/pkgname'. 113 | ${ g( 'Default: \'git\'' ) } 114 | 115 | ${ y( '--resolver-default-branch' ) } The branch name to use if not specified in dependencies in configuration file. 116 | ${ g( 'Default: master' ) } 117 | 118 | ${ y( '--scope' ) } Restricts the command to packages which names match the given glob pattern. 119 | ${ g( 'Default: null' ) } 120 | 121 | ${ y( '--preset' ) } Uses an alternative set of dependencies defined in the config file. 122 | 123 | ${ y( '--skip-root' ) } Allows skipping root repository when executing command, 124 | if "${ u( '$rootRepository' ) }" is defined in the config file. 125 | 126 | ${ u( 'Git Options:' ) } 127 | Git options are supported by the following commands: commit, diff, fetch, push. 128 | Type "mrgit [command] -h" in order to see which options are supported. 129 | `, meowOptions ); 130 | 131 | const commandName = cli.input[ 0 ]; 132 | 133 | // If user specified a command and `--help` flag wasn't active. 134 | if ( commandName && !cli.flags.help ) { 135 | return mrgit( cli.input, cli.flags ); 136 | } 137 | 138 | // A user wants to see "help" screen. 139 | // Missing command. Displays help screen for the entire Mr. Git. 140 | if ( !commandName ) { 141 | return cli.showHelp( 0 ); 142 | } 143 | 144 | const commandInstance = getCommandInstance( commandName ); 145 | 146 | if ( !commandInstance ) { 147 | process.errorCode = 1; 148 | 149 | return; 150 | } 151 | 152 | // Specified command is available, displays the command's help. 153 | console.log( logo ); 154 | console.log( ` ${ u( 'Command:' ) } ${ c( commandInstance.name || commandName ) } ` ); 155 | console.log( commandInstance.helpMessage ); 156 | } 157 | -------------------------------------------------------------------------------- /lib/commands/checkout.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const chalk = require( 'chalk' ); 9 | const execCommand = require( './exec' ); 10 | const gitStatusParser = require( '../utils/gitstatusparser' ); 11 | 12 | module.exports = { 13 | name: 'checkout', 14 | 15 | get helpMessage() { 16 | const { 17 | gray: g, 18 | underline: u, 19 | yellow: y 20 | } = chalk; 21 | 22 | return ` 23 | ${ u( 'Description:' ) } 24 | Checks out the repository to specified branch or commit saved in configuration file. 25 | 26 | If specified a branch as an argument for "checkout" command, mrgit will use the branch 27 | instead of data saved in configuration file. E.g "${ g( 'mrgit checkout master' ) }" will check out 28 | all branches to "master". 29 | 30 | You can also call "${ g( 'mrgit checkout .' ) }" in order to restore files before changes. 31 | 32 | ${ u( 'Options:' ) } 33 | ${ y( '--branch' ) } (-b) If specified, mrgit will create given branch in all repositories 34 | that contain changes that could be committed. 35 | ${ g( '> mrgit checkout --branch develop' ) } 36 | `; 37 | }, 38 | 39 | /** 40 | * @param {CommandData} data 41 | * @returns {Promise} 42 | */ 43 | execute( data ) { 44 | // Used `--branch` option. 45 | if ( data.toolOptions.branch ) { 46 | return this._createAndCheckout( data.toolOptions.branch, data ); 47 | } 48 | 49 | const branch = data.arguments[ 0 ] || data.repository.branch; 50 | const checkoutCommand = `git checkout ${ branch }`; 51 | 52 | return execCommand.execute( this._getExecData( checkoutCommand, data ) ); 53 | }, 54 | 55 | /** 56 | * Executes "git checkout -b `branch`" command if a repository contains changes which could be committed. 57 | * 58 | * @private 59 | * @param {String} branch 60 | * @param {CommandData} data 61 | * @returns {Promise} 62 | */ 63 | _createAndCheckout( branch, data ) { 64 | const log = require( '../utils/log' )(); 65 | 66 | return execCommand.execute( this._getExecData( 'git status --branch --porcelain', data ) ) 67 | .then( execResponse => { 68 | const status = gitStatusParser( execResponse.logs.info[ 0 ] ); 69 | 70 | if ( !status.anythingToCommit ) { 71 | log.info( 'Repository does not contain changes to commit. New branch was not created.' ); 72 | 73 | return { 74 | logs: log.all() 75 | }; 76 | } 77 | 78 | const checkoutCommand = `git checkout -b ${ branch }`; 79 | 80 | return execCommand.execute( this._getExecData( checkoutCommand, data ) ); 81 | } ); 82 | }, 83 | 84 | /** 85 | * Prepares new configuration object for "execute" command which is called inside this command. 86 | * 87 | * @private 88 | * @param {String} command 89 | * @param {CommandData} data 90 | * @returns {CommandData} 91 | */ 92 | _getExecData( command, data ) { 93 | return Object.assign( {}, data, { 94 | arguments: [ command ] 95 | } ); 96 | } 97 | }; 98 | -------------------------------------------------------------------------------- /lib/commands/close.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const chalk = require( 'chalk' ); 9 | const buildOptions = require( 'minimist-options' ); 10 | const minimist = require( 'minimist' ); 11 | 12 | module.exports = { 13 | name: 'close', 14 | 15 | get helpMessage() { 16 | const { 17 | italic: i, 18 | gray: g, 19 | magenta: m, 20 | underline: u 21 | } = chalk; 22 | 23 | return ` 24 | ${ u( 'Description:' ) } 25 | Merges specified branch with the current which on the repository is checked out. 26 | 27 | Merge is executed only on repositories where specified branch exists. 28 | 29 | The merge commit will be made using following message: "${ i( 'Merge branch \'branch-name\'' ) }". 30 | 31 | After merging, specified branch will be removed from the remote and local registry. 32 | 33 | ${ u( 'Options:' ) } 34 | ${ m( '--message' ) } (-m) An additional description for merge commit. It will be 35 | appended to the default message. E.g.: 36 | ${ g( '> mrgit merge develop -- -m "Some description about merged changes."' ) } 37 | `; 38 | }, 39 | 40 | beforeExecute( args ) { 41 | if ( !args[ 1 ] ) { 42 | throw new Error( 'Missing branch to merge. Use: mrgit close [branch].' ); 43 | } 44 | }, 45 | 46 | /** 47 | * @param {CommandData} data 48 | * @returns {Promise} 49 | */ 50 | execute( data ) { 51 | const log = require( '../utils/log' )(); 52 | const execCommand = require( './exec' ); 53 | const branch = data.arguments[ 0 ]; 54 | 55 | return execCommand.execute( getExecData( `git branch --list ${ branch }` ) ) 56 | .then( async execResponse => { 57 | const branchExists = Boolean( execResponse.logs.info[ 0 ] ); 58 | 59 | if ( !branchExists ) { 60 | log.info( 'Branch does not exist.' ); 61 | 62 | return { 63 | logs: log.all() 64 | }; 65 | } 66 | 67 | const commandResponse = await execCommand.execute( getExecData( 'git branch --show-current' ) ); 68 | const detachedHead = !commandResponse.logs.info[ 0 ]; 69 | 70 | if ( detachedHead ) { 71 | log.info( 'This repository is currently in detached head mode - skipping.' ); 72 | 73 | return { 74 | logs: log.all() 75 | }; 76 | } 77 | 78 | const mergeMessage = this._getMergeMessage( data.toolOptions, data.arguments ); 79 | const commitTitle = `Merge branch '${ branch }'`; 80 | 81 | let mergeCommand = `git merge ${ branch } --no-ff -m "${ commitTitle }"`; 82 | 83 | if ( mergeMessage.length ) { 84 | mergeCommand += ' ' + mergeMessage.map( message => `-m "${ message }"` ).join( ' ' ); 85 | } 86 | 87 | return execCommand.execute( getExecData( mergeCommand ) ) 88 | .then( execResponse => { 89 | log.concat( execResponse.logs ); 90 | log.info( `Removing "${ branch }" branch from the local registry.` ); 91 | 92 | return execCommand.execute( getExecData( `git branch -d ${ branch }` ) ); 93 | } ) 94 | .then( execResponse => { 95 | log.concat( execResponse.logs ); 96 | log.info( `Removing "${ branch }" branch from the remote.` ); 97 | 98 | return execCommand.execute( getExecData( `git push origin :${ branch }` ) ); 99 | } ) 100 | .then( execResponse => { 101 | log.concat( execResponse.logs ); 102 | 103 | return { logs: log.all() }; 104 | } ); 105 | } ); 106 | 107 | function getExecData( command ) { 108 | return Object.assign( {}, data, { 109 | arguments: [ command ] 110 | } ); 111 | } 112 | }, 113 | 114 | /** 115 | * @private 116 | * @param {options} toolOptions Options resolved by mrgit. 117 | * @param {Array.>} argv List of arguments provided by the user via CLI. 118 | * @returns {Array.} 119 | */ 120 | _getMergeMessage( toolOptions, argv ) { 121 | const cliOptions = this._parseArguments( argv ); 122 | 123 | let message; 124 | 125 | if ( toolOptions.message ) { 126 | message = toolOptions.message; 127 | } else if ( cliOptions.message ) { 128 | message = cliOptions.message; 129 | } else { 130 | return []; 131 | } 132 | 133 | /* istanbul ignore else */ 134 | if ( !Array.isArray( message ) ) { 135 | message = [ message ].filter( Boolean ); 136 | } 137 | 138 | return message; 139 | }, 140 | 141 | /** 142 | * @private 143 | * @param {Array.} argv List of arguments provided by the user via CLI. 144 | * @returns {Object} 145 | */ 146 | _parseArguments( argv ) { 147 | return minimist( argv, buildOptions( { 148 | message: { 149 | type: 'string', 150 | alias: 'm' 151 | } 152 | } ) ); 153 | } 154 | }; 155 | -------------------------------------------------------------------------------- /lib/commands/commit.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const chalk = require( 'chalk' ); 9 | const buildOptions = require( 'minimist-options' ); 10 | const minimist = require( 'minimist' ); 11 | const gitStatusParser = require( '../utils/gitstatusparser' ); 12 | 13 | module.exports = { 14 | name: 'commit', 15 | 16 | get helpMessage() { 17 | const { 18 | italic: i, 19 | gray: g, 20 | magenta: m, 21 | underline: u, 22 | yellow: y 23 | } = chalk; 24 | 25 | return ` 26 | ${ u( 'Description:' ) } 27 | Makes a commit in every repository that contains tracked files that have changed. 28 | This command is a shorthand for: "${ i( 'mrgit exec \'git commit -a\'' ) }". 29 | 30 | ${ u( 'Options:' ) } 31 | ${ y( '--message' ) } (-m) Required. A message for the commit. It can be specified more then once, e.g.: 32 | ${ g( '> mrgit commit --message "Title of the commit." --message "Additional description."' ) } 33 | 34 | ${ u( 'Git Options:' ) } 35 | ${ m( '--no-verify' ) } (-n) Whether to skip pre-commit and commit-msg hooks. 36 | ${ g( '> mrgit commit -m "Title of the commit." -- -n' ) } 37 | `; 38 | }, 39 | 40 | /** 41 | * @param {Array.} args Arguments and options that a user provided calling the command. 42 | * @param {Options} toolOptions Options resolved by mrgit. 43 | */ 44 | beforeExecute( args, toolOptions ) { 45 | const cliOptions = this._parseArguments( args ); 46 | const commitMessage = this._getCommitMessage( toolOptions, cliOptions ); 47 | 48 | if ( !commitMessage.length ) { 49 | throw new Error( 'Missing --message (-m) option. Call "mrgit commit -h" in order to read more.' ); 50 | } 51 | }, 52 | 53 | /** 54 | * @param {CommandData} data 55 | * @returns {Promise} 56 | */ 57 | execute( data ) { 58 | const log = require( '../utils/log' )(); 59 | const execCommand = require( './exec' ); 60 | 61 | return execCommand.execute( getExecData( 'git status --branch --porcelain' ) ) 62 | .then( execResponse => { 63 | const status = gitStatusParser( execResponse.logs.info[ 0 ] ); 64 | 65 | if ( status.detachedHead ) { 66 | log.info( 'This repository is currently in detached head mode - skipping.' ); 67 | 68 | return { 69 | logs: log.all() 70 | }; 71 | } 72 | 73 | if ( !status.anythingToCommit ) { 74 | log.info( 'Nothing to commit.' ); 75 | 76 | return { 77 | logs: log.all() 78 | }; 79 | } 80 | 81 | const cliOptions = this._parseArguments( data.arguments ); 82 | const commitCommand = this._buildCliCommand( data.toolOptions, cliOptions ); 83 | 84 | return execCommand.execute( getExecData( commitCommand ) ); 85 | } ); 86 | 87 | function getExecData( command ) { 88 | return Object.assign( {}, data, { 89 | arguments: [ command ] 90 | } ); 91 | } 92 | }, 93 | 94 | /** 95 | * @private 96 | * @param {options} toolOptions Options resolved by mrgit. 97 | * @param {Object} cliOptions Parsed arguments provided by the user via CLI. 98 | * @returns {String} 99 | */ 100 | _buildCliCommand( toolOptions, cliOptions ) { 101 | const commitMessage = this._getCommitMessage( toolOptions, cliOptions ); 102 | let command = 'git commit -a'; 103 | 104 | command += ' ' + commitMessage.map( message => `-m "${ message }"` ).join( ' ' ); 105 | 106 | if ( cliOptions[ 'no-verify' ] ) { 107 | command += ' -n'; 108 | } 109 | 110 | return command; 111 | }, 112 | 113 | /** 114 | * @private 115 | * @param {Options} toolOptions Options resolved by mrgit. 116 | * @param {Object} cliOptions Parsed arguments provided by the user via CLI. 117 | * @returns {Array.} 118 | */ 119 | _getCommitMessage( toolOptions, cliOptions ) { 120 | let message; 121 | 122 | if ( toolOptions.message ) { 123 | message = toolOptions.message; 124 | } else if ( cliOptions.message ) { 125 | message = cliOptions.message; 126 | } else { 127 | return []; 128 | } 129 | 130 | /* istanbul ignore else */ 131 | if ( !Array.isArray( message ) ) { 132 | message = [ message ].filter( Boolean ); 133 | } 134 | 135 | return message; 136 | }, 137 | 138 | /** 139 | * @private 140 | * @param {Array.} argv List of arguments provided by the user via CLI. 141 | * @returns {Object} 142 | */ 143 | _parseArguments( argv ) { 144 | const options = minimist( argv, buildOptions( { 145 | message: { 146 | type: 'string', 147 | alias: 'm' 148 | }, 149 | 'no-verify': { 150 | type: 'boolean', 151 | alias: 'n', 152 | default: false 153 | } 154 | } ) ); 155 | 156 | /* istanbul ignore else */ 157 | if ( !Array.isArray( options.message ) ) { 158 | options.message = [ options.message ].filter( Boolean ); 159 | } 160 | 161 | return options; 162 | } 163 | }; 164 | -------------------------------------------------------------------------------- /lib/commands/diff.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const chalk = require( 'chalk' ); 9 | 10 | module.exports = { 11 | name: 'diff', 12 | 13 | skipCounter: true, 14 | 15 | get helpMessage() { 16 | const { 17 | italic: i, 18 | gray: g, 19 | magenta: m, 20 | underline: u 21 | } = chalk; 22 | 23 | return ` 24 | ${ u( 'Description:' ) } 25 | Shows changes between commits, commit and working tree, etc. Works the same as "${ i( 'git diff' ) }" command. By default a flag 26 | "${ m( '--color' ) }" is adding. You can cancel it using option "${ m( '--no-color' ) }". 27 | 28 | ${ u( 'Git Options:' ) } 29 | All options accepted by "${ i( 'git diff' ) }" are supported by mrgit. Everything specified after "--" is passed directly to the 30 | "${ i( 'git diff' ) }" command. 31 | 32 | E.g.: "${ g( 'mrgit diff -- origin/master..master' ) }" will execute "${ i( 'git diff --color origin/master..master' ) }" 33 | `; 34 | }, 35 | 36 | beforeExecute() { 37 | console.log( chalk.blue( 'Collecting changes...' ) ); 38 | }, 39 | 40 | /** 41 | * @param {CommandData} data 42 | * @returns {Promise} 43 | */ 44 | execute( data ) { 45 | const execCommand = require( './exec' ); 46 | const diffCommand = ( 'git diff --color ' + data.arguments.join( ' ' ) ).trim(); 47 | 48 | return execCommand.execute( getExecData( diffCommand ) ) 49 | .then( execResponse => { 50 | if ( !execResponse.logs.info.length ) { 51 | return {}; 52 | } 53 | 54 | return execResponse; 55 | } ); 56 | 57 | function getExecData( command ) { 58 | return Object.assign( {}, data, { 59 | arguments: [ command ] 60 | } ); 61 | } 62 | }, 63 | 64 | afterExecute() { 65 | console.log( chalk.blue.italic( 'Logs are displayed from repositories which contain any change.' ) ); 66 | } 67 | }; 68 | -------------------------------------------------------------------------------- /lib/commands/exec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const fs = require( 'fs' ); 9 | const path = require( 'upath' ); 10 | const chalk = require( 'chalk' ); 11 | const shell = require( '../utils/shell' ); 12 | 13 | module.exports = { 14 | name: 'exec', 15 | 16 | get helpMessage() { 17 | const { 18 | italic: i, 19 | gray: g, 20 | underline: u 21 | } = chalk; 22 | 23 | return ` 24 | ${ u( 'Description:' ) } 25 | Requires a command that will be executed on all repositories. E.g. "${ g( 'mrgit exec pwd' ) }" will execute "${ i( 'pwd' ) }" 26 | command in every repository. Commands that contain spaces must be wrapped in quotation marks, 27 | e.g.: "${ g( 'mrgit exec "git remote"' ) }". 28 | `; 29 | }, 30 | 31 | /** 32 | * @param {Array.} args Arguments that user provided calling the mrgit. 33 | */ 34 | beforeExecute( args ) { 35 | if ( args.length === 1 ) { 36 | throw new Error( 'Missing command to execute. Use: mrgit exec [command-to-execute].' ); 37 | } 38 | }, 39 | 40 | /** 41 | * @param {CommandData} data 42 | * @returns {Promise} 43 | */ 44 | execute( data ) { 45 | const log = require( '../utils/log' )(); 46 | 47 | return new Promise( ( resolve, reject ) => { 48 | if ( !data.isRootRepository ) { 49 | const newCwd = path.join( data.toolOptions.packages, data.repository.directory ); 50 | 51 | // Package does not exist. 52 | if ( !fs.existsSync( newCwd ) ) { 53 | log.error( `Package "${ data.packageName }" is not available. Run "mrgit sync" in order to download the package.` ); 54 | 55 | return reject( { logs: log.all() } ); 56 | } 57 | 58 | process.chdir( newCwd ); 59 | } 60 | 61 | shell( data.arguments[ 0 ] ) 62 | .then( stdout => { 63 | process.chdir( data.toolOptions.cwd ); 64 | 65 | log.info( stdout ); 66 | 67 | resolve( { logs: log.all() } ); 68 | } ) 69 | .catch( error => { 70 | process.chdir( data.toolOptions.cwd ); 71 | 72 | log.error( error ); 73 | 74 | reject( { logs: log.all() } ); 75 | } ); 76 | } ); 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /lib/commands/fetch.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const fs = require( 'fs' ); 9 | const path = require( 'upath' ); 10 | const chalk = require( 'chalk' ); 11 | const buildOptions = require( 'minimist-options' ); 12 | const minimist = require( 'minimist' ); 13 | 14 | module.exports = { 15 | name: 'fetch', 16 | 17 | skipCounter: true, 18 | 19 | get helpMessage() { 20 | const { 21 | italic: i, 22 | gray: g, 23 | magenta: m, 24 | underline: u 25 | } = chalk; 26 | 27 | return ` 28 | ${ u( 'Description:' ) } 29 | Download objects and refs from the remote repository. If some package is missed, the command will not be executed. 30 | For cloned repositories this command is a shorthand for: "${ i( 'mrgit exec \'git fetch\'' ) }". 31 | 32 | ${ u( 'Git Options:' ) } 33 | ${ m( '--prune' ) } (-p) Before fetching, remove any remote-tracking references that 34 | no longer exist on the remote. 35 | ${ g( 'Default: false' ) } 36 | `; 37 | }, 38 | 39 | /** 40 | * @param {CommandData} data 41 | * @returns {Promise} 42 | */ 43 | execute( data ) { 44 | const log = require( '../utils/log' )(); 45 | const execCommand = require( './exec' ); 46 | 47 | if ( !data.isRootRepository ) { 48 | const destinationPath = path.join( data.toolOptions.packages, data.repository.directory ); 49 | 50 | // Package is not cloned. 51 | if ( !fs.existsSync( destinationPath ) ) { 52 | return Promise.resolve( {} ); 53 | } 54 | } 55 | 56 | const options = this._parseArguments( data.arguments ); 57 | let command = 'git fetch'; 58 | 59 | if ( options.prune ) { 60 | command += ' -p'; 61 | } 62 | 63 | return execCommand.execute( getExecData( command ) ) 64 | .then( execResponse => { 65 | if ( execResponse.logs.info.length ) { 66 | return execResponse; 67 | } 68 | 69 | log.info( 'Repository is up to date.' ); 70 | 71 | return { logs: log.all() }; 72 | } ); 73 | 74 | function getExecData( command ) { 75 | return Object.assign( {}, data, { 76 | arguments: [ command ] 77 | } ); 78 | } 79 | }, 80 | 81 | /** 82 | * @param {Set} parsedPackages Collection of processed packages. 83 | */ 84 | afterExecute( parsedPackages ) { 85 | console.log( chalk.cyan( `${ parsedPackages.size } packages have been processed.` ) ); 86 | }, 87 | 88 | /** 89 | * @private 90 | * @param {Array.} argv List of arguments provided by the user via CLI. 91 | * @returns {Object} 92 | */ 93 | _parseArguments( argv ) { 94 | return minimist( argv, buildOptions( { 95 | prune: { 96 | type: 'boolean', 97 | alias: 'p' 98 | } 99 | } ) ); 100 | } 101 | }; 102 | -------------------------------------------------------------------------------- /lib/commands/pull.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const fs = require( 'fs' ); 9 | const path = require( 'upath' ); 10 | const chalk = require( 'chalk' ); 11 | 12 | module.exports = { 13 | name: 'pull', 14 | 15 | skipCounter: true, 16 | 17 | get helpMessage() { 18 | const { 19 | italic: i, 20 | underline: u 21 | } = chalk; 22 | 23 | return ` 24 | ${ u( 'Description:' ) } 25 | Pull changes in all packages. If some package is missed, the command will not be executed. 26 | For cloned repositories this command is a shorthand for: "${ i( 'mrgit exec \'git pull\'' ) }". 27 | `; 28 | }, 29 | 30 | /** 31 | * @param {CommandData} data 32 | * @returns {Promise} 33 | */ 34 | async execute( data ) { 35 | const execCommand = require( './exec' ); 36 | 37 | if ( !data.isRootRepository ) { 38 | const destinationPath = path.join( data.toolOptions.packages, data.repository.directory ); 39 | 40 | // Package is not cloned. 41 | if ( !fs.existsSync( destinationPath ) ) { 42 | return Promise.resolve( {} ); 43 | } 44 | } 45 | 46 | const commandResponse = await execCommand.execute( getExecData( 'git branch --show-current' ) ); 47 | const currentlyOnBranch = Boolean( commandResponse.logs.info[ 0 ] ); 48 | 49 | if ( !currentlyOnBranch ) { 50 | return Promise.resolve( { 51 | logs: { 52 | error: [], 53 | info: [ 'This repository is currently in detached head mode - skipping.' ] 54 | } 55 | } ); 56 | } 57 | 58 | return execCommand.execute( getExecData( 'git pull' ) ); 59 | 60 | function getExecData( command ) { 61 | return Object.assign( {}, data, { 62 | arguments: [ command ] 63 | } ); 64 | } 65 | }, 66 | 67 | /** 68 | * @param {Set} parsedPackages Collection of processed packages. 69 | */ 70 | afterExecute( parsedPackages ) { 71 | console.log( chalk.cyan( `${ parsedPackages.size } packages have been processed.` ) ); 72 | } 73 | }; 74 | -------------------------------------------------------------------------------- /lib/commands/push.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const fs = require( 'fs' ); 9 | const path = require( 'upath' ); 10 | const chalk = require( 'chalk' ); 11 | 12 | module.exports = { 13 | name: 'push', 14 | 15 | skipCounter: true, 16 | 17 | get helpMessage() { 18 | const { 19 | italic: i, 20 | gray: g, 21 | underline: u 22 | } = chalk; 23 | 24 | return ` 25 | ${ u( 'Description:' ) } 26 | Push changes in all packages. If some package is missed, the command will not be executed. 27 | For cloned repositories this command is a shorthand for: "${ i( 'mrgit exec \'git push\'' ) }". 28 | 29 | ${ u( 'Git Options:' ) } 30 | All options accepted by "${ i( 'git push' ) }" are supported by mrgit. Everything specified after "--" is passed directly to the 31 | "${ i( 'git push' ) }" command. 32 | 33 | E.g.: "${ g( 'mrgit push -- --verbose --all' ) }" will execute "${ i( 'git push --verbose --all' ) }" 34 | `; 35 | }, 36 | 37 | /** 38 | * @param {CommandData} data 39 | * @returns {Promise} 40 | */ 41 | async execute( data ) { 42 | const execCommand = require( './exec' ); 43 | 44 | if ( !data.isRootRepository ) { 45 | const destinationPath = path.join( data.toolOptions.packages, data.repository.directory ); 46 | 47 | // Package is not cloned. 48 | if ( !fs.existsSync( destinationPath ) ) { 49 | return Promise.resolve( {} ); 50 | } 51 | } 52 | 53 | const commandResponse = await execCommand.execute( getExecData( 'git branch --show-current' ) ); 54 | const currentlyOnBranch = Boolean( commandResponse.logs.info[ 0 ] ); 55 | 56 | if ( !currentlyOnBranch ) { 57 | return Promise.resolve( { 58 | logs: { 59 | error: [], 60 | info: [ 'This repository is currently in detached head mode - skipping.' ] 61 | } 62 | } ); 63 | } 64 | 65 | const pushCommand = ( 'git push ' + data.arguments.join( ' ' ) ).trim(); 66 | 67 | return execCommand.execute( getExecData( pushCommand ) ); 68 | 69 | function getExecData( command ) { 70 | return Object.assign( {}, data, { 71 | arguments: [ command ] 72 | } ); 73 | } 74 | }, 75 | 76 | /** 77 | * @param {Set} parsedPackages Collection of processed packages. 78 | */ 79 | afterExecute( parsedPackages ) { 80 | console.log( chalk.cyan( `${ parsedPackages.size } packages have been processed.` ) ); 81 | } 82 | }; 83 | -------------------------------------------------------------------------------- /lib/commands/save.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const chalk = require( 'chalk' ); 9 | const updateJsonFile = require( '../utils/updatejsonfile' ); 10 | const gitStatusParser = require( '../utils/gitstatusparser' ); 11 | 12 | module.exports = { 13 | name: 'save', 14 | 15 | get helpMessage() { 16 | const { 17 | gray: g, 18 | underline: u, 19 | yellow: y 20 | } = chalk; 21 | 22 | return ` 23 | ${ u( 'Description:' ) } 24 | Saves hashes of commits or branches which repositories are checked out in configuration file. 25 | 26 | ${ u( 'Options:' ) } 27 | ${ y( '--hash' ) } Whether to save hashes (id of last commit) on current branch. 28 | ${ g( 'Default: true' ) } 29 | ${ y( '--branch' ) } (-b) Whether to save names of current branches instead of commit ids. 30 | ${ g( 'Default: false' ) } 31 | ${ g( '> mrgit save --branch' ) } 32 | `; 33 | }, 34 | 35 | /** 36 | * @param {Array.} args Arguments and options that a user provided calling the command. 37 | * @param {Options} toolOptions Options resolved by mrgit. 38 | */ 39 | beforeExecute( args, toolOptions ) { 40 | if ( !toolOptions.branch && !toolOptions.hash ) { 41 | toolOptions.hash = true; 42 | toolOptions.branch = false; 43 | } 44 | 45 | if ( toolOptions.hash && toolOptions.branch ) { 46 | throw new Error( 'Cannot use "hash" and "branch" options at the same time.' ); 47 | } 48 | }, 49 | 50 | /** 51 | * @param {CommandData} data 52 | * @returns {Promise} 53 | */ 54 | execute( data ) { 55 | const log = require( '../utils/log' )(); 56 | const execCommand = require( './exec' ); 57 | 58 | let promise; 59 | 60 | /* istanbul ignore else */ 61 | if ( data.toolOptions.branch ) { 62 | promise = execCommand.execute( getExecData( 'git status --branch --porcelain' ) ) 63 | .then( execResponse => gitStatusParser( execResponse.logs.info[ 0 ] ).branch ); 64 | } else if ( data.toolOptions.hash ) { 65 | promise = execCommand.execute( getExecData( 'git rev-parse HEAD' ) ) 66 | .then( execResponse => execResponse.logs.info[ 0 ].slice( 0, 7 ) ); 67 | } 68 | 69 | return promise 70 | .then( dataToSave => { 71 | const commandResponse = { 72 | packageName: data.packageName, 73 | data: dataToSave, 74 | branch: data.toolOptions.branch, 75 | hash: data.toolOptions.hash 76 | }; 77 | 78 | /* istanbul ignore else */ 79 | if ( data.toolOptions.branch ) { 80 | log.info( `Branch: "${ dataToSave }".` ); 81 | } else if ( data.toolOptions.hash ) { 82 | log.info( `Commit: "${ dataToSave }".` ); 83 | } 84 | 85 | return { 86 | response: commandResponse, 87 | logs: log.all() 88 | }; 89 | } ); 90 | 91 | function getExecData( command ) { 92 | return Object.assign( {}, data, { 93 | arguments: [ command ] 94 | } ); 95 | } 96 | }, 97 | 98 | /** 99 | * Saves collected hashes to configuration file. 100 | * 101 | * @param {Set} processedPackages Collection of processed packages. 102 | * @param {Set} commandResponses Results of executed command for each package. 103 | * @param {Options} toolOptions Options resolved by mrgit. 104 | */ 105 | afterExecute( processedPackages, commandResponses, toolOptions ) { 106 | const tagPattern = /@([^ ~^:?*\\]*?)$/; 107 | 108 | updateJsonFile( toolOptions.config, json => { 109 | for ( const response of commandResponses.values() ) { 110 | const repository = json.dependencies[ response.packageName ] 111 | .replace( tagPattern, '' ) 112 | .split( '#' )[ 0 ]; 113 | 114 | const objectToUpdate = getObjectToUpdate( json, toolOptions, response.packageName ); 115 | 116 | // If returned branch is equal to 'master', save only the repository path. 117 | if ( response.branch && response.data === 'master' ) { 118 | objectToUpdate[ response.packageName ] = repository; 119 | } else { 120 | objectToUpdate[ response.packageName ] = `${ repository }#${ response.data }`; 121 | } 122 | } 123 | 124 | return json; 125 | } ); 126 | 127 | /** 128 | * If preset is being used it should update the value defined in the preset, 129 | * rather than the one in the base "dependencies" object. 130 | * 131 | * @param {Object} json 132 | * @param {Options} toolOptions 133 | * @param {String} packageName 134 | * @returns 135 | */ 136 | function getObjectToUpdate( json, toolOptions ) { 137 | if ( !toolOptions.preset ) { 138 | return json.dependencies; 139 | } 140 | 141 | if ( !json.presets ) { 142 | return json.dependencies; 143 | } 144 | 145 | if ( !json.presets[ toolOptions.preset ] ) { 146 | return json.dependencies; 147 | } 148 | 149 | return json.presets[ toolOptions.preset ]; 150 | } 151 | } 152 | }; 153 | -------------------------------------------------------------------------------- /lib/commands/status.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const chalk = require( 'chalk' ); 9 | const Table = require( 'cli-table' ); 10 | const gitStatusParser = require( '../utils/gitstatusparser' ); 11 | const { addRootRepositorySuffix } = require( '../utils/rootrepositoryutils' ); 12 | 13 | module.exports = { 14 | name: 'status', 15 | 16 | get helpMessage() { 17 | const { 18 | underline: u 19 | } = chalk; 20 | 21 | return ` 22 | ${ u( 'Description:' ) } 23 | Prints a useful table that contains status of every repository. It displays: 24 | 25 | * current branch, 26 | * whether current branch is equal to specified in configuration file, 27 | * whether current branch is behind or ahead with the remote, 28 | * current commit short hash, 29 | * how many files is staged, modified and untracked. 30 | `; 31 | }, 32 | 33 | beforeExecute() { 34 | console.log( chalk.blue( 'Collecting statuses...' ) ); 35 | }, 36 | 37 | /** 38 | * @param {CommandData} data 39 | * @returns {Promise} 40 | */ 41 | async execute( data ) { 42 | const execCommand = require( './exec' ); 43 | 44 | let latestTag = null; 45 | let currentTag = null; 46 | let packageName = data.packageName; 47 | 48 | const hashResponse = await execCommand.execute( getExecData( 'git rev-parse HEAD' ) ); 49 | const currentBranchStatusResponse = await execCommand.execute( getExecData( 'git status --branch --porcelain' ) ); 50 | const latestTagStatusResponse = await execCommand.execute( getExecData( 'git log --tags --simplify-by-decoration --pretty="%S"' ) ); 51 | 52 | if ( latestTagStatusResponse.logs.info.length ) { 53 | const currentTagStatusResponse = await execCommand.execute( getExecData( 'git describe --abbrev=0 --tags --always' ) ); 54 | 55 | latestTag = latestTagStatusResponse.logs.info[ 0 ].trim().split( '\n' ).shift(); 56 | currentTag = currentTagStatusResponse.logs.info[ 0 ]; 57 | } 58 | 59 | for ( const packagePrefix of data.toolOptions.packagesPrefix ) { 60 | packageName = packageName.replace( new RegExp( '^' + packagePrefix ), '' ); 61 | } 62 | 63 | if ( data.isRootRepository ) { 64 | packageName = addRootRepositorySuffix( packageName, { bold: true } ); 65 | } 66 | 67 | const commandResponse = { 68 | packageName, 69 | isRootRepository: data.isRootRepository, 70 | status: gitStatusParser( currentBranchStatusResponse.logs.info[ 0 ], currentTag ), 71 | commit: hashResponse.logs.info[ 0 ].slice( 0, 7 ), // Short version of the commit hash. 72 | mrgitBranch: data.repository.branch, 73 | mrgitTag: data.repository.tag, 74 | latestTag 75 | }; 76 | 77 | return { response: commandResponse }; 78 | 79 | function getExecData( command ) { 80 | return Object.assign( {}, data, { 81 | arguments: [ command ] 82 | } ); 83 | } 84 | }, 85 | 86 | /** 87 | * Saves collected hashes to configuration file. 88 | * 89 | * @param {Set} processedPackages Collection of processed packages. 90 | * @param {Set} commandResponses Results of executed command for each package. 91 | */ 92 | afterExecute( processedPackages, commandResponses ) { 93 | if ( !processedPackages.size || !commandResponses.size ) { 94 | return; 95 | } 96 | 97 | let shouldDisplayLatestHint = false; 98 | let shouldDisplaySyncHint = false; 99 | 100 | const table = new Table( { 101 | head: [ 'Package', 'Branch/Tag', 'Commit', 'Status' ], 102 | style: { 103 | compact: true 104 | } 105 | } ); 106 | 107 | const packagesResponses = Array.from( commandResponses.values() ) 108 | .sort( ( a, b ) => a.packageName.localeCompare( b.packageName ) ) 109 | // The root package should be at the top. 110 | .sort( ( a, b ) => b.isRootRepository - a.isRootRepository ); 111 | 112 | for ( const singleResponse of packagesResponses ) { 113 | table.push( createSingleRow( singleResponse ) ); 114 | } 115 | 116 | console.log( table.toString() ); 117 | displayLegend(); 118 | displayHints( shouldDisplayLatestHint, shouldDisplaySyncHint ); 119 | 120 | function createSingleRow( data ) { 121 | const { packageName, status, commit, mrgitBranch, mrgitTag, latestTag } = data; 122 | const statusColumn = []; 123 | 124 | const shouldUseTag = mrgitTag !== undefined; 125 | const shouldUseLatestTag = mrgitTag === 'latest'; 126 | let branchOrTag = !status.detachedHead ? status.branch : status.tag; 127 | 128 | // Unmerged files are also modified so we should print the number of them out. 129 | const modifiedFiles = [ status.modified, status.unmerged ] 130 | .reduce( ( sum, item ) => sum + item.length, 0 ); 131 | 132 | if ( shouldUseTag && shouldUseLatestTag && status.detachedHead && status.tag === latestTag ) { 133 | branchOrTag = `${ chalk.green( 'L' ) } ${ branchOrTag }`; 134 | shouldDisplayLatestHint = true; 135 | } 136 | 137 | if ( shouldUseTag && shouldUseLatestTag && ( !status.detachedHead || status.tag !== latestTag ) ) { 138 | branchOrTag = `${ chalk.cyan( '!' ) } ${ branchOrTag }`; 139 | shouldDisplaySyncHint = true; 140 | } 141 | 142 | if ( shouldUseTag && !shouldUseLatestTag && status.tag !== mrgitTag ) { 143 | branchOrTag = `${ chalk.cyan( '!' ) } ${ branchOrTag }`; 144 | shouldDisplaySyncHint = true; 145 | } 146 | 147 | if ( !shouldUseTag && status.branch !== mrgitBranch && commit !== mrgitBranch ) { 148 | branchOrTag = `${ chalk.cyan( '!' ) } ${ branchOrTag }`; 149 | shouldDisplaySyncHint = true; 150 | } 151 | 152 | if ( !shouldUseTag && mrgitBranch === commit ) { 153 | branchOrTag = 'Using saved commit →'; 154 | } 155 | 156 | if ( status.ahead ) { 157 | branchOrTag += chalk.yellow( ` ↑${ status.ahead }` ); 158 | } 159 | 160 | if ( status.behind ) { 161 | branchOrTag += chalk.yellow( ` ↓${ status.behind }` ); 162 | } 163 | 164 | if ( status.staged.length ) { 165 | statusColumn.push( chalk.green( `+${ status.staged.length }` ) ); 166 | } 167 | 168 | if ( modifiedFiles ) { 169 | statusColumn.push( chalk.red( `M${ modifiedFiles }` ) ); 170 | } 171 | 172 | if ( status.untracked.length ) { 173 | statusColumn.push( chalk.blue( `?${ status.untracked.length }` ) ); 174 | } 175 | 176 | return [ 177 | packageName, 178 | branchOrTag, 179 | commit, 180 | statusColumn.join( ' ' ) 181 | ]; 182 | } 183 | 184 | function displayLegend() { 185 | const legend = [ 186 | `${ chalk.yellow( '↑' ) } branch is ahead ${ chalk.yellow( '↓' ) } or behind`, 187 | `${ chalk.green( '+' ) } staged files`, 188 | `${ chalk.red( 'M' ) } modified files`, 189 | `${ chalk.blue( '?' ) } untracked files`, 190 | `\n${ chalk.green( 'L' ) } latest tag`, 191 | `${ chalk.cyan( '!' ) } not on a branch or a tag specified in configuration file` 192 | ]; 193 | 194 | console.log( `${ chalk.bold( 'Legend:' ) }\n${ legend.join( ', ' ) }.` ); 195 | } 196 | 197 | function displayHints( shouldDisplayLatestHint, shouldDisplaySyncHint ) { 198 | const hints = []; 199 | 200 | if ( shouldDisplayLatestHint ) { 201 | hints.push( [ 202 | chalk.green( 'L' ), 203 | 'This is the latest local tag. To ensure having latest remote tag, execute', 204 | chalk.blue( 'mrgit fetch' ), 205 | 'before checking status.' 206 | ].join( ' ' ) ); 207 | } 208 | 209 | if ( shouldDisplaySyncHint ) { 210 | hints.push( [ 211 | chalk.cyan( '!' ), 212 | 'In order to bring your repositories up to date, execute', 213 | chalk.blue( 'mrgit sync' ) 214 | ].join( ' ' ) + '.' ); 215 | } 216 | 217 | if ( !hints.length ) { 218 | return; 219 | } 220 | 221 | console.log( `\n${ chalk.bold( 'Hints:' ) }\n${ hints.join( '\n' ) }` ); 222 | } 223 | } 224 | }; 225 | -------------------------------------------------------------------------------- /lib/commands/sync.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const fs = require( 'fs' ); 9 | const path = require( 'upath' ); 10 | const chalk = require( 'chalk' ); 11 | const shell = require( '../utils/shell' ); 12 | 13 | module.exports = { 14 | name: 'sync', 15 | 16 | get helpMessage() { 17 | const { 18 | gray: g, 19 | magenta: m, 20 | underline: u 21 | } = chalk; 22 | 23 | return ` 24 | ${ u( 'Description:' ) } 25 | Updates all packages. For packages that contain uncommitted changes, the update process is aborted. 26 | If some package is missed, it will be installed automatically. 27 | 28 | The update process executes following commands: 29 | 30 | * Checks whether repository can be updated. If the repository contains uncommitted changes, 31 | the process is aborted. 32 | * Fetches changes from the remote. 33 | * Checks out on the branch or particular commit that is specified in configuration file. 34 | * Pulls the changes if the repository is not detached at some commit. 35 | 36 | ${ u( 'Options:' ) } 37 | ${ m( '--recursive' ) } (-r) Whether to install dependencies recursively. Only packages matching these 38 | patterns will be cloned recursively. 39 | ${ g( 'Default: false' ) } 40 | `; 41 | }, 42 | 43 | /** 44 | * @param {CommandData} data 45 | * @returns {Promise} 46 | */ 47 | execute( data ) { 48 | const log = require( '../utils/log' )(); 49 | const execCommand = require( './exec' ); 50 | 51 | if ( !data.isRootRepository ) { 52 | const destinationPath = path.join( data.toolOptions.packages, data.repository.directory ); 53 | 54 | // Package is not cloned. 55 | if ( !fs.existsSync( destinationPath ) ) { 56 | log.info( `Package "${ data.packageName }" was not found. Cloning...` ); 57 | 58 | return this._clonePackage( { 59 | path: destinationPath, 60 | name: data.packageName, 61 | url: data.repository.url, 62 | branch: data.repository.branch, 63 | tag: data.repository.tag 64 | }, data.toolOptions, { log } ); 65 | } 66 | } 67 | 68 | return execCommand.execute( getExecData( 'git status -s' ) ) 69 | .then( async response => { 70 | const stdout = response.logs.info.join( '\n' ).trim(); 71 | 72 | if ( stdout ) { 73 | throw new Error( `Package "${ data.packageName }" has uncommitted changes. Aborted.` ); 74 | } 75 | 76 | return execCommand.execute( getExecData( 'git fetch' ) ); 77 | } ) 78 | .then( response => { 79 | log.concat( response.logs ); 80 | } ) 81 | .then( async () => { 82 | let checkoutValue; 83 | 84 | if ( !data.repository.tag ) { 85 | checkoutValue = data.repository.branch; 86 | } else if ( data.repository.tag === 'latest' ) { 87 | const commandOutput = await execCommand.execute( 88 | getExecData( 'git log --tags --simplify-by-decoration --pretty="%S"' ) 89 | ); 90 | 91 | if ( !commandOutput.logs.info.length ) { 92 | throw new Error( `Can't check out the latest tag as package "${ data.packageName }" has no tags. Aborted.` ); 93 | } 94 | 95 | const latestTag = commandOutput.logs.info[ 0 ].trim().split( '\n' ).shift(); 96 | 97 | checkoutValue = 'tags/' + latestTag.trim(); 98 | } else { 99 | checkoutValue = 'tags/' + data.repository.tag; 100 | } 101 | 102 | return execCommand.execute( getExecData( `git checkout "${ checkoutValue }"` ) ); 103 | } ) 104 | .then( response => { 105 | log.concat( response.logs ); 106 | } ) 107 | .then( () => { 108 | return execCommand.execute( getExecData( 'git branch' ) ); 109 | } ) 110 | .then( response => { 111 | const stdout = response.logs.info.join( '\n' ).trim(); 112 | const isOnBranchRegexp = /HEAD detached at+/; 113 | 114 | // If on a detached commit, mrgit must not pull the changes. 115 | if ( isOnBranchRegexp.test( stdout ) ) { 116 | log.info( `Package "${ data.packageName }" is on a detached commit.` ); 117 | 118 | return { logs: log.all() }; 119 | } 120 | 121 | return execCommand.execute( getExecData( `git pull origin "${ data.repository.branch }"` ) ) 122 | .then( response => { 123 | log.concat( response.logs ); 124 | 125 | return { logs: log.all() }; 126 | } ); 127 | } ) 128 | .catch( commandResponseOrError => { 129 | if ( commandResponseOrError instanceof Error ) { 130 | log.error( commandResponseOrError.message ); 131 | } else { 132 | log.concat( commandResponseOrError.logs ); 133 | } 134 | 135 | return Promise.reject( { logs: log.all() } ); 136 | } ); 137 | 138 | function getExecData( command ) { 139 | return Object.assign( {}, data, { 140 | arguments: [ command ] 141 | } ); 142 | } 143 | }, 144 | 145 | /** 146 | * @param {Set} parsedPackages Collection of processed packages. 147 | */ 148 | afterExecute( parsedPackages, commandResponses, toolOptions ) { 149 | console.log( chalk.cyan( `${ parsedPackages.size } packages have been processed.` ) ); 150 | 151 | const repositoryResolver = require( toolOptions.resolverPath ); 152 | 153 | const repositoryDirectories = Object.keys( toolOptions.dependencies ) 154 | .map( packageName => { 155 | const repository = repositoryResolver( packageName, toolOptions ); 156 | 157 | return path.join( toolOptions.packages, repository.directory ); 158 | } ); 159 | 160 | const skippedPackages = fs.readdirSync( toolOptions.packages ) 161 | .map( directoryName => { 162 | const absolutePath = path.join( toolOptions.packages, directoryName ); 163 | 164 | if ( !directoryName.startsWith( '@' ) ) { 165 | return absolutePath; 166 | } 167 | 168 | return fs.readdirSync( absolutePath ).map( directoryName => path.join( absolutePath, directoryName ) ); 169 | } ) 170 | // TODO: Array.prototype.flat would be awesome here... But it isn't supported in Node yet. 171 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flat 172 | .reduce( ( pathsCollection, pathOrArrayPaths ) => { 173 | if ( Array.isArray( pathOrArrayPaths ) ) { 174 | pathsCollection.push( ...pathOrArrayPaths ); 175 | } else { 176 | pathsCollection.push( pathOrArrayPaths ); 177 | } 178 | 179 | return pathsCollection; 180 | }, [] ) 181 | .filter( pathOrDirectory => { 182 | if ( !fs.lstatSync( pathOrDirectory ).isDirectory() ) { 183 | return false; 184 | } 185 | 186 | return !repositoryDirectories.includes( pathOrDirectory ); 187 | } ); 188 | 189 | if ( skippedPackages.length ) { 190 | console.log( 191 | chalk.yellow( 'Paths to directories listed below are skipped by mrgit because they are not defined in configuration file:' ) 192 | ); 193 | 194 | skippedPackages.forEach( absolutePath => { 195 | console.log( chalk.yellow( ` - ${ absolutePath }` ) ); 196 | } ); 197 | } 198 | }, 199 | 200 | /** 201 | * @private 202 | * @param {Object} packageDetails 203 | * @param {String} packageDetails.name A name of the package. 204 | * @param {String} packageDetails.url A url that will be cloned. 205 | * @param {String} packageDetails.path An absolute path where the package should be cloned. 206 | * @param {String} packageDetails.branch A branch on which the repository will be checked out after cloning. 207 | * @param {Options} toolOptions Options resolved by mrgit. 208 | * @param {Object} options Additional options which aren't related to mrgit. 209 | * @param {Logger} options.log Logger 210 | * @param {Boolean} [options.doNotTryAgain=false] If set to `true`, bootstrap command won't be executed again. 211 | * @returns {Promise} 212 | */ 213 | _clonePackage( packageDetails, toolOptions, options ) { 214 | const log = options.log; 215 | 216 | return shell( `git clone --progress "${ packageDetails.url }" "${ packageDetails.path }"` ) 217 | .then( async output => { 218 | log.info( output ); 219 | 220 | let checkoutValue; 221 | 222 | if ( !packageDetails.tag ) { 223 | checkoutValue = packageDetails.branch; 224 | } else if ( packageDetails.tag === 'latest' ) { 225 | const commandOutput = await shell( 226 | `cd "${ packageDetails.path }" && git log --tags --simplify-by-decoration --pretty="%S"` 227 | ); 228 | const latestTag = commandOutput.trim().split( '\n' ).shift(); 229 | 230 | checkoutValue = 'tags/' + latestTag.trim(); 231 | } else { 232 | checkoutValue = 'tags/' + packageDetails.tag; 233 | } 234 | 235 | return shell( `cd "${ packageDetails.path }" && git checkout --quiet "${ checkoutValue }"` ); 236 | } ) 237 | .then( () => { 238 | const commandOutput = { 239 | logs: log.all() 240 | }; 241 | 242 | if ( toolOptions.recursive ) { 243 | const packageJson = require( path.join( packageDetails.path, 'package.json' ) ); 244 | const packages = []; 245 | 246 | if ( packageJson.dependencies ) { 247 | packages.push( ...Object.keys( packageJson.dependencies ) ); 248 | } 249 | 250 | if ( packageJson.devDependencies ) { 251 | packages.push( ...Object.keys( packageJson.devDependencies ) ); 252 | } 253 | 254 | commandOutput.packages = packages; 255 | } 256 | 257 | return commandOutput; 258 | } ) 259 | .catch( error => { 260 | /* istanbul ignore else */ 261 | if ( isRemoteHungUpError( error ) && !options.doNotTryAgain ) { 262 | return delay( 5000 ).then( () => { 263 | return this._clonePackage( packageDetails, toolOptions, { log, doNotTryAgain: true } ); 264 | } ); 265 | } 266 | 267 | log.error( error ); 268 | 269 | return Promise.reject( { logs: log.all() } ); 270 | } ); 271 | } 272 | }; 273 | 274 | // See: https://github.com/cksource/mrgit/issues/87 275 | function isRemoteHungUpError( error ) { 276 | if ( typeof error != 'string' ) { 277 | error = error.toString(); 278 | } 279 | 280 | const fatalErrors = error.split( '\n' ) 281 | .filter( message => message.startsWith( 'fatal:' ) ) 282 | .map( message => message.trim() ); 283 | 284 | return fatalErrors[ 0 ] && fatalErrors[ 0 ].match( /fatal: the remote end hung up unexpectedly/i ); 285 | } 286 | 287 | function delay( ms ) { 288 | return new Promise( resolve => { 289 | setTimeout( resolve, ms ); 290 | } ); 291 | } 292 | -------------------------------------------------------------------------------- /lib/default-resolver.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const parseRepositoryUrl = require( './utils/parserepositoryurl' ); 9 | 10 | /** 11 | * Resolves repository URL for a given package name. 12 | * 13 | * @param {String} packageName Package name. 14 | * @param {Options} options The options object. 15 | * @param {Boolean} isRootRepository 16 | * @returns {Repository|null} 17 | */ 18 | module.exports = function resolver( packageName, options, isRootRepository ) { 19 | const repositoryUrl = isRootRepository ? 20 | options.$rootRepository : 21 | options.dependencies[ packageName ]; 22 | 23 | if ( !repositoryUrl ) { 24 | return null; 25 | } 26 | 27 | const repository = parseRepositoryUrl( repositoryUrl, { 28 | urlTemplate: options.resolverUrlTemplate, 29 | defaultBranch: options.resolverDefaultBranch, 30 | baseBranches: options.baseBranches, 31 | cwdPackageBranch: options.cwdPackageBranch 32 | } ); 33 | 34 | if ( options.overrideDirectoryNames[ packageName ] ) { 35 | repository.directory = options.overrideDirectoryNames[ packageName ]; 36 | } 37 | 38 | if ( options.resolverTargetDirectory == 'npm' ) { 39 | repository.directory = packageName; 40 | } 41 | 42 | return repository; 43 | }; 44 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const fs = require( 'fs' ); 9 | const path = require( 'upath' ); 10 | const chalk = require( 'chalk' ); 11 | const createForkPool = require( './utils/createforkpool' ); 12 | const displayLog = require( './utils/displaylog' ); 13 | const getOptions = require( './utils/getoptions' ); 14 | const getPackageNames = require( './utils/getpackagenames' ); 15 | const getCommandInstance = require( './utils/getcommandinstance' ); 16 | const { addRootRepositorySuffix } = require( './utils/rootrepositoryutils' ); 17 | 18 | const CHILD_PROCESS_PATH = require.resolve( './utils/child-process' ); 19 | 20 | /** 21 | * @param {Array.} args Arguments that the user provided. 22 | * @param {Options} options The options object. It will be extended with the default options. 23 | */ 24 | module.exports = function( args, options ) { 25 | const command = getCommandInstance( args[ 0 ] ); 26 | 27 | if ( !command ) { 28 | return; 29 | } 30 | 31 | const startTime = process.hrtime(); 32 | const toolOptions = getOptions( options ); 33 | const repositoryResolver = require( toolOptions.resolverPath ); 34 | const forkPool = createForkPool( CHILD_PROCESS_PATH ); 35 | 36 | if ( shouldBreakProcess( toolOptions ) ) { 37 | console.log( chalk.red( 38 | // eslint-disable-next-line @stylistic/max-len 39 | `When the "${ chalk.bold( '$rootRepository' ) }" option is used, the configuration file must be located inside a git repository.` 40 | ) ); 41 | 42 | process.exit( 1 ); 43 | } 44 | 45 | const mainPkgJsonPath = path.resolve( toolOptions.cwd, 'package.json' ); 46 | const mainPackageName = fs.existsSync( mainPkgJsonPath ) ? require( mainPkgJsonPath ).name : ''; 47 | 48 | if ( command.beforeExecute ) { 49 | try { 50 | command.beforeExecute( args, toolOptions ); 51 | } catch ( error ) { 52 | console.log( chalk.red( error.message ) ); 53 | 54 | process.exit( 1 ); 55 | } 56 | } 57 | 58 | const processedPackages = new Set(); 59 | const commandResponses = new Set(); 60 | const packagesWithError = new Set(); 61 | const packageNames = getPackageNames( toolOptions, command ); 62 | 63 | let allPackagesNumber = packageNames.length; 64 | let donePackagesNumber = 0; 65 | 66 | if ( allPackagesNumber === 0 ) { 67 | console.log( chalk.yellow( 'No packages found that match to specified criteria.' ) ); 68 | 69 | return onDone(); 70 | } 71 | 72 | for ( const item of packageNames ) { 73 | enqueue( item ); 74 | } 75 | 76 | function enqueue( packageName ) { 77 | if ( processedPackages.has( packageName ) ) { 78 | return; 79 | } 80 | 81 | // Do not enqueue main package even if other package from dependencies require it. 82 | if ( packageName === mainPackageName ) { 83 | return; 84 | } 85 | 86 | const isRootRepository = packageName.startsWith( '$' ); 87 | packageName = packageName.replace( /^\$/, '' ); 88 | 89 | processedPackages.add( packageName ); 90 | 91 | const data = { 92 | packageName, 93 | isRootRepository, 94 | toolOptions, 95 | commandPath: command.path, 96 | arguments: args.slice( 1 ), 97 | repository: repositoryResolver( packageName, toolOptions, isRootRepository ) 98 | }; 99 | 100 | forkPool.enqueue( data ) 101 | .then( returnedData => { 102 | donePackagesNumber += 1; 103 | 104 | if ( Array.isArray( returnedData.packages ) ) { 105 | returnedData.packages.forEach( item => { 106 | if ( processedPackages.has( item ) ) { 107 | return; 108 | } 109 | 110 | allPackagesNumber += 1; 111 | enqueue( item ); 112 | } ); 113 | } 114 | 115 | if ( returnedData.response ) { 116 | commandResponses.add( returnedData.response ); 117 | } 118 | 119 | if ( returnedData.logs ) { 120 | if ( data.isRootRepository ) { 121 | packageName = addRootRepositorySuffix( packageName ); 122 | } 123 | 124 | if ( returnedData.logs.error.length ) { 125 | packagesWithError.add( packageName ); 126 | } 127 | 128 | displayLog( packageName, returnedData.logs, { 129 | current: donePackagesNumber, 130 | all: allPackagesNumber, 131 | skipCounter: command.skipCounter, 132 | colorizeOutput: command.colorizeOutput 133 | } ); 134 | } 135 | 136 | if ( forkPool.isDone ) { 137 | return onDone(); 138 | } 139 | } ) 140 | .catch( error => { 141 | console.log( chalk.red( error.stack ) ); 142 | 143 | process.exit( 1 ); 144 | } ); 145 | } 146 | 147 | function onDone() { 148 | return forkPool.killAll() 149 | .then( () => { 150 | if ( command.afterExecute ) { 151 | command.afterExecute( processedPackages, commandResponses, toolOptions ); 152 | } 153 | 154 | const endTime = process.hrtime( startTime ); 155 | 156 | console.log( chalk.cyan( `Execution time: ${ endTime[ 0 ] }s${ endTime[ 1 ].toString().substring( 0, 3 ) }ms.` ) ); 157 | 158 | if ( packagesWithError.size ) { 159 | const repositoryForm = packagesWithError.size === 1 ? 'repository' : 'repositories'; 160 | let message = `\n❗❗❗ The command failed to execute in ${ packagesWithError.size } ${ repositoryForm }:\n`; 161 | message += [ ...packagesWithError ].map( pkgName => ` - ${ pkgName }` ).join( '\n' ); 162 | message += '\n'; 163 | 164 | console.log( chalk.red( message ) ); 165 | process.exit( 1 ); 166 | } 167 | } ); 168 | } 169 | 170 | function shouldBreakProcess( toolOptions ) { 171 | if ( options.skipRoot ) { 172 | return false; 173 | } 174 | 175 | if ( !toolOptions.$rootRepository ) { 176 | return false; 177 | } 178 | 179 | if ( fs.existsSync( path.join( toolOptions.cwd, '.git' ) ) ) { 180 | return false; 181 | } 182 | 183 | return true; 184 | } 185 | }; 186 | -------------------------------------------------------------------------------- /lib/utils/child-process.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | process.on( 'message', onMessage ); 9 | 10 | /** 11 | * @param {CommandData} data 12 | */ 13 | function onMessage( data ) { 14 | const log = require( './log' )(); 15 | 16 | if ( !data.repository ) { 17 | log.info( `Repository URL for package "${ data.packageName }" could not be resolved. Skipping.` ); 18 | 19 | return process.send( { logs: log.all() } ); 20 | } 21 | 22 | const command = require( data.commandPath ); 23 | 24 | command.execute( data ) 25 | .then( returnedData => { 26 | process.send( returnedData ); 27 | } ) 28 | .catch( err => { 29 | if ( !( err instanceof Error ) ) { 30 | return process.send( err ); 31 | } 32 | 33 | log.error( err.stack ); 34 | 35 | process.send( { logs: log.all() } ); 36 | } ); 37 | } 38 | -------------------------------------------------------------------------------- /lib/utils/createforkpool.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const childProcess = require( 'child_process' ); 9 | const genericPool = require( 'generic-pool' ); 10 | 11 | /** 12 | * @param {String} childPath Path to module that will be forked. 13 | * @returns {Object} response 14 | * @returns {Boolean} response.isDone 15 | * @returns {Function} response.enqueue 16 | * @returns {Promise} response.killAll 17 | */ 18 | module.exports = function createForkPool( childPath ) { 19 | const forkPoolFactory = { 20 | create() { 21 | return new Promise( resolve => { 22 | resolve( childProcess.fork( childPath ) ); 23 | } ); 24 | }, 25 | 26 | destroy( child ) { 27 | child.kill(); 28 | } 29 | }; 30 | 31 | const pool = genericPool.createPool( forkPoolFactory, { 32 | max: 4, 33 | min: 2 34 | } ); 35 | 36 | return { 37 | get isDone() { 38 | return !pool.pending && !pool.borrowed; 39 | }, 40 | 41 | enqueue( data ) { 42 | return new Promise( ( resolve, reject ) => { 43 | pool.acquire() 44 | .then( child => { 45 | child.once( 'message', returnedData => { 46 | pool.release( child ); 47 | 48 | resolve( returnedData ); 49 | } ); 50 | 51 | child.send( data ); 52 | } ) 53 | .catch( reject ); 54 | } ); 55 | }, 56 | 57 | killAll() { 58 | return pool.drain() 59 | .then( () => { 60 | pool.clear(); 61 | } ); 62 | } 63 | }; 64 | }; 65 | -------------------------------------------------------------------------------- /lib/utils/displaylog.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const chalk = require( 'chalk' ); 9 | 10 | /** 11 | * Formats the logs and writes them to the console. 12 | * 13 | * @param {String} packageName 14 | * @param {Logs} logs 15 | * @param {Object} options 16 | * @param {Number} options.current Number of packages that have been processed. 17 | * @param {Number} options.all Number of all packages that will be processed. 18 | * @param {Boolean} [options.skipCounter=false] A flag that allows hiding the progress bar. 19 | */ 20 | module.exports = function displayLog( packageName, logs, options ) { 21 | const infoLogs = logs.info.filter( l => l.length ).join( '\n' ).trim(); 22 | const errorLogs = logs.error.filter( l => l.length ).join( '\n' ).trim(); 23 | 24 | const progressPercentage = Math.round( ( options.current / options.all ) * 100 ); 25 | const progressBar = `${ options.current }/${ options.all } (${ progressPercentage }%)`; 26 | 27 | console.log( chalk.inverse( getPackageHeader() ) ); 28 | 29 | if ( infoLogs ) { 30 | console.log( chalk.gray( infoLogs ) ); 31 | } 32 | 33 | if ( errorLogs ) { 34 | console.log( chalk.red( errorLogs ) ); 35 | } 36 | 37 | console.log( '' ); 38 | 39 | function getPackageHeader() { 40 | const headerParts = [ 41 | chalk.cyan( ' # ' ), 42 | ' ', 43 | padEnd( packageName.trim(), 80 - progressBar.length ), 44 | ' ' 45 | ]; 46 | 47 | if ( options.skipCounter ) { 48 | headerParts.push( ' '.repeat( progressBar.length ) ); 49 | } else { 50 | headerParts.push( progressBar ); 51 | } 52 | 53 | headerParts.push( ' ' ); 54 | 55 | return headerParts.join( '' ); 56 | } 57 | }; 58 | 59 | function padEnd( str, targetLength, padChar = ' ' ) { 60 | while ( targetLength > str.length ) { 61 | str += padChar; 62 | } 63 | 64 | return str; 65 | } 66 | -------------------------------------------------------------------------------- /lib/utils/getcommandinstance.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const chalk = require( 'chalk' ); 9 | 10 | const COMMAND_ALIASES = { 11 | ci: 'commit', 12 | co: 'checkout', 13 | st: 'status' 14 | }; 15 | 16 | /** 17 | * @param {String} commandName An alias or fully command name that should be used. 18 | * @returns {Command|null} 19 | */ 20 | module.exports = function getCommandInstance( commandName ) { 21 | try { 22 | // Find full command name if used an alias or just use specified name. 23 | const resolvedCommandName = ( COMMAND_ALIASES[ commandName ] || commandName ).replace( /-/g, '' ); 24 | 25 | const commandPath = require.resolve( '../commands/' + resolvedCommandName ); 26 | const commandInstance = require( commandPath ); 27 | 28 | commandInstance.path = commandPath; 29 | 30 | return commandInstance; 31 | } catch { 32 | const message = `Command "${ commandName }" does not exist. Type: "mrgit --help" in order to see available commands.`; 33 | 34 | console.error( chalk.red( message ) ); 35 | } 36 | 37 | process.exitCode = 1; 38 | 39 | return null; 40 | }; 41 | 42 | /** 43 | * @typedef {Object} Command 44 | * 45 | * @property {String} path An absolute path to the file that keeps the command. 46 | * 47 | * @property {String} helpMessage A message that explains how to use specified command. 48 | * 49 | * @property {Function} execute A function that is called on every repository that match to specified criteria. It receives an object 50 | * as an argument that contains following properties 51 | * 52 | * @property {Boolean} [skipCounter=false] A flag that allows hiding the progress bar (number of package and number of all 53 | * packages to process) on the screen. 54 | * 55 | * @property {String} name A name of the command. Used for aliases and handling root package logic. 56 | * 57 | * @property {Function} [beforeExecute] A function that is called by mrgit automatically before executing the main command's method. 58 | * This function is called once. It receives two parameters: 59 | * - an array of arguments typed by a user (including called command name). 60 | * - an options object (`Options`) which contains options resolved by mrgit. 61 | * 62 | * @property {Function} execute The main function of command. 63 | * It receives single argument (`CommandData`) that represents an input provided by a user. 64 | * It must returns an instance of `Promise`. The promise must resolve an object that can contains following properties: 65 | * - `logs` - an object that matches to `Logs` object definition. 66 | * - `response` - the entire `response` object is added to a collection that will be passed as second argument to `#afterExecute` 67 | * function. 68 | * - `packages` - an array of packages that mrgit should process as well. 69 | * 70 | * @property {Function} [afterExecute] A function that is called by mrgit automatically after executing the main command's method. 71 | * This function is called once. It receives three parameters: 72 | * - a collection (`Set`) that contains all processed packages by mrgit. 73 | * - a collection (`Set`) that contains responses returned by `#execute` function. 74 | * - an options object (`Options`) which contains options resolved by mrgit. 75 | */ 76 | 77 | /** 78 | * @typedef {Object} CommandData 79 | * 80 | * @property {String} packageName A name of package. 81 | * 82 | * @property {Options} toolOptions Options resolved by mrgit. 83 | * 84 | * @property {String} commandPath An absolute path to the file that keeps the command. 85 | * 86 | * @property {Array.} arguments Arguments provided by the user via CLI. 87 | * 88 | * @property {Repository|null} repository An object that keeps data about repository for specified package. 89 | */ 90 | -------------------------------------------------------------------------------- /lib/utils/getcwd.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const fs = require( 'fs' ); 9 | const path = require( 'upath' ); 10 | 11 | /** 12 | * Returns an absolute path to the directory that contains a configuration file. 13 | * 14 | * It scans directory tree up for a configuration file. If the file won't be found, 15 | * an exception should be thrown. 16 | * 17 | * @param {String} config Configuration file. 18 | * @param {String} [cwd=process.cwd()] An absolute path to the directory where searching for the configuration file is started. 19 | * @returns {String} 20 | */ 21 | module.exports = function cwdResolver( config, cwd = process.cwd() ) { 22 | while ( !fs.existsSync( path.resolve( cwd, config ) ) ) { 23 | const parentCwd = path.resolve( cwd, '..' ); 24 | 25 | if ( cwd === parentCwd ) { 26 | throw new Error( 'Cannot find the configuration file.' ); 27 | } 28 | 29 | cwd = parentCwd; 30 | } 31 | 32 | return cwd; 33 | }; 34 | -------------------------------------------------------------------------------- /lib/utils/getoptions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const fs = require( 'fs' ); 9 | const path = require( 'upath' ); 10 | const shell = require( 'shelljs' ); 11 | const getCwd = require( './getcwd' ); 12 | 13 | /** 14 | * @param {Object} callOptions Call options. 15 | * @param {String} [cwd=process.cwd()] An absolute path to the directory where searching for the configuration file is started. 16 | * @returns {Options} The options object. 17 | */ 18 | module.exports = function getOptions( callOptions, cwd = process.cwd() ) { 19 | const configName = callOptions.config || 'mrgit.json'; 20 | const configPath = path.resolve( getCwd( configName, cwd ), configName ); 21 | const configOptions = require( configPath ); 22 | 23 | const defaultOptions = { 24 | packages: 'packages', 25 | resolverPath: path.resolve( __dirname, '..', 'default-resolver.js' ), 26 | resolverUrlTemplate: 'git@github.com:${ path }.git', 27 | resolverTargetDirectory: 'git', 28 | resolverDefaultBranch: 'master', 29 | ignore: null, 30 | scope: null, 31 | skipRoot: false, 32 | packagesPrefix: [], 33 | overrideDirectoryNames: {}, 34 | baseBranches: [] 35 | }; 36 | 37 | const options = { 38 | ...defaultOptions, 39 | ...configOptions, 40 | ...callOptions 41 | }; 42 | 43 | options.config = configPath; 44 | options.cwd = path.dirname( configPath ); 45 | options.packages = path.resolve( options.cwd, options.packages ); 46 | 47 | if ( !Array.isArray( options.packagesPrefix ) ) { 48 | options.packagesPrefix = [ options.packagesPrefix ]; 49 | } 50 | 51 | // Check if under specified `cwd` path, the git repository exists. 52 | // If so, find a branch name that the repository is checked out. See #103. 53 | if ( fs.existsSync( path.join( options.cwd, '.git' ) ) ) { 54 | const response = shell.exec( 'git rev-parse --abbrev-ref HEAD', { silent: true } ); 55 | 56 | options.cwdPackageBranch = response.stdout.trim(); 57 | } 58 | 59 | if ( !options.preset ) { 60 | return options; 61 | } 62 | 63 | if ( !options.presets || !options.presets[ options.preset ] ) { 64 | throw new Error( `Preset "${ options.preset }" is not defined in configuration file.` ); 65 | } 66 | 67 | if ( options.presets[ options.preset ].$rootRepository ) { 68 | options.$rootRepository = options.presets[ options.preset ].$rootRepository; 69 | 70 | delete options.presets[ options.preset ].$rootRepository; 71 | } 72 | 73 | if ( options.dependencies ) { 74 | Object.assign( options.dependencies, options.presets[ options.preset ] ); 75 | } 76 | 77 | return options; 78 | }; 79 | 80 | /** 81 | * @typedef {Object} Options 82 | * 83 | * @property {String} cwd An absolute path to the directory which contains configuration file. 84 | * 85 | * @property {String} [config='/mrgit.json'] An absolute path to configuration file. 86 | * 87 | * @property {String} [packages='/packages/'] Directory to which all repositories will be cloned. 88 | * 89 | * @property {String} [resolverPath='@mrgit/lib/default-resolver.js'] Path to a custom repository resolver function. 90 | * 91 | * @property {String} [resolverUrlTemplate='git@github.com:${ path }.git'] Template used to generate repository URL out of a 92 | * simplified 'organization/repository' format of the dependencies option. 93 | * 94 | * @property {String} [resolverTargetDirectory='git'] Defines how the target directory (where the repository will be cloned) 95 | * is resolved. Supported options are: 'git' (default), 'npm'. 96 | * 97 | * * If 'git' was specified, then the directory name will be extracted from 98 | * the git URL (e.g. for 'git@github.com:a/b.git' it will be 'b'). 99 | * * If 'npm' was specified, then the package name will be used as a directory name. 100 | * 101 | * This option can be useful when scoped npm packages are used and one wants to decide 102 | * whether the repository will be cloned to packages/@scope/pkgname' or 'packages/pkgname'. 103 | * 104 | * @property {String} [resolverDefaultBranch='master'] The branch name to use if not specified in dependencies in configuration file. 105 | * 106 | * @property {String|null} [ignore=null] Ignores packages with names matching the given glob. 107 | * 108 | * @property {String|null} [scope=null] Restricts the scope to package names matching the given glob. 109 | * 110 | * @property {Boolean} [skipRoot=false] Allows skipping root repository when executing command, 111 | * if "$rootRepository" is defined in the config file. 112 | * 113 | * @property {Boolean|undefined} [recursive=undefined] Whether to install dependencies recursively. 114 | * 115 | * @property {Boolean|String|undefined} [branch=undefined] If a bool: whether to use branch names as an input data. 116 | * If a string: name of branch that should be created. 117 | * 118 | * @property {Boolean|undefined} [hash=undefined] Whether to use current commit hashes as an input data. 119 | * 120 | * @property {Object} [overrideDirectoryNames={}] A map that allows renaming directories where packages will be cloned. 121 | * 122 | * @property {String|Array.} [packagesPrefix=[]] Prefix or prefixes which will be removed from packages' names during 123 | * printing the summary of the "status" command. 124 | * 125 | * @property {Array.} [baseBranches=[]] Name of branches that are allowed to check out (based on a branch in main repository) 126 | * if specified package does not have defined a branch. 127 | * 128 | * @property {String} [cwdPackageBranch] If the main repository is a git repository, this variable keeps 129 | * a name of a current branch of the repository. The value is required for `baseBranches` option. 130 | */ 131 | -------------------------------------------------------------------------------- /lib/utils/getpackagenames.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const minimatch = require( 'minimatch' ); 9 | const { doesCommandSupportRootPackage } = require( '../utils/rootrepositoryutils' ); 10 | 11 | /** 12 | * @param {Object} options 13 | * @param {Array.} options.dependencies List of packages. 14 | * @param {String|null} [options.ignore=null] Ignores packages with names matching the given glob. 15 | * @param {String|null} [options.scope=null] Restricts the scope to package names matching the given glob. 16 | * @param {Command} command 17 | * @returns {Array.} 18 | */ 19 | module.exports = function getPackageNames( options, command ) { 20 | const miniMatchOptions = { matchBase: true }; 21 | 22 | let packageNames = Object.keys( options.dependencies ); 23 | 24 | if ( !options.skipRoot && options.$rootRepository && doesCommandSupportRootPackage( command ) ) { 25 | packageNames.unshift( '$' + options.$rootRepository.match( /(?<=\/)[^#@]+/ )[ 0 ] ); 26 | } 27 | 28 | if ( options.ignore ) { 29 | packageNames = packageNames.filter( packageName => { 30 | return !minimatch( packageName, options.ignore, miniMatchOptions ); 31 | } ); 32 | } 33 | 34 | if ( options.scope ) { 35 | packageNames = packageNames.filter( packageName => { 36 | return minimatch( packageName, options.scope, miniMatchOptions ); 37 | } ); 38 | } 39 | 40 | return packageNames; 41 | }; 42 | -------------------------------------------------------------------------------- /lib/utils/gitstatusparser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const UNMERGED_SYMBOLS = [ 9 | 'DD', // Deleted by both. 10 | 'UU', // Modified by both. 11 | 'AA', // Added by both. 12 | 'DU', // Deleted by us. 13 | 'AU', // Added by us. 14 | 'UD', // Deleted by them. 15 | 'UA' // Added by them. 16 | ]; 17 | 18 | const DELETE_STAGED_SYMBOL = 'D '; 19 | const DELETE_NOT_STAGED_SYMBOL = ' D'; 20 | const MODIFIED_STAGED_SYMBOL = 'M '; 21 | const MODIFIED_NOT_STAGED_SYMBOL = ' M'; 22 | const MODIFIED_STAGED_AND_NOT_STAGED_SYMBOL = 'MM'; 23 | const RENAMED_STAGED_SYMBOL = 'R '; 24 | const ADDED_STAGED_SYMBOL = 'A '; 25 | const UNTRACKED_SYMBOL = '??'; 26 | 27 | /** 28 | * @param {String} branchStatus An output returned by `git status --branch --porcelain` command. 29 | * @param {String} currentTag An output returned by `git describe --abbrev=0 --tags` command. 30 | * @returns {Object} data 31 | * @returns {Boolean} data.anythingToCommit Returns true if any changed file could be committed using command `git commit -a`. 32 | * @returns {String} data.branchOrTag Current branch or tag. 33 | * @returns {Number|null} data.behind Number of commits that branch is behind the remote upstream. 34 | * @returns {Number|null} data.ahead Number of commits that branch is ahead the remote upstream. 35 | * @returns {Array.} data.added List of files created files (untracked files are tracked now). 36 | * @returns {Array.} data.modified List of tracked files that have changed. 37 | * @returns {Array.} data.deleted List of tracked files that have deleted. 38 | * @returns {Array.} data.renamed List of tracked files that have moved (or renamed). 39 | * @returns {Array.} data.unmerged List of tracked files that contain (unresolved) conflicts. 40 | * @returns {Array.} data.untracked List of untracked files which won't be committed using command `git commit -a`. 41 | * @returns {Array.} data.staged List of files that their changes are ready to commit. 42 | */ 43 | module.exports = function gitStatusParser( branchStatus, currentTag ) { 44 | const responseAsArray = branchStatus.split( '\n' ); 45 | const branchData = responseAsArray.shift(); 46 | 47 | const branch = branchData.split( '...' )[ 0 ].match( /## (.*)$/ )[ 1 ]; 48 | const tag = currentTag; 49 | const detachedHead = branch === 'HEAD (no branch)'; 50 | const added = filterFiles( [ ADDED_STAGED_SYMBOL ] ); 51 | const modified = filterFiles( [ MODIFIED_NOT_STAGED_SYMBOL, MODIFIED_STAGED_AND_NOT_STAGED_SYMBOL, DELETE_NOT_STAGED_SYMBOL ] ); 52 | const deleted = filterFiles( [ DELETE_STAGED_SYMBOL, DELETE_NOT_STAGED_SYMBOL ] ); 53 | const renamed = filterFiles( [ RENAMED_STAGED_SYMBOL ] ); 54 | const unmerged = filterFiles( UNMERGED_SYMBOLS ); 55 | const untracked = filterFiles( [ UNTRACKED_SYMBOL ] ); 56 | const staged = filterFiles( [ 57 | ADDED_STAGED_SYMBOL, 58 | DELETE_STAGED_SYMBOL, 59 | MODIFIED_STAGED_SYMBOL, 60 | MODIFIED_STAGED_AND_NOT_STAGED_SYMBOL, 61 | RENAMED_STAGED_SYMBOL 62 | ] ); 63 | 64 | let behind = branchData.match( /behind (\d+)/ ); 65 | let ahead = branchData.match( /ahead (\d+)/ ); 66 | 67 | if ( behind ) { 68 | behind = parseInt( behind[ 1 ], 10 ); 69 | } 70 | 71 | if ( ahead ) { 72 | ahead = parseInt( ahead[ 1 ], 10 ); 73 | } 74 | 75 | return { 76 | get anythingToCommit() { 77 | return [ added, modified, deleted, renamed, unmerged, staged ].some( collection => collection.length ); 78 | }, 79 | 80 | branch, 81 | tag, 82 | detachedHead, 83 | behind, 84 | ahead, 85 | added, 86 | modified, 87 | deleted, 88 | renamed, 89 | unmerged, 90 | untracked, 91 | staged 92 | }; 93 | 94 | function filterFiles( prefixes ) { 95 | return responseAsArray 96 | .filter( line => prefixes.some( prefix => prefix === line.substring( 0, 2 ) ) ) 97 | .map( line => line.slice( 2 ).trim() ); 98 | } 99 | }; 100 | -------------------------------------------------------------------------------- /lib/utils/log.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | module.exports = function log() { 9 | const logs = new Map( [ 10 | [ 'info', [] ], 11 | [ 'error', [] ] 12 | ] ); 13 | 14 | const logger = { 15 | info( msg ) { 16 | return logger.log( 'info', msg ); 17 | }, 18 | 19 | error( msg ) { 20 | if ( msg instanceof Error ) { 21 | msg = msg.stack; 22 | } 23 | 24 | return logger.log( 'error', msg ); 25 | }, 26 | 27 | log( type, msg ) { 28 | if ( !msg ) { 29 | return; 30 | } 31 | 32 | msg = msg.trim(); 33 | 34 | if ( !msg ) { 35 | return; 36 | } 37 | 38 | logs.get( type ).push( msg ); 39 | }, 40 | 41 | concat( responseLogs ) { 42 | responseLogs.info.forEach( msg => logger.info( msg ) ); 43 | 44 | responseLogs.error.forEach( err => logger.error( err ) ); 45 | }, 46 | 47 | /** 48 | * @returns {Logs} 49 | */ 50 | all() { 51 | return { 52 | error: logs.get( 'error' ), 53 | info: logs.get( 'info' ) 54 | }; 55 | } 56 | }; 57 | 58 | return logger; 59 | }; 60 | 61 | /** 62 | * @typedef {Object} Logger 63 | * 64 | * @property {Function} info A function that informs about process. 65 | * 66 | * @property {Function} error A function that informs about errors. 67 | */ 68 | 69 | /** 70 | * @typedef {Object} Logs 71 | * 72 | * @property {Array.} error An error messages. 73 | * 74 | * @property {Array.} info An information messages. 75 | */ 76 | -------------------------------------------------------------------------------- /lib/utils/parserepositoryurl.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const url = require( 'url' ); 9 | 10 | // Limitations on git tag names: 11 | // https://git-scm.com/docs/git-check-ref-format#_description 12 | const tagPattern = /@([^ ~^:?*\\]*?)$/; 13 | 14 | /** 15 | * Parses repository URL taken from dependencies provided in configuration file and returns 16 | * it as an object containing repository URL and branch name. 17 | * 18 | * @param {String} repositoryUrl The repository URL in formats supported by configuration file. 19 | * @param {Object} options 20 | * @param {String} [options.urlTemplate] The URL template. 21 | * Used if `repositoryUrl` defines only `'/'`. 22 | * @param {String} [options.defaultBranch='master'] The default branch name to be used if the 23 | * repository URL doesn't specify it. 24 | * @param {Array.} [options.baseBranches=[]] Name of branches that are allowed to check out 25 | * based on the value specified as `options.cwdPackageBranch`. 26 | * @param {String} [options.cwdPackageBranch] A name of a branch that the main repository is checked out. 27 | * @returns {Repository} 28 | */ 29 | module.exports = function parseRepositoryUrl( repositoryUrl, options = {} ) { 30 | let tag = undefined; 31 | 32 | if ( tagPattern.test( repositoryUrl ) ) { 33 | tag = repositoryUrl.match( tagPattern )[ 1 ]; 34 | 35 | repositoryUrl = repositoryUrl.replace( tagPattern, '' ); 36 | } 37 | 38 | const parsedUrl = url.parse( repositoryUrl ); 39 | 40 | const branch = getBranch( parsedUrl, { 41 | defaultBranch: options.defaultBranch, 42 | baseBranches: options.baseBranches || [], 43 | cwdPackageBranch: options.cwdPackageBranch 44 | } ); 45 | 46 | let repoUrl; 47 | 48 | if ( repositoryUrl.match( /^(file|https?):\/\// ) || repositoryUrl.match( /^git@/ ) ) { 49 | parsedUrl.hash = null; 50 | 51 | repoUrl = url.format( parsedUrl ); 52 | } else { 53 | const pattern = options.urlTemplate; 54 | 55 | repoUrl = pattern.replace( /\${ path }/, parsedUrl.path ); 56 | } 57 | 58 | return { 59 | url: repoUrl, 60 | branch, 61 | directory: repoUrl.replace( /\.git$/, '' ).match( /[:/]([^/]+)\/?$/ )[ 1 ], 62 | tag 63 | }; 64 | }; 65 | 66 | function getBranch( parsedUrl, options ) { 67 | const defaultBranch = options.defaultBranch || 'master'; 68 | 69 | // Check if branch is defined in configuration file. Use it. 70 | if ( parsedUrl.hash ) { 71 | return parsedUrl.hash.slice( 1 ); 72 | } 73 | 74 | // Check if the main repo is on one of base branches. If yes, use that branch. 75 | if ( options.cwdPackageBranch && options.baseBranches.includes( options.cwdPackageBranch ) ) { 76 | return options.cwdPackageBranch; 77 | } 78 | 79 | // Nothing matches. Use default branch. 80 | return defaultBranch; 81 | } 82 | 83 | /** 84 | * Repository info. 85 | * 86 | * @typedef {Object} Repository 87 | * @property {String} url Repository URL. E.g. `'git@github.com:ckeditor/ckeditor5.git'`. 88 | * @property {String} branch Branch name. E.g. `'master'`. 89 | * @property {String} directory Directory to which the repository would be cloned. 90 | */ 91 | -------------------------------------------------------------------------------- /lib/utils/rootrepositoryutils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const chalk = require( 'chalk' ); 9 | 10 | const ROOT_REPOSITORY_SUFFIX = '[ROOT REPOSITORY]'; 11 | 12 | const ROOT_REPOSITORY_UNSUPPORTED_COMMANDS = [ 13 | require( '../commands/close' ).name, 14 | require( '../commands/save' ).name 15 | ]; 16 | 17 | function addRootRepositorySuffix( packageName, { bold = false } = {} ) { 18 | let suffix = ROOT_REPOSITORY_SUFFIX; 19 | 20 | if ( bold ) { 21 | suffix = chalk.bold( suffix ); 22 | } 23 | 24 | return [ packageName, suffix ].join( ' ' ); 25 | } 26 | 27 | function doesCommandSupportRootPackage( command ) { 28 | return !ROOT_REPOSITORY_UNSUPPORTED_COMMANDS.includes( command.name ); 29 | } 30 | 31 | module.exports = { 32 | addRootRepositorySuffix, 33 | doesCommandSupportRootPackage 34 | }; 35 | -------------------------------------------------------------------------------- /lib/utils/shell.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const shell = require( 'shelljs' ); 9 | 10 | module.exports = function exec( command ) { 11 | return new Promise( ( resolve, reject ) => { 12 | const response = shell.exec( command, { silent: true } ); 13 | 14 | // Git commands can write output to "stderr" even the task was executed properly. 15 | if ( response.code === 0 ) { 16 | return resolve( response.stderr + response.stdout ); 17 | } 18 | 19 | // Gulp writes to "stdout" even the task wasn't executed properly. 20 | return reject( response.stdout + response.stderr ); 21 | } ); 22 | }; 23 | -------------------------------------------------------------------------------- /lib/utils/updatejsonfile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const fs = require( 'fs' ); 9 | 10 | /** 11 | * Updates JSON file under specified path. 12 | * @param {String} path Path to file on disk. 13 | * @param {Function} updateFunction Function that will be called with parsed JSON object. It should return 14 | * modified JSON object to save. 15 | */ 16 | module.exports = function updateJsonFile( path, updateFunction ) { 17 | const contents = fs.readFileSync( path, 'utf-8' ); 18 | let json = JSON.parse( contents ); 19 | json = updateFunction( json ); 20 | 21 | fs.writeFileSync( path, JSON.stringify( json, null, 2 ) + '\n', 'utf-8' ); 22 | }; 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mrgit", 3 | "version": "4.1.0", 4 | "description": "A tool for managing projects build using multiple repositories.", 5 | "keywords": [ 6 | "git", 7 | "repository", 8 | "submodule", 9 | "package", 10 | "multi-repository", 11 | "multi-repo", 12 | "lerna", 13 | "yarn", 14 | "workspaces" 15 | ], 16 | "main": "index.js", 17 | "dependencies": { 18 | "chalk": "^4.1.0", 19 | "cli-table": "^0.3.1", 20 | "generic-pool": "^3.7.1", 21 | "meow": "^9.0.0", 22 | "minimatch": "^4.0.0", 23 | "minimist": "^1.2.5", 24 | "minimist-options": "^4.1.0", 25 | "shelljs": "^0.10.0", 26 | "upath": "^2.0.0" 27 | }, 28 | "devDependencies": { 29 | "@ckeditor/ckeditor5-dev-bump-year": "^53.0.0", 30 | "@ckeditor/ckeditor5-dev-changelog": "^53.0.0", 31 | "@ckeditor/ckeditor5-dev-ci": "^53.0.0", 32 | "@ckeditor/ckeditor5-dev-release-tools": "^53.0.0", 33 | "@inquirer/prompts": "^7.8.3", 34 | "@listr2/prompt-adapter-inquirer": "^3.0.2", 35 | "chai": "^4.2.0", 36 | "eslint": "^9.36.0", 37 | "eslint-config-ckeditor5": "^12.1.1", 38 | "eslint-plugin-ckeditor5-rules": "^12.1.1", 39 | "fs-extra": "^11.3.0", 40 | "husky": "^8.0.2", 41 | "lint-staged": "^10.2.11", 42 | "listr2": "^9.0.2", 43 | "mocha": "^10.0.0", 44 | "mockery": "^2.1.0", 45 | "nyc": "^15.1.0", 46 | "sinon": "^9.0.3" 47 | }, 48 | "pnpm": { 49 | "overrides": { 50 | "form-data": "^4.0.4" 51 | } 52 | }, 53 | "repository": { 54 | "type": "git", 55 | "url": "https://github.com/cksource/mrgit.git" 56 | }, 57 | "engines": { 58 | "node": ">=22.0.0", 59 | "pnpm": ">=10.14.0", 60 | "yarn": "\n\n┌─────────────────────────┐\n│ Hey, we use pnpm now! │\n└─────────────────────────┘\n\n" 61 | }, 62 | "author": "CKSource (http://cksource.com/)", 63 | "license": "MIT", 64 | "bugs": "https://github.com/cksource/mrgit/issues", 65 | "homepage": "https://github.com/cksource/mrgit#readme", 66 | "scripts": { 67 | "nice": "ckeditor5-dev-changelog-create-entry", 68 | "postinstall": "node ./scripts/postinstall.js", 69 | "test": "mocha tests --recursive", 70 | "coverage": "nyc --reporter=lcov --reporter=text-summary pnpm run test", 71 | "lint": "eslint", 72 | "release:prepare-changelog": "node ./scripts/preparechangelog.mjs", 73 | "release:prepare-packages": "node ./scripts/preparepackages.mjs", 74 | "release:publish-packages": "node ./scripts/publishpackages.mjs" 75 | }, 76 | "bin": { 77 | "mrgit": "./index.js" 78 | }, 79 | "files": [ 80 | "index.js", 81 | "lib", 82 | "README.md", 83 | "CHANGELOG.md" 84 | ], 85 | "lint-staged": { 86 | "**/*": [ 87 | "eslint --quiet" 88 | ] 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | shellEmulator: true 2 | shamefullyHoist: true 3 | preferFrozenLockfile: true 4 | 5 | onlyBuiltDependencies: 6 | - esbuild 7 | -------------------------------------------------------------------------------- /scripts/bump-year.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 5 | * For licensing, see LICENSE.md. 6 | */ 7 | 8 | /* 9 | 10 | Usage: 11 | node scripts/bump-year.mjs 12 | 13 | And after reviewing the changes: 14 | git commit -am "Internal: Bumped the year." && git push 15 | 16 | */ 17 | 18 | import { bumpYear } from '@ckeditor/ckeditor5-dev-bump-year'; 19 | 20 | bumpYear( { 21 | cwd: process.cwd(), 22 | globPatterns: [ 23 | { // LICENSE.md, .eslintrc.js, etc. 24 | pattern: '*', 25 | options: { 26 | dot: true 27 | } 28 | }, 29 | { 30 | pattern: '.husky/*' 31 | }, 32 | { 33 | pattern: '!(coverage|.nyc_output)/**' 34 | } 35 | ] 36 | } ); 37 | -------------------------------------------------------------------------------- /scripts/ci/is-project-ready-to-release.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 5 | * For licensing, see LICENSE.md. 6 | */ 7 | 8 | import { createRequire } from 'module'; 9 | import * as releaseTools from '@ckeditor/ckeditor5-dev-release-tools'; 10 | 11 | const require = createRequire( import.meta.url ); 12 | const { name: packageName } = require( '../../package.json' ); 13 | 14 | const changelogVersion = releaseTools.getLastFromChangelog(); 15 | const npmTag = releaseTools.getNpmTagFromVersion( changelogVersion ); 16 | 17 | releaseTools.isVersionPublishableForTag( packageName, changelogVersion, npmTag ) 18 | .then( result => { 19 | if ( !result ) { 20 | console.error( `The proposed changelog (${ changelogVersion }) version is not higher than the already published one.` ); 21 | process.exit( 1 ); 22 | } else { 23 | console.log( 'The project is ready to release.' ); 24 | } 25 | } ); 26 | 27 | -------------------------------------------------------------------------------- /scripts/postinstall.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const path = require( 'path' ); 9 | const fs = require( 'fs' ); 10 | const ROOT_DIRECTORY = path.join( __dirname, '..' ); 11 | 12 | // When installing a repository as a dependency, the `.git` directory does not exist. 13 | // In such a case, husky should not attach its hooks as npm treats it as a package, not a git repository. 14 | if ( fs.existsSync( path.join( ROOT_DIRECTORY, '.git' ) ) ) { 15 | require( 'husky' ).install(); 16 | } 17 | -------------------------------------------------------------------------------- /scripts/preparechangelog.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | import { generateChangelogForSingleRepository } from '@ckeditor/ckeditor5-dev-changelog'; 7 | import parseArguments from './utils/parsearguments.mjs'; 8 | import { ROOT_DIRECTORY } from './utils/constants.mjs'; 9 | 10 | const cliOptions = parseArguments( process.argv.slice( 2 ) ); 11 | 12 | const changelogOptions = { 13 | cwd: ROOT_DIRECTORY, 14 | disableFilesystemOperations: cliOptions.dryRun 15 | }; 16 | 17 | if ( cliOptions.date ) { 18 | changelogOptions.date = cliOptions.date; 19 | } 20 | 21 | generateChangelogForSingleRepository( changelogOptions ) 22 | .then( maybeChangelog => { 23 | if ( maybeChangelog ) { 24 | console.log( maybeChangelog ); 25 | } 26 | } ); 27 | -------------------------------------------------------------------------------- /scripts/preparepackages.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 5 | * For licensing, see LICENSE.md. 6 | */ 7 | 8 | import fs from 'fs-extra'; 9 | import { Listr } from 'listr2'; 10 | import { ListrInquirerPromptAdapter } from '@listr2/prompt-adapter-inquirer'; 11 | import { confirm } from '@inquirer/prompts'; 12 | import * as releaseTools from '@ckeditor/ckeditor5-dev-release-tools'; 13 | import parseArguments from './utils/parsearguments.mjs'; 14 | import getListrOptions from './utils/getlistroptions.mjs'; 15 | import { RELEASE_DIRECTORY } from './utils/constants.mjs'; 16 | 17 | const cliArguments = parseArguments( process.argv.slice( 2 ) ); 18 | const latestVersion = releaseTools.getLastFromChangelog(); 19 | const versionChangelog = releaseTools.getChangesForVersion( latestVersion ); 20 | 21 | const tasks = new Listr( [ 22 | { 23 | title: 'Verifying the repository.', 24 | task: async () => { 25 | const errors = await releaseTools.validateRepositoryToRelease( { 26 | version: latestVersion, 27 | changes: versionChangelog, 28 | branch: cliArguments.branch 29 | } ); 30 | 31 | if ( !errors.length ) { 32 | return; 33 | } 34 | 35 | return Promise.reject( 'Aborted due to errors.\n' + errors.map( message => `* ${ message }` ).join( '\n' ) ); 36 | }, 37 | skip: () => { 38 | // When compiling the packages only, do not validate the release. 39 | if ( cliArguments.compileOnly ) { 40 | return true; 41 | } 42 | 43 | return false; 44 | } 45 | }, 46 | { 47 | title: 'Check the release directory.', 48 | task: async ( ctx, task ) => { 49 | const isAvailable = await fs.exists( RELEASE_DIRECTORY ); 50 | 51 | if ( !isAvailable ) { 52 | return fs.ensureDir( RELEASE_DIRECTORY ); 53 | } 54 | 55 | const isEmpty = ( await fs.readdir( RELEASE_DIRECTORY ) ).length === 0; 56 | 57 | if ( isEmpty ) { 58 | return Promise.resolve(); 59 | } 60 | 61 | // Do not ask when running on CI. 62 | if ( cliArguments.ci ) { 63 | return fs.emptyDir( RELEASE_DIRECTORY ); 64 | } 65 | 66 | const shouldContinue = await task.prompt( ListrInquirerPromptAdapter ) 67 | .run( confirm, { 68 | message: 'The release directory must be empty. Continue and remove all files?' 69 | } ); 70 | 71 | if ( !shouldContinue ) { 72 | return Promise.reject( 'Aborting as requested.' ); 73 | } 74 | 75 | return fs.emptyDir( RELEASE_DIRECTORY ); 76 | } 77 | }, 78 | { 79 | title: 'Updating the `#version` field.', 80 | task: () => { 81 | return releaseTools.updateVersions( { 82 | version: latestVersion 83 | } ); 84 | }, 85 | skip: () => { 86 | // When compiling the packages only, do not validate the release. 87 | if ( cliArguments.compileOnly ) { 88 | return true; 89 | } 90 | 91 | return false; 92 | } 93 | }, 94 | { 95 | title: 'Creating the `mrgit2` package in the release directory.', 96 | task: async () => { 97 | return releaseTools.prepareRepository( { 98 | outputDirectory: RELEASE_DIRECTORY, 99 | // `cwd` points to the repository root directory. 100 | rootPackageJson: await fs.readJson( './package.json' ) 101 | } ); 102 | } 103 | }, 104 | { 105 | title: 'Cleaning-up.', 106 | task: () => { 107 | return releaseTools.cleanUpPackages( { 108 | packagesDirectory: RELEASE_DIRECTORY 109 | } ); 110 | } 111 | }, 112 | { 113 | title: 'Commit & tag.', 114 | task: () => { 115 | return releaseTools.commitAndTag( { 116 | version: latestVersion, 117 | files: [ 118 | 'package.json' 119 | ] 120 | } ); 121 | }, 122 | skip: () => { 123 | // When compiling the packages only, do not validate the release. 124 | if ( cliArguments.compileOnly ) { 125 | return true; 126 | } 127 | 128 | return false; 129 | } 130 | } 131 | ], getListrOptions( cliArguments ) ); 132 | 133 | tasks.run() 134 | .catch( err => { 135 | process.exitCode = 1; 136 | 137 | console.log( '' ); 138 | console.error( err ); 139 | } ); 140 | -------------------------------------------------------------------------------- /scripts/publishpackages.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 5 | * For licensing, see LICENSE.md. 6 | */ 7 | 8 | import { Listr } from 'listr2'; 9 | import { ListrInquirerPromptAdapter } from '@listr2/prompt-adapter-inquirer'; 10 | import { confirm } from '@inquirer/prompts'; 11 | import * as releaseTools from '@ckeditor/ckeditor5-dev-release-tools'; 12 | import parseArguments from './utils/parsearguments.mjs'; 13 | import getListrOptions from './utils/getlistroptions.mjs'; 14 | import { RELEASE_DIRECTORY } from './utils/constants.mjs'; 15 | 16 | const cliArguments = parseArguments( process.argv.slice( 2 ) ); 17 | const latestVersion = releaseTools.getLastFromChangelog(); 18 | const versionChangelog = releaseTools.getChangesForVersion( latestVersion ); 19 | 20 | let githubToken; 21 | 22 | if ( !cliArguments.npmTag ) { 23 | cliArguments.npmTag = releaseTools.getNpmTagFromVersion( latestVersion ); 24 | } 25 | 26 | const tasks = new Listr( [ 27 | { 28 | title: 'Publishing packages.', 29 | task: async ( _, task ) => { 30 | return releaseTools.publishPackages( { 31 | packagesDirectory: RELEASE_DIRECTORY, 32 | npmOwner: 'ckeditor', 33 | npmTag: cliArguments.npmTag, 34 | listrTask: task, 35 | confirmationCallback: () => { 36 | if ( cliArguments.ci ) { 37 | return true; 38 | } 39 | 40 | return task.prompt( ListrInquirerPromptAdapter ) 41 | .run( confirm, { message: 'Do you want to continue?' } ); 42 | } 43 | } ); 44 | } 45 | }, 46 | { 47 | title: 'Pushing changes.', 48 | task: () => { 49 | return releaseTools.push( { 50 | releaseBranch: cliArguments.branch, 51 | version: latestVersion 52 | } ); 53 | } 54 | }, 55 | { 56 | title: 'Creating the release page.', 57 | task: async ( _, task ) => { 58 | const releaseUrl = await releaseTools.createGithubRelease( { 59 | token: githubToken, 60 | version: latestVersion, 61 | description: versionChangelog 62 | } ); 63 | 64 | task.output = `Release page: ${ releaseUrl }`; 65 | }, 66 | options: { 67 | persistentOutput: true 68 | } 69 | } 70 | ], getListrOptions( cliArguments ) ); 71 | 72 | ( async () => { 73 | try { 74 | if ( process.env.CKE5_RELEASE_TOKEN ) { 75 | githubToken = process.env.CKE5_RELEASE_TOKEN; 76 | } else { 77 | githubToken = await releaseTools.provideToken(); 78 | } 79 | 80 | await tasks.run(); 81 | } catch ( err ) { 82 | process.exitCode = 1; 83 | 84 | console.error( err ); 85 | } 86 | } )(); 87 | -------------------------------------------------------------------------------- /scripts/utils/constants.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | import upath from 'upath'; 7 | import { fileURLToPath } from 'url'; 8 | 9 | const __filename = fileURLToPath( import.meta.url ); 10 | const __dirname = upath.dirname( __filename ); 11 | 12 | export const ROOT_DIRECTORY = upath.join( __dirname, '..', '..' ); 13 | 14 | export const RELEASE_DIRECTORY = 'release'; 15 | -------------------------------------------------------------------------------- /scripts/utils/getlistroptions.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | /** 7 | * @param {ReleaseOptions} cliArguments 8 | * @returns {Object} 9 | */ 10 | export default function getListrOptions( cliArguments ) { 11 | return { 12 | renderer: cliArguments.verbose ? 'verbose' : 'default' 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /scripts/utils/parsearguments.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | import minimist from 'minimist'; 7 | 8 | /** 9 | * @param {Array.} cliArguments 10 | * @returns {ReleaseOptions} options 11 | */ 12 | export default function parseArguments( cliArguments ) { 13 | const config = { 14 | boolean: [ 15 | 'verbose', 16 | 'compile-only', 17 | 'ci', 18 | 'dry-run' 19 | ], 20 | 21 | string: [ 22 | 'branch', 23 | 'from', 24 | 'npm-tag', 25 | 'date' 26 | ], 27 | 28 | default: { 29 | ci: false, 30 | verbose: false, 31 | 'compile-only': false, 32 | branch: 'master', 33 | 'npm-tag': null, 34 | 'dry-run': false 35 | } 36 | }; 37 | 38 | const options = minimist( cliArguments, config ); 39 | 40 | replaceKebabCaseWithCamelCase( options, [ 41 | 'npm-tag', 42 | 'compile-only', 43 | 'dry-run' 44 | ] ); 45 | 46 | if ( process.env.CI ) { 47 | options.ci = true; 48 | } 49 | 50 | return options; 51 | } 52 | 53 | function replaceKebabCaseWithCamelCase( options, keys ) { 54 | for ( const key of keys ) { 55 | const camelCaseKey = toCamelCase( key ); 56 | 57 | options[ camelCaseKey ] = options[ key ]; 58 | delete options[ key ]; 59 | } 60 | } 61 | 62 | function toCamelCase( value ) { 63 | return value.split( '-' ) 64 | .map( ( item, index ) => { 65 | if ( index == 0 ) { 66 | return item.toLowerCase(); 67 | } 68 | 69 | return item.charAt( 0 ).toUpperCase() + item.slice( 1 ).toLowerCase(); 70 | } ) 71 | .join( '' ); 72 | } 73 | 74 | /** 75 | * @typedef {Object} ReleaseOptions 76 | * 77 | * @property {String|null} [npmTag=null] 78 | * 79 | * @property {String} [from] 80 | * 81 | * @property {String} [branch='master'] 82 | * 83 | * @property {Boolean} [compileOnly=false] 84 | * 85 | * @property {Boolean} [verbose=false] 86 | * 87 | * @property {Boolean} [ci=false] 88 | * 89 | * @property {Boolean} [dryRun=false] 90 | * 91 | * @property {String} [date] 92 | */ 93 | -------------------------------------------------------------------------------- /tests/commands/checkout.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | /* jshint mocha:true */ 7 | 8 | 'use strict'; 9 | 10 | const sinon = require( 'sinon' ); 11 | const mockery = require( 'mockery' ); 12 | const expect = require( 'chai' ).expect; 13 | 14 | describe( 'commands/checkout', () => { 15 | let checkoutCommand, stubs, commandData, toolOptions; 16 | 17 | beforeEach( () => { 18 | mockery.enable( { 19 | useCleanCache: true, 20 | warnOnReplace: false, 21 | warnOnUnregistered: false 22 | } ); 23 | 24 | stubs = { 25 | execCommand: { 26 | execute: sinon.stub() 27 | }, 28 | gitStatusParser: sinon.stub() 29 | }; 30 | 31 | toolOptions = {}; 32 | 33 | commandData = { 34 | arguments: [], 35 | repository: { 36 | branch: 'master' 37 | }, 38 | toolOptions 39 | }; 40 | 41 | mockery.registerMock( './exec', stubs.execCommand ); 42 | mockery.registerMock( '../utils/gitstatusparser', stubs.gitStatusParser ); 43 | 44 | checkoutCommand = require( '../../lib/commands/checkout' ); 45 | } ); 46 | 47 | afterEach( () => { 48 | sinon.restore(); 49 | mockery.deregisterAll(); 50 | mockery.disable(); 51 | } ); 52 | 53 | describe( '#helpMessage', () => { 54 | it( 'defines help screen', () => { 55 | expect( checkoutCommand.helpMessage ).is.a( 'string' ); 56 | } ); 57 | } ); 58 | 59 | describe( '#name', () => { 60 | it( 'returns a full name of executed command', () => { 61 | expect( checkoutCommand.name ).is.a( 'string' ); 62 | } ); 63 | } ); 64 | 65 | describe( 'execute()', () => { 66 | it( 'rejects promise if called command returned an error', () => { 67 | const error = new Error( 'Unexpected error.' ); 68 | 69 | stubs.execCommand.execute.rejects( { 70 | logs: { 71 | error: [ error.stack ] 72 | } 73 | } ); 74 | 75 | return checkoutCommand.execute( commandData ) 76 | .then( 77 | () => { 78 | throw new Error( 'Supposed to be rejected.' ); 79 | }, 80 | response => { 81 | expect( response.logs.error[ 0 ].split( '\n' )[ 0 ] ).to.equal( `Error: ${ error.message }` ); 82 | } 83 | ); 84 | } ); 85 | 86 | it( 'checkouts to the correct branch', () => { 87 | stubs.execCommand.execute.resolves( { 88 | logs: { 89 | info: [ 90 | 'Already on \'master\'', 91 | 'Already on \'master\'\nYour branch is up-to-date with \'origin/master\'.' 92 | ] 93 | } 94 | } ); 95 | 96 | return checkoutCommand.execute( commandData ) 97 | .then( commandResponse => { 98 | expect( stubs.execCommand.execute.calledOnce ).to.equal( true ); 99 | expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { 100 | repository: { 101 | branch: 'master' 102 | }, 103 | arguments: [ 'git checkout master' ], 104 | toolOptions 105 | } ); 106 | 107 | expect( commandResponse.logs.info ).to.deep.equal( [ 108 | 'Already on \'master\'', 109 | 'Already on \'master\'\nYour branch is up-to-date with \'origin/master\'.' 110 | ] ); 111 | } ); 112 | } ); 113 | 114 | it( 'checkouts to specified branch', () => { 115 | commandData.arguments.push( 'develop' ); 116 | 117 | stubs.execCommand.execute.resolves( { 118 | logs: { 119 | info: [ 120 | 'Switched to branch \'develop\'', 121 | 'Your branch is up to date with \'origin/develop\'.' 122 | ] 123 | } 124 | } ); 125 | 126 | return checkoutCommand.execute( commandData ) 127 | .then( commandResponse => { 128 | expect( stubs.execCommand.execute.calledOnce ).to.equal( true ); 129 | expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { 130 | repository: { 131 | branch: 'master' 132 | }, 133 | arguments: [ 'git checkout develop' ], 134 | toolOptions 135 | } ); 136 | 137 | expect( commandResponse.logs.info ).to.deep.equal( [ 138 | 'Switched to branch \'develop\'', 139 | 'Your branch is up to date with \'origin/develop\'.' 140 | ] ); 141 | } ); 142 | } ); 143 | 144 | it( 'creates a new branch if a repository has changes that could be committed and specified --branch option', () => { 145 | toolOptions.branch = 'develop'; 146 | 147 | stubs.execCommand.execute.onFirstCall().resolves( { 148 | logs: { 149 | info: [ 150 | 'Response returned by "git status" command.' 151 | ] 152 | } 153 | } ); 154 | 155 | stubs.execCommand.execute.onSecondCall().resolves( { 156 | logs: { 157 | info: [ 158 | 'Switched to a new branch \'develop\'' 159 | ] 160 | } 161 | } ); 162 | 163 | stubs.gitStatusParser.returns( { anythingToCommit: true } ); 164 | 165 | return checkoutCommand.execute( commandData ) 166 | .then( commandResponse => { 167 | expect( stubs.execCommand.execute.calledTwice ).to.equal( true ); 168 | 169 | expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { 170 | repository: { 171 | branch: 'master' 172 | }, 173 | arguments: [ 'git status --branch --porcelain' ], 174 | toolOptions 175 | } ); 176 | 177 | expect( stubs.execCommand.execute.secondCall.args[ 0 ] ).to.deep.equal( { 178 | repository: { 179 | branch: 'master' 180 | }, 181 | arguments: [ 'git checkout -b develop' ], 182 | toolOptions 183 | } ); 184 | 185 | expect( commandResponse.logs.info ).to.deep.equal( [ 186 | 'Switched to a new branch \'develop\'' 187 | ] ); 188 | } ); 189 | } ); 190 | 191 | it( 'does not create a branch if a repository has no-changes that could be committed when specified --branch option', () => { 192 | toolOptions.branch = 'develop'; 193 | 194 | stubs.execCommand.execute.onFirstCall().resolves( { 195 | logs: { 196 | info: [ 197 | 'Response returned by "git status" command.' 198 | ] 199 | } 200 | } ); 201 | 202 | stubs.gitStatusParser.returns( { anythingToCommit: false } ); 203 | 204 | return checkoutCommand.execute( commandData ) 205 | .then( commandResponse => { 206 | expect( stubs.execCommand.execute.calledOnce ).to.equal( true ); 207 | 208 | expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { 209 | repository: { 210 | branch: 'master' 211 | }, 212 | arguments: [ 'git status --branch --porcelain' ], 213 | toolOptions 214 | } ); 215 | 216 | expect( commandResponse.logs.info ).to.deep.equal( [ 217 | 'Repository does not contain changes to commit. New branch was not created.' 218 | ] ); 219 | } ); 220 | } ); 221 | } ); 222 | } ); 223 | -------------------------------------------------------------------------------- /tests/commands/commit.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | /* jshint mocha:true */ 7 | 8 | 'use strict'; 9 | 10 | const sinon = require( 'sinon' ); 11 | const mockery = require( 'mockery' ); 12 | const expect = require( 'chai' ).expect; 13 | 14 | describe( 'commands/commit', () => { 15 | let commitCommand, stubs, commandData, toolOptions; 16 | 17 | beforeEach( () => { 18 | mockery.enable( { 19 | useCleanCache: true, 20 | warnOnReplace: false, 21 | warnOnUnregistered: false 22 | } ); 23 | 24 | stubs = { 25 | execCommand: { 26 | execute: sinon.stub() 27 | }, 28 | gitStatusParser: sinon.stub() 29 | }; 30 | 31 | toolOptions = {}; 32 | 33 | commandData = { 34 | arguments: [], 35 | repository: { 36 | branch: 'master' 37 | }, 38 | toolOptions 39 | }; 40 | 41 | mockery.registerMock( './exec', stubs.execCommand ); 42 | mockery.registerMock( '../utils/gitstatusparser', stubs.gitStatusParser ); 43 | 44 | commitCommand = require( '../../lib/commands/commit' ); 45 | } ); 46 | 47 | afterEach( () => { 48 | sinon.restore(); 49 | mockery.deregisterAll(); 50 | mockery.disable(); 51 | } ); 52 | 53 | describe( '#helpMessage', () => { 54 | it( 'defines help screen', () => { 55 | expect( commitCommand.helpMessage ).is.a( 'string' ); 56 | } ); 57 | } ); 58 | 59 | describe( '#name', () => { 60 | it( 'returns a full name of executed command', () => { 61 | expect( commitCommand.name ).is.a( 'string' ); 62 | } ); 63 | } ); 64 | 65 | describe( 'beforeExecute()', () => { 66 | it( 'throws an error if merge message is missing', () => { 67 | sinon.stub( commitCommand, '_parseArguments' ).returns( {} ); 68 | 69 | expect( () => { 70 | commitCommand.beforeExecute( [ 'commit' ], {} ); 71 | } ).to.throw( Error, 'Missing --message (-m) option. Call "mrgit commit -h" in order to read more.' ); 72 | } ); 73 | 74 | it( 'does nothing if specified message for commit (as git option)', () => { 75 | expect( () => { 76 | commitCommand.beforeExecute( [ 'commit', '--message', 'Test' ], {} ); 77 | } ).to.not.throw( Error ); 78 | } ); 79 | 80 | it( 'does nothing if specified message for commit (as mrgit option)', () => { 81 | expect( () => { 82 | commitCommand.beforeExecute( [ 'commit' ], { message: 'Test.' } ); 83 | } ).to.not.throw( Error ); 84 | } ); 85 | } ); 86 | 87 | describe( 'execute()', () => { 88 | it( 'rejects promise if called command returned an error', () => { 89 | const error = new Error( 'Unexpected error.' ); 90 | 91 | stubs.execCommand.execute.rejects( { 92 | logs: { 93 | error: [ error.stack ] 94 | } 95 | } ); 96 | 97 | return commitCommand.execute( commandData ) 98 | .then( 99 | () => { 100 | throw new Error( 'Supposed to be rejected.' ); 101 | }, 102 | response => { 103 | expect( response.logs.error[ 0 ].split( '\n' )[ 0 ] ).to.equal( `Error: ${ error.message }` ); 104 | } 105 | ); 106 | } ); 107 | 108 | it( 'commits all changes', () => { 109 | toolOptions.message = 'Test.'; 110 | 111 | stubs.execCommand.execute.onFirstCall().resolves( { 112 | logs: { 113 | info: [ 114 | 'Response returned by "git status" command.' 115 | ] 116 | } 117 | } ); 118 | 119 | stubs.execCommand.execute.onSecondCall().resolves( { 120 | logs: { 121 | info: [ 122 | '[master a89f9ee] Test.' 123 | ] 124 | } 125 | } ); 126 | 127 | stubs.gitStatusParser.returns( { anythingToCommit: true } ); 128 | 129 | return commitCommand.execute( commandData ) 130 | .then( commandResponse => { 131 | expect( stubs.execCommand.execute.calledTwice ).to.equal( true ); 132 | 133 | expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { 134 | repository: { 135 | branch: 'master' 136 | }, 137 | arguments: [ 'git status --branch --porcelain' ], 138 | toolOptions 139 | } ); 140 | 141 | expect( stubs.execCommand.execute.secondCall.args[ 0 ] ).to.deep.equal( { 142 | repository: { 143 | branch: 'master' 144 | }, 145 | arguments: [ 'git commit -a -m "Test."' ], 146 | toolOptions 147 | } ); 148 | 149 | expect( commandResponse.logs.info ).to.deep.equal( [ 150 | '[master a89f9ee] Test.' 151 | ] ); 152 | } ); 153 | } ); 154 | 155 | it( 'commits all changes (message was specified as a git option)', () => { 156 | commandData.arguments.push( '--message' ); 157 | commandData.arguments.push( 'Test.' ); 158 | 159 | stubs.execCommand.execute.onFirstCall().resolves( { 160 | logs: { 161 | info: [ 162 | 'Response returned by "git status" command.' 163 | ] 164 | } 165 | } ); 166 | 167 | stubs.execCommand.execute.onSecondCall().resolves( { 168 | logs: { 169 | info: [ 170 | '[master a89f9ee] Test.' 171 | ] 172 | } 173 | } ); 174 | 175 | stubs.gitStatusParser.returns( { anythingToCommit: true } ); 176 | 177 | return commitCommand.execute( commandData ) 178 | .then( commandResponse => { 179 | expect( stubs.execCommand.execute.calledTwice ).to.equal( true ); 180 | 181 | expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { 182 | repository: { 183 | branch: 'master' 184 | }, 185 | arguments: [ 'git status --branch --porcelain' ], 186 | toolOptions 187 | } ); 188 | 189 | expect( stubs.execCommand.execute.secondCall.args[ 0 ] ).to.deep.equal( { 190 | repository: { 191 | branch: 'master' 192 | }, 193 | arguments: [ 'git commit -a -m "Test."' ], 194 | toolOptions 195 | } ); 196 | 197 | expect( commandResponse.logs.info ).to.deep.equal( [ 198 | '[master a89f9ee] Test.' 199 | ] ); 200 | } ); 201 | } ); 202 | 203 | it( 'accepts `--no-verify` option', () => { 204 | commandData.arguments.push( '-n' ); 205 | commandData.arguments.push( '--message' ); 206 | commandData.arguments.push( 'Test' ); 207 | 208 | stubs.execCommand.execute.onFirstCall().resolves( { 209 | logs: { 210 | info: [ 211 | 'Response returned by "git status" command.' 212 | ] 213 | } 214 | } ); 215 | 216 | stubs.execCommand.execute.onSecondCall().resolves( { 217 | logs: { 218 | info: [ 219 | '[master a89f9ee] Test' 220 | ] 221 | } 222 | } ); 223 | 224 | stubs.gitStatusParser.returns( { anythingToCommit: true } ); 225 | 226 | return commitCommand.execute( commandData ) 227 | .then( commandResponse => { 228 | expect( stubs.execCommand.execute.calledTwice ).to.equal( true ); 229 | 230 | expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { 231 | repository: { 232 | branch: 'master' 233 | }, 234 | arguments: [ 'git status --branch --porcelain' ], 235 | toolOptions 236 | } ); 237 | 238 | expect( stubs.execCommand.execute.secondCall.args[ 0 ] ).to.deep.equal( { 239 | repository: { 240 | branch: 'master' 241 | }, 242 | arguments: [ 'git commit -a -m "Test" -n' ], 243 | toolOptions 244 | } ); 245 | 246 | expect( commandResponse.logs.info ).to.deep.equal( [ 247 | '[master a89f9ee] Test' 248 | ] ); 249 | } ); 250 | } ); 251 | 252 | it( 'accepts duplicated `--message` option', () => { 253 | toolOptions.message = [ 254 | 'Test.', 255 | 'Foo.' 256 | ]; 257 | 258 | stubs.execCommand.execute.onFirstCall().resolves( { 259 | logs: { 260 | info: [ 261 | 'Response returned by "git status" command.' 262 | ] 263 | } 264 | } ); 265 | 266 | stubs.execCommand.execute.onSecondCall().resolves( { 267 | logs: { 268 | info: [ 269 | '[master a89f9ee] Test.' 270 | ] 271 | } 272 | } ); 273 | 274 | stubs.gitStatusParser.returns( { anythingToCommit: true } ); 275 | 276 | return commitCommand.execute( commandData ) 277 | .then( commandResponse => { 278 | expect( stubs.execCommand.execute.calledTwice ).to.equal( true ); 279 | 280 | expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { 281 | repository: { 282 | branch: 'master' 283 | }, 284 | arguments: [ 'git status --branch --porcelain' ], 285 | toolOptions 286 | } ); 287 | 288 | expect( stubs.execCommand.execute.secondCall.args[ 0 ] ).to.deep.equal( { 289 | repository: { 290 | branch: 'master' 291 | }, 292 | arguments: [ 'git commit -a -m "Test." -m "Foo."' ], 293 | toolOptions 294 | } ); 295 | 296 | expect( commandResponse.logs.info ).to.deep.equal( [ 297 | '[master a89f9ee] Test.' 298 | ] ); 299 | } ); 300 | } ); 301 | 302 | it( 'accepts duplicated `--message` option (messages were specified as a git option)', () => { 303 | commandData.arguments.push( '--message' ); 304 | commandData.arguments.push( 'Test.' ); 305 | commandData.arguments.push( '-m' ); 306 | commandData.arguments.push( 'Foo.' ); 307 | 308 | stubs.execCommand.execute.onFirstCall().resolves( { 309 | logs: { 310 | info: [ 311 | 'Response returned by "git status" command.' 312 | ] 313 | } 314 | } ); 315 | 316 | stubs.execCommand.execute.onSecondCall().resolves( { 317 | logs: { 318 | info: [ 319 | '[master a89f9ee] Test.' 320 | ] 321 | } 322 | } ); 323 | 324 | stubs.gitStatusParser.returns( { anythingToCommit: true } ); 325 | 326 | return commitCommand.execute( commandData ) 327 | .then( commandResponse => { 328 | expect( stubs.execCommand.execute.calledTwice ).to.equal( true ); 329 | 330 | expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { 331 | repository: { 332 | branch: 'master' 333 | }, 334 | arguments: [ 'git status --branch --porcelain' ], 335 | toolOptions 336 | } ); 337 | 338 | expect( stubs.execCommand.execute.secondCall.args[ 0 ] ).to.deep.equal( { 339 | repository: { 340 | branch: 'master' 341 | }, 342 | arguments: [ 'git commit -a -m "Test." -m "Foo."' ], 343 | toolOptions 344 | } ); 345 | 346 | expect( commandResponse.logs.info ).to.deep.equal( [ 347 | '[master a89f9ee] Test.' 348 | ] ); 349 | } ); 350 | } ); 351 | 352 | it( 'does not commit if there is no changes', () => { 353 | commandData.arguments.push( '--message' ); 354 | commandData.arguments.push( 'Test.' ); 355 | 356 | stubs.execCommand.execute.onFirstCall().resolves( { 357 | logs: { 358 | info: [ 359 | 'Response returned by "git status" command.' 360 | ] 361 | } 362 | } ); 363 | 364 | stubs.gitStatusParser.returns( { anythingToCommit: false } ); 365 | 366 | return commitCommand.execute( commandData ) 367 | .then( commandResponse => { 368 | expect( stubs.execCommand.execute.calledOnce ).to.equal( true ); 369 | 370 | expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { 371 | repository: { 372 | branch: 'master' 373 | }, 374 | arguments: [ 'git status --branch --porcelain' ], 375 | toolOptions 376 | } ); 377 | 378 | expect( commandResponse.logs.info ).to.deep.equal( [ 379 | 'Nothing to commit.' 380 | ] ); 381 | } ); 382 | } ); 383 | 384 | it( 'does not commit if repository is in detached head mode', () => { 385 | toolOptions.message = 'Test.'; 386 | 387 | stubs.execCommand.execute.onFirstCall().resolves( { 388 | logs: { 389 | info: [ 390 | 'Response returned by "git status" command.' 391 | ] 392 | } 393 | } ); 394 | 395 | stubs.execCommand.execute.onSecondCall().resolves( { 396 | logs: { 397 | info: [ 398 | '[master a89f9ee] Test.' 399 | ] 400 | } 401 | } ); 402 | 403 | stubs.gitStatusParser.returns( { anythingToCommit: true, detachedHead: true } ); 404 | 405 | return commitCommand.execute( commandData ) 406 | .then( commandResponse => { 407 | expect( stubs.execCommand.execute.callCount ).to.equal( 1 ); 408 | 409 | expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { 410 | repository: { 411 | branch: 'master' 412 | }, 413 | arguments: [ 'git status --branch --porcelain' ], 414 | toolOptions 415 | } ); 416 | 417 | expect( commandResponse.logs.info ).to.deep.equal( [ 418 | 'This repository is currently in detached head mode - skipping.' 419 | ] ); 420 | } ); 421 | } ); 422 | } ); 423 | } ); 424 | -------------------------------------------------------------------------------- /tests/commands/diff.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | /* jshint mocha:true */ 7 | 8 | 'use strict'; 9 | 10 | const sinon = require( 'sinon' ); 11 | const mockery = require( 'mockery' ); 12 | const expect = require( 'chai' ).expect; 13 | 14 | describe( 'commands/diff', () => { 15 | let diffCommand, stubs, commandData; 16 | 17 | beforeEach( () => { 18 | mockery.enable( { 19 | useCleanCache: true, 20 | warnOnReplace: false, 21 | warnOnUnregistered: false 22 | } ); 23 | 24 | stubs = { 25 | execCommand: { 26 | execute: sinon.stub() 27 | } 28 | }; 29 | 30 | commandData = { 31 | arguments: [] 32 | }; 33 | 34 | mockery.registerMock( './exec', stubs.execCommand ); 35 | 36 | diffCommand = require( '../../lib/commands/diff' ); 37 | } ); 38 | 39 | afterEach( () => { 40 | sinon.restore(); 41 | mockery.deregisterAll(); 42 | mockery.disable(); 43 | } ); 44 | 45 | describe( '#helpMessage', () => { 46 | it( 'defines help screen', () => { 47 | expect( diffCommand.helpMessage ).is.a( 'string' ); 48 | } ); 49 | } ); 50 | 51 | describe( 'beforeExecute()', () => { 52 | it( 'informs about starting the process', () => { 53 | const consoleLog = sinon.stub( console, 'log' ); 54 | 55 | diffCommand.beforeExecute(); 56 | 57 | expect( consoleLog.calledOnce ).to.equal( true ); 58 | expect( consoleLog.firstCall.args[ 0 ] ).to.match( /Collecting changes\.\.\./ ); 59 | 60 | consoleLog.restore(); 61 | } ); 62 | } ); 63 | 64 | describe( 'execute()', () => { 65 | it( 'rejects promise if called command returned an error', () => { 66 | const error = new Error( 'Unexpected error.' ); 67 | 68 | stubs.execCommand.execute.rejects( { 69 | logs: { 70 | error: [ error.stack ] 71 | } 72 | } ); 73 | 74 | return diffCommand.execute( commandData ) 75 | .then( 76 | () => { 77 | throw new Error( 'Supposed to be rejected.' ); 78 | }, 79 | response => { 80 | expect( response.logs.error[ 0 ].split( '\n' )[ 0 ] ).to.equal( `Error: ${ error.message }` ); 81 | } 82 | ); 83 | } ); 84 | 85 | it( 'returns logs with the changes if any occurs', () => { 86 | const diffResult = 'diff --git a/gulpfile.js b/gulpfile.js\n' + 87 | 'index 40c0e59..0699706 100644\n' + 88 | '--- a/gulpfile.js\n' + 89 | '+++ b/gulpfile.js\n' + 90 | '@@ -20,3 +20,4 @@ const options = {\n' + 91 | ' gulp.task( \'lint\', () => ckeditor5Lint.lint( options ) );\n' + 92 | ' gulp.task( \'lint-staged\', () => ckeditor5Lint.lintStaged( options ) );\n' + 93 | ' gulp.task( \'pre-commit\', [ \'lint-staged\' ] );\n' + 94 | '+// Some comment.'; 95 | 96 | stubs.execCommand.execute.resolves( { 97 | logs: { 98 | info: [ diffResult ] 99 | } 100 | } ); 101 | 102 | return diffCommand.execute( commandData ) 103 | .then( diffResponse => { 104 | expect( stubs.execCommand.execute.calledOnce ).to.equal( true ); 105 | expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { 106 | arguments: [ 'git diff --color' ] 107 | } ); 108 | 109 | expect( diffResponse.logs.info[ 0 ] ).to.equal( diffResult ); 110 | } ); 111 | } ); 112 | 113 | it( 'does not return the logs when repository has not changed', () => { 114 | stubs.execCommand.execute.resolves( { logs: { info: [] } } ); 115 | 116 | return diffCommand.execute( commandData ) 117 | .then( diffResponse => { 118 | expect( stubs.execCommand.execute.calledOnce ).to.equal( true ); 119 | 120 | expect( diffResponse ).to.deep.equal( {} ); 121 | } ); 122 | } ); 123 | 124 | it( 'allows modifying the "git diff" command', () => { 125 | stubs.execCommand.execute.resolves( { logs: { info: [] } } ); 126 | 127 | commandData.arguments = [ 128 | '--stat', 129 | '--staged' 130 | ]; 131 | 132 | return diffCommand.execute( commandData ) 133 | .then( diffResponse => { 134 | expect( stubs.execCommand.execute.calledOnce ).to.equal( true ); 135 | expect( stubs.execCommand.execute.firstCall.args[ 0 ] ).to.deep.equal( { 136 | arguments: [ 'git diff --color --stat --staged' ] 137 | } ); 138 | 139 | expect( diffResponse ).to.deep.equal( {} ); 140 | } ); 141 | } ); 142 | } ); 143 | 144 | describe( 'afterExecute()', () => { 145 | it( 'should describe what kind of logs are displayed', () => { 146 | const logStub = sinon.stub( console, 'log' ); 147 | 148 | diffCommand.afterExecute(); 149 | 150 | expect( logStub.calledOnce ).to.equal( true ); 151 | logStub.restore(); 152 | } ); 153 | } ); 154 | } ); 155 | -------------------------------------------------------------------------------- /tests/commands/exec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | /* jshint mocha:true */ 7 | 8 | 'use strict'; 9 | 10 | const fs = require( 'fs' ); 11 | const path = require( 'upath' ); 12 | const sinon = require( 'sinon' ); 13 | const mockery = require( 'mockery' ); 14 | const expect = require( 'chai' ).expect; 15 | 16 | describe( 'commands/exec', () => { 17 | let execCommand, stubs, commandData; 18 | 19 | beforeEach( () => { 20 | mockery.enable( { 21 | useCleanCache: true, 22 | warnOnReplace: false, 23 | warnOnUnregistered: false 24 | } ); 25 | 26 | stubs = { 27 | shell: sinon.stub(), 28 | fs: { 29 | existsSync: sinon.stub( fs, 'existsSync' ) 30 | }, 31 | path: { 32 | join: sinon.stub( path, 'join' ).callsFake( ( ...chunks ) => chunks.join( '/' ) ) 33 | }, 34 | process: { 35 | chdir: sinon.stub( process, 'chdir' ) 36 | } 37 | }; 38 | 39 | commandData = { 40 | // Command `#execute` function is called without the "exec" command. 41 | // `mrgit exec pwd` => [ 'pwd' ] 42 | arguments: [ 'pwd' ], 43 | packageName: 'test-package', 44 | toolOptions: { 45 | cwd: __dirname, 46 | packages: 'packages' 47 | }, 48 | repository: { 49 | directory: 'test-package' 50 | } 51 | }; 52 | 53 | mockery.registerMock( '../utils/shell', stubs.shell ); 54 | 55 | execCommand = require( '../../lib/commands/exec' ); 56 | } ); 57 | 58 | afterEach( () => { 59 | sinon.restore(); 60 | mockery.deregisterAll(); 61 | mockery.disable(); 62 | } ); 63 | 64 | describe( '#helpMessage', () => { 65 | it( 'defines help screen', () => { 66 | expect( execCommand.helpMessage ).is.a( 'string' ); 67 | } ); 68 | } ); 69 | 70 | describe( 'beforeExecute()', () => { 71 | it( 'throws an error if command to execute is not specified', () => { 72 | expect( () => { 73 | // `beforeExecute` is called with full user's input (mrgit exec [command-to-execute]). 74 | execCommand.beforeExecute( [ 'exec' ] ); 75 | } ).to.throw( Error, 'Missing command to execute. Use: mrgit exec [command-to-execute].' ); 76 | } ); 77 | 78 | it( 'does nothing if command is specified', () => { 79 | expect( () => { 80 | execCommand.beforeExecute( [ 'exec', 'pwd' ] ); 81 | } ).to.not.throw( Error ); 82 | } ); 83 | } ); 84 | 85 | describe( 'execute()', () => { 86 | it( 'does not execute the command if package is not available', () => { 87 | stubs.fs.existsSync.returns( false ); 88 | 89 | return execCommand.execute( commandData ) 90 | .then( 91 | () => { 92 | throw new Error( 'Supposed to be rejected.' ); 93 | }, 94 | response => { 95 | const err = 'Package "test-package" is not available. Run "mrgit sync" in order to download the package.'; 96 | expect( response.logs.error[ 0 ] ).to.equal( err ); 97 | } 98 | ); 99 | } ); 100 | 101 | it( 'rejects promise if something went wrong', () => { 102 | const error = new Error( 'Unexpected error.' ); 103 | 104 | stubs.fs.existsSync.returns( true ); 105 | stubs.shell.returns( Promise.reject( error ) ); 106 | 107 | return execCommand.execute( commandData ) 108 | .then( 109 | () => { 110 | throw new Error( 'Supposed to be rejected.' ); 111 | }, 112 | response => { 113 | expect( stubs.process.chdir.calledTwice ).to.equal( true ); 114 | expect( stubs.process.chdir.firstCall.args[ 0 ] ).to.equal( 'packages/test-package' ); 115 | expect( stubs.process.chdir.secondCall.args[ 0 ] ).to.equal( __dirname ); 116 | expect( response.logs.error[ 0 ].split( '\n' )[ 0 ] ).to.equal( `Error: ${ error.message }` ); 117 | } 118 | ); 119 | } ); 120 | 121 | it( 'resolves promise if command has been executed', () => { 122 | const pwd = '/packages/test-package'; 123 | stubs.fs.existsSync.returns( true ); 124 | stubs.shell.returns( Promise.resolve( pwd ) ); 125 | 126 | return execCommand.execute( commandData ) 127 | .then( response => { 128 | expect( stubs.process.chdir.calledTwice ).to.equal( true ); 129 | expect( stubs.process.chdir.firstCall.args[ 0 ] ).to.equal( 'packages/test-package' ); 130 | expect( stubs.process.chdir.secondCall.args[ 0 ] ).to.equal( __dirname ); 131 | expect( response.logs.info[ 0 ] ).to.equal( pwd ); 132 | } ); 133 | } ); 134 | } ); 135 | } ); 136 | -------------------------------------------------------------------------------- /tests/commands/fetch.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | /* jshint mocha:true */ 7 | 8 | 'use strict'; 9 | 10 | const fs = require( 'fs' ); 11 | const path = require( 'upath' ); 12 | const sinon = require( 'sinon' ); 13 | const mockery = require( 'mockery' ); 14 | const expect = require( 'chai' ).expect; 15 | 16 | describe( 'commands/fetch', () => { 17 | let fetchCommand, stubs, commandData; 18 | 19 | beforeEach( () => { 20 | mockery.enable( { 21 | useCleanCache: true, 22 | warnOnReplace: false, 23 | warnOnUnregistered: false 24 | } ); 25 | 26 | stubs = { 27 | exec: sinon.stub(), 28 | fs: { 29 | existsSync: sinon.stub( fs, 'existsSync' ) 30 | }, 31 | path: { 32 | join: sinon.stub( path, 'join' ).callsFake( ( ...chunks ) => chunks.join( '/' ) ) 33 | }, 34 | execCommand: { 35 | execute: sinon.stub() 36 | } 37 | }; 38 | 39 | commandData = { 40 | arguments: [], 41 | packageName: 'test-package', 42 | toolOptions: { 43 | cwd: __dirname, 44 | packages: 'packages' 45 | }, 46 | repository: { 47 | directory: 'test-package', 48 | url: 'git@github.com/organization/test-package.git', 49 | branch: 'master' 50 | } 51 | }; 52 | 53 | mockery.registerMock( './exec', stubs.execCommand ); 54 | 55 | fetchCommand = require( '../../lib/commands/fetch' ); 56 | } ); 57 | 58 | afterEach( () => { 59 | sinon.restore(); 60 | mockery.deregisterAll(); 61 | mockery.disable(); 62 | } ); 63 | 64 | describe( '#helpMessage', () => { 65 | it( 'defines help screen', () => { 66 | expect( fetchCommand.helpMessage ).is.a( 'string' ); 67 | } ); 68 | } ); 69 | 70 | describe( 'execute()', () => { 71 | it( 'skips a package if is not available', () => { 72 | stubs.fs.existsSync.returns( false ); 73 | 74 | return fetchCommand.execute( commandData ) 75 | .then( response => { 76 | expect( response ).to.deep.equal( {} ); 77 | } ); 78 | } ); 79 | 80 | it( 'resolves promise after pushing the changes', () => { 81 | stubs.fs.existsSync.returns( true ); 82 | 83 | const exec = stubs.execCommand.execute; 84 | 85 | exec.returns( Promise.resolve( { 86 | logs: getCommandLogs( 'remote: Counting objects: 254, done.' ) 87 | } ) ); 88 | 89 | return fetchCommand.execute( commandData ) 90 | .then( response => { 91 | expect( exec.callCount ).to.equal( 1 ); 92 | expect( exec.firstCall.args[ 0 ].arguments[ 0 ] ).to.equal( 'git fetch' ); 93 | 94 | expect( response.logs.info ).to.deep.equal( [ 95 | 'remote: Counting objects: 254, done.' 96 | ] ); 97 | } ); 98 | } ); 99 | 100 | it( 'allows removing remote-tracking references that no longer exist', () => { 101 | commandData.arguments.push( '--prune' ); 102 | stubs.fs.existsSync.returns( true ); 103 | 104 | const exec = stubs.execCommand.execute; 105 | 106 | exec.returns( Promise.resolve( { 107 | logs: getCommandLogs( 'remote: Counting objects: 254, done.' ) 108 | } ) ); 109 | 110 | return fetchCommand.execute( commandData ) 111 | .then( response => { 112 | expect( exec.callCount ).to.equal( 1 ); 113 | expect( exec.firstCall.args[ 0 ].arguments[ 0 ] ).to.equal( 'git fetch -p' ); 114 | 115 | expect( response.logs.info ).to.deep.equal( [ 116 | 'remote: Counting objects: 254, done.' 117 | ] ); 118 | } ); 119 | } ); 120 | 121 | it( 'prints a log if repository is up-to-date', () => { 122 | stubs.fs.existsSync.returns( true ); 123 | 124 | const exec = stubs.execCommand.execute; 125 | 126 | exec.returns( Promise.resolve( { 127 | logs: { info: [] } 128 | } ) ); 129 | 130 | return fetchCommand.execute( commandData ) 131 | .then( response => { 132 | expect( exec.callCount ).to.equal( 1 ); 133 | expect( exec.firstCall.args[ 0 ].arguments[ 0 ] ).to.equal( 'git fetch' ); 134 | 135 | expect( response.logs.info ).to.deep.equal( [ 136 | 'Repository is up to date.' 137 | ] ); 138 | } ); 139 | } ); 140 | } ); 141 | 142 | describe( 'afterExecute()', () => { 143 | it( 'informs about number of processed packages', () => { 144 | const consoleLog = sinon.stub( console, 'log' ); 145 | 146 | const processedPackages = new Set(); 147 | processedPackages.add( 'package-1' ); 148 | processedPackages.add( 'package-2' ); 149 | 150 | fetchCommand.afterExecute( processedPackages ); 151 | 152 | expect( consoleLog.calledOnce ).to.equal( true ); 153 | expect( consoleLog.firstCall.args[ 0 ] ).to.match( /2 packages have been processed\./ ); 154 | 155 | consoleLog.restore(); 156 | } ); 157 | } ); 158 | 159 | function getCommandLogs( msg, isError = false ) { 160 | const logs = { 161 | error: [], 162 | info: [] 163 | }; 164 | 165 | if ( isError ) { 166 | logs.error.push( msg ); 167 | } else { 168 | logs.info.push( msg ); 169 | } 170 | 171 | return logs; 172 | } 173 | } ); 174 | -------------------------------------------------------------------------------- /tests/commands/pull.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | /* jshint mocha:true */ 7 | 8 | 'use strict'; 9 | 10 | const fs = require( 'fs' ); 11 | const path = require( 'upath' ); 12 | const sinon = require( 'sinon' ); 13 | const mockery = require( 'mockery' ); 14 | const expect = require( 'chai' ).expect; 15 | 16 | describe( 'commands/pull', () => { 17 | let pullCommand, stubs, commandData; 18 | 19 | beforeEach( () => { 20 | mockery.enable( { 21 | useCleanCache: true, 22 | warnOnReplace: false, 23 | warnOnUnregistered: false 24 | } ); 25 | 26 | stubs = { 27 | exec: sinon.stub(), 28 | fs: { 29 | existsSync: sinon.stub( fs, 'existsSync' ) 30 | }, 31 | path: { 32 | join: sinon.stub( path, 'join' ).callsFake( ( ...chunks ) => chunks.join( '/' ) ) 33 | }, 34 | bootstrapCommand: { 35 | execute: sinon.stub() 36 | }, 37 | execCommand: { 38 | execute: sinon.stub() 39 | } 40 | }; 41 | 42 | commandData = { 43 | arguments: [], 44 | packageName: 'test-package', 45 | toolOptions: { 46 | cwd: __dirname, 47 | packages: 'packages' 48 | }, 49 | repository: { 50 | directory: 'test-package', 51 | url: 'git@github.com/organization/test-package.git', 52 | branch: 'master' 53 | } 54 | }; 55 | 56 | mockery.registerMock( './exec', stubs.execCommand ); 57 | mockery.registerMock( './bootstrap', stubs.bootstrapCommand ); 58 | 59 | pullCommand = require( '../../lib/commands/pull' ); 60 | } ); 61 | 62 | afterEach( () => { 63 | sinon.restore(); 64 | mockery.deregisterAll(); 65 | mockery.disable(); 66 | } ); 67 | 68 | describe( '#helpMessage', () => { 69 | it( 'defines help screen', () => { 70 | expect( pullCommand.helpMessage ).is.a( 'string' ); 71 | } ); 72 | } ); 73 | 74 | describe( 'execute()', () => { 75 | it( 'skips a package if is not available', () => { 76 | stubs.fs.existsSync.returns( false ); 77 | 78 | return pullCommand.execute( commandData ) 79 | .then( response => { 80 | expect( response ).to.deep.equal( {} ); 81 | } ); 82 | } ); 83 | 84 | it( 'skips a package if its in detached head mode', () => { 85 | stubs.fs.existsSync.returns( true ); 86 | 87 | const exec = stubs.execCommand.execute; 88 | 89 | exec.onCall( 0 ).returns( Promise.resolve( { 90 | logs: getCommandLogs( '' ) 91 | } ) ); 92 | 93 | return pullCommand.execute( commandData ) 94 | .then( response => { 95 | expect( exec.callCount ).to.equal( 1 ); 96 | expect( exec.getCall( 0 ).args[ 0 ].arguments[ 0 ] ).to.equal( 'git branch --show-current' ); 97 | 98 | expect( response.logs.info ).to.deep.equal( [ 99 | 'This repository is currently in detached head mode - skipping.' 100 | ] ); 101 | } ); 102 | } ); 103 | 104 | it( 'resolves promise after pulling the changes', () => { 105 | stubs.fs.existsSync.returns( true ); 106 | 107 | const exec = stubs.execCommand.execute; 108 | 109 | exec.onCall( 0 ).returns( Promise.resolve( { 110 | logs: getCommandLogs( 'master' ) 111 | } ) ); 112 | exec.onCall( 1 ).returns( Promise.resolve( { 113 | logs: getCommandLogs( 'Already up-to-date.' ) 114 | } ) ); 115 | 116 | return pullCommand.execute( commandData ) 117 | .then( response => { 118 | expect( exec.callCount ).to.equal( 2 ); 119 | expect( exec.getCall( 0 ).args[ 0 ].arguments[ 0 ] ).to.equal( 'git branch --show-current' ); 120 | expect( exec.getCall( 1 ).args[ 0 ].arguments[ 0 ] ).to.equal( 'git pull' ); 121 | 122 | expect( response.logs.info ).to.deep.equal( [ 123 | 'Already up-to-date.' 124 | ] ); 125 | } ); 126 | } ); 127 | } ); 128 | 129 | describe( 'afterExecute()', () => { 130 | it( 'informs about number of processed packages', () => { 131 | const consoleLog = sinon.stub( console, 'log' ); 132 | 133 | const processedPackages = new Set(); 134 | processedPackages.add( 'package-1' ); 135 | processedPackages.add( 'package-2' ); 136 | 137 | pullCommand.afterExecute( processedPackages ); 138 | 139 | expect( consoleLog.calledOnce ).to.equal( true ); 140 | expect( consoleLog.firstCall.args[ 0 ] ).to.match( /2 packages have been processed\./ ); 141 | 142 | consoleLog.restore(); 143 | } ); 144 | } ); 145 | 146 | function getCommandLogs( msg, isError = false ) { 147 | const logs = { 148 | error: [], 149 | info: [] 150 | }; 151 | 152 | if ( isError ) { 153 | logs.error.push( msg ); 154 | } else { 155 | logs.info.push( msg ); 156 | } 157 | 158 | return logs; 159 | } 160 | } ); 161 | -------------------------------------------------------------------------------- /tests/commands/push.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | /* jshint mocha:true */ 7 | 8 | 'use strict'; 9 | 10 | const fs = require( 'fs' ); 11 | const path = require( 'upath' ); 12 | const sinon = require( 'sinon' ); 13 | const mockery = require( 'mockery' ); 14 | const expect = require( 'chai' ).expect; 15 | 16 | describe( 'commands/push', () => { 17 | let pushCommand, stubs, commandData; 18 | 19 | beforeEach( () => { 20 | mockery.enable( { 21 | useCleanCache: true, 22 | warnOnReplace: false, 23 | warnOnUnregistered: false 24 | } ); 25 | 26 | stubs = { 27 | exec: sinon.stub(), 28 | fs: { 29 | existsSync: sinon.stub( fs, 'existsSync' ) 30 | }, 31 | path: { 32 | join: sinon.stub( path, 'join' ).callsFake( ( ...chunks ) => chunks.join( '/' ) ) 33 | }, 34 | execCommand: { 35 | execute: sinon.stub() 36 | } 37 | }; 38 | 39 | commandData = { 40 | arguments: [], 41 | packageName: 'test-package', 42 | toolOptions: { 43 | cwd: __dirname, 44 | packages: 'packages' 45 | }, 46 | repository: { 47 | directory: 'test-package', 48 | url: 'git@github.com/organization/test-package.git', 49 | branch: 'master' 50 | } 51 | }; 52 | 53 | mockery.registerMock( './exec', stubs.execCommand ); 54 | 55 | pushCommand = require( '../../lib/commands/push' ); 56 | } ); 57 | 58 | afterEach( () => { 59 | sinon.restore(); 60 | mockery.deregisterAll(); 61 | mockery.disable(); 62 | } ); 63 | 64 | describe( '#helpMessage', () => { 65 | it( 'defines help screen', () => { 66 | expect( pushCommand.helpMessage ).is.a( 'string' ); 67 | } ); 68 | } ); 69 | 70 | describe( 'execute()', () => { 71 | it( 'skips a package if is not available', () => { 72 | stubs.fs.existsSync.returns( false ); 73 | 74 | return pushCommand.execute( commandData ) 75 | .then( response => { 76 | expect( response ).to.deep.equal( {} ); 77 | } ); 78 | } ); 79 | 80 | it( 'skips a package if its in detached head mode', () => { 81 | stubs.fs.existsSync.returns( true ); 82 | 83 | const exec = stubs.execCommand.execute; 84 | 85 | exec.onCall( 0 ).returns( Promise.resolve( { 86 | logs: getCommandLogs( '' ) 87 | } ) ); 88 | 89 | return pushCommand.execute( commandData ) 90 | .then( response => { 91 | expect( exec.callCount ).to.equal( 1 ); 92 | expect( exec.getCall( 0 ).args[ 0 ].arguments[ 0 ] ).to.equal( 'git branch --show-current' ); 93 | 94 | expect( response.logs.info ).to.deep.equal( [ 95 | 'This repository is currently in detached head mode - skipping.' 96 | ] ); 97 | } ); 98 | } ); 99 | 100 | it( 'resolves promise after pushing the changes', () => { 101 | stubs.fs.existsSync.returns( true ); 102 | 103 | const exec = stubs.execCommand.execute; 104 | 105 | exec.onCall( 0 ).returns( Promise.resolve( { 106 | logs: getCommandLogs( 'master' ) 107 | } ) ); 108 | exec.onCall( 1 ).returns( Promise.resolve( { 109 | logs: getCommandLogs( 'Everything up-to-date' ) 110 | } ) ); 111 | 112 | return pushCommand.execute( commandData ) 113 | .then( response => { 114 | expect( exec.callCount ).to.equal( 2 ); 115 | expect( exec.getCall( 0 ).args[ 0 ].arguments[ 0 ] ).to.equal( 'git branch --show-current' ); 116 | expect( exec.getCall( 1 ).args[ 0 ].arguments[ 0 ] ).to.equal( 'git push' ); 117 | 118 | expect( response.logs.info ).to.deep.equal( [ 119 | 'Everything up-to-date' 120 | ] ); 121 | } ); 122 | } ); 123 | 124 | it( 'allows modifying the "git push" command', () => { 125 | commandData.arguments.push( '--verbose' ); 126 | commandData.arguments.push( '--all' ); 127 | stubs.fs.existsSync.returns( true ); 128 | 129 | const exec = stubs.execCommand.execute; 130 | 131 | exec.returns( Promise.resolve( { 132 | logs: getCommandLogs( 'Everything up-to-date' ) 133 | } ) ); 134 | 135 | return pushCommand.execute( commandData ) 136 | .then( response => { 137 | expect( exec.callCount ).to.equal( 2 ); 138 | expect( exec.getCall( 0 ).args[ 0 ].arguments[ 0 ] ).to.equal( 'git branch --show-current' ); 139 | expect( exec.getCall( 1 ).args[ 0 ].arguments[ 0 ] ).to.equal( 'git push --verbose --all' ); 140 | 141 | expect( response.logs.info ).to.deep.equal( [ 142 | 'Everything up-to-date' 143 | ] ); 144 | } ); 145 | } ); 146 | } ); 147 | 148 | describe( 'afterExecute()', () => { 149 | it( 'informs about number of processed packages', () => { 150 | const consoleLog = sinon.stub( console, 'log' ); 151 | 152 | const processedPackages = new Set(); 153 | processedPackages.add( 'package-1' ); 154 | processedPackages.add( 'package-2' ); 155 | 156 | pushCommand.afterExecute( processedPackages ); 157 | 158 | expect( consoleLog.calledOnce ).to.equal( true ); 159 | expect( consoleLog.firstCall.args[ 0 ] ).to.match( /2 packages have been processed\./ ); 160 | 161 | consoleLog.restore(); 162 | } ); 163 | } ); 164 | 165 | function getCommandLogs( msg, isError = false ) { 166 | const logs = { 167 | error: [], 168 | info: [] 169 | }; 170 | 171 | if ( isError ) { 172 | logs.error.push( msg ); 173 | } else { 174 | logs.info.push( msg ); 175 | } 176 | 177 | return logs; 178 | } 179 | } ); 180 | -------------------------------------------------------------------------------- /tests/default-resolver.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | /* jshint mocha:true */ 7 | 8 | 'use strict'; 9 | 10 | const resolver = require( '../lib/default-resolver' ); 11 | const getOptions = require( '../lib/utils/getoptions' ); 12 | const expect = require( 'chai' ).expect; 13 | const cwd = require( 'upath' ).resolve( __dirname, 'fixtures', 'project-a' ); 14 | 15 | describe( 'default resolver()', () => { 16 | let options; 17 | 18 | beforeEach( () => { 19 | options = getOptions( {}, cwd ); 20 | } ); 21 | 22 | describe( 'with default options', () => { 23 | it( 'returns undefined if package was not defined', () => { 24 | expect( resolver( '404', options ) ).to.equal( null ); 25 | } ); 26 | 27 | it( 'returns Git over SSH URL', () => { 28 | expect( resolver( 'simple-package', options ) ).to.deep.equal( { 29 | url: 'git@github.com:a/b.git', 30 | branch: 'master', 31 | tag: undefined, 32 | directory: 'b' 33 | } ); 34 | } ); 35 | 36 | it( 'returns Git over SSH URL with branch name', () => { 37 | expect( resolver( 'package-with-branch', options ) ).to.deep.equal( { 38 | url: 'git@github.com:a/b.git', 39 | branch: 'dev', 40 | tag: undefined, 41 | directory: 'b' 42 | } ); 43 | } ); 44 | 45 | it( 'returns Git over SSH URL for a scoped package', () => { 46 | expect( resolver( '@scoped/package', options ) ).to.deep.equal( { 47 | url: 'git@github.com:c/d.git', 48 | branch: 'master', 49 | tag: undefined, 50 | directory: 'd' 51 | } ); 52 | } ); 53 | 54 | it( 'returns original URL if git URL is specified', () => { 55 | expect( resolver( 'full-url-git', options ) ).to.deep.equal( { 56 | url: 'git@github.com:cksource/mrgit.git', 57 | branch: 'master', 58 | tag: undefined, 59 | directory: 'mrgit' 60 | } ); 61 | } ); 62 | 63 | it( 'returns original URL and branch if git URL is specified', () => { 64 | expect( resolver( 'full-url-git-with-branch', options ) ).to.deep.equal( { 65 | url: 'git@github.com:cksource/mrgit.git', 66 | branch: 'xyz', 67 | tag: undefined, 68 | directory: 'mrgit' 69 | } ); 70 | } ); 71 | 72 | it( 'returns original URL if HTTPS URL is specified', () => { 73 | expect( resolver( 'full-url-https', options ) ).to.deep.equal( { 74 | url: 'https://github.com/cksource/mrgit.git', 75 | branch: 'master', 76 | tag: undefined, 77 | directory: 'mrgit' 78 | } ); 79 | } ); 80 | 81 | it( 'returns specific tag', () => { 82 | expect( resolver( 'package-with-specific-tag', options ) ).to.deep.equal( { 83 | url: 'git@github.com:a/b.git', 84 | branch: 'master', 85 | tag: 'v30.0.0', 86 | directory: 'b' 87 | } ); 88 | } ); 89 | 90 | it( 'returns the "latest" tag', () => { 91 | expect( resolver( 'package-with-latest-tag', options ) ).to.deep.equal( { 92 | url: 'git@github.com:a/b.git', 93 | branch: 'master', 94 | tag: 'latest', 95 | directory: 'b' 96 | } ); 97 | } ); 98 | 99 | it( 'ignores "$rootRepository" if "isRootRepository" argument is not set to true', () => { 100 | options = getOptions( { $rootRepository: 'rootOwner/rootName' }, cwd ); 101 | 102 | expect( resolver( 'simple-package', options ) ).to.deep.equal( { 103 | url: 'git@github.com:a/b.git', 104 | branch: 'master', 105 | tag: undefined, 106 | directory: 'b' 107 | } ); 108 | } ); 109 | 110 | it( 'utilizes "$rootRepository" if "isRootRepository" argument is set to true', () => { 111 | options = getOptions( { $rootRepository: 'rootOwner/rootName' }, cwd ); 112 | 113 | expect( resolver( 'simple-package', options, true ) ).to.deep.equal( { 114 | url: 'git@github.com:rootOwner/rootName.git', 115 | branch: 'master', 116 | tag: undefined, 117 | directory: 'rootName' 118 | } ); 119 | } ); 120 | } ); 121 | 122 | describe( 'with options.resolverUrlTempalate', () => { 123 | const options = getOptions( { 124 | resolverUrlTemplate: 'custom@path:${ path }.git' 125 | }, cwd ); 126 | 127 | it( 'uses the template if short dependency path was used', () => { 128 | expect( resolver( 'simple-package', options ) ).to.deep.equal( { 129 | url: 'custom@path:a/b.git', 130 | branch: 'master', 131 | tag: undefined, 132 | directory: 'b' 133 | } ); 134 | } ); 135 | 136 | it( 'returns original URL if git URL is specified', () => { 137 | expect( resolver( 'full-url-git', options ) ).to.deep.equal( { 138 | url: 'git@github.com:cksource/mrgit.git', 139 | branch: 'master', 140 | tag: undefined, 141 | directory: 'mrgit' 142 | } ); 143 | } ); 144 | } ); 145 | 146 | describe( 'with options.resolverDefaultBranch', () => { 147 | const options = getOptions( { 148 | resolverDefaultBranch: 'major' 149 | }, cwd ); 150 | 151 | it( 'returns the default branch if dependency URL does not specify it (simple package)', () => { 152 | expect( resolver( 'simple-package', options ) ).to.deep.equal( { 153 | url: 'git@github.com:a/b.git', 154 | branch: 'major', 155 | tag: undefined, 156 | directory: 'b' 157 | } ); 158 | } ); 159 | 160 | it( 'returns the default branch if dependency URL does not specify it (package with branch)', () => { 161 | expect( resolver( 'package-with-branch', options ) ).to.deep.equal( { 162 | url: 'git@github.com:a/b.git', 163 | branch: 'dev', 164 | tag: undefined, 165 | directory: 'b' 166 | } ); 167 | } ); 168 | } ); 169 | 170 | describe( 'with options.resolverTargetDirectory', () => { 171 | const options = getOptions( { 172 | resolverTargetDirectory: 'npm' 173 | }, cwd ); 174 | 175 | it( 'returns package name as directory for non-scoped package', () => { 176 | expect( resolver( 'simple-package', options ) ).to.deep.equal( { 177 | url: 'git@github.com:a/b.git', 178 | branch: 'master', 179 | tag: undefined, 180 | directory: 'simple-package' 181 | } ); 182 | } ); 183 | 184 | it( 'returns package name as directory for scoped package', () => { 185 | expect( resolver( '@scoped/package', options ) ).to.deep.equal( { 186 | url: 'git@github.com:c/d.git', 187 | branch: 'master', 188 | tag: undefined, 189 | directory: '@scoped/package' 190 | } ); 191 | } ); 192 | } ); 193 | 194 | describe( 'with options.overrideDirectoryNames', () => { 195 | it( 'returns package with modified directory', () => { 196 | const options = getOptions( {}, cwd ); 197 | 198 | expect( resolver( 'override-directory', options ) ).to.deep.equal( { 199 | url: 'git@github.com:foo/bar.git', 200 | branch: 'master', 201 | tag: undefined, 202 | directory: 'custom-directory' 203 | } ); 204 | } ); 205 | 206 | it( 'ignores modified directory if "resolverTargetDirectory" is set to "npm"', () => { 207 | const options = getOptions( { 208 | resolverTargetDirectory: 'npm' 209 | }, cwd ); 210 | 211 | expect( resolver( 'override-directory', options ) ).to.deep.equal( { 212 | url: 'git@github.com:foo/bar.git', 213 | branch: 'master', 214 | tag: undefined, 215 | directory: 'override-directory' 216 | } ); 217 | } ); 218 | } ); 219 | } ); 220 | -------------------------------------------------------------------------------- /tests/fixtures/project-a/mrgit.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "simple-package": "a/b", 4 | "package-with-branch": "a/b#dev", 5 | "package-with-specific-tag": "a/b@v30.0.0", 6 | "package-with-latest-tag": "a/b@latest", 7 | "@scoped/package": "c/d", 8 | "full-url-git": "git@github.com:cksource/mrgit.git", 9 | "full-url-git-with-branch": "git@github.com:cksource/mrgit.git#xyz", 10 | "full-url-https": "https://github.com/cksource/mrgit.git", 11 | "override-directory": "foo/bar" 12 | }, 13 | "overrideDirectoryNames": { 14 | "override-directory": "custom-directory" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/fixtures/project-a/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "test-foo": "organization/test-foo" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/fixtures/project-with-custom-config/mrgit-custom.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": "foo", 3 | "dependencies": { 4 | "simple-package": "a/b" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/fixtures/project-with-defined-root/mrgit.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": "packages/", 3 | "$rootRepository": "ckeditor/ckeditor5", 4 | "presets": { 5 | "development": { 6 | "$rootRepository": "ckeditor/ckeditor5#developmentBranch" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tests/fixtures/project-with-options-in-mrgitjson/mrgit.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": "foo", 3 | "dependencies": { 4 | "simple-package": "a/b" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/fixtures/project-with-options-in-mrgitjson/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "test-bar": "organization/test-bar" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/fixtures/project-with-presets/mrgit.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": "packages/", 3 | "dependencies": { 4 | "linters-config": "foo/linters-config@latest", 5 | "dev-tools": "foo/dev-tools@latest" 6 | }, 7 | "presets": { 8 | "development": { 9 | "dev-tools": "foo/dev-tools#developmentBranch" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/utils/getcwd.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | /* jshint mocha:true */ 7 | 8 | 'use strict'; 9 | 10 | const fs = require( 'fs' ); 11 | const getCwd = require( '../../lib/utils/getcwd' ); 12 | const expect = require( 'chai' ).expect; 13 | const sinon = require( 'sinon' ); 14 | 15 | describe( 'utils', () => { 16 | afterEach( () => { 17 | sinon.restore(); 18 | } ); 19 | 20 | describe( 'getCwd()', () => { 21 | it( 'returns "process.cwd()" value if the "mrgit.json" has been found', () => { 22 | sinon.stub( process, 'cwd' ).returns( '/workspace/ckeditor/ckeditor5' ); 23 | sinon.stub( fs, 'existsSync' ).returns( true ); 24 | 25 | expect( getCwd( 'mrgit.json' ) ).to.equal( '/workspace/ckeditor/ckeditor5' ); 26 | } ); 27 | 28 | it( 'returns a path to the "mrgit.json" when custom working directory is provided', () => { 29 | sinon.stub( fs, 'existsSync' ).returns( true ); 30 | 31 | expect( getCwd( 'mrgit.json', '/another-workspace/ckeditor/ckeditor5' ) ).to.equal( '/another-workspace/ckeditor/ckeditor5' ); 32 | } ); 33 | 34 | it( 'scans dir tree up in order to find configuration file', () => { 35 | sinon.stub( process, 'cwd' ).returns( '/workspace/ckeditor/ckeditor5/packages/ckeditor5-engine/node_modules/@ckeditor' ); 36 | 37 | const existsSync = sinon.stub( fs, 'existsSync' ); 38 | 39 | // /workspace/ckeditor/ckeditor5/packages/ckeditor5-engine/node_modules/@ckeditor 40 | existsSync.onCall( 0 ).returns( false ); 41 | // /workspace/ckeditor/ckeditor5/packages/ckeditor5-engine/node_modules 42 | existsSync.onCall( 1 ).returns( false ); 43 | // /workspace/ckeditor/ckeditor5/packages/ckeditor5-engine 44 | existsSync.onCall( 2 ).returns( false ); 45 | // /workspace/ckeditor/ckeditor5/packages 46 | existsSync.onCall( 3 ).returns( false ); 47 | // /workspace/ckeditor/ckeditor5 48 | existsSync.onCall( 4 ).returns( true ); 49 | 50 | expect( getCwd( 'mrgit-custom.json' ) ).to.equal( '/workspace/ckeditor/ckeditor5' ); 51 | 52 | expect( existsSync.getCall( 0 ).args[ 0 ] ).to.equal( 53 | '/workspace/ckeditor/ckeditor5/packages/ckeditor5-engine/node_modules/@ckeditor/mrgit-custom.json' 54 | ); 55 | expect( existsSync.getCall( 1 ).args[ 0 ] ).to.equal( 56 | '/workspace/ckeditor/ckeditor5/packages/ckeditor5-engine/node_modules/mrgit-custom.json' 57 | ); 58 | expect( existsSync.getCall( 2 ).args[ 0 ] ).to.equal( 59 | '/workspace/ckeditor/ckeditor5/packages/ckeditor5-engine/mrgit-custom.json' 60 | ); 61 | expect( existsSync.getCall( 3 ).args[ 0 ] ).to.equal( '/workspace/ckeditor/ckeditor5/packages/mrgit-custom.json' ); 62 | expect( existsSync.getCall( 4 ).args[ 0 ] ).to.equal( '/workspace/ckeditor/ckeditor5/mrgit-custom.json' ); 63 | } ); 64 | 65 | it( 'throws an error if the configuration file cannot be found', () => { 66 | sinon.stub( process, 'cwd' ).returns( '/workspace/ckeditor' ); 67 | sinon.stub( fs, 'existsSync' ).returns( false ); 68 | 69 | expect( () => getCwd( 'mrgit.json' ) ).to.throw( Error, 'Cannot find the configuration file.' ); 70 | } ); 71 | } ); 72 | } ); 73 | -------------------------------------------------------------------------------- /tests/utils/getoptions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | /* jshint mocha:true */ 7 | 8 | 'use strict'; 9 | 10 | const getOptions = require( '../../lib/utils/getoptions' ); 11 | const path = require( 'upath' ); 12 | const fs = require( 'fs' ); 13 | const shell = require( 'shelljs' ); 14 | const expect = require( 'chai' ).expect; 15 | const sinon = require( 'sinon' ); 16 | 17 | const cwd = path.resolve( __dirname, '..', 'fixtures', 'project-a' ); 18 | 19 | describe( 'utils', () => { 20 | describe( 'getOptions()', () => { 21 | it( 'returns default options', () => { 22 | const options = getOptions( {}, cwd ); 23 | 24 | expect( options ).to.have.property( 'dependencies' ); 25 | 26 | delete options.dependencies; 27 | 28 | expect( options ).to.deep.equal( { 29 | cwd, 30 | config: path.resolve( cwd, 'mrgit.json' ), 31 | packages: path.resolve( cwd, 'packages' ), 32 | resolverPath: path.resolve( __dirname, '../../lib/default-resolver.js' ), 33 | resolverUrlTemplate: 'git@github.com:${ path }.git', 34 | resolverTargetDirectory: 'git', 35 | resolverDefaultBranch: 'master', 36 | scope: null, 37 | ignore: null, 38 | skipRoot: false, 39 | packagesPrefix: [], 40 | overrideDirectoryNames: { 41 | 'override-directory': 'custom-directory' 42 | }, 43 | baseBranches: [] 44 | } ); 45 | } ); 46 | 47 | it( 'uses default process.cwd() if not specified', () => { 48 | sinon.stub( process, 'cwd' ).returns( cwd ); 49 | 50 | const options = getOptions( {} ); 51 | 52 | expect( options.cwd ).to.equal( cwd ); 53 | } ); 54 | 55 | it( 'returns dependencies read from default configuration file', () => { 56 | const options = getOptions( {}, cwd ); 57 | const mrgitJson = require( path.join( cwd, 'mrgit.json' ) ); 58 | 59 | expect( options.dependencies ).to.deep.equal( mrgitJson.dependencies ); 60 | } ); 61 | 62 | it( 'fails if configuration file is not defined ', () => { 63 | const cwd = path.resolve( __dirname, '..', 'fixtures', 'project-with-no-mrgitjson' ); 64 | 65 | expect( () => getOptions( {}, cwd ) ).to.throw( Error, 'Cannot find the configuration file.' ); 66 | } ); 67 | 68 | it( 'reads options from default configuration file', () => { 69 | const cwd = path.resolve( __dirname, '..', 'fixtures', 'project-with-options-in-mrgitjson' ); 70 | const options = getOptions( {}, cwd ); 71 | 72 | expect( options ).to.deep.equal( { 73 | dependencies: { 74 | 'simple-package': 'a/b' 75 | }, 76 | cwd, 77 | config: path.resolve( cwd, 'mrgit.json' ), 78 | packages: path.resolve( cwd, 'foo' ), 79 | resolverPath: path.resolve( __dirname, '../../lib/default-resolver.js' ), 80 | resolverUrlTemplate: 'git@github.com:${ path }.git', 81 | resolverTargetDirectory: 'git', 82 | resolverDefaultBranch: 'master', 83 | scope: null, 84 | ignore: null, 85 | skipRoot: false, 86 | packagesPrefix: [], 87 | overrideDirectoryNames: {}, 88 | baseBranches: [] 89 | } ); 90 | } ); 91 | 92 | it( 'reads options from custom configuration file', () => { 93 | const cwd = path.resolve( __dirname, '..', 'fixtures', 'project-with-custom-config' ); 94 | const options = getOptions( { 95 | config: 'mrgit-custom.json' 96 | }, cwd ); 97 | 98 | expect( options.dependencies ).to.deep.equal( { 99 | 'simple-package': 'a/b' 100 | } ); 101 | 102 | expect( options.config ).to.equal( path.resolve( cwd, 'mrgit-custom.json' ) ); 103 | } ); 104 | 105 | it( 'priorities passed options', () => { 106 | const cwd = path.resolve( __dirname, '..', 'fixtures', 'project-with-options-in-mrgitjson' ); 107 | const options = getOptions( { 108 | resolverUrlTemplate: 'a/b/c', 109 | packages: 'bar' 110 | }, cwd ); 111 | 112 | expect( options ).to.deep.equal( { 113 | dependencies: { 114 | 'simple-package': 'a/b' 115 | }, 116 | cwd, 117 | config: path.resolve( cwd, 'mrgit.json' ), 118 | packages: path.resolve( cwd, 'bar' ), 119 | resolverPath: path.resolve( __dirname, '../../lib/default-resolver.js' ), 120 | resolverUrlTemplate: 'a/b/c', 121 | resolverTargetDirectory: 'git', 122 | resolverDefaultBranch: 'master', 123 | scope: null, 124 | ignore: null, 125 | skipRoot: false, 126 | packagesPrefix: [], 127 | overrideDirectoryNames: {}, 128 | baseBranches: [] 129 | } ); 130 | } ); 131 | 132 | it( 'returns "packagesPrefix" as array', () => { 133 | const options = getOptions( { 134 | packagesPrefix: 'ckeditor5-' 135 | }, cwd ); 136 | 137 | expect( options ).to.have.property( 'dependencies' ); 138 | 139 | delete options.dependencies; 140 | 141 | expect( options ).to.deep.equal( { 142 | cwd, 143 | config: path.resolve( cwd, 'mrgit.json' ), 144 | packages: path.resolve( cwd, 'packages' ), 145 | resolverPath: path.resolve( __dirname, '../../lib/default-resolver.js' ), 146 | resolverUrlTemplate: 'git@github.com:${ path }.git', 147 | resolverTargetDirectory: 'git', 148 | resolverDefaultBranch: 'master', 149 | scope: null, 150 | ignore: null, 151 | skipRoot: false, 152 | packagesPrefix: [ 153 | 'ckeditor5-' 154 | ], 155 | overrideDirectoryNames: { 156 | 'override-directory': 'custom-directory' 157 | }, 158 | baseBranches: [] 159 | } ); 160 | } ); 161 | 162 | it( 'attaches to options branch name from the cwd directory (if in git repository)', () => { 163 | const fsExistsStub = sinon.stub( fs, 'existsSync' ); 164 | const shelljsStub = sinon.stub( shell, 'exec' ); 165 | 166 | fsExistsStub.returns( true ); 167 | shelljsStub.returns( { 168 | stdout: 'master\n' 169 | } ); 170 | 171 | const options = getOptions( {}, cwd ); 172 | 173 | expect( options ).to.have.property( 'dependencies' ); 174 | 175 | delete options.dependencies; 176 | 177 | expect( options ).to.deep.equal( { 178 | cwd, 179 | config: path.resolve( cwd, 'mrgit.json' ), 180 | packages: path.resolve( cwd, 'packages' ), 181 | resolverPath: path.resolve( __dirname, '../../lib/default-resolver.js' ), 182 | resolverUrlTemplate: 'git@github.com:${ path }.git', 183 | resolverTargetDirectory: 'git', 184 | resolverDefaultBranch: 'master', 185 | scope: null, 186 | ignore: null, 187 | skipRoot: false, 188 | packagesPrefix: [], 189 | overrideDirectoryNames: { 190 | 'override-directory': 'custom-directory' 191 | }, 192 | baseBranches: [], 193 | cwdPackageBranch: 'master' 194 | } ); 195 | 196 | fsExistsStub.restore(); 197 | shelljsStub.restore(); 198 | } ); 199 | 200 | it( 'throws an error when --preset option is used, but presets are not defined in configuration', () => { 201 | expect( () => { 202 | getOptions( { preset: 'foo' }, cwd ); 203 | } ).to.throw( Error, 'Preset "foo" is not defined in configuration file.' ); 204 | } ); 205 | 206 | it( 'throws an error when --preset option is used, but the specific preset is not defined in configuration', () => { 207 | const cwdForPresets = path.resolve( __dirname, '..', 'fixtures', 'project-with-presets' ); 208 | 209 | expect( () => { 210 | getOptions( { preset: 'foo' }, cwdForPresets ); 211 | } ).to.throw( Error, 'Preset "foo" is not defined in configuration file.' ); 212 | } ); 213 | 214 | it( 'returns options with preset merged with dependencies when --preset option is used', () => { 215 | const cwdForPresets = path.resolve( __dirname, '..', 'fixtures', 'project-with-presets' ); 216 | 217 | const options = getOptions( { preset: 'development' }, cwdForPresets ); 218 | 219 | expect( options ).to.have.property( 'dependencies' ); 220 | expect( options.dependencies ).to.deep.equal( { 221 | 'linters-config': 'foo/linters-config@latest', 222 | 'dev-tools': 'foo/dev-tools#developmentBranch' 223 | } ); 224 | } ); 225 | 226 | it( 'returns options with "$rootRepository" taken from a preset if --preset option is used', () => { 227 | const cwdForPresets = path.resolve( __dirname, '..', 'fixtures', 'project-with-defined-root' ); 228 | 229 | const options = getOptions( { preset: 'development' }, cwdForPresets ); 230 | 231 | expect( options.$rootRepository ).to.equal( 'ckeditor/ckeditor5#developmentBranch' ); 232 | } ); 233 | } ); 234 | } ); 235 | -------------------------------------------------------------------------------- /tests/utils/getpackagenames.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | /* jshint mocha:true */ 7 | 8 | 'use strict'; 9 | 10 | const getPackageNames = require( '../../lib/utils/getpackagenames' ); 11 | const expect = require( 'chai' ).expect; 12 | 13 | describe( 'utils', () => { 14 | describe( 'getPackageNames()', () => { 15 | it( 'returns specified packages', () => { 16 | const dependencies = { 17 | '@ckeditor/ckeditor5-core': '*', 18 | '@ckeditor/ckeditor5-engine': '*', 19 | '@ckeditor/ckeditor5-utils': '*' 20 | }; 21 | 22 | const command = { name: 'sync' }; 23 | 24 | const packages = getPackageNames( { dependencies }, command ); 25 | 26 | expect( packages ).to.deep.equal( Object.keys( dependencies ) ); 27 | } ); 28 | 29 | it( 'returns specified packages which match to specified pattern (scope)', () => { 30 | const dependencies = { 31 | '@ckeditor/ckeditor5-core': '*', 32 | '@ckeditor/ckeditor5-engine': '*', 33 | '@ckeditor/ckeditor5-editor-classic': '*', 34 | '@ckeditor/ckeditor5-editor-inline': '*', 35 | '@ckeditor/ckeditor5-utils': '*' 36 | }; 37 | 38 | const command = { name: 'sync' }; 39 | 40 | const packages = getPackageNames( { 41 | dependencies, 42 | scope: 'ckeditor5-editor-*' 43 | }, command ); 44 | 45 | expect( packages ).to.deep.equal( [ 46 | '@ckeditor/ckeditor5-editor-classic', 47 | '@ckeditor/ckeditor5-editor-inline' 48 | ] ); 49 | } ); 50 | 51 | it( 'returns specified packages which match to specified pattern (ignore)', () => { 52 | const dependencies = { 53 | '@ckeditor/ckeditor5-core': '*', 54 | '@ckeditor/ckeditor5-engine': '*', 55 | '@ckeditor/ckeditor5-editor-classic': '*', 56 | '@ckeditor/ckeditor5-editor-inline': '*', 57 | '@ckeditor/ckeditor5-utils': '*' 58 | }; 59 | 60 | const command = { name: 'sync' }; 61 | 62 | const packages = getPackageNames( { 63 | dependencies, 64 | ignore: 'ckeditor5-e*' 65 | }, command ); 66 | 67 | expect( packages ).to.deep.equal( [ 68 | '@ckeditor/ckeditor5-core', 69 | '@ckeditor/ckeditor5-utils' 70 | ] ); 71 | } ); 72 | 73 | it( 'returns specified packages which match to specified patterns (scope and ignore)', () => { 74 | const dependencies = { 75 | '@ckeditor/ckeditor5-engine': '*', 76 | '@ckeditor/ckeditor5-editor-classic': '*', 77 | '@ckeditor/ckeditor5-editor-inline': '*', 78 | '@ckeditor/ckeditor5-utils': '*' 79 | }; 80 | 81 | const command = { name: 'sync' }; 82 | 83 | const packages = getPackageNames( { 84 | dependencies, 85 | scope: 'ckeditor5-editor-*', 86 | ignore: 'ckeditor5-*-inline' 87 | }, command ); 88 | 89 | expect( packages ).to.deep.equal( [ 90 | '@ckeditor/ckeditor5-editor-classic' 91 | ] ); 92 | } ); 93 | 94 | it( 'returns root package name', () => { 95 | const dependencies = { 96 | '@ckeditor/ckeditor5-core': '*', 97 | '@ckeditor/ckeditor5-engine': '*', 98 | '@ckeditor/ckeditor5-utils': '*' 99 | }; 100 | 101 | const command = { name: 'sync' }; 102 | 103 | const packages = getPackageNames( { 104 | dependencies, 105 | $rootRepository: 'rootOwner/rootName' 106 | }, command ); 107 | 108 | expect( packages ).to.deep.equal( [ '$rootName', ...Object.keys( dependencies ) ] ); 109 | } ); 110 | } ); 111 | } ); 112 | -------------------------------------------------------------------------------- /tests/utils/gitstatusparser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | /* jshint mocha:true */ 7 | 8 | 'use strict'; 9 | 10 | const expect = require( 'chai' ).expect; 11 | const gitStatusParser = require( '../../lib/utils/gitstatusparser' ); 12 | 13 | const gitStatusResponse = [ 14 | '## master...origin/master', 15 | 'R CHANGELOG.txt -> CHANGELOG.md', 16 | ' D README.txt', 17 | '?? README.md', 18 | 'A .eslintrc.js', 19 | '?? lib/utils/helper.js', 20 | ' M lib/index.js', 21 | ' M lib/tasks/logger.js', 22 | ' D lib/tasks/.gitkeep', 23 | '?? tests/utils/helper.js', 24 | 'M tests/index.js', 25 | 'M tests/tasks/logger.js', 26 | 'D tests/tasks/.gitkeep', 27 | 'DU bin/unmerged_deleted-by-us', 28 | 'AU bin/unmerged_added-by-us', 29 | 'UD bin/unmerged_deleted-by-them', 30 | 'UA bin/unmerged_added-by-them', 31 | 'DD bin/unmerged_both-deleted', 32 | 'AA bin/unmerged_both-added', 33 | 'UU bin/unmerged_both-modified' 34 | ].join( '\n' ); 35 | 36 | describe( 'utils', () => { 37 | describe( 'gitStatusParser()', () => { 38 | describe( '#anythingToCommit', () => { 39 | it( 'returns false for untracked files', () => { 40 | const gitStatusResponse = [ 41 | '## master...origin/master', 42 | '?? README.md', 43 | '?? .eslintrc.js' 44 | ].join( '\n' ); 45 | 46 | const status = gitStatusParser( gitStatusResponse ); 47 | 48 | expect( status.anythingToCommit ).to.equal( false ); 49 | } ); 50 | 51 | it( 'returns true for any tracked file', () => { 52 | const gitStatusResponse = [ 53 | '## master...origin/master', 54 | ' M lib/index.js' 55 | ].join( '\n' ); 56 | 57 | const status = gitStatusParser( gitStatusResponse ); 58 | 59 | expect( status.anythingToCommit ).to.equal( true ); 60 | } ); 61 | 62 | it( 'returns true for any tracked file and some untracked', () => { 63 | const gitStatusResponse = [ 64 | '## master...origin/master', 65 | ' M lib/index.js', 66 | '?? README.md' 67 | ].join( '\n' ); 68 | 69 | const status = gitStatusParser( gitStatusResponse ); 70 | 71 | expect( status.anythingToCommit ).to.equal( true ); 72 | } ); 73 | } ); 74 | 75 | it( 'returns branch name for freshly created', () => { 76 | const status = gitStatusParser( '## master' ); 77 | 78 | expect( status.branch ).to.equal( 'master' ); 79 | } ); 80 | 81 | it( 'returns branch name even if the upstream is set', () => { 82 | const status = gitStatusParser( '## master...origin/master' ); 83 | 84 | expect( status.branch ).to.equal( 'master' ); 85 | } ); 86 | 87 | it( 'returns tag name if its available and the repository is in detached head mode', () => { 88 | const status = gitStatusParser( '## HEAD (no branch)', 'v30.0.0' ); 89 | 90 | expect( status.tag ).to.equal( 'v30.0.0' ); 91 | } ); 92 | 93 | it( 'returns number of commits being behind the remote branch', () => { 94 | const status = gitStatusParser( '## master [behind 3 ahead 6]' ); 95 | 96 | expect( status.behind ).to.equal( 3 ); 97 | } ); 98 | 99 | it( 'returns number of commits being ahead the remote branch', () => { 100 | const status = gitStatusParser( '## master [behind 3 ahead 6]' ); 101 | 102 | expect( status.ahead ).to.equal( 6 ); 103 | } ); 104 | 105 | it( 'returns a list with modified files', () => { 106 | const status = gitStatusParser( gitStatusResponse ); 107 | 108 | expect( status.modified ).to.deep.equal( [ 109 | 'README.txt', 110 | 'lib/index.js', 111 | 'lib/tasks/logger.js', 112 | 'lib/tasks/.gitkeep' 113 | ] ); 114 | } ); 115 | 116 | it( 'returns a list with deleted files', () => { 117 | const status = gitStatusParser( gitStatusResponse ); 118 | 119 | expect( status.deleted ).to.deep.equal( [ 120 | 'README.txt', 121 | 'lib/tasks/.gitkeep', 122 | 'tests/tasks/.gitkeep' 123 | ] ); 124 | } ); 125 | 126 | it( 'returns a list with renamed files', () => { 127 | const status = gitStatusParser( gitStatusResponse ); 128 | 129 | expect( status.renamed ).to.deep.equal( [ 130 | 'CHANGELOG.txt -> CHANGELOG.md' 131 | ] ); 132 | } ); 133 | 134 | it( 'returns a list with unmerged files (conflicts)', () => { 135 | const status = gitStatusParser( gitStatusResponse ); 136 | 137 | expect( status.unmerged ).to.deep.equal( [ 138 | 'bin/unmerged_deleted-by-us', 139 | 'bin/unmerged_added-by-us', 140 | 'bin/unmerged_deleted-by-them', 141 | 'bin/unmerged_added-by-them', 142 | 'bin/unmerged_both-deleted', 143 | 'bin/unmerged_both-added', 144 | 'bin/unmerged_both-modified' 145 | ] ); 146 | } ); 147 | 148 | it( 'returns a list with added files', () => { 149 | const status = gitStatusParser( gitStatusResponse ); 150 | 151 | expect( status.added ).to.deep.equal( [ 152 | '.eslintrc.js' 153 | ] ); 154 | } ); 155 | 156 | it( 'returns a list with untracked files', () => { 157 | const status = gitStatusParser( gitStatusResponse ); 158 | 159 | expect( status.untracked ).to.deep.equal( [ 160 | 'README.md', 161 | 'lib/utils/helper.js', 162 | 'tests/utils/helper.js' 163 | ] ); 164 | } ); 165 | 166 | it( 'returns a list with modified files (staged and not staged)', () => { 167 | const gitStatusResponse = [ 168 | '## master...origin/master', 169 | ' M lib/index.js', // modified, not staged 170 | 'MM lib/tasks/logger.js', // modified, a part of the changes is staged 171 | 'M tests/index.js' // modified, the whole file is staged 172 | ].join( '\n' ); 173 | 174 | const status = gitStatusParser( gitStatusResponse ); 175 | 176 | expect( status.staged ).to.deep.equal( [ 177 | 'lib/tasks/logger.js', 178 | 'tests/index.js' 179 | ] ); 180 | 181 | expect( status.modified ).to.deep.equal( [ 182 | 'lib/index.js', 183 | 'lib/tasks/logger.js' 184 | ] ); 185 | } ); 186 | } ); 187 | } ); 188 | -------------------------------------------------------------------------------- /tests/utils/log.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | /* jshint mocha:true */ 7 | 8 | 'use strict'; 9 | 10 | const sinon = require( 'sinon' ); 11 | const expect = require( 'chai' ).expect; 12 | 13 | describe( 'utils/log', () => { 14 | let log, stubs; 15 | 16 | beforeEach( () => { 17 | log = require( '../../lib/utils/log' ); 18 | 19 | stubs = { 20 | info: sinon.stub(), 21 | error: sinon.stub(), 22 | log: sinon.stub() 23 | }; 24 | } ); 25 | 26 | afterEach( () => { 27 | sinon.restore(); 28 | } ); 29 | 30 | describe( 'log()', () => { 31 | it( 'returns the logger', () => { 32 | const logger = log(); 33 | 34 | expect( logger ).to.be.an( 'object' ); 35 | expect( logger.info ).to.be.a( 'function' ); 36 | expect( logger.error ).to.be.a( 'function' ); 37 | expect( logger.log ).to.be.a( 'function' ); 38 | expect( logger.concat ).to.be.a( 'function' ); 39 | expect( logger.all ).to.be.a( 'function' ); 40 | } ); 41 | } ); 42 | 43 | describe( 'logger', () => { 44 | let logger; 45 | 46 | beforeEach( () => { 47 | logger = log(); 48 | } ); 49 | 50 | describe( 'info()', () => { 51 | it( 'calls the log() function with the received message and the type set to "info"', () => { 52 | logger.log = stubs.log; 53 | 54 | logger.info( 'Info message.' ); 55 | 56 | expect( stubs.log.callCount ).to.equal( 1 ); 57 | expect( stubs.log.getCall( 0 ).args[ 0 ] ).to.equal( 'info' ); 58 | expect( stubs.log.getCall( 0 ).args[ 1 ] ).to.equal( 'Info message.' ); 59 | } ); 60 | } ); 61 | 62 | describe( 'error()', () => { 63 | it( 'calls the log() function with the received message and the type set to "error"', () => { 64 | logger.log = stubs.log; 65 | 66 | logger.error( 'Error message.' ); 67 | 68 | expect( stubs.log.callCount ).to.equal( 1 ); 69 | expect( stubs.log.getCall( 0 ).args[ 0 ] ).to.equal( 'error' ); 70 | expect( stubs.log.getCall( 0 ).args[ 1 ] ).to.equal( 'Error message.' ); 71 | } ); 72 | 73 | it( 'calls the log() function with the stack trace of the received error and the type set to "error"', () => { 74 | logger.log = stubs.log; 75 | 76 | const errorStack = [ 77 | '-Error: Error message.', 78 | '- at foo (path/to/foo.js:10:20)', 79 | '- at bar (path/to/bar.js:30:40)' 80 | ].join( '\n' ); 81 | 82 | const error = new Error( 'Error message.' ); 83 | error.stack = errorStack; 84 | 85 | logger.error( error ); 86 | 87 | expect( stubs.log.callCount ).to.equal( 1 ); 88 | expect( stubs.log.getCall( 0 ).args[ 0 ] ).to.equal( 'error' ); 89 | expect( stubs.log.getCall( 0 ).args[ 1 ] ).to.equal( errorStack ); 90 | } ); 91 | } ); 92 | 93 | describe( 'log()', () => { 94 | it( 'stores messages of the "info" type', () => { 95 | expect( logger.all() ).to.deep.equal( { 96 | error: [], 97 | info: [] 98 | } ); 99 | 100 | logger.log( 'info', 'Info message.' ); 101 | 102 | expect( logger.all() ).to.deep.equal( { 103 | error: [], 104 | info: [ 'Info message.' ] 105 | } ); 106 | } ); 107 | 108 | it( 'stores messages of the "error" type', () => { 109 | expect( logger.all() ).to.deep.equal( { 110 | error: [], 111 | info: [] 112 | } ); 113 | 114 | logger.log( 'error', 'Error message.' ); 115 | 116 | expect( logger.all() ).to.deep.equal( { 117 | error: [ 'Error message.' ], 118 | info: [] 119 | } ); 120 | } ); 121 | 122 | it( 'trims whitespaces from received messages', () => { 123 | expect( logger.all() ).to.deep.equal( { 124 | error: [], 125 | info: [] 126 | } ); 127 | 128 | logger.log( 'info', ' Info message.\n ' ); 129 | 130 | expect( logger.all() ).to.deep.equal( { 131 | error: [], 132 | info: [ 'Info message.' ] 133 | } ); 134 | } ); 135 | 136 | it( 'ignores the "undefined" value passed as the message', () => { 137 | expect( logger.all() ).to.deep.equal( { 138 | error: [], 139 | info: [] 140 | } ); 141 | 142 | logger.log( 'info', undefined ); 143 | 144 | expect( logger.all() ).to.deep.equal( { 145 | error: [], 146 | info: [] 147 | } ); 148 | } ); 149 | 150 | it( 'ignores messages consisting of whitespace alone', () => { 151 | expect( logger.all() ).to.deep.equal( { 152 | error: [], 153 | info: [] 154 | } ); 155 | 156 | logger.log( 'info', ' ' ); 157 | 158 | expect( logger.all() ).to.deep.equal( { 159 | error: [], 160 | info: [] 161 | } ); 162 | } ); 163 | } ); 164 | 165 | describe( 'concat()', () => { 166 | it( 'passes messages of the respective types to the info() and error() functions', () => { 167 | logger.info = stubs.info; 168 | logger.error = stubs.error; 169 | 170 | logger.concat( { 171 | info: [ 'Info message 1.', 'Info message 2.' ], 172 | error: [ 'Error message 1.', 'Error message 2.' ] 173 | } ); 174 | 175 | expect( stubs.info.callCount ).to.equal( 2 ); 176 | expect( stubs.info.getCall( 0 ).args[ 0 ] ).to.equal( 'Info message 1.' ); 177 | expect( stubs.info.getCall( 1 ).args[ 0 ] ).to.equal( 'Info message 2.' ); 178 | 179 | expect( stubs.error.callCount ).to.equal( 2 ); 180 | expect( stubs.error.getCall( 0 ).args[ 0 ] ).to.equal( 'Error message 1.' ); 181 | expect( stubs.error.getCall( 1 ).args[ 0 ] ).to.equal( 'Error message 2.' ); 182 | } ); 183 | } ); 184 | 185 | describe( 'all()', () => { 186 | it( 'returns all stored messages', () => { 187 | logger.concat( { 188 | info: [ 'Info message 1.', 'Info message 2.' ], 189 | error: [ 'Error message 1.', 'Error message 2.' ] 190 | } ); 191 | 192 | expect( logger.all() ).to.deep.equal( { 193 | info: [ 'Info message 1.', 'Info message 2.' ], 194 | error: [ 'Error message 1.', 'Error message 2.' ] 195 | } ); 196 | } ); 197 | } ); 198 | } ); 199 | } ); 200 | -------------------------------------------------------------------------------- /tests/utils/parserepositoryurl.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | /* jshint mocha:true */ 7 | 8 | 'use strict'; 9 | 10 | const expect = require( 'chai' ).expect; 11 | const parseRepositoryUrl = require( '../../lib/utils/parserepositoryurl' ); 12 | 13 | describe( 'utils', () => { 14 | describe( 'parseRepositoryUrl()', () => { 15 | it( 'returns "master" branch if "options.defaultBranch" was not specified', () => { 16 | const repository = parseRepositoryUrl( 'foo/bar', { 17 | urlTemplate: 'https://github.com/${ path }.git' 18 | } ); 19 | 20 | expect( repository ).to.deep.equal( { 21 | url: 'https://github.com/foo/bar.git', 22 | branch: 'master', 23 | tag: undefined, 24 | directory: 'bar' 25 | } ); 26 | } ); 27 | 28 | it( 'allows modifying the branch using hash in "/" template', () => { 29 | const repository = parseRepositoryUrl( 'foo/bar#stable', { 30 | urlTemplate: 'https://github.com/${ path }.git' 31 | } ); 32 | 33 | expect( repository ).to.deep.equal( { 34 | url: 'https://github.com/foo/bar.git', 35 | branch: 'stable', 36 | tag: undefined, 37 | directory: 'bar' 38 | } ); 39 | } ); 40 | 41 | it( 'ignores "options.defaultBranch" if branch is defined in specified repository', () => { 42 | const repository = parseRepositoryUrl( 'foo/bar#stable', { 43 | urlTemplate: 'https://github.com/${ path }.git', 44 | defaultBranch: 'master' 45 | } ); 46 | 47 | expect( repository ).to.deep.equal( { 48 | url: 'https://github.com/foo/bar.git', 49 | branch: 'stable', 50 | tag: undefined, 51 | directory: 'bar' 52 | } ); 53 | } ); 54 | 55 | it( 'extracts all parameters basing on specified "http" URL', () => { 56 | const repository = parseRepositoryUrl( 'http://github.com/foo/bar.git' ); 57 | 58 | expect( repository ).to.deep.equal( { 59 | url: 'http://github.com/foo/bar.git', 60 | branch: 'master', 61 | tag: undefined, 62 | directory: 'bar' 63 | } ); 64 | } ); 65 | 66 | it( 'extracts all parameters basing on specified "https" URL', () => { 67 | const repository = parseRepositoryUrl( 'https://github.com/foo/bar.git' ); 68 | 69 | expect( repository ).to.deep.equal( { 70 | url: 'https://github.com/foo/bar.git', 71 | branch: 'master', 72 | tag: undefined, 73 | directory: 'bar' 74 | } ); 75 | } ); 76 | 77 | it( 'extracts all parameters basing on specified "file" (Unix path)', () => { 78 | const repository = parseRepositoryUrl( 'file:///Users/Workspace/Projects/foo/bar' ); 79 | 80 | expect( repository ).to.deep.equal( { 81 | url: 'file:///Users/Workspace/Projects/foo/bar', 82 | branch: 'master', 83 | tag: undefined, 84 | directory: 'bar' 85 | } ); 86 | } ); 87 | 88 | it( 'extracts all parameters basing on specified "file" (Windows path)', () => { 89 | const repository = parseRepositoryUrl( 'file://C:/Users/Workspace/Projects/foo/bar' ); 90 | 91 | expect( repository ).to.deep.equal( { 92 | url: 'file://c/Users/Workspace/Projects/foo/bar', 93 | branch: 'master', 94 | tag: undefined, 95 | directory: 'bar' 96 | } ); 97 | } ); 98 | 99 | it( 'extracts all parameters basing on specified "git" URL', () => { 100 | const repository = parseRepositoryUrl( 'git@github.com:foo/bar.git' ); 101 | 102 | expect( repository ).to.deep.equal( { 103 | url: 'git@github.com:foo/bar.git', 104 | branch: 'master', 105 | tag: undefined, 106 | directory: 'bar' 107 | } ); 108 | } ); 109 | 110 | it( 'allows modifying the branch using hash in the URL', () => { 111 | const repository = parseRepositoryUrl( 'https://github.com/foo/bar.git#stable' ); 112 | 113 | expect( repository ).to.deep.equal( { 114 | url: 'https://github.com/foo/bar.git', 115 | branch: 'stable', 116 | tag: undefined, 117 | directory: 'bar' 118 | } ); 119 | } ); 120 | 121 | it( 'returns specific tag that contains semantic version number', () => { 122 | const repository = parseRepositoryUrl( 'foo/bar@v30.0.0', { 123 | urlTemplate: 'https://github.com/${ path }.git' 124 | } ); 125 | 126 | expect( repository ).to.deep.equal( { 127 | url: 'https://github.com/foo/bar.git', 128 | branch: 'master', 129 | tag: 'v30.0.0', 130 | directory: 'bar' 131 | } ); 132 | } ); 133 | 134 | it( 'returns specific tag that does not contain semantic version number', () => { 135 | const repository = parseRepositoryUrl( 'foo/bar@customTagName', { 136 | urlTemplate: 'https://github.com/${ path }.git' 137 | } ); 138 | 139 | expect( repository ).to.deep.equal( { 140 | url: 'https://github.com/foo/bar.git', 141 | branch: 'master', 142 | tag: 'customTagName', 143 | directory: 'bar' 144 | } ); 145 | } ); 146 | 147 | it( 'returns the "latest" tag', () => { 148 | const repository = parseRepositoryUrl( 'foo/bar@latest', { 149 | urlTemplate: 'https://github.com/${ path }.git' 150 | } ); 151 | 152 | expect( repository ).to.deep.equal( { 153 | url: 'https://github.com/foo/bar.git', 154 | branch: 'master', 155 | tag: 'latest', 156 | directory: 'bar' 157 | } ); 158 | } ); 159 | 160 | describe( 'baseBranches support (ticket: #103)', () => { 161 | it( 'returns default branch name if base branches is not specified', () => { 162 | const repository = parseRepositoryUrl( 'foo/bar', { 163 | urlTemplate: 'https://github.com/${ path }.git', 164 | defaultBranch: 'develop', 165 | tag: undefined, 166 | cwdPackageBranch: 'master' 167 | } ); 168 | 169 | expect( repository ).to.deep.equal( { 170 | url: 'https://github.com/foo/bar.git', 171 | branch: 'develop', 172 | tag: undefined, 173 | directory: 'bar' 174 | } ); 175 | } ); 176 | 177 | it( 'returns default branch name if main package is not a git repository', () => { 178 | const repository = parseRepositoryUrl( 'foo/bar', { 179 | urlTemplate: 'https://github.com/${ path }.git', 180 | defaultBranch: 'develop' 181 | } ); 182 | 183 | expect( repository ).to.deep.equal( { 184 | url: 'https://github.com/foo/bar.git', 185 | branch: 'develop', 186 | tag: undefined, 187 | directory: 'bar' 188 | } ); 189 | } ); 190 | 191 | it( 'returns "master" as default branch if base branches and default branch are not specified', () => { 192 | const repository = parseRepositoryUrl( 'foo/bar', { 193 | urlTemplate: 'https://github.com/${ path }.git', 194 | cwdPackageBranch: 'master' 195 | } ); 196 | 197 | expect( repository ).to.deep.equal( { 198 | url: 'https://github.com/foo/bar.git', 199 | branch: 'master', 200 | tag: undefined, 201 | directory: 'bar' 202 | } ); 203 | } ); 204 | 205 | it( 'returns default branch name if base branches is an empty array', () => { 206 | const repository = parseRepositoryUrl( 'foo/bar', { 207 | urlTemplate: 'https://github.com/${ path }.git', 208 | defaultBranch: 'develop', 209 | tag: undefined, 210 | baseBranches: [], 211 | cwdPackageBranch: 'master' 212 | } ); 213 | 214 | expect( repository ).to.deep.equal( { 215 | url: 'https://github.com/foo/bar.git', 216 | branch: 'develop', 217 | tag: undefined, 218 | directory: 'bar' 219 | } ); 220 | } ); 221 | 222 | it( 'returns default branch name if the main repo is not whitelisted in "baseBranches" array', () => { 223 | const repository = parseRepositoryUrl( 'foo/bar', { 224 | urlTemplate: 'https://github.com/${ path }.git', 225 | defaultBranch: 'develop', 226 | tag: undefined, 227 | baseBranches: [ 'stable' ], 228 | cwdPackageBranch: 'master' 229 | } ); 230 | 231 | expect( repository ).to.deep.equal( { 232 | url: 'https://github.com/foo/bar.git', 233 | branch: 'develop', 234 | tag: undefined, 235 | directory: 'bar' 236 | } ); 237 | } ); 238 | 239 | it( 'returns the "cwdPackageBranch" value if a branch is not specified and the value is whitelisted', () => { 240 | const repository = parseRepositoryUrl( 'foo/bar', { 241 | urlTemplate: 'https://github.com/${ path }.git', 242 | defaultBranch: 'develop', 243 | tag: undefined, 244 | baseBranches: [ 'stable', 'master' ], 245 | cwdPackageBranch: 'stable' 246 | } ); 247 | 248 | expect( repository ).to.deep.equal( { 249 | url: 'https://github.com/foo/bar.git', 250 | branch: 'stable', 251 | tag: undefined, 252 | directory: 'bar' 253 | } ); 254 | } ); 255 | 256 | it( 'ignores options if a branch is specified in the repository URL', () => { 257 | const repository = parseRepositoryUrl( 'foo/bar#mrgit', { 258 | urlTemplate: 'https://github.com/${ path }.git', 259 | defaultBranch: 'develop', 260 | tag: undefined, 261 | baseBranches: [ 'stable' ], 262 | cwdPackageBranch: 'master' 263 | } ); 264 | 265 | expect( repository ).to.deep.equal( { 266 | url: 'https://github.com/foo/bar.git', 267 | branch: 'mrgit', 268 | tag: undefined, 269 | directory: 'bar' 270 | } ); 271 | } ); 272 | 273 | it( 'ignores options if a branch is specified in the repository URL ("baseBranches" contains "cwdPackageBranch")', () => { 274 | const repository = parseRepositoryUrl( 'foo/bar#mrgit', { 275 | urlTemplate: 'https://github.com/${ path }.git', 276 | defaultBranch: 'develop', 277 | tag: undefined, 278 | baseBranches: [ 'master' ], 279 | cwdPackageBranch: 'master' 280 | } ); 281 | 282 | expect( repository ).to.deep.equal( { 283 | url: 'https://github.com/foo/bar.git', 284 | branch: 'mrgit', 285 | tag: undefined, 286 | directory: 'bar' 287 | } ); 288 | } ); 289 | } ); 290 | } ); 291 | } ); 292 | -------------------------------------------------------------------------------- /tests/utils/updatejsonfile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md. 4 | */ 5 | 6 | /* jshint mocha:true */ 7 | 8 | 'use strict'; 9 | 10 | const updateJsonFile = require( '../../lib/utils/updatejsonfile' ); 11 | const expect = require( 'chai' ).expect; 12 | const sinon = require( 'sinon' ); 13 | 14 | describe( 'utils', () => { 15 | afterEach( () => { 16 | sinon.restore(); 17 | } ); 18 | 19 | describe( 'updateJsonFile()', () => { 20 | it( 'should read, update and save JSON file', () => { 21 | const path = 'path/to/file.json'; 22 | const fs = require( 'fs' ); 23 | const readFileStub = sinon.stub( fs, 'readFileSync' ).callsFake( () => '{}' ); 24 | const modifiedJSON = { modified: true }; 25 | const writeFileStub = sinon.stub( fs, 'writeFileSync' ); 26 | 27 | updateJsonFile( path, () => { 28 | return modifiedJSON; 29 | } ); 30 | 31 | expect( readFileStub.calledOnce ).to.equal( true ); 32 | expect( readFileStub.firstCall.args[ 0 ] ).to.equal( path ); 33 | expect( writeFileStub.calledOnce ).to.equal( true ); 34 | expect( writeFileStub.firstCall.args[ 0 ] ).to.equal( path ); 35 | expect( writeFileStub.firstCall.args[ 1 ] ).to.equal( JSON.stringify( modifiedJSON, null, 2 ) + '\n' ); 36 | } ); 37 | } ); 38 | } ); 39 | --------------------------------------------------------------------------------