diff --git a/README.md b/README.md index 3e87188..83fed0e 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,10 @@ Per-service help: `pnpm cella --help`. | Service | Useful options | |---------|----------------| | analyze | `--log`, `--list`, `--json`, `--scope `, `--track `, `--ref `, `--diff `, `--open-diff ` | -| sync | `--log`, `--hard`, `--unpinned`, `--track `, `--ref ` | -| migrate | `--all`, `--json`, `--show `, `--extract `, `--mark ` | +| sync | `--log`, `--hard`, `--unpinned`, `--track `, `--ref `, `--keep-config` | +| migrate | `--all`, `--json`, `--show `, `--extract `, `--run [-- ]`, `--script `, `--mark ` | | audit | `--list`, `--force`, `--check-overrides` | -| forks | `--fork `, `--log`, `--hard`, `-V, --verbose` | +| forks | `--fork `, `--log`, `--hard`, `--keep-config`, `-V, --verbose` | | contributions | `--fork `, `--list`, `--json`, `--diff ` | | stats | `--coverage`, `-V, --verbose` | @@ -58,6 +58,11 @@ apps). To deviate files or folders from the template: - **`ignored`** — files completely excluded from sync (existing and new) - **`pinned`** — full fork control: existing, modified, or deleted files are preserved +An `ignored` entry may name a path your app does not have, to keep it from arriving (a file upstream +has and your app deleted). Every run warns `ignored entry not found` only when neither your app nor +upstream has the path. Upstream is read at `settings.upstreamBranch` as last fetched; before the +first fetch, every `ignored` entry missing from your app warns. + ## Upstream tracking The sync CLI tracks upstream cella one of two ways, set via `settings.upstreamTrack`: @@ -83,8 +88,9 @@ pnpm cella sync --ref 4f7d87c # sync up to this upstream commit ``` A run never syncs to a point behind the last sync: when an earlier `--ref` or `--track branch` run -went past the latest release, release tracking stops with a message until a newer release exists, -instead of reverting what the app already has. +went past the latest release, release tracking ends with a `nothing to sync` message until a newer +release exists, instead of reverting what the app already has. A `--ref` behind the last sync is +refused. Either way the run changes nothing and leaves you on the branch you started from. ## Sync workflow @@ -105,6 +111,12 @@ drift triage (`pnpm cella analyze` diffs committed HEAD) and follow-up commits h 2. **Final re-run `pnpm cella sync`** on the committed branch ships it: pushes to `origin`, opens a PR into `main` (via `gh`), and switches you back to `main`. +Before the merge starts, a first run checks that it can go ahead, and stops without changing +anything when it cannot: the temporary branch is removed again and you are back on the branch you +started from. It stops when upstream needs a newer `@cellajs/cli` than the one running, and when +upstream changed its own sync config in ways yours does not follow (see +[Upstream changes that never sync](#upstream-changes-that-never-sync)). + `sync` also runs from a linked git worktree while another worktree has `main` checked out: it never switches to `main`. It compares `main` with `origin/main` by ref and cuts the sync branch from `main`; when `main` is behind and checked out elsewhere, it cuts from `origin/main` instead @@ -153,10 +165,26 @@ listed until marked. ```bash pnpm cella migrate # open notes, with summary and links pnpm cella migrate --show # one note's README -pnpm cella migrate --extract # its folder under node_modules/.cache/cella/migrations//, to run the codemod +pnpm cella migrate --run # run its codemod, report only +pnpm cella migrate --run -- rewrite frontend/src # run it with your own arguments +pnpm cella migrate --extract # its folder under node_modules/.cache/cella/migrations// pnpm cella migrate --mark [...] # record notes as handled ``` +`--run` extracts the note and runs its codemod with `tsx` from your app root. Everything after `--` +goes to the codemod as given; without it the codemod gets `inventory` and the note's `roots`, the +report-only run. The codemod is the one script in the note folder; when a folder holds several, +name it with `--script `. + +A codemod's roots cover template-owned files too, and a file that is identical to upstream already +is the way upstream wants it: upstream may have changed it after writing the codemod, so a rewrite +there only creates drift. Before the codemod starts, `--run` notes which files are byte-identical +to the last synced upstream commit (`upstream.commit` in `cella/cella.manifest.json`, during a +staged sync the incoming commit). After the run, each of those files the codemod changed gets its +upstream content back, and the run prints how many it restored. Files your app changed keep the +codemod's rewrite. The codemod's own report still counts the restored files, in a report-only run +too. A codemod run by hand from the `--extract` folder has no such protection. + ## Sync rules For each file, `sync` compares fork and upstream **content** (blob comparison) and resolves it @@ -211,6 +239,22 @@ missing new upstream utilities that synced components rely on), so both commands `--scope all` and `--scope protected` (`--json` adds `upstreamChanged` and `upstreamChangedLines`). - `sync` prints the same list at the end of its summary, right when the drop happens. +Both lists end with one `git diff .. -- ` line per `pinned` or +`ignored` entry that holds a listed file, to paste. The range is what makes it useful: +`git diff HEAD -- shared/config` shows your whole config against the template's, while +`..` shows only what upstream changed since your last sync. + +``` +⚠ 3 protected files kept the fork version, dropping upstream changes: + ⨀ frontend/src/styling/tailwind.css · 21 lines changed upstream + ⨂ shared/config/default.ts · 4 lines changed upstream + ⨂ shared/config/staging.ts · 2 lines changed upstream + pinned/ignored files where upstream also changed since the last sync; the fork side wins on conflict, so diff each against upstream (analyze --open-diff ) and adopt what you need. + what upstream changed since the last sync, per pinned/ignored entry: + git diff e9a8d485e..a81e3353b -- frontend/src/styling/tailwind.css + git diff e9a8d485e..a81e3353b -- shared/config +``` + Diff each against upstream (`cella analyze --open-diff `) and adopt what you need. The check is relative to the last sync point: a drop that happened in an earlier sync only shows up as plain `ahead` afterwards. For that case the `↑ protected in fork` section annotates pinned @@ -230,12 +274,12 @@ its summary, and `sync` after its merge summary: paste. Files both sides changed stay in the section above, and generated output the fork regenerates itself (`sdk/gen`, `*.gen.*` files) is left out. `--list`/`--json` include these files in `--scope all` and `--scope protected` (`--json`: `upstreamOnly`). -- **Upstream override changes.** `cella/cella.config.ts` never syncs, so entries upstream adds to - its own `overrides.pinned` or `overrides.ignored` (the template your config started from) never - arrive. The report names each entry upstream added that your config lacks (`+`) and each entry - upstream dropped that your config still has (`−`). It reads upstream's config without running it - and never edits yours. `--json` puts the lists on the `cella/cella.config.ts` entry - (`upstreamOverrides`). +- **Upstream sync config changes.** `cella/cella.config.ts` never syncs, so entries upstream adds to + its own `overrides.pinned` or `overrides.ignored`, and keys it adds to `settings.packageJsonSync` + (the template your config started from), never arrive. The report names each entry upstream added + that your config lacks (`+`) and each entry upstream dropped that your config still has (`−`). It + reads upstream's config without running it and never edits yours. `--json` puts the lists on the + `cella/cella.config.ts` entry (`upstreamOverrides`). ``` ⚠ ignored paths changed upstream · 1 file under 1 entry @@ -243,11 +287,24 @@ its summary, and `sync` after its merge summary: git diff e9a8d485e..a81e3353b -- frontend/src/modules/marketing the fork left these untouched and ignored paths never sync: diff and adopt what you need. -⚠ upstream changed its sync overrides · 1 entry to review +⚠ upstream changed its sync config · 2 entries to review + pinned: backend/src/bundle-config.ts + + packageJsonSync: exports cella/cella.config.ts never syncs: add or drop these by hand where they fit your app. ``` +`analyze` only reports these. `sync` checks the sync config before a new merge and stops when it +finds entries: the merge runs on your config, so with a list that is behind, a path upstream now +ignores or pins would arrive as an ordinary synced file (upstream renamed an ignored folder, your +list still names the old one). Nothing has changed at that point. Add or drop the entries that fit +your app in `cella/cella.config.ts`, commit and rerun, or pass `--keep-config` to merge with your +config as it stands; the report then follows the merge summary as before. The rerun that commits an +already staged merge never stops for this. + +```bash +pnpm cella sync --keep-config # merge although upstream changed its sync config +``` + ## Package.json sync `packageJsonSync` controls which package.json sections sync from upstream: diff --git a/src/cli.ts b/src/cli.ts index 839f1d2..75f5159 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -34,12 +34,16 @@ type CliOptionState = Pick< | 'unpinned' | 'track' | 'ref' + | 'keepConfig' | 'force' | 'checkOverrides' | 'coverage' | 'all' | 'show' | 'extract' + | 'run' + | 'script' + | 'runArgs' | 'mark' >; @@ -57,17 +61,24 @@ type ServiceDefinition = { name: SyncService; description: string; options?: ServiceOptionDefinition[]; + /** Arguments the service takes after its options, e.g. the ones `migrate --run` hands to the codemod. */ + operands?: ServiceOptionDefinition; includeInMenu?: (context: MenuContext) => boolean; menuDescription?: (context: MenuContext) => string; }; -function readOptions(opts: Record): CliOptionState { +function readOptions(opts: Record, operands: string[] = []): CliOptionState { const scope = typeof opts.scope === 'string' ? opts.scope : undefined; if (scope && scope !== 'all' && scope !== 'risk' && scope !== 'protected') { throw new Error(`invalid --scope '${scope}'. expected one of: all, risk, protected`); } const normalizedScope = scope as AnalyzeScope | undefined; + const run = typeof opts.run === 'string' ? opts.run : undefined; + if (operands.length > 0 && !run) { + throw new Error(`unexpected argument '${operands[0]}'. arguments after the options only go with --run `); + } + return { logFile: opts.log === true, verbose: opts.verbose === true, @@ -81,12 +92,16 @@ function readOptions(opts: Record): CliOptionState { unpinned: opts.unpinned === true, track: opts.track === 'release' || opts.track === 'branch' ? opts.track : undefined, ref: typeof opts.ref === 'string' && opts.ref ? opts.ref : undefined, + keepConfig: opts.keepConfig === true, force: opts.force === true, checkOverrides: opts.checkOverrides === true, coverage: opts.coverage === true, all: opts.all === true, show: typeof opts.show === 'string' ? opts.show : undefined, extract: typeof opts.extract === 'string' ? opts.extract : undefined, + run, + script: typeof opts.script === 'string' ? opts.script : undefined, + runArgs: run ? operands : undefined, mark: Array.isArray(opts.mark) ? opts.mark.filter((id): id is string => typeof id === 'string') : undefined, }; } @@ -116,6 +131,7 @@ const serviceDefinitions: ServiceDefinition[] = [ { 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: '--ref ', description: 'pin the upstream commit for this run: sha, release tag or branch' }, + { flags: '--keep-config', description: 'merge with your sync config as it stands when upstream changed its own' }, ], includeInMenu: (context) => !context.isUpstreamRepo, menuDescription: () => 'merge upstream changes + sync package.json', @@ -128,8 +144,14 @@ const serviceDefinitions: ServiceDefinition[] = [ { flags: '--json', description: 'machine-readable output for tooling/agents' }, { flags: '--show ', description: "print one note's README" }, { flags: '--extract ', description: "write one note's folder under node_modules/.cache to run its codemod" }, + { flags: '--run ', description: "run one note's codemod; files identical to upstream stay as they are" }, + { flags: '--script ', description: 'with --run: the script to run when the note folder holds several' }, { flags: '--mark ', description: 'record notes as handled' }, ], + operands: { + flags: '[codemodArgs...]', + description: "with --run, after `--`: arguments for the codemod (default: inventory and the note's roots)", + }, includeInMenu: (context) => !context.isUpstreamRepo, }, { @@ -149,6 +171,10 @@ const serviceDefinitions: ServiceDefinition[] = [ { flags: '--log', description: 'write complete file list to cella-sync.log for each synced fork' }, { flags: '-V, --verbose', description: 'show detailed output during operations' }, { flags: '--hard', description: 'overwrite drifted files with upstream version (aggressive realignment)' }, + { + flags: '--keep-config', + description: "merge with the fork's sync config as it stands when upstream changed its own", + }, ], includeInMenu: (context) => context.hasForks, }, @@ -210,11 +236,15 @@ function addServiceCommand( for (const option of definition.options ?? []) { command.option(option.flags, option.description); } + if (definition.operands) command.argument(definition.operands.flags, definition.operands.description); - command.action((opts) => { + // Commander hands declared arguments to the action before the options + command.action((...args: unknown[]) => { + const operands = definition.operands ? (args[0] as string[]) : []; + const opts = (definition.operands ? args[1] : args[0]) as Record; setSelection({ service: definition.name, - options: readOptions(opts), + options: readOptions(opts, operands), }); }); } @@ -238,6 +268,7 @@ function buildProgram(setSelection: (selection: CliServiceSelection) => void): C ' $ cella sync --unpinned', ' $ cella sync --track branch', ' $ cella sync --ref 4f7d87c', + ' $ cella migrate --run 20261001T2116-tailwind-class-conventions -- rewrite frontend/src', ' $ cella migrate --mark 20261002T0614-config-switch', ' $ cella audit --check-overrides', ' $ cella contributions --fork raak --json', @@ -313,8 +344,8 @@ export async function parseCli(userConfig: CellaCliConfig, forkPath: string): Pr // Print header printHeader(); - // Validate config and show warnings - const warnings = validateOverrides(userConfig, forkPath); + // Validate config and show warnings (ignored entries are looked up at the upstream branch as last fetched) + const warnings = await validateOverrides(userConfig, forkPath, resolveUpstream(userConfig.settings).branchRef); if (warnings.length > 0) { printWarnings(warnings); console.info(); diff --git a/src/config/types.ts b/src/config/types.ts index ca2a15d..23392b0 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -270,6 +270,13 @@ export interface RuntimeConfig extends CellaCliConfig { */ ref?: string; + /** + * Merge with the fork's sync config as it stands (sync service). Without it, a new merge stops + * before anything changes when upstream changed its own `overrides` or `packageJsonSync` in ways + * the fork config does not follow. + */ + keepConfig?: boolean; + /** Bypass pnpm metadata cache for fresh registry data (audit service) */ force?: boolean; @@ -288,6 +295,15 @@ export interface RuntimeConfig extends CellaCliConfig { /** Write one migration note's folder where its codemod can run (migrate service) */ extract?: string; + /** Run one migration note's codemod, keeping files identical to upstream as they are (migrate service) */ + run?: string; + + /** The script to run with `run`, when the note folder holds more than one (migrate service) */ + script?: string; + + /** Arguments after `--`, passed to the codemod as given (migrate service, with `run`) */ + runArgs?: string[]; + /** Migration note ids to record as handled (migrate service) */ mark?: string[]; } @@ -405,9 +421,11 @@ export interface MergeResult { * them: upstream's hunks were dropped, not merged. Review each against upstream. */ protectedConflicts?: string[]; + /** `protectedConflicts` grouped by the `pinned` or `ignored` entry they fall under, for one diff hint per entry. */ + protectedUpstreamChanges?: ProtectedUpstreamGroup[]; /** Pasteable `..` range of short SHAs, for `git diff` hints. */ upstreamDiffRange?: string; - /** How upstream's own `overrides` changed since the merge-base, where the fork config does not follow. */ + /** How upstream's own sync config changed since the merge-base, where the fork config does not follow. */ upstreamOverrides?: UpstreamOverridesReport; /** Files flagged `upstreamOnly`, grouped by the `ignored` entry they fall under (config order). */ ignoredUpstreamChanges?: IgnoredUpstreamGroup[]; @@ -418,7 +436,7 @@ export interface MergeResult { migrationNotes?: { total: number; arrived: string[]; open: number }; } -/** Entries of one override list (`pinned` or `ignored`) that the fork config does not mirror. */ +/** Entries of one sync config list (`pinned`, `ignored` or `packageJsonSync`) that the fork config does not mirror. */ export interface OverrideListChanges { /** Entries upstream added since the merge-base that the fork list lacks. */ added: string[]; @@ -427,15 +445,28 @@ export interface OverrideListChanges { } /** - * Upstream override changes the fork should review. The sync config is managed (never merged), - * so new upstream `pinned`/`ignored` entries only reach the fork by hand. `unreadable` means - * upstream's config was missing or unparsable at one side (`base` = merge-base, `incoming` = - * upstream ref), so nothing was compared. + * Upstream sync config changes the fork should review. The sync config is managed (never merged), + * so new upstream `pinned`/`ignored` entries and `packageJsonSync` keys only reach the fork by + * hand. `unreadable` means upstream's config was missing or unparsable at one side (`base` = + * merge-base, `incoming` = upstream ref), so nothing was compared. */ export type UpstreamOverridesReport = - | { kind: 'changes'; pinned: OverrideListChanges; ignored: OverrideListChanges } + | { + kind: 'changes'; + pinned: OverrideListChanges; + ignored: OverrideListChanges; + packageJsonSync: OverrideListChanges; + } | { kind: 'unreadable'; side: 'base' | 'incoming' }; +/** Protected paths upstream also changed, under one `pinned` or `ignored` entry. */ +export interface ProtectedUpstreamGroup { + /** The `pinned` or `ignored` entry the paths fall under; the path itself when no entry covers it (a managed file). */ + entry: string; + /** Paths under the entry, sorted. */ + paths: string[]; +} + /** Upstream-only changes under one `ignored` entry. */ export interface IgnoredUpstreamGroup { /** The `ignored` entry (configured or derived from an app-owned module) the files fall under. */ diff --git a/src/services/analyze.ts b/src/services/analyze.ts index 035410d..6626ffa 100644 --- a/src/services/analyze.ts +++ b/src/services/analyze.ts @@ -111,9 +111,11 @@ export async function runAnalyze(config: RuntimeConfig): Promise { upstreamChangedLines: f.upstreamChangedLines ?? null, upstreamLinesAbsent: f.upstreamLinesAbsent ?? null, upstreamOnly: f.upstreamOnly ?? false, - // Only on the sync config: upstream override entries the fork config does not follow + // Only on the sync config: upstream override entries and packageJsonSync keys the fork config does not follow upstreamOverrides: - overrides && f.path === CONFIG_FILE ? { pinned: overrides.pinned, ignored: overrides.ignored } : null, + overrides && f.path === CONFIG_FILE + ? { pinned: overrides.pinned, ignored: overrides.ignored, packageJsonSync: overrides.packageJsonSync } + : null, })); writeStdout(JSON.stringify(out, null, 2)); return result; @@ -163,12 +165,12 @@ export async function runAnalyze(config: RuntimeConfig): Promise { }; // Print file lists first (analyze shows file lists for review) - printAnalysisFileGroups(result.files, linkOptions); + printAnalysisFileGroups(result.files, linkOptions, result); // Print summary at the end printSummary(result.summary, 'analysis summary'); - // Surface upstream changes the sync never brings in: ignored paths and upstream's own overrides + // Surface upstream changes the sync never brings in: ignored paths and upstream's own sync config printIgnoredUpstreamChanges(result); printUpstreamOverrideChanges(result); diff --git a/src/services/forks.ts b/src/services/forks.ts index 061bb21..816caca 100644 --- a/src/services/forks.ts +++ b/src/services/forks.ts @@ -129,6 +129,7 @@ async function syncFork(config: RuntimeConfig, fork: ForkConfig, forkPath: strin json: false, verbose: config.verbose, hard: config.hard, + keepConfig: config.keepConfig, }; await runSyncCommand(forkRuntimeConfig); diff --git a/src/services/merge-engine.ts b/src/services/merge-engine.ts index 9756f10..07ad4c6 100644 --- a/src/services/merge-engine.ts +++ b/src/services/merge-engine.ts @@ -16,7 +16,13 @@ import { existsSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { join } from 'node:path'; -import type { AnalysisSummary, AnalyzedFile, MergeResult, RuntimeConfig } from '../config/types'; +import type { + AnalysisSummary, + AnalyzedFile, + MergeResult, + RuntimeConfig, + UpstreamOverridesReport, +} from '../config/types'; import { cleanupLeftoverWorktrees, cleanupWorktree, getWorktreePath, registerWorktree } from '../utils/cleanup'; import { cliVersionMismatch, readUpstreamCliRange } from '../utils/cli-version'; import { DEFAULT_UPSTREAM_REMOTE, resolveUpstream } from '../utils/config'; @@ -60,7 +66,7 @@ import { storeLastSyncRef, withTemporarySyncBaseGraft, } from '../utils/git'; -import { isManagedFile } from '../utils/managed-files'; +import { CONFIG_FILE, isManagedFile } from '../utils/managed-files'; import { MANIFEST_FILE, type SyncManifest, writeSyncManifest } from '../utils/manifest'; import { arrivedNoteIds, @@ -70,7 +76,12 @@ import { readPending, writePending, } from '../utils/migration-notes'; -import { groupIgnoredUpstreamChanges, isIgnored, isPinnedForSync } from '../utils/overrides'; +import { + groupIgnoredUpstreamChanges, + groupProtectedUpstreamChanges, + isIgnored, + isPinnedForSync, +} from '../utils/overrides'; import { compareUpstreamOverrides } from '../utils/upstream-overrides'; import { type AnalyzePredicates, analyzeRefs, enrichChangeInfo } from './analyze-core'; @@ -80,6 +91,32 @@ type ProgressCallback = (message: string, detail?: string) => void; /** Step completion callback - marks a step as done with optional detail */ type StepCallback = (label: string, detail?: string) => void; +/** + * Thrown when the upstream commit is behind the fork's last sync point: the fork already has it, + * and merging it would stage a revert. `cella sync` ends as a no-op on it, unless the run pinned + * the ref itself (`--ref`). + */ +export class BehindSyncPointError extends Error {} + +/** + * Thrown before a new merge starts when upstream changed its sync config in ways the fork config + * does not follow. Carries the report so the caller can print it. + */ +export class UpstreamConfigChangedError extends Error { + readonly report: UpstreamOverridesReport; + + constructor(report: UpstreamOverridesReport) { + super( + [ + 'upstream changed its sync config and yours does not follow it: stopped before the merge, nothing changed.', + ` add or drop the entries that fit your app in ${CONFIG_FILE}, commit, then rerun.`, + ' to merge with your config as it stands, rerun with --keep-config.', + ].join('\n'), + ); + this.report = report; + } +} + /** * Convert a git remote URL to a GitHub base URL. * Supports both SSH (git@github.com:org/repo.git) and HTTPS formats. @@ -378,6 +415,8 @@ interface UpstreamContext { releaseTag?: string; /** Effective merge-base used for the 3-way analysis */ mergeBase: string; + /** Recorded sync point. Same as `mergeBase`, except under --hard/--unpinned, which merge from the natural base */ + syncPoint: string; /** Upstream GitHub URL base for commit links */ upstreamGitHubUrl?: string; /** Upstream HEAD commit info */ @@ -531,7 +570,7 @@ async function prepareUpstream( : releaseTag ? `Nothing to sync until a release past ${syncShort}; to follow the tip instead, run with --track branch.` : `Nothing to sync until '${branchRef}' moves past ${syncShort}.`; - throw new Error( + throw new BehindSyncPointError( `upstream ${upstreamLabel} (${upstreamShort}) is behind the last sync point ${syncShort}: the fork already ` + `has it, and syncing it would revert the upstream changes after it.\n${hint}`, ); @@ -550,7 +589,23 @@ async function prepareUpstream( onStep?.('fetched upstream', formatFetchedUpstreamDetail(commitCount, upstreamCommits, upstreamGitHubUrl)); - return { upstreamRef, releaseTag, mergeBase, upstreamGitHubUrl, upstreamCommit, upstreamCommits }; + return { upstreamRef, releaseTag, mergeBase, syncPoint, upstreamGitHubUrl, upstreamCommit, upstreamCommits }; +} + +/** + * Stop a new merge when upstream changed its sync config since the sync point in ways the fork + * config does not follow. The merge runs on the fork's config, so a path upstream newly ignores or + * pins would arrive as an ordinary synced file (upstream renamed an ignored folder, the fork's list + * still names the old one). Compared from the sync point, under `--hard`/`--unpinned` too: older + * changes were reported by the syncs they arrived with. `--keep-config` skips the gate; an + * unreadable upstream config or a failed comparison never stops the run. + */ +async function assertSyncConfigFollowed(config: RuntimeConfig, ctx: UpstreamContext): Promise { + if (config.keepConfig) return; + const report = await compareUpstreamOverrides(config.forkPath, ctx.syncPoint, ctx.upstreamRef, config).catch( + () => undefined, + ); + if (report?.kind === 'changes') throw new UpstreamConfigChangedError(report); } /** MergeResult fields shared by both engine modes. */ @@ -567,15 +622,19 @@ function resultMeta(config: RuntimeConfig, ctx: UpstreamContext) { /** * Upstream changes the fork never receives, shared by both engine modes: the upstream config's - * `overrides` entries the fork config does not follow (the config itself never syncs), and - * ignored files only upstream changed. Report only, read from refs (the merge state is + * `overrides` entries and `packageJsonSync` keys the fork config does not follow (the config itself + * never syncs), ignored files only upstream changed, and the protected files upstream also changed + * (`protectedConflicts`) grouped by entry. Report only, read from refs (the merge state is * irrelevant); a failure here yields no report and never fails the run. */ async function upstreamOnlyReports( config: RuntimeConfig, ctx: UpstreamContext, files: AnalyzedFile[], -): Promise> { + protectedConflicts: string[], +): Promise< + Pick +> { const { forkPath } = config; try { const [baseSha, upstreamSha, upstreamOverrides] = await Promise.all([ @@ -588,6 +647,7 @@ async function upstreamOnlyReports( upstreamDiffRange: `${baseSha}..${upstreamSha}`, upstreamOverrides, ignoredUpstreamChanges: groupIgnoredUpstreamChanges(files, config.overrides?.ignored ?? []), + protectedUpstreamChanges: groupProtectedUpstreamChanges(protectedConflicts, config), }; } catch { return {}; @@ -610,12 +670,10 @@ interface NotesInRange { async function readNotesInRange(config: RuntimeConfig, ctx: UpstreamContext): Promise { const { forkPath } = config; try { - const aggressive = config.hard === true || config.unpinned === true; - const syncBase = aggressive ? (await getEffectiveMergeBase(forkPath, 'HEAD', ctx.upstreamRef)).base : ctx.mergeBase; const [total, arrived, pending] = await Promise.all([ listNoteIds(forkPath, ctx.upstreamRef), - arrivedNoteIds(forkPath, syncBase, ctx.upstreamRef), - readPending(forkPath, syncBase), + arrivedNoteIds(forkPath, ctx.syncPoint, ctx.upstreamRef), + readPending(forkPath, ctx.syncPoint), ]); return { total: total.length, arrived, pending: [...new Set([...pending, ...arrived])] }; } catch { @@ -651,6 +709,10 @@ async function runSyncMerge( ): Promise { const { forkPath } = config; const { upstreamRef, releaseTag, mergeBase, upstreamGitHubUrl, upstreamCommit } = ctx; + + // Last gate before anything merges (the CLI version gate runs in prepareUpstream) + await assertSyncConfigFollowed(config, ctx); + const notes = await readNotesInRange(config, ctx); const { remainingConflicts, analyzedFiles, autoMergedFiles, protectedConflicts } = await applyDirectMerge( @@ -729,7 +791,7 @@ async function runSyncMerge( autoMergedFiles, protectedConflicts, ...resultMeta(config, ctx), - ...(await upstreamOnlyReports(config, ctx, analyzedFiles)), + ...(await upstreamOnlyReports(config, ctx, analyzedFiles, protectedConflicts)), ...notesResult(notes), }; } @@ -773,7 +835,8 @@ async function runAnalyzePreview( onProgress, ); await enrichChangeInfo(forkPath, analyzedFiles, ctx.mergeBase, 'HEAD', ctx.upstreamRef); - const reports = await upstreamOnlyReports(config, ctx, analyzedFiles); + const protectedConflicts = analyzedFiles.filter((f) => f.upstreamChanged).map((f) => f.path); + const reports = await upstreamOnlyReports(config, ctx, analyzedFiles, protectedConflicts); onStep?.('analysis complete', `${analyzedFiles.length} files analyzed, dry run — no changes applied`); onProgress?.('cleaning up worktree...'); @@ -785,7 +848,7 @@ async function runAnalyzePreview( summary: calculateSummary(analyzedFiles), // For analyze mode, count diverged files as potential conflicts conflicts: analyzedFiles.filter((f) => f.status === 'diverged').map((f) => f.path), - protectedConflicts: analyzedFiles.filter((f) => f.upstreamChanged).map((f) => f.path), + protectedConflicts, ...resultMeta(config, ctx), ...reports, ...notesResult(notes), diff --git a/src/services/migrate.ts b/src/services/migrate.ts index 3058ece..2595874 100644 --- a/src/services/migrate.ts +++ b/src/services/migrate.ts @@ -5,18 +5,31 @@ * fork last synced to, which the sync already fetched, so this works offline after a sync and * while a conflicted sync is still open. Each note also gets a GitHub permalink for readers * without the upstream remote. Nothing here blocks: the list is information to act on. + * + * `--run` runs a note's codemod from an extracted copy and puts back the files it changed that + * were identical to upstream (see `runNote`). */ -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import type { MergeResult, RuntimeConfig } from '../config/types'; import pc from '../utils/colors'; import { DEFAULT_UPSTREAM_REMOTE } from '../utils/config'; import { writeStdout } from '../utils/display'; -import { ensureRemote, fetch, getStoredSyncRef, git, readManifestBaseAtRef } from '../utils/git'; +import { + batchRestoreWorktreeFromRef, + ensureRemote, + fetch, + getStoredSyncRef, + git, + listIdenticalToRef, + readManifestBaseAtRef, +} from '../utils/git'; +import { readManifestBase } from '../utils/manifest'; import { formatNotesLine, + isCodemodFile, listNoteIds, type MigrationNote, NOTES_DIR, @@ -30,19 +43,27 @@ import { getGitHubBaseUrl } from './merge-engine'; /** Where `--extract` writes a note folder: inside the fork, so a codemod resolves the fork's packages. */ const EXTRACT_DIR = 'node_modules/.cache/cella/migrations'; +/** How many restored paths `--run` lists before it cuts the list short. */ +const RESTORED_LIST_MAX = 20; + +/** Make sure an upstream commit is in this clone, fetching upstream once when it is not. */ +async function ensureUpstreamCommit(config: RuntimeConfig, sha: string): Promise { + const { forkPath } = config; + const present = async () => + !!(await git(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`], forkPath, { ignoreErrors: true })); + if (await present()) return; + await ensureRemote(forkPath, DEFAULT_UPSTREAM_REMOTE, config.settings.upstreamUrl); + await fetch(forkPath, DEFAULT_UPSTREAM_REMOTE); + if (!(await present())) + throw new Error(`upstream commit ${sha.slice(0, 7)} is not reachable from '${DEFAULT_UPSTREAM_REMOTE}'`); +} + /** The upstream commit the fork last synced to, fetched if this clone lacks it; null before any sync. */ async function resolveNotesRef(config: RuntimeConfig): Promise { const { forkPath } = config; const ref = (await getStoredSyncRef(forkPath)) ?? (await readManifestBaseAtRef(forkPath, 'HEAD')); if (!ref) return null; - const present = async () => - !!(await git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], forkPath, { ignoreErrors: true })); - if (!(await present())) { - await ensureRemote(forkPath, DEFAULT_UPSTREAM_REMOTE, config.settings.upstreamUrl); - await fetch(forkPath, DEFAULT_UPSTREAM_REMOTE); - if (!(await present())) - throw new Error(`upstream commit ${ref.slice(0, 7)} is not reachable from '${DEFAULT_UPSTREAM_REMOTE}'`); - } + await ensureUpstreamCommit(config, ref); return ref; } @@ -94,12 +115,19 @@ function printNote(index: number, note: MigrationNote, url: string | undefined): if (note.summary) console.info(` ${note.summary}`); console.info(pc.dim(` read: pnpm cella migrate --show ${note.id}`)); if (url) console.info(pc.dim(` ${url}`)); - if (note.codemod) console.info(pc.dim(` codemod: pnpm cella migrate --extract ${note.id}`)); + if (note.codemod) console.info(pc.dim(` codemod: pnpm cella migrate --run ${note.id}`)); console.info(); } -/** `--extract `: write the note folder under {@link EXTRACT_DIR} and print how to run its codemod. */ -async function extractNote(config: RuntimeConfig, ref: string, id: string): Promise { +/** + * Write a note's folder under {@link EXTRACT_DIR}, byte for byte. Returns the note, the folder + * (relative to the fork) and its files. + */ +async function writeNoteFolder( + config: RuntimeConfig, + ref: string, + id: string, +): Promise<{ note: MigrationNote; target: string; files: string[] }> { const { forkPath } = config; const note = await readNote(forkPath, ref, id); if (!note) throw new Error(`no migration note '${id}' at upstream ${ref.slice(0, 7)}`); @@ -118,10 +146,96 @@ async function extractNote(config: RuntimeConfig, ref: string, id: string): Prom await mkdir(dirname(path), { recursive: true }); await writeFile(path, content); } + return { note, target, files }; +} + +/** `--extract `: write the note folder under {@link EXTRACT_DIR} and print how to run its codemod. */ +async function extractNote(config: RuntimeConfig, ref: string, id: string): Promise { + const { note, target, files } = await writeNoteFolder(config, ref, id); console.info(`${pc.green('✓')} extracted ${files.length} file(s) to ${target}/`); console.info(pc.dim(` the README's ${NOTES_DIR}/${id}/ paths are this folder.`)); - if (note.codemod) console.info(pc.dim(` pnpm exec tsx ${target}/${note.codemod} inventory ${note.roots.join(' ')}`)); + if (note.codemod) { + console.info(pc.dim(` pnpm exec tsx ${target}/${note.codemod} inventory ${note.roots.join(' ')}`)); + console.info(pc.dim(` or, keeping files identical to upstream as they are: pnpm cella migrate --run ${id}`)); + } +} + +/** + * The codemod of an extracted note: the file named with `--script`, else the one script in the + * folder itself. Throws when the folder holds no script or several. + */ +function resolveCodemod(id: string, files: string[], script?: string): string { + if (script) { + const name = script.replace(/^\.\//, ''); + if (!files.includes(name)) { + throw new Error(`migration note '${id}' has no file '${script}'. its files: ${files.join(', ')}`); + } + return name; + } + const scripts = files.filter((file) => !file.includes('/') && isCodemodFile(file)); + if (scripts.length === 0) { + throw new Error( + `migration note '${id}' ships no codemod script: its steps are manual, read them with \`pnpm cella migrate --show ${id}\`.`, + ); + } + if (scripts.length > 1) { + throw new Error( + `migration note '${id}' ships ${scripts.length} scripts (${scripts.join(', ')}): name the one to run with --script .`, + ); + } + return scripts[0]; +} + +/** + * `--run `: extract the note and run its codemod with `tsx` from the fork root, with the + * arguments given after `--`, else `inventory` (report only) and the note's roots. + * + * A codemod's roots cover template-owned files too. A file identical to upstream already is the way + * upstream wants it, so a rewrite there is drift (upstream may have changed the file after writing + * the codemod). Files identical to the last synced upstream commit before the run, and changed by + * it, get their upstream content back. That commit is `upstream.commit` in the working tree's + * manifest, during a staged sync the incoming commit. The whole working tree is compared, so the + * roots named after `--` are covered whichever they are. + */ +async function runNote(config: RuntimeConfig, ref: string, id: string): Promise { + const { forkPath } = config; + const { note, target, files } = await writeNoteFolder(config, ref, id); + const script = join(target, resolveCodemod(id, files, config.script)); + const args = config.runArgs?.length ? config.runArgs : ['inventory', ...note.roots]; + + const upstreamCommit = (await readManifestBase(forkPath)) ?? ref; + await ensureUpstreamCommit(config, upstreamCommit); + const identical = await listIdenticalToRef(forkPath, upstreamCommit); + + console.info(pc.dim(`running pnpm exec tsx ${script} ${args.join(' ')}`)); + console.info(); + const child = spawnSync('pnpm', ['exec', 'tsx', script, ...args], { cwd: forkPath, stdio: 'inherit' }); + if (child.error) throw new Error(`could not run the codemod: ${child.error.message}`); + + // Also after a failed run: a codemod that stopped halfway may have rewritten files already. + const stillIdentical = await listIdenticalToRef(forkPath, upstreamCommit); + const restored = [...identical].filter((path) => !stillIdentical.has(path)).sort(); + await batchRestoreWorktreeFromRef(forkPath, upstreamCommit, restored); + + const upstreamShort = upstreamCommit.slice(0, 9); + console.info(); + if (restored.length > 0) { + console.info(`${pc.green('✓')} restored ${restored.length} file(s) to upstream ${upstreamShort}`); + console.info(pc.dim(' the codemod changed them, and they were identical to upstream before the run:')); + for (const path of restored.slice(0, RESTORED_LIST_MAX)) console.info(pc.dim(` ${path}`)); + if (restored.length > RESTORED_LIST_MAX) { + console.info(pc.dim(` ... + ${restored.length - RESTORED_LIST_MAX} more`)); + } + } else { + console.info(pc.dim(`no file identical to upstream ${upstreamShort} was changed by the codemod.`)); + } + + if (child.status !== 0) throw new Error(`the codemod exited with code ${child.status ?? 'unknown'}`); + if (!config.runArgs?.length) { + const apply = ['rewrite', ...note.roots].join(' '); + console.info(pc.dim(`report only. to apply: pnpm cella migrate --run ${id} -- ${apply}`)); + } } /** `--mark `: drop handled notes from the pending list. */ @@ -150,6 +264,7 @@ export async function runMigrate(config: RuntimeConfig): Promise { const urlOf = (id: string) => noteUrl(githubUrl, ref, id); if (config.mark?.length) return markNotes(config, ref, pending, config.mark); + if (config.run) return runNote(config, ref, config.run); if (config.extract) return extractNote(config, ref, config.extract); if (config.show) { diff --git a/src/services/packages.ts b/src/services/packages.ts index 3751bb1..70afbaf 100644 --- a/src/services/packages.ts +++ b/src/services/packages.ts @@ -21,6 +21,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { PackageJsonSyncKey, RuntimeConfig } from '../config/types'; import pc from '../utils/colors'; +import { DEFAULT_PACKAGE_JSON_SYNC } from '../utils/config'; import { createSpinner, spinnerSuccess, spinnerText, warningMark } from '../utils/display'; import { getEffectiveMergeBase, git } from '../utils/git'; import { isIgnored } from '../utils/overrides'; @@ -493,7 +494,7 @@ async function syncPackageJson( export async function runPackages(config: RuntimeConfig, options: { conflictedFiles?: string[] } = {}): Promise { createSpinner('syncing package.json files...'); - const keysToSync = config.settings.packageJsonSync || ['dependencies', 'devDependencies']; + const keysToSync = config.settings.packageJsonSync || DEFAULT_PACKAGE_JSON_SYNC; const conflictedSet = new Set(options.conflictedFiles ?? []); const baseRef = await resolveMergeBase(config.forkPath, config.upstreamRef); diff --git a/src/services/sync.ts b/src/services/sync.ts index f13766b..a249e26 100644 --- a/src/services/sync.ts +++ b/src/services/sync.ts @@ -66,7 +66,7 @@ import { } from '../utils/git'; import { readSyncManifest } from '../utils/manifest'; import { listNoteIds, noteUrl, readNote, readPending } from '../utils/migration-notes'; -import { runMergeEngine } from './merge-engine'; +import { BehindSyncPointError, runMergeEngine, UpstreamConfigChangedError } from './merge-engine'; import { printMigrationNotesLine } from './migrate'; import { runPackages } from './packages'; @@ -209,16 +209,28 @@ export async function runSync( ): Promise { createSpinner('starting sync...'); - const result = await runMergeEngine(config, { - apply: true, - onProgress: (message) => { - spinnerText(message); - }, - onStep: (label, detail) => { - spinnerSuccess(label, detail); - createSpinner('...'); - }, - }); + let result: MergeResult; + try { + result = await runMergeEngine(config, { + apply: true, + onProgress: (message) => { + spinnerText(message); + }, + onStep: (label, detail) => { + spinnerSuccess(label, detail); + createSpinner('...'); + }, + }); + } catch (error) { + // The engine threw, usually a stop before the merge: end the spinner, and when the sync config + // gate stopped it, show what upstream changed. + spinnerSuccess(); + if (error instanceof UpstreamConfigChangedError) { + printUpstreamOverrideChanges({ upstreamOverrides: error.report }); + console.info(); + } + throw error; + } if (result.success) { spinnerSuccess(); @@ -229,7 +241,7 @@ export async function runSync( // Print summary only (no file lists for sync) printSummary(result.summary, 'merge summary'); - // Surface upstream changes the sync left out: ignored paths and upstream's own overrides + // Surface upstream changes the sync left out: ignored paths and upstream's own sync config printIgnoredUpstreamChanges(result); printUpstreamOverrideChanges(result); @@ -547,7 +559,36 @@ function finalizeWorkspace(forkPath: string): boolean { type SyncCycleOutcome = | { status: 'conflicts'; branch: TemporarySyncBranch } | { status: 'staged'; branch: TemporarySyncBranch } - | { status: 'noop' }; + | { status: 'noop'; behind?: string }; + +/** + * Return to where the cycle started and drop the throwaway branch. Only for a branch that holds + * nothing: an up-to-date run, or one that stopped before the merge. + */ +async function discardTemporarySyncBranch(forkPath: string, branch: TemporarySyncBranch): Promise { + if (branch.startBranch === 'HEAD') await switchDetached(forkPath, branch.startCommit); + else await switchBranch(forkPath, branch.startBranch); + await deleteBranch(forkPath, branch.temporaryBranch); +} + +/** + * After the merge step threw: drop the throwaway branch when the run stopped before the merge (a + * gate, an unreachable upstream, a ref that does not resolve), so nothing changed and a rerun + * starts a fresh cycle instead of shipping an empty sync branch. A branch that holds a merge or + * changes stays as it is. Returns whether the branch was dropped. + */ +async function discardUnusedSyncBranch(forkPath: string, branch: TemporarySyncBranch): Promise { + const unused = + (await getCurrentBranch(forkPath)) === branch.temporaryBranch && + !(await mergeInProgress(forkPath)) && + (await isClean(forkPath)); + if (!unused) return false; + + await discardTemporarySyncBranch(forkPath, branch); + const start = branch.startBranch === 'HEAD' ? `at ${branch.startCommit.slice(0, 7)}` : `on '${branch.startBranch}'`; + console.info(pc.dim(`removed the unused '${branch.temporaryBranch}', back ${start}.`)); + return true; +} /** * Run one sync cycle on a fresh temporary branch: cut the branch, merge upstream (+ packages), @@ -555,14 +596,27 @@ type SyncCycleOutcome = * * - `conflicts`: merge staged with unresolved conflicts, left for IDE resolution. * - `staged`: clean merge staged, ready to commit. - * - `noop`: upstream had nothing new; the throwaway branch was deleted and the original branch - * restored. + * - `noop`: upstream had nothing new, or (`behind`) only a point the last sync already went past; + * the throwaway branch was deleted and the original branch restored. + * + * A run that stops before the merge throws, after the throwaway branch is dropped the same way. */ async function runSyncCycle(config: RuntimeConfig): Promise { const { forkPath } = config; const branch = await setupTemporarySyncBranch(config); - const result = await runSync(config, { stagedBranch: branch.temporaryBranch }); + let result: MergeResult; + try { + result = await runSync(config, { stagedBranch: branch.temporaryBranch }); + } catch (error) { + const discarded = await discardUnusedSyncBranch(forkPath, branch).catch(() => false); + // Tracking a release or branch the last sync went past is nothing to sync, not a failure. + // A pinned --ref behind the sync point stays an error: the run cannot do what it was asked. + if (discarded && error instanceof BehindSyncPointError && !config.ref) { + return { status: 'noop', behind: error.message }; + } + throw error; + } if (config.settings.syncWithPackages !== false) { // Run package sync even when the merge left conflicts: package.json files that are @@ -575,9 +629,7 @@ async function runSyncCycle(config: RuntimeConfig): Promise { // Nothing staged: already up to date. Return to where the run started and drop the throwaway // branch (a fresh cycle never starts on a sync branch: those resume or ship instead). if (!(await mergeInProgress(forkPath))) { - if (branch.startBranch === 'HEAD') await switchDetached(forkPath, branch.startCommit); - else await switchBranch(forkPath, branch.startBranch); - await deleteBranch(forkPath, branch.temporaryBranch); + await discardTemporarySyncBranch(forkPath, branch); return { status: 'noop' }; } @@ -735,7 +787,9 @@ async function guardAgainstOpenSyncPr(config: RuntimeConfig): Promise<'continue' * Idempotent. Each run advances the sync one stage and never commits and ships in the same run: * - Anywhere else: require a clean tree, cut a fresh temporary branch and merge upstream; a * clean merge is committed right away (the run stops there, for drift triage), a conflicted - * one stops for IDE resolution. + * one stops for IDE resolution. A run that stops before the merge (upstream needs a newer CLI, + * upstream changed its sync config, upstream is behind the last sync point) drops the branch + * again and leaves everything as it was. * - On a sync branch with a merge in progress: commit it (resume after conflicts), then stop. * - On a sync branch with the merge already committed: push and open the PR, then switch back * to the trunk (detach at it when another worktree has it checked out). Shipping is @@ -783,7 +837,12 @@ export async function runSyncCommand(config: RuntimeConfig): Promise { console.info(); if (outcome.status === 'noop') { - console.info(pc.green('already up to date with upstream — nothing to sync.')); + if (outcome.behind) { + console.info(pc.green('nothing to sync — the last sync already went past this upstream point.')); + for (const line of outcome.behind.split('\n')) console.info(pc.dim(` ${line}`)); + } else { + console.info(pc.green('already up to date with upstream — nothing to sync.')); + } return; } diff --git a/src/utils/config.ts b/src/utils/config.ts index 1ed3855..3ff31e6 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -7,7 +7,7 @@ import { existsSync, realpathSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { z } from 'zod'; -import { type CellaCliConfig, cellaConfigSchema, type SyncSettings } from '../config/types'; +import { type CellaCliConfig, cellaConfigSchema, type PackageJsonSyncKey, type SyncSettings } from '../config/types'; import { CONFIG_FILE } from './managed-files'; import { resolveAppModuleFolders } from './module-territory'; @@ -31,6 +31,9 @@ export const DEFAULT_SYNC_PREFIX = 'cella/sync'; */ export const DEFAULT_RELEASE_BASE = 'main'; +/** The package.json keys that sync when a config omits `packageJsonSync`. */ +export const DEFAULT_PACKAGE_JSON_SYNC: PackageJsonSyncKey[] = ['dependencies', 'devDependencies']; + /** * Whether `branch` is one of the temporary integration branches `cella sync` cuts, i.e. it * lives under the internal `cella/sync/` namespace. diff --git a/src/utils/display.ts b/src/utils/display.ts index eb49309..7314384 100644 --- a/src/utils/display.ts +++ b/src/utils/display.ts @@ -523,6 +523,27 @@ function formatUpstreamChangedLines(file: AnalyzedFile): string { return pc.yellow(` · ${n} ${n === 1 ? 'line' : 'lines'} changed upstream`); } +/** What a protected-but-behind list needs for its `git diff` hints. */ +type ProtectedDiffSource = Pick; + +/** + * Pasteable `git diff` lines for a protected-but-behind list: one per `pinned` or `ignored` entry + * that holds a listed path, under a line that introduces them. The range runs from the last sync + * point to upstream, so each shows only what upstream changed; a diff of the fork against upstream + * would show the fork's own changes too. Empty without a range or a matching entry. + */ +function protectedDiffHints(source: ProtectedDiffSource | undefined, paths: string[]): string[] { + const range = source?.upstreamDiffRange; + if (!range) return []; + const listed = new Set(paths); + const groups = (source?.protectedUpstreamChanges ?? []).filter((group) => group.paths.some((p) => listed.has(p))); + if (groups.length === 0) return []; + return [ + 'what upstream changed since the last sync, per pinned/ignored entry:', + ...groups.map((group) => ` git diff ${range} -- ${shellPath(group.entry)}`), + ]; +} + /** Per-file detail for pinned `ahead` files: upstream lines the fork lacks (only when > 0). */ function formatUpstreamLinesAbsent(file: AnalyzedFile): string { const n = file.upstreamLinesAbsent ?? 0; @@ -534,9 +555,15 @@ function formatUpstreamLinesAbsent(file: AnalyzedFile): string { * behind, ahead (protected), protected-but-behind, drifted, and diverged. * * Protected-but-behind covers every `pinned`-status file (both changed, fork wins) plus - * ignored files upstream also changed, so there is no separate `pinned` group. + * ignored files upstream also changed, so there is no separate `pinned` group. With `diffSource` + * (the merge result) it ends with one `git diff` line per pinned/ignored entry. */ -export function printAnalysisFileGroups(files: AnalyzedFile[], linkOptions: LinkOptions): void { +export function printAnalysisFileGroups( + files: AnalyzedFile[], + linkOptions: LinkOptions, + diffSource?: ProtectedDiffSource, +): void { + const protectedBehind = findProtectedBehind(files); printFileGroup(files, 'behind', linkOptions, { title: pc.cyan('↓ behind on upstream'), }); @@ -549,17 +576,18 @@ export function printAnalysisFileGroups(files: AnalyzedFile[], linkOptions: Link 'deliberately or not: diff and decide.', ], }); - printFileSection( - findProtectedBehind(files), - `${warningMark} ${pc.yellow('protected but behind upstream')}`, - linkOptions, - { - icon: protectionIcon, - suffix: formatUpstreamChangedLines, - dateSource: 'upstream', - hint: PROTECTED_BEHIND_HINT, - }, - ); + printFileSection(protectedBehind, `${warningMark} ${pc.yellow('protected but behind upstream')}`, linkOptions, { + icon: protectionIcon, + suffix: formatUpstreamChangedLines, + dateSource: 'upstream', + hint: [ + PROTECTED_BEHIND_HINT, + ...protectedDiffHints( + diffSource, + protectedBehind.map((file) => file.path), + ), + ], + }); printFileGroup(files, 'drifted', linkOptions, { title: `${warningMark} ${pc.yellow('drifted from upstream')}`, hint: 'these files have fork changes but are not pinned or ignored.', @@ -650,7 +678,7 @@ function printProtectedConflicts(result: MergeResult): void { const icon = file ? protectionIcon(file) : statusConfig.pinned.icon; console.info(` ${icon} ${path}${file ? formatUpstreamChangedLines(file) : ''}`); } - console.info(pc.dim(` ${PROTECTED_BEHIND_HINT}`)); + for (const line of [PROTECTED_BEHIND_HINT, ...protectedDiffHints(result, paths)]) console.info(pc.dim(` ${line}`)); } /** `1 file`, `2 files`: a count with its noun. */ @@ -691,12 +719,13 @@ export function printIgnoredUpstreamChanges(result: MergeResult): void { } /** - * Upstream `overrides` entries the fork config does not follow (`MergeResult.upstreamOverrides`): - * entries upstream added that the fork lacks, and entries upstream dropped that the fork still - * has. The sync config is managed and never merges, so these only reach the fork by hand. - * Silent when there is nothing to report; one dim line when upstream's config was unreadable. + * Upstream sync config entries the fork config does not follow (`MergeResult.upstreamOverrides`): + * `overrides` entries and `packageJsonSync` keys upstream added that the fork lacks, and ones + * upstream dropped that the fork still has. The sync config is managed and never merges, so these + * only reach the fork by hand. Silent when there is nothing to report; one dim line when upstream's + * config was unreadable. */ -export function printUpstreamOverrideChanges(result: MergeResult): void { +export function printUpstreamOverrideChanges(result: Pick): void { const report = result.upstreamOverrides; if (!report) return; @@ -704,20 +733,20 @@ export function printUpstreamOverrideChanges(result: MergeResult): void { if (report.kind === 'unreadable') { const side = report.side === 'base' ? 'the last sync point' : 'the incoming ref'; console.info( - pc.dim(`upstream overrides not compared: upstream ${CONFIG_FILE} is missing or unreadable at ${side}`), + pc.dim(`upstream sync config not compared: upstream ${CONFIG_FILE} is missing or unreadable at ${side}`), ); return; } const lines: string[] = []; - for (const list of ['pinned', 'ignored'] as const) { + for (const list of ['pinned', 'ignored', 'packageJsonSync'] as const) { for (const entry of report[list].added) lines.push(` ${pc.green('+')} ${list}: ${entry}`); for (const entry of report[list].removed) { lines.push(` ${pc.red('−')} ${list}: ${entry}${pc.dim(' · dropped upstream, still in your config')}`); } } console.info( - `${warningMark} ${pc.yellow(`upstream changed its sync overrides · ${plural(lines.length, 'entry', 'entries')} to review`)}`, + `${warningMark} ${pc.yellow(`upstream changed its sync config · ${plural(lines.length, 'entry', 'entries')} to review`)}`, ); for (const line of lines) console.info(line); console.info(pc.dim(` ${CONFIG_FILE} never syncs: add or drop these by hand where they fit your app.`)); diff --git a/src/utils/git.ts b/src/utils/git.ts index 6cb00ac..f3947cc 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -660,6 +660,32 @@ export async function restoreWorktreeFromRef(cwd: string, ref: string, filePath: await git(['restore', `--source=${ref}`, '--worktree', '--', filePath], cwd); } +/** + * Restore paths from a ref into the working tree only, in batches, leaving the index untouched. + * Paths are taken literally, so a file name with glob characters (`[id].tsx`) restores that file. + */ +export async function batchRestoreWorktreeFromRef(cwd: string, ref: string, filePaths: string[]): Promise { + const batchSize = 200; + for (let i = 0; i < filePaths.length; i += batchSize) { + const batch = filePaths.slice(i, i + batchSize); + await git(['--literal-pathspecs', 'restore', `--source=${ref}`, '--worktree', '--', ...batch], cwd); + } +} + +/** + * Paths at `ref` whose working tree copy is byte-identical to it. Renames are not detected, so a + * file the working tree moved away counts as changed at its old path. + */ +export async function listIdenticalToRef(cwd: string, ref: string): Promise> { + const nulSeparated = (out: string) => out.split('\0').filter(Boolean); + const [tracked, changed] = await Promise.all([ + git(['ls-tree', '-r', '--name-only', '-z', ref], cwd), + git(['diff', '--name-only', '--no-renames', '-z', ref, '--'], cwd), + ]); + const differs = new Set(nulSeparated(changed)); + return new Set(nulSeparated(tracked).filter((path) => !differs.has(path))); +} + /** * Get the merge base between two refs. */ diff --git a/src/utils/migration-notes.ts b/src/utils/migration-notes.ts index baab1bd..952837e 100644 --- a/src/utils/migration-notes.ts +++ b/src/utils/migration-notes.ts @@ -43,6 +43,11 @@ export interface MigrationNote { codemod: string | null; } +/** Whether a file in a note folder is a codemod script: a `.ts` file that is not a test. */ +export function isCodemodFile(name: string): boolean { + return name.endsWith('.ts') && !name.endsWith('.test.ts'); +} + /** Whether a path is upstream-only: the sync never brings it in and removes a fork copy. */ export function isUpstreamOnly(filePath: string): boolean { return filePath === NOTES_DIR || filePath.startsWith(`${NOTES_DIR}/`); @@ -86,7 +91,7 @@ export function parseNote(id: string, source: string, fileNames: string[]): Migr .split(',') .map((root) => root.trim()) .filter(Boolean), - codemod: fileNames.find((name) => name.endsWith('.ts') && !name.endsWith('.test.ts')) ?? null, + codemod: fileNames.find(isCodemodFile) ?? null, }; } diff --git a/src/utils/overrides.ts b/src/utils/overrides.ts index d966378..3f4eea6 100644 --- a/src/utils/overrides.ts +++ b/src/utils/overrides.ts @@ -7,8 +7,9 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import type { AnalyzedFile, CellaCliConfig, IgnoredUpstreamGroup } from '../config/types'; +import type { AnalyzedFile, CellaCliConfig, IgnoredUpstreamGroup, ProtectedUpstreamGroup } from '../config/types'; import { warningMark } from './display'; +import { fileExistsAtRef } from './git'; import { isGeneratedFile, isManagedFile } from './managed-files'; import { isUpstreamOnly } from './migration-notes'; @@ -76,6 +77,34 @@ export function groupIgnoredUpstreamChanges(files: AnalyzedFile[], ignored: stri return entries.flatMap((entry) => groups.get(entry) ?? []); } +/** + * Group protected paths upstream also changed by the `pinned` or `ignored` entry they fall under, + * so a report can print one diff hint per entry instead of one per file. + * + * A path under nested entries lands on the most specific one; a path no entry covers (a managed + * file) stands for itself. Groups follow the order the paths come in. + * + * @param paths - Protected paths upstream also changed (`MergeResult.protectedConflicts`) + * @param config - The fork's sync config + */ +export function groupProtectedUpstreamChanges(paths: string[], config: CellaCliConfig): ProtectedUpstreamGroup[] { + const entries = [...(config.overrides?.pinned ?? []), ...(config.overrides?.ignored ?? [])] + .map((entry) => entry.replace(/\/+$/, '')) + .filter(Boolean); + const mostSpecificFirst = [...new Set(entries)].sort((a, b) => b.length - a.length); + const groups = new Map(); + + for (const path of paths) { + const entry = mostSpecificFirst.find((candidate) => isUnderAnyFolder(path, [candidate])) ?? path; + const group = groups.get(entry) ?? { entry, paths: [] }; + group.paths.push(path); + groups.set(entry, group); + } + + for (const group of groups.values()) group.paths.sort(); + return [...groups.values()]; +} + /** * Resolve effective pin status for a sync run, honoring the --unpinned flag. * @@ -109,12 +138,22 @@ function hasGlobChars(entry: string): boolean { * Checks for: * - Pinned entries using glob patterns (no longer supported) * - Pinned entries that don't exist in fork - * - Ignored entries that don't exist in fork + * - Ignored entries that exist neither in the fork nor upstream + * + * A fork ignores some paths to keep them from arriving (a file upstream has and the fork deleted), + * so an ignored entry missing from the fork is fine while upstream has the path. That is read at + * `upstreamRef` as last fetched, without fetching: when the ref does not resolve (nothing fetched + * yet), every ignored entry missing from the fork warns. * * @param config - The sync config to validate * @param forkPath - Path to the fork repository + * @param upstreamRef - Upstream ref to look ignored entries up at (e.g. 'cella-upstream/main') */ -export function validateOverrides(config: CellaCliConfig, forkPath: string): ConfigWarning[] { +export async function validateOverrides( + config: CellaCliConfig, + forkPath: string, + upstreamRef?: string, +): Promise { const warnings: ConfigWarning[] = []; // Check pinned entries @@ -145,6 +184,7 @@ export function validateOverrides(config: CellaCliConfig, forkPath: string): Con message: `ignored entry uses glob (not supported, use a path or folder): ${entry}`, }); } else if (!existsSync(join(forkPath, entry.replace(/\/+$/, '')))) { + if (upstreamRef && (await fileExistsAtRef(forkPath, upstreamRef, entry.replace(/\/+$/, '')))) continue; warnings.push({ type: 'ignored-not-found', pattern: entry, diff --git a/src/utils/upstream-overrides.ts b/src/utils/upstream-overrides.ts index 57cb7d3..5a21a5d 100644 --- a/src/utils/upstream-overrides.ts +++ b/src/utils/upstream-overrides.ts @@ -2,23 +2,27 @@ * Upstream override changes for sync CLI. * * The fork's `cella/cella.config.ts` is a managed file: the sync never merges it, so entries - * upstream adds to (or drops from) its own `overrides.pinned`/`overrides.ignored` never reach - * the fork on their own. This module reads upstream's config at the merge-base and at the - * incoming ref, extracts both lists without evaluating the file (ts-morph, syntax only), and - * keeps the entries the fork config does not follow: upstream additions the fork lacks, and - * upstream removals the fork still carries. Report only: the fork config is never written. + * upstream adds to (or drops from) its own `overrides.pinned`/`overrides.ignored`, and keys it + * adds to or drops from `settings.packageJsonSync`, never reach the fork on their own. This module + * reads upstream's config at the merge-base and at the incoming ref, extracts the lists without + * evaluating the file (ts-morph, syntax only), and keeps the entries the fork config does not + * follow: upstream additions the fork lacks, and upstream removals the fork still carries. Report + * only: the fork config is never written. */ import { Node, type ObjectLiteralExpression, Project, type SourceFile, SyntaxKind } from 'ts-morph'; import type { CellaCliConfig, OverrideListChanges, UpstreamOverridesReport } from '../config/types'; +import { DEFAULT_PACKAGE_JSON_SYNC } from './config'; import { git } from './git'; import { CONFIG_FILE_PATHS } from './managed-files'; import { isUnderAnyFolder } from './overrides'; -/** The two override lists, as plain string entries. */ +/** The lists the comparison reads from a sync config, as plain string entries. */ export interface OverrideLists { pinned: string[]; ignored: string[]; + /** `settings.packageJsonSync`, the default when the config names none; null when it cannot be read statically. */ + packageJsonSync: string[] | null; } const LIST_KEYS = ['pinned', 'ignored'] as const; @@ -68,12 +72,39 @@ function readProperty(object: ObjectLiteralExpression, name: string, sourceFile: return undefined; } +/** String entries of an array literal; null when `node` is not one. Other elements (spreads, variables) are skipped. */ +function readStringEntries(node: Node): string[] | null { + if (!Node.isArrayLiteralExpression(node)) return null; + const entries: string[] = []; + for (const element of node.getElements()) { + if (Node.isStringLiteral(element) || Node.isNoSubstitutionTemplateLiteral(element)) { + entries.push(element.getLiteralValue()); + } + } + return entries; +} + +/** + * `settings.packageJsonSync` of a config object: the default keys when `settings` or the list is + * absent, null when either is present without a statically readable value. + */ +function readPackageJsonSync(config: ObjectLiteralExpression, sourceFile: SourceFile): string[] | null { + const settings = readProperty(config, 'settings', sourceFile); + if (settings === undefined) return [...DEFAULT_PACKAGE_JSON_SYNC]; + if (!settings || !Node.isObjectLiteralExpression(settings)) return null; + const keys = readProperty(settings, 'packageJsonSync', sourceFile); + if (keys === undefined) return [...DEFAULT_PACKAGE_JSON_SYNC]; + return keys ? readStringEntries(keys) : null; +} + /** - * Extract `overrides.pinned` and `overrides.ignored` from config source without evaluating it. + * Extract `overrides.pinned`, `overrides.ignored` and `settings.packageJsonSync` from config source + * without evaluating it. * * Only string literals count; other array elements (spreads, variables) are skipped. A missing - * `overrides` or list reads as empty. Returns null when the config object or a present - * `overrides`/list cannot be read statically. + * `overrides` or list reads as empty, a missing `packageJsonSync` as the default keys. Returns null + * when the config object or a present `overrides`/list cannot be read statically; an unreadable + * `packageJsonSync` alone reads as null, so the override lists still compare. */ export function parseOverrideLists(source: string): OverrideLists | null { try { @@ -82,20 +113,21 @@ export function parseOverrideLists(source: string): OverrideLists | null { const config = findConfigObject(sourceFile); if (!config) return null; + const lists: OverrideLists = { + pinned: [], + ignored: [], + packageJsonSync: readPackageJsonSync(config, sourceFile), + }; const overrides = readProperty(config, 'overrides', sourceFile); - if (overrides === undefined) return { pinned: [], ignored: [] }; + if (overrides === undefined) return lists; if (!overrides || !Node.isObjectLiteralExpression(overrides)) return null; - const lists: OverrideLists = { pinned: [], ignored: [] }; for (const key of LIST_KEYS) { const list = readProperty(overrides, key, sourceFile); if (list === undefined) continue; - if (!list || !Node.isArrayLiteralExpression(list)) return null; - for (const element of list.getElements()) { - if (Node.isStringLiteral(element) || Node.isNoSubstitutionTemplateLiteral(element)) { - lists[key].push(element.getLiteralValue()); - } - } + const entries = list ? readStringEntries(list) : null; + if (!entries) return null; + lists[key] = entries; } return lists; } catch { @@ -114,13 +146,14 @@ function normalizeEntry(entry: string): string { * - added: new upstream entries the fork list lacks (an exact entry or a parent folder covers it). * - removed: entries upstream dropped that the fork list still carries verbatim. * - * Entries upstream had all along are never reported, whether or not the fork has them. + * `packageJsonSync` keys compare by name, and not at all when a side could not be read. Entries + * upstream had all along are never reported, whether or not the fork has them. */ export function diffOverrideLists( base: OverrideLists, incoming: OverrideLists, fork: OverrideLists, -): Record<(typeof LIST_KEYS)[number], OverrideListChanges> { +): Record<(typeof LIST_KEYS)[number] | 'packageJsonSync', OverrideListChanges> { const diff = (key: (typeof LIST_KEYS)[number]): OverrideListChanges => { const baseSet = new Set(base[key].map(normalizeEntry).filter(Boolean)); const incomingSet = new Set(incoming[key].map(normalizeEntry).filter(Boolean)); @@ -130,7 +163,15 @@ export function diffOverrideLists( removed: [...baseSet].filter((entry) => !incomingSet.has(entry) && forkEntries.includes(entry)), }; }; - return { pinned: diff('pinned'), ignored: diff('ignored') }; + const diffKeys = (): OverrideListChanges => { + const [baseKeys, incomingKeys, forkKeys] = [base.packageJsonSync, incoming.packageJsonSync, fork.packageJsonSync]; + if (!baseKeys || !incomingKeys || !forkKeys) return { added: [], removed: [] }; + return { + added: [...new Set(incomingKeys)].filter((key) => !baseKeys.includes(key) && !forkKeys.includes(key)), + removed: [...new Set(baseKeys)].filter((key) => !incomingKeys.includes(key) && forkKeys.includes(key)), + }; + }; + return { pinned: diff('pinned'), ignored: diff('ignored'), packageJsonSync: diffKeys() }; } /** Upstream's config source at a ref (current path first, then the legacy root path), or null. */ @@ -143,8 +184,8 @@ async function readConfigSourceAtRef(repoPath: string, ref: string): Promise n + changes[key].added.length + changes[key].removed.length, 0); + const count = Object.values(changes).reduce((n, list) => n + list.added.length + list.removed.length, 0); return count > 0 ? { kind: 'changes', ...changes } : undefined; } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index c81b52a..19874de 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -91,4 +91,55 @@ describe('parseCli', () => { process.argv = ['node', 'cella', 'analyze', '--ref', 'v0.14.0']; expect((await parseCli(baseConfig, '/tmp/fork')).ref).toBe('v0.14.0'); }); + + it('parses --keep-config for sync and forks', async () => { + process.argv = ['node', 'cella', 'sync']; + expect((await parseCli(baseConfig, '/tmp/fork')).keepConfig).toBe(false); + + process.argv = ['node', 'cella', 'sync', '--ref', '4f7d87c', '--keep-config']; + expect((await parseCli(baseConfig, '/tmp/fork')).keepConfig).toBe(true); + + process.argv = ['node', 'cella', 'forks', '--fork', 'raak', '--keep-config']; + expect((await parseCli(baseConfig, '/tmp/fork')).keepConfig).toBe(true); + }); + + it('hands the arguments after -- to the codemod of migrate --run as given', async () => { + const id = '20261001T2116-tailwind-class-conventions'; + process.argv = ['node', 'cella', 'migrate', '--run', id, '--', 'rewrite', 'frontend/src', '--module', '~/app x']; + + const config = await parseCli(baseConfig, '/tmp/fork'); + + expect(config.service).toBe('migrate'); + expect(config.run).toBe(id); + expect(config.runArgs).toEqual(['rewrite', 'frontend/src', '--module', '~/app x']); + expect(config.script).toBeUndefined(); + }); + + it('parses migrate --run without arguments and with a named script', async () => { + process.argv = [ + 'node', + 'cella', + 'migrate', + '--run', + '20261001T0909-line-width-150', + '--script', + 'collapse-objects.ts', + ]; + + const config = await parseCli(baseConfig, '/tmp/fork'); + + expect(config.run).toBe('20261001T0909-line-width-150'); + expect(config.script).toBe('collapse-objects.ts'); + expect(config.runArgs).toEqual([]); + }); + + it('refuses arguments after the options without --run', async () => { + process.argv = ['node', 'cella', 'migrate', '--', 'rewrite', 'frontend/src']; + await expect(parseCli(baseConfig, '/tmp/fork')).rejects.toThrow(/unexpected argument 'rewrite'.*--run /); + + process.argv = ['node', 'cella', 'migrate', '--mark', '20261002T0614-config-switch']; + const config = await parseCli(baseConfig, '/tmp/fork'); + expect(config.mark).toEqual(['20261002T0614-config-switch']); + expect(config.runArgs).toBeUndefined(); + }); }); diff --git a/tests/e2e/helpers/test-env.ts b/tests/e2e/helpers/test-env.ts index 2d5ab2c..fb9221a 100644 --- a/tests/e2e/helpers/test-env.ts +++ b/tests/e2e/helpers/test-env.ts @@ -190,9 +190,11 @@ export function buildRuntimeConfig( trackOverride?: 'release' | 'branch'; /** A pinned upstream ref (`--ref`) */ ref?: string; + /** Merge with the fork config as it stands (`--keep-config`) */ + keepConfig?: boolean; } = {}, ): RuntimeConfig { - const { service = 'analyze', pinned = [], ignored = [], track = 'branch', trackOverride, ref } = options; + const { service = 'analyze', pinned = [], ignored = [], track = 'branch', trackOverride, ref, keepConfig } = options; const config: CellaCliConfig = { settings: { @@ -213,6 +215,7 @@ export function buildRuntimeConfig( service, track: trackOverride, ref, + keepConfig, logFile: false, list: false, json: false, diff --git a/tests/e2e/migrate-run.test.ts b/tests/e2e/migrate-run.test.ts new file mode 100644 index 0000000..a7674bd --- /dev/null +++ b/tests/e2e/migrate-run.test.ts @@ -0,0 +1,236 @@ +/** + * E2E tests for `cella migrate --run `: the note's codemod runs from its extracted copy with + * `tsx`, and files that were identical to the last synced upstream commit get their upstream + * content back when the codemod changed them. + * + * `pnpm exec tsx` is replaced by this repo's own `tsx` (the temp fork has no node_modules), so the + * codemod really runs, from the fork root, with the arguments the service hands it. + */ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { runMigrate } from '../../src/services/migrate'; +import { runSync } from '../../src/services/sync'; +import { commitSquash, stageAll } from '../../src/utils/git'; +import { + buildRuntimeConfig, + createTestEnv, + fetchUpstream, + makeCommit, + readRepoFile, + type TestEnv, +} from './helpers/test-env'; + +/** Every `pnpm exec tsx` the service spawned: where, and with which arguments. */ +const { codemodRuns } = vi.hoisted(() => ({ codemodRuns: [] as Array<{ cwd: string; args: string[] }> })); + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + const { resolve } = await import('node:path'); + const tsx = resolve(__dirname, '../../node_modules/.bin/tsx'); + const spawnSync = (command: string, args: string[], options: { cwd: string }) => { + if (command !== 'pnpm' || args[0] !== 'exec' || args[1] !== 'tsx') return actual.spawnSync(command, args, options); + codemodRuns.push({ cwd: options.cwd, args: args.slice(2) }); + return actual.spawnSync(tsx, args.slice(2), { cwd: options.cwd, encoding: 'utf8', stdio: 'pipe' }); + }; + return { ...actual, spawnSync }; +}); + +const NOTE = '20261001T2116-icon-classes'; +const MANUAL = '20261002T0000-manual-note'; +const TWO_SCRIPTS = '20261003T0000-two-scripts'; +const EXTRACTED = `node_modules/.cache/cella/migrations/${NOTE}/icon-classes.ts`; + +const readme = (title: string, roots?: string) => + `---\nsyncBreaking: true\nclientCacheBump: false\n${roots ? `roots: ${roots}\n` : ''}---\n\n# ${title}\n\nWhat the app changes.\n`; + +/** + * A codemod in the shape upstream ships: ` `, here turning `icon-xs` + * into `size-3`. `crash` rewrites, deletes a file and exits 3, like a codemod that fails halfway. + */ +const CODEMOD = `import { readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +type Mode = 'inventory' | 'rewrite' | 'crash'; +const [mode, ...roots] = process.argv.slice(2) as [Mode, ...string[]]; +if (!['inventory', 'rewrite', 'crash'].includes(mode)) { + console.error('Usage: '); + process.exit(1); +} + +const list = (dir: string): string[] => + readdirSync(dir).flatMap((name) => { + const full = join(dir, name); + if (statSync(full).isDirectory()) return list(full); + return full.endsWith('.ts') ? [full] : []; + }); + +let count = 0; +for (const file of roots.flatMap(list)) { + const text = readFileSync(file, 'utf8'); + if (!text.includes('icon-xs')) continue; + count++; + if (mode !== 'inventory') writeFileSync(file, text.replaceAll('icon-xs', 'size-3')); +} +if (mode === 'crash') { + rmSync('frontend/src/removed.ts'); + process.exit(3); +} +console.log(\`\${mode}: \${count} file(s)\`); +`; + +describe('migrate --run e2e', () => { + let env: TestEnv; + let lines: string[]; + let upstreamCommit: string; + + const git = (cmd: string) => execSync(`git ${cmd}`, { cwd: env.forkPath, encoding: 'utf8' }).trim(); + const run = (options: { run: string; runArgs?: string[]; script?: string }) => + runMigrate({ ...buildRuntimeConfig(env, { service: 'migrate' }), ...options }); + + /** + * The app has its own file and its own edit of a template file. Upstream then ships the notes, + * plus template files that still hold the class the codemod rewrites: upstream changed them + * after writing the codemod. The sync that brings those in is staged, not committed. + */ + beforeEach(async () => { + env = createTestEnv(); + codemodRuns.length = 0; + lines = []; + + makeCommit(env.upstreamPath, { + files: { 'frontend/src/shared.ts': 'export const shared = "icon-xs";\n' }, + message: 'feat: shared', + }); + fetchUpstream(env.forkPath); + git('merge -q --ff-only cella-upstream/main'); + makeCommit(env.forkPath, { + files: { + 'frontend/src/app-only.ts': 'export const mine = "icon-xs";\n', + 'frontend/src/shared.ts': 'export const shared = "icon-xs app";\n', + }, + message: 'feat: app files', + }); + + upstreamCommit = makeCommit(env.upstreamPath, { + files: { + 'frontend/src/template.ts': 'export const template = "icon-xs";\n', + 'frontend/src/removed.ts': 'export const removed = "icon-xs";\n', + 'backend/src/template.ts': 'export const backend = "icon-xs";\n', + 'mcp/src/[id].ts': 'export const route = "icon-xs";\n', + 'mcp/src/i.ts': 'export const index = "icon-xs";\n', + [`cella/migrations/${NOTE}/README.md`]: readme('Icon classes', 'frontend/src'), + [`cella/migrations/${NOTE}/icon-classes.ts`]: CODEMOD, + [`cella/migrations/${NOTE}/icon-classes.test.ts`]: 'export {};\n', + [`cella/migrations/${MANUAL}/README.md`]: readme('Manual note'), + [`cella/migrations/${TWO_SCRIPTS}/README.md`]: readme('Two scripts', 'frontend/src'), + [`cella/migrations/${TWO_SCRIPTS}/icon-classes.ts`]: CODEMOD, + [`cella/migrations/${TWO_SCRIPTS}/lib.ts`]: 'export const lib = 1;\n', + }, + message: 'feat: notes and template files', + }); + fetchUpstream(env.forkPath); + + vi.spyOn(console, 'info').mockImplementation((...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }); + await runSync(buildRuntimeConfig(env, { service: 'sync' })); + lines.length = 0; + }); + + afterEach(() => { + vi.restoreAllMocks(); + env.cleanup(); + }); + + it('passes the arguments after -- as given, rewrites app files and restores files identical to upstream', async () => { + await run({ run: NOTE, runArgs: ['rewrite', 'frontend/src'] }); + + expect(codemodRuns).toEqual([{ cwd: env.forkPath, args: [EXTRACTED, 'rewrite', 'frontend/src'] }]); + // App-owned content is rewritten + expect(readRepoFile(env.forkPath, 'frontend/src/app-only.ts')).toBe('export const mine = "size-3";\n'); + expect(readRepoFile(env.forkPath, 'frontend/src/shared.ts')).toBe('export const shared = "size-3 app";\n'); + // Template files identical to the incoming upstream commit keep upstream's content + expect(readRepoFile(env.forkPath, 'frontend/src/template.ts')).toBe('export const template = "icon-xs";\n'); + expect(readRepoFile(env.forkPath, 'frontend/src/removed.ts')).toBe('export const removed = "icon-xs";\n'); + expect(git(`diff --name-only ${upstreamCommit} -- frontend/src/template.ts frontend/src/removed.ts`)).toBe(''); + + const out = lines.join('\n'); + expect(out).toContain(`restored 2 file(s) to upstream ${upstreamCommit.slice(0, 9)}`); + expect(out).toContain(' frontend/src/removed.ts'); + expect(out).toContain(' frontend/src/template.ts'); + expect(out).not.toContain('frontend/src/app-only.ts'); + }); + + it('measures against the committed sync point once the sync is committed', async () => { + await stageAll(env.forkPath); + await commitSquash(env.forkPath, 'chore: sync upstream cella'); + makeCommit(env.forkPath, { + files: { 'frontend/src/template.ts': 'export const template = "icon-xs app";\n' }, + message: 'feat: app edit of a template file', + }); + + await run({ run: NOTE, runArgs: ['rewrite', 'frontend/src'] }); + + // No longer identical to upstream: the app owns the edit, so the rewrite stays + expect(readRepoFile(env.forkPath, 'frontend/src/template.ts')).toBe('export const template = "size-3 app";\n'); + expect(readRepoFile(env.forkPath, 'frontend/src/removed.ts')).toBe('export const removed = "icon-xs";\n'); + expect(lines.join('\n')).toContain(`restored 1 file(s) to upstream ${upstreamCommit.slice(0, 9)}`); + }); + + it('covers the roots named after --, whichever they are', async () => { + await run({ run: NOTE, runArgs: ['rewrite', 'backend/src'] }); + + expect(readRepoFile(env.forkPath, 'backend/src/template.ts')).toBe('export const backend = "icon-xs";\n'); + expect(lines.join('\n')).toContain('restored 1 file(s)'); + }); + + it("defaults to a report-only run over the note's roots", async () => { + await run({ run: NOTE }); + + expect(codemodRuns[0].args).toEqual([EXTRACTED, 'inventory', 'frontend/src']); + expect(readRepoFile(env.forkPath, 'frontend/src/app-only.ts')).toBe('export const mine = "icon-xs";\n'); + const out = lines.join('\n'); + expect(out).toContain(`no file identical to upstream ${upstreamCommit.slice(0, 9)} was changed`); + expect(out).toContain(`report only. to apply: pnpm cella migrate --run ${NOTE} -- rewrite frontend/src`); + }); + + it('restores what a failing codemod changed or deleted, then fails', async () => { + await expect(run({ run: NOTE, runArgs: ['crash', 'frontend/src'] })).rejects.toThrow( + 'the codemod exited with code 3', + ); + + expect(readRepoFile(env.forkPath, 'frontend/src/template.ts')).toBe('export const template = "icon-xs";\n'); + expect(readRepoFile(env.forkPath, 'frontend/src/removed.ts')).toBe('export const removed = "icon-xs";\n'); + expect(lines.join('\n')).toContain('restored 2 file(s)'); + }); + + it('refuses a note without a script, or with several unless one is named', async () => { + await expect(run({ run: MANUAL })).rejects.toThrow( + `migration note '${MANUAL}' ships no codemod script: its steps are manual`, + ); + await expect(run({ run: TWO_SCRIPTS })).rejects.toThrow( + `migration note '${TWO_SCRIPTS}' ships 2 scripts (icon-classes.ts, lib.ts): name the one to run with --script .`, + ); + await expect(run({ run: TWO_SCRIPTS, script: 'nope.ts' })).rejects.toThrow( + `migration note '${TWO_SCRIPTS}' has no file 'nope.ts'`, + ); + await expect(run({ run: '20260101T0000-unknown' })).rejects.toThrow(/no migration note/); + expect(codemodRuns).toEqual([]); + + await run({ run: TWO_SCRIPTS, script: 'icon-classes.ts', runArgs: ['inventory', 'frontend/src'] }); + expect(codemodRuns[0].args[0]).toBe(`node_modules/.cache/cella/migrations/${TWO_SCRIPTS}/icon-classes.ts`); + }); + + it('restores a file whose name holds glob characters, and only that file', async () => { + // As a pattern, `[id].ts` also names `i.ts`, which the app changed: its rewrite has to stay + fs.writeFileSync(path.join(env.forkPath, 'mcp/src/i.ts'), 'export const index = "icon-xs app";\n'); + + await run({ run: NOTE, runArgs: ['rewrite', 'mcp/src'] }); + + expect(readRepoFile(env.forkPath, 'mcp/src/[id].ts')).toBe('export const route = "icon-xs";\n'); + expect(readRepoFile(env.forkPath, 'mcp/src/i.ts')).toBe('export const index = "size-3 app";\n'); + expect(lines.join('\n')).toContain('restored 1 file(s)'); + }); +}); diff --git a/tests/e2e/migration-notes.test.ts b/tests/e2e/migration-notes.test.ts index 23687ef..4612a20 100644 --- a/tests/e2e/migration-notes.test.ts +++ b/tests/e2e/migration-notes.test.ts @@ -3,13 +3,16 @@ * records the notes that arrive as pending, and `cella migrate --mark` clears them. */ import { execSync } from 'node:child_process'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runAnalyze } from '../../src/services/analyze'; import { runMigrate } from '../../src/services/migrate'; -import { runSync } from '../../src/services/sync'; +import { runSync, runSyncCommand } from '../../src/services/sync'; import { buildRuntimeConfig, createTestEnv, + deleteFileAndCommit, fetchUpstream, fileExists, makeCommit, @@ -18,6 +21,17 @@ import { type TestEnv, } from './helpers/test-env'; +// The full `runSyncCommand` flow runs `pnpm install` + `pnpm check` before it commits, and asks for `gh` +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + const spawnSync = (command: string, ...rest: unknown[]) => { + if (command === 'pnpm') return { status: 0, stdout: '', stderr: '' }; + if (command === 'gh') return { status: 1, stdout: '', stderr: '' }; + return (actual.spawnSync as (...args: unknown[]) => unknown)(command, ...rest); + }; + return { ...actual, spawnSync }; +}); + const note = (title: string) => `---\nsyncBreaking: true\nclientCacheBump: false\n---\n\n# ${title}\n\nWhat the app changes.\n`; const OLD = '20260101T0000-old-note'; @@ -81,6 +95,71 @@ describe('migration notes e2e', () => { expect(result.migrationNotes).toEqual({ total: 2, arrived: [NEW], open: 1 }); }); + it('removes the copy and converts the record when the sync stops at conflicts, and the rerun keeps it gone', async () => { + const git = (cmd: string) => execSync(`git ${cmd}`, { cwd: env.forkPath, encoding: 'utf8' }).trim(); + const info = vi.spyOn(console, 'info').mockImplementation(() => {}); + + // Legacy state: an older sync brought the whole folder in, and the app recorded the applied set + makeCommit(env.upstreamPath, { + files: { + 'cella/migrations/README.md': '# Migrations\n', + [`cella/migrations/${OLD}/README.md`]: '# Old note\n\nWhat the app changes.\n', + [`cella/migrations/${OLD}/codemod.ts`]: 'export const codemod = 1;\n', + }, + message: 'feat: old note', + }); + fetchUpstream(env.forkPath); + git('merge --ff-only cella-upstream/main'); + // The test fork is a clone of upstream: without this its trunk tracks upstream and fast-forwards to it + git('branch --unset-upstream main'); + makeCommit(env.forkPath, { + files: { + 'cella/cella.migrations.json': `${JSON.stringify({ applied: [OLD] })}\n`, + 'README.md': '# Fork readme\n', + }, + message: 'chore: applied, own readme', + }); + + // Upstream, in the sync range: changes existing note files, deletes one, adds a note, and conflicts elsewhere + makeCommit(env.upstreamPath, { + files: { + 'cella/migrations/README.md': '# Migrations\n\nNotes stay upstream.\n', + [`cella/migrations/${OLD}/README.md`]: note('Old note'), + [`cella/migrations/${NEW}/README.md`]: note('New note'), + [`cella/migrations/${NEW}/codemod.ts`]: 'export const codemod = 2;\n', + 'README.md': '# Upstream readme\n', + }, + message: 'feat: new note, frontmatter on the old one', + }); + deleteFileAndCommit(env.upstreamPath, `cella/migrations/${OLD}/codemod.ts`, 'chore: drop old codemod'); + + try { + // First run: stops at the conflict, with the folder already gone and the record converted + await runSyncCommand(buildRuntimeConfig(env, { service: 'sync' })); + + const branch = git('rev-parse --abbrev-ref HEAD'); + expect(branch).toMatch(/^cella\/sync\//); + expect(git('diff --name-only --diff-filter=U')).toBe('README.md'); + expect(fileExists(env.forkPath, 'cella/migrations')).toBe(false); + expect(git('ls-files cella/migrations')).toBe(''); + expect(pending(env.forkPath)).toEqual({ pending: [NEW] }); + + // Finishing rerun: commits the resolved merge, and the folder stays gone + fs.writeFileSync(path.join(env.forkPath, 'README.md'), '# Fork readme\n'); + git('add README.md'); + await runSyncCommand(buildRuntimeConfig(env, { service: 'sync' })); + + expect(git('rev-parse --abbrev-ref HEAD')).toBe(branch); + expect(git('log -1 --format=%s')).toMatch(/^chore: sync upstream cella/); + expect(git('status --porcelain')).toBe(''); + expect(git('ls-tree -r --name-only HEAD -- cella/migrations')).toBe(''); + expect(fileExists(env.forkPath, 'cella/migrations')).toBe(false); + expect(JSON.parse(git('show HEAD:cella/cella.migrations.json'))).toEqual({ pending: [NEW] }); + } finally { + info.mockRestore(); + } + }); + it('reports arriving notes in analyze without writing anything', async () => { makeCommit(env.upstreamPath, { files: { [`cella/migrations/${NEW}/README.md`]: note('New note') }, diff --git a/tests/e2e/sync-e2e.test.ts b/tests/e2e/sync-e2e.test.ts index fd6e80a..0c18d43 100644 --- a/tests/e2e/sync-e2e.test.ts +++ b/tests/e2e/sync-e2e.test.ts @@ -7,6 +7,7 @@ import { execSync } from 'node:child_process'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runAnalyze } from '../../src/services/analyze'; +import { UpstreamConfigChangedError } from '../../src/services/merge-engine'; import { runSync } from '../../src/services/sync'; import { commitSquash, fetchUpstreamTags, resolveUpstreamCommit, stageAll } from '../../src/utils/git'; import { @@ -942,6 +943,7 @@ describe('sync e2e', () => { kind: 'changes', pinned: { added: ['backend/src/bundle-config.ts'], removed: [] }, ignored: { added: [], removed: [] }, + packageJsonSync: { added: [], removed: [] }, }); expect(result.ignoredUpstreamChanges).toEqual([ { @@ -979,12 +981,50 @@ describe('sync e2e', () => { expect(byPath.get('cella/cella.config.ts')?.upstreamOverrides).toEqual({ pinned: { added: ['backend/src/bundle-config.ts'], removed: [] }, ignored: { added: [], removed: [] }, + packageJsonSync: { added: [], removed: [] }, }); }); - it('sync reports the same and leaves the fork config and ignored files alone', async () => { + it('sync stops before the merge while the fork config does not follow, and changes nothing', async () => { await setupBase(); const config = buildRuntimeConfig(env, { service: 'sync', pinned: ['a.ts'], ignored: ['shared/config'] }); + const head = execSync('git rev-parse HEAD', { cwd: env.forkPath }).toString(); + + const stop = await runSync(config).catch((error: unknown) => error); + + expect(stop).toBeInstanceOf(UpstreamConfigChangedError); + expect((stop as UpstreamConfigChangedError).report).toMatchObject({ + kind: 'changes', + pinned: { added: ['backend/src/bundle-config.ts'], removed: [] }, + }); + expect((stop as Error).message).toContain('stopped before the merge, nothing changed'); + expect((stop as Error).message).toContain('rerun with --keep-config'); + // No merge was started and nothing was written + expect(execSync('git rev-parse HEAD', { cwd: env.forkPath }).toString()).toBe(head); + expect(execSync('git status --porcelain', { cwd: env.forkPath }).toString()).toBe(''); + expect(fileExists(env.forkPath, '.git/MERGE_HEAD')).toBe(false); + }); + + it('sync merges once the fork config follows upstream', async () => { + await setupBase(); + const pinned = ['a.ts', 'backend/src/bundle-config.ts']; + const config = buildRuntimeConfig(env, { service: 'sync', pinned, ignored: ['shared/config'] }); + + const result = await runSync(config); + + expect(result.success).toBe(true); + expect(result.upstreamOverrides).toBeUndefined(); + expect(fileExists(env.forkPath, '.git/MERGE_HEAD')).toBe(true); + }); + + it('sync --keep-config merges, reports the same and leaves the fork config and ignored files alone', async () => { + await setupBase(); + const config = buildRuntimeConfig(env, { + service: 'sync', + pinned: ['a.ts'], + ignored: ['shared/config'], + keepConfig: true, + }); const result = await runSync(config); @@ -995,5 +1035,38 @@ describe('sync e2e', () => { expect(readRepoFile(env.forkPath, 'shared/config/default.ts')).not.toContain('newKey'); expect(fileExists(env.forkPath, 'shared/config/staging.ts')).toBe(false); }); + + it('groups protected files both sides changed by entry, with a range that shows only upstream changes', async () => { + await setupBase(); + // The fork changed an ignored config file and a pinned file that upstream changes too + makeCommit(env.upstreamPath, { + files: { 'backend/src/index.ts': '// Backend entry\nexport const backend = "upstream";\n' }, + message: 'feat: upstream backend', + }); + makeCommit(env.forkPath, { + files: { + 'shared/config/default.ts': 'export const config = { a: 1, forkKey: true };\n', + 'backend/src/index.ts': '// Backend entry\nexport const backend = "fork";\n', + }, + message: 'feat: fork config and backend', + }); + fetchUpstream(env.forkPath); + const options = { pinned: ['a.ts', 'backend/src'], ignored: ['shared/config'], keepConfig: true }; + const groups = [ + { entry: 'backend/src', paths: ['backend/src/index.ts'] }, + { entry: 'shared/config', paths: ['shared/config/default.ts'] }, + ]; + + const analysis = await runAnalyze(buildRuntimeConfig(env, { service: 'analyze', ...options })); + expect(analysis.protectedUpstreamChanges).toEqual(groups); + + const result = await runSync(buildRuntimeConfig(env, { service: 'sync', ...options })); + expect(result.protectedUpstreamChanges).toEqual(groups); + + // The printed command: upstream's side of the entry since the last sync, without the fork's own changes + const diff = execSync(`git diff ${result.upstreamDiffRange} -- shared/config`, { cwd: env.forkPath }).toString(); + expect(diff).toContain('+export const config = { a: 1, newKey: true };'); + expect(diff).not.toContain('forkKey'); + }); }); }); diff --git a/tests/overrides.test.ts b/tests/overrides.test.ts index 769febc..12c9398 100644 --- a/tests/overrides.test.ts +++ b/tests/overrides.test.ts @@ -2,12 +2,20 @@ * Unit tests for override matching utilities. * * Tests isIgnored, isPinned, and isUnderAnyFolder with the - * shared path-or-folder-prefix model. + * shared path-or-folder-prefix model, the grouping of protected paths by entry, and the config + * validation warnings. */ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { CellaCliConfig } from '../src/config/types'; import { isManagedFile } from '../src/utils/managed-files'; -import { isIgnored, isPinned, isUnderAnyFolder } from '../src/utils/overrides'; +import { + groupProtectedUpstreamChanges, + isIgnored, + isPinned, + isUnderAnyFolder, + validateOverrides, +} from '../src/utils/overrides'; +import { createTestEnv, deleteFileAndCommit, fetchUpstream, makeCommit, type TestEnv } from './e2e/helpers/test-env'; /** Helper to build a minimal config with overrides */ function buildConfig(overrides: { pinned?: string[]; ignored?: string[] }): CellaCliConfig { @@ -113,4 +121,86 @@ describe('overrides', () => { expect(isIgnored('any/file.ts', config)).toBe(false); }); }); + + describe('groupProtectedUpstreamChanges', () => { + const config = buildConfig({ + pinned: ['frontend/src/styling', 'frontend/src/styling/tailwind.css', 'backend/src/modules.ts'], + ignored: ['shared/config/'], + }); + + it('groups paths under their most specific pinned or ignored entry, in the order they come', () => { + const paths = [ + 'shared/config/staging.ts', + 'frontend/src/styling/tailwind.css', + 'frontend/src/styling/gradients.css', + 'shared/config/default.ts', + 'frontend/src/styling/base.css', + ]; + expect(groupProtectedUpstreamChanges(paths, config)).toEqual([ + { entry: 'shared/config', paths: ['shared/config/default.ts', 'shared/config/staging.ts'] }, + { entry: 'frontend/src/styling/tailwind.css', paths: ['frontend/src/styling/tailwind.css'] }, + { + entry: 'frontend/src/styling', + paths: ['frontend/src/styling/base.css', 'frontend/src/styling/gradients.css'], + }, + ]); + }); + + it('lets a path no entry covers stand for itself', () => { + expect(groupProtectedUpstreamChanges(['package.json', 'backend/src/modules.ts'], config)).toEqual([ + { entry: 'package.json', paths: ['package.json'] }, + { entry: 'backend/src/modules.ts', paths: ['backend/src/modules.ts'] }, + ]); + expect(groupProtectedUpstreamChanges([], config)).toEqual([]); + }); + }); + + describe('validateOverrides', () => { + let env: TestEnv; + + beforeEach(() => { + env = createTestEnv(); + // Upstream has a ledger and a docs folder the fork keeps out, plus a folder the fork keeps + makeCommit(env.upstreamPath, { + files: { 'json/ledger.json': '{}\n', 'docs/guide.md': '# Guide\n', 'kept/file.ts': 'export {};\n' }, + message: 'feat: ledger and docs', + }); + fetchUpstream(env.forkPath); + makeCommit(env.forkPath, { files: { 'kept/file.ts': 'export {};\n' }, message: 'feat: kept' }); + }); + + afterEach(() => { + env.cleanup(); + }); + + const ignored = ['json/ledger.json', 'docs/', 'kept', 'never/existed']; + + it('warns about an ignored entry only when the path is in neither the fork nor upstream', async () => { + const warnings = await validateOverrides(buildConfig({ ignored }), env.forkPath, 'cella-upstream/main'); + expect(warnings.map((warning) => warning.message)).toEqual(['ignored entry not found: never/existed']); + }); + + it('warns once upstream no longer has the path either', async () => { + deleteFileAndCommit(env.upstreamPath, 'json/ledger.json', 'chore: drop ledger'); + fetchUpstream(env.forkPath); + + const warnings = await validateOverrides(buildConfig({ ignored }), env.forkPath, 'cella-upstream/main'); + expect(warnings.map((warning) => warning.pattern)).toEqual(['json/ledger.json', 'never/existed']); + }); + + it('warns about every ignored entry missing from the fork while no upstream ref resolves', async () => { + const missing = ['json/ledger.json', 'docs/', 'never/existed']; + const unfetched = await validateOverrides(buildConfig({ ignored }), env.forkPath, 'cella-upstream/unfetched'); + expect(unfetched.map((warning) => warning.pattern)).toEqual(missing); + + const noRef = await validateOverrides(buildConfig({ ignored }), env.forkPath); + expect(noRef.map((warning) => warning.pattern)).toEqual(missing); + }); + + it('keeps warning about pins missing from the fork and about globs', async () => { + const config = buildConfig({ pinned: ['json/ledger.json', 'src/*.ts'], ignored: ['docs/*'] }); + const warnings = await validateOverrides(config, env.forkPath, 'cella-upstream/main'); + expect(warnings.map((warning) => warning.type)).toEqual(['pinned-not-found', 'pinned-glob', 'ignored-not-found']); + }); + }); }); diff --git a/tests/protected-behind.test.ts b/tests/protected-behind.test.ts index f722560..2491e29 100644 --- a/tests/protected-behind.test.ts +++ b/tests/protected-behind.test.ts @@ -4,7 +4,8 @@ * A pinned/ignored file wins whole-file on conflict, so when upstream ALSO changed it since the * merge-base, upstream's hunks are dropped silently. `analyzeRefs` flags exactly those files * (`upstreamChanged` + `upstreamChangedLines`), `findProtectedBehind` selects them for display, - * and `printSyncComplete` lists `MergeResult.protectedConflicts` at the end of a sync. + * and `printSyncComplete` lists `MergeResult.protectedConflicts` at the end of a sync. Both lists + * end with one `git diff ..` line per pinned/ignored entry. */ import { execSync } from 'node:child_process'; import fs from 'node:fs'; @@ -139,19 +140,50 @@ describe('analyzeRefs upstreamChanged', () => { }); describe('printAnalysisFileGroups upstream lines absent', () => { - function capture(files: AnalyzedFile[]): string { + function capture(files: AnalyzedFile[], diffSource?: Parameters[2]): string { const lines: string[] = []; const spy = vi.spyOn(console, 'info').mockImplementation((...args: unknown[]) => { lines.push(args.map(String).join(' ')); }); try { - printAnalysisFileGroups(files, {}); + printAnalysisFileGroups(files, {}, diffSource); } finally { spy.mockRestore(); } return lines.join('\n'); } + it('ends the protected-but-behind section with one range diff per entry that holds a listed file', () => { + const files = [ + file({ path: 'shared/config/default.ts', status: 'ignored', isIgnored: true, upstreamChanged: true }), + file({ path: 'shared/config/staging.ts', status: 'ignored', isIgnored: true, upstreamChanged: true }), + file({ path: 'frontend/src/my styles.css', status: 'pinned', isPinned: true, upstreamChanged: true }), + file({ path: 'package.json', status: 'pinned', isPinned: true, upstreamChanged: true }), + ]; + const out = capture(files, { + upstreamDiffRange: '1a2b3c4..5d6e7f8', + protectedUpstreamChanges: [ + { entry: 'shared/config', paths: ['shared/config/default.ts', 'shared/config/staging.ts'] }, + { entry: 'frontend/src/my styles.css', paths: ['frontend/src/my styles.css'] }, + // managed files are not listed in this section, so their entry gets no line + { entry: 'package.json', paths: ['package.json'] }, + ], + }); + + expect(out).toContain('what upstream changed since the last sync, per pinned/ignored entry:'); + expect(out.match(/git diff 1a2b3c4\.\.5d6e7f8 -- shared\/config$/gm)).toHaveLength(1); + expect(out).toContain(`git diff 1a2b3c4..5d6e7f8 -- 'frontend/src/my styles.css'`); + expect(out).not.toContain('-- package.json'); + }); + + it('prints no diff lines without a range', () => { + const files = [file({ path: 'own/a.ts', status: 'ignored', isIgnored: true, upstreamChanged: true })]; + expect(capture(files)).not.toContain('git diff'); + expect(capture(files, { protectedUpstreamChanges: [{ entry: 'own', paths: ['own/a.ts'] }] })).not.toContain( + 'git diff', + ); + }); + it('annotates pinned ahead files with a positive count and prints the hint', () => { const out = capture([ file({ path: 'frontend/src/styling/tailwind.css', status: 'ahead', isPinned: true, upstreamLinesAbsent: 7 }), @@ -229,6 +261,29 @@ describe('printSyncComplete protected conflicts', () => { expect(out).toContain('frontend/src/styling/tailwind.css'); expect(out).toContain('21 lines changed upstream'); expect(out).toContain('adopt what you need'); + expect(out).not.toContain('git diff'); + }); + + it('prints one range diff per pinned/ignored entry, not per file', () => { + const result = baseResult(); + result.files = [ + file({ path: 'shared/config/default.ts', status: 'ignored', isIgnored: true, upstreamChanged: true }), + file({ path: 'shared/config/staging.ts', status: 'ignored', isIgnored: true, upstreamChanged: true }), + file({ path: 'frontend/src/styling/tailwind.css', status: 'pinned', isPinned: true, upstreamChanged: true }), + ]; + result.protectedConflicts = result.files.map((f) => f.path); + result.upstreamDiffRange = 'e9a8d485e..a81e3353b'; + result.protectedUpstreamChanges = [ + { entry: 'shared/config', paths: ['shared/config/default.ts', 'shared/config/staging.ts'] }, + { entry: 'frontend/src/styling/tailwind.css', paths: ['frontend/src/styling/tailwind.css'] }, + ]; + + const out = capture(result); + expect(out).toContain('3 protected files kept the fork version'); + expect(out).toContain('what upstream changed since the last sync, per pinned/ignored entry:'); + expect(out.match(/git diff /g)).toHaveLength(2); + expect(out).toContain(' git diff e9a8d485e..a81e3353b -- shared/config'); + expect(out).toContain(' git diff e9a8d485e..a81e3353b -- frontend/src/styling/tailwind.css'); }); it('stays silent when nothing was dropped', () => { diff --git a/tests/sync-gates.test.ts b/tests/sync-gates.test.ts new file mode 100644 index 0000000..0814d7e --- /dev/null +++ b/tests/sync-gates.test.ts @@ -0,0 +1,210 @@ +/** + * Tests for `cella sync` runs that stop before the merge. + * + * A fresh cycle cuts its temporary branch first and resolves upstream after. A gate that stops the + * run there (upstream changed its sync config, upstream needs a newer CLI, upstream is behind the + * last sync point) drops that branch again, so the fork ends where it started and a rerun starts a + * fresh cycle instead of shipping an empty sync branch. Full `runSyncCommand` runs, with `pnpm` + * (install + check) stubbed and `gh` reported missing. + */ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { UpstreamConfigChangedError } from '../src/services/merge-engine'; +import { runSyncCommand } from '../src/services/sync'; +import { buildRuntimeConfig, createTestEnv, makeCommit, type TestEnv, tagUpstream } from './e2e/helpers/test-env'; + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + const spawnSync = (command: string, ...rest: unknown[]) => { + if (command === 'pnpm') return { status: 0, stdout: '', stderr: '' }; + if (command === 'gh') return { status: 1, stdout: '', stderr: '' }; + return (actual.spawnSync as (...args: unknown[]) => unknown)(command, ...rest); + }; + return { ...actual, spawnSync }; +}); + +function exec(cmd: string, cwd: string): string { + return execSync(cmd, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); +} + +/** Upstream sync config with the given pinned entries and package.json keys. */ +const upstreamConfig = (pinned: string[], packageJsonSync: string[]) => + `export default defineConfig({\n settings: {\n upstreamUrl: 'x',\n` + + ` packageJsonSync: [${packageJsonSync.map((key) => `'${key}'`).join(', ')}],\n },\n` + + ` overrides: {\n pinned: [${pinned.map((entry) => `'${entry}'`).join(', ')}],\n },\n});\n`; + +describe('sync stops before the merge', () => { + let env: TestEnv; + let lines: string[]; + + /** The fork is where it started: on `branch`, no sync branch left, nothing staged or changed. */ + function expectUntouched(branch: string, head: string): void { + expect(exec('git rev-parse --abbrev-ref HEAD', env.forkPath)).toBe(branch); + expect(exec('git rev-parse HEAD', env.forkPath)).toBe(head); + expect(exec('git branch --list "cella/sync/*"', env.forkPath)).toBe(''); + expect(exec('git status --porcelain', env.forkPath)).toBe(''); + expect(fs.existsSync(path.join(env.forkPath, '.git/MERGE_HEAD'))).toBe(false); + } + + /** Move the trunk to the committed sync branch and drop that branch, as merging its PR would. */ + function landSyncBranch(): void { + const branch = exec('git rev-parse --abbrev-ref HEAD', env.forkPath); + expect(branch).toMatch(/^cella\/sync\//); + exec(`git switch -q main && git merge -q --ff-only ${branch} && git branch -q -D ${branch}`, env.forkPath); + } + + beforeEach(() => { + env = createTestEnv(); + // The test fork is a clone of upstream: without this its trunk tracks upstream and fast-forwards to it + exec('git branch --unset-upstream main', env.forkPath); + lines = []; + vi.spyOn(console, 'info').mockImplementation((...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + env.cleanup(); + }); + + describe('upstream changed its sync config', () => { + /** Both sides share upstream's config; upstream then pins a path, syncs `exports` and adds a file. */ + function setupConfigChange(): void { + makeCommit(env.upstreamPath, { + files: { 'cella/cella.config.ts': upstreamConfig(['a.ts'], ['dependencies', 'devDependencies']) }, + message: 'chore: add sync config', + }); + exec('git fetch -q cella-upstream && git merge -q --ff-only cella-upstream/main', env.forkPath); + makeCommit(env.upstreamPath, { + files: { + 'cella/cella.config.ts': upstreamConfig( + ['a.ts', 'backend/src/bundle-config.ts'], + ['dependencies', 'devDependencies', 'exports'], + ), + 'backend/src/bundle-config.ts': 'export const bundle = [];\n', + 'README.md': '# Upstream readme\n', + }, + message: 'feat: bundle config', + }); + } + + it('prints what changed, stops and leaves the fork on the branch it started from', async () => { + setupConfigChange(); + exec('git switch -q -c work', env.forkPath); + const head = exec('git rev-parse HEAD', env.forkPath); + + const run = runSyncCommand(buildRuntimeConfig(env, { service: 'sync', pinned: ['a.ts'] })); + + await expect(run).rejects.toBeInstanceOf(UpstreamConfigChangedError); + expectUntouched('work', head); + expect(fs.existsSync(path.join(env.forkPath, 'backend/src/bundle-config.ts'))).toBe(false); + const out = lines.join('\n'); + expect(out).toContain('upstream changed its sync config · 2 entries to review'); + expect(out).toContain('+ pinned: backend/src/bundle-config.ts'); + expect(out).toContain('+ packageJsonSync: exports'); + expect(out).toMatch(/removed the unused 'cella\/sync\/[\d-]+', back on 'work'\./); + }); + + it('merges with the fork config as it stands on --keep-config', async () => { + setupConfigChange(); + + await runSyncCommand(buildRuntimeConfig(env, { service: 'sync', pinned: ['a.ts'], keepConfig: true })); + + // The path upstream now pins arrived as an ordinary synced file, and the run still reports the entries + expect(exec('git rev-parse --abbrev-ref HEAD', env.forkPath)).toMatch(/^cella\/sync\//); + expect(exec('git log -1 --format=%s', env.forkPath)).toMatch(/^chore: sync upstream cella/); + expect(fs.existsSync(path.join(env.forkPath, 'backend/src/bundle-config.ts'))).toBe(true); + expect(lines.join('\n')).toContain('+ pinned: backend/src/bundle-config.ts'); + }); + + it('merges once the fork config follows upstream', async () => { + setupConfigChange(); + const config = buildRuntimeConfig(env, { service: 'sync', pinned: ['a.ts', 'backend/src/bundle-config.ts'] }); + config.settings.packageJsonSync = ['dependencies', 'devDependencies', 'exports']; + + await runSyncCommand(config); + + expect(exec('git log -1 --format=%s', env.forkPath)).toMatch(/^chore: sync upstream cella/); + // Pinned and absent in the fork: the file stays out + expect(fs.existsSync(path.join(env.forkPath, 'backend/src/bundle-config.ts'))).toBe(false); + expect(lines.join('\n')).not.toContain('upstream changed its sync config'); + }); + + it('never gates the rerun that commits an already staged merge', async () => { + setupConfigChange(); + makeCommit(env.forkPath, { files: { 'README.md': '# Fork readme\n' }, message: 'docs: fork readme' }); + const keep = buildRuntimeConfig(env, { service: 'sync', pinned: ['a.ts'], keepConfig: true }); + await runSyncCommand(keep); + expect(exec('git diff --name-only --diff-filter=U', env.forkPath)).toBe('README.md'); + + // Resolve the conflict, then rerun without the flag: the merge is staged, so the run only commits it + fs.writeFileSync(path.join(env.forkPath, 'README.md'), '# Fork readme\n'); + exec('git add README.md', env.forkPath); + await runSyncCommand(buildRuntimeConfig(env, { service: 'sync', pinned: ['a.ts'] })); + + expect(exec('git log -1 --format=%s', env.forkPath)).toMatch(/^chore: sync upstream cella/); + expect(exec('git status --porcelain', env.forkPath)).toBe(''); + }); + }); + + describe('upstream is behind the last sync point', () => { + /** A branch-tracking sync that went past the latest release (v0.1.0), landed on the trunk. */ + async function syncPastLatestRelease(): Promise { + makeCommit(env.upstreamPath, { files: { 'version.ts': 'v1\n' }, message: 'feat: v1' }); + tagUpstream(env.upstreamPath, 'v0.1.0'); + makeCommit(env.upstreamPath, { files: { 'version.ts': 'v2\n' }, message: 'feat: v2' }); + await runSyncCommand(buildRuntimeConfig(env, { service: 'sync', track: 'branch' })); + landSyncBranch(); + lines.length = 0; + return exec('git rev-parse HEAD', env.forkPath); + } + + it('ends release tracking as a no-op with a message, back on the start branch', async () => { + const head = await syncPastLatestRelease(); + + await runSyncCommand(buildRuntimeConfig(env, { service: 'sync', track: 'release' })); + + expectUntouched('main', head); + expect(fs.readFileSync(path.join(env.forkPath, 'version.ts'), 'utf8')).toBe('v2\n'); + const out = lines.join('\n'); + expect(out).toContain('nothing to sync — the last sync already went past this upstream point.'); + expect(out).toMatch(/upstream v0\.1\.0 \([0-9a-f]+\) is behind the last sync point/); + expect(out).toContain('Nothing to sync until a release past'); + }); + + it('returns a detached start to its commit', async () => { + const head = await syncPastLatestRelease(); + exec('git switch -q --detach', env.forkPath); + + await runSyncCommand(buildRuntimeConfig(env, { service: 'sync', track: 'release' })); + + expectUntouched('HEAD', head); + }); + + it('fails for a pinned --ref behind the sync point, and still drops the branch', async () => { + const head = await syncPastLatestRelease(); + + const run = runSyncCommand(buildRuntimeConfig(env, { service: 'sync', ref: 'v0.1.0' })); + + await expect(run).rejects.toThrow(/behind the last sync point[\s\S]*Pick a newer --ref/); + expectUntouched('main', head); + }); + }); + + it('drops the branch when upstream needs a newer CLI', async () => { + makeCommit(env.upstreamPath, { + files: { 'package.json': '{"name": "test-upstream", "devDependencies": {"@cellajs/cli": "^999.0.0"}}\n' }, + message: 'chore: bump cli', + }); + const head = exec('git rev-parse HEAD', env.forkPath); + + await expect(runSyncCommand(buildRuntimeConfig(env, { service: 'sync' }))).rejects.toThrow( + /upstream needs @cellajs\/cli \^999\.0\.0/, + ); + + expectUntouched('main', head); + }); +}); diff --git a/tests/upstream-overrides.test.ts b/tests/upstream-overrides.test.ts index 4f4e5cd..d180bf0 100644 --- a/tests/upstream-overrides.test.ts +++ b/tests/upstream-overrides.test.ts @@ -2,26 +2,37 @@ * Tests for upstream override changes. * * The sync config is managed (never merged), so entries upstream adds to its own - * `overrides.pinned`/`overrides.ignored` never reach the fork. `parseOverrideLists` reads both - * lists without evaluating the file, `diffOverrideLists` keeps what the fork config does not - * follow, and `compareUpstreamOverrides` runs both against upstream's config at two refs. + * `overrides.pinned`/`overrides.ignored`, and keys it adds to `settings.packageJsonSync`, never + * reach the fork. `parseOverrideLists` reads the lists without evaluating the file, + * `diffOverrideLists` keeps what the fork config does not follow, and `compareUpstreamOverrides` + * runs both against upstream's config at two refs. */ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { CellaCliConfig, MergeResult } from '../src/config/types'; +import type { CellaCliConfig, MergeResult, PackageJsonSyncKey } from '../src/config/types'; import { printUpstreamOverrideChanges } from '../src/utils/display'; import { compareUpstreamOverrides, diffOverrideLists, parseOverrideLists } from '../src/utils/upstream-overrides'; -/** A cella-style config with the given list bodies (raw source, so tests can embed comments). */ -function configSource(pinned: string, ignored: string): string { +/** The keys a config without `packageJsonSync` syncs. */ +const DEFAULT_KEYS = ['dependencies', 'devDependencies']; + +/** No change in one list. */ +const NONE = { added: [], removed: [] }; + +/** + * A cella-style config with the given list bodies (raw source, so tests can embed comments). + * `settings` adds raw lines to the settings object, e.g. a `packageJsonSync` list. + */ +function configSource(pinned: string, ignored: string, settings = ''): string { return `import { defineConfig } from '@cellajs/cli/config'; export default defineConfig({ settings: { upstreamUrl: 'git@github.com:cellajs/cella.git', + ${settings} }, overrides: { // Paths the fork fully owns @@ -32,8 +43,11 @@ export default defineConfig({ `; } -function forkConfig(overrides: { pinned?: string[]; ignored?: string[] }): CellaCliConfig { - return { settings: { upstreamUrl: 'test' }, overrides }; +function forkConfig( + overrides: { pinned?: string[]; ignored?: string[] }, + packageJsonSync?: PackageJsonSyncKey[], +): CellaCliConfig { + return { settings: { upstreamUrl: 'test', packageJsonSync }, overrides }; } describe('parseOverrideLists', () => { @@ -54,6 +68,7 @@ describe('parseOverrideLists', () => { expect(parseOverrideLists(source)).toEqual({ pinned: ['backend/src/modules.ts', 'frontend/src/menu-config.tsx', 'json/text-blocks.json'], ignored: ['README.md', 'shared/config'], + packageJsonSync: DEFAULT_KEYS, }); }); @@ -61,23 +76,39 @@ describe('parseOverrideLists', () => { expect(parseOverrideLists(`export default defineConfig({ settings: { upstreamUrl: 'x' } });`)).toEqual({ pinned: [], ignored: [], + packageJsonSync: DEFAULT_KEYS, }); expect(parseOverrideLists(`export default defineConfig({ overrides: { pinned: ['a'] } });`)).toEqual({ pinned: ['a'], ignored: [], + packageJsonSync: DEFAULT_KEYS, }); }); + it('reads the packageJsonSync keys, and null when settings or the list cannot be read statically', () => { + const source = configSource( + `'a.ts'`, + '', + `packageJsonSync: ['dependencies', 'scripts', /* add-only */ 'exports'],`, + ); + expect(parseOverrideLists(source)?.packageJsonSync).toEqual(['dependencies', 'scripts', 'exports']); + + // An unreadable key list leaves the override lists readable + const computed = configSource(`'a.ts'`, '', 'packageJsonSync: keys(),'); + expect(parseOverrideLists(computed)).toEqual({ pinned: ['a.ts'], ignored: [], packageJsonSync: null }); + }); + it('follows same-file variables, satisfies and a plain default export', () => { const viaVariable = ` const ignored = ['own']; const overrides = { ignored, 'pinned': ['a.ts', ...extra] }; export default defineConfig({ settings, overrides }); `; - expect(parseOverrideLists(viaVariable)).toEqual({ pinned: ['a.ts'], ignored: ['own'] }); + // `settings` is imported, so its packageJsonSync is unknown + expect(parseOverrideLists(viaVariable)).toEqual({ pinned: ['a.ts'], ignored: ['own'], packageJsonSync: null }); const plain = `export default ({ overrides: { pinned: ['b.ts'] } }) satisfies CellaCliConfig;`; - expect(parseOverrideLists(plain)).toEqual({ pinned: ['b.ts'], ignored: [] }); + expect(parseOverrideLists(plain)).toEqual({ pinned: ['b.ts'], ignored: [], packageJsonSync: DEFAULT_KEYS }); }); it('returns null when the config cannot be read statically', () => { @@ -89,34 +120,49 @@ describe('parseOverrideLists', () => { }); describe('diffOverrideLists', () => { - const base = { pinned: ['a.ts', 'b.ts', 'gone.ts'], ignored: ['README.md', 'old'] }; + const base = { pinned: ['a.ts', 'b.ts', 'gone.ts'], ignored: ['README.md', 'old'], packageJsonSync: DEFAULT_KEYS }; const incoming = { pinned: ['a.ts', 'b.ts', 'backend/src/bundle-config.ts'], ignored: ['README.md', 'shared/config/'], + packageJsonSync: DEFAULT_KEYS, }; it('reports upstream additions the fork lacks and removals the fork still carries', () => { - const fork = { pinned: ['a.ts', 'gone.ts'], ignored: ['README.md', 'old/'] }; + const fork = { pinned: ['a.ts', 'gone.ts'], ignored: ['README.md', 'old/'], packageJsonSync: DEFAULT_KEYS }; expect(diffOverrideLists(base, incoming, fork)).toEqual({ pinned: { added: ['backend/src/bundle-config.ts'], removed: ['gone.ts'] }, ignored: { added: ['shared/config'], removed: ['old'] }, + packageJsonSync: NONE, }); }); it('skips additions the fork already covers, by entry or parent folder', () => { - const fork = { pinned: ['backend/src/bundle-config.ts'], ignored: ['shared'] }; - expect(diffOverrideLists(base, incoming, fork)).toEqual({ - pinned: { added: [], removed: [] }, - ignored: { added: [], removed: [] }, - }); + const fork = { pinned: ['backend/src/bundle-config.ts'], ignored: ['shared'], packageJsonSync: DEFAULT_KEYS }; + expect(diffOverrideLists(base, incoming, fork)).toEqual({ pinned: NONE, ignored: NONE, packageJsonSync: NONE }); }); it('never reports entries upstream had all along, whether the fork has them or not', () => { // 'a.ts' is in both upstream refs and the fork; 'b.ts' in both upstream refs, dropped by the fork - const result = diffOverrideLists(base, incoming, { pinned: ['a.ts'], ignored: [] }); + const result = diffOverrideLists(base, incoming, { pinned: ['a.ts'], ignored: [], packageJsonSync: [] }); expect(result.pinned.added).not.toContain('a.ts'); expect(result.pinned.added).not.toContain('b.ts'); expect(result.pinned.removed).toEqual([]); + // the default keys are in both upstream refs, the fork syncs none of them + expect(result.packageJsonSync).toEqual(NONE); + }); + + it('reports packageJsonSync keys upstream added that the fork lacks and dropped that the fork still lists', () => { + const lists = (packageJsonSync: string[] | null) => ({ pinned: [], ignored: [], packageJsonSync }); + const before = lists(['dependencies', 'devDependencies', 'engines']); + const after = lists(['dependencies', 'devDependencies', 'scripts', 'exports']); + + expect(diffOverrideLists(before, after, lists(['dependencies', 'scripts', 'engines'])).packageJsonSync).toEqual({ + added: ['exports'], + removed: ['engines'], + }); + // a side that could not be read compares nothing + expect(diffOverrideLists(before, lists(null), lists(['dependencies'])).packageJsonSync).toEqual(NONE); + expect(diffOverrideLists(before, after, lists(null)).packageJsonSync).toEqual(NONE); }); }); @@ -172,9 +218,33 @@ describe('compareUpstreamOverrides', () => { kind: 'changes', pinned: { added: ['backend/src/bundle-config.ts'], removed: [] }, ignored: { added: [], removed: ['old'] }, + packageJsonSync: NONE, }); }); + it('reports a packageJsonSync key upstream added, against the default when the fork config names none', async () => { + createRepo(); + const base = commit({ 'cella/cella.config.ts': configSource(`'a.ts'`, '') }); + const incoming = commit({ + 'cella/cella.config.ts': configSource( + `'a.ts'`, + '', + `packageJsonSync: ['dependencies', 'devDependencies', 'exports'],`, + ), + }); + + const fork = forkConfig({ pinned: ['a.ts'] }); + expect(await compareUpstreamOverrides(repoPath, base, incoming, fork)).toEqual({ + kind: 'changes', + pinned: NONE, + ignored: NONE, + packageJsonSync: { added: ['exports'], removed: [] }, + }); + + const following = forkConfig({ pinned: ['a.ts'] }, ['dependencies', 'exports']); + expect(await compareUpstreamOverrides(repoPath, base, incoming, following)).toBeUndefined(); + }); + it('reads the legacy root config path at an older merge-base', async () => { createRepo(); const base = commit({ 'cella.config.ts': configSource(`'a.ts'`, '') }); @@ -223,7 +293,7 @@ describe('printUpstreamOverrideChanges', () => { lines.push(args.map(String).join(' ')); }); try { - printUpstreamOverrideChanges({ upstreamOverrides } as MergeResult); + printUpstreamOverrideChanges({ upstreamOverrides }); } finally { spy.mockRestore(); } @@ -235,11 +305,14 @@ describe('printUpstreamOverrideChanges', () => { kind: 'changes', pinned: { added: ['backend/src/bundle-config.ts'], removed: [] }, ignored: { added: ['infra/Pulumi.staging.yaml'], removed: ['old'] }, + packageJsonSync: { added: ['exports'], removed: ['engines'] }, }); - expect(out).toContain('upstream changed its sync overrides · 3 entries to review'); + expect(out).toContain('upstream changed its sync config · 5 entries to review'); expect(out).toContain('+ pinned: backend/src/bundle-config.ts'); expect(out).toContain('+ ignored: infra/Pulumi.staging.yaml'); expect(out).toContain('− ignored: old'); + expect(out).toContain('+ packageJsonSync: exports'); + expect(out).toContain('− packageJsonSync: engines'); expect(out).toContain('dropped upstream, still in your config'); expect(out).toContain('cella/cella.config.ts never syncs'); }); @@ -247,7 +320,7 @@ describe('printUpstreamOverrideChanges', () => { it('prints one dim line when upstream config was unreadable, nothing when there is no report', () => { const unreadable = capture({ kind: 'unreadable', side: 'incoming' }); expect(unreadable.trim().split('\n')).toHaveLength(1); - expect(unreadable).toContain('upstream overrides not compared'); + expect(unreadable).toContain('upstream sync config not compared'); expect(capture(undefined)).toBe(''); }); });