diff --git a/src/cli.ts b/src/cli.ts index 532c3ae..4a2c2a4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -36,6 +36,7 @@ type CliOptionState = Pick< | 'force' | 'checkOverrides' | 'coverage' + | 'directMerge' >; type MenuContext = { @@ -78,6 +79,7 @@ function readOptions(opts: Record): CliOptionState { force: opts.force === true, checkOverrides: opts.checkOverrides === true, coverage: opts.coverage === true, + directMerge: opts.directMerge === true, }; } @@ -104,6 +106,10 @@ const serviceDefinitions: ServiceDefinition[] = [ { flags: '--hard', description: 'overwrite drifted files with upstream version (aggressive realignment)' }, { flags: '--unpinned', description: 'ignore pinned files (except package.json) to resurface upstream changes' }, { flags: '--track ', description: 'override upstream tracking for this run: release|branch' }, + { + flags: '--direct-merge', + description: 'auto-merge (squash) the sync PR once checks pass — needs auto-merge enabled on the repo', + }, ], includeInMenu: (context) => !context.isUpstreamRepo, menuDescription: () => 'merge upstream changes + sync package.json', @@ -213,6 +219,7 @@ function buildProgram(setSelection: (selection: CliServiceSelection) => void): C ' $ cella sync --hard', ' $ cella sync --unpinned', ' $ cella sync --track branch', + ' $ cella sync --direct-merge', ' $ cella audit --check-overrides', ' $ cella contributions --fork raak --json', ].join('\n'), diff --git a/src/config/types.ts b/src/config/types.ts index 85422b7..0f9d6d4 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -270,6 +270,13 @@ export interface RuntimeConfig extends CellaCliConfig { /** Regenerate test coverage before showing the stats summary (stats service) */ coverage?: boolean; + + /** + * After opening the sync PR, enable GitHub auto-merge (squash) so it merges itself once + * required checks pass (sync service). Opt-in per run via `--direct-merge`; needs auto-merge + * enabled on the repo. Not the advised default — a sync PR is usually worth a human look. + */ + directMerge?: boolean; } /** File status after analysis */ diff --git a/src/services/sync.ts b/src/services/sync.ts index 7a7e1db..0e0a205 100644 --- a/src/services/sync.ts +++ b/src/services/sync.ts @@ -7,9 +7,15 @@ */ import { spawnSync } from 'node:child_process'; +import { select } from '@inquirer/prompts'; import type { MergeResult, RuntimeConfig } from '../config/types'; import pc from '../utils/colors'; -import { buildTemporarySyncBranch, isTemporarySyncBranch, resolveReleaseBase } from '../utils/config'; +import { + buildTemporarySyncBranch, + DEFAULT_SYNC_PREFIX, + isTemporarySyncBranch, + resolveReleaseBase, +} from '../utils/config'; import { createSpinner, printFlagWarnings, @@ -21,6 +27,7 @@ import { warningMark, writeLogFile, } from '../utils/display'; +import { closePr, type GhPullRequest, ghAvailable, listOpenSyncPrs, mergePrSquash } from '../utils/gh'; import { assertClean, type CommitRangeEntry, @@ -299,11 +306,6 @@ function hasStagedSyncChanges(result: MergeResult): boolean { return result.files.some((file) => ['behind', 'diverged', 'renamed', 'ignored', 'pinned'].includes(file.status)); } -/** Whether the GitHub CLI is available on PATH. */ -function ghAvailable(): boolean { - return spawnSync('gh', ['--version'], { stdio: 'ignore' }).status === 0; -} - /** Extract the first URL from command output, usually the PR URL emitted by GitHub CLI. */ function extractFirstUrl(output: string): string | undefined { return output.match(/https?:\/\/\S+/)?.[0]; @@ -338,6 +340,30 @@ async function flattenSyncBranch(forkPath: string, branch: string, base: string) return true; } +/** Indent every line of `text` by two spaces, for nesting captured `gh` output under a message. */ +function indentLines(text: string): string { + return text + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); +} + +/** + * Turn on GitHub auto-merge (squash) for the just-opened sync PR: it merges itself once required + * checks pass, so `--direct-merge` needs no babysitting. Returns whether it was enabled; on failure + * (the repo doesn't allow auto-merge, `gh` too old) it prints the manual command and returns false. + */ +function enableAutoMerge(forkPath: string, branch: string): boolean { + console.info(pc.dim('enabling auto-merge (squash) once checks pass...')); + const merge = mergePrSquash(forkPath, branch, { auto: true, deleteBranch: true }); + if (merge.ok) return true; + + console.info(pc.yellow('could not enable auto-merge (is it enabled for the repo?). merge it manually:')); + if (merge.output) console.info(pc.dim(indentLines(merge.output))); + console.info(pc.dim(` gh pr merge ${branch} --squash --delete-branch`)); + return false; +} + /** * Push the finished sync branch to `origin`, open a PR into the trunk, and switch back to the * trunk. Runs automatically once a rerun completes the merge cleanly. @@ -345,6 +371,9 @@ async function flattenSyncBranch(forkPath: string, branch: string, base: string) * Before pushing, any merge commits on the branch are flattened away (see `flattenSyncBranch`) * so the PR never lists the upstream branch's entire history. * + * With `--direct-merge`, once the PR is open GitHub auto-merge is enabled (see `enableAutoMerge`) + * so it squash-merges itself as soon as required checks pass — no forgotten open PR to trip over. + * * Every step degrades gracefully: a failed push (no `origin`, auth) prints the manual steps and * leaves you on the branch; a missing/failed `gh` (or an existing PR) prints the `gh` command but * still returns you to the trunk since the branch is already pushed. @@ -356,6 +385,7 @@ async function shipSyncBranch(config: RuntimeConfig, branch: string): Promise { + const { forkPath, settings } = config; + const base = resolveReleaseBase(settings); + const [newest, ...superseded] = open; + + console.info(); + console.info(pc.dim(`squash-merging #${newest.number} into '${base}'...`)); + const merged = mergePrSquash(forkPath, newest.number, { deleteBranch: true }); + if (!merged.ok) { + console.info(); + console.info(pc.yellow(`could not merge #${newest.number} — resolve it first, then re-run \`pnpm cella sync\`:`)); + if (merged.output) console.info(pc.dim(indentLines(merged.output))); + console.info(pc.dim(` ${newest.url}`)); + return 'cancel'; + } + console.info(`${pc.green('✓')} merged #${newest.number}`); + // Drop the stale local branch that tracked the merged PR (the remote one went with --delete-branch). + await deleteBranch(forkPath, newest.headRefName); + + for (const pr of superseded) { + console.info(pc.dim(`closing #${pr.number} — its changes are included in #${newest.number}...`)); + closePr(forkPath, pr.number); + await deleteBranch(forkPath, pr.headRefName); + } + + // Bring the merged commit into the local trunk so the new cycle cuts from an up-to-date base. + console.info(pc.dim(`switching to '${base}' and fast-forwarding...`)); + await switchBranch(forkPath, base); + await pullFastForward(forkPath); + + return 'continue'; +} + +/** + * Before cutting a fresh sync branch, warn when an earlier sync PR is still open and unmerged. + * + * The last-sync point is recorded in the manifest committed *on the sync branch*, not on the + * trunk. So a new cycle cut from a trunk that still lacks a merged sync PR re-includes that PR's + * whole delta on top of the new upstream commits — the "why are there suddenly so many changes" + * surprise. This offers to squash-merge the open PR first (so the new cycle cuts from an + * up-to-date trunk), continue anyway, or cancel. + * + * Degrades to a silent no-op (`continue`) when it can't help: no `gh`, no open sync PR, or a + * non-interactive session (no TTY to prompt on). + */ +async function guardAgainstOpenSyncPr(config: RuntimeConfig): Promise<'continue' | 'cancel'> { + const { forkPath } = config; + + if (!ghAvailable() || !process.stdout.isTTY) return 'continue'; + + const open = listOpenSyncPrs(forkPath, DEFAULT_SYNC_PREFIX); + if (open.length === 0) return 'continue'; + + const many = open.length > 1; + console.info(); + console.info( + `${warningMark} ${pc.yellow(`${many ? `${open.length} earlier sync PRs are` : 'an earlier sync PR is'} still open and unmerged:`)}`, + ); + for (const pr of open) { + console.info(pc.dim(` #${pr.number} ${pr.title}`)); + console.info(pc.dim(` ${pr.url}`)); + } + console.info(pc.dim(` starting a new sync now produces a PR that re-includes ${many ? 'these' : 'those'} changes.`)); + console.info(); + + const choice = await select<'merge' | 'continue' | 'cancel'>({ + message: 'how do you want to proceed?', + choices: [ + { + value: 'merge', + name: `merge ${many ? 'them' : `#${open[0].number}`} first, then sync ${pc.dim('(recommended)')}`, + }, + { value: 'continue', name: `continue anyway ${pc.dim('(new PR will re-include the unmerged changes)')}` }, + { value: 'cancel', name: pc.red('cancel') }, + ], + loop: false, + }); + + if (choice === 'cancel') return 'cancel'; + if (choice === 'continue') return 'continue'; + return mergeOpenSyncPrs(config, open); +} + /** * Run the standalone `cella sync` command. * @@ -553,6 +680,11 @@ export async function runSyncCommand(config: RuntimeConfig): Promise { // Fresh cycle: only ever cut the temporary branch from a clean tree. await assertClean(forkPath); + + // Guard against stacking: if an earlier sync PR is still open, its delta would re-appear in + // this cycle's PR. Offer to merge it first (or bail) before cutting a new branch. + if ((await guardAgainstOpenSyncPr(config)) === 'cancel') return; + const outcome = await runSyncCycle(config); console.info(); diff --git a/src/utils/config.ts b/src/utils/config.ts index b57dee9..c3b3451 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -23,7 +23,7 @@ export const DEFAULT_BRANCH = 'main'; * The three-segment shape (`cella/sync/`) means it can never collide with a real branch * named `cella` or `cella/sync`. */ -const DEFAULT_SYNC_PREFIX = 'cella/sync'; +export const DEFAULT_SYNC_PREFIX = 'cella/sync'; /** * Default trunk branch that `cella sync` cuts from and opens PRs into. diff --git a/src/utils/gh.ts b/src/utils/gh.ts new file mode 100644 index 0000000..3e81179 --- /dev/null +++ b/src/utils/gh.ts @@ -0,0 +1,97 @@ +/** + * GitHub CLI (`gh`) helpers for the cella CLI. + * + * Thin wrappers around `gh` used by the sync service to list, merge, and close the sync pull + * requests. Every wrapper degrades gracefully when `gh` is missing or a call fails, so a sync + * run on a repo without `gh`/`origin` still works — it just skips the PR automation. + */ + +import { spawnSync } from 'node:child_process'; + +/** An open pull request, as returned by `gh pr list --json`. */ +export interface GhPullRequest { + number: number; + title: string; + headRefName: string; + url: string; +} + +/** Result of a `gh` invocation: whether it exited 0, plus its combined stdout+stderr. */ +export interface GhResult { + ok: boolean; + output: string; +} + +/** Whether the GitHub CLI is available on PATH. */ +export function ghAvailable(): boolean { + return spawnSync('gh', ['--version'], { stdio: 'ignore' }).status === 0; +} + +/** Run `gh` in `cwd`, capturing stdout+stderr. Never throws. */ +function runGh(cwd: string, args: string[]): GhResult { + const result = spawnSync('gh', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }); + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim(); + return { ok: result.status === 0, output }; +} + +/** + * Keep only the pull requests whose head branch is one of cella's temporary sync branches + * (under the `/` namespace, e.g. `cella/sync/`). Pure — unit-tested. + */ +export function filterSyncPrs(prs: GhPullRequest[], prefix: string): GhPullRequest[] { + return prs.filter((pr) => pr.headRefName.startsWith(`${prefix}/`)); +} + +/** + * List open sync PRs (head branch under `/`), newest first (highest PR number). + * Returns an empty array when `gh` fails (no auth, no origin) so callers degrade to a no-op. + */ +export function listOpenSyncPrs(cwd: string, prefix: string): GhPullRequest[] { + const { ok, output } = runGh(cwd, [ + 'pr', + 'list', + '--state', + 'open', + '--json', + 'number,title,headRefName,url', + '--limit', + '50', + ]); + if (!ok || !output) return []; + + let parsed: GhPullRequest[]; + try { + parsed = JSON.parse(output) as GhPullRequest[]; + } catch { + return []; + } + + return filterSyncPrs(parsed, prefix).sort((a, b) => b.number - a.number); +} + +/** + * Build the argv for `gh pr merge`. Pure — unit-tested. + * + * `auto` uses GitHub auto-merge (the PR squashes once required checks pass); without it the + * merge is attempted immediately and fails when the PR is not mergeable. + */ +export function buildMergeArgs(ref: string, options: { auto?: boolean; deleteBranch?: boolean } = {}): string[] { + const args = ['pr', 'merge', ref, '--squash']; + if (options.auto) args.push('--auto'); + if (options.deleteBranch) args.push('--delete-branch'); + return args; +} + +/** Squash-merge a PR (by number, branch, or URL). See {@link buildMergeArgs}. */ +export function mergePrSquash( + cwd: string, + ref: string | number, + options: { auto?: boolean; deleteBranch?: boolean } = {}, +): GhResult { + return runGh(cwd, buildMergeArgs(String(ref), options)); +} + +/** Close a PR without merging (used to discard superseded sync PRs). */ +export function closePr(cwd: string, ref: string | number): GhResult { + return runGh(cwd, ['pr', 'close', String(ref)]); +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 1bb7524..0904167 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -82,5 +82,15 @@ describe('parseCli', () => { expect(config.service).toBe('sync'); expect(config.logFile).toBe(true); expect(config.hard).toBe(true); + expect(config.directMerge).toBe(false); + }); + + it('parses --direct-merge for sync', async () => { + process.argv = ['node', 'cella', 'sync', '--direct-merge']; + + const config = await parseCli(baseConfig, '/tmp/fork'); + + expect(config.service).toBe('sync'); + expect(config.directMerge).toBe(true); }); }); diff --git a/tests/gh.test.ts b/tests/gh.test.ts new file mode 100644 index 0000000..b414e45 --- /dev/null +++ b/tests/gh.test.ts @@ -0,0 +1,62 @@ +/** + * Unit tests for the pure helpers in the `gh` wrapper module. + * + * The `gh`-invoking functions hit the network and are exercised manually / in integration; here + * we cover the pure logic that decides *which* PRs count as sync PRs and *what* argv is sent to + * `gh pr merge`. + */ +import { describe, expect, it } from 'vitest'; +import { buildMergeArgs, filterSyncPrs, type GhPullRequest } from '../src/utils/gh'; + +const pr = (number: number, headRefName: string): GhPullRequest => ({ + number, + headRefName, + title: `chore: sync upstream cella #${number}`, + url: `https://github.com/org/app/pull/${number}`, +}); + +describe('filterSyncPrs', () => { + it('keeps only PRs whose head branch is under the sync prefix', () => { + const prs = [ + pr(1, 'cella/sync/20260101-1200'), + pr(2, 'feature/login'), + pr(3, 'cella/sync/20260202-0900'), + pr(4, 'cella-sync-lookalike'), // no trailing slash — must NOT match + pr(5, 'cella/sync'), // the bare namespace — must NOT match (needs the trailing slash) + ]; + + const result = filterSyncPrs(prs, 'cella/sync'); + + expect(result.map((p) => p.number)).toEqual([1, 3]); + }); + + it('returns an empty array when nothing matches', () => { + expect(filterSyncPrs([pr(1, 'main'), pr(2, 'dev')], 'cella/sync')).toEqual([]); + }); + + it('respects a custom prefix', () => { + const prs = [pr(1, 'cella/sync/x'), pr(2, 'my/prefix/y')]; + expect(filterSyncPrs(prs, 'my/prefix').map((p) => p.number)).toEqual([2]); + }); +}); + +describe('buildMergeArgs', () => { + it('squash-merges immediately by default', () => { + expect(buildMergeArgs('42')).toEqual(['pr', 'merge', '42', '--squash']); + }); + + it('adds --auto for auto-merge and --delete-branch when requested', () => { + expect(buildMergeArgs('cella/sync/x', { auto: true, deleteBranch: true })).toEqual([ + 'pr', + 'merge', + 'cella/sync/x', + '--squash', + '--auto', + '--delete-branch', + ]); + }); + + it('can delete the branch without enabling auto-merge', () => { + expect(buildMergeArgs('7', { deleteBranch: true })).toEqual(['pr', 'merge', '7', '--squash', '--delete-branch']); + }); +});