javi-forge 1.24.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -70,9 +70,14 @@ export async function handleCi(cli, ctx) {
70
70
  // Sub-command: javi-forge ci init → install git hooks
71
71
  if (cli.input[1] === "init") {
72
72
  const { installCIHooks } = await import("../../commands/ci.js");
73
- const { installed, upgraded, backups, errors, states } = await installCIHooks(process.cwd(), {
73
+ const { installed, upgraded, backups, errors, states, notes } = await installCIHooks(process.cwd(), {
74
74
  force: cli.flags.force === true,
75
75
  });
76
+ // Migration notes (e.g. "legacy javi-forge hooksPath removed") come first:
77
+ // they explain a config change the operator did not explicitly ask for.
78
+ for (const note of notes) {
79
+ console.log(`ℹ ${note}`);
80
+ }
76
81
  for (const backup of backups) {
77
82
  console.log(`⚠ Backed up the previous hook → ${backup}`);
78
83
  }
@@ -212,6 +212,12 @@ export interface InstallHooksResult {
212
212
  backups: string[];
213
213
  errors: string[];
214
214
  states: HookStateReport[];
215
+ /**
216
+ * Informational messages from the hooksPath guard (D6) — e.g. the "legacy
217
+ * javi-forge hooksPath removed" note emitted when the migration path unsets
218
+ * a `ci-local/hooks` local value. NOT errors; surfaced to the operator.
219
+ */
220
+ notes: string[];
215
221
  }
216
222
  /**
217
223
  * Pure classification of hook CONTENT (D6 steps 1-5).
@@ -1548,9 +1548,227 @@ async function repairHookMode(hookPath) {
1548
1548
  async function readHookBody(hookName) {
1549
1549
  return await fs.readFile(path.join(HOOK_ASSETS_DIR, hookName), "utf8");
1550
1550
  }
1551
+ /** The exact — and ONLY — value javi-forge itself ever wrote (git.ts:83). */
1552
+ const LEGACY_HOOKSPATH = "ci-local/hooks";
1553
+ /**
1554
+ * Read a higher-scope `core.hooksPath` with `git config <scope> --get`.
1555
+ *
1556
+ * `--global`/`--system` reads return each scope's OWN value regardless of the
1557
+ * local value, so a shadowing higher-scope hooksPath is visible with ZERO
1558
+ * mutation. git exits 1 (no section / no config file) or 5 (key unset) for "no
1559
+ * value" → `{ value: "" }`. ANY other failure is surfaced as `failed` so the
1560
+ * guard can fail CLOSED: a blind spot on a possible shadow must never let the
1561
+ * migration unset the local value and half-migrate the repo.
1562
+ */
1563
+ async function readScopedHooksPath(projectDir, scope) {
1564
+ try {
1565
+ const { stdout } = await execFileAsync("git", ["config", scope, "--get", "core.hooksPath"], { cwd: projectDir });
1566
+ return { value: stdout.trim() };
1567
+ }
1568
+ catch (e) {
1569
+ const code = e.code;
1570
+ if (code === 1 || code === 5) {
1571
+ return { value: "" };
1572
+ }
1573
+ return { value: "", failed: e instanceof Error ? e.message : String(e) };
1574
+ }
1575
+ }
1576
+ /**
1577
+ * Read the LOCAL `core.hooksPath` with the SAME fail-closed discipline as
1578
+ * {@link readScopedHooksPath} (JDA-001).
1579
+ *
1580
+ * git exits 1 (no section / no config file) or 5 (key unset) for a genuine "no
1581
+ * value" → `{ value: "" }`, the safe fresh-install path. ANY OTHER failure
1582
+ * (config lock, I/O error, a corrupt `.git/config`, a git fault) is surfaced as
1583
+ * `failed` so the guard can REFUSE. Swallowing such a failure to `""` would be a
1584
+ * FAIL-OPEN: a local value genuinely = `ci-local/hooks` that transiently fails
1585
+ * to read would look like a fresh repo, so step 5 would never unset it and the
1586
+ * shims would be installed into `.git/hooks` while the still-present local value
1587
+ * keeps SHADOWING them — `installCIHooks` would report installed/upgraded while
1588
+ * git runs the OLD ci-local bodies. An unknown-state local read must never be
1589
+ * treated as a fresh install.
1590
+ */
1591
+ async function readLocalHooksPath(projectDir) {
1592
+ try {
1593
+ const { stdout } = await execFileAsync("git", ["config", "--local", "--get", "core.hooksPath"], { cwd: projectDir });
1594
+ return { value: stdout.trim() };
1595
+ }
1596
+ catch (e) {
1597
+ const code = e.code;
1598
+ if (code === 1 || code === 5) {
1599
+ return { value: "" };
1600
+ }
1601
+ return { value: "", failed: e instanceof Error ? e.message : String(e) };
1602
+ }
1603
+ }
1604
+ /**
1605
+ * Read a WORKTREE-scoped `core.hooksPath` (JDA-002), which — with
1606
+ * `extensions.worktreeConfig` enabled — ALSO shadows `.git/hooks`.
1607
+ *
1608
+ * The extension is checked FIRST for a load-bearing reason: when it is OFF, `git
1609
+ * config --worktree` silently behaves like `--local` (verified), so reading it
1610
+ * here would double-count the LOCAL value and refuse a valid legacy migration.
1611
+ * It is a DISTINCT scope only when the extension is enabled, and only then does
1612
+ * `--worktree --get` read from `$GIT_DIR/config.worktree` WITHOUT falling back to
1613
+ * local. Gating on the extension also handles the multiple-worktree + extension-off
1614
+ * case where `--worktree` errors out ("not enabled"): we never issue that read, so
1615
+ * that specific error can never surface as a spurious refuse.
1616
+ *
1617
+ * Same fail-closed discipline as the scoped reads: exit 1/5 → genuine no value;
1618
+ * any OTHER failure → `failed` so the guard REFUSES rather than fail open.
1619
+ */
1620
+ async function readWorktreeHooksPath(projectDir) {
1621
+ // Determine whether the worktree config extension is enabled.
1622
+ let enabled = false;
1623
+ try {
1624
+ const { stdout } = await execFileAsync("git", ["config", "--bool", "--get", "extensions.worktreeConfig"], { cwd: projectDir });
1625
+ enabled = stdout.trim() === "true";
1626
+ }
1627
+ catch (e) {
1628
+ const code = e.code;
1629
+ if (code === 1 || code === 5) {
1630
+ // Extension unset/disabled → no DISTINCT worktree scope exists; the local
1631
+ // read (step 3) already covers the --worktree==--local fallback. Treat as
1632
+ // no worktree value, never a refuse.
1633
+ return { value: "" };
1634
+ }
1635
+ // Cannot determine the extension state → fail CLOSED.
1636
+ return { value: "", failed: e instanceof Error ? e.message : String(e) };
1637
+ }
1638
+ if (!enabled) {
1639
+ return { value: "" };
1640
+ }
1641
+ try {
1642
+ const { stdout } = await execFileAsync("git", ["config", "--worktree", "--get", "core.hooksPath"], { cwd: projectDir });
1643
+ return { value: stdout.trim() };
1644
+ }
1645
+ catch (e) {
1646
+ const code = e.code;
1647
+ if (code === 1 || code === 5) {
1648
+ return { value: "" };
1649
+ }
1650
+ return { value: "", failed: e instanceof Error ? e.message : String(e) };
1651
+ }
1652
+ }
1653
+ /**
1654
+ * The ATOMIC `core.hooksPath` guard (design D6). Runs BEFORE the install loop
1655
+ * mutates anything.
1656
+ *
1657
+ * DETECT-BEFORE-MUTATE — steps 1-4 are pure reads; only step 5 writes (unset
1658
+ * the legacy local value). EVERY refuse path returns `{ refuse }` having mutated
1659
+ * NOTHING, so the repo is left in its EXACT prior state and can never land
1660
+ * half-migrated (JDA-001 / JDA-008). The single write (`--local --unset`) and
1661
+ * the subsequent shim install happen ONLY on the fully-validated success/force
1662
+ * path.
1663
+ */
1664
+ async function guardHooksPath(projectDir, hooksDir, manifest, force) {
1665
+ // ── Step 1: classify the managed slots (pure read), exactly as the install
1666
+ // loop will. A manifest problem is NOT classified here — it surfaces
1667
+ // per-hook in the loop; an unclassifiable slot is treated as ABSENT (never
1668
+ // fabricate a foreign refusal from a manifest error).
1669
+ const foreignSlots = [];
1670
+ for (const name of HOOK_NAMES) {
1671
+ const entry = manifest[name];
1672
+ try {
1673
+ assertHookManifestEntry(entry, name);
1674
+ }
1675
+ catch {
1676
+ continue;
1677
+ }
1678
+ const state = await classifyHookPath(path.join(hooksDir, name), name, entry);
1679
+ if (state === HOOK_STATE.FOREIGN) {
1680
+ foreignSlots.push(name);
1681
+ }
1682
+ }
1683
+ // ── Step 2: DETECT a shadowing higher-scope hooksPath via SCOPED reads (ZERO
1684
+ // mutation). A global/system value shadows .git/hooks the moment git
1685
+ // consults hooksPath, so installing there would be INERT — refuse rather
1686
+ // than fail open. Detected WITHOUT unsetting local, so a
1687
+ // `local-unset + global-present` half-migration is impossible.
1688
+ for (const scope of ["--system", "--global"]) {
1689
+ const probe = await readScopedHooksPath(projectDir, scope);
1690
+ if (probe.failed !== undefined) {
1691
+ return {
1692
+ refuse: `could not read ${scope} git config core.hooksPath (${probe.failed}). Refusing to install: a higher-scope hooksPath could silently shadow .git/hooks and the hooks would not run. Resolve the git error and re-run.`,
1693
+ };
1694
+ }
1695
+ if (probe.value !== "") {
1696
+ const label = scope === "--global" ? "global" : "system";
1697
+ return {
1698
+ refuse: `a ${label} core.hooksPath='${probe.value}' would redirect git away from .git/hooks — the installed hooks would NOT run. Resolve it first (git config ${scope} --unset core.hooksPath) and re-run.`,
1699
+ };
1700
+ }
1701
+ }
1702
+ // ── Step 2b (JDA-002): a WORKTREE-scoped hooksPath (with
1703
+ // extensions.worktreeConfig enabled) ALSO shadows .git/hooks. Same
1704
+ // fail-closed handling: a non-1/5 read failure REFUSES rather than fail open.
1705
+ const worktree = await readWorktreeHooksPath(projectDir);
1706
+ if (worktree.failed !== undefined) {
1707
+ return {
1708
+ refuse: `could not read --worktree git config core.hooksPath (${worktree.failed}). Refusing to install: a worktree-scoped hooksPath could silently shadow .git/hooks and the hooks would not run. Resolve the git error and re-run.`,
1709
+ };
1710
+ }
1711
+ if (worktree.value !== "") {
1712
+ return {
1713
+ refuse: `a worktree core.hooksPath='${worktree.value}' would redirect git away from .git/hooks — the installed hooks would NOT run. Resolve it first (git config --worktree --unset core.hooksPath) and re-run.`,
1714
+ };
1715
+ }
1716
+ // Shadow reads now cover --global, --system and --worktree. The includeIf
1717
+ // residual edge remains: a hooksPath injected only through a
1718
+ // [includeIf "gitdir:…"] conditional include is NOT returned by
1719
+ // --global/--system/--worktree --get and would surface only in an effective
1720
+ // read — a documented residual edge, not covered by these scoped reads.
1721
+ // ── Step 3: the LOCAL value. A foreign local manager (husky/lefthook/custom)
1722
+ // owns this repo's hooks; never hijack it. --force does NOT override (force
1723
+ // is consent to lose a file, not to seize another manager's config). The read
1724
+ // fails CLOSED (JDA-001): a non-1/5 failure REFUSES rather than mistake an
1725
+ // unreadable local value for a fresh repo and install over a shadowing value.
1726
+ const localProbe = await readLocalHooksPath(projectDir);
1727
+ if (localProbe.failed !== undefined) {
1728
+ return {
1729
+ refuse: `could not read --local git config core.hooksPath (${localProbe.failed}). Refusing to install: a local hooksPath could silently shadow .git/hooks and the installed hooks would not run. Resolve the git error and re-run.`,
1730
+ };
1731
+ }
1732
+ const local = localProbe.value;
1733
+ if (local !== "" && local !== LEGACY_HOOKSPATH) {
1734
+ return {
1735
+ refuse: `core.hooksPath is set to '${local}' — another hook manager owns this repo's hooks. javi-forge refuses to install or change it. Unset it yourself (git config --local --unset core.hooksPath) if you want javi-forge hooks.`,
1736
+ };
1737
+ }
1738
+ const legacy = local === LEGACY_HOOKSPATH;
1739
+ // ── Step 4: installability, ONLY on the migration path. Unsetting the legacy
1740
+ // value ACTIVATES whatever sits in .git/hooks; if a slot is FOREIGN without
1741
+ // --force, migrating now would activate a dormant foreign hook → ATOMIC
1742
+ // REFUSE, leave core.hooksPath SET, install NOTHING. When there is no legacy
1743
+ // value to unset, foreign slots are handled per-hook by the install loop as
1744
+ // before — nothing is being activated.
1745
+ if (legacy && !force && foreignSlots.length > 0) {
1746
+ const slot = foreignSlots[0];
1747
+ return {
1748
+ refuse: `core.hooksPath=${LEGACY_HOOKSPATH} would be removed, but .git/hooks/${slot} holds a foreign hook (a prior 'tdd init' or hand-written hook). Migrating now would activate it. Resolve it (delete/back it up) or re-run with --force.`,
1749
+ };
1750
+ }
1751
+ // ── Step 5: ONLY NOW mutate. Every check passed (or --force). Unset the
1752
+ // legacy local value — the ONLY value javi-forge ever wrote, and only the
1753
+ // --local scope is ever touched — then let the caller install. The install
1754
+ // NEVER runs before steps 2 and 4.
1755
+ if (legacy) {
1756
+ await execFileAsync("git", ["config", "--local", "--unset", "core.hooksPath"], { cwd: projectDir });
1757
+ return {
1758
+ note: "legacy javi-forge hooksPath removed; hooks now live in .git/hooks",
1759
+ };
1760
+ }
1761
+ return {};
1762
+ }
1551
1763
  export async function installCIHooks(projectDir, options = {}) {
1552
1764
  const force = options.force === true;
1553
- const empty = { installed: [], upgraded: [], backups: [], states: [] };
1765
+ const empty = {
1766
+ installed: [],
1767
+ upgraded: [],
1768
+ backups: [],
1769
+ states: [],
1770
+ notes: [],
1771
+ };
1554
1772
  const gitDir = path.join(projectDir, ".git");
1555
1773
  if (!(await fs.pathExists(gitDir))) {
1556
1774
  return {
@@ -1587,6 +1805,19 @@ export async function installCIHooks(projectDir, options = {}) {
1587
1805
  const backups = [];
1588
1806
  const errors = [];
1589
1807
  const states = [];
1808
+ const notes = [];
1809
+ // The ATOMIC core.hooksPath guard (D6) is the SINGLE choke point shared by
1810
+ // `ci init` and `init`. It runs DETECT-BEFORE-MUTATE: on refuse it has
1811
+ // mutated nothing and we return without installing; on the success/force
1812
+ // path it has already unset any legacy `ci-local/hooks` value and we
1813
+ // proceed to install the shims.
1814
+ const guard = await guardHooksPath(projectDir, hooksDir, manifest, force);
1815
+ if ("refuse" in guard) {
1816
+ return { ...empty, errors: [guard.refuse] };
1817
+ }
1818
+ if (guard.note !== undefined) {
1819
+ notes.push(guard.note);
1820
+ }
1590
1821
  for (const name of HOOK_NAMES) {
1591
1822
  const hookPath = path.join(hooksDir, name);
1592
1823
  const entry = manifest[name];
@@ -1631,6 +1862,6 @@ export async function installCIHooks(projectDir, options = {}) {
1631
1862
  errors.push(`${name}: ${e instanceof Error ? e.message : String(e)}`);
1632
1863
  }
1633
1864
  }
1634
- return { installed, upgraded, backups, errors, states };
1865
+ return { installed, upgraded, backups, errors, states, notes };
1635
1866
  }
1636
1867
  //# sourceMappingURL=ci.js.map
@@ -11,15 +11,17 @@ import type { StepFn } from "../types.js";
11
11
  */
12
12
  export declare const stepGitInit: StepFn;
13
13
  /**
14
- * Step 2: Configure git hooks path.
14
+ * Step 2: Install managed git hooks (D7 reconciliation).
15
15
  *
16
- * - Copies templates/ci-local/ <project>/ci-local/ (no overwrite).
17
- * - chmod 0755 on hook files.
18
- * - Sets git config core.hooksPath to ci-local/hooks.
19
- * - Skips entirely when CI_LOCAL_DIR template is missing.
20
- * - Errors are swallowed and reported as status:"error" never thrown.
16
+ * Init no longer copies `ci-local/` hook bodies into the project nor flips
17
+ * `core.hooksPath` both were the pre-consolidation mechanism. It now delegates
18
+ * to `installCIHooks`, the SINGLE writer of `.git/hooks` and the choke point for
19
+ * the ATOMIC `core.hooksPath` guard (D6). A foreign/global hooksPath, or a
20
+ * dormant foreign slot, is REFUSED there with zero mutation and surfaced as an
21
+ * error here — init never overrides the user's hook manager.
21
22
  *
22
- * Extracted VERBATIM from src/commands/init.ts (PR 1 of 6).
23
+ * - dry-run: reports "would install managed hooks", calls nothing.
24
+ * - Errors are swallowed and reported as status:"error" — never thrown.
23
25
  */
24
26
  export declare const stepGitHooks: StepFn;
25
27
  //# sourceMappingURL=git.d.ts.map
@@ -1,6 +1,5 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
- import { CI_LOCAL_DIR } from "../../../constants.js";
4
3
  import { execFileAsync } from "../../../lib/exec.js";
5
4
  import { report } from "../report.js";
6
5
  /**
@@ -34,48 +33,46 @@ export const stepGitInit = async (ctx) => {
34
33
  }
35
34
  };
36
35
  /**
37
- * Step 2: Configure git hooks path.
36
+ * Step 2: Install managed git hooks (D7 reconciliation).
38
37
  *
39
- * - Copies templates/ci-local/ <project>/ci-local/ (no overwrite).
40
- * - chmod 0755 on hook files.
41
- * - Sets git config core.hooksPath to ci-local/hooks.
42
- * - Skips entirely when CI_LOCAL_DIR template is missing.
43
- * - Errors are swallowed and reported as status:"error" never thrown.
38
+ * Init no longer copies `ci-local/` hook bodies into the project nor flips
39
+ * `core.hooksPath` both were the pre-consolidation mechanism. It now delegates
40
+ * to `installCIHooks`, the SINGLE writer of `.git/hooks` and the choke point for
41
+ * the ATOMIC `core.hooksPath` guard (D6). A foreign/global hooksPath, or a
42
+ * dormant foreign slot, is REFUSED there with zero mutation and surfaced as an
43
+ * error here — init never overrides the user's hook manager.
44
44
  *
45
- * Extracted VERBATIM from src/commands/init.ts (PR 1 of 6).
45
+ * - dry-run: reports "would install managed hooks", calls nothing.
46
+ * - Errors are swallowed and reported as status:"error" — never thrown.
46
47
  */
47
48
  export const stepGitHooks = async (ctx) => {
48
49
  const { projectDir, dryRun, onStep } = ctx;
49
50
  const stepId = "git-hooks";
50
- report(onStep, stepId, "Configure git hooks path", "running");
51
+ const label = "Install git hooks";
52
+ report(onStep, stepId, label, "running");
53
+ if (dryRun) {
54
+ report(onStep, stepId, label, "done", "would install managed hooks");
55
+ return;
56
+ }
51
57
  try {
52
- const ciLocalSrc = CI_LOCAL_DIR;
53
- const ciLocalDest = path.join(projectDir, "ci-local");
54
- if (await fs.pathExists(ciLocalSrc)) {
55
- if (!dryRun) {
56
- await fs.copy(ciLocalSrc, ciLocalDest, {
57
- overwrite: false,
58
- errorOnExist: false,
59
- });
60
- // Set core.hooksPath to ci-local/hooks
61
- const hooksDir = path.join(ciLocalDest, "hooks");
62
- if (await fs.pathExists(hooksDir)) {
63
- // Ensure hooks are executable
64
- const hookFiles = await fs.readdir(hooksDir);
65
- for (const hook of hookFiles) {
66
- await fs.chmod(path.join(hooksDir, hook), 0o755);
67
- }
68
- await execFileAsync("git", ["config", "core.hooksPath", "ci-local/hooks"], { cwd: projectDir });
69
- }
70
- }
71
- report(onStep, stepId, "Configure git hooks path", "done", "ci-local/hooks");
72
- }
73
- else {
74
- report(onStep, stepId, "Configure git hooks path", "skipped", "no ci-local dir");
58
+ // Lazy import: installCIHooks pulls in the CI command surface; keep it off
59
+ // the init cold-start path, exactly like the `ci init` dispatch branch.
60
+ const { installCIHooks } = await import("../../ci.js");
61
+ const { installed, upgraded, backups, errors, notes } = await installCIHooks(projectDir);
62
+ if (errors.length > 0) {
63
+ report(onStep, stepId, label, "error", errors.join("; "));
64
+ return;
75
65
  }
66
+ const detail = [
67
+ ...installed.map((h) => `installed ${h}`),
68
+ ...upgraded.map((h) => `upgraded ${h}`),
69
+ ...notes,
70
+ ...backups.map((b) => `backup ${b}`),
71
+ ].join("; ") || "managed hooks installed";
72
+ report(onStep, stepId, label, "done", detail);
76
73
  }
77
74
  catch (e) {
78
- report(onStep, stepId, "Configure git hooks path", "error", String(e));
75
+ report(onStep, stepId, label, "error", String(e));
79
76
  }
80
77
  };
81
78
  //# sourceMappingURL=git.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.24.0",
3
+ "version": "1.25.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {