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.

186 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 description: repo.description ?? "",
50 isPrivate: repo.isPrivate,
51 defaultBranch: repo.defaultBranch,
52 forkable: !repo.isPrivate || Boolean(repo.allowForking),
53 },
54 destinations,
55 selected,
56 };
57}
58
59export async function action({ request, params, context }: Route.ActionArgs) {
60 assertSameOrigin(request);
61 const user = requireUser(context, request);
62 const form = await request.formData();
63 const text = (name: string) => String(form.get(name) ?? "").trim();
64 const result = await repos.fork(user, { namespace: params.owner, name: params.repo }, {
65 namespace: text("workspace"),
66 name: tidyName(text("name")) || undefined,
67 description: String(form.get("description") ?? ""),
68 defaultBranchOnly: form.get("defaultBranchOnly") != null,
69 });
70 if (!result.ok) return { error: result.error.message };
71 throw redirect(`/${result.value.namespace}/${result.value.name}`);
72}
73
74export default function Fork({ loaderData, actionData }: Route.ComponentProps) {
75 const { source, destinations, selected } = loaderData;
76 const full = `${source.namespace}/${source.name}`;
77 const [workspace, setWorkspace] = useState(selected);
78 const [name, setName] = useState(source.name);
79 const wanted = tidyName(name) || source.name;
80 const onto = workspace.toLowerCase() === source.namespace.toLowerCase() && wanted === source.name.toLowerCase();
81 return (
82 <div className="max-w-2xl">
83 <div className="flex items-start gap-3">
84 <span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/30">
85 <GitFork size={18} />
86 </span>
87 <div className="min-w-0">
88 <h2 className="text-lg font-semibold tracking-tight">Fork {full}</h2>
89 <p className="mt-1 text-sm text-muted">
90 A copy of its code in a workspace of yours, to change as you like. It remembers where it came from, so you can
91 bring in what lands there and open pull requests back to it.
92 </p>
93 </div>
94 </div>
95
96 {!source.forkable ? (
97 <p className="mt-8 text-sm text-muted">
98 {full} is private, and its admins have not allowed forking it. They can in its settings.
99 </p>
100 ) : destinations.length === 0 ? (
101 <p className="mt-8 text-sm text-muted">
102 None of your workspaces lets you make a {source.isPrivate ? "private" : "public"} repository, so there is nowhere
103 to fork it. Ask an owner of one to allow it in its member privileges, or create a workspace of your own.
104 </p>
105 ) : (
106 <Form method="post" className="mt-8 space-y-6">
107 <Field>
108 <FieldLabel htmlFor="fork-name">Where and what to call it</FieldLabel>
109 <InputGroup className="h-10">
110 <Select name="workspace" value={workspace} onValueChange={setWorkspace}>
111 <SelectTrigger
112 aria-label="Workspace"
113 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%]"
114 >
115 <SelectValue />
116 </SelectTrigger>
117 <SelectContent>
118 {destinations.map((d) => (
119 <SelectItem key={d.slug} value={d.slug}>
120 {d.name === d.slug ? d.slug : `${d.name} (${d.slug})`}
121 </SelectItem>
122 ))}
123 </SelectContent>
124 </Select>
125 <InputAddon className="px-2 font-mono text-base text-faint">/</InputAddon>
126 <Input
127 id="fork-name"
128 name="name"
129 required
130 maxLength={100}
131 spellCheck={false}
132 autoCapitalize="off"
133 value={name}
134 onChange={(event) => setName(event.target.value)}
135 aria-invalid={onto || undefined}
136 className="pl-1 font-mono"
137 />
138 </InputGroup>
139 <FieldDescription>
140 {onto ? (
141 <span className="text-danger">That is {full} itself. Choose another name or workspace.</span>
142 ) : (
143 <>
144 At{" "}
145 <span className="font-mono text-muted">
146 g1t.sh/{workspace}/<span className="text-fg">{wanted}</span>
147 </span>
148 . It is {source.isPrivate ? "private" : "public"}, as {full} is.
149 </>
150 )}
151 </FieldDescription>
152 </Field>
153
154 <Field>
155 <FieldLabel htmlFor="fork-description">
156 Description <span className="font-normal text-faint">(optional)</span>
157 </FieldLabel>
158 <Input id="fork-description" name="description" maxLength={200} defaultValue={source.description} placeholder="What it is, in a line" />
159 </Field>
160
161 <CheckboxOption
162 name="defaultBranchOnly"
163 defaultChecked
164 label={
165 <>
166 Copy the <span className="font-mono">{source.defaultBranch}</span> branch only
167 </>
168 }
169 description="Other branches and tags are not copied. Leave it off to copy every branch and tag."
170 />
171
172 <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">
173 <li>Its code and description are copied. Its settings, secrets, variables, rules, webhooks, deployments, issues and pull requests are not.</li>
174 <li>Workflows do not run in the fork until you turn Actions on, and Issues start off.</li>
175 <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>
176 </ul>
177
178 <ErrorText>{actionData?.error}</ErrorText>
179 <SubmitButton variant="accent" pending="Forking…" disabled={onto || !workspace}>
180 Create fork
181 </SubmitButton>
182 </Form>
183 )}
184 </div>
185 );
186}