Skip to content

feat(gate): a force delete of a branch that adds nothing to the default is not stopped (0.2.19) - #148

Merged
chrispezza merged 1 commit into
mainfrom
claude/branch-delete-122
Oct 12, 2026
Merged

chrispezza merged 1 commit into
mainfrom
claude/branch-delete-122

Conversation

@chrispezza

Copy link
Copy Markdown
Contributor

The last of the four noisy hard rules. After a squash merge git branch -d refuses the branch, -D matched git-branch-force-delete, and in seatbelt every cleanup was denied (#122).

git branch -D merged-by-squash and git branch -D a-week-of-unpushed-work are the same string, so unlike #87 and #98 this could not be fixed by editing the rule. ADR-014 is the decision; it amends ADR-004.

What changed

  • A hard rule may carry unless, naming a fact the engine does not establish. One value exists: branch_adds_nothing_to_default. The three force-delete entries in the bundled policy name it. A misspelt value is a load error.
  • The hook asks git, after the entry has matched and only then. For each branch it runs git merge-tree --write-tree <remote default> refs/heads/<branch> and compares the tree with the default branch's own. A merge rather than a tree diff, because the default branch has usually moved on since the squash.
  • When it holds, the entry is passed over and the call is judged like any other. It is not approved.
  • Only a call that is the force delete and nothing else. A chain can change directory and the hook looks in its own, so cd x && git branch -D y, git -C, a variable, a glob and -r keep the entry's verdict. The deny reason now ends with the form that can pass, since the acting model reads it.
  • The log line of a waived call carries hard_rule_waived. bouncer status reports a missing unless as a difference from the bundled entry.

The built hook, seatbelt, in a scratch repository (test/exemption.test.ts)

call emitted
git branch -D squashed (squash-merged; default branch has moved on) nothing, source: judge
git branch -D unmerged deny
git branch -D squashed unmerged deny
git checkout main && git branch -D squashed deny
git branch -D squashed, policy without the unless line deny

What it does not do

  • A squash-merged branch whose lines the default branch later rewrote is still stopped. git cannot tell it from unmerged work, and asking the forge means a network call with credentials inside a hook.
  • In a repository with no remote the exemption never holds. The base is a remote-tracking ref on purpose: against a local main, a commit that was only ever local would add nothing.

New surface, flagged

  • The hook now runs git in the project directory, on force-delete calls only: symbolic-ref, rev-parse, merge-tree. Branch names reach git as arguments, never through a shell, and are restricted to [A-Za-z0-9._/-] not starting with - or ..
  • merge-tree writes tree objects into the object database. No ref, index or working file changes; a test asserts that.
  • The hook trusts the payload's cwd to be where the command runs. ADR-014 names that assumption.

Cost

  • bench --against the base bundle with the base policy: paired median -0.4 ms judged, -0.7 ms hard-rule, 30 pairs. Calls that are not a force delete do not pay.
  • Where git is asked: 90 ms median against 54 ms, 21 runs, mock backend.
  • CACHE_VERSION 8 to 9, so each install recompiles its policy once.

For an existing install with a copied policy

Add unless: branch_adds_nothing_to_default to the three git-branch-force-delete entries. An older bouncer reading a policy with the key ignores it and stops every force delete.

Checks

1694 tests pass. Two holdout cases added, FROZEN updated in the same commit. HANDOFF.md updated.

Closes #122.

🤖 Generated with Claude Code

…lt is not stopped (0.2.19)

After a squash merge `git branch -d` refuses the branch and `-D` matched
git-branch-force-delete, so in seatbelt every cleanup was denied (#122). The
merged and the unmerged case are the same string, so this could not be fixed
by editing the rule.

A hard rule may now carry `unless`, naming a fact the engine does not
establish (ADR-014). The hook asks only after the entry has matched: for a
call that is the force delete and nothing else, whether merging each branch
into the remote's default branch would change nothing (`git merge-tree
--write-tree`). When it holds the entry is passed over and the call is judged
like any other. A chain, `git -C`, a repository with no remote and every way
of not knowing keep the entry's verdict, and the deny reason says which form
can pass.

CACHE_VERSION 9. A waived line carries hard_rule_waived. `bouncer status`
reports a missing `unless` as a difference. Two holdout cases, refrozen.

Bench against the base bundle with the base policy: paired median -0.4 ms
judged, -0.7 ms hard-rule, 30 pairs. Where git is asked: 90 ms against 54 ms.

Closes #122.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chrispezza
chrispezza merged commit 9055f23 into main Oct 12, 2026
2 checks passed
@chrispezza
chrispezza deleted the claude/branch-delete-122 branch October 12, 2026 23:04
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.

git-branch-force-delete blocks squash-merged branch cleanup; force-refspec rule fires on stash push and heredoc text

1 participant