From 81da0cb994ae8652e66bbd8449aa44340d373bfc Mon Sep 17 00:00:00 2001 From: Marius Helf Date: Mon, 19 Oct 2026 01:09:25 +0200 Subject: [PATCH 1/4] feat: keep a per-worktree graphify graph current via pre-commit post-* hooks Co-Authored-By: Claude Sonnet 5.5 --- template/.gitignore | 3 + template/.pre-commit-config.yaml.jinja | 10 +- template/AGENTS.md.jinja | 12 +++ template/README.md.jinja | 2 +- template/pyproject.toml.jinja | 1 + template/scripts/graphify-update.sh | 28 +++++ tests/test_template.py | 144 +++++++++++++++++++++++++ 7 files changed, 198 insertions(+), 2 deletions(-) create mode 100755 template/scripts/graphify-update.sh diff --git a/template/.gitignore b/template/.gitignore index 158760b..e029ff6 100644 --- a/template/.gitignore +++ b/template/.gitignore @@ -153,6 +153,9 @@ data/** poetry.lock .isort.cfg +# graphify code graph (rebuilt by git hooks) +graphify-out/ + # Claude code artifacts **/.claude/settings.local.json diff --git a/template/.pre-commit-config.yaml.jinja b/template/.pre-commit-config.yaml.jinja index ba8bfcb..3b512f3 100644 --- a/template/.pre-commit-config.yaml.jinja +++ b/template/.pre-commit-config.yaml.jinja @@ -1,4 +1,5 @@ -minimum_pre_commit_version: "3.0" +minimum_pre_commit_version: "3.2.0" +default_install_hook_types: [pre-commit, post-commit, post-checkout, post-merge, post-rewrite] repos: - repo: https://github.com/charliermarsh/ruff-pre-commit # Ruff version. @@ -29,6 +30,13 @@ repos: language: system pass_filenames: false {%- endif %} + - id: graphify + name: graphify update + entry: sh scripts/graphify-update.sh + language: system + stages: [post-commit, post-checkout, post-merge, post-rewrite] + always_run: true + pass_filenames: false - repo: https://github.com/gitleaks/gitleaks rev: v8.30.1 hooks: diff --git a/template/AGENTS.md.jinja b/template/AGENTS.md.jinja index f5efe4e..8daf454 100644 --- a/template/AGENTS.md.jinja +++ b/template/AGENTS.md.jinja @@ -71,6 +71,18 @@ those blocks once you start modelling your own domain. - `make lint` runs ruff and the `ty` type checker. {%- endif %} +## Code Graph + +The graph in `graphify-out/` is rebuilt by git hooks after commit, checkout, +merge, and rebase. It is AST only and can lag a moment behind. Each worktree +has its own graph, and it is not committed. + +- Query it before broad searches: `graphify query ""`, + `graphify explain ""`, `graphify path "A" "B"`, and + `graphify-out/GRAPH_REPORT.md` for an overview. +- `pre-commit install` installs all hook types, including these. +- If the graph is missing, run `uv run graphify update .`. + ## Documentation and Comment Style Brevity is not tidiness — it is what gets the text read. A docstring, comment, diff --git a/template/README.md.jinja b/template/README.md.jinja index 2eaff08..9c20903 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -32,7 +32,7 @@ pip install {{ project_slug | replace('_', '-') }} ```bash uv sync -uv run pre-commit install +uv run pre-commit install # also installs the post-* hooks that keep graphify-out/ current ``` ## Running tests diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index 4505f7a..ef000b7 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -94,6 +94,7 @@ type = "github" [dependency-groups] dev = [ + "graphifyy>=0.9.92", {%- if include_hexagonal %} "import-linter>=2.0", {%- endif %} diff --git a/template/scripts/graphify-update.sh b/template/scripts/graphify-update.sh new file mode 100755 index 0000000..aed1177 --- /dev/null +++ b/template/scripts/graphify-update.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Rebuild the graphify code graph (graphify-out/) for this worktree. +# +# Called by the pre-commit "graphify" hook after commit, checkout, merge and +# rewrite. Runs the AST-only update in the background and always exits 0, so +# it never slows down or breaks a git operation. + +# Stops graphify from rewriting the global agent skill files with this +# project's pinned version. +export GRAPHIFY_NO_AUTO_REFRESH=1 + +command -v uv >/dev/null 2>&1 || exit 0 +mkdir -p graphify-out 2>/dev/null || exit 0 + +# One build at a time per worktree. flock is missing on macOS: run unguarded. +if command -v flock >/dev/null 2>&1; then + update='flock -n graphify-out/.update.lock uv run --frozen graphify update .' +else + update='uv run --frozen graphify update .' +fi + +# Detach from the git process: new session when setsid exists, no stdio. +if command -v setsid >/dev/null 2>&1; then + setsid sh -c "$update" >graphify-out/update.log 2>&1 graphify-out/update.log 2>&1 dict: + config = yaml.safe_load( + (project_path / ".pre-commit-config.yaml").read_text(encoding="utf-8") + ) + hooks = [h for r in config["repos"] for h in r["hooks"] if h["id"] == "graphify"] + assert len(hooks) == 1 + return hooks[0] + + +def test_graphify_is_configured(project): + """The rendered project wires graphify into pre-commit, deps and docs.""" + config = yaml.safe_load( + (project.path / ".pre-commit-config.yaml").read_text(encoding="utf-8") + ) + assert set(config["default_install_hook_types"]) == {"pre-commit", *GRAPHIFY_STAGES} + + hook = _graphify_hook(project.path) + assert hook["stages"] == GRAPHIFY_STAGES + assert hook["always_run"] is True + assert hook["pass_filenames"] is False + assert hook["language"] == "system" + + script = project.path / "scripts" / "graphify-update.sh" + assert script.stat().st_mode & 0o111, "hook script must be executable" + body = script.read_text(encoding="utf-8") + assert "GRAPHIFY_NO_AUTO_REFRESH=1" in body + assert "uv run --frozen graphify update ." in body + + pyproject = tomllib.loads((project.path / "pyproject.toml").read_text("utf-8")) + assert any( + d.startswith("graphifyy>=") for d in pyproject["dependency-groups"]["dev"] + ) + gitignore = (project.path / ".gitignore").read_text(encoding="utf-8") + assert "graphify-out/" in gitignore.splitlines() + assert not (project.path / ".gitattributes").exists() + agents = (project.path / "AGENTS.md").read_text(encoding="utf-8") + assert "## Code Graph" in agents + assert "graphify query" in agents + + +def test_graphify_script_exits_zero_without_tools(project, tmp_path): + """With neither uv nor graphify on PATH the script is a silent no-op.""" + sh = shutil.which("sh") + result = subprocess.run( + [sh, str(project.path / "scripts" / "graphify-update.sh")], + cwd=tmp_path, + env={"PATH": str(tmp_path)}, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert not (tmp_path / "graphify-out").exists() + + +def test_graphify_script_exits_zero_when_graphify_fails(project, tmp_path): + """A failing `uv run` must not surface as a failing git operation.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake_uv = bin_dir / "uv" + fake_uv.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") + fake_uv.chmod(0o755) + result = subprocess.run( + ["sh", str(project.path / "scripts" / "graphify-update.sh")], + cwd=tmp_path, + env={**os.environ, "PATH": f"{bin_dir}:{os.environ['PATH']}"}, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + + +def _wait_for(path: Path, timeout: float = 90.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if path.is_file(): + return True + time.sleep(1) + return path.is_file() + + +@pytest.mark.skipif(shutil.which("uv") is None, reason="uv not available") +def test_graphify_hooks_build_graph_per_worktree(tmp_path): + """Commit and `git worktree add` each produce their own graphify-out/. + + Needs network access (installs pre-commit hook environments and graphify). + Exercises the real post-commit and post-checkout hooks end to end. + """ + dst = tmp_path / "proj" + copier.run_copy( + src_path=str(TEMPLATE_ROOT), + dst_path=str(dst), + data={**COPIER_DATA, "include_hexagonal": False}, + defaults=True, + unsafe=True, + vcs_ref="HEAD", + ) + + def run(*cmd, cwd=dst): + return subprocess.run( + cmd, cwd=cwd, check=True, capture_output=True, text=True + ) + + run("uv", "sync") + run("git", "config", "user.email", "t@example.com") + run("git", "config", "user.name", "T") + # Hook environments are installed lazily; the graphify hook is `system`. + run("uvx", "pre-commit", "install") + hooks = dst / ".git" / "hooks" + for name in ["post-commit", "post-checkout", "post-merge", "post-rewrite"]: + assert (hooks / name).is_file(), f"{name} hook not installed" + # `uvx pre-commit` runs the hooks with uv on PATH but not pre-commit + # itself; the installed shims call `pre-commit`, so expose it. + shim_dir = tmp_path / "shims" + shim_dir.mkdir() + pre_commit = shim_dir / "pre-commit" + pre_commit.write_text('#!/bin/sh\nexec uvx pre-commit "$@"\n', encoding="utf-8") + pre_commit.chmod(0o755) + env = {**os.environ, "PATH": f"{shim_dir}:{os.environ['PATH']}"} + + def git(*args, cwd=dst): + return subprocess.run( + ["git", *args], cwd=cwd, check=True, capture_output=True, text=True, env=env + ) + + git("add", ".") + git("commit", "--no-verify", "-m", "chore: initial") + # --no-verify skips pre-commit/commit-msg only; post-commit still fires. + assert _wait_for(dst / "graphify-out" / "graph.json"), "post-commit built no graph" + + worktree = tmp_path / "wt" + git("worktree", "add", "-b", "other", str(worktree)) + assert _wait_for(worktree / "graphify-out" / "graph.json"), ( + "post-checkout did not build a graph in the new worktree" + ) From fca89a43b8c63cd56f5ddd01c77d2ccda7f10a09 Mon Sep 17 00:00:00 2001 From: Marius Helf Date: Mon, 19 Oct 2026 01:20:11 +0200 Subject: [PATCH 2/4] 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 --- copier.yml | 1 + template/.pre-commit-config.yaml.jinja | 1 + template/AGENTS.md.jinja | 7 +- template/README.md.jinja | 5 +- template/pyproject.toml.jinja | 1 + template/scripts/graphify-update.sh | 84 +++++++++-- tests/test_template.py | 186 ++++++++++++++++++------- 7 files changed, 223 insertions(+), 62 deletions(-) diff --git a/copier.yml b/copier.yml index 2d1d192..b7cd725 100644 --- a/copier.yml +++ b/copier.yml @@ -10,6 +10,7 @@ _exclude: _tasks: - "git init" + - "uv lock" - "git checkout -b main" # Questions diff --git a/template/.pre-commit-config.yaml.jinja b/template/.pre-commit-config.yaml.jinja index 3b512f3..884b0e2 100644 --- a/template/.pre-commit-config.yaml.jinja +++ b/template/.pre-commit-config.yaml.jinja @@ -1,4 +1,5 @@ minimum_pre_commit_version: "3.2.0" +default_stages: [pre-commit] default_install_hook_types: [pre-commit, post-commit, post-checkout, post-merge, post-rewrite] repos: - repo: https://github.com/charliermarsh/ruff-pre-commit diff --git a/template/AGENTS.md.jinja b/template/AGENTS.md.jinja index 8daf454..eec4add 100644 --- a/template/AGENTS.md.jinja +++ b/template/AGENTS.md.jinja @@ -80,8 +80,11 @@ has its own graph, and it is not committed. - Query it before broad searches: `graphify query ""`, `graphify explain ""`, `graphify path "A" "B"`, and `graphify-out/GRAPH_REPORT.md` for an overview. -- `pre-commit install` installs all hook types, including these. -- If the graph is missing, run `uv run graphify update .`. +- `uv run pre-commit install --allow-missing-config` installs all hook types, + including these. The flag keeps checkouts of old commits without a config + working. +- If the graph is missing, run `sh scripts/graphify-update.sh`. Do not call + `graphify update .` directly: it overwrites the global graphify skill files. ## Documentation and Comment Style diff --git a/template/README.md.jinja b/template/README.md.jinja index 9c20903..9c82e78 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -32,9 +32,12 @@ pip install {{ project_slug | replace('_', '-') }} ```bash uv sync -uv run pre-commit install # also installs the post-* hooks that keep graphify-out/ current +uv run pre-commit install --allow-missing-config ``` +The hooks keep a local code graph in `graphify-out/` current after commit, +checkout, merge and rebase. They need a POSIX `sh` (on Windows: Git Bash). + ## Running tests ```bash diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index ef000b7..1f51c81 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -98,6 +98,7 @@ dev = [ {%- if include_hexagonal %} "import-linter>=2.0", {%- endif %} + "pre-commit>=3.2", "pytest>=8.0", "ruff>=0.15", "ty>=0.0.17", diff --git a/template/scripts/graphify-update.sh b/template/scripts/graphify-update.sh index aed1177..d15e2f7 100755 --- a/template/scripts/graphify-update.sh +++ b/template/scripts/graphify-update.sh @@ -2,27 +2,87 @@ # Rebuild the graphify code graph (graphify-out/) for this worktree. # # Called by the pre-commit "graphify" hook after commit, checkout, merge and -# rewrite. Runs the AST-only update in the background and always exits 0, so -# it never slows down or breaks a git operation. +# rewrite. Needs a POSIX sh. Always exits 0 so it never breaks a git operation. +# +# Every call marks the worktree "dirty" and starts a detached worker. Only one +# worker per worktree holds the lock (a mkdir lock, portable to macOS); it +# rebuilds until nothing marked the tree dirty meanwhile, so the final graph +# always reflects the latest trigger. +# +# GRAPHIFY_UPDATE_FOREGROUND=1 runs the worker inline (for tests). # Stops graphify from rewriting the global agent skill files with this # project's pinned version. export GRAPHIFY_NO_AUTO_REFRESH=1 +out=graphify-out +lock=$out/.lock +dirty=$out/.dirty +log=$out/update.log +stale_minutes=10 +max_log_bytes=100000 + +say() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >>"$log"; } + +trim_log() { + size=$(wc -c <"$log" 2>/dev/null || echo 0) + if [ "${size:-0}" -gt "$max_log_bytes" ]; then + tail -n 200 "$log" >"$log.tmp" 2>/dev/null && mv "$log.tmp" "$log" + fi +} + +# Take the lock, clearing it first when its holder is dead or it is too old. +acquire() { + mkdir "$lock" 2>/dev/null && { echo $$ >"$lock/pid"; return 0; } + pid=$(cat "$lock/pid" 2>/dev/null) + if { [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; } || + [ -n "$(find "$lock" -maxdepth 0 -mmin +"$stale_minutes" 2>/dev/null)" ]; then + say "removing stale lock (holder ${pid:-unknown})" + rm -rf "$lock" + mkdir "$lock" 2>/dev/null && { echo $$ >"$lock/pid"; return 0; } + fi + return 1 +} + +worker() { + while acquire; do + trap 'rm -rf "$lock"' EXIT + while [ -f "$dirty" ]; do + rm -f "$dirty" + say "update start" + if uv run --frozen graphify update . >>"$log" 2>&1; then + say "update done" + else + say "update failed (exit $?)" + fi + trim_log + done + rm -rf "$lock" + trap - EXIT + # A trigger may have arrived between the last check and the release. + [ -f "$dirty" ] || return 0 + done +} + +if [ "$1" = "--worker" ]; then + worker + exit 0 +fi + command -v uv >/dev/null 2>&1 || exit 0 -mkdir -p graphify-out 2>/dev/null || exit 0 +mkdir -p "$out" 2>/dev/null || exit 0 -# One build at a time per worktree. flock is missing on macOS: run unguarded. -if command -v flock >/dev/null 2>&1; then - update='flock -n graphify-out/.update.lock uv run --frozen graphify update .' -else - update='uv run --frozen graphify update .' +if [ ! -f uv.lock ]; then + say "no uv.lock in this checkout; skipping (run 'uv lock' to enable the graph)" + exit 0 fi -# Detach from the git process: new session when setsid exists, no stdio. -if command -v setsid >/dev/null 2>&1; then - setsid sh -c "$update" >graphify-out/update.log 2>&1 /dev/null 2>&1; then + setsid sh "$0" --worker >/dev/null 2>&1 graphify-out/update.log 2>&1 /dev/null 2>&1 str: + return str(project_path / "scripts" / "graphify-update.sh") + + +def _fake_uv(bin_dir: Path, body: str) -> dict: + bin_dir.mkdir(exist_ok=True) + fake = bin_dir / "uv" + fake.write_text(f"#!/bin/sh\n{body}\n", encoding="utf-8") + fake.chmod(0o755) + return {**os.environ, "PATH": f"{bin_dir}:{os.environ['PATH']}"} + + def test_graphify_script_exits_zero_without_tools(project, tmp_path): """With neither uv nor graphify on PATH the script is a silent no-op.""" - sh = shutil.which("sh") result = subprocess.run( - [sh, str(project.path / "scripts" / "graphify-update.sh")], + [shutil.which("sh"), _script(project.path)], cwd=tmp_path, env={"PATH": str(tmp_path)}, capture_output=True, @@ -502,21 +518,87 @@ def test_graphify_script_exits_zero_without_tools(project, tmp_path): assert not (tmp_path / "graphify-out").exists() -def test_graphify_script_exits_zero_when_graphify_fails(project, tmp_path): - """A failing `uv run` must not surface as a failing git operation.""" - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - fake_uv = bin_dir / "uv" - fake_uv.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") - fake_uv.chmod(0o755) +def test_graphify_script_skips_without_lockfile(project, tmp_path): + """No uv.lock: log why, exit 0, and do not create a lock.""" + env = _fake_uv(tmp_path / "bin", "echo built >> built.txt") + result = subprocess.run( + ["sh", _script(project.path)], + cwd=tmp_path, + env={**env, "GRAPHIFY_UPDATE_FOREGROUND": "1"}, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "no uv.lock" in (tmp_path / "graphify-out" / "update.log").read_text() + assert not (tmp_path / "uv.lock").exists() + assert not (tmp_path / "built.txt").exists() + + +def test_graphify_script_survives_failing_build(project, tmp_path): + """A failing build is logged and still exits 0, releasing the lock.""" + (tmp_path / "uv.lock").write_text("") + env = _fake_uv(tmp_path / "bin", "echo boom; exit 3") result = subprocess.run( - ["sh", str(project.path / "scripts" / "graphify-update.sh")], + ["sh", _script(project.path)], cwd=tmp_path, - env={**os.environ, "PATH": f"{bin_dir}:{os.environ['PATH']}"}, + env={**env, "GRAPHIFY_UPDATE_FOREGROUND": "1"}, capture_output=True, text=True, ) assert result.returncode == 0 + log = (tmp_path / "graphify-out" / "update.log").read_text() + assert "boom" in log + assert "update failed" in log + assert not (tmp_path / "graphify-out" / ".lock").exists() + + +def test_graphify_script_coalesces_triggers(project, tmp_path): + """A trigger during a running build makes the holder build once more.""" + (tmp_path / "uv.lock").write_text("") + counter = tmp_path / "builds.txt" + env = { + **_fake_uv(tmp_path / "bin", f"echo x >> {counter}; sleep 2"), + "GRAPHIFY_UPDATE_FOREGROUND": "1", + } + first = subprocess.Popen( + ["sh", _script(project.path)], cwd=tmp_path, env=env + ) + deadline = time.monotonic() + 30 + while not counter.exists() and time.monotonic() < deadline: + time.sleep(0.1) + # Second and third triggers arrive mid-build: no extra parallel build. + for _ in range(2): + second = subprocess.run(["sh", _script(project.path)], cwd=tmp_path, env=env) + assert second.returncode == 0 + assert first.wait(timeout=60) == 0 + assert counter.read_text().count("x") == 2 + assert not (tmp_path / "graphify-out" / ".lock").exists() + + +def test_graphify_script_recovers_stale_lock(project, tmp_path): + """A lock whose holder is dead does not block builds forever.""" + (tmp_path / "uv.lock").write_text("") + lock = tmp_path / "graphify-out" / ".lock" + lock.mkdir(parents=True) + dead = subprocess.Popen(["true"]) + dead.wait() + (lock / "pid").write_text(str(dead.pid)) + env = _fake_uv(tmp_path / "bin", f"echo x >> {tmp_path / 'builds.txt'}") + subprocess.run( + ["sh", _script(project.path)], + cwd=tmp_path, + env={**env, "GRAPHIFY_UPDATE_FOREGROUND": "1"}, + check=True, + ) + assert (tmp_path / "builds.txt").read_text().count("x") == 1 + + +def _online() -> bool: + try: + socket.create_connection(("pypi.org", 443), timeout=5).close() + except OSError: + return False + return True def _wait_for(path: Path, timeout: float = 90.0) -> bool: @@ -528,14 +610,31 @@ def _wait_for(path: Path, timeout: float = 90.0) -> bool: return path.is_file() +def _wait_idle(*roots: Path, timeout: float = 60.0) -> None: + """Wait for background builds to finish so tmp_path can be removed.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not any((r / "graphify-out" / ".lock").exists() for r in roots): + return + time.sleep(1) + for r in roots: + pid_file = r / "graphify-out" / ".lock" / "pid" + if pid_file.exists(): + with contextlib.suppress(OSError, ValueError): + os.kill(int(pid_file.read_text()), signal.SIGTERM) + + @pytest.mark.skipif(shutil.which("uv") is None, reason="uv not available") +@pytest.mark.skipif(not _online(), reason="network not available") def test_graphify_hooks_build_graph_per_worktree(tmp_path): """Commit and `git worktree add` each produce their own graphify-out/. - Needs network access (installs pre-commit hook environments and graphify). - Exercises the real post-commit and post-checkout hooks end to end. + Needs network access (installs the project's dev dependencies, graphify + included). Exercises the real post-commit and post-checkout hooks, and that + a checkout of a commit without a pre-commit config still succeeds. """ dst = tmp_path / "proj" + worktree = tmp_path / "wt" copier.run_copy( src_path=str(TEMPLATE_ROOT), dst_path=str(dst), @@ -544,41 +643,34 @@ def test_graphify_hooks_build_graph_per_worktree(tmp_path): unsafe=True, vcs_ref="HEAD", ) + assert (dst / "uv.lock").is_file(), "copier _tasks should create uv.lock" def run(*cmd, cwd=dst): - return subprocess.run( - cmd, cwd=cwd, check=True, capture_output=True, text=True - ) - - run("uv", "sync") - run("git", "config", "user.email", "t@example.com") - run("git", "config", "user.name", "T") - # Hook environments are installed lazily; the graphify hook is `system`. - run("uvx", "pre-commit", "install") - hooks = dst / ".git" / "hooks" - for name in ["post-commit", "post-checkout", "post-merge", "post-rewrite"]: - assert (hooks / name).is_file(), f"{name} hook not installed" - # `uvx pre-commit` runs the hooks with uv on PATH but not pre-commit - # itself; the installed shims call `pre-commit`, so expose it. - shim_dir = tmp_path / "shims" - shim_dir.mkdir() - pre_commit = shim_dir / "pre-commit" - pre_commit.write_text('#!/bin/sh\nexec uvx pre-commit "$@"\n', encoding="utf-8") - pre_commit.chmod(0o755) - env = {**os.environ, "PATH": f"{shim_dir}:{os.environ['PATH']}"} - - def git(*args, cwd=dst): - return subprocess.run( - ["git", *args], cwd=cwd, check=True, capture_output=True, text=True, env=env + return subprocess.run(cmd, cwd=cwd, check=True, capture_output=True, text=True) + + try: + run("uv", "sync") + run("git", "config", "user.email", "t@example.com") + run("git", "config", "user.name", "T") + run("uv", "run", "pre-commit", "install", "--allow-missing-config") + for name in ["post-commit", "post-checkout", "post-merge", "post-rewrite"]: + assert (dst / ".git" / "hooks" / name).is_file(), f"{name} not installed" + + run("git", "add", ".") + # --no-verify skips the pre-commit stage only; post-commit still fires. + run("git", "commit", "--no-verify", "-m", "chore: initial") + assert _wait_for(dst / "graphify-out" / "graph.json"), "no graph after commit" + + run("git", "worktree", "add", "-b", "other", str(worktree)) + assert _wait_for(worktree / "graphify-out" / "graph.json"), ( + "post-checkout did not build a graph in the new worktree" ) - git("add", ".") - git("commit", "--no-verify", "-m", "chore: initial") - # --no-verify skips pre-commit/commit-msg only; post-commit still fires. - assert _wait_for(dst / "graphify-out" / "graph.json"), "post-commit built no graph" - - worktree = tmp_path / "wt" - git("worktree", "add", "-b", "other", str(worktree)) - assert _wait_for(worktree / "graphify-out" / "graph.json"), ( - "post-checkout did not build a graph in the new worktree" - ) + # A commit without .pre-commit-config.yaml must stay checkout-able. + run("git", "checkout", "-q", "--orphan", "bare") + run("git", "rm", "-rfq", ".") + run("git", "commit", "-q", "--allow-empty", "--no-verify", "-m", "bare") + run("git", "checkout", "-q", "main") + run("git", "checkout", "-q", "bare") + finally: + _wait_idle(dst, worktree) From b54b93a5c0e7e955a36ac0e498623eda2fa8af69 Mon Sep 17 00:00:00 2001 From: Marius Helf Date: Mon, 19 Oct 2026 01:27:38 +0200 Subject: [PATCH 3/4] fix: make uv lock best-effort and never steal a live graphify lock Co-Authored-By: Claude Sonnet 5.5 --- copier.yml | 3 +- template/scripts/graphify-update.sh | 26 ++++++++++++---- tests/test_template.py | 46 +++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/copier.yml b/copier.yml index b7cd725..46992d9 100644 --- a/copier.yml +++ b/copier.yml @@ -10,7 +10,8 @@ _exclude: _tasks: - "git init" - - "uv lock" + # Best effort: offline or without uv the project must still be generated. + - "sh -c 'uv lock || echo \"uv lock failed; run it later to enable the code graph\"'" - "git checkout -b main" # Questions diff --git a/template/scripts/graphify-update.sh b/template/scripts/graphify-update.sh index d15e2f7..939cde0 100755 --- a/template/scripts/graphify-update.sh +++ b/template/scripts/graphify-update.sh @@ -15,6 +15,11 @@ # project's pinned version. export GRAPHIFY_NO_AUTO_REFRESH=1 +# Work from the worktree root, whatever the caller's directory (kept as is +# outside a git repository). +self=$(cd "$(dirname "$0")" && pwd)/$(basename "$0") +root=$(git rev-parse --show-toplevel 2>/dev/null) && cd "$root" + out=graphify-out lock=$out/.lock dirty=$out/.dirty @@ -31,12 +36,20 @@ trim_log() { fi } -# Take the lock, clearing it first when its holder is dead or it is too old. +# Remove the lock only if this process still owns it. +release() { + [ "$(cat "$lock/pid" 2>/dev/null)" = "$$" ] && rm -rf "$lock" + return 0 +} + +# Take the lock. It is stale if its holder is dead, or if it has no pid file +# and has not been touched for a while. A live holder is never robbed. acquire() { mkdir "$lock" 2>/dev/null && { echo $$ >"$lock/pid"; return 0; } pid=$(cat "$lock/pid" 2>/dev/null) if { [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; } || - [ -n "$(find "$lock" -maxdepth 0 -mmin +"$stale_minutes" 2>/dev/null)" ]; then + { [ -z "$pid" ] && + [ -n "$(find "$lock" -maxdepth 0 -mmin +"$stale_minutes" 2>/dev/null)" ]; }; then say "removing stale lock (holder ${pid:-unknown})" rm -rf "$lock" mkdir "$lock" 2>/dev/null && { echo $$ >"$lock/pid"; return 0; } @@ -46,8 +59,9 @@ acquire() { worker() { while acquire; do - trap 'rm -rf "$lock"' EXIT + trap release EXIT while [ -f "$dirty" ]; do + touch "$lock" 2>/dev/null rm -f "$dirty" say "update start" if uv run --frozen graphify update . >>"$log" 2>&1; then @@ -57,7 +71,7 @@ worker() { fi trim_log done - rm -rf "$lock" + release trap - EXIT # A trigger may have arrived between the last check and the release. [ -f "$dirty" ] || return 0 @@ -81,8 +95,8 @@ touch "$dirty" if [ -n "$GRAPHIFY_UPDATE_FOREGROUND" ]; then worker elif command -v setsid >/dev/null 2>&1; then - setsid sh "$0" --worker >/dev/null 2>&1 /dev/null 2>&1 /dev/null 2>&1 /dev/null 2>&1 > {tmp_path / 'builds.txt'}") + subprocess.run( + ["sh", _script(project.path)], + cwd=tmp_path, + env={**env, "GRAPHIFY_UPDATE_FOREGROUND": "1"}, + check=True, + ) + assert not (tmp_path / "builds.txt").exists() + assert (lock / "pid").read_text() == str(holder.pid) + finally: + holder.kill() + holder.wait() + + +def test_generation_survives_without_uv(tmp_path): + """The best-effort `uv lock` task must not abort project generation.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + for tool in ("git", "sh"): + (bin_dir / tool).symlink_to(shutil.which(tool)) + dst = tmp_path / "proj" + # copier runs tasks with plumbum's environment, not os.environ. + with plumbum.local.env(PATH=str(bin_dir)): + copier.run_copy( + src_path=str(TEMPLATE_ROOT), + dst_path=str(dst), + data={**COPIER_DATA, "include_hexagonal": False}, + defaults=True, + unsafe=True, + vcs_ref="HEAD", + ) + assert (dst / "pyproject.toml").is_file() + assert not (dst / "uv.lock").exists() + + def _online() -> bool: try: socket.create_connection(("pypi.org", 443), timeout=5).close() From 7ed0bbcbd58a78d480c4b0d9fceec2746cbdf3b9 Mon Sep 17 00:00:00 2001 From: Marius Helf Date: Mon, 19 Oct 2026 01:29:39 +0200 Subject: [PATCH 4/4] test: simulate a failing uv lock instead of removing tools from PATH Co-Authored-By: Claude Sonnet 5.5 --- tests/test_template.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/test_template.py b/tests/test_template.py index 97fc2a1..904f711 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -618,15 +618,12 @@ def test_graphify_script_does_not_steal_live_lock(project, tmp_path): holder.wait() -def test_generation_survives_without_uv(tmp_path): +def test_generation_survives_failing_uv_lock(tmp_path): """The best-effort `uv lock` task must not abort project generation.""" - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - for tool in ("git", "sh"): - (bin_dir / tool).symlink_to(shutil.which(tool)) + env = _fake_uv(tmp_path / "bin", "echo offline >&2; exit 2") dst = tmp_path / "proj" # copier runs tasks with plumbum's environment, not os.environ. - with plumbum.local.env(PATH=str(bin_dir)): + with plumbum.local.env(PATH=env["PATH"]): copier.run_copy( src_path=str(TEMPLATE_ROOT), dst_path=str(dst),