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.

191 linesCodeBlameRaw
1import { ArrowLeft, GitCommitHorizontal, GitPullRequest } from "lucide-react";
2import { Form, Link } from "react-router";
3
4import type { Route } from "./+types/compare";
5import { DiffView } from "../../components/diff-view";
6import { CommitAvatars, CommitNames } from "../../components/commit-person";
7import { CommitText } from "../../components/commit-message";
8import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
9import { Button } from "../../components/ui/button";
10import { Card } from "../../components/ui/card";
11import { Hint } from "../../components/ui/hint";
12import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
13import { type HighlightedComparison, firstScreens } from "../../lib/diff";
14import { commitHint } from "../../lib/commit-message";
15import { page } from "../../lib/meta";
16import { showCommits } from "../../lib/commit-people.server";
17import { repos } from "../../lib/services.server";
18import { getViewer, unwrap } from "../../lib/session.server";
19import { Unavailable } from "../../components/boundary";
20import { type Failed, isFailed, part } from "../../lib/partial";
21
22/** How far back each side's history is read to find what head has that base does not. */
23const HEAD_DEPTH = 100;
24const BASE_DEPTH = 300;
25
26export function meta({ params, ...args }: Route.MetaArgs) {
27 const range = params["*"] ? ` ${params["*"]}` : "";
28 return page(args, { title: `Compare${range} · ${params.owner}/${params.repo} · g1t` });
29}
30
31/** `main...feature` into its two sides; either may be missing. */
32function sides(range: string): { base: string | null; head: string | null } {
33 const at = range.indexOf("...");
34 if (at === -1) return { base: null, head: range || null };
35 return { base: range.slice(0, at) || null, head: range.slice(at + 3) || null };
36}
37
38export async function loader({ params, context, request }: Route.LoaderArgs) {
39 const path = { namespace: params.owner, name: params.repo };
40 const viewer = getViewer(context);
41 const url = new URL(request.url);
42 // The pickers submit as ?base=&head=; the address keeps base...head.
43 if (url.searchParams.has("base") || url.searchParams.has("head")) {
44 const base = url.searchParams.get("base") ?? "";
45 const head = url.searchParams.get("head") ?? "";
46 return Response.redirect(new URL(`/${params.owner}/${params.repo}/compare/${base}...${head}`, url).toString(), 302);
47 }
48 // The branches to pick from are a part: without them, the pickers offer
49 // the two being compared and say the rest could not be read.
50 const [repo, list] = await Promise.all([repos.get(path, viewer), part(repos.branches(path, viewer), "Branches")]);
51 const found = unwrap(repo);
52 const branches: string[] | Failed = isFailed(list)
53 ? list
54 : unwrap(list)
55 .map((branch) => branch.name)
56 .filter((name) => !name.startsWith("g1t-queue/"));
57 const asked = sides(params["*"] ?? "");
58 const base = asked.base ?? found.defaultBranch;
59 const head = asked.head;
60 if (!head || head === base) return { base, head, branches, comparison: null, commits: [], same: head === base };
61 const [headLog, baseLog] = await Promise.all([repos.log(path, viewer, head, HEAD_DEPTH), repos.log(path, viewer, base, BASE_DEPTH)]);
62 if (!headLog.ok || headLog.value.length === 0) throw new Response(`There is no branch, tag or commit named ${head}.`, { status: 404 });
63 // What head has that base does not: its history down to the first commit base also has.
64 const onBase = new Set(baseLog.ok ? baseLog.value.map((commit) => commit.hash) : []);
65 const shared = headLog.value.findIndex((commit) => onBase.has(commit.hash));
66 // With their people, in one identity call.
67 const commits = (await showCommits(shared === -1 ? headLog.value : headLog.value.slice(0, shared))).map((commit) => ({
68 hash: commit.hash,
69 message: commit.message.split("\n")[0] ?? "",
70 author: commit.author,
71 coAuthors: commit.coAuthors,
72 at: commit.authoredAt,
73 }));
74 // The changes from where the two last agreed, as a pull request would
75 // bring them: what base gained since is not head taking it away. When
76 // that point is past what was read, the two tips are compared.
77 const mergeBase = shared === -1 ? base : headLog.value[shared]!.hash;
78 // The changes are a part: the commits above them stay when they fail.
79 const compared = await part(repos.compare(found.id, viewer, mergeBase, head), "The changes");
80 // Its first screens with their lines; the rest load as they are reached.
81 const comparison: HighlightedComparison | Failed | null = isFailed(compared) ? compared : compared.ok ? firstScreens(compared.value) : null;
82 return { base, head, branches, comparison, commits, same: false };
83}
84
85function RefPicker({ name, label, value, branches }: { name: string; label: string; value: string | null; branches: string[] }) {
86 const options = value && !branches.includes(value) ? [value, ...branches] : branches;
87 return (
88 <div className="inline-flex items-center gap-2 text-sm">
89 <span className="text-muted" id={`${name}-label`}>
90 {label}
91 </span>
92 <Select key={value ?? ""} name={name} defaultValue={value ?? undefined}>
93 <SelectTrigger size="sm" aria-labelledby={`${name}-label`} className="w-auto max-w-56 font-mono">
94 <SelectValue placeholder="Choose a branch" />
95 </SelectTrigger>
96 <SelectContent>
97 {options.map((option) => (
98 <SelectItem key={option} value={option} className="font-mono text-[0.8125rem]">
99 {option}
100 </SelectItem>
101 ))}
102 </SelectContent>
103 </Select>
104 </div>
105 );
106}
107
108export default function Compare({ loaderData, params }: Route.ComponentProps) {
109 const { base, head, comparison, commits, same } = loaderData;
110 const branches = isFailed(loaderData.branches) ? [] : loaderData.branches;
111 const repoBase = `/${params.owner}/${params.repo}`;
112 return (
113 <div className="space-y-5">
114 <div>
115 <h2 className="text-lg font-semibold tracking-tight">Compare changes</h2>
116 <p className="mt-1 text-sm text-muted">Choose two branches to see what one has that the other does not, then open a pull request for it.</p>
117 </div>
118 <Card asChild className="flex flex-wrap items-center gap-3 px-4 py-3">
119 <Form method="get">
120 <RefPicker name="base" label="base" value={base} branches={branches} />
121 <ArrowLeft size={14} className="text-faint" aria-hidden="true" />
122 <RefPicker name="head" label="compare" value={head} branches={branches} />
123 <Button type="submit" variant="outline" className="h-8 px-3 text-fg/90 hover:bg-raised font-normal">
124 Compare
125 </Button>
126 {head && !same && commits.length > 0 && (
127 <ButtonLink to={`${repoBase}/pulls/new?branch=${encodeURIComponent(head)}&base=${encodeURIComponent(base)}`} variant="accent">
128 <GitPullRequest size={15} />
129 Open a pull request
130 </ButtonLink>
131 )}
132 </Form>
133 </Card>
134 {isFailed(loaderData.branches) && <Unavailable what={loaderData.branches.what} compact />}
135
136 {!head ? (
137 <EmptyState title="Pick a branch to compare">Its commits and changes against {base} show here.</EmptyState>
138 ) : same ? (
139 <EmptyState title="Nothing to compare">Both sides are {base}. Choose another branch to compare with it.</EmptyState>
140 ) : commits.length === 0 ? (
141 <EmptyState title={`${base} has everything ${head} has`}>
142 There is nothing on {head} to bring into {base}.
143 </EmptyState>
144 ) : (
145 <>
146 <section>
147 <h3 className="mb-2 text-sm font-medium text-muted">
148 {commits.length === HEAD_DEPTH ? `${HEAD_DEPTH}+` : commits.length} {commits.length === 1 ? "commit" : "commits"}
149 </h3>
150 <Card asChild divided className="overflow-hidden">
151 <ul>
152 {commits.map((commit) => (
153 <li key={commit.hash} className="flex items-center gap-3 px-4 py-2.5 text-sm">
154 <GitCommitHorizontal size={15} className="shrink-0 text-faint" />
155 <CommitAvatars commit={commit} size={16} max={2} />
156 <Hint label={commitHint(commit.message)}>
157 <span className="min-w-0 grow truncate">
158 <CommitText text={commit.message} base={repoBase} to={`${repoBase}/commit/${commit.hash}`} linkClassName="hover:text-accent" />
159 </span>
160 </Hint>
161 <span className="hidden shrink-0 text-xs text-muted sm:inline">
162 <CommitNames commit={commit} className="hover:text-fg" />
163 </span>
164 <Link to={`${repoBase}/commit/${commit.hash}`} className="shrink-0 font-mono text-xs text-faint hover:text-fg">
165 {commit.hash.slice(0, 7)}
166 </Link>
167 <span className="shrink-0 text-xs text-faint">
168 <TimeAgo at={commit.at} />
169 </span>
170 </li>
171 ))}
172 </ul>
173 </Card>
174 </section>
175 {isFailed(comparison) ? (
176 <Unavailable what={comparison.what} />
177 ) : comparison ? (
178 <DiffView
179 comparison={comparison}
180 repo={repoBase}
181 fileBase={`${repoBase}/blob/${encodeURIComponent(head)}`}
182 empty="The two sides have the same files."
183 />
184 ) : (
185 <p className="text-sm text-muted">The changes could not be read just now. The commits above are what {head} adds.</p>
186 )}
187 </>
188 )}
189 </div>
190 );
191}