feat(merge): gstack-merge regime-aware merge helper + tests

Pure regime logic in lib/merge.ts (detect/submit-plan/classify-land/handoff
schema+validation) behind a thin bin/gstack-merge CLI (detect/submit/wait/
write-state/read-state). Supports none, GitHub native merge queue, and the
trunk.io merge queue with a comment-first submit chain. 41 deterministic
tests (30 unit + 11 CLI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-31 09:13:16 -07:00
parent 9562ad4e70
commit e7741d9841
4 changed files with 1133 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
// Deterministic end-to-end coverage of the bin/gstack-merge CLI (arg parsing,
// file IO, and the lib/merge.ts wiring through the real binary). Free + fast —
// no claude -p, no network. The submit/wait/classify *logic* is unit-tested in
// gstack-merge.test.ts; this proves the executable plumbs it correctly and that
// the handoff contract (write → consume, with null/stale/foreign rejection)
// round-trips through the actual CLI surface /land and /land-and-deploy call.
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const BIN = join(import.meta.dir, '..', 'bin', 'gstack-merge');
function runCli(args: string[], opts: { cwd?: string; home?: string } = {}) {
const r = spawnSync('bun', [BIN, ...args], {
encoding: 'utf-8',
cwd: opts.cwd || process.cwd(),
env: { ...process.env, ...(opts.home ? { HOME: opts.home } : {}) },
timeout: 30000,
});
return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' };
}
let tmp: string;
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'gstack-merge-cli-')); });
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
describe('gstack-merge CLI: detect', () => {
test('an explicit "Merge queue: trunk" config wins (no git/gh needed)', () => {
// A non-git temp dir: gh/git calls return nothing, so the config key is the
// only signal — exactly the precedence /land relies on.
writeFileSync(join(tmp, 'CLAUDE.md'), '# proj\n\n## Merge Configuration\n- Merge queue: trunk\n');
const r = runCli(['detect', '--base', 'main', '--json'], { cwd: tmp });
expect(r.code).toBe(0);
const out = JSON.parse(r.stdout.trim());
expect(out.regime).toBe('trunk');
expect(out.source).toBe('config');
});
test('config "Merge queue: github" is honored', () => {
writeFileSync(join(tmp, 'CLAUDE.md'), '## Merge Configuration\n- Merge queue: github\n');
const r = runCli(['detect', '--base', 'main', '--json'], { cwd: tmp });
expect(JSON.parse(r.stdout.trim()).regime).toBe('github');
});
test('no config + no signals → none', () => {
const r = runCli(['detect', '--base', 'main', '--json'], { cwd: tmp });
expect(r.code).toBe(0);
expect(JSON.parse(r.stdout.trim()).regime).toBe('none');
});
});
describe('gstack-merge CLI: handoff round-trip (write → consume)', () => {
const SLUG = 'acme-widget';
const REPO = 'acme/widget';
function seedHandoff(home: string, overrides: Record<string, unknown> = {}) {
const dir = join(home, '.gstack', 'projects', SLUG);
mkdirSync(dir, { recursive: true });
const state = {
schema_version: 1,
pr: 42,
sha: 'deadbeefcafe',
headRefOid: 'cafef00d',
base: 'main',
head_branch: 'feat/x',
repo: REPO,
regime: 'trunk',
ts: new Date().toISOString(),
...overrides,
};
writeFileSync(join(dir, 'last-land.json'), JSON.stringify(state, null, 2));
}
test('read-state accepts a matching recent handoff and emits the SHA', () => {
seedHandoff(tmp);
const r = runCli(['read-state', '--slug', SLUG, '--pr', '42', '--repo', REPO], { home: tmp });
expect(r.code).toBe(0);
expect(r.stdout).toContain('LAND_SHA=deadbeefcafe');
expect(r.stdout).toContain('LAND_REGIME=trunk');
});
test('read-state rejects a handoff for a different PR (no cross-PR deploy)', () => {
seedHandoff(tmp);
const r = runCli(['read-state', '--slug', SLUG, '--pr', '99', '--repo', REPO], { home: tmp });
expect(r.code).toBe(1);
expect(r.stderr).toContain('READ_STATE_INVALID');
});
test('read-state rejects a handoff for a different repo', () => {
seedHandoff(tmp);
const r = runCli(['read-state', '--slug', SLUG, '--pr', '42', '--repo', 'other/repo'], { home: tmp });
expect(r.code).toBe(1);
expect(r.stderr).toContain('READ_STATE_INVALID');
});
test('read-state rejects a stale handoff', () => {
seedHandoff(tmp, { ts: '2020-01-01T00:00:00.000Z' });
const r = runCli(['read-state', '--slug', SLUG, '--pr', '42', '--repo', REPO], { home: tmp });
expect(r.code).toBe(1);
expect(r.stderr).toContain('READ_STATE_INVALID');
});
test('read-state rejects a handoff with an empty SHA (the null-SHA STOP)', () => {
seedHandoff(tmp, { sha: '' });
const r = runCli(['read-state', '--slug', SLUG, '--pr', '42', '--repo', REPO], { home: tmp });
expect(r.code).toBe(1);
expect(r.stderr).toContain('READ_STATE_INVALID');
});
test('read-state reports missing when there is no handoff at all', () => {
const r = runCli(['read-state', '--slug', 'nope', '--pr', '1', '--repo', REPO], { home: tmp });
expect(r.code).toBe(1);
expect(r.stderr).toContain('READ_STATE_INVALID');
});
});
describe('gstack-merge CLI: argument handling', () => {
test('unknown subcommand exits 2 with usage', () => {
const r = runCli(['frobnicate']);
expect(r.code).toBe(2);
expect(r.stderr).toContain('usage: gstack-merge');
});
test('submit without --pr exits 2', () => {
const r = runCli(['submit', '--regime', 'none']);
expect(r.code).toBe(2);
});
});
+263
View File
@@ -0,0 +1,263 @@
import { describe, test, expect } from 'bun:test';
import {
detectRegime,
planSubmit,
classifyLand,
buildLandState,
validateConsume,
isTrunkQueueCheck,
trunkQueueCheckName,
LAND_STATE_SCHEMA_VERSION,
type LandState,
} from '../lib/merge';
describe('detectRegime', () => {
test('explicit config key wins over everything', () => {
const r = detectRegime({
base: 'main',
configRegime: 'github',
checks: [{ name: trunkQueueCheckName('main'), state: 'PENDING' }], // would say trunk
trunkYaml: 'merge:\n required_statuses: [ci]',
githubMergeQueue: false,
});
expect(r.regime).toBe('github');
expect(r.source).toBe('config');
});
test('invalid config key is ignored and falls through to live signals', () => {
const r = detectRegime({
base: 'main',
configRegime: 'banana',
checks: [{ name: 'Trunk Merge Queue (main)' }],
trunkYaml: null,
});
expect(r.regime).toBe('trunk');
expect(r.source).toBe('trunk-status-check');
});
test('trunk status check on the PR → trunk (even on a non-main base)', () => {
const r = detectRegime({
base: 'develop',
checks: [{ name: 'Trunk Merge Queue (develop)', state: 'TESTING' }],
trunkYaml: null,
});
expect(r.regime).toBe('trunk');
expect(r.source).toBe('trunk-status-check');
});
test('.trunk/trunk.yaml with a merge: section → trunk (secondary signal)', () => {
const r = detectRegime({
base: 'main',
checks: [{ name: 'build' }, { name: 'test' }],
trunkYaml: 'version: 0.1\nmerge:\n required_statuses:\n - build\n',
});
expect(r.regime).toBe('trunk');
expect(r.source).toBe('trunk-yaml');
});
test('bare .trunk/trunk.yaml WITHOUT a merge: section is NOT trunk (check-only false positive guard)', () => {
const r = detectRegime({
base: 'main',
checks: [{ name: 'build' }],
trunkYaml: 'version: 0.1\ncli:\n version: 1.22.0\nlint:\n enabled:\n - eslint\n',
});
expect(r.regime).toBe('none');
expect(r.source).toBe('default');
});
test('github branch-protection merge queue → github', () => {
const r = detectRegime({
base: 'main',
checks: [{ name: 'build' }],
trunkYaml: null,
githubMergeQueue: true,
});
expect(r.regime).toBe('github');
expect(r.source).toBe('github-branch-protection');
});
test('no signals → none', () => {
const r = detectRegime({ base: 'main', checks: [], trunkYaml: null });
expect(r.regime).toBe('none');
expect(r.source).toBe('default');
});
});
describe('isTrunkQueueCheck', () => {
test('matches the queue check name for any branch', () => {
expect(isTrunkQueueCheck('Trunk Merge Queue (main)')).toBe(true);
expect(isTrunkQueueCheck('Trunk Merge Queue (release/v2)')).toBe(true);
});
test('does not match unrelated checks', () => {
expect(isTrunkQueueCheck('Trunk Check')).toBe(false);
expect(isTrunkQueueCheck('build')).toBe(false);
expect(isTrunkQueueCheck(undefined)).toBe(false);
});
});
describe('planSubmit', () => {
test('none → single direct squash with branch delete', () => {
const p = planSubmit('none', 42);
expect(p.deleteBranch).toBe(true);
expect(p.candidates).toHaveLength(1);
expect(p.candidates[0].args).toEqual(['pr', 'merge', '42', '--squash', '--delete-branch']);
});
test('github → auto-merge first, squash fallback', () => {
const p = planSubmit('github', 42);
expect(p.deleteBranch).toBe(true);
expect(p.candidates[0].args).toContain('--auto');
expect(p.candidates[1].args).toContain('--squash');
});
test('trunk is comment-first and never deletes the branch', () => {
const p = planSubmit('trunk', 7, { trunkCliAvailable: false, trunkToken: false });
expect(p.deleteBranch).toBe(false);
expect(p.candidates).toHaveLength(1);
expect(p.candidates[0].cmd).toBe('gh');
expect(p.candidates[0].args).toEqual(['pr', 'comment', '7', '--body', '/trunk merge']);
// No gh pr merge anywhere in the trunk plan.
expect(p.candidates.some((c) => c.args.includes('merge') && c.cmd === 'gh' && c.args.includes('pr') && c.args[1] === 'merge')).toBe(false);
});
test('trunk adds CLI then REST as opportunistic fallbacks, in order', () => {
const p = planSubmit('trunk', 7, { trunkCliAvailable: true, trunkToken: true });
expect(p.candidates.map((c) => c.cmd)).toEqual(['gh', 'trunk', 'trunk-rest']);
});
test('trunk priority threads into the comment body and CLI', () => {
const p = planSubmit('trunk', 7, { trunkCliAvailable: true, priority: 'high' });
expect(p.candidates[0].args).toContain('/trunk merge --priority=high');
expect(p.candidates[1].args).toEqual(['merge', '7', '--priority', 'high']);
});
});
describe('classifyLand', () => {
test('MERGED with a merge SHA → landed', () => {
const c = classifyLand({ state: 'MERGED', mergeCommitOid: 'abc123', baseContainsHead: false });
expect(c.status).toBe('landed');
});
test('MERGED with null SHA but base contains head → landed (rebase-merge case, H3)', () => {
const c = classifyLand({ state: 'MERGED', mergeCommitOid: null, baseContainsHead: true });
expect(c.status).toBe('landed');
});
test('MERGED but SHA not visible and base does not yet contain head → pending (squash lag)', () => {
const c = classifyLand({ state: 'MERGED', mergeCommitOid: null, baseContainsHead: false });
expect(c.status).toBe('pending');
});
test('OPEN with a failed queue check → ejected', () => {
const c = classifyLand({
state: 'OPEN',
mergeCommitOid: null,
baseContainsHead: false,
queueCheck: { name: 'Trunk Merge Queue (main)', state: 'FAILURE' },
});
expect(c.status).toBe('ejected');
});
test('OPEN with a cancelled queue check → ejected', () => {
const c = classifyLand({
state: 'OPEN',
mergeCommitOid: null,
baseContainsHead: false,
queueCheck: { name: 'Trunk Merge Queue (main)', bucket: 'CANCELLED' },
});
expect(c.status).toBe('ejected');
});
test('OPEN with a still-testing queue check → pending', () => {
const c = classifyLand({
state: 'OPEN',
mergeCommitOid: null,
baseContainsHead: false,
queueCheck: { name: 'Trunk Merge Queue (main)', state: 'IN_PROGRESS' },
});
expect(c.status).toBe('pending');
});
test('CLOSED → closed', () => {
const c = classifyLand({ state: 'CLOSED', mergeCommitOid: null, baseContainsHead: false });
expect(c.status).toBe('closed');
});
});
describe('buildLandState', () => {
const base = {
pr: 12,
sha: 'deadbeef',
headRefOid: 'cafe',
base: 'main',
head_branch: 'feat/x',
repo: 'owner/name',
regime: 'trunk' as const,
ts: '2026-05-31T00:00:00.000Z',
};
test('assembles a schema-versioned state', () => {
const s = buildLandState(base);
expect(s.schema_version).toBe(LAND_STATE_SCHEMA_VERSION);
expect(s.sha).toBe('deadbeef');
expect(s.repo).toBe('owner/name');
// scope is intentionally absent (T2 — parent recomputes diff-scope).
expect('scope' in s).toBe(false);
});
test('refuses to build with an empty SHA (handoff would silently kill revert)', () => {
expect(() => buildLandState({ ...base, sha: '' })).toThrow(/empty merge SHA/);
});
});
describe('validateConsume', () => {
const now = Date.parse('2026-05-31T01:00:00.000Z');
const good: LandState = {
schema_version: LAND_STATE_SCHEMA_VERSION,
pr: 12,
sha: 'deadbeef',
headRefOid: 'cafe',
base: 'main',
head_branch: 'feat/x',
repo: 'owner/name',
regime: 'trunk',
ts: '2026-05-31T00:30:00.000Z',
};
test('accepts a matching recent state', () => {
expect(validateConsume(good, { pr: 12, repo: 'owner/name' }, now).ok).toBe(true);
});
test('rejects when no state file', () => {
const v = validateConsume(null, { pr: 12, repo: 'owner/name' }, now);
expect(v.ok).toBe(false);
});
test('rejects a state for a different PR (stale-state-drives-wrong-deploy, H5)', () => {
const v = validateConsume(good, { pr: 99, repo: 'owner/name' }, now);
expect(v.ok).toBe(false);
expect(v.reason).toMatch(/PR #12/);
});
test('rejects a state for a different repo', () => {
const v = validateConsume(good, { pr: 12, repo: 'other/name' }, now);
expect(v.ok).toBe(false);
});
test('rejects a stale state past max age', () => {
const stale = { ...good, ts: '2026-05-30T00:00:00.000Z' };
const v = validateConsume(stale, { pr: 12, repo: 'owner/name' }, now);
expect(v.ok).toBe(false);
expect(v.reason).toMatch(/stale/);
});
test('rejects an empty SHA', () => {
const v = validateConsume({ ...good, sha: '' }, { pr: 12, repo: 'owner/name' }, now);
expect(v.ok).toBe(false);
});
test('rejects a schema_version mismatch', () => {
const v = validateConsume({ ...good, schema_version: 99 }, { pr: 12, repo: 'owner/name' }, now);
expect(v.ok).toBe(false);
});
});