@cellajs/cli 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,12 +2,10 @@
2
2
 
3
3
  // src/cella-cli.ts
4
4
  import { existsSync as existsSync13, statSync as statSync3 } from "fs";
5
- import { join as join21, resolve as resolve8 } from "path";
5
+ import { join as join19, resolve as resolve8 } from "path";
6
6
  import process9 from "process";
7
7
 
8
8
  // src/cli.ts
9
- import { readFileSync } from "fs";
10
- import { join as join8 } from "path";
11
9
  import process5 from "process";
12
10
  import { select } from "@inquirer/prompts";
13
11
  import { Command } from "commander";
@@ -53,32 +51,31 @@ var pc = {
53
51
  var colors_default = pc;
54
52
 
55
53
  // src/utils/config.ts
56
- import { existsSync as existsSync2, realpathSync } from "fs";
54
+ import { existsSync as existsSync2, readFileSync, realpathSync } from "fs";
57
55
  import { join as join2, resolve } from "path";
58
56
  import { z as z2 } from "zod";
59
57
 
60
58
  // src/config/types.ts
61
59
  import { z } from "zod";
60
+ var PACKAGE_JSON_SYNC_KEYS = [
61
+ "dependencies",
62
+ "devDependencies",
63
+ "peerDependencies",
64
+ "optionalDependencies",
65
+ "scripts",
66
+ "engines",
67
+ "packageManager",
68
+ "overrides",
69
+ "exports",
70
+ "pnpm"
71
+ ];
62
72
  var cellaConfigSchema = z.object({
63
73
  settings: z.object({
64
74
  upstreamUrl: z.string().min(1),
65
75
  upstreamBranch: z.string().min(1).optional(),
66
76
  upstreamTrack: z.enum(["release", "branch"]).optional(),
67
77
  releaseBase: z.string().min(1).optional(),
68
- packageJsonSync: z.array(
69
- z.enum([
70
- "dependencies",
71
- "devDependencies",
72
- "peerDependencies",
73
- "optionalDependencies",
74
- "scripts",
75
- "engines",
76
- "packageManager",
77
- "overrides",
78
- "exports",
79
- "pnpm"
80
- ])
81
- ).optional(),
78
+ packageJsonSync: z.array(z.enum(PACKAGE_JSON_SYNC_KEYS)).optional(),
82
79
  syncWithPackages: z.boolean().optional(),
83
80
  fileLinkMode: z.enum(["commit", "file", "local"]).optional()
84
81
  }).strict(),
@@ -95,6 +92,12 @@ var cellaConfigSchema = z.object({
95
92
  }).strict()
96
93
  ).optional()
97
94
  }).strict();
95
+ var SYNC_APPLIED_STATUSES = ["behind", "diverged", "renamed", "ignored", "pinned"];
96
+
97
+ // src/utils/errors.ts
98
+ function errorMessage(error) {
99
+ return error instanceof Error ? error.message : String(error);
100
+ }
98
101
 
99
102
  // src/utils/managed-files.ts
100
103
  var CONFIG_FILE = "cella/cella.config.ts";
@@ -160,6 +163,10 @@ var DEFAULT_UPSTREAM_REMOTE = "cella-upstream";
160
163
  var DEFAULT_BRANCH = "main";
161
164
  var DEFAULT_SYNC_PREFIX = "cella/sync";
162
165
  var DEFAULT_RELEASE_BASE = "main";
166
+ var DEFAULT_PACKAGE_JSON_SYNC = ["dependencies", "devDependencies"];
167
+ function isUpstreamRepo(repoPath) {
168
+ return JSON.parse(readFileSync(join2(repoPath, "package.json"), "utf8")).name === "cella";
169
+ }
163
170
  function isTemporarySyncBranch(branch) {
164
171
  return branch.startsWith(`${DEFAULT_SYNC_PREFIX}/`);
165
172
  }
@@ -192,8 +199,7 @@ async function loadConfig(forkPath) {
192
199
  try {
193
200
  configModule = await import(realConfigPath);
194
201
  } catch (error) {
195
- const message = error instanceof Error ? error.message : String(error);
196
- throw new Error(`failed to load ${configPath}: ${message}`);
202
+ throw new Error(`failed to load ${configPath}: ${errorMessage(error)}`);
197
203
  }
198
204
  const result = cellaConfigSchema.safeParse(configModule.default);
199
205
  if (!result.success) {
@@ -220,7 +226,7 @@ import process4 from "process";
220
226
  // package.json
221
227
  var package_default = {
222
228
  name: "@cellajs/cli",
223
- version: "0.3.1",
229
+ version: "0.3.2",
224
230
  private: false,
225
231
  license: "MIT",
226
232
  description: "CLI for managing your monorepo and syncing with upstream Cella.",
@@ -346,6 +352,8 @@ var package_default = {
346
352
  var NAME = "cella cli";
347
353
  var VERSION = package_default.version;
348
354
  var DIVIDER = "\u2500".repeat(60);
355
+ var MENU_DIVIDER = "\u2500".repeat(40);
356
+ var COMMIT_LIST_MAX = 50;
349
357
  var warningMark = colors_default.yellow("\u26A0");
350
358
  var checkMark = colors_default.green("\u2713");
351
359
  var TerminalSpinner = class {
@@ -454,7 +462,7 @@ function getVsCodeOpenFileLink(filePath, options) {
454
462
  const url = `vscode://file${absolutePath}`;
455
463
  return hyperlink(filePath, url);
456
464
  }
457
- function formatFetchedUpstreamDetail(commitCount, commits, upstreamGitHubUrl, maxShownCommits = 50) {
465
+ function formatFetchedUpstreamDetail(commitCount, commits, upstreamGitHubUrl, maxShownCommits = COMMIT_LIST_MAX) {
458
466
  const commitLabel = commitCount === 1 ? "1 new commit" : `${commitCount} new commits`;
459
467
  const lines = [`${commitLabel} since last merge`];
460
468
  const shown = commits.slice(0, maxShownCommits);
@@ -538,19 +546,7 @@ var summaryStatusConfig = {
538
546
  var statusOrder = Object.keys(statusConfig);
539
547
  function printSummary(summary, title = "summary") {
540
548
  printSectionHeader(colors_default.cyan(title));
541
- const maxCount = Math.max(
542
- summary.managed,
543
- summary.ignored,
544
- summary.identical,
545
- summary.ahead,
546
- summary.local,
547
- summary.drifted,
548
- summary.behind,
549
- summary.diverged,
550
- summary.pinned,
551
- summary.deleted,
552
- summary.renamed
553
- );
549
+ const maxCount = Math.max(...statusOrder.map((status) => summary[status]), summary.managed);
554
550
  const countWidth = String(maxCount).length + 1;
555
551
  const printLine = (status, count) => {
556
552
  const { icon, label, color, description } = summaryStatusConfig[status];
@@ -613,11 +609,23 @@ function formatUpstreamChangedLines(file) {
613
609
  const n = file.upstreamChangedLines;
614
610
  return colors_default.yellow(` \xB7 ${n} ${n === 1 ? "line" : "lines"} changed upstream`);
615
611
  }
612
+ function protectedDiffHints(source, paths) {
613
+ const range = source?.upstreamDiffRange;
614
+ if (!range) return [];
615
+ const listed = new Set(paths);
616
+ const groups = (source?.protectedUpstreamChanges ?? []).filter((group) => group.paths.some((p) => listed.has(p)));
617
+ if (groups.length === 0) return [];
618
+ return [
619
+ "what upstream changed since the last sync, per pinned/ignored entry:",
620
+ ...groups.map((group) => ` git diff ${range} -- ${shellPath(group.entry)}`)
621
+ ];
622
+ }
616
623
  function formatUpstreamLinesAbsent(file) {
617
624
  const n = file.upstreamLinesAbsent ?? 0;
618
625
  return n > 0 ? colors_default.yellow(` \xB7 ${n} upstream ${n === 1 ? "line" : "lines"} absent`) : "";
619
626
  }
620
- function printAnalysisFileGroups(files, linkOptions) {
627
+ function printAnalysisFileGroups(files, linkOptions, diffSource) {
628
+ const protectedBehind = findProtectedBehind(files);
621
629
  printFileGroup(files, "behind", linkOptions, {
622
630
  title: colors_default.cyan("\u2193 behind on upstream")
623
631
  });
@@ -629,17 +637,18 @@ function printAnalysisFileGroups(files, linkOptions) {
629
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."
630
638
  ]
631
639
  });
632
- printFileSection(
633
- findProtectedBehind(files),
634
- `${warningMark} ${colors_default.yellow("protected but behind upstream")}`,
635
- linkOptions,
636
- {
637
- icon: protectionIcon,
638
- suffix: formatUpstreamChangedLines,
639
- dateSource: "upstream",
640
- hint: PROTECTED_BEHIND_HINT
641
- }
642
- );
640
+ printFileSection(protectedBehind, `${warningMark} ${colors_default.yellow("protected but behind upstream")}`, linkOptions, {
641
+ icon: protectionIcon,
642
+ suffix: formatUpstreamChangedLines,
643
+ dateSource: "upstream",
644
+ hint: [
645
+ PROTECTED_BEHIND_HINT,
646
+ ...protectedDiffHints(
647
+ diffSource,
648
+ protectedBehind.map((file) => file.path)
649
+ )
650
+ ]
651
+ });
643
652
  printFileGroup(files, "drifted", linkOptions, {
644
653
  title: `${warningMark} ${colors_default.yellow("drifted from upstream")}`,
645
654
  hint: "these files have fork changes but are not pinned or ignored."
@@ -653,7 +662,7 @@ function printAnalysisFileGroups(files, linkOptions) {
653
662
  function writeLogFile(forkPath, files) {
654
663
  const logPath = join3(forkPath, "cella-sync.log");
655
664
  const lines = [
656
- `cella sync analysis - ${(/* @__PURE__ */ new Date()).toISOString()}`,
665
+ `cella sync analysis: ${(/* @__PURE__ */ new Date()).toISOString()}`,
657
666
  DIVIDER,
658
667
  "",
659
668
  `complete file list (${files.length} files)`,
@@ -671,7 +680,7 @@ function writeLogFile(forkPath, files) {
671
680
  const icon = config.icon.replace(/\x1b\[[0-9;]*m/g, "");
672
681
  lines.push(` ${icon} ${config.label.padEnd(12)} ${file.path}`);
673
682
  }
674
- writeFileSync(logPath, lines.join("\n"), "utf-8");
683
+ writeFileSync(logPath, lines.join("\n"), "utf8");
675
684
  return logPath;
676
685
  }
677
686
  function printSyncComplete(result, options = {}) {
@@ -681,12 +690,12 @@ function printSyncComplete(result, options = {}) {
681
690
  const conflicts = result.conflicts.length;
682
691
  console.info();
683
692
  if (options.stagedBranch) {
684
- console.info(`${colors_default.green("\u2713")} Sync merge staged on '${options.stagedBranch}'`);
693
+ console.info(`${checkMark} sync merge staged on '${options.stagedBranch}'`);
685
694
  console.info(
686
- colors_default.dim(` ${updated} files updated, ${merged} auto-merged, ${conflicts} conflicts. Committing next...`)
695
+ colors_default.dim(` ${updated} files updated, ${merged} auto-merged, ${conflicts} conflicts. committing next...`)
687
696
  );
688
697
  } else {
689
- console.info(`${colors_default.green("\u2713")} sync complete`);
698
+ console.info(`${checkMark} sync complete`);
690
699
  console.info(colors_default.dim(` ${updated} files updated, ${merged} auto-merged, ${conflicts} conflicts`));
691
700
  }
692
701
  printProtectedConflicts(result);
@@ -702,18 +711,18 @@ function printProtectedConflicts(result) {
702
711
  `${paths.length} protected ${paths.length === 1 ? "file kept" : "files kept"} the fork version, dropping upstream changes:`
703
712
  )}`
704
713
  );
705
- for (const path4 of paths) {
706
- const file = byPath.get(path4);
714
+ for (const path of paths) {
715
+ const file = byPath.get(path);
707
716
  const icon = file ? protectionIcon(file) : statusConfig.pinned.icon;
708
- console.info(` ${icon} ${path4}${file ? formatUpstreamChangedLines(file) : ""}`);
717
+ console.info(` ${icon} ${path}${file ? formatUpstreamChangedLines(file) : ""}`);
709
718
  }
710
- console.info(colors_default.dim(` ${PROTECTED_BEHIND_HINT}`));
719
+ for (const line of [PROTECTED_BEHIND_HINT, ...protectedDiffHints(result, paths)]) console.info(colors_default.dim(` ${line}`));
711
720
  }
712
721
  function plural(count, singular, pluralForm = `${singular}s`) {
713
722
  return `${count} ${count === 1 ? singular : pluralForm}`;
714
723
  }
715
- function shellPath(path4) {
716
- return /^[\w./@+-]+$/.test(path4) ? path4 : `'${path4.replace(/'/g, `'\\''`)}'`;
724
+ function shellPath(path) {
725
+ return /^[\w./@+-]+$/.test(path) ? path : `'${path.replace(/'/g, `'\\''`)}'`;
717
726
  }
718
727
  function printIgnoredUpstreamChanges(result) {
719
728
  const groups = result.ignoredUpstreamChanges ?? [];
@@ -741,19 +750,19 @@ function printUpstreamOverrideChanges(result) {
741
750
  if (report.kind === "unreadable") {
742
751
  const side = report.side === "base" ? "the last sync point" : "the incoming ref";
743
752
  console.info(
744
- colors_default.dim(`upstream overrides not compared: upstream ${CONFIG_FILE} is missing or unreadable at ${side}`)
753
+ colors_default.dim(`upstream sync config not compared: upstream ${CONFIG_FILE} is missing or unreadable at ${side}`)
745
754
  );
746
755
  return;
747
756
  }
748
757
  const lines = [];
749
- for (const list of ["pinned", "ignored"]) {
758
+ for (const list of ["pinned", "ignored", "packageJsonSync"]) {
750
759
  for (const entry of report[list].added) lines.push(` ${colors_default.green("+")} ${list}: ${entry}`);
751
760
  for (const entry of report[list].removed) {
752
761
  lines.push(` ${colors_default.red("\u2212")} ${list}: ${entry}${colors_default.dim(" \xB7 dropped upstream, still in your config")}`);
753
762
  }
754
763
  }
755
764
  console.info(
756
- `${warningMark} ${colors_default.yellow(`upstream changed its sync overrides \xB7 ${plural(lines.length, "entry", "entries")} to review`)}`
765
+ `${warningMark} ${colors_default.yellow(`upstream changed its sync config \xB7 ${plural(lines.length, "entry", "entries")} to review`)}`
757
766
  );
758
767
  for (const line of lines) console.info(line);
759
768
  console.info(colors_default.dim(` ${CONFIG_FILE} never syncs: add or drop these by hand where they fit your app.`));
@@ -770,7 +779,7 @@ function printMaskingPinWarning(files) {
770
779
  console.info();
771
780
  console.info(
772
781
  `${warningMark} ${colors_default.yellow(
773
- `${masking.length} pinned ${many ? "files match" : "file matches"} the previous upstream but changed upstream \u2014`
782
+ `${masking.length} pinned ${many ? "files match" : "file matches"} the previous upstream but changed upstream:`
774
783
  )}`
775
784
  );
776
785
  console.info(colors_default.yellow(" the pin keeps the old fork copy and silently drops those upstream changes:"));
@@ -778,7 +787,7 @@ function printMaskingPinWarning(files) {
778
787
  console.info(colors_default.dim(` \u2A00 ${file.path}`));
779
788
  }
780
789
  console.info(
781
- colors_default.yellow(" If the fork never customized these, unpin them in cella/cella.config.ts to take upstream.")
790
+ colors_default.yellow(" if the fork never customized these, unpin them in cella/cella.config.ts to take upstream.")
782
791
  );
783
792
  console.info();
784
793
  }
@@ -795,19 +804,26 @@ function printFlagWarnings(options) {
795
804
  )
796
805
  );
797
806
  }
798
- console.info(colors_default.yellow(" Be extra careful & cherrypick what you want only."));
807
+ console.info(colors_default.yellow(" be extra careful and cherry-pick what you want only."));
799
808
  console.info();
800
809
  }
810
+ function printEngineReports(result, title) {
811
+ printSummary(result.summary, title);
812
+ printIgnoredUpstreamChanges(result);
813
+ printUpstreamOverrideChanges(result);
814
+ printMaskingPinWarning(result.files);
815
+ }
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
+ }
801
822
 
802
823
  // src/utils/overrides.ts
803
824
  import { existsSync as existsSync5 } from "fs";
804
825
  import { join as join7 } from "path";
805
826
 
806
- // src/utils/migration-notes.ts
807
- import { existsSync as existsSync4 } from "fs";
808
- import { mkdir as mkdir3, readFile as readFile2, rm, writeFile as writeFile2 } from "fs/promises";
809
- import { dirname as dirname3, join as join6 } from "path";
810
-
811
827
  // src/utils/git.ts
812
828
  import { execFile } from "child_process";
813
829
  import { existsSync as existsSync3 } from "fs";
@@ -819,9 +835,9 @@ import { promisify } from "util";
819
835
  import { mkdir, readFile, writeFile } from "fs/promises";
820
836
  import { dirname, join as join4 } from "path";
821
837
  var MANIFEST_FILE = "cella/cella.manifest.json";
822
- async function readSyncManifest(cwd) {
838
+ function parseSyncManifest(raw) {
823
839
  try {
824
- const parsed = JSON.parse(await readFile(join4(cwd, MANIFEST_FILE), "utf8"));
840
+ const parsed = JSON.parse(raw);
825
841
  const commit = parsed?.upstream?.commit;
826
842
  if (typeof commit !== "string" || !/^[0-9a-f]{40}$/i.test(commit)) return null;
827
843
  return parsed;
@@ -829,6 +845,13 @@ async function readSyncManifest(cwd) {
829
845
  return null;
830
846
  }
831
847
  }
848
+ async function readSyncManifest(cwd) {
849
+ try {
850
+ return parseSyncManifest(await readFile(join4(cwd, MANIFEST_FILE), "utf8"));
851
+ } catch {
852
+ return null;
853
+ }
854
+ }
832
855
  async function readManifestBase(cwd) {
833
856
  const manifest = await readSyncManifest(cwd);
834
857
  return manifest ? manifest.upstream.commit.toLowerCase() : null;
@@ -842,7 +865,8 @@ async function writeSyncManifest(cwd, manifest) {
842
865
 
843
866
  // src/utils/git.ts
844
867
  var execFileAsync = promisify(execFile);
845
- function sanitizeCredentials(text) {
868
+ var MAX_BUFFER = 50 * 1024 * 1024;
869
+ function redactUrlSecrets(text) {
846
870
  return text.replace(/https?:\/\/[^@\s]+@/g, (match) => {
847
871
  const protocol = match.startsWith("https") ? "https" : "http";
848
872
  return `${protocol}://***@`;
@@ -854,14 +878,14 @@ async function git(args, cwd, options = {}) {
854
878
  ...options.skipEditor ? { GIT_EDITOR: "true" } : {},
855
879
  ...options.env
856
880
  };
857
- const maxBuffer = options.maxBuffer ?? 50 * 1024 * 1024;
881
+ const maxBuffer = options.maxBuffer ?? MAX_BUFFER;
858
882
  try {
859
883
  const { stdout } = await execFileAsync("git", args, { cwd, env, maxBuffer });
860
884
  return stdout.trim();
861
885
  } catch (error) {
862
886
  if (options.ignoreErrors) return "";
863
887
  if (error instanceof Error) {
864
- error.message = sanitizeCredentials(error.message);
888
+ error.message = redactUrlSecrets(error.message);
865
889
  }
866
890
  throw error;
867
891
  }
@@ -932,8 +956,8 @@ async function flattenBranch(cwd, baseRef, message) {
932
956
  }
933
957
  async function commitSquash(cwd, message) {
934
958
  for (const file of ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE"]) {
935
- const path4 = await gitPath(cwd, file);
936
- if (existsSync3(path4)) await unlink(path4);
959
+ const path = await gitPath(cwd, file);
960
+ if (existsSync3(path)) await unlink(path);
937
961
  }
938
962
  await git(["commit", "-m", message], cwd, { skipEditor: true });
939
963
  }
@@ -965,7 +989,7 @@ async function ensureRemote(cwd, remoteName, url) {
965
989
  await git(["remote", "add", remoteName, url], cwd);
966
990
  }
967
991
  }
968
- async function fetch2(cwd, remoteName) {
992
+ async function fetchRemote(cwd, remoteName) {
969
993
  await git(["fetch", remoteName], cwd);
970
994
  }
971
995
  async function fetchUpstreamTags(cwd, remoteName) {
@@ -1023,10 +1047,8 @@ async function countCommitsBetween(cwd, fromRef, toRef) {
1023
1047
  return Number.parseInt(output.trim(), 10) || 0;
1024
1048
  }
1025
1049
  async function listCommitsBetween(cwd, fromRef, toRef, options = {}) {
1026
- const { oldestFirst = true, skip = 0, limit } = options;
1027
- const args = ["log", "--format=%H%x1f%s%x1f%ar"];
1028
- if (oldestFirst) args.push("--reverse");
1029
- args.push(`${fromRef}..${toRef}`);
1050
+ const { skip, limit } = options;
1051
+ const args = ["log", "--format=%H%x1f%s%x1f%ar", "--reverse", `${fromRef}..${toRef}`];
1030
1052
  const output = await git(args, cwd, { ignoreErrors: true });
1031
1053
  if (!output) return [];
1032
1054
  const commits = [];
@@ -1036,14 +1058,8 @@ async function listCommitsBetween(cwd, fromRef, toRef, options = {}) {
1036
1058
  if (!hash || !message || !date) continue;
1037
1059
  commits.push({ hash, message, date });
1038
1060
  }
1039
- const normalizedSkip = Math.max(0, skip);
1040
- const normalizedLimit = typeof limit === "number" && limit > 0 ? limit : void 0;
1041
- if (normalizedSkip === 0 && normalizedLimit === void 0) {
1042
- return commits;
1043
- }
1044
- const start = Math.min(normalizedSkip, commits.length);
1045
- const end = normalizedLimit === void 0 ? commits.length : Math.min(start + normalizedLimit, commits.length);
1046
- return commits.slice(start, end);
1061
+ const start = Math.max(0, skip ?? 0);
1062
+ return limit && limit > 0 ? commits.slice(start, start + limit) : commits.slice(start);
1047
1063
  }
1048
1064
  async function getFileChangeInfo(cwd, fromRef, toRef) {
1049
1065
  const info = /* @__PURE__ */ new Map();
@@ -1098,12 +1114,9 @@ async function listWorktrees(cwd) {
1098
1114
  }
1099
1115
  return paths;
1100
1116
  }
1101
- async function merge(cwd, ref, options = {}) {
1102
- const args = ["merge", "--no-ff", ref];
1103
- if (options.noCommit) args.push("--no-commit");
1104
- if (options.noEdit) args.push("--no-edit");
1117
+ async function merge(cwd, ref) {
1105
1118
  try {
1106
- await git(args, cwd, { skipEditor: true });
1119
+ await git(["merge", "--no-ff", "--no-commit", "--no-edit", ref], cwd, { skipEditor: true });
1107
1120
  return { success: true, conflicts: [] };
1108
1121
  } catch (error) {
1109
1122
  const conflicts = await getConflictedFiles(cwd);
@@ -1131,6 +1144,22 @@ async function checkoutFromRef(cwd, ref, filePath) {
1131
1144
  async function restoreWorktreeFromRef(cwd, ref, filePath) {
1132
1145
  await git(["restore", `--source=${ref}`, "--worktree", "--", filePath], cwd);
1133
1146
  }
1147
+ async function batchRestoreWorktreeFromRef(cwd, ref, filePaths) {
1148
+ const batchSize = 200;
1149
+ for (let i = 0; i < filePaths.length; i += batchSize) {
1150
+ const batch = filePaths.slice(i, i + batchSize);
1151
+ await git(["--literal-pathspecs", "restore", `--source=${ref}`, "--worktree", "--", ...batch], cwd);
1152
+ }
1153
+ }
1154
+ async function listIdenticalToRef(cwd, ref) {
1155
+ const nulSeparated = (out) => out.split("\0").filter(Boolean);
1156
+ const [tracked, changed] = await Promise.all([
1157
+ git(["ls-tree", "-r", "--name-only", "-z", ref], cwd),
1158
+ git(["diff", "--name-only", "--no-renames", "-z", ref, "--"], cwd)
1159
+ ]);
1160
+ const differs = new Set(nulSeparated(changed));
1161
+ return new Set(nulSeparated(tracked).filter((path) => !differs.has(path)));
1162
+ }
1134
1163
  async function getMergeBase(cwd, ref1, ref2) {
1135
1164
  return git(["merge-base", ref1, ref2], cwd);
1136
1165
  }
@@ -1140,6 +1169,7 @@ async function getFileChanges(cwd, baseRef, targetRef) {
1140
1169
  });
1141
1170
  const changes = /* @__PURE__ */ new Map();
1142
1171
  if (!output) return changes;
1172
+ const hashOrNull = (hash) => hash === "0".repeat(40) ? null : hash;
1143
1173
  for (const line of output.split("\n")) {
1144
1174
  if (!line) continue;
1145
1175
  const renameMatch = line.match(/^:\d+ \d+ ([a-f0-9]+) ([a-f0-9]+) R\d*\s+(.+)\t(.+)$/);
@@ -1147,10 +1177,9 @@ async function getFileChanges(cwd, baseRef, targetRef) {
1147
1177
  const [, baseHash, targetHash, oldPath, newPath] = renameMatch;
1148
1178
  changes.set(newPath, {
1149
1179
  status: "R",
1150
- baseHash: baseHash === "0".repeat(40) ? null : baseHash,
1151
- targetHash: targetHash === "0".repeat(40) ? null : targetHash,
1152
- oldPath,
1153
- newPath
1180
+ baseHash: hashOrNull(baseHash),
1181
+ targetHash: hashOrNull(targetHash),
1182
+ oldPath
1154
1183
  });
1155
1184
  continue;
1156
1185
  }
@@ -1159,8 +1188,8 @@ async function getFileChanges(cwd, baseRef, targetRef) {
1159
1188
  const [, baseHash, targetHash, status, filePath] = match;
1160
1189
  changes.set(filePath, {
1161
1190
  status,
1162
- baseHash: baseHash === "0".repeat(40) ? null : baseHash,
1163
- targetHash: targetHash === "0".repeat(40) ? null : targetHash
1191
+ baseHash: hashOrNull(baseHash),
1192
+ targetHash: hashOrNull(targetHash)
1164
1193
  });
1165
1194
  }
1166
1195
  }
@@ -1174,9 +1203,9 @@ async function getDiffStat(cwd, fromRef, toRef, paths = []) {
1174
1203
  for (const line of out.split("\n")) {
1175
1204
  if (!line.trim()) continue;
1176
1205
  const [a, d, ...rest] = line.split(" ");
1177
- const path4 = rest.join(" ");
1178
- if (!path4) continue;
1179
- stat.set(path4, {
1206
+ const path = rest.join(" ");
1207
+ if (!path) continue;
1208
+ stat.set(path, {
1180
1209
  additions: a === "-" ? null : Number(a),
1181
1210
  deletions: d === "-" ? null : Number(d)
1182
1211
  });
@@ -1258,8 +1287,8 @@ async function storeLastSyncRef(cwd, upstreamHash) {
1258
1287
  await git(["update-ref", "refs/cella/last-sync-head", head], cwd);
1259
1288
  }
1260
1289
  }
1261
- async function stagePath(cwd, path4) {
1262
- await git(["add", "--", path4], cwd, { ignoreErrors: true });
1290
+ async function stagePath(cwd, path) {
1291
+ await git(["add", "--", path], cwd, { ignoreErrors: true });
1263
1292
  }
1264
1293
  async function getStoredSyncRef(cwd) {
1265
1294
  const ref = await git(["rev-parse", "refs/cella/last-sync"], cwd, { ignoreErrors: true });
@@ -1272,14 +1301,7 @@ async function getStoredSyncHead(cwd) {
1272
1301
  async function readManifestAtRef(cwd, ref) {
1273
1302
  const raw = await git(["show", `${ref}:${MANIFEST_FILE}`], cwd, { ignoreErrors: true });
1274
1303
  if (!raw) return null;
1275
- try {
1276
- const parsed = JSON.parse(raw);
1277
- const commit = parsed?.upstream?.commit;
1278
- if (typeof commit !== "string" || !/^[0-9a-f]{40}$/i.test(commit)) return null;
1279
- return parsed;
1280
- } catch {
1281
- return null;
1282
- }
1304
+ return parseSyncManifest(raw);
1283
1305
  }
1284
1306
  async function readManifestBaseAtRef(cwd, ref) {
1285
1307
  const manifest = await readManifestAtRef(cwd, ref);
@@ -1400,8 +1422,8 @@ async function ensureSyncBase(cwd, headRef, upstreamRef) {
1400
1422
  throw new Error(
1401
1423
  `no common ancestor between the fork and '${upstreamRef}', and no sync base could be determined.
1402
1424
 
1403
- This fork has unrelated history with upstream \u2014 typical for a create-cella scaffold, or after
1404
- upstream squashed its history \u2014 and no recorded or inferable base commit exists after fetching
1425
+ This fork has unrelated history with upstream (typical for a create-cella scaffold, or after
1426
+ upstream squashed its history) and no recorded or inferable base commit exists after fetching
1405
1427
  upstream. Seed it manually with the upstream commit the fork was created from, then re-run sync:
1406
1428
 
1407
1429
  git update-ref refs/cella/last-sync <upstream-sha>
@@ -1460,9 +1482,15 @@ async function withTemporarySyncBaseGraft(cwd, headRef, baseSha, fn) {
1460
1482
  }
1461
1483
 
1462
1484
  // src/utils/migration-notes.ts
1485
+ import { existsSync as existsSync4 } from "fs";
1486
+ import { mkdir as mkdir3, readFile as readFile2, rm, writeFile as writeFile2 } from "fs/promises";
1487
+ import { dirname as dirname3, join as join6 } from "path";
1463
1488
  var NOTES_DIR = "cella/migrations";
1464
1489
  var PENDING_FILE = "cella/cella.migrations.json";
1465
1490
  var NOTE_ID = /^\d{8}T\d{4}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
1491
+ function isCodemodFile(name) {
1492
+ return name.endsWith(".ts") && !name.endsWith(".test.ts");
1493
+ }
1466
1494
  function isUpstreamOnly(filePath) {
1467
1495
  return filePath === NOTES_DIR || filePath.startsWith(`${NOTES_DIR}/`);
1468
1496
  }
@@ -1493,7 +1521,7 @@ function parseNote(id, source, fileNames) {
1493
1521
  syncBreaking: meta.get("syncBreaking") === "true",
1494
1522
  clientCacheBump: meta.get("clientCacheBump") === "true",
1495
1523
  roots: (meta.get("roots") ?? "").split(",").map((root) => root.trim()).filter(Boolean),
1496
- codemod: fileNames.find((name) => name.endsWith(".ts") && !name.endsWith(".test.ts")) ?? null
1524
+ codemod: fileNames.find(isCodemodFile) ?? null
1497
1525
  };
1498
1526
  }
1499
1527
  async function listNoteIds(cwd, ref) {
@@ -1512,23 +1540,23 @@ async function arrivedNoteIds(cwd, from, to) {
1512
1540
  return after.filter((id) => !known.has(id));
1513
1541
  }
1514
1542
  async function readPending(cwd, syncRef) {
1515
- const path4 = join6(cwd, PENDING_FILE);
1516
- if (!existsSync4(path4)) return [];
1517
- const parsed = JSON.parse(await readFile2(path4, "utf8"));
1543
+ const path = join6(cwd, PENDING_FILE);
1544
+ if (!existsSync4(path)) return [];
1545
+ const parsed = JSON.parse(await readFile2(path, "utf8"));
1518
1546
  if (Array.isArray(parsed.pending)) return parsed.pending.filter((id) => typeof id === "string");
1519
1547
  if (!Array.isArray(parsed.applied) || !syncRef) return [];
1520
1548
  const applied = new Set(parsed.applied);
1521
1549
  return (await listNoteIds(cwd, syncRef)).filter((id) => !applied.has(id));
1522
1550
  }
1523
1551
  async function writePending(cwd, ids) {
1524
- const path4 = join6(cwd, PENDING_FILE);
1552
+ const path = join6(cwd, PENDING_FILE);
1525
1553
  const pending = [...new Set(ids)].sort();
1526
1554
  if (pending.length === 0) {
1527
- await rm(path4, { force: true });
1555
+ await rm(path, { force: true });
1528
1556
  return;
1529
1557
  }
1530
- await mkdir3(dirname3(path4), { recursive: true });
1531
- await writeFile2(path4, `${JSON.stringify({ pending }, null, 2)}
1558
+ await mkdir3(dirname3(path), { recursive: true });
1559
+ await writeFile2(path, `${JSON.stringify({ pending }, null, 2)}
1532
1560
  `, "utf8");
1533
1561
  }
1534
1562
  function noteUrl(upstreamGitHubUrl, commit, id) {
@@ -1572,6 +1600,19 @@ function groupIgnoredUpstreamChanges(files, ignored) {
1572
1600
  for (const group of groups.values()) group.paths.sort();
1573
1601
  return entries.flatMap((entry) => groups.get(entry) ?? []);
1574
1602
  }
1603
+ function groupProtectedUpstreamChanges(paths, config) {
1604
+ const entries = [...config.overrides?.pinned ?? [], ...config.overrides?.ignored ?? []].map((entry) => entry.replace(/\/+$/, "")).filter(Boolean);
1605
+ const mostSpecificFirst = [...new Set(entries)].sort((a, b) => b.length - a.length);
1606
+ const groups = /* @__PURE__ */ new Map();
1607
+ for (const path of paths) {
1608
+ const entry = mostSpecificFirst.find((candidate) => isUnderAnyFolder(path, [candidate])) ?? path;
1609
+ const group = groups.get(entry) ?? { entry, paths: [] };
1610
+ group.paths.push(path);
1611
+ groups.set(entry, group);
1612
+ }
1613
+ for (const group of groups.values()) group.paths.sort();
1614
+ return [...groups.values()];
1615
+ }
1575
1616
  function isPinnedForSync(filePath, config, unpinned) {
1576
1617
  if (isManagedFile(filePath)) return true;
1577
1618
  if (unpinned) return false;
@@ -1580,7 +1621,7 @@ function isPinnedForSync(filePath, config, unpinned) {
1580
1621
  function hasGlobChars(entry) {
1581
1622
  return entry.includes("*") || entry.includes("?");
1582
1623
  }
1583
- function validateOverrides(config, forkPath) {
1624
+ async function validateOverrides(config, forkPath, upstreamRef) {
1584
1625
  const warnings = [];
1585
1626
  const pinned = config.overrides?.pinned || [];
1586
1627
  for (const entry of pinned) {
@@ -1607,6 +1648,7 @@ function validateOverrides(config, forkPath) {
1607
1648
  message: `ignored entry uses glob (not supported, use a path or folder): ${entry}`
1608
1649
  });
1609
1650
  } else if (!existsSync5(join7(forkPath, entry.replace(/\/+$/, "")))) {
1651
+ if (upstreamRef && await fileExistsAtRef(forkPath, upstreamRef, entry.replace(/\/+$/, ""))) continue;
1610
1652
  warnings.push({
1611
1653
  type: "ignored-not-found",
1612
1654
  pattern: entry,
@@ -1623,78 +1665,124 @@ function printWarnings(warnings) {
1623
1665
  }
1624
1666
 
1625
1667
  // src/cli.ts
1626
- function readOptions(opts) {
1627
- const scope = typeof opts.scope === "string" ? opts.scope : void 0;
1668
+ var str = (value) => typeof value === "string" ? value : void 0;
1669
+ var flag = (value) => value === true;
1670
+ function readOptions(opts, operands = []) {
1671
+ const scope = str(opts.scope);
1628
1672
  if (scope && scope !== "all" && scope !== "risk" && scope !== "protected") {
1629
1673
  throw new Error(`invalid --scope '${scope}'. expected one of: all, risk, protected`);
1630
1674
  }
1631
1675
  const normalizedScope = scope;
1676
+ const run = str(opts.run);
1677
+ if (operands.length > 0 && !run) {
1678
+ throw new Error(`unexpected argument '${operands[0]}'. arguments after the options only go with --run <id>`);
1679
+ }
1632
1680
  return {
1633
- logFile: opts.log === true,
1634
- verbose: opts.verbose === true,
1635
- list: opts.list === true,
1636
- json: opts.json === true,
1637
- diff: typeof opts.diff === "string" ? opts.diff : void 0,
1638
- openDiff: typeof opts.openDiff === "string" ? opts.openDiff : void 0,
1681
+ logFile: flag(opts.log),
1682
+ verbose: flag(opts.verbose),
1683
+ list: flag(opts.list),
1684
+ json: flag(opts.json),
1685
+ diff: str(opts.diff),
1686
+ openDiff: str(opts.openDiff),
1639
1687
  scope: normalizedScope,
1640
- fork: typeof opts.fork === "string" ? opts.fork : void 0,
1641
- hard: opts.hard === true,
1642
- unpinned: opts.unpinned === true,
1688
+ fork: str(opts.fork),
1689
+ hard: flag(opts.hard),
1690
+ unpinned: flag(opts.unpinned),
1643
1691
  track: opts.track === "release" || opts.track === "branch" ? opts.track : void 0,
1644
- ref: typeof opts.ref === "string" && opts.ref ? opts.ref : void 0,
1645
- force: opts.force === true,
1646
- checkOverrides: opts.checkOverrides === true,
1647
- coverage: opts.coverage === true,
1648
- all: opts.all === true,
1649
- show: typeof opts.show === "string" ? opts.show : void 0,
1650
- extract: typeof opts.extract === "string" ? opts.extract : void 0,
1692
+ ref: str(opts.ref) || void 0,
1693
+ keepConfig: flag(opts.keepConfig),
1694
+ force: flag(opts.force),
1695
+ checkOverrides: flag(opts.checkOverrides),
1696
+ coverage: flag(opts.coverage),
1697
+ since: str(opts.since) || void 0,
1698
+ md: flag(opts.md),
1699
+ all: flag(opts.all),
1700
+ show: str(opts.show),
1701
+ extract: str(opts.extract),
1702
+ run,
1703
+ script: str(opts.script),
1704
+ runArgs: run ? operands : void 0,
1651
1705
  mark: Array.isArray(opts.mark) ? opts.mark.filter((id) => typeof id === "string") : void 0
1652
1706
  };
1653
1707
  }
1708
+ var logOption = {
1709
+ flags: "--log",
1710
+ description: "write complete file list to cella-sync.log"
1711
+ };
1712
+ var jsonOption = {
1713
+ flags: "--json",
1714
+ description: "machine-readable output for tooling/agents"
1715
+ };
1716
+ var trackOption = {
1717
+ flags: "--track <mode>",
1718
+ description: "override upstream tracking for this run: release|branch"
1719
+ };
1720
+ var refOption = {
1721
+ flags: "--ref <ref>",
1722
+ description: "pin the upstream commit for this run: sha, release tag or branch"
1723
+ };
1724
+ var hardOption = {
1725
+ flags: "--hard",
1726
+ description: "overwrite drifted files with upstream version (aggressive realignment)"
1727
+ };
1728
+ var verboseOption = {
1729
+ flags: "-V, --verbose",
1730
+ description: "show detailed output during operations"
1731
+ };
1732
+ var forkOption = {
1733
+ flags: "--fork <name>",
1734
+ description: "pre-select a fork by name (skips the fork selection prompt)"
1735
+ };
1654
1736
  var serviceDefinitions = [
1655
1737
  {
1656
1738
  name: "analyze",
1657
1739
  description: "dry run to see what would change on sync",
1658
1740
  options: [
1659
- { flags: "--log", description: "write complete file list to cella-sync.log" },
1741
+ logOption,
1660
1742
  { flags: "--list", description: "non-interactive output for tooling (one file per line)" },
1661
- { flags: "--json", description: "machine-readable output for tooling/agents" },
1743
+ jsonOption,
1662
1744
  { flags: "--scope <scope>", description: "analyze scope for --list/--json: all|risk|protected" },
1663
- { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" },
1664
- { flags: "--ref <ref>", description: "pin the upstream commit for this run: sha, release tag or branch" },
1665
- { flags: "--diff <path>", description: "print unified diff for one file, then exit" },
1745
+ trackOption,
1746
+ refOption,
1747
+ { flags: "--diff <path>", description: "print the unified diff for one file, then exit" },
1666
1748
  { flags: "--open-diff <path>", description: "open a browser diff for one file, then exit" }
1667
1749
  ],
1668
1750
  includeInMenu: (context) => !context.isUpstreamRepo
1669
1751
  },
1670
1752
  {
1671
1753
  name: "sync",
1672
- description: "merge upstream changes into your app",
1754
+ description: "merge upstream changes onto a fresh branch, sync package.json and open a squash-merge PR",
1673
1755
  options: [
1674
- { flags: "--log", description: "write complete file list to cella-sync.log" },
1675
- { flags: "--hard", description: "overwrite drifted files with upstream version (aggressive realignment)" },
1756
+ logOption,
1757
+ hardOption,
1676
1758
  { flags: "--unpinned", description: "ignore pinned files (except package.json) to resurface upstream changes" },
1677
- { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" },
1678
- { flags: "--ref <ref>", description: "pin the upstream commit for this run: sha, release tag or branch" }
1759
+ trackOption,
1760
+ refOption,
1761
+ { flags: "--keep-config", description: "merge with your sync config as it stands when upstream changed its own" }
1679
1762
  ],
1680
- includeInMenu: (context) => !context.isUpstreamRepo,
1681
- menuDescription: () => "merge upstream changes + sync package.json"
1763
+ includeInMenu: (context) => !context.isUpstreamRepo
1682
1764
  },
1683
1765
  {
1684
1766
  name: "migrate",
1685
1767
  description: "list the upstream migration notes this app has not handled yet",
1686
1768
  options: [
1687
1769
  { flags: "--all", description: "list every upstream note, handled or not" },
1688
- { flags: "--json", description: "machine-readable output for tooling/agents" },
1770
+ jsonOption,
1689
1771
  { flags: "--show <id>", description: "print one note's README" },
1690
1772
  { flags: "--extract <id>", description: "write one note's folder under node_modules/.cache to run its codemod" },
1773
+ { flags: "--run <id>", description: "run one note's codemod; files identical to upstream stay as they are" },
1774
+ { flags: "--script <file>", description: "with --run: the script to run when the note folder holds several" },
1691
1775
  { flags: "--mark <ids...>", description: "record notes as handled" }
1692
1776
  ],
1777
+ operands: {
1778
+ flags: "[codemodArgs...]",
1779
+ description: "with --run, after `--`: arguments for the codemod (default: inventory and the note's roots)"
1780
+ },
1693
1781
  includeInMenu: (context) => !context.isUpstreamRepo
1694
1782
  },
1695
1783
  {
1696
1784
  name: "audit",
1697
- description: "check for outdated packages & vulnerabilities",
1785
+ description: "check for outdated packages and vulnerabilities",
1698
1786
  options: [
1699
1787
  { flags: "--list", description: "skip interactive update prompts after printing audit results" },
1700
1788
  { flags: "--force", description: "bypass pnpm metadata cache for fresh registry data" },
@@ -1703,23 +1791,27 @@ var serviceDefinitions = [
1703
1791
  },
1704
1792
  {
1705
1793
  name: "forks",
1706
- description: "sync downstream to local fork repositories",
1794
+ description: "run normal sync inside local fork repositories",
1707
1795
  options: [
1708
- { flags: "--fork <name>", description: "pre-select fork by name (skips fork selection prompt)" },
1796
+ forkOption,
1709
1797
  { flags: "--log", description: "write complete file list to cella-sync.log for each synced fork" },
1710
- { flags: "-V, --verbose", description: "show detailed output during operations" },
1711
- { flags: "--hard", description: "overwrite drifted files with upstream version (aggressive realignment)" }
1798
+ verboseOption,
1799
+ hardOption,
1800
+ {
1801
+ flags: "--keep-config",
1802
+ description: "merge with the fork's sync config as it stands when upstream changed its own"
1803
+ }
1712
1804
  ],
1713
1805
  includeInMenu: (context) => context.hasForks
1714
1806
  },
1715
1807
  {
1716
1808
  name: "contributions",
1717
- description: "pull and adopt changes from forks",
1809
+ description: "pull and adopt changes from local forks",
1718
1810
  options: [
1719
- { flags: "--fork <name>", description: "select a specific fork directly (skips fork selection)" },
1720
- { flags: "--list", description: "non-interactive output (one file per line)" },
1721
- { flags: "--json", description: "machine-readable JSON output for tooling/agents" },
1722
- { flags: "--diff <path>", description: "print the unified diff for a single contributed file, then exit" }
1811
+ forkOption,
1812
+ { flags: "--list", description: "non-interactive output for tooling (tab-separated rows)" },
1813
+ jsonOption,
1814
+ { flags: "--diff <path>", description: "print the unified diff for one contributed file, then exit" }
1723
1815
  ],
1724
1816
  includeInMenu: (context) => context.hasForks
1725
1817
  },
@@ -1727,26 +1819,31 @@ var serviceDefinitions = [
1727
1819
  name: "stats",
1728
1820
  description: "count files by category and workspace package",
1729
1821
  options: [
1730
- { flags: "-V, --verbose", description: "show detailed output during operations" },
1731
- { flags: "--coverage", description: "regenerate test coverage before showing the stats summary" }
1822
+ verboseOption,
1823
+ { flags: "--coverage", description: "regenerate test coverage before showing the stats summary" },
1824
+ {
1825
+ flags: "--since <ref>",
1826
+ description: "count what the current branch changed since it left <ref>, by kind of file"
1827
+ },
1828
+ { flags: "--md", description: "with --since: print the table as markdown for a pull request description" }
1732
1829
  ]
1733
1830
  }
1734
1831
  ];
1735
- async function getMenuContext(userConfig, forkPath) {
1832
+ function getMenuContext(userConfig, forkPath) {
1736
1833
  return {
1737
1834
  hasForks: (userConfig.forks?.length ?? 0) > 0,
1738
- isUpstreamRepo: JSON.parse(readFileSync(join8(forkPath, "package.json"), "utf8")).name === "cella"
1835
+ isUpstreamRepo: isUpstreamRepo(forkPath)
1739
1836
  };
1740
1837
  }
1741
1838
  function buildServiceChoices(context) {
1742
1839
  const label = (name) => name.padEnd(14);
1743
1840
  const baseChoices = serviceDefinitions.filter((service) => service.includeInMenu?.(context) ?? true).map((service) => ({
1744
1841
  value: service.name,
1745
- name: `${label(service.name)}${colors_default.dim(service.menuDescription?.(context) ?? service.description)}`
1842
+ name: `${label(service.name)}${colors_default.dim(service.description)}`
1746
1843
  }));
1747
1844
  return [
1748
1845
  ...baseChoices,
1749
- { type: "separator", separator: "\u2500".repeat(40) },
1846
+ { type: "separator", separator: MENU_DIVIDER },
1750
1847
  { value: "exit", name: colors_default.red(`${label("exit")}${colors_default.dim("quit without doing anything")}`) }
1751
1848
  ];
1752
1849
  }
@@ -1755,10 +1852,13 @@ function addServiceCommand(program, definition, setSelection) {
1755
1852
  for (const option of definition.options ?? []) {
1756
1853
  command.option(option.flags, option.description);
1757
1854
  }
1758
- command.action((opts) => {
1855
+ if (definition.operands) command.argument(definition.operands.flags, definition.operands.description);
1856
+ command.action((...args) => {
1857
+ const operands = definition.operands ? args[0] : [];
1858
+ const opts = definition.operands ? args[1] : args[0];
1759
1859
  setSelection({
1760
1860
  service: definition.name,
1761
- options: readOptions(opts)
1861
+ options: readOptions(opts, operands)
1762
1862
  });
1763
1863
  });
1764
1864
  }
@@ -1775,8 +1875,10 @@ function buildProgram(setSelection) {
1775
1875
  " $ cella sync --unpinned",
1776
1876
  " $ cella sync --track branch",
1777
1877
  " $ cella sync --ref 4f7d87c",
1878
+ " $ cella migrate --run 20261001T2116-tailwind-class-conventions -- rewrite frontend/src",
1778
1879
  " $ cella migrate --mark 20261002T0614-config-switch",
1779
1880
  " $ cella audit --check-overrides",
1881
+ " $ cella stats --since origin/main --md",
1780
1882
  " $ cella contributions --fork raak --json"
1781
1883
  ].join("\n")
1782
1884
  );
@@ -1799,7 +1901,7 @@ function parseCommandLine(argv) {
1799
1901
  async function promptForService(userConfig, forkPath) {
1800
1902
  const selected = await select({
1801
1903
  message: "choose a service:",
1802
- choices: buildServiceChoices(await getMenuContext(userConfig, forkPath)),
1904
+ choices: buildServiceChoices(getMenuContext(userConfig, forkPath)),
1803
1905
  loop: false
1804
1906
  });
1805
1907
  if (selected === "exit") {
@@ -1822,9 +1924,10 @@ function buildRuntimeConfig(userConfig, forkPath, selection) {
1822
1924
  }
1823
1925
  async function parseCli(userConfig, forkPath) {
1824
1926
  const selection = parseCommandLine(process5.argv);
1825
- if (selection.options.json || selection.options.diff) setJsonMode(true);
1927
+ const machineList = selection.options.list && selection.service !== "audit";
1928
+ if (selection.options.json || selection.options.diff || selection.options.md || machineList) setJsonMode(true);
1826
1929
  printHeader();
1827
- const warnings = validateOverrides(userConfig, forkPath);
1930
+ const warnings = await validateOverrides(userConfig, forkPath, resolveUpstream(userConfig.settings).branchRef);
1828
1931
  if (warnings.length > 0) {
1829
1932
  printWarnings(warnings);
1830
1933
  console.info();
@@ -1842,14 +1945,14 @@ import { basename as basename4 } from "path";
1842
1945
  import { execFileSync } from "child_process";
1843
1946
  import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
1844
1947
  import { tmpdir } from "os";
1845
- import { basename as basename2, join as join9 } from "path";
1948
+ import { basename as basename2, join as join8 } from "path";
1846
1949
  import process6 from "process";
1847
1950
  function gitDiffFile(cwd, range, filePath, options = {}) {
1848
1951
  const args = ["diff", "--no-color"];
1849
1952
  if (options.dstPrefix) args.push("--src-prefix=cella/", `--dst-prefix=${options.dstPrefix}/`);
1850
1953
  args.push(range, "--", filePath);
1851
1954
  try {
1852
- return execFileSync("git", args, { cwd, maxBuffer: 50 * 1024 * 1024 });
1955
+ return execFileSync("git", args, { cwd, maxBuffer: MAX_BUFFER });
1853
1956
  } catch (error) {
1854
1957
  const stderr = error.stderr?.toString().trim();
1855
1958
  throw new Error(stderr || `failed to diff ${filePath}`);
@@ -1936,9 +2039,9 @@ function openWithDefaultApp(target) {
1936
2039
  }
1937
2040
  async function openDiffInBrowser(patch, meta, repoPath) {
1938
2041
  const html = await renderDiffPage(patch, meta);
1939
- const dir = join9(tmpdir(), `cella-diff-${basename2(repoPath)}`);
2042
+ const dir = join8(tmpdir(), `cella-diff-${basename2(repoPath)}`);
1940
2043
  mkdirSync(dir, { recursive: true });
1941
- const outPath = join9(dir, `${meta.filePath.replace(/[^\w.-]+/g, "-")}.html`);
2044
+ const outPath = join8(dir, `${meta.filePath.replace(/[^\w.-]+/g, "-")}.html`);
1942
2045
  writeFileSync2(outPath, html);
1943
2046
  openWithDefaultApp(outPath);
1944
2047
  return outPath;
@@ -1947,13 +2050,13 @@ async function openDiffInBrowser(patch, meta, repoPath) {
1947
2050
  // src/services/merge-engine.ts
1948
2051
  import { existsSync as existsSync7 } from "fs";
1949
2052
  import { rm as rm2 } from "fs/promises";
1950
- import { join as join11 } from "path";
2053
+ import { join as join10 } from "path";
1951
2054
 
1952
2055
  // src/utils/cleanup.ts
1953
2056
  import { createHash } from "crypto";
1954
2057
  import { existsSync as existsSync6, rmSync } from "fs";
1955
2058
  import { tmpdir as tmpdir2 } from "os";
1956
- import { basename as basename3, join as join10, resolve as resolve4 } from "path";
2059
+ import { basename as basename3, join as join9, resolve as resolve4 } from "path";
1957
2060
  import process7 from "process";
1958
2061
  var WORKTREE_PREFIXES = {
1959
2062
  sync: "cella-sync-",
@@ -1961,10 +2064,10 @@ var WORKTREE_PREFIXES = {
1961
2064
  };
1962
2065
  function buildWorktreePath(kind, repoPath) {
1963
2066
  const key = createHash("sha256").update(resolve4(repoPath)).digest("hex").slice(0, 8);
1964
- return join10(tmpdir2(), `${WORKTREE_PREFIXES[kind]}${basename3(repoPath)}-${key}`);
2067
+ return join9(tmpdir2(), `${WORKTREE_PREFIXES[kind]}${basename3(repoPath)}-${key}`);
1965
2068
  }
1966
2069
  function legacyWorktreePath(kind, repoPath) {
1967
- return join10(tmpdir2(), `${WORKTREE_PREFIXES[kind]}${basename3(repoPath)}`);
2070
+ return join9(tmpdir2(), `${WORKTREE_PREFIXES[kind]}${basename3(repoPath)}`);
1968
2071
  }
1969
2072
  function getWorktreePath(repoPath) {
1970
2073
  return buildWorktreePath("sync", repoPath);
@@ -2003,7 +2106,7 @@ async function cleanupLeftoverWorktrees(repoPath) {
2003
2106
  }
2004
2107
  async function handleAbort(signal) {
2005
2108
  console.info();
2006
- console.info(`${warningMark} Interrupted (${signal}) - cleaning up...`);
2109
+ console.info(`${warningMark} interrupted (${signal}): cleaning up...`);
2007
2110
  if (currentRepoPath && currentWorktreePath) {
2008
2111
  try {
2009
2112
  await mergeAbort(currentWorktreePath);
@@ -2011,11 +2114,9 @@ async function handleAbort(signal) {
2011
2114
  }
2012
2115
  try {
2013
2116
  await cleanupWorktree(currentRepoPath, currentWorktreePath);
2014
- console.info(`${colors_default.green("\u2713")} no changes were made to your repository.`);
2117
+ console.info(`${checkMark} no changes were made to your repository.`);
2015
2118
  } catch (error) {
2016
- console.error(
2017
- `${colors_default.red("\u2717")} failed to clean up worktree: ${error instanceof Error ? error.message : "unknown error"}`
2018
- );
2119
+ console.error(`${colors_default.red("\u2717")} failed to clean up worktree: ${errorMessage(error)}`);
2019
2120
  }
2020
2121
  }
2021
2122
  process7.exit(1);
@@ -2111,25 +2212,44 @@ function readProperty(object, name, sourceFile) {
2111
2212
  }
2112
2213
  return void 0;
2113
2214
  }
2215
+ function readStringEntries(node) {
2216
+ if (!Node.isArrayLiteralExpression(node)) return null;
2217
+ const entries = [];
2218
+ for (const element of node.getElements()) {
2219
+ if (Node.isStringLiteral(element) || Node.isNoSubstitutionTemplateLiteral(element)) {
2220
+ entries.push(element.getLiteralValue());
2221
+ }
2222
+ }
2223
+ return entries;
2224
+ }
2225
+ function readPackageJsonSync(config, sourceFile) {
2226
+ const settings = readProperty(config, "settings", sourceFile);
2227
+ if (settings === void 0) return [...DEFAULT_PACKAGE_JSON_SYNC];
2228
+ if (!settings || !Node.isObjectLiteralExpression(settings)) return null;
2229
+ const keys = readProperty(settings, "packageJsonSync", sourceFile);
2230
+ if (keys === void 0) return [...DEFAULT_PACKAGE_JSON_SYNC];
2231
+ return keys ? readStringEntries(keys) : null;
2232
+ }
2114
2233
  function parseOverrideLists(source) {
2115
2234
  try {
2116
2235
  const project = new Project2({ useInMemoryFileSystem: true, skipLoadingLibFiles: true });
2117
2236
  const sourceFile = project.createSourceFile("cella.config.ts", source);
2118
2237
  const config = findConfigObject(sourceFile);
2119
2238
  if (!config) return null;
2239
+ const lists = {
2240
+ pinned: [],
2241
+ ignored: [],
2242
+ packageJsonSync: readPackageJsonSync(config, sourceFile)
2243
+ };
2120
2244
  const overrides = readProperty(config, "overrides", sourceFile);
2121
- if (overrides === void 0) return { pinned: [], ignored: [] };
2245
+ if (overrides === void 0) return lists;
2122
2246
  if (!overrides || !Node.isObjectLiteralExpression(overrides)) return null;
2123
- const lists = { pinned: [], ignored: [] };
2124
2247
  for (const key of LIST_KEYS) {
2125
2248
  const list = readProperty(overrides, key, sourceFile);
2126
2249
  if (list === void 0) continue;
2127
- if (!list || !Node.isArrayLiteralExpression(list)) return null;
2128
- for (const element of list.getElements()) {
2129
- if (Node.isStringLiteral(element) || Node.isNoSubstitutionTemplateLiteral(element)) {
2130
- lists[key].push(element.getLiteralValue());
2131
- }
2132
- }
2250
+ const entries = list ? readStringEntries(list) : null;
2251
+ if (!entries) return null;
2252
+ lists[key] = entries;
2133
2253
  }
2134
2254
  return lists;
2135
2255
  } catch {
@@ -2149,11 +2269,19 @@ function diffOverrideLists(base, incoming, fork) {
2149
2269
  removed: [...baseSet].filter((entry) => !incomingSet.has(entry) && forkEntries.includes(entry))
2150
2270
  };
2151
2271
  };
2152
- return { pinned: diff("pinned"), ignored: diff("ignored") };
2272
+ const diffKeys = () => {
2273
+ const [baseKeys, incomingKeys, forkKeys] = [base.packageJsonSync, incoming.packageJsonSync, fork.packageJsonSync];
2274
+ if (!baseKeys || !incomingKeys || !forkKeys) return { added: [], removed: [] };
2275
+ return {
2276
+ added: [...new Set(incomingKeys)].filter((key) => !baseKeys.includes(key) && !forkKeys.includes(key)),
2277
+ removed: [...new Set(baseKeys)].filter((key) => !incomingKeys.includes(key) && forkKeys.includes(key))
2278
+ };
2279
+ };
2280
+ return { pinned: diff("pinned"), ignored: diff("ignored"), packageJsonSync: diffKeys() };
2153
2281
  }
2154
2282
  async function readConfigSourceAtRef(repoPath, ref) {
2155
- for (const path4 of CONFIG_FILE_PATHS) {
2156
- const source = await git(["show", `${ref}:${path4}`], repoPath, { ignoreErrors: true });
2283
+ for (const path of CONFIG_FILE_PATHS) {
2284
+ const source = await git(["show", `${ref}:${path}`], repoPath, { ignoreErrors: true });
2157
2285
  if (source) return source;
2158
2286
  }
2159
2287
  return null;
@@ -2170,10 +2298,11 @@ async function compareUpstreamOverrides(repoPath, mergeBaseRef, incomingRef, for
2170
2298
  if (!incoming) return { kind: "unreadable", side: "incoming" };
2171
2299
  const fork = {
2172
2300
  pinned: forkConfig.overrides?.pinned ?? [],
2173
- ignored: forkConfig.overrides?.ignored ?? []
2301
+ ignored: forkConfig.overrides?.ignored ?? [],
2302
+ packageJsonSync: forkConfig.settings.packageJsonSync ?? DEFAULT_PACKAGE_JSON_SYNC
2174
2303
  };
2175
2304
  const changes = diffOverrideLists(base, incoming, fork);
2176
- const count = LIST_KEYS.reduce((n, key) => n + changes[key].added.length + changes[key].removed.length, 0);
2305
+ const count = Object.values(changes).reduce((n, list) => n + list.added.length + list.removed.length, 0);
2177
2306
  return count > 0 ? { kind: "changes", ...changes } : void 0;
2178
2307
  }
2179
2308
 
@@ -2297,10 +2426,8 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
2297
2426
  status = fileIsPinned ? "pinned" : "diverged";
2298
2427
  } else if (forkChanged && !upstreamChanged2) {
2299
2428
  status = fileIsPinned ? "ahead" : predicates.hard ? "behind" : "drifted";
2300
- } else if (!forkChanged && upstreamChanged2) {
2301
- status = "behind";
2302
2429
  } else {
2303
- status = "identical";
2430
+ status = "behind";
2304
2431
  }
2305
2432
  }
2306
2433
  const upstreamChanged = (fileIsPinned || fileIsIgnored) && inUpstream && upstreamHash !== baseHash && forkHash !== baseHash;
@@ -2385,6 +2512,21 @@ async function enrichChangeInfo(repoPath, files, mergeBaseRef, localRef, incomin
2385
2512
  }
2386
2513
 
2387
2514
  // src/services/merge-engine.ts
2515
+ var BehindSyncPointError = class extends Error {
2516
+ };
2517
+ var UpstreamConfigChangedError = class extends Error {
2518
+ report;
2519
+ constructor(report) {
2520
+ super(
2521
+ [
2522
+ "upstream changed its sync config and yours does not follow it: stopped before the merge, nothing changed.",
2523
+ ` add or drop the entries that fit your app in ${CONFIG_FILE}, commit, then rerun.`,
2524
+ " to merge with your config as it stands, rerun with --keep-config."
2525
+ ].join("\n")
2526
+ );
2527
+ this.report = report;
2528
+ }
2529
+ };
2388
2530
  function getGitHubBaseUrl(remoteUrl) {
2389
2531
  const sshMatch = remoteUrl.match(/git@github\.com:([^/]+)\/([^.]+)(?:\.git)?$/);
2390
2532
  if (sshMatch) {
@@ -2396,10 +2538,13 @@ function getGitHubBaseUrl(remoteUrl) {
2396
2538
  }
2397
2539
  return null;
2398
2540
  }
2541
+ function isProtectedFile(file) {
2542
+ return file.isIgnored || file.isPinned;
2543
+ }
2399
2544
  function syncPredicates(config) {
2400
2545
  return {
2401
- isIgnored: (path4) => isIgnored(path4, config),
2402
- isPinned: (path4) => isPinnedForSync(path4, config, config.unpinned),
2546
+ isIgnored: (path) => isIgnored(path, config),
2547
+ isPinned: (path) => isPinnedForSync(path, config, config.unpinned),
2403
2548
  hard: config.hard
2404
2549
  };
2405
2550
  }
@@ -2418,6 +2563,7 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
2418
2563
  for (const file of analyzedFiles) {
2419
2564
  const { path: filePath, status, isPinned: pinned, isIgnored: ignored, existsInFork } = file;
2420
2565
  if (status === "identical" || status === "ahead") continue;
2566
+ if (!isProtectedFile(file)) continue;
2421
2567
  if (ignored && existsInFork) {
2422
2568
  batchRestorePaths.push(filePath);
2423
2569
  } else if (ignored && !existsInFork) {
@@ -2429,12 +2575,7 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
2429
2575
  }
2430
2576
  }
2431
2577
  onProgress?.("starting merge in fork...");
2432
- await withTemporarySyncBaseGraft(
2433
- forkPath,
2434
- "HEAD",
2435
- mergeBaseRef,
2436
- () => merge(forkPath, upstreamRef, { noCommit: true, noEdit: true })
2437
- );
2578
+ await withTemporarySyncBaseGraft(forkPath, "HEAD", mergeBaseRef, () => merge(forkPath, upstreamRef));
2438
2579
  if (batchRestorePaths.length > 0) {
2439
2580
  onProgress?.(`batch restoring ${batchRestorePaths.length} pinned/ignored files...`);
2440
2581
  await batchRestoreToHead(forkPath, batchRestorePaths);
@@ -2447,29 +2588,11 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
2447
2588
  }
2448
2589
  }
2449
2590
  for (const file of analyzedFiles) {
2450
- const { path: filePath, status, isPinned: pinned, isIgnored: ignored, existsInFork } = file;
2591
+ const { path: filePath, status, existsInFork } = file;
2451
2592
  if (status === "identical" || status === "ahead") {
2452
2593
  continue;
2453
2594
  }
2454
- if (ignored) continue;
2455
- if (pinned && !file.renamedFrom) continue;
2456
- if (pinned && file.renamedFrom) {
2457
- const oldPathExists = await fileExistsInWorktree(forkPath, file.renamedFrom);
2458
- if (oldPathExists) {
2459
- onProgress?.(`\u2192 ${file.renamedFrom} \u2192 ${filePath}: moving fork content (pinned rename)`);
2460
- try {
2461
- await gitMv(forkPath, file.renamedFrom, filePath);
2462
- } catch {
2463
- await removeFileFully(forkPath, file.renamedFrom);
2464
- await checkoutFromRef(forkPath, "HEAD", file.renamedFrom).catch(() => {
2465
- });
2466
- }
2467
- } else {
2468
- onProgress?.(`\u2192 ${filePath}: keeping fork content (pinned rename, old path removed)`);
2469
- await restoreToHead(forkPath, filePath);
2470
- }
2471
- continue;
2472
- }
2595
+ if (isProtectedFile(file)) continue;
2473
2596
  if (status === "diverged") {
2474
2597
  onProgress?.(`\u2192 ${filePath}: using git merge result (diverged)`);
2475
2598
  continue;
@@ -2514,6 +2637,7 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
2514
2637
  }
2515
2638
  }
2516
2639
  const protectedConflicts = analyzedFiles.filter((file) => file.upstreamChanged).map((file) => file.path);
2640
+ const protectedConflictSet = new Set(protectedConflicts);
2517
2641
  const gitConflicts = await getConflictedFiles(forkPath);
2518
2642
  for (const filePath of gitConflicts) {
2519
2643
  const isProtected = isIgnored(filePath, config) || isPinnedForSync(filePath, config, config.unpinned);
@@ -2521,7 +2645,10 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
2521
2645
  if (await fileExistsAtRef(forkPath, "HEAD", filePath)) {
2522
2646
  onProgress?.(`\u2192 ${filePath}: keeping fork (protected conflict)`);
2523
2647
  await restoreToHead(forkPath, filePath);
2524
- if (!protectedConflicts.includes(filePath)) protectedConflicts.push(filePath);
2648
+ if (!protectedConflictSet.has(filePath)) {
2649
+ protectedConflictSet.add(filePath);
2650
+ protectedConflicts.push(filePath);
2651
+ }
2525
2652
  } else {
2526
2653
  onProgress?.(`\u2192 ${filePath}: removing (protected conflict, not in fork)`);
2527
2654
  await removeFileFully(forkPath, filePath);
@@ -2553,8 +2680,7 @@ function calculateSummary(files) {
2553
2680
  pinned: 0,
2554
2681
  ignored: 0,
2555
2682
  deleted: 0,
2556
- renamed: 0,
2557
- total: files.length
2683
+ renamed: 0
2558
2684
  };
2559
2685
  for (const file of files) {
2560
2686
  if (file.status !== "identical" && isManagedFile(file.path)) {
@@ -2600,7 +2726,7 @@ async function prepareUpstream(config, onProgress, onStep) {
2600
2726
  const track = config.track ?? configTrack;
2601
2727
  await ensureRemote(forkPath, remoteName, config.settings.upstreamUrl);
2602
2728
  onProgress?.(`fetching upstream (${remoteName})...`);
2603
- await fetch2(forkPath, remoteName);
2729
+ await fetchRemote(forkPath, remoteName);
2604
2730
  let upstreamRef = branchRef;
2605
2731
  let releaseTag;
2606
2732
  if (config.ref) {
@@ -2638,21 +2764,23 @@ async function prepareUpstream(config, onProgress, onStep) {
2638
2764
  getShortSha(forkPath, syncPoint)
2639
2765
  ]);
2640
2766
  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}.`;
2641
- throw new Error(
2767
+ throw new BehindSyncPointError(
2642
2768
  `upstream ${upstreamLabel} (${upstreamShort}) is behind the last sync point ${syncShort}: the fork already has it, and syncing it would revert the upstream changes after it.
2643
2769
  ${hint}`
2644
2770
  );
2645
2771
  }
2646
2772
  const commitCount = await countCommitsBetween(forkPath, mergeBase, upstreamRef);
2647
- const commitListMax = 50;
2648
- const commitSkip = commitCount > commitListMax ? commitCount - commitListMax : 0;
2649
- const upstreamCommits = commitCount > 0 ? await listCommitsBetween(forkPath, mergeBase, upstreamRef, {
2650
- oldestFirst: true,
2651
- skip: commitSkip,
2652
- limit: commitListMax
2653
- }) : [];
2773
+ const commitSkip = commitCount > COMMIT_LIST_MAX ? commitCount - COMMIT_LIST_MAX : 0;
2774
+ const upstreamCommits = commitCount > 0 ? await listCommitsBetween(forkPath, mergeBase, upstreamRef, { skip: commitSkip, limit: COMMIT_LIST_MAX }) : [];
2654
2775
  onStep?.("fetched upstream", formatFetchedUpstreamDetail(commitCount, upstreamCommits, upstreamGitHubUrl));
2655
- return { upstreamRef, releaseTag, mergeBase, upstreamGitHubUrl, upstreamCommit, upstreamCommits };
2776
+ return { upstreamRef, releaseTag, mergeBase, syncPoint, upstreamGitHubUrl, upstreamCommit, upstreamCommits };
2777
+ }
2778
+ async function assertSyncConfigFollowed(config, ctx) {
2779
+ if (config.keepConfig) return;
2780
+ const report = await compareUpstreamOverrides(config.forkPath, ctx.syncPoint, ctx.upstreamRef, config).catch(
2781
+ () => void 0
2782
+ );
2783
+ if (report?.kind === "changes") throw new UpstreamConfigChangedError(report);
2656
2784
  }
2657
2785
  function resultMeta(config, ctx) {
2658
2786
  return {
@@ -2664,7 +2792,7 @@ function resultMeta(config, ctx) {
2664
2792
  upstreamCommits: ctx.upstreamCommits
2665
2793
  };
2666
2794
  }
2667
- async function upstreamOnlyReports(config, ctx, files) {
2795
+ async function upstreamOnlyReports(config, ctx, files, protectedConflicts) {
2668
2796
  const { forkPath } = config;
2669
2797
  try {
2670
2798
  const [baseSha, upstreamSha, upstreamOverrides] = await Promise.all([
@@ -2676,7 +2804,8 @@ async function upstreamOnlyReports(config, ctx, files) {
2676
2804
  return {
2677
2805
  upstreamDiffRange: `${baseSha}..${upstreamSha}`,
2678
2806
  upstreamOverrides,
2679
- ignoredUpstreamChanges: groupIgnoredUpstreamChanges(files, config.overrides?.ignored ?? [])
2807
+ ignoredUpstreamChanges: groupIgnoredUpstreamChanges(files, config.overrides?.ignored ?? []),
2808
+ protectedUpstreamChanges: groupProtectedUpstreamChanges(protectedConflicts, config)
2680
2809
  };
2681
2810
  } catch {
2682
2811
  return {};
@@ -2685,12 +2814,10 @@ async function upstreamOnlyReports(config, ctx, files) {
2685
2814
  async function readNotesInRange(config, ctx) {
2686
2815
  const { forkPath } = config;
2687
2816
  try {
2688
- const aggressive = config.hard === true || config.unpinned === true;
2689
- const syncBase = aggressive ? (await getEffectiveMergeBase(forkPath, "HEAD", ctx.upstreamRef)).base : ctx.mergeBase;
2690
2817
  const [total, arrived, pending] = await Promise.all([
2691
2818
  listNoteIds(forkPath, ctx.upstreamRef),
2692
- arrivedNoteIds(forkPath, syncBase, ctx.upstreamRef),
2693
- readPending(forkPath, syncBase)
2819
+ arrivedNoteIds(forkPath, ctx.syncPoint, ctx.upstreamRef),
2820
+ readPending(forkPath, ctx.syncPoint)
2694
2821
  ]);
2695
2822
  return { total: total.length, arrived, pending: [.../* @__PURE__ */ new Set([...pending, ...arrived])] };
2696
2823
  } catch {
@@ -2701,14 +2828,15 @@ function notesResult(notes) {
2701
2828
  return notes ? { migrationNotes: { total: notes.total, arrived: notes.arrived, open: notes.pending.length } } : {};
2702
2829
  }
2703
2830
  async function removeUpstreamOnlyCopy(forkPath, onStep) {
2704
- if (!existsSync7(join11(forkPath, NOTES_DIR))) return;
2831
+ if (!existsSync7(join10(forkPath, NOTES_DIR))) return;
2705
2832
  await git(["rm", "-r", "-q", "--ignore-unmatch", "--", NOTES_DIR], forkPath);
2706
- await rm2(join11(forkPath, NOTES_DIR), { recursive: true, force: true });
2833
+ await rm2(join10(forkPath, NOTES_DIR), { recursive: true, force: true });
2707
2834
  onStep?.("migration notes", `removed ${NOTES_DIR}/: notes stay upstream, list them with pnpm cella migrate`);
2708
2835
  }
2709
2836
  async function runSyncMerge(config, ctx, onProgress, onStep) {
2710
2837
  const { forkPath } = config;
2711
2838
  const { upstreamRef, releaseTag, mergeBase, upstreamGitHubUrl, upstreamCommit } = ctx;
2839
+ await assertSyncConfigFollowed(config, ctx);
2712
2840
  const notes = await readNotesInRange(config, ctx);
2713
2841
  const { remainingConflicts, analyzedFiles, autoMergedFiles, protectedConflicts } = await applyDirectMerge(
2714
2842
  forkPath,
@@ -2719,9 +2847,7 @@ async function runSyncMerge(config, ctx, onProgress, onStep) {
2719
2847
  );
2720
2848
  const summary = calculateSummary(analyzedFiles);
2721
2849
  const synced = analyzedFiles.filter((file) => ["behind", "diverged", "renamed"].includes(file.status)).length;
2722
- const totalResolved = analyzedFiles.filter(
2723
- (file) => ["behind", "diverged", "renamed", "ignored", "pinned"].includes(file.status)
2724
- ).length;
2850
+ const totalResolved = analyzedFiles.filter((file) => SYNC_APPLIED_STATUSES.includes(file.status)).length;
2725
2851
  const syncManifest = {
2726
2852
  upstream: {
2727
2853
  repo: upstreamGitHubUrl ? upstreamGitHubUrl.replace("https://github.com/", "") : void 0,
@@ -2764,7 +2890,7 @@ async function runSyncMerge(config, ctx, onProgress, onStep) {
2764
2890
  autoMergedFiles,
2765
2891
  protectedConflicts,
2766
2892
  ...resultMeta(config, ctx),
2767
- ...await upstreamOnlyReports(config, ctx, analyzedFiles),
2893
+ ...await upstreamOnlyReports(config, ctx, analyzedFiles, protectedConflicts),
2768
2894
  ...notesResult(notes)
2769
2895
  };
2770
2896
  }
@@ -2776,12 +2902,7 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2776
2902
  await createWorktree(forkPath, worktreePath, "HEAD");
2777
2903
  onStep?.("worktree created", worktreePath);
2778
2904
  onProgress?.("performing merge in worktree...");
2779
- await withTemporarySyncBaseGraft(
2780
- worktreePath,
2781
- "HEAD",
2782
- ctx.mergeBase,
2783
- () => merge(worktreePath, ctx.upstreamRef, { noCommit: true, noEdit: true })
2784
- );
2905
+ await withTemporarySyncBaseGraft(worktreePath, "HEAD", ctx.mergeBase, () => merge(worktreePath, ctx.upstreamRef));
2785
2906
  onStep?.("merge complete", "upstream merged into worktree");
2786
2907
  onProgress?.("analyzing files...");
2787
2908
  const analyzedFiles = await analyzeRefs(
@@ -2793,8 +2914,9 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2793
2914
  onProgress
2794
2915
  );
2795
2916
  await enrichChangeInfo(forkPath, analyzedFiles, ctx.mergeBase, "HEAD", ctx.upstreamRef);
2796
- const reports = await upstreamOnlyReports(config, ctx, analyzedFiles);
2797
- onStep?.("analysis complete", `${analyzedFiles.length} files analyzed, dry run \u2014 no changes applied`);
2917
+ const protectedConflicts = analyzedFiles.filter((f) => f.upstreamChanged).map((f) => f.path);
2918
+ const reports = await upstreamOnlyReports(config, ctx, analyzedFiles, protectedConflicts);
2919
+ onStep?.("analysis complete", `${analyzedFiles.length} files analyzed, dry run, no changes applied`);
2798
2920
  onProgress?.("cleaning up worktree...");
2799
2921
  await cleanupWorktree(forkPath, worktreePath);
2800
2922
  return {
@@ -2803,7 +2925,7 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2803
2925
  summary: calculateSummary(analyzedFiles),
2804
2926
  // For analyze mode, count diverged files as potential conflicts
2805
2927
  conflicts: analyzedFiles.filter((f) => f.status === "diverged").map((f) => f.path),
2806
- protectedConflicts: analyzedFiles.filter((f) => f.upstreamChanged).map((f) => f.path),
2928
+ protectedConflicts,
2807
2929
  ...resultMeta(config, ctx),
2808
2930
  ...reports,
2809
2931
  ...notesResult(notes)
@@ -2823,23 +2945,39 @@ async function runMergeEngine(config, options) {
2823
2945
  throw error;
2824
2946
  }
2825
2947
  }
2948
+ async function runEngineWithSpinner(config, apply) {
2949
+ return runMergeEngine(config, {
2950
+ apply,
2951
+ onProgress: (message) => {
2952
+ spinnerText(message);
2953
+ },
2954
+ onStep: (label, detail) => {
2955
+ spinnerSuccess(label, detail);
2956
+ createSpinner("...");
2957
+ }
2958
+ });
2959
+ }
2826
2960
 
2827
2961
  // src/services/migrate.ts
2828
- import { execFileSync as execFileSync2 } from "child_process";
2962
+ import { execFileSync as execFileSync2, spawnSync } from "child_process";
2829
2963
  import { mkdir as mkdir4, writeFile as writeFile3 } from "fs/promises";
2830
- import { dirname as dirname4, join as join12 } from "path";
2964
+ import { dirname as dirname4, join as join11 } from "path";
2831
2965
  var EXTRACT_DIR = "node_modules/.cache/cella/migrations";
2966
+ var RESTORED_LIST_MAX = 20;
2967
+ async function ensureUpstreamCommit(config, sha) {
2968
+ const { forkPath } = config;
2969
+ const present = async () => !!await git(["rev-parse", "--verify", "--quiet", `${sha}^{commit}`], forkPath, { ignoreErrors: true });
2970
+ if (await present()) return;
2971
+ await ensureRemote(forkPath, DEFAULT_UPSTREAM_REMOTE, config.settings.upstreamUrl);
2972
+ await fetchRemote(forkPath, DEFAULT_UPSTREAM_REMOTE);
2973
+ if (!await present())
2974
+ throw new Error(`upstream commit ${sha.slice(0, 7)} is not reachable from '${DEFAULT_UPSTREAM_REMOTE}'`);
2975
+ }
2832
2976
  async function resolveNotesRef(config) {
2833
2977
  const { forkPath } = config;
2834
2978
  const ref = await getStoredSyncRef(forkPath) ?? await readManifestBaseAtRef(forkPath, "HEAD");
2835
2979
  if (!ref) return null;
2836
- const present = async () => !!await git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], forkPath, { ignoreErrors: true });
2837
- if (!await present()) {
2838
- await ensureRemote(forkPath, DEFAULT_UPSTREAM_REMOTE, config.settings.upstreamUrl);
2839
- await fetch2(forkPath, DEFAULT_UPSTREAM_REMOTE);
2840
- if (!await present())
2841
- throw new Error(`upstream commit ${ref.slice(0, 7)} is not reachable from '${DEFAULT_UPSTREAM_REMOTE}'`);
2842
- }
2980
+ await ensureUpstreamCommit(config, ref);
2843
2981
  return ref;
2844
2982
  }
2845
2983
  async function readNotesStatus(config) {
@@ -2881,27 +3019,88 @@ function printNote(index, note, url) {
2881
3019
  if (note.summary) console.info(` ${note.summary}`);
2882
3020
  console.info(colors_default.dim(` read: pnpm cella migrate --show ${note.id}`));
2883
3021
  if (url) console.info(colors_default.dim(` ${url}`));
2884
- if (note.codemod) console.info(colors_default.dim(` codemod: pnpm cella migrate --extract ${note.id}`));
3022
+ if (note.codemod) console.info(colors_default.dim(` codemod: pnpm cella migrate --run ${note.id}`));
2885
3023
  console.info();
2886
3024
  }
2887
- async function extractNote(config, ref, id) {
3025
+ async function writeNoteFolder(config, ref, id) {
2888
3026
  const { forkPath } = config;
2889
3027
  const note = await readNote(forkPath, ref, id);
2890
3028
  if (!note) throw new Error(`no migration note '${id}' at upstream ${ref.slice(0, 7)}`);
2891
3029
  const files = (await git(["ls-tree", "-r", "--name-only", `${ref}:${NOTES_DIR}/${id}`], forkPath)).split("\n").filter(Boolean);
2892
- const target = join12(EXTRACT_DIR, id);
3030
+ const target = join11(EXTRACT_DIR, id);
2893
3031
  for (const file of files) {
2894
3032
  const content = execFileSync2("git", ["show", `${ref}:${NOTES_DIR}/${id}/${file}`], {
2895
3033
  cwd: forkPath,
2896
- maxBuffer: 50 * 1024 * 1024
3034
+ maxBuffer: MAX_BUFFER
2897
3035
  });
2898
- const path4 = join12(forkPath, target, file);
2899
- await mkdir4(dirname4(path4), { recursive: true });
2900
- await writeFile3(path4, content);
3036
+ const path = join11(forkPath, target, file);
3037
+ await mkdir4(dirname4(path), { recursive: true });
3038
+ await writeFile3(path, content);
2901
3039
  }
2902
- console.info(`${colors_default.green("\u2713")} extracted ${files.length} file(s) to ${target}/`);
3040
+ return { note, target, files };
3041
+ }
3042
+ async function extractNote(config, ref, id) {
3043
+ const { note, target, files } = await writeNoteFolder(config, ref, id);
3044
+ console.info(`${checkMark} extracted ${files.length} file(s) to ${target}/`);
2903
3045
  console.info(colors_default.dim(` the README's ${NOTES_DIR}/${id}/ paths are this folder.`));
2904
- if (note.codemod) console.info(colors_default.dim(` pnpm exec tsx ${target}/${note.codemod} inventory ${note.roots.join(" ")}`));
3046
+ if (note.codemod) {
3047
+ console.info(colors_default.dim(` pnpm exec tsx ${target}/${note.codemod} inventory ${note.roots.join(" ")}`));
3048
+ console.info(colors_default.dim(` or, keeping files identical to upstream as they are: pnpm cella migrate --run ${id}`));
3049
+ }
3050
+ }
3051
+ function resolveCodemod(id, files, script) {
3052
+ if (script) {
3053
+ const name = script.replace(/^\.\//, "");
3054
+ if (!files.includes(name)) {
3055
+ throw new Error(`migration note '${id}' has no file '${script}'. its files: ${files.join(", ")}`);
3056
+ }
3057
+ return name;
3058
+ }
3059
+ const scripts = files.filter((file) => !file.includes("/") && isCodemodFile(file));
3060
+ if (scripts.length === 0) {
3061
+ throw new Error(
3062
+ `migration note '${id}' ships no codemod script: its steps are manual, read them with \`pnpm cella migrate --show ${id}\`.`
3063
+ );
3064
+ }
3065
+ if (scripts.length > 1) {
3066
+ throw new Error(
3067
+ `migration note '${id}' ships ${scripts.length} scripts (${scripts.join(", ")}): name the one to run with --script <file>.`
3068
+ );
3069
+ }
3070
+ return scripts[0];
3071
+ }
3072
+ async function runNote(config, ref, id) {
3073
+ const { forkPath } = config;
3074
+ const { note, target, files } = await writeNoteFolder(config, ref, id);
3075
+ const script = join11(target, resolveCodemod(id, files, config.script));
3076
+ const args = config.runArgs?.length ? config.runArgs : ["inventory", ...note.roots];
3077
+ const upstreamCommit = await readManifestBase(forkPath) ?? ref;
3078
+ await ensureUpstreamCommit(config, upstreamCommit);
3079
+ const identical = await listIdenticalToRef(forkPath, upstreamCommit);
3080
+ console.info(colors_default.dim(`running pnpm exec tsx ${script} ${args.join(" ")}`));
3081
+ console.info();
3082
+ const child = spawnSync("pnpm", ["exec", "tsx", script, ...args], { cwd: forkPath, stdio: "inherit" });
3083
+ if (child.error) throw new Error(`could not run the codemod: ${child.error.message}`);
3084
+ const stillIdentical = await listIdenticalToRef(forkPath, upstreamCommit);
3085
+ const restored = [...identical].filter((path) => !stillIdentical.has(path)).sort();
3086
+ await batchRestoreWorktreeFromRef(forkPath, upstreamCommit, restored);
3087
+ const upstreamShort = upstreamCommit.slice(0, 9);
3088
+ console.info();
3089
+ if (restored.length > 0) {
3090
+ console.info(`${checkMark} restored ${restored.length} file(s) to upstream ${upstreamShort}`);
3091
+ console.info(colors_default.dim(" the codemod changed them, and they were identical to upstream before the run:"));
3092
+ for (const path of restored.slice(0, RESTORED_LIST_MAX)) console.info(colors_default.dim(` ${path}`));
3093
+ if (restored.length > RESTORED_LIST_MAX) {
3094
+ console.info(colors_default.dim(` ... + ${restored.length - RESTORED_LIST_MAX} more`));
3095
+ }
3096
+ } else {
3097
+ console.info(colors_default.dim(`no file identical to upstream ${upstreamShort} was changed by the codemod.`));
3098
+ }
3099
+ if (child.status !== 0) throw new Error(`the codemod exited with code ${child.status ?? "unknown"}`);
3100
+ if (!config.runArgs?.length) {
3101
+ const apply = ["rewrite", ...note.roots].join(" ");
3102
+ console.info(colors_default.dim(`report only. to apply: pnpm cella migrate --run ${id} -- ${apply}`));
3103
+ }
2905
3104
  }
2906
3105
  async function markNotes(config, ref, pending, ids) {
2907
3106
  const open = new Set(pending);
@@ -2909,7 +3108,7 @@ async function markNotes(config, ref, pending, ids) {
2909
3108
  for (const id of ids) open.delete(id);
2910
3109
  await writePending(config.forkPath, [...open]);
2911
3110
  const marked = ids.length - unknown.length;
2912
- if (marked > 0) console.info(`${colors_default.green("\u2713")} recorded ${marked} note(s) as handled`);
3111
+ if (marked > 0) console.info(`${checkMark} recorded ${marked} note(s) as handled`);
2913
3112
  if (unknown.length > 0) console.info(colors_default.dim(`not pending, left as is: ${unknown.join(", ")}`));
2914
3113
  console.info(colors_default.dim(formatNotesLine((await listNoteIds(config.forkPath, ref)).length, open.size)));
2915
3114
  }
@@ -2924,6 +3123,7 @@ async function runMigrate(config) {
2924
3123
  const githubUrl = getGitHubBaseUrl(config.settings.upstreamUrl) ?? void 0;
2925
3124
  const urlOf = (id) => noteUrl(githubUrl, ref, id);
2926
3125
  if (config.mark?.length) return markNotes(config, ref, pending, config.mark);
3126
+ if (config.run) return runNote(config, ref, config.run);
2927
3127
  if (config.extract) return extractNote(config, ref, config.extract);
2928
3128
  if (config.show) {
2929
3129
  const source = await git(["show", `${ref}:${NOTES_DIR}/${config.show}/README.md`], forkPath, {
@@ -2997,16 +3197,7 @@ function printUnifiedDiff(config, filePath) {
2997
3197
  }
2998
3198
  async function runAnalyze(config) {
2999
3199
  createSpinner("starting analysis...");
3000
- const result = await runMergeEngine(config, {
3001
- apply: false,
3002
- onProgress: (message) => {
3003
- spinnerText(message);
3004
- },
3005
- onStep: (label, detail) => {
3006
- spinnerSuccess(label, detail);
3007
- createSpinner("...");
3008
- }
3009
- });
3200
+ const result = await runEngineWithSpinner(config, false);
3010
3201
  spinnerSuccess();
3011
3202
  const scopedFiles = filterByScope(result, config.scope ?? "all");
3012
3203
  if (config.diff) {
@@ -3028,15 +3219,15 @@ async function runAnalyze(config) {
3028
3219
  upstreamChangedLines: f.upstreamChangedLines ?? null,
3029
3220
  upstreamLinesAbsent: f.upstreamLinesAbsent ?? null,
3030
3221
  upstreamOnly: f.upstreamOnly ?? false,
3031
- // Only on the sync config: upstream override entries the fork config does not follow
3032
- upstreamOverrides: overrides && f.path === CONFIG_FILE ? { pinned: overrides.pinned, ignored: overrides.ignored } : null
3222
+ // Only on the sync config: upstream override entries and packageJsonSync keys the fork config does not follow
3223
+ upstreamOverrides: overrides && f.path === CONFIG_FILE ? { pinned: overrides.pinned, ignored: overrides.ignored, packageJsonSync: overrides.packageJsonSync } : null
3033
3224
  }));
3034
3225
  writeStdout(JSON.stringify(out, null, 2));
3035
3226
  return result;
3036
3227
  }
3037
3228
  if (config.list) {
3038
3229
  for (const file of scopedFiles) {
3039
- console.info(file.path);
3230
+ writeStdout(file.path);
3040
3231
  }
3041
3232
  return result;
3042
3233
  }
@@ -3045,7 +3236,7 @@ async function runAnalyze(config) {
3045
3236
  if (!file) throw new Error(`file not found in analysis results: ${config.openDiff}`);
3046
3237
  const patch = gitDiffFile(config.forkPath, config.upstreamRef, file.path);
3047
3238
  if (patch.length === 0) {
3048
- console.info(`${colors_default.green("\u2713")} ${file.path} is identical to upstream`);
3239
+ console.info(`${checkMark} ${file.path} is identical to upstream`);
3049
3240
  return result;
3050
3241
  }
3051
3242
  const pagePath = await openDiffInBrowser(
@@ -3058,7 +3249,7 @@ async function runAnalyze(config) {
3058
3249
  },
3059
3250
  config.forkPath
3060
3251
  );
3061
- console.info(`${colors_default.green("\u2713")} opened browser diff for ${file.path}`);
3252
+ console.info(`${checkMark} opened browser diff for ${file.path}`);
3062
3253
  console.info(colors_default.dim(` ${pagePath}`));
3063
3254
  return result;
3064
3255
  }
@@ -3068,41 +3259,34 @@ async function runAnalyze(config) {
3068
3259
  fileLinkMode: config.settings.fileLinkMode,
3069
3260
  forkPath: config.forkPath
3070
3261
  };
3071
- printAnalysisFileGroups(result.files, linkOptions);
3072
- printSummary(result.summary, "analysis summary");
3073
- printIgnoredUpstreamChanges(result);
3074
- printUpstreamOverrideChanges(result);
3075
- printMaskingPinWarning(result.files);
3262
+ printAnalysisFileGroups(result.files, linkOptions, result);
3263
+ printEngineReports(result, "analysis summary");
3076
3264
  await printMigrationNotesLine(config, result);
3077
- if (config.logFile) {
3078
- const logPath = writeLogFile(config.forkPath, result.files);
3079
- console.info();
3080
- console.info(colors_default.dim(`full file list written to: ${logPath}`));
3081
- }
3265
+ printLogFileReport(config, result.files);
3082
3266
  console.info();
3083
3267
  return result;
3084
3268
  }
3085
3269
 
3086
3270
  // src/services/audit.ts
3087
- import { spawnSync } from "child_process";
3088
- import * as fs2 from "fs";
3089
- import * as path2 from "path";
3271
+ import { spawnSync as spawnSync2 } from "child_process";
3272
+ import { readFileSync as readFileSync3 } from "fs";
3273
+ import { basename as basename5, join as join13, relative as relative2 } from "path";
3090
3274
  import { checkbox, confirm, Separator } from "@inquirer/prompts";
3091
3275
 
3092
3276
  // src/utils/audit-utils.ts
3093
3277
  import { execFile as execFile2 } from "child_process";
3094
- import * as fs from "fs";
3095
- import * as path from "path";
3278
+ import { existsSync as existsSync8, readFileSync as readFileSync2, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
3279
+ import { join as join12 } from "path";
3096
3280
  import { promisify as promisify2 } from "util";
3097
3281
  var execFileAsync2 = promisify2(execFile2);
3098
- var CACHE_FILE = path.join(import.meta.dirname, "..", "..", ".audit.cache.json");
3282
+ var CACHE_FILE = join12(import.meta.dirname, "..", "..", ".audit.cache.json");
3099
3283
  var CACHE_TTL = 7 * 24 * 60 * 60 * 1e3;
3100
3284
  var CHANGELOG_PATHS = ["CHANGELOG.md", "CHANGELOG", "changelog.md", "HISTORY.md", "CHANGES.md", "NEWS.md"];
3101
3285
  var DEFAULT_BRANCHES = ["main", "master"];
3102
3286
  function loadCache() {
3103
3287
  try {
3104
- if (fs.existsSync(CACHE_FILE)) {
3105
- return JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8"));
3288
+ if (existsSync8(CACHE_FILE)) {
3289
+ return JSON.parse(readFileSync2(CACHE_FILE, "utf8"));
3106
3290
  }
3107
3291
  } catch {
3108
3292
  }
@@ -3110,15 +3294,15 @@ function loadCache() {
3110
3294
  }
3111
3295
  function saveCache(cache) {
3112
3296
  try {
3113
- fs.writeFileSync(CACHE_FILE, `${JSON.stringify(cache, null, 2)}
3297
+ writeFileSync3(CACHE_FILE, `${JSON.stringify(cache, null, 2)}
3114
3298
  `);
3115
3299
  } catch {
3116
3300
  }
3117
3301
  }
3118
3302
  function clearCache() {
3119
3303
  try {
3120
- if (fs.existsSync(CACHE_FILE)) {
3121
- fs.unlinkSync(CACHE_FILE);
3304
+ if (existsSync8(CACHE_FILE)) {
3305
+ unlinkSync(CACHE_FILE);
3122
3306
  console.info(colors_default.green("\u2713 cache cleared successfully"));
3123
3307
  } else {
3124
3308
  console.info(colors_default.yellow("no cache file found"));
@@ -3206,56 +3390,32 @@ function isMajorVersionChange(current, latest) {
3206
3390
  if (!currentMajor || !latestMajor) return false;
3207
3391
  return Number.parseInt(latestMajor, 10) > Number.parseInt(currentMajor, 10);
3208
3392
  }
3209
- async function getOutdatedPackages(cwd) {
3393
+ async function runPnpmJson(args, cwd) {
3394
+ const parse = (stdout) => {
3395
+ if (!stdout || stdout.trim() === "") return null;
3396
+ try {
3397
+ return JSON.parse(stdout);
3398
+ } catch {
3399
+ return null;
3400
+ }
3401
+ };
3210
3402
  try {
3211
- const { stdout } = await execFileAsync2("pnpm", ["-r", "outdated", "--json"], {
3212
- encoding: "utf-8",
3403
+ const { stdout } = await execFileAsync2("pnpm", args, {
3404
+ encoding: "utf8",
3213
3405
  maxBuffer: 10 * 1024 * 1024,
3214
3406
  // 10MB buffer for large outputs
3215
3407
  cwd
3216
3408
  });
3217
- if (!stdout || stdout.trim() === "") {
3218
- return {};
3219
- }
3220
- return JSON.parse(stdout);
3409
+ return parse(stdout);
3221
3410
  } catch (error) {
3222
- if (error instanceof Error && "stdout" in error) {
3223
- const stdout = error.stdout;
3224
- if (stdout?.trim()) {
3225
- try {
3226
- return JSON.parse(stdout);
3227
- } catch {
3228
- return {};
3229
- }
3230
- }
3231
- }
3232
- return {};
3411
+ return parse(error instanceof Error && "stdout" in error ? error.stdout : void 0);
3233
3412
  }
3234
3413
  }
3414
+ async function getOutdatedPackages(cwd) {
3415
+ return await runPnpmJson(["-r", "outdated", "--json"], cwd) ?? {};
3416
+ }
3235
3417
  async function runPnpmAudit(cwd) {
3236
- try {
3237
- const { stdout } = await execFileAsync2("pnpm", ["audit", "--json"], {
3238
- encoding: "utf-8",
3239
- maxBuffer: 10 * 1024 * 1024,
3240
- cwd
3241
- });
3242
- if (!stdout || stdout.trim() === "") {
3243
- return null;
3244
- }
3245
- return JSON.parse(stdout);
3246
- } catch (error) {
3247
- if (error instanceof Error && "stdout" in error) {
3248
- const stdout = error.stdout;
3249
- if (stdout?.trim()) {
3250
- try {
3251
- return JSON.parse(stdout);
3252
- } catch {
3253
- return null;
3254
- }
3255
- }
3256
- }
3257
- return null;
3258
- }
3418
+ return await runPnpmJson(["audit", "--json"], cwd);
3259
3419
  }
3260
3420
  function parseDependencyPath(paths) {
3261
3421
  for (const pathStr of paths) {
@@ -3277,12 +3437,9 @@ function buildVulnerabilityMap(auditResult) {
3277
3437
  const { workspace, directDependency } = parseDependencyPath(allPaths);
3278
3438
  const existing = map.get(advisory.module_name) || [];
3279
3439
  existing.push({
3280
- id: advisory.id,
3281
3440
  title: advisory.title,
3282
3441
  severity: advisory.severity,
3283
- url: advisory.url,
3284
3442
  vulnerableVersions: advisory.vulnerable_versions,
3285
- patchedVersions: advisory.patched_versions,
3286
3443
  cves: advisory.cves || [],
3287
3444
  workspace,
3288
3445
  directDependency
@@ -3291,19 +3448,15 @@ function buildVulnerabilityMap(auditResult) {
3291
3448
  }
3292
3449
  return map;
3293
3450
  }
3451
+ var vulnIcons = {
3452
+ critical: colors_default.red("\u25CF"),
3453
+ high: colors_default.red("\u25CF"),
3454
+ moderate: colors_default.yellow("\u25CF"),
3455
+ low: colors_default.blue("\u25CF"),
3456
+ info: colors_default.gray("\u25CF")
3457
+ };
3294
3458
  function getVulnIcon(severity) {
3295
- switch (severity) {
3296
- case "critical":
3297
- return colors_default.red("\u25CF");
3298
- case "high":
3299
- return colors_default.red("\u25CF");
3300
- case "moderate":
3301
- return colors_default.yellow("\u25CF");
3302
- case "low":
3303
- return colors_default.blue("\u25CF");
3304
- default:
3305
- return colors_default.gray("\u25CF");
3306
- }
3459
+ return vulnIcons[severity] ?? colors_default.gray("\u25CF");
3307
3460
  }
3308
3461
  function getHighestSeverity(vulns) {
3309
3462
  if (vulns.length === 0) return null;
@@ -3313,13 +3466,13 @@ function getHighestSeverity(vulns) {
3313
3466
  }
3314
3467
  return null;
3315
3468
  }
3316
- function middleTruncate(str, maxLen) {
3317
- if (str.length <= maxLen) return str;
3469
+ function middleTruncate(str2, maxLen) {
3470
+ if (str2.length <= maxLen) return str2;
3318
3471
  const ellipsis = "\u2026";
3319
3472
  const charsToShow = maxLen - ellipsis.length;
3320
3473
  const frontChars = Math.ceil(charsToShow / 2);
3321
3474
  const backChars = Math.floor(charsToShow / 2);
3322
- return str.slice(0, frontChars) + ellipsis + str.slice(-backChars);
3475
+ return str2.slice(0, frontChars) + ellipsis + str2.slice(-backChars);
3323
3476
  }
3324
3477
  function formatDependents(dependents) {
3325
3478
  if (dependents.length === 0) return "";
@@ -3364,18 +3517,42 @@ function parseYamlBlockList(content, key) {
3364
3517
  }
3365
3518
 
3366
3519
  // src/services/audit.ts
3367
- async function runAudit(config, options = {}) {
3520
+ var AUDIT_FIX_VALUE = "__audit_fix__";
3521
+ function createDependentPkgReader() {
3522
+ const cache = /* @__PURE__ */ new Map();
3523
+ return (location) => {
3524
+ let cached = cache.get(location);
3525
+ if (cached === void 0) {
3526
+ try {
3527
+ cached = JSON.parse(readFileSync3(join13(location, "package.json"), "utf8"));
3528
+ } catch {
3529
+ cached = null;
3530
+ }
3531
+ cache.set(location, cached ?? null);
3532
+ }
3533
+ return cached ?? null;
3534
+ };
3535
+ }
3536
+ function formatSeverityCounts(counts, suffix = "") {
3537
+ const parts = [];
3538
+ if (counts.critical) parts.push(colors_default.red(`${counts.critical} critical${suffix}`));
3539
+ if (counts.high) parts.push(colors_default.red(`${counts.high} high${suffix}`));
3540
+ if (counts.moderate) parts.push(colors_default.yellow(`${counts.moderate} moderate${suffix}`));
3541
+ if (counts.low) parts.push(colors_default.blue(`${counts.low} low`));
3542
+ return parts;
3543
+ }
3544
+ async function runAudit(config) {
3368
3545
  const { forkPath } = config;
3369
- if (options.checkOverrides) {
3546
+ if (config.checkOverrides) {
3370
3547
  await checkOverrides(forkPath);
3371
3548
  return;
3372
3549
  }
3373
3550
  const spinner = createSpinner("checking for outdated packages & vulnerabilities...");
3374
3551
  try {
3375
- if (options.force) {
3552
+ if (config.force) {
3376
3553
  spinnerText("clearing pnpm metadata cache...");
3377
3554
  clearCache();
3378
- spawnSync("pnpm", ["cache", "delete", "*"], {
3555
+ spawnSync2("pnpm", ["cache", "delete", "*"], {
3379
3556
  cwd: forkPath,
3380
3557
  stdio: "ignore"
3381
3558
  });
@@ -3389,25 +3566,13 @@ async function runAudit(config, options = {}) {
3389
3566
  return;
3390
3567
  }
3391
3568
  const cache = loadCache();
3392
- spinnerText(`found ${packageNames.length} outdated package(s) - fetching metadata...`);
3393
- const pkgJsonCache = /* @__PURE__ */ new Map();
3394
- const readDependentPkg = (location) => {
3395
- let cached = pkgJsonCache.get(location);
3396
- if (cached === void 0) {
3397
- try {
3398
- cached = JSON.parse(fs2.readFileSync(path2.join(location, "package.json"), "utf-8"));
3399
- } catch {
3400
- cached = null;
3401
- }
3402
- pkgJsonCache.set(location, cached ?? null);
3403
- }
3404
- return cached ?? null;
3405
- };
3569
+ spinnerText(`found ${packageNames.length} outdated package(s), fetching metadata...`);
3570
+ const readDependentPkg = createDependentPkgReader();
3406
3571
  const dependentNameCache = /* @__PURE__ */ new Map();
3407
3572
  for (const name of packageNames) {
3408
3573
  for (const dep of outdatedPackages[name].dependentPackages) {
3409
3574
  if (!dependentNameCache.has(dep.location)) {
3410
- dependentNameCache.set(dep.location, readDependentPkg(dep.location)?.name || path2.basename(dep.location));
3575
+ dependentNameCache.set(dep.location, readDependentPkg(dep.location)?.name || basename5(dep.location));
3411
3576
  }
3412
3577
  }
3413
3578
  }
@@ -3518,7 +3683,7 @@ function printOutdatedResults(enhancedPackages, dependentNameCache, forkPath) {
3518
3683
  const dependentMap = /* @__PURE__ */ new Map();
3519
3684
  for (const pkg of enhancedPackages) {
3520
3685
  for (const loc of pkg.dependentLocations) {
3521
- const dependentName = dependentNameCache.get(loc) || path2.basename(loc);
3686
+ const dependentName = dependentNameCache.get(loc) || basename5(loc);
3522
3687
  const existing = dependentMap.get(dependentName);
3523
3688
  if (existing) {
3524
3689
  existing.count++;
@@ -3530,34 +3695,15 @@ function printOutdatedResults(enhancedPackages, dependentNameCache, forkPath) {
3530
3695
  const sortedDependents = [...dependentMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
3531
3696
  const maxDependentNameLen = Math.max(0, ...sortedDependents.map(([name]) => name.length));
3532
3697
  for (const [dependentName, { location, count }] of sortedDependents) {
3533
- const packageJsonPath2 = path2.join(location, "package.json");
3534
- const relativePath = path2.relative(forkPath, packageJsonPath2);
3698
+ const packageJsonPath2 = join13(location, "package.json");
3699
+ const relativePath = relative2(forkPath, packageJsonPath2);
3535
3700
  const paddedName = dependentName.padEnd(maxDependentNameLen);
3536
3701
  console.info(
3537
3702
  ` ${colors_default.dim("\u2022")} ${paddedName} ${hyperlink(relativePath, `file://${packageJsonPath2}`)} ${colors_default.dim(`${count}`)}`
3538
3703
  );
3539
3704
  }
3540
3705
  }
3541
- async function promptForUpdates(packages, forkPath, auditResult) {
3542
- const hasVulnerabilities = auditResult !== null && Object.values(auditResult.metadata?.vulnerabilities || {}).some((c) => c > 0);
3543
- const hasPackages = packages.length > 0;
3544
- let confirmMessage = "update packages?";
3545
- if (hasVulnerabilities && hasPackages) {
3546
- confirmMessage = "update packages and fix vulnerabilities?";
3547
- } else if (hasVulnerabilities) {
3548
- confirmMessage = "fix vulnerabilities?";
3549
- }
3550
- let wantsUpdate;
3551
- try {
3552
- wantsUpdate = await confirm({
3553
- message: confirmMessage,
3554
- default: true
3555
- });
3556
- } catch {
3557
- return;
3558
- }
3559
- if (!wantsUpdate) return;
3560
- console.info();
3706
+ function buildUpdateChoices(packages, auditResult, hasVulnerabilities) {
3561
3707
  const makeChoice = (pkg) => {
3562
3708
  const devTag = pkg.isDev ? colors_default.dim(" (dev)") : "";
3563
3709
  const version = `${colors_default.red(pkg.current)} \u2192 ${colors_default.green(pkg.latest)}`;
@@ -3568,17 +3714,11 @@ async function promptForUpdates(packages, forkPath, auditResult) {
3568
3714
  checked: false
3569
3715
  };
3570
3716
  };
3571
- const AUDIT_FIX_VALUE = "__audit_fix__";
3572
3717
  const pinned = packages.filter((p) => p.pinnedIn.length > 0);
3573
3718
  const regular = packages.filter((p) => p.pinnedIn.length === 0);
3574
3719
  const choices = [];
3575
3720
  if (hasVulnerabilities) {
3576
- const vulnMeta = auditResult?.metadata?.vulnerabilities || {};
3577
- const parts = [];
3578
- if (vulnMeta.critical) parts.push(colors_default.red(`${vulnMeta.critical} critical`));
3579
- if (vulnMeta.high) parts.push(colors_default.red(`${vulnMeta.high} high`));
3580
- if (vulnMeta.moderate) parts.push(colors_default.yellow(`${vulnMeta.moderate} moderate`));
3581
- if (vulnMeta.low) parts.push(colors_default.blue(`${vulnMeta.low} low`));
3721
+ const parts = formatSeverityCounts(auditResult?.metadata?.vulnerabilities || {});
3582
3722
  const vulnSummary = parts.length > 0 ? ` ${colors_default.dim("\xB7")} ${parts.join(" ")}` : "";
3583
3723
  choices.push(new Separator(colors_default.red(`\u2500\u2500 vulnerabilities${vulnSummary} \u2500\u2500`)));
3584
3724
  choices.push({
@@ -3597,6 +3737,29 @@ async function promptForUpdates(packages, forkPath, auditResult) {
3597
3737
  }
3598
3738
  choices.push(...regular.map(makeChoice));
3599
3739
  }
3740
+ return choices;
3741
+ }
3742
+ async function promptForUpdates(packages, forkPath, auditResult) {
3743
+ const hasVulnerabilities = auditResult !== null && Object.values(auditResult.metadata?.vulnerabilities || {}).some((c) => c > 0);
3744
+ const hasPackages = packages.length > 0;
3745
+ let confirmMessage = "update packages?";
3746
+ if (hasVulnerabilities && hasPackages) {
3747
+ confirmMessage = "update packages and fix vulnerabilities?";
3748
+ } else if (hasVulnerabilities) {
3749
+ confirmMessage = "fix vulnerabilities?";
3750
+ }
3751
+ let wantsUpdate;
3752
+ try {
3753
+ wantsUpdate = await confirm({
3754
+ message: confirmMessage,
3755
+ default: true
3756
+ });
3757
+ } catch {
3758
+ return;
3759
+ }
3760
+ if (!wantsUpdate) return;
3761
+ console.info();
3762
+ const choices = buildUpdateChoices(packages, auditResult, hasVulnerabilities);
3600
3763
  let selected;
3601
3764
  try {
3602
3765
  selected = await checkbox({
@@ -3624,6 +3787,9 @@ async function promptForUpdates(packages, forkPath, auditResult) {
3624
3787
  }
3625
3788
  const runAuditFix = selected.includes(AUDIT_FIX_VALUE);
3626
3789
  const selectedPackages = selected.filter((s) => s !== AUDIT_FIX_VALUE);
3790
+ await executeUpdates(packages, forkPath, selectedPackages, runAuditFix);
3791
+ }
3792
+ async function executeUpdates(packages, forkPath, selectedPackages, runAuditFix) {
3627
3793
  const summaryParts = [];
3628
3794
  if (selectedPackages.length > 0) {
3629
3795
  summaryParts.push(`updating ${selectedPackages.length} package${selectedPackages.length !== 1 ? "s" : ""}`);
@@ -3655,17 +3821,15 @@ async function promptForUpdates(packages, forkPath, auditResult) {
3655
3821
  for (const pkgName of selectedPackages) {
3656
3822
  const pkg = packages.find((p) => p.name === pkgName);
3657
3823
  if (!pkg) continue;
3658
- for (let i = 0; i < pkg.dependents.length; i++) {
3659
- const dependent = pkg.dependents[i];
3660
- if (!workspacePackages.has(dependent)) {
3661
- workspacePackages.set(dependent, { packages: [] });
3662
- }
3663
- workspacePackages.get(dependent)?.packages.push(pkgName);
3824
+ for (const dependent of pkg.dependents) {
3825
+ const group = workspacePackages.get(dependent);
3826
+ if (group) group.push(pkgName);
3827
+ else workspacePackages.set(dependent, [pkgName]);
3664
3828
  }
3665
3829
  }
3666
3830
  if (runAuditFix) {
3667
3831
  console.info(colors_default.dim("running pnpm audit --fix override..."));
3668
- const auditFixResult = spawnSync("pnpm", ["audit", "--fix", "override"], {
3832
+ const auditFixResult = spawnSync2("pnpm", ["audit", "--fix", "override"], {
3669
3833
  cwd: forkPath,
3670
3834
  stdio: "inherit"
3671
3835
  });
@@ -3676,7 +3840,7 @@ async function promptForUpdates(packages, forkPath, auditResult) {
3676
3840
  console.error(colors_default.red(`\u2717 pnpm audit --fix override failed (exit code ${auditFixResult.status})`));
3677
3841
  }
3678
3842
  }
3679
- for (const [workspace, { packages: pkgs }] of workspacePackages) {
3843
+ for (const [workspace, pkgs] of workspacePackages) {
3680
3844
  const pkgList = pkgs.join(", ");
3681
3845
  console.info(`${colors_default.cyan("\u2191")} ${colors_default.bold(workspace)}: ${colors_default.dim(pkgList)}`);
3682
3846
  }
@@ -3685,7 +3849,7 @@ async function promptForUpdates(packages, forkPath, auditResult) {
3685
3849
  const selectedCatalogPackages = selectedPackages.filter((pkgName) => catalogPackages.has(pkgName));
3686
3850
  if (selectedCatalogPackages.length > 0) {
3687
3851
  console.info(colors_default.dim(`normalizing catalog consumers: ${selectedCatalogPackages.join(", ")}`));
3688
- const normalizeResult = spawnSync("pnpm", ["-r", "up", ...selectedCatalogPackages], {
3852
+ const normalizeResult = spawnSync2("pnpm", ["-r", "up", ...selectedCatalogPackages], {
3689
3853
  cwd: forkPath,
3690
3854
  stdio: "inherit"
3691
3855
  });
@@ -3695,7 +3859,7 @@ async function promptForUpdates(packages, forkPath, auditResult) {
3695
3859
  return;
3696
3860
  }
3697
3861
  }
3698
- const result = spawnSync("pnpm", ["-r", "up", "--latest", ...selectedPackages], {
3862
+ const result = spawnSync2("pnpm", ["-r", "up", "--latest", ...selectedPackages], {
3699
3863
  cwd: forkPath,
3700
3864
  stdio: "inherit"
3701
3865
  });
@@ -3710,15 +3874,11 @@ async function promptForUpdates(packages, forkPath, auditResult) {
3710
3874
  function printVulnerabilityResults(auditResult, vulnMap) {
3711
3875
  if (auditResult && vulnMap.size > 0) {
3712
3876
  const vulnMeta = auditResult.metadata?.vulnerabilities || {};
3713
- const criticalCount = vulnMeta.critical || 0;
3714
- const highCount = vulnMeta.high || 0;
3715
- const moderateCount = vulnMeta.moderate || 0;
3716
- const lowCount = vulnMeta.low || 0;
3717
3877
  console.info();
3718
3878
  console.info(
3719
- `${colors_default.red("\u26A0")} ${colors_default.bold("vulnerabilities")} ${colors_default.dim("\xB7")} ` + (criticalCount > 0 ? colors_default.red(`${criticalCount} critical `) : "") + (highCount > 0 ? colors_default.red(`${highCount} high `) : "") + (moderateCount > 0 ? colors_default.yellow(`${moderateCount} moderate `) : "") + (lowCount > 0 ? colors_default.blue(`${lowCount} low`) : "")
3879
+ `${colors_default.red("\u26A0")} ${colors_default.bold("vulnerabilities")} ${colors_default.dim("\xB7")} ${formatSeverityCounts(vulnMeta, " ").join("")}`
3720
3880
  );
3721
- console.info("\u2500".repeat(60));
3881
+ console.info(DIVIDER);
3722
3882
  for (const [pkgName, vulns] of vulnMap.entries()) {
3723
3883
  for (const vuln of vulns) {
3724
3884
  const severityIcon = getVulnIcon(vuln.severity);
@@ -3738,14 +3898,14 @@ function printVulnerabilityResults(auditResult, vulnMap) {
3738
3898
  }
3739
3899
  } else {
3740
3900
  console.info();
3741
- console.info(`${colors_default.green("\u2713")} no vulnerabilities found`);
3901
+ console.info(`${checkMark} no vulnerabilities found`);
3742
3902
  }
3743
3903
  console.info();
3744
3904
  }
3745
3905
  function readCatalogPackageNames(forkPath) {
3746
- const workspacePath = path2.join(forkPath, "pnpm-workspace.yaml");
3906
+ const workspacePath = join13(forkPath, "pnpm-workspace.yaml");
3747
3907
  try {
3748
- const content = fs2.readFileSync(workspacePath, "utf-8");
3908
+ const content = readFileSync3(workspacePath, "utf8");
3749
3909
  return new Set(Object.keys(parseYamlBlockMap(content, "catalog")));
3750
3910
  } catch {
3751
3911
  return /* @__PURE__ */ new Set();
@@ -3753,18 +3913,18 @@ function readCatalogPackageNames(forkPath) {
3753
3913
  }
3754
3914
  function readOverrides(forkPath) {
3755
3915
  const overrides = /* @__PURE__ */ new Map();
3756
- const pkgJsonPath = path2.join(forkPath, "package.json");
3916
+ const pkgJsonPath = join13(forkPath, "package.json");
3757
3917
  try {
3758
- const pkgJson = JSON.parse(fs2.readFileSync(pkgJsonPath, "utf-8"));
3918
+ const pkgJson = JSON.parse(readFileSync3(pkgJsonPath, "utf8"));
3759
3919
  const pnpmOverrides = pkgJson.pnpm?.overrides || {};
3760
3920
  for (const [key, value] of Object.entries(pnpmOverrides)) {
3761
3921
  overrides.set(key, { target: value, source: "package.json" });
3762
3922
  }
3763
3923
  } catch {
3764
3924
  }
3765
- const workspacePath = path2.join(forkPath, "pnpm-workspace.yaml");
3925
+ const workspacePath = join13(forkPath, "pnpm-workspace.yaml");
3766
3926
  try {
3767
- const content = fs2.readFileSync(workspacePath, "utf-8");
3927
+ const content = readFileSync3(workspacePath, "utf8");
3768
3928
  const wsOverrides = parseYamlBlockMap(content, "overrides");
3769
3929
  for (const [key, value] of Object.entries(wsOverrides)) {
3770
3930
  overrides.set(key, { target: value, source: "pnpm-workspace.yaml" });
@@ -3807,7 +3967,7 @@ async function checkOverrides(forkPath) {
3807
3967
  const maxTargetLen = Math.max(6, ...allValues.map((v) => v.target.length));
3808
3968
  if (securityOverrides.length > 0) {
3809
3969
  console.info(colors_default.bold(`security overrides (${securityOverrides.length})`));
3810
- console.info("\u2500".repeat(60));
3970
+ console.info(DIVIDER);
3811
3971
  for (const o of securityOverrides) {
3812
3972
  const icon = o.status === "active" ? colors_default.red("\u25CF") : colors_default.green("\u25CB");
3813
3973
  const statusText = o.status === "active" ? colors_default.red("active") : colors_default.green("likely stale");
@@ -3820,7 +3980,7 @@ async function checkOverrides(forkPath) {
3820
3980
  }
3821
3981
  if (pinOverrides.length > 0) {
3822
3982
  console.info(colors_default.bold(`version pins (${pinOverrides.length})`));
3823
- console.info("\u2500".repeat(60));
3983
+ console.info(DIVIDER);
3824
3984
  for (const o of pinOverrides) {
3825
3985
  const keyText = colors_default.white(o.key.padEnd(maxKeyLen));
3826
3986
  const targetText = colors_default.dim(o.target.padEnd(maxTargetLen));
@@ -3833,26 +3993,24 @@ async function checkOverrides(forkPath) {
3833
3993
  const activeCount = securityOverrides.filter((o) => o.status === "active").length;
3834
3994
  if (staleCount > 0) {
3835
3995
  console.info(
3836
- `${colors_default.green("\u25CB")} ${staleCount} override${staleCount !== 1 ? "s" : ""} likely stale \u2014 no matching advisory found in current audit`
3996
+ `${colors_default.green("\u25CB")} ${staleCount} override${staleCount !== 1 ? "s" : ""} likely stale: no matching advisory found in current audit`
3837
3997
  );
3838
- console.info(colors_default.dim(" overrides mask vulnerabilities \u2014 verify by temporarily removing and running pnpm audit"));
3998
+ console.info(colors_default.dim(" overrides mask vulnerabilities: verify by temporarily removing and running pnpm audit"));
3839
3999
  }
3840
4000
  if (activeCount > 0) {
3841
4001
  console.info(
3842
- `${colors_default.red("\u25CF")} ${activeCount} override${activeCount !== 1 ? "s" : ""} still active \u2014 advisory present without override`
4002
+ `${colors_default.red("\u25CF")} ${activeCount} override${activeCount !== 1 ? "s" : ""} still active: advisory present without override`
3843
4003
  );
3844
4004
  }
3845
4005
  if (pinOverrides.length > 0) {
3846
4006
  console.info(
3847
- `${colors_default.blue("\u25C6")} ${pinOverrides.length} version pin${pinOverrides.length !== 1 ? "s" : ""} \u2014 not security-related, review manually`
4007
+ `${colors_default.blue("\u25C6")} ${pinOverrides.length} version pin${pinOverrides.length !== 1 ? "s" : ""}: not security-related, review manually`
3848
4008
  );
3849
4009
  }
3850
4010
  console.info();
3851
4011
  }
3852
4012
 
3853
4013
  // src/services/contributions.ts
3854
- import { readFileSync as readFileSync4 } from "fs";
3855
- import { join as join16 } from "path";
3856
4014
  import {
3857
4015
  createPrompt,
3858
4016
  isDownKey,
@@ -3860,14 +4018,13 @@ import {
3860
4018
  isSpaceKey,
3861
4019
  isUpKey,
3862
4020
  useKeypress,
3863
- useMemo,
3864
4021
  usePagination,
3865
4022
  useState
3866
4023
  } from "@inquirer/core";
3867
4024
  import { select as select2 } from "@inquirer/prompts";
3868
4025
 
3869
4026
  // src/services/contrib-core.ts
3870
- import { dirname as dirname5, join as join15, resolve as resolve5 } from "path";
4027
+ import { dirname as dirname5, join as join14, resolve as resolve5 } from "path";
3871
4028
  async function listFilesAtRef(repoPath, ref) {
3872
4029
  const raw = await git(["ls-tree", "-r", "--name-only", ref], repoPath);
3873
4030
  return new Set(raw.split("\n").filter(Boolean));
@@ -3875,8 +4032,8 @@ async function listFilesAtRef(repoPath, ref) {
3875
4032
  async function detectContributableFiles(repoPath, baseRef, forkRef, config, forkTerritory = []) {
3876
4033
  const mergeBase = (await git(["merge-base", baseRef, forkRef], repoPath, { ignoreErrors: true })).trim() || baseRef;
3877
4034
  const predicates = {
3878
- isIgnored: (path4) => isIgnored(path4, config) || isUnderAnyFolder(path4, forkTerritory),
3879
- isPinned: (path4) => isPinned(path4, config)
4035
+ isIgnored: (path) => isIgnored(path, config) || isUnderAnyFolder(path, forkTerritory),
4036
+ isPinned: (path) => isPinned(path, config)
3880
4037
  };
3881
4038
  const analyzed = await analyzeRefs(repoPath, baseRef, forkRef, mergeBase, predicates);
3882
4039
  await enrichChangeInfo(repoPath, analyzed, mergeBase, baseRef, forkRef);
@@ -3931,7 +4088,7 @@ async function readBlobInfo(repoPath, ref, filePath) {
3931
4088
  async function buildContribBranch(repoPath, baseRef, forkRef, detection, forkName) {
3932
4089
  const branch = `contrib/${forkName}`;
3933
4090
  const gitDir = resolve5(repoPath, await git(["rev-parse", "--git-dir"], repoPath));
3934
- const tmpIndex = join15(gitDir, `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
4091
+ const tmpIndex = join14(gitDir, `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
3935
4092
  const indexEnv = { GIT_INDEX_FILE: tmpIndex };
3936
4093
  try {
3937
4094
  await git(["read-tree", baseRef], repoPath, { env: indexEnv });
@@ -4004,20 +4161,14 @@ async function showContribDiff(item, baseRef, cwd, forkName) {
4004
4161
  if (patch.length === 0) return null;
4005
4162
  return openDiffInBrowser(patch.toString(), { filePath: item.path, srcLabel: "cella", dstLabel: forkName }, cwd);
4006
4163
  }
4007
- function shouldApplyContributionsUnstaged(repoPath) {
4008
- return JSON.parse(readFileSync4(join16(repoPath, "package.json"), "utf8")).name === "cella";
4009
- }
4010
4164
  var contribPrompt = createPrompt((config, done) => {
4011
4165
  const { pageSize = 20, baseRef, cwd, forkName } = config;
4012
4166
  const [items, setItems] = useState(config.items);
4013
4167
  const [active, setActive] = useState(0);
4014
4168
  const [statusMsg, setStatusMsg] = useState("");
4015
4169
  const [promptStatus, setPromptStatus] = useState("idle");
4016
- const bounds = useMemo(() => {
4017
- if (items.length === 0) return { first: 0, last: 0 };
4018
- return { first: 0, last: items.length - 1 };
4019
- }, [items]);
4020
4170
  useKeypress(async (key) => {
4171
+ const last = items.length - 1;
4021
4172
  if (items.length === 0) {
4022
4173
  if (isEnterKey(key) || key.name === "q") {
4023
4174
  setPromptStatus("done");
@@ -4041,22 +4192,22 @@ var contribPrompt = createPrompt((config, done) => {
4041
4192
  return;
4042
4193
  }
4043
4194
  if (key.ctrl && isUpKey(key)) {
4044
- setActive(bounds.first);
4195
+ setActive(0);
4045
4196
  setStatusMsg("");
4046
4197
  return;
4047
4198
  }
4048
4199
  if (key.ctrl && isDownKey(key)) {
4049
- setActive(bounds.last);
4200
+ setActive(last);
4050
4201
  setStatusMsg("");
4051
4202
  return;
4052
4203
  }
4053
4204
  if (isUpKey(key)) {
4054
- setActive(active <= bounds.first ? bounds.first : active - 1);
4205
+ setActive(active <= 0 ? 0 : active - 1);
4055
4206
  setStatusMsg("");
4056
4207
  return;
4057
4208
  }
4058
4209
  if (isDownKey(key)) {
4059
- setActive(active >= bounds.last ? bounds.last : active + 1);
4210
+ setActive(active >= last ? last : active + 1);
4060
4211
  setStatusMsg("");
4061
4212
  return;
4062
4213
  }
@@ -4069,7 +4220,7 @@ var contribPrompt = createPrompt((config, done) => {
4069
4220
  const pagePath = await showContribDiff(items[active], baseRef, cwd, forkName);
4070
4221
  setStatusMsg(pagePath ? `opened ${items[active].path} in browser` : `no changes for ${items[active].path}`);
4071
4222
  } catch (error) {
4072
- setStatusMsg(`diff failed: ${error instanceof Error ? error.message : "unknown error"}`);
4223
+ setStatusMsg(`diff failed: ${errorMessage(error)}`);
4073
4224
  }
4074
4225
  return;
4075
4226
  }
@@ -4083,12 +4234,12 @@ var contribPrompt = createPrompt((config, done) => {
4083
4234
  if (promptStatus === "done") {
4084
4235
  const checked = items.filter((i) => i.checked);
4085
4236
  if (checked.length > 0) {
4086
- return `${colors_default.green("\u2713")} ${checked.length} files accepted`;
4237
+ return `${checkMark} ${checked.length} files accepted`;
4087
4238
  }
4088
- return `${colors_default.green("\u2713")} done`;
4239
+ return `${checkMark} done`;
4089
4240
  }
4090
4241
  if (items.length === 0) {
4091
- return `${colors_default.green("\u2713")} no contributions \u2014 press enter`;
4242
+ return `${checkMark} no contributions: press enter`;
4092
4243
  }
4093
4244
  const checkedCount = items.filter((i) => i.checked).length;
4094
4245
  const page = usePagination({
@@ -4122,6 +4273,38 @@ var contribPrompt = createPrompt((config, done) => {
4122
4273
  const lines = [header, page, statusLine, helpLine].filter(Boolean).join("\n").trimEnd();
4123
4274
  return `${lines}\x1B[?25l`;
4124
4275
  });
4276
+ async function selectFork(config, validated) {
4277
+ if (config.fork) {
4278
+ const match = validated.find((v) => v.fork.name === config.fork);
4279
+ if (!match?.valid) {
4280
+ throw new Error(`fork '${config.fork}' not found or invalid in config`);
4281
+ }
4282
+ return match;
4283
+ }
4284
+ const validForks = validated.filter((v) => v.valid);
4285
+ if (config.list || config.json || config.diff) {
4286
+ if (validForks.length === 0) {
4287
+ throw new Error("no valid forks configured");
4288
+ }
4289
+ if (validForks.length > 1) {
4290
+ throw new Error("multiple forks configured; pass --fork <name> to choose one");
4291
+ }
4292
+ return validForks[0];
4293
+ }
4294
+ const choices = validated.map((v) => ({
4295
+ value: v.fork.name,
4296
+ name: v.valid ? `${v.fork.name} ${colors_default.dim(`[${v.fork.pullBranch}] ${v.fork.localPath}`)}` : `${v.fork.name} ${colors_default.dim(v.fork.localPath)}`,
4297
+ disabled: v.valid ? false : v.error ?? "invalid"
4298
+ }));
4299
+ const picked = await select2({
4300
+ message: "select fork to pull contributions from:",
4301
+ choices,
4302
+ loop: false
4303
+ });
4304
+ const selected = validated.find((v) => v.fork.name === picked);
4305
+ if (!selected) console.info(colors_default.dim("no fork selected."));
4306
+ return selected;
4307
+ }
4125
4308
  async function fetchForkBranch(cellaPath, fetchSource, pullBranch) {
4126
4309
  await git(["fetch", fetchSource, pullBranch], cellaPath);
4127
4310
  return git(["rev-parse", "FETCH_HEAD"], cellaPath);
@@ -4149,42 +4332,8 @@ async function runContributions(config) {
4149
4332
  }
4150
4333
  const forkBasePath = await resolveForkBasePath(config.forkPath);
4151
4334
  const validated = forks.map((fork2) => validateForkPath(fork2, forkBasePath));
4152
- const validForks = validated.filter((v) => v.valid);
4153
- let selectedFork;
4154
- if (config.fork) {
4155
- const match = validated.find((v) => v.fork.name === config.fork);
4156
- if (!match?.valid) {
4157
- console.error(colors_default.red(`fork '${config.fork}' not found or invalid in config`));
4158
- return;
4159
- }
4160
- selectedFork = match;
4161
- } else if (config.list || config.json || config.diff) {
4162
- if (validForks.length === 0) {
4163
- console.error(colors_default.red("no valid forks configured"));
4164
- return;
4165
- }
4166
- if (validForks.length > 1) {
4167
- console.error(colors_default.red("multiple forks configured; pass --fork <name> to choose one"));
4168
- return;
4169
- }
4170
- selectedFork = validForks[0];
4171
- } else {
4172
- const choices = validated.map((v) => ({
4173
- value: v.fork.name,
4174
- name: v.valid ? `${v.fork.name} ${colors_default.dim(`[${v.fork.pullBranch}] ${v.fork.localPath}`)}` : `${v.fork.name} ${colors_default.dim(v.fork.localPath)}`,
4175
- disabled: v.valid ? false : v.error ?? "invalid"
4176
- }));
4177
- const picked = await select2({
4178
- message: "select fork to pull contributions from:",
4179
- choices,
4180
- loop: false
4181
- });
4182
- selectedFork = validated.find((v) => v.fork.name === picked);
4183
- }
4184
- if (!selectedFork) {
4185
- console.info(colors_default.dim("no fork selected."));
4186
- return;
4187
- }
4335
+ const selectedFork = await selectFork(config, validated);
4336
+ if (!selectedFork) return;
4188
4337
  const { fork, resolvedPath } = selectedFork;
4189
4338
  const forkName = fork.name;
4190
4339
  createSpinner("pulling fork contributions...");
@@ -4207,40 +4356,28 @@ async function runContributions(config) {
4207
4356
  if (appliedFiles.length > 0) {
4208
4357
  const stat = await getDiffStat(config.forkPath, baseRef, branch);
4209
4358
  const metaByPath = new Map(detection.files.map((f) => [f.path, f]));
4210
- for (const path4 of [...detection.modified, ...detection.created]) {
4211
- const fileMeta = metaByPath.get(path4);
4212
- allItems.push({
4213
- path: path4,
4214
- ref: branch,
4215
- deleted: false,
4216
- created: fileMeta?.kind === "created",
4217
- status: fileMeta?.status,
4218
- changedAt: fileMeta?.changedAt,
4219
- changedTs: fileMeta?.changedTs,
4220
- additions: stat.get(path4)?.additions ?? null,
4221
- deletions: stat.get(path4)?.deletions ?? null,
4222
- checked: false
4223
- });
4224
- }
4225
- for (const path4 of detection.deleted) {
4226
- const fileMeta = metaByPath.get(path4);
4227
- allItems.push({
4228
- path: path4,
4359
+ const toItem = (path, deleted) => {
4360
+ const fileMeta = metaByPath.get(path);
4361
+ return {
4362
+ path,
4229
4363
  ref: branch,
4230
- deleted: true,
4364
+ deleted,
4365
+ ...deleted ? {} : { created: fileMeta?.kind === "created" },
4231
4366
  status: fileMeta?.status,
4232
4367
  changedAt: fileMeta?.changedAt,
4233
4368
  changedTs: fileMeta?.changedTs,
4234
- additions: stat.get(path4)?.additions ?? null,
4235
- deletions: stat.get(path4)?.deletions ?? null,
4369
+ additions: stat.get(path)?.additions ?? null,
4370
+ deletions: stat.get(path)?.deletions ?? null,
4236
4371
  checked: false
4237
- });
4238
- }
4372
+ };
4373
+ };
4374
+ for (const path of [...detection.modified, ...detection.created]) allItems.push(toItem(path, false));
4375
+ for (const path of detection.deleted) allItems.push(toItem(path, true));
4239
4376
  }
4240
4377
  }
4241
4378
  } catch (error) {
4242
4379
  spinnerFail(`failed to pull ${forkName}`);
4243
- console.info(colors_default.red(` \u2717 ${error instanceof Error ? error.message : "unknown error"}`));
4380
+ console.info(colors_default.red(` \u2717 ${errorMessage(error)}`));
4244
4381
  return;
4245
4382
  }
4246
4383
  if (allItems.length === 0) {
@@ -4251,15 +4388,13 @@ async function runContributions(config) {
4251
4388
  spinnerSuccess(`${allItems.length} files from ${forkName}`);
4252
4389
  if (!config.list && !config.diff && forkBanner) {
4253
4390
  console.info(
4254
- ` ${colors_default.dim(`${forkName}: comparing committed '${forkBanner.pullBranch}' @ ${forkBanner.sha} (${forkBanner.date}) \u2014 uncommitted fork changes are not included`)}`
4391
+ ` ${colors_default.dim(`${forkName}: comparing committed '${forkBanner.pullBranch}' @ ${forkBanner.sha} (${forkBanner.date}); uncommitted fork changes are not included`)}`
4255
4392
  );
4256
4393
  }
4257
4394
  if (config.diff) {
4258
4395
  const matches = allItems.filter((i) => i.path === config.diff);
4259
4396
  if (matches.length === 0) {
4260
- console.error(colors_default.red(`no contribution found for path: ${config.diff}`));
4261
- process.exitCode = 1;
4262
- return;
4397
+ throw new Error(`no contribution found for path: ${config.diff}`);
4263
4398
  }
4264
4399
  for (const item of matches) {
4265
4400
  const diff = gitDiffFile(config.forkPath, `${baseRef}..${item.ref}`, item.path, { dstPrefix: forkName });
@@ -4286,7 +4421,7 @@ async function runContributions(config) {
4286
4421
  const status = item.status ?? "behind";
4287
4422
  const kind = item.deleted ? "deleted" : "modified";
4288
4423
  const changedAt = item.changedAt ?? "-";
4289
- console.info(`${forkName} ${status} ${kind} ${changedAt} ${item.path}`);
4424
+ writeStdout(`${forkName} ${status} ${kind} ${changedAt} ${item.path}`);
4290
4425
  }
4291
4426
  return;
4292
4427
  }
@@ -4305,7 +4440,7 @@ async function runContributions(config) {
4305
4440
  return;
4306
4441
  }
4307
4442
  createSpinner(`applying ${selected.length} files...`);
4308
- const applyUnstaged = shouldApplyContributionsUnstaged(config.forkPath);
4443
+ const applyUnstaged = isUpstreamRepo(config.forkPath);
4309
4444
  let applied = 0;
4310
4445
  const errors = [];
4311
4446
  for (const item of selected) {
@@ -4329,7 +4464,7 @@ async function runContributions(config) {
4329
4464
  }
4330
4465
  applied++;
4331
4466
  } catch (error) {
4332
- errors.push(`${item.path}: ${error instanceof Error ? error.message : "unknown error"}`);
4467
+ errors.push(`${item.path}: ${errorMessage(error)}`);
4333
4468
  }
4334
4469
  }
4335
4470
  if (errors.length > 0) {
@@ -4343,7 +4478,7 @@ async function runContributions(config) {
4343
4478
  console.info();
4344
4479
  console.info(
4345
4480
  colors_default.dim(
4346
- applyUnstaged ? " files are unstaged \u2014 review, stage what you want, and commit when ready" : " files are staged \u2014 review and commit when ready"
4481
+ applyUnstaged ? " files are unstaged: review, stage what you want, and commit when ready" : " files are staged: review and commit when ready"
4347
4482
  )
4348
4483
  );
4349
4484
  console.info();
@@ -4355,16 +4490,16 @@ import process8 from "process";
4355
4490
  import { Separator as Separator2, select as select4 } from "@inquirer/prompts";
4356
4491
 
4357
4492
  // src/services/sync.ts
4358
- import { spawnSync as spawnSync3 } from "child_process";
4493
+ import { spawnSync as spawnSync4 } from "child_process";
4359
4494
  import { select as select3 } from "@inquirer/prompts";
4360
4495
 
4361
4496
  // src/utils/gh.ts
4362
- import { spawnSync as spawnSync2 } from "child_process";
4497
+ import { spawnSync as spawnSync3 } from "child_process";
4363
4498
  function ghAvailable() {
4364
- return spawnSync2("gh", ["--version"], { stdio: "ignore" }).status === 0;
4499
+ return spawnSync3("gh", ["--version"], { stdio: "ignore" }).status === 0;
4365
4500
  }
4366
4501
  function runGh(cwd, args) {
4367
- const result = spawnSync2("gh", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
4502
+ const result = spawnSync3("gh", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
4368
4503
  const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
4369
4504
  return { ok: result.status === 0, output };
4370
4505
  }
@@ -4404,12 +4539,12 @@ function closePr(cwd, ref) {
4404
4539
  }
4405
4540
 
4406
4541
  // src/services/packages.ts
4407
- import { existsSync as existsSync11, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
4408
- import { join as join18 } from "path";
4542
+ import { existsSync as existsSync11, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
4543
+ import { join as join16 } from "path";
4409
4544
 
4410
4545
  // src/services/package-usage.ts
4411
- import { existsSync as existsSync10, readFileSync as readFileSync5, statSync } from "fs";
4412
- import { join as join17 } from "path";
4546
+ import { existsSync as existsSync10, readFileSync as readFileSync4, statSync } from "fs";
4547
+ import { join as join15 } from "path";
4413
4548
  var MAX_FILE_BYTES = 1024 * 1024;
4414
4549
  function escapeRegExp(value) {
4415
4550
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -4417,41 +4552,41 @@ function escapeRegExp(value) {
4417
4552
  function readSearchable(filePath) {
4418
4553
  try {
4419
4554
  if (statSync(filePath).size > MAX_FILE_BYTES) return "";
4420
- const text = readFileSync5(filePath, "utf-8");
4555
+ const text = readFileSync4(filePath, "utf8");
4421
4556
  return text.includes("\0") ? "" : text;
4422
4557
  } catch {
4423
4558
  return "";
4424
4559
  }
4425
4560
  }
4426
- function fileSource(forkPath, path4) {
4561
+ function fileSource(forkPath, path) {
4427
4562
  let text;
4428
- return { path: path4, label: path4, text: () => text ??= readSearchable(join17(forkPath, path4)) };
4563
+ return { path, label: path, text: () => text ??= readSearchable(join15(forkPath, path)) };
4429
4564
  }
4430
- async function readScriptsAtRef(forkPath, ref, path4) {
4565
+ async function readScriptsAtRef(forkPath, ref, path) {
4431
4566
  try {
4432
- return JSON.parse(await git(["show", `${ref}:${path4}`], forkPath)).scripts ?? {};
4567
+ return JSON.parse(await git(["show", `${ref}:${path}`], forkPath)).scripts ?? {};
4433
4568
  } catch {
4434
4569
  return {};
4435
4570
  }
4436
4571
  }
4437
- async function scriptSources(forkPath, upstreamRef, baseRef, path4) {
4572
+ async function scriptSources(forkPath, upstreamRef, baseRef, path) {
4438
4573
  let forkScripts;
4439
4574
  try {
4440
- forkScripts = JSON.parse(readFileSync5(join17(forkPath, path4), "utf-8")).scripts ?? {};
4575
+ forkScripts = JSON.parse(readFileSync4(join15(forkPath, path), "utf8")).scripts ?? {};
4441
4576
  } catch {
4442
4577
  return [];
4443
4578
  }
4444
- const baseScripts = await readScriptsAtRef(forkPath, baseRef, path4);
4445
- const upstreamScripts = await readScriptsAtRef(forkPath, upstreamRef, path4);
4446
- return Object.entries(forkScripts).filter(([name, value]) => value !== baseScripts[name] && value !== upstreamScripts[name]).map(([name, value]) => ({ path: path4, label: `${path4} (scripts.${name})`, text: () => value }));
4579
+ const baseScripts = await readScriptsAtRef(forkPath, baseRef, path);
4580
+ const upstreamScripts = await readScriptsAtRef(forkPath, upstreamRef, path);
4581
+ return Object.entries(forkScripts).filter(([name, value]) => value !== baseScripts[name] && value !== upstreamScripts[name]).map(([name, value]) => ({ path, label: `${path} (scripts.${name})`, text: () => value }));
4447
4582
  }
4448
4583
  function binNames(forkPath, location, name) {
4449
4584
  const bareName = name.split("/").pop();
4450
- for (const dir of [join17(forkPath, location), forkPath]) {
4451
- const manifest = join17(dir, "node_modules", name, "package.json");
4585
+ for (const dir of [join15(forkPath, location), forkPath]) {
4586
+ const manifest = join15(dir, "node_modules", name, "package.json");
4452
4587
  if (!existsSync10(manifest)) continue;
4453
4588
  try {
4454
- const { bin } = JSON.parse(readFileSync5(manifest, "utf-8"));
4589
+ const { bin } = JSON.parse(readFileSync4(manifest, "utf8"));
4455
4590
  if (typeof bin === "string") return [bareName];
4456
4591
  return bin && typeof bin === "object" ? Object.keys(bin) : [];
4457
4592
  } catch {
@@ -4476,9 +4611,9 @@ function scriptPattern(name) {
4476
4611
  async function loadForkUsage(forkPath, upstreamRef, baseRef) {
4477
4612
  const diff = await git(["diff", "--name-only", "-z", "--diff-filter=d", upstreamRef], forkPath);
4478
4613
  const sources = [];
4479
- for (const path4 of diff.split("\0").filter(Boolean)) {
4480
- if (isPackageJson(path4)) sources.push(...await scriptSources(forkPath, upstreamRef, baseRef, path4));
4481
- else if (!isManagedFile(path4)) sources.push(fileSource(forkPath, path4));
4614
+ for (const path of diff.split("\0").filter(Boolean)) {
4615
+ if (isPackageJson(path)) sources.push(...await scriptSources(forkPath, upstreamRef, baseRef, path));
4616
+ else if (!isManagedFile(path)) sources.push(fileSource(forkPath, path));
4482
4617
  }
4483
4618
  const findIn = (scoped, patterns) => scoped.find((source) => patterns.some((pattern) => pattern.test(source.text())))?.label;
4484
4619
  return {
@@ -4631,14 +4766,14 @@ function setTopLevelKey(pkg, key, value) {
4631
4766
  function readPackageJson(filePath) {
4632
4767
  if (!existsSync11(filePath)) return null;
4633
4768
  try {
4634
- return JSON.parse(readFileSync6(filePath, "utf-8"));
4769
+ return JSON.parse(readFileSync5(filePath, "utf8"));
4635
4770
  } catch {
4636
4771
  return null;
4637
4772
  }
4638
4773
  }
4639
4774
  function writePackageJson(filePath, data) {
4640
4775
  writeFileSync4(filePath, `${JSON.stringify(data, null, 2)}
4641
- `, "utf-8");
4776
+ `, "utf8");
4642
4777
  }
4643
4778
  function packageJsonPath(relativePath) {
4644
4779
  return relativePath ? `${relativePath}/package.json` : "package.json";
@@ -4667,16 +4802,16 @@ async function syncPackageJson(config, baseRef, usage, relativePath, keysToSync)
4667
4802
  const changes = [];
4668
4803
  const kept = [];
4669
4804
  const pkgRelPath = packageJsonPath(relativePath);
4670
- const pkgPath = join18(forkPath, pkgRelPath);
4805
+ const pkgPath = join16(forkPath, pkgRelPath);
4671
4806
  const forkPkg = readPackageJson(pkgPath);
4672
4807
  const upstream = await readPackageJsonAtRef(forkPath, upstreamRef, relativePath);
4673
4808
  const basePkg = baseRef ? (await readPackageJsonAtRef(forkPath, baseRef, relativePath))?.data : void 0;
4674
4809
  if (!upstream) return { updated: false, changes, kept };
4675
4810
  if (!forkPkg) {
4676
- const workspaceArrived = relativePath !== "" && existsSync11(join18(forkPath, relativePath));
4811
+ const workspaceArrived = relativePath !== "" && existsSync11(join16(forkPath, relativePath));
4677
4812
  if (basePkg || !workspaceArrived || isIgnored(pkgRelPath, config)) return { updated: false, changes, kept };
4678
4813
  writeFileSync4(pkgPath, upstream.raw.endsWith("\n") ? upstream.raw : `${upstream.raw}
4679
- `, "utf-8");
4814
+ `, "utf8");
4680
4815
  return { updated: true, changes: ["copied from upstream (new workspace)"], kept };
4681
4816
  }
4682
4817
  const upstreamPkg = upstream.data;
@@ -4748,7 +4883,7 @@ async function syncPackageJson(config, baseRef, usage, relativePath, keysToSync)
4748
4883
  }
4749
4884
  async function runPackages(config, options = {}) {
4750
4885
  createSpinner("syncing package.json files...");
4751
- const keysToSync = config.settings.packageJsonSync || ["dependencies", "devDependencies"];
4886
+ const keysToSync = config.settings.packageJsonSync || DEFAULT_PACKAGE_JSON_SYNC;
4752
4887
  const conflictedSet = new Set(options.conflictedFiles ?? []);
4753
4888
  const baseRef = await resolveMergeBase(config.forkPath, config.upstreamRef);
4754
4889
  const usage = baseRef ? await loadForkUsage(config.forkPath, config.upstreamRef, baseRef) : null;
@@ -4770,26 +4905,26 @@ async function runPackages(config, options = {}) {
4770
4905
  "package sync complete",
4771
4906
  `${changedCount} package.json${changedCount === 1 ? " was" : "s were"} changed`
4772
4907
  );
4773
- for (const { path: path4, changes } of reports) {
4908
+ for (const { path, changes } of reports) {
4774
4909
  if (changes.length === 0) continue;
4775
- console.info(` ${path4}`);
4910
+ console.info(` ${path}`);
4776
4911
  for (const change of changes) console.info(` ${colors_default.dim(change)}`);
4777
4912
  }
4778
- const kept = reports.flatMap(({ path: path4, kept: kept2 }) => kept2.map((entry) => `${path4} ${entry}`));
4913
+ const kept = reports.flatMap(({ path, kept: kept2 }) => kept2.map((entry) => `${path} ${entry}`));
4779
4914
  if (kept.length > 0) {
4780
4915
  console.info();
4781
- console.warn(
4782
- `${warningMark} kept ${kept.length} ${kept.length === 1 ? "entry" : "entries"} upstream dropped, the fork still uses ${kept.length === 1 ? "it" : "them"}:`
4916
+ console.info(
4917
+ `${warningMark} ${colors_default.yellow(`kept ${kept.length} ${kept.length === 1 ? "entry" : "entries"} upstream dropped, the fork still uses ${kept.length === 1 ? "it" : "them"}:`)}`
4783
4918
  );
4784
- for (const entry of kept) console.warn(` ${colors_default.dim("\u2192")} ${entry}`);
4919
+ for (const entry of kept) console.info(` ${colors_default.dim("\u2192")} ${entry}`);
4785
4920
  }
4786
4921
  if (skipped.length > 0) {
4787
4922
  console.info();
4788
- console.warn(
4789
- `${warningMark} skipped ${skipped.length} conflicted package.json file(s) \u2014 resolve the conflicts, commit the merge, then rerun \`pnpm cella sync\`:`
4923
+ console.info(
4924
+ `${warningMark} ${colors_default.yellow(`skipped ${skipped.length} conflicted package.json file(s): resolve the conflicts, commit the merge, then rerun \`pnpm cella sync\`:`)}`
4790
4925
  );
4791
- for (const path4 of skipped) {
4792
- console.warn(` ${colors_default.dim("\u2192")} ${path4}`);
4926
+ for (const path of skipped) {
4927
+ console.info(` ${colors_default.dim("\u2192")} ${path}`);
4793
4928
  }
4794
4929
  }
4795
4930
  }
@@ -4797,14 +4932,14 @@ async function runPackages(config, options = {}) {
4797
4932
  // src/services/sync.ts
4798
4933
  async function resolveCycleStart(forkPath, base, currentBranch) {
4799
4934
  if (!await branchExists(forkPath, base)) {
4800
- await fetch2(forkPath, "origin").catch(() => {
4935
+ await fetchRemote(forkPath, "origin").catch(() => {
4801
4936
  });
4802
- console.info(colors_default.dim(`'${base}' has no local branch \u2014 cutting from 'origin/${base}'.`));
4937
+ console.info(colors_default.dim(`'${base}' has no local branch: cutting from 'origin/${base}'.`));
4803
4938
  return `origin/${base}`;
4804
4939
  }
4805
4940
  const { upstream, ahead, behind } = await getUpstreamStatus(forkPath, base);
4806
4941
  if (!upstream) {
4807
- console.info(colors_default.dim(`'${base}' has no upstream \u2014 skipping the up-to-date check.`));
4942
+ console.info(colors_default.dim(`'${base}' has no upstream: skipping the up-to-date check.`));
4808
4943
  return base;
4809
4944
  }
4810
4945
  if (ahead > 0 && behind > 0) {
@@ -4817,7 +4952,7 @@ Reconcile '${base}' with '${upstream}' first (e.g. rebase or reset it), then re-
4817
4952
  const worktree = currentBranch === base ? null : await getBranchWorktree(forkPath, base);
4818
4953
  if (worktree) {
4819
4954
  console.info(
4820
- colors_default.dim(`'${base}' is ${behind} behind '${upstream}' but checked out at ${worktree} \u2014 leaving it as it is.`)
4955
+ colors_default.dim(`'${base}' is ${behind} behind '${upstream}' but checked out at ${worktree}; leaving it as it is.`)
4821
4956
  );
4822
4957
  return upstream;
4823
4958
  }
@@ -4827,7 +4962,7 @@ Reconcile '${base}' with '${upstream}' first (e.g. rebase or reset it), then re-
4827
4962
  return base;
4828
4963
  }
4829
4964
  if (ahead > 0) {
4830
- console.info(colors_default.dim(`'${base}' is ${ahead} commit(s) ahead of '${upstream}' (unpushed) \u2014 continuing.`));
4965
+ console.info(colors_default.dim(`'${base}' is ${ahead} commit(s) ahead of '${upstream}' (unpushed); continuing.`));
4831
4966
  }
4832
4967
  return base;
4833
4968
  }
@@ -4852,7 +4987,7 @@ async function resolveCutBase(forkPath, base) {
4852
4987
  async function returnToBase(forkPath, base) {
4853
4988
  const worktree = await getBranchWorktree(forkPath, base);
4854
4989
  if (worktree) {
4855
- console.info(colors_default.dim(`'${base}' is checked out at ${worktree} \u2014 detaching at '${base}' here instead...`));
4990
+ console.info(colors_default.dim(`'${base}' is checked out at ${worktree}; detaching at '${base}' here instead...`));
4856
4991
  await switchDetached(forkPath, base);
4857
4992
  return `detached at '${base}'`;
4858
4993
  }
@@ -4864,30 +4999,24 @@ async function returnToBase(forkPath, base) {
4864
4999
  }
4865
5000
  async function runSync(config, options) {
4866
5001
  createSpinner("starting sync...");
4867
- const result = await runMergeEngine(config, {
4868
- apply: true,
4869
- onProgress: (message) => {
4870
- spinnerText(message);
4871
- },
4872
- onStep: (label, detail) => {
4873
- spinnerSuccess(label, detail);
4874
- createSpinner("...");
5002
+ let result;
5003
+ try {
5004
+ result = await runEngineWithSpinner(config, true);
5005
+ } catch (error) {
5006
+ spinnerSuccess();
5007
+ if (error instanceof UpstreamConfigChangedError) {
5008
+ printUpstreamOverrideChanges({ upstreamOverrides: error.report });
5009
+ console.info();
4875
5010
  }
4876
- });
5011
+ throw error;
5012
+ }
4877
5013
  if (result.success) {
4878
5014
  spinnerSuccess();
4879
5015
  } else {
4880
5016
  spinnerFail("sync completed with conflicts");
4881
5017
  }
4882
- printSummary(result.summary, "merge summary");
4883
- printIgnoredUpstreamChanges(result);
4884
- printUpstreamOverrideChanges(result);
4885
- printMaskingPinWarning(result.files);
4886
- if (config.logFile) {
4887
- const logPath = writeLogFile(config.forkPath, result.files);
4888
- console.info();
4889
- console.info(colors_default.dim(`full file list written to: ${logPath}`));
4890
- }
5018
+ printEngineReports(result, "merge summary");
5019
+ printLogFileReport(config, result.files);
4891
5020
  const stagedBranch = options?.stagedBranch && result.conflicts.length === 0 && hasStagedSyncChanges(result) ? options.stagedBranch : void 0;
4892
5021
  printSyncComplete(result, { stagedBranch });
4893
5022
  printFlagWarnings({ hard: config.hard, unpinned: config.unpinned });
@@ -4895,7 +5024,6 @@ async function runSync(config, options) {
4895
5024
  return result;
4896
5025
  }
4897
5026
  var SYNC_PR_TITLE = "chore: sync upstream cella";
4898
- var PR_BODY_COMMIT_MAX = 50;
4899
5027
  async function buildSyncCommitMessage(forkPath, upstreamRef) {
4900
5028
  const shortSha = await getShortSha(forkPath, upstreamRef).catch(() => "");
4901
5029
  if (!shortSha) return SYNC_PR_TITLE;
@@ -4947,9 +5075,8 @@ async function buildSyncPrBodyForBranch(forkPath, base) {
4947
5075
  const version = manifest.upstream.release ?? await readPackageVersionAtRef(forkPath, toSha).catch(() => null);
4948
5076
  const totalCount = fromSha ? await countCommitsBetween(forkPath, fromSha, toSha).catch(() => 0) : 0;
4949
5077
  const commits = fromSha && totalCount > 0 ? await listCommitsBetween(forkPath, fromSha, toSha, {
4950
- oldestFirst: true,
4951
- skip: totalCount > PR_BODY_COMMIT_MAX ? totalCount - PR_BODY_COMMIT_MAX : 0,
4952
- limit: PR_BODY_COMMIT_MAX
5078
+ skip: totalCount > COMMIT_LIST_MAX ? totalCount - COMMIT_LIST_MAX : 0,
5079
+ limit: COMMIT_LIST_MAX
4953
5080
  }) : [];
4954
5081
  const notes = await readPrBodyNotes(forkPath, toSha, manifest.upstream.repo).catch(() => void 0);
4955
5082
  return buildSyncPrBody({ repoSlug: manifest.upstream.repo, version, fromSha, toSha, commits, totalCount, notes });
@@ -4973,11 +5100,8 @@ function printShipSteps(temporaryBranch, base, title) {
4973
5100
  console.info(colors_default.dim(` git push -u origin ${temporaryBranch}`));
4974
5101
  printPrCreateStep(temporaryBranch, base, title);
4975
5102
  }
4976
- function printFinishSteps() {
4977
- console.info(colors_default.dim(" pnpm cella sync"));
4978
- }
4979
5103
  function hasStagedSyncChanges(result) {
4980
- return result.files.some((file) => ["behind", "diverged", "renamed", "ignored", "pinned"].includes(file.status));
5104
+ return result.files.some((file) => SYNC_APPLIED_STATUSES.includes(file.status));
4981
5105
  }
4982
5106
  function extractFirstUrl(output) {
4983
5107
  return output.match(/https?:\/\/\S+/)?.[0];
@@ -4988,7 +5112,7 @@ async function flattenSyncBranch(forkPath, branch, base) {
4988
5112
  const message = await buildSyncCommitMessage(forkPath, `${mergeCommits[0]}^2`);
4989
5113
  console.info(
4990
5114
  colors_default.yellow(
4991
- `'${branch}' contains ${mergeCommits.length} merge commit(s) \u2014 the PR would list the entire upstream history.`
5115
+ `'${branch}' contains ${mergeCommits.length} merge commit(s): the PR would list the entire upstream history.`
4992
5116
  )
4993
5117
  );
4994
5118
  console.info(colors_default.dim(`flattening '${branch}' to a single commit (same content)...`));
@@ -5011,8 +5135,7 @@ async function shipSyncBranch(config, branch) {
5011
5135
  try {
5012
5136
  await pushBranch(forkPath, "origin", branch, { forceWithLease: flattened });
5013
5137
  } catch (error) {
5014
- const detail = error instanceof Error ? error.message : String(error);
5015
- console.info(colors_default.yellow(`push failed (${detail.split("\n")[0]}). finish manually:`));
5138
+ console.info(colors_default.yellow(`push failed (${errorMessage(error).split("\n")[0]}). finish manually:`));
5016
5139
  printShipSteps(branch, base, prTitle);
5017
5140
  return;
5018
5141
  }
@@ -5020,9 +5143,9 @@ async function shipSyncBranch(config, branch) {
5020
5143
  console.info(colors_default.dim("opening a pull request..."));
5021
5144
  const prBody = await buildSyncPrBodyForBranch(forkPath, cutBase);
5022
5145
  const bodyArgs = prBody ? ["--body", prBody] : ["--fill"];
5023
- const pr = spawnSync3("gh", ["pr", "create", "--base", base, "--head", branch, "--title", prTitle, ...bodyArgs], {
5146
+ const pr = spawnSync4("gh", ["pr", "create", "--base", base, "--head", branch, "--title", prTitle, ...bodyArgs], {
5024
5147
  cwd: forkPath,
5025
- encoding: "utf-8",
5148
+ encoding: "utf8",
5026
5149
  stdio: ["ignore", "pipe", "pipe"]
5027
5150
  });
5028
5151
  prUrl = extractFirstUrl(`${pr.stdout ?? ""}
@@ -5034,16 +5157,16 @@ ${pr.stderr ?? ""}`);
5034
5157
  printPrCreateStep(branch, base, prTitle);
5035
5158
  }
5036
5159
  } else {
5037
- console.info(colors_default.yellow("`gh` not found \u2014 open the PR manually:"));
5160
+ console.info(colors_default.yellow("`gh` not found, open the PR manually:"));
5038
5161
  printPrCreateStep(branch, base, prTitle);
5039
5162
  }
5040
5163
  const position = await returnToBase(forkPath, base);
5041
5164
  console.info();
5042
5165
  if (prUrl) {
5043
- console.info(`${colors_default.green("\u2713")} Sync pull request ${prOpened ? "opened" : "ready"}`);
5166
+ console.info(`${checkMark} sync pull request ${prOpened ? "opened" : "ready"}`);
5044
5167
  console.info(colors_default.dim(` ${prUrl} \xB7 branch pushed, ${position}`));
5045
5168
  } else {
5046
- console.info(`${colors_default.green("\u2713")} Sync branch pushed`);
5169
+ console.info(`${checkMark} sync branch pushed`);
5047
5170
  console.info(colors_default.dim(` '${branch}' is on origin, ${position}`));
5048
5171
  }
5049
5172
  await printMigrationNotesLine(config);
@@ -5051,23 +5174,43 @@ ${pr.stderr ?? ""}`);
5051
5174
  function finalizeWorkspace(forkPath) {
5052
5175
  for (const args of [["install"], ["check"]]) {
5053
5176
  console.info(colors_default.dim(`running pnpm ${args.join(" ")}...`));
5054
- const result = spawnSync3("pnpm", args, { cwd: forkPath, stdio: "inherit" });
5177
+ const result = spawnSync4("pnpm", args, { cwd: forkPath, stdio: "inherit" });
5055
5178
  if (result.status !== 0) return false;
5056
5179
  }
5057
5180
  return true;
5058
5181
  }
5182
+ async function discardTemporarySyncBranch(forkPath, branch) {
5183
+ if (branch.startBranch === "HEAD") await switchDetached(forkPath, branch.startCommit);
5184
+ else await switchBranch(forkPath, branch.startBranch);
5185
+ await deleteBranch(forkPath, branch.temporaryBranch);
5186
+ }
5187
+ async function discardUnusedSyncBranch(forkPath, branch) {
5188
+ const unused = await getCurrentBranch(forkPath) === branch.temporaryBranch && !await mergeInProgress(forkPath) && await isClean(forkPath);
5189
+ if (!unused) return false;
5190
+ await discardTemporarySyncBranch(forkPath, branch);
5191
+ const start = branch.startBranch === "HEAD" ? `at ${branch.startCommit.slice(0, 7)}` : `on '${branch.startBranch}'`;
5192
+ console.info(colors_default.dim(`removed the unused '${branch.temporaryBranch}', back ${start}.`));
5193
+ return true;
5194
+ }
5059
5195
  async function runSyncCycle(config) {
5060
5196
  const { forkPath } = config;
5061
5197
  const branch = await setupTemporarySyncBranch(config);
5062
- const result = await runSync(config, { stagedBranch: branch.temporaryBranch });
5198
+ let result;
5199
+ try {
5200
+ result = await runSync(config, { stagedBranch: branch.temporaryBranch });
5201
+ } catch (error) {
5202
+ const discarded = await discardUnusedSyncBranch(forkPath, branch).catch(() => false);
5203
+ if (discarded && error instanceof BehindSyncPointError && !config.ref) {
5204
+ return { status: "noop", behind: error.message };
5205
+ }
5206
+ throw error;
5207
+ }
5063
5208
  if (config.settings.syncWithPackages !== false) {
5064
5209
  await runPackages(config, { conflictedFiles: result.conflicts });
5065
5210
  }
5066
5211
  if (result.conflicts.length > 0) return { status: "conflicts", branch };
5067
5212
  if (!await mergeInProgress(forkPath)) {
5068
- if (branch.startBranch === "HEAD") await switchDetached(forkPath, branch.startCommit);
5069
- else await switchBranch(forkPath, branch.startBranch);
5070
- await deleteBranch(forkPath, branch.temporaryBranch);
5213
+ await discardTemporarySyncBranch(forkPath, branch);
5071
5214
  return { status: "noop" };
5072
5215
  }
5073
5216
  return { status: "staged", branch };
@@ -5112,15 +5255,15 @@ async function mergeOpenSyncPrs(config, open) {
5112
5255
  const merged = mergePrSquash(forkPath, newest.number, { deleteBranch: true });
5113
5256
  if (!merged.ok) {
5114
5257
  console.info();
5115
- console.info(colors_default.yellow(`could not merge #${newest.number} \u2014 resolve it first, then re-run \`pnpm cella sync\`:`));
5258
+ console.info(colors_default.yellow(`could not merge #${newest.number}: resolve it first, then re-run \`pnpm cella sync\`:`));
5116
5259
  if (merged.output) console.info(colors_default.dim(indentLines(merged.output)));
5117
5260
  console.info(colors_default.dim(` ${newest.url}`));
5118
5261
  return "cancel";
5119
5262
  }
5120
- console.info(`${colors_default.green("\u2713")} merged #${newest.number}`);
5263
+ console.info(`${checkMark} merged #${newest.number}`);
5121
5264
  await deleteBranch(forkPath, newest.headRefName);
5122
5265
  for (const pr of superseded) {
5123
- console.info(colors_default.dim(`closing #${pr.number} \u2014 its changes are included in #${newest.number}...`));
5266
+ console.info(colors_default.dim(`closing #${pr.number}: its changes are included in #${newest.number}...`));
5124
5267
  closePr(forkPath, pr.number);
5125
5268
  await deleteBranch(forkPath, pr.headRefName);
5126
5269
  }
@@ -5185,15 +5328,20 @@ commit them first (\`git commit --amend --no-edit\` or a new commit).`
5185
5328
  const outcome = await runSyncCycle(config);
5186
5329
  console.info();
5187
5330
  if (outcome.status === "noop") {
5188
- console.info(colors_default.green("already up to date with upstream \u2014 nothing to sync."));
5331
+ if (outcome.behind) {
5332
+ console.info(colors_default.green("nothing to sync: the last sync already went past this upstream point."));
5333
+ for (const line of outcome.behind.split("\n")) console.info(colors_default.dim(` ${line}`));
5334
+ } else {
5335
+ console.info(colors_default.green("already up to date with upstream, nothing to sync."));
5336
+ }
5189
5337
  return;
5190
5338
  }
5191
5339
  const { temporaryBranch } = outcome.branch;
5192
5340
  if (outcome.status === "conflicts") {
5193
5341
  console.info(`${warningMark} ${colors_default.yellow(`conflicts on '${temporaryBranch}'. Resolve and stage them, then:`)}`);
5194
- printFinishSteps();
5342
+ console.info(colors_default.dim(" pnpm cella sync"));
5195
5343
  console.info(colors_default.dim(" rerun commits the sync and stops for drift triage; a further rerun ships (push + PR)."));
5196
- console.info(colors_default.dim(" let the rerun commit \u2014 a manual `git commit` records a merge commit that bloats the PR."));
5344
+ console.info(colors_default.dim(" let the rerun commit: a manual `git commit` records a merge commit that bloats the PR."));
5197
5345
  return;
5198
5346
  }
5199
5347
  await commitSyncMerge(config, temporaryBranch);
@@ -5201,12 +5349,11 @@ commit them first (\`git commit --amend --no-edit\` or a new commit).`
5201
5349
 
5202
5350
  // src/services/forks.ts
5203
5351
  async function gatherForkStatus(forkPath) {
5204
- const [branch, porcelain, syncRef] = await Promise.all([
5352
+ const [branch, dirty, syncRef] = await Promise.all([
5205
5353
  getCurrentBranch(forkPath).catch(() => "unknown"),
5206
- git(["status", "--porcelain"], forkPath, { ignoreErrors: true }),
5354
+ getWorkingTreeChangeCount(forkPath).catch(() => 0),
5207
5355
  getStoredSyncRef(forkPath)
5208
5356
  ]);
5209
- const dirty = porcelain ? porcelain.split("\n").filter(Boolean).length : 0;
5210
5357
  let lastSync = null;
5211
5358
  if (syncRef) {
5212
5359
  const commitInfo = await getCommitInfo(forkPath, syncRef).catch(() => null);
@@ -5269,7 +5416,8 @@ async function syncFork(config, fork, forkPath) {
5269
5416
  list: false,
5270
5417
  json: false,
5271
5418
  verbose: config.verbose,
5272
- hard: config.hard
5419
+ hard: config.hard,
5420
+ keepConfig: config.keepConfig
5273
5421
  };
5274
5422
  await runSyncCommand(forkRuntimeConfig);
5275
5423
  }
@@ -5282,8 +5430,7 @@ async function runForks(config) {
5282
5430
  if (config.fork) {
5283
5431
  const match = forks.find((f) => f.name === config.fork);
5284
5432
  if (!match) {
5285
- console.error(colors_default.red(`fork '${config.fork}' not found in config`));
5286
- return;
5433
+ throw new Error(`fork '${config.fork}' not found in config`);
5287
5434
  }
5288
5435
  const resolvedPath = resolve7(await resolveForkBasePath(config.forkPath), match.localPath);
5289
5436
  await syncFork(config, match, resolvedPath);
@@ -5292,7 +5439,7 @@ async function runForks(config) {
5292
5439
  const forkBasePath = await resolveForkBasePath(config.forkPath);
5293
5440
  while (true) {
5294
5441
  const choices = await buildForkChoices(forks, forkBasePath);
5295
- const forkChoices = [...choices, new Separator2("\u2500".repeat(40)), { value: "_exit", name: colors_default.dim("exit") }];
5442
+ const forkChoices = [...choices, new Separator2(MENU_DIVIDER), { value: "_exit", name: colors_default.dim("exit") }];
5296
5443
  const selectedPath = await select4({
5297
5444
  message: "select fork to sync:",
5298
5445
  choices: forkChoices,
@@ -5314,15 +5461,15 @@ async function runForks(config) {
5314
5461
 
5315
5462
  // src/services/stats.ts
5316
5463
  import { readFile as readFile3 } from "fs/promises";
5317
- import { join as join20 } from "path";
5464
+ import { join as join18 } from "path";
5318
5465
 
5319
5466
  // src/utils/coverage-utils.ts
5320
- import { spawnSync as spawnSync4 } from "child_process";
5321
- import * as fs3 from "fs";
5322
- import * as path3 from "path";
5467
+ import { spawnSync as spawnSync5 } from "child_process";
5468
+ import { existsSync as existsSync12, readFileSync as readFileSync6, statSync as statSync2 } from "fs";
5469
+ import { join as join17, relative as relative3, sep as sep2 } from "path";
5323
5470
  var STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1e3;
5324
5471
  function summaryPath(forkPath) {
5325
- return path3.join(forkPath, ".coverage", "coverage-summary.json");
5472
+ return join17(forkPath, ".coverage", "coverage-summary.json");
5326
5473
  }
5327
5474
  function colorPct(pct) {
5328
5475
  if (typeof pct !== "number" || !Number.isFinite(pct)) return colors_default.dim("n/a");
@@ -5341,7 +5488,7 @@ function formatAge(ms) {
5341
5488
  }
5342
5489
  function refreshCoverage(forkPath) {
5343
5490
  console.info(colors_default.dim("running `pnpm test` (this spins up Docker and runs the full suite)..."));
5344
- const result = spawnSync4("pnpm", ["test"], { cwd: forkPath, stdio: "inherit" });
5491
+ const result = spawnSync5("pnpm", ["test"], { cwd: forkPath, stdio: "inherit" });
5345
5492
  return result.status === 0;
5346
5493
  }
5347
5494
  function printCoverageSummary(forkPath, options = {}) {
@@ -5349,24 +5496,24 @@ function printCoverageSummary(forkPath, options = {}) {
5349
5496
  if (options.refresh) {
5350
5497
  const ok = refreshCoverage(forkPath);
5351
5498
  if (!ok) {
5352
- console.info(colors_default.red("coverage run failed \u2014 showing last cached results (if any)"));
5499
+ console.info(colors_default.red("coverage run failed: showing last cached results (if any)"));
5353
5500
  }
5354
5501
  console.info();
5355
5502
  }
5356
- if (!fs3.existsSync(file)) {
5503
+ if (!existsSync12(file)) {
5357
5504
  console.info(colors_default.bold("coverage"));
5358
- console.info(colors_default.yellow(" no coverage data found \u2014 run `pnpm test` to generate it"));
5505
+ console.info(colors_default.yellow(" no coverage data found: run `pnpm test` to generate it"));
5359
5506
  console.info();
5360
5507
  return;
5361
5508
  }
5362
5509
  let summary;
5363
5510
  let mtimeMs;
5364
5511
  try {
5365
- summary = JSON.parse(fs3.readFileSync(file, "utf-8"));
5366
- mtimeMs = fs3.statSync(file).mtimeMs;
5512
+ summary = JSON.parse(readFileSync6(file, "utf8"));
5513
+ mtimeMs = statSync2(file).mtimeMs;
5367
5514
  } catch {
5368
5515
  console.info(colors_default.bold("coverage"));
5369
- console.info(colors_default.yellow(" could not read coverage-summary.json \u2014 run `pnpm test` to regenerate"));
5516
+ console.info(colors_default.yellow(" could not read coverage-summary.json: run `pnpm test` to regenerate"));
5370
5517
  console.info();
5371
5518
  return;
5372
5519
  }
@@ -5375,9 +5522,9 @@ function printCoverageSummary(forkPath, options = {}) {
5375
5522
  const perPackage = /* @__PURE__ */ new Map();
5376
5523
  for (const [key, entry] of Object.entries(summary)) {
5377
5524
  if (key === "total") continue;
5378
- const rel = path3.relative(forkPath, key);
5525
+ const rel = relative3(forkPath, key);
5379
5526
  if (rel.startsWith("..")) continue;
5380
- const pkg = rel.split(path3.sep)[0];
5527
+ const pkg = rel.split(sep2)[0];
5381
5528
  const acc = perPackage.get(pkg) ?? { covered: 0, total: 0 };
5382
5529
  acc.covered += entry.lines.covered;
5383
5530
  acc.total += entry.lines.total;
@@ -5386,10 +5533,10 @@ function printCoverageSummary(forkPath, options = {}) {
5386
5533
  const ageLabel = formatAge(ageMs);
5387
5534
  console.info(colors_default.bold("coverage"), colors_default.dim(`(updated ${ageLabel})`));
5388
5535
  if (stale) {
5389
- console.info(colors_default.yellow(" \u26A0 data is older than 7 days \u2014 run `pnpm test` to refresh"));
5536
+ console.info(colors_default.yellow(" \u26A0 data is older than 7 days: run `pnpm test` to refresh"));
5390
5537
  }
5391
5538
  if (!Object.keys(summary).some((key) => key !== "total")) {
5392
- console.info(colors_default.yellow(" no coverage data found \u2014 run `pnpm test` to regenerate"));
5539
+ console.info(colors_default.yellow(" no coverage data found: run `pnpm test` to regenerate"));
5393
5540
  console.info();
5394
5541
  return;
5395
5542
  }
@@ -5408,7 +5555,7 @@ function printCoverageSummary(forkPath, options = {}) {
5408
5555
  // src/services/stats.ts
5409
5556
  var sourceExtensions = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs", "json", "jsonc", "css", "html"]);
5410
5557
  async function getWorkspacePackages(forkPath) {
5411
- const content = await readFile3(join20(forkPath, "pnpm-workspace.yaml"), "utf-8");
5558
+ const content = await readFile3(join18(forkPath, "pnpm-workspace.yaml"), "utf8");
5412
5559
  return parseYamlBlockList(content, "packages").map((pattern) => {
5413
5560
  if (pattern.endsWith("/*")) {
5414
5561
  const base = pattern.slice(0, -2);
@@ -5472,12 +5619,12 @@ async function collectStats(forkPath) {
5472
5619
  const categories = emptyCat();
5473
5620
  const pkgStats = /* @__PURE__ */ new Map();
5474
5621
  let totalLoc = 0;
5475
- for (const { path: path4, loc } of entries) {
5476
- const category = classifyFile(path4);
5622
+ for (const { path, loc } of entries) {
5623
+ const category = classifyFile(path);
5477
5624
  categories[category].files++;
5478
5625
  categories[category].loc += loc;
5479
5626
  totalLoc += loc;
5480
- const pkg = matchPackage(path4, packages);
5627
+ const pkg = matchPackage(path, packages);
5481
5628
  let entry = pkgStats.get(pkg);
5482
5629
  if (!entry) {
5483
5630
  entry = { total: 0, loc: 0, categories: emptyCat() };
@@ -5554,17 +5701,237 @@ function printStats(stats, verbose) {
5554
5701
  }
5555
5702
  console.info();
5556
5703
  }
5557
- async function runStats(forkPath, options = {}) {
5704
+ async function runStats(config) {
5705
+ const { forkPath } = config;
5558
5706
  createSpinner("counting files...");
5559
5707
  const stats = await collectStats(forkPath);
5560
- spinnerSuccess("Finished counting by raw line of code");
5561
- if (options.json) {
5562
- console.info(JSON.stringify(stats, null, 2));
5563
- } else {
5564
- printStats(stats, options.verbose ?? false);
5708
+ spinnerSuccess("counted raw lines of code");
5709
+ printStats(stats, config.verbose);
5710
+ console.info();
5711
+ printCoverageSummary(forkPath, { refresh: config.coverage });
5712
+ }
5713
+
5714
+ // src/services/stats-since.ts
5715
+ var kindLabels = {
5716
+ source: "source",
5717
+ test: "tests",
5718
+ stories: "stories",
5719
+ generated: "generated",
5720
+ json: "json",
5721
+ docs: "docs",
5722
+ other: "other"
5723
+ };
5724
+ var docExtensions = /* @__PURE__ */ new Set(["md", "mdx"]);
5725
+ var lockFiles = /* @__PURE__ */ new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lock", "bun.lockb"]);
5726
+ var codeExtensions = [...sourceExtensions].filter((ext) => !ext.startsWith("json"));
5727
+ function classifyChange(filePath) {
5728
+ const name = filePath.slice(filePath.lastIndexOf("/") + 1);
5729
+ if (lockFiles.has(name)) return "generated";
5730
+ const category = classifyFile(filePath);
5731
+ if (category !== "other") return category;
5732
+ const ext = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : "";
5733
+ if (docExtensions.has(ext)) return "docs";
5734
+ return sourceExtensions.has(ext) ? "source" : "other";
5735
+ }
5736
+ function classifyLine(text) {
5737
+ const line = text.trim();
5738
+ if (!line) return "blank";
5739
+ if (line.startsWith("//") || line.startsWith("/*") || line.startsWith("*") || line.startsWith("{/*")) {
5740
+ return "comments";
5741
+ }
5742
+ return "code";
5743
+ }
5744
+ function parseNumstat(output) {
5745
+ const files = [];
5746
+ for (const record of output.split("\0")) {
5747
+ if (!record) continue;
5748
+ const [added, removed, ...rest] = record.split(" ");
5749
+ const path = rest.join(" ");
5750
+ if (!path) continue;
5751
+ files.push({
5752
+ path,
5753
+ kind: classifyChange(path),
5754
+ added: Number.parseInt(added, 10) || 0,
5755
+ removed: Number.parseInt(removed, 10) || 0
5756
+ });
5757
+ }
5758
+ return files;
5759
+ }
5760
+ function countLineContent(patch, isCounted) {
5761
+ const content = {
5762
+ code: { added: 0, removed: 0 },
5763
+ comments: { added: 0, removed: 0 },
5764
+ blank: { added: 0, removed: 0 }
5765
+ };
5766
+ let counted = false;
5767
+ let remaining = 0;
5768
+ for (const line of patch.split("\n")) {
5769
+ if (remaining > 0) {
5770
+ if (line.startsWith("\\")) continue;
5771
+ remaining--;
5772
+ if (!counted) continue;
5773
+ const change = line.startsWith("+") ? "added" : "removed";
5774
+ content[classifyLine(line.slice(1))][change]++;
5775
+ continue;
5776
+ }
5777
+ if (line.startsWith("diff --git ")) {
5778
+ counted = false;
5779
+ continue;
5780
+ }
5781
+ if (line.startsWith("--- a/") || line.startsWith("+++ b/")) {
5782
+ counted = isCounted(line.slice(6));
5783
+ continue;
5784
+ }
5785
+ const hunk = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/.exec(line);
5786
+ if (hunk) remaining = Number(hunk[1] ?? 1) + Number(hunk[2] ?? 1);
5787
+ }
5788
+ return content;
5789
+ }
5790
+ async function collectBranchStats(forkPath, since) {
5791
+ const sinceSha = await git(["rev-parse", "--verify", "--quiet", `${since}^{commit}`], forkPath, {
5792
+ ignoreErrors: true
5793
+ });
5794
+ if (!sinceSha) throw new Error(`unknown ref '${since}'. pass a branch, tag or commit, such as origin/main`);
5795
+ const mergeBase = await git(["merge-base", sinceSha, "HEAD"], forkPath, { ignoreErrors: true });
5796
+ if (!mergeBase) throw new Error(`the current branch shares no history with '${since}'`);
5797
+ const range = [mergeBase, "HEAD"];
5798
+ const files = parseNumstat(await git(["diff", "--numstat", "-z", "--no-renames", ...range], forkPath));
5799
+ const kindOf = new Map(files.map((file) => [file.path, file.kind]));
5800
+ const emptyKind = () => ({ files: 0, added: 0, removed: 0 });
5801
+ const kinds = {
5802
+ source: emptyKind(),
5803
+ test: emptyKind(),
5804
+ stories: emptyKind(),
5805
+ generated: emptyKind(),
5806
+ json: emptyKind(),
5807
+ docs: emptyKind(),
5808
+ other: emptyKind()
5809
+ };
5810
+ const total = emptyKind();
5811
+ const packages = await getWorkspacePackages(forkPath).catch(() => []);
5812
+ const byPackage = /* @__PURE__ */ new Map();
5813
+ const add = (target, file) => {
5814
+ target.files++;
5815
+ target.added += file.added;
5816
+ target.removed += file.removed;
5817
+ };
5818
+ for (const file of files) {
5819
+ add(total, file);
5820
+ add(kinds[file.kind], file);
5821
+ if (file.kind !== "source") continue;
5822
+ const pkg = matchPackage(file.path, packages);
5823
+ const entry = byPackage.get(pkg) ?? emptyKind();
5824
+ add(entry, file);
5825
+ byPackage.set(pkg, entry);
5826
+ }
5827
+ const patch = await git(
5828
+ ["diff", "-U0", "--no-renames", "--no-color", ...range, "--", ...codeExtensions.map((ext) => `*.${ext}`)],
5829
+ forkPath,
5830
+ { ignoreErrors: true }
5831
+ );
5832
+ const sourceContent = kinds.source.files > 0 && !patch ? null : countLineContent(patch, (path) => kindOf.get(path) === "source");
5833
+ const sourceByPackage = Object.fromEntries(
5834
+ [...byPackage.entries()].sort((a, b) => net(b[1]) - net(a[1]) || a[0].localeCompare(b[0]))
5835
+ );
5836
+ const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"], forkPath);
5837
+ const status = await git(["status", "--porcelain"], forkPath, { ignoreErrors: true });
5838
+ return {
5839
+ since,
5840
+ mergeBase: mergeBase.slice(0, 7),
5841
+ head: branch === "HEAD" ? await git(["rev-parse", "--short", "HEAD"], forkPath) : branch,
5842
+ total,
5843
+ kinds,
5844
+ sourceContent,
5845
+ sourceByPackage,
5846
+ files: files.sort((a, b) => net(b) - net(a) || a.path.localeCompare(b.path)),
5847
+ uncommitted: status.split("\n").filter(Boolean).length
5848
+ };
5849
+ }
5850
+ function net(change) {
5851
+ return change.added - change.removed;
5852
+ }
5853
+ function signed(n) {
5854
+ return n > 0 ? `+${n}` : String(n);
5855
+ }
5856
+ function changedKinds(stats) {
5857
+ return Object.entries(stats.kinds).filter(([, change]) => change.files > 0).sort((a, b) => net(b[1]) - net(a[1]));
5858
+ }
5859
+ function formatNetList(parts) {
5860
+ return parts.filter(([, change]) => change.added > 0 || change.removed > 0).map(([label, change]) => `${label} ${signed(net(change))}`).join(", ");
5861
+ }
5862
+ function formatMarkdown(stats) {
5863
+ const row = (label, change) => `| ${label} | ${change.files} | ${signed(change.added)} | ${signed(-change.removed)} | ${signed(net(change))} |`;
5864
+ const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
5865
+ const lines = [
5866
+ "| Kind | Files | Added | Removed | Net |",
5867
+ "| --- | ---: | ---: | ---: | ---: |",
5868
+ ...changedKinds(stats).map(
5869
+ ([kind, change]) => row(kind === "json" ? "JSON" : capitalize(kindLabels[kind]), change)
5870
+ ),
5871
+ `| **Total** | **${stats.total.files}** | **${signed(stats.total.added)}** | **${signed(-stats.total.removed)}** | **${signed(net(stats.total))}** |`
5872
+ ];
5873
+ const notes = [];
5874
+ if (stats.sourceContent) {
5875
+ const content = formatNetList(Object.entries(stats.sourceContent));
5876
+ if (content) notes.push(`Source lines by content: ${content}.`);
5877
+ }
5878
+ const byPackage = formatNetList(Object.entries(stats.sourceByPackage));
5879
+ if (byPackage) notes.push(`Source lines by package: ${byPackage}.`);
5880
+ return notes.length > 0 ? [...lines, "", ...notes].join("\n") : lines.join("\n");
5881
+ }
5882
+ function printBranchStats(stats, verbose) {
5883
+ const columns = (files, change, label) => ` ${files.padStart(5)} ${signed(change.added).padStart(8)} ${signed(-change.removed).padStart(8)} ${colors_default.cyan(signed(net(change)).padStart(8))} ${label}`;
5884
+ console.info();
5885
+ console.info(colors_default.dim(` ${"files".padStart(5)} ${"added".padStart(8)} ${"removed".padStart(8)} ${"net".padStart(8)}`));
5886
+ for (const [kind, change] of changedKinds(stats)) {
5887
+ console.info(columns(String(change.files), change, kindLabels[kind]));
5888
+ }
5889
+ console.info(columns(String(stats.total.files), stats.total, colors_default.bold("total")));
5890
+ if (stats.sourceContent && stats.kinds.source.files > 0) {
5891
+ console.info();
5892
+ console.info(colors_default.dim(" source lines by content"));
5893
+ for (const [content, change] of Object.entries(stats.sourceContent)) {
5894
+ console.info(columns("", change, content));
5895
+ }
5896
+ }
5897
+ const packages = Object.entries(stats.sourceByPackage);
5898
+ if (packages.length > 0) {
5565
5899
  console.info();
5566
- printCoverageSummary(forkPath, { refresh: options.refreshCoverage });
5900
+ console.info(colors_default.dim(" source lines by package"));
5901
+ for (const [name, change] of packages) {
5902
+ console.info(columns(String(change.files), change, name));
5903
+ }
5567
5904
  }
5905
+ if (verbose) {
5906
+ console.info();
5907
+ console.info(DIVIDER);
5908
+ console.info();
5909
+ for (const file of stats.files) {
5910
+ console.info(columns("", file, `${file.path} ${colors_default.dim(kindLabels[file.kind])}`));
5911
+ }
5912
+ }
5913
+ if (stats.uncommitted > 0) {
5914
+ console.info();
5915
+ console.info(
5916
+ colors_default.dim(` ${stats.uncommitted} uncommitted file${stats.uncommitted === 1 ? " is" : "s are"} not counted`)
5917
+ );
5918
+ }
5919
+ console.info();
5920
+ }
5921
+ async function runBranchStats(forkPath, options) {
5922
+ createSpinner("counting changed lines...");
5923
+ const stats = await collectBranchStats(forkPath, options.since);
5924
+ spinnerSuccess(`Counted what ${stats.head} changed since ${stats.since} (${stats.mergeBase})`);
5925
+ if (stats.total.files === 0) {
5926
+ console.info(colors_default.dim(` nothing committed since ${stats.since}`));
5927
+ return;
5928
+ }
5929
+ if (options.markdown) {
5930
+ writeStdout(formatMarkdown(stats));
5931
+ if (stats.uncommitted > 0) console.info(colors_default.dim(`${stats.uncommitted} uncommitted file(s) are not counted`));
5932
+ return;
5933
+ }
5934
+ printBranchStats(stats, options.verbose ?? false);
5568
5935
  }
5569
5936
 
5570
5937
  // src/cella-cli.ts
@@ -5583,8 +5950,8 @@ function getForkPath() {
5583
5950
  }
5584
5951
  return cwd;
5585
5952
  }
5586
- async function preflight(forkPath) {
5587
- if (!existsSync13(join21(forkPath, ".git"))) {
5953
+ function preflight(forkPath) {
5954
+ if (!existsSync13(join19(forkPath, ".git"))) {
5588
5955
  throw new Error(`not a git repository: ${forkPath}`);
5589
5956
  }
5590
5957
  }
@@ -5602,7 +5969,7 @@ async function main() {
5602
5969
  const userConfig = await loadConfig(forkPath);
5603
5970
  const config = await parseCli(userConfig, forkPath);
5604
5971
  if (!["audit", "forks", "contributions", "stats"].includes(config.service)) {
5605
- await preflight(forkPath);
5972
+ preflight(forkPath);
5606
5973
  }
5607
5974
  switch (config.service) {
5608
5975
  case "analyze": {
@@ -5613,26 +5980,36 @@ async function main() {
5613
5980
  await runSyncCommand(config);
5614
5981
  break;
5615
5982
  }
5616
- case "migrate":
5983
+ case "migrate": {
5617
5984
  await runMigrate(config);
5618
5985
  break;
5619
- case "audit":
5620
- await runAudit(config, { force: config.force, checkOverrides: config.checkOverrides });
5986
+ }
5987
+ case "audit": {
5988
+ await runAudit(config);
5621
5989
  break;
5622
- case "forks":
5990
+ }
5991
+ case "forks": {
5623
5992
  await runForks(config);
5624
5993
  break;
5625
- case "contributions":
5994
+ }
5995
+ case "contributions": {
5626
5996
  await runContributions(config);
5627
5997
  break;
5628
- case "stats":
5629
- await runStats(config.forkPath, { verbose: config.verbose, refreshCoverage: config.coverage });
5998
+ }
5999
+ case "stats": {
6000
+ if (config.md && !config.since) throw new Error("--md goes with --since <ref>");
6001
+ if (config.since) {
6002
+ await runBranchStats(config.forkPath, { since: config.since, markdown: config.md, verbose: config.verbose });
6003
+ } else {
6004
+ await runStats(config);
6005
+ }
5630
6006
  break;
6007
+ }
5631
6008
  }
5632
6009
  console.info();
5633
6010
  } catch (error) {
5634
6011
  console.error();
5635
- console.error(`${colors_default.red("\u2717")} ${error instanceof Error ? error.message : "unknown error"}`);
6012
+ console.error(`${colors_default.red("\u2717")} ${errorMessage(error)}`);
5636
6013
  process9.exit(1);
5637
6014
  }
5638
6015
  }