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 |
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
<base>...<head> (or <base>..<head>) 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/<branch>) - 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/<default>...<branch>
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/<name>") — 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:branchinput is still normalized to a local ref until fork PRs ship. - A mergeability/status line plus a "Create pull request" button when
headhas commits not onbase. - 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):
- Reads
<old> <new> <ref>lines from stdin. - Runs the existing S15 policy gate (suspended/archived/deleted).
- 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
DecisionwithAllow,Reason, andPattern.
- Skip non-
- On any
Allow=false: writeprotection.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/<new>
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:
Enforceskipsrefs/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_commitsenforcement → 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.
View source
| 1 | # Branch protection |
| 2 | |
| 3 | S20 ships the branches index, tags index, compare view, and the |
| 4 | basic branch-protection rule engine enforced by the pre-receive |
| 5 | hook. |
| 6 | |
| 7 | ## Routes |
| 8 | |
| 9 | | Route | Handler | |
| 10 | | ------------------------------------------------------- | ----------------------------- | |
| 11 | | `GET /{owner}/{repo}/branches?filter=active|stale|` | `branchesList` | |
| 12 | | `GET /{owner}/{repo}/tags` | `tagsList` | |
| 13 | | `GET /{owner}/{repo}/compare` | `compareView` | |
| 14 | | `GET /{owner}/{repo}/compare/{base}...{head}` | `compareView` | |
| 15 | | `GET /{owner}/{repo}/settings/branches` | `settingsBranches` (auth-gated) | |
| 16 | | `POST /{owner}/{repo}/settings/branches` | upsert rule | |
| 17 | | `POST /{owner}/{repo}/settings/branches/{id}/delete` | delete rule | |
| 18 | | `POST /{owner}/{repo}/settings/default-branch` | swap default | |
| 19 | |
| 20 | The compare route uses chi's wildcard because chi can't represent the |
| 21 | literal `...` separator in a route param. The handler parses |
| 22 | `<base>...<head>` (or `<base>..<head>`) server-side. Cross-repo head |
| 23 | shape `fork:branch` is accepted but currently treated as local — the |
| 24 | full cross-repo flow lives with S22 (PRs) + S27 (forks). |
| 25 | |
| 26 | ## Branches list |
| 27 | |
| 28 | For each branch we show: |
| 29 | |
| 30 | - Name (linked to `/tree/<branch>`) |
| 31 | - Last-commit subject + age (from `repogit.HeadOf`) |
| 32 | - Ahead/behind vs the default branch (from `repogit.AheadBehind`, |
| 33 | i.e. `git rev-list --left-right --count default...branch`) |
| 34 | - Default badge + Protected badge (any rule whose pattern matches) |
| 35 | - "Compare" button → `/compare/<default>...<branch>` |
| 36 | |
| 37 | Sort: default first, then by recent activity. Filter: `active` (within |
| 38 | 90 days), `stale` (>90 days), or all. |
| 39 | |
| 40 | Counts are recomputed per-request today; the spec proposes caching |
| 41 | ahead/behind on push (S14's `push:process`). That's a perf-pass item |
| 42 | and lives with the rest of the cache work in **S36**. |
| 43 | |
| 44 | ## Tags list |
| 45 | |
| 46 | Lightweight: name, OID, subject, author age. Resolution uses |
| 47 | `repogit.HeadOf(gitDir, "tags/<name>")` — works for both lightweight |
| 48 | and annotated tags. The "Releases" placeholder note signals that |
| 49 | first-class releases ship post-MVP. |
| 50 | |
| 51 | ## Compare view |
| 52 | |
| 53 | Inputs: `base` and `head` (defaults to `repo.default_branch` when |
| 54 | empty). The bare `/compare` route renders GitHub's branch/tag picker |
| 55 | blank slate instead of redirecting to `default...default`. Renders: |
| 56 | |
| 57 | - Base/head dropdowns listing local branches and tags with live |
| 58 | filtering. Cross-repo `fork:branch` input is still normalized to a |
| 59 | local ref until fork PRs ship. |
| 60 | - A mergeability/status line plus a "Create pull request" button when |
| 61 | `head` has commits not on `base`. |
| 62 | - The commits-list (head-side only) via |
| 63 | `repogit.CommitsBetween(base, head, 250)`. |
| 64 | - The three-dot diff via S19's renderer fed from |
| 65 | `diffsource.FromMergeBase`. |
| 66 | |
| 67 | Empty state ("nothing to compare") fires when `Ahead == 0`. |
| 68 | |
| 69 | ## Branch-protection rules |
| 70 | |
| 71 | Stored in `branch_protection_rules`: |
| 72 | |
| 73 | | Column | Default | Notes | |
| 74 | | -------------------------- | ------- | -------------------------------------------- | |
| 75 | | `pattern` | (set) | `filepath.Match` glob | |
| 76 | | `prevent_force_push` | `true` | enforced | |
| 77 | | `prevent_deletion` | `true` | enforced | |
| 78 | | `require_pr_for_push` | `false` | enforced for direct git pushes and web edits | |
| 79 | | `allowed_pusher_user_ids` | `{}` | enforced; empty = no restriction | |
| 80 | | `require_signed_commits` | `false` | placeholder — post-MVP | |
| 81 | | `status_checks_required` | `{}` | placeholder — S24 wires real checks | |
| 82 | |
| 83 | ### Pattern matching |
| 84 | |
| 85 | `filepath.Match` semantics: |
| 86 | |
| 87 | - `*` matches any non-separator chars (does NOT cross `/`) |
| 88 | - `?` matches a single non-separator char |
| 89 | - `[abc]` matches one of a/b/c |
| 90 | |
| 91 | `release/*` matches `release/v1.0` but NOT `release/v1.0/sub`. The |
| 92 | matcher is unit-tested in `protection_test.go::TestMatchRule_…`. |
| 93 | |
| 94 | When multiple rules match a branch, the **longest pattern wins** |
| 95 | (alphabetical tiebreaker). Document this in the settings UI when the |
| 96 | rule list grows complex. |
| 97 | |
| 98 | ### Enforcement (pre-receive) |
| 99 | |
| 100 | The pre-receive hook (`cmd/shithubd/hook.go::hookPreReceiveCmd`): |
| 101 | |
| 102 | 1. Reads `<old> <new> <ref>` lines from stdin. |
| 103 | 2. Runs the existing S15 policy gate (suspended/archived/deleted). |
| 104 | 3. For each ref update, calls `protection.Enforce`: |
| 105 | - Skip non-`refs/heads/*` refs (tag protection out of scope). |
| 106 | - Resolve the longest-matching rule. |
| 107 | - Apply the gates in order: deletion → require-PR → force-push → |
| 108 | allowed-pushers. |
| 109 | - Return a `Decision` with `Allow`, `Reason`, and `Pattern`. |
| 110 | 4. On any `Allow=false`: write `protection.FriendlyMessage(d)` to |
| 111 | stderr; exit non-zero. |
| 112 | |
| 113 | **Force-push detection** uses `git merge-base --is-ancestor old new` |
| 114 | (via `repogit.IsAncestor`). When the old SHA is not an ancestor of |
| 115 | the new SHA, the update is non-fast-forward → reject. |
| 116 | |
| 117 | **Require pull request** rejects direct branch creates and updates when |
| 118 | `require_pr_for_push` is true on the matching rule. That covers both |
| 119 | normal git pushes and in-browser file-editor commits because both paths |
| 120 | call `protection.Enforce` before advancing `refs/heads/*`. Branch |
| 121 | deletions remain controlled by `prevent_deletion`. |
| 122 | |
| 123 | **Failure mode** (DB unavailable from hook): the rule lookup returns |
| 124 | an error; the hook prints "transient; retry" to stderr and exits |
| 125 | non-zero. **Fail closed**, per the S20 lean: better to reject a |
| 126 | legitimate push than to allow a force-push past a missing rule. |
| 127 | |
| 128 | ## Default-branch change |
| 129 | |
| 130 | `POST /settings/default-branch` validates the target exists, updates |
| 131 | `repos.default_branch`, then runs `git symbolic-ref HEAD refs/heads/<new>` |
| 132 | in the bare repo so new clones pick up the new default. The DB row is |
| 133 | the source of truth; symbolic-ref failure is logged but not rolled |
| 134 | back (operator can re-run). |
| 135 | |
| 136 | Existing clones don't follow the change — that's git's nature, not a |
| 137 | bug. The settings UI will warn on this when a fuller settings nav |
| 138 | lands in S32. |
| 139 | |
| 140 | ## Audit log |
| 141 | |
| 142 | Every protection-rule create/update/delete and default-branch change |
| 143 | emits an audit row through the existing recorder (`audit.TargetRepo`, |
| 144 | `ActionRepoCreated` placeholder slot — until S20-specific actions |
| 145 | land, the meta blob carries `action: "default_branch_changed"`/ |
| 146 | `"create"`/`"update"`/`"delete"` discriminators). |
| 147 | |
| 148 | ## Tests |
| 149 | |
| 150 | - `internal/repos/protection/protection_test.go` — pattern-match |
| 151 | precedence (longest-prefix, alphabetical tiebreak, no-cross-slash), |
| 152 | zero-SHA detection. |
| 153 | - `internal/repos/git/branchops_test.go` — covered by the existing |
| 154 | log/blame test fixtures (initial commit suffices for AheadBehind + |
| 155 | IsAncestor smoke). |
| 156 | |
| 157 | ## Pitfalls handled |
| 158 | |
| 159 | - **Tag pushes**: `Enforce` skips `refs/tags/*`; rules only apply to |
| 160 | branches. Tag protection is its own (post-MVP) concept. |
| 161 | - **Pattern globs vs regexes**: deliberate; matches GitHub UX. |
| 162 | - **Pre-receive cache scope**: one DB read per hook invocation. With |
| 163 | small rule sets this is cheap. A long-lived cache would complicate |
| 164 | invalidation; defer. |
| 165 | - **Default-branch change while open clones**: existing clones have a |
| 166 | stale HEAD; new clones pick up the new default. Standard git |
| 167 | behavior; documented in the settings UI. |
| 168 | - **DB unreachable from hook**: fail-closed reject with retry message. |
| 169 | |
| 170 | ## Deferred to later sprints |
| 171 | |
| 172 | - **`require_signed_commits` enforcement** → post-MVP signing surface. |
| 173 | - **`status_checks_required`** → S24 ships the check engine. |
| 174 | - **Required reviewers attached to rules** → S23. |
| 175 | - **Per-team allowed-pushers** → S31 (orgs/teams). |
| 176 | - **Tag protection** → post-MVP. |
| 177 | - **Ahead/behind caching on push** → S36. |
| 178 | - **Cross-repo compare full UI** → S22 + S27. |