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

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.
@@ -11,17 +11,25 @@ import {
11
11
  getClient
12
12
  } from "./chunk-G2VMA2QO.js";
13
13
  import {
14
+ GIT_HOOK_CONTRACT_VERSION,
14
15
  binFile,
16
+ blockMarkers,
17
+ canonicalHookBlock,
15
18
  compareSemver,
16
19
  cursorConfigDirectory,
20
+ hookBlockContractVersion,
21
+ inspectGitHookEntrypoint,
22
+ managedHookBlock,
23
+ pinnedHookCommand,
17
24
  pinnedNpxCommand
18
- } from "./chunk-X64HTHUF.js";
25
+ } from "./chunk-AXQQMX2U.js";
19
26
  import {
20
27
  gitToplevel,
21
28
  githubRepositoryFullName
22
29
  } from "./chunk-H4UNYFUC.js";
23
30
  import {
24
31
  atomicWriteFile,
32
+ primConfigDirectory,
25
33
  withFileLock
26
34
  } from "./chunk-2GP5UFV7.js";
27
35
 
@@ -560,8 +568,8 @@ function applyBlock(existing, block, eol) {
560
568
  return existing.slice(0, b) + block + existing.slice(e + SKILL_END.length);
561
569
  }
562
570
  if (existing.length === 0) return `${block}${eol}`;
563
- const sep2 = existing.endsWith(eol) ? "" : eol;
564
- return `${existing}${sep2}${block}${eol}`;
571
+ const sep3 = existing.endsWith(eol) ? "" : eol;
572
+ return `${existing}${sep3}${block}${eol}`;
565
573
  }
566
574
  function removeBlock(existing) {
567
575
  const b = existing.indexOf(SKILL_BEGIN);
@@ -701,60 +709,28 @@ function registerSkillCommands(program) {
701
709
  });
702
710
  }
703
711
 
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}
712
+ // src/lib/git-hook-legacy.ts
713
+ import { isAbsolute as isAbsolute2 } from "path";
714
+ var PRIM_MANAGED_MARK = "prim-managed-hook";
715
+ var PRE_COMMIT = { hookName: "pre-commit", binName: "prim-pre-commit" };
716
+ var POST_COMMIT = { hookName: "post-commit", binName: "prim-post-commit" };
717
+ var POST_REWRITE = { hookName: "post-rewrite", binName: "prim-post-rewrite" };
718
+ function blockMarkers2(spec) {
719
+ return {
720
+ start: `# >>> prim ${spec.hookName} hook >>>`,
721
+ end: `# <<< prim ${spec.hookName} hook <<<`
722
+ };
723
+ }
724
+ function hookShim(binName) {
725
+ return `{ ${pinnedHookCommand(binName)}; } || true`;
726
+ }
727
+ function gatedShim(binName) {
728
+ return `if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then
729
+ ${hookShim(binName)}
730
+ fi`;
731
+ }
732
+ function legacyPostCommitBlock() {
733
+ return `# >>> prim post-commit hook >>>
758
734
  if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then
759
735
  prim_commit_sha=$(git rev-parse --verify HEAD 2>/dev/null) || prim_commit_sha=
760
736
  case "$prim_commit_sha" in *[!0-9a-f]*|"") prim_commit_sha= ;; esac
@@ -783,13 +759,10 @@ if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then
783
759
  fi
784
760
  unset prim_commit_sha
785
761
  fi
786
- ${PRIM_POST_COMMIT_BLOCK_END}`;
762
+ # <<< prim post-commit hook <<<`;
787
763
  }
788
- function postCommitHookBlock() {
789
- return currentPostCommitBlock();
790
- }
791
- function postRewriteHookBlock() {
792
- return `${PRIM_POST_REWRITE_BLOCK_START}
764
+ function legacyPostRewriteBlock() {
765
+ return `# >>> prim post-rewrite hook >>>
793
766
  if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then
794
767
  case "$1" in amend|rebase) prim_rewrite_source="$1" ;; *) prim_rewrite_source= ;; esac
795
768
  if [ -n "$prim_rewrite_source" ]; then
@@ -828,22 +801,227 @@ if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then
828
801
  fi
829
802
  unset prim_rewrite_source
830
803
  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
804
+ # <<< prim post-rewrite hook <<<`;
805
+ }
806
+ function legacyGlobalHookScript(spec) {
807
+ const chainExit = spec.hookName === PRE_COMMIT.hookName ? "|| exit $?" : "|| true";
808
+ const managedBlock = spec.hookName === POST_COMMIT.hookName ? legacyPostCommitBlock() : spec.hookName === POST_REWRITE.hookName ? legacyPostRewriteBlock() : void 0;
809
+ const invocation = managedBlock ?? gatedShim(spec.binName);
810
+ const managedRepoGuard = managedBlock ? ` && ! grep -Fq '${blockMarkers2(spec).start.slice(0, -3)}'">>>" "$repo_hook" 2>/dev/null` : "";
811
+ const beforeComments = managedBlock ? `${invocation}
812
+ ` : "";
813
+ const afterComments = managedBlock ? "" : `${invocation}
814
+ `;
815
+ return `#!/bin/sh
816
+ ${beforeComments}# prim global ${spec.hookName} hook (core.hooksPath) \u2014 managed by prim; do not edit.
817
+ # Install/uninstall: prim hooks install|uninstall --scope user
818
+ # Runs prim only where activated \u2014 'prim enable' (this repo) or
819
+ # 'git config --global prim.active true' (every repo). Chains to the repo's own
820
+ # hook regardless, so inactive repos are unaffected.
821
+ ${afterComments}common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0
822
+ repo_hook="$common_dir/hooks/${spec.hookName}"
823
+ if [ -x "$repo_hook" ] && ! grep -q '${PRIM_MANAGED_MARK}' "$repo_hook" 2>/dev/null${managedRepoGuard}; then
824
+ "$repo_hook" "$@" ${chainExit}
825
+ fi
826
+ exit 0
827
+ `;
828
+ }
829
+ var PRE_COMMIT_GATE = 'if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then';
830
+ function preCommitLadder(fallback, failSoft) {
831
+ const soft = failSoft ? " || true" : "";
832
+ return `if command -v prim-pre-commit >/dev/null 2>&1; then
833
+ prim-pre-commit${soft}
834
+ elif [ -f "./node_modules/.bin/prim-pre-commit" ]; then
835
+ ./node_modules/.bin/prim-pre-commit${soft}
836
+ else
837
+ ${fallback} 2>/dev/null || true
838
+ fi`;
839
+ }
840
+ var LADDER_ALPHA_2 = preCommitLadder("npx --yes @primitive.ai/prim pre-commit-hook", false);
841
+ var LADDER_ALPHA_9 = preCommitLadder("npx --yes -p @primitive.ai/prim prim-pre-commit", false);
842
+ var LADDER_ALPHA_35 = preCommitLadder("npx --yes -p @primitive.ai/prim prim-pre-commit", true);
843
+ 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`;
844
+ var PINNED_PRE_COMMIT_RE = new RegExp(
845
+ String.raw`^\{ (?:${PINNED_NPX_PRE_COMMIT}|if \[ -x (?<node>'.*') \] && \[ -f (?<entry>'.*') \]; then \k<node> \k<entry>; else ${PINNED_NPX_PRE_COMMIT}; fi); \} \|\| true$`,
846
+ "u"
847
+ );
848
+ var SHELL_QUOTED_RE = /^'(?:[^']|'"'"')*'$/u;
849
+ function decodePinnedPath(token) {
850
+ if (!token || !SHELL_QUOTED_RE.test(token)) return void 0;
851
+ const value = token.slice(1, -1).replaceAll(`'"'"'`, "'");
852
+ if (!isAbsolute2(value) || value.includes("\0") || value.includes("\n") || value.includes("\r")) {
853
+ return void 0;
854
+ }
855
+ return value;
856
+ }
857
+ function isPinnedPreCommitInvocation(line) {
858
+ const match = PINNED_PRE_COMMIT_RE.exec(line);
859
+ if (!match) return false;
860
+ if (match.groups?.node === void 0) return true;
861
+ return Boolean(
862
+ decodePinnedPath(match.groups.node) && decodePinnedPath(match.groups.entry)?.endsWith("/dist/hooks/pre-commit.js")
863
+ );
864
+ }
865
+ function isLegacyPreCommitBody(body) {
866
+ if (body === LADDER_ALPHA_2 || body === LADDER_ALPHA_9 || body === LADDER_ALPHA_35) return true;
867
+ if (isPinnedPreCommitInvocation(body)) return true;
868
+ const prefix = `${PRE_COMMIT_GATE}
869
+ `;
870
+ if (!body.startsWith(prefix) || !body.endsWith("\nfi")) return false;
871
+ const inner = body.slice(prefix.length, -"\nfi".length);
872
+ return inner === LADDER_ALPHA_35 || isPinnedPreCommitInvocation(inner);
873
+ }
874
+ function isLegacyOwnedPreCommitScript(content) {
875
+ const original = (ladder) => `#!/bin/sh
876
+ # prim pre-commit hook \u2014 auto-syncs affected specs on commit
877
+ # Installed by: prim hooks install
878
+
879
+ # Find the nearest node_modules/.bin with prim, or use npx
880
+ ${ladder}
881
+ `;
882
+ if (content === original(LADDER_ALPHA_2) || content === original(LADDER_ALPHA_9)) return true;
883
+ if (content === `#!/bin/sh
884
+ # prim pre-commit hook \u2014 installed by: prim hooks install
885
+
886
+ ${LADDER_ALPHA_9}
887
+ `) {
888
+ return true;
889
+ }
890
+ const prefix = `#!/bin/sh
891
+ # prim pre-commit hook \u2014 installed by: prim hooks install (${PRIM_MANAGED_MARK})
892
+
893
+ `;
894
+ if (!content.startsWith(prefix) || !content.endsWith("\n")) return false;
895
+ const body = content.slice(prefix.length, -1);
896
+ return body === LADDER_ALPHA_35 || isPinnedPreCommitInvocation(body);
897
+ }
898
+ var PINNED_PACKAGE_VERSION_RE = /@primitive\.ai\/prim@[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/gu;
899
+ function normalizeOwnedGlobalHook(content, hookName) {
900
+ if (hookName === PRE_COMMIT.hookName) {
901
+ const lines = content.split("\n");
902
+ const invocationIndexes = lines.flatMap(
903
+ (line, index) => line.startsWith("{ ") && line.endsWith("; } || true") ? [index] : []
904
+ );
905
+ if (invocationIndexes.length !== 1) return null;
906
+ const invocationIndex = invocationIndexes[0];
907
+ if (!isPinnedPreCommitInvocation(lines[invocationIndex])) return null;
908
+ lines[invocationIndex] = "{ <recognized Prim pre-commit invocation>; } || true";
909
+ return lines.join("\n");
910
+ }
911
+ return content.replace(PINNED_PACKAGE_VERSION_RE, "@primitive.ai/prim@<version>");
912
+ }
913
+ function isLegacyOwnedGlobalHook(content, hookName) {
914
+ const spec = [PRE_COMMIT, POST_COMMIT, POST_REWRITE].find((s) => s.hookName === hookName);
915
+ if (!spec) return false;
916
+ const expected = legacyGlobalHookScript(spec);
917
+ if (content === expected) return true;
918
+ if (spec === PRE_COMMIT && content === expected.replace(gatedShim(spec.binName), `${PRE_COMMIT_GATE}
919
+ ${LADDER_ALPHA_35}
920
+ fi`)) {
921
+ return true;
922
+ }
923
+ const normalized = normalizeOwnedGlobalHook(content, hookName);
924
+ return normalized !== null && normalized === normalizeOwnedGlobalHook(expected, hookName);
925
+ }
926
+ function legacyInlineHookBlock(hookName) {
927
+ return hookName === "post-commit" ? legacyPostCommitBlock() : legacyPostRewriteBlock();
928
+ }
929
+ function isRecognizedLegacyBlock(hookName, block) {
930
+ if (hookName === "pre-commit") {
931
+ const { start, end } = blockMarkers2(PRE_COMMIT);
932
+ const prefix = `${start}
933
+ `;
934
+ const suffix = `
935
+ ${end}`;
936
+ if (!block.startsWith(prefix) || !block.endsWith(suffix)) return false;
937
+ return isLegacyPreCommitBody(block.slice(prefix.length, -suffix.length));
938
+ }
939
+ const expected = legacyInlineHookBlock(hookName);
940
+ const normalize = (text) => text.replace(PINNED_PACKAGE_VERSION_RE, "@primitive.ai/prim@<version>");
941
+ return normalize(block) === normalize(expected);
942
+ }
943
+
944
+ // src/lib/git-hooks.ts
945
+ import { execFileSync } from "child_process";
946
+ import { createHash as createHash3, randomUUID } from "crypto";
947
+ import {
948
+ constants as constants3,
949
+ closeSync as closeSync3,
950
+ existsSync as existsSync3,
951
+ fchmodSync,
952
+ fsyncSync,
953
+ lstatSync as lstatSync3,
954
+ mkdirSync as mkdirSync3,
955
+ openSync as openSync3,
956
+ readFileSync as readFileSync3,
957
+ realpathSync as realpathSync2,
958
+ renameSync,
959
+ unlinkSync as unlinkSync2,
960
+ writeFileSync
961
+ } from "fs";
962
+ import { basename, dirname as dirname3, isAbsolute as isAbsolute3, join as join4, relative as relative2, resolve as resolve3, sep as sep2 } from "path";
963
+ var LEGACY_PRIM_CREATED_MARK = "prim-created-hook";
964
+ var LEGACY_PRIM_OWNED_HEADER = "# prim post-commit hook \u2014 installed by: prim hooks install (prim-managed-hook)";
965
+ var LEGACY_GLOBAL_OWNED_HEADER = "# prim global post-commit hook (core.hooksPath) \u2014 managed by prim; do not edit.";
966
+ var MAX_HOOK_BYTES = 1048576;
967
+ var GIT_TIMEOUT_MS = 1e3;
968
+ var SUPPORTED_SHEBANGS = /* @__PURE__ */ new Set([
969
+ "#!/bin/sh",
970
+ "#!/usr/bin/env sh",
971
+ "#!/bin/bash",
972
+ "#!/usr/bin/env bash",
973
+ "#!/bin/zsh",
974
+ "#!/usr/bin/env zsh"
975
+ ]);
976
+ var HUSKY_V9_DISPATCHERS = /* @__PURE__ */ new Set([
977
+ '#!/usr/bin/env sh\n. "${0%/*}/h"',
978
+ '#!/usr/bin/env sh\n. "$(dirname "$0")/h"'
979
+ ]);
980
+ var HUSKY_DIRECT_DISPATCHER = '#!/bin/sh\nexec sh "$(dirname "$0")/../post-commit"\n';
981
+ var HUSKY_V9_RUNTIME_HASHES = /* @__PURE__ */ new Set([
982
+ "d6aaa6f3f7c11008c525e649161df4a93152f1e55d0887fa1d7b8d622735d380",
983
+ "b02cb91cab7125a9e906be723a1b85297fc435a8307d429ae845546b4ee0ecde",
984
+ "9db5d129c4eb822a773157d98cd8fb8f1b156b85e3193117fefe527ca6d45e91",
985
+ "3b2cb21335e544b4ebce658ca47af4a69d2c77026cc4d3f8b2227c34d062b207",
986
+ "548990e1c11285694096184993d907b258d41235461a7ca2551e3b53ff9d3a38",
987
+ "c029a943acea5d6e2ddbaa271a1fb22c2da55d56b5c6fb59ca804ac9a4018708",
988
+ "ae3413a8fe2b39372de48bdc9691f42055f089c0c7529834b2fb07a131585b6a",
989
+ "70200b200ca709b0622784f93839a5b2872333a917a09afddefd7dc2d8cdc680"
990
+ ]);
991
+ function managedHookSpecFor(hookName) {
992
+ const { start, end } = blockMarkers(hookName);
993
+ return {
994
+ hookName,
995
+ blockStart: start,
996
+ blockEnd: end,
997
+ createdMark: `prim-created-${hookName}-hook`
998
+ };
999
+ }
1000
+ var MANAGED_HOOK_SPECS = {
1001
+ "pre-commit": managedHookSpecFor("pre-commit"),
1002
+ "post-commit": managedHookSpecFor("post-commit"),
1003
+ "post-rewrite": managedHookSpecFor("post-rewrite")
846
1004
  };
1005
+ function managedHookSpec(hookName) {
1006
+ return MANAGED_HOOK_SPECS[hookName];
1007
+ }
1008
+ function gitHooksMode(options = {}) {
1009
+ try {
1010
+ const value = execFileSync(
1011
+ "git",
1012
+ ["config", ...options.global ? ["--global"] : [], "--get", "prim.gitHooks"],
1013
+ {
1014
+ cwd: options.cwd,
1015
+ encoding: "utf8",
1016
+ stdio: ["ignore", "pipe", "ignore"],
1017
+ timeout: GIT_TIMEOUT_MS
1018
+ }
1019
+ ).trim();
1020
+ return value === "manual" ? "manual" : "auto";
1021
+ } catch {
1022
+ return "auto";
1023
+ }
1024
+ }
847
1025
  function legacyFloatingInvocation() {
848
1026
  return `if command -v prim-post-commit >/dev/null 2>&1; then
849
1027
  prim-post-commit || true
@@ -860,15 +1038,6 @@ fi
860
1038
  `);
861
1039
  }
862
1040
  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
1041
  function isPinnedInvocation(value) {
873
1042
  const match = PINNED_INVOCATION_RE.exec(value);
874
1043
  const node = decodePinnedPath(match?.groups?.node);
@@ -901,48 +1070,149 @@ function isKnownLegacyGlobalGate(value) {
901
1070
  }
902
1071
  function createdScaffold(spec) {
903
1072
  return Buffer.from(`#!/bin/sh
904
- ${spec.block()}
1073
+ ${managedHookBlock(spec.hookName)}
905
1074
  # ${spec.createdMark}
906
1075
  `);
907
1076
  }
1077
+ function oldCreatedPrefixes(spec) {
1078
+ return [
1079
+ Buffer.from(`#!/bin/sh
1080
+ # ${spec.createdMark}
1081
+
1082
+ `),
1083
+ Buffer.from(`#!/bin/sh
1084
+ # ${LEGACY_PRIM_CREATED_MARK}
1085
+
1086
+ `)
1087
+ ];
1088
+ }
1089
+ function isOwnedStandalonePreCommit(content) {
1090
+ return content === "#!/bin/sh\nprim-pre-commit\n" || isLegacyOwnedPreCommitScript(content);
1091
+ }
1092
+ function realpathLoose(path) {
1093
+ const missing = [];
1094
+ let current = resolve3(path);
1095
+ for (; ; ) {
1096
+ try {
1097
+ return join4(realpathSync2.native(current), ...missing);
1098
+ } catch {
1099
+ const parent = dirname3(current);
1100
+ if (parent === current) return resolve3(path);
1101
+ missing.unshift(basename(current));
1102
+ current = parent;
1103
+ }
1104
+ }
1105
+ }
1106
+ function isWithin(path, directory) {
1107
+ const rel = relative2(directory, path);
1108
+ return rel === "" || !isAbsolute3(rel) && rel !== ".." && !rel.startsWith(`..${sep2}`);
1109
+ }
1110
+ function primGitHooksDirectory() {
1111
+ try {
1112
+ return join4(primConfigDirectory(), "git-hooks");
1113
+ } catch {
1114
+ return void 0;
1115
+ }
1116
+ }
1117
+ function otherWorktreeRoots(gitRoot) {
1118
+ try {
1119
+ const self = realpathLoose(gitRoot);
1120
+ return execFileSync("git", ["worktree", "list", "--porcelain"], {
1121
+ cwd: gitRoot,
1122
+ encoding: "utf8",
1123
+ stdio: ["ignore", "pipe", "ignore"],
1124
+ timeout: GIT_TIMEOUT_MS
1125
+ }).split("\n").flatMap((line) => line.startsWith("worktree ") ? [realpathLoose(line.slice(9))] : []).filter((root) => root !== self);
1126
+ } catch {
1127
+ return [];
1128
+ }
1129
+ }
1130
+ function hookLocation(hookPath, dirs) {
1131
+ const path = realpathLoose(hookPath);
1132
+ if (isWithin(path, realpathLoose(dirs.gitDir)) || isWithin(path, realpathLoose(dirs.commonDir))) {
1133
+ return "repository";
1134
+ }
1135
+ const primDir = primGitHooksDirectory();
1136
+ if (primDir !== void 0 && isWithin(path, realpathLoose(primDir))) return "prim";
1137
+ const root = realpathLoose(dirs.gitRoot);
1138
+ if (!isWithin(path, root)) return "external";
1139
+ const nested = otherWorktreeRoots(dirs.gitRoot).filter((other) => isWithin(other, root));
1140
+ return nested.some((other) => isWithin(path, other)) ? "external" : "worktree";
1141
+ }
1142
+ function hooksPathScope(cwd) {
1143
+ try {
1144
+ const scope = execFileSync("git", ["config", "--show-scope", "--get", "core.hooksPath"], {
1145
+ cwd,
1146
+ encoding: "utf8",
1147
+ stdio: ["ignore", "pipe", "ignore"],
1148
+ timeout: GIT_TIMEOUT_MS
1149
+ }).split(" ")[0];
1150
+ return scope === "local" || scope === "worktree" || scope === "global" || scope === "system" || scope === "command" ? scope : void 0;
1151
+ } catch {
1152
+ return void 0;
1153
+ }
1154
+ }
1155
+ function externalHookRemedy(hookName, cwd) {
1156
+ const snippet = `place \`prim hooks snippet ${hookName}\` there yourself`;
1157
+ switch (hooksPathScope(cwd)) {
1158
+ case "global":
1159
+ 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)`;
1160
+ case "system":
1161
+ return `the system core.hooksPath is shared by every user on this machine: ${snippet}, or ask its owner`;
1162
+ case "local":
1163
+ case "worktree":
1164
+ return `this repository's core.hooksPath points outside it: ${snippet}, or point core.hooksPath inside the repository`;
1165
+ default:
1166
+ return snippet;
1167
+ }
1168
+ }
908
1169
  function safeGitPath(root, value) {
909
1170
  if (value.length === 0 || value !== value.trim() || value.includes("\0") || value.includes("\n") || value.includes("\r")) {
910
1171
  throw new Error("Git returned an unsafe hooks path");
911
1172
  }
912
- return isAbsolute2(value) ? resolve3(value) : resolve3(root, value);
1173
+ return isAbsolute3(value) ? resolve3(value) : resolve3(root, value);
913
1174
  }
914
- function resolveEffectiveManagedHook(spec, cwd = process.cwd()) {
1175
+ function resolveEffectiveGitHook(hookName, cwd = process.cwd()) {
915
1176
  const gitRoot = gitToplevel(cwd);
916
1177
  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);
1178
+ const lines = execFileSync(
1179
+ "git",
1180
+ ["rev-parse", "--git-path", "hooks", "--absolute-git-dir", "--git-common-dir"],
1181
+ {
1182
+ cwd: gitRoot,
1183
+ encoding: "utf8",
1184
+ stdio: ["ignore", "pipe", "ignore"],
1185
+ timeout: GIT_TIMEOUT_MS
1186
+ }
1187
+ ).replace(/\r?\n$/u, "").split(/\r?\n/u);
1188
+ if (lines.length !== 3) throw new Error("Git returned unexpected repository paths");
1189
+ const [hooksValue, gitDirValue, commonDirValue] = lines;
1190
+ const hooksDir = safeGitPath(gitRoot, hooksValue);
1191
+ const dirs = {
1192
+ gitRoot,
1193
+ gitDir: safeGitPath(gitRoot, gitDirValue),
1194
+ commonDir: safeGitPath(gitRoot, commonDirValue)
1195
+ };
924
1196
  if (basename(hooksDir) === "_" && basename(dirname3(hooksDir)) === ".husky") {
1197
+ const hookPath2 = resolve3(dirname3(hooksDir), hookName);
925
1198
  return {
926
1199
  gitRoot,
927
1200
  hooksDir,
928
- hookPath: resolve3(dirname3(hooksDir), spec.hookName),
1201
+ hookPath: hookPath2,
929
1202
  kind: "husky_v9",
930
- dispatcherPath: resolve3(hooksDir, spec.hookName)
1203
+ dispatcherPath: resolve3(hooksDir, hookName),
1204
+ location: hookLocation(hookPath2, dirs)
931
1205
  };
932
1206
  }
1207
+ const hookPath = resolve3(hooksDir, hookName);
933
1208
  return {
934
1209
  gitRoot,
935
1210
  hooksDir,
936
- hookPath: resolve3(hooksDir, spec.hookName),
937
- kind: "direct"
1211
+ hookPath,
1212
+ kind: "direct",
1213
+ location: hookLocation(hookPath, dirs)
938
1214
  };
939
1215
  }
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
1216
  function assertSafeFile(path, spec) {
947
1217
  if (!existsSync3(path)) return void 0;
948
1218
  const stat = lstatSync3(path);
@@ -1024,16 +1294,107 @@ function blockRange(content, spec) {
1024
1294
  }
1025
1295
  const start = starts[0];
1026
1296
  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 };
1297
+ const text = content.subarray(start, end).toString("utf8");
1298
+ if (canonicalHookBlock(text) === canonicalHookBlock(managedHookBlock(spec.hookName))) {
1299
+ return { kind: "current", start, end };
1300
+ }
1301
+ const version = hookBlockContractVersion(text);
1302
+ if (version !== void 0 && version > GIT_HOOK_CONTRACT_VERSION) {
1303
+ return { kind: "newer", start, end };
1304
+ }
1305
+ return { kind: "stale", start, end };
1306
+ }
1307
+ var HUSKY_V8_SOURCE_RE = /^\.[ \t]+"\$\(dirname(?:[ \t]+--)?[ \t]+"\$0"\)\/_\/husky\.sh"[ \t]*$/mu;
1308
+ function blockInsertionPoint(content, allowShebangless, spec, huskyV8) {
1309
+ const shebangEnd = shellInsertionPoint(content, allowShebangless, spec);
1310
+ if (!huskyV8) return shebangEnd;
1311
+ const rest = content.subarray(shebangEnd).toString("utf8");
1312
+ const match = HUSKY_V8_SOURCE_RE.exec(rest);
1313
+ if (!match) return shebangEnd;
1314
+ const lineEnd = match.index + match[0].length;
1315
+ const afterLine = rest[lineEnd] === "\n" ? lineEnd + 1 : lineEnd;
1316
+ return shebangEnd + Buffer.byteLength(rest.slice(0, afterLine), "utf8");
1317
+ }
1318
+ var HUSKY_SPEC_MAJOR_RE = /^\s*[~^]?=?\s*v?([0-9]+)(?:\.(?:[0-9]+|[xX*])){0,2}(?:-[0-9A-Za-z.-]+)?\s*$/u;
1319
+ function declaredHuskyMajor(dir) {
1320
+ for (let current = resolve3(dir); ; ) {
1321
+ const manifest = join4(current, "package.json");
1322
+ if (existsSync3(manifest)) {
1323
+ try {
1324
+ if (lstatSync3(manifest).size > MAX_HOOK_BYTES) return void 0;
1325
+ const parsed = JSON.parse(readFileSync3(manifest, "utf8"));
1326
+ const range = parsed.devDependencies?.husky ?? parsed.dependencies?.husky;
1327
+ const major = typeof range === "string" ? HUSKY_SPEC_MAJOR_RE.exec(range)?.[1] : void 0;
1328
+ return major === void 0 ? void 0 : Number(major);
1329
+ } catch {
1330
+ return void 0;
1331
+ }
1332
+ }
1333
+ const parent = dirname3(current);
1334
+ if (parent === current || existsSync3(join4(current, ".git"))) return void 0;
1335
+ current = parent;
1336
+ }
1029
1337
  }
1030
- function mergedContent(existing, allowShebangless, spec) {
1031
- const block = Buffer.from(spec.block());
1032
- if (!existing) {
1338
+ function isHuskyV8Hook(hookPath) {
1339
+ const runtime = join4(dirname3(hookPath), "_", "husky.sh");
1340
+ try {
1341
+ const stat = lstatSync3(runtime);
1342
+ return stat.isFile() && stat.size <= MAX_HOOK_BYTES && readFileSync3(runtime, "utf8").includes("husky_skip_init");
1343
+ } catch (error) {
1344
+ if (errorCode(error) !== "ENOENT") return false;
1345
+ }
1346
+ const major = declaredHuskyMajor(dirname3(dirname3(hookPath)));
1347
+ return major !== void 0 && major <= 8;
1348
+ }
1349
+ var TERMINAL_LINE_RE = /^(?:exit|return)(?:\s|;|$)/u;
1350
+ var REDIRECT_OPERATOR_RE = /^[0-9]*(?:<<?|>>?|<&|>&|<>|>\|)$/u;
1351
+ var REDIRECT_WORD_RE = /^[0-9]*(?:<|>)/u;
1352
+ function isReplacingExec(line) {
1353
+ if (!/^exec(?:\s|$)/u.test(line)) return false;
1354
+ const words = line.slice(4).trim().split(/\s+/u).filter(Boolean);
1355
+ for (let index = 0; index < words.length; index += 1) {
1356
+ const word = words[index];
1357
+ if (word.startsWith("#")) break;
1358
+ if (REDIRECT_OPERATOR_RE.test(word)) {
1359
+ index += 1;
1360
+ continue;
1361
+ }
1362
+ if (!REDIRECT_WORD_RE.test(word)) return true;
1363
+ }
1364
+ return false;
1365
+ }
1366
+ function terminatesBefore(content, from, offset) {
1367
+ const lines = content.subarray(from, offset).toString("utf8").split("\n");
1368
+ return lines.some((line) => TERMINAL_LINE_RE.test(line) || isReplacingExec(line));
1369
+ }
1370
+ function mayTerminateBefore(content, from, offset) {
1371
+ return content.subarray(from, offset).toString("utf8").split(/\n|;|&&|\|\||\||[{()]|\b(?:then|do|else)\b/u).some((part) => {
1372
+ const command = part.trim();
1373
+ return TERMINAL_LINE_RE.test(command) || isReplacingExec(command);
1374
+ });
1375
+ }
1376
+ function withoutRange(content, start, end) {
1377
+ const suffix = content.subarray(end);
1378
+ return Buffer.concat([
1379
+ content.subarray(0, start),
1380
+ suffix.at(0) === 10 ? suffix.subarray(1) : suffix
1381
+ ]);
1382
+ }
1383
+ function insertBlock(content, at, spec) {
1384
+ return Buffer.concat([
1385
+ content.subarray(0, at),
1386
+ Buffer.from(managedHookBlock(spec.hookName)),
1387
+ Buffer.from("\n"),
1388
+ content.subarray(at)
1389
+ ]);
1390
+ }
1391
+ function migratedLegacyContent(existing, allowShebangless, spec, huskyV8) {
1392
+ const text = existing.toString("utf8");
1393
+ if (spec.hookName === "pre-commit" && isOwnedStandalonePreCommit(text)) {
1033
1394
  return createdScaffold(spec);
1034
1395
  }
1035
- const range = blockRange(existing, spec);
1036
- if (spec.hookName === POST_COMMIT_MANAGED_HOOK.hookName && range.kind === "absent" && existing.toString("utf8").startsWith(`#!/bin/sh
1396
+ if (spec.hookName !== "post-commit") return void 0;
1397
+ if (text.startsWith(`#!/bin/sh
1037
1398
  ${LEGACY_GLOBAL_OWNED_HEADER}
1038
1399
  `)) {
1039
1400
  const gateAt = existing.indexOf(Buffer.from('if [ "$(git config --get prim.active'));
@@ -1048,15 +1409,13 @@ ${LEGACY_GLOBAL_OWNED_HEADER}
1048
1409
  existing.subarray(0, gateAt),
1049
1410
  existing.subarray(chainAt)
1050
1411
  ]);
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
- ]);
1412
+ return insertBlock(
1413
+ withoutLegacyGate,
1414
+ blockInsertionPoint(withoutLegacyGate, allowShebangless, spec, huskyV8),
1415
+ spec
1416
+ );
1058
1417
  }
1059
- if (spec.hookName === POST_COMMIT_MANAGED_HOOK.hookName && range.kind === "absent" && existing.toString("utf8").startsWith(`#!/bin/sh
1418
+ if (text.startsWith(`#!/bin/sh
1060
1419
  ${LEGACY_PRIM_OWNED_HEADER}
1061
1420
  `)) {
1062
1421
  const prefixLength = legacyProjectPrefixLength(existing);
@@ -1065,42 +1424,59 @@ ${LEGACY_PRIM_OWNED_HEADER}
1065
1424
  }
1066
1425
  return Buffer.concat([createdScaffold(spec), existing.subarray(prefixLength)]);
1067
1426
  }
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(
1427
+ return void 0;
1428
+ }
1429
+ function mergedContent(existing, allowShebangless, spec, relocate, huskyV8) {
1430
+ if (!existing) return { content: createdScaffold(spec), migrates: false };
1431
+ const range = blockRange(existing, spec);
1432
+ if (range.kind === "absent") {
1433
+ const migrated = migratedLegacyContent(existing, allowShebangless, spec, huskyV8);
1434
+ if (migrated) return { content: migrated, migrates: true };
1435
+ } else if (range.kind !== "newer") {
1436
+ const oldCreated = oldCreatedPrefixes(spec).some(
1080
1437
  (prefix) => range.start === prefix.length && existing.subarray(0, prefix.length).equals(prefix)
1081
1438
  );
1082
1439
  if (oldCreated) {
1083
1440
  const suffix = existing.subarray(range.end);
1084
1441
  const tail = suffix.at(0) === 10 ? suffix.subarray(1) : suffix;
1085
- return Buffer.concat([createdScaffold(spec), tail]);
1442
+ return {
1443
+ content: Buffer.concat([createdScaffold(spec), tail]),
1444
+ migrates: range.kind === "stale"
1445
+ };
1086
1446
  }
1087
1447
  }
1088
1448
  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
- ]);
1449
+ const insertion = blockInsertionPoint(existing, allowShebangless, spec, huskyV8);
1450
+ switch (range.kind) {
1451
+ case "newer":
1452
+ return { content: existing, migrates: false };
1453
+ case "absent":
1454
+ return { content: insertBlock(existing, insertion, spec), migrates: false };
1455
+ case "current":
1456
+ case "stale": {
1457
+ const misplaced = terminatesBefore(existing, shebangEnd, range.start) || range.start < insertion;
1458
+ if (relocate && misplaced) {
1459
+ const without = withoutRange(existing, range.start, range.end);
1460
+ return {
1461
+ content: insertBlock(
1462
+ without,
1463
+ blockInsertionPoint(without, allowShebangless, spec, huskyV8),
1464
+ spec
1465
+ ),
1466
+ migrates: range.kind === "stale"
1467
+ };
1468
+ }
1469
+ if (range.kind === "current") return { content: existing, migrates: false };
1470
+ return {
1471
+ content: Buffer.concat([
1472
+ existing.subarray(0, range.start),
1473
+ Buffer.from(managedHookBlock(spec.hookName)),
1474
+ existing.subarray(range.end)
1475
+ ]),
1476
+ migrates: true
1477
+ };
1478
+ }
1097
1479
  }
1098
- return Buffer.concat([
1099
- withoutBlock.subarray(0, shebangEnd),
1100
- block,
1101
- Buffer.from("\n"),
1102
- withoutBlock.subarray(shebangEnd)
1103
- ]);
1104
1480
  }
1105
1481
  function errorCode(error) {
1106
1482
  return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
@@ -1160,137 +1536,143 @@ function unlinkHookUnchanged(path, expected, stat, spec) {
1160
1536
  assertTargetUnchanged(path, expected, spec, stat);
1161
1537
  unlinkSync2(path);
1162
1538
  }
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);
1539
+ function workingLegacyBlock(content, target, spec) {
1540
+ try {
1541
+ const range = blockRange(content, spec);
1542
+ if (range.kind !== "stale") return false;
1543
+ const block = content.subarray(range.start, range.end).toString("utf8");
1544
+ const from = shellInsertionPoint(content, target.kind === "husky_v9", spec);
1545
+ return isRecognizedLegacyBlock(spec.hookName, block) && !mayTerminateBefore(content, from, range.start);
1546
+ } catch {
1547
+ return false;
1548
+ }
1170
1549
  }
1171
- function ensureTarget(target, spec, initialContent) {
1550
+ var AMBIENT_LOCATIONS = ["repository", "prim"];
1551
+ var EXPLICIT_LOCATIONS = ["repository", "prim", "worktree"];
1552
+ function ensureTarget(target, spec, policy) {
1553
+ const result = { hookName: spec.hookName, path: target.hookPath, kind: target.kind };
1554
+ const skip = (outcome) => ({
1555
+ ...result,
1556
+ changed: false,
1557
+ outcome
1558
+ });
1172
1559
  assertUsableHuskyDispatcher(target, spec);
1173
1560
  const stat = assertSafeFile(target.hookPath, spec);
1174
1561
  if (stat && target.kind === "direct" && (stat.mode & 64) === 0) {
1175
1562
  throw new Error(`existing ${spec.hookName} hook is not executable: ${target.hookPath}`);
1176
1563
  }
1177
1564
  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);
1565
+ if (policy.repairOnly && (!existing || blockRange(existing, spec).kind === "absent")) {
1566
+ return skip("skipped");
1567
+ }
1568
+ const huskyV8 = isHuskyV8Hook(target.hookPath);
1569
+ const next = mergedContent(existing, target.kind === "husky_v9", spec, policy.relocate, huskyV8);
1570
+ if (existing && next.content.equals(existing)) return skip("unchanged");
1571
+ if (!policy.writable(target.location)) {
1572
+ if (target.location === "worktree") return skip("deferred");
1573
+ return skip(existing && workingLegacyBlock(existing, target, spec) ? "kept" : "external");
1574
+ }
1575
+ if (next.migrates && inspectGitHookEntrypoint() !== "ready") return skip("runtime_missing");
1576
+ rewriteHookAtomically(target.hookPath, next.content, existing, spec, stat);
1577
+ return { ...result, changed: true, outcome: existing ? "updated" : "created" };
1578
+ }
1579
+ function ensureEffectiveGitHook(hookName, cwd = process.cwd(), options = {}) {
1580
+ const target = resolveEffectiveGitHook(hookName, cwd);
1581
+ if (gitHooksMode({ cwd: target.gitRoot }) === "manual") {
1582
+ return {
1583
+ hookName,
1584
+ path: target.hookPath,
1585
+ kind: target.kind,
1586
+ changed: false,
1587
+ outcome: "manual"
1588
+ };
1189
1589
  }
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" };
1590
+ const explicit = (options.context ?? "explicit") === "explicit";
1591
+ const allowed = explicit ? EXPLICIT_LOCATIONS : AMBIENT_LOCATIONS;
1592
+ return ensureTarget(target, managedHookSpec(hookName), {
1593
+ writable: (location) => allowed.includes(location),
1594
+ relocate: explicit,
1595
+ repairOnly: options.repairOnly === true
1596
+ });
1205
1597
  }
1206
- function ensurePostRewriteHookAtPath(hookPath, initialContent) {
1598
+ function explicitTarget(hookPath, options) {
1207
1599
  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" };
1600
+ return {
1601
+ gitRoot: dirname3(dirname3(absolute)),
1602
+ hooksDir: dirname3(absolute),
1603
+ hookPath: absolute,
1604
+ // A `.husky/<hook>` file is run by Husky's dispatcher via `sh`, so it needs
1605
+ // neither a shebang nor the executable bit.
1606
+ kind: options.husky ? "husky_v9" : "direct",
1607
+ // The caller chose this exact file (a --target, or a consented global dir).
1608
+ location: "external"
1609
+ };
1219
1610
  }
1220
- function inspectEffectivePostCommitHook(cwd = process.cwd()) {
1221
- return inspectEffectiveManagedHook(POST_COMMIT_MANAGED_HOOK, cwd);
1611
+ function hasCurrentHookBlock(hookName, hookPath) {
1612
+ try {
1613
+ const kind = blockRange(readHookFile(hookPath), managedHookSpec(hookName)).kind;
1614
+ return kind === "current" || kind === "newer";
1615
+ } catch {
1616
+ return false;
1617
+ }
1222
1618
  }
1223
- function inspectEffectivePostRewriteHook(cwd = process.cwd()) {
1224
- return inspectEffectiveManagedHook(POST_REWRITE_MANAGED_HOOK, cwd);
1619
+ function ensureGitHookAtPath(hookName, hookPath, options = {}) {
1620
+ return ensureTarget(explicitTarget(hookPath, options), managedHookSpec(hookName), {
1621
+ writable: () => true,
1622
+ relocate: true,
1623
+ repairOnly: false
1624
+ });
1225
1625
  }
1226
- function inspectEffectiveManagedHook(spec, cwd) {
1227
- const target = resolveEffectiveManagedHook(spec, cwd);
1626
+ function inspectEffectiveGitHook(hookName, cwd = process.cwd()) {
1627
+ const spec = managedHookSpec(hookName);
1628
+ const target = resolveEffectiveGitHook(hookName, cwd);
1629
+ const base = {
1630
+ ...target,
1631
+ hookName,
1632
+ mode: gitHooksMode({ cwd: target.gitRoot }),
1633
+ entrypoint: inspectGitHookEntrypoint()
1634
+ };
1635
+ const uncovered = (reason2, executable2 = false) => ({ ...base, covered: false, executable: executable2, current: false, reason: reason2 });
1228
1636
  try {
1229
1637
  assertUsableHuskyDispatcher(target, spec);
1230
1638
  } catch (error) {
1231
1639
  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
- };
1640
+ return uncovered(missing ? "husky_dispatcher_missing" : "husky_dispatcher_invalid");
1239
1641
  }
1240
1642
  let stat;
1241
1643
  try {
1242
1644
  stat = assertSafeFile(target.hookPath, spec);
1243
1645
  } 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
- };
1646
+ return uncovered("unsafe_target");
1260
1647
  }
1648
+ if (!stat) return uncovered("missing");
1261
1649
  const executable = target.kind === "husky_v9" || (stat.mode & 64) !== 0;
1650
+ let range;
1651
+ let content;
1652
+ let insertion;
1262
1653
  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
- };
1654
+ content = readHookFile(target.hookPath);
1655
+ insertion = shellInsertionPoint(content, target.kind === "husky_v9", spec);
1656
+ range = blockRange(content, spec);
1275
1657
  } catch (error) {
1276
1658
  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
- };
1659
+ return uncovered(
1660
+ message.includes("binary") ? "binary" : message.includes("unsupported") ? "unsupported_interpreter" : "malformed_markers",
1661
+ executable
1662
+ );
1285
1663
  }
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);
1664
+ const huskyV8 = isHuskyV8Hook(target.hookPath);
1665
+ const preferred = blockInsertionPoint(content, target.kind === "husky_v9", spec, huskyV8);
1666
+ const current = range.kind === "current" || range.kind === "newer";
1667
+ const legacy = range.kind === "stale" && workingLegacyBlock(content, target, spec);
1668
+ 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;
1669
+ return {
1670
+ ...base,
1671
+ covered: reason === void 0,
1672
+ executable,
1673
+ current,
1674
+ ...reason ? { reason } : {}
1675
+ };
1294
1676
  }
1295
1677
  function projectHooksPathIsConfigured(gitRoot) {
1296
1678
  for (const scope of ["--worktree", "--local"]) {
@@ -1307,45 +1689,53 @@ function projectHooksPathIsConfigured(gitRoot) {
1307
1689
  }
1308
1690
  return false;
1309
1691
  }
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);
1692
+ function uninstallProjectGitHook(hookName, cwd = process.cwd()) {
1693
+ const target = projectGitHookTarget(hookName, cwd);
1694
+ if (target.location === "external") {
1695
+ return { path: target.hookPath, changed: false, removedFile: false, skipped: "external" };
1315
1696
  }
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
- );
1697
+ return uninstallTarget(target, managedHookSpec(hookName));
1325
1698
  }
1326
- function uninstallProjectPostRewriteHook(cwd = process.cwd()) {
1699
+ function projectGitHookTarget(hookName, cwd = process.cwd()) {
1327
1700
  const gitRoot = gitToplevel(cwd);
1328
1701
  if (!gitRoot) throw new Error("not a git repository");
1329
- if (projectHooksPathIsConfigured(gitRoot)) {
1330
- return uninstallEffectivePostRewriteHook(gitRoot);
1331
- }
1702
+ if (projectHooksPathIsConfigured(gitRoot)) return resolveEffectiveGitHook(hookName, gitRoot);
1703
+ return {
1704
+ ...explicitTarget(resolve3(projectHooksDir(gitRoot), hookName), {}),
1705
+ gitRoot,
1706
+ location: "repository"
1707
+ };
1708
+ }
1709
+ function projectHooksDir(gitRoot) {
1332
1710
  const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
1333
1711
  cwd: gitRoot,
1334
1712
  encoding: "utf8",
1335
1713
  stdio: ["ignore", "pipe", "ignore"],
1336
1714
  timeout: GIT_TIMEOUT_MS
1337
1715
  }).replace(/\r?\n$/u, "");
1338
- return uninstallPostRewriteHookAtPath(
1339
- resolve3(safeGitPath(gitRoot, commonDir), "hooks", POST_REWRITE_MANAGED_HOOK.hookName)
1340
- );
1716
+ return resolve3(safeGitPath(gitRoot, commonDir), "hooks");
1717
+ }
1718
+ function uninstallGitHookAtPath(hookName, hookPath, options = {}) {
1719
+ return uninstallTarget(explicitTarget(hookPath, options), managedHookSpec(hookName));
1341
1720
  }
1342
1721
  function uninstallTarget(target, spec) {
1722
+ const unchanged = { path: target.hookPath, changed: false, removedFile: false };
1343
1723
  const stat = assertSafeFile(target.hookPath, spec);
1344
- if (!stat) return { path: target.hookPath, changed: false, removedFile: false };
1724
+ if (!stat) return unchanged;
1345
1725
  const existing = readHookFile(target.hookPath);
1726
+ const remove = () => {
1727
+ unlinkHookUnchanged(target.hookPath, existing, stat, spec);
1728
+ return { path: target.hookPath, changed: true, removedFile: true };
1729
+ };
1730
+ const rewrite = (next) => {
1731
+ rewriteHookAtomically(target.hookPath, next, existing, spec, stat);
1732
+ return { path: target.hookPath, changed: true, removedFile: false };
1733
+ };
1346
1734
  const range = blockRange(existing, spec);
1347
1735
  if (range.kind === "absent") {
1348
- if (spec.hookName === POST_COMMIT_MANAGED_HOOK.hookName && existing.toString("utf8").startsWith(`#!/bin/sh
1736
+ const text = existing.toString("utf8");
1737
+ if (spec.hookName === "pre-commit" && isOwnedStandalonePreCommit(text)) return remove();
1738
+ if (spec.hookName === "post-commit" && text.startsWith(`#!/bin/sh
1349
1739
  ${LEGACY_PRIM_OWNED_HEADER}
1350
1740
  `)) {
1351
1741
  const prefixLength = legacyProjectPrefixLength(existing);
@@ -1353,20 +1743,9 @@ ${LEGACY_PRIM_OWNED_HEADER}
1353
1743
  throw new Error("unrecognized legacy Prim post-commit invocation");
1354
1744
  }
1355
1745
  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 };
1746
+ return tail.length === 0 ? remove() : rewrite(Buffer.concat([Buffer.from("#!/bin/sh\n"), tail]));
1368
1747
  }
1369
- return { path: target.hookPath, changed: false, removedFile: false };
1748
+ return unchanged;
1370
1749
  }
1371
1750
  const shebangEnd = shellInsertionPoint(existing, target.kind === "husky_v9", spec);
1372
1751
  const suffix = existing.subarray(range.end);
@@ -1375,77 +1754,16 @@ ${LEGACY_PRIM_OWNED_HEADER}
1375
1754
  `);
1376
1755
  if (range.start === shebangEnd && suffix.subarray(0, createdSuffix.length).equals(createdSuffix)) {
1377
1756
  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 };
1757
+ return tail.length === 0 ? remove() : rewrite(Buffer.concat([existing.subarray(0, shebangEnd), tail]));
1390
1758
  }
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(
1759
+ const createdPrefix = oldCreatedPrefixes(spec).find(
1402
1760
  (prefix) => range.start === prefix.length && existing.subarray(0, prefix.length).equals(prefix)
1403
1761
  );
1404
1762
  if (createdPrefix) {
1405
1763
  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 };
1764
+ return tail.length === 0 ? remove() : rewrite(Buffer.concat([Buffer.from("#!/bin/sh\n"), tail]));
1418
1765
  }
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
- );
1766
+ return rewrite(withoutRange(existing, range.start, range.end));
1449
1767
  }
1450
1768
 
1451
1769
  // src/lib/repository-binding.ts
@@ -1537,18 +1855,19 @@ function kickDaemonEnsure(options = {}) {
1537
1855
 
1538
1856
  export {
1539
1857
  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,
1858
+ isLegacyOwnedGlobalHook,
1859
+ gitHooksMode,
1860
+ isOwnedStandalonePreCommit,
1861
+ externalHookRemedy,
1862
+ resolveEffectiveGitHook,
1863
+ ensureEffectiveGitHook,
1864
+ hasCurrentHookBlock,
1865
+ ensureGitHookAtPath,
1866
+ inspectEffectiveGitHook,
1867
+ uninstallProjectGitHook,
1868
+ projectGitHookTarget,
1869
+ projectHooksDir,
1870
+ uninstallGitHookAtPath,
1552
1871
  resolveRepositoryBinding,
1553
1872
  bindRepositoryWithClient,
1554
1873
  bindRepository,