@cellajs/cli 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -1
- package/dist/config.js.map +1 -1
- package/dist/index.js +227 -12
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ pnpm cella audit
|
|
|
17
17
|
|
|
18
18
|
| Service | Description |
|
|
19
19
|
|---------|-------------|
|
|
20
|
-
| `analyze` | Dry run to see what would change on sync |
|
|
20
|
+
| `analyze` | Dry run to see what would change on sync. It leaves your files and branches alone but updates git metadata: it fetches upstream and writes `refs/cella/last-sync` and a graft for the sync base |
|
|
21
21
|
| `sync` | Merge upstream changes onto a fresh branch and open a squash-merge PR into `main` |
|
|
22
22
|
| `audit` | Check for outdated packages & vulnerabilities |
|
|
23
23
|
| `stats` | Count files by category and workspace package |
|
|
@@ -169,6 +169,36 @@ files with `· N upstream lines absent` (lines upstream has that the fork lacks,
|
|
|
169
169
|
tips, `--json`: `upstreamLinesAbsent`). That is upstream content the fork never received,
|
|
170
170
|
deliberately or not: diff and decide. Ignored files are not annotated.
|
|
171
171
|
|
|
172
|
+
### Upstream changes that never sync
|
|
173
|
+
|
|
174
|
+
Two more kinds of upstream change never reach the fork on their own. `analyze` prints both after
|
|
175
|
+
its summary, and `sync` after its merge summary:
|
|
176
|
+
|
|
177
|
+
- **Ignored paths changed upstream.** Upstream changed, added or deleted a file under an `ignored`
|
|
178
|
+
entry (app-owned module folders included) and the fork left it untouched. No conflict marks it,
|
|
179
|
+
so this is where new config keys and version bumps under `shared/config` show up. Each `ignored`
|
|
180
|
+
entry gets one line with a file count and a `git diff <last-sync>..<upstream> -- <entry>` line to
|
|
181
|
+
paste. Files both sides changed stay in the section above, and generated output the fork
|
|
182
|
+
regenerates itself (`sdk/gen`, `*.gen.*` files) is left out. `--list`/`--json` include these
|
|
183
|
+
files in `--scope all` and `--scope protected` (`--json`: `upstreamOnly`).
|
|
184
|
+
- **Upstream override changes.** `cella/cella.config.ts` never syncs, so entries upstream adds to
|
|
185
|
+
its own `overrides.pinned` or `overrides.ignored` (the template your config started from) never
|
|
186
|
+
arrive. The report names each entry upstream added that your config lacks (`+`) and each entry
|
|
187
|
+
upstream dropped that your config still has (`−`). It reads upstream's config without running it
|
|
188
|
+
and never edits yours. `--json` puts the lists on the `cella/cella.config.ts` entry
|
|
189
|
+
(`upstreamOverrides`).
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
⚠ ignored paths changed upstream · 1 file under 1 entry
|
|
193
|
+
⨂ frontend/src/modules/marketing · 1 file
|
|
194
|
+
git diff e9a8d485e..a81e3353b -- frontend/src/modules/marketing
|
|
195
|
+
the fork left these untouched and ignored paths never sync: diff and adopt what you need.
|
|
196
|
+
|
|
197
|
+
⚠ upstream changed its sync overrides · 1 entry to review
|
|
198
|
+
+ pinned: backend/src/bundle-config.ts
|
|
199
|
+
cella/cella.config.ts never syncs: add or drop these by hand where they fit your app.
|
|
200
|
+
```
|
|
201
|
+
|
|
172
202
|
## Package.json sync
|
|
173
203
|
|
|
174
204
|
`packageJsonSync` controls which package.json sections sync from upstream:
|
package/dist/config.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/config/types.ts"],"sourcesContent":["/**\n * Sync CLI v2 - Configuration Types\n *\n * Simplified configuration for worktree-based merge approach.\n */\n\nimport { z } from 'zod';\n\n// ─────────────────────────────────────────────────────────────────────────────\n// PUBLIC API - for cella/cella.config.ts\n// ─────────────────────────────────────────────────────────────────────────────\n\n/** Valid package.json keys that can be synced */\nexport type PackageJsonSyncKey =\n | 'dependencies'\n | 'devDependencies'\n | 'peerDependencies'\n | 'optionalDependencies'\n | 'scripts'\n | 'engines'\n | 'packageManager'\n | 'overrides'\n | 'exports'\n | 'pnpm';\n\n/**\n * Sync settings - all configurable options for the sync CLI.\n * Hover over each property for documentation.\n */\nexport interface SyncSettings {\n /** Upstream repository URL (e.g., 'git@github.com:cellajs/cella.git') */\n upstreamUrl: string;\n\n /**\n * Upstream branch, used for `upstreamTrack: 'branch'` and for GitHub file links.\n * Defaults to 'main'.\n */\n upstreamBranch?: string;\n\n /**\n * How to track upstream cella. Defaults to 'release'.\n * - 'release' (default): sync to a published cella release tag (`v*`). Stable and\n * reviewable — each bump maps to a changelog. Uses the latest release.\n * - 'branch': follow the bleeding-edge tip of `upstreamBranch`. For cella\n * maintainers and forks doing active development on top of unreleased changes.\n */\n upstreamTrack?: 'release' | 'branch';\n\n /**\n * Trunk branch that `pnpm cella sync` cuts the temporary branch from and that you open the\n * squash-merge PR into. Defaults to 'main'. Cutting fresh from trunk each cycle keeps every\n * PR limited to that cycle's upstream delta (no accumulation) and `main` linear.\n */\n releaseBase?: string;\n\n /** Which package.json keys to sync (default: ['dependencies', 'devDependencies']) */\n packageJsonSync?: PackageJsonSyncKey[];\n\n /**\n * Automatically run packages sync after the sync service completes.\n * When true (default), the packages service is hidden from the menu\n * and runs automatically as part of sync.\n * Set to false to keep packages as a separate manual service.\n */\n syncWithPackages?: boolean;\n\n /**\n * How to link files in CLI output.\n * - 'commit' (default): Link to the commit that changed the file on GitHub.\n * - 'file': Link to the file in the repo at the upstream branch on GitHub.\n * - 'local': Deprecated (the upstream view worktree was replaced by browser diffs); treated as 'file'.\n */\n fileLinkMode?: 'commit' | 'file' | 'local';\n}\n\n/**\n * Configuration for a downstream fork repository that cella interacts with.\n *\n * Cella is the upstream/source-of-truth. For each fork it can:\n * - pull contributions FROM the fork's `pullBranch` (contributions service)\n * - run the normal temporary-branch sync flow inside the fork (forks service)\n */\nexport interface ForkConfig {\n /** Display name for the fork (also used as the contrib/<name> branch slug) */\n name: string;\n /** Local path to the fork repository (relative to this config or absolute) */\n localPath: string;\n /**\n * Git remote URL of the fork (e.g. 'git@github.com:org/fork.git').\n * When set, the contributions service fetches the fork's `pullBranch` from this\n * remote (the authoritative committed ref) instead of the local clone, so the\n * comparison no longer depends on the local checkout being up to date. The local\n * clone is still used to read the fork's owned-folder territory when available.\n */\n remoteUrl?: string;\n /** Fork branch that cella pulls contributions from (contributions service) */\n pullBranch: string;\n}\n\n/**\n * User-configurable sync options for cella/cella.config.ts.\n * Simplified for v2 - no long-lived sync branch or squash options.\n */\nexport interface CellaCliConfig {\n /** Core sync settings */\n settings: SyncSettings;\n\n /**\n * File overrides for controlling sync behavior per folder/file.\n */\n overrides?: {\n /**\n * Paths the fork fully owns — never synced (existing or new).\n * Exact paths or directory prefixes, not globs: 'bench' matches 'bench/' and\n * everything under it, 'README.md' matches that exact file.\n * Local territory: upstream cannot add, modify, or delete anything under these.\n */\n ignored?: string[];\n\n /**\n * Paths pinned to fork — fork wins on conflicts.\n * Exact paths or directory prefixes, not globs: 'bench' matches 'bench/' and\n * everything under it, 'README.md' matches that exact file.\n * Non-conflicting upstream changes merge normally.\n * If fork deleted a pinned file, deletion is respected.\n */\n pinned?: string[];\n };\n\n /**\n * Downstream fork repositories that cella interacts with (for upstream template repos).\n * When configured, the 'forks' and 'contributions' services appear in the CLI menu.\n */\n forks?: ForkConfig[];\n}\n\n/**\n * Helper function for defining cella/cella.config.ts with type checking.\n */\nexport function defineConfig(config: CellaCliConfig): CellaCliConfig {\n return config;\n}\n\n/**\n * Strict runtime schema mirroring {@link CellaCliConfig}.\n *\n * The TS interfaces above stay the source of truth (and keep their hover docs for\n * config authors). This schema is what's enforced at load time: `.strict()` rejects\n * unknown keys, so a typo like `ignoredFolders` instead of `ignored` fails closed\n * instead of silently dropping sync protection (tsx does not typecheck the config).\n *\n * The `satisfies z.ZodType<CellaCliConfig>` assertion keeps schema and interface in\n * lockstep: if either drifts (a renamed/removed/mis-typed field), TypeScript errors here.\n */\nexport const cellaConfigSchema = z\n .object({\n settings: z\n .object({\n upstreamUrl: z.string().min(1),\n upstreamBranch: z.string().min(1).optional(),\n upstreamTrack: z.enum(['release', 'branch']).optional(),\n releaseBase: z.string().min(1).optional(),\n packageJsonSync: z\n .array(\n z.enum([\n 'dependencies',\n 'devDependencies',\n 'peerDependencies',\n 'optionalDependencies',\n 'scripts',\n 'engines',\n 'packageManager',\n 'overrides',\n 'exports',\n 'pnpm',\n ]),\n )\n .optional(),\n syncWithPackages: z.boolean().optional(),\n fileLinkMode: z.enum(['commit', 'file', 'local']).optional(),\n })\n .strict(),\n overrides: z\n .object({\n ignored: z.array(z.string()).optional(),\n pinned: z.array(z.string()).optional(),\n })\n .strict()\n .optional(),\n forks: z\n .array(\n z\n .object({\n name: z.string().min(1),\n localPath: z.string().min(1),\n remoteUrl: z.string().optional(),\n pullBranch: z.string().min(1),\n })\n .strict(),\n )\n .optional(),\n })\n .strict() satisfies z.ZodType<CellaCliConfig>;\n\n// ─────────────────────────────────────────────────────────────────────────────\n// INTERNAL TYPES - for sync modules\n// ─────────────────────────────────────────────────────────────────────────────\n\n/** Sync services available in the CLI */\nexport type SyncService = 'analyze' | 'sync' | 'audit' | 'forks' | 'contributions' | 'stats';\n\n/** Analyze output scope for interactive and machine-readable flows */\nexport type AnalyzeScope = 'all' | 'risk' | 'protected';\n\n/** Runtime configuration with all resolved values */\nexport interface RuntimeConfig extends CellaCliConfig {\n /** Resolved fork repository path */\n forkPath: string;\n\n /** Full upstream remote ref (e.g., 'cella-upstream/development') */\n upstreamRef: string;\n\n /** Selected sync service */\n service: SyncService;\n\n /** Write full file list to log file */\n logFile: boolean;\n\n /** Non-interactive list output (for LLM/agent usage) */\n list: boolean;\n\n /** Machine-readable JSON output (for tooling/agent usage) */\n json: boolean;\n\n /** Print the unified diff for a single contributed file, then exit (contributions; for tooling/agents) */\n diff?: string;\n\n /** Open a browser diff for one file, then exit (analyze) */\n openDiff?: string;\n\n /** Scope of files returned in analyze list/json output */\n scope?: AnalyzeScope;\n\n /** Show verbose output */\n verbose: boolean;\n\n /** Pre-selected fork name (skips fork selection prompt) */\n fork?: string;\n\n /** Overwrite drifted files with upstream version (aggressive realignment) */\n hard?: boolean;\n\n /**\n * Disable pinned entries (cella/cella.config.ts overrides.pinned) for this sync so upstream\n * versions surface as behind/diverged. Managed files stay pinned because cella\n * handles them separately. Like --hard, uses the natural merge-base to resurface\n * full upstream history.\n */\n unpinned?: boolean;\n\n /**\n * Override upstream tracking for this run (analyze/sync), ignoring `settings.upstreamTrack`.\n * 'branch' follows the upstream branch tip (bleeding edge); 'release' uses a release tag.\n */\n track?: 'release' | 'branch';\n\n /** Bypass pnpm metadata cache for fresh registry data (audit service) */\n force?: boolean;\n\n /** Check which pnpm.overrides are still needed (audit service) */\n checkOverrides?: boolean;\n\n /** Regenerate test coverage before showing the stats summary (stats service) */\n coverage?: boolean;\n\n /**\n * After opening the sync PR, enable GitHub auto-merge (squash) so it merges itself once\n * required checks pass (sync service). Opt-in per run via `--direct-merge`; needs auto-merge\n * enabled on the repo. Not the advised default — a sync PR is usually worth a human look.\n */\n directMerge?: boolean;\n}\n\n/** File status after analysis */\nexport type FileStatus =\n | 'identical' // No changes needed\n | 'ahead' // Fork is ahead, protected (pinned/ignored)\n | 'local' // Local file (never existed upstream)\n | 'drifted' // Fork is ahead, NOT protected (at risk)\n | 'behind' // Upstream has changes to sync\n | 'diverged' // Both changed, will merge\n | 'pinned' // Fork wins on conflict\n | 'ignored' // Excluded from sync entirely\n | 'deleted' // Fork deleted, will stay deleted\n | 'renamed'; // Upstream renamed file\n\n/** Analyzed file with status and metadata */\nexport interface AnalyzedFile {\n path: string;\n status: FileStatus;\n /** True if file is in ignored list */\n isIgnored: boolean;\n /** True if file is in pinned list */\n isPinned: boolean;\n /** True if file exists in fork */\n existsInFork: boolean;\n /** True if file exists in upstream */\n existsInUpstream: boolean;\n /** True if file has merge conflict */\n hasConflict?: boolean;\n /** Relative date when file was last changed (since merge-base) */\n changedAt?: string;\n /** Unix epoch seconds of the last change, for sorting */\n changedTs?: number;\n /** Short commit hash of the last change */\n changedCommit?: string;\n /** For diverged files: relative date of upstream change */\n upstreamChangedAt?: string;\n /** For diverged files: Unix epoch seconds of the upstream change, for sorting */\n upstreamChangedTs?: number;\n /** For diverged files: short commit hash of the upstream change */\n upstreamCommit?: string;\n /** For renamed files: the original path before rename */\n renamedFrom?: string;\n /**\n * Protected (pinned/ignored) files only: both sides changed since the merge-base. The fork\n * side wins whole-file, so upstream's changes to this path are dropped by the sync.\n */\n upstreamChanged?: boolean;\n /** For `upstreamChanged` files: lines upstream changed since the merge-base (undefined for binary) */\n upstreamChangedLines?: number;\n /**\n * Pinned `ahead` files only: lines present upstream but absent from the fork (undefined for\n * binary). Independent of the sync point, so it also shows upstream content dropped by an\n * earlier sync — or removed on purpose; only a diff can tell.\n */\n upstreamLinesAbsent?: number;\n}\n\n/** Summary counts by status */\nexport interface AnalysisSummary {\n /** Changed package/config files that are handled separately from normal sync categories */\n managed: number;\n identical: number;\n ahead: number;\n local: number;\n drifted: number;\n behind: number;\n diverged: number;\n pinned: number;\n ignored: number;\n deleted: number;\n renamed: number;\n total: number;\n}\n\n/** Merge result from merge-engine */\nexport interface MergeResult {\n success: boolean;\n /** Upstream branch name for file links */\n upstreamBranch?: string;\n /** Resolved concrete upstream ref that was merged (branch ref or release tag ref) */\n upstreamRef?: string;\n /** Upstream release tag synced (when tracking releases) */\n upstreamTag?: string;\n files: AnalyzedFile[];\n summary: AnalysisSummary;\n conflicts: string[];\n /** Upstream GitHub URL base for commit links */\n upstreamGitHubUrl?: string;\n /** Upstream commit info */\n upstreamCommit?: {\n hash: string;\n message: string;\n date: string;\n };\n /** Commits included in this sync range (oldest-first when rendered) */\n upstreamCommits?: Array<{\n hash: string;\n message: string;\n date: string;\n }>;\n /** Files that were auto-merged by git (diverged without remaining conflicts) */\n autoMergedFiles?: string[];\n /**\n * Protected (pinned/ignored) files resolved to the fork side although upstream also changed\n * them: upstream's hunks were dropped, not merged. Review each against upstream.\n */\n protectedConflicts?: string[];\n}\n"],"mappings":";AAMA,SAAS,SAAS;AAqIX,SAAS,aAAa,QAAwC;AACnE,SAAO;AACT;AAaO,IAAM,oBAAoB,EAC9B,OAAO;AAAA,EACN,UAAU,EACP,OAAO;AAAA,IACN,aAAa,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IAC7B,gBAAgB,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,SAAS;AAAA,IAC3C,eAAe,EAAE,KAAK,CAAC,WAAW,QAAQ,CAAC,EAAE,SAAS;AAAA,IACtD,aAAa,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,SAAS;AAAA,IACxC,iBAAiB,EACd;AAAA,MACC,EAAE,KAAK;AAAA,QACL;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,MACF,CAAC;AAAA,IACH,EACC,SAAS;AAAA,IACZ,kBAAkB,EAAE,QAAQ,EAAE,SAAS;AAAA,IACvC,cAAc,EAAE,KAAK,CAAC,UAAU,QAAQ,OAAO,CAAC,EAAE,SAAS;AAAA,EAC7D,CAAC,EACA,OAAO;AAAA,EACV,WAAW,EACR,OAAO;AAAA,IACN,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS;AAAA,IACtC,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS;AAAA,EACvC,CAAC,EACA,OAAO,EACP,SAAS;AAAA,EACZ,OAAO,EACJ;AAAA,IACC,EACG,OAAO;AAAA,MACN,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,MACtB,WAAW,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,MAC3B,WAAW,EAAE,OAAO,EAAE,SAAS;AAAA,MAC/B,YAAY,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IAC9B,CAAC,EACA,OAAO;AAAA,EACZ,EACC,SAAS;AACd,CAAC,EACA,OAAO;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/config/types.ts"],"sourcesContent":["/**\n * Sync CLI v2 - Configuration Types\n *\n * Simplified configuration for worktree-based merge approach.\n */\n\nimport { z } from 'zod';\n\n// ─────────────────────────────────────────────────────────────────────────────\n// PUBLIC API - for cella/cella.config.ts\n// ─────────────────────────────────────────────────────────────────────────────\n\n/** Valid package.json keys that can be synced */\nexport type PackageJsonSyncKey =\n | 'dependencies'\n | 'devDependencies'\n | 'peerDependencies'\n | 'optionalDependencies'\n | 'scripts'\n | 'engines'\n | 'packageManager'\n | 'overrides'\n | 'exports'\n | 'pnpm';\n\n/**\n * Sync settings - all configurable options for the sync CLI.\n * Hover over each property for documentation.\n */\nexport interface SyncSettings {\n /** Upstream repository URL (e.g., 'git@github.com:cellajs/cella.git') */\n upstreamUrl: string;\n\n /**\n * Upstream branch, used for `upstreamTrack: 'branch'` and for GitHub file links.\n * Defaults to 'main'.\n */\n upstreamBranch?: string;\n\n /**\n * How to track upstream cella. Defaults to 'release'.\n * - 'release' (default): sync to a published cella release tag (`v*`). Stable and\n * reviewable — each bump maps to a changelog. Uses the latest release.\n * - 'branch': follow the bleeding-edge tip of `upstreamBranch`. For cella\n * maintainers and forks doing active development on top of unreleased changes.\n */\n upstreamTrack?: 'release' | 'branch';\n\n /**\n * Trunk branch that `pnpm cella sync` cuts the temporary branch from and that you open the\n * squash-merge PR into. Defaults to 'main'. Cutting fresh from trunk each cycle keeps every\n * PR limited to that cycle's upstream delta (no accumulation) and `main` linear.\n */\n releaseBase?: string;\n\n /** Which package.json keys to sync (default: ['dependencies', 'devDependencies']) */\n packageJsonSync?: PackageJsonSyncKey[];\n\n /**\n * Automatically run packages sync after the sync service completes.\n * When true (default), the packages service is hidden from the menu\n * and runs automatically as part of sync.\n * Set to false to keep packages as a separate manual service.\n */\n syncWithPackages?: boolean;\n\n /**\n * How to link files in CLI output.\n * - 'commit' (default): Link to the commit that changed the file on GitHub.\n * - 'file': Link to the file in the repo at the upstream branch on GitHub.\n * - 'local': Deprecated (the upstream view worktree was replaced by browser diffs); treated as 'file'.\n */\n fileLinkMode?: 'commit' | 'file' | 'local';\n}\n\n/**\n * Configuration for a downstream fork repository that cella interacts with.\n *\n * Cella is the upstream/source-of-truth. For each fork it can:\n * - pull contributions FROM the fork's `pullBranch` (contributions service)\n * - run the normal temporary-branch sync flow inside the fork (forks service)\n */\nexport interface ForkConfig {\n /** Display name for the fork (also used as the contrib/<name> branch slug) */\n name: string;\n /** Local path to the fork repository (relative to this config or absolute) */\n localPath: string;\n /**\n * Git remote URL of the fork (e.g. 'git@github.com:org/fork.git').\n * When set, the contributions service fetches the fork's `pullBranch` from this\n * remote (the authoritative committed ref) instead of the local clone, so the\n * comparison no longer depends on the local checkout being up to date. The local\n * clone is still used to read the fork's owned-folder territory when available.\n */\n remoteUrl?: string;\n /** Fork branch that cella pulls contributions from (contributions service) */\n pullBranch: string;\n}\n\n/**\n * User-configurable sync options for cella/cella.config.ts.\n * Simplified for v2 - no long-lived sync branch or squash options.\n */\nexport interface CellaCliConfig {\n /** Core sync settings */\n settings: SyncSettings;\n\n /**\n * File overrides for controlling sync behavior per folder/file.\n */\n overrides?: {\n /**\n * Paths the fork fully owns — never synced (existing or new).\n * Exact paths or directory prefixes, not globs: 'bench' matches 'bench/' and\n * everything under it, 'README.md' matches that exact file.\n * Local territory: upstream cannot add, modify, or delete anything under these.\n */\n ignored?: string[];\n\n /**\n * Paths pinned to fork — fork wins on conflicts.\n * Exact paths or directory prefixes, not globs: 'bench' matches 'bench/' and\n * everything under it, 'README.md' matches that exact file.\n * Non-conflicting upstream changes merge normally.\n * If fork deleted a pinned file, deletion is respected.\n */\n pinned?: string[];\n };\n\n /**\n * Downstream fork repositories that cella interacts with (for upstream template repos).\n * When configured, the 'forks' and 'contributions' services appear in the CLI menu.\n */\n forks?: ForkConfig[];\n}\n\n/**\n * Helper function for defining cella/cella.config.ts with type checking.\n */\nexport function defineConfig(config: CellaCliConfig): CellaCliConfig {\n return config;\n}\n\n/**\n * Strict runtime schema mirroring {@link CellaCliConfig}.\n *\n * The TS interfaces above stay the source of truth (and keep their hover docs for\n * config authors). This schema is what's enforced at load time: `.strict()` rejects\n * unknown keys, so a typo like `ignoredFolders` instead of `ignored` fails closed\n * instead of silently dropping sync protection (tsx does not typecheck the config).\n *\n * The `satisfies z.ZodType<CellaCliConfig>` assertion keeps schema and interface in\n * lockstep: if either drifts (a renamed/removed/mis-typed field), TypeScript errors here.\n */\nexport const cellaConfigSchema = z\n .object({\n settings: z\n .object({\n upstreamUrl: z.string().min(1),\n upstreamBranch: z.string().min(1).optional(),\n upstreamTrack: z.enum(['release', 'branch']).optional(),\n releaseBase: z.string().min(1).optional(),\n packageJsonSync: z\n .array(\n z.enum([\n 'dependencies',\n 'devDependencies',\n 'peerDependencies',\n 'optionalDependencies',\n 'scripts',\n 'engines',\n 'packageManager',\n 'overrides',\n 'exports',\n 'pnpm',\n ]),\n )\n .optional(),\n syncWithPackages: z.boolean().optional(),\n fileLinkMode: z.enum(['commit', 'file', 'local']).optional(),\n })\n .strict(),\n overrides: z\n .object({\n ignored: z.array(z.string()).optional(),\n pinned: z.array(z.string()).optional(),\n })\n .strict()\n .optional(),\n forks: z\n .array(\n z\n .object({\n name: z.string().min(1),\n localPath: z.string().min(1),\n remoteUrl: z.string().optional(),\n pullBranch: z.string().min(1),\n })\n .strict(),\n )\n .optional(),\n })\n .strict() satisfies z.ZodType<CellaCliConfig>;\n\n// ─────────────────────────────────────────────────────────────────────────────\n// INTERNAL TYPES - for sync modules\n// ─────────────────────────────────────────────────────────────────────────────\n\n/** Sync services available in the CLI */\nexport type SyncService = 'analyze' | 'sync' | 'audit' | 'forks' | 'contributions' | 'stats';\n\n/** Analyze output scope for interactive and machine-readable flows */\nexport type AnalyzeScope = 'all' | 'risk' | 'protected';\n\n/** Runtime configuration with all resolved values */\nexport interface RuntimeConfig extends CellaCliConfig {\n /** Resolved fork repository path */\n forkPath: string;\n\n /** Full upstream remote ref (e.g., 'cella-upstream/development') */\n upstreamRef: string;\n\n /** Selected sync service */\n service: SyncService;\n\n /** Write full file list to log file */\n logFile: boolean;\n\n /** Non-interactive list output (for LLM/agent usage) */\n list: boolean;\n\n /** Machine-readable JSON output (for tooling/agent usage) */\n json: boolean;\n\n /** Print the unified diff for a single contributed file, then exit (contributions; for tooling/agents) */\n diff?: string;\n\n /** Open a browser diff for one file, then exit (analyze) */\n openDiff?: string;\n\n /** Scope of files returned in analyze list/json output */\n scope?: AnalyzeScope;\n\n /** Show verbose output */\n verbose: boolean;\n\n /** Pre-selected fork name (skips fork selection prompt) */\n fork?: string;\n\n /** Overwrite drifted files with upstream version (aggressive realignment) */\n hard?: boolean;\n\n /**\n * Disable pinned entries (cella/cella.config.ts overrides.pinned) for this sync so upstream\n * versions surface as behind/diverged. Managed files stay pinned because cella\n * handles them separately. Like --hard, uses the natural merge-base to resurface\n * full upstream history.\n */\n unpinned?: boolean;\n\n /**\n * Override upstream tracking for this run (analyze/sync), ignoring `settings.upstreamTrack`.\n * 'branch' follows the upstream branch tip (bleeding edge); 'release' uses a release tag.\n */\n track?: 'release' | 'branch';\n\n /** Bypass pnpm metadata cache for fresh registry data (audit service) */\n force?: boolean;\n\n /** Check which pnpm.overrides are still needed (audit service) */\n checkOverrides?: boolean;\n\n /** Regenerate test coverage before showing the stats summary (stats service) */\n coverage?: boolean;\n\n /**\n * After opening the sync PR, enable GitHub auto-merge (squash) so it merges itself once\n * required checks pass (sync service). Opt-in per run via `--direct-merge`; needs auto-merge\n * enabled on the repo. Not the advised default — a sync PR is usually worth a human look.\n */\n directMerge?: boolean;\n}\n\n/** File status after analysis */\nexport type FileStatus =\n | 'identical' // No changes needed\n | 'ahead' // Fork is ahead, protected (pinned/ignored)\n | 'local' // Local file (never existed upstream)\n | 'drifted' // Fork is ahead, NOT protected (at risk)\n | 'behind' // Upstream has changes to sync\n | 'diverged' // Both changed, will merge\n | 'pinned' // Fork wins on conflict\n | 'ignored' // Excluded from sync entirely\n | 'deleted' // Fork deleted, will stay deleted\n | 'renamed'; // Upstream renamed file\n\n/** Analyzed file with status and metadata */\nexport interface AnalyzedFile {\n path: string;\n status: FileStatus;\n /** True if file is in ignored list */\n isIgnored: boolean;\n /** True if file is in pinned list */\n isPinned: boolean;\n /** True if file exists in fork */\n existsInFork: boolean;\n /** True if file exists in upstream */\n existsInUpstream: boolean;\n /** True if file has merge conflict */\n hasConflict?: boolean;\n /** Relative date when file was last changed (since merge-base) */\n changedAt?: string;\n /** Unix epoch seconds of the last change, for sorting */\n changedTs?: number;\n /** Short commit hash of the last change */\n changedCommit?: string;\n /** For diverged files: relative date of upstream change */\n upstreamChangedAt?: string;\n /** For diverged files: Unix epoch seconds of the upstream change, for sorting */\n upstreamChangedTs?: number;\n /** For diverged files: short commit hash of the upstream change */\n upstreamCommit?: string;\n /** For renamed files: the original path before rename */\n renamedFrom?: string;\n /**\n * Protected (pinned/ignored) files only: both sides changed since the merge-base. The fork\n * side wins whole-file, so upstream's changes to this path are dropped by the sync.\n */\n upstreamChanged?: boolean;\n /** For `upstreamChanged` files: lines upstream changed since the merge-base (undefined for binary) */\n upstreamChangedLines?: number;\n /**\n * Ignored files only: upstream changed, added or deleted the file since the merge-base and the\n * fork did not touch it. Ignored paths never sync, so without this flag the change stays unseen\n * (new config keys, version bumps). Managed files and generated output (`isGeneratedFile`) are\n * never flagged.\n */\n upstreamOnly?: boolean;\n /**\n * Pinned `ahead` files only: lines present upstream but absent from the fork (undefined for\n * binary). Independent of the sync point, so it also shows upstream content dropped by an\n * earlier sync — or removed on purpose; only a diff can tell.\n */\n upstreamLinesAbsent?: number;\n}\n\n/** Summary counts by status */\nexport interface AnalysisSummary {\n /** Changed package/config files that are handled separately from normal sync categories */\n managed: number;\n identical: number;\n ahead: number;\n local: number;\n drifted: number;\n behind: number;\n diverged: number;\n pinned: number;\n ignored: number;\n deleted: number;\n renamed: number;\n total: number;\n}\n\n/** Merge result from merge-engine */\nexport interface MergeResult {\n success: boolean;\n /** Upstream branch name for file links */\n upstreamBranch?: string;\n /** Resolved concrete upstream ref that was merged (branch ref or release tag ref) */\n upstreamRef?: string;\n /** Upstream release tag synced (when tracking releases) */\n upstreamTag?: string;\n files: AnalyzedFile[];\n summary: AnalysisSummary;\n conflicts: string[];\n /** Upstream GitHub URL base for commit links */\n upstreamGitHubUrl?: string;\n /** Upstream commit info */\n upstreamCommit?: {\n hash: string;\n message: string;\n date: string;\n };\n /** Commits included in this sync range (oldest-first when rendered) */\n upstreamCommits?: Array<{\n hash: string;\n message: string;\n date: string;\n }>;\n /** Files that were auto-merged by git (diverged without remaining conflicts) */\n autoMergedFiles?: string[];\n /**\n * Protected (pinned/ignored) files resolved to the fork side although upstream also changed\n * them: upstream's hunks were dropped, not merged. Review each against upstream.\n */\n protectedConflicts?: string[];\n /** Pasteable `<merge-base>..<upstream>` range of short SHAs, for `git diff` hints. */\n upstreamDiffRange?: string;\n /** How upstream's own `overrides` changed since the merge-base, where the fork config does not follow. */\n upstreamOverrides?: UpstreamOverridesReport;\n /** Files flagged `upstreamOnly`, grouped by the `ignored` entry they fall under (config order). */\n ignoredUpstreamChanges?: IgnoredUpstreamGroup[];\n}\n\n/** Entries of one override list (`pinned` or `ignored`) that the fork config does not mirror. */\nexport interface OverrideListChanges {\n /** Entries upstream added since the merge-base that the fork list lacks. */\n added: string[];\n /** Entries upstream removed since the merge-base that the fork list still carries. */\n removed: string[];\n}\n\n/**\n * Upstream override changes the fork should review. The sync config is managed (never merged),\n * so new upstream `pinned`/`ignored` entries only reach the fork by hand. `unreadable` means\n * upstream's config was missing or unparsable at one side (`base` = merge-base, `incoming` =\n * upstream ref), so nothing was compared.\n */\nexport type UpstreamOverridesReport =\n | { kind: 'changes'; pinned: OverrideListChanges; ignored: OverrideListChanges }\n | { kind: 'unreadable'; side: 'base' | 'incoming' };\n\n/** Upstream-only changes under one `ignored` entry. */\nexport interface IgnoredUpstreamGroup {\n /** The `ignored` entry (configured or derived from an app-owned module) the files fall under. */\n entry: string;\n /** Changed paths under the entry, sorted. */\n paths: string[];\n /** Paths the fork lacks (new upstream files). */\n added: number;\n /** Paths upstream deleted (the fork keeps them). */\n deleted: number;\n}\n"],"mappings":";AAMA,SAAS,SAAS;AAqIX,SAAS,aAAa,QAAwC;AACnE,SAAO;AACT;AAaO,IAAM,oBAAoB,EAC9B,OAAO;AAAA,EACN,UAAU,EACP,OAAO;AAAA,IACN,aAAa,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IAC7B,gBAAgB,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,SAAS;AAAA,IAC3C,eAAe,EAAE,KAAK,CAAC,WAAW,QAAQ,CAAC,EAAE,SAAS;AAAA,IACtD,aAAa,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,SAAS;AAAA,IACxC,iBAAiB,EACd;AAAA,MACC,EAAE,KAAK;AAAA,QACL;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,MACF,CAAC;AAAA,IACH,EACC,SAAS;AAAA,IACZ,kBAAkB,EAAE,QAAQ,EAAE,SAAS;AAAA,IACvC,cAAc,EAAE,KAAK,CAAC,UAAU,QAAQ,OAAO,CAAC,EAAE,SAAS;AAAA,EAC7D,CAAC,EACA,OAAO;AAAA,EACV,WAAW,EACR,OAAO;AAAA,IACN,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS;AAAA,IACtC,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS;AAAA,EACvC,CAAC,EACA,OAAO,EACP,SAAS;AAAA,EACZ,OAAO,EACJ;AAAA,IACC,EACG,OAAO;AAAA,MACN,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,MACtB,WAAW,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,MAC3B,WAAW,EAAE,OAAO,EAAE,SAAS;AAAA,MAC/B,YAAY,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IAC9B,CAAC,EACA,OAAO;AAAA,EACZ,EACC,SAAS;AACd,CAAC,EACA,OAAO;","names":[]}
|
package/dist/index.js
CHANGED
|
@@ -106,6 +106,10 @@ function isConfigFile(filePath) {
|
|
|
106
106
|
function isPackageJson(filePath) {
|
|
107
107
|
return filePath === "package.json" || filePath.endsWith("/package.json");
|
|
108
108
|
}
|
|
109
|
+
function isGeneratedFile(filePath) {
|
|
110
|
+
const segments = filePath.split("/");
|
|
111
|
+
return segments.includes("gen") || (segments.at(-1) ?? "").includes(".gen.");
|
|
112
|
+
}
|
|
109
113
|
function isManagedFile(filePath) {
|
|
110
114
|
return isPackageJson(filePath) || filePath === "pnpm-lock.yaml" || isConfigFile(filePath);
|
|
111
115
|
}
|
|
@@ -216,7 +220,7 @@ import process4 from "process";
|
|
|
216
220
|
// package.json
|
|
217
221
|
var package_default = {
|
|
218
222
|
name: "@cellajs/cli",
|
|
219
|
-
version: "0.2.
|
|
223
|
+
version: "0.2.3",
|
|
220
224
|
private: false,
|
|
221
225
|
license: "MIT",
|
|
222
226
|
description: "CLI for managing your monorepo and syncing with upstream Cella.",
|
|
@@ -705,6 +709,55 @@ function printProtectedConflicts(result) {
|
|
|
705
709
|
}
|
|
706
710
|
console.info(colors_default.dim(` ${PROTECTED_BEHIND_HINT}`));
|
|
707
711
|
}
|
|
712
|
+
function plural(count, singular, pluralForm = `${singular}s`) {
|
|
713
|
+
return `${count} ${count === 1 ? singular : pluralForm}`;
|
|
714
|
+
}
|
|
715
|
+
function shellPath(path4) {
|
|
716
|
+
return /^[\w./@+-]+$/.test(path4) ? path4 : `'${path4.replace(/'/g, `'\\''`)}'`;
|
|
717
|
+
}
|
|
718
|
+
function printIgnoredUpstreamChanges(result) {
|
|
719
|
+
const groups = result.ignoredUpstreamChanges ?? [];
|
|
720
|
+
if (groups.length === 0) return;
|
|
721
|
+
const total = groups.reduce((count, group) => count + group.paths.length, 0);
|
|
722
|
+
console.info();
|
|
723
|
+
console.info(
|
|
724
|
+
`${warningMark} ${colors_default.yellow(`ignored paths changed upstream \xB7 ${plural(total, "file")} under ${plural(groups.length, "entry", "entries")}`)}`
|
|
725
|
+
);
|
|
726
|
+
for (const group of groups) {
|
|
727
|
+
const counts = [plural(group.paths.length, "file")];
|
|
728
|
+
if (group.added > 0) counts.push(`${group.added} new`);
|
|
729
|
+
if (group.deleted > 0) counts.push(`${group.deleted} deleted`);
|
|
730
|
+
console.info(` ${statusConfig.ignored.icon} ${group.entry} ${colors_default.dim(`\xB7 ${counts.join(", ")}`)}`);
|
|
731
|
+
if (result.upstreamDiffRange) {
|
|
732
|
+
console.info(colors_default.dim(` git diff ${result.upstreamDiffRange} -- ${shellPath(group.entry)}`));
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
console.info(colors_default.dim(" the fork left these untouched and ignored paths never sync: diff and adopt what you need."));
|
|
736
|
+
}
|
|
737
|
+
function printUpstreamOverrideChanges(result) {
|
|
738
|
+
const report = result.upstreamOverrides;
|
|
739
|
+
if (!report) return;
|
|
740
|
+
console.info();
|
|
741
|
+
if (report.kind === "unreadable") {
|
|
742
|
+
const side = report.side === "base" ? "the last sync point" : "the incoming ref";
|
|
743
|
+
console.info(
|
|
744
|
+
colors_default.dim(`upstream overrides not compared: upstream ${CONFIG_FILE} is missing or unreadable at ${side}`)
|
|
745
|
+
);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const lines = [];
|
|
749
|
+
for (const list of ["pinned", "ignored"]) {
|
|
750
|
+
for (const entry of report[list].added) lines.push(` ${colors_default.green("+")} ${list}: ${entry}`);
|
|
751
|
+
for (const entry of report[list].removed) {
|
|
752
|
+
lines.push(` ${colors_default.red("\u2212")} ${list}: ${entry}${colors_default.dim(" \xB7 dropped upstream, still in your config")}`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
console.info(
|
|
756
|
+
`${warningMark} ${colors_default.yellow(`upstream changed its sync overrides \xB7 ${plural(lines.length, "entry", "entries")} to review`)}`
|
|
757
|
+
);
|
|
758
|
+
for (const line of lines) console.info(line);
|
|
759
|
+
console.info(colors_default.dim(` ${CONFIG_FILE} never syncs: add or drop these by hand where they fit your app.`));
|
|
760
|
+
}
|
|
708
761
|
function findMaskingPins(files) {
|
|
709
762
|
return files.filter(
|
|
710
763
|
(file) => file.isPinned && !isManagedFile(file.path) && file.status === "behind" && file.existsInFork && file.existsInUpstream
|
|
@@ -762,6 +815,23 @@ function isPinned(filePath, config) {
|
|
|
762
815
|
if (isManagedFile(filePath)) return true;
|
|
763
816
|
return isUnderAnyFolder(filePath, config.overrides?.pinned || []);
|
|
764
817
|
}
|
|
818
|
+
function groupIgnoredUpstreamChanges(files, ignored) {
|
|
819
|
+
const entries = [...new Set(ignored.map((entry) => entry.replace(/\/+$/, "")).filter(Boolean))];
|
|
820
|
+
const mostSpecificFirst = [...entries].sort((a, b) => b.length - a.length);
|
|
821
|
+
const groups = /* @__PURE__ */ new Map();
|
|
822
|
+
for (const file of files) {
|
|
823
|
+
if (!file.upstreamOnly || isManagedFile(file.path) || isGeneratedFile(file.path)) continue;
|
|
824
|
+
const entry = mostSpecificFirst.find((candidate) => isUnderAnyFolder(file.path, [candidate]));
|
|
825
|
+
if (!entry) continue;
|
|
826
|
+
const group = groups.get(entry) ?? { entry, paths: [], added: 0, deleted: 0 };
|
|
827
|
+
group.paths.push(file.path);
|
|
828
|
+
if (!file.existsInFork) group.added++;
|
|
829
|
+
if (!file.existsInUpstream) group.deleted++;
|
|
830
|
+
groups.set(entry, group);
|
|
831
|
+
}
|
|
832
|
+
for (const group of groups.values()) group.paths.sort();
|
|
833
|
+
return entries.flatMap((entry) => groups.get(entry) ?? []);
|
|
834
|
+
}
|
|
765
835
|
function isPinnedForSync(filePath, config, unpinned) {
|
|
766
836
|
if (isManagedFile(filePath)) return true;
|
|
767
837
|
if (unpinned) return false;
|
|
@@ -1791,7 +1861,115 @@ function registerSignalHandlers() {
|
|
|
1791
1861
|
cleanupRegistered = true;
|
|
1792
1862
|
}
|
|
1793
1863
|
|
|
1864
|
+
// src/utils/upstream-overrides.ts
|
|
1865
|
+
import { Node, Project as Project2, SyntaxKind as SyntaxKind2 } from "ts-morph";
|
|
1866
|
+
var LIST_KEYS = ["pinned", "ignored"];
|
|
1867
|
+
function resolveExpression(node, sourceFile, depth = 0) {
|
|
1868
|
+
if (!node || depth > 8) return node;
|
|
1869
|
+
if (Node.isParenthesizedExpression(node) || Node.isAsExpression(node) || Node.isSatisfiesExpression(node)) {
|
|
1870
|
+
return resolveExpression(node.getExpression(), sourceFile, depth + 1);
|
|
1871
|
+
}
|
|
1872
|
+
if (Node.isIdentifier(node)) {
|
|
1873
|
+
return resolveExpression(
|
|
1874
|
+
sourceFile.getVariableDeclaration(node.getText())?.getInitializer(),
|
|
1875
|
+
sourceFile,
|
|
1876
|
+
depth + 1
|
|
1877
|
+
);
|
|
1878
|
+
}
|
|
1879
|
+
return node;
|
|
1880
|
+
}
|
|
1881
|
+
function findConfigObject(sourceFile) {
|
|
1882
|
+
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind2.CallExpression)) {
|
|
1883
|
+
if (call.getExpression().getText() !== "defineConfig") continue;
|
|
1884
|
+
const arg = resolveExpression(call.getArguments()[0], sourceFile);
|
|
1885
|
+
if (Node.isObjectLiteralExpression(arg)) return arg;
|
|
1886
|
+
}
|
|
1887
|
+
const exported = sourceFile.getExportAssignment((assignment) => !assignment.isExportEquals());
|
|
1888
|
+
const value = resolveExpression(exported?.getExpression(), sourceFile);
|
|
1889
|
+
return Node.isObjectLiteralExpression(value) ? value : void 0;
|
|
1890
|
+
}
|
|
1891
|
+
function readProperty(object, name, sourceFile) {
|
|
1892
|
+
for (const property of object.getProperties()) {
|
|
1893
|
+
if (Node.isPropertyAssignment(property)) {
|
|
1894
|
+
if (property.getName().replace(/^['"`]|['"`]$/g, "") !== name) continue;
|
|
1895
|
+
return resolveExpression(property.getInitializer(), sourceFile) ?? null;
|
|
1896
|
+
}
|
|
1897
|
+
if (Node.isShorthandPropertyAssignment(property) && property.getName() === name) {
|
|
1898
|
+
return resolveExpression(property.getNameNode(), sourceFile) ?? null;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
return void 0;
|
|
1902
|
+
}
|
|
1903
|
+
function parseOverrideLists(source) {
|
|
1904
|
+
try {
|
|
1905
|
+
const project = new Project2({ useInMemoryFileSystem: true, skipLoadingLibFiles: true });
|
|
1906
|
+
const sourceFile = project.createSourceFile("cella.config.ts", source);
|
|
1907
|
+
const config = findConfigObject(sourceFile);
|
|
1908
|
+
if (!config) return null;
|
|
1909
|
+
const overrides = readProperty(config, "overrides", sourceFile);
|
|
1910
|
+
if (overrides === void 0) return { pinned: [], ignored: [] };
|
|
1911
|
+
if (!overrides || !Node.isObjectLiteralExpression(overrides)) return null;
|
|
1912
|
+
const lists = { pinned: [], ignored: [] };
|
|
1913
|
+
for (const key of LIST_KEYS) {
|
|
1914
|
+
const list = readProperty(overrides, key, sourceFile);
|
|
1915
|
+
if (list === void 0) continue;
|
|
1916
|
+
if (!list || !Node.isArrayLiteralExpression(list)) return null;
|
|
1917
|
+
for (const element of list.getElements()) {
|
|
1918
|
+
if (Node.isStringLiteral(element) || Node.isNoSubstitutionTemplateLiteral(element)) {
|
|
1919
|
+
lists[key].push(element.getLiteralValue());
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
return lists;
|
|
1924
|
+
} catch {
|
|
1925
|
+
return null;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
function normalizeEntry(entry) {
|
|
1929
|
+
return entry.trim().replace(/^\.\//, "").replace(/\/+$/, "");
|
|
1930
|
+
}
|
|
1931
|
+
function diffOverrideLists(base, incoming, fork) {
|
|
1932
|
+
const diff = (key) => {
|
|
1933
|
+
const baseSet = new Set(base[key].map(normalizeEntry).filter(Boolean));
|
|
1934
|
+
const incomingSet = new Set(incoming[key].map(normalizeEntry).filter(Boolean));
|
|
1935
|
+
const forkEntries = fork[key].map(normalizeEntry).filter(Boolean);
|
|
1936
|
+
return {
|
|
1937
|
+
added: [...incomingSet].filter((entry) => !baseSet.has(entry) && !isUnderAnyFolder(entry, forkEntries)),
|
|
1938
|
+
removed: [...baseSet].filter((entry) => !incomingSet.has(entry) && forkEntries.includes(entry))
|
|
1939
|
+
};
|
|
1940
|
+
};
|
|
1941
|
+
return { pinned: diff("pinned"), ignored: diff("ignored") };
|
|
1942
|
+
}
|
|
1943
|
+
async function readConfigSourceAtRef(repoPath, ref) {
|
|
1944
|
+
for (const path4 of CONFIG_FILE_PATHS) {
|
|
1945
|
+
const source = await git(["show", `${ref}:${path4}`], repoPath, { ignoreErrors: true });
|
|
1946
|
+
if (source) return source;
|
|
1947
|
+
}
|
|
1948
|
+
return null;
|
|
1949
|
+
}
|
|
1950
|
+
async function compareUpstreamOverrides(repoPath, mergeBaseRef, incomingRef, forkConfig) {
|
|
1951
|
+
const [baseSource, incomingSource] = await Promise.all([
|
|
1952
|
+
readConfigSourceAtRef(repoPath, mergeBaseRef),
|
|
1953
|
+
readConfigSourceAtRef(repoPath, incomingRef)
|
|
1954
|
+
]);
|
|
1955
|
+
if (baseSource === incomingSource) return void 0;
|
|
1956
|
+
const base = baseSource === null ? null : parseOverrideLists(baseSource);
|
|
1957
|
+
if (!base) return { kind: "unreadable", side: "base" };
|
|
1958
|
+
const incoming = incomingSource === null ? null : parseOverrideLists(incomingSource);
|
|
1959
|
+
if (!incoming) return { kind: "unreadable", side: "incoming" };
|
|
1960
|
+
const fork = {
|
|
1961
|
+
pinned: forkConfig.overrides?.pinned ?? [],
|
|
1962
|
+
ignored: forkConfig.overrides?.ignored ?? []
|
|
1963
|
+
};
|
|
1964
|
+
const changes = diffOverrideLists(base, incoming, fork);
|
|
1965
|
+
const count = LIST_KEYS.reduce((n, key) => n + changes[key].added.length + changes[key].removed.length, 0);
|
|
1966
|
+
return count > 0 ? { kind: "changes", ...changes } : void 0;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1794
1969
|
// src/services/analyze-core.ts
|
|
1970
|
+
function isReportableIgnored(filePath) {
|
|
1971
|
+
return !isManagedFile(filePath) && !isGeneratedFile(filePath);
|
|
1972
|
+
}
|
|
1795
1973
|
async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predicates, onProgress) {
|
|
1796
1974
|
onProgress?.("collecting file hashes (batch)...");
|
|
1797
1975
|
const [forkHashes, upstreamHashes, baseHashes] = await Promise.all([
|
|
@@ -1870,6 +2048,7 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
|
|
|
1870
2048
|
status = "renamed";
|
|
1871
2049
|
renamedFrom = oldPath;
|
|
1872
2050
|
}
|
|
2051
|
+
const upstreamOnly2 = fileIsIgnored && !forkModifiedOld && isReportableIgnored(filePath);
|
|
1873
2052
|
analyzedFiles.push({
|
|
1874
2053
|
path: filePath,
|
|
1875
2054
|
status,
|
|
@@ -1878,7 +2057,8 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
|
|
|
1878
2057
|
existsInFork: inFork,
|
|
1879
2058
|
existsInUpstream: true,
|
|
1880
2059
|
renamedFrom,
|
|
1881
|
-
upstreamChanged: upstreamChanged2
|
|
2060
|
+
upstreamChanged: upstreamChanged2,
|
|
2061
|
+
upstreamOnly: upstreamOnly2 || void 0
|
|
1882
2062
|
});
|
|
1883
2063
|
continue;
|
|
1884
2064
|
}
|
|
@@ -1913,6 +2093,7 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
|
|
|
1913
2093
|
}
|
|
1914
2094
|
}
|
|
1915
2095
|
const upstreamChanged = (fileIsPinned || fileIsIgnored) && inUpstream && upstreamHash !== baseHash && forkHash !== baseHash;
|
|
2096
|
+
const upstreamOnly = fileIsIgnored && isReportableIgnored(filePath) && upstreamHash !== baseHash && forkHash === baseHash;
|
|
1916
2097
|
analyzedFiles.push({
|
|
1917
2098
|
path: filePath,
|
|
1918
2099
|
status,
|
|
@@ -1920,7 +2101,8 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
|
|
|
1920
2101
|
isPinned: fileIsPinned,
|
|
1921
2102
|
existsInFork: inFork,
|
|
1922
2103
|
existsInUpstream: inUpstream,
|
|
1923
|
-
upstreamChanged: upstreamChanged || void 0
|
|
2104
|
+
upstreamChanged: upstreamChanged || void 0,
|
|
2105
|
+
upstreamOnly: upstreamOnly || void 0
|
|
1924
2106
|
});
|
|
1925
2107
|
}
|
|
1926
2108
|
const flagged = analyzedFiles.filter((file) => file.upstreamChanged && !renamedProtected.has(file.path));
|
|
@@ -2236,6 +2418,24 @@ function resultMeta(config, ctx) {
|
|
|
2236
2418
|
upstreamCommits: ctx.upstreamCommits
|
|
2237
2419
|
};
|
|
2238
2420
|
}
|
|
2421
|
+
async function upstreamOnlyReports(config, ctx, files) {
|
|
2422
|
+
const { forkPath } = config;
|
|
2423
|
+
try {
|
|
2424
|
+
const [baseSha, upstreamSha, upstreamOverrides] = await Promise.all([
|
|
2425
|
+
getShortSha(forkPath, ctx.mergeBase),
|
|
2426
|
+
// the commit, not an annotated release tag object
|
|
2427
|
+
getShortSha(forkPath, ctx.upstreamCommit.hash),
|
|
2428
|
+
compareUpstreamOverrides(forkPath, ctx.mergeBase, ctx.upstreamRef, config)
|
|
2429
|
+
]);
|
|
2430
|
+
return {
|
|
2431
|
+
upstreamDiffRange: `${baseSha}..${upstreamSha}`,
|
|
2432
|
+
upstreamOverrides,
|
|
2433
|
+
ignoredUpstreamChanges: groupIgnoredUpstreamChanges(files, config.overrides?.ignored ?? [])
|
|
2434
|
+
};
|
|
2435
|
+
} catch {
|
|
2436
|
+
return {};
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2239
2439
|
async function runSyncMerge(config, ctx, onProgress, onStep) {
|
|
2240
2440
|
const { forkPath } = config;
|
|
2241
2441
|
const { upstreamRef, releaseTag, mergeBase, upstreamGitHubUrl, upstreamCommit } = ctx;
|
|
@@ -2287,7 +2487,8 @@ async function runSyncMerge(config, ctx, onProgress, onStep) {
|
|
|
2287
2487
|
conflicts: remainingConflicts,
|
|
2288
2488
|
autoMergedFiles,
|
|
2289
2489
|
protectedConflicts,
|
|
2290
|
-
...resultMeta(config, ctx)
|
|
2490
|
+
...resultMeta(config, ctx),
|
|
2491
|
+
...await upstreamOnlyReports(config, ctx, analyzedFiles)
|
|
2291
2492
|
};
|
|
2292
2493
|
}
|
|
2293
2494
|
async function runAnalyzePreview(config, ctx, onProgress, onStep) {
|
|
@@ -2314,6 +2515,7 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
|
|
|
2314
2515
|
onProgress
|
|
2315
2516
|
);
|
|
2316
2517
|
await enrichChangeInfo(forkPath, analyzedFiles, ctx.mergeBase, "HEAD", ctx.upstreamRef);
|
|
2518
|
+
const reports = await upstreamOnlyReports(config, ctx, analyzedFiles);
|
|
2317
2519
|
onStep?.("analysis complete", `${analyzedFiles.length} files analyzed, dry run \u2014 no changes applied`);
|
|
2318
2520
|
onProgress?.("cleaning up worktree...");
|
|
2319
2521
|
await cleanupWorktree(forkPath, worktreePath);
|
|
@@ -2324,7 +2526,8 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
|
|
|
2324
2526
|
// For analyze mode, count diverged files as potential conflicts
|
|
2325
2527
|
conflicts: analyzedFiles.filter((f) => f.status === "diverged").map((f) => f.path),
|
|
2326
2528
|
protectedConflicts: analyzedFiles.filter((f) => f.upstreamChanged).map((f) => f.path),
|
|
2327
|
-
...resultMeta(config, ctx)
|
|
2529
|
+
...resultMeta(config, ctx),
|
|
2530
|
+
...reports
|
|
2328
2531
|
};
|
|
2329
2532
|
}
|
|
2330
2533
|
async function runMergeEngine(config, options) {
|
|
@@ -2348,10 +2551,13 @@ var scopeStatuses = {
|
|
|
2348
2551
|
risk: /* @__PURE__ */ new Set(["drifted", "diverged"]),
|
|
2349
2552
|
protected: /* @__PURE__ */ new Set(["ahead"])
|
|
2350
2553
|
};
|
|
2351
|
-
function filterByScope(
|
|
2554
|
+
function filterByScope(result, scope) {
|
|
2352
2555
|
const statuses = scopeStatuses[scope];
|
|
2353
|
-
|
|
2354
|
-
|
|
2556
|
+
if (scope === "risk") return result.files.filter((f) => statuses.has(f.status));
|
|
2557
|
+
const overridesChanged = result.upstreamOverrides?.kind === "changes";
|
|
2558
|
+
return result.files.filter(
|
|
2559
|
+
(f) => statuses.has(f.status) || f.upstreamChanged === true || f.upstreamOnly === true || overridesChanged && f.path === CONFIG_FILE
|
|
2560
|
+
);
|
|
2355
2561
|
}
|
|
2356
2562
|
function findTargetFile(files, targetPath) {
|
|
2357
2563
|
const exact = files.find((f) => f.path === targetPath);
|
|
@@ -2378,7 +2584,7 @@ async function runAnalyze(config) {
|
|
|
2378
2584
|
}
|
|
2379
2585
|
});
|
|
2380
2586
|
spinnerSuccess();
|
|
2381
|
-
const scopedFiles = filterByScope(result
|
|
2587
|
+
const scopedFiles = filterByScope(result, config.scope ?? "all");
|
|
2382
2588
|
if (config.diff) {
|
|
2383
2589
|
const file = findTargetFile(scopedFiles, config.diff) ?? findTargetFile(result.files, config.diff);
|
|
2384
2590
|
if (!file) throw new Error(`file not found in analysis results: ${config.diff}`);
|
|
@@ -2386,6 +2592,7 @@ async function runAnalyze(config) {
|
|
|
2386
2592
|
return result;
|
|
2387
2593
|
}
|
|
2388
2594
|
if (config.json) {
|
|
2595
|
+
const overrides = result.upstreamOverrides?.kind === "changes" ? result.upstreamOverrides : void 0;
|
|
2389
2596
|
const out = scopedFiles.map((f) => ({
|
|
2390
2597
|
path: f.path,
|
|
2391
2598
|
status: f.status,
|
|
@@ -2395,7 +2602,10 @@ async function runAnalyze(config) {
|
|
|
2395
2602
|
upstreamCommit: f.upstreamCommit ?? null,
|
|
2396
2603
|
upstreamChanged: f.upstreamChanged ?? false,
|
|
2397
2604
|
upstreamChangedLines: f.upstreamChangedLines ?? null,
|
|
2398
|
-
upstreamLinesAbsent: f.upstreamLinesAbsent ?? null
|
|
2605
|
+
upstreamLinesAbsent: f.upstreamLinesAbsent ?? null,
|
|
2606
|
+
upstreamOnly: f.upstreamOnly ?? false,
|
|
2607
|
+
// Only on the sync config: upstream override entries the fork config does not follow
|
|
2608
|
+
upstreamOverrides: overrides && f.path === CONFIG_FILE ? { pinned: overrides.pinned, ignored: overrides.ignored } : null
|
|
2399
2609
|
}));
|
|
2400
2610
|
writeStdout(JSON.stringify(out, null, 2));
|
|
2401
2611
|
return result;
|
|
@@ -2436,6 +2646,8 @@ async function runAnalyze(config) {
|
|
|
2436
2646
|
};
|
|
2437
2647
|
printAnalysisFileGroups(result.files, linkOptions);
|
|
2438
2648
|
printSummary(result.summary, "analysis summary");
|
|
2649
|
+
printIgnoredUpstreamChanges(result);
|
|
2650
|
+
printUpstreamOverrideChanges(result);
|
|
2439
2651
|
printMaskingPinWarning(result.files);
|
|
2440
2652
|
if (config.logFile) {
|
|
2441
2653
|
const logPath = writeLogFile(config.forkPath, result.files);
|
|
@@ -4204,6 +4416,8 @@ async function runSync(config, options) {
|
|
|
4204
4416
|
spinnerFail("sync completed with conflicts");
|
|
4205
4417
|
}
|
|
4206
4418
|
printSummary(result.summary, "merge summary");
|
|
4419
|
+
printIgnoredUpstreamChanges(result);
|
|
4420
|
+
printUpstreamOverrideChanges(result);
|
|
4207
4421
|
printMaskingPinWarning(result.files);
|
|
4208
4422
|
if (config.logFile) {
|
|
4209
4423
|
const logPath = writeLogFile(config.forkPath, result.files);
|
|
@@ -4820,9 +5034,9 @@ function fmtPct(value, total) {
|
|
|
4820
5034
|
function printStats(stats, verbose) {
|
|
4821
5035
|
console.info();
|
|
4822
5036
|
console.info(
|
|
4823
|
-
` ${colors_default.bold("
|
|
5037
|
+
` ${colors_default.bold("code files".padEnd(13))} ${String(stats.total)} ${colors_default.dim(`(${[...sourceExtensions].join(" ")})`)}`
|
|
4824
5038
|
);
|
|
4825
|
-
console.info(` ${colors_default.bold("lines of code")}
|
|
5039
|
+
console.info(` ${colors_default.bold("lines of code")} ${colors_default.cyan(formatLoc(stats.totalLoc))}`);
|
|
4826
5040
|
console.info();
|
|
4827
5041
|
const catEntries = [
|
|
4828
5042
|
["source & config", stats.categories.other],
|
|
@@ -4852,6 +5066,7 @@ function printStats(stats, verbose) {
|
|
|
4852
5066
|
);
|
|
4853
5067
|
}
|
|
4854
5068
|
} else {
|
|
5069
|
+
console.info(colors_default.dim(" source & config by package"));
|
|
4855
5070
|
for (const [name, data] of Object.entries(stats.packages)) {
|
|
4856
5071
|
const srcFiles = data.categories.other.files;
|
|
4857
5072
|
const srcLoc = data.categories.other.loc;
|