#!/usr/bin/env -S bun --no-env-file --config=/dev/null /** * gstack-coauthor-trailers — the contributor-credit trailers a ported branch owes * (docs/review-policy.md#porting-a-contributors-work). * * gstack-coauthor-trailers [ | --base ] [--message ] [--exclude ]... [--json] * * Lists every distinct author of the commits in (default * `..HEAD`, base `origin/HEAD`'s branch) other than this checkout's own * committer identity and any --exclude, and prints one * `Co-authored-by: Name ` line per author in first-appearance order. * With --message , the trailers the file lacks join its trailer block * (after a blank line when it has none; idempotent; existing trailers are * matched by email, case folded), so a port's own fix commits keep the * original author's credit. * Exit: 0 printed · 1 the range does not resolve · 2 usage. */ import * as fs from 'node:fs'; import { spawnSync } from 'node:child_process'; import { EXIT } from '../lib/headless-artifacts'; import { resolveBaseBranch } from '../lib/ship-policy'; const HELP = `gstack-coauthor-trailers — Co-authored-by trailers for the authors of a ported range gstack-coauthor-trailers [ | --base ] [--message ] [--exclude ]... [--json] Defaults: range ..HEAD with base origin/HEAD's branch; this checkout's committer identity is never a co-author. --message appends the missing trailers to the commit message file (idempotent). Exit 0 · 1 bad range · 2 usage.`; function git(args: string[]): { ok: boolean; out: string; err: string } { const r = spawnSync('git', args, { encoding: 'utf8', timeout: 60_000 }); return { ok: r.status === 0, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() }; } function main(argv: string[]): number { if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(HELP + '\n'); return EXIT.ok; } const excludes = new Set(); let base: string | undefined; let message: string | undefined; let range: string | undefined; for (let i = 0; i < argv.length; i++) { const arg = argv[i]!; if (arg === '--base' || arg === '--message' || arg === '--exclude') { const value = argv[++i]; if (!value) { process.stderr.write(`gstack-coauthor-trailers: ${arg} needs a value\n${HELP}\n`); return EXIT.usage; } if (arg === '--base') base = value; else if (arg === '--message') message = value; else excludes.add(value.toLowerCase()); } else if (arg === '--json') continue; else if (arg.startsWith('-')) { process.stderr.write(`gstack-coauthor-trailers: unknown flag ${arg}\n${HELP}\n`); return EXIT.usage; } else if (range) { process.stderr.write(`gstack-coauthor-trailers: one range only\n${HELP}\n`); return EXIT.usage; } else range = arg; } if ((range ?? base ?? '').startsWith('-')) { process.stderr.write(`gstack-coauthor-trailers: a range or base cannot start with -\n${HELP}\n`); return EXIT.usage; } const root = git(['rev-parse', '--show-toplevel']); if (!root.ok) { process.stderr.write('gstack-coauthor-trailers: not inside a git repository\n'); return EXIT.fail; } if (!range) { // The base branch name may itself hold a slash (release/1.2), so "has a slash" is not "already qualified": // origin/ wins when it resolves; a sha or a ref that only resolves as given is used as given. const ref = base ?? resolveBaseBranch(root.out); const qualified = /^[0-9a-f]{7,64}$/i.test(ref) || ref.startsWith('origin/') ? ref : git(['rev-parse', '--verify', '--quiet', `origin/${ref}^{commit}`]).ok ? `origin/${ref}` : ref; range = `${qualified}..HEAD`; } const self = /<([^>]*)>/.exec(git(['var', 'GIT_COMMITTER_IDENT']).out)?.[1]?.toLowerCase(); if (self) excludes.add(self); const log = git(['log', '--no-merges', '--reverse', '--format=%an%x1f%ae', '--end-of-options', range]); if (!log.ok) { process.stderr.write(`gstack-coauthor-trailers: cannot read ${range}: ${log.err.split('\n')[0] ?? ''}\n`); return EXIT.fail; } const authors = new Map(); for (const line of log.out.split('\n').filter(Boolean)) { const [name = '', email = ''] = line.split('\x1f'); const key = email.toLowerCase(); if (!key || excludes.has(key) || authors.has(key)) continue; authors.set(key, { name: name.trim(), email: email.trim() }); } const trailers = [...authors.values()].map(a => `Co-authored-by: ${a.name} <${a.email}>`); let appended: string[] = []; if (message !== undefined) { const text = fs.existsSync(message) ? fs.readFileSync(message, 'utf8') : ''; const present = new Set([...text.matchAll(/^co-authored-by:.*<([^>]+)>\s*$/gim)].map(m => m[1]!.toLowerCase())); appended = [...authors.entries()].filter(([key]) => !present.has(key)).map(([, a]) => `Co-authored-by: ${a.name} <${a.email}>`); if (appended.length) { // git places the lines in the message's trailer block (joining an existing block, above comment // lines, CRLF kept), so an existing co-author keeps GitHub's attribution; a separate paragraph would not. // Placement is pinned on the command line: a `---` body line is not a divider, and the user's trailer.* // config cannot replace or drop the existing lines. if (!fs.existsSync(message)) fs.writeFileSync(message, ''); const placed = git(['-c', 'trailer.separators=:', 'interpret-trailers', '--in-place', '--no-divider', '--where', 'end', '--if-exists', 'addIfDifferentNeighbor', '--if-missing', 'add', ...appended.flatMap(t => ['--trailer', t]), '--', message]); if (!placed.ok) { process.stderr.write(`gstack-coauthor-trailers: cannot append to ${message}: ${placed.err.split('\n')[0] ?? ''}\n`); return EXIT.fail; } } } if (argv.includes('--json')) process.stdout.write(JSON.stringify({ range, authors: [...authors.values()], trailers, appended }) + '\n'); else process.stdout.write(trailers.length ? trailers.join('\n') + '\n' : ''); process.stderr.write(`COAUTHORS: ${authors.size} in ${range}${message !== undefined ? `, ${appended.length} appended to ${message}` : ''}\n`); return EXIT.ok; } process.exit(main(process.argv.slice(2)));