@cellajs/cli 0.3.3 → 0.4.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
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env tsx
2
2
 
3
3
  // src/cella-cli.ts
4
- import { existsSync as existsSync13, statSync as statSync3 } from "fs";
5
- import { join as join19, resolve as resolve8 } from "path";
6
- import process9 from "process";
4
+ import { existsSync as existsSync12, statSync as statSync3 } from "fs";
5
+ import { join as join17, resolve as resolve7 } from "path";
6
+ import process8 from "process";
7
7
 
8
8
  // src/cli.ts
9
9
  import process5 from "process";
@@ -77,7 +77,7 @@ var cellaConfigSchema = z.object({
77
77
  releaseBase: z.string().min(1).optional(),
78
78
  packageJsonSync: z.array(z.enum(PACKAGE_JSON_SYNC_KEYS)).optional(),
79
79
  syncWithPackages: z.boolean().optional(),
80
- fileLinkMode: z.enum(["commit", "file", "local"]).optional()
80
+ fileLinkMode: z.enum(["commit", "file"]).optional()
81
81
  }).strict(),
82
82
  overrides: z.object({
83
83
  ignored: z.array(z.string()).optional(),
@@ -101,11 +101,6 @@ function errorMessage(error) {
101
101
 
102
102
  // src/utils/managed-files.ts
103
103
  var CONFIG_FILE = "cella/cella.config.ts";
104
- var LEGACY_CONFIG_FILE = "cella.config.ts";
105
- var CONFIG_FILE_PATHS = [CONFIG_FILE, LEGACY_CONFIG_FILE];
106
- function isConfigFile(filePath) {
107
- return CONFIG_FILE_PATHS.includes(filePath);
108
- }
109
104
  function isPackageJson(filePath) {
110
105
  return filePath === "package.json" || filePath.endsWith("/package.json");
111
106
  }
@@ -114,7 +109,7 @@ function isGeneratedFile(filePath) {
114
109
  return segments.includes("gen") || (segments.at(-1) ?? "").includes(".gen.");
115
110
  }
116
111
  function isManagedFile(filePath) {
117
- return isPackageJson(filePath) || filePath === "pnpm-lock.yaml" || isConfigFile(filePath);
112
+ return isPackageJson(filePath) || filePath === "pnpm-lock.yaml" || filePath === CONFIG_FILE;
118
113
  }
119
114
 
120
115
  // src/utils/module-territory.ts
@@ -162,7 +157,6 @@ function resolveAppModuleFolders(repoPath) {
162
157
  var DEFAULT_UPSTREAM_REMOTE = "cella-upstream";
163
158
  var DEFAULT_BRANCH = "main";
164
159
  var DEFAULT_SYNC_PREFIX = "cella/sync";
165
- var DEFAULT_RELEASE_BASE = "main";
166
160
  var DEFAULT_PACKAGE_JSON_SYNC = ["dependencies", "devDependencies"];
167
161
  function isUpstreamRepo(repoPath) {
168
162
  return JSON.parse(readFileSync(join2(repoPath, "package.json"), "utf8")).name === "cella";
@@ -171,7 +165,7 @@ function isTemporarySyncBranch(branch) {
171
165
  return branch.startsWith(`${DEFAULT_SYNC_PREFIX}/`);
172
166
  }
173
167
  function resolveReleaseBase(settings) {
174
- return settings.releaseBase ?? DEFAULT_RELEASE_BASE;
168
+ return settings.releaseBase ?? DEFAULT_BRANCH;
175
169
  }
176
170
  function buildTemporarySyncBranch() {
177
171
  const now = /* @__PURE__ */ new Date();
@@ -219,14 +213,13 @@ ${z2.prettifyError(result.error)}`);
219
213
  }
220
214
 
221
215
  // src/utils/display.ts
222
- import { writeFileSync } from "fs";
223
- import { join as join3, resolve as resolve2 } from "path";
216
+ import { resolve as resolve2 } from "path";
224
217
  import process4 from "process";
225
218
 
226
219
  // package.json
227
220
  var package_default = {
228
221
  name: "@cellajs/cli",
229
- version: "0.3.3",
222
+ version: "0.4.0",
230
223
  private: false,
231
224
  license: "MIT",
232
225
  description: "CLI for managing your monorepo and syncing with upstream Cella.",
@@ -281,6 +274,7 @@ var package_default = {
281
274
  "@pierre/diffs": "^1.5.1",
282
275
  commander: "^15.0.0",
283
276
  "ts-morph": "^28.0.0",
277
+ yaml: "^2.9.1",
284
278
  zod: "^4.6.5"
285
279
  },
286
280
  devDependencies: {
@@ -349,44 +343,12 @@ var package_default = {
349
343
  };
350
344
 
351
345
  // src/utils/display.ts
352
- var NAME = "cella cli";
353
346
  var VERSION = package_default.version;
354
347
  var DIVIDER = "\u2500".repeat(60);
355
348
  var MENU_DIVIDER = "\u2500".repeat(40);
356
349
  var COMMIT_LIST_MAX = 50;
357
350
  var warningMark = colors_default.yellow("\u26A0");
358
351
  var checkMark = colors_default.green("\u2713");
359
- var TerminalSpinner = class {
360
- constructor(text, isSilent) {
361
- this.text = text;
362
- this.enabled = !isSilent && !!process4.stdout.isTTY;
363
- }
364
- text;
365
- frames = ["-", "\\", "|", "/"];
366
- frameIndex = 0;
367
- timer = null;
368
- enabled;
369
- start() {
370
- if (!this.enabled || this.timer) return this;
371
- this.render();
372
- this.timer = setInterval(() => {
373
- this.frameIndex = (this.frameIndex + 1) % this.frames.length;
374
- this.render();
375
- }, 80);
376
- return this;
377
- }
378
- stop() {
379
- if (this.timer) {
380
- clearInterval(this.timer);
381
- this.timer = null;
382
- }
383
- if (!this.enabled) return;
384
- process4.stdout.write("\r\x1B[2K");
385
- }
386
- render() {
387
- process4.stdout.write(`\r${colors_default.cyan(this.frames[this.frameIndex])} ${this.text}`);
388
- }
389
- };
390
352
  var activeSpinner = null;
391
353
  var jsonMode = false;
392
354
  function setJsonMode(enabled) {
@@ -397,42 +359,48 @@ function setJsonMode(enabled) {
397
359
  `);
398
360
  };
399
361
  console.info = toStderr;
400
- console.log = toStderr;
401
362
  console.warn = toStderr;
402
363
  }
403
364
  function writeStdout(text) {
404
365
  process4.stdout.write(text.endsWith("\n") ? text : `${text}
405
366
  `);
406
367
  }
407
- function getHeader() {
408
- const right = "cellajs.com";
409
- const visibleLeft = `\u29C8 ${NAME} v${VERSION}`;
410
- const padding = Math.max(1, 60 - visibleLeft.length - right.length);
411
- return `${colors_default.cyan(`\u29C8 ${NAME}`)}${colors_default.dim(` \xB7 v${VERSION}`)}${colors_default.cyan(`${" ".repeat(padding)}${right}`)}`;
412
- }
413
368
  function printHeader() {
369
+ const left = "\u29C8 cella cli";
370
+ const right = "cellajs.com";
371
+ const padding = Math.max(1, 60 - `${left} v${VERSION}`.length - right.length);
414
372
  console.info();
415
- console.info(getHeader());
373
+ console.info(`${colors_default.cyan(left)}${colors_default.dim(` \xB7 v${VERSION}`)}${colors_default.cyan(`${" ".repeat(padding)}${right}`)}`);
416
374
  console.info(DIVIDER);
417
375
  console.info();
418
376
  }
419
- function printStep(label, detail) {
420
- console.info(`${checkMark} ${label}`);
421
- if (detail) {
422
- console.info(` ${colors_default.dim(detail)}`);
423
- console.info();
424
- }
425
- }
426
377
  function createSpinner(text) {
427
- const isTestEnv = !!getEnv("VITEST") || getEnv("NODE_ENV") === "test";
428
- activeSpinner = new TerminalSpinner(text, isTestEnv || jsonMode);
429
- activeSpinner.start();
430
- return activeSpinner;
378
+ const enabled = !jsonMode && !getEnv("VITEST") && getEnv("NODE_ENV") !== "test" && !!process4.stdout.isTTY;
379
+ const frames = ["-", "\\", "|", "/"];
380
+ let frame = 0;
381
+ let timer;
382
+ const spinner = {
383
+ text,
384
+ stop() {
385
+ clearInterval(timer);
386
+ if (enabled) process4.stdout.write("\r\x1B[2K");
387
+ }
388
+ };
389
+ const render = () => process4.stdout.write(`\r${colors_default.cyan(frames[frame++ % frames.length])} ${spinner.text}`);
390
+ if (enabled) {
391
+ render();
392
+ timer = setInterval(render, 80);
393
+ }
394
+ activeSpinner = spinner;
395
+ return spinner;
431
396
  }
432
397
  function spinnerSuccess(message, detail) {
433
398
  stopSpinner();
434
- if (message) {
435
- printStep(message, detail);
399
+ if (!message) return;
400
+ console.info(`${checkMark} ${message}`);
401
+ if (detail) {
402
+ console.info(` ${colors_default.dim(detail)}`);
403
+ console.info();
436
404
  }
437
405
  }
438
406
  function spinnerFail(message) {
@@ -455,16 +423,8 @@ function hyperlink(label, url) {
455
423
  const safeUrl = url.replace(/[\x00-\x1f\x7f]/g, "");
456
424
  return `\x1B]8;;${safeUrl}\x07${label}\x1B]8;;\x07`;
457
425
  }
458
- function getVsCodeOpenFileLink(filePath, options) {
459
- const { forkPath } = options;
460
- if (!forkPath) return filePath;
461
- const absolutePath = resolve2(forkPath, filePath);
462
- const url = `vscode://file${absolutePath}`;
463
- return hyperlink(filePath, url);
464
- }
465
426
  function formatFetchedUpstreamDetail(commitCount, commits, upstreamGitHubUrl, maxShownCommits = COMMIT_LIST_MAX) {
466
- const commitLabel = commitCount === 1 ? "1 new commit" : `${commitCount} new commits`;
467
- const lines = [`${commitLabel} since last merge`];
427
+ const lines = [`${plural(commitCount, "new commit")} since last merge`];
468
428
  const shown = commits.slice(0, maxShownCommits);
469
429
  for (const commit of shown) {
470
430
  const shortHash = commit.hash.slice(0, 7);
@@ -477,32 +437,22 @@ function formatFetchedUpstreamDetail(commitCount, commits, upstreamGitHubUrl, ma
477
437
  }
478
438
  return lines.join("\n");
479
439
  }
480
- function getFileLink(filePath, commitHash, options) {
481
- const { upstreamGitHubUrl, upstreamBranch, fileLinkMode = "commit" } = options;
482
- const mode = fileLinkMode === "local" ? "file" : fileLinkMode;
483
- if (!upstreamGitHubUrl) {
484
- return { label: commitHash?.slice(0, 9) || "" };
485
- }
486
- if (mode === "file" && upstreamBranch) {
487
- const url = `${upstreamGitHubUrl}/blob/${upstreamBranch}/${filePath}`;
488
- return { label: filePath.split("/").pop() || filePath, url };
489
- }
490
- if (commitHash) {
491
- const url = `${upstreamGitHubUrl}/commit/${commitHash}`;
492
- return { label: commitHash.slice(0, 9), url };
493
- }
494
- return { label: "" };
495
- }
496
440
  function printSectionHeader(title) {
497
441
  console.info();
498
442
  console.info(title);
499
443
  console.info(DIVIDER);
500
444
  console.info();
501
445
  }
502
- function formatFileDateInfo(filePath, commit, date, linkOptions) {
503
- const link = getFileLink(filePath, commit, linkOptions);
504
- const linkLabel = link.label ? hyperlink(link.label, link.url) : "";
505
- return date ? colors_default.dim(` \u2260 ${date} ${linkLabel}`) : "";
446
+ function formatFileDateInfo(filePath, commit, date, options) {
447
+ if (!date) return "";
448
+ const { upstreamGitHubUrl: url, upstreamBranch, fileLinkMode = "commit" } = options;
449
+ let link = commit?.slice(0, 9) ?? "";
450
+ if (url && fileLinkMode === "file" && upstreamBranch) {
451
+ link = hyperlink(filePath.split("/").pop() || filePath, `${url}/blob/${upstreamBranch}/${filePath}`);
452
+ } else if (url) {
453
+ link = commit ? hyperlink(link, `${url}/commit/${commit}`) : "";
454
+ }
455
+ return colors_default.dim(` \u2260 ${date} ${link}`);
506
456
  }
507
457
  function formatMergeInProgressDetail(conflictCount, autoMergedFiles, linkOptions, maxFiles = 100) {
508
458
  const lines = [`${conflictCount} conflicts to resolve in IDE`];
@@ -511,10 +461,10 @@ function formatMergeInProgressDetail(conflictCount, autoMergedFiles, linkOptions
511
461
  }
512
462
  lines.push("");
513
463
  lines.push(` auto-merged files (${autoMergedFiles.length}):`);
464
+ const { forkPath } = linkOptions;
514
465
  const shown = autoMergedFiles.slice(0, maxFiles);
515
466
  for (const filePath of shown) {
516
- const fileLink = getVsCodeOpenFileLink(filePath, linkOptions);
517
- lines.push(` - ${fileLink}`);
467
+ lines.push(` - ${forkPath ? hyperlink(filePath, `vscode://file${resolve2(forkPath, filePath)}`) : filePath}`);
518
468
  }
519
469
  if (autoMergedFiles.length > shown.length) {
520
470
  lines.push(` ... + ${autoMergedFiles.length - shown.length} more`);
@@ -543,30 +493,25 @@ var summaryStatusConfig = {
543
493
  },
544
494
  ignored: { ...statusConfig.ignored, description: "protected by ignored config" }
545
495
  };
546
- var statusOrder = Object.keys(statusConfig);
547
496
  function printSummary(summary, title = "summary") {
548
497
  printSectionHeader(colors_default.cyan(title));
549
- const maxCount = Math.max(...statusOrder.map((status) => summary[status]), summary.managed);
498
+ const maxCount = Math.max(...Object.values(summary));
550
499
  const countWidth = String(maxCount).length + 1;
551
- const printLine = (status, count) => {
552
- const { icon, label, color, description } = summaryStatusConfig[status];
553
- const countStr = color(String(count).padStart(countWidth));
554
- const desc = description ? colors_default.dim(description) : "";
555
- console.info(` ${icon} ${countStr} ${label.padEnd(15)}${desc}`);
556
- };
557
- printLine("managed", summary.managed);
558
- printLine("ignored", summary.ignored);
559
- console.info();
560
- printLine("identical", summary.identical);
561
- console.info();
562
- printLine("ahead", summary.ahead);
563
- printLine("local", summary.local);
564
- printLine("drifted", summary.drifted);
565
- console.info();
566
- printLine("diverged", summary.diverged);
567
- printLine("pinned", summary.pinned);
568
- console.info();
569
- printLine("behind", summary.behind);
500
+ const groups = [
501
+ ["managed", "ignored"],
502
+ ["identical"],
503
+ ["ahead", "local", "drifted"],
504
+ ["diverged", "pinned"],
505
+ ["behind"]
506
+ ];
507
+ for (const group of groups) {
508
+ if (group !== groups[0]) console.info();
509
+ for (const status of group) {
510
+ const { icon, label, color, description } = summaryStatusConfig[status];
511
+ const countStr = color(String(summary[status]).padStart(countWidth));
512
+ console.info(` ${icon} ${countStr} ${label.padEnd(15)}${description ? colors_default.dim(description) : ""}`);
513
+ }
514
+ }
570
515
  }
571
516
  function printFileSection(files, title, linkOptions, options) {
572
517
  if (files.length === 0) return;
@@ -600,14 +545,13 @@ function printFileGroup(files, status, linkOptions, options) {
600
545
  function findProtectedBehind(files) {
601
546
  return files.filter((file) => file.upstreamChanged && !isManagedFile(file.path));
602
547
  }
603
- var PROTECTED_BEHIND_HINT = "pinned/ignored files where upstream also changed since the last sync; the fork side wins on conflict, so diff each against upstream (analyze --open-diff <path>) and adopt what you need.";
548
+ var PROTECTED_BEHIND_HINT = "pinned/ignored files where upstream also changed since the last sync; the fork side wins on conflict, so diff each against upstream and adopt what you need.";
604
549
  function protectionIcon(file) {
605
550
  return file.isIgnored ? statusConfig.ignored.icon : statusConfig.pinned.icon;
606
551
  }
607
552
  function formatUpstreamChangedLines(file) {
608
553
  if (file.upstreamChangedLines === void 0) return "";
609
- const n = file.upstreamChangedLines;
610
- return colors_default.yellow(` \xB7 ${n} ${n === 1 ? "line" : "lines"} changed upstream`);
554
+ return colors_default.yellow(` \xB7 ${plural(file.upstreamChangedLines, "line")} changed upstream`);
611
555
  }
612
556
  function protectedDiffHints(source, paths) {
613
557
  const range = source?.upstreamDiffRange;
@@ -622,7 +566,7 @@ function protectedDiffHints(source, paths) {
622
566
  }
623
567
  function formatUpstreamLinesAbsent(file) {
624
568
  const n = file.upstreamLinesAbsent ?? 0;
625
- return n > 0 ? colors_default.yellow(` \xB7 ${n} upstream ${n === 1 ? "line" : "lines"} absent`) : "";
569
+ return n > 0 ? colors_default.yellow(` \xB7 ${plural(n, "upstream line")} absent`) : "";
626
570
  }
627
571
  function printAnalysisFileGroups(files, linkOptions, diffSource) {
628
572
  const protectedBehind = findProtectedBehind(files);
@@ -634,7 +578,7 @@ function printAnalysisFileGroups(files, linkOptions, diffSource) {
634
578
  suffix: formatUpstreamLinesAbsent,
635
579
  hint: [
636
580
  "these files have fork changes but are protected (pinned); upstream did not change them since the last sync.",
637
- "pinned files keep the fork side on every conflict; a count here is upstream content the fork never received, deliberately or not: diff and decide."
581
+ "pinned files keep the fork copy whole; a count here is upstream content the fork never received, deliberately or not: `analyze --unpinned` shows what a merge would bring in."
638
582
  ]
639
583
  });
640
584
  printFileSection(protectedBehind, `${warningMark} ${colors_default.yellow("protected but behind upstream")}`, linkOptions, {
@@ -659,30 +603,6 @@ function printAnalysisFileGroups(files, linkOptions, diffSource) {
659
603
  dateSource: "upstream"
660
604
  });
661
605
  }
662
- function writeLogFile(forkPath, files) {
663
- const logPath = join3(forkPath, "cella-sync.log");
664
- const lines = [
665
- `cella sync analysis: ${(/* @__PURE__ */ new Date()).toISOString()}`,
666
- DIVIDER,
667
- "",
668
- `complete file list (${files.length} files)`,
669
- DIVIDER,
670
- ""
671
- ];
672
- const sortedFiles = [...files].sort((a, b) => {
673
- const aOrder = statusOrder.indexOf(a.status);
674
- const bOrder = statusOrder.indexOf(b.status);
675
- if (aOrder !== bOrder) return aOrder - bOrder;
676
- return a.path.localeCompare(b.path);
677
- });
678
- for (const file of sortedFiles) {
679
- const config = statusConfig[file.status];
680
- const icon = config.icon.replace(/\x1b\[[0-9;]*m/g, "");
681
- lines.push(` ${icon} ${config.label.padEnd(12)} ${file.path}`);
682
- }
683
- writeFileSync(logPath, lines.join("\n"), "utf8");
684
- return logPath;
685
- }
686
606
  function printSyncComplete(result, options = {}) {
687
607
  const updated = result.files.filter((file) => file.status === "behind" || file.status === "diverged").length;
688
608
  const diverged = result.files.filter((file) => file.status === "diverged").length;
@@ -708,7 +628,7 @@ function printProtectedConflicts(result) {
708
628
  console.info();
709
629
  console.info(
710
630
  `${warningMark} ${colors_default.yellow(
711
- `${paths.length} protected ${paths.length === 1 ? "file kept" : "files kept"} the fork version, dropping upstream changes:`
631
+ `${plural(paths.length, "protected file kept", "protected files kept")} the fork version, dropping upstream changes:`
712
632
  )}`
713
633
  );
714
634
  for (const path of paths) {
@@ -718,8 +638,8 @@ function printProtectedConflicts(result) {
718
638
  }
719
639
  for (const line of [PROTECTED_BEHIND_HINT, ...protectedDiffHints(result, paths)]) console.info(colors_default.dim(` ${line}`));
720
640
  }
721
- function plural(count, singular, pluralForm = `${singular}s`) {
722
- return `${count} ${count === 1 ? singular : pluralForm}`;
641
+ function plural(count2, singular, pluralForm = `${singular}s`) {
642
+ return `${count2} ${count2 === 1 ? singular : pluralForm}`;
723
643
  }
724
644
  function shellPath(path) {
725
645
  return /^[\w./@+-]+$/.test(path) ? path : `'${path.replace(/'/g, `'\\''`)}'`;
@@ -727,7 +647,7 @@ function shellPath(path) {
727
647
  function printIgnoredUpstreamChanges(result) {
728
648
  const groups = result.ignoredUpstreamChanges ?? [];
729
649
  if (groups.length === 0) return;
730
- const total = groups.reduce((count, group) => count + group.paths.length, 0);
650
+ const total = groups.reduce((count2, group) => count2 + group.paths.length, 0);
731
651
  console.info();
732
652
  console.info(
733
653
  `${warningMark} ${colors_default.yellow(`ignored paths changed upstream \xB7 ${plural(total, "file")} under ${plural(groups.length, "entry", "entries")}`)}`
@@ -775,11 +695,10 @@ function findMaskingPins(files) {
775
695
  function printMaskingPinWarning(files) {
776
696
  const masking = findMaskingPins(files);
777
697
  if (masking.length === 0) return;
778
- const many = masking.length > 1;
779
698
  console.info();
780
699
  console.info(
781
700
  `${warningMark} ${colors_default.yellow(
782
- `${masking.length} pinned ${many ? "files match" : "file matches"} the previous upstream but changed upstream:`
701
+ `${plural(masking.length, "pinned file matches", "pinned files match")} the previous upstream but changed upstream:`
783
702
  )}`
784
703
  );
785
704
  console.info(colors_default.yellow(" the pin keeps the old fork copy and silently drops those upstream changes:"));
@@ -791,49 +710,27 @@ function printMaskingPinWarning(files) {
791
710
  );
792
711
  console.info();
793
712
  }
794
- function printFlagWarnings(options) {
795
- const { hard, unpinned } = options;
796
- if (!hard && !unpinned) return;
797
- if (hard) {
798
- console.info(colors_default.yellow("\u26A0 --hard used: drifted files were treated as behind, overwriting with incoming."));
799
- }
800
- if (unpinned) {
801
- console.info(
802
- colors_default.yellow(
803
- "\u26A0 --unpinned used: pinned files in cella/cella.config.ts were ignored, this can result in more drifts."
804
- )
805
- );
806
- }
807
- console.info(colors_default.yellow(" be extra careful and cherry-pick what you want only."));
808
- console.info();
809
- }
810
713
  function printEngineReports(result, title) {
811
714
  printSummary(result.summary, title);
812
715
  printIgnoredUpstreamChanges(result);
813
716
  printUpstreamOverrideChanges(result);
814
717
  printMaskingPinWarning(result.files);
815
718
  }
816
- function printLogFileReport(config, files) {
817
- if (!config.logFile) return;
818
- const logPath = writeLogFile(config.forkPath, files);
819
- console.info();
820
- console.info(colors_default.dim(`full file list written to: ${logPath}`));
821
- }
822
719
 
823
720
  // src/utils/overrides.ts
824
721
  import { existsSync as existsSync5 } from "fs";
825
- import { join as join7 } from "path";
722
+ import { join as join6 } from "path";
826
723
 
827
724
  // src/utils/git.ts
828
725
  import { execFile } from "child_process";
829
726
  import { existsSync as existsSync3 } from "fs";
830
727
  import { mkdir as mkdir2, readdir, rmdir, unlink } from "fs/promises";
831
- import { dirname as dirname2, join as join5, resolve as resolve3 } from "path";
728
+ import { dirname as dirname2, join as join4, resolve as resolve3 } from "path";
832
729
  import { promisify } from "util";
833
730
 
834
731
  // src/utils/manifest.ts
835
732
  import { mkdir, readFile, writeFile } from "fs/promises";
836
- import { dirname, join as join4 } from "path";
733
+ import { dirname, join as join3 } from "path";
837
734
  var MANIFEST_FILE = "cella/cella.manifest.json";
838
735
  function parseSyncManifest(raw) {
839
736
  try {
@@ -847,7 +744,7 @@ function parseSyncManifest(raw) {
847
744
  }
848
745
  async function readSyncManifest(cwd) {
849
746
  try {
850
- return parseSyncManifest(await readFile(join4(cwd, MANIFEST_FILE), "utf8"));
747
+ return parseSyncManifest(await readFile(join3(cwd, MANIFEST_FILE), "utf8"));
851
748
  } catch {
852
749
  return null;
853
750
  }
@@ -857,7 +754,7 @@ async function readManifestBase(cwd) {
857
754
  return manifest ? manifest.upstream.commit.toLowerCase() : null;
858
755
  }
859
756
  async function writeSyncManifest(cwd, manifest) {
860
- const manifestPath = join4(cwd, MANIFEST_FILE);
757
+ const manifestPath = join3(cwd, MANIFEST_FILE);
861
758
  await mkdir(dirname(manifestPath), { recursive: true });
862
759
  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}
863
760
  `, "utf8");
@@ -878,9 +775,8 @@ async function git(args, cwd, options = {}) {
878
775
  ...options.skipEditor ? { GIT_EDITOR: "true" } : {},
879
776
  ...options.env
880
777
  };
881
- const maxBuffer = options.maxBuffer ?? MAX_BUFFER;
882
778
  try {
883
- const { stdout } = await execFileAsync("git", args, { cwd, env, maxBuffer });
779
+ const { stdout } = await execFileAsync("git", args, { cwd, env, maxBuffer: MAX_BUFFER });
884
780
  return stdout.trim();
885
781
  } catch (error) {
886
782
  if (options.ignoreErrors) return "";
@@ -890,6 +786,9 @@ async function git(args, cwd, options = {}) {
890
786
  throw error;
891
787
  }
892
788
  }
789
+ async function revParse(cwd, ref) {
790
+ return await git(["rev-parse", "-q", "--verify", ref], cwd, { ignoreErrors: true }) || null;
791
+ }
893
792
  async function getCurrentBranch(cwd) {
894
793
  return git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
895
794
  }
@@ -903,7 +802,7 @@ async function createBranchFrom(cwd, branch, startPoint) {
903
802
  await git(["switch", "-c", branch, "--no-track", startPoint], cwd);
904
803
  }
905
804
  async function branchExists(cwd, branch) {
906
- return await git(["rev-parse", "-q", "--verify", `refs/heads/${branch}`], cwd, { ignoreErrors: true }) !== "";
805
+ return await revParse(cwd, `refs/heads/${branch}`) !== null;
907
806
  }
908
807
  async function getBranchWorktree(cwd, branch) {
909
808
  const output = await git(["worktree", "list", "--porcelain"], cwd, { ignoreErrors: true });
@@ -968,7 +867,7 @@ async function stageAll(cwd) {
968
867
  await git(["add", "-A"], cwd);
969
868
  }
970
869
  async function mergeInProgress(cwd) {
971
- return await git(["rev-parse", "-q", "--verify", "MERGE_HEAD"], cwd, { ignoreErrors: true }) !== "";
870
+ return await revParse(cwd, "MERGE_HEAD") !== null;
972
871
  }
973
872
  async function getShortSha(cwd, ref) {
974
873
  return git(["rev-parse", "--short", ref], cwd);
@@ -978,16 +877,9 @@ async function getWorkingTreeChangeCount(cwd) {
978
877
  if (!status) return 0;
979
878
  return status.split("\n").filter(Boolean).length;
980
879
  }
981
- async function remoteExists(cwd, remoteName) {
982
- const remotes = await git(["remote"], cwd);
983
- return remotes.split("\n").includes(remoteName);
984
- }
985
880
  async function ensureRemote(cwd, remoteName, url) {
986
- if (await remoteExists(cwd, remoteName)) {
987
- await git(["remote", "set-url", remoteName, url], cwd);
988
- } else {
989
- await git(["remote", "add", remoteName, url], cwd);
990
- }
881
+ await git(["remote", "add", remoteName, url], cwd, { ignoreErrors: true });
882
+ await git(["remote", "set-url", remoteName, url], cwd);
991
883
  }
992
884
  async function fetchRemote(cwd, remoteName) {
993
885
  await git(["fetch", remoteName], cwd);
@@ -1014,7 +906,7 @@ async function resolveUpstreamCommit(cwd, remoteName, ref) {
1014
906
  if (ref.startsWith("-")) return null;
1015
907
  const tagRef = `refs/${remoteName}/tags/${ref}`;
1016
908
  for (const candidate of [`refs/remotes/${remoteName}/${ref}`, tagRef, ref]) {
1017
- const sha = await git(["rev-parse", "-q", "--verify", `${candidate}^{commit}`], cwd, { ignoreErrors: true });
909
+ const sha = await revParse(cwd, `${candidate}^{commit}`);
1018
910
  if (!sha) continue;
1019
911
  const release = candidate === tagRef && releaseTagPattern.test(ref) ? { tag: ref, ref: tagRef } : void 0;
1020
912
  return { sha, release };
@@ -1048,9 +940,9 @@ async function countCommitsBetween(cwd, fromRef, toRef) {
1048
940
  const output = await git(["rev-list", "--count", `${fromRef}..${toRef}`], cwd);
1049
941
  return Number.parseInt(output.trim(), 10) || 0;
1050
942
  }
1051
- async function listCommitsBetween(cwd, fromRef, toRef, options = {}) {
1052
- const { skip, limit } = options;
943
+ async function listCommitsBetween(cwd, fromRef, toRef, limit) {
1053
944
  const args = ["log", "--format=%H%x1f%s%x1f%ar", "--reverse", `${fromRef}..${toRef}`];
945
+ if (limit) args.push(`-n${limit}`);
1054
946
  const output = await git(args, cwd, { ignoreErrors: true });
1055
947
  if (!output) return [];
1056
948
  const commits = [];
@@ -1060,61 +952,32 @@ async function listCommitsBetween(cwd, fromRef, toRef, options = {}) {
1060
952
  if (!hash || !message || !date) continue;
1061
953
  commits.push({ hash, message, date });
1062
954
  }
1063
- const start = Math.max(0, skip ?? 0);
1064
- return limit && limit > 0 ? commits.slice(start, start + limit) : commits.slice(start);
955
+ return commits;
1065
956
  }
1066
957
  async function getFileChangeInfo(cwd, fromRef, toRef) {
1067
958
  const info = /* @__PURE__ */ new Map();
1068
- try {
1069
- const output = await git(["log", "--name-only", "--format=%h %ct %ar", `${fromRef}..${toRef}`], cwd, {
1070
- ignoreErrors: true
1071
- });
1072
- if (!output.trim()) return info;
1073
- const lines = output.split("\n");
1074
- let currentHash = "";
1075
- let currentDate = "";
1076
- let currentTimestamp = 0;
1077
- for (const line of lines) {
1078
- if (!line) continue;
1079
- const match = line.match(/^([a-f0-9]{7,})\s+(\d+)\s+(.+)$/);
1080
- if (match) {
1081
- currentHash = match[1];
1082
- currentTimestamp = Number(match[2]);
1083
- currentDate = match[3];
1084
- } else if (currentHash && currentDate && !info.has(line)) {
1085
- info.set(line, { date: currentDate, hash: currentHash, timestamp: currentTimestamp });
1086
- }
959
+ const output = await git(["log", "--name-only", "--format=%h %ct %ar", `${fromRef}..${toRef}`], cwd, {
960
+ ignoreErrors: true
961
+ });
962
+ if (!output) return info;
963
+ let currentHash = "";
964
+ let currentDate = "";
965
+ let currentTimestamp = 0;
966
+ for (const line of output.split("\n")) {
967
+ if (!line) continue;
968
+ const match = line.match(/^([a-f0-9]{7,})\s+(\d+)\s+(.+)$/);
969
+ if (match) {
970
+ currentHash = match[1];
971
+ currentTimestamp = Number(match[2]);
972
+ currentDate = match[3];
973
+ } else if (currentHash && currentDate && !info.has(line)) {
974
+ info.set(line, { date: currentDate, hash: currentHash, timestamp: currentTimestamp });
1087
975
  }
1088
- } catch {
1089
976
  }
1090
977
  return info;
1091
978
  }
1092
979
  async function isClean(cwd) {
1093
- const status = await git(["status", "--porcelain"], cwd);
1094
- return !status;
1095
- }
1096
- async function assertClean(cwd, subject = "working directory") {
1097
- if (!await isClean(cwd)) {
1098
- throw new Error(`${subject} has uncommitted changes. please commit or stash before syncing.`);
1099
- }
1100
- }
1101
- async function createWorktree(cwd, worktreePath, commitRef) {
1102
- await git(["worktree", "add", "--detach", worktreePath, commitRef], cwd);
1103
- }
1104
- async function removeWorktree(cwd, worktreePath) {
1105
- await git(["worktree", "remove", worktreePath, "--force"], cwd, { ignoreErrors: true });
1106
- await git(["worktree", "prune"], cwd, { ignoreErrors: true });
1107
- }
1108
- async function listWorktrees(cwd) {
1109
- const output = await git(["worktree", "list", "--porcelain"], cwd);
1110
- const lines = output.split("\n");
1111
- const paths = [];
1112
- for (const line of lines) {
1113
- if (line.startsWith("worktree ")) {
1114
- paths.push(line.slice(9));
1115
- }
1116
- }
1117
- return paths;
980
+ return await getWorkingTreeChangeCount(cwd) === 0;
1118
981
  }
1119
982
  async function merge(cwd, ref) {
1120
983
  try {
@@ -1140,8 +1003,8 @@ async function fileExistsAtRef(cwd, ref, filePath) {
1140
1003
  const result = await git(["ls-tree", "--name-only", ref, "--", filePath], cwd, { ignoreErrors: true });
1141
1004
  return result !== "";
1142
1005
  }
1143
- async function checkoutFromRef(cwd, ref, filePath) {
1144
- await git(["checkout", ref, "--", filePath], cwd);
1006
+ async function checkoutFromRef(cwd, ref, paths) {
1007
+ await git(["checkout", ref, "--", ...paths], cwd);
1145
1008
  }
1146
1009
  async function restoreWorktreeFromRef(cwd, ref, filePath) {
1147
1010
  await git(["restore", `--source=${ref}`, "--worktree", "--", filePath], cwd);
@@ -1162,6 +1025,10 @@ async function listIdenticalToRef(cwd, ref) {
1162
1025
  const differs = new Set(nulSeparated(changed));
1163
1026
  return new Set(nulSeparated(tracked).filter((path) => !differs.has(path)));
1164
1027
  }
1028
+ async function listChangedSinceRef(cwd, ref, paths) {
1029
+ const out = await git(["--literal-pathspecs", "diff", "--name-only", "--no-renames", "-z", ref, "--", ...paths], cwd);
1030
+ return out.split("\0").filter(Boolean);
1031
+ }
1165
1032
  async function getMergeBase(cwd, ref1, ref2) {
1166
1033
  return git(["merge-base", ref1, ref2], cwd);
1167
1034
  }
@@ -1170,30 +1037,17 @@ async function getFileChanges(cwd, baseRef, targetRef) {
1170
1037
  ignoreErrors: true
1171
1038
  });
1172
1039
  const changes = /* @__PURE__ */ new Map();
1173
- if (!output) return changes;
1174
1040
  const hashOrNull = (hash) => hash === "0".repeat(40) ? null : hash;
1175
1041
  for (const line of output.split("\n")) {
1176
- if (!line) continue;
1177
- const renameMatch = line.match(/^:\d+ \d+ ([a-f0-9]+) ([a-f0-9]+) R\d*\s+(.+)\t(.+)$/);
1178
- if (renameMatch) {
1179
- const [, baseHash, targetHash, oldPath, newPath] = renameMatch;
1180
- changes.set(newPath, {
1181
- status: "R",
1182
- baseHash: hashOrNull(baseHash),
1183
- targetHash: hashOrNull(targetHash),
1184
- oldPath
1185
- });
1186
- continue;
1187
- }
1188
- const match = line.match(/^:\d+ \d+ ([a-f0-9]+) ([a-f0-9]+) ([ADMT])\t(.+)$/);
1189
- if (match) {
1190
- const [, baseHash, targetHash, status, filePath] = match;
1191
- changes.set(filePath, {
1192
- status,
1193
- baseHash: hashOrNull(baseHash),
1194
- targetHash: hashOrNull(targetHash)
1195
- });
1196
- }
1042
+ const m = line.match(/^:\d+ \d+ ([a-f0-9]+) ([a-f0-9]+) ([ADMRT])\d*\t([^\t]+)(?:\t(.+))?$/);
1043
+ if (!m) continue;
1044
+ const status = m[3];
1045
+ changes.set(m[5] ?? m[4], {
1046
+ status,
1047
+ baseHash: hashOrNull(m[1]),
1048
+ targetHash: hashOrNull(m[2]),
1049
+ ...status === "R" ? { oldPath: m[4] } : {}
1050
+ });
1197
1051
  }
1198
1052
  return changes;
1199
1053
  }
@@ -1228,38 +1082,23 @@ async function getFileHashesAtRef(cwd, ref) {
1228
1082
  }
1229
1083
  return hashes;
1230
1084
  }
1231
- async function restoreToHead(cwd, filePath) {
1232
- await git(["checkout", "HEAD", "--", filePath], cwd);
1233
- }
1234
- async function batchRestoreToHead(cwd, filePaths) {
1235
- if (filePaths.length === 0) return;
1236
- await git(["checkout", "HEAD", "--", ...filePaths], cwd);
1237
- }
1238
- async function gitRm(cwd, filePath) {
1239
- await git(["rm", "-f", "--", filePath], cwd, { ignoreErrors: true });
1240
- }
1241
- async function batchGitRm(cwd, filePaths) {
1242
- if (filePaths.length === 0) return;
1243
- await git(["rm", "-f", "--", ...filePaths], cwd, { ignoreErrors: true });
1085
+ async function gitRm(cwd, paths, options = {}) {
1086
+ await git(["rm", ...options.cached ? ["--cached"] : [], "-f", "--", ...paths], cwd, { ignoreErrors: true });
1244
1087
  }
1245
1088
  async function getStagedNewFiles(cwd) {
1246
1089
  const output = await git(["diff", "--cached", "--name-only", "--diff-filter=A"], cwd, { ignoreErrors: true });
1247
1090
  return output ? output.split("\n").filter(Boolean) : [];
1248
1091
  }
1249
- async function batchUnstageFiles(cwd, filePaths) {
1250
- if (filePaths.length === 0) return;
1251
- await git(["rm", "--cached", "-f", "--", ...filePaths], cwd, { ignoreErrors: true });
1252
- }
1253
1092
  async function gitMv(cwd, oldPath, newPath) {
1254
- const newDir = dirname2(join5(cwd, newPath));
1093
+ const newDir = dirname2(join4(cwd, newPath));
1255
1094
  await mkdir2(newDir, { recursive: true });
1256
1095
  await git(["mv", "-f", oldPath, newPath], cwd);
1257
1096
  }
1258
1097
  async function fileExistsInWorktree(cwd, filePath) {
1259
- return existsSync3(join5(cwd, filePath));
1098
+ return existsSync3(join4(cwd, filePath));
1260
1099
  }
1261
1100
  async function removeFileFromWorktree(cwd, filePath) {
1262
- const fullPath = join5(cwd, filePath);
1101
+ const fullPath = join4(cwd, filePath);
1263
1102
  try {
1264
1103
  await unlink(fullPath);
1265
1104
  await cleanupEmptyParentDirs(cwd, dirname2(filePath));
@@ -1267,12 +1106,12 @@ async function removeFileFromWorktree(cwd, filePath) {
1267
1106
  }
1268
1107
  }
1269
1108
  async function removeFileFully(cwd, filePath) {
1270
- await gitRm(cwd, filePath);
1109
+ await gitRm(cwd, [filePath]);
1271
1110
  await removeFileFromWorktree(cwd, filePath);
1272
1111
  }
1273
1112
  async function cleanupEmptyParentDirs(cwd, relativePath) {
1274
1113
  if (!relativePath || relativePath === ".") return;
1275
- const fullPath = join5(cwd, relativePath);
1114
+ const fullPath = join4(cwd, relativePath);
1276
1115
  try {
1277
1116
  const entries = await readdir(fullPath);
1278
1117
  if (entries.length === 0) {
@@ -1284,21 +1123,14 @@ async function cleanupEmptyParentDirs(cwd, relativePath) {
1284
1123
  }
1285
1124
  async function storeLastSyncRef(cwd, upstreamHash) {
1286
1125
  await git(["update-ref", "refs/cella/last-sync", upstreamHash], cwd);
1287
- const head = await git(["rev-parse", "HEAD"], cwd, { ignoreErrors: true });
1288
- if (head) {
1289
- await git(["update-ref", "refs/cella/last-sync-head", head], cwd);
1290
- }
1126
+ const head = await revParse(cwd, "HEAD");
1127
+ if (head) await git(["update-ref", "refs/cella/last-sync-head", head], cwd);
1291
1128
  }
1292
1129
  async function stagePath(cwd, path) {
1293
1130
  await git(["add", "--", path], cwd, { ignoreErrors: true });
1294
1131
  }
1295
1132
  async function getStoredSyncRef(cwd) {
1296
- const ref = await git(["rev-parse", "refs/cella/last-sync"], cwd, { ignoreErrors: true });
1297
- return ref || null;
1298
- }
1299
- async function getStoredSyncHead(cwd) {
1300
- const ref = await git(["rev-parse", "refs/cella/last-sync-head"], cwd, { ignoreErrors: true });
1301
- return ref || null;
1133
+ return revParse(cwd, "refs/cella/last-sync");
1302
1134
  }
1303
1135
  async function readManifestAtRef(cwd, ref) {
1304
1136
  const raw = await git(["show", `${ref}:${MANIFEST_FILE}`], cwd, { ignoreErrors: true });
@@ -1320,57 +1152,13 @@ async function readPackageVersionAtRef(cwd, ref) {
1320
1152
  }
1321
1153
  }
1322
1154
  async function commitObjectExists(cwd, sha) {
1323
- try {
1324
- await git(["cat-file", "-e", `${sha}^{commit}`], cwd);
1325
- return true;
1326
- } catch {
1327
- return false;
1328
- }
1155
+ return await revParse(cwd, `${sha}^{commit}`) !== null;
1329
1156
  }
1330
1157
  async function getRootCommit(cwd, ref) {
1331
1158
  const out = await git(["rev-list", "--max-parents=0", ref], cwd, { ignoreErrors: true });
1332
1159
  const roots = out.split("\n").filter(Boolean);
1333
1160
  return roots.length > 0 ? roots[roots.length - 1] : null;
1334
1161
  }
1335
- var SCAFFOLD_CANDIDATE_LIMIT = 400;
1336
- var SCAFFOLD_SCORE_CONCURRENCY = 10;
1337
- var SCAFFOLD_MATCH_MAX_DIFF_RATIO = 0.3;
1338
- async function inferScaffoldBase(cwd, headRef, upstreamRef) {
1339
- const root = await getRootCommit(cwd, headRef);
1340
- if (!root) return null;
1341
- const rootDate = await git(["show", "-s", "--format=%cI", root], cwd, { ignoreErrors: true });
1342
- if (!rootDate) return null;
1343
- const before = new Date(new Date(rootDate).getTime() + 24 * 60 * 60 * 1e3).toISOString();
1344
- const revList = await git(
1345
- ["rev-list", `--max-count=${SCAFFOLD_CANDIDATE_LIMIT}`, `--before=${before}`, upstreamRef],
1346
- cwd,
1347
- { ignoreErrors: true }
1348
- );
1349
- const candidates = revList.split("\n").filter(Boolean);
1350
- if (candidates.length === 0) return null;
1351
- const totalPaths = (await git(["ls-tree", "-r", "--name-only", root], cwd)).split("\n").filter(Boolean).length;
1352
- if (totalPaths === 0) return null;
1353
- let best = null;
1354
- outer: for (let i = 0; i < candidates.length; i += SCAFFOLD_SCORE_CONCURRENCY) {
1355
- const chunk = candidates.slice(i, i + SCAFFOLD_SCORE_CONCURRENCY);
1356
- const scores = await Promise.all(
1357
- chunk.map(async (sha) => {
1358
- try {
1359
- const out = await git(["diff-tree", "-r", "--name-only", sha, root], cwd);
1360
- return { sha, diff: out ? out.split("\n").length : 0 };
1361
- } catch {
1362
- return { sha, diff: Number.POSITIVE_INFINITY };
1363
- }
1364
- })
1365
- );
1366
- for (const score of scores) {
1367
- if (!best || score.diff < best.diff) best = score;
1368
- if (best.diff === 0) break outer;
1369
- }
1370
- }
1371
- if (!best || best.diff / totalPaths > SCAFFOLD_MATCH_MAX_DIFF_RATIO) return null;
1372
- return best.sha;
1373
- }
1374
1162
  async function readRootTrailerBase(cwd, headRef) {
1375
1163
  const root = await getRootCommit(cwd, headRef);
1376
1164
  if (!root) return null;
@@ -1409,8 +1197,7 @@ async function ensureSyncBase(cwd, headRef, upstreamRef) {
1409
1197
  () => getStoredSyncRef(cwd),
1410
1198
  () => readManifestBaseAtRef(cwd, headRef),
1411
1199
  () => readManifestBase(cwd),
1412
- () => readRootTrailerBase(cwd, headRef),
1413
- () => inferScaffoldBase(cwd, headRef, upstreamRef)
1200
+ () => readRootTrailerBase(cwd, headRef)
1414
1201
  ];
1415
1202
  let baseSha = null;
1416
1203
  for (const source of sources) {
@@ -1425,7 +1212,7 @@ async function ensureSyncBase(cwd, headRef, upstreamRef) {
1425
1212
  `no common ancestor between the fork and '${upstreamRef}', and no sync base could be determined.
1426
1213
 
1427
1214
  This fork has unrelated history with upstream (typical for a create-cella scaffold, or after
1428
- upstream squashed its history) and no recorded or inferable base commit exists after fetching
1215
+ upstream squashed its history) and no recorded base commit exists after fetching
1429
1216
  upstream. Seed it manually with the upstream commit the fork was created from, then re-run sync:
1430
1217
 
1431
1218
  git update-ref refs/cella/last-sync <upstream-sha>
@@ -1448,9 +1235,9 @@ async function isAncestor(cwd, ancestor, descendant) {
1448
1235
  async function getLegacySyncRef(cwd, headRef) {
1449
1236
  const storedRef = await getStoredSyncRef(cwd);
1450
1237
  if (!storedRef) return null;
1451
- const storedHead = await getStoredSyncHead(cwd);
1238
+ const storedHead = await revParse(cwd, "refs/cella/last-sync-head");
1452
1239
  if (storedHead) {
1453
- const currentHead = await git(["rev-parse", headRef], cwd, { ignoreErrors: true });
1240
+ const currentHead = await revParse(cwd, headRef);
1454
1241
  if (currentHead && currentHead === storedHead) return null;
1455
1242
  }
1456
1243
  return storedRef;
@@ -1468,7 +1255,7 @@ async function getEffectiveMergeBase(cwd, headRef, upstreamRef) {
1468
1255
  return { base: gitBase, isStale: false, storedRef: recorded };
1469
1256
  }
1470
1257
  async function withTemporarySyncBaseGraft(cwd, headRef, baseSha, fn) {
1471
- const headSha = await git(["rev-parse", headRef], cwd, { ignoreErrors: true });
1258
+ const headSha = await revParse(cwd, headRef);
1472
1259
  if (!headSha || !await commitObjectExists(cwd, baseSha)) return fn();
1473
1260
  if (await isAncestor(cwd, baseSha, headSha)) return fn();
1474
1261
  const existingReplacement = await git(["replace", "-l", headSha], cwd, { ignoreErrors: true });
@@ -1486,7 +1273,7 @@ async function withTemporarySyncBaseGraft(cwd, headRef, baseSha, fn) {
1486
1273
  // src/utils/migration-notes.ts
1487
1274
  import { existsSync as existsSync4 } from "fs";
1488
1275
  import { mkdir as mkdir3, readFile as readFile2, rm, writeFile as writeFile2 } from "fs/promises";
1489
- import { dirname as dirname3, join as join6 } from "path";
1276
+ import { dirname as dirname3, join as join5 } from "path";
1490
1277
  var NOTES_DIR = "cella/migrations";
1491
1278
  var PENDING_FILE = "cella/cella.migrations.json";
1492
1279
  var NOTE_ID = /^\d{8}T\d{4}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -1541,17 +1328,14 @@ async function arrivedNoteIds(cwd, from, to) {
1541
1328
  const known = new Set(before);
1542
1329
  return after.filter((id) => !known.has(id));
1543
1330
  }
1544
- async function readPending(cwd, syncRef) {
1545
- const path = join6(cwd, PENDING_FILE);
1331
+ async function readPending(cwd) {
1332
+ const path = join5(cwd, PENDING_FILE);
1546
1333
  if (!existsSync4(path)) return [];
1547
1334
  const parsed = JSON.parse(await readFile2(path, "utf8"));
1548
- if (Array.isArray(parsed.pending)) return parsed.pending.filter((id) => typeof id === "string");
1549
- if (!Array.isArray(parsed.applied) || !syncRef) return [];
1550
- const applied = new Set(parsed.applied);
1551
- return (await listNoteIds(cwd, syncRef)).filter((id) => !applied.has(id));
1335
+ return Array.isArray(parsed.pending) ? parsed.pending.filter((id) => typeof id === "string") : [];
1552
1336
  }
1553
1337
  async function writePending(cwd, ids) {
1554
- const path = join6(cwd, PENDING_FILE);
1338
+ const path = join5(cwd, PENDING_FILE);
1555
1339
  const pending = [...new Set(ids)].sort();
1556
1340
  if (pending.length === 0) {
1557
1341
  await rm(path, { force: true });
@@ -1572,9 +1356,12 @@ function formatNotesLine(total, open, arrived, options = {}) {
1572
1356
  }
1573
1357
 
1574
1358
  // src/utils/overrides.ts
1359
+ function normalizeEntry(entry) {
1360
+ return entry.trim().replace(/^\.\//, "").replace(/\/+$/, "");
1361
+ }
1575
1362
  function isUnderAnyFolder(filePath, folders) {
1576
1363
  return folders.some((folder) => {
1577
- const entry = folder.replace(/\/+$/, "");
1364
+ const entry = normalizeEntry(folder);
1578
1365
  return filePath === entry || filePath.startsWith(`${entry}/`);
1579
1366
  });
1580
1367
  }
@@ -1585,13 +1372,17 @@ function isPinned(filePath, config) {
1585
1372
  if (isManagedFile(filePath)) return true;
1586
1373
  return isUnderAnyFolder(filePath, config.overrides?.pinned || []);
1587
1374
  }
1588
- function groupIgnoredUpstreamChanges(files, ignored) {
1589
- const entries = [...new Set(ignored.map((entry) => entry.replace(/\/+$/, "")).filter(Boolean))];
1375
+ function entryMatcher(list) {
1376
+ const entries = [...new Set(list.map(normalizeEntry).filter(Boolean))];
1590
1377
  const mostSpecificFirst = [...entries].sort((a, b) => b.length - a.length);
1378
+ return [entries, (path) => mostSpecificFirst.find((entry) => isUnderAnyFolder(path, [entry]))];
1379
+ }
1380
+ function groupIgnoredUpstreamChanges(files, ignored) {
1381
+ const [entries, findEntry] = entryMatcher(ignored);
1591
1382
  const groups = /* @__PURE__ */ new Map();
1592
1383
  for (const file of files) {
1593
1384
  if (!file.upstreamOnly || isManagedFile(file.path) || isGeneratedFile(file.path)) continue;
1594
- const entry = mostSpecificFirst.find((candidate) => isUnderAnyFolder(file.path, [candidate]));
1385
+ const entry = findEntry(file.path);
1595
1386
  if (!entry) continue;
1596
1387
  const group = groups.get(entry) ?? { entry, paths: [], added: 0, deleted: 0 };
1597
1388
  group.paths.push(file.path);
@@ -1603,11 +1394,10 @@ function groupIgnoredUpstreamChanges(files, ignored) {
1603
1394
  return entries.flatMap((entry) => groups.get(entry) ?? []);
1604
1395
  }
1605
1396
  function groupProtectedUpstreamChanges(paths, config) {
1606
- const entries = [...config.overrides?.pinned ?? [], ...config.overrides?.ignored ?? []].map((entry) => entry.replace(/\/+$/, "")).filter(Boolean);
1607
- const mostSpecificFirst = [...new Set(entries)].sort((a, b) => b.length - a.length);
1397
+ const [, findEntry] = entryMatcher([...config.overrides?.pinned ?? [], ...config.overrides?.ignored ?? []]);
1608
1398
  const groups = /* @__PURE__ */ new Map();
1609
1399
  for (const path of paths) {
1610
- const entry = mostSpecificFirst.find((candidate) => isUnderAnyFolder(path, [candidate])) ?? path;
1400
+ const entry = findEntry(path) ?? path;
1611
1401
  const group = groups.get(entry) ?? { entry, paths: [] };
1612
1402
  group.paths.push(path);
1613
1403
  groups.set(entry, group);
@@ -1615,56 +1405,39 @@ function groupProtectedUpstreamChanges(paths, config) {
1615
1405
  for (const group of groups.values()) group.paths.sort();
1616
1406
  return [...groups.values()];
1617
1407
  }
1618
- function isPinnedForSync(filePath, config, unpinned) {
1619
- if (isManagedFile(filePath)) return true;
1620
- if (unpinned) return false;
1621
- return isPinned(filePath, config);
1622
- }
1623
- function hasGlobChars(entry) {
1624
- return entry.includes("*") || entry.includes("?");
1625
- }
1626
1408
  async function validateOverrides(config, forkPath, upstreamRef) {
1627
1409
  const warnings = [];
1628
- const pinned = config.overrides?.pinned || [];
1629
- for (const entry of pinned) {
1630
- if (hasGlobChars(entry)) {
1631
- warnings.push({
1632
- type: "pinned-glob",
1633
- pattern: entry,
1634
- message: `pin uses glob (not supported, use a path or folder): ${entry}`
1635
- });
1636
- } else if (!existsSync5(join7(forkPath, entry))) {
1637
- warnings.push({
1638
- type: "pinned-not-found",
1639
- pattern: entry,
1640
- message: `pin not found: ${entry}`
1641
- });
1642
- }
1643
- }
1644
- const ignored = config.overrides?.ignored || [];
1645
- for (const entry of ignored) {
1646
- if (hasGlobChars(entry)) {
1647
- warnings.push({
1648
- type: "ignored-not-found",
1649
- pattern: entry,
1650
- message: `ignored entry uses glob (not supported, use a path or folder): ${entry}`
1651
- });
1652
- } else if (!existsSync5(join7(forkPath, entry.replace(/\/+$/, "")))) {
1653
- if (upstreamRef && await fileExistsAtRef(forkPath, upstreamRef, entry.replace(/\/+$/, ""))) continue;
1654
- warnings.push({
1655
- type: "ignored-not-found",
1656
- pattern: entry,
1657
- message: `ignored entry not found: ${entry}`
1658
- });
1410
+ const ignored = config.overrides?.ignored ?? [];
1411
+ const upstream = upstreamRef && await revParse(forkPath, upstreamRef) ? upstreamRef : void 0;
1412
+ const pinsUpstreamHas = [];
1413
+ for (const entry of config.overrides?.pinned ?? []) {
1414
+ const path = normalizeEntry(entry);
1415
+ const ignoredEntry = ignored.find((folder) => isUnderAnyFolder(path, [folder]));
1416
+ if (/[*?]/.test(entry)) warnings.push(`pin uses glob (not supported, use a path or folder): ${entry}`);
1417
+ else if (!existsSync5(join6(forkPath, path))) warnings.push(`pin not found: ${entry}`);
1418
+ else if (ignoredEntry) warnings.push(`pin does nothing, '${normalizeEntry(ignoredEntry)}' is ignored: ${entry}`);
1419
+ else if (!upstream) continue;
1420
+ else if (await fileExistsAtRef(forkPath, upstream, path)) pinsUpstreamHas.push(entry);
1421
+ else warnings.push(`pin does nothing, upstream no longer has it: ${entry}`);
1422
+ }
1423
+ if (upstream && pinsUpstreamHas.length > 0) {
1424
+ const differs = await listChangedSinceRef(forkPath, upstream, pinsUpstreamHas.map(normalizeEntry));
1425
+ for (const entry of pinsUpstreamHas) {
1426
+ if (differs.some((changed) => isUnderAnyFolder(changed, [entry]))) continue;
1427
+ warnings.push(`pin is identical to upstream, it only drops upstream's next change: ${entry}`);
1428
+ }
1429
+ }
1430
+ for (const entry of config.overrides?.ignored ?? []) {
1431
+ const path = normalizeEntry(entry);
1432
+ if (/[*?]/.test(entry)) {
1433
+ warnings.push(`ignored entry uses glob (not supported, use a path or folder): ${entry}`);
1434
+ } else if (!existsSync5(join6(forkPath, path))) {
1435
+ if (upstreamRef && await fileExistsAtRef(forkPath, upstreamRef, path)) continue;
1436
+ warnings.push(`ignored entry not found: ${entry}`);
1659
1437
  }
1660
1438
  }
1661
1439
  return warnings;
1662
1440
  }
1663
- function printWarnings(warnings) {
1664
- for (const warning of warnings) {
1665
- console.info(`${warningMark} ${warning.message}`);
1666
- }
1667
- }
1668
1441
 
1669
1442
  // src/cli.ts
1670
1443
  var str = (value) => typeof value === "string" ? value : void 0;
@@ -1680,74 +1453,44 @@ function readOptions(opts, operands = []) {
1680
1453
  throw new Error(`unexpected argument '${operands[0]}'. arguments after the options only go with --run <id>`);
1681
1454
  }
1682
1455
  return {
1683
- logFile: flag(opts.log),
1684
1456
  verbose: flag(opts.verbose),
1685
1457
  list: flag(opts.list),
1686
1458
  json: flag(opts.json),
1687
- diff: str(opts.diff),
1688
- openDiff: str(opts.openDiff),
1689
1459
  scope: normalizedScope,
1690
1460
  fork: str(opts.fork),
1691
- hard: flag(opts.hard),
1692
- unpinned: flag(opts.unpinned),
1693
1461
  track: opts.track === "release" || opts.track === "branch" ? opts.track : void 0,
1694
1462
  ref: str(opts.ref) || void 0,
1695
1463
  keepConfig: flag(opts.keepConfig),
1696
- force: flag(opts.force),
1697
- checkOverrides: flag(opts.checkOverrides),
1698
- coverage: flag(opts.coverage),
1464
+ unpinned: flag(opts.unpinned),
1699
1465
  since: str(opts.since) || void 0,
1700
1466
  md: flag(opts.md),
1701
1467
  all: flag(opts.all),
1702
1468
  show: str(opts.show),
1703
1469
  extract: str(opts.extract),
1704
1470
  run,
1705
- script: str(opts.script),
1706
1471
  runArgs: run ? operands : void 0,
1707
1472
  mark: Array.isArray(opts.mark) ? opts.mark.filter((id) => typeof id === "string") : void 0
1708
1473
  };
1709
1474
  }
1710
- var logOption = {
1711
- flags: "--log",
1712
- description: "write complete file list to cella-sync.log"
1713
- };
1714
- var jsonOption = {
1715
- flags: "--json",
1716
- description: "machine-readable output for tooling/agents"
1717
- };
1718
- var trackOption = {
1719
- flags: "--track <mode>",
1720
- description: "override upstream tracking for this run: release|branch"
1721
- };
1722
- var refOption = {
1723
- flags: "--ref <ref>",
1724
- description: "pin the upstream commit for this run: sha, release tag or branch"
1725
- };
1726
- var hardOption = {
1727
- flags: "--hard",
1728
- description: "overwrite drifted files with upstream version (aggressive realignment)"
1729
- };
1730
- var verboseOption = {
1731
- flags: "-V, --verbose",
1732
- description: "show detailed output during operations"
1733
- };
1734
- var forkOption = {
1735
- flags: "--fork <name>",
1736
- description: "pre-select a fork by name (skips the fork selection prompt)"
1737
- };
1475
+ var jsonOption = ["--json", "machine-readable output for tooling/agents"];
1476
+ var trackOption = ["--track <mode>", "override upstream tracking for this run: release|branch"];
1477
+ var refOption = ["--ref <ref>", "pin the upstream commit for this run: sha, release tag or branch"];
1478
+ var unpinnedOption = [
1479
+ "--unpinned",
1480
+ "lift every pinned entry for this run (managed files stay pinned) and merge from the full upstream history"
1481
+ ];
1482
+ var forkOption = ["--fork <name>", "pre-select a fork by name (skips the fork selection prompt)"];
1738
1483
  var serviceDefinitions = [
1739
1484
  {
1740
1485
  name: "analyze",
1741
1486
  description: "dry run to see what would change on sync",
1742
1487
  options: [
1743
- logOption,
1744
- { flags: "--list", description: "non-interactive output for tooling (one file per line)" },
1488
+ ["--list", "non-interactive output for tooling (one file per line)"],
1745
1489
  jsonOption,
1746
- { flags: "--scope <scope>", description: "analyze scope for --list/--json: all|risk|protected" },
1490
+ ["--scope <scope>", "analyze scope for --list/--json: all|risk|protected"],
1747
1491
  trackOption,
1748
1492
  refOption,
1749
- { flags: "--diff <path>", description: "print the unified diff for one file, then exit" },
1750
- { flags: "--open-diff <path>", description: "open a browser diff for one file, then exit" }
1493
+ unpinnedOption
1751
1494
  ],
1752
1495
  includeInMenu: (context) => !context.isUpstreamRepo
1753
1496
  },
@@ -1755,12 +1498,10 @@ var serviceDefinitions = [
1755
1498
  name: "sync",
1756
1499
  description: "merge upstream changes onto a fresh branch, sync package.json and open a squash-merge PR",
1757
1500
  options: [
1758
- logOption,
1759
- hardOption,
1760
- { flags: "--unpinned", description: "ignore pinned files (except package.json) to resurface upstream changes" },
1761
1501
  trackOption,
1762
1502
  refOption,
1763
- { flags: "--keep-config", description: "merge with your sync config as it stands when upstream changed its own" }
1503
+ ["--keep-config", "merge with your sync config as it stands when upstream changed its own"],
1504
+ unpinnedOption
1764
1505
  ],
1765
1506
  includeInMenu: (context) => !context.isUpstreamRepo
1766
1507
  },
@@ -1768,66 +1509,46 @@ var serviceDefinitions = [
1768
1509
  name: "migrate",
1769
1510
  description: "list the upstream migration notes this app has not handled yet",
1770
1511
  options: [
1771
- { flags: "--all", description: "list every upstream note, handled or not" },
1512
+ ["--all", "list every upstream note, handled or not"],
1772
1513
  jsonOption,
1773
- { flags: "--show <id>", description: "print one note's README" },
1774
- { flags: "--extract <id>", description: "write one note's folder under node_modules/.cache to run its codemod" },
1775
- { flags: "--run <id>", description: "run one note's codemod; files identical to upstream stay as they are" },
1776
- { flags: "--script <file>", description: "with --run: the script to run when the note folder holds several" },
1777
- { flags: "--mark <ids...>", description: "record notes as handled" }
1514
+ ["--show <id>", "print one note's README"],
1515
+ ["--extract <id>", "write one note's folder under node_modules/.cache to run its codemod"],
1516
+ ["--run <id>", "run one note's codemod; files identical to upstream stay as they are"],
1517
+ ["--mark <ids...>", "record notes as handled"]
1518
+ ],
1519
+ operands: [
1520
+ "[codemodArgs...]",
1521
+ "with --run, after `--`: arguments for the codemod (default: inventory and the note's roots)"
1778
1522
  ],
1779
- operands: {
1780
- flags: "[codemodArgs...]",
1781
- description: "with --run, after `--`: arguments for the codemod (default: inventory and the note's roots)"
1782
- },
1783
1523
  includeInMenu: (context) => !context.isUpstreamRepo
1784
1524
  },
1785
1525
  {
1786
1526
  name: "audit",
1787
- description: "check for outdated packages and vulnerabilities",
1788
- options: [
1789
- { flags: "--list", description: "skip interactive update prompts after printing audit results" },
1790
- { flags: "--force", description: "bypass pnpm metadata cache for fresh registry data" },
1791
- { flags: "--check-overrides", description: "check which pnpm.overrides are still needed" }
1792
- ]
1527
+ description: "update outdated packages, then rebuild the security overrides from a fresh audit",
1528
+ options: [["--list", "report only: no prompts, and nothing is written"]]
1793
1529
  },
1794
1530
  {
1795
1531
  name: "forks",
1796
1532
  description: "run normal sync inside local fork repositories",
1797
1533
  options: [
1798
1534
  forkOption,
1799
- { flags: "--log", description: "write complete file list to cella-sync.log for each synced fork" },
1800
- verboseOption,
1801
- hardOption,
1802
- {
1803
- flags: "--keep-config",
1804
- description: "merge with the fork's sync config as it stands when upstream changed its own"
1805
- }
1535
+ ["--keep-config", "merge with the fork's sync config as it stands when upstream changed its own"]
1806
1536
  ],
1807
1537
  includeInMenu: (context) => context.hasForks
1808
1538
  },
1809
1539
  {
1810
1540
  name: "contributions",
1811
1541
  description: "pull and adopt changes from local forks",
1812
- options: [
1813
- forkOption,
1814
- { flags: "--list", description: "non-interactive output for tooling (tab-separated rows)" },
1815
- jsonOption,
1816
- { flags: "--diff <path>", description: "print the unified diff for one contributed file, then exit" }
1817
- ],
1542
+ options: [forkOption, jsonOption],
1818
1543
  includeInMenu: (context) => context.hasForks
1819
1544
  },
1820
1545
  {
1821
1546
  name: "stats",
1822
1547
  description: "count files by category and workspace package",
1823
1548
  options: [
1824
- verboseOption,
1825
- { flags: "--coverage", description: "regenerate test coverage before showing the stats summary" },
1826
- {
1827
- flags: "--since <ref>",
1828
- description: "count what the current branch changed since it left <ref>, by kind of file"
1829
- },
1830
- { flags: "--md", description: "with --since: print the table as markdown for a pull request description" }
1549
+ ["-V, --verbose", "show detailed output during operations"],
1550
+ ["--since <ref>", "count what the current branch changed since it left <ref>, by kind of file"],
1551
+ ["--md", "with --since: print the table as markdown for a pull request description"]
1831
1552
  ]
1832
1553
  }
1833
1554
  ];
@@ -1851,10 +1572,8 @@ function buildServiceChoices(context) {
1851
1572
  }
1852
1573
  function addServiceCommand(program, definition, setSelection) {
1853
1574
  const command = program.command(definition.name).description(definition.description);
1854
- for (const option of definition.options ?? []) {
1855
- command.option(option.flags, option.description);
1856
- }
1857
- if (definition.operands) command.argument(definition.operands.flags, definition.operands.description);
1575
+ for (const option of definition.options ?? []) command.option(...option);
1576
+ if (definition.operands) command.argument(...definition.operands);
1858
1577
  command.action((...args) => {
1859
1578
  const operands = definition.operands ? args[0] : [];
1860
1579
  const opts = definition.operands ? args[1] : args[0];
@@ -1865,21 +1584,18 @@ function addServiceCommand(program, definition, setSelection) {
1865
1584
  });
1866
1585
  }
1867
1586
  function buildProgram(setSelection) {
1868
- const program = new Command(NAME).name("cella").version(VERSION, "-v, --version", "output the current version").usage("[service] [options]").helpOption("-h, --help", "display help for a command").showHelpAfterError().addHelpText(
1587
+ const program = new Command("cella").version(VERSION, "-v, --version", "output the current version").usage("[service] [options]").helpOption("-h, --help", "display help for a command").showHelpAfterError().addHelpText(
1869
1588
  "after",
1870
1589
  [
1871
1590
  "",
1872
1591
  "Examples:",
1873
1592
  " $ cella analyze",
1874
1593
  " $ cella analyze --json --scope risk",
1875
- " $ cella analyze --open-diff frontend/src/routes/index.tsx",
1876
- " $ cella sync --hard",
1877
- " $ cella sync --unpinned",
1878
1594
  " $ cella sync --track branch",
1879
1595
  " $ cella sync --ref 4f7d87c",
1880
1596
  " $ cella migrate --run 20261001T2116-tailwind-class-conventions -- rewrite frontend/src",
1881
1597
  " $ cella migrate --mark 20261002T0614-config-switch",
1882
- " $ cella audit --check-overrides",
1598
+ " $ cella audit --list",
1883
1599
  " $ cella stats --since origin/main --md",
1884
1600
  " $ cella contributions --fork raak --json"
1885
1601
  ].join("\n")
@@ -1914,274 +1630,110 @@ async function promptForService(userConfig, forkPath) {
1914
1630
  console.info();
1915
1631
  return selected;
1916
1632
  }
1917
- function buildRuntimeConfig(userConfig, forkPath, selection) {
1918
- const { branchRef } = resolveUpstream(userConfig.settings);
1919
- return {
1920
- ...userConfig,
1921
- forkPath,
1922
- upstreamRef: branchRef,
1923
- service: selection.service ?? "analyze",
1924
- ...selection.options
1925
- };
1633
+ function buildRuntimeConfig(userConfig, forkPath, selection, upstreamRef) {
1634
+ return { ...userConfig, forkPath, upstreamRef, service: selection.service ?? "analyze", ...selection.options };
1926
1635
  }
1927
1636
  async function parseCli(userConfig, forkPath) {
1928
1637
  const selection = parseCommandLine(process5.argv);
1929
1638
  const machineList = selection.options.list && selection.service !== "audit";
1930
- if (selection.options.json || selection.options.diff || selection.options.md || machineList) setJsonMode(true);
1639
+ if (selection.options.json || selection.options.md || machineList) setJsonMode(true);
1931
1640
  printHeader();
1932
- const warnings = await validateOverrides(userConfig, forkPath, resolveUpstream(userConfig.settings).branchRef);
1933
- if (warnings.length > 0) {
1934
- printWarnings(warnings);
1935
- console.info();
1936
- }
1641
+ const { branchRef } = resolveUpstream(userConfig.settings);
1642
+ const warnings = await validateOverrides(userConfig, forkPath, branchRef);
1643
+ for (const warning of warnings) console.info(`${warningMark} ${warning}`);
1644
+ if (warnings.length > 0) console.info();
1937
1645
  if (!selection.service) {
1938
1646
  selection.service = await promptForService(userConfig, forkPath);
1939
1647
  }
1940
- return buildRuntimeConfig(userConfig, forkPath, selection);
1648
+ return buildRuntimeConfig(userConfig, forkPath, selection, branchRef);
1941
1649
  }
1942
1650
 
1943
- // src/services/analyze.ts
1944
- import { basename as basename4 } from "path";
1651
+ // src/services/merge-engine.ts
1652
+ import { existsSync as existsSync6 } from "fs";
1653
+ import { rm as rm2 } from "fs/promises";
1654
+ import { join as join7 } from "path";
1945
1655
 
1946
- // src/utils/diff.ts
1947
- import { execFileSync } from "child_process";
1948
- import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
1949
- import { tmpdir } from "os";
1950
- import { basename as basename2, join as join8 } from "path";
1951
- import process6 from "process";
1952
- function gitDiffFile(cwd, range, filePath, options = {}) {
1953
- const args = ["diff", "--no-color"];
1954
- if (options.dstPrefix) args.push("--src-prefix=cella/", `--dst-prefix=${options.dstPrefix}/`);
1955
- args.push(range, "--", filePath);
1956
- try {
1957
- return execFileSync("git", args, { cwd, maxBuffer: MAX_BUFFER });
1958
- } catch (error) {
1959
- const stderr = error.stderr?.toString().trim();
1960
- throw new Error(stderr || `failed to diff ${filePath}`);
1961
- }
1656
+ // src/utils/versions.ts
1657
+ function parseComparableVersion(version) {
1658
+ const trimmed = version.trim();
1659
+ const match = trimmed.match(
1660
+ /^(?:\^|~|>=|<=|>|<|=)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
1661
+ );
1662
+ if (!match) return null;
1663
+ return {
1664
+ major: Number(match[1]),
1665
+ minor: Number(match[2] ?? 0),
1666
+ patch: Number(match[3] ?? 0)
1667
+ };
1962
1668
  }
1963
- function escapeHtml(text) {
1964
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1669
+ function compareVersions(left, right) {
1670
+ if (left.major !== right.major) return left.major - right.major;
1671
+ if (left.minor !== right.minor) return left.minor - right.minor;
1672
+ return left.patch - right.patch;
1965
1673
  }
1966
- async function renderDiffPage(patch, meta) {
1967
- const { preloadPatchDiff } = await import("@pierre/diffs/ssr");
1968
- const { prerenderedHTML } = await preloadPatchDiff({
1969
- patch,
1970
- options: {
1971
- diffStyle: "split",
1972
- themeType: "system",
1973
- lineDiffType: "word-alt",
1974
- hunkSeparators: "line-info",
1975
- overflow: "wrap",
1976
- disableFileHeader: true
1977
- }
1674
+ function isPrerelease(version) {
1675
+ return /^v?\d+(?:\.\d+){0,2}-/.test(version.trim());
1676
+ }
1677
+ function compareVersionStrings(left, right) {
1678
+ const a = parseComparableVersion(left);
1679
+ const b = parseComparableVersion(right);
1680
+ if (!a || !b) return Number(!!a) - Number(!!b);
1681
+ return compareVersions(a, b) || Number(isPrerelease(right)) - Number(isPrerelease(left));
1682
+ }
1683
+ function satisfiesComparators(version, range) {
1684
+ return range.split("||").some((alternative) => {
1685
+ const comparators = [...alternative.matchAll(/(>=|<=|>|<|=)?\s*v?(\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?)/g)];
1686
+ if (comparators.length === 0) return false;
1687
+ return comparators.every(([, operator = "=", bound]) => {
1688
+ const order = compareVersionStrings(version, bound);
1689
+ if (operator === ">=") return order >= 0;
1690
+ if (operator === "<=") return order <= 0;
1691
+ if (operator === ">") return order > 0;
1692
+ if (operator === "<") return order < 0;
1693
+ return order === 0;
1694
+ });
1978
1695
  });
1979
- const title = `${escapeHtml(meta.filePath)} \xB7 ${escapeHtml(meta.srcLabel)} vs ${escapeHtml(meta.dstLabel)}`;
1980
- const note = meta.note ? `<span class="note">\xB7 ${escapeHtml(meta.note)}</span>` : "";
1981
- return `<!doctype html>
1982
- <html lang="en">
1983
- <head>
1984
- <meta charset="utf-8">
1985
- <meta name="viewport" content="width=device-width, initial-scale=1">
1986
- <title>${title}</title>
1987
- <style>
1988
- :root { color-scheme: light dark; }
1989
- * { box-sizing: border-box; }
1990
- body {
1991
- margin: 0;
1992
- font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
1993
- background: light-dark(#fafafa, #111);
1994
- color: light-dark(#111, #eee);
1696
+ }
1697
+ function isBreakingJump(from, to) {
1698
+ const a = parseComparableVersion(from);
1699
+ const b = parseComparableVersion(to);
1700
+ if (!a || !b) return true;
1701
+ if (a.major !== b.major) return true;
1702
+ if (a.major > 0) return false;
1703
+ if (a.minor !== b.minor) return true;
1704
+ return a.minor === 0 && a.patch !== b.patch;
1705
+ }
1706
+
1707
+ // src/utils/cli-version.ts
1708
+ var CLI_PACKAGE = "@cellajs/cli";
1709
+ async function readUpstreamCliRange(repoPath, ref) {
1710
+ const source = await git(["show", `${ref}:package.json`], repoPath, { ignoreErrors: true });
1711
+ if (!source) return null;
1712
+ try {
1713
+ const pkg = JSON.parse(source);
1714
+ return pkg.devDependencies?.[CLI_PACKAGE] ?? pkg.dependencies?.[CLI_PACKAGE] ?? null;
1715
+ } catch {
1716
+ return null;
1995
1717
  }
1996
- header {
1997
- display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap;
1998
- padding: 0.9rem 1.25rem;
1999
- border-bottom: 1px solid light-dark(#e2e2e2, #2a2a2a);
2000
- position: sticky; top: 0;
2001
- background: inherit;
2002
- }
2003
- header .brand { font-weight: 600; color: light-dark(#0891b2, #22d3ee); }
2004
- header code { font-family: ui-monospace, monospace; font-size: 0.9rem; }
2005
- header .sides { margin-left: auto; font-size: 0.8rem; opacity: 0.7; }
2006
- main { max-width: 1400px; margin: 1rem auto; padding: 0 1rem 2rem; }
2007
- .diff-shell {
2008
- border: 1px solid light-dark(#e2e2e2, #2a2a2a);
2009
- border-radius: 8px; overflow: hidden;
2010
- background: light-dark(#fff, #161616);
2011
- }
2012
- </style>
2013
- </head>
2014
- <body>
2015
- <header>
2016
- <span class="brand">\u29C8 cella diff</span>
2017
- <code>${escapeHtml(meta.filePath)}</code>
2018
- <span class="sides">${escapeHtml(meta.srcLabel)} \u2190 \u2192 ${escapeHtml(meta.dstLabel)} ${note}</span>
2019
- </header>
2020
- <main>
2021
- <!-- Declarative shadow DOM: the library's CSS targets :host, which only
2022
- matches inside a shadow root (its browser component renders into one). -->
2023
- <div class="diff-shell">
2024
- <template shadowrootmode="open">
2025
- ${prerenderedHTML}
2026
- </template>
2027
- </div>
2028
- </main>
2029
- </body>
2030
- </html>`;
2031
- }
2032
- function openWithDefaultApp(target) {
2033
- try {
2034
- if (process6.platform === "win32") {
2035
- execFileSync("rundll32", ["url.dll,FileProtocolHandler", target], { stdio: "ignore" });
2036
- } else {
2037
- execFileSync(process6.platform === "darwin" ? "open" : "xdg-open", [target], { stdio: "ignore" });
2038
- }
2039
- } catch {
2040
- }
2041
- }
2042
- async function openDiffInBrowser(patch, meta, repoPath) {
2043
- const html = await renderDiffPage(patch, meta);
2044
- const dir = join8(tmpdir(), `cella-diff-${basename2(repoPath)}`);
2045
- mkdirSync(dir, { recursive: true });
2046
- const outPath = join8(dir, `${meta.filePath.replace(/[^\w.-]+/g, "-")}.html`);
2047
- writeFileSync2(outPath, html);
2048
- openWithDefaultApp(outPath);
2049
- return outPath;
2050
- }
2051
-
2052
- // src/services/merge-engine.ts
2053
- import { existsSync as existsSync7 } from "fs";
2054
- import { rm as rm2 } from "fs/promises";
2055
- import { join as join10 } from "path";
2056
-
2057
- // src/utils/cleanup.ts
2058
- import { createHash } from "crypto";
2059
- import { existsSync as existsSync6, rmSync } from "fs";
2060
- import { tmpdir as tmpdir2 } from "os";
2061
- import { basename as basename3, join as join9, resolve as resolve4 } from "path";
2062
- import process7 from "process";
2063
- var WORKTREE_PREFIXES = {
2064
- sync: "cella-sync-",
2065
- view: "cella-view-"
2066
- };
2067
- function buildWorktreePath(kind, repoPath) {
2068
- const key = createHash("sha256").update(resolve4(repoPath)).digest("hex").slice(0, 8);
2069
- return join9(tmpdir2(), `${WORKTREE_PREFIXES[kind]}${basename3(repoPath)}-${key}`);
2070
- }
2071
- function legacyWorktreePath(kind, repoPath) {
2072
- return join9(tmpdir2(), `${WORKTREE_PREFIXES[kind]}${basename3(repoPath)}`);
2073
- }
2074
- function getWorktreePath(repoPath) {
2075
- return buildWorktreePath("sync", repoPath);
2076
- }
2077
- var cleanupRegistered = false;
2078
- var currentWorktreePath = null;
2079
- var currentRepoPath = null;
2080
- function registerWorktree(repoPath, worktreePath) {
2081
- currentRepoPath = repoPath;
2082
- currentWorktreePath = worktreePath;
2083
- }
2084
- function unregisterWorktree() {
2085
- currentRepoPath = null;
2086
- currentWorktreePath = null;
2087
- }
2088
- async function cleanupWorktree(repoPath, worktreePath) {
2089
- await removeWorktree(repoPath, worktreePath);
2090
- if (existsSync6(worktreePath)) {
2091
- rmSync(worktreePath, { recursive: true, force: true });
2092
- }
2093
- unregisterWorktree();
2094
- }
2095
- async function cleanupLeftoverWorktrees(repoPath) {
2096
- for (const kind of Object.keys(WORKTREE_PREFIXES)) {
2097
- for (const worktreePath of [buildWorktreePath(kind, repoPath), legacyWorktreePath(kind, repoPath)]) {
2098
- if (existsSync6(worktreePath)) await cleanupWorktree(repoPath, worktreePath);
2099
- }
2100
- }
2101
- const prefixes = Object.values(WORKTREE_PREFIXES);
2102
- const worktrees = await listWorktrees(repoPath);
2103
- for (const wt of worktrees) {
2104
- if (prefixes.some((prefix) => wt.includes(prefix)) && !existsSync6(wt)) {
2105
- await removeWorktree(repoPath, wt);
2106
- }
2107
- }
2108
- }
2109
- async function handleAbort(signal) {
2110
- console.info();
2111
- console.info(`${warningMark} interrupted (${signal}): cleaning up...`);
2112
- if (currentRepoPath && currentWorktreePath) {
2113
- try {
2114
- await mergeAbort(currentWorktreePath);
2115
- } catch {
2116
- }
2117
- try {
2118
- await cleanupWorktree(currentRepoPath, currentWorktreePath);
2119
- console.info(`${checkMark} no changes were made to your repository.`);
2120
- } catch (error) {
2121
- console.error(`${colors_default.red("\u2717")} failed to clean up worktree: ${errorMessage(error)}`);
2122
- }
2123
- }
2124
- process7.exit(1);
2125
- }
2126
- function registerSignalHandlers() {
2127
- if (cleanupRegistered) return;
2128
- process7.on("SIGINT", () => handleAbort("SIGINT"));
2129
- process7.on("SIGTERM", () => handleAbort("SIGTERM"));
2130
- cleanupRegistered = true;
2131
- }
2132
-
2133
- // src/utils/versions.ts
2134
- function parseComparableVersion(version) {
2135
- const trimmed = version.trim();
2136
- if (trimmed === "" || trimmed === "*" || trimmed.includes("workspace:") || trimmed.includes("catalog:") || trimmed.includes("file:") || trimmed.includes("link:") || trimmed.includes("git+") || trimmed.includes("github:") || trimmed.includes("http://") || trimmed.includes("https://") || trimmed.includes("||")) {
2137
- return null;
2138
- }
2139
- const match = trimmed.match(
2140
- /^(?:\^|~|>=|<=|>|<|=)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
2141
- );
2142
- if (!match) return null;
2143
- return {
2144
- major: Number(match[1]),
2145
- minor: Number(match[2] ?? 0),
2146
- patch: Number(match[3] ?? 0)
2147
- };
2148
- }
2149
- function compareVersions(left, right) {
2150
- if (left.major !== right.major) return left.major - right.major;
2151
- if (left.minor !== right.minor) return left.minor - right.minor;
2152
- return left.patch - right.patch;
2153
- }
2154
-
2155
- // src/utils/cli-version.ts
2156
- var CLI_PACKAGE = "@cellajs/cli";
2157
- async function readUpstreamCliRange(repoPath, ref) {
2158
- const source = await git(["show", `${ref}:package.json`], repoPath, { ignoreErrors: true });
2159
- if (!source) return null;
2160
- try {
2161
- const pkg = JSON.parse(source);
2162
- return pkg.devDependencies?.[CLI_PACKAGE] ?? pkg.dependencies?.[CLI_PACKAGE] ?? null;
2163
- } catch {
2164
- return null;
2165
- }
2166
- }
2167
- function cliVersionMismatch(running, range) {
2168
- if (/^\s*(<|>(?!=))/.test(range)) return null;
2169
- const required = parseComparableVersion(range);
2170
- const current = parseComparableVersion(running);
2171
- if (!required || !current || compareVersions(current, required) >= 0) return null;
2172
- return [
2173
- `upstream needs ${CLI_PACKAGE} ${range}, this is ${running}: an older CLI misses fixes to the sync itself.`,
2174
- ` update it, commit, then rerun: pnpm add -D -w '${CLI_PACKAGE}@${range}'`
2175
- ].join("\n");
2176
- }
2177
-
2178
- // src/utils/upstream-overrides.ts
2179
- import { Node, Project as Project2, SyntaxKind as SyntaxKind2 } from "ts-morph";
2180
- var LIST_KEYS = ["pinned", "ignored"];
2181
- function resolveExpression(node, sourceFile, depth = 0) {
2182
- if (!node || depth > 8) return node;
2183
- if (Node.isParenthesizedExpression(node) || Node.isAsExpression(node) || Node.isSatisfiesExpression(node)) {
2184
- return resolveExpression(node.getExpression(), sourceFile, depth + 1);
1718
+ }
1719
+ function cliVersionMismatch(running, range) {
1720
+ if (/^\s*(<|>(?!=))/.test(range)) return null;
1721
+ const required = parseComparableVersion(range);
1722
+ const current = parseComparableVersion(running);
1723
+ if (!required || !current || compareVersions(current, required) >= 0) return null;
1724
+ return [
1725
+ `upstream needs ${CLI_PACKAGE} ${range}, this is ${running}: an older CLI misses fixes to the sync itself.`,
1726
+ ` update it, commit, then rerun: pnpm add -D -w '${CLI_PACKAGE}@${range}'`
1727
+ ].join("\n");
1728
+ }
1729
+
1730
+ // src/utils/upstream-overrides.ts
1731
+ import { Node, Project as Project2, SyntaxKind as SyntaxKind2 } from "ts-morph";
1732
+ var LIST_KEYS = ["pinned", "ignored"];
1733
+ function resolveExpression(node, sourceFile, depth = 0) {
1734
+ if (!node || depth > 8) return node;
1735
+ if (Node.isParenthesizedExpression(node) || Node.isAsExpression(node) || Node.isSatisfiesExpression(node)) {
1736
+ return resolveExpression(node.getExpression(), sourceFile, depth + 1);
2185
1737
  }
2186
1738
  if (Node.isIdentifier(node)) {
2187
1739
  return resolveExpression(
@@ -2258,9 +1810,6 @@ function parseOverrideLists(source) {
2258
1810
  return null;
2259
1811
  }
2260
1812
  }
2261
- function normalizeEntry(entry) {
2262
- return entry.trim().replace(/^\.\//, "").replace(/\/+$/, "");
2263
- }
2264
1813
  function diffOverrideLists(base, incoming, fork) {
2265
1814
  const diff = (key) => {
2266
1815
  const baseSet = new Set(base[key].map(normalizeEntry).filter(Boolean));
@@ -2281,22 +1830,14 @@ function diffOverrideLists(base, incoming, fork) {
2281
1830
  };
2282
1831
  return { pinned: diff("pinned"), ignored: diff("ignored"), packageJsonSync: diffKeys() };
2283
1832
  }
2284
- async function readConfigSourceAtRef(repoPath, ref) {
2285
- for (const path of CONFIG_FILE_PATHS) {
2286
- const source = await git(["show", `${ref}:${path}`], repoPath, { ignoreErrors: true });
2287
- if (source) return source;
2288
- }
2289
- return null;
2290
- }
2291
1833
  async function compareUpstreamOverrides(repoPath, mergeBaseRef, incomingRef, forkConfig) {
2292
- const [baseSource, incomingSource] = await Promise.all([
2293
- readConfigSourceAtRef(repoPath, mergeBaseRef),
2294
- readConfigSourceAtRef(repoPath, incomingRef)
2295
- ]);
1834
+ const [baseSource, incomingSource] = await Promise.all(
1835
+ [mergeBaseRef, incomingRef].map((ref) => git(["show", `${ref}:${CONFIG_FILE}`], repoPath, { ignoreErrors: true }))
1836
+ );
2296
1837
  if (baseSource === incomingSource) return void 0;
2297
- const base = baseSource === null ? null : parseOverrideLists(baseSource);
1838
+ const base = baseSource ? parseOverrideLists(baseSource) : null;
2298
1839
  if (!base) return { kind: "unreadable", side: "base" };
2299
- const incoming = incomingSource === null ? null : parseOverrideLists(incomingSource);
1840
+ const incoming = incomingSource ? parseOverrideLists(incomingSource) : null;
2300
1841
  if (!incoming) return { kind: "unreadable", side: "incoming" };
2301
1842
  const fork = {
2302
1843
  pinned: forkConfig.overrides?.pinned ?? [],
@@ -2304,8 +1845,8 @@ async function compareUpstreamOverrides(repoPath, mergeBaseRef, incomingRef, for
2304
1845
  packageJsonSync: forkConfig.settings.packageJsonSync ?? DEFAULT_PACKAGE_JSON_SYNC
2305
1846
  };
2306
1847
  const changes = diffOverrideLists(base, incoming, fork);
2307
- const count = Object.values(changes).reduce((n, list) => n + list.added.length + list.removed.length, 0);
2308
- return count > 0 ? { kind: "changes", ...changes } : void 0;
1848
+ const count2 = Object.values(changes).reduce((n, list) => n + list.added.length + list.removed.length, 0);
1849
+ return count2 > 0 ? { kind: "changes", ...changes } : void 0;
2309
1850
  }
2310
1851
 
2311
1852
  // src/services/analyze-core.ts
@@ -2346,15 +1887,17 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
2346
1887
  const upstreamChange = upstreamChanges.get(filePath);
2347
1888
  const isUpstreamRename = upstreamChange?.status === "R" && upstreamChange.oldPath;
2348
1889
  const renamedToPath = upstreamRenames.get(filePath);
1890
+ const push = (status2, extra = {}) => analyzedFiles.push({
1891
+ path: filePath,
1892
+ status: status2,
1893
+ isIgnored: fileIsIgnored,
1894
+ isPinned: fileIsPinned,
1895
+ existsInFork: inFork,
1896
+ existsInUpstream: inUpstream,
1897
+ ...extra
1898
+ });
2349
1899
  if (!changedFiles.has(filePath) && inFork && inUpstream) {
2350
- analyzedFiles.push({
2351
- path: filePath,
2352
- status: "identical",
2353
- isIgnored: fileIsIgnored,
2354
- isPinned: fileIsPinned,
2355
- existsInFork: true,
2356
- existsInUpstream: true
2357
- });
1900
+ push("identical");
2358
1901
  continue;
2359
1902
  }
2360
1903
  processed++;
@@ -2391,17 +1934,7 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
2391
1934
  renamedFrom = oldPath;
2392
1935
  }
2393
1936
  const upstreamOnly2 = fileIsIgnored && !forkModifiedOld && isReportableIgnored(filePath);
2394
- analyzedFiles.push({
2395
- path: filePath,
2396
- status,
2397
- isIgnored: fileIsIgnored,
2398
- isPinned: fileIsPinned,
2399
- existsInFork: inFork,
2400
- existsInUpstream: true,
2401
- renamedFrom,
2402
- upstreamChanged: upstreamChanged2,
2403
- upstreamOnly: upstreamOnly2 || void 0
2404
- });
1937
+ push(status, { existsInUpstream: true, renamedFrom, upstreamChanged: upstreamChanged2, upstreamOnly: upstreamOnly2 || void 0 });
2405
1938
  continue;
2406
1939
  }
2407
1940
  if (renamedToPath) {
@@ -2427,23 +1960,14 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
2427
1960
  if (forkChanged && upstreamChanged2) {
2428
1961
  status = fileIsPinned ? "pinned" : "diverged";
2429
1962
  } else if (forkChanged && !upstreamChanged2) {
2430
- status = fileIsPinned ? "ahead" : predicates.hard ? "behind" : "drifted";
1963
+ status = fileIsPinned ? "ahead" : "drifted";
2431
1964
  } else {
2432
1965
  status = "behind";
2433
1966
  }
2434
1967
  }
2435
1968
  const upstreamChanged = (fileIsPinned || fileIsIgnored) && inUpstream && upstreamHash !== baseHash && forkHash !== baseHash;
2436
1969
  const upstreamOnly = fileIsIgnored && isReportableIgnored(filePath) && upstreamHash !== baseHash && forkHash === baseHash;
2437
- analyzedFiles.push({
2438
- path: filePath,
2439
- status,
2440
- isIgnored: fileIsIgnored,
2441
- isPinned: fileIsPinned,
2442
- existsInFork: inFork,
2443
- existsInUpstream: inUpstream,
2444
- upstreamChanged: upstreamChanged || void 0,
2445
- upstreamOnly: upstreamOnly || void 0
2446
- });
1970
+ push(status, { upstreamChanged: upstreamChanged || void 0, upstreamOnly: upstreamOnly || void 0 });
2447
1971
  }
2448
1972
  const flagged = analyzedFiles.filter((file) => file.upstreamChanged && !renamedProtected.has(file.path));
2449
1973
  if (flagged.length > 0) {
@@ -2480,34 +2004,25 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
2480
2004
  async function enrichChangeInfo(repoPath, files, mergeBaseRef, localRef, incomingRef) {
2481
2005
  const forkInfo = await getFileChangeInfo(repoPath, mergeBaseRef, localRef);
2482
2006
  const upstreamInfo = await getFileChangeInfo(repoPath, mergeBaseRef, incomingRef);
2007
+ const setChanged = (file, info) => {
2008
+ if (!info) return;
2009
+ file.changedAt = info.date;
2010
+ file.changedTs = info.timestamp;
2011
+ file.changedCommit = info.hash;
2012
+ };
2483
2013
  for (const file of files) {
2484
2014
  if (file.status === "identical") continue;
2485
2015
  if (file.status === "ahead" || file.status === "drifted") {
2486
- const info = forkInfo.get(file.path);
2487
- if (info) {
2488
- file.changedAt = info.date;
2489
- file.changedTs = info.timestamp;
2490
- file.changedCommit = info.hash;
2491
- }
2016
+ setChanged(file, forkInfo.get(file.path));
2492
2017
  } else if (file.status === "behind") {
2018
+ setChanged(file, upstreamInfo.get(file.path));
2019
+ } else if (file.status === "diverged" || file.status === "pinned" || file.upstreamChanged) {
2020
+ setChanged(file, forkInfo.get(file.path));
2493
2021
  const info = upstreamInfo.get(file.path);
2494
2022
  if (info) {
2495
- file.changedAt = info.date;
2496
- file.changedTs = info.timestamp;
2497
- file.changedCommit = info.hash;
2498
- }
2499
- } else if (file.status === "diverged" || file.status === "pinned" || file.upstreamChanged) {
2500
- const forkFileInfo = forkInfo.get(file.path);
2501
- const upstreamFileInfo = upstreamInfo.get(file.path);
2502
- if (forkFileInfo) {
2503
- file.changedAt = forkFileInfo.date;
2504
- file.changedTs = forkFileInfo.timestamp;
2505
- file.changedCommit = forkFileInfo.hash;
2506
- }
2507
- if (upstreamFileInfo) {
2508
- file.upstreamChangedAt = upstreamFileInfo.date;
2509
- file.upstreamChangedTs = upstreamFileInfo.timestamp;
2510
- file.upstreamCommit = upstreamFileInfo.hash;
2023
+ file.upstreamChangedAt = info.date;
2024
+ file.upstreamChangedTs = info.timestamp;
2025
+ file.upstreamCommit = info.hash;
2511
2026
  }
2512
2027
  }
2513
2028
  }
@@ -2530,15 +2045,8 @@ var UpstreamConfigChangedError = class extends Error {
2530
2045
  }
2531
2046
  };
2532
2047
  function getGitHubBaseUrl(remoteUrl) {
2533
- const sshMatch = remoteUrl.match(/git@github\.com:([^/]+)\/([^.]+)(?:\.git)?$/);
2534
- if (sshMatch) {
2535
- return `https://github.com/${sshMatch[1]}/${sshMatch[2]}`;
2536
- }
2537
- const httpsMatch = remoteUrl.match(/https:\/\/github\.com\/([^/]+)\/([^/.]+)(?:\.git)?$/);
2538
- if (httpsMatch) {
2539
- return `https://github.com/${httpsMatch[1]}/${httpsMatch[2]}`;
2540
- }
2541
- return null;
2048
+ const match = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
2049
+ return match ? `https://github.com/${match[1]}/${match[2]}` : null;
2542
2050
  }
2543
2051
  function isProtectedFile(file) {
2544
2052
  return file.isIgnored || file.isPinned;
@@ -2546,8 +2054,7 @@ function isProtectedFile(file) {
2546
2054
  function syncPredicates(config) {
2547
2055
  return {
2548
2056
  isIgnored: (path) => isIgnored(path, config),
2549
- isPinned: (path) => isPinnedForSync(path, config, config.unpinned),
2550
- hard: config.hard
2057
+ isPinned: (path) => config.unpinned ? isManagedFile(path) : isPinned(path, config)
2551
2058
  };
2552
2059
  }
2553
2060
  async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onProgress) {
@@ -2563,38 +2070,27 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
2563
2070
  const batchRestorePaths = [];
2564
2071
  const batchRemovePaths = [];
2565
2072
  for (const file of analyzedFiles) {
2566
- const { path: filePath, status, isPinned: pinned, isIgnored: ignored, existsInFork } = file;
2567
- if (status === "identical" || status === "ahead") continue;
2568
- if (!isProtectedFile(file)) continue;
2569
- if (ignored && existsInFork) {
2570
- batchRestorePaths.push(filePath);
2571
- } else if (ignored && !existsInFork) {
2572
- batchRemovePaths.push(filePath);
2573
- } else if (pinned && !file.renamedFrom && existsInFork) {
2574
- batchRestorePaths.push(filePath);
2575
- } else if (pinned && !file.renamedFrom && !existsInFork) {
2576
- batchRemovePaths.push(filePath);
2073
+ if (file.status === "identical" || file.status === "ahead") continue;
2074
+ if (file.isIgnored || file.isPinned && !file.renamedFrom) {
2075
+ (file.existsInFork ? batchRestorePaths : batchRemovePaths).push(file.path);
2577
2076
  }
2578
2077
  }
2579
2078
  onProgress?.("starting merge in fork...");
2580
2079
  await withTemporarySyncBaseGraft(forkPath, "HEAD", mergeBaseRef, () => merge(forkPath, upstreamRef));
2581
2080
  if (batchRestorePaths.length > 0) {
2582
2081
  onProgress?.(`batch restoring ${batchRestorePaths.length} pinned/ignored files...`);
2583
- await batchRestoreToHead(forkPath, batchRestorePaths);
2082
+ await checkoutFromRef(forkPath, "HEAD", batchRestorePaths);
2584
2083
  }
2585
2084
  if (batchRemovePaths.length > 0) {
2586
2085
  onProgress?.(`batch removing ${batchRemovePaths.length} ignored files...`);
2587
- await batchGitRm(forkPath, batchRemovePaths);
2086
+ await gitRm(forkPath, batchRemovePaths);
2588
2087
  for (const filePath of batchRemovePaths) {
2589
2088
  await removeFileFromWorktree(forkPath, filePath);
2590
2089
  }
2591
2090
  }
2592
2091
  for (const file of analyzedFiles) {
2593
2092
  const { path: filePath, status, existsInFork } = file;
2594
- if (status === "identical" || status === "ahead") {
2595
- continue;
2596
- }
2597
- if (isProtectedFile(file)) continue;
2093
+ if (status === "identical" || status === "ahead" || isProtectedFile(file)) continue;
2598
2094
  if (status === "diverged") {
2599
2095
  onProgress?.(`\u2192 ${filePath}: using git merge result (diverged)`);
2600
2096
  continue;
@@ -2602,13 +2098,13 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
2602
2098
  if (status === "behind") {
2603
2099
  if (!existsInFork) {
2604
2100
  onProgress?.(`\u2192 ${filePath}: adding from upstream (new file)`);
2605
- await checkoutFromRef(forkPath, upstreamRef, filePath);
2101
+ await checkoutFromRef(forkPath, upstreamRef, [filePath]);
2606
2102
  } else if (!file.existsInUpstream) {
2607
2103
  onProgress?.(`\u2192 ${filePath}: removing (deleted in upstream)`);
2608
2104
  await removeFileFully(forkPath, filePath);
2609
2105
  } else {
2610
2106
  onProgress?.(`\u2192 ${filePath}: accepting upstream (behind)`);
2611
- await checkoutFromRef(forkPath, upstreamRef, filePath);
2107
+ await checkoutFromRef(forkPath, upstreamRef, [filePath]);
2612
2108
  }
2613
2109
  continue;
2614
2110
  }
@@ -2626,31 +2122,25 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
2626
2122
  await gitMv(forkPath, oldPath, filePath);
2627
2123
  } catch {
2628
2124
  await removeFileFully(forkPath, oldPath);
2629
- await checkoutFromRef(forkPath, upstreamRef, filePath);
2125
+ await checkoutFromRef(forkPath, upstreamRef, [filePath]);
2630
2126
  }
2631
2127
  } else if (oldPathExists && newPathExists) {
2632
2128
  onProgress?.(`\u2192 ${oldPath} \u2192 ${filePath}: completing rename (removing old)`);
2633
2129
  await removeFileFully(forkPath, oldPath);
2634
- await checkoutFromRef(forkPath, upstreamRef, filePath);
2130
+ await checkoutFromRef(forkPath, upstreamRef, [filePath]);
2635
2131
  } else {
2636
2132
  onProgress?.(`\u2192 ${filePath}: updating from upstream (renamed)`);
2637
- await checkoutFromRef(forkPath, upstreamRef, filePath);
2133
+ await checkoutFromRef(forkPath, upstreamRef, [filePath]);
2638
2134
  }
2639
2135
  }
2640
2136
  }
2641
- const protectedConflicts = analyzedFiles.filter((file) => file.upstreamChanged).map((file) => file.path);
2642
- const protectedConflictSet = new Set(protectedConflicts);
2643
- const gitConflicts = await getConflictedFiles(forkPath);
2644
- for (const filePath of gitConflicts) {
2645
- const isProtected = isIgnored(filePath, config) || isPinnedForSync(filePath, config, config.unpinned);
2646
- if (!isProtected) continue;
2137
+ const protectedConflicts = new Set(analyzedFiles.filter((file) => file.upstreamChanged).map((file) => file.path));
2138
+ for (const filePath of await getConflictedFiles(forkPath)) {
2139
+ if (!isIgnored(filePath, config) && !syncPredicates(config).isPinned(filePath)) continue;
2647
2140
  if (await fileExistsAtRef(forkPath, "HEAD", filePath)) {
2648
2141
  onProgress?.(`\u2192 ${filePath}: keeping fork (protected conflict)`);
2649
- await restoreToHead(forkPath, filePath);
2650
- if (!protectedConflictSet.has(filePath)) {
2651
- protectedConflictSet.add(filePath);
2652
- protectedConflicts.push(filePath);
2653
- }
2142
+ await checkoutFromRef(forkPath, "HEAD", [filePath]);
2143
+ protectedConflicts.add(filePath);
2654
2144
  } else {
2655
2145
  onProgress?.(`\u2192 ${filePath}: removing (protected conflict, not in fork)`);
2656
2146
  await removeFileFully(forkPath, filePath);
@@ -2663,12 +2153,12 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
2663
2153
  const staleIgnoredFiles = stagedNewFiles.filter((f) => isIgnored(f, config));
2664
2154
  if (staleIgnoredFiles.length > 0) {
2665
2155
  onProgress?.(`cleaning up ${staleIgnoredFiles.length} ignored files still in index...`);
2666
- await batchUnstageFiles(forkPath, staleIgnoredFiles);
2156
+ await gitRm(forkPath, staleIgnoredFiles, { cached: true });
2667
2157
  for (const filePath of staleIgnoredFiles) {
2668
2158
  await removeFileFromWorktree(forkPath, filePath);
2669
2159
  }
2670
2160
  }
2671
- return { remainingConflicts, analyzedFiles, autoMergedFiles, protectedConflicts };
2161
+ return { remainingConflicts, analyzedFiles, autoMergedFiles, protectedConflicts: [...protectedConflicts] };
2672
2162
  }
2673
2163
  function calculateSummary(files) {
2674
2164
  const summary = {
@@ -2756,14 +2246,13 @@ async function prepareUpstream(config, onProgress, onStep) {
2756
2246
  onProgress?.("checking sync base...");
2757
2247
  await ensureSyncBase(forkPath, "HEAD", upstreamRef);
2758
2248
  const upstreamGitHubUrl = getGitHubBaseUrl(config.settings.upstreamUrl) ?? void 0;
2759
- const aggressive = config.hard === true || config.unpinned === true;
2760
- const syncPoint = (await getEffectiveMergeBase(forkPath, "HEAD", upstreamRef)).base;
2761
- const mergeBase = aggressive ? await getMergeBase(forkPath, "HEAD", upstreamRef) : syncPoint;
2249
+ const mergeBase = (await getEffectiveMergeBase(forkPath, "HEAD", upstreamRef)).base;
2250
+ const mergeRef = config.unpinned ? await getMergeBase(forkPath, "HEAD", upstreamRef) : mergeBase;
2762
2251
  const upstreamCommit = await getCommitInfo(forkPath, upstreamRef);
2763
- if (upstreamCommit.hash !== syncPoint && await isAncestor(forkPath, upstreamCommit.hash, syncPoint)) {
2252
+ if (upstreamCommit.hash !== mergeBase && await isAncestor(forkPath, upstreamCommit.hash, mergeBase)) {
2764
2253
  const [upstreamShort, syncShort] = await Promise.all([
2765
2254
  getShortSha(forkPath, upstreamCommit.hash),
2766
- getShortSha(forkPath, syncPoint)
2255
+ getShortSha(forkPath, mergeBase)
2767
2256
  ]);
2768
2257
  const hint = config.ref ? "Pick a newer --ref." : releaseTag ? `Nothing to sync until a release past ${syncShort}; to follow the tip instead, run with --track branch.` : `Nothing to sync until '${branchRef}' moves past ${syncShort}.`;
2769
2258
  throw new BehindSyncPointError(
@@ -2772,14 +2261,13 @@ ${hint}`
2772
2261
  );
2773
2262
  }
2774
2263
  const commitCount = await countCommitsBetween(forkPath, mergeBase, upstreamRef);
2775
- const commitSkip = commitCount > COMMIT_LIST_MAX ? commitCount - COMMIT_LIST_MAX : 0;
2776
- const upstreamCommits = commitCount > 0 ? await listCommitsBetween(forkPath, mergeBase, upstreamRef, { skip: commitSkip, limit: COMMIT_LIST_MAX }) : [];
2264
+ const upstreamCommits = commitCount > 0 ? await listCommitsBetween(forkPath, mergeBase, upstreamRef, COMMIT_LIST_MAX) : [];
2777
2265
  onStep?.("fetched upstream", formatFetchedUpstreamDetail(commitCount, upstreamCommits, upstreamGitHubUrl));
2778
- return { upstreamRef, releaseTag, mergeBase, syncPoint, upstreamGitHubUrl, upstreamCommit, upstreamCommits };
2266
+ return { upstreamRef, releaseTag, mergeBase, mergeRef, upstreamGitHubUrl, upstreamCommit };
2779
2267
  }
2780
2268
  async function assertSyncConfigFollowed(config, ctx) {
2781
2269
  if (config.keepConfig) return;
2782
- const report = await compareUpstreamOverrides(config.forkPath, ctx.syncPoint, ctx.upstreamRef, config).catch(
2270
+ const report = await compareUpstreamOverrides(config.forkPath, ctx.mergeBase, ctx.upstreamRef, config).catch(
2783
2271
  () => void 0
2784
2272
  );
2785
2273
  if (report?.kind === "changes") throw new UpstreamConfigChangedError(report);
@@ -2790,8 +2278,7 @@ function resultMeta(config, ctx) {
2790
2278
  upstreamRef: ctx.upstreamRef,
2791
2279
  upstreamTag: ctx.releaseTag,
2792
2280
  upstreamGitHubUrl: ctx.upstreamGitHubUrl,
2793
- upstreamCommit: ctx.upstreamCommit,
2794
- upstreamCommits: ctx.upstreamCommits
2281
+ upstreamCommit: ctx.upstreamCommit
2795
2282
  };
2796
2283
  }
2797
2284
  async function upstreamOnlyReports(config, ctx, files, protectedConflicts) {
@@ -2818,8 +2305,8 @@ async function readNotesInRange(config, ctx) {
2818
2305
  try {
2819
2306
  const [total, arrived, pending] = await Promise.all([
2820
2307
  listNoteIds(forkPath, ctx.upstreamRef),
2821
- arrivedNoteIds(forkPath, ctx.syncPoint, ctx.upstreamRef),
2822
- readPending(forkPath, ctx.syncPoint)
2308
+ arrivedNoteIds(forkPath, ctx.mergeBase, ctx.upstreamRef),
2309
+ readPending(forkPath)
2823
2310
  ]);
2824
2311
  return { total: total.length, arrived, pending: [.../* @__PURE__ */ new Set([...pending, ...arrived])] };
2825
2312
  } catch {
@@ -2830,20 +2317,20 @@ function notesResult(notes) {
2830
2317
  return notes ? { migrationNotes: { total: notes.total, arrived: notes.arrived, open: notes.pending.length } } : {};
2831
2318
  }
2832
2319
  async function removeUpstreamOnlyCopy(forkPath, onStep) {
2833
- if (!existsSync7(join10(forkPath, NOTES_DIR))) return;
2320
+ if (!existsSync6(join7(forkPath, NOTES_DIR))) return;
2834
2321
  await git(["rm", "-r", "-q", "--ignore-unmatch", "--", NOTES_DIR], forkPath);
2835
- await rm2(join10(forkPath, NOTES_DIR), { recursive: true, force: true });
2322
+ await rm2(join7(forkPath, NOTES_DIR), { recursive: true, force: true });
2836
2323
  onStep?.("migration notes", `removed ${NOTES_DIR}/: notes stay upstream, list them with pnpm cella migrate`);
2837
2324
  }
2838
2325
  async function runSyncMerge(config, ctx, onProgress, onStep) {
2839
2326
  const { forkPath } = config;
2840
- const { upstreamRef, releaseTag, mergeBase, upstreamGitHubUrl, upstreamCommit } = ctx;
2327
+ const { upstreamRef, releaseTag, mergeRef, upstreamGitHubUrl, upstreamCommit } = ctx;
2841
2328
  await assertSyncConfigFollowed(config, ctx);
2842
2329
  const notes = await readNotesInRange(config, ctx);
2843
2330
  const { remainingConflicts, analyzedFiles, autoMergedFiles, protectedConflicts } = await applyDirectMerge(
2844
2331
  forkPath,
2845
2332
  upstreamRef,
2846
- mergeBase,
2333
+ mergeRef,
2847
2334
  config,
2848
2335
  onProgress
2849
2336
  );
@@ -2898,20 +2385,13 @@ async function runSyncMerge(config, ctx, onProgress, onStep) {
2898
2385
  }
2899
2386
  async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2900
2387
  const { forkPath } = config;
2901
- const worktreePath = getWorktreePath(forkPath);
2902
2388
  const notes = await readNotesInRange(config, ctx);
2903
- onProgress?.("creating worktree in temp directory...");
2904
- await createWorktree(forkPath, worktreePath, "HEAD");
2905
- onStep?.("worktree created", worktreePath);
2906
- onProgress?.("performing merge in worktree...");
2907
- await withTemporarySyncBaseGraft(worktreePath, "HEAD", ctx.mergeBase, () => merge(worktreePath, ctx.upstreamRef));
2908
- onStep?.("merge complete", "upstream merged into worktree");
2909
2389
  onProgress?.("analyzing files...");
2910
2390
  const analyzedFiles = await analyzeRefs(
2911
2391
  forkPath,
2912
2392
  "HEAD",
2913
2393
  ctx.upstreamRef,
2914
- ctx.mergeBase,
2394
+ ctx.mergeRef,
2915
2395
  syncPredicates(config),
2916
2396
  onProgress
2917
2397
  );
@@ -2919,8 +2399,6 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2919
2399
  const protectedConflicts = analyzedFiles.filter((f) => f.upstreamChanged).map((f) => f.path);
2920
2400
  const reports = await upstreamOnlyReports(config, ctx, analyzedFiles, protectedConflicts);
2921
2401
  onStep?.("analysis complete", `${analyzedFiles.length} files analyzed, dry run, no changes applied`);
2922
- onProgress?.("cleaning up worktree...");
2923
- await cleanupWorktree(forkPath, worktreePath);
2924
2402
  return {
2925
2403
  success: true,
2926
2404
  files: analyzedFiles,
@@ -2933,42 +2411,25 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2933
2411
  ...notesResult(notes)
2934
2412
  };
2935
2413
  }
2936
- async function runMergeEngine(config, options) {
2937
- const { forkPath } = config;
2938
- const worktreePath = getWorktreePath(forkPath);
2939
- const { apply, onProgress, onStep } = options;
2940
- await cleanupLeftoverWorktrees(forkPath);
2941
- registerWorktree(forkPath, worktreePath);
2942
- try {
2943
- const ctx = await prepareUpstream(config, onProgress, onStep);
2944
- return apply ? await runSyncMerge(config, ctx, onProgress, onStep) : await runAnalyzePreview(config, ctx, onProgress, onStep);
2945
- } catch (error) {
2946
- await cleanupWorktree(forkPath, worktreePath);
2947
- throw error;
2948
- }
2949
- }
2950
2414
  async function runEngineWithSpinner(config, apply) {
2951
- return runMergeEngine(config, {
2952
- apply,
2953
- onProgress: (message) => {
2954
- spinnerText(message);
2955
- },
2956
- onStep: (label, detail) => {
2957
- spinnerSuccess(label, detail);
2958
- createSpinner("...");
2959
- }
2960
- });
2415
+ const onProgress = (message) => spinnerText(message);
2416
+ const onStep = (label, detail) => {
2417
+ spinnerSuccess(label, detail);
2418
+ createSpinner("...");
2419
+ };
2420
+ const ctx = await prepareUpstream(config, onProgress, onStep);
2421
+ return apply ? runSyncMerge(config, ctx, onProgress, onStep) : runAnalyzePreview(config, ctx, onProgress, onStep);
2961
2422
  }
2962
2423
 
2963
2424
  // src/services/migrate.ts
2964
- import { execFileSync as execFileSync2, spawnSync } from "child_process";
2425
+ import { execFileSync, spawnSync } from "child_process";
2965
2426
  import { mkdir as mkdir4, writeFile as writeFile3 } from "fs/promises";
2966
- import { dirname as dirname4, join as join11 } from "path";
2427
+ import { dirname as dirname4, join as join8 } from "path";
2967
2428
  var EXTRACT_DIR = "node_modules/.cache/cella/migrations";
2968
2429
  var RESTORED_LIST_MAX = 20;
2969
2430
  async function ensureUpstreamCommit(config, sha) {
2970
2431
  const { forkPath } = config;
2971
- const present = async () => !!await git(["rev-parse", "--verify", "--quiet", `${sha}^{commit}`], forkPath, { ignoreErrors: true });
2432
+ const present = async () => !!await revParse(forkPath, `${sha}^{commit}`);
2972
2433
  if (await present()) return;
2973
2434
  await ensureRemote(forkPath, DEFAULT_UPSTREAM_REMOTE, config.settings.upstreamUrl);
2974
2435
  await fetchRemote(forkPath, DEFAULT_UPSTREAM_REMOTE);
@@ -2986,7 +2447,7 @@ async function readNotesStatus(config) {
2986
2447
  try {
2987
2448
  const ref = await resolveNotesRef(config);
2988
2449
  if (!ref) return null;
2989
- const [ids, pending] = await Promise.all([listNoteIds(config.forkPath, ref), readPending(config.forkPath, ref)]);
2450
+ const [ids, pending] = await Promise.all([listNoteIds(config.forkPath, ref), readPending(config.forkPath)]);
2990
2451
  return ids.length > 0 || pending.length > 0 ? { total: ids.length, open: pending.length } : null;
2991
2452
  } catch {
2992
2453
  return null;
@@ -3007,9 +2468,6 @@ async function printMigrationNotesLine(config, result) {
3007
2468
  console.info();
3008
2469
  console.info(colors_default.dim(formatNotesLine(status.total, status.open)));
3009
2470
  }
3010
- function toJson(note, url, open) {
3011
- return { ...note, kind: note.codemod ? "codemod" : "manual", open, url: url ?? null };
3012
- }
3013
2471
  function printNote(index, note, url) {
3014
2472
  const tags = [
3015
2473
  note.codemod ? "codemod" : "manual",
@@ -3029,13 +2487,13 @@ async function writeNoteFolder(config, ref, id) {
3029
2487
  const note = await readNote(forkPath, ref, id);
3030
2488
  if (!note) throw new Error(`no migration note '${id}' at upstream ${ref.slice(0, 7)}`);
3031
2489
  const files = (await git(["ls-tree", "-r", "--name-only", `${ref}:${NOTES_DIR}/${id}`], forkPath)).split("\n").filter(Boolean);
3032
- const target = join11(EXTRACT_DIR, id);
2490
+ const target = join8(EXTRACT_DIR, id);
3033
2491
  for (const file of files) {
3034
- const content = execFileSync2("git", ["show", `${ref}:${NOTES_DIR}/${id}/${file}`], {
2492
+ const content = execFileSync("git", ["show", `${ref}:${NOTES_DIR}/${id}/${file}`], {
3035
2493
  cwd: forkPath,
3036
2494
  maxBuffer: MAX_BUFFER
3037
2495
  });
3038
- const path = join11(forkPath, target, file);
2496
+ const path = join8(forkPath, target, file);
3039
2497
  await mkdir4(dirname4(path), { recursive: true });
3040
2498
  await writeFile3(path, content);
3041
2499
  }
@@ -3050,31 +2508,15 @@ async function extractNote(config, ref, id) {
3050
2508
  console.info(colors_default.dim(` or, keeping files identical to upstream as they are: pnpm cella migrate --run ${id}`));
3051
2509
  }
3052
2510
  }
3053
- function resolveCodemod(id, files, script) {
3054
- if (script) {
3055
- const name = script.replace(/^\.\//, "");
3056
- if (!files.includes(name)) {
3057
- throw new Error(`migration note '${id}' has no file '${script}'. its files: ${files.join(", ")}`);
3058
- }
3059
- return name;
3060
- }
3061
- const scripts = files.filter((file) => !file.includes("/") && isCodemodFile(file));
3062
- if (scripts.length === 0) {
2511
+ async function runNote(config, ref, id) {
2512
+ const { forkPath } = config;
2513
+ const { note, target } = await writeNoteFolder(config, ref, id);
2514
+ if (!note.codemod) {
3063
2515
  throw new Error(
3064
2516
  `migration note '${id}' ships no codemod script: its steps are manual, read them with \`pnpm cella migrate --show ${id}\`.`
3065
2517
  );
3066
2518
  }
3067
- if (scripts.length > 1) {
3068
- throw new Error(
3069
- `migration note '${id}' ships ${scripts.length} scripts (${scripts.join(", ")}): name the one to run with --script <file>.`
3070
- );
3071
- }
3072
- return scripts[0];
3073
- }
3074
- async function runNote(config, ref, id) {
3075
- const { forkPath } = config;
3076
- const { note, target, files } = await writeNoteFolder(config, ref, id);
3077
- const script = join11(target, resolveCodemod(id, files, config.script));
2519
+ const script = join8(target, note.codemod);
3078
2520
  const args = config.runArgs?.length ? config.runArgs : ["inventory", ...note.roots];
3079
2521
  const upstreamCommit = await readManifestBase(forkPath) ?? ref;
3080
2522
  await ensureUpstreamCommit(config, upstreamCommit);
@@ -3121,7 +2563,7 @@ async function runMigrate(config) {
3121
2563
  console.info(colors_default.dim("no sync point recorded yet: migration notes start with the first `pnpm cella sync`."));
3122
2564
  return;
3123
2565
  }
3124
- const pending = await readPending(forkPath, ref);
2566
+ const pending = await readPending(forkPath);
3125
2567
  const githubUrl = getGitHubBaseUrl(config.settings.upstreamUrl) ?? void 0;
3126
2568
  const urlOf = (id) => noteUrl(githubUrl, ref, id);
3127
2569
  if (config.mark?.length) return markNotes(config, ref, pending, config.mark);
@@ -3156,7 +2598,12 @@ async function runMigrate(config) {
3156
2598
  upstreamCommit: ref,
3157
2599
  total,
3158
2600
  open: pending.length,
3159
- notes: notes.map((note) => toJson(note, urlOf(note.id), open.has(note.id)))
2601
+ notes: notes.map((note) => ({
2602
+ ...note,
2603
+ kind: note.codemod ? "codemod" : "manual",
2604
+ open: open.has(note.id),
2605
+ url: urlOf(note.id) ?? null
2606
+ }))
3160
2607
  };
3161
2608
  writeStdout(JSON.stringify(out, null, 2));
3162
2609
  return;
@@ -3185,29 +2632,11 @@ function filterByScope(result, scope) {
3185
2632
  (f) => statuses.has(f.status) || f.upstreamChanged === true || f.upstreamOnly === true || overridesChanged && f.path === CONFIG_FILE
3186
2633
  );
3187
2634
  }
3188
- function findTargetFile(files, targetPath) {
3189
- const exact = files.find((f) => f.path === targetPath);
3190
- if (exact) return exact;
3191
- const normalized = targetPath.replace(/^\.\//, "");
3192
- return files.find((f) => f.path === normalized);
3193
- }
3194
- function printUnifiedDiff(config, filePath) {
3195
- const diff = gitDiffFile(config.forkPath, `${config.upstreamRef}..HEAD`, filePath, {
3196
- dstPrefix: basename4(config.forkPath)
3197
- });
3198
- writeStdout(diff.toString());
3199
- }
3200
2635
  async function runAnalyze(config) {
3201
2636
  createSpinner("starting analysis...");
3202
2637
  const result = await runEngineWithSpinner(config, false);
3203
2638
  spinnerSuccess();
3204
2639
  const scopedFiles = filterByScope(result, config.scope ?? "all");
3205
- if (config.diff) {
3206
- const file = findTargetFile(scopedFiles, config.diff) ?? findTargetFile(result.files, config.diff);
3207
- if (!file) throw new Error(`file not found in analysis results: ${config.diff}`);
3208
- printUnifiedDiff(config, file.path);
3209
- return result;
3210
- }
3211
2640
  if (config.json) {
3212
2641
  const overrides = result.upstreamOverrides?.kind === "changes" ? result.upstreamOverrides : void 0;
3213
2642
  const out = scopedFiles.map((f) => ({
@@ -3233,28 +2662,6 @@ async function runAnalyze(config) {
3233
2662
  }
3234
2663
  return result;
3235
2664
  }
3236
- if (config.openDiff) {
3237
- const file = findTargetFile(scopedFiles, config.openDiff) ?? findTargetFile(result.files, config.openDiff);
3238
- if (!file) throw new Error(`file not found in analysis results: ${config.openDiff}`);
3239
- const patch = gitDiffFile(config.forkPath, config.upstreamRef, file.path);
3240
- if (patch.length === 0) {
3241
- console.info(`${checkMark} ${file.path} is identical to upstream`);
3242
- return result;
3243
- }
3244
- const pagePath = await openDiffInBrowser(
3245
- patch.toString(),
3246
- {
3247
- filePath: file.path,
3248
- srcLabel: "cella",
3249
- dstLabel: basename4(config.forkPath),
3250
- note: "local working tree"
3251
- },
3252
- config.forkPath
3253
- );
3254
- console.info(`${checkMark} opened browser diff for ${file.path}`);
3255
- console.info(colors_default.dim(` ${pagePath}`));
3256
- return result;
3257
- }
3258
2665
  const linkOptions = {
3259
2666
  upstreamGitHubUrl: result.upstreamGitHubUrl,
3260
2667
  upstreamBranch: result.upstreamBranch,
@@ -3264,67 +2671,23 @@ async function runAnalyze(config) {
3264
2671
  printAnalysisFileGroups(result.files, linkOptions, result);
3265
2672
  printEngineReports(result, "analysis summary");
3266
2673
  await printMigrationNotesLine(config, result);
3267
- printLogFileReport(config, result.files);
3268
2674
  console.info();
3269
2675
  return result;
3270
2676
  }
3271
2677
 
3272
2678
  // src/services/audit.ts
3273
- import { spawnSync as spawnSync2 } from "child_process";
2679
+ import { spawnSync as spawnSync3 } from "child_process";
3274
2680
  import { readFileSync as readFileSync3 } from "fs";
3275
- import { basename as basename5, join as join13, relative as relative2 } from "path";
3276
- import { checkbox, confirm, Separator } from "@inquirer/prompts";
2681
+ import { basename as basename2, join as join10, relative as relative3 } from "path";
2682
+ import { checkbox, confirm as confirm2, Separator } from "@inquirer/prompts";
3277
2683
 
3278
2684
  // src/utils/audit-utils.ts
3279
2685
  import { execFile as execFile2 } from "child_process";
3280
- import { existsSync as existsSync8, readFileSync as readFileSync2, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
3281
- import { join as join12 } from "path";
3282
2686
  import { promisify as promisify2 } from "util";
3283
2687
  var execFileAsync2 = promisify2(execFile2);
3284
- var CACHE_FILE = join12(import.meta.dirname, "..", "..", ".audit.cache.json");
3285
- var CACHE_TTL = 7 * 24 * 60 * 60 * 1e3;
3286
- var CHANGELOG_PATHS = ["CHANGELOG.md", "CHANGELOG", "changelog.md", "HISTORY.md", "CHANGES.md", "NEWS.md"];
3287
- var DEFAULT_BRANCHES = ["main", "master"];
3288
- function loadCache() {
3289
- try {
3290
- if (existsSync8(CACHE_FILE)) {
3291
- return JSON.parse(readFileSync2(CACHE_FILE, "utf8"));
3292
- }
3293
- } catch {
3294
- }
3295
- return {};
3296
- }
3297
- function saveCache(cache) {
3298
- try {
3299
- writeFileSync3(CACHE_FILE, `${JSON.stringify(cache, null, 2)}
3300
- `);
3301
- } catch {
3302
- }
3303
- }
3304
- function clearCache() {
3305
- try {
3306
- if (existsSync8(CACHE_FILE)) {
3307
- unlinkSync(CACHE_FILE);
3308
- console.info(colors_default.green("\u2713 cache cleared successfully"));
3309
- } else {
3310
- console.info(colors_default.yellow("no cache file found"));
3311
- }
3312
- } catch (err) {
3313
- console.error(colors_default.red("failed to clear cache:"), err);
3314
- }
3315
- }
3316
- async function fetchNpmMetadata(packageName) {
3317
- try {
3318
- const response = await fetch(`https://registry.npmjs.org/${packageName}`);
3319
- if (!response.ok) return null;
3320
- return await response.json();
3321
- } catch {
3322
- return null;
3323
- }
3324
- }
3325
- function getRepoUrl(data) {
3326
- if (!data?.repository?.url) return null;
3327
- let url = data.repository.url;
2688
+ function getRepoUrl(repositoryUrl) {
2689
+ if (!repositoryUrl) return null;
2690
+ let url = repositoryUrl;
3328
2691
  if (url.startsWith("git+")) {
3329
2692
  url = url.slice(4);
3330
2693
  }
@@ -3345,110 +2708,43 @@ function isGitHubRepoUrl(repoUrl) {
3345
2708
  return false;
3346
2709
  }
3347
2710
  }
3348
- async function findGitHubFile(repoUrl, filePath) {
3349
- if (!isGitHubRepoUrl(repoUrl)) return null;
3350
- for (const branch of DEFAULT_BRANCHES) {
3351
- const rawUrl = repoUrl.replace("github.com", "raw.githubusercontent.com").concat(`/${branch}/${filePath}`);
3352
- try {
3353
- const response = await fetch(rawUrl, { method: "HEAD" });
3354
- if (response.ok) return branch;
3355
- } catch {
3356
- }
3357
- }
3358
- return null;
3359
- }
3360
- async function findChangelogUrl(repoUrl, packageName, cache) {
3361
- if (!isGitHubRepoUrl(repoUrl)) return null;
3362
- const cached = cache[packageName];
3363
- if (cached && Date.now() - cached.fetchedAt < CACHE_TTL) {
3364
- return cached.changelogUrl;
3365
- }
3366
- for (const changelogPath of CHANGELOG_PATHS) {
3367
- const branch = await findGitHubFile(repoUrl, changelogPath);
3368
- if (branch) {
3369
- const blobUrl = `${repoUrl}/blob/${branch}/${changelogPath}`;
3370
- cache[packageName] = {
3371
- repoUrl,
3372
- changelogUrl: blobUrl,
3373
- fetchedAt: Date.now()
3374
- };
3375
- return blobUrl;
3376
- }
3377
- }
3378
- cache[packageName] = {
3379
- repoUrl,
3380
- changelogUrl: null,
3381
- fetchedAt: Date.now()
3382
- };
3383
- return null;
3384
- }
3385
2711
  function getReleasesUrl(repoUrl) {
3386
2712
  if (!isGitHubRepoUrl(repoUrl)) return null;
3387
2713
  return `${repoUrl}/releases`;
3388
2714
  }
3389
2715
  function isMajorVersionChange(current, latest) {
3390
- const currentMajor = current.split(".")[0]?.replace(/^\D+/, "");
3391
- const latestMajor = latest.split(".")[0]?.replace(/^\D+/, "");
3392
- if (!currentMajor || !latestMajor) return false;
3393
- return Number.parseInt(latestMajor, 10) > Number.parseInt(currentMajor, 10);
2716
+ const from = parseComparableVersion(current);
2717
+ const to = parseComparableVersion(latest);
2718
+ return !!from && !!to && to.major > from.major;
3394
2719
  }
3395
- async function runPnpmJson(args, cwd) {
3396
- const parse = (stdout) => {
3397
- if (!stdout || stdout.trim() === "") return null;
3398
- try {
3399
- return JSON.parse(stdout);
3400
- } catch {
3401
- return null;
3402
- }
3403
- };
2720
+ async function runPnpm(args, cwd) {
3404
2721
  try {
3405
- const { stdout } = await execFileAsync2("pnpm", args, {
2722
+ const { stdout, stderr } = await execFileAsync2("pnpm", args, {
3406
2723
  encoding: "utf8",
3407
2724
  maxBuffer: 10 * 1024 * 1024,
3408
- // 10MB buffer for large outputs
3409
2725
  cwd
3410
2726
  });
3411
- return parse(stdout);
2727
+ return { ok: true, stdout, stderr };
3412
2728
  } catch (error) {
3413
- return parse(error instanceof Error && "stdout" in error ? error.stdout : void 0);
2729
+ const { stdout = "", stderr = "" } = error;
2730
+ return { ok: false, stdout, stderr: stdout || stderr ? stderr : String(error) };
3414
2731
  }
3415
2732
  }
3416
- async function getOutdatedPackages(cwd) {
3417
- return await runPnpmJson(["-r", "outdated", "--json"], cwd) ?? {};
2733
+ async function runPnpmJson(args, cwd) {
2734
+ try {
2735
+ return JSON.parse((await runPnpm([...args, "--loglevel=error"], cwd)).stdout);
2736
+ } catch {
2737
+ return null;
2738
+ }
3418
2739
  }
3419
2740
  async function runPnpmAudit(cwd) {
3420
- return await runPnpmJson(["audit", "--json"], cwd);
2741
+ const result = await runPnpmJson(["audit", "--json"], cwd);
2742
+ if (!result || typeof result.advisories !== "object" || result.advisories === null) return null;
2743
+ return result;
3421
2744
  }
3422
- function parseDependencyPath(paths) {
3423
- for (const pathStr of paths) {
3424
- const parts = pathStr.split(">");
3425
- if (parts.length >= 2) {
3426
- return {
3427
- workspace: parts[0],
3428
- directDependency: parts.length > 2 ? parts[1] : null
3429
- };
3430
- }
3431
- }
3432
- return { workspace: null, directDependency: null };
3433
- }
3434
- function buildVulnerabilityMap(auditResult) {
3435
- const map = /* @__PURE__ */ new Map();
3436
- if (!auditResult?.advisories) return map;
3437
- for (const advisory of Object.values(auditResult.advisories)) {
3438
- const allPaths = advisory.findings?.flatMap((f) => f.paths) || [];
3439
- const { workspace, directDependency } = parseDependencyPath(allPaths);
3440
- const existing = map.get(advisory.module_name) || [];
3441
- existing.push({
3442
- title: advisory.title,
3443
- severity: advisory.severity,
3444
- vulnerableVersions: advisory.vulnerable_versions,
3445
- cves: advisory.cves || [],
3446
- workspace,
3447
- directDependency
3448
- });
3449
- map.set(advisory.module_name, existing);
3450
- }
3451
- return map;
2745
+ async function fetchPublishTimes(name, cwd) {
2746
+ const times = await runPnpmJson(["view", name, "time", "--json"], cwd);
2747
+ return times && typeof times === "object" ? times : null;
3452
2748
  }
3453
2749
  var vulnIcons = {
3454
2750
  critical: colors_default.red("\u25CF"),
@@ -3460,14 +2756,6 @@ var vulnIcons = {
3460
2756
  function getVulnIcon(severity) {
3461
2757
  return vulnIcons[severity] ?? colors_default.gray("\u25CF");
3462
2758
  }
3463
- function getHighestSeverity(vulns) {
3464
- if (vulns.length === 0) return null;
3465
- const order = ["critical", "high", "moderate", "low", "info"];
3466
- for (const severity of order) {
3467
- if (vulns.some((v) => v.severity === severity)) return severity;
3468
- }
3469
- return null;
3470
- }
3471
2759
  function middleTruncate(str2, maxLen) {
3472
2760
  if (str2.length <= maxLen) return str2;
3473
2761
  const ellipsis = "\u2026";
@@ -3482,51 +2770,503 @@ function formatDependents(dependents) {
3482
2770
  return `${dependents[0]} +${dependents.length - 1}`;
3483
2771
  }
3484
2772
 
3485
- // src/utils/yaml.ts
3486
- function yamlBlockLines(content, key) {
3487
- const lines = [];
3488
- let inBlock = false;
3489
- for (const line of content.split("\n")) {
3490
- if (new RegExp(`^${key}:\\s*$`).test(line)) {
3491
- inBlock = true;
3492
- continue;
2773
+ // src/utils/security-plan.ts
2774
+ var SEVERITIES = ["critical", "high", "moderate", "low", "info"];
2775
+ function isAuditOverride(key) {
2776
+ if (/[^ |@]>/.test(key)) return false;
2777
+ return key.indexOf("@", 1) > 0;
2778
+ }
2779
+ function overrideModule(key) {
2780
+ const child = key.split(/(?<=[^ |@])>/).at(-1) ?? key;
2781
+ const at = child.indexOf("@", 1);
2782
+ return at > 0 ? child.slice(0, at) : child;
2783
+ }
2784
+ function parseVersionedExclude(entry) {
2785
+ const at = entry.lastIndexOf("@");
2786
+ if (at <= 0) return null;
2787
+ const version = entry.slice(at + 1);
2788
+ return /^\d+\.\d+\.\d+[0-9A-Za-z.+-]*$/.test(version) ? { name: entry.slice(0, at), version } : null;
2789
+ }
2790
+ function advisoriesFromAudit(audit) {
2791
+ return Object.values(audit.advisories).map((advisory) => {
2792
+ const paths = (advisory.findings ?? []).flatMap((finding) => finding.paths ?? []).map((path) => path.split(">"));
2793
+ return {
2794
+ ghsa: advisory.github_advisory_id ?? advisory.url ?? String(advisory.id),
2795
+ module: advisory.module_name,
2796
+ severity: advisory.severity,
2797
+ range: advisory.vulnerable_versions,
2798
+ installed: [...new Set((advisory.findings ?? []).map((finding) => finding.version))],
2799
+ via: [...new Set(paths.map((parts) => parts[parts.length > 2 ? 1 : 0]))],
2800
+ direct: paths.some((parts) => parts.length === 2)
2801
+ };
2802
+ });
2803
+ }
2804
+ var publishedAgo = (name, version, input) => input.now - Date.parse(input.published[name]?.[version] ?? "");
2805
+ function isInstallable(name, version, input) {
2806
+ const excludes = input.previous.releaseAgeExcludes;
2807
+ if (excludes.includes(name) || excludes.includes(`${name}@${version}`)) return true;
2808
+ const age = publishedAgo(name, version, input);
2809
+ return Number.isNaN(age) || age >= input.releaseAgeMinutes * 6e4;
2810
+ }
2811
+ function admits(target, version) {
2812
+ const base = target.replace(/^[\^~>=<\s]+/, "");
2813
+ if (compareVersionStrings(base, version) > 0) return false;
2814
+ return target.trim().startsWith(">") || !isBreakingJump(base, version);
2815
+ }
2816
+ var previousOverrides = (module, previous) => Object.entries(previous.overrides).filter(([key]) => isAuditOverride(key) && overrideModule(key) === module);
2817
+ function standingOverrides(module, input) {
2818
+ const versions = Object.keys(input.published[module] ?? {});
2819
+ return Object.fromEntries(
2820
+ previousOverrides(module, input.previous).filter(([, target]) => versions.some((v) => admits(target, v)))
2821
+ );
2822
+ }
2823
+ function planSecurity(input) {
2824
+ const ignored = new Set(input.previous.ignoredGhsas);
2825
+ const byModule = Map.groupBy(input.advisories, (advisory) => advisory.module);
2826
+ const items = [];
2827
+ for (const [module, advisories] of byModule) {
2828
+ const versions = Object.keys(input.published[module] ?? {}).filter((v) => /^\d/.test(v));
2829
+ const stable = versions.filter((v) => !isPrerelease(v)).sort(compareVersionStrings);
2830
+ const escapesAll = (v) => !advisories.some((advisory) => satisfiesComparators(v, advisory.range));
2831
+ const fixOf = (advisory) => {
2832
+ const highest = [...advisory.installed].sort(compareVersionStrings).at(-1) ?? "0.0.0";
2833
+ const candidates = stable.filter(
2834
+ (v) => compareVersionStrings(v, highest) > 0 && !satisfiesComparators(v, advisory.range)
2835
+ );
2836
+ const installable = candidates.filter((v) => isInstallable(module, v, input));
2837
+ const pick = (among) => among.find(escapesAll) ?? among[0];
2838
+ return pick(installable) ?? pick(candidates);
2839
+ };
2840
+ for (const [fix, group] of Map.groupBy(advisories, fixOf)) {
2841
+ const installed = [...new Set(group.flatMap((advisory) => advisory.installed))].sort(compareVersionStrings);
2842
+ const ghsas = group.map((advisory) => advisory.ghsa);
2843
+ const allIgnored = ghsas.every((ghsa) => ignored.has(ghsa));
2844
+ const breaking = !!fix && installed.some((v) => isBreakingJump(v, fix));
2845
+ let remedy = { kind: "none" };
2846
+ let standing = allIgnored ? "ignored" : "new";
2847
+ let overrides = {};
2848
+ if (fix) {
2849
+ const hoursOld = Math.max(0, Math.floor(publishedAgo(module, fix, input) / 36e5));
2850
+ remedy = isInstallable(module, fix, input) ? { kind: "override", target: fix, breaking } : { kind: "fresh", target: fix, breaking, hoursOld };
2851
+ overrides = Object.fromEntries(
2852
+ [...new Set(group.map((advisory) => advisory.range))].map((range) => {
2853
+ const key = `${module}@${range}`;
2854
+ const kept = input.previous.overrides[key];
2855
+ return [key, kept && admits(kept, fix) ? kept : `^${fix}`];
2856
+ })
2857
+ );
2858
+ if (remedy.kind === "override") {
2859
+ if (previousOverrides(module, input.previous).some(([, target]) => admits(target, fix))) standing = "carried";
2860
+ else if (allIgnored && !breaking) standing = "new";
2861
+ }
2862
+ }
2863
+ items.push({
2864
+ module,
2865
+ severity: SEVERITIES.find((severity) => group.some((advisory) => advisory.severity === severity)) ?? "info",
2866
+ ghsas,
2867
+ installed: installed[0] ?? "",
2868
+ via: [...new Set(group.flatMap((advisory) => advisory.via))],
2869
+ direct: group.some((advisory) => advisory.direct),
2870
+ remedy,
2871
+ standing,
2872
+ overrides
2873
+ });
3493
2874
  }
3494
- if (!inBlock) continue;
3495
- const trimmed = line.trim();
3496
- if (trimmed.startsWith("#")) continue;
3497
- if (trimmed !== "" && !line.startsWith(" ") && !line.startsWith(" ")) break;
3498
- lines.push(line);
3499
- }
3500
- return lines;
3501
- }
3502
- function parseYamlBlockMap(content, key) {
3503
- const result = {};
3504
- for (const line of yamlBlockLines(content, key)) {
3505
- const match = line.match(/^\s+(.+?):\s+['"]?([^'"]+)['"]?\s*$/);
3506
- if (match) {
3507
- result[match[1].replace(/^['"]|['"]$/g, "")] = match[2];
2875
+ }
2876
+ return items.sort(
2877
+ (a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity) || a.module.localeCompare(b.module)
2878
+ );
2879
+ }
2880
+ function liveExcludes(input) {
2881
+ return input.previous.releaseAgeExcludes.filter((entry) => {
2882
+ const versioned = parseVersionedExclude(entry);
2883
+ if (!versioned || !input.published[versioned.name]) return true;
2884
+ return publishedAgo(versioned.name, versioned.version, input) < input.releaseAgeMinutes * 6e4;
2885
+ });
2886
+ }
2887
+
2888
+ // src/utils/workspace-yaml.ts
2889
+ import { isMap, isScalar, isSeq, parseDocument } from "yaml";
2890
+ var record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
2891
+ var stringRecord = (value) => Object.fromEntries(Object.entries(record(value)).map(([key, entry]) => [key, String(entry)]));
2892
+ var stringList = (value) => Array.isArray(value) ? value.map(String) : [];
2893
+ function readAuditSettings(content) {
2894
+ const doc = parseDocument(content);
2895
+ if (doc.errors.length > 0) throw new Error(doc.errors[0].message);
2896
+ const settings = record(doc.toJS());
2897
+ return {
2898
+ overrides: stringRecord(settings.overrides),
2899
+ releaseAgeExcludes: stringList(settings.minimumReleaseAgeExclude),
2900
+ ignoredGhsas: stringList(record(settings.auditConfig).ignoreGhsas)
2901
+ };
2902
+ }
2903
+ function readTopLevel(content, key) {
2904
+ return record(parseDocument(content).toJS())[key];
2905
+ }
2906
+ function readPatchFiles(content) {
2907
+ return Object.values(stringRecord(record(parseDocument(content).toJS()).patchedDependencies));
2908
+ }
2909
+ var keyOf = (pair) => String(isScalar(pair.key) ? pair.key.value : pair.key);
2910
+ var itemText = (item) => String(isScalar(item) ? item.value : item);
2911
+ function keepingOrder(map, change) {
2912
+ const keys = map.items.map(keyOf);
2913
+ const wasSorted = keys.every((key, i) => i === 0 || keys[i - 1].localeCompare(key) <= 0);
2914
+ change();
2915
+ if (wasSorted) map.items.sort((a, b) => keyOf(a).localeCompare(keyOf(b)));
2916
+ }
2917
+ function mapAt(doc, path) {
2918
+ if (path.length === 0) {
2919
+ if (!isMap(doc.contents)) doc.contents = doc.createNode({});
2920
+ return doc.contents;
2921
+ }
2922
+ const parent = mapAt(doc, path.slice(0, -1));
2923
+ const key = path[path.length - 1];
2924
+ const existing = parent.get(key, true);
2925
+ if (isMap(existing)) {
2926
+ if (existing.items.length === 0) existing.flow = false;
2927
+ return existing;
2928
+ }
2929
+ const created = doc.createNode({});
2930
+ keepingOrder(parent, () => parent.set(key, created));
2931
+ return created;
2932
+ }
2933
+ function syncMap(doc, path, entries) {
2934
+ if (!isMap(doc.getIn(path)) && Object.keys(entries).length === 0) return;
2935
+ const map = mapAt(doc, path);
2936
+ keepingOrder(map, () => {
2937
+ for (const key of map.items.map(keyOf)) if (!(key in entries)) map.delete(key);
2938
+ for (const [key, value] of Object.entries(entries)) map.set(key, value);
2939
+ });
2940
+ if (map.items.length === 0) doc.deleteIn(path);
2941
+ }
2942
+ function syncList(doc, path, entries) {
2943
+ const current = doc.getIn(path);
2944
+ if (!isSeq(current)) {
2945
+ if (entries.length === 0) return;
2946
+ const parent = mapAt(doc, path.slice(0, -1));
2947
+ keepingOrder(parent, () => parent.set(path[path.length - 1], doc.createNode(entries)));
2948
+ return;
2949
+ }
2950
+ current.items = current.items.filter((item) => entries.includes(itemText(item)));
2951
+ const present = new Set(current.items.map(itemText));
2952
+ for (const entry of entries) if (!present.has(entry)) current.add(doc.createNode(entry));
2953
+ if (current.items.length === 0) doc.deleteIn(path);
2954
+ else current.flow = false;
2955
+ }
2956
+ function writeAuditSettings(content, next) {
2957
+ const doc = parseDocument(content);
2958
+ syncMap(doc, ["overrides"], next.overrides);
2959
+ syncList(doc, ["minimumReleaseAgeExclude"], next.releaseAgeExcludes);
2960
+ syncList(doc, ["auditConfig", "ignoreGhsas"], next.ignoredGhsas);
2961
+ const auditConfig = doc.getIn(["auditConfig"]);
2962
+ if (isMap(auditConfig) && auditConfig.items.length === 0) doc.deleteIn(["auditConfig"]);
2963
+ return doc.toString({ lineWidth: 0, singleQuote: true });
2964
+ }
2965
+
2966
+ // src/services/audit-security.ts
2967
+ import { spawnSync as spawnSync2 } from "child_process";
2968
+ import { cpSync, existsSync as existsSync7, mkdirSync, mkdtempSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "fs";
2969
+ import { tmpdir } from "os";
2970
+ import { dirname as dirname5, join as join9, relative as relative2 } from "path";
2971
+ import { confirm, select as select2 } from "@inquirer/prompts";
2972
+ var WORKSPACE_FILE = "pnpm-workspace.yaml";
2973
+ var LOCK_FILE = "pnpm-lock.yaml";
2974
+ var RESOLUTION_FILES = ["package.json", LOCK_FILE, ".npmrc", ".pnpmfile.cjs"];
2975
+ var readOr = (path, fallback = "") => existsSync7(path) ? readFileSync2(path, "utf8") : fallback;
2976
+ var count = (n, noun, plural2 = `${noun}s`) => `${n} ${n === 1 ? noun : plural2}`;
2977
+ var isBreaking = (item) => item.remedy.kind !== "none" && item.remedy.breaking;
2978
+ var needsDecision = (item) => item.standing === "new" && (item.remedy.kind !== "override" || item.remedy.breaking || item.direct);
2979
+ var sameEntries = (a, b) => Object.keys(a).length === Object.keys(b).length && Object.entries(a).every(([key, value]) => b[key] === value);
2980
+ async function createStaging(forkPath, workspaceYaml) {
2981
+ const staging = mkdtempSync(join9(tmpdir(), "cella-audit-"));
2982
+ const projects = await runPnpmJson(["ls", "-r", "--depth", "-1", "--json"], forkPath) ?? [];
2983
+ const files = [
2984
+ ...RESOLUTION_FILES,
2985
+ ...readPatchFiles(workspaceYaml),
2986
+ ...projects.map((project) => join9(relative2(forkPath, project.path), "package.json"))
2987
+ ];
2988
+ for (const file of new Set(files)) {
2989
+ if (file.startsWith("..") || !existsSync7(join9(forkPath, file))) continue;
2990
+ mkdirSync(dirname5(join9(staging, file)), { recursive: true });
2991
+ cpSync(join9(forkPath, file), join9(staging, file));
2992
+ }
2993
+ return staging;
2994
+ }
2995
+ async function resolveStaging(staging, workspaceYaml, settings) {
2996
+ writeFileSync(join9(staging, WORKSPACE_FILE), writeAuditSettings(workspaceYaml, settings));
2997
+ const result = await runPnpm(["install", "--lockfile-only", "--no-frozen-lockfile", "--ignore-scripts"], staging);
2998
+ return result.ok ? { lock: readOr(join9(staging, LOCK_FILE)) } : { error: `${result.stdout}${result.stderr}`.trim() };
2999
+ }
3000
+ function lockedVersions(lock, name) {
3001
+ const escaped = name.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
3002
+ const versions = [...lock.matchAll(new RegExp(`^ {2}'?${escaped}@(\\d[^:'()\\s]*)`, "gm"))].map((match) => match[1]);
3003
+ return [...new Set(versions)];
3004
+ }
3005
+ function versionsText(item) {
3006
+ if (item.remedy.kind === "none") return item.installed;
3007
+ return `${item.installed} \u2192 ${item.remedy.target}${isBreaking(item) ? " \u26A0" : ""}`;
3008
+ }
3009
+ function noteText(item, releaseAgeHours) {
3010
+ if (item.remedy.kind === "none") return "no fix published";
3011
+ if (item.remedy.kind === "fresh")
3012
+ return `fix published ${item.remedy.hoursOld}h ago, inside the ${releaseAgeHours}h release age`;
3013
+ if (item.standing === "ignored") return "the fix is a breaking jump";
3014
+ return "";
3015
+ }
3016
+ function printGroup(title, items, releaseAgeHours) {
3017
+ if (items.length === 0) return;
3018
+ const nameWidth = Math.min(30, Math.max(...items.map((item) => item.module.length)));
3019
+ const versionWidth = Math.max(...items.map((item) => versionsText(item).length));
3020
+ console.info(colors_default.bold(`${title} (${items.length})`));
3021
+ for (const item of items) {
3022
+ const name = colors_default.white(middleTruncate(item.module, nameWidth).padEnd(nameWidth));
3023
+ const source = `${formatDependents(item.via)}${item.direct ? " (direct)" : ""}`;
3024
+ const tail = [source, noteText(item, releaseAgeHours)].filter(Boolean).join(" \xB7 ");
3025
+ console.info(
3026
+ `${getVulnIcon(item.severity)} ${name} ${item.severity.padEnd(8)} ${versionsText(item).padEnd(versionWidth)} ${colors_default.dim(tail)}`
3027
+ );
3028
+ }
3029
+ console.info();
3030
+ }
3031
+ function printDropped(title, rows) {
3032
+ if (rows.length === 0) return;
3033
+ const nameWidth = Math.min(40, Math.max(...rows.map(([name]) => name.length)));
3034
+ console.info(colors_default.bold(`${title} (${rows.length})`));
3035
+ for (const [name, reason] of rows) {
3036
+ console.info(`${colors_default.green("\u25CB")} ${colors_default.white(middleTruncate(name, nameWidth).padEnd(nameWidth))} ${colors_default.dim(reason)}`);
3037
+ }
3038
+ console.info();
3039
+ }
3040
+ function droppedReason(key, items, bareLock) {
3041
+ const module = overrideModule(key);
3042
+ const item = items.find((candidate) => candidate.module === module);
3043
+ if (item) return item.remedy.kind === "none" ? "no published version fixes it" : "replaced by another range";
3044
+ const versions = lockedVersions(bareLock, module);
3045
+ return versions.length > 0 ? `resolves to ${versions.join(", ")} on its own` : "no longer installed";
3046
+ }
3047
+ async function decide(items, releaseAgeHours) {
3048
+ const decisions = [];
3049
+ for (const item of items) {
3050
+ const { remedy } = item;
3051
+ const jump = remedy.kind === "none" ? "" : `${item.installed} \u2192 ${remedy.target}`;
3052
+ if (!needsDecision(item)) {
3053
+ decisions.push({ item, action: item.standing === "ignored" ? "ignore" : "override" });
3054
+ } else if (remedy.kind === "none") {
3055
+ const ignore = await confirm({
3056
+ message: `${item.module}: no fix is published. ignore ${item.ghsas.join(", ")} until one is?`,
3057
+ default: true
3058
+ });
3059
+ decisions.push({ item, action: ignore ? "ignore" : "open" });
3060
+ } else if (remedy.kind === "fresh") {
3061
+ const action = await select2({
3062
+ message: `${item.module}: fix ${remedy.target} was published ${remedy.hoursOld}h ago, inside the ${releaseAgeHours}h release age`,
3063
+ choices: [
3064
+ { name: "wait and re-run later", value: "open" },
3065
+ { name: "install now, with an exclude that a later audit removes", value: "override" }
3066
+ ]
3067
+ });
3068
+ decisions.push({ item, action, exclude: action === "override" ? `${item.module}@${remedy.target}` : void 0 });
3069
+ } else {
3070
+ const message = item.direct ? `the fix is ${jump}, and ${item.via[0]} depends on it directly` : `the fix jumps ${jump}, past what ${formatDependents(item.via)} asks for`;
3071
+ const action = await select2({
3072
+ message: `${item.module}: ${message}`,
3073
+ choices: [
3074
+ { name: item.direct ? "leave open, update it in package.json" : "leave open", value: "open" },
3075
+ { name: "apply the override", value: "override" },
3076
+ { name: "ignore the advisory", value: "ignore" }
3077
+ ]
3078
+ });
3079
+ decisions.push({ item, action });
3508
3080
  }
3509
3081
  }
3510
- return result;
3082
+ return decisions;
3511
3083
  }
3512
- function parseYamlBlockList(content, key) {
3513
- const items = [];
3514
- for (const line of yamlBlockLines(content, key)) {
3515
- const trimmed = line.trim();
3516
- if (trimmed.startsWith("- ")) items.push(trimmed.slice(2).trim());
3084
+ function settingsFor(decisions, kept, excludes) {
3085
+ const overriding = decisions.filter((decision) => decision.action === "override");
3086
+ return {
3087
+ overrides: { ...kept, ...Object.assign({}, ...overriding.map((decision) => decision.item.overrides)) },
3088
+ releaseAgeExcludes: [.../* @__PURE__ */ new Set([...excludes, ...overriding.flatMap((decision) => decision.exclude ?? [])])],
3089
+ ignoredGhsas: decisions.filter((decision) => decision.action === "ignore").flatMap((decision) => decision.item.ghsas)
3090
+ };
3091
+ }
3092
+ function changeLine(label, before, after) {
3093
+ const dropped = before.filter((entry) => !after.includes(entry)).length;
3094
+ const added = after.filter((entry) => !before.includes(entry)).length;
3095
+ const moved = [dropped ? `${dropped} dropped` : "", added ? `${added} added` : ""].filter(Boolean).join(", ");
3096
+ const counts = moved ? `${before.length} \u2192 ${after.length}` : String(after.length);
3097
+ return ` ${label.padEnd(9)} ${counts.padEnd(7)} ${colors_default.dim(moved || "unchanged")}`;
3098
+ }
3099
+ async function runSecurityPass(forkPath, options) {
3100
+ const workspaceYaml = readOr(join9(forkPath, WORKSPACE_FILE));
3101
+ const workspaceLock = readOr(join9(forkPath, LOCK_FILE));
3102
+ let previous;
3103
+ try {
3104
+ previous = readAuditSettings(workspaceYaml);
3105
+ } catch (error) {
3106
+ console.error(
3107
+ `${colors_default.red("\u2717")} ${WORKSPACE_FILE} could not be read: ${error instanceof Error ? error.message : error}`
3108
+ );
3109
+ return;
3110
+ }
3111
+ const auditKeys = Object.keys(previous.overrides).filter(isAuditOverride);
3112
+ const lasting = Object.fromEntries(Object.entries(previous.overrides).filter(([key]) => !isAuditOverride(key)));
3113
+ const spinner = createSpinner(
3114
+ auditKeys.length > 0 ? `resolving without the ${count(auditKeys.length, "audit override")}...` : "resolving the workspace..."
3115
+ );
3116
+ const fail = (message, detail = "") => {
3117
+ spinner.stop();
3118
+ console.error(`${colors_default.red("\u2717")} ${message}`);
3119
+ if (detail) console.error(colors_default.dim(detail.split("\n").slice(-12).join("\n")));
3120
+ console.info(colors_default.dim(" the workspace is unchanged"));
3121
+ console.info();
3122
+ };
3123
+ const staging = await createStaging(forkPath, workspaceYaml);
3124
+ try {
3125
+ const bare = await resolveStaging(staging, workspaceYaml, { ...previous, overrides: lasting, ignoredGhsas: [] });
3126
+ if ("error" in bare) return fail("could not resolve the workspace without its audit overrides", bare.error);
3127
+ spinnerText("checking for vulnerabilities...");
3128
+ const audit = await runPnpmAudit(staging);
3129
+ if (!audit) return fail("pnpm audit gave no answer, so nothing can be decided");
3130
+ const advisories = advisoriesFromAudit(audit);
3131
+ spinnerText(`checking ${count(advisories.length, "advisory", "advisories")} against the registry...`);
3132
+ const versionedExcludes = previous.releaseAgeExcludes.flatMap((entry) => parseVersionedExclude(entry)?.name ?? []);
3133
+ const names = [.../* @__PURE__ */ new Set([...advisories.map((advisory) => advisory.module), ...versionedExcludes])];
3134
+ const times = await Promise.all(names.map((name) => fetchPublishTimes(name, forkPath)));
3135
+ const published = Object.fromEntries(
3136
+ names.map((name, i) => [name, times[i]]).filter(([, answer]) => answer)
3137
+ );
3138
+ const unanswered = advisories.map((advisory) => advisory.module).filter((name) => !published[name]);
3139
+ if (unanswered.length > 0) return fail(`the registry did not answer for ${[...new Set(unanswered)].join(", ")}`);
3140
+ const releaseAge = Number.parseInt((await runPnpm(["config", "get", "minimumReleaseAge"], forkPath)).stdout, 10);
3141
+ const releaseAgeMinutes = Number.isNaN(releaseAge) ? 0 : releaseAge;
3142
+ const releaseAgeHours = Math.round(releaseAgeMinutes / 60);
3143
+ const planInput = { advisories, published, previous, releaseAgeMinutes, now: Date.now() };
3144
+ const items = planSecurity(planInput);
3145
+ const excludes = liveExcludes(planInput);
3146
+ spinner.stop();
3147
+ const changes = printPlan({
3148
+ items,
3149
+ previous,
3150
+ auditKeys,
3151
+ lasting,
3152
+ advisories,
3153
+ bareLock: bare.lock,
3154
+ releaseAgeHours
3155
+ });
3156
+ if (!options.apply) {
3157
+ if (changes) console.info(colors_default.dim("run without --list to apply"));
3158
+ console.info();
3159
+ return;
3160
+ }
3161
+ let decisions;
3162
+ try {
3163
+ decisions = await decide(items, releaseAgeHours);
3164
+ } catch {
3165
+ console.info(colors_default.dim(" cancelled, the workspace is unchanged"));
3166
+ return;
3167
+ }
3168
+ const open = decisions.filter((decision) => decision.action === "open").map((decision) => decision.item.module);
3169
+ const kept = Object.assign({}, lasting, ...open.map((module) => standingOverrides(module, planInput)));
3170
+ const next = settingsFor(decisions, kept, excludes);
3171
+ const nextYaml = writeAuditSettings(workspaceYaml, next);
3172
+ if (nextYaml === workspaceYaml) {
3173
+ const state = `${count(auditKeys.length, "override")} \xB7 ${next.ignoredGhsas.length} ignored`;
3174
+ console.info(`${checkMark} security settings are current ${colors_default.dim(`\xB7 ${state}`)}`);
3175
+ printOpen(open);
3176
+ return;
3177
+ }
3178
+ const summarize = () => {
3179
+ console.info(colors_default.bold(WORKSPACE_FILE));
3180
+ console.info(changeLine("overrides", auditKeys, Object.keys(next.overrides).filter(isAuditOverride)));
3181
+ console.info(changeLine("excludes", previous.releaseAgeExcludes, next.releaseAgeExcludes));
3182
+ console.info(changeLine("ignored", previous.ignoredGhsas, next.ignoredGhsas));
3183
+ console.info();
3184
+ };
3185
+ if (sameEntries(next.overrides, previous.overrides)) {
3186
+ writeFileSync(join9(forkPath, WORKSPACE_FILE), nextYaml);
3187
+ console.info(`${checkMark} ${WORKSPACE_FILE} updated ${colors_default.dim("\xB7 nothing to install")}`);
3188
+ printOpen(open);
3189
+ summarize();
3190
+ return;
3191
+ }
3192
+ const stagingSpinner = createSpinner("resolving with the new settings...");
3193
+ const staged = await resolveStaging(staging, workspaceYaml, next);
3194
+ stagingSpinner.stop();
3195
+ if ("error" in staged) return fail("could not resolve the workspace with the new settings", staged.error);
3196
+ writeFileSync(join9(forkPath, WORKSPACE_FILE), nextYaml);
3197
+ writeFileSync(join9(forkPath, LOCK_FILE), staged.lock);
3198
+ const install = spawnSync2("pnpm", ["install"], { cwd: forkPath, stdio: "inherit" });
3199
+ console.info();
3200
+ if (install.status !== 0) {
3201
+ writeFileSync(join9(forkPath, WORKSPACE_FILE), workspaceYaml);
3202
+ writeFileSync(join9(forkPath, LOCK_FILE), workspaceLock);
3203
+ console.error(`${colors_default.red("\u2717")} pnpm install failed (exit code ${install.status})`);
3204
+ console.info(colors_default.dim(` ${WORKSPACE_FILE} and ${LOCK_FILE} are back as they were; run pnpm install to match`));
3205
+ console.info();
3206
+ return;
3207
+ }
3208
+ const verified = await runPnpmAudit(forkPath);
3209
+ const reported = verified ? [...new Set(advisoriesFromAudit(verified).map((advisory) => advisory.module))] : open;
3210
+ console.info(`${checkMark} installed${reported.length === 0 ? ` ${colors_default.dim("\xB7")} audit clean` : ""}`);
3211
+ printOpen(reported);
3212
+ summarize();
3213
+ } finally {
3214
+ spinner.stop();
3215
+ rmSync(staging, { recursive: true, force: true });
3517
3216
  }
3518
- return items;
3217
+ }
3218
+ function printOpen(modules) {
3219
+ if (modules.length > 0) {
3220
+ console.info(`${warningMark} ${count(modules.length, "package")} still reported: ${modules.join(", ")}`);
3221
+ }
3222
+ console.info();
3223
+ }
3224
+ function printPlan(plan) {
3225
+ const { items, previous, releaseAgeHours } = plan;
3226
+ const kept = items.filter((item) => item.standing === "carried");
3227
+ const undecided = items.filter(needsDecision);
3228
+ const added = items.filter((item) => item.standing === "new" && !needsDecision(item));
3229
+ const ignored = items.filter((item) => item.standing === "ignored");
3230
+ const plannedKeys = new Set(
3231
+ items.flatMap((item) => item.standing === "ignored" ? [] : Object.keys(item.overrides))
3232
+ );
3233
+ const pending = new Set(undecided.map((item) => item.module));
3234
+ const droppedOverrides = plan.auditKeys.filter((key) => !plannedKeys.has(key) && !pending.has(overrideModule(key))).map((key) => [key, droppedReason(key, items, plan.bareLock)]);
3235
+ const reportedGhsas = new Set(plan.advisories.map((advisory) => advisory.ghsa));
3236
+ const fixedGhsas = new Set([...kept, ...added].flatMap((item) => item.ghsas));
3237
+ const droppedIgnores = previous.ignoredGhsas.filter((ghsa) => !reportedGhsas.has(ghsa) || fixedGhsas.has(ghsa)).map((ghsa) => [ghsa, fixedGhsas.has(ghsa) ? "a fix is published" : "no longer applies"]);
3238
+ console.info(colors_default.bold("security"));
3239
+ console.info(DIVIDER);
3240
+ if (items.length === 0 && droppedOverrides.length === 0 && droppedIgnores.length === 0) {
3241
+ console.info(`${checkMark} no vulnerabilities found`);
3242
+ console.info();
3243
+ return false;
3244
+ }
3245
+ printGroup("override kept", kept, releaseAgeHours);
3246
+ printGroup("fix available", added, releaseAgeHours);
3247
+ printGroup("needs a decision", undecided, releaseAgeHours);
3248
+ printGroup("ignored", ignored, releaseAgeHours);
3249
+ printDropped("override dropped", droppedOverrides);
3250
+ printDropped("ignore dropped", droppedIgnores);
3251
+ const lastingCount = Object.keys(plan.lasting).length;
3252
+ if (lastingCount > 0) {
3253
+ console.info(colors_default.dim(`${count(lastingCount, "override")} without a version selector left as written`));
3254
+ }
3255
+ if (items.some(isBreaking)) {
3256
+ console.info(colors_default.dim("\u26A0 a breaking jump: another major, or another minor below 1.0"));
3257
+ }
3258
+ console.info();
3259
+ return added.length + undecided.length + droppedOverrides.length + droppedIgnores.length > 0;
3519
3260
  }
3520
3261
 
3521
3262
  // src/services/audit.ts
3522
- var AUDIT_FIX_VALUE = "__audit_fix__";
3523
3263
  function createDependentPkgReader() {
3524
3264
  const cache = /* @__PURE__ */ new Map();
3525
3265
  return (location) => {
3526
3266
  let cached = cache.get(location);
3527
3267
  if (cached === void 0) {
3528
3268
  try {
3529
- cached = JSON.parse(readFileSync3(join13(location, "package.json"), "utf8"));
3269
+ cached = JSON.parse(readFileSync3(join10(location, "package.json"), "utf8"));
3530
3270
  } catch {
3531
3271
  cached = null;
3532
3272
  }
@@ -3535,106 +3275,74 @@ function createDependentPkgReader() {
3535
3275
  return cached ?? null;
3536
3276
  };
3537
3277
  }
3538
- function formatSeverityCounts(counts, suffix = "") {
3539
- const parts = [];
3540
- if (counts.critical) parts.push(colors_default.red(`${counts.critical} critical${suffix}`));
3541
- if (counts.high) parts.push(colors_default.red(`${counts.high} high${suffix}`));
3542
- if (counts.moderate) parts.push(colors_default.yellow(`${counts.moderate} moderate${suffix}`));
3543
- if (counts.low) parts.push(colors_default.blue(`${counts.low} low`));
3544
- return parts;
3545
- }
3546
3278
  async function runAudit(config) {
3547
3279
  const { forkPath } = config;
3548
- if (config.checkOverrides) {
3549
- await checkOverrides(forkPath);
3550
- return;
3551
- }
3552
- const spinner = createSpinner("checking for outdated packages & vulnerabilities...");
3280
+ const spinner = createSpinner("checking for outdated packages...");
3553
3281
  try {
3554
- if (config.force) {
3555
- spinnerText("clearing pnpm metadata cache...");
3556
- clearCache();
3557
- spawnSync2("pnpm", ["cache", "delete", "*"], {
3558
- cwd: forkPath,
3559
- stdio: "ignore"
3560
- });
3561
- spinnerText("checking for outdated packages & vulnerabilities...");
3282
+ const [outdated, auditResult] = await Promise.all([
3283
+ runPnpmJson(["-r", "outdated", "--json", "--long"], forkPath),
3284
+ runPnpmAudit(forkPath)
3285
+ ]);
3286
+ if (!outdated) {
3287
+ spinnerFail("pnpm outdated gave no answer: run pnpm -r outdated to see why");
3288
+ console.info(`${warningMark} security pass skipped: it runs after the update check`);
3289
+ return;
3562
3290
  }
3563
- const [outdatedPackages, auditResult] = await Promise.all([getOutdatedPackages(forkPath), runPnpmAudit(forkPath)]);
3291
+ const outdatedPackages = outdated;
3564
3292
  const packageNames = Object.keys(outdatedPackages);
3565
- const vulnMap = buildVulnerabilityMap(auditResult);
3566
- if (packageNames.length === 0 && vulnMap.size === 0) {
3567
- spinnerSuccess("all packages are up to date and secure");
3293
+ if (packageNames.length === 0) {
3294
+ spinnerSuccess("all packages are up to date");
3295
+ console.info();
3296
+ await runSecurityPass(forkPath, { apply: !config.list });
3568
3297
  return;
3569
3298
  }
3570
- const cache = loadCache();
3571
- spinnerText(`found ${packageNames.length} outdated package(s), fetching metadata...`);
3299
+ const advisories = auditResult ? advisoriesFromAudit(auditResult) : [];
3300
+ const severityOf = (name) => SEVERITIES.find((severity) => advisories.some((a) => a.module === name && a.severity === severity)) ?? null;
3572
3301
  const readDependentPkg = createDependentPkgReader();
3573
- const dependentNameCache = /* @__PURE__ */ new Map();
3574
- for (const name of packageNames) {
3575
- for (const dep of outdatedPackages[name].dependentPackages) {
3576
- if (!dependentNameCache.has(dep.location)) {
3577
- dependentNameCache.set(dep.location, readDependentPkg(dep.location)?.name || basename5(dep.location));
3302
+ const enhancedPackages = packageNames.map((name) => {
3303
+ const pkg = outdatedPackages[name];
3304
+ const repoUrl = getRepoUrl(pkg.latestManifest?.repository?.url);
3305
+ const pinnedIn = [];
3306
+ for (const d of pkg.dependentPackages) {
3307
+ const spec = readDependentPkg(d.location)?.[pkg.dependencyType]?.[name] || "";
3308
+ if (spec && !spec.startsWith("^") && !spec.startsWith("~") && !spec.startsWith(">")) {
3309
+ pinnedIn.push(d.name);
3578
3310
  }
3579
3311
  }
3580
- }
3581
- const enhancedPackages = [];
3582
- const batchSize = 10;
3583
- for (let i = 0; i < packageNames.length; i += batchSize) {
3584
- const batch = packageNames.slice(i, i + batchSize);
3585
- const results = await Promise.all(
3586
- batch.map(async (name) => {
3587
- const pkg = outdatedPackages[name];
3588
- const metadata = await fetchNpmMetadata(name);
3589
- const repoUrl = getRepoUrl(metadata);
3590
- const changelogUrl = await findChangelogUrl(repoUrl, name, cache);
3591
- const pinnedIn = [];
3592
- for (const d of pkg.dependentPackages) {
3593
- const spec = readDependentPkg(d.location)?.[pkg.dependencyType]?.[name] || "";
3594
- if (spec && !spec.startsWith("^") && !spec.startsWith("~") && !spec.startsWith(">")) {
3595
- pinnedIn.push(d.name);
3596
- }
3597
- }
3598
- return {
3599
- name,
3600
- current: pkg.current,
3601
- latest: pkg.latest,
3602
- dependents: pkg.dependentPackages.map((d) => d.name),
3603
- dependentLocations: pkg.dependentPackages.map((d) => d.location),
3604
- isDev: pkg.dependencyType === "devDependencies",
3605
- isMajorUpdate: isMajorVersionChange(pkg.current, pkg.latest),
3606
- pinnedIn,
3607
- repoUrl,
3608
- changelogUrl,
3609
- releasesUrl: getReleasesUrl(repoUrl),
3610
- vulnerabilities: vulnMap.get(name) || []
3611
- };
3612
- })
3613
- );
3614
- enhancedPackages.push(...results);
3615
- }
3616
- saveCache(cache);
3312
+ return {
3313
+ name,
3314
+ current: pkg.current,
3315
+ latest: pkg.latest,
3316
+ dependentPackages: pkg.dependentPackages,
3317
+ isDev: pkg.dependencyType === "devDependencies",
3318
+ isMajorUpdate: isMajorVersionChange(pkg.current, pkg.latest),
3319
+ pinnedIn,
3320
+ repoUrl,
3321
+ releasesUrl: getReleasesUrl(repoUrl),
3322
+ severity: severityOf(name)
3323
+ };
3324
+ });
3617
3325
  enhancedPackages.sort((a, b) => {
3618
- const depA = formatDependents(a.dependents);
3619
- const depB = formatDependents(b.dependents);
3620
- const depCompare = depA.localeCompare(depB);
3326
+ const depCompare = dependentsText(a).localeCompare(dependentsText(b));
3621
3327
  if (depCompare !== 0) return depCompare;
3622
3328
  return a.name.localeCompare(b.name);
3623
3329
  });
3624
3330
  spinner.stop();
3625
- if (enhancedPackages.length > 0) {
3626
- printOutdatedResults(enhancedPackages, dependentNameCache, forkPath);
3627
- }
3628
- printVulnerabilityResults(auditResult, vulnMap);
3629
- const hasVulns = vulnMap.size > 0;
3630
- if ((enhancedPackages.length > 0 || hasVulns) && !config.list) {
3631
- await promptForUpdates(enhancedPackages, forkPath, auditResult);
3331
+ printOutdatedResults(enhancedPackages, forkPath);
3332
+ console.info();
3333
+ const outcome = config.list ? "skipped" : await promptForUpdates(enhancedPackages, forkPath);
3334
+ if (outcome === "cancelled") return;
3335
+ if (outcome === "failed") {
3336
+ console.info(`${warningMark} security pass skipped: it needs a workspace that installs`);
3337
+ return;
3632
3338
  }
3339
+ await runSecurityPass(forkPath, { apply: !config.list });
3633
3340
  } finally {
3634
3341
  spinner.stop();
3635
3342
  }
3636
3343
  }
3637
- function printOutdatedResults(enhancedPackages, dependentNameCache, forkPath) {
3344
+ var dependentsText = (pkg) => formatDependents(pkg.dependentPackages.map((d) => d.name));
3345
+ function printOutdatedResults(enhancedPackages, forkPath) {
3638
3346
  const DEV_TAG = " (dev)";
3639
3347
  const MAX_NAME_LEN = 35;
3640
3348
  const MAX_DEPENDENTS_LEN = 15;
@@ -3646,7 +3354,7 @@ function printOutdatedResults(enhancedPackages, dependentNameCache, forkPath) {
3646
3354
  const maxLatestLen = Math.max(6, ...enhancedPackages.map((p) => p.latest.length));
3647
3355
  const maxDependentsLen = Math.min(
3648
3356
  MAX_DEPENDENTS_LEN,
3649
- Math.max(10, ...enhancedPackages.map((p) => formatDependents(p.dependents).length))
3357
+ Math.max(10, ...enhancedPackages.map((p) => dependentsText(p).length))
3650
3358
  );
3651
3359
  const header = [
3652
3360
  " ",
@@ -3658,20 +3366,15 @@ function printOutdatedResults(enhancedPackages, dependentNameCache, forkPath) {
3658
3366
  ].join(" ");
3659
3367
  console.info(header);
3660
3368
  for (const pkg of enhancedPackages) {
3661
- const highestSeverity = getHighestSeverity(pkg.vulnerabilities);
3662
- const vulnIndicator = highestSeverity ? getVulnIcon(highestSeverity) : " ";
3369
+ const vulnIndicator = pkg.severity ? getVulnIcon(pkg.severity) : " ";
3663
3370
  const fullName = pkg.isDev ? `${pkg.name}${DEV_TAG}` : pkg.name;
3664
3371
  const displayName = middleTruncate(fullName, maxNameLen);
3665
3372
  const name = colors_default.white(displayName.padEnd(maxNameLen));
3666
3373
  const current = colors_default.red(pkg.current.padEnd(maxCurrentLen));
3667
3374
  const latestText = pkg.latest.padEnd(maxLatestLen);
3668
3375
  const latest = pkg.isMajorUpdate ? colors_default.bold(colors_default.green(latestText)) : colors_default.green(latestText);
3669
- const dependentsText = formatDependents(pkg.dependents);
3670
- const dependents = colors_default.dim(middleTruncate(dependentsText, maxDependentsLen).padEnd(maxDependentsLen));
3376
+ const dependents = colors_default.dim(middleTruncate(dependentsText(pkg), maxDependentsLen).padEnd(maxDependentsLen));
3671
3377
  const links = [];
3672
- if (pkg.changelogUrl) {
3673
- links.push(hyperlink(colors_default.magenta("log"), pkg.changelogUrl));
3674
- }
3675
3378
  if (pkg.releasesUrl) {
3676
3379
  links.push(hyperlink(colors_default.blue("rel"), pkg.releasesUrl));
3677
3380
  }
@@ -3684,28 +3387,28 @@ function printOutdatedResults(enhancedPackages, dependentNameCache, forkPath) {
3684
3387
  console.info();
3685
3388
  const dependentMap = /* @__PURE__ */ new Map();
3686
3389
  for (const pkg of enhancedPackages) {
3687
- for (const loc of pkg.dependentLocations) {
3688
- const dependentName = dependentNameCache.get(loc) || basename5(loc);
3390
+ for (const { name, location } of pkg.dependentPackages) {
3391
+ const dependentName = name || basename2(location);
3689
3392
  const existing = dependentMap.get(dependentName);
3690
3393
  if (existing) {
3691
3394
  existing.count++;
3692
3395
  } else {
3693
- dependentMap.set(dependentName, { location: loc, count: 1 });
3396
+ dependentMap.set(dependentName, { location, count: 1 });
3694
3397
  }
3695
3398
  }
3696
3399
  }
3697
3400
  const sortedDependents = [...dependentMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
3698
3401
  const maxDependentNameLen = Math.max(0, ...sortedDependents.map(([name]) => name.length));
3699
- for (const [dependentName, { location, count }] of sortedDependents) {
3700
- const packageJsonPath2 = join13(location, "package.json");
3701
- const relativePath = relative2(forkPath, packageJsonPath2);
3402
+ for (const [dependentName, { location, count: count2 }] of sortedDependents) {
3403
+ const packageJsonPath2 = join10(location, "package.json");
3404
+ const relativePath = relative3(forkPath, packageJsonPath2);
3702
3405
  const paddedName = dependentName.padEnd(maxDependentNameLen);
3703
3406
  console.info(
3704
- ` ${colors_default.dim("\u2022")} ${paddedName} ${hyperlink(relativePath, `file://${packageJsonPath2}`)} ${colors_default.dim(`${count}`)}`
3407
+ ` ${colors_default.dim("\u2022")} ${paddedName} ${hyperlink(relativePath, `file://${packageJsonPath2}`)} ${colors_default.dim(`${count2}`)}`
3705
3408
  );
3706
3409
  }
3707
3410
  }
3708
- function buildUpdateChoices(packages, auditResult, hasVulnerabilities) {
3411
+ function buildUpdateChoices(packages) {
3709
3412
  const makeChoice = (pkg) => {
3710
3413
  const devTag = pkg.isDev ? colors_default.dim(" (dev)") : "";
3711
3414
  const version = `${colors_default.red(pkg.current)} \u2192 ${colors_default.green(pkg.latest)}`;
@@ -3719,49 +3422,26 @@ function buildUpdateChoices(packages, auditResult, hasVulnerabilities) {
3719
3422
  const pinned = packages.filter((p) => p.pinnedIn.length > 0);
3720
3423
  const regular = packages.filter((p) => p.pinnedIn.length === 0);
3721
3424
  const choices = [];
3722
- if (hasVulnerabilities) {
3723
- const parts = formatSeverityCounts(auditResult?.metadata?.vulnerabilities || {});
3724
- const vulnSummary = parts.length > 0 ? ` ${colors_default.dim("\xB7")} ${parts.join(" ")}` : "";
3725
- choices.push(new Separator(colors_default.red(`\u2500\u2500 vulnerabilities${vulnSummary} \u2500\u2500`)));
3726
- choices.push({
3727
- name: `${colors_default.cyan("pnpm audit --fix override")} ${colors_default.dim("add overrides for non-vulnerable versions")}`,
3728
- value: AUDIT_FIX_VALUE,
3729
- checked: false
3730
- });
3731
- }
3732
3425
  if (pinned.length > 0) {
3733
3426
  choices.push(new Separator(colors_default.yellow(`\u2500\u2500 pinned (${pinned.length}) \u2500\u2500`)));
3734
3427
  choices.push(...pinned.map(makeChoice));
3735
3428
  }
3736
3429
  if (regular.length > 0) {
3737
- if (pinned.length > 0 || hasVulnerabilities) {
3738
- choices.push(new Separator(colors_default.dim(`\u2500\u2500 packages (${regular.length}) \u2500\u2500`)));
3739
- }
3430
+ if (pinned.length > 0) choices.push(new Separator(colors_default.dim(`\u2500\u2500 packages (${regular.length}) \u2500\u2500`)));
3740
3431
  choices.push(...regular.map(makeChoice));
3741
3432
  }
3742
3433
  return choices;
3743
3434
  }
3744
- async function promptForUpdates(packages, forkPath, auditResult) {
3745
- const hasVulnerabilities = auditResult !== null && Object.values(auditResult.metadata?.vulnerabilities || {}).some((c) => c > 0);
3746
- const hasPackages = packages.length > 0;
3747
- let confirmMessage = "update packages?";
3748
- if (hasVulnerabilities && hasPackages) {
3749
- confirmMessage = "update packages and fix vulnerabilities?";
3750
- } else if (hasVulnerabilities) {
3751
- confirmMessage = "fix vulnerabilities?";
3752
- }
3435
+ async function promptForUpdates(packages, forkPath) {
3753
3436
  let wantsUpdate;
3754
3437
  try {
3755
- wantsUpdate = await confirm({
3756
- message: confirmMessage,
3757
- default: true
3758
- });
3438
+ wantsUpdate = await confirm2({ message: "update packages?", default: true });
3759
3439
  } catch {
3760
- return;
3440
+ return "cancelled";
3761
3441
  }
3762
- if (!wantsUpdate) return;
3763
3442
  console.info();
3764
- const choices = buildUpdateChoices(packages, auditResult, hasVulnerabilities);
3443
+ if (!wantsUpdate) return "skipped";
3444
+ const choices = buildUpdateChoices(packages);
3765
3445
  let selected;
3766
3446
  try {
3767
3447
  selected = await checkbox({
@@ -3785,37 +3465,29 @@ async function promptForUpdates(packages, forkPath, auditResult) {
3785
3465
  }
3786
3466
  });
3787
3467
  } catch {
3788
- return;
3468
+ return "cancelled";
3789
3469
  }
3790
- const runAuditFix = selected.includes(AUDIT_FIX_VALUE);
3791
- const selectedPackages = selected.filter((s) => s !== AUDIT_FIX_VALUE);
3792
- await executeUpdates(packages, forkPath, selectedPackages, runAuditFix);
3470
+ return executeUpdates(packages, forkPath, selected);
3793
3471
  }
3794
- async function executeUpdates(packages, forkPath, selectedPackages, runAuditFix) {
3795
- const summaryParts = [];
3796
- if (selectedPackages.length > 0) {
3797
- summaryParts.push(`updating ${selectedPackages.length} package${selectedPackages.length !== 1 ? "s" : ""}`);
3798
- }
3799
- if (runAuditFix) {
3800
- summaryParts.push("then updating dependencies");
3801
- }
3472
+ async function executeUpdates(packages, forkPath, selectedPackages) {
3802
3473
  console.info();
3803
- console.info(colors_default.green(`\u2713 ${summaryParts.join(" ")}`));
3474
+ console.info(colors_default.green(`\u2713 updating ${selectedPackages.length} package${selectedPackages.length !== 1 ? "s" : ""}`));
3804
3475
  console.info();
3805
3476
  const selectedMajor = packages.filter((p) => selectedPackages.includes(p.name) && p.isMajorUpdate);
3806
3477
  if (selectedMajor.length > 0) {
3807
3478
  const majorNames = selectedMajor.map((p) => colors_default.bold(p.name)).join(", ");
3808
3479
  try {
3809
- const proceed = await confirm({
3480
+ const proceed = await confirm2({
3810
3481
  message: `${selectedMajor.length} major update${selectedMajor.length > 1 ? "s" : ""} selected (${majorNames}). proceed?`,
3811
3482
  default: true
3812
3483
  });
3813
3484
  if (!proceed) {
3814
3485
  console.info(colors_default.dim(" update cancelled"));
3815
- return;
3486
+ console.info();
3487
+ return "skipped";
3816
3488
  }
3817
3489
  } catch {
3818
- return;
3490
+ return "cancelled";
3819
3491
  }
3820
3492
  }
3821
3493
  const catalogPackages = readCatalogPackageNames(forkPath);
@@ -3823,194 +3495,49 @@ async function executeUpdates(packages, forkPath, selectedPackages, runAuditFix)
3823
3495
  for (const pkgName of selectedPackages) {
3824
3496
  const pkg = packages.find((p) => p.name === pkgName);
3825
3497
  if (!pkg) continue;
3826
- for (const dependent of pkg.dependents) {
3498
+ for (const { name: dependent } of pkg.dependentPackages) {
3827
3499
  const group = workspacePackages.get(dependent);
3828
3500
  if (group) group.push(pkgName);
3829
3501
  else workspacePackages.set(dependent, [pkgName]);
3830
3502
  }
3831
3503
  }
3832
- if (runAuditFix) {
3833
- console.info(colors_default.dim("running pnpm audit --fix override..."));
3834
- const auditFixResult = spawnSync2("pnpm", ["audit", "--fix", "override"], {
3835
- cwd: forkPath,
3836
- stdio: "inherit"
3837
- });
3838
- console.info();
3839
- if (auditFixResult.status === 0) {
3840
- console.info(colors_default.green("\u2713 pnpm audit --fix override completed"));
3841
- } else {
3842
- console.error(colors_default.red(`\u2717 pnpm audit --fix override failed (exit code ${auditFixResult.status})`));
3843
- }
3844
- }
3845
3504
  for (const [workspace, pkgs] of workspacePackages) {
3846
3505
  const pkgList = pkgs.join(", ");
3847
3506
  console.info(`${colors_default.cyan("\u2191")} ${colors_default.bold(workspace)}: ${colors_default.dim(pkgList)}`);
3848
3507
  }
3849
- if (selectedPackages.length > 0) {
3850
- console.info();
3851
- const selectedCatalogPackages = selectedPackages.filter((pkgName) => catalogPackages.has(pkgName));
3852
- if (selectedCatalogPackages.length > 0) {
3853
- console.info(colors_default.dim(`normalizing catalog consumers: ${selectedCatalogPackages.join(", ")}`));
3854
- const normalizeResult = spawnSync2("pnpm", ["-r", "up", ...selectedCatalogPackages], {
3855
- cwd: forkPath,
3856
- stdio: "inherit"
3857
- });
3858
- console.info();
3859
- if (normalizeResult.status !== 0) {
3860
- console.error(colors_default.red(`\u2717 catalog normalization failed (exit code ${normalizeResult.status})`));
3861
- return;
3862
- }
3863
- }
3864
- const result = spawnSync2("pnpm", ["-r", "up", "--latest", ...selectedPackages], {
3508
+ console.info();
3509
+ const selectedCatalogPackages = selectedPackages.filter((pkgName) => catalogPackages.has(pkgName));
3510
+ if (selectedCatalogPackages.length > 0) {
3511
+ console.info(colors_default.dim(`normalizing catalog consumers: ${selectedCatalogPackages.join(", ")}`));
3512
+ const normalizeResult = spawnSync3("pnpm", ["-r", "up", ...selectedCatalogPackages], {
3865
3513
  cwd: forkPath,
3866
3514
  stdio: "inherit"
3867
3515
  });
3868
3516
  console.info();
3869
- if (result.status === 0) {
3870
- console.info(colors_default.green(`\u2713 ${selectedPackages.length} package${selectedPackages.length > 1 ? "s" : ""} updated`));
3871
- } else {
3872
- console.error(colors_default.red(`\u2717 update failed (exit code ${result.status})`));
3517
+ if (normalizeResult.status !== 0) {
3518
+ console.error(colors_default.red(`\u2717 catalog normalization failed (exit code ${normalizeResult.status})`));
3519
+ return "failed";
3873
3520
  }
3874
3521
  }
3875
- }
3876
- function printVulnerabilityResults(auditResult, vulnMap) {
3877
- if (auditResult && vulnMap.size > 0) {
3878
- const vulnMeta = auditResult.metadata?.vulnerabilities || {};
3879
- console.info();
3880
- console.info(
3881
- `${colors_default.red("\u26A0")} ${colors_default.bold("vulnerabilities")} ${colors_default.dim("\xB7")} ${formatSeverityCounts(vulnMeta, " ").join("")}`
3882
- );
3883
- console.info(DIVIDER);
3884
- for (const [pkgName, vulns] of vulnMap.entries()) {
3885
- for (const vuln of vulns) {
3886
- const severityIcon = getVulnIcon(vuln.severity);
3887
- const cveStr = vuln.cves.length > 0 ? colors_default.dim(` ${vuln.cves[0]}`) : "";
3888
- let sourceStr = "";
3889
- if (vuln.workspace) {
3890
- sourceStr = colors_default.dim(` in ${vuln.workspace}`);
3891
- if (vuln.directDependency) {
3892
- sourceStr += colors_default.dim(` via ${vuln.directDependency}`);
3893
- }
3894
- }
3895
- const title = middleTruncate(vuln.title, 60);
3896
- console.info(
3897
- `${severityIcon} ${colors_default.white(pkgName)} ${colors_default.dim(vuln.vulnerableVersions)}${sourceStr} ${colors_default.dim("\xB7")} ${title}${cveStr}`
3898
- );
3899
- }
3900
- }
3901
- } else {
3902
- console.info();
3903
- console.info(`${checkMark} no vulnerabilities found`);
3522
+ const result = spawnSync3("pnpm", ["-r", "up", "--latest", ...selectedPackages], { cwd: forkPath, stdio: "inherit" });
3523
+ console.info();
3524
+ if (result.status !== 0) {
3525
+ console.error(colors_default.red(`\u2717 update failed (exit code ${result.status})`));
3526
+ return "failed";
3904
3527
  }
3528
+ console.info(colors_default.green(`\u2713 ${selectedPackages.length} package${selectedPackages.length > 1 ? "s" : ""} updated`));
3905
3529
  console.info();
3530
+ return "updated";
3906
3531
  }
3907
3532
  function readCatalogPackageNames(forkPath) {
3908
- const workspacePath = join13(forkPath, "pnpm-workspace.yaml");
3533
+ const workspacePath = join10(forkPath, "pnpm-workspace.yaml");
3909
3534
  try {
3910
3535
  const content = readFileSync3(workspacePath, "utf8");
3911
- return new Set(Object.keys(parseYamlBlockMap(content, "catalog")));
3536
+ return new Set(Object.keys(readTopLevel(content, "catalog") ?? {}));
3912
3537
  } catch {
3913
3538
  return /* @__PURE__ */ new Set();
3914
3539
  }
3915
3540
  }
3916
- function readOverrides(forkPath) {
3917
- const overrides = /* @__PURE__ */ new Map();
3918
- const pkgJsonPath = join13(forkPath, "package.json");
3919
- try {
3920
- const pkgJson = JSON.parse(readFileSync3(pkgJsonPath, "utf8"));
3921
- const pnpmOverrides = pkgJson.pnpm?.overrides || {};
3922
- for (const [key, value] of Object.entries(pnpmOverrides)) {
3923
- overrides.set(key, { target: value, source: "package.json" });
3924
- }
3925
- } catch {
3926
- }
3927
- const workspacePath = join13(forkPath, "pnpm-workspace.yaml");
3928
- try {
3929
- const content = readFileSync3(workspacePath, "utf8");
3930
- const wsOverrides = parseYamlBlockMap(content, "overrides");
3931
- for (const [key, value] of Object.entries(wsOverrides)) {
3932
- overrides.set(key, { target: value, source: "pnpm-workspace.yaml" });
3933
- }
3934
- } catch {
3935
- }
3936
- return overrides;
3937
- }
3938
- async function checkOverrides(forkPath) {
3939
- const spinner = createSpinner("checking overrides against current audit...");
3940
- const overrides = readOverrides(forkPath);
3941
- if (overrides.size === 0) {
3942
- spinnerSuccess("no pnpm.overrides found");
3943
- return;
3944
- }
3945
- const auditResult = await runPnpmAudit(forkPath);
3946
- const activeAdvisoryPackages = /* @__PURE__ */ new Set();
3947
- if (auditResult?.advisories) {
3948
- for (const advisory of Object.values(auditResult.advisories)) {
3949
- activeAdvisoryPackages.add(advisory.module_name);
3950
- }
3951
- }
3952
- spinner.stop();
3953
- const securityOverrides = [];
3954
- const pinOverrides = [];
3955
- for (const [key, { target, source }] of overrides) {
3956
- const atIndex = key.indexOf("@", key.startsWith("@") ? 1 : 0);
3957
- if (atIndex > 0) {
3958
- const pkg = key.slice(0, atIndex);
3959
- const hasActiveAdvisory = activeAdvisoryPackages.has(pkg);
3960
- const status = hasActiveAdvisory ? "active" : "likely stale";
3961
- securityOverrides.push({ key, pkg, target, source, status });
3962
- } else {
3963
- pinOverrides.push({ key, target, source });
3964
- }
3965
- }
3966
- const allKeys = Array.from(overrides.keys());
3967
- const allValues = Array.from(overrides.values());
3968
- const maxKeyLen = Math.max(8, ...allKeys.map((k) => k.length));
3969
- const maxTargetLen = Math.max(6, ...allValues.map((v) => v.target.length));
3970
- if (securityOverrides.length > 0) {
3971
- console.info(colors_default.bold(`security overrides (${securityOverrides.length})`));
3972
- console.info(DIVIDER);
3973
- for (const o of securityOverrides) {
3974
- const icon = o.status === "active" ? colors_default.red("\u25CF") : colors_default.green("\u25CB");
3975
- const statusText = o.status === "active" ? colors_default.red("active") : colors_default.green("likely stale");
3976
- const keyText = colors_default.white(o.key.padEnd(maxKeyLen));
3977
- const targetText = colors_default.dim(o.target.padEnd(maxTargetLen));
3978
- const sourceText = colors_default.dim(o.source);
3979
- console.info(` ${icon} ${keyText} \u2192 ${targetText} ${statusText} ${sourceText}`);
3980
- }
3981
- console.info();
3982
- }
3983
- if (pinOverrides.length > 0) {
3984
- console.info(colors_default.bold(`version pins (${pinOverrides.length})`));
3985
- console.info(DIVIDER);
3986
- for (const o of pinOverrides) {
3987
- const keyText = colors_default.white(o.key.padEnd(maxKeyLen));
3988
- const targetText = colors_default.dim(o.target.padEnd(maxTargetLen));
3989
- const sourceText = colors_default.dim(o.source);
3990
- console.info(` ${colors_default.blue("\u25C6")} ${keyText} \u2192 ${targetText} ${sourceText}`);
3991
- }
3992
- console.info();
3993
- }
3994
- const staleCount = securityOverrides.filter((o) => o.status === "likely stale").length;
3995
- const activeCount = securityOverrides.filter((o) => o.status === "active").length;
3996
- if (staleCount > 0) {
3997
- console.info(
3998
- `${colors_default.green("\u25CB")} ${staleCount} override${staleCount !== 1 ? "s" : ""} likely stale: no matching advisory found in current audit`
3999
- );
4000
- console.info(colors_default.dim(" overrides mask vulnerabilities: verify by temporarily removing and running pnpm audit"));
4001
- }
4002
- if (activeCount > 0) {
4003
- console.info(
4004
- `${colors_default.red("\u25CF")} ${activeCount} override${activeCount !== 1 ? "s" : ""} still active: advisory present without override`
4005
- );
4006
- }
4007
- if (pinOverrides.length > 0) {
4008
- console.info(
4009
- `${colors_default.blue("\u25C6")} ${pinOverrides.length} version pin${pinOverrides.length !== 1 ? "s" : ""}: not security-related, review manually`
4010
- );
4011
- }
4012
- console.info();
4013
- }
4014
3541
 
4015
3542
  // src/services/contributions.ts
4016
3543
  import {
@@ -4023,10 +3550,113 @@ import {
4023
3550
  usePagination,
4024
3551
  useState
4025
3552
  } from "@inquirer/core";
4026
- import { select as select2 } from "@inquirer/prompts";
3553
+ import { select as select3 } from "@inquirer/prompts";
3554
+
3555
+ // src/utils/diff.ts
3556
+ import { execFileSync as execFileSync2 } from "child_process";
3557
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
3558
+ import { tmpdir as tmpdir2 } from "os";
3559
+ import { basename as basename3, join as join11 } from "path";
3560
+ import process6 from "process";
3561
+ function gitDiffFile(cwd, range, filePath, options = {}) {
3562
+ const args = ["diff", "--no-color"];
3563
+ if (options.dstPrefix) args.push("--src-prefix=cella/", `--dst-prefix=${options.dstPrefix}/`);
3564
+ args.push(range, "--", filePath);
3565
+ try {
3566
+ return execFileSync2("git", args, { cwd, maxBuffer: MAX_BUFFER });
3567
+ } catch (error) {
3568
+ const stderr = error.stderr?.toString().trim();
3569
+ throw new Error(stderr || `failed to diff ${filePath}`);
3570
+ }
3571
+ }
3572
+ function escapeHtml(text) {
3573
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3574
+ }
3575
+ async function renderDiffPage(patch, meta) {
3576
+ const { preloadPatchDiff } = await import("@pierre/diffs/ssr");
3577
+ const { prerenderedHTML } = await preloadPatchDiff({
3578
+ patch,
3579
+ options: {
3580
+ diffStyle: "split",
3581
+ themeType: "system",
3582
+ lineDiffType: "word-alt",
3583
+ hunkSeparators: "line-info",
3584
+ overflow: "wrap",
3585
+ disableFileHeader: true
3586
+ }
3587
+ });
3588
+ const title = `${escapeHtml(meta.filePath)} \xB7 ${escapeHtml(meta.srcLabel)} vs ${escapeHtml(meta.dstLabel)}`;
3589
+ const note = meta.note ? `<span class="note">\xB7 ${escapeHtml(meta.note)}</span>` : "";
3590
+ return `<!doctype html>
3591
+ <html lang="en">
3592
+ <head>
3593
+ <meta charset="utf-8">
3594
+ <meta name="viewport" content="width=device-width, initial-scale=1">
3595
+ <title>${title}</title>
3596
+ <style>
3597
+ :root { color-scheme: light dark; }
3598
+ * { box-sizing: border-box; }
3599
+ body {
3600
+ margin: 0;
3601
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
3602
+ background: light-dark(#fafafa, #111);
3603
+ color: light-dark(#111, #eee);
3604
+ }
3605
+ header {
3606
+ display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap;
3607
+ padding: 0.9rem 1.25rem;
3608
+ border-bottom: 1px solid light-dark(#e2e2e2, #2a2a2a);
3609
+ position: sticky; top: 0;
3610
+ background: inherit;
3611
+ }
3612
+ header .brand { font-weight: 600; color: light-dark(#0891b2, #22d3ee); }
3613
+ header code { font-family: ui-monospace, monospace; font-size: 0.9rem; }
3614
+ header .sides { margin-left: auto; font-size: 0.8rem; opacity: 0.7; }
3615
+ main { max-width: 1400px; margin: 1rem auto; padding: 0 1rem 2rem; }
3616
+ .diff-shell {
3617
+ border: 1px solid light-dark(#e2e2e2, #2a2a2a);
3618
+ border-radius: 8px; overflow: hidden;
3619
+ background: light-dark(#fff, #161616);
3620
+ }
3621
+ </style>
3622
+ </head>
3623
+ <body>
3624
+ <header>
3625
+ <span class="brand">\u29C8 cella diff</span>
3626
+ <code>${escapeHtml(meta.filePath)}</code>
3627
+ <span class="sides">${escapeHtml(meta.srcLabel)} \u2190 \u2192 ${escapeHtml(meta.dstLabel)} ${note}</span>
3628
+ </header>
3629
+ <main>
3630
+ <!-- Declarative shadow DOM: the library's CSS targets :host, which only
3631
+ matches inside a shadow root (its browser component renders into one). -->
3632
+ <div class="diff-shell">
3633
+ <template shadowrootmode="open">
3634
+ ${prerenderedHTML}
3635
+ </template>
3636
+ </div>
3637
+ </main>
3638
+ </body>
3639
+ </html>`;
3640
+ }
3641
+ function openWithDefaultApp(target) {
3642
+ try {
3643
+ execFileSync2(process6.platform === "darwin" ? "open" : "xdg-open", [target], { stdio: "ignore" });
3644
+ } catch {
3645
+ }
3646
+ }
3647
+ async function openDiffInBrowser(patch, meta, repoPath) {
3648
+ const html = await renderDiffPage(patch, meta);
3649
+ const dir = join11(tmpdir2(), `cella-diff-${basename3(repoPath)}`);
3650
+ mkdirSync2(dir, { recursive: true });
3651
+ const outPath = join11(dir, `${meta.filePath.replace(/[^\w.-]+/g, "-")}.html`);
3652
+ writeFileSync2(outPath, html);
3653
+ openWithDefaultApp(outPath);
3654
+ return outPath;
3655
+ }
4027
3656
 
4028
3657
  // src/services/contrib-core.ts
4029
- import { dirname as dirname5, join as join14, resolve as resolve5 } from "path";
3658
+ import { rmSync as rmSync2 } from "fs";
3659
+ import { dirname as dirname6, join as join12, resolve as resolve4 } from "path";
4030
3660
  async function listFilesAtRef(repoPath, ref) {
4031
3661
  const raw = await git(["ls-tree", "-r", "--name-only", ref], repoPath);
4032
3662
  return new Set(raw.split("\n").filter(Boolean));
@@ -4042,10 +3672,10 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
4042
3672
  const baseFiles = await listFilesAtRef(repoPath, baseRef);
4043
3673
  const baseDirs = /* @__PURE__ */ new Set(["."]);
4044
3674
  for (const f of baseFiles) {
4045
- let dir = dirname5(f);
3675
+ let dir = dirname6(f);
4046
3676
  while (dir !== ".") {
4047
3677
  baseDirs.add(dir);
4048
- dir = dirname5(dir);
3678
+ dir = dirname6(dir);
4049
3679
  }
4050
3680
  }
4051
3681
  const files = [];
@@ -4058,7 +3688,7 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
4058
3688
  if (inCella && !inFork) {
4059
3689
  kind = "deleted";
4060
3690
  } else if (!inCella && inFork) {
4061
- if (!baseDirs.has(dirname5(file.path))) continue;
3691
+ if (!baseDirs.has(dirname6(file.path))) continue;
4062
3692
  kind = "created";
4063
3693
  } else {
4064
3694
  kind = "modified";
@@ -4067,15 +3697,7 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
4067
3697
  const changedTs = file.status === "diverged" ? file.upstreamChangedTs : file.changedTs;
4068
3698
  files.push({ path: file.path, kind, status: file.status, changedAt, changedTs });
4069
3699
  }
4070
- return {
4071
- modified: files.filter((f) => f.kind === "modified").map((f) => f.path),
4072
- created: files.filter((f) => f.kind === "created").map((f) => f.path),
4073
- deleted: files.filter((f) => f.kind === "deleted").map((f) => f.path),
4074
- files
4075
- };
4076
- }
4077
- function countDetection(detection) {
4078
- return detection.modified.length + detection.created.length + detection.deleted.length;
3700
+ return files;
4079
3701
  }
4080
3702
  async function readBlobInfo(repoPath, ref, filePath) {
4081
3703
  const line = await git(["ls-tree", ref, "--", filePath], repoPath, { ignoreErrors: true });
@@ -4087,29 +3709,25 @@ async function readBlobInfo(repoPath, ref, filePath) {
4087
3709
  if (mode !== "100644" && mode !== "100755") return null;
4088
3710
  return { mode, hash };
4089
3711
  }
4090
- async function buildContribBranch(repoPath, baseRef, forkRef, detection, forkName) {
3712
+ async function buildContribBranch(repoPath, baseRef, forkRef, files, forkName) {
4091
3713
  const branch = `contrib/${forkName}`;
4092
- const gitDir = resolve5(repoPath, await git(["rev-parse", "--git-dir"], repoPath));
4093
- const tmpIndex = join14(gitDir, `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
3714
+ const gitDir = resolve4(repoPath, await git(["rev-parse", "--git-dir"], repoPath));
3715
+ const tmpIndex = join12(gitDir, `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
4094
3716
  const indexEnv = { GIT_INDEX_FILE: tmpIndex };
4095
3717
  try {
4096
3718
  await git(["read-tree", baseRef], repoPath, { env: indexEnv });
4097
3719
  const appliedFiles = [];
4098
- for (const filePath of [...detection.modified, ...detection.created]) {
4099
- const blob = await readBlobInfo(repoPath, forkRef, filePath);
3720
+ for (const { path, kind } of files) {
3721
+ if (kind === "deleted") {
3722
+ await git(["update-index", "--remove", path], repoPath, { env: indexEnv, ignoreErrors: true });
3723
+ appliedFiles.push(`(deleted) ${path}`);
3724
+ continue;
3725
+ }
3726
+ const blob = await readBlobInfo(repoPath, forkRef, path);
4100
3727
  if (!blob) continue;
4101
- await git(
4102
- ["update-index", "--add", "--replace", "--cacheinfo", `${blob.mode},${blob.hash},${filePath}`],
4103
- repoPath,
4104
- {
4105
- env: indexEnv
4106
- }
4107
- );
4108
- appliedFiles.push(filePath);
4109
- }
4110
- for (const filePath of detection.deleted) {
4111
- await git(["update-index", "--remove", filePath], repoPath, { env: indexEnv, ignoreErrors: true });
4112
- appliedFiles.push(`(deleted) ${filePath}`);
3728
+ const cacheinfo = `${blob.mode},${blob.hash},${path}`;
3729
+ await git(["update-index", "--add", "--replace", "--cacheinfo", cacheinfo], repoPath, { env: indexEnv });
3730
+ appliedFiles.push(path);
4113
3731
  }
4114
3732
  if (appliedFiles.length === 0) {
4115
3733
  return { branch, appliedFiles };
@@ -4124,29 +3742,25 @@ ${commitBody}`;
4124
3742
  await git(["update-ref", `refs/heads/${branch}`, commitHash], repoPath);
4125
3743
  return { branch, appliedFiles };
4126
3744
  } finally {
4127
- try {
4128
- const { unlinkSync: unlinkSync2 } = await import("fs");
4129
- unlinkSync2(tmpIndex);
4130
- } catch {
4131
- }
3745
+ rmSync2(tmpIndex, { force: true });
4132
3746
  }
4133
3747
  }
4134
3748
 
4135
3749
  // src/services/fork-utils.ts
4136
- import { existsSync as existsSync9 } from "fs";
4137
- import { dirname as dirname6, resolve as resolve6 } from "path";
3750
+ import { existsSync as existsSync8 } from "fs";
3751
+ import { dirname as dirname7, resolve as resolve5 } from "path";
4138
3752
  async function resolveForkBasePath(repoPath) {
4139
3753
  const commonDir = await git(["rev-parse", "--git-common-dir"], repoPath, { ignoreErrors: true });
4140
- return commonDir ? dirname6(resolve6(repoPath, commonDir)) : repoPath;
3754
+ return commonDir ? dirname7(resolve5(repoPath, commonDir)) : repoPath;
4141
3755
  }
4142
3756
  function validateForkPath(fork, basePath, requireConfig = false) {
4143
- const resolvedPath = resolve6(basePath, fork.localPath);
4144
- if (!resolvedPath.startsWith(resolve6(basePath, ".."))) {
3757
+ const resolvedPath = resolve5(basePath, fork.localPath);
3758
+ if (!resolvedPath.startsWith(resolve5(basePath, ".."))) {
4145
3759
  return { fork, resolvedPath, valid: false, error: "path resolves outside parent directory" };
4146
3760
  }
4147
- if (!existsSync9(resolvedPath)) return { fork, resolvedPath, valid: false, error: "path does not exist" };
4148
- if (!existsSync9(`${resolvedPath}/.git`)) return { fork, resolvedPath, valid: false, error: "not a git repository" };
4149
- if (requireConfig && !existsSync9(`${resolvedPath}/${CONFIG_FILE}`)) {
3761
+ if (!existsSync8(resolvedPath)) return { fork, resolvedPath, valid: false, error: "path does not exist" };
3762
+ if (!existsSync8(`${resolvedPath}/.git`)) return { fork, resolvedPath, valid: false, error: "not a git repository" };
3763
+ if (requireConfig && !existsSync8(`${resolvedPath}/${CONFIG_FILE}`)) {
4150
3764
  return { fork, resolvedPath, valid: false, error: `missing ${CONFIG_FILE}` };
4151
3765
  }
4152
3766
  return { fork, resolvedPath, valid: true };
@@ -4158,13 +3772,8 @@ function printNoForksHint(action) {
4158
3772
  }
4159
3773
 
4160
3774
  // src/services/contributions.ts
4161
- async function showContribDiff(item, baseRef, cwd, forkName) {
4162
- const patch = gitDiffFile(cwd, `${baseRef}..${item.ref}`, item.path);
4163
- if (patch.length === 0) return null;
4164
- return openDiffInBrowser(patch.toString(), { filePath: item.path, srcLabel: "cella", dstLabel: forkName }, cwd);
4165
- }
4166
3775
  var contribPrompt = createPrompt((config, done) => {
4167
- const { pageSize = 20, baseRef, cwd, forkName } = config;
3776
+ const { pageSize = 20, baseRef, branch, cwd, forkName } = config;
4168
3777
  const [items, setItems] = useState(config.items);
4169
3778
  const [active, setActive] = useState(0);
4170
3779
  const [statusMsg, setStatusMsg] = useState("");
@@ -4218,9 +3827,12 @@ var contribPrompt = createPrompt((config, done) => {
4218
3827
  return;
4219
3828
  }
4220
3829
  if (key.name === "d") {
3830
+ const { path } = items[active];
4221
3831
  try {
4222
- const pagePath = await showContribDiff(items[active], baseRef, cwd, forkName);
4223
- setStatusMsg(pagePath ? `opened ${items[active].path} in browser` : `no changes for ${items[active].path}`);
3832
+ const patch = gitDiffFile(cwd, `${baseRef}..${branch}`, path);
3833
+ if (patch.length === 0) return setStatusMsg(`no changes for ${path}`);
3834
+ await openDiffInBrowser(patch.toString(), { filePath: path, srcLabel: "cella", dstLabel: forkName }, cwd);
3835
+ setStatusMsg(`opened ${path} in browser`);
4224
3836
  } catch (error) {
4225
3837
  setStatusMsg(`diff failed: ${errorMessage(error)}`);
4226
3838
  }
@@ -4250,8 +3862,8 @@ var contribPrompt = createPrompt((config, done) => {
4250
3862
  renderItem({ item, isActive }) {
4251
3863
  const checkbox2 = item.checked ? colors_default.green("\u25CF") : colors_default.dim("\u25CB");
4252
3864
  const cursor = isActive ? colors_default.cyan("\u276F") : " ";
4253
- const newLabel = item.created ? colors_default.yellow(" \xB7 not in cella") : "";
4254
- const delLabel = item.deleted ? colors_default.yellow(" [del]") : "";
3865
+ const newLabel = item.kind === "created" ? colors_default.yellow(" \xB7 not in cella") : "";
3866
+ const delLabel = item.kind === "deleted" ? colors_default.yellow(" [del]") : "";
4255
3867
  const statusTag = item.status === "diverged" ? colors_default.yellow(" \u26A0 diverged") : "";
4256
3868
  const dateLabel = item.changedAt ? colors_default.dim(` \xB7 ${item.changedAt}`) : "";
4257
3869
  const line = `${cursor} ${checkbox2} ${item.path}${delLabel}${statusTag}${newLabel}${dateLabel}`;
@@ -4283,14 +3895,10 @@ async function selectFork(config, validated) {
4283
3895
  }
4284
3896
  return match;
4285
3897
  }
4286
- const validForks = validated.filter((v) => v.valid);
4287
- if (config.list || config.json || config.diff) {
4288
- if (validForks.length === 0) {
4289
- throw new Error("no valid forks configured");
4290
- }
4291
- if (validForks.length > 1) {
4292
- throw new Error("multiple forks configured; pass --fork <name> to choose one");
4293
- }
3898
+ if (config.json) {
3899
+ const validForks = validated.filter((v) => v.valid);
3900
+ if (validForks.length === 0) throw new Error("no valid forks configured");
3901
+ if (validForks.length > 1) throw new Error("multiple forks configured; pass --fork <name> to choose one");
4294
3902
  return validForks[0];
4295
3903
  }
4296
3904
  const choices = validated.map((v) => ({
@@ -4298,7 +3906,7 @@ async function selectFork(config, validated) {
4298
3906
  name: v.valid ? `${v.fork.name} ${colors_default.dim(`[${v.fork.pullBranch}] ${v.fork.localPath}`)}` : `${v.fork.name} ${colors_default.dim(v.fork.localPath)}`,
4299
3907
  disabled: v.valid ? false : v.error ?? "invalid"
4300
3908
  }));
4301
- const picked = await select2({
3909
+ const picked = await select3({
4302
3910
  message: "select fork to pull contributions from:",
4303
3911
  choices,
4304
3912
  loop: false
@@ -4307,10 +3915,6 @@ async function selectFork(config, validated) {
4307
3915
  if (!selected) console.info(colors_default.dim("no fork selected."));
4308
3916
  return selected;
4309
3917
  }
4310
- async function fetchForkBranch(cellaPath, fetchSource, pullBranch) {
4311
- await git(["fetch", fetchSource, pullBranch], cellaPath);
4312
- return git(["rev-parse", "FETCH_HEAD"], cellaPath);
4313
- }
4314
3918
  async function forkRefMeta(cellaPath, forkRef) {
4315
3919
  const out = await git(["log", "-1", "--format=%h%x09%cr", forkRef], cellaPath, { ignoreErrors: true });
4316
3920
  const [sha = forkRef.slice(0, 7), date = "unknown"] = out.split(" ");
@@ -4341,9 +3945,10 @@ async function runContributions(config) {
4341
3945
  createSpinner("pulling fork contributions...");
4342
3946
  const allItems = [];
4343
3947
  let forkBanner = null;
3948
+ let branch = "";
4344
3949
  try {
4345
- const fetchSource = fork.remoteUrl ?? resolvedPath;
4346
- const forkRef = await fetchForkBranch(config.forkPath, fetchSource, fork.pullBranch);
3950
+ await git(["fetch", fork.remoteUrl ?? resolvedPath, fork.pullBranch], config.forkPath);
3951
+ const forkRef = await git(["rev-parse", "FETCH_HEAD"], config.forkPath);
4347
3952
  const meta = await forkRefMeta(config.forkPath, forkRef);
4348
3953
  forkBanner = { pullBranch: fork.pullBranch, sha: meta.sha, date: meta.date };
4349
3954
  let forkTerritory = [];
@@ -4352,29 +3957,16 @@ async function runContributions(config) {
4352
3957
  forkTerritory = forkConfig.overrides?.ignored ?? [];
4353
3958
  } catch {
4354
3959
  }
4355
- const detection = await detectContributableFiles(config.forkPath, baseRef, forkRef, config, forkTerritory);
4356
- if (countDetection(detection) > 0) {
4357
- const { branch, appliedFiles } = await buildContribBranch(config.forkPath, baseRef, forkRef, detection, forkName);
4358
- if (appliedFiles.length > 0) {
3960
+ const files = await detectContributableFiles(config.forkPath, baseRef, forkRef, config, forkTerritory);
3961
+ if (files.length > 0) {
3962
+ const built = await buildContribBranch(config.forkPath, baseRef, forkRef, files, forkName);
3963
+ if (built.appliedFiles.length > 0) {
3964
+ branch = built.branch;
4359
3965
  const stat = await getDiffStat(config.forkPath, baseRef, branch);
4360
- const metaByPath = new Map(detection.files.map((f) => [f.path, f]));
4361
- const toItem = (path, deleted) => {
4362
- const fileMeta = metaByPath.get(path);
4363
- return {
4364
- path,
4365
- ref: branch,
4366
- deleted,
4367
- ...deleted ? {} : { created: fileMeta?.kind === "created" },
4368
- status: fileMeta?.status,
4369
- changedAt: fileMeta?.changedAt,
4370
- changedTs: fileMeta?.changedTs,
4371
- additions: stat.get(path)?.additions ?? null,
4372
- deletions: stat.get(path)?.deletions ?? null,
4373
- checked: false
4374
- };
4375
- };
4376
- for (const path of [...detection.modified, ...detection.created]) allItems.push(toItem(path, false));
4377
- for (const path of detection.deleted) allItems.push(toItem(path, true));
3966
+ for (const file of files) {
3967
+ const { additions = null, deletions = null } = stat.get(file.path) ?? {};
3968
+ allItems.push({ ...file, additions, deletions, checked: false });
3969
+ }
4378
3970
  }
4379
3971
  }
4380
3972
  } catch (error) {
@@ -4388,45 +3980,25 @@ async function runContributions(config) {
4388
3980
  }
4389
3981
  allItems.sort((a, b) => (b.changedTs ?? 0) - (a.changedTs ?? 0) || a.path.localeCompare(b.path));
4390
3982
  spinnerSuccess(`${allItems.length} files from ${forkName}`);
4391
- if (!config.list && !config.diff && forkBanner) {
3983
+ if (forkBanner) {
4392
3984
  console.info(
4393
3985
  ` ${colors_default.dim(`${forkName}: comparing committed '${forkBanner.pullBranch}' @ ${forkBanner.sha} (${forkBanner.date}); uncommitted fork changes are not included`)}`
4394
3986
  );
4395
3987
  }
4396
- if (config.diff) {
4397
- const matches = allItems.filter((i) => i.path === config.diff);
4398
- if (matches.length === 0) {
4399
- throw new Error(`no contribution found for path: ${config.diff}`);
4400
- }
4401
- for (const item of matches) {
4402
- const diff = gitDiffFile(config.forkPath, `${baseRef}..${item.ref}`, item.path, { dstPrefix: forkName });
4403
- writeStdout(diff.toString());
4404
- }
4405
- return;
4406
- }
4407
3988
  if (config.json) {
4408
3989
  const out = allItems.map((item) => ({
4409
3990
  fork: forkName,
4410
3991
  path: item.path,
4411
- status: item.status ?? null,
4412
- kind: item.deleted ? "deleted" : "modified",
3992
+ status: item.status,
3993
+ kind: item.kind === "deleted" ? "deleted" : "modified",
4413
3994
  changedAt: item.changedAt ?? null,
4414
3995
  changedTs: item.changedTs ?? null,
4415
- additions: item.additions ?? null,
4416
- deletions: item.deletions ?? null
3996
+ additions: item.additions,
3997
+ deletions: item.deletions
4417
3998
  }));
4418
3999
  writeStdout(JSON.stringify(out, null, 2));
4419
4000
  return;
4420
4001
  }
4421
- if (config.list) {
4422
- for (const item of allItems) {
4423
- const status = item.status ?? "behind";
4424
- const kind = item.deleted ? "deleted" : "modified";
4425
- const changedAt = item.changedAt ?? "-";
4426
- writeStdout(`${forkName} ${status} ${kind} ${changedAt} ${item.path}`);
4427
- }
4428
- return;
4429
- }
4430
4002
  console.info();
4431
4003
  console.info(DIVIDER);
4432
4004
  const selected = await contribPrompt({
@@ -4434,6 +4006,7 @@ async function runContributions(config) {
4434
4006
  items: allItems,
4435
4007
  forkName,
4436
4008
  baseRef,
4009
+ branch,
4437
4010
  cwd: config.forkPath,
4438
4011
  pageSize: 20
4439
4012
  });
@@ -4451,17 +4024,17 @@ async function runContributions(config) {
4451
4024
  errors.push(`${item.path}: rejected (path traversal)`);
4452
4025
  continue;
4453
4026
  }
4454
- if (item.deleted) {
4027
+ if (item.kind === "deleted") {
4455
4028
  if (applyUnstaged) {
4456
4029
  await removeFileFromWorktree(config.forkPath, item.path);
4457
4030
  } else {
4458
- await git(["rm", "-f", "--", item.path], config.forkPath, { ignoreErrors: true });
4031
+ await gitRm(config.forkPath, [item.path]);
4459
4032
  }
4460
4033
  } else {
4461
4034
  if (applyUnstaged) {
4462
- await restoreWorktreeFromRef(config.forkPath, item.ref, item.path);
4035
+ await restoreWorktreeFromRef(config.forkPath, branch, item.path);
4463
4036
  } else {
4464
- await git(["checkout", item.ref, "--", item.path], config.forkPath);
4037
+ await checkoutFromRef(config.forkPath, branch, [item.path]);
4465
4038
  }
4466
4039
  }
4467
4040
  applied++;
@@ -4487,27 +4060,24 @@ async function runContributions(config) {
4487
4060
  }
4488
4061
 
4489
4062
  // src/services/forks.ts
4490
- import { resolve as resolve7 } from "path";
4491
- import process8 from "process";
4492
- import { Separator as Separator2, select as select4 } from "@inquirer/prompts";
4063
+ import { resolve as resolve6 } from "path";
4064
+ import process7 from "process";
4065
+ import { Separator as Separator2, select as select5 } from "@inquirer/prompts";
4493
4066
 
4494
4067
  // src/services/sync.ts
4495
- import { spawnSync as spawnSync4 } from "child_process";
4496
- import { select as select3 } from "@inquirer/prompts";
4068
+ import { spawnSync as spawnSync5 } from "child_process";
4069
+ import { select as select4 } from "@inquirer/prompts";
4497
4070
 
4498
4071
  // src/utils/gh.ts
4499
- import { spawnSync as spawnSync3 } from "child_process";
4072
+ import { spawnSync as spawnSync4 } from "child_process";
4500
4073
  function ghAvailable() {
4501
- return spawnSync3("gh", ["--version"], { stdio: "ignore" }).status === 0;
4074
+ return spawnSync4("gh", ["--version"], { stdio: "ignore" }).status === 0;
4502
4075
  }
4503
4076
  function runGh(cwd, args) {
4504
- const result = spawnSync3("gh", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
4077
+ const result = spawnSync4("gh", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
4505
4078
  const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
4506
4079
  return { ok: result.status === 0, output };
4507
4080
  }
4508
- function filterSyncPrs(prs, prefix) {
4509
- return prs.filter((pr) => pr.headRefName.startsWith(`${prefix}/`));
4510
- }
4511
4081
  function listOpenSyncPrs(cwd, prefix) {
4512
4082
  const { ok, output } = runGh(cwd, [
4513
4083
  "pr",
@@ -4526,27 +4096,22 @@ function listOpenSyncPrs(cwd, prefix) {
4526
4096
  } catch {
4527
4097
  return [];
4528
4098
  }
4529
- return filterSyncPrs(parsed, prefix).sort((a, b) => b.number - a.number);
4530
- }
4531
- function buildMergeArgs(ref, options = {}) {
4532
- const args = ["pr", "merge", ref, "--squash"];
4533
- if (options.deleteBranch) args.push("--delete-branch");
4534
- return args;
4099
+ return parsed.filter((pr) => pr.headRefName.startsWith(`${prefix}/`)).sort((a, b) => b.number - a.number);
4535
4100
  }
4536
4101
  function mergePrSquash(cwd, ref, options = {}) {
4537
- return runGh(cwd, buildMergeArgs(String(ref), options));
4102
+ return runGh(cwd, ["pr", "merge", String(ref), "--squash", ...options.deleteBranch ? ["--delete-branch"] : []]);
4538
4103
  }
4539
4104
  function closePr(cwd, ref) {
4540
4105
  return runGh(cwd, ["pr", "close", String(ref)]);
4541
4106
  }
4542
4107
 
4543
4108
  // src/services/packages.ts
4544
- import { existsSync as existsSync11, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
4545
- import { join as join16 } from "path";
4109
+ import { existsSync as existsSync10, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
4110
+ import { join as join14 } from "path";
4546
4111
 
4547
4112
  // src/services/package-usage.ts
4548
- import { existsSync as existsSync10, readFileSync as readFileSync4, statSync } from "fs";
4549
- import { join as join15 } from "path";
4113
+ import { existsSync as existsSync9, readFileSync as readFileSync4, statSync } from "fs";
4114
+ import { join as join13 } from "path";
4550
4115
  var MAX_FILE_BYTES = 1024 * 1024;
4551
4116
  function escapeRegExp(value) {
4552
4117
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -4562,7 +4127,7 @@ function readSearchable(filePath) {
4562
4127
  }
4563
4128
  function fileSource(forkPath, path) {
4564
4129
  let text;
4565
- return { path, label: path, text: () => text ??= readSearchable(join15(forkPath, path)) };
4130
+ return { path, label: path, text: () => text ??= readSearchable(join13(forkPath, path)) };
4566
4131
  }
4567
4132
  async function readScriptsAtRef(forkPath, ref, path) {
4568
4133
  try {
@@ -4574,7 +4139,7 @@ async function readScriptsAtRef(forkPath, ref, path) {
4574
4139
  async function scriptSources(forkPath, upstreamRef, baseRef, path) {
4575
4140
  let forkScripts;
4576
4141
  try {
4577
- forkScripts = JSON.parse(readFileSync4(join15(forkPath, path), "utf8")).scripts ?? {};
4142
+ forkScripts = JSON.parse(readFileSync4(join13(forkPath, path), "utf8")).scripts ?? {};
4578
4143
  } catch {
4579
4144
  return [];
4580
4145
  }
@@ -4584,9 +4149,9 @@ async function scriptSources(forkPath, upstreamRef, baseRef, path) {
4584
4149
  }
4585
4150
  function binNames(forkPath, location, name) {
4586
4151
  const bareName = name.split("/").pop();
4587
- for (const dir of [join15(forkPath, location), forkPath]) {
4588
- const manifest = join15(dir, "node_modules", name, "package.json");
4589
- if (!existsSync10(manifest)) continue;
4152
+ for (const dir of [join13(forkPath, location), forkPath]) {
4153
+ const manifest = join13(dir, "node_modules", name, "package.json");
4154
+ if (!existsSync9(manifest)) continue;
4590
4155
  try {
4591
4156
  const { bin } = JSON.parse(readFileSync4(manifest, "utf8"));
4592
4157
  if (typeof bin === "string") return [bareName];
@@ -4641,7 +4206,7 @@ var DEPENDENCY_KEYS = [
4641
4206
  "peerDependencies",
4642
4207
  "optionalDependencies"
4643
4208
  ];
4644
- function mergeRecord(forkRecord, upstreamRecord, baseRecord, { bumpHigher = true, findUse } = {}) {
4209
+ function mergeRecord(forkRecord, upstreamRecord, baseRecord, { bumpHigher = true, findUse, upstreamOrder = false } = {}) {
4645
4210
  if (!upstreamRecord) return void 0;
4646
4211
  const merged = { ...forkRecord || {} };
4647
4212
  const base = baseRecord || {};
@@ -4675,9 +4240,26 @@ function mergeRecord(forkRecord, upstreamRecord, baseRecord, { bumpHigher = true
4675
4240
  delete merged[name];
4676
4241
  removed.push(name);
4677
4242
  }
4678
- const sorted = Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b)));
4243
+ const keys = upstreamOrder ? orderLikeUpstream(Object.keys(merged), Object.keys(upstreamRecord)) : Object.keys(merged).sort((a, b) => a.localeCompare(b));
4244
+ const ordered = Object.fromEntries(keys.map((name) => [name, merged[name]]));
4679
4245
  const changed = added.length + updated.length + removed.length > 0;
4680
- return { merged: sorted, changed, added, updated, removed, kept };
4246
+ return { merged: ordered, changed, added, updated, removed, kept };
4247
+ }
4248
+ function orderLikeUpstream(forkKeys, upstreamKeys) {
4249
+ const upstream = new Set(upstreamKeys);
4250
+ const forkOnlyAfter = /* @__PURE__ */ new Map();
4251
+ let anchor;
4252
+ for (const key of forkKeys) {
4253
+ if (upstream.has(key)) anchor = key;
4254
+ else forkOnlyAfter.set(anchor, [...forkOnlyAfter.get(anchor) ?? [], key]);
4255
+ }
4256
+ const present = new Set(forkKeys);
4257
+ const ordered = [...forkOnlyAfter.get(void 0) ?? []];
4258
+ for (const key of upstreamKeys) {
4259
+ if (present.has(key)) ordered.push(key);
4260
+ ordered.push(...forkOnlyAfter.get(key) ?? []);
4261
+ }
4262
+ return ordered;
4681
4263
  }
4682
4264
  function isSubpathMap(value) {
4683
4265
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
@@ -4695,26 +4277,10 @@ function safeMergePnpm(forkPnpm, upstreamPnpm, basePnpm) {
4695
4277
  if (!upstreamPnpm) return void 0;
4696
4278
  const merged = { ...forkPnpm || {} };
4697
4279
  let changed = false;
4698
- if (upstreamPnpm.overrides) {
4699
- const result = mergeRecord(
4700
- merged.overrides,
4701
- upstreamPnpm.overrides,
4702
- basePnpm?.overrides
4703
- );
4704
- if (result?.changed) {
4705
- merged.overrides = result.merged;
4706
- changed = true;
4707
- }
4708
- }
4709
- if (upstreamPnpm.patchedDependencies) {
4710
- const result = mergeRecord(
4711
- merged.patchedDependencies,
4712
- upstreamPnpm.patchedDependencies,
4713
- basePnpm?.patchedDependencies,
4714
- { bumpHigher: false }
4715
- );
4280
+ for (const sub of ["overrides", "patchedDependencies"]) {
4281
+ const result = mergeRecord(merged[sub], upstreamPnpm[sub], basePnpm?.[sub], { bumpHigher: sub === "overrides" });
4716
4282
  if (result?.changed) {
4717
- merged.patchedDependencies = result.merged;
4283
+ merged[sub] = result.merged;
4718
4284
  changed = true;
4719
4285
  }
4720
4286
  }
@@ -4766,17 +4332,13 @@ function setTopLevelKey(pkg, key, value) {
4766
4332
  Object.assign(pkg, Object.fromEntries(entries));
4767
4333
  }
4768
4334
  function readPackageJson(filePath) {
4769
- if (!existsSync11(filePath)) return null;
4335
+ if (!existsSync10(filePath)) return null;
4770
4336
  try {
4771
4337
  return JSON.parse(readFileSync5(filePath, "utf8"));
4772
- } catch {
4773
- return null;
4338
+ } catch (error) {
4339
+ throw new Error(`cannot parse ${filePath}: ${errorMessage(error)}`);
4774
4340
  }
4775
4341
  }
4776
- function writePackageJson(filePath, data) {
4777
- writeFileSync4(filePath, `${JSON.stringify(data, null, 2)}
4778
- `, "utf8");
4779
- }
4780
4342
  function packageJsonPath(relativePath) {
4781
4343
  return relativePath ? `${relativePath}/package.json` : "package.json";
4782
4344
  }
@@ -4804,15 +4366,15 @@ async function syncPackageJson(config, baseRef, usage, relativePath, keysToSync)
4804
4366
  const changes = [];
4805
4367
  const kept = [];
4806
4368
  const pkgRelPath = packageJsonPath(relativePath);
4807
- const pkgPath = join16(forkPath, pkgRelPath);
4369
+ const pkgPath = join14(forkPath, pkgRelPath);
4808
4370
  const forkPkg = readPackageJson(pkgPath);
4809
4371
  const upstream = await readPackageJsonAtRef(forkPath, upstreamRef, relativePath);
4810
4372
  const basePkg = baseRef ? (await readPackageJsonAtRef(forkPath, baseRef, relativePath))?.data : void 0;
4811
4373
  if (!upstream) return { updated: false, changes, kept };
4812
4374
  if (!forkPkg) {
4813
- const workspaceArrived = relativePath !== "" && existsSync11(join16(forkPath, relativePath));
4375
+ const workspaceArrived = relativePath !== "" && existsSync10(join14(forkPath, relativePath));
4814
4376
  if (basePkg || !workspaceArrived || isIgnored(pkgRelPath, config)) return { updated: false, changes, kept };
4815
- writeFileSync4(pkgPath, upstream.raw.endsWith("\n") ? upstream.raw : `${upstream.raw}
4377
+ writeFileSync3(pkgPath, upstream.raw.endsWith("\n") ? upstream.raw : `${upstream.raw}
4816
4378
  `, "utf8");
4817
4379
  return { updated: true, changes: ["copied from upstream (new workspace)"], kept };
4818
4380
  }
@@ -4836,18 +4398,12 @@ async function syncPackageJson(config, baseRef, usage, relativePath, keysToSync)
4836
4398
  continue;
4837
4399
  }
4838
4400
  if (key === "packageManager") {
4839
- const upstreamValue2 = upstreamPkg[key];
4840
- const forkValue2 = forkPkg[key];
4841
- const baseValue2 = basePkg?.[key];
4842
- if (upstreamValue2 && !forkValue2) {
4843
- if (baseValue2) continue;
4844
- forkPkg[key] = upstreamValue2;
4845
- updated = true;
4846
- changes.push(`${key}: added`);
4847
- } else if (upstreamValue2 && forkValue2 && upstreamValue2 !== forkValue2 && (forkValue2 === baseValue2 || isHigherVersion(upstreamValue2, forkValue2))) {
4848
- forkPkg[key] = upstreamValue2;
4401
+ const asRecord = (pkg) => pkg?.[key] ? { [key]: String(pkg[key]) } : void 0;
4402
+ const result2 = mergeRecord(asRecord(forkPkg), asRecord(upstreamPkg), asRecord(basePkg));
4403
+ if (result2?.changed) {
4404
+ forkPkg[key] = result2.merged[key];
4849
4405
  updated = true;
4850
- changes.push(`${key}: updated`);
4406
+ changes.push(`${key}: ${result2.added.length > 0 ? "added" : "updated"}`);
4851
4407
  }
4852
4408
  continue;
4853
4409
  }
@@ -4866,7 +4422,12 @@ async function syncPackageJson(config, baseRef, usage, relativePath, keysToSync)
4866
4422
  let findUse;
4867
4423
  if (usage && key === "scripts") findUse = (name) => usage.findScript(name);
4868
4424
  else if (usage && DEPENDENCY_KEYS.includes(key)) findUse = (name) => usage.findPackage(relativePath, name);
4869
- const result = mergeRecord(forkValue, upstreamValue, baseValue, { bumpHigher: key !== "scripts", findUse });
4425
+ const isScripts = key === "scripts";
4426
+ const result = mergeRecord(forkValue, upstreamValue, baseValue, {
4427
+ bumpHigher: !isScripts,
4428
+ findUse,
4429
+ upstreamOrder: isScripts
4430
+ });
4870
4431
  kept.push(...(result?.kept ?? []).map(({ name, usedAt }) => `${key}.${name}: used in ${usedAt}`));
4871
4432
  if (result?.changed) {
4872
4433
  forkPkg[key] = result.merged;
@@ -4878,9 +4439,8 @@ async function syncPackageJson(config, baseRef, usage, relativePath, keysToSync)
4878
4439
  );
4879
4440
  }
4880
4441
  }
4881
- if (updated) {
4882
- writePackageJson(pkgPath, forkPkg);
4883
- }
4442
+ if (updated) writeFileSync3(pkgPath, `${JSON.stringify(forkPkg, null, 2)}
4443
+ `, "utf8");
4884
4444
  return { updated, changes, kept };
4885
4445
  }
4886
4446
  async function runPackages(config, options = {}) {
@@ -4903,32 +4463,25 @@ async function runPackages(config, options = {}) {
4903
4463
  if (updated || kept2.length > 0) reports.push({ path: pkgRelPath, changes, kept: kept2 });
4904
4464
  }
4905
4465
  const changedCount = reports.filter(({ changes }) => changes.length > 0).length;
4906
- spinnerSuccess(
4907
- "package sync complete",
4908
- `${changedCount} package.json${changedCount === 1 ? " was" : "s were"} changed`
4909
- );
4466
+ spinnerSuccess("package sync complete", `${plural(changedCount, "package.json was", "package.jsons were")} changed`);
4910
4467
  for (const { path, changes } of reports) {
4911
4468
  if (changes.length === 0) continue;
4912
4469
  console.info(` ${path}`);
4913
4470
  for (const change of changes) console.info(` ${colors_default.dim(change)}`);
4914
4471
  }
4915
4472
  const kept = reports.flatMap(({ path, kept: kept2 }) => kept2.map((entry) => `${path} ${entry}`));
4916
- if (kept.length > 0) {
4917
- console.info();
4918
- console.info(
4919
- `${warningMark} ${colors_default.yellow(`kept ${kept.length} ${kept.length === 1 ? "entry" : "entries"} upstream dropped, the fork still uses ${kept.length === 1 ? "it" : "them"}:`)}`
4920
- );
4921
- for (const entry of kept) console.info(` ${colors_default.dim("\u2192")} ${entry}`);
4922
- }
4923
- if (skipped.length > 0) {
4924
- console.info();
4925
- console.info(
4926
- `${warningMark} ${colors_default.yellow(`skipped ${skipped.length} conflicted package.json file(s): resolve the conflicts, commit the merge, then rerun \`pnpm cella sync\`:`)}`
4927
- );
4928
- for (const path of skipped) {
4929
- console.info(` ${colors_default.dim("\u2192")} ${path}`);
4930
- }
4931
- }
4473
+ const them = kept.length === 1 ? "it" : "them";
4474
+ printWarnList(`kept ${plural(kept.length, "entry", "entries")} upstream dropped, the fork still uses ${them}:`, kept);
4475
+ printWarnList(
4476
+ `skipped ${skipped.length} conflicted package.json file(s): resolve the conflicts, commit the merge, then rerun \`pnpm cella sync\`:`,
4477
+ skipped
4478
+ );
4479
+ }
4480
+ function printWarnList(header, entries) {
4481
+ if (entries.length === 0) return;
4482
+ console.info();
4483
+ console.info(`${warningMark} ${colors_default.yellow(header)}`);
4484
+ for (const entry of entries) console.info(` ${colors_default.dim("\u2192")} ${entry}`);
4932
4485
  }
4933
4486
 
4934
4487
  // src/services/sync.ts
@@ -5018,10 +4571,13 @@ async function runSync(config, options) {
5018
4571
  spinnerFail("sync completed with conflicts");
5019
4572
  }
5020
4573
  printEngineReports(result, "merge summary");
5021
- printLogFileReport(config, result.files);
5022
- const stagedBranch = options?.stagedBranch && result.conflicts.length === 0 && hasStagedSyncChanges(result) ? options.stagedBranch : void 0;
4574
+ if (config.unpinned) {
4575
+ console.info(colors_default.yellow("\u26A0 --unpinned: every pinned entry was merged from upstream; keep only what you want."));
4576
+ console.info();
4577
+ }
4578
+ const staged = result.files.some((file) => SYNC_APPLIED_STATUSES.includes(file.status));
4579
+ const stagedBranch = options?.stagedBranch && result.conflicts.length === 0 && staged ? options.stagedBranch : void 0;
5023
4580
  printSyncComplete(result, { stagedBranch });
5024
- printFlagWarnings({ hard: config.hard, unpinned: config.unpinned });
5025
4581
  await printMigrationNotesLine(config, result);
5026
4582
  return result;
5027
4583
  }
@@ -5076,16 +4632,13 @@ async function buildSyncPrBodyForBranch(forkPath, base) {
5076
4632
  const fromSha = await readManifestBaseAtRef(forkPath, base) ?? await getMergeBase(forkPath, base, toSha).catch(() => null);
5077
4633
  const version = manifest.upstream.release ?? await readPackageVersionAtRef(forkPath, toSha).catch(() => null);
5078
4634
  const totalCount = fromSha ? await countCommitsBetween(forkPath, fromSha, toSha).catch(() => 0) : 0;
5079
- const commits = fromSha && totalCount > 0 ? await listCommitsBetween(forkPath, fromSha, toSha, {
5080
- skip: totalCount > COMMIT_LIST_MAX ? totalCount - COMMIT_LIST_MAX : 0,
5081
- limit: COMMIT_LIST_MAX
5082
- }) : [];
4635
+ const commits = fromSha && totalCount > 0 ? await listCommitsBetween(forkPath, fromSha, toSha, COMMIT_LIST_MAX) : [];
5083
4636
  const notes = await readPrBodyNotes(forkPath, toSha, manifest.upstream.repo).catch(() => void 0);
5084
4637
  return buildSyncPrBody({ repoSlug: manifest.upstream.repo, version, fromSha, toSha, commits, totalCount, notes });
5085
4638
  }
5086
4639
  async function readPrBodyNotes(forkPath, toSha, repoSlug) {
5087
4640
  const githubUrl = repoSlug ? `https://github.com/${repoSlug}` : void 0;
5088
- const [ids, pending] = await Promise.all([listNoteIds(forkPath, toSha), readPending(forkPath, toSha)]);
4641
+ const [ids, pending] = await Promise.all([listNoteIds(forkPath, toSha), readPending(forkPath)]);
5089
4642
  const open = await Promise.all(
5090
4643
  pending.map(async (id) => ({
5091
4644
  id,
@@ -5098,16 +4651,6 @@ async function readPrBodyNotes(forkPath, toSha, repoSlug) {
5098
4651
  function printPrCreateStep(branch, base, title = SYNC_PR_TITLE) {
5099
4652
  console.info(colors_default.dim(` gh pr create --base ${base} --head ${branch} --title "${title}" --fill`));
5100
4653
  }
5101
- function printShipSteps(temporaryBranch, base, title) {
5102
- console.info(colors_default.dim(` git push -u origin ${temporaryBranch}`));
5103
- printPrCreateStep(temporaryBranch, base, title);
5104
- }
5105
- function hasStagedSyncChanges(result) {
5106
- return result.files.some((file) => SYNC_APPLIED_STATUSES.includes(file.status));
5107
- }
5108
- function extractFirstUrl(output) {
5109
- return output.match(/https?:\/\/\S+/)?.[0];
5110
- }
5111
4654
  async function flattenSyncBranch(forkPath, branch, base) {
5112
4655
  const mergeCommits = await listBranchMergeCommits(forkPath, base);
5113
4656
  if (mergeCommits.length === 0) return false;
@@ -5121,9 +4664,6 @@ async function flattenSyncBranch(forkPath, branch, base) {
5121
4664
  await flattenBranch(forkPath, base, message);
5122
4665
  return true;
5123
4666
  }
5124
- function indentLines(text) {
5125
- return text.split("\n").map((line) => ` ${line}`).join("\n");
5126
- }
5127
4667
  async function shipSyncBranch(config, branch) {
5128
4668
  const { forkPath, settings } = config;
5129
4669
  const base = resolveReleaseBase(settings);
@@ -5131,27 +4671,28 @@ async function shipSyncBranch(config, branch) {
5131
4671
  const flattened = await flattenSyncBranch(forkPath, branch, cutBase);
5132
4672
  let prUrl;
5133
4673
  let prOpened = false;
5134
- const headSubject = (await getCommitInfo(forkPath, "HEAD").catch(() => null))?.message;
5135
- const prTitle = headSubject?.startsWith(SYNC_PR_TITLE) ? headSubject : SYNC_PR_TITLE;
4674
+ const upstreamCommit = (await readSyncManifest(forkPath))?.upstream.commit;
4675
+ const prTitle = upstreamCommit ? await buildSyncCommitMessage(forkPath, upstreamCommit) : SYNC_PR_TITLE;
5136
4676
  console.info(colors_default.dim(`pushing '${branch}' to origin...`));
5137
4677
  try {
5138
4678
  await pushBranch(forkPath, "origin", branch, { forceWithLease: flattened });
5139
4679
  } catch (error) {
5140
4680
  console.info(colors_default.yellow(`push failed (${errorMessage(error).split("\n")[0]}). finish manually:`));
5141
- printShipSteps(branch, base, prTitle);
4681
+ console.info(colors_default.dim(` git push -u origin ${branch}`));
4682
+ printPrCreateStep(branch, base, prTitle);
5142
4683
  return;
5143
4684
  }
5144
4685
  if (ghAvailable()) {
5145
4686
  console.info(colors_default.dim("opening a pull request..."));
5146
4687
  const prBody = await buildSyncPrBodyForBranch(forkPath, cutBase);
5147
4688
  const bodyArgs = prBody ? ["--body", prBody] : ["--fill"];
5148
- const pr = spawnSync4("gh", ["pr", "create", "--base", base, "--head", branch, "--title", prTitle, ...bodyArgs], {
4689
+ const pr = spawnSync5("gh", ["pr", "create", "--base", base, "--head", branch, "--title", prTitle, ...bodyArgs], {
5149
4690
  cwd: forkPath,
5150
4691
  encoding: "utf8",
5151
4692
  stdio: ["ignore", "pipe", "pipe"]
5152
4693
  });
5153
- prUrl = extractFirstUrl(`${pr.stdout ?? ""}
5154
- ${pr.stderr ?? ""}`);
4694
+ prUrl = `${pr.stdout ?? ""}
4695
+ ${pr.stderr ?? ""}`.match(/https?:\/\/\S+/)?.[0];
5155
4696
  prOpened = pr.status === 0;
5156
4697
  if (!prOpened) {
5157
4698
  console.info(colors_default.yellow("could not open the PR automatically (it may already exist). open it with:"));
@@ -5176,7 +4717,7 @@ ${pr.stderr ?? ""}`);
5176
4717
  function finalizeWorkspace(forkPath) {
5177
4718
  for (const args of [["install"], ["check"]]) {
5178
4719
  console.info(colors_default.dim(`running pnpm ${args.join(" ")}...`));
5179
- const result = spawnSync4("pnpm", args, { cwd: forkPath, stdio: "inherit" });
4720
+ const result = spawnSync5("pnpm", args, { cwd: forkPath, stdio: "inherit" });
5180
4721
  if (result.status !== 0) return false;
5181
4722
  }
5182
4723
  return true;
@@ -5240,13 +4781,10 @@ async function commitSyncMerge(config, branch) {
5240
4781
  await commitSquash(forkPath, message);
5241
4782
  console.info();
5242
4783
  console.info(colors_default.green(`committed the sync on '${branch}' as '${message}'.`));
5243
- printTriageSteps(branch);
5244
- await printMigrationNotesLine(config);
5245
- }
5246
- function printTriageSteps(branch) {
5247
4784
  console.info(colors_default.dim(`staying on '${branch}' without pushing.`));
5248
4785
  console.info(colors_default.dim(" run drift triage (`pnpm cella analyze`), commit any follow-ups, then:"));
5249
4786
  console.info(colors_default.dim(" pnpm cella sync (pushes the branch and opens the PR)"));
4787
+ await printMigrationNotesLine(config);
5250
4788
  }
5251
4789
  async function mergeOpenSyncPrs(config, open) {
5252
4790
  const { forkPath, settings } = config;
@@ -5258,7 +4796,7 @@ async function mergeOpenSyncPrs(config, open) {
5258
4796
  if (!merged.ok) {
5259
4797
  console.info();
5260
4798
  console.info(colors_default.yellow(`could not merge #${newest.number}: resolve it first, then re-run \`pnpm cella sync\`:`));
5261
- if (merged.output) console.info(colors_default.dim(indentLines(merged.output)));
4799
+ if (merged.output) console.info(colors_default.dim(merged.output.replace(/^/gm, " ")));
5262
4800
  console.info(colors_default.dim(` ${newest.url}`));
5263
4801
  return "cancel";
5264
4802
  }
@@ -5287,7 +4825,7 @@ async function guardAgainstOpenSyncPr(config) {
5287
4825
  }
5288
4826
  console.info(colors_default.dim(` starting a new sync now produces a PR that re-includes ${many ? "these" : "those"} changes.`));
5289
4827
  console.info();
5290
- const choice = await select3({
4828
+ const choice = await select4({
5291
4829
  message: "how do you want to proceed?",
5292
4830
  choices: [
5293
4831
  {
@@ -5325,7 +4863,9 @@ commit them first (\`git commit --amend --no-edit\` or a new commit).`
5325
4863
  await shipSyncBranch(config, currentBranch);
5326
4864
  return;
5327
4865
  }
5328
- await assertClean(forkPath);
4866
+ if (!await isClean(forkPath)) {
4867
+ throw new Error("working directory has uncommitted changes. please commit or stash before syncing.");
4868
+ }
5329
4869
  if (await guardAgainstOpenSyncPr(config) === "cancel") return;
5330
4870
  const outcome = await runSyncCycle(config);
5331
4871
  console.info();
@@ -5407,21 +4947,16 @@ async function syncFork(config, fork, forkPath) {
5407
4947
  console.info(colors_default.dim(`path: ${forkPath}`));
5408
4948
  console.info();
5409
4949
  const forkConfig = await loadConfig(forkPath);
5410
- const { branchRef } = resolveUpstream(forkConfig.settings);
5411
- const upstreamRef = branchRef;
5412
- const forkRuntimeConfig = {
4950
+ await runSyncCommand({
5413
4951
  ...forkConfig,
5414
4952
  forkPath,
5415
- upstreamRef,
4953
+ upstreamRef: resolveUpstream(forkConfig.settings).branchRef,
5416
4954
  service: "sync",
5417
- logFile: config.logFile,
5418
4955
  list: false,
5419
4956
  json: false,
5420
- verbose: config.verbose,
5421
- hard: config.hard,
4957
+ verbose: false,
5422
4958
  keepConfig: config.keepConfig
5423
- };
5424
- await runSyncCommand(forkRuntimeConfig);
4959
+ });
5425
4960
  }
5426
4961
  async function runForks(config) {
5427
4962
  const forks = config.forks ?? [];
@@ -5434,7 +4969,7 @@ async function runForks(config) {
5434
4969
  if (!match) {
5435
4970
  throw new Error(`fork '${config.fork}' not found in config`);
5436
4971
  }
5437
- const resolvedPath = resolve7(await resolveForkBasePath(config.forkPath), match.localPath);
4972
+ const resolvedPath = resolve6(await resolveForkBasePath(config.forkPath), match.localPath);
5438
4973
  await syncFork(config, match, resolvedPath);
5439
4974
  return;
5440
4975
  }
@@ -5442,15 +4977,15 @@ async function runForks(config) {
5442
4977
  while (true) {
5443
4978
  const choices = await buildForkChoices(forks, forkBasePath);
5444
4979
  const forkChoices = [...choices, new Separator2(MENU_DIVIDER), { value: "_exit", name: colors_default.dim("exit") }];
5445
- const selectedPath = await select4({
4980
+ const selectedPath = await select5({
5446
4981
  message: "select fork to sync:",
5447
4982
  choices: forkChoices,
5448
4983
  loop: false
5449
4984
  });
5450
4985
  if (selectedPath === "_exit") {
5451
- process8.exit(0);
4986
+ process7.exit(0);
5452
4987
  }
5453
- const resolvedForkPath = resolve7(forkBasePath, selectedPath);
4988
+ const resolvedForkPath = resolve6(forkBasePath, selectedPath);
5454
4989
  const selectedFork = forks.find((f) => f.localPath === selectedPath);
5455
4990
  if (!selectedFork) {
5456
4991
  console.error(colors_default.red(`fork '${selectedPath}' not found in config`));
@@ -5463,16 +4998,12 @@ async function runForks(config) {
5463
4998
 
5464
4999
  // src/services/stats.ts
5465
5000
  import { readFile as readFile3 } from "fs/promises";
5466
- import { join as join18 } from "path";
5001
+ import { join as join16 } from "path";
5467
5002
 
5468
5003
  // src/utils/coverage-utils.ts
5469
- import { spawnSync as spawnSync5 } from "child_process";
5470
- import { existsSync as existsSync12, readFileSync as readFileSync6, statSync as statSync2 } from "fs";
5471
- import { join as join17, relative as relative3, sep as sep2 } from "path";
5004
+ import { existsSync as existsSync11, readFileSync as readFileSync6, statSync as statSync2 } from "fs";
5005
+ import { join as join15, relative as relative4, sep as sep2 } from "path";
5472
5006
  var STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1e3;
5473
- function summaryPath(forkPath) {
5474
- return join17(forkPath, ".coverage", "coverage-summary.json");
5475
- }
5476
5007
  function colorPct(pct) {
5477
5008
  if (typeof pct !== "number" || !Number.isFinite(pct)) return colors_default.dim("n/a");
5478
5009
  const label = `${pct.toFixed(2)}%`;
@@ -5488,34 +5019,22 @@ function formatAge(ms) {
5488
5019
  const mins = Math.floor(ms / (60 * 1e3));
5489
5020
  return `${mins} minute${mins === 1 ? "" : "s"} ago`;
5490
5021
  }
5491
- function refreshCoverage(forkPath) {
5492
- console.info(colors_default.dim("running `pnpm test` (this spins up Docker and runs the full suite)..."));
5493
- const result = spawnSync5("pnpm", ["test"], { cwd: forkPath, stdio: "inherit" });
5494
- return result.status === 0;
5495
- }
5496
- function printCoverageSummary(forkPath, options = {}) {
5497
- const file = summaryPath(forkPath);
5498
- if (options.refresh) {
5499
- const ok = refreshCoverage(forkPath);
5500
- if (!ok) {
5501
- console.info(colors_default.red("coverage run failed: showing last cached results (if any)"));
5022
+ function printCoverageSummary(forkPath) {
5023
+ const file = join15(forkPath, ".coverage", "coverage-summary.json");
5024
+ let summary;
5025
+ let mtimeMs = 0;
5026
+ let problem = "no coverage data found";
5027
+ if (existsSync11(file)) {
5028
+ try {
5029
+ mtimeMs = statSync2(file).mtimeMs;
5030
+ summary = JSON.parse(readFileSync6(file, "utf8"));
5031
+ } catch {
5032
+ problem = "could not read coverage-summary.json";
5502
5033
  }
5503
- console.info();
5504
- }
5505
- if (!existsSync12(file)) {
5506
- console.info(colors_default.bold("coverage"));
5507
- console.info(colors_default.yellow(" no coverage data found: run `pnpm test` to generate it"));
5508
- console.info();
5509
- return;
5510
5034
  }
5511
- let summary;
5512
- let mtimeMs;
5513
- try {
5514
- summary = JSON.parse(readFileSync6(file, "utf8"));
5515
- mtimeMs = statSync2(file).mtimeMs;
5516
- } catch {
5035
+ if (!summary || !Object.keys(summary).some((key) => key !== "total")) {
5517
5036
  console.info(colors_default.bold("coverage"));
5518
- console.info(colors_default.yellow(" could not read coverage-summary.json: run `pnpm test` to regenerate"));
5037
+ console.info(colors_default.yellow(` ${problem}: run \`pnpm test\` to generate it`));
5519
5038
  console.info();
5520
5039
  return;
5521
5040
  }
@@ -5524,7 +5043,7 @@ function printCoverageSummary(forkPath, options = {}) {
5524
5043
  const perPackage = /* @__PURE__ */ new Map();
5525
5044
  for (const [key, entry] of Object.entries(summary)) {
5526
5045
  if (key === "total") continue;
5527
- const rel = relative3(forkPath, key);
5046
+ const rel = relative4(forkPath, key);
5528
5047
  if (rel.startsWith("..")) continue;
5529
5048
  const pkg = rel.split(sep2)[0];
5530
5049
  const acc = perPackage.get(pkg) ?? { covered: 0, total: 0 };
@@ -5537,11 +5056,6 @@ function printCoverageSummary(forkPath, options = {}) {
5537
5056
  if (stale) {
5538
5057
  console.info(colors_default.yellow(" \u26A0 data is older than 7 days: run `pnpm test` to refresh"));
5539
5058
  }
5540
- if (!Object.keys(summary).some((key) => key !== "total")) {
5541
- console.info(colors_default.yellow(" no coverage data found: run `pnpm test` to regenerate"));
5542
- console.info();
5543
- return;
5544
- }
5545
5059
  const total = summary.total;
5546
5060
  console.info(
5547
5061
  ` ${colors_default.bold("overall")} lines ${colorPct(total.lines.pct)} ${colors_default.dim(`(${total.lines.covered}/${total.lines.total})`)} funcs ${colorPct(total.functions.pct)} branches ${colorPct(total.branches.pct)}`
@@ -5557,8 +5071,9 @@ function printCoverageSummary(forkPath, options = {}) {
5557
5071
  // src/services/stats.ts
5558
5072
  var sourceExtensions = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs", "json", "jsonc", "css", "html"]);
5559
5073
  async function getWorkspacePackages(forkPath) {
5560
- const content = await readFile3(join18(forkPath, "pnpm-workspace.yaml"), "utf8");
5561
- return parseYamlBlockList(content, "packages").map((pattern) => {
5074
+ const content = await readFile3(join16(forkPath, "pnpm-workspace.yaml"), "utf8");
5075
+ const patterns = readTopLevel(content, "packages");
5076
+ return (Array.isArray(patterns) ? patterns.map(String) : []).map((pattern) => {
5562
5077
  if (pattern.endsWith("/*")) {
5563
5078
  const base = pattern.slice(0, -2);
5564
5079
  return { name: `${base}/*`, prefix: `${base}/` };
@@ -5600,8 +5115,6 @@ function matchPackage(filePath, packages) {
5600
5115
  var sourcePathspecs = [...sourceExtensions].map((ext) => `*.${ext}`);
5601
5116
  async function collectStats(forkPath) {
5602
5117
  const output = await git(["grep", "-c", "", "--", ...sourcePathspecs], forkPath, { ignoreErrors: true });
5603
- const allFilesOutput = await git(["ls-files", "--cached"], forkPath);
5604
- const allFilesCount = allFilesOutput.split("\n").filter(Boolean).length;
5605
5118
  const packages = await getWorkspacePackages(forkPath);
5606
5119
  const entries = [];
5607
5120
  for (const line of output.split("\n")) {
@@ -5610,7 +5123,6 @@ async function collectStats(forkPath) {
5610
5123
  if (sep3 === -1) continue;
5611
5124
  entries.push({ path: line.slice(0, sep3), loc: Number.parseInt(line.slice(sep3 + 1), 10) });
5612
5125
  }
5613
- const skipped = allFilesCount - entries.length;
5614
5126
  const emptyCat = () => ({
5615
5127
  test: { files: 0, loc: 0 },
5616
5128
  stories: { files: 0, loc: 0 },
@@ -5640,7 +5152,7 @@ async function collectStats(forkPath) {
5640
5152
  const sortedPackages = Object.fromEntries(
5641
5153
  [...pkgStats.entries()].sort((a, b) => b[1].categories.other.loc - a[1].categories.other.loc)
5642
5154
  );
5643
- return { total: entries.length, totalLoc, skipped, categories, packages: sortedPackages };
5155
+ return { total: entries.length, totalLoc, categories, packages: sortedPackages };
5644
5156
  }
5645
5157
  function pad(n, width = 6) {
5646
5158
  return String(n).padStart(width);
@@ -5649,9 +5161,6 @@ function formatLoc(n) {
5649
5161
  if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
5650
5162
  return String(n);
5651
5163
  }
5652
- function formatLocColumn(n, width = 8) {
5653
- return formatLoc(n).padStart(width);
5654
- }
5655
5164
  function fmtPct(value, total) {
5656
5165
  const pct = (value / total * 100).toFixed(1);
5657
5166
  return `${pct.padStart(5)}%`;
@@ -5672,34 +5181,20 @@ function printStats(stats, verbose) {
5672
5181
  ];
5673
5182
  for (const [label, data] of catEntries) {
5674
5183
  console.info(
5675
- ` ${pad(data.files)} ${colors_default.dim(fmtPct(data.files, stats.total))} ${label.padEnd(18)} ${colors_default.cyan(formatLocColumn(data.loc))} ${colors_default.dim(fmtPct(data.loc, stats.totalLoc))}`
5184
+ ` ${pad(data.files)} ${colors_default.dim(fmtPct(data.files, stats.total))} ${label.padEnd(18)} ${colors_default.cyan(formatLoc(data.loc).padStart(8))} ${colors_default.dim(fmtPct(data.loc, stats.totalLoc))}`
5676
5185
  );
5677
5186
  }
5678
5187
  console.info();
5679
5188
  console.info(DIVIDER);
5680
5189
  console.info();
5681
- if (verbose) {
5682
- 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)}`;
5683
- console.info(colors_default.dim(header));
5684
- for (const [name, data] of Object.entries(stats.packages)) {
5685
- const c = data.categories;
5686
- const srcFiles = c.other.files;
5687
- const srcLoc = c.other.loc;
5688
- if (srcFiles === 0) continue;
5689
- console.info(
5690
- ` ${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))}`
5691
- );
5692
- }
5693
- } else {
5694
- console.info(colors_default.dim(" source & config by package"));
5695
- for (const [name, data] of Object.entries(stats.packages)) {
5696
- const srcFiles = data.categories.other.files;
5697
- const srcLoc = data.categories.other.loc;
5698
- if (srcFiles === 0) continue;
5699
- console.info(
5700
- ` ${pad(srcFiles)} ${colors_default.dim(fmtPct(srcFiles, stats.categories.other.files))} ${name.padEnd(18)} ${colors_default.cyan(formatLocColumn(srcLoc))} ${colors_default.dim(fmtPct(srcLoc, stats.categories.other.loc))}`
5701
- );
5702
- }
5190
+ const header = verbose ? ` ${"".padEnd(6)} ${"package".padEnd(18)} ${"loc".padStart(8)} ${"test".padStart(6)} ${"story".padStart(6)} ${"gen".padStart(6)} ${"json".padStart(6)} ${"src".padStart(6)}` : " source & config by package";
5191
+ console.info(colors_default.dim(header));
5192
+ const { other } = stats.categories;
5193
+ for (const [name, { categories: c }] of Object.entries(stats.packages)) {
5194
+ if (c.other.files === 0) continue;
5195
+ const head = verbose ? colors_default.dim(pad(c.other.files)) : `${pad(c.other.files)} ${colors_default.dim(fmtPct(c.other.files, other.files))}`;
5196
+ const tail = verbose ? ` ${[c.test, c.stories, c.generated, c.json, c.other].map((cat) => colors_default.dim(pad(cat.files))).join(" ")}` : ` ${colors_default.dim(fmtPct(c.other.loc, other.loc))}`;
5197
+ console.info(` ${head} ${name.padEnd(18)} ${colors_default.cyan(formatLoc(c.other.loc).padStart(8))}${tail}`);
5703
5198
  }
5704
5199
  console.info();
5705
5200
  }
@@ -5710,19 +5205,11 @@ async function runStats(config) {
5710
5205
  spinnerSuccess("counted raw lines of code");
5711
5206
  printStats(stats, config.verbose);
5712
5207
  console.info();
5713
- printCoverageSummary(forkPath, { refresh: config.coverage });
5208
+ printCoverageSummary(forkPath);
5714
5209
  }
5715
5210
 
5716
5211
  // src/services/stats-since.ts
5717
- var kindLabels = {
5718
- source: "source",
5719
- test: "tests",
5720
- stories: "stories",
5721
- generated: "generated",
5722
- json: "json",
5723
- docs: "docs",
5724
- other: "other"
5725
- };
5212
+ var kindLabel = (kind) => kind === "test" ? "tests" : kind;
5726
5213
  var docExtensions = /* @__PURE__ */ new Set(["md", "mdx"]);
5727
5214
  var lockFiles = /* @__PURE__ */ new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lock", "bun.lockb"]);
5728
5215
  var codeExtensions = [...sourceExtensions].filter((ext) => !ext.startsWith("json"));
@@ -5745,9 +5232,9 @@ function classifyLine(text) {
5745
5232
  }
5746
5233
  function parseNumstat(output) {
5747
5234
  const files = [];
5748
- for (const record of output.split("\0")) {
5749
- if (!record) continue;
5750
- const [added, removed, ...rest] = record.split(" ");
5235
+ for (const record2 of output.split("\0")) {
5236
+ if (!record2) continue;
5237
+ const [added, removed, ...rest] = record2.split(" ");
5751
5238
  const path = rest.join(" ");
5752
5239
  if (!path) continue;
5753
5240
  files.push({
@@ -5790,9 +5277,7 @@ function countLineContent(patch, isCounted) {
5790
5277
  return content;
5791
5278
  }
5792
5279
  async function collectBranchStats(forkPath, since) {
5793
- const sinceSha = await git(["rev-parse", "--verify", "--quiet", `${since}^{commit}`], forkPath, {
5794
- ignoreErrors: true
5795
- });
5280
+ const sinceSha = await revParse(forkPath, `${since}^{commit}`);
5796
5281
  if (!sinceSha) throw new Error(`unknown ref '${since}'. pass a branch, tag or commit, such as origin/main`);
5797
5282
  const mergeBase = await git(["merge-base", sinceSha, "HEAD"], forkPath, { ignoreErrors: true });
5798
5283
  if (!mergeBase) throw new Error(`the current branch shares no history with '${since}'`);
@@ -5867,9 +5352,7 @@ function formatMarkdown(stats) {
5867
5352
  const lines = [
5868
5353
  "| Kind | Files | Added | Removed | Net |",
5869
5354
  "| --- | ---: | ---: | ---: | ---: |",
5870
- ...changedKinds(stats).map(
5871
- ([kind, change]) => row(kind === "json" ? "JSON" : capitalize(kindLabels[kind]), change)
5872
- ),
5355
+ ...changedKinds(stats).map(([kind, change]) => row(kind === "json" ? "JSON" : capitalize(kindLabel(kind)), change)),
5873
5356
  `| **Total** | **${stats.total.files}** | **${signed(stats.total.added)}** | **${signed(-stats.total.removed)}** | **${signed(net(stats.total))}** |`
5874
5357
  ];
5875
5358
  const notes = [];
@@ -5886,7 +5369,7 @@ function printBranchStats(stats, verbose) {
5886
5369
  console.info();
5887
5370
  console.info(colors_default.dim(` ${"files".padStart(5)} ${"added".padStart(8)} ${"removed".padStart(8)} ${"net".padStart(8)}`));
5888
5371
  for (const [kind, change] of changedKinds(stats)) {
5889
- console.info(columns(String(change.files), change, kindLabels[kind]));
5372
+ console.info(columns(String(change.files), change, kindLabel(kind)));
5890
5373
  }
5891
5374
  console.info(columns(String(stats.total.files), stats.total, colors_default.bold("total")));
5892
5375
  if (stats.sourceContent && stats.kinds.source.files > 0) {
@@ -5909,7 +5392,7 @@ function printBranchStats(stats, verbose) {
5909
5392
  console.info(DIVIDER);
5910
5393
  console.info();
5911
5394
  for (const file of stats.files) {
5912
- console.info(columns("", file, `${file.path} ${colors_default.dim(kindLabels[file.kind])}`));
5395
+ console.info(columns("", file, `${file.path} ${colors_default.dim(kindLabel(file.kind))}`));
5913
5396
  }
5914
5397
  }
5915
5398
  if (stats.uncommitted > 0) {
@@ -5920,48 +5403,60 @@ function printBranchStats(stats, verbose) {
5920
5403
  }
5921
5404
  console.info();
5922
5405
  }
5923
- async function runBranchStats(forkPath, options) {
5406
+ async function runBranchStats(config, since) {
5924
5407
  createSpinner("counting changed lines...");
5925
- const stats = await collectBranchStats(forkPath, options.since);
5408
+ const stats = await collectBranchStats(config.forkPath, since);
5926
5409
  spinnerSuccess(`Counted what ${stats.head} changed since ${stats.since} (${stats.mergeBase})`);
5927
5410
  if (stats.total.files === 0) {
5928
5411
  console.info(colors_default.dim(` nothing committed since ${stats.since}`));
5929
5412
  return;
5930
5413
  }
5931
- if (options.markdown) {
5414
+ if (config.md) {
5932
5415
  writeStdout(formatMarkdown(stats));
5933
5416
  if (stats.uncommitted > 0) console.info(colors_default.dim(`${stats.uncommitted} uncommitted file(s) are not counted`));
5934
5417
  return;
5935
5418
  }
5936
- printBranchStats(stats, options.verbose ?? false);
5419
+ printBranchStats(stats, config.verbose);
5937
5420
  }
5938
5421
 
5939
5422
  // src/cella-cli.ts
5940
5423
  function getForkPath() {
5941
5424
  const envPath = getEnv("CELLA_FORK_PATH");
5942
5425
  if (envPath) {
5943
- const resolved = resolve8(envPath);
5944
- if (!existsSync13(resolved) || !statSync3(resolved).isDirectory()) {
5426
+ const resolved = resolve7(envPath);
5427
+ if (!existsSync12(resolved) || !statSync3(resolved).isDirectory()) {
5945
5428
  throw new Error(`CELLA_FORK_PATH is not a valid directory: ${resolved}`);
5946
5429
  }
5947
5430
  return resolved;
5948
5431
  }
5949
- let cwd = process9.cwd();
5432
+ let cwd = process8.cwd();
5950
5433
  if (cwd.endsWith("/cli/cella") || cwd.endsWith("\\cli\\cella")) {
5951
- cwd = resolve8(cwd, "../..");
5434
+ cwd = resolve7(cwd, "../..");
5952
5435
  }
5953
5436
  return cwd;
5954
5437
  }
5955
5438
  function preflight(forkPath) {
5956
- if (!existsSync13(join19(forkPath, ".git"))) {
5439
+ if (!existsSync12(join17(forkPath, ".git"))) {
5957
5440
  throw new Error(`not a git repository: ${forkPath}`);
5958
5441
  }
5959
5442
  }
5443
+ var services = {
5444
+ analyze: runAnalyze,
5445
+ sync: runSyncCommand,
5446
+ migrate: runMigrate,
5447
+ audit: runAudit,
5448
+ forks: runForks,
5449
+ contributions: runContributions,
5450
+ stats: async (config) => {
5451
+ if (config.md && !config.since) throw new Error("--md goes with --since <ref>");
5452
+ if (!config.since) return runStats(config);
5453
+ return runBranchStats(config, config.since);
5454
+ }
5455
+ };
5960
5456
  function isHelpOrVersionRequest() {
5961
- return process9.argv.slice(2).some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v") || process9.argv[2] === "help";
5457
+ return process8.argv.slice(2).some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v") || process8.argv[2] === "help";
5962
5458
  }
5963
5459
  async function main() {
5964
- registerSignalHandlers();
5965
5460
  try {
5966
5461
  const forkPath = getForkPath();
5967
5462
  if (isHelpOrVersionRequest()) {
@@ -5973,46 +5468,12 @@ async function main() {
5973
5468
  if (!["audit", "forks", "contributions", "stats"].includes(config.service)) {
5974
5469
  preflight(forkPath);
5975
5470
  }
5976
- switch (config.service) {
5977
- case "analyze": {
5978
- await runAnalyze(config);
5979
- break;
5980
- }
5981
- case "sync": {
5982
- await runSyncCommand(config);
5983
- break;
5984
- }
5985
- case "migrate": {
5986
- await runMigrate(config);
5987
- break;
5988
- }
5989
- case "audit": {
5990
- await runAudit(config);
5991
- break;
5992
- }
5993
- case "forks": {
5994
- await runForks(config);
5995
- break;
5996
- }
5997
- case "contributions": {
5998
- await runContributions(config);
5999
- break;
6000
- }
6001
- case "stats": {
6002
- if (config.md && !config.since) throw new Error("--md goes with --since <ref>");
6003
- if (config.since) {
6004
- await runBranchStats(config.forkPath, { since: config.since, markdown: config.md, verbose: config.verbose });
6005
- } else {
6006
- await runStats(config);
6007
- }
6008
- break;
6009
- }
6010
- }
5471
+ await services[config.service](config);
6011
5472
  console.info();
6012
5473
  } catch (error) {
6013
5474
  console.error();
6014
5475
  console.error(`${colors_default.red("\u2717")} ${errorMessage(error)}`);
6015
- process9.exit(1);
5476
+ process8.exit(1);
6016
5477
  }
6017
5478
  }
6018
5479
  main();