baldart 3.33.1 → 3.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +1 -1
- package/VERSION +1 -1
- package/framework/.claude/skills/worktree-manager/SKILL.md +26 -2
- package/package.json +1 -1
- package/src/commands/doctor.js +56 -0
- package/src/utils/__tests__/githooks-active.test.js +63 -0
- package/src/utils/githooks.js +233 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ All notable changes to BALDART will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [3.34.0] - 2026-05-30
|
|
9
|
+
|
|
10
|
+
Adds an **assert-active git-hooks health check** to `baldart doctor` and the `worktree-manager` verify step. A consumer had `core.hooksPath` pointing at `.git/hooks` (empty) while the repo's versioned hooks lived in `scripts/git-hooks/` — the pre-commit schema guard and pre-push were **silently inactive for days**. The existing worktree-manager verify (`git config core.hooksPath || true`) only *read* the value without asserting anything, so it never caught the drift. Same class as the LSP bug: the framework "verifies" by reading a proxy instead of asserting the real state. This moves it from read-only to assert-active. **WARN-only** — the check never mutates `core.hooksPath` (a wrong heuristic + auto-fix would be worse than the original bug); it surfaces the exact remediation command and lets the user apply it. **No new `baldart.config.yml` keys** — it's a universal, always-on, filesystem-autodetected check, so the schema-propagation rule does not apply.
|
|
11
|
+
|
|
12
|
+
### Added — git-hooks health check (assert-active)
|
|
13
|
+
|
|
14
|
+
- **[src/utils/githooks.js](src/utils/githooks.js)**: new, self-contained, fail-safe utility (distinct from `src/utils/hooks.js`, which manages `.claude/settings.json` hooks). `getStatus(cwd)` detects the repo's versioned-hooks directory (`.husky`, `.githooks`, `scripts/git-hooks`, `githooks` — first that exists and contains a standard-named hook), resolves the **active** hooks dir explicitly (`core.hooksPath` → relative resolved against `git rev-parse --show-toplevel`, absolute verbatim; unset → `<git-dir>/hooks`, handling the worktree `.git`-is-a-file case), and asserts the active dir serves the versioned hooks. **`git rev-parse --git-path hooks` is deliberately NOT used** — empirically it returns the configured value relative/unresolved and historically some git versions ignored `core.hooksPath` for it. Returns `checked:false` (silent, `ok:true`) when no managed hooks exist or on any error, so the check never produces a false WARN.
|
|
15
|
+
- **[src/utils/githooks.js](src/utils/githooks.js)**: pure exported `isActiveServing(activeAbs, expectedAbs)` — identical-or-nested decision (covers husky v9, which sets `core.hooksPath=.husky/_` with user hooks in `.husky/`). Paths are canonicalized (symlink-resolved, handling non-existent leaves) before comparison so a symlinked ancestor (macOS `/var`→`/private/var`, symlinked repo/home) never yields a false INACTIVE. Executability (`-x`) is asserted only for raw dirs git execs directly; husky user hooks (not required `+x` in v9) skip the assert.
|
|
16
|
+
- **[src/commands/doctor.js](src/commands/doctor.js)**: probe wired into `detectState` (`state.gitHooks`), a `Git hooks` line in `renderDiagnostic` (silent when `checked:false`), and an **advisory print-only** action in `planActions` (`githooks-health`) whose `run()` prints the WARN + fix command and **never mutates** `core.hooksPath` (safe under `--auto`).
|
|
17
|
+
- **[framework/.claude/skills/worktree-manager/SKILL.md](framework/.claude/skills/worktree-manager/SKILL.md)**: step 4 (isolation setup) replaces the silent `git config core.hooksPath || true` read with a lean **assert** — detects the versioned-hooks dir, resolves the active dir, and echoes a loud `⚠️ WARNING` + the `git config core.hooksPath <dir>` fix on mismatch (matches husky v9 `.husky/_` and `<dir>/*` as active). The worktree shares `core.hooksPath` via `.git/commondir`, so the same resolution applies.
|
|
18
|
+
|
|
19
|
+
### Tests
|
|
20
|
+
|
|
21
|
+
- **[src/utils/__tests__/githooks-active.test.js](src/utils/__tests__/githooks-active.test.js)**: new — 8 pure fixtures for `isActiveServing` (the mayo bug, husky v9 nesting, identical dirs, prefix-not-a-boundary, trailing-sep normalization).
|
|
22
|
+
- **[scripts/test-githooks.sh](scripts/test-githooks.sh)**: new — drives **real** git repos: misconfig (`hooksPath=.git/hooks`, hooks in `scripts/git-hooks`) → inactive + fix; correctly wired → ok; non-executable raw hook → ok=false + chmod fix; husky v9 → active, `-x` assert skipped; no managed hooks → `checked:false` (silent).
|
|
23
|
+
|
|
8
24
|
## [3.33.1] - 2026-05-30
|
|
9
25
|
|
|
10
26
|
Fixes a **false-positive in the LSP layer's verify step** that let `/lsp-bootstrap` (and `baldart configure` / `doctor`) report a language server as `verified` while every real LSP call failed with `Executable not found in $PATH` — a dead layer masked by `codebase-architect`'s silent Grep fallback. Root cause was a **resolution mismatch**: verify used `npx --no-install typescript-language-server --version` (resolves from `node_modules/.bin`), but the consumer — Claude Code's LSP tool — spawns the server **by name from `$PATH`**. The npm-dev install (`npm install --save-dev`) put the binary in `node_modules/.bin`, which is *not* on `$PATH`, so verify passed and spawn failed. The npm-managed adapters now install **globally** so the binary lands on `$PATH`, and verify checks `$PATH` reachability the *same way the consumer spawns it*. **No new `baldart.config.yml` keys** — reachability is computed, not configured, so the schema-propagation rule does not apply.
|
package/README.md
CHANGED
|
@@ -239,7 +239,7 @@ The `ui-expert` agent is upgraded from a generic baseline to a world-class UI/UX
|
|
|
239
239
|
|
|
240
240
|
### LSP Symbol Search Layer (new in v3.10.0)
|
|
241
241
|
|
|
242
|
-
When `features.has_lsp_layer: true`, `codebase-architect` and the code-exploration skills (`context-primer`, `bug`, `prd`, `new`, `simplify`) prefer LSP `find-references` / `go-to-definition` over Grep for identifier-shaped queries — the filtering happens **before** Claude reads files, so a common function name no longer dumps thousands of textual matches into context. Opt-in at `baldart configure`; BALDART installs the matching language servers (npm
|
|
242
|
+
When `features.has_lsp_layer: true`, `codebase-architect` and the code-exploration skills (`context-primer`, `bug`, `prd`, `new`, `simplify`) prefer LSP `find-references` / `go-to-definition` over Grep for identifier-shaped queries — the filtering happens **before** Claude reads files, so a common function name no longer dumps thousands of textual matches into context. Opt-in at `baldart configure`; BALDART installs the matching language servers (global npm install for TypeScript/Python — the binary must be on `$PATH` because Claude Code's LSP tool spawns it by name; system commands printed for Go/Rust/Ruby). Grep remains the fallback for free-text queries and degraded states. See [`framework/agents/code-search-protocol.md`](framework/agents/code-search-protocol.md).
|
|
243
243
|
|
|
244
244
|
### Commands
|
|
245
245
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.34.0
|
|
@@ -411,8 +411,32 @@ else
|
|
|
411
411
|
echo "PORT=$PORT" >> .env.local
|
|
412
412
|
fi
|
|
413
413
|
|
|
414
|
-
# 5.
|
|
415
|
-
|
|
414
|
+
# 5. ASSERT git hooks are ACTIVE — not just readable.
|
|
415
|
+
# The worktree shares core.hooksPath via .git/commondir, so the same
|
|
416
|
+
# resolution applies here as in the main checkout. Reading the value
|
|
417
|
+
# (the old `git config core.hooksPath || true`) is NOT enough: it never
|
|
418
|
+
# catches the failure mode where core.hooksPath points at an empty dir
|
|
419
|
+
# while the repo's versioned hooks live elsewhere (pre-commit / pre-push
|
|
420
|
+
# silently inactive). Detect the versioned-hooks dir and assert it is served.
|
|
421
|
+
HOOK_SRC=""
|
|
422
|
+
for d in .husky .githooks scripts/git-hooks githooks; do
|
|
423
|
+
if [ -d "$d" ] && ls "$d" 2>/dev/null | grep -qE '^(pre-commit|pre-push|commit-msg)$'; then
|
|
424
|
+
HOOK_SRC="$d"; break
|
|
425
|
+
fi
|
|
426
|
+
done
|
|
427
|
+
if [ -n "$HOOK_SRC" ]; then
|
|
428
|
+
ACTIVE="$(git config --get core.hooksPath || echo .git/hooks)"
|
|
429
|
+
# Lean literal-string match (HOOK_SRC is relative). Full path resolution +
|
|
430
|
+
# canonicalization lives in `baldart doctor` (src/utils/githooks.js); an
|
|
431
|
+
# absolute core.hooksPath here would warn spuriously — rare, run doctor to confirm.
|
|
432
|
+
case "$ACTIVE" in
|
|
433
|
+
"$HOOK_SRC"|"$HOOK_SRC"/*|.husky/_) : ;; # active dir serves the versioned hooks (incl. husky v9 .husky/_)
|
|
434
|
+
*)
|
|
435
|
+
echo "⚠️ WARNING: git hooks INATTIVI — gli hook versionati sono in '$HOOK_SRC' ma core.hooksPath = '$ACTIVE'." >&2
|
|
436
|
+
echo " Esegui per riattivarli: git config core.hooksPath $HOOK_SRC" >&2
|
|
437
|
+
;;
|
|
438
|
+
esac
|
|
439
|
+
fi
|
|
416
440
|
```
|
|
417
441
|
|
|
418
442
|
### 5. Verify baseline
|
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -30,6 +30,7 @@ const GitUtils = require('../utils/git');
|
|
|
30
30
|
const UI = require('../utils/ui');
|
|
31
31
|
const State = require('../utils/state');
|
|
32
32
|
const Hooks = require('../utils/hooks');
|
|
33
|
+
const GitHooks = require('../utils/githooks');
|
|
33
34
|
const LspInstaller = require('../utils/lsp-installer');
|
|
34
35
|
const UpdateNotifier = require('../utils/update-notifier');
|
|
35
36
|
const cliPackageJson = require('../../package.json');
|
|
@@ -234,6 +235,16 @@ async function detectState(cwd, opts = {}) {
|
|
|
234
235
|
}
|
|
235
236
|
} catch (_) {}
|
|
236
237
|
|
|
238
|
+
// ---- Git-hooks health (since v3.34.0) ------------------------------
|
|
239
|
+
// Assert that the repo's versioned git hooks (pre-commit/pre-push/…) are
|
|
240
|
+
// actually being run — i.e. `core.hooksPath` resolves to the directory
|
|
241
|
+
// that holds them. A drift here (e.g. core.hooksPath=.git/hooks while the
|
|
242
|
+
// hooks live in scripts/git-hooks/) silently disables every hook. Distinct
|
|
243
|
+
// from the BALDART `.claude/settings.json` hooks probed above. Fully
|
|
244
|
+
// fail-safe: `checked:false` when no managed hooks exist or on any error.
|
|
245
|
+
state.gitHooks = { checked: false, ok: true };
|
|
246
|
+
try { state.gitHooks = GitHooks.getStatus(cwd); } catch (_) {}
|
|
247
|
+
|
|
237
248
|
// ---- Agent symlink integrity (since v3.20.0) -----------------------
|
|
238
249
|
// Source of truth for the BALDART agent list is the framework filesystem
|
|
239
250
|
// directory itself (`.framework/framework/.claude/agents/*.md`). For each
|
|
@@ -503,6 +514,28 @@ function planActions(state) {
|
|
|
503
514
|
});
|
|
504
515
|
}
|
|
505
516
|
|
|
517
|
+
// Git-hooks health (since v3.34.0). ADVISORY ONLY — the action's run() never
|
|
518
|
+
// mutates `core.hooksPath` (a wrong heuristic + auto-fix is worse than the
|
|
519
|
+
// original silent-inactive bug). It surfaces the WARN + the exact remediation
|
|
520
|
+
// command so the user applies it deliberately. Present as an action (rather
|
|
521
|
+
// than a bare table WARN) so `doctor` doesn't contradict it with "Nothing to
|
|
522
|
+
// do". Safe to run in --auto since it only prints.
|
|
523
|
+
if (state.gitHooks && state.gitHooks.checked && !state.gitHooks.ok) {
|
|
524
|
+
actions.push({
|
|
525
|
+
key: 'githooks-health',
|
|
526
|
+
label: state.gitHooks.inactive
|
|
527
|
+
? `Git hooks INACTIVE — ${state.gitHooks.source} hooks in ${state.gitHooks.expectedDir} are not being run`
|
|
528
|
+
: `Git hooks not executable — ${state.gitHooks.nonExecutable.join(', ')}`,
|
|
529
|
+
why: `${state.gitHooks.detail}. Fix: ${state.gitHooks.fixCommand}`,
|
|
530
|
+
autoOk: true, // advisory print-only; run() does NOT mutate config
|
|
531
|
+
run: () => {
|
|
532
|
+
UI.warning(state.gitHooks.detail);
|
|
533
|
+
UI.info('Run to re-activate the repo\'s versioned git hooks:');
|
|
534
|
+
console.log(` ${state.gitHooks.fixCommand}`);
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
506
539
|
// Agent symlink integrity (since v3.20.0). Re-runs the agent merge loop to
|
|
507
540
|
// recreate any missing per-agent symlink. Calls `SymlinkUtils.mergeAgents`
|
|
508
541
|
// directly rather than dispatching to `update` — we want only the symlink
|
|
@@ -787,6 +820,29 @@ function renderDiagnostic(state) {
|
|
|
787
820
|
console.log(` • pre-v3.18: ${state.hooksLegacy.join(', ')}`);
|
|
788
821
|
}
|
|
789
822
|
|
|
823
|
+
// Git-hooks health (v3.34.0+). Silent when no versioned-hooks dir exists
|
|
824
|
+
// (`checked:false`) — most consumers have no managed git hooks and must see
|
|
825
|
+
// zero noise here.
|
|
826
|
+
if (state.gitHooks && state.gitHooks.checked) {
|
|
827
|
+
if (state.gitHooks.ok) {
|
|
828
|
+
console.log(statusLine(
|
|
829
|
+
'Git hooks',
|
|
830
|
+
`${state.gitHooks.source} active (${state.gitHooks.expectedHooks.join(', ')})`,
|
|
831
|
+
'ok'
|
|
832
|
+
));
|
|
833
|
+
} else if (state.gitHooks.inactive) {
|
|
834
|
+
console.log(statusLine(
|
|
835
|
+
'Git hooks',
|
|
836
|
+
`INACTIVE — ${state.gitHooks.source} hooks in ${state.gitHooks.expectedDir} not run by core.hooksPath`,
|
|
837
|
+
'warn'
|
|
838
|
+
));
|
|
839
|
+
console.log(` • fix: ${state.gitHooks.fixCommand}`);
|
|
840
|
+
} else {
|
|
841
|
+
console.log(statusLine('Git hooks', state.gitHooks.detail, 'warn'));
|
|
842
|
+
console.log(` • fix: ${state.gitHooks.fixCommand}`);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
790
846
|
if (state.frameworkIgnored && state.frameworkIgnored.ignored) {
|
|
791
847
|
console.log(statusLine(
|
|
792
848
|
'.gitignore',
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Fixture-driven test for the git-hooks active-vs-expected decision.
|
|
4
|
+
*
|
|
5
|
+
* Run: node src/utils/__tests__/githooks-active.test.js
|
|
6
|
+
* Exits 0 on success, 1 on any mismatch (CI-friendly).
|
|
7
|
+
*
|
|
8
|
+
* `isActiveServing` is the one decision that, if wrong, silently invalidates
|
|
9
|
+
* the entire git-hooks health check (src/utils/githooks.js). It is pure (path
|
|
10
|
+
* logic only) so it is fully unit-testable without a live repo — covering the
|
|
11
|
+
* husky-version branches that fs/git probing can't easily exercise.
|
|
12
|
+
*/
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { isActiveServing } = require('../githooks');
|
|
15
|
+
|
|
16
|
+
// Absolute roots so the fixtures don't depend on cwd. Normalize through
|
|
17
|
+
// path.resolve the same way the implementation does.
|
|
18
|
+
const ROOT = path.resolve('/repo');
|
|
19
|
+
const j = (...p) => path.join(ROOT, ...p);
|
|
20
|
+
|
|
21
|
+
const FIXTURES = [
|
|
22
|
+
// ─── the mayo bug: core.hooksPath=.git/hooks, hooks in scripts/git-hooks ──
|
|
23
|
+
{ active: j('.git', 'hooks'), expected: j('scripts', 'git-hooks'), want: false, note: 'mayo: inactive' },
|
|
24
|
+
|
|
25
|
+
// ─── husky v9: active=.husky/_ wrappers, user hooks in .husky ─────────────
|
|
26
|
+
{ active: j('.husky', '_'), expected: j('.husky'), want: true, note: 'husky v9 nested' },
|
|
27
|
+
// ─── husky v5-v8: core.hooksPath=.husky, hooks in .husky ──────────────────
|
|
28
|
+
{ active: j('.husky'), expected: j('.husky'), want: true, note: 'husky v8 identical' },
|
|
29
|
+
|
|
30
|
+
// ─── scripts convention, correctly wired ─────────────────────────────────
|
|
31
|
+
{ active: j('scripts', 'git-hooks'), expected: j('scripts', 'git-hooks'), want: true, note: 'scripts identical' },
|
|
32
|
+
|
|
33
|
+
// ─── .githooks convention, wired to default .git/hooks (inactive) ─────────
|
|
34
|
+
{ active: j('.git', 'hooks'), expected: j('.githooks'), want: false, note: '.githooks inactive' },
|
|
35
|
+
|
|
36
|
+
// ─── reverse nesting (expected within active) also counts as serving ──────
|
|
37
|
+
{ active: j('.husky'), expected: j('.husky', '_'), want: true, note: 'reverse nesting' },
|
|
38
|
+
|
|
39
|
+
// ─── sibling dirs sharing a prefix must NOT match (prefix-not-boundary) ───
|
|
40
|
+
{ active: j('hooks'), expected: j('hooks-extra'), want: false, note: 'prefix not a path boundary' },
|
|
41
|
+
|
|
42
|
+
// ─── trailing-slash / non-normalized inputs normalize equal ──────────────
|
|
43
|
+
{ active: j('.husky') + path.sep, expected: j('.husky'), want: true, note: 'trailing sep normalized' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
let failures = 0;
|
|
47
|
+
for (const f of FIXTURES) {
|
|
48
|
+
const got = isActiveServing(f.active, f.expected);
|
|
49
|
+
const ok = got === f.want;
|
|
50
|
+
if (!ok) {
|
|
51
|
+
failures++;
|
|
52
|
+
console.error(`✗ ${f.note}: isActiveServing(${f.active}, ${f.expected}) = ${got}, expected ${f.want}`);
|
|
53
|
+
} else {
|
|
54
|
+
console.log(`✓ ${f.note}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (failures) {
|
|
59
|
+
console.error(`\n${failures} failure(s).`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
console.log(`\nAll ${FIXTURES.length} fixtures passed.`);
|
|
63
|
+
process.exit(0);
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git-hooks health check — **assert-active**, not read-only.
|
|
3
|
+
*
|
|
4
|
+
* NOT to be confused with `src/utils/hooks.js`: that module manages the Claude
|
|
5
|
+
* Code hooks registered in `.claude/settings.json`. THIS module inspects the
|
|
6
|
+
* repo's own *git* hooks (pre-commit, pre-push, …) and asserts they are
|
|
7
|
+
* actually being executed by git — i.e. that `core.hooksPath` resolves to the
|
|
8
|
+
* directory where the repo's versioned hooks live.
|
|
9
|
+
*
|
|
10
|
+
* **Why it exists** (since v3.34.0)
|
|
11
|
+
*
|
|
12
|
+
* A consumer had `core.hooksPath` pointing at `.git/hooks` (empty) while the
|
|
13
|
+
* repo's real, versioned hooks lived in `scripts/git-hooks/`. Result: the
|
|
14
|
+
* pre-commit schema guard and pre-push were silently inactive for days, and
|
|
15
|
+
* the worktree-manager verify step (`git config core.hooksPath || true`) only
|
|
16
|
+
* *read* the value without asserting anything — so it never caught the drift.
|
|
17
|
+
* Same class as the LSP bug: the framework "verifies" by reading a proxy
|
|
18
|
+
* instead of asserting the real state. This moves it from read-only to
|
|
19
|
+
* assert-active.
|
|
20
|
+
*
|
|
21
|
+
* **Resolution of the active hooks dir** (the crux)
|
|
22
|
+
*
|
|
23
|
+
* `git rev-parse --git-path hooks` is NOT used: empirically it returns the
|
|
24
|
+
* configured value *relative and unresolved*, and historically some git
|
|
25
|
+
* versions ignored `core.hooksPath` for `--git-path` entirely. Instead we
|
|
26
|
+
* resolve explicitly:
|
|
27
|
+
* - `core.hooksPath` set → relative resolves against the working-tree
|
|
28
|
+
* top (`git rev-parse --show-toplevel`),
|
|
29
|
+
* absolute is used verbatim.
|
|
30
|
+
* - `core.hooksPath` unset → `<git rev-parse --git-dir>/hooks` (handles
|
|
31
|
+
* the worktree case where `.git` is a file).
|
|
32
|
+
*
|
|
33
|
+
* **Design invariants**
|
|
34
|
+
*
|
|
35
|
+
* - Fail-safe: every probe is wrapped; any internal error yields
|
|
36
|
+
* `{ checked: false, ok: true }` so the check NEVER blocks legitimate work
|
|
37
|
+
* and never produces a false WARN.
|
|
38
|
+
* - Silent when nothing to assert: a repo with no versioned-hooks directory
|
|
39
|
+
* returns `checked: false` (most consumers have no managed hooks).
|
|
40
|
+
* - No config flag: this is a universal, always-on health check auto-detected
|
|
41
|
+
* from the filesystem, so the schema-change-propagation rule does not apply.
|
|
42
|
+
* - WARN only: this module never mutates `core.hooksPath`. It reports a
|
|
43
|
+
* `fixCommand` string; applying it is the caller's (user's) decision.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const fs = require('fs');
|
|
47
|
+
const path = require('path');
|
|
48
|
+
const { execFileSync } = require('child_process');
|
|
49
|
+
|
|
50
|
+
// Standard git hook filenames. A managed-hooks directory is recognized by
|
|
51
|
+
// containing at least one of these (husky places them at `.husky/<name>`,
|
|
52
|
+
// raw dirs at `<dir>/<name>`).
|
|
53
|
+
const STANDARD_HOOKS = [
|
|
54
|
+
'pre-commit', 'pre-push', 'commit-msg', 'prepare-commit-msg',
|
|
55
|
+
'pre-rebase', 'pre-merge-commit', 'post-checkout', 'post-commit',
|
|
56
|
+
'post-merge', 'post-rewrite', 'pre-applypatch', 'applypatch-msg',
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// Candidate versioned-hooks directories, in priority order. The first that
|
|
60
|
+
// exists AND contains a standard-named hook wins. `scripts/git-hooks` and
|
|
61
|
+
// `githooks` are conventions (not universal) — we enumerate a small known set
|
|
62
|
+
// rather than guess.
|
|
63
|
+
const MANAGED_DIRS = [
|
|
64
|
+
{ dir: '.husky', source: 'husky' },
|
|
65
|
+
{ dir: '.githooks', source: 'githooks' },
|
|
66
|
+
{ dir: 'scripts/git-hooks', source: 'scripts' },
|
|
67
|
+
{ dir: 'githooks', source: 'githooks' },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
function git(cwd, args) {
|
|
71
|
+
return execFileSync('git', args, {
|
|
72
|
+
cwd,
|
|
73
|
+
encoding: 'utf8',
|
|
74
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
75
|
+
}).trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Find the repo's versioned-hooks directory, or null when none is managed.
|
|
80
|
+
* @returns {{ dir: string, abs: string, source: string, hooks: string[] } | null}
|
|
81
|
+
*/
|
|
82
|
+
function detectExpected(cwd) {
|
|
83
|
+
for (const cand of MANAGED_DIRS) {
|
|
84
|
+
const abs = path.join(cwd, cand.dir);
|
|
85
|
+
let names = [];
|
|
86
|
+
try {
|
|
87
|
+
if (!fs.statSync(abs).isDirectory()) continue;
|
|
88
|
+
names = fs.readdirSync(abs).filter((f) => STANDARD_HOOKS.includes(f));
|
|
89
|
+
} catch (_) { continue; }
|
|
90
|
+
if (names.length) return { dir: cand.dir, abs, source: cand.source, hooks: names };
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the directory git will actually look in for hooks.
|
|
97
|
+
* @returns {{ configured: string | null, abs: string }}
|
|
98
|
+
*/
|
|
99
|
+
function resolveActiveDir(cwd) {
|
|
100
|
+
let configured = null;
|
|
101
|
+
try { configured = git(cwd, ['config', '--get', 'core.hooksPath']) || null; } catch (_) {}
|
|
102
|
+
|
|
103
|
+
if (configured) {
|
|
104
|
+
if (path.isAbsolute(configured)) return { configured, abs: configured };
|
|
105
|
+
// Relative core.hooksPath resolves against the working-tree top, not cwd.
|
|
106
|
+
let top = cwd;
|
|
107
|
+
try { top = git(cwd, ['rev-parse', '--show-toplevel']); } catch (_) {}
|
|
108
|
+
return { configured, abs: path.resolve(top, configured) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Unset → <git-dir>/hooks. git-dir may be relative or a worktree gitdir.
|
|
112
|
+
let gitDir;
|
|
113
|
+
try { gitDir = git(cwd, ['rev-parse', '--git-dir']); } catch (_) { gitDir = path.join(cwd, '.git'); }
|
|
114
|
+
if (!path.isAbsolute(gitDir)) gitDir = path.resolve(cwd, gitDir);
|
|
115
|
+
return { configured: null, abs: path.join(gitDir, 'hooks') };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve a path to its canonical (symlink-free) form so two paths computed
|
|
120
|
+
* from different bases compare correctly. The active dir (from git's
|
|
121
|
+
* realpath-resolved toplevel/git-dir) and the expected dir (from cwd) can
|
|
122
|
+
* otherwise differ purely by a symlinked ancestor — e.g. macOS `/var` →
|
|
123
|
+
* `/private/var`, or a symlinked repo/home path — yielding a false INACTIVE.
|
|
124
|
+
*
|
|
125
|
+
* Handles non-existent leaves (the active `.git/hooks` may not exist on a
|
|
126
|
+
* misconfigured repo): realpath the deepest existing ancestor, re-append the
|
|
127
|
+
* rest. Falls back to a plain resolve if nothing along the path is realpath-able.
|
|
128
|
+
*/
|
|
129
|
+
function canonicalize(p) {
|
|
130
|
+
let dir = path.resolve(p);
|
|
131
|
+
const tail = [];
|
|
132
|
+
for (;;) {
|
|
133
|
+
try {
|
|
134
|
+
const real = fs.realpathSync(dir);
|
|
135
|
+
return tail.length ? path.join(real, ...tail) : real;
|
|
136
|
+
} catch (_) {
|
|
137
|
+
const parent = path.dirname(dir);
|
|
138
|
+
if (parent === dir) return path.resolve(p); // reached fs root
|
|
139
|
+
tail.unshift(path.basename(dir));
|
|
140
|
+
dir = parent;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* PURE decision: is the active hooks dir serving the versioned hooks?
|
|
147
|
+
*
|
|
148
|
+
* True when the two dirs are identical OR one is nested within the other.
|
|
149
|
+
* The nesting case covers husky v9, which sets `core.hooksPath=.husky/_`
|
|
150
|
+
* (wrappers) while the user hooks live in `.husky/`. Exported for unit tests —
|
|
151
|
+
* this is the one decision that, if wrong, silently invalidates the whole check.
|
|
152
|
+
*/
|
|
153
|
+
function isActiveServing(activeAbs, expectedAbs) {
|
|
154
|
+
const a = path.resolve(activeAbs);
|
|
155
|
+
const e = path.resolve(expectedAbs);
|
|
156
|
+
if (a === e) return true;
|
|
157
|
+
return a.startsWith(e + path.sep) || e.startsWith(a + path.sep);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Inspect the repo's git-hooks health. Fail-safe.
|
|
162
|
+
* @returns {{
|
|
163
|
+
* checked: boolean, ok: boolean, source: string|null, configured: string|null,
|
|
164
|
+
* activeDir: string|null, expectedDir: string|null, expectedHooks: string[],
|
|
165
|
+
* inactive: boolean, nonExecutable: string[], detail: string, fixCommand: string|null
|
|
166
|
+
* }}
|
|
167
|
+
*/
|
|
168
|
+
function getStatus(cwd = process.cwd()) {
|
|
169
|
+
const result = {
|
|
170
|
+
checked: false,
|
|
171
|
+
ok: true,
|
|
172
|
+
source: null,
|
|
173
|
+
configured: null,
|
|
174
|
+
activeDir: null,
|
|
175
|
+
expectedDir: null,
|
|
176
|
+
expectedHooks: [],
|
|
177
|
+
inactive: false,
|
|
178
|
+
nonExecutable: [],
|
|
179
|
+
detail: '',
|
|
180
|
+
fixCommand: null,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const expected = detectExpected(cwd);
|
|
185
|
+
if (!expected) return result; // no managed hooks → silent, ok
|
|
186
|
+
|
|
187
|
+
const active = resolveActiveDir(cwd);
|
|
188
|
+
result.checked = true;
|
|
189
|
+
result.source = expected.source;
|
|
190
|
+
result.configured = active.configured;
|
|
191
|
+
result.activeDir = active.abs;
|
|
192
|
+
result.expectedDir = expected.dir;
|
|
193
|
+
result.expectedHooks = expected.hooks;
|
|
194
|
+
|
|
195
|
+
// Canonicalize both ends before comparing — they're computed from different
|
|
196
|
+
// bases (git realpath vs cwd) and a symlinked ancestor would otherwise
|
|
197
|
+
// produce a false INACTIVE.
|
|
198
|
+
if (!isActiveServing(canonicalize(active.abs), canonicalize(expected.abs))) {
|
|
199
|
+
result.inactive = true;
|
|
200
|
+
result.ok = false;
|
|
201
|
+
result.fixCommand = `git config core.hooksPath ${expected.dir}`;
|
|
202
|
+
result.detail = `core.hooksPath risolve a ${active.abs} ma gli hook versionati sono in ${expected.dir}`;
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Executability assert — only for raw dirs git execs directly. Husky sources
|
|
207
|
+
// user hooks via its `_` wrappers and v9 does NOT require them to be +x, so
|
|
208
|
+
// asserting -x there would manufacture false positives.
|
|
209
|
+
if (expected.source !== 'husky') {
|
|
210
|
+
for (const h of expected.hooks) {
|
|
211
|
+
try { fs.accessSync(path.join(expected.abs, h), fs.constants.X_OK); }
|
|
212
|
+
catch (_) { result.nonExecutable.push(h); }
|
|
213
|
+
}
|
|
214
|
+
if (result.nonExecutable.length) {
|
|
215
|
+
result.ok = false;
|
|
216
|
+
result.detail = `hook non eseguibili (manca il bit -x): ${result.nonExecutable.join(', ')}`;
|
|
217
|
+
result.fixCommand = `chmod +x ${result.nonExecutable.map((h) => path.join(expected.dir, h)).join(' ')}`;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return result;
|
|
222
|
+
} catch (_) {
|
|
223
|
+
// Never block doctor / worktree verify on a probe failure.
|
|
224
|
+
return { ...result, checked: false, ok: true };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = { getStatus, isActiveServing };
|
|
229
|
+
// Exposed for completeness / advanced callers and unit tests.
|
|
230
|
+
module.exports.detectExpected = detectExpected;
|
|
231
|
+
module.exports.resolveActiveDir = resolveActiveDir;
|
|
232
|
+
module.exports.STANDARD_HOOKS = STANDARD_HOOKS;
|
|
233
|
+
module.exports.MANAGED_DIRS = MANAGED_DIRS;
|