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;
+}