-
- Repositories
-
- {ready && (
-
-
- {initial(owner()?.displayName || accountInfo.handle)}
-
- }
- className="size-6"
- />
-
- {accountInfo.handle}
-
-
- )}
+ {/* The window is the app: the bar holds its place and each pane below
+ takes its own scroll, so nothing but the reading area ever moves. */}
+
+ {/* Where the reader is, and nothing else. Which section is open and
+ what that section is looking at each get a band of their own
+ below, so no one bar carries three different jobs. */}
+
+
+
+ {/* On a narrow window the repository's own name is the one that
+ has to survive. The avatar carries the way back to the list
+ at every width. */}
+
+ {accountInfo.handle || 'Repositories'}
+
+ {known && (
+ <>
+ /
+
+ {repo}
+
+ {/* Code is where a repository opens, so naming it here
+ would say nothing the crumb before it has not. */}
+ {section !== 'code' && (
+ <>
+ /
+
+ {SECTION_TITLES[section]}
+
+ >
+ )}
+ >
+ )}
+
+ {ready && (
+
+
+
+ {initial(owner()?.displayName || accountInfo.handle)}
+
+ }
+ className="size-7"
+ />
+
+
+ )}
+
- {error && !ready ? (
-
- Could not reach the PDS. {error.message}
-
- ) : !ready ? (
- // The URL says which screen is coming, so the placeholder can hold
- // its shape rather than a bare block.
- path.split('/').filter(Boolean).length === 0 ? (
-
- ) : (
-
- )
- ) : (
-
+ {/* The repository's sections, with the one action that belongs to the
+ repository rather than to any one of them. */}
+ {known && (
+
)}
-
- Served from an atproto repo by{' '}
-
- pds.js
-
-
+
+ {error && !ready ? (
+
+ Could not reach the PDS. {error.message}
+
+ ) : !ready ? (
+ // The URL says which screen is coming, so the placeholder can hold
+ // its shape rather than a bare block.
+ segments.length === 0 ? (
+
+ ) : (
+
+ )
+ ) : filled ? (
+
+ ) : (
+
+
+
+ )}
+
);
diff --git a/packages/git-ui/src/components/molecules/account-panel.jsx b/packages/git-ui/src/components/molecules/account-panel.jsx
new file mode 100644
index 0000000..e252324
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/account-panel.jsx
@@ -0,0 +1,86 @@
+import { Avatar } from '#/components/atoms/avatar.jsx';
+import { initial, owner } from '#/lib/authors.js';
+import { bytes } from '#/lib/format.js';
+import { account, repoSize } from '#/lib/git.js';
+
+/** One reading of the account, label above value. */
+function Field({ label, children }) {
+ return (
+
+
+ {label}
+
+
+ {children}
+
+
+ );
+}
+
+/**
+ * Whose repositories these are, beside the list of them: the account, the
+ * name it answers to, and the server holding it. Every repository on this
+ * page is one account's, so the account is named once here rather than on
+ * each row.
+ */
+export function AccountPanel({ repos }) {
+ const profile = owner();
+ const total = (repos ?? []).reduce((sum, repo) => sum + repoSize(repo), 0);
+
+ return (
+
+
+
+
+ {initial(profile?.displayName || account.handle)}
+
+ }
+ className="size-10 shrink-0"
+ />
+
+
+ {profile?.displayName || account.handle}
+
+
+ {account.handle}
+
+
+
+
+ {profile?.description && (
+
+ {profile.description}
+
+ )}
+
+
+
+ {account.did}
+
+
+
+ {account.pds.replace(/^https?:\/\//, '')}
+
+
+
+ {(repos ?? []).length} · {bytes(total)}
+
+
+
+
+
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/activity-ticks.jsx b/packages/git-ui/src/components/molecules/activity-ticks.jsx
new file mode 100644
index 0000000..b2fd658
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/activity-ticks.jsx
@@ -0,0 +1,55 @@
+import { ACTIVITY_WEEKS, WEEK_MS } from '#/lib/activity.js';
+import { cn } from '#/lib/utils.js';
+
+/**
+ * How often this ref was committed to, week by week, as a row of ticks read
+ * like a gauge. The dates come from the walk the page already made for its
+ * latest commit, so this costs no read of its own.
+ *
+ * A week with no commits keeps a floor tick, so the row reads as a scale
+ * rather than as gaps. Heights are against the busiest week in view, which
+ * makes the shape of the work legible whatever the repository's volume.
+ */
+export function ActivityTicks({ dates, className }) {
+ const now = Date.now();
+ /** @type {{start: number, count: number}[]} oldest first */
+ const weeks = Array.from({ length: ACTIVITY_WEEKS }, (_, index) => ({
+ start: now - (ACTIVITY_WEEKS - index) * WEEK_MS,
+ count: 0,
+ }));
+ for (const at of dates) {
+ const week = Math.floor((now - at) / WEEK_MS);
+ if (week >= 0 && week < ACTIVITY_WEEKS) {
+ weeks[ACTIVITY_WEEKS - 1 - week].count += 1;
+ }
+ }
+
+ const busiest = Math.max(...weeks.map((week) => week.count));
+ if (busiest === 0) return null;
+ const total = weeks.reduce((sum, week) => sum + week.count, 0);
+ const words = `${total} ${total === 1 ? 'commit' : 'commits'} in the last ${ACTIVITY_WEEKS} weeks`;
+
+ return (
+
+ {weeks.map((week) => (
+ 0 ? 'bg-key' : 'h-[2px] bg-border',
+ )}
+ style={
+ week.count > 0
+ ? { height: `${Math.max(20, (week.count / busiest) * 100)}%` }
+ : undefined
+ }
+ />
+ ))}
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/check-disclosure.jsx b/packages/git-ui/src/components/molecules/check-disclosure.jsx
new file mode 100644
index 0000000..ad3a5de
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/check-disclosure.jsx
@@ -0,0 +1,52 @@
+import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react';
+import { useState } from 'react';
+import { Link } from '#/components/atoms/link.jsx';
+import { CheckLog } from '#/components/molecules/check-log.jsx';
+import { CheckRow } from '#/components/molecules/check-row.jsx';
+import { CheckSteps } from '#/components/molecules/check-steps.jsx';
+
+/**
+ * One run as a line that opens in place: the steps it ran and the log behind
+ * them. The log is fetched only once opened, so a commit checked by several
+ * workflows costs one request per run the reader actually opens.
+ *
+ * A failed run opens itself. Someone reading a commit whose check failed came
+ * for the output.
+ */
+export function CheckDisclosure({ runner, check, runHref }) {
+ const failed = check.status === 'failure' || check.status === 'error';
+ const [open, setOpen] = useState(failed);
+ const steps = Array.isArray(check.steps) ? check.steps : [];
+ const Chevron = open ? ChevronDownIcon : ChevronRightIcon;
+
+ return (
+
+
+ setOpen(!open)}
+ aria-expanded={open}
+ className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 py-0 pl-2 text-left hover:bg-accent"
+ >
+
+
+
+ {runHref && (
+
+ Details
+
+ )}
+
+ {open && (
+
+ {steps.length > 0 &&
}
+ {/* A log opened beside its neighbours is capped, so one long run
+ does not push the rest of them off the page. */}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/check-filter.jsx b/packages/git-ui/src/components/molecules/check-filter.jsx
new file mode 100644
index 0000000..447efd7
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/check-filter.jsx
@@ -0,0 +1,43 @@
+import { cn } from '#/lib/utils.js';
+
+/**
+ * Which workflow a run listing shows, with how many runs each holds. A
+ * repository checked by one workflow has nothing to choose between, so the
+ * filter appears from two workflows upwards.
+ */
+export function CheckFilter({ workflows, counts, total, value, onSelect }) {
+ if (workflows.length < 2) return null;
+
+ const options = [
+ { key: '', title: 'All workflows', count: total },
+ ...workflows.map((name) => ({
+ key: name,
+ title: name,
+ count: counts[name],
+ })),
+ ];
+
+ return (
+
+ {options.map((option) => (
+ onSelect(option.key)}
+ aria-pressed={value === option.key}
+ className={cn(
+ 'flex cursor-pointer items-center gap-2 rounded-[3px] px-3 py-1.5 text-[13px]',
+ value === option.key
+ ? 'bg-accent text-foreground'
+ : 'text-muted-foreground hover:bg-accent/60 hover:text-foreground',
+ )}
+ >
+ {option.title}
+
+ {option.count}
+
+
+ ))}
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/check-group.jsx b/packages/git-ui/src/components/molecules/check-group.jsx
new file mode 100644
index 0000000..3089e79
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/check-group.jsx
@@ -0,0 +1,77 @@
+import { Link } from '#/components/atoms/link.jsx';
+import { CheckRow } from '#/components/molecules/check-row.jsx';
+import {
+ checkLook,
+ latestPerWorkflow,
+ summarize,
+ summaryWords,
+} from '#/lib/checks.js';
+import { shortRef } from '#/lib/git.js';
+import { cn } from '#/lib/utils.js';
+
+/**
+ * One commit's runs, under a heading that names the commit. A sha names
+ * nothing to a reader, so the heading reads as the commit's subject line once
+ * the repository is open, and as the short sha until then.
+ *
+ * The heading's status covers the commit: the newest run of each workflow
+ * decides it, so a workflow asked for twice counts once.
+ */
+export function CheckGroup({ repo, group, label }) {
+ const summary = summarize(latestPerWorkflow(group.runs));
+ const look = checkLook(summary?.status);
+ const commitHref = `/${encodeURIComponent(repo)}/commit/${group.sha}`;
+ const short = group.sha.slice(0, 8);
+ const ref = group.runs.find((run) => run.ref)?.ref;
+
+ return (
+
+
+
+
+ {label?.subject ?? short}
+
+ {ref && (
+
+ {shortRef(ref)}
+
+ )}
+ {label && (
+
+ {short}
+
+ )}
+
+
+ {group.runs.map((run) => (
+
+ {/* A record read without its key has no page to open. */}
+ {run.rkey ? (
+
+
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/check-log.jsx b/packages/git-ui/src/components/molecules/check-log.jsx
index f23353f..caa29f0 100644
--- a/packages/git-ui/src/components/molecules/check-log.jsx
+++ b/packages/git-ui/src/components/molecules/check-log.jsx
@@ -37,7 +37,7 @@ export function CheckLog({ runner, check }) {
return (
<>
-
+
{data || 'The log is empty.'}
diff --git a/packages/git-ui/src/components/molecules/check-row.jsx b/packages/git-ui/src/components/molecules/check-row.jsx
new file mode 100644
index 0000000..1ecf02e
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/check-row.jsx
@@ -0,0 +1,36 @@
+import { checkLook, runDuration, workflowOf } from '#/lib/checks.js';
+import { timeAgo } from '#/lib/format.js';
+import { cn } from '#/lib/utils.js';
+
+/**
+ * One run as a single line: which workflow ran, how long it took, and how it
+ * ended. Every listing of runs sits under something that already names the
+ * commit, so the line carries no sha and no ref.
+ *
+ * This holds no anchor and no button of its own. The caller decides whether
+ * the line opens the run's page or expands in place.
+ */
+export function CheckRow({ check, className }) {
+ const look = checkLook(check.status);
+ const took = runDuration(check);
+
+ return (
+
+
+
+ {workflowOf(check)}
+
+ {/* Each column holds its width whether or not the run fills it, so the
+ durations and times read down the page as columns. */}
+
+ {took}
+ {timeAgo(check.startedAt)}
+
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/check-run.jsx b/packages/git-ui/src/components/molecules/check-run.jsx
index 1f6bd2b..8ae3532 100644
--- a/packages/git-ui/src/components/molecules/check-run.jsx
+++ b/packages/git-ui/src/components/molecules/check-run.jsx
@@ -1,107 +1,82 @@
-import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react';
-import { useState } from 'react';
-import { Card } from '#/components/atoms/card.jsx';
import { Link } from '#/components/atoms/link.jsx';
import { CheckLog } from '#/components/molecules/check-log.jsx';
-import { checkLook, duration, runDuration } from '#/lib/checks.js';
+import { CheckSteps } from '#/components/molecules/check-steps.jsx';
+import { checkLook, runDuration, workflowOf } from '#/lib/checks.js';
import { shortRef } from '#/lib/git.js';
import { cn } from '#/lib/utils.js';
-/** A step's own line: the exit code decides how its name reads. */
-function StepRow({ step }) {
- const failed = step.exitCode !== 0;
+/** One field of the run's summary block. */
+function Field({ label, children }) {
return (
-
-
- {step.name}
-
- {typeof step.durationMs === 'number' && (
-
- {duration(step.durationMs)}
-
- )}
-
- {failed ? `exit ${step.exitCode}` : 'ok'}
-
+
+
+ {label}
+
+
+ {children}
+
);
}
/**
- * One run in full: what the runner ran, how each step ended, and the log
- * behind a disclosure. The log is fetched only once opened, so a commit with
- * several runs costs one request per run the reader actually opens.
- *
- * A failed run opens itself. Someone reading a commit whose check failed came
- * for the output.
- *
- * `commitHref` names the commit under test. A page that already names the
- * commit passes nothing, and the header leaves the sha out.
+ * One run in full, which is what the run's own page shows: what the runner
+ * ran it against, how each step ended, and the whole log. The log is on this
+ * page because a reader who opened one run came for its output.
*/
export function CheckRun({ runner, check, commitHref }) {
- const failed = check.status === 'failure' || check.status === 'error';
- const [open, setOpen] = useState(failed);
const look = checkLook(check.status);
const took = runDuration(check);
const steps = Array.isArray(check.steps) ? check.steps : [];
- const Chevron = open ? ChevronDownIcon : ChevronRightIcon;
return (
-
-
-
-
- {check.workflow ?? 'ci'} {look.label}
-
- {check.ref && (
-
- {shortRef(check.ref)}
+
+
+
+
+
+ {workflowOf(check)}
+
+
+ {look.label}
- )}
- {commitHref && (
-
- {check.sha.slice(0, 8)}
-
- )}
-
- {took && {took} }
- {new Date(check.startedAt).toLocaleString()}
-
+
+
+
+
+ {commitHref ? (
+
+ {check.sha.slice(0, 8)}
+
+ ) : (
+ {check.sha.slice(0, 8)}
+ )}
+
+ {check.ref && (
+
+ {shortRef(check.ref)}
+
+ )}
+
+ {took ?? 'running'}
+
+
+ {new Date(check.startedAt).toLocaleString()}
+
+
{steps.length > 0 && (
-
- {steps.map((step) => (
-
- ))}
+
+
)}
-
-
setOpen(!open)}
- className="flex w-full cursor-pointer items-center gap-1.5 px-4 py-2 text-left text-[12.5px] text-muted-foreground hover:text-foreground"
- >
-
- {open ? 'Hide log' : 'Show log'}
-
- {open && (
-
-
-
- )}
+ {/* The log takes what the window has left, so a long run reads without
+ moving the readings above it. */}
+
+
-
+
);
}
diff --git a/packages/git-ui/src/components/molecules/check-status.jsx b/packages/git-ui/src/components/molecules/check-status.jsx
index b89ff5a..cfe7e39 100644
--- a/packages/git-ui/src/components/molecules/check-status.jsx
+++ b/packages/git-ui/src/components/molecules/check-status.jsx
@@ -1,28 +1,25 @@
-import { useQuery } from '@tanstack/react-query';
-import { checkLook, loadChecks } from '#/lib/checks.js';
+import { checkLook, summaryWords } from '#/lib/checks.js';
import { cn } from '#/lib/utils.js';
/**
- * The check the repository's runner published for one commit, as one icon.
- * Absent means the repository names no runner, or the runner has not reported
- * on this commit; neither is worth showing.
+ * How a commit's checks ended, as one icon. A commit checked by several
+ * workflows reads as one status: a single failure makes the commit failed,
+ * whatever its other workflows say.
*
* The icon carries its words in a title, so a commit list row stays one line
- * and one anchor. The commit page shows the run itself instead.
+ * and one anchor. The commit page shows the runs themselves instead.
*/
-export function CheckStatus({ repo, sha, className }) {
- const { data } = useQuery({
- queryKey: ['checks', repo],
- queryFn: () => loadChecks(repo),
- });
- const check = data?.bySha.get(sha);
- if (!check) return null;
- const look = checkLook(check.status);
- const words = `${check.workflow ?? 'ci'} ${look.label}`;
+export function CheckStatus({ summary, runs, className }) {
+ if (!summary) return null;
+ const look = checkLook(summary.status);
+ const words = summaryWords(summary, runs);
return (
-
+
{words}
);
diff --git a/packages/git-ui/src/components/molecules/check-steps.jsx b/packages/git-ui/src/components/molecules/check-steps.jsx
new file mode 100644
index 0000000..735f684
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/check-steps.jsx
@@ -0,0 +1,44 @@
+import { duration } from '#/lib/checks.js';
+import { cn } from '#/lib/utils.js';
+
+/**
+ * What the runner ran, in order, and how each step ended. The exit code
+ * decides how a step's name reads.
+ */
+export function CheckSteps({ steps }) {
+ return (
+
+ {steps.map((step) => {
+ const failed = step.exitCode !== 0;
+ return (
+
+
+ {step.name}
+
+ {typeof step.durationMs === 'number' && (
+
+ {duration(step.durationMs)}
+
+ )}
+
+ {failed ? `exit ${step.exitCode}` : 'ok'}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/code-menu.jsx b/packages/git-ui/src/components/molecules/code-menu.jsx
index f1bec1a..f5a5b71 100644
--- a/packages/git-ui/src/components/molecules/code-menu.jsx
+++ b/packages/git-ui/src/components/molecules/code-menu.jsx
@@ -30,15 +30,18 @@ export function CodeMenu({ repo, httpClone }) {
return (
+ {/* The same control the ref picker is. Cloning is the one thing this
+ page offers, but a filled button for it would be the only loud
+ surface on a page of flat panels. */}
-
- Code
-
+
+ Clone
+
diff --git a/packages/git-ui/src/components/molecules/commit-check-status.jsx b/packages/git-ui/src/components/molecules/commit-check-status.jsx
new file mode 100644
index 0000000..b986816
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/commit-check-status.jsx
@@ -0,0 +1,24 @@
+import { useQuery } from '@tanstack/react-query';
+import { CheckStatus } from '#/components/molecules/check-status.jsx';
+import { loadChecks, summarize } from '#/lib/checks.js';
+
+/**
+ * The badge for one commit in a history listing, from the runner's run
+ * listing. Absent means the repository names no runner, or the runner has not
+ * reported on this commit; neither is worth showing.
+ */
+export function CommitCheckStatus({ repo, sha, className }) {
+ const { data } = useQuery({
+ queryKey: ['checks', repo],
+ queryFn: () => loadChecks(repo),
+ });
+ const latest = data?.bySha.get(sha) ?? [];
+
+ return (
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/commit-checks.jsx b/packages/git-ui/src/components/molecules/commit-checks.jsx
index 21b3ed5..5626d8d 100644
--- a/packages/git-ui/src/components/molecules/commit-checks.jsx
+++ b/packages/git-ui/src/components/molecules/commit-checks.jsx
@@ -1,11 +1,21 @@
import { useQuery } from '@tanstack/react-query';
-import { CheckRun } from '#/components/molecules/check-run.jsx';
-import { loadChecks } from '#/lib/checks.js';
+import { CheckDisclosure } from '#/components/molecules/check-disclosure.jsx';
+import {
+ checkLook,
+ loadChecks,
+ summarize,
+ summaryWords,
+} from '#/lib/checks.js';
+import { cn } from '#/lib/utils.js';
/**
- * Every run the repository's runner published for one commit, newest first.
- * A commit usually has one. It has more when someone asked for the check
- * again, and each of those is a separate result worth reading.
+ * Every run the repository's runner published for one commit, newest first,
+ * one line each. A commit usually has one line per workflow. It has more when
+ * someone asked for a workflow again, and each of those is a separate result
+ * worth reading.
+ *
+ * The heading counts the workflows rather than the runs, so a commit re-run
+ * four times still reads as one workflow passing.
*/
export function CommitChecks({ repo, sha }) {
const { data } = useQuery({
@@ -15,7 +25,32 @@ export function CommitChecks({ repo, sha }) {
const runs = (data?.runs ?? []).filter((run) => run.sha === sha);
if (runs.length === 0) return null;
- return runs.map((run) => (
-
- ));
+ const latest = data?.bySha.get(sha) ?? [];
+ const summary = summarize(latest);
+ const look = checkLook(summary?.status);
+
+ return (
+
+ {summary && (
+
+
+
+ {summaryWords(summary, latest)}
+
+
+ )}
+ {runs.map((run) => (
+
+ ))}
+
+ );
}
diff --git a/packages/git-ui/src/components/molecules/dir-table.jsx b/packages/git-ui/src/components/molecules/dir-table.jsx
new file mode 100644
index 0000000..1d0beb9
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/dir-table.jsx
@@ -0,0 +1,59 @@
+import { FileIcon, FolderIcon, LinkIcon, PackageIcon } from 'lucide-react';
+import { Link } from '#/components/atoms/link.jsx';
+import { bytes } from '#/lib/format.js';
+import { cn } from '#/lib/utils.js';
+
+const ICONS = {
+ dir: FolderIcon,
+ file: FileIcon,
+ symlink: LinkIcon,
+ submodule: PackageIcon,
+};
+
+/**
+ * A directory's entries with their sizes, for the reading pane of a directory
+ * that says nothing about itself. The pane beside it navigates; this says how
+ * big each of those entries is, which the narrow pane has no room for.
+ */
+export function DirTable({ repo, refName, path, entries }) {
+ return (
+
+ {entries.map((entry) => {
+ const child = path ? `${path}/${entry.name}` : entry.name;
+ const kind = entry.type === 'dir' ? 'tree' : 'blob';
+ const href = `/${encodeURIComponent(repo)}/${kind}/${encodeURI(refName)}/${encodeURI(child)}`;
+ const Icon = ICONS[entry.type] ?? FileIcon;
+ const row = (
+ <>
+
+ {entry.name}
+
+ {entry.type === 'submodule' ? 'submodule' : bytes(entry.size)}
+
+ >
+ );
+ return (
+
+ {entry.type === 'submodule' ? (
+
+ {row}
+
+ ) : (
+
+ {row}
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/language-bar.jsx b/packages/git-ui/src/components/molecules/language-bar.jsx
index 2eb29bd..69d6eb7 100644
--- a/packages/git-ui/src/components/molecules/language-bar.jsx
+++ b/packages/git-ui/src/components/molecules/language-bar.jsx
@@ -1,56 +1,96 @@
-import { useState } from 'react';
+import { useRef, useState } from 'react';
import { percent } from '#/lib/languages.js';
+/** Ticks across the whole scale. Each one is a share of the repository. */
+const TICKS = 72;
+
/**
- * What a repository is written in: one bar of the languages by share, with
- * the one under the pointer named in a chip below it.
+ * What a repository is written in, as a row of ticks read like a gauge: each
+ * language holds the run of ticks its share earns, and the one under the
+ * pointer names itself in a chip below.
+ *
+ * A language too small for a whole tick still gets one, so a listed language
+ * is always visible. The chip is drawn over what follows rather than in the
+ * flow, so the row keeps its height and nothing moves as the pointer crosses
+ * it.
*
- * The chip is drawn over what follows rather than in the flow, so the header
- * keeps its height and nothing moves as the pointer crosses the bar. Each
- * segment names itself, which is what a screen reader reads in place of the
- * chip a pointer brings up.
+ * The row answers the pointer, not the ticks: which language is under it
+ * comes from how far across the row it is. Ticks are 2px with air between
+ * them, so asking each tick would leave the gaps answering for nobody, and
+ * crossing one run would blink the chip off and on again.
*/
export function LanguageBar({ languages }) {
const [hovered, setHovered] = useState(null);
+ const row = useRef(null);
+
+ // Hand out the scale, one language at a time, and let the last one take
+ // whatever rounding left over so the row always ends full. Each tick
+ // carries its own place in the row, which is what names it to React.
+ /** @type {{id: number, language: {name: string, color: string}, index: number}[]} */
+ const ticks = [];
+ languages.forEach((language, index) => {
+ const last = index === languages.length - 1;
+ const count = last
+ ? TICKS - ticks.length
+ : Math.max(1, Math.round((language.share / 100) * TICKS));
+ for (let tick = 0; tick < count; tick++) {
+ ticks.push({ id: ticks.length, language, index });
+ }
+ });
+
+ const words = languages
+ .map((language) => `${language.name} ${percent(language.share)}`)
+ .join(', ');
- /**
- * The middle of a segment, as a percent across the bar, which is where its
- * chip points. Near an end the chip would hang off the bar, so it stops
- * short of both.
- * @param {number} index
- */
- const centerOf = (index) => {
- let start = 0;
- for (let i = 0; i < index; i++) start += languages[i].share;
- return Math.min(90, Math.max(10, start + languages[index].share / 2));
+ /** Which language the pointer stands over, by how far across the row it is. */
+ const track = (event) => {
+ const box = row.current?.getBoundingClientRect();
+ if (!box || box.width === 0) return;
+ const across = (event.clientX - box.left) / box.width;
+ const at = Math.floor(across * TICKS);
+ setHovered(
+ ticks[Math.min(ticks.length - 1, Math.max(0, at))]?.index ?? null,
+ );
};
return (
-
-
- {languages.map((language, index) => (
-
setHovered(index)}
- onMouseLeave={() => setHovered(null)}
- />
- ))}
+
+ {/* The ticks stand a few pixels tall, which is a hard thing to keep a
+ pointer inside. The reach is taller than they are, and pulled back
+ out of the layout so nothing below it moves. */}
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: the row inside carries the reading; this only widens the reach for a pointer */}
+ setHovered(null)}
+ >
+ {/* The ticks keep their own width and spread across whatever room the
+ row has, which is what makes the scale read as an instrument
+ rather than as a filled bar. */}
+
+ {ticks.map(({ id, language, index }) => (
+
+ ))}
+
{hovered !== null && (
-
+
diff --git a/packages/git-ui/src/components/molecules/latest-commit.jsx b/packages/git-ui/src/components/molecules/latest-commit.jsx
index d553a30..818a20f 100644
--- a/packages/git-ui/src/components/molecules/latest-commit.jsx
+++ b/packages/git-ui/src/components/molecules/latest-commit.jsx
@@ -1,16 +1,17 @@
import { HistoryIcon } from 'lucide-react';
import { Link } from '#/components/atoms/link.jsx';
import { AuthorLine } from '#/components/molecules/author-line.jsx';
-import { CheckStatus } from '#/components/molecules/check-status.jsx';
+import { RefCheckStatus } from '#/components/molecules/ref-check-status.jsx';
import { timeAgo } from '#/lib/format.js';
/**
* The commit at the tip of the ref in view, above the file listing: who wrote
- * it, what it said, and how far the history goes.
+ * it, what it said, how its checks ended, and how far the history goes.
*
* The count comes from the same walk that produced the commit, so it is the
* number of commits reachable from this ref rather than a total for the
- * repository. A walk that hit its ceiling reads as "N+".
+ * repository. A walk that hit its ceiling reads as "N+". It is the only route
+ * to the history from this page.
*/
export function LatestCommit({ repo, refName, commit, count, truncated }) {
const subject = commit.message.split('\n')[0];
@@ -26,7 +27,7 @@ export function LatestCommit({ repo, refName, commit, count, truncated }) {
>
{subject}
-
+
-
+
+
{pkg.name}
@@ -49,7 +48,7 @@ export function PackageCard({ pkg }) {
-
+
{count > 0 && (
-
+ <>
setOpen(!open)}
- className="flex w-full cursor-pointer items-center gap-1.5 px-4 py-2 text-left text-[12.5px] text-muted-foreground hover:text-foreground"
+ className="flex w-full cursor-pointer items-center gap-1.5 px-5 pb-3 text-left text-[12.5px] text-muted-foreground hover:text-foreground"
>
{count} {count === 1 ? noun : `${noun}s`}
@@ -73,8 +72,8 @@ export function PackageCard({ pkg }) {
))}
)}
-
+ >
)}
-
+
);
}
diff --git a/packages/git-ui/src/components/molecules/ref-check-status.jsx b/packages/git-ui/src/components/molecules/ref-check-status.jsx
new file mode 100644
index 0000000..0e0d0a5
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/ref-check-status.jsx
@@ -0,0 +1,36 @@
+import { useQuery } from '@tanstack/react-query';
+import { CheckStatus } from '#/components/molecules/check-status.jsx';
+import { loadChecks, loadRefChecks, summarize } from '#/lib/checks.js';
+
+/**
+ * The badge for the commit at the tip of a ref. The runner keeps a record
+ * naming the latest run of each workflow for one ref, so this costs one
+ * record read whatever the history's length. The run listing behind the other
+ * badges reads a bounded number of pages, and a repository with more runs
+ * than that loses its oldest badges.
+ *
+ * Both sources are filtered to the tip commit. A ref whose newest run is
+ * against an earlier commit shows no badge, rather than reporting another
+ * commit's result beside this one's sha.
+ */
+export function RefCheckStatus({ repo, refName, sha, className }) {
+ const { data } = useQuery({
+ queryKey: ['ref-checks', repo, refName],
+ queryFn: () => loadRefChecks(repo, refName),
+ });
+ const { data: listing } = useQuery({
+ queryKey: ['checks', repo],
+ queryFn: () => loadChecks(repo),
+ });
+
+ const named = (data?.runs ?? []).filter((run) => run.sha === sha);
+ const latest = named.length > 0 ? named : (listing?.bySha.get(sha) ?? []);
+
+ return (
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/repo-gauges.jsx b/packages/git-ui/src/components/molecules/repo-gauges.jsx
new file mode 100644
index 0000000..123d8c1
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/repo-gauges.jsx
@@ -0,0 +1,59 @@
+import { useQuery } from '@tanstack/react-query';
+import { ActivityTicks } from '#/components/molecules/activity-ticks.jsx';
+import { LanguageBar } from '#/components/molecules/language-bar.jsx';
+import { ACTIVITY_WEEKS } from '#/lib/activity.js';
+import { account, isEmpty, readerFor, repoRecord } from '#/lib/git.js';
+import { languageShares, percent } from '#/lib/languages.js';
+import { useHistory } from '#/lib/source.js';
+
+/**
+ * Two readings of the repository as a whole: what it is written in, and how
+ * lately it was worked on. Both stand under the file pane, so they keep the
+ * reader's company whatever file or directory is open.
+ *
+ * Each walk reads objects the page has already downloaded, and both share
+ * their keys with the pages that want the same answers, so a gauge costs no
+ * request of its own.
+ */
+export function RepoGauges({ repo, refName }) {
+ const record = repoRecord(repo);
+ const asked = Boolean(record) && !isEmpty(record);
+
+ const { data: languages } = useQuery({
+ queryKey: ['languages', repo, refName],
+ enabled: asked,
+ queryFn: async () => {
+ const listing = await readerFor(repo).listFiles(
+ account.did,
+ repo,
+ refName,
+ );
+ return languageShares(listing?.files ?? []);
+ },
+ });
+
+ const { data: history } = useHistory(repo, refName);
+
+ if (!languages?.length && !history?.dates?.length) return null;
+
+ return (
+
+ {languages && languages.length > 0 && (
+
+
+
+ {languages[0].name} {percent(languages[0].share)}
+
+
+ )}
+ {history?.dates && history.dates.length > 0 && (
+
+
+
+ {ACTIVITY_WEEKS} weeks
+
+
+ )}
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/repo-list-skeleton.jsx b/packages/git-ui/src/components/molecules/repo-list-skeleton.jsx
new file mode 100644
index 0000000..21bd039
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/repo-list-skeleton.jsx
@@ -0,0 +1,27 @@
+import { Skeleton } from '#/components/atoms/skeleton.jsx';
+
+/** One cell's shape: a name line, a description, a line of readings. */
+function RepoCellSkeleton() {
+ return (
+
+
+
+
+
+ );
+}
+
+/**
+ * The repository grid, before any records arrive. Only a first visit sees it;
+ * a revisit paints the cached rows instead.
+ */
+export function RepoListSkeleton() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/page-skeleton.jsx b/packages/git-ui/src/components/molecules/repo-page-skeleton.jsx
similarity index 54%
rename from packages/git-ui/src/components/molecules/page-skeleton.jsx
rename to packages/git-ui/src/components/molecules/repo-page-skeleton.jsx
index 9505f92..0f68b9b 100644
--- a/packages/git-ui/src/components/molecules/page-skeleton.jsx
+++ b/packages/git-ui/src/components/molecules/repo-page-skeleton.jsx
@@ -1,46 +1,16 @@
-import { Card } from '#/components/atoms/card.jsx';
import { Skeleton } from '#/components/atoms/skeleton.jsx';
-/** One repository card's shape: a name line, a description, a fact line. */
-function RepoCardSkeleton() {
- return (
-
-
-
-
-
- );
-}
-
/**
- * The repository list, before any records arrive. Only a first visit sees
- * it; a revisit paints the cached rows instead.
- */
-export function RepoListSkeleton() {
- return (
-
-
-
-
-
- );
-}
-
-/**
- * A repository screen, shaped like the tree page it stands in for:
- * breadcrumb line, toolbar, a file table, a README block. Shown while
- * discovery still names the account, so the real page has nothing to say
- * yet.
+ * A repository screen, shaped like the tree page it stands in for: the ref
+ * and clone controls, a file table, a README block. Shown while discovery
+ * still names the account, so the real page has nothing to say yet.
*/
export function RepoPageSkeleton() {
return (
<>
-
-
-
-
+
-
+
diff --git a/packages/git-ui/src/components/molecules/repo-tabs.jsx b/packages/git-ui/src/components/molecules/repo-tabs.jsx
new file mode 100644
index 0000000..d0be777
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/repo-tabs.jsx
@@ -0,0 +1,91 @@
+import { linkedPackagesOf, runnerOf } from '@pdsjs/git/rules';
+import { useQuery } from '@tanstack/react-query';
+import {
+ CircleCheckIcon,
+ CodeIcon,
+ GitCommitVerticalIcon,
+ PackageIcon,
+} from 'lucide-react';
+import { Link } from '#/components/atoms/link.jsx';
+import { repoConfig } from '#/lib/git.js';
+import { cn } from '#/lib/utils.js';
+
+/** What each section is called, for the tabs and for the heading above them. */
+export const SECTION_TITLES = {
+ code: 'Code',
+ commits: 'Commits',
+ checks: 'Checks',
+ packages: 'Packages',
+};
+
+const ICONS = {
+ code: CodeIcon,
+ commits: GitCommitVerticalIcon,
+ checks: CircleCheckIcon,
+ packages: PackageIcon,
+};
+
+/**
+ * The sections of one repository, on a band of their own between the heading
+ * and whatever the open section puts above its list. A page that reached a
+ * dead end before, a run or a package or a file, carries the way back to
+ * everything else.
+ *
+ * Which sections exist comes from the repository's config record, one cheap
+ * read that is already in hand from elsewhere in the app. Reading the runner's
+ * check listing would answer the same question and cost a listing walk, which
+ * a reader looking at a package should not pay for.
+ */
+export function RepoTabs({ repo, active }) {
+ const { data: config } = useQuery({
+ queryKey: ['config', repo],
+ queryFn: () => repoConfig(repo),
+ staleTime: Number.POSITIVE_INFINITY,
+ });
+ const packages = linkedPackagesOf(config);
+ const base = `/${encodeURIComponent(repo)}`;
+
+ const tabs = [
+ { key: 'code', href: base },
+ { key: 'commits', href: `${base}/commits` },
+ ...(runnerOf(config) ? [{ key: 'checks', href: `${base}/checks` }] : []),
+ ...(packages.length > 0
+ ? [{ key: 'packages', href: `${base}/packages`, count: packages.length }]
+ : []),
+ ];
+
+ return (
+
+ {tabs.map((tab) => {
+ const Icon = ICONS[tab.key];
+ const open = tab.key === active;
+ return (
+
+
+ {SECTION_TITLES[tab.key]}
+ {typeof tab.count === 'number' && (
+
+ {tab.count}
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/source-panes.jsx b/packages/git-ui/src/components/molecules/source-panes.jsx
new file mode 100644
index 0000000..2e97bf8
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/source-panes.jsx
@@ -0,0 +1,70 @@
+import { CornerLeftUpIcon } from 'lucide-react';
+import { Link } from '#/components/atoms/link.jsx';
+import { Skeleton } from '#/components/atoms/skeleton.jsx';
+import { RepoGauges } from '#/components/molecules/repo-gauges.jsx';
+import { TreeList } from '#/components/molecules/tree-list.jsx';
+import { parentOf, useDirectory } from '#/lib/source.js';
+
+/**
+ * The source view: one directory down the left, whatever the reader opened
+ * down the right. Opening a file keeps its neighbours in view, which is what
+ * makes reading a repository feel like reading a checkout.
+ *
+ * `dirPath` is the directory the pane lists. A directory's own page lists
+ * itself; a file's page lists the directory holding it, with that file marked.
+ * The pane keeps its own scroll, so a long listing and a long file each move
+ * on their own.
+ */
+export function SourcePanes({
+ repo,
+ refName,
+ dirPath,
+ selected = '',
+ children,
+}) {
+ const { data: entries } = useDirectory(repo, refName, dirPath);
+ const up = dirPath
+ ? `/${encodeURIComponent(repo)}/tree/${encodeURI(refName)}${
+ parentOf(dirPath) ? `/${encodeURI(parentOf(dirPath))}` : ''
+ }`
+ : '';
+
+ return (
+ // The panes fill what the window has left and each takes its own scroll,
+ // so a long listing and a long file move past each other.
+
+
+
+ {up && (
+
+
+
{dirPath}
+
+ )}
+ {entries ? (
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+
+
+
+
{children}
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/tree-list.jsx b/packages/git-ui/src/components/molecules/tree-list.jsx
index bcef649..97f9ae5 100644
--- a/packages/git-ui/src/components/molecules/tree-list.jsx
+++ b/packages/git-ui/src/components/molecules/tree-list.jsx
@@ -1,6 +1,5 @@
import { FileIcon, FolderIcon, LinkIcon, PackageIcon } from 'lucide-react';
import { Link } from '#/components/atoms/link.jsx';
-import { bytes } from '#/lib/format.js';
import { cn } from '#/lib/utils.js';
const ICONS = {
@@ -10,15 +9,21 @@ const ICONS = {
submodule: PackageIcon,
};
-/** One directory's entries, directories first, then files, both by name. */
-export function TreeList({ repo, refName, path, entries }) {
+/**
+ * One directory's entries, directories first, then files, both by name.
+ *
+ * `selected` is the path of the file open beside this listing, which reads as
+ * the row a reader is standing on.
+ */
+export function TreeList({ repo, refName, path, entries, selected = '' }) {
return (
-
+
{entries.map((entry) => {
const child = path ? `${path}/${entry.name}` : entry.name;
const kind = entry.type === 'dir' ? 'tree' : 'blob';
const href = `/${encodeURIComponent(repo)}/${kind}/${encodeURI(refName)}/${encodeURI(child)}`;
const Icon = ICONS[entry.type] ?? FileIcon;
+ const open = child === selected;
const row = (
<>
- {entry.name}
-
- {entry.type === 'submodule' ? 'submodule' : bytes(entry.size)}
+
+ {entry.name}
>
);
return (
-
+
{entry.type === 'submodule' ? (
-
+
{row}
) : (
{row}
diff --git a/packages/git-ui/src/lib/activity.js b/packages/git-ui/src/lib/activity.js
new file mode 100644
index 0000000..2463d96
--- /dev/null
+++ b/packages/git-ui/src/lib/activity.js
@@ -0,0 +1,8 @@
+/** How far back the commit-activity gauge reads, and the bucket it counts in. */
+
+export const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
+
+/** Weeks the gauge covers, newest at the right. */
+export const ACTIVITY_WEEKS = 24;
+
+export const ACTIVITY_WINDOW_MS = ACTIVITY_WEEKS * WEEK_MS;
diff --git a/packages/git-ui/src/lib/checks.js b/packages/git-ui/src/lib/checks.js
index d909381..560da04 100644
--- a/packages/git-ui/src/lib/checks.js
+++ b/packages/git-ui/src/lib/checks.js
@@ -13,10 +13,18 @@
// badge, and none of them is an error worth showing.
import { runnerOf } from '@pdsjs/git/rules';
+import { latestCheckKey } from '@pdsjs/git-ci/spec';
import { CheckIcon, CircleDotIcon, XIcon } from 'lucide-react';
-import { account, didDocumentUrl, repoConfig } from './git.js';
+import {
+ account,
+ didDocumentUrl,
+ repoConfig,
+ repoRecord,
+ shortRef,
+} from './git.js';
const CHECK_COLLECTION = 'dev.pdsjs.git.check';
+const LATEST_CHECK_COLLECTION = 'dev.pdsjs.git.latestCheck';
const REPO_COLLECTION = 'dev.pdsjs.git.repo';
/**
@@ -38,6 +46,7 @@ const MAX_PAGES = 3;
* @property {string} sha
* @property {string} status
* @property {string} startedAt
+ * @property {string} [rkey] - the record key, which addresses the run page
* @property {string} [ref]
* @property {string} [workflow]
* @property {string} [finishedAt]
@@ -45,6 +54,14 @@ const MAX_PAGES = 3;
* @property {{ref?: {$link?: string}}} [logs]
*/
+/**
+ * How a whole commit reads: one status for the badge, and the count behind it.
+ * @typedef {Object} CheckSummary
+ * @property {string} status - the status the badge shows
+ * @property {number} passed - workflows that succeeded
+ * @property {number} total - workflows that reported
+ */
+
/** How each check status reads, and the colour it reads in. */
export const CHECK_LOOKS = {
success: { label: 'passed', className: 'text-success', Icon: CheckIcon },
@@ -65,6 +82,14 @@ export const CHECK_LOOKS = {
export const checkLook = (status) =>
CHECK_LOOKS[/** @type {keyof CHECK_LOOKS} */ (status)] ?? CHECK_LOOKS.running;
+/**
+ * What a run's workflow is called. The field is optional in the lexicon, and a
+ * runner that publishes one workflow may leave it out.
+ * @param {{workflow?: string}} check
+ * @returns {string}
+ */
+export const workflowOf = (check) => check.workflow || 'ci';
+
/**
* An elapsed time in the largest units that stay readable.
* @param {number} ms
@@ -97,7 +122,7 @@ export function runDuration(check) {
* @property {string} service - the runner's PDS origin
*/
-/** @typedef {{runner: CheckRunner|null, runs: CheckRun[], bySha: Map}} RepoChecks */
+/** @typedef {{runner: CheckRunner|null, runs: CheckRun[], bySha: Map}} RepoChecks */
/**
* This repository's runs, newest first, from the runner's raw listing. The
@@ -112,35 +137,119 @@ export function runsFor(records, subjectUri) {
/** @type {CheckRun[]} */
const runs = [];
for (const entry of records) {
- const value = /** @type {{value?: unknown}|null} */ (entry)?.value;
+ const row = /** @type {{value?: unknown, uri?: unknown}|null} */ (entry);
const check = /** @type {CheckRun & {subject?: {uri?: unknown}}} */ (
- value ?? {}
+ row?.value ?? {}
);
if (check.subject?.uri !== subjectUri) continue;
if (typeof check.sha !== 'string' || typeof check.startedAt !== 'string') {
continue;
}
- runs.push(check);
+ const rkey = String(row?.uri ?? '')
+ .split('/')
+ .pop();
+ runs.push(rkey ? { ...check, rkey } : check);
}
return runs.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
}
/**
- * The newest run per commit, which is what a one-icon badge shows. A commit
- * checked more than once, because someone asked for a re-run, keeps its most
- * recent result here.
+ * The newest run of each workflow. A workflow asked for again reports twice,
+ * and only the most recent of those says where that workflow stands.
* @param {CheckRun[]} runs - newest first
- * @returns {Map}
+ * @returns {CheckRun[]}
+ */
+export function latestPerWorkflow(runs) {
+ /** @type {CheckRun[]} */
+ const latest = [];
+ for (const run of runs) {
+ if (latest.some((seen) => workflowOf(seen) === workflowOf(run))) continue;
+ latest.push(run);
+ }
+ return latest;
+}
+
+/**
+ * The runs of each commit, newest commit first. A commit's group holds every
+ * run against it, re-runs included, in the order the listing gave them.
+ * @param {CheckRun[]} runs - newest first
+ * @returns {{sha: string, runs: CheckRun[]}[]}
+ */
+export function groupBySha(runs) {
+ /** @type {Map} */
+ const groups = new Map();
+ for (const run of runs) {
+ const group = groups.get(run.sha);
+ if (group) group.runs.push(run);
+ else groups.set(run.sha, { sha: run.sha, runs: [run] });
+ }
+ return [...groups.values()];
+}
+
+/**
+ * The newest run of each workflow, per commit. One commit may be checked by
+ * several workflows, and each of them reports separately.
+ * @param {CheckRun[]} runs - newest first
+ * @returns {Map}
*/
export function latestBySha(runs) {
- /** @type {Map} */
+ /** @type {Map} */
const bySha = new Map();
- for (const run of runs) {
- if (!bySha.has(run.sha)) bySha.set(run.sha, run);
+ for (const group of groupBySha(runs)) {
+ bySha.set(group.sha, latestPerWorkflow(group.runs));
}
return bySha;
}
+/**
+ * One status for a set of runs. A single failure decides the whole set: a
+ * commit whose test workflow failed is a failed commit, whatever its other
+ * workflows say. An unfinished run reads as running, and only a set where
+ * every workflow succeeded reads as passed.
+ * @param {CheckRun[]} runs - the latest run of each workflow
+ * @returns {CheckSummary|null} null for a commit no workflow reported on
+ */
+export function summarize(runs) {
+ if (runs.length === 0) return null;
+ const passed = runs.filter((run) => run.status === 'success').length;
+ const failed = runs.some(
+ (run) => run.status === 'failure' || run.status === 'error',
+ );
+ const status = failed
+ ? (runs.find((run) => run.status === 'error')?.status ?? 'failure')
+ : passed === runs.length
+ ? 'success'
+ : 'running';
+ return { status, passed, total: runs.length };
+}
+
+/**
+ * How a summary reads in words, for the badge's title and the commit page's
+ * heading. A single workflow names itself; several are counted.
+ * @param {CheckSummary} summary
+ * @param {CheckRun[]} runs - the runs the summary counts
+ * @returns {string}
+ */
+export function summaryWords(summary, runs) {
+ if (runs.length === 1) {
+ return `${workflowOf(runs[0])} ${checkLook(summary.status).label}`;
+ }
+ if (summary.status === 'running') {
+ return `${summary.passed} of ${summary.total} workflows passed, the rest running`;
+ }
+ return `${summary.passed} of ${summary.total} workflows passed`;
+}
+
+/**
+ * Every workflow that reported, in the order a reader meets them, for the
+ * filter above a run listing.
+ * @param {CheckRun[]} runs
+ * @returns {string[]}
+ */
+export function workflowNames(runs) {
+ return [...new Set(runs.map(workflowOf))];
+}
+
/**
* Where a finished check's log reads from: the blob on the runner's PDS.
* @param {CheckRunner|null|undefined} runner
@@ -195,6 +304,98 @@ function pdsEndpoint(doc) {
).replace(/\/$/, '');
}
+/**
+ * One runner lookup per repository, kept once it is read. Two screens want it,
+ * the run listing and the ref badge, and it changes only when the repository's
+ * owner names another runner.
+ * @type {Map>}
+ */
+const runners = new Map();
+
+/**
+ * The runner a repository names, and the PDS that answers for it. Null where
+ * the repository names no runner, or the runner's DID does not resolve.
+ * @param {string} repo - repository name, the record rkey
+ * @returns {Promise}
+ */
+export function repoRunner(repo) {
+ let pending = runners.get(repo);
+ if (!pending) {
+ pending = (async () => {
+ try {
+ const did = runnerOf(await repoConfig(repo));
+ if (!did) return null;
+ const doc = await (await fetch(didDocumentUrl(did))).json();
+ const service = pdsEndpoint(doc);
+ return service ? { did, service } : null;
+ } catch {
+ return null;
+ }
+ })();
+ runners.set(repo, pending);
+ }
+ return pending;
+}
+
+/**
+ * The full name of a ref the page names in short form. The repository record
+ * holds both, so this needs no round trip. A name that matches no ref reads as
+ * a branch, which is what a URL naming a deleted branch means.
+ * @param {string} repo
+ * @param {string} refName - short form, e.g. main
+ * @returns {string}
+ */
+export function fullRefName(repo, refName) {
+ const refs = /** @type {{refs?: {name: string}[]}|null} */ (repoRecord(repo))
+ ?.refs;
+ const found = (refs ?? []).find((ref) => shortRef(ref.name) === refName);
+ return found?.name ?? `refs/heads/${refName}`;
+}
+
+/**
+ * The latest run of each workflow for one ref, from the record the runner
+ * keeps for exactly this question. It costs one getRecord, where the listing
+ * below costs a page walk, and it stays right for a repository whose history
+ * runs past that walk's ceiling.
+ *
+ * The entries carry no steps and no log. They name the check record that
+ * holds both, which is what the rkey addresses.
+ * @param {string} repo - repository name, the record rkey
+ * @param {string} refName - short form, e.g. main
+ * @returns {Promise<{runner: CheckRunner|null, runs: CheckRun[]}>}
+ */
+export async function loadRefChecks(repo, refName) {
+ const runner = await repoRunner(repo);
+ if (!runner) return { runner: null, runs: [] };
+ try {
+ const params = new URLSearchParams({
+ repo: runner.did,
+ collection: LATEST_CHECK_COLLECTION,
+ rkey: latestCheckKey(account.did, repo, fullRefName(repo, refName)),
+ });
+ const res = await fetch(
+ `${runner.service}/xrpc/com.atproto.repo.getRecord?${params}`,
+ );
+ if (!res.ok) return { runner, runs: [] };
+ const value = /** @type {{value?: {checks?: unknown[]}}} */ (
+ await res.json()
+ ).value;
+ /** @type {CheckRun[]} */
+ const runs = [];
+ for (const entry of value?.checks ?? []) {
+ const check = /** @type {CheckRun & {check?: {uri?: unknown}}} */ (entry);
+ if (typeof check.sha !== 'string') continue;
+ const rkey = String(check.check?.uri ?? '')
+ .split('/')
+ .pop();
+ runs.push(rkey ? { ...check, rkey } : check);
+ }
+ return { runner, runs };
+ } catch {
+ return { runner, runs: [] };
+ }
+}
+
/**
* @param {string} repo - repository name, the record rkey
* @returns {Promise}
@@ -203,12 +404,9 @@ export async function loadChecks(repo) {
/** @type {RepoChecks} */
const none = { runner: null, runs: [], bySha: new Map() };
try {
- const did = runnerOf(await repoConfig(repo));
- if (!did) return none;
-
- const doc = await (await fetch(didDocumentUrl(did))).json();
- const service = pdsEndpoint(doc);
- if (!service) return none;
+ const runner = await repoRunner(repo);
+ if (!runner) return none;
+ const { did, service } = runner;
/** @type {unknown[]} */
const records = [];
@@ -234,7 +432,7 @@ export async function loadChecks(repo) {
const subjectUri = `at://${account.did}/${REPO_COLLECTION}/${repo}`;
const runs = runsFor(records, subjectUri);
- return { runner: { did, service }, runs, bySha: latestBySha(runs) };
+ return { runner, runs, bySha: latestBySha(runs) };
} catch {
return none;
}
diff --git a/packages/git-ui/src/lib/git.js b/packages/git-ui/src/lib/git.js
index 6e23391..2af6943 100644
--- a/packages/git-ui/src/lib/git.js
+++ b/packages/git-ui/src/lib/git.js
@@ -395,6 +395,45 @@ export function repoConfig(name) {
return pending;
}
+/**
+ * @typedef {Object} CommitLabel
+ * @property {string} subject - the message's first line
+ * @property {string} author - the commit's author ident
+ */
+
+/**
+ * What to call each commit reachable from the named refs, by sha. A run names
+ * the commit it checked by sha alone, and a sha names nothing to a reader.
+ *
+ * This opens the repository, which the checks page otherwise never needs. The
+ * caller runs it behind the paint and labels its rows when it answers.
+ * @param {string} name - repository name, the record rkey
+ * @param {string[]} refs - full ref names, e.g. refs/heads/main
+ * @param {number} [limit] - commits to walk per ref
+ * @returns {Promise>}
+ */
+export async function commitLabels(name, refs, limit = 100) {
+ const reader = readerFor(name);
+ /** @type {Map} */
+ const labels = new Map();
+ for (const ref of refs) {
+ try {
+ const listing = await reader.listCommits(account.did, name, ref, limit);
+ for (const commit of listing?.commits ?? []) {
+ if (labels.has(commit.sha)) continue;
+ labels.set(commit.sha, {
+ subject: String(commit.message).split('\n')[0],
+ author: commit.author,
+ });
+ }
+ } catch {
+ // A ref the repository no longer holds labels nothing, and the rows
+ // that wanted it read as their sha.
+ }
+ }
+ return labels;
+}
+
/**
* How much of the repository being opened has arrived. One repository opens
* at a time, so a single value carries it.
diff --git a/packages/git-ui/src/lib/source.js b/packages/git-ui/src/lib/source.js
new file mode 100644
index 0000000..632221d
--- /dev/null
+++ b/packages/git-ui/src/lib/source.js
@@ -0,0 +1,88 @@
+// The directory behind the source view's left pane.
+//
+// Both halves of the view want the same listing: the pane lists it, and a
+// directory's own page reads it again to find the README. One query key
+// serves both, so opening a file beside a listing costs no second walk.
+
+import { useQuery } from '@tanstack/react-query';
+import { ACTIVITY_WINDOW_MS } from './activity.js';
+import { account, isEmpty, readerFor, repoRecord } from './git.js';
+
+/** Deep enough to count and date every commit a repository of this shape holds. */
+const COUNT_LIMIT = 50000;
+
+/**
+ * The directory holding a path. A path with no slash is at the root, whose
+ * parent is the root itself.
+ * @param {string} path
+ * @returns {string}
+ */
+export function parentOf(path) {
+ const cut = path.lastIndexOf('/');
+ return cut === -1 ? '' : path.slice(0, cut);
+}
+
+/**
+ * One directory's entries at one ref. An empty repository has none, and asks
+ * for nothing.
+ * @param {string} repo
+ * @param {string} refName
+ * @param {string} path - the directory, '' for the root
+ */
+export function useDirectory(repo, refName, path) {
+ const record = repoRecord(repo);
+ return useQuery({
+ queryKey: ['dir', repo, refName, path],
+ enabled: Boolean(record) && !isEmpty(record),
+ queryFn: async () => {
+ const listing = await readerFor(repo).listTree(
+ account.did,
+ repo,
+ refName,
+ path,
+ );
+ return listing?.entries ?? [];
+ },
+ });
+}
+
+/**
+ * The tip of a ref, how far its history runs, and when its recent commits
+ * were made. The walk reads every commit, which on an indexed chain is a
+ * download of its own, so a caller paints first and fills this in.
+ *
+ * Three readers want the same answer: the latest-commit line, the commit
+ * count beside it, and the activity gauge. One key serves them all.
+ * @param {string} repo
+ * @param {string} refName
+ */
+export function useHistory(repo, refName) {
+ const record = repoRecord(repo);
+ return useQuery({
+ queryKey: ['history', repo, refName],
+ enabled: Boolean(record) && !isEmpty(record),
+ queryFn: async () => {
+ const log = await readerFor(repo).listCommits(
+ account.did,
+ repo,
+ refName,
+ COUNT_LIMIT,
+ );
+ // The gauge reads dates alone, and only the recent ones. The walk may
+ // hold tens of thousands of commits; keeping the whole of it in query
+ // state to draw a line of ticks would be waste. A commit dates itself
+ // in epoch milliseconds, which is what the gauge buckets.
+ const floor = Date.now() - ACTIVITY_WINDOW_MS;
+ const dates = [];
+ for (const commit of log?.commits ?? []) {
+ if (commit.committedAt >= floor) dates.push(commit.committedAt);
+ }
+ return {
+ head: log?.commits[0] ?? null,
+ count: log?.commits.length ?? 0,
+ truncated: Boolean(log?.truncated),
+ dates,
+ };
+ },
+ });
+}
diff --git a/packages/git-ui/src/pages/check.jsx b/packages/git-ui/src/pages/check.jsx
new file mode 100644
index 0000000..6e37abd
--- /dev/null
+++ b/packages/git-ui/src/pages/check.jsx
@@ -0,0 +1,48 @@
+import { useQuery } from '@tanstack/react-query';
+import { Skeleton } from '#/components/atoms/skeleton.jsx';
+import { CheckRun } from '#/components/molecules/check-run.jsx';
+import { EmptyState } from '#/components/molecules/empty-state.jsx';
+import { loadChecks } from '#/lib/checks.js';
+import { repoRecord } from '#/lib/git.js';
+
+/**
+ * One run, addressed by the record key the runner wrote it under. The listing
+ * this reads from is the one the checks page already loaded, so arriving from
+ * that page costs no request; arriving from a shared link loads it.
+ */
+export function CheckPage({ repo, rkey }) {
+ const record = repoRecord(repo);
+ const { data } = useQuery({
+ queryKey: ['checks', repo],
+ queryFn: () => loadChecks(repo),
+ });
+
+ if (!record) {
+ return No such repository.
;
+ }
+
+ const check = data?.runs.find((run) => run.rkey === rkey);
+
+ return (
+ <>
+ {!data ? (
+
+
+
+ ) : !check ? (
+
+
+
+ ) : (
+
+ )}
+ >
+ );
+}
diff --git a/packages/git-ui/src/pages/checks.jsx b/packages/git-ui/src/pages/checks.jsx
index 7c39470..4b7c629 100644
--- a/packages/git-ui/src/pages/checks.jsx
+++ b/packages/git-ui/src/pages/checks.jsx
@@ -1,56 +1,99 @@
import { useQuery } from '@tanstack/react-query';
+import { useState } from 'react';
import { Skeleton } from '#/components/atoms/skeleton.jsx';
-import { Breadcrumbs } from '#/components/molecules/breadcrumbs.jsx';
-import { CheckRun } from '#/components/molecules/check-run.jsx';
+import { CheckFilter } from '#/components/molecules/check-filter.jsx';
+import { CheckGroup } from '#/components/molecules/check-group.jsx';
import { EmptyState } from '#/components/molecules/empty-state.jsx';
-import { loadChecks } from '#/lib/checks.js';
-import { defaultRef, repoRecord } from '#/lib/git.js';
+import {
+ groupBySha,
+ loadChecks,
+ workflowNames,
+ workflowOf,
+} from '#/lib/checks.js';
+import { commitLabels, repoRecord } from '#/lib/git.js';
/**
- * Every run the repository's runner published, newest first. One commit may
- * appear more than once: a re-run is its own record, and both results stay
- * readable.
+ * Every run the repository's runner published, newest first, grouped by the
+ * commit it ran against. One commit's group holds a line per workflow, and a
+ * second line for a workflow someone asked for again.
*/
export function ChecksPage({ repo }) {
const record = repoRecord(repo);
+ const [workflow, setWorkflow] = useState('');
const { data } = useQuery({
queryKey: ['checks', repo],
queryFn: () => loadChecks(repo),
});
+ // What each commit is called. This opens the repository, which costs the
+ // bundle download the run listing itself never needs, so it runs behind the
+ // paint: the groups render under their shas and take their subjects here.
+ const refs = [
+ ...new Set((data?.runs ?? []).map((run) => run.ref).filter(Boolean)),
+ ];
+ const { data: labels } = useQuery({
+ queryKey: ['commit-labels', repo, refs],
+ enabled: refs.length > 0,
+ queryFn: () => commitLabels(repo, /** @type {string[]} */ (refs)),
+ staleTime: Number.POSITIVE_INFINITY,
+ });
+
if (!record) {
return No such repository.
;
}
+ const all = data?.runs ?? [];
+ const runs = all.filter((run) => !workflow || workflowOf(run) === workflow);
+ const counts = {};
+ for (const run of all) {
+ counts[workflowOf(run)] = (counts[workflowOf(run)] ?? 0) + 1;
+ }
+
+ if (!data) {
+ return (
+
+
+
+ );
+ }
+
+ if (all.length === 0) {
+ return (
+
+
+
+ );
+ }
+
return (
- <>
-
-
+
+
+
- {!data ? (
-
- ) : data.runs.length === 0 ? (
-
-
-
- ) : (
- data.runs.map((run) => (
-
+ {groupBySha(runs).map((group) => (
+
- ))
- )}
- >
+ ))}
+
+
);
}
diff --git a/packages/git-ui/src/pages/commit.jsx b/packages/git-ui/src/pages/commit.jsx
index db62b27..3771fe7 100644
--- a/packages/git-ui/src/pages/commit.jsx
+++ b/packages/git-ui/src/pages/commit.jsx
@@ -1,23 +1,11 @@
import { useQuery } from '@tanstack/react-query';
-import { GitCommitVerticalIcon } from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
-import { buttonVariants } from '#/components/atoms/button.jsx';
-import { Card } from '#/components/atoms/card.jsx';
-import { Link } from '#/components/atoms/link.jsx';
import { AuthorLine } from '#/components/molecules/author-line.jsx';
-import { Breadcrumbs } from '#/components/molecules/breadcrumbs.jsx';
import { CommitChecks } from '#/components/molecules/commit-checks.jsx';
import { DiffFile } from '#/components/molecules/diff-file.jsx';
import { DiffTree } from '#/components/molecules/diff-tree.jsx';
import { ReadingRepo } from '#/components/molecules/reading-repo.jsx';
-import {
- account,
- defaultRef,
- readerFor,
- repoRecord,
- repoSize,
-} from '#/lib/git.js';
-import { cn } from '#/lib/utils.js';
+import { account, readerFor, repoRecord, repoSize } from '#/lib/git.js';
export function CommitPage({ repo, sha }) {
const record = repoRecord(repo);
@@ -46,80 +34,64 @@ export function CommitPage({ repo, sha }) {
const [subject, ...rest] = (data?.message ?? '').split('\n');
const body = rest.join('\n').trim();
- return (
- <>
-
-
-
-
- History
-
+ if (error) {
+ return
{error.message}
;
+ }
+ if (!data) {
+ return (
+
+
+ );
+ }
- {error &&
{error.message}
}
- {!data && !error && (
-
-
+ return (
+
+ {/* What the commit says, above the panes and holding its place while
+ the diffs move. */}
+
+
{subject}
+ {body && (
+
+ {body}
+
+ )}
+
+
+
{new Date(data.committedAt).toLocaleString()}
+
{data.sha}
+
+ {data.files.length} {data.files.length === 1 ? 'file' : 'files'}{' '}
+ changed
+
+ {data.parents.length > 1 && (
+
merge, shown against the first parent
+ )}
- )}
+
- {data && (
- <>
-
- {subject}
- {body && (
-
- {body}
-
- )}
-
-
-
{new Date(data.committedAt).toLocaleString()}
-
{data.sha}
-
-
-
- {data.files.length} {data.files.length === 1 ? 'file' : 'files'}{' '}
- changed
-
- {data.parents.length > 1 && (
- merge, shown against the first parent
- )}
-
-
+
+ {/* The tree stays put while the diffs move under it, and steps aside
+ on a narrow screen where there is no room for two columns. */}
+ {data.files.length > 1 && (
+
+ )}
+
- {data.files.length > 1 ? (
-
- {/* The tree stays put while the diffs scroll under it, and
- steps aside on a narrow screen where there is no room for
- two columns. */}
-
-
- {data.files.map((file) => (
- {
- if (node) sections.current.set(file.path, node);
- else sections.current.delete(file.path);
- }}
- />
- ))}
-
-
- ) : (
- data.files.map((file) =>
)
- )}
- >
- )}
- >
+ {data.files.map((file) => (
+
{
+ if (node) sections.current.set(file.path, node);
+ else sections.current.delete(file.path);
+ }}
+ />
+ ))}
+
+
+
);
}
diff --git a/packages/git-ui/src/pages/commits.jsx b/packages/git-ui/src/pages/commits.jsx
index a4a5022..df46518 100644
--- a/packages/git-ui/src/pages/commits.jsx
+++ b/packages/git-ui/src/pages/commits.jsx
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query';
+import { GitCommitVerticalIcon } from 'lucide-react';
import { Link } from '#/components/atoms/link.jsx';
import { AuthorLine } from '#/components/molecules/author-line.jsx';
-import { Breadcrumbs } from '#/components/molecules/breadcrumbs.jsx';
-import { CheckStatus } from '#/components/molecules/check-status.jsx';
+import { CommitCheckStatus } from '#/components/molecules/commit-check-status.jsx';
import { ReadingRepo } from '#/components/molecules/reading-repo.jsx';
import { RefSelect } from '#/components/molecules/ref-select.jsx';
import { timeAgo } from '#/lib/format.js';
@@ -27,9 +27,8 @@ export function CommitsPage({ repo, refName }) {
}
return (
- <>
-
-
+
+
- {error &&
{error.message}
}
- {!data && !error && (
-
-
-
- )}
- {data && (
-
- {data.commits.map((commit) => (
-
-
-
-
- {commit.message.split('\n')[0]}
+
+
+ {error && (
+
{error.message}
+ )}
+ {!data && !error && (
+
+
+
+ )}
+ {data && (
+
+ {data.commits.map((commit) => (
+
+
+
+
+
+ {commit.message.split('\n')[0]}
+
+
+
+
+ {commit.sha.slice(0, 8)}
+
+ {timeAgo(commit.committedAt)}
+
-
-
-
-
- {commit.sha.slice(0, 8)}
-
-
- {timeAgo(commit.committedAt)}
-
-
-
- ))}
-
- )}
- {data?.truncated && (
-
- Showing the newest {LIMIT} commits.
-
- )}
- >
+
+
+
+ ))}
+
+ )}
+ {data?.truncated && (
+
+ Showing the newest {LIMIT} commits.
+
+ )}
+
+
);
}
diff --git a/packages/git-ui/src/pages/file.jsx b/packages/git-ui/src/pages/file.jsx
index 61bf67c..7770c35 100644
--- a/packages/git-ui/src/pages/file.jsx
+++ b/packages/git-ui/src/pages/file.jsx
@@ -5,6 +5,8 @@ import { buttonVariants } from '#/components/atoms/button.jsx';
import { Card } from '#/components/atoms/card.jsx';
import { Breadcrumbs } from '#/components/molecules/breadcrumbs.jsx';
import { ReadingRepo } from '#/components/molecules/reading-repo.jsx';
+import { RefSelect } from '#/components/molecules/ref-select.jsx';
+import { SourcePanes } from '#/components/molecules/source-panes.jsx';
import { bytes } from '#/lib/format.js';
import {
account,
@@ -15,6 +17,8 @@ import {
repoSize,
} from '#/lib/git.js';
import { highlightBlock, languageFor } from '#/lib/highlight.js';
+import { useNavigate } from '#/lib/navigation.jsx';
+import { parentOf } from '#/lib/source.js';
import { cn } from '#/lib/utils.js';
/** The bytes of an image, as an object URL that lives as long as the view. */
@@ -32,6 +36,7 @@ function useObjectUrl(image) {
}
export function FilePage({ repo, refName, path }) {
+ const navigate = useNavigate();
const record = repoRecord(repo);
const { data, error } = useQuery({
@@ -55,35 +60,54 @@ export function FilePage({ repo, refName, path }) {
return (
<>
-
-
- {capabilities.rawFiles && (
-
-
- Raw
-
- )}
-
- {error &&
{error.message}
}
- {!data && !error && (
-
-
+
+
+
+ navigate(
+ `/${encodeURIComponent(repo)}/blob/${encodeURI(next)}/${encodeURI(path)}`,
+ )
+ }
+ />
+
- )}
- {data?.file && (
- <>
-
-
{bytes(data.file.size)}
- {data.file.mode === '120000' &&
symlink }
+
+ {bytes(data?.file?.size)}
+ {data?.file?.mode === '120000' && symlink }
+ {capabilities.rawFiles && (
+
+
+ Raw
+
+ )}
+
+
+
+
+ {error && (
+ {error.message}
+ )}
+ {!data && !error && (
+
+
- {data.image ? (
-
+ )}
+ {data?.file &&
+ (data.image ? (
+
{imageUrl && (
) : data.file.text !== null ? (
-
+
{data.file.text
.replace(/\n$/, '')
@@ -108,7 +132,7 @@ export function FilePage({ repo, refName, path }) {
{/* highlight.js escapes the source it is given, so nothing
in the file can reach the page as markup. */}
-
+
) : (
-
+
{data.file.binary
? `Binary file, ${bytes(data.file.size)}.`
: `File is ${bytes(data.file.size)}, too large to show.`}
- )}
- >
- )}
+ ))}
+
>
);
}
diff --git a/packages/git-ui/src/pages/packages.jsx b/packages/git-ui/src/pages/packages.jsx
index 2367313..4475004 100644
--- a/packages/git-ui/src/pages/packages.jsx
+++ b/packages/git-ui/src/pages/packages.jsx
@@ -1,10 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import { PackageIcon } from 'lucide-react';
import { Skeleton } from '#/components/atoms/skeleton.jsx';
-import { Breadcrumbs } from '#/components/molecules/breadcrumbs.jsx';
import { EmptyState } from '#/components/molecules/empty-state.jsx';
import { PackageCard } from '#/components/molecules/package-card.jsx';
-import { defaultRef, repoRecord } from '#/lib/git.js';
+import { repoRecord } from '#/lib/git.js';
import { loadPackages } from '#/lib/packages.js';
/**
@@ -25,14 +24,12 @@ export function PackagesPage({ repo }) {
return (
<>
-
-
-
-
{!data ? (
-
+
+
+
) : data.length === 0 ? (
-
+
}
title="No packages"
@@ -40,9 +37,11 @@ export function PackagesPage({ repo }) {
/>
) : (
- data.map((pkg) => (
-
- ))
+
+ {data.map((pkg) => (
+
+ ))}
+
)}
>
);
diff --git a/packages/git-ui/src/pages/repos.jsx b/packages/git-ui/src/pages/repos.jsx
index fab372b..607b0b2 100644
--- a/packages/git-ui/src/pages/repos.jsx
+++ b/packages/git-ui/src/pages/repos.jsx
@@ -1,9 +1,14 @@
import { useQuery } from '@tanstack/react-query';
-import { FolderGitIcon } from 'lucide-react';
-import { Card } from '#/components/atoms/card.jsx';
+import {
+ ArrowUpFromLineIcon,
+ FolderGitIcon,
+ GitBranchIcon,
+ TagIcon,
+} from 'lucide-react';
import { Link } from '#/components/atoms/link.jsx';
+import { AccountPanel } from '#/components/molecules/account-panel.jsx';
import { EmptyState } from '#/components/molecules/empty-state.jsx';
-import { RepoListSkeleton } from '#/components/molecules/page-skeleton.jsx';
+import { RepoListSkeleton } from '#/components/molecules/repo-list-skeleton.jsx';
import { bytes, timeAgo } from '#/lib/format.js';
import {
branchRefs,
@@ -14,20 +19,24 @@ import {
tagRefs,
} from '#/lib/git.js';
-/** One line summarizing a repository's shape. */
-function summary(repo) {
- const facts = [];
- if (isEmpty(repo)) {
- facts.push('nothing pushed yet');
- } else {
- const branches = branchRefs(repo).length;
- facts.push(`${branches} ${branches === 1 ? 'branch' : 'branches'}`);
- const tags = tagRefs(repo).length;
- if (tags > 0) facts.push(`${tags} ${tags === 1 ? 'tag' : 'tags'}`);
- facts.push(bytes(repoSize(repo)));
- }
- if (repo.updatedAt) facts.push(`pushed ${timeAgo(repo.updatedAt)}`);
- return facts.join(' · ');
+/**
+ * How many times this repository has been pushed to. Every push appends a
+ * bundle to the chain, so the chain's length counts them, and no packfile has
+ * to be opened to know it.
+ */
+const pushes = (repo) => (repo.bundles || []).length;
+
+/** One reading: an icon, a number, and what the number counts. */
+function Metric({ icon, value, unit }) {
+ return (
+
+ {icon}
+
+ {value}
+
+ {unit}
+
+ );
}
export function ReposPage() {
@@ -63,29 +72,68 @@ export function ReposPage() {
}
return (
-
- {data.map((repo) => (
-
-
-
-
+
+
+ {/* One panel divided by rules rather than a stack of separate tiles: the
+ repositories of one account read as one instrument. */}
+
+ {data.map((repo) => (
+
+
+
{repo.name}
- {repo.description && (
-
- {repo.description}
-
- )}
-
- {summary(repo)}
+
+ {repo.description || (
+ No description
+ )}
+
+
+ {isEmpty(repo) ? (
+ nothing pushed yet
+ ) : (
+ <>
+ }
+ value={branchRefs(repo).length}
+ unit={
+ branchRefs(repo).length === 1 ? 'branch' : 'branches'
+ }
+ />
+ {tagRefs(repo).length > 0 && (
+ }
+ value={tagRefs(repo).length}
+ unit={tagRefs(repo).length === 1 ? 'tag' : 'tags'}
+ />
+ )}
+
+ }
+ value={pushes(repo)}
+ unit={pushes(repo) === 1 ? 'push' : 'pushes'}
+ />
+
+ {bytes(repoSize(repo))}
+
+ >
+ )}
+ {repo.updatedAt && (
+
+ {timeAgo(repo.updatedAt)}
+
+ )}
-
-
-
- ))}
-
+
+
+ ))}
+
+
);
}
diff --git a/packages/git-ui/src/pages/tree.jsx b/packages/git-ui/src/pages/tree.jsx
index 7226e60..1551852 100644
--- a/packages/git-ui/src/pages/tree.jsx
+++ b/packages/git-ui/src/pages/tree.jsx
@@ -1,25 +1,14 @@
import { useQuery } from '@tanstack/react-query';
-import {
- CircleCheckIcon,
- GitBranchIcon,
- GitCommitVerticalIcon,
- PackageIcon,
- TagIcon,
-} from 'lucide-react';
-import { buttonVariants } from '#/components/atoms/button.jsx';
-import { Card } from '#/components/atoms/card.jsx';
-import { Link } from '#/components/atoms/link.jsx';
+import { GitBranchIcon, TagIcon } from 'lucide-react';
import { Skeleton } from '#/components/atoms/skeleton.jsx';
import { Breadcrumbs } from '#/components/molecules/breadcrumbs.jsx';
-import { CodeMenu } from '#/components/molecules/code-menu.jsx';
+import { DirTable } from '#/components/molecules/dir-table.jsx';
import { EmptyState } from '#/components/molecules/empty-state.jsx';
-import { LanguageBar } from '#/components/molecules/language-bar.jsx';
import { LatestCommit } from '#/components/molecules/latest-commit.jsx';
import { Markdown } from '#/components/molecules/markdown.jsx';
import { ReadingRepo } from '#/components/molecules/reading-repo.jsx';
import { RefSelect } from '#/components/molecules/ref-select.jsx';
-import { TreeList } from '#/components/molecules/tree-list.jsx';
-import { loadChecks } from '#/lib/checks.js';
+import { SourcePanes } from '#/components/molecules/source-panes.jsx';
import {
account,
branchRefs,
@@ -29,94 +18,39 @@ import {
repoSize,
tagRefs,
} from '#/lib/git.js';
-import { languageShares } from '#/lib/languages.js';
import { useNavigate } from '#/lib/navigation.jsx';
-import { loadPackages } from '#/lib/packages.js';
-import { cn } from '#/lib/utils.js';
+import { useDirectory, useHistory } from '#/lib/source.js';
const README = /^readme(\.md|\.txt|\.markdown)?$/i;
-/**
- * Deep enough to count every commit a repository of this shape holds. The
- * walk runs over the objects already in memory, so the ceiling is a guard
- * rather than a budget.
- */
-const COUNT_LIMIT = 50000;
-
-export function TreePage({ repo, refName, path, httpClone }) {
+export function TreePage({ repo, refName, path }) {
const navigate = useNavigate();
const record = repoRecord(repo);
- const { data, error } = useQuery({
- queryKey: ['tree', repo, refName, path],
- enabled: Boolean(record) && !isEmpty(record),
- queryFn: async () => {
- const reader = readerFor(repo);
- const listing = await reader.listTree(account.did, repo, refName, path);
- const readme = listing?.entries.find(
- (entry) => entry.type === 'file' && README.test(entry.name),
- );
- const file = readme
- ? await reader.readFile(
- account.did,
- repo,
- refName,
- path ? `${path}/${readme.name}` : readme.name,
- )
- : null;
- return {
- listing,
- readme: readme ? { name: readme.name, file } : null,
- };
- },
- });
-
- // The tip of the ref and how far its history runs. The walk reads every
- // commit, which on an indexed chain is a download of its own, so the file
- // list paints first and this line fills in.
- const { data: history } = useQuery({
- queryKey: ['tree-head', repo, refName],
- enabled: Boolean(record) && !isEmpty(record),
- queryFn: async () => {
- const log = await readerFor(repo).listCommits(
- account.did,
- repo,
- refName,
- COUNT_LIMIT,
- );
- return {
- head: log?.commits[0] ?? null,
- count: log?.commits.length ?? 0,
- truncated: Boolean(log?.truncated),
- };
- },
- });
+ // The listing the pane beside this one draws. Reading it here too costs
+ // nothing: one query key serves both.
+ const { data: entries, error } = useDirectory(repo, refName, path);
- // Every file at the ref, weighed by language. The walk reads the whole
- // tree, so the file list paints first and the bar arrives after it. Only
- // the root asks: the shares are the repository's, not a directory's.
- const { data: languages } = useQuery({
- queryKey: ['languages', repo, refName],
- enabled: Boolean(record) && !isEmpty(record) && !path,
- queryFn: async () => {
- const listing = await readerFor(repo).listFiles(
+ // What this directory says about itself. A directory with no README leaves
+ // the reading pane empty, which is what a checkout looks like.
+ const readme = (entries ?? []).find(
+ (entry) => entry.type === 'file' && README.test(entry.name),
+ );
+ const { data: readmeFile } = useQuery({
+ queryKey: ['readme', repo, refName, path, readme?.name],
+ enabled: Boolean(readme),
+ queryFn: () =>
+ readerFor(repo).readFile(
account.did,
repo,
refName,
- );
- return languageShares(listing?.files ?? []);
- },
- });
-
- const { data: checks } = useQuery({
- queryKey: ['checks', repo],
- queryFn: () => loadChecks(repo),
+ path ? `${path}/${readme.name}` : readme.name,
+ ),
});
- const { data: packages } = useQuery({
- queryKey: ['packages', repo],
- queryFn: () => loadPackages(repo),
- });
+ // The tip of the ref and how far its history runs. The gauges in the pane
+ // beside this one read the same answer.
+ const { data: history } = useHistory(repo, refName);
if (!record) {
return No such repository.
;
@@ -125,9 +59,11 @@ export function TreePage({ repo, refName, path, httpClone }) {
const branches = branchRefs(record).length;
const tags = tagRefs(record).length;
+ // What this view is looking at: which ref, and where inside it. The
+ // sections of the repository, and cloning it, are in the band above.
const toolbar = (
-
-
+
+
+ {/* What else the reader could have picked, beside the picker. */}
{branches}
@@ -147,130 +84,92 @@ export function TreePage({ repo, refName, path, httpClone }) {
{tags === 1 ? 'tag' : 'tags'}
)}
-
-
- {/* Only where there is something to open. A repository that names no
- package has an empty packages page. */}
- {packages && packages.length > 0 && (
-
-
- {packages.length === 1
- ? '1 package'
- : `${packages.length} packages`}
-
- )}
- {/* Only where there is something to open. A repository that names no
- runner has an empty checks page. */}
- {checks?.runner && (
-
-
- Checks
-
- )}
-
-
- History
-
-
+ {/* Only inside a directory. At the root the heading above already
+ names the repository. */}
+ {path &&
}
);
+ if (isEmpty(record)) {
+ return (
+
+
+
+ );
+ }
+
return (
<>
-
-
+ {toolbar}
+
+ {/* Only at the root, where the tip of the ref is the repository's latest
+ commit. Inside a directory a reader would take it for the last change
+ to that directory, which it is not. */}
+ {!path &&
+ (history?.head ? (
+
+ ) : (
+
+
+
+ ))}
+
+
+ {error && (
+ {error.message}
+ )}
+ {!entries && !error && (
+
+
+
+ )}
{!path && record.description && (
-
+
{record.description}
)}
-
-
- {isEmpty(record) ? (
-
-
-
- ) : (
- <>
- {toolbar}
- {/* The empty track while the walk runs, so the listing keeps its
- place when the colours arrive. */}
- {!path && !languages && (
-
- )}
- {languages && languages.length > 0 && (
-
- )}
- {error &&
{error.message}
}
- {!data && !error &&
}
- {data?.listing && (
-
- {/* Only at the root, where the tip of the ref is the repository's
- latest commit. Inside a directory a reader would take it for
- the last change to that directory, which it is not. */}
- {!path &&
- (history ? (
- history.head && (
-
- )
- ) : (
-
-
-
- ))}
-
+ {readme && (
+ <>
+
+ {readme.name}
+
+
+ {readmeFile?.text ? (
+
+
+
+ ) : (
+
+ )}
- )}
- {data?.readme?.file?.text && (
-
-
- {data.readme.name}
-
-
-
-
-
- )}
- >
- )}
+ >
+ )}
+ {/* A directory that says nothing about itself shows what it holds,
+ with the sizes the navigating pane has no room for. */}
+ {entries && !readme && (
+
+ )}
+
>
);
}
diff --git a/packages/git-ui/src/style.css b/packages/git-ui/src/style.css
index 1d723a4..db037b0 100644
--- a/packages/git-ui/src/style.css
+++ b/packages/git-ui/src/style.css
@@ -54,7 +54,16 @@
--color-diff-del-ink: var(--diff-del-ink);
--color-diff-meta: var(--diff-meta);
- --radius-lg: 0.75rem;
+ /* One radius for every surface. Panels and controls are cut square with
+ the corners taken off, which is what makes a page of them read as one
+ instrument rather than a pile of tiles. Every rounded-* utility resolves
+ here, so a component asks for the shape it wants and gets this. */
+ --radius-sm: 2px;
+ --radius-md: 3px;
+ --radius-lg: 3px;
+ --radius-xl: 4px;
+ --radius-2xl: 4px;
+ --radius-3xl: 5px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, SFMono-Regular, menlo, monospace;
diff --git a/packages/git-ui/test/checks.test.js b/packages/git-ui/test/checks.test.js
index acef095..70b17d3 100644
--- a/packages/git-ui/test/checks.test.js
+++ b/packages/git-ui/test/checks.test.js
@@ -8,10 +8,17 @@ import {
checkLogUrl,
checkLook,
duration,
+ groupBySha,
latestBySha,
+ latestPerWorkflow,
+ loadRefChecks,
readLog,
runDuration,
runsFor,
+ summarize,
+ summaryWords,
+ workflowNames,
+ workflowOf,
} from '../src/lib/checks.js';
const SUBJECT = 'at://did:plc:owner/dev.pdsjs.git.repo/project';
@@ -96,14 +103,42 @@ describe('runsFor', () => {
);
expect(runs.map((run) => run.sha)).toEqual(['f'.repeat(40)]);
});
+
+ it('keeps the record key, which addresses the run page', () => {
+ const [run] = runsFor(
+ [
+ {
+ uri: 'at://did:plc:runner/dev.pdsjs.git.check/3ltid',
+ value: {
+ subject: { uri: SUBJECT },
+ sha: 'a'.repeat(40),
+ status: 'success',
+ startedAt: '2026-01-01T00:00:00Z',
+ },
+ },
+ ],
+ SUBJECT,
+ );
+ expect(run.rkey).toBe('3ltid');
+ });
});
describe('latestBySha', () => {
- it('keys the newest run by its commit', () => {
+ it('keys the newest run of each workflow by its commit', () => {
const sha = 'a'.repeat(40);
const map = latestBySha([
- { sha, status: 'success', startedAt: '2026-01-02T00:00:00Z' },
- { sha, status: 'failure', startedAt: '2026-01-01T00:00:00Z' },
+ {
+ sha,
+ workflow: 'ci',
+ status: 'success',
+ startedAt: '2026-01-02T00:00:00Z',
+ },
+ {
+ sha,
+ workflow: 'ci',
+ status: 'failure',
+ startedAt: '2026-01-01T00:00:00Z',
+ },
{
sha: 'b'.repeat(40),
status: 'failure',
@@ -111,8 +146,152 @@ describe('latestBySha', () => {
},
]);
expect(map.size).toBe(2);
- expect(map.get(sha)?.status).toBe('success');
- expect(map.get('b'.repeat(40))?.status).toBe('failure');
+ expect(map.get(sha)?.map((run) => run.status)).toEqual(['success']);
+ expect(map.get('b'.repeat(40))?.map((run) => run.status)).toEqual([
+ 'failure',
+ ]);
+ });
+
+ it('keeps one entry per workflow, so no workflow hides another', () => {
+ const sha = 'a'.repeat(40);
+ const map = latestBySha([
+ {
+ sha,
+ workflow: 'publish',
+ status: 'success',
+ startedAt: '2026-01-02T00:00:00Z',
+ },
+ {
+ sha,
+ workflow: 'ci',
+ status: 'failure',
+ startedAt: '2026-01-01T00:00:00Z',
+ },
+ ]);
+ expect(map.get(sha)?.map(workflowOf)).toEqual(['publish', 'ci']);
+ });
+});
+
+describe('groupBySha', () => {
+ const run = (sha, workflow, startedAt) => ({
+ sha,
+ workflow,
+ status: 'success',
+ startedAt,
+ });
+
+ it('gathers each commit runs, newest commit first', () => {
+ const groups = groupBySha([
+ run('b', 'ci', '2026-01-03T00:00:00Z'),
+ run('a', 'ci', '2026-01-02T00:00:00Z'),
+ run('b', 'lint', '2026-01-01T00:00:00Z'),
+ ]);
+ expect(groups.map((group) => group.sha)).toEqual(['b', 'a']);
+ expect(groups[0].runs.map(workflowOf)).toEqual(['ci', 'lint']);
+ });
+
+ it('keeps a re-run beside the run it repeated', () => {
+ const groups = groupBySha([
+ run('a', 'ci', '2026-01-02T00:00:00Z'),
+ run('a', 'ci', '2026-01-01T00:00:00Z'),
+ ]);
+ expect(groups).toHaveLength(1);
+ expect(groups[0].runs).toHaveLength(2);
+ });
+});
+
+describe('latestPerWorkflow', () => {
+ it('keeps the newest run of each workflow', () => {
+ const latest = latestPerWorkflow([
+ { sha: 'a', workflow: 'ci', status: 'success', startedAt: 'b' },
+ { sha: 'a', workflow: 'ci', status: 'failure', startedAt: 'a' },
+ { sha: 'a', workflow: 'lint', status: 'success', startedAt: 'a' },
+ ]);
+ expect(latest.map((run) => [workflowOf(run), run.status])).toEqual([
+ ['ci', 'success'],
+ ['lint', 'success'],
+ ]);
+ });
+});
+
+describe('summarize', () => {
+ const run = (workflow, status) => ({
+ sha: 'a'.repeat(40),
+ workflow,
+ status,
+ startedAt: '2026-01-01T00:00:00Z',
+ });
+
+ it('reads a set every workflow passed as passed', () => {
+ expect(
+ summarize([run('ci', 'success'), run('publish', 'success')]),
+ ).toEqual({ status: 'success', passed: 2, total: 2 });
+ });
+
+ it('lets one failure decide the whole set', () => {
+ expect(
+ summarize([run('publish', 'success'), run('ci', 'failure')]),
+ ).toEqual({ status: 'failure', passed: 1, total: 2 });
+ });
+
+ it('names an errored run rather than calling it failed', () => {
+ expect(summarize([run('ci', 'error')]).status).toBe('error');
+ });
+
+ it('reads an unfinished set as running', () => {
+ expect(
+ summarize([run('ci', 'success'), run('publish', 'running')]),
+ ).toEqual({ status: 'running', passed: 1, total: 2 });
+ });
+
+ it('answers null for a commit no workflow reported on', () => {
+ expect(summarize([])).toBe(null);
+ });
+});
+
+describe('summaryWords', () => {
+ const run = (workflow, status) => ({
+ sha: 'a'.repeat(40),
+ workflow,
+ status,
+ startedAt: '2026-01-01T00:00:00Z',
+ });
+
+ it('names the workflow where there is only one', () => {
+ const runs = [run('ci', 'failure')];
+ expect(summaryWords(summarize(runs), runs)).toBe('ci failed');
+ });
+
+ it('counts the workflows where there are several', () => {
+ const runs = [run('ci', 'failure'), run('publish', 'success')];
+ expect(summaryWords(summarize(runs), runs)).toBe('1 of 2 workflows passed');
+ });
+
+ it('says what an unfinished set is still doing', () => {
+ const runs = [run('ci', 'success'), run('publish', 'running')];
+ expect(summaryWords(summarize(runs), runs)).toBe(
+ '1 of 2 workflows passed, the rest running',
+ );
+ });
+});
+
+describe('workflowOf', () => {
+ it('reads a run that names no workflow as ci', () => {
+ expect(workflowOf({})).toBe('ci');
+ expect(workflowOf({ workflow: 'publish' })).toBe('publish');
+ });
+});
+
+describe('workflowNames', () => {
+ it('names each workflow once, in the order a reader meets it', () => {
+ expect(
+ workflowNames([
+ { workflow: 'publish' },
+ { workflow: 'ci' },
+ { workflow: 'publish' },
+ {},
+ ]),
+ ).toEqual(['publish', 'ci']);
});
});
@@ -204,6 +383,66 @@ describe('checkLogUrl', () => {
});
});
+describe('loadRefChecks', () => {
+ const original = globalThis.fetch;
+
+ afterEach(() => {
+ globalThis.fetch = original;
+ });
+
+ /**
+ * The three reads the ref badge makes: the repository's config, the
+ * runner's DID document, and the runner's latest-check record.
+ * @param {unknown} record - what getRecord answers for the latest check
+ */
+ const serve = (record) => {
+ globalThis.fetch = async (/** @type {string} */ url) => {
+ if (url.includes('dev.pdsjs.git.config')) {
+ return Response.json({ value: { runner: 'did:plc:runner' } });
+ }
+ if (url.includes('did:plc:runner') && !url.includes('xrpc')) {
+ return Response.json({
+ service: [
+ {
+ id: '#atproto_pds',
+ serviceEndpoint: 'https://runner.example.com',
+ },
+ ],
+ });
+ }
+ return Response.json({ value: record });
+ };
+ };
+
+ it('reads the latest run of each workflow, and the run each names', async () => {
+ serve({
+ ref: 'refs/heads/main',
+ checks: [
+ {
+ workflow: 'ci',
+ sha: 'a'.repeat(40),
+ status: 'success',
+ startedAt: '2026-01-01T00:00:00Z',
+ check: { uri: 'at://did:plc:runner/dev.pdsjs.git.check/3ltid' },
+ },
+ ],
+ });
+ const { runner, runs } = await loadRefChecks('project', 'main');
+ expect(runner?.service).toBe('https://runner.example.com');
+ expect(runs).toHaveLength(1);
+ expect(runs[0].rkey).toBe('3ltid');
+ expect(workflowOf(runs[0])).toBe('ci');
+ });
+
+ it('reads a record the runner has not written as no runs', async () => {
+ serve(undefined);
+ // The runner cache keys off the repository name, so this asks about
+ // another one rather than reusing the entry the test above filled.
+ const { runs } = await loadRefChecks('other', 'main');
+ expect(runs).toEqual([]);
+ });
+});
+
describe('readLog', () => {
const runner = { did: 'did:plc:runner', service: 'https://pds.example.com' };
const check = {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index eba827a..a46a549 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -288,6 +288,9 @@ importers:
'@pdsjs/git':
specifier: workspace:*
version: link:../git
+ '@pdsjs/git-ci':
+ specifier: workspace:*
+ version: link:../git-ci
'@pdsjs/npm':
specifier: workspace:*
version: link:../npm
--
2.51.2
From cc1277053c731f05a56187e389a17c9f81c8796b Mon Sep 17 00:00:00 2001
From: Chad Miller
Date: Sun, 23 Aug 2026 15:29:37 -0700
Subject: [PATCH 05/13] chore(dev): seed a repository worth browsing
The demo repository was one commit of one language written in a single
second, which left the file pane with nothing to walk into, the language
gauge one colour and the activity gauge empty. It now carries nested
directories, files long enough to scroll, seven languages by size, and a
history dated across five months.
`just dev-git` also publishes check records now: several workflows over
several commits, a workflow asked for twice, a failure beside a success,
and a run still going. The account stands as its own runner, which is
what the config record's runner field names, so a reader resolves that
DID and reads its checks the same way it would read another account's.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01Gp5VgVe393AeJL5jKARqR3
---
Justfile | 9 +-
scripts/dev-git-checks.mjs | 273 +++++++++++++++++++++++++++++++++++++
scripts/dev-git-demo.mjs | 160 ++++++++++++++++++++--
3 files changed, 428 insertions(+), 14 deletions(-)
create mode 100644 scripts/dev-git-checks.mjs
diff --git a/Justfile b/Justfile
index bff9396..cb56e18 100644
--- a/Justfile
+++ b/Justfile
@@ -63,8 +63,9 @@ dev-multi: stack-up pds-start
@echo "Both use the password test-password. Starting Vite HMR for the UI…"
npm run dev --workspace=@pdsjs/account-ui
-# The repository browser with a repository, a package and an image to read.
-dev-git: plc-up (pds-start "localhost:2471") (seed base) git-demo
+# The repository browser with a repository, a package, an image and a history
+# of check runs to read.
+dev-git: plc-up (pds-start "localhost:2471") (seed base) git-demo git-checks
@echo ""
@echo "Open http://localhost:{{ui_port}} — the repository is demo-app."
@echo "The account page is {{base}}/account (handle {{handle}}.localhost, password test-password)."
@@ -88,6 +89,10 @@ spaces-demo:
git-demo:
PDS_DEV_URL={{base}} PDS_DEV_PLC_URL={{plc}} node scripts/dev-git-demo.mjs
+# A few more commits, and the check records a runner would publish about them.
+git-checks:
+ PDS_DEV_URL={{base}} PDS_DEV_PLC_URL={{plc}} node scripts/dev-git-checks.mjs
+
# Start the local PLC alone, for a stack that needs no relay.
plc-up:
#!/usr/bin/env bash
diff --git a/scripts/dev-git-checks.mjs b/scripts/dev-git-checks.mjs
new file mode 100644
index 0000000..ddd36eb
--- /dev/null
+++ b/scripts/dev-git-checks.mjs
@@ -0,0 +1,273 @@
+#!/usr/bin/env node
+/**
+ * Local-dev seed for the checks pages: a few more commits on the demo
+ * repository, and the check records a runner would have published about them.
+ * The account runs as its own runner, which is what the config record's
+ * `runner` field names. A reader resolves that DID and reads its checks, so
+ * one account playing both parts exercises the same path as two.
+ *
+ * The runs are shaped to cover what the pages have to render: several
+ * workflows on one commit, a workflow re-run, a failure beside a success, and
+ * a run still going.
+ *
+ * Run `node scripts/dev-git-demo.mjs` first; this pushes onto the repository
+ * that leaves behind. See `just dev-git`, which runs all three.
+ *
+ * Usage: node scripts/dev-git-checks.mjs [repo-name]
+ * Env: PDS_DEV_URL (default http://localhost:2471)
+ * PDS_DEV_PASSWORD (default test-password)
+ * PDS_DEV_PLC_URL (default http://localhost:2582)
+ * PDS_DEV_WORK_DIR (default .dev-pds/work)
+ */
+import { spawnSync } from 'node:child_process';
+import { appendFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+const BASE = process.env.PDS_DEV_URL || 'http://localhost:2471';
+const PASSWORD = process.env.PDS_DEV_PASSWORD || 'test-password';
+const PLC_URL = process.env.PDS_DEV_PLC_URL || 'http://localhost:2582';
+const WORK = resolve(process.env.PDS_DEV_WORK_DIR || '.dev-pds/work');
+const NAME = process.argv[2] || 'demo-app';
+
+const CHECK_COLLECTION = 'dev.pdsjs.git.check';
+const LATEST_CHECK_COLLECTION = 'dev.pdsjs.git.latestCheck';
+const REF = 'refs/heads/main';
+
+/** @param {string} command @param {string[]} args @param {object} [options] */
+function run(command, args, options = {}) {
+ const result = spawnSync(command, args, {
+ cwd: `${WORK}/${NAME}`,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ encoding: 'utf8',
+ ...options,
+ env: { ...process.env, ...(options.env ?? {}) },
+ });
+ if (result.status !== 0) {
+ throw new Error(
+ `${command} ${args.join(' ')} failed:\n${result.stderr || result.stdout}`,
+ );
+ }
+ return result.stdout?.trim() ?? '';
+}
+
+const did = (await (await fetch(`${BASE}/.well-known/atproto-did`)).text())
+ .trim()
+ .replace(/^"|"$/g, '');
+
+const session = await (
+ await fetch(`${BASE}/xrpc/com.atproto.server.createSession`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ identifier: did, password: PASSWORD }),
+ })
+).json();
+const AUTH = { Authorization: `Bearer ${session.accessJwt}` };
+
+/** @param {string} path @param {unknown} body */
+async function call(path, body) {
+ const res = await fetch(`${BASE}/xrpc/${path}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...AUTH },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) throw new Error(`${path} answered ${res.status}`);
+ return res.json();
+}
+
+// ---- 1. a history worth listing ------------------------------------------
+
+const COMMITS = [
+ ['README.md', '\nBuilt by its own runner.\n', 'document the runner'],
+ ['index.js', 'export const version = () => "1.0.1";\n', 'add version()'],
+ [
+ 'index.js',
+ 'export const shout = (who) => `HELLO, ${who}`;\n',
+ 'add shout()',
+ ],
+];
+for (const [file, body, message] of COMMITS) {
+ appendFileSync(`${WORK}/${NAME}/${file}`, body);
+ run('git', ['add', '-A']);
+ run('git', [
+ '-c',
+ 'user.email=alice@localhost',
+ '-c',
+ 'user.name=Alice',
+ 'commit',
+ '-qm',
+ message,
+ ]);
+}
+run('git', ['push', '-q', `atproto://${did}/${NAME}`, 'main'], {
+ env: {
+ PATH: `${WORK}/bin:${process.env.PATH}`,
+ ATPROTO_GIT_IDENTIFIER: did,
+ ATPROTO_GIT_PASSWORD: PASSWORD,
+ ATPROTO_GIT_PLC_URL: PLC_URL,
+ ATPROTO_GIT_SERVICE: BASE,
+ },
+});
+
+// Newest first, which is the order the runs below are written in.
+const shas = run('git', ['log', '-4', '--format=%H']).split('\n');
+console.log(`Pushed ${COMMITS.length} more commits to ${NAME}`);
+
+// ---- 2. what the runner would have published ------------------------------
+
+const repoRecord = await (
+ await fetch(
+ `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=dev.pdsjs.git.repo&rkey=${NAME}`,
+ )
+).json();
+const subject = { uri: repoRecord.uri, cid: repoRecord.cid };
+
+/** A step's line in the log, the way a shell runner writes one. */
+const logFor = (steps) =>
+ steps
+ .map(({ name, exitCode }) =>
+ exitCode === 0
+ ? `$ ${name}\nran ${name} in the checkout\n${name}: ok\n`
+ : `$ ${name}\nran ${name} in the checkout\n${name}: FAILED\nexit status ${exitCode}\n`,
+ )
+ .join('\n');
+
+/** @param {string} text */
+async function uploadLog(text) {
+ const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'text/plain', ...AUTH },
+ body: text,
+ });
+ if (!res.ok) throw new Error(`uploadBlob answered ${res.status}`);
+ return (await res.json()).blob;
+}
+
+const MINUTE = 60_000;
+let clock = Date.now() - 90 * MINUTE;
+
+/**
+ * One run, written the way the runner writes one.
+ * @param {{sha: string, workflow: string, steps: {name: string, exitCode: number, durationMs: number}[], running?: boolean}} spec
+ */
+async function publish({ sha, workflow, steps, running }) {
+ const startedAt = new Date(clock).toISOString();
+ const took = steps.reduce((total, step) => total + step.durationMs, 0);
+ clock += took + MINUTE;
+ const failed = steps.find((step) => step.exitCode !== 0);
+ const status = running ? 'running' : failed ? 'failure' : 'success';
+ const record = {
+ $type: CHECK_COLLECTION,
+ subject,
+ ref: REF,
+ sha,
+ workflow,
+ status,
+ steps,
+ startedAt,
+ ...(running
+ ? {}
+ : {
+ finishedAt: new Date(Date.parse(startedAt) + took).toISOString(),
+ logs: await uploadLog(logFor(steps)),
+ }),
+ };
+ const { uri, cid } = await call('com.atproto.repo.createRecord', {
+ repo: did,
+ collection: CHECK_COLLECTION,
+ record,
+ });
+ return { workflow, subject, check: { uri, cid }, sha, status, startedAt };
+}
+
+const step = (name, exitCode, durationMs) => ({ name, exitCode, durationMs });
+const install = step('pnpm install --frozen-lockfile', 0, 21_400);
+
+// Oldest commit first, so the runs read in the order they happened.
+const [head, second, third, first] = shas;
+const written = [];
+for (const spec of [
+ { sha: first, workflow: 'ci', steps: [install, step('npm test', 0, 46_800)] },
+ { sha: third, workflow: 'ci', steps: [install, step('npm test', 0, 51_200)] },
+ {
+ sha: third,
+ workflow: 'lint',
+ steps: [install, step('biome check .', 0, 3_900)],
+ },
+ {
+ sha: second,
+ workflow: 'ci',
+ steps: [install, step('npm test', 1, 38_500)],
+ },
+ // The same workflow again, which is what a re-run leaves behind.
+ {
+ sha: second,
+ workflow: 'ci',
+ steps: [install, step('npm test', 0, 44_100)],
+ },
+ {
+ sha: second,
+ workflow: 'lint',
+ steps: [install, step('biome check .', 0, 4_200)],
+ },
+ {
+ sha: second,
+ workflow: 'publish',
+ steps: [install, step('npm publish', 0, 12_600)],
+ },
+ {
+ sha: head,
+ workflow: 'lint',
+ steps: [install, step('biome check .', 0, 4_050)],
+ },
+ {
+ sha: head,
+ workflow: 'ci',
+ steps: [
+ install,
+ step('npm test', 1, 62_300),
+ step('npm run typecheck', 0, 9_100),
+ ],
+ },
+ {
+ sha: head,
+ workflow: 'publish',
+ steps: [install, step('npm publish', 0, 0)],
+ running: true,
+ },
+]) {
+ written.push(await publish(spec));
+}
+console.log(`Published ${written.length} check records as ${did}`);
+
+// ---- 3. the record a reader asks for by name ------------------------------
+
+/** The newest entry per workflow, which is what this record holds. */
+const latest = new Map();
+for (const entry of written) latest.set(entry.workflow, entry);
+
+const RKEY_SAFE = /[^A-Za-z0-9._~:-]/g;
+await call('com.atproto.repo.putRecord', {
+ repo: did,
+ collection: LATEST_CHECK_COLLECTION,
+ rkey: `${did}:${NAME}:${REF}`.replace(RKEY_SAFE, '_'),
+ record: {
+ $type: LATEST_CHECK_COLLECTION,
+ ref: REF,
+ checks: [...latest.values()],
+ },
+});
+
+// ---- 4. the repository names its runner -----------------------------------
+
+const config = await (
+ await fetch(
+ `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=dev.pdsjs.git.config&rkey=${NAME}`,
+ )
+).json();
+await call('com.atproto.repo.putRecord', {
+ repo: did,
+ collection: 'dev.pdsjs.git.config',
+ rkey: NAME,
+ record: { ...config.value, runner: did },
+});
+console.log(`${NAME} names ${did} as its runner`);
diff --git a/scripts/dev-git-demo.mjs b/scripts/dev-git-demo.mjs
index caace3a..1a6a886 100644
--- a/scripts/dev-git-demo.mjs
+++ b/scripts/dev-git-demo.mjs
@@ -17,7 +17,13 @@
*/
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
-import { mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
+import {
+ appendFileSync,
+ mkdirSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync,
+} from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -85,22 +91,152 @@ const files = {
2,
)}\n`,
'.npmrc': `registry=${BASE}/npm/\n//${HOST}/npm/:_authToken=${PASSWORD}\n`,
+ LICENSE: `MIT License\n\nCopyright (c) ${new Date().getFullYear()}\n`,
+
+ // Nested directories, so the file pane has somewhere to walk into, and a
+ // file long enough to scroll past a window.
+ 'src/index.js':
+ "export { parse } from './lib/parse.js';\n" +
+ "export { format } from './lib/format.js';\n" +
+ "export { run } from './commands/run.js';\n",
+ 'src/lib/parse.js': lines(
+ 'parse',
+ 'Read one line of the record stream into a value.',
+ 140,
+ ),
+ 'src/lib/format.js': lines(
+ 'format',
+ 'Write a value back out in the shape the stream expects.',
+ 90,
+ ),
+ 'src/lib/errors.js': lines('errors', 'The failures this package names.', 40),
+ 'src/commands/run.js': lines(
+ 'run',
+ 'The command the CLI runs by default.',
+ 70,
+ ),
+ 'src/commands/list.js': lines('list', 'List what the account holds.', 55),
+ 'test/parse.test.js': lines('parseTest', 'What parse promises.', 60),
+ 'docs/guide.md':
+ `# Guide\n\nHow to use ${NAME}.\n\n` +
+ Array.from(
+ { length: 24 },
+ (_, index) =>
+ `## Step ${index + 1}\n\nRun the command, read what it prints, and move on.\n`,
+ ).join('\n'),
+
+ // A spread of languages, so the gauge on the repository page has a mix to
+ // draw rather than one colour. The sizes decide the shares, and the tail
+ // past the sixth largest is what the gauge adds together as Other.
+ 'web/app.css': repeat(
+ (n) =>
+ `.panel-${n} {\n border: 1px solid var(--border);\n padding: ${n % 8}px;\n}\n`,
+ 120,
+ ),
+ 'web/index.html':
+ `\n\n \n \n ${NAME} \n \n \n \n` +
+ repeat((n) => ` \n`, 60) +
+ ' \n\n',
+ 'tools/report.py':
+ '"""Summarise a run of the toolkit."""\n\n\ndef summarise(rows):\n' +
+ repeat((n) => ` total_${n} = sum(row[${n % 5}] for row in rows)\n`, 70) +
+ ' return rows\n',
+ 'tools/checksum.go':
+ 'package tools\n\nimport "crypto/sha256"\n\n// Sum hashes each chunk in turn.\nfunc Sum(chunks [][]byte) []byte {\n\th := sha256.New()\n' +
+ repeat((n) => `\th.Write(chunks[${n % 9}])\n`, 45) +
+ '\treturn h.Sum(nil)\n}\n',
+ 'scripts/release.sh':
+ '#!/usr/bin/env bash\nset -euo pipefail\n\n' +
+ repeat((n) => `echo "step ${n}: checking the tree"\n`, 30),
+ Makefile: `build:\n\tnode src/index.js\n\ntest:\n\tnode --test\n`,
+ Dockerfile: `FROM node:22-alpine\nWORKDIR /app\nCOPY . .\nCMD ["node", "src/index.js"]\n`,
};
+
+/**
+ * `count` lines from one template, for the generated files above.
+ * @param {(n: number) => string} line
+ * @param {number} count
+ */
+function repeat(line, count) {
+ return Array.from({ length: count }, (_, index) => line(index + 1)).join('');
+}
+
+/**
+ * A source file of about `count` lines. Real length is what makes a pane
+ * worth scrolling, and generated length is what keeps this script short.
+ * @param {string} name
+ * @param {string} summary
+ * @param {number} count
+ */
+function lines(name, summary, count) {
+ const body = Array.from({ length: count }, (_, index) => {
+ const step = index + 1;
+ if (step % 12 === 0) return `\n // step ${step}: nothing to do here yet`;
+ return ` const step${step} = value[${step % 7}] ?? fallback(${step});`;
+ }).join('\n');
+ return `/** ${summary} */\nexport function ${name}(value, fallback) {\n${body}\n return value;\n}\n`;
+}
+
for (const [path, body] of Object.entries(files)) {
- writeFileSync(`${WORK}/${NAME}/${path}`, body);
+ const full = `${WORK}/${NAME}/${path}`;
+ mkdirSync(dirname(full), { recursive: true });
+ writeFileSync(full, body);
}
run('git', ['init', '-q', '-b', 'main']);
-run('git', ['add', '-A']);
-run('git', [
- '-c',
- 'user.email=alice@localhost',
- '-c',
- 'user.name=Alice',
- 'commit',
- '-qm',
- 'the first commit',
-]);
+
+/**
+ * One commit, dated. git takes the date from the environment, which is what
+ * lets this build a history with a shape rather than a stack of commits all
+ * made in the same second.
+ * @param {string} message
+ * @param {Date} [when]
+ */
+function commit(message, when) {
+ const at = when ? when.toISOString() : undefined;
+ run('git', ['add', '-A']);
+ run(
+ 'git',
+ [
+ '-c',
+ 'user.email=alice@localhost',
+ '-c',
+ 'user.name=Alice',
+ 'commit',
+ '-qm',
+ message,
+ ],
+ {
+ env: at
+ ? { GIT_AUTHOR_DATE: at, GIT_COMMITTER_DATE: at }
+ : /** @type {Record} */ ({}),
+ },
+ );
+}
+
+commit('the first commit', new Date(Date.now() - 154 * 86_400_000));
+
+// A run of dated commits so the repository has a history to draw: the
+// activity gauge on the repository page reads the last few months, and a
+// repository built in one second has nothing to show it.
+const DAY_MS = 86_400_000;
+const BACKDATED = [
+ [147, 'add a changelog'],
+ [140, 'note the license'],
+ [126, 'describe the layout'],
+ [119, 'tidy the readme'],
+ [98, 'add a usage example'],
+ [91, 'fix a typo in the example'],
+ [84, 'link the registry'],
+ [56, 'document the helper'],
+ [49, 'mention the runner'],
+ [21, 'refresh the readme'],
+ [14, 'add a contributing note'],
+];
+for (const [daysAgo, message] of BACKDATED) {
+ appendFileSync(`${WORK}/${NAME}/README.md`, `\n${message}.\n`);
+ commit(message, new Date(Date.now() - daysAgo * DAY_MS));
+}
run('git', ['push', '-q', `atproto://${did}/${NAME}`, 'main'], {
env: {
PATH: `${WORK}/bin:${process.env.PATH}`,
--
2.51.2
From 3febe1c0f80a5778c10c0e9e049e466f5eb73ba5 Mon Sep 17 00:00:00 2001
From: Chad Miller
Date: Sun, 23 Aug 2026 22:29:26 -0700
Subject: [PATCH 06/13] feat(git): stream a clone's pack to the client
upload-pack held every bundle in the chain, the merged pack, and the framed
response in memory at once, so a clone cost about six times the pack size. A
repository over roughly 20 MB exhausted a Cloudflare isolate's 128 MB.
The response now reads one chunk blob at a time and hashes the trailer as
the bytes go out. A chain of one bundle passes through with no rehash at
all. Cloning a 128 MB repository moves the server's peak from 640 MB to 43
MB.
bench/clone-memory.mjs is the measurement.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01Gp5VgVe393AeJL5jKARqR3
---
.changeset/git-http-clone-streams.md | 12 ++
packages/git/README.md | 11 +-
packages/git/bench/clone-memory.mjs | 222 +++++++++++++++++++++++++++
packages/git/package.json | 3 +-
packages/git/src/http.js | 218 ++++++++++++++++++++++----
packages/git/src/pack.js | 85 ++++++++--
packages/git/test/http.test.js | 51 ++++++
packages/git/test/wire.test.js | 65 +++++++-
8 files changed, 616 insertions(+), 51 deletions(-)
create mode 100644 .changeset/git-http-clone-streams.md
create mode 100644 packages/git/bench/clone-memory.mjs
diff --git a/.changeset/git-http-clone-streams.md b/.changeset/git-http-clone-streams.md
new file mode 100644
index 0000000..1123ae9
--- /dev/null
+++ b/.changeset/git-http-clone-streams.md
@@ -0,0 +1,12 @@
+---
+'@pdsjs/git': patch
+---
+
+Smart HTTP clones stream the merged pack instead of building it in memory.
+`upload-pack` held every bundle in the chain, the merged pack, and the
+framed response at once, so a clone cost about six times the pack size and
+a repository over roughly 20 MB exhausted a Cloudflare isolate's 128 MB. The
+response now reads one chunk blob at a time and hashes the trailer as the
+bytes go out, and a chain of one bundle passes through without a rehash at
+all. Cloning a 128 MB repository moves the server's peak from 640 MB to 43
+MB; `bench/clone-memory.mjs` is the measurement.
diff --git a/packages/git/README.md b/packages/git/README.md
index ca30fd7..378bc9a 100644
--- a/packages/git/README.md
+++ b/packages/git/README.md
@@ -99,6 +99,14 @@ read latency with and without ETag revalidation. From the workspace root:
node packages/git/bench/push-costs.mjs --commits 60 --bytes 8192
```
+`bench/clone-memory.mjs` measures what a clone costs the server: peak memory
+and wall time by repository size, for a repacked chain and an unmerged one.
+The peak is what a Cloudflare isolate must hold, against its 128 MB.
+
+```sh
+node --expose-gc packages/git/bench/clone-memory.mjs --sizes 8,48,128
+```
+
## Private repositories
A repository can live in a permissioned space
@@ -285,6 +293,7 @@ matching how the reference PDS treats unresolvable lexicons.
enables the smart HTTP endpoint, and with the helper everywhere else.
- A fresh clone downloads the whole bundle chain. Fetches skip bundles whose
heads are already present, which approximates negotiation at personal
- scale.
+ scale. The server streams that response one chunk blob at a time, so its
+ memory follows `ATPROTO_GIT_CHUNK_SIZE` rather than the repository size.
- SHA-1 repositories only over HTTP; the helper itself is happy with either
object format.
diff --git a/packages/git/bench/clone-memory.mjs b/packages/git/bench/clone-memory.mjs
new file mode 100644
index 0000000..dd8c0f9
--- /dev/null
+++ b/packages/git/bench/clone-memory.mjs
@@ -0,0 +1,222 @@
+#!/usr/bin/env node
+/**
+ * Server-side cost of a smart HTTP clone, measured against a local PDS.
+ *
+ * The upload-pack response is assembled in the server process, so the peak
+ * this reports is what a Cloudflare isolate must hold. An isolate gets 128
+ * MB, which is the number the ratio column is read against.
+ *
+ * Repository sizes are random bytes, so packs do not compress and the
+ * content size is the pack size.
+ *
+ * --gc collects on every sample, which separates memory the response holds
+ * from memory it has dropped but V8 has not swept.
+ *
+ * Run from the workspace root after `pnpm install`:
+ *
+ * node packages/git/bench/clone-memory.mjs --sizes 8,24,48
+ */
+
+import { execFile } from 'node:child_process';
+import { randomBytes } from 'node:crypto';
+import {
+ chmodSync,
+ mkdirSync,
+ mkdtempSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { promisify } from 'node:util';
+import { defineLexicon } from '@bigmoves/lexicon';
+import { GIT_REPO_COLLECTION, gitRepoLexicon } from '@pdsjs/git';
+import { LexiconResolver } from '@pdsjs/lexicon-resolver';
+import { createServer } from '@pdsjs/node';
+
+const execFileAsync = promisify(execFile);
+
+function arg(name, fallback) {
+ const i = process.argv.indexOf(`--${name}`);
+ return i === -1 ? fallback : process.argv[i + 1];
+}
+
+const SIZES = String(arg('sizes', '8,24,48'))
+ .split(',')
+ .map((s) => Number(s.trim()))
+ .filter((n) => Number.isFinite(n) && n > 0);
+const COMMITS = Number(arg('commits', 6));
+const PORT = Number(arg('port', 2490));
+const BASE = `http://127.0.0.1:${PORT}`;
+const PASSWORD = 'bench-password';
+const chars = 'abcdefghijklmnopqrstuvwxyz234567';
+const DID = `did:plc:${Array.from(randomBytes(24))
+ .map((b) => chars[b % 32])
+ .join('')}`;
+
+const workDir = mkdtempSync(join(tmpdir(), 'pdsjs-git-clone-bench-'));
+const server = await createServer({
+ port: PORT,
+ dbPath: join(workDir, 'pds.db'),
+ blobsDir: join(workDir, 'blobs'),
+ jwtSecret: 'bench-secret',
+ hostname: `127.0.0.1:${PORT}`,
+ password: PASSWORD,
+ lexiconResolver: new LexiconResolver({
+ schemas: [defineLexicon(gitRepoLexicon)],
+ }),
+ experimental: { git: { http: true } },
+});
+await server.listen();
+const init = await fetch(`${BASE}/init?did=${DID}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ did: DID,
+ privateKey: randomBytes(32).toString('hex'),
+ handle: 'bench.test',
+ password: PASSWORD,
+ }),
+});
+if (!init.ok) throw new Error('account init failed');
+
+const binDir = join(workDir, 'bin');
+mkdirSync(binDir);
+const cliPath = join(
+ dirname(fileURLToPath(import.meta.url)),
+ '..',
+ 'src',
+ 'cli.js',
+);
+writeFileSync(
+ join(binDir, 'git-remote-atproto'),
+ `#!/bin/sh\nexec node ${cliPath} "$@"\n`,
+);
+chmodSync(join(binDir, 'git-remote-atproto'), 0o755);
+
+const baseEnv = {
+ ...process.env,
+ PATH: `${binDir}:${process.env.PATH}`,
+ ATPROTO_GIT_SERVICE: BASE,
+ ATPROTO_GIT_IDENTIFIER: DID,
+ ATPROTO_GIT_PASSWORD: PASSWORD,
+ GIT_TERMINAL_PROMPT: '0',
+ GIT_AUTHOR_NAME: 'Bench',
+ GIT_AUTHOR_EMAIL: 'bench@example.com',
+ GIT_COMMITTER_NAME: 'Bench',
+ GIT_COMMITTER_EMAIL: 'bench@example.com',
+};
+
+function runGit(cwd, args, env) {
+ return execFileAsync('git', args, {
+ cwd,
+ env,
+ encoding: 'utf8',
+ maxBuffer: 1 << 28,
+ });
+}
+
+const mb = (n) => n / 1024 / 1024;
+const fmt = (n) => `${mb(n).toFixed(1)} MB`;
+
+/**
+ * Chain policies differ in what a clone must merge. A repacked chain is one
+ * bundle; an unmerged chain is one bundle per push.
+ */
+const POLICIES = [
+ { label: 'single bundle', factor: '2', threshold: '2' },
+ { label: 'chain of 6 ', factor: '0', threshold: '100' },
+];
+
+async function measure(sizeMb, policy) {
+ const rkey = `bench-${sizeMb}-${policy.threshold}`;
+ const env = {
+ ...baseEnv,
+ ATPROTO_GIT_COMPACT_FACTOR: policy.factor,
+ ATPROTO_GIT_REPACK_THRESHOLD: policy.threshold,
+ };
+ const repoDir = join(workDir, rkey);
+ mkdirSync(repoDir);
+ await runGit(repoDir, ['init', '-b', 'main'], env);
+ await runGit(
+ repoDir,
+ ['remote', 'add', 'origin', `atproto://${DID}/${rkey}`],
+ env,
+ );
+ const perCommit = Math.floor((sizeMb * 1024 * 1024) / COMMITS);
+ for (let i = 0; i < COMMITS; i++) {
+ writeFileSync(join(repoDir, `data-${i}.bin`), randomBytes(perCommit));
+ await runGit(repoDir, ['add', '.'], env);
+ await runGit(repoDir, ['commit', '-m', `commit ${i}`], env);
+ await runGit(repoDir, ['push', 'origin', 'main'], env);
+ }
+
+ const params = new URLSearchParams({
+ repo: DID,
+ collection: GIT_REPO_COLLECTION,
+ rkey,
+ });
+ const res = await fetch(`${BASE}/xrpc/com.atproto.repo.getRecord?${params}`);
+ const record = (await res.json()).value;
+ const packBytes = record.bundles.reduce(
+ (sum, b) => sum + b.parts.reduce((t, p) => t + p.size, 0),
+ 0,
+ );
+
+ if (global.gc) global.gc();
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ const before = process.memoryUsage();
+ let peakBuffers = before.arrayBuffers;
+ let peakRss = before.rss;
+ const collect = process.argv.includes('--gc');
+ const sampler = setInterval(() => {
+ if (collect && global.gc) global.gc();
+ const m = process.memoryUsage();
+ peakBuffers = Math.max(peakBuffers, m.arrayBuffers);
+ peakRss = Math.max(peakRss, m.rss);
+ }, 20);
+ const t0 = performance.now();
+ let failure = null;
+ try {
+ await runGit(
+ workDir,
+ ['clone', `${BASE}/git/${DID}/${rkey}`, join(workDir, `${rkey}-clone`)],
+ baseEnv,
+ );
+ } catch (err) {
+ failure = err.stderr?.trim().split('\n').pop() ?? String(err);
+ }
+ const secs = (performance.now() - t0) / 1000;
+ clearInterval(sampler);
+
+ return {
+ sizeMb,
+ policy: policy.label,
+ bundles: record.bundles.length,
+ packBytes,
+ buffers: peakBuffers - before.arrayBuffers,
+ rss: peakRss - before.rss,
+ secs,
+ failure,
+ };
+}
+
+console.log(
+ `clone cost by repository size, ${COMMITS} commits each, sampling the server process every 20 ms\n`,
+);
+console.log(
+ ' policy size bundles peak buffers peak rss x pack clone',
+);
+for (const sizeMb of SIZES) {
+ for (const policy of POLICIES) {
+ const r = await measure(sizeMb, policy);
+ const ratio = (r.buffers / r.packBytes).toFixed(1);
+ console.log(
+ ` ${r.policy} ${String(sizeMb).padStart(3)} MB ${String(r.bundles).padStart(4)} ${fmt(r.buffers).padStart(9)} ${fmt(r.rss).padStart(9)} ${ratio.padStart(5)}x ${r.failure ? `FAILED ${r.failure}` : `${r.secs.toFixed(1)}s`}`,
+ );
+ }
+}
+
+await server.close();
+rmSync(workDir, { recursive: true, force: true });
diff --git a/packages/git/package.json b/packages/git/package.json
index 206cdfe..d777b48 100644
--- a/packages/git/package.json
+++ b/packages/git/package.json
@@ -15,7 +15,8 @@
"./authors": "./src/authors.js"
},
"scripts": {
- "bench": "node bench/push-costs.mjs"
+ "bench": "node bench/push-costs.mjs",
+ "bench:clone": "node --expose-gc bench/clone-memory.mjs"
},
"dependencies": {
"pako": "^2.1.0"
diff --git a/packages/git/src/http.js b/packages/git/src/http.js
index 11b452a..037996e 100644
--- a/packages/git/src/http.js
+++ b/packages/git/src/http.js
@@ -15,9 +15,8 @@
*/
import { createGitBrowser } from './browser.js';
-import { parseBundle } from './bundle.js';
import { GIT_REPO_COLLECTION } from './lexicon.js';
-import { emptyPack, mergePacks } from './pack.js';
+import { mergePackStream, parsePackHeader } from './pack.js';
import { concatBytes, FLUSH, parsePkts, pktLine, pktText } from './pkt.js';
import { concatChunks, parseRepoRecord } from './record.js';
@@ -38,6 +37,88 @@ const PUSH_HINT =
// Payload byte budget for one side-band pkt-line: 65519 frame minus the
// 4-byte length prefix and 1-byte band marker.
const SIDE_BAND_MAX = 65514;
+// Band 1 carries pack data, band 3 an error the client prints and fails on.
+const BAND_PACK = 1;
+const BAND_ERROR = 3;
+// A bundle's text header ends at a blank line. It is small next to the
+// pack, so one read of this size finds the pack start on any ref list a
+// repository is likely to carry.
+const BUNDLE_HEAD_READ = 64 * 1024;
+
+/**
+ * The offset of the packfile inside a bundle: the byte after the blank line
+ * that ends the bundle's text header.
+ * @param {Uint8Array} bytes - the start of a bundle
+ * @returns {number} -1 when the buffer stops short of the blank line
+ */
+function packOffset(bytes) {
+ for (let i = 1; i < bytes.length; i++) {
+ if (bytes[i] === 0x0a && bytes[i - 1] === 0x0a) return i + 1;
+ }
+ return -1;
+}
+
+/**
+ * @param {number} band
+ * @param {Uint8Array} payload - at most SIDE_BAND_MAX bytes
+ * @returns {Uint8Array} one side-band pkt-line
+ */
+function bandLine(band, payload) {
+ const framed = new Uint8Array(payload.length + 1);
+ framed[0] = band;
+ framed.set(payload, 1);
+ return pktLine(framed);
+}
+
+/**
+ * The upload-pack response body after the header, in order: the NAK, the
+ * merged pack, and the terminating flush when the client took side-band.
+ * An error after the first byte is out of reach of a status code, so it
+ * goes out as a band 3 line the client reports.
+ * @param {import('./pack.js').PackSource[]} sources
+ * @param {boolean} sideBand
+ * @returns {AsyncGenerator}
+ */
+async function* uploadPackBody(sources, sideBand) {
+ yield pktLine('NAK\n');
+ try {
+ for await (const chunk of mergePackStream(sources)) {
+ if (!sideBand) {
+ yield chunk;
+ continue;
+ }
+ for (let offset = 0; offset < chunk.length; offset += SIDE_BAND_MAX) {
+ yield bandLine(
+ BAND_PACK,
+ chunk.subarray(offset, offset + SIDE_BAND_MAX),
+ );
+ }
+ }
+ } catch (err) {
+ if (!sideBand) throw err;
+ const message = err instanceof Error ? err.message : String(err);
+ yield bandLine(BAND_ERROR, new TextEncoder().encode(`${message}\n`));
+ return;
+ }
+ if (sideBand) yield FLUSH;
+}
+
+/**
+ * @param {AsyncGenerator} chunks
+ * @returns {ReadableStream}
+ */
+function toStream(chunks) {
+ return new ReadableStream({
+ async pull(controller) {
+ const { value, done } = await chunks.next();
+ if (done) controller.close();
+ else controller.enqueue(value);
+ },
+ async cancel() {
+ await chunks.return(undefined);
+ },
+ });
+}
/**
* @typedef {Object} GitHttpContext
@@ -137,22 +218,108 @@ export function createGitHttp(ctx) {
}
/**
+ * One byte range of a blob. A 206 answers the range itself; a server that
+ * ignores Range answers 200 with the whole blob, sliced here so both
+ * behave the same.
* @param {string} did
+ * @param {string} cid
+ * @param {number} start
+ * @param {number} end - exclusive
+ * @returns {Promise}
+ */
+ async function fetchBlobRange(did, cid, start, end) {
+ const params = new URLSearchParams({ did, cid });
+ const res = await ctx.xrpc(
+ new Request(
+ `https://pds.internal/xrpc/com.atproto.sync.getBlob?${params}`,
+ { headers: { range: `bytes=${start}-${end - 1}` } },
+ ),
+ );
+ if (!res.ok) throw new Error(`bundle blob ${cid} unavailable`);
+ const bytes = new Uint8Array(await res.arrayBuffer());
+ if (res.status === 206) return bytes;
+ return bytes.subarray(start, end);
+ }
+
+ /**
* @param {import('./record.js').BundleEntry} bundle
- * @returns {Promise} the bundle's packfile region
+ * @returns {number} the bundle's length in bytes
*/
- async function fetchBundlePack(did, bundle) {
- /** @type {Uint8Array[]} */
- const chunks = [];
+ function bundleLength(bundle) {
+ return bundle.parts.reduce((sum, part) => sum + part.size, 0);
+ }
+
+ /**
+ * Bundle bytes in [start, end), one chunk blob at a time, so a reader
+ * holds one chunk however long the bundle is.
+ * @param {string} did
+ * @param {import('./record.js').BundleEntry} bundle
+ * @param {number} start
+ * @param {number} end - exclusive
+ * @returns {AsyncGenerator}
+ */
+ async function* bundleBytes(did, bundle, start, end) {
+ let offset = 0;
for (const part of bundle.parts) {
- const params = new URLSearchParams({ did, cid: part.ref.$link });
- const res = await xrpcGet(`/xrpc/com.atproto.sync.getBlob?${params}`);
- if (!res.ok) {
- throw new Error(`bundle blob ${part.ref.$link} unavailable`);
+ if (offset >= end) return;
+ const partEnd = offset + part.size;
+ if (partEnd > start) {
+ yield await fetchBlobRange(
+ did,
+ part.ref.$link,
+ Math.max(start, offset) - offset,
+ Math.min(end, partEnd) - offset,
+ );
}
- chunks.push(new Uint8Array(await res.arrayBuffer()));
+ offset = partEnd;
}
- return parseBundle(concatChunks(chunks)).pack;
+ }
+
+ /**
+ * Where the packfile starts inside the bundle, and how many objects it
+ * holds. Both come from the bundle's first bytes, so the pack itself
+ * stays unread until the response streams.
+ * @param {string} did
+ * @param {import('./record.js').BundleEntry} bundle
+ * @returns {Promise<{packStart: number, count: number}>}
+ */
+ async function readPackHeader(did, bundle) {
+ const total = bundleLength(bundle);
+ /** @type {Uint8Array[]} */
+ const head = [];
+ let read = 0;
+ while (read < total) {
+ const end = Math.min(total, read + BUNDLE_HEAD_READ);
+ for await (const chunk of bundleBytes(did, bundle, read, end)) {
+ head.push(chunk);
+ }
+ read = end;
+ const bytes = concatChunks(head);
+ const packStart = packOffset(bytes);
+ if (packStart !== -1 && packStart + 12 <= bytes.length) {
+ return {
+ packStart,
+ count: parsePackHeader(bytes.subarray(packStart, packStart + 12)),
+ };
+ }
+ }
+ throw new Error('bundle carries no packfile');
+ }
+
+ /**
+ * @param {string} did
+ * @param {import('./record.js').BundleEntry} bundle
+ * @returns {Promise}
+ */
+ async function bundlePackSource(did, bundle) {
+ const total = bundleLength(bundle);
+ const { packStart, count } = await readPackHeader(did, bundle);
+ return {
+ count,
+ length: total - packStart,
+ read: (start, end) =>
+ bundleBytes(did, bundle, packStart + start, packStart + end),
+ };
}
/**
@@ -301,28 +468,15 @@ export function createGitHttp(ctx) {
// An empty suffix is still a valid fetch: a refs-only update (branch or
// tag created on objects the client already has) needs no objects sent.
const bundles = neededBundles(repo, haves);
- /** @type {Uint8Array[]} */
- const packs = [];
+ // The pack headers are read before the response starts, so a bundle
+ // this server cannot read is still a status code rather than a body
+ // that stops halfway.
+ /** @type {import('./pack.js').PackSource[]} */
+ const sources = [];
for (const bundle of bundles) {
- packs.push(await fetchBundlePack(did, bundle));
+ sources.push(await bundlePackSource(did, bundle));
}
- const pack = bundles.length === 0 ? emptyPack() : mergePacks(packs);
-
- /** @type {Uint8Array[]} */
- const parts = [pktLine('NAK\n')];
- if (sideBand) {
- for (let offset = 0; offset < pack.length; offset += SIDE_BAND_MAX) {
- const slice = pack.subarray(offset, offset + SIDE_BAND_MAX);
- const framed = new Uint8Array(slice.length + 1);
- framed[0] = 1;
- framed.set(slice, 1);
- parts.push(pktLine(framed));
- }
- parts.push(FLUSH);
- } else {
- parts.push(pack);
- }
- return new Response(/** @type {BodyInit} */ (concatBytes(parts)), {
+ return new Response(toStream(uploadPackBody(sources, sideBand)), {
headers: {
'Content-Type': `application/x-${UPLOAD_PACK}-result`,
'Cache-Control': 'no-cache',
diff --git a/packages/git/src/pack.js b/packages/git/src/pack.js
index 2474a3e..deadae2 100644
--- a/packages/git/src/pack.js
+++ b/packages/git/src/pack.js
@@ -25,16 +25,38 @@ const PACK_MAGIC = 0x5041434b;
*/
export function splitPack(pack) {
if (pack.length < 32) throw new Error('pack too short');
- const view = new DataView(pack.buffer, pack.byteOffset, pack.byteLength);
+ return {
+ count: parsePackHeader(pack.subarray(0, 12)),
+ entries: pack.subarray(12, pack.length - 20),
+ };
+}
+
+/**
+ * @param {Uint8Array} header - a packfile's first 12 bytes
+ * @returns {number} the object count
+ */
+export function parsePackHeader(header) {
+ if (header.length < 12) throw new Error('pack header too short');
+ const view = new DataView(header.buffer, header.byteOffset, 12);
if (view.getUint32(0) !== PACK_MAGIC) throw new Error('bad pack magic');
const packVersion = view.getUint32(4);
if (packVersion !== 2 && packVersion !== 3) {
throw new Error(`unsupported pack version ${packVersion}`);
}
- return {
- count: view.getUint32(8),
- entries: pack.subarray(12, pack.length - 20),
- };
+ return view.getUint32(8);
+}
+
+/**
+ * @param {number} count - objects the pack holds
+ * @returns {Uint8Array} a 12-byte packfile header
+ */
+function packHeader(count) {
+ const header = new Uint8Array(12);
+ const view = new DataView(header.buffer);
+ view.setUint32(0, PACK_MAGIC);
+ view.setUint32(4, 2);
+ view.setUint32(8, count);
+ return header;
}
/**
@@ -44,10 +66,7 @@ export function splitPack(pack) {
*/
export function emptyPack() {
const out = new Uint8Array(32);
- const view = new DataView(out.buffer);
- view.setUint32(0, PACK_MAGIC);
- view.setUint32(4, 2);
- view.setUint32(8, 0);
+ out.set(packHeader(0), 0);
out.set(new Sha1().update(out.subarray(0, 12)).digest(), 12);
return out;
}
@@ -62,13 +81,7 @@ export function mergePacks(packs) {
if (packs.length === 1) return packs[0];
if (packs.length === 0) return emptyPack();
const parts = packs.map(splitPack);
- const count = parts.reduce((sum, p) => sum + p.count, 0);
- const header = new Uint8Array(12);
- const view = new DataView(header.buffer);
- view.setUint32(0, PACK_MAGIC);
- view.setUint32(4, 2);
- view.setUint32(8, count);
-
+ const header = packHeader(parts.reduce((sum, p) => sum + p.count, 0));
const total = 12 + parts.reduce((sum, p) => sum + p.entries.length, 0) + 20;
const out = new Uint8Array(total);
out.set(header, 0);
@@ -81,3 +94,43 @@ export function mergePacks(packs) {
out.set(sha, offset);
return out;
}
+
+/**
+ * A packfile the merge reads in ranges instead of holding.
+ * @typedef {Object} PackSource
+ * @property {number} count - objects the pack holds
+ * @property {number} length - complete packfile length in bytes
+ * @property {(start: number, end: number) => AsyncIterable} read -
+ * pack bytes in [start, end), in order
+ */
+
+/**
+ * The bytes mergePacks returns for the same packs, yielded in order and
+ * without holding any source whole. Peak memory is one source read. Pass
+ * the sources oldest first, as mergePacks wants them.
+ * @param {PackSource[]} sources
+ * @returns {AsyncGenerator}
+ */
+export async function* mergePackStream(sources) {
+ if (sources.length === 0) {
+ yield emptyPack();
+ return;
+ }
+ // A single pack is already its own merge, trailer included, so it passes
+ // through without a rehash.
+ if (sources.length === 1) {
+ yield* sources[0].read(0, sources[0].length);
+ return;
+ }
+ const header = packHeader(sources.reduce((sum, s) => sum + s.count, 0));
+ const sha = new Sha1().update(header);
+ yield header;
+ for (const source of sources) {
+ for await (const chunk of source.read(12, source.length - 20)) {
+ if (chunk.length === 0) continue;
+ sha.update(chunk);
+ yield chunk;
+ }
+ }
+ yield sha.digest();
+}
diff --git a/packages/git/test/http.test.js b/packages/git/test/http.test.js
index 2f0ea7f..b65efb3 100644
--- a/packages/git/test/http.test.js
+++ b/packages/git/test/http.test.js
@@ -24,7 +24,9 @@ import { defineLexicon } from '@bigmoves/lexicon';
import { LexiconResolver } from '@pdsjs/lexicon-resolver';
import { createServer } from '@pdsjs/node';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { createGitHttp } from '../src/http.js';
import { gitRepoLexicon } from '../src/index.js';
+import { concatBytes, FLUSH, parsePkts, pktLine, pktText } from '../src/pkt.js';
const execFileAsync = promisify(execFile);
@@ -229,6 +231,55 @@ describe('git smart HTTP e2e', () => {
});
});
+describe('upload-pack failures', () => {
+ it('reports a blob lost mid-stream on the error band', async () => {
+ const head = (await runGit(srcDir, ['rev-parse', 'main'])).trim();
+ // Every blob is served once and is gone after that, the way orphan
+ // cleanup can take a chunk between the header read and the body.
+ /** @type {Set} */
+ const served = new Set();
+ const git = createGitHttp({
+ xrpc: async (request) => {
+ const url = new URL(request.url);
+ const cid = url.searchParams.get('cid');
+ if (url.pathname.endsWith('getBlob') && cid) {
+ if (served.has(cid)) return new Response('gone', { status: 404 });
+ served.add(cid);
+ }
+ return fetch(`${BASE}${url.pathname}${url.search}`, {
+ headers: request.headers,
+ });
+ },
+ });
+
+ const res = await git.handle(
+ new Request(`http://pds.internal/git/${DID}/webrepo/git-upload-pack`, {
+ method: 'POST',
+ body: /** @type {BodyInit} */ (
+ concatBytes([
+ pktLine(`want ${head} side-band-64k\n`),
+ FLUSH,
+ pktLine('done\n'),
+ ])
+ ),
+ }),
+ );
+
+ // The header is out before the read fails, so the failure rides the
+ // error band rather than a status code.
+ expect(res?.status).toBe(200);
+ const pkts = parsePkts(
+ new Uint8Array(await /** @type {Response} */ (res).arrayBuffer()),
+ );
+ expect(pktText(/** @type {{data: Uint8Array}} */ (pkts[0]).data)).toBe(
+ 'NAK',
+ );
+ const last = /** @type {{data: Uint8Array}} */ (pkts[pkts.length - 1]).data;
+ expect(last[0]).toBe(3);
+ expect(pktText(last.subarray(1))).toMatch(/unavailable/);
+ });
+});
+
describe('raw files', () => {
/** @param {string} path */
const raw = (path) => fetch(`${HTTP_URL}/raw/${path}`);
diff --git a/packages/git/test/wire.test.js b/packages/git/test/wire.test.js
index 3d69f62..6d64142 100644
--- a/packages/git/test/wire.test.js
+++ b/packages/git/test/wire.test.js
@@ -12,7 +12,12 @@ import { join } from 'node:path';
import { promisify } from 'node:util';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { parseBundle } from '../src/bundle.js';
-import { emptyPack, mergePacks, splitPack } from '../src/pack.js';
+import {
+ emptyPack,
+ mergePackStream,
+ mergePacks,
+ splitPack,
+} from '../src/pack.js';
import { concatBytes, FLUSH, parsePkts, pktLine, pktText } from '../src/pkt.js';
import { Sha1 } from '../src/sha1.js';
@@ -146,6 +151,64 @@ describe('pack merging against real git', () => {
await runGit(target, ['rev-list', '--objects', shaTwo]);
});
+ it('streams the same bytes it buffers, holding one read at a time', async () => {
+ const packs = [
+ parseBundle(readFileSync(join(dir, 'b1.bundle'))).pack,
+ parseBundle(readFileSync(join(dir, 'b2.bundle'))).pack,
+ ];
+ const READ = 512;
+ let largestRead = 0;
+ const sources = packs.map((pack) => ({
+ count: splitPack(pack).count,
+ length: pack.length,
+ /**
+ * @param {number} start
+ * @param {number} end
+ */
+ read: async function* (start, end) {
+ for (let offset = start; offset < end; offset += READ) {
+ const slice = pack.subarray(offset, Math.min(end, offset + READ));
+ largestRead = Math.max(largestRead, slice.length);
+ yield slice;
+ }
+ },
+ }));
+
+ /** @type {Uint8Array[]} */
+ const chunks = [];
+ for await (const chunk of mergePackStream(sources)) chunks.push(chunk);
+ expect(concatBytes(chunks)).toEqual(mergePacks(packs));
+ expect(largestRead).toBe(READ);
+ });
+
+ it('streams a lone pack through unchanged', async () => {
+ const pack = parseBundle(readFileSync(join(dir, 'b1.bundle'))).pack;
+ /** @type {Uint8Array[]} */
+ const chunks = [];
+ const stream = mergePackStream([
+ {
+ count: splitPack(pack).count,
+ length: pack.length,
+ /**
+ * @param {number} start
+ * @param {number} end
+ */
+ read: async function* (start, end) {
+ yield pack.subarray(start, end);
+ },
+ },
+ ]);
+ for await (const chunk of stream) chunks.push(chunk);
+ expect(concatBytes(chunks)).toEqual(new Uint8Array(pack));
+ });
+
+ it('streams an empty pack for an empty source list', async () => {
+ /** @type {Uint8Array[]} */
+ const chunks = [];
+ for await (const chunk of mergePackStream([])) chunks.push(chunk);
+ expect(concatBytes(chunks)).toEqual(emptyPack());
+ });
+
it('produces an empty pack git accepts', async () => {
const target = join(dir, 'empty-target');
await runGit(dir, ['init', '-b', 'main', target]);
--
2.51.2
From aeb61f9cff0438261390920a1f9e14b50de31855 Mon Sep 17 00:00:00 2001
From: Chad Miller
Date: Sun, 23 Aug 2026 22:29:55 -0700
Subject: [PATCH 07/13] feat(git): read a space's bundles in ranges
The space reader served whole blobs only, so the account page downloaded a
private repository's whole bundle chain to list a directory, while a public
one read single objects out of the chain.
SpaceReader takes an optional getBlobRange, and both platform adapters
supply it wherever the blob store reads ranges. A space repository whose
bundles all carry an object index now reads lazily, as a public one does. A
reader without ranges still reads the chain whole.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01Gp5VgVe393AeJL5jKARqR3
---
.changeset/git-space-ranged-reads.md | 14 ++++++
packages/cloudflare/src/index.js | 17 ++++++++
packages/git/src/browser.js | 62 +++++++++++++++++++--------
packages/git/test/browser.test.js | 64 ++++++++++++++++++++++++++++
packages/node/src/index.js | 16 +++++++
5 files changed, 156 insertions(+), 17 deletions(-)
create mode 100644 .changeset/git-space-ranged-reads.md
diff --git a/.changeset/git-space-ranged-reads.md b/.changeset/git-space-ranged-reads.md
new file mode 100644
index 0000000..a2c4088
--- /dev/null
+++ b/.changeset/git-space-ranged-reads.md
@@ -0,0 +1,14 @@
+---
+'@pdsjs/git': patch
+'@pdsjs/node': patch
+'@pdsjs/cloudflare': patch
+---
+
+Repositories in a permissioned space browse with ranged reads. The space
+reader served whole blobs only, so the account page downloaded a private
+repository's whole bundle chain to list a directory, while a public one read
+single objects out of the chain. `SpaceReader` takes an optional
+`getBlobRange`, and both platform adapters supply it wherever the blob store
+reads ranges. A space repository whose bundles all carry an object index now
+reads lazily, as a public one does; a reader without ranges still reads the
+chain whole.
diff --git a/packages/cloudflare/src/index.js b/packages/cloudflare/src/index.js
index ca40b68..40ccdde 100644
--- a/packages/cloudflare/src/index.js
+++ b/packages/cloudflare/src/index.js
@@ -1370,6 +1370,23 @@ export class PDSDurableObject {
/** @type {ArrayBuffer} */ (result.data),
);
},
+ // A store that reads ranges lets the browser read a
+ // space repository lazily. getStream takes inclusive
+ // positions.
+ getBlobRange: blobs.getStream
+ ? async (cid, start, end) => {
+ const did = await actorStorage.getDid();
+ if (!did) return null;
+ const found = await blobs.getStream?.(did, cid, {
+ start,
+ end: end - 1,
+ });
+ if (!found) return null;
+ return new Uint8Array(
+ await new Response(found.body).arrayBuffer(),
+ );
+ }
+ : undefined,
}
: undefined,
})
diff --git a/packages/git/src/browser.js b/packages/git/src/browser.js
index ab40763..88ffd7f 100644
--- a/packages/git/src/browser.js
+++ b/packages/git/src/browser.js
@@ -67,6 +67,10 @@ const IMAGE_MEDIA_TYPES = {
* @typedef {Object} SpaceReader
* @property {(space: string, rkey: string) => Promise<{cid: string, value: unknown}|null>} getRecord
* @property {(cid: string) => Promise} getBlob
+ * @property {(cid: string, start: number, end: number) => Promise} [getBlobRange] -
+ * Optional: one byte range of a blob, end exclusive. A reader that
+ * answers ranges lets a space repository read lazily, as a public one
+ * does. Absent, the chain is read whole.
*/
/**
@@ -625,23 +629,46 @@ export function createGitBrowser(ctx) {
}
/**
- * Download the whole chain and unpack it in memory.
+ * Blob reads for one open. A public repository reads through the sync
+ * endpoints; a space repository reads its own storage through the space
+ * reader, which answers ranges only when the platform gave it a
+ * getBlobRange.
* @param {string} did
- * @param {import('./record.js').GitRepoRecord} repo
* @param {string|undefined} space
- * @returns {Promise}
+ * @returns {{read: (cid: string) => Promise, readRange: ((cid: string, start: number, end: number) => Promise)|null}}
*/
- async function openEager(did, repo, space) {
- /** @param {string} cid */
- async function fetchPart(cid) {
- if (space && ctx.spaceReader) {
- const bytes = await ctx.spaceReader.getBlob(cid);
+ function blobReaders(did, space) {
+ const reader = ctx.spaceReader;
+ if (!space || !reader) {
+ return {
+ read: (cid) => fetchBlob(did, cid),
+ readRange: (cid, start, end) => fetchBlobRange(did, cid, start, end),
+ };
+ }
+ const getBlobRange = reader.getBlobRange;
+ return {
+ read: async (cid) => {
+ const bytes = await reader.getBlob(cid);
if (!bytes) throw new Error(`bundle blob ${cid} unavailable`);
return bytes;
- }
- return fetchBlob(did, cid);
- }
+ },
+ readRange: getBlobRange
+ ? async (cid, start, end) => {
+ const bytes = await getBlobRange(cid, start, end);
+ if (!bytes) throw new Error(`bundle blob ${cid} unavailable`);
+ return bytes;
+ }
+ : null,
+ };
+ }
+ /**
+ * Download the whole chain and unpack it in memory.
+ * @param {import('./record.js').GitRepoRecord} repo
+ * @param {(cid: string) => Promise} fetchPart
+ * @returns {Promise}
+ */
+ async function openEager(repo, fetchPart) {
// Every chunk of every bundle at once. They are independent reads, and
// asking for them one after another spends a round trip on each.
const total = repo.bundles.reduce(
@@ -699,10 +726,11 @@ export function createGitBrowser(ctx) {
return cached;
}
- // The space reader serves whole blobs only, so a space repository
- // reads eagerly whatever its record carries.
+ const { read: readBlob, readRange } = blobReaders(did, space);
+ // Ranged reads need an index for every bundle, and a reader that
+ // answers ranges.
const lazy =
- !space &&
+ readRange !== null &&
repo.bundles.length > 0 &&
repo.bundles.every((bundle) => bundle.index);
/** @type {ObjectStore|null} */
@@ -718,12 +746,12 @@ export function createGitBrowser(ctx) {
store = await createLazyStore(
repo,
async (cid) => {
- const bytes = await fetchBlob(did, cid);
+ const bytes = await readBlob(cid);
loaded += bytes.length;
ctx.onProgress?.({ loaded, total });
return bytes;
},
- (cid, start, end) => fetchBlobRange(did, cid, start, end),
+ readRange,
);
} catch {
// An index blob that is missing or unreadable costs laziness, not
@@ -731,7 +759,7 @@ export function createGitBrowser(ctx) {
store = null;
}
}
- if (!store) store = await openEager(did, repo, space);
+ if (!store) store = await openEager(repo, readBlob);
cached = { cid: body.cid, repo, store };
return cached;
}
diff --git a/packages/git/test/browser.test.js b/packages/git/test/browser.test.js
index d6149b2..3e00552 100644
--- a/packages/git/test/browser.test.js
+++ b/packages/git/test/browser.test.js
@@ -759,6 +759,7 @@ describe.each(/** @type {const} */ (['eager', 'lazy']))(
describe('lazy reader efficiency', () => {
const DID = 'did:plc:lazytest';
+ const SPACE = 'at://did:plc:lazytest/com.atproto.simplespace.space/team';
/** @type {string} */
let bigDir = '';
/** @type {string} */
@@ -827,6 +828,69 @@ describe('lazy reader efficiency', () => {
expect(stats.bytes).toBeLessThan(bundleBytes.length / 4);
});
+ /**
+ * The account page reads a space repository straight from storage, so
+ * these stand in for the platform's own blob access.
+ * @param {{bytes: number}} stats
+ * @param {{ranges?: boolean}} [opts] - ranges: false is a reader that
+ * answers whole blobs only
+ * @returns {import('../src/browser.js').SpaceReader}
+ */
+ function fakeSpaceReader(stats, opts = {}) {
+ return {
+ getRecord: async () => ({ cid: 'bafyrecordcid', value: record }),
+ getBlob: async (cid) => {
+ const blob = blobs.get(cid) ?? null;
+ if (blob) stats.bytes += blob.length;
+ return blob;
+ },
+ getBlobRange:
+ opts.ranges === false
+ ? undefined
+ : async (cid, start, end) => {
+ const blob = blobs.get(cid);
+ if (!blob) return null;
+ const slice = blob.subarray(start, Math.min(end, blob.length));
+ stats.bytes += slice.length;
+ return slice;
+ },
+ };
+ }
+
+ it('reads a space repository for a fraction of the chain', async () => {
+ const stats = { bytes: 0 };
+ const browser = createGitBrowser({
+ xrpc: fakePds(record, blobs, { bytes: 0 }),
+ spaceReader: fakeSpaceReader(stats),
+ });
+ const file = await browser.readFile(
+ DID,
+ 'testrepo',
+ 'main',
+ 'README.md',
+ SPACE,
+ );
+ expect(file?.text).toBe('# lazy test\n');
+ expect(stats.bytes).toBeLessThan(bundleBytes.length / 4);
+ });
+
+ it('reads a space repository whole when its reader has no ranges', async () => {
+ const stats = { bytes: 0 };
+ const browser = createGitBrowser({
+ xrpc: fakePds(record, blobs, { bytes: 0 }),
+ spaceReader: fakeSpaceReader(stats, { ranges: false }),
+ });
+ const file = await browser.readFile(
+ DID,
+ 'testrepo',
+ 'main',
+ 'README.md',
+ SPACE,
+ );
+ expect(file?.text).toBe('# lazy test\n');
+ expect(stats.bytes).toBeGreaterThanOrEqual(bundleBytes.length);
+ });
+
it('still reads the large file whole when asked', async () => {
const browser = createGitBrowser({
xrpc: fakePds(record, blobs, { bytes: 0 }),
diff --git a/packages/node/src/index.js b/packages/node/src/index.js
index 43e4297..9f478bd 100644
--- a/packages/node/src/index.js
+++ b/packages/node/src/index.js
@@ -616,6 +616,22 @@ export async function createServer({
? result.data
: new Uint8Array(/** @type {ArrayBuffer} */ (result.data));
},
+ // A store that reads ranges lets the browser read a space
+ // repository lazily. getStream takes inclusive positions.
+ getBlobRange: blobs.getStream
+ ? async (cid, start, end) => {
+ const did = await actorStorage.getDid();
+ if (!did) return null;
+ const found = await blobs.getStream?.(did, cid, {
+ start,
+ end: end - 1,
+ });
+ if (!found) return null;
+ return new Uint8Array(
+ await new Response(found.body).arrayBuffer(),
+ );
+ }
+ : undefined,
}
: undefined,
});
--
2.51.2
From 2c68bdce9c9f3f8c2d27ece09462b85177cf39ad Mon Sep 17 00:00:00 2001
From: Chad Miller
Date: Sun, 23 Aug 2026 22:30:03 -0700
Subject: [PATCH 08/13] feat(git): name the commit that last changed a path
lastCommit(did, repo, ref, path) answers which commit last changed one
path, so a repository page can name the change a reader is looking at
rather than only the change at the tip.
The walk reads trees rather than diffs and remembers what it finds, so a
path untouched for a hundred commits resolves to the same object in all of
them and the answer costs a handful of reads.
A commit counts as changing the path only when what stands there differs
from every one of its parents. Matching a parent means the walk carries on
down that parent, which credits a merge's change to the branch it came from
rather than to the merge, the same answer `git log -- path` gives. The
tests check every path against real git, including across a merge.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01Gp5VgVe393AeJL5jKARqR3
---
.changeset/git-last-commit-for-path.md | 15 +++
packages/git/src/browser.js | 135 +++++++++++++++++++++++++
packages/git/test/browser.test.js | 95 +++++++++++++++++
3 files changed, 245 insertions(+)
create mode 100644 .changeset/git-last-commit-for-path.md
diff --git a/.changeset/git-last-commit-for-path.md b/.changeset/git-last-commit-for-path.md
new file mode 100644
index 0000000..5e5b392
--- /dev/null
+++ b/.changeset/git-last-commit-for-path.md
@@ -0,0 +1,15 @@
+---
+'@pdsjs/git': patch
+---
+
+The browser reader answers which commit last changed one path, with
+`lastCommit(did, repo, ref, path)`. A repository page can then name the
+change a reader is looking at rather than only the change at the tip.
+
+The walk reads trees rather than diffs and remembers what it finds, so a
+path untouched for a hundred commits resolves to the same object in all of
+them and the answer costs a handful of reads. A commit counts as changing
+the path only when what stands there differs from every one of its parents;
+matching a parent means the walk carries on down that parent, which is what
+credits a merge's change to the branch it came from rather than to the
+merge, the same answer `git log -- path` gives.
diff --git a/packages/git/src/browser.js b/packages/git/src/browser.js
index 88ffd7f..a888517 100644
--- a/packages/git/src/browser.js
+++ b/packages/git/src/browser.js
@@ -1010,6 +1010,141 @@ export function createGitBrowser(ctx) {
return { ref: refEntry.name, commits, truncated: frontier.length > 0 };
},
+ /**
+ * The last commit to change one path, walking back from a ref. A commit
+ * changed the path when what stands there differs from what stood there
+ * in every one of its parents. A commit that matches one of them changed
+ * nothing, and the walk carries on down that parent, which is what
+ * credits a merge's change to the branch it came from.
+ *
+ * The walk reads trees rather than diffs, and remembers what it finds:
+ * a path untouched for a hundred commits resolves to the same object in
+ * all of them, so the answer costs a handful of reads however far back
+ * the change was made.
+ * @param {string} did
+ * @param {string} repoName
+ * @param {string} [ref]
+ * @param {string} [path]
+ * @param {number} [limit] - commits to walk before giving up
+ * @param {string} [space]
+ * @returns {Promise}
+ * null where nothing in the walk touched the path
+ */
+ async lastCommit(did, repoName, ref, path = '', limit = 4000, space) {
+ const opened = await open(did, repoName, space);
+ if (!opened) return null;
+ const refEntry = findRef(opened.repo, ref);
+ if (!refEntry) throw new Error(`no such ref: ${ref}`);
+ await opened.store.prefetchCommits();
+
+ const segments = path.split('/').filter(Boolean);
+ /** What stands at the path in one commit, by the commit's own id. */
+ /** @type {Map} */
+ const standing = new Map();
+ /** Entries by tree, so a tree shared by many commits is read once. */
+ /** @type {Map} */
+ const parsed = new Map();
+
+ const entriesOf = (/** @type {string} */ treeSha) => {
+ let entries = parsed.get(treeSha);
+ if (!entries) {
+ const object = opened.store.peek(treeSha);
+ entries = object?.type === 'tree' ? parseTree(object.data) : [];
+ parsed.set(treeSha, entries);
+ }
+ return entries;
+ };
+
+ /**
+ * Resolve the path for a run of commits at once, a level of the path
+ * at a time. Every commit's root tree is read in one coalesced batch,
+ * then every directory below it, so the walk costs a read per level
+ * rather than a read per commit per level.
+ * @param {string[]} commitShas
+ */
+ const resolve = async (commitShas) => {
+ const wanted = commitShas.filter((sha) => !standing.has(sha));
+ if (wanted.length === 0) return;
+ await opened.store.getMany(wanted);
+ /** Where each commit has got to, as the walk descends. */
+ /** @type {Map} */
+ let here = new Map();
+ for (const sha of wanted) {
+ const object = opened.store.peek(sha);
+ if (object?.type === 'commit') here.set(sha, commitTree(object.data));
+ else standing.set(sha, null);
+ }
+ for (const segment of segments) {
+ await opened.store.getMany(new Set(here.values()));
+ /** @type {Map} */
+ const next = new Map();
+ for (const [sha, treeSha] of here) {
+ const found = entriesOf(treeSha).find((e) => e.name === segment);
+ if (found) next.set(sha, found.sha);
+ else standing.set(sha, null);
+ }
+ here = next;
+ }
+ for (const [sha, found] of here) standing.set(sha, found);
+ };
+
+ /** How many commits to look ahead down the first parents at a time. */
+ const RUN = 64;
+
+ let current = await peelToCommit(opened.store, refEntry.sha);
+ let walked = 0;
+ while (walked < limit) {
+ // The commits are all in memory, so the run costs no reads to
+ // collect; only resolving the path for it does.
+ /** @type {string[]} */
+ const run = [];
+ let cursor = current;
+ while (cursor && run.length < RUN) {
+ run.push(cursor);
+ const object =
+ opened.store.peek(cursor) ?? (await opened.store.get(cursor));
+ if (object?.type !== 'commit') break;
+ cursor = parseCommit(object.data).parents[0] ?? '';
+ }
+ await resolve(cursor ? [...run, cursor] : run);
+
+ let redirected = false;
+ for (let index = 0; index < run.length && walked < limit; index++) {
+ const sha = run[index];
+ walked++;
+ const object = opened.store.peek(sha);
+ if (object?.type !== 'commit') return null;
+ const commit = parseCommit(object.data);
+ const here = standing.get(sha) ?? null;
+ if (here === null) return null;
+
+ // A commit that leaves the path as one of its parents left it did
+ // not change it, and the walk carries on down that parent. Only a
+ // commit that differs from all of them made the change. That is
+ // what credits a merge's change to the commit on the branch it
+ // came from rather than to the merge.
+ let unchanged = null;
+ for (const parent of commit.parents) {
+ if (!standing.has(parent)) await resolve([parent]);
+ if (standing.get(parent) === here) {
+ unchanged = parent;
+ break;
+ }
+ }
+ if (unchanged === null) return { ...commit, sha };
+ current = unchanged;
+ // A merge sent the walk down a parent this run does not hold, so
+ // the next run starts from there.
+ if (unchanged !== run[index + 1]) {
+ redirected = true;
+ break;
+ }
+ }
+ if (!redirected && run.length < RUN) return null;
+ }
+ return null;
+ },
+
/**
* One commit: its metadata and the files it changed, each with unified
* diff hunks. The comparison is against the first parent, which is how
diff --git a/packages/git/test/browser.test.js b/packages/git/test/browser.test.js
index 3e00552..b3ec099 100644
--- a/packages/git/test/browser.test.js
+++ b/packages/git/test/browser.test.js
@@ -900,3 +900,98 @@ describe('lazy reader efficiency', () => {
expect(bytes?.binary).toBe(true);
});
});
+
+describe('the commit that last changed a path', () => {
+ const DID = 'did:plc:lastcommit';
+ /** @type {string} */
+ let workDir = '';
+ /** @type {string} */
+ let workRepo = '';
+ /** @type {{record: Record, blobs: Map}} */
+ let fixture = { record: {}, blobs: new Map() };
+
+ beforeAll(async () => {
+ workDir = mkdtempSync(join(tmpdir(), 'pdsjs-git-lastcommit-'));
+ workRepo = join(workDir, 'repo');
+ await runGit(workDir, ['init', '-b', 'main', workRepo]);
+
+ mkdirSync(join(workRepo, 'dir'));
+ writeFileSync(join(workRepo, 'a.txt'), 'a one\n');
+ writeFileSync(join(workRepo, 'b.txt'), 'b one\n');
+ writeFileSync(join(workRepo, 'dir', 'c.txt'), 'c one\n');
+ await runGit(workRepo, ['add', '.']);
+ await runGit(workRepo, ['commit', '-m', 'base']);
+
+ // A branch changes one file while the trunk changes another, so the
+ // merge that follows differs from each parent in a different path.
+ await runGit(workRepo, ['checkout', '-b', 'topic']);
+ writeFileSync(join(workRepo, 'b.txt'), 'b from topic\n');
+ await runGit(workRepo, ['commit', '-am', 'change b on topic']);
+ await runGit(workRepo, ['checkout', 'main']);
+ writeFileSync(join(workRepo, 'a.txt'), 'a from main\n');
+ await runGit(workRepo, ['commit', '-am', 'change a on main']);
+ await runGit(workRepo, [
+ 'merge',
+ '--no-ff',
+ 'topic',
+ '-m',
+ 'merge topic into main',
+ ]);
+
+ const head = await runGit(workRepo, ['rev-parse', 'HEAD']);
+ await runGit(workRepo, [
+ 'bundle',
+ 'create',
+ join(workDir, 'merge.bundle'),
+ 'main',
+ ]);
+ fixture = await chainFixture(
+ 'lazy',
+ [new Uint8Array(readFileSync(join(workDir, 'merge.bundle')))],
+ head,
+ );
+ });
+
+ afterAll(() => {
+ rmSync(workDir, { recursive: true, force: true });
+ });
+
+ const browserOf = () =>
+ createGitBrowser({
+ xrpc: fakePds(fixture.record, fixture.blobs, { bytes: 0 }),
+ });
+
+ it('names the commit git names, for every path', async () => {
+ const browser = browserOf();
+ for (const path of ['a.txt', 'b.txt', 'dir/c.txt']) {
+ const wanted = await runGit(workRepo, [
+ 'log',
+ '-1',
+ '--format=%H',
+ '--',
+ path,
+ ]);
+ const answer = await browser.lastCommit(DID, 'testrepo', 'main', path);
+ expect(answer?.sha, path).toBe(wanted);
+ }
+ });
+
+ it('credits the branch a merge took a change from, not the merge', async () => {
+ const browser = browserOf();
+ const merge = await runGit(workRepo, ['rev-parse', 'HEAD']);
+ const answer = await browser.lastCommit(DID, 'testrepo', 'main', 'b.txt');
+ expect(answer?.sha).not.toBe(merge);
+ expect(answer?.message).toContain('change b on topic');
+ });
+
+ it('answers null for a path no commit holds', async () => {
+ const browser = browserOf();
+ const answer = await browser.lastCommit(
+ DID,
+ 'testrepo',
+ 'main',
+ 'gone.txt',
+ );
+ expect(answer).toBeNull();
+ });
+});
--
2.51.2
From 288063adb25635232cad7c2d5316b64ccd0831d5 Mon Sep 17 00:00:00 2001
From: Chad Miller
Date: Sun, 23 Aug 2026 22:30:10 -0700
Subject: [PATCH 09/13] feat(git): credit a commit's co-authors
coAuthorsOf reads the Co-authored-by trailers a message ends with,
returning each identity once in the order the message first gives it. Only
the last paragraph counts, which is where git itself looks, so a trailer
quoted in a revert credits nobody.
A claimed identity may also carry a label and an avatar, for an author with
no atproto account to name it: a tool, a bot, or a person who keeps none.
parseIdentityRecord carries both, and a did still wins where one is given,
since that account's own profile is the truer answer.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01Gp5VgVe393AeJL5jKARqR3
---
.changeset/git-co-authors.md | 14 +++++
packages/git/src/authors.js | 44 +++++++++++++++-
packages/git/src/lexicon.js | 13 +++++
packages/git/test/authors.test.js | 85 +++++++++++++++++++++++++++++--
4 files changed, 150 insertions(+), 6 deletions(-)
create mode 100644 .changeset/git-co-authors.md
diff --git a/.changeset/git-co-authors.md b/.changeset/git-co-authors.md
new file mode 100644
index 0000000..f022615
--- /dev/null
+++ b/.changeset/git-co-authors.md
@@ -0,0 +1,14 @@
+---
+'@pdsjs/git': patch
+---
+
+A commit credits everyone who wrote it. `coAuthorsOf` reads the
+`Co-authored-by` trailers a message ends with, returning each identity once
+in the order the message first gives it. Only the last paragraph counts,
+which is where git itself looks, so a trailer quoted in a revert credits
+nobody.
+
+A claimed identity may also carry a `label` and an `avatar`, for an author
+with no atproto account to name it: a tool, a bot, or a person who keeps
+none. `parseIdentityRecord` carries both, and a `did` still wins where one is
+given, since that account's own profile is the truer answer.
diff --git a/packages/git/src/authors.js b/packages/git/src/authors.js
index 205f1c4..feadf21 100644
--- a/packages/git/src/authors.js
+++ b/packages/git/src/authors.js
@@ -22,6 +22,8 @@
* @property {string} email
* @property {string} did - whose identity this is, empty for the account
* holding the record
+ * @property {string} label - what to call an identity with no account
+ * @property {string} avatarCid - the face to show for a labelled identity
*/
/**
@@ -36,6 +38,41 @@ export function splitIdent(ident) {
return { name: match[1].trim(), email: match[2].trim() };
}
+/**
+ * The people a commit message credits beside its author.
+ *
+ * Git carries them as `Co-authored-by:` trailers, one per line, in the same
+ * "Name " shape the author ident uses. A message may name the same
+ * person twice, or name the author again; each identity is returned once, in
+ * the order the message first gives it.
+ *
+ * Only trailers in the message's last paragraph count, which is where git
+ * itself looks. A line further up quoting a trailer, in a revert or a quoted
+ * patch, credits nobody.
+ * @param {string} message - the whole commit message
+ * @returns {string[]} idents, as written
+ */
+export function coAuthorsOf(message) {
+ const text = String(message ?? '').trimEnd();
+ if (!text) return [];
+ const paragraph = text.slice(text.lastIndexOf('\n\n') + 1);
+ /** @type {string[]} */
+ const found = [];
+ const seen = new Set();
+ for (const line of paragraph.split('\n')) {
+ const match = /^\s*co-authored-by:\s*(.+)$/i.exec(line);
+ if (!match) continue;
+ const ident = match[1].trim();
+ const { name, email } = splitIdent(ident);
+ if (!name && !email) continue;
+ const key = `${name.toLowerCase()}\u0000${email.toLowerCase()}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ found.push(ident);
+ }
+ return found;
+}
+
/**
* The claims a dev.pdsjs.git.identity record carries. Anything malformed is
* dropped rather than failing the read: the record is advisory, and a reader
@@ -49,14 +86,17 @@ export function parseIdentityRecord(value) {
/** @type {IdentClaim[]} */
const idents = [];
for (const entry of list) {
- const { email, name, did } =
- /** @type {{email?: unknown, name?: unknown, did?: unknown}} */ (
+ const { email, name, did, label, avatar } =
+ /** @type {{email?: unknown, name?: unknown, did?: unknown, label?: unknown, avatar?: {ref?: {$link?: unknown}}}} */ (
entry ?? {}
);
+ const link = avatar?.ref?.$link;
const claim = {
name: typeof name === 'string' ? name.trim() : '',
email: typeof email === 'string' ? email.trim().toLowerCase() : '',
did: typeof did === 'string' && did.startsWith('did:') ? did : '',
+ label: typeof label === 'string' ? label.trim() : '',
+ avatarCid: typeof link === 'string' ? link : '',
};
if (claim.email || claim.name) idents.push(claim);
}
diff --git a/packages/git/src/lexicon.js b/packages/git/src/lexicon.js
index a215762..e4cc75c 100644
--- a/packages/git/src/lexicon.js
+++ b/packages/git/src/lexicon.js
@@ -64,6 +64,19 @@ export const gitIdentityLexicon = {
maxLength: 512,
description: 'Commit author name, compared exactly.',
},
+ label: {
+ type: 'string',
+ maxLength: 128,
+ description:
+ 'What to call this identity where it has no atproto account to name it: a tool, a bot, or a person who keeps none.',
+ },
+ avatar: {
+ type: 'blob',
+ accept: ['image/*'],
+ maxSize: 262144,
+ description:
+ 'The face to show for a labelled identity. Ignored where a did names an account, whose own profile picture is the truer answer.',
+ },
did: {
type: 'string',
format: 'did',
diff --git a/packages/git/test/authors.test.js b/packages/git/test/authors.test.js
index eac2698..b80dd99 100644
--- a/packages/git/test/authors.test.js
+++ b/packages/git/test/authors.test.js
@@ -4,6 +4,7 @@
import { describe, expect, it } from 'vitest';
import {
+ coAuthorsOf,
identMatches,
matchIdent,
parseIdentityRecord,
@@ -54,8 +55,14 @@ describe('parseIdentityRecord', () => {
],
}),
).toEqual([
- { name: '', email: 'chad@example.com', did: '' },
- { name: 'Chad Miller', email: '', did: '' },
+ {
+ name: '',
+ email: 'chad@example.com',
+ did: '',
+ label: '',
+ avatarCid: '',
+ },
+ { name: 'Chad Miller', email: '', did: '', label: '', avatarCid: '' },
]);
});
@@ -68,8 +75,42 @@ describe('parseIdentityRecord', () => {
],
}),
).toEqual([
- { name: '', email: 'them@example.com', did: 'did:plc:someone' },
- { name: '', email: 'bogus@example.com', did: '' },
+ {
+ name: '',
+ email: 'them@example.com',
+ did: 'did:plc:someone',
+ label: '',
+ avatarCid: '',
+ },
+ {
+ name: '',
+ email: 'bogus@example.com',
+ did: '',
+ label: '',
+ avatarCid: '',
+ },
+ ]);
+ });
+
+ it('carries the label and face of an identity with no account', () => {
+ expect(
+ parseIdentityRecord({
+ idents: [
+ {
+ email: 'noreply@anthropic.com',
+ label: 'Claude',
+ avatar: { $type: 'blob', ref: { $link: 'bafyface' } },
+ },
+ ],
+ }),
+ ).toEqual([
+ {
+ name: '',
+ email: 'noreply@anthropic.com',
+ did: '',
+ label: 'Claude',
+ avatarCid: 'bafyface',
+ },
]);
});
@@ -132,3 +173,39 @@ describe('identMatches', () => {
);
});
});
+
+describe('coAuthorsOf', () => {
+ it('reads the trailers a message ends with', () => {
+ expect(
+ coAuthorsOf(
+ 'feat: a thing\n\nWhy it is here.\n\n' +
+ 'Co-Authored-By: Ada \n' +
+ 'Co-authored-by: Bo \n',
+ ),
+ ).toEqual(['Ada ', 'Bo ']);
+ });
+
+ it('credits each person once, however often they are named', () => {
+ expect(
+ coAuthorsOf(
+ 'x\n\nCo-authored-by: Ada \n' +
+ 'Co-authored-by: ada \n',
+ ),
+ ).toEqual(['Ada ']);
+ });
+
+ it('reads only the last paragraph, so a quoted trailer credits nobody', () => {
+ expect(
+ coAuthorsOf(
+ 'revert\n\nThis reverts a commit that said:\n' +
+ 'Co-authored-by: Ada \n\n' +
+ 'Co-authored-by: Bo \n',
+ ),
+ ).toEqual(['Bo ']);
+ });
+
+ it('answers empty for a message with no trailers', () => {
+ expect(coAuthorsOf('just a subject')).toEqual([]);
+ expect(coAuthorsOf('')).toEqual([]);
+ });
+});
--
2.51.2
From a67a568efae8f3e229a7b31ca3635ab362ce0326 Mon Sep 17 00:00:00 2001
From: Chad Miller
Date: Sun, 23 Aug 2026 22:30:17 -0700
Subject: [PATCH 10/13] feat(sites): an account publishes a palette
dev.pdsjs.app.theme holds one palette per record key: a display name,
whether it runs light or dark, and colours by semantic name rather than by
the element they paint. A page that meets a name it does not know ignores
it, and a name left out keeps the page's own.
The record says nothing about how a page should read it. A page that has to
choose, such as one colouring a set of things apart, asks the colours: a
palette with no hue to spend answers for itself.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01Gp5VgVe393AeJL5jKARqR3
---
.changeset/app-theme-records.md | 12 +++++++++
packages/sites/src/lexicons.js | 47 +++++++++++++++++++++++++++++++++
2 files changed, 59 insertions(+)
create mode 100644 .changeset/app-theme-records.md
diff --git a/.changeset/app-theme-records.md b/.changeset/app-theme-records.md
new file mode 100644
index 0000000..dce9d29
--- /dev/null
+++ b/.changeset/app-theme-records.md
@@ -0,0 +1,12 @@
+---
+'@pdsjs/sites': patch
+---
+
+An account can publish a palette. `dev.pdsjs.app.theme` holds one per record
+key: a display name, whether it runs light or dark, and colours by semantic
+name rather than by the element they paint. A page that meets a name it does not know ignores it, and a name left
+out keeps the page's own.
+
+The record says nothing about how a page should read it. A page that has to
+choose, such as one colouring a set of things apart, asks the colours: a
+palette with no hue to spend answers for itself.
diff --git a/packages/sites/src/lexicons.js b/packages/sites/src/lexicons.js
index f60150f..3f152da 100644
--- a/packages/sites/src/lexicons.js
+++ b/packages/sites/src/lexicons.js
@@ -187,6 +187,49 @@ export const devPdsjsAppInstall = {
},
};
+/**
+ * A palette an account publishes for the pages it serves. The record key
+ * names the theme, so an account may hold several and a reader may pick
+ * between them.
+ *
+ * The colours are the semantic names a page paints with, not the elements
+ * they paint: background, foreground, key, destructive and so on. A page
+ * that knows a name it is not given falls back to its own.
+ */
+export const devPdsjsAppTheme = {
+ lexicon: 1,
+ id: 'dev.pdsjs.app.theme',
+ defs: {
+ main: {
+ type: 'record',
+ key: 'any',
+ record: {
+ type: 'object',
+ required: ['name', 'scheme', 'colors'],
+ properties: {
+ name: {
+ type: 'string',
+ maxLength: 64,
+ description: 'What to call this palette in a theme picker.',
+ },
+ scheme: {
+ type: 'string',
+ knownValues: ['light', 'dark'],
+ description:
+ 'Which way round the palette runs. A page tells the browser, so scrollbars and form controls match.',
+ },
+ colors: {
+ type: 'unknown',
+ description:
+ 'Colour per semantic name. A name the page does not know is ignored, and a name left out keeps the page default.',
+ },
+ createdAt: { type: 'string', format: 'datetime' },
+ },
+ },
+ },
+ },
+};
+
/**
* A named live view over one collection of this repo. The record key names
* the query; dev.pdsjs.query.run serves its rows.
@@ -232,9 +275,13 @@ export const SITE_COLLECTION = 'dev.pdsjs.site.deploy';
/** The collection query definitions live in. */
export const QUERY_COLLECTION = 'dev.pdsjs.query.def';
+/** The collection published palettes live in. */
+export const THEME_COLLECTION = 'dev.pdsjs.app.theme';
+
export const siteLexicons = [
devPdsjsSiteDeploy,
devPdsjsAppManifest,
devPdsjsAppInstall,
+ devPdsjsAppTheme,
devPdsjsQueryDef,
];
--
2.51.2
From 820fad89b923bacd3ac3db291657170cbdb2d241 Mon Sep 17 00:00:00 2001
From: Chad Miller
Date: Sun, 23 Aug 2026 22:30:25 -0700
Subject: [PATCH 11/13] feat(git-ui): a greyscale instrument, and themes an
account publishes
The interface reads as one instrument: a fixed app shell of three bands,
a two-pane source view, and readings set in one mono face against a
greyscale ground.
Checks group by commit, and a run opens on its own page with its steps and
log. A commit page names its co-authors as a facepile. A file names the
commit that last changed it. The repository index draws each repository's
commit activity beside how lately it was pushed.
A palette comes from dev.pdsjs.app.theme, so an account can publish its
own; ten ship built in. Linguist colours stay whatever the palette, since
they name languages rather than the interface.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01Gp5VgVe393AeJL5jKARqR3
---
packages/git-ui/src/app.jsx | 44 +-
.../git-ui/src/components/atoms/button.jsx | 4 +-
.../components/molecules/activity-ticks.jsx | 8 +-
.../components/molecules/author-facepile.jsx | 86 +++
.../src/components/molecules/author-line.jsx | 24 +-
.../src/components/molecules/breadcrumbs.jsx | 14 +-
.../components/molecules/check-disclosure.jsx | 2 +-
.../src/components/molecules/check-group.jsx | 16 +-
.../src/components/molecules/check-row.jsx | 24 +-
.../src/components/molecules/check-run.jsx | 7 +-
.../src/components/molecules/check-steps.jsx | 17 +-
.../src/components/molecules/code-menu.jsx | 4 +-
.../src/components/molecules/diff-file.jsx | 147 +++--
.../src/components/molecules/diff-tree.jsx | 4 +-
.../src/components/molecules/file-commit.jsx | 55 ++
.../src/components/molecules/language-bar.jsx | 41 +-
.../components/molecules/latest-commit.jsx | 10 +-
.../components/molecules/repo-activity.jsx | 30 +
.../src/components/molecules/repo-gauges.jsx | 40 +-
.../src/components/molecules/repo-tabs.jsx | 16 +-
.../src/components/molecules/theme-menu.jsx | 59 ++
packages/git-ui/src/lib/authors.js | 14 +
packages/git-ui/src/lib/source.js | 18 +
packages/git-ui/src/lib/theme.js | 274 +++++++++
packages/git-ui/src/main.jsx | 5 +
packages/git-ui/src/pages/checks.jsx | 2 +-
packages/git-ui/src/pages/commit.jsx | 28 +-
packages/git-ui/src/pages/commits.jsx | 9 +-
packages/git-ui/src/pages/file.jsx | 15 +-
packages/git-ui/src/pages/repos.jsx | 117 ++--
packages/git-ui/src/pages/tree.jsx | 51 +-
packages/git-ui/src/style.css | 527 +++++++++++++-----
scripts/dev-git-checks.mjs | 59 ++
33 files changed, 1437 insertions(+), 334 deletions(-)
create mode 100644 packages/git-ui/src/components/molecules/author-facepile.jsx
create mode 100644 packages/git-ui/src/components/molecules/file-commit.jsx
create mode 100644 packages/git-ui/src/components/molecules/repo-activity.jsx
create mode 100644 packages/git-ui/src/components/molecules/theme-menu.jsx
create mode 100644 packages/git-ui/src/lib/theme.js
diff --git a/packages/git-ui/src/app.jsx b/packages/git-ui/src/app.jsx
index fd7a0ad..ef94ba4 100644
--- a/packages/git-ui/src/app.jsx
+++ b/packages/git-ui/src/app.jsx
@@ -6,6 +6,7 @@ import { CodeMenu } from '#/components/molecules/code-menu.jsx';
import { RepoListSkeleton } from '#/components/molecules/repo-list-skeleton.jsx';
import { RepoPageSkeleton } from '#/components/molecules/repo-page-skeleton.jsx';
import { RepoTabs, SECTION_TITLES } from '#/components/molecules/repo-tabs.jsx';
+import { ThemeMenu } from '#/components/molecules/theme-menu.jsx';
import { initial, loadAuthors, owner } from '#/lib/authors.js';
import {
account as accountInfo,
@@ -19,6 +20,7 @@ import {
splitRefPath,
} from '#/lib/git.js';
import { NavigationProvider, useLocation } from '#/lib/navigation.jsx';
+import { loadThemes } from '#/lib/theme.js';
import { cn } from '#/lib/utils.js';
import { CheckPage } from '#/pages/check.jsx';
import { ChecksPage } from '#/pages/checks.jsx';
@@ -99,11 +101,7 @@ export function App() {
queryKey: ['account'],
queryFn: async () => {
const found = await discover();
- // The identity claims have to be in hand before a commit renders, so
- // an author does not change from a bare name to a handle under the
- // reader. They cost one request each and never change while the tab
- // is open.
- const [repos] = await Promise.all([loadRepos(), loadAuthors()]);
+ const repos = await loadRepos();
// The list screen may be showing the previous visit's rows; this
// hands it the fresh ones in place.
queryClient.setQueryData(['repos'], repos);
@@ -125,6 +123,29 @@ export function App() {
staleTime: Number.POSITIVE_INFINITY,
});
+ // The palettes the account publishes, added to the ones the page ships.
+ // A theme is decoration, so this runs beside the screen; the remembered
+ // choice is already painted below.
+ useQuery({
+ queryKey: ['themes'],
+ enabled: ready,
+ queryFn: loadThemes,
+ staleTime: Number.POSITIVE_INFINITY,
+ });
+
+ // Which atproto identity each commit author is, where the account claims
+ // one. The claims are the account's own record, but the profiles behind
+ // them belong to an appview, and nothing this page shows is worth waiting
+ // on a third party for. So this runs beside the screen rather than before
+ // it, and an author reads as the name their git client wrote until it
+ // lands.
+ useQuery({
+ queryKey: ['authors'],
+ enabled: ready,
+ queryFn: loadAuthors,
+ staleTime: Number.POSITIVE_INFINITY,
+ });
+
// The repository in view, and which of its sections. Both come from the
// path, so every page carries the same heading and the same tabs without
// passing them down.
@@ -146,7 +167,7 @@ export function App() {
what that section is looking at each get a band of their own
below, so no one bar carries three different jobs. */}