Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type CliOptionState = Pick<
| 'force'
| 'checkOverrides'
| 'coverage'
| 'directMerge'
>;

type MenuContext = {
Expand Down Expand Up @@ -78,6 +79,7 @@ function readOptions(opts: Record<string, unknown>): CliOptionState {
force: opts.force === true,
checkOverrides: opts.checkOverrides === true,
coverage: opts.coverage === true,
directMerge: opts.directMerge === true,
};
}

Expand All @@ -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 <mode>', 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',
Expand Down Expand Up @@ -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'),
Expand Down
7 changes: 7 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
148 changes: 140 additions & 8 deletions src/services/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,6 +27,7 @@ import {
warningMark,
writeLogFile,
} from '../utils/display';
import { closePr, type GhPullRequest, ghAvailable, listOpenSyncPrs, mergePrSquash } from '../utils/gh';
import {
assertClean,
type CommitRangeEntry,
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -338,13 +340,40 @@ 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.
*
* 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.
Expand All @@ -356,6 +385,7 @@ async function shipSyncBranch(config: RuntimeConfig, branch: string): Promise<vo
const flattened = await flattenSyncBranch(forkPath, branch, base);
let prUrl: string | undefined;
let prOpened = false;
let autoMergeEnabled = false;

// The squash commit's subject is the versioned sync message — reuse it as the PR title so the
// PR name carries the upstream version and commit id (release-please only needs the prefix).
Expand Down Expand Up @@ -383,7 +413,9 @@ async function shipSyncBranch(config: RuntimeConfig, branch: string): Promise<vo
});
prUrl = extractFirstUrl(`${pr.stdout ?? ''}\n${pr.stderr ?? ''}`);
prOpened = pr.status === 0;
if (!prOpened) {
if (prOpened) {
if (config.directMerge) autoMergeEnabled = enableAutoMerge(forkPath, branch);
} else {
console.info(pc.yellow('could not open the PR automatically (it may already exist). open it with:'));
if (prUrl) console.info(pc.dim(` ${prUrl}`));
printPrCreateStep(branch, base, prTitle);
Expand All @@ -400,7 +432,10 @@ async function shipSyncBranch(config: RuntimeConfig, branch: string): Promise<vo
console.info();
if (prUrl) {
console.info(`${pc.green('✓')} Sync pull request ${prOpened ? 'opened' : 'ready'}`);
console.info(pc.dim(` ${prUrl} · branch pushed, back on '${base}'`));
const status = autoMergeEnabled
? `auto-merge on — squashes into '${base}' when checks pass`
: `branch pushed, back on '${base}'`;
console.info(pc.dim(` ${prUrl} · ${status}`));
} else {
console.info(`${pc.green('✓')} Sync branch pushed`);
console.info(pc.dim(` '${branch}' is on origin, back on '${base}'`));
Expand Down Expand Up @@ -511,6 +546,98 @@ async function resumeSyncMerge(config: RuntimeConfig, branch: string): Promise<v
await shipSyncBranch(config, branch);
}

/**
* Merge the open sync PR(s) so the trunk carries the recorded sync point before a new cycle cuts
* from it. The newest PR is a content superset of any older ones (each cycle re-syncs from the
* same stale trunk base), so the newest is squash-merged and the rest are closed.
*
* A merge GitHub refuses — conflicts with the trunk, or failing required checks (the "breaking
* changes" to fix first) — stops the run: those must be resolved on the PR before syncing again.
* On success the trunk is fast-forwarded locally so the fresh cycle cuts from it.
*/
async function mergeOpenSyncPrs(config: RuntimeConfig, open: GhPullRequest[]): Promise<'continue' | 'cancel'> {
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.
*
Expand Down Expand Up @@ -553,6 +680,11 @@ export async function runSyncCommand(config: RuntimeConfig): Promise<void> {

// 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();

Expand Down
2 changes: 1 addition & 1 deletion src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const DEFAULT_BRANCH = 'main';
* The three-segment shape (`cella/sync/<stamp>`) 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.
Expand Down
97 changes: 97 additions & 0 deletions src/utils/gh.ts
Original file line number Diff line number Diff line change
@@ -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 `<prefix>/` 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 `<prefix>/`), 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)]);
}
10 changes: 10 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading
Loading