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.

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