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.

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