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