@cellajs/cli 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env tsx
2
2
 
3
3
  // src/cella-cli.ts
4
- import { existsSync as existsSync13, statSync as statSync3 } from "fs";
4
+ import { existsSync as existsSync14, statSync as statSync3 } from "fs";
5
5
  import { join as join19, resolve as resolve7 } from "path";
6
- import process9 from "process";
6
+ import process11 from "process";
7
7
 
8
8
  // src/cli.ts
9
- import process5 from "process";
9
+ import process6 from "process";
10
10
  import { select } from "@inquirer/prompts";
11
11
  import { Command } from "commander";
12
12
 
@@ -57,7 +57,7 @@ import { z as z2 } from "zod";
57
57
 
58
58
  // src/config/types.ts
59
59
  import { z } from "zod";
60
- var PACKAGE_JSON_SYNC_KEYS = [
60
+ var PACKAGE_JSON_KEYS = [
61
61
  "dependencies",
62
62
  "devDependencies",
63
63
  "peerDependencies",
@@ -75,7 +75,9 @@ var cellaConfigSchema = z.object({
75
75
  upstreamBranch: z.string().min(1).optional(),
76
76
  upstreamTrack: z.enum(["release", "branch"]).optional(),
77
77
  releaseBase: z.string().min(1).optional(),
78
- packageJsonSync: z.array(z.enum(PACKAGE_JSON_SYNC_KEYS)).optional(),
78
+ packageJsonKeys: z.array(z.enum(PACKAGE_JSON_KEYS)).optional(),
79
+ mergePackageJson: z.boolean().optional(),
80
+ packageJsonSync: z.array(z.enum(PACKAGE_JSON_KEYS)).optional(),
79
81
  syncWithPackages: z.boolean().optional(),
80
82
  fileLinkMode: z.enum(["commit", "file"]).optional()
81
83
  }).strict(),
@@ -156,13 +158,18 @@ function resolveAppModuleFolders(repoPath) {
156
158
  // src/utils/config.ts
157
159
  var DEFAULT_UPSTREAM_REMOTE = "cella-upstream";
158
160
  var DEFAULT_BRANCH = "main";
159
- var DEFAULT_SYNC_PREFIX = "cella/sync";
160
- var DEFAULT_PACKAGE_JSON_SYNC = ["dependencies", "devDependencies"];
161
+ var UPGRADE_BRANCH_PREFIX = "cella/upgrade";
162
+ var TEMPORARY_BRANCH_PREFIXES = [UPGRADE_BRANCH_PREFIX, "cella/sync"];
163
+ var DEFAULT_PACKAGE_JSON_KEYS = ["dependencies", "devDependencies"];
164
+ var RENAMED_SETTINGS = { packageJsonSync: "packageJsonKeys", syncWithPackages: "mergePackageJson" };
165
+ function renamedSettingWarnings(config) {
166
+ return Object.entries(RENAMED_SETTINGS).filter(([old]) => old in config.settings).map(([old, current]) => `settings.${old} is renamed to ${current}: rename it in ${CONFIG_FILE}`);
167
+ }
161
168
  function isUpstreamRepo(repoPath) {
162
169
  return JSON.parse(readFileSync(join2(repoPath, "package.json"), "utf8")).name === "cella";
163
170
  }
164
171
  function isTemporarySyncBranch(branch) {
165
- return branch.startsWith(`${DEFAULT_SYNC_PREFIX}/`);
172
+ return TEMPORARY_BRANCH_PREFIXES.some((prefix) => branch.startsWith(`${prefix}/`));
166
173
  }
167
174
  function resolveReleaseBase(settings) {
168
175
  return settings.releaseBase ?? DEFAULT_BRANCH;
@@ -171,7 +178,7 @@ function buildTemporarySyncBranch() {
171
178
  const now = /* @__PURE__ */ new Date();
172
179
  const pad2 = (n) => String(n).padStart(2, "0");
173
180
  const stamp = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}-${pad2(now.getHours())}${pad2(now.getMinutes())}`;
174
- return `${DEFAULT_SYNC_PREFIX}/${stamp}`;
181
+ return `${UPGRADE_BRANCH_PREFIX}/${stamp}`;
175
182
  }
176
183
  function resolveUpstream(settings) {
177
184
  const track = settings.upstreamTrack ?? "release";
@@ -201,6 +208,8 @@ async function loadConfig(forkPath) {
201
208
  ${z2.prettifyError(result.error)}`);
202
209
  }
203
210
  const config = result.data;
211
+ config.settings.packageJsonKeys ??= config.settings.packageJsonSync;
212
+ config.settings.mergePackageJson ??= config.settings.syncWithPackages;
204
213
  const appModuleFolders = resolveAppModuleFolders(realForkPath);
205
214
  if (appModuleFolders.length > 0) {
206
215
  const existing = config.overrides?.ignored ?? [];
@@ -213,16 +222,16 @@ ${z2.prettifyError(result.error)}`);
213
222
  }
214
223
 
215
224
  // src/utils/display.ts
216
- import { resolve as resolve2 } from "path";
217
- import process4 from "process";
225
+ import { resolve as resolve3 } from "path";
226
+ import process5 from "process";
218
227
 
219
228
  // package.json
220
229
  var package_default = {
221
230
  name: "@cellajs/cli",
222
- version: "0.4.2",
231
+ version: "0.6.0",
223
232
  private: false,
224
233
  license: "MIT",
225
- description: "CLI for managing your monorepo and syncing with upstream Cella.",
234
+ description: "CLI for managing your monorepo and upgrading it with upstream Cella.",
226
235
  repository: {
227
236
  type: "git",
228
237
  url: "https://github.com/cellajs/cella-cli"
@@ -334,377 +343,19 @@ var package_default = {
334
343
  200
335
344
  ],
336
345
  "footer-max-line-length": [
337
- 0,
338
- "always",
339
- 200
340
- ]
341
- }
342
- }
343
- };
344
-
345
- // src/utils/display.ts
346
- var VERSION = package_default.version;
347
- var DIVIDER = "\u2500".repeat(60);
348
- var MENU_DIVIDER = "\u2500".repeat(40);
349
- var COMMIT_LIST_MAX = 50;
350
- var warningMark = colors_default.yellow("\u26A0");
351
- var checkMark = colors_default.green("\u2713");
352
- var activeSpinner = null;
353
- var jsonMode = false;
354
- function setJsonMode(enabled) {
355
- jsonMode = enabled;
356
- if (!enabled) return;
357
- const toStderr = (...args) => {
358
- process4.stderr.write(`${args.map((a) => typeof a === "string" ? a : String(a)).join(" ")}
359
- `);
360
- };
361
- console.info = toStderr;
362
- console.warn = toStderr;
363
- }
364
- function writeStdout(text) {
365
- process4.stdout.write(text.endsWith("\n") ? text : `${text}
366
- `);
367
- }
368
- function printHeader() {
369
- const left = "\u29C8 cella cli";
370
- const right = "cellajs.com";
371
- const padding = Math.max(1, 60 - `${left} v${VERSION}`.length - right.length);
372
- console.info();
373
- console.info(`${colors_default.cyan(left)}${colors_default.dim(` \xB7 v${VERSION}`)}${colors_default.cyan(`${" ".repeat(padding)}${right}`)}`);
374
- console.info(DIVIDER);
375
- console.info();
376
- }
377
- function createSpinner(text) {
378
- const enabled = !jsonMode && !getEnv("VITEST") && getEnv("NODE_ENV") !== "test" && !!process4.stdout.isTTY;
379
- const frames = ["-", "\\", "|", "/"];
380
- let frame = 0;
381
- let timer;
382
- const spinner = {
383
- text,
384
- stop() {
385
- clearInterval(timer);
386
- if (enabled) process4.stdout.write("\r\x1B[2K");
387
- }
388
- };
389
- const render = () => process4.stdout.write(`\r${colors_default.cyan(frames[frame++ % frames.length])} ${spinner.text}`);
390
- if (enabled) {
391
- render();
392
- timer = setInterval(render, 80);
393
- }
394
- activeSpinner = spinner;
395
- return spinner;
396
- }
397
- function spinnerSuccess(message, detail) {
398
- stopSpinner();
399
- if (!message) return;
400
- console.info(`${checkMark} ${message}`);
401
- if (detail) {
402
- console.info(` ${colors_default.dim(detail)}`);
403
- console.info();
404
- }
405
- }
406
- function spinnerFail(message) {
407
- stopSpinner();
408
- console.info(`${colors_default.red("\u2717")} ${message}`);
409
- }
410
- function spinnerText(text) {
411
- if (activeSpinner) {
412
- activeSpinner.text = text;
413
- }
414
- }
415
- function stopSpinner() {
416
- if (activeSpinner) {
417
- activeSpinner.stop();
418
- activeSpinner = null;
419
- }
420
- }
421
- function hyperlink(label, url) {
422
- if (!url) return label;
423
- const safeUrl = url.replace(/[\x00-\x1f\x7f]/g, "");
424
- return `\x1B]8;;${safeUrl}\x07${label}\x1B]8;;\x07`;
425
- }
426
- function formatFetchedUpstreamDetail(commitCount, commits, upstreamGitHubUrl, maxShownCommits = COMMIT_LIST_MAX) {
427
- const lines = [`${plural(commitCount, "new commit")} since last merge`];
428
- const shown = commits.slice(0, maxShownCommits);
429
- for (const commit of shown) {
430
- const shortHash = commit.hash.slice(0, 7);
431
- const commitUrl = upstreamGitHubUrl ? `${upstreamGitHubUrl}/commit/${commit.hash}` : void 0;
432
- const hashLabel = hyperlink(shortHash, commitUrl);
433
- lines.push(` ${hashLabel} "${commit.message}" (${commit.date})`);
434
- }
435
- if (commitCount > shown.length) {
436
- lines.push(` ... and ${commitCount - shown.length} more`);
437
- }
438
- return lines.join("\n");
439
- }
440
- function printSectionHeader(title) {
441
- console.info();
442
- console.info(title);
443
- console.info(DIVIDER);
444
- console.info();
445
- }
446
- function formatFileDateInfo(filePath, commit, date, options) {
447
- if (!date) return "";
448
- const { upstreamGitHubUrl: url, upstreamBranch, fileLinkMode = "commit" } = options;
449
- let link = commit?.slice(0, 9) ?? "";
450
- if (url && fileLinkMode === "file" && upstreamBranch) {
451
- link = hyperlink(filePath.split("/").pop() || filePath, `${url}/blob/${upstreamBranch}/${filePath}`);
452
- } else if (url) {
453
- link = commit ? hyperlink(link, `${url}/commit/${commit}`) : "";
454
- }
455
- return colors_default.dim(` \u2260 ${date} ${link}`);
456
- }
457
- function formatMergeInProgressDetail(conflictCount, autoMergedFiles, linkOptions, maxFiles = 100) {
458
- const lines = [`${conflictCount} conflicts to resolve in IDE`];
459
- if (autoMergedFiles.length === 0) {
460
- return lines.join("\n");
461
- }
462
- lines.push("");
463
- lines.push(` auto-merged files (${autoMergedFiles.length}):`);
464
- const { forkPath } = linkOptions;
465
- const shown = autoMergedFiles.slice(0, maxFiles);
466
- for (const filePath of shown) {
467
- lines.push(` - ${forkPath ? hyperlink(filePath, `vscode://file${resolve2(forkPath, filePath)}`) : filePath}`);
468
- }
469
- if (autoMergedFiles.length > shown.length) {
470
- lines.push(` ... + ${autoMergedFiles.length - shown.length} more`);
471
- }
472
- return lines.join("\n");
473
- }
474
- var statusConfig = {
475
- behind: { icon: colors_default.cyan("\u2193"), label: "behind", color: colors_default.cyan, description: "upstream changed, will sync" },
476
- diverged: { icon: colors_default.magenta("\u21C5"), label: "diverged", color: colors_default.magenta, description: "both changed, will merge" },
477
- drifted: { icon: colors_default.yellow("!"), label: "drifted", color: colors_default.yellow, description: "fork changed (at risk)" },
478
- ahead: { icon: colors_default.blue("\u2191"), label: "ahead", color: colors_default.blue, description: "fork changed (protected)" },
479
- local: { icon: colors_default.green("+"), label: "local", color: colors_default.green, description: "local file in cella folders" },
480
- pinned: { icon: colors_default.green("\u2A00"), label: "pinned", color: colors_default.green, description: "both changed, fork wins" },
481
- ignored: { icon: colors_default.gray("\u2A02"), label: "ignored", color: colors_default.gray },
482
- identical: { icon: colors_default.gray("\u2713"), label: "identical", color: colors_default.gray, description: "no changes" },
483
- deleted: { icon: colors_default.red("\u2717"), label: "deleted", color: colors_default.red },
484
- renamed: { icon: colors_default.blue("\u2192"), label: "renamed", color: colors_default.blue }
485
- };
486
- var summaryStatusConfig = {
487
- ...statusConfig,
488
- managed: {
489
- icon: colors_default.cyan("\u25C7"),
490
- label: "managed",
491
- color: colors_default.cyan,
492
- description: "package.jsons & cella/cella.config.ts handled separately"
493
- },
494
- ignored: { ...statusConfig.ignored, description: "protected by ignored config" }
495
- };
496
- function printSummary(summary, title = "summary") {
497
- printSectionHeader(colors_default.cyan(title));
498
- const maxCount = Math.max(...Object.values(summary));
499
- const countWidth = String(maxCount).length + 1;
500
- const groups = [
501
- ["managed", "ignored"],
502
- ["identical"],
503
- ["ahead", "local", "drifted"],
504
- ["diverged", "pinned"],
505
- ["behind"]
506
- ];
507
- for (const group of groups) {
508
- if (group !== groups[0]) console.info();
509
- for (const status of group) {
510
- const { icon, label, color, description } = summaryStatusConfig[status];
511
- const countStr = color(String(summary[status]).padStart(countWidth));
512
- console.info(` ${icon} ${countStr} ${label.padEnd(15)}${description ? colors_default.dim(description) : ""}`);
513
- }
514
- }
515
- }
516
- function printFileSection(files, title, linkOptions, options) {
517
- if (files.length === 0) return;
518
- printSectionHeader(`${title} ${colors_default.dim(`\xB7 ${files.length} files`)}`);
519
- const useUpstream = options.dateSource === "upstream";
520
- const maxLines = 100;
521
- const shown = files.length > maxLines ? files.slice(0, maxLines) : files;
522
- for (const file of shown) {
523
- const icon = typeof options.icon === "string" ? options.icon : options.icon(file);
524
- const commit = useUpstream ? file.upstreamCommit : file.changedCommit;
525
- const date = useUpstream ? file.upstreamChangedAt : file.changedAt;
526
- const dateInfo = formatFileDateInfo(file.path, commit, date, linkOptions);
527
- console.info(` ${icon} ${file.path}${dateInfo}${options.suffix?.(file) ?? ""}`);
528
- }
529
- if (files.length > maxLines) {
530
- console.info(colors_default.dim(` ... + ${files.length - maxLines} more`));
531
- }
532
- if (options.hint) {
533
- console.info();
534
- for (const line of Array.isArray(options.hint) ? options.hint : [options.hint]) {
535
- console.info(colors_default.dim(` ${line}`));
536
- }
537
- }
538
- }
539
- function filesWithStatus(files, status) {
540
- return files.filter((f) => f.status === status && !isManagedFile(f.path));
541
- }
542
- function printFileGroup(files, status, linkOptions, options) {
543
- const config = statusConfig[status];
544
- const title = options?.title ?? `${config.icon} ${config.label}`;
545
- printFileSection(filesWithStatus(files, status), title, linkOptions, { ...options, icon: config.icon });
546
- }
547
- function findProtectedBehind(files) {
548
- return files.filter((file) => file.upstreamChanged && !isManagedFile(file.path));
549
- }
550
- var PROTECTED_BEHIND_HINT = "pinned/ignored files where upstream also changed since the last sync; the fork side wins on conflict, so review what upstream changed and adopt what you need.";
551
- function protectionIcon(file) {
552
- return file.isIgnored ? statusConfig.ignored.icon : statusConfig.pinned.icon;
553
- }
554
- function formatUpstreamChangedLines(file) {
555
- if (file.upstreamChangedLines === void 0) return "";
556
- return colors_default.yellow(` \xB7 ${plural(file.upstreamChangedLines, "line")} changed upstream`);
557
- }
558
- function formatUpstreamLinesAbsent(file) {
559
- const n = file.upstreamLinesAbsent ?? 0;
560
- return n > 0 ? colors_default.yellow(` \xB7 ${plural(n, "upstream line")} absent`) : "";
561
- }
562
- function printAnalysisFileGroups(files, linkOptions) {
563
- const protectedBehind = findProtectedBehind(files);
564
- printFileGroup(files, "behind", linkOptions, {
565
- title: colors_default.cyan("\u2193 behind on upstream")
566
- });
567
- printFileGroup(files, "ahead", linkOptions, {
568
- title: `${colors_default.blue("\u2191 protected in fork")}`,
569
- suffix: formatUpstreamLinesAbsent,
570
- hint: [
571
- "these files have fork changes but are protected (pinned); upstream did not change them since the last sync.",
572
- "pinned files keep the fork copy whole; a count here is upstream content the fork never received, deliberately or not: `analyze --unpinned` shows what a merge would bring in."
573
- ]
574
- });
575
- printFileSection(protectedBehind, `${warningMark} ${colors_default.yellow("protected but behind upstream")}`, linkOptions, {
576
- icon: protectionIcon,
577
- suffix: formatUpstreamChangedLines,
578
- dateSource: "upstream",
579
- hint: PROTECTED_BEHIND_HINT
580
- });
581
- printFileGroup(files, "drifted", linkOptions, {
582
- title: `${warningMark} ${colors_default.yellow("drifted from upstream")}`,
583
- hint: "these files have fork changes but are not pinned or ignored."
584
- });
585
- printFileGroup(files, "diverged", linkOptions, {
586
- title: `${colors_default.magenta("\u21C5 diverged")}`,
587
- hint: "both fork and upstream changed.",
588
- dateSource: "upstream"
589
- });
590
- }
591
- function printSyncComplete(result, options = {}) {
592
- const updated = result.files.filter((file) => file.status === "behind" || file.status === "diverged").length;
593
- const diverged = result.files.filter((file) => file.status === "diverged").length;
594
- const merged = result.autoMergedFiles?.length ?? Math.max(0, diverged - result.conflicts.length);
595
- const conflicts = result.conflicts.length;
596
- console.info();
597
- if (options.stagedBranch) {
598
- console.info(`${checkMark} sync merge staged on '${options.stagedBranch}'`);
599
- console.info(
600
- colors_default.dim(` ${updated} files updated, ${merged} auto-merged, ${conflicts} conflicts. committing next...`)
601
- );
602
- } else {
603
- console.info(`${checkMark} sync complete`);
604
- console.info(colors_default.dim(` ${updated} files updated, ${merged} auto-merged, ${conflicts} conflicts`));
605
- }
606
- printProtectedConflicts(result);
607
- console.info();
608
- }
609
- function printProtectedConflicts(result) {
610
- const paths = result.protectedConflicts ?? [];
611
- if (paths.length === 0) return;
612
- const byPath = new Map(result.files.map((file) => [file.path, file]));
613
- console.info();
614
- console.info(
615
- `${warningMark} ${colors_default.yellow(
616
- `${plural(paths.length, "protected file kept", "protected files kept")} the fork version, dropping upstream changes:`
617
- )}`
618
- );
619
- for (const path of paths) {
620
- const file = byPath.get(path);
621
- const icon = file ? protectionIcon(file) : statusConfig.pinned.icon;
622
- console.info(` ${icon} ${path}${file ? formatUpstreamChangedLines(file) : ""}`);
623
- }
624
- console.info(colors_default.dim(` ${PROTECTED_BEHIND_HINT}`));
625
- }
626
- function plural(count3, singular, pluralForm = `${singular}s`) {
627
- return `${count3} ${count3 === 1 ? singular : pluralForm}`;
628
- }
629
- function printIgnoredUpstreamChanges(result) {
630
- const groups = result.ignoredUpstreamChanges ?? [];
631
- if (groups.length === 0) return;
632
- const total = groups.reduce((count3, group) => count3 + group.paths.length, 0);
633
- console.info();
634
- console.info(
635
- `${warningMark} ${colors_default.yellow(`ignored paths changed upstream \xB7 ${plural(total, "file")} under ${plural(groups.length, "entry", "entries")}`)}`
636
- );
637
- for (const group of groups) {
638
- const counts = [plural(group.paths.length, "file")];
639
- if (group.added > 0) counts.push(`${group.added} new`);
640
- if (group.deleted > 0) counts.push(`${group.deleted} deleted`);
641
- console.info(` ${statusConfig.ignored.icon} ${group.entry} ${colors_default.dim(`\xB7 ${counts.join(", ")}`)}`);
642
- }
643
- console.info(colors_default.dim(" the fork left these untouched and ignored paths never sync: review and adopt what you need."));
644
- }
645
- function printUpstreamOverrideChanges(result) {
646
- const report = result.upstreamOverrides;
647
- if (!report) return;
648
- console.info();
649
- if (report.kind === "unreadable") {
650
- const side = report.side === "base" ? "the last sync point" : "the incoming ref";
651
- console.info(
652
- colors_default.dim(`upstream sync config not compared: upstream ${CONFIG_FILE} is missing or unreadable at ${side}`)
653
- );
654
- return;
655
- }
656
- const lines = [];
657
- for (const list of ["pinned", "ignored", "packageJsonSync"]) {
658
- for (const entry of report[list].added) lines.push(` ${colors_default.green("+")} ${list}: ${entry}`);
659
- for (const entry of report[list].removed) {
660
- lines.push(` ${colors_default.red("\u2212")} ${list}: ${entry}${colors_default.dim(" \xB7 dropped upstream, still in your config")}`);
661
- }
662
- }
663
- console.info(
664
- `${warningMark} ${colors_default.yellow(`upstream changed its sync config \xB7 ${plural(lines.length, "entry", "entries")} to review`)}`
665
- );
666
- for (const line of lines) console.info(line);
667
- console.info(colors_default.dim(` ${CONFIG_FILE} never syncs: add or drop these by hand where they fit your app.`));
668
- }
669
- function findMaskingPins(files) {
670
- return files.filter(
671
- (file) => file.isPinned && !isManagedFile(file.path) && file.status === "behind" && file.existsInFork && file.existsInUpstream
672
- );
673
- }
674
- function printMaskingPinWarning(files) {
675
- const masking = findMaskingPins(files);
676
- if (masking.length === 0) return;
677
- console.info();
678
- console.info(
679
- `${warningMark} ${colors_default.yellow(
680
- `${plural(masking.length, "pinned file matches", "pinned files match")} the previous upstream but changed upstream:`
681
- )}`
682
- );
683
- console.info(colors_default.yellow(" the pin keeps the old fork copy and silently drops those upstream changes:"));
684
- for (const file of masking) {
685
- console.info(colors_default.dim(` \u2A00 ${file.path}`));
686
- }
687
- console.info(
688
- colors_default.yellow(" if the fork never customized these, unpin them in cella/cella.config.ts to take upstream.")
689
- );
690
- console.info();
691
- }
692
- function printEngineReports(result, title) {
693
- printSummary(result.summary, title);
694
- printIgnoredUpstreamChanges(result);
695
- printUpstreamOverrideChanges(result);
696
- printMaskingPinWarning(result.files);
697
- }
698
-
699
- // src/utils/overrides.ts
700
- import { existsSync as existsSync5 } from "fs";
701
- import { join as join6 } from "path";
346
+ 0,
347
+ "always",
348
+ 200
349
+ ]
350
+ }
351
+ }
352
+ };
702
353
 
703
354
  // src/utils/git.ts
704
355
  import { execFile } from "child_process";
705
356
  import { existsSync as existsSync3 } from "fs";
706
357
  import { mkdir as mkdir2, readdir, rmdir, unlink } from "fs/promises";
707
- import { dirname as dirname2, join as join4, resolve as resolve3 } from "path";
358
+ import { dirname as dirname2, join as join4, resolve as resolve2 } from "path";
708
359
  import { promisify } from "util";
709
360
 
710
361
  // src/utils/manifest.ts
@@ -840,7 +491,7 @@ async function commitSquash(cwd, message) {
840
491
  await git(["commit", "-m", message], cwd, { skipEditor: true });
841
492
  }
842
493
  async function gitPath(cwd, file) {
843
- return resolve3(cwd, await git(["rev-parse", "--git-path", file], cwd));
494
+ return resolve2(cwd, await git(["rev-parse", "--git-path", file], cwd));
844
495
  }
845
496
  async function stageAll(cwd) {
846
497
  await git(["add", "-A"], cwd);
@@ -848,6 +499,10 @@ async function stageAll(cwd) {
848
499
  async function mergeInProgress(cwd) {
849
500
  return await revParse(cwd, "MERGE_HEAD") !== null;
850
501
  }
502
+ function splitPrNumber(subject) {
503
+ const match = /\s*\(#(\d+)\)\s*$/.exec(subject);
504
+ return match ? { subject: subject.slice(0, match.index), pr: Number(match[1]) } : { subject };
505
+ }
851
506
  async function getShortSha(cwd, ref) {
852
507
  return git(["rev-parse", "--short", ref], cwd);
853
508
  }
@@ -920,16 +575,16 @@ async function countCommitsBetween(cwd, fromRef, toRef) {
920
575
  return Number.parseInt(output.trim(), 10) || 0;
921
576
  }
922
577
  async function listCommitsBetween(cwd, fromRef, toRef, limit) {
923
- const args = ["log", "--format=%H%x1f%s%x1f%ar", "--reverse", `${fromRef}..${toRef}`];
578
+ const args = ["log", "--format=%H%x1f%s%x1f%ar%x1f%ct", "--reverse", `${fromRef}..${toRef}`];
924
579
  if (limit) args.push(`-n${limit}`);
925
580
  const output = await git(args, cwd, { ignoreErrors: true });
926
581
  if (!output) return [];
927
582
  const commits = [];
928
583
  for (const line of output.split("\n")) {
929
584
  if (!line) continue;
930
- const [hash, message, date] = line.split("");
585
+ const [hash, message, date, timestamp] = line.split("");
931
586
  if (!hash || !message || !date) continue;
932
- commits.push({ hash, message, date });
587
+ commits.push({ hash, message, date, timestamp: Number(timestamp) || void 0 });
933
588
  }
934
589
  return commits;
935
590
  }
@@ -1100,16 +755,25 @@ async function cleanupEmptyParentDirs(cwd, relativePath) {
1100
755
  } catch {
1101
756
  }
1102
757
  }
758
+ var LAST_UPGRADE_REF = "refs/cella/last-upgrade";
759
+ var LEGACY_LAST_UPGRADE_REF = "refs/cella/last-sync";
1103
760
  async function storeLastSyncRef(cwd, upstreamHash) {
1104
- await git(["update-ref", "refs/cella/last-sync", upstreamHash], cwd);
761
+ await git(["update-ref", LAST_UPGRADE_REF, upstreamHash], cwd);
1105
762
  const head = await revParse(cwd, "HEAD");
1106
- if (head) await git(["update-ref", "refs/cella/last-sync-head", head], cwd);
763
+ if (head) await git(["update-ref", `${LAST_UPGRADE_REF}-head`, head], cwd);
1107
764
  }
1108
765
  async function stagePath(cwd, path) {
1109
766
  await git(["add", "--", path], cwd, { ignoreErrors: true });
1110
767
  }
768
+ async function readStoredSyncRefs(cwd) {
769
+ for (const name of [LAST_UPGRADE_REF, LEGACY_LAST_UPGRADE_REF]) {
770
+ const ref = await revParse(cwd, name);
771
+ if (ref) return { ref, head: await revParse(cwd, `${name}-head`) };
772
+ }
773
+ return null;
774
+ }
1111
775
  async function getStoredSyncRef(cwd) {
1112
- return revParse(cwd, "refs/cella/last-sync");
776
+ return (await readStoredSyncRefs(cwd))?.ref ?? null;
1113
777
  }
1114
778
  async function readManifestAtRef(cwd, ref) {
1115
779
  const raw = await git(["show", `${ref}:${MANIFEST_FILE}`], cwd, { ignoreErrors: true });
@@ -1158,11 +822,11 @@ async function unshallowRepository(cwd) {
1158
822
  ${error.message}` : "";
1159
823
  throw new Error(
1160
824
  `this is a shallow clone and its full history could not be restored automatically.
1161
- Sync needs the complete history to find the common ancestor with upstream. Restore it with:
825
+ An upgrade needs the complete history to find the common ancestor with upstream. Restore it with:
1162
826
 
1163
827
  git fetch --unshallow
1164
828
 
1165
- then re-run sync.${detail}`
829
+ then re-run the upgrade.${detail}`
1166
830
  );
1167
831
  }
1168
832
  }
@@ -1188,20 +852,20 @@ async function ensureSyncBase(cwd, headRef, upstreamRef) {
1188
852
  }
1189
853
  if (!baseSha) {
1190
854
  throw new Error(
1191
- `no common ancestor between the fork and '${upstreamRef}', and no sync base could be determined.
855
+ `no common ancestor between the fork and '${upstreamRef}', and no upgrade base could be determined.
1192
856
 
1193
857
  This fork has unrelated history with upstream (typical for a create-cella scaffold, or after
1194
858
  upstream squashed its history) and no recorded base commit exists after fetching
1195
- upstream. Seed it manually with the upstream commit the fork was created from, then re-run sync:
859
+ upstream. Seed it manually with the upstream commit the fork was created from, then re-run the upgrade:
1196
860
 
1197
- git update-ref refs/cella/last-sync <upstream-sha>
861
+ git update-ref ${LAST_UPGRADE_REF} <upstream-sha>
1198
862
  `
1199
863
  );
1200
864
  }
1201
865
  const root = await getRootCommit(cwd, headRef);
1202
- if (!root) throw new Error("could not determine the fork's root commit to bootstrap sync ancestry");
866
+ if (!root) throw new Error("could not determine the fork's root commit to bootstrap upgrade ancestry");
1203
867
  await git(["replace", "-f", "--graft", root, baseSha], cwd);
1204
- await git(["update-ref", "refs/cella/last-sync", baseSha], cwd);
868
+ await git(["update-ref", LAST_UPGRADE_REF, baseSha], cwd);
1205
869
  }
1206
870
  async function isAncestor(cwd, ancestor, descendant) {
1207
871
  try {
@@ -1212,14 +876,13 @@ async function isAncestor(cwd, ancestor, descendant) {
1212
876
  }
1213
877
  }
1214
878
  async function getLegacySyncRef(cwd, headRef) {
1215
- const storedRef = await getStoredSyncRef(cwd);
1216
- if (!storedRef) return null;
1217
- const storedHead = await revParse(cwd, "refs/cella/last-sync-head");
1218
- if (storedHead) {
879
+ const stored = await readStoredSyncRefs(cwd);
880
+ if (!stored) return null;
881
+ if (stored.head) {
1219
882
  const currentHead = await revParse(cwd, headRef);
1220
- if (currentHead && currentHead === storedHead) return null;
883
+ if (currentHead && currentHead === stored.head) return null;
1221
884
  }
1222
- return storedRef;
885
+ return stored.ref;
1223
886
  }
1224
887
  async function getEffectiveMergeBase(cwd, headRef, upstreamRef) {
1225
888
  const gitBase = await getMergeBase(cwd, headRef, upstreamRef);
@@ -1231,36 +894,432 @@ async function getEffectiveMergeBase(cwd, headRef, upstreamRef) {
1231
894
  if (recordedIsNewer && recorded !== gitBase) {
1232
895
  return { base: recorded, isStale: true, storedRef: recorded };
1233
896
  }
1234
- return { base: gitBase, isStale: false, storedRef: recorded };
897
+ return { base: gitBase, isStale: false, storedRef: recorded };
898
+ }
899
+ async function withTemporarySyncBaseGraft(cwd, headRef, baseSha, fn) {
900
+ const headSha = await revParse(cwd, headRef);
901
+ if (!headSha || !await commitObjectExists(cwd, baseSha)) return fn();
902
+ if (await isAncestor(cwd, baseSha, headSha)) return fn();
903
+ const existingReplacement = await git(["replace", "-l", headSha], cwd, { ignoreErrors: true });
904
+ if (existingReplacement) return fn();
905
+ const parentsRaw = await git(["log", "-1", "--format=%P", headSha], cwd, { ignoreErrors: true });
906
+ const parents = parentsRaw ? parentsRaw.split(/\s+/) : [];
907
+ await git(["replace", "-f", "--graft", headSha, ...parents, baseSha], cwd);
908
+ try {
909
+ return await fn();
910
+ } finally {
911
+ await git(["replace", "-d", headSha], cwd, { ignoreErrors: true });
912
+ }
913
+ }
914
+
915
+ // src/utils/layout.ts
916
+ import process4 from "process";
917
+ import { stripVTControlCharacters } from "util";
918
+ function terminalColumns() {
919
+ const columns = process4.stdout.isTTY ? process4.stdout.columns : void 0;
920
+ return Math.max(60, columns || 80);
921
+ }
922
+ function visibleWidth(text) {
923
+ return stripVTControlCharacters(text).length;
924
+ }
925
+ function padVisible(text, width, align = "start") {
926
+ const padding = " ".repeat(Math.max(0, width - visibleWidth(text)));
927
+ return align === "start" ? `${text}${padding}` : `${padding}${text}`;
928
+ }
929
+ function fitText(text, width) {
930
+ if (text.length <= width) return text;
931
+ return `${text.slice(0, Math.max(0, width - 1)).trimEnd()}\u2026`;
932
+ }
933
+ var AGE_STEPS = [
934
+ [90 * 60, 60, "m"],
935
+ [36 * 3600, 3600, "h"],
936
+ [14 * 86400, 86400, "d"],
937
+ [70 * 86400, 7 * 86400, "w"],
938
+ [365 * 86400, 30 * 86400, "mo"]
939
+ ];
940
+ function shortAge(epochSeconds, nowMs = Date.now()) {
941
+ const seconds = Math.max(0, Math.round(nowMs / 1e3 - epochSeconds));
942
+ if (seconds < 60) return "now";
943
+ for (const [below, unit, suffix] of AGE_STEPS) {
944
+ if (seconds < below) return `${Math.max(1, Math.round(seconds / unit))}${suffix}`;
945
+ }
946
+ return `${Math.floor(seconds / (365 * 86400))}y`;
947
+ }
948
+ function compactCount(count3) {
949
+ if (count3 < 1e4) return String(count3);
950
+ if (count3 < 1e6) return `${(count3 / 1e3).toFixed(1)}k`;
951
+ return `${(count3 / 1e6).toFixed(1)}M`;
952
+ }
953
+
954
+ // src/utils/display.ts
955
+ var VERSION = package_default.version;
956
+ var DIVIDER = "\u2500".repeat(60);
957
+ var MENU_DIVIDER = "\u2500".repeat(40);
958
+ var COMMIT_LIST_MAX = 50;
959
+ var COMMIT_STEP_MAX = 10;
960
+ var STEP_LABEL_WIDTH = 9;
961
+ var STEP_INDENT = 2 + STEP_LABEL_WIDTH + 2;
962
+ var warningMark = colors_default.yellow("\u26A0");
963
+ var checkMark = colors_default.green("\u2713");
964
+ var activeSpinner = null;
965
+ var jsonMode = false;
966
+ function setJsonMode(enabled) {
967
+ jsonMode = enabled;
968
+ if (!enabled) return;
969
+ const toStderr = (...args) => {
970
+ process5.stderr.write(`${args.map((a) => typeof a === "string" ? a : String(a)).join(" ")}
971
+ `);
972
+ };
973
+ console.info = toStderr;
974
+ console.warn = toStderr;
975
+ }
976
+ function writeStdout(text) {
977
+ process5.stdout.write(text.endsWith("\n") ? text : `${text}
978
+ `);
979
+ }
980
+ function printHeader() {
981
+ const left = "\u29C8 cella cli";
982
+ const version = ` \xB7 v${VERSION}`;
983
+ const right = "cellajs.com";
984
+ const padding = Math.max(1, DIVIDER.length - `${left}${version}${right}`.length);
985
+ console.info();
986
+ console.info(`${colors_default.cyan(left)}${colors_default.dim(version)}${colors_default.cyan(`${" ".repeat(padding)}${right}`)}`);
987
+ console.info(DIVIDER);
988
+ console.info();
989
+ }
990
+ function createSpinner(text) {
991
+ const enabled = !jsonMode && !getEnv("VITEST") && getEnv("NODE_ENV") !== "test" && !!process5.stdout.isTTY;
992
+ const frames = ["-", "\\", "|", "/"];
993
+ let frame = 0;
994
+ let timer;
995
+ const spinner = {
996
+ text,
997
+ stop() {
998
+ clearInterval(timer);
999
+ if (enabled) process5.stdout.write("\r\x1B[2K");
1000
+ }
1001
+ };
1002
+ const render = () => process5.stdout.write(`\r${colors_default.cyan(frames[frame++ % frames.length])} ${spinner.text}`);
1003
+ if (enabled) {
1004
+ render();
1005
+ timer = setInterval(render, 80);
1006
+ }
1007
+ activeSpinner = spinner;
1008
+ return spinner;
1009
+ }
1010
+ function printStep(label, detail, mark = checkMark) {
1011
+ stopSpinner();
1012
+ const [first = "", ...rest] = (detail ?? "").split("\n");
1013
+ console.info(`${mark} ${colors_default.bold(label.padEnd(STEP_LABEL_WIDTH))} ${first}`.trimEnd());
1014
+ for (const line of rest) console.info(line ? `${" ".repeat(STEP_INDENT)}${line}` : "");
1015
+ }
1016
+ function spinnerSuccess(message, detail) {
1017
+ stopSpinner();
1018
+ if (!message) return;
1019
+ console.info(`${checkMark} ${message}`);
1020
+ if (detail) {
1021
+ console.info(` ${colors_default.dim(detail)}`);
1022
+ console.info();
1023
+ }
1024
+ }
1025
+ function spinnerFail(message) {
1026
+ stopSpinner();
1027
+ console.info(`${colors_default.red("\u2717")} ${message}`);
1028
+ }
1029
+ function spinnerText(text) {
1030
+ if (activeSpinner) {
1031
+ activeSpinner.text = text;
1032
+ }
1033
+ }
1034
+ function stopSpinner() {
1035
+ if (activeSpinner) {
1036
+ activeSpinner.stop();
1037
+ activeSpinner = null;
1038
+ }
1039
+ }
1040
+ function hyperlink(label, url) {
1041
+ if (!url) return label;
1042
+ const safeUrl = url.replace(/[\x00-\x1f\x7f]/g, "");
1043
+ return `\x1B]8;;${safeUrl}\x07${label}\x1B]8;;\x07`;
1044
+ }
1045
+ function styleSubject(subject) {
1046
+ if (/^chore(\([^)]*\))?: release\b/.test(subject)) return colors_default.dim(subject);
1047
+ const breaking = /^([a-z]+(\([^)]*\))?!:)/.exec(subject);
1048
+ return breaking ? `${colors_default.yellow(breaking[1])}${subject.slice(breaking[1].length)}` : subject;
1049
+ }
1050
+ function formatCheckoutStep(checkout) {
1051
+ const { forkPath, branch, headSha, changeCount, upstream } = checkout;
1052
+ const parts = [
1053
+ changeCount === 0 ? "clean" : `${plural(changeCount, "uncommitted change")}, not included in the analysis`
1054
+ ];
1055
+ if (upstream.upstream && (upstream.ahead > 0 || upstream.behind > 0)) {
1056
+ parts.push(`${upstream.ahead} ahead, ${upstream.behind} behind ${upstream.upstream}`);
1057
+ }
1058
+ const line = `${branch} @ ${headSha}${colors_default.dim(` \xB7 ${parts.join(" \xB7 ")}`)}`;
1059
+ return resolve3(forkPath) === resolve3(process5.cwd()) ? line : `${line}
1060
+ ${colors_default.dim(forkPath)}`;
1061
+ }
1062
+ function formatUpstreamStep(upstream) {
1063
+ const { label, sha, baseSha, pinned, commitCount, commits, githubUrl } = upstream;
1064
+ const since = commitCount === 0 ? "no new commits since the last upgrade" : `${plural(commitCount, "new commit")} since the last upgrade at ${baseSha}`;
1065
+ return [
1066
+ `${label} @ ${sha}${colors_default.dim(`${pinned ? " \xB7 --ref" : ""} \xB7 ${since}`)}`,
1067
+ ...formatUpstreamCommits(commitCount, commits, githubUrl)
1068
+ ].join("\n");
1069
+ }
1070
+ function formatUpstreamCommits(commitCount, commits, upstreamGitHubUrl, options = {}) {
1071
+ const { columns = terminalColumns(), max = COMMIT_STEP_MAX, nowMs } = options;
1072
+ const shown = commits.slice(-max).reverse();
1073
+ const rows = shown.map((commit) => ({ commit, ...splitPrNumber(commit.message) }));
1074
+ const prWidth = Math.max(0, ...rows.map((row) => row.pr ? `#${row.pr}`.length : 0));
1075
+ const ages = rows.map((row) => row.commit.timestamp ? shortAge(row.commit.timestamp, nowMs) : "");
1076
+ const ageWidth = Math.max(0, ...ages.map((age) => age.length));
1077
+ const subjectWidth = Math.max(
1078
+ 20,
1079
+ columns - STEP_INDENT - 7 - 2 - (prWidth ? prWidth + 2 : 0) - (ageWidth ? ageWidth + 2 : 0)
1080
+ );
1081
+ const lines = rows.map((row, index) => {
1082
+ const hash = hyperlink(
1083
+ row.commit.hash.slice(0, 7),
1084
+ upstreamGitHubUrl && `${upstreamGitHubUrl}/commit/${row.commit.hash}`
1085
+ );
1086
+ const subject = fitText(row.subject, subjectWidth);
1087
+ let line = `${colors_default.dim(hash)} ${padVisible(styleSubject(subject), subjectWidth)}`;
1088
+ if (prWidth) {
1089
+ const pr = row.pr ? hyperlink(`#${row.pr}`, upstreamGitHubUrl && `${upstreamGitHubUrl}/pull/${row.pr}`) : "";
1090
+ line += ` ${colors_default.dim(padVisible(pr, prWidth))}`;
1091
+ }
1092
+ if (ageWidth) line += ` ${colors_default.dim(ages[index].padStart(ageWidth))}`;
1093
+ return line.trimEnd();
1094
+ });
1095
+ if (commitCount > shown.length) lines.push(colors_default.dim(`+ ${commitCount - shown.length} earlier`));
1096
+ return lines;
1097
+ }
1098
+ function printNextSteps(steps) {
1099
+ if (steps.length === 0) return;
1100
+ printSectionHeader(colors_default.cyan("next steps"));
1101
+ for (const [index, step] of steps.entries()) {
1102
+ const label = step.label.padEnd(STEP_INDENT - 5);
1103
+ console.info(` ${colors_default.dim(String(index + 1))} ${colors_default.bold(label)} ${step.line}`.trimEnd());
1104
+ for (const detail of step.details ?? []) console.info(`${" ".repeat(STEP_INDENT)}${detail}`);
1105
+ }
1106
+ }
1107
+ function printSectionHeader(title) {
1108
+ console.info();
1109
+ console.info(title);
1110
+ console.info(DIVIDER);
1111
+ console.info();
1112
+ }
1113
+ function formatMergeInProgressDetail(conflictCount, autoMergedFiles, linkOptions, maxFiles = 100) {
1114
+ const lines = [`${plural(conflictCount, "conflict")} to resolve in your editor`];
1115
+ if (autoMergedFiles.length === 0) {
1116
+ return lines.join("\n");
1117
+ }
1118
+ lines.push("");
1119
+ lines.push(` auto-merged files (${autoMergedFiles.length}):`);
1120
+ const { forkPath } = linkOptions;
1121
+ const shown = autoMergedFiles.slice(0, maxFiles);
1122
+ for (const filePath of shown) {
1123
+ lines.push(` - ${forkPath ? hyperlink(filePath, `vscode://file${resolve3(forkPath, filePath)}`) : filePath}`);
1124
+ }
1125
+ if (autoMergedFiles.length > shown.length) {
1126
+ lines.push(` ... + ${autoMergedFiles.length - shown.length} more`);
1127
+ }
1128
+ return lines.join("\n");
1129
+ }
1130
+ var statusConfig = {
1131
+ behind: { icon: colors_default.cyan("\u2193"), label: "behind", color: colors_default.cyan, description: "upstream changed, will update" },
1132
+ diverged: { icon: colors_default.magenta("\u21C5"), label: "diverged", color: colors_default.magenta, description: "both changed, will merge" },
1133
+ drifted: { icon: colors_default.yellow("!"), label: "drifted", color: colors_default.yellow, description: "fork changed (at risk)" },
1134
+ ahead: { icon: colors_default.blue("\u2191"), label: "ahead", color: colors_default.blue, description: "fork changed (protected)" },
1135
+ local: { icon: colors_default.green("+"), label: "local", color: colors_default.green, description: "local file in cella folders" },
1136
+ pinned: { icon: colors_default.green("\u2A00"), label: "pinned", color: colors_default.green, description: "both changed, fork wins" },
1137
+ ignored: { icon: colors_default.gray("\u2A02"), label: "ignored", color: colors_default.gray },
1138
+ identical: { icon: colors_default.gray("\u2713"), label: "identical", color: colors_default.gray, description: "no changes" },
1139
+ deleted: { icon: colors_default.red("\u2717"), label: "deleted", color: colors_default.red },
1140
+ renamed: { icon: colors_default.blue("\u2192"), label: "renamed", color: colors_default.blue }
1141
+ };
1142
+ var summaryStatusConfig = {
1143
+ ...statusConfig,
1144
+ managed: {
1145
+ icon: colors_default.cyan("\u25C7"),
1146
+ label: "managed",
1147
+ color: colors_default.cyan,
1148
+ description: "package.jsons & cella/cella.config.ts handled separately"
1149
+ },
1150
+ ignored: { ...statusConfig.ignored, description: "protected by ignored config" }
1151
+ };
1152
+ function printSummary(summary, title = "summary") {
1153
+ printSectionHeader(colors_default.cyan(title));
1154
+ const maxCount = Math.max(...Object.values(summary));
1155
+ const countWidth = String(maxCount).length + 1;
1156
+ const groups = [
1157
+ ["managed", "ignored"],
1158
+ ["identical"],
1159
+ ["ahead", "local", "drifted"],
1160
+ ["diverged", "pinned"],
1161
+ ["behind"]
1162
+ ];
1163
+ for (const group of groups) {
1164
+ if (group !== groups[0]) console.info();
1165
+ for (const status of group) {
1166
+ const { icon, label, color, description } = summaryStatusConfig[status];
1167
+ const countStr = color(String(summary[status]).padStart(countWidth));
1168
+ console.info(` ${icon} ${countStr} ${label.padEnd(15)}${description ? colors_default.dim(description) : ""}`);
1169
+ }
1170
+ }
1171
+ }
1172
+ function filesWithStatus(files, status) {
1173
+ return files.filter((f) => f.status === status && !isManagedFile(f.path));
1174
+ }
1175
+ function findProtectedBehind(files) {
1176
+ return files.filter((file) => file.upstreamChanged && !isManagedFile(file.path) && !isGeneratedFile(file.path));
1177
+ }
1178
+ function findProtectedBehindUpstream(result) {
1179
+ const ignoredOnly = new Set((result.ignoredUpstreamChanges ?? []).flatMap((group) => group.paths));
1180
+ return [...findProtectedBehind(result.files), ...result.files.filter((file) => ignoredOnly.has(file.path))];
1181
+ }
1182
+ var PROTECTED_BEHIND_HINT = "pinned/ignored files where upstream also changed since the last upgrade; the fork side wins on conflict, so review what upstream changed and adopt what you need.";
1183
+ function protectionIcon(file) {
1184
+ return file.isIgnored ? statusConfig.ignored.icon : statusConfig.pinned.icon;
1185
+ }
1186
+ function formatUpstreamChangedLines(file) {
1187
+ if (file.upstreamChangedLines === void 0) return "";
1188
+ return colors_default.yellow(` \xB7 ${plural(file.upstreamChangedLines, "line")} changed upstream`);
1189
+ }
1190
+ function printSyncComplete(result, options = {}) {
1191
+ const updated = result.files.filter((file) => file.status === "behind" || file.status === "diverged").length;
1192
+ const diverged = result.files.filter((file) => file.status === "diverged").length;
1193
+ const merged = result.autoMergedFiles?.length ?? Math.max(0, diverged - result.conflicts.length);
1194
+ const conflicts = result.conflicts.length;
1195
+ const counts = `${plural(updated, "file")} updated, ${merged} auto-merged, ${plural(conflicts, "conflict")}`;
1196
+ console.info();
1197
+ if (options.stagedBranch) {
1198
+ console.info(`${checkMark} upgrade merge staged on '${options.stagedBranch}'`);
1199
+ console.info(colors_default.dim(` ${counts}. committing next...`));
1200
+ } else if (conflicts > 0) {
1201
+ console.info(`${warningMark} upgrade merge staged with conflicts`);
1202
+ console.info(colors_default.dim(` ${counts}`));
1203
+ } else {
1204
+ console.info(`${checkMark} upgrade merge complete`);
1205
+ console.info(colors_default.dim(` ${counts}`));
1206
+ }
1207
+ printProtectedConflicts(result);
1208
+ console.info();
1209
+ }
1210
+ function printProtectedConflicts(result) {
1211
+ const paths = result.protectedConflicts ?? [];
1212
+ if (paths.length === 0) return;
1213
+ const byPath2 = new Map(result.files.map((file) => [file.path, file]));
1214
+ console.info();
1215
+ console.info(
1216
+ `${warningMark} ${colors_default.yellow(
1217
+ `${plural(paths.length, "protected file kept", "protected files kept")} the fork version, dropping upstream changes:`
1218
+ )}`
1219
+ );
1220
+ for (const path of paths) {
1221
+ const file = byPath2.get(path);
1222
+ const icon = file ? protectionIcon(file) : statusConfig.pinned.icon;
1223
+ console.info(` ${icon} ${path}${file ? formatUpstreamChangedLines(file) : ""}`);
1224
+ }
1225
+ console.info(colors_default.dim(` ${PROTECTED_BEHIND_HINT}`));
1226
+ }
1227
+ function plural(count3, singular, pluralForm = `${singular}s`) {
1228
+ return `${count3} ${count3 === 1 ? singular : pluralForm}`;
1229
+ }
1230
+ function printIgnoredUpstreamChanges(result) {
1231
+ const groups = result.ignoredUpstreamChanges ?? [];
1232
+ if (groups.length === 0) return;
1233
+ const total = groups.reduce((count3, group) => count3 + group.paths.length, 0);
1234
+ console.info();
1235
+ console.info(
1236
+ `${warningMark} ${colors_default.yellow(`ignored paths changed upstream \xB7 ${plural(total, "file")} under ${plural(groups.length, "entry", "entries")}`)}`
1237
+ );
1238
+ for (const group of groups) {
1239
+ const counts = [plural(group.paths.length, "file")];
1240
+ if (group.added > 0) counts.push(`${group.added} new`);
1241
+ if (group.deleted > 0) counts.push(`${group.deleted} deleted`);
1242
+ console.info(` ${statusConfig.ignored.icon} ${group.entry} ${colors_default.dim(`\xB7 ${counts.join(", ")}`)}`);
1243
+ }
1244
+ console.info(
1245
+ colors_default.dim(" the fork left these untouched and ignored paths never merge: review and adopt what you need.")
1246
+ );
1247
+ }
1248
+ function printUpstreamOverrideChanges(result) {
1249
+ const report = result.upstreamOverrides;
1250
+ if (!report) return;
1251
+ console.info();
1252
+ if (report.kind === "unreadable") {
1253
+ const side = report.side === "base" ? "the last upgrade" : "the incoming ref";
1254
+ console.info(
1255
+ colors_default.dim(`upstream CLI config not compared: upstream ${CONFIG_FILE} is missing or unreadable at ${side}`)
1256
+ );
1257
+ return;
1258
+ }
1259
+ const lines = [];
1260
+ for (const list of ["pinned", "ignored", "packageJsonKeys"]) {
1261
+ for (const entry of report[list].added) lines.push(` ${colors_default.green("+")} ${list}: ${entry}`);
1262
+ for (const entry of report[list].removed) {
1263
+ lines.push(` ${colors_default.red("\u2212")} ${list}: ${entry}${colors_default.dim(" \xB7 dropped upstream, still in your config")}`);
1264
+ }
1265
+ }
1266
+ console.info(
1267
+ `${warningMark} ${colors_default.yellow(`upstream changed its CLI config \xB7 ${plural(lines.length, "entry", "entries")} to review`)}`
1268
+ );
1269
+ for (const line of lines) console.info(line);
1270
+ console.info(colors_default.dim(` ${CONFIG_FILE} never merges: add or drop these by hand where they fit your app.`));
1235
1271
  }
1236
- async function withTemporarySyncBaseGraft(cwd, headRef, baseSha, fn) {
1237
- const headSha = await revParse(cwd, headRef);
1238
- if (!headSha || !await commitObjectExists(cwd, baseSha)) return fn();
1239
- if (await isAncestor(cwd, baseSha, headSha)) return fn();
1240
- const existingReplacement = await git(["replace", "-l", headSha], cwd, { ignoreErrors: true });
1241
- if (existingReplacement) return fn();
1242
- const parentsRaw = await git(["log", "-1", "--format=%P", headSha], cwd, { ignoreErrors: true });
1243
- const parents = parentsRaw ? parentsRaw.split(/\s+/) : [];
1244
- await git(["replace", "-f", "--graft", headSha, ...parents, baseSha], cwd);
1245
- try {
1246
- return await fn();
1247
- } finally {
1248
- await git(["replace", "-d", headSha], cwd, { ignoreErrors: true });
1272
+ function findMaskingPins(files) {
1273
+ return files.filter(
1274
+ (file) => file.isPinned && !isManagedFile(file.path) && file.status === "behind" && file.existsInFork && file.existsInUpstream
1275
+ );
1276
+ }
1277
+ function printMaskingPinWarning(files) {
1278
+ const masking = findMaskingPins(files);
1279
+ if (masking.length === 0) return;
1280
+ console.info();
1281
+ console.info(
1282
+ `${warningMark} ${colors_default.yellow(
1283
+ `${plural(masking.length, "pinned file matches", "pinned files match")} the previous upstream but changed upstream:`
1284
+ )}`
1285
+ );
1286
+ console.info(colors_default.yellow(" the pin keeps the old fork copy and silently drops those upstream changes:"));
1287
+ for (const file of masking) {
1288
+ console.info(colors_default.dim(` \u2A00 ${file.path}`));
1249
1289
  }
1290
+ console.info(
1291
+ colors_default.yellow(" if the fork never customized these, unpin them in cella/cella.config.ts to take upstream.")
1292
+ );
1293
+ console.info();
1294
+ }
1295
+ function printEngineReports(result, title) {
1296
+ printSummary(result.summary, title);
1297
+ printIgnoredUpstreamChanges(result);
1298
+ printUpstreamOverrideChanges(result);
1299
+ printMaskingPinWarning(result.files);
1250
1300
  }
1251
1301
 
1252
- // src/utils/migration-notes.ts
1302
+ // src/utils/overrides.ts
1303
+ import { existsSync as existsSync5 } from "fs";
1304
+ import { join as join6 } from "path";
1305
+
1306
+ // src/utils/alignments.ts
1253
1307
  import { existsSync as existsSync4 } from "fs";
1254
1308
  import { mkdir as mkdir3, readFile as readFile2, rm, writeFile as writeFile2 } from "fs/promises";
1255
1309
  import { dirname as dirname3, join as join5 } from "path";
1256
- var NOTES_DIR = "cella/migrations";
1257
- var PENDING_FILE = "cella/cella.migrations.json";
1258
- var NOTE_ID = /^\d{8}T\d{4}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
1310
+ var ALIGNMENTS_DIR = "cella/alignments";
1311
+ var UPSTREAM_ONLY_DIRS = [ALIGNMENTS_DIR, "cella/migrations"];
1312
+ var PENDING_FILE = "cella/cella.alignments.json";
1313
+ var ALIGNMENT_ID = /^\d{8}T\d{4}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
1314
+ var CAUGHT_BY = ["compiler", "tests", "nothing"];
1259
1315
  function isCodemodFile(name) {
1260
1316
  return name.endsWith(".ts") && !name.endsWith(".test.ts");
1261
1317
  }
1318
+ function splitList(value) {
1319
+ return (value ?? "").split(",").map((item) => item.trim()).filter(Boolean);
1320
+ }
1262
1321
  function isUpstreamOnly(filePath) {
1263
- return filePath === NOTES_DIR || filePath.startsWith(`${NOTES_DIR}/`);
1322
+ return UPSTREAM_ONLY_DIRS.some((dir) => filePath === dir || filePath.startsWith(`${dir}/`));
1264
1323
  }
1265
1324
  function bodyLines(lines) {
1266
1325
  let inComment = false;
@@ -1270,7 +1329,7 @@ function bodyLines(lines) {
1270
1329
  return false;
1271
1330
  });
1272
1331
  }
1273
- function parseNote(id, source, fileNames) {
1332
+ function parseAlignment(id, source, fileNames) {
1274
1333
  const lines = source.replace(/\r\n/g, "\n").split("\n");
1275
1334
  const close = lines[0] === "---" ? lines.indexOf("---", 1) : -1;
1276
1335
  const meta = /* @__PURE__ */ new Map();
@@ -1282,31 +1341,75 @@ function parseNote(id, source, fileNames) {
1282
1341
  const blocks = body.split(/\n\s*\n/).map((block) => block.trim()).filter(Boolean);
1283
1342
  const title = /^# (.+)$/.exec(blocks[0] ?? "")?.[1].trim();
1284
1343
  const summary = title && blocks[1] && !/^(#|>|[-*|]|```)/.test(blocks[1]) ? blocks[1].replace(/\s*\n\s*/g, " ") : "";
1344
+ const caughtBy = CAUGHT_BY.find((value) => value === meta.get("caughtBy")) ?? null;
1285
1345
  return {
1286
1346
  id,
1287
1347
  title: title || id,
1288
1348
  summary,
1289
- syncBreaking: meta.get("syncBreaking") === "true",
1349
+ // `syncBreaking` is the field's name in alignments up to cella 0.27
1350
+ breaking: (meta.get("breaking") ?? meta.get("syncBreaking")) === "true",
1290
1351
  clientCacheBump: meta.get("clientCacheBump") === "true",
1291
- roots: (meta.get("roots") ?? "").split(",").map((root) => root.trim()).filter(Boolean),
1352
+ caughtBy,
1353
+ generate: meta.get("generate") === "true",
1354
+ grep: splitList(meta.get("grep")),
1355
+ roots: splitList(meta.get("roots")),
1292
1356
  codemod: fileNames.find(isCodemodFile) ?? null
1293
1357
  };
1294
1358
  }
1295
- async function listNoteIds(cwd, ref) {
1296
- const out = await git(["ls-tree", "--name-only", `${ref}:${NOTES_DIR}`], cwd, { ignoreErrors: true });
1297
- return out.split("\n").filter((name) => NOTE_ID.test(name)).sort();
1359
+ async function listAlignmentIds(cwd, ref) {
1360
+ const out = await git(["ls-tree", "--name-only", `${ref}:${ALIGNMENTS_DIR}`], cwd, { ignoreErrors: true });
1361
+ return out.split("\n").filter((name) => ALIGNMENT_ID.test(name)).sort();
1298
1362
  }
1299
- async function readNote(cwd, ref, id) {
1300
- const source = await git(["show", `${ref}:${NOTES_DIR}/${id}/README.md`], cwd, { ignoreErrors: true });
1363
+ async function readAlignment(cwd, ref, id) {
1364
+ const source = await git(["show", `${ref}:${ALIGNMENTS_DIR}/${id}/README.md`], cwd, { ignoreErrors: true });
1301
1365
  if (!source) return null;
1302
- const files = await git(["ls-tree", "--name-only", `${ref}:${NOTES_DIR}/${id}`], cwd, { ignoreErrors: true });
1303
- return parseNote(id, source, files.split("\n").filter(Boolean));
1366
+ const files = await git(["ls-tree", "--name-only", `${ref}:${ALIGNMENTS_DIR}/${id}`], cwd, { ignoreErrors: true });
1367
+ return parseAlignment(id, source, files.split("\n").filter(Boolean));
1304
1368
  }
1305
- async function arrivedNoteIds(cwd, from, to) {
1306
- const [before, after] = await Promise.all([listNoteIds(cwd, from), listNoteIds(cwd, to)]);
1369
+ async function arrivedAlignmentIds(cwd, from, to) {
1370
+ const [before, after] = await Promise.all([listAlignmentIds(cwd, from), listAlignmentIds(cwd, to)]);
1307
1371
  const known = new Set(before);
1308
1372
  return after.filter((id) => !known.has(id));
1309
1373
  }
1374
+ async function readAlignmentSource(cwd, ref, id) {
1375
+ const out = await git(
1376
+ ["log", "-1", "--diff-filter=A", "--format=%H%x09%s", ref, "--", `${ALIGNMENTS_DIR}/${id}/README.md`],
1377
+ cwd,
1378
+ { ignoreErrors: true }
1379
+ );
1380
+ if (!out) return null;
1381
+ const [commit, subject = ""] = out.split(" ");
1382
+ const { pr = null } = splitPrNumber(subject);
1383
+ const nulSeparated = (text) => text.split("\0").filter(Boolean);
1384
+ const [changed, added] = await Promise.all([
1385
+ git(["diff-tree", "--root", "--no-commit-id", "--name-only", "--no-renames", "-r", "-z", commit], cwd),
1386
+ git(
1387
+ [
1388
+ "diff-tree",
1389
+ "--root",
1390
+ "--no-commit-id",
1391
+ "--name-only",
1392
+ "--diff-filter=A",
1393
+ "-r",
1394
+ "-z",
1395
+ commit,
1396
+ "--",
1397
+ ALIGNMENTS_DIR
1398
+ ],
1399
+ cwd
1400
+ )
1401
+ ]);
1402
+ const readmeId = new RegExp(`^${ALIGNMENTS_DIR}/([^/]+)/README\\.md$`);
1403
+ const sharedCommit = nulSeparated(added).map((path) => readmeId.exec(path)?.[1]).filter((other) => !!other && other !== id && ALIGNMENT_ID.test(other)).sort();
1404
+ return { commit, pr, sharedCommit, changedFiles: nulSeparated(changed) };
1405
+ }
1406
+ async function grepAlignmentTerms(cwd, terms, inFiles) {
1407
+ if (terms.length === 0) return [];
1408
+ const out = await git(["grep", "-n", "-I", "-w", "-F", "-z", ...terms.flatMap((term) => ["-e", term]), "--"], cwd, {
1409
+ ignoreErrors: true
1410
+ });
1411
+ return out.split("\n").map((match) => match.split("\0")).filter(([file, line]) => file && line && inFiles(file)).map(([file, line]) => ({ file, line: Number(line) }));
1412
+ }
1310
1413
  async function readPending(cwd) {
1311
1414
  const path = join5(cwd, PENDING_FILE);
1312
1415
  if (!existsSync4(path)) return [];
@@ -1324,14 +1427,13 @@ async function writePending(cwd, ids) {
1324
1427
  await writeFile2(path, `${JSON.stringify({ pending }, null, 2)}
1325
1428
  `, "utf8");
1326
1429
  }
1327
- function noteUrl(upstreamGitHubUrl, commit, id) {
1328
- return upstreamGitHubUrl ? `${upstreamGitHubUrl}/blob/${commit}/${NOTES_DIR}/${id}/README.md` : void 0;
1430
+ function alignmentUrl(upstreamGitHubUrl, commit, id) {
1431
+ return upstreamGitHubUrl ? `${upstreamGitHubUrl}/blob/${commit}/${ALIGNMENTS_DIR}/${id}/README.md` : void 0;
1329
1432
  }
1330
- function formatNotesLine(total, open, arrived, options = {}) {
1433
+ function formatAlignmentsLine(total, open) {
1331
1434
  const parts = [open === 0 ? `all ${total} handled` : `${Math.max(0, total - open)} of ${total} handled`];
1332
- if (arrived) parts.push(`${arrived} ${options.dryRun ? "arrive" : "arrived"} with this sync`);
1333
- if (open > 0) parts.push(`${open} open: pnpm cella migrate`);
1334
- return `migration notes: ${parts.join(" \xB7 ")}`;
1435
+ if (open > 0) parts.push(`${open} open: pnpm cella align`);
1436
+ return `alignments: ${parts.join(" \xB7 ")}`;
1335
1437
  }
1336
1438
 
1337
1439
  // src/utils/overrides.ts
@@ -1450,11 +1552,12 @@ var forkOption = ["--fork <name>", "pre-select a fork by name (skips the fork se
1450
1552
  var serviceDefinitions = [
1451
1553
  {
1452
1554
  name: "analyze",
1453
- description: "dry run to see what would change on sync",
1555
+ description: "dry run to see what would change on upgrade",
1454
1556
  options: [
1455
1557
  ["--list", "non-interactive output for tooling (one file per line)"],
1456
1558
  jsonOption,
1457
1559
  ["--scope <scope>", "analyze scope for --list/--json: all|risk|protected"],
1560
+ ["--all", "list every file instead of collapsing large groups"],
1458
1561
  trackOption,
1459
1562
  refOption,
1460
1563
  unpinnedOption
@@ -1462,30 +1565,31 @@ var serviceDefinitions = [
1462
1565
  includeInMenu: (context) => !context.isUpstreamRepo
1463
1566
  },
1464
1567
  {
1465
- name: "sync",
1466
- description: "merge upstream changes onto a fresh branch, sync package.json and open a squash-merge PR",
1568
+ name: "upgrade",
1569
+ description: "merge upstream changes onto a fresh branch, merge package.json and open a squash-merge PR",
1570
+ aliases: ["sync"],
1467
1571
  options: [
1468
1572
  trackOption,
1469
1573
  refOption,
1470
- ["--keep-config", "merge with your sync config as it stands when upstream changed its own"],
1574
+ ["--keep-config", "merge with your CLI config as it stands when upstream changed its own"],
1471
1575
  unpinnedOption
1472
1576
  ],
1473
1577
  includeInMenu: (context) => !context.isUpstreamRepo
1474
1578
  },
1475
1579
  {
1476
- name: "migrate",
1477
- description: "list the upstream migration notes this app has not handled yet",
1580
+ name: "align",
1581
+ description: "list the upstream alignments this app has not handled yet",
1478
1582
  options: [
1479
- ["--all", "list every upstream note, handled or not"],
1583
+ ["--all", "list every upstream alignment, handled or not"],
1480
1584
  jsonOption,
1481
- ["--show <id>", "print one note's README"],
1482
- ["--extract <id>", "write one note's folder under node_modules/.cache to run its codemod"],
1483
- ["--run <id>", "run one note's codemod; files identical to upstream stay as they are"],
1484
- ["--mark <ids...>", "record notes as handled"]
1585
+ ["--show <id>", "print one alignment's README and what git tells about it"],
1586
+ ["--extract <id>", "write one alignment's folder under node_modules/.cache to run its codemod"],
1587
+ ["--run <id>", "run one alignment's codemod; files identical to upstream stay as they are"],
1588
+ ["--mark <ids...>", "record alignments as handled"]
1485
1589
  ],
1486
1590
  operands: [
1487
1591
  "[codemodArgs...]",
1488
- "with --run, after `--`: arguments for the codemod (default: inventory and the note's roots)"
1592
+ "with --run, after `--`: arguments for the codemod (default: inventory and the alignment's roots)"
1489
1593
  ],
1490
1594
  includeInMenu: (context) => !context.isUpstreamRepo
1491
1595
  },
@@ -1496,10 +1600,10 @@ var serviceDefinitions = [
1496
1600
  },
1497
1601
  {
1498
1602
  name: "forks",
1499
- description: "run normal sync inside local fork repositories",
1603
+ description: "run the upgrade inside local fork repositories",
1500
1604
  options: [
1501
1605
  forkOption,
1502
- ["--keep-config", "merge with the fork's sync config as it stands when upstream changed its own"]
1606
+ ["--keep-config", "merge with the fork's CLI config as it stands when upstream changed its own"]
1503
1607
  ],
1504
1608
  includeInMenu: (context) => context.hasForks
1505
1609
  },
@@ -1538,17 +1642,21 @@ function buildServiceChoices(context) {
1538
1642
  ];
1539
1643
  }
1540
1644
  function addServiceCommand(program, definition, setSelection) {
1541
- const command = program.command(definition.name).description(definition.description);
1542
- for (const option of definition.options ?? []) command.option(...option);
1543
- if (definition.operands) command.argument(...definition.operands);
1544
- command.action((...args) => {
1545
- const operands = definition.operands ? args[0] : [];
1546
- const opts = definition.operands ? args[1] : args[0];
1547
- setSelection({
1548
- service: definition.name,
1549
- options: readOptions(opts, operands)
1645
+ for (const name of [definition.name, ...definition.aliases ?? []]) {
1646
+ const renamedFrom = name === definition.name ? void 0 : name;
1647
+ const command = program.command(name, { hidden: !!renamedFrom }).description(definition.description);
1648
+ for (const option of definition.options ?? []) command.option(...option);
1649
+ if (definition.operands) command.argument(...definition.operands);
1650
+ command.action((...args) => {
1651
+ const operands = definition.operands ? args[0] : [];
1652
+ const opts = definition.operands ? args[1] : args[0];
1653
+ setSelection({
1654
+ service: definition.name,
1655
+ renamedFrom,
1656
+ options: readOptions(opts, operands)
1657
+ });
1550
1658
  });
1551
- });
1659
+ }
1552
1660
  }
1553
1661
  function buildProgram(setSelection) {
1554
1662
  const program = new Command("cella").version(VERSION, "-v, --version", "output the current version").usage("[service] [options]").helpOption("-h, --help", "display help for a command").showHelpAfterError().addHelpText(
@@ -1558,10 +1666,10 @@ function buildProgram(setSelection) {
1558
1666
  "Examples:",
1559
1667
  " $ cella analyze",
1560
1668
  " $ cella analyze --json --scope risk",
1561
- " $ cella sync --track branch",
1562
- " $ cella sync --ref 4f7d87c",
1563
- " $ cella migrate --run 20261001T2116-tailwind-class-conventions -- rewrite frontend/src",
1564
- " $ cella migrate --mark 20261002T0614-config-switch",
1669
+ " $ cella upgrade --track branch",
1670
+ " $ cella upgrade --ref 4f7d87c",
1671
+ " $ cella align --run 20261001T2116-tailwind-class-conventions -- rewrite frontend/src",
1672
+ " $ cella align --mark 20261002T0614-config-switch",
1565
1673
  " $ cella audit --list",
1566
1674
  " $ cella stats --since origin/main --md",
1567
1675
  " $ cella contributions --fork raak --json"
@@ -1592,7 +1700,7 @@ async function promptForService(userConfig, forkPath) {
1592
1700
  if (selected === "exit") {
1593
1701
  console.info(colors_default.dim("exiting..."));
1594
1702
  console.info();
1595
- process5.exit(0);
1703
+ process6.exit(0);
1596
1704
  }
1597
1705
  console.info();
1598
1706
  return selected;
@@ -1601,12 +1709,18 @@ function buildRuntimeConfig(userConfig, forkPath, selection, upstreamRef) {
1601
1709
  return { ...userConfig, forkPath, upstreamRef, service: selection.service ?? "analyze", ...selection.options };
1602
1710
  }
1603
1711
  async function parseCli(userConfig, forkPath) {
1604
- const selection = parseCommandLine(process5.argv);
1712
+ const selection = parseCommandLine(process6.argv);
1605
1713
  const machineList = selection.options.list && selection.service !== "audit";
1606
1714
  if (selection.options.json || selection.options.md || machineList) setJsonMode(true);
1607
1715
  printHeader();
1608
1716
  const { branchRef } = resolveUpstream(userConfig.settings);
1609
- const warnings = await validateOverrides(userConfig, forkPath, branchRef);
1717
+ const warnings = [
1718
+ ...renamedSettingWarnings(userConfig),
1719
+ ...await validateOverrides(userConfig, forkPath, branchRef)
1720
+ ];
1721
+ if (selection.renamedFrom) {
1722
+ warnings.unshift(`\`cella ${selection.renamedFrom}\` is renamed to \`cella ${selection.service}\``);
1723
+ }
1610
1724
  for (const warning of warnings) console.info(`${warningMark} ${warning}`);
1611
1725
  if (warnings.length > 0) console.info();
1612
1726
  if (!selection.service) {
@@ -1615,6 +1729,12 @@ async function parseCli(userConfig, forkPath) {
1615
1729
  return buildRuntimeConfig(userConfig, forkPath, selection, branchRef);
1616
1730
  }
1617
1731
 
1732
+ // src/services/align.ts
1733
+ import { execFileSync, spawnSync } from "child_process";
1734
+ import { mkdir as mkdir4, writeFile as writeFile3 } from "fs/promises";
1735
+ import { dirname as dirname4, join as join8 } from "path";
1736
+ import process7 from "process";
1737
+
1618
1738
  // src/services/merge-engine.ts
1619
1739
  import { existsSync as existsSync6 } from "fs";
1620
1740
  import { rm as rm2 } from "fs/promises";
@@ -1689,7 +1809,7 @@ function cliVersionMismatch(running, range) {
1689
1809
  const current = parseComparableVersion(running);
1690
1810
  if (!required || !current || compareVersions(current, required) >= 0) return null;
1691
1811
  return [
1692
- `upstream needs ${CLI_PACKAGE} ${range}, this is ${running}: an older CLI misses fixes to the sync itself.`,
1812
+ `upstream needs ${CLI_PACKAGE} ${range}, this is ${running}: an older CLI misses fixes to the upgrade itself.`,
1693
1813
  ` update it, commit, then rerun: pnpm add -D -w '${CLI_PACKAGE}@${range}'`
1694
1814
  ].join("\n");
1695
1815
  }
@@ -1743,12 +1863,13 @@ function readStringEntries(node) {
1743
1863
  }
1744
1864
  return entries;
1745
1865
  }
1746
- function readPackageJsonSync(config, sourceFile) {
1866
+ function readPackageJsonKeys(config, sourceFile) {
1747
1867
  const settings = readProperty(config, "settings", sourceFile);
1748
- if (settings === void 0) return [...DEFAULT_PACKAGE_JSON_SYNC];
1868
+ if (settings === void 0) return [...DEFAULT_PACKAGE_JSON_KEYS];
1749
1869
  if (!settings || !Node.isObjectLiteralExpression(settings)) return null;
1750
- const keys = readProperty(settings, "packageJsonSync", sourceFile);
1751
- if (keys === void 0) return [...DEFAULT_PACKAGE_JSON_SYNC];
1870
+ const current = readProperty(settings, "packageJsonKeys", sourceFile);
1871
+ const keys = current === void 0 ? readProperty(settings, "packageJsonSync", sourceFile) : current;
1872
+ if (keys === void 0) return [...DEFAULT_PACKAGE_JSON_KEYS];
1752
1873
  return keys ? readStringEntries(keys) : null;
1753
1874
  }
1754
1875
  function parseOverrideLists(source) {
@@ -1760,7 +1881,7 @@ function parseOverrideLists(source) {
1760
1881
  const lists = {
1761
1882
  pinned: [],
1762
1883
  ignored: [],
1763
- packageJsonSync: readPackageJsonSync(config, sourceFile)
1884
+ packageJsonKeys: readPackageJsonKeys(config, sourceFile)
1764
1885
  };
1765
1886
  const overrides = readProperty(config, "overrides", sourceFile);
1766
1887
  if (overrides === void 0) return lists;
@@ -1788,14 +1909,14 @@ function diffOverrideLists(base, incoming, fork) {
1788
1909
  };
1789
1910
  };
1790
1911
  const diffKeys = () => {
1791
- const [baseKeys, incomingKeys, forkKeys] = [base.packageJsonSync, incoming.packageJsonSync, fork.packageJsonSync];
1912
+ const [baseKeys, incomingKeys, forkKeys] = [base.packageJsonKeys, incoming.packageJsonKeys, fork.packageJsonKeys];
1792
1913
  if (!baseKeys || !incomingKeys || !forkKeys) return { added: [], removed: [] };
1793
1914
  return {
1794
1915
  added: [...new Set(incomingKeys)].filter((key) => !baseKeys.includes(key) && !forkKeys.includes(key)),
1795
1916
  removed: [...new Set(baseKeys)].filter((key) => !incomingKeys.includes(key) && forkKeys.includes(key))
1796
1917
  };
1797
1918
  };
1798
- return { pinned: diff("pinned"), ignored: diff("ignored"), packageJsonSync: diffKeys() };
1919
+ return { pinned: diff("pinned"), ignored: diff("ignored"), packageJsonKeys: diffKeys() };
1799
1920
  }
1800
1921
  async function compareUpstreamOverrides(repoPath, mergeBaseRef, incomingRef, forkConfig) {
1801
1922
  const [baseSource, incomingSource] = await Promise.all(
@@ -1809,7 +1930,7 @@ async function compareUpstreamOverrides(repoPath, mergeBaseRef, incomingRef, for
1809
1930
  const fork = {
1810
1931
  pinned: forkConfig.overrides?.pinned ?? [],
1811
1932
  ignored: forkConfig.overrides?.ignored ?? [],
1812
- packageJsonSync: forkConfig.settings.packageJsonSync ?? DEFAULT_PACKAGE_JSON_SYNC
1933
+ packageJsonKeys: forkConfig.settings.packageJsonKeys ?? DEFAULT_PACKAGE_JSON_KEYS
1813
1934
  };
1814
1935
  const changes = diffOverrideLists(base, incoming, fork);
1815
1936
  const count3 = Object.values(changes).reduce((n, list) => n + list.added.length + list.removed.length, 0);
@@ -1936,7 +2057,9 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
1936
2057
  const upstreamOnly = fileIsIgnored && isReportableIgnored(filePath) && upstreamHash !== baseHash && forkHash === baseHash;
1937
2058
  push(status, { upstreamChanged: upstreamChanged || void 0, upstreamOnly: upstreamOnly || void 0 });
1938
2059
  }
1939
- const flagged = analyzedFiles.filter((file) => file.upstreamChanged && !renamedProtected.has(file.path));
2060
+ const flagged = analyzedFiles.filter(
2061
+ (file) => file.upstreamChanged && !renamedProtected.has(file.path) || file.upstreamOnly
2062
+ );
1940
2063
  if (flagged.length > 0) {
1941
2064
  const stat = await getDiffStat(
1942
2065
  repoPath,
@@ -1983,7 +2106,7 @@ async function enrichChangeInfo(repoPath, files, mergeBaseRef, localRef, incomin
1983
2106
  setChanged(file, forkInfo.get(file.path));
1984
2107
  } else if (file.status === "behind") {
1985
2108
  setChanged(file, upstreamInfo.get(file.path));
1986
- } else if (file.status === "diverged" || file.status === "pinned" || file.upstreamChanged) {
2109
+ } else if (file.status === "diverged" || file.status === "pinned" || file.upstreamChanged || file.upstreamOnly) {
1987
2110
  setChanged(file, forkInfo.get(file.path));
1988
2111
  const info = upstreamInfo.get(file.path);
1989
2112
  if (info) {
@@ -2003,7 +2126,7 @@ var UpstreamConfigChangedError = class extends Error {
2003
2126
  constructor(report) {
2004
2127
  super(
2005
2128
  [
2006
- "upstream changed its sync config and yours does not follow it: stopped before the merge, nothing changed.",
2129
+ "upstream changed its CLI config and yours does not follow it: stopped before the merge, nothing changed.",
2007
2130
  ` add or drop the entries that fit your app in ${CONFIG_FILE}, commit, then rerun.`,
2008
2131
  " to merge with your config as it stands, rerun with --keep-config."
2009
2132
  ].join("\n")
@@ -2015,6 +2138,10 @@ function getGitHubBaseUrl(remoteUrl) {
2015
2138
  const match = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
2016
2139
  return match ? `https://github.com/${match[1]}/${match[2]}` : null;
2017
2140
  }
2141
+ async function readOriginGitHubUrl(forkPath) {
2142
+ const origin = await git(["remote", "get-url", "origin"], forkPath, { ignoreErrors: true });
2143
+ return getGitHubBaseUrl(origin.trim()) ?? void 0;
2144
+ }
2018
2145
  function isProtectedFile(file) {
2019
2146
  return file.isIgnored || file.isPinned;
2020
2147
  }
@@ -2150,11 +2277,6 @@ function calculateSummary(files) {
2150
2277
  }
2151
2278
  return summary;
2152
2279
  }
2153
- function formatLocalCheckoutDetail(forkPath, branch, headSha, changeCount, upstream) {
2154
- const tracking = upstream.upstream ? `, tracking ${upstream.upstream} (${upstream.ahead} ahead, ${upstream.behind} behind)` : ", no tracking branch";
2155
- const workingTree = changeCount === 0 ? " working tree clean" : ` ${changeCount} uncommitted change${changeCount === 1 ? "" : "s"} in working tree, not included in analysis`;
2156
- return [`${forkPath}`, ` ${branch} @ ${headSha}${tracking}`, workingTree].join("\n");
2157
- }
2158
2280
  async function resolvePinnedRef(forkPath, config, remoteName, branchRef, ref) {
2159
2281
  const resolved = await resolveUpstreamCommit(forkPath, remoteName, ref);
2160
2282
  if (!resolved) {
@@ -2164,7 +2286,7 @@ async function resolvePinnedRef(forkPath, config, remoteName, branchRef, ref) {
2164
2286
  }
2165
2287
  if (!await isPublishedUpstream(forkPath, remoteName, branchRef, resolved.sha)) {
2166
2288
  throw new Error(
2167
- `--ref '${ref}' (${resolved.sha.slice(0, 7)}) is not on ${branchRef} or in an upstream release. Sync only to commits upstream has published there.`
2289
+ `--ref '${ref}' (${resolved.sha.slice(0, 7)}) is not on ${branchRef} or in an upstream release. Upgrade only to commits upstream has published there.`
2168
2290
  );
2169
2291
  }
2170
2292
  return resolved.release ? { ref: resolved.release.ref, releaseTag: resolved.release.tag } : { ref: resolved.sha };
@@ -2178,7 +2300,7 @@ async function prepareUpstream(config, onProgress, onStep) {
2178
2300
  getWorkingTreeChangeCount(forkPath),
2179
2301
  getUpstreamStatus(forkPath)
2180
2302
  ]);
2181
- onStep?.("local checkout", formatLocalCheckoutDetail(forkPath, branch, headSha, changeCount, upstreamStatus));
2303
+ onStep?.("checkout", formatCheckoutStep({ forkPath, branch, headSha, changeCount, upstream: upstreamStatus }));
2182
2304
  onProgress?.("setting up upstream remote...");
2183
2305
  const { track: configTrack, branchRef } = resolveUpstream(config.settings);
2184
2306
  const remoteName = DEFAULT_UPSTREAM_REMOTE;
@@ -2206,7 +2328,6 @@ async function prepareUpstream(config, onProgress, onStep) {
2206
2328
  }
2207
2329
  const upstreamLabel = releaseTag ?? (config.ref ? `--ref ${config.ref}` : upstreamRef);
2208
2330
  config.upstreamRef = upstreamRef;
2209
- onStep?.("remote configured", `${upstreamLabel} \u2192 ${config.settings.upstreamUrl}`);
2210
2331
  const cliRange = await readUpstreamCliRange(forkPath, upstreamRef);
2211
2332
  const cliMismatch = cliRange ? cliVersionMismatch(VERSION, cliRange) : null;
2212
2333
  if (cliMismatch) throw new Error(cliMismatch);
@@ -2217,20 +2338,35 @@ async function prepareUpstream(config, onProgress, onStep) {
2217
2338
  const mergeRef = config.unpinned ? await getMergeBase(forkPath, "HEAD", upstreamRef) : mergeBase;
2218
2339
  const upstreamCommit = await getCommitInfo(forkPath, upstreamRef);
2219
2340
  if (upstreamCommit.hash !== mergeBase && await isAncestor(forkPath, upstreamCommit.hash, mergeBase)) {
2220
- const [upstreamShort, syncShort] = await Promise.all([
2341
+ const [upstreamShort2, syncShort] = await Promise.all([
2221
2342
  getShortSha(forkPath, upstreamCommit.hash),
2222
2343
  getShortSha(forkPath, mergeBase)
2223
2344
  ]);
2224
- 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}.`;
2345
+ const hint2 = config.ref ? "Pick a newer --ref." : releaseTag ? `Nothing to upgrade until a release past ${syncShort}; to follow the tip instead, run with --track branch.` : `Nothing to upgrade until '${branchRef}' moves past ${syncShort}.`;
2225
2346
  throw new BehindSyncPointError(
2226
- `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.
2227
- ${hint}`
2347
+ `upstream ${upstreamLabel} (${upstreamShort2}) is behind the last upgrade at ${syncShort}: the fork already has it, and merging it would revert the upstream changes after it.
2348
+ ${hint2}`
2228
2349
  );
2229
2350
  }
2230
2351
  const commitCount = await countCommitsBetween(forkPath, mergeBase, upstreamRef);
2231
- const upstreamCommits = commitCount > 0 ? await listCommitsBetween(forkPath, mergeBase, upstreamRef, COMMIT_LIST_MAX) : [];
2232
- onStep?.("fetched upstream", formatFetchedUpstreamDetail(commitCount, upstreamCommits, upstreamGitHubUrl));
2233
- return { upstreamRef, releaseTag, mergeBase, mergeRef, upstreamGitHubUrl, upstreamCommit };
2352
+ const upstreamCommits = commitCount > 0 ? await listCommitsBetween(forkPath, mergeBase, upstreamRef, COMMIT_STEP_MAX) : [];
2353
+ const [upstreamShort, baseShort] = await Promise.all([
2354
+ getShortSha(forkPath, upstreamCommit.hash),
2355
+ getShortSha(forkPath, mergeBase)
2356
+ ]);
2357
+ onStep?.(
2358
+ "upstream",
2359
+ formatUpstreamStep({
2360
+ label: releaseTag ?? branchRef.replace(`${remoteName}/`, ""),
2361
+ sha: upstreamShort,
2362
+ baseSha: baseShort,
2363
+ pinned: !!config.ref,
2364
+ commitCount,
2365
+ commits: upstreamCommits,
2366
+ githubUrl: upstreamGitHubUrl
2367
+ })
2368
+ );
2369
+ return { upstreamRef, releaseTag, mergeBase, mergeRef, upstreamGitHubUrl, upstreamCommit, commitCount };
2234
2370
  }
2235
2371
  async function assertSyncConfigFollowed(config, ctx) {
2236
2372
  if (config.keepConfig) return;
@@ -2245,7 +2381,8 @@ function resultMeta(config, ctx) {
2245
2381
  upstreamRef: ctx.upstreamRef,
2246
2382
  upstreamTag: ctx.releaseTag,
2247
2383
  upstreamGitHubUrl: ctx.upstreamGitHubUrl,
2248
- upstreamCommit: ctx.upstreamCommit
2384
+ upstreamCommit: ctx.upstreamCommit,
2385
+ upstreamCommitCount: ctx.commitCount
2249
2386
  };
2250
2387
  }
2251
2388
  async function upstreamOnlyReports(config, ctx, files) {
@@ -2266,33 +2403,40 @@ async function upstreamOnlyReports(config, ctx, files) {
2266
2403
  return {};
2267
2404
  }
2268
2405
  }
2269
- async function readNotesInRange(config, ctx) {
2406
+ function pendingAfterSync(alignments) {
2407
+ return [.../* @__PURE__ */ new Set([...alignments.pending, ...alignments.arrived])];
2408
+ }
2409
+ async function readAlignmentsInRange(config, ctx) {
2270
2410
  const { forkPath } = config;
2271
2411
  try {
2272
2412
  const [total, arrived, pending] = await Promise.all([
2273
- listNoteIds(forkPath, ctx.upstreamRef),
2274
- arrivedNoteIds(forkPath, ctx.mergeBase, ctx.upstreamRef),
2413
+ listAlignmentIds(forkPath, ctx.upstreamRef),
2414
+ arrivedAlignmentIds(forkPath, ctx.mergeBase, ctx.upstreamRef),
2275
2415
  readPending(forkPath)
2276
2416
  ]);
2277
- return { total: total.length, arrived, pending: [.../* @__PURE__ */ new Set([...pending, ...arrived])] };
2417
+ return { total: total.length, arrived, pending };
2278
2418
  } catch {
2279
2419
  return void 0;
2280
2420
  }
2281
2421
  }
2282
- function notesResult(notes) {
2283
- return notes ? { migrationNotes: { total: notes.total, arrived: notes.arrived, open: notes.pending.length } } : {};
2422
+ function alignmentsResult(alignments, options) {
2423
+ if (!alignments) return {};
2424
+ const open = options.dryRun ? alignments.pending : pendingAfterSync(alignments);
2425
+ return { alignments: { total: alignments.total, arrived: alignments.arrived, open } };
2284
2426
  }
2285
2427
  async function removeUpstreamOnlyCopy(forkPath, onStep) {
2286
- if (!existsSync6(join7(forkPath, NOTES_DIR))) return;
2287
- await git(["rm", "-r", "-q", "--ignore-unmatch", "--", NOTES_DIR], forkPath);
2288
- await rm2(join7(forkPath, NOTES_DIR), { recursive: true, force: true });
2289
- onStep?.("migration notes", `removed ${NOTES_DIR}/: notes stay upstream, list them with pnpm cella migrate`);
2428
+ for (const dir of UPSTREAM_ONLY_DIRS) {
2429
+ if (!existsSync6(join7(forkPath, dir))) continue;
2430
+ await git(["rm", "-r", "-q", "--ignore-unmatch", "--", dir], forkPath);
2431
+ await rm2(join7(forkPath, dir), { recursive: true, force: true });
2432
+ onStep?.("alignments", `removed ${dir}/: alignments stay upstream, list them with pnpm cella align`);
2433
+ }
2290
2434
  }
2291
2435
  async function runSyncMerge(config, ctx, onProgress, onStep) {
2292
2436
  const { forkPath } = config;
2293
2437
  const { upstreamRef, releaseTag, mergeRef, upstreamGitHubUrl, upstreamCommit } = ctx;
2294
2438
  await assertSyncConfigFollowed(config, ctx);
2295
- const notes = await readNotesInRange(config, ctx);
2439
+ const alignments = await readAlignmentsInRange(config, ctx);
2296
2440
  const { remainingConflicts, analyzedFiles, autoMergedFiles, protectedConflicts } = await applyDirectMerge(
2297
2441
  forkPath,
2298
2442
  upstreamRef,
@@ -2310,32 +2454,29 @@ async function runSyncMerge(config, ctx, onProgress, onStep) {
2310
2454
  commit: upstreamCommit.hash,
2311
2455
  release: releaseTag ?? null,
2312
2456
  url: upstreamGitHubUrl ? releaseTag ? `${upstreamGitHubUrl}/releases/tag/${releaseTag}` : `${upstreamGitHubUrl}/commit/${upstreamCommit.hash}` : void 0,
2313
- syncedAt: (/* @__PURE__ */ new Date()).toISOString()
2457
+ upgradedAt: (/* @__PURE__ */ new Date()).toISOString()
2314
2458
  }
2315
2459
  };
2316
2460
  const recordSyncPoint = async () => {
2317
2461
  await storeLastSyncRef(forkPath, upstreamCommit.hash);
2318
2462
  await writeSyncManifest(forkPath, syncManifest);
2319
2463
  await stagePath(forkPath, MANIFEST_FILE);
2320
- if (notes) {
2321
- await writePending(forkPath, notes.pending);
2464
+ if (alignments) {
2465
+ await writePending(forkPath, pendingAfterSync(alignments));
2322
2466
  await stagePath(forkPath, PENDING_FILE);
2323
2467
  }
2324
2468
  await removeUpstreamOnlyCopy(forkPath, onStep);
2325
2469
  };
2326
2470
  if (remainingConflicts.length > 0) {
2327
2471
  await recordSyncPoint();
2328
- onStep?.(
2329
- "merge in progress",
2330
- formatMergeInProgressDetail(remainingConflicts.length, autoMergedFiles, { forkPath }, 100)
2331
- );
2472
+ onStep?.("merged", formatMergeInProgressDetail(remainingConflicts.length, autoMergedFiles, { forkPath }, 100));
2332
2473
  } else if (totalResolved > 0) {
2333
2474
  const label = synced > 0 ? `${synced} files from upstream` : "upstream changes";
2334
2475
  await recordSyncPoint();
2335
- onStep?.("synced", `${label} (staged, commit to finish)`);
2476
+ onStep?.("upgraded", `${label} (staged, commit to finish)`);
2336
2477
  } else {
2337
2478
  await mergeAbort(forkPath);
2338
- onStep?.("up to date", "no upstream changes to sync");
2479
+ onStep?.("up to date", "no upstream changes to merge");
2339
2480
  }
2340
2481
  return {
2341
2482
  success: remainingConflicts.length === 0,
@@ -2346,12 +2487,12 @@ async function runSyncMerge(config, ctx, onProgress, onStep) {
2346
2487
  protectedConflicts,
2347
2488
  ...resultMeta(config, ctx),
2348
2489
  ...await upstreamOnlyReports(config, ctx, analyzedFiles),
2349
- ...notesResult(notes)
2490
+ ...alignmentsResult(alignments, { dryRun: false })
2350
2491
  };
2351
2492
  }
2352
2493
  async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2353
2494
  const { forkPath } = config;
2354
- const notes = await readNotesInRange(config, ctx);
2495
+ const alignments = await readAlignmentsInRange(config, ctx);
2355
2496
  onProgress?.("analyzing files...");
2356
2497
  const analyzedFiles = await analyzeRefs(
2357
2498
  forkPath,
@@ -2364,7 +2505,7 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2364
2505
  await enrichChangeInfo(forkPath, analyzedFiles, ctx.mergeBase, "HEAD", ctx.upstreamRef);
2365
2506
  const protectedConflicts = analyzedFiles.filter((f) => f.upstreamChanged).map((f) => f.path);
2366
2507
  const reports = await upstreamOnlyReports(config, ctx, analyzedFiles);
2367
- onStep?.("analysis complete", `${analyzedFiles.length} files analyzed, dry run, no changes applied`);
2508
+ onStep?.("analyzed", `${plural(analyzedFiles.length, "file")}${colors_default.dim(" \xB7 dry run, nothing changed")}`);
2368
2509
  return {
2369
2510
  success: true,
2370
2511
  files: analyzedFiles,
@@ -2374,25 +2515,23 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2374
2515
  protectedConflicts,
2375
2516
  ...resultMeta(config, ctx),
2376
2517
  ...reports,
2377
- ...notesResult(notes)
2518
+ ...alignmentsResult(alignments, { dryRun: true })
2378
2519
  };
2379
2520
  }
2380
2521
  async function runEngineWithSpinner(config, apply) {
2381
2522
  const onProgress = (message) => spinnerText(message);
2382
2523
  const onStep = (label, detail) => {
2383
- spinnerSuccess(label, detail);
2524
+ printStep(label, detail);
2384
2525
  createSpinner("...");
2385
2526
  };
2386
2527
  const ctx = await prepareUpstream(config, onProgress, onStep);
2387
2528
  return apply ? runSyncMerge(config, ctx, onProgress, onStep) : runAnalyzePreview(config, ctx, onProgress, onStep);
2388
2529
  }
2389
2530
 
2390
- // src/services/migrate.ts
2391
- import { execFileSync, spawnSync } from "child_process";
2392
- import { mkdir as mkdir4, writeFile as writeFile3 } from "fs/promises";
2393
- import { dirname as dirname4, join as join8 } from "path";
2394
- var EXTRACT_DIR = "node_modules/.cache/cella/migrations";
2531
+ // src/services/align.ts
2532
+ var EXTRACT_DIR = "node_modules/.cache/cella/alignments";
2395
2533
  var RESTORED_LIST_MAX = 20;
2534
+ var SHOW_LIST_MAX = 30;
2396
2535
  async function ensureUpstreamCommit(config, sha) {
2397
2536
  const { forkPath } = config;
2398
2537
  const present = async () => !!await revParse(forkPath, `${sha}^{commit}`);
@@ -2402,60 +2541,158 @@ async function ensureUpstreamCommit(config, sha) {
2402
2541
  if (!await present())
2403
2542
  throw new Error(`upstream commit ${sha.slice(0, 7)} is not reachable from '${DEFAULT_UPSTREAM_REMOTE}'`);
2404
2543
  }
2405
- async function resolveNotesRef(config) {
2544
+ async function resolveAlignmentsRef(config) {
2406
2545
  const { forkPath } = config;
2407
2546
  const ref = await getStoredSyncRef(forkPath) ?? await readManifestBaseAtRef(forkPath, "HEAD");
2408
2547
  if (!ref) return null;
2409
2548
  await ensureUpstreamCommit(config, ref);
2410
2549
  return ref;
2411
2550
  }
2412
- async function readNotesStatus(config) {
2551
+ async function readOpenAlignments(config) {
2413
2552
  try {
2414
- const ref = await resolveNotesRef(config);
2553
+ const ref = await resolveAlignmentsRef(config);
2415
2554
  if (!ref) return null;
2416
- const [ids, pending] = await Promise.all([listNoteIds(config.forkPath, ref), readPending(config.forkPath)]);
2417
- return ids.length > 0 || pending.length > 0 ? { total: ids.length, open: pending.length } : null;
2555
+ return { ref, open: await readPending(config.forkPath) };
2418
2556
  } catch {
2419
2557
  return null;
2420
2558
  }
2421
2559
  }
2422
- async function printMigrationNotesLine(config, result) {
2423
- const notes = result?.migrationNotes;
2424
- if (notes) {
2425
- if (notes.total === 0) return;
2426
- console.info();
2427
- console.info(
2428
- colors_default.dim(formatNotesLine(notes.total, notes.open, notes.arrived.length, { dryRun: config.service === "analyze" }))
2429
- );
2430
- return;
2431
- }
2432
- const status = await readNotesStatus(config);
2433
- if (!status) return;
2434
- console.info();
2435
- console.info(colors_default.dim(formatNotesLine(status.total, status.open)));
2560
+ var STEP_ROWS_MAX = 5;
2561
+ function alignmentTags(alignment) {
2562
+ return [
2563
+ alignment.codemod ? "codemod" : "manual",
2564
+ alignment.breaking ? "breaking" : "not breaking",
2565
+ alignment.caughtBy ? `caught by ${alignment.caughtBy}` : null
2566
+ ].filter(Boolean).join(" \xB7 ");
2567
+ }
2568
+ async function alignmentsStep(config, view) {
2569
+ const arriving = view.arriving ?? [];
2570
+ const open = view.open.filter((id) => !arriving.includes(id));
2571
+ const arrived = (view.arrived ?? []).filter((id) => open.includes(id));
2572
+ if (arriving.length === 0 && open.length === 0) return void 0;
2573
+ const line = arriving.length > 0 ? `${plural(arriving.length, "alignment")} ${arriving.length === 1 ? "arrives" : "arrive"} with this upgrade${open.length > 0 ? colors_default.dim(` \xB7 ${open.length} open`) : ""}` : `${plural(open.length, "alignment")} open${arrived.length > 0 ? colors_default.dim(` \xB7 ${arrived.length} arrived with this upgrade`) : ""}`;
2574
+ const githubUrl = getGitHubBaseUrl(config.settings.upstreamUrl) ?? void 0;
2575
+ const ids = [...arriving, ...open];
2576
+ const details = [];
2577
+ for (const id of ids.slice(0, STEP_ROWS_MAX)) {
2578
+ const alignment = await readAlignment(config.forkPath, view.ref, id).catch(() => null);
2579
+ const title = hyperlink(alignment?.title ?? id, alignmentUrl(githubUrl, view.ref, id));
2580
+ details.push(`${colors_default.cyan("\u25CB")} ${title}${alignment ? colors_default.dim(` \xB7 ${alignmentTags(alignment)}`) : ""}`);
2581
+ if (!process7.stdout.isTTY) details.push(colors_default.dim(` id ${id}`));
2582
+ }
2583
+ if (ids.length > STEP_ROWS_MAX) details.push(colors_default.dim(`+ ${ids.length - STEP_ROWS_MAX} more`));
2584
+ details.push(`${arriving.length > 0 ? colors_default.dim("after the upgrade: ") : ""}pnpm cella align`);
2585
+ return { label: "align", line, details };
2586
+ }
2587
+ async function readForkView(config, ref) {
2588
+ const { pinned = [], ignored = [] } = config.overrides ?? {};
2589
+ const counts = (path) => !isManagedFile(path) && !isGeneratedFile(path) && !isUpstreamOnly(path);
2590
+ const out = await git(["diff", "--name-only", "--no-renames", "-z", ref, "--"], config.forkPath);
2591
+ return {
2592
+ githubUrl: getGitHubBaseUrl(config.settings.upstreamUrl) ?? void 0,
2593
+ owns: (path) => counts(path) && isUnderAnyFolder(path, [...pinned, ...ignored]),
2594
+ changed: new Set(out.split("\0").filter((path) => path && counts(path)))
2595
+ };
2596
+ }
2597
+ async function describeAlignment(config, view, ref, alignment, open) {
2598
+ const { forkPath } = config;
2599
+ const [source, grepHits] = await Promise.all([
2600
+ readAlignmentSource(forkPath, ref, alignment.id),
2601
+ grepAlignmentTerms(forkPath, alignment.grep, (path) => view.changed.has(path))
2602
+ ]);
2603
+ const pr = source?.pr ?? null;
2604
+ return {
2605
+ ...alignment,
2606
+ kind: alignment.codemod ? "codemod" : "manual",
2607
+ open,
2608
+ url: alignmentUrl(view.githubUrl, ref, alignment.id) ?? null,
2609
+ commit: source?.commit ?? null,
2610
+ pr,
2611
+ prUrl: pr !== null && view.githubUrl ? `${view.githubUrl}/pull/${pr}` : null,
2612
+ sharedCommit: source?.sharedCommit ?? [],
2613
+ yourFiles: (source?.changedFiles ?? []).filter((path) => view.owns(path) || view.changed.has(path)),
2614
+ grepHits
2615
+ };
2616
+ }
2617
+ function grepSummary(alignment) {
2618
+ if (alignment.grep.length === 0) return null;
2619
+ const hits = alignment.grepHits.length;
2620
+ if (hits === 0) return "grep: no app hits";
2621
+ const files = new Set(alignment.grepHits.map((hit) => hit.file)).size;
2622
+ return `grep: ${hits} hit${hits === 1 ? "" : "s"} in ${files} app file${files === 1 ? "" : "s"}`;
2436
2623
  }
2437
- function printNote(index, note, url) {
2624
+ function printAlignment(index, alignment) {
2438
2625
  const tags = [
2439
- note.codemod ? "codemod" : "manual",
2440
- note.syncBreaking ? "sync-breaking" : null,
2441
- note.clientCacheBump ? "cache-bump" : null
2626
+ alignment.codemod ? "codemod" : "manual",
2627
+ alignment.breaking ? "breaking" : null,
2628
+ alignment.clientCacheBump ? "cache-bump" : null,
2629
+ alignment.caughtBy ? `caught by: ${alignment.caughtBy}` : null,
2630
+ alignment.generate ? "generate" : null
2442
2631
  ].filter(Boolean).join(", ");
2443
- console.info(`${index}. ${note.title} ${colors_default.dim(`[${tags}]`)}`);
2444
- console.info(colors_default.dim(` id: ${note.id}`));
2445
- if (note.summary) console.info(` ${note.summary}`);
2446
- console.info(colors_default.dim(` read: pnpm cella migrate --show ${note.id}`));
2447
- if (url) console.info(colors_default.dim(` ${url}`));
2448
- if (note.codemod) console.info(colors_default.dim(` codemod: pnpm cella migrate --run ${note.id}`));
2632
+ const grep = grepSummary(alignment);
2633
+ console.info(`${index}. ${alignment.title} ${colors_default.dim(`[${tags}]`)}`);
2634
+ console.info(colors_default.dim(` id: ${alignment.id}`));
2635
+ if (alignment.summary) console.info(` ${alignment.summary}`);
2636
+ if (alignment.pr !== null) {
2637
+ console.info(colors_default.dim(` added in #${alignment.pr}${alignment.prUrl ? ` ${alignment.prUrl}` : ""}`));
2638
+ }
2639
+ if (grep) console.info(colors_default.dim(` ${grep}`));
2640
+ console.info(colors_default.dim(` read: pnpm cella align --show ${alignment.id}`));
2641
+ if (alignment.url) console.info(colors_default.dim(` ${alignment.url}`));
2642
+ if (alignment.codemod) console.info(colors_default.dim(` codemod: pnpm cella align --run ${alignment.id}`));
2643
+ console.info();
2644
+ }
2645
+ function printShowBlock(title, items) {
2646
+ if (items.length === 0) return;
2647
+ console.info(colors_default.dim(`${title} (${items.length}):`));
2648
+ for (const item of items.slice(0, SHOW_LIST_MAX)) console.info(colors_default.dim(` ${item}`));
2649
+ if (items.length > SHOW_LIST_MAX) console.info(colors_default.dim(` ... + ${items.length - SHOW_LIST_MAX} more`));
2650
+ }
2651
+ async function showAlignment(config, ref, pending, id) {
2652
+ const { forkPath } = config;
2653
+ const upstreamBranch = `${DEFAULT_UPSTREAM_REMOTE}/${config.settings.upstreamBranch}`;
2654
+ let readRef = ref;
2655
+ let alignment = await readAlignment(forkPath, ref, id);
2656
+ if (!alignment && await revParse(forkPath, upstreamBranch)) {
2657
+ alignment = await readAlignment(forkPath, upstreamBranch, id);
2658
+ readRef = upstreamBranch;
2659
+ }
2660
+ if (!alignment) throw new Error(`no alignment '${id}' at upstream ${ref.slice(0, 7)} or ${upstreamBranch}`);
2661
+ const source = await git(["show", `${readRef}:${ALIGNMENTS_DIR}/${id}/README.md`], forkPath);
2662
+ const described = await describeAlignment(
2663
+ config,
2664
+ await readForkView(config, ref),
2665
+ readRef,
2666
+ alignment,
2667
+ pending.includes(id)
2668
+ );
2669
+ if (readRef !== ref) console.info(colors_default.dim(`not at the last upgrade ${ref.slice(0, 7)}: read at ${upstreamBranch}`));
2670
+ if (described.url) console.info(colors_default.dim(described.url));
2671
+ console.info(source);
2449
2672
  console.info();
2673
+ if (described.commit) {
2674
+ const pr = described.pr !== null ? `#${described.pr} ` : "";
2675
+ console.info(
2676
+ colors_default.dim(`added in ${pr}(${described.commit.slice(0, 9)})${described.prUrl ? ` ${described.prUrl}` : ""}`)
2677
+ );
2678
+ }
2679
+ printShowBlock("added in the same commit", described.sharedCommit);
2680
+ printShowBlock("files of that commit the app pins, ignores or changed", described.yourFiles);
2681
+ const grep = grepSummary(described);
2682
+ if (grep) console.info(colors_default.dim(`${grep} (${described.grep.join(", ")})`));
2683
+ printShowBlock(
2684
+ "grep hits",
2685
+ described.grepHits.map((hit) => `${hit.file}:${hit.line}`)
2686
+ );
2450
2687
  }
2451
- async function writeNoteFolder(config, ref, id) {
2688
+ async function writeAlignmentFolder(config, ref, id) {
2452
2689
  const { forkPath } = config;
2453
- const note = await readNote(forkPath, ref, id);
2454
- if (!note) throw new Error(`no migration note '${id}' at upstream ${ref.slice(0, 7)}`);
2455
- const files = (await git(["ls-tree", "-r", "--name-only", `${ref}:${NOTES_DIR}/${id}`], forkPath)).split("\n").filter(Boolean);
2690
+ const alignment = await readAlignment(forkPath, ref, id);
2691
+ if (!alignment) throw new Error(`no alignment '${id}' at upstream ${ref.slice(0, 7)}`);
2692
+ const files = (await git(["ls-tree", "-r", "--name-only", `${ref}:${ALIGNMENTS_DIR}/${id}`], forkPath)).split("\n").filter(Boolean);
2456
2693
  const target = join8(EXTRACT_DIR, id);
2457
2694
  for (const file of files) {
2458
- const content = execFileSync("git", ["show", `${ref}:${NOTES_DIR}/${id}/${file}`], {
2695
+ const content = execFileSync("git", ["show", `${ref}:${ALIGNMENTS_DIR}/${id}/${file}`], {
2459
2696
  cwd: forkPath,
2460
2697
  maxBuffer: MAX_BUFFER
2461
2698
  });
@@ -2463,27 +2700,27 @@ async function writeNoteFolder(config, ref, id) {
2463
2700
  await mkdir4(dirname4(path), { recursive: true });
2464
2701
  await writeFile3(path, content);
2465
2702
  }
2466
- return { note, target, files };
2703
+ return { alignment, target, files };
2467
2704
  }
2468
- async function extractNote(config, ref, id) {
2469
- const { note, target, files } = await writeNoteFolder(config, ref, id);
2705
+ async function extractAlignment(config, ref, id) {
2706
+ const { alignment, target, files } = await writeAlignmentFolder(config, ref, id);
2470
2707
  console.info(`${checkMark} extracted ${files.length} file(s) to ${target}/`);
2471
- console.info(colors_default.dim(` the README's ${NOTES_DIR}/${id}/ paths are this folder.`));
2472
- if (note.codemod) {
2473
- console.info(colors_default.dim(` pnpm exec tsx ${target}/${note.codemod} inventory ${note.roots.join(" ")}`));
2474
- console.info(colors_default.dim(` or, keeping files identical to upstream as they are: pnpm cella migrate --run ${id}`));
2708
+ console.info(colors_default.dim(` the README's ${ALIGNMENTS_DIR}/${id}/ paths are this folder.`));
2709
+ if (alignment.codemod) {
2710
+ console.info(colors_default.dim(` pnpm exec tsx ${target}/${alignment.codemod} inventory ${alignment.roots.join(" ")}`));
2711
+ console.info(colors_default.dim(` or, keeping files identical to upstream as they are: pnpm cella align --run ${id}`));
2475
2712
  }
2476
2713
  }
2477
- async function runNote(config, ref, id) {
2714
+ async function runAlignment(config, ref, id) {
2478
2715
  const { forkPath } = config;
2479
- const { note, target } = await writeNoteFolder(config, ref, id);
2480
- if (!note.codemod) {
2716
+ const { alignment, target } = await writeAlignmentFolder(config, ref, id);
2717
+ if (!alignment.codemod) {
2481
2718
  throw new Error(
2482
- `migration note '${id}' ships no codemod script: its steps are manual, read them with \`pnpm cella migrate --show ${id}\`.`
2719
+ `alignment '${id}' ships no codemod script: its steps are manual, read them with \`pnpm cella align --show ${id}\`.`
2483
2720
  );
2484
2721
  }
2485
- const script = join8(target, note.codemod);
2486
- const args = config.runArgs?.length ? config.runArgs : ["inventory", ...note.roots];
2722
+ const script = join8(target, alignment.codemod);
2723
+ const args = config.runArgs?.length ? config.runArgs : ["inventory", ...alignment.roots];
2487
2724
  const upstreamCommit = await readManifestBase(forkPath) ?? ref;
2488
2725
  await ensureUpstreamCommit(config, upstreamCommit);
2489
2726
  const identical = await listIdenticalToRef(forkPath, upstreamCommit);
@@ -2508,92 +2745,335 @@ async function runNote(config, ref, id) {
2508
2745
  }
2509
2746
  if (child.status !== 0) throw new Error(`the codemod exited with code ${child.status ?? "unknown"}`);
2510
2747
  if (!config.runArgs?.length) {
2511
- const apply = ["rewrite", ...note.roots].join(" ");
2512
- console.info(colors_default.dim(`report only. to apply: pnpm cella migrate --run ${id} -- ${apply}`));
2748
+ const apply = ["rewrite", ...alignment.roots].join(" ");
2749
+ console.info(colors_default.dim(`report only. to apply: pnpm cella align --run ${id} -- ${apply}`));
2513
2750
  }
2514
2751
  }
2515
- async function markNotes(config, ref, pending, ids) {
2752
+ async function markAlignments(config, ref, pending, ids) {
2516
2753
  const open = new Set(pending);
2517
2754
  const unknown = ids.filter((id) => !open.has(id));
2518
2755
  for (const id of ids) open.delete(id);
2519
2756
  await writePending(config.forkPath, [...open]);
2520
2757
  const marked = ids.length - unknown.length;
2521
- if (marked > 0) console.info(`${checkMark} recorded ${marked} note(s) as handled`);
2758
+ if (marked > 0) console.info(`${checkMark} recorded ${marked} alignment(s) as handled`);
2522
2759
  if (unknown.length > 0) console.info(colors_default.dim(`not pending, left as is: ${unknown.join(", ")}`));
2523
- console.info(colors_default.dim(formatNotesLine((await listNoteIds(config.forkPath, ref)).length, open.size)));
2760
+ console.info(colors_default.dim(formatAlignmentsLine((await listAlignmentIds(config.forkPath, ref)).length, open.size)));
2524
2761
  }
2525
- async function runMigrate(config) {
2762
+ async function runAlign(config) {
2526
2763
  const { forkPath } = config;
2527
- const ref = await resolveNotesRef(config);
2764
+ const ref = await resolveAlignmentsRef(config);
2528
2765
  if (!ref) {
2529
- console.info(colors_default.dim("no sync point recorded yet: migration notes start with the first `pnpm cella sync`."));
2766
+ console.info(colors_default.dim("no upgrade recorded yet: alignments start with the first `pnpm cella upgrade`."));
2530
2767
  return;
2531
2768
  }
2532
2769
  const pending = await readPending(forkPath);
2533
- const githubUrl = getGitHubBaseUrl(config.settings.upstreamUrl) ?? void 0;
2534
- const urlOf = (id) => noteUrl(githubUrl, ref, id);
2535
- if (config.mark?.length) return markNotes(config, ref, pending, config.mark);
2536
- if (config.run) return runNote(config, ref, config.run);
2537
- if (config.extract) return extractNote(config, ref, config.extract);
2538
- if (config.show) {
2539
- const source = await git(["show", `${ref}:${NOTES_DIR}/${config.show}/README.md`], forkPath, {
2540
- ignoreErrors: true
2541
- });
2542
- if (!source) throw new Error(`no migration note '${config.show}' at upstream ${ref.slice(0, 7)}`);
2543
- const url = urlOf(config.show);
2544
- if (url) console.info(colors_default.dim(url));
2545
- console.info(source);
2546
- return;
2547
- }
2548
- const ids = config.all ? await listNoteIds(forkPath, ref) : pending;
2549
- const notes = (await Promise.all(ids.map((id) => readNote(forkPath, ref, id)))).map(
2550
- (note, i) => note ?? {
2770
+ if (config.mark?.length) return markAlignments(config, ref, pending, config.mark);
2771
+ if (config.run) return runAlignment(config, ref, config.run);
2772
+ if (config.extract) return extractAlignment(config, ref, config.extract);
2773
+ if (config.show) return showAlignment(config, ref, pending, config.show);
2774
+ const ids = config.all ? await listAlignmentIds(forkPath, ref) : pending;
2775
+ const read = await Promise.all(ids.map((id) => readAlignment(forkPath, ref, id)));
2776
+ const alignments = read.map(
2777
+ (alignment, i) => alignment ?? {
2551
2778
  id: ids[i],
2552
2779
  title: `${ids[i]} (no longer upstream)`,
2553
2780
  summary: "",
2554
- syncBreaking: false,
2781
+ breaking: false,
2555
2782
  clientCacheBump: false,
2783
+ caughtBy: null,
2784
+ generate: false,
2785
+ grep: [],
2556
2786
  roots: [],
2557
2787
  codemod: null
2558
2788
  }
2559
2789
  );
2560
- const total = (await listNoteIds(forkPath, ref)).length;
2790
+ const total = (await listAlignmentIds(forkPath, ref)).length;
2791
+ const open = new Set(pending);
2792
+ const view = await readForkView(config, ref);
2793
+ const described = await Promise.all(
2794
+ alignments.map((alignment) => describeAlignment(config, view, ref, alignment, open.has(alignment.id)))
2795
+ );
2561
2796
  if (config.json) {
2562
- const open = new Set(pending);
2563
- const out = {
2564
- upstreamCommit: ref,
2565
- total,
2566
- open: pending.length,
2567
- notes: notes.map((note) => ({
2568
- ...note,
2569
- kind: note.codemod ? "codemod" : "manual",
2570
- open: open.has(note.id),
2571
- url: urlOf(note.id) ?? null
2572
- }))
2573
- };
2574
- writeStdout(JSON.stringify(out, null, 2));
2797
+ writeStdout(JSON.stringify({ upstreamCommit: ref, total, open: pending.length, alignments: described }, null, 2));
2575
2798
  return;
2576
2799
  }
2577
- console.info(colors_default.dim(formatNotesLine(total, pending.length)));
2578
- if (notes.length === 0) return;
2800
+ console.info(colors_default.dim(formatAlignmentsLine(total, pending.length)));
2801
+ if (described.length === 0) return;
2579
2802
  console.info();
2580
- for (const [i, note] of notes.entries()) printNote(i + 1, note, urlOf(note.id));
2803
+ for (const [i, alignment] of described.entries()) printAlignment(i + 1, alignment);
2804
+ const schema = described.filter((alignment) => alignment.open && alignment.generate);
2805
+ if (schema.length > 1) {
2806
+ console.info(
2807
+ colors_default.dim(
2808
+ `${schema.length} open alignments change the schema: work their code steps first, then run pnpm generate once.`
2809
+ )
2810
+ );
2811
+ }
2581
2812
  if (pending.length > 0) {
2582
- console.info(colors_default.dim("work each note's README, check with `pnpm check`, then record it:"));
2583
- console.info(colors_default.dim(` pnpm cella migrate --mark ${pending.join(" ")}`));
2813
+ console.info(colors_default.dim("work each alignment's README, check with `pnpm check`, then record it:"));
2814
+ console.info(colors_default.dim(` pnpm cella align --mark ${pending.join(" ")}`));
2584
2815
  }
2585
2816
  }
2586
2817
 
2587
- // src/services/review-page.ts
2818
+ // src/services/analyze-report.ts
2588
2819
  import { basename as basename2 } from "path";
2589
- import process7 from "process";
2820
+ var BEHIND_FLAT_MAX = 15;
2821
+ var AHEAD_FLAT_MAX = 8;
2822
+ var ABSENT_SHOWN_MIN = 10;
2823
+ var ENTRY_COLLAPSE_MIN = 5;
2824
+ var SECTION_ROWS_MAX = 100;
2825
+ var UPSTREAM_CHANGE = { fields: "changed", repo: "upstream" };
2826
+ var UPSTREAM_SIDE = { fields: "upstream", repo: "upstream" };
2827
+ var FORK_SIDE = { fields: "changed", repo: "fork" };
2828
+ function cell(value, width, color, align) {
2829
+ if (!value) return " ".repeat(width);
2830
+ return padVisible(color ? color(value) : value, width, align === "end" ? "end" : "start");
2831
+ }
2832
+ function formatRows(rows, caption) {
2833
+ const iconWidth = rows.some((row) => row.icon) ? 2 : 0;
2834
+ const labelWidth = Math.max(...rows.map((row) => row.label.length + (row.note ? row.note.length + 1 : 0)));
2835
+ const countWidth = Math.max(0, ...rows.map((row) => row.count?.length ?? 0));
2836
+ const tagWidth = Math.max(0, ...rows.map((row) => row.tag?.length ?? 0));
2837
+ const ageWidth = Math.max(0, ...rows.map((row) => row.age?.length ?? 0));
2838
+ const lines = [];
2839
+ if (caption && countWidth > 0) lines.push(colors_default.dim(caption.padStart(2 + iconWidth + labelWidth + 2 + countWidth)));
2840
+ for (const row of rows) {
2841
+ const icon = iconWidth ? cell(row.icon, iconWidth) : "";
2842
+ const label = `${hyperlink(row.label, row.link)}${row.note ? ` ${colors_default.dim(row.note)}` : ""}`;
2843
+ const cells = [` ${icon}${padVisible(label, labelWidth)}`];
2844
+ if (countWidth) cells.push(cell(row.count, countWidth, row.countColor, "end"));
2845
+ if (tagWidth) cells.push(cell(row.tag, tagWidth, row.tagColor));
2846
+ if (ageWidth) cells.push(cell(row.age, ageWidth, colors_default.dim, "end"));
2847
+ lines.push(cells.join(" ").trimEnd());
2848
+ }
2849
+ return lines;
2850
+ }
2851
+ function formatCappedRows(rows, caption) {
2852
+ if (rows.length <= SECTION_ROWS_MAX) return formatRows(rows, caption);
2853
+ return [
2854
+ ...formatRows(rows.slice(0, SECTION_ROWS_MAX), caption),
2855
+ colors_default.dim(` + ${rows.length - SECTION_ROWS_MAX} more`)
2856
+ ];
2857
+ }
2858
+ function hint(text, command = "") {
2859
+ return ` ${colors_default.dim(text)}${command}`;
2860
+ }
2861
+ function printSection(title, count3, body, hints = []) {
2862
+ printSectionHeader(`${title} ${colors_default.dim(`\xB7 ${plural(count3, "file")}`)}`);
2863
+ for (const line of body) console.info(line);
2864
+ if (hints.length === 0) return;
2865
+ console.info();
2866
+ for (const line of hints) console.info(line);
2867
+ }
2868
+ function byPath(a, b) {
2869
+ return a.path.localeCompare(b.path);
2870
+ }
2871
+ function fileLink(file, side, links) {
2872
+ const { fileLinkMode = "commit", upstreamGitHubUrl, upstreamSha, forkGitHubUrl } = links;
2873
+ if (fileLinkMode === "file") {
2874
+ if (!upstreamGitHubUrl || !upstreamSha || !file.existsInUpstream) return void 0;
2875
+ return `${upstreamGitHubUrl}/blob/${upstreamSha}/${file.path}`;
2876
+ }
2877
+ const commit = side.fields === "upstream" ? file.upstreamCommit : file.changedCommit;
2878
+ const repo = side.repo === "upstream" ? upstreamGitHubUrl : forkGitHubUrl;
2879
+ return commit && repo ? `${repo}/commit/${commit}` : void 0;
2880
+ }
2881
+ function changeTimestamp(file, side) {
2882
+ return side.fields === "upstream" ? file.upstreamChangedTs : file.changedTs;
2883
+ }
2884
+ function changeKind(file) {
2885
+ if (!file.existsInFork) return "new";
2886
+ if (!file.existsInUpstream) return "deleted";
2887
+ return "changed";
2888
+ }
2889
+ var KIND_TAG = { new: { tag: "new", color: colors_default.green }, deleted: { tag: "deleted", color: colors_default.red } };
2890
+ function fileRow(file, side, options, extra = {}) {
2891
+ const timestamp = changeTimestamp(file, side);
2892
+ const kind = changeKind(file);
2893
+ return {
2894
+ label: file.path,
2895
+ link: fileLink(file, side, options.links),
2896
+ age: timestamp ? shortAge(timestamp, options.nowMs) : void 0,
2897
+ ...kind === "changed" ? {} : { tag: KIND_TAG[kind].tag, tagColor: KIND_TAG[kind].color },
2898
+ ...extra
2899
+ };
2900
+ }
2901
+ function commonFolder(paths) {
2902
+ const folders = paths.map((path) => path.split("/").slice(0, -1));
2903
+ const [first] = folders;
2904
+ let depth = 0;
2905
+ while (depth < first.length && folders.every((parts) => parts[depth] === first[depth])) depth++;
2906
+ return first.slice(0, depth).join("/");
2907
+ }
2908
+ function formatFolderRows(files, columns) {
2909
+ const groups = /* @__PURE__ */ new Map();
2910
+ for (const file of files) {
2911
+ const top = file.path.includes("/") ? file.path.split("/")[0] : file.path;
2912
+ groups.set(top, [...groups.get(top) ?? [], file]);
2913
+ }
2914
+ const rows = [...groups.values()].map((group) => ({
2915
+ label: group.length === 1 ? group[0].path : `${commonFolder(group.map((file) => file.path))}/`,
2916
+ files: group
2917
+ })).sort((a, b) => b.files.length - a.files.length || a.label.localeCompare(b.label));
2918
+ const labelWidth = Math.max(...rows.map((row) => row.label.length));
2919
+ const countWidth = Math.max(...rows.map((row) => String(row.files.length).length));
2920
+ const order = { new: 0, deleted: 1, changed: 2 };
2921
+ return rows.map((row) => {
2922
+ let line = ` ${row.label.padEnd(labelWidth)} ${colors_default.cyan(String(row.files.length).padStart(countWidth))}`;
2923
+ if (row.files.length === 1) return line.trimEnd();
2924
+ const seen = /* @__PURE__ */ new Map();
2925
+ for (const file of row.files) seen.set(basename2(file.path), (seen.get(basename2(file.path)) ?? 0) + 1);
2926
+ const names = row.files.map((file) => ({
2927
+ kind: changeKind(file),
2928
+ name: (seen.get(basename2(file.path)) ?? 0) > 1 ? file.path.slice(row.label.length) : basename2(file.path)
2929
+ })).sort((a, b) => order[a.kind] - order[b.kind] || a.name.localeCompare(b.name));
2930
+ let width = visibleWidth(line);
2931
+ for (const { kind, name } of names) {
2932
+ const text = kind === "new" ? `+${name}` : kind === "deleted" ? `\u2212${name}` : name;
2933
+ if (width + 2 + text.length > columns - 2) {
2934
+ line += colors_default.dim(" \u2026");
2935
+ break;
2936
+ }
2937
+ line += ` ${kind === "new" ? colors_default.green(text) : kind === "deleted" ? colors_default.red(text) : colors_default.dim(text)}`;
2938
+ width += 2 + text.length;
2939
+ }
2940
+ return line;
2941
+ });
2942
+ }
2943
+ function printBehind(files, options, columns) {
2944
+ const behind = filesWithStatus(files, "behind").sort(byPath);
2945
+ if (behind.length === 0) return;
2946
+ const title = colors_default.cyan("\u2193 behind on upstream");
2947
+ if (options.all || behind.length <= BEHIND_FLAT_MAX) {
2948
+ printSection(title, behind.length, formatCappedRows(behind.map((file) => fileRow(file, UPSTREAM_CHANGE, options))));
2949
+ return;
2950
+ }
2951
+ printSection(title, behind.length, formatFolderRows(behind, columns), [
2952
+ hint("every path: ", "pnpm cella analyze --all")
2953
+ ]);
2954
+ }
2955
+ function printFlat(files, title, side, options) {
2956
+ if (files.length === 0) return;
2957
+ const rows = [...files].sort(byPath).map((file) => fileRow(file, side, options));
2958
+ printSection(title, files.length, formatCappedRows(rows));
2959
+ }
2960
+ function absentLines(file) {
2961
+ return file.upstreamLinesAbsent ?? 0;
2962
+ }
2963
+ function describeAbsent(files) {
2964
+ const counts = files.map(absentLines);
2965
+ const missing = counts.filter((count3) => count3 > 0);
2966
+ const parts = [];
2967
+ if (missing.length > 0) {
2968
+ const [low, high] = [Math.min(...missing), Math.max(...missing)];
2969
+ parts.push(`${missing.length} with ${low === high ? plural(low, "line") : `${low}\u2013${high} lines`} absent`);
2970
+ }
2971
+ if (missing.length < counts.length) parts.push(`${counts.length - missing.length} with none`);
2972
+ return parts.join(", ");
2973
+ }
2974
+ function printAhead(files, options) {
2975
+ const ahead = filesWithStatus(files, "ahead").sort((a, b) => absentLines(b) - absentLines(a) || byPath(a, b));
2976
+ if (ahead.length === 0) return;
2977
+ let shown = ahead;
2978
+ if (!options.all && ahead.length > AHEAD_FLAT_MAX) {
2979
+ const many = ahead.filter((file) => absentLines(file) >= ABSENT_SHOWN_MIN);
2980
+ shown = many.length > 0 ? many : ahead.slice(0, 5);
2981
+ }
2982
+ const rest = ahead.slice(shown.length);
2983
+ const rows = shown.map(
2984
+ (file) => fileRow(file, FORK_SIDE, options, {
2985
+ count: absentLines(file) > 0 ? String(absentLines(file)) : void 0,
2986
+ countColor: colors_default.yellow
2987
+ })
2988
+ );
2989
+ const body = formatCappedRows(rows, "upstream lines absent");
2990
+ if (rest.length > 0) body.push(colors_default.dim(` + ${rest.length} more: ${describeAbsent(rest)}`));
2991
+ const hints = ahead.some((file) => absentLines(file) > 0) ? [hint("what a merge would bring in: ", "pnpm cella analyze --unpinned")] : [];
2992
+ printSection(colors_default.blue("\u2191 protected in fork"), ahead.length, body, hints);
2993
+ }
2994
+ function entryFinder(overrides) {
2995
+ const entries = [...overrides?.ignored ?? [], ...overrides?.pinned ?? []].map(normalizeEntry).filter(Boolean).sort((a, b) => b.length - a.length);
2996
+ return (path) => entries.find((entry) => isUnderAnyFolder(path, [entry]));
2997
+ }
2998
+ var KIND_ORDER = { new: 0, changed: 1, deleted: 2 };
2999
+ function protectedRow(items, options) {
3000
+ const [{ file, kind, entry }] = items;
3001
+ const lines = items.map((item) => item.file.upstreamChangedLines);
3002
+ const total = lines.every((count3) => count3 !== void 0) ? lines.reduce((sum, count3) => sum + (count3 ?? 0), 0) : void 0;
3003
+ const sign = kind === "new" ? "+" : kind === "deleted" ? "\u2212" : "";
3004
+ const newest = Math.max(0, ...items.map((item) => changeTimestamp(item.file, UPSTREAM_SIDE) ?? 0));
3005
+ const row = {
3006
+ label: file.path,
3007
+ link: fileLink(file, UPSTREAM_SIDE, options.links),
3008
+ icon: protectionIcon(file),
3009
+ count: total === void 0 ? void 0 : `${sign}${compactCount(total)}`,
3010
+ countColor: kind === "new" ? colors_default.green : kind === "deleted" ? colors_default.red : colors_default.yellow,
3011
+ ...kind === "changed" ? {} : { tag: KIND_TAG[kind].tag, tagColor: KIND_TAG[kind].color },
3012
+ age: newest ? shortAge(newest, options.nowMs) : void 0
3013
+ };
3014
+ if (items.length === 1 || !entry) return row;
3015
+ const names = new Set(items.map((item) => basename2(item.file.path)));
3016
+ const { upstreamGitHubUrl, upstreamSha } = options.links;
3017
+ return {
3018
+ ...row,
3019
+ label: `${entry}/`,
3020
+ note: names.size === 1 ? `\xB7 ${items.length} \xD7 ${[...names][0]}` : `\xB7 ${items.length} files`,
3021
+ link: upstreamGitHubUrl && upstreamSha ? `${upstreamGitHubUrl}/tree/${upstreamSha}/${entry}` : void 0
3022
+ };
3023
+ }
3024
+ function printProtectedBehind(result, options) {
3025
+ const findEntry = entryFinder(options.overrides);
3026
+ const items = findProtectedBehindUpstream(result).map((file) => ({
3027
+ file,
3028
+ kind: file.upstreamOnly ? changeKind(file) : "changed",
3029
+ entry: findEntry(file.path)
3030
+ }));
3031
+ if (items.length === 0) return;
3032
+ const groups = /* @__PURE__ */ new Map();
3033
+ for (const item of items) {
3034
+ const key = options.all || !item.entry ? item.file.path : `${item.kind}\0${item.entry}`;
3035
+ groups.set(key, [...groups.get(key) ?? [], item]);
3036
+ }
3037
+ const single = (group) => group.length < ENTRY_COLLAPSE_MIN;
3038
+ const lines = (group) => group.reduce((sum, item) => sum + (item.file.upstreamChangedLines ?? 0), 0);
3039
+ const ordered = [...groups.values()].flatMap((group) => single(group) ? group.map((item) => [item]) : [group]).sort(
3040
+ (a, b) => Number(a.length > 1) - Number(b.length > 1) || KIND_ORDER[a[0].kind] - KIND_ORDER[b[0].kind] || lines(b) - lines(a) || byPath(a[0].file, b[0].file)
3041
+ );
3042
+ printSection(
3043
+ `${warningMark} ${colors_default.yellow("protected but behind upstream")}`,
3044
+ items.length,
3045
+ formatCappedRows(
3046
+ ordered.map((group) => protectedRow(group, options)),
3047
+ "lines changed upstream"
3048
+ ),
3049
+ [hint("the fork side wins: adopt what you need from the review page")]
3050
+ );
3051
+ }
3052
+ function printAnalysisFileGroups(result, options) {
3053
+ const columns = options.columns ?? terminalColumns();
3054
+ printBehind(result.files, options, columns);
3055
+ printFlat(filesWithStatus(result.files, "diverged"), colors_default.magenta("\u21C5 diverged"), UPSTREAM_SIDE, options);
3056
+ printAhead(result.files, options);
3057
+ printFlat(
3058
+ filesWithStatus(result.files, "drifted"),
3059
+ `${warningMark} ${colors_default.yellow("drifted from upstream")}`,
3060
+ FORK_SIDE,
3061
+ options
3062
+ );
3063
+ printProtectedBehind(result, options);
3064
+ }
3065
+
3066
+ // src/services/review-page.ts
3067
+ import { existsSync as existsSync7, rmSync } from "fs";
3068
+ import { basename as basename3 } from "path";
3069
+ import process9 from "process";
2590
3070
  import { pathToFileURL } from "url";
2591
3071
 
2592
3072
  // src/utils/diff.ts
2593
3073
  import { execFileSync as execFileSync2 } from "child_process";
2594
3074
  import { mkdirSync, writeFileSync } from "fs";
2595
- import { join as join9 } from "path";
2596
- import process6 from "process";
3075
+ import { dirname as dirname5, join as join9 } from "path";
3076
+ import process8 from "process";
2597
3077
  function gitDiffFile(cwd, range, filePath) {
2598
3078
  try {
2599
3079
  return execFileSync2("git", ["diff", "--no-color", range, "--", filePath], { cwd, maxBuffer: MAX_BUFFER });
@@ -2628,9 +3108,9 @@ ${prerenderedHTML}
2628
3108
  for (const file of section.files) files.push(await renderFile(file));
2629
3109
  const count3 = section.files.length + (section.omitted?.length ?? 0);
2630
3110
  const title = section.title ? `<h2>${escapeHtml(section.title)} <span class="count">\xB7 ${count3} ${count3 === 1 ? "file" : "files"}</span></h2>` : "";
2631
- const hint = section.hint ? `<p class="hint">${escapeHtml(section.hint)}</p>` : "";
3111
+ const hint2 = section.hint ? `<p class="hint">${escapeHtml(section.hint)}</p>` : "";
2632
3112
  const omitted = section.omitted?.length ? `<p class="hint">Not shown here (binary, too large, or past the page limit):</p><ul class="omitted">${section.omitted.map((path) => `<li><code>${escapeHtml(path)}</code></li>`).join("")}</ul>` : "";
2633
- blocks.push(`<section id="section-${index2 + 1}">${title}${hint}${files.join("\n")}${omitted}</section>`);
3113
+ blocks.push(`<section id="section-${index2 + 1}">${title}${hint2}${files.join("\n")}${omitted}</section>`);
2634
3114
  }
2635
3115
  const titled = sections.filter((section) => section.title);
2636
3116
  const index = titled.length > 1 ? `<nav><ol>${sections.map(
@@ -2694,16 +3174,18 @@ ${blocks.join("\n")}
2694
3174
  }
2695
3175
  async function writeDiffPage(sections, meta, repoPath, name) {
2696
3176
  const html = await renderDiffPage(sections, meta);
2697
- const gitDir = execFileSync2("git", ["rev-parse", "--absolute-git-dir"], { cwd: repoPath, encoding: "utf8" }).trim();
2698
- const dir = join9(gitDir, "cella-diff");
2699
- mkdirSync(dir, { recursive: true });
2700
- const outPath = join9(dir, `${name.replace(/[^\w.-]+/g, "-")}.html`);
3177
+ const outPath = diffPagePath(repoPath, name);
3178
+ mkdirSync(dirname5(outPath), { recursive: true });
2701
3179
  writeFileSync(outPath, html);
2702
3180
  return outPath;
2703
3181
  }
3182
+ function diffPagePath(repoPath, name) {
3183
+ const gitDir = execFileSync2("git", ["rev-parse", "--absolute-git-dir"], { cwd: repoPath, encoding: "utf8" }).trim();
3184
+ return join9(gitDir, "cella-diff", `${name.replace(/[^\w.-]+/g, "-")}.html`);
3185
+ }
2704
3186
  function openInBrowser(target) {
2705
3187
  try {
2706
- execFileSync2(process6.platform === "darwin" ? "open" : "xdg-open", [target], { stdio: "ignore" });
3188
+ execFileSync2(process8.platform === "darwin" ? "open" : "xdg-open", [target], { stdio: "ignore" });
2707
3189
  return true;
2708
3190
  } catch {
2709
3191
  return false;
@@ -2711,49 +3193,50 @@ function openInBrowser(target) {
2711
3193
  }
2712
3194
 
2713
3195
  // src/services/review-page.ts
2714
- var MAX_PAGE_FILES = 50;
3196
+ var PAGE_LIMITS = { files: 50, lines: 15e3, patchLines: 2e3 };
2715
3197
  var MAX_PATCH_BYTES = 512 * 1024;
2716
3198
  function reviewSections(result, command) {
2717
3199
  const [base, upstream] = result.upstreamDiffRange?.split("..") ?? [];
2718
3200
  if (!base || !upstream) return [];
2719
3201
  const sinceSync = `${base}..${upstream}`;
2720
- const dropped = command === "sync" ? {
2721
- title: "protected files kept the fork version",
2722
- hint: "What upstream changed in these files since the last sync. The fork kept its own version, so none of it arrived: adopt what you need.",
2723
- range: sinceSync,
2724
- paths: result.protectedConflicts ?? []
2725
- } : {
2726
- title: "protected but behind upstream",
2727
- hint: "What upstream changed in these files since the last sync. They are pinned or ignored, so the sync keeps the fork version and none of it arrives: adopt what you need.",
2728
- range: sinceSync,
2729
- paths: findProtectedBehind(result.files).map((file) => file.path)
2730
- };
2731
- const sections = [
2732
- dropped,
3202
+ const sections = command === "upgrade" ? [
3203
+ {
3204
+ title: "protected files kept the fork version",
3205
+ hint: "What upstream changed in these files since the last upgrade. The fork kept its own version, so none of it arrived: adopt what you need.",
3206
+ range: sinceSync,
3207
+ paths: result.protectedConflicts ?? []
3208
+ },
2733
3209
  {
2734
3210
  title: "ignored paths changed upstream",
2735
- hint: "What upstream changed under ignored paths since the last sync. The fork left these files untouched, and ignored paths never sync.",
3211
+ hint: "What upstream changed under ignored paths since the last upgrade. The fork left these files untouched, and ignored paths never merge.",
2736
3212
  range: sinceSync,
2737
3213
  paths: (result.ignoredUpstreamChanges ?? []).flatMap((group) => group.paths)
2738
- },
3214
+ }
3215
+ ] : [
2739
3216
  {
2740
- title: "pinned files that match the previous upstream",
2741
- hint: "What upstream changed in these files since the last sync. The fork copy still equals the previous upstream, so the pin only drops these changes: unpin to take them.",
3217
+ title: "protected but behind upstream",
3218
+ hint: "What upstream changed in these files since the last upgrade. They are pinned or ignored, so the upgrade keeps the fork version and none of it arrives: adopt what you need.",
2742
3219
  range: sinceSync,
2743
- paths: findMaskingPins(result.files).map((file) => file.path)
3220
+ paths: findProtectedBehindUpstream(result).map((file) => file.path)
2744
3221
  }
2745
3222
  ];
3223
+ sections.push({
3224
+ title: "pinned files that match the previous upstream",
3225
+ hint: "What upstream changed in these files since the last upgrade. The fork copy still equals the previous upstream, so the pin only drops these changes: unpin to take them.",
3226
+ range: sinceSync,
3227
+ paths: findMaskingPins(result.files).map((file) => file.path)
3228
+ });
2746
3229
  if (command === "analyze") {
2747
3230
  sections.push(
2748
3231
  {
2749
3232
  title: "protected in fork, upstream lines absent",
2750
- hint: "The fork's version against upstream's, both as they are now: added lines are upstream content the fork does not have, dropped by an earlier sync or removed on purpose.",
3233
+ hint: "The fork's version against upstream's, both as they are now: added lines are upstream content the fork does not have, dropped by an earlier upgrade or removed on purpose.",
2751
3234
  range: `HEAD..${upstream}`,
2752
3235
  paths: filesWithStatus(result.files, "ahead").filter((file) => (file.upstreamLinesAbsent ?? 0) > 0).map((file) => file.path)
2753
3236
  },
2754
3237
  {
2755
3238
  title: "drifted from upstream",
2756
- hint: "What the fork changed in these files since the last sync. They are not pinned or ignored, so upstream changes still merge into them.",
3239
+ hint: "What the fork changed in these files since the last upgrade. They are not pinned or ignored, so upstream changes still merge into them.",
2757
3240
  range: `${base}..HEAD`,
2758
3241
  paths: filesWithStatus(result.files, "drifted").map((file) => file.path)
2759
3242
  }
@@ -2761,55 +3244,100 @@ function reviewSections(result, command) {
2761
3244
  }
2762
3245
  return sections.filter((section) => section.paths.length > 0);
2763
3246
  }
2764
- async function writeReviewPage(forkPath, sections, meta) {
3247
+ function countLines(patch) {
3248
+ let count3 = 0;
3249
+ for (let at = patch.indexOf(10); at !== -1; at = patch.indexOf(10, at + 1)) count3++;
3250
+ return count3;
3251
+ }
3252
+ async function writeReviewPage(forkPath, sections, meta, limits = PAGE_LIMITS) {
2765
3253
  const [base, upstream] = meta.range.split("..");
3254
+ const pageSections = sections.map((section) => ({
3255
+ title: section.title,
3256
+ hint: section.hint,
3257
+ files: [],
3258
+ omitted: []
3259
+ }));
3260
+ const queues = sections.map((section) => [...section.paths]);
2766
3261
  let diffs = 0;
2767
- const pageSections = [];
2768
- for (const section of sections) {
2769
- const pageSection = { title: section.title, hint: section.hint, files: [], omitted: [] };
2770
- for (const path of section.paths) {
2771
- if (diffs >= MAX_PAGE_FILES) {
2772
- pageSection.omitted?.push(path);
3262
+ let lines = 0;
3263
+ while (queues.some((queue) => queue.length > 0)) {
3264
+ for (const [index, queue] of queues.entries()) {
3265
+ const path = queue.shift();
3266
+ if (path === void 0) continue;
3267
+ const pageSection = pageSections[index];
3268
+ if (diffs >= limits.files || lines >= limits.lines) {
3269
+ pageSection.omitted.push(path);
2773
3270
  continue;
2774
3271
  }
2775
- const patch = gitDiffFile(forkPath, section.range, path);
3272
+ const patch = gitDiffFile(forkPath, sections[index].range, path);
2776
3273
  if (patch.length === 0) continue;
2777
- if (patch.length > MAX_PATCH_BYTES || !patch.includes("\n@@ ")) {
2778
- pageSection.omitted?.push(path);
3274
+ const patchLines = patch.length > MAX_PATCH_BYTES ? Number.POSITIVE_INFINITY : countLines(patch);
3275
+ if (patchLines > limits.patchLines || lines + patchLines > limits.lines || !patch.includes("\n@@ ")) {
3276
+ pageSection.omitted.push(path);
2779
3277
  continue;
2780
3278
  }
2781
3279
  pageSection.files.push({ path, patch: patch.toString() });
2782
3280
  diffs++;
3281
+ lines += patchLines;
2783
3282
  }
2784
- if (pageSection.files.length > 0 || pageSection.omitted?.length) pageSections.push(pageSection);
2785
3283
  }
2786
3284
  if (diffs === 0) return void 0;
3285
+ const shown = pageSections.filter((section) => section.files.length > 0 || section.omitted.length > 0);
2787
3286
  const page = await writeDiffPage(
2788
- pageSections,
2789
- { title: `${meta.command} review`, note: `${basename2(forkPath)} \xB7 last sync ${base} \u2192 upstream ${upstream}` },
3287
+ shown,
3288
+ { title: `${meta.command} review`, note: `${basename3(forkPath)} \xB7 last upgrade ${base} \u2192 upstream ${upstream}` },
2790
3289
  forkPath,
2791
3290
  `${meta.command}-review`
2792
3291
  );
2793
- return { path: page, diffs };
3292
+ const listed = shown.reduce((count3, section) => count3 + section.omitted.length, 0);
3293
+ return { path: page, diffs, listed, sections: shown.length };
2794
3294
  }
2795
3295
  async function reportReviewPage(forkPath, result, command) {
2796
3296
  const sections = reviewSections(result, command);
2797
- if (sections.length === 0 || !result.upstreamDiffRange) return;
2798
- createSpinner("writing review page...");
2799
3297
  let page;
3298
+ if (sections.length > 0 && result.upstreamDiffRange) {
3299
+ createSpinner("writing review page...");
3300
+ try {
3301
+ page = await writeReviewPage(forkPath, sections, { command, range: result.upstreamDiffRange });
3302
+ } catch {
3303
+ page = void 0;
3304
+ }
3305
+ spinnerSuccess();
3306
+ }
3307
+ if (!page) {
3308
+ if (command === "upgrade") removeSyncReviewPage(forkPath);
3309
+ return void 0;
3310
+ }
3311
+ const opened = command === "upgrade" && process9.stdout.isTTY === true && openInBrowser(page.path);
3312
+ return { ...page, command, opened };
3313
+ }
3314
+ function removeSyncReviewPage(forkPath) {
2800
3315
  try {
2801
- page = await writeReviewPage(forkPath, sections, { command, range: result.upstreamDiffRange });
3316
+ rmSync(diffPagePath(forkPath, "upgrade-review"), { force: true });
2802
3317
  } catch {
2803
- page = void 0;
2804
3318
  }
2805
- spinnerSuccess();
2806
- if (!page) return;
2807
- const opened = command === "sync" && process7.stdout.isTTY === true && openInBrowser(page.path);
2808
- console.info();
2809
- console.info(
2810
- `${colors_default.cyan("\u29C8")} review page with ${plural(page.diffs, "diff")}${opened ? ", opened in the browser" : ""}:`
2811
- );
2812
- console.info(colors_default.dim(` ${hyperlink(page.path, pathToFileURL(page.path).href)}`));
3319
+ }
3320
+ function existingSyncReviewPage(forkPath) {
3321
+ try {
3322
+ const path = diffPagePath(forkPath, "upgrade-review");
3323
+ return existsSync7(path) ? path : void 0;
3324
+ } catch {
3325
+ return void 0;
3326
+ }
3327
+ }
3328
+ function reviewStep(page) {
3329
+ const path = typeof page === "string" ? page : page.path;
3330
+ const url = pathToFileURL(path).href;
3331
+ let detail = colors_default.dim("upstream changes the upgrade did not bring in, from the merge run");
3332
+ if (typeof page !== "string") {
3333
+ const what = page.command === "upgrade" ? `${plural(page.diffs, "diff")} of upstream changes that did not arrive` : `${plural(page.diffs, "diff")} in ${plural(page.sections, "section")}`;
3334
+ const notes = [
3335
+ page.listed > 0 ? `${page.listed} listed without a diff` : "",
3336
+ page.opened ? "opened in the browser" : ""
3337
+ ].filter(Boolean).map((note) => ` \xB7 ${note}`).join("");
3338
+ detail = `${what}${notes ? colors_default.dim(notes) : ""}`;
3339
+ }
3340
+ return { label: "review", line: hyperlink(colors_default.cyan(url), url), details: [detail] };
2813
3341
  }
2814
3342
 
2815
3343
  // src/services/analyze.ts
@@ -2844,8 +3372,8 @@ async function runAnalyze(config) {
2844
3372
  upstreamChangedLines: f.upstreamChangedLines ?? null,
2845
3373
  upstreamLinesAbsent: f.upstreamLinesAbsent ?? null,
2846
3374
  upstreamOnly: f.upstreamOnly ?? false,
2847
- // Only on the sync config: upstream override entries and packageJsonSync keys the fork config does not follow
2848
- upstreamOverrides: overrides && f.path === CONFIG_FILE ? { pinned: overrides.pinned, ignored: overrides.ignored, packageJsonSync: overrides.packageJsonSync } : null
3375
+ // Only on the CLI config: upstream override entries and packageJsonKeys the fork config does not follow
3376
+ upstreamOverrides: overrides && f.path === CONFIG_FILE ? { pinned: overrides.pinned, ignored: overrides.ignored, packageJsonKeys: overrides.packageJsonKeys } : null
2849
3377
  }));
2850
3378
  writeStdout(JSON.stringify(out, null, 2));
2851
3379
  return result;
@@ -2856,23 +3384,56 @@ async function runAnalyze(config) {
2856
3384
  }
2857
3385
  return result;
2858
3386
  }
2859
- const linkOptions = {
2860
- upstreamGitHubUrl: result.upstreamGitHubUrl,
2861
- upstreamBranch: result.upstreamBranch,
2862
- fileLinkMode: config.settings.fileLinkMode,
2863
- forkPath: config.forkPath
2864
- };
2865
- printAnalysisFileGroups(result.files, linkOptions);
2866
- printEngineReports(result, "analysis summary");
2867
- await reportReviewPage(config.forkPath, result, "analyze");
2868
- await printMigrationNotesLine(config, result);
2869
- console.info();
3387
+ printAnalysisFileGroups(result, {
3388
+ links: {
3389
+ upstreamGitHubUrl: result.upstreamGitHubUrl,
3390
+ upstreamSha: result.upstreamCommit?.hash,
3391
+ forkGitHubUrl: await readOriginGitHubUrl(config.forkPath),
3392
+ fileLinkMode: config.settings.fileLinkMode,
3393
+ forkPath: config.forkPath
3394
+ },
3395
+ all: config.all,
3396
+ overrides: config.overrides
3397
+ });
3398
+ printUpstreamOverrideChanges(result);
3399
+ printMaskingPinWarning(result.files);
3400
+ printSummary(result.summary, "analysis summary");
3401
+ const steps = [];
3402
+ const review = await reportReviewPage(config.forkPath, result, "analyze");
3403
+ if (review) steps.push(reviewStep(review));
3404
+ const sync = syncStep(result);
3405
+ if (sync) steps.push(sync);
3406
+ const align = result.alignments && result.upstreamCommit ? await alignmentsStep(config, {
3407
+ ref: result.upstreamCommit.hash,
3408
+ open: result.alignments.open,
3409
+ arriving: result.alignments.arrived
3410
+ }) : void 0;
3411
+ if (align) steps.push(align);
3412
+ printNextSteps(steps);
2870
3413
  return result;
2871
3414
  }
3415
+ function syncStep(result) {
3416
+ if (!result.upstreamCommitCount) return void 0;
3417
+ const { behind, diverged, renamed } = result.summary;
3418
+ const changes = [
3419
+ behind > 0 ? `${behind} update` : "",
3420
+ diverged > 0 ? `${diverged} merge` : "",
3421
+ renamed > 0 ? `${renamed} rename` : ""
3422
+ ].filter(Boolean);
3423
+ return {
3424
+ label: "upgrade",
3425
+ line: "pnpm cella upgrade",
3426
+ details: [
3427
+ colors_default.dim(
3428
+ `commits on a fresh branch, a rerun opens the PR \xB7 ${changes.length > 0 ? changes.join(", ") : "no file changes"}`
3429
+ )
3430
+ ]
3431
+ };
3432
+ }
2872
3433
 
2873
3434
  // src/services/audit.ts
2874
3435
  import { readFileSync as readFileSync4 } from "fs";
2875
- import { basename as basename4, join as join13, relative as relative3 } from "path";
3436
+ import { basename as basename5, join as join13, relative as relative3 } from "path";
2876
3437
  import { checkbox, Separator } from "@inquirer/prompts";
2877
3438
 
2878
3439
  // src/utils/audit-utils.ts
@@ -2980,14 +3541,14 @@ function formatDependents(dependents) {
2980
3541
 
2981
3542
  // src/utils/local-tools.ts
2982
3543
  import { execFile as execFile3 } from "child_process";
2983
- import { existsSync as existsSync7, realpathSync as realpathSync2 } from "fs";
3544
+ import { existsSync as existsSync8, realpathSync as realpathSync2 } from "fs";
2984
3545
  import { tmpdir } from "os";
2985
- import { delimiter, dirname as dirname6, join as join11, sep as sep2 } from "path";
3546
+ import { delimiter, dirname as dirname7, join as join11, sep as sep2 } from "path";
2986
3547
  import { promisify as promisify3 } from "util";
2987
3548
 
2988
3549
  // src/utils/runtimes.ts
2989
3550
  import { readFileSync as readFileSync2 } from "fs";
2990
- import { basename as basename3, dirname as dirname5, join as join10 } from "path";
3551
+ import { basename as basename4, dirname as dirname6, join as join10 } from "path";
2991
3552
  var DAY = 24 * 60 * 60 * 1e3;
2992
3553
  var FROZEN_AFTER = 180 * DAY;
2993
3554
  var RECENT_PUSH = 30 * DAY;
@@ -2995,7 +3556,7 @@ var EXACT_VERSION = /^\d+\.\d+\.\d+$/;
2995
3556
  var FROM_LINE = /^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)(?:\s+AS\s+(\S+))?\s*$/i;
2996
3557
  var NODE_ARG_TAG = /^(?:docker\.io\/)?(?:library\/)?node:\$\{?\w+\}?([\w.-]*)$/;
2997
3558
  var MINOR_TAG = /^(\d+(?:\.\d+){1,2})(-[\w.-]+)?$/;
2998
- var isDockerfile = (path) => /(^|\.)Dockerfile$|^Dockerfile\./i.test(basename3(path));
3559
+ var isDockerfile = (path) => /(^|\.)Dockerfile$|^Dockerfile\./i.test(basename4(path));
2999
3560
  function parseDockerfile(file, content) {
3000
3561
  const images = [];
3001
3562
  const nodeImageSuffixes = [];
@@ -3233,7 +3794,7 @@ async function missingNodeImages(version, suffixes) {
3233
3794
  return missing.flat();
3234
3795
  }
3235
3796
  function pinLocation(file) {
3236
- const dir = dirname5(file);
3797
+ const dir = dirname6(file);
3237
3798
  return dir === "." ? file : dir;
3238
3799
  }
3239
3800
 
@@ -3357,7 +3918,7 @@ var execFileAsync3 = promisify3(execFile3);
3357
3918
  function findOnPath(name, skip = []) {
3358
3919
  for (const dir of (getEnv("PATH") ?? "").split(delimiter)) {
3359
3920
  if (!dir || skip.includes(dir) || dir.endsWith(`node_modules${sep2}.bin`)) continue;
3360
- if (existsSync7(join11(dir, name))) return join11(dir, name);
3921
+ if (existsSync8(join11(dir, name))) return join11(dir, name);
3361
3922
  }
3362
3923
  return null;
3363
3924
  }
@@ -3385,7 +3946,7 @@ async function readNode(now) {
3385
3946
  }
3386
3947
  async function readPnpm(cwd) {
3387
3948
  const running = getEnv("npm_execpath");
3388
- const file = findOnPath("pnpm", running ? [dirname6(running)] : []);
3949
+ const file = findOnPath("pnpm", running ? [dirname7(running)] : []);
3389
3950
  const version = file && await versionOf(file, tmpdir());
3390
3951
  if (!file || !version) return null;
3391
3952
  const [published, advisories] = await Promise.all([fetchPublishTimes("pnpm", cwd), fetchPnpmAdvisories(version)]);
@@ -3556,14 +4117,14 @@ function writeAuditSettings(content, next) {
3556
4117
 
3557
4118
  // src/services/audit-security.ts
3558
4119
  import { spawnSync as spawnSync2 } from "child_process";
3559
- import { cpSync, existsSync as existsSync8, mkdirSync as mkdirSync2, mkdtempSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "fs";
4120
+ import { cpSync, existsSync as existsSync9, mkdirSync as mkdirSync2, mkdtempSync, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
3560
4121
  import { tmpdir as tmpdir2 } from "os";
3561
- import { dirname as dirname7, join as join12, relative as relative2 } from "path";
4122
+ import { dirname as dirname8, join as join12, relative as relative2 } from "path";
3562
4123
  import { confirm, select as select2 } from "@inquirer/prompts";
3563
4124
  var WORKSPACE_FILE = "pnpm-workspace.yaml";
3564
4125
  var LOCK_FILE = "pnpm-lock.yaml";
3565
4126
  var RESOLUTION_FILES = ["package.json", LOCK_FILE, ".npmrc", ".pnpmfile.cjs"];
3566
- var readOr = (path, fallback = "") => existsSync8(path) ? readFileSync3(path, "utf8") : fallback;
4127
+ var readOr = (path, fallback = "") => existsSync9(path) ? readFileSync3(path, "utf8") : fallback;
3567
4128
  var count2 = (n, noun, plural2 = `${noun}s`) => `${n} ${n === 1 ? noun : plural2}`;
3568
4129
  var isBreaking = (item) => item.remedy.kind !== "none" && item.remedy.breaking;
3569
4130
  var needsDecision = (item) => item.standing === "new" && (item.remedy.kind !== "override" || item.remedy.breaking || item.direct);
@@ -3577,8 +4138,8 @@ async function createStaging(forkPath, workspaceYaml) {
3577
4138
  ...projects.map((project) => join12(relative2(forkPath, project.path), "package.json"))
3578
4139
  ];
3579
4140
  for (const file of new Set(files)) {
3580
- if (file.startsWith("..") || !existsSync8(join12(forkPath, file))) continue;
3581
- mkdirSync2(dirname7(join12(staging, file)), { recursive: true });
4141
+ if (file.startsWith("..") || !existsSync9(join12(forkPath, file))) continue;
4142
+ mkdirSync2(dirname8(join12(staging, file)), { recursive: true });
3582
4143
  cpSync(join12(forkPath, file), join12(staging, file));
3583
4144
  }
3584
4145
  return staging;
@@ -3821,7 +4382,7 @@ async function runSecurityPass(forkPath, options) {
3821
4382
  return true;
3822
4383
  } finally {
3823
4384
  spinner.stop();
3824
- rmSync(staging, { recursive: true, force: true });
4385
+ rmSync2(staging, { recursive: true, force: true });
3825
4386
  }
3826
4387
  }
3827
4388
  function printOpen(modules) {
@@ -4023,7 +4584,7 @@ function printOutdatedResults(rows, forkPath) {
4023
4584
  const dependentMap = /* @__PURE__ */ new Map();
4024
4585
  for (const pkg of rows) {
4025
4586
  for (const { name, location } of pkg.dependentPackages) {
4026
- const dependentName = name || basename4(location);
4587
+ const dependentName = name || basename5(location);
4027
4588
  const existing = dependentMap.get(dependentName);
4028
4589
  if (existing) {
4029
4590
  existing.count++;
@@ -4219,8 +4780,8 @@ import {
4219
4780
  import { select as select3 } from "@inquirer/prompts";
4220
4781
 
4221
4782
  // src/services/contrib-core.ts
4222
- import { rmSync as rmSync2 } from "fs";
4223
- import { dirname as dirname8, join as join14, resolve as resolve4 } from "path";
4783
+ import { rmSync as rmSync3 } from "fs";
4784
+ import { dirname as dirname9, join as join14, resolve as resolve4 } from "path";
4224
4785
  async function listFilesAtRef(repoPath, ref) {
4225
4786
  const raw = await git(["ls-tree", "-r", "--name-only", ref], repoPath);
4226
4787
  return new Set(raw.split("\n").filter(Boolean));
@@ -4236,10 +4797,10 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
4236
4797
  const baseFiles = await listFilesAtRef(repoPath, baseRef);
4237
4798
  const baseDirs = /* @__PURE__ */ new Set(["."]);
4238
4799
  for (const f of baseFiles) {
4239
- let dir = dirname8(f);
4800
+ let dir = dirname9(f);
4240
4801
  while (dir !== ".") {
4241
4802
  baseDirs.add(dir);
4242
- dir = dirname8(dir);
4803
+ dir = dirname9(dir);
4243
4804
  }
4244
4805
  }
4245
4806
  const files = [];
@@ -4252,7 +4813,7 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
4252
4813
  if (inCella && !inFork) {
4253
4814
  kind = "deleted";
4254
4815
  } else if (!inCella && inFork) {
4255
- if (!baseDirs.has(dirname8(file.path))) continue;
4816
+ if (!baseDirs.has(dirname9(file.path))) continue;
4256
4817
  kind = "created";
4257
4818
  } else {
4258
4819
  kind = "modified";
@@ -4306,25 +4867,25 @@ ${commitBody}`;
4306
4867
  await git(["update-ref", `refs/heads/${branch}`, commitHash], repoPath);
4307
4868
  return { branch, appliedFiles };
4308
4869
  } finally {
4309
- rmSync2(tmpIndex, { force: true });
4870
+ rmSync3(tmpIndex, { force: true });
4310
4871
  }
4311
4872
  }
4312
4873
 
4313
4874
  // src/services/fork-utils.ts
4314
- import { existsSync as existsSync9 } from "fs";
4315
- import { dirname as dirname9, resolve as resolve5 } from "path";
4875
+ import { existsSync as existsSync10 } from "fs";
4876
+ import { dirname as dirname10, resolve as resolve5 } from "path";
4316
4877
  async function resolveForkBasePath(repoPath) {
4317
4878
  const commonDir = await git(["rev-parse", "--git-common-dir"], repoPath, { ignoreErrors: true });
4318
- return commonDir ? dirname9(resolve5(repoPath, commonDir)) : repoPath;
4879
+ return commonDir ? dirname10(resolve5(repoPath, commonDir)) : repoPath;
4319
4880
  }
4320
4881
  function validateForkPath(fork, basePath, requireConfig = false) {
4321
4882
  const resolvedPath = resolve5(basePath, fork.localPath);
4322
4883
  if (!resolvedPath.startsWith(resolve5(basePath, ".."))) {
4323
4884
  return { fork, resolvedPath, valid: false, error: "path resolves outside parent directory" };
4324
4885
  }
4325
- if (!existsSync9(resolvedPath)) return { fork, resolvedPath, valid: false, error: "path does not exist" };
4326
- if (!existsSync9(`${resolvedPath}/.git`)) return { fork, resolvedPath, valid: false, error: "not a git repository" };
4327
- if (requireConfig && !existsSync9(`${resolvedPath}/${CONFIG_FILE}`)) {
4886
+ if (!existsSync10(resolvedPath)) return { fork, resolvedPath, valid: false, error: "path does not exist" };
4887
+ if (!existsSync10(`${resolvedPath}/.git`)) return { fork, resolvedPath, valid: false, error: "not a git repository" };
4888
+ if (requireConfig && !existsSync10(`${resolvedPath}/${CONFIG_FILE}`)) {
4328
4889
  return { fork, resolvedPath, valid: false, error: `missing ${CONFIG_FILE}` };
4329
4890
  }
4330
4891
  return { fork, resolvedPath, valid: true };
@@ -4630,10 +5191,10 @@ async function runContributions(config) {
4630
5191
 
4631
5192
  // src/services/forks.ts
4632
5193
  import { resolve as resolve6 } from "path";
4633
- import process8 from "process";
5194
+ import process10 from "process";
4634
5195
  import { Separator as Separator2, select as select5 } from "@inquirer/prompts";
4635
5196
 
4636
- // src/services/sync.ts
5197
+ // src/services/upgrade.ts
4637
5198
  import { spawnSync as spawnSync4 } from "child_process";
4638
5199
  import { select as select4 } from "@inquirer/prompts";
4639
5200
 
@@ -4647,7 +5208,7 @@ function runGh(cwd, args) {
4647
5208
  const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
4648
5209
  return { ok: result.status === 0, output };
4649
5210
  }
4650
- function listOpenSyncPrs(cwd, prefix) {
5211
+ function listOpenSyncPrs(cwd, prefixes) {
4651
5212
  const { ok, output } = runGh(cwd, [
4652
5213
  "pr",
4653
5214
  "list",
@@ -4665,7 +5226,29 @@ function listOpenSyncPrs(cwd, prefix) {
4665
5226
  } catch {
4666
5227
  return [];
4667
5228
  }
4668
- return parsed.filter((pr) => pr.headRefName.startsWith(`${prefix}/`)).sort((a, b) => b.number - a.number);
5229
+ return parsed.filter((pr) => prefixes.some((prefix) => pr.headRefName.startsWith(`${prefix}/`))).sort((a, b) => b.number - a.number);
5230
+ }
5231
+ function createPr(cwd, repoSlug, pr) {
5232
+ const repo = repoSlug ?? "{owner}/{repo}";
5233
+ const fields = ["title", "head", "base", "body"];
5234
+ const created = runGh(cwd, [
5235
+ "api",
5236
+ `repos/${repo}/pulls`,
5237
+ "-X",
5238
+ "POST",
5239
+ ...fields.flatMap((field) => ["-f", `${field}=${pr[field]}`]),
5240
+ "--jq",
5241
+ ".html_url"
5242
+ ]);
5243
+ if (created.ok) return { ...created, url: created.output || void 0 };
5244
+ const owner = repoSlug ? repoSlug.split("/")[0] : "{owner}";
5245
+ const existing = runGh(cwd, [
5246
+ "api",
5247
+ `repos/${repo}/pulls?state=open&head=${owner}:${pr.head}`,
5248
+ "--jq",
5249
+ ".[0].html_url // empty"
5250
+ ]);
5251
+ return { ...created, url: existing.ok && existing.output ? existing.output : void 0 };
4669
5252
  }
4670
5253
  function mergePrSquash(cwd, ref, options = {}) {
4671
5254
  return runGh(cwd, ["pr", "merge", String(ref), "--squash", ...options.deleteBranch ? ["--delete-branch"] : []]);
@@ -4675,11 +5258,11 @@ function closePr(cwd, ref) {
4675
5258
  }
4676
5259
 
4677
5260
  // src/services/packages.ts
4678
- import { existsSync as existsSync11, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
5261
+ import { existsSync as existsSync12, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
4679
5262
  import { join as join16 } from "path";
4680
5263
 
4681
5264
  // src/services/package-usage.ts
4682
- import { existsSync as existsSync10, readFileSync as readFileSync5, statSync } from "fs";
5265
+ import { existsSync as existsSync11, readFileSync as readFileSync5, statSync } from "fs";
4683
5266
  import { join as join15 } from "path";
4684
5267
  var MAX_FILE_BYTES = 1024 * 1024;
4685
5268
  function escapeRegExp(value) {
@@ -4720,7 +5303,7 @@ function binNames(forkPath, location, name) {
4720
5303
  const bareName = name.split("/").pop();
4721
5304
  for (const dir of [join15(forkPath, location), forkPath]) {
4722
5305
  const manifest = join15(dir, "node_modules", name, "package.json");
4723
- if (!existsSync10(manifest)) continue;
5306
+ if (!existsSync11(manifest)) continue;
4724
5307
  try {
4725
5308
  const { bin } = JSON.parse(readFileSync5(manifest, "utf8"));
4726
5309
  if (typeof bin === "string") return [bareName];
@@ -4901,7 +5484,7 @@ function setTopLevelKey(pkg, key, value) {
4901
5484
  Object.assign(pkg, Object.fromEntries(entries));
4902
5485
  }
4903
5486
  function readPackageJson(filePath) {
4904
- if (!existsSync11(filePath)) return null;
5487
+ if (!existsSync12(filePath)) return null;
4905
5488
  try {
4906
5489
  return JSON.parse(readFileSync6(filePath, "utf8"));
4907
5490
  } catch (error) {
@@ -4941,7 +5524,7 @@ async function syncPackageJson(config, baseRef, usage, relativePath, keysToSync)
4941
5524
  const basePkg = baseRef ? (await readPackageJsonAtRef(forkPath, baseRef, relativePath))?.data : void 0;
4942
5525
  if (!upstream) return { updated: false, changes, kept };
4943
5526
  if (!forkPkg) {
4944
- const workspaceArrived = relativePath !== "" && existsSync11(join16(forkPath, relativePath));
5527
+ const workspaceArrived = relativePath !== "" && existsSync12(join16(forkPath, relativePath));
4945
5528
  if (basePkg || !workspaceArrived || isIgnored(pkgRelPath, config)) return { updated: false, changes, kept };
4946
5529
  writeFileSync3(pkgPath, upstream.raw.endsWith("\n") ? upstream.raw : `${upstream.raw}
4947
5530
  `, "utf8");
@@ -5013,8 +5596,8 @@ async function syncPackageJson(config, baseRef, usage, relativePath, keysToSync)
5013
5596
  return { updated, changes, kept };
5014
5597
  }
5015
5598
  async function runPackages(config, options = {}) {
5016
- createSpinner("syncing package.json files...");
5017
- const keysToSync = config.settings.packageJsonSync || DEFAULT_PACKAGE_JSON_SYNC;
5599
+ createSpinner("merging package.json files...");
5600
+ const keysToSync = config.settings.packageJsonKeys || DEFAULT_PACKAGE_JSON_KEYS;
5018
5601
  const conflictedSet = new Set(options.conflictedFiles ?? []);
5019
5602
  const baseRef = await resolveMergeBase(config.forkPath, config.upstreamRef);
5020
5603
  const usage = baseRef ? await loadForkUsage(config.forkPath, config.upstreamRef, baseRef) : null;
@@ -5027,12 +5610,15 @@ async function runPackages(config, options = {}) {
5027
5610
  skipped.push(pkgRelPath);
5028
5611
  continue;
5029
5612
  }
5030
- spinnerText(`syncing ${location || "root"}/package.json...`);
5613
+ spinnerText(`merging ${location || "root"}/package.json...`);
5031
5614
  const { updated, changes, kept: kept2 } = await syncPackageJson(config, baseRef, usage, location, keysToSync);
5032
5615
  if (updated || kept2.length > 0) reports.push({ path: pkgRelPath, changes, kept: kept2 });
5033
5616
  }
5034
5617
  const changedCount = reports.filter(({ changes }) => changes.length > 0).length;
5035
- spinnerSuccess("package sync complete", `${plural(changedCount, "package.json was", "package.jsons were")} changed`);
5618
+ spinnerSuccess(
5619
+ "package.json merge complete",
5620
+ `${plural(changedCount, "package.json was", "package.jsons were")} changed`
5621
+ );
5036
5622
  for (const { path, changes } of reports) {
5037
5623
  if (changes.length === 0) continue;
5038
5624
  console.info(` ${path}`);
@@ -5042,7 +5628,7 @@ async function runPackages(config, options = {}) {
5042
5628
  const them = kept.length === 1 ? "it" : "them";
5043
5629
  printWarnList(`kept ${plural(kept.length, "entry", "entries")} upstream dropped, the fork still uses ${them}:`, kept);
5044
5630
  printWarnList(
5045
- `skipped ${skipped.length} conflicted package.json file(s): resolve the conflicts, commit the merge, then rerun \`pnpm cella sync\`:`,
5631
+ `skipped ${skipped.length} conflicted package.json file(s): resolve the conflicts, commit the merge, then rerun \`pnpm cella upgrade\`:`,
5046
5632
  skipped
5047
5633
  );
5048
5634
  }
@@ -5053,7 +5639,7 @@ function printWarnList(header, entries) {
5053
5639
  for (const entry of entries) console.info(` ${colors_default.dim("\u2192")} ${entry}`);
5054
5640
  }
5055
5641
 
5056
- // src/services/sync.ts
5642
+ // src/services/upgrade.ts
5057
5643
  async function resolveCycleStart(forkPath, base, currentBranch) {
5058
5644
  if (!await branchExists(forkPath, base)) {
5059
5645
  await fetchRemote(forkPath, "origin").catch(() => {
@@ -5069,7 +5655,7 @@ async function resolveCycleStart(forkPath, base, currentBranch) {
5069
5655
  if (ahead > 0 && behind > 0) {
5070
5656
  throw new Error(
5071
5657
  `'${base}' has diverged from '${upstream}' (${ahead} ahead, ${behind} behind).
5072
- Reconcile '${base}' with '${upstream}' first (e.g. rebase or reset it), then re-run sync.`
5658
+ Reconcile '${base}' with '${upstream}' first (e.g. rebase or reset it), then re-run the upgrade.`
5073
5659
  );
5074
5660
  }
5075
5661
  if (behind > 0) {
@@ -5098,7 +5684,7 @@ async function setupTemporarySyncBranch(config) {
5098
5684
  console.info(colors_default.dim(`updating '${base}'...`));
5099
5685
  const startPoint = await resolveCycleStart(forkPath, base, startBranch);
5100
5686
  const temporaryBranch = buildTemporarySyncBranch();
5101
- console.info(colors_default.dim(`creating temporary sync branch '${temporaryBranch}' from '${startPoint}'...`));
5687
+ console.info(colors_default.dim(`creating temporary upgrade branch '${temporaryBranch}' from '${startPoint}'...`));
5102
5688
  console.info();
5103
5689
  await createBranchFrom(forkPath, temporaryBranch, startPoint);
5104
5690
  return { temporaryBranch, base, startBranch, startCommit };
@@ -5122,7 +5708,7 @@ async function returnToBase(forkPath, base) {
5122
5708
  return `back on '${base}'`;
5123
5709
  }
5124
5710
  async function runSync(config, options) {
5125
- createSpinner("starting sync...");
5711
+ createSpinner("starting upgrade...");
5126
5712
  let result;
5127
5713
  try {
5128
5714
  result = await runEngineWithSpinner(config, true);
@@ -5137,7 +5723,7 @@ async function runSync(config, options) {
5137
5723
  if (result.success) {
5138
5724
  spinnerSuccess();
5139
5725
  } else {
5140
- spinnerFail("sync completed with conflicts");
5726
+ spinnerFail("upgrade merge completed with conflicts");
5141
5727
  }
5142
5728
  printEngineReports(result, "merge summary");
5143
5729
  if (config.unpinned) {
@@ -5147,49 +5733,89 @@ async function runSync(config, options) {
5147
5733
  const staged = result.files.some((file) => SYNC_APPLIED_STATUSES.includes(file.status));
5148
5734
  const stagedBranch = options?.stagedBranch && result.conflicts.length === 0 && staged ? options.stagedBranch : void 0;
5149
5735
  printSyncComplete(result, { stagedBranch });
5150
- await reportReviewPage(config.forkPath, result, "sync");
5151
- await printMigrationNotesLine(config, result);
5152
- return result;
5736
+ const reviewPage = await reportReviewPage(config.forkPath, result, "upgrade");
5737
+ return { ...result, reviewPage };
5738
+ }
5739
+ var CONFLICTS_LISTED = 10;
5740
+ async function syncAlignmentsStep(config, run) {
5741
+ if (run?.alignments && run.upstreamCommit) {
5742
+ const { open, arrived } = run.alignments;
5743
+ return alignmentsStep(config, { ref: run.upstreamCommit.hash, open, arrived });
5744
+ }
5745
+ const view = await readOpenAlignments(config);
5746
+ return view ? alignmentsStep(config, view) : void 0;
5747
+ }
5748
+ function syncReviewStep(config, run) {
5749
+ const page = run ? run.reviewPage : existingSyncReviewPage(config.forkPath);
5750
+ return page ? reviewStep(page) : void 0;
5751
+ }
5752
+ async function conflictSteps(config, conflicts, run) {
5753
+ const listed = conflicts.slice(0, CONFLICTS_LISTED).map((file) => colors_default.dim(file));
5754
+ if (conflicts.length > CONFLICTS_LISTED) listed.push(colors_default.dim(`+ ${conflicts.length - CONFLICTS_LISTED} more`));
5755
+ const steps = [
5756
+ {
5757
+ label: "resolve",
5758
+ line: `${plural(conflicts.length, "conflict")} in your editor, then stage them`,
5759
+ details: listed
5760
+ },
5761
+ {
5762
+ label: "finish",
5763
+ line: "pnpm cella upgrade",
5764
+ details: [
5765
+ colors_default.dim("commits the upgrade and stops for drift triage"),
5766
+ colors_default.dim("let the rerun commit: a manual `git commit` records a merge commit that bloats the PR")
5767
+ ]
5768
+ },
5769
+ syncReviewStep(config, run),
5770
+ await syncAlignmentsStep(config, run)
5771
+ ];
5772
+ return steps.filter((step) => !!step);
5153
5773
  }
5154
- var SYNC_PR_TITLE = "chore: sync upstream cella";
5774
+ var UPGRADE_PR_TITLE = "chore: upgrade cella";
5155
5775
  async function buildSyncCommitMessage(forkPath, upstreamRef) {
5156
5776
  const shortSha = await getShortSha(forkPath, upstreamRef).catch(() => "");
5157
- if (!shortSha) return SYNC_PR_TITLE;
5777
+ if (!shortSha) return UPGRADE_PR_TITLE;
5158
5778
  const manifest = await readSyncManifest(forkPath);
5159
5779
  const version = manifest?.upstream.release ?? await readPackageVersionAtRef(forkPath, upstreamRef).catch(() => null);
5160
- return version ? `${SYNC_PR_TITLE} v${version.replace(/^v/, "")} (${shortSha})` : `${SYNC_PR_TITLE} ${shortSha}`;
5780
+ return version ? `${UPGRADE_PR_TITLE} to v${version.replace(/^v/, "")} (${shortSha})` : `${UPGRADE_PR_TITLE} to ${shortSha}`;
5161
5781
  }
5162
5782
  function qualifyPrRefs(subject, repoSlug) {
5163
5783
  if (!repoSlug) return subject;
5164
5784
  return subject.replace(/(^|[^\w/])#(\d+)\b/g, `$1${repoSlug}#$2`);
5165
5785
  }
5166
5786
  function buildSyncPrBody(input) {
5167
- const { repoSlug, version, fromSha, toSha, commits, totalCount, notes } = input;
5787
+ const { repoSlug, version, fromSha, toSha, commits, totalCount, alignments } = input;
5168
5788
  const githubUrl = repoSlug ? `https://github.com/${repoSlug}` : void 0;
5169
5789
  const short = (sha) => sha.slice(0, 7);
5170
5790
  const commitRef = (sha) => githubUrl ? `[\`${short(sha)}\`](${githubUrl}/commit/${sha})` : `\`${short(sha)}\``;
5171
5791
  const upstreamName = githubUrl ? `[${repoSlug}](${githubUrl})` : "cella";
5172
5792
  const versionSuffix = version ? ` (v${version.replace(/^v/, "")})` : "";
5173
- const lines = [`Syncs upstream ${upstreamName} to ${commitRef(toSha)}${versionSuffix}.`];
5793
+ const lines = [`Upgrades ${upstreamName} to ${commitRef(toSha)}${versionSuffix}.`];
5174
5794
  if (totalCount > 0 && fromSha) {
5175
5795
  const compare = githubUrl ? ` ([compare](${githubUrl}/compare/${short(fromSha)}...${short(toSha)}))` : "";
5176
- lines.push("", `**${totalCount} upstream commit${totalCount === 1 ? "" : "s"} since last sync**${compare}:`, "");
5796
+ lines.push(
5797
+ "",
5798
+ `**${totalCount} upstream commit${totalCount === 1 ? "" : "s"} since the last upgrade**${compare}:`,
5799
+ ""
5800
+ );
5177
5801
  if (totalCount > commits.length) lines.push(`- \u2026${totalCount - commits.length} earlier commit(s) not shown`);
5178
5802
  for (const commit of commits) {
5179
5803
  lines.push(`- ${commitRef(commit.hash)} ${qualifyPrRefs(commit.message, repoSlug)}`);
5180
5804
  }
5181
5805
  }
5182
- if (notes && notes.total > 0) {
5183
- const { total, open } = notes;
5184
- if (open.length === 0) lines.push("", `**Migration notes**: all ${total} handled.`);
5806
+ if (alignments && alignments.total > 0) {
5807
+ const { total, open } = alignments;
5808
+ if (open.length === 0) lines.push("", `**Alignments**: all ${total} handled.`);
5185
5809
  else {
5186
5810
  lines.push(
5187
5811
  "",
5188
- `**Migration notes**: ${Math.max(0, total - open.length)} of ${total} handled, open (\`pnpm cella migrate\`):`,
5812
+ `**Alignments**: ${Math.max(0, total - open.length)} of ${total} handled, open (\`pnpm cella align\`):`,
5189
5813
  ""
5190
5814
  );
5191
- for (const note of open)
5192
- lines.push(`- ${note.url ? `[${note.title}](${note.url})` : note.title} (\`${note.id}\`)`);
5815
+ for (const alignment of open)
5816
+ lines.push(
5817
+ `- ${alignment.url ? `[${alignment.title}](${alignment.url})` : alignment.title} (\`${alignment.id}\`)`
5818
+ );
5193
5819
  }
5194
5820
  }
5195
5821
  return `${lines.join("\n")}
@@ -5203,22 +5829,30 @@ async function buildSyncPrBodyForBranch(forkPath, base) {
5203
5829
  const version = manifest.upstream.release ?? await readPackageVersionAtRef(forkPath, toSha).catch(() => null);
5204
5830
  const totalCount = fromSha ? await countCommitsBetween(forkPath, fromSha, toSha).catch(() => 0) : 0;
5205
5831
  const commits = fromSha && totalCount > 0 ? await listCommitsBetween(forkPath, fromSha, toSha, COMMIT_LIST_MAX) : [];
5206
- const notes = await readPrBodyNotes(forkPath, toSha, manifest.upstream.repo).catch(() => void 0);
5207
- return buildSyncPrBody({ repoSlug: manifest.upstream.repo, version, fromSha, toSha, commits, totalCount, notes });
5832
+ const alignments = await readPrBodyAlignments(forkPath, toSha, manifest.upstream.repo).catch(() => void 0);
5833
+ return buildSyncPrBody({
5834
+ repoSlug: manifest.upstream.repo,
5835
+ version,
5836
+ fromSha,
5837
+ toSha,
5838
+ commits,
5839
+ totalCount,
5840
+ alignments
5841
+ });
5208
5842
  }
5209
- async function readPrBodyNotes(forkPath, toSha, repoSlug) {
5843
+ async function readPrBodyAlignments(forkPath, toSha, repoSlug) {
5210
5844
  const githubUrl = repoSlug ? `https://github.com/${repoSlug}` : void 0;
5211
- const [ids, pending] = await Promise.all([listNoteIds(forkPath, toSha), readPending(forkPath)]);
5845
+ const [ids, pending] = await Promise.all([listAlignmentIds(forkPath, toSha), readPending(forkPath)]);
5212
5846
  const open = await Promise.all(
5213
5847
  pending.map(async (id) => ({
5214
5848
  id,
5215
- title: (await readNote(forkPath, toSha, id))?.title ?? id,
5216
- url: noteUrl(githubUrl, toSha, id)
5849
+ title: (await readAlignment(forkPath, toSha, id))?.title ?? id,
5850
+ url: alignmentUrl(githubUrl, toSha, id)
5217
5851
  }))
5218
5852
  );
5219
5853
  return { total: ids.length, open };
5220
5854
  }
5221
- function printPrCreateStep(branch, base, title = SYNC_PR_TITLE) {
5855
+ function printPrCreateStep(branch, base, title = UPGRADE_PR_TITLE) {
5222
5856
  console.info(colors_default.dim(` gh pr create --base ${base} --head ${branch} --title "${title}" --fill`));
5223
5857
  }
5224
5858
  async function flattenSyncBranch(forkPath, branch, base) {
@@ -5242,7 +5876,7 @@ async function shipSyncBranch(config, branch) {
5242
5876
  let prUrl;
5243
5877
  let prOpened = false;
5244
5878
  const upstreamCommit = (await readSyncManifest(forkPath))?.upstream.commit;
5245
- const prTitle = upstreamCommit ? await buildSyncCommitMessage(forkPath, upstreamCommit) : SYNC_PR_TITLE;
5879
+ const prTitle = upstreamCommit ? await buildSyncCommitMessage(forkPath, upstreamCommit) : UPGRADE_PR_TITLE;
5246
5880
  console.info(colors_default.dim(`pushing '${branch}' to origin...`));
5247
5881
  try {
5248
5882
  await pushBranch(forkPath, "origin", branch, { forceWithLease: flattened });
@@ -5254,16 +5888,11 @@ async function shipSyncBranch(config, branch) {
5254
5888
  }
5255
5889
  if (ghAvailable()) {
5256
5890
  console.info(colors_default.dim("opening a pull request..."));
5257
- const prBody = await buildSyncPrBodyForBranch(forkPath, cutBase);
5258
- const bodyArgs = prBody ? ["--body", prBody] : ["--fill"];
5259
- const pr = spawnSync4("gh", ["pr", "create", "--base", base, "--head", branch, "--title", prTitle, ...bodyArgs], {
5260
- cwd: forkPath,
5261
- encoding: "utf8",
5262
- stdio: ["ignore", "pipe", "pipe"]
5263
- });
5264
- prUrl = `${pr.stdout ?? ""}
5265
- ${pr.stderr ?? ""}`.match(/https?:\/\/\S+/)?.[0];
5266
- prOpened = pr.status === 0;
5891
+ const prBody = await buildSyncPrBodyForBranch(forkPath, cutBase) ?? "";
5892
+ const repoSlug = (await readOriginGitHubUrl(forkPath))?.replace("https://github.com/", "") ?? null;
5893
+ const pr = createPr(forkPath, repoSlug, { base, head: branch, title: prTitle, body: prBody });
5894
+ prUrl = pr.url;
5895
+ prOpened = pr.ok;
5267
5896
  if (!prOpened) {
5268
5897
  console.info(colors_default.yellow("could not open the PR automatically (it may already exist). open it with:"));
5269
5898
  if (prUrl) console.info(colors_default.dim(` ${prUrl}`));
@@ -5276,13 +5905,21 @@ ${pr.stderr ?? ""}`.match(/https?:\/\/\S+/)?.[0];
5276
5905
  const position = await returnToBase(forkPath, base);
5277
5906
  console.info();
5278
5907
  if (prUrl) {
5279
- console.info(`${checkMark} sync pull request ${prOpened ? "opened" : "ready"}`);
5908
+ console.info(`${checkMark} upgrade pull request ${prOpened ? "opened" : "ready"}`);
5280
5909
  console.info(colors_default.dim(` ${prUrl} \xB7 branch pushed, ${position}`));
5281
5910
  } else {
5282
- console.info(`${checkMark} sync branch pushed`);
5911
+ console.info(`${checkMark} upgrade branch pushed`);
5283
5912
  console.info(colors_default.dim(` '${branch}' is on origin, ${position}`));
5284
5913
  }
5285
- await printMigrationNotesLine(config);
5914
+ const steps = [
5915
+ prUrl ? {
5916
+ label: "merge",
5917
+ line: hyperlink(colors_default.cyan(prUrl), prUrl),
5918
+ details: [colors_default.dim(`squash-merge it into '${base}'; the next upgrade offers to while it is open`)]
5919
+ } : void 0,
5920
+ await syncAlignmentsStep(config)
5921
+ ];
5922
+ printNextSteps(steps.filter((step) => !!step));
5286
5923
  }
5287
5924
  function finalizeWorkspace(forkPath) {
5288
5925
  for (const args of [["install"], ["check"]]) {
@@ -5318,43 +5955,56 @@ async function runSyncCycle(config) {
5318
5955
  }
5319
5956
  throw error;
5320
5957
  }
5321
- if (config.settings.syncWithPackages !== false) {
5958
+ if (config.settings.mergePackageJson !== false) {
5322
5959
  await runPackages(config, { conflictedFiles: result.conflicts });
5323
5960
  }
5324
- if (result.conflicts.length > 0) return { status: "conflicts", branch };
5961
+ if (result.conflicts.length > 0) return { status: "conflicts", branch, result };
5325
5962
  if (!await mergeInProgress(forkPath)) {
5326
5963
  await discardTemporarySyncBranch(forkPath, branch);
5327
- return { status: "noop" };
5964
+ return { status: "noop", result };
5328
5965
  }
5329
- return { status: "staged", branch };
5966
+ return { status: "staged", branch, result };
5330
5967
  }
5331
- async function commitSyncMerge(config, branch) {
5968
+ async function commitSyncMerge(config, branch, run) {
5332
5969
  const { forkPath } = config;
5333
5970
  const conflicts = await getConflictedFiles(forkPath);
5334
5971
  console.info();
5335
5972
  if (conflicts.length > 0) {
5336
- console.info(colors_default.yellow(`${conflicts.length} file(s) still conflict on '${branch}':`));
5337
- for (const file of conflicts) console.info(colors_default.dim(` ${file}`));
5338
- console.info(colors_default.dim("resolve and stage them, then re-run `pnpm cella sync` to finish (it commits for you)."));
5973
+ printStep("conflicts", `${plural(conflicts.length, "file")} still conflict on '${branch}'`, warningMark);
5974
+ printNextSteps(await conflictSteps(config, conflicts, run));
5339
5975
  return;
5340
5976
  }
5341
5977
  await stageAll(forkPath);
5342
5978
  if (!finalizeWorkspace(forkPath)) {
5343
5979
  console.info();
5344
- console.info(
5345
- colors_default.yellow("`pnpm install`/`pnpm check` failed. fix the issues, then re-run `pnpm cella sync` to finish.")
5346
- );
5980
+ printStep("checks", "`pnpm install` or `pnpm check` failed", warningMark);
5981
+ printNextSteps([
5982
+ { label: "fix", line: "what failed above", details: [colors_default.dim(`the merge stays staged on '${branch}'`)] },
5983
+ {
5984
+ label: "finish",
5985
+ line: "pnpm cella upgrade",
5986
+ details: [colors_default.dim("commits the upgrade and stops for drift triage")]
5987
+ }
5988
+ ]);
5347
5989
  return;
5348
5990
  }
5349
5991
  const message = await buildSyncCommitMessage(forkPath, "MERGE_HEAD");
5350
5992
  await stageAll(forkPath);
5351
5993
  await commitSquash(forkPath, message);
5352
5994
  console.info();
5353
- console.info(colors_default.green(`committed the sync on '${branch}' as '${message}'.`));
5354
- console.info(colors_default.dim(`staying on '${branch}' without pushing.`));
5355
- console.info(colors_default.dim(" run drift triage (`pnpm cella analyze`), commit any follow-ups, then:"));
5356
- console.info(colors_default.dim(" pnpm cella sync (pushes the branch and opens the PR)"));
5357
- await printMigrationNotesLine(config);
5995
+ printStep("committed", `${message}
5996
+ ${colors_default.dim(`on '${branch}', not pushed`)}`);
5997
+ const steps = [
5998
+ syncReviewStep(config, run),
5999
+ {
6000
+ label: "triage",
6001
+ line: "pnpm cella analyze",
6002
+ details: [colors_default.dim("drift triage: commit any follow-ups on this branch")]
6003
+ },
6004
+ await syncAlignmentsStep(config, run),
6005
+ { label: "ship", line: "pnpm cella upgrade", details: [colors_default.dim("pushes the branch and opens the squash-merge PR")] }
6006
+ ];
6007
+ printNextSteps(steps.filter((step) => !!step));
5358
6008
  }
5359
6009
  async function mergeOpenSyncPrs(config, open) {
5360
6010
  const { forkPath, settings } = config;
@@ -5365,7 +6015,7 @@ async function mergeOpenSyncPrs(config, open) {
5365
6015
  const merged = mergePrSquash(forkPath, newest.number, { deleteBranch: true });
5366
6016
  if (!merged.ok) {
5367
6017
  console.info();
5368
- console.info(colors_default.yellow(`could not merge #${newest.number}: resolve it first, then re-run \`pnpm cella sync\`:`));
6018
+ console.info(colors_default.yellow(`could not merge #${newest.number}: resolve it first, then re-run \`pnpm cella upgrade\`:`));
5369
6019
  if (merged.output) console.info(colors_default.dim(merged.output.replace(/^/gm, " ")));
5370
6020
  console.info(colors_default.dim(` ${newest.url}`));
5371
6021
  return "cancel";
@@ -5382,25 +6032,27 @@ async function mergeOpenSyncPrs(config, open) {
5382
6032
  async function guardAgainstOpenSyncPr(config) {
5383
6033
  const { forkPath } = config;
5384
6034
  if (!ghAvailable() || !process.stdout.isTTY) return "continue";
5385
- const open = listOpenSyncPrs(forkPath, DEFAULT_SYNC_PREFIX);
6035
+ const open = listOpenSyncPrs(forkPath, TEMPORARY_BRANCH_PREFIXES);
5386
6036
  if (open.length === 0) return "continue";
5387
6037
  const many = open.length > 1;
5388
6038
  console.info();
5389
6039
  console.info(
5390
- `${warningMark} ${colors_default.yellow(`${many ? `${open.length} earlier sync PRs are` : "an earlier sync PR is"} still open and unmerged:`)}`
6040
+ `${warningMark} ${colors_default.yellow(`${many ? `${open.length} earlier upgrade PRs are` : "an earlier upgrade PR is"} still open and unmerged:`)}`
5391
6041
  );
5392
6042
  for (const pr of open) {
5393
6043
  console.info(colors_default.dim(` #${pr.number} ${pr.title}`));
5394
6044
  console.info(colors_default.dim(` ${pr.url}`));
5395
6045
  }
5396
- console.info(colors_default.dim(` starting a new sync now produces a PR that re-includes ${many ? "these" : "those"} changes.`));
6046
+ console.info(
6047
+ colors_default.dim(` starting a new upgrade now produces a PR that re-includes ${many ? "these" : "those"} changes.`)
6048
+ );
5397
6049
  console.info();
5398
6050
  const choice = await select4({
5399
6051
  message: "how do you want to proceed?",
5400
6052
  choices: [
5401
6053
  {
5402
6054
  value: "merge",
5403
- name: `merge ${many ? "them" : `#${open[0].number}`} first, then sync ${colors_default.dim("(recommended)")}`
6055
+ name: `merge ${many ? "them" : `#${open[0].number}`} first, then upgrade ${colors_default.dim("(recommended)")}`
5404
6056
  },
5405
6057
  { value: "continue", name: `continue anyway ${colors_default.dim("(new PR will re-include the unmerged changes)")}` },
5406
6058
  { value: "cancel", name: colors_default.red("cancel") }
@@ -5411,7 +6063,7 @@ async function guardAgainstOpenSyncPr(config) {
5411
6063
  if (choice === "continue") return "continue";
5412
6064
  return mergeOpenSyncPrs(config, open);
5413
6065
  }
5414
- async function runSyncCommand(config) {
6066
+ async function runUpgradeCommand(config) {
5415
6067
  const { forkPath } = config;
5416
6068
  const currentBranch = await getCurrentBranch(forkPath);
5417
6069
  const onSyncBranch = isTemporarySyncBranch(currentBranch);
@@ -5424,7 +6076,7 @@ async function runSyncCommand(config) {
5424
6076
  console.info();
5425
6077
  console.info(
5426
6078
  colors_default.yellow(
5427
- `sync branch '${currentBranch}' has uncommitted changes.
6079
+ `upgrade branch '${currentBranch}' has uncommitted changes.
5428
6080
  commit them first (\`git commit --amend --no-edit\` or a new commit).`
5429
6081
  )
5430
6082
  );
@@ -5434,29 +6086,30 @@ commit them first (\`git commit --amend --no-edit\` or a new commit).`
5434
6086
  return;
5435
6087
  }
5436
6088
  if (!await isClean(forkPath)) {
5437
- throw new Error("working directory has uncommitted changes. please commit or stash before syncing.");
6089
+ throw new Error("working directory has uncommitted changes. please commit or stash before upgrading.");
5438
6090
  }
5439
6091
  if (await guardAgainstOpenSyncPr(config) === "cancel") return;
5440
6092
  const outcome = await runSyncCycle(config);
5441
- console.info();
5442
6093
  if (outcome.status === "noop") {
6094
+ console.info();
5443
6095
  if (outcome.behind) {
5444
- console.info(colors_default.green("nothing to sync: the last sync already went past this upstream point."));
6096
+ console.info(colors_default.green("nothing to upgrade: the last upgrade already went past this upstream point."));
5445
6097
  for (const line of outcome.behind.split("\n")) console.info(colors_default.dim(` ${line}`));
5446
6098
  } else {
5447
- console.info(colors_default.green("already up to date with upstream, nothing to sync."));
6099
+ console.info(colors_default.green("already up to date with upstream, nothing to upgrade."));
5448
6100
  }
6101
+ const align = await syncAlignmentsStep(config, outcome.result);
6102
+ if (align) printNextSteps([align]);
5449
6103
  return;
5450
6104
  }
5451
6105
  const { temporaryBranch } = outcome.branch;
5452
6106
  if (outcome.status === "conflicts") {
5453
- console.info(`${warningMark} ${colors_default.yellow(`conflicts on '${temporaryBranch}'. Resolve and stage them, then:`)}`);
5454
- console.info(colors_default.dim(" pnpm cella sync"));
5455
- console.info(colors_default.dim(" rerun commits the sync and stops for drift triage; a further rerun ships (push + PR)."));
5456
- console.info(colors_default.dim(" let the rerun commit: a manual `git commit` records a merge commit that bloats the PR."));
6107
+ const conflicts = await getConflictedFiles(forkPath);
6108
+ printStep("conflicts", `${plural(conflicts.length, "file")} on '${temporaryBranch}'`, warningMark);
6109
+ printNextSteps(await conflictSteps(config, conflicts, outcome.result));
5457
6110
  return;
5458
6111
  }
5459
- await commitSyncMerge(config, temporaryBranch);
6112
+ await commitSyncMerge(config, temporaryBranch, outcome.result);
5460
6113
  }
5461
6114
 
5462
6115
  // src/services/forks.ts
@@ -5481,7 +6134,7 @@ function formatForkChoice(name, status) {
5481
6134
  syncPart = colors_default.dim(`${status.lastSync.date}`);
5482
6135
  if (msg) syncPart += colors_default.dim(` '${msg}'`);
5483
6136
  } else {
5484
- syncPart = colors_default.dim("never synced");
6137
+ syncPart = colors_default.dim("never upgraded");
5485
6138
  }
5486
6139
  const dirtyPart = status.dirty > 0 ? colors_default.yellow(`${status.dirty} uncommitted`) : "";
5487
6140
  const parts = [name, colors_default.dim(`[${status.branch}]`), syncPart];
@@ -5513,15 +6166,15 @@ async function buildForkChoices(forks, basePath) {
5513
6166
  }
5514
6167
  async function syncFork(config, fork, forkPath) {
5515
6168
  console.info();
5516
- console.info(colors_default.cyan(`syncing to ${fork.name}...`));
6169
+ console.info(colors_default.cyan(`upgrading ${fork.name}...`));
5517
6170
  console.info(colors_default.dim(`path: ${forkPath}`));
5518
6171
  console.info();
5519
6172
  const forkConfig = await loadConfig(forkPath);
5520
- await runSyncCommand({
6173
+ await runUpgradeCommand({
5521
6174
  ...forkConfig,
5522
6175
  forkPath,
5523
6176
  upstreamRef: resolveUpstream(forkConfig.settings).branchRef,
5524
- service: "sync",
6177
+ service: "upgrade",
5525
6178
  list: false,
5526
6179
  json: false,
5527
6180
  verbose: false,
@@ -5548,12 +6201,12 @@ async function runForks(config) {
5548
6201
  const choices = await buildForkChoices(forks, forkBasePath);
5549
6202
  const forkChoices = [...choices, new Separator2(MENU_DIVIDER), { value: "_exit", name: colors_default.dim("exit") }];
5550
6203
  const selectedPath = await select5({
5551
- message: "select fork to sync:",
6204
+ message: "select fork to upgrade:",
5552
6205
  choices: forkChoices,
5553
6206
  loop: false
5554
6207
  });
5555
6208
  if (selectedPath === "_exit") {
5556
- process8.exit(0);
6209
+ process10.exit(0);
5557
6210
  }
5558
6211
  const resolvedForkPath = resolve6(forkBasePath, selectedPath);
5559
6212
  const selectedFork = forks.find((f) => f.localPath === selectedPath);
@@ -5571,7 +6224,7 @@ import { readFile as readFile3 } from "fs/promises";
5571
6224
  import { join as join18 } from "path";
5572
6225
 
5573
6226
  // src/utils/coverage-utils.ts
5574
- import { existsSync as existsSync12, readFileSync as readFileSync7, statSync as statSync2 } from "fs";
6227
+ import { existsSync as existsSync13, readFileSync as readFileSync7, statSync as statSync2 } from "fs";
5575
6228
  import { join as join17, relative as relative4, sep as sep3 } from "path";
5576
6229
  var STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1e3;
5577
6230
  function colorPct(pct) {
@@ -5594,7 +6247,7 @@ function printCoverageSummary(forkPath) {
5594
6247
  let summary;
5595
6248
  let mtimeMs = 0;
5596
6249
  let problem = "no coverage data found";
5597
- if (existsSync12(file)) {
6250
+ if (existsSync13(file)) {
5598
6251
  try {
5599
6252
  mtimeMs = statSync2(file).mtimeMs;
5600
6253
  summary = JSON.parse(readFileSync7(file, "utf8"));
@@ -5994,26 +6647,26 @@ function getForkPath() {
5994
6647
  const envPath = getEnv("CELLA_FORK_PATH");
5995
6648
  if (envPath) {
5996
6649
  const resolved = resolve7(envPath);
5997
- if (!existsSync13(resolved) || !statSync3(resolved).isDirectory()) {
6650
+ if (!existsSync14(resolved) || !statSync3(resolved).isDirectory()) {
5998
6651
  throw new Error(`CELLA_FORK_PATH is not a valid directory: ${resolved}`);
5999
6652
  }
6000
6653
  return resolved;
6001
6654
  }
6002
- let cwd = process9.cwd();
6655
+ let cwd = process11.cwd();
6003
6656
  if (cwd.endsWith("/cli/cella") || cwd.endsWith("\\cli\\cella")) {
6004
6657
  cwd = resolve7(cwd, "../..");
6005
6658
  }
6006
6659
  return cwd;
6007
6660
  }
6008
6661
  function preflight(forkPath) {
6009
- if (!existsSync13(join19(forkPath, ".git"))) {
6662
+ if (!existsSync14(join19(forkPath, ".git"))) {
6010
6663
  throw new Error(`not a git repository: ${forkPath}`);
6011
6664
  }
6012
6665
  }
6013
6666
  var services = {
6014
6667
  analyze: runAnalyze,
6015
- sync: runSyncCommand,
6016
- migrate: runMigrate,
6668
+ upgrade: runUpgradeCommand,
6669
+ align: runAlign,
6017
6670
  audit: runAudit,
6018
6671
  forks: runForks,
6019
6672
  contributions: runContributions,
@@ -6024,7 +6677,7 @@ var services = {
6024
6677
  }
6025
6678
  };
6026
6679
  function isHelpOrVersionRequest() {
6027
- return process9.argv.slice(2).some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v") || process9.argv[2] === "help";
6680
+ return process11.argv.slice(2).some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v") || process11.argv[2] === "help";
6028
6681
  }
6029
6682
  async function main() {
6030
6683
  try {
@@ -6043,7 +6696,7 @@ async function main() {
6043
6696
  } catch (error) {
6044
6697
  console.error();
6045
6698
  console.error(`${colors_default.red("\u2717")} ${errorMessage(error)}`);
6046
- process9.exit(1);
6699
+ process11.exit(1);
6047
6700
  }
6048
6701
  }
6049
6702
  main();