├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── BUG_REPORT.md │ └── FEATURE_REQUEST.md ├── PULL_REQUEST_TEMPLATE.md ├── SECURITY.md ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── dependabot-approval.yml │ └── publish.yml ├── .gitignore ├── .npmrc ├── .pre-commit-hooks.yaml ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── .xo-config ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── packages ├── bob │ ├── git-mob-core.config.js │ ├── git-mob.config.js │ ├── index.js │ └── package.json ├── git-mob-core │ ├── .npmignore │ ├── README.md │ ├── jest.config.json │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── config-manager.spec.ts │ │ ├── config-manager.ts │ │ ├── git-mob-api │ │ │ ├── author.ts │ │ │ ├── errors │ │ │ │ └── author-not-found.ts │ │ │ ├── exec-command.ts │ │ │ ├── fetch │ │ │ │ └── http-fetch.ts │ │ │ ├── git-authors │ │ │ │ ├── create-coauthors-file.spec.ts │ │ │ │ ├── create-coauthors-file.ts │ │ │ │ ├── fetch-github-authors.spec.ts │ │ │ │ ├── fetch-github-authors.ts │ │ │ │ ├── index.spec.ts │ │ │ │ ├── index.ts │ │ │ │ ├── repo-author-list.spec.ts │ │ │ │ └── repo-author-list.ts │ │ │ ├── git-config.ts │ │ │ ├── git-message │ │ │ │ ├── index.spec.ts │ │ │ │ ├── index.ts │ │ │ │ ├── message-formatter.spec.ts │ │ │ │ └── message-formatter.ts │ │ │ ├── git-mob-config.ts │ │ │ ├── git-rev-parse.ts │ │ │ ├── manage-authors │ │ │ │ ├── add-new-coauthor.spec.ts │ │ │ │ └── add-new-coauthor.ts │ │ │ └── resolve-git-message-path.ts │ │ ├── index.spec.ts │ │ ├── index.ts │ │ └── test-helpers │ │ │ └── author-mocks.ts │ ├── tsconfig.prod.json │ └── tsconfig.test.json └── git-mob │ ├── .npmignore │ ├── README.md │ ├── bin │ ├── add-coauthor.js │ ├── mob-print.js │ ├── mob.js │ ├── solo.js │ └── suggest-coauthors.js │ ├── hook-examples │ └── prepare-commit-msg-nodejs │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── check-author.spec.ts │ ├── check-author.ts │ ├── colours.ts │ ├── git-add-coauthor.spec.ts │ ├── git-add-coauthor.ts │ ├── git-authors │ │ ├── save-missing-authors.spec.ts │ │ └── save-missing-authors.ts │ ├── git-mob-local-coauthors.spec.js │ ├── git-mob-print.spec.ts │ ├── git-mob-print.ts │ ├── git-mob.d.ts │ ├── git-mob.spec.ts │ ├── git-mob.ts │ ├── git-solo.spec.ts │ ├── git-suggest-coauthors.spec.ts │ ├── git-suggest-coauthors.ts │ ├── helpers.spec.ts │ ├── helpers.ts │ ├── install │ │ └── create-author-file.ts │ └── solo.ts │ └── test-helpers │ ├── .gitconfig │ ├── env.cjs │ └── index.js └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: rkotze # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | --- 5 | 6 | 10 | 11 | ### Prerequisites 12 | 13 | - [ ] [Checked that your issue isn't already filed][git-mob issues] 14 | - [ ] Tried upgrading to the latest git-mob version (`npm i -g git-mob`) 15 | - [ ] Tried upgrading to the latest git version (`brew upgrade git` if installed with Homebrew/Linuxbrew) 16 | 17 | [git-mob issues]: https://github.com/rkotze/git-mob/issues?utf8=%E2%9C%93&q=is%3Aissue 18 | 19 | ### Description 20 | 21 | 22 | 23 | ### Steps to Reproduce 24 | 25 | 1. ... 26 | 2. ... 27 | 3. and so on... 28 | 29 | **Expected behavior:** [What you expect to happen] 30 | 31 | **Actual behavior:** [What actually happens] 32 | 33 | **Reproduces how often:** [What percentage of the time does it reproduce?] 34 | 35 | ### Versions 36 | 37 | - operating system and version: 38 | - git-mob version (`git-mob --version`): 39 | - git version (`git --version`): 40 | 41 | ### Additional Information 42 | 43 | 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | --- 5 | 6 | 13 | 14 | ## Summary 15 | 16 | 17 | 18 | ## Motivation 19 | 20 | 21 | 22 | ## Describe alternatives you've considered 23 | 24 | 25 | 26 | ## Additional context 27 | 28 | 29 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Issue URL: 6 | 7 | ## What is the new behaviour? 8 | 9 | 10 | 11 | ## What was the behaviour? 12 | 13 | 14 | 15 | ## Pull request checklist 16 | 17 | 18 | 19 | - [ ] Tests for the changes have been added (for bug fixes / features) 20 | - [ ] Docs have been reviewed and added / updated if needed (for bug fixes / features) 21 | - [ ] Updated the `CHANGELOG.md` to capture my changes 22 | - [ ] Build (`npm run build`) was successfully run locally 23 | - [ ] All tests and linting (`npm run checks`) has passed locally 24 | - [ ] I kept my pull requests small so it can be reviewed easier 25 | 26 | ## Pull request type 27 | 28 | 29 | 30 | 31 | 32 | Please check the type of change your PR introduces: 33 | 34 | - [ ] Bug fix 35 | - [ ] Feature 36 | - [ ] Code style update (formatting, renaming) 37 | - [ ] Refactoring (no functional changes, no api changes) 38 | - [ ] Build related changes 39 | - [ ] Documentation content changes 40 | - [ ] Other (please describe): 41 | 42 | ## Does this introduce a breaking change? 43 | 44 | - [ ] Yes 45 | - [ ] No 46 | 47 | 48 | 49 | ## Other information 50 | 51 | 52 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Support the latest major version. 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 2.x | :white_check_mark: | 10 | 11 | Will look to try keep dependencies up to date. 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: 'npm' # See documentation for possible values 9 | directory: '/' 10 | schedule: 11 | interval: 'weekly' 12 | open-pull-requests-limit: 10 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the trunk branch 8 | push: 9 | branches: [master] 10 | pull_request: 11 | branches: [master] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "build" 19 | build: 20 | name: Node.js ${{ matrix.node-version }} 21 | # The type of runner that the job will run on 22 | runs-on: ubuntu-latest 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | node-version: 27 | - 22 28 | - 20 29 | - 18 30 | 31 | # Steps represent a sequence of tasks that will be executed as part of the job 32 | steps: 33 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 34 | - name: Checkout code 35 | uses: actions/checkout@v4 36 | with: 37 | fetch-depth: '0' 38 | 39 | - name: Setup Node 40 | uses: actions/setup-node@v4 41 | with: 42 | node-version: ${{ matrix.node-version }} 43 | 44 | - name: Install dependencies 45 | run: npm install --ignore-scripts 46 | 47 | - name: Build minify 48 | run: npm run prepack 49 | 50 | - name: Run lint and tests 51 | run: npm run checks 52 | 53 | - name: Minify test 54 | run: npm run minifytest 55 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-approval.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot Pull Request Approve and Merge 2 | 3 | on: pull_request_target 4 | 5 | permissions: 6 | pull-requests: write 7 | contents: write 8 | 9 | jobs: 10 | dependabot: 11 | runs-on: ubuntu-latest 12 | # Checking the actor will prevent your Action run failing on non-Dependabot 13 | # PRs but also ensures that it only does work for Dependabot PRs. 14 | if: ${{ github.actor == 'dependabot[bot]' }} 15 | steps: 16 | # This first step will fail if there's no metadata and so the approval 17 | # will not occur. 18 | - name: Dependabot metadata 19 | id: dependabot-metadata 20 | uses: dependabot/fetch-metadata@v2.2.0 21 | with: 22 | github-token: '${{ secrets.GITHUB_TOKEN }}' 23 | # Finally, this sets the PR to allow auto-merging for patch and minor 24 | # updates if all checks pass 25 | - name: Enable auto-merge for Dependabot PRs 26 | if: ${{ steps.dependabot-metadata.outputs.update-type != 'version-update:semver-major' }} 27 | run: gh pr merge --auto --squash "$PR_URL" 28 | env: 29 | PR_URL: ${{ github.event.pull_request.html_url }} 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: 'Publish' 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Check out code 11 | uses: actions/checkout@v4 12 | 13 | - name: Set up Node 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: '20' 17 | 18 | - name: Install dependencies 19 | run: npm install --ignore-scripts 20 | 21 | - name: Publish Git Mob Core 22 | uses: JS-DevTools/npm-publish@v1 23 | with: 24 | token: ${{ secrets.NPM_Publish }} 25 | greater-version-only: true 26 | package: 'packages/git-mob-core/package.json' 27 | 28 | - name: Publish Git Mob CLI 29 | uses: JS-DevTools/npm-publish@v1 30 | with: 31 | token: ${{ secrets.NPM_Publish }} 32 | greater-version-only: true 33 | package: 'packages/git-mob/package.json' 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | !test-helpers/.env 60 | 61 | # next.js build output 62 | .next 63 | 64 | # Editor config 65 | .idea/ 66 | 67 | dist -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | if-present=true -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: add-coauthors 2 | name: add-coauthors 3 | entry: ./packages/git-mob/hook-examples/prepare-commit-msg-nodejs 4 | language: script 5 | stages: [prepare-commit-msg] 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 85, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "useTabs": false, 6 | "tabWidth": 2, 7 | "arrowParens": "avoid", 8 | "endOfLine": "auto", 9 | "bracketSpacing": true 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["samverschueren.linter-xo", "RichardKotze.git-mob"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "xo.enable": true, 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | "cSpell.words": ["coauthors"] 6 | } 7 | -------------------------------------------------------------------------------- /.xo-config: -------------------------------------------------------------------------------- 1 | { 2 | "space": true, 3 | "ignores": [ 4 | "bin", 5 | "dist", 6 | "test-helpers", 7 | "*.d.ts" 8 | ], 9 | "prettier": true, 10 | "plugins": [ 11 | "jest" 12 | ], 13 | "envs": [ 14 | "jest" 15 | ], 16 | "rules": { 17 | "no-unused-vars": [ 18 | "error", 19 | { 20 | "argsIgnorePattern": "^_" 21 | } 22 | ], 23 | "object-curly-spacing": "off", 24 | "curly": [ 25 | "error", 26 | "multi-line" 27 | ], 28 | "comma-dangle": "off", 29 | "ava/no-skip-test": "off", 30 | "linebreak-style": "off", 31 | "operator-linebreak": [ 32 | "error", 33 | "after", 34 | { 35 | "overrides": { 36 | "?": "ignore", 37 | ":": "ignore" 38 | } 39 | } 40 | ], 41 | "valid-jsdoc": [ 42 | "warn", 43 | { 44 | "requireParamDescription": false, 45 | "requireReturn": false 46 | } 47 | ], 48 | "import/extensions": "off", 49 | "unicorn/no-array-reduce": "off", 50 | "unicorn/no-array-callback-reference": "off", 51 | "unicorn/prefer-module": 0, 52 | "unicorn/no-process-exit": 0, 53 | "unicorn/prevent-abbreviations": "off", 54 | "node/prefer-global/process": 0, 55 | "n/prefer-global/process": 0, 56 | "n/file-extension-in-import": 0, 57 | "object-shorthand": 0, 58 | "arrow-body-style": 0, 59 | "capitalized-comments": 0, 60 | "ava/no-ignored-test-files": [ 61 | "error", 62 | { 63 | "files": [ 64 | "**/*.spec.*" 65 | ] 66 | } 67 | ], 68 | "@typescript-eslint/object-curly-spacing": "off", 69 | "@typescript-eslint/comma-dangle": "off", 70 | "@typescript-eslint/prefer-nullish-coalescing": 0, 71 | "@typescript-eslint/parameter-properties": "off" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | Follows [Semantic Versioning](https://semver.org/). 4 | 5 | ## git-mob-core 0.10.1 6 | 7 | ### Fixed 8 | 9 | - If an email has partially the same email as another co-author they will get included. This addresses this issue. [Issue 213](https://github.com/rkotze/git-mob/issues/213) 10 | 11 | ## git-mob 4.0.0 12 | 13 | ### Added 14 | 15 | - Update to `suggest-coauthors` will show a select interactive list using `inquirer/checkbox`. Select one or more authors to save. Reducing the number of steps to add new co-authors. 16 | - Finished migration to TypeScript for the main files in git-mob and git-mob-core packages. [Issue 83](https://github.com/rkotze/git-mob/issues/83) 17 | 18 | ### Breaking 19 | 20 | - Removed the following commands because I think they are low value to maintain. See readme on how to edit/delete co-authors. 21 | - `git delete-coauthor` 22 | - `git edit-coauthor` 23 | 24 | ## git-mob-core 0.10.0 25 | 26 | ### Added 27 | 28 | - Expose a public function to format Git message co-author trailers in one place. For consumers like Git Mob VS Code extension. 29 | 30 | ## git-mob-core 0.9.3 31 | 32 | ### Added 33 | 34 | - Migrate git template `git-messages` function to TypeScript. 35 | 36 | ### Fixes 37 | 38 | - When no path set for the commit template, default to the global template. Don't use a relative path. 39 | 40 | ## git-mob-core 0.9.2 41 | 42 | ### Fixes 43 | 44 | - Global path to `.git-coauthors` can be overwritten by env var `GITMOB_COAUTHORS_PATH`. 45 | 46 | ## git-mob-core 0.9.1 47 | 48 | ### Fixes 49 | 50 | - When creating a new `.git-coauthors` using the `createCoAuthorsFile` it is created only globally by providing internally the global path. 51 | 52 | ## git-mob-core 0.9.0 53 | 54 | ### Added 55 | 56 | - Specify authors to save when creating the coAuthor file. 57 | - Clean up unused features in author file: `coAuthor`, `author` format functions and no need for the `write` method. 58 | - Remove unused old command API 59 | - Convert `GitAuthors` function to TypeScript and define new internal type `CoAuthorSchema`. 60 | - Breaking: `getSelectedCoAuthors` now returns a **promise** with type `Author[]`. 61 | - Breaking: `getPrimaryAuthor` now returns a **promise** with type `Author`. 62 | - Breaking: `setPrimaryAuthor` now returns a **promise** `void`. 63 | - New: `searchGitHubAuthors` search by name and this will return `Author[]`. 64 | 65 | ## git-mob 3.2.0 66 | 67 | ### Added 68 | 69 | - Integrate breaking changes in core API: `getPrimaryAuthor`, `getSelectedCoAuthors`, `setPrimaryAuthor`. 70 | - New flag `-p` path will print out path to `.git-coauthors` file. 71 | 72 | ## git-mob 3.1.1 73 | 74 | ### Added 75 | 76 | -- `git suggest-coauthors [author name or author email]` can filter by author name or email. Addresses [issue 90](https://github.com/rkotze/git-mob/issues/90) 77 | 78 | ### Refactored 79 | 80 | - Remove legacy git-message API and replace it with git-mob-core `git-message` API 81 | - Remove legacy git-add-coauthor API and replace it with git-mob-core `saveNewCoAuthors` 82 | - Remove legacy git-suggest-coauthor API and replace it with git-mob-core `repoAuthorList` 83 | - Migrated `git-suggest-coauthors` to TypeScript 84 | - `git mob-print -i` uses git mob core to print out initials of selected co-authors 85 | - Post install script to create global coauthor file uses `createCoAuthorsFile` from git mob core 86 | - Migrated mob print file to TypeScript 87 | - Removed legacy git mob commands no longer used. 88 | - Removed legacy git commands no longer used. 89 | 90 | ## git-mob-core 0.8.2 91 | 92 | ### Added 93 | 94 | - Added function to create a global coauthor file `createCoAuthorsFile`. 95 | 96 | ## git-mob-core 0.8.0 97 | 98 | ### Added 99 | 100 | - Added `repoAuthorList` which will list all contributors from a repo 101 | - Added filter to `repoAuthorList` which uses `--author` flag from `git shortlog`. 102 | 103 | ### Refactored 104 | 105 | - Add new co-author module migrated to TypeScript and tested 106 | - Change to async `topLevelDirectory`, `insideWorkTree` - may not be needed in future versions 107 | - `resolve-git-message-path` migrated to TypeScript 108 | - Changed to async `resolveGitMessagePath`, `setCommitTemplate` 109 | 110 | ## git-mob 3.0.0 111 | 112 | ### Added 113 | 114 | - Now uses ESM modules and requires Node 16+ (Most parts work with Node 14) 115 | - CI run tests in 3 Node environments 14, 16, 18 116 | - Updated dependencies and dev dependencies to patch security issues 117 | - Updated tests to support ESM 118 | 119 | ## git-mob-core 0.7.0 120 | 121 | - Requires Node 16+ 122 | - Module systems support ESM and CJS 123 | 124 | ## git-mob-core 0.6.0 125 | 126 | ### Added 127 | 128 | ```ts 129 | gitMobConfig = { 130 | localTemplate(): >, 131 | fetchFromGitHub(): >, 132 | }; 133 | 134 | gitConfig = { 135 | getLocalCommitTemplate(): >, 136 | getGlobalCommitTemplate(): >, 137 | }; 138 | 139 | gitRevParse = { 140 | insideWorkTree(): string, 141 | topLevelDirectory(): boolean, 142 | }; 143 | ``` 144 | 145 | ## git-mob 2.5.0 146 | 147 | ### Added 148 | 149 | - Integrated git-mob-core for main `git mob` features 150 | - Reduced the calls to `git` CLI to speed up command execution for `git mob` 151 | - Convert src git-mob and spec files from JS to TS 152 | 153 | ## git-mob-core 0.5.0 10-06-2023 154 | 155 | ### Added 156 | 157 | - Added config manager feature to set the child process cwd when needed see [issue 109](https://github.com/rkotze/git-mob/issues/109). New functions `getConfig` and `updateConfig`. 158 | 159 | ## git-mob 2.4.0 21-05-2023 160 | 161 | ### Added 162 | 163 | - Integrated git-mob-core solo function 164 | - Override the global `.git-coauthors` file with one specified in root folder of a Git repository. Thanks to @tlabeeuw 165 | 166 | ## git-mob-core 0.4.0 21-05-2023 167 | 168 | ### Added 169 | 170 | - `updateGitTemplate` will keep global template up to date if local one is used for current repository. 171 | - `pathToCoAuthors` will return the path to `.git-coauthors` file. 172 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at ideler.dennis@gmail.com or rkotze@findmypast.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | ## Getting Started 4 | 5 | 1. Install dependencies 6 | ``` 7 | npm install 8 | ``` 9 | 1. Run linter and tests 10 | ``` 11 | npm run checks 12 | ``` 13 | 14 | Other test commands 15 | 16 | - Run a test file 17 | 18 | ``` 19 | npm test ./dist/git-mob.spec.js 20 | ``` 21 | 22 | See [Ava](https://github.com/avajs/ava) for more options. This is for `git-mob` package. 23 | 24 | Jest is used for `git-mob-core` package. 25 | 26 | Asserting prompts on the cli using [coffee](https://github.com/node-modules/coffee). 27 | 28 | ## Releasing 29 | 30 | This section is for maintainers with push access to git-mob on npm. 31 | 32 | Git Mob uses workspaces now and the flags below are needed to version each of the packages. 33 | 34 | Read more about [workspaces](https://docs.npmjs.com/cli/v8/commands/npm-version?v=true#workspaces) for version command. Using workspaces flag runs the version command in all packages 35 | 36 | ### Versioning 37 | 38 | 1. Version a package 39 | ``` 40 | npm version minor --workspace=git-mob 41 | ``` 42 | 2. Or all packages 43 | ``` 44 | npm version patch --workspaces 45 | ``` 46 | 3. Commit and push 47 | 48 | ### Releasing 49 | 50 | 1. Bump the root package version and this will make a git tag (major, minor, patch); e.g. 51 | ``` 52 | npm version patch 53 | ``` 54 | 2. Git Push 55 | 3. Run the publish CI GitHub actions 56 | 4. Release notes added here https://github.com/rkotze/git-mob/releases 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Richard Kotze 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Git Mob - Co-author commits 2 | 3 | _Add co-authors to commits_ when you collaborate on code. Use when pairing with a team mate or mobbing with your team. 4 | 5 | Buy Me A Coffee 6 | 7 | [✨ Git Mob VS Code extension](https://github.com/rkotze/git-mob-vs-code) 8 | 9 | ![gif showing example usage of git-mob](https://user-images.githubusercontent.com/497458/38682926-2e0cc99c-3e64-11e8-9f71-6336e111005b.gif) 10 | 11 | Git Mob repository is organised into workspaces 12 | 13 | 🌟 [Git Mob CLI](packages/git-mob) documentation: Main npm package to install & see list of features 14 | 15 | 🌟 [Git Mob core](packages/git-mob-core) documentation: An npm package containing core Git Mob functions 16 | 17 | ## More info 18 | 19 | [See contribution guidelines](CONTRIBUTING.md) 20 | 21 | [See git-mob discussions](https://github.com/rkotze/git-mob/discussions) 22 | 23 | Read our blog post to find out why git-mob exists: [Co-author commits with Git Mob](http://tech.findmypast.com/co-author-commits-with-git-mob) 24 | 25 | \* [If you have git-duet installed, you'll need to uninstall it](https://github.com/rkotze/git-mob/issues/2) since it conflicts with the git-solo command. 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "git-mob-workspace", 3 | "version": "4.0.1", 4 | "description": "CLI tool for adding co-authors to commits.", 5 | "private": true, 6 | "workspaces": [ 7 | "packages/git-mob-core", 8 | "packages/git-mob", 9 | "packages/bob" 10 | ], 11 | "scripts": { 12 | "build": "npm run build --workspaces", 13 | "checks": "npm run test && npm run lint", 14 | "test:w": "echo \"Run per workspace.\"", 15 | "test": "npm run test --workspaces", 16 | "lint": "xo", 17 | "minifytest": "npm run minifytest --workspaces", 18 | "install": "npm install --ignore-scripts", 19 | "prepack": "npm run prepack --workspaces", 20 | "preversion": "npm run checks --workspaces", 21 | "postversion": "git push --follow-tags" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git@github.com:rkotze/git-mob.git" 26 | }, 27 | "engines": { 28 | "node": ">=16" 29 | }, 30 | "keywords": [ 31 | "cli", 32 | "cli-app", 33 | "git-pair", 34 | "git-duet", 35 | "git", 36 | "github", 37 | "co-author", 38 | "pairing", 39 | "pair programming", 40 | "mob programming", 41 | "extreme programming", 42 | "xp", 43 | "social coding" 44 | ], 45 | "author": "Richard Kotze", 46 | "license": "MIT", 47 | "contributors": [ 48 | { 49 | "name": "Richard Kotze", 50 | "url": "https://github.com/rkotze" 51 | }, 52 | { 53 | "name": "Dennis Ideler", 54 | "url": "https://github.com/dideler" 55 | } 56 | ], 57 | "devDependencies": { 58 | "eslint-plugin-jest": "^28.13.0", 59 | "xo": "^0.60.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/bob/git-mob-core.config.js: -------------------------------------------------------------------------------- 1 | const glob = require('glob'); 2 | 3 | const baseConfig = { 4 | entryPoints: ['./src/index.ts'], 5 | mainFields: ['module', 'main'], 6 | platform: 'node', 7 | target: ['node16'], 8 | format: 'cjs', 9 | outdir: './dist', 10 | plugins: [], 11 | logLevel: 'info', 12 | bundle: true, 13 | external: [], 14 | }; 15 | 16 | function gitMobCoreConfig(argv) { 17 | if (argv.test) { 18 | const specFiles = glob.sync('./src/**/*(*.js|*.ts)'); 19 | baseConfig.entryPoints = [...baseConfig.entryPoints, ...specFiles]; 20 | baseConfig.sourcemap = true; 21 | baseConfig.bundle = false; 22 | } 23 | 24 | return baseConfig; 25 | } 26 | 27 | module.exports = { gitMobCoreConfig }; 28 | -------------------------------------------------------------------------------- /packages/bob/git-mob.config.js: -------------------------------------------------------------------------------- 1 | const glob = require('glob'); 2 | 3 | const baseConfig = { 4 | entryPoints: [ 5 | './src/git-mob.ts', 6 | './src/solo.ts', 7 | './src/git-add-coauthor.ts', 8 | './src/git-mob-print.ts', 9 | './src/git-suggest-coauthors.ts', 10 | './src/install/create-author-file.ts', 11 | ], 12 | mainFields: ['module', 'main'], 13 | bundle: true, 14 | platform: 'node', 15 | target: ['node16'], 16 | outdir: './dist', 17 | format: 'esm', 18 | plugins: [], 19 | logLevel: 'info', 20 | external: [ 21 | 'git-mob-core', 22 | '@inquirer/checkbox', 23 | 'common-tags', 24 | 'minimist', 25 | 'update-notifier', 26 | 'ava', 27 | 'sinon', 28 | 'coffee', 29 | ], 30 | }; 31 | 32 | function gitMobConfig(argv) { 33 | if (argv.test) { 34 | const specFiles = glob.sync('./src/**/*.spec.*'); 35 | baseConfig.entryPoints = [...baseConfig.entryPoints, ...specFiles]; 36 | baseConfig.sourcemap = true; 37 | } 38 | 39 | return baseConfig; 40 | } 41 | 42 | module.exports = { gitMobConfig }; 43 | -------------------------------------------------------------------------------- /packages/bob/index.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | const esbuild = require('esbuild'); 4 | const minimist = require('minimist'); 5 | const { gitMobConfig } = require('./git-mob.config'); 6 | const { gitMobCoreConfig } = require('./git-mob-core.config'); 7 | 8 | // Flags 9 | // -w: watch for file changes 10 | // -m: minify code - use for publish 11 | // -t: test flow to include sourcemaps 12 | const argv = minimist(process.argv.slice(2), { 13 | boolean: ['w', 'm', 't', 'c'], 14 | 15 | alias: { 16 | w: 'watch', 17 | m: 'minify', 18 | t: 'test', 19 | c: 'config', 20 | }, 21 | }); 22 | 23 | let baseConfig = gitMobConfig(argv); 24 | 25 | if (argv.config === 'core') { 26 | baseConfig = gitMobCoreConfig(argv); 27 | } 28 | 29 | baseConfig.minify = argv.minify; 30 | 31 | if (argv.watch) { 32 | baseConfig.watch = { 33 | onRebuild(error, result) { 34 | if (error) console.error('watch build failed:', error); 35 | else console.log('watch build succeeded:', result); 36 | }, 37 | }; 38 | } 39 | 40 | esbuild 41 | .build(baseConfig) 42 | .then(_ => { 43 | if (argv.watch) { 44 | console.log('watching...'); 45 | } 46 | }) 47 | // eslint-disable-next-line unicorn/prefer-top-level-await 48 | .catch(() => process.exit(1)); 49 | -------------------------------------------------------------------------------- /packages/bob/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bob", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": {}, 7 | "bin": { 8 | "bob": "index.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/rkotze/git-mob.git" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "bugs": { 18 | "url": "https://github.com/rkotze/git-mob/issues" 19 | }, 20 | "homepage": "https://github.com/rkotze/git-mob#readme", 21 | "dependencies": { 22 | "esbuild": "^0.25.5", 23 | "glob": "^11.0.2", 24 | "minimist": "^1.2.8" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/git-mob-core/.npmignore: -------------------------------------------------------------------------------- 1 | **/*.spec.js 2 | **/*.spec.ts 3 | **/*.tgz 4 | test-helpers/ 5 | node_modules/ 6 | .prettierrc 7 | .prettierignore 8 | tsconfig.* 9 | jest.config.json 10 | src 11 | !dist -------------------------------------------------------------------------------- /packages/git-mob-core/README.md: -------------------------------------------------------------------------------- 1 | # Git Mob core 2 | 3 | > Beta 4 | 5 | The core API for managing Git Mob co-authors. 6 | 7 | Shared between Git Mob CLI and Git Mob VS code. 8 | 9 | ``` 10 | npm i git-mob-core 11 | ``` 12 | 13 | ## API 14 | 15 | ### Environment variables 16 | 17 | - `process.env.GITMOB_MESSAGE_PATH` set the primary path to Git message template 18 | - `process.env.GITMOB_COAUTHORS_PATH` set the primary path to coauthors file 19 | 20 | ```TS 21 | // Write actions 22 | saveNewCoAuthors(authors: Author[]): > 23 | createCoAuthorsFile(authors: Author[]): > 24 | updateGitTemplate(selectedAuthors?: Author[]): void 25 | solo(): > 26 | setCoAuthors(keys: string[]): > 27 | messageFormatter(txt: string, authors: Author[]): string 28 | setPrimaryAuthor(author: Author): void 29 | 30 | // Read actions 31 | getAllAuthors(): > 32 | getPrimaryAuthor(): > 33 | getSelectedCoAuthors(allAuthors): > 34 | repoAuthorList(authorFilter?: string): Promise 35 | pathToCoAuthors(): > 36 | 37 | // GitHub 38 | fetchGitHubAuthors(userNames: string[], userAgent: string): > 39 | searchGitHubAuthors(query: string, userAgent: string): > 40 | 41 | gitRevParse = { 42 | insideWorkTree(): >, 43 | topLevelDirectory(): >, 44 | }; 45 | ``` 46 | 47 | ### Config 48 | 49 | ```TS 50 | // Config manager for library 51 | // supported prop: "processCwd" = set the directory to exec commands 52 | getConfig(prop: string): string | undefined 53 | updateConfig(prop: string, value: string): void 54 | 55 | // Read GitMob properties from Git config file 56 | gitMobConfig = { 57 | localTemplate(): >, 58 | fetchFromGitHub(): >, 59 | }; 60 | 61 | // Read Git properties from Git config 62 | gitConfig = { 63 | getLocalCommitTemplate(): >, 64 | getGlobalCommitTemplate(): >, 65 | }; 66 | ``` 67 | 68 | ### Author class 69 | 70 | Do not change the structure of the class. 71 | 72 | ```TS 73 | class Author; 74 | 75 | // Properties 76 | Author.key: string 77 | Author.name: string 78 | Author.email: string 79 | Author.trailer: AuthorTrailers // defaults to AuthorTrailers.CoAuthorBy 80 | 81 | //Methods 82 | Author.format(): string 83 | Author.toString(): string 84 | ``` 85 | -------------------------------------------------------------------------------- /packages/git-mob-core/jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "roots": ["/dist"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/git-mob-core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "git-mob-core", 3 | "version": "0.10.1", 4 | "description": "Git Mob Core library to manage co-authoring", 5 | "homepage": "https://github.com/rkotze/git-mob/blob/master/packages/git-mob-core/README.md", 6 | "main": "./dist/index.js", 7 | "types": "./dist/index.d.ts", 8 | "scripts": { 9 | "build": "rimraf dist && tsc --project tsconfig.test.json && bob -c=core", 10 | "pretest": "npm run build -- -t", 11 | "test": "jest", 12 | "minifytest": "npm run build -- -m -t && npm run test", 13 | "prepack": "rimraf dist && tsc --project tsconfig.prod.json && bob -c=core -m", 14 | "checks": "npm run test && npm run lint", 15 | "lint": "xo --cwd=../../", 16 | "preversion": "npm run checks" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git@github.com:rkotze/git-mob.git", 21 | "directory": "packages/git-mob-core" 22 | }, 23 | "engines": { 24 | "node": ">=16" 25 | }, 26 | "keywords": [ 27 | "cli", 28 | "cli-app", 29 | "git-pair", 30 | "git-duet", 31 | "git", 32 | "github", 33 | "co-author", 34 | "pairing", 35 | "pair programming", 36 | "mob programming", 37 | "extreme programming", 38 | "xp", 39 | "social coding" 40 | ], 41 | "author": "Richard Kotze", 42 | "license": "MIT", 43 | "funding":{ 44 | "type": "BuyMeACoffee", 45 | "url": "https://www.buymeacoffee.com/rkotze" 46 | }, 47 | "devDependencies": { 48 | "@jest/globals": "^29.7.0", 49 | "@types/jest": "^29.5.14", 50 | "@types/node": "^22.15.30", 51 | "jest": "^29.7.0", 52 | "jest-config": "^29.7.0", 53 | "rimraf": "^6.0.1" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/config-manager.spec.ts: -------------------------------------------------------------------------------- 1 | import { getConfig, updateConfig } from './config-manager.js'; 2 | 3 | it('change the processCwd config property', () => { 4 | expect(getConfig('processCwd')).toBeUndefined(); 5 | const dir = 'C:/path/dir'; 6 | updateConfig('processCwd', dir); 7 | expect(getConfig('processCwd')).toBe(dir); 8 | }); 9 | 10 | it('throw error for invalid config property', () => { 11 | const dir = 'C:/path/dir'; 12 | 13 | expect(() => { 14 | updateConfig('cwd', dir); 15 | }).toThrow( 16 | expect.objectContaining({ 17 | message: expect.stringMatching( 18 | 'Invalid Git Mob Core config property "cwd"' 19 | ) as string, 20 | }) as Error 21 | ); 22 | }); 23 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/config-manager.ts: -------------------------------------------------------------------------------- 1 | const config: Record = { 2 | processCwd: undefined, 3 | }; 4 | 5 | export function getConfig(prop: string) { 6 | return config[prop]; 7 | } 8 | 9 | export function updateConfig(prop: string, value: string) { 10 | if (prop in config) { 11 | config[prop] = value; 12 | } else { 13 | throw new Error(`Invalid Git Mob Core config property "${prop}"`); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/author.ts: -------------------------------------------------------------------------------- 1 | import { AuthorTrailers } from './git-message/message-formatter'; 2 | 3 | export class Author { 4 | key: string; 5 | name: string; 6 | email: string; 7 | trailer: AuthorTrailers; 8 | 9 | constructor( 10 | key: string, 11 | name: string, 12 | email: string, 13 | trailer: AuthorTrailers = AuthorTrailers.CoAuthorBy 14 | ) { 15 | this.key = key; 16 | this.name = name; 17 | this.email = email; 18 | this.trailer = trailer; 19 | } 20 | 21 | format() { 22 | return `${this.trailer} ${this.toString()}`; 23 | } 24 | 25 | toString() { 26 | return `${this.name} <${this.email}>`; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/errors/author-not-found.ts: -------------------------------------------------------------------------------- 1 | export class AuthorNotFound extends Error { 2 | constructor(initials: string) { 3 | super(`Author with initials "${initials}" not found!`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/exec-command.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'node:child_process'; 2 | import { promisify } from 'node:util'; 3 | import { getConfig as cmGetConfig } from '../config-manager.js'; 4 | 5 | type ExecCommandOptions = { 6 | encoding: string; 7 | cwd?: string; 8 | }; 9 | 10 | // Runs the given command in a shell. 11 | export async function execCommand(command: string): Promise { 12 | const cmdConfig: ExecCommandOptions = { encoding: 'utf8' }; 13 | const processCwd = cmGetConfig('processCwd'); 14 | if (processCwd) cmdConfig.cwd = processCwd; 15 | const execAsync = promisify(exec); 16 | const { stderr, stdout } = await execAsync(command, cmdConfig); 17 | 18 | if (stderr) { 19 | throw new Error(`Git mob core execCommand: "${command}" ${stderr.trim()}`); 20 | } 21 | 22 | return stdout.trim(); 23 | } 24 | 25 | export async function getConfig(key: string) { 26 | try { 27 | return await execCommand(`git config --get ${key}`); 28 | } catch { 29 | return undefined; 30 | } 31 | } 32 | 33 | export async function getAllConfig(key: string) { 34 | try { 35 | return await execCommand(`git config --get-all ${key}`); 36 | } catch { 37 | return undefined; 38 | } 39 | } 40 | 41 | export async function setConfig(key: string, value: string) { 42 | try { 43 | await execCommand(`git config ${key} "${value}"`); 44 | } catch { 45 | const message = `Option ${key} has multiple values. Cannot overwrite multiple values for option ${key} with a single value.`; 46 | throw new Error(`Git mob core setConfig: ${message}`); 47 | } 48 | } 49 | 50 | export async function getRepoAuthors(authorFilter?: string) { 51 | let repoAuthorQuery = 'git shortlog -seni HEAD'; 52 | if (authorFilter) { 53 | repoAuthorQuery += ` --author="${authorFilter}"`; 54 | } 55 | 56 | return execCommand(repoAuthorQuery); 57 | } 58 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/fetch/http-fetch.ts: -------------------------------------------------------------------------------- 1 | import https from 'node:https'; 2 | 3 | export type BasicResponse = { 4 | statusCode: number | undefined; 5 | data: Record; 6 | }; 7 | 8 | async function httpFetch( 9 | url: string, 10 | options: https.RequestOptions 11 | ): Promise { 12 | return new Promise((resolve, reject) => { 13 | const httpRequest = https 14 | .request(url, options, response => { 15 | let chunkedData = ''; 16 | 17 | response.on('data', (chunk: string) => { 18 | chunkedData += chunk; 19 | }); 20 | 21 | response.on('end', () => { 22 | resolve({ 23 | statusCode: response.statusCode, 24 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 25 | data: JSON.parse(chunkedData), 26 | }); 27 | }); 28 | }) 29 | .on('error', error => { 30 | reject(error); 31 | }); 32 | 33 | httpRequest.end(); 34 | }); 35 | } 36 | 37 | export { httpFetch }; 38 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/git-authors/create-coauthors-file.spec.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'node:fs'; 2 | import { mockGitAuthors as mockGitAuthorsFn } from '../../test-helpers/author-mocks'; 3 | import { Author } from '../author'; 4 | import { createCoAuthorsFile } from './create-coauthors-file'; 5 | import { gitAuthors, globalGitCoAuthorsPath } from '.'; 6 | 7 | jest.mock('node:fs'); 8 | jest.mock('.'); 9 | 10 | const mockExistsSync = jest.mocked(existsSync); 11 | const mockGitAuthors = jest.mocked(gitAuthors); 12 | 13 | test('Throw error if coauthor file exists', async () => { 14 | mockExistsSync.mockReturnValueOnce(true); 15 | 16 | await expect(createCoAuthorsFile()).rejects.toThrow( 17 | expect.objectContaining({ 18 | message: expect.stringMatching( 19 | '.git-coauthors file exists globally' 20 | ) as string, 21 | }) as Error 22 | ); 23 | }); 24 | 25 | test('Save coauthor file in home directory', async () => { 26 | const defaultAuthor = { 27 | pa: { 28 | name: 'Placeholder Author', 29 | email: 'placeholder@author.org', 30 | }, 31 | }; 32 | mockExistsSync.mockReturnValueOnce(false); 33 | const mockGitAuthorsObject = mockGitAuthorsFn(['jo', 'hu']); 34 | mockGitAuthors.mockReturnValue(mockGitAuthorsObject); 35 | 36 | await expect(createCoAuthorsFile()).resolves.toEqual(true); 37 | expect(mockGitAuthorsObject.overwrite).toHaveBeenCalledWith( 38 | { coauthors: defaultAuthor }, 39 | globalGitCoAuthorsPath() 40 | ); 41 | }); 42 | 43 | test('Save coauthor file with defined coauthor list', async () => { 44 | const expectAuthor = { 45 | rk: { 46 | name: 'rich kid', 47 | email: 'richkid@gmail.com', 48 | }, 49 | }; 50 | mockExistsSync.mockReturnValueOnce(false); 51 | const mockGitAuthorsObject = mockGitAuthorsFn(['jo', 'hu']); 52 | mockGitAuthorsObject.toObject.mockReturnValue({ 53 | coauthors: expectAuthor, 54 | }); 55 | mockGitAuthors.mockReturnValue(mockGitAuthorsObject); 56 | 57 | await expect( 58 | createCoAuthorsFile([new Author('rk', 'rich kid', 'richkid@gmail.com')]) 59 | ).resolves.toEqual(true); 60 | 61 | expect(mockGitAuthorsObject.overwrite).toHaveBeenCalledWith( 62 | { 63 | coauthors: expectAuthor, 64 | }, 65 | globalGitCoAuthorsPath() 66 | ); 67 | }); 68 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/git-authors/create-coauthors-file.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'node:fs'; 2 | import { type Author } from '../author'; 3 | import { gitAuthors, gitCoauthorsFileName, globalGitCoAuthorsPath } from './index'; 4 | 5 | const coAuthorSchema = { 6 | coauthors: { 7 | pa: { 8 | name: 'Placeholder Author', 9 | email: 'placeholder@author.org', 10 | }, 11 | }, 12 | }; 13 | 14 | export async function createCoAuthorsFile(authorList?: Author[]): Promise { 15 | const authorOps = gitAuthors(); 16 | const coAuthorFilePath: string = globalGitCoAuthorsPath(); 17 | if (existsSync(coAuthorFilePath)) { 18 | throw new Error(`${gitCoauthorsFileName} file exists globally`); 19 | } 20 | 21 | if (authorList && authorList.length > 0) { 22 | const schema = authorOps.toObject(authorList); 23 | await authorOps.overwrite(schema, coAuthorFilePath); 24 | } else { 25 | await authorOps.overwrite(coAuthorSchema, coAuthorFilePath); 26 | } 27 | 28 | return true; 29 | } 30 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/git-authors/fetch-github-authors.spec.ts: -------------------------------------------------------------------------------- 1 | import { type BasicResponse, httpFetch } from '../fetch/http-fetch'; 2 | import { AuthorTrailers } from '../git-message/message-formatter'; 3 | import { fetchGitHubAuthors, searchGitHubAuthors } from './fetch-github-authors'; 4 | 5 | jest.mock('../fetch/http-fetch'); 6 | const mockedFetch = jest.mocked(httpFetch); 7 | 8 | const ghRkotzeResponse = { 9 | id: 123, 10 | login: 'rkotze', 11 | name: 'Richard Kotze', 12 | stars: 2, 13 | }; 14 | 15 | const ghDidelerResponse = { 16 | id: 345, 17 | login: 'dideler', 18 | name: 'Dennis', 19 | }; 20 | 21 | function buildBasicResponse(ghResponse: Record): BasicResponse { 22 | return { 23 | statusCode: 200, 24 | data: ghResponse, 25 | }; 26 | } 27 | 28 | function buildSearchResponse(ghResponse: { 29 | items: Array>; 30 | }): BasicResponse { 31 | return { 32 | statusCode: 200, 33 | data: ghResponse, 34 | }; 35 | } 36 | 37 | const headers = { 38 | // eslint-disable-next-line @typescript-eslint/naming-convention 39 | Accept: 'application/vnd.github.v3+json', 40 | method: 'GET', 41 | }; 42 | 43 | const agentHeader = 'random-agent'; 44 | 45 | afterEach(() => { 46 | mockedFetch.mockReset(); 47 | }); 48 | 49 | test('Query for one GitHub user and check RESTful url', async () => { 50 | mockedFetch.mockResolvedValue(buildBasicResponse(ghRkotzeResponse)); 51 | 52 | await fetchGitHubAuthors(['rkotze'], agentHeader); 53 | 54 | expect(mockedFetch).toHaveBeenCalledWith('https://api.github.com/users/rkotze', { 55 | headers: { 56 | ...headers, 57 | 'user-agent': agentHeader, 58 | }, 59 | }); 60 | }); 61 | 62 | test('Query for one GitHub user and return in AuthorList', async () => { 63 | mockedFetch.mockResolvedValue(buildBasicResponse(ghDidelerResponse)); 64 | 65 | const actualAuthorList = await fetchGitHubAuthors(['dideler'], agentHeader); 66 | 67 | expect(actualAuthorList).toEqual([ 68 | { 69 | key: 'dideler', 70 | name: 'Dennis', 71 | email: '345+dideler@users.noreply.github.com', 72 | trailer: AuthorTrailers.CoAuthorBy, 73 | }, 74 | ]); 75 | }); 76 | 77 | test('Query for two GitHub users and build AuthorList', async () => { 78 | mockedFetch 79 | .mockResolvedValueOnce(buildBasicResponse(ghDidelerResponse)) 80 | .mockResolvedValueOnce(buildBasicResponse(ghRkotzeResponse)); 81 | 82 | const actualAuthorList = await fetchGitHubAuthors( 83 | ['dideler', 'rkotze'], 84 | agentHeader 85 | ); 86 | 87 | expect(actualAuthorList).toEqual([ 88 | { 89 | key: 'dideler', 90 | name: 'Dennis', 91 | email: '345+dideler@users.noreply.github.com', 92 | trailer: AuthorTrailers.CoAuthorBy, 93 | }, 94 | { 95 | key: 'rkotze', 96 | name: 'Richard Kotze', 97 | email: '123+rkotze@users.noreply.github.com', 98 | trailer: AuthorTrailers.CoAuthorBy, 99 | }, 100 | ]); 101 | }); 102 | 103 | test('Handle GitHub user with no name', async () => { 104 | mockedFetch.mockResolvedValue( 105 | buildBasicResponse({ 106 | id: 329, 107 | name: null, 108 | login: 'kotze', 109 | }) 110 | ); 111 | 112 | const actualAuthorList = await fetchGitHubAuthors(['kotze'], agentHeader); 113 | 114 | expect(actualAuthorList).toEqual([ 115 | { 116 | key: 'kotze', 117 | name: 'kotze', 118 | email: '329+kotze@users.noreply.github.com', 119 | trailer: AuthorTrailers.CoAuthorBy, 120 | }, 121 | ]); 122 | }); 123 | 124 | test('Error if no user agent specified', async () => { 125 | await expect(fetchGitHubAuthors(['badrequestuser'], '')).rejects.toThrow( 126 | /Error no user-agent header string given./ 127 | ); 128 | }); 129 | 130 | test('Http status code 404 throws error', async () => { 131 | mockedFetch.mockResolvedValue({ 132 | statusCode: 404, 133 | data: {}, 134 | }); 135 | 136 | await expect(fetchGitHubAuthors(['notaUser'], agentHeader)).rejects.toThrow( 137 | /GitHub user not found!/ 138 | ); 139 | }); 140 | 141 | test('Http status code not 200 or 404 throws generic error', async () => { 142 | mockedFetch.mockResolvedValue({ 143 | statusCode: 500, 144 | data: {}, 145 | }); 146 | 147 | await expect(fetchGitHubAuthors(['badrequestuser'], agentHeader)).rejects.toThrow( 148 | /Error failed to fetch GitHub user! Status code 500./ 149 | ); 150 | }); 151 | 152 | test('Search for users by name', async () => { 153 | mockedFetch 154 | .mockResolvedValueOnce( 155 | buildSearchResponse({ items: [ghDidelerResponse, ghRkotzeResponse] }) 156 | ) 157 | .mockResolvedValueOnce(buildBasicResponse(ghDidelerResponse)) 158 | .mockResolvedValueOnce(buildBasicResponse(ghRkotzeResponse)); 159 | 160 | const actualAuthorList = await searchGitHubAuthors('kotze', agentHeader); 161 | expect(mockedFetch).toHaveBeenCalledTimes(3); 162 | expect(actualAuthorList).toEqual([ 163 | { 164 | key: 'dideler', 165 | name: 'Dennis', 166 | email: '345+dideler@users.noreply.github.com', 167 | trailer: AuthorTrailers.CoAuthorBy, 168 | }, 169 | { 170 | key: 'rkotze', 171 | name: 'Richard Kotze', 172 | email: '123+rkotze@users.noreply.github.com', 173 | trailer: AuthorTrailers.CoAuthorBy, 174 | }, 175 | ]); 176 | }); 177 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/git-authors/fetch-github-authors.ts: -------------------------------------------------------------------------------- 1 | import type { RequestOptions } from 'node:https'; 2 | import { Author } from '../author.js'; 3 | import { httpFetch } from '../fetch/http-fetch.js'; 4 | 5 | const gitHubUserUrl = 'https://api.github.com/users'; 6 | const gitHubSearchUserUrl = 'https://api.github.com/search/users'; 7 | const getHeaders: RequestOptions = { 8 | headers: { 9 | // eslint-disable-next-line @typescript-eslint/naming-convention 10 | Accept: 'application/vnd.github.v3+json', 11 | method: 'GET', 12 | }, 13 | }; 14 | 15 | type GitHubUser = { 16 | id: number; 17 | login: string; 18 | name?: string; 19 | }; 20 | 21 | type GitHubSearchUser = { 22 | items: GitHubUser[]; 23 | }; 24 | 25 | function validateGhUser(o: any): o is GitHubUser { 26 | return 'id' in o && 'login' in o && 'name' in o; 27 | } 28 | 29 | async function fetchGitHubAuthors( 30 | usernames: string[], 31 | userAgentHeader: string, 32 | fetch = httpFetch 33 | ): Promise { 34 | if (!userAgentHeader) { 35 | throw new Error('Error no user-agent header string given.'); 36 | } 37 | 38 | getHeaders.headers = { 39 | ...getHeaders.headers, 40 | 'user-agent': userAgentHeader, 41 | }; 42 | 43 | const ghUsers = await Promise.all( 44 | usernames.map(async usernames => 45 | fetch(gitHubUserUrl + '/' + usernames, getHeaders) 46 | ) 47 | ); 48 | 49 | const authorAuthorList: Author[] = []; 50 | 51 | for (const ghUser of ghUsers) { 52 | throwStatusCodeErrors(ghUser.statusCode); 53 | 54 | if (validateGhUser(ghUser.data)) { 55 | const { login, id, name } = ghUser.data; 56 | authorAuthorList.push( 57 | new Author(login, name || login, `${id}+${login}@users.noreply.github.com`) 58 | ); 59 | } 60 | } 61 | 62 | return authorAuthorList; 63 | } 64 | 65 | async function searchGitHubAuthors( 66 | query: string, 67 | userAgentHeader: string, 68 | fetch = httpFetch 69 | ): Promise { 70 | if (!userAgentHeader) { 71 | throw new Error('Error no user-agent header string given.'); 72 | } 73 | 74 | getHeaders.headers = { 75 | ...getHeaders.headers, 76 | 'user-agent': userAgentHeader, 77 | }; 78 | 79 | const ghSearchUser = await fetch(gitHubSearchUserUrl + '?q=' + query, getHeaders); 80 | throwStatusCodeErrors(ghSearchUser.statusCode); 81 | 82 | const results = ghSearchUser.data as GitHubSearchUser; 83 | 84 | const gitHubUsernames = results.items.map(ghUser => ghUser.login); 85 | 86 | return fetchGitHubAuthors(gitHubUsernames, userAgentHeader, fetch); 87 | } 88 | 89 | function throwStatusCodeErrors(statusCode: number | undefined) { 90 | if (statusCode === 404) { 91 | throw new Error('GitHub user not found!'); 92 | } 93 | 94 | if (statusCode && statusCode > 299) { 95 | throw new Error(`Error failed to fetch GitHub user! Status code ${statusCode}.`); 96 | } 97 | } 98 | 99 | export { fetchGitHubAuthors, searchGitHubAuthors }; 100 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/git-authors/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'node:path'; 2 | import fs from 'node:fs'; 3 | import { Author } from '../author'; 4 | import { topLevelDirectory } from '../git-rev-parse'; 5 | import { type CoAuthorSchema, gitAuthors, pathToCoAuthors } from './index'; 6 | 7 | jest.mock('../git-rev-parse'); 8 | const mockedTopLevelDirectory = jest.mocked(topLevelDirectory); 9 | 10 | const validJsonString = ` 11 | { 12 | "coauthors": { 13 | "jd": { 14 | "name": "Jane Doe", 15 | "email": "jane@findmypast.com" 16 | }, 17 | "fb": { 18 | "name": "Frances Bar", 19 | "email": "frances-bar@findmypast.com" 20 | } 21 | } 22 | }`; 23 | 24 | const authorsJson: CoAuthorSchema = { 25 | coauthors: { 26 | jd: { 27 | name: 'Jane Doe', 28 | email: 'jane@findmypast.com', 29 | }, 30 | fb: { 31 | name: 'Frances Bar', 32 | email: 'frances-bar@findmypast.com', 33 | }, 34 | }, 35 | }; 36 | 37 | beforeAll(() => { 38 | mockedTopLevelDirectory.mockResolvedValue('./path'); 39 | }); 40 | 41 | test('.git-coauthors file does not exist', async () => { 42 | const authors = gitAuthors(async () => { 43 | throw new Error('enoent: no such file or directory, open'); 44 | }); 45 | await expect(authors.read()).rejects.toEqual( 46 | expect.objectContaining({ 47 | message: expect.stringMatching( 48 | /enoent: no such file or directory, open/i 49 | ) as string, 50 | }) 51 | ); 52 | }); 53 | 54 | test('.git-coauthors by default is in the home directory', async () => { 55 | expect(await pathToCoAuthors()).toEqual( 56 | join(process.env.HOME || '', '.git-coauthors') 57 | ); 58 | }); 59 | 60 | test('Not running in Git repo should return home directory path', async () => { 61 | mockedTopLevelDirectory.mockImplementationOnce(() => { 62 | throw new Error('Fake error'); 63 | }); 64 | 65 | expect(await pathToCoAuthors()).toEqual( 66 | join(process.env.HOME || '', '.git-coauthors') 67 | ); 68 | }); 69 | 70 | test('.git-coauthors can be overwritten by a repo file', async () => { 71 | const mockedPath = './path/to'; 72 | jest.spyOn(fs, 'existsSync').mockReturnValueOnce(true); 73 | mockedTopLevelDirectory.mockResolvedValueOnce(mockedPath); 74 | expect(await pathToCoAuthors()).toEqual(join(mockedPath, '.git-coauthors')); 75 | }); 76 | 77 | test('.git-coauthors can be overwritten by the env var', async () => { 78 | const oldEnv = process.env.GITMOB_COAUTHORS_PATH; 79 | try { 80 | process.env.GITMOB_COAUTHORS_PATH = '~/Env/Path/.git-co-authors'; 81 | expect(await pathToCoAuthors()).toEqual('~/Env/Path/.git-co-authors'); 82 | } finally { 83 | process.env.GITMOB_COAUTHORS_PATH = oldEnv; 84 | } 85 | }); 86 | 87 | test('read contents from .git-coauthors', async () => { 88 | const authors = gitAuthors(async () => validJsonString); 89 | 90 | const json = (await authors.read()) as unknown; 91 | expect(json).toEqual(authorsJson); 92 | }); 93 | 94 | test('convert .git-coauthors json into array of Authors', async () => { 95 | const authors = gitAuthors(async () => validJsonString); 96 | 97 | const json = await authors.read(); 98 | const authorList = authors.toList(json); 99 | const expectAuthorList = [ 100 | new Author('jd', 'Jane Doe', 'jane@findmypast.com'), 101 | new Author('fb', 'Frances Bar', 'frances-bar@findmypast.com'), 102 | ]; 103 | expect(expectAuthorList).toEqual(authorList); 104 | }); 105 | 106 | test('convert an array of authors into .git-coauthors json schema', async () => { 107 | const authors = gitAuthors(async () => validJsonString); 108 | 109 | const authorList = [ 110 | new Author('jd', 'Jane Doe', 'jane@findmypast.com'), 111 | new Author('fb', 'Frances Bar', 'frances-bar@findmypast.com'), 112 | ]; 113 | const authorSchema = authors.toObject(authorList); 114 | expect(authorsJson).toEqual(authorSchema); 115 | }); 116 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/git-authors/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import os from 'node:os'; 3 | import path from 'node:path'; 4 | import { promisify } from 'node:util'; 5 | import { Author } from '../author.js'; 6 | import { topLevelDirectory } from '../git-rev-parse.js'; 7 | 8 | export type CoAuthorSchema = { 9 | coauthors: Record; 10 | }; 11 | 12 | export function gitAuthors( 13 | readFilePromise?: () => Promise, 14 | overwriteFilePromise?: () => Promise 15 | ) { 16 | return { 17 | read: async (path?: string) => { 18 | const readPromise = readFilePromise || promisify(fs.readFile); 19 | const authorJsonString = (await readPromise( 20 | path || (await pathToCoAuthors()) 21 | )) as string; 22 | return JSON.parse(authorJsonString) as CoAuthorSchema; 23 | }, 24 | 25 | overwrite: async (authorJson: CoAuthorSchema, path?: string) => { 26 | const overwritePromise = overwriteFilePromise || promisify(fs.writeFile); 27 | return overwritePromise( 28 | path || (await pathToCoAuthors()), 29 | JSON.stringify(authorJson, null, 2) 30 | ); 31 | }, 32 | 33 | toList(authors: CoAuthorSchema) { 34 | const entries = Object.entries(authors.coauthors); 35 | return entries.map(([key, { name, email }]) => new Author(key, name, email)); 36 | }, 37 | 38 | toObject(authorList: Author[]): CoAuthorSchema { 39 | const authorObject: CoAuthorSchema = { 40 | coauthors: {}, 41 | }; 42 | 43 | for (const author of authorList) { 44 | authorObject.coauthors[author.key] = { 45 | name: author.name, 46 | email: author.email, 47 | }; 48 | } 49 | 50 | return authorObject; 51 | }, 52 | }; 53 | } 54 | 55 | export const gitCoauthorsFileName = '.git-coauthors'; 56 | 57 | export function globalGitCoAuthorsPath() { 58 | if (process.env.GITMOB_COAUTHORS_PATH) { 59 | return process.env.GITMOB_COAUTHORS_PATH; 60 | } 61 | 62 | return path.join(os.homedir(), gitCoauthorsFileName); 63 | } 64 | 65 | export async function pathToCoAuthors(): Promise { 66 | if (process.env.GITMOB_COAUTHORS_PATH) { 67 | return process.env.GITMOB_COAUTHORS_PATH; 68 | } 69 | 70 | let repoAuthorsFile = null; 71 | 72 | try { 73 | repoAuthorsFile = path.join(await topLevelDirectory(), gitCoauthorsFileName); 74 | } catch { 75 | repoAuthorsFile = ''; 76 | } 77 | 78 | return fs.existsSync(repoAuthorsFile) ? repoAuthorsFile : globalGitCoAuthorsPath(); 79 | } 80 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/git-authors/repo-author-list.spec.ts: -------------------------------------------------------------------------------- 1 | import os from 'node:os'; 2 | import { getRepoAuthors } from '../exec-command'; 3 | import { Author } from '../author'; 4 | import { repoAuthorList } from './repo-author-list'; 5 | 6 | jest.mock('../exec-command'); 7 | const mockedGetRepoAuthors = jest.mocked(getRepoAuthors); 8 | 9 | describe('Extract repository authors', function () { 10 | it('Given a list of authors extract the name and email', async function () { 11 | mockedGetRepoAuthors.mockResolvedValueOnce( 12 | ` 33\tRichard Kotze ${os.EOL} 53\tTony Stark ` 13 | ); 14 | const listOfAuthors = await repoAuthorList(); 15 | expect(listOfAuthors).toEqual([ 16 | new Author('rkrk', 'Richard Kotze', 'rkotze@email.com'), 17 | new Author('tsto', 'Tony Stark', 'tony@stark.com'), 18 | ]); 19 | }); 20 | 21 | it('author has one name', async function () { 22 | mockedGetRepoAuthors.mockResolvedValueOnce( 23 | ` 33\tRichard ${os.EOL} 53\tTony Stark ` 24 | ); 25 | const listOfAuthors = await repoAuthorList(); 26 | expect(listOfAuthors).toEqual([ 27 | new Author('rrk', 'Richard', 'rkotze@email.com'), 28 | new Author('tsto', 'Tony Stark', 'tony@stark.com'), 29 | ]); 30 | }); 31 | 32 | it('author uses a private GitHub email', async function () { 33 | mockedGetRepoAuthors.mockResolvedValueOnce( 34 | ` 33\tRichard ${os.EOL} 53\tTony Stark <20342323+tony[bot]@users.noreply.github.com>` 35 | ); 36 | const listOfAuthors = await repoAuthorList(); 37 | expect(listOfAuthors).toEqual([ 38 | new Author('rrk', 'Richard', 'rkotze@email.com'), 39 | new Author( 40 | 'ts20', 41 | 'Tony Stark', 42 | '20342323+tony[bot]@users.noreply.github.com' 43 | ), 44 | ]); 45 | }); 46 | 47 | it('only one author on repository', async function () { 48 | mockedGetRepoAuthors.mockResolvedValueOnce( 49 | ` 33\tRichard Kotze ` 50 | ); 51 | const listOfAuthors = await repoAuthorList(); 52 | expect(listOfAuthors).toEqual([ 53 | new Author('rkrk', 'Richard Kotze', 'rkotze@email.com'), 54 | ]); 55 | }); 56 | 57 | it('author has special characters in name', async function () { 58 | mockedGetRepoAuthors.mockResolvedValueOnce( 59 | ` 33\tRic<8D>rd Kotze ` 60 | ); 61 | const listOfAuthors = await repoAuthorList(); 62 | expect(listOfAuthors).toEqual([ 63 | new Author('rkrk', 'Ric<8D>rd Kotze', 'rkotze@email.com'), 64 | ]); 65 | }); 66 | 67 | it('exclude if fails to match author pattern in list', async function () { 68 | mockedGetRepoAuthors.mockResolvedValueOnce( 69 | ` 33\tRichard Kotze { 8 | const repoAuthorsString = await getRepoAuthors(authorFilter); 9 | const splitEndOfLine = repoAuthorsString.split(EOL); 10 | const authorList = splitEndOfLine 11 | .map(createRepoAuthor) 12 | .filter(author => author !== undefined); 13 | 14 | if (authorList.length > 0) return authorList; 15 | } 16 | 17 | function createRepoAuthor(authorString: string) { 18 | const regexList = /\d+\t(.+)\s<(.+)>/; 19 | const authorArray = regexList.exec(authorString); 20 | if (authorArray !== null) { 21 | const [, name, email] = authorArray; 22 | return new Author(genKey(name, email), name, email); 23 | } 24 | } 25 | 26 | function genKey(name: string, email: string) { 27 | const nameInitials = name 28 | .toLowerCase() 29 | .split(' ') 30 | .reduce(function (acc, cur) { 31 | return acc + cur[0]; 32 | }, ''); 33 | 34 | const domainFirstTwoLetters = email.slice(0, 2); 35 | return nameInitials + domainFirstTwoLetters; 36 | } 37 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/git-config.ts: -------------------------------------------------------------------------------- 1 | import { getConfig, setConfig } from './exec-command.js'; 2 | import { resolveGitMessagePath } from './resolve-git-message-path.js'; 3 | 4 | export async function getLocalCommitTemplate() { 5 | return getConfig('--local commit.template'); 6 | } 7 | 8 | export async function getGlobalCommitTemplate() { 9 | return (await getConfig('--global commit.template')) || resolveGitMessagePath(); 10 | } 11 | 12 | export async function getGitUserName() { 13 | return getConfig('user.name'); 14 | } 15 | 16 | export async function getGitUserEmail() { 17 | return getConfig('user.email'); 18 | } 19 | 20 | export async function setGitUserName(name: string) { 21 | return setConfig('user.name', name); 22 | } 23 | 24 | export async function setGitUserEmail(email: string) { 25 | return setConfig('user.email', email); 26 | } 27 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/git-message/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { EOL } from 'node:os'; 2 | import { readFile, writeFile } from 'node:fs/promises'; 3 | import { Author } from '../author.js'; 4 | import { gitMessage } from './index.js'; 5 | 6 | jest.mock('node:fs/promises'); 7 | 8 | test('Append co-authors to .gitmessage append file mock', async () => { 9 | const message = gitMessage('.fake/.gitmessage'); 10 | await message.writeCoAuthors([ 11 | new Author('jd', 'Jane Doe', 'jane@findmypast.com'), 12 | new Author('fb', 'Frances Bar', 'frances-bar@findmypast.com'), 13 | ]); 14 | 15 | expect(readFile).toHaveBeenCalledTimes(1); 16 | expect(writeFile).toHaveBeenCalledTimes(1); 17 | expect(writeFile).toHaveBeenCalledWith( 18 | expect.stringContaining('.gitmessage'), 19 | [ 20 | EOL, 21 | EOL, 22 | 'Co-authored-by: Jane Doe ', 23 | EOL, 24 | 'Co-authored-by: Frances Bar ', 25 | ].join('') 26 | ); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/git-message/index.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from 'node:fs/promises'; 2 | import { type Author } from '../author'; 3 | import { messageFormatter } from './message-formatter'; 4 | 5 | function fileExists(error: NodeJS.ErrnoException) { 6 | return error.code !== 'ENOENT'; 7 | } 8 | 9 | async function append(messagePath: string, newAuthors: Author[]): Promise { 10 | const data = await read(messagePath); 11 | 12 | const result = messageFormatter(data || '', newAuthors); 13 | 14 | await writeFile(messagePath, result); 15 | } 16 | 17 | async function read(messagePath: string) { 18 | try { 19 | return await readFile(messagePath, { encoding: 'utf8' }); 20 | } catch (error: unknown) { 21 | if (error && fileExists(error as Error)) { 22 | throw error as Error; 23 | } 24 | } 25 | } 26 | 27 | function gitMessage( 28 | messagePath: string, 29 | appendFilePromise?: () => Promise, 30 | readFilePromise?: () => Promise 31 | ) { 32 | const appendPromise = appendFilePromise || append; 33 | const readPromise = readFilePromise || read; 34 | 35 | return { 36 | writeCoAuthors: async (coAuthorList: Author[]) => { 37 | await appendPromise(messagePath, coAuthorList); 38 | }, 39 | readCoAuthors: async () => { 40 | return readPromise(messagePath); 41 | }, 42 | removeCoAuthors: async () => { 43 | return appendPromise(messagePath, []); 44 | }, 45 | }; 46 | } 47 | 48 | export { gitMessage }; 49 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/git-message/message-formatter.spec.ts: -------------------------------------------------------------------------------- 1 | import { EOL } from 'node:os'; 2 | import { Author } from '../author'; 3 | import { messageFormatter } from './message-formatter'; 4 | 5 | test('MessageFormatter: No authors to append to git message', () => { 6 | const txt = `git message`; 7 | const message = messageFormatter(txt, []); 8 | 9 | expect(message).toBe(txt); 10 | }); 11 | 12 | test('MessageFormatter: Append co-authors to git message', () => { 13 | const txt = `git message`; 14 | const message = messageFormatter(txt, [ 15 | new Author('jd', 'Jane Doe', 'jane@gitmob.com'), 16 | new Author('fb', 'Frances Bar', 'frances-bar@gitmob.com'), 17 | ]); 18 | 19 | expect(message).toBe( 20 | [ 21 | txt, 22 | EOL, 23 | EOL, 24 | 'Co-authored-by: Jane Doe ', 25 | EOL, 26 | 'Co-authored-by: Frances Bar ', 27 | ].join('') 28 | ); 29 | }); 30 | 31 | test('MessageFormatter: Replace co-author in the git message', () => { 32 | const firstLine = 'git message'; 33 | const txt = [ 34 | firstLine, 35 | EOL, 36 | EOL, 37 | 'Co-authored-by: Jane Doe ', 38 | ].join(''); 39 | const message = messageFormatter(txt, [ 40 | new Author('fb', 'Frances Bar', 'frances-bar@gitmob.com'), 41 | ]); 42 | 43 | expect(message).toBe( 44 | [ 45 | firstLine, 46 | EOL, 47 | EOL, 48 | 'Co-authored-by: Frances Bar ', 49 | ].join('') 50 | ); 51 | }); 52 | 53 | test('MessageFormatter: Replace co-author in the git message with no line break', () => { 54 | const firstLine = 'git message'; 55 | const txt = [firstLine, 'Co-authored-by: Jane Doe '].join(''); 56 | const message = messageFormatter(txt, [ 57 | new Author('fb', 'Frances Bar', 'frances-bar@gitmob.com'), 58 | ]); 59 | 60 | expect(message).toBe( 61 | [ 62 | firstLine, 63 | EOL, 64 | EOL, 65 | 'Co-authored-by: Frances Bar ', 66 | ].join('') 67 | ); 68 | }); 69 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/git-message/message-formatter.ts: -------------------------------------------------------------------------------- 1 | import { EOL } from 'node:os'; 2 | import { type Author } from '../author'; 3 | 4 | export enum AuthorTrailers { 5 | CoAuthorBy = 'Co-authored-by:', 6 | } 7 | 8 | export function messageFormatter(txt: string, authors: Author[]): string { 9 | const trailers = AuthorTrailers.CoAuthorBy; 10 | const regex = new RegExp(`(\r\n|\r|\n){0,2}(${trailers}).*`, 'g'); 11 | const message = txt.replaceAll(regex, ''); 12 | 13 | if (authors && authors.length > 0) { 14 | const authorTrailerTxt = authors.map(author => author.format()).join(EOL); 15 | return [message, EOL, EOL, authorTrailerTxt].join(''); 16 | } 17 | 18 | return message; 19 | } 20 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/git-mob-config.ts: -------------------------------------------------------------------------------- 1 | import { getConfig, getAllConfig, execCommand } from './exec-command.js'; 2 | 3 | export async function localTemplate() { 4 | const localTemplate = await getConfig('--local git-mob-config.use-local-template'); 5 | return localTemplate === 'true'; 6 | } 7 | 8 | export async function fetchFromGitHub() { 9 | const githubFetch = await getConfig('--global git-mob-config.github-fetch'); 10 | return githubFetch === 'true'; 11 | } 12 | 13 | export async function getSetCoAuthors() { 14 | return getAllConfig('--global git-mob.co-author'); 15 | } 16 | 17 | export async function addCoAuthor(coAuthor: string) { 18 | const addAuthorQuery = `git config --add --global git-mob.co-author "${coAuthor}"`; 19 | 20 | return execCommand(addAuthorQuery); 21 | } 22 | 23 | export async function removeGitMobSection() { 24 | try { 25 | return await execCommand('git config --global --remove-section git-mob'); 26 | } catch {} 27 | } 28 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/git-rev-parse.ts: -------------------------------------------------------------------------------- 1 | import { execCommand } from './exec-command.js'; 2 | 3 | async function topLevelDirectory(): Promise { 4 | return execCommand('git rev-parse --show-toplevel'); 5 | } 6 | 7 | async function insideWorkTree(): Promise { 8 | try { 9 | const isGitRepo = await execCommand('git rev-parse --is-inside-work-tree'); 10 | return isGitRepo === 'true'; 11 | } catch { 12 | return false; 13 | } 14 | } 15 | 16 | export { topLevelDirectory, insideWorkTree }; 17 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/manage-authors/add-new-coauthor.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | buildAuthorList, 3 | buildCoAuthorObject, 4 | mockGitAuthors, 5 | } from '../../test-helpers/author-mocks'; 6 | import { gitAuthors } from '../git-authors'; 7 | import { saveNewCoAuthors } from './add-new-coauthor'; 8 | 9 | jest.mock('../git-authors/index.js'); 10 | const mockedGitAuthors = jest.mocked(gitAuthors); 11 | 12 | const coAuthorKeys: string[] = ['joe', 'rich']; 13 | 14 | test('Save multiple new authors', async () => { 15 | const newAuthorKeys = ['fred', 'dim']; 16 | const newAuthors = buildAuthorList(newAuthorKeys); 17 | const mockGitAuthorsObject = mockGitAuthors(coAuthorKeys); 18 | mockedGitAuthors.mockReturnValue(mockGitAuthorsObject); 19 | 20 | const savedAuthors = await saveNewCoAuthors(newAuthors); 21 | expect(mockGitAuthorsObject.overwrite).toHaveBeenCalledTimes(1); 22 | expect(mockGitAuthorsObject.overwrite).toHaveBeenCalledWith( 23 | buildCoAuthorObject([...coAuthorKeys, ...newAuthorKeys]) 24 | ); 25 | expect(savedAuthors).toEqual(newAuthors); 26 | }); 27 | 28 | test('Duplicated error when saving multiple new authors', async () => { 29 | const newAuthorKeys = ['joe', 'dim']; 30 | const newAuthors = buildAuthorList(newAuthorKeys); 31 | const mockGitAuthorsObject = mockGitAuthors(coAuthorKeys); 32 | mockedGitAuthors.mockReturnValue(mockGitAuthorsObject); 33 | 34 | await expect(saveNewCoAuthors(newAuthors)).rejects.toThrow( 35 | expect.objectContaining({ 36 | message: expect.stringMatching( 37 | 'Duplicate key joe exists in .git-coauthors' 38 | ) as string, 39 | }) as Error 40 | ); 41 | expect(mockGitAuthorsObject.overwrite).not.toHaveBeenCalled(); 42 | }); 43 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/manage-authors/add-new-coauthor.ts: -------------------------------------------------------------------------------- 1 | import { Author } from '../author.js'; 2 | import { gitAuthors } from '../git-authors/index.js'; 3 | 4 | export async function saveNewCoAuthors(authors: Author[]): Promise { 5 | if (!Array.isArray(authors)) { 6 | throw new TypeError('saveNewCoAuthors argument should be an Array of Authors'); 7 | } 8 | 9 | const coauthors = gitAuthors(); 10 | const authorList = await coauthors.read(); 11 | const newAuthors = []; 12 | 13 | for (const author of authors) { 14 | const { key, name, email } = author; 15 | if (key in authorList.coauthors) { 16 | throw new Error(`Duplicate key ${key} exists in .git-coauthors`); 17 | } else { 18 | authorList.coauthors[key] = { name, email }; 19 | newAuthors.push(new Author(key, name, email)); 20 | } 21 | } 22 | 23 | await coauthors.overwrite(authorList); 24 | return newAuthors; 25 | } 26 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/git-mob-api/resolve-git-message-path.ts: -------------------------------------------------------------------------------- 1 | import { resolve, join } from 'node:path'; 2 | import { homedir } from 'node:os'; 3 | import { topLevelDirectory } from './git-rev-parse.js'; 4 | import { getConfig, setConfig } from './exec-command.js'; 5 | 6 | async function setCommitTemplate() { 7 | const hasTemplate = await getConfig('commit.template'); 8 | if (!hasTemplate) { 9 | await setConfig('--global commit.template', gitMessagePath()); 10 | } 11 | } 12 | 13 | async function resolveGitMessagePath(templatePath?: string) { 14 | if (process.env.GITMOB_MESSAGE_PATH) { 15 | return resolve(process.env.GITMOB_MESSAGE_PATH); 16 | } 17 | 18 | if (templatePath) return resolve(await topLevelDirectory(), templatePath); 19 | 20 | return resolve(gitMessagePath()); 21 | } 22 | 23 | function gitMessagePath() { 24 | return join(homedir(), '.gitmessage'); 25 | } 26 | 27 | export { resolveGitMessagePath, setCommitTemplate }; 28 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { gitAuthors } from './git-mob-api/git-authors'; 2 | import { gitMessage } from './git-mob-api/git-message'; 3 | import { AuthorNotFound } from './git-mob-api/errors/author-not-found'; 4 | import * as gitConfig from './git-mob-api/git-config'; 5 | import { buildAuthorList, mockGitAuthors } from './test-helpers/author-mocks'; 6 | import { 7 | addCoAuthor, 8 | getSetCoAuthors, 9 | removeGitMobSection, 10 | } from './git-mob-api/git-mob-config'; 11 | import { 12 | getPrimaryAuthor, 13 | getSelectedCoAuthors, 14 | setCoAuthors, 15 | setPrimaryAuthor, 16 | updateGitTemplate, 17 | } from '.'; 18 | 19 | jest.mock('./git-mob-api/exec-command'); 20 | jest.mock('./git-mob-api/git-authors'); 21 | jest.mock('./git-mob-api/git-message'); 22 | jest.mock('./git-mob-api/resolve-git-message-path'); 23 | jest.mock('./git-mob-api/git-config'); 24 | jest.mock('./git-mob-api/git-mob-config'); 25 | 26 | const mockedGitAuthors = jest.mocked(gitAuthors); 27 | const mockedGitMessage = jest.mocked(gitMessage); 28 | const mockedRemoveGitMobSection = jest.mocked(removeGitMobSection); 29 | const mockedGitConfig = jest.mocked(gitConfig); 30 | const mockedGetSetCoAuthors = jest.mocked(getSetCoAuthors); 31 | 32 | describe('Git Mob core API', () => { 33 | afterEach(() => { 34 | mockedRemoveGitMobSection.mockReset(); 35 | mockedGetSetCoAuthors.mockReset(); 36 | mockedGitConfig.getGlobalCommitTemplate.mockReset(); 37 | mockedGitConfig.getLocalCommitTemplate.mockReset(); 38 | }); 39 | 40 | it('missing author to pick for list throws error', async () => { 41 | const authorKeys = ['ab', 'cd']; 42 | const mockWriteCoAuthors = jest.fn(async () => undefined); 43 | const mockRemoveCoAuthors = jest.fn(async () => ''); 44 | mockedGitAuthors.mockReturnValue(mockGitAuthors([...authorKeys, 'ef'])); 45 | 46 | mockedGitMessage.mockReturnValue({ 47 | writeCoAuthors: mockWriteCoAuthors, 48 | readCoAuthors: () => '', 49 | removeCoAuthors: mockRemoveCoAuthors, 50 | }); 51 | 52 | await expect(async () => { 53 | await setCoAuthors([...authorKeys, 'rk']); 54 | }).rejects.toThrow(AuthorNotFound); 55 | 56 | expect(mockedRemoveGitMobSection).not.toHaveBeenCalled(); 57 | }); 58 | 59 | it('apply co-authors to git config and git message', async () => { 60 | const authorKeys = ['ab', 'cd']; 61 | const authorList = buildAuthorList(authorKeys); 62 | const mockWriteCoAuthors = jest.fn(async () => undefined); 63 | const mockRemoveCoAuthors = jest.fn(async () => ''); 64 | mockedGitAuthors.mockReturnValue(mockGitAuthors([...authorKeys, 'ef'])); 65 | 66 | mockedGitMessage.mockReturnValue({ 67 | writeCoAuthors: mockWriteCoAuthors, 68 | readCoAuthors: () => '', 69 | removeCoAuthors: mockRemoveCoAuthors, 70 | }); 71 | 72 | const coAuthors = await setCoAuthors(authorKeys); 73 | 74 | expect(mockedRemoveGitMobSection).toHaveBeenCalledTimes(1); 75 | expect(mockRemoveCoAuthors).toHaveBeenCalledTimes(1); 76 | expect(addCoAuthor).toHaveBeenCalledTimes(2); 77 | expect(mockWriteCoAuthors).toHaveBeenCalledWith(authorList); 78 | expect(coAuthors).toEqual(authorList); 79 | }); 80 | 81 | it('update git template by adding co-authors', async () => { 82 | const authorKeys = ['ab', 'cd']; 83 | const authorList = buildAuthorList(authorKeys); 84 | const mockWriteCoAuthors = jest.fn(); 85 | 86 | mockedGitMessage.mockReturnValue({ 87 | writeCoAuthors: mockWriteCoAuthors, 88 | readCoAuthors: () => '', 89 | removeCoAuthors: jest.fn(async () => ''), 90 | }); 91 | 92 | await updateGitTemplate(authorList); 93 | 94 | expect(mockWriteCoAuthors).toHaveBeenCalledWith(authorList); 95 | }); 96 | 97 | // GitMob is Global by default: https://github.com/rkotze/git-mob/discussions/81 98 | it('using local gitmessage updates local & global gitmessage with co-authors', async () => { 99 | const authorKeys = ['ab', 'cd']; 100 | const authorList = buildAuthorList(authorKeys); 101 | const mockWriteCoAuthors = jest.fn(); 102 | const mockRemoveCoAuthors = jest.fn(); 103 | 104 | mockedGitConfig.getLocalCommitTemplate.mockResolvedValueOnce('template/path'); 105 | mockedGitMessage.mockReturnValue({ 106 | writeCoAuthors: mockWriteCoAuthors, 107 | readCoAuthors: () => '', 108 | removeCoAuthors: mockRemoveCoAuthors, 109 | }); 110 | 111 | await updateGitTemplate(authorList); 112 | 113 | expect(mockedGitConfig.getLocalCommitTemplate).toHaveBeenCalledTimes(1); 114 | expect(mockedGitConfig.getGlobalCommitTemplate).toHaveBeenCalledTimes(1); 115 | expect(mockWriteCoAuthors).toHaveBeenCalledTimes(2); 116 | expect(mockWriteCoAuthors).toHaveBeenCalledWith(authorList); 117 | expect(mockRemoveCoAuthors).not.toHaveBeenCalled(); 118 | }); 119 | 120 | it('update git template by removing all co-authors', async () => { 121 | const mockRemoveCoAuthors = jest.fn(); 122 | const mockWriteCoAuthors = jest.fn(); 123 | 124 | mockedGitMessage.mockReturnValue({ 125 | writeCoAuthors: mockWriteCoAuthors, 126 | readCoAuthors: () => '', 127 | removeCoAuthors: mockRemoveCoAuthors, 128 | }); 129 | 130 | await updateGitTemplate(); 131 | 132 | expect(mockRemoveCoAuthors).toHaveBeenCalledTimes(1); 133 | expect(mockWriteCoAuthors).not.toHaveBeenCalled(); 134 | }); 135 | 136 | it('Get the selected co-authors', async () => { 137 | const listAll = buildAuthorList(['ab', 'cd']); 138 | const selectedAuthor = listAll[1]; 139 | mockedGetSetCoAuthors.mockResolvedValueOnce(selectedAuthor.toString()); 140 | const selected = await getSelectedCoAuthors(listAll); 141 | 142 | expect(mockedGetSetCoAuthors).toHaveBeenCalledTimes(1); 143 | expect(selected).toEqual([selectedAuthor]); 144 | }); 145 | 146 | it('Use exact email for selected co-authors', async () => { 147 | const listAll = buildAuthorList(['ab', 'efcd', 'cd']); 148 | const selectedAuthor = listAll[1]; 149 | mockedGetSetCoAuthors.mockResolvedValueOnce(selectedAuthor.toString()); 150 | const selected = await getSelectedCoAuthors(listAll); 151 | 152 | expect(mockedGetSetCoAuthors).toHaveBeenCalledTimes(1); 153 | expect(selected).toEqual([selectedAuthor]); 154 | }); 155 | 156 | it('Get the Git primary author', async () => { 157 | const primaryAuthor = buildAuthorList(['prime'])[0]; 158 | mockedGitConfig.getGitUserName.mockResolvedValueOnce(primaryAuthor.name); 159 | mockedGitConfig.getGitUserEmail.mockResolvedValueOnce(primaryAuthor.email); 160 | const author = await getPrimaryAuthor(); 161 | expect(mockedGitConfig.getGitUserName).toHaveBeenCalledTimes(1); 162 | expect(mockedGitConfig.getGitUserEmail).toHaveBeenCalledTimes(1); 163 | expect(author).toEqual(primaryAuthor); 164 | }); 165 | 166 | it('Set the Git primary author', async () => { 167 | const primaryAuthor = buildAuthorList(['prime'])[0]; 168 | await setPrimaryAuthor(primaryAuthor); 169 | expect(mockedGitConfig.setGitUserName).toHaveBeenCalledWith(primaryAuthor.name); 170 | expect(mockedGitConfig.setGitUserEmail).toHaveBeenCalledWith( 171 | primaryAuthor.email 172 | ); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Author } from './git-mob-api/author.js'; 2 | import { AuthorNotFound } from './git-mob-api/errors/author-not-found.js'; 3 | import { gitAuthors } from './git-mob-api/git-authors/index.js'; 4 | import { gitMessage } from './git-mob-api/git-message/index.js'; 5 | import { 6 | localTemplate, 7 | fetchFromGitHub, 8 | getSetCoAuthors, 9 | addCoAuthor, 10 | removeGitMobSection, 11 | } from './git-mob-api/git-mob-config.js'; 12 | import { 13 | getLocalCommitTemplate, 14 | getGlobalCommitTemplate, 15 | getGitUserName, 16 | getGitUserEmail, 17 | setGitUserName, 18 | setGitUserEmail, 19 | } from './git-mob-api/git-config.js'; 20 | import { 21 | resolveGitMessagePath, 22 | setCommitTemplate, 23 | } from './git-mob-api/resolve-git-message-path.js'; 24 | import { insideWorkTree, topLevelDirectory } from './git-mob-api/git-rev-parse.js'; 25 | import { getConfig } from './git-mob-api/exec-command.js'; 26 | 27 | async function getAllAuthors() { 28 | const gitMobAuthors = gitAuthors(); 29 | return gitMobAuthors.toList(await gitMobAuthors.read()); 30 | } 31 | 32 | async function setCoAuthors(keys: string[]): Promise { 33 | const selectedAuthors = pickSelectedAuthors(keys, await getAllAuthors()); 34 | await solo(); 35 | 36 | for (const author of selectedAuthors) { 37 | // eslint-disable-next-line no-await-in-loop 38 | await addCoAuthor(author.toString()); 39 | } 40 | 41 | await updateGitTemplate(selectedAuthors); 42 | return selectedAuthors; 43 | } 44 | 45 | async function updateGitTemplate(selectedAuthors?: Author[]) { 46 | const [usingLocal, templatePath] = await Promise.all([ 47 | getLocalCommitTemplate(), 48 | getConfig('commit.template'), 49 | ]); 50 | 51 | const gitTemplate = gitMessage(await resolveGitMessagePath(templatePath)); 52 | 53 | if (selectedAuthors && selectedAuthors.length > 0) { 54 | if (usingLocal) { 55 | await gitMessage(await getGlobalCommitTemplate()).writeCoAuthors( 56 | selectedAuthors 57 | ); 58 | } 59 | 60 | return gitTemplate.writeCoAuthors(selectedAuthors); 61 | } 62 | 63 | if (usingLocal) { 64 | await gitMessage(await getGlobalCommitTemplate()).removeCoAuthors(); 65 | } 66 | 67 | return gitTemplate.removeCoAuthors(); 68 | } 69 | 70 | function pickSelectedAuthors(keys: string[], authorMap: Author[]): Author[] { 71 | const selectedAuthors = []; 72 | for (const key of keys) { 73 | const author = authorMap.find(author => author.key === key); 74 | 75 | if (!author) throw new AuthorNotFound(key); 76 | selectedAuthors.push(author); 77 | } 78 | 79 | return selectedAuthors; 80 | } 81 | 82 | async function getSelectedCoAuthors(allAuthors: Author[]) { 83 | let coAuthorsString = ''; 84 | const coAuthorConfigValue = await getSetCoAuthors(); 85 | if (coAuthorConfigValue) coAuthorsString = coAuthorConfigValue; 86 | 87 | return allAuthors.filter(author => coAuthorsString.includes('<' + author.email)); 88 | } 89 | 90 | async function solo() { 91 | await setCommitTemplate(); 92 | await removeGitMobSection(); 93 | return updateGitTemplate(); 94 | } 95 | 96 | async function getPrimaryAuthor() { 97 | const name = await getGitUserName(); 98 | const email = await getGitUserEmail(); 99 | 100 | if (name && email) { 101 | return new Author('prime', name, email); 102 | } 103 | } 104 | 105 | async function setPrimaryAuthor(author: Author) { 106 | await setGitUserName(author.name); 107 | await setGitUserEmail(author.email); 108 | } 109 | 110 | export { 111 | getAllAuthors, 112 | getPrimaryAuthor, 113 | getSelectedCoAuthors, 114 | setCoAuthors, 115 | setPrimaryAuthor, 116 | solo, 117 | updateGitTemplate, 118 | }; 119 | 120 | export const gitMobConfig = { 121 | localTemplate, 122 | fetchFromGitHub, 123 | getSetCoAuthors, 124 | }; 125 | 126 | export const gitConfig = { 127 | getLocalCommitTemplate, 128 | getGlobalCommitTemplate, 129 | }; 130 | 131 | export const gitRevParse = { 132 | insideWorkTree, 133 | topLevelDirectory, 134 | }; 135 | 136 | export { saveNewCoAuthors } from './git-mob-api/manage-authors/add-new-coauthor.js'; 137 | export { createCoAuthorsFile } from './git-mob-api/git-authors/create-coauthors-file.js'; 138 | export { repoAuthorList } from './git-mob-api/git-authors/repo-author-list.js'; 139 | export { pathToCoAuthors } from './git-mob-api/git-authors/index.js'; 140 | export { 141 | fetchGitHubAuthors, 142 | searchGitHubAuthors, 143 | } from './git-mob-api/git-authors/fetch-github-authors.js'; 144 | export { getConfig, updateConfig } from './config-manager.js'; 145 | export { Author } from './git-mob-api/author.js'; 146 | export { messageFormatter } from './git-mob-api/git-message/message-formatter.js'; 147 | -------------------------------------------------------------------------------- /packages/git-mob-core/src/test-helpers/author-mocks.ts: -------------------------------------------------------------------------------- 1 | import { Author } from '../git-mob-api/author'; 2 | 3 | export function buildAuthorList(keys: string[]): Author[] { 4 | return keys.map(key => new Author(key, key + ' lastName', key + '@email.com')); 5 | } 6 | 7 | export function buildCoAuthorObject(keys: string[]) { 8 | const authorList = buildAuthorList(keys); 9 | const coAuthorList: Record< 10 | string, 11 | Record 12 | > = { coauthors: {} }; 13 | 14 | for (const author of authorList) { 15 | coAuthorList.coauthors[author.key] = { name: author.name, email: author.email }; 16 | } 17 | 18 | return coAuthorList; 19 | } 20 | 21 | export function mockGitAuthors(keys: string[]) { 22 | const authors = buildAuthorList(keys); 23 | const coAuthors = buildCoAuthorObject(keys); 24 | return { 25 | read: jest.fn(async () => coAuthors), 26 | overwrite: jest.fn(async () => ''), 27 | toList: jest.fn(() => authors), 28 | toObject: jest.fn(() => ({ coauthors: {} })), 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /packages/git-mob-core/tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2017" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | "lib": [ 16 | "ES2021" 17 | ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, 18 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 19 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 20 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 21 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 22 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 23 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 24 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 25 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 26 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 27 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 28 | 29 | /* Modules */ 30 | "module": "NodeNext" /* Specify what module code is generated. */, 31 | // "rootDir": "src" /* Specify the root folder within your source files. */, 32 | // "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 33 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 34 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 35 | "rootDirs": [ 36 | "src" 37 | ] /* Allow multiple folders to be treated as one when resolving modules. */, 38 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 39 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 40 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 41 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 42 | // "resolveJsonModule": true, /* Enable importing .json files. */ 43 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 44 | 45 | /* JavaScript Support */ 46 | "allowJs": true /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */, 47 | // "checkJs": false /* Enable error reporting in type-checked JavaScript files. */, 48 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 49 | 50 | /* Emit */ 51 | "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, 52 | "declarationMap": false /* Create sourcemaps for d.ts files. */, 53 | "emitDeclarationOnly": true /* Only output d.ts files and not JavaScript files. */, 54 | // "sourceMap": true /* Create source map files for emitted JavaScript files. */, 55 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 56 | "outDir": "dist" /* Specify an output folder for all emitted files. */, 57 | // "removeComments": true, /* Disable emitting comments. */ 58 | // "noEmit": true /* Disable emitting files from a compilation. */, 59 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 60 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 61 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 62 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 63 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 64 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 65 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 66 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 67 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 68 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 69 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 70 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 71 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 72 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 73 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 74 | 75 | /* Interop Constraints */ 76 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 77 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 78 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 79 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 80 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 81 | 82 | /* Type Checking */ 83 | "strict": true /* Enable all strict type-checking options. */ 84 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 85 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 86 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 87 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 88 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 89 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 90 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 91 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 92 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 93 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 94 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 95 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 96 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 97 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 98 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 99 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 100 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 101 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 102 | 103 | /* Completeness */ 104 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 105 | // "skipLibCheck": true /* Skip type checking all .d.ts files. */ 106 | }, 107 | "include": ["src/**/*.ts"], 108 | "exclude": ["src/**/*.spec.ts"] 109 | } 110 | -------------------------------------------------------------------------------- /packages/git-mob-core/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2017" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | "lib": [ 16 | "ES2021" 17 | ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, 18 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 19 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 20 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 21 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 22 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 23 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 24 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 25 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 26 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 27 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 28 | 29 | /* Modules */ 30 | "module": "NodeNext" /* Specify what module code is generated. */, 31 | // "rootDir": "src" /* Specify the root folder within your source files. */, 32 | // "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 33 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 34 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 35 | "rootDirs": [ 36 | "src" 37 | ] /* Allow multiple folders to be treated as one when resolving modules. */, 38 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 39 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 40 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 41 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 42 | // "resolveJsonModule": true, /* Enable importing .json files. */ 43 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 44 | 45 | /* JavaScript Support */ 46 | "allowJs": true /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */, 47 | // "checkJs": false /* Enable error reporting in type-checked JavaScript files. */, 48 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 49 | 50 | /* Emit */ 51 | "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, 52 | "declarationMap": true /* Create sourcemaps for d.ts files. */, 53 | "emitDeclarationOnly": true /* Only output d.ts files and not JavaScript files. */, 54 | // "sourceMap": true /* Create source map files for emitted JavaScript files. */, 55 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 56 | "outDir": "dist" /* Specify an output folder for all emitted files. */, 57 | // "removeComments": true, /* Disable emitting comments. */ 58 | // "noEmit": true /* Disable emitting files from a compilation. */, 59 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 60 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 61 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 62 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 63 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 64 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 65 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 66 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 67 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 68 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 69 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 70 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 71 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 72 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 73 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 74 | 75 | /* Interop Constraints */ 76 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 77 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 78 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 79 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 80 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 81 | 82 | /* Type Checking */ 83 | "strict": true /* Enable all strict type-checking options. */ 84 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 85 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 86 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 87 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 88 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 89 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 90 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 91 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 92 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 93 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 94 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 95 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 96 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 97 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 98 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 99 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 100 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 101 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 102 | 103 | /* Completeness */ 104 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 105 | // "skipLibCheck": true /* Skip type checking all .d.ts files. */ 106 | }, 107 | "include": ["src/**/*.ts"], 108 | "exclude": ["src/**/*.spec.ts"] 109 | } 110 | -------------------------------------------------------------------------------- /packages/git-mob/.npmignore: -------------------------------------------------------------------------------- 1 | **/*.spec.js 2 | **/*.spec.ts 3 | **/*.tgz 4 | src 5 | hook-examples 6 | test-helpers/ 7 | node_modules/ 8 | .prettierrc 9 | .prettierignore 10 | .travis.yml 11 | .vscode 12 | .idea 13 | .yarn 14 | .github 15 | tsconfig.json 16 | .pre-commit-hooks.yaml 17 | build 18 | !dist -------------------------------------------------------------------------------- /packages/git-mob/README.md: -------------------------------------------------------------------------------- 1 | # Git Mob - Co-author commits 2 | 3 | ![npm downloads](https://img.shields.io/npm/dm/git-mob.svg) [![npm version](https://badge.fury.io/js/git-mob.svg)](https://www.npmjs.com/package/git-mob) 4 | 5 | > A command-line tool for social coding 6 | 7 | _Add co-authors to commits_ when you collaborate on code. Use when pairing with a buddy or mobbing with your team. 8 | 9 | Buy Me A Coffee 10 | 11 | [✨ Git Mob VS Code extension](https://github.com/rkotze/git-mob-vs-code) 12 | 13 | ![gif showing example usage of git-mob](https://user-images.githubusercontent.com/497458/38682926-2e0cc99c-3e64-11e8-9f71-6336e111005b.gif) 14 | 15 | - [Git Mob - Co-author commits](#git-mob---co-author-commits) 16 | - [Install](#install) 17 | - [Workflow / Usage](#workflow--usage) 18 | - [Add co-author from GitHub](#add-co-author-from-github) 19 | - [Custom setup](#custom-setup) 20 | - [Using `git commit -m` setup](#using-git-commit--m-setup) 21 | - [Using pre-commit to install](#using-pre-commit-to-install) 22 | - [Revert back to default setup](#revert-back-to-default-setup) 23 | - [Git Mob config](#git-mob-config) 24 | - [Use local commit template](#use-local-commit-template) 25 | - [Enable GitHub author fetch](#enable-github-author-fetch) 26 | - [More commands](#more-commands) 27 | - [List all co-authors](#list-all-co-authors) 28 | - [Overwrite the main author](#overwrite-the-main-author) 29 | - [Add co-author](#add-co-author) 30 | - [Suggest co-authors](#suggest-co-authors) 31 | - [Path to .git-coauthors](#path-to-git-coauthors) 32 | - [Help](#help) 33 | - [Add initials of current mob to your prompt](#add-initials-of-current-mob-to-your-prompt) 34 | - [Bash](#bash) 35 | - [Fish](#fish) 36 | - [More info](#more-info) 37 | 38 | ## Install 39 | 40 | git-mob is a CLI tool, so you'll need to install the package globally. 41 | 42 | ``` 43 | npm i -g git-mob 44 | ``` 45 | 46 | MacOS use Homebrew 47 | 48 | ``` 49 | brew install git-mob 50 | ``` 51 | 52 | By default git-mob will use the **global** config `.gitmessage` template to append co-authors. 53 | 54 | ## Workflow / Usage 55 | 56 | With git-mob, the primary author will always be the primary user of the computer. 57 | Set your author info in git if you haven't done so before. 58 | 59 | ``` 60 | $ git config --global user.name "Jane Doe" 61 | $ git config --global user.email "jane@example.com" 62 | ``` 63 | 64 | To keep track of co-authors git-mob uses a JSON file called `.git-coauthors`, and will try to find it in the following directories: 65 | 66 | 1. If `GITMOB_COAUTHORS_PATH` environment variable is set this will override any other settings. 67 | 2. If the current Git repository has a `.git-coauthors` file in the root directory. 68 | 3. The default is the users home directory at `~/.git-coauthors`. 69 | 70 | Here's a template of its structure: 71 | 72 | ```json 73 | { 74 | "coauthors": { 75 | "": { 76 | "name": "", 77 | "email": "" 78 | } 79 | } 80 | } 81 | ``` 82 | 83 | Start by adding a few co-authors that you work with. Also see [add co-author](#add-co-author) command. 84 | 85 | ```bash 86 | $ cat <<-EOF > ~/.git-coauthors 87 | { 88 | "coauthors": { 89 | "ad": { 90 | "name": "Amy Doe", 91 | "email": "amy@findmypast.com" 92 | }, 93 | "bd": { 94 | "name": "Bob Doe", 95 | "email": "bob@findmypast.com" 96 | } 97 | } 98 | } 99 | EOF 100 | ``` 101 | 102 | You're ready to create your mob. Tell git-mob you're pairing with Amy by using her initials. `git mob ad` 103 | 104 | ``` 105 | $ git mob ad 106 | Jane Doe 107 | Amy Doe 108 | ``` 109 | 110 | Commit like you normally would. 111 | You should see `Co-authored-by: Amy Doe ` appear at the end of the commit message. 112 | 113 | Let's add Bob to the group to create a three-person mob. 114 | 115 | ``` 116 | $ git mob ad bd 117 | Jane Doe 118 | Amy Doe 119 | Bob Doe 120 | ``` 121 | 122 | Once you're done mobbing, switch back to developing solo.\* 123 | 124 | ``` 125 | $ git solo 126 | Jane Doe 127 | ``` 128 | 129 | Selected co-authors are **stored globally** meaning when switching between projects your co-authors stay the same\*. 130 | 131 | **\*Note**: If you've set a **local** commit template in your config then that template will be updated. However, **not** when you switch projects and you will see a warning. You can run `git mob` to update the commit template. [Read more here](https://github.com/rkotze/git-mob/discussions/81) 132 | 133 | ### Add co-author from GitHub 134 | 135 | Provide the GitHub username to generate their co-author details. The _anonymous_ GitHub email is used. You need to enable it [see config](#enable-github-author-fetch). 136 | 137 | ``` 138 | $ git mob rkotze 139 | Jane Doe 140 | Richard Kotze <10422117+rkotze@users.noreply.github.com> 141 | ``` 142 | 143 | ## Custom setup 144 | 145 | ### Using `git commit -m` setup 146 | 147 | How to append co-authors to the message when using message flag - `git commit -m "commit message"`? 148 | 149 | 1. Add `prepare-commit-msg` hook file in `.git/hooks` dir. See [hook-examples](https://github.com/rkotze/git-mob/tree/master/hook-examples) 150 | 2. The **hook** will need to be executable `chmod +x prepare-commit-msg` 151 | 152 | `prepare-commit-msg` will need a script to read the co-authors, which can be done via `git mob-print`. See [hook-examples](https://github.com/rkotze/git-mob/tree/master/hook-examples) folder for working scripts. 153 | 154 | The command `git mob-print` will output to `stdout` the formatted co-authors. 155 | 156 | **Note:** > `v1.1.0` `git mob --installTemplate` and `git mob --uninstallTemplate` has been removed. 157 | 158 | #### Using pre-commit to install 159 | 160 | You can install the git hook using `[pre-commit](https://pre-commit.com/)`. Add the following to your `pre-commit-config.yaml` 161 | 162 | ```yaml 163 | repos: 164 | - repo: https://github.com/rkotze/git-mob 165 | rev: { tag-version } 166 | hooks: 167 | - id: add-coauthors 168 | stages: ['prepare-commit-msg'] 169 | ``` 170 | 171 | And install with: `pre-commit install --hook-type prepare-commit-msg`. 172 | 173 | Removing the above snippet and running `git commit` will uninstall the pre-commit hook 174 | 175 | ### Revert back to default setup 176 | 177 | 1. Remove relevant scripts `prepare-commit-msg` file 178 | 179 | ## Git Mob config 180 | 181 | Git Mob config is a section in the Git config. 182 | 183 | ### Use local commit template 184 | 185 | If you are using a local commit template and want to remove the warning message then set this option to `true`. Only reads from the local git config. 186 | 187 | `type: Boolean`, `scope: local`, `version: 2.2.0` 188 | 189 | `git config --local git-mob-config.use-local-template true` 190 | 191 | ### Enable GitHub author fetch 192 | 193 | To fetch authors from GitHub you need to enable it using the config. 194 | 195 | `type: Boolean`, `scope: global`, `version: 2.3.3` 196 | 197 | `git config --global git-mob-config.github-fetch true` 198 | 199 | ## More commands 200 | 201 | ### List all co-authors 202 | 203 | Check which co-authors you have available in your `.git-coauthors` file. 204 | 205 | ``` 206 | $ git mob --list 207 | jd Jane Doe jane@example.com 208 | ad Amy Doe amy@example.com 209 | bd Bob Doe bob@example.com 210 | ``` 211 | 212 | ### Overwrite the main author 213 | 214 | Overwrite the current author which could be useful for pairing on different machines 215 | 216 | If the current author is: **Bob Doe** 217 | 218 | ``` 219 | $ git mob -o jd ad 220 | jd Jane Doe jane@example.com 221 | ad Amy Doe amy@example.com 222 | ``` 223 | 224 | Now the author has changed to **Jane Doe**. 225 | 226 | ### Add co-author 227 | 228 | Add a new co-author to your `.git-coauthors` file. 229 | 230 | ``` 231 | $ git add-coauthor bb "Barry Butterworth" barry@butterworth.org 232 | ``` 233 | 234 | ### Suggest co-authors 235 | 236 | Suggest co-authors from contributors in local Git repository and add to `.git-coauthors` file. 237 | 238 | Optional author filter by name or email. 239 | 240 | ``` 241 | $ git suggest-coauthors [author name | author email] 242 | ``` 243 | 244 | ### Path to .git-coauthors 245 | 246 | Print out path to `.git-coauthors` file. 247 | 248 | ``` 249 | git mob -p 250 | ``` 251 | 252 | Open co-author file: `git mob -p | xargs code` 253 | 254 | ### Help 255 | 256 | Find out more. 257 | 258 | ``` 259 | git mob -h 260 | ``` 261 | 262 | ### Add initials of current mob to your prompt 263 | 264 | #### Bash 265 | 266 | Add the initials to `PS1`, in `~/.bashrc` 267 | 268 | ```bash 269 | function git_initials { 270 | local initials=$(git mob-print --initials) 271 | if [[ -n "${initials}" ]]; then 272 | echo " [${initials}]" 273 | fi 274 | } 275 | 276 | export PS1="\$(pwd)\$(git_initials) -> " 277 | ``` 278 | 279 | #### Fish 280 | 281 | Add the following functions to `.config/fish/config.fish` 282 | 283 | ```fish 284 | function git_initials --description 'Print the initials for who I am currently pairing with' 285 | set -lx initials (git mob-print --initials) 286 | if test -n "$initials" 287 | printf ' [%s]' $initials 288 | end 289 | end 290 | 291 | function fish_prompt 292 | printf "%s%s ->" (pwd) (git_initials) 293 | end 294 | ``` 295 | 296 | ## More info 297 | 298 | [See git-mob discussions](https://github.com/rkotze/git-mob/discussions) 299 | 300 | Read our blog post to find out why git-mob exists: [Co-author commits with Git Mob](http://tech.findmypast.com/co-author-commits-with-git-mob) 301 | 302 | \* [If you have git-duet installed, you'll need to uninstall it](https://github.com/rkotze/git-mob/issues/2) since it conflicts with the git-solo command. 303 | -------------------------------------------------------------------------------- /packages/git-mob/bin/add-coauthor.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | import '../dist/git-add-coauthor.js'; 4 | -------------------------------------------------------------------------------- /packages/git-mob/bin/mob-print.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | import '../dist/git-mob-print.js'; 3 | -------------------------------------------------------------------------------- /packages/git-mob/bin/mob.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | import '../dist/git-mob.js'; 4 | -------------------------------------------------------------------------------- /packages/git-mob/bin/solo.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | import '../dist/solo.js'; 4 | -------------------------------------------------------------------------------- /packages/git-mob/bin/suggest-coauthors.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | import '../dist/git-suggest-coauthors.js'; 3 | -------------------------------------------------------------------------------- /packages/git-mob/hook-examples/prepare-commit-msg-nodejs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { exec } from 'node:child_process'; 3 | import { readFileSync, writeFileSync } from 'node:fs'; 4 | 5 | const commitMessage = process.argv[2]; 6 | // expect .git/COMMIT_EDITMSG 7 | if (/COMMIT_EDITMSG/g.test(commitMessage)) { 8 | let contents = ''; 9 | exec('git mob-print', function (err, stdout) { 10 | if (err) { 11 | process.exit(0); 12 | } 13 | 14 | // opens .git/COMMIT_EDITMSG 15 | contents = readFileSync(commitMessage); 16 | 17 | if (contents.indexOf(stdout.trim()) !== -1) { 18 | process.exit(0); 19 | } 20 | 21 | // Show in console any co-authors that were added 22 | if (stdout.trim().length) { 23 | const cyan = '\x1b[36m%s\x1b[0m'; 24 | console.log(cyan, stdout.trim()); 25 | } 26 | 27 | const commentPos = contents.indexOf('# '); 28 | const gitMessage = contents.slice(0, commentPos); 29 | const gitComments = contents.slice(commentPos); 30 | 31 | writeFileSync(commitMessage, gitMessage + stdout + gitComments); 32 | process.exit(0); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /packages/git-mob/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "git-mob", 3 | "version": "4.0.1", 4 | "description": "CLI tool for adding co-authors to commits.", 5 | "homepage": "https://github.com/rkotze/git-mob/blob/master/packages/git-mob/README.md", 6 | "type": "module", 7 | "scripts": { 8 | "build": "rimraf dist && bob", 9 | "test:w": "npm run build -- -w -t & env-cmd -f test-helpers/env.cjs ava --watch --serial --no-worker-threads", 10 | "pretest": "npm run build -- -t", 11 | "test": "npm run testbase", 12 | "checks": "npm run test && npm run lint", 13 | "lint": "xo --cwd=../../", 14 | "testbase": "ava --serial --no-worker-threads", 15 | "minifytest": "npm run build -- -m -t && npm run testbase", 16 | "preversion": "npm run checks", 17 | "prepack": "npm run build -- -m", 18 | "postinstall": "node ./dist/install/create-author-file.js" 19 | }, 20 | "bin": { 21 | "git-mob": "bin/mob.js", 22 | "git-mob-print": "bin/mob-print.js", 23 | "git-solo": "bin/solo.js", 24 | "git-add-coauthor": "bin/add-coauthor.js", 25 | "git-suggest-coauthors": "bin/suggest-coauthors.js" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "git@github.com:rkotze/git-mob.git", 30 | "directory": "packages/git-mob" 31 | }, 32 | "engines": { 33 | "node": ">=16" 34 | }, 35 | "keywords": [ 36 | "cli", 37 | "cli-app", 38 | "git-pair", 39 | "git-duet", 40 | "git", 41 | "github", 42 | "co-author", 43 | "pairing", 44 | "pair programming", 45 | "mob programming", 46 | "extreme programming", 47 | "xp", 48 | "social coding" 49 | ], 50 | "author": "Richard Kotze", 51 | "license": "MIT", 52 | "contributors": [ 53 | { 54 | "name": "Richard Kotze", 55 | "url": "https://github.com/rkotze" 56 | }, 57 | { 58 | "name": "Dennis Ideler", 59 | "url": "https://github.com/dideler" 60 | } 61 | ], 62 | "funding":{ 63 | "type": "BuyMeACoffee", 64 | "url": "https://www.buymeacoffee.com/rkotze" 65 | }, 66 | "dependencies": { 67 | "@inquirer/checkbox": "^4.1.8", 68 | "common-tags": "^1.8.2", 69 | "git-mob-core": "^0.10.1", 70 | "minimist": "^1.2.8", 71 | "update-notifier": "^7.3.1" 72 | }, 73 | "devDependencies": { 74 | "@ava/typescript": "^5.0.0", 75 | "@types/common-tags": "^1.8.4", 76 | "@types/minimist": "^1.2.5", 77 | "@types/node": "^22.15.30", 78 | "@types/sinon": "^17.0.4", 79 | "@types/update-notifier": "^6.0.8", 80 | "ava": "^6.4.0", 81 | "bob": "file:../bob", 82 | "coffee": "^5.5.1", 83 | "eol": "^0.10.0", 84 | "rimraf": "^6.0.1", 85 | "sinon": "^20.0.0", 86 | "tempy": "^3.1.0", 87 | "typescript": "^5.8.3" 88 | }, 89 | "ava": { 90 | "files": [ 91 | "dist/**/*.spec.js" 92 | ], 93 | "require": [ 94 | "./test-helpers/env.cjs" 95 | ], 96 | "watchMode.ignoreChanges": [ 97 | "dist", 98 | "test-env", 99 | "test-helpers" 100 | ], 101 | "typescript": { 102 | "rewritePaths": { 103 | "src/": "build/" 104 | }, 105 | "compile": false 106 | }, 107 | "nodeArguments": [ 108 | "--experimental-vm-modules" 109 | ] 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /packages/git-mob/src/check-author.spec.ts: -------------------------------------------------------------------------------- 1 | import os from 'node:os'; 2 | import test from 'ava'; 3 | import type { Author } from 'git-mob-core'; 4 | import { configWarning } from './check-author.js'; 5 | 6 | test('does not print warning when config present', t => { 7 | const actual = configWarning({ 8 | name: 'John Doe', 9 | email: 'jdoe@example.com', 10 | } as Author); 11 | t.is(actual, ''); 12 | }); 13 | 14 | test('prints warning and missing config when one argument is missing', t => { 15 | const actual = configWarning({ name: 'John Doe', email: '' } as Author); 16 | const expected = 17 | 'Warning: Missing information for the primary author. Set with:' + 18 | os.EOL + 19 | '$ git config --global user.email "jane@example.com"'; 20 | t.is(actual, expected); 21 | }); 22 | 23 | test('prints warning and missing config when both arguments are missing', t => { 24 | const actual = configWarning({ name: '', email: '' } as Author); 25 | const expected = 26 | 'Warning: Missing information for the primary author. Set with:' + 27 | os.EOL + 28 | '$ git config --global user.name "Jane Doe"' + 29 | os.EOL + 30 | '$ git config --global user.email "jane@example.com"'; 31 | t.is(actual, expected); 32 | }); 33 | -------------------------------------------------------------------------------- /packages/git-mob/src/check-author.ts: -------------------------------------------------------------------------------- 1 | import os from 'node:os'; 2 | import type { Author } from 'git-mob-core'; 3 | 4 | function configWarning({ name, email }: Author) { 5 | let result = ''; 6 | if (name === '' || email === '') { 7 | result = 'Warning: Missing information for the primary author. Set with:'; 8 | } 9 | 10 | if (name === '') { 11 | result += os.EOL + '$ git config --global user.name "Jane Doe"'; 12 | } 13 | 14 | if (email === '') { 15 | result += os.EOL + '$ git config --global user.email "jane@example.com"'; 16 | } 17 | 18 | return result; 19 | } 20 | 21 | export { configWarning }; 22 | -------------------------------------------------------------------------------- /packages/git-mob/src/colours.ts: -------------------------------------------------------------------------------- 1 | const colours = { 2 | red: '\u001B[31m', 3 | yellow: '\u001B[33m', 4 | }; 5 | const reset = '\u001B[0m'; 6 | 7 | function red(text: string) { 8 | return colours.red + text + reset; 9 | } 10 | 11 | function yellow(text: string) { 12 | return colours.yellow + text + reset; 13 | } 14 | 15 | export { red, yellow }; 16 | -------------------------------------------------------------------------------- /packages/git-mob/src/git-add-coauthor.spec.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { 3 | setCoauthorsFile, 4 | readCoauthorsFile, 5 | exec, 6 | deleteCoauthorsFile, 7 | } from '../test-helpers/index.js'; 8 | 9 | test.afterEach.always('cleanup', () => { 10 | deleteCoauthorsFile(); 11 | }); 12 | 13 | type CoAuthorList = { 14 | coauthors: Record; 15 | }; 16 | 17 | function loadCoauthors(): CoAuthorList { 18 | return JSON.parse(readCoauthorsFile() || '') as CoAuthorList; 19 | } 20 | 21 | test('adds a coauthor to coauthors file', t => { 22 | setCoauthorsFile(); 23 | exec('git add-coauthor tb "Barry Butterworth" barry@butterworth.org'); 24 | 25 | const addCoauthorActual = loadCoauthors(); 26 | const addCoauthorExpected = { 27 | coauthors: { 28 | jd: { 29 | name: 'Jane Doe', 30 | email: 'jane@findmypast.com', 31 | }, 32 | fb: { 33 | name: 'Frances Bar', 34 | email: 'frances-bar@findmypast.com', 35 | }, 36 | ea: { 37 | name: 'Elliot Alderson', 38 | email: 'ealderson@findmypast.com', 39 | }, 40 | tb: { 41 | name: 'Barry Butterworth', 42 | email: 'barry@butterworth.org', 43 | }, 44 | }, 45 | }; 46 | 47 | t.deepEqual(addCoauthorActual, addCoauthorExpected); 48 | }); 49 | 50 | test('does not add a coauthor to coauthors file if email invalid', t => { 51 | setCoauthorsFile(); 52 | 53 | const error = 54 | t.throws(() => { 55 | exec('git add-coauthor tb "Barry Butterworth" barry.org'); 56 | }) || new Error('No error'); 57 | 58 | t.regex(error.message, /invalid email format/i); 59 | 60 | const addCoauthorActual = loadCoauthors(); 61 | const addCoauthorExpected = { 62 | coauthors: { 63 | jd: { 64 | name: 'Jane Doe', 65 | email: 'jane@findmypast.com', 66 | }, 67 | fb: { 68 | name: 'Frances Bar', 69 | email: 'frances-bar@findmypast.com', 70 | }, 71 | ea: { 72 | name: 'Elliot Alderson', 73 | email: 'ealderson@findmypast.com', 74 | }, 75 | }, 76 | }; 77 | 78 | t.deepEqual(addCoauthorActual, addCoauthorExpected); 79 | }); 80 | 81 | test('does not add coauthor to coauthors file if wrong amount of parameters', t => { 82 | setCoauthorsFile(); 83 | 84 | const error = 85 | t.throws(() => { 86 | exec('git add-coauthor tb "Barry Butterworth"'); 87 | }) || new Error('No error'); 88 | 89 | t.regex(error.message, /incorrect number of parameters/i); 90 | 91 | const addCoauthorActual = loadCoauthors(); 92 | const addCoauthorExpected = { 93 | coauthors: { 94 | jd: { 95 | name: 'Jane Doe', 96 | email: 'jane@findmypast.com', 97 | }, 98 | fb: { 99 | name: 'Frances Bar', 100 | email: 'frances-bar@findmypast.com', 101 | }, 102 | ea: { 103 | name: 'Elliot Alderson', 104 | email: 'ealderson@findmypast.com', 105 | }, 106 | }, 107 | }; 108 | 109 | t.deepEqual(addCoauthorActual, addCoauthorExpected); 110 | }); 111 | 112 | test('does not add coauthor to coauthors file if key already exists', t => { 113 | setCoauthorsFile(); 114 | const error = 115 | t.throws(() => { 116 | exec('git add-coauthor ea "Emily Anderson" "emily@anderson.org"'); 117 | }) || new Error('No error'); 118 | 119 | t.regex(error.message, /duplicate key ea exists in .git-coauthors/i); 120 | 121 | const addCoauthorActual = loadCoauthors(); 122 | const addCoauthorExpected = { 123 | coauthors: { 124 | jd: { 125 | name: 'Jane Doe', 126 | email: 'jane@findmypast.com', 127 | }, 128 | fb: { 129 | name: 'Frances Bar', 130 | email: 'frances-bar@findmypast.com', 131 | }, 132 | ea: { 133 | name: 'Elliot Alderson', 134 | email: 'ealderson@findmypast.com', 135 | }, 136 | }, 137 | }; 138 | 139 | t.deepEqual(addCoauthorActual, addCoauthorExpected); 140 | }); 141 | 142 | test('-h prints help', t => { 143 | const { stdout } = exec('git add-coauthor -h'); 144 | 145 | t.regex(stdout, /usage/i); 146 | t.regex(stdout, /options/i); 147 | t.regex(stdout, /examples/i); 148 | }); 149 | -------------------------------------------------------------------------------- /packages/git-mob/src/git-add-coauthor.ts: -------------------------------------------------------------------------------- 1 | import minimist from 'minimist'; 2 | import { Author, saveNewCoAuthors } from 'git-mob-core'; 3 | import { runAddCoauthorHelp, validateEmail } from './helpers.js'; 4 | import { red } from './colours.js'; 5 | 6 | const argv = minimist(process.argv.slice(2), { 7 | alias: { 8 | h: 'help', 9 | }, 10 | }); 11 | 12 | function buildAuthorFromInput(args: string[]): Author { 13 | if (args.length !== 3) { 14 | throw new Error('Incorrect number of parameters.'); 15 | } 16 | 17 | if (!validateEmail(args[2])) { 18 | throw new Error('Invalid email format.'); 19 | } 20 | 21 | return new Author(args[0], args[1], args[2]); 22 | } 23 | 24 | async function execute(argv: minimist.ParsedArgs): Promise { 25 | if (argv.help) { 26 | runAddCoauthorHelp(); 27 | return; 28 | } 29 | 30 | const author = buildAuthorFromInput(argv._); 31 | await saveNewCoAuthors([author]); 32 | console.log(author.name + ' has been added to the .git-coauthors file'); 33 | } 34 | 35 | await execute(argv) 36 | .then(() => { 37 | process.exit(0); 38 | }) 39 | .catch((error: unknown) => { 40 | console.error(red((error as Error).message)); 41 | process.exit(1); 42 | }); 43 | -------------------------------------------------------------------------------- /packages/git-mob/src/git-authors/save-missing-authors.spec.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { type SinonSandbox, type SinonStub, createSandbox, assert } from 'sinon'; 3 | import { Author, gitMobConfig } from 'git-mob-core'; 4 | import { saveMissingAuthors, findMissingAuthors } from './save-missing-authors.js'; 5 | 6 | const savedAuthors = [ 7 | new Author('jd', 'Jane Doe', 'jane@findmypast.com'), 8 | new Author('fb', 'Frances Bar', 'frances-bar@findmypast.com'), 9 | ]; 10 | 11 | const gitHubAuthors = [ 12 | new Author('rkotze', 'Richard', 'rich@gitmob.com'), 13 | new Author('dideler', 'Denis', 'denis@gitmob.com'), 14 | ]; 15 | 16 | let sandbox: SinonSandbox; 17 | let saveCoauthorStub: SinonStub; 18 | 19 | test.before(() => { 20 | sandbox = createSandbox(); 21 | saveCoauthorStub = sandbox.stub(); 22 | }); 23 | 24 | test.afterEach(() => { 25 | sandbox.restore(); 26 | }); 27 | 28 | test('Search from GitHub not enabled', async t => { 29 | const fetchAuthorsStub = sandbox.stub().resolves(gitHubAuthors); 30 | sandbox.stub(gitMobConfig, 'fetchFromGitHub').resolves(false); 31 | 32 | t.deepEqual( 33 | await saveMissingAuthors( 34 | ['rkotze', 'dideler', 'jd'], 35 | savedAuthors, 36 | fetchAuthorsStub, 37 | saveCoauthorStub 38 | ), 39 | [] 40 | ); 41 | }); 42 | 43 | test('Find missing author initials "rkotze" and "dideler" to an array', t => { 44 | const missingCoAuthor = findMissingAuthors( 45 | ['rkotze', 'dideler', 'jd'], 46 | savedAuthors 47 | ); 48 | t.deepEqual(missingCoAuthor, ['rkotze', 'dideler']); 49 | }); 50 | 51 | test('No missing author initials', t => { 52 | const missingCoAuthor = findMissingAuthors(['jd', 'fb'], savedAuthors); 53 | t.deepEqual(missingCoAuthor, []); 54 | }); 55 | 56 | test('Search GitHub for missing co-authors', async t => { 57 | const fetchAuthorsStub = sandbox.stub().resolves(gitHubAuthors); 58 | sandbox.stub(gitMobConfig, 'fetchFromGitHub').resolves(true); 59 | 60 | await saveMissingAuthors( 61 | ['rkotze', 'dideler', 'jd'], 62 | savedAuthors, 63 | fetchAuthorsStub, 64 | saveCoauthorStub 65 | ); 66 | t.notThrows(() => { 67 | assert.calledWith(fetchAuthorsStub, ['rkotze', 'dideler']); 68 | }, 'Not called with ["rkotze", "dideler"]'); 69 | }); 70 | 71 | test('Create author list from GitHub and co-author file', async t => { 72 | const fetchAuthorsStub = sandbox.stub().resolves(gitHubAuthors); 73 | sandbox.stub(gitMobConfig, 'fetchFromGitHub').resolves(true); 74 | 75 | const authorList = await saveMissingAuthors( 76 | ['rkotze', 'dideler', 'jd'], 77 | savedAuthors, 78 | fetchAuthorsStub, 79 | saveCoauthorStub 80 | ); 81 | 82 | const expectedAuthorList = [ 83 | 'Richard ', 84 | 'Denis ', 85 | ]; 86 | 87 | t.deepEqual(authorList, expectedAuthorList); 88 | }); 89 | 90 | test('Save missing co-author', async t => { 91 | const rkotzeAuthor = [new Author('rkotze', 'Richard', 'rich@gitmob.com')]; 92 | const fetchAuthorsStub = sandbox.stub().resolves(rkotzeAuthor); 93 | sandbox.stub(gitMobConfig, 'fetchFromGitHub').resolves(true); 94 | 95 | await saveMissingAuthors( 96 | ['rkotze', 'jd'], 97 | savedAuthors, 98 | fetchAuthorsStub, 99 | saveCoauthorStub 100 | ); 101 | 102 | const rkotzeAuthorList = [new Author('rkotze', 'Richard', 'rich@gitmob.com')]; 103 | 104 | t.notThrows(() => { 105 | assert.calledWith(saveCoauthorStub, rkotzeAuthorList); 106 | }, 'Not called with GitMobCoauthors type'); 107 | }); 108 | 109 | test('Throw error if author not found', async t => { 110 | const fetchAuthorsStub = sandbox.stub().rejects(); 111 | sandbox.stub(gitMobConfig, 'fetchFromGitHub').resolves(true); 112 | 113 | await t.throwsAsync(async () => 114 | saveMissingAuthors(['rkotze', 'dideler'], savedAuthors, fetchAuthorsStub) 115 | ); 116 | }); 117 | -------------------------------------------------------------------------------- /packages/git-mob/src/git-authors/save-missing-authors.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Author, 3 | fetchGitHubAuthors, 4 | saveNewCoAuthors, 5 | gitMobConfig, 6 | } from 'git-mob-core'; 7 | 8 | async function saveMissingAuthors( 9 | initials: string[], 10 | coAuthorList: Author[], 11 | getAuthors = fetchGitHubAuthors, 12 | saveAuthors = saveNewCoAuthors 13 | ): Promise { 14 | const gitHubFetch = await gitMobConfig.fetchFromGitHub(); 15 | if (!gitHubFetch) { 16 | return []; 17 | } 18 | 19 | const missing = findMissingAuthors(initials, coAuthorList); 20 | if (missing.length > 0) { 21 | const fetchedAuthors: Author[] = await getAuthors(missing, 'git-mob-cli'); 22 | await saveAuthors(fetchedAuthors); 23 | return fetchedAuthors.map(author => author.toString()); 24 | } 25 | 26 | return []; 27 | } 28 | 29 | function findMissingAuthors( 30 | initialList: string[], 31 | coAuthorList: Author[] 32 | ): string[] { 33 | return initialList.filter(initials => !containsAuthor(initials, coAuthorList)); 34 | } 35 | 36 | function containsAuthor(initials: string, coauthors: Author[]): boolean { 37 | return coauthors.some(author => author.key === initials); 38 | } 39 | 40 | export { findMissingAuthors, saveMissingAuthors }; 41 | -------------------------------------------------------------------------------- /packages/git-mob/src/git-mob-local-coauthors.spec.js: -------------------------------------------------------------------------------- 1 | import { EOL } from 'node:os'; 2 | import test from 'ava'; 3 | import { 4 | exec, 5 | setCoauthorsFile, 6 | deleteCoauthorsFile, 7 | setup, 8 | tearDown, 9 | setLocalCoauthorsFile, 10 | deleteLocalCoauthorsFile, 11 | } from '../test-helpers/index.js'; 12 | 13 | const { before, after } = test; 14 | 15 | before('setup', () => { 16 | setup(); 17 | setCoauthorsFile(); 18 | }); 19 | 20 | after.always('final cleanup', () => { 21 | deleteCoauthorsFile(); 22 | tearDown(); 23 | }); 24 | 25 | test('--list print a list of available co-authors from repo root .git-coauthor', t => { 26 | setLocalCoauthorsFile(); 27 | const oldEnv = process.env.GITMOB_COAUTHORS_PATH; 28 | delete process.env.GITMOB_COAUTHORS_PATH; 29 | const actual = exec('git mob --list').stdout.trimEnd(); 30 | const expected = [ 31 | 'dd, Din Djarin, din@mando.com', 32 | 'bk, Bo-Katan Kryze, bo-katan@dwatch.com', 33 | ].join(EOL); 34 | 35 | t.is(actual, expected); 36 | process.env.GITMOB_COAUTHORS_PATH = oldEnv; 37 | deleteLocalCoauthorsFile(); 38 | }); 39 | -------------------------------------------------------------------------------- /packages/git-mob/src/git-mob-print.spec.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { 3 | addAuthor, 4 | addCoAuthor, 5 | removeCoAuthors, 6 | safelyRemoveGitConfigSection, 7 | deleteGitMessageFile, 8 | exec, 9 | setCoauthorsFile, 10 | deleteCoauthorsFile, 11 | setup, 12 | tearDown, 13 | unsetCommitTemplate, 14 | } from '../test-helpers/index.js'; 15 | 16 | const { before, after, afterEach } = test; 17 | 18 | before('setup', () => { 19 | setup(); 20 | setCoauthorsFile(); 21 | }); 22 | 23 | after.always('final cleanup', () => { 24 | deleteCoauthorsFile(); 25 | deleteGitMessageFile(); 26 | tearDown(); 27 | }); 28 | 29 | afterEach.always('each cleanup', () => { 30 | safelyRemoveGitConfigSection('user'); 31 | safelyRemoveGitConfigSection('git-mob'); 32 | safelyRemoveGitConfigSection('commit'); 33 | }); 34 | 35 | test('Print a list of selected author keys', t => { 36 | addAuthor('John Doe', 'jdoe@example.com'); 37 | addCoAuthor('Jane Doe', 'jane@findmypast.com'); 38 | addCoAuthor('Elliot Alderson', 'ealderson@findmypast.com>'); 39 | const { stdout } = exec('git mob-print -i'); 40 | 41 | t.regex(stdout, /jd,ea/); 42 | 43 | unsetCommitTemplate(); 44 | removeCoAuthors(); 45 | }); 46 | -------------------------------------------------------------------------------- /packages/git-mob/src/git-mob-print.ts: -------------------------------------------------------------------------------- 1 | import os from 'node:os'; 2 | import minimist from 'minimist'; 3 | import { getAllAuthors, getSelectedCoAuthors } from 'git-mob-core'; 4 | import { runMobPrintHelp } from './helpers.js'; 5 | 6 | const argv = minimist(process.argv.slice(2), { 7 | alias: { 8 | i: 'initials', 9 | h: 'help', 10 | }, 11 | }); 12 | 13 | await execute(argv); 14 | 15 | async function execute(args: minimist.ParsedArgs) { 16 | if (args.help) { 17 | runMobPrintHelp(); 18 | process.exit(0); 19 | } 20 | 21 | if (args.initials) { 22 | return printCoAuthorsInitials(); 23 | } 24 | 25 | return printCoAuthors(); 26 | } 27 | 28 | async function listSelectedAuthors() { 29 | const allAuthors = await getAllAuthors(); 30 | return getSelectedCoAuthors(allAuthors); 31 | } 32 | 33 | async function printCoAuthors() { 34 | try { 35 | const selectedAuthors = await listSelectedAuthors(); 36 | const coAuthors = selectedAuthors.map(author => author.format()).join(os.EOL); 37 | console.log(os.EOL + os.EOL + coAuthors); 38 | } catch (error: unknown) { 39 | const printError = error as Error; 40 | console.error(`Error: ${printError.message}`); 41 | process.exit(1); 42 | } 43 | } 44 | 45 | async function printCoAuthorsInitials() { 46 | try { 47 | const selectedAuthors = await listSelectedAuthors(); 48 | 49 | if (selectedAuthors.length > 0) { 50 | console.log(selectedAuthors.map(author => author.key).join(',')); 51 | } 52 | } catch (error: unknown) { 53 | const initialsError = error as Error; 54 | console.error(`Error: ${initialsError.message}`); 55 | process.exit(1); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/git-mob/src/git-mob.d.ts: -------------------------------------------------------------------------------- 1 | type Author = { 2 | name: string; 3 | email: string; 4 | }; 5 | 6 | type AuthorList = Record; 7 | 8 | type GitMobCoauthors = { coauthors: AuthorList }; 9 | -------------------------------------------------------------------------------- /packages/git-mob/src/git-mob.spec.ts: -------------------------------------------------------------------------------- 1 | import { EOL } from 'node:os'; 2 | import test from 'ava'; 3 | import { stripIndent } from 'common-tags'; 4 | import { auto } from 'eol'; 5 | import { temporaryDirectory } from 'tempy'; 6 | import { 7 | addAuthor, 8 | addCoAuthor, 9 | removeCoAuthors, 10 | unsetCommitTemplate, 11 | safelyRemoveGitConfigSection, 12 | setGitMessageFile, 13 | readGitMessageFile, 14 | deleteGitMessageFile, 15 | exec, 16 | setCoauthorsFile, 17 | deleteCoauthorsFile, 18 | setup, 19 | tearDown, 20 | } from '../test-helpers/index.js'; 21 | 22 | const { before, after, afterEach, skip } = test; 23 | 24 | before('setup', () => { 25 | setup(); 26 | setCoauthorsFile(); 27 | }); 28 | 29 | after.always('final cleanup', () => { 30 | deleteCoauthorsFile(); 31 | deleteGitMessageFile(); 32 | tearDown(); 33 | }); 34 | 35 | afterEach.always('each cleanup', () => { 36 | safelyRemoveGitConfigSection('git-mob'); 37 | safelyRemoveGitConfigSection('user'); 38 | safelyRemoveGitConfigSection('commit'); 39 | }); 40 | 41 | test('-h prints help', t => { 42 | const { stdout } = exec('git mob -h'); 43 | 44 | t.regex(stdout, /usage/i); 45 | t.regex(stdout, /options/i); 46 | t.regex(stdout, /examples/i); 47 | }); 48 | 49 | if (process.platform === 'win32') { 50 | // Windows tries to open a man page at git-doc/git-mob.html which errors. 51 | skip('--help is intercepted by git launcher on Windows', () => null); 52 | } else { 53 | test('--help is intercepted by git launcher', t => { 54 | const error = 55 | t.throws(() => { 56 | exec('git mob --help'); 57 | }) || new Error('No error'); 58 | 59 | t.regex(error.message, /no manual entry for git-mob/i); 60 | }); 61 | } 62 | 63 | test('-v prints version', t => { 64 | const { stdout } = exec('git mob -v'); 65 | 66 | t.regex(stdout, /\d.\d.\d/); 67 | }); 68 | 69 | test('--version prints version', t => { 70 | const { stdout } = exec('git mob --version'); 71 | 72 | t.regex(stdout, /\d.\d.\d/); 73 | }); 74 | 75 | test('--list print a list of available co-authors', t => { 76 | const actual = exec('git mob --list').stdout.trimEnd(); 77 | const expected = [ 78 | 'jd, Jane Doe, jane@findmypast.com', 79 | 'fb, Frances Bar, frances-bar@findmypast.com', 80 | 'ea, Elliot Alderson, ealderson@findmypast.com', 81 | ].join(EOL); 82 | 83 | t.is(actual, expected); 84 | }); 85 | 86 | test('prints only primary author when there is no mob', t => { 87 | addAuthor('John Doe', 'jdoe@example.com'); 88 | 89 | const actual = exec('git mob').stdout.trimEnd(); 90 | 91 | t.is(actual, 'John Doe '); 92 | }); 93 | 94 | test('prints current mob', t => { 95 | addAuthor('John Doe', 'jdoe@example.com'); 96 | addCoAuthor('Jane Doe', 'jane@findmypast.com'); 97 | addCoAuthor('Elliot Alderson', 'ealderson@findmypast.com>'); 98 | 99 | const actual = exec('git mob').stdout.trimEnd(); 100 | const expected = stripIndent` 101 | John Doe 102 | Jane Doe 103 | Elliot Alderson `; 104 | 105 | t.is(actual, expected); 106 | // setting co-authors outside the git mob lifecycle the commit.template 107 | // is never updated. By default git mob is global and this asserts the 108 | // template is not updated as it's expect to be up to date. 109 | t.is(readGitMessageFile(true), undefined); 110 | removeCoAuthors(); 111 | }); 112 | 113 | test('shows warning if local commit.template is used', t => { 114 | addAuthor('John Doe', 'jdoe@example.com'); 115 | 116 | exec('git config --local commit.template ".git/.gitmessage"'); 117 | const actual = exec('git mob').stdout.trimEnd(); 118 | const expected = /Warning: Git Mob uses Git global config/; 119 | 120 | t.regex(actual, expected); 121 | exec('git config --local --remove-section commit'); 122 | }); 123 | 124 | test('hides warning if local git mob config template is used true', t => { 125 | addAuthor('John Doe', 'jdoe@example.com'); 126 | exec('git config --local commit.template ".git/.gitmessage"'); 127 | exec('git config --local git-mob-config.use-local-template true'); 128 | 129 | const actual = exec('git mob').stdout.trimEnd(); 130 | const expected = /Warning: Git Mob uses Git global config/; 131 | 132 | t.notRegex(actual, expected); 133 | exec('git config --local --remove-section git-mob-config'); 134 | exec('git config --local --remove-section commit'); 135 | }); 136 | 137 | test('update local commit template if using one', t => { 138 | addAuthor('John Doe', 'jdoe@example.com'); 139 | addCoAuthor('Elliot Alderson', 'ealderson@findmypast.com'); 140 | 141 | exec('git config --local commit.template ".git/.gitmessage"'); 142 | 143 | exec('git mob').stdout.trimEnd(); 144 | const actualGitMessage = readGitMessageFile(); 145 | const expectedGitMessage = auto( 146 | [EOL, EOL, 'Co-authored-by: Elliot Alderson '].join('') 147 | ); 148 | 149 | t.is(actualGitMessage, expectedGitMessage); 150 | exec('git config --local --remove-section commit'); 151 | }); 152 | 153 | test('sets mob when co-author initials found', t => { 154 | addAuthor('Billy the Kid', 'billy@example.com'); 155 | 156 | const actual = exec('git mob jd ea').stdout.trimEnd(); 157 | const expected = stripIndent` 158 | Billy the Kid 159 | Jane Doe 160 | Elliot Alderson 161 | `; 162 | 163 | t.is(actual, expected); 164 | removeCoAuthors(); 165 | }); 166 | 167 | test('sets mob and override primary author', t => { 168 | addAuthor('Billy the Kid', 'billy@example.com'); 169 | 170 | const actual = exec('git mob -o jd ea').stdout.trimEnd(); 171 | const expected = stripIndent` 172 | Jane Doe 173 | Elliot Alderson 174 | `; 175 | 176 | t.is(actual, expected); 177 | removeCoAuthors(); 178 | }); 179 | 180 | test('Incorrect override author key will show error', t => { 181 | addAuthor('Billy the Kid', 'billy@example.com'); 182 | 183 | const error = 184 | t.throws(() => { 185 | exec('git mob -o kl ea'); 186 | }) || new Error('No error'); 187 | 188 | t.regex(error.message, /error: kl author key not found!/i); 189 | }); 190 | 191 | test('overwrites old mob when setting a new mob', t => { 192 | setGitMessageFile(); 193 | addAuthor('John Doe', 'jdoe@example.com'); 194 | 195 | exec('git mob jd'); 196 | 197 | const actualOutput = exec('git mob ea').stdout.trimEnd(); 198 | const expectedOutput = stripIndent` 199 | John Doe 200 | Elliot Alderson 201 | `; 202 | 203 | t.is(actualOutput, expectedOutput); 204 | 205 | const actualGitmessage = readGitMessageFile(); 206 | const expectedGitmessage = auto(stripIndent` 207 | A commit title 208 | 209 | A commit body that goes into more detail. 210 | 211 | Co-authored-by: Elliot Alderson `); 212 | 213 | t.is(actualGitmessage, expectedGitmessage); 214 | removeCoAuthors(); 215 | }); 216 | 217 | test('appends co-authors to an existing commit template', t => { 218 | setGitMessageFile(); 219 | addAuthor('Thomas Anderson', 'neo@example.com'); 220 | 221 | exec('git mob jd ea'); 222 | 223 | const actualGitMessage = readGitMessageFile(); 224 | const expectedGitMessage = auto(stripIndent` 225 | A commit title 226 | 227 | A commit body that goes into more detail. 228 | 229 | Co-authored-by: Jane Doe 230 | Co-authored-by: Elliot Alderson `); 231 | 232 | t.is(actualGitMessage, expectedGitMessage); 233 | 234 | unsetCommitTemplate(); 235 | removeCoAuthors(); 236 | }); 237 | 238 | test('appends co-authors to a new commit template', t => { 239 | deleteGitMessageFile(); 240 | addAuthor('Thomas Anderson', 'neo@example.com'); 241 | 242 | exec('git mob jd ea'); 243 | 244 | const actualGitMessage = readGitMessageFile(); 245 | const expectedGitMessage = auto( 246 | [ 247 | EOL, 248 | EOL, 249 | 'Co-authored-by: Jane Doe ', 250 | EOL, 251 | 'Co-authored-by: Elliot Alderson ', 252 | ].join('') 253 | ); 254 | 255 | t.is(actualGitMessage, expectedGitMessage); 256 | 257 | removeCoAuthors(); 258 | unsetCommitTemplate(); 259 | }); 260 | 261 | test('warns when used outside of a git repo', t => { 262 | const repoDir = process.cwd(); 263 | const temporaryDir = temporaryDirectory(); 264 | process.chdir(temporaryDir); 265 | 266 | const error = 267 | t.throws(() => { 268 | exec('git mob'); 269 | }) || new Error('No error'); 270 | 271 | t.regex(error.message, /not a git repository/i); 272 | 273 | process.chdir(repoDir); 274 | }); 275 | -------------------------------------------------------------------------------- /packages/git-mob/src/git-mob.ts: -------------------------------------------------------------------------------- 1 | import os from 'node:os'; 2 | import minimist from 'minimist'; 3 | import { stripIndents } from 'common-tags'; 4 | import { 5 | getAllAuthors, 6 | getPrimaryAuthor, 7 | getSelectedCoAuthors, 8 | gitMobConfig, 9 | gitConfig, 10 | gitRevParse, 11 | setCoAuthors, 12 | setPrimaryAuthor, 13 | updateGitTemplate, 14 | Author, 15 | pathToCoAuthors, 16 | } from 'git-mob-core'; 17 | import { checkForUpdates, runHelp, runVersion, printList } from './helpers.js'; 18 | import { configWarning } from './check-author.js'; 19 | import { red, yellow } from './colours.js'; 20 | import { saveMissingAuthors } from './git-authors/save-missing-authors.js'; 21 | 22 | checkForUpdates(); 23 | 24 | const argv = minimist(process.argv.slice(2), { 25 | boolean: ['h', 'v', 'l', 'o', 'p'], 26 | 27 | alias: { 28 | h: 'help', 29 | v: 'version', 30 | l: 'list', 31 | o: 'override', 32 | p: 'coauthors-path', 33 | }, 34 | }); 35 | 36 | await execute(argv).catch(() => null); 37 | 38 | async function execute(args: minimist.ParsedArgs) { 39 | if (args.help) { 40 | runHelp(); 41 | process.exit(0); 42 | } 43 | 44 | if (args.version) { 45 | runVersion(); 46 | process.exit(0); 47 | } 48 | 49 | if (args['coauthors-path']) { 50 | console.log(await pathToCoAuthors()); 51 | process.exit(0); 52 | } 53 | 54 | if (args.list) { 55 | await listCoAuthors(); 56 | process.exit(0); 57 | } 58 | 59 | const isGitRepo = await gitRevParse.insideWorkTree(); 60 | if (!isGitRepo) { 61 | console.error(red('Error: not a Git repository')); 62 | process.exit(1); 63 | } 64 | 65 | if (args.override) { 66 | const initial = args._.shift(); 67 | if (initial) { 68 | await setAuthor(initial); 69 | } 70 | 71 | await runMob(args._); 72 | } else { 73 | await runMob(args._); 74 | } 75 | } 76 | 77 | async function runMob(args: string[]) { 78 | if (args.length === 0) { 79 | const gitAuthor = await getPrimaryAuthor(); 80 | const [authorList, useLocalTemplate, template] = await Promise.all([ 81 | getAllAuthors(), 82 | gitMobConfig.localTemplate(), 83 | gitConfig.getLocalCommitTemplate(), 84 | ]); 85 | const selectedCoAuthors = await getSelectedCoAuthors(authorList); 86 | 87 | printMob(gitAuthor, selectedCoAuthors, useLocalTemplate, template); 88 | 89 | if (template && selectedCoAuthors) { 90 | await updateGitTemplate(selectedCoAuthors); 91 | } 92 | } else { 93 | await setMob(args); 94 | } 95 | } 96 | 97 | function printMob( 98 | gitAuthor: Author | undefined, 99 | selectedCoAuthors: Author[], 100 | useLocalTemplate: boolean, 101 | template: string | undefined 102 | ) { 103 | const theAuthor = gitAuthor || new Author('', '', ''); 104 | const authorWarnConfig = configWarning(theAuthor); 105 | if (authorWarnConfig) { 106 | console.log(red(authorWarnConfig)); 107 | process.exit(1); 108 | } 109 | 110 | console.log(theAuthor.toString()); 111 | 112 | if (selectedCoAuthors && selectedCoAuthors.length > 0) { 113 | console.log(selectedCoAuthors.join(os.EOL)); 114 | } 115 | 116 | if (!useLocalTemplate && template) { 117 | console.log( 118 | yellow(stripIndents`Warning: Git Mob uses Git global config. 119 | Using local commit.template could mean your template does not have selected co-authors appended after switching projects. 120 | See: https://github.com/rkotze/git-mob/discussions/81`) 121 | ); 122 | } 123 | } 124 | 125 | async function listCoAuthors() { 126 | try { 127 | const coAuthors = await getAllAuthors(); 128 | 129 | printList(coAuthors); 130 | } catch (error: unknown) { 131 | const authorListError = error as Error; 132 | console.error(red(`listCoAuthors error: ${authorListError.message}`)); 133 | process.exit(1); 134 | } 135 | } 136 | 137 | async function setMob(initials: string[]) { 138 | try { 139 | const authorList = await getAllAuthors(); 140 | await saveMissingAuthors(initials, authorList); 141 | const selectedCoAuthors = await setCoAuthors(initials); 142 | 143 | const [useLocalTemplate, template] = await Promise.all([ 144 | gitMobConfig.localTemplate(), 145 | gitConfig.getLocalCommitTemplate(), 146 | ]); 147 | 148 | const gitAuthor = await getPrimaryAuthor(); 149 | printMob(gitAuthor, selectedCoAuthors, useLocalTemplate, template); 150 | } catch (error: unknown) { 151 | const setMobError = error as Error; 152 | console.error(red(`setMob error: ${setMobError.message}`)); 153 | if (setMobError.message.includes('not found!')) { 154 | console.log( 155 | yellow( 156 | 'Run "git config --global git-mob-config.github-fetch true" to fetch GitHub authors.' 157 | ) 158 | ); 159 | } 160 | 161 | process.exit(1); 162 | } 163 | } 164 | 165 | async function setAuthor(initial: string) { 166 | try { 167 | const authorList = await getAllAuthors(); 168 | const author = authorList.find(author => author.key === initial); 169 | 170 | if (!author) { 171 | throw new Error(`${initial} author key not found!`); 172 | } 173 | 174 | await setPrimaryAuthor(author); 175 | } catch (error: unknown) { 176 | const setAuthorError = error as Error; 177 | console.error(red(`setAuthor error: ${setAuthorError.message}`)); 178 | process.exit(1); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /packages/git-mob/src/git-solo.spec.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { stripIndent } from 'common-tags'; 3 | import { auto } from 'eol'; 4 | import { temporaryDirectory } from 'tempy'; 5 | import { 6 | addAuthor, 7 | unsetCommitTemplate, 8 | setGitMessageFile, 9 | readGitMessageFile, 10 | deleteGitMessageFile, 11 | exec, 12 | setCoauthorsFile, 13 | deleteCoauthorsFile, 14 | setup, 15 | tearDown, 16 | } from '../test-helpers/index.js'; 17 | 18 | const { before, after } = test; 19 | 20 | before('Check author', () => { 21 | setup(); 22 | setCoauthorsFile(); 23 | }); 24 | 25 | after.always('cleanup', () => { 26 | tearDown(); 27 | deleteCoauthorsFile(); 28 | deleteGitMessageFile(); 29 | }); 30 | 31 | test('sets the current mob to the primary author', t => { 32 | addAuthor('Thomas Anderson', 'neo@example.com'); 33 | setGitMessageFile(); 34 | 35 | exec('git mob jd ea'); 36 | 37 | const soloActual = exec('git solo').stdout.trimEnd(); 38 | const soloExpected = 'Thomas Anderson '; 39 | 40 | const mobActual = exec('git mob').stdout.trimEnd(); 41 | const mobExpected = 'Thomas Anderson '; 42 | 43 | t.is(soloActual, soloExpected); 44 | t.is(mobActual, mobExpected); 45 | 46 | unsetCommitTemplate(); 47 | }); 48 | 49 | test('removes co-authors from commit template', t => { 50 | addAuthor('Thomas Anderson', 'neo@example.com'); 51 | setGitMessageFile(); 52 | 53 | exec('git mob jd ea'); 54 | exec('git solo'); 55 | 56 | const actualGitMessage = readGitMessageFile(); 57 | const expectedGitMessage = auto(stripIndent` 58 | A commit title 59 | 60 | A commit body that goes into more detail.`); 61 | 62 | t.is(actualGitMessage, expectedGitMessage); 63 | 64 | unsetCommitTemplate(); 65 | }); 66 | 67 | test('ignores positional arguments', t => { 68 | addAuthor('Thomas Anderson', 'neo@example.com'); 69 | 70 | const soloActual = exec('git solo yolo').stdout.trimEnd(); 71 | const soloExpected = 'Thomas Anderson '; 72 | 73 | t.is(soloActual, soloExpected); 74 | 75 | unsetCommitTemplate(); 76 | }); 77 | 78 | test('warns when used outside of a git repo', t => { 79 | const repoDir = process.cwd(); 80 | const temporaryDir = temporaryDirectory(); 81 | process.chdir(temporaryDir); 82 | 83 | const error = 84 | t.throws(() => { 85 | exec('git solo'); 86 | }) || new Error('No error'); 87 | 88 | t.regex(error.message, /not a git repository/i); 89 | 90 | process.chdir(repoDir); 91 | }); 92 | -------------------------------------------------------------------------------- /packages/git-mob/src/git-suggest-coauthors.spec.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import coffee from 'coffee'; 3 | import { 4 | setCoauthorsFile, 5 | deleteCoauthorsFile, 6 | readCoauthorsFile, 7 | } from '../test-helpers/index.js'; 8 | 9 | const { before, after } = test; 10 | 11 | before('setup', () => { 12 | setCoauthorsFile(); 13 | }); 14 | 15 | after.always('final cleanup', () => { 16 | deleteCoauthorsFile(); 17 | }); 18 | 19 | test('Suggests coauthors using repo contributors', async t => { 20 | const { stdout } = await coffee 21 | .spawn('git', ['suggest-coauthors']) 22 | .waitForPrompt(false) 23 | .writeKey('ENTER') 24 | .end(); 25 | 26 | t.regex(stdout, /"Richard Kotze" richkotze@outlook.com/); 27 | }); 28 | 29 | test('Filter suggestions of coauthors', async t => { 30 | const { stdout } = await coffee 31 | .spawn('git', ['suggest-coauthors', 'dennis i']) 32 | .waitForPrompt(false) 33 | .writeKey('SPACE', 'ENTER') 34 | .end(); 35 | 36 | const coAuthorFile = readCoauthorsFile() || ''; 37 | t.regex(stdout, /"Dennis Ideler" ideler.dennis@gmail.com/); 38 | t.regex(coAuthorFile, /ideler.dennis@gmail.com/); 39 | t.regex(coAuthorFile, /Dennis Ideler/); 40 | t.regex(coAuthorFile, /diid/); 41 | }); 42 | 43 | test('Prints help message', async t => { 44 | const { stdout } = await coffee.spawn('git', ['suggest-coauthors', '-h']).end(); 45 | 46 | t.regex(stdout, /usage/i); 47 | t.regex(stdout, /options/i); 48 | t.regex(stdout, /example/i); 49 | }); 50 | -------------------------------------------------------------------------------- /packages/git-mob/src/git-suggest-coauthors.ts: -------------------------------------------------------------------------------- 1 | import minimist from 'minimist'; 2 | import { 3 | type Author, 4 | gitRevParse, 5 | repoAuthorList, 6 | saveNewCoAuthors, 7 | } from 'git-mob-core'; 8 | import checkbox from '@inquirer/checkbox'; 9 | import { runSuggestCoauthorsHelp } from './helpers.js'; 10 | import { red, yellow } from './colours.js'; 11 | 12 | const argv = minimist(process.argv.slice(2), { 13 | alias: { 14 | h: 'help', 15 | }, 16 | }); 17 | 18 | async function execute(argv: minimist.ParsedArgs) { 19 | if (argv.help) { 20 | runSuggestCoauthorsHelp(); 21 | process.exit(0); 22 | } 23 | 24 | const isGitRepo = await gitRevParse.insideWorkTree(); 25 | if (!isGitRepo) { 26 | console.error(red('Error: not a git repository')); 27 | process.exit(1); 28 | } 29 | 30 | await coAuthorSuggestions(argv._.join(' ')); 31 | process.exit(0); 32 | } 33 | 34 | async function coAuthorSuggestions(authorFilter: string) { 35 | try { 36 | const gitAuthors = await repoAuthorList(authorFilter.trim()); 37 | 38 | if (gitAuthors && gitAuthors.length > 0) { 39 | const selected = await selectCoAuthors(gitAuthors); 40 | if (selected) { 41 | await saveNewCoAuthors(selected); 42 | } 43 | } else { 44 | console.log(yellow('Could not find authors!')); 45 | } 46 | } catch (error: unknown) { 47 | const errorSuggest = error as Error; 48 | console.error(red(`Error: ${errorSuggest.message}`)); 49 | process.exit(1); 50 | } 51 | } 52 | 53 | async function selectCoAuthors(coAuthors: Author[]): Promise { 54 | const selected = await checkbox({ 55 | message: 'Select co-authors to save', 56 | choices: coAuthors.map(author => ({ 57 | name: `"${author.name}" ${author.email}`, 58 | value: author, 59 | })), 60 | }); 61 | 62 | return selected; 63 | } 64 | 65 | await execute(argv); 66 | -------------------------------------------------------------------------------- /packages/git-mob/src/helpers.spec.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { validateEmail } from './helpers.js'; 3 | 4 | test('email validator returns true for valid emails', t => { 5 | const validEmail = validateEmail('johndoe@aol.org'); 6 | const validEmail2 = validateEmail('john.doe@aol.org'); 7 | const validEmail3 = validateEmail('johndoe@aol.org.com'); 8 | const validEmail4 = validateEmail('johndoe@aol.org'); 9 | 10 | t.is(validEmail, true); 11 | t.is(validEmail2, true); 12 | t.is(validEmail3, true); 13 | t.is(validEmail4, true); 14 | }); 15 | 16 | test('email validator returns false for invalid emails', t => { 17 | const invalidEmail = validateEmail('johndoe.@aol.org'); 18 | const invalidEmail2 = validateEmail('johndoe@.org'); 19 | const invalidEmail3 = validateEmail('johndoe@aol.c'); 20 | const invalidEmail4 = validateEmail('johndoe@aol'); 21 | const invalidEmail5 = validateEmail('johndoe.aol'); 22 | 23 | t.is(invalidEmail, false); 24 | t.is(invalidEmail2, false); 25 | t.is(invalidEmail3, false); 26 | t.is(invalidEmail4, false); 27 | t.is(invalidEmail5, false); 28 | }); 29 | -------------------------------------------------------------------------------- /packages/git-mob/src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { EOL } from 'node:os'; 2 | import { stripIndent } from 'common-tags'; 3 | import { type Author } from 'git-mob-core'; 4 | import updateNotifier from 'update-notifier'; 5 | import pkg from '../package.json'; 6 | 7 | const weekly = 1000 * 60 * 60 * 24 * 7; 8 | 9 | function runHelp() { 10 | const message = stripIndent` 11 | Usage 12 | $ git mob 13 | $ git solo 14 | $ git mob-print 15 | $ git add-coauthor "Coauthor Name" 16 | $ git suggest-coauthors [author name | author email] 17 | 18 | Options 19 | -h Prints usage information 20 | -v Prints current version 21 | -l Prints list of available co-authors 22 | -o Overwrite the main author 23 | -p Print path to .git-coauthors file 24 | 25 | Examples 26 | $ git mob jd # Set John Doe as co-author 27 | $ git mob jd am # Set John & Amy as co-authors 28 | $ git mob rkotze # Set co-author from GitHub username 29 | $ git mob -l # Show a list of all co-authors 30 | $ git mob -o jd # Will change main author to jd 31 | $ git solo # Dissipate the mob 32 | $ git mob-print # Prints git-mob template to stdout. Used for prepare-commit-msg hook. 33 | `; 34 | console.log(message); 35 | } 36 | 37 | function runAddCoauthorHelp() { 38 | const message = stripIndent` 39 | Usage 40 | $ git add-coauthor "Coauthor Name" 41 | Options 42 | -h Prints usage information 43 | Examples 44 | $ git add-coauthor jd "John Doe" johndoe@aol.org # adds John Doe to coauthors file 45 | `; 46 | console.log(message); 47 | } 48 | 49 | function runMobPrintHelp() { 50 | const message = stripIndent` 51 | Usage 52 | $ git mob-print 53 | Options 54 | -h Prints usage information 55 | -i Prints a comma separated list of selected co-author initials 56 | Examples 57 | $ git mob -i # Prints a list of selected co-authors initials (jd,bd) 58 | `; 59 | console.log(message); 60 | } 61 | 62 | function runSuggestCoauthorsHelp() { 63 | const message = stripIndent` 64 | Usage 65 | $ git suggest-coauthors [author name | author email] 66 | Options 67 | -h Prints usage information 68 | Example 69 | $ git suggest-coauthors # suggests coauthors who have contributed to this repo 70 | $ git suggest-coauthors rich # filter suggested coauthors 71 | `; 72 | console.log(message); 73 | } 74 | 75 | function runVersion() { 76 | console.log(pkg.version); 77 | } 78 | 79 | function checkForUpdates(intervalInMs = weekly) { 80 | updateNotifier({ pkg, updateCheckInterval: intervalInMs }).notify({ 81 | isGlobal: true, 82 | }); 83 | } 84 | 85 | function printList(list: Author[]) { 86 | console.log(list.map(a => `${a.key}, ${a.name}, ${a.email}`).join(EOL)); 87 | } 88 | 89 | function validateEmail(email: string) { 90 | const re = 91 | /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[(?:\d{1,3}\.){3}\d{1,3}])|((\w+\.)+[a-zA-Z]{2,}))$/; 92 | return re.test(email); 93 | } 94 | 95 | export { 96 | runHelp, 97 | runVersion, 98 | checkForUpdates, 99 | printList, 100 | validateEmail, 101 | runAddCoauthorHelp, 102 | runMobPrintHelp, 103 | runSuggestCoauthorsHelp, 104 | }; 105 | -------------------------------------------------------------------------------- /packages/git-mob/src/install/create-author-file.ts: -------------------------------------------------------------------------------- 1 | import { createCoAuthorsFile } from 'git-mob-core'; 2 | 3 | await createFileIfNotExist(); 4 | 5 | async function createFileIfNotExist() { 6 | try { 7 | if (await createCoAuthorsFile()) { 8 | console.log('Co-authors file created!'); 9 | } 10 | } catch (error) { 11 | console.log('Something went wrong adding a new co-authors file, error:', error); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/git-mob/src/solo.ts: -------------------------------------------------------------------------------- 1 | import minimist from 'minimist'; 2 | import { gitRevParse, getPrimaryAuthor, solo } from 'git-mob-core'; 3 | import { checkForUpdates, runHelp, runVersion } from './helpers.js'; 4 | 5 | checkForUpdates(); 6 | 7 | const argv = minimist(process.argv.slice(2), { 8 | alias: { 9 | h: 'help', 10 | v: 'version', 11 | }, 12 | }); 13 | 14 | if (argv.help) { 15 | runHelp(); 16 | process.exit(0); 17 | } 18 | 19 | if (argv.version) { 20 | runVersion(); 21 | process.exit(0); 22 | } 23 | 24 | const isGitRepo = await gitRevParse.insideWorkTree(); 25 | if (!isGitRepo) { 26 | console.error('Error: not a git repository'); 27 | process.exit(1); 28 | } 29 | 30 | await runSolo(); 31 | 32 | async function runSolo() { 33 | try { 34 | await solo(); 35 | await printAuthor(); 36 | } catch (error: unknown) { 37 | const soloError = error as Error; 38 | console.error(`Error: ${soloError.message}`); 39 | process.exit(1); 40 | } 41 | } 42 | 43 | async function printAuthor() { 44 | const author = await getPrimaryAuthor(); 45 | console.log(author?.toString()); 46 | } 47 | -------------------------------------------------------------------------------- /packages/git-mob/test-helpers/.gitconfig: -------------------------------------------------------------------------------- 1 | [user] 2 | name = Billy the Kid 3 | email = billy@example.com 4 | -------------------------------------------------------------------------------- /packages/git-mob/test-helpers/env.cjs: -------------------------------------------------------------------------------- 1 | const { spawnSync } = require('child_process'); 2 | const path = require('path'); 3 | 4 | const testHelperPath = path.join(process.cwd(), '/test-helpers'); 5 | 6 | process.env.GITMOB_COAUTHORS_PATH = path.join(testHelperPath, '.git-coauthors'); 7 | process.env.GITMOB_MESSAGE_PATH = path.join(testHelperPath, '.gitmessage'); 8 | process.env.GITMOB_GLOBAL_MESSAGE_PATH = path.join( 9 | testHelperPath, 10 | '.gitglobalmessage' 11 | ); 12 | process.env.NO_UPDATE_NOTIFIER = true; 13 | process.env.HOME = testHelperPath; 14 | process.env.GITMOB_TEST_ENV_FOLDER = './test-env'; 15 | process.env.GITMOB_TEST_HELPER_FOLDER = './test-helpers'; 16 | -------------------------------------------------------------------------------- /packages/git-mob/test-helpers/index.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { spawnSync } from 'child_process'; 4 | import { stripIndent } from 'common-tags'; 5 | import eol from 'eol'; 6 | 7 | function retainLocalAuthor() { 8 | const localName = exec('git config user.name').stdout.trim(); 9 | const localEmail = exec('git config user.email').stdout.trim(); 10 | return function () { 11 | if (localEmail && localName) { 12 | addAuthor(localName, localEmail); 13 | } else { 14 | removeAuthor(); 15 | } 16 | }; 17 | } 18 | 19 | function addAuthor(name, email) { 20 | exec(`git config user.name "${name}"`); 21 | exec(`git config user.email "${email}"`); 22 | } 23 | 24 | function removeAuthor() { 25 | exec('git config --unset user.name'); 26 | exec('git config --unset user.email'); 27 | } 28 | 29 | function addCoAuthor(name, email) { 30 | exec(`git config --global --add git-mob.co-author "${name} <${email}>"`); 31 | } 32 | 33 | function globalCommitTemplate() { 34 | const tempGlobal = path.join(process.cwd(), '.gitglobalmessage'); 35 | exec(`git config --global --add commit.template ${tempGlobal}`); 36 | } 37 | 38 | function removeCoAuthors() { 39 | removeGitConfigSection('git-mob'); 40 | } 41 | 42 | function unsetCommitTemplate() { 43 | removeGitConfigSection('commit'); 44 | } 45 | 46 | function hasGitConfigSection(section) { 47 | try { 48 | const config = exec(`git config --get-regexp ${section}`); 49 | 50 | return config.status !== 0 && config.stdout.trimEnd().length > 0; 51 | } catch { 52 | return false; 53 | } 54 | } 55 | 56 | function safelyRemoveGitConfigSection(section) { 57 | if (hasGitConfigSection(section)) { 58 | removeGitConfigSection(section); 59 | } 60 | } 61 | 62 | function removeGitConfigSection(section) { 63 | exec(`git config --global --remove-section ${section}`); 64 | } 65 | 66 | function setGitMessageFile() { 67 | try { 68 | const commitMessageTemplate = stripIndent` 69 | A commit title 70 | 71 | A commit body that goes into more detail. 72 | `; 73 | writeNewFile(process.env.GITMOB_MESSAGE_PATH, commitMessageTemplate); 74 | } catch (error) { 75 | console.warn('Failed to create .gitmessage file.', error.message); 76 | } 77 | } 78 | 79 | function writeNewFile(filePath, text) { 80 | try { 81 | fs.writeFileSync(filePath, text); 82 | } catch (error) { 83 | console.warn(`Failed to create ${filePath} file.`, error.message); 84 | } 85 | } 86 | 87 | function readGitMessageFile(noFile = false) { 88 | if (noFile && !fs.existsSync(process.env.GITMOB_MESSAGE_PATH)) { 89 | return undefined; 90 | } 91 | 92 | try { 93 | return eol.auto(fs.readFileSync(process.env.GITMOB_MESSAGE_PATH, 'utf8')); 94 | } catch (error) { 95 | console.warn('Failed to read .gitmessage file.', error.message); 96 | } 97 | } 98 | 99 | function setCoauthorsFile() { 100 | try { 101 | const coauthorsTemplate = stripIndent` 102 | { 103 | "coauthors": { 104 | "jd": { 105 | "name": "Jane Doe", 106 | "email": "jane@findmypast.com" 107 | }, 108 | "fb": { 109 | "name": "Frances Bar", 110 | "email": "frances-bar@findmypast.com" 111 | }, 112 | "ea": { 113 | "name": "Elliot Alderson", 114 | "email": "ealderson@findmypast.com" 115 | } 116 | } 117 | } 118 | `; 119 | fs.writeFileSync(process.env.GITMOB_COAUTHORS_PATH, coauthorsTemplate); 120 | } catch (error) { 121 | console.warn( 122 | 'Test Helpers: Failed to create global .git-coauthors file.', 123 | error.message 124 | ); 125 | } 126 | } 127 | 128 | function readCoauthorsFile() { 129 | try { 130 | return eol.auto(fs.readFileSync(process.env.GITMOB_COAUTHORS_PATH, 'utf8')); 131 | } catch (error) { 132 | console.warn('Failed to read .git-coauthors file.', error.message); 133 | } 134 | } 135 | 136 | function deleteCoauthorsFile() { 137 | deleteFile(process.env.GITMOB_COAUTHORS_PATH); 138 | } 139 | 140 | function deleteGitMessageFile() { 141 | const filePath = process.env.GITMOB_MESSAGE_PATH; 142 | deleteFile(filePath); 143 | } 144 | 145 | function deleteFile(filePath) { 146 | try { 147 | if (fs.existsSync(filePath)) { 148 | fs.unlinkSync(filePath); 149 | } 150 | } catch (error) { 151 | console.warn( 152 | `Test helpers: Failed to delete global ${filePath} file.`, 153 | error.message 154 | ); 155 | } 156 | } 157 | 158 | function exec(command) { 159 | const spawnString = spawnSync(command, { encoding: 'utf8', shell: true }); 160 | 161 | if (spawnString.status !== 0) { 162 | throw new Error(`GitMob test helper: "${command}" 163 | stdout: ${spawnString.stdout} 164 | --- 165 | stderr: ${spawnString.stderr}`); 166 | } 167 | 168 | return spawnString; 169 | } 170 | 171 | const testDir = process.env.GITMOB_TEST_ENV_FOLDER; 172 | const coAuthorsFilename = '.git-coauthors'; 173 | 174 | function setLocalCoauthorsFile() { 175 | try { 176 | const coauthorsTemplate = stripIndent` 177 | { 178 | "coauthors": { 179 | "dd": { 180 | "name": "Din Djarin", 181 | "email": "din@mando.com" 182 | }, 183 | "bk": { 184 | "name": "Bo-Katan Kryze", 185 | "email": "bo-katan@dwatch.com" 186 | } 187 | } 188 | } 189 | `; 190 | fs.writeFileSync( 191 | path.join(process.env.HOME, testDir, coAuthorsFilename), 192 | coauthorsTemplate 193 | ); 194 | } catch (error) { 195 | console.warn( 196 | 'Test Helpers: Failed to create local .git-coauthors file.', 197 | error.message 198 | ); 199 | } 200 | } 201 | 202 | function deleteLocalCoauthorsFile() { 203 | const filePath = path.join(process.env.HOME, testDir, coAuthorsFilename); 204 | try { 205 | if (fs.existsSync(filePath)) { 206 | fs.unlinkSync(filePath); 207 | } 208 | } catch (error) { 209 | console.warn( 210 | 'Test helpers: Failed to delete local .git-coauthors file.', 211 | error.message 212 | ); 213 | } 214 | } 215 | 216 | function setup() { 217 | process.chdir(process.env.GITMOB_TEST_HELPER_FOLDER); 218 | try { 219 | if (!fs.existsSync(testDir)) { 220 | fs.mkdirSync(testDir); 221 | } 222 | } catch (error) { 223 | console.log(error); 224 | } 225 | writeNewFile(process.env.GITMOB_GLOBAL_MESSAGE_PATH, ''); 226 | globalCommitTemplate(); 227 | process.chdir(testDir); 228 | exec('git init -q'); 229 | } 230 | 231 | function tearDown() { 232 | safelyRemoveGitConfigSection('commit'); 233 | deleteFile(process.env.GITMOB_GLOBAL_MESSAGE_PATH); 234 | process.chdir(process.env.HOME); 235 | /* eslint n/no-unsupported-features/node-builtins: 0 */ 236 | fs.rmSync(testDir, { recursive: true }); 237 | } 238 | 239 | export { 240 | addAuthor, 241 | removeAuthor, 242 | addCoAuthor, 243 | removeCoAuthors, 244 | unsetCommitTemplate, 245 | safelyRemoveGitConfigSection, 246 | setGitMessageFile, 247 | readGitMessageFile, 248 | setCoauthorsFile, 249 | setLocalCoauthorsFile, 250 | readCoauthorsFile, 251 | deleteGitMessageFile, 252 | deleteCoauthorsFile, 253 | deleteLocalCoauthorsFile, 254 | exec, 255 | retainLocalAuthor, 256 | setup, 257 | tearDown, 258 | }; 259 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2017" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | "lib": [ 16 | "ES2021" 17 | ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, 18 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 19 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 20 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 21 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 22 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 23 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 24 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 25 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 26 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 27 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 28 | 29 | /* Modules */ 30 | "module": "NodeNext" /* Specify what module code is generated. */, 31 | // "rootDir": "src" /* Specify the root folder within your source files. */, 32 | // "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 33 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 34 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 35 | "rootDirs": [ 36 | "src", 37 | "test-helpers" 38 | ] /* Allow multiple folders to be treated as one when resolving modules. */, 39 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 40 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 41 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 42 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 43 | "resolveJsonModule": true /* Enable importing .json files. */, 44 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 45 | 46 | /* JavaScript Support */ 47 | "allowJs": true /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */, 48 | // "checkJs": false /* Enable error reporting in type-checked JavaScript files. */, 49 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 50 | 51 | /* Emit */ 52 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 53 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 54 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 55 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 56 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 57 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 58 | // "removeComments": true, /* Disable emitting comments. */ 59 | // "noEmit": true /* Disable emitting files from a compilation. */, 60 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 61 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 62 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 63 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 64 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 65 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 66 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 67 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 68 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 69 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 70 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 71 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 72 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 73 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 74 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 75 | 76 | /* Interop Constraints */ 77 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 78 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 79 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 80 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 81 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 82 | 83 | /* Type Checking */ 84 | "strict": true /* Enable all strict type-checking options. */, 85 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 86 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 87 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 88 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 89 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 90 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 91 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 92 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 93 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 94 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 95 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 96 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 97 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 98 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 99 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 100 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 101 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 102 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 103 | 104 | /* Completeness */ 105 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 106 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 107 | }, 108 | "include": ["packages/**/*.ts"] 109 | } 110 | --------------------------------------------------------------------------------