Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -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 <dir-containing-this-hook>
# (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 <<EOF
ERROR: refusing to commit directly to the default branch '$default'.

agentflare enforces branch isolation: create an isolated worktree or feature
branch first, e.g.
git worktree add ../<repo>-<topic> -b <topic>
# or, in-session:
git checkout -b <topic>

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)
Comment on lines +76 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use NUL-delimited staged filenames.

--name-only plus line-based read splits valid filenames containing newlines, causing the resulting fragments to be silently skipped by the LOC gate.

Proposed fix
     staged_rs=()
-    while IFS= read -r f; do
+    while IFS= read -r -d '' f; do
         [ -n "$f" ] && staged_rs+=("$f")
-    done < <(git diff --cached --name-only --diff-filter=ACMR -- '*.rs' 2>/dev/null || true)
+    done < <(git diff --cached --name-only -z --diff-filter=ACMR -- '*.rs' 2>/dev/null || true)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
staged_rs=()
while IFS= read -r -d '' f; do
[ -n "$f" ] && staged_rs+=("$f")
done < <(git diff --cached --name-only -z --diff-filter=ACMR -- '*.rs' 2>/dev/null || true)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.githooks/pre-commit around lines 76 - 79, Update the staged Rust filename
collection in the pre-commit hook to request NUL-delimited output from git and
consume it with NUL-aware reading, preserving complete filenames—including
embedded newlines—when populating staged_rs for the LOC gate.

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
60 changes: 60 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -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 <default>` 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 <dir-containing-this-hook>
# (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")
Comment on lines +43 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Guard the destination ref, not the source ref.

The protected branch is remote_ref. Checking local_ref allows git push origin feature:main to update the remote default branch while rejecting harmless pushes such as main:backup.

Proposed fix
 while read -r local_ref local_sha remote_ref remote_sha; do
-    case "$local_ref" in
+    case "$remote_ref" in
         refs/heads/"$default")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
while read -r local_ref local_sha remote_ref remote_sha; do
case "$local_ref" in
refs/heads/"$default")
while read -r local_ref local_sha remote_ref remote_sha; do
case "$remote_ref" in
refs/heads/"$default")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.githooks/pre-push around lines 43 - 45, Update the branch-matching case in
the pre-push hook’s read loop to inspect remote_ref rather than local_ref, so
protection applies to pushes targeting the remote default branch while allowing
pushes sourced from it to other branches.

cat >&2 <<EOF
ERROR: refusing to push the default branch '$default' to a remote.

agentflare enforces branch isolation. Push a feature/worktree branch and open
a PR instead. To override in an emergency (not recommended):
git push --no-verify origin $default
EOF
exit 1
;;
*)
;;
esac
done

exit 0
54 changes: 35 additions & 19 deletions scripts/loc-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,23 @@ fail=0
file_list=$(mktemp)
trap 'rm -f "$file_list"' EXIT

# git ls-files 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 ! git ls-files -z -- '*.rs' >"$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
Expand All @@ -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
Expand All @@ -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"
158 changes: 158 additions & 0 deletions src/cli/git.rs
Original file line number Diff line number Diff line change
@@ -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 `<repo>/.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,
Comment on lines +34 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Honor the confirmation contract before overwriting hooks.

--yes is documented as skipping confirmation, but opts is discarded and existing .githooks/pre-commit and .githooks/pre-push files are overwritten without consent. Prompt unless opts.yes, especially when either destination already exists.

Also applies to: 100-104, 131-137

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/git.rs` around lines 34 - 38, Update the install-hooks flow using
InstallHooksArgs and its opts value to honor the confirmation contract: before
overwriting existing .githooks/pre-commit or .githooks/pre-push files, prompt
for confirmation unless opts.yes is true. Preserve non-interactive behavior when
--yes is supplied, and abort without overwriting if confirmation is declined.

}

/// 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)?;
}
Comment on lines +52 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Refresh shared templates on every installation.

Files are written only when absent, so an older Agentflare installation permanently retains stale embedded hooks. Subsequent runs copy those stale scripts instead of the current PRE_COMMIT and PRE_PUSH versions.

Proposed fix
     let pc = dir.join("pre-commit");
-    if !pc.exists() {
-        fs::write(&pc, PRE_COMMIT)?;
-    }
+    fs::write(&pc, PRE_COMMIT)?;
     let pp = dir.join("pre-push");
-    if !pp.exists() {
-        fs::write(&pp, PRE_PUSH)?;
-    }
+    fs::write(&pp, PRE_PUSH)?;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)?;
}
fn ensure_shared_templates() -> std::io::Result<()> {
let dir = shared_hooks_dir();
fs::create_dir_all(&dir)?;
let pc = dir.join("pre-commit");
fs::write(&pc, PRE_COMMIT)?;
let pp = dir.join("pre-push");
fs::write(&pp, PRE_PUSH)?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/git.rs` around lines 52 - 62, Update ensure_shared_templates to
overwrite the existing pre-commit and pre-push files on every installation using
the current PRE_COMMIT and PRE_PUSH embedded contents; remove the existence
checks while preserving directory creation and error propagation.

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");
Comment on lines +73 to +94

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Resolve the actual repository root.

When invoked from a repository subdirectory, rev-parse --git-dir passes but .githooks is created beneath that subdirectory. The relative core.hooksPath is resolved for the repository, so the installed hooks are not found. Use git rev-parse --show-toplevel as repo_root.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/git.rs` around lines 73 - 94, Update the repository-root resolution
in the install-hooks flow to obtain repo_root from git rev-parse
--show-toplevel, rather than current_dir. Preserve the existing not-a-repository
error handling and use the resolved root for .githooks creation and subsequent
operations.

if let Err(e) = fs::create_dir_all(&local_dir) {
eprintln!("agentflare git install-hooks: cannot create {local_dir:?}: {e}");
return;
}
Comment on lines +72 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Propagate installation failures before reporting success.

Template, directory, copy, permission, and git config failures either return a successful CLI status or are ignored entirely. In particular, the command prints core.hooksPath as configured even when git config fails. Return a Result, check every operation, and print success only after all steps complete.

Also applies to: 111-129, 153-157

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/git.rs` around lines 72 - 98, Update install_hooks to return a Result
and propagate failures from ensure_shared_templates, directory creation, hook
copying, permission updates, and git config operations instead of returning
success or ignoring errors. Check the git config result before printing the
configured core.hooksPath, and emit the success message only after every
installation step completes; update the related call sites and ranges around
install_hooks accordingly.


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<String> {
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();
}
Loading
Loading