Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

177 linesCodeBlameRaw
1/**
2 * Forking a repository into one of the viewer's workspaces: where it goes,
3 * its name and description, and whether only the default branch is
4 * copied. The fork keeps a link to where it came from, for syncing and for
5 * pull requests back to it.
6 */
7import { GitFork } from "lucide-react";
8import { useState } from "react";
9import { Form, redirect } from "react-router";
10
11import { DEFAULT_MEMBER_PRIVILEGES } from "@g1t/contracts";
12
13import type { Route } from "./+types/fork";
14import { ErrorText, SubmitButton } from "../../components/ui";
15import { CheckboxOption } from "../../components/ui/checkbox";
16import { Field, FieldDescription, FieldLabel } from "../../components/ui/field";
17import { Input, InputAddon, InputGroup } from "../../components/ui/input";
18import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
19import { requireRepo } from "../../lib/access.server";
20import { page } from "../../lib/meta";
21import { tidyName } from "../../lib/repo-lifecycle";
22import { repos } from "../../lib/services.server";
23import { assertSameOrigin, requireUser } from "../../lib/session.server";
24
25export function meta({ params, ...args }: Route.MetaArgs) {
26 return page(args, { title: `Fork · ${params.owner}/${params.repo} · g1t` });
27}
28
29export async function loader({ request, params, context }: Route.LoaderArgs) {
30 const user = requireUser(context, request);
31 const { repo } = await requireRepo(context, params, "read");
32 // The workspaces where the person may make a repository as visible as
33 // this one: a fork is as visible as its source.
34 const destinations = (user.workspaces ?? [])
35 .filter((m) => m.code_access !== false)
36 .filter((m) => {
37 if (m.role === "owner") return true;
38 const privileges = { ...DEFAULT_MEMBER_PRIVILEGES, ...(m.privileges ?? {}) };
39 return repo.isPrivate ? privileges.members_can_create_private_repositories : privileges.members_can_create_public_repositories;
40 })
41 .map((m) => ({ slug: m.slug, name: m.name?.trim() || m.slug }));
42 // Another workspace first: forking into the same one needs another name.
43 const own = repo.namespace.toLowerCase();
44 const selected = destinations.find((d) => d.slug !== own)?.slug ?? destinations[0]?.slug ?? "";
45 return {
46 source: {
47 namespace: repo.namespace,
48 name: repo.name,
49 isPrivate: repo.isPrivate,
50 defaultBranch: repo.defaultBranch,
51 forkable: !repo.isPrivate || Boolean(repo.allowForking),
52 },
53 destinations,
54 selected,
55 };
56}
57
58export async function action({ request, params, context }: Route.ActionArgs) {
59 assertSameOrigin(request);
60 const user = requireUser(context, request);
61 const form = await request.formData();
62 const text = (name: string) => String(form.get(name) ?? "").trim();
63 const result = await repos.fork(user, { namespace: params.owner, name: params.repo }, {
64 namespace: text("workspace"),
65 name: tidyName(text("name")) || undefined,
66 defaultBranchOnly: form.get("defaultBranchOnly") != null,
67 });
68 if (!result.ok) return { error: result.error.message };
69 throw redirect(`/${result.value.namespace}/${result.value.name}`);
70}
71
72export default function Fork({ loaderData, actionData }: Route.ComponentProps) {
73 const { source, destinations, selected } = loaderData;
74 const full = `${source.namespace}/${source.name}`;
75 const [workspace, setWorkspace] = useState(selected);
76 const [name, setName] = useState(source.name);
77 const wanted = tidyName(name) || source.name;
78 const onto = workspace.toLowerCase() === source.namespace.toLowerCase() && wanted === source.name.toLowerCase();
79 return (
80 <div className="max-w-2xl">
81 <div className="flex items-start gap-3">
82 <span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/30">
83 <GitFork size={18} />
84 </span>
85 <div className="min-w-0">
86 <h2 className="text-lg font-semibold tracking-tight">Fork {full}</h2>
87 <p className="mt-1 text-sm text-muted">
88 A copy of its code in a workspace of yours, to change as you like. It remembers where it came from, so you can
89 bring in what lands there and open pull requests back to it.
90 </p>
91 </div>
92 </div>
93
94 {!source.forkable ? (
95 <p className="mt-8 text-sm text-muted">
96 {full} is private, and its admins have not allowed forking it. They can in its settings.
97 </p>
98 ) : destinations.length === 0 ? (
99 <p className="mt-8 text-sm text-muted">
100 None of your workspaces lets you make a {source.isPrivate ? "private" : "public"} repository, so there is nowhere
101 to fork it. Ask an owner of one to allow it in its member privileges, or create a workspace of your own.
102 </p>
103 ) : (
104 <Form method="post" className="mt-8 space-y-6">
105 <Field>
106 <FieldLabel htmlFor="fork-name">Where and what to call it</FieldLabel>
107 <InputGroup className="h-10">
108 <Select name="workspace" value={workspace} onValueChange={setWorkspace}>
109 <SelectTrigger
110 aria-label="Workspace"
111 className="h-full w-auto max-w-[55%] shrink-0 rounded-none border-0 border-r border-line bg-surface font-medium shadow-none focus-visible:ring-0 data-[state=open]:ring-0 sm:max-w-[45%]"
112 >
113 <SelectValue />
114 </SelectTrigger>
115 <SelectContent>
116 {destinations.map((d) => (
117 <SelectItem key={d.slug} value={d.slug}>
118 {d.name === d.slug ? d.slug : `${d.name} (${d.slug})`}
119 </SelectItem>
120 ))}
121 </SelectContent>
122 </Select>
123 <InputAddon className="px-2 font-mono text-base text-faint">/</InputAddon>
124 <Input
125 id="fork-name"
126 name="name"
127 required
128 maxLength={100}
129 spellCheck={false}
130 autoCapitalize="off"
131 value={name}
132 onChange={(event) => setName(event.target.value)}
133 aria-invalid={onto || undefined}
134 className="pl-1 font-mono"
135 />
136 </InputGroup>
137 <FieldDescription>
138 {onto ? (
139 <span className="text-danger">That is {full} itself. Choose another name or workspace.</span>
140 ) : (
141 <>
142 At{" "}
143 <span className="font-mono text-muted">
144 g1t.sh/{workspace}/<span className="text-fg">{wanted}</span>
145 </span>
146 . It is {source.isPrivate ? "private" : "public"}, as {full} is.
147 </>
148 )}
149 </FieldDescription>
150 </Field>
151
152 <CheckboxOption
153 name="defaultBranchOnly"
154 defaultChecked
155 label={
156 <>
157 Copy the <span className="font-mono">{source.defaultBranch}</span> branch only
158 </>
159 }
160 description="Other branches and tags are not copied. Leave it off to copy every branch and tag."
161 />
162
163 <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">
164 <li>Its code and description are copied. Its settings, secrets, variables, rules, webhooks, deployments, issues and pull requests are not.</li>
165 <li>Workflows do not run in the fork until you turn Actions on, and Issues start off.</li>
166 <li>Its size counts toward the storage of the workspace it goes in.{source.isPrivate ? " A free workspace may not have room for it." : ""}</li>
167 </ul>
168
169 <ErrorText>{actionData?.error}</ErrorText>
170 <SubmitButton variant="accent" pending="Forking…" disabled={onto || !workspace}>
171 Create fork
172 </SubmitButton>
173 </Form>
174 )}
175 </div>
176 );
177}