From 14a39b6205a0cb439e3a7d350d265165d2bfef7d Mon Sep 17 00:00:00 2001 From: dave Date: Wed, 13 May 2026 14:38:55 +0000 Subject: [PATCH] huskies: merge 980 --- .huskies/AGENT.md | 6 ++ script/check | 21 ++-- server/src/agents/pool/start/spawn.rs | 7 ++ server/src/worktree/create.rs | 138 ++++++++++++++++++++++++++ server/src/worktree/mod.rs | 1 + 5 files changed, 167 insertions(+), 6 deletions(-) diff --git a/.huskies/AGENT.md b/.huskies/AGENT.md index 8a49a2fa..0c8a3f86 100644 --- a/.huskies/AGENT.md +++ b/.huskies/AGENT.md @@ -75,6 +75,12 @@ The frontend is embedded into the Rust binary via `rust-embed`. Run `npm run bui Clippy is zero-tolerance: no warnings allowed. Fix every warning before committing. +## Pre-commit hook + +Every agent worktree has a pre-commit hook installed at `.git-hooks/pre-commit` that runs `script/check` (fmt-check, clippy, cargo check, source-map-check) before every `git commit`. If the hook fails, fix the issues shown and re-run `script/check` to validate. + +`git commit --no-verify` bypasses the hook. Do **not** use it. The hook exists to prevent broken commits from reaching the merge gate; bypassing it defeats the purpose and wastes CI cycles. + ## File size Target a maximum of 800 lines per source file as a soft guide. If a file grows beyond 800 lines, decompose it by concern into smaller modules. Split at natural seams: group related types, functions, or handlers together and move each cohesive group to its own file. This keeps files readable and diffs focused. diff --git a/script/check b/script/check index b31fd795..b9fd69cd 100755 --- a/script/check +++ b/script/check @@ -1,8 +1,17 @@ #!/usr/bin/env bash -# Fast compile-only check + doc-coverage check on changed files. -# Use this for rapid iteration feedback while writing code. -# Catches the doc-coverage gate failures locally instead of waiting for -# the merge gate to bounce on a single missing `///`. +# Pre-commit quality gate: fmt-check, clippy, cargo check, and doc-coverage. +# Run this before committing to catch fmt drift, clippy warnings, compile +# errors, and missing doc comments without waiting for the full test suite. set -euo pipefail -cargo check --tests --workspace -cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +echo "=== Checking Rust formatting ===" +cargo fmt --manifest-path "$PROJECT_ROOT/Cargo.toml" --all --check + +echo "=== Running cargo clippy ===" +cargo clippy --manifest-path "$PROJECT_ROOT/Cargo.toml" --workspace --all-targets -- -D warnings + +echo "=== Checking doc coverage on changed files ===" +cargo run --manifest-path "$PROJECT_ROOT/Cargo.toml" -p source-map-gen --bin source-map-check --quiet -- --worktree "$PROJECT_ROOT" --base master diff --git a/server/src/agents/pool/start/spawn.rs b/server/src/agents/pool/start/spawn.rs index acf66c9c..57ccc2e3 100644 --- a/server/src/agents/pool/start/spawn.rs +++ b/server/src/agents/pool/start/spawn.rs @@ -195,6 +195,13 @@ pub(super) async fn run_agent_spawn( } }; + // Step 1.1: Install the pre-commit quality-gate hook in the worktree. + // Non-fatal — if installation fails the agent can still run; the failure + // is logged so the operator can investigate. + if let Err(e) = crate::worktree::install_pre_commit_hook(&wt_info.path) { + slog_error!("[agents] pre-commit hook install failed for {sid}: {e}"); + } + // Step 1.5: Update the source map for changed files since master. // Non-blocking — failures are logged but do not gate the spawn. { diff --git a/server/src/worktree/create.rs b/server/src/worktree/create.rs index 61c8e35c..7047b7cb 100644 --- a/server/src/worktree/create.rs +++ b/server/src/worktree/create.rs @@ -89,6 +89,69 @@ pub(crate) async fn run_teardown_commands( Ok(()) } +/// Install a pre-commit git hook in an agent worktree. +/// +/// Creates `{wt_path}/.git-hooks/pre-commit` containing a shell script that +/// runs `script/check` and aborts the commit if it fails. Configures the +/// worktree's `core.hooksPath` (via `git config --worktree`) so only this +/// worktree uses the per-worktree hooks directory. +pub fn install_pre_commit_hook(wt_path: &Path) -> Result<(), String> { + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + let hooks_dir = wt_path.join(".git-hooks"); + std::fs::create_dir_all(&hooks_dir).map_err(|e| format!("create .git-hooks dir: {e}"))?; + + let hook = "#!/bin/sh\n\ + #\n\ + # Pre-commit hook installed by huskies.\n\ + # Runs script/check (fmt-check, clippy, cargo check, source-map-check)\n\ + # before every commit. Aborts if any gate fails.\n\ + #\n\ + # Emergency bypass: git commit --no-verify (see AGENT.md — avoid this)\n\ + \n\ + REPO_ROOT=\"$(git rev-parse --show-toplevel)\"\n\ + \n\ + printf '[pre-commit] Running script/check ...\\n'\n\ + OUTPUT=$(\"$REPO_ROOT/script/check\" 2>&1)\n\ + STATUS=$?\n\ + \n\ + if [ \"$STATUS\" -ne 0 ]; then\n\ + printf '\\n=== PRE-COMMIT HOOK FAILED ===\\n\\n'\n\ + printf '%s\\n' \"$OUTPUT\"\n\ + printf '\\nFix the issues above, then re-validate with:\\n'\n\ + printf ' script/check\\n'\n\ + printf '\\nEmergency bypass (see AGENT.md -- avoid this):\\n'\n\ + printf ' git commit --no-verify\\n\\n'\n\ + exit 1\n\ + fi\n"; + + let hook_path = hooks_dir.join("pre-commit"); + std::fs::write(&hook_path, hook).map_err(|e| format!("write pre-commit hook: {e}"))?; + + #[cfg(unix)] + std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755)) + .map_err(|e| format!("chmod pre-commit hook: {e}"))?; + + // Point git at the per-worktree hooks dir so only this worktree uses + // these hooks (not the main repo or other worktrees). + // Requires extensions.worktreeConfig = true in the repository config. + let output = std::process::Command::new("git") + .args(["config", "--worktree", "core.hooksPath", ".git-hooks"]) + .current_dir(wt_path) + .output() + .map_err(|e| format!("git config --worktree core.hooksPath: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "git config --worktree core.hooksPath failed: {stderr}" + )); + } + + Ok(()) +} + pub(crate) async fn run_shell_command(cmd: &str, cwd: &Path) -> Result<(), String> { let cmd = cmd.to_string(); let cwd = cwd.to_path_buf(); @@ -335,4 +398,79 @@ mod tests { result.err() ); } + + #[test] + fn install_pre_commit_hook_creates_executable_hook_and_sets_hookspath() { + let tmp = TempDir::new().unwrap(); + let project_root = tmp.path().join("main-repo"); + fs::create_dir_all(&project_root).unwrap(); + init_git_repo(&project_root); + + // Enable per-worktree config so git config --worktree works. + Command::new("git") + .args(["config", "extensions.worktreeConfig", "true"]) + .current_dir(&project_root) + .output() + .expect("enable extensions.worktreeConfig"); + + // Create a linked worktree to simulate what huskies does for agents. + let wt_path = tmp.path().join("linked-wt"); + let out = Command::new("git") + .args([ + "worktree", + "add", + wt_path.to_str().unwrap(), + "-b", + "feature/hook-test", + ]) + .current_dir(&project_root) + .output() + .expect("git worktree add"); + assert!( + out.status.success(), + "git worktree add failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + install_pre_commit_hook(&wt_path).expect("install_pre_commit_hook must succeed"); + + // Hook file must exist. + let hook_path = wt_path.join(".git-hooks").join("pre-commit"); + assert!(hook_path.exists(), "pre-commit hook must be created"); + + // Hook must be executable. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(&hook_path).unwrap().permissions().mode(); + assert!(mode & 0o111 != 0, "pre-commit hook must be executable"); + } + + // Hook content must reference script/check and --no-verify. + let content = fs::read_to_string(&hook_path).unwrap(); + assert!( + content.contains("script/check"), + "hook must invoke script/check; got:\n{content}" + ); + assert!( + content.contains("--no-verify"), + "hook must mention --no-verify bypass; got:\n{content}" + ); + + // git config core.hooksPath for the worktree must be .git-hooks. + let cfg_out = Command::new("git") + .args(["config", "--worktree", "core.hooksPath"]) + .current_dir(&wt_path) + .output() + .expect("git config --worktree core.hooksPath"); + assert!( + cfg_out.status.success(), + "git config --worktree core.hooksPath lookup failed" + ); + let value = String::from_utf8_lossy(&cfg_out.stdout).trim().to_string(); + assert_eq!( + value, ".git-hooks", + "core.hooksPath must be set to .git-hooks" + ); + } } diff --git a/server/src/worktree/mod.rs b/server/src/worktree/mod.rs index eb32b647..9035617f 100644 --- a/server/src/worktree/mod.rs +++ b/server/src/worktree/mod.rs @@ -9,6 +9,7 @@ mod sweep; pub use cleanup::{format_report, run_cleanup}; pub use create::create_worktree; +pub use create::install_pre_commit_hook; pub use git::migrate_slug_paths; pub use remove::remove_worktree_by_story_id; pub use sweep::sweep_orphaned_worktrees;