Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

stale-branches.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.

stale-branches.tsxBlame700 lines · 2 contributors
9fbe6cdClaude1/**
2 * Stale Branch Cleanup UI — /:owner/:repo/branches/stale
3 *
4 * Lists merged branches that are safe to delete and lets the repo owner
5 * bulk-delete them via a form POST.
6 *
7 * Filtering: protected/special branches (main, master, develop, staging,
8 * production, HEAD, and the default branch itself) are never shown.
9 *
10 * For each stale branch we query pull_requests to find the most-recently
11 * merged PR for that head branch — we display the PR number as a link
12 * and the mergedAt date.
13 */
14
15import { Hono } from "hono";
16import { eq, and, desc } from "drizzle-orm";
17import { db } from "../db";
18import { repositories, users, pullRequests } from "../db/schema";
19import { Layout } from "../views/layout";
20import { RepoHeader, RepoNav } from "../views/components";
21import { softAuth, requireAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import { getDefaultBranch } from "../git/repository";
24import { getUnreadCount } from "../lib/unread";
25
26// ---------------------------------------------------------------------------
27// Constants
28// ---------------------------------------------------------------------------
29
30/** These branches are never shown as stale candidates. */
31const PROTECTED_NAMES = new Set([
32 "main",
33 "master",
34 "develop",
35 "staging",
36 "production",
37 "HEAD",
38]);
39
40// ---------------------------------------------------------------------------
41// Router
42// ---------------------------------------------------------------------------
43
44const staleBranchRoutes = new Hono<AuthEnv>();
45
46// Path-scoped middleware (must NOT use `use("*", ...)` — see CLAUDE.md rule).
47// softAuth for the GET (public repos visible to all), requireAuth for the POST.
03e6f9bccantynz-alt48// "path*" (no slash before the *) doesn't match the bare path in this Hono
49// version -- see admin-security.tsx's fix for the confirmed repro.
50staleBranchRoutes.use("/:owner/:repo/branches/stale", softAuth);
51staleBranchRoutes.use("/:owner/:repo/branches/stale/*", softAuth);
9fbe6cdClaude52
53// ---------------------------------------------------------------------------
54// Helpers
55// ---------------------------------------------------------------------------
56
57/** Resolve owner user + repo record from URL params. */
58async function resolveRepo(ownerName: string, repoName: string) {
59 const [owner] = await db
60 .select()
61 .from(users)
62 .where(eq(users.username, ownerName))
63 .limit(1);
64 if (!owner) return null;
65
66 const [repo] = await db
67 .select()
68 .from(repositories)
69 .where(
70 and(
71 eq(repositories.ownerId, owner.id),
72 eq(repositories.name, repoName)
73 )
74 )
75 .limit(1);
76 if (!repo) return null;
77
78 return { owner, repo };
79}
80
81/**
82 * Run `git --git-dir <diskPath> branch --merged <defaultBranch>` and return
83 * the list of branch names that are fully merged into defaultBranch, minus
84 * any protected names and the default branch itself.
85 */
86async function getStaleBranches(
87 diskPath: string,
88 defaultBranch: string
89): Promise<string[]> {
90 const proc = Bun.spawn(
91 ["git", "--git-dir", diskPath, "branch", "--merged", defaultBranch],
92 { stdout: "pipe", stderr: "pipe" }
93 );
94 const [stdout] = await Promise.all([
95 new Response(proc.stdout).text(),
96 new Response(proc.stderr).text(),
97 ]);
98 await proc.exited;
99
100 return stdout
101 .split("\n")
102 .map((l) => l.replace(/^\*?\s+/, "").trim()) // strip leading "* " or spaces
103 .filter(Boolean)
104 .filter((b) => b !== defaultBranch && !PROTECTED_NAMES.has(b));
105}
106
107/** Age string from a Date — e.g. "3 days ago", "2 months ago". */
108function ageFromNow(date: Date): string {
109 const diffMs = Date.now() - date.getTime();
110 const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
111 if (diffDays === 0) return "today";
112 if (diffDays === 1) return "yesterday";
113 if (diffDays < 30) return `${diffDays} days ago`;
114 const diffMonths = Math.floor(diffDays / 30);
115 if (diffMonths < 12) return `${diffMonths} month${diffMonths > 1 ? "s" : ""} ago`;
116 const diffYears = Math.floor(diffDays / 365);
117 return `${diffYears} year${diffYears > 1 ? "s" : ""} ago`;
118}
119
120// ---------------------------------------------------------------------------
121// Scoped CSS
122// ---------------------------------------------------------------------------
123
124const sbStyles = `
125 .sb-container {
126 max-width: 960px;
127 margin: 0 auto;
128 padding: 0 var(--space-3, 16px);
129 }
130
131 /* Hero */
132 .sb-hero {
133 position: relative;
134 margin: 4px 0 24px;
135 padding: 28px 32px;
136 background: var(--bg-elevated);
137 border: 1px solid var(--border);
138 border-radius: 16px;
139 overflow: hidden;
140 }
141 .sb-hero::before {
142 content: '';
143 position: absolute;
144 top: 0; left: 0; right: 0;
145 height: 2px;
6fd5915Claude146 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
9fbe6cdClaude147 opacity: 0.7;
148 pointer-events: none;
149 }
150 .sb-hero-eyebrow {
151 font-size: 12px;
152 color: var(--text-muted);
153 margin-bottom: 6px;
154 letter-spacing: 0.04em;
155 text-transform: uppercase;
156 font-weight: 600;
157 }
158 .sb-hero-title {
159 font-size: clamp(22px, 3vw, 32px);
160 font-weight: 800;
161 letter-spacing: -0.02em;
162 line-height: 1.1;
163 margin: 0 0 8px;
164 color: var(--text-strong);
165 }
166 .sb-hero-sub {
167 font-size: 14px;
168 color: var(--text-muted);
169 margin: 0;
170 line-height: 1.5;
171 }
172
173 /* Flash banner */
174 .sb-flash {
175 display: flex;
176 align-items: center;
177 gap: 10px;
178 padding: 12px 16px;
179 border-radius: 10px;
180 font-size: 14px;
181 margin-bottom: 18px;
182 border: 1px solid;
183 }
184 .sb-flash.is-success {
185 background: rgba(52,211,153,0.08);
186 border-color: rgba(52,211,153,0.3);
187 color: #34d399;
188 }
189 .sb-flash.is-error {
190 background: rgba(248,113,113,0.08);
191 border-color: rgba(248,113,113,0.3);
192 color: #f87171;
193 }
194
195 /* Protected-branches hint */
196 .sb-hint {
197 font-size: 13px;
198 color: var(--text-muted);
199 margin-bottom: 18px;
200 padding: 10px 14px;
201 background: var(--bg-secondary);
202 border-radius: 8px;
203 border: 1px solid var(--border);
204 }
205
206 /* Toolbar */
207 .sb-toolbar {
208 display: flex;
209 align-items: center;
210 justify-content: space-between;
211 gap: 12px;
212 margin-bottom: 14px;
213 flex-wrap: wrap;
214 }
215 .sb-count {
216 font-size: 14px;
217 color: var(--text-muted);
218 }
219 .sb-count strong { color: var(--text); }
220
221 /* Table */
222 .sb-table-wrap {
223 border: 1px solid var(--border);
224 border-radius: 12px;
225 overflow: hidden;
226 }
227 .sb-table {
228 width: 100%;
229 border-collapse: collapse;
230 font-size: 14px;
231 }
232 .sb-table thead {
233 background: var(--bg-secondary);
234 border-bottom: 1px solid var(--border);
235 }
236 .sb-table th {
237 padding: 10px 14px;
238 text-align: left;
239 font-size: 12px;
240 font-weight: 600;
241 color: var(--text-muted);
242 letter-spacing: 0.04em;
243 text-transform: uppercase;
244 white-space: nowrap;
245 }
246 .sb-table th.sb-th-check {
247 width: 36px;
248 text-align: center;
249 }
250 .sb-table td {
251 padding: 11px 14px;
252 border-top: 1px solid var(--border);
253 vertical-align: middle;
254 }
255 .sb-table tr:first-child td { border-top: none; }
256 .sb-table tbody tr:hover { background: var(--bg-hover, rgba(255,255,255,0.03)); }
257
258 .sb-td-check { text-align: center; }
259 .sb-branch-name {
260 font-family: var(--font-mono, monospace);
261 font-size: 13px;
262 color: var(--text-strong);
263 word-break: break-all;
264 }
265 .sb-pr-link {
6fd5915Claude266 color: var(--text-link, #5b6ee8);
9fbe6cdClaude267 text-decoration: none;
268 font-size: 13px;
269 }
270 .sb-pr-link:hover { text-decoration: underline; }
271 .sb-dash { color: var(--text-muted); }
272 .sb-date {
273 font-size: 13px;
274 color: var(--text);
275 white-space: nowrap;
276 }
277 .sb-age {
278 font-size: 12px;
279 color: var(--text-muted);
280 }
281
282 /* Empty state */
283 .sb-empty {
284 text-align: center;
285 padding: 60px 24px;
286 color: var(--text-muted);
287 }
288 .sb-empty-icon {
289 font-size: 40px;
290 margin-bottom: 16px;
291 line-height: 1;
292 }
293 .sb-empty-title {
294 font-size: 18px;
295 font-weight: 700;
296 color: var(--text-strong);
297 margin: 0 0 8px;
298 }
299 .sb-empty-sub {
300 font-size: 14px;
301 margin: 0;
302 }
303
304 /* Actions */
305 .sb-actions {
306 display: flex;
307 align-items: center;
308 justify-content: flex-end;
309 gap: 10px;
310 margin-top: 18px;
311 }
312 .sb-btn {
313 display: inline-flex;
314 align-items: center;
315 gap: 6px;
316 padding: 8px 18px;
317 border-radius: 8px;
318 font-size: 14px;
319 font-weight: 600;
320 cursor: pointer;
321 transition: opacity 140ms ease, background 140ms ease;
322 border: 1px solid transparent;
323 text-decoration: none;
324 }
325 .sb-btn-danger {
326 background: rgba(248,113,113,0.12);
327 color: #f87171;
328 border-color: rgba(248,113,113,0.35);
329 }
330 .sb-btn-danger:hover:not(:disabled) {
331 background: rgba(248,113,113,0.22);
332 }
333 .sb-btn-danger:disabled {
334 opacity: 0.4;
335 cursor: not-allowed;
336 }
337`;
338
339// ---------------------------------------------------------------------------
340// GET /:owner/:repo/branches/stale
341// ---------------------------------------------------------------------------
342
343staleBranchRoutes.get("/:owner/:repo/branches/stale", async (c) => {
344 const { owner: ownerName, repo: repoName } = c.req.param();
345 const user = c.get("user");
346
347 const resolved = await resolveRepo(ownerName, repoName);
348 if (!resolved) return c.notFound();
349
350 const { repo } = resolved;
351
352 // Private repos require auth
353 if (repo.isPrivate && !user) {
354 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
355 }
356
357 const isOwner = user?.id === repo.ownerId;
358
359 // Get default branch
360 const defaultBranch =
361 (await getDefaultBranch(ownerName, repoName)) ?? repo.defaultBranch ?? "main";
362
363 // Get stale branches
364 let staleBranches: string[] = [];
365 try {
366 staleBranches = await getStaleBranches(repo.diskPath, defaultBranch);
367 } catch {
368 staleBranches = [];
369 }
370
371 // For each stale branch, find the last merged PR
372 type BranchRow = {
373 branch: string;
374 prNumber: number | null;
375 mergedAt: Date | null;
376 };
377
378 const rows: BranchRow[] = await Promise.all(
379 staleBranches.map(async (branch) => {
380 const [pr] = await db
381 .select({
382 number: pullRequests.number,
383 mergedAt: pullRequests.mergedAt,
384 })
385 .from(pullRequests)
386 .where(
387 and(
388 eq(pullRequests.repositoryId, repo.id),
389 eq(pullRequests.headBranch, branch),
390 eq(pullRequests.state, "merged")
391 )
392 )
393 .orderBy(desc(pullRequests.mergedAt))
394 .limit(1);
395
396 return {
397 branch,
398 prNumber: pr?.number ?? null,
399 mergedAt: pr?.mergedAt ?? null,
400 };
401 })
402 );
403
404 // Sort by mergedAt desc (branches with no PR go last)
405 rows.sort((a, b) => {
406 if (a.mergedAt && b.mergedAt) {
407 return b.mergedAt.getTime() - a.mergedAt.getTime();
408 }
409 if (a.mergedAt) return -1;
410 if (b.mergedAt) return 1;
411 return a.branch.localeCompare(b.branch);
412 });
413
414 // Unread count for nav badge
415 const unreadCount = user ? await getUnreadCount(user.id) : 0;
416
417 // Flash params
418 const deleted = c.req.query("deleted");
419 const failed = c.req.query("failed");
420
421 return c.html(
422 <Layout
423 title={`Stale Branches — ${ownerName}/${repoName}`}
424 user={user}
425 notificationCount={unreadCount}
426 >
427 <style dangerouslySetInnerHTML={{ __html: sbStyles }} />
428 <div class="sb-container">
429 <RepoHeader
430 owner={ownerName}
431 repo={repoName}
432 starCount={repo.starCount}
433 forkCount={repo.forkCount}
434 currentUser={user?.username ?? null}
435 />
436 <RepoNav owner={ownerName} repo={repoName} active="code" />
437
438 {/* Flash message */}
439 {(deleted !== undefined || failed !== undefined) && (
440 <div
441 class={`sb-flash ${Number(failed ?? 0) > 0 && Number(deleted ?? 0) === 0 ? "is-error" : "is-success"}`}
442 >
443 {Number(deleted ?? 0) > 0 && (
444 <span>
445 Deleted {deleted} branch{Number(deleted) !== 1 ? "es" : ""}
446 {Number(failed ?? 0) > 0 && ` (${failed} failed)`}.
447 </span>
448 )}
449 {Number(deleted ?? 0) === 0 && Number(failed ?? 0) > 0 && (
450 <span>Failed to delete {failed} branch{Number(failed) !== 1 ? "es" : ""}.</span>
451 )}
452 </div>
453 )}
454
455 {/* Hero */}
456 <div class="sb-hero">
457 <p class="sb-hero-eyebrow">Repository maintenance</p>
458 <h1 class="sb-hero-title">Stale Branches</h1>
459 <p class="sb-hero-sub">
460 Branches that have been fully merged into{" "}
461 <code>{defaultBranch}</code> and are safe to remove.
462 {!isOwner && " Only the repository owner can delete branches."}
463 </p>
464 </div>
465
466 {/* Protected-branches hint */}
467 <p class="sb-hint">
468 Protected branches (<code>main</code>, <code>master</code>,{" "}
469 <code>develop</code>, <code>staging</code>, <code>production</code>,{" "}
470 <code>HEAD</code>, and <code>{defaultBranch}</code>) are never
471 listed here.
472 </p>
473
474 {rows.length === 0 ? (
475 /* Empty state */
476 <div class="sb-empty">
477 <div class="sb-empty-icon">&#10003;</div>
478 <p class="sb-empty-title">
479 No stale branches — great job keeping things tidy!
480 </p>
481 <p class="sb-empty-sub">
482 All merged branches have already been cleaned up.
483 </p>
484 </div>
485 ) : (
486 <form method="post" action={`/${ownerName}/${repoName}/branches/stale/delete`}>
487 {/* Toolbar */}
488 <div class="sb-toolbar">
489 <span class="sb-count">
490 <strong>{rows.length}</strong> stale{" "}
491 {rows.length === 1 ? "branch" : "branches"}
492 </span>
493 </div>
494
495 {/* Table */}
496 <div class="sb-table-wrap">
497 <table class="sb-table">
498 <thead>
499 <tr>
500 {isOwner && (
501 <th class="sb-th-check">
502 <input
503 type="checkbox"
504 id="sb-select-all"
505 title="Select all"
506 aria-label="Select all branches"
507 />
508 </th>
509 )}
510 <th>Branch</th>
511 <th>Merged PR</th>
512 <th>Merged date</th>
513 <th>Age</th>
514 </tr>
515 </thead>
516 <tbody>
517 {rows.map((row) => (
518 <tr key={row.branch}>
519 {isOwner && (
520 <td class="sb-td-check">
521 <input
522 type="checkbox"
523 name="branches[]"
524 value={row.branch}
525 class="sb-row-check"
526 aria-label={`Select ${row.branch}`}
527 />
528 </td>
529 )}
530 <td>
531 <span class="sb-branch-name">{row.branch}</span>
532 </td>
533 <td>
534 {row.prNumber != null ? (
535 <a
536 href={`/${ownerName}/${repoName}/pulls/${row.prNumber}`}
537 class="sb-pr-link"
538 >
539 #{row.prNumber}
540 </a>
541 ) : (
542 <span class="sb-dash">—</span>
543 )}
544 </td>
545 <td>
546 {row.mergedAt ? (
547 <span class="sb-date">
548 {row.mergedAt.toISOString().slice(0, 10)}
549 </span>
550 ) : (
551 <span class="sb-dash">—</span>
552 )}
553 </td>
554 <td>
555 {row.mergedAt ? (
556 <span class="sb-age">{ageFromNow(row.mergedAt)}</span>
557 ) : (
558 <span class="sb-dash">—</span>
559 )}
560 </td>
561 </tr>
562 ))}
563 </tbody>
564 </table>
565 </div>
566
567 {/* Submit */}
568 {isOwner && (
569 <div class="sb-actions">
570 <button
571 type="submit"
572 class="sb-btn sb-btn-danger"
573 id="sb-delete-btn"
574 disabled
575 >
576 Delete selected
577 </button>
578 </div>
579 )}
580 </form>
581 )}
582 </div>
583
584 {/* JS: select-all + disable/enable submit button */}
585 <script
586 dangerouslySetInnerHTML={{
587 __html: `
588(function () {
589 var selectAll = document.getElementById('sb-select-all');
590 var deleteBtn = document.getElementById('sb-delete-btn');
591 var checks = [];
592
593 function refresh() {
594 checks = Array.from(document.querySelectorAll('.sb-row-check'));
595 if (!deleteBtn) return;
596 var anyChecked = checks.some(function (c) { return c.checked; });
597 deleteBtn.disabled = !anyChecked;
598 }
599
600 if (selectAll) {
601 selectAll.addEventListener('change', function () {
602 checks.forEach(function (c) { c.checked = selectAll.checked; });
603 refresh();
604 });
605 }
606
607 document.addEventListener('change', function (e) {
608 if (e.target && e.target.classList.contains('sb-row-check')) {
609 refresh();
610 if (selectAll) {
611 selectAll.checked = checks.length > 0 && checks.every(function (c) { return c.checked; });
612 selectAll.indeterminate = checks.some(function (c) { return c.checked; }) && !checks.every(function (c) { return c.checked; });
613 }
614 }
615 });
616
617 refresh();
618})();
619 `,
620 }}
621 />
622 </Layout>
623 );
624});
625
626// ---------------------------------------------------------------------------
627// POST /:owner/:repo/branches/stale/delete (owner-only)
628// ---------------------------------------------------------------------------
629
630staleBranchRoutes.use("/:owner/:repo/branches/stale/delete", requireAuth);
631
632staleBranchRoutes.post("/:owner/:repo/branches/stale/delete", async (c) => {
633 const { owner: ownerName, repo: repoName } = c.req.param();
634 const user = c.get("user")!;
635
636 const resolved = await resolveRepo(ownerName, repoName);
637 if (!resolved) return c.notFound();
638
639 const { repo } = resolved;
640
641 // Owner-only
642 if (user.id !== repo.ownerId) {
643 return c.text("Forbidden", 403);
644 }
645
646 const defaultBranch =
647 (await getDefaultBranch(ownerName, repoName)) ?? repo.defaultBranch ?? "main";
648
649 // Re-derive the allowed stale set (re-run git to verify)
650 let allowed: Set<string>;
651 try {
652 const stale = await getStaleBranches(repo.diskPath, defaultBranch);
653 allowed = new Set(stale);
654 } catch {
655 allowed = new Set();
656 }
657
658 // Parse submitted branch names
659 const body = await c.req.parseBody();
660 const raw = body["branches[]"];
661 const requested: string[] = (
662 Array.isArray(raw) ? raw : raw ? [raw] : []
663 ).map((v) => String(v).trim()).filter(Boolean);
664
665 let deletedCount = 0;
666 let failedCount = 0;
667
668 for (const branch of requested) {
669 // Must still be in the stale list (safety check)
670 if (!allowed.has(branch)) {
671 failedCount++;
672 continue;
673 }
674
675 const proc = Bun.spawn(
676 ["git", "--git-dir", repo.diskPath, "branch", "-d", branch],
677 { stdout: "pipe", stderr: "pipe" }
678 );
679 await Promise.all([
680 new Response(proc.stdout).text(),
681 new Response(proc.stderr).text(),
682 ]);
683 const exitCode = await proc.exited;
684 if (exitCode === 0) {
685 deletedCount++;
686 } else {
687 failedCount++;
688 }
689 }
690
691 const params = new URLSearchParams();
692 params.set("deleted", String(deletedCount));
693 params.set("failed", String(failedCount));
694
695 return c.redirect(
696 `/${ownerName}/${repoName}/branches/stale?${params.toString()}`
697 );
698});
699
700export { staleBranchRoutes };