@cellajs/cli 0.4.1 → 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 existsSync12, statSync as statSync3 } from "fs";
5
- import { join as join18, resolve as resolve7 } from "path";
6
- import process9 from "process";
4
+ import { existsSync as existsSync14, statSync as statSync3 } from "fs";
5
+ import { join as join19, resolve as resolve7 } from "path";
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.1",
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.",
@@ -342,489 +342,131 @@ var package_default = {
342
342
  }
343
343
  };
344
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);
345
+ // src/utils/git.ts
346
+ import { execFile } from "child_process";
347
+ import { existsSync as existsSync3 } from "fs";
348
+ import { mkdir as mkdir2, readdir, rmdir, unlink } from "fs/promises";
349
+ import { dirname as dirname2, join as join4, resolve as resolve2 } from "path";
350
+ import { promisify } from "util";
351
+
352
+ // src/utils/manifest.ts
353
+ import { mkdir, readFile, writeFile } from "fs/promises";
354
+ import { dirname, join as join3 } from "path";
355
+ var MANIFEST_FILE = "cella/cella.manifest.json";
356
+ function parseSyncManifest(raw) {
357
+ try {
358
+ const parsed = JSON.parse(raw);
359
+ const commit = parsed?.upstream?.commit;
360
+ if (typeof commit !== "string" || !/^[0-9a-f]{40}$/i.test(commit)) return null;
361
+ return parsed;
362
+ } catch {
363
+ return null;
393
364
  }
394
- activeSpinner = spinner;
395
- return spinner;
396
365
  }
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();
366
+ async function readSyncManifest(cwd) {
367
+ try {
368
+ return parseSyncManifest(await readFile(join3(cwd, MANIFEST_FILE), "utf8"));
369
+ } catch {
370
+ return null;
404
371
  }
405
372
  }
406
- function spinnerFail(message) {
407
- stopSpinner();
408
- console.info(`${colors_default.red("\u2717")} ${message}`);
373
+ async function readManifestBase(cwd) {
374
+ const manifest = await readSyncManifest(cwd);
375
+ return manifest ? manifest.upstream.commit.toLowerCase() : null;
409
376
  }
410
- function spinnerText(text) {
411
- if (activeSpinner) {
412
- activeSpinner.text = text;
413
- }
377
+ async function writeSyncManifest(cwd, manifest) {
378
+ const manifestPath = join3(cwd, MANIFEST_FILE);
379
+ await mkdir(dirname(manifestPath), { recursive: true });
380
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}
381
+ `, "utf8");
414
382
  }
415
- function stopSpinner() {
416
- if (activeSpinner) {
417
- activeSpinner.stop();
418
- activeSpinner = null;
383
+
384
+ // src/utils/git.ts
385
+ var execFileAsync = promisify(execFile);
386
+ var MAX_BUFFER = 50 * 1024 * 1024;
387
+ function redactUrlSecrets(text) {
388
+ return text.replace(/https?:\/\/[^@\s]+@/g, (match) => {
389
+ const protocol = match.startsWith("https") ? "https" : "http";
390
+ return `${protocol}://***@`;
391
+ });
392
+ }
393
+ async function git(args, cwd, options = {}) {
394
+ const env = {
395
+ ...getEnvSnapshot(),
396
+ ...options.skipEditor ? { GIT_EDITOR: "true" } : {},
397
+ ...options.env
398
+ };
399
+ try {
400
+ const { stdout } = await execFileAsync("git", args, { cwd, env, maxBuffer: MAX_BUFFER });
401
+ return stdout.trim();
402
+ } catch (error) {
403
+ if (options.ignoreErrors) return "";
404
+ if (error instanceof Error) {
405
+ error.message = redactUrlSecrets(error.message);
406
+ }
407
+ throw error;
419
408
  }
420
409
  }
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`;
410
+ async function revParse(cwd, ref) {
411
+ return await git(["rev-parse", "-q", "--verify", ref], cwd, { ignoreErrors: true }) || null;
425
412
  }
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");
413
+ async function getCurrentBranch(cwd) {
414
+ return git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
439
415
  }
440
- function printSectionHeader(title) {
441
- console.info();
442
- console.info(title);
443
- console.info(DIVIDER);
444
- console.info();
416
+ async function switchBranch(cwd, branch) {
417
+ await git(["switch", branch], cwd);
445
418
  }
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}`);
419
+ async function switchDetached(cwd, ref) {
420
+ await git(["switch", "--detach", ref], cwd);
456
421
  }
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");
422
+ async function createBranchFrom(cwd, branch, startPoint) {
423
+ await git(["switch", "-c", branch, "--no-track", startPoint], cwd);
473
424
  }
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
- }
425
+ async function branchExists(cwd, branch) {
426
+ return await revParse(cwd, `refs/heads/${branch}`) !== null;
515
427
  }
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
- }
428
+ async function getBranchWorktree(cwd, branch) {
429
+ const output = await git(["worktree", "list", "--porcelain"], cwd, { ignoreErrors: true });
430
+ let worktree = null;
431
+ for (const line of output.split("\n")) {
432
+ if (line.startsWith("worktree ")) worktree = line.slice("worktree ".length);
433
+ else if (line === `branch refs/heads/${branch}`) return worktree;
537
434
  }
435
+ return null;
538
436
  }
539
- function filesWithStatus(files, status) {
540
- return files.filter((f) => f.status === status && !isManagedFile(f.path));
437
+ async function fastForwardBranch(cwd, branch, ref) {
438
+ await git(["fetch", "--quiet", ".", `${ref}:refs/heads/${branch}`], cwd);
541
439
  }
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 });
440
+ async function deleteBranch(cwd, branch) {
441
+ await git(["branch", "-D", branch], cwd, { ignoreErrors: true });
546
442
  }
547
- function findProtectedBehind(files) {
548
- return files.filter((file) => file.upstreamChanged && !isManagedFile(file.path));
443
+ async function pullFastForward(cwd) {
444
+ await git(["pull", "--ff-only"], cwd, { ignoreErrors: true, skipEditor: true });
549
445
  }
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;
446
+ async function getBranchUpstream(cwd, branch = "") {
447
+ const upstream = await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", `${branch}@{upstream}`], cwd, {
448
+ ignoreErrors: true
449
+ });
450
+ return upstream || null;
553
451
  }
554
- function formatUpstreamChangedLines(file) {
555
- if (file.upstreamChangedLines === void 0) return "";
556
- return colors_default.yellow(` \xB7 ${plural(file.upstreamChangedLines, "line")} changed upstream`);
452
+ async function getUpstreamStatus(cwd, branch) {
453
+ const local = branch ? `refs/heads/${branch}` : "HEAD";
454
+ const upstream = await getBranchUpstream(cwd, branch);
455
+ if (!upstream) return { upstream: null, ahead: 0, behind: 0 };
456
+ const remote = upstream.split("/")[0];
457
+ await git(["fetch", remote], cwd, { ignoreErrors: true, skipEditor: true });
458
+ const counts = await git(["rev-list", "--left-right", "--count", `${local}...${upstream}`], cwd, {
459
+ ignoreErrors: true
460
+ });
461
+ const [ahead, behind] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0);
462
+ return { upstream, ahead: ahead ?? 0, behind: behind ?? 0 };
557
463
  }
558
- function formatUpstreamLinesAbsent(file) {
559
- const n = file.upstreamLinesAbsent ?? 0;
560
- return n > 0 ? colors_default.yellow(` \xB7 ${plural(n, "upstream line")} absent`) : "";
464
+ async function pushBranch(cwd, remote, branch, options = {}) {
465
+ await git(["push", "-u", ...options.forceWithLease ? ["--force-with-lease"] : [], remote, branch], cwd);
561
466
  }
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";
702
-
703
- // src/utils/git.ts
704
- import { execFile } from "child_process";
705
- import { existsSync as existsSync3 } from "fs";
706
- import { mkdir as mkdir2, readdir, rmdir, unlink } from "fs/promises";
707
- import { dirname as dirname2, join as join4, resolve as resolve3 } from "path";
708
- import { promisify } from "util";
709
-
710
- // src/utils/manifest.ts
711
- import { mkdir, readFile, writeFile } from "fs/promises";
712
- import { dirname, join as join3 } from "path";
713
- var MANIFEST_FILE = "cella/cella.manifest.json";
714
- function parseSyncManifest(raw) {
715
- try {
716
- const parsed = JSON.parse(raw);
717
- const commit = parsed?.upstream?.commit;
718
- if (typeof commit !== "string" || !/^[0-9a-f]{40}$/i.test(commit)) return null;
719
- return parsed;
720
- } catch {
721
- return null;
722
- }
723
- }
724
- async function readSyncManifest(cwd) {
725
- try {
726
- return parseSyncManifest(await readFile(join3(cwd, MANIFEST_FILE), "utf8"));
727
- } catch {
728
- return null;
729
- }
730
- }
731
- async function readManifestBase(cwd) {
732
- const manifest = await readSyncManifest(cwd);
733
- return manifest ? manifest.upstream.commit.toLowerCase() : null;
734
- }
735
- async function writeSyncManifest(cwd, manifest) {
736
- const manifestPath = join3(cwd, MANIFEST_FILE);
737
- await mkdir(dirname(manifestPath), { recursive: true });
738
- await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}
739
- `, "utf8");
740
- }
741
-
742
- // src/utils/git.ts
743
- var execFileAsync = promisify(execFile);
744
- var MAX_BUFFER = 50 * 1024 * 1024;
745
- function redactUrlSecrets(text) {
746
- return text.replace(/https?:\/\/[^@\s]+@/g, (match) => {
747
- const protocol = match.startsWith("https") ? "https" : "http";
748
- return `${protocol}://***@`;
749
- });
750
- }
751
- async function git(args, cwd, options = {}) {
752
- const env = {
753
- ...getEnvSnapshot(),
754
- ...options.skipEditor ? { GIT_EDITOR: "true" } : {},
755
- ...options.env
756
- };
757
- try {
758
- const { stdout } = await execFileAsync("git", args, { cwd, env, maxBuffer: MAX_BUFFER });
759
- return stdout.trim();
760
- } catch (error) {
761
- if (options.ignoreErrors) return "";
762
- if (error instanceof Error) {
763
- error.message = redactUrlSecrets(error.message);
764
- }
765
- throw error;
766
- }
767
- }
768
- async function revParse(cwd, ref) {
769
- return await git(["rev-parse", "-q", "--verify", ref], cwd, { ignoreErrors: true }) || null;
770
- }
771
- async function getCurrentBranch(cwd) {
772
- return git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
773
- }
774
- async function switchBranch(cwd, branch) {
775
- await git(["switch", branch], cwd);
776
- }
777
- async function switchDetached(cwd, ref) {
778
- await git(["switch", "--detach", ref], cwd);
779
- }
780
- async function createBranchFrom(cwd, branch, startPoint) {
781
- await git(["switch", "-c", branch, "--no-track", startPoint], cwd);
782
- }
783
- async function branchExists(cwd, branch) {
784
- return await revParse(cwd, `refs/heads/${branch}`) !== null;
785
- }
786
- async function getBranchWorktree(cwd, branch) {
787
- const output = await git(["worktree", "list", "--porcelain"], cwd, { ignoreErrors: true });
788
- let worktree = null;
789
- for (const line of output.split("\n")) {
790
- if (line.startsWith("worktree ")) worktree = line.slice("worktree ".length);
791
- else if (line === `branch refs/heads/${branch}`) return worktree;
792
- }
793
- return null;
794
- }
795
- async function fastForwardBranch(cwd, branch, ref) {
796
- await git(["fetch", "--quiet", ".", `${ref}:refs/heads/${branch}`], cwd);
797
- }
798
- async function deleteBranch(cwd, branch) {
799
- await git(["branch", "-D", branch], cwd, { ignoreErrors: true });
800
- }
801
- async function pullFastForward(cwd) {
802
- await git(["pull", "--ff-only"], cwd, { ignoreErrors: true, skipEditor: true });
803
- }
804
- async function getBranchUpstream(cwd, branch = "") {
805
- const upstream = await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", `${branch}@{upstream}`], cwd, {
806
- ignoreErrors: true
807
- });
808
- return upstream || null;
809
- }
810
- async function getUpstreamStatus(cwd, branch) {
811
- const local = branch ? `refs/heads/${branch}` : "HEAD";
812
- const upstream = await getBranchUpstream(cwd, branch);
813
- if (!upstream) return { upstream: null, ahead: 0, behind: 0 };
814
- const remote = upstream.split("/")[0];
815
- await git(["fetch", remote], cwd, { ignoreErrors: true, skipEditor: true });
816
- const counts = await git(["rev-list", "--left-right", "--count", `${local}...${upstream}`], cwd, {
817
- ignoreErrors: true
818
- });
819
- const [ahead, behind] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0);
820
- return { upstream, ahead: ahead ?? 0, behind: behind ?? 0 };
821
- }
822
- async function pushBranch(cwd, remote, branch, options = {}) {
823
- await git(["push", "-u", ...options.forceWithLease ? ["--force-with-lease"] : [], remote, branch], cwd);
824
- }
825
- async function listBranchMergeCommits(cwd, baseRef) {
826
- const out = await git(["rev-list", "--merges", "--first-parent", `${baseRef}..HEAD`], cwd, { ignoreErrors: true });
827
- return out ? out.split("\n").filter(Boolean) : [];
467
+ async function listBranchMergeCommits(cwd, baseRef) {
468
+ const out = await git(["rev-list", "--merges", "--first-parent", `${baseRef}..HEAD`], cwd, { ignoreErrors: true });
469
+ return out ? out.split("\n").filter(Boolean) : [];
828
470
  }
829
471
  async function flattenBranch(cwd, baseRef, message) {
830
472
  const parent = await getMergeBase(cwd, baseRef, "HEAD");
@@ -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
  }
@@ -1231,36 +877,430 @@ async function getEffectiveMergeBase(cwd, headRef, upstreamRef) {
1231
877
  if (recordedIsNewer && recorded !== gitBase) {
1232
878
  return { base: recorded, isStale: true, storedRef: recorded };
1233
879
  }
1234
- return { base: gitBase, isStale: false, storedRef: recorded };
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
+ }
1246
+ }
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.`));
1252
+ }
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}`));
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();
1235
1275
  }
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 });
1249
- }
1276
+ function printEngineReports(result, title) {
1277
+ printSummary(result.summary, title);
1278
+ printIgnoredUpstreamChanges(result);
1279
+ printUpstreamOverrideChanges(result);
1280
+ printMaskingPinWarning(result.files);
1250
1281
  }
1251
1282
 
1252
- // src/utils/migration-notes.ts
1283
+ // src/utils/overrides.ts
1284
+ import { existsSync as existsSync5 } from "fs";
1285
+ import { join as join6 } from "path";
1286
+
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}`));
2611
+ console.info();
2612
+ }
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);
2449
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
+ );
2450
2655
  }
2451
- async function writeNoteFolder(config, ref, id) {
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,29 +3352,67 @@ 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 join12, 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
2879
3408
  import { execFile as execFile2 } from "child_process";
2880
3409
  import { promisify as promisify2 } from "util";
2881
3410
  var execFileAsync2 = promisify2(execFile2);
3411
+ function outdatedPackageName(key) {
3412
+ const name = key.split(" ")[0];
3413
+ const at = name.indexOf("@", 1);
3414
+ return at === -1 ? name : name.slice(0, at);
3415
+ }
2882
3416
  function getRepoUrl(repositoryUrl) {
2883
3417
  if (!repositoryUrl) return null;
2884
3418
  let url = repositoryUrl;
@@ -2973,9 +3507,16 @@ function formatDependents(dependents) {
2973
3507
  return `${dependents[0]} +${dependents.length - 1}`;
2974
3508
  }
2975
3509
 
3510
+ // src/utils/local-tools.ts
3511
+ import { execFile as execFile3 } from "child_process";
3512
+ import { existsSync as existsSync8, realpathSync as realpathSync2 } from "fs";
3513
+ import { tmpdir } from "os";
3514
+ import { delimiter, dirname as dirname7, join as join11, sep as sep2 } from "path";
3515
+ import { promisify as promisify3 } from "util";
3516
+
2976
3517
  // src/utils/runtimes.ts
2977
3518
  import { readFileSync as readFileSync2 } from "fs";
2978
- 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";
2979
3520
  var DAY = 24 * 60 * 60 * 1e3;
2980
3521
  var FROZEN_AFTER = 180 * DAY;
2981
3522
  var RECENT_PUSH = 30 * DAY;
@@ -2983,7 +3524,7 @@ var EXACT_VERSION = /^\d+\.\d+\.\d+$/;
2983
3524
  var FROM_LINE = /^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)(?:\s+AS\s+(\S+))?\s*$/i;
2984
3525
  var NODE_ARG_TAG = /^(?:docker\.io\/)?(?:library\/)?node:\$\{?\w+\}?([\w.-]*)$/;
2985
3526
  var MINOR_TAG = /^(\d+(?:\.\d+){1,2})(-[\w.-]+)?$/;
2986
- var isDockerfile = (path) => /(^|\.)Dockerfile$|^Dockerfile\./i.test(basename3(path));
3527
+ var isDockerfile = (path) => /(^|\.)Dockerfile$|^Dockerfile\./i.test(basename4(path));
2987
3528
  function parseDockerfile(file, content) {
2988
3529
  const images = [];
2989
3530
  const nodeImageSuffixes = [];
@@ -3094,24 +3635,23 @@ async function fetchImageTags(pin) {
3094
3635
  if (!Array.isArray(results)) return null;
3095
3636
  return { pinned: hubTag(pinned), tags: results.flatMap((entry) => hubTag(entry) ?? []) };
3096
3637
  }
3638
+ async function fetchPnpmAdvisories(version) {
3639
+ const answer = await fetchJson("https://registry.npmjs.org/-/npm/v1/security/advisories/bulk", {
3640
+ method: "POST",
3641
+ headers: { "content-type": "application/json" },
3642
+ body: JSON.stringify({ pnpm: [version] })
3643
+ });
3644
+ return answer ? answer.pnpm ?? [] : null;
3645
+ }
3097
3646
  async function fetchRuntimeFeeds(pins, cwd, now) {
3098
3647
  const nodeMajor = pins.node ? parseComparableVersion(pins.node)?.major : void 0;
3099
3648
  const [node, published, advisories, images] = await Promise.all([
3100
3649
  nodeMajor === void 0 ? null : fetchNodeReleases(nodeMajor, now),
3101
3650
  pins.pnpm ? fetchPublishTimes("pnpm", cwd) : null,
3102
- pins.pnpm ? fetchJson("https://registry.npmjs.org/-/npm/v1/security/advisories/bulk", {
3103
- method: "POST",
3104
- headers: { "content-type": "application/json" },
3105
- body: JSON.stringify({ pnpm: [pins.pnpm] })
3106
- }) : null,
3651
+ pins.pnpm ? fetchPnpmAdvisories(pins.pnpm) : null,
3107
3652
  Promise.all(pins.images.map(fetchImageTags))
3108
3653
  ]);
3109
- const pnpmAdvisories = advisories?.pnpm;
3110
- return {
3111
- node,
3112
- pnpm: published && advisories ? { published, advisories: pnpmAdvisories ?? [] } : null,
3113
- images
3114
- };
3654
+ return { node, pnpm: published && advisories ? { published, advisories } : null, images };
3115
3655
  }
3116
3656
  var count = (n, noun, plural2 = `${noun}s`) => `${n} ${n === 1 ? noun : plural2}`;
3117
3657
  var monthYear = (time) => new Date(time).toLocaleString("en", { month: "short", year: "numeric", timeZone: "UTC" }).toLowerCase();
@@ -3222,7 +3762,7 @@ async function missingNodeImages(version, suffixes) {
3222
3762
  return missing.flat();
3223
3763
  }
3224
3764
  function pinLocation(file) {
3225
- const dir = dirname5(file);
3765
+ const dir = dirname6(file);
3226
3766
  return dir === "." ? file : dir;
3227
3767
  }
3228
3768
 
@@ -3341,6 +3881,130 @@ function liveExcludes(input) {
3341
3881
  });
3342
3882
  }
3343
3883
 
3884
+ // src/utils/local-tools.ts
3885
+ var execFileAsync3 = promisify3(execFile3);
3886
+ function findOnPath(name, skip = []) {
3887
+ for (const dir of (getEnv("PATH") ?? "").split(delimiter)) {
3888
+ if (!dir || skip.includes(dir) || dir.endsWith(`node_modules${sep2}.bin`)) continue;
3889
+ if (existsSync8(join11(dir, name))) return join11(dir, name);
3890
+ }
3891
+ return null;
3892
+ }
3893
+ function installerOf(file) {
3894
+ let real = file;
3895
+ try {
3896
+ real = realpathSync2(file);
3897
+ } catch {
3898
+ }
3899
+ if (real.includes(`${sep2}.volta${sep2}`)) return "volta";
3900
+ return /[\\/](Cellar|homebrew|linuxbrew)[\\/]/.test(real) ? "brew" : null;
3901
+ }
3902
+ async function versionOf(file, cwd) {
3903
+ try {
3904
+ const { stdout } = await execFileAsync3(file, ["--version"], { encoding: "utf8", timeout: 1e4, cwd });
3905
+ return stdout.match(/\d+\.\d+\.\d+/)?.[0] ?? null;
3906
+ } catch {
3907
+ return null;
3908
+ }
3909
+ }
3910
+ async function readNode(now) {
3911
+ const version = process.version.slice(1);
3912
+ const major = parseComparableVersion(version)?.major ?? 0;
3913
+ return { version, installer: installerOf(process.execPath), releases: await fetchNodeReleases(major, now) };
3914
+ }
3915
+ async function readPnpm(cwd) {
3916
+ const running = getEnv("npm_execpath");
3917
+ const file = findOnPath("pnpm", running ? [dirname7(running)] : []);
3918
+ const version = file && await versionOf(file, tmpdir());
3919
+ if (!file || !version) return null;
3920
+ const [published, advisories] = await Promise.all([fetchPublishTimes("pnpm", cwd), fetchPnpmAdvisories(version)]);
3921
+ return { version, installer: installerOf(file), published, advisories };
3922
+ }
3923
+ async function fetchOsvSeverities(module, version) {
3924
+ const answer = await fetchJson("https://api.osv.dev/v1/query", {
3925
+ method: "POST",
3926
+ body: JSON.stringify({ package: { name: module, ecosystem: "Go" }, version })
3927
+ });
3928
+ if (!answer) return null;
3929
+ const byGhsa = /* @__PURE__ */ new Map();
3930
+ for (const vuln of answer.vulns ?? []) {
3931
+ const id = [vuln.id, ...vuln.aliases ?? []].find((alias) => alias.startsWith("GHSA-")) ?? vuln.id;
3932
+ const severity = SEVERITIES.find((level) => level === vuln.database_specific?.severity?.toLowerCase());
3933
+ if (severity || !byGhsa.has(id)) byGhsa.set(id, severity ?? "moderate");
3934
+ }
3935
+ return [...byGhsa.values()];
3936
+ }
3937
+ async function readGh() {
3938
+ const file = findOnPath("gh");
3939
+ const version = file && await versionOf(file);
3940
+ if (!file || !version) return null;
3941
+ const [list, advisories] = await Promise.all([
3942
+ fetchJson("https://api.github.com/repos/cli/cli/releases?per_page=30"),
3943
+ fetchOsvSeverities("github.com/cli/cli/v2", version)
3944
+ ]);
3945
+ const releases = Array.isArray(list) ? list.flatMap((entry) => {
3946
+ const { tag_name, published_at } = entry;
3947
+ const publishedAt = Date.parse(published_at ?? "");
3948
+ return tag_name && !Number.isNaN(publishedAt) ? [{ version: tag_name.replace(/^v/, ""), publishedAt }] : [];
3949
+ }) : null;
3950
+ return { version, installer: installerOf(file), releases, advisories };
3951
+ }
3952
+ async function readLocalTools(cwd, now) {
3953
+ const [node, pnpm, gh] = await Promise.all([readNode(now), readPnpm(cwd), readGh()]);
3954
+ return { node, pnpm, gh };
3955
+ }
3956
+ function upgradeCommand(name, installer, target) {
3957
+ if (installer === "brew") return `brew upgrade ${name}`;
3958
+ if (!target) return "no fixed release older than the release age yet";
3959
+ if (installer === "volta") return `volta install ${name}@${target}`;
3960
+ return name === "pnpm" ? `pnpm self-update ${target}` : `update to ${target}`;
3961
+ }
3962
+ function toCheck(name, tool, missed, target, noun) {
3963
+ if (!missed)
3964
+ return { name, version: tool.version, severity: null, note: "not checked, no answer from the feed", fix: "" };
3965
+ if (missed.length === 0) return { name, version: tool.version, severity: null, note: "", fix: "" };
3966
+ const high = missed.filter((severity) => severity === "critical" || severity === "high").length;
3967
+ return {
3968
+ name,
3969
+ version: tool.version,
3970
+ severity: SEVERITIES.find((severity) => missed.includes(severity)) ?? null,
3971
+ note: `${plural(missed.length, ...noun)}${high > 0 && high < missed.length ? `, ${high} high` : ""}`,
3972
+ fix: upgradeCommand(name, tool.installer, target)
3973
+ };
3974
+ }
3975
+ function planLocalTools(tools, options) {
3976
+ const cutoff = options.now - options.releaseAgeMinutes * 60 * 1e3;
3977
+ const { node, pnpm, gh } = tools;
3978
+ const nodeTarget = node.releases ? newestAged(node.version, node.releases, cutoff) : void 0;
3979
+ const securityReleases = (node.releases ?? []).filter(
3980
+ (release) => release.security && nodeTarget && compareVersionStrings(release.version, node.version) > 0 && compareVersionStrings(release.version, nodeTarget.version) <= 0
3981
+ );
3982
+ const checks = [
3983
+ toCheck(
3984
+ "node",
3985
+ node,
3986
+ node.releases && securityReleases.map(() => "high"),
3987
+ nodeTarget?.version,
3988
+ ["security release", "security releases"]
3989
+ )
3990
+ ];
3991
+ if (pnpm) {
3992
+ const affecting = (version) => (pnpm.advisories ?? []).filter((advisory) => satisfiesComparators(version, advisory.vulnerable_versions));
3993
+ const fixed = Object.entries(pnpm.published ?? {}).map(([version, time]) => ({ version, publishedAt: Date.parse(time) })).filter(
3994
+ (release) => parseComparableVersion(release.version) && !isPrerelease(release.version) && release.publishedAt <= cutoff && compareVersionStrings(release.version, pnpm.version) > 0 && affecting(release.version).length === 0
3995
+ ).sort((a, b) => compareVersionStrings(b.version, a.version));
3996
+ const major = parseComparableVersion(pnpm.version)?.major;
3997
+ const target = fixed.find((release) => parseComparableVersion(release.version)?.major === major) ?? fixed[0];
3998
+ const missed = pnpm.advisories && pnpm.published && affecting(pnpm.version).map((advisory) => advisory.severity);
3999
+ checks.push(toCheck("pnpm", pnpm, missed, target?.version, ["advisory", "advisories"]));
4000
+ }
4001
+ if (gh) {
4002
+ const target = gh.releases ? newestAged(gh.version, gh.releases, cutoff) : void 0;
4003
+ checks.push(toCheck("gh", gh, gh.advisories, target?.version, ["advisory", "advisories"]));
4004
+ }
4005
+ return checks;
4006
+ }
4007
+
3344
4008
  // src/utils/workspace-yaml.ts
3345
4009
  import { isMap, isScalar, isSeq, parseDocument } from "yaml";
3346
4010
  var record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
@@ -3421,37 +4085,37 @@ function writeAuditSettings(content, next) {
3421
4085
 
3422
4086
  // src/services/audit-security.ts
3423
4087
  import { spawnSync as spawnSync2 } from "child_process";
3424
- import { cpSync, existsSync as existsSync7, mkdirSync as mkdirSync2, mkdtempSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "fs";
3425
- import { tmpdir } from "os";
3426
- import { dirname as dirname6, join as join11, relative as relative2 } from "path";
4088
+ import { cpSync, existsSync as existsSync9, mkdirSync as mkdirSync2, mkdtempSync, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
4089
+ import { tmpdir as tmpdir2 } from "os";
4090
+ import { dirname as dirname8, join as join12, relative as relative2 } from "path";
3427
4091
  import { confirm, select as select2 } from "@inquirer/prompts";
3428
4092
  var WORKSPACE_FILE = "pnpm-workspace.yaml";
3429
4093
  var LOCK_FILE = "pnpm-lock.yaml";
3430
4094
  var RESOLUTION_FILES = ["package.json", LOCK_FILE, ".npmrc", ".pnpmfile.cjs"];
3431
- var readOr = (path, fallback = "") => existsSync7(path) ? readFileSync3(path, "utf8") : fallback;
4095
+ var readOr = (path, fallback = "") => existsSync9(path) ? readFileSync3(path, "utf8") : fallback;
3432
4096
  var count2 = (n, noun, plural2 = `${noun}s`) => `${n} ${n === 1 ? noun : plural2}`;
3433
4097
  var isBreaking = (item) => item.remedy.kind !== "none" && item.remedy.breaking;
3434
4098
  var needsDecision = (item) => item.standing === "new" && (item.remedy.kind !== "override" || item.remedy.breaking || item.direct);
3435
4099
  var sameEntries = (a, b) => Object.keys(a).length === Object.keys(b).length && Object.entries(a).every(([key, value]) => b[key] === value);
3436
4100
  async function createStaging(forkPath, workspaceYaml) {
3437
- const staging = mkdtempSync(join11(tmpdir(), "cella-audit-"));
4101
+ const staging = mkdtempSync(join12(tmpdir2(), "cella-audit-"));
3438
4102
  const projects = await runPnpmJson(["ls", "-r", "--depth", "-1", "--json"], forkPath) ?? [];
3439
4103
  const files = [
3440
4104
  ...RESOLUTION_FILES,
3441
4105
  ...readPatchFiles(workspaceYaml),
3442
- ...projects.map((project) => join11(relative2(forkPath, project.path), "package.json"))
4106
+ ...projects.map((project) => join12(relative2(forkPath, project.path), "package.json"))
3443
4107
  ];
3444
4108
  for (const file of new Set(files)) {
3445
- if (file.startsWith("..") || !existsSync7(join11(forkPath, file))) continue;
3446
- mkdirSync2(dirname6(join11(staging, file)), { recursive: true });
3447
- cpSync(join11(forkPath, file), join11(staging, file));
4109
+ if (file.startsWith("..") || !existsSync9(join12(forkPath, file))) continue;
4110
+ mkdirSync2(dirname8(join12(staging, file)), { recursive: true });
4111
+ cpSync(join12(forkPath, file), join12(staging, file));
3448
4112
  }
3449
4113
  return staging;
3450
4114
  }
3451
4115
  async function resolveStaging(staging, workspaceYaml, settings) {
3452
- writeFileSync2(join11(staging, WORKSPACE_FILE), writeAuditSettings(workspaceYaml, settings));
4116
+ writeFileSync2(join12(staging, WORKSPACE_FILE), writeAuditSettings(workspaceYaml, settings));
3453
4117
  const result = await runPnpm(["install", "--lockfile-only", "--no-frozen-lockfile", "--ignore-scripts"], staging);
3454
- return result.ok ? { lock: readOr(join11(staging, LOCK_FILE)) } : { error: `${result.stdout}${result.stderr}`.trim() };
4118
+ return result.ok ? { lock: readOr(join12(staging, LOCK_FILE)) } : { error: `${result.stdout}${result.stderr}`.trim() };
3455
4119
  }
3456
4120
  function lockedVersions(lock, name) {
3457
4121
  const escaped = name.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
@@ -3555,8 +4219,8 @@ function changeLine(label, before, after) {
3555
4219
  var NO_EDITS = { manifests: {}, files: {} };
3556
4220
  async function runSecurityPass(forkPath, options) {
3557
4221
  const { releaseAgeMinutes, edits = NO_EDITS } = options;
3558
- const workspaceYaml = readOr(join11(forkPath, WORKSPACE_FILE));
3559
- const workspaceLock = readOr(join11(forkPath, LOCK_FILE));
4222
+ const workspaceYaml = readOr(join12(forkPath, WORKSPACE_FILE));
4223
+ const workspaceLock = readOr(join12(forkPath, LOCK_FILE));
3560
4224
  let previous;
3561
4225
  try {
3562
4226
  previous = readAuditSettings(workspaceYaml);
@@ -3582,7 +4246,7 @@ async function runSecurityPass(forkPath, options) {
3582
4246
  };
3583
4247
  const staging = await createStaging(forkPath, workspaceYaml);
3584
4248
  try {
3585
- for (const [file, content] of Object.entries(edits.manifests)) writeFileSync2(join11(staging, file), content);
4249
+ for (const [file, content] of Object.entries(edits.manifests)) writeFileSync2(join12(staging, file), content);
3586
4250
  const bare = await resolveStaging(staging, workspaceYaml, { ...previous, overrides: lasting, ignoredGhsas: [] });
3587
4251
  if ("error" in bare) {
3588
4252
  const without = updatesManifests ? "with the selected updates" : "without its audit overrides";
@@ -3634,7 +4298,7 @@ async function runSecurityPass(forkPath, options) {
3634
4298
  const settingsChanged = nextYaml !== workspaceYaml;
3635
4299
  const otherFiles = Object.keys(edits.files);
3636
4300
  const writeFiles = () => {
3637
- for (const [file, content] of Object.entries(edits.files)) writeFileSync2(join11(forkPath, file), content);
4301
+ for (const [file, content] of Object.entries(edits.files)) writeFileSync2(join12(forkPath, file), content);
3638
4302
  if (otherFiles.length > 0) console.info(`${checkMark} ${otherFiles.join(", ")} updated`);
3639
4303
  };
3640
4304
  if (!settingsChanged && !updatesManifests) {
@@ -3654,7 +4318,7 @@ async function runSecurityPass(forkPath, options) {
3654
4318
  };
3655
4319
  if (!updatesManifests && sameEntries(next.overrides, previous.overrides)) {
3656
4320
  writeFiles();
3657
- writeFileSync2(join11(forkPath, WORKSPACE_FILE), nextYaml);
4321
+ writeFileSync2(join12(forkPath, WORKSPACE_FILE), nextYaml);
3658
4322
  console.info(`${checkMark} ${WORKSPACE_FILE} updated ${colors_default.dim("\xB7 nothing to install")}`);
3659
4323
  printOpen(open);
3660
4324
  summarize();
@@ -3665,14 +4329,14 @@ async function runSecurityPass(forkPath, options) {
3665
4329
  stagingSpinner.stop();
3666
4330
  if ("error" in staged) return fail("could not resolve the workspace with the new settings", staged.error);
3667
4331
  const written = { ...edits.files, ...edits.manifests, [WORKSPACE_FILE]: nextYaml, [LOCK_FILE]: staged.lock };
3668
- const before = Object.fromEntries(Object.keys(written).map((file) => [file, readOr(join11(forkPath, file))]));
4332
+ const before = Object.fromEntries(Object.keys(written).map((file) => [file, readOr(join12(forkPath, file))]));
3669
4333
  before[WORKSPACE_FILE] = workspaceYaml;
3670
4334
  before[LOCK_FILE] = workspaceLock;
3671
- for (const [file, content] of Object.entries(written)) writeFileSync2(join11(forkPath, file), content);
4335
+ for (const [file, content] of Object.entries(written)) writeFileSync2(join12(forkPath, file), content);
3672
4336
  const install = spawnSync2("pnpm", ["install"], { cwd: forkPath, stdio: "inherit" });
3673
4337
  console.info();
3674
4338
  if (install.status !== 0) {
3675
- for (const [file, content] of Object.entries(before)) writeFileSync2(join11(forkPath, file), content);
4339
+ for (const [file, content] of Object.entries(before)) writeFileSync2(join12(forkPath, file), content);
3676
4340
  console.error(`${colors_default.red("\u2717")} pnpm install failed (exit code ${install.status})`);
3677
4341
  console.info(colors_default.dim(` ${Object.keys(before).join(", ")} are back as they were; run pnpm install to match`));
3678
4342
  console.info();
@@ -3686,7 +4350,7 @@ async function runSecurityPass(forkPath, options) {
3686
4350
  return true;
3687
4351
  } finally {
3688
4352
  spinner.stop();
3689
- rmSync(staging, { recursive: true, force: true });
4353
+ rmSync2(staging, { recursive: true, force: true });
3690
4354
  }
3691
4355
  }
3692
4356
  function printOpen(modules) {
@@ -3741,7 +4405,7 @@ function createDependentPkgReader() {
3741
4405
  let cached = cache.get(location);
3742
4406
  if (cached === void 0) {
3743
4407
  try {
3744
- cached = JSON.parse(readFileSync4(join12(location, "package.json"), "utf8"));
4408
+ cached = JSON.parse(readFileSync4(join13(location, "package.json"), "utf8"));
3745
4409
  } catch {
3746
4410
  cached = null;
3747
4411
  }
@@ -3756,11 +4420,12 @@ async function runAudit(config) {
3756
4420
  try {
3757
4421
  const pins = await readRuntimePins(forkPath);
3758
4422
  const now = Date.now();
3759
- const [outdated, auditResult, releaseAgeMinutes, feeds] = await Promise.all([
4423
+ const [outdated, auditResult, releaseAgeMinutes, feeds, machine] = await Promise.all([
3760
4424
  runPnpmJson(["-r", "outdated", "--json", "--long"], forkPath),
3761
4425
  runPnpmAudit(forkPath),
3762
4426
  readReleaseAgeMinutes(forkPath),
3763
- fetchRuntimeFeeds(pins, forkPath, now)
4427
+ fetchRuntimeFeeds(pins, forkPath, now),
4428
+ readLocalTools(forkPath, now)
3764
4429
  ]);
3765
4430
  if (!outdated) {
3766
4431
  spinnerFail("pnpm outdated gave no answer: run pnpm -r outdated to see why");
@@ -3768,6 +4433,7 @@ async function runAudit(config) {
3768
4433
  return;
3769
4434
  }
3770
4435
  const runtimes = planRuntimeUpdates(pins, feeds, { releaseAgeMinutes, now });
4436
+ const localTools = planLocalTools(machine, { releaseAgeMinutes, now });
3771
4437
  const rows = [
3772
4438
  ...packageRows(outdated, auditResult ? advisoriesFromAudit(auditResult) : []),
3773
4439
  ...runtimes.updates.map(runtimeRow)
@@ -3776,6 +4442,7 @@ async function runAudit(config) {
3776
4442
  spinnerSuccess("all packages and runtimes are up to date");
3777
4443
  printNotes(forkPath, runtimes.unchecked);
3778
4444
  console.info();
4445
+ printLocalTools(localTools);
3779
4446
  await runSecurityPass(forkPath, { apply: !config.list, releaseAgeMinutes });
3780
4447
  return;
3781
4448
  }
@@ -3783,6 +4450,7 @@ async function runAudit(config) {
3783
4450
  printOutdatedResults(rows, forkPath);
3784
4451
  printNotes(forkPath, runtimes.unchecked);
3785
4452
  console.info();
4453
+ printLocalTools(localTools);
3786
4454
  if (config.list) {
3787
4455
  await runSecurityPass(forkPath, { apply: false, releaseAgeMinutes });
3788
4456
  return;
@@ -3798,7 +4466,8 @@ async function runAudit(config) {
3798
4466
  function packageRows(outdated, advisories) {
3799
4467
  const severityOf = (name) => SEVERITIES.find((severity) => advisories.some((a) => a.module === name && a.severity === severity)) ?? null;
3800
4468
  const readDependentPkg = createDependentPkgReader();
3801
- const rows = Object.entries(outdated).map(([name, pkg]) => {
4469
+ const rows = Object.entries(outdated).map(([key, pkg]) => {
4470
+ const name = outdatedPackageName(key);
3802
4471
  const repoUrl = getRepoUrl(pkg.latestManifest?.repository?.url);
3803
4472
  const current = pkg.current ?? "missing";
3804
4473
  const pinnedIn = [];
@@ -3809,6 +4478,7 @@ function packageRows(outdated, advisories) {
3809
4478
  }
3810
4479
  }
3811
4480
  return {
4481
+ key,
3812
4482
  name,
3813
4483
  current,
3814
4484
  latest: pkg.latest,
@@ -3825,6 +4495,7 @@ function packageRows(outdated, advisories) {
3825
4495
  }
3826
4496
  function runtimeRow(update) {
3827
4497
  return {
4498
+ key: `runtime:${update.file}:${update.name}`,
3828
4499
  name: update.name,
3829
4500
  current: update.current,
3830
4501
  latest: update.target,
@@ -3838,7 +4509,6 @@ function runtimeRow(update) {
3838
4509
  runtime: update
3839
4510
  };
3840
4511
  }
3841
- var rowKey = (row) => row.runtime ? `runtime:${row.runtime.file}:${row.name}` : row.name;
3842
4512
  var isRed = (row) => row.severity === "critical" || row.severity === "high";
3843
4513
  var inText = (row) => row.runtime ? pinLocation(row.runtime.file) : formatDependents(row.dependentPackages.map((d) => d.name));
3844
4514
  function printOutdatedResults(rows, forkPath) {
@@ -3882,7 +4552,7 @@ function printOutdatedResults(rows, forkPath) {
3882
4552
  const dependentMap = /* @__PURE__ */ new Map();
3883
4553
  for (const pkg of rows) {
3884
4554
  for (const { name, location } of pkg.dependentPackages) {
3885
- const dependentName = name || basename4(location);
4555
+ const dependentName = name || basename5(location);
3886
4556
  const existing = dependentMap.get(dependentName);
3887
4557
  if (existing) {
3888
4558
  existing.count++;
@@ -3894,7 +4564,7 @@ function printOutdatedResults(rows, forkPath) {
3894
4564
  const sortedDependents = [...dependentMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
3895
4565
  const maxDependentNameLen = Math.max(0, ...sortedDependents.map(([name]) => name.length));
3896
4566
  for (const [dependentName, { location, count: count3 }] of sortedDependents) {
3897
- const packageJsonPath2 = join12(location, "package.json");
4567
+ const packageJsonPath2 = join13(location, "package.json");
3898
4568
  const relativePath = relative3(forkPath, packageJsonPath2);
3899
4569
  const paddedName = dependentName.padEnd(maxDependentNameLen);
3900
4570
  console.info(
@@ -3905,7 +4575,7 @@ function printOutdatedResults(rows, forkPath) {
3905
4575
  function printNotes(forkPath, unchecked) {
3906
4576
  let held = [];
3907
4577
  try {
3908
- const updateConfig = readTopLevel(readFileSync4(join12(forkPath, "pnpm-workspace.yaml"), "utf8"), "updateConfig");
4578
+ const updateConfig = readTopLevel(readFileSync4(join13(forkPath, "pnpm-workspace.yaml"), "utf8"), "updateConfig");
3909
4579
  const ignored = updateConfig?.ignoreDependencies;
3910
4580
  held = Array.isArray(ignored) ? ignored.map(String) : [];
3911
4581
  } catch {
@@ -3914,6 +4584,16 @@ function printNotes(forkPath, unchecked) {
3914
4584
  if (unchecked.length > 0)
3915
4585
  console.info(` ${colors_default.dim(`not checked, no answer from the feed: ${unchecked.join(", ")}`)}`);
3916
4586
  }
4587
+ function printLocalTools(checks) {
4588
+ const width = Math.max(...checks.map((tool) => `${tool.name} ${tool.version}`.length));
4589
+ console.info(colors_default.bold("this machine"));
4590
+ for (const tool of checks) {
4591
+ const label = `${tool.name} ${tool.version}`.padEnd(width);
4592
+ const detail = tool.severity ? `${tool.note} ${colors_default.dim("\xB7")} ${tool.fix}` : colors_default.dim(tool.note || "no missing security fixes");
4593
+ console.info(`${tool.severity ? getVulnIcon(tool.severity) : " "} ${label} ${detail}`);
4594
+ }
4595
+ console.info();
4596
+ }
3917
4597
  function buildUpdateChoices(rows) {
3918
4598
  const makeChoice = (row) => {
3919
4599
  const devTag = row.isDev ? colors_default.dim(" (dev)") : "";
@@ -3925,7 +4605,7 @@ function buildUpdateChoices(rows) {
3925
4605
  ].filter(Boolean);
3926
4606
  return {
3927
4607
  name: `${row.name}${devTag} ${version}${notes.length > 0 ? ` ${notes.join(" ")}` : ""}`,
3928
- value: rowKey(row),
4608
+ value: row.key,
3929
4609
  checked: isRed(row)
3930
4610
  };
3931
4611
  };
@@ -3985,17 +4665,17 @@ async function planEdits(forkPath, rows, selected, nodeImageSuffixes) {
3985
4665
  const summary = /* @__PURE__ */ new Map();
3986
4666
  const bumped = [];
3987
4667
  const edit = (file, label, item, resolves, change) => {
3988
- const entry = contents.get(file) ?? { content: readFileSync4(join12(forkPath, file), "utf8"), resolves: false };
4668
+ const entry = contents.get(file) ?? { content: readFileSync4(join13(forkPath, file), "utf8"), resolves: false };
3989
4669
  const next = change(entry.content);
3990
4670
  if (next === entry.content) return;
3991
4671
  contents.set(file, { content: next, resolves: entry.resolves || resolves });
3992
4672
  summary.set(label, [...summary.get(label) ?? [], item]);
3993
4673
  };
3994
- const chosen = rows.filter((row) => selected.includes(rowKey(row)));
4674
+ const chosen = rows.filter((row) => selected.includes(row.key));
3995
4675
  for (const row of chosen) {
3996
4676
  if (row.runtime) continue;
3997
4677
  for (const { name: dependent, location } of row.dependentPackages) {
3998
- const file = relative3(forkPath, join12(location, "package.json"));
4678
+ const file = relative3(forkPath, join13(location, "package.json"));
3999
4679
  edit(
4000
4680
  file,
4001
4681
  dependent || file,
@@ -4068,8 +4748,8 @@ import {
4068
4748
  import { select as select3 } from "@inquirer/prompts";
4069
4749
 
4070
4750
  // src/services/contrib-core.ts
4071
- import { rmSync as rmSync2 } from "fs";
4072
- import { dirname as dirname7, join as join13, 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";
4073
4753
  async function listFilesAtRef(repoPath, ref) {
4074
4754
  const raw = await git(["ls-tree", "-r", "--name-only", ref], repoPath);
4075
4755
  return new Set(raw.split("\n").filter(Boolean));
@@ -4085,10 +4765,10 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
4085
4765
  const baseFiles = await listFilesAtRef(repoPath, baseRef);
4086
4766
  const baseDirs = /* @__PURE__ */ new Set(["."]);
4087
4767
  for (const f of baseFiles) {
4088
- let dir = dirname7(f);
4768
+ let dir = dirname9(f);
4089
4769
  while (dir !== ".") {
4090
4770
  baseDirs.add(dir);
4091
- dir = dirname7(dir);
4771
+ dir = dirname9(dir);
4092
4772
  }
4093
4773
  }
4094
4774
  const files = [];
@@ -4101,7 +4781,7 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
4101
4781
  if (inCella && !inFork) {
4102
4782
  kind = "deleted";
4103
4783
  } else if (!inCella && inFork) {
4104
- if (!baseDirs.has(dirname7(file.path))) continue;
4784
+ if (!baseDirs.has(dirname9(file.path))) continue;
4105
4785
  kind = "created";
4106
4786
  } else {
4107
4787
  kind = "modified";
@@ -4125,7 +4805,7 @@ async function readBlobInfo(repoPath, ref, filePath) {
4125
4805
  async function buildContribBranch(repoPath, baseRef, forkRef, files, forkName) {
4126
4806
  const branch = `contrib/${forkName}`;
4127
4807
  const gitDir = resolve4(repoPath, await git(["rev-parse", "--git-dir"], repoPath));
4128
- const tmpIndex = join13(gitDir, `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
4808
+ const tmpIndex = join14(gitDir, `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
4129
4809
  const indexEnv = { GIT_INDEX_FILE: tmpIndex };
4130
4810
  try {
4131
4811
  await git(["read-tree", baseRef], repoPath, { env: indexEnv });
@@ -4155,25 +4835,25 @@ ${commitBody}`;
4155
4835
  await git(["update-ref", `refs/heads/${branch}`, commitHash], repoPath);
4156
4836
  return { branch, appliedFiles };
4157
4837
  } finally {
4158
- rmSync2(tmpIndex, { force: true });
4838
+ rmSync3(tmpIndex, { force: true });
4159
4839
  }
4160
4840
  }
4161
4841
 
4162
4842
  // src/services/fork-utils.ts
4163
- import { existsSync as existsSync8 } from "fs";
4164
- import { dirname as dirname8, resolve as resolve5 } from "path";
4843
+ import { existsSync as existsSync10 } from "fs";
4844
+ import { dirname as dirname10, resolve as resolve5 } from "path";
4165
4845
  async function resolveForkBasePath(repoPath) {
4166
4846
  const commonDir = await git(["rev-parse", "--git-common-dir"], repoPath, { ignoreErrors: true });
4167
- return commonDir ? dirname8(resolve5(repoPath, commonDir)) : repoPath;
4847
+ return commonDir ? dirname10(resolve5(repoPath, commonDir)) : repoPath;
4168
4848
  }
4169
4849
  function validateForkPath(fork, basePath, requireConfig = false) {
4170
4850
  const resolvedPath = resolve5(basePath, fork.localPath);
4171
4851
  if (!resolvedPath.startsWith(resolve5(basePath, ".."))) {
4172
4852
  return { fork, resolvedPath, valid: false, error: "path resolves outside parent directory" };
4173
4853
  }
4174
- if (!existsSync8(resolvedPath)) return { fork, resolvedPath, valid: false, error: "path does not exist" };
4175
- if (!existsSync8(`${resolvedPath}/.git`)) return { fork, resolvedPath, valid: false, error: "not a git repository" };
4176
- if (requireConfig && !existsSync8(`${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}`)) {
4177
4857
  return { fork, resolvedPath, valid: false, error: `missing ${CONFIG_FILE}` };
4178
4858
  }
4179
4859
  return { fork, resolvedPath, valid: true };
@@ -4479,7 +5159,7 @@ async function runContributions(config) {
4479
5159
 
4480
5160
  // src/services/forks.ts
4481
5161
  import { resolve as resolve6 } from "path";
4482
- import process8 from "process";
5162
+ import process10 from "process";
4483
5163
  import { Separator as Separator2, select as select5 } from "@inquirer/prompts";
4484
5164
 
4485
5165
  // src/services/sync.ts
@@ -4516,6 +5196,28 @@ function listOpenSyncPrs(cwd, prefix) {
4516
5196
  }
4517
5197
  return parsed.filter((pr) => pr.headRefName.startsWith(`${prefix}/`)).sort((a, b) => b.number - a.number);
4518
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
+ }
4519
5221
  function mergePrSquash(cwd, ref, options = {}) {
4520
5222
  return runGh(cwd, ["pr", "merge", String(ref), "--squash", ...options.deleteBranch ? ["--delete-branch"] : []]);
4521
5223
  }
@@ -4524,12 +5226,12 @@ function closePr(cwd, ref) {
4524
5226
  }
4525
5227
 
4526
5228
  // src/services/packages.ts
4527
- import { existsSync as existsSync10, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
4528
- import { join as join15 } from "path";
5229
+ import { existsSync as existsSync12, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
5230
+ import { join as join16 } from "path";
4529
5231
 
4530
5232
  // src/services/package-usage.ts
4531
- import { existsSync as existsSync9, readFileSync as readFileSync5, statSync } from "fs";
4532
- import { join as join14 } from "path";
5233
+ import { existsSync as existsSync11, readFileSync as readFileSync5, statSync } from "fs";
5234
+ import { join as join15 } from "path";
4533
5235
  var MAX_FILE_BYTES = 1024 * 1024;
4534
5236
  function escapeRegExp(value) {
4535
5237
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -4545,7 +5247,7 @@ function readSearchable(filePath) {
4545
5247
  }
4546
5248
  function fileSource(forkPath, path) {
4547
5249
  let text;
4548
- return { path, label: path, text: () => text ??= readSearchable(join14(forkPath, path)) };
5250
+ return { path, label: path, text: () => text ??= readSearchable(join15(forkPath, path)) };
4549
5251
  }
4550
5252
  async function readScriptsAtRef(forkPath, ref, path) {
4551
5253
  try {
@@ -4557,7 +5259,7 @@ async function readScriptsAtRef(forkPath, ref, path) {
4557
5259
  async function scriptSources(forkPath, upstreamRef, baseRef, path) {
4558
5260
  let forkScripts;
4559
5261
  try {
4560
- forkScripts = JSON.parse(readFileSync5(join14(forkPath, path), "utf8")).scripts ?? {};
5262
+ forkScripts = JSON.parse(readFileSync5(join15(forkPath, path), "utf8")).scripts ?? {};
4561
5263
  } catch {
4562
5264
  return [];
4563
5265
  }
@@ -4567,9 +5269,9 @@ async function scriptSources(forkPath, upstreamRef, baseRef, path) {
4567
5269
  }
4568
5270
  function binNames(forkPath, location, name) {
4569
5271
  const bareName = name.split("/").pop();
4570
- for (const dir of [join14(forkPath, location), forkPath]) {
4571
- const manifest = join14(dir, "node_modules", name, "package.json");
4572
- if (!existsSync9(manifest)) continue;
5272
+ for (const dir of [join15(forkPath, location), forkPath]) {
5273
+ const manifest = join15(dir, "node_modules", name, "package.json");
5274
+ if (!existsSync11(manifest)) continue;
4573
5275
  try {
4574
5276
  const { bin } = JSON.parse(readFileSync5(manifest, "utf8"));
4575
5277
  if (typeof bin === "string") return [bareName];
@@ -4750,7 +5452,7 @@ function setTopLevelKey(pkg, key, value) {
4750
5452
  Object.assign(pkg, Object.fromEntries(entries));
4751
5453
  }
4752
5454
  function readPackageJson(filePath) {
4753
- if (!existsSync10(filePath)) return null;
5455
+ if (!existsSync12(filePath)) return null;
4754
5456
  try {
4755
5457
  return JSON.parse(readFileSync6(filePath, "utf8"));
4756
5458
  } catch (error) {
@@ -4784,13 +5486,13 @@ async function syncPackageJson(config, baseRef, usage, relativePath, keysToSync)
4784
5486
  const changes = [];
4785
5487
  const kept = [];
4786
5488
  const pkgRelPath = packageJsonPath(relativePath);
4787
- const pkgPath = join15(forkPath, pkgRelPath);
5489
+ const pkgPath = join16(forkPath, pkgRelPath);
4788
5490
  const forkPkg = readPackageJson(pkgPath);
4789
5491
  const upstream = await readPackageJsonAtRef(forkPath, upstreamRef, relativePath);
4790
5492
  const basePkg = baseRef ? (await readPackageJsonAtRef(forkPath, baseRef, relativePath))?.data : void 0;
4791
5493
  if (!upstream) return { updated: false, changes, kept };
4792
5494
  if (!forkPkg) {
4793
- const workspaceArrived = relativePath !== "" && existsSync10(join15(forkPath, relativePath));
5495
+ const workspaceArrived = relativePath !== "" && existsSync12(join16(forkPath, relativePath));
4794
5496
  if (basePkg || !workspaceArrived || isIgnored(pkgRelPath, config)) return { updated: false, changes, kept };
4795
5497
  writeFileSync3(pkgPath, upstream.raw.endsWith("\n") ? upstream.raw : `${upstream.raw}
4796
5498
  `, "utf8");
@@ -4996,9 +5698,43 @@ async function runSync(config, options) {
4996
5698
  const staged = result.files.some((file) => SYNC_APPLIED_STATUSES.includes(file.status));
4997
5699
  const stagedBranch = options?.stagedBranch && result.conflicts.length === 0 && staged ? options.stagedBranch : void 0;
4998
5700
  printSyncComplete(result, { stagedBranch });
4999
- await reportReviewPage(config.forkPath, result, "sync");
5000
- await printMigrationNotesLine(config, result);
5001
- 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);
5002
5738
  }
5003
5739
  var SYNC_PR_TITLE = "chore: sync upstream cella";
5004
5740
  async function buildSyncCommitMessage(forkPath, upstreamRef) {
@@ -5013,7 +5749,7 @@ function qualifyPrRefs(subject, repoSlug) {
5013
5749
  return subject.replace(/(^|[^\w/])#(\d+)\b/g, `$1${repoSlug}#$2`);
5014
5750
  }
5015
5751
  function buildSyncPrBody(input) {
5016
- const { repoSlug, version, fromSha, toSha, commits, totalCount, notes } = input;
5752
+ const { repoSlug, version, fromSha, toSha, commits, totalCount, alignments } = input;
5017
5753
  const githubUrl = repoSlug ? `https://github.com/${repoSlug}` : void 0;
5018
5754
  const short = (sha) => sha.slice(0, 7);
5019
5755
  const commitRef = (sha) => githubUrl ? `[\`${short(sha)}\`](${githubUrl}/commit/${sha})` : `\`${short(sha)}\``;
@@ -5028,17 +5764,19 @@ function buildSyncPrBody(input) {
5028
5764
  lines.push(`- ${commitRef(commit.hash)} ${qualifyPrRefs(commit.message, repoSlug)}`);
5029
5765
  }
5030
5766
  }
5031
- if (notes && notes.total > 0) {
5032
- const { total, open } = notes;
5033
- 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.`);
5034
5770
  else {
5035
5771
  lines.push(
5036
5772
  "",
5037
- `**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\`):`,
5038
5774
  ""
5039
5775
  );
5040
- for (const note of open)
5041
- 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
+ );
5042
5780
  }
5043
5781
  }
5044
5782
  return `${lines.join("\n")}
@@ -5052,17 +5790,25 @@ async function buildSyncPrBodyForBranch(forkPath, base) {
5052
5790
  const version = manifest.upstream.release ?? await readPackageVersionAtRef(forkPath, toSha).catch(() => null);
5053
5791
  const totalCount = fromSha ? await countCommitsBetween(forkPath, fromSha, toSha).catch(() => 0) : 0;
5054
5792
  const commits = fromSha && totalCount > 0 ? await listCommitsBetween(forkPath, fromSha, toSha, COMMIT_LIST_MAX) : [];
5055
- const notes = await readPrBodyNotes(forkPath, toSha, manifest.upstream.repo).catch(() => void 0);
5056
- 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
+ });
5057
5803
  }
5058
- async function readPrBodyNotes(forkPath, toSha, repoSlug) {
5804
+ async function readPrBodyAlignments(forkPath, toSha, repoSlug) {
5059
5805
  const githubUrl = repoSlug ? `https://github.com/${repoSlug}` : void 0;
5060
- const [ids, pending] = await Promise.all([listNoteIds(forkPath, toSha), readPending(forkPath)]);
5806
+ const [ids, pending] = await Promise.all([listAlignmentIds(forkPath, toSha), readPending(forkPath)]);
5061
5807
  const open = await Promise.all(
5062
5808
  pending.map(async (id) => ({
5063
5809
  id,
5064
- title: (await readNote(forkPath, toSha, id))?.title ?? id,
5065
- url: noteUrl(githubUrl, toSha, id)
5810
+ title: (await readAlignment(forkPath, toSha, id))?.title ?? id,
5811
+ url: alignmentUrl(githubUrl, toSha, id)
5066
5812
  }))
5067
5813
  );
5068
5814
  return { total: ids.length, open };
@@ -5103,16 +5849,11 @@ async function shipSyncBranch(config, branch) {
5103
5849
  }
5104
5850
  if (ghAvailable()) {
5105
5851
  console.info(colors_default.dim("opening a pull request..."));
5106
- const prBody = await buildSyncPrBodyForBranch(forkPath, cutBase);
5107
- const bodyArgs = prBody ? ["--body", prBody] : ["--fill"];
5108
- const pr = spawnSync4("gh", ["pr", "create", "--base", base, "--head", branch, "--title", prTitle, ...bodyArgs], {
5109
- cwd: forkPath,
5110
- encoding: "utf8",
5111
- stdio: ["ignore", "pipe", "pipe"]
5112
- });
5113
- prUrl = `${pr.stdout ?? ""}
5114
- ${pr.stderr ?? ""}`.match(/https?:\/\/\S+/)?.[0];
5115
- 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;
5116
5857
  if (!prOpened) {
5117
5858
  console.info(colors_default.yellow("could not open the PR automatically (it may already exist). open it with:"));
5118
5859
  if (prUrl) console.info(colors_default.dim(` ${prUrl}`));
@@ -5131,7 +5872,15 @@ ${pr.stderr ?? ""}`.match(/https?:\/\/\S+/)?.[0];
5131
5872
  console.info(`${checkMark} sync branch pushed`);
5132
5873
  console.info(colors_default.dim(` '${branch}' is on origin, ${position}`));
5133
5874
  }
5134
- 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));
5135
5884
  }
5136
5885
  function finalizeWorkspace(forkPath) {
5137
5886
  for (const args of [["install"], ["check"]]) {
@@ -5170,40 +5919,49 @@ async function runSyncCycle(config) {
5170
5919
  if (config.settings.syncWithPackages !== false) {
5171
5920
  await runPackages(config, { conflictedFiles: result.conflicts });
5172
5921
  }
5173
- if (result.conflicts.length > 0) return { status: "conflicts", branch };
5922
+ if (result.conflicts.length > 0) return { status: "conflicts", branch, result };
5174
5923
  if (!await mergeInProgress(forkPath)) {
5175
5924
  await discardTemporarySyncBranch(forkPath, branch);
5176
- return { status: "noop" };
5925
+ return { status: "noop", result };
5177
5926
  }
5178
- return { status: "staged", branch };
5927
+ return { status: "staged", branch, result };
5179
5928
  }
5180
- async function commitSyncMerge(config, branch) {
5929
+ async function commitSyncMerge(config, branch, run) {
5181
5930
  const { forkPath } = config;
5182
5931
  const conflicts = await getConflictedFiles(forkPath);
5183
5932
  console.info();
5184
5933
  if (conflicts.length > 0) {
5185
- console.info(colors_default.yellow(`${conflicts.length} file(s) still conflict on '${branch}':`));
5186
- for (const file of conflicts) console.info(colors_default.dim(` ${file}`));
5187
- 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));
5188
5936
  return;
5189
5937
  }
5190
5938
  await stageAll(forkPath);
5191
5939
  if (!finalizeWorkspace(forkPath)) {
5192
5940
  console.info();
5193
- console.info(
5194
- colors_default.yellow("`pnpm install`/`pnpm check` failed. fix the issues, then re-run `pnpm cella sync` to finish.")
5195
- );
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
+ ]);
5196
5946
  return;
5197
5947
  }
5198
5948
  const message = await buildSyncCommitMessage(forkPath, "MERGE_HEAD");
5199
5949
  await stageAll(forkPath);
5200
5950
  await commitSquash(forkPath, message);
5201
5951
  console.info();
5202
- console.info(colors_default.green(`committed the sync on '${branch}' as '${message}'.`));
5203
- console.info(colors_default.dim(`staying on '${branch}' without pushing.`));
5204
- console.info(colors_default.dim(" run drift triage (`pnpm cella analyze`), commit any follow-ups, then:"));
5205
- console.info(colors_default.dim(" pnpm cella sync (pushes the branch and opens the PR)"));
5206
- 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));
5207
5965
  }
5208
5966
  async function mergeOpenSyncPrs(config, open) {
5209
5967
  const { forkPath, settings } = config;
@@ -5287,25 +6045,26 @@ commit them first (\`git commit --amend --no-edit\` or a new commit).`
5287
6045
  }
5288
6046
  if (await guardAgainstOpenSyncPr(config) === "cancel") return;
5289
6047
  const outcome = await runSyncCycle(config);
5290
- console.info();
5291
6048
  if (outcome.status === "noop") {
6049
+ console.info();
5292
6050
  if (outcome.behind) {
5293
6051
  console.info(colors_default.green("nothing to sync: the last sync already went past this upstream point."));
5294
6052
  for (const line of outcome.behind.split("\n")) console.info(colors_default.dim(` ${line}`));
5295
6053
  } else {
5296
6054
  console.info(colors_default.green("already up to date with upstream, nothing to sync."));
5297
6055
  }
6056
+ const align = await syncAlignmentsStep(config, outcome.result);
6057
+ if (align) printNextSteps([align]);
5298
6058
  return;
5299
6059
  }
5300
6060
  const { temporaryBranch } = outcome.branch;
5301
6061
  if (outcome.status === "conflicts") {
5302
- console.info(`${warningMark} ${colors_default.yellow(`conflicts on '${temporaryBranch}'. Resolve and stage them, then:`)}`);
5303
- console.info(colors_default.dim(" pnpm cella sync"));
5304
- console.info(colors_default.dim(" rerun commits the sync and stops for drift triage; a further rerun ships (push + PR)."));
5305
- 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));
5306
6065
  return;
5307
6066
  }
5308
- await commitSyncMerge(config, temporaryBranch);
6067
+ await commitSyncMerge(config, temporaryBranch, outcome.result);
5309
6068
  }
5310
6069
 
5311
6070
  // src/services/forks.ts
@@ -5402,7 +6161,7 @@ async function runForks(config) {
5402
6161
  loop: false
5403
6162
  });
5404
6163
  if (selectedPath === "_exit") {
5405
- process8.exit(0);
6164
+ process10.exit(0);
5406
6165
  }
5407
6166
  const resolvedForkPath = resolve6(forkBasePath, selectedPath);
5408
6167
  const selectedFork = forks.find((f) => f.localPath === selectedPath);
@@ -5417,11 +6176,11 @@ async function runForks(config) {
5417
6176
 
5418
6177
  // src/services/stats.ts
5419
6178
  import { readFile as readFile3 } from "fs/promises";
5420
- import { join as join17 } from "path";
6179
+ import { join as join18 } from "path";
5421
6180
 
5422
6181
  // src/utils/coverage-utils.ts
5423
- import { existsSync as existsSync11, readFileSync as readFileSync7, statSync as statSync2 } from "fs";
5424
- import { join as join16, relative as relative4, sep as sep2 } from "path";
6182
+ import { existsSync as existsSync13, readFileSync as readFileSync7, statSync as statSync2 } from "fs";
6183
+ import { join as join17, relative as relative4, sep as sep3 } from "path";
5425
6184
  var STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1e3;
5426
6185
  function colorPct(pct) {
5427
6186
  if (typeof pct !== "number" || !Number.isFinite(pct)) return colors_default.dim("n/a");
@@ -5439,11 +6198,11 @@ function formatAge(ms) {
5439
6198
  return `${mins} minute${mins === 1 ? "" : "s"} ago`;
5440
6199
  }
5441
6200
  function printCoverageSummary(forkPath) {
5442
- const file = join16(forkPath, ".coverage", "coverage-summary.json");
6201
+ const file = join17(forkPath, ".coverage", "coverage-summary.json");
5443
6202
  let summary;
5444
6203
  let mtimeMs = 0;
5445
6204
  let problem = "no coverage data found";
5446
- if (existsSync11(file)) {
6205
+ if (existsSync13(file)) {
5447
6206
  try {
5448
6207
  mtimeMs = statSync2(file).mtimeMs;
5449
6208
  summary = JSON.parse(readFileSync7(file, "utf8"));
@@ -5464,7 +6223,7 @@ function printCoverageSummary(forkPath) {
5464
6223
  if (key === "total") continue;
5465
6224
  const rel = relative4(forkPath, key);
5466
6225
  if (rel.startsWith("..")) continue;
5467
- const pkg = rel.split(sep2)[0];
6226
+ const pkg = rel.split(sep3)[0];
5468
6227
  const acc = perPackage.get(pkg) ?? { covered: 0, total: 0 };
5469
6228
  acc.covered += entry.lines.covered;
5470
6229
  acc.total += entry.lines.total;
@@ -5490,7 +6249,7 @@ function printCoverageSummary(forkPath) {
5490
6249
  // src/services/stats.ts
5491
6250
  var sourceExtensions = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs", "json", "jsonc", "css", "html"]);
5492
6251
  async function getWorkspacePackages(forkPath) {
5493
- const content = await readFile3(join17(forkPath, "pnpm-workspace.yaml"), "utf8");
6252
+ const content = await readFile3(join18(forkPath, "pnpm-workspace.yaml"), "utf8");
5494
6253
  const patterns = readTopLevel(content, "packages");
5495
6254
  return (Array.isArray(patterns) ? patterns.map(String) : []).map((pattern) => {
5496
6255
  if (pattern.endsWith("/*")) {
@@ -5538,9 +6297,9 @@ async function collectStats(forkPath) {
5538
6297
  const entries = [];
5539
6298
  for (const line of output.split("\n")) {
5540
6299
  if (!line) continue;
5541
- const sep3 = line.lastIndexOf(":");
5542
- if (sep3 === -1) continue;
5543
- entries.push({ path: line.slice(0, sep3), loc: Number.parseInt(line.slice(sep3 + 1), 10) });
6300
+ const sep4 = line.lastIndexOf(":");
6301
+ if (sep4 === -1) continue;
6302
+ entries.push({ path: line.slice(0, sep4), loc: Number.parseInt(line.slice(sep4 + 1), 10) });
5544
6303
  }
5545
6304
  const emptyCat = () => ({
5546
6305
  test: { files: 0, loc: 0 },
@@ -5843,26 +6602,26 @@ function getForkPath() {
5843
6602
  const envPath = getEnv("CELLA_FORK_PATH");
5844
6603
  if (envPath) {
5845
6604
  const resolved = resolve7(envPath);
5846
- if (!existsSync12(resolved) || !statSync3(resolved).isDirectory()) {
6605
+ if (!existsSync14(resolved) || !statSync3(resolved).isDirectory()) {
5847
6606
  throw new Error(`CELLA_FORK_PATH is not a valid directory: ${resolved}`);
5848
6607
  }
5849
6608
  return resolved;
5850
6609
  }
5851
- let cwd = process9.cwd();
6610
+ let cwd = process11.cwd();
5852
6611
  if (cwd.endsWith("/cli/cella") || cwd.endsWith("\\cli\\cella")) {
5853
6612
  cwd = resolve7(cwd, "../..");
5854
6613
  }
5855
6614
  return cwd;
5856
6615
  }
5857
6616
  function preflight(forkPath) {
5858
- if (!existsSync12(join18(forkPath, ".git"))) {
6617
+ if (!existsSync14(join19(forkPath, ".git"))) {
5859
6618
  throw new Error(`not a git repository: ${forkPath}`);
5860
6619
  }
5861
6620
  }
5862
6621
  var services = {
5863
6622
  analyze: runAnalyze,
5864
6623
  sync: runSyncCommand,
5865
- migrate: runMigrate,
6624
+ align: runAlign,
5866
6625
  audit: runAudit,
5867
6626
  forks: runForks,
5868
6627
  contributions: runContributions,
@@ -5873,7 +6632,7 @@ var services = {
5873
6632
  }
5874
6633
  };
5875
6634
  function isHelpOrVersionRequest() {
5876
- 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";
5877
6636
  }
5878
6637
  async function main() {
5879
6638
  try {
@@ -5892,7 +6651,7 @@ async function main() {
5892
6651
  } catch (error) {
5893
6652
  console.error();
5894
6653
  console.error(`${colors_default.red("\u2717")} ${errorMessage(error)}`);
5895
- process9.exit(1);
6654
+ process11.exit(1);
5896
6655
  }
5897
6656
  }
5898
6657
  main();