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.
- graphify/__init__.py +30 -0
- graphify/__main__.py +757 -0
- graphify/_minhash.py +107 -0
- graphify/affected.py +318 -0
- graphify/always_on/agents-md.md +12 -0
- graphify/always_on/antigravity-rules.md +14 -0
- graphify/always_on/claude-md.md +9 -0
- graphify/always_on/gemini-md.md +9 -0
- graphify/always_on/kiro-steering.md +5 -0
- graphify/always_on/vscode-instructions.md +17 -0
- graphify/analyze.py +769 -0
- graphify/benchmark.py +152 -0
- graphify/build.py +2300 -0
- graphify/cache.py +1746 -0
- graphify/callflow_html.py +2051 -0
- graphify/cargo_introspect.py +109 -0
- graphify/cli.py +4745 -0
- graphify/cluster.py +409 -0
- graphify/command-kilo.md +15 -0
- graphify/cross_repo_calls.py +216 -0
- graphify/cross_repo_types.py +75 -0
- graphify/csharp_dispatch.py +154 -0
- graphify/dedup.py +1213 -0
- graphify/detect.py +2566 -0
- graphify/diagnostics.py +406 -0
- graphify/export.py +1349 -0
- graphify/exporters/__init__.py +1 -0
- graphify/exporters/base.py +14 -0
- graphify/exporters/graphdb.py +173 -0
- graphify/exporters/html.py +637 -0
- graphify/extract.py +7856 -0
- graphify/extractors/MIGRATION.md +107 -0
- graphify/extractors/__init__.py +66 -0
- graphify/extractors/apex.py +215 -0
- graphify/extractors/base.py +85 -0
- graphify/extractors/bash.py +579 -0
- graphify/extractors/blade.py +53 -0
- graphify/extractors/commonlisp.py +540 -0
- graphify/extractors/csharp.py +448 -0
- graphify/extractors/dart.py +564 -0
- graphify/extractors/dm.py +494 -0
- graphify/extractors/elixir.py +241 -0
- graphify/extractors/engine.py +6509 -0
- graphify/extractors/fortran.py +311 -0
- graphify/extractors/go.py +527 -0
- graphify/extractors/json_config.py +240 -0
- graphify/extractors/julia.py +289 -0
- graphify/extractors/markdown.py +408 -0
- graphify/extractors/models.py +131 -0
- graphify/extractors/objc.py +566 -0
- graphify/extractors/ocaml.py +289 -0
- graphify/extractors/pascal.py +688 -0
- graphify/extractors/pascal_forms.py +196 -0
- graphify/extractors/powershell.py +522 -0
- graphify/extractors/razor.py +192 -0
- graphify/extractors/resolution.py +3584 -0
- graphify/extractors/robot.py +296 -0
- graphify/extractors/rust.py +470 -0
- graphify/extractors/sln.py +92 -0
- graphify/extractors/sql.py +720 -0
- graphify/extractors/terraform.py +181 -0
- graphify/extractors/verilog.py +329 -0
- graphify/extractors/zig.py +181 -0
- graphify/file_slice.py +246 -0
- graphify/global_graph.py +194 -0
- graphify/google_workspace.py +237 -0
- graphify/hooks.py +933 -0
- graphify/ids.py +93 -0
- graphify/ingest.py +358 -0
- graphify/install.py +2366 -0
- graphify/llm.py +3544 -0
- graphify/manifest.py +4 -0
- graphify/manifest_ingest.py +311 -0
- graphify/mcp_ingest.py +386 -0
- graphify/multigraph_compat.py +212 -0
- graphify/pascal_resolution.py +129 -0
- graphify/paths.py +436 -0
- graphify/pg_introspect.py +165 -0
- graphify/prs.py +770 -0
- graphify/querylog.py +80 -0
- graphify/reflect.py +882 -0
- graphify/report.py +346 -0
- graphify/resolver_registry.py +85 -0
- graphify/ruby_resolution.py +242 -0
- graphify/scip_ingest.py +363 -0
- graphify/security.py +460 -0
- graphify/semantic_cleanup.py +336 -0
- graphify/serve.py +2608 -0
- graphify/skill-agents.md +710 -0
- graphify/skill-aider.md +1283 -0
- graphify/skill-amp.md +710 -0
- graphify/skill-claw.md +713 -0
- graphify/skill-codex.md +710 -0
- graphify/skill-copilot.md +713 -0
- graphify/skill-devin.md +1410 -0
- graphify/skill-droid.md +710 -0
- graphify/skill-kilo.md +722 -0
- graphify/skill-kiro.md +713 -0
- graphify/skill-opencode.md +705 -0
- graphify/skill-pi.md +713 -0
- graphify/skill-trae.md +711 -0
- graphify/skill-vscode.md +709 -0
- graphify/skill-windows.md +755 -0
- graphify/skill.md +713 -0
- graphify/skills/agents/references/add-watch.md +56 -0
- graphify/skills/agents/references/exports.md +87 -0
- graphify/skills/agents/references/extraction-spec.md +70 -0
- graphify/skills/agents/references/github-and-merge.md +46 -0
- graphify/skills/agents/references/hooks.md +33 -0
- graphify/skills/agents/references/query.md +311 -0
- graphify/skills/agents/references/transcribe.md +52 -0
- graphify/skills/agents/references/update.md +210 -0
- graphify/skills/amp/references/add-watch.md +56 -0
- graphify/skills/amp/references/exports.md +87 -0
- graphify/skills/amp/references/extraction-spec.md +70 -0
- graphify/skills/amp/references/github-and-merge.md +46 -0
- graphify/skills/amp/references/hooks.md +33 -0
- graphify/skills/amp/references/query.md +311 -0
- graphify/skills/amp/references/transcribe.md +52 -0
- graphify/skills/amp/references/update.md +210 -0
- graphify/skills/claude/references/add-watch.md +56 -0
- graphify/skills/claude/references/exports.md +87 -0
- graphify/skills/claude/references/extraction-spec.md +70 -0
- graphify/skills/claude/references/github-and-merge.md +46 -0
- graphify/skills/claude/references/hooks.md +33 -0
- graphify/skills/claude/references/query.md +311 -0
- graphify/skills/claude/references/transcribe.md +52 -0
- graphify/skills/claude/references/update.md +210 -0
- graphify/skills/claw/references/add-watch.md +56 -0
- graphify/skills/claw/references/exports.md +87 -0
- graphify/skills/claw/references/extraction-spec.md +31 -0
- graphify/skills/claw/references/github-and-merge.md +46 -0
- graphify/skills/claw/references/hooks.md +33 -0
- graphify/skills/claw/references/query.md +311 -0
- graphify/skills/claw/references/transcribe.md +52 -0
- graphify/skills/claw/references/update.md +210 -0
- graphify/skills/codex/references/add-watch.md +56 -0
- graphify/skills/codex/references/exports.md +87 -0
- graphify/skills/codex/references/extraction-spec.md +31 -0
- graphify/skills/codex/references/github-and-merge.md +46 -0
- graphify/skills/codex/references/hooks.md +33 -0
- graphify/skills/codex/references/query.md +311 -0
- graphify/skills/codex/references/transcribe.md +52 -0
- graphify/skills/codex/references/update.md +210 -0
- graphify/skills/copilot/references/add-watch.md +56 -0
- graphify/skills/copilot/references/exports.md +87 -0
- graphify/skills/copilot/references/extraction-spec.md +70 -0
- graphify/skills/copilot/references/github-and-merge.md +46 -0
- graphify/skills/copilot/references/hooks.md +33 -0
- graphify/skills/copilot/references/query.md +311 -0
- graphify/skills/copilot/references/transcribe.md +52 -0
- graphify/skills/copilot/references/update.md +210 -0
- graphify/skills/droid/references/add-watch.md +56 -0
- graphify/skills/droid/references/exports.md +87 -0
- graphify/skills/droid/references/extraction-spec.md +70 -0
- graphify/skills/droid/references/github-and-merge.md +46 -0
- graphify/skills/droid/references/hooks.md +33 -0
- graphify/skills/droid/references/query.md +311 -0
- graphify/skills/droid/references/transcribe.md +52 -0
- graphify/skills/droid/references/update.md +210 -0
- graphify/skills/kilo/references/add-watch.md +56 -0
- graphify/skills/kilo/references/exports.md +87 -0
- graphify/skills/kilo/references/extraction-spec.md +70 -0
- graphify/skills/kilo/references/github-and-merge.md +46 -0
- graphify/skills/kilo/references/hooks.md +33 -0
- graphify/skills/kilo/references/query.md +311 -0
- graphify/skills/kilo/references/transcribe.md +52 -0
- graphify/skills/kilo/references/update.md +210 -0
- graphify/skills/kiro/references/add-watch.md +56 -0
- graphify/skills/kiro/references/exports.md +87 -0
- graphify/skills/kiro/references/extraction-spec.md +31 -0
- graphify/skills/kiro/references/github-and-merge.md +46 -0
- graphify/skills/kiro/references/hooks.md +33 -0
- graphify/skills/kiro/references/query.md +311 -0
- graphify/skills/kiro/references/transcribe.md +52 -0
- graphify/skills/kiro/references/update.md +210 -0
- graphify/skills/opencode/references/add-watch.md +56 -0
- graphify/skills/opencode/references/exports.md +87 -0
- graphify/skills/opencode/references/extraction-spec.md +70 -0
- graphify/skills/opencode/references/github-and-merge.md +46 -0
- graphify/skills/opencode/references/hooks.md +33 -0
- graphify/skills/opencode/references/query.md +311 -0
- graphify/skills/opencode/references/transcribe.md +52 -0
- graphify/skills/opencode/references/update.md +210 -0
- graphify/skills/pi/references/add-watch.md +56 -0
- graphify/skills/pi/references/exports.md +87 -0
- graphify/skills/pi/references/extraction-spec.md +31 -0
- graphify/skills/pi/references/github-and-merge.md +46 -0
- graphify/skills/pi/references/hooks.md +33 -0
- graphify/skills/pi/references/query.md +311 -0
- graphify/skills/pi/references/transcribe.md +52 -0
- graphify/skills/pi/references/update.md +210 -0
- graphify/skills/trae/references/add-watch.md +56 -0
- graphify/skills/trae/references/exports.md +87 -0
- graphify/skills/trae/references/extraction-spec.md +70 -0
- graphify/skills/trae/references/github-and-merge.md +46 -0
- graphify/skills/trae/references/hooks.md +35 -0
- graphify/skills/trae/references/query.md +311 -0
- graphify/skills/trae/references/transcribe.md +52 -0
- graphify/skills/trae/references/update.md +210 -0
- graphify/skills/vscode/references/add-watch.md +56 -0
- graphify/skills/vscode/references/exports.md +87 -0
- graphify/skills/vscode/references/extraction-spec.md +70 -0
- graphify/skills/vscode/references/github-and-merge.md +46 -0
- graphify/skills/vscode/references/hooks.md +33 -0
- graphify/skills/vscode/references/query.md +311 -0
- graphify/skills/vscode/references/transcribe.md +52 -0
- graphify/skills/vscode/references/update.md +210 -0
- graphify/skills/windows/references/add-watch.md +56 -0
- graphify/skills/windows/references/exports.md +87 -0
- graphify/skills/windows/references/extraction-spec.md +70 -0
- graphify/skills/windows/references/github-and-merge.md +46 -0
- graphify/skills/windows/references/hooks.md +33 -0
- graphify/skills/windows/references/query.md +311 -0
- graphify/skills/windows/references/transcribe.md +52 -0
- graphify/skills/windows/references/update.md +210 -0
- graphify/symbol_resolution.py +556 -0
- graphify/transcribe.py +186 -0
- graphify/tree_html.py +603 -0
- graphify/validate.py +95 -0
- graphify/watch.py +2280 -0
- graphify/wiki.py +405 -0
- graphitect/__init__.py +28 -0
- graphitect/__main__.py +4 -0
- graphitect/_vendor/__init__.py +2 -0
- graphitect/_vendor/archify/LICENSE +22 -0
- graphitect/_vendor/archify/SKILL.md +137 -0
- graphitect/_vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
- graphitect/_vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
- graphitect/_vendor/archify/assets/template.html +14935 -0
- graphitect/_vendor/archify/bin/archify.mjs +2091 -0
- graphitect/_vendor/archify/bin/open-artifact.mjs +86 -0
- graphitect/_vendor/archify/bin/preview.mjs +653 -0
- graphitect/_vendor/archify/bin/visual-check.mjs +829 -0
- graphitect/_vendor/archify/brand-marks/README.md +31 -0
- graphitect/_vendor/archify/brand-marks/catalog.json +131 -0
- graphitect/_vendor/archify/delta/architecture-delta.mjs +1221 -0
- graphitect/_vendor/archify/examples/agent-run.lifecycle.json +60 -0
- graphitect/_vendor/archify/examples/agent-tool-call.workflow.json +94 -0
- graphitect/_vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
- graphitect/_vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
- graphitect/_vendor/archify/examples/cache-miss-request.sequence.json +82 -0
- graphitect/_vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
- graphitect/_vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
- graphitect/_vendor/archify/examples/dataflow-product-analytics.html +15045 -0
- graphitect/_vendor/archify/examples/deployment-release.lifecycle.json +49 -0
- graphitect/_vendor/archify/examples/event-stream.dataflow.json +57 -0
- graphitect/_vendor/archify/examples/incident-response.workflow.json +64 -0
- graphitect/_vendor/archify/examples/lifecycle-agent-run.html +14980 -0
- graphitect/_vendor/archify/examples/product-analytics.dataflow.json +76 -0
- graphitect/_vendor/archify/examples/production-deployment.architecture.json +71 -0
- graphitect/_vendor/archify/examples/release-delivery.workflow.json +62 -0
- graphitect/_vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
- graphitect/_vendor/archify/examples/web-app-rendered.html +15009 -0
- graphitect/_vendor/archify/examples/web-app.architecture.json +46 -0
- graphitect/_vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
- graphitect/_vendor/archify/migrations/workflow-v2.mjs +279 -0
- graphitect/_vendor/archify/package-lock.json +149 -0
- graphitect/_vendor/archify/package.json +39 -0
- graphitect/_vendor/archify/recipes/scenarios.mjs +391 -0
- graphitect/_vendor/archify/references/authoring-contract.md +243 -0
- graphitect/_vendor/archify/references/brand-marks.md +65 -0
- graphitect/_vendor/archify/references/delivery-contract.md +120 -0
- graphitect/_vendor/archify/references/viewer-runtime.md +45 -0
- graphitect/_vendor/archify/renderers/architecture/grid.mjs +62 -0
- graphitect/_vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
- graphitect/_vendor/archify/renderers/dataflow/README.md +104 -0
- graphitect/_vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
- graphitect/_vendor/archify/renderers/lifecycle/README.md +115 -0
- graphitect/_vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
- graphitect/_vendor/archify/renderers/sequence/README.md +114 -0
- graphitect/_vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
- graphitect/_vendor/archify/renderers/shared/brand-marks.mjs +563 -0
- graphitect/_vendor/archify/renderers/shared/cli.mjs +218 -0
- graphitect/_vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
- graphitect/_vendor/archify/renderers/shared/diagnostics.mjs +127 -0
- graphitect/_vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
- graphitect/_vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- graphitect/_vendor/archify/renderers/shared/generated-validators.mjs +13 -0
- graphitect/_vendor/archify/renderers/shared/geometry.mjs +1423 -0
- graphitect/_vendor/archify/renderers/shared/i18n.mjs +595 -0
- graphitect/_vendor/archify/renderers/shared/layout-report.mjs +40 -0
- graphitect/_vendor/archify/renderers/shared/legend.mjs +217 -0
- graphitect/_vendor/archify/renderers/shared/output-path.mjs +340 -0
- graphitect/_vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
- graphitect/_vendor/archify/renderers/shared/repository-location.mjs +58 -0
- graphitect/_vendor/archify/renderers/shared/text-fit.mjs +49 -0
- graphitect/_vendor/archify/renderers/shared/utils.mjs +232 -0
- graphitect/_vendor/archify/renderers/shared/validator.mjs +86 -0
- graphitect/_vendor/archify/renderers/workflow/README.md +223 -0
- graphitect/_vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
- graphitect/_vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
- graphitect/_vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
- graphitect/_vendor/archify/schemas/README.md +211 -0
- graphitect/_vendor/archify/schemas/architecture.schema.json +178 -0
- graphitect/_vendor/archify/schemas/common.schema.json +115 -0
- graphitect/_vendor/archify/schemas/dataflow.schema.json +243 -0
- graphitect/_vendor/archify/schemas/lifecycle.schema.json +266 -0
- graphitect/_vendor/archify/schemas/sequence.schema.json +223 -0
- graphitect/_vendor/archify/schemas/workflow.schema.json +428 -0
- graphitect/_vendor/archify/scripts/check-render-output.mjs +836 -0
- graphitect/_vendor/archify/scripts/check-update.mjs +1667 -0
- graphitect/_vendor/archify/scripts/generate-brand-marks.mjs +141 -0
- graphitect/_vendor/archify/scripts/generate-validators.mjs +66 -0
- graphitect/_vendor/archify/scripts/render-examples.mjs +26 -0
- graphitect/_vendor/archify/scripts/update-contract.mjs +182 -0
- graphitect/_vendor/archify/skill-release.json +10 -0
- graphitect/cli.py +981 -0
- graphitect/deliver/__init__.py +5 -0
- graphitect/deliver/archify_adapter.py +1877 -0
- graphitect/deliver/archify_ir.py +160 -0
- graphitect/deliver/archify_repair.py +135 -0
- graphitect/deliver/doc_compiler.py +916 -0
- graphitect/ground/__init__.py +5 -0
- graphitect/ground/describe_source.py +27 -0
- graphitect/ground/fullread_source.py +56 -0
- graphitect/ground/graphify_source.py +107 -0
- graphitect/models.py +118 -0
- graphitect/skill/SKILL.md +80 -0
- graphitect/skill/agents/openai.yaml +4 -0
- graphitect/synthesize/__init__.py +5 -0
- graphitect/synthesize/engine.py +281 -0
- graphitect/synthesize/llm_backend.py +331 -0
- graphitect/synthesize/questions.py +139 -0
- graphitect/synthesize/rubric.py +104 -0
- graphitect-0.2.0.dist-info/METADATA +284 -0
- graphitect-0.2.0.dist-info/RECORD +336 -0
- graphitect-0.2.0.dist-info/WHEEL +5 -0
- graphitect-0.2.0.dist-info/entry_points.txt +2 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE +21 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-ARCHIFY-MIT +22 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-APACHE-2.0 +202 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-MIT +21 -0
- graphitect-0.2.0.dist-info/licenses/NOTICE-ARCHIFY-THIRD-PARTY.md +69 -0
- graphitect-0.2.0.dist-info/licenses/NOTICE-GRAPHIFY +8 -0
- 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
|