Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

3 commits

8 files+170−300/8 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"),
+156−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 [headLog, baseLog] = await Promise.all([repos.log(path, viewer, head, HEAD_DEPTH), repos.log(path, viewer, base, BASE_DEPTH)]);
49+ if (!headLog.ok || headLog.value.length === 0) throw new Response(`There is no branch, tag or commit named ${head}.`, { status: 404 });
50+ // What head has that base does not: its history down to the first commit base also has.
51+ const onBase = new Set(baseLog.ok ? baseLog.value.map((commit) => commit.hash) : []);
52+ const shared = headLog.value.findIndex((commit) => onBase.has(commit.hash));
53+ const commits = (shared === -1 ? headLog.value : headLog.value.slice(0, shared)).map((commit) => ({
54+ hash: commit.hash,
55+ message: commit.message.split("\n")[0] ?? "",
56+ author: commit.author.name,
57+ at: commit.authoredAt,
58+ }));
59+ // The changes from where the two last agreed, as a pull request would
60+ // bring them: what base gained since is not head taking it away. When
61+ // that point is past what was read, the two tips are compared.
62+ const mergeBase = shared === -1 ? base : headLog.value[shared]!.hash;
63+ const compared = await repos.compare(found.id, viewer, mergeBase, head);
64+ const comparison: Comparison | null = compared.ok ? compared.value : null;
65+ return { base, head, branches, comparison, commits, same: false };
66+}
67+
68+function RefPicker({ name, label, value, branches }: { name: string; label: string; value: string | null; branches: string[] }) {
69+ const options = value && !branches.includes(value) ? [value, ...branches] : branches;
70+ return (
71+ <label className="inline-flex items-center gap-2 text-sm">
72+ <span className="text-muted">{label}</span>
73+ <select
74+ name={name}
75+ defaultValue={value ?? ""}
76+ 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"
77+ >
78+ {value == null && <option value="">Choose a branch</option>}
79+ {options.map((option) => (
80+ <option key={option} value={option}>
81+ {option}
82+ </option>
83+ ))}
84+ </select>
85+ </label>
86+ );
87+}
88+
89+export default function Compare({ loaderData, params }: Route.ComponentProps) {
90+ const { base, head, branches, comparison, commits, same } = loaderData;
91+ const repoBase = `/${params.owner}/${params.repo}`;
92+ return (
93+ <div className="space-y-5">
94+ <div>
95+ <h2 className="text-lg font-semibold tracking-tight">Compare changes</h2>
96+ <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>
97+ </div>
98+ <Form method="get" className="flex flex-wrap items-center gap-3 rounded-xl border border-line bg-surface px-4 py-3">
99+ <RefPicker name="base" label="base" value={base} branches={branches} />
100+ <ArrowLeft size={14} className="text-faint" aria-hidden="true" />
101+ <RefPicker name="head" label="compare" value={head} branches={branches} />
102+ <button
103+ type="submit"
104+ className="h-8 rounded-md border border-line px-3 text-sm text-fg/90 transition-colors hover:border-line-strong hover:bg-raised"
105+ >
106+ Compare
107+ </button>
108+ {head && !same && commits.length > 0 && (
109+ <ButtonLink to={`${repoBase}/pulls/new?branch=${encodeURIComponent(head)}`} variant="accent">
110+ <GitPullRequest size={15} />
111+ Open a pull request
112+ </ButtonLink>
113+ )}
114+ </Form>
115+
116+ {!head ? (
117+ <EmptyState title="Pick a branch to compare">Its commits and changes against {base} show here.</EmptyState>
118+ ) : same ? (
119+ <EmptyState title="Nothing to compare">Both sides are {base}. Choose another branch to compare with it.</EmptyState>
120+ ) : commits.length === 0 ? (
121+ <EmptyState title={`${base} has everything ${head} has`}>There is nothing on {head} to bring into {base}.</EmptyState>
122+ ) : (
123+ <>
124+ <section>
125+ <h3 className="mb-2 text-sm font-medium text-muted">
126+ {commits.length === HEAD_DEPTH ? `${HEAD_DEPTH}+` : commits.length} {commits.length === 1 ? "commit" : "commits"}
127+ </h3>
128+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
129+ {commits.map((commit) => (
130+ <li key={commit.hash} className="flex items-center gap-3 px-4 py-2.5 text-sm">
131+ <GitCommitHorizontal size={15} className="shrink-0 text-faint" />
132+ <Avatar name={commit.author} size={16} />
133+ <Link to={`${repoBase}/commit/${commit.hash}`} className="min-w-0 grow truncate hover:text-accent" title={commit.message}>
134+ {commit.message}
135+ </Link>
136+ <span className="hidden shrink-0 text-xs text-muted sm:inline">{commit.author}</span>
137+ <Link to={`${repoBase}/commit/${commit.hash}`} className="shrink-0 font-mono text-xs text-faint hover:text-fg">
138+ {commit.hash.slice(0, 7)}
139+ </Link>
140+ <span className="shrink-0 text-xs text-faint">
141+ <TimeAgo at={commit.at} />
142+ </span>
143+ </li>
144+ ))}
145+ </ul>
146+ </section>
147+ {comparison ? (
148+ <DiffView comparison={comparison} fileBase={`${repoBase}/blob/${encodeURIComponent(head)}`} empty="The two sides have the same files." />
149+ ) : (
150+ <p className="text-sm text-muted">The changes could not be read just now. The commits above are what {head} adds.</p>
151+ )}
152+ </>
153+ )}
154+ </div>
155+ );
156+}
+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 (
+3−19
1111
1212 ## The point
1313
14−**GitHub is where people keep code. g1t is where a team of agents ships it.**
14+**g1t is where a team of agents ships code, with the people they work for.**
1515
16−Hosting git is table stakes, and g1t does it the way GitHub does: issues,
16+Hosting git is table stakes, and g1t does it the familiar way: issues,
1717 branches, pull requests, review, protected branches, people working by hand.
1818 None of that is the selling point. The selling point is the layer above it,
1919 which no forge has: **you hand g1t an outcome, and a fleet of agents converges
2525 1. **Outcomes, not pull requests.** The unit people work in is "make onboarding
2626 work offline", not branch #4012. A brief becomes a plan of issues with
2727 dependencies; agents take them as they unblock; people steer the outcome
28− and see it converge. GitHub, Origin and Entire all stop at the pull request.
28+ and see it converge, rather than stopping at the pull request.
2929 2. **Agents that work as a team.** Agents know what the others are doing, file
3030 what they find instead of widening their change, ask and answer each other
3131 through the forge, and defer to people. Many agents on one codebase without
3333 3. **`main` that only ever moves forward.** Every change lands through checks
3434 in the combination it will live in (the merge queue), failures go back to the
3535 agent that wrote them, and any line can answer "why is this here?"
36−
37−### Against the others
38−
39−| | GitHub | Cursor Origin | Entire | g1t |
40−| --- | --- | --- | --- | --- |
41−| Core idea | Code hosting with Copilot bolted on | A forge for Cursor's cloud agents | Store every agent session with the code | Agents converge an outcome onto `main` |
42−| Unit of work | Pull request | Pull request, stacked | Commit plus session | Outcome → plan → issues → pull requests |
43−| Agent context | In the Copilot app | In Cursor | In the repo, per commit | Per commit, plus why-blame on any line and what agents told each other |
44−| Many agents at once | Compare outputs by hand | Agents can review agents | Not the focus | Plan with dependencies, overlap awareness, coordination tools, queue |
45−| Landing | Merge queue (paid) | Stacks | Not the focus | Speculative queue testing combinations; failures return to their agent |
46−| Which agents | Copilot, some others | Cursor's | Any (CLI) | Hosted agents plus any MCP client |
47−
48−Entire's insight, that the session belongs with the code, is one g1t shares
49−and already ships (sessions, why-blame). Origin's, that agents should live in
50−the forge, too. Neither coordinates a team of agents towards an outcome; that
51−is the gap g1t is built for.
5236
5337 ## Product model
5438