Commit

Branches and Tags pages, each file's last commit, and the branch menu on files

- Files: every row shows the commit that last changed it and when, from a new repos last_commits walk (300 commits, first parent, trees read only where the folder changed), kept in the colo cache by head commit and path and streamed in after the list. - Branches: default, Active and Stale, each with its last commit, ahead and behind, the open pull request and its checks, and its preview; search by name. The overview's Active branches share the same reader. - Tags: newest first with their commit and a ZIP, from a new repos tags RPC. - Blob pages get the branch menu, which keeps the file when switching. - Web tests import with .ts as plain node needs.

syntaqxcommitted Parentc229ecdBrowse files
20 files+820−1070/20 viewed
+16−0
117117 git push origin my-change
118118 ```
119119
120+A repository's **Branches** tab, `g1t.sh/<workspace>/<repo>/branches`, lists
121+every branch: the default one first, then those with a commit in the last 90
122+days (**Active**), then the rest (**Stale**). Each shows its last commit, how
123+many commits it is ahead of and behind the default branch, the pull request
124+open on it with its checks, and its preview when it has one. A count with a
125+`+` ran past how far back g1t reads, 40 commits on the branch and 120 on the
126+default. Search narrows the list by name.
127+
128+The **Tags** tab lists tags newest first, up to 100, each with its commit
129+and a ZIP of its files.
130+
131+On **Files**, each file and folder shows the commit that last changed it and
132+when, from up to 300 commits of the branch's history; one changed before
133+that shows none. The branch menu at the top switches branch and keeps the
134+folder or file you are on.
135+
120136 ## Pull request forks
121137
122138 A pull request that was not opened from a branch has its own remote:
+125−37
1−import { BookOpen, Check, ChevronDown, Code2, File, FileArchive, Folder, FolderGit2, GitBranch, History, Search, SquareTerminal } from "lucide-react";
2−import { Form, Link } from "react-router";
1+import { BookOpen, Check, ChevronDown, Code2, File, FileArchive, Folder, FolderGit2, GitBranch, History, Search, SquareTerminal, Tag as TagIcon } from "lucide-react";
2+import { Suspense } from "react";
3+import { Await, Form, Link } from "react-router";
34
4−import type { Blame, BlobView as Blob, Branch, Commit, TreeView as Tree } from "@g1t/contracts";
5+import type { Blame, BlobView as Blob, Branch, Commit, LastCommits, TreeView as Tree } from "@g1t/contracts";
56
67 import { BlameView } from "./blame-view";
78 import { CodeLines } from "./code-lines";
8687 "inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-line bg-surface px-3 text-sm transition-colors hover:border-line-strong hover:bg-raised data-[state=open]:border-line-strong";
8788
8889 /** Which branch is shown, and the others to switch to, at the same path. */
89−function BranchMenu({ base, gitRef, path, branches }: { base: string; gitRef: string; path: string; branches: Branch[] }) {
90+function BranchMenu({
91+ base,
92+ gitRef,
93+ path,
94+ branches,
95+ view = "tree",
96+}: {
97+ base: string;
98+ gitRef: string;
99+ path: string;
100+ branches: Branch[];
101+ /** Whether `path` is a folder or a file. */
102+ view?: "tree" | "blob";
103+}) {
90104 const rest = path ? `/${encodePath(path)}` : "";
91105 return (
92106 <DropdownMenu>
99113 <DropdownMenuLabel>Switch branches</DropdownMenuLabel>
100114 {branches.map((branch) => (
101115 <DropdownMenuItem key={branch.name} asChild>
102− <Link to={`${base}/tree/${encodePath(branch.name)}${rest}`}>
116+ <Link to={`${base}/${view}/${encodePath(branch.name)}${rest}`}>
103117 <Check className={branch.name === gitRef ? "" : "invisible"} />
104118 <span className="truncate font-mono text-[0.8125rem]">{branch.name}</span>
105119 </Link>
185199 ) : (
186200 <>
187201 {branches && (
188− <span className="inline-flex items-center gap-1.5 text-sm text-muted">
202+ <Link to={`${base}/branches`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent">
189203 <GitBranch size={14} className="text-faint" />
190204 <span className="font-medium text-fg">{branches.length}</span>
191205 {branches.length === 1 ? "branch" : "branches"}
192− </span>
206+ </Link>
193207 )}
208+ <Link to={`${base}/tags`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent">
209+ <TagIcon size={14} className="text-faint" />
210+ Tags
211+ </Link>
194212 <SearchCode repo={full} />
195213 <CodeButton path={full} gitRef={gitRef} />
196214 </>
199217 );
200218 }
201219
202−export function TreeView({ tree, branches = null }: { tree: Tree; branches?: Branch[] | null }) {
220+/**
221+ * The files of a directory, each with the commit that last changed it.
222+ * `last` is undefined while those are still being read (the column holds
223+ * its place), and null when they could not be.
224+ */
225+function FileRows({
226+ base,
227+ gitRef,
228+ prefix,
229+ entries,
230+ last,
231+}: {
232+ base: string;
233+ gitRef: string;
234+ prefix: string;
235+ entries: Tree["entries"];
236+ last: LastCommits | null | undefined;
237+}) {
238+ const byName = new Map((last?.entries ?? []).map((entry) => [entry.name, entry.commit]));
239+ return (
240+ <ul className="divide-y divide-line text-sm">
241+ {entries.map((entry) => {
242+ const isTree = entry.kind === "tree";
243+ const Icon = isTree ? Folder : entry.kind === "gitlink" ? FolderGit2 : File;
244+ const commit = byName.get(entry.name);
245+ return (
246+ <li key={entry.name} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 px-4 py-2 transition-colors hover:bg-surface sm:grid-cols-[minmax(0,14rem)_minmax(0,1fr)_auto] lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)_auto]">
247+ <Link
248+ to={`${base}/${isTree ? "tree" : "blob"}/${gitRef}/${prefix}${encodeURIComponent(entry.name)}`}
249+ className="flex min-w-0 items-center gap-3 hover:text-accent hover:underline"
250+ >
251+ <Icon size={15} className={`shrink-0 ${isTree ? "text-accent-dim" : "text-faint"}`} />
252+ <span className="truncate font-mono text-[0.8125rem]">{entry.name}</span>
253+ </Link>
254+ <span className="hidden min-w-0 sm:block">
255+ {commit ? (
256+ <Link
257+ to={`${base}/commit/${commit.hash}`}
258+ className="block truncate text-muted hover:text-fg hover:underline"
259+ title={commit.message.split("\n")[0]}
260+ >
261+ {commit.message.split("\n")[0]}
262+ </Link>
263+ ) : last === undefined ? (
264+ <span aria-hidden="true" className="block h-3 w-40 max-w-full animate-pulse rounded bg-raised" />
265+ ) : null}
266+ </span>
267+ <span className="text-right text-xs whitespace-nowrap text-faint">
268+ {commit ? <TimeAgo at={commit.authoredAt} /> : last === undefined ? <span aria-hidden="true" className="inline-block h-3 w-12 animate-pulse rounded bg-raised" /> : null}
269+ </span>
270+ </li>
271+ );
272+ })}
273+ </ul>
274+ );
275+}
276+
277+export function TreeView({
278+ tree,
279+ branches = null,
280+ lastCommits = null,
281+}: {
282+ tree: Tree;
283+ branches?: Branch[] | null;
284+ /** Each entry's last commit, streamed in after the list. */
285+ lastCommits?: Promise<LastCommits | null> | null;
286+}) {
203287 const { repo, ref, path, head, entries, readme } = tree;
204288 const base = `/${repo.namespace}/${repo.name}`;
205289 const prefix = path ? `${encodePath(path)}/` : "";
234318 <CodeBar base={base} repo={repo} gitRef={ref} path={path} branches={branches} />
235319 <div className="overflow-hidden rounded-xl border border-line">
236320 <CommitBar commit={head} base={base} />
237− <ul className="divide-y divide-line text-sm">
238− {entries.map((entry) => {
239− const isTree = entry.kind === "tree";
240− const Icon = isTree ? Folder : entry.kind === "gitlink" ? FolderGit2 : File;
241− return (
242− <li key={entry.name}>
243− <Link
244− to={`${base}/${isTree ? "tree" : "blob"}/${ref}/${prefix}${encodeURIComponent(entry.name)}`}
245− className="flex items-center gap-3 px-4 py-2 transition-colors hover:bg-surface"
246− >
247− <Icon
248− size={15}
249− className={isTree ? "text-accent-dim" : "text-faint"}
250− />
251− <span className="font-mono text-[0.8125rem]">{entry.name}</span>
252− </Link>
253− </li>
254− );
255− })}
256− </ul>
321+ {lastCommits ? (
322+ <Suspense fallback={<FileRows base={base} gitRef={ref} prefix={prefix} entries={entries} last={undefined} />}>
323+ <Await resolve={lastCommits} errorElement={<FileRows base={base} gitRef={ref} prefix={prefix} entries={entries} last={null} />}>
324+ {(last) => <FileRows base={base} gitRef={ref} prefix={prefix} entries={entries} last={last} />}
325+ </Await>
326+ </Suspense>
327+ ) : (
328+ <FileRows base={base} gitRef={ref} prefix={prefix} entries={entries} last={null} />
329+ )}
257330 </div>
258331
259332 {readme?.text != null && (
299372 </Link>
300373 </li>
301374 {branches && (
302− <li className="flex items-center gap-2">
303− <GitBranch size={15} className="text-faint" />
304− {branches.length} {branches.length === 1 ? "branch" : "branches"}
375+ <li>
376+ <Link to={`${base}/branches`} className="inline-flex items-center gap-2 hover:text-fg">
377+ <GitBranch size={15} className="text-faint" />
378+ {branches.length} {branches.length === 1 ? "branch" : "branches"}
379+ </Link>
305380 </li>
306381 )}
382+ <li>
383+ <Link to={`${base}/tags`} className="inline-flex items-center gap-2 hover:text-fg">
384+ <TagIcon size={15} className="text-faint" />
385+ Tags
386+ </Link>
387+ </li>
307388 </ul>
308389 <p className="mt-4 text-xs text-faint">
309390 Created <TimeAgo at={repo.createdAt} />
318399 blob,
319400 html,
320401 blame,
402+ branches = null,
321403 }: {
404+ /** For the branch menu; it shows the current branch alone without them. */
405+ branches?: Branch[] | null;
322406 blob: Blob;
323407 /** Syntax-highlighted HTML per line, when the language is known. */
324408 html: string[] | null;
339423 );
340424 return (
341425 <div>
342− <Breadcrumbs
343− base={`/${repo.namespace}/${repo.name}`}
344− repo={repo.name}
345− gitRef={ref}
346− path={path}
347− />
426+ <div className="mb-4 flex flex-wrap items-center gap-x-3 gap-y-2">
427+ <BranchMenu
428+ base={base}
429+ gitRef={ref}
430+ path={path}
431+ branches={branches && branches.length > 0 ? branches : [{ name: ref, hash: "" }]}
432+ view="blob"
433+ />
434+ <Breadcrumbs base={base} repo={repo.name} gitRef={ref} path={path} />
435+ </div>
348436 <div className="overflow-hidden rounded-xl border border-line">
349437 <div className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5 text-xs text-muted">
350438 {lines && <span>{lines.length.toLocaleString("en-US")} lines</span>}
+4−3
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`, ...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`, ...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}>
12091209 access: "Access",
12101210 invitations: "Invitation",
12111211 repositories: "Repositories",
1212− branches: "Branches and merging",
1212+ branches: "Branches",
1213+ tags: "Tags",
12131214 dependencies: "Dependencies",
12141215 code: "Files",
12151216 secrets: "Secrets and variables",
13081309 trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
13091310 // A settings page names which one: Settings / Secrets and variables.
13101311 if (third === "settings" && fourth && SECTIONS[fourth]) {
1311− trail.push({ label: SECTIONS[fourth]!, to: `${repo}/settings/${fourth}` });
1312+ trail.push({ label: fourth === "branches" ? "Branches and merging" : SECTIONS[fourth]!, to: `${repo}/settings/${fourth}` });
13121313 }
13131314 }
13141315 }
+1−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { shownStep } from "./agent-step";
4+import { shownStep } from "./agent-step.ts";
55
66 test("a step that hands back a file says what was done, not where", () => {
77 assert.equal(shownStep("Said: Done. The review is written to `/work/review.json`."), "Wrote its review.");
+60−0
1+/**
2+ * A repository's branches as its pages show them: each one's head commit,
3+ * how far it has moved from the default branch, the pull request open on
4+ * it with its checks, and its preview. The overview shows the newest few;
5+ * the Branches page shows them all.
6+ */
7+import type { Branch, Pull, RepoPath, Viewer } from "@g1t/contracts";
8+
9+import type { ActiveBranch } from "../components/branches";
10+import { drift } from "./branches";
11+import { repos } from "./services.server";
12+
13+/** How far back each branch's history, and the default branch's, is read to count ahead and behind. */
14+export const BRANCH_DEPTH = 40;
15+export const MAIN_DEPTH = 120;
16+
17+type Preview = { branch?: string | null; number?: number | null; url: string };
18+
19+/**
20+ * The branches other than the default, at most `read` of them read (those
21+ * with an open pull request first), newest commit first. `main` is the
22+ * default branch's head commit with its first line.
23+ */
24+export async function readBranches(
25+ path: RepoPath,
26+ viewer: Viewer,
27+ input: { defaultBranch: string; branches: Branch[]; pulls: Pull[]; previews: Preview[] },
28+ read: number,
29+): Promise<{ main: string; total: number; shown: ActiveBranch[]; head: ActiveBranch["commit"] }> {
30+ const soft = <T,>(promise: Promise<T>): Promise<T | null> => promise.catch(() => null);
31+ const main = input.defaultBranch;
32+ const pullOn = new Map(input.pulls.filter((pull) => pull.branch).map((pull) => [pull.branch as string, pull]));
33+ const others = input.branches.filter((branch) => branch.name !== main);
34+ const reading = [...others.filter((b) => pullOn.has(b.name)), ...others.filter((b) => !pullOn.has(b.name))].slice(0, read);
35+ // By commit hash, not name: history from a commit never changes, so
36+ // repos keeps it (services/repos/src/store.rs) and only new heads cost a walk.
37+ const mainHead = input.branches.find((branch) => branch.name === main)?.hash ?? main;
38+ const [mainLog, ...logs] = await Promise.all([
39+ soft(repos.log(path, viewer, mainHead, MAIN_DEPTH)),
40+ ...reading.map((branch) => soft(repos.log(path, viewer, branch.hash || branch.name, BRANCH_DEPTH))),
41+ ]);
42+ const mainHistory = mainLog?.ok ? mainLog.value : [];
43+ const mainHashes = mainHistory.map((c) => c.hash);
44+ const summary = (commit: (typeof mainHistory)[number] | undefined): ActiveBranch["commit"] =>
45+ commit ? { hash: commit.hash, message: commit.message.split("\n")[0] ?? "", author: commit.author.name, at: commit.authoredAt } : null;
46+ const shown = reading
47+ .map((branch, index): ActiveBranch => {
48+ const history = logs[index]?.ok ? logs[index].value : [];
49+ const pull = pullOn.get(branch.name);
50+ return {
51+ name: branch.name,
52+ commit: summary(history[0]),
53+ ...drift(history.map((c) => c.hash), mainHashes, BRANCH_DEPTH),
54+ pull: pull ? { number: pull.number, title: pull.title, checkStatus: pull.checkStatus, draft: pull.status === "draft" } : null,
55+ preview: input.previews.find((app) => app.branch === branch.name || (pull != null && app.number === pull.number))?.url ?? null,
56+ };
57+ })
58+ .sort((a, b) => Date.parse(b.commit?.at ?? "0") - Date.parse(a.commit?.at ?? "0"));
59+ return { main, total: others.length, shown, head: summary(mainHistory[0]) };
60+}
+2−0
3737 tabs: [
3838 { label: "Files", path: "code", also: ["tree", "blob"] },
3939 { label: "Commits", path: "commits", also: ["commit"] },
40+ { label: "Branches", path: "branches" },
41+ { label: "Tags", path: "tags" },
4042 ...soon("Code"),
4143 ],
4244 },
+0−23
8181
8282 // --- Code ---------------------------------------------------------------
8383 {
84− key: "branches",
85− title: "Branches",
86− section: "Code",
87− summary: "Every branch: who is on it, how far behind it is, and its preview.",
88− why: "With agents opening branches by the dozen, a list of names is not enough. Each branch shows its pull request, its checks, its live preview and how stale it is.",
89− plans: [
90− "Branches with their pull request, checks and preview address",
91− "Ahead and behind the default branch, with one-click catch-up by an agent",
92− "Protection rules: required checks, reviews and the merge queue",
93− "Stale branches cleaned up on a schedule you set",
94− ],
95− today: { label: "Pull requests", path: "pulls" },
96− },
97− {
98− key: "tags",
99− title: "Tags",
100− section: "Code",
101− summary: "Tags, and the releases made from them.",
102− why: "A tag marks a version of the code. Each links to its release notes and to the deployment that shipped it.",
103− plans: ["Tags with their commit, release and deployment", "Signed tags verified", "Rules for who may create and move them"],
104− today: { label: "Commits", path: "commits" },
105− },
106− {
10784 key: "compare",
10885 title: "Compare",
10986 section: "Code",
+1−1
22 import { test } from "node:test";
33 import { inflateRawSync } from "node:zlib";
44
5−import { crc32, zip } from "./zip";
5+import { crc32, zip } from "./zip.ts";
66
77 test("crc32 matches the standard check value", () => {
88 assert.equal(crc32(new TextEncoder().encode("123456789")), 0xcbf43926);
+2−0
104104 route("blob/:ref/*", "routes/repo/blob.tsx"),
105105 route("archive/*", "routes/repo/archive.ts"),
106106 route("commits", "routes/repo/commits.tsx"),
107+ route("branches", "routes/repo/branches.tsx"),
108+ route("tags", "routes/repo/tags.tsx"),
107109 route("commit/:hash", "routes/repo/commit.tsx"),
108110 route("issues", "routes/repo/issues.tsx"),
109111 route("issues/new", "routes/repo/issue-new.tsx"),
+7−2
1515 const path = { namespace: params.owner, name: params.repo };
1616 const file = params["*"] ?? "";
1717 const wantsBlame = new URL(request.url).searchParams.has("blame");
18− const [found, blame] = await Promise.all([
18+ const [found, blame, list] = await Promise.all([
1919 repos.blob(path, viewer, params.ref, file),
2020 wantsBlame ? repos.blame(path, viewer, params.ref, file) : null,
21+ // For the branch menu; the page still shows without it.
22+ repos.branches(path, viewer).catch(() => null),
2123 ]);
24+ const branches = list?.ok ? list.value : null;
2225 // A branch that was renamed: the same file on its new name.
2326 if (!found.ok && found.error.code === "not_found") await redirectIfBranchRenamed(request, path, viewer, params.ref);
2427 const blob = unwrap(found);
2730 blob,
2831 html: null,
2932 blame: { blame: blame.value, lines: await highlightLines(blob.path, blob.text) },
33+ branches,
3034 };
3135 }
3236 return {
3337 blob,
3438 html: blob.text == null ? null : await highlightLines(blob.path, blob.text),
3539 blame: null,
40+ branches,
3641 };
3742 }
3843
3944 export default function Blob({ loaderData }: Route.ComponentProps) {
40− return <BlobView blob={loaderData.blob} html={loaderData.html} blame={loaderData.blame} />;
45+ return <BlobView blob={loaderData.blob} html={loaderData.html} blame={loaderData.blame} branches={loaderData.branches} />;
4146 }
+144−0
1+import { GitBranch, Search } from "lucide-react";
2+import { Form, Link } from "react-router";
3+
4+import type { Route } from "./+types/branches";
5+import { ActiveBranches } from "../../components/branches";
6+import { Avatar, EmptyState, TimeAgo, notACredential } from "../../components/ui";
7+import { readBranches } from "../../lib/branches.server";
8+import { page } from "../../lib/meta";
9+import { deployments, repos, work } from "../../lib/services.server";
10+import { getViewer, unwrap } from "../../lib/session.server";
11+
12+/** The most branches read for one page; past that, search narrows them. */
13+const BRANCHES_READ = 60;
14+/** A branch whose last commit is older than this is stale. */
15+const STALE_DAYS = 90;
16+
17+export function meta({ params, ...args }: Route.MetaArgs) {
18+ return page(args, { title: `Branches · ${params.owner}/${params.repo} · g1t` });
19+}
20+
21+export async function loader({ params, context, request }: Route.LoaderArgs) {
22+ const path = { namespace: params.owner, name: params.repo };
23+ const viewer = getViewer(context);
24+ const query = new URL(request.url).searchParams.get("q")?.trim().toLowerCase() || null;
25+ const soft = <T,>(promise: Promise<T>): Promise<T | null> => promise.catch(() => null);
26+ const [repo, list, pulls, deploys] = await Promise.all([
27+ repos.get(path, viewer),
28+ repos.branches(path, viewer),
29+ soft(work.listPulls(path, viewer, "open")),
30+ // Previews are for people with a role here; everyone else sees none.
31+ soft(deployments.list({ workspace: params.owner, slug: params.repo }, viewer)),
32+ ]);
33+ const found = unwrap(repo);
34+ const all = unwrap(list);
35+ const matching = query ? all.filter((branch) => branch.name === found.defaultBranch || branch.name.toLowerCase().includes(query)) : all;
36+ const read = await readBranches(
37+ path,
38+ viewer,
39+ {
40+ defaultBranch: found.defaultBranch,
41+ branches: matching,
42+ pulls: pulls?.ok ? pulls.value : [],
43+ previews: deploys?.ok ? deploys.value.live.filter((app) => app.kind === "preview") : [],
44+ },
45+ BRANCHES_READ,
46+ );
47+ const staleBefore = Date.now() - STALE_DAYS * 86_400_000;
48+ const isStale = (at: string | undefined) => at != null && Date.parse(at) < staleBefore;
49+ return {
50+ main: found.defaultBranch,
51+ head: read.head,
52+ active: read.shown.filter((branch) => !isStale(branch.commit?.at)),
53+ stale: read.shown.filter((branch) => isStale(branch.commit?.at)),
54+ // Every branch but the default, and how many matched the search.
55+ total: all.length - 1,
56+ matched: read.total,
57+ unread: Math.max(0, read.total - read.shown.length),
58+ query,
59+ };
60+}
61+
62+export default function Branches({ loaderData, params }: Route.ComponentProps) {
63+ const { main, head, active, stale, total, matched, unread, query } = loaderData;
64+ const base = `/${params.owner}/${params.repo}`;
65+ return (
66+ <div className="space-y-6">
67+ <div className="flex flex-wrap items-center justify-between gap-3">
68+ <h2 className="text-lg font-semibold tracking-tight">
69+ Branches <span className="font-normal text-faint">{total + 1}</span>
70+ </h2>
71+ <Form method="get" role="search" className="relative w-full sm:w-72">
72+ <Search size={14} className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-faint" />
73+ <input
74+ name="q"
75+ defaultValue={query ?? ""}
76+ {...notACredential()}
77+ placeholder="Search branches"
78+ aria-label="Search branches"
79+ 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"
80+ />
81+ </Form>
82+ </div>
83+
84+ <section>
85+ <h3 className="mb-2 text-sm font-medium text-muted">Default</h3>
86+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-xl border border-line bg-surface px-4 py-3">
87+ <GitBranch size={15} className="shrink-0 text-faint" />
88+ <Link to={`${base}/tree/${encodeURIComponent(main)}`} className="font-mono text-[0.8125rem] font-medium hover:text-accent">
89+ {main}
90+ </Link>
91+ <span className="rounded-full border border-line px-2 py-px text-xs text-muted">default</span>
92+ {head && (
93+ <span className="flex min-w-0 grow basis-64 items-center gap-1.5 text-xs text-muted">
94+ <Avatar name={head.author} size={13} />
95+ <span className="shrink-0">{head.author}</span>
96+ <span className="text-faint">·</span>
97+ <Link to={`${base}/commit/${head.hash}`} className="min-w-0 truncate hover:text-fg" title={head.message}>
98+ {head.message}
99+ </Link>
100+ <span className="shrink-0 text-faint">
101+ · <TimeAgo at={head.at} />
102+ </span>
103+ </span>
104+ )}
105+ </div>
106+ </section>
107+
108+ {query && (
109+ <p className="text-sm text-muted">
110+ {matched} of {total} {total === 1 ? "branch matches" : "branches match"} “{query}”.{" "}
111+ <Link to={`${base}/branches`} className="text-fg hover:underline">
112+ Clear
113+ </Link>
114+ </p>
115+ )}
116+
117+ {active.length > 0 && (
118+ <section>
119+ <h3 className="mb-2 text-sm font-medium text-muted">Active</h3>
120+ <ActiveBranches branches={active} base={base} main={main} />
121+ </section>
122+ )}
123+ {stale.length > 0 && (
124+ <section>
125+ <h3 className="mb-2 text-sm font-medium text-muted">Stale</h3>
126+ <p className="-mt-1 mb-2 text-xs text-faint">No commits in the last 90 days.</p>
127+ <ActiveBranches branches={stale} base={base} main={main} />
128+ </section>
129+ )}
130+ {active.length === 0 && stale.length === 0 && (
131+ <EmptyState title={query ? "No branch matches" : "Only the default branch"}>
132+ {query
133+ ? "Try another part of the name."
134+ : "Branches agents and people push show here, each with its pull request, checks and how far it has moved from the default branch."}
135+ </EmptyState>
136+ )}
137+ {unread > 0 && (
138+ <p className="text-xs text-faint">
139+ {unread} more {unread === 1 ? "branch is" : "branches are"} not shown. Search to find one by name.
140+ </p>
141+ )}
142+ </div>
143+ );
144+}
+7−2
1616 // For the branch menu; the page still shows without it.
1717 repos.branches(path, viewer).catch(() => null),
1818 ]);
19− return { tree: unwrap(tree), branches: branches?.ok ? branches.value : null };
19+ // Each entry's last commit walks history: streamed in after the list.
20+ const lastCommits = repos
21+ .lastCommits(path, viewer, null, "")
22+ .then((found) => (found.ok ? found.value : null))
23+ .catch(() => null);
24+ return { tree: unwrap(tree), branches: branches?.ok ? branches.value : null, lastCommits };
2025 }
2126
2227 export default function Code({ loaderData }: Route.ComponentProps) {
23− return <TreeView tree={loaderData.tree} branches={loaderData.branches} />;
28+ return <TreeView tree={loaderData.tree} branches={loaderData.branches} lastCommits={loaderData.lastCommits} />;
2429 }
+14−36
6464 rankNeeds,
6565 stuckMinutes,
6666 } from "../../lib/mission";
67−import { drift } from "../../lib/branches";
6867 import { agentWasAssigned, hasInstructions, productionChecklist, releaseChecklist } from "../../lib/checklist";
6968 import { ECOSYSTEM_LABEL, installCommands } from "../../lib/packages";
7069 import { PUBLISH_GUIDES, hasRelease, libraryPackages, packageName, packagePath, publishGuide } from "../../lib/project-kind";
7372 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
7473 import { accessTo, countsFor, refusal, repoFor } from "../../lib/access.server";
7574 import { shotVersion } from "./production-screenshot";
75+import { readBranches } from "../../lib/branches.server";
7676
7777 const MAX_LANDED = 6;
7878 /** Branches read for the Active branches list, and shown. */
7979 const BRANCHES_READ = 10;
8080 const BRANCHES_SHOWN = 5;
81−/** How far back each branch's history, and the default branch's, is read to count ahead and behind. */
82−const BRANCH_DEPTH = 40;
83−const MAIN_DEPTH = 120;
8481 /** A library's packages shown on its overview; the rest are a link away. */
8582 const PACKAGES_SHOWN = 3;
8683
136133 async ([repo, branchList, pulls, deploys]): Promise<{ main: string; total: number; shown: ActiveBranch[] } | null> => {
137134 if (!repo?.ok || !branchList?.ok) return null;
138135 const main = repo.value.defaultBranch;
139− const pullList = pulls?.ok ? pulls.value : [];
140− const pullOn = new Map(pullList.filter((pull) => pull.branch).map((pull) => [pull.branch as string, pull]));
141− const others = branchList.value.filter((branch) => branch.name !== main);
142− if (others.length === 0) return { main, total: 0, shown: [] };
143− const read = [...others.filter((b) => pullOn.has(b.name)), ...others.filter((b) => !pullOn.has(b.name))].slice(0, BRANCHES_READ);
144− // By commit hash, not name: history from a commit never changes, so
145− // repos keeps it (services/repos/src/store.rs) and only new heads cost
146− // a walk.
147− const mainHead = branchList.value.find((branch) => branch.name === main)?.hash ?? main;
148− const [mainLog, ...logs] = await Promise.all([
149− soft(repos.log(path, viewer, mainHead, MAIN_DEPTH)),
150− ...read.map((branch) => soft(repos.log(path, viewer, branch.hash || branch.name, BRANCH_DEPTH))),
151− ]);
152− const mainHashes = mainLog?.ok ? mainLog.value.map((c) => c.hash) : [];
153− const previews = deploys?.ok ? deploys.value.live.filter((app) => app.kind === "preview") : [];
154− const shown = read
155− .map((branch, index): ActiveBranch => {
156− const history = logs[index]?.ok ? logs[index].value : [];
157− const head = history[0];
158− const moved = drift(history.map((c) => c.hash), mainHashes, BRANCH_DEPTH);
159− const pull = pullOn.get(branch.name);
160− return {
161− name: branch.name,
162− commit: head ? { hash: head.hash, message: head.message.split("\n")[0], author: head.author.name, at: head.authoredAt } : null,
163− ...moved,
164− pull: pull ? { number: pull.number, title: pull.title, checkStatus: pull.checkStatus, draft: pull.status === "draft" } : null,
165− preview: previews.find((app) => app.branch === branch.name || (pull != null && app.number === pull.number))?.url ?? null,
166− };
167− })
168− .sort((a, b) => Date.parse(b.commit?.at ?? "0") - Date.parse(a.commit?.at ?? "0"))
169− .slice(0, BRANCHES_SHOWN);
170− return { main, total: others.length, shown };
136+ if (branchList.value.every((branch) => branch.name === main)) return { main, total: 0, shown: [] };
137+ const read = await readBranches(
138+ path,
139+ viewer,
140+ {
141+ defaultBranch: main,
142+ branches: branchList.value,
143+ pulls: pulls?.ok ? pulls.value : [],
144+ previews: deploys?.ok ? deploys.value.live.filter((app) => app.kind === "preview") : [],
145+ },
146+ BRANCHES_READ,
147+ );
148+ return { main, total: read.total, shown: read.shown.slice(0, BRANCHES_SHOWN) };
171149 },
172150 );
173151 // Active branches read several logs each: streamed, so the rest shows first.
+77−0
1+import { FileArchive, Tag as TagIcon } from "lucide-react";
2+import { Link } from "react-router";
3+
4+import type { Route } from "./+types/tags";
5+import { Avatar, EmptyState, TimeAgo } from "../../components/ui";
6+import { page } from "../../lib/meta";
7+import { repos } from "../../lib/services.server";
8+import { getViewer, unwrap } from "../../lib/session.server";
9+
10+export function meta({ params, ...args }: Route.MetaArgs) {
11+ return page(args, { title: `Tags · ${params.owner}/${params.repo} · g1t` });
12+}
13+
14+export async function loader({ params, context }: Route.LoaderArgs) {
15+ const path = { namespace: params.owner, name: params.repo };
16+ return { tags: unwrap(await repos.tags(path, getViewer(context))) };
17+}
18+
19+export default function Tags({ loaderData, params }: Route.ComponentProps) {
20+ const { tags } = loaderData;
21+ const base = `/${params.owner}/${params.repo}`;
22+ const path = (name: string) => name.split("/").map(encodeURIComponent).join("/");
23+ return (
24+ <div className="space-y-4">
25+ <h2 className="text-lg font-semibold tracking-tight">
26+ Tags <span className="font-normal text-faint">{tags.length}</span>
27+ </h2>
28+ {tags.length === 0 ? (
29+ <EmptyState title="No tags yet">
30+ A tag marks a version of the code: <code className="font-mono text-xs">git tag v1.0.0 && git push --tags</code>. Each
31+ one shows here with its commit and a download.
32+ </EmptyState>
33+ ) : (
34+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
35+ {tags.map((tag) => (
36+ <li key={tag.name} className="flex flex-wrap items-center gap-x-3 gap-y-1.5 px-4 py-3">
37+ <TagIcon size={15} className="shrink-0 text-faint" />
38+ <span className="min-w-0 grow basis-56">
39+ <Link to={`${base}/tree/${path(tag.name)}`} className="block truncate font-mono text-[0.8125rem] font-medium hover:text-accent">
40+ {tag.name}
41+ </Link>
42+ {tag.commit && (
43+ <span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted">
44+ <Avatar name={tag.commit.author.name} size={13} />
45+ <span className="shrink-0">{tag.commit.author.name}</span>
46+ <span className="text-faint">·</span>
47+ <Link to={`${base}/commit/${tag.commit.hash}`} className="min-w-0 truncate hover:text-fg" title={tag.commit.message}>
48+ {tag.commit.message.split("\n")[0]}
49+ </Link>
50+ <span className="shrink-0 text-faint">
51+ · <TimeAgo at={tag.commit.authoredAt} />
52+ </span>
53+ </span>
54+ )}
55+ </span>
56+ <span className="ml-6.5 flex shrink-0 items-center gap-3 text-xs sm:ml-0">
57+ {tag.commit && (
58+ <Link to={`${base}/commit/${tag.commit.hash}`} className="font-mono text-faint hover:text-fg">
59+ {tag.commit.hash.slice(0, 7)}
60+ </Link>
61+ )}
62+ <a
63+ href={`${base}/archive/${path(tag.name)}.zip`}
64+ download
65+ className="inline-flex items-center gap-1.5 rounded-md border border-line px-1.5 py-0.5 text-muted hover:border-line-strong hover:text-fg"
66+ >
67+ <FileArchive size={12} />
68+ ZIP
69+ </a>
70+ </span>
71+ </li>
72+ ))}
73+ </ul>
74+ )}
75+ </div>
76+ );
77+}
+7−2
2020 ]);
2121 // A branch that was renamed: the same folder on its new name.
2222 if (!tree.ok && tree.error.code === "not_found") await redirectIfBranchRenamed(request, path, viewer, params.ref);
23− return { tree: unwrap(tree), branches: branches?.ok ? branches.value : null };
23+ // Each entry's last commit walks history: streamed in after the list.
24+ const lastCommits = repos
25+ .lastCommits(path, viewer, params.ref, params["*"] ?? "")
26+ .then((found) => (found.ok ? found.value : null))
27+ .catch(() => null);
28+ return { tree: unwrap(tree), branches: branches?.ok ? branches.value : null, lastCommits };
2429 }
2530
2631 export default function Tree({ loaderData }: Route.ComponentProps) {
27− return <TreeView tree={loaderData.tree} branches={loaderData.branches} />;
32+ return <TreeView tree={loaderData.tree} branches={loaderData.branches} lastCommits={loaderData.lastCommits} />;
2833 }
+43−0
565565 pub hash: String,
566566 }
567567
568+/// `last_commits`: which commit last changed each entry of a directory at
569+/// `ref` (the default branch when absent). Returns `Outcome<LastCommits>`.
570+#[derive(Debug, Serialize, Deserialize)]
571+#[serde(rename_all = "camelCase")]
572+pub struct LastCommitsArgs {
573+ pub path: RepoPath,
574+ pub viewer: Viewer,
575+ #[serde(default, rename = "ref")]
576+ pub git_ref: Option<String>,
577+ #[serde(default)]
578+ pub tree_path: String,
579+}
580+
581+/// An entry of a directory and the commit that last changed it.
582+#[derive(Clone, Debug, Serialize, Deserialize)]
583+pub struct LastCommit {
584+ pub name: String,
585+ pub commit: Commit,
586+}
587+
588+/// The entries' last commits. `complete` is false when the history walked
589+/// ran out before every entry was placed; those entries are left out.
590+#[derive(Clone, Debug, Serialize, Deserialize)]
591+pub struct LastCommits {
592+ pub entries: Vec<LastCommit>,
593+ pub complete: bool,
594+}
595+
596+/// `tags`: the repository's tags, newest commit first, each with the
597+/// commit it names. Returns `Outcome<Vec<Tag>>`.
598+#[derive(Debug, Serialize, Deserialize)]
599+pub struct TagsArgs {
600+ pub path: RepoPath,
601+ pub viewer: Viewer,
602+}
603+
604+/// A tag, and its commit when it could be read.
605+#[derive(Clone, Debug, Serialize, Deserialize)]
606+pub struct Tag {
607+ pub name: String,
608+ pub commit: Option<Commit>,
609+}
610+
568611 /// `branches`: the repository's branches, default branch first.
569612 /// Returns `Outcome<Vec<Branch>>`.
570613 #[derive(Debug, Serialize, Deserialize)]
+2−0
285285 gitAccess: (path, viewer, service) =>
286286 call("git_access", { path, viewer, service }),
287287 branches: (path, viewer) => call("branches", { path, viewer }),
288+ lastCommits: (path, viewer, ref, treePath) => call("last_commits", { path, viewer, ref, treePath }),
289+ tags: (path, viewer) => call("tags", { path, viewer }),
288290 listFiles: (repoId, ref, limit) => call("list_files", { repoId, ref, skipDirs: [], limit }),
289291 rawBlobs: (repoId, hashes, maxBytes) => call("raw_blobs", { repoId, hashes, maxBytes }),
290292 commitFile: (repo, actor, file) => call("commit_file", { repo, actor, ...file }),
+12−0
234234 /** The repository's branches, default branch first. */
235235 branches(path: RepoPath, viewer: Viewer): Promise<Result<Branch[]>>;
236236
237+ /** Which commit last changed each entry of a directory at `ref` (the default branch when null). */
238+ lastCommits(path: RepoPath, viewer: Viewer, ref: string | null, treePath: string): Promise<Result<LastCommits>>;
239+
240+ /** The repository's tags, newest commit first, at most 100. */
241+ tags(path: RepoPath, viewer: Viewer): Promise<Result<Tag[]>>;
242+
237243 /**
238244 * Every file at `ref` (the default branch when null), at most `limit`
239245 * (10,000 at most). No viewer: check access first.
291297 /** A branch and the commit it points to. */
292298 export type Branch = { name: string; hash: string };
293299
300+/** Each entry's last commit; `complete` is false when some were not reached. */
301+export type LastCommits = { entries: { name: string; commit: Commit }[]; complete: boolean };
302+
303+/** A tag, and its commit when it could be read. */
304+export type Tag = { name: string; commit: Commit | null };
305+
294306 /** Files at a commit, and whether there were more than were listed. */
295307 export type FileList = { commit: string | null; files: { path: string; hash: string | null }[]; truncated: boolean };
296308
+224−0
1+//! Which commit last changed each entry of a directory, for the file list.
2+//!
3+//! History is walked newest first along the first-parent chain. Each commit
4+//! is compared with its parent only where the directory itself changed (its
5+//! tree hash differs), so the walk reads a tree only for the commits that
6+//! touched it. An entry is given the newest commit after which its hash is
7+//! no longer the same; one that never changes within the walk is given the
8+//! oldest commit reached if that is the root, and nothing otherwise.
9+
10+use std::collections::HashMap;
11+
12+use g1t_contracts::repos::{Commit, EntryKind, LastCommit, TreeEntry};
13+use worker::Result;
14+
15+use crate::store::GitRepo;
16+
17+/// How far back the history is walked.
18+pub const MAX_COMMITS: u32 = 300;
19+
20+/// Reads trees, remembering those already read: commits share most of them.
21+struct Trees<'a, R: GitRepo> {
22+ repo: &'a R,
23+ read: HashMap<String, Option<Vec<TreeEntry>>>,
24+}
25+
26+impl<'a, R: GitRepo> Trees<'a, R> {
27+ async fn get(&mut self, hash: &str) -> Result<Option<Vec<TreeEntry>>> {
28+ if let Some(found) = self.read.get(hash) {
29+ return Ok(found.clone());
30+ }
31+ let found = self.repo.read_tree(hash).await?;
32+ self.read.insert(hash.to_owned(), found.clone());
33+ Ok(found)
34+ }
35+
36+ /// The tree hash of `path` in a commit's root tree; the root for an empty path.
37+ async fn dir(&mut self, root: &str, path: &str) -> Result<Option<String>> {
38+ let mut hash = root.to_owned();
39+ for segment in path.split('/').filter(|segment| !segment.is_empty()) {
40+ let Some(entries) = self.get(&hash).await? else {
41+ return Ok(None);
42+ };
43+ match entries.into_iter().find(|entry| entry.name == segment && entry.kind == EntryKind::Tree) {
44+ Some(entry) => hash = entry.hash,
45+ None => return Ok(None),
46+ }
47+ }
48+ Ok(Some(hash))
49+ }
50+
51+ async fn entries(&mut self, dir: Option<&str>) -> Result<HashMap<String, String>> {
52+ let Some(dir) = dir else {
53+ return Ok(HashMap::new());
54+ };
55+ Ok(self.get(dir).await?.unwrap_or_default().into_iter().map(|entry| (entry.name, entry.hash)).collect())
56+ }
57+}
58+
59+/// The last commit of each entry of `path` at `git_ref`, and whether every
60+/// entry was given one.
61+pub async fn last_commits<R: GitRepo>(repo: &R, git_ref: &str, path: &str) -> Result<(Vec<LastCommit>, bool)> {
62+ let history = repo.log(git_ref, MAX_COMMITS).await?;
63+ let Some(head) = history.first() else {
64+ return Ok((Vec::new(), true));
65+ };
66+ let mut trees = Trees { repo, read: HashMap::new() };
67+ let mut dir = trees.dir(&head.tree_hash, path).await?;
68+ let mut current = trees.entries(dir.as_deref()).await?;
69+ let mut open: Vec<String> = current.keys().cloned().collect();
70+ let mut found: Vec<LastCommit> = Vec::new();
71+ let give = |found: &mut Vec<LastCommit>, name: String, commit: &Commit| found.push(LastCommit { name, commit: commit.clone() });
72+ for (index, commit) in history.iter().enumerate() {
73+ if open.is_empty() {
74+ break;
75+ }
76+ let Some(parent) = history.get(index + 1) else {
77+ // The oldest commit read. If it is the first commit there is,
78+ // what is left was added by it.
79+ if commit.parents.is_empty() {
80+ for name in open.drain(..) {
81+ give(&mut found, name, commit);
82+ }
83+ }
84+ break;
85+ };
86+ let parent_dir = trees.dir(&parent.tree_hash, path).await?;
87+ if parent_dir == dir {
88+ continue;
89+ }
90+ let before = trees.entries(parent_dir.as_deref()).await?;
91+ let (changed, still): (Vec<String>, Vec<String>) = open.into_iter().partition(|name| before.get(name) != current.get(name));
92+ for name in changed {
93+ give(&mut found, name, commit);
94+ }
95+ open = still;
96+ dir = parent_dir;
97+ current = before;
98+ }
99+ let complete = open.is_empty();
100+ Ok((found, complete))
101+}
102+
103+#[cfg(test)]
104+mod tests {
105+ use std::future::Future;
106+ use std::pin::pin;
107+ use std::task::{Context, Poll, Waker};
108+
109+ use g1t_contracts::repos::{Branch, GitAccess, Signature};
110+
111+ use super::*;
112+ use crate::store::Scope;
113+
114+ fn run<F: Future>(future: F) -> F::Output {
115+ match pin!(future).as_mut().poll(&mut Context::from_waker(Waker::noop())) {
116+ Poll::Ready(output) => output,
117+ Poll::Pending => panic!("the fake store never waits"),
118+ }
119+ }
120+
121+ #[derive(Default)]
122+ struct Fake {
123+ trees: HashMap<String, Vec<TreeEntry>>,
124+ history: Vec<Commit>,
125+ }
126+
127+ impl GitRepo for Fake {
128+ async fn access(&self, _scope: Scope) -> Result<GitAccess> {
129+ unimplemented!()
130+ }
131+ async fn branches(&self) -> Result<Vec<Branch>> {
132+ Ok(Vec::new())
133+ }
134+ async fn log(&self, _git_ref: &str, limit: u32) -> Result<Vec<Commit>> {
135+ Ok(self.history.iter().take(limit as usize).cloned().collect())
136+ }
137+ async fn parents(&self, _commit_hash: &str) -> Result<Option<Vec<String>>> {
138+ Ok(None)
139+ }
140+ async fn read_tree(&self, tree_hash: &str) -> Result<Option<Vec<TreeEntry>>> {
141+ Ok(self.trees.get(tree_hash).cloned())
142+ }
143+ async fn read_blob(&self, _blob_hash: &str) -> Result<Option<Vec<u8>>> {
144+ Ok(None)
145+ }
146+ async fn read_file(&self, _git_ref: &str, _path: &str) -> Result<Option<Vec<u8>>> {
147+ Ok(None)
148+ }
149+ async fn fork(&self, _target_key: &str) -> Result<()> {
150+ Ok(())
151+ }
152+ }
153+
154+ fn entry(name: &str, hash: &str, kind: EntryKind) -> TreeEntry {
155+ TreeEntry { name: name.into(), hash: hash.into(), kind }
156+ }
157+
158+ fn commit(hash: &str, tree: &str, parent: Option<&str>) -> Commit {
159+ Commit {
160+ hash: hash.into(),
161+ tree_hash: tree.into(),
162+ message: format!("commit {hash}"),
163+ author: Signature { name: "a".into(), email: "a@example.com".into() },
164+ parents: parent.map(|p| vec![p.to_owned()]).unwrap_or_default(),
165+ authored_at: String::new(),
166+ }
167+ }
168+
169+ /// c1 adds README and src/a.rs; c2 changes src/a.rs; c3 changes README.
170+ fn repo() -> Fake {
171+ let mut fake = Fake::default();
172+ fake.trees.insert("src1".into(), vec![entry("a.rs", "a1", EntryKind::Blob)]);
173+ fake.trees.insert("src2".into(), vec![entry("a.rs", "a2", EntryKind::Blob)]);
174+ fake.trees.insert("root1".into(), vec![entry("README.md", "r1", EntryKind::Blob), entry("src", "src1", EntryKind::Tree)]);
175+ fake.trees.insert("root2".into(), vec![entry("README.md", "r1", EntryKind::Blob), entry("src", "src2", EntryKind::Tree)]);
176+ fake.trees.insert("root3".into(), vec![entry("README.md", "r2", EntryKind::Blob), entry("src", "src2", EntryKind::Tree)]);
177+ fake.history = vec![commit("c3", "root3", Some("c2")), commit("c2", "root2", Some("c1")), commit("c1", "root1", None)];
178+ fake
179+ }
180+
181+ fn by_name(found: Vec<LastCommit>) -> HashMap<String, String> {
182+ found.into_iter().map(|last| (last.name, last.commit.hash)).collect()
183+ }
184+
185+ #[test]
186+ fn each_root_entry_gets_the_newest_commit_that_changed_it() {
187+ let (found, complete) = run(last_commits(&repo(), "main", "")).unwrap();
188+ assert!(complete);
189+ let found = by_name(found);
190+ assert_eq!(found["README.md"], "c3");
191+ assert_eq!(found["src"], "c2");
192+ }
193+
194+ #[test]
195+ fn a_subdirectory_is_walked_by_its_own_tree() {
196+ let (found, complete) = run(last_commits(&repo(), "main", "src")).unwrap();
197+ assert!(complete);
198+ assert_eq!(by_name(found)["a.rs"], "c2");
199+ }
200+
201+ #[test]
202+ fn an_entry_unchanged_since_the_first_commit_belongs_to_it() {
203+ let mut fake = repo();
204+ fake.trees.insert("root2".into(), vec![entry("README.md", "r1", EntryKind::Blob), entry("src", "src1", EntryKind::Tree)]);
205+ fake.trees.insert("root3".into(), vec![entry("README.md", "r2", EntryKind::Blob), entry("src", "src1", EntryKind::Tree)]);
206+ let (found, complete) = run(last_commits(&fake, "main", "")).unwrap();
207+ assert!(complete);
208+ assert_eq!(by_name(found)["src"], "c1");
209+ }
210+
211+ #[test]
212+ fn a_walk_cut_short_leaves_the_rest_unknown() {
213+ let mut fake = repo();
214+ // c1 has a parent the walk never reaches.
215+ fake.history[2].parents = vec!["c0".into()];
216+ fake.trees.insert("root2".into(), vec![entry("README.md", "r1", EntryKind::Blob), entry("src", "src1", EntryKind::Tree)]);
217+ fake.trees.insert("root3".into(), vec![entry("README.md", "r2", EntryKind::Blob), entry("src", "src1", EntryKind::Tree)]);
218+ let (found, complete) = run(last_commits(&fake, "main", "")).unwrap();
219+ assert!(!complete);
220+ let found = by_name(found);
221+ assert_eq!(found["README.md"], "c3");
222+ assert!(!found.contains_key("src"));
223+ }
224+}
+72−0
1515 mod git_ops;
1616 mod import;
1717 mod land;
18+mod last_commits;
1819 mod lifecycle;
1920 mod listing;
2021 mod meters;
5758 const MAX_TEXT_BYTES: usize = 512 * 1024;
5859 /// How far back a pull request may have forked and still be landed.
5960 const MAX_ANCESTRY: u32 = 1000;
61+/// The most tags a repository's Tags page reads and lists.
62+const MAX_TAGS_READ: usize = 100;
63+
64+/// One path segment, percent-encoded for a cache key.
65+fn urlencoding_segment(segment: &str) -> String {
66+ segment
67+ .bytes()
68+ .map(|b| if b.is_ascii_alphanumeric() || b"-._~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") })
69+ .collect()
70+}
6071 const MAX_DESCRIPTION_CHARS: usize = 200;
6172 pub(crate) const SOURCE: &str = "repos";
6273 pub(crate) const UNVERIFIED: &str = "Confirm your email address first. Check your inbox, or resend the link from the banner on g1t.sh.";
735746 Ok(Outcome::Ok(git.log(&git_ref, a.limit).await?))
736747 }
737748
749+ /// Which commit last changed each entry of a directory. Kept in this
750+ /// colo's cache by repository, head commit and path: a commit's history
751+ /// never changes, so an answer is good for as long as it is kept.
752+ async fn last_commits(&self, a: g1t_contracts::repos::LastCommitsArgs) -> Result<Outcome<g1t_contracts::repos::LastCommits>> {
753+ let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
754+ return Ok(not_found());
755+ };
756+ let git = self.read_git(&repo).await?;
757+ let git_ref = a.git_ref.unwrap_or_else(|| repo.default_branch.clone());
758+ let Some(head) = git.log(&git_ref, 1).await?.into_iter().next() else {
759+ return Ok(Outcome::fail(FailureCode::NotFound, "No such branch, tag or commit."));
760+ };
761+ let key = format!(
762+ "https://last-commits.g1t.internal/{}/{}/{}",
763+ repo.id,
764+ head.hash,
765+ a.tree_path.split('/').map(urlencoding_segment).collect::<Vec<_>>().join("/")
766+ );
767+ let cache = worker::Cache::default();
768+ if let Ok(Some(mut kept)) = cache.get(key.as_str(), false).await {
769+ if let Ok(found) = kept.json::<g1t_contracts::repos::LastCommits>().await {
770+ return Ok(Outcome::Ok(found));
771+ }
772+ }
773+ let (entries, complete) = last_commits::last_commits(&git, &head.hash, &a.tree_path).await?;
774+ let found = g1t_contracts::repos::LastCommits { entries, complete };
775+ if let Ok(mut response) = worker::Response::from_json(&found) {
776+ let _ = response.headers_mut().set("cache-control", "max-age=604800");
777+ let _ = cache.put(key.as_str(), response).await;
778+ }
779+ Ok(Outcome::Ok(found))
780+ }
781+
782+ /// The repository's tags, newest commit first, at most 100.
783+ async fn tags(&self, a: g1t_contracts::repos::TagsArgs) -> Result<Outcome<Vec<g1t_contracts::repos::Tag>>> {
784+ let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
785+ return Ok(not_found());
786+ };
787+ let git = self.store.open(&store_key(&repo)).await?;
788+ let access = git.access(Scope::Read).await?;
789+ let named: Vec<(String, String)> = refs::heads_and_tags(refs::all(&access).await?)
790+ .into_iter()
791+ .filter_map(|(name, hash)| name.strip_prefix("refs/tags/").map(|tag| (tag.to_owned(), hash)))
792+ .collect();
793+ let read = self.read_git(&repo).await?;
794+ let commits = futures_util::future::join_all(named.iter().take(MAX_TAGS_READ).map(|(_, hash)| read.log(hash, 1))).await;
795+ let mut tags: Vec<g1t_contracts::repos::Tag> = named
796+ .into_iter()
797+ .zip(commits.into_iter().map(|found| found.ok().and_then(|list| list.into_iter().next())).chain(std::iter::repeat(None)))
798+ .map(|((name, _), commit)| g1t_contracts::repos::Tag { name, commit })
799+ .collect();
800+ tags.sort_by(|a, b| {
801+ let at = |tag: &g1t_contracts::repos::Tag| tag.commit.as_ref().map(|c| c.authored_at.clone()).unwrap_or_default();
802+ at(b).cmp(&at(a)).then_with(|| b.name.cmp(&a.name))
803+ });
804+ tags.truncate(MAX_TAGS_READ);
805+ Ok(Outcome::Ok(tags))
806+ }
807+
738808 /// The repository's branches, default branch first.
739809 async fn branches(&self, a: BranchesArgs) -> Result<Outcome<Vec<Branch>>> {
740810 let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
19211991 "fork_for_pull" => reply(&repos.fork_for_pull(args(body)?).await?),
19221992 "git_access" => reply(&repos.git_access(args(body)?).await?),
19231993 "branches" => reply(&repos.branches(args(body)?).await?),
1994+ "last_commits" => reply(&repos.last_commits(args(body)?).await?),
1995+ "tags" => reply(&repos.tags(args(body)?).await?),
19241996 "head" => reply(&repos.head(args(body)?).await?),
19251997 "behind" => reply(&repos.behind(args(body)?).await?),
19261998 "divergence" => reply(&repos.divergence(args(body)?).await?),