@cellajs/cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,4216 @@
1
+ #!/usr/bin/env tsx
2
+
3
+ // src/cella-cli.ts
4
+ import { existsSync as existsSync11, statSync as statSync2 } from "fs";
5
+ import { join as join16, resolve as resolve6 } from "path";
6
+ import process8 from "process";
7
+
8
+ // src/cli.ts
9
+ import { readFileSync } from "fs";
10
+ import { join as join4 } from "path";
11
+ import process4 from "process";
12
+ import { select } from "@inquirer/prompts";
13
+ import { Command } from "commander";
14
+
15
+ // src/utils/colors.ts
16
+ import process2 from "process";
17
+ import { styleText } from "util";
18
+ function colorsEnabled() {
19
+ if ("NO_COLOR" in process2.env) return false;
20
+ if ("FORCE_COLOR" in process2.env) return true;
21
+ return !!process2.stdout.isTTY;
22
+ }
23
+ function applyStyle(style, text) {
24
+ if (!colorsEnabled()) return text;
25
+ return styleText(style, text, { validateStream: false });
26
+ }
27
+ var pc = {
28
+ blue: (text) => applyStyle("blue", text),
29
+ bold: (text) => applyStyle("bold", text),
30
+ cyan: (text) => applyStyle("cyan", text),
31
+ dim: (text) => applyStyle("dim", text),
32
+ gray: (text) => applyStyle("gray", text),
33
+ green: (text) => applyStyle("green", text),
34
+ magenta: (text) => applyStyle("magenta", text),
35
+ red: (text) => applyStyle("red", text),
36
+ white: (text) => applyStyle("white", text),
37
+ yellow: (text) => applyStyle("yellow", text)
38
+ };
39
+ var colors_default = pc;
40
+
41
+ // src/utils/config.ts
42
+ import { existsSync, realpathSync } from "fs";
43
+ import { join, resolve } from "path";
44
+ import { z as z2 } from "zod";
45
+
46
+ // src/config/types.ts
47
+ import { z } from "zod";
48
+ var cellaConfigSchema = z.object({
49
+ settings: z.object({
50
+ upstreamUrl: z.string().min(1),
51
+ upstreamBranch: z.string().min(1).optional(),
52
+ upstreamTrack: z.enum(["release", "branch"]).optional(),
53
+ releaseBase: z.string().min(1).optional(),
54
+ packageJsonSync: z.array(
55
+ z.enum([
56
+ "dependencies",
57
+ "devDependencies",
58
+ "peerDependencies",
59
+ "optionalDependencies",
60
+ "scripts",
61
+ "engines",
62
+ "packageManager",
63
+ "overrides",
64
+ "pnpm"
65
+ ])
66
+ ).optional(),
67
+ syncWithPackages: z.boolean().optional(),
68
+ fileLinkMode: z.enum(["commit", "file", "local"]).optional()
69
+ }).strict(),
70
+ overrides: z.object({
71
+ ignored: z.array(z.string()).optional(),
72
+ pinned: z.array(z.string()).optional()
73
+ }).strict().optional(),
74
+ forks: z.array(
75
+ z.object({
76
+ name: z.string().min(1),
77
+ localPath: z.string().min(1),
78
+ remoteUrl: z.string().optional(),
79
+ pullBranch: z.string().min(1)
80
+ }).strict()
81
+ ).optional()
82
+ }).strict();
83
+
84
+ // src/utils/module-territory.ts
85
+ import { relative, sep } from "path";
86
+ import { Project, SyntaxKind } from "ts-morph";
87
+ var MODULE_FILE_GLOBS = ["backend/src/modules/*/*-module.ts", "frontend/src/modules/*/*-module.ts"];
88
+ function readOwner(call) {
89
+ const arg = call.getArguments()[0]?.asKind(SyntaxKind.ObjectLiteralExpression);
90
+ const initializer = arg?.getProperty("owner")?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
91
+ return initializer?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
92
+ }
93
+ function resolveAppModuleFolders(repoPath) {
94
+ const project = new Project({
95
+ skipAddingFilesFromTsConfig: true,
96
+ skipFileDependencyResolution: true,
97
+ skipLoadingLibFiles: true
98
+ });
99
+ const sourceFiles = project.addSourceFilesAtPaths(MODULE_FILE_GLOBS.map((glob) => `${repoPath}/${glob}`));
100
+ const folders = /* @__PURE__ */ new Set();
101
+ for (const sourceFile of sourceFiles) {
102
+ const ownsApp = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).some((call) => call.getExpression().getText() === "registerModule" && readOwner(call) === "app");
103
+ if (!ownsApp) continue;
104
+ const folder = relative(repoPath, sourceFile.getDirectoryPath()).split(sep).join("/");
105
+ folders.add(folder);
106
+ }
107
+ return [...folders];
108
+ }
109
+
110
+ // src/utils/config.ts
111
+ var DEFAULT_UPSTREAM_REMOTE = "cella-upstream";
112
+ var DEFAULT_BRANCH = "main";
113
+ var DEFAULT_SYNC_PREFIX = "cella/sync";
114
+ var DEFAULT_RELEASE_BASE = "main";
115
+ function isTemporarySyncBranch(branch) {
116
+ return branch.startsWith(`${DEFAULT_SYNC_PREFIX}/`);
117
+ }
118
+ function resolveReleaseBase(settings) {
119
+ return settings.releaseBase ?? DEFAULT_RELEASE_BASE;
120
+ }
121
+ function buildTemporarySyncBranch() {
122
+ const now = /* @__PURE__ */ new Date();
123
+ const pad2 = (n) => String(n).padStart(2, "0");
124
+ const stamp = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}-${pad2(now.getHours())}${pad2(now.getMinutes())}`;
125
+ return `${DEFAULT_SYNC_PREFIX}/${stamp}`;
126
+ }
127
+ function resolveUpstream(settings) {
128
+ const track = settings.upstreamTrack ?? "release";
129
+ const branch = settings.upstreamBranch ?? DEFAULT_BRANCH;
130
+ const branchRef = `${DEFAULT_UPSTREAM_REMOTE}/${branch}`;
131
+ return { track, branchRef };
132
+ }
133
+ async function loadConfig(forkPath) {
134
+ const configPath = join(forkPath, "cella.config.ts");
135
+ if (!existsSync(configPath)) {
136
+ throw new Error(`config file not found: ${configPath}`);
137
+ }
138
+ const realConfigPath = realpathSync(configPath);
139
+ const realForkPath = realpathSync(resolve(forkPath));
140
+ if (!realConfigPath.startsWith(`${realForkPath}/`)) {
141
+ throw new Error("config path resolves outside fork directory");
142
+ }
143
+ let configModule;
144
+ try {
145
+ configModule = await import(realConfigPath);
146
+ } catch (error) {
147
+ const message = error instanceof Error ? error.message : String(error);
148
+ throw new Error(`failed to load ${configPath}: ${message}`);
149
+ }
150
+ const result = cellaConfigSchema.safeParse(configModule.default);
151
+ if (!result.success) {
152
+ throw new Error(`invalid ${configPath}:
153
+ ${z2.prettifyError(result.error)}`);
154
+ }
155
+ const config = result.data;
156
+ const appModuleFolders = resolveAppModuleFolders(realForkPath);
157
+ if (appModuleFolders.length > 0) {
158
+ const existing = config.overrides?.ignored ?? [];
159
+ config.overrides = {
160
+ ...config.overrides,
161
+ ignored: [.../* @__PURE__ */ new Set([...existing, ...appModuleFolders])]
162
+ };
163
+ }
164
+ return config;
165
+ }
166
+
167
+ // src/utils/display.ts
168
+ import { spawnSync } from "child_process";
169
+ import { existsSync as existsSync2, writeFileSync } from "fs";
170
+ import { join as join2, resolve as resolve2 } from "path";
171
+ import process3 from "process";
172
+
173
+ // package.json
174
+ var package_default = {
175
+ name: "@cellajs/cli",
176
+ version: "0.0.1",
177
+ private: false,
178
+ license: "MIT",
179
+ description: "CLI for syncing and managing apps with upstream Cella.",
180
+ repository: {
181
+ type: "git",
182
+ url: "https://github.com/cellajs/cella-cli"
183
+ },
184
+ homepage: "https://cellajs.com",
185
+ author: "CellaJS <info@cellajs.com>",
186
+ engines: {
187
+ node: "24.x"
188
+ },
189
+ type: "module",
190
+ main: "./dist/index.js",
191
+ exports: {
192
+ ".": "./dist/index.js",
193
+ "./config": {
194
+ types: "./dist/config.d.ts",
195
+ import: "./dist/config.js"
196
+ },
197
+ "./package.json": "./package.json"
198
+ },
199
+ files: [
200
+ "dist",
201
+ "index.js",
202
+ "README.md"
203
+ ],
204
+ bin: {
205
+ cella: "index.js"
206
+ },
207
+ imports: {
208
+ "#/*": "./src/*"
209
+ },
210
+ scripts: {
211
+ start: "tsx index.ts",
212
+ clean: "rimraf ./dist",
213
+ build: "tsup",
214
+ "test-build": "pnpm run build && node index.js --help",
215
+ prepublishOnly: "pnpm run test:release",
216
+ check: "pnpm ts && pnpm biome check --write .",
217
+ ts: "tsgo --pretty",
218
+ lint: "biome check .",
219
+ "lint:fix": "biome check --write .",
220
+ test: "vitest run",
221
+ "test:release": "pnpm run build && vitest run --config vitest.release.config.ts",
222
+ "test:watch": "vitest",
223
+ prepare: "lefthook install"
224
+ },
225
+ dependencies: {
226
+ "@inquirer/core": "^11.2.1",
227
+ "@inquirer/prompts": "^8.5.2",
228
+ commander: "^15.0.0",
229
+ "ts-morph": "^28.0.0",
230
+ zod: "^4.4.3"
231
+ },
232
+ devDependencies: {
233
+ "@biomejs/biome": "^2.5.2",
234
+ "@commitlint/cli": "^21.2.0",
235
+ "@commitlint/config-conventional": "^21.2.0",
236
+ "@evilmartians/lefthook": "^2.1.9",
237
+ "@types/node": "24.10.13",
238
+ "@typescript/native-preview": "7.0.0-dev.20260702.3",
239
+ rimraf: "^6.1.3",
240
+ tsup: "^8.5.1",
241
+ tsx: "^4.22.4",
242
+ typescript: "^6.0.3",
243
+ vitest: "^4.1.9"
244
+ },
245
+ publishConfig: {
246
+ access: "public"
247
+ },
248
+ keywords: [
249
+ "cella",
250
+ "cellajs",
251
+ "cli",
252
+ "sync",
253
+ "template"
254
+ ],
255
+ packageManager: "pnpm@11.8.0",
256
+ commitlint: {
257
+ extends: [
258
+ "@commitlint/config-conventional"
259
+ ],
260
+ rules: {
261
+ "type-enum": [
262
+ 2,
263
+ "always",
264
+ [
265
+ "feat",
266
+ "fix",
267
+ "perf",
268
+ "refactor",
269
+ "revert",
270
+ "docs",
271
+ "chore",
272
+ "build",
273
+ "ci",
274
+ "style",
275
+ "test"
276
+ ]
277
+ ],
278
+ "header-max-length": [
279
+ 2,
280
+ "always",
281
+ 120
282
+ ],
283
+ "body-max-line-length": [
284
+ 0,
285
+ "always",
286
+ 200
287
+ ],
288
+ "footer-max-line-length": [
289
+ 0,
290
+ "always",
291
+ 200
292
+ ]
293
+ }
294
+ }
295
+ };
296
+
297
+ // src/utils/managed-files.ts
298
+ function isPackageJson(filePath) {
299
+ return filePath === "package.json" || filePath.endsWith("/package.json");
300
+ }
301
+ function isManagedFile(filePath) {
302
+ return isPackageJson(filePath) || filePath === "pnpm-lock.yaml" || filePath === "cella.config.ts";
303
+ }
304
+
305
+ // src/utils/display.ts
306
+ var NAME = "cella cli";
307
+ var VERSION = package_default.version;
308
+ var DIVIDER = "\u2500".repeat(60);
309
+ var warningMark = colors_default.yellow("\u26A0");
310
+ var checkMark = colors_default.green("\u2713");
311
+ var TerminalSpinner = class {
312
+ constructor(text, isSilent) {
313
+ this.text = text;
314
+ this.enabled = !isSilent && !!process3.stdout.isTTY;
315
+ }
316
+ text;
317
+ frames = ["-", "\\", "|", "/"];
318
+ frameIndex = 0;
319
+ timer = null;
320
+ enabled;
321
+ start() {
322
+ if (!this.enabled || this.timer) return this;
323
+ this.render();
324
+ this.timer = setInterval(() => {
325
+ this.frameIndex = (this.frameIndex + 1) % this.frames.length;
326
+ this.render();
327
+ }, 80);
328
+ return this;
329
+ }
330
+ stop() {
331
+ if (this.timer) {
332
+ clearInterval(this.timer);
333
+ this.timer = null;
334
+ }
335
+ if (!this.enabled) return;
336
+ process3.stdout.write("\r\x1B[2K");
337
+ }
338
+ render() {
339
+ process3.stdout.write(`\r${colors_default.cyan(this.frames[this.frameIndex])} ${this.text}`);
340
+ }
341
+ };
342
+ var activeSpinner = null;
343
+ var jsonMode = false;
344
+ function setJsonMode(enabled) {
345
+ jsonMode = enabled;
346
+ if (!enabled) return;
347
+ const toStderr = (...args) => {
348
+ process3.stderr.write(`${args.map((a) => typeof a === "string" ? a : String(a)).join(" ")}
349
+ `);
350
+ };
351
+ console.info = toStderr;
352
+ console.log = toStderr;
353
+ console.warn = toStderr;
354
+ }
355
+ function writeStdout(text) {
356
+ process3.stdout.write(text.endsWith("\n") ? text : `${text}
357
+ `);
358
+ }
359
+ function getHeader() {
360
+ const right = "cellajs.com";
361
+ const visibleLeft = `\u29C8 ${NAME} v${VERSION}`;
362
+ const padding = Math.max(1, 60 - visibleLeft.length - right.length);
363
+ return `${colors_default.cyan(`\u29C8 ${NAME}`)}${colors_default.dim(` \xB7 v${VERSION}`)}${colors_default.cyan(`${" ".repeat(padding)}${right}`)}`;
364
+ }
365
+ function printHeader() {
366
+ console.info();
367
+ console.info(getHeader());
368
+ console.info(DIVIDER);
369
+ console.info();
370
+ }
371
+ function printStep(label, detail) {
372
+ console.info(`${checkMark} ${label}`);
373
+ if (detail) {
374
+ console.info(` ${colors_default.dim(detail)}`);
375
+ console.info();
376
+ }
377
+ }
378
+ function createSpinner(text) {
379
+ const isTestEnv = !!process3.env.VITEST || process3.env.NODE_ENV === "test";
380
+ activeSpinner = new TerminalSpinner(text, isTestEnv || jsonMode);
381
+ activeSpinner.start();
382
+ return activeSpinner;
383
+ }
384
+ function spinnerSuccess(message, detail) {
385
+ stopSpinner();
386
+ if (message) {
387
+ printStep(message, detail);
388
+ }
389
+ }
390
+ function spinnerFail(message) {
391
+ stopSpinner();
392
+ console.info(`${colors_default.red("\u2717")} ${message}`);
393
+ }
394
+ function spinnerText(text) {
395
+ if (activeSpinner) {
396
+ activeSpinner.text = text;
397
+ }
398
+ }
399
+ function stopSpinner() {
400
+ if (activeSpinner) {
401
+ activeSpinner.stop();
402
+ activeSpinner = null;
403
+ }
404
+ }
405
+ function showDiffInPager(diffOutput) {
406
+ if (diffOutput.length === 0) return;
407
+ const hasBat = spawnSync("which", ["bat"], { stdio: "pipe" }).status === 0;
408
+ process3.stdout.write("\x1B[?25h");
409
+ if (hasBat) {
410
+ spawnSync("bat", ["--language", "diff", "--paging", "always", "--style", "plain"], {
411
+ input: diffOutput,
412
+ stdio: ["pipe", "inherit", "inherit"]
413
+ });
414
+ } else {
415
+ spawnSync("less", ["-R"], {
416
+ input: diffOutput,
417
+ stdio: ["pipe", "inherit", "inherit"]
418
+ });
419
+ }
420
+ process3.stdout.write("\x1B[2J\x1B[0;0H");
421
+ }
422
+ function hyperlink(label, url) {
423
+ if (!url) return label;
424
+ const safeUrl = url.replace(/[\x00-\x1f\x7f]/g, "");
425
+ return `\x1B]8;;${safeUrl}\x07${label}\x1B]8;;\x07`;
426
+ }
427
+ function getVsCodeOpenFileLink(filePath, options) {
428
+ const { forkPath } = options;
429
+ if (!forkPath) return filePath;
430
+ const absolutePath = resolve2(forkPath, filePath);
431
+ const url = `vscode://file${absolutePath}`;
432
+ return hyperlink(filePath, url);
433
+ }
434
+ function formatFetchedUpstreamDetail(commitCount, commits, upstreamGitHubUrl, maxShownCommits = 50) {
435
+ const commitLabel = commitCount === 1 ? "1 new commit" : `${commitCount} new commits`;
436
+ const lines = [`${commitLabel} since last merge`];
437
+ const shown = commits.slice(0, maxShownCommits);
438
+ for (const commit of shown) {
439
+ const shortHash = commit.hash.slice(0, 7);
440
+ const commitUrl = upstreamGitHubUrl ? `${upstreamGitHubUrl}/commit/${commit.hash}` : void 0;
441
+ const hashLabel = hyperlink(shortHash, commitUrl);
442
+ lines.push(` ${hashLabel} "${commit.message}" (${commit.date})`);
443
+ }
444
+ if (commitCount > shown.length) {
445
+ lines.push(` ... and ${commitCount - shown.length} more`);
446
+ }
447
+ return lines.join("\n");
448
+ }
449
+ function getFileLink(filePath, commitHash, options) {
450
+ const { upstreamGitHubUrl, upstreamBranch, fileLinkMode = "commit", upstreamViewPath, forkPath } = options;
451
+ if (fileLinkMode === "local" && upstreamViewPath && forkPath) {
452
+ const absolutePath = resolve2(upstreamViewPath, filePath);
453
+ if (existsSync2(absolutePath)) {
454
+ const url = `vscode://file${absolutePath}`;
455
+ return { label: filePath.split("/").pop() || filePath, url };
456
+ }
457
+ }
458
+ if (!upstreamGitHubUrl) {
459
+ return { label: commitHash?.slice(0, 9) || "" };
460
+ }
461
+ if (fileLinkMode === "file" && upstreamBranch) {
462
+ const url = `${upstreamGitHubUrl}/blob/${upstreamBranch}/${filePath}`;
463
+ return { label: filePath.split("/").pop() || filePath, url };
464
+ }
465
+ if (commitHash) {
466
+ const url = `${upstreamGitHubUrl}/commit/${commitHash}`;
467
+ return { label: commitHash.slice(0, 9), url };
468
+ }
469
+ return { label: "" };
470
+ }
471
+ function printSectionHeader(title) {
472
+ console.info();
473
+ console.info(title);
474
+ console.info(DIVIDER);
475
+ console.info();
476
+ }
477
+ function formatFileDateInfo(filePath, commit, date, linkOptions) {
478
+ const link = getFileLink(filePath, commit, linkOptions);
479
+ const linkLabel = link.label ? hyperlink(link.label, link.url) : "";
480
+ return date ? colors_default.dim(` \u2260 ${date} ${linkLabel}`) : "";
481
+ }
482
+ function formatMergeInProgressDetail(conflictCount, autoMergedFiles, linkOptions, maxFiles = 100) {
483
+ const lines = [`${conflictCount} conflicts to resolve in IDE`];
484
+ if (autoMergedFiles.length === 0) {
485
+ return lines.join("\n");
486
+ }
487
+ lines.push("");
488
+ lines.push(` auto-merged files (${autoMergedFiles.length}):`);
489
+ const shown = autoMergedFiles.slice(0, maxFiles);
490
+ for (const filePath of shown) {
491
+ const fileLink = getVsCodeOpenFileLink(filePath, linkOptions);
492
+ lines.push(` - ${fileLink}`);
493
+ }
494
+ if (autoMergedFiles.length > shown.length) {
495
+ lines.push(` ... + ${autoMergedFiles.length - shown.length} more`);
496
+ }
497
+ return lines.join("\n");
498
+ }
499
+ var statusConfig = {
500
+ behind: { icon: colors_default.cyan("\u2193"), label: "behind", color: colors_default.cyan, description: "upstream changed, will sync" },
501
+ diverged: { icon: colors_default.magenta("\u21C5"), label: "diverged", color: colors_default.magenta, description: "both changed, will merge" },
502
+ drifted: { icon: colors_default.yellow("!"), label: "drifted", color: colors_default.yellow, description: "fork changed (at risk)" },
503
+ ahead: { icon: colors_default.blue("\u2191"), label: "ahead", color: colors_default.blue, description: "fork changed (protected)" },
504
+ local: { icon: colors_default.green("+"), label: "local", color: colors_default.green, description: "local file" },
505
+ pinned: { icon: colors_default.green("\u2A00"), label: "pinned", color: colors_default.green, description: "both changed, fork wins" },
506
+ ignored: { icon: colors_default.gray("\u2A02"), label: "ignored", color: colors_default.gray },
507
+ identical: { icon: colors_default.gray("\u2713"), label: "identical", color: colors_default.gray, description: "no changes" },
508
+ deleted: { icon: colors_default.red("\u2717"), label: "deleted", color: colors_default.red },
509
+ renamed: { icon: colors_default.blue("\u2192"), label: "renamed", color: colors_default.blue }
510
+ };
511
+ var summaryStatusConfig = {
512
+ ...statusConfig,
513
+ managed: {
514
+ icon: colors_default.cyan("\u25C7"),
515
+ label: "managed",
516
+ color: colors_default.cyan,
517
+ description: "package/config files handled separately"
518
+ },
519
+ ignored: { ...statusConfig.ignored, description: "protected by ignored config" }
520
+ };
521
+ var statusOrder = Object.keys(statusConfig);
522
+ function printSummary(summary, title = "summary") {
523
+ printSectionHeader(colors_default.cyan(title));
524
+ const maxCount = Math.max(
525
+ summary.managed,
526
+ summary.ignored,
527
+ summary.identical,
528
+ summary.ahead,
529
+ summary.local,
530
+ summary.drifted,
531
+ summary.behind,
532
+ summary.diverged,
533
+ summary.pinned,
534
+ summary.deleted,
535
+ summary.renamed
536
+ );
537
+ const countWidth = String(maxCount).length + 1;
538
+ const printLine = (status, count) => {
539
+ const { icon, label, color, description } = summaryStatusConfig[status];
540
+ const countStr = color(String(count).padStart(countWidth));
541
+ const desc = description ? colors_default.dim(description) : "";
542
+ console.info(` ${icon} ${countStr} ${label.padEnd(15)}${desc}`);
543
+ };
544
+ printLine("managed", summary.managed);
545
+ printLine("ignored", summary.ignored);
546
+ console.info();
547
+ printLine("identical", summary.identical);
548
+ console.info();
549
+ printLine("ahead", summary.ahead);
550
+ printLine("local", summary.local);
551
+ printLine("drifted", summary.drifted);
552
+ console.info();
553
+ printLine("diverged", summary.diverged);
554
+ printLine("pinned", summary.pinned);
555
+ console.info();
556
+ printLine("behind", summary.behind);
557
+ }
558
+ function printFileGroup(files, status, linkOptions, options) {
559
+ const filtered = files.filter((f) => f.status === status && !isManagedFile(f.path));
560
+ if (filtered.length === 0) return;
561
+ const config = statusConfig[status];
562
+ const title = options?.title ?? `${config.icon} ${config.label}`;
563
+ printSectionHeader(`${title} ${colors_default.dim(`\xB7 ${filtered.length} files`)}`);
564
+ const useUpstream = options?.dateSource === "upstream";
565
+ const maxLines = 100;
566
+ const shown = filtered.length > maxLines ? filtered.slice(0, maxLines) : filtered;
567
+ for (const file of shown) {
568
+ const commit = useUpstream ? file.upstreamCommit : file.changedCommit;
569
+ const date = useUpstream ? file.upstreamChangedAt : file.changedAt;
570
+ const dateInfo = formatFileDateInfo(file.path, commit, date, linkOptions);
571
+ console.info(` ${config.icon} ${file.path}${dateInfo}`);
572
+ }
573
+ if (filtered.length > maxLines) {
574
+ console.info(colors_default.dim(` ... + ${filtered.length - maxLines} more`));
575
+ }
576
+ if (options?.hint) {
577
+ console.info();
578
+ console.info(colors_default.dim(` ${options.hint}`));
579
+ }
580
+ }
581
+ function printAnalysisFileGroups(files, linkOptions) {
582
+ printFileGroup(files, "behind", linkOptions, {
583
+ title: colors_default.cyan("\u2193 behind on upstream")
584
+ });
585
+ printFileGroup(files, "ahead", linkOptions, {
586
+ title: `${colors_default.blue("\u2191 protected in fork")}`,
587
+ hint: "these files have fork changes and are protected by pinned or ignored config."
588
+ });
589
+ printFileGroup(files, "drifted", linkOptions, {
590
+ title: `${warningMark} ${colors_default.yellow("drifted from upstream")}`,
591
+ hint: "these files have fork changes but are not pinned or ignored."
592
+ });
593
+ printFileGroup(files, "diverged", linkOptions, {
594
+ title: `${colors_default.magenta("\u21C5 diverged")}`,
595
+ hint: "both fork and upstream changed.",
596
+ dateSource: "upstream"
597
+ });
598
+ printFileGroup(files, "pinned", linkOptions, {
599
+ title: `${colors_default.green("\u2A00 pinned")}`,
600
+ hint: "both changed, fork wins (pinned in cella.config.ts).",
601
+ dateSource: "upstream"
602
+ });
603
+ }
604
+ function writeLogFile(forkPath, files) {
605
+ const logPath = join2(forkPath, "cella-sync.log");
606
+ const lines = [
607
+ `cella sync analysis - ${(/* @__PURE__ */ new Date()).toISOString()}`,
608
+ DIVIDER,
609
+ "",
610
+ `complete file list (${files.length} files)`,
611
+ DIVIDER,
612
+ ""
613
+ ];
614
+ const sortedFiles = [...files].sort((a, b) => {
615
+ const aOrder = statusOrder.indexOf(a.status);
616
+ const bOrder = statusOrder.indexOf(b.status);
617
+ if (aOrder !== bOrder) return aOrder - bOrder;
618
+ return a.path.localeCompare(b.path);
619
+ });
620
+ for (const file of sortedFiles) {
621
+ const config = statusConfig[file.status];
622
+ const icon = config.icon.replace(/\x1b\[[0-9;]*m/g, "");
623
+ lines.push(` ${icon} ${config.label.padEnd(12)} ${file.path}`);
624
+ }
625
+ writeFileSync(logPath, lines.join("\n"), "utf-8");
626
+ return logPath;
627
+ }
628
+ function printSyncComplete(result) {
629
+ const updated = result.files.filter((file) => file.status === "behind" || file.status === "diverged").length;
630
+ const diverged = result.files.filter((file) => file.status === "diverged").length;
631
+ const merged = result.autoMergedFiles?.length ?? Math.max(0, diverged - result.conflicts.length);
632
+ const conflicts = result.conflicts.length;
633
+ console.info();
634
+ console.info(`${colors_default.green("\u2713")} sync complete`);
635
+ console.info(colors_default.dim(` ${updated} files updated, ${merged} auto-merged, ${conflicts} conflicts`));
636
+ console.info();
637
+ }
638
+ function printFlagWarnings(options) {
639
+ const { hard, unpinned } = options;
640
+ if (!hard && !unpinned) return;
641
+ if (hard) {
642
+ console.info(colors_default.yellow("\u26A0 --hard used: drifted files were treated as behind, overwriting with incoming."));
643
+ }
644
+ if (unpinned) {
645
+ console.info(
646
+ colors_default.yellow("\u26A0 --unpinned used: pinned files in cella.config.ts were ignored, this can result in more drifts.")
647
+ );
648
+ }
649
+ console.info(colors_default.yellow(" Be extra careful & cherrypick what you want only."));
650
+ console.info();
651
+ }
652
+
653
+ // src/utils/overrides.ts
654
+ import { existsSync as existsSync3 } from "fs";
655
+ import { join as join3 } from "path";
656
+ function isUnderAnyFolder(filePath, folders) {
657
+ return folders.some((folder) => {
658
+ const entry = folder.replace(/\/+$/, "");
659
+ return filePath === entry || filePath.startsWith(`${entry}/`);
660
+ });
661
+ }
662
+ function isIgnored(filePath, config) {
663
+ return isUnderAnyFolder(filePath, config.overrides?.ignored || []);
664
+ }
665
+ function isPinned(filePath, config) {
666
+ if (isManagedFile(filePath)) return true;
667
+ return isUnderAnyFolder(filePath, config.overrides?.pinned || []);
668
+ }
669
+ function isPinnedForSync(filePath, config, unpinned) {
670
+ if (isManagedFile(filePath)) return true;
671
+ if (unpinned) return false;
672
+ return isPinned(filePath, config);
673
+ }
674
+ function hasGlobChars(entry) {
675
+ return entry.includes("*") || entry.includes("?");
676
+ }
677
+ function validateOverrides(config, forkPath) {
678
+ const warnings = [];
679
+ const pinned = config.overrides?.pinned || [];
680
+ for (const entry of pinned) {
681
+ if (hasGlobChars(entry)) {
682
+ warnings.push({
683
+ type: "pinned-glob",
684
+ pattern: entry,
685
+ message: `pin uses glob (not supported, use a path or folder): ${entry}`
686
+ });
687
+ } else if (!existsSync3(join3(forkPath, entry))) {
688
+ warnings.push({
689
+ type: "pinned-not-found",
690
+ pattern: entry,
691
+ message: `pin not found: ${entry}`
692
+ });
693
+ }
694
+ }
695
+ const ignored = config.overrides?.ignored || [];
696
+ for (const entry of ignored) {
697
+ if (hasGlobChars(entry)) {
698
+ warnings.push({
699
+ type: "ignored-not-found",
700
+ pattern: entry,
701
+ message: `ignored entry uses glob (not supported, use a path or folder): ${entry}`
702
+ });
703
+ } else if (!existsSync3(join3(forkPath, entry.replace(/\/+$/, "")))) {
704
+ warnings.push({
705
+ type: "ignored-not-found",
706
+ pattern: entry,
707
+ message: `ignored entry not found: ${entry}`
708
+ });
709
+ }
710
+ }
711
+ return warnings;
712
+ }
713
+ function printWarnings(warnings) {
714
+ for (const warning of warnings) {
715
+ console.info(`${warningMark} ${warning.message}`);
716
+ }
717
+ }
718
+
719
+ // src/cli.ts
720
+ function readOptions(opts) {
721
+ const scope = typeof opts.scope === "string" ? opts.scope : void 0;
722
+ if (scope && scope !== "all" && scope !== "risk" && scope !== "protected") {
723
+ throw new Error(`invalid --scope '${scope}'. expected one of: all, risk, protected`);
724
+ }
725
+ const normalizedScope = scope;
726
+ return {
727
+ logFile: opts.log === true,
728
+ verbose: opts.verbose === true,
729
+ list: opts.list === true,
730
+ json: opts.json === true,
731
+ diff: typeof opts.diff === "string" ? opts.diff : void 0,
732
+ openDiff: typeof opts.openDiff === "string" ? opts.openDiff : void 0,
733
+ scope: normalizedScope,
734
+ fork: typeof opts.fork === "string" ? opts.fork : void 0,
735
+ hard: opts.hard === true,
736
+ unpinned: opts.unpinned === true,
737
+ track: opts.track === "release" || opts.track === "branch" ? opts.track : void 0,
738
+ force: opts.force === true,
739
+ checkOverrides: opts.checkOverrides === true,
740
+ coverage: opts.coverage === true
741
+ };
742
+ }
743
+ var serviceDefinitions = [
744
+ {
745
+ name: "analyze",
746
+ description: "dry run to see what would change on sync",
747
+ options: [
748
+ { flags: "--log", description: "write complete file list to cella-sync.log" },
749
+ { flags: "--list", description: "non-interactive output for tooling (one file per line)" },
750
+ { flags: "--json", description: "machine-readable output for tooling/agents" },
751
+ { flags: "--scope <scope>", description: "analyze scope for --list/--json: all|risk|protected" },
752
+ { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" },
753
+ { flags: "--diff <path>", description: "print unified diff for one file, then exit" },
754
+ { flags: "--open-diff <path>", description: "open VS Code side-by-side diff for one file, then exit" }
755
+ ],
756
+ includeInMenu: (context) => !context.isUpstreamRepo
757
+ },
758
+ {
759
+ name: "sync",
760
+ description: "merge upstream changes into your app",
761
+ options: [
762
+ { flags: "--log", description: "write complete file list to cella-sync.log" },
763
+ { flags: "--hard", description: "overwrite drifted files with upstream version (aggressive realignment)" },
764
+ { flags: "--unpinned", description: "ignore pinned files (except package.json) to resurface upstream changes" },
765
+ { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" }
766
+ ],
767
+ includeInMenu: (context) => !context.isUpstreamRepo,
768
+ menuDescription: () => "merge upstream changes + sync package.json"
769
+ },
770
+ {
771
+ name: "audit",
772
+ description: "check for outdated packages & vulnerabilities",
773
+ options: [
774
+ { flags: "--list", description: "skip interactive update prompts after printing audit results" },
775
+ { flags: "--force", description: "bypass pnpm metadata cache for fresh registry data" },
776
+ { flags: "--check-overrides", description: "check which pnpm.overrides are still needed" }
777
+ ]
778
+ },
779
+ {
780
+ name: "forks",
781
+ description: "sync downstream to local fork repositories",
782
+ options: [
783
+ { flags: "--fork <name>", description: "pre-select fork by name (skips fork selection prompt)" },
784
+ { flags: "--log", description: "write complete file list to cella-sync.log for each synced fork" },
785
+ { flags: "-V, --verbose", description: "show detailed output during operations" },
786
+ { flags: "--hard", description: "overwrite drifted files with upstream version (aggressive realignment)" }
787
+ ],
788
+ includeInMenu: (context) => context.hasForks
789
+ },
790
+ {
791
+ name: "contributions",
792
+ description: "pull and adopt changes from forks",
793
+ options: [
794
+ { flags: "--fork <name>", description: "select a specific fork directly (skips fork selection)" },
795
+ { flags: "--list", description: "non-interactive output (one file per line)" },
796
+ { flags: "--json", description: "machine-readable JSON output for tooling/agents" },
797
+ { flags: "--diff <path>", description: "print the unified diff for a single contributed file, then exit" }
798
+ ],
799
+ includeInMenu: (context) => context.hasForks
800
+ },
801
+ {
802
+ name: "stats",
803
+ description: "count files by category and workspace package",
804
+ options: [
805
+ { flags: "-V, --verbose", description: "show detailed output during operations" },
806
+ { flags: "--coverage", description: "regenerate test coverage before showing the stats summary" }
807
+ ]
808
+ }
809
+ ];
810
+ async function getMenuContext(userConfig, forkPath) {
811
+ return {
812
+ hasForks: (userConfig.forks?.length ?? 0) > 0,
813
+ isUpstreamRepo: JSON.parse(readFileSync(join4(forkPath, "package.json"), "utf8")).name === "cella"
814
+ };
815
+ }
816
+ function buildServiceChoices(context) {
817
+ const label = (name) => name.padEnd(14);
818
+ const baseChoices = serviceDefinitions.filter((service) => service.includeInMenu?.(context) ?? true).map((service) => ({
819
+ value: service.name,
820
+ name: `${label(service.name)}${colors_default.dim(service.menuDescription?.(context) ?? service.description)}`
821
+ }));
822
+ return [
823
+ ...baseChoices,
824
+ { type: "separator", separator: "\u2500".repeat(40) },
825
+ { value: "exit", name: colors_default.red(`${label("exit")}${colors_default.dim("quit without doing anything")}`) }
826
+ ];
827
+ }
828
+ function addServiceCommand(program, definition, setSelection) {
829
+ const command = program.command(definition.name).description(definition.description);
830
+ for (const option of definition.options ?? []) {
831
+ command.option(option.flags, option.description);
832
+ }
833
+ command.action((opts) => {
834
+ setSelection({
835
+ service: definition.name,
836
+ options: readOptions(opts)
837
+ });
838
+ });
839
+ }
840
+ function buildProgram(setSelection) {
841
+ 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(
842
+ "after",
843
+ [
844
+ "",
845
+ "Examples:",
846
+ " $ cella analyze",
847
+ " $ cella analyze --json --scope risk",
848
+ " $ cella analyze --open-diff frontend/src/routes/index.tsx",
849
+ " $ cella sync --hard",
850
+ " $ cella sync --unpinned",
851
+ " $ cella sync --track branch",
852
+ " $ cella audit --check-overrides",
853
+ " $ cella contributions --fork raak --json"
854
+ ].join("\n")
855
+ );
856
+ for (const definition of serviceDefinitions) {
857
+ addServiceCommand(program, definition, setSelection);
858
+ }
859
+ return program;
860
+ }
861
+ function parseCommandLine(argv) {
862
+ if (argv.length <= 2) {
863
+ return { options: readOptions({}) };
864
+ }
865
+ let selection = { options: readOptions({}) };
866
+ const program = buildProgram((nextSelection) => {
867
+ selection = nextSelection;
868
+ });
869
+ program.parse(argv);
870
+ return selection;
871
+ }
872
+ async function promptForService(userConfig, forkPath) {
873
+ const selected = await select({
874
+ message: "choose a service:",
875
+ choices: buildServiceChoices(await getMenuContext(userConfig, forkPath)),
876
+ loop: false
877
+ });
878
+ if (selected === "exit") {
879
+ console.info(colors_default.dim("exiting..."));
880
+ console.info();
881
+ process4.exit(0);
882
+ }
883
+ console.info();
884
+ return selected;
885
+ }
886
+ function buildRuntimeConfig(userConfig, forkPath, selection) {
887
+ const { branchRef } = resolveUpstream(userConfig.settings);
888
+ return {
889
+ ...userConfig,
890
+ forkPath,
891
+ upstreamRef: branchRef,
892
+ service: selection.service ?? "analyze",
893
+ ...selection.options
894
+ };
895
+ }
896
+ async function parseCli(userConfig, forkPath) {
897
+ const selection = parseCommandLine(process4.argv);
898
+ if (selection.options.json || selection.options.diff) setJsonMode(true);
899
+ printHeader();
900
+ const warnings = validateOverrides(userConfig, forkPath);
901
+ if (warnings.length > 0) {
902
+ printWarnings(warnings);
903
+ console.info();
904
+ }
905
+ if (!selection.service) {
906
+ selection.service = await promptForService(userConfig, forkPath);
907
+ }
908
+ return buildRuntimeConfig(userConfig, forkPath, selection);
909
+ }
910
+
911
+ // src/services/analyze.ts
912
+ import { basename as basename2 } from "path";
913
+
914
+ // src/utils/cleanup.ts
915
+ import { existsSync as existsSync5, rmSync } from "fs";
916
+ import { tmpdir } from "os";
917
+ import { basename, join as join7 } from "path";
918
+ import process6 from "process";
919
+
920
+ // src/utils/git.ts
921
+ import { execFile } from "child_process";
922
+ import { existsSync as existsSync4 } from "fs";
923
+ import { mkdir, readdir, rmdir, unlink } from "fs/promises";
924
+ import { dirname, join as join6 } from "path";
925
+ import process5 from "process";
926
+ import { promisify } from "util";
927
+
928
+ // src/utils/manifest.ts
929
+ import { readFile, writeFile } from "fs/promises";
930
+ import { join as join5 } from "path";
931
+ var MANIFEST_FILE = "cella.manifest.json";
932
+ async function readSyncManifest(cwd) {
933
+ try {
934
+ const parsed = JSON.parse(await readFile(join5(cwd, MANIFEST_FILE), "utf8"));
935
+ const commit = parsed?.upstream?.commit;
936
+ if (typeof commit !== "string" || !/^[0-9a-f]{40}$/i.test(commit)) return null;
937
+ return parsed;
938
+ } catch {
939
+ return null;
940
+ }
941
+ }
942
+ async function readManifestBase(cwd) {
943
+ const manifest = await readSyncManifest(cwd);
944
+ return manifest ? manifest.upstream.commit.toLowerCase() : null;
945
+ }
946
+ async function writeSyncManifest(cwd, manifest) {
947
+ await writeFile(join5(cwd, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}
948
+ `, "utf8");
949
+ }
950
+
951
+ // src/utils/git.ts
952
+ var execFileAsync = promisify(execFile);
953
+ function sanitizeCredentials(text) {
954
+ return text.replace(/https?:\/\/[^@\s]+@/g, (match) => {
955
+ const protocol = match.startsWith("https") ? "https" : "http";
956
+ return `${protocol}://***@`;
957
+ });
958
+ }
959
+ async function git(args, cwd, options = {}) {
960
+ const env = {
961
+ ...process5.env,
962
+ ...options.skipEditor ? { GIT_EDITOR: "true" } : {},
963
+ ...options.env
964
+ };
965
+ const maxBuffer = options.maxBuffer ?? 50 * 1024 * 1024;
966
+ try {
967
+ const { stdout } = await execFileAsync("git", args, { cwd, env, maxBuffer });
968
+ return stdout.trim();
969
+ } catch (error) {
970
+ if (options.ignoreErrors) return "";
971
+ if (error instanceof Error) {
972
+ error.message = sanitizeCredentials(error.message);
973
+ }
974
+ throw error;
975
+ }
976
+ }
977
+ async function getCurrentBranch(cwd) {
978
+ return git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
979
+ }
980
+ async function switchBranch(cwd, branch) {
981
+ await git(["switch", branch], cwd);
982
+ }
983
+ async function createBranchFrom(cwd, branch, startPoint) {
984
+ await git(["switch", "-c", branch, startPoint], cwd);
985
+ }
986
+ async function deleteBranch(cwd, branch) {
987
+ await git(["branch", "-D", branch], cwd, { ignoreErrors: true });
988
+ }
989
+ async function pullFastForward(cwd) {
990
+ await git(["pull", "--ff-only"], cwd, { ignoreErrors: true, skipEditor: true });
991
+ }
992
+ async function getUpstreamStatus(cwd) {
993
+ const upstream = await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], cwd, {
994
+ ignoreErrors: true
995
+ });
996
+ if (!upstream) return { upstream: null, ahead: 0, behind: 0 };
997
+ const remote = upstream.split("/")[0];
998
+ await git(["fetch", remote], cwd, { ignoreErrors: true, skipEditor: true });
999
+ const counts = await git(["rev-list", "--left-right", "--count", `HEAD...${upstream}`], cwd, { ignoreErrors: true });
1000
+ const [ahead, behind] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0);
1001
+ return { upstream, ahead: ahead ?? 0, behind: behind ?? 0 };
1002
+ }
1003
+ async function pushBranch(cwd, remote, branch) {
1004
+ await git(["push", "-u", remote, branch], cwd);
1005
+ }
1006
+ async function commitSquash(cwd, message) {
1007
+ for (const file of ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE"]) {
1008
+ const path4 = join6(cwd, ".git", file);
1009
+ if (existsSync4(path4)) await unlink(path4);
1010
+ }
1011
+ await git(["commit", "-m", message], cwd, { skipEditor: true });
1012
+ }
1013
+ async function stageAll(cwd) {
1014
+ await git(["add", "-A"], cwd);
1015
+ }
1016
+ function mergeInProgress(cwd) {
1017
+ return existsSync4(join6(cwd, ".git", "MERGE_HEAD"));
1018
+ }
1019
+ async function getShortSha(cwd, ref) {
1020
+ return git(["rev-parse", "--short", ref], cwd);
1021
+ }
1022
+ async function getWorkingTreeChangeCount(cwd) {
1023
+ const status = await git(["status", "--porcelain"], cwd);
1024
+ if (!status) return 0;
1025
+ return status.split("\n").filter(Boolean).length;
1026
+ }
1027
+ async function remoteExists(cwd, remoteName) {
1028
+ const remotes = await git(["remote"], cwd);
1029
+ return remotes.split("\n").includes(remoteName);
1030
+ }
1031
+ async function ensureRemote(cwd, remoteName, url) {
1032
+ if (await remoteExists(cwd, remoteName)) {
1033
+ await git(["remote", "set-url", remoteName, url], cwd);
1034
+ } else {
1035
+ await git(["remote", "add", remoteName, url], cwd);
1036
+ }
1037
+ }
1038
+ async function fetch2(cwd, remoteName) {
1039
+ await git(["fetch", remoteName], cwd);
1040
+ }
1041
+ async function fetchUpstreamTags(cwd, remoteName) {
1042
+ await git(["fetch", remoteName, "--no-tags", `+refs/tags/*:refs/${remoteName}/tags/*`], cwd);
1043
+ }
1044
+ async function resolveLatestReleaseTag(cwd, remoteName) {
1045
+ const prefix = `refs/${remoteName}/tags/`;
1046
+ const output = await git(["for-each-ref", "--sort=-version:refname", "--format=%(refname)", prefix], cwd, {
1047
+ ignoreErrors: true
1048
+ });
1049
+ if (!output) return null;
1050
+ for (const line of output.split("\n")) {
1051
+ const ref = line.trim();
1052
+ if (!ref) continue;
1053
+ const tag = ref.slice(prefix.length);
1054
+ if (/^v\d/.test(tag)) return { tag, ref };
1055
+ }
1056
+ return null;
1057
+ }
1058
+ async function getCommitInfo(cwd, ref) {
1059
+ const format = "%H%n%s%n%ar";
1060
+ const output = await git(["log", "-1", `--format=${format}`, ref], cwd);
1061
+ const [hash, message, date] = output.split("\n");
1062
+ return { hash, message, date };
1063
+ }
1064
+ async function countCommitsBetween(cwd, fromRef, toRef) {
1065
+ const output = await git(["rev-list", "--count", `${fromRef}..${toRef}`], cwd);
1066
+ return Number.parseInt(output.trim(), 10) || 0;
1067
+ }
1068
+ async function listCommitsBetween(cwd, fromRef, toRef, options = {}) {
1069
+ const { oldestFirst = true, skip = 0, limit } = options;
1070
+ const args = ["log", "--format=%H%x1f%s%x1f%ar"];
1071
+ if (oldestFirst) args.push("--reverse");
1072
+ args.push(`${fromRef}..${toRef}`);
1073
+ const output = await git(args, cwd, { ignoreErrors: true });
1074
+ if (!output) return [];
1075
+ const commits = [];
1076
+ for (const line of output.split("\n")) {
1077
+ if (!line) continue;
1078
+ const [hash, message, date] = line.split("");
1079
+ if (!hash || !message || !date) continue;
1080
+ commits.push({ hash, message, date });
1081
+ }
1082
+ const normalizedSkip = Math.max(0, skip);
1083
+ const normalizedLimit = typeof limit === "number" && limit > 0 ? limit : void 0;
1084
+ if (normalizedSkip === 0 && normalizedLimit === void 0) {
1085
+ return commits;
1086
+ }
1087
+ const start = Math.min(normalizedSkip, commits.length);
1088
+ const end = normalizedLimit === void 0 ? commits.length : Math.min(start + normalizedLimit, commits.length);
1089
+ return commits.slice(start, end);
1090
+ }
1091
+ async function getFileChangeInfo(cwd, fromRef, toRef) {
1092
+ const info = /* @__PURE__ */ new Map();
1093
+ try {
1094
+ const output = await git(["log", "--name-only", "--format=%h %ar", `${fromRef}..${toRef}`], cwd, {
1095
+ ignoreErrors: true
1096
+ });
1097
+ if (!output.trim()) return info;
1098
+ const lines = output.split("\n");
1099
+ let currentHash = "";
1100
+ let currentDate = "";
1101
+ for (const line of lines) {
1102
+ if (!line) continue;
1103
+ const match = line.match(/^([a-f0-9]{7,})\s+(.+)$/);
1104
+ if (match) {
1105
+ currentHash = match[1];
1106
+ currentDate = match[2];
1107
+ } else if (currentHash && currentDate && !info.has(line)) {
1108
+ info.set(line, { date: currentDate, hash: currentHash });
1109
+ }
1110
+ }
1111
+ } catch {
1112
+ }
1113
+ return info;
1114
+ }
1115
+ async function isClean(cwd) {
1116
+ const status = await git(["status", "--porcelain"], cwd);
1117
+ return !status;
1118
+ }
1119
+ async function assertClean(cwd, subject = "working directory") {
1120
+ if (!await isClean(cwd)) {
1121
+ throw new Error(`${subject} has uncommitted changes. please commit or stash before syncing.`);
1122
+ }
1123
+ }
1124
+ async function createWorktree(cwd, worktreePath, commitRef) {
1125
+ await git(["worktree", "add", "--detach", worktreePath, commitRef], cwd);
1126
+ }
1127
+ async function removeWorktree(cwd, worktreePath) {
1128
+ await git(["worktree", "remove", worktreePath, "--force"], cwd, { ignoreErrors: true });
1129
+ await git(["worktree", "prune"], cwd, { ignoreErrors: true });
1130
+ }
1131
+ async function listWorktrees(cwd) {
1132
+ const output = await git(["worktree", "list", "--porcelain"], cwd);
1133
+ const lines = output.split("\n");
1134
+ const paths = [];
1135
+ for (const line of lines) {
1136
+ if (line.startsWith("worktree ")) {
1137
+ paths.push(line.slice(9));
1138
+ }
1139
+ }
1140
+ return paths;
1141
+ }
1142
+ async function merge(cwd, ref, options = {}) {
1143
+ const args = ["merge", "--no-ff", ref];
1144
+ if (options.noCommit) args.push("--no-commit");
1145
+ if (options.noEdit) args.push("--no-edit");
1146
+ try {
1147
+ await git(args, cwd, { skipEditor: true });
1148
+ return { success: true, conflicts: [] };
1149
+ } catch (error) {
1150
+ const conflicts = await getConflictedFiles(cwd);
1151
+ if (conflicts.length > 0) {
1152
+ return { success: false, conflicts };
1153
+ }
1154
+ throw error;
1155
+ }
1156
+ }
1157
+ async function getConflictedFiles(cwd) {
1158
+ const output = await git(["diff", "--name-only", "--diff-filter=U"], cwd, { ignoreErrors: true });
1159
+ if (!output) return [];
1160
+ return output.split("\n").filter(Boolean);
1161
+ }
1162
+ async function mergeAbort(cwd) {
1163
+ await git(["merge", "--abort"], cwd, { ignoreErrors: true });
1164
+ }
1165
+ async function fileExistsAtRef(cwd, ref, filePath) {
1166
+ const result = await git(["ls-tree", "--name-only", ref, "--", filePath], cwd, { ignoreErrors: true });
1167
+ return result !== "";
1168
+ }
1169
+ async function checkoutFromRef(cwd, ref, filePath) {
1170
+ await git(["checkout", ref, "--", filePath], cwd);
1171
+ }
1172
+ async function restoreWorktreeFromRef(cwd, ref, filePath) {
1173
+ await git(["restore", `--source=${ref}`, "--worktree", "--", filePath], cwd);
1174
+ }
1175
+ async function getMergeBase(cwd, ref1, ref2) {
1176
+ return git(["merge-base", ref1, ref2], cwd);
1177
+ }
1178
+ async function getFileChanges(cwd, baseRef, targetRef) {
1179
+ const output = await git(["diff-tree", "-r", "-M90%", "--no-commit-id", baseRef, targetRef], cwd, {
1180
+ ignoreErrors: true
1181
+ });
1182
+ const changes = /* @__PURE__ */ new Map();
1183
+ if (!output) return changes;
1184
+ for (const line of output.split("\n")) {
1185
+ if (!line) continue;
1186
+ const renameMatch = line.match(/^:\d+ \d+ ([a-f0-9]+) ([a-f0-9]+) R\d*\s+(.+)\t(.+)$/);
1187
+ if (renameMatch) {
1188
+ const [, baseHash, targetHash, oldPath, newPath] = renameMatch;
1189
+ changes.set(newPath, {
1190
+ status: "R",
1191
+ baseHash: baseHash === "0".repeat(40) ? null : baseHash,
1192
+ targetHash: targetHash === "0".repeat(40) ? null : targetHash,
1193
+ oldPath,
1194
+ newPath
1195
+ });
1196
+ continue;
1197
+ }
1198
+ const match = line.match(/^:\d+ \d+ ([a-f0-9]+) ([a-f0-9]+) ([ADMT])\t(.+)$/);
1199
+ if (match) {
1200
+ const [, baseHash, targetHash, status, filePath] = match;
1201
+ changes.set(filePath, {
1202
+ status,
1203
+ baseHash: baseHash === "0".repeat(40) ? null : baseHash,
1204
+ targetHash: targetHash === "0".repeat(40) ? null : targetHash
1205
+ });
1206
+ }
1207
+ }
1208
+ return changes;
1209
+ }
1210
+ async function getFileHashesAtRef(cwd, ref) {
1211
+ const output = await git(["ls-tree", "-r", ref], cwd);
1212
+ const hashes = /* @__PURE__ */ new Map();
1213
+ if (!output) return hashes;
1214
+ for (const line of output.split("\n")) {
1215
+ if (!line) continue;
1216
+ const match = line.match(/^\d+ \w+ ([a-f0-9]+)\t(.+)$/);
1217
+ if (match) {
1218
+ const [, hash, filePath] = match;
1219
+ hashes.set(filePath, hash);
1220
+ }
1221
+ }
1222
+ return hashes;
1223
+ }
1224
+ async function restoreToHead(cwd, filePath) {
1225
+ await git(["checkout", "HEAD", "--", filePath], cwd);
1226
+ }
1227
+ async function batchRestoreToHead(cwd, filePaths) {
1228
+ if (filePaths.length === 0) return;
1229
+ await git(["checkout", "HEAD", "--", ...filePaths], cwd);
1230
+ }
1231
+ async function gitRm(cwd, filePath) {
1232
+ await git(["rm", "-f", "--", filePath], cwd, { ignoreErrors: true });
1233
+ }
1234
+ async function batchGitRm(cwd, filePaths) {
1235
+ if (filePaths.length === 0) return;
1236
+ await git(["rm", "-f", "--", ...filePaths], cwd, { ignoreErrors: true });
1237
+ }
1238
+ async function getStagedNewFiles(cwd) {
1239
+ const output = await git(["diff", "--cached", "--name-only", "--diff-filter=A"], cwd, { ignoreErrors: true });
1240
+ return output ? output.split("\n").filter(Boolean) : [];
1241
+ }
1242
+ async function batchUnstageFiles(cwd, filePaths) {
1243
+ if (filePaths.length === 0) return;
1244
+ await git(["rm", "--cached", "-f", "--", ...filePaths], cwd, { ignoreErrors: true });
1245
+ }
1246
+ async function gitMv(cwd, oldPath, newPath) {
1247
+ const newDir = dirname(join6(cwd, newPath));
1248
+ await mkdir(newDir, { recursive: true });
1249
+ await git(["mv", "-f", oldPath, newPath], cwd);
1250
+ }
1251
+ async function fileExistsInWorktree(cwd, filePath) {
1252
+ return existsSync4(join6(cwd, filePath));
1253
+ }
1254
+ async function removeFileFromWorktree(cwd, filePath) {
1255
+ const fullPath = join6(cwd, filePath);
1256
+ try {
1257
+ await unlink(fullPath);
1258
+ await cleanupEmptyParentDirs(cwd, dirname(filePath));
1259
+ } catch {
1260
+ }
1261
+ }
1262
+ async function removeFileFully(cwd, filePath) {
1263
+ await gitRm(cwd, filePath);
1264
+ await removeFileFromWorktree(cwd, filePath);
1265
+ }
1266
+ async function cleanupEmptyParentDirs(cwd, relativePath) {
1267
+ if (!relativePath || relativePath === ".") return;
1268
+ const fullPath = join6(cwd, relativePath);
1269
+ try {
1270
+ const entries = await readdir(fullPath);
1271
+ if (entries.length === 0) {
1272
+ await rmdir(fullPath);
1273
+ await cleanupEmptyParentDirs(cwd, dirname(relativePath));
1274
+ }
1275
+ } catch {
1276
+ }
1277
+ }
1278
+ async function storeLastSyncRef(cwd, upstreamHash) {
1279
+ await git(["update-ref", "refs/cella/last-sync", upstreamHash], cwd);
1280
+ const head = await git(["rev-parse", "HEAD"], cwd, { ignoreErrors: true });
1281
+ if (head) {
1282
+ await git(["update-ref", "refs/cella/last-sync-head", head], cwd);
1283
+ }
1284
+ }
1285
+ async function stagePath(cwd, path4) {
1286
+ await git(["add", "--", path4], cwd, { ignoreErrors: true });
1287
+ }
1288
+ async function getStoredSyncRef(cwd) {
1289
+ const ref = await git(["rev-parse", "refs/cella/last-sync"], cwd, { ignoreErrors: true });
1290
+ return ref || null;
1291
+ }
1292
+ async function getStoredSyncHead(cwd) {
1293
+ const ref = await git(["rev-parse", "refs/cella/last-sync-head"], cwd, { ignoreErrors: true });
1294
+ return ref || null;
1295
+ }
1296
+ async function readManifestBaseAtRef(cwd, ref) {
1297
+ const raw = await git(["show", `${ref}:${MANIFEST_FILE}`], cwd, { ignoreErrors: true });
1298
+ if (!raw) return null;
1299
+ try {
1300
+ const parsed = JSON.parse(raw);
1301
+ const commit = parsed?.upstream?.commit;
1302
+ if (typeof commit !== "string" || !/^[0-9a-f]{40}$/i.test(commit)) return null;
1303
+ return commit.toLowerCase();
1304
+ } catch {
1305
+ return null;
1306
+ }
1307
+ }
1308
+ async function commitObjectExists(cwd, sha) {
1309
+ try {
1310
+ await git(["cat-file", "-e", `${sha}^{commit}`], cwd);
1311
+ return true;
1312
+ } catch {
1313
+ return false;
1314
+ }
1315
+ }
1316
+ async function getRootCommit(cwd, ref) {
1317
+ const out = await git(["rev-list", "--max-parents=0", ref], cwd, { ignoreErrors: true });
1318
+ const roots = out.split("\n").filter(Boolean);
1319
+ return roots.length > 0 ? roots[roots.length - 1] : null;
1320
+ }
1321
+ async function ensureSyncBase(cwd, headRef, upstreamRef) {
1322
+ const nativeBase = await git(["merge-base", headRef, upstreamRef], cwd, { ignoreErrors: true });
1323
+ if (nativeBase) return;
1324
+ const baseSha = await getStoredSyncRef(cwd) ?? await readManifestBaseAtRef(cwd, headRef) ?? await readManifestBase(cwd);
1325
+ if (!baseSha) {
1326
+ throw new Error(
1327
+ `no common ancestor between the fork and '${upstreamRef}', and no sync base recorded.
1328
+
1329
+ This fork has unrelated history with upstream \u2014 typical for a create-cella scaffold, or after
1330
+ upstream squashed its history. Seed the base with the upstream commit the fork was created from,
1331
+ then re-run sync:
1332
+
1333
+ git update-ref refs/cella/last-sync <upstream-sha>
1334
+ `
1335
+ );
1336
+ }
1337
+ if (!await commitObjectExists(cwd, baseSha)) {
1338
+ throw new Error(
1339
+ `recorded sync base ${baseSha.slice(0, 12)} is not present after fetching '${upstreamRef}'.
1340
+ It may belong to a different upstream, or have been dropped by an upstream history rewrite.`
1341
+ );
1342
+ }
1343
+ const root = await getRootCommit(cwd, headRef);
1344
+ if (!root) throw new Error("could not determine the fork's root commit to bootstrap sync ancestry");
1345
+ await git(["replace", "-f", "--graft", root, baseSha], cwd);
1346
+ await git(["update-ref", "refs/cella/last-sync", baseSha], cwd);
1347
+ }
1348
+ async function isAncestor(cwd, ancestor, descendant) {
1349
+ try {
1350
+ await git(["merge-base", "--is-ancestor", ancestor, descendant], cwd);
1351
+ return true;
1352
+ } catch {
1353
+ return false;
1354
+ }
1355
+ }
1356
+ async function getLegacySyncRef(cwd, headRef) {
1357
+ const storedRef = await getStoredSyncRef(cwd);
1358
+ if (!storedRef) return null;
1359
+ const storedHead = await getStoredSyncHead(cwd);
1360
+ if (storedHead) {
1361
+ const currentHead = await git(["rev-parse", headRef], cwd, { ignoreErrors: true });
1362
+ if (currentHead && currentHead === storedHead) return null;
1363
+ }
1364
+ return storedRef;
1365
+ }
1366
+ async function getEffectiveMergeBase(cwd, headRef, upstreamRef) {
1367
+ const gitBase = await getMergeBase(cwd, headRef, upstreamRef);
1368
+ const recorded = await readManifestBaseAtRef(cwd, headRef) ?? await getLegacySyncRef(cwd, headRef);
1369
+ if (!recorded) {
1370
+ return { base: gitBase, isStale: false, storedRef: null };
1371
+ }
1372
+ const recordedIsNewer = await isAncestor(cwd, gitBase, recorded);
1373
+ if (recordedIsNewer && recorded !== gitBase) {
1374
+ return { base: recorded, isStale: true, storedRef: recorded };
1375
+ }
1376
+ return { base: gitBase, isStale: false, storedRef: recorded };
1377
+ }
1378
+
1379
+ // src/utils/cleanup.ts
1380
+ var WORKTREE_PREFIXES = {
1381
+ sync: "cella-sync-",
1382
+ view: "cella-view-"
1383
+ };
1384
+ function buildWorktreePath(kind, repoPath) {
1385
+ return join7(tmpdir(), `${WORKTREE_PREFIXES[kind]}${basename(repoPath)}`);
1386
+ }
1387
+ function getWorktreePath(repoPath) {
1388
+ return buildWorktreePath("sync", repoPath);
1389
+ }
1390
+ function getViewWorktreePath(repoPath) {
1391
+ return buildWorktreePath("view", repoPath);
1392
+ }
1393
+ async function refreshViewWorktree(repoPath, upstreamRef) {
1394
+ const viewPath = getViewWorktreePath(repoPath);
1395
+ await removeWorktree(repoPath, viewPath);
1396
+ if (existsSync5(viewPath)) {
1397
+ rmSync(viewPath, { recursive: true, force: true });
1398
+ }
1399
+ await createWorktree(repoPath, viewPath, upstreamRef);
1400
+ return viewPath;
1401
+ }
1402
+ var cleanupRegistered = false;
1403
+ var currentWorktreePath = null;
1404
+ var currentRepoPath = null;
1405
+ function registerWorktree(repoPath, worktreePath) {
1406
+ currentRepoPath = repoPath;
1407
+ currentWorktreePath = worktreePath;
1408
+ }
1409
+ function unregisterWorktree() {
1410
+ currentRepoPath = null;
1411
+ currentWorktreePath = null;
1412
+ }
1413
+ async function cleanupWorktree(repoPath, worktreePath) {
1414
+ await removeWorktree(repoPath, worktreePath);
1415
+ if (existsSync5(worktreePath)) {
1416
+ rmSync(worktreePath, { recursive: true, force: true });
1417
+ }
1418
+ unregisterWorktree();
1419
+ }
1420
+ async function cleanupLeftoverWorktrees(repoPath) {
1421
+ const worktreePath = getWorktreePath(repoPath);
1422
+ if (existsSync5(worktreePath)) {
1423
+ await cleanupWorktree(repoPath, worktreePath);
1424
+ }
1425
+ const prefixes = Object.values(WORKTREE_PREFIXES);
1426
+ const worktrees = await listWorktrees(repoPath);
1427
+ for (const wt of worktrees) {
1428
+ if (prefixes.some((prefix) => wt.includes(prefix)) && !existsSync5(wt)) {
1429
+ await removeWorktree(repoPath, wt);
1430
+ }
1431
+ }
1432
+ }
1433
+ async function handleAbort(signal) {
1434
+ console.info();
1435
+ console.info(`${warningMark} Interrupted (${signal}) - cleaning up...`);
1436
+ if (currentRepoPath && currentWorktreePath) {
1437
+ try {
1438
+ await mergeAbort(currentWorktreePath);
1439
+ } catch {
1440
+ }
1441
+ try {
1442
+ await cleanupWorktree(currentRepoPath, currentWorktreePath);
1443
+ console.info(`${colors_default.green("\u2713")} no changes were made to your repository.`);
1444
+ } catch (error) {
1445
+ console.error(
1446
+ `${colors_default.red("\u2717")} failed to clean up worktree: ${error instanceof Error ? error.message : "unknown error"}`
1447
+ );
1448
+ }
1449
+ }
1450
+ process6.exit(1);
1451
+ }
1452
+ function registerSignalHandlers() {
1453
+ if (cleanupRegistered) return;
1454
+ process6.on("SIGINT", () => handleAbort("SIGINT"));
1455
+ process6.on("SIGTERM", () => handleAbort("SIGTERM"));
1456
+ cleanupRegistered = true;
1457
+ }
1458
+
1459
+ // src/utils/diff.ts
1460
+ import { spawnSync as spawnSync2 } from "child_process";
1461
+ import { existsSync as existsSync6, mkdtempSync, writeFileSync as writeFileSync2 } from "fs";
1462
+ import { tmpdir as tmpdir2 } from "os";
1463
+ import { join as join8, resolve as resolve3 } from "path";
1464
+ function gitDiffFile(cwd, range, filePath, options) {
1465
+ const args = ["diff"];
1466
+ if (options.color === "always") args.push("--color=always");
1467
+ if (options.color === "never") args.push("--no-color");
1468
+ args.push("--src-prefix=cella/", `--dst-prefix=${options.dstPrefix}/`, range, "--", filePath);
1469
+ const result = spawnSync2("git", args, { cwd, maxBuffer: 50 * 1024 * 1024 });
1470
+ if (result.status !== 0) {
1471
+ const stderr = result.stderr?.toString().trim();
1472
+ throw new Error(stderr || `failed to diff ${filePath}`);
1473
+ }
1474
+ return result.stdout;
1475
+ }
1476
+ function openVsCodeDiff(forkPath, upstreamRef, viewPath, filePath) {
1477
+ const forkFile = resolve3(forkPath, filePath);
1478
+ if (viewPath) {
1479
+ const upstreamFile = resolve3(viewPath, filePath);
1480
+ if (existsSync6(upstreamFile)) {
1481
+ spawnSync2("code", ["--diff", upstreamFile, forkFile], { stdio: "ignore" });
1482
+ return;
1483
+ }
1484
+ }
1485
+ const tmpDir = mkdtempSync(join8(tmpdir2(), "cella-analyze-diff-"));
1486
+ const fileName = filePath.split("/").pop() || "file";
1487
+ const tmpFile = join8(tmpDir, `upstream-${fileName}`);
1488
+ const result = spawnSync2("git", ["show", `${upstreamRef}:${filePath}`], {
1489
+ cwd: forkPath,
1490
+ stdio: ["pipe", "pipe", "pipe"]
1491
+ });
1492
+ if (result.status !== 0) {
1493
+ const stderr = result.stderr.toString().trim();
1494
+ throw new Error(stderr || `failed to read upstream version for ${filePath}`);
1495
+ }
1496
+ writeFileSync2(tmpFile, result.stdout);
1497
+ spawnSync2("code", ["--diff", tmpFile, forkFile], { stdio: "ignore" });
1498
+ }
1499
+
1500
+ // src/services/analyze-core.ts
1501
+ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predicates, onProgress) {
1502
+ onProgress?.("collecting file hashes (batch)...");
1503
+ const [forkHashes, upstreamHashes, baseHashes] = await Promise.all([
1504
+ getFileHashesAtRef(repoPath, localRef),
1505
+ getFileHashesAtRef(repoPath, incomingRef),
1506
+ getFileHashesAtRef(repoPath, mergeBaseRef)
1507
+ ]);
1508
+ const upstreamChanges = await getFileChanges(repoPath, mergeBaseRef, incomingRef);
1509
+ const forkChanges = await getFileChanges(repoPath, mergeBaseRef, localRef);
1510
+ const upstreamRenames = /* @__PURE__ */ new Map();
1511
+ for (const [newPath, change] of upstreamChanges) {
1512
+ if (change.status === "R" && change.oldPath) {
1513
+ upstreamRenames.set(change.oldPath, newPath);
1514
+ }
1515
+ }
1516
+ const allFiles = /* @__PURE__ */ new Set([...forkHashes.keys(), ...upstreamHashes.keys(), ...upstreamRenames.keys()]);
1517
+ const changedFiles = /* @__PURE__ */ new Set([...upstreamChanges.keys(), ...forkChanges.keys(), ...upstreamRenames.keys()]);
1518
+ const upstreamHashSet = new Set(upstreamHashes.values());
1519
+ onProgress?.(
1520
+ `analyzing ${changedFiles.size} changed files (${allFiles.size - changedFiles.size} identical skipped)...`
1521
+ );
1522
+ const analyzedFiles = [];
1523
+ const handledOldPaths = /* @__PURE__ */ new Set();
1524
+ let processed = 0;
1525
+ for (const filePath of allFiles) {
1526
+ if (handledOldPaths.has(filePath)) continue;
1527
+ const inFork = forkHashes.has(filePath);
1528
+ const inUpstream = upstreamHashes.has(filePath);
1529
+ const fileIsIgnored = predicates.isIgnored(filePath);
1530
+ const fileIsPinned = predicates.isPinned(filePath);
1531
+ const upstreamChange = upstreamChanges.get(filePath);
1532
+ const isUpstreamRename = upstreamChange?.status === "R" && upstreamChange.oldPath;
1533
+ const renamedToPath = upstreamRenames.get(filePath);
1534
+ if (!changedFiles.has(filePath) && inFork && inUpstream) {
1535
+ analyzedFiles.push({
1536
+ path: filePath,
1537
+ status: "identical",
1538
+ isIgnored: fileIsIgnored,
1539
+ isPinned: fileIsPinned,
1540
+ existsInFork: true,
1541
+ existsInUpstream: true
1542
+ });
1543
+ continue;
1544
+ }
1545
+ processed++;
1546
+ if (processed % 100 === 0) {
1547
+ onProgress?.(`analyzing ${processed}/${changedFiles.size} changed files...`);
1548
+ }
1549
+ const forkHash = forkHashes.get(filePath) ?? null;
1550
+ const upstreamHash = upstreamHashes.get(filePath) ?? null;
1551
+ const baseHash = baseHashes.get(filePath) ?? null;
1552
+ let status;
1553
+ let renamedFrom;
1554
+ if (isUpstreamRename && upstreamChange.oldPath) {
1555
+ const oldPath = upstreamChange.oldPath;
1556
+ const oldPathInFork = forkHashes.has(oldPath);
1557
+ const forkOldHash = forkHashes.get(oldPath) ?? null;
1558
+ const baseOldHash = baseHashes.get(oldPath) ?? null;
1559
+ handledOldPaths.add(oldPath);
1560
+ const forkModifiedOld = forkOldHash !== baseOldHash;
1561
+ const oldPathPinned = predicates.isPinned(oldPath);
1562
+ if (fileIsPinned || oldPathPinned || predicates.isIgnored(oldPath)) {
1563
+ status = "pinned";
1564
+ } else if (!oldPathInFork) {
1565
+ status = "behind";
1566
+ } else if (forkModifiedOld) {
1567
+ status = "diverged";
1568
+ renamedFrom = oldPath;
1569
+ } else {
1570
+ status = "renamed";
1571
+ renamedFrom = oldPath;
1572
+ }
1573
+ analyzedFiles.push({
1574
+ path: filePath,
1575
+ status,
1576
+ isIgnored: fileIsIgnored,
1577
+ isPinned: fileIsPinned,
1578
+ existsInFork: inFork,
1579
+ existsInUpstream: true,
1580
+ renamedFrom
1581
+ });
1582
+ continue;
1583
+ }
1584
+ if (renamedToPath) {
1585
+ handledOldPaths.add(filePath);
1586
+ continue;
1587
+ }
1588
+ if (fileIsIgnored) {
1589
+ status = "ignored";
1590
+ } else if (!inFork && inUpstream) {
1591
+ const locallyDeleted = baseHash !== null && upstreamHash === baseHash;
1592
+ status = fileIsPinned || locallyDeleted ? "deleted" : "behind";
1593
+ } else if (inFork && !inUpstream) {
1594
+ if (baseHash !== null) {
1595
+ status = fileIsPinned ? "ahead" : "behind";
1596
+ } else {
1597
+ status = forkHash && upstreamHashSet.has(forkHash) ? "behind" : "local";
1598
+ }
1599
+ } else if (forkHash === upstreamHash) {
1600
+ status = "identical";
1601
+ } else {
1602
+ const forkChanged = forkHash !== baseHash;
1603
+ const upstreamChanged = upstreamHash !== baseHash;
1604
+ if (forkChanged && upstreamChanged) {
1605
+ status = fileIsPinned ? "pinned" : "diverged";
1606
+ } else if (forkChanged && !upstreamChanged) {
1607
+ status = fileIsPinned ? "ahead" : predicates.hard ? "behind" : "drifted";
1608
+ } else if (!forkChanged && upstreamChanged) {
1609
+ status = "behind";
1610
+ } else {
1611
+ status = "identical";
1612
+ }
1613
+ }
1614
+ analyzedFiles.push({
1615
+ path: filePath,
1616
+ status,
1617
+ isIgnored: fileIsIgnored,
1618
+ isPinned: fileIsPinned,
1619
+ existsInFork: inFork,
1620
+ existsInUpstream: inUpstream
1621
+ });
1622
+ }
1623
+ return analyzedFiles;
1624
+ }
1625
+ async function enrichChangeInfo(repoPath, files, mergeBaseRef, localRef, incomingRef) {
1626
+ const forkInfo = await getFileChangeInfo(repoPath, mergeBaseRef, localRef);
1627
+ const upstreamInfo = await getFileChangeInfo(repoPath, mergeBaseRef, incomingRef);
1628
+ for (const file of files) {
1629
+ if (file.status === "identical") continue;
1630
+ if (file.status === "ahead" || file.status === "drifted") {
1631
+ const info = forkInfo.get(file.path);
1632
+ if (info) {
1633
+ file.changedAt = info.date;
1634
+ file.changedCommit = info.hash;
1635
+ }
1636
+ } else if (file.status === "behind") {
1637
+ const info = upstreamInfo.get(file.path);
1638
+ if (info) {
1639
+ file.changedAt = info.date;
1640
+ file.changedCommit = info.hash;
1641
+ }
1642
+ } else if (file.status === "diverged" || file.status === "pinned") {
1643
+ const forkFileInfo = forkInfo.get(file.path);
1644
+ const upstreamFileInfo = upstreamInfo.get(file.path);
1645
+ if (forkFileInfo) {
1646
+ file.changedAt = forkFileInfo.date;
1647
+ file.changedCommit = forkFileInfo.hash;
1648
+ }
1649
+ if (upstreamFileInfo) {
1650
+ file.upstreamChangedAt = upstreamFileInfo.date;
1651
+ file.upstreamCommit = upstreamFileInfo.hash;
1652
+ }
1653
+ }
1654
+ }
1655
+ }
1656
+
1657
+ // src/services/merge-engine.ts
1658
+ function getGitHubBaseUrl(remoteUrl) {
1659
+ const sshMatch = remoteUrl.match(/git@github\.com:([^/]+)\/([^.]+)(?:\.git)?$/);
1660
+ if (sshMatch) {
1661
+ return `https://github.com/${sshMatch[1]}/${sshMatch[2]}`;
1662
+ }
1663
+ const httpsMatch = remoteUrl.match(/https:\/\/github\.com\/([^/]+)\/([^/.]+)(?:\.git)?$/);
1664
+ if (httpsMatch) {
1665
+ return `https://github.com/${httpsMatch[1]}/${httpsMatch[2]}`;
1666
+ }
1667
+ return null;
1668
+ }
1669
+ function syncPredicates(config) {
1670
+ return {
1671
+ isIgnored: (path4) => isIgnored(path4, config),
1672
+ isPinned: (path4) => isPinnedForSync(path4, config, config.unpinned),
1673
+ hard: config.hard
1674
+ };
1675
+ }
1676
+ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onProgress) {
1677
+ onProgress?.("analyzing files...");
1678
+ const analyzedFiles = await analyzeRefs(
1679
+ forkPath,
1680
+ "HEAD",
1681
+ upstreamRef,
1682
+ mergeBaseRef,
1683
+ syncPredicates(config),
1684
+ onProgress
1685
+ );
1686
+ const batchRestorePaths = [];
1687
+ const batchRemovePaths = [];
1688
+ for (const file of analyzedFiles) {
1689
+ const { path: filePath, status, isPinned: pinned, isIgnored: ignored, existsInFork } = file;
1690
+ if (status === "identical" || status === "ahead") continue;
1691
+ if (ignored && existsInFork) {
1692
+ batchRestorePaths.push(filePath);
1693
+ } else if (ignored && !existsInFork) {
1694
+ batchRemovePaths.push(filePath);
1695
+ } else if (pinned && !file.renamedFrom && existsInFork) {
1696
+ batchRestorePaths.push(filePath);
1697
+ } else if (pinned && !file.renamedFrom && !existsInFork) {
1698
+ batchRemovePaths.push(filePath);
1699
+ }
1700
+ }
1701
+ onProgress?.("starting merge in fork...");
1702
+ await merge(forkPath, upstreamRef, { noCommit: true, noEdit: true });
1703
+ if (batchRestorePaths.length > 0) {
1704
+ onProgress?.(`batch restoring ${batchRestorePaths.length} pinned/ignored files...`);
1705
+ await batchRestoreToHead(forkPath, batchRestorePaths);
1706
+ }
1707
+ if (batchRemovePaths.length > 0) {
1708
+ onProgress?.(`batch removing ${batchRemovePaths.length} ignored files...`);
1709
+ await batchGitRm(forkPath, batchRemovePaths);
1710
+ for (const filePath of batchRemovePaths) {
1711
+ await removeFileFromWorktree(forkPath, filePath);
1712
+ }
1713
+ }
1714
+ for (const file of analyzedFiles) {
1715
+ const { path: filePath, status, isPinned: pinned, isIgnored: ignored, existsInFork } = file;
1716
+ if (status === "identical" || status === "ahead") {
1717
+ continue;
1718
+ }
1719
+ if (ignored) continue;
1720
+ if (pinned && !file.renamedFrom) continue;
1721
+ if (pinned && file.renamedFrom) {
1722
+ const oldPathExists = await fileExistsInWorktree(forkPath, file.renamedFrom);
1723
+ if (oldPathExists) {
1724
+ onProgress?.(`\u2192 ${file.renamedFrom} \u2192 ${filePath}: moving fork content (pinned rename)`);
1725
+ try {
1726
+ await gitMv(forkPath, file.renamedFrom, filePath);
1727
+ } catch {
1728
+ await removeFileFully(forkPath, file.renamedFrom);
1729
+ await checkoutFromRef(forkPath, "HEAD", file.renamedFrom).catch(() => {
1730
+ });
1731
+ }
1732
+ } else {
1733
+ onProgress?.(`\u2192 ${filePath}: keeping fork content (pinned rename, old path removed)`);
1734
+ await restoreToHead(forkPath, filePath);
1735
+ }
1736
+ continue;
1737
+ }
1738
+ if (status === "diverged") {
1739
+ onProgress?.(`\u2192 ${filePath}: using git merge result (diverged)`);
1740
+ continue;
1741
+ }
1742
+ if (status === "behind") {
1743
+ if (!existsInFork) {
1744
+ onProgress?.(`\u2192 ${filePath}: adding from upstream (new file)`);
1745
+ await checkoutFromRef(forkPath, upstreamRef, filePath);
1746
+ } else if (!file.existsInUpstream) {
1747
+ onProgress?.(`\u2192 ${filePath}: removing (deleted in upstream)`);
1748
+ await removeFileFully(forkPath, filePath);
1749
+ } else {
1750
+ onProgress?.(`\u2192 ${filePath}: accepting upstream (behind)`);
1751
+ await checkoutFromRef(forkPath, upstreamRef, filePath);
1752
+ }
1753
+ continue;
1754
+ }
1755
+ if (status === "deleted") {
1756
+ await removeFileFully(forkPath, filePath);
1757
+ continue;
1758
+ }
1759
+ if (status === "renamed" && file.renamedFrom) {
1760
+ const oldPath = file.renamedFrom;
1761
+ const oldPathExists = await fileExistsInWorktree(forkPath, oldPath);
1762
+ const newPathExists = await fileExistsInWorktree(forkPath, filePath);
1763
+ if (oldPathExists && !newPathExists) {
1764
+ onProgress?.(`\u2192 ${oldPath} \u2192 ${filePath}: moving (renamed in upstream)`);
1765
+ try {
1766
+ await gitMv(forkPath, oldPath, filePath);
1767
+ } catch {
1768
+ await removeFileFully(forkPath, oldPath);
1769
+ await checkoutFromRef(forkPath, upstreamRef, filePath);
1770
+ }
1771
+ } else if (oldPathExists && newPathExists) {
1772
+ onProgress?.(`\u2192 ${oldPath} \u2192 ${filePath}: completing rename (removing old)`);
1773
+ await removeFileFully(forkPath, oldPath);
1774
+ await checkoutFromRef(forkPath, upstreamRef, filePath);
1775
+ } else {
1776
+ onProgress?.(`\u2192 ${filePath}: updating from upstream (renamed)`);
1777
+ await checkoutFromRef(forkPath, upstreamRef, filePath);
1778
+ }
1779
+ }
1780
+ }
1781
+ const gitConflicts = await getConflictedFiles(forkPath);
1782
+ for (const filePath of gitConflicts) {
1783
+ const isProtected = isIgnored(filePath, config) || isPinnedForSync(filePath, config, config.unpinned);
1784
+ if (!isProtected) continue;
1785
+ if (await fileExistsAtRef(forkPath, "HEAD", filePath)) {
1786
+ onProgress?.(`\u2192 ${filePath}: keeping fork (protected conflict)`);
1787
+ await restoreToHead(forkPath, filePath);
1788
+ } else {
1789
+ onProgress?.(`\u2192 ${filePath}: removing (protected conflict, not in fork)`);
1790
+ await removeFileFully(forkPath, filePath);
1791
+ }
1792
+ }
1793
+ const remainingConflicts = await getConflictedFiles(forkPath);
1794
+ const remainingConflictSet = new Set(remainingConflicts);
1795
+ const autoMergedFiles = analyzedFiles.filter((file) => file.status === "diverged" && !remainingConflictSet.has(file.path)).map((file) => file.path);
1796
+ const stagedNewFiles = await getStagedNewFiles(forkPath);
1797
+ const staleIgnoredFiles = stagedNewFiles.filter((f) => isIgnored(f, config));
1798
+ if (staleIgnoredFiles.length > 0) {
1799
+ onProgress?.(`cleaning up ${staleIgnoredFiles.length} ignored files still in index...`);
1800
+ await batchUnstageFiles(forkPath, staleIgnoredFiles);
1801
+ for (const filePath of staleIgnoredFiles) {
1802
+ await removeFileFromWorktree(forkPath, filePath);
1803
+ }
1804
+ }
1805
+ return { remainingConflicts, analyzedFiles, autoMergedFiles };
1806
+ }
1807
+ function calculateSummary(files) {
1808
+ const summary = {
1809
+ managed: 0,
1810
+ identical: 0,
1811
+ ahead: 0,
1812
+ local: 0,
1813
+ drifted: 0,
1814
+ behind: 0,
1815
+ diverged: 0,
1816
+ pinned: 0,
1817
+ ignored: 0,
1818
+ deleted: 0,
1819
+ renamed: 0,
1820
+ total: files.length
1821
+ };
1822
+ for (const file of files) {
1823
+ if (file.status !== "identical" && isManagedFile(file.path)) {
1824
+ summary.managed++;
1825
+ continue;
1826
+ }
1827
+ summary[file.status]++;
1828
+ }
1829
+ return summary;
1830
+ }
1831
+ function formatLocalCheckoutDetail(forkPath, branch, headSha, changeCount, upstream) {
1832
+ const tracking = upstream.upstream ? `, tracking ${upstream.upstream} (${upstream.ahead} ahead, ${upstream.behind} behind)` : ", no tracking branch";
1833
+ const workingTree = changeCount === 0 ? " working tree clean" : ` ${changeCount} uncommitted change${changeCount === 1 ? "" : "s"} in working tree, not included in analysis`;
1834
+ return [`${forkPath}`, ` ${branch} @ ${headSha}${tracking}`, workingTree].join("\n");
1835
+ }
1836
+ async function prepareUpstream(config, onProgress, onStep) {
1837
+ const { forkPath } = config;
1838
+ onProgress?.("checking local checkout...");
1839
+ const [branch, headSha, changeCount, upstreamStatus] = await Promise.all([
1840
+ getCurrentBranch(forkPath),
1841
+ getShortSha(forkPath, "HEAD"),
1842
+ getWorkingTreeChangeCount(forkPath),
1843
+ getUpstreamStatus(forkPath)
1844
+ ]);
1845
+ onStep?.("local checkout", formatLocalCheckoutDetail(forkPath, branch, headSha, changeCount, upstreamStatus));
1846
+ onProgress?.("setting up upstream remote...");
1847
+ const { track: configTrack, branchRef } = resolveUpstream(config.settings);
1848
+ const remoteName = DEFAULT_UPSTREAM_REMOTE;
1849
+ const track = config.track ?? configTrack;
1850
+ await ensureRemote(forkPath, remoteName, config.settings.upstreamUrl);
1851
+ onProgress?.(`fetching upstream (${remoteName})...`);
1852
+ await fetch2(forkPath, remoteName);
1853
+ let upstreamRef = branchRef;
1854
+ let releaseTag;
1855
+ if (track === "release") {
1856
+ await fetchUpstreamTags(forkPath, remoteName);
1857
+ const latest = await resolveLatestReleaseTag(forkPath, remoteName);
1858
+ if (!latest) {
1859
+ throw new Error(
1860
+ `no upstream releases (v*) found on '${remoteName}'. Set upstreamTrack: 'branch' to follow ${branchRef}, or check that ${config.settings.upstreamUrl} publishes release tags.`
1861
+ );
1862
+ }
1863
+ upstreamRef = latest.ref;
1864
+ releaseTag = latest.tag;
1865
+ }
1866
+ config.upstreamRef = upstreamRef;
1867
+ onStep?.("remote configured", `${releaseTag ?? upstreamRef} \u2192 ${config.settings.upstreamUrl}`);
1868
+ onProgress?.("checking sync base...");
1869
+ await ensureSyncBase(forkPath, "HEAD", upstreamRef);
1870
+ const upstreamGitHubUrl = getGitHubBaseUrl(config.settings.upstreamUrl) ?? void 0;
1871
+ const aggressive = config.hard === true || config.unpinned === true;
1872
+ const mergeBase = aggressive ? await getMergeBase(forkPath, "HEAD", upstreamRef) : (await getEffectiveMergeBase(forkPath, "HEAD", upstreamRef)).base;
1873
+ const upstreamCommit = await getCommitInfo(forkPath, upstreamRef);
1874
+ const commitCount = await countCommitsBetween(forkPath, mergeBase, upstreamRef);
1875
+ const commitListMax = 50;
1876
+ const commitSkip = commitCount > commitListMax ? commitCount - commitListMax : 0;
1877
+ const upstreamCommits = commitCount > 0 ? await listCommitsBetween(forkPath, mergeBase, upstreamRef, {
1878
+ oldestFirst: true,
1879
+ skip: commitSkip,
1880
+ limit: commitListMax
1881
+ }) : [];
1882
+ onStep?.("fetched upstream", formatFetchedUpstreamDetail(commitCount, upstreamCommits, upstreamGitHubUrl));
1883
+ return { upstreamRef, releaseTag, mergeBase, upstreamGitHubUrl, upstreamCommit, upstreamCommits };
1884
+ }
1885
+ function resultMeta(config, ctx) {
1886
+ return {
1887
+ upstreamBranch: config.settings.upstreamBranch ?? "main",
1888
+ upstreamRef: ctx.upstreamRef,
1889
+ upstreamTag: ctx.releaseTag,
1890
+ upstreamGitHubUrl: ctx.upstreamGitHubUrl,
1891
+ upstreamCommit: ctx.upstreamCommit,
1892
+ upstreamCommits: ctx.upstreamCommits
1893
+ };
1894
+ }
1895
+ async function runSyncMerge(config, ctx, onProgress, onStep) {
1896
+ const { forkPath } = config;
1897
+ const { upstreamRef, releaseTag, mergeBase, upstreamGitHubUrl, upstreamCommit } = ctx;
1898
+ const { remainingConflicts, analyzedFiles, autoMergedFiles } = await applyDirectMerge(
1899
+ forkPath,
1900
+ upstreamRef,
1901
+ mergeBase,
1902
+ config,
1903
+ onProgress
1904
+ );
1905
+ const summary = calculateSummary(analyzedFiles);
1906
+ const synced = analyzedFiles.filter((file) => ["behind", "diverged", "renamed"].includes(file.status)).length;
1907
+ const totalResolved = analyzedFiles.filter(
1908
+ (file) => ["behind", "diverged", "renamed", "ignored", "pinned"].includes(file.status)
1909
+ ).length;
1910
+ const syncManifest = {
1911
+ upstream: {
1912
+ repo: upstreamGitHubUrl ? upstreamGitHubUrl.replace("https://github.com/", "") : void 0,
1913
+ track: releaseTag ? "release" : "branch",
1914
+ commit: upstreamCommit.hash,
1915
+ release: releaseTag ?? null,
1916
+ url: upstreamGitHubUrl ? releaseTag ? `${upstreamGitHubUrl}/releases/tag/${releaseTag}` : `${upstreamGitHubUrl}/commit/${upstreamCommit.hash}` : void 0,
1917
+ syncedAt: (/* @__PURE__ */ new Date()).toISOString()
1918
+ }
1919
+ };
1920
+ const recordSyncPoint = async () => {
1921
+ await storeLastSyncRef(forkPath, upstreamCommit.hash);
1922
+ await writeSyncManifest(forkPath, syncManifest);
1923
+ await stagePath(forkPath, MANIFEST_FILE);
1924
+ };
1925
+ if (remainingConflicts.length > 0) {
1926
+ await recordSyncPoint();
1927
+ onStep?.(
1928
+ "merge in progress",
1929
+ formatMergeInProgressDetail(remainingConflicts.length, autoMergedFiles, { forkPath }, 100)
1930
+ );
1931
+ } else if (totalResolved > 0) {
1932
+ const label = synced > 0 ? `${synced} files from upstream` : "upstream changes";
1933
+ await recordSyncPoint();
1934
+ onStep?.("synced", `${label} (staged, commit to finish)`);
1935
+ } else {
1936
+ await mergeAbort(forkPath);
1937
+ onStep?.("up to date", "no upstream changes to sync");
1938
+ }
1939
+ return {
1940
+ success: remainingConflicts.length === 0,
1941
+ files: analyzedFiles,
1942
+ summary,
1943
+ conflicts: remainingConflicts,
1944
+ autoMergedFiles,
1945
+ ...resultMeta(config, ctx)
1946
+ };
1947
+ }
1948
+ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
1949
+ const { forkPath } = config;
1950
+ const worktreePath = getWorktreePath(forkPath);
1951
+ onProgress?.("creating worktree in temp directory...");
1952
+ await createWorktree(forkPath, worktreePath, "HEAD");
1953
+ onStep?.("worktree created", worktreePath);
1954
+ onProgress?.("performing merge in worktree...");
1955
+ await merge(worktreePath, ctx.upstreamRef, { noCommit: true, noEdit: true });
1956
+ onStep?.("merge complete", "upstream merged into worktree");
1957
+ onProgress?.("analyzing files...");
1958
+ const analyzedFiles = await analyzeRefs(
1959
+ forkPath,
1960
+ "HEAD",
1961
+ ctx.upstreamRef,
1962
+ ctx.mergeBase,
1963
+ syncPredicates(config),
1964
+ onProgress
1965
+ );
1966
+ await enrichChangeInfo(forkPath, analyzedFiles, ctx.mergeBase, "HEAD", ctx.upstreamRef);
1967
+ onStep?.("analysis complete", `${analyzedFiles.length} files analyzed, dry run \u2014 no changes applied`);
1968
+ onProgress?.("cleaning up worktree...");
1969
+ await cleanupWorktree(forkPath, worktreePath);
1970
+ return {
1971
+ success: true,
1972
+ files: analyzedFiles,
1973
+ summary: calculateSummary(analyzedFiles),
1974
+ // For analyze mode, count diverged files as potential conflicts
1975
+ conflicts: analyzedFiles.filter((f) => f.status === "diverged").map((f) => f.path),
1976
+ ...resultMeta(config, ctx)
1977
+ };
1978
+ }
1979
+ async function runMergeEngine(config, options) {
1980
+ const { forkPath } = config;
1981
+ const worktreePath = getWorktreePath(forkPath);
1982
+ const { apply, onProgress, onStep } = options;
1983
+ await cleanupLeftoverWorktrees(forkPath);
1984
+ registerWorktree(forkPath, worktreePath);
1985
+ try {
1986
+ const ctx = await prepareUpstream(config, onProgress, onStep);
1987
+ return apply ? await runSyncMerge(config, ctx, onProgress, onStep) : await runAnalyzePreview(config, ctx, onProgress, onStep);
1988
+ } catch (error) {
1989
+ await cleanupWorktree(forkPath, worktreePath);
1990
+ throw error;
1991
+ }
1992
+ }
1993
+
1994
+ // src/services/analyze.ts
1995
+ var scopeStatuses = {
1996
+ all: /* @__PURE__ */ new Set(["ahead", "drifted", "diverged"]),
1997
+ risk: /* @__PURE__ */ new Set(["drifted", "diverged"]),
1998
+ protected: /* @__PURE__ */ new Set(["ahead"])
1999
+ };
2000
+ function filterByScope(files, scope) {
2001
+ const statuses = scopeStatuses[scope];
2002
+ return files.filter((f) => statuses.has(f.status));
2003
+ }
2004
+ function findTargetFile(files, targetPath) {
2005
+ const exact = files.find((f) => f.path === targetPath);
2006
+ if (exact) return exact;
2007
+ const normalized = targetPath.replace(/^\.\//, "");
2008
+ return files.find((f) => f.path === normalized);
2009
+ }
2010
+ function printUnifiedDiff(config, filePath) {
2011
+ const diff = gitDiffFile(config.forkPath, `${config.upstreamRef}..HEAD`, filePath, {
2012
+ dstPrefix: basename2(config.forkPath),
2013
+ color: "never"
2014
+ });
2015
+ writeStdout(diff.toString());
2016
+ }
2017
+ async function runAnalyze(config) {
2018
+ createSpinner("starting analysis...");
2019
+ const result = await runMergeEngine(config, {
2020
+ apply: false,
2021
+ onProgress: (message) => {
2022
+ spinnerText(message);
2023
+ },
2024
+ onStep: (label, detail) => {
2025
+ spinnerSuccess(label, detail);
2026
+ createSpinner("...");
2027
+ }
2028
+ });
2029
+ spinnerSuccess();
2030
+ const scopedFiles = filterByScope(result.files, config.scope ?? "all");
2031
+ if (config.diff) {
2032
+ const file = findTargetFile(scopedFiles, config.diff) ?? findTargetFile(result.files, config.diff);
2033
+ if (!file) throw new Error(`file not found in analysis results: ${config.diff}`);
2034
+ printUnifiedDiff(config, file.path);
2035
+ return result;
2036
+ }
2037
+ if (config.json) {
2038
+ const out = scopedFiles.map((f) => ({
2039
+ path: f.path,
2040
+ status: f.status,
2041
+ changedAt: f.changedAt ?? null,
2042
+ changedCommit: f.changedCommit ?? null,
2043
+ upstreamChangedAt: f.upstreamChangedAt ?? null,
2044
+ upstreamCommit: f.upstreamCommit ?? null
2045
+ }));
2046
+ writeStdout(JSON.stringify(out, null, 2));
2047
+ return result;
2048
+ }
2049
+ if (config.list) {
2050
+ for (const file of scopedFiles) {
2051
+ console.info(file.path);
2052
+ }
2053
+ return result;
2054
+ }
2055
+ const upstreamViewPath = await refreshViewWorktree(config.forkPath, config.upstreamRef);
2056
+ if (config.openDiff) {
2057
+ const file = findTargetFile(scopedFiles, config.openDiff) ?? findTargetFile(result.files, config.openDiff);
2058
+ if (!file) throw new Error(`file not found in analysis results: ${config.openDiff}`);
2059
+ openVsCodeDiff(config.forkPath, config.upstreamRef, upstreamViewPath, file.path);
2060
+ console.info(`${colors_default.green("\u2713")} opened VS Code diff for ${file.path}`);
2061
+ return result;
2062
+ }
2063
+ const linkOptions = {
2064
+ upstreamGitHubUrl: result.upstreamGitHubUrl,
2065
+ upstreamBranch: result.upstreamBranch,
2066
+ fileLinkMode: config.settings.fileLinkMode,
2067
+ upstreamViewPath,
2068
+ forkPath: config.forkPath
2069
+ };
2070
+ printAnalysisFileGroups(result.files, linkOptions);
2071
+ printSummary(result.summary, "analysis summary");
2072
+ if (config.logFile) {
2073
+ const logPath = writeLogFile(config.forkPath, result.files);
2074
+ console.info();
2075
+ console.info(colors_default.dim(`full file list written to: ${logPath}`));
2076
+ }
2077
+ console.info();
2078
+ return result;
2079
+ }
2080
+
2081
+ // src/services/audit.ts
2082
+ import { spawnSync as spawnSync3 } from "child_process";
2083
+ import * as fs2 from "fs";
2084
+ import * as path2 from "path";
2085
+ import { checkbox, confirm, Separator } from "@inquirer/prompts";
2086
+
2087
+ // src/utils/audit-utils.ts
2088
+ import { execFile as execFile2 } from "child_process";
2089
+ import * as fs from "fs";
2090
+ import * as path from "path";
2091
+ import { promisify as promisify2 } from "util";
2092
+ var execFileAsync2 = promisify2(execFile2);
2093
+ var CACHE_FILE = path.join(import.meta.dirname, "..", "..", ".audit.cache.json");
2094
+ var CACHE_TTL = 7 * 24 * 60 * 60 * 1e3;
2095
+ var CHANGELOG_PATHS = ["CHANGELOG.md", "CHANGELOG", "changelog.md", "HISTORY.md", "CHANGES.md", "NEWS.md"];
2096
+ var DEFAULT_BRANCHES = ["main", "master"];
2097
+ function loadCache() {
2098
+ try {
2099
+ if (fs.existsSync(CACHE_FILE)) {
2100
+ return JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8"));
2101
+ }
2102
+ } catch {
2103
+ }
2104
+ return {};
2105
+ }
2106
+ function saveCache(cache) {
2107
+ try {
2108
+ fs.writeFileSync(CACHE_FILE, `${JSON.stringify(cache, null, 2)}
2109
+ `);
2110
+ } catch {
2111
+ }
2112
+ }
2113
+ function clearCache() {
2114
+ try {
2115
+ if (fs.existsSync(CACHE_FILE)) {
2116
+ fs.unlinkSync(CACHE_FILE);
2117
+ console.info(colors_default.green("\u2713 cache cleared successfully"));
2118
+ } else {
2119
+ console.info(colors_default.yellow("no cache file found"));
2120
+ }
2121
+ } catch (err) {
2122
+ console.error(colors_default.red("failed to clear cache:"), err);
2123
+ }
2124
+ }
2125
+ async function fetchNpmMetadata(packageName) {
2126
+ try {
2127
+ const response = await fetch(`https://registry.npmjs.org/${packageName}`);
2128
+ if (!response.ok) return null;
2129
+ return await response.json();
2130
+ } catch {
2131
+ return null;
2132
+ }
2133
+ }
2134
+ function getRepoUrl(data) {
2135
+ if (!data?.repository?.url) return null;
2136
+ let url = data.repository.url;
2137
+ if (url.startsWith("git+")) {
2138
+ url = url.slice(4);
2139
+ }
2140
+ if (url.endsWith(".git")) {
2141
+ url = url.slice(0, -4);
2142
+ }
2143
+ if (url.startsWith("git://")) {
2144
+ url = url.replace("git://", "https://");
2145
+ }
2146
+ return url;
2147
+ }
2148
+ function isGitHubRepoUrl(repoUrl) {
2149
+ if (!repoUrl) return false;
2150
+ try {
2151
+ const parsed = new URL(repoUrl);
2152
+ return parsed.hostname === "github.com" || parsed.hostname === "www.github.com";
2153
+ } catch {
2154
+ return false;
2155
+ }
2156
+ }
2157
+ async function findGitHubFile(repoUrl, filePath) {
2158
+ if (!isGitHubRepoUrl(repoUrl)) return null;
2159
+ for (const branch of DEFAULT_BRANCHES) {
2160
+ const rawUrl = repoUrl.replace("github.com", "raw.githubusercontent.com").concat(`/${branch}/${filePath}`);
2161
+ try {
2162
+ const response = await fetch(rawUrl, { method: "HEAD" });
2163
+ if (response.ok) return branch;
2164
+ } catch {
2165
+ }
2166
+ }
2167
+ return null;
2168
+ }
2169
+ async function findChangelogUrl(repoUrl, packageName, cache) {
2170
+ if (!isGitHubRepoUrl(repoUrl)) return null;
2171
+ const cached = cache[packageName];
2172
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL) {
2173
+ return cached.changelogUrl;
2174
+ }
2175
+ for (const changelogPath of CHANGELOG_PATHS) {
2176
+ const branch = await findGitHubFile(repoUrl, changelogPath);
2177
+ if (branch) {
2178
+ const blobUrl = `${repoUrl}/blob/${branch}/${changelogPath}`;
2179
+ cache[packageName] = {
2180
+ repoUrl,
2181
+ changelogUrl: blobUrl,
2182
+ fetchedAt: Date.now()
2183
+ };
2184
+ return blobUrl;
2185
+ }
2186
+ }
2187
+ cache[packageName] = {
2188
+ repoUrl,
2189
+ changelogUrl: null,
2190
+ fetchedAt: Date.now()
2191
+ };
2192
+ return null;
2193
+ }
2194
+ function getReleasesUrl(repoUrl) {
2195
+ if (!isGitHubRepoUrl(repoUrl)) return null;
2196
+ return `${repoUrl}/releases`;
2197
+ }
2198
+ function isMajorVersionChange(current, latest) {
2199
+ const currentMajor = current.split(".")[0]?.replace(/^\D+/, "");
2200
+ const latestMajor = latest.split(".")[0]?.replace(/^\D+/, "");
2201
+ if (!currentMajor || !latestMajor) return false;
2202
+ return Number.parseInt(latestMajor, 10) > Number.parseInt(currentMajor, 10);
2203
+ }
2204
+ async function getOutdatedPackages(cwd) {
2205
+ try {
2206
+ const { stdout } = await execFileAsync2("pnpm", ["-r", "outdated", "--json"], {
2207
+ encoding: "utf-8",
2208
+ maxBuffer: 10 * 1024 * 1024,
2209
+ // 10MB buffer for large outputs
2210
+ cwd
2211
+ });
2212
+ if (!stdout || stdout.trim() === "") {
2213
+ return {};
2214
+ }
2215
+ return JSON.parse(stdout);
2216
+ } catch (error) {
2217
+ if (error instanceof Error && "stdout" in error) {
2218
+ const stdout = error.stdout;
2219
+ if (stdout?.trim()) {
2220
+ try {
2221
+ return JSON.parse(stdout);
2222
+ } catch {
2223
+ return {};
2224
+ }
2225
+ }
2226
+ }
2227
+ return {};
2228
+ }
2229
+ }
2230
+ async function runPnpmAudit(cwd) {
2231
+ try {
2232
+ const { stdout } = await execFileAsync2("pnpm", ["audit", "--json"], {
2233
+ encoding: "utf-8",
2234
+ maxBuffer: 10 * 1024 * 1024,
2235
+ cwd
2236
+ });
2237
+ if (!stdout || stdout.trim() === "") {
2238
+ return null;
2239
+ }
2240
+ return JSON.parse(stdout);
2241
+ } catch (error) {
2242
+ if (error instanceof Error && "stdout" in error) {
2243
+ const stdout = error.stdout;
2244
+ if (stdout?.trim()) {
2245
+ try {
2246
+ return JSON.parse(stdout);
2247
+ } catch {
2248
+ return null;
2249
+ }
2250
+ }
2251
+ }
2252
+ return null;
2253
+ }
2254
+ }
2255
+ function parseDependencyPath(paths) {
2256
+ for (const pathStr of paths) {
2257
+ const parts = pathStr.split(">");
2258
+ if (parts.length >= 2) {
2259
+ return {
2260
+ workspace: parts[0],
2261
+ directDependency: parts.length > 2 ? parts[1] : null
2262
+ };
2263
+ }
2264
+ }
2265
+ return { workspace: null, directDependency: null };
2266
+ }
2267
+ function buildVulnerabilityMap(auditResult) {
2268
+ const map = /* @__PURE__ */ new Map();
2269
+ if (!auditResult?.advisories) return map;
2270
+ for (const advisory of Object.values(auditResult.advisories)) {
2271
+ const allPaths = advisory.findings?.flatMap((f) => f.paths) || [];
2272
+ const { workspace, directDependency } = parseDependencyPath(allPaths);
2273
+ const existing = map.get(advisory.module_name) || [];
2274
+ existing.push({
2275
+ id: advisory.id,
2276
+ title: advisory.title,
2277
+ severity: advisory.severity,
2278
+ url: advisory.url,
2279
+ vulnerableVersions: advisory.vulnerable_versions,
2280
+ patchedVersions: advisory.patched_versions,
2281
+ cves: advisory.cves || [],
2282
+ workspace,
2283
+ directDependency
2284
+ });
2285
+ map.set(advisory.module_name, existing);
2286
+ }
2287
+ return map;
2288
+ }
2289
+ function getVulnIcon(severity) {
2290
+ switch (severity) {
2291
+ case "critical":
2292
+ return colors_default.red("\u25CF");
2293
+ case "high":
2294
+ return colors_default.red("\u25CF");
2295
+ case "moderate":
2296
+ return colors_default.yellow("\u25CF");
2297
+ case "low":
2298
+ return colors_default.blue("\u25CF");
2299
+ default:
2300
+ return colors_default.gray("\u25CF");
2301
+ }
2302
+ }
2303
+ function getHighestSeverity(vulns) {
2304
+ if (vulns.length === 0) return null;
2305
+ const order = ["critical", "high", "moderate", "low", "info"];
2306
+ for (const severity of order) {
2307
+ if (vulns.some((v) => v.severity === severity)) return severity;
2308
+ }
2309
+ return null;
2310
+ }
2311
+ function middleTruncate(str, maxLen) {
2312
+ if (str.length <= maxLen) return str;
2313
+ const ellipsis = "\u2026";
2314
+ const charsToShow = maxLen - ellipsis.length;
2315
+ const frontChars = Math.ceil(charsToShow / 2);
2316
+ const backChars = Math.floor(charsToShow / 2);
2317
+ return str.slice(0, frontChars) + ellipsis + str.slice(-backChars);
2318
+ }
2319
+ function formatDependents(dependents) {
2320
+ if (dependents.length === 0) return "";
2321
+ if (dependents.length === 1) return dependents[0];
2322
+ return `${dependents[0]} +${dependents.length - 1}`;
2323
+ }
2324
+
2325
+ // src/utils/yaml.ts
2326
+ function yamlBlockLines(content, key) {
2327
+ const lines = [];
2328
+ let inBlock = false;
2329
+ for (const line of content.split("\n")) {
2330
+ if (new RegExp(`^${key}:\\s*$`).test(line)) {
2331
+ inBlock = true;
2332
+ continue;
2333
+ }
2334
+ if (!inBlock) continue;
2335
+ const trimmed = line.trim();
2336
+ if (trimmed.startsWith("#")) continue;
2337
+ if (trimmed !== "" && !line.startsWith(" ") && !line.startsWith(" ")) break;
2338
+ lines.push(line);
2339
+ }
2340
+ return lines;
2341
+ }
2342
+ function parseYamlBlockMap(content, key) {
2343
+ const result = {};
2344
+ for (const line of yamlBlockLines(content, key)) {
2345
+ const match = line.match(/^\s+(.+?):\s+['"]?([^'"]+)['"]?\s*$/);
2346
+ if (match) {
2347
+ result[match[1].replace(/^['"]|['"]$/g, "")] = match[2];
2348
+ }
2349
+ }
2350
+ return result;
2351
+ }
2352
+ function parseYamlBlockList(content, key) {
2353
+ const items = [];
2354
+ for (const line of yamlBlockLines(content, key)) {
2355
+ const trimmed = line.trim();
2356
+ if (trimmed.startsWith("- ")) items.push(trimmed.slice(2).trim());
2357
+ }
2358
+ return items;
2359
+ }
2360
+
2361
+ // src/services/audit.ts
2362
+ async function runAudit(config, options = {}) {
2363
+ const { forkPath } = config;
2364
+ if (options.checkOverrides) {
2365
+ await checkOverrides(forkPath);
2366
+ return;
2367
+ }
2368
+ const spinner = createSpinner("checking for outdated packages & vulnerabilities...");
2369
+ try {
2370
+ if (options.force) {
2371
+ spinnerText("clearing pnpm metadata cache...");
2372
+ clearCache();
2373
+ spawnSync3("pnpm", ["cache", "delete", "*"], {
2374
+ cwd: forkPath,
2375
+ stdio: "ignore"
2376
+ });
2377
+ spinnerText("checking for outdated packages & vulnerabilities...");
2378
+ }
2379
+ const [outdatedPackages, auditResult] = await Promise.all([getOutdatedPackages(forkPath), runPnpmAudit(forkPath)]);
2380
+ const packageNames = Object.keys(outdatedPackages);
2381
+ const vulnMap = buildVulnerabilityMap(auditResult);
2382
+ if (packageNames.length === 0 && vulnMap.size === 0) {
2383
+ spinnerSuccess("all packages are up to date and secure");
2384
+ return;
2385
+ }
2386
+ const cache = loadCache();
2387
+ spinnerText(`found ${packageNames.length} outdated package(s) - fetching metadata...`);
2388
+ const pkgJsonCache = /* @__PURE__ */ new Map();
2389
+ const readDependentPkg = (location) => {
2390
+ let cached = pkgJsonCache.get(location);
2391
+ if (cached === void 0) {
2392
+ try {
2393
+ cached = JSON.parse(fs2.readFileSync(path2.join(location, "package.json"), "utf-8"));
2394
+ } catch {
2395
+ cached = null;
2396
+ }
2397
+ pkgJsonCache.set(location, cached ?? null);
2398
+ }
2399
+ return cached ?? null;
2400
+ };
2401
+ const dependentNameCache = /* @__PURE__ */ new Map();
2402
+ for (const name of packageNames) {
2403
+ for (const dep of outdatedPackages[name].dependentPackages) {
2404
+ if (!dependentNameCache.has(dep.location)) {
2405
+ dependentNameCache.set(dep.location, readDependentPkg(dep.location)?.name || path2.basename(dep.location));
2406
+ }
2407
+ }
2408
+ }
2409
+ const enhancedPackages = [];
2410
+ const batchSize = 10;
2411
+ for (let i = 0; i < packageNames.length; i += batchSize) {
2412
+ const batch = packageNames.slice(i, i + batchSize);
2413
+ const results = await Promise.all(
2414
+ batch.map(async (name) => {
2415
+ const pkg = outdatedPackages[name];
2416
+ const metadata = await fetchNpmMetadata(name);
2417
+ const repoUrl = getRepoUrl(metadata);
2418
+ const changelogUrl = await findChangelogUrl(repoUrl, name, cache);
2419
+ const pinnedIn = [];
2420
+ for (const d of pkg.dependentPackages) {
2421
+ const spec = readDependentPkg(d.location)?.[pkg.dependencyType]?.[name] || "";
2422
+ if (spec && !spec.startsWith("^") && !spec.startsWith("~") && !spec.startsWith(">")) {
2423
+ pinnedIn.push(d.name);
2424
+ }
2425
+ }
2426
+ return {
2427
+ name,
2428
+ current: pkg.current,
2429
+ latest: pkg.latest,
2430
+ dependents: pkg.dependentPackages.map((d) => d.name),
2431
+ dependentLocations: pkg.dependentPackages.map((d) => d.location),
2432
+ isDev: pkg.dependencyType === "devDependencies",
2433
+ isMajorUpdate: isMajorVersionChange(pkg.current, pkg.latest),
2434
+ pinnedIn,
2435
+ repoUrl,
2436
+ changelogUrl,
2437
+ releasesUrl: getReleasesUrl(repoUrl),
2438
+ vulnerabilities: vulnMap.get(name) || []
2439
+ };
2440
+ })
2441
+ );
2442
+ enhancedPackages.push(...results);
2443
+ }
2444
+ saveCache(cache);
2445
+ enhancedPackages.sort((a, b) => {
2446
+ const depA = formatDependents(a.dependents);
2447
+ const depB = formatDependents(b.dependents);
2448
+ const depCompare = depA.localeCompare(depB);
2449
+ if (depCompare !== 0) return depCompare;
2450
+ return a.name.localeCompare(b.name);
2451
+ });
2452
+ spinner.stop();
2453
+ if (enhancedPackages.length > 0) {
2454
+ printOutdatedResults(enhancedPackages, dependentNameCache, forkPath);
2455
+ }
2456
+ printVulnerabilityResults(auditResult, vulnMap);
2457
+ const hasVulns = vulnMap.size > 0;
2458
+ if ((enhancedPackages.length > 0 || hasVulns) && !config.list) {
2459
+ await promptForUpdates(enhancedPackages, forkPath, auditResult);
2460
+ }
2461
+ } finally {
2462
+ spinner.stop();
2463
+ }
2464
+ }
2465
+ function printOutdatedResults(enhancedPackages, dependentNameCache, forkPath) {
2466
+ const DEV_TAG = " (dev)";
2467
+ const MAX_NAME_LEN = 35;
2468
+ const MAX_DEPENDENTS_LEN = 15;
2469
+ const maxNameLen = Math.min(
2470
+ MAX_NAME_LEN,
2471
+ Math.max(7, ...enhancedPackages.map((p) => p.name.length + (p.isDev ? DEV_TAG.length : 0)))
2472
+ );
2473
+ const maxCurrentLen = Math.max(7, ...enhancedPackages.map((p) => p.current.length));
2474
+ const maxLatestLen = Math.max(6, ...enhancedPackages.map((p) => p.latest.length));
2475
+ const maxDependentsLen = Math.min(
2476
+ MAX_DEPENDENTS_LEN,
2477
+ Math.max(10, ...enhancedPackages.map((p) => formatDependents(p.dependents).length))
2478
+ );
2479
+ const header = [
2480
+ " ",
2481
+ colors_default.bold("package".padEnd(maxNameLen)),
2482
+ colors_default.bold("current".padEnd(maxCurrentLen)),
2483
+ colors_default.bold("latest".padEnd(maxLatestLen)),
2484
+ colors_default.bold("in".padEnd(maxDependentsLen)),
2485
+ colors_default.bold("links")
2486
+ ].join(" ");
2487
+ console.info(header);
2488
+ for (const pkg of enhancedPackages) {
2489
+ const highestSeverity = getHighestSeverity(pkg.vulnerabilities);
2490
+ const vulnIndicator = highestSeverity ? getVulnIcon(highestSeverity) : " ";
2491
+ const fullName = pkg.isDev ? `${pkg.name}${DEV_TAG}` : pkg.name;
2492
+ const displayName = middleTruncate(fullName, maxNameLen);
2493
+ const name = colors_default.white(displayName.padEnd(maxNameLen));
2494
+ const current = colors_default.red(pkg.current.padEnd(maxCurrentLen));
2495
+ const latestText = pkg.latest.padEnd(maxLatestLen);
2496
+ const latest = pkg.isMajorUpdate ? colors_default.bold(colors_default.green(latestText)) : colors_default.green(latestText);
2497
+ const dependentsText = formatDependents(pkg.dependents);
2498
+ const dependents = colors_default.dim(middleTruncate(dependentsText, maxDependentsLen).padEnd(maxDependentsLen));
2499
+ const links = [];
2500
+ if (pkg.changelogUrl) {
2501
+ links.push(hyperlink(colors_default.magenta("log"), pkg.changelogUrl));
2502
+ }
2503
+ if (pkg.releasesUrl) {
2504
+ links.push(hyperlink(colors_default.blue("rel"), pkg.releasesUrl));
2505
+ }
2506
+ if (pkg.repoUrl) {
2507
+ links.push(hyperlink(colors_default.cyan("repo"), pkg.repoUrl));
2508
+ }
2509
+ const linksStr = links.length > 0 ? links.join(" ") : colors_default.dim("n/a");
2510
+ console.info(`${vulnIndicator} ${name} ${current} ${latest} ${dependents} ${linksStr}`);
2511
+ }
2512
+ console.info();
2513
+ const dependentMap = /* @__PURE__ */ new Map();
2514
+ for (const pkg of enhancedPackages) {
2515
+ for (const loc of pkg.dependentLocations) {
2516
+ const dependentName = dependentNameCache.get(loc) || path2.basename(loc);
2517
+ const existing = dependentMap.get(dependentName);
2518
+ if (existing) {
2519
+ existing.count++;
2520
+ } else {
2521
+ dependentMap.set(dependentName, { location: loc, count: 1 });
2522
+ }
2523
+ }
2524
+ }
2525
+ const sortedDependents = [...dependentMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
2526
+ const maxDependentNameLen = Math.max(0, ...sortedDependents.map(([name]) => name.length));
2527
+ for (const [dependentName, { location, count }] of sortedDependents) {
2528
+ const packageJsonPath = path2.join(location, "package.json");
2529
+ const relativePath = path2.relative(forkPath, packageJsonPath);
2530
+ const paddedName = dependentName.padEnd(maxDependentNameLen);
2531
+ console.info(
2532
+ ` ${colors_default.dim("\u2022")} ${paddedName} ${hyperlink(relativePath, `file://${packageJsonPath}`)} ${colors_default.dim(`${count}`)}`
2533
+ );
2534
+ }
2535
+ }
2536
+ async function promptForUpdates(packages, forkPath, auditResult) {
2537
+ const hasVulnerabilities = auditResult !== null && Object.values(auditResult.metadata?.vulnerabilities || {}).some((c) => c > 0);
2538
+ const hasPackages = packages.length > 0;
2539
+ let confirmMessage = "update packages?";
2540
+ if (hasVulnerabilities && hasPackages) {
2541
+ confirmMessage = "update packages and fix vulnerabilities?";
2542
+ } else if (hasVulnerabilities) {
2543
+ confirmMessage = "fix vulnerabilities?";
2544
+ }
2545
+ let wantsUpdate;
2546
+ try {
2547
+ wantsUpdate = await confirm({
2548
+ message: confirmMessage,
2549
+ default: true
2550
+ });
2551
+ } catch {
2552
+ return;
2553
+ }
2554
+ if (!wantsUpdate) return;
2555
+ console.info();
2556
+ const makeChoice = (pkg) => {
2557
+ const devTag = pkg.isDev ? colors_default.dim(" (dev)") : "";
2558
+ const version = `${colors_default.red(pkg.current)} \u2192 ${colors_default.green(pkg.latest)}`;
2559
+ const pinnedWarning = pkg.pinnedIn.length > 0 ? ` ${warningMark} ${colors_default.dim(`pinned in ${pkg.pinnedIn.join(", ")}`)}` : "";
2560
+ return {
2561
+ name: `${pkg.name}${devTag} ${version}${pinnedWarning}`,
2562
+ value: pkg.name,
2563
+ checked: false
2564
+ };
2565
+ };
2566
+ const AUDIT_FIX_VALUE = "__audit_fix__";
2567
+ const pinned = packages.filter((p) => p.pinnedIn.length > 0);
2568
+ const regular = packages.filter((p) => p.pinnedIn.length === 0);
2569
+ const choices = [];
2570
+ if (hasVulnerabilities) {
2571
+ const vulnMeta = auditResult?.metadata?.vulnerabilities || {};
2572
+ const parts = [];
2573
+ if (vulnMeta.critical) parts.push(colors_default.red(`${vulnMeta.critical} critical`));
2574
+ if (vulnMeta.high) parts.push(colors_default.red(`${vulnMeta.high} high`));
2575
+ if (vulnMeta.moderate) parts.push(colors_default.yellow(`${vulnMeta.moderate} moderate`));
2576
+ if (vulnMeta.low) parts.push(colors_default.blue(`${vulnMeta.low} low`));
2577
+ const vulnSummary = parts.length > 0 ? ` ${colors_default.dim("\xB7")} ${parts.join(" ")}` : "";
2578
+ choices.push(new Separator(colors_default.red(`\u2500\u2500 vulnerabilities${vulnSummary} \u2500\u2500`)));
2579
+ choices.push({
2580
+ name: `${colors_default.cyan("pnpm audit --fix override")} ${colors_default.dim("add overrides for non-vulnerable versions")}`,
2581
+ value: AUDIT_FIX_VALUE,
2582
+ checked: false
2583
+ });
2584
+ }
2585
+ if (pinned.length > 0) {
2586
+ choices.push(new Separator(colors_default.yellow(`\u2500\u2500 pinned (${pinned.length}) \u2500\u2500`)));
2587
+ choices.push(...pinned.map(makeChoice));
2588
+ }
2589
+ if (regular.length > 0) {
2590
+ if (pinned.length > 0 || hasVulnerabilities) {
2591
+ choices.push(new Separator(colors_default.dim(`\u2500\u2500 packages (${regular.length}) \u2500\u2500`)));
2592
+ }
2593
+ choices.push(...regular.map(makeChoice));
2594
+ }
2595
+ let selected;
2596
+ try {
2597
+ selected = await checkbox({
2598
+ message: "toggle packages to update",
2599
+ choices,
2600
+ required: true,
2601
+ pageSize: 20,
2602
+ loop: false,
2603
+ theme: {
2604
+ icon: {
2605
+ cursor: "\u276F "
2606
+ },
2607
+ style: {
2608
+ highlight: (text) => text,
2609
+ keysHelpTip: (keys) => {
2610
+ const tips = keys.map(([key, action]) => `${colors_default.dim(key)} ${action}`);
2611
+ return tips.join(" ");
2612
+ },
2613
+ renderSelectedChoices: (selectedChoices) => colors_default.dim(`${selectedChoices.length} package${selectedChoices.length !== 1 ? "s" : ""} selected`)
2614
+ }
2615
+ }
2616
+ });
2617
+ } catch {
2618
+ return;
2619
+ }
2620
+ const runAuditFix = selected.includes(AUDIT_FIX_VALUE);
2621
+ const selectedPackages = selected.filter((s) => s !== AUDIT_FIX_VALUE);
2622
+ const summaryParts = [];
2623
+ if (selectedPackages.length > 0) {
2624
+ summaryParts.push(`updating ${selectedPackages.length} package${selectedPackages.length !== 1 ? "s" : ""}`);
2625
+ }
2626
+ if (runAuditFix) {
2627
+ summaryParts.push("then updating dependencies");
2628
+ }
2629
+ console.info();
2630
+ console.info(colors_default.green(`\u2713 ${summaryParts.join(" ")}`));
2631
+ console.info();
2632
+ const selectedMajor = packages.filter((p) => selectedPackages.includes(p.name) && p.isMajorUpdate);
2633
+ if (selectedMajor.length > 0) {
2634
+ const majorNames = selectedMajor.map((p) => colors_default.bold(p.name)).join(", ");
2635
+ try {
2636
+ const proceed = await confirm({
2637
+ message: `${selectedMajor.length} major update${selectedMajor.length > 1 ? "s" : ""} selected (${majorNames}). proceed?`,
2638
+ default: true
2639
+ });
2640
+ if (!proceed) {
2641
+ console.info(colors_default.dim(" update cancelled"));
2642
+ return;
2643
+ }
2644
+ } catch {
2645
+ return;
2646
+ }
2647
+ }
2648
+ const catalogPackages = readCatalogPackageNames(forkPath);
2649
+ const workspacePackages = /* @__PURE__ */ new Map();
2650
+ for (const pkgName of selectedPackages) {
2651
+ const pkg = packages.find((p) => p.name === pkgName);
2652
+ if (!pkg) continue;
2653
+ for (let i = 0; i < pkg.dependents.length; i++) {
2654
+ const dependent = pkg.dependents[i];
2655
+ if (!workspacePackages.has(dependent)) {
2656
+ workspacePackages.set(dependent, { packages: [] });
2657
+ }
2658
+ workspacePackages.get(dependent)?.packages.push(pkgName);
2659
+ }
2660
+ }
2661
+ if (runAuditFix) {
2662
+ console.info(colors_default.dim("running pnpm audit --fix override..."));
2663
+ const auditFixResult = spawnSync3("pnpm", ["audit", "--fix", "override"], {
2664
+ cwd: forkPath,
2665
+ stdio: "inherit"
2666
+ });
2667
+ console.info();
2668
+ if (auditFixResult.status === 0) {
2669
+ console.info(colors_default.green("\u2713 pnpm audit --fix override completed"));
2670
+ } else {
2671
+ console.error(colors_default.red(`\u2717 pnpm audit --fix override failed (exit code ${auditFixResult.status})`));
2672
+ }
2673
+ }
2674
+ for (const [workspace, { packages: pkgs }] of workspacePackages) {
2675
+ const pkgList = pkgs.join(", ");
2676
+ console.info(`${colors_default.cyan("\u2191")} ${colors_default.bold(workspace)}: ${colors_default.dim(pkgList)}`);
2677
+ }
2678
+ if (selectedPackages.length > 0) {
2679
+ console.info();
2680
+ const selectedCatalogPackages = selectedPackages.filter((pkgName) => catalogPackages.has(pkgName));
2681
+ if (selectedCatalogPackages.length > 0) {
2682
+ console.info(colors_default.dim(`normalizing catalog consumers: ${selectedCatalogPackages.join(", ")}`));
2683
+ const normalizeResult = spawnSync3("pnpm", ["-r", "up", ...selectedCatalogPackages], {
2684
+ cwd: forkPath,
2685
+ stdio: "inherit"
2686
+ });
2687
+ console.info();
2688
+ if (normalizeResult.status !== 0) {
2689
+ console.error(colors_default.red(`\u2717 catalog normalization failed (exit code ${normalizeResult.status})`));
2690
+ return;
2691
+ }
2692
+ }
2693
+ const result = spawnSync3("pnpm", ["-r", "up", "--latest", ...selectedPackages], {
2694
+ cwd: forkPath,
2695
+ stdio: "inherit"
2696
+ });
2697
+ console.info();
2698
+ if (result.status === 0) {
2699
+ console.info(colors_default.green(`\u2713 ${selectedPackages.length} package${selectedPackages.length > 1 ? "s" : ""} updated`));
2700
+ } else {
2701
+ console.error(colors_default.red(`\u2717 update failed (exit code ${result.status})`));
2702
+ }
2703
+ }
2704
+ }
2705
+ function printVulnerabilityResults(auditResult, vulnMap) {
2706
+ if (auditResult && vulnMap.size > 0) {
2707
+ const vulnMeta = auditResult.metadata?.vulnerabilities || {};
2708
+ const criticalCount = vulnMeta.critical || 0;
2709
+ const highCount = vulnMeta.high || 0;
2710
+ const moderateCount = vulnMeta.moderate || 0;
2711
+ const lowCount = vulnMeta.low || 0;
2712
+ console.info();
2713
+ console.info(
2714
+ `${colors_default.red("\u26A0")} ${colors_default.bold("vulnerabilities")} ${colors_default.dim("\xB7")} ` + (criticalCount > 0 ? colors_default.red(`${criticalCount} critical `) : "") + (highCount > 0 ? colors_default.red(`${highCount} high `) : "") + (moderateCount > 0 ? colors_default.yellow(`${moderateCount} moderate `) : "") + (lowCount > 0 ? colors_default.blue(`${lowCount} low`) : "")
2715
+ );
2716
+ console.info("\u2500".repeat(60));
2717
+ for (const [pkgName, vulns] of vulnMap.entries()) {
2718
+ for (const vuln of vulns) {
2719
+ const severityIcon = getVulnIcon(vuln.severity);
2720
+ const cveStr = vuln.cves.length > 0 ? colors_default.dim(` ${vuln.cves[0]}`) : "";
2721
+ let sourceStr = "";
2722
+ if (vuln.workspace) {
2723
+ sourceStr = colors_default.dim(` in ${vuln.workspace}`);
2724
+ if (vuln.directDependency) {
2725
+ sourceStr += colors_default.dim(` via ${vuln.directDependency}`);
2726
+ }
2727
+ }
2728
+ const title = middleTruncate(vuln.title, 60);
2729
+ console.info(
2730
+ `${severityIcon} ${colors_default.white(pkgName)} ${colors_default.dim(vuln.vulnerableVersions)}${sourceStr} ${colors_default.dim("\xB7")} ${title}${cveStr}`
2731
+ );
2732
+ }
2733
+ }
2734
+ } else {
2735
+ console.info();
2736
+ console.info(`${colors_default.green("\u2713")} no vulnerabilities found`);
2737
+ }
2738
+ console.info();
2739
+ }
2740
+ function readCatalogPackageNames(forkPath) {
2741
+ const workspacePath = path2.join(forkPath, "pnpm-workspace.yaml");
2742
+ try {
2743
+ const content = fs2.readFileSync(workspacePath, "utf-8");
2744
+ return new Set(Object.keys(parseYamlBlockMap(content, "catalog")));
2745
+ } catch {
2746
+ return /* @__PURE__ */ new Set();
2747
+ }
2748
+ }
2749
+ function readOverrides(forkPath) {
2750
+ const overrides = /* @__PURE__ */ new Map();
2751
+ const pkgJsonPath = path2.join(forkPath, "package.json");
2752
+ try {
2753
+ const pkgJson = JSON.parse(fs2.readFileSync(pkgJsonPath, "utf-8"));
2754
+ const pnpmOverrides = pkgJson.pnpm?.overrides || {};
2755
+ for (const [key, value] of Object.entries(pnpmOverrides)) {
2756
+ overrides.set(key, { target: value, source: "package.json" });
2757
+ }
2758
+ } catch {
2759
+ }
2760
+ const workspacePath = path2.join(forkPath, "pnpm-workspace.yaml");
2761
+ try {
2762
+ const content = fs2.readFileSync(workspacePath, "utf-8");
2763
+ const wsOverrides = parseYamlBlockMap(content, "overrides");
2764
+ for (const [key, value] of Object.entries(wsOverrides)) {
2765
+ overrides.set(key, { target: value, source: "pnpm-workspace.yaml" });
2766
+ }
2767
+ } catch {
2768
+ }
2769
+ return overrides;
2770
+ }
2771
+ async function checkOverrides(forkPath) {
2772
+ const spinner = createSpinner("checking overrides against current audit...");
2773
+ const overrides = readOverrides(forkPath);
2774
+ if (overrides.size === 0) {
2775
+ spinnerSuccess("no pnpm.overrides found");
2776
+ return;
2777
+ }
2778
+ const auditResult = await runPnpmAudit(forkPath);
2779
+ const activeAdvisoryPackages = /* @__PURE__ */ new Set();
2780
+ if (auditResult?.advisories) {
2781
+ for (const advisory of Object.values(auditResult.advisories)) {
2782
+ activeAdvisoryPackages.add(advisory.module_name);
2783
+ }
2784
+ }
2785
+ spinner.stop();
2786
+ const securityOverrides = [];
2787
+ const pinOverrides = [];
2788
+ for (const [key, { target, source }] of overrides) {
2789
+ const atIndex = key.indexOf("@", key.startsWith("@") ? 1 : 0);
2790
+ if (atIndex > 0) {
2791
+ const pkg = key.slice(0, atIndex);
2792
+ const hasActiveAdvisory = activeAdvisoryPackages.has(pkg);
2793
+ const status = hasActiveAdvisory ? "active" : "likely stale";
2794
+ securityOverrides.push({ key, pkg, target, source, status });
2795
+ } else {
2796
+ pinOverrides.push({ key, target, source });
2797
+ }
2798
+ }
2799
+ const allKeys = Array.from(overrides.keys());
2800
+ const allValues = Array.from(overrides.values());
2801
+ const maxKeyLen = Math.max(8, ...allKeys.map((k) => k.length));
2802
+ const maxTargetLen = Math.max(6, ...allValues.map((v) => v.target.length));
2803
+ if (securityOverrides.length > 0) {
2804
+ console.info(colors_default.bold(`security overrides (${securityOverrides.length})`));
2805
+ console.info("\u2500".repeat(60));
2806
+ for (const o of securityOverrides) {
2807
+ const icon = o.status === "active" ? colors_default.red("\u25CF") : colors_default.green("\u25CB");
2808
+ const statusText = o.status === "active" ? colors_default.red("active") : colors_default.green("likely stale");
2809
+ const keyText = colors_default.white(o.key.padEnd(maxKeyLen));
2810
+ const targetText = colors_default.dim(o.target.padEnd(maxTargetLen));
2811
+ const sourceText = colors_default.dim(o.source);
2812
+ console.info(` ${icon} ${keyText} \u2192 ${targetText} ${statusText} ${sourceText}`);
2813
+ }
2814
+ console.info();
2815
+ }
2816
+ if (pinOverrides.length > 0) {
2817
+ console.info(colors_default.bold(`version pins (${pinOverrides.length})`));
2818
+ console.info("\u2500".repeat(60));
2819
+ for (const o of pinOverrides) {
2820
+ const keyText = colors_default.white(o.key.padEnd(maxKeyLen));
2821
+ const targetText = colors_default.dim(o.target.padEnd(maxTargetLen));
2822
+ const sourceText = colors_default.dim(o.source);
2823
+ console.info(` ${colors_default.blue("\u25C6")} ${keyText} \u2192 ${targetText} ${sourceText}`);
2824
+ }
2825
+ console.info();
2826
+ }
2827
+ const staleCount = securityOverrides.filter((o) => o.status === "likely stale").length;
2828
+ const activeCount = securityOverrides.filter((o) => o.status === "active").length;
2829
+ if (staleCount > 0) {
2830
+ console.info(
2831
+ `${colors_default.green("\u25CB")} ${staleCount} override${staleCount !== 1 ? "s" : ""} likely stale \u2014 no matching advisory found in current audit`
2832
+ );
2833
+ console.info(colors_default.dim(" overrides mask vulnerabilities \u2014 verify by temporarily removing and running pnpm audit"));
2834
+ }
2835
+ if (activeCount > 0) {
2836
+ console.info(
2837
+ `${colors_default.red("\u25CF")} ${activeCount} override${activeCount !== 1 ? "s" : ""} still active \u2014 advisory present without override`
2838
+ );
2839
+ }
2840
+ if (pinOverrides.length > 0) {
2841
+ console.info(
2842
+ `${colors_default.blue("\u25C6")} ${pinOverrides.length} version pin${pinOverrides.length !== 1 ? "s" : ""} \u2014 not security-related, review manually`
2843
+ );
2844
+ }
2845
+ console.info();
2846
+ }
2847
+
2848
+ // src/services/contributions.ts
2849
+ import { readFileSync as readFileSync4 } from "fs";
2850
+ import { join as join12 } from "path";
2851
+ import {
2852
+ createPrompt,
2853
+ isDownKey,
2854
+ isEnterKey,
2855
+ isSpaceKey,
2856
+ isUpKey,
2857
+ useKeypress,
2858
+ useMemo,
2859
+ usePagination,
2860
+ useState
2861
+ } from "@inquirer/core";
2862
+ import { select as select2 } from "@inquirer/prompts";
2863
+
2864
+ // src/services/contrib-core.ts
2865
+ import { dirname as dirname2, join as join11 } from "path";
2866
+ async function listFilesAtRef(repoPath, ref) {
2867
+ const raw = await git(["ls-tree", "-r", "--name-only", ref], repoPath);
2868
+ return new Set(raw.split("\n").filter(Boolean));
2869
+ }
2870
+ async function detectContributableFiles(repoPath, baseRef, forkRef, config, forkTerritory = []) {
2871
+ const mergeBase = (await git(["merge-base", baseRef, forkRef], repoPath, { ignoreErrors: true })).trim() || baseRef;
2872
+ const predicates = {
2873
+ isIgnored: (path4) => isIgnored(path4, config) || isUnderAnyFolder(path4, forkTerritory),
2874
+ isPinned: (path4) => isPinned(path4, config)
2875
+ };
2876
+ const analyzed = await analyzeRefs(repoPath, baseRef, forkRef, mergeBase, predicates);
2877
+ await enrichChangeInfo(repoPath, analyzed, mergeBase, baseRef, forkRef);
2878
+ const baseFiles = await listFilesAtRef(repoPath, baseRef);
2879
+ const baseDirs = /* @__PURE__ */ new Set(["."]);
2880
+ for (const f of baseFiles) {
2881
+ let dir = dirname2(f);
2882
+ while (dir !== ".") {
2883
+ baseDirs.add(dir);
2884
+ dir = dirname2(dir);
2885
+ }
2886
+ }
2887
+ const files = [];
2888
+ for (const file of analyzed) {
2889
+ if (file.status !== "behind" && file.status !== "diverged") continue;
2890
+ if (file.isIgnored || file.isPinned) continue;
2891
+ const inCella = file.existsInFork;
2892
+ const inFork = file.existsInUpstream;
2893
+ let kind;
2894
+ if (inCella && !inFork) {
2895
+ kind = "deleted";
2896
+ } else if (!inCella && inFork) {
2897
+ if (!baseDirs.has(dirname2(file.path))) continue;
2898
+ kind = "created";
2899
+ } else {
2900
+ kind = "modified";
2901
+ }
2902
+ const changedAt = file.status === "diverged" ? file.upstreamChangedAt : file.changedAt;
2903
+ files.push({ path: file.path, kind, status: file.status, changedAt });
2904
+ }
2905
+ return {
2906
+ modified: files.filter((f) => f.kind === "modified").map((f) => f.path),
2907
+ created: files.filter((f) => f.kind === "created").map((f) => f.path),
2908
+ deleted: files.filter((f) => f.kind === "deleted").map((f) => f.path),
2909
+ files
2910
+ };
2911
+ }
2912
+ function countDetection(detection) {
2913
+ return detection.modified.length + detection.created.length + detection.deleted.length;
2914
+ }
2915
+ async function readBlobInfo(repoPath, ref, filePath) {
2916
+ const line = await git(["ls-tree", ref, "--", filePath], repoPath, { ignoreErrors: true });
2917
+ if (!line) return null;
2918
+ const match = line.match(/^(\d{6}) (\w+) ([0-9a-f]+)\t/);
2919
+ if (!match) return null;
2920
+ const [, mode, type, hash] = match;
2921
+ if (type !== "blob") return null;
2922
+ if (mode !== "100644" && mode !== "100755") return null;
2923
+ return { mode, hash };
2924
+ }
2925
+ async function buildContribBranch(repoPath, baseRef, forkRef, detection, forkName) {
2926
+ const branch = `contrib/${forkName}`;
2927
+ const tmpIndex = join11(repoPath, ".git", `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
2928
+ const indexEnv = { GIT_INDEX_FILE: tmpIndex };
2929
+ try {
2930
+ await git(["read-tree", baseRef], repoPath, { env: indexEnv });
2931
+ const appliedFiles = [];
2932
+ for (const filePath of [...detection.modified, ...detection.created]) {
2933
+ const blob = await readBlobInfo(repoPath, forkRef, filePath);
2934
+ if (!blob) continue;
2935
+ await git(
2936
+ ["update-index", "--add", "--replace", "--cacheinfo", `${blob.mode},${blob.hash},${filePath}`],
2937
+ repoPath,
2938
+ {
2939
+ env: indexEnv
2940
+ }
2941
+ );
2942
+ appliedFiles.push(filePath);
2943
+ }
2944
+ for (const filePath of detection.deleted) {
2945
+ await git(["update-index", "--remove", filePath], repoPath, { env: indexEnv, ignoreErrors: true });
2946
+ appliedFiles.push(`(deleted) ${filePath}`);
2947
+ }
2948
+ if (appliedFiles.length === 0) {
2949
+ return { branch, appliedFiles };
2950
+ }
2951
+ const treeHash = await git(["write-tree"], repoPath, { env: indexEnv });
2952
+ const parentHash = await git(["rev-parse", baseRef], repoPath);
2953
+ const commitBody = appliedFiles.map((f) => `- ${f}`).join("\n");
2954
+ const commitMessage = `contrib(${forkName}): ${appliedFiles.length} files
2955
+
2956
+ ${commitBody}`;
2957
+ const commitHash = await git(["commit-tree", treeHash, "-p", parentHash, "-m", commitMessage], repoPath);
2958
+ await git(["update-ref", `refs/heads/${branch}`, commitHash], repoPath);
2959
+ return { branch, appliedFiles };
2960
+ } finally {
2961
+ try {
2962
+ const { unlinkSync: unlinkSync2 } = await import("fs");
2963
+ unlinkSync2(tmpIndex);
2964
+ } catch {
2965
+ }
2966
+ }
2967
+ }
2968
+
2969
+ // src/services/fork-utils.ts
2970
+ import { existsSync as existsSync8 } from "fs";
2971
+ import { resolve as resolve4 } from "path";
2972
+ function validateForkPath(fork, basePath, requireConfig = false) {
2973
+ const resolvedPath = resolve4(basePath, fork.localPath);
2974
+ if (!resolvedPath.startsWith(resolve4(basePath, ".."))) {
2975
+ return { fork, resolvedPath, valid: false, error: "path resolves outside parent directory" };
2976
+ }
2977
+ if (!existsSync8(resolvedPath)) return { fork, resolvedPath, valid: false, error: "path does not exist" };
2978
+ if (!existsSync8(`${resolvedPath}/.git`)) return { fork, resolvedPath, valid: false, error: "not a git repository" };
2979
+ if (requireConfig && !existsSync8(`${resolvedPath}/cella.config.ts`)) {
2980
+ return { fork, resolvedPath, valid: false, error: "missing cella.config.ts" };
2981
+ }
2982
+ return { fork, resolvedPath, valid: true };
2983
+ }
2984
+ function printNoForksHint(action) {
2985
+ console.info(colors_default.yellow("no forks configured in cella.config.ts"));
2986
+ console.info(colors_default.dim(action));
2987
+ console.info(colors_default.dim(` forks: [{ name: 'my-app', localPath: '../my-app', pullBranch: 'development' }]`));
2988
+ }
2989
+
2990
+ // src/services/contributions.ts
2991
+ function showContribDiff(item, baseRef, cwd, forkName) {
2992
+ const diff = gitDiffFile(cwd, `${baseRef}..${item.ref}`, item.path, { dstPrefix: forkName, color: "always" });
2993
+ showDiffInPager(diff);
2994
+ }
2995
+ function shouldApplyContributionsUnstaged(repoPath) {
2996
+ return JSON.parse(readFileSync4(join12(repoPath, "package.json"), "utf8")).name === "cella";
2997
+ }
2998
+ var contribPrompt = createPrompt((config, done) => {
2999
+ const { pageSize = 20, baseRef, cwd, forkName } = config;
3000
+ const [items, setItems] = useState(config.items);
3001
+ const [active, setActive] = useState(0);
3002
+ const [statusMsg, setStatusMsg] = useState("");
3003
+ const [promptStatus, setPromptStatus] = useState("idle");
3004
+ const bounds = useMemo(() => {
3005
+ if (items.length === 0) return { first: 0, last: 0 };
3006
+ return { first: 0, last: items.length - 1 };
3007
+ }, [items]);
3008
+ useKeypress(async (key) => {
3009
+ if (items.length === 0) {
3010
+ if (isEnterKey(key) || key.name === "q") {
3011
+ setPromptStatus("done");
3012
+ done([]);
3013
+ }
3014
+ return;
3015
+ }
3016
+ if (key.name === "q") {
3017
+ setPromptStatus("done");
3018
+ done([]);
3019
+ return;
3020
+ }
3021
+ if (isEnterKey(key)) {
3022
+ const selected = items.filter((i) => i.checked);
3023
+ if (selected.length === 0) {
3024
+ setStatusMsg("select files with space first, or q to quit");
3025
+ return;
3026
+ }
3027
+ setPromptStatus("done");
3028
+ done(selected);
3029
+ return;
3030
+ }
3031
+ if (key.ctrl && isUpKey(key)) {
3032
+ setActive(bounds.first);
3033
+ setStatusMsg("");
3034
+ return;
3035
+ }
3036
+ if (key.ctrl && isDownKey(key)) {
3037
+ setActive(bounds.last);
3038
+ setStatusMsg("");
3039
+ return;
3040
+ }
3041
+ if (isUpKey(key)) {
3042
+ setActive(active <= bounds.first ? bounds.first : active - 1);
3043
+ setStatusMsg("");
3044
+ return;
3045
+ }
3046
+ if (isDownKey(key)) {
3047
+ setActive(active >= bounds.last ? bounds.last : active + 1);
3048
+ setStatusMsg("");
3049
+ return;
3050
+ }
3051
+ if (isSpaceKey(key)) {
3052
+ setItems(items.map((item, i) => i === active ? { ...item, checked: !item.checked } : item));
3053
+ return;
3054
+ }
3055
+ if (key.name === "d") {
3056
+ showContribDiff(items[active], baseRef, cwd, forkName);
3057
+ setStatusMsg(`viewed ${items[active].path}`);
3058
+ return;
3059
+ }
3060
+ if (key.name === "a") {
3061
+ const allChecked = items.every((item) => item.checked);
3062
+ setItems(items.map((item) => ({ ...item, checked: !allChecked })));
3063
+ setStatusMsg(allChecked ? "deselected all" : "selected all");
3064
+ return;
3065
+ }
3066
+ });
3067
+ if (promptStatus === "done") {
3068
+ const checked = items.filter((i) => i.checked);
3069
+ if (checked.length > 0) {
3070
+ return `${colors_default.green("\u2713")} ${checked.length} files accepted`;
3071
+ }
3072
+ return `${colors_default.green("\u2713")} done`;
3073
+ }
3074
+ if (items.length === 0) {
3075
+ return `${colors_default.green("\u2713")} no contributions \u2014 press enter`;
3076
+ }
3077
+ const checkedCount = items.filter((i) => i.checked).length;
3078
+ const page = usePagination({
3079
+ items,
3080
+ active,
3081
+ renderItem({ item, isActive }) {
3082
+ const checkbox2 = item.checked ? colors_default.green("\u25CF") : colors_default.dim("\u25CB");
3083
+ const cursor = isActive ? colors_default.cyan("\u276F") : " ";
3084
+ const newLabel = item.created ? colors_default.yellow(" \xB7 not in cella") : "";
3085
+ const delLabel = item.deleted ? colors_default.yellow(" [del]") : "";
3086
+ const statusTag = item.status === "diverged" ? colors_default.yellow(" \u26A0 diverged") : "";
3087
+ const dateLabel = item.changedAt ? colors_default.dim(` \xB7 ${item.changedAt}`) : "";
3088
+ const line = `${cursor} ${checkbox2} ${item.path}${delLabel}${statusTag}${newLabel}${dateLabel}`;
3089
+ return isActive ? colors_default.cyan(line) : line;
3090
+ },
3091
+ pageSize,
3092
+ loop: false
3093
+ });
3094
+ const countInfo = checkedCount > 0 ? `${items.length} files, ${colors_default.green(`${checkedCount} selected`)}` : `${items.length} files`;
3095
+ const header = `${colors_default.cyan("\u2B07")} ${config.message} ${colors_default.dim(`(${countInfo})`)}`;
3096
+ const keys = [
3097
+ ["\u2191\u2193", "navigate"],
3098
+ ["d", "diff"],
3099
+ ["a", "(de)select all"],
3100
+ ["space", "select"],
3101
+ ["\u23CE", "accept"],
3102
+ ["q", "quit"]
3103
+ ];
3104
+ const helpLine = keys.map(([k, a]) => `${colors_default.bold(k)} ${colors_default.dim(a)}`).join(colors_default.dim(" \xB7 "));
3105
+ const statusLine = statusMsg ? ` ${colors_default.dim(statusMsg)}` : "";
3106
+ const lines = [header, page, statusLine, helpLine].filter(Boolean).join("\n").trimEnd();
3107
+ return `${lines}\x1B[?25l`;
3108
+ });
3109
+ async function fetchForkBranch(cellaPath, fetchSource, pullBranch) {
3110
+ await git(["fetch", fetchSource, pullBranch], cellaPath);
3111
+ return git(["rev-parse", "FETCH_HEAD"], cellaPath);
3112
+ }
3113
+ async function forkRefMeta(cellaPath, forkRef) {
3114
+ const out = await git(["log", "-1", "--format=%h%x09%cr", forkRef], cellaPath, { ignoreErrors: true });
3115
+ const [sha = forkRef.slice(0, 7), date = "unknown"] = out.split(" ");
3116
+ return { sha, date };
3117
+ }
3118
+ async function diffStat(cellaPath, baseRef, ref) {
3119
+ const out = await git(["diff", "--numstat", `${baseRef}..${ref}`], cellaPath, { ignoreErrors: true });
3120
+ const stat = /* @__PURE__ */ new Map();
3121
+ for (const line of out.split("\n")) {
3122
+ if (!line.trim()) continue;
3123
+ const [a, d, ...rest] = line.split(" ");
3124
+ const path4 = rest.join(" ");
3125
+ if (!path4) continue;
3126
+ stat.set(path4, {
3127
+ additions: a === "-" ? null : Number(a),
3128
+ deletions: d === "-" ? null : Number(d)
3129
+ });
3130
+ }
3131
+ return stat;
3132
+ }
3133
+ async function runContributions(config) {
3134
+ const forks = config.forks ?? [];
3135
+ if (forks.length === 0) {
3136
+ printNoForksHint("add forks to pull contributions from:");
3137
+ return;
3138
+ }
3139
+ const baseRef = await getCurrentBranch(config.forkPath);
3140
+ if (baseRef !== DEFAULT_BRANCH) {
3141
+ console.info(
3142
+ `${warningMark} ${colors_default.yellow(`not on base branch '${DEFAULT_BRANCH}'`)} ${colors_default.dim(`comparing forks against '${baseRef}'`)}`
3143
+ );
3144
+ console.info(
3145
+ ` ${colors_default.dim(`older merge-base may show extra 'diverged' files; switch to '${DEFAULT_BRANCH}' for the cleanest result`)}`
3146
+ );
3147
+ console.info("");
3148
+ }
3149
+ const validated = forks.map((fork2) => validateForkPath(fork2, config.forkPath));
3150
+ const validForks = validated.filter((v) => v.valid);
3151
+ let selectedFork;
3152
+ if (config.fork) {
3153
+ const match = validated.find((v) => v.fork.name === config.fork);
3154
+ if (!match?.valid) {
3155
+ console.error(colors_default.red(`fork '${config.fork}' not found or invalid in config`));
3156
+ return;
3157
+ }
3158
+ selectedFork = match;
3159
+ } else if (config.list || config.json || config.diff) {
3160
+ if (validForks.length === 0) {
3161
+ console.error(colors_default.red("no valid forks configured"));
3162
+ return;
3163
+ }
3164
+ if (validForks.length > 1) {
3165
+ console.error(colors_default.red("multiple forks configured; pass --fork <name> to choose one"));
3166
+ return;
3167
+ }
3168
+ selectedFork = validForks[0];
3169
+ } else {
3170
+ const choices = validated.map((v) => ({
3171
+ value: v.fork.name,
3172
+ name: v.valid ? `${v.fork.name} ${colors_default.dim(`[${v.fork.pullBranch}] ${v.fork.localPath}`)}` : `${v.fork.name} ${colors_default.dim(v.fork.localPath)}`,
3173
+ disabled: v.valid ? false : v.error ?? "invalid"
3174
+ }));
3175
+ const picked = await select2({
3176
+ message: "select fork to pull contributions from:",
3177
+ choices,
3178
+ loop: false
3179
+ });
3180
+ selectedFork = validated.find((v) => v.fork.name === picked);
3181
+ }
3182
+ if (!selectedFork) {
3183
+ console.info(colors_default.dim("no fork selected."));
3184
+ return;
3185
+ }
3186
+ const { fork, resolvedPath } = selectedFork;
3187
+ const forkName = fork.name;
3188
+ createSpinner("pulling fork contributions...");
3189
+ const allItems = [];
3190
+ let forkBanner = null;
3191
+ try {
3192
+ const fetchSource = fork.remoteUrl ?? resolvedPath;
3193
+ const forkRef = await fetchForkBranch(config.forkPath, fetchSource, fork.pullBranch);
3194
+ const meta = await forkRefMeta(config.forkPath, forkRef);
3195
+ forkBanner = { pullBranch: fork.pullBranch, sha: meta.sha, date: meta.date };
3196
+ let forkTerritory = [];
3197
+ try {
3198
+ const forkConfig = await loadConfig(resolvedPath);
3199
+ forkTerritory = forkConfig.overrides?.ignored ?? [];
3200
+ } catch {
3201
+ }
3202
+ const detection = await detectContributableFiles(config.forkPath, baseRef, forkRef, config, forkTerritory);
3203
+ if (countDetection(detection) > 0) {
3204
+ const { branch, appliedFiles } = await buildContribBranch(config.forkPath, baseRef, forkRef, detection, forkName);
3205
+ if (appliedFiles.length > 0) {
3206
+ const stat = await diffStat(config.forkPath, baseRef, branch);
3207
+ const metaByPath = new Map(detection.files.map((f) => [f.path, f]));
3208
+ for (const path4 of [...detection.modified, ...detection.created]) {
3209
+ const fileMeta = metaByPath.get(path4);
3210
+ allItems.push({
3211
+ path: path4,
3212
+ ref: branch,
3213
+ deleted: false,
3214
+ created: fileMeta?.kind === "created",
3215
+ status: fileMeta?.status,
3216
+ changedAt: fileMeta?.changedAt,
3217
+ additions: stat.get(path4)?.additions ?? null,
3218
+ deletions: stat.get(path4)?.deletions ?? null,
3219
+ checked: false
3220
+ });
3221
+ }
3222
+ for (const path4 of detection.deleted) {
3223
+ const fileMeta = metaByPath.get(path4);
3224
+ allItems.push({
3225
+ path: path4,
3226
+ ref: branch,
3227
+ deleted: true,
3228
+ status: fileMeta?.status,
3229
+ changedAt: fileMeta?.changedAt,
3230
+ additions: stat.get(path4)?.additions ?? null,
3231
+ deletions: stat.get(path4)?.deletions ?? null,
3232
+ checked: false
3233
+ });
3234
+ }
3235
+ }
3236
+ }
3237
+ } catch (error) {
3238
+ spinnerFail(`failed to pull ${forkName}`);
3239
+ console.info(colors_default.red(` \u2717 ${error instanceof Error ? error.message : "unknown error"}`));
3240
+ return;
3241
+ }
3242
+ if (allItems.length === 0) {
3243
+ spinnerSuccess("no contributions found");
3244
+ return;
3245
+ }
3246
+ allItems.sort((a, b) => a.path.localeCompare(b.path));
3247
+ spinnerSuccess(`${allItems.length} files from ${forkName}`);
3248
+ if (!config.list && !config.diff && forkBanner) {
3249
+ console.info(
3250
+ ` ${colors_default.dim(`${forkName}: comparing committed '${forkBanner.pullBranch}' @ ${forkBanner.sha} (${forkBanner.date}) \u2014 uncommitted fork changes are not included`)}`
3251
+ );
3252
+ }
3253
+ if (config.diff) {
3254
+ const matches = allItems.filter((i) => i.path === config.diff);
3255
+ if (matches.length === 0) {
3256
+ console.error(colors_default.red(`no contribution found for path: ${config.diff}`));
3257
+ process.exitCode = 1;
3258
+ return;
3259
+ }
3260
+ for (const item of matches) {
3261
+ const diff = gitDiffFile(config.forkPath, `${baseRef}..${item.ref}`, item.path, { dstPrefix: forkName });
3262
+ writeStdout(diff.toString());
3263
+ }
3264
+ return;
3265
+ }
3266
+ if (config.json) {
3267
+ const out = allItems.map((item) => ({
3268
+ fork: forkName,
3269
+ path: item.path,
3270
+ status: item.status ?? null,
3271
+ kind: item.deleted ? "deleted" : "modified",
3272
+ changedAt: item.changedAt ?? null,
3273
+ additions: item.additions ?? null,
3274
+ deletions: item.deletions ?? null
3275
+ }));
3276
+ writeStdout(JSON.stringify(out, null, 2));
3277
+ return;
3278
+ }
3279
+ if (config.list) {
3280
+ for (const item of allItems) {
3281
+ const status = item.status ?? "behind";
3282
+ const kind = item.deleted ? "deleted" : "modified";
3283
+ const changedAt = item.changedAt ?? "-";
3284
+ console.info(`${forkName} ${status} ${kind} ${changedAt} ${item.path}`);
3285
+ }
3286
+ return;
3287
+ }
3288
+ console.info();
3289
+ console.info(DIVIDER);
3290
+ const selected = await contribPrompt({
3291
+ message: `contributions from ${forkName}`,
3292
+ items: allItems,
3293
+ forkName,
3294
+ baseRef,
3295
+ cwd: config.forkPath,
3296
+ pageSize: 20
3297
+ });
3298
+ if (selected.length === 0) {
3299
+ console.info();
3300
+ return;
3301
+ }
3302
+ createSpinner(`applying ${selected.length} files...`);
3303
+ const applyUnstaged = shouldApplyContributionsUnstaged(config.forkPath);
3304
+ let applied = 0;
3305
+ const errors = [];
3306
+ for (const item of selected) {
3307
+ try {
3308
+ if (item.path.includes("..") || item.path.startsWith("/")) {
3309
+ errors.push(`${item.path}: rejected (path traversal)`);
3310
+ continue;
3311
+ }
3312
+ if (item.deleted) {
3313
+ if (applyUnstaged) {
3314
+ await removeFileFromWorktree(config.forkPath, item.path);
3315
+ } else {
3316
+ await git(["rm", "-f", "--", item.path], config.forkPath, { ignoreErrors: true });
3317
+ }
3318
+ } else {
3319
+ if (applyUnstaged) {
3320
+ await restoreWorktreeFromRef(config.forkPath, item.ref, item.path);
3321
+ } else {
3322
+ await git(["checkout", item.ref, "--", item.path], config.forkPath);
3323
+ }
3324
+ }
3325
+ applied++;
3326
+ } catch (error) {
3327
+ errors.push(`${item.path}: ${error instanceof Error ? error.message : "unknown error"}`);
3328
+ }
3329
+ }
3330
+ if (errors.length > 0) {
3331
+ spinnerFail(`applied ${applied}/${selected.length} files (${errors.length} errors)`);
3332
+ for (const err of errors) {
3333
+ console.info(colors_default.red(` \u2717 ${err}`));
3334
+ }
3335
+ } else {
3336
+ spinnerSuccess(`applied ${applied} files ${colors_default.dim(applyUnstaged ? "(unstaged)" : "(staged)")}`);
3337
+ }
3338
+ console.info();
3339
+ console.info(` ${colors_default.cyan(forkName)} ${colors_default.dim(`(${selected.length} files)`)}`);
3340
+ for (const item of selected) {
3341
+ console.info(` ${colors_default.dim("\u2192")} ${item.deleted ? `(deleted) ${item.path}` : item.path}`);
3342
+ }
3343
+ console.info();
3344
+ console.info(
3345
+ colors_default.dim(
3346
+ applyUnstaged ? " files are unstaged \u2014 review, stage what you want, and commit when ready" : " files are staged \u2014 review and commit when ready"
3347
+ )
3348
+ );
3349
+ console.info();
3350
+ }
3351
+
3352
+ // src/services/forks.ts
3353
+ import { resolve as resolve5 } from "path";
3354
+ import process7 from "process";
3355
+ import { Separator as Separator2, select as select3 } from "@inquirer/prompts";
3356
+
3357
+ // src/services/sync.ts
3358
+ import { spawnSync as spawnSync4 } from "child_process";
3359
+
3360
+ // src/services/packages.ts
3361
+ import { existsSync as existsSync9, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
3362
+ import { join as join13 } from "path";
3363
+ function parseComparableVersion(version) {
3364
+ const trimmed = version.trim();
3365
+ 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("||")) {
3366
+ return null;
3367
+ }
3368
+ const match = trimmed.match(
3369
+ /^(?:\^|~|>=|<=|>|<|=)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
3370
+ );
3371
+ if (!match) return null;
3372
+ return {
3373
+ major: Number(match[1]),
3374
+ minor: Number(match[2] ?? 0),
3375
+ patch: Number(match[3] ?? 0)
3376
+ };
3377
+ }
3378
+ function compareVersions(left, right) {
3379
+ if (left.major !== right.major) return left.major - right.major;
3380
+ if (left.minor !== right.minor) return left.minor - right.minor;
3381
+ return left.patch - right.patch;
3382
+ }
3383
+ function isHigherVersion(upstreamVersion, forkVersion) {
3384
+ if (upstreamVersion === forkVersion) return false;
3385
+ const upCoerced = parseComparableVersion(upstreamVersion);
3386
+ const forkCoerced = parseComparableVersion(forkVersion);
3387
+ if (!upCoerced || !forkCoerced) return false;
3388
+ return compareVersions(upCoerced, forkCoerced) > 0;
3389
+ }
3390
+ function safeMergeRecord(forkRecord, upstreamRecord, addOnly = false) {
3391
+ if (!upstreamRecord) return void 0;
3392
+ const merged = { ...forkRecord || {} };
3393
+ const added = [];
3394
+ let changed = false;
3395
+ for (const [name, upstreamValue] of Object.entries(upstreamRecord)) {
3396
+ const forkValue = merged[name];
3397
+ if (forkValue === void 0) {
3398
+ merged[name] = upstreamValue;
3399
+ added.push(name);
3400
+ changed = true;
3401
+ } else if (!addOnly && forkValue !== upstreamValue && isHigherVersion(upstreamValue, forkValue)) {
3402
+ merged[name] = upstreamValue;
3403
+ changed = true;
3404
+ }
3405
+ }
3406
+ const sorted = Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b)));
3407
+ return { merged: sorted, changed, added };
3408
+ }
3409
+ function safeMergePnpm(forkPnpm, upstreamPnpm) {
3410
+ if (!upstreamPnpm) return void 0;
3411
+ const merged = { ...forkPnpm || {} };
3412
+ let changed = false;
3413
+ if (upstreamPnpm.overrides) {
3414
+ const result = safeMergeRecord(
3415
+ merged.overrides,
3416
+ upstreamPnpm.overrides
3417
+ );
3418
+ if (result?.changed) {
3419
+ merged.overrides = result.merged;
3420
+ changed = true;
3421
+ }
3422
+ }
3423
+ if (upstreamPnpm.patchedDependencies) {
3424
+ const forkPatched = merged.patchedDependencies || {};
3425
+ const upstreamPatched = upstreamPnpm.patchedDependencies;
3426
+ for (const [name, value] of Object.entries(upstreamPatched)) {
3427
+ if (!(name in forkPatched)) {
3428
+ forkPatched[name] = value;
3429
+ changed = true;
3430
+ }
3431
+ }
3432
+ if (changed || !merged.patchedDependencies) {
3433
+ merged.patchedDependencies = Object.fromEntries(
3434
+ Object.entries(forkPatched).sort(([a], [b]) => a.localeCompare(b))
3435
+ );
3436
+ }
3437
+ }
3438
+ if (upstreamPnpm.packageExtensions) {
3439
+ const forkExts = merged.packageExtensions || {};
3440
+ const upstreamExts = upstreamPnpm.packageExtensions;
3441
+ for (const [pkg, upstreamExt] of Object.entries(upstreamExts)) {
3442
+ if (!(pkg in forkExts)) {
3443
+ forkExts[pkg] = upstreamExt;
3444
+ changed = true;
3445
+ } else {
3446
+ for (const [subKey, subValue] of Object.entries(upstreamExt)) {
3447
+ if (!(subKey in forkExts[pkg])) {
3448
+ forkExts[pkg][subKey] = subValue;
3449
+ changed = true;
3450
+ }
3451
+ }
3452
+ }
3453
+ }
3454
+ merged.packageExtensions = forkExts;
3455
+ }
3456
+ for (const [key, value] of Object.entries(upstreamPnpm)) {
3457
+ if (["overrides", "patchedDependencies", "packageExtensions"].includes(key)) continue;
3458
+ if (!(key in merged)) {
3459
+ merged[key] = value;
3460
+ changed = true;
3461
+ }
3462
+ }
3463
+ return { merged, changed };
3464
+ }
3465
+ function readPackageJson(filePath) {
3466
+ if (!existsSync9(filePath)) return null;
3467
+ try {
3468
+ return JSON.parse(readFileSync5(filePath, "utf-8"));
3469
+ } catch {
3470
+ return null;
3471
+ }
3472
+ }
3473
+ function writePackageJson(filePath, data) {
3474
+ writeFileSync4(filePath, `${JSON.stringify(data, null, 2)}
3475
+ `, "utf-8");
3476
+ }
3477
+ async function getUpstreamPackageJson(forkPath, upstreamRef, relativePath) {
3478
+ const filePath = relativePath ? `${relativePath}/package.json` : "package.json";
3479
+ try {
3480
+ return JSON.parse(await git(["show", `${upstreamRef}:${filePath}`], forkPath));
3481
+ } catch {
3482
+ return null;
3483
+ }
3484
+ }
3485
+ async function discoverPackageLocations(forkPath, upstreamRef) {
3486
+ const stdout = await git(["ls-tree", "-r", "--name-only", upstreamRef], forkPath, { ignoreErrors: true });
3487
+ const upstreamPaths = stdout.split("\n").filter((p) => p.endsWith("/package.json") || p === "package.json").map((p) => p === "package.json" ? "" : p.replace("/package.json", ""));
3488
+ return upstreamPaths.filter((loc) => existsSync9(join13(forkPath, loc, "package.json")));
3489
+ }
3490
+ async function syncPackageJson(forkPath, upstreamRef, relativePath, keysToSync) {
3491
+ const changes = [];
3492
+ const pkgPath = join13(forkPath, relativePath, "package.json");
3493
+ const forkPkg = readPackageJson(pkgPath);
3494
+ const upstreamPkg = await getUpstreamPackageJson(forkPath, upstreamRef, relativePath);
3495
+ if (!forkPkg || !upstreamPkg) {
3496
+ return { updated: false, changes };
3497
+ }
3498
+ let updated = false;
3499
+ for (const key of keysToSync) {
3500
+ if (key === "pnpm") {
3501
+ const result2 = safeMergePnpm(forkPkg.pnpm, upstreamPkg.pnpm);
3502
+ if (result2?.changed) {
3503
+ forkPkg.pnpm = result2.merged;
3504
+ updated = true;
3505
+ changes.push("pnpm: merged");
3506
+ }
3507
+ continue;
3508
+ }
3509
+ if (key === "packageManager") {
3510
+ const upstreamValue2 = upstreamPkg[key];
3511
+ const forkValue2 = forkPkg[key];
3512
+ if (upstreamValue2 && !forkValue2) {
3513
+ forkPkg[key] = upstreamValue2;
3514
+ updated = true;
3515
+ changes.push(`${key}: added`);
3516
+ } else if (upstreamValue2 && forkValue2 && upstreamValue2 !== forkValue2 && isHigherVersion(upstreamValue2, forkValue2)) {
3517
+ forkPkg[key] = upstreamValue2;
3518
+ updated = true;
3519
+ changes.push(`${key}: bumped`);
3520
+ }
3521
+ continue;
3522
+ }
3523
+ const forkValue = forkPkg[key];
3524
+ const upstreamValue = upstreamPkg[key];
3525
+ const addOnly = key === "scripts";
3526
+ const result = safeMergeRecord(forkValue, upstreamValue, addOnly);
3527
+ if (result?.changed) {
3528
+ forkPkg[key] = result.merged;
3529
+ updated = true;
3530
+ if (addOnly) {
3531
+ changes.push(...result.added.map((name) => `scripts.${name}: added`));
3532
+ } else {
3533
+ changes.push(`${key}: merged`);
3534
+ }
3535
+ }
3536
+ }
3537
+ if (updated) {
3538
+ writePackageJson(pkgPath, forkPkg);
3539
+ }
3540
+ return { updated, changes };
3541
+ }
3542
+ async function runPackages(config, options = {}) {
3543
+ createSpinner("syncing package.json files...");
3544
+ const keysToSync = config.settings.packageJsonSync || ["dependencies", "devDependencies"];
3545
+ const conflictedSet = new Set(options.conflictedFiles ?? []);
3546
+ const locations = await discoverPackageLocations(config.forkPath, config.upstreamRef);
3547
+ const results = [];
3548
+ const skipped = [];
3549
+ for (const location of locations) {
3550
+ const pkgRelPath = location ? `${location}/package.json` : "package.json";
3551
+ if (conflictedSet.has(pkgRelPath)) {
3552
+ skipped.push(pkgRelPath);
3553
+ continue;
3554
+ }
3555
+ spinnerText(`syncing ${location || "root"}/package.json...`);
3556
+ const { updated, changes } = await syncPackageJson(config.forkPath, config.upstreamRef, location, keysToSync);
3557
+ if (updated) {
3558
+ results.push({ location: location || "root", changes });
3559
+ }
3560
+ }
3561
+ spinnerSuccess("package sync complete");
3562
+ if (results.length === 0) {
3563
+ console.info(colors_default.dim(" no package.json changes needed"));
3564
+ } else {
3565
+ for (const { location, changes } of results) {
3566
+ console.info(` ${colors_default.green("\u2713")} ${location}/package.json`);
3567
+ for (const change of changes) {
3568
+ console.info(` ${colors_default.dim("\u2192")} ${change}`);
3569
+ }
3570
+ }
3571
+ }
3572
+ if (skipped.length > 0) {
3573
+ console.info();
3574
+ console.warn(
3575
+ `${warningMark} skipped ${skipped.length} conflicted package.json file(s) \u2014 resolve the conflicts, commit the merge, then rerun \`pnpm cella sync\`:`
3576
+ );
3577
+ for (const path4 of skipped) {
3578
+ console.warn(` ${colors_default.dim("\u2192")} ${path4}`);
3579
+ }
3580
+ }
3581
+ }
3582
+
3583
+ // src/services/sync.ts
3584
+ async function ensureBaseUpToDate(forkPath, base) {
3585
+ const { upstream, ahead, behind } = await getUpstreamStatus(forkPath);
3586
+ if (!upstream) {
3587
+ console.info(colors_default.dim(`'${base}' has no upstream \u2014 skipping the up-to-date check.`));
3588
+ return;
3589
+ }
3590
+ if (ahead > 0 && behind > 0) {
3591
+ throw new Error(
3592
+ `'${base}' has diverged from '${upstream}' (${ahead} ahead, ${behind} behind).
3593
+ Reconcile '${base}' with '${upstream}' first (e.g. rebase or reset it), then re-run sync.`
3594
+ );
3595
+ }
3596
+ if (behind > 0) {
3597
+ console.info(colors_default.dim(`fast-forwarding '${base}' to '${upstream}' (${behind} behind)...`));
3598
+ await pullFastForward(forkPath);
3599
+ return;
3600
+ }
3601
+ if (ahead > 0) {
3602
+ console.info(colors_default.dim(`'${base}' is ${ahead} commit(s) ahead of '${upstream}' (unpushed) \u2014 continuing.`));
3603
+ }
3604
+ }
3605
+ async function setupTemporarySyncBranch(config) {
3606
+ const { forkPath, settings } = config;
3607
+ const base = resolveReleaseBase(settings);
3608
+ const startBranch = await getCurrentBranch(forkPath);
3609
+ console.info(colors_default.dim(`switching to '${base}' and updating...`));
3610
+ await switchBranch(forkPath, base);
3611
+ await ensureBaseUpToDate(forkPath, base);
3612
+ const temporaryBranch = buildTemporarySyncBranch();
3613
+ console.info(colors_default.dim(`creating temporary sync branch '${temporaryBranch}' from '${base}'...`));
3614
+ console.info();
3615
+ await createBranchFrom(forkPath, temporaryBranch, base);
3616
+ return { temporaryBranch, base, startBranch };
3617
+ }
3618
+ async function runSync(config, options) {
3619
+ createSpinner("starting sync...");
3620
+ const result = await runMergeEngine(config, {
3621
+ apply: true,
3622
+ onProgress: (message) => {
3623
+ spinnerText(message);
3624
+ },
3625
+ onStep: (label, detail) => {
3626
+ spinnerSuccess(label, detail);
3627
+ createSpinner("...");
3628
+ }
3629
+ });
3630
+ if (result.success) {
3631
+ spinnerSuccess();
3632
+ } else {
3633
+ spinnerFail("sync completed with conflicts");
3634
+ }
3635
+ printSummary(result.summary, "merge summary");
3636
+ options?.onAfterSummary?.(result);
3637
+ if (config.logFile) {
3638
+ const logPath = writeLogFile(config.forkPath, result.files);
3639
+ console.info();
3640
+ console.info(colors_default.dim(`full file list written to: ${logPath}`));
3641
+ }
3642
+ printSyncComplete(result);
3643
+ printFlagWarnings({ hard: config.hard, unpinned: config.unpinned });
3644
+ return result;
3645
+ }
3646
+ var SYNC_PR_TITLE = "chore: sync upstream cella";
3647
+ function printPrCreateStep(branch, base) {
3648
+ console.info(colors_default.dim(` gh pr create --base ${base} --head ${branch} --title "${SYNC_PR_TITLE}" --fill`));
3649
+ }
3650
+ function printShipSteps(temporaryBranch, base) {
3651
+ console.info(colors_default.dim(` git push -u origin ${temporaryBranch}`));
3652
+ printPrCreateStep(temporaryBranch, base);
3653
+ }
3654
+ function printFinishSteps() {
3655
+ console.info(colors_default.dim(" pnpm cella sync"));
3656
+ }
3657
+ function hasStagedSyncChanges(result) {
3658
+ return result.files.some((file) => ["behind", "diverged", "renamed", "ignored", "pinned"].includes(file.status));
3659
+ }
3660
+ function printCleanSyncNextStep(branch) {
3661
+ console.info();
3662
+ console.info(`${checkMark} ${colors_default.green(`Sync merge staged on '${branch}'. No conflicts. Review, then finish with:`)}`);
3663
+ printFinishSteps();
3664
+ console.info(colors_default.dim(" rerun commits the sync, pushes the branch, and opens a PR."));
3665
+ }
3666
+ function ghAvailable() {
3667
+ return spawnSync4("gh", ["--version"], { stdio: "ignore" }).status === 0;
3668
+ }
3669
+ async function shipSyncBranch(config, branch) {
3670
+ const { forkPath, settings } = config;
3671
+ const base = resolveReleaseBase(settings);
3672
+ console.info(colors_default.dim(`pushing '${branch}' to origin...`));
3673
+ try {
3674
+ await pushBranch(forkPath, "origin", branch);
3675
+ } catch (error) {
3676
+ const detail = error instanceof Error ? error.message : String(error);
3677
+ console.info(colors_default.yellow(`push failed (${detail.split("\n")[0]}). finish manually:`));
3678
+ printShipSteps(branch, base);
3679
+ return;
3680
+ }
3681
+ if (ghAvailable()) {
3682
+ console.info(colors_default.dim("opening a pull request..."));
3683
+ const pr = spawnSync4("gh", ["pr", "create", "--base", base, "--head", branch, "--title", SYNC_PR_TITLE, "--fill"], {
3684
+ cwd: forkPath,
3685
+ stdio: "inherit"
3686
+ });
3687
+ if (pr.status !== 0) {
3688
+ console.info(colors_default.yellow("could not open the PR automatically (it may already exist). open it with:"));
3689
+ printPrCreateStep(branch, base);
3690
+ }
3691
+ } else {
3692
+ console.info(colors_default.yellow("`gh` not found \u2014 open the PR manually:"));
3693
+ printPrCreateStep(branch, base);
3694
+ }
3695
+ console.info(colors_default.dim(`switching back to '${base}'...`));
3696
+ await switchBranch(forkPath, base);
3697
+ await pullFastForward(forkPath).catch(() => {
3698
+ });
3699
+ console.info(colors_default.green(`done \u2014 '${branch}' is pushed and you're back on '${base}'.`));
3700
+ }
3701
+ function finalizeWorkspace(forkPath) {
3702
+ for (const args of [["install"], ["check"]]) {
3703
+ console.info(colors_default.dim(`running pnpm ${args.join(" ")}...`));
3704
+ const result = spawnSync4("pnpm", args, { cwd: forkPath, stdio: "inherit" });
3705
+ if (result.status !== 0) return false;
3706
+ }
3707
+ return true;
3708
+ }
3709
+ async function runSyncCycle(config) {
3710
+ const { forkPath } = config;
3711
+ const branch = await setupTemporarySyncBranch(config);
3712
+ const result = await runSync(config, {
3713
+ onAfterSummary: (syncResult) => {
3714
+ if (syncResult.conflicts.length === 0 && hasStagedSyncChanges(syncResult)) {
3715
+ printCleanSyncNextStep(branch.temporaryBranch);
3716
+ }
3717
+ }
3718
+ });
3719
+ if (config.settings.syncWithPackages !== false) {
3720
+ await runPackages(config, { conflictedFiles: result.conflicts });
3721
+ }
3722
+ if (result.conflicts.length > 0) return { status: "conflicts", branch };
3723
+ if (!mergeInProgress(forkPath)) {
3724
+ await switchBranch(forkPath, branch.startBranch === branch.temporaryBranch ? branch.base : branch.startBranch);
3725
+ await deleteBranch(forkPath, branch.temporaryBranch);
3726
+ return { status: "noop" };
3727
+ }
3728
+ return { status: "staged", branch };
3729
+ }
3730
+ async function resumeSyncMerge(config, branch) {
3731
+ const { forkPath } = config;
3732
+ const conflicts = await getConflictedFiles(forkPath);
3733
+ console.info();
3734
+ if (conflicts.length > 0) {
3735
+ console.info(colors_default.yellow(`${conflicts.length} file(s) still conflict on '${branch}':`));
3736
+ for (const file of conflicts) console.info(colors_default.dim(` ${file}`));
3737
+ console.info(colors_default.dim("resolve and stage them, then re-run `pnpm cella sync` to finish."));
3738
+ return;
3739
+ }
3740
+ await stageAll(forkPath);
3741
+ if (!finalizeWorkspace(forkPath)) {
3742
+ console.info();
3743
+ console.info(
3744
+ colors_default.yellow("`pnpm install`/`pnpm check` failed. fix the issues, then re-run `pnpm cella sync` to finish.")
3745
+ );
3746
+ return;
3747
+ }
3748
+ const upstreamSha = await getShortSha(forkPath, "MERGE_HEAD").catch(() => "");
3749
+ await stageAll(forkPath);
3750
+ const message = upstreamSha ? `${SYNC_PR_TITLE} ${upstreamSha}` : SYNC_PR_TITLE;
3751
+ await commitSquash(forkPath, message);
3752
+ console.info();
3753
+ console.info(colors_default.green(`committed the sync on '${branch}' as '${message}'.`));
3754
+ await shipSyncBranch(config, branch);
3755
+ }
3756
+ async function runSyncCommand(config) {
3757
+ const { forkPath } = config;
3758
+ const currentBranch = await getCurrentBranch(forkPath);
3759
+ const onSyncBranch = isTemporarySyncBranch(currentBranch);
3760
+ if (onSyncBranch && mergeInProgress(forkPath)) {
3761
+ await resumeSyncMerge(config, currentBranch);
3762
+ return;
3763
+ }
3764
+ if (onSyncBranch) {
3765
+ if (!await isClean(forkPath)) {
3766
+ console.info();
3767
+ console.info(
3768
+ colors_default.yellow(
3769
+ `sync branch '${currentBranch}' has uncommitted changes that would not be shipped.
3770
+ commit them (\`git commit --amend --no-edit\` or a new commit), then re-run \`pnpm cella sync\`.`
3771
+ )
3772
+ );
3773
+ return;
3774
+ }
3775
+ console.info();
3776
+ console.info(colors_default.green(`sync branch '${currentBranch}' is already committed.`));
3777
+ await shipSyncBranch(config, currentBranch);
3778
+ return;
3779
+ }
3780
+ await assertClean(forkPath);
3781
+ const outcome = await runSyncCycle(config);
3782
+ console.info();
3783
+ if (outcome.status === "noop") {
3784
+ console.info(colors_default.green("already up to date with upstream \u2014 nothing to sync."));
3785
+ return;
3786
+ }
3787
+ const { temporaryBranch } = outcome.branch;
3788
+ if (outcome.status === "conflicts") {
3789
+ console.info(`${warningMark} ${colors_default.yellow(`conflicts on '${temporaryBranch}'. Resolve them and then:`)}`);
3790
+ printFinishSteps();
3791
+ }
3792
+ }
3793
+
3794
+ // src/services/forks.ts
3795
+ async function gatherForkStatus(forkPath) {
3796
+ const [branch, porcelain, syncRef] = await Promise.all([
3797
+ getCurrentBranch(forkPath).catch(() => "unknown"),
3798
+ git(["status", "--porcelain"], forkPath, { ignoreErrors: true }),
3799
+ getStoredSyncRef(forkPath)
3800
+ ]);
3801
+ const dirty = porcelain ? porcelain.split("\n").filter(Boolean).length : 0;
3802
+ let lastSync = null;
3803
+ if (syncRef) {
3804
+ const commitInfo = await getCommitInfo(forkPath, syncRef).catch(() => null);
3805
+ lastSync = { date: commitInfo?.date ?? "unknown", message: commitInfo?.message ?? "" };
3806
+ }
3807
+ return { branch, dirty, lastSync };
3808
+ }
3809
+ function formatForkChoice(name, status) {
3810
+ if (!status) return name;
3811
+ let syncPart;
3812
+ if (status.lastSync) {
3813
+ const msg = status.lastSync.message.length > 36 ? `${status.lastSync.message.slice(0, 36)}\u2026` : status.lastSync.message;
3814
+ syncPart = colors_default.dim(`${status.lastSync.date}`);
3815
+ if (msg) syncPart += colors_default.dim(` '${msg}'`);
3816
+ } else {
3817
+ syncPart = colors_default.dim("never synced");
3818
+ }
3819
+ const dirtyPart = status.dirty > 0 ? colors_default.yellow(`${status.dirty} uncommitted`) : "";
3820
+ const parts = [name, colors_default.dim(`[${status.branch}]`), syncPart];
3821
+ if (dirtyPart) parts.push(dirtyPart);
3822
+ return parts.join(colors_default.dim(" \xB7 "));
3823
+ }
3824
+ async function buildForkChoices(forks, basePath) {
3825
+ const validated = forks.map((fork) => validateForkPath(fork, basePath, true));
3826
+ const statusEntries = await Promise.all(
3827
+ validated.filter((v) => v.valid).map(async (v) => {
3828
+ const status = await gatherForkStatus(v.resolvedPath);
3829
+ return { path: v.fork.localPath, status };
3830
+ })
3831
+ );
3832
+ const statusMap = new Map(statusEntries.map((e) => [e.path, e.status]));
3833
+ return validated.map((v) => {
3834
+ if (!v.valid) {
3835
+ return {
3836
+ value: v.fork.localPath,
3837
+ name: `${v.fork.name} ${colors_default.dim(v.fork.localPath)}`,
3838
+ disabled: v.error
3839
+ };
3840
+ }
3841
+ return {
3842
+ value: v.fork.localPath,
3843
+ name: formatForkChoice(v.fork.name, statusMap.get(v.fork.localPath) ?? null)
3844
+ };
3845
+ });
3846
+ }
3847
+ async function syncFork(config, fork, forkPath) {
3848
+ console.info();
3849
+ console.info(colors_default.cyan(`syncing to ${fork.name}...`));
3850
+ console.info(colors_default.dim(`path: ${forkPath}`));
3851
+ console.info();
3852
+ const forkConfig = await loadConfig(forkPath);
3853
+ const { branchRef } = resolveUpstream(forkConfig.settings);
3854
+ const upstreamRef = branchRef;
3855
+ const forkRuntimeConfig = {
3856
+ ...forkConfig,
3857
+ forkPath,
3858
+ upstreamRef,
3859
+ service: "sync",
3860
+ logFile: config.logFile,
3861
+ list: false,
3862
+ json: false,
3863
+ verbose: config.verbose,
3864
+ hard: config.hard
3865
+ };
3866
+ await runSyncCommand(forkRuntimeConfig);
3867
+ }
3868
+ async function runForks(config) {
3869
+ const forks = config.forks ?? [];
3870
+ if (forks.length === 0) {
3871
+ printNoForksHint("add forks to your config:");
3872
+ return;
3873
+ }
3874
+ if (config.fork) {
3875
+ const match = forks.find((f) => f.name === config.fork);
3876
+ if (!match) {
3877
+ console.error(colors_default.red(`fork '${config.fork}' not found in config`));
3878
+ return;
3879
+ }
3880
+ const resolvedPath = resolve5(config.forkPath, match.localPath);
3881
+ await syncFork(config, match, resolvedPath);
3882
+ return;
3883
+ }
3884
+ while (true) {
3885
+ const choices = await buildForkChoices(forks, config.forkPath);
3886
+ const forkChoices = [...choices, new Separator2("\u2500".repeat(40)), { value: "_exit", name: colors_default.dim("exit") }];
3887
+ const selectedPath = await select3({
3888
+ message: "select fork to sync:",
3889
+ choices: forkChoices,
3890
+ loop: false
3891
+ });
3892
+ if (selectedPath === "_exit") {
3893
+ process7.exit(0);
3894
+ }
3895
+ const resolvedForkPath = resolve5(config.forkPath, selectedPath);
3896
+ const selectedFork = forks.find((f) => f.localPath === selectedPath);
3897
+ if (!selectedFork) {
3898
+ console.error(colors_default.red(`fork '${selectedPath}' not found in config`));
3899
+ continue;
3900
+ }
3901
+ await syncFork(config, selectedFork, resolvedForkPath);
3902
+ console.info();
3903
+ }
3904
+ }
3905
+
3906
+ // src/services/stats.ts
3907
+ import { readFile as readFile2 } from "fs/promises";
3908
+ import { join as join15 } from "path";
3909
+
3910
+ // src/utils/coverage-utils.ts
3911
+ import { spawnSync as spawnSync5 } from "child_process";
3912
+ import * as fs3 from "fs";
3913
+ import * as path3 from "path";
3914
+ var STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1e3;
3915
+ function summaryPath(forkPath) {
3916
+ return path3.join(forkPath, ".coverage", "coverage-summary.json");
3917
+ }
3918
+ function colorPct(pct) {
3919
+ const label = `${pct.toFixed(2)}%`;
3920
+ if (pct >= 80) return colors_default.green(label);
3921
+ if (pct >= 50) return colors_default.yellow(label);
3922
+ return colors_default.red(label);
3923
+ }
3924
+ function formatAge(ms) {
3925
+ const days = Math.floor(ms / (24 * 60 * 60 * 1e3));
3926
+ if (days >= 1) return `${days} day${days === 1 ? "" : "s"} ago`;
3927
+ const hours = Math.floor(ms / (60 * 60 * 1e3));
3928
+ if (hours >= 1) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
3929
+ const mins = Math.floor(ms / (60 * 1e3));
3930
+ return `${mins} minute${mins === 1 ? "" : "s"} ago`;
3931
+ }
3932
+ function refreshCoverage(forkPath) {
3933
+ console.info(colors_default.dim("running `pnpm test` (this spins up Docker and runs the full suite)..."));
3934
+ const result = spawnSync5("pnpm", ["test"], { cwd: forkPath, stdio: "inherit" });
3935
+ return result.status === 0;
3936
+ }
3937
+ function printCoverageSummary(forkPath, options = {}) {
3938
+ const file = summaryPath(forkPath);
3939
+ if (options.refresh) {
3940
+ const ok = refreshCoverage(forkPath);
3941
+ if (!ok) {
3942
+ console.info(colors_default.red("coverage run failed \u2014 showing last cached results (if any)"));
3943
+ }
3944
+ console.info();
3945
+ }
3946
+ if (!fs3.existsSync(file)) {
3947
+ console.info(colors_default.bold("coverage"));
3948
+ console.info(colors_default.yellow(" no coverage data found \u2014 run `pnpm test` to generate it"));
3949
+ console.info();
3950
+ return;
3951
+ }
3952
+ let summary;
3953
+ let mtimeMs;
3954
+ try {
3955
+ summary = JSON.parse(fs3.readFileSync(file, "utf-8"));
3956
+ mtimeMs = fs3.statSync(file).mtimeMs;
3957
+ } catch {
3958
+ console.info(colors_default.bold("coverage"));
3959
+ console.info(colors_default.yellow(" could not read coverage-summary.json \u2014 run `pnpm test` to regenerate"));
3960
+ console.info();
3961
+ return;
3962
+ }
3963
+ const ageMs = Date.now() - mtimeMs;
3964
+ const stale = ageMs > STALE_THRESHOLD_MS;
3965
+ const perPackage = /* @__PURE__ */ new Map();
3966
+ for (const [key, entry] of Object.entries(summary)) {
3967
+ if (key === "total") continue;
3968
+ const rel = path3.relative(forkPath, key);
3969
+ if (rel.startsWith("..")) continue;
3970
+ const pkg = rel.split(path3.sep)[0];
3971
+ const acc = perPackage.get(pkg) ?? { covered: 0, total: 0 };
3972
+ acc.covered += entry.lines.covered;
3973
+ acc.total += entry.lines.total;
3974
+ perPackage.set(pkg, acc);
3975
+ }
3976
+ const ageLabel = formatAge(ageMs);
3977
+ console.info(colors_default.bold("coverage"), colors_default.dim(`(updated ${ageLabel})`));
3978
+ if (stale) {
3979
+ console.info(colors_default.yellow(" \u26A0 data is older than 7 days \u2014 run `pnpm test` to refresh"));
3980
+ }
3981
+ const total = summary.total;
3982
+ console.info(
3983
+ ` ${colors_default.bold("overall")} lines ${colorPct(total.lines.pct)} ${colors_default.dim(`(${total.lines.covered}/${total.lines.total})`)} funcs ${colorPct(total.functions.pct)} branches ${colorPct(total.branches.pct)}`
3984
+ );
3985
+ const rows = [...perPackage.entries()].map(([pkg, v]) => ({ pkg, pct: v.total ? 100 * v.covered / v.total : 0, ...v })).sort((a, b) => a.pct - b.pct);
3986
+ const nameWidth = Math.max(...rows.map((r) => r.pkg.length), 0);
3987
+ for (const r of rows) {
3988
+ console.info(` ${r.pkg.padEnd(nameWidth)} ${colorPct(r.pct)} ${colors_default.dim(`(${r.covered}/${r.total})`)}`);
3989
+ }
3990
+ console.info();
3991
+ }
3992
+
3993
+ // src/services/stats.ts
3994
+ var sourceExtensions = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs", "json", "jsonc", "css", "html"]);
3995
+ async function getWorkspacePackages(forkPath) {
3996
+ const content = await readFile2(join15(forkPath, "pnpm-workspace.yaml"), "utf-8");
3997
+ return parseYamlBlockList(content, "packages").map((pattern) => {
3998
+ if (pattern.endsWith("/*")) {
3999
+ const base = pattern.slice(0, -2);
4000
+ return { name: `${base}/*`, prefix: `${base}/` };
4001
+ }
4002
+ return { name: pattern, prefix: `${pattern}/` };
4003
+ });
4004
+ }
4005
+ function classifyFile(filePath) {
4006
+ if (filePath.includes(".test.") || filePath.includes(".spec.") || filePath.includes("/tests/") || filePath.includes("/test/") || filePath.startsWith("tests/") || filePath.includes("/__tests__/")) {
4007
+ return "test";
4008
+ }
4009
+ if (filePath.includes(".gen.") || filePath.includes(".gen/") || filePath.includes("/gen/") || filePath.startsWith("drizzle/") || filePath.includes("/drizzle/") || filePath.endsWith(".d.ts") || filePath.includes(".tsbuildinfo")) {
4010
+ return "generated";
4011
+ }
4012
+ if (filePath.endsWith(".json") || filePath.endsWith(".jsonc")) {
4013
+ return "json";
4014
+ }
4015
+ return "other";
4016
+ }
4017
+ function matchPackage(filePath, packages) {
4018
+ for (const pkg of packages) {
4019
+ if (pkg.name.endsWith("/*")) {
4020
+ if (filePath.startsWith(pkg.prefix)) {
4021
+ const rest = filePath.slice(pkg.prefix.length);
4022
+ const subDir = rest.split("/")[0];
4023
+ if (subDir) {
4024
+ return `${pkg.prefix}${subDir}`;
4025
+ }
4026
+ }
4027
+ } else if (filePath.startsWith(pkg.prefix)) {
4028
+ return pkg.name;
4029
+ }
4030
+ }
4031
+ return "(root)";
4032
+ }
4033
+ var sourcePathspecs = [...sourceExtensions].map((ext) => `*.${ext}`);
4034
+ async function collectStats(forkPath) {
4035
+ const output = await git(["grep", "-c", "", "--", ...sourcePathspecs], forkPath, { ignoreErrors: true });
4036
+ const allFilesOutput = await git(["ls-files", "--cached"], forkPath);
4037
+ const allFilesCount = allFilesOutput.split("\n").filter(Boolean).length;
4038
+ const packages = await getWorkspacePackages(forkPath);
4039
+ const entries = [];
4040
+ for (const line of output.split("\n")) {
4041
+ if (!line) continue;
4042
+ const sep3 = line.lastIndexOf(":");
4043
+ if (sep3 === -1) continue;
4044
+ entries.push({ path: line.slice(0, sep3), loc: Number.parseInt(line.slice(sep3 + 1), 10) });
4045
+ }
4046
+ const skipped = allFilesCount - entries.length;
4047
+ const emptyCat = () => ({
4048
+ test: { files: 0, loc: 0 },
4049
+ generated: { files: 0, loc: 0 },
4050
+ json: { files: 0, loc: 0 },
4051
+ other: { files: 0, loc: 0 }
4052
+ });
4053
+ const categories = emptyCat();
4054
+ const pkgStats = /* @__PURE__ */ new Map();
4055
+ let totalLoc = 0;
4056
+ for (const { path: path4, loc } of entries) {
4057
+ const category = classifyFile(path4);
4058
+ categories[category].files++;
4059
+ categories[category].loc += loc;
4060
+ totalLoc += loc;
4061
+ const pkg = matchPackage(path4, packages);
4062
+ let entry = pkgStats.get(pkg);
4063
+ if (!entry) {
4064
+ entry = { total: 0, loc: 0, categories: emptyCat() };
4065
+ pkgStats.set(pkg, entry);
4066
+ }
4067
+ entry.total++;
4068
+ entry.loc += loc;
4069
+ entry.categories[category].files++;
4070
+ entry.categories[category].loc += loc;
4071
+ }
4072
+ const sortedPackages = Object.fromEntries(
4073
+ [...pkgStats.entries()].sort((a, b) => b[1].categories.other.loc - a[1].categories.other.loc)
4074
+ );
4075
+ return { total: entries.length, totalLoc, skipped, categories, packages: sortedPackages };
4076
+ }
4077
+ function pad(n, width = 6) {
4078
+ return String(n).padStart(width);
4079
+ }
4080
+ function formatLoc(n) {
4081
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
4082
+ return String(n);
4083
+ }
4084
+ function formatLocColumn(n, width = 8) {
4085
+ return formatLoc(n).padStart(width);
4086
+ }
4087
+ function fmtPct(value, total) {
4088
+ const pct = (value / total * 100).toFixed(1);
4089
+ return `${pct.padStart(5)}%`;
4090
+ }
4091
+ function printStats(stats, verbose) {
4092
+ console.info();
4093
+ console.info(
4094
+ ` ${colors_default.bold("source files")} ${String(stats.total)} ${colors_default.dim(`(${stats.skipped} non-source skipped)`)}`
4095
+ );
4096
+ console.info(` ${colors_default.bold("lines of code")} ${colors_default.cyan(formatLoc(stats.totalLoc))}`);
4097
+ console.info();
4098
+ const catEntries = [
4099
+ ["source & config", stats.categories.other],
4100
+ ["test", stats.categories.test],
4101
+ ["generated", stats.categories.generated],
4102
+ ["json", stats.categories.json]
4103
+ ];
4104
+ for (const [label, data] of catEntries) {
4105
+ console.info(
4106
+ ` ${pad(data.files)} ${colors_default.dim(fmtPct(data.files, stats.total))} ${label.padEnd(18)} ${colors_default.cyan(formatLocColumn(data.loc))} ${colors_default.dim(fmtPct(data.loc, stats.totalLoc))}`
4107
+ );
4108
+ }
4109
+ console.info();
4110
+ console.info(DIVIDER);
4111
+ console.info();
4112
+ if (verbose) {
4113
+ const header = ` ${"".padEnd(6)} ${"package".padEnd(18)} ${"loc".padStart(8)} ${"test".padStart(6)} ${"gen".padStart(6)} ${"json".padStart(6)} ${"src".padStart(6)}`;
4114
+ console.info(colors_default.dim(header));
4115
+ for (const [name, data] of Object.entries(stats.packages)) {
4116
+ const c = data.categories;
4117
+ const srcFiles = c.other.files;
4118
+ const srcLoc = c.other.loc;
4119
+ if (srcFiles === 0) continue;
4120
+ console.info(
4121
+ ` ${colors_default.dim(pad(srcFiles))} ${name.padEnd(18)} ${colors_default.cyan(formatLocColumn(srcLoc))} ${colors_default.dim(pad(c.test.files))} ${colors_default.dim(pad(c.generated.files))} ${colors_default.dim(pad(c.json.files))} ${colors_default.dim(pad(srcFiles))}`
4122
+ );
4123
+ }
4124
+ } else {
4125
+ for (const [name, data] of Object.entries(stats.packages)) {
4126
+ const srcFiles = data.categories.other.files;
4127
+ const srcLoc = data.categories.other.loc;
4128
+ if (srcFiles === 0) continue;
4129
+ console.info(
4130
+ ` ${pad(srcFiles)} ${colors_default.dim(fmtPct(srcFiles, stats.categories.other.files))} ${name.padEnd(18)} ${colors_default.cyan(formatLocColumn(srcLoc))} ${colors_default.dim(fmtPct(srcLoc, stats.categories.other.loc))}`
4131
+ );
4132
+ }
4133
+ }
4134
+ console.info();
4135
+ }
4136
+ async function runStats(forkPath, options = {}) {
4137
+ createSpinner("counting files...");
4138
+ const stats = await collectStats(forkPath);
4139
+ spinnerSuccess("Finished counting by raw line of code");
4140
+ if (options.json) {
4141
+ console.info(JSON.stringify(stats, null, 2));
4142
+ } else {
4143
+ printStats(stats, options.verbose ?? false);
4144
+ console.info();
4145
+ printCoverageSummary(forkPath, { refresh: options.refreshCoverage });
4146
+ }
4147
+ }
4148
+
4149
+ // src/cella-cli.ts
4150
+ function getForkPath() {
4151
+ const envPath = process8.env.CELLA_FORK_PATH;
4152
+ if (envPath) {
4153
+ const resolved = resolve6(envPath);
4154
+ if (!existsSync11(resolved) || !statSync2(resolved).isDirectory()) {
4155
+ throw new Error(`CELLA_FORK_PATH is not a valid directory: ${resolved}`);
4156
+ }
4157
+ return resolved;
4158
+ }
4159
+ let cwd = process8.cwd();
4160
+ if (cwd.endsWith("/cli/cella") || cwd.endsWith("\\cli\\cella")) {
4161
+ cwd = resolve6(cwd, "../..");
4162
+ }
4163
+ return cwd;
4164
+ }
4165
+ async function preflight(forkPath) {
4166
+ if (!existsSync11(join16(forkPath, ".git"))) {
4167
+ throw new Error(`not a git repository: ${forkPath}`);
4168
+ }
4169
+ }
4170
+ function isHelpOrVersionRequest() {
4171
+ return process8.argv.slice(2).some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v") || process8.argv[2] === "help";
4172
+ }
4173
+ async function main() {
4174
+ registerSignalHandlers();
4175
+ try {
4176
+ const forkPath = getForkPath();
4177
+ if (isHelpOrVersionRequest()) {
4178
+ await parseCli({ settings: { upstreamUrl: "", upstreamBranch: "main" } }, forkPath);
4179
+ return;
4180
+ }
4181
+ const userConfig = await loadConfig(forkPath);
4182
+ const config = await parseCli(userConfig, forkPath);
4183
+ if (!["audit", "forks", "contributions", "stats"].includes(config.service)) {
4184
+ await preflight(forkPath);
4185
+ }
4186
+ switch (config.service) {
4187
+ case "analyze": {
4188
+ await runAnalyze(config);
4189
+ break;
4190
+ }
4191
+ case "sync": {
4192
+ await runSyncCommand(config);
4193
+ break;
4194
+ }
4195
+ case "audit":
4196
+ await runAudit(config, { force: config.force, checkOverrides: config.checkOverrides });
4197
+ break;
4198
+ case "forks":
4199
+ await runForks(config);
4200
+ break;
4201
+ case "contributions":
4202
+ await runContributions(config);
4203
+ break;
4204
+ case "stats":
4205
+ await runStats(config.forkPath, { verbose: config.verbose, refreshCoverage: config.coverage });
4206
+ break;
4207
+ }
4208
+ console.info();
4209
+ } catch (error) {
4210
+ console.error();
4211
+ console.error(`${colors_default.red("\u2717")} ${error instanceof Error ? error.message : "unknown error"}`);
4212
+ process8.exit(1);
4213
+ }
4214
+ }
4215
+ main();
4216
+ //# sourceMappingURL=index.js.map