Skip to content

Commit

Commits show their accounts, and every name opens a card (repos 0016)

One shared helper (apps/web/app/lib/commit-people.ts, .server.ts) turns a commit's author and Co-authored-by trailers into people: the account an address belongs to by email_owners (confirmed or noreply addresses only), g1t for its own address and the ones its agents and merge queue used before (agent@, queue@, mergecheck@g1t.sh), ghost for a deleted account, else the name on the commit with a plain avatar and no link. One identity call per page, answers kept a minute in the isolate. Addresses never reach the page: commits are sent with people, file rows without authors, and trailers on the commit page drop their addresses. Used by the Files commit bar, history, the commit page, blame and its why panel, branches, tags, compare and the overview. Contributors (About and the Contributors page) render through the same person mapping; the repos tally now counts g1t's older addresses as g1t, and a stats version (migration 0016) makes kept answers be worked out again. A shadcn HoverCard (components/ui/hover-card.tsx) opens a person's card on hover or focus, never on touch: profile, workspaces the viewer may see, and recent commits to the repository the viewer can read, from /-/hovercard/user/:username (snake_case, private max-age 60). @g1t has a card of its own; ghost has none. PersonLink, mentions, commit names and avatars, contributors, reviewers, assignees and member lists use it. Docs: profiles guide gains the card and "Commits and your account" with noreply setup.

syntaqxcommitted Parent82db908Browse files
50 files+1381−1910/50 viewed
+2−1
386386 (`<username>@users.g1t.sh`) count as yours too. An unconfirmed address
387387 never attributes a commit, so nobody can claim your commits by adding your
388388 address. Commits whose address matches no account show the name in the
389−commit.
389+commit. See [Commits and your account](/guides/workspaces/#commits-and-your-account)
390+for setting your noreply address in git.
390391
391392 ### Email addresses through the API
392393
+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, 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
101101 import { cloneUrl, useAddresses } from "../../lib/addresses";
102102 import { readBranches } from "../../lib/branches.server";
103103 import { commitChecksFor } from "../../lib/commit-checks.server";
104+import { showCommits } from "../../lib/commit-people.server";
105+import { CommitAvatars, CommitNames } from "../../components/commit-person";
104106 import { CommitChecksBadge } from "../../components/commit-checks";
105107
106108 const MAX_LANDED = 6;
209211 listP,
210212 openP,
211213 soft(work.listPulls(path, viewer, "closed")),
212− soft(repos.log(path, viewer, null, COMMITS_SHOWN)),
214+ // With their people, in one identity call.
215+ soft(repos.log(path, viewer, null, COMMITS_SHOWN).then(async (found) => (found.ok ? { ok: true as const, value: await showCommits(found.value) } : found))),
213216 soft(countsFor(context, params)),
214217 soft(projects.dependencies(params.owner, params.repo, viewer)),
215218 soft(agents.listRuns(viewer, { repo: path, limit: 60 })),
435438 builds: builds.slice(0, 30),
436439 live: ok(list)?.live ?? [],
437440 commit: ok(log)?.[0] ?? null,
438− commits: (ok(log) ?? []).map((one) => ({ hash: one.hash, message: one.message.split("\n")[0] ?? "", author: one.author.name, at: one.authoredAt })),
441+ commits: (ok(log) ?? []).map((one) => ({ hash: one.hash, message: one.message.split("\n")[0] ?? "", author: one.author, coAuthors: one.coAuthors, at: one.authoredAt })),
439442 release: latestTag(ok(tagList) ?? []),
440443 checklistTitle: plan.title,
441444 // Production as reported from outside g1t, for an app deployed elsewhere.
10071010 {commit && (
10081011 <p className="truncate border-t border-line px-5 py-2.5 text-xs text-muted sm:px-6">
10091012 <GitCommitHorizontal size={12} className="mr-1.5 inline text-faint" />
1010− {commit.message.split("\n")[0]} <span className="text-faint">· {commit.author.name}</span>
1013+ {commit.message.split("\n")[0]}{" "}
1014+ <span className="text-faint">
1015+ · <CommitNames commit={commit} className="hover:text-fg" />
1016+ </span>
10111017 </p>
10121018 )}
10131019 </section>
12061212 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
12071213 {loaderData.commits.map((one) => (
12081214 <li key={one.hash} className="flex items-center gap-3 px-4 py-2.5 text-sm">
1209− <Avatar name={one.author} size={18} />
1215+ <CommitAvatars commit={one} size={18} max={2} />
12101216 <span className="min-w-0 grow">
12111217 <Link to={`${base}/commit/${one.hash}`} className="block truncate hover:text-accent">
12121218 {one.message}
12131219 </Link>
1214− <span className="text-xs text-muted">{one.author}</span>
1220+ <span className="text-xs text-muted">
1221+ <CommitNames commit={one} className="hover:text-fg" />
1222+ </span>
12151223 </span>
12161224 <Link to={`${base}/commit/${one.hash}`} className="hidden shrink-0 font-mono text-xs text-faint hover:text-fg sm:block">
12171225 {one.hash.slice(0, 7)}
+6−2
14351435 ) : (
14361436 <Avatar name={name} size={20} />
14371437 )}
1438− <span className="grow truncate font-mono text-xs">{name}</span>
1438+ <span className="grow truncate font-mono text-xs">
1439+ <PersonLink name={name} className="hover:underline" />
1440+ </span>
14391441 {pending ? (
14401442 <span className="flex items-center gap-1.5 text-xs text-muted">
14411443 <span className="size-1.5 animate-pulse rounded-full bg-accent" />
15331535 {pull.assignees.map((name) => (
15341536 <li key={name} className="flex items-center gap-2 px-1">
15351537 <Avatar name={name} size={20} />
1536− <span className="grow truncate font-mono text-xs">{name}</span>
1538+ <span className="grow truncate font-mono text-xs">
1539+ <PersonLink name={name} className="hover:underline" />
1540+ </span>
15371541 </li>
15381542 ))}
15391543 {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() };