graphitect 0.2.0__py3-none-any.whl

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 (336) hide show
  1. graphify/__init__.py +30 -0
  2. graphify/__main__.py +757 -0
  3. graphify/_minhash.py +107 -0
  4. graphify/affected.py +318 -0
  5. graphify/always_on/agents-md.md +12 -0
  6. graphify/always_on/antigravity-rules.md +14 -0
  7. graphify/always_on/claude-md.md +9 -0
  8. graphify/always_on/gemini-md.md +9 -0
  9. graphify/always_on/kiro-steering.md +5 -0
  10. graphify/always_on/vscode-instructions.md +17 -0
  11. graphify/analyze.py +769 -0
  12. graphify/benchmark.py +152 -0
  13. graphify/build.py +2300 -0
  14. graphify/cache.py +1746 -0
  15. graphify/callflow_html.py +2051 -0
  16. graphify/cargo_introspect.py +109 -0
  17. graphify/cli.py +4745 -0
  18. graphify/cluster.py +409 -0
  19. graphify/command-kilo.md +15 -0
  20. graphify/cross_repo_calls.py +216 -0
  21. graphify/cross_repo_types.py +75 -0
  22. graphify/csharp_dispatch.py +154 -0
  23. graphify/dedup.py +1213 -0
  24. graphify/detect.py +2566 -0
  25. graphify/diagnostics.py +406 -0
  26. graphify/export.py +1349 -0
  27. graphify/exporters/__init__.py +1 -0
  28. graphify/exporters/base.py +14 -0
  29. graphify/exporters/graphdb.py +173 -0
  30. graphify/exporters/html.py +637 -0
  31. graphify/extract.py +7856 -0
  32. graphify/extractors/MIGRATION.md +107 -0
  33. graphify/extractors/__init__.py +66 -0
  34. graphify/extractors/apex.py +215 -0
  35. graphify/extractors/base.py +85 -0
  36. graphify/extractors/bash.py +579 -0
  37. graphify/extractors/blade.py +53 -0
  38. graphify/extractors/commonlisp.py +540 -0
  39. graphify/extractors/csharp.py +448 -0
  40. graphify/extractors/dart.py +564 -0
  41. graphify/extractors/dm.py +494 -0
  42. graphify/extractors/elixir.py +241 -0
  43. graphify/extractors/engine.py +6509 -0
  44. graphify/extractors/fortran.py +311 -0
  45. graphify/extractors/go.py +527 -0
  46. graphify/extractors/json_config.py +240 -0
  47. graphify/extractors/julia.py +289 -0
  48. graphify/extractors/markdown.py +408 -0
  49. graphify/extractors/models.py +131 -0
  50. graphify/extractors/objc.py +566 -0
  51. graphify/extractors/ocaml.py +289 -0
  52. graphify/extractors/pascal.py +688 -0
  53. graphify/extractors/pascal_forms.py +196 -0
  54. graphify/extractors/powershell.py +522 -0
  55. graphify/extractors/razor.py +192 -0
  56. graphify/extractors/resolution.py +3584 -0
  57. graphify/extractors/robot.py +296 -0
  58. graphify/extractors/rust.py +470 -0
  59. graphify/extractors/sln.py +92 -0
  60. graphify/extractors/sql.py +720 -0
  61. graphify/extractors/terraform.py +181 -0
  62. graphify/extractors/verilog.py +329 -0
  63. graphify/extractors/zig.py +181 -0
  64. graphify/file_slice.py +246 -0
  65. graphify/global_graph.py +194 -0
  66. graphify/google_workspace.py +237 -0
  67. graphify/hooks.py +933 -0
  68. graphify/ids.py +93 -0
  69. graphify/ingest.py +358 -0
  70. graphify/install.py +2366 -0
  71. graphify/llm.py +3544 -0
  72. graphify/manifest.py +4 -0
  73. graphify/manifest_ingest.py +311 -0
  74. graphify/mcp_ingest.py +386 -0
  75. graphify/multigraph_compat.py +212 -0
  76. graphify/pascal_resolution.py +129 -0
  77. graphify/paths.py +436 -0
  78. graphify/pg_introspect.py +165 -0
  79. graphify/prs.py +770 -0
  80. graphify/querylog.py +80 -0
  81. graphify/reflect.py +882 -0
  82. graphify/report.py +346 -0
  83. graphify/resolver_registry.py +85 -0
  84. graphify/ruby_resolution.py +242 -0
  85. graphify/scip_ingest.py +363 -0
  86. graphify/security.py +460 -0
  87. graphify/semantic_cleanup.py +336 -0
  88. graphify/serve.py +2608 -0
  89. graphify/skill-agents.md +710 -0
  90. graphify/skill-aider.md +1283 -0
  91. graphify/skill-amp.md +710 -0
  92. graphify/skill-claw.md +713 -0
  93. graphify/skill-codex.md +710 -0
  94. graphify/skill-copilot.md +713 -0
  95. graphify/skill-devin.md +1410 -0
  96. graphify/skill-droid.md +710 -0
  97. graphify/skill-kilo.md +722 -0
  98. graphify/skill-kiro.md +713 -0
  99. graphify/skill-opencode.md +705 -0
  100. graphify/skill-pi.md +713 -0
  101. graphify/skill-trae.md +711 -0
  102. graphify/skill-vscode.md +709 -0
  103. graphify/skill-windows.md +755 -0
  104. graphify/skill.md +713 -0
  105. graphify/skills/agents/references/add-watch.md +56 -0
  106. graphify/skills/agents/references/exports.md +87 -0
  107. graphify/skills/agents/references/extraction-spec.md +70 -0
  108. graphify/skills/agents/references/github-and-merge.md +46 -0
  109. graphify/skills/agents/references/hooks.md +33 -0
  110. graphify/skills/agents/references/query.md +311 -0
  111. graphify/skills/agents/references/transcribe.md +52 -0
  112. graphify/skills/agents/references/update.md +210 -0
  113. graphify/skills/amp/references/add-watch.md +56 -0
  114. graphify/skills/amp/references/exports.md +87 -0
  115. graphify/skills/amp/references/extraction-spec.md +70 -0
  116. graphify/skills/amp/references/github-and-merge.md +46 -0
  117. graphify/skills/amp/references/hooks.md +33 -0
  118. graphify/skills/amp/references/query.md +311 -0
  119. graphify/skills/amp/references/transcribe.md +52 -0
  120. graphify/skills/amp/references/update.md +210 -0
  121. graphify/skills/claude/references/add-watch.md +56 -0
  122. graphify/skills/claude/references/exports.md +87 -0
  123. graphify/skills/claude/references/extraction-spec.md +70 -0
  124. graphify/skills/claude/references/github-and-merge.md +46 -0
  125. graphify/skills/claude/references/hooks.md +33 -0
  126. graphify/skills/claude/references/query.md +311 -0
  127. graphify/skills/claude/references/transcribe.md +52 -0
  128. graphify/skills/claude/references/update.md +210 -0
  129. graphify/skills/claw/references/add-watch.md +56 -0
  130. graphify/skills/claw/references/exports.md +87 -0
  131. graphify/skills/claw/references/extraction-spec.md +31 -0
  132. graphify/skills/claw/references/github-and-merge.md +46 -0
  133. graphify/skills/claw/references/hooks.md +33 -0
  134. graphify/skills/claw/references/query.md +311 -0
  135. graphify/skills/claw/references/transcribe.md +52 -0
  136. graphify/skills/claw/references/update.md +210 -0
  137. graphify/skills/codex/references/add-watch.md +56 -0
  138. graphify/skills/codex/references/exports.md +87 -0
  139. graphify/skills/codex/references/extraction-spec.md +31 -0
  140. graphify/skills/codex/references/github-and-merge.md +46 -0
  141. graphify/skills/codex/references/hooks.md +33 -0
  142. graphify/skills/codex/references/query.md +311 -0
  143. graphify/skills/codex/references/transcribe.md +52 -0
  144. graphify/skills/codex/references/update.md +210 -0
  145. graphify/skills/copilot/references/add-watch.md +56 -0
  146. graphify/skills/copilot/references/exports.md +87 -0
  147. graphify/skills/copilot/references/extraction-spec.md +70 -0
  148. graphify/skills/copilot/references/github-and-merge.md +46 -0
  149. graphify/skills/copilot/references/hooks.md +33 -0
  150. graphify/skills/copilot/references/query.md +311 -0
  151. graphify/skills/copilot/references/transcribe.md +52 -0
  152. graphify/skills/copilot/references/update.md +210 -0
  153. graphify/skills/droid/references/add-watch.md +56 -0
  154. graphify/skills/droid/references/exports.md +87 -0
  155. graphify/skills/droid/references/extraction-spec.md +70 -0
  156. graphify/skills/droid/references/github-and-merge.md +46 -0
  157. graphify/skills/droid/references/hooks.md +33 -0
  158. graphify/skills/droid/references/query.md +311 -0
  159. graphify/skills/droid/references/transcribe.md +52 -0
  160. graphify/skills/droid/references/update.md +210 -0
  161. graphify/skills/kilo/references/add-watch.md +56 -0
  162. graphify/skills/kilo/references/exports.md +87 -0
  163. graphify/skills/kilo/references/extraction-spec.md +70 -0
  164. graphify/skills/kilo/references/github-and-merge.md +46 -0
  165. graphify/skills/kilo/references/hooks.md +33 -0
  166. graphify/skills/kilo/references/query.md +311 -0
  167. graphify/skills/kilo/references/transcribe.md +52 -0
  168. graphify/skills/kilo/references/update.md +210 -0
  169. graphify/skills/kiro/references/add-watch.md +56 -0
  170. graphify/skills/kiro/references/exports.md +87 -0
  171. graphify/skills/kiro/references/extraction-spec.md +31 -0
  172. graphify/skills/kiro/references/github-and-merge.md +46 -0
  173. graphify/skills/kiro/references/hooks.md +33 -0
  174. graphify/skills/kiro/references/query.md +311 -0
  175. graphify/skills/kiro/references/transcribe.md +52 -0
  176. graphify/skills/kiro/references/update.md +210 -0
  177. graphify/skills/opencode/references/add-watch.md +56 -0
  178. graphify/skills/opencode/references/exports.md +87 -0
  179. graphify/skills/opencode/references/extraction-spec.md +70 -0
  180. graphify/skills/opencode/references/github-and-merge.md +46 -0
  181. graphify/skills/opencode/references/hooks.md +33 -0
  182. graphify/skills/opencode/references/query.md +311 -0
  183. graphify/skills/opencode/references/transcribe.md +52 -0
  184. graphify/skills/opencode/references/update.md +210 -0
  185. graphify/skills/pi/references/add-watch.md +56 -0
  186. graphify/skills/pi/references/exports.md +87 -0
  187. graphify/skills/pi/references/extraction-spec.md +31 -0
  188. graphify/skills/pi/references/github-and-merge.md +46 -0
  189. graphify/skills/pi/references/hooks.md +33 -0
  190. graphify/skills/pi/references/query.md +311 -0
  191. graphify/skills/pi/references/transcribe.md +52 -0
  192. graphify/skills/pi/references/update.md +210 -0
  193. graphify/skills/trae/references/add-watch.md +56 -0
  194. graphify/skills/trae/references/exports.md +87 -0
  195. graphify/skills/trae/references/extraction-spec.md +70 -0
  196. graphify/skills/trae/references/github-and-merge.md +46 -0
  197. graphify/skills/trae/references/hooks.md +35 -0
  198. graphify/skills/trae/references/query.md +311 -0
  199. graphify/skills/trae/references/transcribe.md +52 -0
  200. graphify/skills/trae/references/update.md +210 -0
  201. graphify/skills/vscode/references/add-watch.md +56 -0
  202. graphify/skills/vscode/references/exports.md +87 -0
  203. graphify/skills/vscode/references/extraction-spec.md +70 -0
  204. graphify/skills/vscode/references/github-and-merge.md +46 -0
  205. graphify/skills/vscode/references/hooks.md +33 -0
  206. graphify/skills/vscode/references/query.md +311 -0
  207. graphify/skills/vscode/references/transcribe.md +52 -0
  208. graphify/skills/vscode/references/update.md +210 -0
  209. graphify/skills/windows/references/add-watch.md +56 -0
  210. graphify/skills/windows/references/exports.md +87 -0
  211. graphify/skills/windows/references/extraction-spec.md +70 -0
  212. graphify/skills/windows/references/github-and-merge.md +46 -0
  213. graphify/skills/windows/references/hooks.md +33 -0
  214. graphify/skills/windows/references/query.md +311 -0
  215. graphify/skills/windows/references/transcribe.md +52 -0
  216. graphify/skills/windows/references/update.md +210 -0
  217. graphify/symbol_resolution.py +556 -0
  218. graphify/transcribe.py +186 -0
  219. graphify/tree_html.py +603 -0
  220. graphify/validate.py +95 -0
  221. graphify/watch.py +2280 -0
  222. graphify/wiki.py +405 -0
  223. graphitect/__init__.py +28 -0
  224. graphitect/__main__.py +4 -0
  225. graphitect/_vendor/__init__.py +2 -0
  226. graphitect/_vendor/archify/LICENSE +22 -0
  227. graphitect/_vendor/archify/SKILL.md +137 -0
  228. graphitect/_vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
  229. graphitect/_vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
  230. graphitect/_vendor/archify/assets/template.html +14935 -0
  231. graphitect/_vendor/archify/bin/archify.mjs +2091 -0
  232. graphitect/_vendor/archify/bin/open-artifact.mjs +86 -0
  233. graphitect/_vendor/archify/bin/preview.mjs +653 -0
  234. graphitect/_vendor/archify/bin/visual-check.mjs +829 -0
  235. graphitect/_vendor/archify/brand-marks/README.md +31 -0
  236. graphitect/_vendor/archify/brand-marks/catalog.json +131 -0
  237. graphitect/_vendor/archify/delta/architecture-delta.mjs +1221 -0
  238. graphitect/_vendor/archify/examples/agent-run.lifecycle.json +60 -0
  239. graphitect/_vendor/archify/examples/agent-tool-call.workflow.json +94 -0
  240. graphitect/_vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
  241. graphitect/_vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
  242. graphitect/_vendor/archify/examples/cache-miss-request.sequence.json +82 -0
  243. graphitect/_vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
  244. graphitect/_vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
  245. graphitect/_vendor/archify/examples/dataflow-product-analytics.html +15045 -0
  246. graphitect/_vendor/archify/examples/deployment-release.lifecycle.json +49 -0
  247. graphitect/_vendor/archify/examples/event-stream.dataflow.json +57 -0
  248. graphitect/_vendor/archify/examples/incident-response.workflow.json +64 -0
  249. graphitect/_vendor/archify/examples/lifecycle-agent-run.html +14980 -0
  250. graphitect/_vendor/archify/examples/product-analytics.dataflow.json +76 -0
  251. graphitect/_vendor/archify/examples/production-deployment.architecture.json +71 -0
  252. graphitect/_vendor/archify/examples/release-delivery.workflow.json +62 -0
  253. graphitect/_vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
  254. graphitect/_vendor/archify/examples/web-app-rendered.html +15009 -0
  255. graphitect/_vendor/archify/examples/web-app.architecture.json +46 -0
  256. graphitect/_vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
  257. graphitect/_vendor/archify/migrations/workflow-v2.mjs +279 -0
  258. graphitect/_vendor/archify/package-lock.json +149 -0
  259. graphitect/_vendor/archify/package.json +39 -0
  260. graphitect/_vendor/archify/recipes/scenarios.mjs +391 -0
  261. graphitect/_vendor/archify/references/authoring-contract.md +243 -0
  262. graphitect/_vendor/archify/references/brand-marks.md +65 -0
  263. graphitect/_vendor/archify/references/delivery-contract.md +120 -0
  264. graphitect/_vendor/archify/references/viewer-runtime.md +45 -0
  265. graphitect/_vendor/archify/renderers/architecture/grid.mjs +62 -0
  266. graphitect/_vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
  267. graphitect/_vendor/archify/renderers/dataflow/README.md +104 -0
  268. graphitect/_vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
  269. graphitect/_vendor/archify/renderers/lifecycle/README.md +115 -0
  270. graphitect/_vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
  271. graphitect/_vendor/archify/renderers/sequence/README.md +114 -0
  272. graphitect/_vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
  273. graphitect/_vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  274. graphitect/_vendor/archify/renderers/shared/cli.mjs +218 -0
  275. graphitect/_vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  276. graphitect/_vendor/archify/renderers/shared/diagnostics.mjs +127 -0
  277. graphitect/_vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  278. graphitect/_vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  279. graphitect/_vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  280. graphitect/_vendor/archify/renderers/shared/geometry.mjs +1423 -0
  281. graphitect/_vendor/archify/renderers/shared/i18n.mjs +595 -0
  282. graphitect/_vendor/archify/renderers/shared/layout-report.mjs +40 -0
  283. graphitect/_vendor/archify/renderers/shared/legend.mjs +217 -0
  284. graphitect/_vendor/archify/renderers/shared/output-path.mjs +340 -0
  285. graphitect/_vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
  286. graphitect/_vendor/archify/renderers/shared/repository-location.mjs +58 -0
  287. graphitect/_vendor/archify/renderers/shared/text-fit.mjs +49 -0
  288. graphitect/_vendor/archify/renderers/shared/utils.mjs +232 -0
  289. graphitect/_vendor/archify/renderers/shared/validator.mjs +86 -0
  290. graphitect/_vendor/archify/renderers/workflow/README.md +223 -0
  291. graphitect/_vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
  292. graphitect/_vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
  293. graphitect/_vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  294. graphitect/_vendor/archify/schemas/README.md +211 -0
  295. graphitect/_vendor/archify/schemas/architecture.schema.json +178 -0
  296. graphitect/_vendor/archify/schemas/common.schema.json +115 -0
  297. graphitect/_vendor/archify/schemas/dataflow.schema.json +243 -0
  298. graphitect/_vendor/archify/schemas/lifecycle.schema.json +266 -0
  299. graphitect/_vendor/archify/schemas/sequence.schema.json +223 -0
  300. graphitect/_vendor/archify/schemas/workflow.schema.json +428 -0
  301. graphitect/_vendor/archify/scripts/check-render-output.mjs +836 -0
  302. graphitect/_vendor/archify/scripts/check-update.mjs +1667 -0
  303. graphitect/_vendor/archify/scripts/generate-brand-marks.mjs +141 -0
  304. graphitect/_vendor/archify/scripts/generate-validators.mjs +66 -0
  305. graphitect/_vendor/archify/scripts/render-examples.mjs +26 -0
  306. graphitect/_vendor/archify/scripts/update-contract.mjs +182 -0
  307. graphitect/_vendor/archify/skill-release.json +10 -0
  308. graphitect/cli.py +981 -0
  309. graphitect/deliver/__init__.py +5 -0
  310. graphitect/deliver/archify_adapter.py +1877 -0
  311. graphitect/deliver/archify_ir.py +160 -0
  312. graphitect/deliver/archify_repair.py +135 -0
  313. graphitect/deliver/doc_compiler.py +916 -0
  314. graphitect/ground/__init__.py +5 -0
  315. graphitect/ground/describe_source.py +27 -0
  316. graphitect/ground/fullread_source.py +56 -0
  317. graphitect/ground/graphify_source.py +107 -0
  318. graphitect/models.py +118 -0
  319. graphitect/skill/SKILL.md +80 -0
  320. graphitect/skill/agents/openai.yaml +4 -0
  321. graphitect/synthesize/__init__.py +5 -0
  322. graphitect/synthesize/engine.py +281 -0
  323. graphitect/synthesize/llm_backend.py +331 -0
  324. graphitect/synthesize/questions.py +139 -0
  325. graphitect/synthesize/rubric.py +104 -0
  326. graphitect-0.2.0.dist-info/METADATA +284 -0
  327. graphitect-0.2.0.dist-info/RECORD +336 -0
  328. graphitect-0.2.0.dist-info/WHEEL +5 -0
  329. graphitect-0.2.0.dist-info/entry_points.txt +2 -0
  330. graphitect-0.2.0.dist-info/licenses/LICENSE +21 -0
  331. graphitect-0.2.0.dist-info/licenses/LICENSE-ARCHIFY-MIT +22 -0
  332. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-APACHE-2.0 +202 -0
  333. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-MIT +21 -0
  334. graphitect-0.2.0.dist-info/licenses/NOTICE-ARCHIFY-THIRD-PARTY.md +69 -0
  335. graphitect-0.2.0.dist-info/licenses/NOTICE-GRAPHIFY +8 -0
  336. graphitect-0.2.0.dist-info/top_level.txt +2 -0
graphify/hooks.py ADDED
@@ -0,0 +1,933 @@
1
+ # git hook integration - install/uninstall graphify post-commit and post-checkout hooks
2
+ from __future__ import annotations
3
+ import os
4
+ import re
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ _HOOK_MARKER = "# graphify-hook-start"
9
+ _HOOK_MARKER_END = "# graphify-hook-end"
10
+ _CHECKOUT_MARKER = "# graphify-checkout-hook-start"
11
+ _CHECKOUT_MARKER_END = "# graphify-checkout-hook-end"
12
+
13
+ # __PINNED_PYTHON__ is replaced at install time with the absolute path of the
14
+ # Python interpreter that ran `graphify hook install`. For uv-tool and pipx
15
+ # installs the interpreter lives inside an isolated venv, so the launcher on
16
+ # PATH is the only entry point — and GUI git clients / CI runners often have a
17
+ # minimal PATH that omits ~/.local/bin. Pinning sys.executable at install time
18
+ # makes the hook work regardless of PATH at git-trigger time.
19
+ _PYTHON_DETECT = """\
20
+ # Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs).
21
+ # _PINNED was recorded at hook-install time; tried first so the hook works even
22
+ # when the graphify launcher is not on PATH (common in GUI clients and CI).
23
+ #
24
+ # Probes check availability with importlib.util.find_spec instead of importing
25
+ # the package: a probe that imports graphify wholesale executes the full package
26
+ # import (10s+ cold on machines with AV-scanned or large site-packages) and used
27
+ # to run up to FOUR times synchronously, stalling every commit before the
28
+ # detached launch even started. find_spec locates the package without executing
29
+ # it, so each probe costs interpreter startup only. The detached rebuild still
30
+ # fails loudly in the log if the package is broken under that interpreter.
31
+ _GFY_PROBE="import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('graphify') else 1)"
32
+ GRAPHIFY_PYTHON=""
33
+ _PINNED='__PINNED_PYTHON__'
34
+ if [ -n "$_PINNED" ] && [ -x "$_PINNED" ] && "$_PINNED" -c "$_GFY_PROBE" 2>/dev/null; then
35
+ GRAPHIFY_PYTHON="$_PINNED"
36
+ fi
37
+ # Second probe: read graphify-out/.graphify_python (written by the skill and
38
+ # CLI; survives uv-tool reinstalls and is the same source the README documents).
39
+ if [ -z "$GRAPHIFY_PYTHON" ]; then
40
+ _GFY_PYTHON_FILE="graphify-out/.graphify_python"
41
+ if [ -f "$_GFY_PYTHON_FILE" ]; then
42
+ _FROM_FILE=$(cat "$_GFY_PYTHON_FILE" 2>/dev/null | tr -d '[:space:]')
43
+ case "$_FROM_FILE" in
44
+ *[!a-zA-Z0-9/_.@:\\\\-]*) _FROM_FILE="" ;; # allowlist (covers Windows paths)
45
+ esac
46
+ if [ -n "$_FROM_FILE" ] && [ -x "$_FROM_FILE" ] && "$_FROM_FILE" -c "$_GFY_PROBE" 2>/dev/null; then
47
+ GRAPHIFY_PYTHON="$_FROM_FILE"
48
+ fi
49
+ fi
50
+ fi
51
+ # Third probe: resolve via the graphify launcher on PATH.
52
+ if [ -z "$GRAPHIFY_PYTHON" ]; then
53
+ GRAPHIFY_BIN=$(command -v graphify 2>/dev/null)
54
+ if [ -n "$GRAPHIFY_BIN" ]; then
55
+ # Windows pip layout: Scripts/graphify(.exe) sits beside ..\\python.exe
56
+ # (or .\\python.exe inside a venv's Scripts dir). NOTE: command -v may
57
+ # return the launcher path WITHOUT the .exe suffix, so this cannot key
58
+ # on the extension.
59
+ _GFY_BINDIR=$(dirname "$GRAPHIFY_BIN")
60
+ if [ -x "$_GFY_BINDIR/../python.exe" ] && "$_GFY_BINDIR/../python.exe" -c "$_GFY_PROBE" 2>/dev/null; then
61
+ GRAPHIFY_PYTHON="$_GFY_BINDIR/../python.exe"
62
+ elif [ -x "$_GFY_BINDIR/python.exe" ] && "$_GFY_BINDIR/python.exe" -c "$_GFY_PROBE" 2>/dev/null; then
63
+ GRAPHIFY_PYTHON="$_GFY_BINDIR/python.exe"
64
+ fi
65
+ fi
66
+ if [ -z "$GRAPHIFY_PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
67
+ # POSIX launcher: parse the shebang. head -c + tr strip NUL bytes first —
68
+ # when the launcher is a Windows binary reached without its .exe suffix,
69
+ # a raw `head -1` reads binary into the command substitution and the
70
+ # shell warns about ignored null bytes on every commit. Gate on a
71
+ # leading '#!': a launcher can also be a binary trampoline with no
72
+ # shebang at all (uv tool installs on Windows), and its bytes must
73
+ # never reach the shebang parse (#2852).
74
+ case "$GRAPHIFY_BIN" in
75
+ *.exe) _GFY_HEAD="" ;;
76
+ *) _GFY_HEAD=$(head -c 256 "$GRAPHIFY_BIN" 2>/dev/null | tr -d '\\000') ;;
77
+ esac
78
+ case "$_GFY_HEAD" in
79
+ '#!'*) _SHEBANG=$(printf '%s\\n' "$_GFY_HEAD" | head -n 1 | sed 's/^#![[:space:]]*//') ;;
80
+ *) _SHEBANG="" ;;
81
+ esac
82
+ case "$_SHEBANG" in
83
+ */env\\ *) GRAPHIFY_PYTHON="${_SHEBANG#*/env }" ;;
84
+ *) GRAPHIFY_PYTHON="$_SHEBANG" ;;
85
+ esac
86
+ # Allowlist: only keep characters valid in a filesystem path to prevent
87
+ # injection if the shebang contains shell metacharacters.
88
+ case "$GRAPHIFY_PYTHON" in
89
+ *[!a-zA-Z0-9/_.@:\\\\-]*) GRAPHIFY_PYTHON="" ;;
90
+ esac
91
+ if [ -n "$GRAPHIFY_PYTHON" ] && ! "$GRAPHIFY_PYTHON" -c "$_GFY_PROBE" 2>/dev/null; then
92
+ GRAPHIFY_PYTHON=""
93
+ fi
94
+ fi
95
+ fi
96
+ # Fourth probe: uv tool environments. `uv tool install` (the README's
97
+ # recommended method) puts graphify in an isolated venv that no ambient
98
+ # python can import, and on Windows its launcher on PATH is a binary
99
+ # trampoline with no shebang to parse — so the probes above can all miss a
100
+ # healthy install and the hook dies at the last-resort fallback (#2852).
101
+ # Scan the uv tool envs directly; UV_TOOL_DIR overrides the default
102
+ # location. A tool env is adopted only if its python passes the probe, so a
103
+ # co-installed tool without graphify never satisfies it.
104
+ #
105
+ # The snap roots matter because an install made from inside a snap-confined
106
+ # editor lands in that snap's private HOME, which the plain $HOME roots above
107
+ # never see once the hook runs from an ordinary shell. Revisions are globbed
108
+ # rather than pinned: snap rotates them on update, which is exactly what makes
109
+ # a pinned path unsafe (see _is_rotating_prefix).
110
+ if [ -z "$GRAPHIFY_PYTHON" ]; then
111
+ for _GFY_TOOLS in \
112
+ "${UV_TOOL_DIR:-}" \
113
+ "$HOME/.local/share/uv/tools" \
114
+ "$HOME/AppData/Roaming/uv/tools" \
115
+ "$HOME"/snap/*/current/.local/share/uv/tools \
116
+ "$HOME"/snap/*/[0-9]*/.local/share/uv/tools; do
117
+ [ -n "$_GFY_TOOLS" ] || continue
118
+ for _GFY_CAND in "$_GFY_TOOLS"/*/bin/python "$_GFY_TOOLS"/*/Scripts/python.exe; do
119
+ [ -x "$_GFY_CAND" ] || continue
120
+ if "$_GFY_CAND" -c "$_GFY_PROBE" 2>/dev/null; then
121
+ GRAPHIFY_PYTHON="$_GFY_CAND"
122
+ break 2
123
+ fi
124
+ done
125
+ done
126
+ fi
127
+ # Last resort: try python3 / python (works for system/venv installs on PATH).
128
+ if [ -z "$GRAPHIFY_PYTHON" ]; then
129
+ if command -v python3 >/dev/null 2>&1 && python3 -c "$_GFY_PROBE" 2>/dev/null; then
130
+ GRAPHIFY_PYTHON="python3"
131
+ elif command -v python >/dev/null 2>&1 && python -c "$_GFY_PROBE" 2>/dev/null; then
132
+ GRAPHIFY_PYTHON="python"
133
+ else
134
+ echo "[graphify hook] could not locate a Python with graphify installed. Add the graphify bin dir to PATH or re-run 'graphify hook install' from the env where graphify lives." >&2
135
+ exit 0
136
+ fi
137
+ fi
138
+ """
139
+
140
+ # The Python that the rebuild runs, shared by both hooks. Embedded verbatim into
141
+ # the launcher below and re-executed in the detached child. Must not contain the
142
+ # double-quote, $, backtick or backslash characters: it is carried inside a
143
+ # shell double-quoted `-c "..."` argument (see _detached_launch).
144
+ _REBUILD_BODY_COMMIT = """\
145
+ import os, signal, sys, threading, multiprocessing
146
+ from pathlib import Path
147
+
148
+ changed_raw = os.environ.get('GRAPHIFY_CHANGED', '')
149
+ changed = [Path(f.strip()) for f in changed_raw.strip().splitlines() if f.strip()]
150
+
151
+ if not changed:
152
+ sys.exit(0)
153
+
154
+ print(f'[graphify hook] {len(changed)} file(s) changed - rebuilding graph...')
155
+
156
+ try:
157
+ from graphify.watch import _rebuild_code, _apply_resource_limits
158
+ _apply_resource_limits()
159
+ _timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600'))
160
+ if _timeout > 0:
161
+ if hasattr(signal, 'SIGALRM'):
162
+ def _sigalrm_bail(*_a):
163
+ # Killing here, before the exception unwinds, matters: once
164
+ # TimeoutError starts propagating it passes straight through
165
+ # the ProcessPoolExecutor with-block's own __exit__, which
166
+ # calls shutdown(wait=True) and blocks until every worker
167
+ # exits -- forever, for a worker stuck the way #3341 was,
168
+ # since the alarm firing never actually stops it. Killing the
169
+ # workers first means shutdown has nothing left to wait for.
170
+ for _child in multiprocessing.active_children():
171
+ _child.kill()
172
+ raise TimeoutError(f'graphify rebuild exceeded {_timeout}s')
173
+ signal.signal(signal.SIGALRM, _sigalrm_bail)
174
+ signal.alarm(_timeout)
175
+ else:
176
+ def _bail():
177
+ print(f'[graphify hook] graphify rebuild exceeded {_timeout}s', flush=True)
178
+ for _child in multiprocessing.active_children():
179
+ _child.kill()
180
+ os._exit(1)
181
+ _watchdog = threading.Timer(_timeout, _bail)
182
+ _watchdog.daemon = True
183
+ _watchdog.start()
184
+ _force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
185
+ _root = Path('.')
186
+ _out = os.environ.get('GRAPHIFY_OUT', 'graphify-out')
187
+ _saved = Path(_out) / '.graphify_root'
188
+ if _saved.exists():
189
+ _txt = _saved.read_text(encoding='utf-8-sig').strip()
190
+ if _txt:
191
+ _candidate = Path(_txt)
192
+ try:
193
+ _cwd = Path.cwd().resolve()
194
+ _resolved = _candidate.resolve()
195
+ _in_repo = _resolved == _cwd or _cwd in _resolved.parents
196
+ except (OSError, RuntimeError):
197
+ _in_repo = False
198
+ if _in_repo:
199
+ _root = _candidate
200
+ else:
201
+ print(f'[graphify hook] ignoring out-of-repo .graphify_root: {_txt}')
202
+ _rebuild_code(_root, changed_paths=changed, force=_force)
203
+ # Refresh the work-memory lessons doc when saved Q&A outcomes exist
204
+ # (best-effort; never fails the hook).
205
+ try:
206
+ _md = (_root / _out) / 'memory'
207
+ if _md.is_dir() and any(_md.glob('*.md')):
208
+ from graphify.reflect import reflect as _reflect
209
+ _gj = (_root / _out) / 'graph.json'
210
+ _reflect(memory_dir=_md, out_path=(_root / _out) / 'reflections' / 'LESSONS.md',
211
+ graph_path=_gj if _gj.exists() else None)
212
+ except Exception:
213
+ pass
214
+ except TimeoutError as exc:
215
+ print(f'[graphify hook] {exc}')
216
+ sys.exit(1)
217
+ except Exception as exc:
218
+ print(f'[graphify hook] Rebuild failed: {exc}')
219
+ sys.exit(1)
220
+ """
221
+
222
+ _REBUILD_BODY_CHECKOUT = """\
223
+ from graphify.watch import _rebuild_code, _apply_resource_limits
224
+ from pathlib import Path
225
+ import os, signal, sys, threading, multiprocessing
226
+ try:
227
+ _apply_resource_limits()
228
+ _timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600'))
229
+ if _timeout > 0:
230
+ if hasattr(signal, 'SIGALRM'):
231
+ def _sigalrm_bail(*_a):
232
+ # Killing here, before the exception unwinds, matters: once
233
+ # TimeoutError starts propagating it passes straight through
234
+ # the ProcessPoolExecutor with-block's own __exit__, which
235
+ # calls shutdown(wait=True) and blocks until every worker
236
+ # exits -- forever, for a worker stuck the way #3341 was,
237
+ # since the alarm firing never actually stops it. Killing the
238
+ # workers first means shutdown has nothing left to wait for.
239
+ for _child in multiprocessing.active_children():
240
+ _child.kill()
241
+ raise TimeoutError(f'graphify rebuild exceeded {_timeout}s')
242
+ signal.signal(signal.SIGALRM, _sigalrm_bail)
243
+ signal.alarm(_timeout)
244
+ else:
245
+ def _bail():
246
+ print(f'[graphify] graphify rebuild exceeded {_timeout}s', flush=True)
247
+ for _child in multiprocessing.active_children():
248
+ _child.kill()
249
+ os._exit(1)
250
+ _watchdog = threading.Timer(_timeout, _bail)
251
+ _watchdog.daemon = True
252
+ _watchdog.start()
253
+ _force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
254
+ # post-checkout: branch switch can touch arbitrary files; full rebuild path
255
+ # (no changed_paths) is correct here. The flock inside _rebuild_code still
256
+ # prevents pile-ups when commit + checkout fire back-to-back.
257
+ _root = Path('.')
258
+ _out = os.environ.get('GRAPHIFY_OUT', 'graphify-out')
259
+ _saved = Path(_out) / '.graphify_root'
260
+ if _saved.exists():
261
+ _txt = _saved.read_text(encoding='utf-8-sig').strip()
262
+ if _txt:
263
+ _candidate = Path(_txt)
264
+ try:
265
+ _cwd = Path.cwd().resolve()
266
+ _resolved = _candidate.resolve()
267
+ _in_repo = _resolved == _cwd or _cwd in _resolved.parents
268
+ except (OSError, RuntimeError):
269
+ _in_repo = False
270
+ if _in_repo:
271
+ _root = _candidate
272
+ else:
273
+ print(f'[graphify] ignoring out-of-repo .graphify_root: {_txt}')
274
+ _rebuild_code(_root, force=_force)
275
+ # Refresh the work-memory lessons doc when saved Q&A outcomes exist
276
+ # (best-effort; never fails the hook).
277
+ try:
278
+ _md = (_root / _out) / 'memory'
279
+ if _md.is_dir() and any(_md.glob('*.md')):
280
+ from graphify.reflect import reflect as _reflect
281
+ _gj = (_root / _out) / 'graph.json'
282
+ _reflect(memory_dir=_md, out_path=(_root / _out) / 'reflections' / 'LESSONS.md',
283
+ graph_path=_gj if _gj.exists() else None)
284
+ except Exception:
285
+ pass
286
+ except TimeoutError as exc:
287
+ print(f'[graphify] {exc}')
288
+ sys.exit(1)
289
+ except Exception as exc:
290
+ print(f'[graphify] Rebuild failed: {exc}')
291
+ sys.exit(1)
292
+ """
293
+
294
+ # Cross-platform detached-launch shim (#1161). The hooks used to background the
295
+ # rebuild with `nohup "$GRAPHIFY_PYTHON" -c "..." &`, but Git for Windows' bundled
296
+ # MSYS shell ships no nohup (nor setsid), so that line died with
297
+ # 'nohup: command not found' and the rebuild silently never ran — git commit/pull
298
+ # still returned 0, so the graph just went stale with no signal. graphify already
299
+ # requires Python, so we let Python do the detaching: a tiny outer process spawns
300
+ # the real rebuild fully detached and returns immediately, so the hook never
301
+ # blocks. POSIX uses start_new_session (the setsid equivalent); Windows uses
302
+ # CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP, breaking away from any job object
303
+ # when allowed. Do NOT 'simplify' CREATE_NO_WINDOW back into DETACHED_PROCESS:
304
+ # on Windows 11 with Windows Terminal as the default console host, a
305
+ # console-less python still gets a VISIBLE console allocated when its runtime
306
+ # touches the console API during startup (ctrl-handler installation), popping
307
+ # an empty Terminal window over whatever the user is doing - once per commit,
308
+ # for the whole rebuild. Reproduced via GetConsoleWindow(): DETACHED_PROCESS
309
+ # child reports a visible hwnd, CREATE_NO_WINDOW child reports none (3bac3df).
310
+ # This payload is carried inside a shell double-quoted -c argument,
311
+ # so it deliberately uses only single-quoted Python strings (no ", $, ` or \\).
312
+ _LAUNCHER_TEMPLATE = """\
313
+ import os, subprocess, sys
314
+ _src = '''
315
+ __REBUILD_BODY__
316
+ '''
317
+ _log = os.environ.get('GRAPHIFY_REBUILD_LOG') or os.path.join(os.path.expanduser('~'), '.cache', 'graphify-rebuild.log')
318
+ try:
319
+ os.makedirs(os.path.dirname(_log), exist_ok=True)
320
+ _out = open(_log, 'a', buffering=1, encoding='utf-8', errors='replace')
321
+ except OSError:
322
+ _out = subprocess.DEVNULL
323
+ _kw = dict(stdout=_out, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, cwd=os.getcwd(), close_fds=True)
324
+ _cmd = [sys.executable, '-c', _src]
325
+ if os.name == 'nt':
326
+ _flags = 0x08000000 | 0x00000200 # CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP
327
+ try:
328
+ subprocess.Popen(_cmd, creationflags=_flags | 0x01000000, **_kw) # + CREATE_BREAKAWAY_FROM_JOB
329
+ except OSError:
330
+ subprocess.Popen(_cmd, creationflags=_flags, **_kw)
331
+ else:
332
+ subprocess.Popen(_cmd, start_new_session=True, **_kw)
333
+ """
334
+
335
+
336
+ def _detached_launch(rebuild_body: str) -> str:
337
+ """Return a POSIX-sh line that runs ``rebuild_body`` as a detached background
338
+ Python process via ``$GRAPHIFY_PYTHON``.
339
+
340
+ Replaces the old ``nohup ... &`` form, which failed on Git for Windows'
341
+ shell (no nohup/setsid) and let the rebuild silently never run (#1161).
342
+ The launcher writes the child's output to ``$GRAPHIFY_REBUILD_LOG`` and
343
+ returns the instant the child is spawned, so the git hook never blocks.
344
+ """
345
+ launcher = _LAUNCHER_TEMPLATE.replace("__REBUILD_BODY__", rebuild_body)
346
+ return '"$GRAPHIFY_PYTHON" -c "' + launcher + '"\n'
347
+
348
+
349
+ # Skip the rebuild inside a linked worktree (git worktree add), shared by both
350
+ # hooks. With core.hooksPath shared across worktrees a commit in any worktree
351
+ # fires these hooks; the canonical graphify-out/ belongs to the primary checkout,
352
+ # so rebuilding from a worktree is wasteful, writes a rogue delta-only graph the
353
+ # user never asked for, and races deploy/CI `git clean` against the detached
354
+ # rebuild ("failed to remove graphify-out/: Directory not empty") (#1809, #1806).
355
+ # A linked worktree has git-dir != git-common-dir. Both are resolved to absolute
356
+ # via `cd ... && pwd` before comparing: git's exported GIT_DIR / --git-dir can be
357
+ # absolute while --git-common-dir is the relative ".git", and a raw compare would
358
+ # false-positive on the PRIMARY checkout and wrongly skip it.
359
+ _WORKTREE_GUARD = """\
360
+ _GFY_GITDIR=$(cd "$(git rev-parse --git-dir 2>/dev/null)" 2>/dev/null && pwd)
361
+ _GFY_COMMONDIR=$(cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd)
362
+ if [ -n "$_GFY_COMMONDIR" ] && [ "$_GFY_GITDIR" != "$_GFY_COMMONDIR" ]; then
363
+ exit 0
364
+ fi
365
+ """
366
+
367
+
368
+ # Both hook bodies run inside a subshell `( ... )`. The generated block is
369
+ # appended to whatever post-commit / post-checkout already exists, and other
370
+ # tools chain their own logic after it; every skip condition in the block is a
371
+ # bare `exit 0`, which in a flat script ends the WHOLE hook, silently dropping
372
+ # anything after graphify's end marker - on every root commit (HEAD~1 does
373
+ # not exist), every rebase/merge, every linked worktree, every
374
+ # GRAPHIFY_SKIP_HOOK=1 (#2986). Inside the subshell an `exit` ends only
375
+ # graphify's section; the detached rebuild launch is unaffected, and the
376
+ # hook's own exit status stays 0 as before.
377
+ _HOOK_SCRIPT = """\
378
+ # graphify-hook-start
379
+ # Auto-rebuilds the knowledge graph after each commit (code files only, no LLM needed).
380
+ # Installed by: graphify hook install
381
+ (
382
+
383
+ # Deterministic clustering: networkx louvain iterates string-keyed sets whose
384
+ # order is randomized per-process by PYTHONHASHSEED, so community assignments
385
+ # churn run-to-run. Pinning it makes graphify-out reproducible.
386
+ export PYTHONHASHSEED=0
387
+ __VIZ_LIMIT_EXPORT__
388
+ # Git for Windows/MSYS hooks can inherit fragile pipe handles from GUI clients
389
+ # and agent shells. Keep hook-triggered rebuilds sequential by default there;
390
+ # explicit GRAPHIFY_MAX_WORKERS still wins for users who want parallelism.
391
+ if [ -n "${WINDIR:-}" ] || [ -n "${MSYSTEM:-}" ]; then
392
+ export GRAPHIFY_MAX_WORKERS="${GRAPHIFY_MAX_WORKERS:-1}"
393
+ fi
394
+
395
+ # Skip during rebase/merge/cherry-pick to avoid blocking --continue with unstaged changes
396
+ # git exports GIT_DIR to hooks; the rev-parse fallback only runs when invoked by
397
+ # hand (each git exec costs 1s+ on AV-scanned Windows machines).
398
+ GIT_DIR=${GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)}
399
+ [ -d "$GIT_DIR/rebase-merge" ] && exit 0
400
+ [ -d "$GIT_DIR/rebase-apply" ] && exit 0
401
+ [ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0
402
+ [ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
403
+
404
+ [ "${GRAPHIFY_SKIP_HOOK:-0}" = "1" ] && exit 0
405
+
406
+ """ + _WORKTREE_GUARD + """
407
+ CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only HEAD 2>/dev/null)
408
+ if [ -z "$CHANGED" ]; then
409
+ exit 0
410
+ fi
411
+
412
+ # Skip when only graphify-out/ artifacts changed (avoids rebuild loop when graph outputs are tracked in git)
413
+ _NON_GRAPH=$(echo "$CHANGED" | grep -v '^graphify-out/' || true)
414
+ if [ -z "$_NON_GRAPH" ]; then
415
+ exit 0
416
+ fi
417
+
418
+ """ + _PYTHON_DETECT + """
419
+ export GRAPHIFY_CHANGED="$CHANGED"
420
+
421
+ # Run the rebuild detached so git commit returns immediately. Full-repo rebuilds
422
+ # can take hours; blocking the post-commit hook stalls the shell. The Python
423
+ # launcher below detaches the child cross-platform, so it works on Git for
424
+ # Windows' shell too (which lacks the coreutils backgrounding tools) (#1161).
425
+ _GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log"
426
+ mkdir -p "$(dirname "$_GRAPHIFY_LOG")"
427
+ export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG"
428
+ echo "[graphify hook] launching background rebuild (log: $_GRAPHIFY_LOG)"
429
+ """ + _detached_launch(_REBUILD_BODY_COMMIT) + """)
430
+ # graphify-hook-end
431
+ """
432
+
433
+
434
+ _CHECKOUT_SCRIPT = """\
435
+ # graphify-checkout-hook-start
436
+ # Auto-rebuilds the knowledge graph (code only) when switching branches.
437
+ # Installed by: graphify hook install
438
+ (
439
+
440
+ # Deterministic clustering: networkx louvain iterates string-keyed sets whose
441
+ # order is randomized per-process by PYTHONHASHSEED, so community assignments
442
+ # churn run-to-run. Pinning it makes graphify-out reproducible.
443
+ export PYTHONHASHSEED=0
444
+ __VIZ_LIMIT_EXPORT__
445
+ # Git for Windows/MSYS hooks can inherit fragile pipe handles from GUI clients
446
+ # and agent shells. Keep hook-triggered rebuilds sequential by default there;
447
+ # explicit GRAPHIFY_MAX_WORKERS still wins for users who want parallelism.
448
+ if [ -n "${WINDIR:-}" ] || [ -n "${MSYSTEM:-}" ]; then
449
+ export GRAPHIFY_MAX_WORKERS="${GRAPHIFY_MAX_WORKERS:-1}"
450
+ fi
451
+
452
+ PREV_HEAD=$1
453
+ NEW_HEAD=$2
454
+ BRANCH_SWITCH=$3
455
+
456
+ # Only run on branch switches, not file checkouts
457
+ if [ "$BRANCH_SWITCH" != "1" ]; then
458
+ exit 0
459
+ fi
460
+
461
+ # A no-op checkout (e.g. `git checkout -b` with no start point) reports a
462
+ # branch switch but leaves the tree unchanged ΓÇö nothing to rebuild (#2421).
463
+ [ "$PREV_HEAD" = "$NEW_HEAD" ] && exit 0
464
+
465
+ # Only run if graphify-out/ exists (graph has been built before)
466
+ if [ ! -d "graphify-out" ]; then
467
+ exit 0
468
+ fi
469
+
470
+ # Skip during rebase/merge/cherry-pick
471
+ # git exports GIT_DIR to hooks; the rev-parse fallback only runs when invoked by
472
+ # hand (each git exec costs 1s+ on AV-scanned Windows machines).
473
+ GIT_DIR=${GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)}
474
+ [ -d "$GIT_DIR/rebase-merge" ] && exit 0
475
+ [ -d "$GIT_DIR/rebase-apply" ] && exit 0
476
+ [ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0
477
+ [ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
478
+
479
+ # Honor the same opt-out as post-commit: without this, GRAPHIFY_SKIP_HOOK=1
480
+ # suppressed commit-triggered rebuilds but not branch-switch ones (#1809).
481
+ [ "${GRAPHIFY_SKIP_HOOK:-0}" = "1" ] && exit 0
482
+
483
+ """ + _WORKTREE_GUARD + _PYTHON_DETECT + """
484
+ _GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log"
485
+ mkdir -p "$(dirname "$_GRAPHIFY_LOG")"
486
+ export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG"
487
+ echo "[graphify] Branch switched - launching background rebuild (log: $_GRAPHIFY_LOG)"
488
+ """ + _detached_launch(_REBUILD_BODY_CHECKOUT) + """)
489
+ # graphify-checkout-hook-end
490
+ """
491
+
492
+
493
+ def _load_graphifyrc(root: Path) -> dict[str, str | int]:
494
+ """Load key/value options from <root>/.graphifyrc if present.
495
+
496
+ Supported options:
497
+ viz_node_limit: integer >= 0 (e.g. viz_node_limit=0)
498
+ """
499
+ rc_path = root / ".graphifyrc"
500
+ if not rc_path.is_file():
501
+ return {}
502
+
503
+ cfg: dict[str, str | int] = {}
504
+ content = rc_path.read_text(encoding="utf-8")
505
+ for line_num, raw in enumerate(content.splitlines(), 1):
506
+ line = raw.strip()
507
+ if not line or line.startswith("#"):
508
+ continue
509
+ if "=" not in line:
510
+ raise ValueError(f"Invalid line {line_num} in {rc_path}: {raw!r} (expected key=value)")
511
+ key, val = line.split("=", 1)
512
+ key = key.strip()
513
+ val = val.strip()
514
+ if key == "viz_node_limit":
515
+ try:
516
+ parsed_val = int(val)
517
+ if parsed_val < 0:
518
+ raise ValueError("must be a non-negative integer")
519
+ cfg["viz_node_limit"] = parsed_val
520
+ except ValueError as exc:
521
+ raise ValueError(
522
+ f"Invalid viz_node_limit in {rc_path} at line {line_num}: {val!r}. "
523
+ f"Must be a non-negative integer."
524
+ ) from exc
525
+ return cfg
526
+
527
+
528
+ def _git_root(path: Path) -> Path | None:
529
+ """Walk up to find .git directory."""
530
+ current = path.resolve()
531
+ for parent in [current, *current.parents]:
532
+ if (parent / ".git").exists():
533
+ return parent
534
+ return None
535
+
536
+
537
+ _WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]")
538
+
539
+
540
+ def _reject_windows_path(value: str, source: str) -> None:
541
+ """Raise if a hooks path looks like a Windows absolute path (#1385).
542
+
543
+ On POSIX/WSL ``Path("C:\\Users\\...").is_absolute()`` is False, so an absolute
544
+ Windows hooks path gets joined under the repo root and mkdir'd as a literal
545
+ junk directory (backslashes and all), while install reports success and the
546
+ real ``.git/hooks`` gets nothing. Fail loudly instead so the user can fix it.
547
+ """
548
+ if os.name == "nt":
549
+ return
550
+ if _WINDOWS_DRIVE_RE.match(value) or "\\" in value:
551
+ raise RuntimeError(
552
+ f"git hooks path from {source} looks like a Windows path: {value!r}. "
553
+ f"On WSL/POSIX this can't resolve to a real directory. Unset it with "
554
+ f"`git config --local --unset core.hooksPath`, or set a POSIX path."
555
+ )
556
+
557
+
558
+ def _hooks_dir(root: Path) -> Path:
559
+ """Return the git hooks directory, respecting core.hooksPath if set (e.g. Husky).
560
+
561
+ Asks git itself via ``rev-parse --git-path hooks`` rather than parsing
562
+ ``.git/config`` with configparser: git legally allows duplicate keys and
563
+ sections (VS Code writes such configs), which a strict configparser rejects
564
+ with DuplicateOptionError/DuplicateSectionError, so every hook command
565
+ printed a spurious "could not read core.hooksPath" warning (#1907). git
566
+ resolves core.hooksPath, includeIf, and linked worktrees (where .git is a
567
+ file, not a directory) correctly in one place. Genuinely corrupt configs
568
+ are still surfaced: git itself fails on them, and its stderr is printed.
569
+ """
570
+ # NOTE: do NOT pass --path-format=absolute — added in git 2.31; older git
571
+ # echoes it back as a literal argument, contaminating stdout and causing a
572
+ # phantom directory to be created (#907). git -C <root> already returns an
573
+ # absolute path for worktree/external-gitdir cases, and a path relative to
574
+ # <root> for normal repos — anchoring on root covers both.
575
+ import subprocess as _sp
576
+ try:
577
+ res = _sp.run(
578
+ ["git", "-C", str(root), "rev-parse", "--git-path", "hooks"],
579
+ capture_output=True, text=True,
580
+ )
581
+ if res.returncode != 0:
582
+ # git failing here is a real signal (corrupt .git/config, tampering,
583
+ # permission flips by another tool). Surface git's own stderr rather
584
+ # than silently falling through to the default hooks directory.
585
+ err = (res.stderr or "").strip()
586
+ print(
587
+ f"[graphify hooks] git could not resolve the hooks path for "
588
+ f"{root}: {err or f'git exited with code {res.returncode}'}",
589
+ file=sys.stderr,
590
+ )
591
+ else:
592
+ raw = res.stdout.strip()
593
+ # A valid hooks path can never contain newlines or NUL. Their presence
594
+ # means git echoed an unrecognised flag back (old git behaviour).
595
+ if raw and not any(c in raw for c in ("\n", "\r", "\x00")):
596
+ _reject_windows_path(raw, "git rev-parse --git-path hooks")
597
+ d = (root / raw).resolve()
598
+ d.mkdir(parents=True, exist_ok=True)
599
+ return d
600
+ except (OSError, FileNotFoundError):
601
+ pass
602
+ d = root / ".git" / "hooks"
603
+ d.mkdir(parents=True, exist_ok=True)
604
+ return d
605
+
606
+
607
+ def _install_hook(
608
+ hooks_dir: Path,
609
+ name: str,
610
+ script: str,
611
+ marker: str,
612
+ marker_end: str = "",
613
+ ) -> str:
614
+ """Install a single git hook, appending if an existing hook is present, or updating
615
+ an existing graphify block in-place."""
616
+ hook_path = hooks_dir / name
617
+ if hook_path.exists():
618
+ content = hook_path.read_text(encoding="utf-8")
619
+ if marker in content:
620
+ if marker_end and marker_end in content:
621
+ start_idx = content.find(marker)
622
+ end_idx = content.find(marker_end)
623
+ if start_idx != -1 and end_idx != -1 and end_idx >= start_idx:
624
+ end_idx += len(marker_end)
625
+ new_content = content[:start_idx] + script.rstrip() + content[end_idx:]
626
+ if new_content == content:
627
+ return f"already installed at {hook_path}"
628
+ hook_path.write_text(new_content, encoding="utf-8", newline="\n")
629
+ return f"updated existing {name} hook at {hook_path}"
630
+ return f"already installed at {hook_path}"
631
+ hook_path.write_text(content.rstrip() + "\n\n" + script, encoding="utf-8", newline="\n")
632
+ return f"appended to existing {name} hook at {hook_path}"
633
+ hook_path.write_text("#!/bin/sh\n" + script, encoding="utf-8", newline="\n")
634
+ hook_path.chmod(0o755)
635
+ return f"installed at {hook_path}"
636
+
637
+
638
+ def _uninstall_hook(hooks_dir: Path, name: str, marker: str, marker_end: str) -> str:
639
+ """Remove graphify section from a git hook using start/end markers."""
640
+ hook_path = hooks_dir / name
641
+ if not hook_path.exists():
642
+ return f"no {name} hook found - nothing to remove."
643
+ content = hook_path.read_text(encoding="utf-8")
644
+ if marker not in content:
645
+ return f"graphify hook not found in {name} - nothing to remove."
646
+ new_content = re.sub(
647
+ rf"{re.escape(marker)}.*?{re.escape(marker_end)}\n?",
648
+ "",
649
+ content,
650
+ flags=re.DOTALL,
651
+ ).strip()
652
+ if not new_content or new_content in ("#!/bin/bash", "#!/bin/sh"):
653
+ hook_path.unlink()
654
+ return f"removed {name} hook at {hook_path}"
655
+ hook_path.write_text(new_content + "\n", encoding="utf-8", newline="\n")
656
+ return f"graphify removed from {name} at {hook_path} (other hook content preserved)"
657
+
658
+
659
+ def _pinned_python() -> str:
660
+ """Return sys.executable if its path is shell-safe, else an empty string.
661
+
662
+ Applies the same allowlist used in _PYTHON_DETECT: rejects any character
663
+ that is not a valid plain filesystem path character, preventing $(...),
664
+ backtick, double-quote, semicolon, etc. from being injected into generated
665
+ shell scripts or the merge-driver command line. The allowlist includes ':'
666
+ and '\\' so Windows paths (C:\\...) are accepted, and a plain space so
667
+ Windows profile paths (C:\\Users\\First Last\\...) are too — a space cannot
668
+ start a substitution or a new command, and every consumer quotes the value:
669
+ the hook scripts embed it as '$_PINNED' (single-quoted, then referenced as
670
+ "$_PINNED") and _register_merge_driver double-quotes it (#2166). Before that
671
+ a space rejected the whole path, so hooks installed under any Windows user
672
+ whose profile name contains a space silently pinned nothing. An empty return
673
+ means callers must fall back to the `graphify` launcher on PATH — safe
674
+ degradation.
675
+ """
676
+ if re.search(r"[^a-zA-Z0-9/_.@: \\-]", sys.executable):
677
+ return ""
678
+ if _is_rotating_prefix(sys.executable):
679
+ return ""
680
+ return sys.executable
681
+
682
+
683
+ # ``~/snap/<app>/<revision>/`` — snap swaps <revision> on every package update and
684
+ # prunes the old tree, so anything under it is a path with an expiry date.
685
+ _ROTATING_PREFIX_RE = re.compile(r"/snap/[^/]+/(\d+|current)/")
686
+
687
+
688
+ def _is_rotating_prefix(path: str) -> bool:
689
+ """True if `path` lives under a directory the packaging system rotates.
690
+
691
+ A pin is only worth writing if it will still resolve tomorrow. An interpreter
692
+ inside a snap revision will not: the revision number changes on update and the
693
+ old tree is removed, which silently kills every hook pinned to it — observed
694
+ across 15 repositories at once when an editor snap moved past its revision.
695
+
696
+ Returning "" here is the documented safe degradation: the hook falls through to
697
+ its other probes, including the uv-tools scan, which searches snap-confined
698
+ homes too.
699
+ """
700
+ return bool(_ROTATING_PREFIX_RE.search(path.replace("\\", "/")))
701
+
702
+
703
+ def _merge_attr_line() -> str:
704
+ """The .gitattributes line assigning the graphify merge driver to graph.json.
705
+
706
+ The graph lives under the configured output directory (graphify.paths,
707
+ GRAPHIFY_OUT env override). gitattributes patterns are repo-relative, so an
708
+ absolute output-dir override cannot be expressed there — fall back to the
709
+ default name in that case.
710
+ """
711
+ from graphify.paths import GRAPHIFY_OUT
712
+ out = GRAPHIFY_OUT
713
+ if not out or Path(out).is_absolute() or "\\" in out:
714
+ out = "graphify-out"
715
+ return f"{out.rstrip('/')}/graph.json merge=graphify"
716
+
717
+
718
+ def _has_merge_attr(content: str) -> bool:
719
+ """True if a (non-comment) `<...>graph.json ... merge=graphify` line exists."""
720
+ for raw in content.splitlines():
721
+ line = raw.strip()
722
+ if not line or line.startswith("#"):
723
+ continue
724
+ fields = line.split()
725
+ if fields and fields[0].endswith("graph.json") and "merge=graphify" in fields[1:]:
726
+ return True
727
+ return False
728
+
729
+
730
+ def _register_merge_driver(root: Path) -> str:
731
+ """Register the graph.json union merge driver in git config + .gitattributes (#1902).
732
+
733
+ README and CHANGELOG 0.7.0 document `graphify merge-driver` as being set up
734
+ by `hook install`, but install never actually registered it. Writes go
735
+ through `git config` (never hand-edit .git/config — in a linked worktree the
736
+ effective config is not at root/.git/config). The interpreter is pinned the
737
+ same way the hook scripts pin it, so the driver works even when the graphify
738
+ launcher is not on PATH at merge time.
739
+ """
740
+ import subprocess as _sp
741
+ pinned = _pinned_python()
742
+ if pinned:
743
+ # Double-quoted: the allowlist in _pinned_python() permits a space (Windows
744
+ # profile paths), and git runs this driver string through a shell, so an
745
+ # unquoted "C:\\Users\\First Last\\...\\python.exe" would split into two
746
+ # words and the driver would never run (#2166). The same allowlist keeps
747
+ # '$' and backticks out, so double quotes cannot introduce expansion.
748
+ driver = f'"{pinned}" -m graphify merge-driver %O %A %B'
749
+ else:
750
+ driver = "graphify merge-driver %O %A %B"
751
+ try:
752
+ for key, value in (
753
+ ("merge.graphify.name", "graphify graph.json union merge"),
754
+ ("merge.graphify.driver", driver),
755
+ ):
756
+ _sp.run(
757
+ ["git", "-C", str(root), "config", key, value],
758
+ check=True, capture_output=True, text=True,
759
+ )
760
+ except (OSError, _sp.CalledProcessError) as exc:
761
+ return f"not registered (git config failed: {exc})"
762
+
763
+ line = _merge_attr_line()
764
+ attrs = root / ".gitattributes"
765
+ if attrs.exists():
766
+ content = attrs.read_text(encoding="utf-8")
767
+ if _has_merge_attr(content):
768
+ return f"already registered ({line})"
769
+ # Never clobber other entries; preserve a trailing newline.
770
+ if content and not content.endswith("\n"):
771
+ content += "\n"
772
+ attrs.write_text(content + line + "\n", encoding="utf-8", newline="\n")
773
+ else:
774
+ attrs.write_text(line + "\n", encoding="utf-8", newline="\n")
775
+ return f"registered ({line})"
776
+
777
+
778
+ def _unregister_merge_driver(root: Path) -> str:
779
+ """Remove the merge-driver git config keys and the .gitattributes line."""
780
+ import subprocess as _sp
781
+ for key in ("merge.graphify.name", "merge.graphify.driver"):
782
+ try:
783
+ # --unset exits nonzero if the key is absent; that is fine.
784
+ _sp.run(
785
+ ["git", "-C", str(root), "config", "--unset", key],
786
+ capture_output=True, text=True,
787
+ )
788
+ except OSError:
789
+ pass
790
+ attrs = root / ".gitattributes"
791
+ if not attrs.exists():
792
+ return "not registered - nothing to remove."
793
+ content = attrs.read_text(encoding="utf-8")
794
+ kept = [
795
+ raw for raw in content.splitlines()
796
+ if not _has_merge_attr(raw)
797
+ ]
798
+ if kept == content.splitlines():
799
+ return "gitattributes entry not found - nothing to remove."
800
+ if kept:
801
+ # Other entries survive; the file stays.
802
+ attrs.write_text("\n".join(kept) + "\n", encoding="utf-8", newline="\n")
803
+ return "removed from .gitattributes (other entries preserved)"
804
+ attrs.unlink()
805
+ return "removed (.gitattributes deleted - no other entries)"
806
+
807
+
808
+ def _merge_driver_status(root: Path) -> str:
809
+ """Report whether the merge driver is registered (config + gitattributes)."""
810
+ import subprocess as _sp
811
+ try:
812
+ res = _sp.run(
813
+ ["git", "-C", str(root), "config", "--get", "merge.graphify.driver"],
814
+ capture_output=True, text=True,
815
+ )
816
+ cfg_ok = res.returncode == 0 and bool(res.stdout.strip())
817
+ except OSError:
818
+ cfg_ok = False
819
+ attrs = root / ".gitattributes"
820
+ attr_ok = attrs.exists() and _has_merge_attr(attrs.read_text(encoding="utf-8"))
821
+ if cfg_ok and attr_ok:
822
+ return "registered"
823
+ if cfg_ok:
824
+ return "partially registered (git config set, .gitattributes line missing)"
825
+ if attr_ok:
826
+ return "partially registered (.gitattributes line set, git config missing)"
827
+ return "not registered"
828
+
829
+
830
+ def _user_hooks_dir(hooks_dir: Path) -> Path:
831
+ """Return the user-editable hooks directory.
832
+
833
+ Husky 9 sets core.hooksPath to .husky/_ (wrapper scripts auto-generated by
834
+ Husky), while user-editable hooks live in the parent .husky/. Return the
835
+ parent when the resolved dir ends in '_' so install/status/uninstall target
836
+ the correct location (#987).
837
+ """
838
+ if hooks_dir.name == "_":
839
+ return hooks_dir.parent
840
+ return hooks_dir
841
+
842
+
843
+ def install(path: Path = Path(".")) -> str:
844
+ """Install graphify post-commit and post-checkout hooks in the nearest git repo."""
845
+ root = _git_root(path)
846
+ if root is None:
847
+ raise RuntimeError(f"No git repository found at or above {path.resolve()}")
848
+
849
+ hooks_dir = _user_hooks_dir(_hooks_dir(root))
850
+
851
+ cfg = _load_graphifyrc(root)
852
+ viz_limit = cfg.get("viz_node_limit")
853
+ if viz_limit is not None:
854
+ # Use the `:-` default form (like GRAPHIFY_MAX_WORKERS below) so an
855
+ # explicit `GRAPHIFY_VIZ_NODE_LIMIT=... git commit` still wins over the
856
+ # baked project default — persisting config must not clobber a per-run
857
+ # override.
858
+ viz_export = f'export GRAPHIFY_VIZ_NODE_LIMIT="${{GRAPHIFY_VIZ_NODE_LIMIT:-{viz_limit}}}"\n'
859
+ else:
860
+ viz_export = ""
861
+
862
+ pinned = _pinned_python()
863
+ hook = _HOOK_SCRIPT.replace("__PINNED_PYTHON__", pinned).replace("__VIZ_LIMIT_EXPORT__", viz_export)
864
+ checkout = _CHECKOUT_SCRIPT.replace("__PINNED_PYTHON__", pinned).replace("__VIZ_LIMIT_EXPORT__", viz_export)
865
+
866
+ commit_msg = _install_hook(hooks_dir, "post-commit", hook, _HOOK_MARKER, _HOOK_MARKER_END)
867
+ checkout_msg = _install_hook(hooks_dir, "post-checkout", checkout, _CHECKOUT_MARKER, _CHECKOUT_MARKER_END)
868
+ merge_msg = _register_merge_driver(root)
869
+
870
+ return f"post-commit: {commit_msg}\npost-checkout: {checkout_msg}\nmerge driver: {merge_msg}"
871
+
872
+
873
+ def uninstall(path: Path = Path(".")) -> str:
874
+ """Remove graphify post-commit and post-checkout hooks."""
875
+ root = _git_root(path)
876
+ if root is None:
877
+ raise RuntimeError(f"No git repository found at or above {path.resolve()}")
878
+
879
+ hooks_dir = _user_hooks_dir(_hooks_dir(root))
880
+ commit_msg = _uninstall_hook(hooks_dir, "post-commit", _HOOK_MARKER, _HOOK_MARKER_END)
881
+ checkout_msg = _uninstall_hook(hooks_dir, "post-checkout", _CHECKOUT_MARKER, _CHECKOUT_MARKER_END)
882
+ merge_msg = _unregister_merge_driver(root)
883
+
884
+ return f"post-commit: {commit_msg}\npost-checkout: {checkout_msg}\nmerge driver: {merge_msg}"
885
+
886
+
887
+ def status(path: Path = Path(".")) -> str:
888
+ """Check if graphify hooks are installed."""
889
+ root = _git_root(path)
890
+ if root is None:
891
+ return "Not in a git repository."
892
+ hooks_dir = _user_hooks_dir(_hooks_dir(root))
893
+ # status is a read-only diagnostic: a malformed .graphifyrc must not turn it
894
+ # into a traceback. Report the config problem and continue with no limit.
895
+ try:
896
+ cfg = _load_graphifyrc(root)
897
+ except ValueError as exc:
898
+ cfg = {}
899
+ print(f" warning: {exc}")
900
+ cfg_limit = cfg.get("viz_node_limit")
901
+
902
+ def _check(name: str, marker: str) -> str:
903
+ p = hooks_dir / name
904
+ if not p.exists():
905
+ return "not installed"
906
+ text = p.read_text(encoding="utf-8")
907
+ if marker not in text:
908
+ return "not installed (hook exists but graphify not found)"
909
+ if cfg_limit is not None:
910
+ # Baked as `"${GRAPHIFY_VIZ_NODE_LIMIT:-<n>}"` so a per-run override
911
+ # wins; match the default <n>, and still accept the older bare
912
+ # `"<n>"` form from hooks installed before that change.
913
+ m = re.search(
914
+ r'export GRAPHIFY_VIZ_NODE_LIMIT="(?:\$\{GRAPHIFY_VIZ_NODE_LIMIT:-(\d+)\}|(\d+))"',
915
+ text,
916
+ )
917
+ installed_limit = int(m.group(1) or m.group(2)) if m else None
918
+ if installed_limit != cfg_limit:
919
+ return (
920
+ f"installed (out of date: hook has limit "
921
+ f"{installed_limit if installed_limit is not None else 'unset'}, "
922
+ f".graphifyrc has {cfg_limit})"
923
+ )
924
+ return "installed"
925
+
926
+ commit = _check("post-commit", _HOOK_MARKER)
927
+ checkout = _check("post-checkout", _CHECKOUT_MARKER)
928
+ merge = _merge_driver_status(root)
929
+
930
+ res = f"post-commit: {commit}\npost-checkout: {checkout}\nmerge driver: {merge}"
931
+ if cfg_limit is not None:
932
+ res += f"\nviz node limit: {cfg_limit}"
933
+ return res