@cellajs/cli 0.4.2 → 0.5.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
 
@@ -213,13 +213,13 @@ ${z2.prettifyError(result.error)}`);
213
213
  }
214
214
 
215
215
  // src/utils/display.ts
216
- import { resolve as resolve2 } from "path";
217
- import process4 from "process";
216
+ import { resolve as resolve3 } from "path";
217
+ import process5 from "process";
218
218
 
219
219
  // package.json
220
220
  var package_default = {
221
221
  name: "@cellajs/cli",
222
- version: "0.4.2",
222
+ version: "0.5.0",
223
223
  private: false,
224
224
  license: "MIT",
225
225
  description: "CLI for managing your monorepo and syncing with upstream Cella.",
@@ -334,377 +334,19 @@ var package_default = {
334
334
  200
335
335
  ],
336
336
  "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";
337
+ 0,
338
+ "always",
339
+ 200
340
+ ]
341
+ }
342
+ }
343
+ };
702
344
 
703
345
  // src/utils/git.ts
704
346
  import { execFile } from "child_process";
705
347
  import { existsSync as existsSync3 } from "fs";
706
348
  import { mkdir as mkdir2, readdir, rmdir, unlink } from "fs/promises";
707
- import { dirname as dirname2, join as join4, resolve as resolve3 } from "path";
349
+ import { dirname as dirname2, join as join4, resolve as resolve2 } from "path";
708
350
  import { promisify } from "util";
709
351
 
710
352
  // src/utils/manifest.ts
@@ -840,7 +482,7 @@ async function commitSquash(cwd, message) {
840
482
  await git(["commit", "-m", message], cwd, { skipEditor: true });
841
483
  }
842
484
  async function gitPath(cwd, file) {
843
- return resolve3(cwd, await git(["rev-parse", "--git-path", file], cwd));
485
+ return resolve2(cwd, await git(["rev-parse", "--git-path", file], cwd));
844
486
  }
845
487
  async function stageAll(cwd) {
846
488
  await git(["add", "-A"], cwd);
@@ -848,6 +490,10 @@ async function stageAll(cwd) {
848
490
  async function mergeInProgress(cwd) {
849
491
  return await revParse(cwd, "MERGE_HEAD") !== null;
850
492
  }
493
+ function splitPrNumber(subject) {
494
+ const match = /\s*\(#(\d+)\)\s*$/.exec(subject);
495
+ return match ? { subject: subject.slice(0, match.index), pr: Number(match[1]) } : { subject };
496
+ }
851
497
  async function getShortSha(cwd, ref) {
852
498
  return git(["rev-parse", "--short", ref], cwd);
853
499
  }
@@ -920,16 +566,16 @@ async function countCommitsBetween(cwd, fromRef, toRef) {
920
566
  return Number.parseInt(output.trim(), 10) || 0;
921
567
  }
922
568
  async function listCommitsBetween(cwd, fromRef, toRef, limit) {
923
- const args = ["log", "--format=%H%x1f%s%x1f%ar", "--reverse", `${fromRef}..${toRef}`];
569
+ const args = ["log", "--format=%H%x1f%s%x1f%ar%x1f%ct", "--reverse", `${fromRef}..${toRef}`];
924
570
  if (limit) args.push(`-n${limit}`);
925
571
  const output = await git(args, cwd, { ignoreErrors: true });
926
572
  if (!output) return [];
927
573
  const commits = [];
928
574
  for (const line of output.split("\n")) {
929
575
  if (!line) continue;
930
- const [hash, message, date] = line.split("");
576
+ const [hash, message, date, timestamp] = line.split("");
931
577
  if (!hash || !message || !date) continue;
932
- commits.push({ hash, message, date });
578
+ commits.push({ hash, message, date, timestamp: Number(timestamp) || void 0 });
933
579
  }
934
580
  return commits;
935
581
  }
@@ -1227,40 +873,434 @@ async function getEffectiveMergeBase(cwd, headRef, upstreamRef) {
1227
873
  if (!recorded) {
1228
874
  return { base: gitBase, isStale: false, storedRef: null };
1229
875
  }
1230
- const recordedIsNewer = await isAncestor(cwd, gitBase, recorded);
1231
- if (recordedIsNewer && recorded !== gitBase) {
1232
- return { base: recorded, isStale: true, storedRef: recorded };
876
+ const recordedIsNewer = await isAncestor(cwd, gitBase, recorded);
877
+ if (recordedIsNewer && recorded !== gitBase) {
878
+ return { base: recorded, isStale: true, storedRef: recorded };
879
+ }
880
+ return { base: gitBase, isStale: false, storedRef: recorded };
881
+ }
882
+ async function withTemporarySyncBaseGraft(cwd, headRef, baseSha, fn) {
883
+ const headSha = await revParse(cwd, headRef);
884
+ if (!headSha || !await commitObjectExists(cwd, baseSha)) return fn();
885
+ if (await isAncestor(cwd, baseSha, headSha)) return fn();
886
+ const existingReplacement = await git(["replace", "-l", headSha], cwd, { ignoreErrors: true });
887
+ if (existingReplacement) return fn();
888
+ const parentsRaw = await git(["log", "-1", "--format=%P", headSha], cwd, { ignoreErrors: true });
889
+ const parents = parentsRaw ? parentsRaw.split(/\s+/) : [];
890
+ await git(["replace", "-f", "--graft", headSha, ...parents, baseSha], cwd);
891
+ try {
892
+ return await fn();
893
+ } finally {
894
+ await git(["replace", "-d", headSha], cwd, { ignoreErrors: true });
895
+ }
896
+ }
897
+
898
+ // src/utils/layout.ts
899
+ import process4 from "process";
900
+ import { stripVTControlCharacters } from "util";
901
+ function terminalColumns() {
902
+ const columns = process4.stdout.isTTY ? process4.stdout.columns : void 0;
903
+ return Math.max(60, columns || 80);
904
+ }
905
+ function visibleWidth(text) {
906
+ return stripVTControlCharacters(text).length;
907
+ }
908
+ function padVisible(text, width, align = "start") {
909
+ const padding = " ".repeat(Math.max(0, width - visibleWidth(text)));
910
+ return align === "start" ? `${text}${padding}` : `${padding}${text}`;
911
+ }
912
+ function fitText(text, width) {
913
+ if (text.length <= width) return text;
914
+ return `${text.slice(0, Math.max(0, width - 1)).trimEnd()}\u2026`;
915
+ }
916
+ var AGE_STEPS = [
917
+ [90 * 60, 60, "m"],
918
+ [36 * 3600, 3600, "h"],
919
+ [14 * 86400, 86400, "d"],
920
+ [70 * 86400, 7 * 86400, "w"],
921
+ [365 * 86400, 30 * 86400, "mo"]
922
+ ];
923
+ function shortAge(epochSeconds, nowMs = Date.now()) {
924
+ const seconds = Math.max(0, Math.round(nowMs / 1e3 - epochSeconds));
925
+ if (seconds < 60) return "now";
926
+ for (const [below, unit, suffix] of AGE_STEPS) {
927
+ if (seconds < below) return `${Math.max(1, Math.round(seconds / unit))}${suffix}`;
928
+ }
929
+ return `${Math.floor(seconds / (365 * 86400))}y`;
930
+ }
931
+ function compactCount(count3) {
932
+ if (count3 < 1e4) return String(count3);
933
+ if (count3 < 1e6) return `${(count3 / 1e3).toFixed(1)}k`;
934
+ return `${(count3 / 1e6).toFixed(1)}M`;
935
+ }
936
+
937
+ // src/utils/display.ts
938
+ var VERSION = package_default.version;
939
+ var DIVIDER = "\u2500".repeat(60);
940
+ var MENU_DIVIDER = "\u2500".repeat(40);
941
+ var COMMIT_LIST_MAX = 50;
942
+ var COMMIT_STEP_MAX = 10;
943
+ var STEP_LABEL_WIDTH = 9;
944
+ var STEP_INDENT = 2 + STEP_LABEL_WIDTH + 2;
945
+ var warningMark = colors_default.yellow("\u26A0");
946
+ var checkMark = colors_default.green("\u2713");
947
+ var activeSpinner = null;
948
+ var jsonMode = false;
949
+ function setJsonMode(enabled) {
950
+ jsonMode = enabled;
951
+ if (!enabled) return;
952
+ const toStderr = (...args) => {
953
+ process5.stderr.write(`${args.map((a) => typeof a === "string" ? a : String(a)).join(" ")}
954
+ `);
955
+ };
956
+ console.info = toStderr;
957
+ console.warn = toStderr;
958
+ }
959
+ function writeStdout(text) {
960
+ process5.stdout.write(text.endsWith("\n") ? text : `${text}
961
+ `);
962
+ }
963
+ function printHeader() {
964
+ const left = "\u29C8 cella cli";
965
+ const version = ` \xB7 v${VERSION}`;
966
+ const right = "cellajs.com";
967
+ const padding = Math.max(1, DIVIDER.length - `${left}${version}${right}`.length);
968
+ console.info();
969
+ console.info(`${colors_default.cyan(left)}${colors_default.dim(version)}${colors_default.cyan(`${" ".repeat(padding)}${right}`)}`);
970
+ console.info(DIVIDER);
971
+ console.info();
972
+ }
973
+ function createSpinner(text) {
974
+ const enabled = !jsonMode && !getEnv("VITEST") && getEnv("NODE_ENV") !== "test" && !!process5.stdout.isTTY;
975
+ const frames = ["-", "\\", "|", "/"];
976
+ let frame = 0;
977
+ let timer;
978
+ const spinner = {
979
+ text,
980
+ stop() {
981
+ clearInterval(timer);
982
+ if (enabled) process5.stdout.write("\r\x1B[2K");
983
+ }
984
+ };
985
+ const render = () => process5.stdout.write(`\r${colors_default.cyan(frames[frame++ % frames.length])} ${spinner.text}`);
986
+ if (enabled) {
987
+ render();
988
+ timer = setInterval(render, 80);
989
+ }
990
+ activeSpinner = spinner;
991
+ return spinner;
992
+ }
993
+ function printStep(label, detail, mark = checkMark) {
994
+ stopSpinner();
995
+ const [first = "", ...rest] = (detail ?? "").split("\n");
996
+ console.info(`${mark} ${colors_default.bold(label.padEnd(STEP_LABEL_WIDTH))} ${first}`.trimEnd());
997
+ for (const line of rest) console.info(line ? `${" ".repeat(STEP_INDENT)}${line}` : "");
998
+ }
999
+ function spinnerSuccess(message, detail) {
1000
+ stopSpinner();
1001
+ if (!message) return;
1002
+ console.info(`${checkMark} ${message}`);
1003
+ if (detail) {
1004
+ console.info(` ${colors_default.dim(detail)}`);
1005
+ console.info();
1006
+ }
1007
+ }
1008
+ function spinnerFail(message) {
1009
+ stopSpinner();
1010
+ console.info(`${colors_default.red("\u2717")} ${message}`);
1011
+ }
1012
+ function spinnerText(text) {
1013
+ if (activeSpinner) {
1014
+ activeSpinner.text = text;
1015
+ }
1016
+ }
1017
+ function stopSpinner() {
1018
+ if (activeSpinner) {
1019
+ activeSpinner.stop();
1020
+ activeSpinner = null;
1021
+ }
1022
+ }
1023
+ function hyperlink(label, url) {
1024
+ if (!url) return label;
1025
+ const safeUrl = url.replace(/[\x00-\x1f\x7f]/g, "");
1026
+ return `\x1B]8;;${safeUrl}\x07${label}\x1B]8;;\x07`;
1027
+ }
1028
+ function styleSubject(subject) {
1029
+ if (/^chore(\([^)]*\))?: release\b/.test(subject)) return colors_default.dim(subject);
1030
+ const breaking = /^([a-z]+(\([^)]*\))?!:)/.exec(subject);
1031
+ return breaking ? `${colors_default.yellow(breaking[1])}${subject.slice(breaking[1].length)}` : subject;
1032
+ }
1033
+ function formatCheckoutStep(checkout) {
1034
+ const { forkPath, branch, headSha, changeCount, upstream } = checkout;
1035
+ const parts = [
1036
+ changeCount === 0 ? "clean" : `${plural(changeCount, "uncommitted change")}, not included in the analysis`
1037
+ ];
1038
+ if (upstream.upstream && (upstream.ahead > 0 || upstream.behind > 0)) {
1039
+ parts.push(`${upstream.ahead} ahead, ${upstream.behind} behind ${upstream.upstream}`);
1040
+ }
1041
+ const line = `${branch} @ ${headSha}${colors_default.dim(` \xB7 ${parts.join(" \xB7 ")}`)}`;
1042
+ return resolve3(forkPath) === resolve3(process5.cwd()) ? line : `${line}
1043
+ ${colors_default.dim(forkPath)}`;
1044
+ }
1045
+ function formatUpstreamStep(upstream) {
1046
+ const { label, sha, baseSha, pinned, commitCount, commits, githubUrl } = upstream;
1047
+ const since = commitCount === 0 ? "no new commits since the last sync" : `${plural(commitCount, "new commit")} since the last sync at ${baseSha}`;
1048
+ return [
1049
+ `${label} @ ${sha}${colors_default.dim(`${pinned ? " \xB7 --ref" : ""} \xB7 ${since}`)}`,
1050
+ ...formatUpstreamCommits(commitCount, commits, githubUrl)
1051
+ ].join("\n");
1052
+ }
1053
+ function formatUpstreamCommits(commitCount, commits, upstreamGitHubUrl, options = {}) {
1054
+ const { columns = terminalColumns(), max = COMMIT_STEP_MAX, nowMs } = options;
1055
+ const shown = commits.slice(-max).reverse();
1056
+ const rows = shown.map((commit) => ({ commit, ...splitPrNumber(commit.message) }));
1057
+ const prWidth = Math.max(0, ...rows.map((row) => row.pr ? `#${row.pr}`.length : 0));
1058
+ const ages = rows.map((row) => row.commit.timestamp ? shortAge(row.commit.timestamp, nowMs) : "");
1059
+ const ageWidth = Math.max(0, ...ages.map((age) => age.length));
1060
+ const subjectWidth = Math.max(
1061
+ 20,
1062
+ columns - STEP_INDENT - 7 - 2 - (prWidth ? prWidth + 2 : 0) - (ageWidth ? ageWidth + 2 : 0)
1063
+ );
1064
+ const lines = rows.map((row, index) => {
1065
+ const hash = hyperlink(
1066
+ row.commit.hash.slice(0, 7),
1067
+ upstreamGitHubUrl && `${upstreamGitHubUrl}/commit/${row.commit.hash}`
1068
+ );
1069
+ const subject = fitText(row.subject, subjectWidth);
1070
+ let line = `${colors_default.dim(hash)} ${padVisible(styleSubject(subject), subjectWidth)}`;
1071
+ if (prWidth) {
1072
+ const pr = row.pr ? hyperlink(`#${row.pr}`, upstreamGitHubUrl && `${upstreamGitHubUrl}/pull/${row.pr}`) : "";
1073
+ line += ` ${colors_default.dim(padVisible(pr, prWidth))}`;
1074
+ }
1075
+ if (ageWidth) line += ` ${colors_default.dim(ages[index].padStart(ageWidth))}`;
1076
+ return line.trimEnd();
1077
+ });
1078
+ if (commitCount > shown.length) lines.push(colors_default.dim(`+ ${commitCount - shown.length} earlier`));
1079
+ return lines;
1080
+ }
1081
+ function printNextSteps(steps) {
1082
+ if (steps.length === 0) return;
1083
+ printSectionHeader(colors_default.cyan("next steps"));
1084
+ for (const [index, step] of steps.entries()) {
1085
+ const label = step.label.padEnd(STEP_INDENT - 5);
1086
+ console.info(` ${colors_default.dim(String(index + 1))} ${colors_default.bold(label)} ${step.line}`.trimEnd());
1087
+ for (const detail of step.details ?? []) console.info(`${" ".repeat(STEP_INDENT)}${detail}`);
1088
+ }
1089
+ }
1090
+ function printSectionHeader(title) {
1091
+ console.info();
1092
+ console.info(title);
1093
+ console.info(DIVIDER);
1094
+ console.info();
1095
+ }
1096
+ function formatMergeInProgressDetail(conflictCount, autoMergedFiles, linkOptions, maxFiles = 100) {
1097
+ const lines = [`${plural(conflictCount, "conflict")} to resolve in your editor`];
1098
+ if (autoMergedFiles.length === 0) {
1099
+ return lines.join("\n");
1100
+ }
1101
+ lines.push("");
1102
+ lines.push(` auto-merged files (${autoMergedFiles.length}):`);
1103
+ const { forkPath } = linkOptions;
1104
+ const shown = autoMergedFiles.slice(0, maxFiles);
1105
+ for (const filePath of shown) {
1106
+ lines.push(` - ${forkPath ? hyperlink(filePath, `vscode://file${resolve3(forkPath, filePath)}`) : filePath}`);
1107
+ }
1108
+ if (autoMergedFiles.length > shown.length) {
1109
+ lines.push(` ... + ${autoMergedFiles.length - shown.length} more`);
1110
+ }
1111
+ return lines.join("\n");
1112
+ }
1113
+ var statusConfig = {
1114
+ behind: { icon: colors_default.cyan("\u2193"), label: "behind", color: colors_default.cyan, description: "upstream changed, will sync" },
1115
+ diverged: { icon: colors_default.magenta("\u21C5"), label: "diverged", color: colors_default.magenta, description: "both changed, will merge" },
1116
+ drifted: { icon: colors_default.yellow("!"), label: "drifted", color: colors_default.yellow, description: "fork changed (at risk)" },
1117
+ ahead: { icon: colors_default.blue("\u2191"), label: "ahead", color: colors_default.blue, description: "fork changed (protected)" },
1118
+ local: { icon: colors_default.green("+"), label: "local", color: colors_default.green, description: "local file in cella folders" },
1119
+ pinned: { icon: colors_default.green("\u2A00"), label: "pinned", color: colors_default.green, description: "both changed, fork wins" },
1120
+ ignored: { icon: colors_default.gray("\u2A02"), label: "ignored", color: colors_default.gray },
1121
+ identical: { icon: colors_default.gray("\u2713"), label: "identical", color: colors_default.gray, description: "no changes" },
1122
+ deleted: { icon: colors_default.red("\u2717"), label: "deleted", color: colors_default.red },
1123
+ renamed: { icon: colors_default.blue("\u2192"), label: "renamed", color: colors_default.blue }
1124
+ };
1125
+ var summaryStatusConfig = {
1126
+ ...statusConfig,
1127
+ managed: {
1128
+ icon: colors_default.cyan("\u25C7"),
1129
+ label: "managed",
1130
+ color: colors_default.cyan,
1131
+ description: "package.jsons & cella/cella.config.ts handled separately"
1132
+ },
1133
+ ignored: { ...statusConfig.ignored, description: "protected by ignored config" }
1134
+ };
1135
+ function printSummary(summary, title = "summary") {
1136
+ printSectionHeader(colors_default.cyan(title));
1137
+ const maxCount = Math.max(...Object.values(summary));
1138
+ const countWidth = String(maxCount).length + 1;
1139
+ const groups = [
1140
+ ["managed", "ignored"],
1141
+ ["identical"],
1142
+ ["ahead", "local", "drifted"],
1143
+ ["diverged", "pinned"],
1144
+ ["behind"]
1145
+ ];
1146
+ for (const group of groups) {
1147
+ if (group !== groups[0]) console.info();
1148
+ for (const status of group) {
1149
+ const { icon, label, color, description } = summaryStatusConfig[status];
1150
+ const countStr = color(String(summary[status]).padStart(countWidth));
1151
+ console.info(` ${icon} ${countStr} ${label.padEnd(15)}${description ? colors_default.dim(description) : ""}`);
1152
+ }
1153
+ }
1154
+ }
1155
+ function filesWithStatus(files, status) {
1156
+ return files.filter((f) => f.status === status && !isManagedFile(f.path));
1157
+ }
1158
+ function findProtectedBehind(files) {
1159
+ return files.filter((file) => file.upstreamChanged && !isManagedFile(file.path) && !isGeneratedFile(file.path));
1160
+ }
1161
+ function findProtectedBehindUpstream(result) {
1162
+ const ignoredOnly = new Set((result.ignoredUpstreamChanges ?? []).flatMap((group) => group.paths));
1163
+ return [...findProtectedBehind(result.files), ...result.files.filter((file) => ignoredOnly.has(file.path))];
1164
+ }
1165
+ 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.";
1166
+ function protectionIcon(file) {
1167
+ return file.isIgnored ? statusConfig.ignored.icon : statusConfig.pinned.icon;
1168
+ }
1169
+ function formatUpstreamChangedLines(file) {
1170
+ if (file.upstreamChangedLines === void 0) return "";
1171
+ return colors_default.yellow(` \xB7 ${plural(file.upstreamChangedLines, "line")} changed upstream`);
1172
+ }
1173
+ function printSyncComplete(result, options = {}) {
1174
+ const updated = result.files.filter((file) => file.status === "behind" || file.status === "diverged").length;
1175
+ const diverged = result.files.filter((file) => file.status === "diverged").length;
1176
+ const merged = result.autoMergedFiles?.length ?? Math.max(0, diverged - result.conflicts.length);
1177
+ const conflicts = result.conflicts.length;
1178
+ const counts = `${plural(updated, "file")} updated, ${merged} auto-merged, ${plural(conflicts, "conflict")}`;
1179
+ console.info();
1180
+ if (options.stagedBranch) {
1181
+ console.info(`${checkMark} sync merge staged on '${options.stagedBranch}'`);
1182
+ console.info(colors_default.dim(` ${counts}. committing next...`));
1183
+ } else if (conflicts > 0) {
1184
+ console.info(`${warningMark} sync merge staged with conflicts`);
1185
+ console.info(colors_default.dim(` ${counts}`));
1186
+ } else {
1187
+ console.info(`${checkMark} sync complete`);
1188
+ console.info(colors_default.dim(` ${counts}`));
1189
+ }
1190
+ printProtectedConflicts(result);
1191
+ console.info();
1192
+ }
1193
+ function printProtectedConflicts(result) {
1194
+ const paths = result.protectedConflicts ?? [];
1195
+ if (paths.length === 0) return;
1196
+ const byPath2 = new Map(result.files.map((file) => [file.path, file]));
1197
+ console.info();
1198
+ console.info(
1199
+ `${warningMark} ${colors_default.yellow(
1200
+ `${plural(paths.length, "protected file kept", "protected files kept")} the fork version, dropping upstream changes:`
1201
+ )}`
1202
+ );
1203
+ for (const path of paths) {
1204
+ const file = byPath2.get(path);
1205
+ const icon = file ? protectionIcon(file) : statusConfig.pinned.icon;
1206
+ console.info(` ${icon} ${path}${file ? formatUpstreamChangedLines(file) : ""}`);
1207
+ }
1208
+ console.info(colors_default.dim(` ${PROTECTED_BEHIND_HINT}`));
1209
+ }
1210
+ function plural(count3, singular, pluralForm = `${singular}s`) {
1211
+ return `${count3} ${count3 === 1 ? singular : pluralForm}`;
1212
+ }
1213
+ function printIgnoredUpstreamChanges(result) {
1214
+ const groups = result.ignoredUpstreamChanges ?? [];
1215
+ if (groups.length === 0) return;
1216
+ const total = groups.reduce((count3, group) => count3 + group.paths.length, 0);
1217
+ console.info();
1218
+ console.info(
1219
+ `${warningMark} ${colors_default.yellow(`ignored paths changed upstream \xB7 ${plural(total, "file")} under ${plural(groups.length, "entry", "entries")}`)}`
1220
+ );
1221
+ for (const group of groups) {
1222
+ const counts = [plural(group.paths.length, "file")];
1223
+ if (group.added > 0) counts.push(`${group.added} new`);
1224
+ if (group.deleted > 0) counts.push(`${group.deleted} deleted`);
1225
+ console.info(` ${statusConfig.ignored.icon} ${group.entry} ${colors_default.dim(`\xB7 ${counts.join(", ")}`)}`);
1226
+ }
1227
+ console.info(colors_default.dim(" the fork left these untouched and ignored paths never sync: review and adopt what you need."));
1228
+ }
1229
+ function printUpstreamOverrideChanges(result) {
1230
+ const report = result.upstreamOverrides;
1231
+ if (!report) return;
1232
+ console.info();
1233
+ if (report.kind === "unreadable") {
1234
+ const side = report.side === "base" ? "the last sync point" : "the incoming ref";
1235
+ console.info(
1236
+ colors_default.dim(`upstream sync config not compared: upstream ${CONFIG_FILE} is missing or unreadable at ${side}`)
1237
+ );
1238
+ return;
1239
+ }
1240
+ const lines = [];
1241
+ for (const list of ["pinned", "ignored", "packageJsonSync"]) {
1242
+ for (const entry of report[list].added) lines.push(` ${colors_default.green("+")} ${list}: ${entry}`);
1243
+ for (const entry of report[list].removed) {
1244
+ lines.push(` ${colors_default.red("\u2212")} ${list}: ${entry}${colors_default.dim(" \xB7 dropped upstream, still in your config")}`);
1245
+ }
1233
1246
  }
1234
- return { base: gitBase, isStale: false, storedRef: recorded };
1247
+ console.info(
1248
+ `${warningMark} ${colors_default.yellow(`upstream changed its sync config \xB7 ${plural(lines.length, "entry", "entries")} to review`)}`
1249
+ );
1250
+ for (const line of lines) console.info(line);
1251
+ console.info(colors_default.dim(` ${CONFIG_FILE} never syncs: add or drop these by hand where they fit your app.`));
1235
1252
  }
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 });
1253
+ function findMaskingPins(files) {
1254
+ return files.filter(
1255
+ (file) => file.isPinned && !isManagedFile(file.path) && file.status === "behind" && file.existsInFork && file.existsInUpstream
1256
+ );
1257
+ }
1258
+ function printMaskingPinWarning(files) {
1259
+ const masking = findMaskingPins(files);
1260
+ if (masking.length === 0) return;
1261
+ console.info();
1262
+ console.info(
1263
+ `${warningMark} ${colors_default.yellow(
1264
+ `${plural(masking.length, "pinned file matches", "pinned files match")} the previous upstream but changed upstream:`
1265
+ )}`
1266
+ );
1267
+ console.info(colors_default.yellow(" the pin keeps the old fork copy and silently drops those upstream changes:"));
1268
+ for (const file of masking) {
1269
+ console.info(colors_default.dim(` \u2A00 ${file.path}`));
1249
1270
  }
1271
+ console.info(
1272
+ colors_default.yellow(" if the fork never customized these, unpin them in cella/cella.config.ts to take upstream.")
1273
+ );
1274
+ console.info();
1250
1275
  }
1276
+ function printEngineReports(result, title) {
1277
+ printSummary(result.summary, title);
1278
+ printIgnoredUpstreamChanges(result);
1279
+ printUpstreamOverrideChanges(result);
1280
+ printMaskingPinWarning(result.files);
1281
+ }
1282
+
1283
+ // src/utils/overrides.ts
1284
+ import { existsSync as existsSync5 } from "fs";
1285
+ import { join as join6 } from "path";
1251
1286
 
1252
- // src/utils/migration-notes.ts
1287
+ // src/utils/alignments.ts
1253
1288
  import { existsSync as existsSync4 } from "fs";
1254
1289
  import { mkdir as mkdir3, readFile as readFile2, rm, writeFile as writeFile2 } from "fs/promises";
1255
1290
  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]+)*$/;
1291
+ var ALIGNMENTS_DIR = "cella/alignments";
1292
+ var UPSTREAM_ONLY_DIRS = [ALIGNMENTS_DIR, "cella/migrations"];
1293
+ var PENDING_FILE = "cella/cella.alignments.json";
1294
+ var ALIGNMENT_ID = /^\d{8}T\d{4}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
1295
+ var CAUGHT_BY = ["compiler", "tests", "nothing"];
1259
1296
  function isCodemodFile(name) {
1260
1297
  return name.endsWith(".ts") && !name.endsWith(".test.ts");
1261
1298
  }
1299
+ function splitList(value) {
1300
+ return (value ?? "").split(",").map((item) => item.trim()).filter(Boolean);
1301
+ }
1262
1302
  function isUpstreamOnly(filePath) {
1263
- return filePath === NOTES_DIR || filePath.startsWith(`${NOTES_DIR}/`);
1303
+ return UPSTREAM_ONLY_DIRS.some((dir) => filePath === dir || filePath.startsWith(`${dir}/`));
1264
1304
  }
1265
1305
  function bodyLines(lines) {
1266
1306
  let inComment = false;
@@ -1270,7 +1310,7 @@ function bodyLines(lines) {
1270
1310
  return false;
1271
1311
  });
1272
1312
  }
1273
- function parseNote(id, source, fileNames) {
1313
+ function parseAlignment(id, source, fileNames) {
1274
1314
  const lines = source.replace(/\r\n/g, "\n").split("\n");
1275
1315
  const close = lines[0] === "---" ? lines.indexOf("---", 1) : -1;
1276
1316
  const meta = /* @__PURE__ */ new Map();
@@ -1282,31 +1322,74 @@ function parseNote(id, source, fileNames) {
1282
1322
  const blocks = body.split(/\n\s*\n/).map((block) => block.trim()).filter(Boolean);
1283
1323
  const title = /^# (.+)$/.exec(blocks[0] ?? "")?.[1].trim();
1284
1324
  const summary = title && blocks[1] && !/^(#|>|[-*|]|```)/.test(blocks[1]) ? blocks[1].replace(/\s*\n\s*/g, " ") : "";
1325
+ const caughtBy = CAUGHT_BY.find((value) => value === meta.get("caughtBy")) ?? null;
1285
1326
  return {
1286
1327
  id,
1287
1328
  title: title || id,
1288
1329
  summary,
1289
1330
  syncBreaking: meta.get("syncBreaking") === "true",
1290
1331
  clientCacheBump: meta.get("clientCacheBump") === "true",
1291
- roots: (meta.get("roots") ?? "").split(",").map((root) => root.trim()).filter(Boolean),
1332
+ caughtBy,
1333
+ generate: meta.get("generate") === "true",
1334
+ grep: splitList(meta.get("grep")),
1335
+ roots: splitList(meta.get("roots")),
1292
1336
  codemod: fileNames.find(isCodemodFile) ?? null
1293
1337
  };
1294
1338
  }
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();
1339
+ async function listAlignmentIds(cwd, ref) {
1340
+ const out = await git(["ls-tree", "--name-only", `${ref}:${ALIGNMENTS_DIR}`], cwd, { ignoreErrors: true });
1341
+ return out.split("\n").filter((name) => ALIGNMENT_ID.test(name)).sort();
1298
1342
  }
1299
- async function readNote(cwd, ref, id) {
1300
- const source = await git(["show", `${ref}:${NOTES_DIR}/${id}/README.md`], cwd, { ignoreErrors: true });
1343
+ async function readAlignment(cwd, ref, id) {
1344
+ const source = await git(["show", `${ref}:${ALIGNMENTS_DIR}/${id}/README.md`], cwd, { ignoreErrors: true });
1301
1345
  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));
1346
+ const files = await git(["ls-tree", "--name-only", `${ref}:${ALIGNMENTS_DIR}/${id}`], cwd, { ignoreErrors: true });
1347
+ return parseAlignment(id, source, files.split("\n").filter(Boolean));
1304
1348
  }
1305
- async function arrivedNoteIds(cwd, from, to) {
1306
- const [before, after] = await Promise.all([listNoteIds(cwd, from), listNoteIds(cwd, to)]);
1349
+ async function arrivedAlignmentIds(cwd, from, to) {
1350
+ const [before, after] = await Promise.all([listAlignmentIds(cwd, from), listAlignmentIds(cwd, to)]);
1307
1351
  const known = new Set(before);
1308
1352
  return after.filter((id) => !known.has(id));
1309
1353
  }
1354
+ async function readAlignmentSource(cwd, ref, id) {
1355
+ const out = await git(
1356
+ ["log", "-1", "--diff-filter=A", "--format=%H%x09%s", ref, "--", `${ALIGNMENTS_DIR}/${id}/README.md`],
1357
+ cwd,
1358
+ { ignoreErrors: true }
1359
+ );
1360
+ if (!out) return null;
1361
+ const [commit, subject = ""] = out.split(" ");
1362
+ const { pr = null } = splitPrNumber(subject);
1363
+ const nulSeparated = (text) => text.split("\0").filter(Boolean);
1364
+ const [changed, added] = await Promise.all([
1365
+ git(["diff-tree", "--root", "--no-commit-id", "--name-only", "--no-renames", "-r", "-z", commit], cwd),
1366
+ git(
1367
+ [
1368
+ "diff-tree",
1369
+ "--root",
1370
+ "--no-commit-id",
1371
+ "--name-only",
1372
+ "--diff-filter=A",
1373
+ "-r",
1374
+ "-z",
1375
+ commit,
1376
+ "--",
1377
+ ALIGNMENTS_DIR
1378
+ ],
1379
+ cwd
1380
+ )
1381
+ ]);
1382
+ const readmeId = new RegExp(`^${ALIGNMENTS_DIR}/([^/]+)/README\\.md$`);
1383
+ const sharedCommit = nulSeparated(added).map((path) => readmeId.exec(path)?.[1]).filter((other) => !!other && other !== id && ALIGNMENT_ID.test(other)).sort();
1384
+ return { commit, pr, sharedCommit, changedFiles: nulSeparated(changed) };
1385
+ }
1386
+ async function grepAlignmentTerms(cwd, terms, inFiles) {
1387
+ if (terms.length === 0) return [];
1388
+ const out = await git(["grep", "-n", "-I", "-w", "-F", "-z", ...terms.flatMap((term) => ["-e", term]), "--"], cwd, {
1389
+ ignoreErrors: true
1390
+ });
1391
+ return out.split("\n").map((match) => match.split("\0")).filter(([file, line]) => file && line && inFiles(file)).map(([file, line]) => ({ file, line: Number(line) }));
1392
+ }
1310
1393
  async function readPending(cwd) {
1311
1394
  const path = join5(cwd, PENDING_FILE);
1312
1395
  if (!existsSync4(path)) return [];
@@ -1324,14 +1407,13 @@ async function writePending(cwd, ids) {
1324
1407
  await writeFile2(path, `${JSON.stringify({ pending }, null, 2)}
1325
1408
  `, "utf8");
1326
1409
  }
1327
- function noteUrl(upstreamGitHubUrl, commit, id) {
1328
- return upstreamGitHubUrl ? `${upstreamGitHubUrl}/blob/${commit}/${NOTES_DIR}/${id}/README.md` : void 0;
1410
+ function alignmentUrl(upstreamGitHubUrl, commit, id) {
1411
+ return upstreamGitHubUrl ? `${upstreamGitHubUrl}/blob/${commit}/${ALIGNMENTS_DIR}/${id}/README.md` : void 0;
1329
1412
  }
1330
- function formatNotesLine(total, open, arrived, options = {}) {
1413
+ function formatAlignmentsLine(total, open) {
1331
1414
  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 ")}`;
1415
+ if (open > 0) parts.push(`${open} open: pnpm cella align`);
1416
+ return `alignments: ${parts.join(" \xB7 ")}`;
1335
1417
  }
1336
1418
 
1337
1419
  // src/utils/overrides.ts
@@ -1455,6 +1537,7 @@ var serviceDefinitions = [
1455
1537
  ["--list", "non-interactive output for tooling (one file per line)"],
1456
1538
  jsonOption,
1457
1539
  ["--scope <scope>", "analyze scope for --list/--json: all|risk|protected"],
1540
+ ["--all", "list every file instead of collapsing large groups"],
1458
1541
  trackOption,
1459
1542
  refOption,
1460
1543
  unpinnedOption
@@ -1473,19 +1556,19 @@ var serviceDefinitions = [
1473
1556
  includeInMenu: (context) => !context.isUpstreamRepo
1474
1557
  },
1475
1558
  {
1476
- name: "migrate",
1477
- description: "list the upstream migration notes this app has not handled yet",
1559
+ name: "align",
1560
+ description: "list the upstream alignments this app has not handled yet",
1478
1561
  options: [
1479
- ["--all", "list every upstream note, handled or not"],
1562
+ ["--all", "list every upstream alignment, handled or not"],
1480
1563
  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"]
1564
+ ["--show <id>", "print one alignment's README and what git tells about it"],
1565
+ ["--extract <id>", "write one alignment's folder under node_modules/.cache to run its codemod"],
1566
+ ["--run <id>", "run one alignment's codemod; files identical to upstream stay as they are"],
1567
+ ["--mark <ids...>", "record alignments as handled"]
1485
1568
  ],
1486
1569
  operands: [
1487
1570
  "[codemodArgs...]",
1488
- "with --run, after `--`: arguments for the codemod (default: inventory and the note's roots)"
1571
+ "with --run, after `--`: arguments for the codemod (default: inventory and the alignment's roots)"
1489
1572
  ],
1490
1573
  includeInMenu: (context) => !context.isUpstreamRepo
1491
1574
  },
@@ -1560,8 +1643,8 @@ function buildProgram(setSelection) {
1560
1643
  " $ cella analyze --json --scope risk",
1561
1644
  " $ cella sync --track branch",
1562
1645
  " $ cella sync --ref 4f7d87c",
1563
- " $ cella migrate --run 20261001T2116-tailwind-class-conventions -- rewrite frontend/src",
1564
- " $ cella migrate --mark 20261002T0614-config-switch",
1646
+ " $ cella align --run 20261001T2116-tailwind-class-conventions -- rewrite frontend/src",
1647
+ " $ cella align --mark 20261002T0614-config-switch",
1565
1648
  " $ cella audit --list",
1566
1649
  " $ cella stats --since origin/main --md",
1567
1650
  " $ cella contributions --fork raak --json"
@@ -1592,7 +1675,7 @@ async function promptForService(userConfig, forkPath) {
1592
1675
  if (selected === "exit") {
1593
1676
  console.info(colors_default.dim("exiting..."));
1594
1677
  console.info();
1595
- process5.exit(0);
1678
+ process6.exit(0);
1596
1679
  }
1597
1680
  console.info();
1598
1681
  return selected;
@@ -1601,7 +1684,7 @@ function buildRuntimeConfig(userConfig, forkPath, selection, upstreamRef) {
1601
1684
  return { ...userConfig, forkPath, upstreamRef, service: selection.service ?? "analyze", ...selection.options };
1602
1685
  }
1603
1686
  async function parseCli(userConfig, forkPath) {
1604
- const selection = parseCommandLine(process5.argv);
1687
+ const selection = parseCommandLine(process6.argv);
1605
1688
  const machineList = selection.options.list && selection.service !== "audit";
1606
1689
  if (selection.options.json || selection.options.md || machineList) setJsonMode(true);
1607
1690
  printHeader();
@@ -1615,6 +1698,12 @@ async function parseCli(userConfig, forkPath) {
1615
1698
  return buildRuntimeConfig(userConfig, forkPath, selection, branchRef);
1616
1699
  }
1617
1700
 
1701
+ // src/services/align.ts
1702
+ import { execFileSync, spawnSync } from "child_process";
1703
+ import { mkdir as mkdir4, writeFile as writeFile3 } from "fs/promises";
1704
+ import { dirname as dirname4, join as join8 } from "path";
1705
+ import process7 from "process";
1706
+
1618
1707
  // src/services/merge-engine.ts
1619
1708
  import { existsSync as existsSync6 } from "fs";
1620
1709
  import { rm as rm2 } from "fs/promises";
@@ -1936,7 +2025,9 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
1936
2025
  const upstreamOnly = fileIsIgnored && isReportableIgnored(filePath) && upstreamHash !== baseHash && forkHash === baseHash;
1937
2026
  push(status, { upstreamChanged: upstreamChanged || void 0, upstreamOnly: upstreamOnly || void 0 });
1938
2027
  }
1939
- const flagged = analyzedFiles.filter((file) => file.upstreamChanged && !renamedProtected.has(file.path));
2028
+ const flagged = analyzedFiles.filter(
2029
+ (file) => file.upstreamChanged && !renamedProtected.has(file.path) || file.upstreamOnly
2030
+ );
1940
2031
  if (flagged.length > 0) {
1941
2032
  const stat = await getDiffStat(
1942
2033
  repoPath,
@@ -1983,7 +2074,7 @@ async function enrichChangeInfo(repoPath, files, mergeBaseRef, localRef, incomin
1983
2074
  setChanged(file, forkInfo.get(file.path));
1984
2075
  } else if (file.status === "behind") {
1985
2076
  setChanged(file, upstreamInfo.get(file.path));
1986
- } else if (file.status === "diverged" || file.status === "pinned" || file.upstreamChanged) {
2077
+ } else if (file.status === "diverged" || file.status === "pinned" || file.upstreamChanged || file.upstreamOnly) {
1987
2078
  setChanged(file, forkInfo.get(file.path));
1988
2079
  const info = upstreamInfo.get(file.path);
1989
2080
  if (info) {
@@ -2015,6 +2106,10 @@ function getGitHubBaseUrl(remoteUrl) {
2015
2106
  const match = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
2016
2107
  return match ? `https://github.com/${match[1]}/${match[2]}` : null;
2017
2108
  }
2109
+ async function readOriginGitHubUrl(forkPath) {
2110
+ const origin = await git(["remote", "get-url", "origin"], forkPath, { ignoreErrors: true });
2111
+ return getGitHubBaseUrl(origin.trim()) ?? void 0;
2112
+ }
2018
2113
  function isProtectedFile(file) {
2019
2114
  return file.isIgnored || file.isPinned;
2020
2115
  }
@@ -2150,11 +2245,6 @@ function calculateSummary(files) {
2150
2245
  }
2151
2246
  return summary;
2152
2247
  }
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
2248
  async function resolvePinnedRef(forkPath, config, remoteName, branchRef, ref) {
2159
2249
  const resolved = await resolveUpstreamCommit(forkPath, remoteName, ref);
2160
2250
  if (!resolved) {
@@ -2178,7 +2268,7 @@ async function prepareUpstream(config, onProgress, onStep) {
2178
2268
  getWorkingTreeChangeCount(forkPath),
2179
2269
  getUpstreamStatus(forkPath)
2180
2270
  ]);
2181
- onStep?.("local checkout", formatLocalCheckoutDetail(forkPath, branch, headSha, changeCount, upstreamStatus));
2271
+ onStep?.("checkout", formatCheckoutStep({ forkPath, branch, headSha, changeCount, upstream: upstreamStatus }));
2182
2272
  onProgress?.("setting up upstream remote...");
2183
2273
  const { track: configTrack, branchRef } = resolveUpstream(config.settings);
2184
2274
  const remoteName = DEFAULT_UPSTREAM_REMOTE;
@@ -2206,7 +2296,6 @@ async function prepareUpstream(config, onProgress, onStep) {
2206
2296
  }
2207
2297
  const upstreamLabel = releaseTag ?? (config.ref ? `--ref ${config.ref}` : upstreamRef);
2208
2298
  config.upstreamRef = upstreamRef;
2209
- onStep?.("remote configured", `${upstreamLabel} \u2192 ${config.settings.upstreamUrl}`);
2210
2299
  const cliRange = await readUpstreamCliRange(forkPath, upstreamRef);
2211
2300
  const cliMismatch = cliRange ? cliVersionMismatch(VERSION, cliRange) : null;
2212
2301
  if (cliMismatch) throw new Error(cliMismatch);
@@ -2217,20 +2306,35 @@ async function prepareUpstream(config, onProgress, onStep) {
2217
2306
  const mergeRef = config.unpinned ? await getMergeBase(forkPath, "HEAD", upstreamRef) : mergeBase;
2218
2307
  const upstreamCommit = await getCommitInfo(forkPath, upstreamRef);
2219
2308
  if (upstreamCommit.hash !== mergeBase && await isAncestor(forkPath, upstreamCommit.hash, mergeBase)) {
2220
- const [upstreamShort, syncShort] = await Promise.all([
2309
+ const [upstreamShort2, syncShort] = await Promise.all([
2221
2310
  getShortSha(forkPath, upstreamCommit.hash),
2222
2311
  getShortSha(forkPath, mergeBase)
2223
2312
  ]);
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}.`;
2313
+ const hint2 = 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}.`;
2225
2314
  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}`
2315
+ `upstream ${upstreamLabel} (${upstreamShort2}) is behind the last sync point ${syncShort}: the fork already has it, and syncing it would revert the upstream changes after it.
2316
+ ${hint2}`
2228
2317
  );
2229
2318
  }
2230
2319
  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 };
2320
+ const upstreamCommits = commitCount > 0 ? await listCommitsBetween(forkPath, mergeBase, upstreamRef, COMMIT_STEP_MAX) : [];
2321
+ const [upstreamShort, baseShort] = await Promise.all([
2322
+ getShortSha(forkPath, upstreamCommit.hash),
2323
+ getShortSha(forkPath, mergeBase)
2324
+ ]);
2325
+ onStep?.(
2326
+ "upstream",
2327
+ formatUpstreamStep({
2328
+ label: releaseTag ?? branchRef.replace(`${remoteName}/`, ""),
2329
+ sha: upstreamShort,
2330
+ baseSha: baseShort,
2331
+ pinned: !!config.ref,
2332
+ commitCount,
2333
+ commits: upstreamCommits,
2334
+ githubUrl: upstreamGitHubUrl
2335
+ })
2336
+ );
2337
+ return { upstreamRef, releaseTag, mergeBase, mergeRef, upstreamGitHubUrl, upstreamCommit, commitCount };
2234
2338
  }
2235
2339
  async function assertSyncConfigFollowed(config, ctx) {
2236
2340
  if (config.keepConfig) return;
@@ -2245,7 +2349,8 @@ function resultMeta(config, ctx) {
2245
2349
  upstreamRef: ctx.upstreamRef,
2246
2350
  upstreamTag: ctx.releaseTag,
2247
2351
  upstreamGitHubUrl: ctx.upstreamGitHubUrl,
2248
- upstreamCommit: ctx.upstreamCommit
2352
+ upstreamCommit: ctx.upstreamCommit,
2353
+ upstreamCommitCount: ctx.commitCount
2249
2354
  };
2250
2355
  }
2251
2356
  async function upstreamOnlyReports(config, ctx, files) {
@@ -2266,33 +2371,40 @@ async function upstreamOnlyReports(config, ctx, files) {
2266
2371
  return {};
2267
2372
  }
2268
2373
  }
2269
- async function readNotesInRange(config, ctx) {
2374
+ function pendingAfterSync(alignments) {
2375
+ return [.../* @__PURE__ */ new Set([...alignments.pending, ...alignments.arrived])];
2376
+ }
2377
+ async function readAlignmentsInRange(config, ctx) {
2270
2378
  const { forkPath } = config;
2271
2379
  try {
2272
2380
  const [total, arrived, pending] = await Promise.all([
2273
- listNoteIds(forkPath, ctx.upstreamRef),
2274
- arrivedNoteIds(forkPath, ctx.mergeBase, ctx.upstreamRef),
2381
+ listAlignmentIds(forkPath, ctx.upstreamRef),
2382
+ arrivedAlignmentIds(forkPath, ctx.mergeBase, ctx.upstreamRef),
2275
2383
  readPending(forkPath)
2276
2384
  ]);
2277
- return { total: total.length, arrived, pending: [.../* @__PURE__ */ new Set([...pending, ...arrived])] };
2385
+ return { total: total.length, arrived, pending };
2278
2386
  } catch {
2279
2387
  return void 0;
2280
2388
  }
2281
2389
  }
2282
- function notesResult(notes) {
2283
- return notes ? { migrationNotes: { total: notes.total, arrived: notes.arrived, open: notes.pending.length } } : {};
2390
+ function alignmentsResult(alignments, options) {
2391
+ if (!alignments) return {};
2392
+ const open = options.dryRun ? alignments.pending : pendingAfterSync(alignments);
2393
+ return { alignments: { total: alignments.total, arrived: alignments.arrived, open } };
2284
2394
  }
2285
2395
  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`);
2396
+ for (const dir of UPSTREAM_ONLY_DIRS) {
2397
+ if (!existsSync6(join7(forkPath, dir))) continue;
2398
+ await git(["rm", "-r", "-q", "--ignore-unmatch", "--", dir], forkPath);
2399
+ await rm2(join7(forkPath, dir), { recursive: true, force: true });
2400
+ onStep?.("alignments", `removed ${dir}/: alignments stay upstream, list them with pnpm cella align`);
2401
+ }
2290
2402
  }
2291
2403
  async function runSyncMerge(config, ctx, onProgress, onStep) {
2292
2404
  const { forkPath } = config;
2293
2405
  const { upstreamRef, releaseTag, mergeRef, upstreamGitHubUrl, upstreamCommit } = ctx;
2294
2406
  await assertSyncConfigFollowed(config, ctx);
2295
- const notes = await readNotesInRange(config, ctx);
2407
+ const alignments = await readAlignmentsInRange(config, ctx);
2296
2408
  const { remainingConflicts, analyzedFiles, autoMergedFiles, protectedConflicts } = await applyDirectMerge(
2297
2409
  forkPath,
2298
2410
  upstreamRef,
@@ -2317,18 +2429,15 @@ async function runSyncMerge(config, ctx, onProgress, onStep) {
2317
2429
  await storeLastSyncRef(forkPath, upstreamCommit.hash);
2318
2430
  await writeSyncManifest(forkPath, syncManifest);
2319
2431
  await stagePath(forkPath, MANIFEST_FILE);
2320
- if (notes) {
2321
- await writePending(forkPath, notes.pending);
2432
+ if (alignments) {
2433
+ await writePending(forkPath, pendingAfterSync(alignments));
2322
2434
  await stagePath(forkPath, PENDING_FILE);
2323
2435
  }
2324
2436
  await removeUpstreamOnlyCopy(forkPath, onStep);
2325
2437
  };
2326
2438
  if (remainingConflicts.length > 0) {
2327
2439
  await recordSyncPoint();
2328
- onStep?.(
2329
- "merge in progress",
2330
- formatMergeInProgressDetail(remainingConflicts.length, autoMergedFiles, { forkPath }, 100)
2331
- );
2440
+ onStep?.("merged", formatMergeInProgressDetail(remainingConflicts.length, autoMergedFiles, { forkPath }, 100));
2332
2441
  } else if (totalResolved > 0) {
2333
2442
  const label = synced > 0 ? `${synced} files from upstream` : "upstream changes";
2334
2443
  await recordSyncPoint();
@@ -2346,12 +2455,12 @@ async function runSyncMerge(config, ctx, onProgress, onStep) {
2346
2455
  protectedConflicts,
2347
2456
  ...resultMeta(config, ctx),
2348
2457
  ...await upstreamOnlyReports(config, ctx, analyzedFiles),
2349
- ...notesResult(notes)
2458
+ ...alignmentsResult(alignments, { dryRun: false })
2350
2459
  };
2351
2460
  }
2352
2461
  async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2353
2462
  const { forkPath } = config;
2354
- const notes = await readNotesInRange(config, ctx);
2463
+ const alignments = await readAlignmentsInRange(config, ctx);
2355
2464
  onProgress?.("analyzing files...");
2356
2465
  const analyzedFiles = await analyzeRefs(
2357
2466
  forkPath,
@@ -2364,7 +2473,7 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2364
2473
  await enrichChangeInfo(forkPath, analyzedFiles, ctx.mergeBase, "HEAD", ctx.upstreamRef);
2365
2474
  const protectedConflicts = analyzedFiles.filter((f) => f.upstreamChanged).map((f) => f.path);
2366
2475
  const reports = await upstreamOnlyReports(config, ctx, analyzedFiles);
2367
- onStep?.("analysis complete", `${analyzedFiles.length} files analyzed, dry run, no changes applied`);
2476
+ onStep?.("analyzed", `${plural(analyzedFiles.length, "file")}${colors_default.dim(" \xB7 dry run, nothing changed")}`);
2368
2477
  return {
2369
2478
  success: true,
2370
2479
  files: analyzedFiles,
@@ -2374,25 +2483,23 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2374
2483
  protectedConflicts,
2375
2484
  ...resultMeta(config, ctx),
2376
2485
  ...reports,
2377
- ...notesResult(notes)
2486
+ ...alignmentsResult(alignments, { dryRun: true })
2378
2487
  };
2379
2488
  }
2380
2489
  async function runEngineWithSpinner(config, apply) {
2381
2490
  const onProgress = (message) => spinnerText(message);
2382
2491
  const onStep = (label, detail) => {
2383
- spinnerSuccess(label, detail);
2492
+ printStep(label, detail);
2384
2493
  createSpinner("...");
2385
2494
  };
2386
2495
  const ctx = await prepareUpstream(config, onProgress, onStep);
2387
2496
  return apply ? runSyncMerge(config, ctx, onProgress, onStep) : runAnalyzePreview(config, ctx, onProgress, onStep);
2388
2497
  }
2389
2498
 
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";
2499
+ // src/services/align.ts
2500
+ var EXTRACT_DIR = "node_modules/.cache/cella/alignments";
2395
2501
  var RESTORED_LIST_MAX = 20;
2502
+ var SHOW_LIST_MAX = 30;
2396
2503
  async function ensureUpstreamCommit(config, sha) {
2397
2504
  const { forkPath } = config;
2398
2505
  const present = async () => !!await revParse(forkPath, `${sha}^{commit}`);
@@ -2402,60 +2509,158 @@ async function ensureUpstreamCommit(config, sha) {
2402
2509
  if (!await present())
2403
2510
  throw new Error(`upstream commit ${sha.slice(0, 7)} is not reachable from '${DEFAULT_UPSTREAM_REMOTE}'`);
2404
2511
  }
2405
- async function resolveNotesRef(config) {
2512
+ async function resolveAlignmentsRef(config) {
2406
2513
  const { forkPath } = config;
2407
2514
  const ref = await getStoredSyncRef(forkPath) ?? await readManifestBaseAtRef(forkPath, "HEAD");
2408
2515
  if (!ref) return null;
2409
2516
  await ensureUpstreamCommit(config, ref);
2410
2517
  return ref;
2411
2518
  }
2412
- async function readNotesStatus(config) {
2519
+ async function readOpenAlignments(config) {
2413
2520
  try {
2414
- const ref = await resolveNotesRef(config);
2521
+ const ref = await resolveAlignmentsRef(config);
2415
2522
  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;
2523
+ return { ref, open: await readPending(config.forkPath) };
2418
2524
  } catch {
2419
2525
  return null;
2420
2526
  }
2421
2527
  }
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)));
2528
+ var STEP_ROWS_MAX = 5;
2529
+ function alignmentTags(alignment) {
2530
+ return [
2531
+ alignment.codemod ? "codemod" : "manual",
2532
+ alignment.syncBreaking ? "sync-breaking" : "not sync-breaking",
2533
+ alignment.caughtBy ? `caught by ${alignment.caughtBy}` : null
2534
+ ].filter(Boolean).join(" \xB7 ");
2535
+ }
2536
+ async function alignmentsStep(config, view) {
2537
+ const arriving = view.arriving ?? [];
2538
+ const open = view.open.filter((id) => !arriving.includes(id));
2539
+ const arrived = (view.arrived ?? []).filter((id) => open.includes(id));
2540
+ if (arriving.length === 0 && open.length === 0) return void 0;
2541
+ const line = arriving.length > 0 ? `${plural(arriving.length, "alignment")} ${arriving.length === 1 ? "arrives" : "arrive"} with this sync${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 sync`) : ""}`;
2542
+ const githubUrl = getGitHubBaseUrl(config.settings.upstreamUrl) ?? void 0;
2543
+ const ids = [...arriving, ...open];
2544
+ const details = [];
2545
+ for (const id of ids.slice(0, STEP_ROWS_MAX)) {
2546
+ const alignment = await readAlignment(config.forkPath, view.ref, id).catch(() => null);
2547
+ const title = hyperlink(alignment?.title ?? id, alignmentUrl(githubUrl, view.ref, id));
2548
+ details.push(`${colors_default.cyan("\u25CB")} ${title}${alignment ? colors_default.dim(` \xB7 ${alignmentTags(alignment)}`) : ""}`);
2549
+ if (!process7.stdout.isTTY) details.push(colors_default.dim(` id ${id}`));
2550
+ }
2551
+ if (ids.length > STEP_ROWS_MAX) details.push(colors_default.dim(`+ ${ids.length - STEP_ROWS_MAX} more`));
2552
+ details.push(`${arriving.length > 0 ? colors_default.dim("after the sync: ") : ""}pnpm cella align`);
2553
+ return { label: "align", line, details };
2554
+ }
2555
+ async function readForkView(config, ref) {
2556
+ const { pinned = [], ignored = [] } = config.overrides ?? {};
2557
+ const counts = (path) => !isManagedFile(path) && !isGeneratedFile(path) && !isUpstreamOnly(path);
2558
+ const out = await git(["diff", "--name-only", "--no-renames", "-z", ref, "--"], config.forkPath);
2559
+ return {
2560
+ githubUrl: getGitHubBaseUrl(config.settings.upstreamUrl) ?? void 0,
2561
+ owns: (path) => counts(path) && isUnderAnyFolder(path, [...pinned, ...ignored]),
2562
+ changed: new Set(out.split("\0").filter((path) => path && counts(path)))
2563
+ };
2564
+ }
2565
+ async function describeAlignment(config, view, ref, alignment, open) {
2566
+ const { forkPath } = config;
2567
+ const [source, grepHits] = await Promise.all([
2568
+ readAlignmentSource(forkPath, ref, alignment.id),
2569
+ grepAlignmentTerms(forkPath, alignment.grep, (path) => view.changed.has(path))
2570
+ ]);
2571
+ const pr = source?.pr ?? null;
2572
+ return {
2573
+ ...alignment,
2574
+ kind: alignment.codemod ? "codemod" : "manual",
2575
+ open,
2576
+ url: alignmentUrl(view.githubUrl, ref, alignment.id) ?? null,
2577
+ commit: source?.commit ?? null,
2578
+ pr,
2579
+ prUrl: pr !== null && view.githubUrl ? `${view.githubUrl}/pull/${pr}` : null,
2580
+ sharedCommit: source?.sharedCommit ?? [],
2581
+ yourFiles: (source?.changedFiles ?? []).filter((path) => view.owns(path) || view.changed.has(path)),
2582
+ grepHits
2583
+ };
2584
+ }
2585
+ function grepSummary(alignment) {
2586
+ if (alignment.grep.length === 0) return null;
2587
+ const hits = alignment.grepHits.length;
2588
+ if (hits === 0) return "grep: no app hits";
2589
+ const files = new Set(alignment.grepHits.map((hit) => hit.file)).size;
2590
+ return `grep: ${hits} hit${hits === 1 ? "" : "s"} in ${files} app file${files === 1 ? "" : "s"}`;
2436
2591
  }
2437
- function printNote(index, note, url) {
2592
+ function printAlignment(index, alignment) {
2438
2593
  const tags = [
2439
- note.codemod ? "codemod" : "manual",
2440
- note.syncBreaking ? "sync-breaking" : null,
2441
- note.clientCacheBump ? "cache-bump" : null
2594
+ alignment.codemod ? "codemod" : "manual",
2595
+ alignment.syncBreaking ? "sync-breaking" : null,
2596
+ alignment.clientCacheBump ? "cache-bump" : null,
2597
+ alignment.caughtBy ? `caught by: ${alignment.caughtBy}` : null,
2598
+ alignment.generate ? "generate" : null
2442
2599
  ].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}`));
2600
+ const grep = grepSummary(alignment);
2601
+ console.info(`${index}. ${alignment.title} ${colors_default.dim(`[${tags}]`)}`);
2602
+ console.info(colors_default.dim(` id: ${alignment.id}`));
2603
+ if (alignment.summary) console.info(` ${alignment.summary}`);
2604
+ if (alignment.pr !== null) {
2605
+ console.info(colors_default.dim(` added in #${alignment.pr}${alignment.prUrl ? ` ${alignment.prUrl}` : ""}`));
2606
+ }
2607
+ if (grep) console.info(colors_default.dim(` ${grep}`));
2608
+ console.info(colors_default.dim(` read: pnpm cella align --show ${alignment.id}`));
2609
+ if (alignment.url) console.info(colors_default.dim(` ${alignment.url}`));
2610
+ if (alignment.codemod) console.info(colors_default.dim(` codemod: pnpm cella align --run ${alignment.id}`));
2449
2611
  console.info();
2450
2612
  }
2451
- async function writeNoteFolder(config, ref, id) {
2613
+ function printShowBlock(title, items) {
2614
+ if (items.length === 0) return;
2615
+ console.info(colors_default.dim(`${title} (${items.length}):`));
2616
+ for (const item of items.slice(0, SHOW_LIST_MAX)) console.info(colors_default.dim(` ${item}`));
2617
+ if (items.length > SHOW_LIST_MAX) console.info(colors_default.dim(` ... + ${items.length - SHOW_LIST_MAX} more`));
2618
+ }
2619
+ async function showAlignment(config, ref, pending, id) {
2620
+ const { forkPath } = config;
2621
+ const upstreamBranch = `${DEFAULT_UPSTREAM_REMOTE}/${config.settings.upstreamBranch}`;
2622
+ let readRef = ref;
2623
+ let alignment = await readAlignment(forkPath, ref, id);
2624
+ if (!alignment && await revParse(forkPath, upstreamBranch)) {
2625
+ alignment = await readAlignment(forkPath, upstreamBranch, id);
2626
+ readRef = upstreamBranch;
2627
+ }
2628
+ if (!alignment) throw new Error(`no alignment '${id}' at upstream ${ref.slice(0, 7)} or ${upstreamBranch}`);
2629
+ const source = await git(["show", `${readRef}:${ALIGNMENTS_DIR}/${id}/README.md`], forkPath);
2630
+ const described = await describeAlignment(
2631
+ config,
2632
+ await readForkView(config, ref),
2633
+ readRef,
2634
+ alignment,
2635
+ pending.includes(id)
2636
+ );
2637
+ if (readRef !== ref) console.info(colors_default.dim(`not at the sync point ${ref.slice(0, 7)}: read at ${upstreamBranch}`));
2638
+ if (described.url) console.info(colors_default.dim(described.url));
2639
+ console.info(source);
2640
+ console.info();
2641
+ if (described.commit) {
2642
+ const pr = described.pr !== null ? `#${described.pr} ` : "";
2643
+ console.info(
2644
+ colors_default.dim(`added in ${pr}(${described.commit.slice(0, 9)})${described.prUrl ? ` ${described.prUrl}` : ""}`)
2645
+ );
2646
+ }
2647
+ printShowBlock("added in the same commit", described.sharedCommit);
2648
+ printShowBlock("files of that commit the app pins, ignores or changed", described.yourFiles);
2649
+ const grep = grepSummary(described);
2650
+ if (grep) console.info(colors_default.dim(`${grep} (${described.grep.join(", ")})`));
2651
+ printShowBlock(
2652
+ "grep hits",
2653
+ described.grepHits.map((hit) => `${hit.file}:${hit.line}`)
2654
+ );
2655
+ }
2656
+ async function writeAlignmentFolder(config, ref, id) {
2452
2657
  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);
2658
+ const alignment = await readAlignment(forkPath, ref, id);
2659
+ if (!alignment) throw new Error(`no alignment '${id}' at upstream ${ref.slice(0, 7)}`);
2660
+ const files = (await git(["ls-tree", "-r", "--name-only", `${ref}:${ALIGNMENTS_DIR}/${id}`], forkPath)).split("\n").filter(Boolean);
2456
2661
  const target = join8(EXTRACT_DIR, id);
2457
2662
  for (const file of files) {
2458
- const content = execFileSync("git", ["show", `${ref}:${NOTES_DIR}/${id}/${file}`], {
2663
+ const content = execFileSync("git", ["show", `${ref}:${ALIGNMENTS_DIR}/${id}/${file}`], {
2459
2664
  cwd: forkPath,
2460
2665
  maxBuffer: MAX_BUFFER
2461
2666
  });
@@ -2463,27 +2668,27 @@ async function writeNoteFolder(config, ref, id) {
2463
2668
  await mkdir4(dirname4(path), { recursive: true });
2464
2669
  await writeFile3(path, content);
2465
2670
  }
2466
- return { note, target, files };
2671
+ return { alignment, target, files };
2467
2672
  }
2468
- async function extractNote(config, ref, id) {
2469
- const { note, target, files } = await writeNoteFolder(config, ref, id);
2673
+ async function extractAlignment(config, ref, id) {
2674
+ const { alignment, target, files } = await writeAlignmentFolder(config, ref, id);
2470
2675
  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}`));
2676
+ console.info(colors_default.dim(` the README's ${ALIGNMENTS_DIR}/${id}/ paths are this folder.`));
2677
+ if (alignment.codemod) {
2678
+ console.info(colors_default.dim(` pnpm exec tsx ${target}/${alignment.codemod} inventory ${alignment.roots.join(" ")}`));
2679
+ console.info(colors_default.dim(` or, keeping files identical to upstream as they are: pnpm cella align --run ${id}`));
2475
2680
  }
2476
2681
  }
2477
- async function runNote(config, ref, id) {
2682
+ async function runAlignment(config, ref, id) {
2478
2683
  const { forkPath } = config;
2479
- const { note, target } = await writeNoteFolder(config, ref, id);
2480
- if (!note.codemod) {
2684
+ const { alignment, target } = await writeAlignmentFolder(config, ref, id);
2685
+ if (!alignment.codemod) {
2481
2686
  throw new Error(
2482
- `migration note '${id}' ships no codemod script: its steps are manual, read them with \`pnpm cella migrate --show ${id}\`.`
2687
+ `alignment '${id}' ships no codemod script: its steps are manual, read them with \`pnpm cella align --show ${id}\`.`
2483
2688
  );
2484
2689
  }
2485
- const script = join8(target, note.codemod);
2486
- const args = config.runArgs?.length ? config.runArgs : ["inventory", ...note.roots];
2690
+ const script = join8(target, alignment.codemod);
2691
+ const args = config.runArgs?.length ? config.runArgs : ["inventory", ...alignment.roots];
2487
2692
  const upstreamCommit = await readManifestBase(forkPath) ?? ref;
2488
2693
  await ensureUpstreamCommit(config, upstreamCommit);
2489
2694
  const identical = await listIdenticalToRef(forkPath, upstreamCommit);
@@ -2508,92 +2713,335 @@ async function runNote(config, ref, id) {
2508
2713
  }
2509
2714
  if (child.status !== 0) throw new Error(`the codemod exited with code ${child.status ?? "unknown"}`);
2510
2715
  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}`));
2716
+ const apply = ["rewrite", ...alignment.roots].join(" ");
2717
+ console.info(colors_default.dim(`report only. to apply: pnpm cella align --run ${id} -- ${apply}`));
2513
2718
  }
2514
2719
  }
2515
- async function markNotes(config, ref, pending, ids) {
2720
+ async function markAlignments(config, ref, pending, ids) {
2516
2721
  const open = new Set(pending);
2517
2722
  const unknown = ids.filter((id) => !open.has(id));
2518
2723
  for (const id of ids) open.delete(id);
2519
2724
  await writePending(config.forkPath, [...open]);
2520
2725
  const marked = ids.length - unknown.length;
2521
- if (marked > 0) console.info(`${checkMark} recorded ${marked} note(s) as handled`);
2726
+ if (marked > 0) console.info(`${checkMark} recorded ${marked} alignment(s) as handled`);
2522
2727
  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)));
2728
+ console.info(colors_default.dim(formatAlignmentsLine((await listAlignmentIds(config.forkPath, ref)).length, open.size)));
2524
2729
  }
2525
- async function runMigrate(config) {
2730
+ async function runAlign(config) {
2526
2731
  const { forkPath } = config;
2527
- const ref = await resolveNotesRef(config);
2732
+ const ref = await resolveAlignmentsRef(config);
2528
2733
  if (!ref) {
2529
- console.info(colors_default.dim("no sync point recorded yet: migration notes start with the first `pnpm cella sync`."));
2734
+ console.info(colors_default.dim("no sync point recorded yet: alignments start with the first `pnpm cella sync`."));
2530
2735
  return;
2531
2736
  }
2532
2737
  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 ?? {
2738
+ if (config.mark?.length) return markAlignments(config, ref, pending, config.mark);
2739
+ if (config.run) return runAlignment(config, ref, config.run);
2740
+ if (config.extract) return extractAlignment(config, ref, config.extract);
2741
+ if (config.show) return showAlignment(config, ref, pending, config.show);
2742
+ const ids = config.all ? await listAlignmentIds(forkPath, ref) : pending;
2743
+ const read = await Promise.all(ids.map((id) => readAlignment(forkPath, ref, id)));
2744
+ const alignments = read.map(
2745
+ (alignment, i) => alignment ?? {
2551
2746
  id: ids[i],
2552
2747
  title: `${ids[i]} (no longer upstream)`,
2553
2748
  summary: "",
2554
2749
  syncBreaking: false,
2555
2750
  clientCacheBump: false,
2751
+ caughtBy: null,
2752
+ generate: false,
2753
+ grep: [],
2556
2754
  roots: [],
2557
2755
  codemod: null
2558
2756
  }
2559
2757
  );
2560
- const total = (await listNoteIds(forkPath, ref)).length;
2758
+ const total = (await listAlignmentIds(forkPath, ref)).length;
2759
+ const open = new Set(pending);
2760
+ const view = await readForkView(config, ref);
2761
+ const described = await Promise.all(
2762
+ alignments.map((alignment) => describeAlignment(config, view, ref, alignment, open.has(alignment.id)))
2763
+ );
2561
2764
  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));
2765
+ writeStdout(JSON.stringify({ upstreamCommit: ref, total, open: pending.length, alignments: described }, null, 2));
2575
2766
  return;
2576
2767
  }
2577
- console.info(colors_default.dim(formatNotesLine(total, pending.length)));
2578
- if (notes.length === 0) return;
2768
+ console.info(colors_default.dim(formatAlignmentsLine(total, pending.length)));
2769
+ if (described.length === 0) return;
2579
2770
  console.info();
2580
- for (const [i, note] of notes.entries()) printNote(i + 1, note, urlOf(note.id));
2771
+ for (const [i, alignment] of described.entries()) printAlignment(i + 1, alignment);
2772
+ const schema = described.filter((alignment) => alignment.open && alignment.generate);
2773
+ if (schema.length > 1) {
2774
+ console.info(
2775
+ colors_default.dim(
2776
+ `${schema.length} open alignments change the schema: work their code steps first, then run pnpm generate once.`
2777
+ )
2778
+ );
2779
+ }
2581
2780
  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(" ")}`));
2781
+ console.info(colors_default.dim("work each alignment's README, check with `pnpm check`, then record it:"));
2782
+ console.info(colors_default.dim(` pnpm cella align --mark ${pending.join(" ")}`));
2584
2783
  }
2585
2784
  }
2586
2785
 
2587
- // src/services/review-page.ts
2786
+ // src/services/analyze-report.ts
2588
2787
  import { basename as basename2 } from "path";
2589
- import process7 from "process";
2788
+ var BEHIND_FLAT_MAX = 15;
2789
+ var AHEAD_FLAT_MAX = 8;
2790
+ var ABSENT_SHOWN_MIN = 10;
2791
+ var ENTRY_COLLAPSE_MIN = 5;
2792
+ var SECTION_ROWS_MAX = 100;
2793
+ var UPSTREAM_CHANGE = { fields: "changed", repo: "upstream" };
2794
+ var UPSTREAM_SIDE = { fields: "upstream", repo: "upstream" };
2795
+ var FORK_SIDE = { fields: "changed", repo: "fork" };
2796
+ function cell(value, width, color, align) {
2797
+ if (!value) return " ".repeat(width);
2798
+ return padVisible(color ? color(value) : value, width, align === "end" ? "end" : "start");
2799
+ }
2800
+ function formatRows(rows, caption) {
2801
+ const iconWidth = rows.some((row) => row.icon) ? 2 : 0;
2802
+ const labelWidth = Math.max(...rows.map((row) => row.label.length + (row.note ? row.note.length + 1 : 0)));
2803
+ const countWidth = Math.max(0, ...rows.map((row) => row.count?.length ?? 0));
2804
+ const tagWidth = Math.max(0, ...rows.map((row) => row.tag?.length ?? 0));
2805
+ const ageWidth = Math.max(0, ...rows.map((row) => row.age?.length ?? 0));
2806
+ const lines = [];
2807
+ if (caption && countWidth > 0) lines.push(colors_default.dim(caption.padStart(2 + iconWidth + labelWidth + 2 + countWidth)));
2808
+ for (const row of rows) {
2809
+ const icon = iconWidth ? cell(row.icon, iconWidth) : "";
2810
+ const label = `${hyperlink(row.label, row.link)}${row.note ? ` ${colors_default.dim(row.note)}` : ""}`;
2811
+ const cells = [` ${icon}${padVisible(label, labelWidth)}`];
2812
+ if (countWidth) cells.push(cell(row.count, countWidth, row.countColor, "end"));
2813
+ if (tagWidth) cells.push(cell(row.tag, tagWidth, row.tagColor));
2814
+ if (ageWidth) cells.push(cell(row.age, ageWidth, colors_default.dim, "end"));
2815
+ lines.push(cells.join(" ").trimEnd());
2816
+ }
2817
+ return lines;
2818
+ }
2819
+ function formatCappedRows(rows, caption) {
2820
+ if (rows.length <= SECTION_ROWS_MAX) return formatRows(rows, caption);
2821
+ return [
2822
+ ...formatRows(rows.slice(0, SECTION_ROWS_MAX), caption),
2823
+ colors_default.dim(` + ${rows.length - SECTION_ROWS_MAX} more`)
2824
+ ];
2825
+ }
2826
+ function hint(text, command = "") {
2827
+ return ` ${colors_default.dim(text)}${command}`;
2828
+ }
2829
+ function printSection(title, count3, body, hints = []) {
2830
+ printSectionHeader(`${title} ${colors_default.dim(`\xB7 ${plural(count3, "file")}`)}`);
2831
+ for (const line of body) console.info(line);
2832
+ if (hints.length === 0) return;
2833
+ console.info();
2834
+ for (const line of hints) console.info(line);
2835
+ }
2836
+ function byPath(a, b) {
2837
+ return a.path.localeCompare(b.path);
2838
+ }
2839
+ function fileLink(file, side, links) {
2840
+ const { fileLinkMode = "commit", upstreamGitHubUrl, upstreamSha, forkGitHubUrl } = links;
2841
+ if (fileLinkMode === "file") {
2842
+ if (!upstreamGitHubUrl || !upstreamSha || !file.existsInUpstream) return void 0;
2843
+ return `${upstreamGitHubUrl}/blob/${upstreamSha}/${file.path}`;
2844
+ }
2845
+ const commit = side.fields === "upstream" ? file.upstreamCommit : file.changedCommit;
2846
+ const repo = side.repo === "upstream" ? upstreamGitHubUrl : forkGitHubUrl;
2847
+ return commit && repo ? `${repo}/commit/${commit}` : void 0;
2848
+ }
2849
+ function changeTimestamp(file, side) {
2850
+ return side.fields === "upstream" ? file.upstreamChangedTs : file.changedTs;
2851
+ }
2852
+ function changeKind(file) {
2853
+ if (!file.existsInFork) return "new";
2854
+ if (!file.existsInUpstream) return "deleted";
2855
+ return "changed";
2856
+ }
2857
+ var KIND_TAG = { new: { tag: "new", color: colors_default.green }, deleted: { tag: "deleted", color: colors_default.red } };
2858
+ function fileRow(file, side, options, extra = {}) {
2859
+ const timestamp = changeTimestamp(file, side);
2860
+ const kind = changeKind(file);
2861
+ return {
2862
+ label: file.path,
2863
+ link: fileLink(file, side, options.links),
2864
+ age: timestamp ? shortAge(timestamp, options.nowMs) : void 0,
2865
+ ...kind === "changed" ? {} : { tag: KIND_TAG[kind].tag, tagColor: KIND_TAG[kind].color },
2866
+ ...extra
2867
+ };
2868
+ }
2869
+ function commonFolder(paths) {
2870
+ const folders = paths.map((path) => path.split("/").slice(0, -1));
2871
+ const [first] = folders;
2872
+ let depth = 0;
2873
+ while (depth < first.length && folders.every((parts) => parts[depth] === first[depth])) depth++;
2874
+ return first.slice(0, depth).join("/");
2875
+ }
2876
+ function formatFolderRows(files, columns) {
2877
+ const groups = /* @__PURE__ */ new Map();
2878
+ for (const file of files) {
2879
+ const top = file.path.includes("/") ? file.path.split("/")[0] : file.path;
2880
+ groups.set(top, [...groups.get(top) ?? [], file]);
2881
+ }
2882
+ const rows = [...groups.values()].map((group) => ({
2883
+ label: group.length === 1 ? group[0].path : `${commonFolder(group.map((file) => file.path))}/`,
2884
+ files: group
2885
+ })).sort((a, b) => b.files.length - a.files.length || a.label.localeCompare(b.label));
2886
+ const labelWidth = Math.max(...rows.map((row) => row.label.length));
2887
+ const countWidth = Math.max(...rows.map((row) => String(row.files.length).length));
2888
+ const order = { new: 0, deleted: 1, changed: 2 };
2889
+ return rows.map((row) => {
2890
+ let line = ` ${row.label.padEnd(labelWidth)} ${colors_default.cyan(String(row.files.length).padStart(countWidth))}`;
2891
+ if (row.files.length === 1) return line.trimEnd();
2892
+ const seen = /* @__PURE__ */ new Map();
2893
+ for (const file of row.files) seen.set(basename2(file.path), (seen.get(basename2(file.path)) ?? 0) + 1);
2894
+ const names = row.files.map((file) => ({
2895
+ kind: changeKind(file),
2896
+ name: (seen.get(basename2(file.path)) ?? 0) > 1 ? file.path.slice(row.label.length) : basename2(file.path)
2897
+ })).sort((a, b) => order[a.kind] - order[b.kind] || a.name.localeCompare(b.name));
2898
+ let width = visibleWidth(line);
2899
+ for (const { kind, name } of names) {
2900
+ const text = kind === "new" ? `+${name}` : kind === "deleted" ? `\u2212${name}` : name;
2901
+ if (width + 2 + text.length > columns - 2) {
2902
+ line += colors_default.dim(" \u2026");
2903
+ break;
2904
+ }
2905
+ line += ` ${kind === "new" ? colors_default.green(text) : kind === "deleted" ? colors_default.red(text) : colors_default.dim(text)}`;
2906
+ width += 2 + text.length;
2907
+ }
2908
+ return line;
2909
+ });
2910
+ }
2911
+ function printBehind(files, options, columns) {
2912
+ const behind = filesWithStatus(files, "behind").sort(byPath);
2913
+ if (behind.length === 0) return;
2914
+ const title = colors_default.cyan("\u2193 behind on upstream");
2915
+ if (options.all || behind.length <= BEHIND_FLAT_MAX) {
2916
+ printSection(title, behind.length, formatCappedRows(behind.map((file) => fileRow(file, UPSTREAM_CHANGE, options))));
2917
+ return;
2918
+ }
2919
+ printSection(title, behind.length, formatFolderRows(behind, columns), [
2920
+ hint("every path: ", "pnpm cella analyze --all")
2921
+ ]);
2922
+ }
2923
+ function printFlat(files, title, side, options) {
2924
+ if (files.length === 0) return;
2925
+ const rows = [...files].sort(byPath).map((file) => fileRow(file, side, options));
2926
+ printSection(title, files.length, formatCappedRows(rows));
2927
+ }
2928
+ function absentLines(file) {
2929
+ return file.upstreamLinesAbsent ?? 0;
2930
+ }
2931
+ function describeAbsent(files) {
2932
+ const counts = files.map(absentLines);
2933
+ const missing = counts.filter((count3) => count3 > 0);
2934
+ const parts = [];
2935
+ if (missing.length > 0) {
2936
+ const [low, high] = [Math.min(...missing), Math.max(...missing)];
2937
+ parts.push(`${missing.length} with ${low === high ? plural(low, "line") : `${low}\u2013${high} lines`} absent`);
2938
+ }
2939
+ if (missing.length < counts.length) parts.push(`${counts.length - missing.length} with none`);
2940
+ return parts.join(", ");
2941
+ }
2942
+ function printAhead(files, options) {
2943
+ const ahead = filesWithStatus(files, "ahead").sort((a, b) => absentLines(b) - absentLines(a) || byPath(a, b));
2944
+ if (ahead.length === 0) return;
2945
+ let shown = ahead;
2946
+ if (!options.all && ahead.length > AHEAD_FLAT_MAX) {
2947
+ const many = ahead.filter((file) => absentLines(file) >= ABSENT_SHOWN_MIN);
2948
+ shown = many.length > 0 ? many : ahead.slice(0, 5);
2949
+ }
2950
+ const rest = ahead.slice(shown.length);
2951
+ const rows = shown.map(
2952
+ (file) => fileRow(file, FORK_SIDE, options, {
2953
+ count: absentLines(file) > 0 ? String(absentLines(file)) : void 0,
2954
+ countColor: colors_default.yellow
2955
+ })
2956
+ );
2957
+ const body = formatCappedRows(rows, "upstream lines absent");
2958
+ if (rest.length > 0) body.push(colors_default.dim(` + ${rest.length} more: ${describeAbsent(rest)}`));
2959
+ const hints = ahead.some((file) => absentLines(file) > 0) ? [hint("what a merge would bring in: ", "pnpm cella analyze --unpinned")] : [];
2960
+ printSection(colors_default.blue("\u2191 protected in fork"), ahead.length, body, hints);
2961
+ }
2962
+ function entryFinder(overrides) {
2963
+ const entries = [...overrides?.ignored ?? [], ...overrides?.pinned ?? []].map(normalizeEntry).filter(Boolean).sort((a, b) => b.length - a.length);
2964
+ return (path) => entries.find((entry) => isUnderAnyFolder(path, [entry]));
2965
+ }
2966
+ var KIND_ORDER = { new: 0, changed: 1, deleted: 2 };
2967
+ function protectedRow(items, options) {
2968
+ const [{ file, kind, entry }] = items;
2969
+ const lines = items.map((item) => item.file.upstreamChangedLines);
2970
+ const total = lines.every((count3) => count3 !== void 0) ? lines.reduce((sum, count3) => sum + (count3 ?? 0), 0) : void 0;
2971
+ const sign = kind === "new" ? "+" : kind === "deleted" ? "\u2212" : "";
2972
+ const newest = Math.max(0, ...items.map((item) => changeTimestamp(item.file, UPSTREAM_SIDE) ?? 0));
2973
+ const row = {
2974
+ label: file.path,
2975
+ link: fileLink(file, UPSTREAM_SIDE, options.links),
2976
+ icon: protectionIcon(file),
2977
+ count: total === void 0 ? void 0 : `${sign}${compactCount(total)}`,
2978
+ countColor: kind === "new" ? colors_default.green : kind === "deleted" ? colors_default.red : colors_default.yellow,
2979
+ ...kind === "changed" ? {} : { tag: KIND_TAG[kind].tag, tagColor: KIND_TAG[kind].color },
2980
+ age: newest ? shortAge(newest, options.nowMs) : void 0
2981
+ };
2982
+ if (items.length === 1 || !entry) return row;
2983
+ const names = new Set(items.map((item) => basename2(item.file.path)));
2984
+ const { upstreamGitHubUrl, upstreamSha } = options.links;
2985
+ return {
2986
+ ...row,
2987
+ label: `${entry}/`,
2988
+ note: names.size === 1 ? `\xB7 ${items.length} \xD7 ${[...names][0]}` : `\xB7 ${items.length} files`,
2989
+ link: upstreamGitHubUrl && upstreamSha ? `${upstreamGitHubUrl}/tree/${upstreamSha}/${entry}` : void 0
2990
+ };
2991
+ }
2992
+ function printProtectedBehind(result, options) {
2993
+ const findEntry = entryFinder(options.overrides);
2994
+ const items = findProtectedBehindUpstream(result).map((file) => ({
2995
+ file,
2996
+ kind: file.upstreamOnly ? changeKind(file) : "changed",
2997
+ entry: findEntry(file.path)
2998
+ }));
2999
+ if (items.length === 0) return;
3000
+ const groups = /* @__PURE__ */ new Map();
3001
+ for (const item of items) {
3002
+ const key = options.all || !item.entry ? item.file.path : `${item.kind}\0${item.entry}`;
3003
+ groups.set(key, [...groups.get(key) ?? [], item]);
3004
+ }
3005
+ const single = (group) => group.length < ENTRY_COLLAPSE_MIN;
3006
+ const lines = (group) => group.reduce((sum, item) => sum + (item.file.upstreamChangedLines ?? 0), 0);
3007
+ const ordered = [...groups.values()].flatMap((group) => single(group) ? group.map((item) => [item]) : [group]).sort(
3008
+ (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)
3009
+ );
3010
+ printSection(
3011
+ `${warningMark} ${colors_default.yellow("protected but behind upstream")}`,
3012
+ items.length,
3013
+ formatCappedRows(
3014
+ ordered.map((group) => protectedRow(group, options)),
3015
+ "lines changed upstream"
3016
+ ),
3017
+ [hint("the fork side wins: adopt what you need from the review page")]
3018
+ );
3019
+ }
3020
+ function printAnalysisFileGroups(result, options) {
3021
+ const columns = options.columns ?? terminalColumns();
3022
+ printBehind(result.files, options, columns);
3023
+ printFlat(filesWithStatus(result.files, "diverged"), colors_default.magenta("\u21C5 diverged"), UPSTREAM_SIDE, options);
3024
+ printAhead(result.files, options);
3025
+ printFlat(
3026
+ filesWithStatus(result.files, "drifted"),
3027
+ `${warningMark} ${colors_default.yellow("drifted from upstream")}`,
3028
+ FORK_SIDE,
3029
+ options
3030
+ );
3031
+ printProtectedBehind(result, options);
3032
+ }
3033
+
3034
+ // src/services/review-page.ts
3035
+ import { existsSync as existsSync7, rmSync } from "fs";
3036
+ import { basename as basename3 } from "path";
3037
+ import process9 from "process";
2590
3038
  import { pathToFileURL } from "url";
2591
3039
 
2592
3040
  // src/utils/diff.ts
2593
3041
  import { execFileSync as execFileSync2 } from "child_process";
2594
3042
  import { mkdirSync, writeFileSync } from "fs";
2595
- import { join as join9 } from "path";
2596
- import process6 from "process";
3043
+ import { dirname as dirname5, join as join9 } from "path";
3044
+ import process8 from "process";
2597
3045
  function gitDiffFile(cwd, range, filePath) {
2598
3046
  try {
2599
3047
  return execFileSync2("git", ["diff", "--no-color", range, "--", filePath], { cwd, maxBuffer: MAX_BUFFER });
@@ -2628,9 +3076,9 @@ ${prerenderedHTML}
2628
3076
  for (const file of section.files) files.push(await renderFile(file));
2629
3077
  const count3 = section.files.length + (section.omitted?.length ?? 0);
2630
3078
  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>` : "";
3079
+ const hint2 = section.hint ? `<p class="hint">${escapeHtml(section.hint)}</p>` : "";
2632
3080
  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>`);
3081
+ blocks.push(`<section id="section-${index2 + 1}">${title}${hint2}${files.join("\n")}${omitted}</section>`);
2634
3082
  }
2635
3083
  const titled = sections.filter((section) => section.title);
2636
3084
  const index = titled.length > 1 ? `<nav><ol>${sections.map(
@@ -2694,16 +3142,18 @@ ${blocks.join("\n")}
2694
3142
  }
2695
3143
  async function writeDiffPage(sections, meta, repoPath, name) {
2696
3144
  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`);
3145
+ const outPath = diffPagePath(repoPath, name);
3146
+ mkdirSync(dirname5(outPath), { recursive: true });
2701
3147
  writeFileSync(outPath, html);
2702
3148
  return outPath;
2703
3149
  }
3150
+ function diffPagePath(repoPath, name) {
3151
+ const gitDir = execFileSync2("git", ["rev-parse", "--absolute-git-dir"], { cwd: repoPath, encoding: "utf8" }).trim();
3152
+ return join9(gitDir, "cella-diff", `${name.replace(/[^\w.-]+/g, "-")}.html`);
3153
+ }
2704
3154
  function openInBrowser(target) {
2705
3155
  try {
2706
- execFileSync2(process6.platform === "darwin" ? "open" : "xdg-open", [target], { stdio: "ignore" });
3156
+ execFileSync2(process8.platform === "darwin" ? "open" : "xdg-open", [target], { stdio: "ignore" });
2707
3157
  return true;
2708
3158
  } catch {
2709
3159
  return false;
@@ -2711,38 +3161,39 @@ function openInBrowser(target) {
2711
3161
  }
2712
3162
 
2713
3163
  // src/services/review-page.ts
2714
- var MAX_PAGE_FILES = 50;
3164
+ var PAGE_LIMITS = { files: 50, lines: 15e3, patchLines: 2e3 };
2715
3165
  var MAX_PATCH_BYTES = 512 * 1024;
2716
3166
  function reviewSections(result, command) {
2717
3167
  const [base, upstream] = result.upstreamDiffRange?.split("..") ?? [];
2718
3168
  if (!base || !upstream) return [];
2719
3169
  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,
3170
+ const sections = command === "sync" ? [
3171
+ {
3172
+ title: "protected files kept the fork version",
3173
+ 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.",
3174
+ range: sinceSync,
3175
+ paths: result.protectedConflicts ?? []
3176
+ },
2733
3177
  {
2734
3178
  title: "ignored paths changed upstream",
2735
3179
  hint: "What upstream changed under ignored paths since the last sync. The fork left these files untouched, and ignored paths never sync.",
2736
3180
  range: sinceSync,
2737
3181
  paths: (result.ignoredUpstreamChanges ?? []).flatMap((group) => group.paths)
2738
- },
3182
+ }
3183
+ ] : [
2739
3184
  {
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.",
3185
+ title: "protected but behind upstream",
3186
+ 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.",
2742
3187
  range: sinceSync,
2743
- paths: findMaskingPins(result.files).map((file) => file.path)
3188
+ paths: findProtectedBehindUpstream(result).map((file) => file.path)
2744
3189
  }
2745
3190
  ];
3191
+ sections.push({
3192
+ title: "pinned files that match the previous upstream",
3193
+ 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.",
3194
+ range: sinceSync,
3195
+ paths: findMaskingPins(result.files).map((file) => file.path)
3196
+ });
2746
3197
  if (command === "analyze") {
2747
3198
  sections.push(
2748
3199
  {
@@ -2761,55 +3212,100 @@ function reviewSections(result, command) {
2761
3212
  }
2762
3213
  return sections.filter((section) => section.paths.length > 0);
2763
3214
  }
2764
- async function writeReviewPage(forkPath, sections, meta) {
3215
+ function countLines(patch) {
3216
+ let count3 = 0;
3217
+ for (let at = patch.indexOf(10); at !== -1; at = patch.indexOf(10, at + 1)) count3++;
3218
+ return count3;
3219
+ }
3220
+ async function writeReviewPage(forkPath, sections, meta, limits = PAGE_LIMITS) {
2765
3221
  const [base, upstream] = meta.range.split("..");
3222
+ const pageSections = sections.map((section) => ({
3223
+ title: section.title,
3224
+ hint: section.hint,
3225
+ files: [],
3226
+ omitted: []
3227
+ }));
3228
+ const queues = sections.map((section) => [...section.paths]);
2766
3229
  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);
3230
+ let lines = 0;
3231
+ while (queues.some((queue) => queue.length > 0)) {
3232
+ for (const [index, queue] of queues.entries()) {
3233
+ const path = queue.shift();
3234
+ if (path === void 0) continue;
3235
+ const pageSection = pageSections[index];
3236
+ if (diffs >= limits.files || lines >= limits.lines) {
3237
+ pageSection.omitted.push(path);
2773
3238
  continue;
2774
3239
  }
2775
- const patch = gitDiffFile(forkPath, section.range, path);
3240
+ const patch = gitDiffFile(forkPath, sections[index].range, path);
2776
3241
  if (patch.length === 0) continue;
2777
- if (patch.length > MAX_PATCH_BYTES || !patch.includes("\n@@ ")) {
2778
- pageSection.omitted?.push(path);
3242
+ const patchLines = patch.length > MAX_PATCH_BYTES ? Number.POSITIVE_INFINITY : countLines(patch);
3243
+ if (patchLines > limits.patchLines || lines + patchLines > limits.lines || !patch.includes("\n@@ ")) {
3244
+ pageSection.omitted.push(path);
2779
3245
  continue;
2780
3246
  }
2781
3247
  pageSection.files.push({ path, patch: patch.toString() });
2782
3248
  diffs++;
3249
+ lines += patchLines;
2783
3250
  }
2784
- if (pageSection.files.length > 0 || pageSection.omitted?.length) pageSections.push(pageSection);
2785
3251
  }
2786
3252
  if (diffs === 0) return void 0;
3253
+ const shown = pageSections.filter((section) => section.files.length > 0 || section.omitted.length > 0);
2787
3254
  const page = await writeDiffPage(
2788
- pageSections,
2789
- { title: `${meta.command} review`, note: `${basename2(forkPath)} \xB7 last sync ${base} \u2192 upstream ${upstream}` },
3255
+ shown,
3256
+ { title: `${meta.command} review`, note: `${basename3(forkPath)} \xB7 last sync ${base} \u2192 upstream ${upstream}` },
2790
3257
  forkPath,
2791
3258
  `${meta.command}-review`
2792
3259
  );
2793
- return { path: page, diffs };
3260
+ const listed = shown.reduce((count3, section) => count3 + section.omitted.length, 0);
3261
+ return { path: page, diffs, listed, sections: shown.length };
2794
3262
  }
2795
3263
  async function reportReviewPage(forkPath, result, command) {
2796
3264
  const sections = reviewSections(result, command);
2797
- if (sections.length === 0 || !result.upstreamDiffRange) return;
2798
- createSpinner("writing review page...");
2799
3265
  let page;
3266
+ if (sections.length > 0 && result.upstreamDiffRange) {
3267
+ createSpinner("writing review page...");
3268
+ try {
3269
+ page = await writeReviewPage(forkPath, sections, { command, range: result.upstreamDiffRange });
3270
+ } catch {
3271
+ page = void 0;
3272
+ }
3273
+ spinnerSuccess();
3274
+ }
3275
+ if (!page) {
3276
+ if (command === "sync") removeSyncReviewPage(forkPath);
3277
+ return void 0;
3278
+ }
3279
+ const opened = command === "sync" && process9.stdout.isTTY === true && openInBrowser(page.path);
3280
+ return { ...page, command, opened };
3281
+ }
3282
+ function removeSyncReviewPage(forkPath) {
2800
3283
  try {
2801
- page = await writeReviewPage(forkPath, sections, { command, range: result.upstreamDiffRange });
3284
+ rmSync(diffPagePath(forkPath, "sync-review"), { force: true });
2802
3285
  } catch {
2803
- page = void 0;
2804
3286
  }
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)}`));
3287
+ }
3288
+ function existingSyncReviewPage(forkPath) {
3289
+ try {
3290
+ const path = diffPagePath(forkPath, "sync-review");
3291
+ return existsSync7(path) ? path : void 0;
3292
+ } catch {
3293
+ return void 0;
3294
+ }
3295
+ }
3296
+ function reviewStep(page) {
3297
+ const path = typeof page === "string" ? page : page.path;
3298
+ const url = pathToFileURL(path).href;
3299
+ let detail = colors_default.dim("upstream changes the sync did not bring in, from the merge run");
3300
+ if (typeof page !== "string") {
3301
+ const what = page.command === "sync" ? `${plural(page.diffs, "diff")} of upstream changes that did not arrive` : `${plural(page.diffs, "diff")} in ${plural(page.sections, "section")}`;
3302
+ const notes = [
3303
+ page.listed > 0 ? `${page.listed} listed without a diff` : "",
3304
+ page.opened ? "opened in the browser" : ""
3305
+ ].filter(Boolean).map((note) => ` \xB7 ${note}`).join("");
3306
+ detail = `${what}${notes ? colors_default.dim(notes) : ""}`;
3307
+ }
3308
+ return { label: "review", line: hyperlink(colors_default.cyan(url), url), details: [detail] };
2813
3309
  }
2814
3310
 
2815
3311
  // src/services/analyze.ts
@@ -2856,23 +3352,56 @@ async function runAnalyze(config) {
2856
3352
  }
2857
3353
  return result;
2858
3354
  }
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();
3355
+ printAnalysisFileGroups(result, {
3356
+ links: {
3357
+ upstreamGitHubUrl: result.upstreamGitHubUrl,
3358
+ upstreamSha: result.upstreamCommit?.hash,
3359
+ forkGitHubUrl: await readOriginGitHubUrl(config.forkPath),
3360
+ fileLinkMode: config.settings.fileLinkMode,
3361
+ forkPath: config.forkPath
3362
+ },
3363
+ all: config.all,
3364
+ overrides: config.overrides
3365
+ });
3366
+ printUpstreamOverrideChanges(result);
3367
+ printMaskingPinWarning(result.files);
3368
+ printSummary(result.summary, "analysis summary");
3369
+ const steps = [];
3370
+ const review = await reportReviewPage(config.forkPath, result, "analyze");
3371
+ if (review) steps.push(reviewStep(review));
3372
+ const sync = syncStep(result);
3373
+ if (sync) steps.push(sync);
3374
+ const align = result.alignments && result.upstreamCommit ? await alignmentsStep(config, {
3375
+ ref: result.upstreamCommit.hash,
3376
+ open: result.alignments.open,
3377
+ arriving: result.alignments.arrived
3378
+ }) : void 0;
3379
+ if (align) steps.push(align);
3380
+ printNextSteps(steps);
2870
3381
  return result;
2871
3382
  }
3383
+ function syncStep(result) {
3384
+ if (!result.upstreamCommitCount) return void 0;
3385
+ const { behind, diverged, renamed } = result.summary;
3386
+ const changes = [
3387
+ behind > 0 ? `${behind} update` : "",
3388
+ diverged > 0 ? `${diverged} merge` : "",
3389
+ renamed > 0 ? `${renamed} rename` : ""
3390
+ ].filter(Boolean);
3391
+ return {
3392
+ label: "sync",
3393
+ line: "pnpm cella sync",
3394
+ details: [
3395
+ colors_default.dim(
3396
+ `commits on a fresh branch, a rerun opens the PR \xB7 ${changes.length > 0 ? changes.join(", ") : "no synced file changes"}`
3397
+ )
3398
+ ]
3399
+ };
3400
+ }
2872
3401
 
2873
3402
  // src/services/audit.ts
2874
3403
  import { readFileSync as readFileSync4 } from "fs";
2875
- import { basename as basename4, join as join13, relative as relative3 } from "path";
3404
+ import { basename as basename5, join as join13, relative as relative3 } from "path";
2876
3405
  import { checkbox, Separator } from "@inquirer/prompts";
2877
3406
 
2878
3407
  // src/utils/audit-utils.ts
@@ -2980,14 +3509,14 @@ function formatDependents(dependents) {
2980
3509
 
2981
3510
  // src/utils/local-tools.ts
2982
3511
  import { execFile as execFile3 } from "child_process";
2983
- import { existsSync as existsSync7, realpathSync as realpathSync2 } from "fs";
3512
+ import { existsSync as existsSync8, realpathSync as realpathSync2 } from "fs";
2984
3513
  import { tmpdir } from "os";
2985
- import { delimiter, dirname as dirname6, join as join11, sep as sep2 } from "path";
3514
+ import { delimiter, dirname as dirname7, join as join11, sep as sep2 } from "path";
2986
3515
  import { promisify as promisify3 } from "util";
2987
3516
 
2988
3517
  // src/utils/runtimes.ts
2989
3518
  import { readFileSync as readFileSync2 } from "fs";
2990
- import { basename as basename3, dirname as dirname5, join as join10 } from "path";
3519
+ import { basename as basename4, dirname as dirname6, join as join10 } from "path";
2991
3520
  var DAY = 24 * 60 * 60 * 1e3;
2992
3521
  var FROZEN_AFTER = 180 * DAY;
2993
3522
  var RECENT_PUSH = 30 * DAY;
@@ -2995,7 +3524,7 @@ var EXACT_VERSION = /^\d+\.\d+\.\d+$/;
2995
3524
  var FROM_LINE = /^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)(?:\s+AS\s+(\S+))?\s*$/i;
2996
3525
  var NODE_ARG_TAG = /^(?:docker\.io\/)?(?:library\/)?node:\$\{?\w+\}?([\w.-]*)$/;
2997
3526
  var MINOR_TAG = /^(\d+(?:\.\d+){1,2})(-[\w.-]+)?$/;
2998
- var isDockerfile = (path) => /(^|\.)Dockerfile$|^Dockerfile\./i.test(basename3(path));
3527
+ var isDockerfile = (path) => /(^|\.)Dockerfile$|^Dockerfile\./i.test(basename4(path));
2999
3528
  function parseDockerfile(file, content) {
3000
3529
  const images = [];
3001
3530
  const nodeImageSuffixes = [];
@@ -3233,7 +3762,7 @@ async function missingNodeImages(version, suffixes) {
3233
3762
  return missing.flat();
3234
3763
  }
3235
3764
  function pinLocation(file) {
3236
- const dir = dirname5(file);
3765
+ const dir = dirname6(file);
3237
3766
  return dir === "." ? file : dir;
3238
3767
  }
3239
3768
 
@@ -3357,7 +3886,7 @@ var execFileAsync3 = promisify3(execFile3);
3357
3886
  function findOnPath(name, skip = []) {
3358
3887
  for (const dir of (getEnv("PATH") ?? "").split(delimiter)) {
3359
3888
  if (!dir || skip.includes(dir) || dir.endsWith(`node_modules${sep2}.bin`)) continue;
3360
- if (existsSync7(join11(dir, name))) return join11(dir, name);
3889
+ if (existsSync8(join11(dir, name))) return join11(dir, name);
3361
3890
  }
3362
3891
  return null;
3363
3892
  }
@@ -3385,7 +3914,7 @@ async function readNode(now) {
3385
3914
  }
3386
3915
  async function readPnpm(cwd) {
3387
3916
  const running = getEnv("npm_execpath");
3388
- const file = findOnPath("pnpm", running ? [dirname6(running)] : []);
3917
+ const file = findOnPath("pnpm", running ? [dirname7(running)] : []);
3389
3918
  const version = file && await versionOf(file, tmpdir());
3390
3919
  if (!file || !version) return null;
3391
3920
  const [published, advisories] = await Promise.all([fetchPublishTimes("pnpm", cwd), fetchPnpmAdvisories(version)]);
@@ -3556,14 +4085,14 @@ function writeAuditSettings(content, next) {
3556
4085
 
3557
4086
  // src/services/audit-security.ts
3558
4087
  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";
4088
+ import { cpSync, existsSync as existsSync9, mkdirSync as mkdirSync2, mkdtempSync, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
3560
4089
  import { tmpdir as tmpdir2 } from "os";
3561
- import { dirname as dirname7, join as join12, relative as relative2 } from "path";
4090
+ import { dirname as dirname8, join as join12, relative as relative2 } from "path";
3562
4091
  import { confirm, select as select2 } from "@inquirer/prompts";
3563
4092
  var WORKSPACE_FILE = "pnpm-workspace.yaml";
3564
4093
  var LOCK_FILE = "pnpm-lock.yaml";
3565
4094
  var RESOLUTION_FILES = ["package.json", LOCK_FILE, ".npmrc", ".pnpmfile.cjs"];
3566
- var readOr = (path, fallback = "") => existsSync8(path) ? readFileSync3(path, "utf8") : fallback;
4095
+ var readOr = (path, fallback = "") => existsSync9(path) ? readFileSync3(path, "utf8") : fallback;
3567
4096
  var count2 = (n, noun, plural2 = `${noun}s`) => `${n} ${n === 1 ? noun : plural2}`;
3568
4097
  var isBreaking = (item) => item.remedy.kind !== "none" && item.remedy.breaking;
3569
4098
  var needsDecision = (item) => item.standing === "new" && (item.remedy.kind !== "override" || item.remedy.breaking || item.direct);
@@ -3577,8 +4106,8 @@ async function createStaging(forkPath, workspaceYaml) {
3577
4106
  ...projects.map((project) => join12(relative2(forkPath, project.path), "package.json"))
3578
4107
  ];
3579
4108
  for (const file of new Set(files)) {
3580
- if (file.startsWith("..") || !existsSync8(join12(forkPath, file))) continue;
3581
- mkdirSync2(dirname7(join12(staging, file)), { recursive: true });
4109
+ if (file.startsWith("..") || !existsSync9(join12(forkPath, file))) continue;
4110
+ mkdirSync2(dirname8(join12(staging, file)), { recursive: true });
3582
4111
  cpSync(join12(forkPath, file), join12(staging, file));
3583
4112
  }
3584
4113
  return staging;
@@ -3821,7 +4350,7 @@ async function runSecurityPass(forkPath, options) {
3821
4350
  return true;
3822
4351
  } finally {
3823
4352
  spinner.stop();
3824
- rmSync(staging, { recursive: true, force: true });
4353
+ rmSync2(staging, { recursive: true, force: true });
3825
4354
  }
3826
4355
  }
3827
4356
  function printOpen(modules) {
@@ -4023,7 +4552,7 @@ function printOutdatedResults(rows, forkPath) {
4023
4552
  const dependentMap = /* @__PURE__ */ new Map();
4024
4553
  for (const pkg of rows) {
4025
4554
  for (const { name, location } of pkg.dependentPackages) {
4026
- const dependentName = name || basename4(location);
4555
+ const dependentName = name || basename5(location);
4027
4556
  const existing = dependentMap.get(dependentName);
4028
4557
  if (existing) {
4029
4558
  existing.count++;
@@ -4219,8 +4748,8 @@ import {
4219
4748
  import { select as select3 } from "@inquirer/prompts";
4220
4749
 
4221
4750
  // 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";
4751
+ import { rmSync as rmSync3 } from "fs";
4752
+ import { dirname as dirname9, join as join14, resolve as resolve4 } from "path";
4224
4753
  async function listFilesAtRef(repoPath, ref) {
4225
4754
  const raw = await git(["ls-tree", "-r", "--name-only", ref], repoPath);
4226
4755
  return new Set(raw.split("\n").filter(Boolean));
@@ -4236,10 +4765,10 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
4236
4765
  const baseFiles = await listFilesAtRef(repoPath, baseRef);
4237
4766
  const baseDirs = /* @__PURE__ */ new Set(["."]);
4238
4767
  for (const f of baseFiles) {
4239
- let dir = dirname8(f);
4768
+ let dir = dirname9(f);
4240
4769
  while (dir !== ".") {
4241
4770
  baseDirs.add(dir);
4242
- dir = dirname8(dir);
4771
+ dir = dirname9(dir);
4243
4772
  }
4244
4773
  }
4245
4774
  const files = [];
@@ -4252,7 +4781,7 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
4252
4781
  if (inCella && !inFork) {
4253
4782
  kind = "deleted";
4254
4783
  } else if (!inCella && inFork) {
4255
- if (!baseDirs.has(dirname8(file.path))) continue;
4784
+ if (!baseDirs.has(dirname9(file.path))) continue;
4256
4785
  kind = "created";
4257
4786
  } else {
4258
4787
  kind = "modified";
@@ -4306,25 +4835,25 @@ ${commitBody}`;
4306
4835
  await git(["update-ref", `refs/heads/${branch}`, commitHash], repoPath);
4307
4836
  return { branch, appliedFiles };
4308
4837
  } finally {
4309
- rmSync2(tmpIndex, { force: true });
4838
+ rmSync3(tmpIndex, { force: true });
4310
4839
  }
4311
4840
  }
4312
4841
 
4313
4842
  // src/services/fork-utils.ts
4314
- import { existsSync as existsSync9 } from "fs";
4315
- import { dirname as dirname9, resolve as resolve5 } from "path";
4843
+ import { existsSync as existsSync10 } from "fs";
4844
+ import { dirname as dirname10, resolve as resolve5 } from "path";
4316
4845
  async function resolveForkBasePath(repoPath) {
4317
4846
  const commonDir = await git(["rev-parse", "--git-common-dir"], repoPath, { ignoreErrors: true });
4318
- return commonDir ? dirname9(resolve5(repoPath, commonDir)) : repoPath;
4847
+ return commonDir ? dirname10(resolve5(repoPath, commonDir)) : repoPath;
4319
4848
  }
4320
4849
  function validateForkPath(fork, basePath, requireConfig = false) {
4321
4850
  const resolvedPath = resolve5(basePath, fork.localPath);
4322
4851
  if (!resolvedPath.startsWith(resolve5(basePath, ".."))) {
4323
4852
  return { fork, resolvedPath, valid: false, error: "path resolves outside parent directory" };
4324
4853
  }
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}`)) {
4854
+ if (!existsSync10(resolvedPath)) return { fork, resolvedPath, valid: false, error: "path does not exist" };
4855
+ if (!existsSync10(`${resolvedPath}/.git`)) return { fork, resolvedPath, valid: false, error: "not a git repository" };
4856
+ if (requireConfig && !existsSync10(`${resolvedPath}/${CONFIG_FILE}`)) {
4328
4857
  return { fork, resolvedPath, valid: false, error: `missing ${CONFIG_FILE}` };
4329
4858
  }
4330
4859
  return { fork, resolvedPath, valid: true };
@@ -4630,7 +5159,7 @@ async function runContributions(config) {
4630
5159
 
4631
5160
  // src/services/forks.ts
4632
5161
  import { resolve as resolve6 } from "path";
4633
- import process8 from "process";
5162
+ import process10 from "process";
4634
5163
  import { Separator as Separator2, select as select5 } from "@inquirer/prompts";
4635
5164
 
4636
5165
  // src/services/sync.ts
@@ -4667,6 +5196,28 @@ function listOpenSyncPrs(cwd, prefix) {
4667
5196
  }
4668
5197
  return parsed.filter((pr) => pr.headRefName.startsWith(`${prefix}/`)).sort((a, b) => b.number - a.number);
4669
5198
  }
5199
+ function createPr(cwd, repoSlug, pr) {
5200
+ const repo = repoSlug ?? "{owner}/{repo}";
5201
+ const fields = ["title", "head", "base", "body"];
5202
+ const created = runGh(cwd, [
5203
+ "api",
5204
+ `repos/${repo}/pulls`,
5205
+ "-X",
5206
+ "POST",
5207
+ ...fields.flatMap((field) => ["-f", `${field}=${pr[field]}`]),
5208
+ "--jq",
5209
+ ".html_url"
5210
+ ]);
5211
+ if (created.ok) return { ...created, url: created.output || void 0 };
5212
+ const owner = repoSlug ? repoSlug.split("/")[0] : "{owner}";
5213
+ const existing = runGh(cwd, [
5214
+ "api",
5215
+ `repos/${repo}/pulls?state=open&head=${owner}:${pr.head}`,
5216
+ "--jq",
5217
+ ".[0].html_url // empty"
5218
+ ]);
5219
+ return { ...created, url: existing.ok && existing.output ? existing.output : void 0 };
5220
+ }
4670
5221
  function mergePrSquash(cwd, ref, options = {}) {
4671
5222
  return runGh(cwd, ["pr", "merge", String(ref), "--squash", ...options.deleteBranch ? ["--delete-branch"] : []]);
4672
5223
  }
@@ -4675,11 +5226,11 @@ function closePr(cwd, ref) {
4675
5226
  }
4676
5227
 
4677
5228
  // src/services/packages.ts
4678
- import { existsSync as existsSync11, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
5229
+ import { existsSync as existsSync12, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
4679
5230
  import { join as join16 } from "path";
4680
5231
 
4681
5232
  // src/services/package-usage.ts
4682
- import { existsSync as existsSync10, readFileSync as readFileSync5, statSync } from "fs";
5233
+ import { existsSync as existsSync11, readFileSync as readFileSync5, statSync } from "fs";
4683
5234
  import { join as join15 } from "path";
4684
5235
  var MAX_FILE_BYTES = 1024 * 1024;
4685
5236
  function escapeRegExp(value) {
@@ -4720,7 +5271,7 @@ function binNames(forkPath, location, name) {
4720
5271
  const bareName = name.split("/").pop();
4721
5272
  for (const dir of [join15(forkPath, location), forkPath]) {
4722
5273
  const manifest = join15(dir, "node_modules", name, "package.json");
4723
- if (!existsSync10(manifest)) continue;
5274
+ if (!existsSync11(manifest)) continue;
4724
5275
  try {
4725
5276
  const { bin } = JSON.parse(readFileSync5(manifest, "utf8"));
4726
5277
  if (typeof bin === "string") return [bareName];
@@ -4901,7 +5452,7 @@ function setTopLevelKey(pkg, key, value) {
4901
5452
  Object.assign(pkg, Object.fromEntries(entries));
4902
5453
  }
4903
5454
  function readPackageJson(filePath) {
4904
- if (!existsSync11(filePath)) return null;
5455
+ if (!existsSync12(filePath)) return null;
4905
5456
  try {
4906
5457
  return JSON.parse(readFileSync6(filePath, "utf8"));
4907
5458
  } catch (error) {
@@ -4941,7 +5492,7 @@ async function syncPackageJson(config, baseRef, usage, relativePath, keysToSync)
4941
5492
  const basePkg = baseRef ? (await readPackageJsonAtRef(forkPath, baseRef, relativePath))?.data : void 0;
4942
5493
  if (!upstream) return { updated: false, changes, kept };
4943
5494
  if (!forkPkg) {
4944
- const workspaceArrived = relativePath !== "" && existsSync11(join16(forkPath, relativePath));
5495
+ const workspaceArrived = relativePath !== "" && existsSync12(join16(forkPath, relativePath));
4945
5496
  if (basePkg || !workspaceArrived || isIgnored(pkgRelPath, config)) return { updated: false, changes, kept };
4946
5497
  writeFileSync3(pkgPath, upstream.raw.endsWith("\n") ? upstream.raw : `${upstream.raw}
4947
5498
  `, "utf8");
@@ -5147,9 +5698,43 @@ async function runSync(config, options) {
5147
5698
  const staged = result.files.some((file) => SYNC_APPLIED_STATUSES.includes(file.status));
5148
5699
  const stagedBranch = options?.stagedBranch && result.conflicts.length === 0 && staged ? options.stagedBranch : void 0;
5149
5700
  printSyncComplete(result, { stagedBranch });
5150
- await reportReviewPage(config.forkPath, result, "sync");
5151
- await printMigrationNotesLine(config, result);
5152
- return result;
5701
+ const reviewPage = await reportReviewPage(config.forkPath, result, "sync");
5702
+ return { ...result, reviewPage };
5703
+ }
5704
+ var CONFLICTS_LISTED = 10;
5705
+ async function syncAlignmentsStep(config, run) {
5706
+ if (run?.alignments && run.upstreamCommit) {
5707
+ const { open, arrived } = run.alignments;
5708
+ return alignmentsStep(config, { ref: run.upstreamCommit.hash, open, arrived });
5709
+ }
5710
+ const view = await readOpenAlignments(config);
5711
+ return view ? alignmentsStep(config, view) : void 0;
5712
+ }
5713
+ function syncReviewStep(config, run) {
5714
+ const page = run ? run.reviewPage : existingSyncReviewPage(config.forkPath);
5715
+ return page ? reviewStep(page) : void 0;
5716
+ }
5717
+ async function conflictSteps(config, conflicts, run) {
5718
+ const listed = conflicts.slice(0, CONFLICTS_LISTED).map((file) => colors_default.dim(file));
5719
+ if (conflicts.length > CONFLICTS_LISTED) listed.push(colors_default.dim(`+ ${conflicts.length - CONFLICTS_LISTED} more`));
5720
+ const steps = [
5721
+ {
5722
+ label: "resolve",
5723
+ line: `${plural(conflicts.length, "conflict")} in your editor, then stage them`,
5724
+ details: listed
5725
+ },
5726
+ {
5727
+ label: "finish",
5728
+ line: "pnpm cella sync",
5729
+ details: [
5730
+ colors_default.dim("commits the sync and stops for drift triage"),
5731
+ colors_default.dim("let the rerun commit: a manual `git commit` records a merge commit that bloats the PR")
5732
+ ]
5733
+ },
5734
+ syncReviewStep(config, run),
5735
+ await syncAlignmentsStep(config, run)
5736
+ ];
5737
+ return steps.filter((step) => !!step);
5153
5738
  }
5154
5739
  var SYNC_PR_TITLE = "chore: sync upstream cella";
5155
5740
  async function buildSyncCommitMessage(forkPath, upstreamRef) {
@@ -5164,7 +5749,7 @@ function qualifyPrRefs(subject, repoSlug) {
5164
5749
  return subject.replace(/(^|[^\w/])#(\d+)\b/g, `$1${repoSlug}#$2`);
5165
5750
  }
5166
5751
  function buildSyncPrBody(input) {
5167
- const { repoSlug, version, fromSha, toSha, commits, totalCount, notes } = input;
5752
+ const { repoSlug, version, fromSha, toSha, commits, totalCount, alignments } = input;
5168
5753
  const githubUrl = repoSlug ? `https://github.com/${repoSlug}` : void 0;
5169
5754
  const short = (sha) => sha.slice(0, 7);
5170
5755
  const commitRef = (sha) => githubUrl ? `[\`${short(sha)}\`](${githubUrl}/commit/${sha})` : `\`${short(sha)}\``;
@@ -5179,17 +5764,19 @@ function buildSyncPrBody(input) {
5179
5764
  lines.push(`- ${commitRef(commit.hash)} ${qualifyPrRefs(commit.message, repoSlug)}`);
5180
5765
  }
5181
5766
  }
5182
- if (notes && notes.total > 0) {
5183
- const { total, open } = notes;
5184
- if (open.length === 0) lines.push("", `**Migration notes**: all ${total} handled.`);
5767
+ if (alignments && alignments.total > 0) {
5768
+ const { total, open } = alignments;
5769
+ if (open.length === 0) lines.push("", `**Alignments**: all ${total} handled.`);
5185
5770
  else {
5186
5771
  lines.push(
5187
5772
  "",
5188
- `**Migration notes**: ${Math.max(0, total - open.length)} of ${total} handled, open (\`pnpm cella migrate\`):`,
5773
+ `**Alignments**: ${Math.max(0, total - open.length)} of ${total} handled, open (\`pnpm cella align\`):`,
5189
5774
  ""
5190
5775
  );
5191
- for (const note of open)
5192
- lines.push(`- ${note.url ? `[${note.title}](${note.url})` : note.title} (\`${note.id}\`)`);
5776
+ for (const alignment of open)
5777
+ lines.push(
5778
+ `- ${alignment.url ? `[${alignment.title}](${alignment.url})` : alignment.title} (\`${alignment.id}\`)`
5779
+ );
5193
5780
  }
5194
5781
  }
5195
5782
  return `${lines.join("\n")}
@@ -5203,17 +5790,25 @@ async function buildSyncPrBodyForBranch(forkPath, base) {
5203
5790
  const version = manifest.upstream.release ?? await readPackageVersionAtRef(forkPath, toSha).catch(() => null);
5204
5791
  const totalCount = fromSha ? await countCommitsBetween(forkPath, fromSha, toSha).catch(() => 0) : 0;
5205
5792
  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 });
5793
+ const alignments = await readPrBodyAlignments(forkPath, toSha, manifest.upstream.repo).catch(() => void 0);
5794
+ return buildSyncPrBody({
5795
+ repoSlug: manifest.upstream.repo,
5796
+ version,
5797
+ fromSha,
5798
+ toSha,
5799
+ commits,
5800
+ totalCount,
5801
+ alignments
5802
+ });
5208
5803
  }
5209
- async function readPrBodyNotes(forkPath, toSha, repoSlug) {
5804
+ async function readPrBodyAlignments(forkPath, toSha, repoSlug) {
5210
5805
  const githubUrl = repoSlug ? `https://github.com/${repoSlug}` : void 0;
5211
- const [ids, pending] = await Promise.all([listNoteIds(forkPath, toSha), readPending(forkPath)]);
5806
+ const [ids, pending] = await Promise.all([listAlignmentIds(forkPath, toSha), readPending(forkPath)]);
5212
5807
  const open = await Promise.all(
5213
5808
  pending.map(async (id) => ({
5214
5809
  id,
5215
- title: (await readNote(forkPath, toSha, id))?.title ?? id,
5216
- url: noteUrl(githubUrl, toSha, id)
5810
+ title: (await readAlignment(forkPath, toSha, id))?.title ?? id,
5811
+ url: alignmentUrl(githubUrl, toSha, id)
5217
5812
  }))
5218
5813
  );
5219
5814
  return { total: ids.length, open };
@@ -5254,16 +5849,11 @@ async function shipSyncBranch(config, branch) {
5254
5849
  }
5255
5850
  if (ghAvailable()) {
5256
5851
  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;
5852
+ const prBody = await buildSyncPrBodyForBranch(forkPath, cutBase) ?? "";
5853
+ const repoSlug = (await readOriginGitHubUrl(forkPath))?.replace("https://github.com/", "") ?? null;
5854
+ const pr = createPr(forkPath, repoSlug, { base, head: branch, title: prTitle, body: prBody });
5855
+ prUrl = pr.url;
5856
+ prOpened = pr.ok;
5267
5857
  if (!prOpened) {
5268
5858
  console.info(colors_default.yellow("could not open the PR automatically (it may already exist). open it with:"));
5269
5859
  if (prUrl) console.info(colors_default.dim(` ${prUrl}`));
@@ -5282,7 +5872,15 @@ ${pr.stderr ?? ""}`.match(/https?:\/\/\S+/)?.[0];
5282
5872
  console.info(`${checkMark} sync branch pushed`);
5283
5873
  console.info(colors_default.dim(` '${branch}' is on origin, ${position}`));
5284
5874
  }
5285
- await printMigrationNotesLine(config);
5875
+ const steps = [
5876
+ prUrl ? {
5877
+ label: "merge",
5878
+ line: hyperlink(colors_default.cyan(prUrl), prUrl),
5879
+ details: [colors_default.dim(`squash-merge it into '${base}'; the next sync offers to while it is open`)]
5880
+ } : void 0,
5881
+ await syncAlignmentsStep(config)
5882
+ ];
5883
+ printNextSteps(steps.filter((step) => !!step));
5286
5884
  }
5287
5885
  function finalizeWorkspace(forkPath) {
5288
5886
  for (const args of [["install"], ["check"]]) {
@@ -5321,40 +5919,49 @@ async function runSyncCycle(config) {
5321
5919
  if (config.settings.syncWithPackages !== false) {
5322
5920
  await runPackages(config, { conflictedFiles: result.conflicts });
5323
5921
  }
5324
- if (result.conflicts.length > 0) return { status: "conflicts", branch };
5922
+ if (result.conflicts.length > 0) return { status: "conflicts", branch, result };
5325
5923
  if (!await mergeInProgress(forkPath)) {
5326
5924
  await discardTemporarySyncBranch(forkPath, branch);
5327
- return { status: "noop" };
5925
+ return { status: "noop", result };
5328
5926
  }
5329
- return { status: "staged", branch };
5927
+ return { status: "staged", branch, result };
5330
5928
  }
5331
- async function commitSyncMerge(config, branch) {
5929
+ async function commitSyncMerge(config, branch, run) {
5332
5930
  const { forkPath } = config;
5333
5931
  const conflicts = await getConflictedFiles(forkPath);
5334
5932
  console.info();
5335
5933
  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)."));
5934
+ printStep("conflicts", `${plural(conflicts.length, "file")} still conflict on '${branch}'`, warningMark);
5935
+ printNextSteps(await conflictSteps(config, conflicts, run));
5339
5936
  return;
5340
5937
  }
5341
5938
  await stageAll(forkPath);
5342
5939
  if (!finalizeWorkspace(forkPath)) {
5343
5940
  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
- );
5941
+ printStep("checks", "`pnpm install` or `pnpm check` failed", warningMark);
5942
+ printNextSteps([
5943
+ { label: "fix", line: "what failed above", details: [colors_default.dim(`the merge stays staged on '${branch}'`)] },
5944
+ { label: "finish", line: "pnpm cella sync", details: [colors_default.dim("commits the sync and stops for drift triage")] }
5945
+ ]);
5347
5946
  return;
5348
5947
  }
5349
5948
  const message = await buildSyncCommitMessage(forkPath, "MERGE_HEAD");
5350
5949
  await stageAll(forkPath);
5351
5950
  await commitSquash(forkPath, message);
5352
5951
  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);
5952
+ printStep("committed", `${message}
5953
+ ${colors_default.dim(`on '${branch}', not pushed`)}`);
5954
+ const steps = [
5955
+ syncReviewStep(config, run),
5956
+ {
5957
+ label: "triage",
5958
+ line: "pnpm cella analyze",
5959
+ details: [colors_default.dim("drift triage: commit any follow-ups on this branch")]
5960
+ },
5961
+ await syncAlignmentsStep(config, run),
5962
+ { label: "ship", line: "pnpm cella sync", details: [colors_default.dim("pushes the branch and opens the squash-merge PR")] }
5963
+ ];
5964
+ printNextSteps(steps.filter((step) => !!step));
5358
5965
  }
5359
5966
  async function mergeOpenSyncPrs(config, open) {
5360
5967
  const { forkPath, settings } = config;
@@ -5438,25 +6045,26 @@ commit them first (\`git commit --amend --no-edit\` or a new commit).`
5438
6045
  }
5439
6046
  if (await guardAgainstOpenSyncPr(config) === "cancel") return;
5440
6047
  const outcome = await runSyncCycle(config);
5441
- console.info();
5442
6048
  if (outcome.status === "noop") {
6049
+ console.info();
5443
6050
  if (outcome.behind) {
5444
6051
  console.info(colors_default.green("nothing to sync: the last sync already went past this upstream point."));
5445
6052
  for (const line of outcome.behind.split("\n")) console.info(colors_default.dim(` ${line}`));
5446
6053
  } else {
5447
6054
  console.info(colors_default.green("already up to date with upstream, nothing to sync."));
5448
6055
  }
6056
+ const align = await syncAlignmentsStep(config, outcome.result);
6057
+ if (align) printNextSteps([align]);
5449
6058
  return;
5450
6059
  }
5451
6060
  const { temporaryBranch } = outcome.branch;
5452
6061
  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."));
6062
+ const conflicts = await getConflictedFiles(forkPath);
6063
+ printStep("conflicts", `${plural(conflicts.length, "file")} on '${temporaryBranch}'`, warningMark);
6064
+ printNextSteps(await conflictSteps(config, conflicts, outcome.result));
5457
6065
  return;
5458
6066
  }
5459
- await commitSyncMerge(config, temporaryBranch);
6067
+ await commitSyncMerge(config, temporaryBranch, outcome.result);
5460
6068
  }
5461
6069
 
5462
6070
  // src/services/forks.ts
@@ -5553,7 +6161,7 @@ async function runForks(config) {
5553
6161
  loop: false
5554
6162
  });
5555
6163
  if (selectedPath === "_exit") {
5556
- process8.exit(0);
6164
+ process10.exit(0);
5557
6165
  }
5558
6166
  const resolvedForkPath = resolve6(forkBasePath, selectedPath);
5559
6167
  const selectedFork = forks.find((f) => f.localPath === selectedPath);
@@ -5571,7 +6179,7 @@ import { readFile as readFile3 } from "fs/promises";
5571
6179
  import { join as join18 } from "path";
5572
6180
 
5573
6181
  // src/utils/coverage-utils.ts
5574
- import { existsSync as existsSync12, readFileSync as readFileSync7, statSync as statSync2 } from "fs";
6182
+ import { existsSync as existsSync13, readFileSync as readFileSync7, statSync as statSync2 } from "fs";
5575
6183
  import { join as join17, relative as relative4, sep as sep3 } from "path";
5576
6184
  var STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1e3;
5577
6185
  function colorPct(pct) {
@@ -5594,7 +6202,7 @@ function printCoverageSummary(forkPath) {
5594
6202
  let summary;
5595
6203
  let mtimeMs = 0;
5596
6204
  let problem = "no coverage data found";
5597
- if (existsSync12(file)) {
6205
+ if (existsSync13(file)) {
5598
6206
  try {
5599
6207
  mtimeMs = statSync2(file).mtimeMs;
5600
6208
  summary = JSON.parse(readFileSync7(file, "utf8"));
@@ -5994,26 +6602,26 @@ function getForkPath() {
5994
6602
  const envPath = getEnv("CELLA_FORK_PATH");
5995
6603
  if (envPath) {
5996
6604
  const resolved = resolve7(envPath);
5997
- if (!existsSync13(resolved) || !statSync3(resolved).isDirectory()) {
6605
+ if (!existsSync14(resolved) || !statSync3(resolved).isDirectory()) {
5998
6606
  throw new Error(`CELLA_FORK_PATH is not a valid directory: ${resolved}`);
5999
6607
  }
6000
6608
  return resolved;
6001
6609
  }
6002
- let cwd = process9.cwd();
6610
+ let cwd = process11.cwd();
6003
6611
  if (cwd.endsWith("/cli/cella") || cwd.endsWith("\\cli\\cella")) {
6004
6612
  cwd = resolve7(cwd, "../..");
6005
6613
  }
6006
6614
  return cwd;
6007
6615
  }
6008
6616
  function preflight(forkPath) {
6009
- if (!existsSync13(join19(forkPath, ".git"))) {
6617
+ if (!existsSync14(join19(forkPath, ".git"))) {
6010
6618
  throw new Error(`not a git repository: ${forkPath}`);
6011
6619
  }
6012
6620
  }
6013
6621
  var services = {
6014
6622
  analyze: runAnalyze,
6015
6623
  sync: runSyncCommand,
6016
- migrate: runMigrate,
6624
+ align: runAlign,
6017
6625
  audit: runAudit,
6018
6626
  forks: runForks,
6019
6627
  contributions: runContributions,
@@ -6024,7 +6632,7 @@ var services = {
6024
6632
  }
6025
6633
  };
6026
6634
  function isHelpOrVersionRequest() {
6027
- return process9.argv.slice(2).some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v") || process9.argv[2] === "help";
6635
+ return process11.argv.slice(2).some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v") || process11.argv[2] === "help";
6028
6636
  }
6029
6637
  async function main() {
6030
6638
  try {
@@ -6043,7 +6651,7 @@ async function main() {
6043
6651
  } catch (error) {
6044
6652
  console.error();
6045
6653
  console.error(`${colors_default.red("\u2717")} ${errorMessage(error)}`);
6046
- process9.exit(1);
6654
+ process11.exit(1);
6047
6655
  }
6048
6656
  }
6049
6657
  main();