@cellajs/cli 0.0.4 → 0.0.6

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.
@@ -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.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 | '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.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.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 '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.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/** 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 /** 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: short commit hash of the upstream change */\n upstreamCommit?: string;\n /** For renamed files: the original path before rename */\n renamedFrom?: string;\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"],"mappings":";AAMA,SAAS,SAAS;AAoIX,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,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.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 | '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.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.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 '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.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\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"],"mappings":";AAMA,SAAS,SAAS;AAoIX,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,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
@@ -196,7 +196,7 @@ import process4 from "process";
196
196
  // package.json
197
197
  var package_default = {
198
198
  name: "@cellajs/cli",
199
- version: "0.0.4",
199
+ version: "0.0.6",
200
200
  private: false,
201
201
  license: "MIT",
202
202
  description: "CLI for managing your monorepo and syncing with upstream Cella.",
@@ -747,7 +747,8 @@ function readOptions(opts) {
747
747
  track: opts.track === "release" || opts.track === "branch" ? opts.track : void 0,
748
748
  force: opts.force === true,
749
749
  checkOverrides: opts.checkOverrides === true,
750
- coverage: opts.coverage === true
750
+ coverage: opts.coverage === true,
751
+ directMerge: opts.directMerge === true
751
752
  };
752
753
  }
753
754
  var serviceDefinitions = [
@@ -772,7 +773,11 @@ var serviceDefinitions = [
772
773
  { flags: "--log", description: "write complete file list to cella-sync.log" },
773
774
  { flags: "--hard", description: "overwrite drifted files with upstream version (aggressive realignment)" },
774
775
  { flags: "--unpinned", description: "ignore pinned files (except package.json) to resurface upstream changes" },
775
- { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" }
776
+ { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" },
777
+ {
778
+ flags: "--direct-merge",
779
+ description: "auto-merge (squash) the sync PR once checks pass \u2014 needs auto-merge enabled on the repo"
780
+ }
776
781
  ],
777
782
  includeInMenu: (context) => !context.isUpstreamRepo,
778
783
  menuDescription: () => "merge upstream changes + sync package.json"
@@ -859,6 +864,7 @@ function buildProgram(setSelection) {
859
864
  " $ cella sync --hard",
860
865
  " $ cella sync --unpinned",
861
866
  " $ cella sync --track branch",
867
+ " $ cella sync --direct-merge",
862
868
  " $ cella audit --check-overrides",
863
869
  " $ cella contributions --fork raak --json"
864
870
  ].join("\n")
@@ -1216,21 +1222,23 @@ async function listCommitsBetween(cwd, fromRef, toRef, options = {}) {
1216
1222
  async function getFileChangeInfo(cwd, fromRef, toRef) {
1217
1223
  const info = /* @__PURE__ */ new Map();
1218
1224
  try {
1219
- const output = await git(["log", "--name-only", "--format=%h %ar", `${fromRef}..${toRef}`], cwd, {
1225
+ const output = await git(["log", "--name-only", "--format=%h %ct %ar", `${fromRef}..${toRef}`], cwd, {
1220
1226
  ignoreErrors: true
1221
1227
  });
1222
1228
  if (!output.trim()) return info;
1223
1229
  const lines = output.split("\n");
1224
1230
  let currentHash = "";
1225
1231
  let currentDate = "";
1232
+ let currentTimestamp = 0;
1226
1233
  for (const line of lines) {
1227
1234
  if (!line) continue;
1228
- const match = line.match(/^([a-f0-9]{7,})\s+(.+)$/);
1235
+ const match = line.match(/^([a-f0-9]{7,})\s+(\d+)\s+(.+)$/);
1229
1236
  if (match) {
1230
1237
  currentHash = match[1];
1231
- currentDate = match[2];
1238
+ currentTimestamp = Number(match[2]);
1239
+ currentDate = match[3];
1232
1240
  } else if (currentHash && currentDate && !info.has(line)) {
1233
- info.set(line, { date: currentDate, hash: currentHash });
1241
+ info.set(line, { date: currentDate, hash: currentHash, timestamp: currentTimestamp });
1234
1242
  }
1235
1243
  }
1236
1244
  } catch {
@@ -1568,6 +1576,21 @@ async function getEffectiveMergeBase(cwd, headRef, upstreamRef) {
1568
1576
  }
1569
1577
  return { base: gitBase, isStale: false, storedRef: recorded };
1570
1578
  }
1579
+ async function withTemporarySyncBaseGraft(cwd, headRef, baseSha, fn) {
1580
+ const headSha = await git(["rev-parse", headRef], cwd, { ignoreErrors: true });
1581
+ if (!headSha || !await commitObjectExists(cwd, baseSha)) return fn();
1582
+ if (await isAncestor(cwd, baseSha, headSha)) return fn();
1583
+ const existingReplacement = await git(["replace", "-l", headSha], cwd, { ignoreErrors: true });
1584
+ if (existingReplacement) return fn();
1585
+ const parentsRaw = await git(["log", "-1", "--format=%P", headSha], cwd, { ignoreErrors: true });
1586
+ const parents = parentsRaw ? parentsRaw.split(/\s+/) : [];
1587
+ await git(["replace", "-f", "--graft", headSha, ...parents, baseSha], cwd);
1588
+ try {
1589
+ return await fn();
1590
+ } finally {
1591
+ await git(["replace", "-d", headSha], cwd, { ignoreErrors: true });
1592
+ }
1593
+ }
1571
1594
 
1572
1595
  // src/utils/cleanup.ts
1573
1596
  var WORKTREE_PREFIXES = {
@@ -1773,12 +1796,14 @@ async function enrichChangeInfo(repoPath, files, mergeBaseRef, localRef, incomin
1773
1796
  const info = forkInfo.get(file.path);
1774
1797
  if (info) {
1775
1798
  file.changedAt = info.date;
1799
+ file.changedTs = info.timestamp;
1776
1800
  file.changedCommit = info.hash;
1777
1801
  }
1778
1802
  } else if (file.status === "behind") {
1779
1803
  const info = upstreamInfo.get(file.path);
1780
1804
  if (info) {
1781
1805
  file.changedAt = info.date;
1806
+ file.changedTs = info.timestamp;
1782
1807
  file.changedCommit = info.hash;
1783
1808
  }
1784
1809
  } else if (file.status === "diverged" || file.status === "pinned") {
@@ -1786,10 +1811,12 @@ async function enrichChangeInfo(repoPath, files, mergeBaseRef, localRef, incomin
1786
1811
  const upstreamFileInfo = upstreamInfo.get(file.path);
1787
1812
  if (forkFileInfo) {
1788
1813
  file.changedAt = forkFileInfo.date;
1814
+ file.changedTs = forkFileInfo.timestamp;
1789
1815
  file.changedCommit = forkFileInfo.hash;
1790
1816
  }
1791
1817
  if (upstreamFileInfo) {
1792
1818
  file.upstreamChangedAt = upstreamFileInfo.date;
1819
+ file.upstreamChangedTs = upstreamFileInfo.timestamp;
1793
1820
  file.upstreamCommit = upstreamFileInfo.hash;
1794
1821
  }
1795
1822
  }
@@ -1841,7 +1868,12 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
1841
1868
  }
1842
1869
  }
1843
1870
  onProgress?.("starting merge in fork...");
1844
- await merge(forkPath, upstreamRef, { noCommit: true, noEdit: true });
1871
+ await withTemporarySyncBaseGraft(
1872
+ forkPath,
1873
+ "HEAD",
1874
+ mergeBaseRef,
1875
+ () => merge(forkPath, upstreamRef, { noCommit: true, noEdit: true })
1876
+ );
1845
1877
  if (batchRestorePaths.length > 0) {
1846
1878
  onProgress?.(`batch restoring ${batchRestorePaths.length} pinned/ignored files...`);
1847
1879
  await batchRestoreToHead(forkPath, batchRestorePaths);
@@ -2094,7 +2126,12 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2094
2126
  await createWorktree(forkPath, worktreePath, "HEAD");
2095
2127
  onStep?.("worktree created", worktreePath);
2096
2128
  onProgress?.("performing merge in worktree...");
2097
- await merge(worktreePath, ctx.upstreamRef, { noCommit: true, noEdit: true });
2129
+ await withTemporarySyncBaseGraft(
2130
+ worktreePath,
2131
+ "HEAD",
2132
+ ctx.mergeBase,
2133
+ () => merge(worktreePath, ctx.upstreamRef, { noCommit: true, noEdit: true })
2134
+ );
2098
2135
  onStep?.("merge complete", "upstream merged into worktree");
2099
2136
  onProgress?.("analyzing files...");
2100
2137
  const analyzedFiles = await analyzeRefs(
@@ -3054,7 +3091,8 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
3054
3091
  kind = "modified";
3055
3092
  }
3056
3093
  const changedAt = file.status === "diverged" ? file.upstreamChangedAt : file.changedAt;
3057
- files.push({ path: file.path, kind, status: file.status, changedAt });
3094
+ const changedTs = file.status === "diverged" ? file.upstreamChangedTs : file.changedTs;
3095
+ files.push({ path: file.path, kind, status: file.status, changedAt, changedTs });
3058
3096
  }
3059
3097
  return {
3060
3098
  modified: files.filter((f) => f.kind === "modified").map((f) => f.path),
@@ -3373,6 +3411,7 @@ async function runContributions(config) {
3373
3411
  created: fileMeta?.kind === "created",
3374
3412
  status: fileMeta?.status,
3375
3413
  changedAt: fileMeta?.changedAt,
3414
+ changedTs: fileMeta?.changedTs,
3376
3415
  additions: stat.get(path4)?.additions ?? null,
3377
3416
  deletions: stat.get(path4)?.deletions ?? null,
3378
3417
  checked: false
@@ -3386,6 +3425,7 @@ async function runContributions(config) {
3386
3425
  deleted: true,
3387
3426
  status: fileMeta?.status,
3388
3427
  changedAt: fileMeta?.changedAt,
3428
+ changedTs: fileMeta?.changedTs,
3389
3429
  additions: stat.get(path4)?.additions ?? null,
3390
3430
  deletions: stat.get(path4)?.deletions ?? null,
3391
3431
  checked: false
@@ -3402,7 +3442,7 @@ async function runContributions(config) {
3402
3442
  spinnerSuccess("no contributions found");
3403
3443
  return;
3404
3444
  }
3405
- allItems.sort((a, b) => a.path.localeCompare(b.path));
3445
+ allItems.sort((a, b) => (b.changedTs ?? 0) - (a.changedTs ?? 0) || a.path.localeCompare(b.path));
3406
3446
  spinnerSuccess(`${allItems.length} files from ${forkName}`);
3407
3447
  if (!config.list && !config.diff && forkBanner) {
3408
3448
  console.info(
@@ -3429,6 +3469,7 @@ async function runContributions(config) {
3429
3469
  status: item.status ?? null,
3430
3470
  kind: item.deleted ? "deleted" : "modified",
3431
3471
  changedAt: item.changedAt ?? null,
3472
+ changedTs: item.changedTs ?? null,
3432
3473
  additions: item.additions ?? null,
3433
3474
  deletions: item.deletions ?? null
3434
3475
  }));
@@ -3506,10 +3547,57 @@ async function runContributions(config) {
3506
3547
  // src/services/forks.ts
3507
3548
  import { resolve as resolve4 } from "path";
3508
3549
  import process8 from "process";
3509
- import { Separator as Separator2, select as select3 } from "@inquirer/prompts";
3550
+ import { Separator as Separator2, select as select4 } from "@inquirer/prompts";
3510
3551
 
3511
3552
  // src/services/sync.ts
3553
+ import { spawnSync as spawnSync3 } from "child_process";
3554
+ import { select as select3 } from "@inquirer/prompts";
3555
+
3556
+ // src/utils/gh.ts
3512
3557
  import { spawnSync as spawnSync2 } from "child_process";
3558
+ function ghAvailable() {
3559
+ return spawnSync2("gh", ["--version"], { stdio: "ignore" }).status === 0;
3560
+ }
3561
+ function runGh(cwd, args) {
3562
+ const result = spawnSync2("gh", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
3563
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
3564
+ return { ok: result.status === 0, output };
3565
+ }
3566
+ function filterSyncPrs(prs, prefix) {
3567
+ return prs.filter((pr) => pr.headRefName.startsWith(`${prefix}/`));
3568
+ }
3569
+ function listOpenSyncPrs(cwd, prefix) {
3570
+ const { ok, output } = runGh(cwd, [
3571
+ "pr",
3572
+ "list",
3573
+ "--state",
3574
+ "open",
3575
+ "--json",
3576
+ "number,title,headRefName,url",
3577
+ "--limit",
3578
+ "50"
3579
+ ]);
3580
+ if (!ok || !output) return [];
3581
+ let parsed;
3582
+ try {
3583
+ parsed = JSON.parse(output);
3584
+ } catch {
3585
+ return [];
3586
+ }
3587
+ return filterSyncPrs(parsed, prefix).sort((a, b) => b.number - a.number);
3588
+ }
3589
+ function buildMergeArgs(ref, options = {}) {
3590
+ const args = ["pr", "merge", ref, "--squash"];
3591
+ if (options.auto) args.push("--auto");
3592
+ if (options.deleteBranch) args.push("--delete-branch");
3593
+ return args;
3594
+ }
3595
+ function mergePrSquash(cwd, ref, options = {}) {
3596
+ return runGh(cwd, buildMergeArgs(String(ref), options));
3597
+ }
3598
+ function closePr(cwd, ref) {
3599
+ return runGh(cwd, ["pr", "close", String(ref)]);
3600
+ }
3513
3601
 
3514
3602
  // src/services/packages.ts
3515
3603
  import { existsSync as existsSync8, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
@@ -3849,9 +3937,6 @@ function printFinishSteps() {
3849
3937
  function hasStagedSyncChanges(result) {
3850
3938
  return result.files.some((file) => ["behind", "diverged", "renamed", "ignored", "pinned"].includes(file.status));
3851
3939
  }
3852
- function ghAvailable() {
3853
- return spawnSync2("gh", ["--version"], { stdio: "ignore" }).status === 0;
3854
- }
3855
3940
  function extractFirstUrl(output) {
3856
3941
  return output.match(/https?:\/\/\S+/)?.[0];
3857
3942
  }
@@ -3868,12 +3953,25 @@ async function flattenSyncBranch(forkPath, branch, base) {
3868
3953
  await flattenBranch(forkPath, base, message);
3869
3954
  return true;
3870
3955
  }
3956
+ function indentLines(text) {
3957
+ return text.split("\n").map((line) => ` ${line}`).join("\n");
3958
+ }
3959
+ function enableAutoMerge(forkPath, branch) {
3960
+ console.info(colors_default.dim("enabling auto-merge (squash) once checks pass..."));
3961
+ const merge2 = mergePrSquash(forkPath, branch, { auto: true, deleteBranch: true });
3962
+ if (merge2.ok) return true;
3963
+ console.info(colors_default.yellow("could not enable auto-merge (is it enabled for the repo?). merge it manually:"));
3964
+ if (merge2.output) console.info(colors_default.dim(indentLines(merge2.output)));
3965
+ console.info(colors_default.dim(` gh pr merge ${branch} --squash --delete-branch`));
3966
+ return false;
3967
+ }
3871
3968
  async function shipSyncBranch(config, branch) {
3872
3969
  const { forkPath, settings } = config;
3873
3970
  const base = resolveReleaseBase(settings);
3874
3971
  const flattened = await flattenSyncBranch(forkPath, branch, base);
3875
3972
  let prUrl;
3876
3973
  let prOpened = false;
3974
+ let autoMergeEnabled = false;
3877
3975
  const headSubject = (await getCommitInfo(forkPath, "HEAD").catch(() => null))?.message;
3878
3976
  const prTitle = headSubject?.startsWith(SYNC_PR_TITLE) ? headSubject : SYNC_PR_TITLE;
3879
3977
  console.info(colors_default.dim(`pushing '${branch}' to origin...`));
@@ -3889,7 +3987,7 @@ async function shipSyncBranch(config, branch) {
3889
3987
  console.info(colors_default.dim("opening a pull request..."));
3890
3988
  const prBody = await buildSyncPrBodyForBranch(forkPath, base);
3891
3989
  const bodyArgs = prBody ? ["--body", prBody] : ["--fill"];
3892
- const pr = spawnSync2("gh", ["pr", "create", "--base", base, "--head", branch, "--title", prTitle, ...bodyArgs], {
3990
+ const pr = spawnSync3("gh", ["pr", "create", "--base", base, "--head", branch, "--title", prTitle, ...bodyArgs], {
3893
3991
  cwd: forkPath,
3894
3992
  encoding: "utf-8",
3895
3993
  stdio: ["ignore", "pipe", "pipe"]
@@ -3897,7 +3995,9 @@ async function shipSyncBranch(config, branch) {
3897
3995
  prUrl = extractFirstUrl(`${pr.stdout ?? ""}
3898
3996
  ${pr.stderr ?? ""}`);
3899
3997
  prOpened = pr.status === 0;
3900
- if (!prOpened) {
3998
+ if (prOpened) {
3999
+ if (config.directMerge) autoMergeEnabled = enableAutoMerge(forkPath, branch);
4000
+ } else {
3901
4001
  console.info(colors_default.yellow("could not open the PR automatically (it may already exist). open it with:"));
3902
4002
  if (prUrl) console.info(colors_default.dim(` ${prUrl}`));
3903
4003
  printPrCreateStep(branch, base, prTitle);
@@ -3913,7 +4013,8 @@ ${pr.stderr ?? ""}`);
3913
4013
  console.info();
3914
4014
  if (prUrl) {
3915
4015
  console.info(`${colors_default.green("\u2713")} Sync pull request ${prOpened ? "opened" : "ready"}`);
3916
- console.info(colors_default.dim(` ${prUrl} \xB7 branch pushed, back on '${base}'`));
4016
+ const status = autoMergeEnabled ? `auto-merge on \u2014 squashes into '${base}' when checks pass` : `branch pushed, back on '${base}'`;
4017
+ console.info(colors_default.dim(` ${prUrl} \xB7 ${status}`));
3917
4018
  } else {
3918
4019
  console.info(`${colors_default.green("\u2713")} Sync branch pushed`);
3919
4020
  console.info(colors_default.dim(` '${branch}' is on origin, back on '${base}'`));
@@ -3922,7 +4023,7 @@ ${pr.stderr ?? ""}`);
3922
4023
  function finalizeWorkspace(forkPath) {
3923
4024
  for (const args of [["install"], ["check"]]) {
3924
4025
  console.info(colors_default.dim(`running pnpm ${args.join(" ")}...`));
3925
- const result = spawnSync2("pnpm", args, { cwd: forkPath, stdio: "inherit" });
4026
+ const result = spawnSync3("pnpm", args, { cwd: forkPath, stdio: "inherit" });
3926
4027
  if (result.status !== 0) return false;
3927
4028
  }
3928
4029
  return true;
@@ -3967,6 +4068,64 @@ async function resumeSyncMerge(config, branch) {
3967
4068
  console.info(colors_default.green(`committed the sync on '${branch}' as '${message}'.`));
3968
4069
  await shipSyncBranch(config, branch);
3969
4070
  }
4071
+ async function mergeOpenSyncPrs(config, open) {
4072
+ const { forkPath, settings } = config;
4073
+ const base = resolveReleaseBase(settings);
4074
+ const [newest, ...superseded] = open;
4075
+ console.info();
4076
+ console.info(colors_default.dim(`squash-merging #${newest.number} into '${base}'...`));
4077
+ const merged = mergePrSquash(forkPath, newest.number, { deleteBranch: true });
4078
+ if (!merged.ok) {
4079
+ console.info();
4080
+ console.info(colors_default.yellow(`could not merge #${newest.number} \u2014 resolve it first, then re-run \`pnpm cella sync\`:`));
4081
+ if (merged.output) console.info(colors_default.dim(indentLines(merged.output)));
4082
+ console.info(colors_default.dim(` ${newest.url}`));
4083
+ return "cancel";
4084
+ }
4085
+ console.info(`${colors_default.green("\u2713")} merged #${newest.number}`);
4086
+ await deleteBranch(forkPath, newest.headRefName);
4087
+ for (const pr of superseded) {
4088
+ console.info(colors_default.dim(`closing #${pr.number} \u2014 its changes are included in #${newest.number}...`));
4089
+ closePr(forkPath, pr.number);
4090
+ await deleteBranch(forkPath, pr.headRefName);
4091
+ }
4092
+ console.info(colors_default.dim(`switching to '${base}' and fast-forwarding...`));
4093
+ await switchBranch(forkPath, base);
4094
+ await pullFastForward(forkPath);
4095
+ return "continue";
4096
+ }
4097
+ async function guardAgainstOpenSyncPr(config) {
4098
+ const { forkPath } = config;
4099
+ if (!ghAvailable() || !process.stdout.isTTY) return "continue";
4100
+ const open = listOpenSyncPrs(forkPath, DEFAULT_SYNC_PREFIX);
4101
+ if (open.length === 0) return "continue";
4102
+ const many = open.length > 1;
4103
+ console.info();
4104
+ console.info(
4105
+ `${warningMark} ${colors_default.yellow(`${many ? `${open.length} earlier sync PRs are` : "an earlier sync PR is"} still open and unmerged:`)}`
4106
+ );
4107
+ for (const pr of open) {
4108
+ console.info(colors_default.dim(` #${pr.number} ${pr.title}`));
4109
+ console.info(colors_default.dim(` ${pr.url}`));
4110
+ }
4111
+ console.info(colors_default.dim(` starting a new sync now produces a PR that re-includes ${many ? "these" : "those"} changes.`));
4112
+ console.info();
4113
+ const choice = await select3({
4114
+ message: "how do you want to proceed?",
4115
+ choices: [
4116
+ {
4117
+ value: "merge",
4118
+ name: `merge ${many ? "them" : `#${open[0].number}`} first, then sync ${colors_default.dim("(recommended)")}`
4119
+ },
4120
+ { value: "continue", name: `continue anyway ${colors_default.dim("(new PR will re-include the unmerged changes)")}` },
4121
+ { value: "cancel", name: colors_default.red("cancel") }
4122
+ ],
4123
+ loop: false
4124
+ });
4125
+ if (choice === "cancel") return "cancel";
4126
+ if (choice === "continue") return "continue";
4127
+ return mergeOpenSyncPrs(config, open);
4128
+ }
3970
4129
  async function runSyncCommand(config) {
3971
4130
  const { forkPath } = config;
3972
4131
  const currentBranch = await getCurrentBranch(forkPath);
@@ -3990,6 +4149,7 @@ commit them first (\`git commit --amend --no-edit\` or a new commit).`
3990
4149
  return;
3991
4150
  }
3992
4151
  await assertClean(forkPath);
4152
+ if (await guardAgainstOpenSyncPr(config) === "cancel") return;
3993
4153
  const outcome = await runSyncCycle(config);
3994
4154
  console.info();
3995
4155
  if (outcome.status === "noop") {
@@ -4098,7 +4258,7 @@ async function runForks(config) {
4098
4258
  while (true) {
4099
4259
  const choices = await buildForkChoices(forks, config.forkPath);
4100
4260
  const forkChoices = [...choices, new Separator2("\u2500".repeat(40)), { value: "_exit", name: colors_default.dim("exit") }];
4101
- const selectedPath = await select3({
4261
+ const selectedPath = await select4({
4102
4262
  message: "select fork to sync:",
4103
4263
  choices: forkChoices,
4104
4264
  loop: false
@@ -4122,7 +4282,7 @@ import { readFile as readFile2 } from "fs/promises";
4122
4282
  import { join as join16 } from "path";
4123
4283
 
4124
4284
  // src/utils/coverage-utils.ts
4125
- import { spawnSync as spawnSync3 } from "child_process";
4285
+ import { spawnSync as spawnSync4 } from "child_process";
4126
4286
  import * as fs3 from "fs";
4127
4287
  import * as path3 from "path";
4128
4288
  var STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1e3;
@@ -4145,7 +4305,7 @@ function formatAge(ms) {
4145
4305
  }
4146
4306
  function refreshCoverage(forkPath) {
4147
4307
  console.info(colors_default.dim("running `pnpm test` (this spins up Docker and runs the full suite)..."));
4148
- const result = spawnSync3("pnpm", ["test"], { cwd: forkPath, stdio: "inherit" });
4308
+ const result = spawnSync4("pnpm", ["test"], { cwd: forkPath, stdio: "inherit" });
4149
4309
  return result.status === 0;
4150
4310
  }
4151
4311
  function printCoverageSummary(forkPath, options = {}) {