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.

160 linesCodeBlameRaw
1import { GitBranch, Search } from "lucide-react";
2import { Form, Link } from "react-router";
3
4import type { Route } from "./+types/branches";
5import { ActiveBranches } from "../../components/branches";
6import { CommitAvatars, CommitNames } from "../../components/commit-person";
7import { EmptyState, TimeAgo, notACredential } from "../../components/ui";
8import { Badge } from "../../components/ui/badge";
9import { Card } from "../../components/ui/card";
10import { Hint } from "../../components/ui/hint";
11import { readBranches } from "../../lib/branches.server";
12import { commitChecksFor } from "../../lib/commit-checks.server";
13import { CommitChecksBadge } from "../../components/commit-checks";
14import { page } from "../../lib/meta";
15import { deployments, repos, work } from "../../lib/services.server";
16import { getViewer, unwrap } from "../../lib/session.server";
17
18/** The most branches read for one page; past that, search narrows them. */
19const BRANCHES_READ = 60;
20/** A branch whose last commit is older than this is stale. */
21const STALE_DAYS = 90;
22
23export function meta({ params, ...args }: Route.MetaArgs) {
24 return page(args, { title: `Branches · ${params.owner}/${params.repo} · g1t` });
25}
26
27export async function loader({ params, context, request }: Route.LoaderArgs) {
28 const path = { namespace: params.owner, name: params.repo };
29 const viewer = getViewer(context);
30 const query = new URL(request.url).searchParams.get("q")?.trim().toLowerCase() || null;
31 const soft = <T,>(promise: Promise<T>): Promise<T | null> => promise.catch(() => null);
32 const [repo, list, pulls, deploys] = await Promise.all([
33 repos.get(path, viewer),
34 repos.branches(path, viewer),
35 soft(work.listPulls(path, viewer, "open")),
36 // Previews are for people with a role here; everyone else sees none.
37 soft(deployments.list({ workspace: params.owner, slug: params.repo }, viewer)),
38 ]);
39 const found = unwrap(repo);
40 // The merge queue's own branches (g1t-queue/<entry>) are its working
41 // copies, not anyone's branch to look at.
42 const all = unwrap(list).filter((branch) => !branch.name.startsWith("g1t-queue/"));
43 const matching = query ? all.filter((branch) => branch.name === found.defaultBranch || branch.name.toLowerCase().includes(query)) : all;
44 const read = await readBranches(
45 path,
46 viewer,
47 {
48 defaultBranch: found.defaultBranch,
49 branches: matching,
50 pulls: pulls?.ok ? pulls.value : [],
51 previews: deploys?.ok ? deploys.value.live.filter((app) => app.kind === "preview") : [],
52 },
53 BRANCHES_READ,
54 );
55 // Each branch head's checks, in one call, streamed in beside it.
56 const checks = commitChecksFor(path, viewer, [read.head?.hash, ...read.shown.map((branch) => branch.commit?.hash)]);
57 const staleBefore = Date.now() - STALE_DAYS * 86_400_000;
58 const isStale = (at: string | undefined) => at != null && Date.parse(at) < staleBefore;
59 return {
60 main: found.defaultBranch,
61 head: read.head,
62 active: read.shown.filter((branch) => !isStale(branch.commit?.at)),
63 stale: read.shown.filter((branch) => isStale(branch.commit?.at)),
64 // Every branch but the default, and how many matched the search.
65 total: all.length - 1,
66 matched: read.total,
67 unread: Math.max(0, read.total - read.shown.length),
68 query,
69 checks,
70 };
71}
72
73export default function Branches({ loaderData, params }: Route.ComponentProps) {
74 const { main, head, active, stale, total, matched, unread, query, checks } = loaderData;
75 const base = `/${params.owner}/${params.repo}`;
76 return (
77 <div className="space-y-6">
78 <div className="flex flex-wrap items-center justify-between gap-3">
79 <h2 className="text-lg font-semibold tracking-tight">
80 Branches <span className="font-normal text-faint">{total + 1}</span>
81 </h2>
82 <Form method="get" role="search" className="relative w-full sm:w-72">
83 <Search size={14} className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-faint" />
84 <input
85 name="q"
86 defaultValue={query ?? ""}
87 {...notACredential()}
88 placeholder="Search branches"
89 aria-label="Search branches"
90 className="h-8 w-full rounded-md border border-line bg-surface pr-3 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
91 />
92 </Form>
93 </div>
94
95 <section>
96 <h3 className="mb-2 text-sm font-medium text-muted">Default</h3>
97 <Card className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3">
98 <GitBranch size={15} className="shrink-0 text-faint" />
99 <Link to={`${base}/tree/${encodeURIComponent(main)}`} className="font-mono text-[0.8125rem] font-medium hover:text-accent">
100 {main}
101 </Link>
102 <Badge size="md" className="py-px">default</Badge>
103 {head && (
104 <span className="flex min-w-0 grow basis-64 items-center gap-1.5 text-xs text-muted">
105 <CommitAvatars commit={head} size={14} max={2} />
106 <span className="shrink-0">
107 <CommitNames commit={head} className="hover:text-fg" />
108 </span>
109 <span className="text-faint">·</span>
110 <Hint label={head.message}>
111 <Link to={`${base}/commit/${head.hash}`} className="min-w-0 truncate hover:text-fg">
112 {head.message}
113 </Link>
114 </Hint>
115 <CommitChecksBadge checks={checks} sha={head.hash} className="size-5" />
116 <span className="shrink-0 text-faint">
117 · <TimeAgo at={head.at} />
118 </span>
119 </span>
120 )}
121 </Card>
122 </section>
123
124 {query && (
125 <p className="text-sm text-muted">
126 {matched} of {total} {total === 1 ? "branch matches" : "branches match"} “{query}”.{" "}
127 <Link to={`${base}/branches`} className="text-fg hover:underline">
128 Clear
129 </Link>
130 </p>
131 )}
132
133 {active.length > 0 && (
134 <section>
135 <h3 className="mb-2 text-sm font-medium text-muted">Active</h3>
136 <ActiveBranches branches={active} base={base} main={main} checks={checks} />
137 </section>
138 )}
139 {stale.length > 0 && (
140 <section>
141 <h3 className="mb-2 text-sm font-medium text-muted">Stale</h3>
142 <p className="-mt-1 mb-2 text-xs text-faint">No commits in the last 90 days.</p>
143 <ActiveBranches branches={stale} base={base} main={main} checks={checks} />
144 </section>
145 )}
146 {active.length === 0 && stale.length === 0 && (
147 <EmptyState title={query ? "No branch matches" : "Only the default branch"}>
148 {query
149 ? "Try another part of the name."
150 : "Branches agents and people push show here, each with its pull request, checks and how far it has moved from the default branch."}
151 </EmptyState>
152 )}
153 {unread > 0 && (
154 <p className="text-xs text-faint">
155 {unread} more {unread === 1 ? "branch is" : "branches are"} not shown. Search to find one by name.
156 </p>
157 )}
158 </div>
159 );
160}