@cellajs/cli 0.2.2 → 0.3.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,13 +1,13 @@
1
1
  #!/usr/bin/env tsx
2
2
 
3
3
  // src/cella-cli.ts
4
- import { existsSync as existsSync11, statSync as statSync3 } from "fs";
5
- import { join as join18, resolve as resolve6 } from "path";
4
+ import { existsSync as existsSync13, statSync as statSync3 } from "fs";
5
+ import { join as join21, resolve as resolve7 } from "path";
6
6
  import process9 from "process";
7
7
 
8
8
  // src/cli.ts
9
9
  import { readFileSync } from "fs";
10
- import { join as join5 } from "path";
10
+ import { join as join8 } from "path";
11
11
  import process5 from "process";
12
12
  import { select } from "@inquirer/prompts";
13
13
  import { Command } from "commander";
@@ -106,6 +106,10 @@ function isConfigFile(filePath) {
106
106
  function isPackageJson(filePath) {
107
107
  return filePath === "package.json" || filePath.endsWith("/package.json");
108
108
  }
109
+ function isGeneratedFile(filePath) {
110
+ const segments = filePath.split("/");
111
+ return segments.includes("gen") || (segments.at(-1) ?? "").includes(".gen.");
112
+ }
109
113
  function isManagedFile(filePath) {
110
114
  return isPackageJson(filePath) || filePath === "pnpm-lock.yaml" || isConfigFile(filePath);
111
115
  }
@@ -216,7 +220,7 @@ import process4 from "process";
216
220
  // package.json
217
221
  var package_default = {
218
222
  name: "@cellajs/cli",
219
- version: "0.2.2",
223
+ version: "0.3.0",
220
224
  private: false,
221
225
  license: "MIT",
222
226
  description: "CLI for managing your monorepo and syncing with upstream Cella.",
@@ -266,25 +270,25 @@ var package_default = {
266
270
  prepare: "lefthook install"
267
271
  },
268
272
  dependencies: {
269
- "@inquirer/core": "^11.2.1",
273
+ "@inquirer/core": "^12.0.3",
270
274
  "@inquirer/prompts": "^8.7.2",
271
- "@pierre/diffs": "^1.4.3",
275
+ "@pierre/diffs": "^1.5.1",
272
276
  commander: "^15.0.0",
273
277
  "ts-morph": "^28.0.0",
274
278
  zod: "^4.6.5"
275
279
  },
276
280
  devDependencies: {
277
- "@biomejs/biome": "^2.5.14",
281
+ "@biomejs/biome": "^2.5.15",
278
282
  "@commitlint/cli": "^21.2.3",
279
283
  "@commitlint/config-conventional": "^21.2.3",
280
- "@evilmartians/lefthook": "^2.1.14",
281
- "@types/node": "26.5.0",
282
- "@typescript/native-preview": "7.0.0-dev.20260702.3",
284
+ "@evilmartians/lefthook": "^2.1.15",
285
+ "@types/node": "26.6.3",
286
+ "@typescript/native-preview": "7.0.0-dev.20260707.2",
283
287
  rimraf: "^6.1.3",
284
288
  tsup: "^8.5.1",
285
289
  tsx: "^4.23.15",
286
290
  typescript: "^6.0.3",
287
- vitest: "^4.1.11"
291
+ vitest: "^5.0.3"
288
292
  },
289
293
  publishConfig: {
290
294
  access: "public"
@@ -705,6 +709,55 @@ function printProtectedConflicts(result) {
705
709
  }
706
710
  console.info(colors_default.dim(` ${PROTECTED_BEHIND_HINT}`));
707
711
  }
712
+ function plural(count, singular, pluralForm = `${singular}s`) {
713
+ return `${count} ${count === 1 ? singular : pluralForm}`;
714
+ }
715
+ function shellPath(path4) {
716
+ return /^[\w./@+-]+$/.test(path4) ? path4 : `'${path4.replace(/'/g, `'\\''`)}'`;
717
+ }
718
+ function printIgnoredUpstreamChanges(result) {
719
+ const groups = result.ignoredUpstreamChanges ?? [];
720
+ if (groups.length === 0) return;
721
+ const total = groups.reduce((count, group) => count + group.paths.length, 0);
722
+ console.info();
723
+ console.info(
724
+ `${warningMark} ${colors_default.yellow(`ignored paths changed upstream \xB7 ${plural(total, "file")} under ${plural(groups.length, "entry", "entries")}`)}`
725
+ );
726
+ for (const group of groups) {
727
+ const counts = [plural(group.paths.length, "file")];
728
+ if (group.added > 0) counts.push(`${group.added} new`);
729
+ if (group.deleted > 0) counts.push(`${group.deleted} deleted`);
730
+ console.info(` ${statusConfig.ignored.icon} ${group.entry} ${colors_default.dim(`\xB7 ${counts.join(", ")}`)}`);
731
+ if (result.upstreamDiffRange) {
732
+ console.info(colors_default.dim(` git diff ${result.upstreamDiffRange} -- ${shellPath(group.entry)}`));
733
+ }
734
+ }
735
+ console.info(colors_default.dim(" the fork left these untouched and ignored paths never sync: diff and adopt what you need."));
736
+ }
737
+ function printUpstreamOverrideChanges(result) {
738
+ const report = result.upstreamOverrides;
739
+ if (!report) return;
740
+ console.info();
741
+ if (report.kind === "unreadable") {
742
+ const side = report.side === "base" ? "the last sync point" : "the incoming ref";
743
+ console.info(
744
+ colors_default.dim(`upstream overrides not compared: upstream ${CONFIG_FILE} is missing or unreadable at ${side}`)
745
+ );
746
+ return;
747
+ }
748
+ const lines = [];
749
+ for (const list of ["pinned", "ignored"]) {
750
+ for (const entry of report[list].added) lines.push(` ${colors_default.green("+")} ${list}: ${entry}`);
751
+ for (const entry of report[list].removed) {
752
+ lines.push(` ${colors_default.red("\u2212")} ${list}: ${entry}${colors_default.dim(" \xB7 dropped upstream, still in your config")}`);
753
+ }
754
+ }
755
+ console.info(
756
+ `${warningMark} ${colors_default.yellow(`upstream changed its sync overrides \xB7 ${plural(lines.length, "entry", "entries")} to review`)}`
757
+ );
758
+ for (const line of lines) console.info(line);
759
+ console.info(colors_default.dim(` ${CONFIG_FILE} never syncs: add or drop these by hand where they fit your app.`));
760
+ }
708
761
  function findMaskingPins(files) {
709
762
  return files.filter(
710
763
  (file) => file.isPinned && !isManagedFile(file.path) && file.status === "behind" && file.existsInFork && file.existsInUpstream
@@ -747,565 +800,195 @@ function printFlagWarnings(options) {
747
800
  }
748
801
 
749
802
  // src/utils/overrides.ts
803
+ import { existsSync as existsSync5 } from "fs";
804
+ import { join as join7 } from "path";
805
+
806
+ // src/utils/migration-notes.ts
807
+ import { existsSync as existsSync4 } from "fs";
808
+ import { mkdir as mkdir3, readFile as readFile2, rm, writeFile as writeFile2 } from "fs/promises";
809
+ import { dirname as dirname3, join as join6 } from "path";
810
+
811
+ // src/utils/git.ts
812
+ import { execFile } from "child_process";
750
813
  import { existsSync as existsSync3 } from "fs";
751
- import { join as join4 } from "path";
752
- function isUnderAnyFolder(filePath, folders) {
753
- return folders.some((folder) => {
754
- const entry = folder.replace(/\/+$/, "");
755
- return filePath === entry || filePath.startsWith(`${entry}/`);
814
+ import { mkdir as mkdir2, readdir, rmdir, unlink } from "fs/promises";
815
+ import { dirname as dirname2, join as join5 } from "path";
816
+ import { promisify } from "util";
817
+
818
+ // src/utils/manifest.ts
819
+ import { mkdir, readFile, writeFile } from "fs/promises";
820
+ import { dirname, join as join4 } from "path";
821
+ var MANIFEST_FILE = "cella/cella.manifest.json";
822
+ async function readSyncManifest(cwd) {
823
+ try {
824
+ const parsed = JSON.parse(await readFile(join4(cwd, MANIFEST_FILE), "utf8"));
825
+ const commit = parsed?.upstream?.commit;
826
+ if (typeof commit !== "string" || !/^[0-9a-f]{40}$/i.test(commit)) return null;
827
+ return parsed;
828
+ } catch {
829
+ return null;
830
+ }
831
+ }
832
+ async function readManifestBase(cwd) {
833
+ const manifest = await readSyncManifest(cwd);
834
+ return manifest ? manifest.upstream.commit.toLowerCase() : null;
835
+ }
836
+ async function writeSyncManifest(cwd, manifest) {
837
+ const manifestPath = join4(cwd, MANIFEST_FILE);
838
+ await mkdir(dirname(manifestPath), { recursive: true });
839
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}
840
+ `, "utf8");
841
+ }
842
+
843
+ // src/utils/git.ts
844
+ var execFileAsync = promisify(execFile);
845
+ function sanitizeCredentials(text) {
846
+ return text.replace(/https?:\/\/[^@\s]+@/g, (match) => {
847
+ const protocol = match.startsWith("https") ? "https" : "http";
848
+ return `${protocol}://***@`;
756
849
  });
757
850
  }
758
- function isIgnored(filePath, config) {
759
- return isUnderAnyFolder(filePath, config.overrides?.ignored || []);
851
+ async function git(args, cwd, options = {}) {
852
+ const env = {
853
+ ...getEnvSnapshot(),
854
+ ...options.skipEditor ? { GIT_EDITOR: "true" } : {},
855
+ ...options.env
856
+ };
857
+ const maxBuffer = options.maxBuffer ?? 50 * 1024 * 1024;
858
+ try {
859
+ const { stdout } = await execFileAsync("git", args, { cwd, env, maxBuffer });
860
+ return stdout.trim();
861
+ } catch (error) {
862
+ if (options.ignoreErrors) return "";
863
+ if (error instanceof Error) {
864
+ error.message = sanitizeCredentials(error.message);
865
+ }
866
+ throw error;
867
+ }
760
868
  }
761
- function isPinned(filePath, config) {
762
- if (isManagedFile(filePath)) return true;
763
- return isUnderAnyFolder(filePath, config.overrides?.pinned || []);
869
+ async function getCurrentBranch(cwd) {
870
+ return git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
764
871
  }
765
- function isPinnedForSync(filePath, config, unpinned) {
766
- if (isManagedFile(filePath)) return true;
767
- if (unpinned) return false;
768
- return isPinned(filePath, config);
872
+ async function switchBranch(cwd, branch) {
873
+ await git(["switch", branch], cwd);
769
874
  }
770
- function hasGlobChars(entry) {
771
- return entry.includes("*") || entry.includes("?");
875
+ async function createBranchFrom(cwd, branch, startPoint) {
876
+ await git(["switch", "-c", branch, startPoint], cwd);
772
877
  }
773
- function validateOverrides(config, forkPath) {
774
- const warnings = [];
775
- const pinned = config.overrides?.pinned || [];
776
- for (const entry of pinned) {
777
- if (hasGlobChars(entry)) {
778
- warnings.push({
779
- type: "pinned-glob",
780
- pattern: entry,
781
- message: `pin uses glob (not supported, use a path or folder): ${entry}`
782
- });
783
- } else if (!existsSync3(join4(forkPath, entry))) {
784
- warnings.push({
785
- type: "pinned-not-found",
786
- pattern: entry,
787
- message: `pin not found: ${entry}`
788
- });
789
- }
878
+ async function deleteBranch(cwd, branch) {
879
+ await git(["branch", "-D", branch], cwd, { ignoreErrors: true });
880
+ }
881
+ async function pullFastForward(cwd) {
882
+ await git(["pull", "--ff-only"], cwd, { ignoreErrors: true, skipEditor: true });
883
+ }
884
+ async function getUpstreamStatus(cwd) {
885
+ const upstream = await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], cwd, {
886
+ ignoreErrors: true
887
+ });
888
+ if (!upstream) return { upstream: null, ahead: 0, behind: 0 };
889
+ const remote = upstream.split("/")[0];
890
+ await git(["fetch", remote], cwd, { ignoreErrors: true, skipEditor: true });
891
+ const counts = await git(["rev-list", "--left-right", "--count", `HEAD...${upstream}`], cwd, { ignoreErrors: true });
892
+ const [ahead, behind] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0);
893
+ return { upstream, ahead: ahead ?? 0, behind: behind ?? 0 };
894
+ }
895
+ async function pushBranch(cwd, remote, branch, options = {}) {
896
+ await git(["push", "-u", ...options.forceWithLease ? ["--force-with-lease"] : [], remote, branch], cwd);
897
+ }
898
+ async function listBranchMergeCommits(cwd, baseRef) {
899
+ const out = await git(["rev-list", "--merges", "--first-parent", `${baseRef}..HEAD`], cwd, { ignoreErrors: true });
900
+ return out ? out.split("\n").filter(Boolean) : [];
901
+ }
902
+ async function flattenBranch(cwd, baseRef, message) {
903
+ const parent = await getMergeBase(cwd, baseRef, "HEAD");
904
+ const tree = await git(["rev-parse", "HEAD^{tree}"], cwd);
905
+ const commit = await git(["commit-tree", tree, "-p", parent, "-m", message], cwd);
906
+ await git(["reset", "--hard", commit], cwd);
907
+ }
908
+ async function commitSquash(cwd, message) {
909
+ for (const file of ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE"]) {
910
+ const path4 = join5(cwd, ".git", file);
911
+ if (existsSync3(path4)) await unlink(path4);
790
912
  }
791
- const ignored = config.overrides?.ignored || [];
792
- for (const entry of ignored) {
793
- if (hasGlobChars(entry)) {
794
- warnings.push({
795
- type: "ignored-not-found",
796
- pattern: entry,
797
- message: `ignored entry uses glob (not supported, use a path or folder): ${entry}`
798
- });
799
- } else if (!existsSync3(join4(forkPath, entry.replace(/\/+$/, "")))) {
800
- warnings.push({
801
- type: "ignored-not-found",
802
- pattern: entry,
803
- message: `ignored entry not found: ${entry}`
804
- });
805
- }
913
+ await git(["commit", "-m", message], cwd, { skipEditor: true });
914
+ }
915
+ async function stageAll(cwd) {
916
+ await git(["add", "-A"], cwd);
917
+ }
918
+ function mergeInProgress(cwd) {
919
+ return existsSync3(join5(cwd, ".git", "MERGE_HEAD"));
920
+ }
921
+ async function getShortSha(cwd, ref) {
922
+ return git(["rev-parse", "--short", ref], cwd);
923
+ }
924
+ async function getWorkingTreeChangeCount(cwd) {
925
+ const status = await git(["status", "--porcelain"], cwd);
926
+ if (!status) return 0;
927
+ return status.split("\n").filter(Boolean).length;
928
+ }
929
+ async function remoteExists(cwd, remoteName) {
930
+ const remotes = await git(["remote"], cwd);
931
+ return remotes.split("\n").includes(remoteName);
932
+ }
933
+ async function ensureRemote(cwd, remoteName, url) {
934
+ if (await remoteExists(cwd, remoteName)) {
935
+ await git(["remote", "set-url", remoteName, url], cwd);
936
+ } else {
937
+ await git(["remote", "add", remoteName, url], cwd);
806
938
  }
807
- return warnings;
808
939
  }
809
- function printWarnings(warnings) {
810
- for (const warning of warnings) {
811
- console.info(`${warningMark} ${warning.message}`);
940
+ async function fetch2(cwd, remoteName) {
941
+ await git(["fetch", remoteName], cwd);
942
+ }
943
+ async function fetchUpstreamTags(cwd, remoteName) {
944
+ await git(["fetch", remoteName, "--no-tags", `+refs/tags/*:refs/${remoteName}/tags/*`], cwd);
945
+ }
946
+ async function resolveLatestReleaseTag(cwd, remoteName) {
947
+ const prefix = `refs/${remoteName}/tags/`;
948
+ const output = await git(["for-each-ref", "--sort=-version:refname", "--format=%(refname)", prefix], cwd, {
949
+ ignoreErrors: true
950
+ });
951
+ if (!output) return null;
952
+ for (const line of output.split("\n")) {
953
+ const ref = line.trim();
954
+ if (!ref) continue;
955
+ const tag = ref.slice(prefix.length);
956
+ if (/^v\d/.test(tag)) return { tag, ref };
812
957
  }
958
+ return null;
813
959
  }
814
-
815
- // src/cli.ts
816
- function readOptions(opts) {
817
- const scope = typeof opts.scope === "string" ? opts.scope : void 0;
818
- if (scope && scope !== "all" && scope !== "risk" && scope !== "protected") {
819
- throw new Error(`invalid --scope '${scope}'. expected one of: all, risk, protected`);
960
+ async function getCommitInfo(cwd, ref) {
961
+ const format = "%H%n%s%n%ar";
962
+ const output = await git(["log", "-1", `--format=${format}`, ref], cwd);
963
+ const [hash, message, date] = output.split("\n");
964
+ return { hash, message, date };
965
+ }
966
+ async function countCommitsBetween(cwd, fromRef, toRef) {
967
+ const output = await git(["rev-list", "--count", `${fromRef}..${toRef}`], cwd);
968
+ return Number.parseInt(output.trim(), 10) || 0;
969
+ }
970
+ async function listCommitsBetween(cwd, fromRef, toRef, options = {}) {
971
+ const { oldestFirst = true, skip = 0, limit } = options;
972
+ const args = ["log", "--format=%H%x1f%s%x1f%ar"];
973
+ if (oldestFirst) args.push("--reverse");
974
+ args.push(`${fromRef}..${toRef}`);
975
+ const output = await git(args, cwd, { ignoreErrors: true });
976
+ if (!output) return [];
977
+ const commits = [];
978
+ for (const line of output.split("\n")) {
979
+ if (!line) continue;
980
+ const [hash, message, date] = line.split("");
981
+ if (!hash || !message || !date) continue;
982
+ commits.push({ hash, message, date });
820
983
  }
821
- const normalizedScope = scope;
822
- return {
823
- logFile: opts.log === true,
824
- verbose: opts.verbose === true,
825
- list: opts.list === true,
826
- json: opts.json === true,
827
- diff: typeof opts.diff === "string" ? opts.diff : void 0,
828
- openDiff: typeof opts.openDiff === "string" ? opts.openDiff : void 0,
829
- scope: normalizedScope,
830
- fork: typeof opts.fork === "string" ? opts.fork : void 0,
831
- hard: opts.hard === true,
832
- unpinned: opts.unpinned === true,
833
- track: opts.track === "release" || opts.track === "branch" ? opts.track : void 0,
834
- force: opts.force === true,
835
- checkOverrides: opts.checkOverrides === true,
836
- coverage: opts.coverage === true,
837
- directMerge: opts.directMerge === true
838
- };
839
- }
840
- var serviceDefinitions = [
841
- {
842
- name: "analyze",
843
- description: "dry run to see what would change on sync",
844
- options: [
845
- { flags: "--log", description: "write complete file list to cella-sync.log" },
846
- { flags: "--list", description: "non-interactive output for tooling (one file per line)" },
847
- { flags: "--json", description: "machine-readable output for tooling/agents" },
848
- { flags: "--scope <scope>", description: "analyze scope for --list/--json: all|risk|protected" },
849
- { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" },
850
- { flags: "--diff <path>", description: "print unified diff for one file, then exit" },
851
- { flags: "--open-diff <path>", description: "open a browser diff for one file, then exit" }
852
- ],
853
- includeInMenu: (context) => !context.isUpstreamRepo
854
- },
855
- {
856
- name: "sync",
857
- description: "merge upstream changes into your app",
858
- options: [
859
- { flags: "--log", description: "write complete file list to cella-sync.log" },
860
- { flags: "--hard", description: "overwrite drifted files with upstream version (aggressive realignment)" },
861
- { flags: "--unpinned", description: "ignore pinned files (except package.json) to resurface upstream changes" },
862
- { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" },
863
- {
864
- flags: "--direct-merge",
865
- description: "auto-merge (squash) the sync PR once checks pass \u2014 needs auto-merge enabled on the repo"
866
- }
867
- ],
868
- includeInMenu: (context) => !context.isUpstreamRepo,
869
- menuDescription: () => "merge upstream changes + sync package.json"
870
- },
871
- {
872
- name: "audit",
873
- description: "check for outdated packages & vulnerabilities",
874
- options: [
875
- { flags: "--list", description: "skip interactive update prompts after printing audit results" },
876
- { flags: "--force", description: "bypass pnpm metadata cache for fresh registry data" },
877
- { flags: "--check-overrides", description: "check which pnpm.overrides are still needed" }
878
- ]
879
- },
880
- {
881
- name: "forks",
882
- description: "sync downstream to local fork repositories",
883
- options: [
884
- { flags: "--fork <name>", description: "pre-select fork by name (skips fork selection prompt)" },
885
- { flags: "--log", description: "write complete file list to cella-sync.log for each synced fork" },
886
- { flags: "-V, --verbose", description: "show detailed output during operations" },
887
- { flags: "--hard", description: "overwrite drifted files with upstream version (aggressive realignment)" }
888
- ],
889
- includeInMenu: (context) => context.hasForks
890
- },
891
- {
892
- name: "contributions",
893
- description: "pull and adopt changes from forks",
894
- options: [
895
- { flags: "--fork <name>", description: "select a specific fork directly (skips fork selection)" },
896
- { flags: "--list", description: "non-interactive output (one file per line)" },
897
- { flags: "--json", description: "machine-readable JSON output for tooling/agents" },
898
- { flags: "--diff <path>", description: "print the unified diff for a single contributed file, then exit" }
899
- ],
900
- includeInMenu: (context) => context.hasForks
901
- },
902
- {
903
- name: "stats",
904
- description: "count files by category and workspace package",
905
- options: [
906
- { flags: "-V, --verbose", description: "show detailed output during operations" },
907
- { flags: "--coverage", description: "regenerate test coverage before showing the stats summary" }
908
- ]
909
- }
910
- ];
911
- async function getMenuContext(userConfig, forkPath) {
912
- return {
913
- hasForks: (userConfig.forks?.length ?? 0) > 0,
914
- isUpstreamRepo: JSON.parse(readFileSync(join5(forkPath, "package.json"), "utf8")).name === "cella"
915
- };
916
- }
917
- function buildServiceChoices(context) {
918
- const label = (name) => name.padEnd(14);
919
- const baseChoices = serviceDefinitions.filter((service) => service.includeInMenu?.(context) ?? true).map((service) => ({
920
- value: service.name,
921
- name: `${label(service.name)}${colors_default.dim(service.menuDescription?.(context) ?? service.description)}`
922
- }));
923
- return [
924
- ...baseChoices,
925
- { type: "separator", separator: "\u2500".repeat(40) },
926
- { value: "exit", name: colors_default.red(`${label("exit")}${colors_default.dim("quit without doing anything")}`) }
927
- ];
928
- }
929
- function addServiceCommand(program, definition, setSelection) {
930
- const command = program.command(definition.name).description(definition.description);
931
- for (const option of definition.options ?? []) {
932
- command.option(option.flags, option.description);
933
- }
934
- command.action((opts) => {
935
- setSelection({
936
- service: definition.name,
937
- options: readOptions(opts)
938
- });
939
- });
940
- }
941
- function buildProgram(setSelection) {
942
- const program = new Command(NAME).name("cella").version(VERSION, "-v, --version", "output the current version").usage("[service] [options]").helpOption("-h, --help", "display help for a command").showHelpAfterError().addHelpText(
943
- "after",
944
- [
945
- "",
946
- "Examples:",
947
- " $ cella analyze",
948
- " $ cella analyze --json --scope risk",
949
- " $ cella analyze --open-diff frontend/src/routes/index.tsx",
950
- " $ cella sync --hard",
951
- " $ cella sync --unpinned",
952
- " $ cella sync --track branch",
953
- " $ cella sync --direct-merge",
954
- " $ cella audit --check-overrides",
955
- " $ cella contributions --fork raak --json"
956
- ].join("\n")
957
- );
958
- for (const definition of serviceDefinitions) {
959
- addServiceCommand(program, definition, setSelection);
960
- }
961
- return program;
962
- }
963
- function parseCommandLine(argv) {
964
- if (argv.length <= 2) {
965
- return { options: readOptions({}) };
966
- }
967
- let selection = { options: readOptions({}) };
968
- const program = buildProgram((nextSelection) => {
969
- selection = nextSelection;
970
- });
971
- program.parse(argv);
972
- return selection;
973
- }
974
- async function promptForService(userConfig, forkPath) {
975
- const selected = await select({
976
- message: "choose a service:",
977
- choices: buildServiceChoices(await getMenuContext(userConfig, forkPath)),
978
- loop: false
979
- });
980
- if (selected === "exit") {
981
- console.info(colors_default.dim("exiting..."));
982
- console.info();
983
- process5.exit(0);
984
- }
985
- console.info();
986
- return selected;
987
- }
988
- function buildRuntimeConfig(userConfig, forkPath, selection) {
989
- const { branchRef } = resolveUpstream(userConfig.settings);
990
- return {
991
- ...userConfig,
992
- forkPath,
993
- upstreamRef: branchRef,
994
- service: selection.service ?? "analyze",
995
- ...selection.options
996
- };
997
- }
998
- async function parseCli(userConfig, forkPath) {
999
- const selection = parseCommandLine(process5.argv);
1000
- if (selection.options.json || selection.options.diff) setJsonMode(true);
1001
- printHeader();
1002
- const warnings = validateOverrides(userConfig, forkPath);
1003
- if (warnings.length > 0) {
1004
- printWarnings(warnings);
1005
- console.info();
1006
- }
1007
- if (!selection.service) {
1008
- selection.service = await promptForService(userConfig, forkPath);
1009
- }
1010
- return buildRuntimeConfig(userConfig, forkPath, selection);
1011
- }
1012
-
1013
- // src/services/analyze.ts
1014
- import { basename as basename4 } from "path";
1015
-
1016
- // src/utils/diff.ts
1017
- import { execFileSync } from "child_process";
1018
- import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
1019
- import { tmpdir } from "os";
1020
- import { basename as basename2, join as join6 } from "path";
1021
- import process6 from "process";
1022
- function gitDiffFile(cwd, range, filePath, options = {}) {
1023
- const args = ["diff", "--no-color"];
1024
- if (options.dstPrefix) args.push("--src-prefix=cella/", `--dst-prefix=${options.dstPrefix}/`);
1025
- args.push(range, "--", filePath);
1026
- try {
1027
- return execFileSync("git", args, { cwd, maxBuffer: 50 * 1024 * 1024 });
1028
- } catch (error) {
1029
- const stderr = error.stderr?.toString().trim();
1030
- throw new Error(stderr || `failed to diff ${filePath}`);
1031
- }
1032
- }
1033
- function escapeHtml(text) {
1034
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1035
- }
1036
- async function renderDiffPage(patch, meta) {
1037
- const { preloadPatchDiff } = await import("@pierre/diffs/ssr");
1038
- const { prerenderedHTML } = await preloadPatchDiff({
1039
- patch,
1040
- options: {
1041
- diffStyle: "split",
1042
- themeType: "system",
1043
- lineDiffType: "word-alt",
1044
- hunkSeparators: "line-info",
1045
- overflow: "wrap",
1046
- disableFileHeader: true
1047
- }
1048
- });
1049
- const title = `${escapeHtml(meta.filePath)} \xB7 ${escapeHtml(meta.srcLabel)} vs ${escapeHtml(meta.dstLabel)}`;
1050
- const note = meta.note ? `<span class="note">\xB7 ${escapeHtml(meta.note)}</span>` : "";
1051
- return `<!doctype html>
1052
- <html lang="en">
1053
- <head>
1054
- <meta charset="utf-8">
1055
- <meta name="viewport" content="width=device-width, initial-scale=1">
1056
- <title>${title}</title>
1057
- <style>
1058
- :root { color-scheme: light dark; }
1059
- * { box-sizing: border-box; }
1060
- body {
1061
- margin: 0;
1062
- font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
1063
- background: light-dark(#fafafa, #111);
1064
- color: light-dark(#111, #eee);
1065
- }
1066
- header {
1067
- display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap;
1068
- padding: 0.9rem 1.25rem;
1069
- border-bottom: 1px solid light-dark(#e2e2e2, #2a2a2a);
1070
- position: sticky; top: 0;
1071
- background: inherit;
1072
- }
1073
- header .brand { font-weight: 600; color: light-dark(#0891b2, #22d3ee); }
1074
- header code { font-family: ui-monospace, monospace; font-size: 0.9rem; }
1075
- header .sides { margin-left: auto; font-size: 0.8rem; opacity: 0.7; }
1076
- main { max-width: 1400px; margin: 1rem auto; padding: 0 1rem 2rem; }
1077
- .diff-shell {
1078
- border: 1px solid light-dark(#e2e2e2, #2a2a2a);
1079
- border-radius: 8px; overflow: hidden;
1080
- background: light-dark(#fff, #161616);
1081
- }
1082
- </style>
1083
- </head>
1084
- <body>
1085
- <header>
1086
- <span class="brand">\u29C8 cella diff</span>
1087
- <code>${escapeHtml(meta.filePath)}</code>
1088
- <span class="sides">${escapeHtml(meta.srcLabel)} \u2190 \u2192 ${escapeHtml(meta.dstLabel)} ${note}</span>
1089
- </header>
1090
- <main>
1091
- <!-- Declarative shadow DOM: the library's CSS targets :host, which only
1092
- matches inside a shadow root (its browser component renders into one). -->
1093
- <div class="diff-shell">
1094
- <template shadowrootmode="open">
1095
- ${prerenderedHTML}
1096
- </template>
1097
- </div>
1098
- </main>
1099
- </body>
1100
- </html>`;
1101
- }
1102
- function openWithDefaultApp(target) {
1103
- try {
1104
- if (process6.platform === "win32") {
1105
- execFileSync("rundll32", ["url.dll,FileProtocolHandler", target], { stdio: "ignore" });
1106
- } else {
1107
- execFileSync(process6.platform === "darwin" ? "open" : "xdg-open", [target], { stdio: "ignore" });
1108
- }
1109
- } catch {
1110
- }
1111
- }
1112
- async function openDiffInBrowser(patch, meta, repoPath) {
1113
- const html = await renderDiffPage(patch, meta);
1114
- const dir = join6(tmpdir(), `cella-diff-${basename2(repoPath)}`);
1115
- mkdirSync(dir, { recursive: true });
1116
- const outPath = join6(dir, `${meta.filePath.replace(/[^\w.-]+/g, "-")}.html`);
1117
- writeFileSync2(outPath, html);
1118
- openWithDefaultApp(outPath);
1119
- return outPath;
1120
- }
1121
-
1122
- // src/utils/cleanup.ts
1123
- import { existsSync as existsSync5, rmSync } from "fs";
1124
- import { tmpdir as tmpdir2 } from "os";
1125
- import { basename as basename3, join as join9 } from "path";
1126
- import process7 from "process";
1127
-
1128
- // src/utils/git.ts
1129
- import { execFile } from "child_process";
1130
- import { existsSync as existsSync4 } from "fs";
1131
- import { mkdir as mkdir2, readdir, rmdir, unlink } from "fs/promises";
1132
- import { dirname as dirname2, join as join8 } from "path";
1133
- import { promisify } from "util";
1134
-
1135
- // src/utils/manifest.ts
1136
- import { mkdir, readFile, writeFile } from "fs/promises";
1137
- import { dirname, join as join7 } from "path";
1138
- var MANIFEST_FILE = "cella/cella.manifest.json";
1139
- async function readSyncManifest(cwd) {
1140
- try {
1141
- const parsed = JSON.parse(await readFile(join7(cwd, MANIFEST_FILE), "utf8"));
1142
- const commit = parsed?.upstream?.commit;
1143
- if (typeof commit !== "string" || !/^[0-9a-f]{40}$/i.test(commit)) return null;
1144
- return parsed;
1145
- } catch {
1146
- return null;
1147
- }
1148
- }
1149
- async function readManifestBase(cwd) {
1150
- const manifest = await readSyncManifest(cwd);
1151
- return manifest ? manifest.upstream.commit.toLowerCase() : null;
1152
- }
1153
- async function writeSyncManifest(cwd, manifest) {
1154
- const manifestPath = join7(cwd, MANIFEST_FILE);
1155
- await mkdir(dirname(manifestPath), { recursive: true });
1156
- await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}
1157
- `, "utf8");
1158
- }
1159
-
1160
- // src/utils/git.ts
1161
- var execFileAsync = promisify(execFile);
1162
- function sanitizeCredentials(text) {
1163
- return text.replace(/https?:\/\/[^@\s]+@/g, (match) => {
1164
- const protocol = match.startsWith("https") ? "https" : "http";
1165
- return `${protocol}://***@`;
1166
- });
1167
- }
1168
- async function git(args, cwd, options = {}) {
1169
- const env = {
1170
- ...getEnvSnapshot(),
1171
- ...options.skipEditor ? { GIT_EDITOR: "true" } : {},
1172
- ...options.env
1173
- };
1174
- const maxBuffer = options.maxBuffer ?? 50 * 1024 * 1024;
1175
- try {
1176
- const { stdout } = await execFileAsync("git", args, { cwd, env, maxBuffer });
1177
- return stdout.trim();
1178
- } catch (error) {
1179
- if (options.ignoreErrors) return "";
1180
- if (error instanceof Error) {
1181
- error.message = sanitizeCredentials(error.message);
1182
- }
1183
- throw error;
1184
- }
1185
- }
1186
- async function getCurrentBranch(cwd) {
1187
- return git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
1188
- }
1189
- async function switchBranch(cwd, branch) {
1190
- await git(["switch", branch], cwd);
1191
- }
1192
- async function createBranchFrom(cwd, branch, startPoint) {
1193
- await git(["switch", "-c", branch, startPoint], cwd);
1194
- }
1195
- async function deleteBranch(cwd, branch) {
1196
- await git(["branch", "-D", branch], cwd, { ignoreErrors: true });
1197
- }
1198
- async function pullFastForward(cwd) {
1199
- await git(["pull", "--ff-only"], cwd, { ignoreErrors: true, skipEditor: true });
1200
- }
1201
- async function getUpstreamStatus(cwd) {
1202
- const upstream = await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], cwd, {
1203
- ignoreErrors: true
1204
- });
1205
- if (!upstream) return { upstream: null, ahead: 0, behind: 0 };
1206
- const remote = upstream.split("/")[0];
1207
- await git(["fetch", remote], cwd, { ignoreErrors: true, skipEditor: true });
1208
- const counts = await git(["rev-list", "--left-right", "--count", `HEAD...${upstream}`], cwd, { ignoreErrors: true });
1209
- const [ahead, behind] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0);
1210
- return { upstream, ahead: ahead ?? 0, behind: behind ?? 0 };
1211
- }
1212
- async function pushBranch(cwd, remote, branch, options = {}) {
1213
- await git(["push", "-u", ...options.forceWithLease ? ["--force-with-lease"] : [], remote, branch], cwd);
1214
- }
1215
- async function listBranchMergeCommits(cwd, baseRef) {
1216
- const out = await git(["rev-list", "--merges", "--first-parent", `${baseRef}..HEAD`], cwd, { ignoreErrors: true });
1217
- return out ? out.split("\n").filter(Boolean) : [];
1218
- }
1219
- async function flattenBranch(cwd, baseRef, message) {
1220
- const parent = await getMergeBase(cwd, baseRef, "HEAD");
1221
- const tree = await git(["rev-parse", "HEAD^{tree}"], cwd);
1222
- const commit = await git(["commit-tree", tree, "-p", parent, "-m", message], cwd);
1223
- await git(["reset", "--hard", commit], cwd);
1224
- }
1225
- async function commitSquash(cwd, message) {
1226
- for (const file of ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE"]) {
1227
- const path4 = join8(cwd, ".git", file);
1228
- if (existsSync4(path4)) await unlink(path4);
1229
- }
1230
- await git(["commit", "-m", message], cwd, { skipEditor: true });
1231
- }
1232
- async function stageAll(cwd) {
1233
- await git(["add", "-A"], cwd);
1234
- }
1235
- function mergeInProgress(cwd) {
1236
- return existsSync4(join8(cwd, ".git", "MERGE_HEAD"));
1237
- }
1238
- async function getShortSha(cwd, ref) {
1239
- return git(["rev-parse", "--short", ref], cwd);
1240
- }
1241
- async function getWorkingTreeChangeCount(cwd) {
1242
- const status = await git(["status", "--porcelain"], cwd);
1243
- if (!status) return 0;
1244
- return status.split("\n").filter(Boolean).length;
1245
- }
1246
- async function remoteExists(cwd, remoteName) {
1247
- const remotes = await git(["remote"], cwd);
1248
- return remotes.split("\n").includes(remoteName);
1249
- }
1250
- async function ensureRemote(cwd, remoteName, url) {
1251
- if (await remoteExists(cwd, remoteName)) {
1252
- await git(["remote", "set-url", remoteName, url], cwd);
1253
- } else {
1254
- await git(["remote", "add", remoteName, url], cwd);
1255
- }
1256
- }
1257
- async function fetch2(cwd, remoteName) {
1258
- await git(["fetch", remoteName], cwd);
1259
- }
1260
- async function fetchUpstreamTags(cwd, remoteName) {
1261
- await git(["fetch", remoteName, "--no-tags", `+refs/tags/*:refs/${remoteName}/tags/*`], cwd);
1262
- }
1263
- async function resolveLatestReleaseTag(cwd, remoteName) {
1264
- const prefix = `refs/${remoteName}/tags/`;
1265
- const output = await git(["for-each-ref", "--sort=-version:refname", "--format=%(refname)", prefix], cwd, {
1266
- ignoreErrors: true
1267
- });
1268
- if (!output) return null;
1269
- for (const line of output.split("\n")) {
1270
- const ref = line.trim();
1271
- if (!ref) continue;
1272
- const tag = ref.slice(prefix.length);
1273
- if (/^v\d/.test(tag)) return { tag, ref };
1274
- }
1275
- return null;
1276
- }
1277
- async function getCommitInfo(cwd, ref) {
1278
- const format = "%H%n%s%n%ar";
1279
- const output = await git(["log", "-1", `--format=${format}`, ref], cwd);
1280
- const [hash, message, date] = output.split("\n");
1281
- return { hash, message, date };
1282
- }
1283
- async function countCommitsBetween(cwd, fromRef, toRef) {
1284
- const output = await git(["rev-list", "--count", `${fromRef}..${toRef}`], cwd);
1285
- return Number.parseInt(output.trim(), 10) || 0;
1286
- }
1287
- async function listCommitsBetween(cwd, fromRef, toRef, options = {}) {
1288
- const { oldestFirst = true, skip = 0, limit } = options;
1289
- const args = ["log", "--format=%H%x1f%s%x1f%ar"];
1290
- if (oldestFirst) args.push("--reverse");
1291
- args.push(`${fromRef}..${toRef}`);
1292
- const output = await git(args, cwd, { ignoreErrors: true });
1293
- if (!output) return [];
1294
- const commits = [];
1295
- for (const line of output.split("\n")) {
1296
- if (!line) continue;
1297
- const [hash, message, date] = line.split("");
1298
- if (!hash || !message || !date) continue;
1299
- commits.push({ hash, message, date });
1300
- }
1301
- const normalizedSkip = Math.max(0, skip);
1302
- const normalizedLimit = typeof limit === "number" && limit > 0 ? limit : void 0;
1303
- if (normalizedSkip === 0 && normalizedLimit === void 0) {
1304
- return commits;
1305
- }
1306
- const start = Math.min(normalizedSkip, commits.length);
1307
- const end = normalizedLimit === void 0 ? commits.length : Math.min(start + normalizedLimit, commits.length);
1308
- return commits.slice(start, end);
984
+ const normalizedSkip = Math.max(0, skip);
985
+ const normalizedLimit = typeof limit === "number" && limit > 0 ? limit : void 0;
986
+ if (normalizedSkip === 0 && normalizedLimit === void 0) {
987
+ return commits;
988
+ }
989
+ const start = Math.min(normalizedSkip, commits.length);
990
+ const end = normalizedLimit === void 0 ? commits.length : Math.min(start + normalizedLimit, commits.length);
991
+ return commits.slice(start, end);
1309
992
  }
1310
993
  async function getFileChangeInfo(cwd, fromRef, toRef) {
1311
994
  const info = /* @__PURE__ */ new Map();
@@ -1426,308 +1109,803 @@ async function getFileChanges(cwd, baseRef, targetRef) {
1426
1109
  });
1427
1110
  }
1428
1111
  }
1429
- return changes;
1112
+ return changes;
1113
+ }
1114
+ async function getDiffStat(cwd, fromRef, toRef, paths = []) {
1115
+ const args = ["diff", "--numstat", "--no-renames", fromRef, toRef];
1116
+ if (paths.length > 0) args.push("--", ...paths);
1117
+ const out = await git(args, cwd, { ignoreErrors: true });
1118
+ const stat = /* @__PURE__ */ new Map();
1119
+ for (const line of out.split("\n")) {
1120
+ if (!line.trim()) continue;
1121
+ const [a, d, ...rest] = line.split(" ");
1122
+ const path4 = rest.join(" ");
1123
+ if (!path4) continue;
1124
+ stat.set(path4, {
1125
+ additions: a === "-" ? null : Number(a),
1126
+ deletions: d === "-" ? null : Number(d)
1127
+ });
1128
+ }
1129
+ return stat;
1130
+ }
1131
+ async function getFileHashesAtRef(cwd, ref) {
1132
+ const output = await git(["ls-tree", "-r", ref], cwd);
1133
+ const hashes = /* @__PURE__ */ new Map();
1134
+ if (!output) return hashes;
1135
+ for (const line of output.split("\n")) {
1136
+ if (!line) continue;
1137
+ const match = line.match(/^\d+ \w+ ([a-f0-9]+)\t(.+)$/);
1138
+ if (match) {
1139
+ const [, hash, filePath] = match;
1140
+ hashes.set(filePath, hash);
1141
+ }
1142
+ }
1143
+ return hashes;
1144
+ }
1145
+ async function restoreToHead(cwd, filePath) {
1146
+ await git(["checkout", "HEAD", "--", filePath], cwd);
1147
+ }
1148
+ async function batchRestoreToHead(cwd, filePaths) {
1149
+ if (filePaths.length === 0) return;
1150
+ await git(["checkout", "HEAD", "--", ...filePaths], cwd);
1151
+ }
1152
+ async function gitRm(cwd, filePath) {
1153
+ await git(["rm", "-f", "--", filePath], cwd, { ignoreErrors: true });
1154
+ }
1155
+ async function batchGitRm(cwd, filePaths) {
1156
+ if (filePaths.length === 0) return;
1157
+ await git(["rm", "-f", "--", ...filePaths], cwd, { ignoreErrors: true });
1158
+ }
1159
+ async function getStagedNewFiles(cwd) {
1160
+ const output = await git(["diff", "--cached", "--name-only", "--diff-filter=A"], cwd, { ignoreErrors: true });
1161
+ return output ? output.split("\n").filter(Boolean) : [];
1162
+ }
1163
+ async function batchUnstageFiles(cwd, filePaths) {
1164
+ if (filePaths.length === 0) return;
1165
+ await git(["rm", "--cached", "-f", "--", ...filePaths], cwd, { ignoreErrors: true });
1166
+ }
1167
+ async function gitMv(cwd, oldPath, newPath) {
1168
+ const newDir = dirname2(join5(cwd, newPath));
1169
+ await mkdir2(newDir, { recursive: true });
1170
+ await git(["mv", "-f", oldPath, newPath], cwd);
1171
+ }
1172
+ async function fileExistsInWorktree(cwd, filePath) {
1173
+ return existsSync3(join5(cwd, filePath));
1174
+ }
1175
+ async function removeFileFromWorktree(cwd, filePath) {
1176
+ const fullPath = join5(cwd, filePath);
1177
+ try {
1178
+ await unlink(fullPath);
1179
+ await cleanupEmptyParentDirs(cwd, dirname2(filePath));
1180
+ } catch {
1181
+ }
1182
+ }
1183
+ async function removeFileFully(cwd, filePath) {
1184
+ await gitRm(cwd, filePath);
1185
+ await removeFileFromWorktree(cwd, filePath);
1186
+ }
1187
+ async function cleanupEmptyParentDirs(cwd, relativePath) {
1188
+ if (!relativePath || relativePath === ".") return;
1189
+ const fullPath = join5(cwd, relativePath);
1190
+ try {
1191
+ const entries = await readdir(fullPath);
1192
+ if (entries.length === 0) {
1193
+ await rmdir(fullPath);
1194
+ await cleanupEmptyParentDirs(cwd, dirname2(relativePath));
1195
+ }
1196
+ } catch {
1197
+ }
1198
+ }
1199
+ async function storeLastSyncRef(cwd, upstreamHash) {
1200
+ await git(["update-ref", "refs/cella/last-sync", upstreamHash], cwd);
1201
+ const head = await git(["rev-parse", "HEAD"], cwd, { ignoreErrors: true });
1202
+ if (head) {
1203
+ await git(["update-ref", "refs/cella/last-sync-head", head], cwd);
1204
+ }
1205
+ }
1206
+ async function stagePath(cwd, path4) {
1207
+ await git(["add", "--", path4], cwd, { ignoreErrors: true });
1208
+ }
1209
+ async function getStoredSyncRef(cwd) {
1210
+ const ref = await git(["rev-parse", "refs/cella/last-sync"], cwd, { ignoreErrors: true });
1211
+ return ref || null;
1212
+ }
1213
+ async function getStoredSyncHead(cwd) {
1214
+ const ref = await git(["rev-parse", "refs/cella/last-sync-head"], cwd, { ignoreErrors: true });
1215
+ return ref || null;
1216
+ }
1217
+ async function readManifestAtRef(cwd, ref) {
1218
+ const raw = await git(["show", `${ref}:${MANIFEST_FILE}`], cwd, { ignoreErrors: true });
1219
+ if (!raw) return null;
1220
+ try {
1221
+ const parsed = JSON.parse(raw);
1222
+ const commit = parsed?.upstream?.commit;
1223
+ if (typeof commit !== "string" || !/^[0-9a-f]{40}$/i.test(commit)) return null;
1224
+ return parsed;
1225
+ } catch {
1226
+ return null;
1227
+ }
1228
+ }
1229
+ async function readManifestBaseAtRef(cwd, ref) {
1230
+ const manifest = await readManifestAtRef(cwd, ref);
1231
+ return manifest ? manifest.upstream.commit.toLowerCase() : null;
1232
+ }
1233
+ async function readPackageVersionAtRef(cwd, ref) {
1234
+ const raw = await git(["show", `${ref}:package.json`], cwd, { ignoreErrors: true });
1235
+ if (!raw) return null;
1236
+ try {
1237
+ const version = JSON.parse(raw).version;
1238
+ return typeof version === "string" && version ? version : null;
1239
+ } catch {
1240
+ return null;
1241
+ }
1242
+ }
1243
+ async function commitObjectExists(cwd, sha) {
1244
+ try {
1245
+ await git(["cat-file", "-e", `${sha}^{commit}`], cwd);
1246
+ return true;
1247
+ } catch {
1248
+ return false;
1249
+ }
1250
+ }
1251
+ async function getRootCommit(cwd, ref) {
1252
+ const out = await git(["rev-list", "--max-parents=0", ref], cwd, { ignoreErrors: true });
1253
+ const roots = out.split("\n").filter(Boolean);
1254
+ return roots.length > 0 ? roots[roots.length - 1] : null;
1255
+ }
1256
+ var SCAFFOLD_CANDIDATE_LIMIT = 400;
1257
+ var SCAFFOLD_SCORE_CONCURRENCY = 10;
1258
+ var SCAFFOLD_MATCH_MAX_DIFF_RATIO = 0.3;
1259
+ async function inferScaffoldBase(cwd, headRef, upstreamRef) {
1260
+ const root = await getRootCommit(cwd, headRef);
1261
+ if (!root) return null;
1262
+ const rootDate = await git(["show", "-s", "--format=%cI", root], cwd, { ignoreErrors: true });
1263
+ if (!rootDate) return null;
1264
+ const before = new Date(new Date(rootDate).getTime() + 24 * 60 * 60 * 1e3).toISOString();
1265
+ const revList = await git(
1266
+ ["rev-list", `--max-count=${SCAFFOLD_CANDIDATE_LIMIT}`, `--before=${before}`, upstreamRef],
1267
+ cwd,
1268
+ { ignoreErrors: true }
1269
+ );
1270
+ const candidates = revList.split("\n").filter(Boolean);
1271
+ if (candidates.length === 0) return null;
1272
+ const totalPaths = (await git(["ls-tree", "-r", "--name-only", root], cwd)).split("\n").filter(Boolean).length;
1273
+ if (totalPaths === 0) return null;
1274
+ let best = null;
1275
+ outer: for (let i = 0; i < candidates.length; i += SCAFFOLD_SCORE_CONCURRENCY) {
1276
+ const chunk = candidates.slice(i, i + SCAFFOLD_SCORE_CONCURRENCY);
1277
+ const scores = await Promise.all(
1278
+ chunk.map(async (sha) => {
1279
+ try {
1280
+ const out = await git(["diff-tree", "-r", "--name-only", sha, root], cwd);
1281
+ return { sha, diff: out ? out.split("\n").length : 0 };
1282
+ } catch {
1283
+ return { sha, diff: Number.POSITIVE_INFINITY };
1284
+ }
1285
+ })
1286
+ );
1287
+ for (const score of scores) {
1288
+ if (!best || score.diff < best.diff) best = score;
1289
+ if (best.diff === 0) break outer;
1290
+ }
1291
+ }
1292
+ if (!best || best.diff / totalPaths > SCAFFOLD_MATCH_MAX_DIFF_RATIO) return null;
1293
+ return best.sha;
1294
+ }
1295
+ async function readRootTrailerBase(cwd, headRef) {
1296
+ const root = await getRootCommit(cwd, headRef);
1297
+ if (!root) return null;
1298
+ const message = await git(["show", "-s", "--format=%B", root], cwd, { ignoreErrors: true });
1299
+ const match = message.match(/^cella-base:\s*([0-9a-f]{40})$/im);
1300
+ return match ? match[1].toLowerCase() : null;
1301
+ }
1302
+ async function isShallowRepository(cwd) {
1303
+ const out = await git(["rev-parse", "--is-shallow-repository"], cwd, { ignoreErrors: true });
1304
+ return out === "true";
1305
+ }
1306
+ async function unshallowRepository(cwd) {
1307
+ try {
1308
+ await git(["fetch", "--unshallow"], cwd, { skipEditor: true });
1309
+ } catch (error) {
1310
+ const detail = error instanceof Error ? `
1311
+
1312
+ ${error.message}` : "";
1313
+ throw new Error(
1314
+ `this is a shallow clone and its full history could not be restored automatically.
1315
+ Sync needs the complete history to find the common ancestor with upstream. Restore it with:
1316
+
1317
+ git fetch --unshallow
1318
+
1319
+ then re-run sync.${detail}`
1320
+ );
1321
+ }
1322
+ }
1323
+ async function ensureSyncBase(cwd, headRef, upstreamRef) {
1324
+ if (await isShallowRepository(cwd)) {
1325
+ await unshallowRepository(cwd);
1326
+ }
1327
+ const nativeBase = await git(["merge-base", headRef, upstreamRef], cwd, { ignoreErrors: true });
1328
+ if (nativeBase) return;
1329
+ const sources = [
1330
+ () => getStoredSyncRef(cwd),
1331
+ () => readManifestBaseAtRef(cwd, headRef),
1332
+ () => readManifestBase(cwd),
1333
+ () => readRootTrailerBase(cwd, headRef),
1334
+ () => inferScaffoldBase(cwd, headRef, upstreamRef)
1335
+ ];
1336
+ let baseSha = null;
1337
+ for (const source of sources) {
1338
+ const sha = await source();
1339
+ if (sha && await commitObjectExists(cwd, sha)) {
1340
+ baseSha = sha;
1341
+ break;
1342
+ }
1343
+ }
1344
+ if (!baseSha) {
1345
+ throw new Error(
1346
+ `no common ancestor between the fork and '${upstreamRef}', and no sync base could be determined.
1347
+
1348
+ This fork has unrelated history with upstream \u2014 typical for a create-cella scaffold, or after
1349
+ upstream squashed its history \u2014 and no recorded or inferable base commit exists after fetching
1350
+ upstream. Seed it manually with the upstream commit the fork was created from, then re-run sync:
1351
+
1352
+ git update-ref refs/cella/last-sync <upstream-sha>
1353
+ `
1354
+ );
1355
+ }
1356
+ const root = await getRootCommit(cwd, headRef);
1357
+ if (!root) throw new Error("could not determine the fork's root commit to bootstrap sync ancestry");
1358
+ await git(["replace", "-f", "--graft", root, baseSha], cwd);
1359
+ await git(["update-ref", "refs/cella/last-sync", baseSha], cwd);
1360
+ }
1361
+ async function isAncestor(cwd, ancestor, descendant) {
1362
+ try {
1363
+ await git(["merge-base", "--is-ancestor", ancestor, descendant], cwd);
1364
+ return true;
1365
+ } catch {
1366
+ return false;
1367
+ }
1368
+ }
1369
+ async function getLegacySyncRef(cwd, headRef) {
1370
+ const storedRef = await getStoredSyncRef(cwd);
1371
+ if (!storedRef) return null;
1372
+ const storedHead = await getStoredSyncHead(cwd);
1373
+ if (storedHead) {
1374
+ const currentHead = await git(["rev-parse", headRef], cwd, { ignoreErrors: true });
1375
+ if (currentHead && currentHead === storedHead) return null;
1376
+ }
1377
+ return storedRef;
1430
1378
  }
1431
- async function getDiffStat(cwd, fromRef, toRef, paths = []) {
1432
- const args = ["diff", "--numstat", "--no-renames", fromRef, toRef];
1433
- if (paths.length > 0) args.push("--", ...paths);
1434
- const out = await git(args, cwd, { ignoreErrors: true });
1435
- const stat = /* @__PURE__ */ new Map();
1436
- for (const line of out.split("\n")) {
1437
- if (!line.trim()) continue;
1438
- const [a, d, ...rest] = line.split(" ");
1439
- const path4 = rest.join(" ");
1440
- if (!path4) continue;
1441
- stat.set(path4, {
1442
- additions: a === "-" ? null : Number(a),
1443
- deletions: d === "-" ? null : Number(d)
1444
- });
1379
+ async function getEffectiveMergeBase(cwd, headRef, upstreamRef) {
1380
+ const gitBase = await getMergeBase(cwd, headRef, upstreamRef);
1381
+ const recorded = await readManifestBaseAtRef(cwd, headRef) ?? await getLegacySyncRef(cwd, headRef);
1382
+ if (!recorded) {
1383
+ return { base: gitBase, isStale: false, storedRef: null };
1445
1384
  }
1446
- return stat;
1385
+ const recordedIsNewer = await isAncestor(cwd, gitBase, recorded);
1386
+ if (recordedIsNewer && recorded !== gitBase) {
1387
+ return { base: recorded, isStale: true, storedRef: recorded };
1388
+ }
1389
+ return { base: gitBase, isStale: false, storedRef: recorded };
1447
1390
  }
1448
- async function getFileHashesAtRef(cwd, ref) {
1449
- const output = await git(["ls-tree", "-r", ref], cwd);
1450
- const hashes = /* @__PURE__ */ new Map();
1451
- if (!output) return hashes;
1452
- for (const line of output.split("\n")) {
1453
- if (!line) continue;
1454
- const match = line.match(/^\d+ \w+ ([a-f0-9]+)\t(.+)$/);
1455
- if (match) {
1456
- const [, hash, filePath] = match;
1457
- hashes.set(filePath, hash);
1458
- }
1391
+ async function withTemporarySyncBaseGraft(cwd, headRef, baseSha, fn) {
1392
+ const headSha = await git(["rev-parse", headRef], cwd, { ignoreErrors: true });
1393
+ if (!headSha || !await commitObjectExists(cwd, baseSha)) return fn();
1394
+ if (await isAncestor(cwd, baseSha, headSha)) return fn();
1395
+ const existingReplacement = await git(["replace", "-l", headSha], cwd, { ignoreErrors: true });
1396
+ if (existingReplacement) return fn();
1397
+ const parentsRaw = await git(["log", "-1", "--format=%P", headSha], cwd, { ignoreErrors: true });
1398
+ const parents = parentsRaw ? parentsRaw.split(/\s+/) : [];
1399
+ await git(["replace", "-f", "--graft", headSha, ...parents, baseSha], cwd);
1400
+ try {
1401
+ return await fn();
1402
+ } finally {
1403
+ await git(["replace", "-d", headSha], cwd, { ignoreErrors: true });
1459
1404
  }
1460
- return hashes;
1461
1405
  }
1462
- async function restoreToHead(cwd, filePath) {
1463
- await git(["checkout", "HEAD", "--", filePath], cwd);
1406
+
1407
+ // src/utils/migration-notes.ts
1408
+ var NOTES_DIR = "cella/migrations";
1409
+ var PENDING_FILE = "cella/cella.migrations.json";
1410
+ var NOTE_ID = /^\d{8}T\d{4}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
1411
+ function isUpstreamOnly(filePath) {
1412
+ return filePath === NOTES_DIR || filePath.startsWith(`${NOTES_DIR}/`);
1413
+ }
1414
+ function bodyLines(lines) {
1415
+ let inComment = false;
1416
+ return lines.filter((line) => {
1417
+ if (!inComment && !line.trimStart().startsWith("<!--")) return true;
1418
+ inComment = !line.includes("-->");
1419
+ return false;
1420
+ });
1464
1421
  }
1465
- async function batchRestoreToHead(cwd, filePaths) {
1466
- if (filePaths.length === 0) return;
1467
- await git(["checkout", "HEAD", "--", ...filePaths], cwd);
1422
+ function parseNote(id, source, fileNames) {
1423
+ const lines = source.replace(/\r\n/g, "\n").split("\n");
1424
+ const close = lines[0] === "---" ? lines.indexOf("---", 1) : -1;
1425
+ const meta = /* @__PURE__ */ new Map();
1426
+ for (const line of lines.slice(1, Math.max(close, 1))) {
1427
+ const match = /^(\w+):\s*(.*)$/.exec(line);
1428
+ if (match) meta.set(match[1], match[2].trim());
1429
+ }
1430
+ const body = bodyLines(lines.slice(close + 1)).join("\n");
1431
+ const blocks = body.split(/\n\s*\n/).map((block) => block.trim()).filter(Boolean);
1432
+ const title = /^# (.+)$/.exec(blocks[0] ?? "")?.[1].trim();
1433
+ const summary = title && blocks[1] && !/^(#|>|[-*|]|```)/.test(blocks[1]) ? blocks[1].replace(/\s*\n\s*/g, " ") : "";
1434
+ return {
1435
+ id,
1436
+ title: title || id,
1437
+ summary,
1438
+ syncBreaking: meta.get("syncBreaking") === "true",
1439
+ clientCacheBump: meta.get("clientCacheBump") === "true",
1440
+ roots: (meta.get("roots") ?? "").split(",").map((root) => root.trim()).filter(Boolean),
1441
+ codemod: fileNames.find((name) => name.endsWith(".ts") && !name.endsWith(".test.ts")) ?? null
1442
+ };
1468
1443
  }
1469
- async function gitRm(cwd, filePath) {
1470
- await git(["rm", "-f", "--", filePath], cwd, { ignoreErrors: true });
1444
+ async function listNoteIds(cwd, ref) {
1445
+ const out = await git(["ls-tree", "--name-only", `${ref}:${NOTES_DIR}`], cwd, { ignoreErrors: true });
1446
+ return out.split("\n").filter((name) => NOTE_ID.test(name)).sort();
1447
+ }
1448
+ async function readNote(cwd, ref, id) {
1449
+ const source = await git(["show", `${ref}:${NOTES_DIR}/${id}/README.md`], cwd, { ignoreErrors: true });
1450
+ if (!source) return null;
1451
+ const files = await git(["ls-tree", "--name-only", `${ref}:${NOTES_DIR}/${id}`], cwd, { ignoreErrors: true });
1452
+ return parseNote(id, source, files.split("\n").filter(Boolean));
1453
+ }
1454
+ async function arrivedNoteIds(cwd, from, to) {
1455
+ const [before, after] = await Promise.all([listNoteIds(cwd, from), listNoteIds(cwd, to)]);
1456
+ const known = new Set(before);
1457
+ return after.filter((id) => !known.has(id));
1458
+ }
1459
+ async function readPending(cwd, syncRef) {
1460
+ const path4 = join6(cwd, PENDING_FILE);
1461
+ if (!existsSync4(path4)) return [];
1462
+ const parsed = JSON.parse(await readFile2(path4, "utf8"));
1463
+ if (Array.isArray(parsed.pending)) return parsed.pending.filter((id) => typeof id === "string");
1464
+ if (!Array.isArray(parsed.applied) || !syncRef) return [];
1465
+ const applied = new Set(parsed.applied);
1466
+ return (await listNoteIds(cwd, syncRef)).filter((id) => !applied.has(id));
1467
+ }
1468
+ async function writePending(cwd, ids) {
1469
+ const path4 = join6(cwd, PENDING_FILE);
1470
+ const pending = [...new Set(ids)].sort();
1471
+ if (pending.length === 0) {
1472
+ await rm(path4, { force: true });
1473
+ return;
1474
+ }
1475
+ await mkdir3(dirname3(path4), { recursive: true });
1476
+ await writeFile2(path4, `${JSON.stringify({ pending }, null, 2)}
1477
+ `, "utf8");
1471
1478
  }
1472
- async function batchGitRm(cwd, filePaths) {
1473
- if (filePaths.length === 0) return;
1474
- await git(["rm", "-f", "--", ...filePaths], cwd, { ignoreErrors: true });
1479
+ function noteUrl(upstreamGitHubUrl, commit, id) {
1480
+ return upstreamGitHubUrl ? `${upstreamGitHubUrl}/blob/${commit}/${NOTES_DIR}/${id}/README.md` : void 0;
1475
1481
  }
1476
- async function getStagedNewFiles(cwd) {
1477
- const output = await git(["diff", "--cached", "--name-only", "--diff-filter=A"], cwd, { ignoreErrors: true });
1478
- return output ? output.split("\n").filter(Boolean) : [];
1482
+ function formatNotesLine(total, open, arrived, options = {}) {
1483
+ const parts = [open === 0 ? `all ${total} handled` : `${Math.max(0, total - open)} of ${total} handled`];
1484
+ if (arrived) parts.push(`${arrived} ${options.dryRun ? "arrive" : "arrived"} with this sync`);
1485
+ if (open > 0) parts.push(`${open} open: pnpm cella migrate`);
1486
+ return `migration notes: ${parts.join(" \xB7 ")}`;
1479
1487
  }
1480
- async function batchUnstageFiles(cwd, filePaths) {
1481
- if (filePaths.length === 0) return;
1482
- await git(["rm", "--cached", "-f", "--", ...filePaths], cwd, { ignoreErrors: true });
1488
+
1489
+ // src/utils/overrides.ts
1490
+ function isUnderAnyFolder(filePath, folders) {
1491
+ return folders.some((folder) => {
1492
+ const entry = folder.replace(/\/+$/, "");
1493
+ return filePath === entry || filePath.startsWith(`${entry}/`);
1494
+ });
1483
1495
  }
1484
- async function gitMv(cwd, oldPath, newPath) {
1485
- const newDir = dirname2(join8(cwd, newPath));
1486
- await mkdir2(newDir, { recursive: true });
1487
- await git(["mv", "-f", oldPath, newPath], cwd);
1496
+ function isIgnored(filePath, config) {
1497
+ return isUpstreamOnly(filePath) || isUnderAnyFolder(filePath, config.overrides?.ignored || []);
1488
1498
  }
1489
- async function fileExistsInWorktree(cwd, filePath) {
1490
- return existsSync4(join8(cwd, filePath));
1499
+ function isPinned(filePath, config) {
1500
+ if (isManagedFile(filePath)) return true;
1501
+ return isUnderAnyFolder(filePath, config.overrides?.pinned || []);
1491
1502
  }
1492
- async function removeFileFromWorktree(cwd, filePath) {
1493
- const fullPath = join8(cwd, filePath);
1494
- try {
1495
- await unlink(fullPath);
1496
- await cleanupEmptyParentDirs(cwd, dirname2(filePath));
1497
- } catch {
1498
- }
1503
+ function groupIgnoredUpstreamChanges(files, ignored) {
1504
+ const entries = [...new Set(ignored.map((entry) => entry.replace(/\/+$/, "")).filter(Boolean))];
1505
+ const mostSpecificFirst = [...entries].sort((a, b) => b.length - a.length);
1506
+ const groups = /* @__PURE__ */ new Map();
1507
+ for (const file of files) {
1508
+ if (!file.upstreamOnly || isManagedFile(file.path) || isGeneratedFile(file.path)) continue;
1509
+ const entry = mostSpecificFirst.find((candidate) => isUnderAnyFolder(file.path, [candidate]));
1510
+ if (!entry) continue;
1511
+ const group = groups.get(entry) ?? { entry, paths: [], added: 0, deleted: 0 };
1512
+ group.paths.push(file.path);
1513
+ if (!file.existsInFork) group.added++;
1514
+ if (!file.existsInUpstream) group.deleted++;
1515
+ groups.set(entry, group);
1516
+ }
1517
+ for (const group of groups.values()) group.paths.sort();
1518
+ return entries.flatMap((entry) => groups.get(entry) ?? []);
1499
1519
  }
1500
- async function removeFileFully(cwd, filePath) {
1501
- await gitRm(cwd, filePath);
1502
- await removeFileFromWorktree(cwd, filePath);
1520
+ function isPinnedForSync(filePath, config, unpinned) {
1521
+ if (isManagedFile(filePath)) return true;
1522
+ if (unpinned) return false;
1523
+ return isPinned(filePath, config);
1503
1524
  }
1504
- async function cleanupEmptyParentDirs(cwd, relativePath) {
1505
- if (!relativePath || relativePath === ".") return;
1506
- const fullPath = join8(cwd, relativePath);
1507
- try {
1508
- const entries = await readdir(fullPath);
1509
- if (entries.length === 0) {
1510
- await rmdir(fullPath);
1511
- await cleanupEmptyParentDirs(cwd, dirname2(relativePath));
1525
+ function hasGlobChars(entry) {
1526
+ return entry.includes("*") || entry.includes("?");
1527
+ }
1528
+ function validateOverrides(config, forkPath) {
1529
+ const warnings = [];
1530
+ const pinned = config.overrides?.pinned || [];
1531
+ for (const entry of pinned) {
1532
+ if (hasGlobChars(entry)) {
1533
+ warnings.push({
1534
+ type: "pinned-glob",
1535
+ pattern: entry,
1536
+ message: `pin uses glob (not supported, use a path or folder): ${entry}`
1537
+ });
1538
+ } else if (!existsSync5(join7(forkPath, entry))) {
1539
+ warnings.push({
1540
+ type: "pinned-not-found",
1541
+ pattern: entry,
1542
+ message: `pin not found: ${entry}`
1543
+ });
1544
+ }
1545
+ }
1546
+ const ignored = config.overrides?.ignored || [];
1547
+ for (const entry of ignored) {
1548
+ if (hasGlobChars(entry)) {
1549
+ warnings.push({
1550
+ type: "ignored-not-found",
1551
+ pattern: entry,
1552
+ message: `ignored entry uses glob (not supported, use a path or folder): ${entry}`
1553
+ });
1554
+ } else if (!existsSync5(join7(forkPath, entry.replace(/\/+$/, "")))) {
1555
+ warnings.push({
1556
+ type: "ignored-not-found",
1557
+ pattern: entry,
1558
+ message: `ignored entry not found: ${entry}`
1559
+ });
1512
1560
  }
1513
- } catch {
1514
1561
  }
1562
+ return warnings;
1515
1563
  }
1516
- async function storeLastSyncRef(cwd, upstreamHash) {
1517
- await git(["update-ref", "refs/cella/last-sync", upstreamHash], cwd);
1518
- const head = await git(["rev-parse", "HEAD"], cwd, { ignoreErrors: true });
1519
- if (head) {
1520
- await git(["update-ref", "refs/cella/last-sync-head", head], cwd);
1564
+ function printWarnings(warnings) {
1565
+ for (const warning of warnings) {
1566
+ console.info(`${warningMark} ${warning.message}`);
1567
+ }
1568
+ }
1569
+
1570
+ // src/cli.ts
1571
+ function readOptions(opts) {
1572
+ const scope = typeof opts.scope === "string" ? opts.scope : void 0;
1573
+ if (scope && scope !== "all" && scope !== "risk" && scope !== "protected") {
1574
+ throw new Error(`invalid --scope '${scope}'. expected one of: all, risk, protected`);
1575
+ }
1576
+ const normalizedScope = scope;
1577
+ return {
1578
+ logFile: opts.log === true,
1579
+ verbose: opts.verbose === true,
1580
+ list: opts.list === true,
1581
+ json: opts.json === true,
1582
+ diff: typeof opts.diff === "string" ? opts.diff : void 0,
1583
+ openDiff: typeof opts.openDiff === "string" ? opts.openDiff : void 0,
1584
+ scope: normalizedScope,
1585
+ fork: typeof opts.fork === "string" ? opts.fork : void 0,
1586
+ hard: opts.hard === true,
1587
+ unpinned: opts.unpinned === true,
1588
+ track: opts.track === "release" || opts.track === "branch" ? opts.track : void 0,
1589
+ force: opts.force === true,
1590
+ checkOverrides: opts.checkOverrides === true,
1591
+ coverage: opts.coverage === true,
1592
+ all: opts.all === true,
1593
+ show: typeof opts.show === "string" ? opts.show : void 0,
1594
+ extract: typeof opts.extract === "string" ? opts.extract : void 0,
1595
+ mark: Array.isArray(opts.mark) ? opts.mark.filter((id) => typeof id === "string") : void 0
1596
+ };
1597
+ }
1598
+ var serviceDefinitions = [
1599
+ {
1600
+ name: "analyze",
1601
+ description: "dry run to see what would change on sync",
1602
+ options: [
1603
+ { flags: "--log", description: "write complete file list to cella-sync.log" },
1604
+ { flags: "--list", description: "non-interactive output for tooling (one file per line)" },
1605
+ { flags: "--json", description: "machine-readable output for tooling/agents" },
1606
+ { flags: "--scope <scope>", description: "analyze scope for --list/--json: all|risk|protected" },
1607
+ { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" },
1608
+ { flags: "--diff <path>", description: "print unified diff for one file, then exit" },
1609
+ { flags: "--open-diff <path>", description: "open a browser diff for one file, then exit" }
1610
+ ],
1611
+ includeInMenu: (context) => !context.isUpstreamRepo
1612
+ },
1613
+ {
1614
+ name: "sync",
1615
+ description: "merge upstream changes into your app",
1616
+ options: [
1617
+ { flags: "--log", description: "write complete file list to cella-sync.log" },
1618
+ { flags: "--hard", description: "overwrite drifted files with upstream version (aggressive realignment)" },
1619
+ { flags: "--unpinned", description: "ignore pinned files (except package.json) to resurface upstream changes" },
1620
+ { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" }
1621
+ ],
1622
+ includeInMenu: (context) => !context.isUpstreamRepo,
1623
+ menuDescription: () => "merge upstream changes + sync package.json"
1624
+ },
1625
+ {
1626
+ name: "migrate",
1627
+ description: "list the upstream migration notes this app has not handled yet",
1628
+ options: [
1629
+ { flags: "--all", description: "list every upstream note, handled or not" },
1630
+ { flags: "--json", description: "machine-readable output for tooling/agents" },
1631
+ { flags: "--show <id>", description: "print one note's README" },
1632
+ { flags: "--extract <id>", description: "write one note's folder under node_modules/.cache to run its codemod" },
1633
+ { flags: "--mark <ids...>", description: "record notes as handled" }
1634
+ ],
1635
+ includeInMenu: (context) => !context.isUpstreamRepo
1636
+ },
1637
+ {
1638
+ name: "audit",
1639
+ description: "check for outdated packages & vulnerabilities",
1640
+ options: [
1641
+ { flags: "--list", description: "skip interactive update prompts after printing audit results" },
1642
+ { flags: "--force", description: "bypass pnpm metadata cache for fresh registry data" },
1643
+ { flags: "--check-overrides", description: "check which pnpm.overrides are still needed" }
1644
+ ]
1645
+ },
1646
+ {
1647
+ name: "forks",
1648
+ description: "sync downstream to local fork repositories",
1649
+ options: [
1650
+ { flags: "--fork <name>", description: "pre-select fork by name (skips fork selection prompt)" },
1651
+ { flags: "--log", description: "write complete file list to cella-sync.log for each synced fork" },
1652
+ { flags: "-V, --verbose", description: "show detailed output during operations" },
1653
+ { flags: "--hard", description: "overwrite drifted files with upstream version (aggressive realignment)" }
1654
+ ],
1655
+ includeInMenu: (context) => context.hasForks
1656
+ },
1657
+ {
1658
+ name: "contributions",
1659
+ description: "pull and adopt changes from forks",
1660
+ options: [
1661
+ { flags: "--fork <name>", description: "select a specific fork directly (skips fork selection)" },
1662
+ { flags: "--list", description: "non-interactive output (one file per line)" },
1663
+ { flags: "--json", description: "machine-readable JSON output for tooling/agents" },
1664
+ { flags: "--diff <path>", description: "print the unified diff for a single contributed file, then exit" }
1665
+ ],
1666
+ includeInMenu: (context) => context.hasForks
1667
+ },
1668
+ {
1669
+ name: "stats",
1670
+ description: "count files by category and workspace package",
1671
+ options: [
1672
+ { flags: "-V, --verbose", description: "show detailed output during operations" },
1673
+ { flags: "--coverage", description: "regenerate test coverage before showing the stats summary" }
1674
+ ]
1521
1675
  }
1676
+ ];
1677
+ async function getMenuContext(userConfig, forkPath) {
1678
+ return {
1679
+ hasForks: (userConfig.forks?.length ?? 0) > 0,
1680
+ isUpstreamRepo: JSON.parse(readFileSync(join8(forkPath, "package.json"), "utf8")).name === "cella"
1681
+ };
1522
1682
  }
1523
- async function stagePath(cwd, path4) {
1524
- await git(["add", "--", path4], cwd, { ignoreErrors: true });
1525
- }
1526
- async function getStoredSyncRef(cwd) {
1527
- const ref = await git(["rev-parse", "refs/cella/last-sync"], cwd, { ignoreErrors: true });
1528
- return ref || null;
1529
- }
1530
- async function getStoredSyncHead(cwd) {
1531
- const ref = await git(["rev-parse", "refs/cella/last-sync-head"], cwd, { ignoreErrors: true });
1532
- return ref || null;
1683
+ function buildServiceChoices(context) {
1684
+ const label = (name) => name.padEnd(14);
1685
+ const baseChoices = serviceDefinitions.filter((service) => service.includeInMenu?.(context) ?? true).map((service) => ({
1686
+ value: service.name,
1687
+ name: `${label(service.name)}${colors_default.dim(service.menuDescription?.(context) ?? service.description)}`
1688
+ }));
1689
+ return [
1690
+ ...baseChoices,
1691
+ { type: "separator", separator: "\u2500".repeat(40) },
1692
+ { value: "exit", name: colors_default.red(`${label("exit")}${colors_default.dim("quit without doing anything")}`) }
1693
+ ];
1533
1694
  }
1534
- async function readManifestAtRef(cwd, ref) {
1535
- const raw = await git(["show", `${ref}:${MANIFEST_FILE}`], cwd, { ignoreErrors: true });
1536
- if (!raw) return null;
1537
- try {
1538
- const parsed = JSON.parse(raw);
1539
- const commit = parsed?.upstream?.commit;
1540
- if (typeof commit !== "string" || !/^[0-9a-f]{40}$/i.test(commit)) return null;
1541
- return parsed;
1542
- } catch {
1543
- return null;
1695
+ function addServiceCommand(program, definition, setSelection) {
1696
+ const command = program.command(definition.name).description(definition.description);
1697
+ for (const option of definition.options ?? []) {
1698
+ command.option(option.flags, option.description);
1544
1699
  }
1700
+ command.action((opts) => {
1701
+ setSelection({
1702
+ service: definition.name,
1703
+ options: readOptions(opts)
1704
+ });
1705
+ });
1545
1706
  }
1546
- async function readManifestBaseAtRef(cwd, ref) {
1547
- const manifest = await readManifestAtRef(cwd, ref);
1548
- return manifest ? manifest.upstream.commit.toLowerCase() : null;
1549
- }
1550
- async function readPackageVersionAtRef(cwd, ref) {
1551
- const raw = await git(["show", `${ref}:package.json`], cwd, { ignoreErrors: true });
1552
- if (!raw) return null;
1553
- try {
1554
- const version = JSON.parse(raw).version;
1555
- return typeof version === "string" && version ? version : null;
1556
- } catch {
1557
- return null;
1707
+ function buildProgram(setSelection) {
1708
+ const program = new Command(NAME).name("cella").version(VERSION, "-v, --version", "output the current version").usage("[service] [options]").helpOption("-h, --help", "display help for a command").showHelpAfterError().addHelpText(
1709
+ "after",
1710
+ [
1711
+ "",
1712
+ "Examples:",
1713
+ " $ cella analyze",
1714
+ " $ cella analyze --json --scope risk",
1715
+ " $ cella analyze --open-diff frontend/src/routes/index.tsx",
1716
+ " $ cella sync --hard",
1717
+ " $ cella sync --unpinned",
1718
+ " $ cella sync --track branch",
1719
+ " $ cella migrate --mark 20261002T0614-config-switch",
1720
+ " $ cella audit --check-overrides",
1721
+ " $ cella contributions --fork raak --json"
1722
+ ].join("\n")
1723
+ );
1724
+ for (const definition of serviceDefinitions) {
1725
+ addServiceCommand(program, definition, setSelection);
1558
1726
  }
1727
+ return program;
1559
1728
  }
1560
- async function commitObjectExists(cwd, sha) {
1561
- try {
1562
- await git(["cat-file", "-e", `${sha}^{commit}`], cwd);
1563
- return true;
1564
- } catch {
1565
- return false;
1729
+ function parseCommandLine(argv) {
1730
+ if (argv.length <= 2) {
1731
+ return { options: readOptions({}) };
1566
1732
  }
1733
+ let selection = { options: readOptions({}) };
1734
+ const program = buildProgram((nextSelection) => {
1735
+ selection = nextSelection;
1736
+ });
1737
+ program.parse(argv);
1738
+ return selection;
1567
1739
  }
1568
- async function getRootCommit(cwd, ref) {
1569
- const out = await git(["rev-list", "--max-parents=0", ref], cwd, { ignoreErrors: true });
1570
- const roots = out.split("\n").filter(Boolean);
1571
- return roots.length > 0 ? roots[roots.length - 1] : null;
1572
- }
1573
- var SCAFFOLD_CANDIDATE_LIMIT = 400;
1574
- var SCAFFOLD_SCORE_CONCURRENCY = 10;
1575
- var SCAFFOLD_MATCH_MAX_DIFF_RATIO = 0.3;
1576
- async function inferScaffoldBase(cwd, headRef, upstreamRef) {
1577
- const root = await getRootCommit(cwd, headRef);
1578
- if (!root) return null;
1579
- const rootDate = await git(["show", "-s", "--format=%cI", root], cwd, { ignoreErrors: true });
1580
- if (!rootDate) return null;
1581
- const before = new Date(new Date(rootDate).getTime() + 24 * 60 * 60 * 1e3).toISOString();
1582
- const revList = await git(
1583
- ["rev-list", `--max-count=${SCAFFOLD_CANDIDATE_LIMIT}`, `--before=${before}`, upstreamRef],
1584
- cwd,
1585
- { ignoreErrors: true }
1586
- );
1587
- const candidates = revList.split("\n").filter(Boolean);
1588
- if (candidates.length === 0) return null;
1589
- const totalPaths = (await git(["ls-tree", "-r", "--name-only", root], cwd)).split("\n").filter(Boolean).length;
1590
- if (totalPaths === 0) return null;
1591
- let best = null;
1592
- outer: for (let i = 0; i < candidates.length; i += SCAFFOLD_SCORE_CONCURRENCY) {
1593
- const chunk = candidates.slice(i, i + SCAFFOLD_SCORE_CONCURRENCY);
1594
- const scores = await Promise.all(
1595
- chunk.map(async (sha) => {
1596
- try {
1597
- const out = await git(["diff-tree", "-r", "--name-only", sha, root], cwd);
1598
- return { sha, diff: out ? out.split("\n").length : 0 };
1599
- } catch {
1600
- return { sha, diff: Number.POSITIVE_INFINITY };
1601
- }
1602
- })
1603
- );
1604
- for (const score of scores) {
1605
- if (!best || score.diff < best.diff) best = score;
1606
- if (best.diff === 0) break outer;
1607
- }
1740
+ async function promptForService(userConfig, forkPath) {
1741
+ const selected = await select({
1742
+ message: "choose a service:",
1743
+ choices: buildServiceChoices(await getMenuContext(userConfig, forkPath)),
1744
+ loop: false
1745
+ });
1746
+ if (selected === "exit") {
1747
+ console.info(colors_default.dim("exiting..."));
1748
+ console.info();
1749
+ process5.exit(0);
1608
1750
  }
1609
- if (!best || best.diff / totalPaths > SCAFFOLD_MATCH_MAX_DIFF_RATIO) return null;
1610
- return best.sha;
1611
- }
1612
- async function readRootTrailerBase(cwd, headRef) {
1613
- const root = await getRootCommit(cwd, headRef);
1614
- if (!root) return null;
1615
- const message = await git(["show", "-s", "--format=%B", root], cwd, { ignoreErrors: true });
1616
- const match = message.match(/^cella-base:\s*([0-9a-f]{40})$/im);
1617
- return match ? match[1].toLowerCase() : null;
1618
- }
1619
- async function isShallowRepository(cwd) {
1620
- const out = await git(["rev-parse", "--is-shallow-repository"], cwd, { ignoreErrors: true });
1621
- return out === "true";
1751
+ console.info();
1752
+ return selected;
1622
1753
  }
1623
- async function unshallowRepository(cwd) {
1624
- try {
1625
- await git(["fetch", "--unshallow"], cwd, { skipEditor: true });
1626
- } catch (error) {
1627
- const detail = error instanceof Error ? `
1628
-
1629
- ${error.message}` : "";
1630
- throw new Error(
1631
- `this is a shallow clone and its full history could not be restored automatically.
1632
- Sync needs the complete history to find the common ancestor with upstream. Restore it with:
1633
-
1634
- git fetch --unshallow
1635
-
1636
- then re-run sync.${detail}`
1637
- );
1638
- }
1754
+ function buildRuntimeConfig(userConfig, forkPath, selection) {
1755
+ const { branchRef } = resolveUpstream(userConfig.settings);
1756
+ return {
1757
+ ...userConfig,
1758
+ forkPath,
1759
+ upstreamRef: branchRef,
1760
+ service: selection.service ?? "analyze",
1761
+ ...selection.options
1762
+ };
1639
1763
  }
1640
- async function ensureSyncBase(cwd, headRef, upstreamRef) {
1641
- if (await isShallowRepository(cwd)) {
1642
- await unshallowRepository(cwd);
1764
+ async function parseCli(userConfig, forkPath) {
1765
+ const selection = parseCommandLine(process5.argv);
1766
+ if (selection.options.json || selection.options.diff) setJsonMode(true);
1767
+ printHeader();
1768
+ const warnings = validateOverrides(userConfig, forkPath);
1769
+ if (warnings.length > 0) {
1770
+ printWarnings(warnings);
1771
+ console.info();
1643
1772
  }
1644
- const nativeBase = await git(["merge-base", headRef, upstreamRef], cwd, { ignoreErrors: true });
1645
- if (nativeBase) return;
1646
- const sources = [
1647
- () => getStoredSyncRef(cwd),
1648
- () => readManifestBaseAtRef(cwd, headRef),
1649
- () => readManifestBase(cwd),
1650
- () => readRootTrailerBase(cwd, headRef),
1651
- () => inferScaffoldBase(cwd, headRef, upstreamRef)
1652
- ];
1653
- let baseSha = null;
1654
- for (const source of sources) {
1655
- const sha = await source();
1656
- if (sha && await commitObjectExists(cwd, sha)) {
1657
- baseSha = sha;
1658
- break;
1659
- }
1773
+ if (!selection.service) {
1774
+ selection.service = await promptForService(userConfig, forkPath);
1660
1775
  }
1661
- if (!baseSha) {
1662
- throw new Error(
1663
- `no common ancestor between the fork and '${upstreamRef}', and no sync base could be determined.
1776
+ return buildRuntimeConfig(userConfig, forkPath, selection);
1777
+ }
1664
1778
 
1665
- This fork has unrelated history with upstream \u2014 typical for a create-cella scaffold, or after
1666
- upstream squashed its history \u2014 and no recorded or inferable base commit exists after fetching
1667
- upstream. Seed it manually with the upstream commit the fork was created from, then re-run sync:
1779
+ // src/services/analyze.ts
1780
+ import { basename as basename4 } from "path";
1668
1781
 
1669
- git update-ref refs/cella/last-sync <upstream-sha>
1670
- `
1671
- );
1672
- }
1673
- const root = await getRootCommit(cwd, headRef);
1674
- if (!root) throw new Error("could not determine the fork's root commit to bootstrap sync ancestry");
1675
- await git(["replace", "-f", "--graft", root, baseSha], cwd);
1676
- await git(["update-ref", "refs/cella/last-sync", baseSha], cwd);
1677
- }
1678
- async function isAncestor(cwd, ancestor, descendant) {
1782
+ // src/utils/diff.ts
1783
+ import { execFileSync } from "child_process";
1784
+ import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
1785
+ import { tmpdir } from "os";
1786
+ import { basename as basename2, join as join9 } from "path";
1787
+ import process6 from "process";
1788
+ function gitDiffFile(cwd, range, filePath, options = {}) {
1789
+ const args = ["diff", "--no-color"];
1790
+ if (options.dstPrefix) args.push("--src-prefix=cella/", `--dst-prefix=${options.dstPrefix}/`);
1791
+ args.push(range, "--", filePath);
1679
1792
  try {
1680
- await git(["merge-base", "--is-ancestor", ancestor, descendant], cwd);
1681
- return true;
1682
- } catch {
1683
- return false;
1684
- }
1685
- }
1686
- async function getLegacySyncRef(cwd, headRef) {
1687
- const storedRef = await getStoredSyncRef(cwd);
1688
- if (!storedRef) return null;
1689
- const storedHead = await getStoredSyncHead(cwd);
1690
- if (storedHead) {
1691
- const currentHead = await git(["rev-parse", headRef], cwd, { ignoreErrors: true });
1692
- if (currentHead && currentHead === storedHead) return null;
1793
+ return execFileSync("git", args, { cwd, maxBuffer: 50 * 1024 * 1024 });
1794
+ } catch (error) {
1795
+ const stderr = error.stderr?.toString().trim();
1796
+ throw new Error(stderr || `failed to diff ${filePath}`);
1693
1797
  }
1694
- return storedRef;
1695
1798
  }
1696
- async function getEffectiveMergeBase(cwd, headRef, upstreamRef) {
1697
- const gitBase = await getMergeBase(cwd, headRef, upstreamRef);
1698
- const recorded = await readManifestBaseAtRef(cwd, headRef) ?? await getLegacySyncRef(cwd, headRef);
1699
- if (!recorded) {
1700
- return { base: gitBase, isStale: false, storedRef: null };
1799
+ function escapeHtml(text) {
1800
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1801
+ }
1802
+ async function renderDiffPage(patch, meta) {
1803
+ const { preloadPatchDiff } = await import("@pierre/diffs/ssr");
1804
+ const { prerenderedHTML } = await preloadPatchDiff({
1805
+ patch,
1806
+ options: {
1807
+ diffStyle: "split",
1808
+ themeType: "system",
1809
+ lineDiffType: "word-alt",
1810
+ hunkSeparators: "line-info",
1811
+ overflow: "wrap",
1812
+ disableFileHeader: true
1813
+ }
1814
+ });
1815
+ const title = `${escapeHtml(meta.filePath)} \xB7 ${escapeHtml(meta.srcLabel)} vs ${escapeHtml(meta.dstLabel)}`;
1816
+ const note = meta.note ? `<span class="note">\xB7 ${escapeHtml(meta.note)}</span>` : "";
1817
+ return `<!doctype html>
1818
+ <html lang="en">
1819
+ <head>
1820
+ <meta charset="utf-8">
1821
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1822
+ <title>${title}</title>
1823
+ <style>
1824
+ :root { color-scheme: light dark; }
1825
+ * { box-sizing: border-box; }
1826
+ body {
1827
+ margin: 0;
1828
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
1829
+ background: light-dark(#fafafa, #111);
1830
+ color: light-dark(#111, #eee);
1701
1831
  }
1702
- const recordedIsNewer = await isAncestor(cwd, gitBase, recorded);
1703
- if (recordedIsNewer && recorded !== gitBase) {
1704
- return { base: recorded, isStale: true, storedRef: recorded };
1832
+ header {
1833
+ display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap;
1834
+ padding: 0.9rem 1.25rem;
1835
+ border-bottom: 1px solid light-dark(#e2e2e2, #2a2a2a);
1836
+ position: sticky; top: 0;
1837
+ background: inherit;
1705
1838
  }
1706
- return { base: gitBase, isStale: false, storedRef: recorded };
1839
+ header .brand { font-weight: 600; color: light-dark(#0891b2, #22d3ee); }
1840
+ header code { font-family: ui-monospace, monospace; font-size: 0.9rem; }
1841
+ header .sides { margin-left: auto; font-size: 0.8rem; opacity: 0.7; }
1842
+ main { max-width: 1400px; margin: 1rem auto; padding: 0 1rem 2rem; }
1843
+ .diff-shell {
1844
+ border: 1px solid light-dark(#e2e2e2, #2a2a2a);
1845
+ border-radius: 8px; overflow: hidden;
1846
+ background: light-dark(#fff, #161616);
1847
+ }
1848
+ </style>
1849
+ </head>
1850
+ <body>
1851
+ <header>
1852
+ <span class="brand">\u29C8 cella diff</span>
1853
+ <code>${escapeHtml(meta.filePath)}</code>
1854
+ <span class="sides">${escapeHtml(meta.srcLabel)} \u2190 \u2192 ${escapeHtml(meta.dstLabel)} ${note}</span>
1855
+ </header>
1856
+ <main>
1857
+ <!-- Declarative shadow DOM: the library's CSS targets :host, which only
1858
+ matches inside a shadow root (its browser component renders into one). -->
1859
+ <div class="diff-shell">
1860
+ <template shadowrootmode="open">
1861
+ ${prerenderedHTML}
1862
+ </template>
1863
+ </div>
1864
+ </main>
1865
+ </body>
1866
+ </html>`;
1707
1867
  }
1708
- async function withTemporarySyncBaseGraft(cwd, headRef, baseSha, fn) {
1709
- const headSha = await git(["rev-parse", headRef], cwd, { ignoreErrors: true });
1710
- if (!headSha || !await commitObjectExists(cwd, baseSha)) return fn();
1711
- if (await isAncestor(cwd, baseSha, headSha)) return fn();
1712
- const existingReplacement = await git(["replace", "-l", headSha], cwd, { ignoreErrors: true });
1713
- if (existingReplacement) return fn();
1714
- const parentsRaw = await git(["log", "-1", "--format=%P", headSha], cwd, { ignoreErrors: true });
1715
- const parents = parentsRaw ? parentsRaw.split(/\s+/) : [];
1716
- await git(["replace", "-f", "--graft", headSha, ...parents, baseSha], cwd);
1868
+ function openWithDefaultApp(target) {
1717
1869
  try {
1718
- return await fn();
1719
- } finally {
1720
- await git(["replace", "-d", headSha], cwd, { ignoreErrors: true });
1870
+ if (process6.platform === "win32") {
1871
+ execFileSync("rundll32", ["url.dll,FileProtocolHandler", target], { stdio: "ignore" });
1872
+ } else {
1873
+ execFileSync(process6.platform === "darwin" ? "open" : "xdg-open", [target], { stdio: "ignore" });
1874
+ }
1875
+ } catch {
1721
1876
  }
1722
1877
  }
1878
+ async function openDiffInBrowser(patch, meta, repoPath) {
1879
+ const html = await renderDiffPage(patch, meta);
1880
+ const dir = join9(tmpdir(), `cella-diff-${basename2(repoPath)}`);
1881
+ mkdirSync(dir, { recursive: true });
1882
+ const outPath = join9(dir, `${meta.filePath.replace(/[^\w.-]+/g, "-")}.html`);
1883
+ writeFileSync2(outPath, html);
1884
+ openWithDefaultApp(outPath);
1885
+ return outPath;
1886
+ }
1887
+
1888
+ // src/services/merge-engine.ts
1889
+ import { existsSync as existsSync7 } from "fs";
1890
+ import { rm as rm2 } from "fs/promises";
1891
+ import { join as join11 } from "path";
1723
1892
 
1724
1893
  // src/utils/cleanup.ts
1894
+ import { createHash } from "crypto";
1895
+ import { existsSync as existsSync6, rmSync } from "fs";
1896
+ import { tmpdir as tmpdir2 } from "os";
1897
+ import { basename as basename3, join as join10, resolve as resolve3 } from "path";
1898
+ import process7 from "process";
1725
1899
  var WORKTREE_PREFIXES = {
1726
1900
  sync: "cella-sync-",
1727
1901
  view: "cella-view-"
1728
1902
  };
1729
1903
  function buildWorktreePath(kind, repoPath) {
1730
- return join9(tmpdir2(), `${WORKTREE_PREFIXES[kind]}${basename3(repoPath)}`);
1904
+ const key = createHash("sha256").update(resolve3(repoPath)).digest("hex").slice(0, 8);
1905
+ return join10(tmpdir2(), `${WORKTREE_PREFIXES[kind]}${basename3(repoPath)}-${key}`);
1906
+ }
1907
+ function legacyWorktreePath(kind, repoPath) {
1908
+ return join10(tmpdir2(), `${WORKTREE_PREFIXES[kind]}${basename3(repoPath)}`);
1731
1909
  }
1732
1910
  function getWorktreePath(repoPath) {
1733
1911
  return buildWorktreePath("sync", repoPath);
@@ -1745,22 +1923,21 @@ function unregisterWorktree() {
1745
1923
  }
1746
1924
  async function cleanupWorktree(repoPath, worktreePath) {
1747
1925
  await removeWorktree(repoPath, worktreePath);
1748
- if (existsSync5(worktreePath)) {
1926
+ if (existsSync6(worktreePath)) {
1749
1927
  rmSync(worktreePath, { recursive: true, force: true });
1750
1928
  }
1751
1929
  unregisterWorktree();
1752
1930
  }
1753
1931
  async function cleanupLeftoverWorktrees(repoPath) {
1754
1932
  for (const kind of Object.keys(WORKTREE_PREFIXES)) {
1755
- const worktreePath = buildWorktreePath(kind, repoPath);
1756
- if (existsSync5(worktreePath)) {
1757
- await cleanupWorktree(repoPath, worktreePath);
1933
+ for (const worktreePath of [buildWorktreePath(kind, repoPath), legacyWorktreePath(kind, repoPath)]) {
1934
+ if (existsSync6(worktreePath)) await cleanupWorktree(repoPath, worktreePath);
1758
1935
  }
1759
1936
  }
1760
1937
  const prefixes = Object.values(WORKTREE_PREFIXES);
1761
1938
  const worktrees = await listWorktrees(repoPath);
1762
1939
  for (const wt of worktrees) {
1763
- if (prefixes.some((prefix) => wt.includes(prefix)) && !existsSync5(wt)) {
1940
+ if (prefixes.some((prefix) => wt.includes(prefix)) && !existsSync6(wt)) {
1764
1941
  await removeWorktree(repoPath, wt);
1765
1942
  }
1766
1943
  }
@@ -1791,7 +1968,160 @@ function registerSignalHandlers() {
1791
1968
  cleanupRegistered = true;
1792
1969
  }
1793
1970
 
1971
+ // src/utils/versions.ts
1972
+ function parseComparableVersion(version) {
1973
+ const trimmed = version.trim();
1974
+ if (trimmed === "" || trimmed === "*" || trimmed.includes("workspace:") || trimmed.includes("catalog:") || trimmed.includes("file:") || trimmed.includes("link:") || trimmed.includes("git+") || trimmed.includes("github:") || trimmed.includes("http://") || trimmed.includes("https://") || trimmed.includes("||")) {
1975
+ return null;
1976
+ }
1977
+ const match = trimmed.match(
1978
+ /^(?:\^|~|>=|<=|>|<|=)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
1979
+ );
1980
+ if (!match) return null;
1981
+ return {
1982
+ major: Number(match[1]),
1983
+ minor: Number(match[2] ?? 0),
1984
+ patch: Number(match[3] ?? 0)
1985
+ };
1986
+ }
1987
+ function compareVersions(left, right) {
1988
+ if (left.major !== right.major) return left.major - right.major;
1989
+ if (left.minor !== right.minor) return left.minor - right.minor;
1990
+ return left.patch - right.patch;
1991
+ }
1992
+
1993
+ // src/utils/cli-version.ts
1994
+ var CLI_PACKAGE = "@cellajs/cli";
1995
+ async function readUpstreamCliRange(repoPath, ref) {
1996
+ const source = await git(["show", `${ref}:package.json`], repoPath, { ignoreErrors: true });
1997
+ if (!source) return null;
1998
+ try {
1999
+ const pkg = JSON.parse(source);
2000
+ return pkg.devDependencies?.[CLI_PACKAGE] ?? pkg.dependencies?.[CLI_PACKAGE] ?? null;
2001
+ } catch {
2002
+ return null;
2003
+ }
2004
+ }
2005
+ function cliVersionMismatch(running, range) {
2006
+ if (/^\s*(<|>(?!=))/.test(range)) return null;
2007
+ const required = parseComparableVersion(range);
2008
+ const current = parseComparableVersion(running);
2009
+ if (!required || !current || compareVersions(current, required) >= 0) return null;
2010
+ return [
2011
+ `upstream needs ${CLI_PACKAGE} ${range}, this is ${running}: an older CLI misses fixes to the sync itself.`,
2012
+ ` update it, commit, then rerun: pnpm add -D -w '${CLI_PACKAGE}@${range}'`
2013
+ ].join("\n");
2014
+ }
2015
+
2016
+ // src/utils/upstream-overrides.ts
2017
+ import { Node, Project as Project2, SyntaxKind as SyntaxKind2 } from "ts-morph";
2018
+ var LIST_KEYS = ["pinned", "ignored"];
2019
+ function resolveExpression(node, sourceFile, depth = 0) {
2020
+ if (!node || depth > 8) return node;
2021
+ if (Node.isParenthesizedExpression(node) || Node.isAsExpression(node) || Node.isSatisfiesExpression(node)) {
2022
+ return resolveExpression(node.getExpression(), sourceFile, depth + 1);
2023
+ }
2024
+ if (Node.isIdentifier(node)) {
2025
+ return resolveExpression(
2026
+ sourceFile.getVariableDeclaration(node.getText())?.getInitializer(),
2027
+ sourceFile,
2028
+ depth + 1
2029
+ );
2030
+ }
2031
+ return node;
2032
+ }
2033
+ function findConfigObject(sourceFile) {
2034
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind2.CallExpression)) {
2035
+ if (call.getExpression().getText() !== "defineConfig") continue;
2036
+ const arg = resolveExpression(call.getArguments()[0], sourceFile);
2037
+ if (Node.isObjectLiteralExpression(arg)) return arg;
2038
+ }
2039
+ const exported = sourceFile.getExportAssignment((assignment) => !assignment.isExportEquals());
2040
+ const value = resolveExpression(exported?.getExpression(), sourceFile);
2041
+ return Node.isObjectLiteralExpression(value) ? value : void 0;
2042
+ }
2043
+ function readProperty(object, name, sourceFile) {
2044
+ for (const property of object.getProperties()) {
2045
+ if (Node.isPropertyAssignment(property)) {
2046
+ if (property.getName().replace(/^['"`]|['"`]$/g, "") !== name) continue;
2047
+ return resolveExpression(property.getInitializer(), sourceFile) ?? null;
2048
+ }
2049
+ if (Node.isShorthandPropertyAssignment(property) && property.getName() === name) {
2050
+ return resolveExpression(property.getNameNode(), sourceFile) ?? null;
2051
+ }
2052
+ }
2053
+ return void 0;
2054
+ }
2055
+ function parseOverrideLists(source) {
2056
+ try {
2057
+ const project = new Project2({ useInMemoryFileSystem: true, skipLoadingLibFiles: true });
2058
+ const sourceFile = project.createSourceFile("cella.config.ts", source);
2059
+ const config = findConfigObject(sourceFile);
2060
+ if (!config) return null;
2061
+ const overrides = readProperty(config, "overrides", sourceFile);
2062
+ if (overrides === void 0) return { pinned: [], ignored: [] };
2063
+ if (!overrides || !Node.isObjectLiteralExpression(overrides)) return null;
2064
+ const lists = { pinned: [], ignored: [] };
2065
+ for (const key of LIST_KEYS) {
2066
+ const list = readProperty(overrides, key, sourceFile);
2067
+ if (list === void 0) continue;
2068
+ if (!list || !Node.isArrayLiteralExpression(list)) return null;
2069
+ for (const element of list.getElements()) {
2070
+ if (Node.isStringLiteral(element) || Node.isNoSubstitutionTemplateLiteral(element)) {
2071
+ lists[key].push(element.getLiteralValue());
2072
+ }
2073
+ }
2074
+ }
2075
+ return lists;
2076
+ } catch {
2077
+ return null;
2078
+ }
2079
+ }
2080
+ function normalizeEntry(entry) {
2081
+ return entry.trim().replace(/^\.\//, "").replace(/\/+$/, "");
2082
+ }
2083
+ function diffOverrideLists(base, incoming, fork) {
2084
+ const diff = (key) => {
2085
+ const baseSet = new Set(base[key].map(normalizeEntry).filter(Boolean));
2086
+ const incomingSet = new Set(incoming[key].map(normalizeEntry).filter(Boolean));
2087
+ const forkEntries = fork[key].map(normalizeEntry).filter(Boolean);
2088
+ return {
2089
+ added: [...incomingSet].filter((entry) => !baseSet.has(entry) && !isUnderAnyFolder(entry, forkEntries)),
2090
+ removed: [...baseSet].filter((entry) => !incomingSet.has(entry) && forkEntries.includes(entry))
2091
+ };
2092
+ };
2093
+ return { pinned: diff("pinned"), ignored: diff("ignored") };
2094
+ }
2095
+ async function readConfigSourceAtRef(repoPath, ref) {
2096
+ for (const path4 of CONFIG_FILE_PATHS) {
2097
+ const source = await git(["show", `${ref}:${path4}`], repoPath, { ignoreErrors: true });
2098
+ if (source) return source;
2099
+ }
2100
+ return null;
2101
+ }
2102
+ async function compareUpstreamOverrides(repoPath, mergeBaseRef, incomingRef, forkConfig) {
2103
+ const [baseSource, incomingSource] = await Promise.all([
2104
+ readConfigSourceAtRef(repoPath, mergeBaseRef),
2105
+ readConfigSourceAtRef(repoPath, incomingRef)
2106
+ ]);
2107
+ if (baseSource === incomingSource) return void 0;
2108
+ const base = baseSource === null ? null : parseOverrideLists(baseSource);
2109
+ if (!base) return { kind: "unreadable", side: "base" };
2110
+ const incoming = incomingSource === null ? null : parseOverrideLists(incomingSource);
2111
+ if (!incoming) return { kind: "unreadable", side: "incoming" };
2112
+ const fork = {
2113
+ pinned: forkConfig.overrides?.pinned ?? [],
2114
+ ignored: forkConfig.overrides?.ignored ?? []
2115
+ };
2116
+ const changes = diffOverrideLists(base, incoming, fork);
2117
+ const count = LIST_KEYS.reduce((n, key) => n + changes[key].added.length + changes[key].removed.length, 0);
2118
+ return count > 0 ? { kind: "changes", ...changes } : void 0;
2119
+ }
2120
+
1794
2121
  // src/services/analyze-core.ts
2122
+ function isReportableIgnored(filePath) {
2123
+ return !isManagedFile(filePath) && !isGeneratedFile(filePath);
2124
+ }
1795
2125
  async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predicates, onProgress) {
1796
2126
  onProgress?.("collecting file hashes (batch)...");
1797
2127
  const [forkHashes, upstreamHashes, baseHashes] = await Promise.all([
@@ -1870,6 +2200,7 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
1870
2200
  status = "renamed";
1871
2201
  renamedFrom = oldPath;
1872
2202
  }
2203
+ const upstreamOnly2 = fileIsIgnored && !forkModifiedOld && isReportableIgnored(filePath);
1873
2204
  analyzedFiles.push({
1874
2205
  path: filePath,
1875
2206
  status,
@@ -1878,7 +2209,8 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
1878
2209
  existsInFork: inFork,
1879
2210
  existsInUpstream: true,
1880
2211
  renamedFrom,
1881
- upstreamChanged: upstreamChanged2
2212
+ upstreamChanged: upstreamChanged2,
2213
+ upstreamOnly: upstreamOnly2 || void 0
1882
2214
  });
1883
2215
  continue;
1884
2216
  }
@@ -1913,6 +2245,7 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
1913
2245
  }
1914
2246
  }
1915
2247
  const upstreamChanged = (fileIsPinned || fileIsIgnored) && inUpstream && upstreamHash !== baseHash && forkHash !== baseHash;
2248
+ const upstreamOnly = fileIsIgnored && isReportableIgnored(filePath) && upstreamHash !== baseHash && forkHash === baseHash;
1916
2249
  analyzedFiles.push({
1917
2250
  path: filePath,
1918
2251
  status,
@@ -1920,7 +2253,8 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
1920
2253
  isPinned: fileIsPinned,
1921
2254
  existsInFork: inFork,
1922
2255
  existsInUpstream: inUpstream,
1923
- upstreamChanged: upstreamChanged || void 0
2256
+ upstreamChanged: upstreamChanged || void 0,
2257
+ upstreamOnly: upstreamOnly || void 0
1924
2258
  });
1925
2259
  }
1926
2260
  const flagged = analyzedFiles.filter((file) => file.upstreamChanged && !renamedProtected.has(file.path));
@@ -2209,6 +2543,9 @@ async function prepareUpstream(config, onProgress, onStep) {
2209
2543
  }
2210
2544
  config.upstreamRef = upstreamRef;
2211
2545
  onStep?.("remote configured", `${releaseTag ?? upstreamRef} \u2192 ${config.settings.upstreamUrl}`);
2546
+ const cliRange = await readUpstreamCliRange(forkPath, upstreamRef);
2547
+ const cliMismatch = cliRange ? cliVersionMismatch(VERSION, cliRange) : null;
2548
+ if (cliMismatch) throw new Error(cliMismatch);
2212
2549
  onProgress?.("checking sync base...");
2213
2550
  await ensureSyncBase(forkPath, "HEAD", upstreamRef);
2214
2551
  const upstreamGitHubUrl = getGitHubBaseUrl(config.settings.upstreamUrl) ?? void 0;
@@ -2236,9 +2573,52 @@ function resultMeta(config, ctx) {
2236
2573
  upstreamCommits: ctx.upstreamCommits
2237
2574
  };
2238
2575
  }
2576
+ async function upstreamOnlyReports(config, ctx, files) {
2577
+ const { forkPath } = config;
2578
+ try {
2579
+ const [baseSha, upstreamSha, upstreamOverrides] = await Promise.all([
2580
+ getShortSha(forkPath, ctx.mergeBase),
2581
+ // the commit, not an annotated release tag object
2582
+ getShortSha(forkPath, ctx.upstreamCommit.hash),
2583
+ compareUpstreamOverrides(forkPath, ctx.mergeBase, ctx.upstreamRef, config)
2584
+ ]);
2585
+ return {
2586
+ upstreamDiffRange: `${baseSha}..${upstreamSha}`,
2587
+ upstreamOverrides,
2588
+ ignoredUpstreamChanges: groupIgnoredUpstreamChanges(files, config.overrides?.ignored ?? [])
2589
+ };
2590
+ } catch {
2591
+ return {};
2592
+ }
2593
+ }
2594
+ async function readNotesInRange(config, ctx) {
2595
+ const { forkPath } = config;
2596
+ try {
2597
+ const aggressive = config.hard === true || config.unpinned === true;
2598
+ const syncBase = aggressive ? (await getEffectiveMergeBase(forkPath, "HEAD", ctx.upstreamRef)).base : ctx.mergeBase;
2599
+ const [total, arrived, pending] = await Promise.all([
2600
+ listNoteIds(forkPath, ctx.upstreamRef),
2601
+ arrivedNoteIds(forkPath, syncBase, ctx.upstreamRef),
2602
+ readPending(forkPath, syncBase)
2603
+ ]);
2604
+ return { total: total.length, arrived, pending: [.../* @__PURE__ */ new Set([...pending, ...arrived])] };
2605
+ } catch {
2606
+ return void 0;
2607
+ }
2608
+ }
2609
+ function notesResult(notes) {
2610
+ return notes ? { migrationNotes: { total: notes.total, arrived: notes.arrived, open: notes.pending.length } } : {};
2611
+ }
2612
+ async function removeUpstreamOnlyCopy(forkPath, onStep) {
2613
+ if (!existsSync7(join11(forkPath, NOTES_DIR))) return;
2614
+ await git(["rm", "-r", "-q", "--ignore-unmatch", "--", NOTES_DIR], forkPath);
2615
+ await rm2(join11(forkPath, NOTES_DIR), { recursive: true, force: true });
2616
+ onStep?.("migration notes", `removed ${NOTES_DIR}/: notes stay upstream, list them with pnpm cella migrate`);
2617
+ }
2239
2618
  async function runSyncMerge(config, ctx, onProgress, onStep) {
2240
2619
  const { forkPath } = config;
2241
2620
  const { upstreamRef, releaseTag, mergeBase, upstreamGitHubUrl, upstreamCommit } = ctx;
2621
+ const notes = await readNotesInRange(config, ctx);
2242
2622
  const { remainingConflicts, analyzedFiles, autoMergedFiles, protectedConflicts } = await applyDirectMerge(
2243
2623
  forkPath,
2244
2624
  upstreamRef,
@@ -2265,6 +2645,11 @@ async function runSyncMerge(config, ctx, onProgress, onStep) {
2265
2645
  await storeLastSyncRef(forkPath, upstreamCommit.hash);
2266
2646
  await writeSyncManifest(forkPath, syncManifest);
2267
2647
  await stagePath(forkPath, MANIFEST_FILE);
2648
+ if (notes) {
2649
+ await writePending(forkPath, notes.pending);
2650
+ await stagePath(forkPath, PENDING_FILE);
2651
+ }
2652
+ await removeUpstreamOnlyCopy(forkPath, onStep);
2268
2653
  };
2269
2654
  if (remainingConflicts.length > 0) {
2270
2655
  await recordSyncPoint();
@@ -2287,12 +2672,15 @@ async function runSyncMerge(config, ctx, onProgress, onStep) {
2287
2672
  conflicts: remainingConflicts,
2288
2673
  autoMergedFiles,
2289
2674
  protectedConflicts,
2290
- ...resultMeta(config, ctx)
2675
+ ...resultMeta(config, ctx),
2676
+ ...await upstreamOnlyReports(config, ctx, analyzedFiles),
2677
+ ...notesResult(notes)
2291
2678
  };
2292
2679
  }
2293
2680
  async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2294
2681
  const { forkPath } = config;
2295
2682
  const worktreePath = getWorktreePath(forkPath);
2683
+ const notes = await readNotesInRange(config, ctx);
2296
2684
  onProgress?.("creating worktree in temp directory...");
2297
2685
  await createWorktree(forkPath, worktreePath, "HEAD");
2298
2686
  onStep?.("worktree created", worktreePath);
@@ -2314,6 +2702,7 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2314
2702
  onProgress
2315
2703
  );
2316
2704
  await enrichChangeInfo(forkPath, analyzedFiles, ctx.mergeBase, "HEAD", ctx.upstreamRef);
2705
+ const reports = await upstreamOnlyReports(config, ctx, analyzedFiles);
2317
2706
  onStep?.("analysis complete", `${analyzedFiles.length} files analyzed, dry run \u2014 no changes applied`);
2318
2707
  onProgress?.("cleaning up worktree...");
2319
2708
  await cleanupWorktree(forkPath, worktreePath);
@@ -2324,7 +2713,9 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2324
2713
  // For analyze mode, count diverged files as potential conflicts
2325
2714
  conflicts: analyzedFiles.filter((f) => f.status === "diverged").map((f) => f.path),
2326
2715
  protectedConflicts: analyzedFiles.filter((f) => f.upstreamChanged).map((f) => f.path),
2327
- ...resultMeta(config, ctx)
2716
+ ...resultMeta(config, ctx),
2717
+ ...reports,
2718
+ ...notesResult(notes)
2328
2719
  };
2329
2720
  }
2330
2721
  async function runMergeEngine(config, options) {
@@ -2342,16 +2733,164 @@ async function runMergeEngine(config, options) {
2342
2733
  }
2343
2734
  }
2344
2735
 
2736
+ // src/services/migrate.ts
2737
+ import { execFileSync as execFileSync2 } from "child_process";
2738
+ import { mkdir as mkdir4, writeFile as writeFile3 } from "fs/promises";
2739
+ import { dirname as dirname4, join as join12 } from "path";
2740
+ var EXTRACT_DIR = "node_modules/.cache/cella/migrations";
2741
+ async function resolveNotesRef(config) {
2742
+ const { forkPath } = config;
2743
+ const ref = await getStoredSyncRef(forkPath) ?? await readManifestBaseAtRef(forkPath, "HEAD");
2744
+ if (!ref) return null;
2745
+ const present = async () => !!await git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], forkPath, { ignoreErrors: true });
2746
+ if (!await present()) {
2747
+ await ensureRemote(forkPath, DEFAULT_UPSTREAM_REMOTE, config.settings.upstreamUrl);
2748
+ await fetch2(forkPath, DEFAULT_UPSTREAM_REMOTE);
2749
+ if (!await present())
2750
+ throw new Error(`upstream commit ${ref.slice(0, 7)} is not reachable from '${DEFAULT_UPSTREAM_REMOTE}'`);
2751
+ }
2752
+ return ref;
2753
+ }
2754
+ async function readNotesStatus(config) {
2755
+ try {
2756
+ const ref = await resolveNotesRef(config);
2757
+ if (!ref) return null;
2758
+ const [ids, pending] = await Promise.all([listNoteIds(config.forkPath, ref), readPending(config.forkPath, ref)]);
2759
+ return ids.length > 0 || pending.length > 0 ? { total: ids.length, open: pending.length } : null;
2760
+ } catch {
2761
+ return null;
2762
+ }
2763
+ }
2764
+ async function printMigrationNotesLine(config, result) {
2765
+ const notes = result?.migrationNotes;
2766
+ if (notes) {
2767
+ if (notes.total === 0) return;
2768
+ console.info();
2769
+ console.info(
2770
+ colors_default.dim(formatNotesLine(notes.total, notes.open, notes.arrived.length, { dryRun: config.service === "analyze" }))
2771
+ );
2772
+ return;
2773
+ }
2774
+ const status = await readNotesStatus(config);
2775
+ if (!status) return;
2776
+ console.info();
2777
+ console.info(colors_default.dim(formatNotesLine(status.total, status.open)));
2778
+ }
2779
+ function toJson(note, url, open) {
2780
+ return { ...note, kind: note.codemod ? "codemod" : "manual", open, url: url ?? null };
2781
+ }
2782
+ function printNote(index, note, url) {
2783
+ const tags = [
2784
+ note.codemod ? "codemod" : "manual",
2785
+ note.syncBreaking ? "sync-breaking" : null,
2786
+ note.clientCacheBump ? "cache-bump" : null
2787
+ ].filter(Boolean).join(", ");
2788
+ console.info(`${index}. ${note.title} ${colors_default.dim(`[${tags}]`)}`);
2789
+ console.info(colors_default.dim(` id: ${note.id}`));
2790
+ if (note.summary) console.info(` ${note.summary}`);
2791
+ console.info(colors_default.dim(` read: pnpm cella migrate --show ${note.id}`));
2792
+ if (url) console.info(colors_default.dim(` ${url}`));
2793
+ if (note.codemod) console.info(colors_default.dim(` codemod: pnpm cella migrate --extract ${note.id}`));
2794
+ console.info();
2795
+ }
2796
+ async function extractNote(config, ref, id) {
2797
+ const { forkPath } = config;
2798
+ const note = await readNote(forkPath, ref, id);
2799
+ if (!note) throw new Error(`no migration note '${id}' at upstream ${ref.slice(0, 7)}`);
2800
+ const files = (await git(["ls-tree", "-r", "--name-only", `${ref}:${NOTES_DIR}/${id}`], forkPath)).split("\n").filter(Boolean);
2801
+ const target = join12(EXTRACT_DIR, id);
2802
+ for (const file of files) {
2803
+ const content = execFileSync2("git", ["show", `${ref}:${NOTES_DIR}/${id}/${file}`], {
2804
+ cwd: forkPath,
2805
+ maxBuffer: 50 * 1024 * 1024
2806
+ });
2807
+ const path4 = join12(forkPath, target, file);
2808
+ await mkdir4(dirname4(path4), { recursive: true });
2809
+ await writeFile3(path4, content);
2810
+ }
2811
+ console.info(`${colors_default.green("\u2713")} extracted ${files.length} file(s) to ${target}/`);
2812
+ console.info(colors_default.dim(` the README's ${NOTES_DIR}/${id}/ paths are this folder.`));
2813
+ if (note.codemod) console.info(colors_default.dim(` pnpm exec tsx ${target}/${note.codemod} inventory ${note.roots.join(" ")}`));
2814
+ }
2815
+ async function markNotes(config, ref, pending, ids) {
2816
+ const open = new Set(pending);
2817
+ const unknown = ids.filter((id) => !open.has(id));
2818
+ for (const id of ids) open.delete(id);
2819
+ await writePending(config.forkPath, [...open]);
2820
+ const marked = ids.length - unknown.length;
2821
+ if (marked > 0) console.info(`${colors_default.green("\u2713")} recorded ${marked} note(s) as handled`);
2822
+ if (unknown.length > 0) console.info(colors_default.dim(`not pending, left as is: ${unknown.join(", ")}`));
2823
+ console.info(colors_default.dim(formatNotesLine((await listNoteIds(config.forkPath, ref)).length, open.size)));
2824
+ }
2825
+ async function runMigrate(config) {
2826
+ const { forkPath } = config;
2827
+ const ref = await resolveNotesRef(config);
2828
+ if (!ref) {
2829
+ console.info(colors_default.dim("no sync point recorded yet: migration notes start with the first `pnpm cella sync`."));
2830
+ return;
2831
+ }
2832
+ const pending = await readPending(forkPath, ref);
2833
+ const githubUrl = getGitHubBaseUrl(config.settings.upstreamUrl) ?? void 0;
2834
+ const urlOf = (id) => noteUrl(githubUrl, ref, id);
2835
+ if (config.mark?.length) return markNotes(config, ref, pending, config.mark);
2836
+ if (config.extract) return extractNote(config, ref, config.extract);
2837
+ if (config.show) {
2838
+ const source = await git(["show", `${ref}:${NOTES_DIR}/${config.show}/README.md`], forkPath, {
2839
+ ignoreErrors: true
2840
+ });
2841
+ if (!source) throw new Error(`no migration note '${config.show}' at upstream ${ref.slice(0, 7)}`);
2842
+ const url = urlOf(config.show);
2843
+ if (url) console.info(colors_default.dim(url));
2844
+ console.info(source);
2845
+ return;
2846
+ }
2847
+ const ids = config.all ? await listNoteIds(forkPath, ref) : pending;
2848
+ const notes = (await Promise.all(ids.map((id) => readNote(forkPath, ref, id)))).map(
2849
+ (note, i) => note ?? {
2850
+ id: ids[i],
2851
+ title: `${ids[i]} (no longer upstream)`,
2852
+ summary: "",
2853
+ syncBreaking: false,
2854
+ clientCacheBump: false,
2855
+ roots: [],
2856
+ codemod: null
2857
+ }
2858
+ );
2859
+ const total = (await listNoteIds(forkPath, ref)).length;
2860
+ if (config.json) {
2861
+ const open = new Set(pending);
2862
+ const out = {
2863
+ upstreamCommit: ref,
2864
+ total,
2865
+ open: pending.length,
2866
+ notes: notes.map((note) => toJson(note, urlOf(note.id), open.has(note.id)))
2867
+ };
2868
+ writeStdout(JSON.stringify(out, null, 2));
2869
+ return;
2870
+ }
2871
+ console.info(colors_default.dim(formatNotesLine(total, pending.length)));
2872
+ if (notes.length === 0) return;
2873
+ console.info();
2874
+ for (const [i, note] of notes.entries()) printNote(i + 1, note, urlOf(note.id));
2875
+ if (pending.length > 0) {
2876
+ console.info(colors_default.dim("work each note's README, check with `pnpm check`, then record it:"));
2877
+ console.info(colors_default.dim(` pnpm cella migrate --mark ${pending.join(" ")}`));
2878
+ }
2879
+ }
2880
+
2345
2881
  // src/services/analyze.ts
2346
2882
  var scopeStatuses = {
2347
2883
  all: /* @__PURE__ */ new Set(["ahead", "drifted", "diverged"]),
2348
2884
  risk: /* @__PURE__ */ new Set(["drifted", "diverged"]),
2349
2885
  protected: /* @__PURE__ */ new Set(["ahead"])
2350
2886
  };
2351
- function filterByScope(files, scope) {
2887
+ function filterByScope(result, scope) {
2352
2888
  const statuses = scopeStatuses[scope];
2353
- const includeProtectedBehind = scope !== "risk";
2354
- return files.filter((f) => statuses.has(f.status) || includeProtectedBehind && f.upstreamChanged === true);
2889
+ if (scope === "risk") return result.files.filter((f) => statuses.has(f.status));
2890
+ const overridesChanged = result.upstreamOverrides?.kind === "changes";
2891
+ return result.files.filter(
2892
+ (f) => statuses.has(f.status) || f.upstreamChanged === true || f.upstreamOnly === true || overridesChanged && f.path === CONFIG_FILE
2893
+ );
2355
2894
  }
2356
2895
  function findTargetFile(files, targetPath) {
2357
2896
  const exact = files.find((f) => f.path === targetPath);
@@ -2378,7 +2917,7 @@ async function runAnalyze(config) {
2378
2917
  }
2379
2918
  });
2380
2919
  spinnerSuccess();
2381
- const scopedFiles = filterByScope(result.files, config.scope ?? "all");
2920
+ const scopedFiles = filterByScope(result, config.scope ?? "all");
2382
2921
  if (config.diff) {
2383
2922
  const file = findTargetFile(scopedFiles, config.diff) ?? findTargetFile(result.files, config.diff);
2384
2923
  if (!file) throw new Error(`file not found in analysis results: ${config.diff}`);
@@ -2386,6 +2925,7 @@ async function runAnalyze(config) {
2386
2925
  return result;
2387
2926
  }
2388
2927
  if (config.json) {
2928
+ const overrides = result.upstreamOverrides?.kind === "changes" ? result.upstreamOverrides : void 0;
2389
2929
  const out = scopedFiles.map((f) => ({
2390
2930
  path: f.path,
2391
2931
  status: f.status,
@@ -2395,7 +2935,10 @@ async function runAnalyze(config) {
2395
2935
  upstreamCommit: f.upstreamCommit ?? null,
2396
2936
  upstreamChanged: f.upstreamChanged ?? false,
2397
2937
  upstreamChangedLines: f.upstreamChangedLines ?? null,
2398
- upstreamLinesAbsent: f.upstreamLinesAbsent ?? null
2938
+ upstreamLinesAbsent: f.upstreamLinesAbsent ?? null,
2939
+ upstreamOnly: f.upstreamOnly ?? false,
2940
+ // Only on the sync config: upstream override entries the fork config does not follow
2941
+ upstreamOverrides: overrides && f.path === CONFIG_FILE ? { pinned: overrides.pinned, ignored: overrides.ignored } : null
2399
2942
  }));
2400
2943
  writeStdout(JSON.stringify(out, null, 2));
2401
2944
  return result;
@@ -2436,7 +2979,10 @@ async function runAnalyze(config) {
2436
2979
  };
2437
2980
  printAnalysisFileGroups(result.files, linkOptions);
2438
2981
  printSummary(result.summary, "analysis summary");
2982
+ printIgnoredUpstreamChanges(result);
2983
+ printUpstreamOverrideChanges(result);
2439
2984
  printMaskingPinWarning(result.files);
2985
+ await printMigrationNotesLine(config, result);
2440
2986
  if (config.logFile) {
2441
2987
  const logPath = writeLogFile(config.forkPath, result.files);
2442
2988
  console.info();
@@ -3215,7 +3761,7 @@ async function checkOverrides(forkPath) {
3215
3761
 
3216
3762
  // src/services/contributions.ts
3217
3763
  import { readFileSync as readFileSync4 } from "fs";
3218
- import { join as join13 } from "path";
3764
+ import { join as join16 } from "path";
3219
3765
  import {
3220
3766
  createPrompt,
3221
3767
  isDownKey,
@@ -3230,7 +3776,7 @@ import {
3230
3776
  import { select as select2 } from "@inquirer/prompts";
3231
3777
 
3232
3778
  // src/services/contrib-core.ts
3233
- import { dirname as dirname3, join as join12, resolve as resolve3 } from "path";
3779
+ import { dirname as dirname5, join as join15, resolve as resolve4 } from "path";
3234
3780
  async function listFilesAtRef(repoPath, ref) {
3235
3781
  const raw = await git(["ls-tree", "-r", "--name-only", ref], repoPath);
3236
3782
  return new Set(raw.split("\n").filter(Boolean));
@@ -3246,10 +3792,10 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
3246
3792
  const baseFiles = await listFilesAtRef(repoPath, baseRef);
3247
3793
  const baseDirs = /* @__PURE__ */ new Set(["."]);
3248
3794
  for (const f of baseFiles) {
3249
- let dir = dirname3(f);
3795
+ let dir = dirname5(f);
3250
3796
  while (dir !== ".") {
3251
3797
  baseDirs.add(dir);
3252
- dir = dirname3(dir);
3798
+ dir = dirname5(dir);
3253
3799
  }
3254
3800
  }
3255
3801
  const files = [];
@@ -3262,7 +3808,7 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
3262
3808
  if (inCella && !inFork) {
3263
3809
  kind = "deleted";
3264
3810
  } else if (!inCella && inFork) {
3265
- if (!baseDirs.has(dirname3(file.path))) continue;
3811
+ if (!baseDirs.has(dirname5(file.path))) continue;
3266
3812
  kind = "created";
3267
3813
  } else {
3268
3814
  kind = "modified";
@@ -3293,8 +3839,8 @@ async function readBlobInfo(repoPath, ref, filePath) {
3293
3839
  }
3294
3840
  async function buildContribBranch(repoPath, baseRef, forkRef, detection, forkName) {
3295
3841
  const branch = `contrib/${forkName}`;
3296
- const gitDir = resolve3(repoPath, await git(["rev-parse", "--git-dir"], repoPath));
3297
- const tmpIndex = join12(gitDir, `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
3842
+ const gitDir = resolve4(repoPath, await git(["rev-parse", "--git-dir"], repoPath));
3843
+ const tmpIndex = join15(gitDir, `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
3298
3844
  const indexEnv = { GIT_INDEX_FILE: tmpIndex };
3299
3845
  try {
3300
3846
  await git(["read-tree", baseRef], repoPath, { env: indexEnv });
@@ -3337,20 +3883,20 @@ ${commitBody}`;
3337
3883
  }
3338
3884
 
3339
3885
  // src/services/fork-utils.ts
3340
- import { existsSync as existsSync7 } from "fs";
3341
- import { dirname as dirname4, resolve as resolve4 } from "path";
3886
+ import { existsSync as existsSync9 } from "fs";
3887
+ import { dirname as dirname6, resolve as resolve5 } from "path";
3342
3888
  async function resolveForkBasePath(repoPath) {
3343
3889
  const commonDir = await git(["rev-parse", "--git-common-dir"], repoPath, { ignoreErrors: true });
3344
- return commonDir ? dirname4(resolve4(repoPath, commonDir)) : repoPath;
3890
+ return commonDir ? dirname6(resolve5(repoPath, commonDir)) : repoPath;
3345
3891
  }
3346
3892
  function validateForkPath(fork, basePath, requireConfig = false) {
3347
- const resolvedPath = resolve4(basePath, fork.localPath);
3348
- if (!resolvedPath.startsWith(resolve4(basePath, ".."))) {
3893
+ const resolvedPath = resolve5(basePath, fork.localPath);
3894
+ if (!resolvedPath.startsWith(resolve5(basePath, ".."))) {
3349
3895
  return { fork, resolvedPath, valid: false, error: "path resolves outside parent directory" };
3350
3896
  }
3351
- if (!existsSync7(resolvedPath)) return { fork, resolvedPath, valid: false, error: "path does not exist" };
3352
- if (!existsSync7(`${resolvedPath}/.git`)) return { fork, resolvedPath, valid: false, error: "not a git repository" };
3353
- if (requireConfig && !existsSync7(`${resolvedPath}/${CONFIG_FILE}`)) {
3897
+ if (!existsSync9(resolvedPath)) return { fork, resolvedPath, valid: false, error: "path does not exist" };
3898
+ if (!existsSync9(`${resolvedPath}/.git`)) return { fork, resolvedPath, valid: false, error: "not a git repository" };
3899
+ if (requireConfig && !existsSync9(`${resolvedPath}/${CONFIG_FILE}`)) {
3354
3900
  return { fork, resolvedPath, valid: false, error: `missing ${CONFIG_FILE}` };
3355
3901
  }
3356
3902
  return { fork, resolvedPath, valid: true };
@@ -3368,7 +3914,7 @@ async function showContribDiff(item, baseRef, cwd, forkName) {
3368
3914
  return openDiffInBrowser(patch.toString(), { filePath: item.path, srcLabel: "cella", dstLabel: forkName }, cwd);
3369
3915
  }
3370
3916
  function shouldApplyContributionsUnstaged(repoPath) {
3371
- return JSON.parse(readFileSync4(join13(repoPath, "package.json"), "utf8")).name === "cella";
3917
+ return JSON.parse(readFileSync4(join16(repoPath, "package.json"), "utf8")).name === "cella";
3372
3918
  }
3373
3919
  var contribPrompt = createPrompt((config, done) => {
3374
3920
  const { pageSize = 20, baseRef, cwd, forkName } = config;
@@ -3713,7 +4259,7 @@ async function runContributions(config) {
3713
4259
  }
3714
4260
 
3715
4261
  // src/services/forks.ts
3716
- import { resolve as resolve5 } from "path";
4262
+ import { resolve as resolve6 } from "path";
3717
4263
  import process8 from "process";
3718
4264
  import { Separator as Separator2, select as select4 } from "@inquirer/prompts";
3719
4265
 
@@ -3756,7 +4302,6 @@ function listOpenSyncPrs(cwd, prefix) {
3756
4302
  }
3757
4303
  function buildMergeArgs(ref, options = {}) {
3758
4304
  const args = ["pr", "merge", ref, "--squash"];
3759
- if (options.auto) args.push("--auto");
3760
4305
  if (options.deleteBranch) args.push("--delete-branch");
3761
4306
  return args;
3762
4307
  }
@@ -3768,12 +4313,12 @@ function closePr(cwd, ref) {
3768
4313
  }
3769
4314
 
3770
4315
  // src/services/packages.ts
3771
- import { existsSync as existsSync9, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
3772
- import { join as join15 } from "path";
4316
+ import { existsSync as existsSync11, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
4317
+ import { join as join18 } from "path";
3773
4318
 
3774
4319
  // src/services/package-usage.ts
3775
- import { existsSync as existsSync8, readFileSync as readFileSync5, statSync } from "fs";
3776
- import { join as join14 } from "path";
4320
+ import { existsSync as existsSync10, readFileSync as readFileSync5, statSync } from "fs";
4321
+ import { join as join17 } from "path";
3777
4322
  var MAX_FILE_BYTES = 1024 * 1024;
3778
4323
  function escapeRegExp(value) {
3779
4324
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -3789,7 +4334,7 @@ function readSearchable(filePath) {
3789
4334
  }
3790
4335
  function fileSource(forkPath, path4) {
3791
4336
  let text;
3792
- return { path: path4, label: path4, text: () => text ??= readSearchable(join14(forkPath, path4)) };
4337
+ return { path: path4, label: path4, text: () => text ??= readSearchable(join17(forkPath, path4)) };
3793
4338
  }
3794
4339
  async function readScriptsAtRef(forkPath, ref, path4) {
3795
4340
  try {
@@ -3801,7 +4346,7 @@ async function readScriptsAtRef(forkPath, ref, path4) {
3801
4346
  async function scriptSources(forkPath, upstreamRef, baseRef, path4) {
3802
4347
  let forkScripts;
3803
4348
  try {
3804
- forkScripts = JSON.parse(readFileSync5(join14(forkPath, path4), "utf-8")).scripts ?? {};
4349
+ forkScripts = JSON.parse(readFileSync5(join17(forkPath, path4), "utf-8")).scripts ?? {};
3805
4350
  } catch {
3806
4351
  return [];
3807
4352
  }
@@ -3811,9 +4356,9 @@ async function scriptSources(forkPath, upstreamRef, baseRef, path4) {
3811
4356
  }
3812
4357
  function binNames(forkPath, location, name) {
3813
4358
  const bareName = name.split("/").pop();
3814
- for (const dir of [join14(forkPath, location), forkPath]) {
3815
- const manifest = join14(dir, "node_modules", name, "package.json");
3816
- if (!existsSync8(manifest)) continue;
4359
+ for (const dir of [join17(forkPath, location), forkPath]) {
4360
+ const manifest = join17(dir, "node_modules", name, "package.json");
4361
+ if (!existsSync10(manifest)) continue;
3817
4362
  try {
3818
4363
  const { bin } = JSON.parse(readFileSync5(manifest, "utf-8"));
3819
4364
  if (typeof bin === "string") return [bareName];
@@ -3855,26 +4400,6 @@ async function loadForkUsage(forkPath, upstreamRef, baseRef) {
3855
4400
  }
3856
4401
 
3857
4402
  // src/services/packages.ts
3858
- function parseComparableVersion(version) {
3859
- const trimmed = version.trim();
3860
- if (trimmed === "" || trimmed === "*" || trimmed.includes("workspace:") || trimmed.includes("catalog:") || trimmed.includes("file:") || trimmed.includes("link:") || trimmed.includes("git+") || trimmed.includes("github:") || trimmed.includes("http://") || trimmed.includes("https://") || trimmed.includes("||")) {
3861
- return null;
3862
- }
3863
- const match = trimmed.match(
3864
- /^(?:\^|~|>=|<=|>|<|=)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
3865
- );
3866
- if (!match) return null;
3867
- return {
3868
- major: Number(match[1]),
3869
- minor: Number(match[2] ?? 0),
3870
- patch: Number(match[3] ?? 0)
3871
- };
3872
- }
3873
- function compareVersions(left, right) {
3874
- if (left.major !== right.major) return left.major - right.major;
3875
- if (left.minor !== right.minor) return left.minor - right.minor;
3876
- return left.patch - right.patch;
3877
- }
3878
4403
  function isHigherVersion(upstreamVersion, forkVersion) {
3879
4404
  if (upstreamVersion === forkVersion) return false;
3880
4405
  const upCoerced = parseComparableVersion(upstreamVersion);
@@ -3993,7 +4518,7 @@ function safeMergePnpm(forkPnpm, upstreamPnpm, basePnpm) {
3993
4518
  return { merged, changed };
3994
4519
  }
3995
4520
  function readPackageJson(filePath) {
3996
- if (!existsSync9(filePath)) return null;
4521
+ if (!existsSync11(filePath)) return null;
3997
4522
  try {
3998
4523
  return JSON.parse(readFileSync6(filePath, "utf-8"));
3999
4524
  } catch {
@@ -4031,13 +4556,13 @@ async function syncPackageJson(config, baseRef, usage, relativePath, keysToSync)
4031
4556
  const changes = [];
4032
4557
  const kept = [];
4033
4558
  const pkgRelPath = packageJsonPath(relativePath);
4034
- const pkgPath = join15(forkPath, pkgRelPath);
4559
+ const pkgPath = join18(forkPath, pkgRelPath);
4035
4560
  const forkPkg = readPackageJson(pkgPath);
4036
4561
  const upstream = await readPackageJsonAtRef(forkPath, upstreamRef, relativePath);
4037
4562
  const basePkg = baseRef ? (await readPackageJsonAtRef(forkPath, baseRef, relativePath))?.data : void 0;
4038
4563
  if (!upstream) return { updated: false, changes, kept };
4039
4564
  if (!forkPkg) {
4040
- const workspaceArrived = relativePath !== "" && existsSync9(join15(forkPath, relativePath));
4565
+ const workspaceArrived = relativePath !== "" && existsSync11(join18(forkPath, relativePath));
4041
4566
  if (basePkg || !workspaceArrived || isIgnored(pkgRelPath, config)) return { updated: false, changes, kept };
4042
4567
  writeFileSync4(pkgPath, upstream.raw.endsWith("\n") ? upstream.raw : `${upstream.raw}
4043
4568
  `, "utf-8");
@@ -4204,6 +4729,8 @@ async function runSync(config, options) {
4204
4729
  spinnerFail("sync completed with conflicts");
4205
4730
  }
4206
4731
  printSummary(result.summary, "merge summary");
4732
+ printIgnoredUpstreamChanges(result);
4733
+ printUpstreamOverrideChanges(result);
4207
4734
  printMaskingPinWarning(result.files);
4208
4735
  if (config.logFile) {
4209
4736
  const logPath = writeLogFile(config.forkPath, result.files);
@@ -4213,6 +4740,7 @@ async function runSync(config, options) {
4213
4740
  const stagedBranch = options?.stagedBranch && result.conflicts.length === 0 && hasStagedSyncChanges(result) ? options.stagedBranch : void 0;
4214
4741
  printSyncComplete(result, { stagedBranch });
4215
4742
  printFlagWarnings({ hard: config.hard, unpinned: config.unpinned });
4743
+ await printMigrationNotesLine(config, result);
4216
4744
  return result;
4217
4745
  }
4218
4746
  var SYNC_PR_TITLE = "chore: sync upstream cella";
@@ -4229,7 +4757,7 @@ function qualifyPrRefs(subject, repoSlug) {
4229
4757
  return subject.replace(/(^|[^\w/])#(\d+)\b/g, `$1${repoSlug}#$2`);
4230
4758
  }
4231
4759
  function buildSyncPrBody(input) {
4232
- const { repoSlug, version, fromSha, toSha, commits, totalCount } = input;
4760
+ const { repoSlug, version, fromSha, toSha, commits, totalCount, notes } = input;
4233
4761
  const githubUrl = repoSlug ? `https://github.com/${repoSlug}` : void 0;
4234
4762
  const short = (sha) => sha.slice(0, 7);
4235
4763
  const commitRef = (sha) => githubUrl ? `[\`${short(sha)}\`](${githubUrl}/commit/${sha})` : `\`${short(sha)}\``;
@@ -4244,6 +4772,19 @@ function buildSyncPrBody(input) {
4244
4772
  lines.push(`- ${commitRef(commit.hash)} ${qualifyPrRefs(commit.message, repoSlug)}`);
4245
4773
  }
4246
4774
  }
4775
+ if (notes && notes.total > 0) {
4776
+ const { total, open } = notes;
4777
+ if (open.length === 0) lines.push("", `**Migration notes**: all ${total} handled.`);
4778
+ else {
4779
+ lines.push(
4780
+ "",
4781
+ `**Migration notes**: ${Math.max(0, total - open.length)} of ${total} handled, open (\`pnpm cella migrate\`):`,
4782
+ ""
4783
+ );
4784
+ for (const note of open)
4785
+ lines.push(`- ${note.url ? `[${note.title}](${note.url})` : note.title} (\`${note.id}\`)`);
4786
+ }
4787
+ }
4247
4788
  return `${lines.join("\n")}
4248
4789
  `;
4249
4790
  }
@@ -4259,7 +4800,20 @@ async function buildSyncPrBodyForBranch(forkPath, base) {
4259
4800
  skip: totalCount > PR_BODY_COMMIT_MAX ? totalCount - PR_BODY_COMMIT_MAX : 0,
4260
4801
  limit: PR_BODY_COMMIT_MAX
4261
4802
  }) : [];
4262
- return buildSyncPrBody({ repoSlug: manifest.upstream.repo, version, fromSha, toSha, commits, totalCount });
4803
+ const notes = await readPrBodyNotes(forkPath, toSha, manifest.upstream.repo).catch(() => void 0);
4804
+ return buildSyncPrBody({ repoSlug: manifest.upstream.repo, version, fromSha, toSha, commits, totalCount, notes });
4805
+ }
4806
+ async function readPrBodyNotes(forkPath, toSha, repoSlug) {
4807
+ const githubUrl = repoSlug ? `https://github.com/${repoSlug}` : void 0;
4808
+ const [ids, pending] = await Promise.all([listNoteIds(forkPath, toSha), readPending(forkPath, toSha)]);
4809
+ const open = await Promise.all(
4810
+ pending.map(async (id) => ({
4811
+ id,
4812
+ title: (await readNote(forkPath, toSha, id))?.title ?? id,
4813
+ url: noteUrl(githubUrl, toSha, id)
4814
+ }))
4815
+ );
4816
+ return { total: ids.length, open };
4263
4817
  }
4264
4818
  function printPrCreateStep(branch, base, title = SYNC_PR_TITLE) {
4265
4819
  console.info(colors_default.dim(` gh pr create --base ${base} --head ${branch} --title "${title}" --fill`));
@@ -4293,22 +4847,12 @@ async function flattenSyncBranch(forkPath, branch, base) {
4293
4847
  function indentLines(text) {
4294
4848
  return text.split("\n").map((line) => ` ${line}`).join("\n");
4295
4849
  }
4296
- function enableAutoMerge(forkPath, branch) {
4297
- console.info(colors_default.dim("enabling auto-merge (squash) once checks pass..."));
4298
- const merge2 = mergePrSquash(forkPath, branch, { auto: true, deleteBranch: true });
4299
- if (merge2.ok) return true;
4300
- console.info(colors_default.yellow("could not enable auto-merge (is it enabled for the repo?). merge it manually:"));
4301
- if (merge2.output) console.info(colors_default.dim(indentLines(merge2.output)));
4302
- console.info(colors_default.dim(` gh pr merge ${branch} --squash --delete-branch`));
4303
- return false;
4304
- }
4305
4850
  async function shipSyncBranch(config, branch) {
4306
4851
  const { forkPath, settings } = config;
4307
4852
  const base = resolveReleaseBase(settings);
4308
4853
  const flattened = await flattenSyncBranch(forkPath, branch, base);
4309
4854
  let prUrl;
4310
4855
  let prOpened = false;
4311
- let autoMergeEnabled = false;
4312
4856
  const headSubject = (await getCommitInfo(forkPath, "HEAD").catch(() => null))?.message;
4313
4857
  const prTitle = headSubject?.startsWith(SYNC_PR_TITLE) ? headSubject : SYNC_PR_TITLE;
4314
4858
  console.info(colors_default.dim(`pushing '${branch}' to origin...`));
@@ -4332,9 +4876,7 @@ async function shipSyncBranch(config, branch) {
4332
4876
  prUrl = extractFirstUrl(`${pr.stdout ?? ""}
4333
4877
  ${pr.stderr ?? ""}`);
4334
4878
  prOpened = pr.status === 0;
4335
- if (prOpened) {
4336
- if (config.directMerge) autoMergeEnabled = enableAutoMerge(forkPath, branch);
4337
- } else {
4879
+ if (!prOpened) {
4338
4880
  console.info(colors_default.yellow("could not open the PR automatically (it may already exist). open it with:"));
4339
4881
  if (prUrl) console.info(colors_default.dim(` ${prUrl}`));
4340
4882
  printPrCreateStep(branch, base, prTitle);
@@ -4350,12 +4892,12 @@ ${pr.stderr ?? ""}`);
4350
4892
  console.info();
4351
4893
  if (prUrl) {
4352
4894
  console.info(`${colors_default.green("\u2713")} Sync pull request ${prOpened ? "opened" : "ready"}`);
4353
- const status = autoMergeEnabled ? `auto-merge on \u2014 squashes into '${base}' when checks pass` : `branch pushed, back on '${base}'`;
4354
- console.info(colors_default.dim(` ${prUrl} \xB7 ${status}`));
4895
+ console.info(colors_default.dim(` ${prUrl} \xB7 branch pushed, back on '${base}'`));
4355
4896
  } else {
4356
4897
  console.info(`${colors_default.green("\u2713")} Sync branch pushed`);
4357
4898
  console.info(colors_default.dim(` '${branch}' is on origin, back on '${base}'`));
4358
4899
  }
4900
+ await printMigrationNotesLine(config);
4359
4901
  }
4360
4902
  function finalizeWorkspace(forkPath) {
4361
4903
  for (const args of [["install"], ["check"]]) {
@@ -4404,6 +4946,7 @@ async function commitSyncMerge(config, branch) {
4404
4946
  console.info();
4405
4947
  console.info(colors_default.green(`committed the sync on '${branch}' as '${message}'.`));
4406
4948
  printTriageSteps(branch);
4949
+ await printMigrationNotesLine(config);
4407
4950
  }
4408
4951
  function printTriageSteps(branch) {
4409
4952
  console.info(colors_default.dim(`staying on '${branch}' without pushing.`));
@@ -4595,7 +5138,7 @@ async function runForks(config) {
4595
5138
  console.error(colors_default.red(`fork '${config.fork}' not found in config`));
4596
5139
  return;
4597
5140
  }
4598
- const resolvedPath = resolve5(await resolveForkBasePath(config.forkPath), match.localPath);
5141
+ const resolvedPath = resolve6(await resolveForkBasePath(config.forkPath), match.localPath);
4599
5142
  await syncFork(config, match, resolvedPath);
4600
5143
  return;
4601
5144
  }
@@ -4611,7 +5154,7 @@ async function runForks(config) {
4611
5154
  if (selectedPath === "_exit") {
4612
5155
  process8.exit(0);
4613
5156
  }
4614
- const resolvedForkPath = resolve5(forkBasePath, selectedPath);
5157
+ const resolvedForkPath = resolve6(forkBasePath, selectedPath);
4615
5158
  const selectedFork = forks.find((f) => f.localPath === selectedPath);
4616
5159
  if (!selectedFork) {
4617
5160
  console.error(colors_default.red(`fork '${selectedPath}' not found in config`));
@@ -4623,8 +5166,8 @@ async function runForks(config) {
4623
5166
  }
4624
5167
 
4625
5168
  // src/services/stats.ts
4626
- import { readFile as readFile2 } from "fs/promises";
4627
- import { join as join17 } from "path";
5169
+ import { readFile as readFile3 } from "fs/promises";
5170
+ import { join as join20 } from "path";
4628
5171
 
4629
5172
  // src/utils/coverage-utils.ts
4630
5173
  import { spawnSync as spawnSync4 } from "child_process";
@@ -4718,7 +5261,7 @@ function printCoverageSummary(forkPath, options = {}) {
4718
5261
  // src/services/stats.ts
4719
5262
  var sourceExtensions = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs", "json", "jsonc", "css", "html"]);
4720
5263
  async function getWorkspacePackages(forkPath) {
4721
- const content = await readFile2(join17(forkPath, "pnpm-workspace.yaml"), "utf-8");
5264
+ const content = await readFile3(join20(forkPath, "pnpm-workspace.yaml"), "utf-8");
4722
5265
  return parseYamlBlockList(content, "packages").map((pattern) => {
4723
5266
  if (pattern.endsWith("/*")) {
4724
5267
  const base = pattern.slice(0, -2);
@@ -4820,9 +5363,9 @@ function fmtPct(value, total) {
4820
5363
  function printStats(stats, verbose) {
4821
5364
  console.info();
4822
5365
  console.info(
4823
- ` ${colors_default.bold("source files")} ${String(stats.total)} ${colors_default.dim(`(${stats.skipped} non-source skipped)`)}`
5366
+ ` ${colors_default.bold("code files".padEnd(13))} ${String(stats.total)} ${colors_default.dim(`(${[...sourceExtensions].join(" ")})`)}`
4824
5367
  );
4825
- console.info(` ${colors_default.bold("lines of code")} ${colors_default.cyan(formatLoc(stats.totalLoc))}`);
5368
+ console.info(` ${colors_default.bold("lines of code")} ${colors_default.cyan(formatLoc(stats.totalLoc))}`);
4826
5369
  console.info();
4827
5370
  const catEntries = [
4828
5371
  ["source & config", stats.categories.other],
@@ -4852,6 +5395,7 @@ function printStats(stats, verbose) {
4852
5395
  );
4853
5396
  }
4854
5397
  } else {
5398
+ console.info(colors_default.dim(" source & config by package"));
4855
5399
  for (const [name, data] of Object.entries(stats.packages)) {
4856
5400
  const srcFiles = data.categories.other.files;
4857
5401
  const srcLoc = data.categories.other.loc;
@@ -4880,20 +5424,20 @@ async function runStats(forkPath, options = {}) {
4880
5424
  function getForkPath() {
4881
5425
  const envPath = getEnv("CELLA_FORK_PATH");
4882
5426
  if (envPath) {
4883
- const resolved = resolve6(envPath);
4884
- if (!existsSync11(resolved) || !statSync3(resolved).isDirectory()) {
5427
+ const resolved = resolve7(envPath);
5428
+ if (!existsSync13(resolved) || !statSync3(resolved).isDirectory()) {
4885
5429
  throw new Error(`CELLA_FORK_PATH is not a valid directory: ${resolved}`);
4886
5430
  }
4887
5431
  return resolved;
4888
5432
  }
4889
5433
  let cwd = process9.cwd();
4890
5434
  if (cwd.endsWith("/cli/cella") || cwd.endsWith("\\cli\\cella")) {
4891
- cwd = resolve6(cwd, "../..");
5435
+ cwd = resolve7(cwd, "../..");
4892
5436
  }
4893
5437
  return cwd;
4894
5438
  }
4895
5439
  async function preflight(forkPath) {
4896
- if (!existsSync11(join18(forkPath, ".git"))) {
5440
+ if (!existsSync13(join21(forkPath, ".git"))) {
4897
5441
  throw new Error(`not a git repository: ${forkPath}`);
4898
5442
  }
4899
5443
  }
@@ -4922,6 +5466,9 @@ async function main() {
4922
5466
  await runSyncCommand(config);
4923
5467
  break;
4924
5468
  }
5469
+ case "migrate":
5470
+ await runMigrate(config);
5471
+ break;
4925
5472
  case "audit":
4926
5473
  await runAudit(config, { force: config.force, checkOverrides: config.checkOverrides });
4927
5474
  break;