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

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