Files
gstack/bin/gstack-coauthor-trailers
T
garrytanandcapy-ai[bot] 47e693b304 fix: hold unreleased policy keys newer than any visible heading; pin trailer placement
The CHANGELOG-heading test asked for the exact next release after the newest
heading, which a depth-1 CI clone (no origin/main) computes from the branch
alone, so it went red there. It now requires an unreleased key's version to be
newer than every heading the checkout can see, which holds at depth 1, on the
stamped train tree and on main; the ship-policy comment says who moves the
constant when the stamp lands under another number.

gstack-coauthor-trailers pins git interpret-trailers' placement on the command
line (--no-divider so a --- body line is not a divider, --where end,
--if-exists addIfDifferentNeighbor, trailer.separators=:, -- before the path)
so a user's trailer.* config cannot replace or drop the existing co-author.

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

97 lines
6.1 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 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 [<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;
}
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/<ref> 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<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) {
// 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)));