Skip to content

main-guard

Provenance

  • Source: .spec/spexcode/spec-cli/source-of-truth/main-guard/spec.md
  • Source SHA-256: 4870201361debcd95b3ae394fd48252b667a2a4a0fdd23c005be475a69b6aaa0

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. A tree-unchanged commit also passes: when the tree being committed (git write-tree over the commit's index) equals HEAD's tree, the commit adds no content and cannot smuggle code onto the trunk — this is the narrow door through which spex ack ([[spec-lint]]) lands its empty Spec-OK: stamp above a trunk merge commit. That pass is an early exit for the whole hook (a no-content commit can't introduce drift either, so the lint shim is waived with it — which also lets an ack land while the real index holds unrelated staged work). The check is a one-line tree compare, deliberately NOT an "is this an amend?" heuristic (undecidable at pre-commit time). Escape hatch for seeding / eager topology: SPEXCODE_ALLOW_MAIN=1.

(The [[local-issues]] store also lands its data commits on the trunk. Its programmatic writer uses --no-verify to skip the pre-commit work; the prepared reference transaction still observes the new commit, but the .spec/.issues/ path is unanchored and therefore passes the candidate lint without a special path exception. An earlier .spec/.forum/** exception here was removed as redundant once the writer moved to --no-verify.)

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 stable mainBranch project fact (stamped at adoption; conventional default main), surfaced to the shell as spex internal 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), shell has no structured config reader and uses only the conventional main; it never guesses identity from the branch a developer happened to check out.

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 iterate it (not a hardcoded file list): scripts/install-hooks.sh (run via npm run hooks) for this monorepo dogfooding itself, and [[spex-init]] for a project adopting SpexCode. Iterating the one source is the point: a new hook template installs from both paths automatically, and a second hand-maintained list could never drift out of sync because there is none. 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). Re-installation refreshes only snapshots whose SpexCode ownership is proven by their managed header or a known exact legacy digest; custom hooks remain untouched, and protocol identity is checked by static bytes rather than executing a hook with probe arguments. 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 [[commit-surgery]] footprint station (unconditional materialize + staged-index repair, after this gate) and the [[spec-lint]] shim (it runs spex lint last), but those blocks are those nodes' contracts, not this one's — they share a file, not a concern.