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.

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