Skip to content

feat(hook-pack): warn-default push-to-main + commit-size PreToolUse gates#248

Merged
naimkatiman merged 3 commits into
mainfrom
feat/enforcing-hook-pack
Jun 19, 2026
Merged

feat(hook-pack): warn-default push-to-main + commit-size PreToolUse gates#248
naimkatiman merged 3 commits into
mainfrom
feat/enforcing-hook-pack

Conversation

@naimkatiman

Copy link
Copy Markdown
Owner

Summary

Adds hook-pack, a warn-default PreToolUse hook that converts two prose-only repo rules into enforced gates:

  • push-to-main — a direct git push to a protected branch (main / master / release/*)
  • commit-size — a git commit staging more than 15 files (the one-concern limit)

This is the WILD pilot from the 2026-06-17 report-coverage map — the first piece that turns advised discipline into tool-boundary enforcement (the map's core finding was that this discipline existed only as prose, which is why the source report's failures happened despite the guidance).

Design

  • One hook (hooks/hook-pack.mjs), registered second in PreToolUse with a Bash matcher so it only spawns on Bash tool calls — the non-Bash hot path stays at two subprocesses, and gateguard stays first (existing test pins this).
  • Pure logic in lib/hook-pack-gate.mjs (parseMode, classifyCommand, evaluatePush, evaluateCommitSize, decide); the hook is an I/O shell wiring stdin + git subprocess (rev-parse / diff --cached) around it. Split mirrors gateguard (lib/gateguard-state) and goal-drift (lib/goal-drift-gate), and keeps the logic importable by tests (the test-imports-only invariant forbids importing from hooks/).
  • Mode CLAUDE_CI_HOOKPACK_GATE = warn (default) | block | off. warn prints a stderr notice and never blocks; block emits the PreToolUse deny shape (empty stdout = allow, per the schema we already burned on); off is a no-op. Ships warn-default, so nothing changes in any project until the operator opts into block.
  • Fail-open: malformed payload, non-git command, or a failed git call → allow. Only git push / git commit spawn a subprocess.

Scope

  • In: src/hooks/hook-pack.mts, src/lib/hook-pack-gate.mts, registration in src/lib/plugin-metadata.mts, src/test/hook-pack.test.mts, + generated mirrors (12 files total: 5 source + 7 generated).
  • Out (deferred): branch-switch-mid-task and pre-verified-deploy gates (fragile — need session baseline state / a "verified" signal); a generated-file-aware commit-size classifier (v1 uses a flat count + the =off escape hatch).

Verification (TDD)

  • RED: 27 tests written first; 20 failed against throw-stubs.
  • GREEN: 27/27 pass after implementation.
  • verify:all 12/12 + typecheck clean; verify:generated clean (deterministic build); gateguard-hook test 19/19 (gateguard still first).
  • Coverage: unit tests for every pure-function branch (mode parse; command classify incl. echo git push false-positive guard; push-target parsing incl. HEAD:branch, --dry-run, -u; commit-size incl. --amend; the decide matrix). Integration tests spawn the built hook for block/warn/off/allow + a temp-repo commit-size gate.

Test plan

  • CI green on the PR
  • Manual: with CLAUDE_CI_HOOKPACK_GATE=block, git push origin main is denied; on a feature branch it's allowed.

Part of the 2026-06-17 report-coverage build train (PRs #246 /ship, #247 /production-readiness-review). The Stop-hook full-ladder extension is the remaining train item. No new dependencies.

…se gates

New Bash-matched hook-pack PreToolUse hook with two gates: a direct git push to a protected branch (main/master/release-*), and a git commit staging more than 15 files. Pure logic in lib/hook-pack-gate (unit-tested); the hook is an I/O shell. Mode CLAUDE_CI_HOOKPACK_GATE=warn (default) | block | off — warn never blocks, so zero disruption until opt-in. Registered second in PreToolUse with a Bash matcher so gateguard stays first and non-Bash tools keep the two-subprocess hot path. Closes the WILD pilot from the 2026-06-17 report-coverage map. Plan: docs/plans/2026-06-17-enforcing-hook-pack.md

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a new PreToolUse hook pack (hook-pack) that gates direct pushes to protected branches and oversized commits exceeding 15 staged files. The feedback highlights several critical edge cases in the git command parsing logic: parsePushTarget and classifyCommand fail to account for global git options, pushing explicitly to HEAD or @ bypasses branch protection fallback, and fully qualified ref names (e.g., refs/heads/main) are not recognized as protected. The reviewer provided robust code suggestions to address these parsing vulnerabilities and recommended expanding the test suite to cover these scenarios.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +47 to +57
function parsePushTarget(command: string): string | null {
const tokens = command.split(/\s+/).filter(Boolean);
const pushIdx = tokens.findIndex((t, i) => t === "push" && tokens[i - 1] === "git");
if (pushIdx === -1) return null;
const operands = tokens.slice(pushIdx + 1).filter((t) => !t.startsWith("-"));
// operands[0] is the remote; operands[1] is the refspec. Fewer than two means
// no explicit branch (e.g. `git push` or `git push origin`).
if (operands.length < 2) return null;
const refspec = operands[1]!;
return refspec.includes(":") ? (refspec.split(":").pop() ?? null) : refspec;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

There are two critical issues in parsePushTarget:

  1. It assumes push is always adjacent to git (tokens[pushIdx - 1] === "git"). If global options are used (e.g., git -c foo=bar push), pushIdx becomes -1, returning null and falling back to the current branch. If the user is on a feature branch, this completely bypasses the gate and allows pushing directly to main!
  2. If the explicit target is HEAD or @ (e.g., git push origin HEAD), it resolves the target branch to "HEAD", which is not marked as protected, bypassing the gate even if the current branch is main. Pushing to HEAD should fall back to the current branch.

We can fix both issues by finding push anywhere after git and returning null (to trigger the current branch fallback) if the target is HEAD or @.

function parsePushTarget(command: string): string | null {
  const tokens = command.split(/\s+/).filter(Boolean);
  const gitIdx = tokens.indexOf("git");
  if (gitIdx === -1) return null;
  const pushIdx = tokens.indexOf("push", gitIdx + 1);
  if (pushIdx === -1) return null;
  const operands = tokens.slice(pushIdx + 1).filter((t) => !t.startsWith("-"));
  // operands[0] is the remote; operands[1] is the refspec. Fewer than two means
  // no explicit branch (e.g. `git push` or `git push origin`).
  if (operands.length < 2) return null;
  const refspec = operands[1]!;
  const target = refspec.includes(":") ? (refspec.split(":").pop() ?? null) : refspec;
  return target === "HEAD" || target === "@" ? null : target;
}

Comment on lines +33 to +37
export function classifyCommand(command: string): "push" | "commit" | "other" {
if (/(?:^|[\n;|&])\s*git\s+push\b/.test(command)) return "push";
if (/(?:^|[\n;|&])\s*git\s+commit\b/.test(command)) return "commit";
return "other";
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The current command classification regexes do not account for global git options (such as -c core.quotePath=false or -C /path/to/repo) that can be specified between git and the push/commit subcommands. This allows commands using these options to completely bypass the hook. We should update the regexes to robustly match optional global options.

export function classifyCommand(command: string): "push" | "commit" | "other" {
  if (/(?:^|[\n;|&])\s*git\s+(?:-[^\s|&;\n]+(?:\s+[^\s|&;\n]+)?\s+)*push\b/.test(command)) return "push";
  if (/(?:^|[\n;|&])\s*git\s+(?:-[^\s|&;\n]+(?:\s+[^\s|&;\n]+)?\s+)*commit\b/.test(command)) return "commit";
  return "other";
}

Comment on lines +39 to +41
export function isProtectedBranch(branch: string): boolean {
return branch === "main" || branch === "master" || /^release\//.test(branch);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

If a user or agent pushes using a fully qualified ref name (e.g., refs/heads/main), isProtectedBranch will fail to match it because it strictly compares the string with "main". We should strip the refs/heads/ prefix before performing the protection checks.

export function isProtectedBranch(branch: string): boolean {
  const clean = branch.replace(/^refs\/heads\//, "");
  return clean === "main" || clean === "master" || /^release\//.test(clean);
}

Comment on lines +109 to +114
it("gates an explicit push to a protected branch regardless of current branch", () => {
assert.equal(evaluatePush("git push origin main", "feature/x").gated, true);
assert.equal(evaluatePush("git push -u origin main", "feature/x").gated, true);
assert.equal(evaluatePush("git push origin HEAD:main", "x").gated, true);
assert.equal(evaluatePush("git push origin release/1.0", "x").gated, true);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Let's add test cases to verify that pushes with global options and fully qualified ref names (e.g., refs/heads/main) are correctly gated.

  it("gates an explicit push to a protected branch regardless of current branch", () => {
    assert.equal(evaluatePush("git push origin main", "feature/x").gated, true);
    assert.equal(evaluatePush("git push -u origin main", "feature/x").gated, true);
    assert.equal(evaluatePush("git push origin HEAD:main", "x").gated, true);
    assert.equal(evaluatePush("git push origin release/1.0", "x").gated, true);
    assert.equal(evaluatePush("git -c core.quotePath=false push origin main", "feature/x").gated, true);
    assert.equal(evaluatePush("git push origin refs/heads/main", "feature/x").gated, true);
  });

Comment on lines +118 to +121
it("falls back to the current branch when no explicit target is given", () => {
assert.equal(evaluatePush("git push", "main").gated, true);
assert.equal(evaluatePush("git push", "feature/x").gated, false);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Let's add test cases to verify that pushing to HEAD or @ correctly falls back to the current branch and is gated when on a protected branch.

  it("falls back to the current branch when no explicit target is given", () => {
    assert.equal(evaluatePush("git push", "main").gated, true);
    assert.equal(evaluatePush("git push", "feature/x").gated, false);
    assert.equal(evaluatePush("git push origin HEAD", "main").gated, true);
    assert.equal(evaluatePush("git push origin @", "main").gated, true);
  });

naimkatiman and others added 2 commits June 18, 2026 00:15
Two CI-only defects from the prior commit: (1) the bundled hooks/hook-pack.mjs imports ../lib/hook-pack-gate.mjs, but the generator's copyFileTo list omitted it, so the installed plugin's hook would crash at runtime (repo-root tests passed because they exercise the flat copy) — add it to the bundle and pin its presence with a regression test. (2) The new .mjs files were committed from Windows as mode 644; the Linux build chmods hooks/lib/plugins .mjs to 755, so verify:generated's git diff failed — set the exec bit so the index matches. Fixes CI on PR #248.
@naimkatiman naimkatiman merged commit c4a09e9 into main Jun 19, 2026
4 checks passed
@naimkatiman naimkatiman deleted the feat/enforcing-hook-pack branch June 19, 2026 12:18
naimkatiman added a commit that referenced this pull request Jun 20, 2026
Bundles #244-#248: /plan-pack, the how-to-best-use guide, /ship, /production-readiness-review, and the enforcing hook-pack. Marketplace consumers get these on merge (the version bump refreshes their plugin cache). npm publish stays gated on the OIDC trusted-publisher config on npmjs.com (v3.13.0 E404'd for exactly that), so the v3.15.0 tag is held pending operator confirmation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant