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.

300 linesCodeBlameRaw
1import { Search, X } from "lucide-react";
2import { useEffect, useMemo, useState } from "react";
3import { Form, Link, redirect, useNavigate } from "react-router";
4
5import type { Route } from "./+types/branches";
6import type { ActiveBranch } from "../../components/branches";
7import { type BranchActionData, type BranchListContext, type BranchRowData, BranchTable, NewBranchDialog, ProtectCallout } from "../../components/branch-list";
8import { EmptyState } from "../../components/ui";
9import { Alert } from "../../components/ui/alert";
10import { Button } from "../../components/ui/button";
11import { Input } from "../../components/ui/input";
12import { Tabs, TabsList, TabsTrigger } from "../../components/ui/tabs";
13import { refusal, requireRepo } from "../../lib/access.server";
14import { type BranchTab, branchSections, driftScale } from "../../lib/branches";
15import { notFound } from "../../lib/not-found.server";
16import { branchSegment, branchesPath, movedTab } from "../../lib/tab-paths";
17import { readBranches } from "../../lib/branches.server";
18import { repoPages } from "../../lib/repo-live";
19import { useRepoLive } from "../../lib/use-repo-live";
20import { commitChecksFor } from "../../lib/commit-checks.server";
21import { page } from "../../lib/meta";
22import { isFailed, orElse, part } from "../../lib/partial";
23import { Unavailable } from "../../components/boundary";
24import { deployments, repos, work } from "../../lib/services.server";
25import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
26
27/** The most branches read for one page; past that, search narrows them. */
28const BRANCHES_READ = 60;
29
30const TAB_NAMES: Record<BranchTab, string> = { overview: "Overview", yours: "Yours", active: "Active", stale: "Stale", all: "All" };
31
32export function meta({ params, ...args }: Route.MetaArgs) {
33 return page(args, { title: `Branches · ${params.owner}/${params.repo} · g1t` });
34}
35
36export async function loader({ params, context, request }: Route.LoaderArgs) {
37 const url = new URL(request.url);
38 // Its tabs are paths: `?tab=stale`, the address before, is `/branches/stale`.
39 const moved = movedTab(url, (asked) => branchesPath(`/${params.owner}/${params.repo}`, asked));
40 if (moved) throw redirect(moved, 301);
41 const tab: BranchTab | null = branchSegment(params.tab);
42 if (!tab) throw notFound("page");
43 if (params.tab != null && params.tab !== tab) throw redirect(`${branchesPath(`/${params.owner}/${params.repo}`, tab)}${url.search}`);
44 const { viewer, repo, access } = await requireRepo(context, params, "read");
45 const path = { namespace: params.owner, name: params.repo };
46 const query = url.searchParams.get("q")?.trim().toLowerCase() || null;
47 // Admins are told when the default branch has no rules at all.
48 const canProtect = access.can.manage_protection && !repo.archivedAt;
49 const [list, open, closed, deploys, rules] = await Promise.all([
50 repos.branches(path, viewer),
51 part(work.listPulls(path, viewer, "open"), "Open pull requests"),
52 // Merged and closed ones, for branches with none open.
53 part(work.listPulls(path, viewer, "closed"), "Merged and closed pull requests"),
54 // Previews are for people with a role here; everyone else sees none.
55 part(deployments.list({ workspace: params.owner, slug: params.repo }, viewer), "Previews"),
56 // Only a hint for admins: without the rules, it is not given.
57 canProtect && !repo.protected
58 ? part(work.effectiveRules(path, repo.defaultBranch, viewer), "Rules").then((found) => orElse(found, null))
59 : Promise.resolve(null),
60 ]);
61 // The merge queue's own branches (g1t-queue/<entry>) are its working
62 // copies, not anyone's branch to look at.
63 const all = unwrap(list).filter((branch) => !branch.name.startsWith("g1t-queue/"));
64 const matching = query ? all.filter((branch) => branch.name === repo.defaultBranch || branch.name.toLowerCase().includes(query)) : all;
65 const read = await readBranches(
66 path,
67 viewer,
68 {
69 defaultBranch: repo.defaultBranch,
70 branches: matching,
71 pulls: [...(!isFailed(open) && open.ok ? open.value : []), ...(!isFailed(closed) && closed.ok ? closed.value : [])],
72 previews: !isFailed(deploys) && deploys.ok ? deploys.value.live.filter((app) => app.kind === "preview") : [],
73 },
74 BRANCHES_READ,
75 );
76 const mainHead = all.find((branch) => branch.name === repo.defaultBranch)?.hash ?? "";
77 // Each branch head's checks, in one call, streamed in beside it.
78 const checks = commitChecksFor(path, viewer, [mainHead, ...read.shown.map((branch) => branch.head)]);
79 const main: ActiveBranch = { name: repo.defaultBranch, head: mainHead, commit: read.head, drift: null, pull: null, preview: null };
80 const canChange = access.can.push && !repo.archivedAt;
81 return {
82 tab,
83 query,
84 main,
85 branches: read.shown,
86 // Every branch but the default, and how many matched the search.
87 total: all.length - 1,
88 matched: read.total,
89 unread: Math.max(0, read.total - read.shown.length),
90 names: [repo.defaultBranch, ...all.map((branch) => branch.name).filter((name) => name !== repo.defaultBranch)],
91 checks,
92 username: viewer?.username ?? null,
93 now: Date.now(),
94 can: {
95 push: canChange,
96 renameDefault: canChange && access.can.administer,
97 viewRules: access.insider && access.can.manage_protection,
98 },
99 // No rules hold for it, and it is not protected the older way.
100 unprotected: rules?.ok === true && rules.value.rulesets.length === 0,
101 // What each row would show beside its branch but could not be loaded.
102 missing: [open, closed, deploys].filter(isFailed).map((failed) => failed.what),
103 };
104}
105
106export async function action({ request, params, context }: Route.ActionArgs): Promise<BranchActionData> {
107 assertSameOrigin(request);
108 const user = requireUser(context, request);
109 const form = await request.formData();
110 const text = (key: string) => String(form.get(key) ?? "").trim();
111 const intent = text("intent");
112 const branch = text("branch");
113 // Making, deleting and renaming branches is pushing; the service also
114 // asks for Admin to rename the default branch, and holds the rules.
115 const refused = await refusal(context, params, "push");
116 if (refused) return { intent, branch, error: refused };
117 const path = { namespace: params.owner, name: params.repo };
118 switch (intent) {
119 case "create":
120 case "restore": {
121 // A restore makes the branch again at the commit it pointed to.
122 const made = await repos.createBranch(user, path, branch, intent === "restore" ? text("head") : text("from"));
123 return made.ok ? { intent, branch: made.value.name, hash: made.value.hash } : { intent, branch, error: made.error.message };
124 }
125 case "delete": {
126 const removed = await repos.removeBranch(user, path, branch, text("head") || undefined);
127 return removed.ok ? { intent, branch, hash: removed.value.hash } : { intent, branch, error: removed.error.message };
128 }
129 case "rename": {
130 const renamed = await repos.renameBranch(user, path, branch, text("to"));
131 return renamed.ok ? { intent, branch: text("to") } : { intent, branch, error: renamed.error.message };
132 }
133 default:
134 return { intent, error: "Nothing to do." };
135 }
136}
137
138export default function Branches({ loaderData, params }: Route.ComponentProps) {
139 const { tab, query, main, branches, total, matched, unread, names, checks, username, now, can, unprotected, missing } = loaderData;
140 const base = `/${params.owner}/${params.repo}`;
141 const navigate = useNavigate();
142 // New, moved and deleted branches show as they happen (lib/use-repo-live.ts).
143 useRepoLive({ match: repoPages.refs() });
144 // Branches deleted on this visit, kept to restore until the page is left.
145 const [deleted, setDeleted] = useState<Record<string, ActiveBranch>>({});
146 const [notice, setNotice] = useState<{ tone: "danger" | "success"; text: string } | null>(null);
147
148 const rows = useMemo<BranchRowData[]>(() => {
149 const here = new Set(branches.map((branch) => branch.name));
150 const gone = Object.values(deleted)
151 .filter((branch) => !here.has(branch.name))
152 .map((branch) => ({ ...branch, deleted: true }));
153 return [...branches, ...gone].sort((a, b) => Date.parse(b.commit?.at ?? "0") - Date.parse(a.commit?.at ?? "0"));
154 }, [branches, deleted]);
155 // A restored branch is back in the list: no longer kept as deleted.
156 useEffect(() => {
157 const here = new Set(branches.map((branch) => branch.name));
158 setDeleted((kept) =>
159 Object.keys(kept).some((name) => here.has(name)) ? Object.fromEntries(Object.entries(kept).filter(([name]) => !here.has(name))) : kept,
160 );
161 }, [branches]);
162 const sections = branchSections(rows, tab, { username, now });
163
164 const context: BranchListContext = {
165 base,
166 main: main.name,
167 checks,
168 scale: driftScale(rows),
169 canPush: can.push,
170 canRenameDefault: can.renameDefault,
171 canViewRules: can.viewRules,
172 onDeleted: (branch) => {
173 setNotice(null);
174 setDeleted((kept) => ({ ...kept, [branch.name]: branch }));
175 },
176 onRestored: (name) => setNotice({ tone: "success", text: `Restored ${name}.` }),
177 onError: (text) => setNotice({ tone: "danger", text }),
178 };
179
180 const tabTo = (next: string, keepQuery = true) => {
181 const search = new URLSearchParams();
182 if (query && keepQuery) search.set("q", query);
183 const qs = search.toString();
184 return `${branchesPath(base, next)}${qs ? `?${qs}` : ""}`;
185 };
186
187 return (
188 <div className="space-y-5">
189 <div className="flex flex-wrap items-center justify-between gap-3">
190 <h2 className="text-lg font-semibold tracking-tight">
191 Branches <span className="font-normal text-faint">{total + 1}</span>
192 </h2>
193 {can.push && <NewBranchDialog branches={names} main={main.name} onMade={(name) => setNotice({ tone: "success", text: `Created ${name}.` })} />}
194 </div>
195
196 {unprotected && (
197 <ProtectCallout base={base} main={main.name} dismissKey={`g1t:protect-hint-dismissed:${username ?? ""}:${base.slice(1).toLowerCase()}`} />
198 )}
199
200 <div className="flex flex-wrap items-center justify-between gap-3">
201 <div className="max-w-full overflow-x-auto">
202 <Tabs value={tab} onValueChange={(next) => navigate(tabTo(next))}>
203 <TabsList aria-label="Which branches">
204 {(Object.keys(TAB_NAMES) as BranchTab[]).map((name) => (
205 <TabsTrigger key={name} value={name}>
206 {TAB_NAMES[name]}
207 </TabsTrigger>
208 ))}
209 </TabsList>
210 </Tabs>
211 </div>
212 <Form method="get" action={branchesPath(base, tab)} role="search" className="relative w-full sm:w-72">
213 <Search size={14} className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-faint" />
214 <Input name="q" defaultValue={query ?? ""} placeholder="Search branches" aria-label="Search branches" className="h-8 pl-8" />
215 </Form>
216 </div>
217
218 {notice && (
219 <Alert tone={notice.tone} role="alert" className="flex items-start justify-between gap-3">
220 <span className="min-w-0">{notice.text}</span>
221 <Button type="button" variant="ghost" size="inline" aria-label="Dismiss" className="p-0.5 text-current" onClick={() => setNotice(null)}>
222 <X size={14} />
223 </Button>
224 </Alert>
225 )}
226
227 {query && (
228 <p className="text-sm text-muted">
229 {matched} of {total} {total === 1 ? "branch matches" : "branches match"} “{query}”.{" "}
230 <Link to={tabTo(tab, false)} className="text-fg hover:underline">
231 Clear
232 </Link>
233 </p>
234 )}
235
236 {/* The branches show without them; each row's pull request or preview would be missing without a word. */}
237 {missing.length > 0 && (
238 <div className="space-y-1">
239 {missing.map((what) => (
240 <Unavailable key={what} compact what={what} />
241 ))}
242 </div>
243 )}
244
245 {tab === "overview" && (
246 <section aria-labelledby="branches-default" className="space-y-2">
247 <h3 id="branches-default" className="text-sm font-medium text-muted">
248 Default
249 </h3>
250 <BranchTable rows={[main]} context={context} label="Default branch" />
251 </section>
252 )}
253
254 {sections.map((section) =>
255 section.branches.length === 0 && tab === "overview" ? null : (
256 <section key={section.tab} aria-labelledby={`branches-${section.tab}`} className="space-y-2">
257 <div className="flex items-baseline justify-between gap-3">
258 <h3 id={`branches-${section.tab}`} className="text-sm font-medium text-muted">
259 {section.title}
260 {section.note && <span className="ml-2 text-xs font-normal text-faint">{section.note}</span>}
261 </h3>
262 {section.more > 0 && (
263 <Link to={tabTo(section.tab)} className="shrink-0 text-xs text-muted hover:text-fg">
264 View {section.more} more
265 </Link>
266 )}
267 </div>
268 {section.branches.length > 0 ? (
269 <BranchTable rows={section.branches} context={context} label={section.title} />
270 ) : (
271 <EmptyState title={query ? "No branch matches" : `No ${section.title.toLowerCase()}`}>
272 {query
273 ? "Try another part of the name."
274 : section.tab === "yours"
275 ? "Branches whose latest commit is yours show here."
276 : section.tab === "stale"
277 ? "Branches with no commits in the last 90 days show here."
278 : "Branches agents and people push show here, each with its pull request, checks and how far it has moved from the default branch."}
279 </EmptyState>
280 )}
281 </section>
282 ),
283 )}
284
285 {tab === "overview" && rows.length === 0 && (
286 <EmptyState title={query ? "No branch matches" : "Only the default branch"}>
287 {query
288 ? "Try another part of the name."
289 : "Branches agents and people push show here, each with its pull request, checks and how far it has moved from the default branch."}
290 </EmptyState>
291 )}
292
293 {unread > 0 && (
294 <p className="text-xs text-faint">
295 {unread} more {unread === 1 ? "branch is" : "branches are"} not shown. Search to find one by name.
296 </p>
297 )}
298 </div>
299 );
300}