-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ci): wire LOC-gate into pre-commit hook (staged files only) #237
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| 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 | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| 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 | ||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Also applies to: 100-104, 131-137 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| /// 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Also applies to: 111-129, 153-157 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| 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(); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
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-onlyplus line-basedreadsplits valid filenames containing newlines, causing the resulting fragments to be silently skipped by the LOC gate.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents