Files
stack/docs/plans/2026-10-20_force-push-guard.md

4.4 KiB

Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426)

Sage, 2026-10-20, for Mos's order mos-1078 (Vikunja P19 #426) and its go mos-1083 item 1. Owner Rocko, reviewer Dewey, gate owner Sage. Base: refactor at this brief's commit. Starts after row 191.

Why

Morgana force-pushed a seat branch on 2026-10-20 (morgana-0016). Mos took it to Jason. Force-push is a Q173 exclusion, and canon says never force-push, but nothing mechanical stops one. Mos: "Done means a force-push from a seat worktree is refused, with a test that shows it."

Earlier the same day, a busy check chained as | tail -1 && passed while the slot was BUSY. The script it called prints BUSY or CLEAR, but it exits 0 either way.

The guard has two layers:

  • (a) Gitea protection with force-push off on seat branch patterns and on refactor, next and main. ops-01 sets it up, through Mos (mos-1082). That part isn't this row.
  • (b) The client hook below. It can be bypassed with --no-verify, so (a) is the real guard and (b) is the early refusal.

What ships

  1. scripts/git-hooks/pre-push (new). For each ref line git passes on stdin:
    • It refuses when the remote sha isn't all zeros and isn't an ancestor of the local sha (git merge-base --is-ancestor). The refusal names the ref and both shas, and the hook exits 1.
    • A new branch passes. A delete (local sha all zeros) is refused.
    • Any failure of the check refuses too, for example an unknown remote object. The hook fetches nothing. If the remote tip isn't present locally, it refuses and says to fetch first.
  2. Install, following scripts/queue-commit.sh --install-hook. Either add a --install-push-hook there or ship a small scripts/install-git-hooks.sh, whichever fits the existing code better. Say which in BUILD.md.
    • The install copies the hook into the common .git/hooks, which every seat worktree shares.
    • It refuses when core.hooksPath is set (the husky trap, 2026-10-09). It refuses when an installed pre-push differs from the repository copy, unless --force-install is given.
    • It never edits the installed copy in place.
  3. scripts/slot-busy.sh (new), the shared busy check.
    • It prints one BUSY line per matching process, or CLEAR.
    • It exits 0 on CLEAR, 1 on BUSY and 2 on its own error.
    • It matches node --test, gate.sh, mutant, scripts/test-*.sh and docker compose command lines. It skips its own process and shell snapshots.
    • It reads /proc/<pid>/cmdline and the cwd link only, never file contents under another seat's directory.
    • Usage in docs/TOOLS.md: bash scripts/slot-busy.sh > LOG && gate, with set -o pipefail in any script that pipes it. Never | tail -1 &&.

Tests

Add them to scripts/test-land-work.sh or a new scripts/test-git-guard.sh (say which). Run against a scratch bare remote and a scratch clone with a worktree, never against origin:

  • A rewritten branch pushed with --force from a worktree is refused, and the remote ref is unchanged.
  • A +refspec push is refused.
  • A fast-forward push passes.
  • A new branch passes.
  • A delete is refused.
  • A remote tip that's missing locally is refused.
  • The install refuses with core.hooksPath set, and refuses a differing installed hook.
  • slot-busy.sh exits 1 with a fake node --test process running (a sleeping stand-in with that argv), and 0 without one.
  • slot-busy.sh | tail -1 && false doesn't apply here. The test runs bash scripts/slot-busy.sh >/dev/null && echo ran, and asserts it doesn't print ran while BUSY.

Mutants, each killed:

  • drop the ancestor check
  • treat an unknown remote object as passing
  • let a delete pass
  • install over a set core.hooksPath
  • slot-busy.sh exits 0 on BUSY

Boundaries

  • Install the hook in the canonical checkout only after landing, as its own step with Sage. Tests use scratch repositories.
  • No push to origin from a test. No change to Gitea config. That's ops-01's.
  • Don't touch ~/.mosaic, ~/.mosaic-dev or ~/.config/mosaic-dev.

Gate

Dewey approves the manifest. The new or changed scripts/test-*.sh file and every other scripts/test-*.sh are green on Sage's gate, and the mutants are killed. Done when the hook is installed in the canonical checkout and a scripted force-push from a seat worktree to a scratch remote is refused there. ops-01's server proof (a refused force-push, plus a fast-forward push and a new branch that go through) is recorded in Vikunja #426 by Mos.