mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* fix(ship-queue): readiness takes dispatched Free Tests, prose check reads key=value pairs, stamp paths accept tentative key versions, ready and train build read the base overlap, pr-wait --exclude, restamp prints the corrected order Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Pass 1 of /ship's review on #3514: the prose check skips a key: value pair's value whatever the whitespace, the restamp recompute keeps a merged-not-in-base predecessor and says to fetch, stampOnlyLine applies the version-width bound, pr-wait --exclude excludes nothing while the required checks are unreadable, baseOverlap reports a failed git read as unknown and gives no verdict at train build, readiness lets a pull_request run outrank a dispatched one, and the READY conflict refusal follows the crew template's rule 14 instead of a crew-local rule. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: stamp rule for tentative key versions, key=value prose claims, QUEUE_STALE merged-not-in-base fix 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>
301 lines
22 KiB
TypeScript
301 lines
22 KiB
TypeScript
/**
|
|
* PR 2 of the /ship + /review convergence lane: a repository with no version
|
|
* source or CHANGELOG ships with `version: "none"` (E18); the documentation
|
|
* audit's owner-accept path and the policy's doc caps (E11); the carry's git
|
|
* floor, named patch-id refusal and branch-touched walk; the digit-bounded
|
|
* stamp replacement.
|
|
*/
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { EXIT } from '../lib/headless-artifacts';
|
|
import { CARRY_NAMED_PATHS, extractReceipt, planCarry, stampOnlyText } from '../lib/ship-receipt';
|
|
import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs';
|
|
import { DOCS_CAPS_KEY_VERSION, validatePolicy } from '../lib/ship-policy';
|
|
import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
|
import { gitArgvIn } from './helpers/scratch-repo';
|
|
|
|
const repos: FixtureRepo[] = [];
|
|
const fixture = (opts: Parameters<typeof makeFixtureRepo>[0] = {}) => { const r = makeFixtureRepo({ prefix: 'ship-receipt-2-', ...opts }); repos.push(r); return r; };
|
|
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
|
|
|
/** A repository with no VERSION, no package.json, no CHANGELOG and no policy: the shape E18 names. */
|
|
function bareRepo(): FixtureRepo {
|
|
const repo = fixture();
|
|
for (const f of ['VERSION', 'package.json', 'package-lock.json', 'CHANGELOG.md', 'gen.js', 'generated', 'docs', '.gstack']) fs.rmSync(path.join(repo.work, f), { recursive: true, force: true });
|
|
fs.writeFileSync(path.join(repo.work, 'README.md'), '# fixture\n');
|
|
repo.git('add', '-A');
|
|
repo.git('commit', '-qm', 'no version source');
|
|
repo.git('push', '-q', '-f', 'origin', 'main');
|
|
return repo;
|
|
}
|
|
|
|
describe('E18: a repository without a version source or CHANGELOG', () => {
|
|
test('write records version "none"; read --body and result --body accept it', () => {
|
|
const repo = bareRepo();
|
|
const head = repo.git('rev-parse', 'HEAD');
|
|
const body = path.join(repo.root, 'body.md');
|
|
fs.writeFileSync(body, '## Summary\n');
|
|
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '5', '--base', 'main', '--session-kind', 'unattended', '--body', body]);
|
|
expect(w.status).toBe(EXIT.ok);
|
|
expect(w.err.trim()).toContain(`head=${head.slice(0, 12)} version=none base=main`);
|
|
const parsed = extractReceipt(w.out);
|
|
expect(parsed.errors).toEqual([]);
|
|
expect(parsed.receipt).toMatchObject({ pr: 5, head, version: 'none' });
|
|
const read = runBin(repo, 'gstack-ship-receipt', ['read', '--body', body, '--json']);
|
|
expect(read.status).toBe(EXIT.ok);
|
|
expect(JSON.parse(read.out).version).toBe('none');
|
|
const out = path.join(repo.root, 'run');
|
|
const result = runBin(repo, 'gstack-ship-receipt', ['result', '--body', body, '--out', out]);
|
|
expect(result.status).toBe(EXIT.ok);
|
|
expect(result.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete run=${out}`);
|
|
expect(JSON.parse(fs.readFileSync(path.join(out, 'ship-run.json'), 'utf8')).version).toBe('none');
|
|
});
|
|
|
|
test('a repository with a version source keeps its version, and one with only a CHANGELOG records no version rather than "none"', () => {
|
|
const repo = fixture();
|
|
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--base', 'main', '--session-kind', 'x', '--json']);
|
|
expect(JSON.parse(w.out).version).toBe('1.2.3.0');
|
|
for (const f of ['VERSION', 'package.json', 'package-lock.json']) fs.rmSync(path.join(repo.work, f));
|
|
repo.git('add', '-A');
|
|
repo.git('commit', '-qm', 'drop the version files, keep the changelog');
|
|
const only = runBin(repo, 'gstack-ship-receipt', ['write', '--base', 'main', '--session-kind', 'x', '--json']);
|
|
expect(only.status).toBe(EXIT.ok);
|
|
expect('version' in JSON.parse(only.out)).toBe(false);
|
|
});
|
|
|
|
test('"none" means absent: a package.json version is used, and a malformed VERSION, a policy stamp file or an unreadable base keep the no-version path', () => {
|
|
const version = (repo: FixtureRepo, args: string[] = []) => JSON.parse(runBin(repo, 'gstack-ship-receipt', ['write', '--base', 'main', '--session-kind', 'x', '--json', ...args]).out).version;
|
|
const manifest = bareRepo();
|
|
fs.writeFileSync(path.join(manifest.work, 'package.json'), JSON.stringify({ name: 'x', version: '4.5.6' }) + '\n');
|
|
manifest.git('add', '-A');
|
|
manifest.git('commit', '-qm', 'manifest only');
|
|
expect(version(manifest)).toBe('4.5.6');
|
|
const malformed = bareRepo();
|
|
fs.writeFileSync(path.join(malformed.work, 'VERSION'), 'not-a-version\n');
|
|
malformed.git('add', '-A');
|
|
malformed.git('commit', '-qm', 'malformed VERSION');
|
|
expect(version(malformed)).toBeUndefined();
|
|
const stamped = fixture({ policy: { ...BASE_POLICY, stamp_files: ['RELEASE'] } });
|
|
fs.writeFileSync(path.join(stamped.work, 'RELEASE'), 'build 7\n');
|
|
for (const f of ['VERSION', 'package.json', 'package-lock.json', 'CHANGELOG.md']) fs.rmSync(path.join(stamped.work, f));
|
|
stamped.git('add', '-A');
|
|
stamped.git('commit', '-qm', 'only a policy stamp file');
|
|
expect(version(stamped)).toBeUndefined();
|
|
const unreachable = bareRepo();
|
|
unreachable.git('update-ref', '-d', 'refs/remotes/origin/main');
|
|
expect(version(unreachable)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('E11: the documentation audit record and the doc caps', () => {
|
|
test('parseReceiptDocs: blocked without an exception is DOCS_AUDIT_BLOCKED; malformed input is RECEIPT_INVALID; a full record parses', () => {
|
|
expect(parseReceiptDocs({ status: 'blocked', scope: 'docs/' })).toMatchObject({ ok: false, code: 'DOCS_AUDIT_BLOCKED' });
|
|
expect(parseReceiptDocs({ status: 'current', scope: 'docs/' })).toEqual({ ok: true, docs: { status: 'current', scope: 'docs/' } });
|
|
for (const bad of [null, [], 'current', { status: 'done', scope: 'x' }, { status: 'current' }, { status: 'current', scope: '' }, { status: 'current', scope: 'x', extra: 1 },
|
|
{ status: 'blocked', scope: 'x', exception: { accepted_by: 'o', date: '2026-10-26', via: 'r' } }, { status: 'blocked', scope: 'x', exception: { accepted_by: 'o', date: 'yesterday', via: 'r', reason: 'w' } },
|
|
{ status: 'blocked', scope: 'x', exception: { accepted_by: 'o', date: '2026-10-26', via: 'r', reason: 'w', more: 1 } }]) {
|
|
expect(parseReceiptDocs(bad)).toMatchObject({ ok: false, code: 'RECEIPT_INVALID' });
|
|
}
|
|
const full = parseReceiptDocs({ status: 'blocked', scope: 'docs/, README.md', exception: { accepted_by: ' owner ', date: '2026-10-26', via: 'PR #12 comment', reason: 'the removed page is republished next release' } });
|
|
expect(full).toEqual({ ok: true, docs: { status: 'blocked', scope: 'docs/, README.md', exception: { accepted_by: 'owner', date: '2026-10-26', via: 'PR #12 comment', reason: 'the removed page is republished next release' } } });
|
|
expect(docsExceptionLine((full as any).docs)).toBe('Documentation exception: accepted by owner 2026-10-26 via PR #12 comment: the removed page is republished next release');
|
|
expect(docsExceptionLine({ status: 'current', scope: 'x' })).toBeNull();
|
|
});
|
|
|
|
test('write --docs refuses a blocked audit with no exception and a body without the exception line, and records the exception otherwise', () => {
|
|
const repo = fixture();
|
|
const body = path.join(repo.root, 'body.md');
|
|
fs.writeFileSync(body, '## Documentation\n\nAudit blocked on docs/removed.md.\n');
|
|
const common = ['write', '--pr', '9', '--base', 'main', '--session-kind', 'unattended', '--body', body];
|
|
const blocked = runBin(repo, 'gstack-ship-receipt', [...common, '--docs', '{"status":"blocked","scope":"docs/"}']);
|
|
expect(blocked.status).toBe(EXIT.refused);
|
|
expect(blocked.err).toContain('DOCS_AUDIT_BLOCKED');
|
|
expect(blocked.err).toContain('troubleshooting.md#docs-audit-blocked');
|
|
expect(fs.readFileSync(body, 'utf8')).not.toContain('gstack-ship-receipt');
|
|
const docsFile = path.join(repo.root, 'docs.json');
|
|
fs.writeFileSync(docsFile, JSON.stringify({ status: 'blocked', scope: 'docs/', exception: { accepted_by: 'owner', date: '2026-10-26', via: 'PR #9 review', reason: 'removed page republished in the next release' } }));
|
|
const noLine = runBin(repo, 'gstack-ship-receipt', [...common, '--docs', '@' + docsFile]);
|
|
expect(noLine.status).toBe(EXIT.refused);
|
|
expect(noLine.err).toContain('does not carry the exception line verbatim');
|
|
expect(noLine.err).toContain('"Documentation exception: accepted by owner 2026-10-26 via PR #9 review: removed page republished in the next release"');
|
|
fs.appendFileSync(body, 'Documentation exception: accepted by owner 2026-10-26 via PR #9 review: removed page republished in the next release\n');
|
|
const ok = runBin(repo, 'gstack-ship-receipt', [...common, '--docs', '@' + docsFile, '--review-loop', '{"passes":1,"blocking_found":0,"deferred":0}']);
|
|
expect(ok.status).toBe(EXIT.ok);
|
|
const receipt = extractReceipt(fs.readFileSync(body, 'utf8'));
|
|
expect(receipt.errors).toEqual([]);
|
|
expect((receipt.receipt as any).docs).toEqual({ status: 'blocked', scope: 'docs/', exception: { accepted_by: 'owner', date: '2026-10-26', via: 'PR #9 review', reason: 'removed page republished in the next release' } });
|
|
expect(Object.keys(receipt.receipt!).slice(-2)).toEqual(['docs', 'review_loop']);
|
|
expect(runBin(repo, 'gstack-ship-receipt', ['read', '--body', body]).status).toBe(EXIT.ok);
|
|
const malformed = runBin(repo, 'gstack-ship-receipt', [...common, '--docs', '{"status":"done","scope":"docs/"}']);
|
|
expect(malformed.status).toBe(EXIT.fail);
|
|
expect(malformed.err).toContain('RECEIPT_INVALID');
|
|
});
|
|
|
|
test('checkDocsCaps and docs-caps: one line per cap, over or absent exits 1 with DOCS_AUDIT_BLOCKED, no caps prints none declared', () => {
|
|
expect(checkDocsCaps([{ path: 'a.md', max_bytes: 10 }, { path: 'b.md', max_bytes: 10 }, { path: 'c.md', max_bytes: 10 }], p => ({ 'a.md': 10, 'b.md': 11 } as Record<string, number>)[p] ?? null).map(docsCapLine))
|
|
.toEqual(['DOCS_CAP: a.md 10/10 ok', 'DOCS_CAP: b.md 11/10 over', 'DOCS_CAP: c.md absent/10 over']);
|
|
const repo = fixture();
|
|
const none = runBin(repo, 'gstack-ship-receipt', ['docs-caps']);
|
|
expect(none.status).toBe(EXIT.ok);
|
|
expect(none.out.trim()).toBe('DOCS_CAP: none declared');
|
|
const policy = path.join(repo.root, 'policy.json');
|
|
fs.writeFileSync(policy, JSON.stringify({ docs: { caps: [{ path: 'docs/notes.md', max_bytes: 4096 }, { path: 'CHANGELOG.md', max_bytes: 16 }, { path: 'docs/missing.md', max_bytes: 10 }] }, min_gstack_version: DOCS_CAPS_KEY_VERSION }));
|
|
const r = runBin(repo, 'gstack-ship-receipt', ['docs-caps', '--policy', policy]);
|
|
expect(r.status).toBe(EXIT.fail);
|
|
const changelogBytes = fs.statSync(path.join(repo.work, 'CHANGELOG.md')).size;
|
|
expect(r.out.trim().split('\n')).toEqual([
|
|
`DOCS_CAP: docs/notes.md ${fs.statSync(path.join(repo.work, 'docs/notes.md')).size}/4096 ok`,
|
|
`DOCS_CAP: CHANGELOG.md ${changelogBytes}/16 over`,
|
|
'DOCS_CAP: docs/missing.md absent/10 over',
|
|
]);
|
|
expect(r.err).toContain('DOCS_AUDIT_BLOCKED');
|
|
expect(r.err).toContain('2 of 3 documentation caps over: CHANGELOG.md, docs/missing.md');
|
|
fs.writeFileSync(policy, JSON.stringify({ docs: { caps: [{ path: 'docs/notes.md', max_bytes: 4096 }] }, min_gstack_version: DOCS_CAPS_KEY_VERSION }));
|
|
const ok = runBin(repo, 'gstack-ship-receipt', ['docs-caps', '--policy', policy, '--json']);
|
|
expect(ok.status).toBe(EXIT.ok);
|
|
expect(JSON.parse(ok.out)).toMatchObject({ over: 0, caps: [{ path: 'docs/notes.md', verdict: 'ok' }] });
|
|
fs.writeFileSync(policy, JSON.stringify({ docs: { caps: [{ path: '../escape.md', max_bytes: 0 }] } }));
|
|
const bad = runBin(repo, 'gstack-ship-receipt', ['docs-caps', '--policy', policy]);
|
|
expect(bad.status).toBe(EXIT.fail);
|
|
expect(bad.err).toContain('POLICY_CONTAINMENT');
|
|
expect(bad.err).toContain('docs.caps[0].max_bytes');
|
|
expect(runBin(repo, 'gstack-ship-receipt', ['docs-caps', '--policy', path.join(repo.root, 'nope.json')]).status).toBe(EXIT.usage);
|
|
});
|
|
|
|
test('docs-caps reads the caps from the base policy, so a branch that raises its own cap in the working tree still reports over', () => {
|
|
const repo = fixture({ policy: { ...BASE_POLICY, docs: { caps: [{ path: 'CHANGELOG.md', max_bytes: 16 }] }, min_gstack_version: DOCS_CAPS_KEY_VERSION } });
|
|
const raised = JSON.stringify({ ...BASE_POLICY, docs: { caps: [{ path: 'CHANGELOG.md', max_bytes: 1_000_000 }] }, min_gstack_version: DOCS_CAPS_KEY_VERSION }, null, 2) + '\n';
|
|
fs.writeFileSync(path.join(repo.work, '.gstack', 'ship-policy.json'), raised);
|
|
repo.git('add', '-A');
|
|
repo.git('commit', '-qm', 'raise my own cap');
|
|
const r = runBin(repo, 'gstack-ship-receipt', ['docs-caps', '--base', 'main']);
|
|
expect(r.status).toBe(EXIT.fail);
|
|
expect(r.out.trim()).toBe(`DOCS_CAP: CHANGELOG.md ${fs.statSync(path.join(repo.work, 'CHANGELOG.md')).size}/16 over`);
|
|
expect(r.err).toContain('DOCS_AUDIT_BLOCKED');
|
|
const explicit = runBin(repo, 'gstack-ship-receipt', ['docs-caps', '--policy', path.join(repo.work, '.gstack', 'ship-policy.json')]);
|
|
expect(explicit.status).toBe(EXIT.ok);
|
|
repo.git('update-ref', '-d', 'refs/remotes/origin/main');
|
|
const gone = runBin(repo, 'gstack-ship-receipt', ['docs-caps', '--base', 'main']);
|
|
expect(gone.status).toBe(EXIT.refused);
|
|
expect(gone.err).toContain('POLICY_SOURCE_UNAVAILABLE');
|
|
});
|
|
|
|
test('the policy docs key validates its shape and needs min_gstack_version', () => {
|
|
const codes = (raw: Record<string, unknown>) => validatePolicy(raw, { gstackVersion: '9.9.9.9' }).map(e => `${e.code}:${e.path}`);
|
|
expect(codes({ docs: null })).toEqual([]);
|
|
expect(codes({ docs: { caps: [] } })).toEqual(['POLICY_INVALID:docs']);
|
|
expect(codes({ docs: { caps: [{ path: 'README.md', max_bytes: 100 }] }, min_gstack_version: DOCS_CAPS_KEY_VERSION })).toEqual([]);
|
|
expect(codes({ docs: { caps: 'README.md' }, min_gstack_version: DOCS_CAPS_KEY_VERSION })).toEqual(['POLICY_INVALID:docs.caps']);
|
|
expect(codes({ docs: { caps: [{ path: '/etc/x', max_bytes: 1.5, other: 1 }], limit: 1 }, min_gstack_version: DOCS_CAPS_KEY_VERSION }))
|
|
.toEqual(['POLICY_INVALID:docs.limit', 'POLICY_INVALID:docs.caps[0].other', 'POLICY_CONTAINMENT:docs.caps[0].path', 'POLICY_INVALID:docs.caps[0].max_bytes']);
|
|
});
|
|
});
|
|
|
|
describe('carry follow-ups: git floor, named refusal, branch-touched walk, digit-bounded stamps', () => {
|
|
test('stampOnlyText replaces the version only at digit boundaries', () => {
|
|
const change = (before: string, after: string) => stampOnlyText({ before, after, oldVersion: '1.2.3', newVersion: '1.2.4' });
|
|
expect(change('v1.2.3 and 11.2.3 and 1.2.34\n', 'v1.2.4 and 11.2.3 and 1.2.34\n')).toBe(true);
|
|
expect(change('v1.2.3 and 11.2.3\n', 'v1.2.4 and 11.2.4\n')).toBe(false);
|
|
expect(change('1.2.34\n', '1.2.44\n')).toBe(false);
|
|
});
|
|
|
|
// #3512: the carry classifies a stamp path as metadata only while its change is a stamp; a tentative key version the slot
|
|
// stamp moved (next minor -> released) was product, so `carry` refused RECEIPT_CARRY_PRODUCT_CHANGED on a stamp commit.
|
|
test('stampOnlyText accepts a tentative key version moved to the new version, and refuses a released one moved by hand or any other edit', () => {
|
|
const templates = ["_KEY_VERSION = '{version}'"];
|
|
const change = (before: string, after: string) => stampOnlyText({ before, after, oldVersion: '1.2.3.0', newVersion: '1.2.4.0', templates });
|
|
const keys = (docs: string, old: string, extra = '') => `export const DOCS_KEY_VERSION = '${docs}';\nexport const OLD_KEY_VERSION = '${old}';\n${extra}`;
|
|
expect(change(keys('1.3.0.0', '1.2.2.0'), keys('1.2.4.0', '1.2.2.0'))).toBe(true);
|
|
expect(change(keys('1.2.3.0', '1.2.2.0'), keys('1.2.4.0', '1.2.2.0'))).toBe(true);
|
|
expect(change(keys('1.3.0.0', '1.2.2.0'), keys('1.2.4.0', '1.2.4.0'))).toBe(false);
|
|
expect(change(keys('1.3.0.0', '1.2.2.0'), keys('1.2.4.0', '1.2.2.0', 'export const EXTRA = 1;\n'))).toBe(false);
|
|
// Without the template the constant is not a stamp form, so the move is a product change.
|
|
expect(stampOnlyText({ before: keys('1.3.0.0', '1.2.2.0'), after: keys('1.2.4.0', '1.2.2.0'), oldVersion: '1.2.3.0', newVersion: '1.2.4.0' })).toBe(false);
|
|
// A mention of another width is never a stamp candidate (tentativeMentions), so moving it by hand is a product change.
|
|
expect(change(keys('1.3.0', '1.2.2.0'), keys('1.2.4.0', '1.2.2.0'))).toBe(false);
|
|
expect(stampOnlyText({ before: 'left-pad since: 1.2.3\n', after: 'left-pad since: 1.2.4.0\n', oldVersion: '1.2.3.0', newVersion: '1.2.4.0' })).toBe(false);
|
|
});
|
|
|
|
test('planCarry names at most CARRY_NAMED_PATHS paths', () => {
|
|
const many = Array.from({ length: CARRY_NAMED_PATHS + 3 }, (_, i) => `src/f${i}.ts`);
|
|
const plan = planCarry({ from: 'a'.repeat(40), head: 'b'.repeat(40), changed: many.map(p => ({ path: p, class: 'product' as const })), productPatchId: { from: 'x', head: 'y' }, testPatchId: { from: '', head: '' }, changedProduct: many, mergeCommits: 0, rangeDiff: '' });
|
|
expect(plan.ok).toBe(false);
|
|
expect((plan as any).message).toContain(`(+3 more)`);
|
|
expect((plan as any).message.split(', ').length).toBe(CARRY_NAMED_PATHS);
|
|
});
|
|
|
|
// The old-git stand-in is a sh script on a colon-joined PATH, which CreateProcess cannot run on Windows.
|
|
test.skipIf(process.platform === 'win32')('carry refuses git below 2.39 by name (RECEIPT_GIT_TOO_OLD) and names a product path the branch touched', () => {
|
|
const repo = fixture();
|
|
makePr(repo, 'pr-carry', { expects: '1.2.4.0' });
|
|
const body = path.join(repo.root, 'body.md');
|
|
fs.writeFileSync(body, '## Summary\n');
|
|
expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '3', '--base', 'main', '--session-kind', 'x', '--body', body]).status).toBe(EXIT.ok);
|
|
fs.writeFileSync(path.join(repo.work, 'src.ts'), 'export const x = 1;\n');
|
|
repo.git('add', 'src.ts');
|
|
repo.git('commit', '-qm', 'product change');
|
|
const shim = path.join(repo.root, 'shim');
|
|
fs.mkdirSync(shim);
|
|
fs.writeFileSync(path.join(shim, 'git'), '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "git version 2.38.5"; exit 0; fi\nexec /usr/bin/git "$@"\n', { mode: 0o755 });
|
|
const realGit = gitArgvIn(repo.work, ['--version']).stdout.toString();
|
|
expect(realGit).toMatch(/git version/);
|
|
const old = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main'], { PATH: `${shim}:${process.env.PATH}` });
|
|
expect(old.status).toBe(EXIT.refused);
|
|
expect(old.err).toContain('RECEIPT_GIT_TOO_OLD');
|
|
expect(old.err).toContain('git 2.38.5 cannot run patch-id --verbatim; the carry needs git 2.39 or later');
|
|
expect(fs.readFileSync(body, 'utf8')).toContain('"pr": 3');
|
|
const changed = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
|
expect(changed.status).toBe(EXIT.refused);
|
|
expect(changed.err).toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
|
|
expect(changed.err).toContain('src.ts');
|
|
});
|
|
|
|
test.skipIf(process.platform === 'win32')('a failing patch-id is a RECEIPT_CARRY_REFUSED naming git\'s error, never a carry', () => {
|
|
const repo = fixture();
|
|
makePr(repo, 'pr-patchid', { expects: '1.2.4.0' });
|
|
const body = path.join(repo.root, 'body.md');
|
|
fs.writeFileSync(body, '## Summary\n');
|
|
expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '5', '--base', 'main', '--session-kind', 'x', '--body', body]).status).toBe(EXIT.ok);
|
|
fs.writeFileSync(path.join(repo.work, 'src.test.ts'), 'export const t = 1;\n');
|
|
repo.git('add', 'src.test.ts');
|
|
repo.git('commit', '-qm', 'test-only change');
|
|
const shim = path.join(repo.root, 'shim');
|
|
fs.mkdirSync(shim);
|
|
fs.writeFileSync(path.join(shim, 'git'), '#!/bin/sh\nif [ "$1" = "patch-id" ]; then echo "fatal: patch-id exploded" >&2; exit 128; fi\nexec /usr/bin/git "$@"\n', { mode: 0o755 });
|
|
const before = fs.readFileSync(body, 'utf8');
|
|
const broken = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main'], { PATH: `${shim}:${process.env.PATH}` });
|
|
expect(broken.status).toBe(EXIT.refused);
|
|
expect(broken.err).toContain('RECEIPT_CARRY_REFUSED');
|
|
expect(broken.err).toContain('git patch-id --verbatim: fatal: patch-id exploded');
|
|
expect(fs.readFileSync(body, 'utf8')).toBe(before);
|
|
expect(runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']).out).toContain('CARRY: carried');
|
|
});
|
|
|
|
test('the carry walk names the branch product path and not a product path a base merge brought in', () => {
|
|
const repo = fixture();
|
|
makePr(repo, 'pr-walk', { expects: '1.2.4.0' });
|
|
const body = path.join(repo.root, 'body.md');
|
|
fs.writeFileSync(body, '## Summary\n');
|
|
expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '4', '--base', 'main', '--session-kind', 'x', '--body', body]).status).toBe(EXIT.ok);
|
|
repo.git('checkout', '-q', 'main');
|
|
fs.writeFileSync(path.join(repo.work, 'lib-base-only.ts'), 'export const base = 1;\n');
|
|
repo.git('add', 'lib-base-only.ts');
|
|
repo.git('commit', '-qm', 'base change');
|
|
repo.git('push', '-q', 'origin', 'main');
|
|
repo.git('checkout', '-q', 'pr-walk');
|
|
repo.git('merge', '-q', '--no-edit', 'origin/main');
|
|
const merged = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
|
expect(merged.status).toBe(EXIT.ok);
|
|
expect(merged.out).toContain('CARRY: carried');
|
|
fs.writeFileSync(path.join(repo.work, 'src.ts'), 'export const x = 2;\n');
|
|
repo.git('add', 'src.ts');
|
|
repo.git('commit', '-qm', 'product change after the merge');
|
|
const changed = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
|
expect(changed.status).toBe(EXIT.refused);
|
|
expect(changed.err).toMatch(/product diff changed between [0-9a-f]{12} and [0-9a-f]{12}: src\.ts/);
|
|
expect(changed.err).not.toContain('lib-base-only.ts');
|
|
});
|
|
});
|