@cellajs/cli 0.2.3 → 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";
@@ -220,7 +220,7 @@ import process4 from "process";
220
220
  // package.json
221
221
  var package_default = {
222
222
  name: "@cellajs/cli",
223
- version: "0.2.3",
223
+ version: "0.3.0",
224
224
  private: false,
225
225
  license: "MIT",
226
226
  description: "CLI for managing your monorepo and syncing with upstream Cella.",
@@ -270,25 +270,25 @@ var package_default = {
270
270
  prepare: "lefthook install"
271
271
  },
272
272
  dependencies: {
273
- "@inquirer/core": "^11.2.1",
273
+ "@inquirer/core": "^12.0.3",
274
274
  "@inquirer/prompts": "^8.7.2",
275
- "@pierre/diffs": "^1.4.3",
275
+ "@pierre/diffs": "^1.5.1",
276
276
  commander: "^15.0.0",
277
277
  "ts-morph": "^28.0.0",
278
278
  zod: "^4.6.5"
279
279
  },
280
280
  devDependencies: {
281
- "@biomejs/biome": "^2.5.14",
281
+ "@biomejs/biome": "^2.5.15",
282
282
  "@commitlint/cli": "^21.2.3",
283
283
  "@commitlint/config-conventional": "^21.2.3",
284
- "@evilmartians/lefthook": "^2.1.14",
285
- "@types/node": "26.5.0",
286
- "@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",
287
287
  rimraf: "^6.1.3",
288
288
  tsup: "^8.5.1",
289
289
  tsx: "^4.23.15",
290
290
  typescript: "^6.0.3",
291
- vitest: "^4.1.11"
291
+ vitest: "^5.0.3"
292
292
  },
293
293
  publishConfig: {
294
294
  access: "public"
@@ -800,415 +800,425 @@ function printFlagWarnings(options) {
800
800
  }
801
801
 
802
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";
803
813
  import { existsSync as existsSync3 } from "fs";
804
- import { join as join4 } from "path";
805
- function isUnderAnyFolder(filePath, folders) {
806
- return folders.some((folder) => {
807
- const entry = folder.replace(/\/+$/, "");
808
- return filePath === entry || filePath.startsWith(`${entry}/`);
809
- });
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
+ }
810
831
  }
811
- function isIgnored(filePath, config) {
812
- return isUnderAnyFolder(filePath, config.overrides?.ignored || []);
832
+ async function readManifestBase(cwd) {
833
+ const manifest = await readSyncManifest(cwd);
834
+ return manifest ? manifest.upstream.commit.toLowerCase() : null;
813
835
  }
814
- function isPinned(filePath, config) {
815
- if (isManagedFile(filePath)) return true;
816
- return isUnderAnyFolder(filePath, config.overrides?.pinned || []);
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");
817
841
  }
818
- function groupIgnoredUpstreamChanges(files, ignored) {
819
- const entries = [...new Set(ignored.map((entry) => entry.replace(/\/+$/, "")).filter(Boolean))];
820
- const mostSpecificFirst = [...entries].sort((a, b) => b.length - a.length);
821
- const groups = /* @__PURE__ */ new Map();
822
- for (const file of files) {
823
- if (!file.upstreamOnly || isManagedFile(file.path) || isGeneratedFile(file.path)) continue;
824
- const entry = mostSpecificFirst.find((candidate) => isUnderAnyFolder(file.path, [candidate]));
825
- if (!entry) continue;
826
- const group = groups.get(entry) ?? { entry, paths: [], added: 0, deleted: 0 };
827
- group.paths.push(file.path);
828
- if (!file.existsInFork) group.added++;
829
- if (!file.existsInUpstream) group.deleted++;
830
- groups.set(entry, group);
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}://***@`;
849
+ });
850
+ }
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;
831
867
  }
832
- for (const group of groups.values()) group.paths.sort();
833
- return entries.flatMap((entry) => groups.get(entry) ?? []);
834
868
  }
835
- function isPinnedForSync(filePath, config, unpinned) {
836
- if (isManagedFile(filePath)) return true;
837
- if (unpinned) return false;
838
- return isPinned(filePath, config);
869
+ async function getCurrentBranch(cwd) {
870
+ return git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
839
871
  }
840
- function hasGlobChars(entry) {
841
- return entry.includes("*") || entry.includes("?");
872
+ async function switchBranch(cwd, branch) {
873
+ await git(["switch", branch], cwd);
842
874
  }
843
- function validateOverrides(config, forkPath) {
844
- const warnings = [];
845
- const pinned = config.overrides?.pinned || [];
846
- for (const entry of pinned) {
847
- if (hasGlobChars(entry)) {
848
- warnings.push({
849
- type: "pinned-glob",
850
- pattern: entry,
851
- message: `pin uses glob (not supported, use a path or folder): ${entry}`
852
- });
853
- } else if (!existsSync3(join4(forkPath, entry))) {
854
- warnings.push({
855
- type: "pinned-not-found",
856
- pattern: entry,
857
- message: `pin not found: ${entry}`
858
- });
859
- }
875
+ async function createBranchFrom(cwd, branch, startPoint) {
876
+ await git(["switch", "-c", branch, startPoint], cwd);
877
+ }
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);
860
912
  }
861
- const ignored = config.overrides?.ignored || [];
862
- for (const entry of ignored) {
863
- if (hasGlobChars(entry)) {
864
- warnings.push({
865
- type: "ignored-not-found",
866
- pattern: entry,
867
- message: `ignored entry uses glob (not supported, use a path or folder): ${entry}`
868
- });
869
- } else if (!existsSync3(join4(forkPath, entry.replace(/\/+$/, "")))) {
870
- warnings.push({
871
- type: "ignored-not-found",
872
- pattern: entry,
873
- message: `ignored entry not found: ${entry}`
874
- });
875
- }
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);
876
938
  }
877
- return warnings;
878
939
  }
879
- function printWarnings(warnings) {
880
- for (const warning of warnings) {
881
- 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 };
882
957
  }
958
+ return null;
883
959
  }
884
-
885
- // src/cli.ts
886
- function readOptions(opts) {
887
- const scope = typeof opts.scope === "string" ? opts.scope : void 0;
888
- if (scope && scope !== "all" && scope !== "risk" && scope !== "protected") {
889
- 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 });
890
983
  }
891
- const normalizedScope = scope;
892
- return {
893
- logFile: opts.log === true,
894
- verbose: opts.verbose === true,
895
- list: opts.list === true,
896
- json: opts.json === true,
897
- diff: typeof opts.diff === "string" ? opts.diff : void 0,
898
- openDiff: typeof opts.openDiff === "string" ? opts.openDiff : void 0,
899
- scope: normalizedScope,
900
- fork: typeof opts.fork === "string" ? opts.fork : void 0,
901
- hard: opts.hard === true,
902
- unpinned: opts.unpinned === true,
903
- track: opts.track === "release" || opts.track === "branch" ? opts.track : void 0,
904
- force: opts.force === true,
905
- checkOverrides: opts.checkOverrides === true,
906
- coverage: opts.coverage === true,
907
- directMerge: opts.directMerge === true
908
- };
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);
909
992
  }
910
- var serviceDefinitions = [
911
- {
912
- name: "analyze",
913
- description: "dry run to see what would change on sync",
914
- options: [
915
- { flags: "--log", description: "write complete file list to cella-sync.log" },
916
- { flags: "--list", description: "non-interactive output for tooling (one file per line)" },
917
- { flags: "--json", description: "machine-readable output for tooling/agents" },
918
- { flags: "--scope <scope>", description: "analyze scope for --list/--json: all|risk|protected" },
919
- { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" },
920
- { flags: "--diff <path>", description: "print unified diff for one file, then exit" },
921
- { flags: "--open-diff <path>", description: "open a browser diff for one file, then exit" }
922
- ],
923
- includeInMenu: (context) => !context.isUpstreamRepo
924
- },
925
- {
926
- name: "sync",
927
- description: "merge upstream changes into your app",
928
- options: [
929
- { flags: "--log", description: "write complete file list to cella-sync.log" },
930
- { flags: "--hard", description: "overwrite drifted files with upstream version (aggressive realignment)" },
931
- { flags: "--unpinned", description: "ignore pinned files (except package.json) to resurface upstream changes" },
932
- { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" },
933
- {
934
- flags: "--direct-merge",
935
- description: "auto-merge (squash) the sync PR once checks pass \u2014 needs auto-merge enabled on the repo"
993
+ async function getFileChangeInfo(cwd, fromRef, toRef) {
994
+ const info = /* @__PURE__ */ new Map();
995
+ try {
996
+ const output = await git(["log", "--name-only", "--format=%h %ct %ar", `${fromRef}..${toRef}`], cwd, {
997
+ ignoreErrors: true
998
+ });
999
+ if (!output.trim()) return info;
1000
+ const lines = output.split("\n");
1001
+ let currentHash = "";
1002
+ let currentDate = "";
1003
+ let currentTimestamp = 0;
1004
+ for (const line of lines) {
1005
+ if (!line) continue;
1006
+ const match = line.match(/^([a-f0-9]{7,})\s+(\d+)\s+(.+)$/);
1007
+ if (match) {
1008
+ currentHash = match[1];
1009
+ currentTimestamp = Number(match[2]);
1010
+ currentDate = match[3];
1011
+ } else if (currentHash && currentDate && !info.has(line)) {
1012
+ info.set(line, { date: currentDate, hash: currentHash, timestamp: currentTimestamp });
936
1013
  }
937
- ],
938
- includeInMenu: (context) => !context.isUpstreamRepo,
939
- menuDescription: () => "merge upstream changes + sync package.json"
940
- },
941
- {
942
- name: "audit",
943
- description: "check for outdated packages & vulnerabilities",
944
- options: [
945
- { flags: "--list", description: "skip interactive update prompts after printing audit results" },
946
- { flags: "--force", description: "bypass pnpm metadata cache for fresh registry data" },
947
- { flags: "--check-overrides", description: "check which pnpm.overrides are still needed" }
948
- ]
949
- },
950
- {
951
- name: "forks",
952
- description: "sync downstream to local fork repositories",
953
- options: [
954
- { flags: "--fork <name>", description: "pre-select fork by name (skips fork selection prompt)" },
955
- { flags: "--log", description: "write complete file list to cella-sync.log for each synced fork" },
956
- { flags: "-V, --verbose", description: "show detailed output during operations" },
957
- { flags: "--hard", description: "overwrite drifted files with upstream version (aggressive realignment)" }
958
- ],
959
- includeInMenu: (context) => context.hasForks
960
- },
961
- {
962
- name: "contributions",
963
- description: "pull and adopt changes from forks",
964
- options: [
965
- { flags: "--fork <name>", description: "select a specific fork directly (skips fork selection)" },
966
- { flags: "--list", description: "non-interactive output (one file per line)" },
967
- { flags: "--json", description: "machine-readable JSON output for tooling/agents" },
968
- { flags: "--diff <path>", description: "print the unified diff for a single contributed file, then exit" }
969
- ],
970
- includeInMenu: (context) => context.hasForks
971
- },
972
- {
973
- name: "stats",
974
- description: "count files by category and workspace package",
975
- options: [
976
- { flags: "-V, --verbose", description: "show detailed output during operations" },
977
- { flags: "--coverage", description: "regenerate test coverage before showing the stats summary" }
978
- ]
979
- }
980
- ];
981
- async function getMenuContext(userConfig, forkPath) {
982
- return {
983
- hasForks: (userConfig.forks?.length ?? 0) > 0,
984
- isUpstreamRepo: JSON.parse(readFileSync(join5(forkPath, "package.json"), "utf8")).name === "cella"
985
- };
986
- }
987
- function buildServiceChoices(context) {
988
- const label = (name) => name.padEnd(14);
989
- const baseChoices = serviceDefinitions.filter((service) => service.includeInMenu?.(context) ?? true).map((service) => ({
990
- value: service.name,
991
- name: `${label(service.name)}${colors_default.dim(service.menuDescription?.(context) ?? service.description)}`
992
- }));
993
- return [
994
- ...baseChoices,
995
- { type: "separator", separator: "\u2500".repeat(40) },
996
- { value: "exit", name: colors_default.red(`${label("exit")}${colors_default.dim("quit without doing anything")}`) }
997
- ];
998
- }
999
- function addServiceCommand(program, definition, setSelection) {
1000
- const command = program.command(definition.name).description(definition.description);
1001
- for (const option of definition.options ?? []) {
1002
- command.option(option.flags, option.description);
1014
+ }
1015
+ } catch {
1003
1016
  }
1004
- command.action((opts) => {
1005
- setSelection({
1006
- service: definition.name,
1007
- options: readOptions(opts)
1008
- });
1009
- });
1017
+ return info;
1010
1018
  }
1011
- function buildProgram(setSelection) {
1012
- 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(
1013
- "after",
1014
- [
1015
- "",
1016
- "Examples:",
1017
- " $ cella analyze",
1018
- " $ cella analyze --json --scope risk",
1019
- " $ cella analyze --open-diff frontend/src/routes/index.tsx",
1020
- " $ cella sync --hard",
1021
- " $ cella sync --unpinned",
1022
- " $ cella sync --track branch",
1023
- " $ cella sync --direct-merge",
1024
- " $ cella audit --check-overrides",
1025
- " $ cella contributions --fork raak --json"
1026
- ].join("\n")
1027
- );
1028
- for (const definition of serviceDefinitions) {
1029
- addServiceCommand(program, definition, setSelection);
1030
- }
1031
- return program;
1019
+ async function isClean(cwd) {
1020
+ const status = await git(["status", "--porcelain"], cwd);
1021
+ return !status;
1032
1022
  }
1033
- function parseCommandLine(argv) {
1034
- if (argv.length <= 2) {
1035
- return { options: readOptions({}) };
1023
+ async function assertClean(cwd, subject = "working directory") {
1024
+ if (!await isClean(cwd)) {
1025
+ throw new Error(`${subject} has uncommitted changes. please commit or stash before syncing.`);
1036
1026
  }
1037
- let selection = { options: readOptions({}) };
1038
- const program = buildProgram((nextSelection) => {
1039
- selection = nextSelection;
1040
- });
1041
- program.parse(argv);
1042
- return selection;
1043
1027
  }
1044
- async function promptForService(userConfig, forkPath) {
1045
- const selected = await select({
1046
- message: "choose a service:",
1047
- choices: buildServiceChoices(await getMenuContext(userConfig, forkPath)),
1048
- loop: false
1049
- });
1050
- if (selected === "exit") {
1051
- console.info(colors_default.dim("exiting..."));
1052
- console.info();
1053
- process5.exit(0);
1054
- }
1055
- console.info();
1056
- return selected;
1028
+ async function createWorktree(cwd, worktreePath, commitRef) {
1029
+ await git(["worktree", "add", "--detach", worktreePath, commitRef], cwd);
1057
1030
  }
1058
- function buildRuntimeConfig(userConfig, forkPath, selection) {
1059
- const { branchRef } = resolveUpstream(userConfig.settings);
1060
- return {
1061
- ...userConfig,
1062
- forkPath,
1063
- upstreamRef: branchRef,
1064
- service: selection.service ?? "analyze",
1065
- ...selection.options
1066
- };
1031
+ async function removeWorktree(cwd, worktreePath) {
1032
+ await git(["worktree", "remove", worktreePath, "--force"], cwd, { ignoreErrors: true });
1033
+ await git(["worktree", "prune"], cwd, { ignoreErrors: true });
1067
1034
  }
1068
- async function parseCli(userConfig, forkPath) {
1069
- const selection = parseCommandLine(process5.argv);
1070
- if (selection.options.json || selection.options.diff) setJsonMode(true);
1071
- printHeader();
1072
- const warnings = validateOverrides(userConfig, forkPath);
1073
- if (warnings.length > 0) {
1074
- printWarnings(warnings);
1075
- console.info();
1076
- }
1077
- if (!selection.service) {
1078
- selection.service = await promptForService(userConfig, forkPath);
1035
+ async function listWorktrees(cwd) {
1036
+ const output = await git(["worktree", "list", "--porcelain"], cwd);
1037
+ const lines = output.split("\n");
1038
+ const paths = [];
1039
+ for (const line of lines) {
1040
+ if (line.startsWith("worktree ")) {
1041
+ paths.push(line.slice(9));
1042
+ }
1079
1043
  }
1080
- return buildRuntimeConfig(userConfig, forkPath, selection);
1044
+ return paths;
1081
1045
  }
1082
-
1083
- // src/services/analyze.ts
1084
- import { basename as basename4 } from "path";
1085
-
1086
- // src/utils/diff.ts
1087
- import { execFileSync } from "child_process";
1088
- import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
1089
- import { tmpdir } from "os";
1090
- import { basename as basename2, join as join6 } from "path";
1091
- import process6 from "process";
1092
- function gitDiffFile(cwd, range, filePath, options = {}) {
1093
- const args = ["diff", "--no-color"];
1094
- if (options.dstPrefix) args.push("--src-prefix=cella/", `--dst-prefix=${options.dstPrefix}/`);
1095
- args.push(range, "--", filePath);
1046
+ async function merge(cwd, ref, options = {}) {
1047
+ const args = ["merge", "--no-ff", ref];
1048
+ if (options.noCommit) args.push("--no-commit");
1049
+ if (options.noEdit) args.push("--no-edit");
1096
1050
  try {
1097
- return execFileSync("git", args, { cwd, maxBuffer: 50 * 1024 * 1024 });
1051
+ await git(args, cwd, { skipEditor: true });
1052
+ return { success: true, conflicts: [] };
1098
1053
  } catch (error) {
1099
- const stderr = error.stderr?.toString().trim();
1100
- throw new Error(stderr || `failed to diff ${filePath}`);
1054
+ const conflicts = await getConflictedFiles(cwd);
1055
+ if (conflicts.length > 0) {
1056
+ return { success: false, conflicts };
1057
+ }
1058
+ throw error;
1101
1059
  }
1102
1060
  }
1103
- function escapeHtml(text) {
1104
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1061
+ async function getConflictedFiles(cwd) {
1062
+ const output = await git(["diff", "--name-only", "--diff-filter=U"], cwd, { ignoreErrors: true });
1063
+ if (!output) return [];
1064
+ return output.split("\n").filter(Boolean);
1105
1065
  }
1106
- async function renderDiffPage(patch, meta) {
1107
- const { preloadPatchDiff } = await import("@pierre/diffs/ssr");
1108
- const { prerenderedHTML } = await preloadPatchDiff({
1109
- patch,
1110
- options: {
1111
- diffStyle: "split",
1112
- themeType: "system",
1113
- lineDiffType: "word-alt",
1114
- hunkSeparators: "line-info",
1115
- overflow: "wrap",
1116
- disableFileHeader: true
1117
- }
1118
- });
1119
- const title = `${escapeHtml(meta.filePath)} \xB7 ${escapeHtml(meta.srcLabel)} vs ${escapeHtml(meta.dstLabel)}`;
1120
- const note = meta.note ? `<span class="note">\xB7 ${escapeHtml(meta.note)}</span>` : "";
1121
- return `<!doctype html>
1122
- <html lang="en">
1123
- <head>
1124
- <meta charset="utf-8">
1125
- <meta name="viewport" content="width=device-width, initial-scale=1">
1126
- <title>${title}</title>
1127
- <style>
1128
- :root { color-scheme: light dark; }
1129
- * { box-sizing: border-box; }
1130
- body {
1131
- margin: 0;
1132
- font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
1133
- background: light-dark(#fafafa, #111);
1134
- color: light-dark(#111, #eee);
1066
+ async function mergeAbort(cwd) {
1067
+ await git(["merge", "--abort"], cwd, { ignoreErrors: true });
1068
+ }
1069
+ async function fileExistsAtRef(cwd, ref, filePath) {
1070
+ const result = await git(["ls-tree", "--name-only", ref, "--", filePath], cwd, { ignoreErrors: true });
1071
+ return result !== "";
1072
+ }
1073
+ async function checkoutFromRef(cwd, ref, filePath) {
1074
+ await git(["checkout", ref, "--", filePath], cwd);
1075
+ }
1076
+ async function restoreWorktreeFromRef(cwd, ref, filePath) {
1077
+ await git(["restore", `--source=${ref}`, "--worktree", "--", filePath], cwd);
1078
+ }
1079
+ async function getMergeBase(cwd, ref1, ref2) {
1080
+ return git(["merge-base", ref1, ref2], cwd);
1081
+ }
1082
+ async function getFileChanges(cwd, baseRef, targetRef) {
1083
+ const output = await git(["diff-tree", "-r", "-M90%", "--no-commit-id", baseRef, targetRef], cwd, {
1084
+ ignoreErrors: true
1085
+ });
1086
+ const changes = /* @__PURE__ */ new Map();
1087
+ if (!output) return changes;
1088
+ for (const line of output.split("\n")) {
1089
+ if (!line) continue;
1090
+ const renameMatch = line.match(/^:\d+ \d+ ([a-f0-9]+) ([a-f0-9]+) R\d*\s+(.+)\t(.+)$/);
1091
+ if (renameMatch) {
1092
+ const [, baseHash, targetHash, oldPath, newPath] = renameMatch;
1093
+ changes.set(newPath, {
1094
+ status: "R",
1095
+ baseHash: baseHash === "0".repeat(40) ? null : baseHash,
1096
+ targetHash: targetHash === "0".repeat(40) ? null : targetHash,
1097
+ oldPath,
1098
+ newPath
1099
+ });
1100
+ continue;
1101
+ }
1102
+ const match = line.match(/^:\d+ \d+ ([a-f0-9]+) ([a-f0-9]+) ([ADMT])\t(.+)$/);
1103
+ if (match) {
1104
+ const [, baseHash, targetHash, status, filePath] = match;
1105
+ changes.set(filePath, {
1106
+ status,
1107
+ baseHash: baseHash === "0".repeat(40) ? null : baseHash,
1108
+ targetHash: targetHash === "0".repeat(40) ? null : targetHash
1109
+ });
1110
+ }
1135
1111
  }
1136
- header {
1137
- display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap;
1138
- padding: 0.9rem 1.25rem;
1139
- border-bottom: 1px solid light-dark(#e2e2e2, #2a2a2a);
1140
- position: sticky; top: 0;
1141
- background: inherit;
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
+ });
1142
1128
  }
1143
- header .brand { font-weight: 600; color: light-dark(#0891b2, #22d3ee); }
1144
- header code { font-family: ui-monospace, monospace; font-size: 0.9rem; }
1145
- header .sides { margin-left: auto; font-size: 0.8rem; opacity: 0.7; }
1146
- main { max-width: 1400px; margin: 1rem auto; padding: 0 1rem 2rem; }
1147
- .diff-shell {
1148
- border: 1px solid light-dark(#e2e2e2, #2a2a2a);
1149
- border-radius: 8px; overflow: hidden;
1150
- background: light-dark(#fff, #161616);
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
+ }
1151
1142
  }
1152
- </style>
1153
- </head>
1154
- <body>
1155
- <header>
1156
- <span class="brand">\u29C8 cella diff</span>
1157
- <code>${escapeHtml(meta.filePath)}</code>
1158
- <span class="sides">${escapeHtml(meta.srcLabel)} \u2190 \u2192 ${escapeHtml(meta.dstLabel)} ${note}</span>
1159
- </header>
1160
- <main>
1161
- <!-- Declarative shadow DOM: the library's CSS targets :host, which only
1162
- matches inside a shadow root (its browser component renders into one). -->
1163
- <div class="diff-shell">
1164
- <template shadowrootmode="open">
1165
- ${prerenderedHTML}
1166
- </template>
1167
- </div>
1168
- </main>
1169
- </body>
1170
- </html>`;
1143
+ return hashes;
1171
1144
  }
1172
- function openWithDefaultApp(target) {
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);
1173
1177
  try {
1174
- if (process6.platform === "win32") {
1175
- execFileSync("rundll32", ["url.dll,FileProtocolHandler", target], { stdio: "ignore" });
1176
- } else {
1177
- execFileSync(process6.platform === "darwin" ? "open" : "xdg-open", [target], { stdio: "ignore" });
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));
1178
1195
  }
1179
1196
  } catch {
1180
1197
  }
1181
1198
  }
1182
- async function openDiffInBrowser(patch, meta, repoPath) {
1183
- const html = await renderDiffPage(patch, meta);
1184
- const dir = join6(tmpdir(), `cella-diff-${basename2(repoPath)}`);
1185
- mkdirSync(dir, { recursive: true });
1186
- const outPath = join6(dir, `${meta.filePath.replace(/[^\w.-]+/g, "-")}.html`);
1187
- writeFileSync2(outPath, html);
1188
- openWithDefaultApp(outPath);
1189
- return outPath;
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
+ }
1190
1205
  }
1191
-
1192
- // src/utils/cleanup.ts
1193
- import { existsSync as existsSync5, rmSync } from "fs";
1194
- import { tmpdir as tmpdir2 } from "os";
1195
- import { basename as basename3, join as join9 } from "path";
1196
- import process7 from "process";
1197
-
1198
- // src/utils/git.ts
1199
- import { execFile } from "child_process";
1200
- import { existsSync as existsSync4 } from "fs";
1201
- import { mkdir as mkdir2, readdir, rmdir, unlink } from "fs/promises";
1202
- import { dirname as dirname2, join as join8 } from "path";
1203
- import { promisify } from "util";
1204
-
1205
- // src/utils/manifest.ts
1206
- import { mkdir, readFile, writeFile } from "fs/promises";
1207
- import { dirname, join as join7 } from "path";
1208
- var MANIFEST_FILE = "cella/cella.manifest.json";
1209
- async function readSyncManifest(cwd) {
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;
1210
1220
  try {
1211
- const parsed = JSON.parse(await readFile(join7(cwd, MANIFEST_FILE), "utf8"));
1221
+ const parsed = JSON.parse(raw);
1212
1222
  const commit = parsed?.upstream?.commit;
1213
1223
  if (typeof commit !== "string" || !/^[0-9a-f]{40}$/i.test(commit)) return null;
1214
1224
  return parsed;
@@ -1216,588 +1226,686 @@ async function readSyncManifest(cwd) {
1216
1226
  return null;
1217
1227
  }
1218
1228
  }
1219
- async function readManifestBase(cwd) {
1220
- const manifest = await readSyncManifest(cwd);
1229
+ async function readManifestBaseAtRef(cwd, ref) {
1230
+ const manifest = await readManifestAtRef(cwd, ref);
1221
1231
  return manifest ? manifest.upstream.commit.toLowerCase() : null;
1222
1232
  }
1223
- async function writeSyncManifest(cwd, manifest) {
1224
- const manifestPath = join7(cwd, MANIFEST_FILE);
1225
- await mkdir(dirname(manifestPath), { recursive: true });
1226
- await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}
1227
- `, "utf8");
1228
- }
1229
-
1230
- // src/utils/git.ts
1231
- var execFileAsync = promisify(execFile);
1232
- function sanitizeCredentials(text) {
1233
- return text.replace(/https?:\/\/[^@\s]+@/g, (match) => {
1234
- const protocol = match.startsWith("https") ? "https" : "http";
1235
- return `${protocol}://***@`;
1236
- });
1237
- }
1238
- async function git(args, cwd, options = {}) {
1239
- const env = {
1240
- ...getEnvSnapshot(),
1241
- ...options.skipEditor ? { GIT_EDITOR: "true" } : {},
1242
- ...options.env
1243
- };
1244
- const maxBuffer = options.maxBuffer ?? 50 * 1024 * 1024;
1233
+ async function readPackageVersionAtRef(cwd, ref) {
1234
+ const raw = await git(["show", `${ref}:package.json`], cwd, { ignoreErrors: true });
1235
+ if (!raw) return null;
1245
1236
  try {
1246
- const { stdout } = await execFileAsync("git", args, { cwd, env, maxBuffer });
1247
- return stdout.trim();
1248
- } catch (error) {
1249
- if (options.ignoreErrors) return "";
1250
- if (error instanceof Error) {
1251
- error.message = sanitizeCredentials(error.message);
1252
- }
1253
- throw error;
1237
+ const version = JSON.parse(raw).version;
1238
+ return typeof version === "string" && version ? version : null;
1239
+ } catch {
1240
+ return null;
1254
1241
  }
1255
1242
  }
1256
- async function getCurrentBranch(cwd) {
1257
- return git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
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
+ }
1258
1250
  }
1259
- async function switchBranch(cwd, branch) {
1260
- await git(["switch", branch], cwd);
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;
1261
1255
  }
1262
- async function createBranchFrom(cwd, branch, startPoint) {
1263
- await git(["switch", "-c", branch, startPoint], cwd);
1264
- }
1265
- async function deleteBranch(cwd, branch) {
1266
- await git(["branch", "-D", branch], cwd, { ignoreErrors: true });
1267
- }
1268
- async function pullFastForward(cwd) {
1269
- await git(["pull", "--ff-only"], cwd, { ignoreErrors: true, skipEditor: true });
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;
1270
1294
  }
1271
- async function getUpstreamStatus(cwd) {
1272
- const upstream = await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], cwd, {
1273
- ignoreErrors: true
1274
- });
1275
- if (!upstream) return { upstream: null, ahead: 0, behind: 0 };
1276
- const remote = upstream.split("/")[0];
1277
- await git(["fetch", remote], cwd, { ignoreErrors: true, skipEditor: true });
1278
- const counts = await git(["rev-list", "--left-right", "--count", `HEAD...${upstream}`], cwd, { ignoreErrors: true });
1279
- const [ahead, behind] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0);
1280
- return { upstream, ahead: ahead ?? 0, behind: behind ?? 0 };
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;
1281
1301
  }
1282
- async function pushBranch(cwd, remote, branch, options = {}) {
1283
- await git(["push", "-u", ...options.forceWithLease ? ["--force-with-lease"] : [], remote, branch], cwd);
1302
+ async function isShallowRepository(cwd) {
1303
+ const out = await git(["rev-parse", "--is-shallow-repository"], cwd, { ignoreErrors: true });
1304
+ return out === "true";
1284
1305
  }
1285
- async function listBranchMergeCommits(cwd, baseRef) {
1286
- const out = await git(["rev-list", "--merges", "--first-parent", `${baseRef}..HEAD`], cwd, { ignoreErrors: true });
1287
- return out ? out.split("\n").filter(Boolean) : [];
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
+ }
1288
1322
  }
1289
- async function flattenBranch(cwd, baseRef, message) {
1290
- const parent = await getMergeBase(cwd, baseRef, "HEAD");
1291
- const tree = await git(["rev-parse", "HEAD^{tree}"], cwd);
1292
- const commit = await git(["commit-tree", tree, "-p", parent, "-m", message], cwd);
1293
- await git(["reset", "--hard", commit], cwd);
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);
1294
1360
  }
1295
- async function commitSquash(cwd, message) {
1296
- for (const file of ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE"]) {
1297
- const path4 = join8(cwd, ".git", file);
1298
- if (existsSync4(path4)) await unlink(path4);
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;
1299
1367
  }
1300
- await git(["commit", "-m", message], cwd, { skipEditor: true });
1301
1368
  }
1302
- async function stageAll(cwd) {
1303
- await git(["add", "-A"], cwd);
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;
1304
1378
  }
1305
- function mergeInProgress(cwd) {
1306
- return existsSync4(join8(cwd, ".git", "MERGE_HEAD"));
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 };
1384
+ }
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 };
1307
1390
  }
1308
- async function getShortSha(cwd, ref) {
1309
- return git(["rev-parse", "--short", ref], cwd);
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 });
1404
+ }
1310
1405
  }
1311
- async function getWorkingTreeChangeCount(cwd) {
1312
- const status = await git(["status", "--porcelain"], cwd);
1313
- if (!status) return 0;
1314
- return status.split("\n").filter(Boolean).length;
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
+ });
1315
1421
  }
1316
- async function remoteExists(cwd, remoteName) {
1317
- const remotes = await git(["remote"], cwd);
1318
- return remotes.split("\n").includes(remoteName);
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
+ };
1319
1443
  }
1320
- async function ensureRemote(cwd, remoteName, url) {
1321
- if (await remoteExists(cwd, remoteName)) {
1322
- await git(["remote", "set-url", remoteName, url], cwd);
1323
- } else {
1324
- await git(["remote", "add", remoteName, url], cwd);
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;
1325
1474
  }
1475
+ await mkdir3(dirname3(path4), { recursive: true });
1476
+ await writeFile2(path4, `${JSON.stringify({ pending }, null, 2)}
1477
+ `, "utf8");
1326
1478
  }
1327
- async function fetch2(cwd, remoteName) {
1328
- await git(["fetch", remoteName], cwd);
1479
+ function noteUrl(upstreamGitHubUrl, commit, id) {
1480
+ return upstreamGitHubUrl ? `${upstreamGitHubUrl}/blob/${commit}/${NOTES_DIR}/${id}/README.md` : void 0;
1329
1481
  }
1330
- async function fetchUpstreamTags(cwd, remoteName) {
1331
- await git(["fetch", remoteName, "--no-tags", `+refs/tags/*:refs/${remoteName}/tags/*`], cwd);
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 ")}`;
1332
1487
  }
1333
- async function resolveLatestReleaseTag(cwd, remoteName) {
1334
- const prefix = `refs/${remoteName}/tags/`;
1335
- const output = await git(["for-each-ref", "--sort=-version:refname", "--format=%(refname)", prefix], cwd, {
1336
- 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}/`);
1337
1494
  });
1338
- if (!output) return null;
1339
- for (const line of output.split("\n")) {
1340
- const ref = line.trim();
1341
- if (!ref) continue;
1342
- const tag = ref.slice(prefix.length);
1343
- if (/^v\d/.test(tag)) return { tag, ref };
1344
- }
1345
- return null;
1346
1495
  }
1347
- async function getCommitInfo(cwd, ref) {
1348
- const format = "%H%n%s%n%ar";
1349
- const output = await git(["log", "-1", `--format=${format}`, ref], cwd);
1350
- const [hash, message, date] = output.split("\n");
1351
- return { hash, message, date };
1496
+ function isIgnored(filePath, config) {
1497
+ return isUpstreamOnly(filePath) || isUnderAnyFolder(filePath, config.overrides?.ignored || []);
1352
1498
  }
1353
- async function countCommitsBetween(cwd, fromRef, toRef) {
1354
- const output = await git(["rev-list", "--count", `${fromRef}..${toRef}`], cwd);
1355
- return Number.parseInt(output.trim(), 10) || 0;
1499
+ function isPinned(filePath, config) {
1500
+ if (isManagedFile(filePath)) return true;
1501
+ return isUnderAnyFolder(filePath, config.overrides?.pinned || []);
1356
1502
  }
1357
- async function listCommitsBetween(cwd, fromRef, toRef, options = {}) {
1358
- const { oldestFirst = true, skip = 0, limit } = options;
1359
- const args = ["log", "--format=%H%x1f%s%x1f%ar"];
1360
- if (oldestFirst) args.push("--reverse");
1361
- args.push(`${fromRef}..${toRef}`);
1362
- const output = await git(args, cwd, { ignoreErrors: true });
1363
- if (!output) return [];
1364
- const commits = [];
1365
- for (const line of output.split("\n")) {
1366
- if (!line) continue;
1367
- const [hash, message, date] = line.split("");
1368
- if (!hash || !message || !date) continue;
1369
- commits.push({ hash, message, date });
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);
1370
1516
  }
1371
- const normalizedSkip = Math.max(0, skip);
1372
- const normalizedLimit = typeof limit === "number" && limit > 0 ? limit : void 0;
1373
- if (normalizedSkip === 0 && normalizedLimit === void 0) {
1374
- return commits;
1375
- }
1376
- const start = Math.min(normalizedSkip, commits.length);
1377
- const end = normalizedLimit === void 0 ? commits.length : Math.min(start + normalizedLimit, commits.length);
1378
- return commits.slice(start, end);
1379
- }
1380
- async function getFileChangeInfo(cwd, fromRef, toRef) {
1381
- const info = /* @__PURE__ */ new Map();
1382
- try {
1383
- const output = await git(["log", "--name-only", "--format=%h %ct %ar", `${fromRef}..${toRef}`], cwd, {
1384
- ignoreErrors: true
1385
- });
1386
- if (!output.trim()) return info;
1387
- const lines = output.split("\n");
1388
- let currentHash = "";
1389
- let currentDate = "";
1390
- let currentTimestamp = 0;
1391
- for (const line of lines) {
1392
- if (!line) continue;
1393
- const match = line.match(/^([a-f0-9]{7,})\s+(\d+)\s+(.+)$/);
1394
- if (match) {
1395
- currentHash = match[1];
1396
- currentTimestamp = Number(match[2]);
1397
- currentDate = match[3];
1398
- } else if (currentHash && currentDate && !info.has(line)) {
1399
- info.set(line, { date: currentDate, hash: currentHash, timestamp: currentTimestamp });
1400
- }
1401
- }
1402
- } catch {
1403
- }
1404
- return info;
1405
- }
1406
- async function isClean(cwd) {
1407
- const status = await git(["status", "--porcelain"], cwd);
1408
- return !status;
1409
- }
1410
- async function assertClean(cwd, subject = "working directory") {
1411
- if (!await isClean(cwd)) {
1412
- throw new Error(`${subject} has uncommitted changes. please commit or stash before syncing.`);
1413
- }
1414
- }
1415
- async function createWorktree(cwd, worktreePath, commitRef) {
1416
- await git(["worktree", "add", "--detach", worktreePath, commitRef], cwd);
1517
+ for (const group of groups.values()) group.paths.sort();
1518
+ return entries.flatMap((entry) => groups.get(entry) ?? []);
1417
1519
  }
1418
- async function removeWorktree(cwd, worktreePath) {
1419
- await git(["worktree", "remove", worktreePath, "--force"], cwd, { ignoreErrors: true });
1420
- await git(["worktree", "prune"], cwd, { ignoreErrors: true });
1520
+ function isPinnedForSync(filePath, config, unpinned) {
1521
+ if (isManagedFile(filePath)) return true;
1522
+ if (unpinned) return false;
1523
+ return isPinned(filePath, config);
1421
1524
  }
1422
- async function listWorktrees(cwd) {
1423
- const output = await git(["worktree", "list", "--porcelain"], cwd);
1424
- const lines = output.split("\n");
1425
- const paths = [];
1426
- for (const line of lines) {
1427
- if (line.startsWith("worktree ")) {
1428
- paths.push(line.slice(9));
1429
- }
1430
- }
1431
- return paths;
1525
+ function hasGlobChars(entry) {
1526
+ return entry.includes("*") || entry.includes("?");
1432
1527
  }
1433
- async function merge(cwd, ref, options = {}) {
1434
- const args = ["merge", "--no-ff", ref];
1435
- if (options.noCommit) args.push("--no-commit");
1436
- if (options.noEdit) args.push("--no-edit");
1437
- try {
1438
- await git(args, cwd, { skipEditor: true });
1439
- return { success: true, conflicts: [] };
1440
- } catch (error) {
1441
- const conflicts = await getConflictedFiles(cwd);
1442
- if (conflicts.length > 0) {
1443
- return { success: false, conflicts };
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
+ });
1444
1544
  }
1445
- throw error;
1446
1545
  }
1447
- }
1448
- async function getConflictedFiles(cwd) {
1449
- const output = await git(["diff", "--name-only", "--diff-filter=U"], cwd, { ignoreErrors: true });
1450
- if (!output) return [];
1451
- return output.split("\n").filter(Boolean);
1452
- }
1453
- async function mergeAbort(cwd) {
1454
- await git(["merge", "--abort"], cwd, { ignoreErrors: true });
1455
- }
1456
- async function fileExistsAtRef(cwd, ref, filePath) {
1457
- const result = await git(["ls-tree", "--name-only", ref, "--", filePath], cwd, { ignoreErrors: true });
1458
- return result !== "";
1459
- }
1460
- async function checkoutFromRef(cwd, ref, filePath) {
1461
- await git(["checkout", ref, "--", filePath], cwd);
1462
- }
1463
- async function restoreWorktreeFromRef(cwd, ref, filePath) {
1464
- await git(["restore", `--source=${ref}`, "--worktree", "--", filePath], cwd);
1465
- }
1466
- async function getMergeBase(cwd, ref1, ref2) {
1467
- return git(["merge-base", ref1, ref2], cwd);
1468
- }
1469
- async function getFileChanges(cwd, baseRef, targetRef) {
1470
- const output = await git(["diff-tree", "-r", "-M90%", "--no-commit-id", baseRef, targetRef], cwd, {
1471
- ignoreErrors: true
1472
- });
1473
- const changes = /* @__PURE__ */ new Map();
1474
- if (!output) return changes;
1475
- for (const line of output.split("\n")) {
1476
- if (!line) continue;
1477
- const renameMatch = line.match(/^:\d+ \d+ ([a-f0-9]+) ([a-f0-9]+) R\d*\s+(.+)\t(.+)$/);
1478
- if (renameMatch) {
1479
- const [, baseHash, targetHash, oldPath, newPath] = renameMatch;
1480
- changes.set(newPath, {
1481
- status: "R",
1482
- baseHash: baseHash === "0".repeat(40) ? null : baseHash,
1483
- targetHash: targetHash === "0".repeat(40) ? null : targetHash,
1484
- oldPath,
1485
- newPath
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}`
1486
1553
  });
1487
- continue;
1488
- }
1489
- const match = line.match(/^:\d+ \d+ ([a-f0-9]+) ([a-f0-9]+) ([ADMT])\t(.+)$/);
1490
- if (match) {
1491
- const [, baseHash, targetHash, status, filePath] = match;
1492
- changes.set(filePath, {
1493
- status,
1494
- baseHash: baseHash === "0".repeat(40) ? null : baseHash,
1495
- targetHash: targetHash === "0".repeat(40) ? null : targetHash
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}`
1496
1559
  });
1497
1560
  }
1498
1561
  }
1499
- return changes;
1500
- }
1501
- async function getDiffStat(cwd, fromRef, toRef, paths = []) {
1502
- const args = ["diff", "--numstat", "--no-renames", fromRef, toRef];
1503
- if (paths.length > 0) args.push("--", ...paths);
1504
- const out = await git(args, cwd, { ignoreErrors: true });
1505
- const stat = /* @__PURE__ */ new Map();
1506
- for (const line of out.split("\n")) {
1507
- if (!line.trim()) continue;
1508
- const [a, d, ...rest] = line.split(" ");
1509
- const path4 = rest.join(" ");
1510
- if (!path4) continue;
1511
- stat.set(path4, {
1512
- additions: a === "-" ? null : Number(a),
1513
- deletions: d === "-" ? null : Number(d)
1514
- });
1515
- }
1516
- return stat;
1562
+ return warnings;
1517
1563
  }
1518
- async function getFileHashesAtRef(cwd, ref) {
1519
- const output = await git(["ls-tree", "-r", ref], cwd);
1520
- const hashes = /* @__PURE__ */ new Map();
1521
- if (!output) return hashes;
1522
- for (const line of output.split("\n")) {
1523
- if (!line) continue;
1524
- const match = line.match(/^\d+ \w+ ([a-f0-9]+)\t(.+)$/);
1525
- if (match) {
1526
- const [, hash, filePath] = match;
1527
- hashes.set(filePath, hash);
1528
- }
1564
+ function printWarnings(warnings) {
1565
+ for (const warning of warnings) {
1566
+ console.info(`${warningMark} ${warning.message}`);
1529
1567
  }
1530
- return hashes;
1531
- }
1532
- async function restoreToHead(cwd, filePath) {
1533
- await git(["checkout", "HEAD", "--", filePath], cwd);
1534
- }
1535
- async function batchRestoreToHead(cwd, filePaths) {
1536
- if (filePaths.length === 0) return;
1537
- await git(["checkout", "HEAD", "--", ...filePaths], cwd);
1538
- }
1539
- async function gitRm(cwd, filePath) {
1540
- await git(["rm", "-f", "--", filePath], cwd, { ignoreErrors: true });
1541
- }
1542
- async function batchGitRm(cwd, filePaths) {
1543
- if (filePaths.length === 0) return;
1544
- await git(["rm", "-f", "--", ...filePaths], cwd, { ignoreErrors: true });
1545
- }
1546
- async function getStagedNewFiles(cwd) {
1547
- const output = await git(["diff", "--cached", "--name-only", "--diff-filter=A"], cwd, { ignoreErrors: true });
1548
- return output ? output.split("\n").filter(Boolean) : [];
1549
- }
1550
- async function batchUnstageFiles(cwd, filePaths) {
1551
- if (filePaths.length === 0) return;
1552
- await git(["rm", "--cached", "-f", "--", ...filePaths], cwd, { ignoreErrors: true });
1553
- }
1554
- async function gitMv(cwd, oldPath, newPath) {
1555
- const newDir = dirname2(join8(cwd, newPath));
1556
- await mkdir2(newDir, { recursive: true });
1557
- await git(["mv", "-f", oldPath, newPath], cwd);
1558
- }
1559
- async function fileExistsInWorktree(cwd, filePath) {
1560
- return existsSync4(join8(cwd, filePath));
1561
1568
  }
1562
- async function removeFileFromWorktree(cwd, filePath) {
1563
- const fullPath = join8(cwd, filePath);
1564
- try {
1565
- await unlink(fullPath);
1566
- await cleanupEmptyParentDirs(cwd, dirname2(filePath));
1567
- } catch {
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`);
1568
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
+ };
1569
1597
  }
1570
- async function removeFileFully(cwd, filePath) {
1571
- await gitRm(cwd, filePath);
1572
- await removeFileFromWorktree(cwd, filePath);
1573
- }
1574
- async function cleanupEmptyParentDirs(cwd, relativePath) {
1575
- if (!relativePath || relativePath === ".") return;
1576
- const fullPath = join8(cwd, relativePath);
1577
- try {
1578
- const entries = await readdir(fullPath);
1579
- if (entries.length === 0) {
1580
- await rmdir(fullPath);
1581
- await cleanupEmptyParentDirs(cwd, dirname2(relativePath));
1582
- }
1583
- } catch {
1584
- }
1585
- }
1586
- async function storeLastSyncRef(cwd, upstreamHash) {
1587
- await git(["update-ref", "refs/cella/last-sync", upstreamHash], cwd);
1588
- const head = await git(["rev-parse", "HEAD"], cwd, { ignoreErrors: true });
1589
- if (head) {
1590
- await git(["update-ref", "refs/cella/last-sync-head", head], cwd);
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
+ ]
1591
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
+ };
1592
1682
  }
1593
- async function stagePath(cwd, path4) {
1594
- await git(["add", "--", path4], cwd, { ignoreErrors: true });
1595
- }
1596
- async function getStoredSyncRef(cwd) {
1597
- const ref = await git(["rev-parse", "refs/cella/last-sync"], cwd, { ignoreErrors: true });
1598
- return ref || null;
1599
- }
1600
- async function getStoredSyncHead(cwd) {
1601
- const ref = await git(["rev-parse", "refs/cella/last-sync-head"], cwd, { ignoreErrors: true });
1602
- 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
+ ];
1603
1694
  }
1604
- async function readManifestAtRef(cwd, ref) {
1605
- const raw = await git(["show", `${ref}:${MANIFEST_FILE}`], cwd, { ignoreErrors: true });
1606
- if (!raw) return null;
1607
- try {
1608
- const parsed = JSON.parse(raw);
1609
- const commit = parsed?.upstream?.commit;
1610
- if (typeof commit !== "string" || !/^[0-9a-f]{40}$/i.test(commit)) return null;
1611
- return parsed;
1612
- } catch {
1613
- 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);
1614
1699
  }
1700
+ command.action((opts) => {
1701
+ setSelection({
1702
+ service: definition.name,
1703
+ options: readOptions(opts)
1704
+ });
1705
+ });
1615
1706
  }
1616
- async function readManifestBaseAtRef(cwd, ref) {
1617
- const manifest = await readManifestAtRef(cwd, ref);
1618
- return manifest ? manifest.upstream.commit.toLowerCase() : null;
1619
- }
1620
- async function readPackageVersionAtRef(cwd, ref) {
1621
- const raw = await git(["show", `${ref}:package.json`], cwd, { ignoreErrors: true });
1622
- if (!raw) return null;
1623
- try {
1624
- const version = JSON.parse(raw).version;
1625
- return typeof version === "string" && version ? version : null;
1626
- } catch {
1627
- 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);
1628
1726
  }
1727
+ return program;
1629
1728
  }
1630
- async function commitObjectExists(cwd, sha) {
1631
- try {
1632
- await git(["cat-file", "-e", `${sha}^{commit}`], cwd);
1633
- return true;
1634
- } catch {
1635
- return false;
1729
+ function parseCommandLine(argv) {
1730
+ if (argv.length <= 2) {
1731
+ return { options: readOptions({}) };
1636
1732
  }
1733
+ let selection = { options: readOptions({}) };
1734
+ const program = buildProgram((nextSelection) => {
1735
+ selection = nextSelection;
1736
+ });
1737
+ program.parse(argv);
1738
+ return selection;
1637
1739
  }
1638
- async function getRootCommit(cwd, ref) {
1639
- const out = await git(["rev-list", "--max-parents=0", ref], cwd, { ignoreErrors: true });
1640
- const roots = out.split("\n").filter(Boolean);
1641
- return roots.length > 0 ? roots[roots.length - 1] : null;
1642
- }
1643
- var SCAFFOLD_CANDIDATE_LIMIT = 400;
1644
- var SCAFFOLD_SCORE_CONCURRENCY = 10;
1645
- var SCAFFOLD_MATCH_MAX_DIFF_RATIO = 0.3;
1646
- async function inferScaffoldBase(cwd, headRef, upstreamRef) {
1647
- const root = await getRootCommit(cwd, headRef);
1648
- if (!root) return null;
1649
- const rootDate = await git(["show", "-s", "--format=%cI", root], cwd, { ignoreErrors: true });
1650
- if (!rootDate) return null;
1651
- const before = new Date(new Date(rootDate).getTime() + 24 * 60 * 60 * 1e3).toISOString();
1652
- const revList = await git(
1653
- ["rev-list", `--max-count=${SCAFFOLD_CANDIDATE_LIMIT}`, `--before=${before}`, upstreamRef],
1654
- cwd,
1655
- { ignoreErrors: true }
1656
- );
1657
- const candidates = revList.split("\n").filter(Boolean);
1658
- if (candidates.length === 0) return null;
1659
- const totalPaths = (await git(["ls-tree", "-r", "--name-only", root], cwd)).split("\n").filter(Boolean).length;
1660
- if (totalPaths === 0) return null;
1661
- let best = null;
1662
- outer: for (let i = 0; i < candidates.length; i += SCAFFOLD_SCORE_CONCURRENCY) {
1663
- const chunk = candidates.slice(i, i + SCAFFOLD_SCORE_CONCURRENCY);
1664
- const scores = await Promise.all(
1665
- chunk.map(async (sha) => {
1666
- try {
1667
- const out = await git(["diff-tree", "-r", "--name-only", sha, root], cwd);
1668
- return { sha, diff: out ? out.split("\n").length : 0 };
1669
- } catch {
1670
- return { sha, diff: Number.POSITIVE_INFINITY };
1671
- }
1672
- })
1673
- );
1674
- for (const score of scores) {
1675
- if (!best || score.diff < best.diff) best = score;
1676
- if (best.diff === 0) break outer;
1677
- }
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);
1678
1750
  }
1679
- if (!best || best.diff / totalPaths > SCAFFOLD_MATCH_MAX_DIFF_RATIO) return null;
1680
- return best.sha;
1681
- }
1682
- async function readRootTrailerBase(cwd, headRef) {
1683
- const root = await getRootCommit(cwd, headRef);
1684
- if (!root) return null;
1685
- const message = await git(["show", "-s", "--format=%B", root], cwd, { ignoreErrors: true });
1686
- const match = message.match(/^cella-base:\s*([0-9a-f]{40})$/im);
1687
- return match ? match[1].toLowerCase() : null;
1688
- }
1689
- async function isShallowRepository(cwd) {
1690
- const out = await git(["rev-parse", "--is-shallow-repository"], cwd, { ignoreErrors: true });
1691
- return out === "true";
1751
+ console.info();
1752
+ return selected;
1692
1753
  }
1693
- async function unshallowRepository(cwd) {
1694
- try {
1695
- await git(["fetch", "--unshallow"], cwd, { skipEditor: true });
1696
- } catch (error) {
1697
- const detail = error instanceof Error ? `
1698
-
1699
- ${error.message}` : "";
1700
- throw new Error(
1701
- `this is a shallow clone and its full history could not be restored automatically.
1702
- Sync needs the complete history to find the common ancestor with upstream. Restore it with:
1703
-
1704
- git fetch --unshallow
1705
-
1706
- then re-run sync.${detail}`
1707
- );
1708
- }
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
+ };
1709
1763
  }
1710
- async function ensureSyncBase(cwd, headRef, upstreamRef) {
1711
- if (await isShallowRepository(cwd)) {
1712
- await unshallowRepository(cwd);
1713
- }
1714
- const nativeBase = await git(["merge-base", headRef, upstreamRef], cwd, { ignoreErrors: true });
1715
- if (nativeBase) return;
1716
- const sources = [
1717
- () => getStoredSyncRef(cwd),
1718
- () => readManifestBaseAtRef(cwd, headRef),
1719
- () => readManifestBase(cwd),
1720
- () => readRootTrailerBase(cwd, headRef),
1721
- () => inferScaffoldBase(cwd, headRef, upstreamRef)
1722
- ];
1723
- let baseSha = null;
1724
- for (const source of sources) {
1725
- const sha = await source();
1726
- if (sha && await commitObjectExists(cwd, sha)) {
1727
- baseSha = sha;
1728
- break;
1729
- }
1730
- }
1731
- if (!baseSha) {
1732
- throw new Error(
1733
- `no common ancestor between the fork and '${upstreamRef}', and no sync base could be determined.
1734
-
1735
- This fork has unrelated history with upstream \u2014 typical for a create-cella scaffold, or after
1736
- upstream squashed its history \u2014 and no recorded or inferable base commit exists after fetching
1737
- upstream. Seed it manually with the upstream commit the fork was created from, then re-run sync:
1738
-
1739
- git update-ref refs/cella/last-sync <upstream-sha>
1740
- `
1741
- );
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();
1742
1772
  }
1743
- const root = await getRootCommit(cwd, headRef);
1744
- if (!root) throw new Error("could not determine the fork's root commit to bootstrap sync ancestry");
1745
- await git(["replace", "-f", "--graft", root, baseSha], cwd);
1746
- await git(["update-ref", "refs/cella/last-sync", baseSha], cwd);
1773
+ if (!selection.service) {
1774
+ selection.service = await promptForService(userConfig, forkPath);
1775
+ }
1776
+ return buildRuntimeConfig(userConfig, forkPath, selection);
1747
1777
  }
1748
- async function isAncestor(cwd, ancestor, descendant) {
1778
+
1779
+ // src/services/analyze.ts
1780
+ import { basename as basename4 } from "path";
1781
+
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);
1749
1792
  try {
1750
- await git(["merge-base", "--is-ancestor", ancestor, descendant], cwd);
1751
- return true;
1752
- } catch {
1753
- return false;
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}`);
1754
1797
  }
1755
1798
  }
1756
- async function getLegacySyncRef(cwd, headRef) {
1757
- const storedRef = await getStoredSyncRef(cwd);
1758
- if (!storedRef) return null;
1759
- const storedHead = await getStoredSyncHead(cwd);
1760
- if (storedHead) {
1761
- const currentHead = await git(["rev-parse", headRef], cwd, { ignoreErrors: true });
1762
- if (currentHead && currentHead === storedHead) return null;
1763
- }
1764
- return storedRef;
1799
+ function escapeHtml(text) {
1800
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1765
1801
  }
1766
- async function getEffectiveMergeBase(cwd, headRef, upstreamRef) {
1767
- const gitBase = await getMergeBase(cwd, headRef, upstreamRef);
1768
- const recorded = await readManifestBaseAtRef(cwd, headRef) ?? await getLegacySyncRef(cwd, headRef);
1769
- if (!recorded) {
1770
- return { base: gitBase, isStale: false, storedRef: null };
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);
1771
1831
  }
1772
- const recordedIsNewer = await isAncestor(cwd, gitBase, recorded);
1773
- if (recordedIsNewer && recorded !== gitBase) {
1774
- 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;
1775
1838
  }
1776
- 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>`;
1777
1867
  }
1778
- async function withTemporarySyncBaseGraft(cwd, headRef, baseSha, fn) {
1779
- const headSha = await git(["rev-parse", headRef], cwd, { ignoreErrors: true });
1780
- if (!headSha || !await commitObjectExists(cwd, baseSha)) return fn();
1781
- if (await isAncestor(cwd, baseSha, headSha)) return fn();
1782
- const existingReplacement = await git(["replace", "-l", headSha], cwd, { ignoreErrors: true });
1783
- if (existingReplacement) return fn();
1784
- const parentsRaw = await git(["log", "-1", "--format=%P", headSha], cwd, { ignoreErrors: true });
1785
- const parents = parentsRaw ? parentsRaw.split(/\s+/) : [];
1786
- await git(["replace", "-f", "--graft", headSha, ...parents, baseSha], cwd);
1868
+ function openWithDefaultApp(target) {
1787
1869
  try {
1788
- return await fn();
1789
- } finally {
1790
- 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 {
1791
1876
  }
1792
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";
1793
1892
 
1794
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";
1795
1899
  var WORKTREE_PREFIXES = {
1796
1900
  sync: "cella-sync-",
1797
1901
  view: "cella-view-"
1798
1902
  };
1799
1903
  function buildWorktreePath(kind, repoPath) {
1800
- 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)}`);
1801
1909
  }
1802
1910
  function getWorktreePath(repoPath) {
1803
1911
  return buildWorktreePath("sync", repoPath);
@@ -1815,22 +1923,21 @@ function unregisterWorktree() {
1815
1923
  }
1816
1924
  async function cleanupWorktree(repoPath, worktreePath) {
1817
1925
  await removeWorktree(repoPath, worktreePath);
1818
- if (existsSync5(worktreePath)) {
1926
+ if (existsSync6(worktreePath)) {
1819
1927
  rmSync(worktreePath, { recursive: true, force: true });
1820
1928
  }
1821
1929
  unregisterWorktree();
1822
1930
  }
1823
1931
  async function cleanupLeftoverWorktrees(repoPath) {
1824
1932
  for (const kind of Object.keys(WORKTREE_PREFIXES)) {
1825
- const worktreePath = buildWorktreePath(kind, repoPath);
1826
- if (existsSync5(worktreePath)) {
1827
- await cleanupWorktree(repoPath, worktreePath);
1933
+ for (const worktreePath of [buildWorktreePath(kind, repoPath), legacyWorktreePath(kind, repoPath)]) {
1934
+ if (existsSync6(worktreePath)) await cleanupWorktree(repoPath, worktreePath);
1828
1935
  }
1829
1936
  }
1830
1937
  const prefixes = Object.values(WORKTREE_PREFIXES);
1831
1938
  const worktrees = await listWorktrees(repoPath);
1832
1939
  for (const wt of worktrees) {
1833
- if (prefixes.some((prefix) => wt.includes(prefix)) && !existsSync5(wt)) {
1940
+ if (prefixes.some((prefix) => wt.includes(prefix)) && !existsSync6(wt)) {
1834
1941
  await removeWorktree(repoPath, wt);
1835
1942
  }
1836
1943
  }
@@ -1861,6 +1968,51 @@ function registerSignalHandlers() {
1861
1968
  cleanupRegistered = true;
1862
1969
  }
1863
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
+
1864
2016
  // src/utils/upstream-overrides.ts
1865
2017
  import { Node, Project as Project2, SyntaxKind as SyntaxKind2 } from "ts-morph";
1866
2018
  var LIST_KEYS = ["pinned", "ignored"];
@@ -2391,6 +2543,9 @@ async function prepareUpstream(config, onProgress, onStep) {
2391
2543
  }
2392
2544
  config.upstreamRef = upstreamRef;
2393
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);
2394
2549
  onProgress?.("checking sync base...");
2395
2550
  await ensureSyncBase(forkPath, "HEAD", upstreamRef);
2396
2551
  const upstreamGitHubUrl = getGitHubBaseUrl(config.settings.upstreamUrl) ?? void 0;
@@ -2436,9 +2591,34 @@ async function upstreamOnlyReports(config, ctx, files) {
2436
2591
  return {};
2437
2592
  }
2438
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
+ }
2439
2618
  async function runSyncMerge(config, ctx, onProgress, onStep) {
2440
2619
  const { forkPath } = config;
2441
2620
  const { upstreamRef, releaseTag, mergeBase, upstreamGitHubUrl, upstreamCommit } = ctx;
2621
+ const notes = await readNotesInRange(config, ctx);
2442
2622
  const { remainingConflicts, analyzedFiles, autoMergedFiles, protectedConflicts } = await applyDirectMerge(
2443
2623
  forkPath,
2444
2624
  upstreamRef,
@@ -2465,6 +2645,11 @@ async function runSyncMerge(config, ctx, onProgress, onStep) {
2465
2645
  await storeLastSyncRef(forkPath, upstreamCommit.hash);
2466
2646
  await writeSyncManifest(forkPath, syncManifest);
2467
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);
2468
2653
  };
2469
2654
  if (remainingConflicts.length > 0) {
2470
2655
  await recordSyncPoint();
@@ -2488,12 +2673,14 @@ async function runSyncMerge(config, ctx, onProgress, onStep) {
2488
2673
  autoMergedFiles,
2489
2674
  protectedConflicts,
2490
2675
  ...resultMeta(config, ctx),
2491
- ...await upstreamOnlyReports(config, ctx, analyzedFiles)
2676
+ ...await upstreamOnlyReports(config, ctx, analyzedFiles),
2677
+ ...notesResult(notes)
2492
2678
  };
2493
2679
  }
2494
2680
  async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2495
2681
  const { forkPath } = config;
2496
2682
  const worktreePath = getWorktreePath(forkPath);
2683
+ const notes = await readNotesInRange(config, ctx);
2497
2684
  onProgress?.("creating worktree in temp directory...");
2498
2685
  await createWorktree(forkPath, worktreePath, "HEAD");
2499
2686
  onStep?.("worktree created", worktreePath);
@@ -2527,7 +2714,8 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2527
2714
  conflicts: analyzedFiles.filter((f) => f.status === "diverged").map((f) => f.path),
2528
2715
  protectedConflicts: analyzedFiles.filter((f) => f.upstreamChanged).map((f) => f.path),
2529
2716
  ...resultMeta(config, ctx),
2530
- ...reports
2717
+ ...reports,
2718
+ ...notesResult(notes)
2531
2719
  };
2532
2720
  }
2533
2721
  async function runMergeEngine(config, options) {
@@ -2545,6 +2733,151 @@ async function runMergeEngine(config, options) {
2545
2733
  }
2546
2734
  }
2547
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
+
2548
2881
  // src/services/analyze.ts
2549
2882
  var scopeStatuses = {
2550
2883
  all: /* @__PURE__ */ new Set(["ahead", "drifted", "diverged"]),
@@ -2649,6 +2982,7 @@ async function runAnalyze(config) {
2649
2982
  printIgnoredUpstreamChanges(result);
2650
2983
  printUpstreamOverrideChanges(result);
2651
2984
  printMaskingPinWarning(result.files);
2985
+ await printMigrationNotesLine(config, result);
2652
2986
  if (config.logFile) {
2653
2987
  const logPath = writeLogFile(config.forkPath, result.files);
2654
2988
  console.info();
@@ -3427,7 +3761,7 @@ async function checkOverrides(forkPath) {
3427
3761
 
3428
3762
  // src/services/contributions.ts
3429
3763
  import { readFileSync as readFileSync4 } from "fs";
3430
- import { join as join13 } from "path";
3764
+ import { join as join16 } from "path";
3431
3765
  import {
3432
3766
  createPrompt,
3433
3767
  isDownKey,
@@ -3442,7 +3776,7 @@ import {
3442
3776
  import { select as select2 } from "@inquirer/prompts";
3443
3777
 
3444
3778
  // src/services/contrib-core.ts
3445
- 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";
3446
3780
  async function listFilesAtRef(repoPath, ref) {
3447
3781
  const raw = await git(["ls-tree", "-r", "--name-only", ref], repoPath);
3448
3782
  return new Set(raw.split("\n").filter(Boolean));
@@ -3458,10 +3792,10 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
3458
3792
  const baseFiles = await listFilesAtRef(repoPath, baseRef);
3459
3793
  const baseDirs = /* @__PURE__ */ new Set(["."]);
3460
3794
  for (const f of baseFiles) {
3461
- let dir = dirname3(f);
3795
+ let dir = dirname5(f);
3462
3796
  while (dir !== ".") {
3463
3797
  baseDirs.add(dir);
3464
- dir = dirname3(dir);
3798
+ dir = dirname5(dir);
3465
3799
  }
3466
3800
  }
3467
3801
  const files = [];
@@ -3474,7 +3808,7 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
3474
3808
  if (inCella && !inFork) {
3475
3809
  kind = "deleted";
3476
3810
  } else if (!inCella && inFork) {
3477
- if (!baseDirs.has(dirname3(file.path))) continue;
3811
+ if (!baseDirs.has(dirname5(file.path))) continue;
3478
3812
  kind = "created";
3479
3813
  } else {
3480
3814
  kind = "modified";
@@ -3505,8 +3839,8 @@ async function readBlobInfo(repoPath, ref, filePath) {
3505
3839
  }
3506
3840
  async function buildContribBranch(repoPath, baseRef, forkRef, detection, forkName) {
3507
3841
  const branch = `contrib/${forkName}`;
3508
- const gitDir = resolve3(repoPath, await git(["rev-parse", "--git-dir"], repoPath));
3509
- 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, "_")}`);
3510
3844
  const indexEnv = { GIT_INDEX_FILE: tmpIndex };
3511
3845
  try {
3512
3846
  await git(["read-tree", baseRef], repoPath, { env: indexEnv });
@@ -3549,20 +3883,20 @@ ${commitBody}`;
3549
3883
  }
3550
3884
 
3551
3885
  // src/services/fork-utils.ts
3552
- import { existsSync as existsSync7 } from "fs";
3553
- 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";
3554
3888
  async function resolveForkBasePath(repoPath) {
3555
3889
  const commonDir = await git(["rev-parse", "--git-common-dir"], repoPath, { ignoreErrors: true });
3556
- return commonDir ? dirname4(resolve4(repoPath, commonDir)) : repoPath;
3890
+ return commonDir ? dirname6(resolve5(repoPath, commonDir)) : repoPath;
3557
3891
  }
3558
3892
  function validateForkPath(fork, basePath, requireConfig = false) {
3559
- const resolvedPath = resolve4(basePath, fork.localPath);
3560
- if (!resolvedPath.startsWith(resolve4(basePath, ".."))) {
3893
+ const resolvedPath = resolve5(basePath, fork.localPath);
3894
+ if (!resolvedPath.startsWith(resolve5(basePath, ".."))) {
3561
3895
  return { fork, resolvedPath, valid: false, error: "path resolves outside parent directory" };
3562
3896
  }
3563
- if (!existsSync7(resolvedPath)) return { fork, resolvedPath, valid: false, error: "path does not exist" };
3564
- if (!existsSync7(`${resolvedPath}/.git`)) return { fork, resolvedPath, valid: false, error: "not a git repository" };
3565
- 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}`)) {
3566
3900
  return { fork, resolvedPath, valid: false, error: `missing ${CONFIG_FILE}` };
3567
3901
  }
3568
3902
  return { fork, resolvedPath, valid: true };
@@ -3580,7 +3914,7 @@ async function showContribDiff(item, baseRef, cwd, forkName) {
3580
3914
  return openDiffInBrowser(patch.toString(), { filePath: item.path, srcLabel: "cella", dstLabel: forkName }, cwd);
3581
3915
  }
3582
3916
  function shouldApplyContributionsUnstaged(repoPath) {
3583
- return JSON.parse(readFileSync4(join13(repoPath, "package.json"), "utf8")).name === "cella";
3917
+ return JSON.parse(readFileSync4(join16(repoPath, "package.json"), "utf8")).name === "cella";
3584
3918
  }
3585
3919
  var contribPrompt = createPrompt((config, done) => {
3586
3920
  const { pageSize = 20, baseRef, cwd, forkName } = config;
@@ -3925,7 +4259,7 @@ async function runContributions(config) {
3925
4259
  }
3926
4260
 
3927
4261
  // src/services/forks.ts
3928
- import { resolve as resolve5 } from "path";
4262
+ import { resolve as resolve6 } from "path";
3929
4263
  import process8 from "process";
3930
4264
  import { Separator as Separator2, select as select4 } from "@inquirer/prompts";
3931
4265
 
@@ -3968,7 +4302,6 @@ function listOpenSyncPrs(cwd, prefix) {
3968
4302
  }
3969
4303
  function buildMergeArgs(ref, options = {}) {
3970
4304
  const args = ["pr", "merge", ref, "--squash"];
3971
- if (options.auto) args.push("--auto");
3972
4305
  if (options.deleteBranch) args.push("--delete-branch");
3973
4306
  return args;
3974
4307
  }
@@ -3980,12 +4313,12 @@ function closePr(cwd, ref) {
3980
4313
  }
3981
4314
 
3982
4315
  // src/services/packages.ts
3983
- import { existsSync as existsSync9, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
3984
- 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";
3985
4318
 
3986
4319
  // src/services/package-usage.ts
3987
- import { existsSync as existsSync8, readFileSync as readFileSync5, statSync } from "fs";
3988
- import { join as join14 } from "path";
4320
+ import { existsSync as existsSync10, readFileSync as readFileSync5, statSync } from "fs";
4321
+ import { join as join17 } from "path";
3989
4322
  var MAX_FILE_BYTES = 1024 * 1024;
3990
4323
  function escapeRegExp(value) {
3991
4324
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -4001,7 +4334,7 @@ function readSearchable(filePath) {
4001
4334
  }
4002
4335
  function fileSource(forkPath, path4) {
4003
4336
  let text;
4004
- return { path: path4, label: path4, text: () => text ??= readSearchable(join14(forkPath, path4)) };
4337
+ return { path: path4, label: path4, text: () => text ??= readSearchable(join17(forkPath, path4)) };
4005
4338
  }
4006
4339
  async function readScriptsAtRef(forkPath, ref, path4) {
4007
4340
  try {
@@ -4013,7 +4346,7 @@ async function readScriptsAtRef(forkPath, ref, path4) {
4013
4346
  async function scriptSources(forkPath, upstreamRef, baseRef, path4) {
4014
4347
  let forkScripts;
4015
4348
  try {
4016
- forkScripts = JSON.parse(readFileSync5(join14(forkPath, path4), "utf-8")).scripts ?? {};
4349
+ forkScripts = JSON.parse(readFileSync5(join17(forkPath, path4), "utf-8")).scripts ?? {};
4017
4350
  } catch {
4018
4351
  return [];
4019
4352
  }
@@ -4023,9 +4356,9 @@ async function scriptSources(forkPath, upstreamRef, baseRef, path4) {
4023
4356
  }
4024
4357
  function binNames(forkPath, location, name) {
4025
4358
  const bareName = name.split("/").pop();
4026
- for (const dir of [join14(forkPath, location), forkPath]) {
4027
- const manifest = join14(dir, "node_modules", name, "package.json");
4028
- 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;
4029
4362
  try {
4030
4363
  const { bin } = JSON.parse(readFileSync5(manifest, "utf-8"));
4031
4364
  if (typeof bin === "string") return [bareName];
@@ -4067,26 +4400,6 @@ async function loadForkUsage(forkPath, upstreamRef, baseRef) {
4067
4400
  }
4068
4401
 
4069
4402
  // src/services/packages.ts
4070
- function parseComparableVersion(version) {
4071
- const trimmed = version.trim();
4072
- 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("||")) {
4073
- return null;
4074
- }
4075
- const match = trimmed.match(
4076
- /^(?:\^|~|>=|<=|>|<|=)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
4077
- );
4078
- if (!match) return null;
4079
- return {
4080
- major: Number(match[1]),
4081
- minor: Number(match[2] ?? 0),
4082
- patch: Number(match[3] ?? 0)
4083
- };
4084
- }
4085
- function compareVersions(left, right) {
4086
- if (left.major !== right.major) return left.major - right.major;
4087
- if (left.minor !== right.minor) return left.minor - right.minor;
4088
- return left.patch - right.patch;
4089
- }
4090
4403
  function isHigherVersion(upstreamVersion, forkVersion) {
4091
4404
  if (upstreamVersion === forkVersion) return false;
4092
4405
  const upCoerced = parseComparableVersion(upstreamVersion);
@@ -4205,7 +4518,7 @@ function safeMergePnpm(forkPnpm, upstreamPnpm, basePnpm) {
4205
4518
  return { merged, changed };
4206
4519
  }
4207
4520
  function readPackageJson(filePath) {
4208
- if (!existsSync9(filePath)) return null;
4521
+ if (!existsSync11(filePath)) return null;
4209
4522
  try {
4210
4523
  return JSON.parse(readFileSync6(filePath, "utf-8"));
4211
4524
  } catch {
@@ -4243,13 +4556,13 @@ async function syncPackageJson(config, baseRef, usage, relativePath, keysToSync)
4243
4556
  const changes = [];
4244
4557
  const kept = [];
4245
4558
  const pkgRelPath = packageJsonPath(relativePath);
4246
- const pkgPath = join15(forkPath, pkgRelPath);
4559
+ const pkgPath = join18(forkPath, pkgRelPath);
4247
4560
  const forkPkg = readPackageJson(pkgPath);
4248
4561
  const upstream = await readPackageJsonAtRef(forkPath, upstreamRef, relativePath);
4249
4562
  const basePkg = baseRef ? (await readPackageJsonAtRef(forkPath, baseRef, relativePath))?.data : void 0;
4250
4563
  if (!upstream) return { updated: false, changes, kept };
4251
4564
  if (!forkPkg) {
4252
- const workspaceArrived = relativePath !== "" && existsSync9(join15(forkPath, relativePath));
4565
+ const workspaceArrived = relativePath !== "" && existsSync11(join18(forkPath, relativePath));
4253
4566
  if (basePkg || !workspaceArrived || isIgnored(pkgRelPath, config)) return { updated: false, changes, kept };
4254
4567
  writeFileSync4(pkgPath, upstream.raw.endsWith("\n") ? upstream.raw : `${upstream.raw}
4255
4568
  `, "utf-8");
@@ -4427,6 +4740,7 @@ async function runSync(config, options) {
4427
4740
  const stagedBranch = options?.stagedBranch && result.conflicts.length === 0 && hasStagedSyncChanges(result) ? options.stagedBranch : void 0;
4428
4741
  printSyncComplete(result, { stagedBranch });
4429
4742
  printFlagWarnings({ hard: config.hard, unpinned: config.unpinned });
4743
+ await printMigrationNotesLine(config, result);
4430
4744
  return result;
4431
4745
  }
4432
4746
  var SYNC_PR_TITLE = "chore: sync upstream cella";
@@ -4443,7 +4757,7 @@ function qualifyPrRefs(subject, repoSlug) {
4443
4757
  return subject.replace(/(^|[^\w/])#(\d+)\b/g, `$1${repoSlug}#$2`);
4444
4758
  }
4445
4759
  function buildSyncPrBody(input) {
4446
- const { repoSlug, version, fromSha, toSha, commits, totalCount } = input;
4760
+ const { repoSlug, version, fromSha, toSha, commits, totalCount, notes } = input;
4447
4761
  const githubUrl = repoSlug ? `https://github.com/${repoSlug}` : void 0;
4448
4762
  const short = (sha) => sha.slice(0, 7);
4449
4763
  const commitRef = (sha) => githubUrl ? `[\`${short(sha)}\`](${githubUrl}/commit/${sha})` : `\`${short(sha)}\``;
@@ -4458,6 +4772,19 @@ function buildSyncPrBody(input) {
4458
4772
  lines.push(`- ${commitRef(commit.hash)} ${qualifyPrRefs(commit.message, repoSlug)}`);
4459
4773
  }
4460
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
+ }
4461
4788
  return `${lines.join("\n")}
4462
4789
  `;
4463
4790
  }
@@ -4473,7 +4800,20 @@ async function buildSyncPrBodyForBranch(forkPath, base) {
4473
4800
  skip: totalCount > PR_BODY_COMMIT_MAX ? totalCount - PR_BODY_COMMIT_MAX : 0,
4474
4801
  limit: PR_BODY_COMMIT_MAX
4475
4802
  }) : [];
4476
- 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 };
4477
4817
  }
4478
4818
  function printPrCreateStep(branch, base, title = SYNC_PR_TITLE) {
4479
4819
  console.info(colors_default.dim(` gh pr create --base ${base} --head ${branch} --title "${title}" --fill`));
@@ -4507,22 +4847,12 @@ async function flattenSyncBranch(forkPath, branch, base) {
4507
4847
  function indentLines(text) {
4508
4848
  return text.split("\n").map((line) => ` ${line}`).join("\n");
4509
4849
  }
4510
- function enableAutoMerge(forkPath, branch) {
4511
- console.info(colors_default.dim("enabling auto-merge (squash) once checks pass..."));
4512
- const merge2 = mergePrSquash(forkPath, branch, { auto: true, deleteBranch: true });
4513
- if (merge2.ok) return true;
4514
- console.info(colors_default.yellow("could not enable auto-merge (is it enabled for the repo?). merge it manually:"));
4515
- if (merge2.output) console.info(colors_default.dim(indentLines(merge2.output)));
4516
- console.info(colors_default.dim(` gh pr merge ${branch} --squash --delete-branch`));
4517
- return false;
4518
- }
4519
4850
  async function shipSyncBranch(config, branch) {
4520
4851
  const { forkPath, settings } = config;
4521
4852
  const base = resolveReleaseBase(settings);
4522
4853
  const flattened = await flattenSyncBranch(forkPath, branch, base);
4523
4854
  let prUrl;
4524
4855
  let prOpened = false;
4525
- let autoMergeEnabled = false;
4526
4856
  const headSubject = (await getCommitInfo(forkPath, "HEAD").catch(() => null))?.message;
4527
4857
  const prTitle = headSubject?.startsWith(SYNC_PR_TITLE) ? headSubject : SYNC_PR_TITLE;
4528
4858
  console.info(colors_default.dim(`pushing '${branch}' to origin...`));
@@ -4546,9 +4876,7 @@ async function shipSyncBranch(config, branch) {
4546
4876
  prUrl = extractFirstUrl(`${pr.stdout ?? ""}
4547
4877
  ${pr.stderr ?? ""}`);
4548
4878
  prOpened = pr.status === 0;
4549
- if (prOpened) {
4550
- if (config.directMerge) autoMergeEnabled = enableAutoMerge(forkPath, branch);
4551
- } else {
4879
+ if (!prOpened) {
4552
4880
  console.info(colors_default.yellow("could not open the PR automatically (it may already exist). open it with:"));
4553
4881
  if (prUrl) console.info(colors_default.dim(` ${prUrl}`));
4554
4882
  printPrCreateStep(branch, base, prTitle);
@@ -4564,12 +4892,12 @@ ${pr.stderr ?? ""}`);
4564
4892
  console.info();
4565
4893
  if (prUrl) {
4566
4894
  console.info(`${colors_default.green("\u2713")} Sync pull request ${prOpened ? "opened" : "ready"}`);
4567
- const status = autoMergeEnabled ? `auto-merge on \u2014 squashes into '${base}' when checks pass` : `branch pushed, back on '${base}'`;
4568
- console.info(colors_default.dim(` ${prUrl} \xB7 ${status}`));
4895
+ console.info(colors_default.dim(` ${prUrl} \xB7 branch pushed, back on '${base}'`));
4569
4896
  } else {
4570
4897
  console.info(`${colors_default.green("\u2713")} Sync branch pushed`);
4571
4898
  console.info(colors_default.dim(` '${branch}' is on origin, back on '${base}'`));
4572
4899
  }
4900
+ await printMigrationNotesLine(config);
4573
4901
  }
4574
4902
  function finalizeWorkspace(forkPath) {
4575
4903
  for (const args of [["install"], ["check"]]) {
@@ -4618,6 +4946,7 @@ async function commitSyncMerge(config, branch) {
4618
4946
  console.info();
4619
4947
  console.info(colors_default.green(`committed the sync on '${branch}' as '${message}'.`));
4620
4948
  printTriageSteps(branch);
4949
+ await printMigrationNotesLine(config);
4621
4950
  }
4622
4951
  function printTriageSteps(branch) {
4623
4952
  console.info(colors_default.dim(`staying on '${branch}' without pushing.`));
@@ -4809,7 +5138,7 @@ async function runForks(config) {
4809
5138
  console.error(colors_default.red(`fork '${config.fork}' not found in config`));
4810
5139
  return;
4811
5140
  }
4812
- const resolvedPath = resolve5(await resolveForkBasePath(config.forkPath), match.localPath);
5141
+ const resolvedPath = resolve6(await resolveForkBasePath(config.forkPath), match.localPath);
4813
5142
  await syncFork(config, match, resolvedPath);
4814
5143
  return;
4815
5144
  }
@@ -4825,7 +5154,7 @@ async function runForks(config) {
4825
5154
  if (selectedPath === "_exit") {
4826
5155
  process8.exit(0);
4827
5156
  }
4828
- const resolvedForkPath = resolve5(forkBasePath, selectedPath);
5157
+ const resolvedForkPath = resolve6(forkBasePath, selectedPath);
4829
5158
  const selectedFork = forks.find((f) => f.localPath === selectedPath);
4830
5159
  if (!selectedFork) {
4831
5160
  console.error(colors_default.red(`fork '${selectedPath}' not found in config`));
@@ -4837,8 +5166,8 @@ async function runForks(config) {
4837
5166
  }
4838
5167
 
4839
5168
  // src/services/stats.ts
4840
- import { readFile as readFile2 } from "fs/promises";
4841
- import { join as join17 } from "path";
5169
+ import { readFile as readFile3 } from "fs/promises";
5170
+ import { join as join20 } from "path";
4842
5171
 
4843
5172
  // src/utils/coverage-utils.ts
4844
5173
  import { spawnSync as spawnSync4 } from "child_process";
@@ -4932,7 +5261,7 @@ function printCoverageSummary(forkPath, options = {}) {
4932
5261
  // src/services/stats.ts
4933
5262
  var sourceExtensions = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs", "json", "jsonc", "css", "html"]);
4934
5263
  async function getWorkspacePackages(forkPath) {
4935
- const content = await readFile2(join17(forkPath, "pnpm-workspace.yaml"), "utf-8");
5264
+ const content = await readFile3(join20(forkPath, "pnpm-workspace.yaml"), "utf-8");
4936
5265
  return parseYamlBlockList(content, "packages").map((pattern) => {
4937
5266
  if (pattern.endsWith("/*")) {
4938
5267
  const base = pattern.slice(0, -2);
@@ -5095,20 +5424,20 @@ async function runStats(forkPath, options = {}) {
5095
5424
  function getForkPath() {
5096
5425
  const envPath = getEnv("CELLA_FORK_PATH");
5097
5426
  if (envPath) {
5098
- const resolved = resolve6(envPath);
5099
- if (!existsSync11(resolved) || !statSync3(resolved).isDirectory()) {
5427
+ const resolved = resolve7(envPath);
5428
+ if (!existsSync13(resolved) || !statSync3(resolved).isDirectory()) {
5100
5429
  throw new Error(`CELLA_FORK_PATH is not a valid directory: ${resolved}`);
5101
5430
  }
5102
5431
  return resolved;
5103
5432
  }
5104
5433
  let cwd = process9.cwd();
5105
5434
  if (cwd.endsWith("/cli/cella") || cwd.endsWith("\\cli\\cella")) {
5106
- cwd = resolve6(cwd, "../..");
5435
+ cwd = resolve7(cwd, "../..");
5107
5436
  }
5108
5437
  return cwd;
5109
5438
  }
5110
5439
  async function preflight(forkPath) {
5111
- if (!existsSync11(join18(forkPath, ".git"))) {
5440
+ if (!existsSync13(join21(forkPath, ".git"))) {
5112
5441
  throw new Error(`not a git repository: ${forkPath}`);
5113
5442
  }
5114
5443
  }
@@ -5137,6 +5466,9 @@ async function main() {
5137
5466
  await runSyncCommand(config);
5138
5467
  break;
5139
5468
  }
5469
+ case "migrate":
5470
+ await runMigrate(config);
5471
+ break;
5140
5472
  case "audit":
5141
5473
  await runAudit(config, { force: config.force, checkOverrides: config.checkOverrides });
5142
5474
  break;