@cellajs/cli 0.2.3 → 0.3.1

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 resolve8 } 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.1",
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
- typescript: "^6.0.3",
291
- vitest: "^4.1.11"
290
+ typescript: "6.0.3",
291
+ vitest: "^5.0.3"
292
292
  },
293
293
  publishConfig: {
294
294
  access: "public"
@@ -800,415 +800,480 @@ 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}/`);
814
+ import { mkdir as mkdir2, readdir, rmdir, unlink } from "fs/promises";
815
+ import { dirname as dirname2, join as join5, resolve as resolve3 } from "path";
816
+ import { promisify } from "util";
817
+
818
+ // src/utils/manifest.ts
819
+ import { mkdir, readFile, writeFile } from "fs/promises";
820
+ import { dirname, join as join4 } from "path";
821
+ var MANIFEST_FILE = "cella/cella.manifest.json";
822
+ async function readSyncManifest(cwd) {
823
+ try {
824
+ const parsed = JSON.parse(await readFile(join4(cwd, MANIFEST_FILE), "utf8"));
825
+ const commit = parsed?.upstream?.commit;
826
+ if (typeof commit !== "string" || !/^[0-9a-f]{40}$/i.test(commit)) return null;
827
+ return parsed;
828
+ } catch {
829
+ return null;
830
+ }
831
+ }
832
+ async function readManifestBase(cwd) {
833
+ const manifest = await readSyncManifest(cwd);
834
+ return manifest ? manifest.upstream.commit.toLowerCase() : null;
835
+ }
836
+ async function writeSyncManifest(cwd, manifest) {
837
+ const manifestPath = join4(cwd, MANIFEST_FILE);
838
+ await mkdir(dirname(manifestPath), { recursive: true });
839
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}
840
+ `, "utf8");
841
+ }
842
+
843
+ // src/utils/git.ts
844
+ var execFileAsync = promisify(execFile);
845
+ function sanitizeCredentials(text) {
846
+ return text.replace(/https?:\/\/[^@\s]+@/g, (match) => {
847
+ const protocol = match.startsWith("https") ? "https" : "http";
848
+ return `${protocol}://***@`;
809
849
  });
810
850
  }
811
- function isIgnored(filePath, config) {
812
- return isUnderAnyFolder(filePath, config.overrides?.ignored || []);
851
+ async function git(args, cwd, options = {}) {
852
+ const env = {
853
+ ...getEnvSnapshot(),
854
+ ...options.skipEditor ? { GIT_EDITOR: "true" } : {},
855
+ ...options.env
856
+ };
857
+ const maxBuffer = options.maxBuffer ?? 50 * 1024 * 1024;
858
+ try {
859
+ const { stdout } = await execFileAsync("git", args, { cwd, env, maxBuffer });
860
+ return stdout.trim();
861
+ } catch (error) {
862
+ if (options.ignoreErrors) return "";
863
+ if (error instanceof Error) {
864
+ error.message = sanitizeCredentials(error.message);
865
+ }
866
+ throw error;
867
+ }
813
868
  }
814
- function isPinned(filePath, config) {
815
- if (isManagedFile(filePath)) return true;
816
- return isUnderAnyFolder(filePath, config.overrides?.pinned || []);
869
+ async function getCurrentBranch(cwd) {
870
+ return git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
817
871
  }
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);
872
+ async function switchBranch(cwd, branch) {
873
+ await git(["switch", branch], cwd);
874
+ }
875
+ async function switchDetached(cwd, ref) {
876
+ await git(["switch", "--detach", ref], cwd);
877
+ }
878
+ async function createBranchFrom(cwd, branch, startPoint) {
879
+ await git(["switch", "-c", branch, "--no-track", startPoint], cwd);
880
+ }
881
+ async function branchExists(cwd, branch) {
882
+ return await git(["rev-parse", "-q", "--verify", `refs/heads/${branch}`], cwd, { ignoreErrors: true }) !== "";
883
+ }
884
+ async function getBranchWorktree(cwd, branch) {
885
+ const output = await git(["worktree", "list", "--porcelain"], cwd, { ignoreErrors: true });
886
+ let worktree = null;
887
+ for (const line of output.split("\n")) {
888
+ if (line.startsWith("worktree ")) worktree = line.slice("worktree ".length);
889
+ else if (line === `branch refs/heads/${branch}`) return worktree;
831
890
  }
832
- for (const group of groups.values()) group.paths.sort();
833
- return entries.flatMap((entry) => groups.get(entry) ?? []);
891
+ return null;
834
892
  }
835
- function isPinnedForSync(filePath, config, unpinned) {
836
- if (isManagedFile(filePath)) return true;
837
- if (unpinned) return false;
838
- return isPinned(filePath, config);
893
+ async function fastForwardBranch(cwd, branch, ref) {
894
+ await git(["fetch", "--quiet", ".", `${ref}:refs/heads/${branch}`], cwd);
839
895
  }
840
- function hasGlobChars(entry) {
841
- return entry.includes("*") || entry.includes("?");
896
+ async function deleteBranch(cwd, branch) {
897
+ await git(["branch", "-D", branch], cwd, { ignoreErrors: true });
842
898
  }
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
- }
899
+ async function pullFastForward(cwd) {
900
+ await git(["pull", "--ff-only"], cwd, { ignoreErrors: true, skipEditor: true });
901
+ }
902
+ async function getBranchUpstream(cwd, branch = "") {
903
+ const upstream = await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", `${branch}@{upstream}`], cwd, {
904
+ ignoreErrors: true
905
+ });
906
+ return upstream || null;
907
+ }
908
+ async function getUpstreamStatus(cwd, branch) {
909
+ const local = branch ? `refs/heads/${branch}` : "HEAD";
910
+ const upstream = await getBranchUpstream(cwd, branch);
911
+ if (!upstream) return { upstream: null, ahead: 0, behind: 0 };
912
+ const remote = upstream.split("/")[0];
913
+ await git(["fetch", remote], cwd, { ignoreErrors: true, skipEditor: true });
914
+ const counts = await git(["rev-list", "--left-right", "--count", `${local}...${upstream}`], cwd, {
915
+ ignoreErrors: true
916
+ });
917
+ const [ahead, behind] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0);
918
+ return { upstream, ahead: ahead ?? 0, behind: behind ?? 0 };
919
+ }
920
+ async function pushBranch(cwd, remote, branch, options = {}) {
921
+ await git(["push", "-u", ...options.forceWithLease ? ["--force-with-lease"] : [], remote, branch], cwd);
922
+ }
923
+ async function listBranchMergeCommits(cwd, baseRef) {
924
+ const out = await git(["rev-list", "--merges", "--first-parent", `${baseRef}..HEAD`], cwd, { ignoreErrors: true });
925
+ return out ? out.split("\n").filter(Boolean) : [];
926
+ }
927
+ async function flattenBranch(cwd, baseRef, message) {
928
+ const parent = await getMergeBase(cwd, baseRef, "HEAD");
929
+ const tree = await git(["rev-parse", "HEAD^{tree}"], cwd);
930
+ const commit = await git(["commit-tree", tree, "-p", parent, "-m", message], cwd);
931
+ await git(["reset", "--hard", commit], cwd);
932
+ }
933
+ async function commitSquash(cwd, message) {
934
+ for (const file of ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE"]) {
935
+ const path4 = await gitPath(cwd, file);
936
+ if (existsSync3(path4)) await unlink(path4);
860
937
  }
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
- }
938
+ await git(["commit", "-m", message], cwd, { skipEditor: true });
939
+ }
940
+ async function gitPath(cwd, file) {
941
+ return resolve3(cwd, await git(["rev-parse", "--git-path", file], cwd));
942
+ }
943
+ async function stageAll(cwd) {
944
+ await git(["add", "-A"], cwd);
945
+ }
946
+ async function mergeInProgress(cwd) {
947
+ return await git(["rev-parse", "-q", "--verify", "MERGE_HEAD"], cwd, { ignoreErrors: true }) !== "";
948
+ }
949
+ async function getShortSha(cwd, ref) {
950
+ return git(["rev-parse", "--short", ref], cwd);
951
+ }
952
+ async function getWorkingTreeChangeCount(cwd) {
953
+ const status = await git(["status", "--porcelain"], cwd);
954
+ if (!status) return 0;
955
+ return status.split("\n").filter(Boolean).length;
956
+ }
957
+ async function remoteExists(cwd, remoteName) {
958
+ const remotes = await git(["remote"], cwd);
959
+ return remotes.split("\n").includes(remoteName);
960
+ }
961
+ async function ensureRemote(cwd, remoteName, url) {
962
+ if (await remoteExists(cwd, remoteName)) {
963
+ await git(["remote", "set-url", remoteName, url], cwd);
964
+ } else {
965
+ await git(["remote", "add", remoteName, url], cwd);
876
966
  }
877
- return warnings;
878
967
  }
879
- function printWarnings(warnings) {
880
- for (const warning of warnings) {
881
- console.info(`${warningMark} ${warning.message}`);
968
+ async function fetch2(cwd, remoteName) {
969
+ await git(["fetch", remoteName], cwd);
970
+ }
971
+ async function fetchUpstreamTags(cwd, remoteName) {
972
+ await git(["fetch", remoteName, "--no-tags", `+refs/tags/*:refs/${remoteName}/tags/*`], cwd);
973
+ }
974
+ async function resolveLatestReleaseTag(cwd, remoteName) {
975
+ const prefix = `refs/${remoteName}/tags/`;
976
+ const output = await git(["for-each-ref", "--sort=-version:refname", "--format=%(refname)", prefix], cwd, {
977
+ ignoreErrors: true
978
+ });
979
+ if (!output) return null;
980
+ for (const line of output.split("\n")) {
981
+ const ref = line.trim();
982
+ if (!ref) continue;
983
+ const tag = ref.slice(prefix.length);
984
+ if (/^v\d/.test(tag)) return { tag, ref };
882
985
  }
986
+ return null;
883
987
  }
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`);
988
+ async function resolveUpstreamCommit(cwd, remoteName, ref) {
989
+ if (ref.startsWith("-")) return null;
990
+ const tagRef = `refs/${remoteName}/tags/${ref}`;
991
+ for (const candidate of [`refs/remotes/${remoteName}/${ref}`, tagRef, ref]) {
992
+ const sha = await git(["rev-parse", "-q", "--verify", `${candidate}^{commit}`], cwd, { ignoreErrors: true });
993
+ if (!sha) continue;
994
+ const release = candidate === tagRef && /^v\d[\w.+-]*$/.test(ref) ? { tag: ref, ref: tagRef } : void 0;
995
+ return { sha, release };
890
996
  }
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
- };
997
+ return null;
909
998
  }
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"
936
- }
999
+ async function isPublishedUpstream(cwd, remoteName, branchRef, sha) {
1000
+ const containing = await git(
1001
+ [
1002
+ "for-each-ref",
1003
+ "--count=1",
1004
+ "--format=%(refname)",
1005
+ "--contains",
1006
+ sha,
1007
+ `refs/remotes/${branchRef}`,
1008
+ `refs/${remoteName}/tags/v*`
937
1009
  ],
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
- };
1010
+ cwd,
1011
+ { ignoreErrors: true }
1012
+ );
1013
+ return containing !== "";
986
1014
  }
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
- ];
1015
+ async function getCommitInfo(cwd, ref) {
1016
+ const format = "%H%n%s%n%ar";
1017
+ const output = await git(["log", "-1", `--format=${format}`, ref], cwd);
1018
+ const [hash, message, date] = output.split("\n");
1019
+ return { hash, message, date };
998
1020
  }
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);
1003
- }
1004
- command.action((opts) => {
1005
- setSelection({
1006
- service: definition.name,
1007
- options: readOptions(opts)
1008
- });
1009
- });
1021
+ async function countCommitsBetween(cwd, fromRef, toRef) {
1022
+ const output = await git(["rev-list", "--count", `${fromRef}..${toRef}`], cwd);
1023
+ return Number.parseInt(output.trim(), 10) || 0;
1010
1024
  }
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);
1025
+ async function listCommitsBetween(cwd, fromRef, toRef, options = {}) {
1026
+ const { oldestFirst = true, skip = 0, limit } = options;
1027
+ const args = ["log", "--format=%H%x1f%s%x1f%ar"];
1028
+ if (oldestFirst) args.push("--reverse");
1029
+ args.push(`${fromRef}..${toRef}`);
1030
+ const output = await git(args, cwd, { ignoreErrors: true });
1031
+ if (!output) return [];
1032
+ const commits = [];
1033
+ for (const line of output.split("\n")) {
1034
+ if (!line) continue;
1035
+ const [hash, message, date] = line.split("");
1036
+ if (!hash || !message || !date) continue;
1037
+ commits.push({ hash, message, date });
1030
1038
  }
1031
- return program;
1032
- }
1033
- function parseCommandLine(argv) {
1034
- if (argv.length <= 2) {
1035
- return { options: readOptions({}) };
1039
+ const normalizedSkip = Math.max(0, skip);
1040
+ const normalizedLimit = typeof limit === "number" && limit > 0 ? limit : void 0;
1041
+ if (normalizedSkip === 0 && normalizedLimit === void 0) {
1042
+ return commits;
1036
1043
  }
1037
- let selection = { options: readOptions({}) };
1038
- const program = buildProgram((nextSelection) => {
1039
- selection = nextSelection;
1040
- });
1041
- program.parse(argv);
1042
- return selection;
1044
+ const start = Math.min(normalizedSkip, commits.length);
1045
+ const end = normalizedLimit === void 0 ? commits.length : Math.min(start + normalizedLimit, commits.length);
1046
+ return commits.slice(start, end);
1043
1047
  }
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);
1048
+ async function getFileChangeInfo(cwd, fromRef, toRef) {
1049
+ const info = /* @__PURE__ */ new Map();
1050
+ try {
1051
+ const output = await git(["log", "--name-only", "--format=%h %ct %ar", `${fromRef}..${toRef}`], cwd, {
1052
+ ignoreErrors: true
1053
+ });
1054
+ if (!output.trim()) return info;
1055
+ const lines = output.split("\n");
1056
+ let currentHash = "";
1057
+ let currentDate = "";
1058
+ let currentTimestamp = 0;
1059
+ for (const line of lines) {
1060
+ if (!line) continue;
1061
+ const match = line.match(/^([a-f0-9]{7,})\s+(\d+)\s+(.+)$/);
1062
+ if (match) {
1063
+ currentHash = match[1];
1064
+ currentTimestamp = Number(match[2]);
1065
+ currentDate = match[3];
1066
+ } else if (currentHash && currentDate && !info.has(line)) {
1067
+ info.set(line, { date: currentDate, hash: currentHash, timestamp: currentTimestamp });
1068
+ }
1069
+ }
1070
+ } catch {
1054
1071
  }
1055
- console.info();
1056
- return selected;
1072
+ return info;
1057
1073
  }
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
- };
1074
+ async function isClean(cwd) {
1075
+ const status = await git(["status", "--porcelain"], cwd);
1076
+ return !status;
1067
1077
  }
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();
1078
+ async function assertClean(cwd, subject = "working directory") {
1079
+ if (!await isClean(cwd)) {
1080
+ throw new Error(`${subject} has uncommitted changes. please commit or stash before syncing.`);
1076
1081
  }
1077
- if (!selection.service) {
1078
- selection.service = await promptForService(userConfig, forkPath);
1082
+ }
1083
+ async function createWorktree(cwd, worktreePath, commitRef) {
1084
+ await git(["worktree", "add", "--detach", worktreePath, commitRef], cwd);
1085
+ }
1086
+ async function removeWorktree(cwd, worktreePath) {
1087
+ await git(["worktree", "remove", worktreePath, "--force"], cwd, { ignoreErrors: true });
1088
+ await git(["worktree", "prune"], cwd, { ignoreErrors: true });
1089
+ }
1090
+ async function listWorktrees(cwd) {
1091
+ const output = await git(["worktree", "list", "--porcelain"], cwd);
1092
+ const lines = output.split("\n");
1093
+ const paths = [];
1094
+ for (const line of lines) {
1095
+ if (line.startsWith("worktree ")) {
1096
+ paths.push(line.slice(9));
1097
+ }
1079
1098
  }
1080
- return buildRuntimeConfig(userConfig, forkPath, selection);
1099
+ return paths;
1081
1100
  }
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);
1101
+ async function merge(cwd, ref, options = {}) {
1102
+ const args = ["merge", "--no-ff", ref];
1103
+ if (options.noCommit) args.push("--no-commit");
1104
+ if (options.noEdit) args.push("--no-edit");
1096
1105
  try {
1097
- return execFileSync("git", args, { cwd, maxBuffer: 50 * 1024 * 1024 });
1106
+ await git(args, cwd, { skipEditor: true });
1107
+ return { success: true, conflicts: [] };
1098
1108
  } catch (error) {
1099
- const stderr = error.stderr?.toString().trim();
1100
- throw new Error(stderr || `failed to diff ${filePath}`);
1109
+ const conflicts = await getConflictedFiles(cwd);
1110
+ if (conflicts.length > 0) {
1111
+ return { success: false, conflicts };
1112
+ }
1113
+ throw error;
1101
1114
  }
1102
1115
  }
1103
- function escapeHtml(text) {
1104
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1116
+ async function getConflictedFiles(cwd) {
1117
+ const output = await git(["diff", "--name-only", "--diff-filter=U"], cwd, { ignoreErrors: true });
1118
+ if (!output) return [];
1119
+ return output.split("\n").filter(Boolean);
1105
1120
  }
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);
1121
+ async function mergeAbort(cwd) {
1122
+ await git(["merge", "--abort"], cwd, { ignoreErrors: true });
1123
+ }
1124
+ async function fileExistsAtRef(cwd, ref, filePath) {
1125
+ const result = await git(["ls-tree", "--name-only", ref, "--", filePath], cwd, { ignoreErrors: true });
1126
+ return result !== "";
1127
+ }
1128
+ async function checkoutFromRef(cwd, ref, filePath) {
1129
+ await git(["checkout", ref, "--", filePath], cwd);
1130
+ }
1131
+ async function restoreWorktreeFromRef(cwd, ref, filePath) {
1132
+ await git(["restore", `--source=${ref}`, "--worktree", "--", filePath], cwd);
1133
+ }
1134
+ async function getMergeBase(cwd, ref1, ref2) {
1135
+ return git(["merge-base", ref1, ref2], cwd);
1136
+ }
1137
+ async function getFileChanges(cwd, baseRef, targetRef) {
1138
+ const output = await git(["diff-tree", "-r", "-M90%", "--no-commit-id", baseRef, targetRef], cwd, {
1139
+ ignoreErrors: true
1140
+ });
1141
+ const changes = /* @__PURE__ */ new Map();
1142
+ if (!output) return changes;
1143
+ for (const line of output.split("\n")) {
1144
+ if (!line) continue;
1145
+ const renameMatch = line.match(/^:\d+ \d+ ([a-f0-9]+) ([a-f0-9]+) R\d*\s+(.+)\t(.+)$/);
1146
+ if (renameMatch) {
1147
+ const [, baseHash, targetHash, oldPath, newPath] = renameMatch;
1148
+ changes.set(newPath, {
1149
+ status: "R",
1150
+ baseHash: baseHash === "0".repeat(40) ? null : baseHash,
1151
+ targetHash: targetHash === "0".repeat(40) ? null : targetHash,
1152
+ oldPath,
1153
+ newPath
1154
+ });
1155
+ continue;
1156
+ }
1157
+ const match = line.match(/^:\d+ \d+ ([a-f0-9]+) ([a-f0-9]+) ([ADMT])\t(.+)$/);
1158
+ if (match) {
1159
+ const [, baseHash, targetHash, status, filePath] = match;
1160
+ changes.set(filePath, {
1161
+ status,
1162
+ baseHash: baseHash === "0".repeat(40) ? null : baseHash,
1163
+ targetHash: targetHash === "0".repeat(40) ? null : targetHash
1164
+ });
1165
+ }
1135
1166
  }
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;
1167
+ return changes;
1168
+ }
1169
+ async function getDiffStat(cwd, fromRef, toRef, paths = []) {
1170
+ const args = ["diff", "--numstat", "--no-renames", fromRef, toRef];
1171
+ if (paths.length > 0) args.push("--", ...paths);
1172
+ const out = await git(args, cwd, { ignoreErrors: true });
1173
+ const stat = /* @__PURE__ */ new Map();
1174
+ for (const line of out.split("\n")) {
1175
+ if (!line.trim()) continue;
1176
+ const [a, d, ...rest] = line.split(" ");
1177
+ const path4 = rest.join(" ");
1178
+ if (!path4) continue;
1179
+ stat.set(path4, {
1180
+ additions: a === "-" ? null : Number(a),
1181
+ deletions: d === "-" ? null : Number(d)
1182
+ });
1142
1183
  }
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);
1184
+ return stat;
1185
+ }
1186
+ async function getFileHashesAtRef(cwd, ref) {
1187
+ const output = await git(["ls-tree", "-r", ref], cwd);
1188
+ const hashes = /* @__PURE__ */ new Map();
1189
+ if (!output) return hashes;
1190
+ for (const line of output.split("\n")) {
1191
+ if (!line) continue;
1192
+ const match = line.match(/^\d+ \w+ ([a-f0-9]+)\t(.+)$/);
1193
+ if (match) {
1194
+ const [, hash, filePath] = match;
1195
+ hashes.set(filePath, hash);
1196
+ }
1151
1197
  }
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>`;
1198
+ return hashes;
1171
1199
  }
1172
- function openWithDefaultApp(target) {
1200
+ async function restoreToHead(cwd, filePath) {
1201
+ await git(["checkout", "HEAD", "--", filePath], cwd);
1202
+ }
1203
+ async function batchRestoreToHead(cwd, filePaths) {
1204
+ if (filePaths.length === 0) return;
1205
+ await git(["checkout", "HEAD", "--", ...filePaths], cwd);
1206
+ }
1207
+ async function gitRm(cwd, filePath) {
1208
+ await git(["rm", "-f", "--", filePath], cwd, { ignoreErrors: true });
1209
+ }
1210
+ async function batchGitRm(cwd, filePaths) {
1211
+ if (filePaths.length === 0) return;
1212
+ await git(["rm", "-f", "--", ...filePaths], cwd, { ignoreErrors: true });
1213
+ }
1214
+ async function getStagedNewFiles(cwd) {
1215
+ const output = await git(["diff", "--cached", "--name-only", "--diff-filter=A"], cwd, { ignoreErrors: true });
1216
+ return output ? output.split("\n").filter(Boolean) : [];
1217
+ }
1218
+ async function batchUnstageFiles(cwd, filePaths) {
1219
+ if (filePaths.length === 0) return;
1220
+ await git(["rm", "--cached", "-f", "--", ...filePaths], cwd, { ignoreErrors: true });
1221
+ }
1222
+ async function gitMv(cwd, oldPath, newPath) {
1223
+ const newDir = dirname2(join5(cwd, newPath));
1224
+ await mkdir2(newDir, { recursive: true });
1225
+ await git(["mv", "-f", oldPath, newPath], cwd);
1226
+ }
1227
+ async function fileExistsInWorktree(cwd, filePath) {
1228
+ return existsSync3(join5(cwd, filePath));
1229
+ }
1230
+ async function removeFileFromWorktree(cwd, filePath) {
1231
+ const fullPath = join5(cwd, filePath);
1173
1232
  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" });
1233
+ await unlink(fullPath);
1234
+ await cleanupEmptyParentDirs(cwd, dirname2(filePath));
1235
+ } catch {
1236
+ }
1237
+ }
1238
+ async function removeFileFully(cwd, filePath) {
1239
+ await gitRm(cwd, filePath);
1240
+ await removeFileFromWorktree(cwd, filePath);
1241
+ }
1242
+ async function cleanupEmptyParentDirs(cwd, relativePath) {
1243
+ if (!relativePath || relativePath === ".") return;
1244
+ const fullPath = join5(cwd, relativePath);
1245
+ try {
1246
+ const entries = await readdir(fullPath);
1247
+ if (entries.length === 0) {
1248
+ await rmdir(fullPath);
1249
+ await cleanupEmptyParentDirs(cwd, dirname2(relativePath));
1178
1250
  }
1179
1251
  } catch {
1180
1252
  }
1181
1253
  }
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;
1254
+ async function storeLastSyncRef(cwd, upstreamHash) {
1255
+ await git(["update-ref", "refs/cella/last-sync", upstreamHash], cwd);
1256
+ const head = await git(["rev-parse", "HEAD"], cwd, { ignoreErrors: true });
1257
+ if (head) {
1258
+ await git(["update-ref", "refs/cella/last-sync-head", head], cwd);
1259
+ }
1190
1260
  }
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) {
1261
+ async function stagePath(cwd, path4) {
1262
+ await git(["add", "--", path4], cwd, { ignoreErrors: true });
1263
+ }
1264
+ async function getStoredSyncRef(cwd) {
1265
+ const ref = await git(["rev-parse", "refs/cella/last-sync"], cwd, { ignoreErrors: true });
1266
+ return ref || null;
1267
+ }
1268
+ async function getStoredSyncHead(cwd) {
1269
+ const ref = await git(["rev-parse", "refs/cella/last-sync-head"], cwd, { ignoreErrors: true });
1270
+ return ref || null;
1271
+ }
1272
+ async function readManifestAtRef(cwd, ref) {
1273
+ const raw = await git(["show", `${ref}:${MANIFEST_FILE}`], cwd, { ignoreErrors: true });
1274
+ if (!raw) return null;
1210
1275
  try {
1211
- const parsed = JSON.parse(await readFile(join7(cwd, MANIFEST_FILE), "utf8"));
1276
+ const parsed = JSON.parse(raw);
1212
1277
  const commit = parsed?.upstream?.commit;
1213
1278
  if (typeof commit !== "string" || !/^[0-9a-f]{40}$/i.test(commit)) return null;
1214
1279
  return parsed;
@@ -1216,588 +1281,690 @@ async function readSyncManifest(cwd) {
1216
1281
  return null;
1217
1282
  }
1218
1283
  }
1219
- async function readManifestBase(cwd) {
1220
- const manifest = await readSyncManifest(cwd);
1284
+ async function readManifestBaseAtRef(cwd, ref) {
1285
+ const manifest = await readManifestAtRef(cwd, ref);
1221
1286
  return manifest ? manifest.upstream.commit.toLowerCase() : null;
1222
1287
  }
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
- });
1288
+ async function readPackageVersionAtRef(cwd, ref) {
1289
+ const raw = await git(["show", `${ref}:package.json`], cwd, { ignoreErrors: true });
1290
+ if (!raw) return null;
1291
+ try {
1292
+ const version = JSON.parse(raw).version;
1293
+ return typeof version === "string" && version ? version : null;
1294
+ } catch {
1295
+ return null;
1296
+ }
1237
1297
  }
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;
1298
+ async function commitObjectExists(cwd, sha) {
1245
1299
  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;
1300
+ await git(["cat-file", "-e", `${sha}^{commit}`], cwd);
1301
+ return true;
1302
+ } catch {
1303
+ return false;
1254
1304
  }
1255
1305
  }
1256
- async function getCurrentBranch(cwd) {
1257
- return git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
1306
+ async function getRootCommit(cwd, ref) {
1307
+ const out = await git(["rev-list", "--max-parents=0", ref], cwd, { ignoreErrors: true });
1308
+ const roots = out.split("\n").filter(Boolean);
1309
+ return roots.length > 0 ? roots[roots.length - 1] : null;
1258
1310
  }
1259
- async function switchBranch(cwd, branch) {
1260
- await git(["switch", branch], cwd);
1311
+ var SCAFFOLD_CANDIDATE_LIMIT = 400;
1312
+ var SCAFFOLD_SCORE_CONCURRENCY = 10;
1313
+ var SCAFFOLD_MATCH_MAX_DIFF_RATIO = 0.3;
1314
+ async function inferScaffoldBase(cwd, headRef, upstreamRef) {
1315
+ const root = await getRootCommit(cwd, headRef);
1316
+ if (!root) return null;
1317
+ const rootDate = await git(["show", "-s", "--format=%cI", root], cwd, { ignoreErrors: true });
1318
+ if (!rootDate) return null;
1319
+ const before = new Date(new Date(rootDate).getTime() + 24 * 60 * 60 * 1e3).toISOString();
1320
+ const revList = await git(
1321
+ ["rev-list", `--max-count=${SCAFFOLD_CANDIDATE_LIMIT}`, `--before=${before}`, upstreamRef],
1322
+ cwd,
1323
+ { ignoreErrors: true }
1324
+ );
1325
+ const candidates = revList.split("\n").filter(Boolean);
1326
+ if (candidates.length === 0) return null;
1327
+ const totalPaths = (await git(["ls-tree", "-r", "--name-only", root], cwd)).split("\n").filter(Boolean).length;
1328
+ if (totalPaths === 0) return null;
1329
+ let best = null;
1330
+ outer: for (let i = 0; i < candidates.length; i += SCAFFOLD_SCORE_CONCURRENCY) {
1331
+ const chunk = candidates.slice(i, i + SCAFFOLD_SCORE_CONCURRENCY);
1332
+ const scores = await Promise.all(
1333
+ chunk.map(async (sha) => {
1334
+ try {
1335
+ const out = await git(["diff-tree", "-r", "--name-only", sha, root], cwd);
1336
+ return { sha, diff: out ? out.split("\n").length : 0 };
1337
+ } catch {
1338
+ return { sha, diff: Number.POSITIVE_INFINITY };
1339
+ }
1340
+ })
1341
+ );
1342
+ for (const score of scores) {
1343
+ if (!best || score.diff < best.diff) best = score;
1344
+ if (best.diff === 0) break outer;
1345
+ }
1346
+ }
1347
+ if (!best || best.diff / totalPaths > SCAFFOLD_MATCH_MAX_DIFF_RATIO) return null;
1348
+ return best.sha;
1261
1349
  }
1262
- async function createBranchFrom(cwd, branch, startPoint) {
1263
- await git(["switch", "-c", branch, startPoint], cwd);
1350
+ async function readRootTrailerBase(cwd, headRef) {
1351
+ const root = await getRootCommit(cwd, headRef);
1352
+ if (!root) return null;
1353
+ const message = await git(["show", "-s", "--format=%B", root], cwd, { ignoreErrors: true });
1354
+ const match = message.match(/^cella-base:\s*([0-9a-f]{40})$/im);
1355
+ return match ? match[1].toLowerCase() : null;
1264
1356
  }
1265
- async function deleteBranch(cwd, branch) {
1266
- await git(["branch", "-D", branch], cwd, { ignoreErrors: true });
1357
+ async function isShallowRepository(cwd) {
1358
+ const out = await git(["rev-parse", "--is-shallow-repository"], cwd, { ignoreErrors: true });
1359
+ return out === "true";
1267
1360
  }
1268
- async function pullFastForward(cwd) {
1269
- await git(["pull", "--ff-only"], cwd, { ignoreErrors: true, skipEditor: true });
1361
+ async function unshallowRepository(cwd) {
1362
+ try {
1363
+ await git(["fetch", "--unshallow"], cwd, { skipEditor: true });
1364
+ } catch (error) {
1365
+ const detail = error instanceof Error ? `
1366
+
1367
+ ${error.message}` : "";
1368
+ throw new Error(
1369
+ `this is a shallow clone and its full history could not be restored automatically.
1370
+ Sync needs the complete history to find the common ancestor with upstream. Restore it with:
1371
+
1372
+ git fetch --unshallow
1373
+
1374
+ then re-run sync.${detail}`
1375
+ );
1376
+ }
1270
1377
  }
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 };
1378
+ async function ensureSyncBase(cwd, headRef, upstreamRef) {
1379
+ if (await isShallowRepository(cwd)) {
1380
+ await unshallowRepository(cwd);
1381
+ }
1382
+ const nativeBase = await git(["merge-base", headRef, upstreamRef], cwd, { ignoreErrors: true });
1383
+ if (nativeBase) return;
1384
+ const sources = [
1385
+ () => getStoredSyncRef(cwd),
1386
+ () => readManifestBaseAtRef(cwd, headRef),
1387
+ () => readManifestBase(cwd),
1388
+ () => readRootTrailerBase(cwd, headRef),
1389
+ () => inferScaffoldBase(cwd, headRef, upstreamRef)
1390
+ ];
1391
+ let baseSha = null;
1392
+ for (const source of sources) {
1393
+ const sha = await source();
1394
+ if (sha && await commitObjectExists(cwd, sha)) {
1395
+ baseSha = sha;
1396
+ break;
1397
+ }
1398
+ }
1399
+ if (!baseSha) {
1400
+ throw new Error(
1401
+ `no common ancestor between the fork and '${upstreamRef}', and no sync base could be determined.
1402
+
1403
+ This fork has unrelated history with upstream \u2014 typical for a create-cella scaffold, or after
1404
+ upstream squashed its history \u2014 and no recorded or inferable base commit exists after fetching
1405
+ upstream. Seed it manually with the upstream commit the fork was created from, then re-run sync:
1406
+
1407
+ git update-ref refs/cella/last-sync <upstream-sha>
1408
+ `
1409
+ );
1410
+ }
1411
+ const root = await getRootCommit(cwd, headRef);
1412
+ if (!root) throw new Error("could not determine the fork's root commit to bootstrap sync ancestry");
1413
+ await git(["replace", "-f", "--graft", root, baseSha], cwd);
1414
+ await git(["update-ref", "refs/cella/last-sync", baseSha], cwd);
1281
1415
  }
1282
- async function pushBranch(cwd, remote, branch, options = {}) {
1283
- await git(["push", "-u", ...options.forceWithLease ? ["--force-with-lease"] : [], remote, branch], cwd);
1416
+ async function isAncestor(cwd, ancestor, descendant) {
1417
+ try {
1418
+ await git(["merge-base", "--is-ancestor", ancestor, descendant], cwd);
1419
+ return true;
1420
+ } catch {
1421
+ return false;
1422
+ }
1284
1423
  }
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) : [];
1424
+ async function getLegacySyncRef(cwd, headRef) {
1425
+ const storedRef = await getStoredSyncRef(cwd);
1426
+ if (!storedRef) return null;
1427
+ const storedHead = await getStoredSyncHead(cwd);
1428
+ if (storedHead) {
1429
+ const currentHead = await git(["rev-parse", headRef], cwd, { ignoreErrors: true });
1430
+ if (currentHead && currentHead === storedHead) return null;
1431
+ }
1432
+ return storedRef;
1288
1433
  }
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);
1434
+ async function getEffectiveMergeBase(cwd, headRef, upstreamRef) {
1435
+ const gitBase = await getMergeBase(cwd, headRef, upstreamRef);
1436
+ const recorded = await readManifestBaseAtRef(cwd, headRef) ?? await getLegacySyncRef(cwd, headRef);
1437
+ if (!recorded) {
1438
+ return { base: gitBase, isStale: false, storedRef: null };
1439
+ }
1440
+ const recordedIsNewer = await isAncestor(cwd, gitBase, recorded);
1441
+ if (recordedIsNewer && recorded !== gitBase) {
1442
+ return { base: recorded, isStale: true, storedRef: recorded };
1443
+ }
1444
+ return { base: gitBase, isStale: false, storedRef: recorded };
1294
1445
  }
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);
1446
+ async function withTemporarySyncBaseGraft(cwd, headRef, baseSha, fn) {
1447
+ const headSha = await git(["rev-parse", headRef], cwd, { ignoreErrors: true });
1448
+ if (!headSha || !await commitObjectExists(cwd, baseSha)) return fn();
1449
+ if (await isAncestor(cwd, baseSha, headSha)) return fn();
1450
+ const existingReplacement = await git(["replace", "-l", headSha], cwd, { ignoreErrors: true });
1451
+ if (existingReplacement) return fn();
1452
+ const parentsRaw = await git(["log", "-1", "--format=%P", headSha], cwd, { ignoreErrors: true });
1453
+ const parents = parentsRaw ? parentsRaw.split(/\s+/) : [];
1454
+ await git(["replace", "-f", "--graft", headSha, ...parents, baseSha], cwd);
1455
+ try {
1456
+ return await fn();
1457
+ } finally {
1458
+ await git(["replace", "-d", headSha], cwd, { ignoreErrors: true });
1299
1459
  }
1300
- await git(["commit", "-m", message], cwd, { skipEditor: true });
1301
1460
  }
1302
- async function stageAll(cwd) {
1303
- await git(["add", "-A"], cwd);
1461
+
1462
+ // src/utils/migration-notes.ts
1463
+ var NOTES_DIR = "cella/migrations";
1464
+ var PENDING_FILE = "cella/cella.migrations.json";
1465
+ var NOTE_ID = /^\d{8}T\d{4}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
1466
+ function isUpstreamOnly(filePath) {
1467
+ return filePath === NOTES_DIR || filePath.startsWith(`${NOTES_DIR}/`);
1468
+ }
1469
+ function bodyLines(lines) {
1470
+ let inComment = false;
1471
+ return lines.filter((line) => {
1472
+ if (!inComment && !line.trimStart().startsWith("<!--")) return true;
1473
+ inComment = !line.includes("-->");
1474
+ return false;
1475
+ });
1304
1476
  }
1305
- function mergeInProgress(cwd) {
1306
- return existsSync4(join8(cwd, ".git", "MERGE_HEAD"));
1477
+ function parseNote(id, source, fileNames) {
1478
+ const lines = source.replace(/\r\n/g, "\n").split("\n");
1479
+ const close = lines[0] === "---" ? lines.indexOf("---", 1) : -1;
1480
+ const meta = /* @__PURE__ */ new Map();
1481
+ for (const line of lines.slice(1, Math.max(close, 1))) {
1482
+ const match = /^(\w+):\s*(.*)$/.exec(line);
1483
+ if (match) meta.set(match[1], match[2].trim());
1484
+ }
1485
+ const body = bodyLines(lines.slice(close + 1)).join("\n");
1486
+ const blocks = body.split(/\n\s*\n/).map((block) => block.trim()).filter(Boolean);
1487
+ const title = /^# (.+)$/.exec(blocks[0] ?? "")?.[1].trim();
1488
+ const summary = title && blocks[1] && !/^(#|>|[-*|]|```)/.test(blocks[1]) ? blocks[1].replace(/\s*\n\s*/g, " ") : "";
1489
+ return {
1490
+ id,
1491
+ title: title || id,
1492
+ summary,
1493
+ syncBreaking: meta.get("syncBreaking") === "true",
1494
+ clientCacheBump: meta.get("clientCacheBump") === "true",
1495
+ roots: (meta.get("roots") ?? "").split(",").map((root) => root.trim()).filter(Boolean),
1496
+ codemod: fileNames.find((name) => name.endsWith(".ts") && !name.endsWith(".test.ts")) ?? null
1497
+ };
1307
1498
  }
1308
- async function getShortSha(cwd, ref) {
1309
- return git(["rev-parse", "--short", ref], cwd);
1499
+ async function listNoteIds(cwd, ref) {
1500
+ const out = await git(["ls-tree", "--name-only", `${ref}:${NOTES_DIR}`], cwd, { ignoreErrors: true });
1501
+ return out.split("\n").filter((name) => NOTE_ID.test(name)).sort();
1502
+ }
1503
+ async function readNote(cwd, ref, id) {
1504
+ const source = await git(["show", `${ref}:${NOTES_DIR}/${id}/README.md`], cwd, { ignoreErrors: true });
1505
+ if (!source) return null;
1506
+ const files = await git(["ls-tree", "--name-only", `${ref}:${NOTES_DIR}/${id}`], cwd, { ignoreErrors: true });
1507
+ return parseNote(id, source, files.split("\n").filter(Boolean));
1508
+ }
1509
+ async function arrivedNoteIds(cwd, from, to) {
1510
+ const [before, after] = await Promise.all([listNoteIds(cwd, from), listNoteIds(cwd, to)]);
1511
+ const known = new Set(before);
1512
+ return after.filter((id) => !known.has(id));
1513
+ }
1514
+ async function readPending(cwd, syncRef) {
1515
+ const path4 = join6(cwd, PENDING_FILE);
1516
+ if (!existsSync4(path4)) return [];
1517
+ const parsed = JSON.parse(await readFile2(path4, "utf8"));
1518
+ if (Array.isArray(parsed.pending)) return parsed.pending.filter((id) => typeof id === "string");
1519
+ if (!Array.isArray(parsed.applied) || !syncRef) return [];
1520
+ const applied = new Set(parsed.applied);
1521
+ return (await listNoteIds(cwd, syncRef)).filter((id) => !applied.has(id));
1522
+ }
1523
+ async function writePending(cwd, ids) {
1524
+ const path4 = join6(cwd, PENDING_FILE);
1525
+ const pending = [...new Set(ids)].sort();
1526
+ if (pending.length === 0) {
1527
+ await rm(path4, { force: true });
1528
+ return;
1529
+ }
1530
+ await mkdir3(dirname3(path4), { recursive: true });
1531
+ await writeFile2(path4, `${JSON.stringify({ pending }, null, 2)}
1532
+ `, "utf8");
1310
1533
  }
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;
1534
+ function noteUrl(upstreamGitHubUrl, commit, id) {
1535
+ return upstreamGitHubUrl ? `${upstreamGitHubUrl}/blob/${commit}/${NOTES_DIR}/${id}/README.md` : void 0;
1315
1536
  }
1316
- async function remoteExists(cwd, remoteName) {
1317
- const remotes = await git(["remote"], cwd);
1318
- return remotes.split("\n").includes(remoteName);
1537
+ function formatNotesLine(total, open, arrived, options = {}) {
1538
+ const parts = [open === 0 ? `all ${total} handled` : `${Math.max(0, total - open)} of ${total} handled`];
1539
+ if (arrived) parts.push(`${arrived} ${options.dryRun ? "arrive" : "arrived"} with this sync`);
1540
+ if (open > 0) parts.push(`${open} open: pnpm cella migrate`);
1541
+ return `migration notes: ${parts.join(" \xB7 ")}`;
1319
1542
  }
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);
1325
- }
1543
+
1544
+ // src/utils/overrides.ts
1545
+ function isUnderAnyFolder(filePath, folders) {
1546
+ return folders.some((folder) => {
1547
+ const entry = folder.replace(/\/+$/, "");
1548
+ return filePath === entry || filePath.startsWith(`${entry}/`);
1549
+ });
1326
1550
  }
1327
- async function fetch2(cwd, remoteName) {
1328
- await git(["fetch", remoteName], cwd);
1551
+ function isIgnored(filePath, config) {
1552
+ return isUpstreamOnly(filePath) || isUnderAnyFolder(filePath, config.overrides?.ignored || []);
1329
1553
  }
1330
- async function fetchUpstreamTags(cwd, remoteName) {
1331
- await git(["fetch", remoteName, "--no-tags", `+refs/tags/*:refs/${remoteName}/tags/*`], cwd);
1554
+ function isPinned(filePath, config) {
1555
+ if (isManagedFile(filePath)) return true;
1556
+ return isUnderAnyFolder(filePath, config.overrides?.pinned || []);
1332
1557
  }
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
1337
- });
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 };
1558
+ function groupIgnoredUpstreamChanges(files, ignored) {
1559
+ const entries = [...new Set(ignored.map((entry) => entry.replace(/\/+$/, "")).filter(Boolean))];
1560
+ const mostSpecificFirst = [...entries].sort((a, b) => b.length - a.length);
1561
+ const groups = /* @__PURE__ */ new Map();
1562
+ for (const file of files) {
1563
+ if (!file.upstreamOnly || isManagedFile(file.path) || isGeneratedFile(file.path)) continue;
1564
+ const entry = mostSpecificFirst.find((candidate) => isUnderAnyFolder(file.path, [candidate]));
1565
+ if (!entry) continue;
1566
+ const group = groups.get(entry) ?? { entry, paths: [], added: 0, deleted: 0 };
1567
+ group.paths.push(file.path);
1568
+ if (!file.existsInFork) group.added++;
1569
+ if (!file.existsInUpstream) group.deleted++;
1570
+ groups.set(entry, group);
1344
1571
  }
1345
- return null;
1572
+ for (const group of groups.values()) group.paths.sort();
1573
+ return entries.flatMap((entry) => groups.get(entry) ?? []);
1346
1574
  }
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 };
1575
+ function isPinnedForSync(filePath, config, unpinned) {
1576
+ if (isManagedFile(filePath)) return true;
1577
+ if (unpinned) return false;
1578
+ return isPinned(filePath, config);
1352
1579
  }
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;
1580
+ function hasGlobChars(entry) {
1581
+ return entry.includes("*") || entry.includes("?");
1356
1582
  }
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 });
1370
- }
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);
1417
- }
1418
- async function removeWorktree(cwd, worktreePath) {
1419
- await git(["worktree", "remove", worktreePath, "--force"], cwd, { ignoreErrors: true });
1420
- await git(["worktree", "prune"], cwd, { ignoreErrors: true });
1421
- }
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;
1432
- }
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 };
1444
- }
1445
- throw error;
1446
- }
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
1583
+ function validateOverrides(config, forkPath) {
1584
+ const warnings = [];
1585
+ const pinned = config.overrides?.pinned || [];
1586
+ for (const entry of pinned) {
1587
+ if (hasGlobChars(entry)) {
1588
+ warnings.push({
1589
+ type: "pinned-glob",
1590
+ pattern: entry,
1591
+ message: `pin uses glob (not supported, use a path or folder): ${entry}`
1486
1592
  });
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
1593
+ } else if (!existsSync5(join7(forkPath, entry))) {
1594
+ warnings.push({
1595
+ type: "pinned-not-found",
1596
+ pattern: entry,
1597
+ message: `pin not found: ${entry}`
1496
1598
  });
1497
1599
  }
1498
1600
  }
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;
1517
- }
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);
1601
+ const ignored = config.overrides?.ignored || [];
1602
+ for (const entry of ignored) {
1603
+ if (hasGlobChars(entry)) {
1604
+ warnings.push({
1605
+ type: "ignored-not-found",
1606
+ pattern: entry,
1607
+ message: `ignored entry uses glob (not supported, use a path or folder): ${entry}`
1608
+ });
1609
+ } else if (!existsSync5(join7(forkPath, entry.replace(/\/+$/, "")))) {
1610
+ warnings.push({
1611
+ type: "ignored-not-found",
1612
+ pattern: entry,
1613
+ message: `ignored entry not found: ${entry}`
1614
+ });
1528
1615
  }
1529
1616
  }
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));
1617
+ return warnings;
1561
1618
  }
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 {
1619
+ function printWarnings(warnings) {
1620
+ for (const warning of warnings) {
1621
+ console.info(`${warningMark} ${warning.message}`);
1568
1622
  }
1569
1623
  }
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 {
1624
+
1625
+ // src/cli.ts
1626
+ function readOptions(opts) {
1627
+ const scope = typeof opts.scope === "string" ? opts.scope : void 0;
1628
+ if (scope && scope !== "all" && scope !== "risk" && scope !== "protected") {
1629
+ throw new Error(`invalid --scope '${scope}'. expected one of: all, risk, protected`);
1584
1630
  }
1631
+ const normalizedScope = scope;
1632
+ return {
1633
+ logFile: opts.log === true,
1634
+ verbose: opts.verbose === true,
1635
+ list: opts.list === true,
1636
+ json: opts.json === true,
1637
+ diff: typeof opts.diff === "string" ? opts.diff : void 0,
1638
+ openDiff: typeof opts.openDiff === "string" ? opts.openDiff : void 0,
1639
+ scope: normalizedScope,
1640
+ fork: typeof opts.fork === "string" ? opts.fork : void 0,
1641
+ hard: opts.hard === true,
1642
+ unpinned: opts.unpinned === true,
1643
+ track: opts.track === "release" || opts.track === "branch" ? opts.track : void 0,
1644
+ ref: typeof opts.ref === "string" && opts.ref ? opts.ref : void 0,
1645
+ force: opts.force === true,
1646
+ checkOverrides: opts.checkOverrides === true,
1647
+ coverage: opts.coverage === true,
1648
+ all: opts.all === true,
1649
+ show: typeof opts.show === "string" ? opts.show : void 0,
1650
+ extract: typeof opts.extract === "string" ? opts.extract : void 0,
1651
+ mark: Array.isArray(opts.mark) ? opts.mark.filter((id) => typeof id === "string") : void 0
1652
+ };
1585
1653
  }
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);
1654
+ var serviceDefinitions = [
1655
+ {
1656
+ name: "analyze",
1657
+ description: "dry run to see what would change on sync",
1658
+ options: [
1659
+ { flags: "--log", description: "write complete file list to cella-sync.log" },
1660
+ { flags: "--list", description: "non-interactive output for tooling (one file per line)" },
1661
+ { flags: "--json", description: "machine-readable output for tooling/agents" },
1662
+ { flags: "--scope <scope>", description: "analyze scope for --list/--json: all|risk|protected" },
1663
+ { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" },
1664
+ { flags: "--ref <ref>", description: "pin the upstream commit for this run: sha, release tag or branch" },
1665
+ { flags: "--diff <path>", description: "print unified diff for one file, then exit" },
1666
+ { flags: "--open-diff <path>", description: "open a browser diff for one file, then exit" }
1667
+ ],
1668
+ includeInMenu: (context) => !context.isUpstreamRepo
1669
+ },
1670
+ {
1671
+ name: "sync",
1672
+ description: "merge upstream changes into your app",
1673
+ options: [
1674
+ { flags: "--log", description: "write complete file list to cella-sync.log" },
1675
+ { flags: "--hard", description: "overwrite drifted files with upstream version (aggressive realignment)" },
1676
+ { flags: "--unpinned", description: "ignore pinned files (except package.json) to resurface upstream changes" },
1677
+ { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" },
1678
+ { flags: "--ref <ref>", description: "pin the upstream commit for this run: sha, release tag or branch" }
1679
+ ],
1680
+ includeInMenu: (context) => !context.isUpstreamRepo,
1681
+ menuDescription: () => "merge upstream changes + sync package.json"
1682
+ },
1683
+ {
1684
+ name: "migrate",
1685
+ description: "list the upstream migration notes this app has not handled yet",
1686
+ options: [
1687
+ { flags: "--all", description: "list every upstream note, handled or not" },
1688
+ { flags: "--json", description: "machine-readable output for tooling/agents" },
1689
+ { flags: "--show <id>", description: "print one note's README" },
1690
+ { flags: "--extract <id>", description: "write one note's folder under node_modules/.cache to run its codemod" },
1691
+ { flags: "--mark <ids...>", description: "record notes as handled" }
1692
+ ],
1693
+ includeInMenu: (context) => !context.isUpstreamRepo
1694
+ },
1695
+ {
1696
+ name: "audit",
1697
+ description: "check for outdated packages & vulnerabilities",
1698
+ options: [
1699
+ { flags: "--list", description: "skip interactive update prompts after printing audit results" },
1700
+ { flags: "--force", description: "bypass pnpm metadata cache for fresh registry data" },
1701
+ { flags: "--check-overrides", description: "check which pnpm.overrides are still needed" }
1702
+ ]
1703
+ },
1704
+ {
1705
+ name: "forks",
1706
+ description: "sync downstream to local fork repositories",
1707
+ options: [
1708
+ { flags: "--fork <name>", description: "pre-select fork by name (skips fork selection prompt)" },
1709
+ { flags: "--log", description: "write complete file list to cella-sync.log for each synced fork" },
1710
+ { flags: "-V, --verbose", description: "show detailed output during operations" },
1711
+ { flags: "--hard", description: "overwrite drifted files with upstream version (aggressive realignment)" }
1712
+ ],
1713
+ includeInMenu: (context) => context.hasForks
1714
+ },
1715
+ {
1716
+ name: "contributions",
1717
+ description: "pull and adopt changes from forks",
1718
+ options: [
1719
+ { flags: "--fork <name>", description: "select a specific fork directly (skips fork selection)" },
1720
+ { flags: "--list", description: "non-interactive output (one file per line)" },
1721
+ { flags: "--json", description: "machine-readable JSON output for tooling/agents" },
1722
+ { flags: "--diff <path>", description: "print the unified diff for a single contributed file, then exit" }
1723
+ ],
1724
+ includeInMenu: (context) => context.hasForks
1725
+ },
1726
+ {
1727
+ name: "stats",
1728
+ description: "count files by category and workspace package",
1729
+ options: [
1730
+ { flags: "-V, --verbose", description: "show detailed output during operations" },
1731
+ { flags: "--coverage", description: "regenerate test coverage before showing the stats summary" }
1732
+ ]
1591
1733
  }
1734
+ ];
1735
+ async function getMenuContext(userConfig, forkPath) {
1736
+ return {
1737
+ hasForks: (userConfig.forks?.length ?? 0) > 0,
1738
+ isUpstreamRepo: JSON.parse(readFileSync(join8(forkPath, "package.json"), "utf8")).name === "cella"
1739
+ };
1592
1740
  }
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;
1741
+ function buildServiceChoices(context) {
1742
+ const label = (name) => name.padEnd(14);
1743
+ const baseChoices = serviceDefinitions.filter((service) => service.includeInMenu?.(context) ?? true).map((service) => ({
1744
+ value: service.name,
1745
+ name: `${label(service.name)}${colors_default.dim(service.menuDescription?.(context) ?? service.description)}`
1746
+ }));
1747
+ return [
1748
+ ...baseChoices,
1749
+ { type: "separator", separator: "\u2500".repeat(40) },
1750
+ { value: "exit", name: colors_default.red(`${label("exit")}${colors_default.dim("quit without doing anything")}`) }
1751
+ ];
1603
1752
  }
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;
1753
+ function addServiceCommand(program, definition, setSelection) {
1754
+ const command = program.command(definition.name).description(definition.description);
1755
+ for (const option of definition.options ?? []) {
1756
+ command.option(option.flags, option.description);
1614
1757
  }
1758
+ command.action((opts) => {
1759
+ setSelection({
1760
+ service: definition.name,
1761
+ options: readOptions(opts)
1762
+ });
1763
+ });
1615
1764
  }
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;
1765
+ function buildProgram(setSelection) {
1766
+ 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(
1767
+ "after",
1768
+ [
1769
+ "",
1770
+ "Examples:",
1771
+ " $ cella analyze",
1772
+ " $ cella analyze --json --scope risk",
1773
+ " $ cella analyze --open-diff frontend/src/routes/index.tsx",
1774
+ " $ cella sync --hard",
1775
+ " $ cella sync --unpinned",
1776
+ " $ cella sync --track branch",
1777
+ " $ cella sync --ref 4f7d87c",
1778
+ " $ cella migrate --mark 20261002T0614-config-switch",
1779
+ " $ cella audit --check-overrides",
1780
+ " $ cella contributions --fork raak --json"
1781
+ ].join("\n")
1782
+ );
1783
+ for (const definition of serviceDefinitions) {
1784
+ addServiceCommand(program, definition, setSelection);
1628
1785
  }
1786
+ return program;
1629
1787
  }
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;
1788
+ function parseCommandLine(argv) {
1789
+ if (argv.length <= 2) {
1790
+ return { options: readOptions({}) };
1636
1791
  }
1792
+ let selection = { options: readOptions({}) };
1793
+ const program = buildProgram((nextSelection) => {
1794
+ selection = nextSelection;
1795
+ });
1796
+ program.parse(argv);
1797
+ return selection;
1637
1798
  }
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
- }
1799
+ async function promptForService(userConfig, forkPath) {
1800
+ const selected = await select({
1801
+ message: "choose a service:",
1802
+ choices: buildServiceChoices(await getMenuContext(userConfig, forkPath)),
1803
+ loop: false
1804
+ });
1805
+ if (selected === "exit") {
1806
+ console.info(colors_default.dim("exiting..."));
1807
+ console.info();
1808
+ process5.exit(0);
1678
1809
  }
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";
1810
+ console.info();
1811
+ return selected;
1692
1812
  }
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
- }
1813
+ function buildRuntimeConfig(userConfig, forkPath, selection) {
1814
+ const { branchRef } = resolveUpstream(userConfig.settings);
1815
+ return {
1816
+ ...userConfig,
1817
+ forkPath,
1818
+ upstreamRef: branchRef,
1819
+ service: selection.service ?? "analyze",
1820
+ ...selection.options
1821
+ };
1709
1822
  }
1710
- async function ensureSyncBase(cwd, headRef, upstreamRef) {
1711
- if (await isShallowRepository(cwd)) {
1712
- await unshallowRepository(cwd);
1823
+ async function parseCli(userConfig, forkPath) {
1824
+ const selection = parseCommandLine(process5.argv);
1825
+ if (selection.options.json || selection.options.diff) setJsonMode(true);
1826
+ printHeader();
1827
+ const warnings = validateOverrides(userConfig, forkPath);
1828
+ if (warnings.length > 0) {
1829
+ printWarnings(warnings);
1830
+ console.info();
1713
1831
  }
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
- }
1832
+ if (!selection.service) {
1833
+ selection.service = await promptForService(userConfig, forkPath);
1730
1834
  }
1731
- if (!baseSha) {
1732
- throw new Error(
1733
- `no common ancestor between the fork and '${upstreamRef}', and no sync base could be determined.
1835
+ return buildRuntimeConfig(userConfig, forkPath, selection);
1836
+ }
1734
1837
 
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:
1838
+ // src/services/analyze.ts
1839
+ import { basename as basename4 } from "path";
1738
1840
 
1739
- git update-ref refs/cella/last-sync <upstream-sha>
1740
- `
1741
- );
1742
- }
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);
1747
- }
1748
- async function isAncestor(cwd, ancestor, descendant) {
1749
- try {
1750
- await git(["merge-base", "--is-ancestor", ancestor, descendant], cwd);
1751
- return true;
1752
- } catch {
1753
- return false;
1754
- }
1755
- }
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;
1841
+ // src/utils/diff.ts
1842
+ import { execFileSync } from "child_process";
1843
+ import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
1844
+ import { tmpdir } from "os";
1845
+ import { basename as basename2, join as join9 } from "path";
1846
+ import process6 from "process";
1847
+ function gitDiffFile(cwd, range, filePath, options = {}) {
1848
+ const args = ["diff", "--no-color"];
1849
+ if (options.dstPrefix) args.push("--src-prefix=cella/", `--dst-prefix=${options.dstPrefix}/`);
1850
+ args.push(range, "--", filePath);
1851
+ try {
1852
+ return execFileSync("git", args, { cwd, maxBuffer: 50 * 1024 * 1024 });
1853
+ } catch (error) {
1854
+ const stderr = error.stderr?.toString().trim();
1855
+ throw new Error(stderr || `failed to diff ${filePath}`);
1763
1856
  }
1764
- return storedRef;
1765
1857
  }
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 };
1858
+ function escapeHtml(text) {
1859
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1860
+ }
1861
+ async function renderDiffPage(patch, meta) {
1862
+ const { preloadPatchDiff } = await import("@pierre/diffs/ssr");
1863
+ const { prerenderedHTML } = await preloadPatchDiff({
1864
+ patch,
1865
+ options: {
1866
+ diffStyle: "split",
1867
+ themeType: "system",
1868
+ lineDiffType: "word-alt",
1869
+ hunkSeparators: "line-info",
1870
+ overflow: "wrap",
1871
+ disableFileHeader: true
1872
+ }
1873
+ });
1874
+ const title = `${escapeHtml(meta.filePath)} \xB7 ${escapeHtml(meta.srcLabel)} vs ${escapeHtml(meta.dstLabel)}`;
1875
+ const note = meta.note ? `<span class="note">\xB7 ${escapeHtml(meta.note)}</span>` : "";
1876
+ return `<!doctype html>
1877
+ <html lang="en">
1878
+ <head>
1879
+ <meta charset="utf-8">
1880
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1881
+ <title>${title}</title>
1882
+ <style>
1883
+ :root { color-scheme: light dark; }
1884
+ * { box-sizing: border-box; }
1885
+ body {
1886
+ margin: 0;
1887
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
1888
+ background: light-dark(#fafafa, #111);
1889
+ color: light-dark(#111, #eee);
1771
1890
  }
1772
- const recordedIsNewer = await isAncestor(cwd, gitBase, recorded);
1773
- if (recordedIsNewer && recorded !== gitBase) {
1774
- return { base: recorded, isStale: true, storedRef: recorded };
1891
+ header {
1892
+ display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap;
1893
+ padding: 0.9rem 1.25rem;
1894
+ border-bottom: 1px solid light-dark(#e2e2e2, #2a2a2a);
1895
+ position: sticky; top: 0;
1896
+ background: inherit;
1775
1897
  }
1776
- return { base: gitBase, isStale: false, storedRef: recorded };
1898
+ header .brand { font-weight: 600; color: light-dark(#0891b2, #22d3ee); }
1899
+ header code { font-family: ui-monospace, monospace; font-size: 0.9rem; }
1900
+ header .sides { margin-left: auto; font-size: 0.8rem; opacity: 0.7; }
1901
+ main { max-width: 1400px; margin: 1rem auto; padding: 0 1rem 2rem; }
1902
+ .diff-shell {
1903
+ border: 1px solid light-dark(#e2e2e2, #2a2a2a);
1904
+ border-radius: 8px; overflow: hidden;
1905
+ background: light-dark(#fff, #161616);
1906
+ }
1907
+ </style>
1908
+ </head>
1909
+ <body>
1910
+ <header>
1911
+ <span class="brand">\u29C8 cella diff</span>
1912
+ <code>${escapeHtml(meta.filePath)}</code>
1913
+ <span class="sides">${escapeHtml(meta.srcLabel)} \u2190 \u2192 ${escapeHtml(meta.dstLabel)} ${note}</span>
1914
+ </header>
1915
+ <main>
1916
+ <!-- Declarative shadow DOM: the library's CSS targets :host, which only
1917
+ matches inside a shadow root (its browser component renders into one). -->
1918
+ <div class="diff-shell">
1919
+ <template shadowrootmode="open">
1920
+ ${prerenderedHTML}
1921
+ </template>
1922
+ </div>
1923
+ </main>
1924
+ </body>
1925
+ </html>`;
1777
1926
  }
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);
1927
+ function openWithDefaultApp(target) {
1787
1928
  try {
1788
- return await fn();
1789
- } finally {
1790
- await git(["replace", "-d", headSha], cwd, { ignoreErrors: true });
1929
+ if (process6.platform === "win32") {
1930
+ execFileSync("rundll32", ["url.dll,FileProtocolHandler", target], { stdio: "ignore" });
1931
+ } else {
1932
+ execFileSync(process6.platform === "darwin" ? "open" : "xdg-open", [target], { stdio: "ignore" });
1933
+ }
1934
+ } catch {
1791
1935
  }
1792
1936
  }
1937
+ async function openDiffInBrowser(patch, meta, repoPath) {
1938
+ const html = await renderDiffPage(patch, meta);
1939
+ const dir = join9(tmpdir(), `cella-diff-${basename2(repoPath)}`);
1940
+ mkdirSync(dir, { recursive: true });
1941
+ const outPath = join9(dir, `${meta.filePath.replace(/[^\w.-]+/g, "-")}.html`);
1942
+ writeFileSync2(outPath, html);
1943
+ openWithDefaultApp(outPath);
1944
+ return outPath;
1945
+ }
1946
+
1947
+ // src/services/merge-engine.ts
1948
+ import { existsSync as existsSync7 } from "fs";
1949
+ import { rm as rm2 } from "fs/promises";
1950
+ import { join as join11 } from "path";
1793
1951
 
1794
1952
  // src/utils/cleanup.ts
1953
+ import { createHash } from "crypto";
1954
+ import { existsSync as existsSync6, rmSync } from "fs";
1955
+ import { tmpdir as tmpdir2 } from "os";
1956
+ import { basename as basename3, join as join10, resolve as resolve4 } from "path";
1957
+ import process7 from "process";
1795
1958
  var WORKTREE_PREFIXES = {
1796
1959
  sync: "cella-sync-",
1797
1960
  view: "cella-view-"
1798
1961
  };
1799
1962
  function buildWorktreePath(kind, repoPath) {
1800
- return join9(tmpdir2(), `${WORKTREE_PREFIXES[kind]}${basename3(repoPath)}`);
1963
+ const key = createHash("sha256").update(resolve4(repoPath)).digest("hex").slice(0, 8);
1964
+ return join10(tmpdir2(), `${WORKTREE_PREFIXES[kind]}${basename3(repoPath)}-${key}`);
1965
+ }
1966
+ function legacyWorktreePath(kind, repoPath) {
1967
+ return join10(tmpdir2(), `${WORKTREE_PREFIXES[kind]}${basename3(repoPath)}`);
1801
1968
  }
1802
1969
  function getWorktreePath(repoPath) {
1803
1970
  return buildWorktreePath("sync", repoPath);
@@ -1815,22 +1982,21 @@ function unregisterWorktree() {
1815
1982
  }
1816
1983
  async function cleanupWorktree(repoPath, worktreePath) {
1817
1984
  await removeWorktree(repoPath, worktreePath);
1818
- if (existsSync5(worktreePath)) {
1985
+ if (existsSync6(worktreePath)) {
1819
1986
  rmSync(worktreePath, { recursive: true, force: true });
1820
1987
  }
1821
1988
  unregisterWorktree();
1822
1989
  }
1823
1990
  async function cleanupLeftoverWorktrees(repoPath) {
1824
1991
  for (const kind of Object.keys(WORKTREE_PREFIXES)) {
1825
- const worktreePath = buildWorktreePath(kind, repoPath);
1826
- if (existsSync5(worktreePath)) {
1827
- await cleanupWorktree(repoPath, worktreePath);
1992
+ for (const worktreePath of [buildWorktreePath(kind, repoPath), legacyWorktreePath(kind, repoPath)]) {
1993
+ if (existsSync6(worktreePath)) await cleanupWorktree(repoPath, worktreePath);
1828
1994
  }
1829
1995
  }
1830
1996
  const prefixes = Object.values(WORKTREE_PREFIXES);
1831
1997
  const worktrees = await listWorktrees(repoPath);
1832
1998
  for (const wt of worktrees) {
1833
- if (prefixes.some((prefix) => wt.includes(prefix)) && !existsSync5(wt)) {
1999
+ if (prefixes.some((prefix) => wt.includes(prefix)) && !existsSync6(wt)) {
1834
2000
  await removeWorktree(repoPath, wt);
1835
2001
  }
1836
2002
  }
@@ -1861,6 +2027,51 @@ function registerSignalHandlers() {
1861
2027
  cleanupRegistered = true;
1862
2028
  }
1863
2029
 
2030
+ // src/utils/versions.ts
2031
+ function parseComparableVersion(version) {
2032
+ const trimmed = version.trim();
2033
+ 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("||")) {
2034
+ return null;
2035
+ }
2036
+ const match = trimmed.match(
2037
+ /^(?:\^|~|>=|<=|>|<|=)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
2038
+ );
2039
+ if (!match) return null;
2040
+ return {
2041
+ major: Number(match[1]),
2042
+ minor: Number(match[2] ?? 0),
2043
+ patch: Number(match[3] ?? 0)
2044
+ };
2045
+ }
2046
+ function compareVersions(left, right) {
2047
+ if (left.major !== right.major) return left.major - right.major;
2048
+ if (left.minor !== right.minor) return left.minor - right.minor;
2049
+ return left.patch - right.patch;
2050
+ }
2051
+
2052
+ // src/utils/cli-version.ts
2053
+ var CLI_PACKAGE = "@cellajs/cli";
2054
+ async function readUpstreamCliRange(repoPath, ref) {
2055
+ const source = await git(["show", `${ref}:package.json`], repoPath, { ignoreErrors: true });
2056
+ if (!source) return null;
2057
+ try {
2058
+ const pkg = JSON.parse(source);
2059
+ return pkg.devDependencies?.[CLI_PACKAGE] ?? pkg.dependencies?.[CLI_PACKAGE] ?? null;
2060
+ } catch {
2061
+ return null;
2062
+ }
2063
+ }
2064
+ function cliVersionMismatch(running, range) {
2065
+ if (/^\s*(<|>(?!=))/.test(range)) return null;
2066
+ const required = parseComparableVersion(range);
2067
+ const current = parseComparableVersion(running);
2068
+ if (!required || !current || compareVersions(current, required) >= 0) return null;
2069
+ return [
2070
+ `upstream needs ${CLI_PACKAGE} ${range}, this is ${running}: an older CLI misses fixes to the sync itself.`,
2071
+ ` update it, commit, then rerun: pnpm add -D -w '${CLI_PACKAGE}@${range}'`
2072
+ ].join("\n");
2073
+ }
2074
+
1864
2075
  // src/utils/upstream-overrides.ts
1865
2076
  import { Node, Project as Project2, SyntaxKind as SyntaxKind2 } from "ts-morph";
1866
2077
  var LIST_KEYS = ["pinned", "ignored"];
@@ -2359,6 +2570,20 @@ function formatLocalCheckoutDetail(forkPath, branch, headSha, changeCount, upstr
2359
2570
  const workingTree = changeCount === 0 ? " working tree clean" : ` ${changeCount} uncommitted change${changeCount === 1 ? "" : "s"} in working tree, not included in analysis`;
2360
2571
  return [`${forkPath}`, ` ${branch} @ ${headSha}${tracking}`, workingTree].join("\n");
2361
2572
  }
2573
+ async function resolvePinnedRef(forkPath, config, remoteName, branchRef, ref) {
2574
+ const resolved = await resolveUpstreamCommit(forkPath, remoteName, ref);
2575
+ if (!resolved) {
2576
+ throw new Error(
2577
+ `--ref '${ref}' does not resolve to a commit. Pass a commit sha, a release tag (v*) or a branch on ${config.settings.upstreamUrl}.`
2578
+ );
2579
+ }
2580
+ if (!await isPublishedUpstream(forkPath, remoteName, branchRef, resolved.sha)) {
2581
+ throw new Error(
2582
+ `--ref '${ref}' (${resolved.sha.slice(0, 7)}) is not on ${branchRef} or in an upstream release (v*). Sync only to commits upstream has published there.`
2583
+ );
2584
+ }
2585
+ return resolved.release ? { ref: resolved.release.ref, releaseTag: resolved.release.tag } : { ref: resolved.sha };
2586
+ }
2362
2587
  async function prepareUpstream(config, onProgress, onStep) {
2363
2588
  const { forkPath } = config;
2364
2589
  onProgress?.("checking local checkout...");
@@ -2378,7 +2603,12 @@ async function prepareUpstream(config, onProgress, onStep) {
2378
2603
  await fetch2(forkPath, remoteName);
2379
2604
  let upstreamRef = branchRef;
2380
2605
  let releaseTag;
2381
- if (track === "release") {
2606
+ if (config.ref) {
2607
+ await fetchUpstreamTags(forkPath, remoteName);
2608
+ const pinned = await resolvePinnedRef(forkPath, config, remoteName, branchRef, config.ref);
2609
+ upstreamRef = pinned.ref;
2610
+ releaseTag = pinned.releaseTag;
2611
+ } else if (track === "release") {
2382
2612
  await fetchUpstreamTags(forkPath, remoteName);
2383
2613
  const latest = await resolveLatestReleaseTag(forkPath, remoteName);
2384
2614
  if (!latest) {
@@ -2389,14 +2619,30 @@ async function prepareUpstream(config, onProgress, onStep) {
2389
2619
  upstreamRef = latest.ref;
2390
2620
  releaseTag = latest.tag;
2391
2621
  }
2622
+ const upstreamLabel = releaseTag ?? (config.ref ? `--ref ${config.ref}` : upstreamRef);
2392
2623
  config.upstreamRef = upstreamRef;
2393
- onStep?.("remote configured", `${releaseTag ?? upstreamRef} \u2192 ${config.settings.upstreamUrl}`);
2624
+ onStep?.("remote configured", `${upstreamLabel} \u2192 ${config.settings.upstreamUrl}`);
2625
+ const cliRange = await readUpstreamCliRange(forkPath, upstreamRef);
2626
+ const cliMismatch = cliRange ? cliVersionMismatch(VERSION, cliRange) : null;
2627
+ if (cliMismatch) throw new Error(cliMismatch);
2394
2628
  onProgress?.("checking sync base...");
2395
2629
  await ensureSyncBase(forkPath, "HEAD", upstreamRef);
2396
2630
  const upstreamGitHubUrl = getGitHubBaseUrl(config.settings.upstreamUrl) ?? void 0;
2397
2631
  const aggressive = config.hard === true || config.unpinned === true;
2398
- const mergeBase = aggressive ? await getMergeBase(forkPath, "HEAD", upstreamRef) : (await getEffectiveMergeBase(forkPath, "HEAD", upstreamRef)).base;
2632
+ const syncPoint = (await getEffectiveMergeBase(forkPath, "HEAD", upstreamRef)).base;
2633
+ const mergeBase = aggressive ? await getMergeBase(forkPath, "HEAD", upstreamRef) : syncPoint;
2399
2634
  const upstreamCommit = await getCommitInfo(forkPath, upstreamRef);
2635
+ if (upstreamCommit.hash !== syncPoint && await isAncestor(forkPath, upstreamCommit.hash, syncPoint)) {
2636
+ const [upstreamShort, syncShort] = await Promise.all([
2637
+ getShortSha(forkPath, upstreamCommit.hash),
2638
+ getShortSha(forkPath, syncPoint)
2639
+ ]);
2640
+ const hint = config.ref ? "Pick a newer --ref." : releaseTag ? `Nothing to sync until a release past ${syncShort}; to follow the tip instead, run with --track branch.` : `Nothing to sync until '${branchRef}' moves past ${syncShort}.`;
2641
+ throw new Error(
2642
+ `upstream ${upstreamLabel} (${upstreamShort}) is behind the last sync point ${syncShort}: the fork already has it, and syncing it would revert the upstream changes after it.
2643
+ ${hint}`
2644
+ );
2645
+ }
2400
2646
  const commitCount = await countCommitsBetween(forkPath, mergeBase, upstreamRef);
2401
2647
  const commitListMax = 50;
2402
2648
  const commitSkip = commitCount > commitListMax ? commitCount - commitListMax : 0;
@@ -2436,9 +2682,34 @@ async function upstreamOnlyReports(config, ctx, files) {
2436
2682
  return {};
2437
2683
  }
2438
2684
  }
2685
+ async function readNotesInRange(config, ctx) {
2686
+ const { forkPath } = config;
2687
+ try {
2688
+ const aggressive = config.hard === true || config.unpinned === true;
2689
+ const syncBase = aggressive ? (await getEffectiveMergeBase(forkPath, "HEAD", ctx.upstreamRef)).base : ctx.mergeBase;
2690
+ const [total, arrived, pending] = await Promise.all([
2691
+ listNoteIds(forkPath, ctx.upstreamRef),
2692
+ arrivedNoteIds(forkPath, syncBase, ctx.upstreamRef),
2693
+ readPending(forkPath, syncBase)
2694
+ ]);
2695
+ return { total: total.length, arrived, pending: [.../* @__PURE__ */ new Set([...pending, ...arrived])] };
2696
+ } catch {
2697
+ return void 0;
2698
+ }
2699
+ }
2700
+ function notesResult(notes) {
2701
+ return notes ? { migrationNotes: { total: notes.total, arrived: notes.arrived, open: notes.pending.length } } : {};
2702
+ }
2703
+ async function removeUpstreamOnlyCopy(forkPath, onStep) {
2704
+ if (!existsSync7(join11(forkPath, NOTES_DIR))) return;
2705
+ await git(["rm", "-r", "-q", "--ignore-unmatch", "--", NOTES_DIR], forkPath);
2706
+ await rm2(join11(forkPath, NOTES_DIR), { recursive: true, force: true });
2707
+ onStep?.("migration notes", `removed ${NOTES_DIR}/: notes stay upstream, list them with pnpm cella migrate`);
2708
+ }
2439
2709
  async function runSyncMerge(config, ctx, onProgress, onStep) {
2440
2710
  const { forkPath } = config;
2441
2711
  const { upstreamRef, releaseTag, mergeBase, upstreamGitHubUrl, upstreamCommit } = ctx;
2712
+ const notes = await readNotesInRange(config, ctx);
2442
2713
  const { remainingConflicts, analyzedFiles, autoMergedFiles, protectedConflicts } = await applyDirectMerge(
2443
2714
  forkPath,
2444
2715
  upstreamRef,
@@ -2465,6 +2736,11 @@ async function runSyncMerge(config, ctx, onProgress, onStep) {
2465
2736
  await storeLastSyncRef(forkPath, upstreamCommit.hash);
2466
2737
  await writeSyncManifest(forkPath, syncManifest);
2467
2738
  await stagePath(forkPath, MANIFEST_FILE);
2739
+ if (notes) {
2740
+ await writePending(forkPath, notes.pending);
2741
+ await stagePath(forkPath, PENDING_FILE);
2742
+ }
2743
+ await removeUpstreamOnlyCopy(forkPath, onStep);
2468
2744
  };
2469
2745
  if (remainingConflicts.length > 0) {
2470
2746
  await recordSyncPoint();
@@ -2488,12 +2764,14 @@ async function runSyncMerge(config, ctx, onProgress, onStep) {
2488
2764
  autoMergedFiles,
2489
2765
  protectedConflicts,
2490
2766
  ...resultMeta(config, ctx),
2491
- ...await upstreamOnlyReports(config, ctx, analyzedFiles)
2767
+ ...await upstreamOnlyReports(config, ctx, analyzedFiles),
2768
+ ...notesResult(notes)
2492
2769
  };
2493
2770
  }
2494
2771
  async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2495
2772
  const { forkPath } = config;
2496
2773
  const worktreePath = getWorktreePath(forkPath);
2774
+ const notes = await readNotesInRange(config, ctx);
2497
2775
  onProgress?.("creating worktree in temp directory...");
2498
2776
  await createWorktree(forkPath, worktreePath, "HEAD");
2499
2777
  onStep?.("worktree created", worktreePath);
@@ -2527,7 +2805,8 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2527
2805
  conflicts: analyzedFiles.filter((f) => f.status === "diverged").map((f) => f.path),
2528
2806
  protectedConflicts: analyzedFiles.filter((f) => f.upstreamChanged).map((f) => f.path),
2529
2807
  ...resultMeta(config, ctx),
2530
- ...reports
2808
+ ...reports,
2809
+ ...notesResult(notes)
2531
2810
  };
2532
2811
  }
2533
2812
  async function runMergeEngine(config, options) {
@@ -2545,6 +2824,151 @@ async function runMergeEngine(config, options) {
2545
2824
  }
2546
2825
  }
2547
2826
 
2827
+ // src/services/migrate.ts
2828
+ import { execFileSync as execFileSync2 } from "child_process";
2829
+ import { mkdir as mkdir4, writeFile as writeFile3 } from "fs/promises";
2830
+ import { dirname as dirname4, join as join12 } from "path";
2831
+ var EXTRACT_DIR = "node_modules/.cache/cella/migrations";
2832
+ async function resolveNotesRef(config) {
2833
+ const { forkPath } = config;
2834
+ const ref = await getStoredSyncRef(forkPath) ?? await readManifestBaseAtRef(forkPath, "HEAD");
2835
+ if (!ref) return null;
2836
+ const present = async () => !!await git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], forkPath, { ignoreErrors: true });
2837
+ if (!await present()) {
2838
+ await ensureRemote(forkPath, DEFAULT_UPSTREAM_REMOTE, config.settings.upstreamUrl);
2839
+ await fetch2(forkPath, DEFAULT_UPSTREAM_REMOTE);
2840
+ if (!await present())
2841
+ throw new Error(`upstream commit ${ref.slice(0, 7)} is not reachable from '${DEFAULT_UPSTREAM_REMOTE}'`);
2842
+ }
2843
+ return ref;
2844
+ }
2845
+ async function readNotesStatus(config) {
2846
+ try {
2847
+ const ref = await resolveNotesRef(config);
2848
+ if (!ref) return null;
2849
+ const [ids, pending] = await Promise.all([listNoteIds(config.forkPath, ref), readPending(config.forkPath, ref)]);
2850
+ return ids.length > 0 || pending.length > 0 ? { total: ids.length, open: pending.length } : null;
2851
+ } catch {
2852
+ return null;
2853
+ }
2854
+ }
2855
+ async function printMigrationNotesLine(config, result) {
2856
+ const notes = result?.migrationNotes;
2857
+ if (notes) {
2858
+ if (notes.total === 0) return;
2859
+ console.info();
2860
+ console.info(
2861
+ colors_default.dim(formatNotesLine(notes.total, notes.open, notes.arrived.length, { dryRun: config.service === "analyze" }))
2862
+ );
2863
+ return;
2864
+ }
2865
+ const status = await readNotesStatus(config);
2866
+ if (!status) return;
2867
+ console.info();
2868
+ console.info(colors_default.dim(formatNotesLine(status.total, status.open)));
2869
+ }
2870
+ function toJson(note, url, open) {
2871
+ return { ...note, kind: note.codemod ? "codemod" : "manual", open, url: url ?? null };
2872
+ }
2873
+ function printNote(index, note, url) {
2874
+ const tags = [
2875
+ note.codemod ? "codemod" : "manual",
2876
+ note.syncBreaking ? "sync-breaking" : null,
2877
+ note.clientCacheBump ? "cache-bump" : null
2878
+ ].filter(Boolean).join(", ");
2879
+ console.info(`${index}. ${note.title} ${colors_default.dim(`[${tags}]`)}`);
2880
+ console.info(colors_default.dim(` id: ${note.id}`));
2881
+ if (note.summary) console.info(` ${note.summary}`);
2882
+ console.info(colors_default.dim(` read: pnpm cella migrate --show ${note.id}`));
2883
+ if (url) console.info(colors_default.dim(` ${url}`));
2884
+ if (note.codemod) console.info(colors_default.dim(` codemod: pnpm cella migrate --extract ${note.id}`));
2885
+ console.info();
2886
+ }
2887
+ async function extractNote(config, ref, id) {
2888
+ const { forkPath } = config;
2889
+ const note = await readNote(forkPath, ref, id);
2890
+ if (!note) throw new Error(`no migration note '${id}' at upstream ${ref.slice(0, 7)}`);
2891
+ const files = (await git(["ls-tree", "-r", "--name-only", `${ref}:${NOTES_DIR}/${id}`], forkPath)).split("\n").filter(Boolean);
2892
+ const target = join12(EXTRACT_DIR, id);
2893
+ for (const file of files) {
2894
+ const content = execFileSync2("git", ["show", `${ref}:${NOTES_DIR}/${id}/${file}`], {
2895
+ cwd: forkPath,
2896
+ maxBuffer: 50 * 1024 * 1024
2897
+ });
2898
+ const path4 = join12(forkPath, target, file);
2899
+ await mkdir4(dirname4(path4), { recursive: true });
2900
+ await writeFile3(path4, content);
2901
+ }
2902
+ console.info(`${colors_default.green("\u2713")} extracted ${files.length} file(s) to ${target}/`);
2903
+ console.info(colors_default.dim(` the README's ${NOTES_DIR}/${id}/ paths are this folder.`));
2904
+ if (note.codemod) console.info(colors_default.dim(` pnpm exec tsx ${target}/${note.codemod} inventory ${note.roots.join(" ")}`));
2905
+ }
2906
+ async function markNotes(config, ref, pending, ids) {
2907
+ const open = new Set(pending);
2908
+ const unknown = ids.filter((id) => !open.has(id));
2909
+ for (const id of ids) open.delete(id);
2910
+ await writePending(config.forkPath, [...open]);
2911
+ const marked = ids.length - unknown.length;
2912
+ if (marked > 0) console.info(`${colors_default.green("\u2713")} recorded ${marked} note(s) as handled`);
2913
+ if (unknown.length > 0) console.info(colors_default.dim(`not pending, left as is: ${unknown.join(", ")}`));
2914
+ console.info(colors_default.dim(formatNotesLine((await listNoteIds(config.forkPath, ref)).length, open.size)));
2915
+ }
2916
+ async function runMigrate(config) {
2917
+ const { forkPath } = config;
2918
+ const ref = await resolveNotesRef(config);
2919
+ if (!ref) {
2920
+ console.info(colors_default.dim("no sync point recorded yet: migration notes start with the first `pnpm cella sync`."));
2921
+ return;
2922
+ }
2923
+ const pending = await readPending(forkPath, ref);
2924
+ const githubUrl = getGitHubBaseUrl(config.settings.upstreamUrl) ?? void 0;
2925
+ const urlOf = (id) => noteUrl(githubUrl, ref, id);
2926
+ if (config.mark?.length) return markNotes(config, ref, pending, config.mark);
2927
+ if (config.extract) return extractNote(config, ref, config.extract);
2928
+ if (config.show) {
2929
+ const source = await git(["show", `${ref}:${NOTES_DIR}/${config.show}/README.md`], forkPath, {
2930
+ ignoreErrors: true
2931
+ });
2932
+ if (!source) throw new Error(`no migration note '${config.show}' at upstream ${ref.slice(0, 7)}`);
2933
+ const url = urlOf(config.show);
2934
+ if (url) console.info(colors_default.dim(url));
2935
+ console.info(source);
2936
+ return;
2937
+ }
2938
+ const ids = config.all ? await listNoteIds(forkPath, ref) : pending;
2939
+ const notes = (await Promise.all(ids.map((id) => readNote(forkPath, ref, id)))).map(
2940
+ (note, i) => note ?? {
2941
+ id: ids[i],
2942
+ title: `${ids[i]} (no longer upstream)`,
2943
+ summary: "",
2944
+ syncBreaking: false,
2945
+ clientCacheBump: false,
2946
+ roots: [],
2947
+ codemod: null
2948
+ }
2949
+ );
2950
+ const total = (await listNoteIds(forkPath, ref)).length;
2951
+ if (config.json) {
2952
+ const open = new Set(pending);
2953
+ const out = {
2954
+ upstreamCommit: ref,
2955
+ total,
2956
+ open: pending.length,
2957
+ notes: notes.map((note) => toJson(note, urlOf(note.id), open.has(note.id)))
2958
+ };
2959
+ writeStdout(JSON.stringify(out, null, 2));
2960
+ return;
2961
+ }
2962
+ console.info(colors_default.dim(formatNotesLine(total, pending.length)));
2963
+ if (notes.length === 0) return;
2964
+ console.info();
2965
+ for (const [i, note] of notes.entries()) printNote(i + 1, note, urlOf(note.id));
2966
+ if (pending.length > 0) {
2967
+ console.info(colors_default.dim("work each note's README, check with `pnpm check`, then record it:"));
2968
+ console.info(colors_default.dim(` pnpm cella migrate --mark ${pending.join(" ")}`));
2969
+ }
2970
+ }
2971
+
2548
2972
  // src/services/analyze.ts
2549
2973
  var scopeStatuses = {
2550
2974
  all: /* @__PURE__ */ new Set(["ahead", "drifted", "diverged"]),
@@ -2649,6 +3073,7 @@ async function runAnalyze(config) {
2649
3073
  printIgnoredUpstreamChanges(result);
2650
3074
  printUpstreamOverrideChanges(result);
2651
3075
  printMaskingPinWarning(result.files);
3076
+ await printMigrationNotesLine(config, result);
2652
3077
  if (config.logFile) {
2653
3078
  const logPath = writeLogFile(config.forkPath, result.files);
2654
3079
  console.info();
@@ -3427,7 +3852,7 @@ async function checkOverrides(forkPath) {
3427
3852
 
3428
3853
  // src/services/contributions.ts
3429
3854
  import { readFileSync as readFileSync4 } from "fs";
3430
- import { join as join13 } from "path";
3855
+ import { join as join16 } from "path";
3431
3856
  import {
3432
3857
  createPrompt,
3433
3858
  isDownKey,
@@ -3442,7 +3867,7 @@ import {
3442
3867
  import { select as select2 } from "@inquirer/prompts";
3443
3868
 
3444
3869
  // src/services/contrib-core.ts
3445
- import { dirname as dirname3, join as join12, resolve as resolve3 } from "path";
3870
+ import { dirname as dirname5, join as join15, resolve as resolve5 } from "path";
3446
3871
  async function listFilesAtRef(repoPath, ref) {
3447
3872
  const raw = await git(["ls-tree", "-r", "--name-only", ref], repoPath);
3448
3873
  return new Set(raw.split("\n").filter(Boolean));
@@ -3458,10 +3883,10 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
3458
3883
  const baseFiles = await listFilesAtRef(repoPath, baseRef);
3459
3884
  const baseDirs = /* @__PURE__ */ new Set(["."]);
3460
3885
  for (const f of baseFiles) {
3461
- let dir = dirname3(f);
3886
+ let dir = dirname5(f);
3462
3887
  while (dir !== ".") {
3463
3888
  baseDirs.add(dir);
3464
- dir = dirname3(dir);
3889
+ dir = dirname5(dir);
3465
3890
  }
3466
3891
  }
3467
3892
  const files = [];
@@ -3474,7 +3899,7 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
3474
3899
  if (inCella && !inFork) {
3475
3900
  kind = "deleted";
3476
3901
  } else if (!inCella && inFork) {
3477
- if (!baseDirs.has(dirname3(file.path))) continue;
3902
+ if (!baseDirs.has(dirname5(file.path))) continue;
3478
3903
  kind = "created";
3479
3904
  } else {
3480
3905
  kind = "modified";
@@ -3505,8 +3930,8 @@ async function readBlobInfo(repoPath, ref, filePath) {
3505
3930
  }
3506
3931
  async function buildContribBranch(repoPath, baseRef, forkRef, detection, forkName) {
3507
3932
  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, "_")}`);
3933
+ const gitDir = resolve5(repoPath, await git(["rev-parse", "--git-dir"], repoPath));
3934
+ const tmpIndex = join15(gitDir, `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
3510
3935
  const indexEnv = { GIT_INDEX_FILE: tmpIndex };
3511
3936
  try {
3512
3937
  await git(["read-tree", baseRef], repoPath, { env: indexEnv });
@@ -3549,20 +3974,20 @@ ${commitBody}`;
3549
3974
  }
3550
3975
 
3551
3976
  // src/services/fork-utils.ts
3552
- import { existsSync as existsSync7 } from "fs";
3553
- import { dirname as dirname4, resolve as resolve4 } from "path";
3977
+ import { existsSync as existsSync9 } from "fs";
3978
+ import { dirname as dirname6, resolve as resolve6 } from "path";
3554
3979
  async function resolveForkBasePath(repoPath) {
3555
3980
  const commonDir = await git(["rev-parse", "--git-common-dir"], repoPath, { ignoreErrors: true });
3556
- return commonDir ? dirname4(resolve4(repoPath, commonDir)) : repoPath;
3981
+ return commonDir ? dirname6(resolve6(repoPath, commonDir)) : repoPath;
3557
3982
  }
3558
3983
  function validateForkPath(fork, basePath, requireConfig = false) {
3559
- const resolvedPath = resolve4(basePath, fork.localPath);
3560
- if (!resolvedPath.startsWith(resolve4(basePath, ".."))) {
3984
+ const resolvedPath = resolve6(basePath, fork.localPath);
3985
+ if (!resolvedPath.startsWith(resolve6(basePath, ".."))) {
3561
3986
  return { fork, resolvedPath, valid: false, error: "path resolves outside parent directory" };
3562
3987
  }
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}`)) {
3988
+ if (!existsSync9(resolvedPath)) return { fork, resolvedPath, valid: false, error: "path does not exist" };
3989
+ if (!existsSync9(`${resolvedPath}/.git`)) return { fork, resolvedPath, valid: false, error: "not a git repository" };
3990
+ if (requireConfig && !existsSync9(`${resolvedPath}/${CONFIG_FILE}`)) {
3566
3991
  return { fork, resolvedPath, valid: false, error: `missing ${CONFIG_FILE}` };
3567
3992
  }
3568
3993
  return { fork, resolvedPath, valid: true };
@@ -3580,7 +4005,7 @@ async function showContribDiff(item, baseRef, cwd, forkName) {
3580
4005
  return openDiffInBrowser(patch.toString(), { filePath: item.path, srcLabel: "cella", dstLabel: forkName }, cwd);
3581
4006
  }
3582
4007
  function shouldApplyContributionsUnstaged(repoPath) {
3583
- return JSON.parse(readFileSync4(join13(repoPath, "package.json"), "utf8")).name === "cella";
4008
+ return JSON.parse(readFileSync4(join16(repoPath, "package.json"), "utf8")).name === "cella";
3584
4009
  }
3585
4010
  var contribPrompt = createPrompt((config, done) => {
3586
4011
  const { pageSize = 20, baseRef, cwd, forkName } = config;
@@ -3925,7 +4350,7 @@ async function runContributions(config) {
3925
4350
  }
3926
4351
 
3927
4352
  // src/services/forks.ts
3928
- import { resolve as resolve5 } from "path";
4353
+ import { resolve as resolve7 } from "path";
3929
4354
  import process8 from "process";
3930
4355
  import { Separator as Separator2, select as select4 } from "@inquirer/prompts";
3931
4356
 
@@ -3968,7 +4393,6 @@ function listOpenSyncPrs(cwd, prefix) {
3968
4393
  }
3969
4394
  function buildMergeArgs(ref, options = {}) {
3970
4395
  const args = ["pr", "merge", ref, "--squash"];
3971
- if (options.auto) args.push("--auto");
3972
4396
  if (options.deleteBranch) args.push("--delete-branch");
3973
4397
  return args;
3974
4398
  }
@@ -3980,12 +4404,12 @@ function closePr(cwd, ref) {
3980
4404
  }
3981
4405
 
3982
4406
  // src/services/packages.ts
3983
- import { existsSync as existsSync9, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
3984
- import { join as join15 } from "path";
4407
+ import { existsSync as existsSync11, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
4408
+ import { join as join18 } from "path";
3985
4409
 
3986
4410
  // src/services/package-usage.ts
3987
- import { existsSync as existsSync8, readFileSync as readFileSync5, statSync } from "fs";
3988
- import { join as join14 } from "path";
4411
+ import { existsSync as existsSync10, readFileSync as readFileSync5, statSync } from "fs";
4412
+ import { join as join17 } from "path";
3989
4413
  var MAX_FILE_BYTES = 1024 * 1024;
3990
4414
  function escapeRegExp(value) {
3991
4415
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -4001,7 +4425,7 @@ function readSearchable(filePath) {
4001
4425
  }
4002
4426
  function fileSource(forkPath, path4) {
4003
4427
  let text;
4004
- return { path: path4, label: path4, text: () => text ??= readSearchable(join14(forkPath, path4)) };
4428
+ return { path: path4, label: path4, text: () => text ??= readSearchable(join17(forkPath, path4)) };
4005
4429
  }
4006
4430
  async function readScriptsAtRef(forkPath, ref, path4) {
4007
4431
  try {
@@ -4013,7 +4437,7 @@ async function readScriptsAtRef(forkPath, ref, path4) {
4013
4437
  async function scriptSources(forkPath, upstreamRef, baseRef, path4) {
4014
4438
  let forkScripts;
4015
4439
  try {
4016
- forkScripts = JSON.parse(readFileSync5(join14(forkPath, path4), "utf-8")).scripts ?? {};
4440
+ forkScripts = JSON.parse(readFileSync5(join17(forkPath, path4), "utf-8")).scripts ?? {};
4017
4441
  } catch {
4018
4442
  return [];
4019
4443
  }
@@ -4023,9 +4447,9 @@ async function scriptSources(forkPath, upstreamRef, baseRef, path4) {
4023
4447
  }
4024
4448
  function binNames(forkPath, location, name) {
4025
4449
  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;
4450
+ for (const dir of [join17(forkPath, location), forkPath]) {
4451
+ const manifest = join17(dir, "node_modules", name, "package.json");
4452
+ if (!existsSync10(manifest)) continue;
4029
4453
  try {
4030
4454
  const { bin } = JSON.parse(readFileSync5(manifest, "utf-8"));
4031
4455
  if (typeof bin === "string") return [bareName];
@@ -4067,26 +4491,6 @@ async function loadForkUsage(forkPath, upstreamRef, baseRef) {
4067
4491
  }
4068
4492
 
4069
4493
  // 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
4494
  function isHigherVersion(upstreamVersion, forkVersion) {
4091
4495
  if (upstreamVersion === forkVersion) return false;
4092
4496
  const upCoerced = parseComparableVersion(upstreamVersion);
@@ -4204,8 +4608,28 @@ function safeMergePnpm(forkPnpm, upstreamPnpm, basePnpm) {
4204
4608
  }
4205
4609
  return { merged, changed };
4206
4610
  }
4611
+ function mergeModuleType(forkType, upstreamType, baseType) {
4612
+ const asString = (value) => typeof value === "string" ? value : void 0;
4613
+ const fork = asString(forkType);
4614
+ const upstream = asString(upstreamType);
4615
+ if (fork === upstream) return null;
4616
+ if (fork === void 0) return { value: upstream, change: "added" };
4617
+ if (fork !== asString(baseType)) return null;
4618
+ return { value: upstream, change: upstream === void 0 ? "removed" : "updated" };
4619
+ }
4620
+ function setTopLevelKey(pkg, key, value) {
4621
+ if (key in pkg) {
4622
+ pkg[key] = value;
4623
+ return;
4624
+ }
4625
+ const entries = Object.entries(pkg);
4626
+ const after = Math.max(...["name", "version", "private"].map((name) => entries.findIndex(([k]) => k === name)));
4627
+ entries.splice(after + 1, 0, [key, value]);
4628
+ for (const name of Object.keys(pkg)) delete pkg[name];
4629
+ Object.assign(pkg, Object.fromEntries(entries));
4630
+ }
4207
4631
  function readPackageJson(filePath) {
4208
- if (!existsSync9(filePath)) return null;
4632
+ if (!existsSync11(filePath)) return null;
4209
4633
  try {
4210
4634
  return JSON.parse(readFileSync6(filePath, "utf-8"));
4211
4635
  } catch {
@@ -4243,13 +4667,13 @@ async function syncPackageJson(config, baseRef, usage, relativePath, keysToSync)
4243
4667
  const changes = [];
4244
4668
  const kept = [];
4245
4669
  const pkgRelPath = packageJsonPath(relativePath);
4246
- const pkgPath = join15(forkPath, pkgRelPath);
4670
+ const pkgPath = join18(forkPath, pkgRelPath);
4247
4671
  const forkPkg = readPackageJson(pkgPath);
4248
4672
  const upstream = await readPackageJsonAtRef(forkPath, upstreamRef, relativePath);
4249
4673
  const basePkg = baseRef ? (await readPackageJsonAtRef(forkPath, baseRef, relativePath))?.data : void 0;
4250
4674
  if (!upstream) return { updated: false, changes, kept };
4251
4675
  if (!forkPkg) {
4252
- const workspaceArrived = relativePath !== "" && existsSync9(join15(forkPath, relativePath));
4676
+ const workspaceArrived = relativePath !== "" && existsSync11(join18(forkPath, relativePath));
4253
4677
  if (basePkg || !workspaceArrived || isIgnored(pkgRelPath, config)) return { updated: false, changes, kept };
4254
4678
  writeFileSync4(pkgPath, upstream.raw.endsWith("\n") ? upstream.raw : `${upstream.raw}
4255
4679
  `, "utf-8");
@@ -4257,6 +4681,13 @@ async function syncPackageJson(config, baseRef, usage, relativePath, keysToSync)
4257
4681
  }
4258
4682
  const upstreamPkg = upstream.data;
4259
4683
  let updated = false;
4684
+ const type = mergeModuleType(forkPkg.type, upstreamPkg.type, basePkg?.type);
4685
+ if (type) {
4686
+ if (type.value === void 0) delete forkPkg.type;
4687
+ else setTopLevelKey(forkPkg, "type", type.value);
4688
+ updated = true;
4689
+ changes.push(`type: ${type.change}`);
4690
+ }
4260
4691
  for (const key of keysToSync) {
4261
4692
  if (key === "pnpm") {
4262
4693
  const result2 = safeMergePnpm(forkPkg.pnpm, upstreamPkg.pnpm, basePkg?.pnpm);
@@ -4364,11 +4795,17 @@ async function runPackages(config, options = {}) {
4364
4795
  }
4365
4796
 
4366
4797
  // src/services/sync.ts
4367
- async function ensureBaseUpToDate(forkPath, base) {
4368
- const { upstream, ahead, behind } = await getUpstreamStatus(forkPath);
4798
+ async function resolveCycleStart(forkPath, base, currentBranch) {
4799
+ if (!await branchExists(forkPath, base)) {
4800
+ await fetch2(forkPath, "origin").catch(() => {
4801
+ });
4802
+ console.info(colors_default.dim(`'${base}' has no local branch \u2014 cutting from 'origin/${base}'.`));
4803
+ return `origin/${base}`;
4804
+ }
4805
+ const { upstream, ahead, behind } = await getUpstreamStatus(forkPath, base);
4369
4806
  if (!upstream) {
4370
4807
  console.info(colors_default.dim(`'${base}' has no upstream \u2014 skipping the up-to-date check.`));
4371
- return;
4808
+ return base;
4372
4809
  }
4373
4810
  if (ahead > 0 && behind > 0) {
4374
4811
  throw new Error(
@@ -4377,26 +4814,53 @@ Reconcile '${base}' with '${upstream}' first (e.g. rebase or reset it), then re-
4377
4814
  );
4378
4815
  }
4379
4816
  if (behind > 0) {
4817
+ const worktree = currentBranch === base ? null : await getBranchWorktree(forkPath, base);
4818
+ if (worktree) {
4819
+ console.info(
4820
+ colors_default.dim(`'${base}' is ${behind} behind '${upstream}' but checked out at ${worktree} \u2014 leaving it as it is.`)
4821
+ );
4822
+ return upstream;
4823
+ }
4380
4824
  console.info(colors_default.dim(`fast-forwarding '${base}' to '${upstream}' (${behind} behind)...`));
4381
- await pullFastForward(forkPath);
4382
- return;
4825
+ if (currentBranch === base) await pullFastForward(forkPath);
4826
+ else await fastForwardBranch(forkPath, base, upstream);
4827
+ return base;
4383
4828
  }
4384
4829
  if (ahead > 0) {
4385
4830
  console.info(colors_default.dim(`'${base}' is ${ahead} commit(s) ahead of '${upstream}' (unpushed) \u2014 continuing.`));
4386
4831
  }
4832
+ return base;
4387
4833
  }
4388
4834
  async function setupTemporarySyncBranch(config) {
4389
4835
  const { forkPath, settings } = config;
4390
4836
  const base = resolveReleaseBase(settings);
4391
4837
  const startBranch = await getCurrentBranch(forkPath);
4392
- console.info(colors_default.dim(`switching to '${base}' and updating...`));
4393
- await switchBranch(forkPath, base);
4394
- await ensureBaseUpToDate(forkPath, base);
4838
+ const startCommit = (await getCommitInfo(forkPath, "HEAD")).hash;
4839
+ console.info(colors_default.dim(`updating '${base}'...`));
4840
+ const startPoint = await resolveCycleStart(forkPath, base, startBranch);
4395
4841
  const temporaryBranch = buildTemporarySyncBranch();
4396
- console.info(colors_default.dim(`creating temporary sync branch '${temporaryBranch}' from '${base}'...`));
4842
+ console.info(colors_default.dim(`creating temporary sync branch '${temporaryBranch}' from '${startPoint}'...`));
4397
4843
  console.info();
4398
- await createBranchFrom(forkPath, temporaryBranch, base);
4399
- return { temporaryBranch, base, startBranch };
4844
+ await createBranchFrom(forkPath, temporaryBranch, startPoint);
4845
+ return { temporaryBranch, base, startBranch, startCommit };
4846
+ }
4847
+ async function resolveCutBase(forkPath, base) {
4848
+ if (!await branchExists(forkPath, base)) return `origin/${base}`;
4849
+ const upstream = await getBranchUpstream(forkPath, base);
4850
+ return upstream && await isAncestor(forkPath, base, upstream) ? upstream : base;
4851
+ }
4852
+ async function returnToBase(forkPath, base) {
4853
+ const worktree = await getBranchWorktree(forkPath, base);
4854
+ if (worktree) {
4855
+ console.info(colors_default.dim(`'${base}' is checked out at ${worktree} \u2014 detaching at '${base}' here instead...`));
4856
+ await switchDetached(forkPath, base);
4857
+ return `detached at '${base}'`;
4858
+ }
4859
+ console.info(colors_default.dim(`switching back to '${base}'...`));
4860
+ await switchBranch(forkPath, base);
4861
+ await pullFastForward(forkPath).catch(() => {
4862
+ });
4863
+ return `back on '${base}'`;
4400
4864
  }
4401
4865
  async function runSync(config, options) {
4402
4866
  createSpinner("starting sync...");
@@ -4427,6 +4891,7 @@ async function runSync(config, options) {
4427
4891
  const stagedBranch = options?.stagedBranch && result.conflicts.length === 0 && hasStagedSyncChanges(result) ? options.stagedBranch : void 0;
4428
4892
  printSyncComplete(result, { stagedBranch });
4429
4893
  printFlagWarnings({ hard: config.hard, unpinned: config.unpinned });
4894
+ await printMigrationNotesLine(config, result);
4430
4895
  return result;
4431
4896
  }
4432
4897
  var SYNC_PR_TITLE = "chore: sync upstream cella";
@@ -4443,7 +4908,7 @@ function qualifyPrRefs(subject, repoSlug) {
4443
4908
  return subject.replace(/(^|[^\w/])#(\d+)\b/g, `$1${repoSlug}#$2`);
4444
4909
  }
4445
4910
  function buildSyncPrBody(input) {
4446
- const { repoSlug, version, fromSha, toSha, commits, totalCount } = input;
4911
+ const { repoSlug, version, fromSha, toSha, commits, totalCount, notes } = input;
4447
4912
  const githubUrl = repoSlug ? `https://github.com/${repoSlug}` : void 0;
4448
4913
  const short = (sha) => sha.slice(0, 7);
4449
4914
  const commitRef = (sha) => githubUrl ? `[\`${short(sha)}\`](${githubUrl}/commit/${sha})` : `\`${short(sha)}\``;
@@ -4458,6 +4923,19 @@ function buildSyncPrBody(input) {
4458
4923
  lines.push(`- ${commitRef(commit.hash)} ${qualifyPrRefs(commit.message, repoSlug)}`);
4459
4924
  }
4460
4925
  }
4926
+ if (notes && notes.total > 0) {
4927
+ const { total, open } = notes;
4928
+ if (open.length === 0) lines.push("", `**Migration notes**: all ${total} handled.`);
4929
+ else {
4930
+ lines.push(
4931
+ "",
4932
+ `**Migration notes**: ${Math.max(0, total - open.length)} of ${total} handled, open (\`pnpm cella migrate\`):`,
4933
+ ""
4934
+ );
4935
+ for (const note of open)
4936
+ lines.push(`- ${note.url ? `[${note.title}](${note.url})` : note.title} (\`${note.id}\`)`);
4937
+ }
4938
+ }
4461
4939
  return `${lines.join("\n")}
4462
4940
  `;
4463
4941
  }
@@ -4473,7 +4951,20 @@ async function buildSyncPrBodyForBranch(forkPath, base) {
4473
4951
  skip: totalCount > PR_BODY_COMMIT_MAX ? totalCount - PR_BODY_COMMIT_MAX : 0,
4474
4952
  limit: PR_BODY_COMMIT_MAX
4475
4953
  }) : [];
4476
- return buildSyncPrBody({ repoSlug: manifest.upstream.repo, version, fromSha, toSha, commits, totalCount });
4954
+ const notes = await readPrBodyNotes(forkPath, toSha, manifest.upstream.repo).catch(() => void 0);
4955
+ return buildSyncPrBody({ repoSlug: manifest.upstream.repo, version, fromSha, toSha, commits, totalCount, notes });
4956
+ }
4957
+ async function readPrBodyNotes(forkPath, toSha, repoSlug) {
4958
+ const githubUrl = repoSlug ? `https://github.com/${repoSlug}` : void 0;
4959
+ const [ids, pending] = await Promise.all([listNoteIds(forkPath, toSha), readPending(forkPath, toSha)]);
4960
+ const open = await Promise.all(
4961
+ pending.map(async (id) => ({
4962
+ id,
4963
+ title: (await readNote(forkPath, toSha, id))?.title ?? id,
4964
+ url: noteUrl(githubUrl, toSha, id)
4965
+ }))
4966
+ );
4967
+ return { total: ids.length, open };
4477
4968
  }
4478
4969
  function printPrCreateStep(branch, base, title = SYNC_PR_TITLE) {
4479
4970
  console.info(colors_default.dim(` gh pr create --base ${base} --head ${branch} --title "${title}" --fill`));
@@ -4507,22 +4998,13 @@ async function flattenSyncBranch(forkPath, branch, base) {
4507
4998
  function indentLines(text) {
4508
4999
  return text.split("\n").map((line) => ` ${line}`).join("\n");
4509
5000
  }
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
5001
  async function shipSyncBranch(config, branch) {
4520
5002
  const { forkPath, settings } = config;
4521
5003
  const base = resolveReleaseBase(settings);
4522
- const flattened = await flattenSyncBranch(forkPath, branch, base);
5004
+ const cutBase = await resolveCutBase(forkPath, base);
5005
+ const flattened = await flattenSyncBranch(forkPath, branch, cutBase);
4523
5006
  let prUrl;
4524
5007
  let prOpened = false;
4525
- let autoMergeEnabled = false;
4526
5008
  const headSubject = (await getCommitInfo(forkPath, "HEAD").catch(() => null))?.message;
4527
5009
  const prTitle = headSubject?.startsWith(SYNC_PR_TITLE) ? headSubject : SYNC_PR_TITLE;
4528
5010
  console.info(colors_default.dim(`pushing '${branch}' to origin...`));
@@ -4536,7 +5018,7 @@ async function shipSyncBranch(config, branch) {
4536
5018
  }
4537
5019
  if (ghAvailable()) {
4538
5020
  console.info(colors_default.dim("opening a pull request..."));
4539
- const prBody = await buildSyncPrBodyForBranch(forkPath, base);
5021
+ const prBody = await buildSyncPrBodyForBranch(forkPath, cutBase);
4540
5022
  const bodyArgs = prBody ? ["--body", prBody] : ["--fill"];
4541
5023
  const pr = spawnSync3("gh", ["pr", "create", "--base", base, "--head", branch, "--title", prTitle, ...bodyArgs], {
4542
5024
  cwd: forkPath,
@@ -4546,9 +5028,7 @@ async function shipSyncBranch(config, branch) {
4546
5028
  prUrl = extractFirstUrl(`${pr.stdout ?? ""}
4547
5029
  ${pr.stderr ?? ""}`);
4548
5030
  prOpened = pr.status === 0;
4549
- if (prOpened) {
4550
- if (config.directMerge) autoMergeEnabled = enableAutoMerge(forkPath, branch);
4551
- } else {
5031
+ if (!prOpened) {
4552
5032
  console.info(colors_default.yellow("could not open the PR automatically (it may already exist). open it with:"));
4553
5033
  if (prUrl) console.info(colors_default.dim(` ${prUrl}`));
4554
5034
  printPrCreateStep(branch, base, prTitle);
@@ -4557,19 +5037,16 @@ ${pr.stderr ?? ""}`);
4557
5037
  console.info(colors_default.yellow("`gh` not found \u2014 open the PR manually:"));
4558
5038
  printPrCreateStep(branch, base, prTitle);
4559
5039
  }
4560
- console.info(colors_default.dim(`switching back to '${base}'...`));
4561
- await switchBranch(forkPath, base);
4562
- await pullFastForward(forkPath).catch(() => {
4563
- });
5040
+ const position = await returnToBase(forkPath, base);
4564
5041
  console.info();
4565
5042
  if (prUrl) {
4566
5043
  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}`));
5044
+ console.info(colors_default.dim(` ${prUrl} \xB7 branch pushed, ${position}`));
4569
5045
  } else {
4570
5046
  console.info(`${colors_default.green("\u2713")} Sync branch pushed`);
4571
- console.info(colors_default.dim(` '${branch}' is on origin, back on '${base}'`));
5047
+ console.info(colors_default.dim(` '${branch}' is on origin, ${position}`));
4572
5048
  }
5049
+ await printMigrationNotesLine(config);
4573
5050
  }
4574
5051
  function finalizeWorkspace(forkPath) {
4575
5052
  for (const args of [["install"], ["check"]]) {
@@ -4587,8 +5064,9 @@ async function runSyncCycle(config) {
4587
5064
  await runPackages(config, { conflictedFiles: result.conflicts });
4588
5065
  }
4589
5066
  if (result.conflicts.length > 0) return { status: "conflicts", branch };
4590
- if (!mergeInProgress(forkPath)) {
4591
- await switchBranch(forkPath, branch.startBranch === branch.temporaryBranch ? branch.base : branch.startBranch);
5067
+ if (!await mergeInProgress(forkPath)) {
5068
+ if (branch.startBranch === "HEAD") await switchDetached(forkPath, branch.startCommit);
5069
+ else await switchBranch(forkPath, branch.startBranch);
4592
5070
  await deleteBranch(forkPath, branch.temporaryBranch);
4593
5071
  return { status: "noop" };
4594
5072
  }
@@ -4618,6 +5096,7 @@ async function commitSyncMerge(config, branch) {
4618
5096
  console.info();
4619
5097
  console.info(colors_default.green(`committed the sync on '${branch}' as '${message}'.`));
4620
5098
  printTriageSteps(branch);
5099
+ await printMigrationNotesLine(config);
4621
5100
  }
4622
5101
  function printTriageSteps(branch) {
4623
5102
  console.info(colors_default.dim(`staying on '${branch}' without pushing.`));
@@ -4645,9 +5124,6 @@ async function mergeOpenSyncPrs(config, open) {
4645
5124
  closePr(forkPath, pr.number);
4646
5125
  await deleteBranch(forkPath, pr.headRefName);
4647
5126
  }
4648
- console.info(colors_default.dim(`switching to '${base}' and fast-forwarding...`));
4649
- await switchBranch(forkPath, base);
4650
- await pullFastForward(forkPath);
4651
5127
  return "continue";
4652
5128
  }
4653
5129
  async function guardAgainstOpenSyncPr(config) {
@@ -4686,7 +5162,7 @@ async function runSyncCommand(config) {
4686
5162
  const { forkPath } = config;
4687
5163
  const currentBranch = await getCurrentBranch(forkPath);
4688
5164
  const onSyncBranch = isTemporarySyncBranch(currentBranch);
4689
- if (onSyncBranch && mergeInProgress(forkPath)) {
5165
+ if (onSyncBranch && await mergeInProgress(forkPath)) {
4690
5166
  await commitSyncMerge(config, currentBranch);
4691
5167
  return;
4692
5168
  }
@@ -4809,7 +5285,7 @@ async function runForks(config) {
4809
5285
  console.error(colors_default.red(`fork '${config.fork}' not found in config`));
4810
5286
  return;
4811
5287
  }
4812
- const resolvedPath = resolve5(await resolveForkBasePath(config.forkPath), match.localPath);
5288
+ const resolvedPath = resolve7(await resolveForkBasePath(config.forkPath), match.localPath);
4813
5289
  await syncFork(config, match, resolvedPath);
4814
5290
  return;
4815
5291
  }
@@ -4825,7 +5301,7 @@ async function runForks(config) {
4825
5301
  if (selectedPath === "_exit") {
4826
5302
  process8.exit(0);
4827
5303
  }
4828
- const resolvedForkPath = resolve5(forkBasePath, selectedPath);
5304
+ const resolvedForkPath = resolve7(forkBasePath, selectedPath);
4829
5305
  const selectedFork = forks.find((f) => f.localPath === selectedPath);
4830
5306
  if (!selectedFork) {
4831
5307
  console.error(colors_default.red(`fork '${selectedPath}' not found in config`));
@@ -4837,8 +5313,8 @@ async function runForks(config) {
4837
5313
  }
4838
5314
 
4839
5315
  // src/services/stats.ts
4840
- import { readFile as readFile2 } from "fs/promises";
4841
- import { join as join17 } from "path";
5316
+ import { readFile as readFile3 } from "fs/promises";
5317
+ import { join as join20 } from "path";
4842
5318
 
4843
5319
  // src/utils/coverage-utils.ts
4844
5320
  import { spawnSync as spawnSync4 } from "child_process";
@@ -4932,7 +5408,7 @@ function printCoverageSummary(forkPath, options = {}) {
4932
5408
  // src/services/stats.ts
4933
5409
  var sourceExtensions = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs", "json", "jsonc", "css", "html"]);
4934
5410
  async function getWorkspacePackages(forkPath) {
4935
- const content = await readFile2(join17(forkPath, "pnpm-workspace.yaml"), "utf-8");
5411
+ const content = await readFile3(join20(forkPath, "pnpm-workspace.yaml"), "utf-8");
4936
5412
  return parseYamlBlockList(content, "packages").map((pattern) => {
4937
5413
  if (pattern.endsWith("/*")) {
4938
5414
  const base = pattern.slice(0, -2);
@@ -5095,20 +5571,20 @@ async function runStats(forkPath, options = {}) {
5095
5571
  function getForkPath() {
5096
5572
  const envPath = getEnv("CELLA_FORK_PATH");
5097
5573
  if (envPath) {
5098
- const resolved = resolve6(envPath);
5099
- if (!existsSync11(resolved) || !statSync3(resolved).isDirectory()) {
5574
+ const resolved = resolve8(envPath);
5575
+ if (!existsSync13(resolved) || !statSync3(resolved).isDirectory()) {
5100
5576
  throw new Error(`CELLA_FORK_PATH is not a valid directory: ${resolved}`);
5101
5577
  }
5102
5578
  return resolved;
5103
5579
  }
5104
5580
  let cwd = process9.cwd();
5105
5581
  if (cwd.endsWith("/cli/cella") || cwd.endsWith("\\cli\\cella")) {
5106
- cwd = resolve6(cwd, "../..");
5582
+ cwd = resolve8(cwd, "../..");
5107
5583
  }
5108
5584
  return cwd;
5109
5585
  }
5110
5586
  async function preflight(forkPath) {
5111
- if (!existsSync11(join18(forkPath, ".git"))) {
5587
+ if (!existsSync13(join21(forkPath, ".git"))) {
5112
5588
  throw new Error(`not a git repository: ${forkPath}`);
5113
5589
  }
5114
5590
  }
@@ -5137,6 +5613,9 @@ async function main() {
5137
5613
  await runSyncCommand(config);
5138
5614
  break;
5139
5615
  }
5616
+ case "migrate":
5617
+ await runMigrate(config);
5618
+ break;
5140
5619
  case "audit":
5141
5620
  await runAudit(config, { force: config.force, checkOverrides: config.checkOverrides });
5142
5621
  break;