@davesheffer/hunch 1.39.2 → 1.40.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/dist/cli/index.js +154 -64
- package/dist/cli/serve.d.ts +14 -0
- package/dist/cli/serve.js +18 -4
- package/dist/constitution/g2BehaviorCandidates.js +6 -1
- package/dist/constitution/g2Candidates.js +11 -1
- package/dist/constitution/structural.js +10 -0
- package/dist/core/correction.js +12 -7
- package/dist/core/delivery.d.ts +34 -0
- package/dist/core/delivery.js +60 -1
- package/dist/core/docanchors.js +195 -11
- package/dist/core/format.js +6 -1
- package/dist/core/glob.d.ts +25 -0
- package/dist/core/glob.js +43 -0
- package/dist/core/hookcache.d.ts +9 -2
- package/dist/core/hookcache.js +10 -3
- package/dist/core/paths.d.ts +48 -5
- package/dist/core/paths.js +89 -6
- package/dist/core/procstart.d.ts +94 -0
- package/dist/core/procstart.js +244 -0
- package/dist/core/served.d.ts +6 -3
- package/dist/core/served.js +10 -6
- package/dist/core/taskDelivery.js +27 -1
- package/dist/core/taskReport.d.ts +2 -0
- package/dist/core/taskReport.js +46 -11
- package/dist/core/taskReportEvidence.js +17 -1
- package/dist/core/taskReportHook.d.ts +19 -0
- package/dist/core/taskReportHook.js +54 -6
- package/dist/core/verifyLauncher.d.ts +21 -0
- package/dist/core/verifyLauncher.js +37 -0
- package/dist/core/workspace.d.ts +9 -0
- package/dist/core/workspace.js +15 -0
- package/dist/extractors/git.d.ts +5 -0
- package/dist/extractors/git.js +87 -10
- package/dist/extractors/indexer.d.ts +10 -0
- package/dist/extractors/indexer.js +56 -10
- package/dist/extractors/k8sManifest.d.ts +13 -0
- package/dist/extractors/k8sManifest.js +207 -23
- package/dist/extractors/landscapeDiscovery.js +9 -1
- package/dist/extractors/nativeTreeSitter.d.ts +24 -0
- package/dist/extractors/nativeTreeSitter.js +54 -1
- package/dist/extractors/parse.js +6 -1
- package/dist/integrations/claudemd.js +3 -3
- package/dist/integrations/hookmatch.d.ts +25 -0
- package/dist/integrations/hookmatch.js +58 -0
- package/dist/integrations/hooks.d.ts +79 -9
- package/dist/integrations/hooks.js +559 -46
- package/dist/integrations/providers.js +2 -17
- package/dist/integrations/scaffold.d.ts +1 -1
- package/dist/integrations/scaffold.js +24 -21
- package/dist/integrations/workspaceLedger.d.ts +10 -0
- package/dist/integrations/workspaceLedger.js +9 -1
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/server.js +32 -16
- package/dist/mcp/taskReportTools.d.ts +5 -9
- package/dist/mcp/taskReportTools.js +9 -20
- package/dist/serve/writelock.d.ts +21 -1
- package/dist/serve/writelock.js +157 -16
- package/dist/store/changeLedger.d.ts +9 -3
- package/dist/store/changeLedger.js +36 -10
- package/dist/store/hunchStore.d.ts +42 -2
- package/dist/store/hunchStore.js +105 -17
- package/dist/store/jsonStore.d.ts +6 -4
- package/dist/store/jsonStore.js +225 -32
- package/dist/store/merge.d.ts +7 -4
- package/dist/store/merge.js +65 -5
- package/dist/store/privateMigrate.js +10 -0
- package/dist/wiki/adopt.js +13 -2
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -16,20 +16,26 @@
|
|
|
16
16
|
import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, realpathSync } from "node:fs";
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
18
|
import { join, isAbsolute, dirname, basename, relative, resolve } from "node:path";
|
|
19
|
-
import {
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { hooksDir, gitCommonDir, isLinkedWorktree } from "../extractors/git.js";
|
|
20
21
|
import { initiatorChildEnv } from "../synthesis/initiator.js";
|
|
21
22
|
import { HUNCH_NPX_PACKAGE_SPEC } from "../core/version.js";
|
|
22
23
|
const MARK = "# >>> hunch post-commit >>>";
|
|
23
24
|
const ENDMARK = "# <<< hunch post-commit <<<";
|
|
24
25
|
const GIT_CONTEXT = { checkoutType: "$3" };
|
|
25
26
|
const PRE_COMMIT_CONTEXT = { checkoutType: "$PRE_COMMIT_CHECKOUT_TYPE" };
|
|
26
|
-
|
|
27
|
+
const LOCAL_ONLY_LINE = " export HUNCH_SYNTH_PROVIDER=deterministic";
|
|
28
|
+
// Tolerates hand-added quotes: missing the line would let a re-run delete it.
|
|
29
|
+
const LOCAL_ONLY_RE = /^\s*export HUNCH_SYNTH_PROVIDER=["']?deterministic["']?\s*$/m;
|
|
30
|
+
/** How the provider line is named among a result's kept/added options. */
|
|
31
|
+
const LOCAL_ONLY_LABEL = "HUNCH_SYNTH_PROVIDER=deterministic";
|
|
32
|
+
function block(invocation, opts = {}, keep) {
|
|
27
33
|
// --private routes the auto-synthesized decision into the HUNCH_PRIVATE_DIR overlay
|
|
28
34
|
// instead of the public repo. --commit (opt-in) also commits & pushes the repo the
|
|
29
35
|
// decision landed in (the private store under --private, else this repo). The hook
|
|
30
36
|
// script is local (.git/hooks/), never committed.
|
|
31
|
-
const priv = opts.private ? " --private" : "";
|
|
32
|
-
const commit = opts.commit ? " --commit" : "";
|
|
37
|
+
const priv = opts.private || keep?.flags.includes("--private") ? " --private" : "";
|
|
38
|
+
const commit = opts.commit || keep?.flags.includes("--commit") ? " --commit" : "";
|
|
33
39
|
return [
|
|
34
40
|
MARK,
|
|
35
41
|
'if [ -z "$HUNCH_SYNC" ]; then',
|
|
@@ -37,7 +43,7 @@ function block(invocation, opts = {}) {
|
|
|
37
43
|
// A split-private capture must not make a storage-private promise and then
|
|
38
44
|
// ship the commit diff to a subscription CLI. Shared overlays are a separate
|
|
39
45
|
// team policy, so only the explicit local-only mode forces deterministic.
|
|
40
|
-
...(opts.localOnly ? [
|
|
46
|
+
...(opts.localOnly || keep?.localOnly ? [LOCAL_ONLY_LINE] : []),
|
|
41
47
|
` ( ${invocation} sync --from-hook --quiet${priv}${commit} >/dev/null 2>&1 || true ) &`,
|
|
42
48
|
// Deliberately NO workspace-ledger snapshot here (docs/workspace-ledger.md): a commit
|
|
43
49
|
// changes HEAD, not which branches and worktrees exist — post-checkout covers that, and
|
|
@@ -213,29 +219,257 @@ function preCommitId(mark) {
|
|
|
213
219
|
[CHECKOUT_MARK]: "hunch-post-checkout",
|
|
214
220
|
}[mark] ?? "hunch";
|
|
215
221
|
}
|
|
216
|
-
|
|
217
|
-
|
|
222
|
+
const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=\S*$/;
|
|
223
|
+
const UNQUOTED_META = /[;|&$`<>()]/;
|
|
224
|
+
const LAUNCHER_EXT = /\.(?:exe|cmd|bat|ps1)$/;
|
|
225
|
+
const HUNCH_ENTRY = /\/cli\/index\.(?:js|ts)$/;
|
|
226
|
+
/** Split an invocation into simple shell words. Hunch writes its own paths with
|
|
227
|
+
* JSON.stringify, so a double-quoted token decodes with JSON.parse. Anything a
|
|
228
|
+
* shell would treat as more than a plain command (metacharacters, expansions,
|
|
229
|
+
* an unterminated quote) yields null — we refuse to guess. */
|
|
230
|
+
function shellWords(s) {
|
|
231
|
+
const out = [];
|
|
232
|
+
let i = 0;
|
|
233
|
+
while (i < s.length) {
|
|
234
|
+
const c = s[i];
|
|
235
|
+
if (/\s/.test(c)) {
|
|
236
|
+
i++;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (c === '"' || c === "'") {
|
|
240
|
+
const end = s.indexOf(c, i + 1);
|
|
241
|
+
if (end < 0)
|
|
242
|
+
return null;
|
|
243
|
+
const raw = s.slice(i, end + 1);
|
|
244
|
+
if (c === '"') {
|
|
245
|
+
if (/[$`]/.test(raw))
|
|
246
|
+
return null;
|
|
247
|
+
try {
|
|
248
|
+
out.push(JSON.parse(raw));
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else
|
|
255
|
+
out.push(raw.slice(1, -1));
|
|
256
|
+
i = end + 1;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
let j = i;
|
|
260
|
+
while (j < s.length && !/\s/.test(s[j]))
|
|
261
|
+
j++;
|
|
262
|
+
const word = s.slice(i, j);
|
|
263
|
+
if (UNQUOTED_META.test(word))
|
|
264
|
+
return null;
|
|
265
|
+
out.push(word);
|
|
266
|
+
i = j;
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
/** The launcher's own name: last path segment, lower-cased, without a Windows
|
|
271
|
+
* executable extension — so `/usr/local/bin/hunch`, `hunch.cmd` and `hunch` all match. */
|
|
272
|
+
function base(tok) {
|
|
273
|
+
return (tok.split(/[\\/]/).at(-1) ?? "").toLowerCase().replace(LAUNCHER_EXT, "");
|
|
274
|
+
}
|
|
275
|
+
function isAbs(tok) {
|
|
276
|
+
return isAbsolute(tok) || /^[A-Za-z]:[\\/]/.test(tok);
|
|
277
|
+
}
|
|
278
|
+
function isPathish(tok) {
|
|
279
|
+
return tok.includes("/") || tok.includes("\\") || isAbs(tok);
|
|
280
|
+
}
|
|
281
|
+
function pathExists(tok, root) {
|
|
282
|
+
// A hand-written hook may spell this machine's install as `~/…`; the shell
|
|
283
|
+
// expands it, so we must too before asking the filesystem.
|
|
284
|
+
const path = tok === "~" ? homedir() : tok.startsWith("~/") ? join(homedir(), tok.slice(2)) : tok;
|
|
285
|
+
return existsSync(isAbs(path) ? path : resolve(root, path));
|
|
286
|
+
}
|
|
287
|
+
const NOT_OURS = "not a command Hunch writes";
|
|
288
|
+
/** Whether the command in front of a managed block's subcommand is a Hunch
|
|
289
|
+
* launcher that could actually run (issue #315): a block whose command was
|
|
290
|
+
* hand-edited into something else, or whose absolute node/CLI path no longer
|
|
291
|
+
* exists after a reinstall, is present but dead. `command` is the text BEFORE
|
|
292
|
+
* the subcommand; env assignments and a leading `(` belong to the block's own
|
|
293
|
+
* shape and are stripped. Never spawns anything, never touches the network. */
|
|
294
|
+
export function hookInvocationHealth(command, root) {
|
|
295
|
+
let rest = command.trim().replace(/^\(\s*/, "");
|
|
296
|
+
for (;;) {
|
|
297
|
+
const m = /^(\S+)\s+/.exec(rest);
|
|
298
|
+
if (!m || !ENV_ASSIGN.test(m[1]))
|
|
299
|
+
break;
|
|
300
|
+
rest = rest.slice(m[0].length);
|
|
301
|
+
}
|
|
302
|
+
const invocation = rest.trim();
|
|
303
|
+
const bad = (reason) => ({ ok: false, invocation, reason });
|
|
304
|
+
const toks = shellWords(invocation);
|
|
305
|
+
if (toks === null || toks.length === 0)
|
|
306
|
+
return bad(NOT_OURS);
|
|
307
|
+
const [head, ...rest2] = toks;
|
|
308
|
+
const missing = (tok) => `${tok} does not exist`;
|
|
309
|
+
// `<runtime> <script…>`: every non-flag token after the runtime is a script
|
|
310
|
+
// path (`<node> <cli>`, or tsx's `<node> <tsx> <cli>`) — a runtime flag like
|
|
311
|
+
// `--no-warnings` is not a path, but a runtime with no script at all is not ours.
|
|
312
|
+
const runtime = () => {
|
|
313
|
+
if (isPathish(head) && !pathExists(head, root))
|
|
314
|
+
return bad(missing(head));
|
|
315
|
+
const scripts = rest2.filter((t) => !t.startsWith("-"));
|
|
316
|
+
if (scripts.length === 0)
|
|
317
|
+
return bad(NOT_OURS);
|
|
318
|
+
const gone = scripts.find((t) => !pathExists(t, root));
|
|
319
|
+
return gone === undefined ? { ok: true, invocation } : bad(missing(gone));
|
|
320
|
+
};
|
|
321
|
+
switch (base(head)) {
|
|
322
|
+
case "hunch":
|
|
323
|
+
if (rest2.length > 0)
|
|
324
|
+
return bad(NOT_OURS);
|
|
325
|
+
if (isPathish(head) && !pathExists(head, root))
|
|
326
|
+
return bad(missing(head));
|
|
327
|
+
return { ok: true, invocation };
|
|
328
|
+
case "node":
|
|
329
|
+
return rest2.length === 0 ? bad(NOT_OURS) : runtime();
|
|
330
|
+
case "npx":
|
|
331
|
+
case "pnpm":
|
|
332
|
+
case "yarn":
|
|
333
|
+
case "bunx":
|
|
334
|
+
case "npm":
|
|
335
|
+
case "env": {
|
|
336
|
+
// A package runner in front of the launcher: `npx -y --package=…@x.y.z hunch`
|
|
337
|
+
// (PORTABLE_HOOK_INVOCATION), `npx hunch`, and the shapes users adapt the
|
|
338
|
+
// snippet into by hand (`pnpm exec hunch`, `yarn run hunch`, `npm exec --
|
|
339
|
+
// hunch`, `env X=1 hunch`). What the runner would resolve is never verified —
|
|
340
|
+
// doing so would mean a network install — so only the launcher it ends in is read.
|
|
341
|
+
const args = rest2.filter((t) => !t.startsWith("-"));
|
|
342
|
+
const last = args.at(-1);
|
|
343
|
+
if (last !== undefined && (base(last) === "hunch" || /^@davesheffer\/hunch(?:@\S+)?$/.test(last)))
|
|
344
|
+
return { ok: true, invocation };
|
|
345
|
+
const tsx = args.indexOf("tsx");
|
|
346
|
+
if (tsx >= 0 && args.length === tsx + 2) {
|
|
347
|
+
return pathExists(args[tsx + 1], root) ? { ok: true, invocation } : bad(missing(args[tsx + 1]));
|
|
348
|
+
}
|
|
349
|
+
return bad(NOT_OURS);
|
|
350
|
+
}
|
|
351
|
+
default:
|
|
352
|
+
// `resolveInvocation` writes process.execPath, which is not always named
|
|
353
|
+
// `node` (Debian's `nodejs`, a version-suffixed binary). Any other runtime
|
|
354
|
+
// is ours only as an absolute path running Hunch's own CLI entry — else
|
|
355
|
+
// `hunch init` would rewrite the same block and doctor would call it stale forever.
|
|
356
|
+
return isAbs(head) && rest2.length > 0 && HUNCH_ENTRY.test(rest2.at(-1).replace(/\\/g, "/")) ? runtime() : bad(NOT_OURS);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/** The command each managed block runs, keyed by mark. Resolved inside a
|
|
360
|
+
* function (like `preCommitId`) because the marks are declared further down. */
|
|
361
|
+
function blockSubcommand(mark) {
|
|
362
|
+
return {
|
|
363
|
+
[MARK]: { re: /(?:^|\s)sync\s+--from-hook\b/, words: "sync --from-hook" },
|
|
364
|
+
[PRE_MARK]: { re: /(?:^|\s)check\s+--staged\b/, words: "check --staged" },
|
|
365
|
+
[GROUNDING_MERGE_MARK]: { re: /(?:^|\s)grounding\s+--refresh\b/, words: "grounding --refresh" },
|
|
366
|
+
[REPAIR_MERGE_MARK]: { re: /(?:^|\s)repair-provenance\b/, words: "repair-provenance" },
|
|
367
|
+
[CHECKOUT_MARK]: { re: /(?:^|\s)workspaces\s+snapshot\b/, words: "workspaces snapshot" },
|
|
368
|
+
}[mark] ?? { re: /(?:^|\s)hunch\b/, words: "hunch" };
|
|
369
|
+
}
|
|
370
|
+
/** Hunch's OWN option flags a block's command line may carry after the
|
|
371
|
+
* subcommand — the ones `hunch init` decides from its options, so a regenerated
|
|
372
|
+
* block silently drops any the re-run does not ask for again. Fixed set, in a
|
|
373
|
+
* stable order; anything else on the line is not ours to report. */
|
|
374
|
+
const BLOCK_FLAGS = ["--private", "--commit", "--strict"];
|
|
375
|
+
function blockFlags(line) {
|
|
376
|
+
return BLOCK_FLAGS.filter((f) => new RegExp(`(?:^|\\s)${escapeRe(f)}(?=\\s|$)`).test(line));
|
|
377
|
+
}
|
|
378
|
+
/** The flags a freshly BUILT block carries, read the way `inspectBlock` reads an
|
|
379
|
+
* installed one: per command line, only from the text after the subcommand. A
|
|
380
|
+
* whole-block scan would count a flag that is merely part of the launcher path
|
|
381
|
+
* (a Hunch installed under `…/opt --strict x/…`), and so miss a real downgrade.
|
|
382
|
+
*
|
|
383
|
+
* With `liveRoot`, only lines whose launcher works are read — for an INSTALLED
|
|
384
|
+
* block's text, where a dead hand-kept line's `--commit` was never in effect
|
|
385
|
+
* and carrying it into a rebuild would switch it on. */
|
|
386
|
+
function builtFlags(blk, mark, liveRoot) {
|
|
387
|
+
const { re } = blockSubcommand(mark);
|
|
388
|
+
const found = new Set();
|
|
389
|
+
for (const line of blk.replace(/\r/g, "").split("\n")) {
|
|
390
|
+
if (line.trim().startsWith("#"))
|
|
391
|
+
continue;
|
|
392
|
+
const m = re.exec(line);
|
|
393
|
+
if (!m)
|
|
394
|
+
continue;
|
|
395
|
+
if (liveRoot !== undefined && !hookInvocationHealth(line.slice(0, m.index), liveRoot).ok)
|
|
396
|
+
continue;
|
|
397
|
+
for (const f of blockFlags(line.slice(m.index)))
|
|
398
|
+
found.add(f);
|
|
399
|
+
}
|
|
400
|
+
return BLOCK_FLAGS.filter((f) => found.has(f));
|
|
401
|
+
}
|
|
402
|
+
/** The text of `mark`'s block in `cur` (which must contain `mark`): through its
|
|
403
|
+
* closing marker, or — when that is missing — up to the next Hunch marker line,
|
|
404
|
+
* so another block's command further down is never read as this block's. */
|
|
405
|
+
function ownBlockText(cur, mark, blockRe) {
|
|
406
|
+
const m = blockRe.exec(cur);
|
|
407
|
+
if (m)
|
|
408
|
+
return m[0];
|
|
409
|
+
const lines = cur.slice(cur.indexOf(mark)).split("\n");
|
|
410
|
+
const stop = lines.findIndex((l, i) => i > 0 && /^# (?:<<<|>>>) hunch /.test(l));
|
|
411
|
+
return (stop < 0 ? lines : lines.slice(0, stop)).join("\n");
|
|
412
|
+
}
|
|
413
|
+
/** Inspect the command inside `mark`'s block in `file`: the block body is the
|
|
414
|
+
* lines between the mark and the closing marker. A block may legitimately carry
|
|
415
|
+
* several command lines (or a hand-added one); any working line makes it live. */
|
|
416
|
+
function inspectBlock(file, mark, root) {
|
|
417
|
+
const { re, words } = blockSubcommand(mark);
|
|
418
|
+
const text = readText(file) ?? "";
|
|
419
|
+
const at = text.indexOf(mark);
|
|
420
|
+
const body = at < 0 ? [] : text.slice(at).replace(/\r/g, "").split("\n").slice(1);
|
|
421
|
+
let first = null;
|
|
422
|
+
for (const line of body) {
|
|
423
|
+
if (/^# <<< hunch /.test(line))
|
|
424
|
+
break;
|
|
425
|
+
if (line.trim().startsWith("#"))
|
|
426
|
+
continue;
|
|
427
|
+
const m = re.exec(line);
|
|
428
|
+
if (!m)
|
|
429
|
+
continue;
|
|
430
|
+
// Flags belong to the SUBCOMMAND, so they are read from the text after it.
|
|
431
|
+
const health = { ...hookInvocationHealth(line.slice(0, m.index), root), flags: blockFlags(line.slice(m.index)) };
|
|
432
|
+
if (health.ok)
|
|
433
|
+
return health;
|
|
434
|
+
first ??= health;
|
|
435
|
+
}
|
|
436
|
+
return first ?? { ok: false, invocation: "", reason: `the block has no \`hunch ${words}\` command`, flags: [] };
|
|
437
|
+
}
|
|
438
|
+
/** Whether our block for `mark` is present where git will actually run it AND
|
|
439
|
+
* its command is a Hunch launcher that exists (issue #315 — a marker alone
|
|
440
|
+
* proves nothing about what the block does). */
|
|
441
|
+
function blockInfo(t, mark, root) {
|
|
218
442
|
const inHook = markerState(t.hookPath, mark);
|
|
443
|
+
// A reachable marker only earns "installed" once its command checks out.
|
|
444
|
+
const live = (file) => {
|
|
445
|
+
const health = inspectBlock(file, mark, root);
|
|
446
|
+
return health.ok
|
|
447
|
+
? { state: "installed", invocation: health.invocation, flags: health.flags, file }
|
|
448
|
+
: { state: "stale", invocation: health.invocation, staleReason: health.reason, flags: health.flags, file };
|
|
449
|
+
};
|
|
219
450
|
switch (t.manager) {
|
|
220
451
|
case "none":
|
|
221
|
-
return inHook === "reachable" ?
|
|
452
|
+
return inHook === "reachable" ? live(t.hookPath) : { state: inHook === "unreachable" ? "unreachable" : "missing", file: t.hookPath };
|
|
222
453
|
case "husky":
|
|
223
454
|
if (markerState(t.ownerFile, mark) === "reachable")
|
|
224
|
-
return
|
|
455
|
+
return live(t.ownerFile);
|
|
225
456
|
if (markerState(t.ownerFile, mark) === "unreachable" || inHook !== "absent")
|
|
226
|
-
return "unreachable";
|
|
227
|
-
return "missing";
|
|
457
|
+
return { state: "unreachable", file: t.hookPath };
|
|
458
|
+
return { state: "missing", file: t.ownerFile };
|
|
228
459
|
case "pre-commit": {
|
|
229
460
|
const config = readText(t.ownerFile);
|
|
461
|
+
// The framework's own config carries the command in its own format; the
|
|
462
|
+
// `id:` is the contract, so it is taken at face value.
|
|
230
463
|
if (config !== null && new RegExp(`\\bid:\\s*["']?${escapeRe(preCommitId(mark))}["']?\\s*$`, "m").test(config))
|
|
231
|
-
return "installed";
|
|
464
|
+
return { state: "installed", file: t.ownerFile };
|
|
232
465
|
// Migration mode: `pre-commit install` moved the previous hook to <hook>.legacy and runs it first.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
466
|
+
const legacy = `${t.hookPath}.legacy`;
|
|
467
|
+
if (markerState(legacy, mark) === "reachable")
|
|
468
|
+
return live(legacy);
|
|
469
|
+
return inHook !== "absent" ? { state: "unreachable", file: t.hookPath } : { state: "missing", file: t.ownerFile };
|
|
236
470
|
}
|
|
237
471
|
default: // husky-legacy, tracked-hooks-path, exec-exit: the hook file itself
|
|
238
|
-
return inHook === "reachable" ?
|
|
472
|
+
return inHook === "reachable" ? live(t.hookPath) : { state: inHook === "unreachable" ? "unreachable" : "missing", file: t.hookPath };
|
|
239
473
|
}
|
|
240
474
|
}
|
|
241
475
|
function stripMarkers(blk) {
|
|
@@ -265,6 +499,107 @@ function snippetFor(t, mark, build, localInvocation) {
|
|
|
265
499
|
}
|
|
266
500
|
return build(PORTABLE_HOOK_INVOCATION, GIT_CONTEXT);
|
|
267
501
|
}
|
|
502
|
+
/** The repo's worktree tops as `git worktree list --porcelain` reports them:
|
|
503
|
+
* `linked` is every entry AFTER the first (the first is always the main
|
|
504
|
+
* worktree, or the bare repo) that is not the bare entry, and `count` is how
|
|
505
|
+
* many non-bare worktrees the repo has — the number the shared-hooks note
|
|
506
|
+
* quotes, which must not include a bare repo's phantom entry. Entries are
|
|
507
|
+
* separated by blank lines and each starts with a `worktree <path>` line.
|
|
508
|
+
* Read straight from git rather than the workspace ledger: this runs during
|
|
509
|
+
* `hunch init`, before any ledger exists. */
|
|
510
|
+
function worktreeTops(root) {
|
|
511
|
+
const out = gitRun(["worktree", "list", "--porcelain"], root).stdout;
|
|
512
|
+
if (!out)
|
|
513
|
+
return { linked: [], count: 0 };
|
|
514
|
+
const entries = out.replace(/\r/g, "").split(/\n\s*\n/).map((e) => e.split("\n"));
|
|
515
|
+
const parsed = entries
|
|
516
|
+
.map((lines) => ({
|
|
517
|
+
path: lines.find((l) => l.startsWith("worktree "))?.slice("worktree ".length) ?? "",
|
|
518
|
+
bare: lines.some((l) => l.trim() === "bare"),
|
|
519
|
+
}))
|
|
520
|
+
.filter((e) => e.path !== "");
|
|
521
|
+
return { linked: parsed.slice(1).filter((e) => !e.bare).map((e) => e.path), count: parsed.filter((e) => !e.bare).length };
|
|
522
|
+
}
|
|
523
|
+
/** The LINKED worktree an invocation is bound to — one it can only work from
|
|
524
|
+
* inside, because some token is an absolute path under it (that worktree's own
|
|
525
|
+
* node_modules install, its own source checkout) — or null.
|
|
526
|
+
*
|
|
527
|
+
* "Inside" counts textually OR physically: a node_modules symlinked out of a
|
|
528
|
+
* worktree still dies with `git worktree remove`, and a path spelled through
|
|
529
|
+
* the main checkout that realpaths into a worktree is just as bound. A
|
|
530
|
+
* RELATIVE path token is bound to nothing: the shell resolves it against the
|
|
531
|
+
* hook's own cwd at run time, which is whichever worktree git is running in,
|
|
532
|
+
* so it stays correct for all of them. */
|
|
533
|
+
function worktreeBound(invocation, tops) {
|
|
534
|
+
const toks = shellWords(invocation);
|
|
535
|
+
if (toks === null)
|
|
536
|
+
return null;
|
|
537
|
+
const norm = (p) => (process.platform === "win32" ? resolve(p).toLowerCase() : resolve(p));
|
|
538
|
+
const textuallyInside = (top, tok) => {
|
|
539
|
+
const rel = relative(norm(top), norm(tok));
|
|
540
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
541
|
+
};
|
|
542
|
+
for (const top of tops) {
|
|
543
|
+
if (toks.some((t) => isAbs(t) && (textuallyInside(top, t) || isInside(top, t))))
|
|
544
|
+
return top;
|
|
545
|
+
}
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
/** The (hookName, mark) pairs of every managed block Hunch writes — the sibling
|
|
549
|
+
* blocks that share one hooks dir, scanned for a working shared launcher.
|
|
550
|
+
* Resolved inside a function (like `preCommitId`) because the marks are
|
|
551
|
+
* declared further down. */
|
|
552
|
+
function managedBlocks() {
|
|
553
|
+
return [
|
|
554
|
+
["post-commit", MARK],
|
|
555
|
+
["pre-commit", PRE_MARK],
|
|
556
|
+
["post-merge", GROUNDING_MERGE_MARK],
|
|
557
|
+
["post-merge", REPAIR_MERGE_MARK],
|
|
558
|
+
["post-checkout", CHECKOUT_MARK],
|
|
559
|
+
];
|
|
560
|
+
}
|
|
561
|
+
/** The repo's existing SHARED invocation to install instead of `invocation`, or
|
|
562
|
+
* null to go on as normal (issue #316).
|
|
563
|
+
*
|
|
564
|
+
* Linked worktrees share ONE `<git-common-dir>/hooks` directory. Rebuilding a
|
|
565
|
+
* block from a path inside a linked worktree therefore re-points the main
|
|
566
|
+
* checkout's and every sibling worktree's hooks at code that disappears with
|
|
567
|
+
* `git worktree remove` — leaving all of them dead. So when the repo already
|
|
568
|
+
* runs a Hunch bound to NO linked worktree, that invocation wins. The gate is
|
|
569
|
+
* the caller's invocation, not `root`: an install run from the MAIN checkout
|
|
570
|
+
* with a worktree-bound launcher poisons the shared hooks exactly the same way.
|
|
571
|
+
*
|
|
572
|
+
* `own: true` means the block for THIS mark already carries it (the caller must
|
|
573
|
+
* also preserve the flags it carries). Otherwise the answer came from a sibling
|
|
574
|
+
* block: `hunch index` self-heals a missing post-merge/post-checkout hook and
|
|
575
|
+
* agents run it from worktrees constantly — a missing block must not become the
|
|
576
|
+
* way a worktree path gets into the shared hooks while the repo already has a
|
|
577
|
+
* working shared launcher. */
|
|
578
|
+
function sharedInvocationFor(root, mark, invocation, own) {
|
|
579
|
+
const tops = worktreeTops(root).linked;
|
|
580
|
+
if (tops.length === 0 || !worktreeBound(invocation, tops))
|
|
581
|
+
return null;
|
|
582
|
+
if (own.state === "installed") {
|
|
583
|
+
// A bound invocation always differs from an unbound one, so no separate
|
|
584
|
+
// "did it change?" test is needed to avoid a pointless rewrite.
|
|
585
|
+
return own.invocation && !worktreeBound(own.invocation, tops)
|
|
586
|
+
? { invocation: own.invocation, own: true, flags: own.flags ?? [] }
|
|
587
|
+
: null;
|
|
588
|
+
}
|
|
589
|
+
for (const [hookName, m] of managedBlocks()) {
|
|
590
|
+
if (m === mark)
|
|
591
|
+
continue;
|
|
592
|
+
const info = blockInfo(resolveHookTarget(root, hookName, m), m, root);
|
|
593
|
+
if (info.state === "installed" && info.invocation && !worktreeBound(info.invocation, tops)) {
|
|
594
|
+
// A rebuilt block gets its options from the CALLER, so `own.flags` are
|
|
595
|
+
// the ones the old block carried that this run may be dropping — reported
|
|
596
|
+
// (droppedFlags), never restored: restoring one would fabricate a request
|
|
597
|
+
// nobody made.
|
|
598
|
+
return { invocation: info.invocation, own: false, flags: own.flags ?? [] };
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
268
603
|
/** Shared idempotent create/append/update-in-place logic for every hunch git
|
|
269
604
|
* hook: write a fresh hook file, replace our own managed block in place if the
|
|
270
605
|
* invocation changed, or append after any pre-existing (non-hunch) hook body
|
|
@@ -276,40 +611,108 @@ function snippetFor(t, mark, build, localInvocation) {
|
|
|
276
611
|
function installManagedBlock(root, hookName, mark, end, build, invocation) {
|
|
277
612
|
const t = resolveHookTarget(root, hookName, mark);
|
|
278
613
|
if (t.manager !== "none") {
|
|
279
|
-
|
|
614
|
+
const info = blockInfo(t, mark, root);
|
|
615
|
+
if (info.state === "installed")
|
|
280
616
|
return { path: t.ownerFile, action: "unchanged", manager: t.manager };
|
|
281
617
|
return {
|
|
282
618
|
path: t.ownerFile,
|
|
283
619
|
action: t.manager === "exec-exit" ? "unreachable" : "managed-elsewhere",
|
|
284
620
|
manager: t.manager,
|
|
285
|
-
|
|
621
|
+
// A stale block IS reachable — the manager's file already carries one, it
|
|
622
|
+
// just cannot run. Saying "a hook manager owns this hook" would send the
|
|
623
|
+
// user looking for a block that is right there (issue #315).
|
|
624
|
+
reason: info.state === "stale"
|
|
625
|
+
? `the Hunch block in ${basename(info.file)} is stale (${info.staleReason ?? "its command is not a Hunch launcher"}) — replace it with the snippet below`
|
|
626
|
+
: t.reason,
|
|
286
627
|
snippet: snippetFor(t, mark, build, invocation),
|
|
628
|
+
...(info.state === "stale" ? { stale: true } : {}),
|
|
287
629
|
};
|
|
288
630
|
}
|
|
289
|
-
|
|
631
|
+
// Issue #316: the hooks dir is shared with every linked worktree, so prefer
|
|
632
|
+
// the repo's existing unbound command over a caller path that dies with a
|
|
633
|
+
// worktree. The invocation text comes from blockInfo, i.e. straight out of the block's
|
|
634
|
+
// command line with its quoting intact, and inspectBlock only calls a block
|
|
635
|
+
// healthy when shellWords parsed it into plain words with no shell
|
|
636
|
+
// metacharacters — so re-embedding that exact text in build() is safe.
|
|
637
|
+
const info = blockInfo(t, mark, root);
|
|
638
|
+
const shared = sharedInvocationFor(root, mark, invocation, info);
|
|
639
|
+
const blk = build(shared?.invocation ?? invocation, GIT_CONTEXT);
|
|
640
|
+
// A dead own block rebuilt from a SIBLING's launcher keeps neither its command
|
|
641
|
+
// nor its options; say which options went, so a downgraded strict guard is not
|
|
642
|
+
// the user's to discover later.
|
|
643
|
+
// The local-only provider line is such an option too: a privacy promise that
|
|
644
|
+
// goes must be named, exactly like a flag.
|
|
290
645
|
const hookPath = t.hookPath;
|
|
646
|
+
const cur = existsSync(hookPath) ? readFileSync(hookPath, "utf8") : null;
|
|
647
|
+
const blockRe = new RegExp(`${escapeRe(mark)}[\\s\\S]*?${escapeRe(end)}`);
|
|
648
|
+
const own = cur?.includes(mark) ? ownBlockText(cur, mark, blockRe) : "";
|
|
649
|
+
const lost = shared && !shared.own
|
|
650
|
+
? [...shared.flags.filter((f) => !builtFlags(blk, mark).includes(f)), ...(LOCAL_ONLY_RE.test(own) && !LOCAL_ONLY_RE.test(blk) ? [LOCAL_ONLY_LABEL] : [])]
|
|
651
|
+
: [];
|
|
652
|
+
const sharedNote = shared ? { sharedInvocation: shared.invocation, ...(lost.length > 0 ? { droppedFlags: lost } : {}) } : {};
|
|
291
653
|
mkdirSync(dirname(hookPath), { recursive: true });
|
|
292
|
-
if (
|
|
654
|
+
if (cur === null) {
|
|
293
655
|
writeFileSync(hookPath, `#!/bin/sh\n${blk}\n`);
|
|
294
656
|
chmodSync(hookPath, 0o755);
|
|
295
|
-
return { path: hookPath, action: "created" };
|
|
657
|
+
return { path: hookPath, action: "created", ...sharedNote };
|
|
296
658
|
}
|
|
297
|
-
const cur = readFileSync(hookPath, "utf8");
|
|
298
659
|
if (cur.includes(mark)) {
|
|
299
|
-
const
|
|
660
|
+
const closed = blockRe.test(cur);
|
|
661
|
+
let next = blk;
|
|
662
|
+
let note = sharedNote;
|
|
663
|
+
if (shared?.own) {
|
|
664
|
+
// A worktree-local re-run may ADD options to the shared block, never
|
|
665
|
+
// remove them or re-point it: an advisory `hunch init` from a worktree
|
|
666
|
+
// must not silently downgrade the repo's strict pre-commit guard. So the
|
|
667
|
+
// block is rebuilt as the UNION of what it carries and what this run asks
|
|
668
|
+
// for. Refusing the whole write over one omitted option would also discard
|
|
669
|
+
// the one the run DID ask for — a `hunch private` whose --private never
|
|
670
|
+
// lands keeps capturing into the public store behind a ✓ line.
|
|
671
|
+
//
|
|
672
|
+
// What the block carries is read from ALL its WORKING command lines (the
|
|
673
|
+
// options of a hand-added second line are merged in — the line itself is
|
|
674
|
+
// not kept: the block is rebuilt from the template), and the provider line
|
|
675
|
+
// is an option like any flag: a privacy promise is not a re-run's to withdraw.
|
|
676
|
+
const carried = builtFlags(own, mark, root);
|
|
677
|
+
const asked = builtFlags(blk, mark);
|
|
678
|
+
const keepsLocalOnly = LOCAL_ONLY_RE.test(own);
|
|
679
|
+
const kept = [...carried.filter((f) => !asked.includes(f)), ...(keepsLocalOnly && !LOCAL_ONLY_RE.test(blk) ? [LOCAL_ONLY_LABEL] : [])];
|
|
680
|
+
const added = [...asked.filter((f) => !carried.includes(f)), ...(!keepsLocalOnly && LOCAL_ONLY_RE.test(blk) ? [LOCAL_ONLY_LABEL] : [])];
|
|
681
|
+
const keptNote = { ...sharedNote, ...(kept.length > 0 ? { keptFlags: kept } : {}) };
|
|
682
|
+
// Nothing to add: the block stays byte-identical. Rebuilding it anyway would
|
|
683
|
+
// let a re-run that asked for nothing rewrite a line someone shaped by hand
|
|
684
|
+
// (a `--strict || true` softened guard would come back blocking).
|
|
685
|
+
if (added.length === 0)
|
|
686
|
+
return { path: hookPath, action: "kept-shared", ...keptNote };
|
|
687
|
+
next = build(shared.invocation, GIT_CONTEXT, { flags: carried, localOnly: keepsLocalOnly });
|
|
688
|
+
note = { ...keptNote, addedFlags: added };
|
|
689
|
+
}
|
|
690
|
+
// No closing marker: there is nothing to replace in place, and saying
|
|
691
|
+
// "unchanged" (or "kept") would hide that this run's block never landed.
|
|
692
|
+
if (!closed) {
|
|
693
|
+
return {
|
|
694
|
+
path: hookPath,
|
|
695
|
+
action: "unreachable",
|
|
696
|
+
reason: `the Hunch block in ${basename(hookPath)} has no closing \`${end}\` line, so it cannot be updated in place — replace it with the snippet below`,
|
|
697
|
+
snippet: next,
|
|
698
|
+
stale: true,
|
|
699
|
+
live: true,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
const updated = cur.replace(blockRe, () => next);
|
|
300
703
|
if (updated === cur)
|
|
301
|
-
return { path: hookPath, action: "unchanged" };
|
|
704
|
+
return { path: hookPath, action: "unchanged", ...sharedNote };
|
|
302
705
|
writeFileSync(hookPath, updated);
|
|
303
706
|
chmodSync(hookPath, 0o755);
|
|
304
|
-
return { path: hookPath, action: "updated" };
|
|
707
|
+
return { path: hookPath, action: "updated", ...note };
|
|
305
708
|
}
|
|
306
709
|
const appended = cur.endsWith("\n") ? `${cur}${blk}\n` : `${cur}\n${blk}\n`;
|
|
307
710
|
writeFileSync(hookPath, appended);
|
|
308
711
|
chmodSync(hookPath, 0o755);
|
|
309
|
-
return { path: hookPath, action: "appended" };
|
|
712
|
+
return { path: hookPath, action: "appended", ...sharedNote };
|
|
310
713
|
}
|
|
311
714
|
export function installPostCommitHook(root, invocation, opts = {}) {
|
|
312
|
-
return installManagedBlock(root, "post-commit", MARK, ENDMARK, (inv) => block(inv, opts), invocation);
|
|
715
|
+
return installManagedBlock(root, "post-commit", MARK, ENDMARK, (inv, _ctx, keep) => block(inv, opts, keep), invocation);
|
|
313
716
|
}
|
|
314
717
|
const PRE_MARK = "# >>> hunch pre-commit (constraint guard) >>>";
|
|
315
718
|
const PRE_END = "# <<< hunch pre-commit <<<";
|
|
@@ -319,9 +722,10 @@ const PRE_END = "# <<< hunch pre-commit <<<";
|
|
|
319
722
|
* blocking invariant (see strictgate.ts), so it's safe on a shared repo.
|
|
320
723
|
* Preserves any existing pre-commit hook. */
|
|
321
724
|
export function installPreCommitHook(root, invocation, strict = false) {
|
|
322
|
-
const build = (inv) => {
|
|
323
|
-
const
|
|
324
|
-
|
|
725
|
+
const build = (inv, _ctx, keep) => {
|
|
726
|
+
const on = strict || keep?.flags.includes("--strict");
|
|
727
|
+
const cmd = `${inv} check --staged${on ? " --strict" : ""}`;
|
|
728
|
+
return [PRE_MARK, on ? cmd : `${cmd} || true`, PRE_END].join("\n");
|
|
325
729
|
};
|
|
326
730
|
return installManagedBlock(root, "pre-commit", PRE_MARK, PRE_END, build, invocation);
|
|
327
731
|
}
|
|
@@ -366,7 +770,7 @@ function repairProvenanceMergeBlock(invocation) {
|
|
|
366
770
|
* (the file itself is new) outranks "appended"/"updated" (an existing file
|
|
367
771
|
* changed), which outrank "unchanged". Non-writing results are handled before
|
|
368
772
|
* ranking (they must never be masked by a sibling's success). */
|
|
369
|
-
const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1, "managed-elsewhere": 0, unreachable: 0 };
|
|
773
|
+
const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1, "kept-shared": 1, "managed-elsewhere": 0, unreachable: 0 };
|
|
370
774
|
const writes = (h) => h.action !== "managed-elsewhere" && h.action !== "unreachable";
|
|
371
775
|
/** Install a post-merge hook carrying TWO independently-managed blocks:
|
|
372
776
|
* re-sync the committed grounding docs when a merge brought memory in behind
|
|
@@ -392,7 +796,17 @@ export function installPostMergeHook(root, invocation) {
|
|
|
392
796
|
return grounding;
|
|
393
797
|
if (!writes(repair))
|
|
394
798
|
return repair;
|
|
395
|
-
|
|
799
|
+
const picked = ACTION_RANK[repair.action] >= ACTION_RANK[grounding.action] ? repair : grounding;
|
|
800
|
+
const other = picked === repair ? grounding : repair;
|
|
801
|
+
// Either half may be the one that kept the repo's shared invocation (issue
|
|
802
|
+
// #316); the ranking above can pick the other one. Carry the shared-hook facts
|
|
803
|
+
// over so the CLI line stays true about what the file now runs.
|
|
804
|
+
return {
|
|
805
|
+
...picked,
|
|
806
|
+
...(picked.sharedInvocation ?? other.sharedInvocation ? { sharedInvocation: picked.sharedInvocation ?? other.sharedInvocation } : {}),
|
|
807
|
+
...(picked.keptFlags ?? other.keptFlags ? { keptFlags: picked.keptFlags ?? other.keptFlags } : {}),
|
|
808
|
+
...(picked.droppedFlags ?? other.droppedFlags ? { droppedFlags: picked.droppedFlags ?? other.droppedFlags } : {}),
|
|
809
|
+
};
|
|
396
810
|
}
|
|
397
811
|
/** When two snippets for the same file are joined, drop the second's leading
|
|
398
812
|
* instruction comment (the first already says where it goes). */
|
|
@@ -422,23 +836,34 @@ export function installPostCheckoutHook(root, invocation) {
|
|
|
422
836
|
function reportEntry(root, hookName, marks) {
|
|
423
837
|
const states = marks.map((mark) => {
|
|
424
838
|
const t = resolveHookTarget(root, hookName, mark);
|
|
425
|
-
return { t,
|
|
839
|
+
return { t, ...blockInfo(t, mark, root) };
|
|
426
840
|
});
|
|
427
841
|
const unreachable = states.find((s) => s.state === "unreachable");
|
|
428
|
-
const
|
|
842
|
+
const stale = states.find((s) => s.state === "stale");
|
|
843
|
+
// A dead block outranks a stale one, which outranks a missing one: each is a
|
|
844
|
+
// stronger statement about why this hook is not doing its job.
|
|
845
|
+
const pick = unreachable ?? stale ?? states.find((s) => s.state === "missing") ?? states[0];
|
|
429
846
|
if (!pick)
|
|
430
847
|
throw new Error("reportEntry needs at least one marker");
|
|
431
|
-
const state = unreachable ? "unreachable" : states.every((s) => s.state === "installed") ? "installed" : "missing";
|
|
848
|
+
const state = unreachable ? "unreachable" : stale ? "stale" : states.every((s) => s.state === "installed") ? "installed" : "missing";
|
|
849
|
+
const invocation = [...new Set(states.map((s) => s.invocation).filter((i) => !!i))].join(" · ");
|
|
850
|
+
// Union across the hook's marks, in BLOCK_FLAGS order — a hook is re-installed
|
|
851
|
+
// as a whole, so a flag on any of its blocks is one a bare re-run would drop.
|
|
852
|
+
const flags = BLOCK_FLAGS.filter((f) => states.some((s) => s.flags?.includes(f)));
|
|
432
853
|
return {
|
|
433
854
|
state,
|
|
434
855
|
manager: pick.t.manager,
|
|
435
|
-
path: state === "unreachable" && pick.t.manager !== "husky" ? pick.t.hookPath : pick.t.ownerFile,
|
|
436
|
-
...(state === "unreachable" ? { reason: pick.t.reason } : {}),
|
|
856
|
+
path: state === "unreachable" && pick.t.manager !== "husky" ? pick.t.hookPath : state === "stale" ? pick.file : pick.t.ownerFile,
|
|
857
|
+
...(state === "unreachable" ? { reason: pick.t.reason } : state === "stale" ? { reason: pick.staleReason } : {}),
|
|
858
|
+
...(invocation ? { invocation } : {}),
|
|
859
|
+
...(flags.length ? { flags } : {}),
|
|
437
860
|
};
|
|
438
861
|
}
|
|
439
862
|
/** Read-only diagnostic (used by `hunch doctor`): each managed hook's state —
|
|
440
|
-
* `installed` (present where git will run it
|
|
441
|
-
* after an exec/exit or in a
|
|
863
|
+
* `installed` (present where git will run it, running a Hunch launcher that
|
|
864
|
+
* exists), `unreachable` (present, but after an exec/exit or in a
|
|
865
|
+
* manager-owned file git never reaches), `stale` (present where git runs it,
|
|
866
|
+
* but its command is not a working Hunch launcher — issue #315), or
|
|
442
867
|
* `missing`. postMerge requires BOTH halves (grounding-refresh and
|
|
443
868
|
* repair-provenance) — a repo carrying only one is a partial install, same as
|
|
444
869
|
* `installPostMergeHook` self-healing it. Never writes anything. */
|
|
@@ -451,9 +876,9 @@ export function hookReport(root) {
|
|
|
451
876
|
};
|
|
452
877
|
}
|
|
453
878
|
/** Boolean view of `hookReport`: a hook counts as installed only when its
|
|
454
|
-
* managed block is present where git will actually run it
|
|
455
|
-
*
|
|
456
|
-
* (issue #
|
|
879
|
+
* managed block is present where git will actually run it AND the command
|
|
880
|
+
* inside it is a Hunch launcher that exists. An unreachable block (issue #311)
|
|
881
|
+
* and a stale one (issue #315) are both NOT installed. */
|
|
457
882
|
export function hookStatus(root) {
|
|
458
883
|
const r = hookReport(root);
|
|
459
884
|
return {
|
|
@@ -463,17 +888,105 @@ export function hookStatus(root) {
|
|
|
463
888
|
postCheckout: r.postCheckout.state === "installed",
|
|
464
889
|
};
|
|
465
890
|
}
|
|
891
|
+
/** Doctor lines for where each installed/stale hook points (issue #315): hooks
|
|
892
|
+
* sharing one invocation share one line, and a line whose invocation is not the
|
|
893
|
+
* Hunch currently running says so. Informational — a hook may legitimately
|
|
894
|
+
* point at a different install. Pure; the caller adds indentation and dim(). */
|
|
895
|
+
export function hookInvocationLines(report, running) {
|
|
896
|
+
const named = [["post-commit", report.postCommit], ["post-merge", report.postMerge], ["pre-commit", report.preCommit], ["post-checkout", report.postCheckout]]
|
|
897
|
+
.filter(([, e]) => e.invocation && (e.state === "installed" || e.state === "stale"));
|
|
898
|
+
const grouped = new Map();
|
|
899
|
+
for (const [name, e] of named)
|
|
900
|
+
grouped.set(e.invocation, [...(grouped.get(e.invocation) ?? []), name]);
|
|
901
|
+
return [...grouped].map(([inv, names]) => `${names.join(", ")} → ${inv}${running && inv !== running ? ` (differs from the running Hunch: ${running})` : ""}`);
|
|
902
|
+
}
|
|
903
|
+
const KEPT_HOW = " — to change its options, re-run `hunch init` with a Hunch that is not inside a linked worktree (a global install, or the main checkout's)";
|
|
466
904
|
/** CLI lines for one install result: the usual ✓ line when the block is in a
|
|
467
905
|
* file git runs, otherwise a warning with the reason and the snippet to add to
|
|
468
906
|
* the manager's own file. */
|
|
469
907
|
export function formatHookInstall(root, label, h, detail = "") {
|
|
470
|
-
|
|
471
|
-
|
|
908
|
+
// kept-shared deliberately drops `detail`: it describes the options this run
|
|
909
|
+
// ASKED for, and a run that asked for FEWER than the shared block carries (an
|
|
910
|
+
// advisory re-run of a strict guard) would describe a block that is not there
|
|
911
|
+
// (issue #316).
|
|
912
|
+
if (h.action === "kept-shared") {
|
|
913
|
+
const flags = h.keptFlags?.length ? ` with ${h.keptFlags.join(" ")}` : "";
|
|
914
|
+
// The block kept options this run did not ask for, so the way to remove them
|
|
915
|
+
// is the one thing worth saying next.
|
|
916
|
+
const how = h.keptFlags?.length ? KEPT_HOW : "";
|
|
917
|
+
return [` ✓ ${label} kept — the shared hook keeps running ${h.sharedInvocation}${flags} (not re-pointed at this worktree)${how}`];
|
|
918
|
+
}
|
|
919
|
+
if (writes(h)) {
|
|
920
|
+
// A union write (issue #316). Once an option was KEPT, `detail` — which
|
|
921
|
+
// describes only what this run asked for — is no longer the truth about the
|
|
922
|
+
// block: an "advisory" detail beside a kept --strict contradicts itself.
|
|
923
|
+
const opts = [h.addedFlags?.length ? `added ${h.addedFlags.join(" ")}` : "", h.keptFlags?.length ? `kept its existing ${h.keptFlags.join(" ")}` : ""].filter(Boolean).join(", ");
|
|
924
|
+
const union = opts ? ` — ${opts} (a worktree re-run adds options, never removes them)${h.keptFlags?.length ? KEPT_HOW : ""}` : "";
|
|
925
|
+
const said = h.keptFlags?.length ? "" : detail;
|
|
926
|
+
const lost = h.droppedFlags?.length ? ` — the previous block's ${h.droppedFlags.join(" ")} was not carried over; re-run with the option to restore it` : "";
|
|
927
|
+
if (h.sharedInvocation)
|
|
928
|
+
return [` ✓ ${label} ${h.action}${said} — runs the repo's shared Hunch (${h.sharedInvocation}), not this worktree's${union}${lost}`];
|
|
929
|
+
return [` ✓ ${label} ${h.action}${detail}${lost}`];
|
|
930
|
+
}
|
|
472
931
|
const shown = isInside(root, h.path) ? relative(realish(root), realish(h.path)).replace(/\\/g, "/") : h.path;
|
|
473
932
|
return [
|
|
474
|
-
` ⚠ ${label} NOT installed — ${h.reason ?? "a hook manager owns this hook"}`,
|
|
475
|
-
|
|
933
|
+
` ⚠ ${label} NOT ${h.live ? "updated" : "installed"} — ${h.reason ?? "a hook manager owns this hook"}`,
|
|
934
|
+
// A stale block is already there and the reason said to replace it; anything
|
|
935
|
+
// else is missing and has to be added.
|
|
936
|
+
` ${h.stale ? "replace the Hunch block in" : "add this to"} ${shown} yourself:`,
|
|
476
937
|
...(h.snippet ?? "").split("\n").map((l) => ` ${l}`),
|
|
477
938
|
];
|
|
478
939
|
}
|
|
940
|
+
/** The closing line `hunch init` prints about the SHARED hooks dir (issue #316).
|
|
941
|
+
* Setup used to claim "sharing the repo's hooks + memory" unconditionally —
|
|
942
|
+
* true of memory, but the hooks dir is genuinely shared, so a block pointing
|
|
943
|
+
* inside a linked worktree dies for every checkout when that worktree goes.
|
|
944
|
+
*
|
|
945
|
+
* The verdict is read from what the shared blocks ACTUALLY run, not from this
|
|
946
|
+
* run's actions: a second `hunch init` writes nothing yet the hooks are just as
|
|
947
|
+
* broken, and the main checkout — which never used to get a note at all — is
|
|
948
|
+
* where the user can most easily fix it. `installs` only decides between the
|
|
949
|
+
* healthy variants. `[]` when the repo has no linked worktree. Read-only. */
|
|
950
|
+
export function sharedHooksNote(root, installs) {
|
|
951
|
+
const { linked, count } = worktreeTops(root);
|
|
952
|
+
if (linked.length === 0)
|
|
953
|
+
return [];
|
|
954
|
+
const d = hooksDir(root);
|
|
955
|
+
const dir = isAbsolute(d) ? d : join(root, d);
|
|
956
|
+
// A hooks dir is shared when git resolves it inside the COMMON dir, or points
|
|
957
|
+
// outside this checkout entirely. A relative `core.hooksPath` (husky's
|
|
958
|
+
// `.husky/_`) resolves per checkout, so it is this worktree's alone.
|
|
959
|
+
const common = gitCommonDir(root);
|
|
960
|
+
const top = gitRun(["rev-parse", "--show-toplevel"], root).stdout;
|
|
961
|
+
const dirShared = (common !== "" && isInside(common, dir)) || (top !== "" && !isInside(top, dir));
|
|
962
|
+
if (dirShared) {
|
|
963
|
+
const bound = [];
|
|
964
|
+
const tops = new Set();
|
|
965
|
+
for (const [hookName, m] of managedBlocks()) {
|
|
966
|
+
const info = blockInfo(resolveHookTarget(root, hookName, m), m, root);
|
|
967
|
+
const at = info.invocation ? worktreeBound(info.invocation, linked) : null;
|
|
968
|
+
if (!at)
|
|
969
|
+
continue;
|
|
970
|
+
tops.add(at);
|
|
971
|
+
if (!bound.includes(hookName))
|
|
972
|
+
bound.push(hookName);
|
|
973
|
+
}
|
|
974
|
+
if (bound.length > 0) {
|
|
975
|
+
const one = bound.length === 1;
|
|
976
|
+
// Several blocks may be bound to different worktrees; naming the first one
|
|
977
|
+
// is enough to find the problem, and the plural says there are more.
|
|
978
|
+
const where = tops.size > 1 ? `linked worktrees (${[...tops][0]})` : `a linked worktree (${[...tops][0]})`;
|
|
979
|
+
return [` ⚠ shared hooks dir (${dir}) — ${bound.join(", ")} run${one ? "s" : ""} Hunch from inside ${where}; removing that worktree breaks ${one ? "it" : "them"} for every checkout of this repo — install Hunch globally or in the main checkout, then re-run \`hunch init\` with it to re-point ${one ? "it" : "them"}`];
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
if (!isLinkedWorktree(root))
|
|
983
|
+
return [];
|
|
984
|
+
const wrote = installs.some((h) => writes(h));
|
|
985
|
+
if (!dirShared || !wrote)
|
|
986
|
+
return [" ✓ linked worktree — sharing the repo's memory"];
|
|
987
|
+
if (installs.some((h) => h.sharedInvocation)) {
|
|
988
|
+
return [` ✓ linked worktree — sharing the repo's memory; the hooks dir is shared by ${count} worktrees (${dir}) and keeps running the repo's Hunch, not this worktree's`];
|
|
989
|
+
}
|
|
990
|
+
return [` ✓ linked worktree — sharing the repo's hooks + memory (hooks dir is shared by ${count} worktrees: ${dir})`];
|
|
991
|
+
}
|
|
479
992
|
//# sourceMappingURL=hooks.js.map
|