guard: prove every removal with the forge's answer, not local state #666
Labels
No labels
breaking
bug
documentation
enhancement
epic
good first issue
help wanted
refactoring
resolution/duplicate
resolution/invalid
resolution/wontfix
security
severity/critical
severity/high
severity/low
severity/medium
status/abandoned
status/blocked
status/needs-decision
status/needs-info
status/needs-verification
testing
upstream
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Blocks
Depends on
#670 epic: one guard enforces the recorded limits on every surface
sixfold-space/madtea
#653 guard: add the guard package with its three effects
sixfold-space/madtea
Reference
sixfold-space/madtea#666
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
ADR 0029 now says that a removal is allowed only when the forge's API confirms that every commit it removes or discards is reachable from the forge's default branch. Local state is not proof. The actor that asks for the removal can move a local ref, rewrite
origin/<default>withgit update-ref, or fetch and then force-push over a pushed branch.Today each removal checks only local state:
prunerunsgit branch --mergedagainst the local default branch.branch -drelies on git's local merged check.push --forcerelies on--force-with-lease, which does not stop a deliberate overwrite.worktree removerelies on git's clean check, and the branch's commits may exist only on this machine.Scope:
prune, branch delete, force push, and worktree remove call it on every surface.Acceptance:
origin/<default>was rewritten to contain it. Each succeeds when the forge's default branch holds the commits.Depends on #653. Decision record: ADR 0029.
Call sites for the
branch -dbullet, confirmed by reading the code:madt_branch_delete:internal/mcp/tools_localgit.go:217callsDeleteLocalBranch. The tool description there says "purely LOCAL (no remote contact)".branch -d:internal/cmd/git/branch.go:369and:407call the sameDeleteLocalBranch.DeleteLocalBranch:internal/service/git/branch_delete.go:161runsgit branch -d <name> --. No forge client, no forge read.The check is against HEAD, not the local default branch.
branchDeleteMergedIntoRef(branch_delete.go:201-214) runs one--is-ancestor <branch> HEADprobe, then a merged-into-upstream fallback. So the delete is verified against whatever is checked out, or an upstream, which need not be any default branch. "Local state" here is looser than the local default branch.Reassurance text: the "(merged - its commits are on '')" line (
internal/cmd/git/branch.go:195,internal/format/git/branch_delete.go:28) names that local ref. When the proof moves to the forge, this text should name the forge's default branch, not the current branch.Severity for branch delete, lower than prune (#564):
git branch -daccepts only a branch merged into HEAD or its upstream, so its commits stay reachable from HEAD after the delete. There is no silent orphaning as in #564. The fix is about the trust model, a local ref is not the forge's answer, so the regression test should assert a refusal when the commits live only on local state, not recovery from content loss.Reconciling with #635: push --force is omitted from both surfaces, so it leaves this proof's caller set.
This proof fits an operation that destroys a pointer to commits the forge still holds under its default branch. Prune, branch delete, and worktree remove are that shape. The ref goes, the commits stay on the default branch, and the forge's answer confirms it.
Force push is a different shape. It removes commits from the remote branch itself. Force is needed only when the new tip does not contain the old remote tip, so every force push drops at least one commit. The forge holds no retained ref that proves those dropped commits survive, and a rebase is indistinguishable server-side from a deliberate drop. There is no proof to compute, so #635 moves it to omit.
So the proof function has three callers, not four: prune, branch delete, and worktree remove. Drop force push from the scope and the acceptance here.
madt_worktrees action=removeremoves a clean worktree when its branch holds a commit that is not on the forge's default branch. ADR 0029 requires a refusal in this case.The remove path has no forge read. The MCP handler
worktreesRemove(internal/mcp/tools_worktrees.go:388-396) callsRemoveWorktree(internal/service/git/worktree.go:580-618).RemoveWorktreerunsgit worktree remove -- <path>, and git checks only that the tree is clean.Steps to reproduce:
origin/main.madt_worktrees action=remove path=<worktree>.Expected result: a refusal that tells me to finish the branch first.
Actual result:
Removed worktree at <path>. The commit stays on the local branch only.The code for this landed on main, and the unit tests pass. The live forge test that proves this acceptance, TestForgeProvenRemoval, did not finish in the throwaway-forge VM: the run hit the 10-minute timeout while downloading the Forgejo binary on a cold cache. Tracked in #796. Keeping this open until the suite completes green.
The v0.20.0 breaking set is complete and green on main. The code landed across the v0.20.0 branches, the full local gate passes, and the forge suite is green on both forges at both pinned versions on current main (
31693766). Closing as done.