Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

branch-rename.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

branch-rename.tsxBlame346 lines · 1 contributor
d6daf49Claude1/**
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;