/** * Contract test — every gitflow CLI must accept `--workdir`. * * The SKILL.md `` table advertises `--workdir` as a universal flag, * but it was only wired into 4 of the 12 CLIs (init/status/update/abort). The * other 8 (sync, commit, start, pr, merge, finish, cleanup, generate-msg) * declared only `--spec`/`--json`, so passing `--workdir` made `parseArgs` throw * "Unknown option '--workdir'" (exit 2) — the reason `/gitflow sync` "generated * errors" whenever the target repo differed from the session cwd. * * This test scans the source of EVERY CLI wrapper and enforces the invariant the * documentation promises: each declares the `--workdir` option (so it never * crashes on it), and each git-touching CLI threads that value into execute() * (so the override actually reaches the git operations). generate-msg is pure * formatting — it accepts the flag for uniformity but does not thread it. */ import { describe, it, expect } from 'vitest'; import { readFileSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const cliRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); const clis = readdirSync(cliRoot, { withFileTypes: true }) .filter((d) => d.isDirectory() && d.name !== '__tests__' && d.name !== 'lib') .map((d) => d.name) .sort(); // generate-msg formats a commit message from Claude's analysis — no git, no // target repo. It accepts --workdir for uniformity but intentionally ignores it. const NON_THREADING = new Set(['generate-msg']); const source = (cli: string) => readFileSync(join(cliRoot, cli, 'index.ts'), 'utf-8'); describe('gitflow CLI --workdir contract', () => { it('discovers the full CLI set', () => { expect(clis).toEqual( expect.arrayContaining([ 'init', 'start', 'commit', 'sync', 'update', 'pr', 'merge', 'finish', 'status', 'cleanup', 'abort', 'generate-msg', ]), ); expect(clis.length).toBeGreaterThanOrEqual(12); }); for (const cli of clis) { it(`${cli} declares --workdir (never crashes on "Unknown option")`, () => { expect(source(cli)).toMatch(/workdir:\s*\{\s*type:\s*'string'\s*\}/); }); } for (const cli of clis.filter((c) => !NON_THREADING.has(c))) { it(`${cli} threads workdir into execute()`, () => { expect(source(cli)).toMatch(/execute\([^)]*workdir/); }); } }); // --------------------------------------------------------------------------- // Deeper half of the --workdir contract: a CLI that reads the gitflow config // must resolve it from the passed cwd (= --workdir), NOT process.cwd(). A bare // readConfig() / readConfigForPlatform() falls back to process.cwd() (see // config.ts resolveConfigPath), so from a cwd ≠ the target worktree it loads // the WRONG/empty config → wrong provider (`Unsupported provider`) and wrong // branches (the mis-targeted-op class). Fixed 2026-06-23 across // sync/commit/start/merge/finish/cleanup; update/status/pr already complied. // --------------------------------------------------------------------------- const executeSource = (cli: string): string | null => { try { return readFileSync(join(cliRoot, cli, 'execute.ts'), 'utf-8'); } catch { return null; // a CLI may inline its execution in index.ts (e.g. init) } }; // Strip block + line comments so a comment that DOCUMENTS the old bad call // (e.g. pr/execute.ts: "readConfigForPlatform() used to ignore cwd") is never // mistaken for a real bare invocation. const codeOnly = (src: string): string => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); const READS_CONFIG = /readConfig(ForPlatform)?\s*\(/; describe('gitflow config is resolved from --workdir, not process.cwd()', () => { const configReaders = clis.filter((c) => { const src = executeSource(c); return !!src && READS_CONFIG.test(codeOnly(src)); }); it('finds the config-reading CLIs', () => { expect(configReaders).toEqual( expect.arrayContaining([ 'sync', 'commit', 'start', 'merge', 'finish', 'cleanup', 'update', 'status', 'pr', ]), ); }); for (const cli of configReaders) { it(`${cli} never reads config with a bare () (would fall back to process.cwd())`, () => { const code = codeOnly(executeSource(cli)!); expect(code).not.toMatch(/readConfig\s*\(\s*\)/); expect(code).not.toMatch(/readConfigForPlatform\s*\(\s*\)/); }); it(`${cli} threads a cwd identifier into resolveConfigPath()`, () => { const code = codeOnly(executeSource(cli)!); expect(code).toMatch(/resolveConfigPath\s*\(\s*\w+\s*\)/); }); } });