@primitive.ai/prim 0.1.0-alpha.93 → 0.1.0-alpha.96

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.
Files changed (41) hide show
  1. package/README.md +57 -12
  2. package/SKILL.md +5 -2
  3. package/dist/{chunk-UKUAAAXE.js → chunk-27ADT6FQ.js} +2 -2
  4. package/dist/{chunk-OTHKG76J.js → chunk-35FMRWJH.js} +1 -1
  5. package/dist/{chunk-DXJQR4QI.js → chunk-4GHANPWQ.js} +2 -2
  6. package/dist/{chunk-DCJN6LZH.js → chunk-7KABYYWG.js} +747 -430
  7. package/dist/{chunk-LFFLHSQO.js → chunk-CU7RW7XU.js} +63 -6
  8. package/dist/{chunk-H4UNYFUC.js → chunk-DHOQGHKQ.js} +184 -1
  9. package/dist/{chunk-2GP5UFV7.js → chunk-DWRSEC34.js} +5 -3
  10. package/dist/{chunk-36TWVNBE.js → chunk-FVDBULGZ.js} +9 -11
  11. package/dist/{chunk-G2VMA2QO.js → chunk-LY6NJ7KL.js} +6 -4
  12. package/dist/{chunk-ZB4F75SZ.js → chunk-MXVCP33E.js} +5 -1
  13. package/dist/{chunk-TYAHC25F.js → chunk-QCBB5HFX.js} +1 -1
  14. package/dist/{chunk-C3VAA5CG.js → chunk-RK4ZS3HG.js} +2 -2
  15. package/dist/{chunk-ZU563PQI.js → chunk-ROLYLXJS.js} +2 -2
  16. package/dist/{chunk-OH3EZXYX.js → chunk-UO2U6IKF.js} +1 -1
  17. package/dist/{chunk-K7D2EYBS.js → chunk-URLLSALP.js} +2 -2
  18. package/dist/chunk-XEDJ5N3M.js +177 -0
  19. package/dist/{chunk-X64HTHUF.js → chunk-Y5W4TZM7.js} +202 -10
  20. package/dist/{chunk-ICM2XCXS.js → chunk-YYE2NLWX.js} +2 -2
  21. package/dist/{chunk-DSWT6BCC.js → chunk-Z6NPNLOT.js} +825 -665
  22. package/dist/daemon/server.js +836 -540
  23. package/dist/hook-runtime/daemon/server.js +836 -540
  24. package/dist/hook-runtime/hooks/post-commit.js +978 -566
  25. package/dist/hook-runtime/hooks/post-rewrite.js +836 -540
  26. package/dist/hook-runtime/hooks/post-tool-use.js +1215 -796
  27. package/dist/hook-runtime/hooks/pre-tool-use.js +2100 -1741
  28. package/dist/hook-runtime/hooks/prim-hook.js +1312 -861
  29. package/dist/hook-runtime/hooks/session-end.js +232 -134
  30. package/dist/hook-runtime/hooks/session-start.js +1799 -935
  31. package/dist/hook-runtime/index.js +3628 -2461
  32. package/dist/hooks/post-commit.js +14 -10
  33. package/dist/hooks/post-rewrite.js +10 -10
  34. package/dist/hooks/post-tool-use.js +20 -20
  35. package/dist/hooks/pre-commit.js +4 -4
  36. package/dist/hooks/pre-tool-use.js +29 -17
  37. package/dist/hooks/prim-hook.js +22 -18
  38. package/dist/hooks/session-end.js +11 -5
  39. package/dist/hooks/session-start.js +46 -28
  40. package/dist/index.js +623 -390
  41. package/package.json +1 -1
@@ -1,62 +1,35 @@
1
1
  import {
2
2
  unattendedEnv
3
3
  } from "./chunk-OYHYCK4K.js";
4
- import {
5
- isValidRepoSyncId,
6
- setRepoSyncId,
7
- setRepositoryBindingState
8
- } from "./chunk-DSWT6BCC.js";
9
4
  import {
10
5
  HttpError,
11
6
  getClient
12
- } from "./chunk-G2VMA2QO.js";
7
+ } from "./chunk-LY6NJ7KL.js";
13
8
  import {
9
+ GIT_HOOK_CONTRACT_VERSION,
14
10
  binFile,
11
+ blockMarkers,
12
+ canonicalHookBlock,
15
13
  compareSemver,
16
14
  cursorConfigDirectory,
15
+ hookBlockContractVersion,
16
+ inspectGitHookEntrypoint,
17
+ managedHookBlock,
18
+ pinnedHookCommand,
17
19
  pinnedNpxCommand
18
- } from "./chunk-X64HTHUF.js";
20
+ } from "./chunk-Y5W4TZM7.js";
19
21
  import {
20
22
  gitToplevel,
21
- githubRepositoryFullName
22
- } from "./chunk-H4UNYFUC.js";
23
+ githubRepositoryFullName,
24
+ isValidRepoSyncId,
25
+ setRepoSyncId,
26
+ setRepositoryBindingState
27
+ } from "./chunk-DHOQGHKQ.js";
23
28
  import {
24
29
  atomicWriteFile,
30
+ primConfigDirectory,
25
31
  withFileLock
26
- } from "./chunk-2GP5UFV7.js";
27
-
28
- // src/lib/bin-cache.ts
29
- import { chmodSync, mkdirSync } from "fs";
30
- import { homedir } from "os";
31
- import { join } from "path";
32
- var GIT_HOOK_CACHE_SHELL_DIR = "${XDG_CACHE_HOME:-$HOME/.cache}/prim/bin";
33
- var GIT_HOOK_CACHE_TTL_MINUTES = 1440;
34
- var GIT_HOOK_CACHED_BINS = ["prim-post-commit", "prim-post-rewrite"];
35
- function binCacheDir() {
36
- const base = process.env.XDG_CACHE_HOME || join(homedir(), ".cache");
37
- return join(base, "prim", "bin");
38
- }
39
- function writeAtomic(path, content) {
40
- atomicWriteFile(path, content, { mode: 384 });
41
- }
42
- function warmBinCache() {
43
- try {
44
- if (process.env.PRIM_BIN_CACHE === "0") {
45
- return;
46
- }
47
- const dir = binCacheDir();
48
- mkdirSync(dir, { recursive: true, mode: 448 });
49
- chmodSync(dir, 448);
50
- writeAtomic(join(dir, "node"), process.execPath);
51
- for (const bin of GIT_HOOK_CACHED_BINS) {
52
- const file = binFile(bin);
53
- if (file) {
54
- writeAtomic(join(dir, bin), file);
55
- }
56
- }
57
- } catch {
58
- }
59
- }
32
+ } from "./chunk-DWRSEC34.js";
60
33
 
61
34
  // src/commands/skill.ts
62
35
  import { createHash as createHash2 } from "crypto";
@@ -73,8 +46,8 @@ import {
73
46
  rmdirSync as rmdirSync2,
74
47
  unlinkSync
75
48
  } from "fs";
76
- import { homedir as homedir3 } from "os";
77
- import { dirname as dirname2, join as join3, resolve as resolve2 } from "path";
49
+ import { homedir as homedir2 } from "os";
50
+ import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
78
51
  import { fileURLToPath as fileURLToPath2 } from "url";
79
52
  import { createPatch } from "diff";
80
53
  import { parse as parseYaml2 } from "yaml";
@@ -92,7 +65,7 @@ import {
92
65
  existsSync,
93
66
  fstatSync,
94
67
  lstatSync,
95
- mkdirSync as mkdirSync2,
68
+ mkdirSync,
96
69
  openSync,
97
70
  readFileSync,
98
71
  readSync,
@@ -101,8 +74,8 @@ import {
101
74
  rmSync,
102
75
  rmdirSync
103
76
  } from "fs";
104
- import { homedir as homedir2 } from "os";
105
- import { dirname, isAbsolute, join as join2, relative, resolve, sep } from "path";
77
+ import { homedir } from "os";
78
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "path";
106
79
  import { fileURLToPath } from "url";
107
80
  import { parse as parseYaml } from "yaml";
108
81
  var __dirname = dirname(fileURLToPath(import.meta.url));
@@ -112,8 +85,8 @@ var MAX_SKILL_BYTES = 1024 * 1024;
112
85
  var REFRESH_LOCK_TIMEOUT_MS = 150;
113
86
  var REFRESH_LOCK_POLL_MS = 10;
114
87
  function pluginDirFor(cwd, scope) {
115
- const base = scope === "user" ? join2(homedir2(), ".claude") : join2(gitToplevel(cwd) ?? cwd, ".claude");
116
- return join2(base, "skills", "prim");
88
+ const base = scope === "user" ? join(homedir(), ".claude") : join(gitToplevel(cwd) ?? cwd, ".claude");
89
+ return join(base, "skills", "prim");
117
90
  }
118
91
  function resolvePluginDir(cwd, scope) {
119
92
  if (scope && scope !== "user" && scope !== "project") {
@@ -145,8 +118,8 @@ function removeDirIfEmpty(dir) {
145
118
  }
146
119
  function pluginPaths(dir) {
147
120
  return {
148
- manifestPath: join2(dir, ".claude-plugin", "plugin.json"),
149
- skillPath: join2(dir, "SKILL.md")
121
+ manifestPath: join(dir, ".claude-plugin", "plugin.json"),
122
+ skillPath: join(dir, "SKILL.md")
150
123
  };
151
124
  }
152
125
  function pathIsInside(root, path) {
@@ -211,9 +184,9 @@ function inspectRecognizedInstallation(paths, projectRoot) {
211
184
  };
212
185
  }
213
186
  function refreshLockDir(physicalPluginDir) {
214
- const cacheRoot = process.env.XDG_CACHE_HOME || join2(homedir2(), ".cache");
187
+ const cacheRoot = process.env.XDG_CACHE_HOME || join(homedir(), ".cache");
215
188
  const identity = createHash("sha256").update(physicalPluginDir).digest("hex");
216
- return join2(cacheRoot, "prim", "skill-refresh-locks", `${identity}.lock`);
189
+ return join(cacheRoot, "prim", "skill-refresh-locks", `${identity}.lock`);
217
190
  }
218
191
  async function refreshClaudePlugins(cwd, options = {}) {
219
192
  const result = { installed: 0, refreshed: 0 };
@@ -221,7 +194,7 @@ async function refreshClaudePlugins(cwd, options = {}) {
221
194
  if (options.includeProject !== false) {
222
195
  const projectRoot = options.projectRoot ?? gitToplevel(cwd);
223
196
  if (projectRoot !== null && isAbsolute(projectRoot)) {
224
- candidates.push({ dir: join2(projectRoot, ".claude", "skills", "prim"), projectRoot });
197
+ candidates.push({ dir: join(projectRoot, ".claude", "skills", "prim"), projectRoot });
225
198
  }
226
199
  }
227
200
  candidates.push({ dir: pluginDirFor(cwd, "user") });
@@ -297,7 +270,7 @@ function installClaudePlugin(cwd, opts) {
297
270
  console.log(`Would write plugin to ${dir} (.claude-plugin/plugin.json + SKILL.md)`);
298
271
  return 0;
299
272
  }
300
- mkdirSync2(join2(dir, ".claude-plugin"), { recursive: true });
273
+ mkdirSync(join(dir, ".claude-plugin"), { recursive: true });
301
274
  atomicWrite(manifestPath, manifest);
302
275
  atomicWrite(skillPath, skill);
303
276
  console.log(`Installed prim skill plugin at ${dir}`);
@@ -317,7 +290,7 @@ function uninstallClaudePlugin(cwd, opts) {
317
290
  }
318
291
  rmSync(manifestPath, { force: true });
319
292
  rmSync(skillPath, { force: true });
320
- removeDirIfEmpty(join2(dir, ".claude-plugin"));
293
+ removeDirIfEmpty(join(dir, ".claude-plugin"));
321
294
  removeDirIfEmpty(dir);
322
295
  console.log(`Removed prim skill plugin from ${dir}`);
323
296
  return 0;
@@ -372,10 +345,10 @@ function readCodexGuidance(path) {
372
345
  }
373
346
  }
374
347
  function effectiveCodexGuidance(scope) {
375
- const override = readCodexGuidance(join3(scope, "AGENTS.override.md"));
348
+ const override = readCodexGuidance(join2(scope, "AGENTS.override.md"));
376
349
  if (override === null) return null;
377
350
  if (override !== void 0 && override.toString("utf8").trim().length > 0) return override;
378
- const base = readCodexGuidance(join3(scope, "AGENTS.md"));
351
+ const base = readCodexGuidance(join2(scope, "AGENTS.md"));
379
352
  return base ?? null;
380
353
  }
381
354
  function hasUsablePrimBlock(content, visibleBytes) {
@@ -398,7 +371,7 @@ function hasUsablePrimBlock(content, visibleBytes) {
398
371
  }
399
372
  function hasUsableCodexGuidance(projectRoot) {
400
373
  try {
401
- const codexHome = process.env.CODEX_HOME?.trim() ? resolve2(process.env.CODEX_HOME) : join3(homedir3(), ".codex");
374
+ const codexHome = process.env.CODEX_HOME?.trim() ? resolve2(process.env.CODEX_HOME) : join2(homedir2(), ".codex");
402
375
  const scopes = [
403
376
  { path: codexHome, visibleBytes: CODEX_GUIDANCE_LIMIT_BYTES },
404
377
  { path: projectRoot, visibleBytes: CODEX_DEFAULT_PROJECT_GUIDANCE_BYTES }
@@ -432,14 +405,14 @@ function sha256(content) {
432
405
  }
433
406
  function cursorSkillDirectory(cwd, scope) {
434
407
  const base = scope === "user" ? cursorConfigDirectory() : gitToplevel(cwd) ?? cwd;
435
- return join3(base, scope === "user" ? "skills" : ".cursor/skills", "prim");
408
+ return join2(base, scope === "user" ? "skills" : ".cursor/skills", "prim");
436
409
  }
437
410
  function cursorSkillPaths(cwd, scope) {
438
411
  const directory = cursorSkillDirectory(cwd, scope);
439
412
  return {
440
413
  directory,
441
- skill: join3(directory, "SKILL.md"),
442
- manifest: join3(directory, CURSOR_SKILL_MANIFEST)
414
+ skill: join2(directory, "SKILL.md"),
415
+ manifest: join2(directory, CURSOR_SKILL_MANIFEST)
443
416
  };
444
417
  }
445
418
  function readCursorSkillManifest(path) {
@@ -527,10 +500,10 @@ function hasUsableCursorSkill(projectRoot) {
527
500
  return cursorSkillStatus(projectRoot, "project").installed || cursorSkillStatus(projectRoot, "user").installed;
528
501
  }
529
502
  function userTargetFor(agent) {
530
- if (agent === "claude") return join3(homedir3(), ".claude", "CLAUDE.md");
531
- if (agent === "codex") return join3(homedir3(), ".codex", "AGENTS.md");
503
+ if (agent === "claude") return join2(homedir2(), ".claude", "CLAUDE.md");
504
+ if (agent === "codex") return join2(homedir2(), ".codex", "AGENTS.md");
532
505
  if (agent === "hermes") {
533
- return join3(process.env.HERMES_HOME ?? join3(homedir3(), ".hermes"), ".hermes.md");
506
+ return join2(process.env.HERMES_HOME ?? join2(homedir2(), ".hermes"), ".hermes.md");
534
507
  }
535
508
  return null;
536
509
  }
@@ -560,8 +533,8 @@ function applyBlock(existing, block, eol) {
560
533
  return existing.slice(0, b) + block + existing.slice(e + SKILL_END.length);
561
534
  }
562
535
  if (existing.length === 0) return `${block}${eol}`;
563
- const sep2 = existing.endsWith(eol) ? "" : eol;
564
- return `${existing}${sep2}${block}${eol}`;
536
+ const sep3 = existing.endsWith(eol) ? "" : eol;
537
+ return `${existing}${sep3}${block}${eol}`;
565
538
  }
566
539
  function removeBlock(existing) {
567
540
  const b = existing.indexOf(SKILL_BEGIN);
@@ -701,60 +674,61 @@ function registerSkillCommands(program) {
701
674
  });
702
675
  }
703
676
 
704
- // src/lib/post-commit-hook.ts
705
- import { execFileSync } from "child_process";
706
- import { createHash as createHash3, randomUUID } from "crypto";
707
- import {
708
- constants as constants3,
709
- closeSync as closeSync3,
710
- existsSync as existsSync3,
711
- fchmodSync,
712
- fsyncSync,
713
- lstatSync as lstatSync3,
714
- mkdirSync as mkdirSync3,
715
- openSync as openSync3,
716
- readFileSync as readFileSync3,
717
- renameSync,
718
- unlinkSync as unlinkSync2,
719
- writeFileSync
720
- } from "fs";
721
- import { basename, dirname as dirname3, isAbsolute as isAbsolute2, resolve as resolve3 } from "path";
722
- var PRIM_POST_COMMIT_BLOCK_START = "# >>> prim post-commit hook >>>";
723
- var PRIM_POST_COMMIT_BLOCK_END = "# <<< prim post-commit hook <<<";
724
- var PRIM_CREATED_MARK = "prim-created-post-commit-hook";
725
- var PRIM_POST_REWRITE_BLOCK_START = "# >>> prim post-rewrite hook >>>";
726
- var PRIM_POST_REWRITE_BLOCK_END = "# <<< prim post-rewrite hook <<<";
727
- var PRIM_POST_REWRITE_CREATED_MARK = "prim-created-post-rewrite-hook";
728
- var LEGACY_PRIM_CREATED_MARK = "prim-created-hook";
729
- var LEGACY_PRIM_OWNED_HEADER = "# prim post-commit hook \u2014 installed by: prim hooks install (prim-managed-hook)";
730
- var LEGACY_GLOBAL_OWNED_HEADER = "# prim global post-commit hook (core.hooksPath) \u2014 managed by prim; do not edit.";
731
- var MAX_HOOK_BYTES = 1048576;
732
- var GIT_TIMEOUT_MS = 1e3;
733
- var SUPPORTED_SHEBANGS = /* @__PURE__ */ new Set([
734
- "#!/bin/sh",
735
- "#!/usr/bin/env sh",
736
- "#!/bin/bash",
737
- "#!/usr/bin/env bash",
738
- "#!/bin/zsh",
739
- "#!/usr/bin/env zsh"
740
- ]);
741
- var HUSKY_V9_DISPATCHERS = /* @__PURE__ */ new Set([
742
- '#!/usr/bin/env sh\n. "${0%/*}/h"',
743
- '#!/usr/bin/env sh\n. "$(dirname "$0")/h"'
744
- ]);
745
- var HUSKY_DIRECT_DISPATCHER = '#!/bin/sh\nexec sh "$(dirname "$0")/../post-commit"\n';
746
- var HUSKY_V9_RUNTIME_HASHES = /* @__PURE__ */ new Set([
747
- "d6aaa6f3f7c11008c525e649161df4a93152f1e55d0887fa1d7b8d622735d380",
748
- "b02cb91cab7125a9e906be723a1b85297fc435a8307d429ae845546b4ee0ecde",
749
- "9db5d129c4eb822a773157d98cd8fb8f1b156b85e3193117fefe527ca6d45e91",
750
- "3b2cb21335e544b4ebce658ca47af4a69d2c77026cc4d3f8b2227c34d062b207",
751
- "548990e1c11285694096184993d907b258d41235461a7ca2551e3b53ff9d3a38",
752
- "c029a943acea5d6e2ddbaa271a1fb22c2da55d56b5c6fb59ca804ac9a4018708",
753
- "ae3413a8fe2b39372de48bdc9691f42055f089c0c7529834b2fb07a131585b6a",
754
- "70200b200ca709b0622784f93839a5b2872333a917a09afddefd7dc2d8cdc680"
755
- ]);
756
- function currentPostCommitBlock() {
757
- return `${PRIM_POST_COMMIT_BLOCK_START}
677
+ // src/lib/bin-cache.ts
678
+ import { chmodSync, mkdirSync as mkdirSync2 } from "fs";
679
+ import { homedir as homedir3 } from "os";
680
+ import { join as join3 } from "path";
681
+ var GIT_HOOK_CACHE_SHELL_DIR = "${XDG_CACHE_HOME:-$HOME/.cache}/prim/bin";
682
+ var GIT_HOOK_CACHE_TTL_MINUTES = 1440;
683
+ var GIT_HOOK_CACHED_BINS = ["prim-post-commit", "prim-post-rewrite"];
684
+ function binCacheDir() {
685
+ const base = process.env.XDG_CACHE_HOME || join3(homedir3(), ".cache");
686
+ return join3(base, "prim", "bin");
687
+ }
688
+ function writeAtomic(path, content) {
689
+ atomicWriteFile(path, content, { mode: 384 });
690
+ }
691
+ function warmBinCache() {
692
+ try {
693
+ if (process.env.PRIM_BIN_CACHE === "0") {
694
+ return;
695
+ }
696
+ const dir = binCacheDir();
697
+ mkdirSync2(dir, { recursive: true, mode: 448 });
698
+ chmodSync(dir, 448);
699
+ writeAtomic(join3(dir, "node"), process.execPath);
700
+ for (const bin of GIT_HOOK_CACHED_BINS) {
701
+ const file = binFile(bin);
702
+ if (file) {
703
+ writeAtomic(join3(dir, bin), file);
704
+ }
705
+ }
706
+ } catch {
707
+ }
708
+ }
709
+
710
+ // src/lib/git-hook-legacy.ts
711
+ import { isAbsolute as isAbsolute2 } from "path";
712
+ var PRIM_MANAGED_MARK = "prim-managed-hook";
713
+ var PRE_COMMIT = { hookName: "pre-commit", binName: "prim-pre-commit" };
714
+ var POST_COMMIT = { hookName: "post-commit", binName: "prim-post-commit" };
715
+ var POST_REWRITE = { hookName: "post-rewrite", binName: "prim-post-rewrite" };
716
+ function blockMarkers2(spec) {
717
+ return {
718
+ start: `# >>> prim ${spec.hookName} hook >>>`,
719
+ end: `# <<< prim ${spec.hookName} hook <<<`
720
+ };
721
+ }
722
+ function hookShim(binName) {
723
+ return `{ ${pinnedHookCommand(binName)}; } || true`;
724
+ }
725
+ function gatedShim(binName) {
726
+ return `if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then
727
+ ${hookShim(binName)}
728
+ fi`;
729
+ }
730
+ function legacyPostCommitBlock() {
731
+ return `# >>> prim post-commit hook >>>
758
732
  if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then
759
733
  prim_commit_sha=$(git rev-parse --verify HEAD 2>/dev/null) || prim_commit_sha=
760
734
  case "$prim_commit_sha" in *[!0-9a-f]*|"") prim_commit_sha= ;; esac
@@ -783,13 +757,10 @@ if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then
783
757
  fi
784
758
  unset prim_commit_sha
785
759
  fi
786
- ${PRIM_POST_COMMIT_BLOCK_END}`;
760
+ # <<< prim post-commit hook <<<`;
787
761
  }
788
- function postCommitHookBlock() {
789
- return currentPostCommitBlock();
790
- }
791
- function postRewriteHookBlock() {
792
- return `${PRIM_POST_REWRITE_BLOCK_START}
762
+ function legacyPostRewriteBlock() {
763
+ return `# >>> prim post-rewrite hook >>>
793
764
  if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then
794
765
  case "$1" in amend|rebase) prim_rewrite_source="$1" ;; *) prim_rewrite_source= ;; esac
795
766
  if [ -n "$prim_rewrite_source" ]; then
@@ -828,22 +799,227 @@ if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then
828
799
  fi
829
800
  unset prim_rewrite_source
830
801
  fi
831
- ${PRIM_POST_REWRITE_BLOCK_END}`;
832
- }
833
- var POST_COMMIT_MANAGED_HOOK = {
834
- hookName: "post-commit",
835
- blockStart: PRIM_POST_COMMIT_BLOCK_START,
836
- blockEnd: PRIM_POST_COMMIT_BLOCK_END,
837
- createdMark: PRIM_CREATED_MARK,
838
- block: postCommitHookBlock
839
- };
840
- var POST_REWRITE_MANAGED_HOOK = {
841
- hookName: "post-rewrite",
842
- blockStart: PRIM_POST_REWRITE_BLOCK_START,
843
- blockEnd: PRIM_POST_REWRITE_BLOCK_END,
844
- createdMark: PRIM_POST_REWRITE_CREATED_MARK,
845
- block: postRewriteHookBlock
802
+ # <<< prim post-rewrite hook <<<`;
803
+ }
804
+ function legacyGlobalHookScript(spec) {
805
+ const chainExit = spec.hookName === PRE_COMMIT.hookName ? "|| exit $?" : "|| true";
806
+ const managedBlock = spec.hookName === POST_COMMIT.hookName ? legacyPostCommitBlock() : spec.hookName === POST_REWRITE.hookName ? legacyPostRewriteBlock() : void 0;
807
+ const invocation = managedBlock ?? gatedShim(spec.binName);
808
+ const managedRepoGuard = managedBlock ? ` && ! grep -Fq '${blockMarkers2(spec).start.slice(0, -3)}'">>>" "$repo_hook" 2>/dev/null` : "";
809
+ const beforeComments = managedBlock ? `${invocation}
810
+ ` : "";
811
+ const afterComments = managedBlock ? "" : `${invocation}
812
+ `;
813
+ return `#!/bin/sh
814
+ ${beforeComments}# prim global ${spec.hookName} hook (core.hooksPath) \u2014 managed by prim; do not edit.
815
+ # Install/uninstall: prim hooks install|uninstall --scope user
816
+ # Runs prim only where activated \u2014 'prim enable' (this repo) or
817
+ # 'git config --global prim.active true' (every repo). Chains to the repo's own
818
+ # hook regardless, so inactive repos are unaffected.
819
+ ${afterComments}common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0
820
+ repo_hook="$common_dir/hooks/${spec.hookName}"
821
+ if [ -x "$repo_hook" ] && ! grep -q '${PRIM_MANAGED_MARK}' "$repo_hook" 2>/dev/null${managedRepoGuard}; then
822
+ "$repo_hook" "$@" ${chainExit}
823
+ fi
824
+ exit 0
825
+ `;
826
+ }
827
+ var PRE_COMMIT_GATE = 'if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then';
828
+ function preCommitLadder(fallback, failSoft) {
829
+ const soft = failSoft ? " || true" : "";
830
+ return `if command -v prim-pre-commit >/dev/null 2>&1; then
831
+ prim-pre-commit${soft}
832
+ elif [ -f "./node_modules/.bin/prim-pre-commit" ]; then
833
+ ./node_modules/.bin/prim-pre-commit${soft}
834
+ else
835
+ ${fallback} 2>/dev/null || true
836
+ fi`;
837
+ }
838
+ var LADDER_ALPHA_2 = preCommitLadder("npx --yes @primitive.ai/prim pre-commit-hook", false);
839
+ var LADDER_ALPHA_9 = preCommitLadder("npx --yes -p @primitive.ai/prim prim-pre-commit", false);
840
+ var LADDER_ALPHA_35 = preCommitLadder("npx --yes -p @primitive.ai/prim prim-pre-commit", true);
841
+ var PINNED_NPX_PRE_COMMIT = String.raw`npx --yes(?: --ignore-scripts)? -p @primitive\.ai/prim@[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)? prim-pre-commit`;
842
+ var PINNED_PRE_COMMIT_RE = new RegExp(
843
+ String.raw`^\{ (?:${PINNED_NPX_PRE_COMMIT}|if \[ -x (?<node>'.*') \] && \[ -f (?<entry>'.*') \]; then \k<node> \k<entry>; else ${PINNED_NPX_PRE_COMMIT}; fi); \} \|\| true$`,
844
+ "u"
845
+ );
846
+ var SHELL_QUOTED_RE = /^'(?:[^']|'"'"')*'$/u;
847
+ function decodePinnedPath(token) {
848
+ if (!token || !SHELL_QUOTED_RE.test(token)) return void 0;
849
+ const value = token.slice(1, -1).replaceAll(`'"'"'`, "'");
850
+ if (!isAbsolute2(value) || value.includes("\0") || value.includes("\n") || value.includes("\r")) {
851
+ return void 0;
852
+ }
853
+ return value;
854
+ }
855
+ function isPinnedPreCommitInvocation(line) {
856
+ const match = PINNED_PRE_COMMIT_RE.exec(line);
857
+ if (!match) return false;
858
+ if (match.groups?.node === void 0) return true;
859
+ return Boolean(
860
+ decodePinnedPath(match.groups.node) && decodePinnedPath(match.groups.entry)?.endsWith("/dist/hooks/pre-commit.js")
861
+ );
862
+ }
863
+ function isLegacyPreCommitBody(body) {
864
+ if (body === LADDER_ALPHA_2 || body === LADDER_ALPHA_9 || body === LADDER_ALPHA_35) return true;
865
+ if (isPinnedPreCommitInvocation(body)) return true;
866
+ const prefix = `${PRE_COMMIT_GATE}
867
+ `;
868
+ if (!body.startsWith(prefix) || !body.endsWith("\nfi")) return false;
869
+ const inner = body.slice(prefix.length, -"\nfi".length);
870
+ return inner === LADDER_ALPHA_35 || isPinnedPreCommitInvocation(inner);
871
+ }
872
+ function isLegacyOwnedPreCommitScript(content) {
873
+ const original = (ladder) => `#!/bin/sh
874
+ # prim pre-commit hook \u2014 auto-syncs affected specs on commit
875
+ # Installed by: prim hooks install
876
+
877
+ # Find the nearest node_modules/.bin with prim, or use npx
878
+ ${ladder}
879
+ `;
880
+ if (content === original(LADDER_ALPHA_2) || content === original(LADDER_ALPHA_9)) return true;
881
+ if (content === `#!/bin/sh
882
+ # prim pre-commit hook \u2014 installed by: prim hooks install
883
+
884
+ ${LADDER_ALPHA_9}
885
+ `) {
886
+ return true;
887
+ }
888
+ const prefix = `#!/bin/sh
889
+ # prim pre-commit hook \u2014 installed by: prim hooks install (${PRIM_MANAGED_MARK})
890
+
891
+ `;
892
+ if (!content.startsWith(prefix) || !content.endsWith("\n")) return false;
893
+ const body = content.slice(prefix.length, -1);
894
+ return body === LADDER_ALPHA_35 || isPinnedPreCommitInvocation(body);
895
+ }
896
+ var PINNED_PACKAGE_VERSION_RE = /@primitive\.ai\/prim@[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/gu;
897
+ function normalizeOwnedGlobalHook(content, hookName) {
898
+ if (hookName === PRE_COMMIT.hookName) {
899
+ const lines = content.split("\n");
900
+ const invocationIndexes = lines.flatMap(
901
+ (line, index) => line.startsWith("{ ") && line.endsWith("; } || true") ? [index] : []
902
+ );
903
+ if (invocationIndexes.length !== 1) return null;
904
+ const invocationIndex = invocationIndexes[0];
905
+ if (!isPinnedPreCommitInvocation(lines[invocationIndex])) return null;
906
+ lines[invocationIndex] = "{ <recognized Prim pre-commit invocation>; } || true";
907
+ return lines.join("\n");
908
+ }
909
+ return content.replace(PINNED_PACKAGE_VERSION_RE, "@primitive.ai/prim@<version>");
910
+ }
911
+ function isLegacyOwnedGlobalHook(content, hookName) {
912
+ const spec = [PRE_COMMIT, POST_COMMIT, POST_REWRITE].find((s) => s.hookName === hookName);
913
+ if (!spec) return false;
914
+ const expected = legacyGlobalHookScript(spec);
915
+ if (content === expected) return true;
916
+ if (spec === PRE_COMMIT && content === expected.replace(gatedShim(spec.binName), `${PRE_COMMIT_GATE}
917
+ ${LADDER_ALPHA_35}
918
+ fi`)) {
919
+ return true;
920
+ }
921
+ const normalized = normalizeOwnedGlobalHook(content, hookName);
922
+ return normalized !== null && normalized === normalizeOwnedGlobalHook(expected, hookName);
923
+ }
924
+ function legacyInlineHookBlock(hookName) {
925
+ return hookName === "post-commit" ? legacyPostCommitBlock() : legacyPostRewriteBlock();
926
+ }
927
+ function isRecognizedLegacyBlock(hookName, block) {
928
+ if (hookName === "pre-commit") {
929
+ const { start, end } = blockMarkers2(PRE_COMMIT);
930
+ const prefix = `${start}
931
+ `;
932
+ const suffix = `
933
+ ${end}`;
934
+ if (!block.startsWith(prefix) || !block.endsWith(suffix)) return false;
935
+ return isLegacyPreCommitBody(block.slice(prefix.length, -suffix.length));
936
+ }
937
+ const expected = legacyInlineHookBlock(hookName);
938
+ const normalize = (text) => text.replace(PINNED_PACKAGE_VERSION_RE, "@primitive.ai/prim@<version>");
939
+ return normalize(block) === normalize(expected);
940
+ }
941
+
942
+ // src/lib/git-hooks.ts
943
+ import { execFileSync } from "child_process";
944
+ import { createHash as createHash3, randomUUID } from "crypto";
945
+ import {
946
+ constants as constants3,
947
+ closeSync as closeSync3,
948
+ existsSync as existsSync3,
949
+ fchmodSync,
950
+ fsyncSync,
951
+ lstatSync as lstatSync3,
952
+ mkdirSync as mkdirSync3,
953
+ openSync as openSync3,
954
+ readFileSync as readFileSync3,
955
+ realpathSync as realpathSync2,
956
+ renameSync,
957
+ unlinkSync as unlinkSync2,
958
+ writeFileSync
959
+ } from "fs";
960
+ import { basename, dirname as dirname3, isAbsolute as isAbsolute3, join as join4, relative as relative2, resolve as resolve3, sep as sep2 } from "path";
961
+ var LEGACY_PRIM_CREATED_MARK = "prim-created-hook";
962
+ var LEGACY_PRIM_OWNED_HEADER = "# prim post-commit hook \u2014 installed by: prim hooks install (prim-managed-hook)";
963
+ var LEGACY_GLOBAL_OWNED_HEADER = "# prim global post-commit hook (core.hooksPath) \u2014 managed by prim; do not edit.";
964
+ var MAX_HOOK_BYTES = 1048576;
965
+ var GIT_TIMEOUT_MS = 1e3;
966
+ var SUPPORTED_SHEBANGS = /* @__PURE__ */ new Set([
967
+ "#!/bin/sh",
968
+ "#!/usr/bin/env sh",
969
+ "#!/bin/bash",
970
+ "#!/usr/bin/env bash",
971
+ "#!/bin/zsh",
972
+ "#!/usr/bin/env zsh"
973
+ ]);
974
+ var HUSKY_V9_DISPATCHERS = /* @__PURE__ */ new Set([
975
+ '#!/usr/bin/env sh\n. "${0%/*}/h"',
976
+ '#!/usr/bin/env sh\n. "$(dirname "$0")/h"'
977
+ ]);
978
+ var HUSKY_DIRECT_DISPATCHER = '#!/bin/sh\nexec sh "$(dirname "$0")/../post-commit"\n';
979
+ var HUSKY_V9_RUNTIME_HASHES = /* @__PURE__ */ new Set([
980
+ "d6aaa6f3f7c11008c525e649161df4a93152f1e55d0887fa1d7b8d622735d380",
981
+ "b02cb91cab7125a9e906be723a1b85297fc435a8307d429ae845546b4ee0ecde",
982
+ "9db5d129c4eb822a773157d98cd8fb8f1b156b85e3193117fefe527ca6d45e91",
983
+ "3b2cb21335e544b4ebce658ca47af4a69d2c77026cc4d3f8b2227c34d062b207",
984
+ "548990e1c11285694096184993d907b258d41235461a7ca2551e3b53ff9d3a38",
985
+ "c029a943acea5d6e2ddbaa271a1fb22c2da55d56b5c6fb59ca804ac9a4018708",
986
+ "ae3413a8fe2b39372de48bdc9691f42055f089c0c7529834b2fb07a131585b6a",
987
+ "70200b200ca709b0622784f93839a5b2872333a917a09afddefd7dc2d8cdc680"
988
+ ]);
989
+ function managedHookSpecFor(hookName) {
990
+ const { start, end } = blockMarkers(hookName);
991
+ return {
992
+ hookName,
993
+ blockStart: start,
994
+ blockEnd: end,
995
+ createdMark: `prim-created-${hookName}-hook`
996
+ };
997
+ }
998
+ var MANAGED_HOOK_SPECS = {
999
+ "pre-commit": managedHookSpecFor("pre-commit"),
1000
+ "post-commit": managedHookSpecFor("post-commit"),
1001
+ "post-rewrite": managedHookSpecFor("post-rewrite")
846
1002
  };
1003
+ function managedHookSpec(hookName) {
1004
+ return MANAGED_HOOK_SPECS[hookName];
1005
+ }
1006
+ function gitHooksMode(options = {}) {
1007
+ try {
1008
+ const value = execFileSync(
1009
+ "git",
1010
+ ["config", ...options.global ? ["--global"] : [], "--get", "prim.gitHooks"],
1011
+ {
1012
+ cwd: options.cwd,
1013
+ encoding: "utf8",
1014
+ stdio: ["ignore", "pipe", "ignore"],
1015
+ timeout: GIT_TIMEOUT_MS
1016
+ }
1017
+ ).trim();
1018
+ return value === "manual" ? "manual" : "auto";
1019
+ } catch {
1020
+ return "auto";
1021
+ }
1022
+ }
847
1023
  function legacyFloatingInvocation() {
848
1024
  return `if command -v prim-post-commit >/dev/null 2>&1; then
849
1025
  prim-post-commit || true
@@ -860,15 +1036,6 @@ fi
860
1036
  `);
861
1037
  }
862
1038
  var PINNED_INVOCATION_RE = /^\{ if \[ -x (?<node>'.*') \] && \[ -f (?<entry>'.*') \]; then \k<node> \k<entry>; else npx --yes -p @primitive\.ai\/prim@(?<version>[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?) prim-post-commit; fi; \} \|\| true$/u;
863
- var SHELL_QUOTED_RE = /^'(?:[^']|'"'"')*'$/u;
864
- function decodePinnedPath(token) {
865
- if (!token || !SHELL_QUOTED_RE.test(token)) return void 0;
866
- const value = token.slice(1, -1).replaceAll(`'"'"'`, "'");
867
- if (!isAbsolute2(value) || value.includes("\0") || value.includes("\n") || value.includes("\r")) {
868
- return void 0;
869
- }
870
- return value;
871
- }
872
1039
  function isPinnedInvocation(value) {
873
1040
  const match = PINNED_INVOCATION_RE.exec(value);
874
1041
  const node = decodePinnedPath(match?.groups?.node);
@@ -901,48 +1068,149 @@ function isKnownLegacyGlobalGate(value) {
901
1068
  }
902
1069
  function createdScaffold(spec) {
903
1070
  return Buffer.from(`#!/bin/sh
904
- ${spec.block()}
1071
+ ${managedHookBlock(spec.hookName)}
905
1072
  # ${spec.createdMark}
906
1073
  `);
907
1074
  }
1075
+ function oldCreatedPrefixes(spec) {
1076
+ return [
1077
+ Buffer.from(`#!/bin/sh
1078
+ # ${spec.createdMark}
1079
+
1080
+ `),
1081
+ Buffer.from(`#!/bin/sh
1082
+ # ${LEGACY_PRIM_CREATED_MARK}
1083
+
1084
+ `)
1085
+ ];
1086
+ }
1087
+ function isOwnedStandalonePreCommit(content) {
1088
+ return content === "#!/bin/sh\nprim-pre-commit\n" || isLegacyOwnedPreCommitScript(content);
1089
+ }
1090
+ function realpathLoose(path) {
1091
+ const missing = [];
1092
+ let current = resolve3(path);
1093
+ for (; ; ) {
1094
+ try {
1095
+ return join4(realpathSync2.native(current), ...missing);
1096
+ } catch {
1097
+ const parent = dirname3(current);
1098
+ if (parent === current) return resolve3(path);
1099
+ missing.unshift(basename(current));
1100
+ current = parent;
1101
+ }
1102
+ }
1103
+ }
1104
+ function isWithin(path, directory) {
1105
+ const rel = relative2(directory, path);
1106
+ return rel === "" || !isAbsolute3(rel) && rel !== ".." && !rel.startsWith(`..${sep2}`);
1107
+ }
1108
+ function primGitHooksDirectory() {
1109
+ try {
1110
+ return join4(primConfigDirectory(), "git-hooks");
1111
+ } catch {
1112
+ return void 0;
1113
+ }
1114
+ }
1115
+ function otherWorktreeRoots(gitRoot) {
1116
+ try {
1117
+ const self = realpathLoose(gitRoot);
1118
+ return execFileSync("git", ["worktree", "list", "--porcelain"], {
1119
+ cwd: gitRoot,
1120
+ encoding: "utf8",
1121
+ stdio: ["ignore", "pipe", "ignore"],
1122
+ timeout: GIT_TIMEOUT_MS
1123
+ }).split("\n").flatMap((line) => line.startsWith("worktree ") ? [realpathLoose(line.slice(9))] : []).filter((root) => root !== self);
1124
+ } catch {
1125
+ return [];
1126
+ }
1127
+ }
1128
+ function hookLocation(hookPath, dirs) {
1129
+ const path = realpathLoose(hookPath);
1130
+ if (isWithin(path, realpathLoose(dirs.gitDir)) || isWithin(path, realpathLoose(dirs.commonDir))) {
1131
+ return "repository";
1132
+ }
1133
+ const primDir = primGitHooksDirectory();
1134
+ if (primDir !== void 0 && isWithin(path, realpathLoose(primDir))) return "prim";
1135
+ const root = realpathLoose(dirs.gitRoot);
1136
+ if (!isWithin(path, root)) return "external";
1137
+ const nested = otherWorktreeRoots(dirs.gitRoot).filter((other) => isWithin(other, root));
1138
+ return nested.some((other) => isWithin(path, other)) ? "external" : "worktree";
1139
+ }
1140
+ function hooksPathScope(cwd) {
1141
+ try {
1142
+ const scope = execFileSync("git", ["config", "--show-scope", "--get", "core.hooksPath"], {
1143
+ cwd,
1144
+ encoding: "utf8",
1145
+ stdio: ["ignore", "pipe", "ignore"],
1146
+ timeout: GIT_TIMEOUT_MS
1147
+ }).split(" ")[0];
1148
+ return scope === "local" || scope === "worktree" || scope === "global" || scope === "system" || scope === "command" ? scope : void 0;
1149
+ } catch {
1150
+ return void 0;
1151
+ }
1152
+ }
1153
+ function externalHookRemedy(hookName, cwd) {
1154
+ const snippet = `place \`prim hooks snippet ${hookName}\` there yourself`;
1155
+ switch (hooksPathScope(cwd)) {
1156
+ case "global":
1157
+ return `your global core.hooksPath runs every repository's hooks: ${snippet}, or have prim add it with \`prim hooks install --scope user --global-hooks-path\` (it changes every repository's hooks: an agent must ask the user first)`;
1158
+ case "system":
1159
+ return `the system core.hooksPath is shared by every user on this machine: ${snippet}, or ask its owner`;
1160
+ case "local":
1161
+ case "worktree":
1162
+ return `this repository's core.hooksPath points outside it: ${snippet}, or point core.hooksPath inside the repository`;
1163
+ default:
1164
+ return snippet;
1165
+ }
1166
+ }
908
1167
  function safeGitPath(root, value) {
909
1168
  if (value.length === 0 || value !== value.trim() || value.includes("\0") || value.includes("\n") || value.includes("\r")) {
910
1169
  throw new Error("Git returned an unsafe hooks path");
911
1170
  }
912
- return isAbsolute2(value) ? resolve3(value) : resolve3(root, value);
1171
+ return isAbsolute3(value) ? resolve3(value) : resolve3(root, value);
913
1172
  }
914
- function resolveEffectiveManagedHook(spec, cwd = process.cwd()) {
1173
+ function resolveEffectiveGitHook(hookName, cwd = process.cwd()) {
915
1174
  const gitRoot = gitToplevel(cwd);
916
1175
  if (!gitRoot) throw new Error("not a git repository");
917
- const value = execFileSync("git", ["rev-parse", "--git-path", "hooks"], {
918
- cwd: gitRoot,
919
- encoding: "utf8",
920
- stdio: ["ignore", "pipe", "ignore"],
921
- timeout: GIT_TIMEOUT_MS
922
- }).replace(/\r?\n$/u, "");
923
- const hooksDir = safeGitPath(gitRoot, value);
1176
+ const lines = execFileSync(
1177
+ "git",
1178
+ ["rev-parse", "--git-path", "hooks", "--absolute-git-dir", "--git-common-dir"],
1179
+ {
1180
+ cwd: gitRoot,
1181
+ encoding: "utf8",
1182
+ stdio: ["ignore", "pipe", "ignore"],
1183
+ timeout: GIT_TIMEOUT_MS
1184
+ }
1185
+ ).replace(/\r?\n$/u, "").split(/\r?\n/u);
1186
+ if (lines.length !== 3) throw new Error("Git returned unexpected repository paths");
1187
+ const [hooksValue, gitDirValue, commonDirValue] = lines;
1188
+ const hooksDir = safeGitPath(gitRoot, hooksValue);
1189
+ const dirs = {
1190
+ gitRoot,
1191
+ gitDir: safeGitPath(gitRoot, gitDirValue),
1192
+ commonDir: safeGitPath(gitRoot, commonDirValue)
1193
+ };
924
1194
  if (basename(hooksDir) === "_" && basename(dirname3(hooksDir)) === ".husky") {
1195
+ const hookPath2 = resolve3(dirname3(hooksDir), hookName);
925
1196
  return {
926
1197
  gitRoot,
927
1198
  hooksDir,
928
- hookPath: resolve3(dirname3(hooksDir), spec.hookName),
1199
+ hookPath: hookPath2,
929
1200
  kind: "husky_v9",
930
- dispatcherPath: resolve3(hooksDir, spec.hookName)
1201
+ dispatcherPath: resolve3(hooksDir, hookName),
1202
+ location: hookLocation(hookPath2, dirs)
931
1203
  };
932
1204
  }
1205
+ const hookPath = resolve3(hooksDir, hookName);
933
1206
  return {
934
1207
  gitRoot,
935
1208
  hooksDir,
936
- hookPath: resolve3(hooksDir, spec.hookName),
937
- kind: "direct"
1209
+ hookPath,
1210
+ kind: "direct",
1211
+ location: hookLocation(hookPath, dirs)
938
1212
  };
939
1213
  }
940
- function resolveEffectivePostCommitHook(cwd = process.cwd()) {
941
- return resolveEffectiveManagedHook(POST_COMMIT_MANAGED_HOOK, cwd);
942
- }
943
- function resolveEffectivePostRewriteHook(cwd = process.cwd()) {
944
- return resolveEffectiveManagedHook(POST_REWRITE_MANAGED_HOOK, cwd);
945
- }
946
1214
  function assertSafeFile(path, spec) {
947
1215
  if (!existsSync3(path)) return void 0;
948
1216
  const stat = lstatSync3(path);
@@ -1024,16 +1292,107 @@ function blockRange(content, spec) {
1024
1292
  }
1025
1293
  const start = starts[0];
1026
1294
  const end = ends[0] + endMarker.length;
1027
- const current = Buffer.from(spec.block());
1028
- return content.subarray(start, end).equals(current) ? { kind: "current", start, end } : { kind: "stale", start, end };
1295
+ const text = content.subarray(start, end).toString("utf8");
1296
+ if (canonicalHookBlock(text) === canonicalHookBlock(managedHookBlock(spec.hookName))) {
1297
+ return { kind: "current", start, end };
1298
+ }
1299
+ const version = hookBlockContractVersion(text);
1300
+ if (version !== void 0 && version > GIT_HOOK_CONTRACT_VERSION) {
1301
+ return { kind: "newer", start, end };
1302
+ }
1303
+ return { kind: "stale", start, end };
1304
+ }
1305
+ var HUSKY_V8_SOURCE_RE = /^\.[ \t]+"\$\(dirname(?:[ \t]+--)?[ \t]+"\$0"\)\/_\/husky\.sh"[ \t]*$/mu;
1306
+ function blockInsertionPoint(content, allowShebangless, spec, huskyV8) {
1307
+ const shebangEnd = shellInsertionPoint(content, allowShebangless, spec);
1308
+ if (!huskyV8) return shebangEnd;
1309
+ const rest = content.subarray(shebangEnd).toString("utf8");
1310
+ const match = HUSKY_V8_SOURCE_RE.exec(rest);
1311
+ if (!match) return shebangEnd;
1312
+ const lineEnd = match.index + match[0].length;
1313
+ const afterLine = rest[lineEnd] === "\n" ? lineEnd + 1 : lineEnd;
1314
+ return shebangEnd + Buffer.byteLength(rest.slice(0, afterLine), "utf8");
1315
+ }
1316
+ var HUSKY_SPEC_MAJOR_RE = /^\s*[~^]?=?\s*v?([0-9]+)(?:\.(?:[0-9]+|[xX*])){0,2}(?:-[0-9A-Za-z.-]+)?\s*$/u;
1317
+ function declaredHuskyMajor(dir) {
1318
+ for (let current = resolve3(dir); ; ) {
1319
+ const manifest = join4(current, "package.json");
1320
+ if (existsSync3(manifest)) {
1321
+ try {
1322
+ if (lstatSync3(manifest).size > MAX_HOOK_BYTES) return void 0;
1323
+ const parsed = JSON.parse(readFileSync3(manifest, "utf8"));
1324
+ const range = parsed.devDependencies?.husky ?? parsed.dependencies?.husky;
1325
+ const major = typeof range === "string" ? HUSKY_SPEC_MAJOR_RE.exec(range)?.[1] : void 0;
1326
+ return major === void 0 ? void 0 : Number(major);
1327
+ } catch {
1328
+ return void 0;
1329
+ }
1330
+ }
1331
+ const parent = dirname3(current);
1332
+ if (parent === current || existsSync3(join4(current, ".git"))) return void 0;
1333
+ current = parent;
1334
+ }
1335
+ }
1336
+ function isHuskyV8Hook(hookPath) {
1337
+ const runtime = join4(dirname3(hookPath), "_", "husky.sh");
1338
+ try {
1339
+ const stat = lstatSync3(runtime);
1340
+ return stat.isFile() && stat.size <= MAX_HOOK_BYTES && readFileSync3(runtime, "utf8").includes("husky_skip_init");
1341
+ } catch (error) {
1342
+ if (errorCode(error) !== "ENOENT") return false;
1343
+ }
1344
+ const major = declaredHuskyMajor(dirname3(dirname3(hookPath)));
1345
+ return major !== void 0 && major <= 8;
1346
+ }
1347
+ var TERMINAL_LINE_RE = /^(?:exit|return)(?:\s|;|$)/u;
1348
+ var REDIRECT_OPERATOR_RE = /^[0-9]*(?:<<?|>>?|<&|>&|<>|>\|)$/u;
1349
+ var REDIRECT_WORD_RE = /^[0-9]*(?:<|>)/u;
1350
+ function isReplacingExec(line) {
1351
+ if (!/^exec(?:\s|$)/u.test(line)) return false;
1352
+ const words = line.slice(4).trim().split(/\s+/u).filter(Boolean);
1353
+ for (let index = 0; index < words.length; index += 1) {
1354
+ const word = words[index];
1355
+ if (word.startsWith("#")) break;
1356
+ if (REDIRECT_OPERATOR_RE.test(word)) {
1357
+ index += 1;
1358
+ continue;
1359
+ }
1360
+ if (!REDIRECT_WORD_RE.test(word)) return true;
1361
+ }
1362
+ return false;
1363
+ }
1364
+ function terminatesBefore(content, from, offset) {
1365
+ const lines = content.subarray(from, offset).toString("utf8").split("\n");
1366
+ return lines.some((line) => TERMINAL_LINE_RE.test(line) || isReplacingExec(line));
1367
+ }
1368
+ function mayTerminateBefore(content, from, offset) {
1369
+ return content.subarray(from, offset).toString("utf8").split(/\n|;|&&|\|\||\||[{()]|\b(?:then|do|else)\b/u).some((part) => {
1370
+ const command = part.trim();
1371
+ return TERMINAL_LINE_RE.test(command) || isReplacingExec(command);
1372
+ });
1373
+ }
1374
+ function withoutRange(content, start, end) {
1375
+ const suffix = content.subarray(end);
1376
+ return Buffer.concat([
1377
+ content.subarray(0, start),
1378
+ suffix.at(0) === 10 ? suffix.subarray(1) : suffix
1379
+ ]);
1380
+ }
1381
+ function insertBlock(content, at, spec) {
1382
+ return Buffer.concat([
1383
+ content.subarray(0, at),
1384
+ Buffer.from(managedHookBlock(spec.hookName)),
1385
+ Buffer.from("\n"),
1386
+ content.subarray(at)
1387
+ ]);
1029
1388
  }
1030
- function mergedContent(existing, allowShebangless, spec) {
1031
- const block = Buffer.from(spec.block());
1032
- if (!existing) {
1389
+ function migratedLegacyContent(existing, allowShebangless, spec, huskyV8) {
1390
+ const text = existing.toString("utf8");
1391
+ if (spec.hookName === "pre-commit" && isOwnedStandalonePreCommit(text)) {
1033
1392
  return createdScaffold(spec);
1034
1393
  }
1035
- const range = blockRange(existing, spec);
1036
- if (spec.hookName === POST_COMMIT_MANAGED_HOOK.hookName && range.kind === "absent" && existing.toString("utf8").startsWith(`#!/bin/sh
1394
+ if (spec.hookName !== "post-commit") return void 0;
1395
+ if (text.startsWith(`#!/bin/sh
1037
1396
  ${LEGACY_GLOBAL_OWNED_HEADER}
1038
1397
  `)) {
1039
1398
  const gateAt = existing.indexOf(Buffer.from('if [ "$(git config --get prim.active'));
@@ -1048,15 +1407,13 @@ ${LEGACY_GLOBAL_OWNED_HEADER}
1048
1407
  existing.subarray(0, gateAt),
1049
1408
  existing.subarray(chainAt)
1050
1409
  ]);
1051
- const shebangEnd2 = shellInsertionPoint(withoutLegacyGate, allowShebangless, spec);
1052
- return Buffer.concat([
1053
- withoutLegacyGate.subarray(0, shebangEnd2),
1054
- block,
1055
- Buffer.from("\n"),
1056
- withoutLegacyGate.subarray(shebangEnd2)
1057
- ]);
1410
+ return insertBlock(
1411
+ withoutLegacyGate,
1412
+ blockInsertionPoint(withoutLegacyGate, allowShebangless, spec, huskyV8),
1413
+ spec
1414
+ );
1058
1415
  }
1059
- if (spec.hookName === POST_COMMIT_MANAGED_HOOK.hookName && range.kind === "absent" && existing.toString("utf8").startsWith(`#!/bin/sh
1416
+ if (text.startsWith(`#!/bin/sh
1060
1417
  ${LEGACY_PRIM_OWNED_HEADER}
1061
1418
  `)) {
1062
1419
  const prefixLength = legacyProjectPrefixLength(existing);
@@ -1065,42 +1422,59 @@ ${LEGACY_PRIM_OWNED_HEADER}
1065
1422
  }
1066
1423
  return Buffer.concat([createdScaffold(spec), existing.subarray(prefixLength)]);
1067
1424
  }
1068
- if (range.kind !== "absent") {
1069
- const oldCreatedPrefixes = [
1070
- Buffer.from(`#!/bin/sh
1071
- # ${spec.createdMark}
1072
-
1073
- `),
1074
- ...spec.hookName === POST_COMMIT_MANAGED_HOOK.hookName ? [Buffer.from(`#!/bin/sh
1075
- # ${LEGACY_PRIM_CREATED_MARK}
1076
-
1077
- `)] : []
1078
- ];
1079
- const oldCreated = oldCreatedPrefixes.some(
1425
+ return void 0;
1426
+ }
1427
+ function mergedContent(existing, allowShebangless, spec, relocate, huskyV8) {
1428
+ if (!existing) return { content: createdScaffold(spec), migrates: false };
1429
+ const range = blockRange(existing, spec);
1430
+ if (range.kind === "absent") {
1431
+ const migrated = migratedLegacyContent(existing, allowShebangless, spec, huskyV8);
1432
+ if (migrated) return { content: migrated, migrates: true };
1433
+ } else if (range.kind !== "newer") {
1434
+ const oldCreated = oldCreatedPrefixes(spec).some(
1080
1435
  (prefix) => range.start === prefix.length && existing.subarray(0, prefix.length).equals(prefix)
1081
1436
  );
1082
1437
  if (oldCreated) {
1083
1438
  const suffix = existing.subarray(range.end);
1084
1439
  const tail = suffix.at(0) === 10 ? suffix.subarray(1) : suffix;
1085
- return Buffer.concat([createdScaffold(spec), tail]);
1440
+ return {
1441
+ content: Buffer.concat([createdScaffold(spec), tail]),
1442
+ migrates: range.kind === "stale"
1443
+ };
1086
1444
  }
1087
1445
  }
1088
1446
  const shebangEnd = shellInsertionPoint(existing, allowShebangless, spec);
1089
- if (range.kind === "current" && range.start === shebangEnd) return existing;
1090
- let withoutBlock = existing;
1091
- if (range.kind !== "absent") {
1092
- const suffix = existing.subarray(range.end);
1093
- withoutBlock = Buffer.concat([
1094
- existing.subarray(0, range.start),
1095
- range.start === shebangEnd && suffix.at(0) === 10 ? suffix.subarray(1) : suffix
1096
- ]);
1447
+ const insertion = blockInsertionPoint(existing, allowShebangless, spec, huskyV8);
1448
+ switch (range.kind) {
1449
+ case "newer":
1450
+ return { content: existing, migrates: false };
1451
+ case "absent":
1452
+ return { content: insertBlock(existing, insertion, spec), migrates: false };
1453
+ case "current":
1454
+ case "stale": {
1455
+ const misplaced = terminatesBefore(existing, shebangEnd, range.start) || range.start < insertion;
1456
+ if (relocate && misplaced) {
1457
+ const without = withoutRange(existing, range.start, range.end);
1458
+ return {
1459
+ content: insertBlock(
1460
+ without,
1461
+ blockInsertionPoint(without, allowShebangless, spec, huskyV8),
1462
+ spec
1463
+ ),
1464
+ migrates: range.kind === "stale"
1465
+ };
1466
+ }
1467
+ if (range.kind === "current") return { content: existing, migrates: false };
1468
+ return {
1469
+ content: Buffer.concat([
1470
+ existing.subarray(0, range.start),
1471
+ Buffer.from(managedHookBlock(spec.hookName)),
1472
+ existing.subarray(range.end)
1473
+ ]),
1474
+ migrates: true
1475
+ };
1476
+ }
1097
1477
  }
1098
- return Buffer.concat([
1099
- withoutBlock.subarray(0, shebangEnd),
1100
- block,
1101
- Buffer.from("\n"),
1102
- withoutBlock.subarray(shebangEnd)
1103
- ]);
1104
1478
  }
1105
1479
  function errorCode(error) {
1106
1480
  return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
@@ -1160,137 +1534,143 @@ function unlinkHookUnchanged(path, expected, stat, spec) {
1160
1534
  assertTargetUnchanged(path, expected, spec, stat);
1161
1535
  unlinkSync2(path);
1162
1536
  }
1163
- function ensureEffectivePostCommitHook(cwd = process.cwd()) {
1164
- const target = resolveEffectivePostCommitHook(cwd);
1165
- return ensureTarget(target, POST_COMMIT_MANAGED_HOOK);
1166
- }
1167
- function ensureEffectivePostRewriteHook(cwd = process.cwd()) {
1168
- const target = resolveEffectivePostRewriteHook(cwd);
1169
- return ensureTarget(target, POST_REWRITE_MANAGED_HOOK);
1537
+ function workingLegacyBlock(content, target, spec) {
1538
+ try {
1539
+ const range = blockRange(content, spec);
1540
+ if (range.kind !== "stale") return false;
1541
+ const block = content.subarray(range.start, range.end).toString("utf8");
1542
+ const from = shellInsertionPoint(content, target.kind === "husky_v9", spec);
1543
+ return isRecognizedLegacyBlock(spec.hookName, block) && !mayTerminateBefore(content, from, range.start);
1544
+ } catch {
1545
+ return false;
1546
+ }
1170
1547
  }
1171
- function ensureTarget(target, spec, initialContent) {
1548
+ var AMBIENT_LOCATIONS = ["repository", "prim"];
1549
+ var EXPLICIT_LOCATIONS = ["repository", "prim", "worktree"];
1550
+ function ensureTarget(target, spec, policy) {
1551
+ const result = { hookName: spec.hookName, path: target.hookPath, kind: target.kind };
1552
+ const skip = (outcome) => ({
1553
+ ...result,
1554
+ changed: false,
1555
+ outcome
1556
+ });
1172
1557
  assertUsableHuskyDispatcher(target, spec);
1173
1558
  const stat = assertSafeFile(target.hookPath, spec);
1174
1559
  if (stat && target.kind === "direct" && (stat.mode & 64) === 0) {
1175
1560
  throw new Error(`existing ${spec.hookName} hook is not executable: ${target.hookPath}`);
1176
1561
  }
1177
1562
  const existing = stat ? readHookFile(target.hookPath) : void 0;
1178
- const next = !existing && initialContent !== void 0 ? Buffer.from(initialContent) : mergedContent(existing, target.kind === "husky_v9", spec);
1179
- if (!existing && initialContent !== void 0) {
1180
- const shebangEnd = shellInsertionPoint(next, target.kind === "husky_v9", spec);
1181
- const range = blockRange(next, spec);
1182
- if (range.kind !== "current" || range.start !== shebangEnd) {
1183
- throw new Error(`invalid initial Prim ${spec.hookName} scaffold`);
1184
- }
1185
- }
1186
- const contentChanged = !existing || !next.equals(existing);
1187
- if (contentChanged) {
1188
- rewriteHookAtomically(target.hookPath, next, existing, spec, stat);
1563
+ if (policy.repairOnly && (!existing || blockRange(existing, spec).kind === "absent")) {
1564
+ return skip("skipped");
1565
+ }
1566
+ const huskyV8 = isHuskyV8Hook(target.hookPath);
1567
+ const next = mergedContent(existing, target.kind === "husky_v9", spec, policy.relocate, huskyV8);
1568
+ if (existing && next.content.equals(existing)) return skip("unchanged");
1569
+ if (!policy.writable(target.location)) {
1570
+ if (target.location === "worktree") return skip("deferred");
1571
+ return skip(existing && workingLegacyBlock(existing, target, spec) ? "kept" : "external");
1572
+ }
1573
+ if (next.migrates && inspectGitHookEntrypoint() !== "ready") return skip("runtime_missing");
1574
+ rewriteHookAtomically(target.hookPath, next.content, existing, spec, stat);
1575
+ return { ...result, changed: true, outcome: existing ? "updated" : "created" };
1576
+ }
1577
+ function ensureEffectiveGitHook(hookName, cwd = process.cwd(), options = {}) {
1578
+ const target = resolveEffectiveGitHook(hookName, cwd);
1579
+ if (gitHooksMode({ cwd: target.gitRoot }) === "manual") {
1580
+ return {
1581
+ hookName,
1582
+ path: target.hookPath,
1583
+ kind: target.kind,
1584
+ changed: false,
1585
+ outcome: "manual"
1586
+ };
1189
1587
  }
1190
- return { path: target.hookPath, changed: contentChanged || !stat, kind: target.kind };
1191
- }
1192
- function ensurePostCommitHookAtPath(hookPath, initialContent) {
1193
- const absolute = resolve3(hookPath);
1194
- const result = ensureTarget(
1195
- {
1196
- gitRoot: dirname3(dirname3(absolute)),
1197
- hooksDir: dirname3(absolute),
1198
- hookPath: absolute,
1199
- kind: "direct"
1200
- },
1201
- POST_COMMIT_MANAGED_HOOK,
1202
- initialContent
1203
- );
1204
- return { ...result, kind: "direct" };
1588
+ const explicit = (options.context ?? "explicit") === "explicit";
1589
+ const allowed = explicit ? EXPLICIT_LOCATIONS : AMBIENT_LOCATIONS;
1590
+ return ensureTarget(target, managedHookSpec(hookName), {
1591
+ writable: (location) => allowed.includes(location),
1592
+ relocate: explicit,
1593
+ repairOnly: options.repairOnly === true
1594
+ });
1205
1595
  }
1206
- function ensurePostRewriteHookAtPath(hookPath, initialContent) {
1596
+ function explicitTarget(hookPath, options) {
1207
1597
  const absolute = resolve3(hookPath);
1208
- const result = ensureTarget(
1209
- {
1210
- gitRoot: dirname3(dirname3(absolute)),
1211
- hooksDir: dirname3(absolute),
1212
- hookPath: absolute,
1213
- kind: "direct"
1214
- },
1215
- POST_REWRITE_MANAGED_HOOK,
1216
- initialContent
1217
- );
1218
- return { ...result, kind: "direct" };
1598
+ return {
1599
+ gitRoot: dirname3(dirname3(absolute)),
1600
+ hooksDir: dirname3(absolute),
1601
+ hookPath: absolute,
1602
+ // A `.husky/<hook>` file is run by Husky's dispatcher via `sh`, so it needs
1603
+ // neither a shebang nor the executable bit.
1604
+ kind: options.husky ? "husky_v9" : "direct",
1605
+ // The caller chose this exact file (a --target, or a consented global dir).
1606
+ location: "external"
1607
+ };
1219
1608
  }
1220
- function inspectEffectivePostCommitHook(cwd = process.cwd()) {
1221
- return inspectEffectiveManagedHook(POST_COMMIT_MANAGED_HOOK, cwd);
1609
+ function hasCurrentHookBlock(hookName, hookPath) {
1610
+ try {
1611
+ const kind = blockRange(readHookFile(hookPath), managedHookSpec(hookName)).kind;
1612
+ return kind === "current" || kind === "newer";
1613
+ } catch {
1614
+ return false;
1615
+ }
1222
1616
  }
1223
- function inspectEffectivePostRewriteHook(cwd = process.cwd()) {
1224
- return inspectEffectiveManagedHook(POST_REWRITE_MANAGED_HOOK, cwd);
1617
+ function ensureGitHookAtPath(hookName, hookPath, options = {}) {
1618
+ return ensureTarget(explicitTarget(hookPath, options), managedHookSpec(hookName), {
1619
+ writable: () => true,
1620
+ relocate: true,
1621
+ repairOnly: false
1622
+ });
1225
1623
  }
1226
- function inspectEffectiveManagedHook(spec, cwd) {
1227
- const target = resolveEffectiveManagedHook(spec, cwd);
1624
+ function inspectEffectiveGitHook(hookName, cwd = process.cwd()) {
1625
+ const spec = managedHookSpec(hookName);
1626
+ const target = resolveEffectiveGitHook(hookName, cwd);
1627
+ const base = {
1628
+ ...target,
1629
+ hookName,
1630
+ mode: gitHooksMode({ cwd: target.gitRoot }),
1631
+ entrypoint: inspectGitHookEntrypoint()
1632
+ };
1633
+ const uncovered = (reason2, executable2 = false) => ({ ...base, covered: false, executable: executable2, current: false, reason: reason2 });
1228
1634
  try {
1229
1635
  assertUsableHuskyDispatcher(target, spec);
1230
1636
  } catch (error) {
1231
1637
  const missing = error instanceof Error && (error.message.includes("missing or not executable") || error.message.includes("unsafe"));
1232
- return {
1233
- ...target,
1234
- covered: false,
1235
- executable: false,
1236
- current: false,
1237
- reason: missing ? "husky_dispatcher_missing" : "husky_dispatcher_invalid"
1238
- };
1638
+ return uncovered(missing ? "husky_dispatcher_missing" : "husky_dispatcher_invalid");
1239
1639
  }
1240
1640
  let stat;
1241
1641
  try {
1242
1642
  stat = assertSafeFile(target.hookPath, spec);
1243
1643
  } catch {
1244
- return {
1245
- ...target,
1246
- covered: false,
1247
- executable: false,
1248
- current: false,
1249
- reason: "unsafe_target"
1250
- };
1251
- }
1252
- if (!stat) {
1253
- return {
1254
- ...target,
1255
- covered: false,
1256
- executable: false,
1257
- current: false,
1258
- reason: "missing"
1259
- };
1644
+ return uncovered("unsafe_target");
1260
1645
  }
1646
+ if (!stat) return uncovered("missing");
1261
1647
  const executable = target.kind === "husky_v9" || (stat.mode & 64) !== 0;
1648
+ let range;
1649
+ let content;
1650
+ let insertion;
1262
1651
  try {
1263
- const content = readHookFile(target.hookPath);
1264
- const shebangEnd = shellInsertionPoint(content, target.kind === "husky_v9", spec);
1265
- const range = blockRange(content, spec);
1266
- const positioned = range.kind !== "absent" && range.start === shebangEnd;
1267
- const current = range.kind === "current" && positioned;
1268
- return {
1269
- ...target,
1270
- covered: current && executable,
1271
- executable,
1272
- current,
1273
- reason: range.kind === "current" && !positioned ? "unreachable_block" : !current ? "stale_block" : executable ? void 0 : "not_executable"
1274
- };
1652
+ content = readHookFile(target.hookPath);
1653
+ insertion = shellInsertionPoint(content, target.kind === "husky_v9", spec);
1654
+ range = blockRange(content, spec);
1275
1655
  } catch (error) {
1276
1656
  const message = error instanceof Error ? error.message : "";
1277
- const reason = message.includes("binary") ? "binary" : message.includes("unsupported") ? "unsupported_interpreter" : "malformed_markers";
1278
- return {
1279
- ...target,
1280
- covered: false,
1281
- executable,
1282
- current: false,
1283
- reason
1284
- };
1657
+ return uncovered(
1658
+ message.includes("binary") ? "binary" : message.includes("unsupported") ? "unsupported_interpreter" : "malformed_markers",
1659
+ executable
1660
+ );
1285
1661
  }
1286
- }
1287
- function uninstallEffectivePostCommitHook(cwd = process.cwd()) {
1288
- const target = resolveEffectivePostCommitHook(cwd);
1289
- return uninstallTarget(target, POST_COMMIT_MANAGED_HOOK);
1290
- }
1291
- function uninstallEffectivePostRewriteHook(cwd = process.cwd()) {
1292
- const target = resolveEffectivePostRewriteHook(cwd);
1293
- return uninstallTarget(target, POST_REWRITE_MANAGED_HOOK);
1662
+ const huskyV8 = isHuskyV8Hook(target.hookPath);
1663
+ const preferred = blockInsertionPoint(content, target.kind === "husky_v9", spec, huskyV8);
1664
+ const current = range.kind === "current" || range.kind === "newer";
1665
+ const legacy = range.kind === "stale" && workingLegacyBlock(content, target, spec);
1666
+ const reason = range.kind === "absent" ? "missing_block" : range.kind === "stale" && !legacy ? "stale_block" : terminatesBefore(content, insertion, range.start) ? "unreachable_block" : !executable ? "not_executable" : legacy ? "legacy_block" : base.entrypoint !== "ready" ? "entrypoint_missing" : range.start < preferred ? "misplaced_block" : void 0;
1667
+ return {
1668
+ ...base,
1669
+ covered: reason === void 0,
1670
+ executable,
1671
+ current,
1672
+ ...reason ? { reason } : {}
1673
+ };
1294
1674
  }
1295
1675
  function projectHooksPathIsConfigured(gitRoot) {
1296
1676
  for (const scope of ["--worktree", "--local"]) {
@@ -1307,45 +1687,53 @@ function projectHooksPathIsConfigured(gitRoot) {
1307
1687
  }
1308
1688
  return false;
1309
1689
  }
1310
- function uninstallProjectPostCommitHook(cwd = process.cwd()) {
1311
- const gitRoot = gitToplevel(cwd);
1312
- if (!gitRoot) throw new Error("not a git repository");
1313
- if (projectHooksPathIsConfigured(gitRoot)) {
1314
- return uninstallEffectivePostCommitHook(gitRoot);
1690
+ function uninstallProjectGitHook(hookName, cwd = process.cwd()) {
1691
+ const target = projectGitHookTarget(hookName, cwd);
1692
+ if (target.location === "external") {
1693
+ return { path: target.hookPath, changed: false, removedFile: false, skipped: "external" };
1315
1694
  }
1316
- const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
1317
- cwd: gitRoot,
1318
- encoding: "utf8",
1319
- stdio: ["ignore", "pipe", "ignore"],
1320
- timeout: GIT_TIMEOUT_MS
1321
- }).replace(/\r?\n$/u, "");
1322
- return uninstallPostCommitHookAtPath(
1323
- resolve3(safeGitPath(gitRoot, commonDir), "hooks", "post-commit")
1324
- );
1695
+ return uninstallTarget(target, managedHookSpec(hookName));
1325
1696
  }
1326
- function uninstallProjectPostRewriteHook(cwd = process.cwd()) {
1697
+ function projectGitHookTarget(hookName, cwd = process.cwd()) {
1327
1698
  const gitRoot = gitToplevel(cwd);
1328
1699
  if (!gitRoot) throw new Error("not a git repository");
1329
- if (projectHooksPathIsConfigured(gitRoot)) {
1330
- return uninstallEffectivePostRewriteHook(gitRoot);
1331
- }
1700
+ if (projectHooksPathIsConfigured(gitRoot)) return resolveEffectiveGitHook(hookName, gitRoot);
1701
+ return {
1702
+ ...explicitTarget(resolve3(projectHooksDir(gitRoot), hookName), {}),
1703
+ gitRoot,
1704
+ location: "repository"
1705
+ };
1706
+ }
1707
+ function projectHooksDir(gitRoot) {
1332
1708
  const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
1333
1709
  cwd: gitRoot,
1334
1710
  encoding: "utf8",
1335
1711
  stdio: ["ignore", "pipe", "ignore"],
1336
1712
  timeout: GIT_TIMEOUT_MS
1337
1713
  }).replace(/\r?\n$/u, "");
1338
- return uninstallPostRewriteHookAtPath(
1339
- resolve3(safeGitPath(gitRoot, commonDir), "hooks", POST_REWRITE_MANAGED_HOOK.hookName)
1340
- );
1714
+ return resolve3(safeGitPath(gitRoot, commonDir), "hooks");
1715
+ }
1716
+ function uninstallGitHookAtPath(hookName, hookPath, options = {}) {
1717
+ return uninstallTarget(explicitTarget(hookPath, options), managedHookSpec(hookName));
1341
1718
  }
1342
1719
  function uninstallTarget(target, spec) {
1720
+ const unchanged = { path: target.hookPath, changed: false, removedFile: false };
1343
1721
  const stat = assertSafeFile(target.hookPath, spec);
1344
- if (!stat) return { path: target.hookPath, changed: false, removedFile: false };
1722
+ if (!stat) return unchanged;
1345
1723
  const existing = readHookFile(target.hookPath);
1724
+ const remove = () => {
1725
+ unlinkHookUnchanged(target.hookPath, existing, stat, spec);
1726
+ return { path: target.hookPath, changed: true, removedFile: true };
1727
+ };
1728
+ const rewrite = (next) => {
1729
+ rewriteHookAtomically(target.hookPath, next, existing, spec, stat);
1730
+ return { path: target.hookPath, changed: true, removedFile: false };
1731
+ };
1346
1732
  const range = blockRange(existing, spec);
1347
1733
  if (range.kind === "absent") {
1348
- if (spec.hookName === POST_COMMIT_MANAGED_HOOK.hookName && existing.toString("utf8").startsWith(`#!/bin/sh
1734
+ const text = existing.toString("utf8");
1735
+ if (spec.hookName === "pre-commit" && isOwnedStandalonePreCommit(text)) return remove();
1736
+ if (spec.hookName === "post-commit" && text.startsWith(`#!/bin/sh
1349
1737
  ${LEGACY_PRIM_OWNED_HEADER}
1350
1738
  `)) {
1351
1739
  const prefixLength = legacyProjectPrefixLength(existing);
@@ -1353,20 +1741,9 @@ ${LEGACY_PRIM_OWNED_HEADER}
1353
1741
  throw new Error("unrecognized legacy Prim post-commit invocation");
1354
1742
  }
1355
1743
  const tail = existing.subarray(prefixLength);
1356
- if (tail.length === 0) {
1357
- unlinkHookUnchanged(target.hookPath, existing, stat, spec);
1358
- return { path: target.hookPath, changed: true, removedFile: true };
1359
- }
1360
- rewriteHookAtomically(
1361
- target.hookPath,
1362
- Buffer.concat([Buffer.from("#!/bin/sh\n"), tail]),
1363
- existing,
1364
- spec,
1365
- stat
1366
- );
1367
- return { path: target.hookPath, changed: true, removedFile: false };
1744
+ return tail.length === 0 ? remove() : rewrite(Buffer.concat([Buffer.from("#!/bin/sh\n"), tail]));
1368
1745
  }
1369
- return { path: target.hookPath, changed: false, removedFile: false };
1746
+ return unchanged;
1370
1747
  }
1371
1748
  const shebangEnd = shellInsertionPoint(existing, target.kind === "husky_v9", spec);
1372
1749
  const suffix = existing.subarray(range.end);
@@ -1375,77 +1752,16 @@ ${LEGACY_PRIM_OWNED_HEADER}
1375
1752
  `);
1376
1753
  if (range.start === shebangEnd && suffix.subarray(0, createdSuffix.length).equals(createdSuffix)) {
1377
1754
  const tail = suffix.subarray(createdSuffix.length);
1378
- if (tail.length === 0) {
1379
- unlinkHookUnchanged(target.hookPath, existing, stat, spec);
1380
- return { path: target.hookPath, changed: true, removedFile: true };
1381
- }
1382
- rewriteHookAtomically(
1383
- target.hookPath,
1384
- Buffer.concat([existing.subarray(0, shebangEnd), tail]),
1385
- existing,
1386
- spec,
1387
- stat
1388
- );
1389
- return { path: target.hookPath, changed: true, removedFile: false };
1755
+ return tail.length === 0 ? remove() : rewrite(Buffer.concat([existing.subarray(0, shebangEnd), tail]));
1390
1756
  }
1391
- const createdPrefixes = [
1392
- Buffer.from(`#!/bin/sh
1393
- # ${spec.createdMark}
1394
-
1395
- `),
1396
- ...spec.hookName === POST_COMMIT_MANAGED_HOOK.hookName ? [Buffer.from(`#!/bin/sh
1397
- # ${LEGACY_PRIM_CREATED_MARK}
1398
-
1399
- `)] : []
1400
- ];
1401
- const createdPrefix = createdPrefixes.find(
1757
+ const createdPrefix = oldCreatedPrefixes(spec).find(
1402
1758
  (prefix) => range.start === prefix.length && existing.subarray(0, prefix.length).equals(prefix)
1403
1759
  );
1404
1760
  if (createdPrefix) {
1405
1761
  const tail = suffix.at(0) === 10 ? suffix.subarray(1) : suffix;
1406
- if (tail.length === 0) {
1407
- unlinkHookUnchanged(target.hookPath, existing, stat, spec);
1408
- return { path: target.hookPath, changed: true, removedFile: true };
1409
- }
1410
- rewriteHookAtomically(
1411
- target.hookPath,
1412
- Buffer.concat([Buffer.from("#!/bin/sh\n"), tail]),
1413
- existing,
1414
- spec,
1415
- stat
1416
- );
1417
- return { path: target.hookPath, changed: true, removedFile: false };
1762
+ return tail.length === 0 ? remove() : rewrite(Buffer.concat([Buffer.from("#!/bin/sh\n"), tail]));
1418
1763
  }
1419
- const next = Buffer.concat([
1420
- existing.subarray(0, range.start),
1421
- range.start === shebangEnd && suffix.at(0) === 10 ? suffix.subarray(1) : suffix
1422
- ]);
1423
- rewriteHookAtomically(target.hookPath, next, existing, spec, stat);
1424
- return { path: target.hookPath, changed: true, removedFile: false };
1425
- }
1426
- function uninstallPostCommitHookAtPath(hookPath) {
1427
- const absolute = resolve3(hookPath);
1428
- return uninstallTarget(
1429
- {
1430
- gitRoot: dirname3(dirname3(absolute)),
1431
- hooksDir: dirname3(absolute),
1432
- hookPath: absolute,
1433
- kind: "direct"
1434
- },
1435
- POST_COMMIT_MANAGED_HOOK
1436
- );
1437
- }
1438
- function uninstallPostRewriteHookAtPath(hookPath) {
1439
- const absolute = resolve3(hookPath);
1440
- return uninstallTarget(
1441
- {
1442
- gitRoot: dirname3(dirname3(absolute)),
1443
- hooksDir: dirname3(absolute),
1444
- hookPath: absolute,
1445
- kind: "direct"
1446
- },
1447
- POST_REWRITE_MANAGED_HOOK
1448
- );
1764
+ return rewrite(withoutRange(existing, range.start, range.end));
1449
1765
  }
1450
1766
 
1451
1767
  // src/lib/repository-binding.ts
@@ -1537,18 +1853,19 @@ function kickDaemonEnsure(options = {}) {
1537
1853
 
1538
1854
  export {
1539
1855
  warmBinCache,
1540
- postCommitHookBlock,
1541
- postRewriteHookBlock,
1542
- ensureEffectivePostCommitHook,
1543
- ensureEffectivePostRewriteHook,
1544
- ensurePostCommitHookAtPath,
1545
- ensurePostRewriteHookAtPath,
1546
- inspectEffectivePostCommitHook,
1547
- inspectEffectivePostRewriteHook,
1548
- uninstallProjectPostCommitHook,
1549
- uninstallProjectPostRewriteHook,
1550
- uninstallPostCommitHookAtPath,
1551
- uninstallPostRewriteHookAtPath,
1856
+ isLegacyOwnedGlobalHook,
1857
+ gitHooksMode,
1858
+ isOwnedStandalonePreCommit,
1859
+ externalHookRemedy,
1860
+ resolveEffectiveGitHook,
1861
+ ensureEffectiveGitHook,
1862
+ hasCurrentHookBlock,
1863
+ ensureGitHookAtPath,
1864
+ inspectEffectiveGitHook,
1865
+ uninstallProjectGitHook,
1866
+ projectGitHookTarget,
1867
+ projectHooksDir,
1868
+ uninstallGitHookAtPath,
1552
1869
  resolveRepositoryBinding,
1553
1870
  bindRepositoryWithClient,
1554
1871
  bindRepository,