create-projx 1.5.4 → 1.5.6

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.
@@ -0,0 +1,629 @@
1
+ import {
2
+ DEFAULT_COMPONENT_SKIP_PATTERNS,
3
+ DEFAULT_ROOT_SKIP_PATTERNS,
4
+ copyComponent,
5
+ copyStaticFiles,
6
+ readComponentMarker,
7
+ readProjxConfig,
8
+ render,
9
+ replaceInDir,
10
+ replaceInFile,
11
+ sharedTemplateDir,
12
+ toSnake,
13
+ upsertComponentMarker,
14
+ writeProjxConfig
15
+ } from "./chunk-FTHX7ILT.js";
16
+
17
+ // src/baseline.ts
18
+ import { existsSync, writeFileSync, unlinkSync } from "fs";
19
+ import { chmod, mkdir, writeFile, rm, readFile as readFile2 } from "fs/promises";
20
+ import { execSync } from "child_process";
21
+ import { join as join2 } from "path";
22
+ import { tmpdir } from "os";
23
+
24
+ // src/generators/index.ts
25
+ import { readFile } from "fs/promises";
26
+ import { join } from "path";
27
+ async function renderShared(filename, vars) {
28
+ const tpl = await readFile(
29
+ join(sharedTemplateDir(), filename),
30
+ "utf-8"
31
+ );
32
+ return render(tpl, vars);
33
+ }
34
+ async function generateDockerCompose(vars) {
35
+ return renderShared("docker-compose.yml.ejs", vars);
36
+ }
37
+ async function generateDockerComposeDev(vars) {
38
+ return renderShared("docker-compose.dev.yml.ejs", vars);
39
+ }
40
+ async function generatePreCommit(vars) {
41
+ return renderShared("pre-commit.ejs", vars);
42
+ }
43
+ async function generateSetupSh(vars) {
44
+ return renderShared("setup.sh.ejs", vars);
45
+ }
46
+ async function generateCiYml(vars) {
47
+ return renderShared("ci.yml.ejs", vars);
48
+ }
49
+ async function generateReadme(vars) {
50
+ return renderShared("README.md.ejs", vars);
51
+ }
52
+ function generateVscodeSettings(vars) {
53
+ const settings = {};
54
+ if (vars.components.includes("fastapi")) {
55
+ settings["[python]"] = {
56
+ "editor.defaultFormatter": "charliermarsh.ruff",
57
+ "editor.codeActionsOnSave": { "source.fixAll.ruff": "explicit" }
58
+ };
59
+ }
60
+ settings["[typescript]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
61
+ settings["[typescriptreact]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
62
+ settings["[javascript]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
63
+ settings["[json]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
64
+ settings["[css]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
65
+ settings["[yaml]"] = { "editor.defaultFormatter": "esbenp.prettier-vscode" };
66
+ settings["editor.formatOnSave"] = true;
67
+ settings["editor.codeActionsOnSave"] = { "source.fixAll.eslint": "explicit" };
68
+ settings["eslint.useFlatConfig"] = true;
69
+ const prettierComponent = ["frontend", "fastify", "e2e"].find(
70
+ (c) => vars.components.includes(c)
71
+ );
72
+ if (prettierComponent) {
73
+ settings["prettier.configPath"] = `${vars.paths[prettierComponent]}/.prettierrc`;
74
+ }
75
+ if (vars.components.includes("fastapi")) {
76
+ settings["ruff.lineLength"] = 120;
77
+ settings["python.analysis.extraPaths"] = [`${vars.paths.fastapi}/src`];
78
+ settings["python.analysis.importFormat"] = "absolute";
79
+ }
80
+ return JSON.stringify(settings, null, 2) + "\n";
81
+ }
82
+
83
+ // src/baseline.ts
84
+ function buildPathsUpper(paths) {
85
+ const result = {};
86
+ for (const [component, dir] of Object.entries(paths)) {
87
+ result[component] = dir.replace(/[^A-Za-z0-9]+/g, "_").toUpperCase();
88
+ }
89
+ return result;
90
+ }
91
+ var CANONICAL_DISPLAY_NAMES = {
92
+ fastapi: "FastAPI",
93
+ fastify: "Fastify",
94
+ frontend: "Frontend",
95
+ mobile: "Flutter",
96
+ e2e: "E2E",
97
+ infra: "Terraform"
98
+ };
99
+ function buildDisplayNames(paths) {
100
+ const result = {};
101
+ for (const [component, dir] of Object.entries(paths)) {
102
+ const canonical = component;
103
+ result[canonical] = dir === canonical ? CANONICAL_DISPLAY_NAMES[canonical] : dir;
104
+ }
105
+ return result;
106
+ }
107
+ var BASELINE_REF = "refs/projx/baseline";
108
+ async function migrateComponentMarkers(cwd, components, componentPaths, applyDefaults) {
109
+ const { readComponentMarker: readComponentMarker2, writeComponentMarker } = await import("./utils-OOY5OZDX.js");
110
+ for (const component of components) {
111
+ const dir = componentPaths[component];
112
+ const markerDir = join2(cwd, dir);
113
+ if (!existsSync(markerDir)) continue;
114
+ const marker = await readComponentMarker2(markerDir);
115
+ if (!marker) continue;
116
+ const next = { ...marker };
117
+ if (applyDefaults) {
118
+ const defaults = DEFAULT_COMPONENT_SKIP_PATTERNS[component] ?? [];
119
+ next.skip = [.../* @__PURE__ */ new Set([...marker.skip, ...defaults])];
120
+ }
121
+ await writeComponentMarker(markerDir, next);
122
+ }
123
+ }
124
+ async function writeManagedProjx(cwd, version, vars, applyDefaults) {
125
+ const existing = await readProjxConfig(cwd);
126
+ delete existing.components;
127
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
128
+ const merged = {
129
+ ...existing,
130
+ version,
131
+ updatedAt: today
132
+ };
133
+ const pmObj = vars.pm;
134
+ if (pmObj?.name && !merged.packageManager) {
135
+ merged.packageManager = pmObj.name;
136
+ }
137
+ if (applyDefaults && !merged.defaultsApplied) {
138
+ const userSkip = Array.isArray(merged.skip) ? merged.skip : [];
139
+ merged.skip = [.../* @__PURE__ */ new Set([...userSkip, ...DEFAULT_ROOT_SKIP_PATTERNS])];
140
+ merged.defaultsApplied = true;
141
+ }
142
+ await writeProjxConfig(cwd, merged);
143
+ }
144
+ function matchesSkip(filePath, patterns) {
145
+ for (const pattern of patterns) {
146
+ if (pattern === "**") return true;
147
+ if (pattern.endsWith("/**")) {
148
+ const prefix = pattern.slice(0, -3);
149
+ if (filePath.startsWith(prefix + "/") || filePath === prefix) return true;
150
+ }
151
+ if (pattern.startsWith("**/")) {
152
+ const suffix = pattern.slice(3);
153
+ if (suffix.startsWith("*.")) {
154
+ const ext = suffix.slice(1);
155
+ if (filePath.endsWith(ext)) return true;
156
+ } else if (filePath.endsWith(suffix) || filePath.includes("/" + suffix)) {
157
+ return true;
158
+ }
159
+ }
160
+ if (pattern.startsWith("*.")) {
161
+ const ext = pattern.slice(1);
162
+ if (filePath.endsWith(ext)) return true;
163
+ }
164
+ if (filePath === pattern) return true;
165
+ }
166
+ return false;
167
+ }
168
+ function saveBaselineRef(cwd) {
169
+ try {
170
+ const head = execSync("git rev-parse HEAD", { cwd, stdio: "pipe" }).toString().trim();
171
+ execSync(`git update-ref ${BASELINE_REF} ${head}`, { cwd, stdio: "pipe" });
172
+ } catch {
173
+ }
174
+ }
175
+ function getBaselineRef(cwd) {
176
+ try {
177
+ return execSync(`git rev-parse --verify ${BASELINE_REF}`, { cwd, stdio: "pipe" }).toString().trim();
178
+ } catch {
179
+ }
180
+ try {
181
+ const sha = execSync("git log -1 --format=%H -- .projx", { cwd, stdio: "pipe" }).toString().trim();
182
+ if (sha) return sha;
183
+ } catch {
184
+ }
185
+ return null;
186
+ }
187
+ function getFileAtRef(cwd, ref, filePath) {
188
+ try {
189
+ return execSync(`git show ${ref}:"${filePath}"`, { cwd, stdio: "pipe" }).toString();
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+ function mergeFileThreeWay(oursPath, baseContent, theirsContent) {
195
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
196
+ const baseTmp = join2(tmpdir(), `projx-base-${id}`);
197
+ const theirsTmp = join2(tmpdir(), `projx-theirs-${id}`);
198
+ try {
199
+ writeFileSync(baseTmp, baseContent);
200
+ writeFileSync(theirsTmp, theirsContent);
201
+ execSync(
202
+ `git merge-file -L "your changes" -L "previous projx baseline" -L "new projx template" "${oursPath}" "${baseTmp}" "${theirsTmp}"`,
203
+ { stdio: "pipe" }
204
+ );
205
+ return true;
206
+ } catch {
207
+ return false;
208
+ } finally {
209
+ try {
210
+ unlinkSync(baseTmp);
211
+ } catch {
212
+ }
213
+ try {
214
+ unlinkSync(theirsTmp);
215
+ } catch {
216
+ }
217
+ }
218
+ }
219
+ async function collectAllFiles(dir, base) {
220
+ const { readdir } = await import("fs/promises");
221
+ const results = [];
222
+ const walk = async (current) => {
223
+ const entries = await readdir(current, { withFileTypes: true });
224
+ for (const entry of entries) {
225
+ const full = join2(current, entry.name);
226
+ if (entry.isDirectory()) {
227
+ await walk(full);
228
+ } else {
229
+ results.push(full.slice(base.length + 1));
230
+ }
231
+ }
232
+ };
233
+ await walk(dir);
234
+ return results;
235
+ }
236
+ function buildPathFallbacks(componentPaths) {
237
+ const fallbacks = {};
238
+ for (const [component, dir] of Object.entries(componentPaths)) {
239
+ if (dir !== component) fallbacks[dir] = component;
240
+ }
241
+ return fallbacks;
242
+ }
243
+ function lookupBaseContent(cwd, baselineRef, file, pathFallbacks) {
244
+ const direct = getFileAtRef(cwd, baselineRef, file);
245
+ if (direct !== null) return direct;
246
+ const slash = file.indexOf("/");
247
+ if (slash === -1) return null;
248
+ const topDir = file.slice(0, slash);
249
+ const canonical = pathFallbacks[topDir];
250
+ if (!canonical) return null;
251
+ return getFileAtRef(cwd, baselineRef, canonical + file.slice(slash));
252
+ }
253
+ async function tryThreeWayMerge(cwd, templateDir, baselineRef, componentPaths) {
254
+ const templateFiles = await collectAllFiles(templateDir, templateDir);
255
+ const merged = [];
256
+ const conflicted = [];
257
+ const pathFallbacks = buildPathFallbacks(componentPaths);
258
+ for (const file of templateFiles) {
259
+ if (file === ".projx") continue;
260
+ if (file.endsWith("/.projx-component") || file === ".projx-component") continue;
261
+ const oursPath = join2(cwd, file);
262
+ if (!existsSync(oursPath)) continue;
263
+ const baseContent = lookupBaseContent(cwd, baselineRef, file, pathFallbacks);
264
+ if (baseContent === null) continue;
265
+ let theirsContent;
266
+ try {
267
+ theirsContent = await readFile2(join2(templateDir, file), "utf-8");
268
+ } catch {
269
+ continue;
270
+ }
271
+ const oursContent = await readFile2(oursPath, "utf-8");
272
+ if (theirsContent === baseContent) continue;
273
+ if (oursContent === baseContent) {
274
+ await writeFile(oursPath, theirsContent);
275
+ merged.push(file);
276
+ continue;
277
+ }
278
+ if (oursContent === theirsContent) continue;
279
+ const clean = mergeFileThreeWay(oursPath, baseContent, theirsContent);
280
+ if (clean) {
281
+ merged.push(file);
282
+ } else {
283
+ conflicted.push(file);
284
+ }
285
+ }
286
+ return { merged, conflicted };
287
+ }
288
+ function createOrphanWorktree(cwd) {
289
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
290
+ const branch = `projx/tmp-${id}`;
291
+ const worktree = join2(tmpdir(), `projx-wt-${id}`);
292
+ try {
293
+ execSync("git worktree prune", { cwd, stdio: "pipe" });
294
+ } catch {
295
+ }
296
+ execSync(`git worktree add --orphan -b ${branch} "${worktree}"`, {
297
+ cwd,
298
+ stdio: "pipe"
299
+ });
300
+ return { worktree, branch };
301
+ }
302
+ function cleanupWorktree(cwd, worktree, branch) {
303
+ try {
304
+ execSync(`git worktree remove "${worktree}" --force`, { cwd, stdio: "pipe" });
305
+ } catch {
306
+ try {
307
+ rm(worktree, { recursive: true, force: true });
308
+ execSync("git worktree prune", { cwd, stdio: "pipe" });
309
+ } catch {
310
+ }
311
+ }
312
+ try {
313
+ execSync(`git branch -D ${branch}`, { cwd, stdio: "pipe" });
314
+ } catch {
315
+ }
316
+ }
317
+ async function removeSkippedFiles(dir, skipPatterns, realDir) {
318
+ if (skipPatterns.length === 0) return;
319
+ const { readdir, unlink } = await import("fs/promises");
320
+ const walk = async (current, base) => {
321
+ const entries = await readdir(current, { withFileTypes: true });
322
+ for (const entry of entries) {
323
+ const full = join2(current, entry.name);
324
+ const rel = full.slice(base.length + 1);
325
+ if (entry.isDirectory()) {
326
+ await walk(full, base);
327
+ } else if (entry.name !== ".projx-component" && matchesSkip(rel, skipPatterns)) {
328
+ if (realDir && !existsSync(join2(realDir, rel))) continue;
329
+ await unlink(full);
330
+ }
331
+ }
332
+ };
333
+ await walk(dir, dir);
334
+ }
335
+ async function writeTemplateToDir(dest, repoDir, components, componentPaths, vars, version, options = {}) {
336
+ const { componentSkips, rootSkip, applyDefaults = false, realCwd = dest } = options;
337
+ const name = vars.projectName;
338
+ const nameSnake = toSnake(name);
339
+ for (const component of components) {
340
+ const targetDir = componentPaths[component];
341
+ const baseSkip = componentSkips?.[component] ?? [];
342
+ const realMarker = await readComponentMarker(join2(realCwd, targetDir));
343
+ const isNewMarker = !realMarker;
344
+ const shouldApplyComponentDefault = isNewMarker || applyDefaults;
345
+ const defaultSkip = shouldApplyComponentDefault ? DEFAULT_COMPONENT_SKIP_PATTERNS[component] ?? [] : [];
346
+ const skipPatterns = [.../* @__PURE__ */ new Set([...baseSkip, ...defaultSkip])];
347
+ const tmpDir = join2(dest, "__cptmp__");
348
+ await copyComponent(repoDir, component, tmpDir);
349
+ const srcDir = join2(tmpDir, component);
350
+ if (skipPatterns.length > 0) {
351
+ const realComponentDir = join2(realCwd, targetDir);
352
+ await removeSkippedFiles(srcDir, skipPatterns, realComponentDir);
353
+ }
354
+ const outDir = join2(dest, targetDir);
355
+ await mkdir(outDir, { recursive: true });
356
+ const { cp } = await import("fs/promises");
357
+ if (existsSync(srcDir)) {
358
+ await cp(srcDir, outDir, { recursive: true, force: true });
359
+ }
360
+ await rm(tmpDir, { recursive: true, force: true });
361
+ await upsertComponentMarker(join2(dest, targetDir), component, skipPatterns.length > 0 ? skipPatterns : void 0);
362
+ }
363
+ if (!vars.pathsUpper) {
364
+ vars.pathsUpper = buildPathsUpper(componentPaths);
365
+ }
366
+ if (!vars.displayNames) {
367
+ vars.displayNames = buildDisplayNames(componentPaths);
368
+ }
369
+ await substituteNames(dest, components, componentPaths, name, nameSnake, vars.nameOverrides);
370
+ const hasBackend = components.includes("fastapi") || components.includes("fastify");
371
+ const userSkip = rootSkip ?? [];
372
+ const defaultRootSkip = applyDefaults ? DEFAULT_ROOT_SKIP_PATTERNS : [];
373
+ const effectiveSkip = [.../* @__PURE__ */ new Set([...userSkip, ...defaultRootSkip])];
374
+ const shouldWrite = (file) => {
375
+ if (!matchesSkip(file, effectiveSkip)) return true;
376
+ return !existsSync(join2(realCwd, file));
377
+ };
378
+ if (hasBackend || components.includes("frontend")) {
379
+ if (shouldWrite("docker-compose.yml"))
380
+ await writeFile(join2(dest, "docker-compose.yml"), await generateDockerCompose(vars));
381
+ if (shouldWrite("docker-compose.dev.yml"))
382
+ await writeFile(join2(dest, "docker-compose.dev.yml"), await generateDockerComposeDev(vars));
383
+ }
384
+ if (shouldWrite("README.md"))
385
+ await writeFile(join2(dest, "README.md"), await generateReadme(vars));
386
+ if (shouldWrite(".githooks/pre-commit")) {
387
+ await mkdir(join2(dest, ".githooks"), { recursive: true });
388
+ await writeFile(join2(dest, ".githooks/pre-commit"), await generatePreCommit(vars));
389
+ await chmod(join2(dest, ".githooks/pre-commit"), 493);
390
+ }
391
+ if (shouldWrite(".github/workflows/ci.yml")) {
392
+ await mkdir(join2(dest, ".github/workflows"), { recursive: true });
393
+ await writeFile(join2(dest, ".github/workflows/ci.yml"), await generateCiYml(vars));
394
+ }
395
+ if (shouldWrite("setup.sh")) {
396
+ await writeFile(join2(dest, "setup.sh"), await generateSetupSh(vars));
397
+ await chmod(join2(dest, "setup.sh"), 493);
398
+ }
399
+ await copyStaticFiles(repoDir, dest);
400
+ if (shouldWrite(".vscode/settings.json")) {
401
+ await mkdir(join2(dest, ".vscode"), { recursive: true });
402
+ await writeFile(join2(dest, ".vscode/settings.json"), generateVscodeSettings(vars));
403
+ }
404
+ await writeManagedProjx(dest, version, vars, applyDefaults);
405
+ }
406
+ async function substituteNames(dest, components, paths, name, nameSnake, overrides) {
407
+ if (components.includes("fastapi")) {
408
+ const target = overrides?.fastapi ?? `${name}-fastapi`;
409
+ await replaceInFile(join2(dest, `${paths.fastapi}/pyproject.toml`), "projx-fastapi", target);
410
+ }
411
+ if (components.includes("fastify")) {
412
+ const target = overrides?.fastify ?? `${name}-fastify`;
413
+ await replaceInFile(join2(dest, `${paths.fastify}/package.json`), "projx-fastify", target);
414
+ }
415
+ if (components.includes("frontend")) {
416
+ const target = overrides?.frontend ?? `${name}-frontend`;
417
+ await replaceInFile(join2(dest, `${paths.frontend}/package.json`), "projx-frontend", target);
418
+ }
419
+ if (components.includes("e2e")) {
420
+ const target = overrides?.e2e ?? `${name}-e2e`;
421
+ await replaceInFile(join2(dest, `${paths.e2e}/package.json`), "projx-e2e", target);
422
+ }
423
+ if (components.includes("mobile")) {
424
+ const target = overrides?.mobile ?? `${nameSnake}_mobile`;
425
+ await replaceInFile(join2(dest, `${paths.mobile}/pubspec.yaml`), "projx_mobile", target);
426
+ await replaceInDir(join2(dest, `${paths.mobile}`), "package:projx_mobile/", `package:${target}/`, ".dart");
427
+ }
428
+ }
429
+ async function detectPackageNameOverrides(cwd, components, componentPaths) {
430
+ const overrides = {};
431
+ if (components.includes("fastapi")) {
432
+ const file = join2(cwd, componentPaths.fastapi, "pyproject.toml");
433
+ const name = await readTomlProjectName(file);
434
+ if (name) overrides.fastapi = name;
435
+ }
436
+ for (const c of ["fastify", "frontend", "e2e"]) {
437
+ if (!components.includes(c)) continue;
438
+ const file = join2(cwd, componentPaths[c], "package.json");
439
+ const name = await readJsonName(file);
440
+ if (name) overrides[c] = name;
441
+ }
442
+ if (components.includes("mobile")) {
443
+ const file = join2(cwd, componentPaths.mobile, "pubspec.yaml");
444
+ const name = await readPubspecName(file);
445
+ if (name) overrides.mobile = name;
446
+ }
447
+ return overrides;
448
+ }
449
+ async function readTomlProjectName(file) {
450
+ if (!existsSync(file)) return null;
451
+ try {
452
+ const content = await readFile2(file, "utf-8");
453
+ const match = content.match(/^\s*name\s*=\s*"([^"]+)"/m);
454
+ return match?.[1] ?? null;
455
+ } catch {
456
+ return null;
457
+ }
458
+ }
459
+ async function readJsonName(file) {
460
+ if (!existsSync(file)) return null;
461
+ try {
462
+ const data = JSON.parse(await readFile2(file, "utf-8"));
463
+ return typeof data.name === "string" ? data.name : null;
464
+ } catch {
465
+ return null;
466
+ }
467
+ }
468
+ async function readPubspecName(file) {
469
+ if (!existsSync(file)) return null;
470
+ try {
471
+ const content = await readFile2(file, "utf-8");
472
+ const match = content.match(/^\s*name\s*:\s*([A-Za-z0-9_]+)/m);
473
+ return match?.[1] ?? null;
474
+ } catch {
475
+ return null;
476
+ }
477
+ }
478
+ async function applyTemplate(cwd, repoDir, components, componentPaths, vars, version, componentSkips, rootSkip, applyDefaults = false) {
479
+ const hasHead = (() => {
480
+ try {
481
+ execSync("git rev-parse HEAD", { cwd, stdio: "pipe" });
482
+ return true;
483
+ } catch {
484
+ return false;
485
+ }
486
+ })();
487
+ if (!hasHead) {
488
+ await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, {
489
+ componentSkips,
490
+ rootSkip,
491
+ applyDefaults,
492
+ realCwd: cwd
493
+ });
494
+ return { status: "clean" };
495
+ }
496
+ const { worktree, branch } = createOrphanWorktree(cwd);
497
+ try {
498
+ await writeTemplateToDir(worktree, repoDir, components, componentPaths, vars, version, {
499
+ componentSkips,
500
+ rootSkip,
501
+ applyDefaults,
502
+ realCwd: cwd
503
+ });
504
+ execSync("git add -A", { cwd: worktree, stdio: "pipe" });
505
+ const diff = execSync("git diff --cached --stat", { cwd: worktree, stdio: "pipe" }).toString().trim();
506
+ if (!diff) {
507
+ cleanupWorktree(cwd, worktree, branch);
508
+ return { status: "clean" };
509
+ }
510
+ execSync(
511
+ `git -c core.hooksPath=/dev/null commit -m "projx: template v${version} [${components.join(", ")}]"`,
512
+ { cwd: worktree, stdio: "pipe" }
513
+ );
514
+ try {
515
+ execSync(`git worktree remove "${worktree}" --force`, { cwd, stdio: "pipe" });
516
+ } catch {
517
+ try {
518
+ await rm(worktree, { recursive: true, force: true });
519
+ execSync("git worktree prune", { cwd, stdio: "pipe" });
520
+ } catch {
521
+ }
522
+ }
523
+ let mergeClean = false;
524
+ try {
525
+ execSync(
526
+ `git merge ${branch} --allow-unrelated-histories -m "projx: update to template v${version}"`,
527
+ { cwd, stdio: "pipe" }
528
+ );
529
+ mergeClean = true;
530
+ } catch {
531
+ try {
532
+ execSync("git merge --abort", { cwd, stdio: "pipe" });
533
+ } catch {
534
+ }
535
+ }
536
+ try {
537
+ execSync(`git branch -D ${branch}`, { cwd, stdio: "pipe" });
538
+ } catch {
539
+ }
540
+ if (mergeClean) {
541
+ await migrateComponentMarkers(cwd, components, componentPaths, applyDefaults);
542
+ saveBaselineRef(cwd);
543
+ return { status: "clean" };
544
+ }
545
+ const baselineRef = getBaselineRef(cwd);
546
+ if (baselineRef) {
547
+ const tmpTemplate = join2(tmpdir(), `projx-tpl-${Date.now()}`);
548
+ await mkdir(tmpTemplate, { recursive: true });
549
+ await writeTemplateToDir(tmpTemplate, repoDir, components, componentPaths, vars, version, {
550
+ componentSkips,
551
+ rootSkip,
552
+ applyDefaults,
553
+ realCwd: cwd
554
+ });
555
+ const result = await tryThreeWayMerge(cwd, tmpTemplate, baselineRef, componentPaths);
556
+ await rm(tmpTemplate, { recursive: true, force: true });
557
+ await migrateComponentMarkers(cwd, components, componentPaths, applyDefaults);
558
+ if (result.conflicted.length === 0) {
559
+ await writeManagedProjx(cwd, version, vars, applyDefaults);
560
+ execSync("git add -A", { cwd, stdio: "pipe" });
561
+ const staged = execSync("git diff --cached --stat", { cwd, stdio: "pipe" }).toString().trim();
562
+ if (staged) {
563
+ try {
564
+ execSync(
565
+ `git commit -m "projx: update to template v${version} (3-way merge)"`,
566
+ { cwd, stdio: "pipe" }
567
+ );
568
+ } catch (err) {
569
+ throw new Error(
570
+ `Pre-commit hook rejected the merged template content. Resolve the issues and commit manually:
571
+ git commit -m "projx: update to template v${version} (3-way merge)"`,
572
+ { cause: err }
573
+ );
574
+ }
575
+ }
576
+ saveBaselineRef(cwd);
577
+ return result.merged.length > 0 ? { status: "merged", mergedFiles: result.merged } : { status: "clean" };
578
+ }
579
+ await writeManagedProjx(cwd, version, vars, applyDefaults);
580
+ for (const f of result.merged) {
581
+ try {
582
+ execSync(`git add "${f}"`, { cwd, stdio: "pipe" });
583
+ } catch {
584
+ }
585
+ }
586
+ execSync("git add .projx", { cwd, stdio: "pipe" });
587
+ for (const component of components) {
588
+ const dir = componentPaths[component];
589
+ const markerRel = `${dir}/.projx-component`;
590
+ if (existsSync(join2(cwd, markerRel))) {
591
+ try {
592
+ execSync(`git add "${markerRel}"`, { cwd, stdio: "pipe" });
593
+ } catch {
594
+ }
595
+ }
596
+ }
597
+ return {
598
+ status: "conflicts",
599
+ mergedFiles: result.merged,
600
+ conflictedFiles: result.conflicted
601
+ };
602
+ }
603
+ await writeTemplateToDir(cwd, repoDir, components, componentPaths, vars, version, {
604
+ componentSkips,
605
+ rootSkip,
606
+ applyDefaults,
607
+ realCwd: cwd
608
+ });
609
+ await migrateComponentMarkers(cwd, components, componentPaths, applyDefaults);
610
+ return { status: "conflicts" };
611
+ } catch (err) {
612
+ cleanupWorktree(cwd, worktree, branch);
613
+ throw err;
614
+ }
615
+ }
616
+
617
+ export {
618
+ buildPathsUpper,
619
+ buildDisplayNames,
620
+ BASELINE_REF,
621
+ matchesSkip,
622
+ saveBaselineRef,
623
+ getBaselineRef,
624
+ getFileAtRef,
625
+ collectAllFiles,
626
+ writeTemplateToDir,
627
+ detectPackageNameOverrides,
628
+ applyTemplate
629
+ };