# Branch protection
S20 ships the branches index, tags index, compare view, and the
basic branch-protection rule engine enforced by the pre-receive
hook.
## Routes
| Route | Handler |
| ------------------------------------------------------- | ----------------------------- |
| `GET /{owner}/{repo}/branches?filter=active|stale|` | `branchesList` |
| `GET /{owner}/{repo}/tags` | `tagsList` |
| `GET /{owner}/{repo}/compare` | `compareView` |
| `GET /{owner}/{repo}/compare/{base}...{head}` | `compareView` |
| `GET /{owner}/{repo}/settings/branches` | `settingsBranches` (auth-gated) |
| `POST /{owner}/{repo}/settings/branches` | upsert rule |
| `POST /{owner}/{repo}/settings/branches/{id}/delete` | delete rule |
| `POST /{owner}/{repo}/settings/default-branch` | swap default |
The compare route uses chi's wildcard because chi can't represent the
literal `...` separator in a route param. The handler parses
`...
` (or `..`) server-side. Cross-repo head
shape `fork:branch` is accepted but currently treated as local — the
full cross-repo flow lives with S22 (PRs) + S27 (forks).
## Branches list
For each branch we show:
- Name (linked to `/tree/`)
- Last-commit subject + age (from `repogit.HeadOf`)
- Ahead/behind vs the default branch (from `repogit.AheadBehind`,
i.e. `git rev-list --left-right --count default...branch`)
- Default badge + Protected badge (any rule whose pattern matches)
- "Compare" button → `/compare/...`
Sort: default first, then by recent activity. Filter: `active` (within
90 days), `stale` (>90 days), or all.
Counts are recomputed per-request today; the spec proposes caching
ahead/behind on push (S14's `push:process`). That's a perf-pass item
and lives with the rest of the cache work in **S36**.
## Tags list
Lightweight: name, OID, subject, author age. Resolution uses
`repogit.HeadOf(gitDir, "tags/")` — works for both lightweight
and annotated tags. The "Releases" placeholder note signals that
first-class releases ship post-MVP.
## Compare view
Inputs: `base` and `head` (defaults to `repo.default_branch` when
empty). The bare `/compare` route renders GitHub's branch/tag picker
blank slate instead of redirecting to `default...default`. Renders:
- Base/head dropdowns listing local branches and tags with live
filtering. Cross-repo `fork:branch` input is still normalized to a
local ref until fork PRs ship.
- A mergeability/status line plus a "Create pull request" button when
`head` has commits not on `base`.
- The commits-list (head-side only) via
`repogit.CommitsBetween(base, head, 250)`.
- The three-dot diff via S19's renderer fed from
`diffsource.FromMergeBase`.
Empty state ("nothing to compare") fires when `Ahead == 0`.
## Branch-protection rules
Stored in `branch_protection_rules`:
| Column | Default | Notes |
| -------------------------- | ------- | -------------------------------------------- |
| `pattern` | (set) | `filepath.Match` glob |
| `prevent_force_push` | `true` | enforced |
| `prevent_deletion` | `true` | enforced |
| `require_pr_for_push` | `false` | enforced for direct git pushes and web edits |
| `allowed_pusher_user_ids` | `{}` | enforced; empty = no restriction |
| `require_signed_commits` | `false` | placeholder — post-MVP |
| `status_checks_required` | `{}` | placeholder — S24 wires real checks |
### Pattern matching
`filepath.Match` semantics:
- `*` matches any non-separator chars (does NOT cross `/`)
- `?` matches a single non-separator char
- `[abc]` matches one of a/b/c
`release/*` matches `release/v1.0` but NOT `release/v1.0/sub`. The
matcher is unit-tested in `protection_test.go::TestMatchRule_…`.
When multiple rules match a branch, the **longest pattern wins**
(alphabetical tiebreaker). Document this in the settings UI when the
rule list grows complex.
### Enforcement (pre-receive)
The pre-receive hook (`cmd/shithubd/hook.go::hookPreReceiveCmd`):
1. Reads ` [` lines from stdin.
2. Runs the existing S15 policy gate (suspended/archived/deleted).
3. For each ref update, calls `protection.Enforce`:
- Skip non-`refs/heads/*` refs (tag protection out of scope).
- Resolve the longest-matching rule.
- Apply the gates in order: deletion → require-PR → force-push →
allowed-pushers.
- Return a `Decision` with `Allow`, `Reason`, and `Pattern`.
4. On any `Allow=false`: write `protection.FriendlyMessage(d)` to
stderr; exit non-zero.
**Force-push detection** uses `git merge-base --is-ancestor old new`
(via `repogit.IsAncestor`). When the old SHA is not an ancestor of
the new SHA, the update is non-fast-forward → reject.
**Require pull request** rejects direct branch creates and updates when
`require_pr_for_push` is true on the matching rule. That covers both
normal git pushes and in-browser file-editor commits because both paths
call `protection.Enforce` before advancing `refs/heads/*`. Branch
deletions remain controlled by `prevent_deletion`.
**Failure mode** (DB unavailable from hook): the rule lookup returns
an error; the hook prints "transient; retry" to stderr and exits
non-zero. **Fail closed**, per the S20 lean: better to reject a
legitimate push than to allow a force-push past a missing rule.
## Default-branch change
`POST /settings/default-branch` validates the target exists, updates
`repos.default_branch`, then runs `git symbolic-ref HEAD refs/heads/`
in the bare repo so new clones pick up the new default. The DB row is
the source of truth; symbolic-ref failure is logged but not rolled
back (operator can re-run).
Existing clones don't follow the change — that's git's nature, not a
bug. The settings UI will warn on this when a fuller settings nav
lands in S32.
## Audit log
Every protection-rule create/update/delete and default-branch change
emits an audit row through the existing recorder (`audit.TargetRepo`,
`ActionRepoCreated` placeholder slot — until S20-specific actions
land, the meta blob carries `action: "default_branch_changed"`/
`"create"`/`"update"`/`"delete"` discriminators).
## Tests
- `internal/repos/protection/protection_test.go` — pattern-match
precedence (longest-prefix, alphabetical tiebreak, no-cross-slash),
zero-SHA detection.
- `internal/repos/git/branchops_test.go` — covered by the existing
log/blame test fixtures (initial commit suffices for AheadBehind +
IsAncestor smoke).
## Pitfalls handled
- **Tag pushes**: `Enforce` skips `refs/tags/*`; rules only apply to
branches. Tag protection is its own (post-MVP) concept.
- **Pattern globs vs regexes**: deliberate; matches GitHub UX.
- **Pre-receive cache scope**: one DB read per hook invocation. With
small rule sets this is cheap. A long-lived cache would complicate
invalidation; defer.
- **Default-branch change while open clones**: existing clones have a
stale HEAD; new clones pick up the new default. Standard git
behavior; documented in the settings UI.
- **DB unreachable from hook**: fail-closed reject with retry message.
## Deferred to later sprints
- **`require_signed_commits` enforcement** → post-MVP signing surface.
- **`status_checks_required`** → S24 ships the check engine.
- **Required reviewers attached to rules** → S23.
- **Per-team allowed-pushers** → S31 (orgs/teams).
- **Tag protection** → post-MVP.
- **Ahead/behind caching on push** → S36.
- **Cross-repo compare full UI** → S22 + S27.
]