tenseleyflow/shithub / b8b7c3a

Browse files

S20: branches/tags/compare + branch-protection settings handlers

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
b8b7c3aead53e8527e259b63a3db6d4754aa0ce7
Parents
0040b5b
Tree
e0c408e

5 changed files

StatusFile+-
M internal/web/handlers/handlers.go 11 0
A internal/web/handlers/repo/branches.go 262 0
A internal/web/handlers/repo/compare_helpers.go 32 0
A internal/web/handlers/repo/settings_branches.go 250 0
M internal/web/server.go 7 0
internal/web/handlers/handlers.gomodified
@@ -66,6 +66,11 @@ type Deps struct {
6666
 	// RepoHistoryMounter registers /commits/{ref}, /commit/{sha},
6767
 	// /blame/{ref}/{path...}, /commits/{ref}.atom (S18).
6868
 	RepoHistoryMounter func(chi.Router)
69
+	// RepoRefsMounter registers /branches, /tags, /compare/* (S20).
70
+	RepoRefsMounter func(chi.Router)
71
+	// RepoSettingsBranchesMounter registers /settings/branches +
72
+	// /settings/default-branch (S20). Auth-required.
73
+	RepoSettingsBranchesMounter func(chi.Router)
6974
 	// GitHTTPMounter, when non-nil, registers the smart-HTTP git routes
7075
 	// (`*.git/info/refs`, `git-upload-pack`, `git-receive-pack`). MUST
7176
 	// land in a route group that bypasses CSRF, response compression,
@@ -178,6 +183,12 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
178183
 		if deps.RepoHistoryMounter != nil {
179184
 			deps.RepoHistoryMounter(r)
180185
 		}
186
+		if deps.RepoRefsMounter != nil {
187
+			deps.RepoRefsMounter(r)
188
+		}
189
+		if deps.RepoSettingsBranchesMounter != nil {
190
+			deps.RepoSettingsBranchesMounter(r)
191
+		}
181192
 		if deps.RepoHomeMounter != nil {
182193
 			deps.RepoHomeMounter(r)
183194
 		}
internal/web/handlers/repo/branches.goadded
@@ -0,0 +1,262 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"net/http"
7
+	"path/filepath"
8
+	"sort"
9
+	"strings"
10
+	"time"
11
+
12
+	"github.com/go-chi/chi/v5"
13
+
14
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
15
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
16
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
17
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
18
+)
19
+
20
+// MountRefs registers the S20 branches, tags, and compare routes.
21
+// Settings routes are wired separately (require lifecycle middleware).
22
+func (h *Handlers) MountRefs(r chi.Router) {
23
+	r.Get("/{owner}/{repo}/branches", h.branchesList)
24
+	r.Get("/{owner}/{repo}/tags", h.tagsList)
25
+	// Compare uses `...` as the base/head separator (matches GitHub).
26
+	// chi can't represent the literal `...` in a route param so we use
27
+	// a wildcard and parse server-side.
28
+	r.Get("/{owner}/{repo}/compare/*", h.compareView)
29
+}
30
+
31
+// branchesList renders the branches index. Computes ahead/behind
32
+// against the default branch for each one. Filterable as
33
+// active/stale/all via the `?filter=` query.
34
+func (h *Handlers) branchesList(w http.ResponseWriter, r *http.Request) {
35
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
36
+	if !ok {
37
+		return
38
+	}
39
+	gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
40
+	if err != nil {
41
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
42
+		return
43
+	}
44
+	refs, err := repogit.ListRefs(r.Context(), gitDir)
45
+	if err != nil {
46
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
47
+		return
48
+	}
49
+	rules, _ := h.rq.ListBranchProtectionRules(r.Context(), h.d.Pool, row.ID)
50
+
51
+	defaultBranch := row.DefaultBranch
52
+	rows := make([]branchRow, 0, len(refs.Branches))
53
+	now := time.Now()
54
+	for _, b := range refs.Branches {
55
+		br := branchRow{Name: b.Name, OID: b.OID}
56
+		if len(b.OID) >= 7 {
57
+			br.ShortOID = b.OID[:7]
58
+		}
59
+		// HeadOf gives us subject + author time without an extra log call.
60
+		if hc, found, herr := repogit.HeadOf(r.Context(), gitDir, b.Name); herr == nil && found {
61
+			br.LastSubject = hc.Subject
62
+			br.LastWhen = hc.AuthorWhen
63
+			br.Stale = now.Sub(hc.AuthorWhen) > 90*24*time.Hour
64
+		}
65
+		if b.Name != defaultBranch {
66
+			ahead, behind, _ := repogit.AheadBehind(r.Context(), gitDir, defaultBranch, b.Name)
67
+			br.Ahead = ahead
68
+			br.Behind = behind
69
+		}
70
+		br.IsDefault = b.Name == defaultBranch
71
+		br.Protected = isBranchProtected(rules, b.Name)
72
+		rows = append(rows, br)
73
+	}
74
+	sort.SliceStable(rows, func(i, j int) bool {
75
+		if rows[i].IsDefault != rows[j].IsDefault {
76
+			return rows[i].IsDefault
77
+		}
78
+		return rows[i].LastWhen.After(rows[j].LastWhen)
79
+	})
80
+
81
+	filter := r.URL.Query().Get("filter")
82
+	if filter == "active" || filter == "stale" {
83
+		want := filter == "stale"
84
+		filtered := rows[:0]
85
+		for _, br := range rows {
86
+			if br.Stale == want {
87
+				filtered = append(filtered, br)
88
+			}
89
+		}
90
+		rows = filtered
91
+	}
92
+
93
+	h.d.Render.RenderPage(w, r, "repo/branches", map[string]any{
94
+		"Title":         "Branches · " + row.Name,
95
+		"CSRFToken":     middleware.CSRFTokenForRequest(r),
96
+		"Owner":         owner.Username,
97
+		"Repo":          row,
98
+		"DefaultBranch": defaultBranch,
99
+		"Rows":          rows,
100
+		"Filter":        filter,
101
+		"Branches":      refs.Branches,
102
+		"Tags":          refs.Tags,
103
+	})
104
+}
105
+
106
+// branchRow is the per-row shape rendered on the branches list.
107
+// Lifted to package level so the filter step can re-slice without an
108
+// awkward anonymous-type signature.
109
+type branchRow struct {
110
+	Name        string
111
+	OID         string
112
+	ShortOID    string
113
+	Ahead       int
114
+	Behind      int
115
+	LastSubject string
116
+	LastWhen    time.Time
117
+	Protected   bool
118
+	IsDefault   bool
119
+	Stale       bool
120
+}
121
+
122
+// isBranchProtected reports whether `branch` matches any rule. Used
123
+// to render the "Protected" badge on the branches list.
124
+func isBranchProtected(rules []reposdb.BranchProtectionRule, branch string) bool {
125
+	for _, r := range rules {
126
+		if ok, err := filepath.Match(r.Pattern, branch); err == nil && ok {
127
+			return true
128
+		}
129
+	}
130
+	return false
131
+}
132
+
133
+// tagsList renders the tags index. For each tag we render name, OID,
134
+// commit subject, and author age. The "Releases" slot per tag is a
135
+// placeholder until first-class releases ship.
136
+func (h *Handlers) tagsList(w http.ResponseWriter, r *http.Request) {
137
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
138
+	if !ok {
139
+		return
140
+	}
141
+	gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
142
+	if err != nil {
143
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
144
+		return
145
+	}
146
+	refs, _ := repogit.ListRefs(r.Context(), gitDir)
147
+
148
+	type tagRow struct {
149
+		Name        string
150
+		OID         string
151
+		ShortOID    string
152
+		Subject     string
153
+		AuthorName  string
154
+		AuthorWhen  time.Time
155
+	}
156
+	rows := make([]tagRow, 0, len(refs.Tags))
157
+	for _, t := range refs.Tags {
158
+		tr := tagRow{Name: t.Name, OID: t.OID}
159
+		if len(t.OID) >= 7 {
160
+			tr.ShortOID = t.OID[:7]
161
+		}
162
+		if hc, _, herr := repogit.HeadOf(r.Context(), gitDir, "tags/"+t.Name); herr == nil {
163
+			tr.Subject = hc.Subject
164
+			tr.AuthorName = hc.AuthorName
165
+			tr.AuthorWhen = hc.AuthorWhen
166
+		}
167
+		rows = append(rows, tr)
168
+	}
169
+	sort.SliceStable(rows, func(i, j int) bool {
170
+		return rows[i].AuthorWhen.After(rows[j].AuthorWhen)
171
+	})
172
+
173
+	h.d.Render.RenderPage(w, r, "repo/tags", map[string]any{
174
+		"Title":     "Tags · " + row.Name,
175
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
176
+		"Owner":     owner.Username,
177
+		"Repo":      row,
178
+		"Rows":      rows,
179
+		"Branches":  refs.Branches,
180
+		"Tags":      refs.Tags,
181
+	})
182
+}
183
+
184
+// compareView renders the compare-against-base preview: a commits
185
+// list (head-side only) plus the three-dot diff. Path shape is
186
+// `/{owner}/{repo}/compare/<base>...<head>`. Cross-repo (e.g.
187
+// `<base>...<fork:branch>`) is shape-supported but the full
188
+// fork-PR flow ships in S22+S27.
189
+func (h *Handlers) compareView(w http.ResponseWriter, r *http.Request) {
190
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
191
+	if !ok {
192
+		return
193
+	}
194
+	gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
195
+	if err != nil {
196
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
197
+		return
198
+	}
199
+	rest := strings.Trim(chi.URLParam(r, "*"), "/")
200
+	if rest == "" {
201
+		http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/compare/"+row.DefaultBranch+"..."+row.DefaultBranch, http.StatusSeeOther)
202
+		return
203
+	}
204
+	base, head, ok := strings.Cut(rest, "...")
205
+	if !ok {
206
+		// Two-dot shape — accept but treat as three-dot for the diff.
207
+		base, head, ok = strings.Cut(rest, "..")
208
+		if !ok {
209
+			head = rest
210
+			base = row.DefaultBranch
211
+		}
212
+	}
213
+	if base == "" {
214
+		base = row.DefaultBranch
215
+	}
216
+
217
+	// Strip cross-repo "fork:branch" prefix for the local path; full
218
+	// cross-repo lookup lands in S22+S27.
219
+	base = stripCrossRepoPrefix(base)
220
+	head = stripCrossRepoPrefix(head)
221
+
222
+	commits, cerr := repogit.CommitsBetween(r.Context(), gitDir, base, head, 250)
223
+	ahead, behind, abErr := repogit.AheadBehind(r.Context(), gitDir, base, head)
224
+
225
+	notFound := abErr != nil
226
+
227
+	// Build an inline diff (three-dot via FromMergeBase).
228
+	var diffHTML string
229
+	if !notFound {
230
+		patch, perr := compareSourceMergeBase(r, gitDir, base, head)
231
+		if perr == nil {
232
+			diffHTML = renderCompareDiff(patch)
233
+		}
234
+	}
235
+
236
+	refs, _ := repogit.ListRefs(r.Context(), gitDir)
237
+	h.d.Render.RenderPage(w, r, "repo/compare", map[string]any{
238
+		"Title":     "Compare · " + row.Name,
239
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
240
+		"Owner":     owner.Username,
241
+		"Repo":      row,
242
+		"Base":      base,
243
+		"Head":      head,
244
+		"Ahead":     ahead,
245
+		"Behind":    behind,
246
+		"Commits":   commits,
247
+		"DiffHTML":  diffHTML,
248
+		"NotFound":  notFound,
249
+		"CommitsErr": cerr != nil,
250
+		"Branches":  refs.Branches,
251
+		"Tags":      refs.Tags,
252
+	})
253
+}
254
+
255
+// stripCrossRepoPrefix turns "fork:branch" into "branch". Local-only
256
+// for now — see comment in compareView.
257
+func stripCrossRepoPrefix(s string) string {
258
+	if idx := strings.IndexByte(s, ':'); idx >= 0 {
259
+		return s[idx+1:]
260
+	}
261
+	return s
262
+}
internal/web/handlers/repo/compare_helpers.goadded
@@ -0,0 +1,32 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"net/http"
7
+
8
+	diffparse "github.com/tenseleyFlow/shithub/internal/repos/diff/parse"
9
+	diffrender "github.com/tenseleyFlow/shithub/internal/repos/diff/render"
10
+	diffsource "github.com/tenseleyFlow/shithub/internal/repos/diff/source"
11
+)
12
+
13
+// compareSourceMergeBase fetches the three-dot diff bytes for the
14
+// compare view. Mirrored from history.go's commit-page wiring; both
15
+// pages share the same renderer (S19).
16
+func compareSourceMergeBase(r *http.Request, gitDir, base, head string) ([]byte, error) {
17
+	hideWS := r.URL.Query().Get("w") == "1"
18
+	return diffsource.FromMergeBase(r.Context(), gitDir, base, head, diffsource.Options{
19
+		IgnoreWhitespace: hideWS, FindRenames: true,
20
+	})
21
+}
22
+
23
+// renderCompareDiff parses + renders unified-mode by default. The
24
+// commit page exposes split + WS toggles; the compare view picks them
25
+// up via the same query params.
26
+func renderCompareDiff(patch []byte) string {
27
+	parsed, err := diffparse.ParseBytes(patch)
28
+	if err != nil {
29
+		return ""
30
+	}
31
+	return diffrender.Diff(parsed, diffrender.Options{Mode: diffrender.ModeUnified})
32
+}
internal/web/handlers/repo/settings_branches.goadded
@@ -0,0 +1,250 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"errors"
7
+	"net/http"
8
+	"strconv"
9
+	"strings"
10
+
11
+	"github.com/go-chi/chi/v5"
12
+	"github.com/jackc/pgx/v5/pgtype"
13
+
14
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
15
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
16
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
17
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
18
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
19
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
20
+)
21
+
22
+// MountSettingsBranches registers the branch-protection + default-
23
+// branch settings routes. Caller wraps with RequireUser.
24
+func (h *Handlers) MountSettingsBranches(r chi.Router) {
25
+	r.Get("/{owner}/{repo}/settings/branches", h.settingsBranches)
26
+	r.Post("/{owner}/{repo}/settings/branches", h.settingsBranchesUpsert)
27
+	r.Post("/{owner}/{repo}/settings/branches/{id}/delete", h.settingsBranchesDelete)
28
+	r.Post("/{owner}/{repo}/settings/default-branch", h.settingsDefaultBranch)
29
+}
30
+
31
+// settingsBranches lists existing protection rules + a form to create
32
+// a new one. Gated by repo:settings:branches.
33
+func (h *Handlers) settingsBranches(w http.ResponseWriter, r *http.Request) {
34
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsBranches)
35
+	if !ok {
36
+		return
37
+	}
38
+	rules, err := h.rq.ListBranchProtectionRules(r.Context(), h.d.Pool, row.ID)
39
+	if err != nil {
40
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
41
+		return
42
+	}
43
+	gitDir, _ := h.d.RepoFS.RepoPath(owner.Username, row.Name)
44
+	refs, _ := repogit.ListRefs(r.Context(), gitDir)
45
+
46
+	h.d.Render.RenderPage(w, r, "repo/settings_branches", map[string]any{
47
+		"Title":     "Branch protection · " + row.Name,
48
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
49
+		"Owner":     owner.Username,
50
+		"Repo":      row,
51
+		"Rules":     rules,
52
+		"Branches":  refs.Branches,
53
+	})
54
+}
55
+
56
+// settingsBranchesUpsert creates a new rule (no `id` param) or updates
57
+// an existing one (`id` param set). Form fields: pattern,
58
+// prevent_force_push, prevent_deletion, require_pr_for_push,
59
+// allowed_pusher_usernames (comma-separated).
60
+func (h *Handlers) settingsBranchesUpsert(w http.ResponseWriter, r *http.Request) {
61
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsBranches)
62
+	if !ok {
63
+		return
64
+	}
65
+	if err := r.ParseForm(); err != nil {
66
+		http.Error(w, "form parse", http.StatusBadRequest)
67
+		return
68
+	}
69
+	pattern := strings.TrimSpace(r.PostFormValue("pattern"))
70
+	if pattern == "" || len(pattern) > 200 {
71
+		http.Error(w, "pattern length must be 1–200", http.StatusBadRequest)
72
+		return
73
+	}
74
+	preventForcePush := r.PostFormValue("prevent_force_push") == "on"
75
+	preventDeletion := r.PostFormValue("prevent_deletion") == "on"
76
+	requirePR := r.PostFormValue("require_pr_for_push") == "on"
77
+
78
+	allowed, err := resolveUsernameList(r, h, r.PostFormValue("allowed_pushers"))
79
+	if err != nil {
80
+		http.Error(w, err.Error(), http.StatusBadRequest)
81
+		return
82
+	}
83
+
84
+	viewer := middleware.CurrentUserFromContext(r.Context())
85
+
86
+	idStr := r.PostFormValue("id")
87
+	if idStr == "" {
88
+		// Create.
89
+		newID, err := h.rq.UpsertBranchProtectionRule(r.Context(), h.d.Pool, reposdb.UpsertBranchProtectionRuleParams{
90
+			RepoID:                row.ID,
91
+			Pattern:               pattern,
92
+			PreventForcePush:      preventForcePush,
93
+			PreventDeletion:       preventDeletion,
94
+			RequirePrForPush:      requirePR,
95
+			AllowedPusherUserIds:  allowed,
96
+			CreatedByUserID:       pgtype.Int8{Int64: viewer.ID, Valid: viewer.ID != 0},
97
+		})
98
+		if err != nil {
99
+			h.d.Logger.WarnContext(r.Context(), "branch-protection: insert", "error", err)
100
+			http.Error(w, "failed to create rule", http.StatusInternalServerError)
101
+			return
102
+		}
103
+		_ = h.d.Audit.Record(r.Context(), h.d.Pool, viewer.ID,
104
+			audit.ActionRepoCreated, audit.TargetRepo, row.ID,
105
+			map[string]any{"branch_protection_rule_id": newID, "pattern": pattern, "action": "create"})
106
+	} else {
107
+		// Update.
108
+		id, err := strconv.ParseInt(idStr, 10, 64)
109
+		if err != nil {
110
+			http.Error(w, "bad id", http.StatusBadRequest)
111
+			return
112
+		}
113
+		// Defense in depth: confirm the rule belongs to this repo.
114
+		existing, err := h.rq.GetBranchProtectionRule(r.Context(), h.d.Pool, id)
115
+		if err != nil || existing.RepoID != row.ID {
116
+			http.Error(w, "rule not found", http.StatusNotFound)
117
+			return
118
+		}
119
+		if err := h.rq.UpdateBranchProtectionRule(r.Context(), h.d.Pool, reposdb.UpdateBranchProtectionRuleParams{
120
+			ID:                   id,
121
+			Pattern:              pattern,
122
+			PreventForcePush:     preventForcePush,
123
+			PreventDeletion:      preventDeletion,
124
+			RequirePrForPush:     requirePR,
125
+			AllowedPusherUserIds: allowed,
126
+		}); err != nil {
127
+			http.Error(w, "failed to update rule", http.StatusInternalServerError)
128
+			return
129
+		}
130
+		_ = h.d.Audit.Record(r.Context(), h.d.Pool, viewer.ID,
131
+			audit.ActionRepoCreated, audit.TargetRepo, row.ID,
132
+			map[string]any{"branch_protection_rule_id": id, "pattern": pattern, "action": "update"})
133
+	}
134
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/branches?notice=saved", http.StatusSeeOther)
135
+}
136
+
137
+// settingsBranchesDelete removes a rule.
138
+func (h *Handlers) settingsBranchesDelete(w http.ResponseWriter, r *http.Request) {
139
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsBranches)
140
+	if !ok {
141
+		return
142
+	}
143
+	idStr := chi.URLParam(r, "id")
144
+	id, err := strconv.ParseInt(idStr, 10, 64)
145
+	if err != nil {
146
+		http.Error(w, "bad id", http.StatusBadRequest)
147
+		return
148
+	}
149
+	existing, err := h.rq.GetBranchProtectionRule(r.Context(), h.d.Pool, id)
150
+	if err != nil || existing.RepoID != row.ID {
151
+		http.Error(w, "rule not found", http.StatusNotFound)
152
+		return
153
+	}
154
+	if err := h.rq.DeleteBranchProtectionRule(r.Context(), h.d.Pool, id); err != nil {
155
+		http.Error(w, "failed to delete rule", http.StatusInternalServerError)
156
+		return
157
+	}
158
+	viewer := middleware.CurrentUserFromContext(r.Context())
159
+	_ = h.d.Audit.Record(r.Context(), h.d.Pool, viewer.ID,
160
+		audit.ActionRepoCreated, audit.TargetRepo, row.ID,
161
+		map[string]any{"branch_protection_rule_id": id, "pattern": existing.Pattern, "action": "delete"})
162
+
163
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/branches?notice=deleted", http.StatusSeeOther)
164
+}
165
+
166
+// settingsDefaultBranch swaps the repo's default branch. Validates
167
+// the target exists, updates the DB row, and updates HEAD on disk via
168
+// `git symbolic-ref`.
169
+func (h *Handlers) settingsDefaultBranch(w http.ResponseWriter, r *http.Request) {
170
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsBranches)
171
+	if !ok {
172
+		return
173
+	}
174
+	if err := r.ParseForm(); err != nil {
175
+		http.Error(w, "form parse", http.StatusBadRequest)
176
+		return
177
+	}
178
+	newDefault := strings.TrimSpace(r.PostFormValue("default_branch"))
179
+	if newDefault == "" {
180
+		http.Error(w, "default_branch required", http.StatusBadRequest)
181
+		return
182
+	}
183
+	gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
184
+	if err != nil {
185
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
186
+		return
187
+	}
188
+	refs, err := repogit.ListRefs(r.Context(), gitDir)
189
+	if err != nil {
190
+		http.Error(w, "ref lookup failed", http.StatusInternalServerError)
191
+		return
192
+	}
193
+	exists := false
194
+	for _, b := range refs.Branches {
195
+		if b.Name == newDefault {
196
+			exists = true
197
+			break
198
+		}
199
+	}
200
+	if !exists {
201
+		http.Error(w, "branch not found", http.StatusBadRequest)
202
+		return
203
+	}
204
+
205
+	if err := h.rq.UpdateRepoDefaultBranch(r.Context(), h.d.Pool, reposdb.UpdateRepoDefaultBranchParams{
206
+		ID: row.ID, DefaultBranch: newDefault,
207
+	}); err != nil {
208
+		http.Error(w, "DB update failed", http.StatusInternalServerError)
209
+		return
210
+	}
211
+	if err := repogit.SetSymbolicRef(r.Context(), gitDir, "HEAD", "refs/heads/"+newDefault); err != nil {
212
+		// DB updated but symbolic-ref failed — log and surface, but don't roll back DB
213
+		// since the user-visible truth is the DB row (their UI shows it; new clones
214
+		// follow it). Operator can re-run by setting it again.
215
+		h.d.Logger.WarnContext(r.Context(), "default-branch: symbolic-ref", "error", err)
216
+	}
217
+
218
+	viewer := middleware.CurrentUserFromContext(r.Context())
219
+	_ = h.d.Audit.Record(r.Context(), h.d.Pool, viewer.ID,
220
+		audit.ActionRepoCreated, audit.TargetRepo, row.ID,
221
+		map[string]any{"action": "default_branch_changed", "from": row.DefaultBranch, "to": newDefault})
222
+
223
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/branches?notice=default-changed", http.StatusSeeOther)
224
+}
225
+
226
+// resolveUsernameList parses a comma-separated username list and
227
+// resolves each to a user_id. Empty input returns an empty slice
228
+// (no allowed-pushers restriction). Unknown usernames produce an
229
+// error so the admin sees the typo before the rule lands.
230
+func resolveUsernameList(r *http.Request, h *Handlers, raw string) ([]int64, error) {
231
+	raw = strings.TrimSpace(raw)
232
+	if raw == "" {
233
+		return []int64{}, nil
234
+	}
235
+	uq := usersdb.New()
236
+	parts := strings.Split(raw, ",")
237
+	out := make([]int64, 0, len(parts))
238
+	for _, p := range parts {
239
+		name := strings.ToLower(strings.TrimSpace(p))
240
+		if name == "" {
241
+			continue
242
+		}
243
+		u, err := uq.GetUserByUsername(r.Context(), h.d.Pool, name)
244
+		if err != nil {
245
+			return nil, errors.New("unknown username: " + name)
246
+		}
247
+		out = append(out, u.ID)
248
+	}
249
+	return out, nil
250
+}
internal/web/server.gomodified
@@ -188,6 +188,13 @@ func Run(ctx context.Context, opts Options) error {
188188
 		deps.RepoHomeMounter = repoH.MountRepoHome
189189
 		deps.RepoCodeMounter = repoH.MountCode
190190
 		deps.RepoHistoryMounter = repoH.MountHistory
191
+		deps.RepoRefsMounter = repoH.MountRefs
192
+		deps.RepoSettingsBranchesMounter = func(r chi.Router) {
193
+			r.Group(func(r chi.Router) {
194
+				r.Use(middleware.RequireUser)
195
+				repoH.MountSettingsBranches(r)
196
+			})
197
+		}
191198
 		// Lifecycle danger-zone routes — also auth-required.
192199
 		deps.RepoLifecycleMounter = func(r chi.Router) {
193200
 			r.Group(func(r chi.Router) {