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

fork.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.

fork.tsxBlame686 lines · 2 contributors
398a10cClaude1/**
2 * Fork route — copy a repository into your account.
3 *
4 * GET /:owner/:repo/fork — polished confirmation page (source repo
5 * card + destination owner picker + gradient
6 * "Create my fork" submit). Mirrors the
7 * existing inline POST button on RepoHeader;
8 * the form here POSTs to the same endpoint
9 * below, so the POST handler logic is the
10 * single source of truth.
11 * POST /:owner/:repo/fork — perform the fork.
12 *
13 * All CSS is scoped under `.fork-*` so it can't bleed into other
14 * surfaces. Visual recipe follows `src/routes/import.tsx` and
15 * `src/routes/connect-claude.tsx`.
16 */
17
18import { Hono } from "hono";
19import { eq, and } from "drizzle-orm";
20import { db } from "../db";
21import { repositories, users, activityFeed } from "../db/schema";
22import { softAuth, requireAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
a1beefbccantynz-alt24import { resolveRepoAccess } from "../middleware/repo-access";
398a10cClaude25import { getRepoPath, repoExists } from "../git/repository";
26import { config } from "../lib/config";
27import { join } from "path";
28import { Layout } from "../views/layout";
29
30const fork = new Hono<AuthEnv>();
31
32fork.use("*", softAuth);
33
34// ─── Scoped CSS — `.fork-*` ────────────────────────────────────────────────
35const forkStyles = `
eed4684Claude36 .fork-wrap { max-width: 900px; margin: 0 auto; padding: var(--space-6, 32px) var(--space-4, 24px); }
398a10cClaude37
38 /* ─── Hero ─── */
39 .fork-hero {
40 position: relative;
41 margin-bottom: var(--space-5);
42 padding: clamp(28px, 4vw, 44px) clamp(24px, 4vw, 36px);
43 background: var(--bg-elevated);
44 border: 1px solid var(--border);
45 border-radius: 18px;
46 overflow: hidden;
47 }
48 .fork-hero::before {
49 content: '';
50 position: absolute;
51 top: 0; left: 0; right: 0;
52 height: 2px;
6fd5915Claude53 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude54 opacity: 0.75;
55 pointer-events: none;
56 }
57 .fork-hero-orb {
58 position: absolute;
59 inset: -30% -10% auto auto;
60 width: 420px; height: 420px;
6fd5915Claude61 background: radial-gradient(circle, rgba(91,110,232,0.22), rgba(95,143,160,0.10) 45%, transparent 70%);
398a10cClaude62 filter: blur(80px);
63 opacity: 0.7;
64 pointer-events: none;
65 animation: forkHeroOrb 14s ease-in-out infinite;
66 z-index: 0;
67 }
68 @keyframes forkHeroOrb {
69 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
70 50% { transform: scale(1.08) translate(-12px, 8px); opacity: 0.85; }
71 }
72 @media (prefers-reduced-motion: reduce) {
73 .fork-hero-orb { animation: none; }
74 }
75 .fork-hero-inner { position: relative; z-index: 1; max-width: 640px; }
76 .fork-eyebrow {
77 display: inline-flex;
78 align-items: center;
79 gap: 8px;
80 font-family: var(--font-mono);
81 font-size: 11px;
82 letter-spacing: 0.16em;
83 text-transform: uppercase;
84 color: var(--text-muted);
85 font-weight: 600;
86 margin-bottom: 12px;
87 }
88 .fork-eyebrow-dot {
89 width: 8px; height: 8px;
90 border-radius: 9999px;
6fd5915Claude91 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
92 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
398a10cClaude93 }
94 .fork-eyebrow strong { color: var(--accent); font-weight: 700; }
95 .fork-title {
96 font-family: var(--font-display);
97 font-size: clamp(28px, 4vw, 40px);
98 font-weight: 800;
99 letter-spacing: -0.028em;
100 line-height: 1.06;
101 margin: 0 0 var(--space-2);
102 color: var(--text-strong);
103 }
104 .fork-title-grad {
6fd5915Claude105 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
398a10cClaude106 -webkit-background-clip: text;
107 background-clip: text;
108 -webkit-text-fill-color: transparent;
109 color: transparent;
110 }
111 .fork-sub {
112 font-size: 15px;
113 color: var(--text-muted);
114 line-height: 1.55;
115 margin: 0;
116 max-width: 560px;
117 }
118
119 /* ─── Source repo card ─── */
120 .fork-source-card {
121 position: relative;
122 display: flex;
123 align-items: center;
124 gap: 14px;
125 padding: 16px 18px;
126 margin-bottom: var(--space-4);
127 background: var(--bg-elevated);
128 border: 1px solid var(--border);
129 border-radius: 14px;
130 overflow: hidden;
131 }
132 .fork-source-card::before {
133 content: '';
134 position: absolute;
135 left: 0; top: 0; bottom: 0;
136 width: 3px;
6fd5915Claude137 background: linear-gradient(180deg, #5b6ee8 0%, #5f8fa0 100%);
398a10cClaude138 }
139 .fork-source-icon {
140 flex-shrink: 0;
141 width: 44px; height: 44px;
142 border-radius: 12px;
6fd5915Claude143 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.14));
144 border: 1px solid rgba(91,110,232,0.35);
398a10cClaude145 display: inline-flex;
146 align-items: center;
147 justify-content: center;
148 color: #c5b3ff;
149 font-family: var(--font-display);
150 font-weight: 800;
151 font-size: 20px;
152 }
153 .fork-source-body { flex: 1; min-width: 0; }
154 .fork-source-eyebrow {
155 font-family: var(--font-mono);
156 font-size: 11px;
157 letter-spacing: 0.14em;
158 text-transform: uppercase;
159 color: var(--text-muted);
160 font-weight: 600;
161 margin-bottom: 3px;
162 }
163 .fork-source-name {
164 font-family: var(--font-display);
165 font-size: 16px;
166 font-weight: 700;
167 color: var(--text-strong);
168 text-decoration: none;
169 letter-spacing: -0.012em;
170 display: block;
171 }
172 .fork-source-name:hover { color: var(--accent); }
173 .fork-source-desc {
174 font-size: 13px;
175 color: var(--text-muted);
176 margin: 3px 0 0;
177 line-height: 1.5;
178 overflow: hidden;
179 text-overflow: ellipsis;
180 white-space: nowrap;
181 }
182
183 /* ─── Form card ─── */
184 .fork-form-card {
185 position: relative;
186 padding: clamp(20px, 3vw, 28px);
187 background: var(--bg-elevated);
188 border: 1px solid var(--border);
189 border-radius: 14px;
190 overflow: hidden;
191 }
192 .fork-form-head {
193 display: flex;
194 align-items: center;
195 gap: 10px;
196 margin-bottom: 8px;
197 }
198 .fork-form-badge {
199 display: inline-flex;
200 align-items: center;
201 justify-content: center;
202 width: 26px; height: 26px;
203 border-radius: 50%;
6fd5915Claude204 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.14));
398a10cClaude205 color: #c5b3ff;
6fd5915Claude206 border: 1px solid rgba(91,110,232,0.40);
398a10cClaude207 font-family: var(--font-display);
208 font-weight: 700;
209 font-size: 13px;
210 }
211 .fork-form-title {
212 font-family: var(--font-display);
213 font-size: 16px;
214 font-weight: 700;
215 letter-spacing: -0.012em;
216 color: var(--text-strong);
217 margin: 0;
218 }
219 .fork-form-sub {
220 font-size: 13px;
221 color: var(--text-muted);
222 margin: 0 0 var(--space-3);
223 line-height: 1.55;
224 }
225 .fork-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-3); }
226 .fork-field-label {
227 font-size: 13px;
228 color: var(--text-strong);
229 font-weight: 600;
230 }
231 .fork-dest-row {
232 display: flex;
233 align-items: center;
234 gap: 10px;
235 padding: 12px 14px;
236 background: var(--bg);
237 border: 1px solid var(--border);
238 border-radius: 10px;
239 }
240 .fork-dest-avatar {
241 width: 28px; height: 28px;
242 border-radius: 50%;
6fd5915Claude243 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
398a10cClaude244 color: #fff;
245 display: inline-flex;
246 align-items: center;
247 justify-content: center;
248 font-family: var(--font-display);
249 font-weight: 700;
250 font-size: 13px;
251 flex-shrink: 0;
252 }
253 .fork-dest-name {
254 font-family: var(--font-display);
255 font-weight: 700;
256 font-size: 14px;
257 color: var(--text-strong);
258 }
259 .fork-dest-slash {
260 color: var(--text-muted);
261 font-family: var(--font-mono);
262 margin: 0 2px;
263 }
264 .fork-dest-repo {
265 font-family: var(--font-mono);
266 font-size: 13.5px;
267 color: var(--text);
268 }
269 .fork-dest-hint {
270 font-size: 12px;
271 color: var(--text-muted);
272 margin-top: 6px;
273 }
274
275 /* ─── Banners ─── */
276 .fork-banner {
277 position: relative;
278 padding: 12px 16px 12px 40px;
279 margin-bottom: var(--space-4);
280 border-radius: 12px;
281 border: 1px solid var(--border);
282 background: var(--bg-elevated);
283 font-size: 14px;
284 line-height: 1.5;
285 }
286 .fork-banner::before {
287 content: '';
288 position: absolute;
289 left: 14px; top: 16px;
290 width: 12px; height: 12px;
291 border-radius: 50%;
292 }
293 .fork-banner-warn {
294 border-color: rgba(251, 191, 36, 0.32);
295 background: linear-gradient(180deg, rgba(251,191,36,0.06) 0%, var(--bg-elevated) 100%);
296 }
297 .fork-banner-warn::before {
298 background: radial-gradient(circle, #fbbf24 30%, transparent 70%);
299 box-shadow: 0 0 10px rgba(251,191,36,0.5);
300 }
301 .fork-banner-error {
302 border-color: rgba(248, 81, 73, 0.32);
303 background: linear-gradient(180deg, rgba(248,81,73,0.06) 0%, var(--bg-elevated) 100%);
304 }
305 .fork-banner-error::before {
306 background: radial-gradient(circle, #f85149 30%, transparent 70%);
307 box-shadow: 0 0 10px rgba(248,81,73,0.5);
308 }
309 .fork-banner a { color: var(--accent); text-decoration: none; }
310 .fork-banner a:hover { text-decoration: underline; }
311
312 /* ─── Actions ─── */
313 .fork-actions {
314 display: flex;
315 align-items: center;
316 gap: var(--space-2);
317 margin-top: var(--space-4);
318 flex-wrap: wrap;
319 }
320 .fork-submit {
321 appearance: none;
6fd5915Claude322 border: 1px solid rgba(91,110,232,0.45);
323 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
398a10cClaude324 color: #fff;
325 padding: 12px 22px;
326 border-radius: 10px;
327 font-family: var(--font-display);
328 font-weight: 700;
329 font-size: 14px;
330 cursor: pointer;
6fd5915Claude331 box-shadow: 0 10px 24px -10px rgba(91,110,232,0.55);
398a10cClaude332 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
333 }
334 .fork-submit:hover {
335 transform: translateY(-1px);
6fd5915Claude336 box-shadow: 0 14px 28px -10px rgba(91,110,232,0.7);
398a10cClaude337 filter: brightness(1.06);
338 }
339 .fork-submit:focus-visible {
6fd5915Claude340 outline: 3px solid rgba(91,110,232,0.45);
398a10cClaude341 outline-offset: 2px;
342 }
343 .fork-submit:disabled {
344 cursor: not-allowed;
345 filter: grayscale(0.6) brightness(0.8);
346 box-shadow: none;
347 }
348 .fork-cancel {
349 font-size: 13.5px;
350 color: var(--text-muted);
351 text-decoration: none;
352 }
353 .fork-cancel:hover { color: var(--text-strong); }
354
355 /* ─── Empty state (zero state when invalid source) ─── */
356 .fork-empty {
357 position: relative;
358 padding: 48px 24px;
359 text-align: center;
360 background: var(--bg-elevated);
361 border: 1px solid var(--border);
362 border-radius: 16px;
363 overflow: hidden;
364 }
365 .fork-empty-orb {
366 position: absolute;
367 inset: -40% 25% auto 25%;
368 width: 50%; height: 240px;
6fd5915Claude369 background: radial-gradient(circle, rgba(91,110,232,0.18), transparent 65%);
398a10cClaude370 filter: blur(60px);
371 opacity: 0.7;
372 pointer-events: none;
373 z-index: 0;
374 }
375 .fork-empty-inner { position: relative; z-index: 1; }
376 .fork-empty-title {
377 font-family: var(--font-display);
378 font-size: 18px;
379 font-weight: 700;
380 letter-spacing: -0.014em;
381 color: var(--text-strong);
382 margin: 0 0 8px;
383 }
384 .fork-empty-desc {
385 font-size: 14px;
386 color: var(--text-muted);
387 line-height: 1.55;
388 margin: 0 0 var(--space-3);
389 }
390`;
391
392// ─── GET — confirmation form ───────────────────────────────────────────────
393// Preserves the existing POST contract; this just gives users a polished
394// confirmation surface before firing. The existing inline button on the
395// repo header is untouched and still posts straight through.
396fork.get("/:owner/:repo/fork", requireAuth, async (c) => {
397 const { owner: ownerName, repo: repoName } = c.req.param();
398 const user = c.get("user")!;
399
400 // Resolve source repo for the card
401 const [sourceOwner] = await db
402 .select()
403 .from(users)
404 .where(eq(users.username, ownerName))
405 .limit(1);
406
407 const sourceRepo = sourceOwner
408 ? (
409 await db
410 .select()
411 .from(repositories)
412 .where(
413 and(
414 eq(repositories.ownerId, sourceOwner.id),
415 eq(repositories.name, repoName)
416 )
417 )
418 .limit(1)
419 )[0]
420 : null;
421
422 // Zero state — invalid source
423 if (!sourceOwner || !sourceRepo || !(await repoExists(ownerName, repoName))) {
424 return c.html(
425 <Layout title="Fork repository" user={user}>
426 <style dangerouslySetInnerHTML={{ __html: forkStyles }} />
427 <div class="fork-wrap">
428 <div class="fork-empty">
429 <div class="fork-empty-orb" aria-hidden="true" />
430 <div class="fork-empty-inner">
431 <h1 class="fork-empty-title">
432 {ownerName}/{repoName} can't be forked
433 </h1>
434 <p class="fork-empty-desc">
435 Either the repository doesn't exist or it's no longer
436 available. Try browsing repositories you can fork.
437 </p>
438 <a href="/explore" class="fork-submit">Explore repositories</a>
439 </div>
440 </div>
441 </div>
442 </Layout>,
443 404
444 );
445 }
446
447 // Owner forking own repo
448 const isSelf = ownerName === user.username;
449 // Already forked
450 const alreadyForked = !isSelf && (await repoExists(user.username, repoName));
451
452 const avatarLetter = (user.username || "?").charAt(0).toUpperCase();
453
454 return c.html(
455 <Layout
456 title={`Fork ${ownerName}/${repoName}`}
457 user={user}
458 >
459 <style dangerouslySetInnerHTML={{ __html: forkStyles }} />
460 <div class="fork-wrap">
461 {/* ─── Hero ─── */}
462 <div class="fork-hero">
463 <div class="fork-hero-orb" aria-hidden="true" />
464 <div class="fork-hero-inner">
465 <div class="fork-eyebrow">
466 <span class="fork-eyebrow-dot" aria-hidden="true" />
467 <strong>Fork</strong> · copy to your account
468 </div>
469 <h1 class="fork-title">
470 Fork{" "}
471 <span class="fork-title-grad">{ownerName}/{repoName}</span>.
472 </h1>
473 <p class="fork-sub">
474 We'll make a private copy under <strong>{user.username}</strong>.
475 All branches and history carry over; gates, AI review, and
476 auto-merge are wired up the moment the fork is ready.
477 </p>
478 </div>
479 </div>
480
481 {/* ─── Source repo card ─── */}
482 <div class="fork-source-card">
483 <div class="fork-source-icon" aria-hidden="true">
484 {(sourceRepo.name || "?").charAt(0).toUpperCase()}
485 </div>
486 <div class="fork-source-body">
487 <div class="fork-source-eyebrow">Source</div>
488 <a
489 class="fork-source-name"
490 href={`/${ownerName}/${repoName}`}
491 >
492 {ownerName}/{repoName}
493 </a>
494 {sourceRepo.description && (
495 <p class="fork-source-desc" title={sourceRepo.description}>
496 {sourceRepo.description}
497 </p>
498 )}
499 </div>
500 </div>
501
502 {/* ─── Conflict banners ─── */}
503 {isSelf && (
504 <div class="fork-banner fork-banner-warn" role="alert">
505 You can't fork your own repository. Browse{" "}
506 <a href="/explore">other repositories</a> instead.
507 </div>
508 )}
509 {alreadyForked && (
510 <div class="fork-banner fork-banner-warn" role="alert">
511 You already have a fork at{" "}
512 <a href={`/${user.username}/${repoName}`}>
513 {user.username}/{repoName}
514 </a>
515 .
516 </div>
517 )}
518
519 {/* ─── Destination + submit ─── */}
520 <div class="fork-form-card">
521 <div class="fork-form-head">
522 <span class="fork-form-badge" aria-hidden="true">1</span>
523 <h2 class="fork-form-title">Destination</h2>
524 </div>
525 <p class="fork-form-sub">
526 v1 forks always land under your personal account. Org
527 destinations coming soon.
528 </p>
529 <div class="fork-field">
530 <span class="fork-field-label">Owner</span>
531 <div class="fork-dest-row">
532 <span class="fork-dest-avatar" aria-hidden="true">
533 {avatarLetter}
534 </span>
535 <span class="fork-dest-name">{user.username}</span>
536 <span class="fork-dest-slash" aria-hidden="true">/</span>
537 <span class="fork-dest-repo">{repoName}</span>
538 </div>
539 <p class="fork-dest-hint">
540 The fork will live at{" "}
541 <strong>{user.username}/{repoName}</strong>.
542 </p>
543 </div>
544
545 <form
546 method="post"
547 action={`/${ownerName}/${repoName}/fork`}
548 >
549 <div class="fork-actions">
550 <button
551 type="submit"
552 class="fork-submit"
553 disabled={isSelf || alreadyForked}
554 >
555 {alreadyForked ? "Already forked" : "Create my fork"}
556 </button>
557 <a href={`/${ownerName}/${repoName}`} class="fork-cancel">
558 Cancel
559 </a>
560 </div>
561 </form>
562 </div>
563 </div>
564 </Layout>
565 );
566});
567
568// Fork a repository
569fork.post("/:owner/:repo/fork", requireAuth, async (c) => {
570 const { owner: ownerName, repo: repoName } = c.req.param();
571 const user = c.get("user")!;
572
573 // Can't fork your own repo
574 if (ownerName === user.username) {
575 return c.redirect(`/${ownerName}/${repoName}`);
576 }
577
578 // Check source exists
579 if (!(await repoExists(ownerName, repoName))) {
580 return c.redirect(`/${ownerName}/${repoName}`);
581 }
582
583 // Check if already forked
584 if (await repoExists(user.username, repoName)) {
585 return c.redirect(`/${user.username}/${repoName}`);
586 }
587
588 // Get source repo from DB
589 const [sourceOwner] = await db
590 .select()
591 .from(users)
592 .where(eq(users.username, ownerName))
593 .limit(1);
594 if (!sourceOwner) return c.redirect(`/${ownerName}/${repoName}`);
595
596 const [sourceRepo] = await db
597 .select()
598 .from(repositories)
599 .where(
600 and(
601 eq(repositories.ownerId, sourceOwner.id),
602 eq(repositories.name, repoName)
603 )
604 )
605 .limit(1);
606 if (!sourceRepo) return c.redirect(`/${ownerName}/${repoName}`);
607
a1beefbccantynz-alt608 // Nothing above this point consulted sourceRepo.isPrivate: the only gates
609 // were requireAuth and repoExists(), and repoExists is a bare filesystem
610 // probe that never reads the repositories row. Any logged-in user could
611 // therefore fork a private repo they had no read access to — and, because
612 // the insert below hardcoded isPrivate: false, the clone landed as a PUBLIC
613 // repository. One POST turned someone else's private source public.
614 const access = await resolveRepoAccess({
615 repoId: sourceRepo.id,
616 userId: user.id,
617 isPublic: !sourceRepo.isPrivate,
618 });
619 if (access === "none") {
620 // 404-equivalent: redirect exactly as a non-existent repo would, so this
621 // does not become an oracle for which private repos exist.
622 return c.redirect(`/${ownerName}/${repoName}`);
623 }
624
398a10cClaude625 // Clone the bare repo
626 const sourcePath = getRepoPath(ownerName, repoName);
627 const destPath = join(config.gitReposPath, user.username, `${repoName}.git`);
628
629 const proc = Bun.spawn(["git", "clone", "--bare", sourcePath, destPath], {
630 stdout: "pipe",
631 stderr: "pipe",
632 });
633 await proc.exited;
634
635 // Insert into DB
636 const [newRepo] = await db
637 .insert(repositories)
638 .values({
639 name: repoName,
640 ownerId: user.id,
641 description: sourceRepo.description
642 ? `Fork of ${ownerName}/${repoName} — ${sourceRepo.description}`
643 : `Fork of ${ownerName}/${repoName}`,
a1beefbccantynz-alt644 // Inherit the source's visibility. Hardcoding false meant a fork of a
645 // private repo was published; GitHub likewise keeps a private repo's
646 // fork private.
647 isPrivate: sourceRepo.isPrivate,
398a10cClaude648 defaultBranch: sourceRepo.defaultBranch,
649 diskPath: destPath,
650 forkedFromId: sourceRepo.id,
651 })
652 .returning();
653
654 // Bootstrap the fork with green-by-default settings, protection, labels
655 if (newRepo) {
656 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
657 await bootstrapRepository({
658 repositoryId: newRepo.id,
659 ownerUserId: user.id,
660 defaultBranch: sourceRepo.defaultBranch,
661 skipWelcomeIssue: true, // forks don't need a welcome issue
662 });
663 }
664
665 // Update fork count
666 await db
667 .update(repositories)
668 .set({ forkCount: sourceRepo.forkCount + 1 })
669 .where(eq(repositories.id, sourceRepo.id));
670
671 // Log activity
672 try {
673 await db.insert(activityFeed).values({
674 repositoryId: sourceRepo.id,
675 userId: user.id,
676 action: "fork",
677 metadata: JSON.stringify({ forkOwner: user.username }),
678 });
679 } catch {
680 // best effort
681 }
682
683 return c.redirect(`/${user.username}/${repoName}`);
684});
685
686export default fork;