Files
gstack/bin/gstack-coauthor-trailers
T
garrytanandcapy-ai[bot] 2d73fadd56 feat(ship): version-less repos, docs-audit record and caps, the frozen-scope paid case, and the review-loop follow-ups
Follow-up to the review-loop convergence PR: Step 12 and the CHANGELOG step skip cleanly in a repository with no version source (receipt version "none"); the receipt records the docs audit outcome and the owner exception (DOCS_AUDIT_BLOCKED without one) and a policy may declare doc caps (gstack-ship-receipt docs-caps); docs/unattended.md names the READY evidence and the cross-family outside voice; the ship-frozen-scope-convergence behaviour case runs the generated Step 9.4 under review_scope frozen through the real binaries; carry probes git >= 2.39 (RECEIPT_GIT_TOO_OLD); defer passes the repository to gh repo view positionally and names its search limit; late defers count without being passes; reviewers diff from RANGE_BASE on pass 2+; the cap check runs before edits; --review-fanout batches specialists by free slots; read --check-prose compares quoted counters with the receipt (RECEIPT_PROSE_MISMATCH); checklist rule ids prereg-with-results and test-keyboard-count-loop; gstack-coauthor-trailers.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-26 20:49:56 +00:00

86 lines
4.8 KiB
TypeScript
Executable File

#!/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 [<range> | --base <ref>] [--message <file>] [--exclude <email>]... [--json]
*
* Lists every distinct author of the commits in <range> (default
* `<base>..HEAD`, base `origin/HEAD`'s branch) other than this checkout's own
* committer identity and any --exclude, and prints one
* `Co-authored-by: Name <email>` line per author in first-appearance order.
* With --message <file>, the trailers the file lacks are appended after a
* blank line (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 [<range> | --base <ref>] [--message <file>] [--exclude <email>]... [--json]
Defaults: range <base>..HEAD with base origin/HEAD's branch; this checkout's committer identity is never a co-author.
--message <file> 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<string>();
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;
}
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) {
const ref = base ?? resolveBaseBranch(root.out);
range = `${/^[0-9a-f]{7,64}$/i.test(ref) || ref.includes('/') ? ref : `origin/${ref}`}..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', 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<string, { name: string; email: string }>();
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) {
const body = text.replace(/\s+$/, '');
fs.writeFileSync(message, (body ? body + '\n\n' : '') + appended.join('\n') + '\n');
}
}
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)));