chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,548 @@
|
||||
# git hook integration - install/uninstall graphify post-commit and post-checkout hooks
|
||||
from __future__ import annotations
|
||||
import configparser
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_HOOK_MARKER = "# graphify-hook-start"
|
||||
_HOOK_MARKER_END = "# graphify-hook-end"
|
||||
_CHECKOUT_MARKER = "# graphify-checkout-hook-start"
|
||||
_CHECKOUT_MARKER_END = "# graphify-checkout-hook-end"
|
||||
|
||||
# __PINNED_PYTHON__ is replaced at install time with the absolute path of the
|
||||
# Python interpreter that ran `graphify hook install`. For uv-tool and pipx
|
||||
# installs the interpreter lives inside an isolated venv, so the launcher on
|
||||
# PATH is the only entry point — and GUI git clients / CI runners often have a
|
||||
# minimal PATH that omits ~/.local/bin. Pinning sys.executable at install time
|
||||
# makes the hook work regardless of PATH at git-trigger time.
|
||||
_PYTHON_DETECT = """\
|
||||
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs).
|
||||
# _PINNED was recorded at hook-install time; tried first so the hook works even
|
||||
# when the graphify launcher is not on PATH (common in GUI clients and CI).
|
||||
#
|
||||
# Probes check availability with importlib.util.find_spec instead of importing
|
||||
# the package: a probe that imports graphify wholesale executes the full package
|
||||
# import (10s+ cold on machines with AV-scanned or large site-packages) and used
|
||||
# to run up to FOUR times synchronously, stalling every commit before the
|
||||
# detached launch even started. find_spec locates the package without executing
|
||||
# it, so each probe costs interpreter startup only. The detached rebuild still
|
||||
# fails loudly in the log if the package is broken under that interpreter.
|
||||
_GFY_PROBE="import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('graphify') else 1)"
|
||||
GRAPHIFY_PYTHON=""
|
||||
_PINNED='__PINNED_PYTHON__'
|
||||
if [ -n "$_PINNED" ] && [ -x "$_PINNED" ] && "$_PINNED" -c "$_GFY_PROBE" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON="$_PINNED"
|
||||
fi
|
||||
# Second probe: read graphify-out/.graphify_python (written by the skill and
|
||||
# CLI; survives uv-tool reinstalls and is the same source the README documents).
|
||||
if [ -z "$GRAPHIFY_PYTHON" ]; then
|
||||
_GFY_PYTHON_FILE="graphify-out/.graphify_python"
|
||||
if [ -f "$_GFY_PYTHON_FILE" ]; then
|
||||
_FROM_FILE=$(cat "$_GFY_PYTHON_FILE" 2>/dev/null | tr -d '[:space:]')
|
||||
case "$_FROM_FILE" in
|
||||
*[!a-zA-Z0-9/_.@:\\-]*) _FROM_FILE="" ;; # allowlist (covers Windows paths)
|
||||
esac
|
||||
if [ -n "$_FROM_FILE" ] && [ -x "$_FROM_FILE" ] && "$_FROM_FILE" -c "$_GFY_PROBE" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON="$_FROM_FILE"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# Third probe: resolve via the graphify launcher on PATH.
|
||||
if [ -z "$GRAPHIFY_PYTHON" ]; then
|
||||
GRAPHIFY_BIN=$(command -v graphify 2>/dev/null)
|
||||
if [ -n "$GRAPHIFY_BIN" ]; then
|
||||
# Windows pip layout: Scripts/graphify(.exe) sits beside ..\\python.exe
|
||||
# (or .\\python.exe inside a venv's Scripts dir). NOTE: command -v may
|
||||
# return the launcher path WITHOUT the .exe suffix, so this cannot key
|
||||
# on the extension.
|
||||
_GFY_BINDIR=$(dirname "$GRAPHIFY_BIN")
|
||||
if [ -x "$_GFY_BINDIR/../python.exe" ] && "$_GFY_BINDIR/../python.exe" -c "$_GFY_PROBE" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON="$_GFY_BINDIR/../python.exe"
|
||||
elif [ -x "$_GFY_BINDIR/python.exe" ] && "$_GFY_BINDIR/python.exe" -c "$_GFY_PROBE" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON="$_GFY_BINDIR/python.exe"
|
||||
fi
|
||||
fi
|
||||
if [ -z "$GRAPHIFY_PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
|
||||
# POSIX launcher: parse the shebang. head -c + tr strip NUL bytes first —
|
||||
# when the launcher is a Windows binary reached without its .exe suffix,
|
||||
# a raw `head -1` reads binary into the command substitution and the
|
||||
# shell warns about ignored null bytes on every commit.
|
||||
case "$GRAPHIFY_BIN" in
|
||||
*.exe) _SHEBANG="" ;;
|
||||
*) _SHEBANG=$(head -c 256 "$GRAPHIFY_BIN" 2>/dev/null | tr -d '\\000' | head -n 1 | sed 's/^#![[:space:]]*//') ;;
|
||||
esac
|
||||
case "$_SHEBANG" in
|
||||
*/env\\ *) GRAPHIFY_PYTHON="${_SHEBANG#*/env }" ;;
|
||||
*) GRAPHIFY_PYTHON="$_SHEBANG" ;;
|
||||
esac
|
||||
# Allowlist: only keep characters valid in a filesystem path to prevent
|
||||
# injection if the shebang contains shell metacharacters.
|
||||
case "$GRAPHIFY_PYTHON" in
|
||||
*[!a-zA-Z0-9/_.@-]*) GRAPHIFY_PYTHON="" ;;
|
||||
esac
|
||||
if [ -n "$GRAPHIFY_PYTHON" ] && ! "$GRAPHIFY_PYTHON" -c "$_GFY_PROBE" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON=""
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# Last resort: try python3 / python (works for system/venv installs on PATH).
|
||||
if [ -z "$GRAPHIFY_PYTHON" ]; then
|
||||
if command -v python3 >/dev/null 2>&1 && python3 -c "$_GFY_PROBE" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON="python3"
|
||||
elif command -v python >/dev/null 2>&1 && python -c "$_GFY_PROBE" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON="python"
|
||||
else
|
||||
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
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
"""
|
||||
|
||||
# The Python that the rebuild runs, shared by both hooks. Embedded verbatim into
|
||||
# the launcher below and re-executed in the detached child. Must not contain the
|
||||
# double-quote, $, backtick or backslash characters: it is carried inside a
|
||||
# shell double-quoted `-c "..."` argument (see _detached_launch).
|
||||
_REBUILD_BODY_COMMIT = """\
|
||||
import os, signal, sys
|
||||
from pathlib import Path
|
||||
|
||||
changed_raw = os.environ.get('GRAPHIFY_CHANGED', '')
|
||||
changed = [Path(f.strip()) for f in changed_raw.strip().splitlines() if f.strip()]
|
||||
|
||||
if not changed:
|
||||
sys.exit(0)
|
||||
|
||||
print(f'[graphify hook] {len(changed)} file(s) changed - rebuilding graph...')
|
||||
|
||||
try:
|
||||
from graphify.watch import _rebuild_code, _apply_resource_limits
|
||||
_apply_resource_limits()
|
||||
_timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600'))
|
||||
if _timeout > 0 and hasattr(signal, 'SIGALRM'):
|
||||
signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s')))
|
||||
signal.alarm(_timeout)
|
||||
_force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
|
||||
_root = Path('.')
|
||||
_out = os.environ.get('GRAPHIFY_OUT', 'graphify-out')
|
||||
_saved = Path(_out) / '.graphify_root'
|
||||
if _saved.exists():
|
||||
_txt = _saved.read_text(encoding='utf-8').strip()
|
||||
if _txt:
|
||||
_root = Path(_txt)
|
||||
_rebuild_code(_root, changed_paths=changed, force=_force)
|
||||
# Refresh the work-memory lessons doc when saved Q&A outcomes exist
|
||||
# (best-effort; never fails the hook).
|
||||
try:
|
||||
_md = (_root / _out) / 'memory'
|
||||
if _md.is_dir() and any(_md.glob('*.md')):
|
||||
from graphify.reflect import reflect as _reflect
|
||||
_gj = (_root / _out) / 'graph.json'
|
||||
_reflect(memory_dir=_md, out_path=(_root / _out) / 'reflections' / 'LESSONS.md',
|
||||
graph_path=_gj if _gj.exists() else None)
|
||||
except Exception:
|
||||
pass
|
||||
except TimeoutError as exc:
|
||||
print(f'[graphify hook] {exc}')
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f'[graphify hook] Rebuild failed: {exc}')
|
||||
sys.exit(1)
|
||||
"""
|
||||
|
||||
_REBUILD_BODY_CHECKOUT = """\
|
||||
from graphify.watch import _rebuild_code, _apply_resource_limits
|
||||
from pathlib import Path
|
||||
import os, signal, sys
|
||||
try:
|
||||
_apply_resource_limits()
|
||||
_timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600'))
|
||||
if _timeout > 0 and hasattr(signal, 'SIGALRM'):
|
||||
signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s')))
|
||||
signal.alarm(_timeout)
|
||||
_force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
|
||||
# post-checkout: branch switch can touch arbitrary files; full rebuild path
|
||||
# (no changed_paths) is correct here. The flock inside _rebuild_code still
|
||||
# prevents pile-ups when commit + checkout fire back-to-back.
|
||||
_root = Path('.')
|
||||
_out = os.environ.get('GRAPHIFY_OUT', 'graphify-out')
|
||||
_saved = Path(_out) / '.graphify_root'
|
||||
if _saved.exists():
|
||||
_txt = _saved.read_text(encoding='utf-8').strip()
|
||||
if _txt:
|
||||
_root = Path(_txt)
|
||||
_rebuild_code(_root, force=_force)
|
||||
# Refresh the work-memory lessons doc when saved Q&A outcomes exist
|
||||
# (best-effort; never fails the hook).
|
||||
try:
|
||||
_md = (_root / _out) / 'memory'
|
||||
if _md.is_dir() and any(_md.glob('*.md')):
|
||||
from graphify.reflect import reflect as _reflect
|
||||
_gj = (_root / _out) / 'graph.json'
|
||||
_reflect(memory_dir=_md, out_path=(_root / _out) / 'reflections' / 'LESSONS.md',
|
||||
graph_path=_gj if _gj.exists() else None)
|
||||
except Exception:
|
||||
pass
|
||||
except TimeoutError as exc:
|
||||
print(f'[graphify] {exc}')
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f'[graphify] Rebuild failed: {exc}')
|
||||
sys.exit(1)
|
||||
"""
|
||||
|
||||
# Cross-platform detached-launch shim (#1161). The hooks used to background the
|
||||
# rebuild with `nohup "$GRAPHIFY_PYTHON" -c "..." &`, but Git for Windows' bundled
|
||||
# MSYS shell ships no nohup (nor setsid), so that line died with
|
||||
# 'nohup: command not found' and the rebuild silently never ran — git commit/pull
|
||||
# still returned 0, so the graph just went stale with no signal. graphify already
|
||||
# requires Python, so we let Python do the detaching: a tiny outer process spawns
|
||||
# the real rebuild fully detached and returns immediately, so the hook never
|
||||
# blocks. POSIX uses start_new_session (the setsid equivalent); Windows uses
|
||||
# DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, breaking away from any job object
|
||||
# when allowed. This payload is carried inside a shell double-quoted -c argument,
|
||||
# so it deliberately uses only single-quoted Python strings (no ", $, ` or \\).
|
||||
_LAUNCHER_TEMPLATE = """\
|
||||
import os, subprocess, sys
|
||||
_src = '''
|
||||
__REBUILD_BODY__
|
||||
'''
|
||||
_log = os.environ.get('GRAPHIFY_REBUILD_LOG') or os.path.join(os.path.expanduser('~'), '.cache', 'graphify-rebuild.log')
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_log), exist_ok=True)
|
||||
_out = open(_log, 'a', buffering=1, encoding='utf-8', errors='replace')
|
||||
except OSError:
|
||||
_out = subprocess.DEVNULL
|
||||
_kw = dict(stdout=_out, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, cwd=os.getcwd(), close_fds=True)
|
||||
_cmd = [sys.executable, '-c', _src]
|
||||
if os.name == 'nt':
|
||||
_flags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
|
||||
try:
|
||||
subprocess.Popen(_cmd, creationflags=_flags | 0x01000000, **_kw) # + CREATE_BREAKAWAY_FROM_JOB
|
||||
except OSError:
|
||||
subprocess.Popen(_cmd, creationflags=_flags, **_kw)
|
||||
else:
|
||||
subprocess.Popen(_cmd, start_new_session=True, **_kw)
|
||||
"""
|
||||
|
||||
|
||||
def _detached_launch(rebuild_body: str) -> str:
|
||||
"""Return a POSIX-sh line that runs ``rebuild_body`` as a detached background
|
||||
Python process via ``$GRAPHIFY_PYTHON``.
|
||||
|
||||
Replaces the old ``nohup ... &`` form, which failed on Git for Windows'
|
||||
shell (no nohup/setsid) and let the rebuild silently never run (#1161).
|
||||
The launcher writes the child's output to ``$GRAPHIFY_REBUILD_LOG`` and
|
||||
returns the instant the child is spawned, so the git hook never blocks.
|
||||
"""
|
||||
launcher = _LAUNCHER_TEMPLATE.replace("__REBUILD_BODY__", rebuild_body)
|
||||
return '"$GRAPHIFY_PYTHON" -c "' + launcher + '"\n'
|
||||
|
||||
|
||||
_HOOK_SCRIPT = """\
|
||||
# graphify-hook-start
|
||||
# Auto-rebuilds the knowledge graph after each commit (code files only, no LLM needed).
|
||||
# Installed by: graphify hook install
|
||||
|
||||
# Deterministic clustering: networkx louvain iterates string-keyed sets whose
|
||||
# order is randomized per-process by PYTHONHASHSEED, so community assignments
|
||||
# churn run-to-run. Pinning it makes graphify-out reproducible.
|
||||
export PYTHONHASHSEED=0
|
||||
|
||||
# Git for Windows/MSYS hooks can inherit fragile pipe handles from GUI clients
|
||||
# and agent shells. Keep hook-triggered rebuilds sequential by default there;
|
||||
# explicit GRAPHIFY_MAX_WORKERS still wins for users who want parallelism.
|
||||
if [ -n "${WINDIR:-}" ] || [ -n "${MSYSTEM:-}" ]; then
|
||||
export GRAPHIFY_MAX_WORKERS="${GRAPHIFY_MAX_WORKERS:-1}"
|
||||
fi
|
||||
|
||||
# Skip during rebase/merge/cherry-pick to avoid blocking --continue with unstaged changes
|
||||
# git exports GIT_DIR to hooks; the rev-parse fallback only runs when invoked by
|
||||
# hand (each git exec costs 1s+ on AV-scanned Windows machines).
|
||||
GIT_DIR=${GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)}
|
||||
[ -d "$GIT_DIR/rebase-merge" ] && exit 0
|
||||
[ -d "$GIT_DIR/rebase-apply" ] && exit 0
|
||||
[ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0
|
||||
[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
|
||||
|
||||
[ "${GRAPHIFY_SKIP_HOOK:-0}" = "1" ] && exit 0
|
||||
|
||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only HEAD 2>/dev/null)
|
||||
if [ -z "$CHANGED" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip when only graphify-out/ artifacts changed (avoids rebuild loop when graph outputs are tracked in git)
|
||||
_NON_GRAPH=$(echo "$CHANGED" | grep -v '^graphify-out/' || true)
|
||||
if [ -z "$_NON_GRAPH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
""" + _PYTHON_DETECT + """
|
||||
export GRAPHIFY_CHANGED="$CHANGED"
|
||||
|
||||
# Run the rebuild detached so git commit returns immediately. Full-repo rebuilds
|
||||
# can take hours; blocking the post-commit hook stalls the shell. The Python
|
||||
# launcher below detaches the child cross-platform, so it works on Git for
|
||||
# Windows' shell too (which lacks the coreutils backgrounding tools) (#1161).
|
||||
_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log"
|
||||
mkdir -p "$(dirname "$_GRAPHIFY_LOG")"
|
||||
export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG"
|
||||
echo "[graphify hook] launching background rebuild (log: $_GRAPHIFY_LOG)"
|
||||
""" + _detached_launch(_REBUILD_BODY_COMMIT) + """# graphify-hook-end
|
||||
"""
|
||||
|
||||
|
||||
_CHECKOUT_SCRIPT = """\
|
||||
# graphify-checkout-hook-start
|
||||
# Auto-rebuilds the knowledge graph (code only) when switching branches.
|
||||
# Installed by: graphify hook install
|
||||
|
||||
# Deterministic clustering: networkx louvain iterates string-keyed sets whose
|
||||
# order is randomized per-process by PYTHONHASHSEED, so community assignments
|
||||
# churn run-to-run. Pinning it makes graphify-out reproducible.
|
||||
export PYTHONHASHSEED=0
|
||||
|
||||
# Git for Windows/MSYS hooks can inherit fragile pipe handles from GUI clients
|
||||
# and agent shells. Keep hook-triggered rebuilds sequential by default there;
|
||||
# explicit GRAPHIFY_MAX_WORKERS still wins for users who want parallelism.
|
||||
if [ -n "${WINDIR:-}" ] || [ -n "${MSYSTEM:-}" ]; then
|
||||
export GRAPHIFY_MAX_WORKERS="${GRAPHIFY_MAX_WORKERS:-1}"
|
||||
fi
|
||||
|
||||
PREV_HEAD=$1
|
||||
NEW_HEAD=$2
|
||||
BRANCH_SWITCH=$3
|
||||
|
||||
# Only run on branch switches, not file checkouts
|
||||
if [ "$BRANCH_SWITCH" != "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only run if graphify-out/ exists (graph has been built before)
|
||||
if [ ! -d "graphify-out" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip during rebase/merge/cherry-pick
|
||||
# git exports GIT_DIR to hooks; the rev-parse fallback only runs when invoked by
|
||||
# hand (each git exec costs 1s+ on AV-scanned Windows machines).
|
||||
GIT_DIR=${GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)}
|
||||
[ -d "$GIT_DIR/rebase-merge" ] && exit 0
|
||||
[ -d "$GIT_DIR/rebase-apply" ] && exit 0
|
||||
[ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0
|
||||
[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
|
||||
|
||||
""" + _PYTHON_DETECT + """
|
||||
_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log"
|
||||
mkdir -p "$(dirname "$_GRAPHIFY_LOG")"
|
||||
export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG"
|
||||
echo "[graphify] Branch switched - launching background rebuild (log: $_GRAPHIFY_LOG)"
|
||||
""" + _detached_launch(_REBUILD_BODY_CHECKOUT) + """# graphify-checkout-hook-end
|
||||
"""
|
||||
|
||||
|
||||
def _git_root(path: Path) -> Path | None:
|
||||
"""Walk up to find .git directory."""
|
||||
current = path.resolve()
|
||||
for parent in [current, *current.parents]:
|
||||
if (parent / ".git").exists():
|
||||
return parent
|
||||
return None
|
||||
|
||||
|
||||
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
||||
|
||||
|
||||
def _reject_windows_path(value: str, source: str) -> None:
|
||||
"""Raise if a hooks path looks like a Windows absolute path (#1385).
|
||||
|
||||
On POSIX/WSL ``Path("C:\\Users\\...").is_absolute()`` is False, so an absolute
|
||||
Windows hooks path gets joined under the repo root and mkdir'd as a literal
|
||||
junk directory (backslashes and all), while install reports success and the
|
||||
real ``.git/hooks`` gets nothing. Fail loudly instead so the user can fix it.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
return
|
||||
if _WINDOWS_DRIVE_RE.match(value) or "\\" in value:
|
||||
raise RuntimeError(
|
||||
f"git hooks path from {source} looks like a Windows path: {value!r}. "
|
||||
f"On WSL/POSIX this can't resolve to a real directory. Unset it with "
|
||||
f"`git config --local --unset core.hooksPath`, or set a POSIX path."
|
||||
)
|
||||
|
||||
|
||||
def _hooks_dir(root: Path) -> Path:
|
||||
"""Return the git hooks directory, respecting core.hooksPath if set (e.g. Husky)."""
|
||||
try:
|
||||
cfg = configparser.RawConfigParser()
|
||||
cfg.read(root / ".git" / "config", encoding="utf-8")
|
||||
# configparser lowercases option names; git's hooksPath becomes hookspath
|
||||
custom = cfg.get("core", "hookspath", fallback="").strip()
|
||||
if custom:
|
||||
_reject_windows_path(custom, "core.hooksPath")
|
||||
p = Path(custom).expanduser()
|
||||
if not p.is_absolute():
|
||||
p = root / p
|
||||
# Validate the resolved path stays within the repository root
|
||||
# to prevent supply-chain attacks via malicious core.hooksPath values
|
||||
try:
|
||||
p.resolve().relative_to(root.resolve())
|
||||
except ValueError:
|
||||
pass # Path escapes repo root; fall through to default .git/hooks
|
||||
else:
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
except (configparser.Error, OSError) as exc:
|
||||
# Narrow the exception (PR747-NEW-2): a bare `except Exception: pass`
|
||||
# was hiding tampering signals (corrupt .git/config, permission flips
|
||||
# by another tool). Surface them on stderr instead of silently
|
||||
# falling through to the default hooks directory.
|
||||
print(
|
||||
f"[graphify hooks] could not read core.hooksPath from "
|
||||
f"{root / '.git' / 'config'}: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# In a linked worktree .git is a file not a directory, so constructing
|
||||
# root/.git/hooks directly fails. Ask git for the real hooks path instead.
|
||||
# NOTE: do NOT pass --path-format=absolute — added in git 2.31; older git
|
||||
# echoes it back as a literal argument, contaminating stdout and causing a
|
||||
# phantom directory to be created (#907). git -C <root> already returns an
|
||||
# absolute path for worktree/external-gitdir cases, and a path relative to
|
||||
# <root> for normal repos — anchoring on root covers both.
|
||||
import subprocess as _sp
|
||||
try:
|
||||
res = _sp.run(
|
||||
["git", "-C", str(root), "rev-parse", "--git-path", "hooks"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
raw = res.stdout.strip()
|
||||
# A valid hooks path can never contain newlines or NUL. Their presence
|
||||
# means git echoed an unrecognised flag back (old git behaviour).
|
||||
if res.returncode == 0 and raw and not any(c in raw for c in ("\n", "\r", "\x00")):
|
||||
_reject_windows_path(raw, "git rev-parse --git-path hooks")
|
||||
d = (root / raw).resolve()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
except (OSError, FileNotFoundError):
|
||||
pass
|
||||
d = root / ".git" / "hooks"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _install_hook(hooks_dir: Path, name: str, script: str, marker: str) -> str:
|
||||
"""Install a single git hook, appending if an existing hook is present."""
|
||||
hook_path = hooks_dir / name
|
||||
if hook_path.exists():
|
||||
content = hook_path.read_text(encoding="utf-8")
|
||||
if marker in content:
|
||||
return f"already installed at {hook_path}"
|
||||
hook_path.write_text(content.rstrip() + "\n\n" + script, encoding="utf-8", newline="\n")
|
||||
return f"appended to existing {name} hook at {hook_path}"
|
||||
hook_path.write_text("#!/bin/sh\n" + script, encoding="utf-8", newline="\n")
|
||||
hook_path.chmod(0o755)
|
||||
return f"installed at {hook_path}"
|
||||
|
||||
|
||||
def _uninstall_hook(hooks_dir: Path, name: str, marker: str, marker_end: str) -> str:
|
||||
"""Remove graphify section from a git hook using start/end markers."""
|
||||
hook_path = hooks_dir / name
|
||||
if not hook_path.exists():
|
||||
return f"no {name} hook found - nothing to remove."
|
||||
content = hook_path.read_text(encoding="utf-8")
|
||||
if marker not in content:
|
||||
return f"graphify hook not found in {name} - nothing to remove."
|
||||
new_content = re.sub(
|
||||
rf"{re.escape(marker)}.*?{re.escape(marker_end)}\n?",
|
||||
"",
|
||||
content,
|
||||
flags=re.DOTALL,
|
||||
).strip()
|
||||
if not new_content or new_content in ("#!/bin/bash", "#!/bin/sh"):
|
||||
hook_path.unlink()
|
||||
return f"removed {name} hook at {hook_path}"
|
||||
hook_path.write_text(new_content + "\n", encoding="utf-8", newline="\n")
|
||||
return f"graphify removed from {name} at {hook_path} (other hook content preserved)"
|
||||
|
||||
|
||||
def _user_hooks_dir(hooks_dir: Path) -> Path:
|
||||
"""Return the user-editable hooks directory.
|
||||
|
||||
Husky 9 sets core.hooksPath to .husky/_ (wrapper scripts auto-generated by
|
||||
Husky), while user-editable hooks live in the parent .husky/. Return the
|
||||
parent when the resolved dir ends in '_' so install/status/uninstall target
|
||||
the correct location (#987).
|
||||
"""
|
||||
if hooks_dir.name == "_":
|
||||
return hooks_dir.parent
|
||||
return hooks_dir
|
||||
|
||||
|
||||
def install(path: Path = Path(".")) -> str:
|
||||
"""Install graphify post-commit and post-checkout hooks in the nearest git repo."""
|
||||
root = _git_root(path)
|
||||
if root is None:
|
||||
raise RuntimeError(f"No git repository found at or above {path.resolve()}")
|
||||
|
||||
hooks_dir = _user_hooks_dir(_hooks_dir(root))
|
||||
|
||||
# Pin the current interpreter so the hook works even when the graphify
|
||||
# launcher is not on PATH at git-trigger time (uv tool / pipx isolation).
|
||||
# sys.executable is the Python running this very install command, so it is
|
||||
# always the correct isolated-venv interpreter. The placeholder is replaced
|
||||
# in both scripts before writing; the allowlist in _PYTHON_DETECT strips any
|
||||
# characters unsafe in a shell path, and import-verification catches a stale
|
||||
# pinned path so it safely falls through to the dynamic detection.
|
||||
# Apply the same allowlist used in _PYTHON_DETECT for all other probes.
|
||||
# This rejects any character that is not a valid plain filesystem path
|
||||
# character, preventing $(...), backtick, double-quote, semicolon, etc.
|
||||
# from being injected into the generated shell scripts. The allowlist
|
||||
# includes ':' and '\' so Windows paths (C:\...) are accepted.
|
||||
import re as _re
|
||||
_safe = sys.executable
|
||||
if _re.search(r"[^a-zA-Z0-9/_.@:\\-]", _safe):
|
||||
# Path contains characters outside the allowlist (spaces, quotes, etc.).
|
||||
# Embed an empty string so the pinned probe is skipped and the hook
|
||||
# falls through to the dynamic detection — safe degradation.
|
||||
_safe = ""
|
||||
pinned = _safe
|
||||
hook = _HOOK_SCRIPT.replace("__PINNED_PYTHON__", pinned)
|
||||
checkout = _CHECKOUT_SCRIPT.replace("__PINNED_PYTHON__", pinned)
|
||||
|
||||
commit_msg = _install_hook(hooks_dir, "post-commit", hook, _HOOK_MARKER)
|
||||
checkout_msg = _install_hook(hooks_dir, "post-checkout", checkout, _CHECKOUT_MARKER)
|
||||
|
||||
return f"post-commit: {commit_msg}\npost-checkout: {checkout_msg}"
|
||||
|
||||
|
||||
def uninstall(path: Path = Path(".")) -> str:
|
||||
"""Remove graphify post-commit and post-checkout hooks."""
|
||||
root = _git_root(path)
|
||||
if root is None:
|
||||
raise RuntimeError(f"No git repository found at or above {path.resolve()}")
|
||||
|
||||
hooks_dir = _user_hooks_dir(_hooks_dir(root))
|
||||
commit_msg = _uninstall_hook(hooks_dir, "post-commit", _HOOK_MARKER, _HOOK_MARKER_END)
|
||||
checkout_msg = _uninstall_hook(hooks_dir, "post-checkout", _CHECKOUT_MARKER, _CHECKOUT_MARKER_END)
|
||||
|
||||
return f"post-commit: {commit_msg}\npost-checkout: {checkout_msg}"
|
||||
|
||||
|
||||
def status(path: Path = Path(".")) -> str:
|
||||
"""Check if graphify hooks are installed."""
|
||||
root = _git_root(path)
|
||||
if root is None:
|
||||
return "Not in a git repository."
|
||||
hooks_dir = _user_hooks_dir(_hooks_dir(root))
|
||||
|
||||
def _check(name: str, marker: str) -> str:
|
||||
p = hooks_dir / name
|
||||
if not p.exists():
|
||||
return "not installed"
|
||||
return "installed" if marker in p.read_text(encoding="utf-8") else "not installed (hook exists but graphify not found)"
|
||||
|
||||
commit = _check("post-commit", _HOOK_MARKER)
|
||||
checkout = _check("post-checkout", _CHECKOUT_MARKER)
|
||||
return f"post-commit: {commit}\npost-checkout: {checkout}"
|
||||
Reference in New Issue
Block a user