@cellajs/cli 0.3.0 → 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 resolve7 } 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.0",
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.",
@@ -287,7 +293,7 @@ var package_default = {
287
293
  rimraf: "^6.1.3",
288
294
  tsup: "^8.5.1",
289
295
  tsx: "^4.23.15",
290
- typescript: "^6.0.3",
296
+ typescript: "6.0.3",
291
297
  vitest: "^5.0.3"
292
298
  },
293
299
  publishConfig: {
@@ -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,33 +804,40 @@ 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."));
808
+ console.info();
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);
799
819
  console.info();
820
+ console.info(colors_default.dim(`full file list written to: ${logPath}`));
800
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";
814
830
  import { mkdir as mkdir2, readdir, rmdir, unlink } from "fs/promises";
815
- import { dirname as dirname2, join as join5 } from "path";
831
+ import { dirname as dirname2, join as join5, resolve as resolve3 } from "path";
816
832
  import { promisify } from "util";
817
833
 
818
834
  // src/utils/manifest.ts
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
  }
@@ -872,8 +896,26 @@ async function getCurrentBranch(cwd) {
872
896
  async function switchBranch(cwd, branch) {
873
897
  await git(["switch", branch], cwd);
874
898
  }
899
+ async function switchDetached(cwd, ref) {
900
+ await git(["switch", "--detach", ref], cwd);
901
+ }
875
902
  async function createBranchFrom(cwd, branch, startPoint) {
876
- await git(["switch", "-c", branch, startPoint], cwd);
903
+ await git(["switch", "-c", branch, "--no-track", startPoint], cwd);
904
+ }
905
+ async function branchExists(cwd, branch) {
906
+ return await git(["rev-parse", "-q", "--verify", `refs/heads/${branch}`], cwd, { ignoreErrors: true }) !== "";
907
+ }
908
+ async function getBranchWorktree(cwd, branch) {
909
+ const output = await git(["worktree", "list", "--porcelain"], cwd, { ignoreErrors: true });
910
+ let worktree = null;
911
+ for (const line of output.split("\n")) {
912
+ if (line.startsWith("worktree ")) worktree = line.slice("worktree ".length);
913
+ else if (line === `branch refs/heads/${branch}`) return worktree;
914
+ }
915
+ return null;
916
+ }
917
+ async function fastForwardBranch(cwd, branch, ref) {
918
+ await git(["fetch", "--quiet", ".", `${ref}:refs/heads/${branch}`], cwd);
877
919
  }
878
920
  async function deleteBranch(cwd, branch) {
879
921
  await git(["branch", "-D", branch], cwd, { ignoreErrors: true });
@@ -881,14 +923,21 @@ async function deleteBranch(cwd, branch) {
881
923
  async function pullFastForward(cwd) {
882
924
  await git(["pull", "--ff-only"], cwd, { ignoreErrors: true, skipEditor: true });
883
925
  }
884
- async function getUpstreamStatus(cwd) {
885
- const upstream = await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], cwd, {
926
+ async function getBranchUpstream(cwd, branch = "") {
927
+ const upstream = await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", `${branch}@{upstream}`], cwd, {
886
928
  ignoreErrors: true
887
929
  });
930
+ return upstream || null;
931
+ }
932
+ async function getUpstreamStatus(cwd, branch) {
933
+ const local = branch ? `refs/heads/${branch}` : "HEAD";
934
+ const upstream = await getBranchUpstream(cwd, branch);
888
935
  if (!upstream) return { upstream: null, ahead: 0, behind: 0 };
889
936
  const remote = upstream.split("/")[0];
890
937
  await git(["fetch", remote], cwd, { ignoreErrors: true, skipEditor: true });
891
- const counts = await git(["rev-list", "--left-right", "--count", `HEAD...${upstream}`], cwd, { ignoreErrors: true });
938
+ const counts = await git(["rev-list", "--left-right", "--count", `${local}...${upstream}`], cwd, {
939
+ ignoreErrors: true
940
+ });
892
941
  const [ahead, behind] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0);
893
942
  return { upstream, ahead: ahead ?? 0, behind: behind ?? 0 };
894
943
  }
@@ -907,16 +956,19 @@ async function flattenBranch(cwd, baseRef, message) {
907
956
  }
908
957
  async function commitSquash(cwd, message) {
909
958
  for (const file of ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE"]) {
910
- const path4 = join5(cwd, ".git", file);
911
- if (existsSync3(path4)) await unlink(path4);
959
+ const path = await gitPath(cwd, file);
960
+ if (existsSync3(path)) await unlink(path);
912
961
  }
913
962
  await git(["commit", "-m", message], cwd, { skipEditor: true });
914
963
  }
964
+ async function gitPath(cwd, file) {
965
+ return resolve3(cwd, await git(["rev-parse", "--git-path", file], cwd));
966
+ }
915
967
  async function stageAll(cwd) {
916
968
  await git(["add", "-A"], cwd);
917
969
  }
918
- function mergeInProgress(cwd) {
919
- return existsSync3(join5(cwd, ".git", "MERGE_HEAD"));
970
+ async function mergeInProgress(cwd) {
971
+ return await git(["rev-parse", "-q", "--verify", "MERGE_HEAD"], cwd, { ignoreErrors: true }) !== "";
920
972
  }
921
973
  async function getShortSha(cwd, ref) {
922
974
  return git(["rev-parse", "--short", ref], cwd);
@@ -937,7 +989,7 @@ async function ensureRemote(cwd, remoteName, url) {
937
989
  await git(["remote", "add", remoteName, url], cwd);
938
990
  }
939
991
  }
940
- async function fetch2(cwd, remoteName) {
992
+ async function fetchRemote(cwd, remoteName) {
941
993
  await git(["fetch", remoteName], cwd);
942
994
  }
943
995
  async function fetchUpstreamTags(cwd, remoteName) {
@@ -957,6 +1009,33 @@ async function resolveLatestReleaseTag(cwd, remoteName) {
957
1009
  }
958
1010
  return null;
959
1011
  }
1012
+ async function resolveUpstreamCommit(cwd, remoteName, ref) {
1013
+ if (ref.startsWith("-")) return null;
1014
+ const tagRef = `refs/${remoteName}/tags/${ref}`;
1015
+ for (const candidate of [`refs/remotes/${remoteName}/${ref}`, tagRef, ref]) {
1016
+ const sha = await git(["rev-parse", "-q", "--verify", `${candidate}^{commit}`], cwd, { ignoreErrors: true });
1017
+ if (!sha) continue;
1018
+ const release = candidate === tagRef && /^v\d[\w.+-]*$/.test(ref) ? { tag: ref, ref: tagRef } : void 0;
1019
+ return { sha, release };
1020
+ }
1021
+ return null;
1022
+ }
1023
+ async function isPublishedUpstream(cwd, remoteName, branchRef, sha) {
1024
+ const containing = await git(
1025
+ [
1026
+ "for-each-ref",
1027
+ "--count=1",
1028
+ "--format=%(refname)",
1029
+ "--contains",
1030
+ sha,
1031
+ `refs/remotes/${branchRef}`,
1032
+ `refs/${remoteName}/tags/v*`
1033
+ ],
1034
+ cwd,
1035
+ { ignoreErrors: true }
1036
+ );
1037
+ return containing !== "";
1038
+ }
960
1039
  async function getCommitInfo(cwd, ref) {
961
1040
  const format = "%H%n%s%n%ar";
962
1041
  const output = await git(["log", "-1", `--format=${format}`, ref], cwd);
@@ -968,10 +1047,8 @@ async function countCommitsBetween(cwd, fromRef, toRef) {
968
1047
  return Number.parseInt(output.trim(), 10) || 0;
969
1048
  }
970
1049
  async function listCommitsBetween(cwd, fromRef, toRef, options = {}) {
971
- const { oldestFirst = true, skip = 0, limit } = options;
972
- const args = ["log", "--format=%H%x1f%s%x1f%ar"];
973
- if (oldestFirst) args.push("--reverse");
974
- args.push(`${fromRef}..${toRef}`);
1050
+ const { skip, limit } = options;
1051
+ const args = ["log", "--format=%H%x1f%s%x1f%ar", "--reverse", `${fromRef}..${toRef}`];
975
1052
  const output = await git(args, cwd, { ignoreErrors: true });
976
1053
  if (!output) return [];
977
1054
  const commits = [];
@@ -981,14 +1058,8 @@ async function listCommitsBetween(cwd, fromRef, toRef, options = {}) {
981
1058
  if (!hash || !message || !date) continue;
982
1059
  commits.push({ hash, message, date });
983
1060
  }
984
- const normalizedSkip = Math.max(0, skip);
985
- const normalizedLimit = typeof limit === "number" && limit > 0 ? limit : void 0;
986
- if (normalizedSkip === 0 && normalizedLimit === void 0) {
987
- return commits;
988
- }
989
- const start = Math.min(normalizedSkip, commits.length);
990
- const end = normalizedLimit === void 0 ? commits.length : Math.min(start + normalizedLimit, commits.length);
991
- 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);
992
1063
  }
993
1064
  async function getFileChangeInfo(cwd, fromRef, toRef) {
994
1065
  const info = /* @__PURE__ */ new Map();
@@ -1043,12 +1114,9 @@ async function listWorktrees(cwd) {
1043
1114
  }
1044
1115
  return paths;
1045
1116
  }
1046
- async function merge(cwd, ref, options = {}) {
1047
- const args = ["merge", "--no-ff", ref];
1048
- if (options.noCommit) args.push("--no-commit");
1049
- if (options.noEdit) args.push("--no-edit");
1117
+ async function merge(cwd, ref) {
1050
1118
  try {
1051
- await git(args, cwd, { skipEditor: true });
1119
+ await git(["merge", "--no-ff", "--no-commit", "--no-edit", ref], cwd, { skipEditor: true });
1052
1120
  return { success: true, conflicts: [] };
1053
1121
  } catch (error) {
1054
1122
  const conflicts = await getConflictedFiles(cwd);
@@ -1076,6 +1144,22 @@ async function checkoutFromRef(cwd, ref, filePath) {
1076
1144
  async function restoreWorktreeFromRef(cwd, ref, filePath) {
1077
1145
  await git(["restore", `--source=${ref}`, "--worktree", "--", filePath], cwd);
1078
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
+ }
1079
1163
  async function getMergeBase(cwd, ref1, ref2) {
1080
1164
  return git(["merge-base", ref1, ref2], cwd);
1081
1165
  }
@@ -1085,6 +1169,7 @@ async function getFileChanges(cwd, baseRef, targetRef) {
1085
1169
  });
1086
1170
  const changes = /* @__PURE__ */ new Map();
1087
1171
  if (!output) return changes;
1172
+ const hashOrNull = (hash) => hash === "0".repeat(40) ? null : hash;
1088
1173
  for (const line of output.split("\n")) {
1089
1174
  if (!line) continue;
1090
1175
  const renameMatch = line.match(/^:\d+ \d+ ([a-f0-9]+) ([a-f0-9]+) R\d*\s+(.+)\t(.+)$/);
@@ -1092,10 +1177,9 @@ async function getFileChanges(cwd, baseRef, targetRef) {
1092
1177
  const [, baseHash, targetHash, oldPath, newPath] = renameMatch;
1093
1178
  changes.set(newPath, {
1094
1179
  status: "R",
1095
- baseHash: baseHash === "0".repeat(40) ? null : baseHash,
1096
- targetHash: targetHash === "0".repeat(40) ? null : targetHash,
1097
- oldPath,
1098
- newPath
1180
+ baseHash: hashOrNull(baseHash),
1181
+ targetHash: hashOrNull(targetHash),
1182
+ oldPath
1099
1183
  });
1100
1184
  continue;
1101
1185
  }
@@ -1104,8 +1188,8 @@ async function getFileChanges(cwd, baseRef, targetRef) {
1104
1188
  const [, baseHash, targetHash, status, filePath] = match;
1105
1189
  changes.set(filePath, {
1106
1190
  status,
1107
- baseHash: baseHash === "0".repeat(40) ? null : baseHash,
1108
- targetHash: targetHash === "0".repeat(40) ? null : targetHash
1191
+ baseHash: hashOrNull(baseHash),
1192
+ targetHash: hashOrNull(targetHash)
1109
1193
  });
1110
1194
  }
1111
1195
  }
@@ -1119,9 +1203,9 @@ async function getDiffStat(cwd, fromRef, toRef, paths = []) {
1119
1203
  for (const line of out.split("\n")) {
1120
1204
  if (!line.trim()) continue;
1121
1205
  const [a, d, ...rest] = line.split(" ");
1122
- const path4 = rest.join(" ");
1123
- if (!path4) continue;
1124
- stat.set(path4, {
1206
+ const path = rest.join(" ");
1207
+ if (!path) continue;
1208
+ stat.set(path, {
1125
1209
  additions: a === "-" ? null : Number(a),
1126
1210
  deletions: d === "-" ? null : Number(d)
1127
1211
  });
@@ -1203,8 +1287,8 @@ async function storeLastSyncRef(cwd, upstreamHash) {
1203
1287
  await git(["update-ref", "refs/cella/last-sync-head", head], cwd);
1204
1288
  }
1205
1289
  }
1206
- async function stagePath(cwd, path4) {
1207
- await git(["add", "--", path4], cwd, { ignoreErrors: true });
1290
+ async function stagePath(cwd, path) {
1291
+ await git(["add", "--", path], cwd, { ignoreErrors: true });
1208
1292
  }
1209
1293
  async function getStoredSyncRef(cwd) {
1210
1294
  const ref = await git(["rev-parse", "refs/cella/last-sync"], cwd, { ignoreErrors: true });
@@ -1217,14 +1301,7 @@ async function getStoredSyncHead(cwd) {
1217
1301
  async function readManifestAtRef(cwd, ref) {
1218
1302
  const raw = await git(["show", `${ref}:${MANIFEST_FILE}`], cwd, { ignoreErrors: true });
1219
1303
  if (!raw) return null;
1220
- try {
1221
- const parsed = JSON.parse(raw);
1222
- const commit = parsed?.upstream?.commit;
1223
- if (typeof commit !== "string" || !/^[0-9a-f]{40}$/i.test(commit)) return null;
1224
- return parsed;
1225
- } catch {
1226
- return null;
1227
- }
1304
+ return parseSyncManifest(raw);
1228
1305
  }
1229
1306
  async function readManifestBaseAtRef(cwd, ref) {
1230
1307
  const manifest = await readManifestAtRef(cwd, ref);
@@ -1345,8 +1422,8 @@ async function ensureSyncBase(cwd, headRef, upstreamRef) {
1345
1422
  throw new Error(
1346
1423
  `no common ancestor between the fork and '${upstreamRef}', and no sync base could be determined.
1347
1424
 
1348
- This fork has unrelated history with upstream \u2014 typical for a create-cella scaffold, or after
1349
- 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
1350
1427
  upstream. Seed it manually with the upstream commit the fork was created from, then re-run sync:
1351
1428
 
1352
1429
  git update-ref refs/cella/last-sync <upstream-sha>
@@ -1405,9 +1482,15 @@ async function withTemporarySyncBaseGraft(cwd, headRef, baseSha, fn) {
1405
1482
  }
1406
1483
 
1407
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";
1408
1488
  var NOTES_DIR = "cella/migrations";
1409
1489
  var PENDING_FILE = "cella/cella.migrations.json";
1410
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
+ }
1411
1494
  function isUpstreamOnly(filePath) {
1412
1495
  return filePath === NOTES_DIR || filePath.startsWith(`${NOTES_DIR}/`);
1413
1496
  }
@@ -1438,7 +1521,7 @@ function parseNote(id, source, fileNames) {
1438
1521
  syncBreaking: meta.get("syncBreaking") === "true",
1439
1522
  clientCacheBump: meta.get("clientCacheBump") === "true",
1440
1523
  roots: (meta.get("roots") ?? "").split(",").map((root) => root.trim()).filter(Boolean),
1441
- codemod: fileNames.find((name) => name.endsWith(".ts") && !name.endsWith(".test.ts")) ?? null
1524
+ codemod: fileNames.find(isCodemodFile) ?? null
1442
1525
  };
1443
1526
  }
1444
1527
  async function listNoteIds(cwd, ref) {
@@ -1457,23 +1540,23 @@ async function arrivedNoteIds(cwd, from, to) {
1457
1540
  return after.filter((id) => !known.has(id));
1458
1541
  }
1459
1542
  async function readPending(cwd, syncRef) {
1460
- const path4 = join6(cwd, PENDING_FILE);
1461
- if (!existsSync4(path4)) return [];
1462
- 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"));
1463
1546
  if (Array.isArray(parsed.pending)) return parsed.pending.filter((id) => typeof id === "string");
1464
1547
  if (!Array.isArray(parsed.applied) || !syncRef) return [];
1465
1548
  const applied = new Set(parsed.applied);
1466
1549
  return (await listNoteIds(cwd, syncRef)).filter((id) => !applied.has(id));
1467
1550
  }
1468
1551
  async function writePending(cwd, ids) {
1469
- const path4 = join6(cwd, PENDING_FILE);
1552
+ const path = join6(cwd, PENDING_FILE);
1470
1553
  const pending = [...new Set(ids)].sort();
1471
1554
  if (pending.length === 0) {
1472
- await rm(path4, { force: true });
1555
+ await rm(path, { force: true });
1473
1556
  return;
1474
1557
  }
1475
- await mkdir3(dirname3(path4), { recursive: true });
1476
- await writeFile2(path4, `${JSON.stringify({ pending }, null, 2)}
1558
+ await mkdir3(dirname3(path), { recursive: true });
1559
+ await writeFile2(path, `${JSON.stringify({ pending }, null, 2)}
1477
1560
  `, "utf8");
1478
1561
  }
1479
1562
  function noteUrl(upstreamGitHubUrl, commit, id) {
@@ -1517,6 +1600,19 @@ function groupIgnoredUpstreamChanges(files, ignored) {
1517
1600
  for (const group of groups.values()) group.paths.sort();
1518
1601
  return entries.flatMap((entry) => groups.get(entry) ?? []);
1519
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
+ }
1520
1616
  function isPinnedForSync(filePath, config, unpinned) {
1521
1617
  if (isManagedFile(filePath)) return true;
1522
1618
  if (unpinned) return false;
@@ -1525,7 +1621,7 @@ function isPinnedForSync(filePath, config, unpinned) {
1525
1621
  function hasGlobChars(entry) {
1526
1622
  return entry.includes("*") || entry.includes("?");
1527
1623
  }
1528
- function validateOverrides(config, forkPath) {
1624
+ async function validateOverrides(config, forkPath, upstreamRef) {
1529
1625
  const warnings = [];
1530
1626
  const pinned = config.overrides?.pinned || [];
1531
1627
  for (const entry of pinned) {
@@ -1552,6 +1648,7 @@ function validateOverrides(config, forkPath) {
1552
1648
  message: `ignored entry uses glob (not supported, use a path or folder): ${entry}`
1553
1649
  });
1554
1650
  } else if (!existsSync5(join7(forkPath, entry.replace(/\/+$/, "")))) {
1651
+ if (upstreamRef && await fileExistsAtRef(forkPath, upstreamRef, entry.replace(/\/+$/, ""))) continue;
1555
1652
  warnings.push({
1556
1653
  type: "ignored-not-found",
1557
1654
  pattern: entry,
@@ -1568,75 +1665,124 @@ function printWarnings(warnings) {
1568
1665
  }
1569
1666
 
1570
1667
  // src/cli.ts
1571
- function readOptions(opts) {
1572
- 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);
1573
1672
  if (scope && scope !== "all" && scope !== "risk" && scope !== "protected") {
1574
1673
  throw new Error(`invalid --scope '${scope}'. expected one of: all, risk, protected`);
1575
1674
  }
1576
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
+ }
1577
1680
  return {
1578
- logFile: opts.log === true,
1579
- verbose: opts.verbose === true,
1580
- list: opts.list === true,
1581
- json: opts.json === true,
1582
- diff: typeof opts.diff === "string" ? opts.diff : void 0,
1583
- 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),
1584
1687
  scope: normalizedScope,
1585
- fork: typeof opts.fork === "string" ? opts.fork : void 0,
1586
- hard: opts.hard === true,
1587
- unpinned: opts.unpinned === true,
1688
+ fork: str(opts.fork),
1689
+ hard: flag(opts.hard),
1690
+ unpinned: flag(opts.unpinned),
1588
1691
  track: opts.track === "release" || opts.track === "branch" ? opts.track : void 0,
1589
- force: opts.force === true,
1590
- checkOverrides: opts.checkOverrides === true,
1591
- coverage: opts.coverage === true,
1592
- all: opts.all === true,
1593
- show: typeof opts.show === "string" ? opts.show : void 0,
1594
- 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,
1595
1705
  mark: Array.isArray(opts.mark) ? opts.mark.filter((id) => typeof id === "string") : void 0
1596
1706
  };
1597
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
+ };
1598
1736
  var serviceDefinitions = [
1599
1737
  {
1600
1738
  name: "analyze",
1601
1739
  description: "dry run to see what would change on sync",
1602
1740
  options: [
1603
- { flags: "--log", description: "write complete file list to cella-sync.log" },
1741
+ logOption,
1604
1742
  { flags: "--list", description: "non-interactive output for tooling (one file per line)" },
1605
- { flags: "--json", description: "machine-readable output for tooling/agents" },
1743
+ jsonOption,
1606
1744
  { flags: "--scope <scope>", description: "analyze scope for --list/--json: all|risk|protected" },
1607
- { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" },
1608
- { 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" },
1609
1748
  { flags: "--open-diff <path>", description: "open a browser diff for one file, then exit" }
1610
1749
  ],
1611
1750
  includeInMenu: (context) => !context.isUpstreamRepo
1612
1751
  },
1613
1752
  {
1614
1753
  name: "sync",
1615
- 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",
1616
1755
  options: [
1617
- { flags: "--log", description: "write complete file list to cella-sync.log" },
1618
- { flags: "--hard", description: "overwrite drifted files with upstream version (aggressive realignment)" },
1756
+ logOption,
1757
+ hardOption,
1619
1758
  { flags: "--unpinned", description: "ignore pinned files (except package.json) to resurface upstream changes" },
1620
- { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" }
1759
+ trackOption,
1760
+ refOption,
1761
+ { flags: "--keep-config", description: "merge with your sync config as it stands when upstream changed its own" }
1621
1762
  ],
1622
- includeInMenu: (context) => !context.isUpstreamRepo,
1623
- menuDescription: () => "merge upstream changes + sync package.json"
1763
+ includeInMenu: (context) => !context.isUpstreamRepo
1624
1764
  },
1625
1765
  {
1626
1766
  name: "migrate",
1627
1767
  description: "list the upstream migration notes this app has not handled yet",
1628
1768
  options: [
1629
1769
  { flags: "--all", description: "list every upstream note, handled or not" },
1630
- { flags: "--json", description: "machine-readable output for tooling/agents" },
1770
+ jsonOption,
1631
1771
  { flags: "--show <id>", description: "print one note's README" },
1632
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" },
1633
1775
  { flags: "--mark <ids...>", description: "record notes as handled" }
1634
1776
  ],
1777
+ operands: {
1778
+ flags: "[codemodArgs...]",
1779
+ description: "with --run, after `--`: arguments for the codemod (default: inventory and the note's roots)"
1780
+ },
1635
1781
  includeInMenu: (context) => !context.isUpstreamRepo
1636
1782
  },
1637
1783
  {
1638
1784
  name: "audit",
1639
- description: "check for outdated packages & vulnerabilities",
1785
+ description: "check for outdated packages and vulnerabilities",
1640
1786
  options: [
1641
1787
  { flags: "--list", description: "skip interactive update prompts after printing audit results" },
1642
1788
  { flags: "--force", description: "bypass pnpm metadata cache for fresh registry data" },
@@ -1645,23 +1791,27 @@ var serviceDefinitions = [
1645
1791
  },
1646
1792
  {
1647
1793
  name: "forks",
1648
- description: "sync downstream to local fork repositories",
1794
+ description: "run normal sync inside local fork repositories",
1649
1795
  options: [
1650
- { flags: "--fork <name>", description: "pre-select fork by name (skips fork selection prompt)" },
1796
+ forkOption,
1651
1797
  { flags: "--log", description: "write complete file list to cella-sync.log for each synced fork" },
1652
- { flags: "-V, --verbose", description: "show detailed output during operations" },
1653
- { 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
+ }
1654
1804
  ],
1655
1805
  includeInMenu: (context) => context.hasForks
1656
1806
  },
1657
1807
  {
1658
1808
  name: "contributions",
1659
- description: "pull and adopt changes from forks",
1809
+ description: "pull and adopt changes from local forks",
1660
1810
  options: [
1661
- { flags: "--fork <name>", description: "select a specific fork directly (skips fork selection)" },
1662
- { flags: "--list", description: "non-interactive output (one file per line)" },
1663
- { flags: "--json", description: "machine-readable JSON output for tooling/agents" },
1664
- { 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" }
1665
1815
  ],
1666
1816
  includeInMenu: (context) => context.hasForks
1667
1817
  },
@@ -1669,26 +1819,31 @@ var serviceDefinitions = [
1669
1819
  name: "stats",
1670
1820
  description: "count files by category and workspace package",
1671
1821
  options: [
1672
- { flags: "-V, --verbose", description: "show detailed output during operations" },
1673
- { 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" }
1674
1829
  ]
1675
1830
  }
1676
1831
  ];
1677
- async function getMenuContext(userConfig, forkPath) {
1832
+ function getMenuContext(userConfig, forkPath) {
1678
1833
  return {
1679
1834
  hasForks: (userConfig.forks?.length ?? 0) > 0,
1680
- isUpstreamRepo: JSON.parse(readFileSync(join8(forkPath, "package.json"), "utf8")).name === "cella"
1835
+ isUpstreamRepo: isUpstreamRepo(forkPath)
1681
1836
  };
1682
1837
  }
1683
1838
  function buildServiceChoices(context) {
1684
1839
  const label = (name) => name.padEnd(14);
1685
1840
  const baseChoices = serviceDefinitions.filter((service) => service.includeInMenu?.(context) ?? true).map((service) => ({
1686
1841
  value: service.name,
1687
- name: `${label(service.name)}${colors_default.dim(service.menuDescription?.(context) ?? service.description)}`
1842
+ name: `${label(service.name)}${colors_default.dim(service.description)}`
1688
1843
  }));
1689
1844
  return [
1690
1845
  ...baseChoices,
1691
- { type: "separator", separator: "\u2500".repeat(40) },
1846
+ { type: "separator", separator: MENU_DIVIDER },
1692
1847
  { value: "exit", name: colors_default.red(`${label("exit")}${colors_default.dim("quit without doing anything")}`) }
1693
1848
  ];
1694
1849
  }
@@ -1697,10 +1852,13 @@ function addServiceCommand(program, definition, setSelection) {
1697
1852
  for (const option of definition.options ?? []) {
1698
1853
  command.option(option.flags, option.description);
1699
1854
  }
1700
- 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];
1701
1859
  setSelection({
1702
1860
  service: definition.name,
1703
- options: readOptions(opts)
1861
+ options: readOptions(opts, operands)
1704
1862
  });
1705
1863
  });
1706
1864
  }
@@ -1716,8 +1874,11 @@ function buildProgram(setSelection) {
1716
1874
  " $ cella sync --hard",
1717
1875
  " $ cella sync --unpinned",
1718
1876
  " $ cella sync --track branch",
1877
+ " $ cella sync --ref 4f7d87c",
1878
+ " $ cella migrate --run 20261001T2116-tailwind-class-conventions -- rewrite frontend/src",
1719
1879
  " $ cella migrate --mark 20261002T0614-config-switch",
1720
1880
  " $ cella audit --check-overrides",
1881
+ " $ cella stats --since origin/main --md",
1721
1882
  " $ cella contributions --fork raak --json"
1722
1883
  ].join("\n")
1723
1884
  );
@@ -1740,7 +1901,7 @@ function parseCommandLine(argv) {
1740
1901
  async function promptForService(userConfig, forkPath) {
1741
1902
  const selected = await select({
1742
1903
  message: "choose a service:",
1743
- choices: buildServiceChoices(await getMenuContext(userConfig, forkPath)),
1904
+ choices: buildServiceChoices(getMenuContext(userConfig, forkPath)),
1744
1905
  loop: false
1745
1906
  });
1746
1907
  if (selected === "exit") {
@@ -1763,9 +1924,10 @@ function buildRuntimeConfig(userConfig, forkPath, selection) {
1763
1924
  }
1764
1925
  async function parseCli(userConfig, forkPath) {
1765
1926
  const selection = parseCommandLine(process5.argv);
1766
- 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);
1767
1929
  printHeader();
1768
- const warnings = validateOverrides(userConfig, forkPath);
1930
+ const warnings = await validateOverrides(userConfig, forkPath, resolveUpstream(userConfig.settings).branchRef);
1769
1931
  if (warnings.length > 0) {
1770
1932
  printWarnings(warnings);
1771
1933
  console.info();
@@ -1783,14 +1945,14 @@ import { basename as basename4 } from "path";
1783
1945
  import { execFileSync } from "child_process";
1784
1946
  import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
1785
1947
  import { tmpdir } from "os";
1786
- import { basename as basename2, join as join9 } from "path";
1948
+ import { basename as basename2, join as join8 } from "path";
1787
1949
  import process6 from "process";
1788
1950
  function gitDiffFile(cwd, range, filePath, options = {}) {
1789
1951
  const args = ["diff", "--no-color"];
1790
1952
  if (options.dstPrefix) args.push("--src-prefix=cella/", `--dst-prefix=${options.dstPrefix}/`);
1791
1953
  args.push(range, "--", filePath);
1792
1954
  try {
1793
- return execFileSync("git", args, { cwd, maxBuffer: 50 * 1024 * 1024 });
1955
+ return execFileSync("git", args, { cwd, maxBuffer: MAX_BUFFER });
1794
1956
  } catch (error) {
1795
1957
  const stderr = error.stderr?.toString().trim();
1796
1958
  throw new Error(stderr || `failed to diff ${filePath}`);
@@ -1877,9 +2039,9 @@ function openWithDefaultApp(target) {
1877
2039
  }
1878
2040
  async function openDiffInBrowser(patch, meta, repoPath) {
1879
2041
  const html = await renderDiffPage(patch, meta);
1880
- const dir = join9(tmpdir(), `cella-diff-${basename2(repoPath)}`);
2042
+ const dir = join8(tmpdir(), `cella-diff-${basename2(repoPath)}`);
1881
2043
  mkdirSync(dir, { recursive: true });
1882
- const outPath = join9(dir, `${meta.filePath.replace(/[^\w.-]+/g, "-")}.html`);
2044
+ const outPath = join8(dir, `${meta.filePath.replace(/[^\w.-]+/g, "-")}.html`);
1883
2045
  writeFileSync2(outPath, html);
1884
2046
  openWithDefaultApp(outPath);
1885
2047
  return outPath;
@@ -1888,24 +2050,24 @@ async function openDiffInBrowser(patch, meta, repoPath) {
1888
2050
  // src/services/merge-engine.ts
1889
2051
  import { existsSync as existsSync7 } from "fs";
1890
2052
  import { rm as rm2 } from "fs/promises";
1891
- import { join as join11 } from "path";
2053
+ import { join as join10 } from "path";
1892
2054
 
1893
2055
  // src/utils/cleanup.ts
1894
2056
  import { createHash } from "crypto";
1895
2057
  import { existsSync as existsSync6, rmSync } from "fs";
1896
2058
  import { tmpdir as tmpdir2 } from "os";
1897
- import { basename as basename3, join as join10, resolve as resolve3 } from "path";
2059
+ import { basename as basename3, join as join9, resolve as resolve4 } from "path";
1898
2060
  import process7 from "process";
1899
2061
  var WORKTREE_PREFIXES = {
1900
2062
  sync: "cella-sync-",
1901
2063
  view: "cella-view-"
1902
2064
  };
1903
2065
  function buildWorktreePath(kind, repoPath) {
1904
- const key = createHash("sha256").update(resolve3(repoPath)).digest("hex").slice(0, 8);
1905
- return join10(tmpdir2(), `${WORKTREE_PREFIXES[kind]}${basename3(repoPath)}-${key}`);
2066
+ const key = createHash("sha256").update(resolve4(repoPath)).digest("hex").slice(0, 8);
2067
+ return join9(tmpdir2(), `${WORKTREE_PREFIXES[kind]}${basename3(repoPath)}-${key}`);
1906
2068
  }
1907
2069
  function legacyWorktreePath(kind, repoPath) {
1908
- return join10(tmpdir2(), `${WORKTREE_PREFIXES[kind]}${basename3(repoPath)}`);
2070
+ return join9(tmpdir2(), `${WORKTREE_PREFIXES[kind]}${basename3(repoPath)}`);
1909
2071
  }
1910
2072
  function getWorktreePath(repoPath) {
1911
2073
  return buildWorktreePath("sync", repoPath);
@@ -1944,7 +2106,7 @@ async function cleanupLeftoverWorktrees(repoPath) {
1944
2106
  }
1945
2107
  async function handleAbort(signal) {
1946
2108
  console.info();
1947
- console.info(`${warningMark} Interrupted (${signal}) - cleaning up...`);
2109
+ console.info(`${warningMark} interrupted (${signal}): cleaning up...`);
1948
2110
  if (currentRepoPath && currentWorktreePath) {
1949
2111
  try {
1950
2112
  await mergeAbort(currentWorktreePath);
@@ -1952,11 +2114,9 @@ async function handleAbort(signal) {
1952
2114
  }
1953
2115
  try {
1954
2116
  await cleanupWorktree(currentRepoPath, currentWorktreePath);
1955
- console.info(`${colors_default.green("\u2713")} no changes were made to your repository.`);
2117
+ console.info(`${checkMark} no changes were made to your repository.`);
1956
2118
  } catch (error) {
1957
- console.error(
1958
- `${colors_default.red("\u2717")} failed to clean up worktree: ${error instanceof Error ? error.message : "unknown error"}`
1959
- );
2119
+ console.error(`${colors_default.red("\u2717")} failed to clean up worktree: ${errorMessage(error)}`);
1960
2120
  }
1961
2121
  }
1962
2122
  process7.exit(1);
@@ -2052,25 +2212,44 @@ function readProperty(object, name, sourceFile) {
2052
2212
  }
2053
2213
  return void 0;
2054
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
+ }
2055
2233
  function parseOverrideLists(source) {
2056
2234
  try {
2057
2235
  const project = new Project2({ useInMemoryFileSystem: true, skipLoadingLibFiles: true });
2058
2236
  const sourceFile = project.createSourceFile("cella.config.ts", source);
2059
2237
  const config = findConfigObject(sourceFile);
2060
2238
  if (!config) return null;
2239
+ const lists = {
2240
+ pinned: [],
2241
+ ignored: [],
2242
+ packageJsonSync: readPackageJsonSync(config, sourceFile)
2243
+ };
2061
2244
  const overrides = readProperty(config, "overrides", sourceFile);
2062
- if (overrides === void 0) return { pinned: [], ignored: [] };
2245
+ if (overrides === void 0) return lists;
2063
2246
  if (!overrides || !Node.isObjectLiteralExpression(overrides)) return null;
2064
- const lists = { pinned: [], ignored: [] };
2065
2247
  for (const key of LIST_KEYS) {
2066
2248
  const list = readProperty(overrides, key, sourceFile);
2067
2249
  if (list === void 0) continue;
2068
- if (!list || !Node.isArrayLiteralExpression(list)) return null;
2069
- for (const element of list.getElements()) {
2070
- if (Node.isStringLiteral(element) || Node.isNoSubstitutionTemplateLiteral(element)) {
2071
- lists[key].push(element.getLiteralValue());
2072
- }
2073
- }
2250
+ const entries = list ? readStringEntries(list) : null;
2251
+ if (!entries) return null;
2252
+ lists[key] = entries;
2074
2253
  }
2075
2254
  return lists;
2076
2255
  } catch {
@@ -2090,11 +2269,19 @@ function diffOverrideLists(base, incoming, fork) {
2090
2269
  removed: [...baseSet].filter((entry) => !incomingSet.has(entry) && forkEntries.includes(entry))
2091
2270
  };
2092
2271
  };
2093
- 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() };
2094
2281
  }
2095
2282
  async function readConfigSourceAtRef(repoPath, ref) {
2096
- for (const path4 of CONFIG_FILE_PATHS) {
2097
- 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 });
2098
2285
  if (source) return source;
2099
2286
  }
2100
2287
  return null;
@@ -2111,10 +2298,11 @@ async function compareUpstreamOverrides(repoPath, mergeBaseRef, incomingRef, for
2111
2298
  if (!incoming) return { kind: "unreadable", side: "incoming" };
2112
2299
  const fork = {
2113
2300
  pinned: forkConfig.overrides?.pinned ?? [],
2114
- ignored: forkConfig.overrides?.ignored ?? []
2301
+ ignored: forkConfig.overrides?.ignored ?? [],
2302
+ packageJsonSync: forkConfig.settings.packageJsonSync ?? DEFAULT_PACKAGE_JSON_SYNC
2115
2303
  };
2116
2304
  const changes = diffOverrideLists(base, incoming, fork);
2117
- 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);
2118
2306
  return count > 0 ? { kind: "changes", ...changes } : void 0;
2119
2307
  }
2120
2308
 
@@ -2238,10 +2426,8 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
2238
2426
  status = fileIsPinned ? "pinned" : "diverged";
2239
2427
  } else if (forkChanged && !upstreamChanged2) {
2240
2428
  status = fileIsPinned ? "ahead" : predicates.hard ? "behind" : "drifted";
2241
- } else if (!forkChanged && upstreamChanged2) {
2242
- status = "behind";
2243
2429
  } else {
2244
- status = "identical";
2430
+ status = "behind";
2245
2431
  }
2246
2432
  }
2247
2433
  const upstreamChanged = (fileIsPinned || fileIsIgnored) && inUpstream && upstreamHash !== baseHash && forkHash !== baseHash;
@@ -2326,6 +2512,21 @@ async function enrichChangeInfo(repoPath, files, mergeBaseRef, localRef, incomin
2326
2512
  }
2327
2513
 
2328
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
+ };
2329
2530
  function getGitHubBaseUrl(remoteUrl) {
2330
2531
  const sshMatch = remoteUrl.match(/git@github\.com:([^/]+)\/([^.]+)(?:\.git)?$/);
2331
2532
  if (sshMatch) {
@@ -2337,10 +2538,13 @@ function getGitHubBaseUrl(remoteUrl) {
2337
2538
  }
2338
2539
  return null;
2339
2540
  }
2541
+ function isProtectedFile(file) {
2542
+ return file.isIgnored || file.isPinned;
2543
+ }
2340
2544
  function syncPredicates(config) {
2341
2545
  return {
2342
- isIgnored: (path4) => isIgnored(path4, config),
2343
- isPinned: (path4) => isPinnedForSync(path4, config, config.unpinned),
2546
+ isIgnored: (path) => isIgnored(path, config),
2547
+ isPinned: (path) => isPinnedForSync(path, config, config.unpinned),
2344
2548
  hard: config.hard
2345
2549
  };
2346
2550
  }
@@ -2359,6 +2563,7 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
2359
2563
  for (const file of analyzedFiles) {
2360
2564
  const { path: filePath, status, isPinned: pinned, isIgnored: ignored, existsInFork } = file;
2361
2565
  if (status === "identical" || status === "ahead") continue;
2566
+ if (!isProtectedFile(file)) continue;
2362
2567
  if (ignored && existsInFork) {
2363
2568
  batchRestorePaths.push(filePath);
2364
2569
  } else if (ignored && !existsInFork) {
@@ -2370,12 +2575,7 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
2370
2575
  }
2371
2576
  }
2372
2577
  onProgress?.("starting merge in fork...");
2373
- await withTemporarySyncBaseGraft(
2374
- forkPath,
2375
- "HEAD",
2376
- mergeBaseRef,
2377
- () => merge(forkPath, upstreamRef, { noCommit: true, noEdit: true })
2378
- );
2578
+ await withTemporarySyncBaseGraft(forkPath, "HEAD", mergeBaseRef, () => merge(forkPath, upstreamRef));
2379
2579
  if (batchRestorePaths.length > 0) {
2380
2580
  onProgress?.(`batch restoring ${batchRestorePaths.length} pinned/ignored files...`);
2381
2581
  await batchRestoreToHead(forkPath, batchRestorePaths);
@@ -2388,29 +2588,11 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
2388
2588
  }
2389
2589
  }
2390
2590
  for (const file of analyzedFiles) {
2391
- const { path: filePath, status, isPinned: pinned, isIgnored: ignored, existsInFork } = file;
2591
+ const { path: filePath, status, existsInFork } = file;
2392
2592
  if (status === "identical" || status === "ahead") {
2393
2593
  continue;
2394
2594
  }
2395
- if (ignored) continue;
2396
- if (pinned && !file.renamedFrom) continue;
2397
- if (pinned && file.renamedFrom) {
2398
- const oldPathExists = await fileExistsInWorktree(forkPath, file.renamedFrom);
2399
- if (oldPathExists) {
2400
- onProgress?.(`\u2192 ${file.renamedFrom} \u2192 ${filePath}: moving fork content (pinned rename)`);
2401
- try {
2402
- await gitMv(forkPath, file.renamedFrom, filePath);
2403
- } catch {
2404
- await removeFileFully(forkPath, file.renamedFrom);
2405
- await checkoutFromRef(forkPath, "HEAD", file.renamedFrom).catch(() => {
2406
- });
2407
- }
2408
- } else {
2409
- onProgress?.(`\u2192 ${filePath}: keeping fork content (pinned rename, old path removed)`);
2410
- await restoreToHead(forkPath, filePath);
2411
- }
2412
- continue;
2413
- }
2595
+ if (isProtectedFile(file)) continue;
2414
2596
  if (status === "diverged") {
2415
2597
  onProgress?.(`\u2192 ${filePath}: using git merge result (diverged)`);
2416
2598
  continue;
@@ -2455,6 +2637,7 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
2455
2637
  }
2456
2638
  }
2457
2639
  const protectedConflicts = analyzedFiles.filter((file) => file.upstreamChanged).map((file) => file.path);
2640
+ const protectedConflictSet = new Set(protectedConflicts);
2458
2641
  const gitConflicts = await getConflictedFiles(forkPath);
2459
2642
  for (const filePath of gitConflicts) {
2460
2643
  const isProtected = isIgnored(filePath, config) || isPinnedForSync(filePath, config, config.unpinned);
@@ -2462,7 +2645,10 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
2462
2645
  if (await fileExistsAtRef(forkPath, "HEAD", filePath)) {
2463
2646
  onProgress?.(`\u2192 ${filePath}: keeping fork (protected conflict)`);
2464
2647
  await restoreToHead(forkPath, filePath);
2465
- if (!protectedConflicts.includes(filePath)) protectedConflicts.push(filePath);
2648
+ if (!protectedConflictSet.has(filePath)) {
2649
+ protectedConflictSet.add(filePath);
2650
+ protectedConflicts.push(filePath);
2651
+ }
2466
2652
  } else {
2467
2653
  onProgress?.(`\u2192 ${filePath}: removing (protected conflict, not in fork)`);
2468
2654
  await removeFileFully(forkPath, filePath);
@@ -2494,8 +2680,7 @@ function calculateSummary(files) {
2494
2680
  pinned: 0,
2495
2681
  ignored: 0,
2496
2682
  deleted: 0,
2497
- renamed: 0,
2498
- total: files.length
2683
+ renamed: 0
2499
2684
  };
2500
2685
  for (const file of files) {
2501
2686
  if (file.status !== "identical" && isManagedFile(file.path)) {
@@ -2511,6 +2696,20 @@ function formatLocalCheckoutDetail(forkPath, branch, headSha, changeCount, upstr
2511
2696
  const workingTree = changeCount === 0 ? " working tree clean" : ` ${changeCount} uncommitted change${changeCount === 1 ? "" : "s"} in working tree, not included in analysis`;
2512
2697
  return [`${forkPath}`, ` ${branch} @ ${headSha}${tracking}`, workingTree].join("\n");
2513
2698
  }
2699
+ async function resolvePinnedRef(forkPath, config, remoteName, branchRef, ref) {
2700
+ const resolved = await resolveUpstreamCommit(forkPath, remoteName, ref);
2701
+ if (!resolved) {
2702
+ throw new Error(
2703
+ `--ref '${ref}' does not resolve to a commit. Pass a commit sha, a release tag (v*) or a branch on ${config.settings.upstreamUrl}.`
2704
+ );
2705
+ }
2706
+ if (!await isPublishedUpstream(forkPath, remoteName, branchRef, resolved.sha)) {
2707
+ throw new Error(
2708
+ `--ref '${ref}' (${resolved.sha.slice(0, 7)}) is not on ${branchRef} or in an upstream release (v*). Sync only to commits upstream has published there.`
2709
+ );
2710
+ }
2711
+ return resolved.release ? { ref: resolved.release.ref, releaseTag: resolved.release.tag } : { ref: resolved.sha };
2712
+ }
2514
2713
  async function prepareUpstream(config, onProgress, onStep) {
2515
2714
  const { forkPath } = config;
2516
2715
  onProgress?.("checking local checkout...");
@@ -2527,10 +2726,15 @@ async function prepareUpstream(config, onProgress, onStep) {
2527
2726
  const track = config.track ?? configTrack;
2528
2727
  await ensureRemote(forkPath, remoteName, config.settings.upstreamUrl);
2529
2728
  onProgress?.(`fetching upstream (${remoteName})...`);
2530
- await fetch2(forkPath, remoteName);
2729
+ await fetchRemote(forkPath, remoteName);
2531
2730
  let upstreamRef = branchRef;
2532
2731
  let releaseTag;
2533
- if (track === "release") {
2732
+ if (config.ref) {
2733
+ await fetchUpstreamTags(forkPath, remoteName);
2734
+ const pinned = await resolvePinnedRef(forkPath, config, remoteName, branchRef, config.ref);
2735
+ upstreamRef = pinned.ref;
2736
+ releaseTag = pinned.releaseTag;
2737
+ } else if (track === "release") {
2534
2738
  await fetchUpstreamTags(forkPath, remoteName);
2535
2739
  const latest = await resolveLatestReleaseTag(forkPath, remoteName);
2536
2740
  if (!latest) {
@@ -2541,8 +2745,9 @@ async function prepareUpstream(config, onProgress, onStep) {
2541
2745
  upstreamRef = latest.ref;
2542
2746
  releaseTag = latest.tag;
2543
2747
  }
2748
+ const upstreamLabel = releaseTag ?? (config.ref ? `--ref ${config.ref}` : upstreamRef);
2544
2749
  config.upstreamRef = upstreamRef;
2545
- onStep?.("remote configured", `${releaseTag ?? upstreamRef} \u2192 ${config.settings.upstreamUrl}`);
2750
+ onStep?.("remote configured", `${upstreamLabel} \u2192 ${config.settings.upstreamUrl}`);
2546
2751
  const cliRange = await readUpstreamCliRange(forkPath, upstreamRef);
2547
2752
  const cliMismatch = cliRange ? cliVersionMismatch(VERSION, cliRange) : null;
2548
2753
  if (cliMismatch) throw new Error(cliMismatch);
@@ -2550,18 +2755,32 @@ async function prepareUpstream(config, onProgress, onStep) {
2550
2755
  await ensureSyncBase(forkPath, "HEAD", upstreamRef);
2551
2756
  const upstreamGitHubUrl = getGitHubBaseUrl(config.settings.upstreamUrl) ?? void 0;
2552
2757
  const aggressive = config.hard === true || config.unpinned === true;
2553
- const mergeBase = aggressive ? await getMergeBase(forkPath, "HEAD", upstreamRef) : (await getEffectiveMergeBase(forkPath, "HEAD", upstreamRef)).base;
2758
+ const syncPoint = (await getEffectiveMergeBase(forkPath, "HEAD", upstreamRef)).base;
2759
+ const mergeBase = aggressive ? await getMergeBase(forkPath, "HEAD", upstreamRef) : syncPoint;
2554
2760
  const upstreamCommit = await getCommitInfo(forkPath, upstreamRef);
2761
+ if (upstreamCommit.hash !== syncPoint && await isAncestor(forkPath, upstreamCommit.hash, syncPoint)) {
2762
+ const [upstreamShort, syncShort] = await Promise.all([
2763
+ getShortSha(forkPath, upstreamCommit.hash),
2764
+ getShortSha(forkPath, syncPoint)
2765
+ ]);
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}.`;
2767
+ throw new BehindSyncPointError(
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.
2769
+ ${hint}`
2770
+ );
2771
+ }
2555
2772
  const commitCount = await countCommitsBetween(forkPath, mergeBase, upstreamRef);
2556
- const commitListMax = 50;
2557
- const commitSkip = commitCount > commitListMax ? commitCount - commitListMax : 0;
2558
- const upstreamCommits = commitCount > 0 ? await listCommitsBetween(forkPath, mergeBase, upstreamRef, {
2559
- oldestFirst: true,
2560
- skip: commitSkip,
2561
- limit: commitListMax
2562
- }) : [];
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 }) : [];
2563
2775
  onStep?.("fetched upstream", formatFetchedUpstreamDetail(commitCount, upstreamCommits, upstreamGitHubUrl));
2564
- 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);
2565
2784
  }
2566
2785
  function resultMeta(config, ctx) {
2567
2786
  return {
@@ -2573,7 +2792,7 @@ function resultMeta(config, ctx) {
2573
2792
  upstreamCommits: ctx.upstreamCommits
2574
2793
  };
2575
2794
  }
2576
- async function upstreamOnlyReports(config, ctx, files) {
2795
+ async function upstreamOnlyReports(config, ctx, files, protectedConflicts) {
2577
2796
  const { forkPath } = config;
2578
2797
  try {
2579
2798
  const [baseSha, upstreamSha, upstreamOverrides] = await Promise.all([
@@ -2585,7 +2804,8 @@ async function upstreamOnlyReports(config, ctx, files) {
2585
2804
  return {
2586
2805
  upstreamDiffRange: `${baseSha}..${upstreamSha}`,
2587
2806
  upstreamOverrides,
2588
- ignoredUpstreamChanges: groupIgnoredUpstreamChanges(files, config.overrides?.ignored ?? [])
2807
+ ignoredUpstreamChanges: groupIgnoredUpstreamChanges(files, config.overrides?.ignored ?? []),
2808
+ protectedUpstreamChanges: groupProtectedUpstreamChanges(protectedConflicts, config)
2589
2809
  };
2590
2810
  } catch {
2591
2811
  return {};
@@ -2594,12 +2814,10 @@ async function upstreamOnlyReports(config, ctx, files) {
2594
2814
  async function readNotesInRange(config, ctx) {
2595
2815
  const { forkPath } = config;
2596
2816
  try {
2597
- const aggressive = config.hard === true || config.unpinned === true;
2598
- const syncBase = aggressive ? (await getEffectiveMergeBase(forkPath, "HEAD", ctx.upstreamRef)).base : ctx.mergeBase;
2599
2817
  const [total, arrived, pending] = await Promise.all([
2600
2818
  listNoteIds(forkPath, ctx.upstreamRef),
2601
- arrivedNoteIds(forkPath, syncBase, ctx.upstreamRef),
2602
- readPending(forkPath, syncBase)
2819
+ arrivedNoteIds(forkPath, ctx.syncPoint, ctx.upstreamRef),
2820
+ readPending(forkPath, ctx.syncPoint)
2603
2821
  ]);
2604
2822
  return { total: total.length, arrived, pending: [.../* @__PURE__ */ new Set([...pending, ...arrived])] };
2605
2823
  } catch {
@@ -2610,14 +2828,15 @@ function notesResult(notes) {
2610
2828
  return notes ? { migrationNotes: { total: notes.total, arrived: notes.arrived, open: notes.pending.length } } : {};
2611
2829
  }
2612
2830
  async function removeUpstreamOnlyCopy(forkPath, onStep) {
2613
- if (!existsSync7(join11(forkPath, NOTES_DIR))) return;
2831
+ if (!existsSync7(join10(forkPath, NOTES_DIR))) return;
2614
2832
  await git(["rm", "-r", "-q", "--ignore-unmatch", "--", NOTES_DIR], forkPath);
2615
- await rm2(join11(forkPath, NOTES_DIR), { recursive: true, force: true });
2833
+ await rm2(join10(forkPath, NOTES_DIR), { recursive: true, force: true });
2616
2834
  onStep?.("migration notes", `removed ${NOTES_DIR}/: notes stay upstream, list them with pnpm cella migrate`);
2617
2835
  }
2618
2836
  async function runSyncMerge(config, ctx, onProgress, onStep) {
2619
2837
  const { forkPath } = config;
2620
2838
  const { upstreamRef, releaseTag, mergeBase, upstreamGitHubUrl, upstreamCommit } = ctx;
2839
+ await assertSyncConfigFollowed(config, ctx);
2621
2840
  const notes = await readNotesInRange(config, ctx);
2622
2841
  const { remainingConflicts, analyzedFiles, autoMergedFiles, protectedConflicts } = await applyDirectMerge(
2623
2842
  forkPath,
@@ -2628,9 +2847,7 @@ async function runSyncMerge(config, ctx, onProgress, onStep) {
2628
2847
  );
2629
2848
  const summary = calculateSummary(analyzedFiles);
2630
2849
  const synced = analyzedFiles.filter((file) => ["behind", "diverged", "renamed"].includes(file.status)).length;
2631
- const totalResolved = analyzedFiles.filter(
2632
- (file) => ["behind", "diverged", "renamed", "ignored", "pinned"].includes(file.status)
2633
- ).length;
2850
+ const totalResolved = analyzedFiles.filter((file) => SYNC_APPLIED_STATUSES.includes(file.status)).length;
2634
2851
  const syncManifest = {
2635
2852
  upstream: {
2636
2853
  repo: upstreamGitHubUrl ? upstreamGitHubUrl.replace("https://github.com/", "") : void 0,
@@ -2673,7 +2890,7 @@ async function runSyncMerge(config, ctx, onProgress, onStep) {
2673
2890
  autoMergedFiles,
2674
2891
  protectedConflicts,
2675
2892
  ...resultMeta(config, ctx),
2676
- ...await upstreamOnlyReports(config, ctx, analyzedFiles),
2893
+ ...await upstreamOnlyReports(config, ctx, analyzedFiles, protectedConflicts),
2677
2894
  ...notesResult(notes)
2678
2895
  };
2679
2896
  }
@@ -2685,12 +2902,7 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2685
2902
  await createWorktree(forkPath, worktreePath, "HEAD");
2686
2903
  onStep?.("worktree created", worktreePath);
2687
2904
  onProgress?.("performing merge in worktree...");
2688
- await withTemporarySyncBaseGraft(
2689
- worktreePath,
2690
- "HEAD",
2691
- ctx.mergeBase,
2692
- () => merge(worktreePath, ctx.upstreamRef, { noCommit: true, noEdit: true })
2693
- );
2905
+ await withTemporarySyncBaseGraft(worktreePath, "HEAD", ctx.mergeBase, () => merge(worktreePath, ctx.upstreamRef));
2694
2906
  onStep?.("merge complete", "upstream merged into worktree");
2695
2907
  onProgress?.("analyzing files...");
2696
2908
  const analyzedFiles = await analyzeRefs(
@@ -2702,8 +2914,9 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2702
2914
  onProgress
2703
2915
  );
2704
2916
  await enrichChangeInfo(forkPath, analyzedFiles, ctx.mergeBase, "HEAD", ctx.upstreamRef);
2705
- const reports = await upstreamOnlyReports(config, ctx, analyzedFiles);
2706
- 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`);
2707
2920
  onProgress?.("cleaning up worktree...");
2708
2921
  await cleanupWorktree(forkPath, worktreePath);
2709
2922
  return {
@@ -2712,7 +2925,7 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2712
2925
  summary: calculateSummary(analyzedFiles),
2713
2926
  // For analyze mode, count diverged files as potential conflicts
2714
2927
  conflicts: analyzedFiles.filter((f) => f.status === "diverged").map((f) => f.path),
2715
- protectedConflicts: analyzedFiles.filter((f) => f.upstreamChanged).map((f) => f.path),
2928
+ protectedConflicts,
2716
2929
  ...resultMeta(config, ctx),
2717
2930
  ...reports,
2718
2931
  ...notesResult(notes)
@@ -2732,23 +2945,39 @@ async function runMergeEngine(config, options) {
2732
2945
  throw error;
2733
2946
  }
2734
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
+ }
2735
2960
 
2736
2961
  // src/services/migrate.ts
2737
- import { execFileSync as execFileSync2 } from "child_process";
2962
+ import { execFileSync as execFileSync2, spawnSync } from "child_process";
2738
2963
  import { mkdir as mkdir4, writeFile as writeFile3 } from "fs/promises";
2739
- import { dirname as dirname4, join as join12 } from "path";
2964
+ import { dirname as dirname4, join as join11 } from "path";
2740
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
+ }
2741
2976
  async function resolveNotesRef(config) {
2742
2977
  const { forkPath } = config;
2743
2978
  const ref = await getStoredSyncRef(forkPath) ?? await readManifestBaseAtRef(forkPath, "HEAD");
2744
2979
  if (!ref) return null;
2745
- const present = async () => !!await git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], forkPath, { ignoreErrors: true });
2746
- if (!await present()) {
2747
- await ensureRemote(forkPath, DEFAULT_UPSTREAM_REMOTE, config.settings.upstreamUrl);
2748
- await fetch2(forkPath, DEFAULT_UPSTREAM_REMOTE);
2749
- if (!await present())
2750
- throw new Error(`upstream commit ${ref.slice(0, 7)} is not reachable from '${DEFAULT_UPSTREAM_REMOTE}'`);
2751
- }
2980
+ await ensureUpstreamCommit(config, ref);
2752
2981
  return ref;
2753
2982
  }
2754
2983
  async function readNotesStatus(config) {
@@ -2790,27 +3019,88 @@ function printNote(index, note, url) {
2790
3019
  if (note.summary) console.info(` ${note.summary}`);
2791
3020
  console.info(colors_default.dim(` read: pnpm cella migrate --show ${note.id}`));
2792
3021
  if (url) console.info(colors_default.dim(` ${url}`));
2793
- 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}`));
2794
3023
  console.info();
2795
3024
  }
2796
- async function extractNote(config, ref, id) {
3025
+ async function writeNoteFolder(config, ref, id) {
2797
3026
  const { forkPath } = config;
2798
3027
  const note = await readNote(forkPath, ref, id);
2799
3028
  if (!note) throw new Error(`no migration note '${id}' at upstream ${ref.slice(0, 7)}`);
2800
3029
  const files = (await git(["ls-tree", "-r", "--name-only", `${ref}:${NOTES_DIR}/${id}`], forkPath)).split("\n").filter(Boolean);
2801
- const target = join12(EXTRACT_DIR, id);
3030
+ const target = join11(EXTRACT_DIR, id);
2802
3031
  for (const file of files) {
2803
3032
  const content = execFileSync2("git", ["show", `${ref}:${NOTES_DIR}/${id}/${file}`], {
2804
3033
  cwd: forkPath,
2805
- maxBuffer: 50 * 1024 * 1024
3034
+ maxBuffer: MAX_BUFFER
2806
3035
  });
2807
- const path4 = join12(forkPath, target, file);
2808
- await mkdir4(dirname4(path4), { recursive: true });
2809
- await writeFile3(path4, content);
3036
+ const path = join11(forkPath, target, file);
3037
+ await mkdir4(dirname4(path), { recursive: true });
3038
+ await writeFile3(path, content);
2810
3039
  }
2811
- 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}/`);
2812
3045
  console.info(colors_default.dim(` the README's ${NOTES_DIR}/${id}/ paths are this folder.`));
2813
- 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
+ }
2814
3104
  }
2815
3105
  async function markNotes(config, ref, pending, ids) {
2816
3106
  const open = new Set(pending);
@@ -2818,7 +3108,7 @@ async function markNotes(config, ref, pending, ids) {
2818
3108
  for (const id of ids) open.delete(id);
2819
3109
  await writePending(config.forkPath, [...open]);
2820
3110
  const marked = ids.length - unknown.length;
2821
- 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`);
2822
3112
  if (unknown.length > 0) console.info(colors_default.dim(`not pending, left as is: ${unknown.join(", ")}`));
2823
3113
  console.info(colors_default.dim(formatNotesLine((await listNoteIds(config.forkPath, ref)).length, open.size)));
2824
3114
  }
@@ -2833,6 +3123,7 @@ async function runMigrate(config) {
2833
3123
  const githubUrl = getGitHubBaseUrl(config.settings.upstreamUrl) ?? void 0;
2834
3124
  const urlOf = (id) => noteUrl(githubUrl, ref, id);
2835
3125
  if (config.mark?.length) return markNotes(config, ref, pending, config.mark);
3126
+ if (config.run) return runNote(config, ref, config.run);
2836
3127
  if (config.extract) return extractNote(config, ref, config.extract);
2837
3128
  if (config.show) {
2838
3129
  const source = await git(["show", `${ref}:${NOTES_DIR}/${config.show}/README.md`], forkPath, {
@@ -2906,16 +3197,7 @@ function printUnifiedDiff(config, filePath) {
2906
3197
  }
2907
3198
  async function runAnalyze(config) {
2908
3199
  createSpinner("starting analysis...");
2909
- const result = await runMergeEngine(config, {
2910
- apply: false,
2911
- onProgress: (message) => {
2912
- spinnerText(message);
2913
- },
2914
- onStep: (label, detail) => {
2915
- spinnerSuccess(label, detail);
2916
- createSpinner("...");
2917
- }
2918
- });
3200
+ const result = await runEngineWithSpinner(config, false);
2919
3201
  spinnerSuccess();
2920
3202
  const scopedFiles = filterByScope(result, config.scope ?? "all");
2921
3203
  if (config.diff) {
@@ -2937,15 +3219,15 @@ async function runAnalyze(config) {
2937
3219
  upstreamChangedLines: f.upstreamChangedLines ?? null,
2938
3220
  upstreamLinesAbsent: f.upstreamLinesAbsent ?? null,
2939
3221
  upstreamOnly: f.upstreamOnly ?? false,
2940
- // Only on the sync config: upstream override entries the fork config does not follow
2941
- 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
2942
3224
  }));
2943
3225
  writeStdout(JSON.stringify(out, null, 2));
2944
3226
  return result;
2945
3227
  }
2946
3228
  if (config.list) {
2947
3229
  for (const file of scopedFiles) {
2948
- console.info(file.path);
3230
+ writeStdout(file.path);
2949
3231
  }
2950
3232
  return result;
2951
3233
  }
@@ -2954,7 +3236,7 @@ async function runAnalyze(config) {
2954
3236
  if (!file) throw new Error(`file not found in analysis results: ${config.openDiff}`);
2955
3237
  const patch = gitDiffFile(config.forkPath, config.upstreamRef, file.path);
2956
3238
  if (patch.length === 0) {
2957
- console.info(`${colors_default.green("\u2713")} ${file.path} is identical to upstream`);
3239
+ console.info(`${checkMark} ${file.path} is identical to upstream`);
2958
3240
  return result;
2959
3241
  }
2960
3242
  const pagePath = await openDiffInBrowser(
@@ -2967,7 +3249,7 @@ async function runAnalyze(config) {
2967
3249
  },
2968
3250
  config.forkPath
2969
3251
  );
2970
- console.info(`${colors_default.green("\u2713")} opened browser diff for ${file.path}`);
3252
+ console.info(`${checkMark} opened browser diff for ${file.path}`);
2971
3253
  console.info(colors_default.dim(` ${pagePath}`));
2972
3254
  return result;
2973
3255
  }
@@ -2977,41 +3259,34 @@ async function runAnalyze(config) {
2977
3259
  fileLinkMode: config.settings.fileLinkMode,
2978
3260
  forkPath: config.forkPath
2979
3261
  };
2980
- printAnalysisFileGroups(result.files, linkOptions);
2981
- printSummary(result.summary, "analysis summary");
2982
- printIgnoredUpstreamChanges(result);
2983
- printUpstreamOverrideChanges(result);
2984
- printMaskingPinWarning(result.files);
3262
+ printAnalysisFileGroups(result.files, linkOptions, result);
3263
+ printEngineReports(result, "analysis summary");
2985
3264
  await printMigrationNotesLine(config, result);
2986
- if (config.logFile) {
2987
- const logPath = writeLogFile(config.forkPath, result.files);
2988
- console.info();
2989
- console.info(colors_default.dim(`full file list written to: ${logPath}`));
2990
- }
3265
+ printLogFileReport(config, result.files);
2991
3266
  console.info();
2992
3267
  return result;
2993
3268
  }
2994
3269
 
2995
3270
  // src/services/audit.ts
2996
- import { spawnSync } from "child_process";
2997
- import * as fs2 from "fs";
2998
- 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";
2999
3274
  import { checkbox, confirm, Separator } from "@inquirer/prompts";
3000
3275
 
3001
3276
  // src/utils/audit-utils.ts
3002
3277
  import { execFile as execFile2 } from "child_process";
3003
- import * as fs from "fs";
3004
- 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";
3005
3280
  import { promisify as promisify2 } from "util";
3006
3281
  var execFileAsync2 = promisify2(execFile2);
3007
- var CACHE_FILE = path.join(import.meta.dirname, "..", "..", ".audit.cache.json");
3282
+ var CACHE_FILE = join12(import.meta.dirname, "..", "..", ".audit.cache.json");
3008
3283
  var CACHE_TTL = 7 * 24 * 60 * 60 * 1e3;
3009
3284
  var CHANGELOG_PATHS = ["CHANGELOG.md", "CHANGELOG", "changelog.md", "HISTORY.md", "CHANGES.md", "NEWS.md"];
3010
3285
  var DEFAULT_BRANCHES = ["main", "master"];
3011
3286
  function loadCache() {
3012
3287
  try {
3013
- if (fs.existsSync(CACHE_FILE)) {
3014
- return JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8"));
3288
+ if (existsSync8(CACHE_FILE)) {
3289
+ return JSON.parse(readFileSync2(CACHE_FILE, "utf8"));
3015
3290
  }
3016
3291
  } catch {
3017
3292
  }
@@ -3019,15 +3294,15 @@ function loadCache() {
3019
3294
  }
3020
3295
  function saveCache(cache) {
3021
3296
  try {
3022
- fs.writeFileSync(CACHE_FILE, `${JSON.stringify(cache, null, 2)}
3297
+ writeFileSync3(CACHE_FILE, `${JSON.stringify(cache, null, 2)}
3023
3298
  `);
3024
3299
  } catch {
3025
3300
  }
3026
3301
  }
3027
3302
  function clearCache() {
3028
3303
  try {
3029
- if (fs.existsSync(CACHE_FILE)) {
3030
- fs.unlinkSync(CACHE_FILE);
3304
+ if (existsSync8(CACHE_FILE)) {
3305
+ unlinkSync(CACHE_FILE);
3031
3306
  console.info(colors_default.green("\u2713 cache cleared successfully"));
3032
3307
  } else {
3033
3308
  console.info(colors_default.yellow("no cache file found"));
@@ -3115,56 +3390,32 @@ function isMajorVersionChange(current, latest) {
3115
3390
  if (!currentMajor || !latestMajor) return false;
3116
3391
  return Number.parseInt(latestMajor, 10) > Number.parseInt(currentMajor, 10);
3117
3392
  }
3118
- 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
+ };
3119
3402
  try {
3120
- const { stdout } = await execFileAsync2("pnpm", ["-r", "outdated", "--json"], {
3121
- encoding: "utf-8",
3403
+ const { stdout } = await execFileAsync2("pnpm", args, {
3404
+ encoding: "utf8",
3122
3405
  maxBuffer: 10 * 1024 * 1024,
3123
3406
  // 10MB buffer for large outputs
3124
3407
  cwd
3125
3408
  });
3126
- if (!stdout || stdout.trim() === "") {
3127
- return {};
3128
- }
3129
- return JSON.parse(stdout);
3409
+ return parse(stdout);
3130
3410
  } catch (error) {
3131
- if (error instanceof Error && "stdout" in error) {
3132
- const stdout = error.stdout;
3133
- if (stdout?.trim()) {
3134
- try {
3135
- return JSON.parse(stdout);
3136
- } catch {
3137
- return {};
3138
- }
3139
- }
3140
- }
3141
- return {};
3411
+ return parse(error instanceof Error && "stdout" in error ? error.stdout : void 0);
3142
3412
  }
3143
3413
  }
3414
+ async function getOutdatedPackages(cwd) {
3415
+ return await runPnpmJson(["-r", "outdated", "--json"], cwd) ?? {};
3416
+ }
3144
3417
  async function runPnpmAudit(cwd) {
3145
- try {
3146
- const { stdout } = await execFileAsync2("pnpm", ["audit", "--json"], {
3147
- encoding: "utf-8",
3148
- maxBuffer: 10 * 1024 * 1024,
3149
- cwd
3150
- });
3151
- if (!stdout || stdout.trim() === "") {
3152
- return null;
3153
- }
3154
- return JSON.parse(stdout);
3155
- } catch (error) {
3156
- if (error instanceof Error && "stdout" in error) {
3157
- const stdout = error.stdout;
3158
- if (stdout?.trim()) {
3159
- try {
3160
- return JSON.parse(stdout);
3161
- } catch {
3162
- return null;
3163
- }
3164
- }
3165
- }
3166
- return null;
3167
- }
3418
+ return await runPnpmJson(["audit", "--json"], cwd);
3168
3419
  }
3169
3420
  function parseDependencyPath(paths) {
3170
3421
  for (const pathStr of paths) {
@@ -3186,12 +3437,9 @@ function buildVulnerabilityMap(auditResult) {
3186
3437
  const { workspace, directDependency } = parseDependencyPath(allPaths);
3187
3438
  const existing = map.get(advisory.module_name) || [];
3188
3439
  existing.push({
3189
- id: advisory.id,
3190
3440
  title: advisory.title,
3191
3441
  severity: advisory.severity,
3192
- url: advisory.url,
3193
3442
  vulnerableVersions: advisory.vulnerable_versions,
3194
- patchedVersions: advisory.patched_versions,
3195
3443
  cves: advisory.cves || [],
3196
3444
  workspace,
3197
3445
  directDependency
@@ -3200,19 +3448,15 @@ function buildVulnerabilityMap(auditResult) {
3200
3448
  }
3201
3449
  return map;
3202
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
+ };
3203
3458
  function getVulnIcon(severity) {
3204
- switch (severity) {
3205
- case "critical":
3206
- return colors_default.red("\u25CF");
3207
- case "high":
3208
- return colors_default.red("\u25CF");
3209
- case "moderate":
3210
- return colors_default.yellow("\u25CF");
3211
- case "low":
3212
- return colors_default.blue("\u25CF");
3213
- default:
3214
- return colors_default.gray("\u25CF");
3215
- }
3459
+ return vulnIcons[severity] ?? colors_default.gray("\u25CF");
3216
3460
  }
3217
3461
  function getHighestSeverity(vulns) {
3218
3462
  if (vulns.length === 0) return null;
@@ -3222,13 +3466,13 @@ function getHighestSeverity(vulns) {
3222
3466
  }
3223
3467
  return null;
3224
3468
  }
3225
- function middleTruncate(str, maxLen) {
3226
- if (str.length <= maxLen) return str;
3469
+ function middleTruncate(str2, maxLen) {
3470
+ if (str2.length <= maxLen) return str2;
3227
3471
  const ellipsis = "\u2026";
3228
3472
  const charsToShow = maxLen - ellipsis.length;
3229
3473
  const frontChars = Math.ceil(charsToShow / 2);
3230
3474
  const backChars = Math.floor(charsToShow / 2);
3231
- return str.slice(0, frontChars) + ellipsis + str.slice(-backChars);
3475
+ return str2.slice(0, frontChars) + ellipsis + str2.slice(-backChars);
3232
3476
  }
3233
3477
  function formatDependents(dependents) {
3234
3478
  if (dependents.length === 0) return "";
@@ -3273,18 +3517,42 @@ function parseYamlBlockList(content, key) {
3273
3517
  }
3274
3518
 
3275
3519
  // src/services/audit.ts
3276
- 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) {
3277
3545
  const { forkPath } = config;
3278
- if (options.checkOverrides) {
3546
+ if (config.checkOverrides) {
3279
3547
  await checkOverrides(forkPath);
3280
3548
  return;
3281
3549
  }
3282
3550
  const spinner = createSpinner("checking for outdated packages & vulnerabilities...");
3283
3551
  try {
3284
- if (options.force) {
3552
+ if (config.force) {
3285
3553
  spinnerText("clearing pnpm metadata cache...");
3286
3554
  clearCache();
3287
- spawnSync("pnpm", ["cache", "delete", "*"], {
3555
+ spawnSync2("pnpm", ["cache", "delete", "*"], {
3288
3556
  cwd: forkPath,
3289
3557
  stdio: "ignore"
3290
3558
  });
@@ -3298,25 +3566,13 @@ async function runAudit(config, options = {}) {
3298
3566
  return;
3299
3567
  }
3300
3568
  const cache = loadCache();
3301
- spinnerText(`found ${packageNames.length} outdated package(s) - fetching metadata...`);
3302
- const pkgJsonCache = /* @__PURE__ */ new Map();
3303
- const readDependentPkg = (location) => {
3304
- let cached = pkgJsonCache.get(location);
3305
- if (cached === void 0) {
3306
- try {
3307
- cached = JSON.parse(fs2.readFileSync(path2.join(location, "package.json"), "utf-8"));
3308
- } catch {
3309
- cached = null;
3310
- }
3311
- pkgJsonCache.set(location, cached ?? null);
3312
- }
3313
- return cached ?? null;
3314
- };
3569
+ spinnerText(`found ${packageNames.length} outdated package(s), fetching metadata...`);
3570
+ const readDependentPkg = createDependentPkgReader();
3315
3571
  const dependentNameCache = /* @__PURE__ */ new Map();
3316
3572
  for (const name of packageNames) {
3317
3573
  for (const dep of outdatedPackages[name].dependentPackages) {
3318
3574
  if (!dependentNameCache.has(dep.location)) {
3319
- dependentNameCache.set(dep.location, readDependentPkg(dep.location)?.name || path2.basename(dep.location));
3575
+ dependentNameCache.set(dep.location, readDependentPkg(dep.location)?.name || basename5(dep.location));
3320
3576
  }
3321
3577
  }
3322
3578
  }
@@ -3427,7 +3683,7 @@ function printOutdatedResults(enhancedPackages, dependentNameCache, forkPath) {
3427
3683
  const dependentMap = /* @__PURE__ */ new Map();
3428
3684
  for (const pkg of enhancedPackages) {
3429
3685
  for (const loc of pkg.dependentLocations) {
3430
- const dependentName = dependentNameCache.get(loc) || path2.basename(loc);
3686
+ const dependentName = dependentNameCache.get(loc) || basename5(loc);
3431
3687
  const existing = dependentMap.get(dependentName);
3432
3688
  if (existing) {
3433
3689
  existing.count++;
@@ -3439,34 +3695,15 @@ function printOutdatedResults(enhancedPackages, dependentNameCache, forkPath) {
3439
3695
  const sortedDependents = [...dependentMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
3440
3696
  const maxDependentNameLen = Math.max(0, ...sortedDependents.map(([name]) => name.length));
3441
3697
  for (const [dependentName, { location, count }] of sortedDependents) {
3442
- const packageJsonPath2 = path2.join(location, "package.json");
3443
- const relativePath = path2.relative(forkPath, packageJsonPath2);
3698
+ const packageJsonPath2 = join13(location, "package.json");
3699
+ const relativePath = relative2(forkPath, packageJsonPath2);
3444
3700
  const paddedName = dependentName.padEnd(maxDependentNameLen);
3445
3701
  console.info(
3446
3702
  ` ${colors_default.dim("\u2022")} ${paddedName} ${hyperlink(relativePath, `file://${packageJsonPath2}`)} ${colors_default.dim(`${count}`)}`
3447
3703
  );
3448
3704
  }
3449
3705
  }
3450
- async function promptForUpdates(packages, forkPath, auditResult) {
3451
- const hasVulnerabilities = auditResult !== null && Object.values(auditResult.metadata?.vulnerabilities || {}).some((c) => c > 0);
3452
- const hasPackages = packages.length > 0;
3453
- let confirmMessage = "update packages?";
3454
- if (hasVulnerabilities && hasPackages) {
3455
- confirmMessage = "update packages and fix vulnerabilities?";
3456
- } else if (hasVulnerabilities) {
3457
- confirmMessage = "fix vulnerabilities?";
3458
- }
3459
- let wantsUpdate;
3460
- try {
3461
- wantsUpdate = await confirm({
3462
- message: confirmMessage,
3463
- default: true
3464
- });
3465
- } catch {
3466
- return;
3467
- }
3468
- if (!wantsUpdate) return;
3469
- console.info();
3706
+ function buildUpdateChoices(packages, auditResult, hasVulnerabilities) {
3470
3707
  const makeChoice = (pkg) => {
3471
3708
  const devTag = pkg.isDev ? colors_default.dim(" (dev)") : "";
3472
3709
  const version = `${colors_default.red(pkg.current)} \u2192 ${colors_default.green(pkg.latest)}`;
@@ -3477,17 +3714,11 @@ async function promptForUpdates(packages, forkPath, auditResult) {
3477
3714
  checked: false
3478
3715
  };
3479
3716
  };
3480
- const AUDIT_FIX_VALUE = "__audit_fix__";
3481
3717
  const pinned = packages.filter((p) => p.pinnedIn.length > 0);
3482
3718
  const regular = packages.filter((p) => p.pinnedIn.length === 0);
3483
3719
  const choices = [];
3484
3720
  if (hasVulnerabilities) {
3485
- const vulnMeta = auditResult?.metadata?.vulnerabilities || {};
3486
- const parts = [];
3487
- if (vulnMeta.critical) parts.push(colors_default.red(`${vulnMeta.critical} critical`));
3488
- if (vulnMeta.high) parts.push(colors_default.red(`${vulnMeta.high} high`));
3489
- if (vulnMeta.moderate) parts.push(colors_default.yellow(`${vulnMeta.moderate} moderate`));
3490
- if (vulnMeta.low) parts.push(colors_default.blue(`${vulnMeta.low} low`));
3721
+ const parts = formatSeverityCounts(auditResult?.metadata?.vulnerabilities || {});
3491
3722
  const vulnSummary = parts.length > 0 ? ` ${colors_default.dim("\xB7")} ${parts.join(" ")}` : "";
3492
3723
  choices.push(new Separator(colors_default.red(`\u2500\u2500 vulnerabilities${vulnSummary} \u2500\u2500`)));
3493
3724
  choices.push({
@@ -3506,6 +3737,29 @@ async function promptForUpdates(packages, forkPath, auditResult) {
3506
3737
  }
3507
3738
  choices.push(...regular.map(makeChoice));
3508
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);
3509
3763
  let selected;
3510
3764
  try {
3511
3765
  selected = await checkbox({
@@ -3533,6 +3787,9 @@ async function promptForUpdates(packages, forkPath, auditResult) {
3533
3787
  }
3534
3788
  const runAuditFix = selected.includes(AUDIT_FIX_VALUE);
3535
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) {
3536
3793
  const summaryParts = [];
3537
3794
  if (selectedPackages.length > 0) {
3538
3795
  summaryParts.push(`updating ${selectedPackages.length} package${selectedPackages.length !== 1 ? "s" : ""}`);
@@ -3564,17 +3821,15 @@ async function promptForUpdates(packages, forkPath, auditResult) {
3564
3821
  for (const pkgName of selectedPackages) {
3565
3822
  const pkg = packages.find((p) => p.name === pkgName);
3566
3823
  if (!pkg) continue;
3567
- for (let i = 0; i < pkg.dependents.length; i++) {
3568
- const dependent = pkg.dependents[i];
3569
- if (!workspacePackages.has(dependent)) {
3570
- workspacePackages.set(dependent, { packages: [] });
3571
- }
3572
- 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]);
3573
3828
  }
3574
3829
  }
3575
3830
  if (runAuditFix) {
3576
3831
  console.info(colors_default.dim("running pnpm audit --fix override..."));
3577
- const auditFixResult = spawnSync("pnpm", ["audit", "--fix", "override"], {
3832
+ const auditFixResult = spawnSync2("pnpm", ["audit", "--fix", "override"], {
3578
3833
  cwd: forkPath,
3579
3834
  stdio: "inherit"
3580
3835
  });
@@ -3585,7 +3840,7 @@ async function promptForUpdates(packages, forkPath, auditResult) {
3585
3840
  console.error(colors_default.red(`\u2717 pnpm audit --fix override failed (exit code ${auditFixResult.status})`));
3586
3841
  }
3587
3842
  }
3588
- for (const [workspace, { packages: pkgs }] of workspacePackages) {
3843
+ for (const [workspace, pkgs] of workspacePackages) {
3589
3844
  const pkgList = pkgs.join(", ");
3590
3845
  console.info(`${colors_default.cyan("\u2191")} ${colors_default.bold(workspace)}: ${colors_default.dim(pkgList)}`);
3591
3846
  }
@@ -3594,7 +3849,7 @@ async function promptForUpdates(packages, forkPath, auditResult) {
3594
3849
  const selectedCatalogPackages = selectedPackages.filter((pkgName) => catalogPackages.has(pkgName));
3595
3850
  if (selectedCatalogPackages.length > 0) {
3596
3851
  console.info(colors_default.dim(`normalizing catalog consumers: ${selectedCatalogPackages.join(", ")}`));
3597
- const normalizeResult = spawnSync("pnpm", ["-r", "up", ...selectedCatalogPackages], {
3852
+ const normalizeResult = spawnSync2("pnpm", ["-r", "up", ...selectedCatalogPackages], {
3598
3853
  cwd: forkPath,
3599
3854
  stdio: "inherit"
3600
3855
  });
@@ -3604,7 +3859,7 @@ async function promptForUpdates(packages, forkPath, auditResult) {
3604
3859
  return;
3605
3860
  }
3606
3861
  }
3607
- const result = spawnSync("pnpm", ["-r", "up", "--latest", ...selectedPackages], {
3862
+ const result = spawnSync2("pnpm", ["-r", "up", "--latest", ...selectedPackages], {
3608
3863
  cwd: forkPath,
3609
3864
  stdio: "inherit"
3610
3865
  });
@@ -3619,15 +3874,11 @@ async function promptForUpdates(packages, forkPath, auditResult) {
3619
3874
  function printVulnerabilityResults(auditResult, vulnMap) {
3620
3875
  if (auditResult && vulnMap.size > 0) {
3621
3876
  const vulnMeta = auditResult.metadata?.vulnerabilities || {};
3622
- const criticalCount = vulnMeta.critical || 0;
3623
- const highCount = vulnMeta.high || 0;
3624
- const moderateCount = vulnMeta.moderate || 0;
3625
- const lowCount = vulnMeta.low || 0;
3626
3877
  console.info();
3627
3878
  console.info(
3628
- `${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("")}`
3629
3880
  );
3630
- console.info("\u2500".repeat(60));
3881
+ console.info(DIVIDER);
3631
3882
  for (const [pkgName, vulns] of vulnMap.entries()) {
3632
3883
  for (const vuln of vulns) {
3633
3884
  const severityIcon = getVulnIcon(vuln.severity);
@@ -3647,14 +3898,14 @@ function printVulnerabilityResults(auditResult, vulnMap) {
3647
3898
  }
3648
3899
  } else {
3649
3900
  console.info();
3650
- console.info(`${colors_default.green("\u2713")} no vulnerabilities found`);
3901
+ console.info(`${checkMark} no vulnerabilities found`);
3651
3902
  }
3652
3903
  console.info();
3653
3904
  }
3654
3905
  function readCatalogPackageNames(forkPath) {
3655
- const workspacePath = path2.join(forkPath, "pnpm-workspace.yaml");
3906
+ const workspacePath = join13(forkPath, "pnpm-workspace.yaml");
3656
3907
  try {
3657
- const content = fs2.readFileSync(workspacePath, "utf-8");
3908
+ const content = readFileSync3(workspacePath, "utf8");
3658
3909
  return new Set(Object.keys(parseYamlBlockMap(content, "catalog")));
3659
3910
  } catch {
3660
3911
  return /* @__PURE__ */ new Set();
@@ -3662,18 +3913,18 @@ function readCatalogPackageNames(forkPath) {
3662
3913
  }
3663
3914
  function readOverrides(forkPath) {
3664
3915
  const overrides = /* @__PURE__ */ new Map();
3665
- const pkgJsonPath = path2.join(forkPath, "package.json");
3916
+ const pkgJsonPath = join13(forkPath, "package.json");
3666
3917
  try {
3667
- const pkgJson = JSON.parse(fs2.readFileSync(pkgJsonPath, "utf-8"));
3918
+ const pkgJson = JSON.parse(readFileSync3(pkgJsonPath, "utf8"));
3668
3919
  const pnpmOverrides = pkgJson.pnpm?.overrides || {};
3669
3920
  for (const [key, value] of Object.entries(pnpmOverrides)) {
3670
3921
  overrides.set(key, { target: value, source: "package.json" });
3671
3922
  }
3672
3923
  } catch {
3673
3924
  }
3674
- const workspacePath = path2.join(forkPath, "pnpm-workspace.yaml");
3925
+ const workspacePath = join13(forkPath, "pnpm-workspace.yaml");
3675
3926
  try {
3676
- const content = fs2.readFileSync(workspacePath, "utf-8");
3927
+ const content = readFileSync3(workspacePath, "utf8");
3677
3928
  const wsOverrides = parseYamlBlockMap(content, "overrides");
3678
3929
  for (const [key, value] of Object.entries(wsOverrides)) {
3679
3930
  overrides.set(key, { target: value, source: "pnpm-workspace.yaml" });
@@ -3716,7 +3967,7 @@ async function checkOverrides(forkPath) {
3716
3967
  const maxTargetLen = Math.max(6, ...allValues.map((v) => v.target.length));
3717
3968
  if (securityOverrides.length > 0) {
3718
3969
  console.info(colors_default.bold(`security overrides (${securityOverrides.length})`));
3719
- console.info("\u2500".repeat(60));
3970
+ console.info(DIVIDER);
3720
3971
  for (const o of securityOverrides) {
3721
3972
  const icon = o.status === "active" ? colors_default.red("\u25CF") : colors_default.green("\u25CB");
3722
3973
  const statusText = o.status === "active" ? colors_default.red("active") : colors_default.green("likely stale");
@@ -3729,7 +3980,7 @@ async function checkOverrides(forkPath) {
3729
3980
  }
3730
3981
  if (pinOverrides.length > 0) {
3731
3982
  console.info(colors_default.bold(`version pins (${pinOverrides.length})`));
3732
- console.info("\u2500".repeat(60));
3983
+ console.info(DIVIDER);
3733
3984
  for (const o of pinOverrides) {
3734
3985
  const keyText = colors_default.white(o.key.padEnd(maxKeyLen));
3735
3986
  const targetText = colors_default.dim(o.target.padEnd(maxTargetLen));
@@ -3742,26 +3993,24 @@ async function checkOverrides(forkPath) {
3742
3993
  const activeCount = securityOverrides.filter((o) => o.status === "active").length;
3743
3994
  if (staleCount > 0) {
3744
3995
  console.info(
3745
- `${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`
3746
3997
  );
3747
- 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"));
3748
3999
  }
3749
4000
  if (activeCount > 0) {
3750
4001
  console.info(
3751
- `${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`
3752
4003
  );
3753
4004
  }
3754
4005
  if (pinOverrides.length > 0) {
3755
4006
  console.info(
3756
- `${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`
3757
4008
  );
3758
4009
  }
3759
4010
  console.info();
3760
4011
  }
3761
4012
 
3762
4013
  // src/services/contributions.ts
3763
- import { readFileSync as readFileSync4 } from "fs";
3764
- import { join as join16 } from "path";
3765
4014
  import {
3766
4015
  createPrompt,
3767
4016
  isDownKey,
@@ -3769,14 +4018,13 @@ import {
3769
4018
  isSpaceKey,
3770
4019
  isUpKey,
3771
4020
  useKeypress,
3772
- useMemo,
3773
4021
  usePagination,
3774
4022
  useState
3775
4023
  } from "@inquirer/core";
3776
4024
  import { select as select2 } from "@inquirer/prompts";
3777
4025
 
3778
4026
  // src/services/contrib-core.ts
3779
- import { dirname as dirname5, join as join15, resolve as resolve4 } from "path";
4027
+ import { dirname as dirname5, join as join14, resolve as resolve5 } from "path";
3780
4028
  async function listFilesAtRef(repoPath, ref) {
3781
4029
  const raw = await git(["ls-tree", "-r", "--name-only", ref], repoPath);
3782
4030
  return new Set(raw.split("\n").filter(Boolean));
@@ -3784,8 +4032,8 @@ async function listFilesAtRef(repoPath, ref) {
3784
4032
  async function detectContributableFiles(repoPath, baseRef, forkRef, config, forkTerritory = []) {
3785
4033
  const mergeBase = (await git(["merge-base", baseRef, forkRef], repoPath, { ignoreErrors: true })).trim() || baseRef;
3786
4034
  const predicates = {
3787
- isIgnored: (path4) => isIgnored(path4, config) || isUnderAnyFolder(path4, forkTerritory),
3788
- isPinned: (path4) => isPinned(path4, config)
4035
+ isIgnored: (path) => isIgnored(path, config) || isUnderAnyFolder(path, forkTerritory),
4036
+ isPinned: (path) => isPinned(path, config)
3789
4037
  };
3790
4038
  const analyzed = await analyzeRefs(repoPath, baseRef, forkRef, mergeBase, predicates);
3791
4039
  await enrichChangeInfo(repoPath, analyzed, mergeBase, baseRef, forkRef);
@@ -3839,8 +4087,8 @@ async function readBlobInfo(repoPath, ref, filePath) {
3839
4087
  }
3840
4088
  async function buildContribBranch(repoPath, baseRef, forkRef, detection, forkName) {
3841
4089
  const branch = `contrib/${forkName}`;
3842
- const gitDir = resolve4(repoPath, await git(["rev-parse", "--git-dir"], repoPath));
3843
- const tmpIndex = join15(gitDir, `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
4090
+ const gitDir = resolve5(repoPath, await git(["rev-parse", "--git-dir"], repoPath));
4091
+ const tmpIndex = join14(gitDir, `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
3844
4092
  const indexEnv = { GIT_INDEX_FILE: tmpIndex };
3845
4093
  try {
3846
4094
  await git(["read-tree", baseRef], repoPath, { env: indexEnv });
@@ -3884,14 +4132,14 @@ ${commitBody}`;
3884
4132
 
3885
4133
  // src/services/fork-utils.ts
3886
4134
  import { existsSync as existsSync9 } from "fs";
3887
- import { dirname as dirname6, resolve as resolve5 } from "path";
4135
+ import { dirname as dirname6, resolve as resolve6 } from "path";
3888
4136
  async function resolveForkBasePath(repoPath) {
3889
4137
  const commonDir = await git(["rev-parse", "--git-common-dir"], repoPath, { ignoreErrors: true });
3890
- return commonDir ? dirname6(resolve5(repoPath, commonDir)) : repoPath;
4138
+ return commonDir ? dirname6(resolve6(repoPath, commonDir)) : repoPath;
3891
4139
  }
3892
4140
  function validateForkPath(fork, basePath, requireConfig = false) {
3893
- const resolvedPath = resolve5(basePath, fork.localPath);
3894
- if (!resolvedPath.startsWith(resolve5(basePath, ".."))) {
4141
+ const resolvedPath = resolve6(basePath, fork.localPath);
4142
+ if (!resolvedPath.startsWith(resolve6(basePath, ".."))) {
3895
4143
  return { fork, resolvedPath, valid: false, error: "path resolves outside parent directory" };
3896
4144
  }
3897
4145
  if (!existsSync9(resolvedPath)) return { fork, resolvedPath, valid: false, error: "path does not exist" };
@@ -3913,20 +4161,14 @@ async function showContribDiff(item, baseRef, cwd, forkName) {
3913
4161
  if (patch.length === 0) return null;
3914
4162
  return openDiffInBrowser(patch.toString(), { filePath: item.path, srcLabel: "cella", dstLabel: forkName }, cwd);
3915
4163
  }
3916
- function shouldApplyContributionsUnstaged(repoPath) {
3917
- return JSON.parse(readFileSync4(join16(repoPath, "package.json"), "utf8")).name === "cella";
3918
- }
3919
4164
  var contribPrompt = createPrompt((config, done) => {
3920
4165
  const { pageSize = 20, baseRef, cwd, forkName } = config;
3921
4166
  const [items, setItems] = useState(config.items);
3922
4167
  const [active, setActive] = useState(0);
3923
4168
  const [statusMsg, setStatusMsg] = useState("");
3924
4169
  const [promptStatus, setPromptStatus] = useState("idle");
3925
- const bounds = useMemo(() => {
3926
- if (items.length === 0) return { first: 0, last: 0 };
3927
- return { first: 0, last: items.length - 1 };
3928
- }, [items]);
3929
4170
  useKeypress(async (key) => {
4171
+ const last = items.length - 1;
3930
4172
  if (items.length === 0) {
3931
4173
  if (isEnterKey(key) || key.name === "q") {
3932
4174
  setPromptStatus("done");
@@ -3950,22 +4192,22 @@ var contribPrompt = createPrompt((config, done) => {
3950
4192
  return;
3951
4193
  }
3952
4194
  if (key.ctrl && isUpKey(key)) {
3953
- setActive(bounds.first);
4195
+ setActive(0);
3954
4196
  setStatusMsg("");
3955
4197
  return;
3956
4198
  }
3957
4199
  if (key.ctrl && isDownKey(key)) {
3958
- setActive(bounds.last);
4200
+ setActive(last);
3959
4201
  setStatusMsg("");
3960
4202
  return;
3961
4203
  }
3962
4204
  if (isUpKey(key)) {
3963
- setActive(active <= bounds.first ? bounds.first : active - 1);
4205
+ setActive(active <= 0 ? 0 : active - 1);
3964
4206
  setStatusMsg("");
3965
4207
  return;
3966
4208
  }
3967
4209
  if (isDownKey(key)) {
3968
- setActive(active >= bounds.last ? bounds.last : active + 1);
4210
+ setActive(active >= last ? last : active + 1);
3969
4211
  setStatusMsg("");
3970
4212
  return;
3971
4213
  }
@@ -3978,7 +4220,7 @@ var contribPrompt = createPrompt((config, done) => {
3978
4220
  const pagePath = await showContribDiff(items[active], baseRef, cwd, forkName);
3979
4221
  setStatusMsg(pagePath ? `opened ${items[active].path} in browser` : `no changes for ${items[active].path}`);
3980
4222
  } catch (error) {
3981
- setStatusMsg(`diff failed: ${error instanceof Error ? error.message : "unknown error"}`);
4223
+ setStatusMsg(`diff failed: ${errorMessage(error)}`);
3982
4224
  }
3983
4225
  return;
3984
4226
  }
@@ -3992,12 +4234,12 @@ var contribPrompt = createPrompt((config, done) => {
3992
4234
  if (promptStatus === "done") {
3993
4235
  const checked = items.filter((i) => i.checked);
3994
4236
  if (checked.length > 0) {
3995
- return `${colors_default.green("\u2713")} ${checked.length} files accepted`;
4237
+ return `${checkMark} ${checked.length} files accepted`;
3996
4238
  }
3997
- return `${colors_default.green("\u2713")} done`;
4239
+ return `${checkMark} done`;
3998
4240
  }
3999
4241
  if (items.length === 0) {
4000
- return `${colors_default.green("\u2713")} no contributions \u2014 press enter`;
4242
+ return `${checkMark} no contributions: press enter`;
4001
4243
  }
4002
4244
  const checkedCount = items.filter((i) => i.checked).length;
4003
4245
  const page = usePagination({
@@ -4031,6 +4273,38 @@ var contribPrompt = createPrompt((config, done) => {
4031
4273
  const lines = [header, page, statusLine, helpLine].filter(Boolean).join("\n").trimEnd();
4032
4274
  return `${lines}\x1B[?25l`;
4033
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
+ }
4034
4308
  async function fetchForkBranch(cellaPath, fetchSource, pullBranch) {
4035
4309
  await git(["fetch", fetchSource, pullBranch], cellaPath);
4036
4310
  return git(["rev-parse", "FETCH_HEAD"], cellaPath);
@@ -4058,42 +4332,8 @@ async function runContributions(config) {
4058
4332
  }
4059
4333
  const forkBasePath = await resolveForkBasePath(config.forkPath);
4060
4334
  const validated = forks.map((fork2) => validateForkPath(fork2, forkBasePath));
4061
- const validForks = validated.filter((v) => v.valid);
4062
- let selectedFork;
4063
- if (config.fork) {
4064
- const match = validated.find((v) => v.fork.name === config.fork);
4065
- if (!match?.valid) {
4066
- console.error(colors_default.red(`fork '${config.fork}' not found or invalid in config`));
4067
- return;
4068
- }
4069
- selectedFork = match;
4070
- } else if (config.list || config.json || config.diff) {
4071
- if (validForks.length === 0) {
4072
- console.error(colors_default.red("no valid forks configured"));
4073
- return;
4074
- }
4075
- if (validForks.length > 1) {
4076
- console.error(colors_default.red("multiple forks configured; pass --fork <name> to choose one"));
4077
- return;
4078
- }
4079
- selectedFork = validForks[0];
4080
- } else {
4081
- const choices = validated.map((v) => ({
4082
- value: v.fork.name,
4083
- name: v.valid ? `${v.fork.name} ${colors_default.dim(`[${v.fork.pullBranch}] ${v.fork.localPath}`)}` : `${v.fork.name} ${colors_default.dim(v.fork.localPath)}`,
4084
- disabled: v.valid ? false : v.error ?? "invalid"
4085
- }));
4086
- const picked = await select2({
4087
- message: "select fork to pull contributions from:",
4088
- choices,
4089
- loop: false
4090
- });
4091
- selectedFork = validated.find((v) => v.fork.name === picked);
4092
- }
4093
- if (!selectedFork) {
4094
- console.info(colors_default.dim("no fork selected."));
4095
- return;
4096
- }
4335
+ const selectedFork = await selectFork(config, validated);
4336
+ if (!selectedFork) return;
4097
4337
  const { fork, resolvedPath } = selectedFork;
4098
4338
  const forkName = fork.name;
4099
4339
  createSpinner("pulling fork contributions...");
@@ -4116,40 +4356,28 @@ async function runContributions(config) {
4116
4356
  if (appliedFiles.length > 0) {
4117
4357
  const stat = await getDiffStat(config.forkPath, baseRef, branch);
4118
4358
  const metaByPath = new Map(detection.files.map((f) => [f.path, f]));
4119
- for (const path4 of [...detection.modified, ...detection.created]) {
4120
- const fileMeta = metaByPath.get(path4);
4121
- allItems.push({
4122
- path: path4,
4123
- ref: branch,
4124
- deleted: false,
4125
- created: fileMeta?.kind === "created",
4126
- status: fileMeta?.status,
4127
- changedAt: fileMeta?.changedAt,
4128
- changedTs: fileMeta?.changedTs,
4129
- additions: stat.get(path4)?.additions ?? null,
4130
- deletions: stat.get(path4)?.deletions ?? null,
4131
- checked: false
4132
- });
4133
- }
4134
- for (const path4 of detection.deleted) {
4135
- const fileMeta = metaByPath.get(path4);
4136
- allItems.push({
4137
- path: path4,
4359
+ const toItem = (path, deleted) => {
4360
+ const fileMeta = metaByPath.get(path);
4361
+ return {
4362
+ path,
4138
4363
  ref: branch,
4139
- deleted: true,
4364
+ deleted,
4365
+ ...deleted ? {} : { created: fileMeta?.kind === "created" },
4140
4366
  status: fileMeta?.status,
4141
4367
  changedAt: fileMeta?.changedAt,
4142
4368
  changedTs: fileMeta?.changedTs,
4143
- additions: stat.get(path4)?.additions ?? null,
4144
- deletions: stat.get(path4)?.deletions ?? null,
4369
+ additions: stat.get(path)?.additions ?? null,
4370
+ deletions: stat.get(path)?.deletions ?? null,
4145
4371
  checked: false
4146
- });
4147
- }
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));
4148
4376
  }
4149
4377
  }
4150
4378
  } catch (error) {
4151
4379
  spinnerFail(`failed to pull ${forkName}`);
4152
- console.info(colors_default.red(` \u2717 ${error instanceof Error ? error.message : "unknown error"}`));
4380
+ console.info(colors_default.red(` \u2717 ${errorMessage(error)}`));
4153
4381
  return;
4154
4382
  }
4155
4383
  if (allItems.length === 0) {
@@ -4160,15 +4388,13 @@ async function runContributions(config) {
4160
4388
  spinnerSuccess(`${allItems.length} files from ${forkName}`);
4161
4389
  if (!config.list && !config.diff && forkBanner) {
4162
4390
  console.info(
4163
- ` ${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`)}`
4164
4392
  );
4165
4393
  }
4166
4394
  if (config.diff) {
4167
4395
  const matches = allItems.filter((i) => i.path === config.diff);
4168
4396
  if (matches.length === 0) {
4169
- console.error(colors_default.red(`no contribution found for path: ${config.diff}`));
4170
- process.exitCode = 1;
4171
- return;
4397
+ throw new Error(`no contribution found for path: ${config.diff}`);
4172
4398
  }
4173
4399
  for (const item of matches) {
4174
4400
  const diff = gitDiffFile(config.forkPath, `${baseRef}..${item.ref}`, item.path, { dstPrefix: forkName });
@@ -4195,7 +4421,7 @@ async function runContributions(config) {
4195
4421
  const status = item.status ?? "behind";
4196
4422
  const kind = item.deleted ? "deleted" : "modified";
4197
4423
  const changedAt = item.changedAt ?? "-";
4198
- console.info(`${forkName} ${status} ${kind} ${changedAt} ${item.path}`);
4424
+ writeStdout(`${forkName} ${status} ${kind} ${changedAt} ${item.path}`);
4199
4425
  }
4200
4426
  return;
4201
4427
  }
@@ -4214,7 +4440,7 @@ async function runContributions(config) {
4214
4440
  return;
4215
4441
  }
4216
4442
  createSpinner(`applying ${selected.length} files...`);
4217
- const applyUnstaged = shouldApplyContributionsUnstaged(config.forkPath);
4443
+ const applyUnstaged = isUpstreamRepo(config.forkPath);
4218
4444
  let applied = 0;
4219
4445
  const errors = [];
4220
4446
  for (const item of selected) {
@@ -4238,7 +4464,7 @@ async function runContributions(config) {
4238
4464
  }
4239
4465
  applied++;
4240
4466
  } catch (error) {
4241
- errors.push(`${item.path}: ${error instanceof Error ? error.message : "unknown error"}`);
4467
+ errors.push(`${item.path}: ${errorMessage(error)}`);
4242
4468
  }
4243
4469
  }
4244
4470
  if (errors.length > 0) {
@@ -4252,28 +4478,28 @@ async function runContributions(config) {
4252
4478
  console.info();
4253
4479
  console.info(
4254
4480
  colors_default.dim(
4255
- 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"
4256
4482
  )
4257
4483
  );
4258
4484
  console.info();
4259
4485
  }
4260
4486
 
4261
4487
  // src/services/forks.ts
4262
- import { resolve as resolve6 } from "path";
4488
+ import { resolve as resolve7 } from "path";
4263
4489
  import process8 from "process";
4264
4490
  import { Separator as Separator2, select as select4 } from "@inquirer/prompts";
4265
4491
 
4266
4492
  // src/services/sync.ts
4267
- import { spawnSync as spawnSync3 } from "child_process";
4493
+ import { spawnSync as spawnSync4 } from "child_process";
4268
4494
  import { select as select3 } from "@inquirer/prompts";
4269
4495
 
4270
4496
  // src/utils/gh.ts
4271
- import { spawnSync as spawnSync2 } from "child_process";
4497
+ import { spawnSync as spawnSync3 } from "child_process";
4272
4498
  function ghAvailable() {
4273
- return spawnSync2("gh", ["--version"], { stdio: "ignore" }).status === 0;
4499
+ return spawnSync3("gh", ["--version"], { stdio: "ignore" }).status === 0;
4274
4500
  }
4275
4501
  function runGh(cwd, args) {
4276
- 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"] });
4277
4503
  const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
4278
4504
  return { ok: result.status === 0, output };
4279
4505
  }
@@ -4313,12 +4539,12 @@ function closePr(cwd, ref) {
4313
4539
  }
4314
4540
 
4315
4541
  // src/services/packages.ts
4316
- import { existsSync as existsSync11, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
4317
- 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";
4318
4544
 
4319
4545
  // src/services/package-usage.ts
4320
- import { existsSync as existsSync10, readFileSync as readFileSync5, statSync } from "fs";
4321
- import { join as join17 } from "path";
4546
+ import { existsSync as existsSync10, readFileSync as readFileSync4, statSync } from "fs";
4547
+ import { join as join15 } from "path";
4322
4548
  var MAX_FILE_BYTES = 1024 * 1024;
4323
4549
  function escapeRegExp(value) {
4324
4550
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -4326,41 +4552,41 @@ function escapeRegExp(value) {
4326
4552
  function readSearchable(filePath) {
4327
4553
  try {
4328
4554
  if (statSync(filePath).size > MAX_FILE_BYTES) return "";
4329
- const text = readFileSync5(filePath, "utf-8");
4555
+ const text = readFileSync4(filePath, "utf8");
4330
4556
  return text.includes("\0") ? "" : text;
4331
4557
  } catch {
4332
4558
  return "";
4333
4559
  }
4334
4560
  }
4335
- function fileSource(forkPath, path4) {
4561
+ function fileSource(forkPath, path) {
4336
4562
  let text;
4337
- return { path: path4, label: path4, text: () => text ??= readSearchable(join17(forkPath, path4)) };
4563
+ return { path, label: path, text: () => text ??= readSearchable(join15(forkPath, path)) };
4338
4564
  }
4339
- async function readScriptsAtRef(forkPath, ref, path4) {
4565
+ async function readScriptsAtRef(forkPath, ref, path) {
4340
4566
  try {
4341
- return JSON.parse(await git(["show", `${ref}:${path4}`], forkPath)).scripts ?? {};
4567
+ return JSON.parse(await git(["show", `${ref}:${path}`], forkPath)).scripts ?? {};
4342
4568
  } catch {
4343
4569
  return {};
4344
4570
  }
4345
4571
  }
4346
- async function scriptSources(forkPath, upstreamRef, baseRef, path4) {
4572
+ async function scriptSources(forkPath, upstreamRef, baseRef, path) {
4347
4573
  let forkScripts;
4348
4574
  try {
4349
- forkScripts = JSON.parse(readFileSync5(join17(forkPath, path4), "utf-8")).scripts ?? {};
4575
+ forkScripts = JSON.parse(readFileSync4(join15(forkPath, path), "utf8")).scripts ?? {};
4350
4576
  } catch {
4351
4577
  return [];
4352
4578
  }
4353
- const baseScripts = await readScriptsAtRef(forkPath, baseRef, path4);
4354
- const upstreamScripts = await readScriptsAtRef(forkPath, upstreamRef, path4);
4355
- 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 }));
4356
4582
  }
4357
4583
  function binNames(forkPath, location, name) {
4358
4584
  const bareName = name.split("/").pop();
4359
- for (const dir of [join17(forkPath, location), forkPath]) {
4360
- 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");
4361
4587
  if (!existsSync10(manifest)) continue;
4362
4588
  try {
4363
- const { bin } = JSON.parse(readFileSync5(manifest, "utf-8"));
4589
+ const { bin } = JSON.parse(readFileSync4(manifest, "utf8"));
4364
4590
  if (typeof bin === "string") return [bareName];
4365
4591
  return bin && typeof bin === "object" ? Object.keys(bin) : [];
4366
4592
  } catch {
@@ -4385,9 +4611,9 @@ function scriptPattern(name) {
4385
4611
  async function loadForkUsage(forkPath, upstreamRef, baseRef) {
4386
4612
  const diff = await git(["diff", "--name-only", "-z", "--diff-filter=d", upstreamRef], forkPath);
4387
4613
  const sources = [];
4388
- for (const path4 of diff.split("\0").filter(Boolean)) {
4389
- if (isPackageJson(path4)) sources.push(...await scriptSources(forkPath, upstreamRef, baseRef, path4));
4390
- 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));
4391
4617
  }
4392
4618
  const findIn = (scoped, patterns) => scoped.find((source) => patterns.some((pattern) => pattern.test(source.text())))?.label;
4393
4619
  return {
@@ -4517,17 +4743,37 @@ function safeMergePnpm(forkPnpm, upstreamPnpm, basePnpm) {
4517
4743
  }
4518
4744
  return { merged, changed };
4519
4745
  }
4746
+ function mergeModuleType(forkType, upstreamType, baseType) {
4747
+ const asString = (value) => typeof value === "string" ? value : void 0;
4748
+ const fork = asString(forkType);
4749
+ const upstream = asString(upstreamType);
4750
+ if (fork === upstream) return null;
4751
+ if (fork === void 0) return { value: upstream, change: "added" };
4752
+ if (fork !== asString(baseType)) return null;
4753
+ return { value: upstream, change: upstream === void 0 ? "removed" : "updated" };
4754
+ }
4755
+ function setTopLevelKey(pkg, key, value) {
4756
+ if (key in pkg) {
4757
+ pkg[key] = value;
4758
+ return;
4759
+ }
4760
+ const entries = Object.entries(pkg);
4761
+ const after = Math.max(...["name", "version", "private"].map((name) => entries.findIndex(([k]) => k === name)));
4762
+ entries.splice(after + 1, 0, [key, value]);
4763
+ for (const name of Object.keys(pkg)) delete pkg[name];
4764
+ Object.assign(pkg, Object.fromEntries(entries));
4765
+ }
4520
4766
  function readPackageJson(filePath) {
4521
4767
  if (!existsSync11(filePath)) return null;
4522
4768
  try {
4523
- return JSON.parse(readFileSync6(filePath, "utf-8"));
4769
+ return JSON.parse(readFileSync5(filePath, "utf8"));
4524
4770
  } catch {
4525
4771
  return null;
4526
4772
  }
4527
4773
  }
4528
4774
  function writePackageJson(filePath, data) {
4529
4775
  writeFileSync4(filePath, `${JSON.stringify(data, null, 2)}
4530
- `, "utf-8");
4776
+ `, "utf8");
4531
4777
  }
4532
4778
  function packageJsonPath(relativePath) {
4533
4779
  return relativePath ? `${relativePath}/package.json` : "package.json";
@@ -4556,20 +4802,27 @@ async function syncPackageJson(config, baseRef, usage, relativePath, keysToSync)
4556
4802
  const changes = [];
4557
4803
  const kept = [];
4558
4804
  const pkgRelPath = packageJsonPath(relativePath);
4559
- const pkgPath = join18(forkPath, pkgRelPath);
4805
+ const pkgPath = join16(forkPath, pkgRelPath);
4560
4806
  const forkPkg = readPackageJson(pkgPath);
4561
4807
  const upstream = await readPackageJsonAtRef(forkPath, upstreamRef, relativePath);
4562
4808
  const basePkg = baseRef ? (await readPackageJsonAtRef(forkPath, baseRef, relativePath))?.data : void 0;
4563
4809
  if (!upstream) return { updated: false, changes, kept };
4564
4810
  if (!forkPkg) {
4565
- const workspaceArrived = relativePath !== "" && existsSync11(join18(forkPath, relativePath));
4811
+ const workspaceArrived = relativePath !== "" && existsSync11(join16(forkPath, relativePath));
4566
4812
  if (basePkg || !workspaceArrived || isIgnored(pkgRelPath, config)) return { updated: false, changes, kept };
4567
4813
  writeFileSync4(pkgPath, upstream.raw.endsWith("\n") ? upstream.raw : `${upstream.raw}
4568
- `, "utf-8");
4814
+ `, "utf8");
4569
4815
  return { updated: true, changes: ["copied from upstream (new workspace)"], kept };
4570
4816
  }
4571
4817
  const upstreamPkg = upstream.data;
4572
4818
  let updated = false;
4819
+ const type = mergeModuleType(forkPkg.type, upstreamPkg.type, basePkg?.type);
4820
+ if (type) {
4821
+ if (type.value === void 0) delete forkPkg.type;
4822
+ else setTopLevelKey(forkPkg, "type", type.value);
4823
+ updated = true;
4824
+ changes.push(`type: ${type.change}`);
4825
+ }
4573
4826
  for (const key of keysToSync) {
4574
4827
  if (key === "pnpm") {
4575
4828
  const result2 = safeMergePnpm(forkPkg.pnpm, upstreamPkg.pnpm, basePkg?.pnpm);
@@ -4630,7 +4883,7 @@ async function syncPackageJson(config, baseRef, usage, relativePath, keysToSync)
4630
4883
  }
4631
4884
  async function runPackages(config, options = {}) {
4632
4885
  createSpinner("syncing package.json files...");
4633
- const keysToSync = config.settings.packageJsonSync || ["dependencies", "devDependencies"];
4886
+ const keysToSync = config.settings.packageJsonSync || DEFAULT_PACKAGE_JSON_SYNC;
4634
4887
  const conflictedSet = new Set(options.conflictedFiles ?? []);
4635
4888
  const baseRef = await resolveMergeBase(config.forkPath, config.upstreamRef);
4636
4889
  const usage = baseRef ? await loadForkUsage(config.forkPath, config.upstreamRef, baseRef) : null;
@@ -4652,36 +4905,42 @@ async function runPackages(config, options = {}) {
4652
4905
  "package sync complete",
4653
4906
  `${changedCount} package.json${changedCount === 1 ? " was" : "s were"} changed`
4654
4907
  );
4655
- for (const { path: path4, changes } of reports) {
4908
+ for (const { path, changes } of reports) {
4656
4909
  if (changes.length === 0) continue;
4657
- console.info(` ${path4}`);
4910
+ console.info(` ${path}`);
4658
4911
  for (const change of changes) console.info(` ${colors_default.dim(change)}`);
4659
4912
  }
4660
- 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}`));
4661
4914
  if (kept.length > 0) {
4662
4915
  console.info();
4663
- console.warn(
4664
- `${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"}:`)}`
4665
4918
  );
4666
- 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}`);
4667
4920
  }
4668
4921
  if (skipped.length > 0) {
4669
4922
  console.info();
4670
- console.warn(
4671
- `${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\`:`)}`
4672
4925
  );
4673
- for (const path4 of skipped) {
4674
- console.warn(` ${colors_default.dim("\u2192")} ${path4}`);
4926
+ for (const path of skipped) {
4927
+ console.info(` ${colors_default.dim("\u2192")} ${path}`);
4675
4928
  }
4676
4929
  }
4677
4930
  }
4678
4931
 
4679
4932
  // src/services/sync.ts
4680
- async function ensureBaseUpToDate(forkPath, base) {
4681
- const { upstream, ahead, behind } = await getUpstreamStatus(forkPath);
4933
+ async function resolveCycleStart(forkPath, base, currentBranch) {
4934
+ if (!await branchExists(forkPath, base)) {
4935
+ await fetchRemote(forkPath, "origin").catch(() => {
4936
+ });
4937
+ console.info(colors_default.dim(`'${base}' has no local branch: cutting from 'origin/${base}'.`));
4938
+ return `origin/${base}`;
4939
+ }
4940
+ const { upstream, ahead, behind } = await getUpstreamStatus(forkPath, base);
4682
4941
  if (!upstream) {
4683
- console.info(colors_default.dim(`'${base}' has no upstream \u2014 skipping the up-to-date check.`));
4684
- return;
4942
+ console.info(colors_default.dim(`'${base}' has no upstream: skipping the up-to-date check.`));
4943
+ return base;
4685
4944
  }
4686
4945
  if (ahead > 0 && behind > 0) {
4687
4946
  throw new Error(
@@ -4690,53 +4949,74 @@ Reconcile '${base}' with '${upstream}' first (e.g. rebase or reset it), then re-
4690
4949
  );
4691
4950
  }
4692
4951
  if (behind > 0) {
4952
+ const worktree = currentBranch === base ? null : await getBranchWorktree(forkPath, base);
4953
+ if (worktree) {
4954
+ console.info(
4955
+ colors_default.dim(`'${base}' is ${behind} behind '${upstream}' but checked out at ${worktree}; leaving it as it is.`)
4956
+ );
4957
+ return upstream;
4958
+ }
4693
4959
  console.info(colors_default.dim(`fast-forwarding '${base}' to '${upstream}' (${behind} behind)...`));
4694
- await pullFastForward(forkPath);
4695
- return;
4960
+ if (currentBranch === base) await pullFastForward(forkPath);
4961
+ else await fastForwardBranch(forkPath, base, upstream);
4962
+ return base;
4696
4963
  }
4697
4964
  if (ahead > 0) {
4698
- 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.`));
4699
4966
  }
4967
+ return base;
4700
4968
  }
4701
4969
  async function setupTemporarySyncBranch(config) {
4702
4970
  const { forkPath, settings } = config;
4703
4971
  const base = resolveReleaseBase(settings);
4704
4972
  const startBranch = await getCurrentBranch(forkPath);
4705
- console.info(colors_default.dim(`switching to '${base}' and updating...`));
4706
- await switchBranch(forkPath, base);
4707
- await ensureBaseUpToDate(forkPath, base);
4973
+ const startCommit = (await getCommitInfo(forkPath, "HEAD")).hash;
4974
+ console.info(colors_default.dim(`updating '${base}'...`));
4975
+ const startPoint = await resolveCycleStart(forkPath, base, startBranch);
4708
4976
  const temporaryBranch = buildTemporarySyncBranch();
4709
- console.info(colors_default.dim(`creating temporary sync branch '${temporaryBranch}' from '${base}'...`));
4977
+ console.info(colors_default.dim(`creating temporary sync branch '${temporaryBranch}' from '${startPoint}'...`));
4710
4978
  console.info();
4711
- await createBranchFrom(forkPath, temporaryBranch, base);
4712
- return { temporaryBranch, base, startBranch };
4979
+ await createBranchFrom(forkPath, temporaryBranch, startPoint);
4980
+ return { temporaryBranch, base, startBranch, startCommit };
4981
+ }
4982
+ async function resolveCutBase(forkPath, base) {
4983
+ if (!await branchExists(forkPath, base)) return `origin/${base}`;
4984
+ const upstream = await getBranchUpstream(forkPath, base);
4985
+ return upstream && await isAncestor(forkPath, base, upstream) ? upstream : base;
4986
+ }
4987
+ async function returnToBase(forkPath, base) {
4988
+ const worktree = await getBranchWorktree(forkPath, base);
4989
+ if (worktree) {
4990
+ console.info(colors_default.dim(`'${base}' is checked out at ${worktree}; detaching at '${base}' here instead...`));
4991
+ await switchDetached(forkPath, base);
4992
+ return `detached at '${base}'`;
4993
+ }
4994
+ console.info(colors_default.dim(`switching back to '${base}'...`));
4995
+ await switchBranch(forkPath, base);
4996
+ await pullFastForward(forkPath).catch(() => {
4997
+ });
4998
+ return `back on '${base}'`;
4713
4999
  }
4714
5000
  async function runSync(config, options) {
4715
5001
  createSpinner("starting sync...");
4716
- const result = await runMergeEngine(config, {
4717
- apply: true,
4718
- onProgress: (message) => {
4719
- spinnerText(message);
4720
- },
4721
- onStep: (label, detail) => {
4722
- spinnerSuccess(label, detail);
4723
- 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();
4724
5010
  }
4725
- });
5011
+ throw error;
5012
+ }
4726
5013
  if (result.success) {
4727
5014
  spinnerSuccess();
4728
5015
  } else {
4729
5016
  spinnerFail("sync completed with conflicts");
4730
5017
  }
4731
- printSummary(result.summary, "merge summary");
4732
- printIgnoredUpstreamChanges(result);
4733
- printUpstreamOverrideChanges(result);
4734
- printMaskingPinWarning(result.files);
4735
- if (config.logFile) {
4736
- const logPath = writeLogFile(config.forkPath, result.files);
4737
- console.info();
4738
- console.info(colors_default.dim(`full file list written to: ${logPath}`));
4739
- }
5018
+ printEngineReports(result, "merge summary");
5019
+ printLogFileReport(config, result.files);
4740
5020
  const stagedBranch = options?.stagedBranch && result.conflicts.length === 0 && hasStagedSyncChanges(result) ? options.stagedBranch : void 0;
4741
5021
  printSyncComplete(result, { stagedBranch });
4742
5022
  printFlagWarnings({ hard: config.hard, unpinned: config.unpinned });
@@ -4744,7 +5024,6 @@ async function runSync(config, options) {
4744
5024
  return result;
4745
5025
  }
4746
5026
  var SYNC_PR_TITLE = "chore: sync upstream cella";
4747
- var PR_BODY_COMMIT_MAX = 50;
4748
5027
  async function buildSyncCommitMessage(forkPath, upstreamRef) {
4749
5028
  const shortSha = await getShortSha(forkPath, upstreamRef).catch(() => "");
4750
5029
  if (!shortSha) return SYNC_PR_TITLE;
@@ -4796,9 +5075,8 @@ async function buildSyncPrBodyForBranch(forkPath, base) {
4796
5075
  const version = manifest.upstream.release ?? await readPackageVersionAtRef(forkPath, toSha).catch(() => null);
4797
5076
  const totalCount = fromSha ? await countCommitsBetween(forkPath, fromSha, toSha).catch(() => 0) : 0;
4798
5077
  const commits = fromSha && totalCount > 0 ? await listCommitsBetween(forkPath, fromSha, toSha, {
4799
- oldestFirst: true,
4800
- skip: totalCount > PR_BODY_COMMIT_MAX ? totalCount - PR_BODY_COMMIT_MAX : 0,
4801
- limit: PR_BODY_COMMIT_MAX
5078
+ skip: totalCount > COMMIT_LIST_MAX ? totalCount - COMMIT_LIST_MAX : 0,
5079
+ limit: COMMIT_LIST_MAX
4802
5080
  }) : [];
4803
5081
  const notes = await readPrBodyNotes(forkPath, toSha, manifest.upstream.repo).catch(() => void 0);
4804
5082
  return buildSyncPrBody({ repoSlug: manifest.upstream.repo, version, fromSha, toSha, commits, totalCount, notes });
@@ -4822,11 +5100,8 @@ function printShipSteps(temporaryBranch, base, title) {
4822
5100
  console.info(colors_default.dim(` git push -u origin ${temporaryBranch}`));
4823
5101
  printPrCreateStep(temporaryBranch, base, title);
4824
5102
  }
4825
- function printFinishSteps() {
4826
- console.info(colors_default.dim(" pnpm cella sync"));
4827
- }
4828
5103
  function hasStagedSyncChanges(result) {
4829
- 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));
4830
5105
  }
4831
5106
  function extractFirstUrl(output) {
4832
5107
  return output.match(/https?:\/\/\S+/)?.[0];
@@ -4837,7 +5112,7 @@ async function flattenSyncBranch(forkPath, branch, base) {
4837
5112
  const message = await buildSyncCommitMessage(forkPath, `${mergeCommits[0]}^2`);
4838
5113
  console.info(
4839
5114
  colors_default.yellow(
4840
- `'${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.`
4841
5116
  )
4842
5117
  );
4843
5118
  console.info(colors_default.dim(`flattening '${branch}' to a single commit (same content)...`));
@@ -4850,7 +5125,8 @@ function indentLines(text) {
4850
5125
  async function shipSyncBranch(config, branch) {
4851
5126
  const { forkPath, settings } = config;
4852
5127
  const base = resolveReleaseBase(settings);
4853
- const flattened = await flattenSyncBranch(forkPath, branch, base);
5128
+ const cutBase = await resolveCutBase(forkPath, base);
5129
+ const flattened = await flattenSyncBranch(forkPath, branch, cutBase);
4854
5130
  let prUrl;
4855
5131
  let prOpened = false;
4856
5132
  const headSubject = (await getCommitInfo(forkPath, "HEAD").catch(() => null))?.message;
@@ -4859,18 +5135,17 @@ async function shipSyncBranch(config, branch) {
4859
5135
  try {
4860
5136
  await pushBranch(forkPath, "origin", branch, { forceWithLease: flattened });
4861
5137
  } catch (error) {
4862
- const detail = error instanceof Error ? error.message : String(error);
4863
- 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:`));
4864
5139
  printShipSteps(branch, base, prTitle);
4865
5140
  return;
4866
5141
  }
4867
5142
  if (ghAvailable()) {
4868
5143
  console.info(colors_default.dim("opening a pull request..."));
4869
- const prBody = await buildSyncPrBodyForBranch(forkPath, base);
5144
+ const prBody = await buildSyncPrBodyForBranch(forkPath, cutBase);
4870
5145
  const bodyArgs = prBody ? ["--body", prBody] : ["--fill"];
4871
- 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], {
4872
5147
  cwd: forkPath,
4873
- encoding: "utf-8",
5148
+ encoding: "utf8",
4874
5149
  stdio: ["ignore", "pipe", "pipe"]
4875
5150
  });
4876
5151
  prUrl = extractFirstUrl(`${pr.stdout ?? ""}
@@ -4882,42 +5157,60 @@ ${pr.stderr ?? ""}`);
4882
5157
  printPrCreateStep(branch, base, prTitle);
4883
5158
  }
4884
5159
  } else {
4885
- 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:"));
4886
5161
  printPrCreateStep(branch, base, prTitle);
4887
5162
  }
4888
- console.info(colors_default.dim(`switching back to '${base}'...`));
4889
- await switchBranch(forkPath, base);
4890
- await pullFastForward(forkPath).catch(() => {
4891
- });
5163
+ const position = await returnToBase(forkPath, base);
4892
5164
  console.info();
4893
5165
  if (prUrl) {
4894
- console.info(`${colors_default.green("\u2713")} Sync pull request ${prOpened ? "opened" : "ready"}`);
4895
- console.info(colors_default.dim(` ${prUrl} \xB7 branch pushed, back on '${base}'`));
5166
+ console.info(`${checkMark} sync pull request ${prOpened ? "opened" : "ready"}`);
5167
+ console.info(colors_default.dim(` ${prUrl} \xB7 branch pushed, ${position}`));
4896
5168
  } else {
4897
- console.info(`${colors_default.green("\u2713")} Sync branch pushed`);
4898
- console.info(colors_default.dim(` '${branch}' is on origin, back on '${base}'`));
5169
+ console.info(`${checkMark} sync branch pushed`);
5170
+ console.info(colors_default.dim(` '${branch}' is on origin, ${position}`));
4899
5171
  }
4900
5172
  await printMigrationNotesLine(config);
4901
5173
  }
4902
5174
  function finalizeWorkspace(forkPath) {
4903
5175
  for (const args of [["install"], ["check"]]) {
4904
5176
  console.info(colors_default.dim(`running pnpm ${args.join(" ")}...`));
4905
- const result = spawnSync3("pnpm", args, { cwd: forkPath, stdio: "inherit" });
5177
+ const result = spawnSync4("pnpm", args, { cwd: forkPath, stdio: "inherit" });
4906
5178
  if (result.status !== 0) return false;
4907
5179
  }
4908
5180
  return true;
4909
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
+ }
4910
5195
  async function runSyncCycle(config) {
4911
5196
  const { forkPath } = config;
4912
5197
  const branch = await setupTemporarySyncBranch(config);
4913
- 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
+ }
4914
5208
  if (config.settings.syncWithPackages !== false) {
4915
5209
  await runPackages(config, { conflictedFiles: result.conflicts });
4916
5210
  }
4917
5211
  if (result.conflicts.length > 0) return { status: "conflicts", branch };
4918
- if (!mergeInProgress(forkPath)) {
4919
- await switchBranch(forkPath, branch.startBranch === branch.temporaryBranch ? branch.base : branch.startBranch);
4920
- await deleteBranch(forkPath, branch.temporaryBranch);
5212
+ if (!await mergeInProgress(forkPath)) {
5213
+ await discardTemporarySyncBranch(forkPath, branch);
4921
5214
  return { status: "noop" };
4922
5215
  }
4923
5216
  return { status: "staged", branch };
@@ -4962,21 +5255,18 @@ async function mergeOpenSyncPrs(config, open) {
4962
5255
  const merged = mergePrSquash(forkPath, newest.number, { deleteBranch: true });
4963
5256
  if (!merged.ok) {
4964
5257
  console.info();
4965
- 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\`:`));
4966
5259
  if (merged.output) console.info(colors_default.dim(indentLines(merged.output)));
4967
5260
  console.info(colors_default.dim(` ${newest.url}`));
4968
5261
  return "cancel";
4969
5262
  }
4970
- console.info(`${colors_default.green("\u2713")} merged #${newest.number}`);
5263
+ console.info(`${checkMark} merged #${newest.number}`);
4971
5264
  await deleteBranch(forkPath, newest.headRefName);
4972
5265
  for (const pr of superseded) {
4973
- 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}...`));
4974
5267
  closePr(forkPath, pr.number);
4975
5268
  await deleteBranch(forkPath, pr.headRefName);
4976
5269
  }
4977
- console.info(colors_default.dim(`switching to '${base}' and fast-forwarding...`));
4978
- await switchBranch(forkPath, base);
4979
- await pullFastForward(forkPath);
4980
5270
  return "continue";
4981
5271
  }
4982
5272
  async function guardAgainstOpenSyncPr(config) {
@@ -5015,7 +5305,7 @@ async function runSyncCommand(config) {
5015
5305
  const { forkPath } = config;
5016
5306
  const currentBranch = await getCurrentBranch(forkPath);
5017
5307
  const onSyncBranch = isTemporarySyncBranch(currentBranch);
5018
- if (onSyncBranch && mergeInProgress(forkPath)) {
5308
+ if (onSyncBranch && await mergeInProgress(forkPath)) {
5019
5309
  await commitSyncMerge(config, currentBranch);
5020
5310
  return;
5021
5311
  }
@@ -5038,15 +5328,20 @@ commit them first (\`git commit --amend --no-edit\` or a new commit).`
5038
5328
  const outcome = await runSyncCycle(config);
5039
5329
  console.info();
5040
5330
  if (outcome.status === "noop") {
5041
- 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
+ }
5042
5337
  return;
5043
5338
  }
5044
5339
  const { temporaryBranch } = outcome.branch;
5045
5340
  if (outcome.status === "conflicts") {
5046
5341
  console.info(`${warningMark} ${colors_default.yellow(`conflicts on '${temporaryBranch}'. Resolve and stage them, then:`)}`);
5047
- printFinishSteps();
5342
+ console.info(colors_default.dim(" pnpm cella sync"));
5048
5343
  console.info(colors_default.dim(" rerun commits the sync and stops for drift triage; a further rerun ships (push + PR)."));
5049
- 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."));
5050
5345
  return;
5051
5346
  }
5052
5347
  await commitSyncMerge(config, temporaryBranch);
@@ -5054,12 +5349,11 @@ commit them first (\`git commit --amend --no-edit\` or a new commit).`
5054
5349
 
5055
5350
  // src/services/forks.ts
5056
5351
  async function gatherForkStatus(forkPath) {
5057
- const [branch, porcelain, syncRef] = await Promise.all([
5352
+ const [branch, dirty, syncRef] = await Promise.all([
5058
5353
  getCurrentBranch(forkPath).catch(() => "unknown"),
5059
- git(["status", "--porcelain"], forkPath, { ignoreErrors: true }),
5354
+ getWorkingTreeChangeCount(forkPath).catch(() => 0),
5060
5355
  getStoredSyncRef(forkPath)
5061
5356
  ]);
5062
- const dirty = porcelain ? porcelain.split("\n").filter(Boolean).length : 0;
5063
5357
  let lastSync = null;
5064
5358
  if (syncRef) {
5065
5359
  const commitInfo = await getCommitInfo(forkPath, syncRef).catch(() => null);
@@ -5122,7 +5416,8 @@ async function syncFork(config, fork, forkPath) {
5122
5416
  list: false,
5123
5417
  json: false,
5124
5418
  verbose: config.verbose,
5125
- hard: config.hard
5419
+ hard: config.hard,
5420
+ keepConfig: config.keepConfig
5126
5421
  };
5127
5422
  await runSyncCommand(forkRuntimeConfig);
5128
5423
  }
@@ -5135,17 +5430,16 @@ async function runForks(config) {
5135
5430
  if (config.fork) {
5136
5431
  const match = forks.find((f) => f.name === config.fork);
5137
5432
  if (!match) {
5138
- console.error(colors_default.red(`fork '${config.fork}' not found in config`));
5139
- return;
5433
+ throw new Error(`fork '${config.fork}' not found in config`);
5140
5434
  }
5141
- const resolvedPath = resolve6(await resolveForkBasePath(config.forkPath), match.localPath);
5435
+ const resolvedPath = resolve7(await resolveForkBasePath(config.forkPath), match.localPath);
5142
5436
  await syncFork(config, match, resolvedPath);
5143
5437
  return;
5144
5438
  }
5145
5439
  const forkBasePath = await resolveForkBasePath(config.forkPath);
5146
5440
  while (true) {
5147
5441
  const choices = await buildForkChoices(forks, forkBasePath);
5148
- 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") }];
5149
5443
  const selectedPath = await select4({
5150
5444
  message: "select fork to sync:",
5151
5445
  choices: forkChoices,
@@ -5154,7 +5448,7 @@ async function runForks(config) {
5154
5448
  if (selectedPath === "_exit") {
5155
5449
  process8.exit(0);
5156
5450
  }
5157
- const resolvedForkPath = resolve6(forkBasePath, selectedPath);
5451
+ const resolvedForkPath = resolve7(forkBasePath, selectedPath);
5158
5452
  const selectedFork = forks.find((f) => f.localPath === selectedPath);
5159
5453
  if (!selectedFork) {
5160
5454
  console.error(colors_default.red(`fork '${selectedPath}' not found in config`));
@@ -5167,15 +5461,15 @@ async function runForks(config) {
5167
5461
 
5168
5462
  // src/services/stats.ts
5169
5463
  import { readFile as readFile3 } from "fs/promises";
5170
- import { join as join20 } from "path";
5464
+ import { join as join18 } from "path";
5171
5465
 
5172
5466
  // src/utils/coverage-utils.ts
5173
- import { spawnSync as spawnSync4 } from "child_process";
5174
- import * as fs3 from "fs";
5175
- 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";
5176
5470
  var STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1e3;
5177
5471
  function summaryPath(forkPath) {
5178
- return path3.join(forkPath, ".coverage", "coverage-summary.json");
5472
+ return join17(forkPath, ".coverage", "coverage-summary.json");
5179
5473
  }
5180
5474
  function colorPct(pct) {
5181
5475
  if (typeof pct !== "number" || !Number.isFinite(pct)) return colors_default.dim("n/a");
@@ -5194,7 +5488,7 @@ function formatAge(ms) {
5194
5488
  }
5195
5489
  function refreshCoverage(forkPath) {
5196
5490
  console.info(colors_default.dim("running `pnpm test` (this spins up Docker and runs the full suite)..."));
5197
- const result = spawnSync4("pnpm", ["test"], { cwd: forkPath, stdio: "inherit" });
5491
+ const result = spawnSync5("pnpm", ["test"], { cwd: forkPath, stdio: "inherit" });
5198
5492
  return result.status === 0;
5199
5493
  }
5200
5494
  function printCoverageSummary(forkPath, options = {}) {
@@ -5202,24 +5496,24 @@ function printCoverageSummary(forkPath, options = {}) {
5202
5496
  if (options.refresh) {
5203
5497
  const ok = refreshCoverage(forkPath);
5204
5498
  if (!ok) {
5205
- 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)"));
5206
5500
  }
5207
5501
  console.info();
5208
5502
  }
5209
- if (!fs3.existsSync(file)) {
5503
+ if (!existsSync12(file)) {
5210
5504
  console.info(colors_default.bold("coverage"));
5211
- 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"));
5212
5506
  console.info();
5213
5507
  return;
5214
5508
  }
5215
5509
  let summary;
5216
5510
  let mtimeMs;
5217
5511
  try {
5218
- summary = JSON.parse(fs3.readFileSync(file, "utf-8"));
5219
- mtimeMs = fs3.statSync(file).mtimeMs;
5512
+ summary = JSON.parse(readFileSync6(file, "utf8"));
5513
+ mtimeMs = statSync2(file).mtimeMs;
5220
5514
  } catch {
5221
5515
  console.info(colors_default.bold("coverage"));
5222
- 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"));
5223
5517
  console.info();
5224
5518
  return;
5225
5519
  }
@@ -5228,9 +5522,9 @@ function printCoverageSummary(forkPath, options = {}) {
5228
5522
  const perPackage = /* @__PURE__ */ new Map();
5229
5523
  for (const [key, entry] of Object.entries(summary)) {
5230
5524
  if (key === "total") continue;
5231
- const rel = path3.relative(forkPath, key);
5525
+ const rel = relative3(forkPath, key);
5232
5526
  if (rel.startsWith("..")) continue;
5233
- const pkg = rel.split(path3.sep)[0];
5527
+ const pkg = rel.split(sep2)[0];
5234
5528
  const acc = perPackage.get(pkg) ?? { covered: 0, total: 0 };
5235
5529
  acc.covered += entry.lines.covered;
5236
5530
  acc.total += entry.lines.total;
@@ -5239,10 +5533,10 @@ function printCoverageSummary(forkPath, options = {}) {
5239
5533
  const ageLabel = formatAge(ageMs);
5240
5534
  console.info(colors_default.bold("coverage"), colors_default.dim(`(updated ${ageLabel})`));
5241
5535
  if (stale) {
5242
- 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"));
5243
5537
  }
5244
5538
  if (!Object.keys(summary).some((key) => key !== "total")) {
5245
- 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"));
5246
5540
  console.info();
5247
5541
  return;
5248
5542
  }
@@ -5261,7 +5555,7 @@ function printCoverageSummary(forkPath, options = {}) {
5261
5555
  // src/services/stats.ts
5262
5556
  var sourceExtensions = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs", "json", "jsonc", "css", "html"]);
5263
5557
  async function getWorkspacePackages(forkPath) {
5264
- const content = await readFile3(join20(forkPath, "pnpm-workspace.yaml"), "utf-8");
5558
+ const content = await readFile3(join18(forkPath, "pnpm-workspace.yaml"), "utf8");
5265
5559
  return parseYamlBlockList(content, "packages").map((pattern) => {
5266
5560
  if (pattern.endsWith("/*")) {
5267
5561
  const base = pattern.slice(0, -2);
@@ -5325,12 +5619,12 @@ async function collectStats(forkPath) {
5325
5619
  const categories = emptyCat();
5326
5620
  const pkgStats = /* @__PURE__ */ new Map();
5327
5621
  let totalLoc = 0;
5328
- for (const { path: path4, loc } of entries) {
5329
- const category = classifyFile(path4);
5622
+ for (const { path, loc } of entries) {
5623
+ const category = classifyFile(path);
5330
5624
  categories[category].files++;
5331
5625
  categories[category].loc += loc;
5332
5626
  totalLoc += loc;
5333
- const pkg = matchPackage(path4, packages);
5627
+ const pkg = matchPackage(path, packages);
5334
5628
  let entry = pkgStats.get(pkg);
5335
5629
  if (!entry) {
5336
5630
  entry = { total: 0, loc: 0, categories: emptyCat() };
@@ -5407,24 +5701,244 @@ function printStats(stats, verbose) {
5407
5701
  }
5408
5702
  console.info();
5409
5703
  }
5410
- async function runStats(forkPath, options = {}) {
5704
+ async function runStats(config) {
5705
+ const { forkPath } = config;
5411
5706
  createSpinner("counting files...");
5412
5707
  const stats = await collectStats(forkPath);
5413
- spinnerSuccess("Finished counting by raw line of code");
5414
- if (options.json) {
5415
- console.info(JSON.stringify(stats, null, 2));
5416
- } else {
5417
- 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) {
5899
+ console.info();
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
+ }
5904
+ }
5905
+ if (verbose) {
5418
5906
  console.info();
5419
- printCoverageSummary(forkPath, { refresh: options.refreshCoverage });
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;
5420
5933
  }
5934
+ printBranchStats(stats, options.verbose ?? false);
5421
5935
  }
5422
5936
 
5423
5937
  // src/cella-cli.ts
5424
5938
  function getForkPath() {
5425
5939
  const envPath = getEnv("CELLA_FORK_PATH");
5426
5940
  if (envPath) {
5427
- const resolved = resolve7(envPath);
5941
+ const resolved = resolve8(envPath);
5428
5942
  if (!existsSync13(resolved) || !statSync3(resolved).isDirectory()) {
5429
5943
  throw new Error(`CELLA_FORK_PATH is not a valid directory: ${resolved}`);
5430
5944
  }
@@ -5432,12 +5946,12 @@ function getForkPath() {
5432
5946
  }
5433
5947
  let cwd = process9.cwd();
5434
5948
  if (cwd.endsWith("/cli/cella") || cwd.endsWith("\\cli\\cella")) {
5435
- cwd = resolve7(cwd, "../..");
5949
+ cwd = resolve8(cwd, "../..");
5436
5950
  }
5437
5951
  return cwd;
5438
5952
  }
5439
- async function preflight(forkPath) {
5440
- if (!existsSync13(join21(forkPath, ".git"))) {
5953
+ function preflight(forkPath) {
5954
+ if (!existsSync13(join19(forkPath, ".git"))) {
5441
5955
  throw new Error(`not a git repository: ${forkPath}`);
5442
5956
  }
5443
5957
  }
@@ -5455,7 +5969,7 @@ async function main() {
5455
5969
  const userConfig = await loadConfig(forkPath);
5456
5970
  const config = await parseCli(userConfig, forkPath);
5457
5971
  if (!["audit", "forks", "contributions", "stats"].includes(config.service)) {
5458
- await preflight(forkPath);
5972
+ preflight(forkPath);
5459
5973
  }
5460
5974
  switch (config.service) {
5461
5975
  case "analyze": {
@@ -5466,26 +5980,36 @@ async function main() {
5466
5980
  await runSyncCommand(config);
5467
5981
  break;
5468
5982
  }
5469
- case "migrate":
5983
+ case "migrate": {
5470
5984
  await runMigrate(config);
5471
5985
  break;
5472
- case "audit":
5473
- await runAudit(config, { force: config.force, checkOverrides: config.checkOverrides });
5986
+ }
5987
+ case "audit": {
5988
+ await runAudit(config);
5474
5989
  break;
5475
- case "forks":
5990
+ }
5991
+ case "forks": {
5476
5992
  await runForks(config);
5477
5993
  break;
5478
- case "contributions":
5994
+ }
5995
+ case "contributions": {
5479
5996
  await runContributions(config);
5480
5997
  break;
5481
- case "stats":
5482
- 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
+ }
5483
6006
  break;
6007
+ }
5484
6008
  }
5485
6009
  console.info();
5486
6010
  } catch (error) {
5487
6011
  console.error();
5488
- console.error(`${colors_default.red("\u2717")} ${error instanceof Error ? error.message : "unknown error"}`);
6012
+ console.error(`${colors_default.red("\u2717")} ${errorMessage(error)}`);
5489
6013
  process9.exit(1);
5490
6014
  }
5491
6015
  }