From d329db4abd391ee3a3ae08107b36c90a7952127c Mon Sep 17 00:00:00 2001 From: zackees Date: Fri, 19 Jun 2026 16:55:30 -0700 Subject: [PATCH 1/2] fix(hooks): stop triggering full editable rebuild on every Edit/Write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two long-standing pain points with `.claude/hooks` on this repo: 1. **Every hook fire was a `soldr cargo build --release -p fbuild-cli`.** The settings.json entries used `uv run --script ci/hooks/.py` without `--no-project`. uv interpreted the cwd as the fbuild project root and ran `setuptools.build_meta.build_editable`, which fbuild's pyproject wires to the native fbuild-cli build. So every Edit/Write kicked off a release build of the CLI — minutes of latency per file change, and a hard failure whenever the underlying soldr/zccache daemon flaked (which is the very class of bug we're hitting in zccache#774 / zccache#784). Fix: add `--no-project` to every hook command. The hooks all use PEP 723 inline script metadata, so they don't actually need the project to be installed — uv was syncing it as a side-effect of project-discovery. 2. **`lint.py` ran fbuild's `./lint` against files outside this repo.** When editing files in a sibling repo's worktree (e.g. a `.claude/` worktree under `~/dev/zccache/`), `detect_crate()` read the worktree's `crates//` path segment and ran `soldr cargo clippy -p ` against the fbuild workspace. Either that errored (no such package) or — when names happened to collide — it lint'd the wrong code. Fix: gate `lint.py` on `file_path.startswith(PROJECT_ROOT)` so files outside this repo are no-ops. And invoke `./lint` via `sys.executable` instead of `uv run --script` so it doesn't trigger a SECOND editable rebuild on top of the outer one. Also: `.claude/tmp/` (scratch for hereoc'd issue bodies / PR descriptions during Claude sessions) added to `.gitignore`. No CI behavior change — `cargo fmt --all --check` + clippy still run via `Stop` hook on workspace-level passes. Per-file Edit hooks now finish in milliseconds instead of minutes. --- .claude/settings.json | 14 +++++++------- .gitignore | 1 + ci/hooks/lint.py | 15 +++++++++++++-- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 30a12bc1..ab2889f8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "cd \"$(git rev-parse --show-toplevel)\" && uv run --script ci/hooks/board_context.py", + "command": "cd \"$(git rev-parse --show-toplevel)\" && uv run --no-project --script ci/hooks/board_context.py", "timeout": 5 } ] @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "cd \"$(git rev-parse --show-toplevel)\" && uv run --script ci/hooks/tool_guard.py", + "command": "cd \"$(git rev-parse --show-toplevel)\" && uv run --no-project --script ci/hooks/tool_guard.py", "timeout": 5 } ] @@ -29,12 +29,12 @@ "hooks": [ { "type": "command", - "command": "cd \"$(git rev-parse --show-toplevel)\" && uv run --script ci/hooks/lint.py", + "command": "cd \"$(git rev-parse --show-toplevel)\" && uv run --no-project --script ci/hooks/lint.py", "timeout": 120 }, { "type": "command", - "command": "cd \"$(git rev-parse --show-toplevel)\" && uv run --script ci/hooks/readme_guard.py", + "command": "cd \"$(git rev-parse --show-toplevel)\" && uv run --no-project --script ci/hooks/readme_guard.py", "timeout": 5 } ] @@ -45,7 +45,7 @@ "hooks": [ { "type": "command", - "command": "cd \"$(git rev-parse --show-toplevel)\" && uv run --script ci/hooks/check-on-start.py", + "command": "cd \"$(git rev-parse --show-toplevel)\" && uv run --no-project --script ci/hooks/check-on-start.py", "timeout": 10 } ] @@ -56,12 +56,12 @@ "hooks": [ { "type": "command", - "command": "cd \"$(git rev-parse --show-toplevel)\" && uv run --script ci/hooks/code-review-on-stop.py", + "command": "cd \"$(git rev-parse --show-toplevel)\" && uv run --no-project --script ci/hooks/code-review-on-stop.py", "timeout": 10 }, { "type": "command", - "command": "cd \"$(git rev-parse --show-toplevel)\" && uv run --script ci/hooks/check-on-stop.py", + "command": "cd \"$(git rev-parse --show-toplevel)\" && uv run --no-project --script ci/hooks/check-on-stop.py", "timeout": 120 } ] diff --git a/.gitignore b/.gitignore index c1bd2f19..ddd38192 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,4 @@ tasks/loop-runs/ # clud project settings !.clud/ !.clud/settings.json +.claude/tmp/ diff --git a/ci/hooks/lint.py b/ci/hooks/lint.py index e19e63c6..deb43735 100644 --- a/ci/hooks/lint.py +++ b/ci/hooks/lint.py @@ -50,10 +50,21 @@ def main(): if not os.path.isfile(file_path): return 0 - # Delegate to ./lint in single-file mode + # Skip files outside this project (e.g. when editing inside a worktree + # of another repo). detect_crate() would otherwise read the worktree's + # `crates/` segment and run clippy with -p against fbuild, which + # either fails or — when names collide — lints the wrong code. + project_root_norm = str(PROJECT_ROOT).replace("\\", "/").rstrip("/") + "/" + if not file_path.startswith(project_root_norm): + return 0 + + # Delegate to ./lint in single-file mode. Use the active interpreter + # directly instead of `uv run --script` so we don't trigger another + # editable-build of the fbuild project (which would re-run + # `soldr cargo build --release -p fbuild-cli` on every Edit). lint_script = str(PROJECT_ROOT / "lint") result = subprocess.run( - ["uv", "run", "--script", lint_script, file_path], + [sys.executable, lint_script, file_path], capture_output=True, text=True, encoding="utf-8", From fef4d894fddfd8e69168d7e136952cb99e575c93 Mon Sep 17 00:00:00 2001 From: zackees Date: Fri, 19 Jun 2026 19:04:07 -0700 Subject: [PATCH 2/2] fix(hooks): canonicalize lint path containment --- ci/hooks/lint.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ci/hooks/lint.py b/ci/hooks/lint.py index deb43735..bd821409 100644 --- a/ci/hooks/lint.py +++ b/ci/hooks/lint.py @@ -35,12 +35,11 @@ def main(): if not file_path: return 0 - # Normalize path - file_path = file_path.replace("\\", "/") - # Resolve relative paths against project root if not os.path.isabs(file_path): - file_path = os.path.join(str(PROJECT_ROOT), file_path).replace("\\", "/") + file_path = os.path.join(str(PROJECT_ROOT), file_path) + + file_path = os.path.realpath(file_path) # Only lint Rust files if not file_path.endswith(".rs"): @@ -54,8 +53,9 @@ def main(): # of another repo). detect_crate() would otherwise read the worktree's # `crates/` segment and run clippy with -p against fbuild, which # either fails or — when names collide — lints the wrong code. - project_root_norm = str(PROJECT_ROOT).replace("\\", "/").rstrip("/") + "/" - if not file_path.startswith(project_root_norm): + project_root_real = os.path.normcase(os.path.realpath(str(PROJECT_ROOT))) + file_path_check = os.path.normcase(file_path) + if os.path.commonpath([project_root_real, file_path_check]) != project_root_real: return 0 # Delegate to ./lint in single-file mode. Use the active interpreter