Files
gstack/test/gstack-coauthor-trailers.test.ts
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

79 lines
4.5 KiB
TypeScript

/**
* Free contracts of gstack-coauthor-trailers: distinct authors of a range in
* first-appearance order, the checkout's own identity and --exclude left out,
* one Co-authored-by line each, and an idempotent --message append. Commits are
* written through fast-import so their authors are the stream's, whatever
* identity the host forces on `git commit`.
*/
import { afterAll, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { EXIT } from '../lib/headless-artifacts';
const BIN = path.resolve(import.meta.dir, '../bin/gstack-coauthor-trailers');
const temps: string[] = [];
afterAll(() => { for (const dir of temps) fs.rmSync(dir, { recursive: true, force: true }); });
const ENV = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' };
function git(cwd: string, args: string[], input?: string) {
const r = spawnSync('git', args, { cwd, env: ENV, encoding: 'utf8', timeout: 30_000, input });
if (r.status !== 0) throw new Error(r.stderr);
return r.stdout.trim();
}
/** The committer identity git reports in this checkout (whatever the host forces); the bin must never list it. */
function selfEmail(cwd: string): string { return /<([^>]*)>/.exec(git(cwd, ['var', 'GIT_COMMITTER_IDENT']))![1]!; }
function repo() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'coauthor-'));
temps.push(dir);
git(dir, ['init', '-q', '-b', 'main']);
const self = selfEmail(dir);
let mark = 0;
const commit = (branch: string, file: string, msg: string, author: string, from?: string) => {
mark++;
const at = `${1700000000 + mark} +0000`;
const parent = from ? git(dir, ['rev-parse', from]) : '';
const stream = `commit refs/heads/${branch}\nmark :${mark}\nauthor ${author} ${at}\ncommitter Porter <${self}> ${at}\ndata ${Buffer.byteLength(msg)}\n${msg}\n`
+ (parent ? `from ${parent}\n` : '') + `M 100644 inline ${file}\ndata ${Buffer.byteLength(msg) + 1}\n${msg}\n\n`;
git(dir, ['fast-import', '--quiet', '--date-format=raw', '--force'], stream);
};
commit('main', 'a.txt', 'seed', `Porter <${self}>`);
git(dir, ['update-ref', 'refs/remotes/origin/main', 'refs/heads/main']);
git(dir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main']);
commit('port/contrib', 'b.txt', 'contributor fix', 'Ada Contributor <Ada@Example.org>', 'refs/heads/main');
commit('port/contrib', 'c.txt', 'second contributor', 'Bo Helper <bo@example.org>', 'refs/heads/port/contrib');
commit('port/contrib', 'd.txt', 'ada again', 'Ada Contributor <ada@example.org>', 'refs/heads/port/contrib');
commit('port/contrib', 'e.txt', 'porter follow-up', `Porter <${self}>`, 'refs/heads/port/contrib');
git(dir, ['checkout', '-q', 'port/contrib']);
return dir;
}
const run = (cwd: string, args: string[]) => spawnSync(BIN, args, { cwd, env: ENV, encoding: 'utf8', timeout: 60_000 });
test('the default range is origin/HEAD..HEAD; authors print once each in first-appearance order without the porter', () => {
const dir = repo();
expect(git(dir, ['log', '--format=%ae', 'origin/main..HEAD']).split('\n')).toHaveLength(4);
const r = run(dir, []);
expect(r.status).toBe(EXIT.ok);
expect(r.stdout).toBe('Co-authored-by: Ada Contributor <Ada@Example.org>\nCo-authored-by: Bo Helper <bo@example.org>\n');
expect(r.stderr.trim()).toBe('COAUTHORS: 2 in origin/main..HEAD');
const json = JSON.parse(run(dir, ['--json', '--exclude', 'BO@example.org']).stdout);
expect(json.trailers).toEqual(['Co-authored-by: Ada Contributor <Ada@Example.org>']);
expect(run(dir, ['HEAD~1..HEAD']).stdout).toBe('');
});
test('--message appends only the missing trailers and is idempotent; a bad range exits 1 and a bad flag 2', () => {
const dir = repo();
const file = path.join(dir, 'MSG');
fs.writeFileSync(file, 'port: contributor fix\n\nCo-authored-by: ada contributor <ADA@example.org>\n');
expect(run(dir, ['--message', file]).status).toBe(EXIT.ok);
const expected = 'port: contributor fix\n\nCo-authored-by: ada contributor <ADA@example.org>\n\nCo-authored-by: Bo Helper <bo@example.org>\n';
expect(fs.readFileSync(file, 'utf8')).toBe(expected);
const again = run(dir, ['--message', file, '--json']);
expect(JSON.parse(again.stdout).appended).toEqual([]);
expect(fs.readFileSync(file, 'utf8')).toBe(expected);
expect(run(dir, ['nope..HEAD']).status).toBe(EXIT.fail);
expect(run(dir, ['--bogus']).status).toBe(EXIT.usage);
});