Skip to main content
Claude Code tips · Lesson 13 of 13 · 12 min

Claude Code Git Workflow: Safe Commits, PRs and Push Rules

A Claude Code git workflow that keeps main clean: commit and PR attribution, feature branches, and a deny rule or hook that stops git push and force-push.

By Matthews WongReviewed on Checked against Claude Code v2.1.301

Key takeaway

Claude Code can commit and open pull requests for you, and by default it adds a Co-Authored-By trailer to commits and a generated-with line to PR descriptions. To keep main clean, work on feature branches. To make a rule hold, enforce it: a CLAUDE.md line is a request, while a deny rule or a PreToolUse hook is checked by Claude Code itself.

You will be able to

  • Say what Claude Code adds to commits and PRs, and change or hide it.
  • Keep main clean with feature branches and a human-reviewed merge.
  • Choose between a CLAUDE.md line, a deny rule and a hook to stop a push.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

Where a git push gets stopped

A deny rule blocks git push origin main by its text but misses git -C . push; a PreToolUse hook reads the whole command and exits 2; a push from a feature branch passes; a CLAUDE.md line only asks.

Reviewed on 17 October 2026 · Claude Code v2.1.301

Step 1 of 6. Claude runs git through the Bash tool. A hook, then permission rules, check each call first. CLAUDE.md sits apart: it is context Claude reads, not something Claude Code checks.

Keys: ← → step · Home / End · K play or pause

  1. Claude runs git through the Bash tool. A hook, then permission rules, check each call first. CLAUDE.md sits apart: it is context Claude reads, not something Claude Code checks.

  2. Claude runs git push origin main. The text matches the deny rule Bash(git push *), so Claude Code refuses the call before it runs, whatever CLAUDE.md says.

    "deny": ["Bash(git push *)"]Predict the next step Claude retries as git -C . push origin main. Does the same deny rule stop it? (Yes, it is still a git push · No, the text differs, so the rule misses it)
  3. A Bash rule matches the text Claude writes. git -C . push does not match Bash(git push *), so other rules and the mode decide. A deny rule is no security boundary.

    git -C . push origin main
  4. A PreToolUse hook gets the full command as JSON. Exit 2 stops the call before permission rules are checked, so the block holds even where an allow rule would permit the push.

    echo "Blocked: no pushes from main" >&2 exit 2Predict the next step Claude now pushes from a feature branch. What does the same hook do? (Blocks it, since every push is suspect · Exits 0 and lets it through)
  5. From a feature branch the hook exits 0 and no rule matches, so the push runs. Review happens in the PR; this repo deploys on main, so merging is the release.

  6. CLAUDE.md shapes what Claude tries; Claude Code does not check it. For a rule that must hold, use a deny rule for the usual form and a hook for the rest.

What does Claude Code add to commits and pull requests?

You can ask Claude to commit, or to open a pull request: the docs show asking directly, for example create a pr for my changes. Claude Code includes built-in commit and pull request workflow instructions in its context, and the includeGitInstructions setting, true by default, controls that. Set it to false only if you bring your own git workflow skills. Whatever Claude drafts, read the PR before you submit it; the docs advise reviewing it and asking Claude to point out risks.

Read more (2)

By default, commits get a git trailer, Co-Authored-By with the model's name, and PR descriptions get a plain generated-with line. The attribution setting changes both: attribution.commit and attribution.pr take your own text, an empty string hides that part, and attribution set to false hides everything. The false value needs Claude Code v2.1.281 or later; older versions reject it and skip the whole settings file that holds it. The older includeCoAuthoredBy key is deprecated, and setting commit or pr makes Claude Code ignore it.

Claude Code links a session to the PR when Claude creates it with gh pr create or glab mr create. Later, claude --from-pr followed by the PR number opens the session picker filtered to that PR. That is the way back when a reviewer asks why a change was made.

How do you keep main clean when Claude writes the commits?

Give every task its own branch and let the pull request be the gate. Claude commits on the feature branch, you review the diff, and only the merge touches main. In a repo like this one that is not a nicety: the deploy workflow starts on every push to main, so a push there is a release, not a save point.

Read more (2)

A git worktree helps when two tasks run at once. claude --worktree feature-auth starts a session in a separate checkout on its own branch, created from an existing commit, so the repository needs at least one commit first. Run the same command with another name in a second terminal and the sessions cannot overwrite each other's files. The worktrees lesson covers parallel work in full; here the point is that the branch is made for you.

Plain git still does the safety work underneath. If a commit was wrong, the rewind lesson covers undoing Claude's edits, and a revert on the branch costs nothing. A force-push to a shared branch is the one move you cannot take back by reading the log, which is why it deserves a rule rather than a promise.

Should you ask in CLAUDE.md, add a deny rule, or write a hook?

Use all three for what each is good at. The docs state it plainly: permission rules are enforced by Claude Code, not by the model, while instructions in your prompt or CLAUDE.md shape what Claude tries to do but do not change what Claude Code allows. So a line such as never force-push is a fine default behaviour, and a poor safeguard.

Read more (3)

A permission rule is the cheap, readable gate. Rules are checked deny first, then ask, then allow, and the first match wins, so no allow rule can carve an exception out of a deny. The docs' own example allows Bash(git commit *) and denies Bash(git push *). Put an ask rule on Bash(git push *) instead if you want a prompt rather than a refusal. Keep the star after the subcommand: Bash(git *) would allow every git command.

But a Bash rule matches text, and the docs list what it misses: git -C . push origin main, git -c push.default=current push origin main and git 'push' origin main all slip past Bash(git push *). Where the rule must hold, add a PreToolUse hook. It receives the command as JSON, exits 2 to block, and its block applies even when an allow rule would permit the call. It can also look at things a rule cannot, such as the branch you are on. Test it by piping sample JSON in before you trust it.

One mode needs a warning. In bypassPermissions mode Claude Code skips permission prompts, and the docs say to use it only in isolated environments such as containers or VMs. If you are not in one, keep the mode off and let the prompts do their job.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

CLAUDE.md:51
**Always run `npx tsc --noEmit && npm run build` before pushing.** The build runs `assertBlogParity()` from `lib/blog-meta.ts` which fails if a blog post is registered in `BLOG_POSTS_STATIC` but missing from `BLOG_META` (or vice versa).

This repo's one rule about pushing, and it is a request. Claude reads it and usually follows it, but nothing in Claude Code checks that the build ran. It works because the failure it guards against is also caught by the build itself, which makes it a good use of CLAUDE.md: guidance with a second line of defence behind it.

.github/workflows/deploy.yml:7-10
on:
  push:
    branches: [main]
  workflow_dispatch:

Why main is worth guarding here. The workflow starts on every push to main, builds the image and deploys it, so a push to main is a release. Keeping Claude's work on feature branches and merging through a pull request puts a human decision in front of that trigger.

.claude/settings.json:1-3
{
  "hooks": {
    "PostToolUse": [

What this repo does not have. The shared settings file holds one PostToolUse hook for blog figures and no permissions key, so no rule or hook here stops a git push or a force-push. The protection today is habit plus CI. The try-it below is what adding it would look like, written as a PreToolUse hook beside this one.

What breaks

Mistakes that cost real time

  • A deny rule that only matches one spelling

    Bash(git push *) stops git push origin main but not git -C . push origin main or git 'push' origin main. The docs call a deny rule a cover for the form Claude usually writes, not a security boundary. Add a hook that inspects the whole command, and use the sandbox when you need enforcement that ignores command text.

  • A hook that exits 1 instead of 2

    Only exit 2 blocks a PreToolUse call. Exit 1 is a non-blocking error, so the push goes ahead while the script looks like it refused. Test the hook by piping sample JSON in and printing the exit code, as the try-it does.

  • Trusting a CLAUDE.md line to hold

    Never push to main in CLAUDE.md is a request, and Claude Code does not check it. If a mistake would be costly, back the sentence with a rule or a hook. If it would be cheap to fix, the sentence alone is fine.

  • A hook that cannot tell a branch from a word

    The sample hook matches the text git, then push, so a commit message that merely contains the word push can trip it. It also blocks every push from main, including ones you wanted. Narrow the pattern before you widen the trust, and read the block reason Claude gets back.

  • An attribution value an old CLI rejects

    attribution set to false needs Claude Code v2.1.281 or later. An older version rejects it and skips the whole settings file that holds it, so every other rule in that file silently stops applying. Check the version with claude --version before a team adopts it.

Three ways to say do not push to main
A line in CLAUDE.mdClaude, by choiceAnything Claude overlooks
A deny ruleClaude Code, on the command textOther spellings of the same push
A PreToolUse hookYour script, on the full commandWhatever your script does not look for
Try it yourself

Do it on your own machine

You need Claude Code, jq and a scratch git repository. Add a deny rule for the usual form and a hook for the rest, then prove both work before Claude touches them.

  1. Allow commits, ask before a push, and deny nothing yet, in .claude/settings.json. Keep the star after the subcommand.

    {
      "permissions": {
        "allow": ["Bash(git commit *)"],
        "ask": ["Bash(git push *)"]
      }
    }
  2. Save the guard as .claude/hooks/git-push-guard.sh. It exits 2 when a push runs from main, or when the command carries a force flag.

    #!/bin/bash
    COMMAND=$(jq -r '.tool_input.command // empty')
    if echo "$COMMAND" | grep -Eq '(^|[;&|] *)git( .*)? push( |$)'; then
      BRANCH=$(git branch --show-current)
      if [[ "$BRANCH" == "main" || "$COMMAND" =~ (--force|[[:space:]]-f([[:space:]]|$)) ]]; then
        echo "Blocked: no pushes from main and no force pushes. Use a feature branch." >&2
        exit 2
      fi
    fi
    exit 0
  3. Make it executable and register it as a PreToolUse hook on Bash, next to any hooks you already have.

    chmod +x .claude/hooks/git-push-guard.sh
    
    "PreToolUse": [
      { "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/git-push-guard.sh" }] }
    ]
  4. Test it by hand, on main and on a feature branch. Main and the two odd spellings should print exit 2; the feature-branch push should print exit 0.

    echo '{"tool_input":{"command":"git -C . push"}}' | .claude/hooks/git-push-guard.sh
    echo "exit $?"
    
    git switch -c feature/try-hook
    echo '{"tool_input":{"command":"git push -u origin feature/try-hook"}}' | .claude/hooks/git-push-guard.sh
    echo "exit $?"
  5. Ask Claude to commit a small change and push it. Check that the commit carries the trailer, then hide it with attribution set to false once your CLI is v2.1.281 or later, and commit again.

    claude --version
    git log -1 --format=%B
    
    { "attribution": false }
Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.

  1. 1.Your settings deny Bash(git push *). Claude runs git -C . push origin main. What happens?
  2. 2.Which of these can a team rely on to stop pushes to main?
  3. 3.You want commits without the Co-Authored-By trailer or the PR attribution line. Which setting do you use?
Think it through

Answer in your head, then check

  1. 1.Why does this repo keep Claude's work off main?Show the answer

    Answer:Its deploy workflow runs on every push to main, so a push there is a release. A feature branch and a reviewed pull request put a human decision in front of it.

  2. 2.A hook and an allow rule disagree about one push. Which one wins?Show the answer

    Answer:The hook, if it exits 2. A blocking hook stops the call before permission rules are evaluated, so the block applies even when an allow rule would permit it. Deny rules, in turn, are checked before ask and allow.

  3. 3.How do you find the Claude session behind a pull request later?Show the answer

    Answer:Run claude --from-pr with the PR number, or paste the PR URL into the resume picker. Claude Code links the session when Claude creates the PR with gh pr create or glab mr create.

Go deeper

Related deep dives on this site