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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 70 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ Per-service help: `pnpm cella <service> --help`.
| Service | Useful options |
|---------|----------------|
| analyze | `--log`, `--list`, `--json`, `--scope <all\|risk\|protected>`, `--track <release\|branch>`, `--ref <ref>`, `--diff <path>`, `--open-diff <path>` |
| sync | `--log`, `--hard`, `--unpinned`, `--track <release\|branch>`, `--ref <ref>` |
| migrate | `--all`, `--json`, `--show <id>`, `--extract <id>`, `--mark <ids...>` |
| sync | `--log`, `--hard`, `--unpinned`, `--track <release\|branch>`, `--ref <ref>`, `--keep-config` |
| migrate | `--all`, `--json`, `--show <id>`, `--extract <id>`, `--run <id> [-- <codemod args>]`, `--script <file>`, `--mark <ids...>` |
| audit | `--list`, `--force`, `--check-overrides` |
| forks | `--fork <name>`, `--log`, `--hard`, `-V, --verbose` |
| forks | `--fork <name>`, `--log`, `--hard`, `--keep-config`, `-V, --verbose` |
| contributions | `--fork <name>`, `--list`, `--json`, `--diff <path>` |
| stats | `--coverage`, `-V, --verbose` |

Expand All @@ -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`:
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -153,10 +165,26 @@ listed until marked.
```bash
pnpm cella migrate # open notes, with summary and links
pnpm cella migrate --show <id> # one note's README
pnpm cella migrate --extract <id> # its folder under node_modules/.cache/cella/migrations/<id>/, to run the codemod
pnpm cella migrate --run <id> # run its codemod, report only
pnpm cella migrate --run <id> -- rewrite frontend/src # run it with your own arguments
pnpm cella migrate --extract <id> # its folder under node_modules/.cache/cella/migrations/<id>/
pnpm cella migrate --mark <id> [...] # 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 <file>`.

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
Expand Down Expand Up @@ -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 <last-sync>..<upstream> -- <entry>` line per `pinned` or
`ignored` entry that holds a listed file, to paste. The range is what makes it useful:
`git diff HEAD <upstream> -- shared/config` shows your whole config against the template's, while
`<last-sync>..<upstream>` 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 <path>) 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 <path>`) 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
Expand All @@ -230,24 +274,37 @@ 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
⨂ frontend/src/modules/marketing · 1 file
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:
Expand Down
41 changes: 36 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,16 @@ type CliOptionState = Pick<
| 'unpinned'
| 'track'
| 'ref'
| 'keepConfig'
| 'force'
| 'checkOverrides'
| 'coverage'
| 'all'
| 'show'
| 'extract'
| 'run'
| 'script'
| 'runArgs'
| 'mark'
>;

Expand All @@ -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<string, unknown>): CliOptionState {
function readOptions(opts: Record<string, unknown>, 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 <id>`);
}

return {
logFile: opts.log === true,
verbose: opts.verbose === true,
Expand All @@ -81,12 +92,16 @@ function readOptions(opts: Record<string, unknown>): 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,
};
}
Expand Down Expand Up @@ -116,6 +131,7 @@ const serviceDefinitions: ServiceDefinition[] = [
{ flags: '--unpinned', description: 'ignore pinned files (except package.json) to resurface upstream changes' },
{ flags: '--track <mode>', description: 'override upstream tracking for this run: release|branch' },
{ flags: '--ref <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',
Expand All @@ -128,8 +144,14 @@ const serviceDefinitions: ServiceDefinition[] = [
{ flags: '--json', description: 'machine-readable output for tooling/agents' },
{ flags: '--show <id>', description: "print one note's README" },
{ flags: '--extract <id>', description: "write one note's folder under node_modules/.cache to run its codemod" },
{ flags: '--run <id>', description: "run one note's codemod; files identical to upstream stay as they are" },
{ flags: '--script <file>', description: 'with --run: the script to run when the note folder holds several' },
{ flags: '--mark <ids...>', 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,
},
{
Expand All @@ -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,
},
Expand Down Expand Up @@ -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<string, unknown>;
setSelection({
service: definition.name,
options: readOptions(opts),
options: readOptions(opts, operands),
});
});
}
Expand All @@ -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',
Expand Down Expand Up @@ -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();
Expand Down
45 changes: 38 additions & 7 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,13 @@ export interface RuntimeConfig extends CellaCliConfig {
*/
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;

Expand All @@ -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[];
}
Expand Down Expand Up @@ -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 `<merge-base>..<upstream>` 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[];
Expand All @@ -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[];
Expand All @@ -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. */
Expand Down
Loading
Loading