Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitd6daf49unknown_key

feat(BLOCK-J): J24 branch rename with PR/protection cascades

feat(BLOCK-J): J24 branch rename with PR/protection cascades

Owner-only GET/POST /:owner/:repo/settings/branches. Pure
validateBranchName + planRename + shouldRewriteProtectionPattern in
src/lib/branch-rename.ts enforce git-check-ref-format rules
(rejects .., @{, bare @, leading -, leading/trailing . or /, //,
.lock suffix, whitespace, ~^:?*[\, control chars). Git plumbing
renameBranch creates refs/heads/<to> then deletes refs/heads/<from>
with rollback on partial failure; setHeadBranch moves HEAD when
renaming the default branch. Route cascades to default_branch, PR
base/head branches, merge queue entries, and exact-match branch
protection rules; every op audited as branch.rename. 39 tests
(113 expects) pass; full suite 1166/1166.
Claude committed on April 15, 2026Parent: 831a117
7 files changed+9621d6daf493dfdaa6e0e057f199495f73ed6fd427ac
7 changed files+962−1
ModifiedBUILD_BIBLE.md+5−1View fileUnifiedSplit
145145| CODEOWNERS validator | ✅ | J21 — `GET /:owner/:repo/codeowners` lints the CODEOWNERS file (standard locations: root / `.github/` / `docs/`) on the default branch against known users + teams and surface-level syntax. Pure `lexCodeowners` (line-aware, CRLF tolerant, inline-comment strip, detects pattern-with-no-owners), `classifyOwnerToken` (user / team / email / invalid), `isPlausiblePattern` (bracket balance, whitespace), `validateCodeowners` (async, takes `OwnerResolver`) in `src/lib/codeowners-lint.ts`. Findings are anchored to 1-indexed line numbers with an `error\|warning\|info` severity + stable `code` taxonomy (`empty_file\|no_owners\|empty_pattern\|bad_pattern_syntax\|bad_owner_format\|unknown_user\|unknown_team\|duplicate_pattern\|duplicate_owner\|missing_catchall`). UI shows four summary cards (rules / errors / warnings / infos), a findings list, and the file body with line numbers. Resolver memoises per-request. Non-destructive — report only. |
146146| Code review suggestion blocks | ✅ | J22 — `` ```suggestion `` fenced blocks inside PR comments are detected (pure `extractSuggestions` in `src/lib/code-suggestions.ts`) and rendered on the PR page with a "Commit suggestion" button when the viewer is the PR author, comment author, or repo owner. `POST /:owner/:repo/pulls/:number/comments/:commentId/apply-suggestion` reads the file on the PR's head branch, runs pure `applySuggestionToContent` (preserves CRLF/LF line endings + trailing-newline presence, validates line range + detects no-op), and commits via git plumbing (`hash-object` → `ls-tree -r` rewrite → `mktree` → `commit-tree` → `update-ref`) with a `Co-authored-by:` trailer crediting the applier. `requireAuth`; private repos 404 for non-owners; closed PRs redirect silently. |
147147| Issue/PR search query DSL | ✅ | J23 — GitHub-style search DSL on the issue list. `GET /:owner/:repo/issues?q=…` accepts `is:open\|closed`, `author:<user>`, `label:<name>` (repeatable, AND), `-label:<name>` (exclude), `no:label`, `milestone:"<title>"`, `sort:created-desc\|created-asc\|updated-desc\|updated-asc\|comments-desc`, and any other text → case-insensitive substring match against title+body. Pure `tokenise` / `parseIssueQuery` / `matchIssue` / `sortIssues` / `applyQuery` / `formatIssueQuery` in `src/lib/issue-query.ts` — never throws; unknown qualifiers fall back to text; `sort:` values not in the allow-list fall back to default. The issues route joins labels, applies the DSL in-memory, and renders an inline search bar + collapsible syntax cheat-sheet + live match count. Tab pill counts remain over all issues so filters don't collapse them. |
148| Branch rename with cascades | ✅ | J24 — `GET/POST /:owner/:repo/settings/branches` (owner-only). Pure `validateBranchName` / `planRename` / `shouldRewriteProtectionPattern` in `src/lib/branch-rename.ts` enforce `git check-ref-format(1)` (rejects `..`, `@{`, bare `@`, leading `-`, leading/trailing `.` or `/`, `//`, `.lock` suffix, whitespace, `~^:?*[\`, control chars). Git plumbing `renameBranch` (creates `refs/heads/<to>` → deletes `refs/heads/<from>` with rollback on partial failure) + `setHeadBranch` live in `src/git/repository.ts`. After the ref move the route cascades to `repositories.default_branch`, `pull_requests.base_branch` / `head_branch`, `merge_queue_entries.base_branch`, and exact-match `branch_protection.pattern` rows (globs untouched), with every rename audited as `branch.rename`. History is preserved — only the ref name changes. |
148149| GitHub Actions equivalent (workflow runner) | ✅ | `src/lib/workflow-parser.ts`, `src/lib/workflow-runner.ts`, `src/routes/workflows.tsx`; `.gluecron/workflows/*.yml` auto-discovered on push; Bun subprocess executor, per-step timeouts, size-capped logs |
149150| Dependabot equivalent (AI dep bumper) | ✅ | D2 — `dep_update_runs` table, npm registry fetch, plan + apply bumps, creates `gluecron/dep-update-*` branch + PR row via git plumbing. `src/lib/dep-updater.ts`, `src/routes/dep-updater.tsx`, settings UI at `/:owner/:repo/settings/dep-updater`. |
150151| Code scanning UI | ✅ | I5 — `src/routes/code-scanning.tsx`, `GET /:owner/:repo/security`. Aggregates last-100 `gate_runs` matching `%scan%`/`%security%`, rolls up latest status per gate, shows failed/repaired/total cards + scanner status list + recent runs. |
529530- `src/routes/code-suggestions.tsx` (Block J22) — serves `POST /:owner/:repo/pulls/:number/comments/:commentId/apply-suggestion`. `requireAuth`. Authorises applier as owner / PR author / comment author. Validates PR is open, comment has `file_path` + `line_number`, suggestion index is in range. Reads the file on the PR's head branch, applies the pure transformer, then commits via git plumbing (`hash-object``ls-tree -r` rewrite → `mktree``commit-tree -p` head → `update-ref refs/heads/<head>`). Commit message: `Apply suggestion from #N\n\nCo-authored-by: …`. Redirects to the PR with a comment anchor on success. `src/routes/pulls.tsx` PR detail page imports `extractSuggestions` and renders a "Commit suggestion" button (per-block when multiple) below the comment body for eligible viewers.
530531- `src/lib/issue-query.ts` (Block J23) — pure GitHub-style search DSL. Exports `IssueState`/`IssueSort` types, `DEFAULT_SORT='created-desc'`, `tokenise(raw)` (whitespace-splitting with `"double"`-quoted-span support; trailing unterminated quote flushes on EOF), `parseIssueQuery(raw)` (handles `is:open\|closed`, `author:`, `label:` (repeatable, AND), `-label:` (exclude), `no:label`, `milestone:`, `sort:` allow-list; unknown qualifiers fall back to free text; never throws; tolerates null/undefined/non-string; key matching is case-insensitive). `matchIssue(issue, q)` applies each filter dimension (case-insensitive label/author/milestone; case-insensitive substring match of `q.text` against `${title}\n${body}`). `sortIssues(list, sort)` returns a new array (never mutates); unparseable dates treated as epoch 0; missing `commentCount` treated as 0. `applyQuery(raw, issues)` one-shot parse+filter+sort. `formatIssueQuery(q)` reverse-formatter with whitespace-triggered quoting. `__internal` re-exports for tests.
531532- `src/routes/issues.tsx` (Block J23 integration) — issue list route accepts `?q=<DSL>`. If `q` sets `is:`, the DSL overrides the tab-based state filter. Labels joined via single `IN (…)` Drizzle query and threaded into both the DSL matcher and inline label pills. Search bar + collapsible syntax cheat-sheet + live match-count rendered above the tab pills. Tab open/closed counts remain over all issues (not filtered) so DSL use doesn't collapse them.
533- `src/lib/branch-rename.ts` (Block J24) — pure branch-rename helpers. `MAX_BRANCH_NAME_LENGTH=250`. `validateBranchName(name)` mirrors `git-check-ref-format(1)` rejecting `not_string\|empty\|too_long\|slash_boundary\|leading_dash\|dot_boundary\|consecutive_slashes\|double_dot\|at_brace\|only_at\|lock_suffix\|forbidden_char\|control_char\|empty_component\|dot_component\|lock_component`. `branchValidationMessage(reason)` returns human-readable copy for each code. `planRename({from,to,existingBranches,defaultBranch})` returns `{ok, from, to, updatesDefault}` or `{ok:false, reason, detail?}` with `same_name\|invalid_from\|invalid_to\|from_missing\|to_exists`; case-sensitive ref comparison. `shouldRewriteProtectionPattern(pattern, from)` returns true only for exact non-glob matches. `__internal` re-exports for tests.
534- `src/routes/branch-rename.tsx` (Block J24) — serves `GET/POST /:owner/:repo/settings/branches[/rename]`. Owner-only (via internal `resolveOwned` that wraps DB lookups in try/catch → null). POST path: `planRename` → on rejection redirects with the error copy from `branchValidationMessage`; on approval calls `renameBranch` (which creates `refs/heads/<to>` then deletes `refs/heads/<from>` with auto-rollback if the delete fails) + `setHeadBranch` when renaming the default. Cascades to `repositories.default_branch`, `pull_requests.base_branch`/`head_branch`, `merge_queue_entries.base_branch`, and `branch_protection.pattern` rows where `shouldRewriteProtectionPattern` returns true (duplicate-pattern conflicts swallowed silently). Every op audited as `branch.rename` with `{from, to, updatesDefault}` metadata. Audit failures never block the rename.
535- `src/git/repository.ts` (Block J24 additions) — adds `renameBranch(owner, name, from, to)` and `setHeadBranch(owner, name, branch)`. `renameBranch` resolves `refs/heads/<from>` to a SHA, writes `refs/heads/<to>` at that SHA, then deletes the old ref; on partial failure it best-effort-removes the newly-created ref so the repo never ends up with both refs pointing at the same SHA. Both helpers flush `gitCache` under `${owner}/${name}:` so branch lists + default-branch lookups see the new state.
532536
533537### 4.7 Views (locked contracts)
534538- `src/views/layout.tsx``Layout` accepts `title`, `user`, `notificationCount`
563567```bash
564568bun install
565569bun dev # hot reload
566bun test # 1127 tests currently pass
570bun test # 1166 tests currently pass
567571bun run db:migrate
568572```
569573
Addedsrc/__tests__/branch-rename.test.ts+341−0View fileUnifiedSplit
1/**
2 * Block J24 — Branch rename. Pure validation + plan tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import {
7 MAX_BRANCH_NAME_LENGTH,
8 validateBranchName,
9 branchValidationMessage,
10 planRename,
11 shouldRewriteProtectionPattern,
12 __internal,
13} from "../lib/branch-rename";
14
15describe("branch-rename — validateBranchName (valid cases)", () => {
16 it("accepts simple names", () => {
17 expect(validateBranchName("main").ok).toBe(true);
18 expect(validateBranchName("develop").ok).toBe(true);
19 expect(validateBranchName("release").ok).toBe(true);
20 });
21
22 it("accepts slash-separated components", () => {
23 expect(validateBranchName("feat/login").ok).toBe(true);
24 expect(validateBranchName("release/v1.0").ok).toBe(true);
25 expect(validateBranchName("release/v1.0.0-rc.1").ok).toBe(true);
26 });
27
28 it("accepts numeric + underscore + hyphen", () => {
29 expect(validateBranchName("123abc").ok).toBe(true);
30 expect(validateBranchName("snake_case_branch").ok).toBe(true);
31 expect(validateBranchName("kebab-case-branch").ok).toBe(true);
32 });
33
34 it("accepts names up to MAX_BRANCH_NAME_LENGTH", () => {
35 const maxName = "a".repeat(MAX_BRANCH_NAME_LENGTH);
36 expect(validateBranchName(maxName).ok).toBe(true);
37 });
38
39 it("accepts single dots inside components", () => {
40 expect(validateBranchName("v1.0.0").ok).toBe(true);
41 });
42});
43
44describe("branch-rename — validateBranchName (invalid cases)", () => {
45 it("rejects non-string", () => {
46 expect(validateBranchName(null).ok).toBe(false);
47 expect(validateBranchName(undefined).ok).toBe(false);
48 expect(validateBranchName(123).ok).toBe(false);
49 });
50
51 it("rejects empty string", () => {
52 const r = validateBranchName("");
53 expect(r.ok).toBe(false);
54 if (!r.ok) expect(r.reason).toBe("empty");
55 });
56
57 it("rejects too-long names", () => {
58 const r = validateBranchName("a".repeat(MAX_BRANCH_NAME_LENGTH + 1));
59 expect(r.ok).toBe(false);
60 if (!r.ok) expect(r.reason).toBe("too_long");
61 });
62
63 it("rejects a bare '@'", () => {
64 const r = validateBranchName("@");
65 expect(r.ok).toBe(false);
66 if (!r.ok) expect(r.reason).toBe("only_at");
67 });
68
69 it("rejects '@{'", () => {
70 const r = validateBranchName("foo@{1}");
71 expect(r.ok).toBe(false);
72 if (!r.ok) expect(r.reason).toBe("at_brace");
73 });
74
75 it("rejects leading or trailing '/'", () => {
76 expect(validateBranchName("/foo").ok).toBe(false);
77 expect(validateBranchName("foo/").ok).toBe(false);
78 });
79
80 it("rejects '//' anywhere", () => {
81 expect(validateBranchName("foo//bar").ok).toBe(false);
82 });
83
84 it("rejects leading '-'", () => {
85 const r = validateBranchName("-no");
86 expect(r.ok).toBe(false);
87 if (!r.ok) expect(r.reason).toBe("leading_dash");
88 });
89
90 it("rejects leading or trailing '.'", () => {
91 expect(validateBranchName(".foo").ok).toBe(false);
92 expect(validateBranchName("foo.").ok).toBe(false);
93 });
94
95 it("rejects '..'", () => {
96 const r = validateBranchName("foo..bar");
97 expect(r.ok).toBe(false);
98 if (!r.ok) expect(r.reason).toBe("double_dot");
99 });
100
101 it("rejects '.lock' suffix", () => {
102 expect(validateBranchName("foo.lock").ok).toBe(false);
103 });
104
105 it("rejects '.lock' on a component", () => {
106 const r = validateBranchName("release/v1.lock");
107 expect(r.ok).toBe(false);
108 if (!r.ok) {
109 // either `lock_suffix` (whole name) or `lock_component` — both valid
110 expect(["lock_suffix", "lock_component"]).toContain(r.reason);
111 }
112 });
113
114 it("rejects whitespace", () => {
115 expect(validateBranchName("has space").ok).toBe(false);
116 expect(validateBranchName("has\ttab").ok).toBe(false);
117 });
118
119 it("rejects forbidden characters", () => {
120 for (const ch of ["~", "^", ":", "?", "*", "[", "\\"]) {
121 expect(validateBranchName(`foo${ch}bar`).ok).toBe(false);
122 }
123 });
124
125 it("rejects control characters", () => {
126 expect(validateBranchName("foo\u0001bar").ok).toBe(false);
127 expect(validateBranchName("foo\u001fbar").ok).toBe(false);
128 expect(validateBranchName("foo\u007fbar").ok).toBe(false);
129 });
130
131 it("rejects component starting or ending with '.'", () => {
132 expect(validateBranchName("release/.hidden").ok).toBe(false);
133 expect(validateBranchName("release/trailing.").ok).toBe(false);
134 });
135});
136
137describe("branch-rename — branchValidationMessage", () => {
138 it("returns non-empty strings for every reason code", () => {
139 const reasons = [
140 "not_string",
141 "empty",
142 "too_long",
143 "slash_boundary",
144 "leading_dash",
145 "dot_boundary",
146 "consecutive_slashes",
147 "double_dot",
148 "at_brace",
149 "only_at",
150 "lock_suffix",
151 "forbidden_char",
152 "control_char",
153 "empty_component",
154 "dot_component",
155 "lock_component",
156 ] as const;
157 for (const r of reasons) {
158 const msg = branchValidationMessage(r);
159 expect(typeof msg).toBe("string");
160 expect(msg.length).toBeGreaterThan(0);
161 }
162 });
163});
164
165describe("branch-rename — planRename", () => {
166 const existing = ["main", "develop", "feat/login"];
167
168 it("approves a valid rename", () => {
169 const r = planRename({
170 from: "feat/login",
171 to: "feat/auth",
172 existingBranches: existing,
173 defaultBranch: "main",
174 });
175 expect(r.ok).toBe(true);
176 if (r.ok) {
177 expect(r.from).toBe("feat/login");
178 expect(r.to).toBe("feat/auth");
179 expect(r.updatesDefault).toBe(false);
180 }
181 });
182
183 it("flags rename of the default branch", () => {
184 const r = planRename({
185 from: "main",
186 to: "trunk",
187 existingBranches: existing,
188 defaultBranch: "main",
189 });
190 expect(r.ok).toBe(true);
191 if (r.ok) expect(r.updatesDefault).toBe(true);
192 });
193
194 it("rejects same-name rename", () => {
195 const r = planRename({
196 from: "main",
197 to: "main",
198 existingBranches: existing,
199 defaultBranch: "main",
200 });
201 expect(r.ok).toBe(false);
202 if (!r.ok) expect(r.reason).toBe("same_name");
203 });
204
205 it("rejects missing source branch", () => {
206 const r = planRename({
207 from: "ghost",
208 to: "new",
209 existingBranches: existing,
210 defaultBranch: "main",
211 });
212 expect(r.ok).toBe(false);
213 if (!r.ok) expect(r.reason).toBe("from_missing");
214 });
215
216 it("rejects when destination already exists", () => {
217 const r = planRename({
218 from: "feat/login",
219 to: "develop",
220 existingBranches: existing,
221 defaultBranch: "main",
222 });
223 expect(r.ok).toBe(false);
224 if (!r.ok) expect(r.reason).toBe("to_exists");
225 });
226
227 it("rejects invalid source name", () => {
228 const r = planRename({
229 from: "bad..name",
230 to: "ok",
231 existingBranches: existing,
232 defaultBranch: "main",
233 });
234 expect(r.ok).toBe(false);
235 if (!r.ok) {
236 expect(r.reason).toBe("invalid_from");
237 expect(r.detail).toBe("double_dot");
238 }
239 });
240
241 it("rejects invalid destination name", () => {
242 const r = planRename({
243 from: "main",
244 to: "has space",
245 existingBranches: existing,
246 defaultBranch: "main",
247 });
248 expect(r.ok).toBe(false);
249 if (!r.ok) {
250 expect(r.reason).toBe("invalid_to");
251 expect(r.detail).toBe("forbidden_char");
252 }
253 });
254
255 it("is case-sensitive ('Main' and 'main' are different)", () => {
256 const r = planRename({
257 from: "main",
258 to: "Main",
259 existingBranches: ["main"],
260 defaultBranch: "main",
261 });
262 expect(r.ok).toBe(true);
263 });
264
265 it("handles empty existingBranches", () => {
266 const r = planRename({
267 from: "main",
268 to: "trunk",
269 existingBranches: [],
270 defaultBranch: null,
271 });
272 expect(r.ok).toBe(false);
273 if (!r.ok) expect(r.reason).toBe("from_missing");
274 });
275
276 it("does not mark updatesDefault when defaultBranch is null", () => {
277 const r = planRename({
278 from: "main",
279 to: "trunk",
280 existingBranches: ["main"],
281 defaultBranch: null,
282 });
283 expect(r.ok).toBe(true);
284 if (r.ok) expect(r.updatesDefault).toBe(false);
285 });
286});
287
288describe("branch-rename — shouldRewriteProtectionPattern", () => {
289 it("rewrites an exact match", () => {
290 expect(shouldRewriteProtectionPattern("main", "main")).toBe(true);
291 });
292
293 it("does not rewrite a different exact", () => {
294 expect(shouldRewriteProtectionPattern("main", "develop")).toBe(false);
295 });
296
297 it("does not rewrite globs even if they match", () => {
298 expect(shouldRewriteProtectionPattern("release/*", "release/*")).toBe(
299 false
300 );
301 });
302
303 it("does not rewrite ? or [ globs", () => {
304 expect(shouldRewriteProtectionPattern("feat/?", "feat/?")).toBe(false);
305 expect(shouldRewriteProtectionPattern("feat/[abc]", "feat/[abc]")).toBe(
306 false
307 );
308 });
309});
310
311describe("branch-rename — routes", () => {
312 it("GET /settings/branches redirects unauthenticated users", async () => {
313 const { default: app } = await import("../app");
314 const res = await app.request("/alice/repo/settings/branches");
315 expect([302, 401, 403, 404]).toContain(res.status);
316 });
317
318 it("POST rename redirects unauthenticated users", async () => {
319 const { default: app } = await import("../app");
320 const form = new FormData();
321 form.append("from", "main");
322 form.append("to", "trunk");
323 const res = await app.request(
324 "/alice/repo/settings/branches/rename",
325 { method: "POST", body: form }
326 );
327 expect([302, 401, 403, 404]).toContain(res.status);
328 });
329});
330
331describe("branch-rename — __internal parity", () => {
332 it("re-exports helpers", () => {
333 expect(__internal.validateBranchName).toBe(validateBranchName);
334 expect(__internal.planRename).toBe(planRename);
335 expect(__internal.branchValidationMessage).toBe(branchValidationMessage);
336 expect(__internal.shouldRewriteProtectionPattern).toBe(
337 shouldRewriteProtectionPattern
338 );
339 expect(__internal.MAX_BRANCH_NAME_LENGTH).toBe(MAX_BRANCH_NAME_LENGTH);
340 });
341});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
8383import staleIssuesRoutes from "./routes/stale-issues";
8484import codeownersLintRoutes from "./routes/codeowners-lint";
8585import codeSuggestionsRoutes from "./routes/code-suggestions";
86import branchRenameRoutes from "./routes/branch-rename";
8687import webRoutes from "./routes/web";
8788
8889const app = new Hono();
295296// Code review suggestion blocks — apply ```suggestion fences to head branch (Block J22)
296297app.route("/", codeSuggestionsRoutes);
297298
299// Branch rename — owner-only /:owner/:repo/settings/branches (Block J24)
300app.route("/", branchRenameRoutes);
301
298302// Insights + milestones
299303app.route("/", insightsRoutes);
300304
Modifiedsrc/git/repository.ts+64−0View fileUnifiedSplit
189189 return exitCode === 0;
190190}
191191
192/**
193 * Rename a branch (refs/heads/<from> → refs/heads/<to>). Preserves the
194 * commit SHA. Writes the new ref first, then deletes the old one (so a
195 * mid-op crash never leaves the branch unreachable). Returns true on
196 * success. Caller is responsible for any semantics around the default
197 * branch (HEAD symbolic-ref update) + cache invalidation — we flush
198 * this repo's branch cache here as a convenience. Block J24.
199 */
200export async function renameBranch(
201 owner: string,
202 name: string,
203 from: string,
204 to: string
205): Promise<boolean> {
206 const path = repoPath(owner, name);
207 const { stdout: shaOut, exitCode: revCode } = await exec(
208 ["git", "rev-parse", "--verify", `refs/heads/${from}`],
209 { cwd: path }
210 );
211 if (revCode !== 0) return false;
212 const sha = shaOut.trim();
213 if (!sha) return false;
214
215 const { exitCode: createCode } = await exec(
216 ["git", "update-ref", `refs/heads/${to}`, sha],
217 { cwd: path }
218 );
219 if (createCode !== 0) return false;
220
221 const { exitCode: deleteCode } = await exec(
222 ["git", "update-ref", "-d", `refs/heads/${from}`, sha],
223 { cwd: path }
224 );
225 if (deleteCode !== 0) {
226 // Best-effort rollback — remove the newly-created ref so we don't
227 // leak a duplicate.
228 await exec(["git", "update-ref", "-d", `refs/heads/${to}`], {
229 cwd: path,
230 });
231 return false;
232 }
233 gitCache.invalidatePrefix(`${owner}/${name}:`);
234 return true;
235}
236
237/**
238 * Update `HEAD` to point at a different branch. Used when the default
239 * branch is renamed (Block J24).
240 */
241export async function setHeadBranch(
242 owner: string,
243 name: string,
244 branch: string
245): Promise<boolean> {
246 const path = repoPath(owner, name);
247 const { exitCode } = await exec(
248 ["git", "symbolic-ref", "HEAD", `refs/heads/${branch}`],
249 { cwd: path }
250 );
251 if (exitCode !== 0) return false;
252 gitCache.invalidatePrefix(`${owner}/${name}:`);
253 return true;
254}
255
192256/**
193257 * List commits between two refs (excluding `from`, including `to`).
194258 */
Addedsrc/lib/branch-rename.ts+197−0View fileUnifiedSplit
1/**
2 * Block J24 — Branch rename.
3 *
4 * Pure helpers for validating + planning a branch rename. The actual git
5 * work + the DB cascades (pull_requests, branch_protection, merge_queue,
6 * default branch) live in the route — this file has no IO and is safe to
7 * unit test.
8 *
9 * Validation follows `git-check-ref-format(1)`. Rules (strict):
10 *
11 * - must be non-empty, at most `MAX_BRANCH_NAME_LENGTH` chars
12 * - can't start or end with `/`
13 * - can't contain `//`
14 * - can't contain `..`, `@{`, a bare `@`, or any of ``` ~^:?*[\ ```
15 * - can't contain ASCII control chars (0x00–0x1F, DEL)
16 * - can't start with `-` or `.`
17 * - can't end with `.` or `.lock`
18 * - each slash-separated component must be non-empty, can't begin or end
19 * with `.`, and can't end with `.lock`
20 *
21 * These mirror what `git check-ref-format --branch` rejects. We do NOT
22 * allow a single `@` (git's own rule).
23 */
24
25export const MAX_BRANCH_NAME_LENGTH = 250;
26
27export type ValidateBranchResult =
28 | { ok: true }
29 | { ok: false; reason: BranchValidationReason };
30
31export type BranchValidationReason =
32 | "not_string"
33 | "empty"
34 | "too_long"
35 | "slash_boundary"
36 | "leading_dash"
37 | "dot_boundary"
38 | "consecutive_slashes"
39 | "double_dot"
40 | "at_brace"
41 | "only_at"
42 | "lock_suffix"
43 | "forbidden_char"
44 | "control_char"
45 | "empty_component"
46 | "dot_component"
47 | "lock_component";
48
49const FORBIDDEN_RE = /[\s~^:?*\[\\\x7f]/;
50const CONTROL_RE = /[\x00-\x1f]/;
51
52export function validateBranchName(name: unknown): ValidateBranchResult {
53 if (typeof name !== "string") return { ok: false, reason: "not_string" };
54 const n = name;
55 if (n.length === 0) return { ok: false, reason: "empty" };
56 if (n.length > MAX_BRANCH_NAME_LENGTH)
57 return { ok: false, reason: "too_long" };
58 if (n === "@") return { ok: false, reason: "only_at" };
59 if (n.startsWith("/") || n.endsWith("/"))
60 return { ok: false, reason: "slash_boundary" };
61 if (n.startsWith("-")) return { ok: false, reason: "leading_dash" };
62 if (n.startsWith(".") || n.endsWith("."))
63 return { ok: false, reason: "dot_boundary" };
64 if (n.includes("//")) return { ok: false, reason: "consecutive_slashes" };
65 if (n.includes("..")) return { ok: false, reason: "double_dot" };
66 if (n.includes("@{")) return { ok: false, reason: "at_brace" };
67 if (n.endsWith(".lock")) return { ok: false, reason: "lock_suffix" };
68 if (CONTROL_RE.test(n)) return { ok: false, reason: "control_char" };
69 if (FORBIDDEN_RE.test(n)) return { ok: false, reason: "forbidden_char" };
70
71 const parts = n.split("/");
72 for (const p of parts) {
73 if (p.length === 0) return { ok: false, reason: "empty_component" };
74 if (p.startsWith(".") || p.endsWith("."))
75 return { ok: false, reason: "dot_component" };
76 if (p.endsWith(".lock")) return { ok: false, reason: "lock_component" };
77 }
78 return { ok: true };
79}
80
81export function branchValidationMessage(
82 r: BranchValidationReason
83): string {
84 switch (r) {
85 case "not_string":
86 case "empty":
87 return "Branch name is required.";
88 case "too_long":
89 return `Branch name must be ${MAX_BRANCH_NAME_LENGTH} characters or fewer.`;
90 case "slash_boundary":
91 return "Branch name cannot start or end with '/'.";
92 case "leading_dash":
93 return "Branch name cannot start with '-'.";
94 case "dot_boundary":
95 return "Branch name cannot start or end with '.'.";
96 case "consecutive_slashes":
97 return "Branch name cannot contain '//'.";
98 case "double_dot":
99 return "Branch name cannot contain '..'.";
100 case "at_brace":
101 return "Branch name cannot contain '@{'.";
102 case "only_at":
103 return "Branch name cannot be '@'.";
104 case "lock_suffix":
105 case "lock_component":
106 return "Branch name components cannot end with '.lock'.";
107 case "forbidden_char":
108 return "Branch name cannot contain whitespace or any of ~ ^ : ? * [ \\.";
109 case "control_char":
110 return "Branch name cannot contain control characters.";
111 case "empty_component":
112 return "Branch name cannot contain an empty path component.";
113 case "dot_component":
114 return "Branch name components cannot start or end with '.'.";
115 }
116}
117
118export interface PlanRenameInput {
119 from: string;
120 to: string;
121 existingBranches: readonly string[];
122 defaultBranch: string | null;
123}
124
125export type PlanRenameResult =
126 | {
127 ok: true;
128 from: string;
129 to: string;
130 /** True when renaming the repo's default branch. */
131 updatesDefault: boolean;
132 }
133 | {
134 ok: false;
135 reason:
136 | "same_name"
137 | "invalid_from"
138 | "invalid_to"
139 | "from_missing"
140 | "to_exists";
141 detail?: BranchValidationReason;
142 };
143
144/**
145 * Compute whether `from → to` is a legal rename given the current state.
146 * Never performs IO. Case-sensitivity matches git (refs are
147 * case-sensitive, so "Main" and "main" are different branches).
148 */
149export function planRename(input: PlanRenameInput): PlanRenameResult {
150 const { from, to, existingBranches, defaultBranch } = input;
151
152 const vFrom = validateBranchName(from);
153 if (!vFrom.ok)
154 return { ok: false, reason: "invalid_from", detail: vFrom.reason };
155 const vTo = validateBranchName(to);
156 if (!vTo.ok)
157 return { ok: false, reason: "invalid_to", detail: vTo.reason };
158
159 if (from === to) return { ok: false, reason: "same_name" };
160
161 const existing = new Set(existingBranches);
162 if (!existing.has(from)) return { ok: false, reason: "from_missing" };
163 if (existing.has(to)) return { ok: false, reason: "to_exists" };
164
165 return {
166 ok: true,
167 from,
168 to,
169 updatesDefault: defaultBranch === from,
170 };
171}
172
173/**
174 * Given a glob/exact branch-protection pattern and the rename, decide
175 * whether the pattern itself should be updated. Only exact (non-glob)
176 * matches are rewritten — globs like `release/*` are left alone because
177 * the rename doesn't change how they match the namespace.
178 */
179export function shouldRewriteProtectionPattern(
180 pattern: string,
181 from: string
182): boolean {
183 if (pattern !== from) return false;
184 // Conservative: only exact matches, no glob characters present.
185 if (/[*?\[]/.test(pattern)) return false;
186 return true;
187}
188
189export const __internal = {
190 MAX_BRANCH_NAME_LENGTH,
191 FORBIDDEN_RE,
192 CONTROL_RE,
193 validateBranchName,
194 branchValidationMessage,
195 planRename,
196 shouldRewriteProtectionPattern,
197};
Addedsrc/routes/branch-rename.tsx+346−0View fileUnifiedSplit
1/**
2 * Block J24 — Branch rename.
3 *
4 * Owner-only. Renames a branch on disk and cascades the rename to:
5 * - repositories.defaultBranch (when renaming the default branch)
6 * - pull_requests.base_branch / head_branch
7 * - merge_queue_entries.base_branch
8 * - branch_protection.pattern (exact matches only — globs untouched)
9 * - HEAD symbolic-ref (via setHeadBranch) when default renames
10 *
11 * Pure validation lives in src/lib/branch-rename.ts. This file does the
12 * IO. Every DB update is audited.
13 */
14
15import { Hono } from "hono";
16import { eq, and } from "drizzle-orm";
17import { db } from "../db";
18import {
19 repositories,
20 users,
21 pullRequests,
22 mergeQueueEntries,
23 branchProtection,
24} from "../db/schema";
25import { Layout } from "../views/layout";
26import { RepoHeader } from "../views/components";
27import { softAuth, requireAuth } from "../middleware/auth";
28import type { AuthEnv } from "../middleware/auth";
29import {
30 listBranches,
31 getDefaultBranch,
32 renameBranch as gitRenameBranch,
33 setHeadBranch,
34} from "../git/repository";
35import {
36 planRename,
37 branchValidationMessage,
38 shouldRewriteProtectionPattern,
39} from "../lib/branch-rename";
40import { audit } from "../lib/notify";
41
42const branchRenameRoutes = new Hono<AuthEnv>();
43
44branchRenameRoutes.use("*", softAuth);
45
46async function resolveOwned(
47 c: Parameters<
48 Parameters<typeof branchRenameRoutes.get>[1]
49 >[0],
50 ownerName: string,
51 repoName: string
52): Promise<{ owner: typeof users.$inferSelect; repo: typeof repositories.$inferSelect } | null> {
53 try {
54 const [owner] = await db
55 .select()
56 .from(users)
57 .where(eq(users.username, ownerName))
58 .limit(1);
59 if (!owner) return null;
60 const user = c.get("user");
61 if (!user || user.id !== owner.id) return null;
62 const [repo] = await db
63 .select()
64 .from(repositories)
65 .where(
66 and(
67 eq(repositories.ownerId, owner.id),
68 eq(repositories.name, repoName)
69 )
70 )
71 .limit(1);
72 if (!repo) return null;
73 return { owner, repo };
74 } catch {
75 return null;
76 }
77}
78
79// GET: branch management page — lists branches with per-row "Rename" form.
80branchRenameRoutes.get(
81 "/:owner/:repo/settings/branches",
82 requireAuth,
83 async (c) => {
84 const { owner: ownerName, repo: repoName } = c.req.param();
85 const user = c.get("user")!;
86 const error = c.req.query("error");
87 const success = c.req.query("success");
88
89 const resolved = await resolveOwned(c, ownerName, repoName);
90 if (!resolved) {
91 return c.html(
92 <Layout title="Unauthorized" user={user}>
93 <div class="empty-state">
94 <h2>Unauthorized</h2>
95 <p>Only the repository owner can manage branches.</p>
96 </div>
97 </Layout>,
98 403
99 );
100 }
101
102 const branches = await listBranches(ownerName, repoName);
103 const defaultBranch =
104 (await getDefaultBranch(ownerName, repoName)) ||
105 resolved.repo.defaultBranch;
106
107 return c.html(
108 <Layout
109 title={`Branches — ${ownerName}/${repoName}`}
110 user={user}
111 >
112 <RepoHeader owner={ownerName} repo={repoName} />
113 <div style="max-width: 720px">
114 <h2 style="margin-bottom: 16px">Branches</h2>
115 {error && (
116 <div class="auth-error">{decodeURIComponent(error)}</div>
117 )}
118 {success && (
119 <div class="auth-success">{decodeURIComponent(success)}</div>
120 )}
121 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px">
122 Renaming a branch updates open PRs that target it, branch
123 protection rules with an exact-match pattern, and the default
124 branch pointer (if applicable). History is preserved — only
125 the ref name changes.
126 </p>
127 {branches.length === 0 ? (
128 <div class="empty-state">
129 <p>No branches yet. Push some commits to get started.</p>
130 </div>
131 ) : (
132 <table style="width: 100%; border-collapse: collapse">
133 <thead>
134 <tr>
135 <th style="text-align: left; padding: 8px; border-bottom: 1px solid var(--border)">
136 Branch
137 </th>
138 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border)">
139 Rename
140 </th>
141 </tr>
142 </thead>
143 <tbody>
144 {branches.map((b) => (
145 <tr>
146 <td style="padding: 8px; border-bottom: 1px solid var(--border); font-family: var(--font-mono); font-size: 13px">
147 {b}
148 {b === defaultBranch && (
149 <span
150 class="issue-badge badge-open"
151 style="margin-left: 8px; font-size: 10px; padding: 1px 6px"
152 >
153 default
154 </span>
155 )}
156 </td>
157 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right">
158 <form
159 method="POST"
160 action={`/${ownerName}/${repoName}/settings/branches/rename`}
161 style="display: inline-flex; gap: 6px"
162 >
163 <input type="hidden" name="from" value={b} />
164 <input
165 type="text"
166 name="to"
167 placeholder="new name"
168 required
169 style="padding: 4px 8px; font-size: 12px; width: 180px"
170 />
171 <button
172 type="submit"
173 class="btn"
174 style="padding: 4px 10px; font-size: 12px"
175 >
176 Rename
177 </button>
178 </form>
179 </td>
180 </tr>
181 ))}
182 </tbody>
183 </table>
184 )}
185 </div>
186 </Layout>
187 );
188 }
189);
190
191// POST: perform the rename.
192branchRenameRoutes.post(
193 "/:owner/:repo/settings/branches/rename",
194 requireAuth,
195 async (c) => {
196 const { owner: ownerName, repo: repoName } = c.req.param();
197 const user = c.get("user")!;
198 const body = await c.req.parseBody();
199 const from = String(body.from || "").trim();
200 const to = String(body.to || "").trim();
201 const base = `/${ownerName}/${repoName}/settings/branches`;
202
203 const resolved = await resolveOwned(c, ownerName, repoName);
204 if (!resolved) {
205 return c.redirect(`/${ownerName}/${repoName}`);
206 }
207
208 const existing = await listBranches(ownerName, repoName);
209 const defaultBranch =
210 (await getDefaultBranch(ownerName, repoName)) ||
211 resolved.repo.defaultBranch;
212
213 const plan = planRename({
214 from,
215 to,
216 existingBranches: existing,
217 defaultBranch,
218 });
219 if (!plan.ok) {
220 const msg = (() => {
221 switch (plan.reason) {
222 case "same_name":
223 return "New name must differ from the current name.";
224 case "from_missing":
225 return `Branch '${from}' does not exist.`;
226 case "to_exists":
227 return `A branch named '${to}' already exists.`;
228 case "invalid_from":
229 return `Source name is invalid: ${
230 plan.detail ? branchValidationMessage(plan.detail) : "invalid"
231 }`;
232 case "invalid_to":
233 return plan.detail
234 ? branchValidationMessage(plan.detail)
235 : "Invalid branch name.";
236 }
237 })();
238 return c.redirect(`${base}?error=${encodeURIComponent(msg)}`);
239 }
240
241 // Git: move the ref. If that fails the repo state is untouched.
242 const moved = await gitRenameBranch(ownerName, repoName, plan.from, plan.to);
243 if (!moved) {
244 return c.redirect(
245 `${base}?error=${encodeURIComponent("git rename failed — check repository state.")}`
246 );
247 }
248
249 // If we just renamed the default branch, point HEAD at the new name
250 // and persist the new default on the repositories row.
251 let cascadeErr: string | null = null;
252 try {
253 if (plan.updatesDefault) {
254 await setHeadBranch(ownerName, repoName, plan.to);
255 await db
256 .update(repositories)
257 .set({ defaultBranch: plan.to, updatedAt: new Date() })
258 .where(eq(repositories.id, resolved.repo.id));
259 }
260
261 // PRs: rewrite base_branch + head_branch on both sides.
262 await db
263 .update(pullRequests)
264 .set({ baseBranch: plan.to, updatedAt: new Date() })
265 .where(
266 and(
267 eq(pullRequests.repositoryId, resolved.repo.id),
268 eq(pullRequests.baseBranch, plan.from)
269 )
270 );
271 await db
272 .update(pullRequests)
273 .set({ headBranch: plan.to, updatedAt: new Date() })
274 .where(
275 and(
276 eq(pullRequests.repositoryId, resolved.repo.id),
277 eq(pullRequests.headBranch, plan.from)
278 )
279 );
280
281 // Merge queue entries pinned to the old base.
282 await db
283 .update(mergeQueueEntries)
284 .set({ baseBranch: plan.to })
285 .where(
286 and(
287 eq(mergeQueueEntries.repositoryId, resolved.repo.id),
288 eq(mergeQueueEntries.baseBranch, plan.from)
289 )
290 );
291
292 // Branch protection: only rewrite exact matches (never globs).
293 const protections = await db
294 .select()
295 .from(branchProtection)
296 .where(eq(branchProtection.repositoryId, resolved.repo.id));
297 for (const p of protections) {
298 if (shouldRewriteProtectionPattern(p.pattern, plan.from)) {
299 try {
300 await db
301 .update(branchProtection)
302 .set({ pattern: plan.to, updatedAt: new Date() })
303 .where(eq(branchProtection.id, p.id));
304 } catch {
305 // Unique constraint on (repo, pattern) — skip if a rule
306 // already exists for the new name.
307 }
308 }
309 }
310 } catch (err) {
311 cascadeErr =
312 err instanceof Error ? err.message : "cascade update failed";
313 }
314
315 try {
316 await audit({
317 userId: user.id,
318 repositoryId: resolved.repo.id,
319 action: "branch.rename",
320 targetId: resolved.repo.id,
321 metadata: {
322 from: plan.from,
323 to: plan.to,
324 updatesDefault: plan.updatesDefault,
325 },
326 });
327 } catch {
328 // audit failures must never block the operation.
329 }
330
331 if (cascadeErr) {
332 return c.redirect(
333 `${base}?error=${encodeURIComponent(
334 `Branch renamed but some cascades failed: ${cascadeErr}`
335 )}`
336 );
337 }
338 return c.redirect(
339 `${base}?success=${encodeURIComponent(
340 `Renamed '${plan.from}' → '${plan.to}'.`
341 )}`
342 );
343 }
344);
345
346export default branchRenameRoutes;
Modifiedsrc/routes/repo-settings.tsx+5−0View fileUnifiedSplit
9595 ))
9696 )}
9797 </select>
98 <div style="margin-top: 6px; font-size: 12px">
99 <a href={`/${ownerName}/${repoName}/settings/branches`}>
100 Manage branches (rename, …)
101 </a>
102 </div>
98103 </div>
99104 <div class="form-group">
100105 <label>Visibility</label>
101106