@gadmin2n/cli 0.0.169 → 0.0.171

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.
@@ -23,6 +23,7 @@ const merge3way_1 = require("../lib/template-merge/merge3way");
23
23
  const binary_detect_1 = require("../lib/template-merge/binary-detect");
24
24
  const residual_scan_1 = require("../lib/template-merge/residual-scan");
25
25
  const classify_1 = require("../lib/template-merge/classify");
26
+ const git_history_1 = require("../lib/template-merge/git-history");
26
27
  const excludes_1 = require("../lib/template-merge/excludes");
27
28
  const base_store_1 = require("../lib/template-merge/base-store");
28
29
  const materialize_1 = require("../lib/template-merge/materialize");
@@ -487,6 +488,81 @@ function handleBinary(rel, baseRoot, oursRoot, theirsRoot, opts, conflicts) {
487
488
  console.warn(chalk.gray(` template(theirs): ${theirsPath}`));
488
489
  console.warn(chalk.gray(` base: ${basePath}`));
489
490
  }
491
+ /**
492
+ * Interactive batch picker for a group of files the instance could adopt from
493
+ * the template. Returns the subset the user chose to adopt. Default action is
494
+ * "skip all" so hitting Enter never pulls in unwanted files.
495
+ */
496
+ function pickAdoptions(label, files) {
497
+ return __awaiter(this, void 0, void 0, function* () {
498
+ const { mode } = yield inquirer.prompt([
499
+ {
500
+ type: 'list',
501
+ name: 'mode',
502
+ message: `${files.length} ${label}. What would you like to do?`,
503
+ choices: [
504
+ { name: 'Skip all — keep them out of your instance (default)', value: 'skip' },
505
+ { name: 'Adopt all — copy them from the template', value: 'all' },
506
+ { name: 'Choose per-file', value: 'choose' },
507
+ ],
508
+ default: 'skip',
509
+ },
510
+ ]);
511
+ if (mode === 'all')
512
+ return [...files];
513
+ if (mode === 'skip')
514
+ return [];
515
+ const { picks } = yield inquirer.prompt([
516
+ {
517
+ type: 'checkbox',
518
+ name: 'picks',
519
+ message: 'Select files to adopt:',
520
+ choices: files.map((f) => ({ name: f, value: f })),
521
+ },
522
+ ]);
523
+ return picks;
524
+ });
525
+ }
526
+ /**
527
+ * Decide which `adoptable` (never-tracked) and `deleteModifyConflict`
528
+ * (previously-deleted) files to bring in from the template, then fold the
529
+ * chosen ones into `plan.add` so applyPlan copies them like any other add.
530
+ * Unchosen files are left where they are: adoptable → silently kept absent;
531
+ * deleteModifyConflict → handled by applyPlan's existing conflict path.
532
+ *
533
+ * Resolution policy:
534
+ * --theirs → adopt everything; --ours / --yes → adopt nothing;
535
+ * interactive → batch prompt per group (default skip).
536
+ */
537
+ function resolveAdoptions(plan, opts, autoYes) {
538
+ return __awaiter(this, void 0, void 0, function* () {
539
+ const groups = [
540
+ { bucket: 'adoptable', label: 'template file(s) your instance has never had' },
541
+ {
542
+ bucket: 'deleteModifyConflict',
543
+ label: 'file(s) you previously deleted that the template has since modified',
544
+ },
545
+ ];
546
+ for (const g of groups) {
547
+ const files = plan[g.bucket];
548
+ if (files.length === 0)
549
+ continue;
550
+ let adopt;
551
+ if (opts.theirs)
552
+ adopt = [...files];
553
+ else if (opts.ours || autoYes)
554
+ adopt = [];
555
+ else
556
+ adopt = yield pickAdoptions(g.label, files);
557
+ if (adopt.length === 0)
558
+ continue;
559
+ const adoptSet = new Set(adopt);
560
+ for (const rel of adopt)
561
+ plan.add.push(rel);
562
+ plan[g.bucket] = files.filter((f) => !adoptSet.has(f));
563
+ }
564
+ });
565
+ }
490
566
  function applyPlan(plan, baseRoot, oursRoot, theirsRoot, opts) {
491
567
  const conflicts = [];
492
568
  for (const rel of plan.add) {
@@ -603,6 +679,13 @@ function printPlanFileLists(plan, opts) {
603
679
  console.info(` ${f}`);
604
680
  console.info();
605
681
  }
682
+ if (plan.adoptable.length > 0) {
683
+ console.info(chalk.cyan(`🆕 Adoptable (optional) — ${plan.adoptable.length} file${plural(plan.adoptable.length)} the template provides that your instance never had`));
684
+ for (const f of plan.adoptable)
685
+ console.info(` ${f}`);
686
+ console.info(chalk.gray(' Not conflicts — skipped by default. Adopt interactively or with --theirs.'));
687
+ console.info();
688
+ }
606
689
  if (plan.delete.length > 0) {
607
690
  console.info(chalk.red(`🗑 Deleted (template removed) — ${plan.delete.length} file${plural(plan.delete.length)}`));
608
691
  for (const f of plan.delete)
@@ -814,7 +897,8 @@ class UpdateAction extends abstract_action_1.AbstractAction {
814
897
  ? `(materialized from schematics ${meta.schematicsVersion})`
815
898
  : '(not yet established — run `gadmin2 update` once to bootstrap)'}`);
816
899
  console.info();
817
- const plan = (0, classify_1.classifyAll)(baseRoot, instanceDir, theirsRoot, userExcludes);
900
+ const oursEverTracked = (0, git_history_1.buildEverTrackedChecker)(instanceDir);
901
+ const plan = (0, classify_1.classifyAll)(baseRoot, instanceDir, theirsRoot, userExcludes, oursEverTracked);
818
902
  if (filterFile) {
819
903
  printPerFileTriDiff(filterFile, baseRoot, instanceDir, theirsRoot, metaExists);
820
904
  return;
@@ -827,6 +911,11 @@ class UpdateAction extends abstract_action_1.AbstractAction {
827
911
  plan.add.forEach((f) => console.info(` ${f}`));
828
912
  console.info();
829
913
  }
914
+ if (plan.adoptable.length > 0) {
915
+ console.info(chalk.cyan(`🆕 Adoptable — optional, never had (${plan.adoptable.length} files)`));
916
+ plan.adoptable.forEach((f) => console.info(` ${f}`));
917
+ console.info();
918
+ }
830
919
  if (plan.delete.length > 0) {
831
920
  console.info(chalk.red(`🗑 To delete (${plan.delete.length} files)`));
832
921
  plan.delete.forEach((f) => console.info(` ${f}`));
@@ -847,7 +936,7 @@ class UpdateAction extends abstract_action_1.AbstractAction {
847
936
  conflicts.forEach(([k, p]) => console.info(` [${k}] ${p}`));
848
937
  console.info();
849
938
  }
850
- if (plan.add.length + plan.delete.length + plan.update.length + conflicts.length === 0) {
939
+ if (plan.add.length + plan.delete.length + plan.update.length + plan.adoptable.length + conflicts.length === 0) {
851
940
  console.info(chalk.green('✅ No differences found. Instance matches template.\n'));
852
941
  }
853
942
  }
@@ -906,12 +995,19 @@ class UpdateAction extends abstract_action_1.AbstractAction {
906
995
  console.error(chalk.red(formatMaterializeError(meta.schematicsVersion, err)));
907
996
  process.exit(2);
908
997
  }
909
- const plan = (0, classify_1.classifyAll)(baseHandle.dir, instanceDir, theirsHandle.dir, userExcludes);
998
+ const oursEverTracked = (0, git_history_1.buildEverTrackedChecker)(instanceDir);
999
+ const plan = (0, classify_1.classifyAll)(baseHandle.dir, instanceDir, theirsHandle.dir, userExcludes, oursEverTracked);
910
1000
  const structural = [
911
1001
  ...plan.addAddConflict.map((p) => ({ kind: 'add/add', path: p })),
912
1002
  ...plan.modifyDeleteConflict.map((p) => ({ kind: 'modify/delete', path: p })),
913
1003
  ...plan.deleteModifyConflict.map((p) => ({ kind: 'delete/modify', path: p })),
914
1004
  ];
1005
+ // Adoptable files are optional (template features the instance never had),
1006
+ // NOT conflicts — surfaced informationally without affecting clean state.
1007
+ if (plan.adoptable.length > 0) {
1008
+ console.info(chalk.cyan(`Adoptable: ${plan.adoptable.length} optional template file(s) available`) +
1009
+ chalk.gray(' (run `gadmin2 update` to adopt, or `gadmin2 update diff` to list)\n'));
1010
+ }
915
1011
  const totalConflicts = residual.length + structural.length;
916
1012
  if (totalConflicts === 0) {
917
1013
  console.info('Instance state: ' + chalk.green('✅ clean (no unresolved conflicts)\n'));
@@ -1034,9 +1130,11 @@ class UpdateAction extends abstract_action_1.AbstractAction {
1034
1130
  process.exit(1);
1035
1131
  }
1036
1132
  const baseRoot = baseHandle.dir;
1037
- // Classify three sides.
1038
- const plan = (0, classify_1.classifyAll)(baseRoot, instanceDir, theirsRoot, userExcludes);
1039
- const totalChanges = plan.add.length + plan.delete.length + plan.update.length +
1133
+ // Classify three sides. git history lets us tell "instance never had
1134
+ // this file" (adoptable) from "instance deleted it" (deleteModifyConflict).
1135
+ const oursEverTracked = (0, git_history_1.buildEverTrackedChecker)(instanceDir);
1136
+ const plan = (0, classify_1.classifyAll)(baseRoot, instanceDir, theirsRoot, userExcludes, oursEverTracked);
1137
+ const totalChanges = plan.add.length + plan.delete.length + plan.update.length + plan.adoptable.length +
1040
1138
  plan.addAddConflict.length + plan.modifyDeleteConflict.length + plan.deleteModifyConflict.length;
1041
1139
  if (totalChanges === 0) {
1042
1140
  console.info(chalk.green('✅ Instance is up to date with template.\n'));
@@ -1050,7 +1148,10 @@ class UpdateAction extends abstract_action_1.AbstractAction {
1050
1148
  }
1051
1149
  else {
1052
1150
  // Pre-apply preview + confirm (skipped under --yes).
1053
- const applyAndReport = () => {
1151
+ const applyAndReport = () => __awaiter(this, void 0, void 0, function* () {
1152
+ // Decide which never-had / previously-deleted files to adopt, then
1153
+ // fold them into plan.add before applying.
1154
+ yield resolveAdoptions(plan, mergeOpts, autoYes);
1054
1155
  const conflicts = applyPlan(plan, baseRoot, instanceDir, theirsRoot, mergeOpts);
1055
1156
  changesApplied = true;
1056
1157
  // Always advance meta to new version, even when conflicts present (invariant #2).
@@ -1064,13 +1165,13 @@ class UpdateAction extends abstract_action_1.AbstractAction {
1064
1165
  try {
1065
1166
  (0, executable_paths_1.ensureExecutableBits)(instanceDir);
1066
1167
  }
1067
- catch (_a) {
1168
+ catch (_j) {
1068
1169
  // best-effort, never fatal
1069
1170
  }
1070
1171
  printSummary(plan, conflicts, templateName, schematicsVersion);
1071
- };
1172
+ });
1072
1173
  if (autoYes) {
1073
- applyAndReport();
1174
+ yield applyAndReport();
1074
1175
  }
1075
1176
  else {
1076
1177
  console.info(chalk.bold('\n═══════════════════════════════════════════════════'));
@@ -1087,7 +1188,7 @@ class UpdateAction extends abstract_action_1.AbstractAction {
1087
1188
  },
1088
1189
  ]);
1089
1190
  if (proceedTemplate) {
1090
- applyAndReport();
1191
+ yield applyAndReport();
1091
1192
  }
1092
1193
  else {
1093
1194
  console.info(chalk.gray('\nTemplate update skipped — base & files unchanged.\n'));
@@ -6,6 +6,7 @@ export interface ClassifyInput {
6
6
  oursEqBase?: () => boolean;
7
7
  oursEqTheirs?: () => boolean;
8
8
  baseEqTheirs?: () => boolean;
9
+ oursEverTracked?: () => boolean;
9
10
  }
10
11
  export declare function classifyFile(input: ClassifyInput): ClassifyResult;
11
- export declare function classifyAll(baseDir: string, oursDir: string, theirsDir: string, userExcludes: string[]): ClassifiedPlan;
12
+ export declare function classifyAll(baseDir: string, oursDir: string, theirsDir: string, userExcludes: string[], oursEverTracked?: (rel: string) => boolean): ClassifiedPlan;
@@ -6,7 +6,7 @@ const fs = require("fs");
6
6
  const path = require("path");
7
7
  const excludes_1 = require("./excludes");
8
8
  function classifyFile(input) {
9
- var _a, _b, _c, _d;
9
+ var _a, _b, _c, _d, _e;
10
10
  const { baseExists, oursExists, theirsExists } = input;
11
11
  if (!baseExists && !oursExists && !theirsExists) {
12
12
  throw new Error('classifyFile: impossible state — file in none of the three sides');
@@ -20,14 +20,20 @@ function classifyFile(input) {
20
20
  }
21
21
  if (baseExists && !oursExists && !theirsExists)
22
22
  return 'noop';
23
- if (baseExists && !oursExists && theirsExists)
24
- return 'deleteModifyConflict';
23
+ if (baseExists && !oursExists && theirsExists) {
24
+ // Instance lacks a file the template has. If the instance NEVER tracked it
25
+ // in git, it's a template feature the instance simply never had → optional
26
+ // adoption (adoptable). If it was tracked and later deleted, it's a genuine
27
+ // delete/modify conflict. When we can't tell (no callback / no git), stay
28
+ // conservative → deleteModifyConflict (the historical behaviour).
29
+ return ((_b = input.oursEverTracked) === null || _b === void 0 ? void 0 : _b.call(input)) === false ? 'adoptable' : 'deleteModifyConflict';
30
+ }
25
31
  if (baseExists && oursExists && !theirsExists) {
26
- return ((_b = input.oursEqBase) === null || _b === void 0 ? void 0 : _b.call(input)) ? 'delete' : 'modifyDeleteConflict';
32
+ return ((_c = input.oursEqBase) === null || _c === void 0 ? void 0 : _c.call(input)) ? 'delete' : 'modifyDeleteConflict';
27
33
  }
28
34
  // ✓✓✓ — all three sides exist.
29
35
  // Fast path 1: ours already equals theirs → no merge needed.
30
- if ((_c = input.oursEqTheirs) === null || _c === void 0 ? void 0 : _c.call(input))
36
+ if ((_d = input.oursEqTheirs) === null || _d === void 0 ? void 0 : _d.call(input))
31
37
  return 'keep';
32
38
  // Fast path 2: base equals theirs → template hasn't changed for this file
33
39
  // since the recorded base. A 3-way merge would produce ours unchanged
@@ -35,7 +41,7 @@ function classifyFile(input) {
35
41
  // avoid spurious "Updated" entries on every run when the instance has
36
42
  // legitimately diverged from the template — rendered placeholders in
37
43
  // init.sql, regenerated yarn.lock files, user-edited .env, etc.
38
- if ((_d = input.baseEqTheirs) === null || _d === void 0 ? void 0 : _d.call(input))
44
+ if ((_e = input.baseEqTheirs) === null || _e === void 0 ? void 0 : _e.call(input))
39
45
  return 'keep';
40
46
  return 'update';
41
47
  }
@@ -43,7 +49,7 @@ exports.classifyFile = classifyFile;
43
49
  function fileBytesEqual(p, q) {
44
50
  return fs.readFileSync(p).equals(fs.readFileSync(q));
45
51
  }
46
- function classifyAll(baseDir, oursDir, theirsDir, userExcludes) {
52
+ function classifyAll(baseDir, oursDir, theirsDir, userExcludes, oursEverTracked) {
47
53
  const excludes = [...excludes_1.HARDCODED_EXCLUDES, ...userExcludes];
48
54
  const baseFiles = new Set((0, excludes_1.collectFiles)(baseDir, excludes));
49
55
  const oursFiles = new Set((0, excludes_1.collectFiles)(oursDir, excludes));
@@ -51,7 +57,7 @@ function classifyAll(baseDir, oursDir, theirsDir, userExcludes) {
51
57
  const all = new Set([...baseFiles, ...oursFiles, ...theirsFiles]);
52
58
  const plan = {
53
59
  add: [], keep: [], noop: [],
54
- delete: [], update: [],
60
+ delete: [], update: [], adoptable: [],
55
61
  addAddConflict: [], modifyDeleteConflict: [], deleteModifyConflict: [],
56
62
  };
57
63
  for (const rel of [...all].sort()) {
@@ -65,6 +71,7 @@ function classifyAll(baseDir, oursDir, theirsDir, userExcludes) {
65
71
  oursEqBase: () => baseExists && oursExists && fileBytesEqual(path.join(baseDir, rel), path.join(oursDir, rel)),
66
72
  oursEqTheirs: () => oursExists && theirsExists && fileBytesEqual(path.join(oursDir, rel), path.join(theirsDir, rel)),
67
73
  baseEqTheirs: () => baseExists && theirsExists && fileBytesEqual(path.join(baseDir, rel), path.join(theirsDir, rel)),
74
+ oursEverTracked: oursEverTracked ? () => oursEverTracked(rel) : undefined,
68
75
  });
69
76
  plan[result].push(rel);
70
77
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Build a checker that answers: "has this relative path EVER been tracked in
3
+ * the instance's git repository?"
4
+ *
5
+ * Used by the 3-way classifier to disambiguate the `base有 / ours无 / theirs有`
6
+ * state:
7
+ * - never tracked → the template carries a file the instance never had
8
+ * (a new feature) → `adoptable` (optional adoption).
9
+ * - was tracked → the instance had it and deleted it on purpose
10
+ * → genuine `deleteModifyConflict`.
11
+ *
12
+ * Safety-first fallback: when the directory is NOT a git repo, or git is not on
13
+ * PATH, we return a checker that reports EVERYTHING as tracked (`true`). That
14
+ * keeps the classifier's conservative default (deleteModifyConflict) and makes
15
+ * this feature a no-op for non-git projects — zero behaviour change for them.
16
+ *
17
+ * A git repo with no commits yet resolves to an empty set → every path reports
18
+ * `false` (never tracked). That's the correct signal: nothing has ever been
19
+ * committed, so any template file the instance lacks is genuinely "never had".
20
+ */
21
+ export declare function buildEverTrackedChecker(instanceDir: string): (rel: string) => boolean;
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildEverTrackedChecker = void 0;
4
+ // cli/lib/template-merge/git-history.ts
5
+ const child_process_1 = require("child_process");
6
+ /**
7
+ * Build a checker that answers: "has this relative path EVER been tracked in
8
+ * the instance's git repository?"
9
+ *
10
+ * Used by the 3-way classifier to disambiguate the `base有 / ours无 / theirs有`
11
+ * state:
12
+ * - never tracked → the template carries a file the instance never had
13
+ * (a new feature) → `adoptable` (optional adoption).
14
+ * - was tracked → the instance had it and deleted it on purpose
15
+ * → genuine `deleteModifyConflict`.
16
+ *
17
+ * Safety-first fallback: when the directory is NOT a git repo, or git is not on
18
+ * PATH, we return a checker that reports EVERYTHING as tracked (`true`). That
19
+ * keeps the classifier's conservative default (deleteModifyConflict) and makes
20
+ * this feature a no-op for non-git projects — zero behaviour change for them.
21
+ *
22
+ * A git repo with no commits yet resolves to an empty set → every path reports
23
+ * `false` (never tracked). That's the correct signal: nothing has ever been
24
+ * committed, so any template file the instance lacks is genuinely "never had".
25
+ */
26
+ function buildEverTrackedChecker(instanceDir) {
27
+ if (!isGitRepo(instanceDir)) {
28
+ return () => true; // conservative: can't tell → treat as tracked
29
+ }
30
+ const tracked = collectEverTrackedPaths(instanceDir);
31
+ return (rel) => tracked.has(normalizeRel(rel));
32
+ }
33
+ exports.buildEverTrackedChecker = buildEverTrackedChecker;
34
+ function isGitRepo(instanceDir) {
35
+ try {
36
+ const out = (0, child_process_1.execFileSync)('git', ['-C', instanceDir, 'rev-parse', '--is-inside-work-tree'], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
37
+ return out.trim() === 'true';
38
+ }
39
+ catch (_a) {
40
+ // ENOENT (git missing) or exit 128 (not a repo) → not usable
41
+ return false;
42
+ }
43
+ }
44
+ /**
45
+ * Union of:
46
+ * - every path touched by any commit on any branch (`git log --all --name-only`)
47
+ * - every path currently tracked in the index (`git ls-files`)
48
+ * The first captures files that were added-then-deleted; the second captures
49
+ * staged-but-never-committed files. Together they answer "ever tracked".
50
+ */
51
+ function collectEverTrackedPaths(instanceDir) {
52
+ const paths = new Set();
53
+ // Historical: all paths ever touched across all refs. Empty on a repo with
54
+ // no commits (git exits 128) — swallowed, leaving the set to be filled by
55
+ // ls-files (also empty for a fresh repo).
56
+ addLines(paths, runGitLines(instanceDir, [
57
+ 'log',
58
+ '--all',
59
+ '--pretty=format:',
60
+ '--name-only',
61
+ ]));
62
+ // Current index.
63
+ addLines(paths, runGitLines(instanceDir, ['ls-files']));
64
+ return paths;
65
+ }
66
+ function runGitLines(instanceDir, args) {
67
+ try {
68
+ // core.quotePath=false → git emits UTF-8 paths verbatim instead of
69
+ // octal-escaping non-ASCII bytes inside quotes, so plain string compare works.
70
+ const out = (0, child_process_1.execFileSync)('git', ['-C', instanceDir, '-c', 'core.quotePath=false', ...args], {
71
+ stdio: ['ignore', 'pipe', 'pipe'],
72
+ encoding: 'utf-8',
73
+ maxBuffer: 64 * 1024 * 1024,
74
+ });
75
+ return out.split('\n');
76
+ }
77
+ catch (_a) {
78
+ // No commits yet / any git error → treat as "no paths from this source".
79
+ return [];
80
+ }
81
+ }
82
+ function addLines(set, lines) {
83
+ for (const line of lines) {
84
+ const rel = normalizeRel(line);
85
+ if (rel)
86
+ set.add(rel);
87
+ }
88
+ }
89
+ function normalizeRel(rel) {
90
+ // classify.ts builds rel paths with '/'; git already uses '/'. Trim and
91
+ // strip any surrounding quotes git may add for paths with special chars.
92
+ return rel.trim().replace(/^"|"$/g, '');
93
+ }
@@ -1,11 +1,12 @@
1
1
  /** 单个文件在三方合并中的分类结果。 */
2
- export declare type ClassifyResult = 'add' | 'keep' | 'noop' | 'delete' | 'update' | 'addAddConflict' | 'modifyDeleteConflict' | 'deleteModifyConflict';
2
+ export declare type ClassifyResult = 'add' | 'keep' | 'noop' | 'delete' | 'update' | 'adoptable' | 'addAddConflict' | 'modifyDeleteConflict' | 'deleteModifyConflict';
3
3
  export interface ClassifiedPlan {
4
4
  add: string[];
5
5
  keep: string[];
6
6
  noop: string[];
7
7
  delete: string[];
8
8
  update: string[];
9
+ adoptable: string[];
9
10
  addAddConflict: string[];
10
11
  modifyDeleteConflict: string[];
11
12
  deleteModifyConflict: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gadmin2n/cli",
3
- "version": "0.0.169",
3
+ "version": "0.0.171",
4
4
  "description": "Gadmin - modern, fast, powerful node.js web framework (@cli)",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -47,7 +47,7 @@
47
47
  "@angular-devkit/core": "13.3.2",
48
48
  "@angular-devkit/schematics": "13.3.2",
49
49
  "@angular-devkit/schematics-cli": "13.3.2",
50
- "@gadmin2n/schematics": "^0.0.137",
50
+ "@gadmin2n/schematics": "^0.0.138",
51
51
  "abc": "^0.6.1",
52
52
  "chalk": "3.0.0",
53
53
  "chokidar": "3.5.3",