mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 02:30:39 +01:00
* Load the Capy crew skills only on Capy machines (GST-1833) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Add changelog entry for Capy-only crew skills (GST-1833) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Move the Capy-only crew tooling into crew/ and run its tests only when needed (GST-1833) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Test that the Capy launch file puts crew/bin on PATH Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Count any ~/.capy as a Capy machine, so a Mac running Capy keeps /captain Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Fix the tests and fixtures that broke when crew tooling moved into crew/ Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Plan every crew test when a pull request's merge-base can't be read Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Add a test that checks each crew-only module left the root tree for crew/ Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Run crew tests on pull requests that change bin/ commands or workflows they drive Review-Finding: p1/specialist-batched#1 reader=testing Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep running every crew test on every pull request; the skip comes in a later change Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep copied installs and older release bisects working after the crew/ move Setup now copies crew/lib into every host's runtime root beside bin and lib, so Windows installs (which copy instead of linking) can still start gstack-ship-receipt, gstack-pregate and the other commands that import it. The release bisect runs the train-gate script from wherever the checked-out commit has it, so a bisect over commits from before the move still gets a verdict. Review-Finding: p1/outside-voice#1 reader=outside-voice Review-Finding: p1/outside-voice#2 reader=outside-voice Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Say in the changelog that Windows installs keep working after the crew/ move Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Point the harness guide's merge-queue rows at crew/lib/ where those modules moved Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Point the plain-title test at the wave module's new home in crew/ (GST-1833) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
97 lines
6.1 KiB
TypeScript
Executable File
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)));
|