# How to stop Claude Code from force-pushing

Three permission rules stop Claude Code from running git push --force, and a 12-line PreToolUse hook catches the spellings the rules miss. Both configs, and the commands that still get through.

Add three deny rules to `.claude/settings.json` and Claude Code refuses the common force-push spellings:

```json
{
  "permissions": {
    "deny": [
      "Bash(git push *--force*)",
      "Bash(git push -f*)",
      "Bash(git push * -f*)"
    ]
  }
}
```

By the matching rules in Claude Code's docs, that covers `git push --force origin main`, `git push -f` and `git push origin main -f`. We derived these from the docs and have not run them in a live session, so try one in a scratch repo before you rely on it. It does not cover `git push origin +main`, a combined flag such as `-uf`, a global option before `push` such as `git -C dir push --force`, a force-push inside a script, or one behind a git alias. The hook below catches the first three. Nothing on the machine catches the last two, which is why the final step is on the server.

## How Claude Code matches a push rule

Rules live in `permissions.deny`, `permissions.ask` and `permissions.allow`. Put them in `.claude/settings.json` to share them with the repo, or in `~/.claude/settings.json` to apply them to every project. Claude Code checks deny first, then ask, then allow, and the first match wins ([permissions docs](https://code.claude.com/docs/en/permissions)).

Three things about how a Bash rule matches, all from those docs:

- `*` stands in for any text, and the rule is matched against the whole command.
- Compound commands are split on `&&`, `||`, `;` and `|`, and each part is checked. `cd app && git push --force` is still caught.
- The first rule above also matches `--force-with-lease`, because the text contains `--force`. If you want lease pushes to go through, use the hook and drop that rule.

If you would rather be asked than refused, use `"ask": ["Bash(git push *)"]`. Every push then prompts, plain ones included.

In auto mode the classifier already blocks a force-push by default ([permission modes](https://code.claude.com/docs/en/permission-modes)). The rules above are for the other modes, and for not depending on a classifier.

## A hook that catches more

A `PreToolUse` hook gets the command as JSON on stdin before it runs. Exit code 2 blocks the call and sends stderr to the model. Save this as `~/.claude/hooks/no-force-push.mjs`:

```js
#!/usr/bin/env node
let raw = "";
process.stdin.on("data", (d) => (raw += d));
process.stdin.on("end", () => {
  let cmd = "";
  try { cmd = String(JSON.parse(raw).tool_input?.command ?? ""); } catch {}
  const force = /\bgit\b[^|;&\n]*\bpush\b[^|;&\n]*\s(--force(?!-with-lease|-if-includes)\b|-[a-zA-Z]*f[a-zA-Z]*(\s|$)|\+\S+)/;
  if (force.test(cmd)) {
    console.error("Blocked: this is a force-push. Stop and ask the human to run it.");
    process.exit(2);
  }
});
```

Register it in `~/.claude/settings.json`:

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "node \"$HOME/.claude/hooks/no-force-push.mjs\"", "onFailure": "block" }
        ]
      }
    ]
  }
}
```

`onFailure: "block"` needs Claude Code 2.1.295 or later. Without it a hook that crashes or times out lets the call run ([hooks docs](https://code.claude.com/docs/en/hooks)).

## What gets past the force-push hook

We ran the hook script against twelve command strings. This is its output:

```
BLOCKED  git push --force origin main
BLOCKED  git push -f
BLOCKED  git push origin +main
BLOCKED  cd app && git push -uf origin fix
BLOCKED  git -C . push --force
ran      git push --force-with-lease
ran      git push origin main
ran      bash scripts/release.sh
ran      git pf
ran      G=git; $G push --force
ran      git push --mirror
ran      git push origin :main
```

`--force-with-lease` and a plain push are meant to run. The ones that got through are `bash scripts/release.sh`, the `pf` alias and `G=git; $G push --force`. The hook reads command text, and none of them says "push --force" in it. The deny rules miss the same three, and Claude Code's docs say as much: a Bash rule "isn't a security boundary around the program."

`--mirror` and `:main` rewrite or delete remote refs without a force flag. Add them to the pattern if you want them blocked.

The fix for all of these is on the server. Turn on branch protection for `main` and block force pushes there. The agent cannot edit that from your terminal. It only covers the branches you protect.

## What ACP adds

ACP is the same kind of hook, maintained for you, and it keeps one log and one set of rules for Claude Code, Codex and Cursor. Its force-push check matches `--force`, `-f` and a `+refspec`, leaves `--force-with-lease` alone, and also reads the command inside `sh -c '...'`. `git push --mirror` and a delete of a protected branch, such as `git push origin :main`, are held as well. It reads command text like the hook above, so a script file or a git alias gets past it too.

Two things differ from the hand-written hook. A force-push is held and you get a prompt in the session, so you can say yes when a force-push is what you want. And every tool call is written to a log, so you can see what ran while you were away. In an unattended run the hold is a block that you approve on the console or from the email.



## Frequently asked questions



## Related

- [How to stop Claude Code, Codex and Cursor from force-pushing](/blog/stop-your-agent-from-rewriting-git-history): the other git commands that lose work, and rules for them
- [Codex --yolo: how to block git push --force](/blog/codex-yolo-block-force-push)
- [Claude Code hooks reference](/blog/claude-code-hooks-reference)
- [Why a Claude Code deny rule did not fire](/blog/claude-code-deny-rules-not-working)
