Skip to content

main-guard

Enforce the invariant — main only RECEIVES merges; all authoring happens in worktrees.

raw source

The model says the trunk is the source of truth that every session branches from. The directory layout doesn't protect it — cd to the root and you can still author on it, breaking the invariant. Protection is a hook, not a folder structure. Make "no direct commits on the trunk" real instead of aspirational — the cheap mechanism the portable-layout convention was relying on.

expanded spec

A pre-commit hook rejects a direct commit while HEAD is the trunk. Merges must pass (the --no-ff gate onto the trunk sets MERGE_HEAD), so the worktree → merge flow is unaffected, and node-branch commits pass because they aren't on the trunk. Escape hatch for seeding / eager topology: SPEXCODE_ALLOW_MAIN=1.

The guard's real question is "am I committing directly onto the trunk?", not "is this branch literally named main?". It resolves the trunk through the SAME single source of truth the rest of SpexCode uses — portable-layout's mainBranch() (config override → the main checkout's current branch → main), surfaced to the shell as spex trunk — so a repo whose trunk is master or any non-main base is protected, not silently exempt. A hardcoded main compare would disagree with the layout side and leave a master-default repo wide open. When the CLI isn't resolvable (advisory mode, no @spexcode/spec-cli installed) the hook falls back to a pure-git auto-detect of the main checkout's current branch, then main — still naming the real trunk in the common case, never enumerating a second hardcoded branch.

Hooks live in the common git dir, so one install covers every worktree at once. There is one canonical hook source — the spec-cli/templates/hooks/ shipped with the package — and both install paths copy from it: scripts/install-hooks.sh (run via npm run hooks) for this monorepo dogfooding itself, and spex-init for a project adopting SpexCode. A single source is the point: a second copy would let the two paths drift, installing different gates. Because .git/hooks/ is never committed, installing is a per-clone onboarding step, re-run whenever the source changes (the installed copy is a snapshot, not a symlink). The hook is advisory and bypassable; the non-bypassable backstop is ci-gate.

This node owns only the main-authoring guard. The same pre-commit file also carries the spec-lint shim (it runs spex lint after this gate), but that block is that node's contract, not this one's — they share a file, not a concern.