diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 00000000..141ec855 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# agentflare branch-protection guard (pre-commit). +# +# Blocks direct commits while on the repo's default branch. The PreToolUse +# branch guard in src/hook_redirect.rs only watches file-write tools, so a +# `git commit` issued through a Bash/shell tool slips past it (see item #132 +# follow-up). A native git hook is the shell-agnostic enforcement boundary: +# it fires for ANY git client — agent Bash, human CLI, CI — not just tool +# calls that route through the agent's PreToolUse hook. +# +# Fail-open by design: if git/branch resolution errors (no repo, no remote, +# detached HEAD), the commit is allowed rather than blocked. +# +# Installed into a project via: +# git config core.hooksPath +# (agentflare stores the canonical copy under ~/.agentflare/githooks/). + +set -euo pipefail + +resolve_default_branch() { + if ! git rev-parse --git-dir >/dev/null 2>&1; then + return 0 + fi + + if git symbolic-ref refs/remotes/origin/HEAD >/dev/null 2>&1; then + git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@' + return 0 + fi + + local cfg + cfg="$(git config --get init.defaultBranch 2>/dev/null || true)" + if [ -n "$cfg" ]; then + echo "$cfg" + return 0 + fi + + for cand in main master; do + if git show-ref --verify --quiet "refs/heads/$cand" 2>/dev/null; then + echo "$cand" + return 0 + fi + done + + echo "master" +} + +current="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")" +if [ -z "$current" ] || [ "$current" = "HEAD" ]; then + exit 0 +fi + +default="$(resolve_default_branch)" + +if [ "$current" = "$default" ]; then + cat >&2 <- -b + # or, in-session: + git checkout -b + +Then commit there and open a PR. To override in an emergency (not recommended): + git commit --no-verify +EOF + exit 1 +fi + +# LOC gate, staged files only (the full-repo scan runs in CI — see +# scripts/loc-gate.sh; passing it explicit paths runs a fast partial scan +# instead). Fail-open if the repo root or script can't be resolved, matching +# this hook's existing fail-open design. +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [ -n "$repo_root" ] && [ -f "$repo_root/scripts/loc-gate.sh" ]; then + staged_rs=() + while IFS= read -r f; do + [ -n "$f" ] && staged_rs+=("$f") + done < <(git diff --cached --name-only --diff-filter=ACMR -- '*.rs' 2>/dev/null || true) + if [ "${#staged_rs[@]}" -gt 0 ]; then + if ! bash "$repo_root/scripts/loc-gate.sh" "${staged_rs[@]}"; then + echo "ERROR: LOC gate failed on staged file(s) above." >&2 + echo "Split the file, or override in an emergency (not recommended):" >&2 + echo " git commit --no-verify" >&2 + exit 1 + fi + fi +fi + +exit 0 diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100644 index 00000000..1fbbcb9b --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# agentflare branch-protection guard (pre-push). +# +# Blocks pushing the repo's default branch to a remote. Mirrors the +# pre-commit guard: the agent's PreToolUse branch guard (src/hook_redirect.rs) +# only watches file-write tools, so `git push origin ` from a shell +# tool would bypass it. A native git hook is the shell-agnostic boundary that +# covers every git client (agent Bash, human CLI, CI). +# +# Fail-open by design: resolution errors (no repo, no remote) allow the push. +# +# Installed into a project via: +# git config core.hooksPath +# (agentflare stores the canonical copy under ~/.agentflare/githooks/.) + +set -euo pipefail + +resolve_default_branch() { + if git symbolic-ref refs/remotes/origin/HEAD >/dev/null 2>&1; then + git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@' + return 0 + fi + + local cfg + cfg="$(git config --get init.defaultBranch 2>/dev/null || true)" + if [ -n "$cfg" ]; then + echo "$cfg" + return 0 + fi + + for cand in main master; do + if git show-ref --verify --quiet "refs/heads/$cand" 2>/dev/null; then + echo "$cand" + return 0 + fi + done + + echo "master" +} + +default="$(resolve_default_branch)" + +while read -r local_ref local_sha remote_ref remote_sha; do + case "$local_ref" in + refs/heads/"$default") + cat >&2 <"$file_list"; then - echo "FAIL: git ls-files failed — refusing to report a clean LOC gate" >&2 - exit 1 +# With args: partial scan (e.g. the pre-commit hook checking only staged +# files) — fast, and skips the allowlist-ratchet check below, which is a +# whole-repo invariant that doesn't apply to a subset. With no args: full +# scan via git ls-files, which only lists tracked files, so it naturally +# skips build output, vendored/scratch clones, and other worktrees without +# needing to know their paths in advance (all of those are either +# .gitignored or simply never added). The hidden-folder skip below is +# defense in depth on top of that, not the primary exclusion. +if (($# > 0)); then + partial_scan=1 + printf '%s\0' "$@" >"$file_list" +else + partial_scan=0 + if ! git ls-files -z -- '*.rs' >"$file_list"; then + echo "FAIL: git ls-files failed — refusing to report a clean LOC gate" >&2 + exit 1 + fi fi while IFS= read -r -d '' file; do @@ -41,6 +50,7 @@ while IFS= read -r -d '' file; do case "$file" in .*/*|*/.*) continue ;; esac + [[ -f "$file" ]] || continue lines=$(wc -l <"$file" | tr -d ' ') if is_allowed "$file"; then if ((lines > FROZEN_LIMIT)); then @@ -53,20 +63,26 @@ while IFS= read -r -d '' file; do fi done <"$file_list" -for a in "${ALLOWLIST[@]}"; do - if [[ -f "$a" ]]; then - lines=$(wc -l <"$a" | tr -d ' ') - if ((lines <= LIMIT)); then - echo "FAIL: $a is now $lines lines (<= $LIMIT) — remove it from allowlist in scripts/loc-gate.sh" +if ((partial_scan == 0)); then + for a in "${ALLOWLIST[@]}"; do + if [[ -f "$a" ]]; then + lines=$(wc -l <"$a" | tr -d ' ') + if ((lines <= LIMIT)); then + echo "FAIL: $a is now $lines lines (<= $LIMIT) — remove it from allowlist in scripts/loc-gate.sh" + fail=1 + fi + else + echo "FAIL: allowlisted file $a no longer exists — remove it from scripts/loc-gate.sh" fail=1 fi - else - echo "FAIL: allowlisted file $a no longer exists — remove it from scripts/loc-gate.sh" - fail=1 - fi -done + done +fi if ((fail == 0)); then - echo "LOC gate OK: all non-allowlisted Rust files <= $LIMIT lines (${#ALLOWLIST[@]} legacy files frozen <= $FROZEN_LIMIT)" + if ((partial_scan == 1)); then + echo "LOC gate OK: staged Rust file(s) within limits" + else + echo "LOC gate OK: all non-allowlisted Rust files <= $LIMIT lines (${#ALLOWLIST[@]} legacy files frozen <= $FROZEN_LIMIT)" + fi fi exit "$fail" diff --git a/src/cli/git.rs b/src/cli/git.rs new file mode 100644 index 00000000..d02ff681 --- /dev/null +++ b/src/cli/git.rs @@ -0,0 +1,158 @@ +//! `agentflare git install-hooks` — installs the shared branch-protection +//! git hooks (pre-commit / pre-push) into the current repository. +//! +//! The canonical hook scripts live in `~/.agentflare/githooks/` (populated by +//! this same command on first run, and reusable across every project). Each +//! invocation copies them into `/.githooks/` and points the repo's +//! `core.hooksPath` at that directory, so the guard is reproducible across +//! clones and applies to every git client (agent Bash, human CLI, CI) — not +//! just tool calls that route through the agent's PreToolUse hook. +//! +//! Why a git hook and not (only) the PreToolUse branch guard in +//! `src/hook_redirect.rs`: that guard only watches file-write tools +//! (`Write`/`Edit`/`ctx_patch`/...), so a `git commit`/`git push` issued +//! through a Bash/shell tool slips past it. A native git hook is the +//! shell-agnostic enforcement boundary. See item #132 follow-up. + +use crate::paths::home; +use clap::{Args, Subcommand}; +use std::fs; +use std::path::PathBuf; + +#[derive(Args)] +pub struct GitArgs { + #[command(subcommand)] + pub command: GitCommand, +} + +#[derive(Subcommand)] +pub enum GitCommand { + /// Install branch-protection pre-commit/pre-push hooks into this repo. + InstallHooks(InstallHooksArgs), +} + +#[derive(Args)] +pub struct InstallHooksArgs { + /// Skip the confirmation prompt (for non-interactive/scripted use). + #[arg(long)] + pub yes: bool, +} + +/// Canonical location: `~/.agentflare/githooks/`. +fn shared_hooks_dir() -> PathBuf { + home().join(".agentflare").join("githooks") +} + +/// The hook scripts embedded as the canonical source of truth. Written into +/// `~/.agentflare/githooks/` on first `install-hooks`, so the shared location +/// is self-bootstrapping and survives repo checkouts. +const PRE_COMMIT: &str = include_str!("../../.githooks/pre-commit"); +const PRE_PUSH: &str = include_str!("../../.githooks/pre-push"); + +fn ensure_shared_templates() -> std::io::Result<()> { + let dir = shared_hooks_dir(); + fs::create_dir_all(&dir)?; + let pc = dir.join("pre-commit"); + if !pc.exists() { + fs::write(&pc, PRE_COMMIT)?; + } + let pp = dir.join("pre-push"); + if !pp.exists() { + fs::write(&pp, PRE_PUSH)?; + } + Ok(()) +} + +pub fn run(args: GitArgs) { + match args.command { + GitCommand::InstallHooks(opts) => install_hooks(opts), + } +} + +fn install_hooks(opts: InstallHooksArgs) { + let repo_root = match std::env::current_dir() { + Ok(d) => d, + Err(e) => { + eprintln!("agentflare git install-hooks: cannot resolve cwd: {e}"); + return; + } + }; + + // Sanity: must be inside a git repo. + if !repo_root.join(".git").exists() + && run_git(&repo_root, &["rev-parse", "--git-dir"]).is_none() + { + eprintln!("agentflare git install-hooks: not a git repository (run inside a repo root)"); + return; + } + + if let Err(e) = ensure_shared_templates() { + eprintln!("agentflare git install-hooks: cannot write shared templates: {e}"); + return; + } + + let local_dir = repo_root.join(".githooks"); + if let Err(e) = fs::create_dir_all(&local_dir) { + eprintln!("agentflare git install-hooks: cannot create {local_dir:?}: {e}"); + return; + } + + let mut changed = false; + for name in ["pre-commit", "pre-push"] { + let src = shared_hooks_dir().join(name); + let dst = local_dir.join(name); + match fs::copy(&src, &dst) { + Ok(_) => { + // Git requires the hook to be executable. On Unix the copied + // file keeps the shared template's mode (0600 from a fresh + // write), so make it user-executable. On Windows git runs + // hooks through its bundled sh and ignores the bit, but + // setting it is harmless and keeps the repo portable. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&dst, fs::Permissions::from_mode(0o755)); + } + println!(" ok .githooks/{name}"); + changed = true; + } + Err(e) => { + eprintln!(" fail copying {name}: {e}"); + return; + } + } + } + + // Point the repo at the local .githooks dir (relative, so it survives + // clone/move). `git config` is run via the shell-free helper below. + set_hooks_path(&repo_root, ".githooks"); + println!(" ok core.hooksPath = .githooks"); + + if changed { + println!( + "\nBranch-protection hooks installed. Direct commits/pushes to the \ + default branch are now blocked for every git client in this repo." + ); + let _ = opts; + } +} + +fn run_git(repo: &std::path::Path, args: &[&str]) -> Option { + let out = std::process::Command::new("git") + .args(args) + .current_dir(repo) + .output() + .ok()?; + if out.status.success() { + Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } else { + None + } +} + +fn set_hooks_path(repo: &std::path::Path, path: &str) { + let _ = std::process::Command::new("git") + .args(["config", "core.hooksPath", path]) + .current_dir(repo) + .output(); +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3319a619..9f79b75b 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -8,6 +8,7 @@ mod coaching; mod cost; mod dev_install; mod gateway; +mod git; mod handoff; mod hook; mod init; @@ -46,6 +47,7 @@ pub enum Commands { DevInstall(dev_install::DevInstallArgs), Coaching(coaching::CoachingArgs), Gateway(gateway::GatewayArgs), + Git(git::GitArgs), Mcp(mcp::McpArgs), Agents(agents::AgentsArgs), Run(run::RunArgs), @@ -72,6 +74,7 @@ impl Commands { Self::DevInstall(cmd) => cmd.run(), Self::Coaching(cmd) => cmd.run(), Self::Gateway(cmd) => cmd.run(), + Self::Git(cmd) => git::run(cmd), Self::Mcp(cmd) => cmd.run(), Self::Agents(cmd) => cmd.run(), Self::Run(cmd) => cmd.run(),