HookStack
Back to catalogue
SecurityPreToolUse· BashPreToolUseBefore tool execution · can block⚡ blocking

Force-push guard (any branch)

Force-with-lease only — never clobber a teammate

Blocks bare git push --force / -f on any branch — not just main — and points the agent to --force-with-lease, which refuses to overwrite commits it has not seen. Complements the main/master push guards for shared feature branches.

What does the Force-push guard (any branch) hook do?

Force-push guard (any branch) is a Claude Code PreToolUse hook matching Bash. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. Force-with-lease only — never clobber a teammate.

As a PreToolUse hook it runs before the action completes, so it can block or adjust what Claude is about to do. Because it is a deterministic Node.js script, it executes on every matching event without relying on the model to remember — the guarantee that makes agentic workflows safe to automate.

Use cases

  • Shared branch safety
  • Team collaboration
  • History protection

Tags

#security#git#force-push#workflow#guardrail

settings.json fragment

{
  "hooks": {
    "PreToolUse": [
      {
        "hooks": [
          {
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/guard-force-push-any.mjs",
            "type": "command"
          }
        ],
        "matcher": "Bash"
      }
    ]
  }
}

Script · .claude/hooks/guard-force-push-any.mjs

#!/usr/bin/env node
// Bloque git push --force / -f sur toute branche, recommande --force-with-lease (PreToolUse Bash)
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';

function stripQuotedArgs(cmd) {
  return cmd.replace(/"(?:[^"\\]|\\.)*"/g, '""').replace(/'(?:[^'\\]|\\.)*'/g, "''");
}

export function run(input) {
  if (input.tool_name && input.tool_name !== 'Bash') return null;
  const command = stripQuotedArgs(input.tool_input?.command ?? '');
  if (!/\bgit\s+push\b/.test(command)) return null;

  // --force-with-lease est le force-push SÛR : on le laisse passer.
  const hasLease = /--force-with-lease\b/.test(command);
  // --force « nu » ou un flag court combiné contenant f (ex. -fu, -f).
  const hasBareForce = /--force\b(?!-with-lease)/.test(command) || /(?:^|\s)-[a-eg-zA-Z]*f[a-zA-Z]*\b/.test(command);

  if (hasBareForce && !hasLease) {
    return {
      decision: 'block',
      reason:
        'git push --force écrase aveuglément le travail distant. ' +
        'Utilisez --force-with-lease : il refuse de clobberer les commits poussés par quelqu\'un d\'autre. ' +
        'Si le force-push nu est réellement voulu, lancez-le manuellement.',
    };
  }
  return null;
}

/* v8 ignore next 5 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const input = JSON.parse(readFileSync(0, 'utf8'));
  const result = run(input);
  if (result) process.stdout.write(JSON.stringify(result));
}

Learn more

Related hooks