diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..ffc3e66 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,13 @@ +{ + "root": true, + + "extends": "@ljharb/eslint-config/node/20", + + "rules": { + "complexity": "off", + "max-lines-per-function": "off", + "max-statements": "off", + "no-magic-numbers": "off", + "sort-keys": "off", + }, +} diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..4cb57dc --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: [ljharb] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: npm/@ljharb/coauthors +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/workflows/node-pretest.yml b/.github/workflows/node-pretest.yml new file mode 100644 index 0000000..765edf7 --- /dev/null +++ b/.github/workflows/node-pretest.yml @@ -0,0 +1,7 @@ +name: 'Tests: pretest/posttest' + +on: [pull_request, push] + +jobs: + tests: + uses: ljharb/actions/.github/workflows/pretest.yml@main diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml new file mode 100644 index 0000000..77e48f0 --- /dev/null +++ b/.github/workflows/node.yml @@ -0,0 +1,11 @@ +name: 'Tests: node.js >= 22.4' + +on: [pull_request, push] + +jobs: + tests: + uses: ljharb/actions/.github/workflows/node.yml@main + with: + range: '>= 22.4' + type: minors + command: npm run tests-only diff --git a/.github/workflows/rebase.yml b/.github/workflows/rebase.yml new file mode 100644 index 0000000..b9e1712 --- /dev/null +++ b/.github/workflows/rebase.yml @@ -0,0 +1,9 @@ +name: Automatic Rebase + +on: [pull_request_target] + +jobs: + _: + uses: ljharb/actions/.github/workflows/rebase.yml@main + secrets: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/require-allow-edits.yml b/.github/workflows/require-allow-edits.yml new file mode 100644 index 0000000..7b842f8 --- /dev/null +++ b/.github/workflows/require-allow-edits.yml @@ -0,0 +1,12 @@ +name: Require “Allow Edits” + +on: [pull_request_target] + +jobs: + _: + name: "Require “Allow Edits”" + + runs-on: ubuntu-latest + + steps: + - uses: ljharb/require-allow-edits@main diff --git a/.gitignore b/.gitignore index 5829e8e..5d47dca 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,5 @@ dist npm-shrinkwrap.json package-lock.json yarn.lock + +.npmignore diff --git a/README.md b/README.md index 03de565..6395555 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,52 @@ -# coauthors +# @ljharb/coauthors [![Version Badge][npm-version-svg]][package-url] + +[![github actions][actions-image]][actions-url] +[![coverage][codecov-image]][codecov-url] +[![License][license-image]][license-url] +[![Downloads][downloads-image]][downloads-url] + +[![npm badge][npm-badge-png]][package-url] + A cli to generate a complete git co-authors list, including existing co-authors, for use in a commit message. + +## Usage + +```sh +npx @ljharb/coauthors # if not installed + +coauthors # if installed and in the PATH +``` + +```sh +$ coauthors --help +Usage: +commit-to-co-authors + +`remote` defaults to `origin`. +``` + +## Install + +``` +npm install --save-dev @ljharb/coauthors +``` + +## License + +MIT + +[package-url]: https://npmjs.org/package/@ljharb/coauthors +[npm-version-svg]: https://versionbadg.es/ljharb/coauthors.svg +[deps-svg]: https://david-dm.org/ljharb/coauthors.svg +[deps-url]: https://david-dm.org/ljharb/coauthors +[dev-deps-svg]: https://david-dm.org/ljharb/coauthors/dev-status.svg +[dev-deps-url]: https://david-dm.org/ljharb/coauthors#info=devDependencies +[npm-badge-png]: https://nodei.co/npm/@ljharb/coauthors.png?downloads=true&stars=true +[license-image]: https://img.shields.io/npm/l/@ljharb/coauthors.svg +[license-url]: LICENSE +[downloads-image]: https://img.shields.io/npm/dm/@ljharb/coauthors.svg +[downloads-url]: https://npm-stat.com/charts.html?package=@ljharb/coauthors +[codecov-image]: https://codecov.io/gh/ljharb/coauthors/branch/main/graphs/badge.svg +[codecov-url]: https://app.codecov.io/gh/ljharb/coauthors/ +[actions-image]: https://img.shields.io/endpoint?url=https://github-actions-badge-u3jn4tfpocch.runkit.sh/ljharb/coauthors +[actions-url]: https://github.com/ljharb/coauthors/actions diff --git a/bin.mjs b/bin.mjs new file mode 100755 index 0000000..fb828cc --- /dev/null +++ b/bin.mjs @@ -0,0 +1,52 @@ +#! /usr/bin/env node + +import { readFile } from 'fs/promises'; +import path from 'path'; + +import validateRemote from './validateRemote.mjs'; +import getResults from './results.mjs'; + +import pargs from './pargs.mjs'; + +async function getHelpText() { + return `${await readFile(path.join(import.meta.dirname, './help.txt'), 'utf-8')}`; +} + +const { + values: { help }, + positionals, + errors, +} = await pargs( + import.meta.filename, + { + options: { + help: { type: 'boolean' }, + }, + allowPositionals: 1, + }, +); + +const remote = validateRemote(positionals[0] ?? 'origin'); + +if (typeof remote !== 'string') { + errors.push(remote.error); +} + +if (help || errors.length > 0) { + const helpText = await getHelpText(); + if (errors.length === 0) { + console.log(helpText); + } else { + console.error(`${helpText}${errors.length === 0 ? '' : '\n'}`); + + process.exitCode ||= parseInt('1'.repeat(errors.length), 2); + errors.forEach((error) => console.error(error)); + } + + process.exit(); +} + +// eslint-disable-next-line no-extra-parens +const results = Array.from(getResults(/** @type {string} */ (remote)), (x) => `Co-authored-by: ${x}`); + +console.log(results.join('\n')); diff --git a/getDefaultBranch.mjs b/getDefaultBranch.mjs new file mode 100644 index 0000000..3872fad --- /dev/null +++ b/getDefaultBranch.mjs @@ -0,0 +1,10 @@ +import { execSync } from 'child_process'; + +/** @type {(remote: string) => string} */ +export default function getDefaultBranch(remote) { + const gitResult = `${execSync(`git rev-parse --abbrev-ref ${remote}/HEAD`)}`.trim(); + + const match = (/\/(?\S+)$/).exec(gitResult); + + return match?.groups?.defaultBranch ?? 'main'; +} diff --git a/help.txt b/help.txt new file mode 100644 index 0000000..402f2cc --- /dev/null +++ b/help.txt @@ -0,0 +1,4 @@ +Usage: +commit-to-co-authors + +`remote` defaults to `origin`. diff --git a/package.json b/package.json index dac6cc1..c9689af 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,23 @@ "name": "@ljharb/coauthors", "version": "0.0.0", "description": "A cli to generate a complete git co-authors list, including existing co-authors, for use in a commit message.", + "bin": "./bin.mjs", + "exports": { + "./package.json": "./package.json" + }, + "sideEffects": false, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "prepack": "npmignore --auto --commentLines=autogenerated", + "prepublish": "not-in-publish || npm run prepublishOnly", + "prepublishOnly": "safe-publish-latest", + "lint": "eslint --ext=js,mjs .", + "postlint": "tsc", + "pretest": "npm run lint", + "tests-only": "c8 tape 'test/**/*.*js'", + "test": "npm run tests-only", + "posttest": "aud --production", + "version": "auto-changelog && git add CHANGELOG.md", + "postversion": "auto-changelog && git add CHANGELOG.md && git commit --no-edit --amend && git tag -f \"v$(node -e \"console.log(require('./package.json').version)\")\"" }, "repository": { "type": "git", @@ -21,5 +36,54 @@ "bugs": { "url": "https://github.com/ljharb/coauthors/issues" }, - "homepage": "https://github.com/ljharb/coauthors#readme" + "homepage": "https://github.com/ljharb/coauthors#readme", + "dependencies": { + "commit-to-co-authors": "^0.1.0" + }, + "engines": { + "node": ">= 22.4" + }, + "devDependencies": { + "@ljharb/eslint-config": "^21.1.1", + "@ljharb/tsconfig": "^0.2.0", + "@types/node": "^20.14.10", + "@types/tape": "^5.6.4", + "aud": "^2.0.4", + "auto-changelog": "^2.4.0", + "c8": "^10.1.2", + "eslint": "^8.8.0", + "esmock": "^2.6.6", + "in-publish": "^2.0.1", + "npmignore": "^0.3.1", + "safe-publish-latest": "^2.0.0", + "tape": "^5.8.1", + "typescript": "next" + }, + "c8": { + "all": true, + "reporters": [ + "html", + "text", + "lcov" + ], + "exclude": [ + "coverage", + "./pargs.mjs" + ] + }, + "auto-changelog": { + "output": "CHANGELOG.md", + "template": "keepachangelog", + "unreleased": false, + "commitLimit": false, + "backfillLimit": false, + "hideCredit": true + }, + "publishConfig": { + "ignore": [ + ".github/workflows", + ".eslintrc", + "test" + ] + } } diff --git a/pargs.mjs b/pargs.mjs new file mode 100644 index 0000000..98cc602 --- /dev/null +++ b/pargs.mjs @@ -0,0 +1,119 @@ +import { parseArgs } from 'util'; +import { realpathSync } from 'fs'; + +/** @typedef {import('util').ParseArgsConfig} ParseArgsConfig */ + +/** @typedef {(Error | TypeError) & { code: 'ERR_PARSE_ARGS_UNKNOWN_OPTION' | 'ERR_PARSE_ARGS_INVALID_OPTION_VALUE' | 'ERR_INVALID_ARG_TYPE' | 'ERR_INVALID_ARG_VALUE' | 'ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL'}} ParseArgsError */ + +/** @type {(e: unknown) => e is ParseArgsError} */ +function isParseArgsError(e) { + return !!e + && typeof e === 'object' + && 'code' in e + && ( + e.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION' + || e.code === 'ERR_PARSE_ARGS_INVALID_OPTION_VALUE' + || e.code === 'ERR_INVALID_ARG_TYPE' + || e.code === 'ERR_INVALID_ARG_VALUE' + || e.code === 'ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL' + ); +} +/** @typedef {Omit & { allowPositionals?: boolean | number }} PargsConfig */ + +/** @type {(entrypointPath: ImportMeta['filename'], obj: PargsConfig) => Promise<{ errors: string[] } & ReturnType>} */ +export default async function pargs(entrypointPath, obj) { + const argv = process.argv.flatMap((arg) => { + try { + const realpathedArg = realpathSync(arg); + if ( + realpathedArg === process.execPath + || realpathedArg === entrypointPath + ) { + return []; + } + } catch (e) { /**/ } + return arg; + }); + + if ('help' in obj) { + throw new TypeError('The "help" option is reserved'); + } + + /** @type {ParseArgsConfig & { tokens: true }} */ + const newObj = { + args: argv, + ...obj, + options: { + ...obj.options, + help: { + default: false, + type: 'boolean', + }, + }, + tokens: true, + // @ts-expect-error blocked on @types/node v22 + allowNegative: true, + allowPositionals: typeof obj.allowPositionals !== 'undefined', + strict: true, + }; + + const errors = []; + + try { + const { tokens, ...results } = parseArgs(newObj); + + const posCount = typeof obj.allowPositionals === 'number' ? obj.allowPositionals : obj.allowPositionals ? Infinity : 0; + if (results.positionals.length > posCount) { + errors.push(`Only ${posCount} positional arguments allowed; got ${results.positionals.length}`); + } + + /** @typedef {Extract} OptionToken */ + const optionTokens = tokens.filter(/** @type {(token: typeof tokens[number]) => token is OptionToken} */ (token) => token.kind === 'option'); + + const bools = obj.options ? Object.entries(obj.options).filter(([, { type }]) => type === 'boolean') : []; + const boolMap = new Map(bools); + for (let i = 0; i < optionTokens.length; i += 1) { + const { name, value } = optionTokens[i]; + if (boolMap.has(name) && typeof value !== 'boolean' && typeof value !== 'undefined') { + errors.push(`Error: Argument --${name} must be a boolean`); + } + } + + const passedArgs = new Set(optionTokens.map(({ name, rawName }) => (rawName === '--no-help' ? rawName : name))); + + const groups = Object.groupBy(passedArgs, (x) => x.replace(/^no-/, '')); + for (let i = 0; i < bools.length; i++) { + const [key] = bools[i]; + if ((groups[key]?.length ?? 0) > 1) { + errors.push(`Error: Arguments \`--${key}\` and \`--no-${key}\` are mutually exclusive`); + } + if (passedArgs.has(`no-${key}`)) { + // @ts-expect-error + results.values[key] = !results.values[`no-${key}`]; + } + // @ts-expect-error + delete results.values[`no-${key}`]; + } + + const knownOptions = obj.options ? Object.keys(obj.options) : []; + const unknownArgs = knownOptions.length > 0 ? passedArgs.difference(new Set(knownOptions)) : passedArgs; + if (unknownArgs.size > 0) { + errors.push(`Error: Unknown option(s): ${Array.from(unknownArgs, (x) => `\`${x}\``).join(', ')}`); + } + + return { + errors, + ...results, + ...obj.tokens && { tokens }, + }; + } catch (e) { + if (isParseArgsError(e)) { + return { + values: {}, + positionals: [], + errors: [`Error: ${e.message}`], + }; + } + throw e; + } +} diff --git a/results.mjs b/results.mjs new file mode 100644 index 0000000..c822dd3 --- /dev/null +++ b/results.mjs @@ -0,0 +1,23 @@ +import { execSync } from 'child_process'; + +import { commitToCoAuthors } from 'commit-to-co-authors'; + +import getDefaultBranch from './getDefaultBranch.mjs'; + +/** @type {(remote: string) => Set} */ +export default function getResults(remote) { + const defaultBranch = getDefaultBranch(remote); + + const logCommitters = `${execSync(`git shortlog -sne ${remote}/${defaultBranch}..HEAD`)}`; + const mappedLogCommitters = logCommitters.matchAll(/\t(?.*)$/gm) + // @ts-expect-error waiting on https://github.com/microsoft/TypeScript/pull/58222 + .map(/** @type {(m: { groups: { author: string } }) => string} */ ({ groups: { author } }) => author); + /** @type {Set} */ + const fromLogs = new Set(mappedLogCommitters); + + const logText = `${execSync(`git log --no-expand-tabs --pretty=full ${remote}/${defaultBranch}..HEAD`)}`; + /** @type {Set} */ + const fromMsgs = new Set(commitToCoAuthors(logText).map(({ name, email }) => `${name} <${email}>`)); + + return fromMsgs.union(fromLogs); +} diff --git a/test/getDefaultBranch.mjs b/test/getDefaultBranch.mjs new file mode 100644 index 0000000..3bb0683 --- /dev/null +++ b/test/getDefaultBranch.mjs @@ -0,0 +1,43 @@ +import test from 'tape'; +import esmock from 'esmock'; + +/** @type {Record} */ +const fakeRemotes = { + // @ts-expect-error TS can't handle null objects + __proto__: null, + origin: 'mainiac', + upstream: 'def', +}; + +test('getDefaultBranch', async (t) => { + const { default: getDefaultBranch } = await esmock( + '../getDefaultBranch.mjs', + { + child_process: { // eslint-disable-line camelcase + /** @type {(cmd: string) => Buffer} */ + execSync(cmd) { + t.match(cmd, /^git rev-parse --abbrev-ref [^ ]+\/HEAD$/, 'command is as expected'); + + const remote = cmd.slice('git rev-parse --abbrev-ref '.length, -'/HEAD'.length); + + if (!(remote in fakeRemotes)) { + // eslint-disable-next-line no-throw-literal + throw `Uncaught Error: Command failed: git rev-parse --abbrev-ref ${remote}/HEAD +fatal: ambiguous argument '${remote}/HEAD': unknown revision or path not in the working tree.`; + } + + return Buffer.from(`${remote}/${fakeRemotes[remote]}\n`); + }, + }, + }, + ); + + t.equal(getDefaultBranch('origin'), 'mainiac', 'mocked remote `origin` exists'); + t.equal(getDefaultBranch('upstream'), 'def', 'mocked remote `upstream` exists'); + + t.throws( + () => getDefaultBranch('nonexistent'), + /Uncaught Error: Command failed: git rev-parse --abbrev-ref nonexistent\/HEAD/, + 'nonexistent remote throws', + ); +}); diff --git a/test/results.mjs b/test/results.mjs new file mode 100644 index 0000000..8a0bee9 --- /dev/null +++ b/test/results.mjs @@ -0,0 +1,70 @@ +import test from 'tape'; +import esmock from 'esmock'; + +test('getResults', async (t) => { + const logText = ''; + + const { default: getResults } = await esmock('../results.mjs', { + '../getDefaultBranch.mjs': { + /** @type {(remote: string) => string} */ + default(remote) { + if (remote !== 'destination') { + // eslint-disable-next-line no-throw-literal + throw `Uncaught Error: Command failed: git rev-parse --abbrev-ref ${remote}/HEAD + fatal: ambiguous argument '${remote}/HEAD': unknown revision or path not in the working tree.`; + } + return 'mainiac'; + }, + }, + 'commit-to-co-authors': { + /** @type {(commit: string) => ReturnType} */ + commitToCoAuthors(commit) { + t.equal(commit, logText, 'git log text is sent to commit-to-co-authors as expected'); + + return [ + { + name: 'foo', + email: 'foo@example.com', + }, + { + name: 'bar', + email: 'bar@example.com', + }, + { + name: 'baz', + email: 'baz@example.com', + }, + ]; + }, + }, + child_process: { // eslint-disable-line camelcase + /** @type {(cmd: string) => Buffer} */ + execSync(cmd) { + t.match(cmd, /^git (?:shortlog|log) .*mainiac\.\.HEAD$/, `command is as expected (${cmd})`); + + if (cmd.startsWith('git log ')) { + return Buffer.from(logText); + } + return Buffer.from(` + 11 quux + 3 baz +`); + }, + }, + }); + + t.throws( + () => getResults('nonexistent'), + 'nonexistent remote throws', + ); + + t.deepEqual( + getResults('destination'), + new Set([ + 'foo ', + 'bar ', + 'baz ', + 'quux ', + ]), + ); +}); diff --git a/test/validateRemote.mjs b/test/validateRemote.mjs new file mode 100644 index 0000000..dc3f6b8 --- /dev/null +++ b/test/validateRemote.mjs @@ -0,0 +1,47 @@ +import test from 'tape'; +import esmock from 'esmock'; + +import validateRemote from '../validateRemote.mjs'; + +test('validateRemote', async (t) => { + t.deepEqual( + validateRemote(''), + { __proto__: null, error: 'Remote name must not be empty, nor contain spaces.' }, + 'empty string', + ); + + t.deepEqual( + validateRemote('foo bar'), + { __proto__: null, error: 'Remote name must not be empty, nor contain spaces.' }, + 'has spaces', + ); + + const mockedValidateRemote = await esmock('../validateRemote.mjs', { + child_process: { // eslint-disable-line camelcase + /** @type {(cmd: string) => Buffer} */ + execSync(cmd) { + t.equal(cmd, 'git remote', 'command is as expected'); + + return Buffer.from('origin\nupstream\n'); + }, + }, + }); + + t.deepEqual( + mockedValidateRemote('foo'), + { __proto__: null, error: 'Remote `foo` does not exist; check `git remote` output' }, + 'nonexistent remote does not exist', + ); + + t.equal( + mockedValidateRemote('origin'), + 'origin', + 'existing remote origin exists', + ); + + t.equal( + mockedValidateRemote('upstream'), + 'upstream', + 'existing remote upstream exists', + ); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ba456cb --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@ljharb/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "nodenext", + }, + "exclude": [ + "coverage" + ] +} diff --git a/validateRemote.mjs b/validateRemote.mjs new file mode 100644 index 0000000..8448d4d --- /dev/null +++ b/validateRemote.mjs @@ -0,0 +1,22 @@ +import { execSync } from 'child_process'; + +/** @type {(remote: string, cwd?: string) => string | { __proto__: null, error: string }} */ +export default function validateRemote(remote, cwd = process.cwd()) { + if (remote === '' || remote.includes(' ')) { + return { + __proto__: null, + error: 'Remote name must not be empty, nor contain spaces.', + }; + } + + const allRemotes = `${execSync('git remote', { cwd })}`.split('\n'); + + if (!allRemotes.includes(remote)) { + return { + __proto__: null, + error: `Remote \`${remote}\` does not exist; check \`git remote\` output`, + }; + } + + return remote; +}