HookStack
Back to catalogue
SecurityPreToolUse· Bash

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
// @hookstack pre-bash-guard-force-push-any
// Bloque git push --force / -f sur toute branche, recommande --force-with-lease (PreToolUse Bash)
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node: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