Skip to content

Compare changes

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

Open a pull request

12 commits

49 files+2359−2010/49 viewed
+1−0
7676 { label: 'Packages', slug: 'guides/packages' },
7777 { label: 'Container images', slug: 'guides/containers' },
7878 { label: 'npm', slug: 'guides/npm' },
79+ { label: 'Cargo', slug: 'guides/cargo' },
7980 { label: 'Composer', slug: 'guides/composer' },
8081 { label: 'Go modules', slug: 'guides/go' },
8182 { label: 'Secrets and variables', slug: 'guides/secrets-and-variables' },
+189−0
1+---
2+title: Cargo
3+description: Publish and add a workspace's Rust crates with Cargo, from a sparse registry of its own on g1t.sh, from your machine and from workflows.
4+---
5+
6+Every workspace has a Cargo registry of its own: a sparse index, with the
7+web API that `cargo publish`, `cargo yank` and `cargo search` use. It
8+works with Cargo 1.74 or later, private crates included.
9+
10+```text
11+sparse+https://g1t.sh/-/cargo/<workspace>/index/
12+```
13+
14+Crates from crates.io still come from crates.io; only the crates you name
15+with the workspace's registry come from g1t.
16+
17+## Set up `.cargo/config.toml`
18+
19+Name the registry in the project's `.cargo/config.toml`, or in
20+`~/.cargo/config.toml` for every project. The name you give it is the one
21+you pass to `--registry`; these pages use the workspace's slug:
22+
23+```toml
24+[registries.acme]
25+index = "sparse+https://g1t.sh/-/cargo/acme/index/"
26+credential-provider = "cargo:token"
27+```
28+
29+Cargo sends a token to a registry that asks for one only through a
30+credential provider named for it. `cargo:token` keeps the token in
31+`~/.cargo/credentials.toml`, which stays out of the project. To keep it in
32+your system's keychain instead, name `cargo:wincred` (Windows),
33+`cargo:macos-keychain` (macOS) or `cargo:libsecret` (Linux).
34+
35+Then give Cargo an [access token](https://g1t.sh/settings/tokens):
36+
37+```sh
38+cargo login --registry acme
39+```
40+
41+Cargo asks for the token and hands it to the provider. A token with full
42+access works; one with scopes needs `packages:read` to add private crates
43+and `packages:write` to publish and yank them.
44+
45+The token can also come from the environment, as
46+`CARGO_REGISTRIES_ACME_TOKEN` for a registry named `acme`, which is how
47+[workflows](#in-workflows) give it.
48+
49+## Publish
50+
51+Say in `Cargo.toml` where the crate is published and which repository it
52+comes from:
53+
54+```toml
55+[package]
56+name = "http-client"
57+version = "0.3.1"
58+edition = "2021"
59+description = "Our HTTP client"
60+license = "MIT"
61+readme = "README.md"
62+repository = "https://g1t.sh/acme/http-client"
63+publish = ["acme"]
64+```
65+
66+```sh
67+cargo publish --registry acme
68+```
69+
70+The first publish makes the crate. When its `repository` is a g1t.sh
71+repository of the same workspace, or a repository is named like the crate
72+(a crate `http_client` is also matched to a repository `http-client`), it
73+is linked to that repository and has its visibility and roles: publishing needs Write on it.
74+Otherwise it is the workspace's, private, and needs the workspace's Write
75+base permission. See
76+[who can see and publish a package](/guides/packages/#who-can-see-and-publish-a-package).
77+
78+`publish = ["acme"]` keeps the crate from being published to crates.io by
79+mistake, and lets `cargo publish` leave out `--registry` when it is the
80+only registry named.
81+
82+A crate may depend on crates.io crates and on other crates of the
83+workspace's registry. The crate's page on g1t.sh shows the README and
84+description of its highest stable version.
85+
86+## Add a crate
87+
88+```sh
89+cargo add http-client --registry acme
90+```
91+
92+That writes the dependency with its registry into `Cargo.toml`:
93+
94+```toml
95+[dependencies]
96+http-client = { version = "0.3.1", registry = "acme" }
97+```
98+
99+`Cargo.lock` records each crate's registry and the SHA-256 of its
100+`.crate` file, which g1t computes when the version is published.
101+
102+`cargo search --registry acme http` lists the workspace's crates you can
103+see whose names match.
104+
105+## Names and versions
106+
107+A crate's name follows the crates.io rules: ASCII letters, digits, `-` and
108+`_`, starting with a letter, at most 64 characters. In a workspace, names
109+that differ only in case or in `-` against `_` are one name: once
110+`http-client` is published, `HTTP_Client` is refused.
111+
112+A version is published once. Publishing a version that is already there is
113+refused, as is one that differs from it only in build metadata (`1.0.0+a`
114+and `1.0.0+b`), so bump `version` first.
115+
116+## Yank
117+
118+```sh
119+cargo yank --registry acme http-client@0.3.1
120+cargo yank --registry acme http-client@0.3.1 --undo
121+```
122+
123+A yanked version stays in the registry: projects whose `Cargo.lock`
124+names it still build, but Cargo no longer picks it for new lockfiles or
125+`cargo update`. Yanking needs what publishing does. The crate's page marks
126+yanked versions, and someone with Admin on the linked repository (an
127+owner, for the workspace's own crates) can delete a version there for
128+good.
129+
130+Crate owners are not kept: who may publish a crate is decided by its
131+repository's roles, or the workspace's, so `cargo owner` answers with an
132+error that says so.
133+
134+## Private and public crates
135+
136+A crate linked to a repository has the repository's visibility; one of
137+the workspace's own is private until an owner makes it public on its page.
138+
139+| The workspace's crates | Without a token | With a token |
140+| --- | --- | --- |
141+| All public | Cargo reads the index and downloads them. | The same; the token is sent to publish and yank. |
142+| Some private | The registry answers `401`: Cargo needs a token for every crate in it, public ones too. | Cargo sends the token with every request, and sees the crates the token's owner may see. |
143+
144+Cargo first asks for the registry's `config.json` without a token. When
145+the registry answers `401`, Cargo asks again with its token, and the
146+answer says `"auth-required": true`, so Cargo sends the token from then on.
147+A private crate you cannot see looks exactly like one that does not exist.
148+
149+## In workflows
150+
151+A workflow's `G1T_TOKEN` is the workspace's own token for the run, and can
152+add and publish the workspace's crates. Give it to Cargo for the registry:
153+
154+```yaml
155+jobs:
156+ publish:
157+ runs-on: ubuntu-latest
158+ env:
159+ CARGO_REGISTRIES_ACME_INDEX: sparse+https://g1t.sh/-/cargo/acme/index/
160+ CARGO_REGISTRIES_ACME_CREDENTIAL_PROVIDER: cargo:token
161+ CARGO_REGISTRIES_ACME_TOKEN: ${{ secrets.G1T_TOKEN }}
162+ steps:
163+ - uses: actions/checkout@v4
164+ - run: cargo test
165+ - run: cargo publish --registry acme
166+```
167+
168+`CARGO_REGISTRIES_ACME_INDEX` and `CARGO_REGISTRIES_ACME_CREDENTIAL_PROVIDER`
169+are needed only when the project has no `.cargo/config.toml` naming the
170+registry.
171+
172+## Size
173+
174+A publish is one request with the `.crate` file inside it, and may hold at
175+most 100 MB. Without the [g1t plan](/guides/usage-and-billing/#the-g1t-plan),
176+a workspace's private packages may hold 500 MB and its public ones 10 GB,
177+as for [container images](/guides/containers/#storage-and-pull-limits).
178+A `.crate` file is stored once, by its content.
179+
180+## Errors
181+
182+| Error | Means |
183+| --- | --- |
184+| `401` | No token, or a wrong or expired one. Run `cargo login --registry acme` with a g1t access token. |
185+| `authenticated registries require a credential-provider to be configured` | The workspace has private crates, and Cargo has no provider for its token. Add `credential-provider = "cargo:token"` to the registry in `.cargo/config.toml`. |
186+| `403` | Signed in, but your role or your token's scopes do not allow it, or the workspace is out of free package storage. The message says which. |
187+| `404` | No such crate or version, or a private one you cannot see. |
188+| `400` | The publish was refused: a name that is not valid or is taken, a version already published, or metadata Cargo did not send in full. The message says which. |
189+| `413` | The publish is over 100 MB. Leave large files out with `exclude` or `include` in `Cargo.toml`, and check with `cargo package --list`. |
+16−0
117117 git push origin my-change
118118 ```
119119
120+A repository's **Branches** tab, `g1t.sh/<workspace>/<repo>/branches`, lists
121+every branch: the default one first, then those with a commit in the last 90
122+days (**Active**), then the rest (**Stale**). Each shows its last commit, how
123+many commits it is ahead of and behind the default branch, the pull request
124+open on it with its checks, and its preview when it has one. A count with a
125+`+` ran past how far back g1t reads, 40 commits on the branch and 120 on the
126+default. Search narrows the list by name.
127+
128+The **Tags** tab lists tags newest first, up to 100, each with its commit
129+and a ZIP of its files.
130+
131+On **Files**, each file and folder shows the commit that last changed it and
132+when, from up to 300 commits of the branch's history; one changed before
133+that shows none. The branch menu at the top switches branch and keeps the
134+folder or file you are on.
135+
120136 ## Pull request forks
121137
122138 A pull request that was not opened from a branch has its own remote:
+10−7
44 ---
55
66 A workspace can publish packages to g1t and install them from it, beside
7−the code they are built from: container images, npm packages, Composer
8−packages and Go modules, with Cargo to follow. Each registry speaks its
9−tool's own protocol, so `docker`, `npm`, `composer` and `go` work with
10−nothing but a login and an address. Composer packages and Go modules are
7+the code they are built from: container images, npm packages, Rust
8+crates, Composer packages and Go modules. Each registry speaks its
9+tool's own protocol, so `docker`, `npm`, `cargo`, `composer` and `go`
10+work with nothing but a login and an address. Composer packages and Go modules are
1111 read from the workspace's repositories: there is nothing to upload.
1212
1313 | Registry | Address | Guide |
1414 | --- | --- | --- |
1515 | Container images | `g1t.sh/<workspace>/<name>` | [Container images](/guides/containers/) |
1616 | npm | `https://g1t.sh/-/npm/`, for the scope `@<workspace>` | [npm](/guides/npm/) |
17+| Cargo | `sparse+https://g1t.sh/-/cargo/<workspace>/index/`, a registry per workspace | [Cargo](/guides/cargo/) |
1718 | Composer | `https://g1t.sh/-/composer/<workspace>/`, from the workspace's repositories | [Composer](/guides/composer/) |
1819 | Go | `g1t.sh/<workspace>/<repo>`, straight from git | [Go modules](/guides/go/) |
1920
3132 repository's name (`acme/web`, `acme/web/worker` for the repository
3233 `acme/web`) links it to that repository; so does the first publish of
3334 an npm package whose `package.json` `repository` is a g1t.sh repository
34− of the workspace, or which is named like one (`@acme/web`). It then has
35− the repository's visibility and [roles](/guides/access-and-roles/):
35+ of the workspace, or which is named like one (`@acme/web`), and of a
36+ crate whose `Cargo.toml` `repository` is one, or which is named like
37+ one. It then has the repository's visibility and [roles](/guides/access-and-roles/):
3638
3739 | | Needs |
3840 | --- | --- |
9294 ## Events and the audit log
9395
9496 Publishing a version, deleting a version and deleting a package are
95−[audit log](/guides/audit-log/) entries, and the events
97+[audit log](/guides/audit-log/) entries (so are deprecating an npm version
98+and yanking or unyanking a crate version), and the events
9699 `package.published`, `package.version_deleted`, `package.deleted` and
97100 `package.visibility_changed`, which [webhooks](/guides/webhooks/) can be
98101 sent: a linked package's go to its repository's webhooks and its
+10−7
313313 shows where you are needed in the workspace you have chosen in the
314314 sidebar, what its agents are doing, and what landed without you.
315315
316−Under the greeting, one line sums up the week, such as *Agents landed 39
317−of 47 changes this week without you*. A change landed without you when
318−g1t merged it, by auto-merge or from the [merge queue](/guides/merge-queue/),
319−with no person pressing merge. **Review N that need you** jumps to the
316+Under the greeting, one line sums up the week, such as *Agents landed 37
317+of their 39 changes this week without you, and people landed 8 changes of
318+their own*. An agent's change landed without you when g1t merged it, by
319+auto-merge or from the [merge queue](/guides/merge-queue/), with no person
320+pressing merge. People's changes are their merged pull requests and the
321+commits they pushed straight to the default branch. **Review N that need you** jumps to the
320322 list, and **New issue** opens a new issue in the project you pick.
321323
322324 | Across the top | What it counts |
323325 | --- | --- |
324326 | **Projects** | The workspace's projects, and how many were added this month. |
325327 | **Agents** | Agent runs going now, and the hours agents worked in the last 7 days. |
326−| **Changes this week** | Pull requests merged in the last 7 days, and the change from the 7 days before. The change is left out when g1t cannot read far enough back to count it. |
327−| **Landed without you** | The share of those changes that g1t merged with no person pressing merge. |
328+| **Changes this week** | Pull requests merged in the last 7 days, and commits people pushed straight to the default branch, with the change from the 7 days before. The change is left out when g1t cannot read far enough back to count it. |
329+| **Landed without you** | The share of agents' changes that g1t merged with no person pressing merge. People's own changes are not counted in it. |
328330 | **Need you** | What is waiting on you, and how many of those block work. |
329331
330332 The list has three tabs. Each row opens to say more; the first is open.
356358 jobs. **By impact** puts the most urgent first; **Newest** sorts by time.
357359
358360 On the right, **This week** charts the changes landed each day, split
359−into those agents landed alone and those a person merged, with what
361+by who did the work: agents on their own, agents with a person merging,
362+and people (their pull requests and direct pushes, merges left out), with what
360363 agents and sandboxes cost over the same days. **Activity** lists what
361364 moved across the workspace, agents marked apart from people. The page
362365 refreshes itself while agents are at work.
+4−3
473473 /** Nothing running, said plainly. */
474474 export function Idle({ children }: { children: ReactNode }) {
475475 return (
476− <p className="flex items-center gap-2 rounded-xl border border-dashed border-line px-4 py-6 text-sm text-muted">
477− <CircleSlash size={15} className="text-faint" />
478− {children}
476+ <p className="flex items-start gap-2 rounded-xl border border-dashed border-line px-4 py-6 text-sm text-muted">
477+ <CircleSlash size={15} className="mt-0.5 shrink-0 text-faint" />
478+ {/* One run of text: a link inside it stays in the sentence. */}
479+ <span className="min-w-0">{children}</span>
479480 </p>
480481 );
481482 }
+4−3
269269 <div aria-hidden="true" className="h-16 bg-gradient-to-b from-transparent to-bg" />
270270 </section>
271271
272− {/* Facts, each one true today. */}
273− <section className="mx-auto max-w-6xl px-4">
274− <dl className="grid gap-px overflow-hidden rounded-2xl bg-line ring-1 ring-line sm:grid-cols-2 lg:grid-cols-4">
272+ {/* Facts, each one true today. Above the hero's fade, with a border of
273+ its own: a ring is a shadow, and the fade drew over its top edge. */}
274+ <section className="relative z-10 mx-auto max-w-6xl px-4">
275+ <dl className="grid gap-px overflow-hidden rounded-2xl border border-line bg-line sm:grid-cols-2 lg:grid-cols-4">
275276 {FACTS.map(([figure, about]) => (
276277 <div key={figure} className="bg-bg px-6 py-6">
277278 <dt className="font-mono text-2xl font-medium tracking-tight text-fg">{figure}</dt>
+23−20
526526 <span className="size-2 rounded-sm bg-info" /> People
527527 </span>
528528 </div>
529− <table className="sr-only">
530− <caption>Changes landed each day</caption>
531− <thead>
532− <tr>
533− <th>Day</th>
534− <th>Agents, on their own</th>
535− <th>Agents, merged by a person</th>
536− <th>People</th>
537− </tr>
538− </thead>
539− <tbody>
540− {week.days.map((day) => (
541− <tr key={day.key}>
542− <td>{day.key}</td>
543− <td>{day.agents}</td>
544− <td>{day.assisted}</td>
545− <td>{day.people}</td>
529+ {/* A table does not shrink to sr-only's 1px; its wrapper does. */}
530+ <div className="sr-only">
531+ <table>
532+ <caption>Changes landed each day</caption>
533+ <thead>
534+ <tr>
535+ <th>Day</th>
536+ <th>Agents, on their own</th>
537+ <th>Agents, merged by a person</th>
538+ <th>People</th>
546539 </tr>
547− ))}
548− </tbody>
549− </table>
540+ </thead>
541+ <tbody>
542+ {week.days.map((day) => (
543+ <tr key={day.key}>
544+ <td>{day.key}</td>
545+ <td>{day.agents}</td>
546+ <td>{day.assisted}</td>
547+ <td>{day.people}</td>
548+ </tr>
549+ ))}
550+ </tbody>
551+ </table>
552+ </div>
550553 </div>
551554 );
552555 }
+125−37
1−import { BookOpen, Check, ChevronDown, Code2, File, FileArchive, Folder, FolderGit2, GitBranch, History, Search, SquareTerminal } from "lucide-react";
2−import { Form, Link } from "react-router";
1+import { BookOpen, Check, ChevronDown, Code2, File, FileArchive, Folder, FolderGit2, GitBranch, History, Search, SquareTerminal, Tag as TagIcon } from "lucide-react";
2+import { Suspense } from "react";
3+import { Await, Form, Link } from "react-router";
34
4−import type { Blame, BlobView as Blob, Branch, Commit, TreeView as Tree } from "@g1t/contracts";
5+import type { Blame, BlobView as Blob, Branch, Commit, LastCommits, TreeView as Tree } from "@g1t/contracts";
56
67 import { BlameView } from "./blame-view";
78 import { CodeLines } from "./code-lines";
8687 "inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-line bg-surface px-3 text-sm transition-colors hover:border-line-strong hover:bg-raised data-[state=open]:border-line-strong";
8788
8889 /** Which branch is shown, and the others to switch to, at the same path. */
89−function BranchMenu({ base, gitRef, path, branches }: { base: string; gitRef: string; path: string; branches: Branch[] }) {
90+function BranchMenu({
91+ base,
92+ gitRef,
93+ path,
94+ branches,
95+ view = "tree",
96+}: {
97+ base: string;
98+ gitRef: string;
99+ path: string;
100+ branches: Branch[];
101+ /** Whether `path` is a folder or a file. */
102+ view?: "tree" | "blob";
103+}) {
90104 const rest = path ? `/${encodePath(path)}` : "";
91105 return (
92106 <DropdownMenu>
99113 <DropdownMenuLabel>Switch branches</DropdownMenuLabel>
100114 {branches.map((branch) => (
101115 <DropdownMenuItem key={branch.name} asChild>
102− <Link to={`${base}/tree/${encodePath(branch.name)}${rest}`}>
116+ <Link to={`${base}/${view}/${encodePath(branch.name)}${rest}`}>
103117 <Check className={branch.name === gitRef ? "" : "invisible"} />
104118 <span className="truncate font-mono text-[0.8125rem]">{branch.name}</span>
105119 </Link>
185199 ) : (
186200 <>
187201 {branches && (
188− <span className="inline-flex items-center gap-1.5 text-sm text-muted">
202+ <Link to={`${base}/branches`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent">
189203 <GitBranch size={14} className="text-faint" />
190204 <span className="font-medium text-fg">{branches.length}</span>
191205 {branches.length === 1 ? "branch" : "branches"}
192− </span>
206+ </Link>
193207 )}
208+ <Link to={`${base}/tags`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent">
209+ <TagIcon size={14} className="text-faint" />
210+ Tags
211+ </Link>
194212 <SearchCode repo={full} />
195213 <CodeButton path={full} gitRef={gitRef} />
196214 </>
199217 );
200218 }
201219
202−export function TreeView({ tree, branches = null }: { tree: Tree; branches?: Branch[] | null }) {
220+/**
221+ * The files of a directory, each with the commit that last changed it.
222+ * `last` is undefined while those are still being read (the column holds
223+ * its place), and null when they could not be.
224+ */
225+function FileRows({
226+ base,
227+ gitRef,
228+ prefix,
229+ entries,
230+ last,
231+}: {
232+ base: string;
233+ gitRef: string;
234+ prefix: string;
235+ entries: Tree["entries"];
236+ last: LastCommits | null | undefined;
237+}) {
238+ const byName = new Map((last?.entries ?? []).map((entry) => [entry.name, entry.commit]));
239+ return (
240+ <ul className="divide-y divide-line text-sm">
241+ {entries.map((entry) => {
242+ const isTree = entry.kind === "tree";
243+ const Icon = isTree ? Folder : entry.kind === "gitlink" ? FolderGit2 : File;
244+ const commit = byName.get(entry.name);
245+ return (
246+ <li key={entry.name} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 px-4 py-2 transition-colors hover:bg-surface sm:grid-cols-[minmax(0,14rem)_minmax(0,1fr)_auto] lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)_auto]">
247+ <Link
248+ to={`${base}/${isTree ? "tree" : "blob"}/${gitRef}/${prefix}${encodeURIComponent(entry.name)}`}
249+ className="flex min-w-0 items-center gap-3 hover:text-accent hover:underline"
250+ >
251+ <Icon size={15} className={`shrink-0 ${isTree ? "text-accent-dim" : "text-faint"}`} />
252+ <span className="truncate font-mono text-[0.8125rem]">{entry.name}</span>
253+ </Link>
254+ <span className="hidden min-w-0 sm:block">
255+ {commit ? (
256+ <Link
257+ to={`${base}/commit/${commit.hash}`}
258+ className="block truncate text-muted hover:text-fg hover:underline"
259+ title={commit.message.split("\n")[0]}
260+ >
261+ {commit.message.split("\n")[0]}
262+ </Link>
263+ ) : last === undefined ? (
264+ <span aria-hidden="true" className="block h-3 w-40 max-w-full animate-pulse rounded bg-raised" />
265+ ) : null}
266+ </span>
267+ <span className="text-right text-xs whitespace-nowrap text-faint">
268+ {commit ? <TimeAgo at={commit.authoredAt} /> : last === undefined ? <span aria-hidden="true" className="inline-block h-3 w-12 animate-pulse rounded bg-raised" /> : null}
269+ </span>
270+ </li>
271+ );
272+ })}
273+ </ul>
274+ );
275+}
276+
277+export function TreeView({
278+ tree,
279+ branches = null,
280+ lastCommits = null,
281+}: {
282+ tree: Tree;
283+ branches?: Branch[] | null;
284+ /** Each entry's last commit, streamed in after the list. */
285+ lastCommits?: Promise<LastCommits | null> | null;
286+}) {
203287 const { repo, ref, path, head, entries, readme } = tree;
204288 const base = `/${repo.namespace}/${repo.name}`;
205289 const prefix = path ? `${encodePath(path)}/` : "";
234318 <CodeBar base={base} repo={repo} gitRef={ref} path={path} branches={branches} />
235319 <div className="overflow-hidden rounded-xl border border-line">
236320 <CommitBar commit={head} base={base} />
237− <ul className="divide-y divide-line text-sm">
238− {entries.map((entry) => {
239− const isTree = entry.kind === "tree";
240− const Icon = isTree ? Folder : entry.kind === "gitlink" ? FolderGit2 : File;
241− return (
242− <li key={entry.name}>
243− <Link
244− to={`${base}/${isTree ? "tree" : "blob"}/${ref}/${prefix}${encodeURIComponent(entry.name)}`}
245− className="flex items-center gap-3 px-4 py-2 transition-colors hover:bg-surface"
246− >
247− <Icon
248− size={15}
249− className={isTree ? "text-accent-dim" : "text-faint"}
250− />
251− <span className="font-mono text-[0.8125rem]">{entry.name}</span>
252− </Link>
253− </li>
254− );
255− })}
256− </ul>
321+ {lastCommits ? (
322+ <Suspense fallback={<FileRows base={base} gitRef={ref} prefix={prefix} entries={entries} last={undefined} />}>
323+ <Await resolve={lastCommits} errorElement={<FileRows base={base} gitRef={ref} prefix={prefix} entries={entries} last={null} />}>
324+ {(last) => <FileRows base={base} gitRef={ref} prefix={prefix} entries={entries} last={last} />}
325+ </Await>
326+ </Suspense>
327+ ) : (
328+ <FileRows base={base} gitRef={ref} prefix={prefix} entries={entries} last={null} />
329+ )}
257330 </div>
258331
259332 {readme?.text != null && (
299372 </Link>
300373 </li>
301374 {branches && (
302− <li className="flex items-center gap-2">
303− <GitBranch size={15} className="text-faint" />
304− {branches.length} {branches.length === 1 ? "branch" : "branches"}
375+ <li>
376+ <Link to={`${base}/branches`} className="inline-flex items-center gap-2 hover:text-fg">
377+ <GitBranch size={15} className="text-faint" />
378+ {branches.length} {branches.length === 1 ? "branch" : "branches"}
379+ </Link>
305380 </li>
306381 )}
382+ <li>
383+ <Link to={`${base}/tags`} className="inline-flex items-center gap-2 hover:text-fg">
384+ <TagIcon size={15} className="text-faint" />
385+ Tags
386+ </Link>
387+ </li>
307388 </ul>
308389 <p className="mt-4 text-xs text-faint">
309390 Created <TimeAgo at={repo.createdAt} />
318399 blob,
319400 html,
320401 blame,
402+ branches = null,
321403 }: {
404+ /** For the branch menu; it shows the current branch alone without them. */
405+ branches?: Branch[] | null;
322406 blob: Blob;
323407 /** Syntax-highlighted HTML per line, when the language is known. */
324408 html: string[] | null;
339423 );
340424 return (
341425 <div>
342− <Breadcrumbs
343− base={`/${repo.namespace}/${repo.name}`}
344− repo={repo.name}
345− gitRef={ref}
346− path={path}
347− />
426+ <div className="mb-4 flex flex-wrap items-center gap-x-3 gap-y-2">
427+ <BranchMenu
428+ base={base}
429+ gitRef={ref}
430+ path={path}
431+ branches={branches && branches.length > 0 ? branches : [{ name: ref, hash: "" }]}
432+ view="blob"
433+ />
434+ <Breadcrumbs base={base} repo={repo.name} gitRef={ref} path={path} />
435+ </div>
348436 <div className="overflow-hidden rounded-xl border border-line">
349437 <div className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5 text-xs text-muted">
350438 {lines && <span>{lines.length.toLocaleString("en-US")} lines</span>}
+4−3
818818 than one view shows them as tabs across its top. */}
819819 <Rule />
820820 <div className="space-y-px">
821− <SidebarLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`, ...soonPaths(base, "Code")]} icon={<Code2 size={15} />}>
821+ <SidebarLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`, `${base}/branches`, `${base}/tags`, ...soonPaths(base, "Code")]} icon={<Code2 size={15} />}>
822822 Code
823823 </SidebarLink>
824824 <SidebarLink to={`${base}/issues`} also={[`${base}/plans`, ...soonPaths(base, "Issues")]} icon={<CircleDot size={15} />} count={repo.issues}>
12091209 access: "Access",
12101210 invitations: "Invitation",
12111211 repositories: "Repositories",
1212− branches: "Branches and merging",
1212+ branches: "Branches",
1213+ tags: "Tags",
12131214 dependencies: "Dependencies",
12141215 code: "Files",
12151216 secrets: "Secrets and variables",
13081309 trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
13091310 // A settings page names which one: Settings / Secrets and variables.
13101311 if (third === "settings" && fourth && SECTIONS[fourth]) {
1311− trail.push({ label: SECTIONS[fourth]!, to: `${repo}/settings/${fourth}` });
1312+ trail.push({ label: fourth === "branches" ? "Branches and merging" : SECTIONS[fourth]!, to: `${repo}/settings/${fourth}` });
13121313 }
13131314 }
13141315 }
+1−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { shownStep } from "./agent-step";
4+import { shownStep } from "./agent-step.ts";
55
66 test("a step that hands back a file says what was done, not where", () => {
77 assert.equal(shownStep("Said: Done. The review is written to `/work/review.json`."), "Wrote its review.");
+60−0
1+/**
2+ * A repository's branches as its pages show them: each one's head commit,
3+ * how far it has moved from the default branch, the pull request open on
4+ * it with its checks, and its preview. The overview shows the newest few;
5+ * the Branches page shows them all.
6+ */
7+import type { Branch, Pull, RepoPath, Viewer } from "@g1t/contracts";
8+
9+import type { ActiveBranch } from "../components/branches";
10+import { drift } from "./branches";
11+import { repos } from "./services.server";
12+
13+/** How far back each branch's history, and the default branch's, is read to count ahead and behind. */
14+export const BRANCH_DEPTH = 40;
15+export const MAIN_DEPTH = 120;
16+
17+type Preview = { branch?: string | null; number?: number | null; url: string };
18+
19+/**
20+ * The branches other than the default, at most `read` of them read (those
21+ * with an open pull request first), newest commit first. `main` is the
22+ * default branch's head commit with its first line.
23+ */
24+export async function readBranches(
25+ path: RepoPath,
26+ viewer: Viewer,
27+ input: { defaultBranch: string; branches: Branch[]; pulls: Pull[]; previews: Preview[] },
28+ read: number,
29+): Promise<{ main: string; total: number; shown: ActiveBranch[]; head: ActiveBranch["commit"] }> {
30+ const soft = <T,>(promise: Promise<T>): Promise<T | null> => promise.catch(() => null);
31+ const main = input.defaultBranch;
32+ const pullOn = new Map(input.pulls.filter((pull) => pull.branch).map((pull) => [pull.branch as string, pull]));
33+ const others = input.branches.filter((branch) => branch.name !== main);
34+ const reading = [...others.filter((b) => pullOn.has(b.name)), ...others.filter((b) => !pullOn.has(b.name))].slice(0, read);
35+ // By commit hash, not name: history from a commit never changes, so
36+ // repos keeps it (services/repos/src/store.rs) and only new heads cost a walk.
37+ const mainHead = input.branches.find((branch) => branch.name === main)?.hash ?? main;
38+ const [mainLog, ...logs] = await Promise.all([
39+ soft(repos.log(path, viewer, mainHead, MAIN_DEPTH)),
40+ ...reading.map((branch) => soft(repos.log(path, viewer, branch.hash || branch.name, BRANCH_DEPTH))),
41+ ]);
42+ const mainHistory = mainLog?.ok ? mainLog.value : [];
43+ const mainHashes = mainHistory.map((c) => c.hash);
44+ const summary = (commit: (typeof mainHistory)[number] | undefined): ActiveBranch["commit"] =>
45+ commit ? { hash: commit.hash, message: commit.message.split("\n")[0] ?? "", author: commit.author.name, at: commit.authoredAt } : null;
46+ const shown = reading
47+ .map((branch, index): ActiveBranch => {
48+ const history = logs[index]?.ok ? logs[index].value : [];
49+ const pull = pullOn.get(branch.name);
50+ return {
51+ name: branch.name,
52+ commit: summary(history[0]),
53+ ...drift(history.map((c) => c.hash), mainHashes, BRANCH_DEPTH),
54+ pull: pull ? { number: pull.number, title: pull.title, checkStatus: pull.checkStatus, draft: pull.status === "draft" } : null,
55+ preview: input.previews.find((app) => app.branch === branch.name || (pull != null && app.number === pull.number))?.url ?? null,
56+ };
57+ })
58+ .sort((a, b) => Date.parse(b.commit?.at ?? "0") - Date.parse(a.commit?.at ?? "0"));
59+ return { main, total: others.length, shown, head: summary(mainHistory[0]) };
60+}
+2−0
2121 assert.deepEqual(drift(["b3", "b2", "b1"], ["m2", "m1"], 3), { ahead: 3, behind: 0, aheadMore: true, behindMore: true });
2222 assert.equal(count(3, true), "3+");
2323 assert.equal(count(3, false), "3");
24+ // Behind by an unknown number: not "0+", which reads as up to date.
25+ assert.equal(count(0, true), "?");
2426 });
+2−1
2121 return { ahead, behind, aheadMore: false, behindMore: false };
2222 }
2323
24−/** `12`, or `50+` when the count ran past what was read. */
24+/** `12`, `50+` when the count ran past what was read, or `?` when nothing was counted before it did. */
2525 export function count(value: number, more: boolean): string {
26+ if (more && value === 0) return "?";
2627 return more ? `${value}+` : String(value);
2728 }
+25−0
1+/**
2+ * Each entry's last commit for the file list, waited on only so long. An
3+ * answer not yet kept walks history, which can take seconds on a large or
4+ * busy repository; the page goes out without it then, the column empty,
5+ * while the walk finishes in the background (waitUntil) so the next view
6+ * has it from the cache. The page's stream never waits on it past its own
7+ * timeout.
8+ */
9+import { waitUntil } from "cloudflare:workers";
10+
11+import type { LastCommits, RepoPath, Viewer } from "@g1t/contracts";
12+
13+import { repos } from "./services.server";
14+
15+/** How long the page waits for the column. */
16+const WAIT_MS = 3_000;
17+
18+export function lastCommitsFor(path: RepoPath, viewer: Viewer, ref: string | null, treePath: string): Promise<LastCommits | null> {
19+ const walk = repos
20+ .lastCommits(path, viewer, ref, treePath)
21+ .then((found) => (found.ok ? found.value : null))
22+ .catch(() => null);
23+ waitUntil(walk);
24+ return Promise.race([walk, new Promise<null>((resolve) => setTimeout(() => resolve(null), WAIT_MS))]);
25+}
+9−0
2020 sortRows,
2121 stallReason,
2222 summaryLine,
23+ pushedCommits,
2324 waitingRows,
2425 weekOf,
2526 whyFor,
171172 files: [],
172173 });
173174
175+test("a push to the default branch counts a person's own commits, not merges or agents'", () => {
176+ const c = (hash: string, author: string, parents = 1) => ({ hash, author: { name: author }, parents: Array(parents).fill("p") });
177+ const history = [c("e", "Chase"), c("d", "g1t"), c("m", "g1t", 2), c("b", "Chase"), c("a", "Chase")];
178+ assert.deepEqual(pushedCommits(history, "a").map((x) => x.hash), ["e", "b"]);
179+ // Where it pointed before was not read: everything read counts.
180+ assert.deepEqual(pushedCommits(history, "zz").map((x) => x.hash), ["e", "b", "a"]);
181+});
182+
174183 test("a change landed without a person when g1t merged it", () => {
175184 assert.ok(landedByAgents({ mergedBy: "g1t" }));
176185 assert.ok(landedByAgents({ mergedBy: "g1t" }));
+15−0
424424 // --- Landed -----------------------------------------------------------------
425425
426426 /** A merged pull request, as the week counts it. */
427+/**
428+ * The commits a push to the default branch brought, from the history at its
429+ * `after` (newest first) back to its `before`: people's own, not merges (a
430+ * pull request landing) and not agents'. A push whose `before` is not in
431+ * what was read gives what was read.
432+ */
433+export function pushedCommits<C extends { hash: string; parents: string[]; author: { name: string } }>(
434+ history: C[],
435+ before: string | undefined,
436+): C[] {
437+ const end = before ? history.findIndex((commit) => commit.hash === before) : -1;
438+ const brought = end === -1 ? history : history.slice(0, end);
439+ return brought.filter((commit) => commit.parents.length <= 1 && !isAgent(commit.author.name));
440+}
441+
427442 export type Merged = {
428443 repo: RepoPath;
429444 number: number;
+11−0
3434 });
3535 });
3636
37+test("a crate is added after .cargo/config.toml names its workspace's registry, with cargo login for private ones", () => {
38+ const pkg = { ecosystem: "cargo" as const, address: "g1t.sh/-/cargo/acme/http-client", name: "http-client", workspace: "acme" };
39+ assert.deepEqual(installCommands(pkg, "0.3.1", "ada"), {
40+ registry:
41+ "mkdir -p .cargo && printf '[registries.acme]\\nindex = \"sparse+https://g1t.sh/-/cargo/acme/index/\"\\ncredential-provider = \"cargo:token\"\\n' >> .cargo/config.toml",
42+ login: "cargo login --registry acme",
43+ install: "cargo add http-client@0.3.1 --registry acme",
44+ });
45+ assert.equal(installCommands(pkg, null, "ada").install, "cargo add http-client --registry acme");
46+});
47+
3748 test("a container image is pulled by its address and tag", () => {
3849 const pkg = { ecosystem: "container" as const, address: "g1t.sh/acme/web", name: "web", workspace: "acme" };
3950 assert.deepEqual(installCommands(pkg, "latest", "ada"), {
+12−8
4040 ready: true,
4141 },
4242 {
43+ ecosystem: "cargo",
44+ blurb: "Rust crates in a sparse registry of the workspace's own, published with cargo publish and added with cargo add.",
45+ start: "cargo publish --registry <workspace>",
46+ guide: "/guides/cargo/",
47+ ready: true,
48+ },
49+ {
4350 ecosystem: "go",
4451 blurb: "Go modules fetched from the repositories themselves with go get, private ones with a token.",
4552 start: "go get g1t.sh/<workspace>/<repo>",
4653 guide: "/guides/go/",
4754 ready: true,
48− },
49− {
50− ecosystem: "cargo",
51− blurb: "Rust crates in a registry of the workspace's own, published with cargo publish.",
52− start: "cargo publish --registry <workspace>",
53− guide: "/guides/packages/",
54− ready: false,
5555 },
5656 ];
5757
143143 install: `composer require ${pkg.name}${version ? `:${version}` : ""}`,
144144 };
145145 case "cargo":
146+ // The workspace's registry (in .cargo/config.toml, needed for any
147+ // install), then the token a private crate also needs, which cargo
148+ // login asks for.
146149 return {
150+ registry: `mkdir -p .cargo && printf '[registries.${pkg.workspace}]\\nindex = "sparse+https://${host}/-/cargo/${pkg.workspace}/index/"\\ncredential-provider = "cargo:token"\\n' >> .cargo/config.toml`,
147151 login: `cargo login --registry ${pkg.workspace}`,
148− install: `cargo add ${pkg.name} --registry ${pkg.workspace}${version ? ` --vers ${version}` : ""}`,
152+ install: `cargo add ${pkg.name}${version ? `@${version}` : ""} --registry ${pkg.workspace}`,
149153 };
150154 case "go":
151155 return {
+2−0
3737 tabs: [
3838 { label: "Files", path: "code", also: ["tree", "blob"] },
3939 { label: "Commits", path: "commits", also: ["commit"] },
40+ { label: "Branches", path: "branches" },
41+ { label: "Tags", path: "tags" },
4042 ...soon("Code"),
4143 ],
4244 },
+17−0
4747 assert.equal(servicePath("/-/composer"), null);
4848 });
4949
50+test("the Cargo registries go to the packages service", () => {
51+ for (const path of [
52+ "/-/cargo/acme/index/config.json",
53+ "/-/cargo/acme/index/se/rd/serde",
54+ "/-/cargo/acme/index/3/a/abc",
55+ "/-/cargo/acme/api/v1/crates/new",
56+ "/-/cargo/acme/api/v1/crates/serde/1.0.0/download",
57+ "/-/cargo/acme/api/v1/crates/serde/1.0.0/yank",
58+ // A crate named like a git endpoint is still Cargo's.
59+ "/-/cargo/acme/index/in/fo/info/refs",
60+ ]) {
61+ assert.equal(servicePath(path), "packages", path);
62+ }
63+ assert.equal(servicePath("/-/cargo"), null);
64+ assert.equal(servicePath("/acme/-/cargo/x"), null);
65+});
66+
5067 test("git goes to repos, and everything else is the site's", () => {
5168 assert.equal(servicePath("/acme/web.git/info/refs"), "git");
5269 assert.equal(servicePath("/acme/web/git-receive-pack"), "git");
+3−1
1111 const NPM_PATH = /^\/-\/npm(?:\/|$)/;
1212 /** The Composer registries: `/-/composer/<workspace>/`, one per workspace. */
1313 const COMPOSER_PATH = /^\/-\/composer\//;
14+/** The Cargo registries: `/-/cargo/<workspace>/`, a sparse index and its web API, one per workspace. */
15+const CARGO_PATH = /^\/-\/cargo\//;
1416
1517 export type ServicePath = "git" | "packages" | null;
1618
1719 export function servicePath(pathname: string): ServicePath {
18− if (REGISTRY_PATH.test(pathname) || NPM_PATH.test(pathname) || COMPOSER_PATH.test(pathname)) return "packages";
20+ if (REGISTRY_PATH.test(pathname) || NPM_PATH.test(pathname) || COMPOSER_PATH.test(pathname) || CARGO_PATH.test(pathname)) return "packages";
1921 if (GIT_PATH.test(pathname)) return "git";
2022 return null;
2123 }
+0−23
8181
8282 // --- Code ---------------------------------------------------------------
8383 {
84− key: "branches",
85− title: "Branches",
86− section: "Code",
87− summary: "Every branch: who is on it, how far behind it is, and its preview.",
88− why: "With agents opening branches by the dozen, a list of names is not enough. Each branch shows its pull request, its checks, its live preview and how stale it is.",
89− plans: [
90− "Branches with their pull request, checks and preview address",
91− "Ahead and behind the default branch, with one-click catch-up by an agent",
92− "Protection rules: required checks, reviews and the merge queue",
93− "Stale branches cleaned up on a schedule you set",
94− ],
95− today: { label: "Pull requests", path: "pulls" },
96− },
97− {
98− key: "tags",
99− title: "Tags",
100− section: "Code",
101− summary: "Tags, and the releases made from them.",
102− why: "A tag marks a version of the code. Each links to its release notes and to the deployment that shipped it.",
103− plans: ["Tags with their commit, release and deployment", "Signed tags verified", "Rules for who may create and move them"],
104− today: { label: "Commits", path: "commits" },
105− },
106− {
10784 key: "compare",
10885 title: "Compare",
10986 section: "Code",
+1−1
22 import { test } from "node:test";
33 import { inflateRawSync } from "node:zlib";
44
5−import { crc32, zip } from "./zip";
5+import { crc32, zip } from "./zip.ts";
66
77 test("crc32 matches the standard check value", () => {
88 assert.equal(crc32(new TextEncoder().encode("123456789")), 0xcbf43926);
+2−0
104104 route("blob/:ref/*", "routes/repo/blob.tsx"),
105105 route("archive/*", "routes/repo/archive.ts"),
106106 route("commits", "routes/repo/commits.tsx"),
107+ route("branches", "routes/repo/branches.tsx"),
108+ route("tags", "routes/repo/tags.tsx"),
107109 route("commit/:hash", "routes/repo/commit.tsx"),
108110 route("issues", "routes/repo/issues.tsx"),
109111 route("issues/new", "routes/repo/issue-new.tsx"),
+42−3
44
55 import {
66 type Confidence,
7+ type G1tEvent,
78 type Lifecycle,
89 type Pull,
910 type Repo,
1011 type RepoPath,
12+ type Viewer,
1113 REPO_ROLE_LABELS,
1214 isActiveRun,
1315 workOwner,
3436 import {
3537 type Fact,
3638 type Merged,
39+ pushedCommits,
3740 type NeedRow,
3841 type QuickAction,
3942 RUN_LABEL,
136139 return data<DelegateResult>({ error: null, notStarted: refused });
137140 }
138141
142+/** Pushes to the default branch read for the week, per project, and commits read back from each. */
143+const PUSHES_READ = 40;
144+const PUSH_DEPTH = 60;
145+
146+/**
147+ * The commits people pushed straight to a project's default branch in the
148+ * last two weeks, each with when its push landed. Merges and agents'
149+ * commits are left out: pull requests count those.
150+ */
151+async function directCommits(repo: Repo, viewer: Viewer): Promise<{ hash: string; at: string }[]> {
152+ const since = Date.now() - 14 * TIME.DAY;
153+ const pushes = (await eventLog.list({ repoId: repo.id, types: ["git.push"], limit: PUSHES_READ })).filter(
154+ (event): event is G1tEvent<"git.push"> => event.type === "git.push" && event.data.defaultBranch && Date.parse(event.time) >= since,
155+ );
156+ const path = { namespace: repo.namespace, name: repo.name };
157+ const read = await Promise.all(
158+ pushes.map(async (push) => {
159+ const history = await reposApi.log(path, viewer, push.data.after, PUSH_DEPTH).catch(() => null);
160+ return history?.ok ? pushedCommits(history.value, push.data.before).map((commit) => ({ hash: commit.hash, at: push.time })) : [];
161+ }),
162+ );
163+ // A commit pushed twice (after a force push, say) counts once, at its first landing.
164+ const seen = new Map<string, string>();
165+ for (const commit of read.flat().reverse()) if (!seen.has(commit.hash)) seen.set(commit.hash, commit.at);
166+ return [...seen].map(([hash, at]) => ({ hash, at }));
167+}
168+
139169 export async function loader({ context, request }: Route.LoaderArgs) {
140170 const viewer = getViewer(context);
141171 if (!viewer) {
177207 const chosen = (repos ?? [])
178208 .filter((repo) => slug != null && repo.namespace.toLowerCase() === slug.toLowerCase())
179209 .slice(0, MAX_PROJECTS);
180− const [batch, logs] = await Promise.all([
210+ const [batch, logs, pushes] = await Promise.all([
181211 work.pullsForRepos(chosen.map((repo) => repo.id), viewer, PULL_PAGE).catch(() => []),
182212 Promise.all(chosen.map((repo) => eventLog.list({ repoId: repo.id, limit: EVENTS_PER_PROJECT }).catch(() => null))),
213+ // People's pushes straight to the default branch, which no pull
214+ // request counts: the commits each brought, for the week.
215+ Promise.all(chosen.map((repo) => directCommits(repo, viewer).catch(() => []))),
183216 ]);
184217 const byId = new Map(batch.map((entry) => [entry.repoId, entry]));
185218 return Promise.all(
186219 chosen.map(async (repo, index) => {
187220 const found = byId.get(repo.id);
188− if (found) return { repo, pulls: found.open.slice(0, 60), closed: found.closed, events: logs[index] };
221+ if (found) return { repo, pulls: found.open.slice(0, 60), closed: found.closed, events: logs[index], direct: pushes[index] ?? [] };
189222 // A fork, which the batch leaves out: asked on its own.
190223 const path = { namespace: repo.namespace, name: repo.name };
191224 const [pulls, closed] = await Promise.all([
197230 pulls: pulls?.ok ? pulls.value.slice(0, 60) : null,
198231 closed: closed?.ok ? closed.value : null,
199232 events: logs[index],
233+ direct: pushes[index] ?? [],
200234 };
201235 }),
202236 );
519553 );
520554 const twoWeeksAgo = now - 14 * TIME.DAY;
521555 const complete = perRepo != null && perRepo.every(({ closed }) => closed != null && reachesBack(closed, twoWeeksAgo, PULL_PAGE));
522− const week = weekOf(merged, now, tz, complete);
556+ // A person's commits pushed straight to the default branch are their own
557+ // changes too, on the day they landed.
558+ const direct = (perRepo ?? []).flatMap(({ direct }) =>
559+ direct.map((commit) => ({ mergedAt: commit.at, mergedBy: null, authoredByAgent: false })),
560+ );
561+ const week = weekOf([...merged, ...direct], now, tz, complete);
523562 const landed = landedToday(merged, now, tz);
524563
525564 // --- Activity -------------------------------------------------------------
+7−2
1515 const path = { namespace: params.owner, name: params.repo };
1616 const file = params["*"] ?? "";
1717 const wantsBlame = new URL(request.url).searchParams.has("blame");
18− const [found, blame] = await Promise.all([
18+ const [found, blame, list] = await Promise.all([
1919 repos.blob(path, viewer, params.ref, file),
2020 wantsBlame ? repos.blame(path, viewer, params.ref, file) : null,
21+ // For the branch menu; the page still shows without it.
22+ repos.branches(path, viewer).catch(() => null),
2123 ]);
24+ const branches = list?.ok ? list.value : null;
2225 // A branch that was renamed: the same file on its new name.
2326 if (!found.ok && found.error.code === "not_found") await redirectIfBranchRenamed(request, path, viewer, params.ref);
2427 const blob = unwrap(found);
2730 blob,
2831 html: null,
2932 blame: { blame: blame.value, lines: await highlightLines(blob.path, blob.text) },
33+ branches,
3034 };
3135 }
3236 return {
3337 blob,
3438 html: blob.text == null ? null : await highlightLines(blob.path, blob.text),
3539 blame: null,
40+ branches,
3641 };
3742 }
3843
3944 export default function Blob({ loaderData }: Route.ComponentProps) {
40− return <BlobView blob={loaderData.blob} html={loaderData.html} blame={loaderData.blame} />;
45+ return <BlobView blob={loaderData.blob} html={loaderData.html} blame={loaderData.blame} branches={loaderData.branches} />;
4146 }
+146−0
1+import { GitBranch, Search } from "lucide-react";
2+import { Form, Link } from "react-router";
3+
4+import type { Route } from "./+types/branches";
5+import { ActiveBranches } from "../../components/branches";
6+import { Avatar, EmptyState, TimeAgo, notACredential } from "../../components/ui";
7+import { readBranches } from "../../lib/branches.server";
8+import { page } from "../../lib/meta";
9+import { deployments, repos, work } from "../../lib/services.server";
10+import { getViewer, unwrap } from "../../lib/session.server";
11+
12+/** The most branches read for one page; past that, search narrows them. */
13+const BRANCHES_READ = 60;
14+/** A branch whose last commit is older than this is stale. */
15+const STALE_DAYS = 90;
16+
17+export function meta({ params, ...args }: Route.MetaArgs) {
18+ return page(args, { title: `Branches · ${params.owner}/${params.repo} · g1t` });
19+}
20+
21+export async function loader({ params, context, request }: Route.LoaderArgs) {
22+ const path = { namespace: params.owner, name: params.repo };
23+ const viewer = getViewer(context);
24+ const query = new URL(request.url).searchParams.get("q")?.trim().toLowerCase() || null;
25+ const soft = <T,>(promise: Promise<T>): Promise<T | null> => promise.catch(() => null);
26+ const [repo, list, pulls, deploys] = await Promise.all([
27+ repos.get(path, viewer),
28+ repos.branches(path, viewer),
29+ soft(work.listPulls(path, viewer, "open")),
30+ // Previews are for people with a role here; everyone else sees none.
31+ soft(deployments.list({ workspace: params.owner, slug: params.repo }, viewer)),
32+ ]);
33+ const found = unwrap(repo);
34+ // The merge queue's own branches (g1t-queue/<entry>) are its working
35+ // copies, not anyone's branch to look at.
36+ const all = unwrap(list).filter((branch) => !branch.name.startsWith("g1t-queue/"));
37+ const matching = query ? all.filter((branch) => branch.name === found.defaultBranch || branch.name.toLowerCase().includes(query)) : all;
38+ const read = await readBranches(
39+ path,
40+ viewer,
41+ {
42+ defaultBranch: found.defaultBranch,
43+ branches: matching,
44+ pulls: pulls?.ok ? pulls.value : [],
45+ previews: deploys?.ok ? deploys.value.live.filter((app) => app.kind === "preview") : [],
46+ },
47+ BRANCHES_READ,
48+ );
49+ const staleBefore = Date.now() - STALE_DAYS * 86_400_000;
50+ const isStale = (at: string | undefined) => at != null && Date.parse(at) < staleBefore;
51+ return {
52+ main: found.defaultBranch,
53+ head: read.head,
54+ active: read.shown.filter((branch) => !isStale(branch.commit?.at)),
55+ stale: read.shown.filter((branch) => isStale(branch.commit?.at)),
56+ // Every branch but the default, and how many matched the search.
57+ total: all.length - 1,
58+ matched: read.total,
59+ unread: Math.max(0, read.total - read.shown.length),
60+ query,
61+ };
62+}
63+
64+export default function Branches({ loaderData, params }: Route.ComponentProps) {
65+ const { main, head, active, stale, total, matched, unread, query } = loaderData;
66+ const base = `/${params.owner}/${params.repo}`;
67+ return (
68+ <div className="space-y-6">
69+ <div className="flex flex-wrap items-center justify-between gap-3">
70+ <h2 className="text-lg font-semibold tracking-tight">
71+ Branches <span className="font-normal text-faint">{total + 1}</span>
72+ </h2>
73+ <Form method="get" role="search" className="relative w-full sm:w-72">
74+ <Search size={14} className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-faint" />
75+ <input
76+ name="q"
77+ defaultValue={query ?? ""}
78+ {...notACredential()}
79+ placeholder="Search branches"
80+ aria-label="Search branches"
81+ className="h-8 w-full rounded-md border border-line bg-surface pr-3 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
82+ />
83+ </Form>
84+ </div>
85+
86+ <section>
87+ <h3 className="mb-2 text-sm font-medium text-muted">Default</h3>
88+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-xl border border-line bg-surface px-4 py-3">
89+ <GitBranch size={15} className="shrink-0 text-faint" />
90+ <Link to={`${base}/tree/${encodeURIComponent(main)}`} className="font-mono text-[0.8125rem] font-medium hover:text-accent">
91+ {main}
92+ </Link>
93+ <span className="rounded-full border border-line px-2 py-px text-xs text-muted">default</span>
94+ {head && (
95+ <span className="flex min-w-0 grow basis-64 items-center gap-1.5 text-xs text-muted">
96+ <Avatar name={head.author} size={13} />
97+ <span className="shrink-0">{head.author}</span>
98+ <span className="text-faint">·</span>
99+ <Link to={`${base}/commit/${head.hash}`} className="min-w-0 truncate hover:text-fg" title={head.message}>
100+ {head.message}
101+ </Link>
102+ <span className="shrink-0 text-faint">
103+ · <TimeAgo at={head.at} />
104+ </span>
105+ </span>
106+ )}
107+ </div>
108+ </section>
109+
110+ {query && (
111+ <p className="text-sm text-muted">
112+ {matched} of {total} {total === 1 ? "branch matches" : "branches match"} “{query}”.{" "}
113+ <Link to={`${base}/branches`} className="text-fg hover:underline">
114+ Clear
115+ </Link>
116+ </p>
117+ )}
118+
119+ {active.length > 0 && (
120+ <section>
121+ <h3 className="mb-2 text-sm font-medium text-muted">Active</h3>
122+ <ActiveBranches branches={active} base={base} main={main} />
123+ </section>
124+ )}
125+ {stale.length > 0 && (
126+ <section>
127+ <h3 className="mb-2 text-sm font-medium text-muted">Stale</h3>
128+ <p className="-mt-1 mb-2 text-xs text-faint">No commits in the last 90 days.</p>
129+ <ActiveBranches branches={stale} base={base} main={main} />
130+ </section>
131+ )}
132+ {active.length === 0 && stale.length === 0 && (
133+ <EmptyState title={query ? "No branch matches" : "Only the default branch"}>
134+ {query
135+ ? "Try another part of the name."
136+ : "Branches agents and people push show here, each with its pull request, checks and how far it has moved from the default branch."}
137+ </EmptyState>
138+ )}
139+ {unread > 0 && (
140+ <p className="text-xs text-faint">
141+ {unread} more {unread === 1 ? "branch is" : "branches are"} not shown. Search to find one by name.
142+ </p>
143+ )}
144+ </div>
145+ );
146+}
+5−2
11 import type { Route } from "./+types/code";
22 import { TreeView } from "../../components/repo-view";
33 import { page } from "../../lib/meta";
4+import { lastCommitsFor } from "../../lib/last-commits.server";
45 import { repos } from "../../lib/services.server";
56 import { getViewer, unwrap } from "../../lib/session.server";
67
1617 // For the branch menu; the page still shows without it.
1718 repos.branches(path, viewer).catch(() => null),
1819 ]);
19− return { tree: unwrap(tree), branches: branches?.ok ? branches.value : null };
20+ // Each entry's last commit walks history: streamed in after the list.
21+ const lastCommits = lastCommitsFor(path, viewer, null, "");
22+ return { tree: unwrap(tree), branches: branches?.ok ? branches.value : null, lastCommits };
2023 }
2124
2225 export default function Code({ loaderData }: Route.ComponentProps) {
23− return <TreeView tree={loaderData.tree} branches={loaderData.branches} />;
26+ return <TreeView tree={loaderData.tree} branches={loaderData.branches} lastCommits={loaderData.lastCommits} />;
2427 }
+7−3
3434 ]);
3535 // Set not to deploy, in General settings: turning them on waits for that to change.
3636 const notDeploying = project?.ok ? project.value.deploys === "no" : false;
37− // Someone outside the workspace does not see its plans; the page works without.
38− const plan = features.ok ? (features.value.find((state) => state.plan.feature === "deployments") ?? null) : null;
37+ // Deployments come with the g1t plan. Someone outside the workspace does
38+ // not see its plans; the page works without, and the deployments service
39+ // refuses a deploy the plan does not cover.
40+ const plan = features.ok
41+ ? (features.value.find((state) => state.plan.feature === "plan" || state.plan.feature === "deployments") ?? null)
42+ : null;
3943 return { can: access.can, settings: unwrap(settings), ...unwrap(list), plan, computeNote, notDeploying };
4044 }
4145
106110 {!actionData && <ComputeNote note={loaderData.computeNote} />}
107111 </div>
108112
109− {!plan?.on ? (
113+ {plan != null && !plan.on && !plan.included && live.length === 0 ? (
110114 <PlanNeeded plan={plan} owner={params.owner} />
111115 ) : !settings.enabled ? (
112116 <section className="mt-2 rounded-xl border border-accent/30 bg-accent/5 p-6">
+1−1
156156 const { pathname } = useLocation();
157157 const tabs = tabsFor(pathname.slice(base.length + 1), member, access.can);
158158 // The files' own About says what it is and its topics, as the one place.
159− const filesPage = /^(code|tree)(\/|$)/.test(pathname.slice(base.length + 1));
159+ const filesPage = /^(code|tree|blob|commits?|branches|tags)(\/|$)/.test(pathname.slice(base.length + 1));
160160 // Everyone, signed in or not, finds the project's pages in the sidebar;
161161 // the page shows its name, and the views of the page it is on as tabs.
162162 return (
+14−36
6464 rankNeeds,
6565 stuckMinutes,
6666 } from "../../lib/mission";
67−import { drift } from "../../lib/branches";
6867 import { agentWasAssigned, hasInstructions, productionChecklist, releaseChecklist } from "../../lib/checklist";
6968 import { ECOSYSTEM_LABEL, installCommands } from "../../lib/packages";
7069 import { PUBLISH_GUIDES, hasRelease, libraryPackages, packageName, packagePath, publishGuide } from "../../lib/project-kind";
7372 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
7473 import { accessTo, countsFor, refusal, repoFor } from "../../lib/access.server";
7574 import { shotVersion } from "./production-screenshot";
75+import { readBranches } from "../../lib/branches.server";
7676
7777 const MAX_LANDED = 6;
7878 /** Branches read for the Active branches list, and shown. */
7979 const BRANCHES_READ = 10;
8080 const BRANCHES_SHOWN = 5;
81−/** How far back each branch's history, and the default branch's, is read to count ahead and behind. */
82−const BRANCH_DEPTH = 40;
83−const MAIN_DEPTH = 120;
8481 /** A library's packages shown on its overview; the rest are a link away. */
8582 const PACKAGES_SHOWN = 3;
8683
136133 async ([repo, branchList, pulls, deploys]): Promise<{ main: string; total: number; shown: ActiveBranch[] } | null> => {
137134 if (!repo?.ok || !branchList?.ok) return null;
138135 const main = repo.value.defaultBranch;
139− const pullList = pulls?.ok ? pulls.value : [];
140− const pullOn = new Map(pullList.filter((pull) => pull.branch).map((pull) => [pull.branch as string, pull]));
141− const others = branchList.value.filter((branch) => branch.name !== main);
142− if (others.length === 0) return { main, total: 0, shown: [] };
143− const read = [...others.filter((b) => pullOn.has(b.name)), ...others.filter((b) => !pullOn.has(b.name))].slice(0, BRANCHES_READ);
144− // By commit hash, not name: history from a commit never changes, so
145− // repos keeps it (services/repos/src/store.rs) and only new heads cost
146− // a walk.
147− const mainHead = branchList.value.find((branch) => branch.name === main)?.hash ?? main;
148− const [mainLog, ...logs] = await Promise.all([
149− soft(repos.log(path, viewer, mainHead, MAIN_DEPTH)),
150− ...read.map((branch) => soft(repos.log(path, viewer, branch.hash || branch.name, BRANCH_DEPTH))),
151− ]);
152− const mainHashes = mainLog?.ok ? mainLog.value.map((c) => c.hash) : [];
153− const previews = deploys?.ok ? deploys.value.live.filter((app) => app.kind === "preview") : [];
154− const shown = read
155− .map((branch, index): ActiveBranch => {
156− const history = logs[index]?.ok ? logs[index].value : [];
157− const head = history[0];
158− const moved = drift(history.map((c) => c.hash), mainHashes, BRANCH_DEPTH);
159− const pull = pullOn.get(branch.name);
160− return {
161− name: branch.name,
162− commit: head ? { hash: head.hash, message: head.message.split("\n")[0], author: head.author.name, at: head.authoredAt } : null,
163− ...moved,
164− pull: pull ? { number: pull.number, title: pull.title, checkStatus: pull.checkStatus, draft: pull.status === "draft" } : null,
165− preview: previews.find((app) => app.branch === branch.name || (pull != null && app.number === pull.number))?.url ?? null,
166− };
167− })
168− .sort((a, b) => Date.parse(b.commit?.at ?? "0") - Date.parse(a.commit?.at ?? "0"))
169− .slice(0, BRANCHES_SHOWN);
170− return { main, total: others.length, shown };
136+ if (branchList.value.every((branch) => branch.name === main)) return { main, total: 0, shown: [] };
137+ const read = await readBranches(
138+ path,
139+ viewer,
140+ {
141+ defaultBranch: main,
142+ branches: branchList.value,
143+ pulls: pulls?.ok ? pulls.value : [],
144+ previews: deploys?.ok ? deploys.value.live.filter((app) => app.kind === "preview") : [],
145+ },
146+ BRANCHES_READ,
147+ );
148+ return { main, total: read.total, shown: read.shown.slice(0, BRANCHES_SHOWN) };
171149 },
172150 );
173151 // Active branches read several logs each: streamed, so the rest shows first.
+21−5
1010 import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
1111 import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
1212 import { DEPLOYS_CHOICES } from "../../lib/project-kind";
13−import { deployments, projects } from "../../lib/services.server";
13+import { deployments, identity, projects } from "../../lib/services.server";
1414 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
1515 import { requireCapability, requireInsider } from "../../lib/access.server";
1616
2828 deployments.settings({ workspace: params.owner, slug: params.repo }, viewer).catch(() => null),
2929 ]);
3030 const found = unwrap(project);
31− // Who made it is kept as an id; name it only when it was the viewer.
32− return { project: found, deploymentsOn: deploys?.ok ? deploys.value.enabled : null, mine: viewer?.id === found.createdBy };
31+ // Who made it is kept as an id: named by identity, or left out if it cannot say.
32+ const names = await identity.usernames([found.createdBy]).catch(() => ({}) as Record<string, string>);
33+ return {
34+ project: found,
35+ deploymentsOn: deploys?.ok ? deploys.value.enabled : null,
36+ mine: viewer?.id === found.createdBy,
37+ creator: names[found.createdBy] ?? null,
38+ };
3339 }
3440
3541 export async function action({ request, params, context }: Route.ActionArgs) {
4854 }
4955
5056 export default function ProjectSettings({ loaderData, actionData, params }: Route.ComponentProps) {
51− const { project, deploymentsOn, mine } = loaderData;
57+ const { project, deploymentsOn, mine, creator } = loaderData;
5258 const [deploys, setDeploys] = useState<DeploysSetting>(project.deploys);
5359 // Not deploying while Deployments are on is refused: they are turned off first.
5460 const blocked = deploys === "no" && project.deploys !== "no" && deploymentsOn === true;
184190 </Button>
185191 {actionData && "saved" in actionData && <span className="text-sm text-accent">Saved.</span>}
186192 <span className="ml-auto text-xs text-faint">
187− Created {mine && "by you "}
193+ Created{" "}
194+ {mine ? (
195+ "by you "
196+ ) : creator ? (
197+ <>
198+ by{" "}
199+ <Link to={`/${creator}`} className="text-muted hover:text-fg">
200+ {creator}
201+ </Link>{" "}
202+ </>
203+ ) : null}
188204 <TimeAgo at={project.createdAt} />
189205 </span>
190206 </div>
+77−0
1+import { FileArchive, Tag as TagIcon } from "lucide-react";
2+import { Link } from "react-router";
3+
4+import type { Route } from "./+types/tags";
5+import { Avatar, EmptyState, TimeAgo } from "../../components/ui";
6+import { page } from "../../lib/meta";
7+import { repos } from "../../lib/services.server";
8+import { getViewer, unwrap } from "../../lib/session.server";
9+
10+export function meta({ params, ...args }: Route.MetaArgs) {
11+ return page(args, { title: `Tags · ${params.owner}/${params.repo} · g1t` });
12+}
13+
14+export async function loader({ params, context }: Route.LoaderArgs) {
15+ const path = { namespace: params.owner, name: params.repo };
16+ return { tags: unwrap(await repos.tags(path, getViewer(context))) };
17+}
18+
19+export default function Tags({ loaderData, params }: Route.ComponentProps) {
20+ const { tags } = loaderData;
21+ const base = `/${params.owner}/${params.repo}`;
22+ const path = (name: string) => name.split("/").map(encodeURIComponent).join("/");
23+ return (
24+ <div className="space-y-4">
25+ <h2 className="text-lg font-semibold tracking-tight">
26+ Tags <span className="font-normal text-faint">{tags.length}</span>
27+ </h2>
28+ {tags.length === 0 ? (
29+ <EmptyState title="No tags yet">
30+ A tag marks a version of the code: <code className="font-mono text-xs">git tag v1.0.0 && git push --tags</code>. Each
31+ one shows here with its commit and a download.
32+ </EmptyState>
33+ ) : (
34+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
35+ {tags.map((tag) => (
36+ <li key={tag.name} className="flex flex-wrap items-center gap-x-3 gap-y-1.5 px-4 py-3">
37+ <TagIcon size={15} className="shrink-0 text-faint" />
38+ <span className="min-w-0 grow basis-56">
39+ <Link to={`${base}/tree/${path(tag.name)}`} className="block truncate font-mono text-[0.8125rem] font-medium hover:text-accent">
40+ {tag.name}
41+ </Link>
42+ {tag.commit && (
43+ <span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted">
44+ <Avatar name={tag.commit.author.name} size={13} />
45+ <span className="shrink-0">{tag.commit.author.name}</span>
46+ <span className="text-faint">·</span>
47+ <Link to={`${base}/commit/${tag.commit.hash}`} className="min-w-0 truncate hover:text-fg" title={tag.commit.message}>
48+ {tag.commit.message.split("\n")[0]}
49+ </Link>
50+ <span className="shrink-0 text-faint">
51+ · <TimeAgo at={tag.commit.authoredAt} />
52+ </span>
53+ </span>
54+ )}
55+ </span>
56+ <span className="ml-6.5 flex shrink-0 items-center gap-3 text-xs sm:ml-0">
57+ {tag.commit && (
58+ <Link to={`${base}/commit/${tag.commit.hash}`} className="font-mono text-faint hover:text-fg">
59+ {tag.commit.hash.slice(0, 7)}
60+ </Link>
61+ )}
62+ <a
63+ href={`${base}/archive/${path(tag.name)}.zip`}
64+ download
65+ className="inline-flex items-center gap-1.5 rounded-md border border-line px-1.5 py-0.5 text-muted hover:border-line-strong hover:text-fg"
66+ >
67+ <FileArchive size={12} />
68+ ZIP
69+ </a>
70+ </span>
71+ </li>
72+ ))}
73+ </ul>
74+ )}
75+ </div>
76+ );
77+}
+5−2
22 import { page } from "../../lib/meta";
33 import { TreeView } from "../../components/repo-view";
44 import { redirectIfBranchRenamed } from "../../lib/branch-redirect.server";
5+import { lastCommitsFor } from "../../lib/last-commits.server";
56 import { repos } from "../../lib/services.server";
67 import { getViewer, unwrap } from "../../lib/session.server";
78
2021 ]);
2122 // A branch that was renamed: the same folder on its new name.
2223 if (!tree.ok && tree.error.code === "not_found") await redirectIfBranchRenamed(request, path, viewer, params.ref);
23− return { tree: unwrap(tree), branches: branches?.ok ? branches.value : null };
24+ // Each entry's last commit walks history: streamed in after the list.
25+ const lastCommits = lastCommitsFor(path, viewer, params.ref, params["*"] ?? "");
26+ return { tree: unwrap(tree), branches: branches?.ok ? branches.value : null, lastCommits };
2427 }
2528
2629 export default function Tree({ loaderData }: Route.ComponentProps) {
27− return <TreeView tree={loaderData.tree} branches={loaderData.branches} />;
30+ return <TreeView tree={loaderData.tree} branches={loaderData.branches} lastCommits={loaderData.lastCommits} />;
2831 }
+10−1
121121 {commands.registry && <CopyLine prompt text={commands.registry} />}
122122 {pkg.visibility === "private" && <CopyLine prompt text={commands.login} />}
123123 <CopyLine prompt text={commands.install} />
124− {commands.registry && pkg.visibility === "private" && (
124+ {commands.registry && pkg.visibility === "private" && pkg.ecosystem === "cargo" && (
125+ <p className="text-xs text-faint">
126+ <code className="font-mono">cargo login</code> asks for an{" "}
127+ <Link to="/settings/tokens" className="text-muted hover:text-fg">
128+ access token
129+ </Link>{" "}
130+ with <code className="font-mono">packages:read</code>.
131+ </p>
132+ )}
133+ {commands.registry && pkg.visibility === "private" && pkg.ecosystem !== "cargo" && (
125134 <p className="text-xs text-faint">
126135 Put an{" "}
127136 <Link to="/settings/tokens" className="text-muted hover:text-fg">
+38−24
44 import { MICROS_PER_DOLLAR, type UsageSlice } from "@g1t/contracts";
55
66 import type { Route } from "./+types/usage";
7−import { foldTasks, usageTask } from "../../lib/billing";
7+import { foldTasks, planStatus, usageTask } from "../../lib/billing";
88 import { page } from "../../lib/meta";
99 import { ButtonLink } from "../../components/ui";
1010 import { billing } from "../../lib/services.server";
3838 const asked = new URL(request.url).searchParams.get("period");
3939 const period: Period = asked && asked in PERIODS ? (asked as Period) : "month";
4040 const since = start(period);
41− const [usage, account] = await Promise.all([
41+ const [usage, account, features, entitlements] = await Promise.all([
4242 billing.usage(params.owner, viewer, since.toISOString()),
4343 billing.account(params.owner, viewer),
44+ billing.features(params.owner, viewer).catch(() => null),
45+ billing.entitlements(params.owner).catch(() => null),
4446 ]);
45− return { period, since: since.toISOString(), usage: unwrap(usage), account: unwrap(account) };
47+ const plan = features?.ok ? (features.value.find((state) => state.plan.feature === "plan") ?? null) : null;
48+ // A comped workspace is charged nothing, so it has no credit to run down.
49+ const comped = planStatus(plan, entitlements).kind === "comped";
50+ return { period, since: since.toISOString(), usage: unwrap(usage), account: unwrap(account), comped };
4651 }
4752
4853 function dollars(micros: number, digits = 2): string {
180185 }
181186
182187 export default function UsagePage({ loaderData, params }: Route.ComponentProps) {
183− const { period, since, usage, account } = loaderData;
188+ const { period, since, usage, account, comped } = loaderData;
184189 const byTask = foldTasks(usage.byTask);
185190 const base = `/${params.owner}`;
186191 const days = Math.max(1, Math.ceil((Date.now() - new Date(since).getTime()) / 86_400_000));
232237 value={usage.runs ? dollars(total / usage.runs, 3) : "—"}
233238 note="Making a change, reviewing, revising…"
234239 />
235− <div className="rounded-2xl bg-surface p-5 ring-1 ring-line">
236− <p className="flex items-center justify-between text-sm text-muted">
237− Credit left
238− {!usage.free && (
239− <Link to={`${base}/-/billing`} className="text-xs text-accent hover:underline">
240− Add credit
241− </Link>
242− )}
243− </p>
244− <p className={`mt-2 text-3xl font-semibold tracking-tight tabular-nums ${account.balanceMicros <= 0 ? "text-warn" : ""}`}>
245− {dollars(account.balanceMicros)}
246− </p>
247− <p className="mt-1 text-xs text-faint">
248− {usage.free
249− ? "Not drawn down while g1t is free."
250− : runway == null
251− ? "Nothing spent in this period."
252− : `About ${runway} ${runway === 1 ? "day" : "days"} at this rate.`}
253− </p>
254− </div>
240+ {comped ? (
241+ // Nothing is charged to a comped workspace: no credit to run down.
242+ <div className="rounded-2xl bg-surface p-5 ring-1 ring-line">
243+ <p className="text-sm text-muted">Credit</p>
244+ <p className="mt-2 text-3xl font-semibold tracking-tight text-accent">Comped</p>
245+ <p className="mt-1 text-xs text-faint">Recorded at what it costs; nothing is charged to this workspace.</p>
246+ </div>
247+ ) : (
248+ <div className="rounded-2xl bg-surface p-5 ring-1 ring-line">
249+ <p className="flex items-center justify-between text-sm text-muted">
250+ Credit left
251+ {!usage.free && (
252+ <Link to={`${base}/-/billing`} className="text-xs text-accent hover:underline">
253+ Add credit
254+ </Link>
255+ )}
256+ </p>
257+ <p className={`mt-2 text-3xl font-semibold tracking-tight tabular-nums ${account.balanceMicros <= 0 ? "text-warn" : ""}`}>
258+ {dollars(account.balanceMicros)}
259+ </p>
260+ <p className="mt-1 text-xs text-faint">
261+ {usage.free
262+ ? "Not drawn down while g1t is free."
263+ : runway == null
264+ ? "Nothing spent in this period."
265+ : `About ${runway} ${runway === 1 ? "day" : "days"} at this rate.`}
266+ </p>
267+ </div>
268+ )}
255269 </div>
256270
257271 <section className="rounded-2xl bg-surface p-5 ring-1 ring-line">
+47−0
565565 pub hash: String,
566566 }
567567
568+/// `last_commits`: which commit last changed each entry of a directory at
569+/// `ref` (the default branch when absent). Returns `Outcome<LastCommits>`.
570+#[derive(Debug, Serialize, Deserialize)]
571+#[serde(rename_all = "camelCase")]
572+pub struct LastCommitsArgs {
573+ pub path: RepoPath,
574+ pub viewer: Viewer,
575+ #[serde(default, rename = "ref")]
576+ pub git_ref: Option<String>,
577+ #[serde(default)]
578+ pub tree_path: String,
579+ /// Answer within this many milliseconds with what was found, not kept;
580+ /// absent, the walk runs to the end and is kept.
581+ #[serde(default)]
582+ pub budget_ms: Option<u64>,
583+}
584+
585+/// An entry of a directory and the commit that last changed it.
586+#[derive(Clone, Debug, Serialize, Deserialize)]
587+pub struct LastCommit {
588+ pub name: String,
589+ pub commit: Commit,
590+}
591+
592+/// The entries' last commits. `complete` is false when the history walked
593+/// ran out before every entry was placed; those entries are left out.
594+#[derive(Clone, Debug, Serialize, Deserialize)]
595+pub struct LastCommits {
596+ pub entries: Vec<LastCommit>,
597+ pub complete: bool,
598+}
599+
600+/// `tags`: the repository's tags, newest commit first, each with the
601+/// commit it names. Returns `Outcome<Vec<Tag>>`.
602+#[derive(Debug, Serialize, Deserialize)]
603+pub struct TagsArgs {
604+ pub path: RepoPath,
605+ pub viewer: Viewer,
606+}
607+
608+/// A tag, and its commit when it could be read.
609+#[derive(Clone, Debug, Serialize, Deserialize)]
610+pub struct Tag {
611+ pub name: String,
612+ pub commit: Option<Commit>,
613+}
614+
568615 /// `branches`: the repository's branches, default branch first.
569616 /// Returns `Outcome<Vec<Branch>>`.
570617 #[derive(Debug, Serialize, Deserialize)]
+2−0
285285 gitAccess: (path, viewer, service) =>
286286 call("git_access", { path, viewer, service }),
287287 branches: (path, viewer) => call("branches", { path, viewer }),
288+ lastCommits: (path, viewer, ref, treePath) => call("last_commits", { path, viewer, ref, treePath }),
289+ tags: (path, viewer) => call("tags", { path, viewer }),
288290 listFiles: (repoId, ref, limit) => call("list_files", { repoId, ref, skipDirs: [], limit }),
289291 rawBlobs: (repoId, hashes, maxBytes) => call("raw_blobs", { repoId, hashes, maxBytes }),
290292 commitFile: (repo, actor, file) => call("commit_file", { repo, actor, ...file }),
+2−1
105105 * One branch moved by a push. `ref` is the full ref, `after` the commit it
106106 * points to now, and `defaultBranch` whether it is the default branch.
107107 */
108− "git.push": { repoId: string; ref: string; after: string; defaultBranch: boolean };
108+ /** `before` is where the ref pointed before; absent for a new branch or tag. */
109+ "git.push": { repoId: string; ref: string; before?: string; after: string; defaultBranch: boolean };
109110 /**
110111 * `author` is who opened it: g1t, for one its agent filed while at work,
111112 * with `requestedBy` the person it was working for. Every issue and pull
+12−0
234234 /** The repository's branches, default branch first. */
235235 branches(path: RepoPath, viewer: Viewer): Promise<Result<Branch[]>>;
236236
237+ /** Which commit last changed each entry of a directory at `ref` (the default branch when null). */
238+ lastCommits(path: RepoPath, viewer: Viewer, ref: string | null, treePath: string): Promise<Result<LastCommits>>;
239+
240+ /** The repository's tags, newest commit first, at most 100. */
241+ tags(path: RepoPath, viewer: Viewer): Promise<Result<Tag[]>>;
242+
237243 /**
238244 * Every file at `ref` (the default branch when null), at most `limit`
239245 * (10,000 at most). No viewer: check access first.
291297 /** A branch and the commit it points to. */
292298 export type Branch = { name: string; hash: string };
293299
300+/** Each entry's last commit; `complete` is false when some were not reached. */
301+export type LastCommits = { entries: { name: string; commit: Commit }[]; complete: boolean };
302+
303+/** A tag, and its commit when it could be read. */
304+export type Tag = { name: string; commit: Commit | null };
305+
294306 /** Files at a commit, and whether there were more than were listed. */
295307 export type FileList = { commit: string | null; files: { path: string; hash: string | null }[]; truncated: boolean };
296308
+426−0
1+//! What the Cargo registry needs that does not touch the network: crate
2+//! names, where a crate's index file is, the publish body cargo sends, and
3+//! the index line each version is.
4+//!
5+//! A version keeps its index entry (without `yanked`, which is a column of
6+//! its own) as its metadata, made once when it is published; the index
7+//! file is those entries, one JSON line each, oldest first.
8+
9+use serde_json::{Map, Value, json};
10+
11+/// The longest crate name crates.io allows.
12+pub const MAX_NAME: usize = 64;
13+
14+/// Names Windows keeps for devices: a crate named so could not be checked
15+/// out of a git index, and cargo refuses them too.
16+const RESERVED: [&str; 22] = [
17+ "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", "lpt1", "lpt2", "lpt3",
18+ "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
19+];
20+
21+/// Checks crates.io's rules for a crate name: ASCII letters, digits, `-`
22+/// and `_`, starting with a letter, at most 64 characters.
23+pub fn valid_name(name: &str) -> Result<(), String> {
24+ if name.is_empty() {
25+ return Err("The crate has no name.".to_owned());
26+ }
27+ if name.len() > MAX_NAME {
28+ return Err(format!("A crate name is at most {MAX_NAME} characters."));
29+ }
30+ if !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') {
31+ return Err(format!("{name} is not a valid crate name: ASCII letters, digits, `-` and `_` only."));
32+ }
33+ if !name.as_bytes()[0].is_ascii_alphabetic() {
34+ return Err(format!("{name} is not a valid crate name: it must start with a letter."));
35+ }
36+ if RESERVED.contains(&name.to_ascii_lowercase().as_str()) {
37+ return Err(format!("{name} is a reserved name."));
38+ }
39+ Ok(())
40+}
41+
42+/// The name two crates may not share: case and `-` against `_` aside, as
43+/// crates.io decides whether a name is taken.
44+pub fn folded(name: &str) -> String {
45+ name.to_ascii_lowercase().replace('_', "-")
46+}
47+
48+/// Where a crate's file is in a sparse index, lowercased: `1/a`, `2/ab`,
49+/// `3/a/abc`, `se/rd/serde`.
50+pub fn index_path(name: &str) -> String {
51+ let name = name.to_ascii_lowercase();
52+ match name.len() {
53+ 1 => format!("1/{name}"),
54+ 2 => format!("2/{name}"),
55+ 3 => format!("3/{}/{name}", &name[..1]),
56+ _ => format!("{}/{}/{name}", &name[..2], &name[2..4]),
57+ }
58+}
59+
60+/// The crate an index path names, when it is where that crate's file is.
61+/// Cargo asks with the lowercased path; any case is taken.
62+pub fn name_of_index_path(path: &str) -> Option<String> {
63+ let name = path.rsplit('/').next()?;
64+ valid_name(name).ok()?;
65+ (index_path(name) == path.to_ascii_lowercase()).then(|| name.to_owned())
66+}
67+
68+/// One of the registry's endpoints, under `/-/cargo/<workspace>/`.
69+#[derive(Clone, Debug, PartialEq, Eq)]
70+pub enum CargoRoute {
71+ /// `index/config.json`.
72+ Config,
73+ /// `index/<path>`: a crate's index file.
74+ Index { name: String },
75+ /// `api/v1/crates/new`.
76+ Publish,
77+ /// `api/v1/crates?q=`: `cargo search`.
78+ Search,
79+ Yank { name: String, version: String },
80+ Unyank { name: String, version: String },
81+ Download { name: String, version: String },
82+ Owners { name: String },
83+}
84+
85+/// The workspace and endpoint a path is. Names are checked; versions are not.
86+pub fn route(path: &str) -> Option<(String, CargoRoute)> {
87+ let rest = path.strip_prefix("/-/cargo/")?;
88+ let (workspace, rest) = rest.split_once('/')?;
89+ let workspace = workspace.to_ascii_lowercase();
90+ if workspace.is_empty() {
91+ return None;
92+ }
93+ if let Some(index) = rest.strip_prefix("index/") {
94+ if index == "config.json" {
95+ return Some((workspace, CargoRoute::Config));
96+ }
97+ return name_of_index_path(index).map(|name| (workspace, CargoRoute::Index { name }));
98+ }
99+ let crates = rest.strip_prefix("api/v1/crates")?;
100+ if crates.is_empty() || crates == "/" {
101+ return Some((workspace, CargoRoute::Search));
102+ }
103+ let parts: Vec<&str> = crates.strip_prefix('/')?.split('/').collect();
104+ let named = |name: &str| valid_name(name).is_ok().then(|| name.to_owned());
105+ let route = match parts.as_slice() {
106+ ["new"] => CargoRoute::Publish,
107+ [name, "owners"] => CargoRoute::Owners { name: named(name)? },
108+ [name, version, action] if !version.is_empty() => {
109+ let (name, version) = (named(name)?, (*version).to_owned());
110+ match *action {
111+ "yank" => CargoRoute::Yank { name, version },
112+ "unyank" => CargoRoute::Unyank { name, version },
113+ "download" => CargoRoute::Download { name, version },
114+ _ => return None,
115+ }
116+ }
117+ _ => return None,
118+ };
119+ Some((workspace, route))
120+}
121+
122+/// The two parts of `cargo publish`'s body: a little-endian `u32` length
123+/// and the JSON metadata, then a `u32` length and the `.crate` file.
124+pub fn parse_publish(body: &[u8]) -> Result<(Value, &[u8]), String> {
125+ let take = |at: usize| -> Result<(usize, usize), String> {
126+ let length = body.get(at..at + 4).ok_or("The publish ends early.")?;
127+ let length = u32::from_le_bytes([length[0], length[1], length[2], length[3]]) as usize;
128+ let start = at + 4;
129+ if body.len() < start + length {
130+ return Err("The publish ends early.".to_owned());
131+ }
132+ Ok((start, start + length))
133+ };
134+ let (json_start, json_end) = take(0)?;
135+ let metadata: Value = serde_json::from_slice(&body[json_start..json_end]).map_err(|_| "The publish's metadata is not JSON.")?;
136+ if !metadata.is_object() {
137+ return Err("The publish's metadata is not a JSON object.".to_owned());
138+ }
139+ let (crate_start, crate_end) = take(json_end)?;
140+ if crate_end != body.len() {
141+ return Err("The publish has bytes after the .crate file.".to_owned());
142+ }
143+ Ok((metadata, &body[crate_start..crate_end]))
144+}
145+
146+/// The version without its build metadata: `1.0.0+abc` is `1.0.0`. Two
147+/// versions that differ only in it may not both be published.
148+pub fn without_build(version: &str) -> &str {
149+ version.split_once('+').map_or(version, |(core, _)| core)
150+}
151+
152+/// Whether a feature's list uses the syntax only newer cargo reads
153+/// (`dep:name`, `name?/feature`), so it belongs in `features2`.
154+fn new_syntax(values: &Value) -> bool {
155+ values
156+ .as_array()
157+ .is_some_and(|values| values.iter().filter_map(Value::as_str).any(|v| v.starts_with("dep:") || v.contains("?/")))
158+}
159+
160+/// A dependency as the index lists it, from how `cargo publish` sends it:
161+/// `version_req` is `req`, and a renamed one (`explicit_name_in_toml`) is
162+/// listed by its new name with `package` naming the crate.
163+fn index_dependency(sent: &Value) -> Result<Value, String> {
164+ let name = sent["name"].as_str().ok_or("A dependency has no name.")?;
165+ let req = sent["version_req"].as_str().ok_or_else(|| format!("The dependency {name} has no version requirement."))?;
166+ let mut dep = Map::new();
167+ let renamed = sent["explicit_name_in_toml"].as_str().filter(|n| !n.is_empty());
168+ dep.insert("name".into(), json!(renamed.unwrap_or(name)));
169+ dep.insert("req".into(), json!(req));
170+ dep.insert("features".into(), sent.get("features").filter(|f| f.is_array()).cloned().unwrap_or_else(|| json!([])));
171+ dep.insert("optional".into(), json!(sent["optional"].as_bool().unwrap_or(false)));
172+ dep.insert("default_features".into(), json!(sent["default_features"].as_bool().unwrap_or(true)));
173+ dep.insert("target".into(), sent.get("target").filter(|t| t.is_string()).cloned().unwrap_or(Value::Null));
174+ dep.insert("kind".into(), json!(sent["kind"].as_str().unwrap_or("normal")));
175+ if let Some(registry) = sent["registry"].as_str() {
176+ dep.insert("registry".into(), json!(registry));
177+ }
178+ if renamed.is_some() {
179+ dep.insert("package".into(), json!(name));
180+ }
181+ Ok(Value::Object(dep))
182+}
183+
184+/// A version's index entry, from the publish's metadata and the `.crate`
185+/// file's SHA-256 in hex. `yanked` is left out: it is added on each read.
186+pub fn index_entry(metadata: &Value, cksum: &str) -> Result<Value, String> {
187+ let name = metadata["name"].as_str().ok_or("The publish names no crate.")?;
188+ let vers = metadata["vers"].as_str().ok_or("The publish names no version.")?;
189+ let deps = match &metadata["deps"] {
190+ Value::Null => Vec::new(),
191+ Value::Array(deps) => deps.iter().map(index_dependency).collect::<Result<Vec<_>, _>>()?,
192+ _ => return Err("The publish's dependencies are not a list.".to_owned()),
193+ };
194+ let (mut features, mut features2) = (Map::new(), Map::new());
195+ match &metadata["features"] {
196+ Value::Null => {}
197+ Value::Object(sent) => {
198+ for (feature, values) in sent {
199+ if !values.is_array() {
200+ return Err(format!("The feature {feature} is not a list."));
201+ }
202+ let into = if new_syntax(values) { &mut features2 } else { &mut features };
203+ into.insert(feature.clone(), values.clone());
204+ }
205+ }
206+ _ => return Err("The publish's features are not an object.".to_owned()),
207+ }
208+ let mut entry = Map::new();
209+ entry.insert("name".into(), json!(name));
210+ entry.insert("vers".into(), json!(vers));
211+ entry.insert("deps".into(), Value::Array(deps));
212+ entry.insert("cksum".into(), json!(cksum));
213+ entry.insert("features".into(), Value::Object(features));
214+ entry.insert("links".into(), metadata.get("links").filter(|l| l.is_string()).cloned().unwrap_or(Value::Null));
215+ if !features2.is_empty() {
216+ entry.insert("features2".into(), Value::Object(features2));
217+ entry.insert("v".into(), json!(2));
218+ }
219+ if let Some(rust_version) = metadata["rust_version"].as_str() {
220+ entry.insert("rust_version".into(), json!(rust_version));
221+ }
222+ Ok(Value::Object(entry))
223+}
224+
225+/// One line of a crate's index file: the version's entry as kept, with
226+/// whether it is yanked.
227+pub fn index_line(entry: &Value, yanked: bool) -> String {
228+ let mut entry = match entry {
229+ Value::Object(map) => map.clone(),
230+ _ => Map::new(),
231+ };
232+ entry.insert("yanked".into(), json!(yanked));
233+ Value::Object(entry).to_string()
234+}
235+
236+/// What `index/config.json` says: where crates are downloaded from and the
237+/// web API is, under `base` (`https://g1t.sh/-/cargo/acme`), and whether
238+/// cargo must send its token for every request.
239+pub fn config(base: &str, auth_required: bool) -> Value {
240+ json!({ "dl": format!("{base}/api/v1/crates"), "api": base, "auth-required": auth_required })
241+}
242+
243+/// The token in cargo's `Authorization` header, which is the token alone;
244+/// `Bearer <token>` is taken too.
245+pub fn token(header: &str) -> Option<&str> {
246+ let header = header.trim();
247+ let token = match header.split_once(' ') {
248+ Some((scheme, rest)) if scheme.eq_ignore_ascii_case("bearer") => rest.trim(),
249+ Some(_) => return None,
250+ None => header,
251+ };
252+ (!token.is_empty()).then_some(token)
253+}
254+
255+#[cfg(test)]
256+mod tests {
257+ use super::*;
258+
259+ #[test]
260+ fn names_follow_crates_io_rules() {
261+ for good in ["serde", "serde_json", "tokio-util", "a", "A1", "Inflector", &"a".repeat(64)] {
262+ assert!(valid_name(good).is_ok(), "{good}");
263+ }
264+ for bad in ["", "1abc", "-abc", "_abc", "a.b", "a b", "naïve", "a/b", "nul", "COM1", &"a".repeat(65)] {
265+ assert!(valid_name(bad).is_err(), "{bad}");
266+ }
267+ assert_eq!(folded("Serde_Json"), folded("serde-json"), "case and -/_ are one name");
268+ assert_ne!(folded("serde"), folded("serde-json"));
269+ }
270+
271+ #[test]
272+ fn index_paths_are_the_standard_sparse_ones_in_lowercase() {
273+ assert_eq!(index_path("a"), "1/a");
274+ assert_eq!(index_path("ab"), "2/ab");
275+ assert_eq!(index_path("abc"), "3/a/abc");
276+ assert_eq!(index_path("serde"), "se/rd/serde");
277+ assert_eq!(index_path("Inflector"), "in/fl/inflector");
278+ assert_eq!(index_path("cargo"), "ca/rg/cargo");
279+ assert_eq!(name_of_index_path("se/rd/serde").as_deref(), Some("serde"));
280+ assert_eq!(name_of_index_path("3/a/abc").as_deref(), Some("abc"));
281+ assert_eq!(name_of_index_path("in/fl/Inflector").as_deref(), Some("Inflector"));
282+ assert_eq!(name_of_index_path("xx/rd/serde"), None, "not where serde's file is");
283+ assert_eq!(name_of_index_path("3/b/abc"), None);
284+ assert_eq!(name_of_index_path("1/ab"), None);
285+ assert_eq!(name_of_index_path("se/rd/se.de"), None);
286+ }
287+
288+ #[test]
289+ fn every_endpoint_is_routed() {
290+ let at = |route: CargoRoute| Some(("acme".to_owned(), route));
291+ let nv = |name: &str, version: &str| (name.to_owned(), version.to_owned());
292+ assert_eq!(route("/-/cargo/acme/index/config.json"), at(CargoRoute::Config));
293+ assert_eq!(route("/-/cargo/Acme/index/se/rd/serde"), at(CargoRoute::Index { name: "serde".into() }));
294+ assert_eq!(route("/-/cargo/acme/index/1/a"), at(CargoRoute::Index { name: "a".into() }));
295+ assert_eq!(route("/-/cargo/acme/api/v1/crates/new"), at(CargoRoute::Publish));
296+ assert_eq!(route("/-/cargo/acme/api/v1/crates"), at(CargoRoute::Search));
297+ let (name, version) = nv("serde", "1.0.0");
298+ assert_eq!(
299+ route("/-/cargo/acme/api/v1/crates/serde/1.0.0/yank"),
300+ at(CargoRoute::Yank { name: name.clone(), version: version.clone() })
301+ );
302+ assert_eq!(
303+ route("/-/cargo/acme/api/v1/crates/serde/1.0.0/unyank"),
304+ at(CargoRoute::Unyank { name: name.clone(), version: version.clone() })
305+ );
306+ assert_eq!(route("/-/cargo/acme/api/v1/crates/serde/1.0.0/download"), at(CargoRoute::Download { name, version }));
307+ assert_eq!(route("/-/cargo/acme/api/v1/crates/serde/owners"), at(CargoRoute::Owners { name: "serde".into() }));
308+ assert_eq!(route("/-/cargo/acme/api/v1/crates/serde/1.0.0/other"), None);
309+ assert_eq!(route("/-/cargo/acme/api/v1/crates/se.de/1.0.0/download"), None);
310+ assert_eq!(route("/-/cargo/acme/index/xx/yy/serde"), None);
311+ assert_eq!(route("/-/cargo/acme"), None);
312+ assert_eq!(route("/-/npm/@acme/web"), None);
313+ }
314+
315+ fn body(metadata: &[u8], krate: &[u8]) -> Vec<u8> {
316+ let mut body = (metadata.len() as u32).to_le_bytes().to_vec();
317+ body.extend_from_slice(metadata);
318+ body.extend_from_slice(&(krate.len() as u32).to_le_bytes());
319+ body.extend_from_slice(krate);
320+ body
321+ }
322+
323+ #[test]
324+ fn the_publish_body_is_two_length_prefixed_parts() {
325+ let sent = body(br#"{"name":"web","vers":"1.0.0"}"#, b"\x1f\x8bcrate");
326+ let (metadata, krate) = parse_publish(&sent).unwrap();
327+ assert_eq!(metadata["name"], "web");
328+ assert_eq!(krate, b"\x1f\x8bcrate");
329+ let empty = body(br#"{"name":"web"}"#, b"");
330+ assert_eq!(parse_publish(&empty).unwrap().1, b"");
331+
332+ assert!(parse_publish(b"").is_err());
333+ assert!(parse_publish(&[10, 0, 0, 0, b'{']).is_err(), "shorter than it says");
334+ assert!(parse_publish(&body(b"not json", b"x")).is_err());
335+ assert!(parse_publish(&body(b"[1]", b"x")).is_err());
336+ let mut cut = sent.clone();
337+ cut.pop();
338+ assert!(parse_publish(&cut).is_err(), "the crate is cut short");
339+ let mut long = sent.clone();
340+ long.push(0);
341+ assert!(parse_publish(&long).is_err(), "bytes after the crate");
342+ let no_crate = br#"{"name":"web"}"#;
343+ let mut half = (no_crate.len() as u32).to_le_bytes().to_vec();
344+ half.extend_from_slice(no_crate);
345+ assert!(parse_publish(&half).is_err(), "no crate length");
346+ }
347+
348+ #[test]
349+ fn index_lines_are_cargos_shape() {
350+ let metadata = json!({
351+ "name": "Web",
352+ "vers": "1.2.0",
353+ "deps": [
354+ { "name": "serde", "version_req": "^1", "features": ["derive"], "optional": false, "default_features": true, "target": null, "kind": "normal", "registry": "https://github.com/rust-lang/crates.io-index" },
355+ { "name": "core-lib", "version_req": "=0.3.0", "features": [], "optional": true, "default_features": false, "target": "cfg(unix)", "kind": "normal", "explicit_name_in_toml": "core" },
356+ { "name": "tempfile", "version_req": "^3", "kind": "dev" }
357+ ],
358+ "features": { "default": ["std"], "std": [], "derive": ["dep:core", "serde?/derive"] },
359+ "links": null,
360+ "rust_version": "1.75",
361+ "description": "kept elsewhere",
362+ });
363+ let entry = index_entry(&metadata, "ab12").unwrap();
364+ let line: Value = serde_json::from_str(&index_line(&entry, false)).unwrap();
365+ assert_eq!(line["name"], "Web");
366+ assert_eq!(line["vers"], "1.2.0");
367+ assert_eq!(line["cksum"], "ab12");
368+ assert_eq!(line["yanked"], false);
369+ assert_eq!(line["links"], Value::Null);
370+ assert_eq!(line["rust_version"], "1.75");
371+ assert!(line.get("description").is_none(), "the index holds what resolving needs");
372+ assert_eq!(line["features"], json!({ "default": ["std"], "std": [] }));
373+ assert_eq!(line["features2"], json!({ "derive": ["dep:core", "serde?/derive"] }));
374+ assert_eq!(line["v"], 2);
375+
376+ let deps = line["deps"].as_array().unwrap();
377+ assert_eq!(deps[0]["name"], "serde");
378+ assert_eq!(deps[0]["req"], "^1");
379+ assert_eq!(deps[0]["features"], json!(["derive"]));
380+ assert_eq!(deps[0]["registry"], "https://github.com/rust-lang/crates.io-index");
381+ assert!(deps[0].get("package").is_none());
382+ assert_eq!(deps[1]["name"], "core", "a renamed dependency by its new name");
383+ assert_eq!(deps[1]["package"], "core-lib");
384+ assert_eq!(deps[1]["optional"], true);
385+ assert_eq!(deps[1]["default_features"], false);
386+ assert_eq!(deps[1]["target"], "cfg(unix)");
387+ assert!(deps[1].get("registry").is_none(), "this registry");
388+ assert_eq!(deps[2]["kind"], "dev");
389+ assert_eq!(deps[2]["features"], json!([]));
390+ assert_eq!(deps[2]["default_features"], true);
391+
392+ let yanked: Value = serde_json::from_str(&index_line(&entry, true)).unwrap();
393+ assert_eq!(yanked["yanked"], true);
394+ assert!(!index_line(&entry, true).contains('\n'), "one line");
395+ }
396+
397+ #[test]
398+ fn a_crate_without_new_feature_syntax_is_index_version_1() {
399+ let entry = index_entry(&json!({ "name": "a", "vers": "0.1.0", "deps": [], "features": { "x": ["a/b"] }, "links": "z" }), "00").unwrap();
400+ assert!(entry.get("v").is_none());
401+ assert!(entry.get("features2").is_none());
402+ assert_eq!(entry["links"], "z");
403+ assert!(index_entry(&json!({ "vers": "0.1.0" }), "00").is_err());
404+ assert!(index_entry(&json!({ "name": "a", "vers": "0.1.0", "deps": [{ "name": "b" }] }), "00").is_err());
405+ assert!(index_entry(&json!({ "name": "a", "vers": "0.1.0", "features": { "x": "y" } }), "00").is_err());
406+ }
407+
408+ #[test]
409+ fn config_names_the_download_and_api_addresses() {
410+ let config = config("https://g1t.sh/-/cargo/acme", true);
411+ assert_eq!(config["dl"], "https://g1t.sh/-/cargo/acme/api/v1/crates");
412+ assert_eq!(config["api"], "https://g1t.sh/-/cargo/acme");
413+ assert_eq!(config["auth-required"], true);
414+ }
415+
416+ #[test]
417+ fn the_token_is_the_whole_header() {
418+ assert_eq!(token("g1t_abc"), Some("g1t_abc"));
419+ assert_eq!(token(" g1t_abc "), Some("g1t_abc"));
420+ assert_eq!(token("Bearer g1t_abc"), Some("g1t_abc"));
421+ assert_eq!(token("Basic YTpi"), None);
422+ assert_eq!(token(""), None);
423+ assert_eq!(without_build("1.0.0+build.1"), "1.0.0");
424+ assert_eq!(without_build("1.0.0-rc.1"), "1.0.0-rc.1");
425+ }
426+}
+462−0
1+//! The Cargo registry: `g1t.sh/-/cargo/<workspace>/`, a sparse registry
2+//! for each workspace. `.cargo/config.toml` names it, and `cargo login`
3+//! keeps a g1t token for it:
4+//!
5+//! ```toml
6+//! [registries.acme]
7+//! index = "sparse+https://g1t.sh/-/cargo/acme/index/"
8+//! ```
9+//!
10+//! Cargo sends the token as the whole `Authorization` header, with no
11+//! scheme. The index is made from the versions on each read; a `.crate`
12+//! is stored once, by its SHA-256, which is also its index `cksum`.
13+
14+use g1t_contracts::User;
15+use g1t_contracts::audit::AuditActor;
16+use g1t_contracts::events::PackageEvent;
17+use g1t_contracts::new_id;
18+use g1t_kit::now_ms;
19+use serde_json::{Value, json};
20+use worker::{Context, Headers, Method, Request, Response, ResponseBody, Result, Url};
21+
22+use crate::access::{self, Action};
23+use crate::cargo::{self, CargoRoute};
24+use crate::db::{NewFile, NewVersion, PackageRow, VersionRow};
25+use crate::digest::Digest;
26+use crate::npm;
27+use crate::oci::{Credentials, origin, published_by};
28+use crate::store::BlobStore;
29+use crate::{Caller, Packages, TargetOf, token};
30+
31+const CARGO: &str = "cargo";
32+/// The most versions an index file lists.
33+const MAX_VERSIONS: u32 = 5000;
34+/// The longest README kept for a crate's page.
35+const MAX_README_BYTES: usize = 1024 * 1024;
36+/// The most crates `cargo search` is answered with.
37+const MAX_SEARCH: u32 = 100;
38+const DOCS: &str = "https://docs.g1t.sh/guides/cargo/";
39+const TOKENS: &str = "https://g1t.sh/settings/tokens";
40+
41+/// Cargo's error shape: `{"errors": [{"detail": "..."}]}`, which it prints.
42+/// A 401 says where to get a token, which cargo shows beside its own hint.
43+fn error(status: u16, message: impl Into<String>) -> Result<Response> {
44+ let mut response = Response::from_json(&json!({ "errors": [{ "detail": message.into() }] }))?.with_status(status);
45+ if status == 401 {
46+ response.headers_mut().set("www-authenticate", &format!("Cargo login_url=\"{TOKENS}\""))?;
47+ }
48+ Ok(response)
49+}
50+
51+fn ok() -> Result<Response> {
52+ Response::from_json(&json!({ "ok": true }))
53+}
54+
55+fn not_found() -> Result<Response> {
56+ error(404, "Not found: no such crate, or you cannot see it. Private crates need a token: cargo login --registry <workspace>.")
57+}
58+
59+fn sign_in(workspace: &str) -> String {
60+ format!("Sign in to use this registry: cargo login --registry {workspace}, with a g1t access token from {TOKENS}")
61+}
62+
63+impl Packages {
64+ /// Answers a Cargo request.
65+ pub async fn cargo(&self, request: Request, ctx: &Context) -> Result<Response> {
66+ let url = request.url()?;
67+ let Some((workspace, route)) = cargo::route(url.path()) else {
68+ return error(404, "There is nothing at this address.");
69+ };
70+ match self.cargo_route(request, &url, &workspace, route, ctx).await {
71+ Ok(response) => Ok(response),
72+ Err(problem) => {
73+ worker::console_error!("packages: cargo {}: {problem}", url.path());
74+ error(500, "Something went wrong on our side. Try again in a moment.")
75+ }
76+ }
77+ }
78+
79+ /// Who the request is from: cargo's bare token, a `Bearer` one, or
80+ /// Basic credentials with a g1t token as the password.
81+ async fn cargo_credentials(&self, request: &Request) -> Result<Credentials> {
82+ let Some(header) = request.headers().get("authorization")? else {
83+ return Ok(Credentials::None);
84+ };
85+ let viewer = if let Some((username, secret)) = token::basic(&header) {
86+ self.viewer_for(&username, &secret).await?
87+ } else if let Some(token) = cargo::token(&header) {
88+ self.viewer_for("token", token).await?
89+ } else {
90+ None
91+ };
92+ Ok(match viewer {
93+ Some(user) => Credentials::Viewer(Some(user)),
94+ None => Credentials::Bad,
95+ })
96+ }
97+
98+ async fn cargo_route(&self, mut request: Request, url: &Url, workspace: &str, route: CargoRoute, ctx: &Context) -> Result<Response> {
99+ let method = request.method();
100+ let credentials = self.cargo_credentials(&request).await?;
101+ if matches!(method, Method::Get | Method::Head)
102+ && let Some(refused) = self.limited(&request, &credentials, &format!("a token (cargo login --registry {workspace})")).await?
103+ {
104+ return Ok(refused);
105+ }
106+ let viewer = match credentials {
107+ Credentials::Viewer(viewer) => viewer,
108+ Credentials::None => None,
109+ Credentials::Token(_) | Credentials::Bad => {
110+ return error(
111+ 401,
112+ format!("The token is not right, or has expired. Make an access token at {TOKENS}, then: cargo login --registry {workspace}"),
113+ );
114+ }
115+ };
116+ let viewer = viewer.as_ref();
117+ let read = matches!(method, Method::Get | Method::Head);
118+ match route {
119+ CargoRoute::Config if read => self.cargo_config(url, workspace, viewer).await,
120+ CargoRoute::Index { name } if read => self.cargo_index(&request, workspace, &name, viewer).await,
121+ CargoRoute::Download { name, version } if read => self.crate_download(workspace, &name, &version, viewer, method == Method::Head, ctx).await,
122+ CargoRoute::Search if read => self.cargo_search(url, workspace, viewer).await,
123+ CargoRoute::Publish if method == Method::Put => self.cargo_publish(&mut request, workspace, viewer).await,
124+ CargoRoute::Yank { name, version } if method == Method::Delete => self.cargo_yank(workspace, &name, &version, true, viewer).await,
125+ CargoRoute::Unyank { name, version } if method == Method::Put => self.cargo_yank(workspace, &name, &version, false, viewer).await,
126+ CargoRoute::Owners { .. } => error(
127+ 400,
128+ "Crate owners are not kept here: who may publish a crate is decided by its repository's roles, or the workspace's. See https://docs.g1t.sh/guides/packages/#who-can-see-and-publish-a-package",
129+ ),
130+ _ => error(405, "Not a method this address takes."),
131+ }
132+ }
133+
134+ /// The crate, by its name in any case, if its workspace is not deleted.
135+ async fn crate_package(&self, workspace: &str, name: &str) -> Result<Option<PackageRow>> {
136+ Ok(self.db.package_any_case(workspace, CARGO, name).await?.filter(|p| !p.hidden()))
137+ }
138+
139+ /// Whether `viewer` may `action` the crate, as the answer when not: 401
140+ /// for someone not signed in who may not read it, 404 for anyone else
141+ /// who may not read it, and 403 with the reason for one who may.
142+ fn cargo_check(&self, viewer: Option<&User>, package: &PackageRow, action: Action) -> Option<Result<Response>> {
143+ let target = TargetOf::package(package);
144+ let decision = access::decide(viewer, &target.view(), action);
145+ if decision.allowed {
146+ return None;
147+ }
148+ let readable = action != Action::Pull && access::decide(viewer, &target.view(), Action::Pull).allowed;
149+ if !readable && viewer.is_none() {
150+ return Some(error(401, sign_in(&package.workspace)));
151+ }
152+ if !readable {
153+ return Some(not_found());
154+ }
155+ Some(error(403, decision.reason.unwrap_or_else(|| "Not allowed.".to_owned())))
156+ }
157+
158+ /// `index/config.json`. Signed in, cargo is told to send its token with
159+ /// every request. Anonymous requests to a workspace that has private
160+ /// crates get a 401, which makes cargo ask again with its token; one
161+ /// with only public crates is open to anyone.
162+ async fn cargo_config(&self, url: &Url, workspace: &str, viewer: Option<&User>) -> Result<Response> {
163+ if viewer.is_none() && self.db.has_private(workspace, CARGO).await? {
164+ return error(401, sign_in(workspace));
165+ }
166+ let base = format!("{}/-/cargo/{workspace}", origin(url));
167+ let mut response = Response::from_json(&cargo::config(&base, viewer.is_some()))?;
168+ response.headers_mut().set("cache-control", "no-cache")?;
169+ Ok(response)
170+ }
171+
172+ /// A crate's index file: one line per version, oldest first.
173+ async fn cargo_index(&self, request: &Request, workspace: &str, name: &str, viewer: Option<&User>) -> Result<Response> {
174+ let Some(package) = self.crate_package(workspace, name).await? else {
175+ return not_found();
176+ };
177+ if let Some(refusal) = self.cargo_check(viewer, &package, Action::Pull) {
178+ return refusal;
179+ }
180+ let mut versions = self.db.versions(&package.id, MAX_VERSIONS).await?;
181+ if versions.is_empty() {
182+ return not_found();
183+ }
184+ versions.reverse();
185+ let mut body = String::new();
186+ for version in &versions {
187+ body.push_str(&cargo::index_line(&version.meta(), version.is_yanked()));
188+ body.push('\n');
189+ }
190+ let etag = format!("\"{}\"", &Digest::of(body.as_bytes()).hex()[..32]);
191+ let headers = Headers::new();
192+ headers.set("content-type", "text/plain; charset=utf-8")?;
193+ headers.set("cache-control", "no-cache")?;
194+ headers.set("etag", &etag)?;
195+ if request.headers().get("if-none-match")?.is_some_and(|sent| sent == etag) {
196+ return Ok(Response::empty()?.with_status(304).with_headers(headers));
197+ }
198+ Ok(Response::ok(body)?.with_headers(headers))
199+ }
200+
201+ async fn crate_download(&self, workspace: &str, name: &str, version: &str, viewer: Option<&User>, head: bool, ctx: &Context) -> Result<Response> {
202+ let Some(package) = self.crate_package(workspace, name).await? else {
203+ return not_found();
204+ };
205+ if let Some(refusal) = self.cargo_check(viewer, &package, Action::Pull) {
206+ return refusal;
207+ }
208+ let gone = || error(404, format!("{name}@{version} is not there."));
209+ let Some(row) = self.db.version_named(&package.id, version).await? else {
210+ return gone();
211+ };
212+ let Some(digest) = Digest::parse(&row.digest) else {
213+ return gone();
214+ };
215+ let Some(blob) = self.db.package_blob(&package.id, &digest).await? else {
216+ return gone();
217+ };
218+ let headers = Headers::new();
219+ headers.set("content-type", "application/gzip")?;
220+ headers.set("content-length", &blob.size.to_string())?;
221+ headers.set("cache-control", "max-age=31536000")?;
222+ if head {
223+ return Ok(Response::from_body(ResponseBody::Empty)?.with_headers(headers));
224+ }
225+ let Some(got) = self.store.get(&blob.object_key, None).await? else {
226+ return gone();
227+ };
228+ self.count_download(&package.id, ctx);
229+ Ok(Response::from_body(got.body)?.with_headers(headers))
230+ }
231+
232+ /// `cargo search`: the workspace's crates the viewer may see, by name.
233+ async fn cargo_search(&self, url: &Url, workspace: &str, viewer: Option<&User>) -> Result<Response> {
234+ let query = url.query_pairs().find(|(k, _)| k == "q").map(|(_, v)| v.into_owned()).unwrap_or_default();
235+ let per_page = url
236+ .query_pairs()
237+ .find(|(k, _)| k == "per_page")
238+ .and_then(|(_, v)| v.parse::<u32>().ok())
239+ .unwrap_or(10)
240+ .clamp(1, MAX_SEARCH);
241+ let rows = self.db.list(workspace, Some(CARGO), None, Some(&query), MAX_SEARCH).await?;
242+ let visible: Vec<_> = rows
243+ .iter()
244+ .filter(|row| access::decide(viewer, &TargetOf::package(&row.package).view(), Action::Pull).allowed)
245+ .collect();
246+ let crates: Vec<Value> = visible
247+ .iter()
248+ .take(per_page as usize)
249+ .map(|row| {
250+ json!({
251+ "name": row.package.name,
252+ "max_version": crate::db::latest_shown(row).unwrap_or_default(),
253+ "description": row.package.description,
254+ })
255+ })
256+ .collect();
257+ Response::from_json(&json!({ "crates": crates, "meta": { "total": visible.len() } }))
258+ }
259+
260+ /// The package publishing makes, linked to the repository the crate's
261+ /// `repository` names on g1t, or else the one named like it.
262+ async fn cargo_target(&self, workspace: &str, name: &str, metadata: &Value) -> Result<TargetOf> {
263+ let named = npm::repository_of(&metadata["repository"], &self.host)
264+ .filter(|(owner, _)| owner == workspace)
265+ .map(|(_, repo)| repo);
266+ let lower = name.to_ascii_lowercase();
267+ let dashed = lower.replace('_', "-");
268+ let mut repo = None;
269+ for candidate in named.iter().map(String::as_str).chain([lower.as_str(), dashed.as_str()]) {
270+ if let Some(found) = self.repo_by_name(workspace, candidate).await? {
271+ repo = Some(found);
272+ break;
273+ }
274+ }
275+ Ok(TargetOf {
276+ workspace: workspace.to_owned(),
277+ repo: repo.map(|r| (r.id, r.name, r.is_private)),
278+ public: false,
279+ })
280+ }
281+
282+ /// `cargo publish`: the metadata and the `.crate` in one body.
283+ async fn cargo_publish(&self, request: &mut Request, workspace: &str, viewer: Option<&User>) -> Result<Response> {
284+ let declared = request.headers().get("content-length")?.and_then(|n| n.parse::<u64>().ok());
285+ let too_large = || {
286+ let mb = self.max_request / 1_000_000;
287+ error(413, format!("A publish may be at most {mb} MB, the .crate file and its metadata together. See {DOCS}#size"))
288+ };
289+ if declared.is_some_and(|n| n > self.max_request) {
290+ return too_large();
291+ }
292+ let bytes = request.bytes().await?;
293+ if bytes.len() as u64 > self.max_request {
294+ return too_large();
295+ }
296+ let (metadata, krate) = match cargo::parse_publish(&bytes) {
297+ Ok(parts) => parts,
298+ Err(message) => return error(400, message),
299+ };
300+ let name = metadata["name"].as_str().unwrap_or("").to_owned();
301+ if let Err(message) = cargo::valid_name(&name) {
302+ return error(400, message);
303+ }
304+ let version = metadata["vers"].as_str().unwrap_or("").to_owned();
305+ if !npm::valid_version(&version) {
306+ return error(400, format!("{version} is not a semver version."));
307+ }
308+ if krate.is_empty() {
309+ return error(400, "The .crate file is empty.");
310+ }
311+ let digest = Digest::of(krate);
312+ let entry = match cargo::index_entry(&metadata, digest.hex()) {
313+ Ok(entry) => entry,
314+ Err(message) => return error(400, message),
315+ };
316+
317+ // A name is taken whatever its case, and `-` and `_` are one.
318+ let found = self.db.package_folded(workspace, CARGO, &cargo::folded(&name)).await?;
319+ if let Some(found) = &found {
320+ if found.hidden() {
321+ return error(403, format!("The workspace {workspace} is deleted; nothing can be published to it."));
322+ }
323+ if found.name != name {
324+ return error(400, format!("The name {name} is taken by the crate {}. Publish it under that name.", found.name));
325+ }
326+ } else if self.db.workspace_hidden(workspace).await? {
327+ return error(403, format!("The workspace {workspace} is deleted; nothing can be published to it."));
328+ }
329+ let target = match &found {
330+ Some(package) => TargetOf::package(package),
331+ None => self.cargo_target(workspace, &name, &metadata).await?,
332+ };
333+ let decision = access::decide(viewer, &target.view(), Action::Push);
334+ if !decision.allowed {
335+ if viewer.is_none() {
336+ return error(401, sign_in(workspace));
337+ }
338+ let readable = found.is_none() || access::decide(viewer, &target.view(), Action::Pull).allowed;
339+ if !readable {
340+ return not_found();
341+ }
342+ return error(403, decision.reason.unwrap_or_else(|| "Not allowed.".to_owned()));
343+ }
344+ let caller = Caller { actor: viewer.map(AuditActor::of) };
345+ let package = match found {
346+ Some(package) => package,
347+ None => {
348+ self.db
349+ .create_package(
350+ &new_id("pkg", now_ms()),
351+ workspace,
352+ CARGO,
353+ &name,
354+ target.repo.as_ref().map(|(id, repo, private)| (id.as_str(), repo.as_str(), *private)),
355+ caller.actor.as_ref().map_or("", |actor| actor.actor_id.as_str()),
356+ now_ms(),
357+ )
358+ .await?
359+ }
360+ };
361+ let existing = self.db.versions(&package.id, MAX_VERSIONS).await?;
362+ if let Some(taken) = existing.iter().find(|v| cargo::without_build(&v.version) == cargo::without_build(&version)) {
363+ return error(
364+ 400,
365+ format!("{name}@{} is already published, and a version is published once. Bump the version in Cargo.toml.", taken.version),
366+ );
367+ }
368+
369+ let size = krate.len() as u64;
370+ if let Some(refusal) = self.storage_refusal(&package, &[(digest.to_string(), size)]).await? {
371+ return error(403, refusal);
372+ }
373+ let now = now_ms();
374+ let stored = match self.db.blob(&digest).await? {
375+ Some(blob) => self.store.head(&blob.object_key).await?.is_some(),
376+ None => false,
377+ };
378+ if !stored {
379+ self.store.put(&digest.object_key(), krate.to_vec()).await?;
380+ }
381+ self.db
382+ .keep_blob(&package.id, &digest, size, Some("application/gzip"), &digest.object_key(), now)
383+ .await?;
384+ self.db
385+ .publish(
386+ NewVersion {
387+ id: new_id("ver", now),
388+ package_id: package.id.clone(),
389+ version: version.clone(),
390+ digest: digest.to_string(),
391+ size,
392+ metadata: entry.to_string(),
393+ subject: None,
394+ published_by: published_by(&caller),
395+ files: vec![NewFile {
396+ name: "crate".to_owned(),
397+ digest: digest.to_string(),
398+ size,
399+ media_type: Some("application/gzip".to_owned()),
400+ }],
401+ },
402+ None,
403+ now,
404+ )
405+ .await?;
406+ // The README and description the crate's page shows: the highest
407+ // stable version's, so a pre-release does not replace them.
408+ let highest = std::iter::once(version.as_str())
409+ .chain(existing.iter().map(|v| v.version.as_str()))
410+ .collect::<Vec<_>>()
411+ .join("\n");
412+ if crate::db::newest_version(&highest).as_deref() == Some(version.as_str()) {
413+ let readme = metadata["readme"].as_str().unwrap_or("").trim();
414+ let readme_digest = if readme.is_empty() || readme.len() > MAX_README_BYTES {
415+ None
416+ } else {
417+ let bytes = readme.as_bytes().to_vec();
418+ let digest = Digest::of(&bytes);
419+ if self.db.blob(&digest).await?.is_none() {
420+ self.store.put(&digest.object_key(), bytes.clone()).await?;
421+ }
422+ self.db
423+ .keep_blob(&package.id, &digest, bytes.len() as u64, Some("text/markdown"), &digest.object_key(), now)
424+ .await?;
425+ Some(digest.to_string())
426+ };
427+ self.db.set_readme(&package.id, readme_digest.as_deref(), metadata["description"].as_str(), now).await?;
428+ }
429+ self.db.measure(&package.workspace).await?;
430+ let event = PackageEvent {
431+ version: Some(version.clone()),
432+ digest: Some(digest.to_string()),
433+ size: Some(size),
434+ ..self.event_of(&package)
435+ };
436+ self.announce("package.published", &package, event, &caller).await;
437+ self.audit(&caller, "package.publish", &package, Some(&format!("{workspace}/{name}@{version}")), None).await;
438+ Response::from_json(&json!({ "warnings": { "invalid_categories": [], "invalid_badges": [], "other": [] } }))
439+ }
440+
441+ /// `cargo yank` and `cargo yank --undo`: the version stays, for
442+ /// lockfiles that name it, but is no longer picked for new ones.
443+ async fn cargo_yank(&self, workspace: &str, name: &str, version: &str, yank: bool, viewer: Option<&User>) -> Result<Response> {
444+ let Some(package) = self.crate_package(workspace, name).await? else {
445+ return not_found();
446+ };
447+ if let Some(refusal) = self.cargo_check(viewer, &package, Action::Push) {
448+ return refusal;
449+ }
450+ let Some(row): Option<VersionRow> = self.db.version_named(&package.id, version).await? else {
451+ return error(404, format!("{name}@{version} is not there."));
452+ };
453+ if row.is_yanked() != yank {
454+ self.db.set_yanked(&row.id, yank).await?;
455+ self.db.touch_package(&package.id, now_ms()).await?;
456+ let caller = Caller { actor: viewer.map(AuditActor::of) };
457+ let action = if yank { "package.yank" } else { "package.unyank" };
458+ self.audit(&caller, action, &package, Some(&format!("{workspace}/{}@{version}", package.name)), None).await;
459+ }
460+ ok()
461+ }
462+}
+108−3
6262 pub version_count: u32,
6363 pub bytes: u64,
6464 pub latest_tag: Option<String>,
65+ /// The version `latest_tag` points to: what an npm listing shows,
66+ /// since its tags name versions rather than being what is installed.
67+ #[serde(default)]
68+ pub latest_tag_version: Option<String>,
6569 /// Every version, newest published first, one per line: the summary
6670 /// picks the highest of them (see `newest_version`).
6771 pub latest_version: Option<String>,
9195 .or_else(|| list.first().map(|v| (*v).to_owned()))
9296 }
9397
98+/// What a listing shows as a package's latest. An image's tag is what is
99+/// pulled, so it is shown as is; npm's dist-tags (`latest`) name versions,
100+/// so the version the tag points to is shown, as for every other registry.
101+/// Without a tag, the highest version (see `newest_version`).
102+pub fn latest_shown(row: &ListedRow) -> Option<String> {
103+ let tagged = if row.package.ecosystem == "container" { &row.latest_tag } else { &row.latest_tag_version };
104+ tagged.clone().or_else(|| row.latest_version.as_deref().and_then(newest_version))
105+}
106+
94107 #[cfg(test)]
95108 mod newest_tests {
96− use super::newest_version;
109+ use super::{ListedRow, PackageRow, latest_shown, newest_version};
110+
111+ fn listed(ecosystem: &str, tag: Option<&str>, tag_version: Option<&str>, versions: &str) -> ListedRow {
112+ ListedRow {
113+ package: PackageRow {
114+ id: "pkg_1".into(),
115+ workspace: "acme".into(),
116+ ecosystem: ecosystem.into(),
117+ name: "web".into(),
118+ repo_id: None,
119+ repo_name: None,
120+ visibility: "private".into(),
121+ description: None,
122+ created_by: "usr_1".into(),
123+ created_at: "2026-10-06T00:00:00.000Z".into(),
124+ updated_at: "2026-10-06T00:00:00.000Z".into(),
125+ downloads: 0,
126+ workspace_deleted_at: None,
127+ },
128+ version_count: 2,
129+ bytes: 0,
130+ latest_tag: tag.map(str::to_owned),
131+ latest_tag_version: tag_version.map(str::to_owned),
132+ latest_version: Some(versions.to_owned()),
133+ }
134+ }
135+
136+ #[test]
137+ fn npm_shows_the_version_its_tag_points_to_and_an_image_its_tag() {
138+ let npm = listed("npm", Some("latest"), Some("1.2.0"), "2.0.0-beta.1\n1.2.0\n1.0.0");
139+ assert_eq!(latest_shown(&npm).as_deref(), Some("1.2.0"), "the version, not the word latest");
140+ let image = listed("container", Some("latest"), Some("sha256:abc"), "sha256:abc");
141+ assert_eq!(latest_shown(&image).as_deref(), Some("latest"), "an image is pulled by its tag");
142+ let cargo = listed("cargo", None, None, "0.9.0\n1.1.0\n1.0.0");
143+ assert_eq!(latest_shown(&cargo).as_deref(), Some("1.1.0"), "no tags: the highest version");
144+ let untagged = listed("container", None, None, "sha256:abc");
145+ assert_eq!(latest_shown(&untagged).as_deref(), Some("sha256:abc"));
146+ }
97147
98148 #[test]
99149 fn the_latest_is_the_highest_stable_version_not_the_last_published() {
135185 /// npm's deprecation message, when the version is deprecated.
136186 #[serde(default)]
137187 pub deprecated: Option<String>,
188+ /// Cargo: 1 when the version is yanked.
189+ #[serde(default)]
190+ pub yanked: u32,
138191 }
139192
140193 impl VersionRow {
194+ pub fn is_yanked(&self) -> bool {
195+ self.yanked != 0
196+ }
197+}
198+
199+impl VersionRow {
141200 pub fn meta(&self) -> serde_json::Value {
142201 serde_json::from_str(&self.metadata).unwrap_or_default()
143202 }
206265 "id, workspace, ecosystem, name, repo_id, repo_name, visibility, description, created_by, created_at, updated_at, downloads, workspace_deleted_at";
207266 /// Workspaces that are deleted, waiting to be purged or restored.
208267 const DELETED_WORKSPACES: &str = "SELECT workspace FROM packages WHERE workspace_deleted_at IS NOT NULL";
209−const VERSION_COLUMNS: &str = "id, package_id, version, digest, size, metadata, subject, published_by, published_at, deprecated";
268+const VERSION_COLUMNS: &str = "id, package_id, version, digest, size, metadata, subject, published_by, published_at, deprecated, yanked";
210269
211270 pub struct Db {
212271 pub db: D1Database,
226285 .await
227286 }
228287
288+ /// A package by its name in any case: Cargo's names are one name
289+ /// whatever their case (`Inflector` is `inflector`).
290+ pub async fn package_any_case(&self, workspace: &str, ecosystem: &str, name: &str) -> Result<Option<PackageRow>> {
291+ self.prepare(
292+ &format!("SELECT {PACKAGE_COLUMNS} FROM packages WHERE workspace = ? AND ecosystem = ? AND name = ? COLLATE NOCASE LIMIT 1"),
293+ &[text(workspace), text(ecosystem), text(name)],
294+ )?
295+ .first(None)
296+ .await
297+ }
298+
299+ /// The package a new crate's name would clash with: one named the same
300+ /// apart from case and `-` against `_`, as crates.io decides.
301+ pub async fn package_folded(&self, workspace: &str, ecosystem: &str, folded: &str) -> Result<Option<PackageRow>> {
302+ self.prepare(
303+ &format!(
304+ "SELECT {PACKAGE_COLUMNS} FROM packages WHERE workspace = ? AND ecosystem = ? AND replace(lower(name), '_', '-') = ? LIMIT 1"
305+ ),
306+ &[text(workspace), text(ecosystem), text(folded)],
307+ )?
308+ .first(None)
309+ .await
310+ }
311+
312+ /// Whether the workspace has a private package of the ecosystem.
313+ pub async fn has_private(&self, workspace: &str, ecosystem: &str) -> Result<bool> {
314+ let row: Option<serde_json::Value> = self
315+ .prepare(
316+ "SELECT 1 AS private FROM packages WHERE workspace = ? AND ecosystem = ? AND visibility = 'private' AND workspace_deleted_at IS NULL LIMIT 1",
317+ &[text(workspace), text(ecosystem)],
318+ )?
319+ .first(None)
320+ .await?;
321+ Ok(row.is_some())
322+ }
323+
324+ pub async fn set_yanked(&self, version_id: &str, yanked: bool) -> Result<()> {
325+ self.prepare("UPDATE versions SET yanked = ? WHERE id = ?", &[num(u64::from(yanked)), text(version_id)])?
326+ .run()
327+ .await?;
328+ Ok(())
329+ }
330+
229331 /// Makes a package unless one of the name is already there (a push
230332 /// beside this one may have made it), and answers with the one kept.
231333 #[allow(clippy::too_many_arguments)]
282384 (SELECT COALESCE(SUM(b.size), 0) FROM blobs b WHERE b.digest IN \
283385 (SELECT vf.digest FROM version_files vf JOIN versions v ON v.id = vf.version_id WHERE v.package_id = p.id)) AS bytes, \
284386 (SELECT t.tag FROM tags t WHERE t.package_id = p.id ORDER BY t.tag = 'latest' DESC, t.updated_at DESC LIMIT 1) AS latest_tag, \
285− (SELECT GROUP_CONCAT(version, char(10)) FROM (SELECT v.version FROM versions v WHERE v.package_id = p.id ORDER BY v.published_at DESC)) AS latest_version \
387+ (SELECT v.version FROM tags t JOIN versions v ON v.id = t.version_id WHERE t.package_id = p.id \
388+ ORDER BY t.tag = 'latest' DESC, t.updated_at DESC LIMIT 1) AS latest_tag_version, \
389+ (SELECT GROUP_CONCAT(version, char(10)) FROM \
390+ (SELECT v.version FROM versions v WHERE v.package_id = p.id AND v.yanked = 0 ORDER BY v.published_at DESC)) AS latest_version \
286391 FROM packages p WHERE p.workspace = ? AND p.workspace_deleted_at IS NULL",
287392 PACKAGE_COLUMNS.split(", ").map(|c| format!("p.{c}")).collect::<Vec<_>>().join(", ")
288393 );
+15−2
88 //! `BlobStore` port (store/), metadata in D1 (db.rs).
99
1010 mod access;
11+mod cargo;
12+mod cargo_http;
1113 mod composer;
1214 mod composer_http;
1315 mod db;
348350 address: match p.ecosystem.as_str() {
349351 "npm" => format!("{}/-/npm/@{}/{}", self.host, p.workspace, p.name),
350352 "composer" => format!("{}/-/composer/{}/{}", self.host, p.workspace, p.name),
353+ "cargo" => format!("{}/-/cargo/{}/{}", self.host, p.workspace, p.name),
351354 _ => format!("{}/{}/{}", self.host, p.workspace, p.name),
352355 },
353356 visibility: Visibility::parse(&p.visibility),
358361 }),
359362 description: p.description.clone(),
360363 versions: row.version_count,
361− latest: row.latest_tag.clone().or_else(|| row.latest_version.as_deref().and_then(db::newest_version)),
364+ latest: db::latest_shown(row),
362365 size: row.bytes,
363366 downloads: p.downloads,
364367 created_at: p.created_at.clone(),
377380 version_count: 0,
378381 bytes: 0,
379382 latest_tag: None,
383+ latest_tag_version: None,
380384 latest_version: None,
381385 }))
382386 }
429433 subject: version.subject,
430434 published_by: version.published_by,
431435 published_at: version.published_at,
432− deprecated: version.deprecated,
436+ // A yanked crate version reads as deprecated: still
437+ // there for lockfiles, no longer picked for new ones.
438+ deprecated: if version.yanked != 0 {
439+ Some("Yanked: Cargo no longer picks this version for new lockfiles.".to_owned())
440+ } else {
441+ version.deprecated
442+ },
433443 }
434444 })
435445 .collect();
669679 if request.path().starts_with("/-/composer/") {
670680 return packages.composer(request, &ctx).await;
671681 }
682+ if request.path().starts_with("/-/cargo/") {
683+ return packages.cargo(request, &ctx).await;
684+ }
672685 return packages.registry(request, &ctx).await;
673686 };
674687 let body: serde_json::Value = request.json().await?;
+3−1
975975 ..self.event_of(package)
976976 };
977977 self.announce("package.version_deleted", package, event, caller).await;
978− // npm names a version by its number; an image by its digest.
978+ // npm and Cargo name a version by its number; an image by its digest.
979979 let path = if package.ecosystem == "npm" {
980980 format!("@{}/{}@{}", package.workspace, package.name, version.version)
981+ } else if package.ecosystem == "cargo" {
982+ format!("{}/{}@{}", package.workspace, package.name, version.version)
981983 } else {
982984 format!("{}/{}@{}", package.workspace, package.name, version.digest)
983985 };
+281−0
1+//! Which commit last changed each entry of a directory, for the file list.
2+//!
3+//! History is walked newest first along the first-parent chain. Each commit
4+//! is compared with its parent only where the directory itself changed (its
5+//! tree hash differs), so the walk reads a tree only for the commits that
6+//! touched it. An entry is given the newest commit after which its hash is
7+//! no longer the same; one that never changes within the walk is given the
8+//! oldest commit reached if that is the root, and nothing otherwise.
9+
10+use std::collections::HashMap;
11+
12+use g1t_contracts::repos::{Commit, EntryKind, LastCommit, TreeEntry};
13+use worker::Result;
14+
15+use crate::store::GitRepo;
16+
17+/// How far back the history is walked.
18+pub const MAX_COMMITS: u32 = 300;
19+/// Commits whose trees are read together, ahead of the walk: each read is a
20+/// round trip to the store, so reading them one by one is what is slow.
21+const READ_AHEAD: usize = 24;
22+/// Commits of history read at a time.
23+const PAGE: u32 = 48;
24+
25+/// Reads trees, remembering those already read: commits share most of them.
26+struct Trees<'a, R: GitRepo> {
27+ repo: &'a R,
28+ read: HashMap<String, Option<Vec<TreeEntry>>>,
29+}
30+
31+impl<'a, R: GitRepo> Trees<'a, R> {
32+ /// Reads the trees not read yet, all at once.
33+ async fn prefetch(&mut self, hashes: impl IntoIterator<Item = String>) -> Result<()> {
34+ let mut wanted: Vec<String> = hashes.into_iter().filter(|hash| !self.read.contains_key(hash)).collect();
35+ wanted.sort();
36+ wanted.dedup();
37+ let found = futures_util::future::join_all(wanted.iter().map(|hash| self.repo.read_tree(hash))).await;
38+ for (hash, tree) in wanted.into_iter().zip(found) {
39+ self.read.insert(hash, tree?);
40+ }
41+ Ok(())
42+ }
43+
44+ /// Reads, level by level and each level at once, the trees on the way
45+ /// to `path` in each of `roots`, and the directory itself.
46+ async fn prefetch_dirs(&mut self, roots: Vec<String>, path: &str) -> Result<()> {
47+ let mut level = roots;
48+ for segment in path.split('/').filter(|segment| !segment.is_empty()) {
49+ self.prefetch(level.clone()).await?;
50+ level = level
51+ .iter()
52+ .filter_map(|hash| {
53+ self.read.get(hash)?.as_ref()?.iter().find(|entry| entry.name == segment && entry.kind == EntryKind::Tree).map(|entry| entry.hash.clone())
54+ })
55+ .collect();
56+ }
57+ self.prefetch(level).await
58+ }
59+
60+ async fn get(&mut self, hash: &str) -> Result<Option<Vec<TreeEntry>>> {
61+ if let Some(found) = self.read.get(hash) {
62+ return Ok(found.clone());
63+ }
64+ let found = self.repo.read_tree(hash).await?;
65+ self.read.insert(hash.to_owned(), found.clone());
66+ Ok(found)
67+ }
68+
69+ /// The tree hash of `path` in a commit's root tree; the root for an empty path.
70+ async fn dir(&mut self, root: &str, path: &str) -> Result<Option<String>> {
71+ let mut hash = root.to_owned();
72+ for segment in path.split('/').filter(|segment| !segment.is_empty()) {
73+ let Some(entries) = self.get(&hash).await? else {
74+ return Ok(None);
75+ };
76+ match entries.into_iter().find(|entry| entry.name == segment && entry.kind == EntryKind::Tree) {
77+ Some(entry) => hash = entry.hash,
78+ None => return Ok(None),
79+ }
80+ }
81+ Ok(Some(hash))
82+ }
83+
84+ async fn entries(&mut self, dir: Option<&str>) -> Result<HashMap<String, String>> {
85+ let Some(dir) = dir else {
86+ return Ok(HashMap::new());
87+ };
88+ Ok(self.get(dir).await?.unwrap_or_default().into_iter().map(|entry| (entry.name, entry.hash)).collect())
89+ }
90+}
91+
92+/// The last commit of each entry of `path` at `git_ref`, and whether every
93+/// entry was given one. `out_of_time` is asked between batches; once it
94+/// says so the walk stops with what it has, incomplete.
95+pub async fn last_commits<R: GitRepo>(
96+ repo: &R,
97+ git_ref: &str,
98+ path: &str,
99+ out_of_time: &dyn Fn() -> bool,
100+) -> Result<(Vec<LastCommit>, bool)> {
101+ // History a page at a time, so a walk that is out of time stops
102+ // between pages rather than after reading all of it.
103+ let mut history = repo.log(git_ref, PAGE).await?;
104+ let Some(head) = history.first().cloned() else {
105+ return Ok((Vec::new(), true));
106+ };
107+ let mut trees = Trees { repo, read: HashMap::new() };
108+ let mut dir = trees.dir(&head.tree_hash, path).await?;
109+ let mut current = trees.entries(dir.as_deref()).await?;
110+ let mut open: Vec<String> = current.keys().cloned().collect();
111+ let mut found: Vec<LastCommit> = Vec::new();
112+ let give = |found: &mut Vec<LastCommit>, name: String, commit: &Commit| found.push(LastCommit { name, commit: commit.clone() });
113+ let mut index = 0;
114+ while !open.is_empty() && index < history.len() {
115+ // Read the next page once the walk reaches the end of this one.
116+ if index + 1 >= history.len() && history.len() < MAX_COMMITS as usize && !out_of_time() {
117+ if let Some(parent) = history.last().and_then(|commit| commit.parents.first()).cloned() {
118+ let more = repo.log(&parent, PAGE).await?;
119+ history.extend(more);
120+ }
121+ }
122+ if index % READ_AHEAD == 0 {
123+ if index > 0 && out_of_time() {
124+ break;
125+ }
126+ let ahead = history.iter().skip(index + 1).take(READ_AHEAD).map(|commit| commit.tree_hash.clone()).collect();
127+ trees.prefetch_dirs(ahead, path).await?;
128+ }
129+ let commit = history[index].clone();
130+ let Some(parent) = history.get(index + 1).cloned() else {
131+ // The oldest commit read. If it is the first commit there is,
132+ // what is left was added by it.
133+ if commit.parents.is_empty() {
134+ for name in open.drain(..) {
135+ give(&mut found, name, &commit);
136+ }
137+ }
138+ break;
139+ };
140+ index += 1;
141+ let parent_dir = trees.dir(&parent.tree_hash, path).await?;
142+ if parent_dir == dir {
143+ continue;
144+ }
145+ let before = trees.entries(parent_dir.as_deref()).await?;
146+ let (changed, still): (Vec<String>, Vec<String>) = open.into_iter().partition(|name| before.get(name) != current.get(name));
147+ for name in changed {
148+ give(&mut found, name, &commit);
149+ }
150+ open = still;
151+ dir = parent_dir;
152+ current = before;
153+ }
154+ let complete = open.is_empty();
155+ Ok((found, complete))
156+}
157+
158+#[cfg(test)]
159+mod tests {
160+ use std::future::Future;
161+ use std::pin::pin;
162+ use std::task::{Context, Poll, Waker};
163+
164+ use g1t_contracts::repos::{Branch, GitAccess, Signature};
165+
166+ use super::*;
167+ use crate::store::Scope;
168+
169+ fn run<F: Future>(future: F) -> F::Output {
170+ match pin!(future).as_mut().poll(&mut Context::from_waker(Waker::noop())) {
171+ Poll::Ready(output) => output,
172+ Poll::Pending => panic!("the fake store never waits"),
173+ }
174+ }
175+
176+ #[derive(Default)]
177+ struct Fake {
178+ trees: HashMap<String, Vec<TreeEntry>>,
179+ history: Vec<Commit>,
180+ }
181+
182+ impl GitRepo for Fake {
183+ async fn access(&self, _scope: Scope) -> Result<GitAccess> {
184+ unimplemented!()
185+ }
186+ async fn branches(&self) -> Result<Vec<Branch>> {
187+ Ok(Vec::new())
188+ }
189+ async fn log(&self, git_ref: &str, limit: u32) -> Result<Vec<Commit>> {
190+ // A branch name starts at the head; a hash at that commit; anything else is unknown.
191+ let start = if git_ref == "main" { Some(0) } else { self.history.iter().position(|commit| commit.hash == git_ref) };
192+ Ok(start.map(|start| self.history.iter().skip(start).take(limit as usize).cloned().collect()).unwrap_or_default())
193+ }
194+ async fn parents(&self, _commit_hash: &str) -> Result<Option<Vec<String>>> {
195+ Ok(None)
196+ }
197+ async fn read_tree(&self, tree_hash: &str) -> Result<Option<Vec<TreeEntry>>> {
198+ Ok(self.trees.get(tree_hash).cloned())
199+ }
200+ async fn read_blob(&self, _blob_hash: &str) -> Result<Option<Vec<u8>>> {
201+ Ok(None)
202+ }
203+ async fn read_file(&self, _git_ref: &str, _path: &str) -> Result<Option<Vec<u8>>> {
204+ Ok(None)
205+ }
206+ async fn fork(&self, _target_key: &str) -> Result<()> {
207+ Ok(())
208+ }
209+ }
210+
211+ fn entry(name: &str, hash: &str, kind: EntryKind) -> TreeEntry {
212+ TreeEntry { name: name.into(), hash: hash.into(), kind }
213+ }
214+
215+ fn commit(hash: &str, tree: &str, parent: Option<&str>) -> Commit {
216+ Commit {
217+ hash: hash.into(),
218+ tree_hash: tree.into(),
219+ message: format!("commit {hash}"),
220+ author: Signature { name: "a".into(), email: "a@example.com".into() },
221+ parents: parent.map(|p| vec![p.to_owned()]).unwrap_or_default(),
222+ authored_at: String::new(),
223+ }
224+ }
225+
226+ /// c1 adds README and src/a.rs; c2 changes src/a.rs; c3 changes README.
227+ fn repo() -> Fake {
228+ let mut fake = Fake::default();
229+ fake.trees.insert("src1".into(), vec![entry("a.rs", "a1", EntryKind::Blob)]);
230+ fake.trees.insert("src2".into(), vec![entry("a.rs", "a2", EntryKind::Blob)]);
231+ fake.trees.insert("root1".into(), vec![entry("README.md", "r1", EntryKind::Blob), entry("src", "src1", EntryKind::Tree)]);
232+ fake.trees.insert("root2".into(), vec![entry("README.md", "r1", EntryKind::Blob), entry("src", "src2", EntryKind::Tree)]);
233+ fake.trees.insert("root3".into(), vec![entry("README.md", "r2", EntryKind::Blob), entry("src", "src2", EntryKind::Tree)]);
234+ fake.history = vec![commit("c3", "root3", Some("c2")), commit("c2", "root2", Some("c1")), commit("c1", "root1", None)];
235+ fake
236+ }
237+
238+ fn by_name(found: Vec<LastCommit>) -> HashMap<String, String> {
239+ found.into_iter().map(|last| (last.name, last.commit.hash)).collect()
240+ }
241+
242+ #[test]
243+ fn each_root_entry_gets_the_newest_commit_that_changed_it() {
244+ let (found, complete) = run(last_commits(&repo(), "main", "", &|| false)).unwrap();
245+ assert!(complete);
246+ let found = by_name(found);
247+ assert_eq!(found["README.md"], "c3");
248+ assert_eq!(found["src"], "c2");
249+ }
250+
251+ #[test]
252+ fn a_subdirectory_is_walked_by_its_own_tree() {
253+ let (found, complete) = run(last_commits(&repo(), "main", "src", &|| false)).unwrap();
254+ assert!(complete);
255+ assert_eq!(by_name(found)["a.rs"], "c2");
256+ }
257+
258+ #[test]
259+ fn an_entry_unchanged_since_the_first_commit_belongs_to_it() {
260+ let mut fake = repo();
261+ fake.trees.insert("root2".into(), vec![entry("README.md", "r1", EntryKind::Blob), entry("src", "src1", EntryKind::Tree)]);
262+ fake.trees.insert("root3".into(), vec![entry("README.md", "r2", EntryKind::Blob), entry("src", "src1", EntryKind::Tree)]);
263+ let (found, complete) = run(last_commits(&fake, "main", "", &|| false)).unwrap();
264+ assert!(complete);
265+ assert_eq!(by_name(found)["src"], "c1");
266+ }
267+
268+ #[test]
269+ fn a_walk_cut_short_leaves_the_rest_unknown() {
270+ let mut fake = repo();
271+ // c1 has a parent the walk never reaches.
272+ fake.history[2].parents = vec!["c0".into()];
273+ fake.trees.insert("root2".into(), vec![entry("README.md", "r1", EntryKind::Blob), entry("src", "src1", EntryKind::Tree)]);
274+ fake.trees.insert("root3".into(), vec![entry("README.md", "r2", EntryKind::Blob), entry("src", "src1", EntryKind::Tree)]);
275+ let (found, complete) = run(last_commits(&fake, "main", "", &|| false)).unwrap();
276+ assert!(!complete);
277+ let found = by_name(found);
278+ assert_eq!(found["README.md"], "c3");
279+ assert!(!found.contains_key("src"));
280+ }
281+}
+80−0
1515 mod git_ops;
1616 mod import;
1717 mod land;
18+mod last_commits;
1819 mod lifecycle;
1920 mod listing;
2021 mod meters;
5758 const MAX_TEXT_BYTES: usize = 512 * 1024;
5859 /// How far back a pull request may have forked and still be landed.
5960 const MAX_ANCESTRY: u32 = 1000;
61+/// The most tags a repository's Tags page reads and lists.
62+const MAX_TAGS_READ: usize = 100;
63+
64+/// One path segment, percent-encoded for a cache key.
65+fn urlencoding_segment(segment: &str) -> String {
66+ segment
67+ .bytes()
68+ .map(|b| if b.is_ascii_alphanumeric() || b"-._~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") })
69+ .collect()
70+}
6071 const MAX_DESCRIPTION_CHARS: usize = 200;
6172 pub(crate) const SOURCE: &str = "repos";
6273 pub(crate) const UNVERIFIED: &str = "Confirm your email address first. Check your inbox, or resend the link from the banner on g1t.sh.";
735746 Ok(Outcome::Ok(git.log(&git_ref, a.limit).await?))
736747 }
737748
749+ /// Which commit last changed each entry of a directory. Kept in this
750+ /// colo's cache by repository, head commit and path: a commit's history
751+ /// never changes, so an answer is good for as long as it is kept.
752+ async fn last_commits(&self, a: g1t_contracts::repos::LastCommitsArgs) -> Result<Outcome<g1t_contracts::repos::LastCommits>> {
753+ let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
754+ return Ok(not_found());
755+ };
756+ let git = self.read_git(&repo).await?;
757+ let git_ref = a.git_ref.unwrap_or_else(|| repo.default_branch.clone());
758+ let Some(head) = git.log(&git_ref, 1).await?.into_iter().next() else {
759+ return Ok(Outcome::fail(FailureCode::NotFound, "No such branch, tag or commit."));
760+ };
761+ let key = format!(
762+ "https://last-commits.g1t.internal/{}/{}/{}",
763+ repo.id,
764+ head.hash,
765+ a.tree_path.split('/').map(urlencoding_segment).collect::<Vec<_>>().join("/")
766+ );
767+ let cache = worker::Cache::default();
768+ if let Ok(Some(mut kept)) = cache.get(key.as_str(), false).await {
769+ if let Ok(found) = kept.json::<g1t_contracts::repos::LastCommits>().await {
770+ return Ok(Outcome::Ok(found));
771+ }
772+ }
773+ // Asked with a budget: past it, what was found so far, not kept.
774+ let started = worker::Date::now().as_millis();
775+ let budget = a.budget_ms;
776+ let out_of_time = move || budget.is_some_and(|budget| worker::Date::now().as_millis().saturating_sub(started) > budget);
777+ let (entries, complete) = last_commits::last_commits(&git, &head.hash, &a.tree_path, &out_of_time).await?;
778+ let stopped = out_of_time();
779+ let found = g1t_contracts::repos::LastCommits { entries, complete };
780+ if stopped && !found.complete {
781+ return Ok(Outcome::Ok(found));
782+ }
783+ if let Ok(mut response) = worker::Response::from_json(&found) {
784+ let _ = response.headers_mut().set("cache-control", "max-age=604800");
785+ let _ = cache.put(key.as_str(), response).await;
786+ }
787+ Ok(Outcome::Ok(found))
788+ }
789+
790+ /// The repository's tags, newest commit first, at most 100.
791+ async fn tags(&self, a: g1t_contracts::repos::TagsArgs) -> Result<Outcome<Vec<g1t_contracts::repos::Tag>>> {
792+ let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
793+ return Ok(not_found());
794+ };
795+ let git = self.store.open(&store_key(&repo)).await?;
796+ let access = git.access(Scope::Read).await?;
797+ let named: Vec<(String, String)> = refs::heads_and_tags(refs::all(&access).await?)
798+ .into_iter()
799+ .filter_map(|(name, hash)| name.strip_prefix("refs/tags/").map(|tag| (tag.to_owned(), hash)))
800+ .collect();
801+ let read = self.read_git(&repo).await?;
802+ let commits = futures_util::future::join_all(named.iter().take(MAX_TAGS_READ).map(|(_, hash)| read.log(hash, 1))).await;
803+ let mut tags: Vec<g1t_contracts::repos::Tag> = named
804+ .into_iter()
805+ .zip(commits.into_iter().map(|found| found.ok().and_then(|list| list.into_iter().next())).chain(std::iter::repeat(None)))
806+ .map(|((name, _), commit)| g1t_contracts::repos::Tag { name, commit })
807+ .collect();
808+ tags.sort_by(|a, b| {
809+ let at = |tag: &g1t_contracts::repos::Tag| tag.commit.as_ref().map(|c| c.authored_at.clone()).unwrap_or_default();
810+ at(b).cmp(&at(a)).then_with(|| b.name.cmp(&a.name))
811+ });
812+ tags.truncate(MAX_TAGS_READ);
813+ Ok(Outcome::Ok(tags))
814+ }
815+
738816 /// The repository's branches, default branch first.
739817 async fn branches(&self, a: BranchesArgs) -> Result<Outcome<Vec<Branch>>> {
740818 let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
19211999 "fork_for_pull" => reply(&repos.fork_for_pull(args(body)?).await?),
19222000 "git_access" => reply(&repos.git_access(args(body)?).await?),
19232001 "branches" => reply(&repos.branches(args(body)?).await?),
2002+ "last_commits" => reply(&repos.last_commits(args(body)?).await?),
2003+ "tags" => reply(&repos.tags(args(body)?).await?),
19242004 "head" => reply(&repos.head(args(body)?).await?),
19252005 "behind" => reply(&repos.behind(args(body)?).await?),
19262006 "divergence" => reply(&repos.divergence(args(body)?).await?),
+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.