markdown · 7994 bytes Raw Blame History

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: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 <old> <new> <ref> 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/<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: 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.
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.