@cellajs/cli 0.0.5 → 0.1.0

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/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cella-cli.ts
4
4
  import { existsSync as existsSync10, statSync as statSync2 } from "fs";
5
- import { join as join17, resolve as resolve5 } from "path";
5
+ import { join as join17, resolve as resolve6 } from "path";
6
6
  import process9 from "process";
7
7
 
8
8
  // src/cli.ts
@@ -95,11 +95,25 @@ var cellaConfigSchema = z.object({
95
95
  ).optional()
96
96
  }).strict();
97
97
 
98
+ // src/utils/managed-files.ts
99
+ var CONFIG_FILE = "cella/cella.config.ts";
100
+ function isPackageJson(filePath) {
101
+ return filePath === "package.json" || filePath.endsWith("/package.json");
102
+ }
103
+ function isManagedFile(filePath) {
104
+ return isPackageJson(filePath) || filePath === "pnpm-lock.yaml" || filePath === CONFIG_FILE;
105
+ }
106
+
98
107
  // src/utils/module-territory.ts
99
108
  import { existsSync } from "fs";
100
109
  import { basename, join, relative, sep } from "path";
101
110
  import { Project, SyntaxKind } from "ts-morph";
102
- var MODULE_FILE_GLOBS = ["backend/src/modules/*/*-module.ts", "frontend/src/modules/*/*-module.ts"];
111
+ var MODULE_FILE_GLOBS = [
112
+ "backend/src/modules/*/*-module.ts",
113
+ "frontend/src/modules/*/*-module.ts",
114
+ "frontend/src/modules/*/*-module.tsx"
115
+ ];
116
+ var MODULE_REGISTER_CALLS = /* @__PURE__ */ new Set(["registerModule", "defineBackendModule", "defineFrontendModule"]);
103
117
  var FRONTEND_STATIC_ROOT = "frontend/public/static";
104
118
  function readOwner(call) {
105
119
  const arg = call.getArguments()[0]?.asKind(SyntaxKind.ObjectLiteralExpression);
@@ -116,7 +130,7 @@ function resolveAppModuleFolders(repoPath) {
116
130
  const folders = /* @__PURE__ */ new Set();
117
131
  const moduleNames = /* @__PURE__ */ new Set();
118
132
  for (const sourceFile of sourceFiles) {
119
- const ownsApp = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).some((call) => call.getExpression().getText() === "registerModule" && readOwner(call) === "app");
133
+ const ownsApp = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).some((call) => MODULE_REGISTER_CALLS.has(call.getExpression().getText()) && readOwner(call) === "app");
120
134
  if (!ownsApp) continue;
121
135
  const folder = relative(repoPath, sourceFile.getDirectoryPath()).split(sep).join("/");
122
136
  folders.add(folder);
@@ -155,7 +169,7 @@ function resolveUpstream(settings) {
155
169
  return { track, branchRef };
156
170
  }
157
171
  async function loadConfig(forkPath) {
158
- const configPath = join2(forkPath, "cella.config.ts");
172
+ const configPath = join2(forkPath, CONFIG_FILE);
159
173
  if (!existsSync2(configPath)) {
160
174
  throw new Error(`config file not found: ${configPath}`);
161
175
  }
@@ -196,7 +210,7 @@ import process4 from "process";
196
210
  // package.json
197
211
  var package_default = {
198
212
  name: "@cellajs/cli",
199
- version: "0.0.5",
213
+ version: "0.1.0",
200
214
  private: false,
201
215
  license: "MIT",
202
216
  description: "CLI for managing your monorepo and syncing with upstream Cella.",
@@ -318,14 +332,6 @@ var package_default = {
318
332
  }
319
333
  };
320
334
 
321
- // src/utils/managed-files.ts
322
- function isPackageJson(filePath) {
323
- return filePath === "package.json" || filePath.endsWith("/package.json");
324
- }
325
- function isManagedFile(filePath) {
326
- return isPackageJson(filePath) || filePath === "pnpm-lock.yaml" || filePath === "cella.config.ts";
327
- }
328
-
329
335
  // src/utils/display.ts
330
336
  var NAME = "cella cli";
331
337
  var VERSION = package_default.version;
@@ -502,7 +508,7 @@ var statusConfig = {
502
508
  diverged: { icon: colors_default.magenta("\u21C5"), label: "diverged", color: colors_default.magenta, description: "both changed, will merge" },
503
509
  drifted: { icon: colors_default.yellow("!"), label: "drifted", color: colors_default.yellow, description: "fork changed (at risk)" },
504
510
  ahead: { icon: colors_default.blue("\u2191"), label: "ahead", color: colors_default.blue, description: "fork changed (protected)" },
505
- local: { icon: colors_default.green("+"), label: "local", color: colors_default.green, description: "local file" },
511
+ local: { icon: colors_default.green("+"), label: "local", color: colors_default.green, description: "local file in cella folders" },
506
512
  pinned: { icon: colors_default.green("\u2A00"), label: "pinned", color: colors_default.green, description: "both changed, fork wins" },
507
513
  ignored: { icon: colors_default.gray("\u2A02"), label: "ignored", color: colors_default.gray },
508
514
  identical: { icon: colors_default.gray("\u2713"), label: "identical", color: colors_default.gray, description: "no changes" },
@@ -515,7 +521,7 @@ var summaryStatusConfig = {
515
521
  icon: colors_default.cyan("\u25C7"),
516
522
  label: "managed",
517
523
  color: colors_default.cyan,
518
- description: "package.jsons & cella.config.ts handled separately"
524
+ description: "package.jsons & cella/cella.config.ts handled separately"
519
525
  },
520
526
  ignored: { ...statusConfig.ignored, description: "protected by ignored config" }
521
527
  };
@@ -585,7 +591,7 @@ function printAnalysisFileGroups(files, linkOptions) {
585
591
  });
586
592
  printFileGroup(files, "ahead", linkOptions, {
587
593
  title: `${colors_default.blue("\u2191 protected in fork")}`,
588
- hint: "these files have fork changes and are protected by pinned or ignored config."
594
+ hint: "these files have fork changes but are protected (pinned)."
589
595
  });
590
596
  printFileGroup(files, "drifted", linkOptions, {
591
597
  title: `${warningMark} ${colors_default.yellow("drifted from upstream")}`,
@@ -598,7 +604,7 @@ function printAnalysisFileGroups(files, linkOptions) {
598
604
  });
599
605
  printFileGroup(files, "pinned", linkOptions, {
600
606
  title: `${colors_default.green("\u2A00 pinned")}`,
601
- hint: "both changed, fork wins (pinned in cella.config.ts).",
607
+ hint: "both changed, fork wins (pinned in cella/cella.config.ts).",
602
608
  dateSource: "upstream"
603
609
  });
604
610
  }
@@ -653,7 +659,9 @@ function printFlagWarnings(options) {
653
659
  }
654
660
  if (unpinned) {
655
661
  console.info(
656
- colors_default.yellow("\u26A0 --unpinned used: pinned files in cella.config.ts were ignored, this can result in more drifts.")
662
+ colors_default.yellow(
663
+ "\u26A0 --unpinned used: pinned files in cella/cella.config.ts were ignored, this can result in more drifts."
664
+ )
657
665
  );
658
666
  }
659
667
  console.info(colors_default.yellow(" Be extra careful & cherrypick what you want only."));
@@ -747,7 +755,8 @@ function readOptions(opts) {
747
755
  track: opts.track === "release" || opts.track === "branch" ? opts.track : void 0,
748
756
  force: opts.force === true,
749
757
  checkOverrides: opts.checkOverrides === true,
750
- coverage: opts.coverage === true
758
+ coverage: opts.coverage === true,
759
+ directMerge: opts.directMerge === true
751
760
  };
752
761
  }
753
762
  var serviceDefinitions = [
@@ -772,7 +781,11 @@ var serviceDefinitions = [
772
781
  { flags: "--log", description: "write complete file list to cella-sync.log" },
773
782
  { flags: "--hard", description: "overwrite drifted files with upstream version (aggressive realignment)" },
774
783
  { 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" }
784
+ { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" },
785
+ {
786
+ flags: "--direct-merge",
787
+ description: "auto-merge (squash) the sync PR once checks pass \u2014 needs auto-merge enabled on the repo"
788
+ }
776
789
  ],
777
790
  includeInMenu: (context) => !context.isUpstreamRepo,
778
791
  menuDescription: () => "merge upstream changes + sync package.json"
@@ -859,6 +872,7 @@ function buildProgram(setSelection) {
859
872
  " $ cella sync --hard",
860
873
  " $ cella sync --unpinned",
861
874
  " $ cella sync --track branch",
875
+ " $ cella sync --direct-merge",
862
876
  " $ cella audit --check-overrides",
863
877
  " $ cella contributions --fork raak --json"
864
878
  ].join("\n")
@@ -1036,14 +1050,14 @@ import process7 from "process";
1036
1050
  // src/utils/git.ts
1037
1051
  import { execFile } from "child_process";
1038
1052
  import { existsSync as existsSync4 } from "fs";
1039
- import { mkdir, readdir, rmdir, unlink } from "fs/promises";
1040
- import { dirname, join as join8 } from "path";
1053
+ import { mkdir as mkdir2, readdir, rmdir, unlink } from "fs/promises";
1054
+ import { dirname as dirname2, join as join8 } from "path";
1041
1055
  import { promisify } from "util";
1042
1056
 
1043
1057
  // src/utils/manifest.ts
1044
- import { readFile, writeFile } from "fs/promises";
1045
- import { join as join7 } from "path";
1046
- var MANIFEST_FILE = "cella.manifest.json";
1058
+ import { mkdir, readFile, writeFile } from "fs/promises";
1059
+ import { dirname, join as join7 } from "path";
1060
+ var MANIFEST_FILE = "cella/cella.manifest.json";
1047
1061
  async function readSyncManifest(cwd) {
1048
1062
  try {
1049
1063
  const parsed = JSON.parse(await readFile(join7(cwd, MANIFEST_FILE), "utf8"));
@@ -1059,7 +1073,9 @@ async function readManifestBase(cwd) {
1059
1073
  return manifest ? manifest.upstream.commit.toLowerCase() : null;
1060
1074
  }
1061
1075
  async function writeSyncManifest(cwd, manifest) {
1062
- await writeFile(join7(cwd, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}
1076
+ const manifestPath = join7(cwd, MANIFEST_FILE);
1077
+ await mkdir(dirname(manifestPath), { recursive: true });
1078
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}
1063
1079
  `, "utf8");
1064
1080
  }
1065
1081
 
@@ -1371,8 +1387,8 @@ async function batchUnstageFiles(cwd, filePaths) {
1371
1387
  await git(["rm", "--cached", "-f", "--", ...filePaths], cwd, { ignoreErrors: true });
1372
1388
  }
1373
1389
  async function gitMv(cwd, oldPath, newPath) {
1374
- const newDir = dirname(join8(cwd, newPath));
1375
- await mkdir(newDir, { recursive: true });
1390
+ const newDir = dirname2(join8(cwd, newPath));
1391
+ await mkdir2(newDir, { recursive: true });
1376
1392
  await git(["mv", "-f", oldPath, newPath], cwd);
1377
1393
  }
1378
1394
  async function fileExistsInWorktree(cwd, filePath) {
@@ -1382,7 +1398,7 @@ async function removeFileFromWorktree(cwd, filePath) {
1382
1398
  const fullPath = join8(cwd, filePath);
1383
1399
  try {
1384
1400
  await unlink(fullPath);
1385
- await cleanupEmptyParentDirs(cwd, dirname(filePath));
1401
+ await cleanupEmptyParentDirs(cwd, dirname2(filePath));
1386
1402
  } catch {
1387
1403
  }
1388
1404
  }
@@ -1397,7 +1413,7 @@ async function cleanupEmptyParentDirs(cwd, relativePath) {
1397
1413
  const entries = await readdir(fullPath);
1398
1414
  if (entries.length === 0) {
1399
1415
  await rmdir(fullPath);
1400
- await cleanupEmptyParentDirs(cwd, dirname(relativePath));
1416
+ await cleanupEmptyParentDirs(cwd, dirname2(relativePath));
1401
1417
  }
1402
1418
  } catch {
1403
1419
  }
@@ -1505,7 +1521,31 @@ async function readRootTrailerBase(cwd, headRef) {
1505
1521
  const match = message.match(/^cella-base:\s*([0-9a-f]{40})$/im);
1506
1522
  return match ? match[1].toLowerCase() : null;
1507
1523
  }
1524
+ async function isShallowRepository(cwd) {
1525
+ const out = await git(["rev-parse", "--is-shallow-repository"], cwd, { ignoreErrors: true });
1526
+ return out === "true";
1527
+ }
1528
+ async function unshallowRepository(cwd) {
1529
+ try {
1530
+ await git(["fetch", "--unshallow"], cwd, { skipEditor: true });
1531
+ } catch (error) {
1532
+ const detail = error instanceof Error ? `
1533
+
1534
+ ${error.message}` : "";
1535
+ throw new Error(
1536
+ `this is a shallow clone and its full history could not be restored automatically.
1537
+ Sync needs the complete history to find the common ancestor with upstream. Restore it with:
1538
+
1539
+ git fetch --unshallow
1540
+
1541
+ then re-run sync.${detail}`
1542
+ );
1543
+ }
1544
+ }
1508
1545
  async function ensureSyncBase(cwd, headRef, upstreamRef) {
1546
+ if (await isShallowRepository(cwd)) {
1547
+ await unshallowRepository(cwd);
1548
+ }
1509
1549
  const nativeBase = await git(["merge-base", headRef, upstreamRef], cwd, { ignoreErrors: true });
1510
1550
  if (nativeBase) return;
1511
1551
  const sources = [
@@ -1570,6 +1610,21 @@ async function getEffectiveMergeBase(cwd, headRef, upstreamRef) {
1570
1610
  }
1571
1611
  return { base: gitBase, isStale: false, storedRef: recorded };
1572
1612
  }
1613
+ async function withTemporarySyncBaseGraft(cwd, headRef, baseSha, fn) {
1614
+ const headSha = await git(["rev-parse", headRef], cwd, { ignoreErrors: true });
1615
+ if (!headSha || !await commitObjectExists(cwd, baseSha)) return fn();
1616
+ if (await isAncestor(cwd, baseSha, headSha)) return fn();
1617
+ const existingReplacement = await git(["replace", "-l", headSha], cwd, { ignoreErrors: true });
1618
+ if (existingReplacement) return fn();
1619
+ const parentsRaw = await git(["log", "-1", "--format=%P", headSha], cwd, { ignoreErrors: true });
1620
+ const parents = parentsRaw ? parentsRaw.split(/\s+/) : [];
1621
+ await git(["replace", "-f", "--graft", headSha, ...parents, baseSha], cwd);
1622
+ try {
1623
+ return await fn();
1624
+ } finally {
1625
+ await git(["replace", "-d", headSha], cwd, { ignoreErrors: true });
1626
+ }
1627
+ }
1573
1628
 
1574
1629
  // src/utils/cleanup.ts
1575
1630
  var WORKTREE_PREFIXES = {
@@ -1847,7 +1902,12 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
1847
1902
  }
1848
1903
  }
1849
1904
  onProgress?.("starting merge in fork...");
1850
- await merge(forkPath, upstreamRef, { noCommit: true, noEdit: true });
1905
+ await withTemporarySyncBaseGraft(
1906
+ forkPath,
1907
+ "HEAD",
1908
+ mergeBaseRef,
1909
+ () => merge(forkPath, upstreamRef, { noCommit: true, noEdit: true })
1910
+ );
1851
1911
  if (batchRestorePaths.length > 0) {
1852
1912
  onProgress?.(`batch restoring ${batchRestorePaths.length} pinned/ignored files...`);
1853
1913
  await batchRestoreToHead(forkPath, batchRestorePaths);
@@ -2100,7 +2160,12 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2100
2160
  await createWorktree(forkPath, worktreePath, "HEAD");
2101
2161
  onStep?.("worktree created", worktreePath);
2102
2162
  onProgress?.("performing merge in worktree...");
2103
- await merge(worktreePath, ctx.upstreamRef, { noCommit: true, noEdit: true });
2163
+ await withTemporarySyncBaseGraft(
2164
+ worktreePath,
2165
+ "HEAD",
2166
+ ctx.mergeBase,
2167
+ () => merge(worktreePath, ctx.upstreamRef, { noCommit: true, noEdit: true })
2168
+ );
2104
2169
  onStep?.("merge complete", "upstream merged into worktree");
2105
2170
  onProgress?.("analyzing files...");
2106
2171
  const analyzedFiles = await analyzeRefs(
@@ -3022,7 +3087,7 @@ import {
3022
3087
  import { select as select2 } from "@inquirer/prompts";
3023
3088
 
3024
3089
  // src/services/contrib-core.ts
3025
- import { dirname as dirname2, join as join12 } from "path";
3090
+ import { dirname as dirname3, join as join12, resolve as resolve3 } from "path";
3026
3091
  async function listFilesAtRef(repoPath, ref) {
3027
3092
  const raw = await git(["ls-tree", "-r", "--name-only", ref], repoPath);
3028
3093
  return new Set(raw.split("\n").filter(Boolean));
@@ -3038,10 +3103,10 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
3038
3103
  const baseFiles = await listFilesAtRef(repoPath, baseRef);
3039
3104
  const baseDirs = /* @__PURE__ */ new Set(["."]);
3040
3105
  for (const f of baseFiles) {
3041
- let dir = dirname2(f);
3106
+ let dir = dirname3(f);
3042
3107
  while (dir !== ".") {
3043
3108
  baseDirs.add(dir);
3044
- dir = dirname2(dir);
3109
+ dir = dirname3(dir);
3045
3110
  }
3046
3111
  }
3047
3112
  const files = [];
@@ -3054,7 +3119,7 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
3054
3119
  if (inCella && !inFork) {
3055
3120
  kind = "deleted";
3056
3121
  } else if (!inCella && inFork) {
3057
- if (!baseDirs.has(dirname2(file.path))) continue;
3122
+ if (!baseDirs.has(dirname3(file.path))) continue;
3058
3123
  kind = "created";
3059
3124
  } else {
3060
3125
  kind = "modified";
@@ -3085,7 +3150,8 @@ async function readBlobInfo(repoPath, ref, filePath) {
3085
3150
  }
3086
3151
  async function buildContribBranch(repoPath, baseRef, forkRef, detection, forkName) {
3087
3152
  const branch = `contrib/${forkName}`;
3088
- const tmpIndex = join12(repoPath, ".git", `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
3153
+ const gitDir = resolve3(repoPath, await git(["rev-parse", "--git-dir"], repoPath));
3154
+ const tmpIndex = join12(gitDir, `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
3089
3155
  const indexEnv = { GIT_INDEX_FILE: tmpIndex };
3090
3156
  try {
3091
3157
  await git(["read-tree", baseRef], repoPath, { env: indexEnv });
@@ -3129,21 +3195,25 @@ ${commitBody}`;
3129
3195
 
3130
3196
  // src/services/fork-utils.ts
3131
3197
  import { existsSync as existsSync7 } from "fs";
3132
- import { resolve as resolve3 } from "path";
3198
+ import { dirname as dirname4, resolve as resolve4 } from "path";
3199
+ async function resolveForkBasePath(repoPath) {
3200
+ const commonDir = await git(["rev-parse", "--git-common-dir"], repoPath, { ignoreErrors: true });
3201
+ return commonDir ? dirname4(resolve4(repoPath, commonDir)) : repoPath;
3202
+ }
3133
3203
  function validateForkPath(fork, basePath, requireConfig = false) {
3134
- const resolvedPath = resolve3(basePath, fork.localPath);
3135
- if (!resolvedPath.startsWith(resolve3(basePath, ".."))) {
3204
+ const resolvedPath = resolve4(basePath, fork.localPath);
3205
+ if (!resolvedPath.startsWith(resolve4(basePath, ".."))) {
3136
3206
  return { fork, resolvedPath, valid: false, error: "path resolves outside parent directory" };
3137
3207
  }
3138
3208
  if (!existsSync7(resolvedPath)) return { fork, resolvedPath, valid: false, error: "path does not exist" };
3139
3209
  if (!existsSync7(`${resolvedPath}/.git`)) return { fork, resolvedPath, valid: false, error: "not a git repository" };
3140
- if (requireConfig && !existsSync7(`${resolvedPath}/cella.config.ts`)) {
3141
- return { fork, resolvedPath, valid: false, error: "missing cella.config.ts" };
3210
+ if (requireConfig && !existsSync7(`${resolvedPath}/${CONFIG_FILE}`)) {
3211
+ return { fork, resolvedPath, valid: false, error: `missing ${CONFIG_FILE}` };
3142
3212
  }
3143
3213
  return { fork, resolvedPath, valid: true };
3144
3214
  }
3145
3215
  function printNoForksHint(action) {
3146
- console.info(colors_default.yellow("no forks configured in cella.config.ts"));
3216
+ console.info(colors_default.yellow("no forks configured in cella/cella.config.ts"));
3147
3217
  console.info(colors_default.dim(action));
3148
3218
  console.info(colors_default.dim(` forks: [{ name: 'my-app', localPath: '../my-app', pullBranch: 'development' }]`));
3149
3219
  }
@@ -3312,7 +3382,8 @@ async function runContributions(config) {
3312
3382
  );
3313
3383
  console.info("");
3314
3384
  }
3315
- const validated = forks.map((fork2) => validateForkPath(fork2, config.forkPath));
3385
+ const forkBasePath = await resolveForkBasePath(config.forkPath);
3386
+ const validated = forks.map((fork2) => validateForkPath(fork2, forkBasePath));
3316
3387
  const validForks = validated.filter((v) => v.valid);
3317
3388
  let selectedFork;
3318
3389
  if (config.fork) {
@@ -3514,12 +3585,59 @@ async function runContributions(config) {
3514
3585
  }
3515
3586
 
3516
3587
  // src/services/forks.ts
3517
- import { resolve as resolve4 } from "path";
3588
+ import { resolve as resolve5 } from "path";
3518
3589
  import process8 from "process";
3519
- import { Separator as Separator2, select as select3 } from "@inquirer/prompts";
3590
+ import { Separator as Separator2, select as select4 } from "@inquirer/prompts";
3520
3591
 
3521
3592
  // src/services/sync.ts
3593
+ import { spawnSync as spawnSync3 } from "child_process";
3594
+ import { select as select3 } from "@inquirer/prompts";
3595
+
3596
+ // src/utils/gh.ts
3522
3597
  import { spawnSync as spawnSync2 } from "child_process";
3598
+ function ghAvailable() {
3599
+ return spawnSync2("gh", ["--version"], { stdio: "ignore" }).status === 0;
3600
+ }
3601
+ function runGh(cwd, args) {
3602
+ const result = spawnSync2("gh", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
3603
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
3604
+ return { ok: result.status === 0, output };
3605
+ }
3606
+ function filterSyncPrs(prs, prefix) {
3607
+ return prs.filter((pr) => pr.headRefName.startsWith(`${prefix}/`));
3608
+ }
3609
+ function listOpenSyncPrs(cwd, prefix) {
3610
+ const { ok, output } = runGh(cwd, [
3611
+ "pr",
3612
+ "list",
3613
+ "--state",
3614
+ "open",
3615
+ "--json",
3616
+ "number,title,headRefName,url",
3617
+ "--limit",
3618
+ "50"
3619
+ ]);
3620
+ if (!ok || !output) return [];
3621
+ let parsed;
3622
+ try {
3623
+ parsed = JSON.parse(output);
3624
+ } catch {
3625
+ return [];
3626
+ }
3627
+ return filterSyncPrs(parsed, prefix).sort((a, b) => b.number - a.number);
3628
+ }
3629
+ function buildMergeArgs(ref, options = {}) {
3630
+ const args = ["pr", "merge", ref, "--squash"];
3631
+ if (options.auto) args.push("--auto");
3632
+ if (options.deleteBranch) args.push("--delete-branch");
3633
+ return args;
3634
+ }
3635
+ function mergePrSquash(cwd, ref, options = {}) {
3636
+ return runGh(cwd, buildMergeArgs(String(ref), options));
3637
+ }
3638
+ function closePr(cwd, ref) {
3639
+ return runGh(cwd, ["pr", "close", String(ref)]);
3640
+ }
3523
3641
 
3524
3642
  // src/services/packages.ts
3525
3643
  import { existsSync as existsSync8, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
@@ -3859,9 +3977,6 @@ function printFinishSteps() {
3859
3977
  function hasStagedSyncChanges(result) {
3860
3978
  return result.files.some((file) => ["behind", "diverged", "renamed", "ignored", "pinned"].includes(file.status));
3861
3979
  }
3862
- function ghAvailable() {
3863
- return spawnSync2("gh", ["--version"], { stdio: "ignore" }).status === 0;
3864
- }
3865
3980
  function extractFirstUrl(output) {
3866
3981
  return output.match(/https?:\/\/\S+/)?.[0];
3867
3982
  }
@@ -3878,12 +3993,25 @@ async function flattenSyncBranch(forkPath, branch, base) {
3878
3993
  await flattenBranch(forkPath, base, message);
3879
3994
  return true;
3880
3995
  }
3996
+ function indentLines(text) {
3997
+ return text.split("\n").map((line) => ` ${line}`).join("\n");
3998
+ }
3999
+ function enableAutoMerge(forkPath, branch) {
4000
+ console.info(colors_default.dim("enabling auto-merge (squash) once checks pass..."));
4001
+ const merge2 = mergePrSquash(forkPath, branch, { auto: true, deleteBranch: true });
4002
+ if (merge2.ok) return true;
4003
+ console.info(colors_default.yellow("could not enable auto-merge (is it enabled for the repo?). merge it manually:"));
4004
+ if (merge2.output) console.info(colors_default.dim(indentLines(merge2.output)));
4005
+ console.info(colors_default.dim(` gh pr merge ${branch} --squash --delete-branch`));
4006
+ return false;
4007
+ }
3881
4008
  async function shipSyncBranch(config, branch) {
3882
4009
  const { forkPath, settings } = config;
3883
4010
  const base = resolveReleaseBase(settings);
3884
4011
  const flattened = await flattenSyncBranch(forkPath, branch, base);
3885
4012
  let prUrl;
3886
4013
  let prOpened = false;
4014
+ let autoMergeEnabled = false;
3887
4015
  const headSubject = (await getCommitInfo(forkPath, "HEAD").catch(() => null))?.message;
3888
4016
  const prTitle = headSubject?.startsWith(SYNC_PR_TITLE) ? headSubject : SYNC_PR_TITLE;
3889
4017
  console.info(colors_default.dim(`pushing '${branch}' to origin...`));
@@ -3899,7 +4027,7 @@ async function shipSyncBranch(config, branch) {
3899
4027
  console.info(colors_default.dim("opening a pull request..."));
3900
4028
  const prBody = await buildSyncPrBodyForBranch(forkPath, base);
3901
4029
  const bodyArgs = prBody ? ["--body", prBody] : ["--fill"];
3902
- const pr = spawnSync2("gh", ["pr", "create", "--base", base, "--head", branch, "--title", prTitle, ...bodyArgs], {
4030
+ const pr = spawnSync3("gh", ["pr", "create", "--base", base, "--head", branch, "--title", prTitle, ...bodyArgs], {
3903
4031
  cwd: forkPath,
3904
4032
  encoding: "utf-8",
3905
4033
  stdio: ["ignore", "pipe", "pipe"]
@@ -3907,7 +4035,9 @@ async function shipSyncBranch(config, branch) {
3907
4035
  prUrl = extractFirstUrl(`${pr.stdout ?? ""}
3908
4036
  ${pr.stderr ?? ""}`);
3909
4037
  prOpened = pr.status === 0;
3910
- if (!prOpened) {
4038
+ if (prOpened) {
4039
+ if (config.directMerge) autoMergeEnabled = enableAutoMerge(forkPath, branch);
4040
+ } else {
3911
4041
  console.info(colors_default.yellow("could not open the PR automatically (it may already exist). open it with:"));
3912
4042
  if (prUrl) console.info(colors_default.dim(` ${prUrl}`));
3913
4043
  printPrCreateStep(branch, base, prTitle);
@@ -3923,7 +4053,8 @@ ${pr.stderr ?? ""}`);
3923
4053
  console.info();
3924
4054
  if (prUrl) {
3925
4055
  console.info(`${colors_default.green("\u2713")} Sync pull request ${prOpened ? "opened" : "ready"}`);
3926
- console.info(colors_default.dim(` ${prUrl} \xB7 branch pushed, back on '${base}'`));
4056
+ const status = autoMergeEnabled ? `auto-merge on \u2014 squashes into '${base}' when checks pass` : `branch pushed, back on '${base}'`;
4057
+ console.info(colors_default.dim(` ${prUrl} \xB7 ${status}`));
3927
4058
  } else {
3928
4059
  console.info(`${colors_default.green("\u2713")} Sync branch pushed`);
3929
4060
  console.info(colors_default.dim(` '${branch}' is on origin, back on '${base}'`));
@@ -3932,7 +4063,7 @@ ${pr.stderr ?? ""}`);
3932
4063
  function finalizeWorkspace(forkPath) {
3933
4064
  for (const args of [["install"], ["check"]]) {
3934
4065
  console.info(colors_default.dim(`running pnpm ${args.join(" ")}...`));
3935
- const result = spawnSync2("pnpm", args, { cwd: forkPath, stdio: "inherit" });
4066
+ const result = spawnSync3("pnpm", args, { cwd: forkPath, stdio: "inherit" });
3936
4067
  if (result.status !== 0) return false;
3937
4068
  }
3938
4069
  return true;
@@ -3977,6 +4108,64 @@ async function resumeSyncMerge(config, branch) {
3977
4108
  console.info(colors_default.green(`committed the sync on '${branch}' as '${message}'.`));
3978
4109
  await shipSyncBranch(config, branch);
3979
4110
  }
4111
+ async function mergeOpenSyncPrs(config, open) {
4112
+ const { forkPath, settings } = config;
4113
+ const base = resolveReleaseBase(settings);
4114
+ const [newest, ...superseded] = open;
4115
+ console.info();
4116
+ console.info(colors_default.dim(`squash-merging #${newest.number} into '${base}'...`));
4117
+ const merged = mergePrSquash(forkPath, newest.number, { deleteBranch: true });
4118
+ if (!merged.ok) {
4119
+ console.info();
4120
+ console.info(colors_default.yellow(`could not merge #${newest.number} \u2014 resolve it first, then re-run \`pnpm cella sync\`:`));
4121
+ if (merged.output) console.info(colors_default.dim(indentLines(merged.output)));
4122
+ console.info(colors_default.dim(` ${newest.url}`));
4123
+ return "cancel";
4124
+ }
4125
+ console.info(`${colors_default.green("\u2713")} merged #${newest.number}`);
4126
+ await deleteBranch(forkPath, newest.headRefName);
4127
+ for (const pr of superseded) {
4128
+ console.info(colors_default.dim(`closing #${pr.number} \u2014 its changes are included in #${newest.number}...`));
4129
+ closePr(forkPath, pr.number);
4130
+ await deleteBranch(forkPath, pr.headRefName);
4131
+ }
4132
+ console.info(colors_default.dim(`switching to '${base}' and fast-forwarding...`));
4133
+ await switchBranch(forkPath, base);
4134
+ await pullFastForward(forkPath);
4135
+ return "continue";
4136
+ }
4137
+ async function guardAgainstOpenSyncPr(config) {
4138
+ const { forkPath } = config;
4139
+ if (!ghAvailable() || !process.stdout.isTTY) return "continue";
4140
+ const open = listOpenSyncPrs(forkPath, DEFAULT_SYNC_PREFIX);
4141
+ if (open.length === 0) return "continue";
4142
+ const many = open.length > 1;
4143
+ console.info();
4144
+ console.info(
4145
+ `${warningMark} ${colors_default.yellow(`${many ? `${open.length} earlier sync PRs are` : "an earlier sync PR is"} still open and unmerged:`)}`
4146
+ );
4147
+ for (const pr of open) {
4148
+ console.info(colors_default.dim(` #${pr.number} ${pr.title}`));
4149
+ console.info(colors_default.dim(` ${pr.url}`));
4150
+ }
4151
+ console.info(colors_default.dim(` starting a new sync now produces a PR that re-includes ${many ? "these" : "those"} changes.`));
4152
+ console.info();
4153
+ const choice = await select3({
4154
+ message: "how do you want to proceed?",
4155
+ choices: [
4156
+ {
4157
+ value: "merge",
4158
+ name: `merge ${many ? "them" : `#${open[0].number}`} first, then sync ${colors_default.dim("(recommended)")}`
4159
+ },
4160
+ { value: "continue", name: `continue anyway ${colors_default.dim("(new PR will re-include the unmerged changes)")}` },
4161
+ { value: "cancel", name: colors_default.red("cancel") }
4162
+ ],
4163
+ loop: false
4164
+ });
4165
+ if (choice === "cancel") return "cancel";
4166
+ if (choice === "continue") return "continue";
4167
+ return mergeOpenSyncPrs(config, open);
4168
+ }
3980
4169
  async function runSyncCommand(config) {
3981
4170
  const { forkPath } = config;
3982
4171
  const currentBranch = await getCurrentBranch(forkPath);
@@ -4000,6 +4189,7 @@ commit them first (\`git commit --amend --no-edit\` or a new commit).`
4000
4189
  return;
4001
4190
  }
4002
4191
  await assertClean(forkPath);
4192
+ if (await guardAgainstOpenSyncPr(config) === "cancel") return;
4003
4193
  const outcome = await runSyncCycle(config);
4004
4194
  console.info();
4005
4195
  if (outcome.status === "noop") {
@@ -4101,14 +4291,15 @@ async function runForks(config) {
4101
4291
  console.error(colors_default.red(`fork '${config.fork}' not found in config`));
4102
4292
  return;
4103
4293
  }
4104
- const resolvedPath = resolve4(config.forkPath, match.localPath);
4294
+ const resolvedPath = resolve5(await resolveForkBasePath(config.forkPath), match.localPath);
4105
4295
  await syncFork(config, match, resolvedPath);
4106
4296
  return;
4107
4297
  }
4298
+ const forkBasePath = await resolveForkBasePath(config.forkPath);
4108
4299
  while (true) {
4109
- const choices = await buildForkChoices(forks, config.forkPath);
4300
+ const choices = await buildForkChoices(forks, forkBasePath);
4110
4301
  const forkChoices = [...choices, new Separator2("\u2500".repeat(40)), { value: "_exit", name: colors_default.dim("exit") }];
4111
- const selectedPath = await select3({
4302
+ const selectedPath = await select4({
4112
4303
  message: "select fork to sync:",
4113
4304
  choices: forkChoices,
4114
4305
  loop: false
@@ -4116,7 +4307,7 @@ async function runForks(config) {
4116
4307
  if (selectedPath === "_exit") {
4117
4308
  process8.exit(0);
4118
4309
  }
4119
- const resolvedForkPath = resolve4(config.forkPath, selectedPath);
4310
+ const resolvedForkPath = resolve5(forkBasePath, selectedPath);
4120
4311
  const selectedFork = forks.find((f) => f.localPath === selectedPath);
4121
4312
  if (!selectedFork) {
4122
4313
  console.error(colors_default.red(`fork '${selectedPath}' not found in config`));
@@ -4132,7 +4323,7 @@ import { readFile as readFile2 } from "fs/promises";
4132
4323
  import { join as join16 } from "path";
4133
4324
 
4134
4325
  // src/utils/coverage-utils.ts
4135
- import { spawnSync as spawnSync3 } from "child_process";
4326
+ import { spawnSync as spawnSync4 } from "child_process";
4136
4327
  import * as fs3 from "fs";
4137
4328
  import * as path3 from "path";
4138
4329
  var STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1e3;
@@ -4155,7 +4346,7 @@ function formatAge(ms) {
4155
4346
  }
4156
4347
  function refreshCoverage(forkPath) {
4157
4348
  console.info(colors_default.dim("running `pnpm test` (this spins up Docker and runs the full suite)..."));
4158
- const result = spawnSync3("pnpm", ["test"], { cwd: forkPath, stdio: "inherit" });
4349
+ const result = spawnSync4("pnpm", ["test"], { cwd: forkPath, stdio: "inherit" });
4159
4350
  return result.status === 0;
4160
4351
  }
4161
4352
  function printCoverageSummary(forkPath, options = {}) {
@@ -4230,6 +4421,9 @@ function classifyFile(filePath) {
4230
4421
  if (filePath.includes(".test.") || filePath.includes(".spec.") || filePath.includes("/tests/") || filePath.includes("/test/") || filePath.startsWith("tests/") || filePath.includes("/__tests__/")) {
4231
4422
  return "test";
4232
4423
  }
4424
+ if (filePath.includes(".stories.")) {
4425
+ return "stories";
4426
+ }
4233
4427
  if (filePath.includes(".gen.") || filePath.includes(".gen/") || filePath.includes("/gen/") || filePath.startsWith("drizzle/") || filePath.includes("/drizzle/") || filePath.endsWith(".d.ts") || filePath.includes(".tsbuildinfo")) {
4234
4428
  return "generated";
4235
4429
  }
@@ -4270,6 +4464,7 @@ async function collectStats(forkPath) {
4270
4464
  const skipped = allFilesCount - entries.length;
4271
4465
  const emptyCat = () => ({
4272
4466
  test: { files: 0, loc: 0 },
4467
+ stories: { files: 0, loc: 0 },
4273
4468
  generated: { files: 0, loc: 0 },
4274
4469
  json: { files: 0, loc: 0 },
4275
4470
  other: { files: 0, loc: 0 }
@@ -4322,6 +4517,7 @@ function printStats(stats, verbose) {
4322
4517
  const catEntries = [
4323
4518
  ["source & config", stats.categories.other],
4324
4519
  ["test", stats.categories.test],
4520
+ ["stories", stats.categories.stories],
4325
4521
  ["generated", stats.categories.generated],
4326
4522
  ["json", stats.categories.json]
4327
4523
  ];
@@ -4334,7 +4530,7 @@ function printStats(stats, verbose) {
4334
4530
  console.info(DIVIDER);
4335
4531
  console.info();
4336
4532
  if (verbose) {
4337
- const header = ` ${"".padEnd(6)} ${"package".padEnd(18)} ${"loc".padStart(8)} ${"test".padStart(6)} ${"gen".padStart(6)} ${"json".padStart(6)} ${"src".padStart(6)}`;
4533
+ const header = ` ${"".padEnd(6)} ${"package".padEnd(18)} ${"loc".padStart(8)} ${"test".padStart(6)} ${"story".padStart(6)} ${"gen".padStart(6)} ${"json".padStart(6)} ${"src".padStart(6)}`;
4338
4534
  console.info(colors_default.dim(header));
4339
4535
  for (const [name, data] of Object.entries(stats.packages)) {
4340
4536
  const c = data.categories;
@@ -4342,7 +4538,7 @@ function printStats(stats, verbose) {
4342
4538
  const srcLoc = c.other.loc;
4343
4539
  if (srcFiles === 0) continue;
4344
4540
  console.info(
4345
- ` ${colors_default.dim(pad(srcFiles))} ${name.padEnd(18)} ${colors_default.cyan(formatLocColumn(srcLoc))} ${colors_default.dim(pad(c.test.files))} ${colors_default.dim(pad(c.generated.files))} ${colors_default.dim(pad(c.json.files))} ${colors_default.dim(pad(srcFiles))}`
4541
+ ` ${colors_default.dim(pad(srcFiles))} ${name.padEnd(18)} ${colors_default.cyan(formatLocColumn(srcLoc))} ${colors_default.dim(pad(c.test.files))} ${colors_default.dim(pad(c.stories.files))} ${colors_default.dim(pad(c.generated.files))} ${colors_default.dim(pad(c.json.files))} ${colors_default.dim(pad(srcFiles))}`
4346
4542
  );
4347
4543
  }
4348
4544
  } else {
@@ -4374,7 +4570,7 @@ async function runStats(forkPath, options = {}) {
4374
4570
  function getForkPath() {
4375
4571
  const envPath = getEnv("CELLA_FORK_PATH");
4376
4572
  if (envPath) {
4377
- const resolved = resolve5(envPath);
4573
+ const resolved = resolve6(envPath);
4378
4574
  if (!existsSync10(resolved) || !statSync2(resolved).isDirectory()) {
4379
4575
  throw new Error(`CELLA_FORK_PATH is not a valid directory: ${resolved}`);
4380
4576
  }
@@ -4382,7 +4578,7 @@ function getForkPath() {
4382
4578
  }
4383
4579
  let cwd = process9.cwd();
4384
4580
  if (cwd.endsWith("/cli/cella") || cwd.endsWith("\\cli\\cella")) {
4385
- cwd = resolve5(cwd, "../..");
4581
+ cwd = resolve6(cwd, "../..");
4386
4582
  }
4387
4583
  return cwd;
4388
4584
  }