Skip to content

Commit c4d1b8d

Browse files
mariushelfclaude
andauthored
feat: per-worktree graphify graph via pre-commit post-* hooks (#23)
* feat: keep a per-worktree graphify graph current via pre-commit post-* hooks Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix: harden graphify hook (coalescing lock, default stages, missing config) Restrict other hooks to the pre-commit stage, install with --allow-missing-config, replace flock with a portable mkdir lock plus dirty marker, create uv.lock at generation time, and add pre-commit to dev deps. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix: make uv lock best-effort and never steal a live graphify lock Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * test: simulate a failing uv lock instead of removing tools from PATH Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 2e0ddfc commit c4d1b8d

8 files changed

Lines changed: 417 additions & 2 deletions

File tree

‎copier.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ _exclude:
1010

1111
_tasks:
1212
- "git init"
13+
# Best effort: offline or without uv the project must still be generated.
14+
- "sh -c 'uv lock || echo \"uv lock failed; run it later to enable the code graph\"'"
1315
- "git checkout -b main"
1416

1517
# Questions

‎template/.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ data/**
153153
poetry.lock
154154
.isort.cfg
155155

156+
# graphify code graph (rebuilt by git hooks)
157+
graphify-out/
158+
156159
# Claude code artifacts
157160
**/.claude/settings.local.json
158161

‎template/.pre-commit-config.yaml.jinja‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
minimum_pre_commit_version: "3.0"
1+
minimum_pre_commit_version: "3.2.0"
2+
default_stages: [pre-commit]
3+
default_install_hook_types: [pre-commit, post-commit, post-checkout, post-merge, post-rewrite]
24
repos:
35
- repo: https://github.com/charliermarsh/ruff-pre-commit
46
# Ruff version.
@@ -29,6 +31,13 @@ repos:
2931
language: system
3032
pass_filenames: false
3133
{%- endif %}
34+
- id: graphify
35+
name: graphify update
36+
entry: sh scripts/graphify-update.sh
37+
language: system
38+
stages: [post-commit, post-checkout, post-merge, post-rewrite]
39+
always_run: true
40+
pass_filenames: false
3241
- repo: https://github.com/gitleaks/gitleaks
3342
rev: v8.30.1
3443
hooks:

‎template/AGENTS.md.jinja‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,21 @@ those blocks once you start modelling your own domain.
7171
- `make lint` runs ruff and the `ty` type checker.
7272
{%- endif %}
7373

74+
## Code Graph
75+
76+
The graph in `graphify-out/` is rebuilt by git hooks after commit, checkout,
77+
merge, and rebase. It is AST only and can lag a moment behind. Each worktree
78+
has its own graph, and it is not committed.
79+
80+
- Query it before broad searches: `graphify query "<question>"`,
81+
`graphify explain "<node>"`, `graphify path "A" "B"`, and
82+
`graphify-out/GRAPH_REPORT.md` for an overview.
83+
- `uv run pre-commit install --allow-missing-config` installs all hook types,
84+
including these. The flag keeps checkouts of old commits without a config
85+
working.
86+
- If the graph is missing, run `sh scripts/graphify-update.sh`. Do not call
87+
`graphify update .` directly: it overwrites the global graphify skill files.
88+
7489
## Documentation and Comment Style
7590

7691
Brevity is not tidiness — it is what gets the text read. A docstring, comment,

‎template/README.md.jinja‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,12 @@ pip install {{ project_slug | replace('_', '-') }}
3232

3333
```bash
3434
uv sync
35-
uv run pre-commit install
35+
uv run pre-commit install --allow-missing-config
3636
```
3737

38+
The hooks keep a local code graph in `graphify-out/` current after commit,
39+
checkout, merge and rebase. They need a POSIX `sh` (on Windows: Git Bash).
40+
3841
## Running tests
3942

4043
```bash

‎template/pyproject.toml.jinja‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,11 @@ type = "github"
9494

9595
[dependency-groups]
9696
dev = [
97+
"graphifyy>=0.9.92",
9798
{%- if include_hexagonal %}
9899
"import-linter>=2.0",
99100
{%- endif %}
101+
"pre-commit>=3.2",
100102
"pytest>=8.0",
101103
"ruff>=0.15",
102104
"ty>=0.0.17",
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
#!/bin/sh
2+
# Rebuild the graphify code graph (graphify-out/) for this worktree.
3+
#
4+
# Called by the pre-commit "graphify" hook after commit, checkout, merge and
5+
# rewrite. Needs a POSIX sh. Always exits 0 so it never breaks a git operation.
6+
#
7+
# Every call marks the worktree "dirty" and starts a detached worker. Only one
8+
# worker per worktree holds the lock (a mkdir lock, portable to macOS); it
9+
# rebuilds until nothing marked the tree dirty meanwhile, so the final graph
10+
# always reflects the latest trigger.
11+
#
12+
# GRAPHIFY_UPDATE_FOREGROUND=1 runs the worker inline (for tests).
13+
14+
# Stops graphify from rewriting the global agent skill files with this
15+
# project's pinned version.
16+
export GRAPHIFY_NO_AUTO_REFRESH=1
17+
18+
# Work from the worktree root, whatever the caller's directory (kept as is
19+
# outside a git repository).
20+
self=$(cd "$(dirname "$0")" && pwd)/$(basename "$0")
21+
root=$(git rev-parse --show-toplevel 2>/dev/null) && cd "$root"
22+
23+
out=graphify-out
24+
lock=$out/.lock
25+
dirty=$out/.dirty
26+
log=$out/update.log
27+
stale_minutes=10
28+
max_log_bytes=100000
29+
30+
say() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >>"$log"; }
31+
32+
trim_log() {
33+
size=$(wc -c <"$log" 2>/dev/null || echo 0)
34+
if [ "${size:-0}" -gt "$max_log_bytes" ]; then
35+
tail -n 200 "$log" >"$log.tmp" 2>/dev/null && mv "$log.tmp" "$log"
36+
fi
37+
}
38+
39+
# Remove the lock only if this process still owns it.
40+
release() {
41+
[ "$(cat "$lock/pid" 2>/dev/null)" = "$$" ] && rm -rf "$lock"
42+
return 0
43+
}
44+
45+
# Take the lock. It is stale if its holder is dead, or if it has no pid file
46+
# and has not been touched for a while. A live holder is never robbed.
47+
acquire() {
48+
mkdir "$lock" 2>/dev/null && { echo $$ >"$lock/pid"; return 0; }
49+
pid=$(cat "$lock/pid" 2>/dev/null)
50+
if { [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; } ||
51+
{ [ -z "$pid" ] &&
52+
[ -n "$(find "$lock" -maxdepth 0 -mmin +"$stale_minutes" 2>/dev/null)" ]; }; then
53+
say "removing stale lock (holder ${pid:-unknown})"
54+
rm -rf "$lock"
55+
mkdir "$lock" 2>/dev/null && { echo $$ >"$lock/pid"; return 0; }
56+
fi
57+
return 1
58+
}
59+
60+
worker() {
61+
while acquire; do
62+
trap release EXIT
63+
while [ -f "$dirty" ]; do
64+
touch "$lock" 2>/dev/null
65+
rm -f "$dirty"
66+
say "update start"
67+
if uv run --frozen graphify update . >>"$log" 2>&1; then
68+
say "update done"
69+
else
70+
say "update failed (exit $?)"
71+
fi
72+
trim_log
73+
done
74+
release
75+
trap - EXIT
76+
# A trigger may have arrived between the last check and the release.
77+
[ -f "$dirty" ] || return 0
78+
done
79+
}
80+
81+
if [ "$1" = "--worker" ]; then
82+
worker
83+
exit 0
84+
fi
85+
86+
command -v uv >/dev/null 2>&1 || exit 0
87+
mkdir -p "$out" 2>/dev/null || exit 0
88+
89+
if [ ! -f uv.lock ]; then
90+
say "no uv.lock in this checkout; skipping (run 'uv lock' to enable the graph)"
91+
exit 0
92+
fi
93+
94+
touch "$dirty"
95+
if [ -n "$GRAPHIFY_UPDATE_FOREGROUND" ]; then
96+
worker
97+
elif command -v setsid >/dev/null 2>&1; then
98+
setsid sh "$self" --worker >/dev/null 2>&1 </dev/null &
99+
else
100+
nohup sh "$self" --worker >/dev/null 2>&1 </dev/null &
101+
fi
102+
exit 0

0 commit comments

Comments
 (0)