Skip to content

Commit

Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016)

# Conflicts: # apps/web/app/components/ui/index.tsx

syntaqxcommitted Parents0bd8603791949cBrowse files
50 files+1381−1910/50 viewed
+2−1
412412 (`<username>@users.g1t.sh`) count as yours too. An unconfirmed address
413413 never attributes a commit, so nobody can claim your commits by adding your
414414 address. Commits whose address matches no account show the name in the
415−commit.
415+commit. See [Commits and your account](/guides/workspaces/#commits-and-your-account)
416+for setting your noreply address in git.
416417
417418 ### Email addresses through the API
418419
+53−0
681681 Someone signed out sees your public work and the workspaces where you made
682682 a public project; nothing else. The link preview for a profile uses only
683683 public work.
684+
685+### The card over a name
686+
687+Hold the pointer over a person's name or picture anywhere on g1t, or move
688+the keyboard focus to their name, and a card opens with their profile at a
689+glance:
690+
691+| On the card | Shown when |
692+| --- | --- |
693+| Picture, name, username and pronouns | Always |
694+| Bio and location | They filled them in |
695+| **Member of** | The same workspaces their profile shows you, at most three named |
696+| **Committed to this repository in the past day**, **week** or **month** | You opened it inside a repository you can read, and their latest commit on its default branch is that recent |
697+
698+On a touch screen no card opens: a tap goes to the profile. `@g1t` has a
699+card of its own, about putting g1t to work. `ghost`, which stands in for
700+deleted accounts, has no card.
701+
702+### Commits and your account
703+
704+A commit shows as yours, with your username, picture, profile link and
705+card, when its author address is one of your
706+[confirmed addresses](/guides/authentication/#email-addresses) or your
707+noreply address. This holds everywhere a commit appears: the Files page,
708+history, a commit, blame, branches, tags, comparisons and the
709+Contributors list, which counts every address of yours as one person.
710+
711+To have commits made on your own machine show as yours without publishing
712+your address, commit with your noreply address:
713+
714+1. Open [Settings → Emails](https://g1t.sh/settings/emails) and copy your
715+ noreply address. It looks like
716+ `<8 characters of your account id>+<username>@users.noreply.g1t.sh`.
717+2. Set it for every repository, or leave out `--global` for one:
718+
719+ ```sh
720+ git config --global user.email "6c1d0efg+sam@users.noreply.g1t.sh"
721+ ```
722+
723+3. Commit and push as usual. Commits you made before keep the address they
724+ were made with; add that address to your account and confirm it to have
725+ them show as yours.
726+
727+| A commit's address | Shown as |
728+| --- | --- |
729+| One of your confirmed addresses, or your noreply address | You |
730+| An address added to an account but not confirmed | The name in the commit |
731+| An address no account has | The name in the commit, with a plain picture, no link and no card |
732+| A deleted account's noreply address | `ghost` |
733+| g1t's own (`g1t@users.noreply.g1t.sh`) | `g1t` |
734+
735+`Co-authored-by` trailers are matched the same way, and their pictures sit
736+beside the author's. An address itself is never shown on g1t.
+8−7
1111 import { Fragment, useEffect, useMemo, useState } from "react";
1212 import { Link, useFetcher } from "react-router";
1313
14−import type { Blame, Commit } from "@g1t/contracts";
14+import type { ShownBlame, ShownCommit } from "../lib/commit-people";
1515
1616 import type { loader as whyLoader } from "../routes/repo/why";
1717 import { repoAt } from "../lib/markdown-plugins";
18+import { CommitAvatars, CommitNames } from "./commit-person";
1819 import { Markdown } from "./markdown";
19−import { Avatar, TimeAgo } from "./ui";
20+import { TimeAgo } from "./ui";
2021 import { Skeleton } from "./ui/skeleton";
2122
2223 /**
3637 lines: string[];
3738 /** Highlighted HTML per line, when the language is known. */
3839 html: string[] | null;
39− blame: Blame;
40+ blame: ShownBlame;
4041 }) {
4142 const commits = useMemo(
42− () => new Map<string, Commit>(blame.commits.map((commit) => [commit.hash, commit])),
43+ () => new Map<string, ShownCommit>(blame.commits.map((commit) => [commit.hash, commit])),
4344 [blame.commits],
4445 );
4546 // Newer commits are brighter: rank them by when they were written.
121122 <td className="w-72 max-w-72 border-r border-line px-3 align-top font-sans">
122123 {first && commit && (
123124 <span className="flex items-center gap-2 py-0.5 text-xs">
124− <Avatar name={commit.author.name} size={16} />
125+ <CommitAvatars commit={commit} size={16} max={2} />
125126 <span className={`min-w-0 grow truncate ${selected ? "text-fg" : "text-muted"}`}>
126127 {commit.message.split("\n")[0]}
127128 </span>
232233 {current.commit.message.split("\n")[0]}
233234 </Link>
234235 <p className="mt-1.5 flex items-center gap-2 text-xs text-muted">
235− <Avatar name={current.commit.author.name} size={14} />
236− {current.commit.author.name} · <TimeAgo at={current.commit.authoredAt} /> ·{" "}
236+ <CommitAvatars commit={current.commit} size={14} />
237+ <CommitNames commit={current.commit} className="text-fg" /> · <TimeAgo at={current.commit.authoredAt} /> ·{" "}
237238 <span className="font-mono">{current.commit.hash.slice(0, 7)}</span>
238239 </p>
239240 </section>
+8−4
44 import type { Pull } from "@g1t/contracts";
55
66 import type { Drift } from "../lib/branches";
7+import type { CommitPerson } from "../lib/commit-people";
8+import { CommitAvatars, CommitNames } from "./commit-person";
79 import { CheckBadge } from "./checks";
810 import { type ChecksSource, CommitChecksBadge } from "./commit-checks";
911 import { host } from "./deploy";
10−import { Avatar, TimeAgo } from "./ui";
12+import { TimeAgo } from "./ui";
1113 import { Hint } from "./ui/hint";
1214
1315 function drift({ ahead, behind }: Drift, main: string): string {
1618
1719 export type ActiveBranch = {
1820 name: string;
19− commit: { hash: string; message: string; author: string; at: string } | null;
21+ commit: { hash: string; message: string; author: CommitPerson; coAuthors: CommitPerson[]; at: string } | null;
2022 /** Null when the two histories were not read far enough to meet. */
2123 drift: Drift | null;
2224 pull: { number: number; title: string; checkStatus: Pull["checkStatus"]; draft: boolean } | null;
5052 </Link>
5153 {branch.commit && (
5254 <span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted">
53− <Avatar name={branch.commit.author} size={13} />
54− <span className="shrink-0">{branch.commit.author}</span>
55+ <CommitAvatars commit={branch.commit} size={14} max={2} />
56+ <span className="shrink-0">
57+ <CommitNames commit={branch.commit} className="hover:text-fg" />
58+ </span>
5559 <span className="text-faint">·</span>
5660 <Hint label={branch.commit.message}>
5761 <Link to={`${base}/commit/${branch.commit.hash}`} className="min-w-0 truncate hover:text-fg">
+6−3
1717
1818 import { Badge } from "./ui/badge";
1919 import { Skeleton } from "./ui/skeleton";
20+import { UserCard } from "./user-card";
2021
2122 /** The guide to the file. */
2223 export const CODEOWNERS_DOCS = "https://docs.g1t.sh/guides/codeowners/";
4546 }
4647 if (owner.startsWith("@") && /^[a-z0-9-]{1,39}$/i.test(name) && name !== "g1t") {
4748 return (
48− <Link to={`/u/${name.toLowerCase()}`} className={className}>
49− {owner}
50− </Link>
49+ <UserCard username={name.toLowerCase()}>
50+ <Link to={`/u/${name.toLowerCase()}`} className={className}>
51+ {owner}
52+ </Link>
53+ </UserCard>
5154 );
5255 }
5356 return <span className="font-mono text-xs text-fg-soft">{owner}</span>;
+132−0
1+import { User } from "lucide-react";
2+import { Link } from "react-router";
3+
4+import { type CommitPerson, type ShownCommit, profileHref, shownName } from "../lib/commit-people";
5+import { cn } from "../lib/cn";
6+import { Avatar } from "./ui";
7+import { UserCard } from "./user-card";
8+
9+/**
10+ * A person on a commit, as an avatar: theirs and their card when the
11+ * commit is matched to an account (or is g1t's), else a plain one.
12+ */
13+export function CommitAvatar({ person, size = 20 }: { person: CommitPerson; size?: number }) {
14+ if (person.kind === "user" || person.kind === "g1t") {
15+ const href = profileHref(person);
16+ const avatar = <Avatar name={shownName(person)} image={person.avatar} size={size} />;
17+ return (
18+ <UserCard username={person.username}>
19+ {href ? (
20+ // Its name beside it is the link a keyboard reaches; this is for the pointer.
21+ <Link to={href} tabIndex={-1} aria-hidden="true" className="relative z-10 inline-flex shrink-0 rounded-full">
22+ {avatar}
23+ </Link>
24+ ) : (
25+ <span className="relative z-10 inline-flex shrink-0">{avatar}</span>
26+ )}
27+ </UserCard>
28+ );
29+ }
30+ // Nobody on g1t, or a deleted account: a plain silhouette, no card.
31+ return (
32+ <span
33+ aria-hidden="true"
34+ className="inline-flex shrink-0 items-center justify-center rounded-full bg-line text-faint"
35+ style={{ width: size, height: size }}
36+ >
37+ <User size={Math.round(size * 0.62)} strokeWidth={2.25} />
38+ </span>
39+ );
40+}
41+
42+/**
43+ * A commit's author and co-authors, as overlapping avatars: the author's
44+ * on top, at most `max`.
45+ */
46+export function CommitAvatars({ commit, size = 20, max = 3 }: { commit: Pick<ShownCommit, "author" | "coAuthors">; size?: number; max?: number }) {
47+ const people = [commit.author, ...commit.coAuthors].slice(0, max);
48+ if (people.length === 1) return <CommitAvatar person={commit.author} size={size} />;
49+ return (
50+ <span className="inline-flex shrink-0 items-center">
51+ {people.map((person, index) => (
52+ <span
53+ key={`${index}:${person.username ?? person.name}`}
54+ className="inline-flex rounded-full ring-2 ring-bg"
55+ style={{ marginLeft: index === 0 ? 0 : -Math.round(size * 0.35), zIndex: people.length - index }}
56+ >
57+ <CommitAvatar person={person} size={size} />
58+ </span>
59+ ))}
60+ </span>
61+ );
62+}
63+
64+/**
65+ * A person on a commit, by name: their username, linked to their profile
66+ * with their card, for an account; g1t with its label and card; ghost for
67+ * a deleted account; else the name written on the commit, as text.
68+ */
69+export function CommitName({ person, className }: { person: CommitPerson; className?: string }) {
70+ if (person.kind === "g1t") {
71+ return (
72+ <UserCard username="g1t">
73+ <span tabIndex={0} className="relative z-10 inline-flex items-baseline gap-1 rounded outline-none focus-visible:ring-2 focus-visible:ring-accent">
74+ <span className={className}>g1t</span>
75+ <span className="rounded border border-line px-1 text-[0.625rem] leading-[1.35] font-medium text-muted">bot</span>
76+ </span>
77+ </UserCard>
78+ );
79+ }
80+ const href = profileHref(person);
81+ if (!href) return <span className={className}>{shownName(person)}</span>;
82+ return (
83+ <UserCard username={person.username}>
84+ <Link to={href} className={cn("relative z-10 hover:underline", className)}>
85+ {shownName(person)}
86+ </Link>
87+ </UserCard>
88+ );
89+}
90+
91+/**
92+ * Who made a commit, in words: the author, and its co-authors after them
93+ * ("ada and grace", "ada, grace and 2 others").
94+ */
95+export function CommitNames({
96+ commit,
97+ className,
98+ all,
99+}: {
100+ commit: Pick<ShownCommit, "author" | "coAuthors">;
101+ className?: string;
102+ /** Name every co-author, rather than counting past the first. */
103+ all?: boolean;
104+}) {
105+ const others = commit.coAuthors;
106+ if (others.length === 0) return <CommitName person={commit.author} className={className} />;
107+ if (all && others.length > 1) {
108+ const people = [commit.author, ...others];
109+ return (
110+ <>
111+ {people.map((person, index) => (
112+ <span key={index}>
113+ {index > 0 && (index === people.length - 1 ? " and " : ", ")}
114+ <CommitName person={person} className={className} />
115+ </span>
116+ ))}
117+ </>
118+ );
119+ }
120+ if (others.length === 1) {
121+ return (
122+ <>
123+ <CommitName person={commit.author} className={className} /> and <CommitName person={others[0]!} className={className} />
124+ </>
125+ );
126+ }
127+ return (
128+ <>
129+ <CommitName person={commit.author} className={className} /> and {others.length} others
130+ </>
131+ );
132+}
+13−1
1616 import remarkGfm from "remark-gfm";
1717
1818 import { Checkbox } from "./ui/checkbox";
19−import { type AlertKind, type MarkdownRepo, rehypeAlerts, rehypeReferences } from "../lib/markdown-plugins";
19+import { type AlertKind, G1T_MENTION_HREF, type MarkdownRepo, rehypeAlerts, rehypeReferences } from "../lib/markdown-plugins";
20+import { UserCard } from "./user-card";
2021
2122 /** The text inside a React tree, for anchors and copying. */
2223 function textOf(node: ReactNode): string {
178179 h4: ({ children }) => <Heading level={4}>{children}</Heading>,
179180 a({ href = "", children, node }) {
180181 const ref = (node?.properties as { dataRef?: string } | undefined)?.dataRef;
182+ if (ref === "mention") {
183+ // `@name` may be a person (with a card) or a workspace (none).
184+ const name = href === G1T_MENTION_HREF ? "g1t" : href.replace(/^\//, "");
185+ return (
186+ <UserCard username={name}>
187+ <Link to={href} prefetch="intent" className="font-medium">
188+ {children}
189+ </Link>
190+ </UserCard>
191+ );
192+ }
181193 if (ref) {
182194 return (
183195 <Link
+6−3
1010
1111 import { Markdown } from "./markdown";
1212 import { Pill, TimeAgo } from "./ui";
13+import { UserCard } from "./user-card";
1314
1415 export function encodeTag(tag: string): string {
1516 return tag.split("/").map(encodeURIComponent).join("/");
5253 </div>
5354 {release.author && (
5455 <p className="mt-1 text-xs text-faint">
55− <Link to={`/u/${release.author}`} className="font-medium text-muted hover:text-accent">
56− {release.author}
57− </Link>{" "}
56+ <UserCard username={release.author}>
57+ <Link to={`/u/${release.author}`} className="font-medium text-muted hover:text-accent">
58+ {release.author}
59+ </Link>
60+ </UserCard>{" "}
5861 {release.draft ? "drafted this" : "released this"} <TimeAgo at={when} />
5962 </p>
6063 )}
+29−15
2626
2727 import type { PackageSummary, Repo, RepoAbout } from "@g1t/contracts";
2828
29−import { compact, contributorHref, count, languageBar, licenseLabel } from "../lib/about";
29+import { compact, count, languageBar, licenseLabel } from "../lib/about";
30+import { contributorPerson, profileHref, shownName } from "../lib/commit-people";
31+import { CommitAvatar } from "./commit-person";
32+import { UserCard } from "./user-card";
3033 import { PackageIcon as EcosystemIcon } from "./package-icon";
3134 import { Topics } from "./topics";
3235 import { Avatar, Pill, TimeAgo } from "./ui";
221224 <>
222225 <ul className="flex flex-wrap gap-1.5">
223226 {shown.map((contributor) => {
224− const href = contributorHref(contributor);
225− const label = `${contributor.name} · ${count(contributor.commits, "commit")}`;
226− const face = <Avatar name={contributor.name} image={contributor.avatar} size={30} system={contributor.kind === "g1t"} />;
227+ // The same person, avatar, link and card as on their commits.
228+ const person = contributorPerson(contributor);
229+ const href = profileHref(person);
230+ const label = `${shownName(person)} · ${count(contributor.commits, "commit")}`;
231+ const face = <Avatar name={shownName(person)} image={person.avatar} size={30} />;
227232 return (
228− <li key={`${contributor.kind}:${contributor.name}`}>
229− <Hint label={label}>
230− {href ? (
231− <Link to={href} aria-label={label} className="block rounded-full">
232− {face}
233− </Link>
234− ) : (
235− <span aria-label={label} className="block rounded-full">
236− {face}
233+ <li key={`${contributor.kind}:${contributor.username ?? contributor.name}`}>
234+ {person.kind === "user" || person.kind === "g1t" ? (
235+ <UserCard username={person.username}>
236+ {href ? (
237+ <Link to={href} aria-label={label} className="block rounded-full">
238+ {face}
239+ </Link>
240+ ) : (
241+ <span tabIndex={0} aria-label={label} className="block rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent">
242+ {face}
243+ </span>
244+ )}
245+ </UserCard>
246+ ) : (
247+ // Nobody on g1t: no card, only the name on their commits.
248+ <Hint label={label}>
249+ <span tabIndex={0} aria-label={label} className="block rounded-full">
250+ <CommitAvatar person={person} size={30} />
237251 </span>
238− )}
239− </Hint>
252+ </Hint>
253+ )}
240254 </li>
241255 );
242256 })}
+16−8
22 import { type ReactNode, Suspense } from "react";
33 import { Await, Form, Link } from "react-router";
44
5−import type { Blame, BlobView as Blob, Branch, Commit, LastCommits, TreeView as Tree } from "@g1t/contracts";
5+import type { BlobView as Blob, Branch, TreeView as RawTree } from "@g1t/contracts";
6+
7+import type { FileCommits, ShownBlame, ShownCommit } from "../lib/commit-people";
8+import { CommitAvatars, CommitNames } from "./commit-person";
69
710 import type { DeploymentEnvironments } from "@g1t/contracts";
811
1619 import { type ChecksSource, CommitChecksBadge } from "./commit-checks";
1720 import { type AboutData, RepoAboutPanel } from "./repo-about";
1821 import { Markdown } from "./markdown";
19−import { Avatar, CopyLine, TimeAgo, notACredential } from "./ui";
22+import { CopyLine, TimeAgo, notACredential } from "./ui";
2023 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu";
2124 import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
2225 import { Hint } from "./ui/hint";
6871 }
6972
7073 /** The bar above a file listing: the latest commit, its checks, and the way to the history. */
71−function CommitBar({ commit, base, checks }: { commit: Commit; base: string; checks?: ChecksSource }) {
74+/** A tree as the page gets it: its latest commit with its people. */
75+export type Tree = Omit<RawTree, "head"> & { head: ShownCommit | null };
76+
77+function CommitBar({ commit, base, checks }: { commit: ShownCommit; base: string; checks?: ChecksSource }) {
7278 return (
7379 <div className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5 text-sm">
74− <Avatar name={commit.author.name} />
75− <span className="shrink-0 font-medium">{commit.author.name}</span>
80+ <CommitAvatars commit={commit} />
81+ <span className="shrink-0 font-medium">
82+ <CommitNames commit={commit} />
83+ </span>
7684 <Link to={`${base}/commit/${commit.hash}`} className="truncate text-muted hover:text-fg hover:underline">
7785 {commit.message.split("\n")[0]}
7886 </Link>
241249 gitRef: string;
242250 prefix: string;
243251 entries: Tree["entries"];
244− last: LastCommits | null | undefined;
252+ last: FileCommits | null | undefined;
245253 }) {
246254 const byName = new Map((last?.entries ?? []).map((entry) => [entry.name, entry.commit]));
247255 return (
294302 tree: Tree;
295303 branches?: Branch[] | null;
296304 /** Each entry's last commit, streamed in after the list. */
297− lastCommits?: Promise<LastCommits | null> | null;
305+ lastCommits?: Promise<FileCommits | null> | null;
298306 /** The latest commit's checks, streamed in. */
299307 checks?: ChecksSource;
300308 /** The About beside the files, at the root: streamed in after them. */
406414 /** Syntax-highlighted HTML per line, when the language is known. */
407415 html: string[] | null;
408416 /** Shown instead of the plain file when asked for, with HTML per line. */
409− blame?: { blame: Blame; lines: string[] | null } | null;
417+ blame?: { blame: ShownBlame; lines: string[] | null } | null;
410418 }) {
411419 const { repo, ref, path, size, text } = blob;
412420 const lines = text?.replace(/\n$/, "").split("\n");
+37−0
1+import { HoverCard as Primitive } from "radix-ui";
2+import type { ComponentProps } from "react";
3+
4+import { cn } from "../../lib/cn";
5+
6+// shadcn/ui's hover card, styled with g1t's tokens. It opens on hover and
7+// keyboard focus, never on touch: a tap on its trigger does what the
8+// trigger does. Hover hints (a label, nothing more) use the Tooltip.
9+
10+export function HoverCard({ openDelay = 450, closeDelay = 150, ...props }: ComponentProps<typeof Primitive.Root>) {
11+ return <Primitive.Root openDelay={openDelay} closeDelay={closeDelay} {...props} />;
12+}
13+
14+export const HoverCardTrigger = Primitive.Trigger;
15+
16+export function HoverCardContent({
17+ className,
18+ align = "start",
19+ sideOffset = 6,
20+ ...props
21+}: ComponentProps<typeof Primitive.Content>) {
22+ return (
23+ <Primitive.Portal>
24+ <Primitive.Content
25+ align={align}
26+ sideOffset={sideOffset}
27+ collisionPadding={12}
28+ className={cn(
29+ "z-50 w-80 max-w-[calc(100vw-2rem)] rounded-xl border border-line-strong bg-raised p-4 text-sm text-fg shadow-xl shadow-black/40 outline-none",
30+ "origin-(--radix-hover-card-content-transform-origin) data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
31+ className,
32+ )}
33+ {...props}
34+ />
35+ </Primitive.Portal>
36+ );
37+}
+14−1
1−import { Check, Copy, LoaderCircle } from "lucide-react";
1+import { Check, Copy, LoaderCircle, User } from "lucide-react";
22 import { type ComponentProps, Fragment, type ReactNode, useState } from "react";
33 import { Link, type LinkProps, NavLink, useLocation, useNavigation } from "react-router";
44
316316 </span>
317317 );
318318 }
319+ // ghost stands in for deleted accounts: a plain silhouette, as for
320+ // anyone on a commit who has no account.
321+ if (name === "ghost" && !image) {
322+ return (
323+ <span
324+ aria-hidden="true"
325+ className="inline-flex shrink-0 items-center justify-center rounded-full bg-line text-faint"
326+ style={{ width: size, height: size }}
327+ >
328+ <User size={Math.round(size * 0.62)} strokeWidth={2.25} />
329+ </span>
330+ );
331+ }
319332 if (image && failed !== image) {
320333 return (
321334 <img
+169−0
1+import { Building2, GitCommitHorizontal, MapPin } from "lucide-react";
2+import { type ReactElement, type ReactNode, useEffect, useState } from "react";
3+import { Link, useParams } from "react-router";
4+
5+import { type Card, type UserCard as UserCardData, cardHref, committedLabel } from "../lib/hovercard";
6+import { G1T_MENTION_HREF } from "../lib/markdown-plugins";
7+import { Avatar } from "./ui";
8+import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
9+import { Skeleton } from "./ui/skeleton";
10+
11+/**
12+ * Cards read this visit, by address: each is asked for once, whichever
13+ * name or avatar opened it. A failure is forgotten, so the next hover asks
14+ * again.
15+ */
16+const cards = new Map<string, Promise<Card | null>>();
17+
18+function readCard(href: string): Promise<Card | null> {
19+ let found = cards.get(href);
20+ if (!found) {
21+ found = fetch(href, { headers: { accept: "application/json" } })
22+ .then((response) => (response.ok ? (response.json() as Promise<Card>) : response.status === 404 ? null : Promise.reject(new Error(String(response.status)))))
23+ .catch(() => {
24+ cards.delete(href);
25+ return null;
26+ });
27+ cards.set(href, found);
28+ }
29+ return found;
30+}
31+
32+/** Whether a name can have a card: a username, and not a deleted account's. */
33+export function hasCard(username: string | null | undefined): username is string {
34+ return Boolean(username) && username !== "ghost" && /^[a-z0-9-]{1,39}$/i.test(username!);
35+}
36+
37+/**
38+ * A person's card, over their name or avatar (`children`, one element that
39+ * takes a ref). It opens on hover and on keyboard focus after a moment,
40+ * and is read only then. On a touch screen it never opens: a tap does what
41+ * the name does. Inside a repository it says whether they committed there
42+ * lately. Ghost, a deleted account, has none.
43+ */
44+export function UserCard({ username, children }: { username: string | null | undefined; children: ReactElement }) {
45+ const params = useParams();
46+ const [open, setOpen] = useState(false);
47+ const [card, setCard] = useState<Card | null | undefined>(undefined);
48+ const name = username?.toLowerCase() ?? "";
49+ const repo = params.owner && params.repo ? `${params.owner}/${params.repo}` : null;
50+ const g1t = name === "g1t";
51+ const href = cardHref(name, repo);
52+
53+ useEffect(() => {
54+ if (!open || g1t || !hasCard(name)) return;
55+ let current = true;
56+ void readCard(href).then((found) => {
57+ if (current) setCard(found);
58+ });
59+ return () => {
60+ current = false;
61+ };
62+ }, [open, g1t, name, href]);
63+
64+ if (!hasCard(name)) return children;
65+ // Nobody by that name (a workspace mentioned, say): no card at all.
66+ const body = g1t || card?.kind === "g1t" ? <G1tCard /> : card === undefined ? <Loading /> : card ? <PersonCard card={card} /> : null;
67+ return (
68+ <HoverCard open={open} onOpenChange={setOpen}>
69+ <HoverCardTrigger asChild>{children}</HoverCardTrigger>
70+ {body && <HoverCardContent aria-label={g1t ? "About g1t" : `About ${name}`}>{body}</HoverCardContent>}
71+ </HoverCard>
72+ );
73+}
74+
75+function Loading() {
76+ return (
77+ <div aria-busy="true" className="flex items-center gap-3">
78+ <Skeleton className="size-12 rounded-full" />
79+ <div className="grow space-y-2">
80+ <Skeleton className="block h-3.5 w-28" />
81+ <Skeleton className="block h-3 w-20" />
82+ </div>
83+ </div>
84+ );
85+}
86+
87+function PersonCard({ card }: { card: UserCardData }) {
88+ const profile = `/u/${card.username}`;
89+ return (
90+ <div className="space-y-3">
91+ <div className="flex items-start gap-3">
92+ <Link to={profile} tabIndex={-1} className="shrink-0 rounded-full">
93+ <Avatar name={card.username} image={card.avatar} size={48} />
94+ </Link>
95+ <div className="min-w-0 pt-0.5">
96+ <Link to={profile} className="block leading-tight hover:underline">
97+ {card.name ? (
98+ <>
99+ <span className="font-semibold wrap-anywhere">{card.name}</span>{" "}
100+ <span className="font-mono text-[0.8125rem] text-muted">{card.username}</span>
101+ </>
102+ ) : (
103+ <span className="font-mono font-semibold">{card.username}</span>
104+ )}
105+ </Link>
106+ {card.pronouns && <p className="mt-0.5 text-xs text-faint">{card.pronouns}</p>}
107+ </div>
108+ </div>
109+ {card.bio && <p className="leading-relaxed text-fg/90 wrap-anywhere">{card.bio}</p>}
110+ {(card.location || card.workspaces.length > 0 || card.committed) && (
111+ <ul className="space-y-1.5 text-[0.8125rem] text-muted">
112+ {card.location && (
113+ <Line icon={<MapPin size={14} />}>
114+ <span className="wrap-anywhere">{card.location}</span>
115+ </Line>
116+ )}
117+ {card.workspaces.length > 0 && (
118+ <Line icon={<Building2 size={14} />}>
119+ Member of{" "}
120+ {card.workspaces.map((workspace, index) => (
121+ <span key={workspace.slug}>
122+ {index > 0 && ", "}
123+ <Link to={`/${workspace.slug}`} className="font-medium text-fg hover:underline">
124+ {workspace.name}
125+ </Link>
126+ </span>
127+ ))}
128+ {card.more_workspaces > 0 && ` and ${card.more_workspaces} more`}
129+ </Line>
130+ )}
131+ {card.committed && <Line icon={<GitCommitHorizontal size={14} />}>{committedLabel(card.committed)}</Line>}
132+ </ul>
133+ )}
134+ </div>
135+ );
136+}
137+
138+function G1tCard() {
139+ return (
140+ <div className="space-y-3">
141+ <div className="flex items-center gap-3">
142+ <Avatar name="g1t" size={48} />
143+ <div className="min-w-0">
144+ <p className="flex items-baseline gap-1.5 leading-tight">
145+ <span className="font-mono font-semibold">g1t</span>
146+ <span className="rounded border border-line px-1 text-[0.625rem] leading-[1.35] font-medium text-muted">bot</span>
147+ </p>
148+ <p className="mt-0.5 text-xs text-muted">g1t's agent</p>
149+ </div>
150+ </div>
151+ <p className="leading-relaxed text-fg/90">
152+ Mention <span className="font-medium text-accent">@g1t</span> in a comment or assign it an issue, and it opens a
153+ pull request and sees it through.
154+ </p>
155+ <a href={G1T_MENTION_HREF} className="inline-block text-[0.8125rem] text-accent hover:underline">
156+ Working with g1t
157+ </a>
158+ </div>
159+ );
160+}
161+
162+function Line({ icon, children }: { icon: ReactElement; children: ReactNode }) {
163+ return (
164+ <li className="flex items-start gap-2">
165+ <span className="mt-0.5 shrink-0 text-faint">{icon}</span>
166+ <span className="min-w-0">{children}</span>
167+ </li>
168+ );
169+}
+23−9
2424 } from "./ui/alert-dialog";
2525 import { CheckboxOption } from "./ui/checkbox";
2626 import { Hint } from "./ui/hint";
27+import { UserCard } from "./user-card";
2728
2829 export { IssueIcon, PullIcon };
2930
203204 label?: string;
204205 children?: ReactNode;
205206 }) {
206− // g1t itself has no profile; its name carries a small label instead.
207+ // g1t itself has no profile; its name carries a small label instead, and
208+ // its card says what it is.
207209 if (name === "g1t" && !children) {
208210 return (
209− <span className="inline-flex items-baseline gap-1">
210− <span className={className}>g1t</span>
211− <span className="rounded border border-line px-1 text-[0.625rem] leading-[1.35] font-medium text-muted">bot</span>
212− </span>
211+ <UserCard username="g1t">
212+ <span tabIndex={0} className="inline-flex items-baseline gap-1 rounded outline-none focus-visible:ring-2 focus-visible:ring-accent">
213+ <span className={className}>g1t</span>
214+ <span className="rounded border border-line px-1 text-[0.625rem] leading-[1.35] font-medium text-muted">bot</span>
215+ </span>
216+ </UserCard>
217+ );
218+ }
219+ if (name === "g1t") {
220+ return (
221+ <UserCard username="g1t">
222+ <span className={className}>{children}</span>
223+ </UserCard>
213224 );
214225 }
215− if (name === "g1t" || !/^[a-z0-9-]{1,39}$/i.test(name)) {
226+ // Ghost, a deleted account, has no profile and no card.
227+ if (name === "ghost" || !/^[a-z0-9-]{1,39}$/i.test(name)) {
216228 return <span className={className}>{children ?? name}</span>;
217229 }
218230 return (
219− <Link to={`/u/${name.toLowerCase()}`} className={className} aria-label={label}>
220− {children ?? name}
221− </Link>
231+ <UserCard username={name}>
232+ <Link to={`/u/${name.toLowerCase()}`} className={className} aria-label={label}>
233+ {children ?? name}
234+ </Link>
235+ </UserCard>
222236 );
223237 }
224238
+1−8
33
44 import type { G1tEvent } from "@g1t/contracts";
55
6−import { activityLine, alignWeeks, contributorHref, count, languageBar, licenseLabel, peak } from "./about.ts";
6+import { activityLine, alignWeeks, count, languageBar, licenseLabel, peak } from "./about.ts";
77
88 test("a license is named by its SPDX id, or not at all", () => {
99 assert.equal(licenseLabel({ spdxId: "MIT", name: "MIT License", path: "LICENSE" }), "MIT license");
3030 assert.equal(bar[2].percent, 0.5);
3131 assert.deepEqual(languageBar([rust, { ...nix, percent: 0 }]), [rust], "nothing to show for nothing");
3232 assert.deepEqual(languageBar([]), []);
33−});
34−
35−test("only accounts lead to a profile", () => {
36− const base = { name: "ada", commits: 1, firstAt: "", lastAt: "", weeks: [] };
37− assert.equal(contributorHref({ ...base, kind: "user", username: "ada" }), "/u/ada");
38− assert.equal(contributorHref({ ...base, kind: "author" }), null);
39− assert.equal(contributorHref({ ...base, kind: "g1t" }), null);
4033 });
4134
4235 test("weeks line up with the repository's", () => {
+1−5
33 * pages say, worked out without the server: labels, the language bar,
44 * and an activity line for each event worth one.
55 */
6−import type { Contributor, G1tEvent, LanguageShare, License, Project, WeekCommits } from "@g1t/contracts";
6+import type { G1tEvent, LanguageShare, License, Project, WeekCommits } from "@g1t/contracts";
77
88 /**
99 * The address the About links to: the project's homepage (its own, or its
4141 return [...shown, { name: "Other", color: null, bytes, percent }];
4242 }
4343
44−/** Where a contributor's name leads: their profile, for an account. */
45−export function contributorHref(contributor: Contributor): string | null {
46− return contributor.kind === "user" && contributor.username ? `/u/${contributor.username}` : null;
47−}
4844
4945 /** The tallest week, for a chart's scale: never zero. */
5046 export function peak(weeks: WeekCommits[]): number {
+8−2
44 * it with its checks, and its preview. The overview shows the newest few;
55 * the Branches page shows them all.
66 */
7−import type { Branch, BranchDrifts, Pull, RepoPath, Viewer } from "@g1t/contracts";
7+import type { Branch, BranchDrifts, Commit, Pull, RepoPath, Viewer } from "@g1t/contracts";
88
99 import type { ActiveBranch } from "../components/branches";
1010 import { activeBranches, branchesToRead, summary } from "./branches";
11+import { addressesToMatch, showCommit } from "./commit-people";
12+import { emailOwners } from "./commit-people.server";
1113 import { repos } from "./services.server";
1214
1315 type Preview = { branch?: string | null; number?: number | null; url: string };
3739 .then((found) => (found.ok ? found.value : null))
3840 .catch(() => null)
3941 : null;
40− return { main: input.defaultBranch, total, shown: activeBranches(reading, measured, input), head: summary(measured?.base) };
42+ // Every head commit's people, in one identity call.
43+ const heads = [measured?.base, ...(measured?.branches ?? []).map((one) => one.commit)].filter((commit): commit is Commit => commit != null);
44+ const owners = await emailOwners(addressesToMatch(heads));
45+ const people = (commit: Commit) => showCommit(commit, owners);
46+ return { main: input.defaultBranch, total, shown: activeBranches(reading, measured, input, people), head: summary(measured?.base, people) };
4147 }
+2−1
4747 assert.deepEqual(shown.map((b) => b.name), ["idea", "fix", "old"]);
4848 assert.deepEqual(shown[1], {
4949 name: "fix",
50− commit: { hash: "f2", message: "f2", author: "Ada", at: "2026-10-07T00:00:00Z" },
50+ // No accounts given: the name on the commit, and never its address.
51+ commit: { hash: "f2", message: "f2", author: { kind: "author", name: "Ada", username: null, avatar: null }, coAuthors: [], at: "2026-10-07T00:00:00Z" },
5152 drift: { ahead: 2, behind: 1 },
5253 pull: { number: 7, title: "Fix it", checkStatus: "passed", draft: false },
5354 preview: "https://fix.g1t.page",
+21−4
66 import type { Branch, BranchDrifts, Commit, Pull } from "@g1t/contracts";
77
88 import type { ActiveBranch } from "../components/branches";
9+import type { ShownCommit } from "./commit-people";
910
1011 /**
1112 * How far a branch has moved from the default branch: commits it has that
1718
1819 type Preview = { branch?: string | null; number?: number | null; url: string };
1920
20−/** A commit as a branch row shows it: its first line. */
21−export const summary = (commit: Commit | null | undefined): ActiveBranch["commit"] =>
22− commit ? { hash: commit.hash, message: commit.message.split("\n")[0] ?? "", author: commit.author.name, at: commit.authoredAt } : null;
21+/** A commit's people, as lib/commit-people.ts `showCommit` finds them. */
22+export type People = (commit: Commit) => Pick<ShownCommit, "author" | "coAuthors">;
2323
24+/** Nobody matched to an account: the name on the commit. */
25+const byName: People = (commit) => ({
26+ author: { kind: "author", name: commit.author.name, username: null, avatar: null },
27+ coAuthors: [],
28+});
29+
2430 /**
31+ * A commit as a branch row shows it: its first line, and its people as
32+ * `people` finds them (lib/branches.server.ts passes the accounts).
33+ */
34+export function summary(commit: Commit | null | undefined, people: People = byName): ActiveBranch["commit"] {
35+ if (!commit) return null;
36+ const shown = people(commit);
37+ return { hash: commit.hash, message: commit.message.split("\n")[0] ?? "", author: shown.author, coAuthors: shown.coAuthors, at: commit.authoredAt };
38+}
39+
40+/**
2541 * The branches other than the default that are read (at most `read`, those
2642 * with an open pull request first), how many there are, and the default
2743 * branch's head commit.
4662 reading: Branch[],
4763 measured: BranchDrifts | null,
4864 input: { pulls: Pick<Pull, "branch" | "number" | "title" | "checkStatus" | "status">[]; previews: Preview[] },
65+ people: People = byName,
4966 ): ActiveBranch[] {
5067 const pullOn = new Map(input.pulls.flatMap((pull) => (pull.branch ? [[pull.branch, pull] as const] : [])));
5168 const byHead = new Map((measured?.branches ?? []).map((found) => [found.head, found]));
5572 const found = byHead.get(branch.hash);
5673 return {
5774 name: branch.name,
58− commit: summary(found?.commit),
75+ commit: summary(found?.commit, people),
5976 drift: found?.drift ?? null,
6077 pull: pull ? { number: pull.number, title: pull.title, checkStatus: pull.checkStatus, draft: pull.status === "draft" } : null,
6178 preview: input.previews.find((app) => app.branch === branch.name || (pull != null && app.number === pull.number))?.url ?? null,
+5−0
2020 assert.deepEqual(parsed.trailers, [{ key: "G1t-Pull", value: "syntaqx/hello#72" }]);
2121 });
2222
23+test("a trailer shows its name, never an address", () => {
24+ const parsed = parseCommitMessage("Fix it\n\nSigned-off-by: Ada <ada@private.example>\nReviewed-by: <sam@x.io>");
25+ assert.deepEqual(parsed.trailers, [{ key: "Signed-off-by", value: "Ada" }]);
26+});
27+
2328 test("prose that happens to contain a colon stays prose", () => {
2429 const parsed = parseCommitMessage("Fix it\n\nNote: this also renames a function.\nAnd tidies the tests.");
2530 assert.equal(parsed.body, "Note: this also renames a function.\nAnd tidies the tests.");
+6−2
88 body: string;
99 /** The names in `Co-Authored-By` trailers, without their addresses. */
1010 coAuthors: string[];
11− /** Every other trailer, as written. */
11+ /** Every other trailer, as written but for any email address in it. */
1212 trailers: { key: string; value: string }[];
1313 };
1414
2828 for (const line of last) {
2929 const [, key, value] = TRAILER.exec(line.trim())!;
3030 if (key.toLowerCase() === "co-authored-by") coAuthors.push(value.replace(/\s*<[^>]*>\s*$/, "").trim());
31− else trailers.push({ key, value });
31+ else {
32+ // `Signed-off-by: Ada <ada@example.com>` shows Ada: g1t never shows an address.
33+ const shown = value.replace(/\s*<[^<>]*@[^<>]*>/g, "").trim();
34+ if (shown) trailers.push({ key, value: shown });
35+ }
3236 }
3337 }
3438 return { subject, body: prose.join("\n").trim(), coAuthors, trailers };
+66−0
1+/**
2+ * The one place commits get their people (lib/commit-people.ts): every
3+ * page that shows a commit's author asks here, once for all its commits.
4+ * One identity call per page for the addresses not already known, and
5+ * each answer kept a minute in the isolate, so a page of fifty commits by
6+ * three people asks about three addresses, and the next page asks about
7+ * none. A failure matches nobody: the names on the commits are shown.
8+ */
9+import type { Commit, EmailOwner } from "@g1t/contracts";
10+
11+import { type ShownCommit, addressesToMatch, showCommit } from "./commit-people";
12+import { accounts } from "./services.server";
13+
14+/** How long an answer is kept: an address can be confirmed or removed. */
15+const KEEP_MS = 60_000;
16+/** Addresses kept at most; the oldest go first. */
17+const MAX_KEPT = 5_000;
18+/** `email_owners` takes at most this many at once. */
19+const BATCH = 200;
20+
21+/** Each address's owner, or null for nobody, until `until`. */
22+const kept = new Map<string, { until: number; owner: EmailOwner | null }>();
23+
24+function keep(email: string, owner: EmailOwner | null) {
25+ kept.delete(email);
26+ kept.set(email, { until: Date.now() + KEEP_MS, owner });
27+ if (kept.size > MAX_KEPT) kept.delete(kept.keys().next().value as string);
28+}
29+
30+/** The accounts behind these (lowercased) addresses; unknown ones are left out. */
31+export async function emailOwners(emails: string[]): Promise<Record<string, EmailOwner>> {
32+ const owners: Record<string, EmailOwner> = {};
33+ const ask: string[] = [];
34+ const now = Date.now();
35+ for (const email of new Set(emails)) {
36+ const hit = kept.get(email);
37+ if (hit && hit.until > now) {
38+ if (hit.owner) owners[email] = hit.owner;
39+ } else {
40+ ask.push(email);
41+ }
42+ }
43+ for (let start = 0; start < ask.length; start += BATCH) {
44+ const chunk = ask.slice(start, start + BATCH);
45+ const found = await accounts.emailOwners(chunk).catch(() => null);
46+ if (!found) continue;
47+ for (const email of chunk) {
48+ const owner = found[email] ?? null;
49+ keep(email, owner);
50+ if (owner) owners[email] = owner;
51+ }
52+ }
53+ return owners;
54+}
55+
56+/** Commits with their people, in one identity call at most. */
57+export async function showCommits(commits: Commit[]): Promise<ShownCommit[]> {
58+ if (commits.length === 0) return [];
59+ const owners = await emailOwners(addressesToMatch(commits));
60+ return commits.map((commit) => showCommit(commit, owners));
61+}
62+
63+/** One commit, or null, with its people. */
64+export async function showOneCommit(commit: Commit | null | undefined): Promise<ShownCommit | null> {
65+ return commit ? (await showCommits([commit]))[0]! : null;
66+}
+110−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { Commit, EmailOwner } from "@g1t/contracts";
5+
6+import { addressesToMatch, coAuthorsOf, contributorPerson, personFor, profileHref, showCommit, shownName } from "./commit-people.ts";
7+
8+const syntaqx: EmailOwner = { id: "usr_01hx", username: "syntaqx", avatar: "f00d" };
9+
10+/**
11+ * What `email_owners` answers (services/identity/src/emails.rs): confirmed
12+ * addresses and noreply addresses only, so an unconfirmed address, or a
13+ * noreply address whose id does not match, is simply missing.
14+ */
15+const owners: Record<string, EmailOwner> = {
16+ "syntaqx@gmail.com": syntaqx,
17+ "01hx0000+syntaqx@users.noreply.g1t.sh": syntaqx,
18+ "1234abcd+gone@users.noreply.g1t.sh": { id: "usr_ghost", username: "ghost", avatar: null },
19+};
20+
21+function commit(name: string, email: string, message = "Change something"): Commit {
22+ return { hash: "a".repeat(40), treeHash: "b".repeat(40), message, author: { name, email }, parents: [], authoredAt: "2026-10-08T12:00:00Z" };
23+}
24+
25+test("a confirmed address is the account, whatever name the commit carries", () => {
26+ const person = personFor("Chase Pierce", "Syntaqx@Gmail.com ", owners);
27+ assert.deepEqual(person, { kind: "user", name: "Chase Pierce", username: "syntaqx", avatar: "f00d" });
28+ assert.equal(shownName(person), "syntaqx");
29+ assert.equal(profileHref(person), "/u/syntaqx");
30+});
31+
32+test("an unconfirmed address matches nobody: the name on the commit, no link", () => {
33+ // email_owners leaves unconfirmed addresses out of its answer.
34+ const person = personFor("Chase Pierce", "unconfirmed@example.com", owners);
35+ assert.deepEqual(person, { kind: "author", name: "Chase Pierce", username: null, avatar: null });
36+ assert.equal(shownName(person), "Chase Pierce");
37+ assert.equal(profileHref(person), null);
38+});
39+
40+test("the noreply address is the account", () => {
41+ assert.equal(personFor("syntaqx", "01hx0000+syntaqx@users.noreply.g1t.sh", owners).username, "syntaqx");
42+});
43+
44+test("a deleted account's commits are ghost's, with no profile", () => {
45+ const person = personFor("Gone", "1234abcd+gone@users.noreply.g1t.sh", owners);
46+ assert.equal(person.kind, "ghost");
47+ assert.equal(shownName(person), "ghost");
48+ assert.equal(profileHref(person), null);
49+});
50+
51+test("g1t's commits, now and as its agents and queue made them before, are g1t", () => {
52+ for (const [name, email] of [
53+ ["g1t", "g1t@users.noreply.g1t.sh"],
54+ ["g1t agent", "agent@g1t.sh"],
55+ ["g1t merge queue", "queue@g1t.sh"],
56+ ["g1t", "mergecheck@g1t.sh"],
57+ ]) {
58+ const person = personFor(name, email, {});
59+ assert.equal(person.kind, "g1t", email);
60+ assert.equal(shownName(person), "g1t");
61+ assert.equal(profileHref(person), null);
62+ }
63+ // Only g1t's own addresses: a name alone is not enough.
64+ assert.equal(personFor("g1t agent", "someone@example.com", {}).kind, "author");
65+});
66+
67+test("a commit sent to the page carries no address at all", () => {
68+ const shown = showCommit(
69+ commit("Chase Pierce", "syntaqx@gmail.com", "Fix it\n\nCo-authored-by: Ada <ada@private.example>\nCo-authored-by: Sam <syntaqx@gmail.com>"),
70+ owners,
71+ );
72+ const sent = JSON.stringify(shown.author) + JSON.stringify(shown.coAuthors);
73+ assert.ok(!sent.includes("@"), sent);
74+ assert.equal("email" in shown.author, false);
75+ assert.deepEqual(
76+ shown.coAuthors.map((person) => [person.kind, shownName(person)]),
77+ [
78+ ["author", "Ada"],
79+ ["user", "syntaqx"],
80+ ],
81+ );
82+});
83+
84+test("one question per page: every distinct address, g1t's left out", () => {
85+ const asked = addressesToMatch([
86+ commit("A", "Ada@Example.com"),
87+ commit("A", "ada@example.com", "x\n\nCo-Authored-By: Grace <grace@example.com>"),
88+ commit("g1t", "g1t@users.noreply.g1t.sh"),
89+ commit("g1t agent", "agent@g1t.sh"),
90+ commit("Nobody", ""),
91+ ]);
92+ assert.deepEqual(asked.sort(), ["ada@example.com", "grace@example.com"]);
93+});
94+
95+test("co-authors are read from the trailers only", () => {
96+ assert.deepEqual(coAuthorsOf("Subject\n\nCo-authored-by: Ada <ada@x.io>\n"), [{ name: "Ada", email: "ada@x.io" }]);
97+ assert.deepEqual(coAuthorsOf("Subject\n\nCo-authored-by: Ada <ada@x.io> said hi\n\nThe end."), []);
98+});
99+
100+test("contributors and commits show one person the same way", () => {
101+ const base = { name: "syntaqx", commits: 361, firstAt: "", lastAt: "", weeks: [] };
102+ const fromTally = contributorPerson({ ...base, kind: "user", username: "syntaqx", avatar: "f00d" });
103+ const fromCommit = personFor("Chase Pierce", "syntaqx@gmail.com", owners);
104+ assert.equal(shownName(fromTally), shownName(fromCommit));
105+ assert.equal(fromTally.avatar, fromCommit.avatar);
106+ assert.equal(profileHref(fromTally), profileHref(fromCommit));
107+ assert.equal(contributorPerson({ ...base, kind: "g1t", name: "g1t" }).kind, "g1t");
108+ assert.equal(contributorPerson({ ...base, kind: "user", username: "ghost" }).kind, "ghost");
109+ assert.deepEqual(contributorPerson({ ...base, kind: "author", name: "Sam" }), { kind: "author", name: "Sam", username: null, avatar: null });
110+});
+136−0
1+/**
2+ * Who a commit is by, as g1t shows it: the account its author address
3+ * belongs to, else the name written on the commit.
4+ *
5+ * Git records a name and an address; g1t matches the address to an
6+ * account by a confirmed address or the account's noreply address
7+ * (`email_owners` in services/identity), never an unconfirmed one. The
8+ * address itself never reaches the page: a commit is shown with the
9+ * person, not the address, so a private address stays private.
10+ */
11+import type { Blame, Commit, Contributor, EmailOwner } from "@g1t/contracts";
12+
13+/**
14+ * One person on a commit. `user` is an account; `g1t` is g1t itself;
15+ * `ghost` is an account that was deleted; `author` is nobody on g1t,
16+ * shown by the name on the commit, with no link and no card.
17+ */
18+export type CommitPerson = {
19+ kind: "user" | "g1t" | "ghost" | "author";
20+ /** The name written on the commit. Beside an account it is a hint. */
21+ name: string;
22+ /** The account's username; null for `author`. */
23+ username: string | null;
24+ /** The account's uploaded avatar, by hash. */
25+ avatar: string | null;
26+};
27+
28+/** A commit as the page gets it: its people, never their addresses. */
29+export type ShownCommit = Omit<Commit, "author"> & {
30+ author: CommitPerson;
31+ /** From `Co-authored-by` trailers, in order. */
32+ coAuthors: CommitPerson[];
33+};
34+
35+/** Who last changed each line, with the commits' people. */
36+export type ShownBlame = Omit<Blame, "commits"> & { commits: ShownCommit[] };
37+
38+/** A commit as a file list's row shows it: no people at all. */
39+export type FileCommit = Pick<Commit, "hash" | "message" | "authoredAt">;
40+
41+/** Each entry's last commit; `complete` is false when some were not reached. */
42+export type FileCommits = { entries: { name: string; commit: FileCommit }[]; complete: boolean };
43+
44+/**
45+ * The addresses on g1t's own commits: its noreply address, and those its
46+ * agents and merge queue used before (as "g1t agent"), which history
47+ * keeps. Every one is shown as g1t.
48+ */
49+export const G1T_COMMIT_EMAILS: ReadonlySet<string> = new Set([
50+ "g1t@users.noreply.g1t.sh",
51+ "agent@g1t.sh",
52+ "queue@g1t.sh",
53+ "mergecheck@g1t.sh",
54+]);
55+
56+const CO_AUTHOR = /^co-authored-by:\s*(.*?)\s*<([^>]*)>\s*$/i;
57+
58+/** The name and address in each `Co-authored-by` trailer of a message. */
59+export function coAuthorsOf(message: string): { name: string; email: string }[] {
60+ const lines = message.trim().split("\n");
61+ const last = lines.slice(lines.lastIndexOf("") + 1);
62+ const found: { name: string; email: string }[] = [];
63+ for (const line of last) {
64+ const match = CO_AUTHOR.exec(line.trim());
65+ if (match) found.push({ name: match[1]!.trim(), email: match[2]!.trim() });
66+ }
67+ return found;
68+}
69+
70+/** Lowercased and trimmed, as `email_owners` keys them. */
71+export function normalizeEmail(email: string): string {
72+ return email.trim().toLowerCase();
73+}
74+
75+/**
76+ * Every distinct address the commits name, authors and co-authors, that
77+ * needs asking about: g1t's own are known already.
78+ */
79+export function addressesToMatch(commits: readonly Pick<Commit, "author" | "message">[]): string[] {
80+ const seen = new Set<string>();
81+ const add = (email: string) => {
82+ const one = normalizeEmail(email);
83+ if (one && one.includes("@") && !G1T_COMMIT_EMAILS.has(one)) seen.add(one);
84+ };
85+ for (const commit of commits) {
86+ add(commit.author.email);
87+ for (const co of coAuthorsOf(commit.message)) add(co.email);
88+ }
89+ return [...seen];
90+}
91+
92+/** The person a name and address on a commit belong to. */
93+export function personFor(name: string, email: string, owners: Readonly<Record<string, EmailOwner>>): CommitPerson {
94+ const written = name.trim() || "Unknown";
95+ const address = normalizeEmail(email);
96+ if (G1T_COMMIT_EMAILS.has(address)) return { kind: "g1t", name: written, username: "g1t", avatar: null };
97+ const owner = address ? owners[address] : undefined;
98+ if (owner?.username === "ghost") return { kind: "ghost", name: written, username: "ghost", avatar: null };
99+ if (owner) return { kind: "user", name: written, username: owner.username, avatar: owner.avatar };
100+ return { kind: "author", name: written, username: null, avatar: null };
101+}
102+
103+/** A commit with its people found and its author's address left out. */
104+export function showCommit(commit: Commit, owners: Readonly<Record<string, EmailOwner>>): ShownCommit {
105+ const { author, ...rest } = commit;
106+ return {
107+ ...rest,
108+ author: personFor(author.name, author.email, owners),
109+ coAuthors: coAuthorsOf(commit.message).map((co) => personFor(co.name, co.email, owners)),
110+ };
111+}
112+
113+/**
114+ * A repository's contributor as a person on its commits. The repos
115+ * service tallies them by the same rules (services/repos/src/contributors.rs:
116+ * `email_owners`, then g1t's addresses, then the name), so the About, the
117+ * Contributors page and every commit show one person the same way.
118+ */
119+export function contributorPerson(contributor: Pick<Contributor, "kind" | "name" | "username" | "avatar">): CommitPerson {
120+ if (contributor.kind === "g1t") return { kind: "g1t", name: contributor.name, username: "g1t", avatar: null };
121+ if (contributor.kind === "user" && contributor.username === "ghost") return { kind: "ghost", name: contributor.name, username: "ghost", avatar: null };
122+ if (contributor.kind === "user" && contributor.username) {
123+ return { kind: "user", name: contributor.name, username: contributor.username, avatar: contributor.avatar ?? null };
124+ }
125+ return { kind: "author", name: contributor.name, username: null, avatar: null };
126+}
127+
128+/** What a person on a commit is called on the page. */
129+export function shownName(person: CommitPerson): string {
130+ return person.username ?? person.name;
131+}
132+
133+/** Where a person on a commit links to, if anywhere. */
134+export function profileHref(person: CommitPerson): string | null {
135+ return person.kind === "user" && person.username ? `/u/${person.username}` : null;
136+}
+143−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { Contributors, Profile, ProfileWorkspace, RepoPath, Result, Viewer } from "@g1t/contracts";
5+
6+import { type CardSources, buildCard, cardHref, committedLabel, committedWithin, parseRepo } from "./hovercard.ts";
7+
8+const NOW = Date.parse("2026-10-08T12:00:00Z");
9+const DAY = 86_400_000;
10+
11+const ada: Profile = {
12+ username: "ada",
13+ name: "Ada Lovelace",
14+ bio: "Engines.",
15+ location: "London",
16+ website: "https://ada.example",
17+ pronouns: "she/her",
18+ avatar: "cafe",
19+ createdAt: "2026-01-01T00:00:00Z",
20+};
21+
22+const me: Viewer = { id: "usr_me", username: "me", kind: "user", verified: true } as Viewer;
23+
24+/**
25+ * Stand-ins for identity and repos that behave as they do: workspaces are
26+ * shown only when the viewer shares them, or when ada has a public project
27+ * there; the repository answers only viewers who may read it.
28+ */
29+function sources(over: Partial<CardSources> = {}): CardSources & { asked: { viewer: Viewer; publicIn: string[] }[] } {
30+ const asked: { viewer: Viewer; publicIn: string[] }[] = [];
31+ const memberships: ProfileWorkspace[] = [
32+ { slug: "acme", name: "Acme", avatar: null },
33+ { slug: "secret-lab", name: "Secret Lab", avatar: null },
34+ ];
35+ return {
36+ asked,
37+ profile: async (username) => (username === "ada" ? ada : null),
38+ accountId: async (username) => (username === "ada" ? "usr_ada" : null),
39+ publicNamespaces: async () => ["acme"],
40+ profileWorkspaces: async (_username, viewer, publicIn) => {
41+ asked.push({ viewer, publicIn });
42+ // secret-lab is private: only someone who belongs to it sees it.
43+ return memberships.filter((one) => publicIn.includes(one.slug) || (viewer?.username === "insider" && one.slug === "secret-lab"));
44+ },
45+ contributors: async (path: RepoPath, viewer: Viewer): Promise<Result<Contributors>> => {
46+ if (path.name === "private" && viewer?.username !== "insider") {
47+ return { ok: false, error: { code: "not_found", message: "Repository not found." } } as Result<Contributors>;
48+ }
49+ return {
50+ ok: true,
51+ value: {
52+ head: "h",
53+ commit: "h",
54+ computedAt: null,
55+ pending: false,
56+ partial: false,
57+ total: 1,
58+ commits: 3,
59+ weeks: [],
60+ contributors: [{ kind: "user", name: "ada", username: "ada", avatar: "cafe", commits: 3, firstAt: "2026-09-01T00:00:00Z", lastAt: new Date(NOW - 3 * DAY).toISOString(), weeks: [] }],
61+ },
62+ };
63+ },
64+ ...over,
65+ };
66+}
67+
68+test("the card is the public profile, signed out too", async () => {
69+ const card = await buildCard("Ada", null, null, sources(), NOW);
70+ assert.ok(card && card.kind === "user");
71+ assert.equal(card.username, "ada");
72+ assert.equal(card.name, "Ada Lovelace");
73+ assert.equal(card.pronouns, "she/her");
74+ assert.equal(card.bio, "Engines.");
75+ assert.equal(card.location, "London");
76+ assert.equal(card.avatar, "cafe");
77+ assert.equal(card.committed, null, "no repository, nothing about commits");
78+ // Never an address.
79+ assert.ok(!JSON.stringify(card).includes("@"));
80+});
81+
82+test("a private workspace is hidden from someone outside it", async () => {
83+ const outside = sources();
84+ const outsider = await buildCard("ada", me, null, outside, NOW);
85+ assert.deepEqual(outsider?.kind === "user" && outsider.workspaces.map((one) => one.slug), ["acme"]);
86+ // The viewer is passed through, for identity to decide.
87+ assert.equal(outside.asked[0]!.viewer, me);
88+ assert.deepEqual(outside.asked[0]!.publicIn, ["acme"]);
89+ const insider = await buildCard("ada", { ...me, username: "insider" } as Viewer, null, sources(), NOW);
90+ assert.deepEqual(insider?.kind === "user" && insider.workspaces.map((one) => one.slug), ["acme", "secret-lab"]);
91+});
92+
93+test("commits to a repository show only to those who may read it", async () => {
94+ const open = await buildCard("ada", null, { namespace: "acme", name: "web" }, sources(), NOW);
95+ assert.equal(open?.kind === "user" && open.committed, "week");
96+ const hidden = await buildCard("ada", me, { namespace: "acme", name: "private" }, sources(), NOW);
97+ assert.equal(hidden?.kind === "user" && hidden.committed, null);
98+});
99+
100+test("a failing service leaves its part out, not the card", async () => {
101+ const card = await buildCard(
102+ "ada",
103+ me,
104+ { namespace: "acme", name: "web" },
105+ sources({ profileWorkspaces: async () => Promise.reject(new Error("down")), contributors: async () => Promise.reject(new Error("down")) }),
106+ NOW,
107+ );
108+ assert.ok(card && card.kind === "user");
109+ assert.deepEqual(card.workspaces, []);
110+ assert.equal(card.committed, null);
111+});
112+
113+test("g1t has its own card; ghost and strangers have none", async () => {
114+ assert.deepEqual(await buildCard("g1t", null, null, sources(), NOW), { kind: "g1t", username: "g1t" });
115+ assert.equal(await buildCard("ghost", me, null, sources(), NOW), null);
116+ assert.equal(await buildCard("nobody", me, null, sources(), NOW), null);
117+ assert.equal(await buildCard("../etc", me, null, sources(), NOW), null);
118+});
119+
120+test("workspaces past three are counted", async () => {
121+ const many = Array.from({ length: 5 }, (_, index) => ({ slug: `w${index}`, name: `W${index}`, avatar: null }));
122+ const card = await buildCard("ada", me, null, sources({ profileWorkspaces: async () => many }), NOW);
123+ assert.ok(card && card.kind === "user");
124+ assert.equal(card.workspaces.length, 3);
125+ assert.equal(card.more_workspaces, 2);
126+});
127+
128+test("how recently, in the words the card uses", () => {
129+ assert.equal(committedWithin(new Date(NOW - 2 * 3_600_000).toISOString(), NOW), "day");
130+ assert.equal(committedWithin(new Date(NOW - 6 * DAY).toISOString(), NOW), "week");
131+ assert.equal(committedWithin(new Date(NOW - 20 * DAY).toISOString(), NOW), "month");
132+ assert.equal(committedWithin(new Date(NOW - 60 * DAY).toISOString(), NOW), null);
133+ assert.equal(committedWithin(null, NOW), null);
134+ assert.equal(committedLabel("week"), "Committed to this repository in the past week");
135+});
136+
137+test("the card's address and its repository", () => {
138+ assert.equal(cardHref("Ada", "acme/web"), "/-/hovercard/user/ada?repo=acme%2Fweb");
139+ assert.equal(cardHref("ada", null), "/-/hovercard/user/ada");
140+ assert.deepEqual(parseRepo("acme/web"), { namespace: "acme", name: "web" });
141+ assert.equal(parseRepo("acme"), null);
142+ assert.equal(parseRepo("../x/y"), null);
143+});
+126−0
1+/**
2+ * The card that opens over a person's name or avatar: who they are, as
3+ * their public profile says, and how they relate to what the viewer is
4+ * looking at. Only what the viewer may see: workspaces through
5+ * `profile_workspaces` (those the viewer shares with them, and those they
6+ * made a public project in), and commits to a repository only when the
7+ * viewer may read it. Never an address.
8+ *
9+ * The body is snake_case, like every API body g1t answers with.
10+ */
11+import type { Contributors, Profile, ProfileWorkspace, RepoPath, Result, Viewer } from "@g1t/contracts";
12+
13+/** How recently someone committed to the repository the card opened in. */
14+export type CommittedWithin = "day" | "week" | "month";
15+
16+export type UserCard = {
17+ kind: "user";
18+ username: string;
19+ name: string | null;
20+ pronouns: string | null;
21+ bio: string | null;
22+ location: string | null;
23+ avatar: string | null;
24+ /** Workspaces the viewer may know they belong to, at most `MAX_WORKSPACES`. */
25+ workspaces: { slug: string; name: string; avatar: string | null }[];
26+ /** Workspaces past those listed. */
27+ more_workspaces: number;
28+ /** Their latest commit to the repository, by how long ago; null when none or not asked. */
29+ committed: CommittedWithin | null;
30+};
31+
32+/** g1t itself: no profile, a card of its own. */
33+export type G1tCard = { kind: "g1t"; username: "g1t" };
34+
35+export type Card = UserCard | G1tCard;
36+
37+export const MAX_WORKSPACES = 3;
38+
39+const DAY_MS = 86_400_000;
40+
41+/** `day`, `week` or `month` for a commit at `at` (RFC 3339), else null. */
42+export function committedWithin(at: string | null | undefined, now: number): CommittedWithin | null {
43+ if (!at) return null;
44+ const ago = now - Date.parse(at);
45+ if (!Number.isFinite(ago) || ago < -DAY_MS) return null;
46+ if (ago <= DAY_MS) return "day";
47+ if (ago <= 7 * DAY_MS) return "week";
48+ if (ago <= 31 * DAY_MS) return "month";
49+ return null;
50+}
51+
52+/** The line the card shows for it. */
53+export function committedLabel(within: CommittedWithin): string {
54+ return `Committed to this repository in the past ${within}`;
55+}
56+
57+/** `owner/name` from the card's `repo` parameter, or null. */
58+export function parseRepo(value: string | null | undefined): RepoPath | null {
59+ const match = /^([A-Za-z0-9][A-Za-z0-9._-]{0,99})\/([A-Za-z0-9._-]{1,100})$/.exec(value?.trim() ?? "");
60+ return match ? { namespace: match[1]!, name: match[2]! } : null;
61+}
62+
63+/** What building a card reads, so tests can stand in for the services. */
64+export type CardSources = {
65+ profile(username: string): Promise<Profile | null>;
66+ /** The account's id, to find where it has public projects. */
67+ accountId(username: string): Promise<string | null>;
68+ publicNamespaces(userId: string): Promise<string[]>;
69+ profileWorkspaces(username: string, viewer: Viewer, publicIn: string[]): Promise<ProfileWorkspace[]>;
70+ contributors(path: RepoPath, viewer: Viewer): Promise<Result<Contributors>>;
71+};
72+
73+/**
74+ * The card for `username` as `viewer` sees it, opened in `repo` if any;
75+ * null when there is nobody by that name (or for ghost, a deleted
76+ * account, which has no card).
77+ */
78+export async function buildCard(
79+ username: string,
80+ viewer: Viewer,
81+ repo: RepoPath | null,
82+ sources: CardSources,
83+ now: number,
84+): Promise<Card | null> {
85+ const name = username.trim().toLowerCase();
86+ if (name === "g1t") return { kind: "g1t", username: "g1t" };
87+ if (!/^[a-z0-9-]{1,39}$/.test(name) || name === "ghost") return null;
88+ const profile = await sources.profile(name).catch(() => null);
89+ if (!profile) return null;
90+ const workspaces = sources
91+ .accountId(profile.username)
92+ .then((id) => (id ? sources.publicNamespaces(id) : []))
93+ .catch(() => [] as string[])
94+ .then((publicIn) => sources.profileWorkspaces(profile.username, viewer, publicIn))
95+ .catch(() => [] as ProfileWorkspace[]);
96+ // The repository answers for the viewer: one they cannot read says nothing.
97+ const committed = repo
98+ ? sources
99+ .contributors(repo, viewer)
100+ .then((found) => {
101+ if (!found.ok) return null;
102+ const them = found.value.contributors.find((one) => one.username?.toLowerCase() === profile.username.toLowerCase());
103+ return committedWithin(them?.lastAt, now);
104+ })
105+ .catch(() => null)
106+ : Promise.resolve(null);
107+ const [shown, within] = await Promise.all([workspaces, committed]);
108+ return {
109+ kind: "user",
110+ username: profile.username,
111+ name: profile.name,
112+ pronouns: profile.pronouns,
113+ bio: profile.bio,
114+ location: profile.location,
115+ avatar: profile.avatar,
116+ workspaces: shown.slice(0, MAX_WORKSPACES).map((one) => ({ slug: one.slug, name: one.name, avatar: one.avatar })),
117+ more_workspaces: Math.max(0, shown.length - MAX_WORKSPACES),
118+ committed: within,
119+ };
120+}
121+
122+/** Where the card for `username` is read from, opened in `repo` if any. */
123+export function cardHref(username: string, repo: string | null): string {
124+ const path = `/-/hovercard/user/${encodeURIComponent(username.toLowerCase())}`;
125+ return repo ? `${path}?repo=${encodeURIComponent(repo)}` : path;
126+}
+16−3
88 */
99 import { waitUntil } from "cloudflare:workers";
1010
11−import type { LastCommits, RepoPath, Viewer } from "@g1t/contracts";
11+import type { RepoPath, Viewer } from "@g1t/contracts";
1212
13+import type { FileCommits } from "./commit-people";
1314 import { repos } from "./services.server";
1415
1516 /** How long the page waits for the column. */
1617 const WAIT_MS = 3_000;
1718
18−export function lastCommitsFor(path: RepoPath, viewer: Viewer, ref: string | null, treePath: string): Promise<LastCommits | null> {
19+export function lastCommitsFor(path: RepoPath, viewer: Viewer, ref: string | null, treePath: string): Promise<FileCommits | null> {
1920 const walk = repos
2021 .lastCommits(path, viewer, ref, treePath)
21− .then((found) => (found.ok ? found.value : null))
22+ // The rows show a commit's subject and age, never who made it: their
23+ // addresses stay on the server.
24+ .then((found): FileCommits | null =>
25+ found.ok
26+ ? {
27+ complete: found.value.complete,
28+ entries: found.value.entries.map(({ name, commit }) => ({
29+ name,
30+ commit: { hash: commit.hash, message: commit.message, authoredAt: commit.authoredAt },
31+ })),
32+ }
33+ : null,
34+ )
2235 .catch(() => null);
2336 waitUntil(walk);
2437 return Promise.race([walk, new Promise<null>((resolve) => setTimeout(() => resolve(null), WAIT_MS))]);
+3−0
4646 route("settings/menu.json", "routes/settings-menu-json.ts"),
4747 // People, apart from workspaces: `u` is a reserved name.
4848 route("u/:username", "routes/user.tsx"),
49+ // The card that opens over a person's name or avatar (lib/hovercard.ts).
50+ // `-` is no workspace's name, so nothing else is ever found here.
51+ route("-/hovercard/user/:username", "routes/hovercard-user.ts"),
4952 route("explore", "routes/explore.tsx", { id: "explore" }),
5053 route("pricing", "routes/pricing.tsx"),
5154 route("search", "routes/search.tsx"),
+32−0
1+/**
2+ * The card over a person's name or avatar, fetched when it first opens
3+ * (components/user-card.tsx): their public profile, the workspaces the
4+ * viewer may know they belong to, and, given `?repo=owner/name` the viewer
5+ * can read, how recently they committed there. Works signed out, with
6+ * public answers only. Kept a minute by the browser, for this viewer only.
7+ */
8+import type { Route } from "./+types/hovercard-user";
9+import { buildCard, parseRepo } from "../lib/hovercard";
10+import { identity, repos } from "../lib/services.server";
11+import { getViewer } from "../lib/session.server";
12+
13+export async function loader({ params, request, context }: Route.LoaderArgs) {
14+ const viewer = getViewer(context);
15+ const repo = parseRepo(new URL(request.url).searchParams.get("repo"));
16+ const card = await buildCard(
17+ params.username,
18+ viewer,
19+ repo,
20+ {
21+ profile: (username) => identity.profile(username),
22+ accountId: async (username) => (await identity.userByUsername(username))?.id ?? null,
23+ publicNamespaces: (id) => repos.publicNamespaces(id),
24+ profileWorkspaces: (username, who, publicIn) => identity.profileWorkspaces(username, who, publicIn),
25+ contributors: (path, who) => repos.contributors(path, who),
26+ },
27+ Date.now(),
28+ );
29+ const headers = { "cache-control": "private, max-age=60", vary: "Cookie" };
30+ if (!card) return Response.json({ error: "not_found" }, { status: 404, headers });
31+ return Response.json(card, { headers });
32+}
+6−3
1111 import { ACTIVITY_TYPES, type ActivityLine, activityLine } from "../../lib/about";
1212 import { page } from "../../lib/meta";
1313 import { events, identity } from "../../lib/services.server";
14+import { UserCard } from "../../components/user-card";
1415
1516 export function meta({ params, ...args }: Route.MetaArgs) {
1617 return page(args, { title: `Activity · ${params.owner}/${params.repo} · g1t` });
112113 <span className="inline-flex items-center gap-1.5 align-middle">
113114 <Avatar name={who ?? "someone"} size={18} system={who === "g1t"} />
114115 {who && who !== "g1t" ? (
115− <Link to={`/u/${who}`} className="font-medium hover:text-accent">
116− {who}
117− </Link>
116+ <UserCard username={who}>
117+ <Link to={`/u/${who}`} className="font-medium hover:text-accent">
118+ {who}
119+ </Link>
120+ </UserCard>
118121 ) : (
119122 <span className="font-medium">{who ?? "Someone"}</span>
120123 )}
+6−1
66 import { BlobView } from "../../components/repo-view";
77 import { highlightLines } from "../../lib/highlight.server";
88 import { repos, work } from "../../lib/services.server";
9+import { showCommits } from "../../lib/commit-people.server";
910 import { redirectIfBranchRenamed } from "../../lib/branch-redirect.server";
1011 import { getViewer, unwrap } from "../../lib/session.server";
1112
3536 return {
3637 blob,
3738 html: null,
38− blame: { blame: blame.value, lines: await highlightLines(blob.path, blob.text) },
39+ blame: {
40+ // Every commit's people at once.
41+ blame: { ...blame.value, commits: await showCommits(blame.value.commits) },
42+ lines: await highlightLines(blob.path, blob.text),
43+ },
3944 branches,
4045 codeowners,
4146 };
+6−3
33
44 import type { Route } from "./+types/branches";
55 import { ActiveBranches } from "../../components/branches";
6−import { Avatar, EmptyState, TimeAgo, notACredential } from "../../components/ui";
6+import { CommitAvatars, CommitNames } from "../../components/commit-person";
7+import { EmptyState, TimeAgo, notACredential } from "../../components/ui";
78 import { Hint } from "../../components/ui/hint";
89 import { readBranches } from "../../lib/branches.server";
910 import { commitChecksFor } from "../../lib/commit-checks.server";
99100 <span className="rounded-full border border-line px-2 py-px text-xs text-muted">default</span>
100101 {head && (
101102 <span className="flex min-w-0 grow basis-64 items-center gap-1.5 text-xs text-muted">
102− <Avatar name={head.author} size={13} />
103− <span className="shrink-0">{head.author}</span>
103+ <CommitAvatars commit={head} size={14} max={2} />
104+ <span className="shrink-0">
105+ <CommitNames commit={head} className="hover:text-fg" />
106+ </span>
104107 <span className="text-faint">·</span>
105108 <Hint label={head.message}>
106109 <Link to={`${base}/commit/${head.hash}`} className="min-w-0 truncate hover:text-fg">
+4−1
55 import { aboutFor } from "../../lib/about.server";
66 import { environmentsFor } from "../../lib/deployments.server";
77 import { lastCommitsFor } from "../../lib/last-commits.server";
8+import { showOneCommit } from "../../lib/commit-people.server";
89 import { repos } from "../../lib/services.server";
910 import { getViewer, unwrap } from "../../lib/session.server";
1011 import { projectHomepage } from "../../lib/about";
2627 ]);
2728 // Each entry's last commit walks history: streamed in after the list.
2829 const lastCommits = lastCommitsFor(path, viewer, null, "");
29− const value = unwrap(tree);
30+ const found = unwrap(tree);
31+ // The latest commit's author, as their account where there is one.
32+ const value = { ...found, head: await showOneCommit(found.head) };
3033 // The About beside the files, streamed in after them.
3134 const about = value.head ? aboutFor(path, viewer, value.repo.id) : null;
3235 // The latest commit's checks: streamed in beside it.
+14−26
99 import { page } from "../../lib/meta";
1010 import { CommitChecksBadge } from "../../components/commit-checks";
1111 import { DiffView } from "../../components/diff-view";
12−import { Avatar, TimeAgo } from "../../components/ui";
13−import { Hint } from "../../components/ui/hint";
12+import { CommitAvatars, CommitNames } from "../../components/commit-person";
13+import { TimeAgo } from "../../components/ui";
1414 import { Skeleton } from "../../components/ui/skeleton";
1515 import { commitChecksFor } from "../../lib/commit-checks.server";
1616 import { immutable } from "../../lib/immutable.server";
1717 import { pullForCommit } from "../../lib/provenance.server";
18−import { accounts, repos } from "../../lib/services.server";
18+import { showOneCommit } from "../../lib/commit-people.server";
19+import { repos } from "../../lib/services.server";
1920 import { getViewer, unwrap } from "../../lib/session.server";
2021
2122 export function headers({ loaderHeaders }: Route.HeadersArgs) {
6566 return { commit, comparison };
6667 });
6768 if (!loaded) throw new Response("Commit not found.", { status: 404 });
68− // The account the author address belongs to, if any; not cached, since
69− // an address can be confirmed or removed later.
70− const email = loaded.commit.author.email.toLowerCase();
71− const owners = await accounts.emailOwners([email]).catch(() => ({}) as Record<string, never>);
72− const owner: { username: string; avatar: string | null } | null = owners[email] ?? null;
69+ // Its people, as their accounts where there are any; not kept with the
70+ // commit, since an address can be confirmed or removed later.
71+ const commit = (await showOneCommit(loaded.commit))!;
7372 return data(
7473 {
7574 ...loaded,
76− owner,
75+ commit,
7776 // Streamed: the page shows the commit while this is worked out.
7877 pull: pullForCommit(path, viewer, loaded.commit.hash),
7978 // Never cached: checks report on a commit long after it was made.
133132 }
134133
135134 export default function CommitPage({ loaderData, params }: Route.ComponentProps) {
136− const { commit, pull, comparison, owner, checks } = loaderData;
135+ const { commit, pull, comparison, checks } = loaderData;
137136 const base = `/${params.owner}/${params.repo}`;
138− const { subject, body, coAuthors, trailers } = parseCommitMessage(commit.message);
137+ const { subject, body, trailers } = parseCommitMessage(commit.message);
139138 return (
140139 <div>
141140 <section className="overflow-hidden rounded-xl border border-line bg-surface">
160159 </div>
161160 <div className="flex flex-wrap items-center gap-x-5 gap-y-3 border-t border-line bg-bg/40 px-5 py-3 text-sm">
162161 <span className="flex items-center gap-2">
163− <Avatar name={owner?.username ?? commit.author.name} image={owner?.avatar} size={20} />
164− {owner ? (
165− <Hint label={commit.author.name}>
166− <Link to={`/u/${owner.username}`} className="font-medium hover:underline">
167− {owner.username}
168− </Link>
169− </Hint>
170− ) : (
171− <span className="font-medium">{commit.author.name}</span>
172− )}
173− {coAuthors.length > 0 && (
174− <span className="text-muted">
175− and <span className="font-medium text-fg">{coAuthors.join(", ")}</span>
176− </span>
177− )}
162+ <CommitAvatars commit={commit} size={20} />
163+ <span className="font-medium">
164+ <CommitNames commit={commit} all />
165+ </span>
178166 <span className="text-muted">
179167 committed <TimeAgo at={commit.authoredAt} />
180168 </span>
+20−26
11 import { GitCommitHorizontal } from "lucide-react";
22 import { Link } from "react-router";
33
4−import type { Commit } from "@g1t/contracts";
4+import type { ShownCommit } from "../../lib/commit-people";
5+import { parseCommitMessage } from "../../lib/commit-message";
56
67 import type { Route } from "./+types/commits";
78 import { page } from "../../lib/meta";
89 import { CommitChecksBadge } from "../../components/commit-checks";
9−import { Avatar, EmptyState, TimeAgo } from "../../components/ui";
10−import { Hint } from "../../components/ui/hint";
10+import { CommitAvatars, CommitNames } from "../../components/commit-person";
11+import { EmptyState, TimeAgo } from "../../components/ui";
1112 import { commitChecksFor } from "../../lib/commit-checks.server";
12−import { accounts, repos } from "../../lib/services.server";
13+import { showCommits } from "../../lib/commit-people.server";
14+import { repos } from "../../lib/services.server";
1315 import { getViewer, unwrap } from "../../lib/session.server";
1416
1517 const PAGE_SIZE = 50;
2123 export async function loader({ params, context }: Route.LoaderArgs) {
2224 const path = { namespace: params.owner, name: params.repo };
2325 const viewer = getViewer(context);
24− const commits = unwrap(await repos.log(path, viewer, null, PAGE_SIZE));
26+ const log = unwrap(await repos.log(path, viewer, null, PAGE_SIZE));
2527 // Every commit's checks in one call, streamed in beside each.
26− const checks = commitChecksFor(path, viewer, commits.map((commit) => commit.hash));
27− // Who wrote each commit, by its author address: confirmed and noreply
28− // addresses only. Without an answer, the name in the commit is shown.
29− const owners = await accounts.emailOwners([...new Set(commits.map((commit) => commit.author.email))]).catch(() => ({}));
30− return { commits, owners: owners as Record<string, { username: string; avatar: string | null }>, checks };
28+ const checks = commitChecksFor(path, viewer, log.map((commit) => commit.hash));
29+ // Who wrote each commit, as their account where there is one.
30+ const commits = await showCommits(log);
31+ return { commits, checks };
3132 }
3233
3334 /** Commits by the day they were made, newest first. */
34−function byDay(commits: Commit[]): [string, Commit[]][] {
35− const days = new Map<string, Commit[]>();
35+function byDay(commits: ShownCommit[]): [string, ShownCommit[]][] {
36+ const days = new Map<string, ShownCommit[]>();
3637 for (const commit of commits) {
3738 const day = commit.authoredAt.slice(0, 10);
3839 days.set(day, [...(days.get(day) ?? []), commit]);
5253 }
5354
5455 export default function Commits({ loaderData, params }: Route.ComponentProps) {
55− const { commits, owners, checks } = loaderData;
56+ const { commits, checks } = loaderData;
5657 const base = `/${params.owner}/${params.repo}`;
5758 if (commits.length === 0) {
5859 return <EmptyState title="No commits yet" />;
6768 </h2>
6869 <ol className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line">
6970 {list.map((commit) => {
70− const [subject, ...body] = commit.message.split("\n");
71− const rest = body.join("\n").trim();
71+ // The body's prose: trailers (co-authors and their addresses) are not shown.
72+ const { subject, body: rest } = parseCommitMessage(commit.message);
7273 const to = `${base}/commit/${commit.hash}`;
73− const owner = owners[commit.author.email.toLowerCase()];
7474 return (
7575 <li key={commit.hash} className="group relative flex items-start gap-3 px-4 py-3 transition-colors hover:bg-surface">
76− <Avatar name={owner?.username ?? commit.author.name} image={owner?.avatar} size={24} />
76+ <span className="mt-0.5 flex">
77+ <CommitAvatars commit={commit} size={24} />
78+ </span>
7779 <div className="min-w-0 grow">
7880 <div className="flex min-w-0 items-center gap-1.5">
7981 <Link to={to} prefetch="intent" className="truncate font-medium after:absolute after:inset-0 group-hover:text-accent">
8385 </div>
8486 {rest && <p className="mt-1 line-clamp-1 text-sm text-muted">{rest}</p>}
8587 <p className="mt-1 text-xs text-faint">
86− {owner ? (
87− <Hint label={commit.author.name}>
88− <Link to={`/u/${owner.username}`} className="relative z-10 text-muted hover:text-fg">
89− {owner.username}
90− </Link>
91− </Hint>
92− ) : (
93− commit.author.name
94− )}{" "}
88+ <CommitNames commit={commit} className="text-muted hover:text-fg" />{" "}
9589 committed <TimeAgo at={commit.authoredAt} />
9690 {commit.parents.length > 1 && " · merge"}
9791 </p>
+11−5
55
66 import type { Route } from "./+types/compare";
77 import { DiffView } from "../../components/diff-view";
8−import { Avatar, ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
8+import { CommitAvatars, CommitNames } from "../../components/commit-person";
9+import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
910 import { Hint } from "../../components/ui/hint";
1011 import { page } from "../../lib/meta";
12+import { showCommits } from "../../lib/commit-people.server";
1113 import { repos } from "../../lib/services.server";
1214 import { getViewer, unwrap } from "../../lib/session.server";
1315
5153 // What head has that base does not: its history down to the first commit base also has.
5254 const onBase = new Set(baseLog.ok ? baseLog.value.map((commit) => commit.hash) : []);
5355 const shared = headLog.value.findIndex((commit) => onBase.has(commit.hash));
54− const commits = (shared === -1 ? headLog.value : headLog.value.slice(0, shared)).map((commit) => ({
56+ // With their people, in one identity call.
57+ const commits = (await showCommits(shared === -1 ? headLog.value : headLog.value.slice(0, shared))).map((commit) => ({
5558 hash: commit.hash,
5659 message: commit.message.split("\n")[0] ?? "",
57− author: commit.author.name,
60+ author: commit.author,
61+ coAuthors: commit.coAuthors,
5862 at: commit.authoredAt,
5963 }));
6064 // The changes from where the two last agreed, as a pull request would
133137 {commits.map((commit) => (
134138 <li key={commit.hash} className="flex items-center gap-3 px-4 py-2.5 text-sm">
135139 <GitCommitHorizontal size={15} className="shrink-0 text-faint" />
136− <Avatar name={commit.author} size={16} />
140+ <CommitAvatars commit={commit} size={16} max={2} />
137141 <Hint label={commit.message}>
138142 <Link to={`${repoBase}/commit/${commit.hash}`} className="min-w-0 grow truncate hover:text-accent">
139143 {commit.message}
140144 </Link>
141145 </Hint>
142− <span className="hidden shrink-0 text-xs text-muted sm:inline">{commit.author}</span>
146+ <span className="hidden shrink-0 text-xs text-muted sm:inline">
147+ <CommitNames commit={commit} className="hover:text-fg" />
148+ </span>
143149 <Link to={`${repoBase}/commit/${commit.hash}`} className="shrink-0 font-mono text-xs text-faint hover:text-fg">
144150 {commit.hash.slice(0, 7)}
145151 </Link>
+8−14
1−import { Link } from "react-router";
21
32 import type { Contributor, WeekCommits } from "@g1t/contracts";
43
54 import type { Route } from "./+types/contributors";
6−import { Avatar, EmptyState, TimeAgo } from "../../components/ui";
5+import { CommitAvatar, CommitName } from "../../components/commit-person";
6+import { EmptyState, TimeAgo } from "../../components/ui";
77 import { Hint } from "../../components/ui/hint";
88 import { requireRepo } from "../../lib/access.server";
9−import { alignWeeks, contributorHref, count, peak } from "../../lib/about";
9+import { alignWeeks, count, peak } from "../../lib/about";
10+import { contributorPerson } from "../../lib/commit-people";
1011 import { page } from "../../lib/meta";
1112 import { repos } from "../../lib/services.server";
1213 import { unwrap } from "../../lib/session.server";
4849 }
4950
5051 function Person({ contributor, rank, weeks, scale }: { contributor: Contributor; rank: number; weeks: WeekCommits[]; scale: number }) {
51− const href = contributorHref(contributor);
52− const name = href ? (
53− <Link to={href} className="font-medium hover:text-accent">
54− {contributor.name}
55− </Link>
56− ) : (
57− <span className="font-medium">{contributor.name}</span>
58− );
52+ // The same person, avatar, link and card as on their commits.
53+ const person = contributorPerson(contributor);
5954 return (
6055 <li className="rounded-xl border border-line bg-surface p-4">
6156 <div className="flex items-center gap-3">
62− <Avatar name={contributor.name} image={contributor.avatar} size={36} system={contributor.kind === "g1t"} />
57+ <CommitAvatar person={person} size={36} />
6358 <div className="min-w-0 grow">
6459 <p className="flex items-center gap-2 truncate text-sm">
65− {name}
66− {contributor.kind === "g1t" && <span className="text-xs text-faint">agent</span>}
60+ <CommitName person={person} className="font-medium" />
6761 </p>
6862 <p className="text-xs text-muted">
6963 {count(contributor.commits, "commit")} · last <TimeAgo at={contributor.lastAt} />
+3−1
474474 {issue.assignees.map((name) => (
475475 <li key={name} className="flex items-center gap-2 px-1">
476476 <Avatar name={name} size={20} />
477− <span className="grow truncate font-mono text-xs">{name}</span>
477+ <span className="grow truncate font-mono text-xs">
478+ <PersonLink name={name} className="hover:underline" />
479+ </span>
478480 </li>
479481 ))}
480482 {open && issue.queued && !assigned && (
+13−5
102102 import { cloneUrl, useAddresses } from "../../lib/addresses";
103103 import { readBranches } from "../../lib/branches.server";
104104 import { commitChecksFor } from "../../lib/commit-checks.server";
105+import { showCommits } from "../../lib/commit-people.server";
106+import { CommitAvatars, CommitNames } from "../../components/commit-person";
105107 import { CommitChecksBadge } from "../../components/commit-checks";
106108
107109 const MAX_LANDED = 6;
214216 listP,
215217 openP,
216218 soft(work.listPulls(path, viewer, "closed")),
217− soft(repos.log(path, viewer, null, COMMITS_SHOWN)),
219+ // With their people, in one identity call.
220+ soft(repos.log(path, viewer, null, COMMITS_SHOWN).then(async (found) => (found.ok ? { ok: true as const, value: await showCommits(found.value) } : found))),
218221 soft(countsFor(context, params)),
219222 soft(projects.dependencies(params.owner, params.repo, viewer)),
220223 soft(agents.listRuns(viewer, { repo: path, limit: 60 })),
438441 builds: builds.slice(0, 30),
439442 live: ok(list)?.live ?? [],
440443 commit: ok(log)?.[0] ?? null,
441− commits: (ok(log) ?? []).map((one) => ({ hash: one.hash, message: one.message.split("\n")[0] ?? "", author: one.author.name, at: one.authoredAt })),
444+ commits: (ok(log) ?? []).map((one) => ({ hash: one.hash, message: one.message.split("\n")[0] ?? "", author: one.author, coAuthors: one.coAuthors, at: one.authoredAt })),
442445 release: latestTag(ok(tagList) ?? []),
443446 checklistTitle: plan.title,
444447 // Production as reported from outside g1t, for an app deployed elsewhere.
10101013 {commit && (
10111014 <p className="truncate border-t border-line px-5 py-2.5 text-xs text-muted sm:px-6">
10121015 <GitCommitHorizontal size={12} className="mr-1.5 inline text-faint" />
1013− {commit.message.split("\n")[0]} <span className="text-faint">· {commit.author.name}</span>
1016+ {commit.message.split("\n")[0]}{" "}
1017+ <span className="text-faint">
1018+ · <CommitNames commit={commit} className="hover:text-fg" />
1019+ </span>
10141020 </p>
10151021 )}
10161022 </section>
12091215 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
12101216 {loaderData.commits.map((one) => (
12111217 <li key={one.hash} className="flex items-center gap-3 px-4 py-2.5 text-sm">
1212− <Avatar name={one.author} size={18} />
1218+ <CommitAvatars commit={one} size={18} max={2} />
12131219 <span className="min-w-0 grow">
12141220 <Link to={`${base}/commit/${one.hash}`} className="block truncate hover:text-accent">
12151221 {one.message}
12161222 </Link>
1217− <span className="text-xs text-muted">{one.author}</span>
1223+ <span className="text-xs text-muted">
1224+ <CommitNames commit={one} className="hover:text-fg" />
1225+ </span>
12181226 </span>
12191227 <Link to={`${base}/commit/${one.hash}`} className="hidden shrink-0 font-mono text-xs text-faint hover:text-fg sm:block">
12201228 {one.hash.slice(0, 7)}
+6−2
15121512 ) : (
15131513 <Avatar name={name} size={20} />
15141514 )}
1515− <span className="grow truncate font-mono text-xs">{name}</span>
1515+ <span className="grow truncate font-mono text-xs">
1516+ <PersonLink name={name} className="hover:underline" />
1517+ </span>
15161518 {pending ? (
15171519 <span className="flex items-center gap-1.5 text-xs text-muted">
15181520 <span className="size-1.5 animate-pulse rounded-full bg-accent" />
16101612 {pull.assignees.map((name) => (
16111613 <li key={name} className="flex items-center gap-2 px-1">
16121614 <Avatar name={name} size={20} />
1613− <span className="grow truncate font-mono text-xs">{name}</span>
1615+ <span className="grow truncate font-mono text-xs">
1616+ <PersonLink name={name} className="hover:underline" />
1617+ </span>
16141618 </li>
16151619 ))}
16161620 {pull.assignees.length === 0 && (
+6−3
3535 import { refusal, requireInsider } from "../../lib/access.server";
3636 import { billing, identity } from "../../lib/services.server";
3737 import { assertSameOrigin, requireUser, roleIn, unwrap } from "../../lib/session.server";
38+import { UserCard } from "../../components/user-card";
3839
3940 export function meta({ params, ...args }: Route.MetaArgs) {
4041 return page(args, { title: `Access · ${params.owner}/${params.repo} · g1t` });
362363 <Avatar name={person.username} image={person.avatar} size={32} />
363364 <div className="min-w-0 grow basis-40">
364365 <p className="flex flex-wrap items-center gap-x-2 gap-y-1">
365− <Link to={`/u/${person.username}`} className="font-mono text-sm hover:text-accent">
366− {person.username}
367− </Link>
366+ <UserCard username={person.username}>
367+ <Link to={`/u/${person.username}`} className="font-mono text-sm hover:text-accent">
368+ {person.username}
369+ </Link>
370+ </UserCard>
368371 {person.name && <span className="truncate text-sm text-muted">{person.name}</span>}
369372 {person.source === "owner" && <Badge tone="accent">Owner</Badge>}
370373 {outside && <Badge tone="info">Outside collaborator</Badge>}
+6−3
66 import { page } from "../../lib/meta";
77 import { repos } from "../../lib/services.server";
88 import { unwrap } from "../../lib/session.server";
9+import { UserCard } from "../../components/user-card";
910
1011 export function meta({ params, ...args }: Route.MetaArgs) {
1112 return page(args, { title: `Stargazers · ${params.owner}/${params.repo} · g1t` });
3637 <li key={person.username} className="flex items-center gap-3 rounded-xl border border-line bg-surface p-3">
3738 <Avatar name={person.username} image={person.avatar} size={36} />
3839 <div className="min-w-0">
39− <Link to={`/u/${person.username}`} className="block truncate text-sm font-medium hover:text-accent">
40− {person.username}
41− </Link>
40+ <UserCard username={person.username}>
41+ <Link to={`/u/${person.username}`} className="block truncate text-sm font-medium hover:text-accent">
42+ {person.username}
43+ </Link>
44+ </UserCard>
4245 <p className="text-xs text-faint">
4346 Starred <TimeAgo at={person.starredAt} />
4447 </p>
+14−5
33
44 import type { Route } from "./+types/tags";
55 import { CommitChecksBadge } from "../../components/commit-checks";
6−import { Avatar, EmptyState, TimeAgo } from "../../components/ui";
6+import { CommitAvatars, CommitNames } from "../../components/commit-person";
7+import { EmptyState, TimeAgo } from "../../components/ui";
78 import { Hint } from "../../components/ui/hint";
89 import { page } from "../../lib/meta";
910 import { commitChecksFor } from "../../lib/commit-checks.server";
11+import { showCommits } from "../../lib/commit-people.server";
1012 import { repos } from "../../lib/services.server";
1113 import { getViewer, unwrap } from "../../lib/session.server";
1214
1719 export async function loader({ params, context }: Route.LoaderArgs) {
1820 const path = { namespace: params.owner, name: params.repo };
1921 const viewer = getViewer(context);
20− const tags = unwrap(await repos.tags(path, viewer));
22+ const found = unwrap(await repos.tags(path, viewer));
2123 // Each tagged commit's checks, in one call, streamed in beside it.
22− return { tags, checks: commitChecksFor(path, viewer, tags.map((tag) => tag.commit?.hash)) };
24+ const checks = commitChecksFor(path, viewer, found.map((tag) => tag.commit?.hash));
25+ // Every tagged commit's people, in one identity call.
26+ const shown = await showCommits(found.flatMap((tag) => (tag.commit ? [tag.commit] : [])));
27+ const byHash = new Map(shown.map((commit) => [commit.hash, commit]));
28+ const tags = found.map((tag) => ({ name: tag.name, commit: tag.commit ? byHash.get(tag.commit.hash)! : null }));
29+ return { tags, checks };
2330 }
2431
2532 export default function Tags({ loaderData, params }: Route.ComponentProps) {
4754 </Link>
4855 {tag.commit && (
4956 <span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted">
50− <Avatar name={tag.commit.author.name} size={13} />
51− <span className="shrink-0">{tag.commit.author.name}</span>
57+ <CommitAvatars commit={tag.commit} size={14} max={2} />
58+ <span className="shrink-0">
59+ <CommitNames commit={tag.commit} className="hover:text-fg" />
60+ </span>
5261 <span className="text-faint">·</span>
5362 <Hint label={tag.commit.message}>
5463 <Link to={`${base}/commit/${tag.commit.hash}`} className="min-w-0 truncate hover:text-fg">
+4−1
66 import { aboutFor } from "../../lib/about.server";
77 import { environmentsFor } from "../../lib/deployments.server";
88 import { lastCommitsFor } from "../../lib/last-commits.server";
9+import { showOneCommit } from "../../lib/commit-people.server";
910 import { repos } from "../../lib/services.server";
1011 import { getViewer, unwrap } from "../../lib/session.server";
1112 import { projectHomepage } from "../../lib/about";
3031 if (!tree.ok && tree.error.code === "not_found") await redirectIfBranchRenamed(request, path, viewer, params.ref);
3132 // Each entry's last commit walks history: streamed in after the list.
3233 const lastCommits = lastCommitsFor(path, viewer, params.ref, params["*"] ?? "");
33− const value = unwrap(tree);
34+ const found = unwrap(tree);
35+ // The latest commit's author, as their account where there is one.
36+ const value = { ...found, head: await showOneCommit(found.head) };
3437 // The About beside the files, streamed in after them.
3538 const about = value.head && !value.path ? aboutFor(path, viewer, value.repo.id) : null;
3639 // The latest commit's checks: streamed in beside it.
+2−1
33 import type { SessionEntry } from "@g1t/contracts";
44
55 import type { Route } from "./+types/why";
6+import { showOneCommit } from "../../lib/commit-people.server";
67 import { pullForCommit } from "../../lib/provenance.server";
78 import { repos, work } from "../../lib/services.server";
89 import { getViewer } from "../../lib/session.server";
6566 repos.log(path, viewer, params.hash, 1),
6667 pullForCommit(path, viewer, params.hash),
6768 ]);
68− const commit = log.ok ? log.value[0] : undefined;
69+ const commit = await showOneCommit(log.ok ? log.value[0] : undefined);
6970 if (!commit) throw data(null, { status: 404 });
7071 if (!pull) return { commit, pull: null, issue: null, steps: [] as SessionEntry[] };
7172 const [detail, session] = await Promise.all([
+11−6
5050 roleIn,
5151 unwrap,
5252 } from "../../lib/session.server";
53+import { UserCard } from "../../components/user-card";
5354
5455 export function meta({ params, ...args }: Route.MetaArgs) {
5556 return page(args, { title: `People · ${params.owner} · g1t` });
318319 <li key={member.username} className="flex flex-wrap items-center gap-3 px-4 py-3">
319320 <Avatar name={member.username} image={member.avatar} size={28} />
320321 <div className="min-w-0 grow truncate">
321− <Link to={`/u/${member.username}`} className="font-mono text-sm hover:text-accent">
322− {member.username}
323− </Link>
322+ <UserCard username={member.username}>
323+ <Link to={`/u/${member.username}`} className="font-mono text-sm hover:text-accent">
324+ {member.username}
325+ </Link>
326+ </UserCard>
324327 {member.name && <span className="ml-2 hidden text-sm text-muted sm:inline">{member.name}</span>}
325328 {(teams[member.username] ?? []).length > 0 && (
326329 <div className="mt-1 flex flex-wrap gap-1">
584587 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
585588 <Avatar name={person.username} image={person.avatar} size={28} />
586589 <div className="min-w-0 grow basis-32">
587− <Link to={`/u/${person.username}`} className="font-mono text-sm hover:text-accent">
588− {person.username}
589− </Link>
590+ <UserCard username={person.username}>
591+ <Link to={`/u/${person.username}`} className="font-mono text-sm hover:text-accent">
592+ {person.username}
593+ </Link>
594+ </UserCard>
590595 {person.name && <span className="ml-2 text-sm text-muted">{person.name}</span>}
591596 </div>
592597 {!free && (
+6−3
1212 import { teamPath } from "../../../lib/teams";
1313 import { identity } from "../../../lib/services.server";
1414 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server";
15+import { UserCard } from "../../../components/user-card";
1516
1617 export async function loader({ params, context, request }: Route.LoaderArgs) {
1718 const viewer = getViewer(context);
7576 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
7677 <Avatar name={member.username} image={member.avatar} size={28} />
7778 <div className="min-w-0 grow basis-32 truncate">
78− <Link to={`/u/${member.username}`} className="font-mono text-sm hover:text-accent">
79− {member.username}
80− </Link>
79+ <UserCard username={member.username}>
80+ <Link to={`/u/${member.username}`} className="font-mono text-sm hover:text-accent">
81+ {member.username}
82+ </Link>
83+ </UserCard>
8184 {member.name && <span className="ml-2 hidden text-sm text-muted sm:inline">{member.name}</span>}
8285 </div>
8386 {member.via ? (
+6−0
1+-- How a repository's About was worked out (src/stats.rs, STATS_VERSION).
2+-- A row worked out by an older version is worked out again on the next
3+-- view, as if its head had moved: version 1 counts the addresses g1t's
4+-- agents and merge queue committed as before 2026-10-06 as g1t.
5+-- Additive; existing rows are version 0.
6+ALTER TABLE repo_stats ADD COLUMN version INTEGER NOT NULL DEFAULT 0;
+23−1
1111 /// The address g1t's own commits carry.
1212 pub const G1T_EMAIL: &str = "g1t@users.noreply.g1t.sh";
1313
14+/// The addresses g1t's agents and merge queue committed as before they
15+/// committed as g1t (2026-10-06): history keeps them, and they are g1t's.
16+/// The site matches the same set (apps/web/app/lib/commit-people.ts).
17+pub const LEGACY_G1T_EMAILS: [&str; 3] = ["agent@g1t.sh", "queue@g1t.sh", "mergecheck@g1t.sh"];
18+
1419 /// One commit's author, as history gives it.
1520 #[derive(Clone, Debug)]
1621 pub struct Authored {
2227
2328 /// Whether a commit's author is g1t itself.
2429 pub fn is_g1t(author: &Authored) -> bool {
25− author.email.eq_ignore_ascii_case(G1T_EMAIL)
30+ let email = author.email.trim();
31+ email.eq_ignore_ascii_case(G1T_EMAIL) || LEGACY_G1T_EMAILS.iter().any(|legacy| email.eq_ignore_ascii_case(legacy))
2632 }
2733
2834 #[derive(Default)]
151157 }
152158
153159 #[test]
160+ fn g1t_older_addresses_are_g1t() {
161+ let commits = vec![
162+ authored("g1t agent", "agent@g1t.sh", "2026-10-01T10:00:00Z"),
163+ authored("g1t merge queue", "Queue@g1t.sh", "2026-10-02T10:00:00Z"),
164+ authored("g1t", G1T_EMAIL, "2026-10-07T10:00:00Z"),
165+ authored("Not g1t", "someone@g1t.sh", "2026-10-07T10:00:00Z"),
166+ ];
167+ let (contributors, total, _) = tally(&commits, &HashMap::new());
168+ assert_eq!(total, 2);
169+ assert_eq!(contributors[0].kind, ContributorKind::G1t);
170+ assert_eq!(contributors[0].name, "g1t");
171+ assert_eq!(contributors[0].commits, 3);
172+ assert_eq!(contributors[1].kind, ContributorKind::Author, "only g1t's own addresses");
173+ }
174+
175+ #[test]
154176 fn no_history_is_no_one() {
155177 let (contributors, total, weeks) = tally(&[], &HashMap::new());
156178 assert!(contributors.is_empty() && weeks.is_empty());
+14−4
4545 /// Where a security policy may be.
4646 const POLICY_DIRS: [&str; 4] = ["", ".g1t/", ".github/", "docs/"];
4747
48+/// How the About is worked out; a row kept by an older version is worked
49+/// out again. 1: g1t's older commit addresses count as g1t.
50+pub const STATS_VERSION: f64 = 1.0;
51+
4852 /// What `repo_stats` keeps for the About, without the full contributors.
4953 #[derive(Debug, Default, Deserialize)]
5054 pub struct Kept {
5761 pub languages: Option<String>,
5862 pub contributors_total: f64,
5963 pub contributors_top: Option<String>,
64+ /// [`STATS_VERSION`] when it was worked out.
65+ #[serde(default)]
66+ pub version: f64,
6067 }
6168
6269 impl Kept {
6976 /// or what is kept is partial, and none is under way.
7077 pub fn wants_run(&self, head: Option<&str>, now: u64) -> bool {
7178 let Some(head) = head else { return false };
72− let behind = self.commit_hash.as_deref() != Some(head) || self.partial > 0.0;
79+ let behind = self.commit_hash.as_deref() != Some(head) || self.partial > 0.0 || self.version < STATS_VERSION;
7380 // A partial answer is tried again at most every lease.
7481 let rested = self.partial == 0.0 || self.started_ms.is_none_or(|started| now.saturating_sub(started as u64) >= LEASE_MS);
7582 behind && rested && !self.running(now)
102109 pub async fn kept(db: &D1Database, repo_id: &str) -> Result<Kept> {
103110 Ok(db
104111 .prepare(
105− "SELECT commit_hash, computed_at, started_ms, partial, license, security_policy, languages, contributors_total, contributors_top
112+ "SELECT commit_hash, computed_at, started_ms, partial, license, security_policy, languages, contributors_total, contributors_top, version
106113 FROM repo_stats WHERE repo_id = ?",
107114 )
108115 .bind(&[repo_id.into()])?
190197 db.prepare(
191198 "UPDATE repo_stats SET commit_hash = ?2, computed_at = ?3, started_ms = CASE WHEN ?4 = 1 THEN started_ms ELSE NULL END,
192199 partial = ?4, license = ?5, security_policy = ?6, languages = ?7, contributors_total = ?8, contributors_top = ?9,
193− contributors = ?10
200+ contributors = ?10, version = ?11
194201 WHERE repo_id = ?1",
195202 )
196203 .bind(&[
210217 JsValue::from_f64(f64::from(worked.contributors_total)),
211218 json(&top).into(),
212219 json(&full).into(),
220+ JsValue::from_f64(STATS_VERSION),
213221 ])?
214222 .run()
215223 .await?;
398406 let none = Kept::default();
399407 assert!(none.wants_run(Some("c2"), now));
400408 assert!(!none.wants_run(None, now), "an empty repository has nothing to work out");
401− let current = Kept { commit_hash: Some("c2".into()), ..Kept::default() };
409+ let current = Kept { commit_hash: Some("c2".into()), version: STATS_VERSION, ..Kept::default() };
402410 assert!(!current.wants_run(Some("c2"), now));
403411 assert!(current.wants_run(Some("c3"), now));
412+ let older = Kept { commit_hash: Some("c2".into()), version: STATS_VERSION - 1.0, ..Kept::default() };
413+ assert!(older.wants_run(Some("c2"), now), "an answer worked out the old way is worked out again");
404414 let running = Kept { commit_hash: Some("c2".into()), started_ms: Some((now - 1_000) as f64), ..Kept::default() };
405415 assert!(!running.wants_run(Some("c3"), now));
406416 let died = Kept { commit_hash: Some("c2".into()), started_ms: Some((now - LEASE_MS - 1) as f64), ..Kept::default() };