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