Commit

Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request

syntaqxcommitted Parente4e783fBrowse files
7 files+166−110/7 viewed
+5−0
128128 The **Tags** tab lists tags newest first, up to 100, each with its commit
129129 and a ZIP of its files.
130130
131+**Compare**, `g1t.sh/<workspace>/<repo>/compare/<base>...<head>`, shows
132+what one branch has that another does not: its commits, then every change.
133+Pick the two branches at the top; **Open a pull request** starts one from
134+the compared branch.
135+
131136 On **Files**, each file and folder shows the commit that last changed it and
132137 when, from up to 300 commits of the branch's history; one changed before
133138 that shows none. The branch menu at the top switches branch and keeps the
+2−1
818818 than one view shows them as tabs across its top. */}
819819 <Rule />
820820 <div className="space-y-px">
821− <SidebarLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`, `${base}/branches`, `${base}/tags`, ...soonPaths(base, "Code")]} icon={<Code2 size={15} />}>
821+ <SidebarLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`, `${base}/branches`, `${base}/tags`, `${base}/compare`, ...soonPaths(base, "Code")]} icon={<Code2 size={15} />}>
822822 Code
823823 </SidebarLink>
824824 <SidebarLink to={`${base}/issues`} also={[`${base}/plans`, ...soonPaths(base, "Issues")]} icon={<CircleDot size={15} />} count={repo.issues}>
12111211 repositories: "Repositories",
12121212 branches: "Branches",
12131213 tags: "Tags",
1214+ compare: "Compare",
12141215 dependencies: "Dependencies",
12151216 code: "Files",
12161217 secrets: "Secrets and variables",
+1−0
3939 { label: "Commits", path: "commits", also: ["commit"] },
4040 { label: "Branches", path: "branches" },
4141 { label: "Tags", path: "tags" },
42+ { label: "Compare", path: "compare" },
4243 ...soon("Code"),
4344 ],
4445 },
+0−9
8181
8282 // --- Code ---------------------------------------------------------------
8383 {
84− key: "compare",
85− title: "Compare",
86− section: "Code",
87− summary: "Any two branches, tags or commits, side by side.",
88− why: "See exactly what changed between two points, with the sessions and pull requests that changed it.",
89− plans: ["Diff any two refs", "The pull requests and agent sessions between them", "Open a pull request from the comparison"],
90− today: { label: "Commits", path: "commits" },
91− },
92− {
9384 key: "docs",
9485 title: "Docs",
9586 section: "Code",
+2−0
106106 route("commits", "routes/repo/commits.tsx"),
107107 route("branches", "routes/repo/branches.tsx"),
108108 route("tags", "routes/repo/tags.tsx"),
109+ route("compare/*", "routes/repo/compare.tsx", { id: "routes/repo/compare-range" }),
110+ route("compare", "routes/repo/compare.tsx"),
109111 route("commit/:hash", "routes/repo/commit.tsx"),
110112 route("issues", "routes/repo/issues.tsx"),
111113 route("issues/new", "routes/repo/issue-new.tsx"),
+155−0
1+import { ArrowLeft, GitCommitHorizontal, GitPullRequest } from "lucide-react";
2+import { Form, Link } from "react-router";
3+
4+import type { Comparison } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/compare";
7+import { DiffView } from "../../components/diff-view";
8+import { Avatar, ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
9+import { page } from "../../lib/meta";
10+import { repos } from "../../lib/services.server";
11+import { getViewer, unwrap } from "../../lib/session.server";
12+
13+/** How far back each side's history is read to find what head has that base does not. */
14+const HEAD_DEPTH = 100;
15+const BASE_DEPTH = 300;
16+
17+export function meta({ params, ...args }: Route.MetaArgs) {
18+ const range = params["*"] ? ` ${params["*"]}` : "";
19+ return page(args, { title: `Compare${range} · ${params.owner}/${params.repo} · g1t` });
20+}
21+
22+/** `main...feature` into its two sides; either may be missing. */
23+function sides(range: string): { base: string | null; head: string | null } {
24+ const at = range.indexOf("...");
25+ if (at === -1) return { base: null, head: range || null };
26+ return { base: range.slice(0, at) || null, head: range.slice(at + 3) || null };
27+}
28+
29+export async function loader({ params, context, request }: Route.LoaderArgs) {
30+ const path = { namespace: params.owner, name: params.repo };
31+ const viewer = getViewer(context);
32+ const url = new URL(request.url);
33+ // The pickers submit as ?base=&head=; the address keeps base...head.
34+ if (url.searchParams.has("base") || url.searchParams.has("head")) {
35+ const base = url.searchParams.get("base") ?? "";
36+ const head = url.searchParams.get("head") ?? "";
37+ return Response.redirect(new URL(`/${params.owner}/${params.repo}/compare/${base}...${head}`, url).toString(), 302);
38+ }
39+ const [repo, list] = await Promise.all([repos.get(path, viewer), repos.branches(path, viewer)]);
40+ const found = unwrap(repo);
41+ const branches = unwrap(list)
42+ .map((branch) => branch.name)
43+ .filter((name) => !name.startsWith("g1t-queue/"));
44+ const asked = sides(params["*"] ?? "");
45+ const base = asked.base ?? found.defaultBranch;
46+ const head = asked.head;
47+ if (!head || head === base) return { base, head, branches, comparison: null, commits: [], same: head === base };
48+ const [compared, headLog, baseLog] = await Promise.all([
49+ repos.compare(found.id, viewer, base, head),
50+ repos.log(path, viewer, head, HEAD_DEPTH),
51+ repos.log(path, viewer, base, BASE_DEPTH),
52+ ]);
53+ if (!headLog.ok || headLog.value.length === 0) throw new Response(`There is no branch, tag or commit named ${head}.`, { status: 404 });
54+ // What head has that base does not: its history down to the first commit base also has.
55+ const onBase = new Set(baseLog.ok ? baseLog.value.map((commit) => commit.hash) : []);
56+ const shared = headLog.value.findIndex((commit) => onBase.has(commit.hash));
57+ const commits = (shared === -1 ? headLog.value : headLog.value.slice(0, shared)).map((commit) => ({
58+ hash: commit.hash,
59+ message: commit.message.split("\n")[0] ?? "",
60+ author: commit.author.name,
61+ at: commit.authoredAt,
62+ }));
63+ const comparison: Comparison | null = compared.ok ? compared.value : null;
64+ return { base, head, branches, comparison, commits, same: false };
65+}
66+
67+function RefPicker({ name, label, value, branches }: { name: string; label: string; value: string | null; branches: string[] }) {
68+ const options = value && !branches.includes(value) ? [value, ...branches] : branches;
69+ return (
70+ <label className="inline-flex items-center gap-2 text-sm">
71+ <span className="text-muted">{label}</span>
72+ <select
73+ name={name}
74+ defaultValue={value ?? ""}
75+ className="h-8 max-w-56 rounded-md border border-line bg-surface px-2 font-mono text-[0.8125rem] outline-none hover:border-line-strong focus:border-accent-dim"
76+ >
77+ {value == null && <option value="">Choose a branch</option>}
78+ {options.map((option) => (
79+ <option key={option} value={option}>
80+ {option}
81+ </option>
82+ ))}
83+ </select>
84+ </label>
85+ );
86+}
87+
88+export default function Compare({ loaderData, params }: Route.ComponentProps) {
89+ const { base, head, branches, comparison, commits, same } = loaderData;
90+ const repoBase = `/${params.owner}/${params.repo}`;
91+ return (
92+ <div className="space-y-5">
93+ <div>
94+ <h2 className="text-lg font-semibold tracking-tight">Compare changes</h2>
95+ <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>
96+ </div>
97+ <Form method="get" className="flex flex-wrap items-center gap-3 rounded-xl border border-line bg-surface px-4 py-3">
98+ <RefPicker name="base" label="base" value={base} branches={branches} />
99+ <ArrowLeft size={14} className="text-faint" aria-hidden="true" />
100+ <RefPicker name="head" label="compare" value={head} branches={branches} />
101+ <button
102+ type="submit"
103+ className="h-8 rounded-md border border-line px-3 text-sm text-fg/90 transition-colors hover:border-line-strong hover:bg-raised"
104+ >
105+ Compare
106+ </button>
107+ {head && !same && commits.length > 0 && (
108+ <ButtonLink to={`${repoBase}/pulls/new?branch=${encodeURIComponent(head)}`} variant="accent">
109+ <GitPullRequest size={15} />
110+ Open a pull request
111+ </ButtonLink>
112+ )}
113+ </Form>
114+
115+ {!head ? (
116+ <EmptyState title="Pick a branch to compare">Its commits and changes against {base} show here.</EmptyState>
117+ ) : same ? (
118+ <EmptyState title="Nothing to compare">Both sides are {base}. Choose another branch to compare with it.</EmptyState>
119+ ) : commits.length === 0 ? (
120+ <EmptyState title={`${base} has everything ${head} has`}>There is nothing on {head} to bring into {base}.</EmptyState>
121+ ) : (
122+ <>
123+ <section>
124+ <h3 className="mb-2 text-sm font-medium text-muted">
125+ {commits.length === HEAD_DEPTH ? `${HEAD_DEPTH}+` : commits.length} {commits.length === 1 ? "commit" : "commits"}
126+ </h3>
127+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
128+ {commits.map((commit) => (
129+ <li key={commit.hash} className="flex items-center gap-3 px-4 py-2.5 text-sm">
130+ <GitCommitHorizontal size={15} className="shrink-0 text-faint" />
131+ <Avatar name={commit.author} size={16} />
132+ <Link to={`${repoBase}/commit/${commit.hash}`} className="min-w-0 grow truncate hover:text-accent" title={commit.message}>
133+ {commit.message}
134+ </Link>
135+ <span className="hidden shrink-0 text-xs text-muted sm:inline">{commit.author}</span>
136+ <Link to={`${repoBase}/commit/${commit.hash}`} className="shrink-0 font-mono text-xs text-faint hover:text-fg">
137+ {commit.hash.slice(0, 7)}
138+ </Link>
139+ <span className="shrink-0 text-xs text-faint">
140+ <TimeAgo at={commit.at} />
141+ </span>
142+ </li>
143+ ))}
144+ </ul>
145+ </section>
146+ {comparison ? (
147+ <DiffView comparison={comparison} fileBase={`${repoBase}/blob/${encodeURIComponent(head)}`} empty="The two sides have the same files." />
148+ ) : (
149+ <p className="text-sm text-muted">The changes could not be read just now. The commits above are what {head} adds.</p>
150+ )}
151+ </>
152+ )}
153+ </div>
154+ );
155+}
+1−1
156156 const { pathname } = useLocation();
157157 const tabs = tabsFor(pathname.slice(base.length + 1), member, access.can);
158158 // The files' own About says what it is and its topics, as the one place.
159− const filesPage = /^(code|tree|blob|commits?|branches|tags)(\/|$)/.test(pathname.slice(base.length + 1));
159+ const filesPage = /^(code|tree|blob|commits?|branches|tags|compare)(\/|$)/.test(pathname.slice(base.length + 1));
160160 // Everyone, signed in or not, finds the project's pages in the sidebar;
161161 // the page shows its name, and the views of the page it is on as tabs.
162162 return (