Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
12 commits
- The agents' empty state keeps its link in the sentence; Branches leaves out the merge queue's working branchesChase Pierce70b3e66
- Fixes from the page sweepChase Pierce1aedeb9
- Mission control counts people's direct pushes to the default branchChase Pierced51c0c2
- A last-commits walk runs to the end unless asked for a budget; the page bounds its own waitChase Piercec7ebe2a
- Cargo on g1t.sh: a sparse registry per workspace, cargo publish/add/yank/search with a g1t token; npm rows show their versionChase Pierce4c64095
- Cache hits are counted, not only their bytesChase Pierceecfa4ef
- Project settings say who created the project, by nameChase Piercec867f21
- An unknown behind count reads ?, not 0+; Code pages leave the description and topics to FilesChase Pierce965630d
- A last-commits walk reads history 48 commits at a time, within its budgetChase Pierce10725c1
- A last-commits walk answers within 2.5 s with what it has, and keeps only finished answersChase Piercedf5ecce
- Last commits read trees two dozen commits at a time, and the file list waits at most 3 s for themChase Pierce763ad40
- Branches and Tags pages, each file's last commit, and the branch menu on filesChase Pierce1aa1479
49 files+2359−2010/49 viewed
| 76 | 76 | { label: 'Packages', slug: 'guides/packages' }, | |
| 77 | 77 | { label: 'Container images', slug: 'guides/containers' }, | |
| 78 | 78 | { label: 'npm', slug: 'guides/npm' }, | |
| 79 | + | { label: 'Cargo', slug: 'guides/cargo' }, | |
| 79 | 80 | { label: 'Composer', slug: 'guides/composer' }, | |
| 80 | 81 | { label: 'Go modules', slug: 'guides/go' }, | |
| 81 | 82 | { label: 'Secrets and variables', slug: 'guides/secrets-and-variables' }, |
| 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`. | |
| 117 | 117 | git push origin my-change | |
| 118 | 118 | ``` | |
| 119 | 119 | ||
| 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 | + | ||
| 120 | 136 | ## Pull request forks | |
| 121 | 137 | ||
| 122 | 138 | A pull request that was not opened from a branch has its own remote: |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | 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 | |
| 11 | 11 | read from the workspace's repositories: there is nothing to upload. | |
| 12 | 12 | ||
| 13 | 13 | | Registry | Address | Guide | | |
| 14 | 14 | | --- | --- | --- | | |
| 15 | 15 | | Container images | `g1t.sh/<workspace>/<name>` | [Container images](/guides/containers/) | | |
| 16 | 16 | | 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/) | | |
| 17 | 18 | | Composer | `https://g1t.sh/-/composer/<workspace>/`, from the workspace's repositories | [Composer](/guides/composer/) | | |
| 18 | 19 | | Go | `g1t.sh/<workspace>/<repo>`, straight from git | [Go modules](/guides/go/) | | |
| 19 | 20 | ||
| 31 | 32 | repository's name (`acme/web`, `acme/web/worker` for the repository | |
| 32 | 33 | `acme/web`) links it to that repository; so does the first publish of | |
| 33 | 34 | 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/): | |
| 36 | 38 | ||
| 37 | 39 | | | Needs | | |
| 38 | 40 | | --- | --- | | |
| 92 | 94 | ## Events and the audit log | |
| 93 | 95 | ||
| 94 | 96 | 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 | |
| 96 | 99 | `package.published`, `package.version_deleted`, `package.deleted` and | |
| 97 | 100 | `package.visibility_changed`, which [webhooks](/guides/webhooks/) can be | |
| 98 | 101 | sent: a linked package's go to its repository's webhooks and its |
| 313 | 313 | shows where you are needed in the workspace you have chosen in the | |
| 314 | 314 | sidebar, what its agents are doing, and what landed without you. | |
| 315 | 315 | ||
| 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 | |
| 320 | 322 | list, and **New issue** opens a new issue in the project you pick. | |
| 321 | 323 | ||
| 322 | 324 | | Across the top | What it counts | | |
| 323 | 325 | | --- | --- | | |
| 324 | 326 | | **Projects** | The workspace's projects, and how many were added this month. | | |
| 325 | 327 | | **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. | | |
| 328 | 330 | | **Need you** | What is waiting on you, and how many of those block work. | | |
| 329 | 331 | ||
| 330 | 332 | The list has three tabs. Each row opens to say more; the first is open. | |
| 356 | 358 | jobs. **By impact** puts the most urgent first; **Newest** sorts by time. | |
| 357 | 359 | ||
| 358 | 360 | 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 | |
| 360 | 363 | agents and sandboxes cost over the same days. **Activity** lists what | |
| 361 | 364 | moved across the workspace, agents marked apart from people. The page | |
| 362 | 365 | refreshes itself while agents are at work. |
| 473 | 473 | /** Nothing running, said plainly. */ | |
| 474 | 474 | export function Idle({ children }: { children: ReactNode }) { | |
| 475 | 475 | 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> | |
| 479 | 480 | </p> | |
| 480 | 481 | ); | |
| 481 | 482 | } |
| 269 | 269 | <div aria-hidden="true" className="h-16 bg-gradient-to-b from-transparent to-bg" /> | |
| 270 | 270 | </section> | |
| 271 | 271 | ||
| 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"> | |
| 275 | 276 | {FACTS.map(([figure, about]) => ( | |
| 276 | 277 | <div key={figure} className="bg-bg px-6 py-6"> | |
| 277 | 278 | <dt className="font-mono text-2xl font-medium tracking-tight text-fg">{figure}</dt> |
| 526 | 526 | <span className="size-2 rounded-sm bg-info" /> People | |
| 527 | 527 | </span> | |
| 528 | 528 | </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> | |
| 546 | 539 | </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> | |
| 550 | 553 | </div> | |
| 551 | 554 | ); | |
| 552 | 555 | } |
| 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"; | |
| 3 | 4 | ||
| 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"; | |
| 5 | 6 | ||
| 6 | 7 | import { BlameView } from "./blame-view"; | |
| 7 | 8 | import { CodeLines } from "./code-lines"; | |
| 86 | 87 | "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"; | |
| 87 | 88 | ||
| 88 | 89 | /** 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 | + | }) { | |
| 90 | 104 | const rest = path ? `/${encodePath(path)}` : ""; | |
| 91 | 105 | return ( | |
| 92 | 106 | <DropdownMenu> | |
| 99 | 113 | <DropdownMenuLabel>Switch branches</DropdownMenuLabel> | |
| 100 | 114 | {branches.map((branch) => ( | |
| 101 | 115 | <DropdownMenuItem key={branch.name} asChild> | |
| 102 | − | <Link to={`${base}/tree/${encodePath(branch.name)}${rest}`}> | |
| 116 | + | <Link to={`${base}/${view}/${encodePath(branch.name)}${rest}`}> | |
| 103 | 117 | <Check className={branch.name === gitRef ? "" : "invisible"} /> | |
| 104 | 118 | <span className="truncate font-mono text-[0.8125rem]">{branch.name}</span> | |
| 105 | 119 | </Link> | |
| 185 | 199 | ) : ( | |
| 186 | 200 | <> | |
| 187 | 201 | {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"> | |
| 189 | 203 | <GitBranch size={14} className="text-faint" /> | |
| 190 | 204 | <span className="font-medium text-fg">{branches.length}</span> | |
| 191 | 205 | {branches.length === 1 ? "branch" : "branches"} | |
| 192 | − | </span> | |
| 206 | + | </Link> | |
| 193 | 207 | )} | |
| 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> | |
| 194 | 212 | <SearchCode repo={full} /> | |
| 195 | 213 | <CodeButton path={full} gitRef={gitRef} /> | |
| 196 | 214 | </> | |
| 199 | 217 | ); | |
| 200 | 218 | } | |
| 201 | 219 | ||
| 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 | + | }) { | |
| 203 | 287 | const { repo, ref, path, head, entries, readme } = tree; | |
| 204 | 288 | const base = `/${repo.namespace}/${repo.name}`; | |
| 205 | 289 | const prefix = path ? `${encodePath(path)}/` : ""; | |
| 234 | 318 | <CodeBar base={base} repo={repo} gitRef={ref} path={path} branches={branches} /> | |
| 235 | 319 | <div className="overflow-hidden rounded-xl border border-line"> | |
| 236 | 320 | <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 | + | )} | |
| 257 | 330 | </div> | |
| 258 | 331 | ||
| 259 | 332 | {readme?.text != null && ( | |
| 299 | 372 | </Link> | |
| 300 | 373 | </li> | |
| 301 | 374 | {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> | |
| 305 | 380 | </li> | |
| 306 | 381 | )} | |
| 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> | |
| 307 | 388 | </ul> | |
| 308 | 389 | <p className="mt-4 text-xs text-faint"> | |
| 309 | 390 | Created <TimeAgo at={repo.createdAt} /> | |
| 318 | 399 | blob, | |
| 319 | 400 | html, | |
| 320 | 401 | blame, | |
| 402 | + | branches = null, | |
| 321 | 403 | }: { | |
| 404 | + | /** For the branch menu; it shows the current branch alone without them. */ | |
| 405 | + | branches?: Branch[] | null; | |
| 322 | 406 | blob: Blob; | |
| 323 | 407 | /** Syntax-highlighted HTML per line, when the language is known. */ | |
| 324 | 408 | html: string[] | null; | |
| 339 | 423 | ); | |
| 340 | 424 | return ( | |
| 341 | 425 | <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> | |
| 348 | 436 | <div className="overflow-hidden rounded-xl border border-line"> | |
| 349 | 437 | <div className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5 text-xs text-muted"> | |
| 350 | 438 | {lines && <span>{lines.length.toLocaleString("en-US")} lines</span>} |
| 818 | 818 | than one view shows them as tabs across its top. */} | |
| 819 | 819 | <Rule /> | |
| 820 | 820 | <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} />}> | |
| 822 | 822 | Code | |
| 823 | 823 | </SidebarLink> | |
| 824 | 824 | <SidebarLink to={`${base}/issues`} also={[`${base}/plans`, ...soonPaths(base, "Issues")]} icon={<CircleDot size={15} />} count={repo.issues}> | |
| 1209 | 1209 | access: "Access", | |
| 1210 | 1210 | invitations: "Invitation", | |
| 1211 | 1211 | repositories: "Repositories", | |
| 1212 | − | branches: "Branches and merging", | |
| 1212 | + | branches: "Branches", | |
| 1213 | + | tags: "Tags", | |
| 1213 | 1214 | dependencies: "Dependencies", | |
| 1214 | 1215 | code: "Files", | |
| 1215 | 1216 | secrets: "Secrets and variables", | |
| 1308 | 1309 | trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` }); | |
| 1309 | 1310 | // A settings page names which one: Settings / Secrets and variables. | |
| 1310 | 1311 | 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}` }); | |
| 1312 | 1313 | } | |
| 1313 | 1314 | } | |
| 1314 | 1315 | } |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { shownStep } from "./agent-step"; | |
| 4 | + | import { shownStep } from "./agent-step.ts"; | |
| 5 | 5 | ||
| 6 | 6 | test("a step that hands back a file says what was done, not where", () => { | |
| 7 | 7 | assert.equal(shownStep("Said: Done. The review is written to `/work/review.json`."), "Wrote its review."); |
| 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 | + | } |
| 21 | 21 | assert.deepEqual(drift(["b3", "b2", "b1"], ["m2", "m1"], 3), { ahead: 3, behind: 0, aheadMore: true, behindMore: true }); | |
| 22 | 22 | assert.equal(count(3, true), "3+"); | |
| 23 | 23 | 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), "?"); | |
| 24 | 26 | }); |
| 21 | 21 | return { ahead, behind, aheadMore: false, behindMore: false }; | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 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. */ | |
| 25 | 25 | export function count(value: number, more: boolean): string { | |
| 26 | + | if (more && value === 0) return "?"; | |
| 26 | 27 | return more ? `${value}+` : String(value); | |
| 27 | 28 | } |
| 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 | + | } |
| 20 | 20 | sortRows, | |
| 21 | 21 | stallReason, | |
| 22 | 22 | summaryLine, | |
| 23 | + | pushedCommits, | |
| 23 | 24 | waitingRows, | |
| 24 | 25 | weekOf, | |
| 25 | 26 | whyFor, | |
| 171 | 172 | files: [], | |
| 172 | 173 | }); | |
| 173 | 174 | ||
| 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 | + | ||
| 174 | 183 | test("a change landed without a person when g1t merged it", () => { | |
| 175 | 184 | assert.ok(landedByAgents({ mergedBy: "g1t" })); | |
| 176 | 185 | assert.ok(landedByAgents({ mergedBy: "g1t" })); |
| 424 | 424 | // --- Landed ----------------------------------------------------------------- | |
| 425 | 425 | ||
| 426 | 426 | /** 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 | + | ||
| 427 | 442 | export type Merged = { | |
| 428 | 443 | repo: RepoPath; | |
| 429 | 444 | number: number; |
| 34 | 34 | }); | |
| 35 | 35 | }); | |
| 36 | 36 | ||
| 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 | + | ||
| 37 | 48 | test("a container image is pulled by its address and tag", () => { | |
| 38 | 49 | const pkg = { ecosystem: "container" as const, address: "g1t.sh/acme/web", name: "web", workspace: "acme" }; | |
| 39 | 50 | assert.deepEqual(installCommands(pkg, "latest", "ada"), { |
| 40 | 40 | ready: true, | |
| 41 | 41 | }, | |
| 42 | 42 | { | |
| 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 | + | { | |
| 43 | 50 | ecosystem: "go", | |
| 44 | 51 | blurb: "Go modules fetched from the repositories themselves with go get, private ones with a token.", | |
| 45 | 52 | start: "go get g1t.sh/<workspace>/<repo>", | |
| 46 | 53 | guide: "/guides/go/", | |
| 47 | 54 | 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, | |
| 55 | 55 | }, | |
| 56 | 56 | ]; | |
| 57 | 57 | ||
| 143 | 143 | install: `composer require ${pkg.name}${version ? `:${version}` : ""}`, | |
| 144 | 144 | }; | |
| 145 | 145 | 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. | |
| 146 | 149 | 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`, | |
| 147 | 151 | 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}`, | |
| 149 | 153 | }; | |
| 150 | 154 | case "go": | |
| 151 | 155 | return { |
| 47 | 47 | assert.equal(servicePath("/-/composer"), null); | |
| 48 | 48 | }); | |
| 49 | 49 | ||
| 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 | + | ||
| 50 | 67 | test("git goes to repos, and everything else is the site's", () => { | |
| 51 | 68 | assert.equal(servicePath("/acme/web.git/info/refs"), "git"); | |
| 52 | 69 | assert.equal(servicePath("/acme/web/git-receive-pack"), "git"); |
| 11 | 11 | const NPM_PATH = /^\/-\/npm(?:\/|$)/; | |
| 12 | 12 | /** The Composer registries: `/-/composer/<workspace>/`, one per workspace. */ | |
| 13 | 13 | 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\//; | |
| 14 | 16 | ||
| 15 | 17 | export type ServicePath = "git" | "packages" | null; | |
| 16 | 18 | ||
| 17 | 19 | 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"; | |
| 19 | 21 | if (GIT_PATH.test(pathname)) return "git"; | |
| 20 | 22 | return null; | |
| 21 | 23 | } |
| 81 | 81 | ||
| 82 | 82 | // --- Code --------------------------------------------------------------- | |
| 83 | 83 | { | |
| 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 | − | { | |
| 107 | 84 | key: "compare", | |
| 108 | 85 | title: "Compare", | |
| 109 | 86 | section: "Code", |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | import { inflateRawSync } from "node:zlib"; | |
| 4 | 4 | ||
| 5 | − | import { crc32, zip } from "./zip"; | |
| 5 | + | import { crc32, zip } from "./zip.ts"; | |
| 6 | 6 | ||
| 7 | 7 | test("crc32 matches the standard check value", () => { | |
| 8 | 8 | assert.equal(crc32(new TextEncoder().encode("123456789")), 0xcbf43926); |
| 104 | 104 | route("blob/:ref/*", "routes/repo/blob.tsx"), | |
| 105 | 105 | route("archive/*", "routes/repo/archive.ts"), | |
| 106 | 106 | route("commits", "routes/repo/commits.tsx"), | |
| 107 | + | route("branches", "routes/repo/branches.tsx"), | |
| 108 | + | route("tags", "routes/repo/tags.tsx"), | |
| 107 | 109 | route("commit/:hash", "routes/repo/commit.tsx"), | |
| 108 | 110 | route("issues", "routes/repo/issues.tsx"), | |
| 109 | 111 | route("issues/new", "routes/repo/issue-new.tsx"), |
| 4 | 4 | ||
| 5 | 5 | import { | |
| 6 | 6 | type Confidence, | |
| 7 | + | type G1tEvent, | |
| 7 | 8 | type Lifecycle, | |
| 8 | 9 | type Pull, | |
| 9 | 10 | type Repo, | |
| 10 | 11 | type RepoPath, | |
| 12 | + | type Viewer, | |
| 11 | 13 | REPO_ROLE_LABELS, | |
| 12 | 14 | isActiveRun, | |
| 13 | 15 | workOwner, | |
| 34 | 36 | import { | |
| 35 | 37 | type Fact, | |
| 36 | 38 | type Merged, | |
| 39 | + | pushedCommits, | |
| 37 | 40 | type NeedRow, | |
| 38 | 41 | type QuickAction, | |
| 39 | 42 | RUN_LABEL, | |
| 136 | 139 | return data<DelegateResult>({ error: null, notStarted: refused }); | |
| 137 | 140 | } | |
| 138 | 141 | ||
| 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 | + | ||
| 139 | 169 | export async function loader({ context, request }: Route.LoaderArgs) { | |
| 140 | 170 | const viewer = getViewer(context); | |
| 141 | 171 | if (!viewer) { | |
| 177 | 207 | const chosen = (repos ?? []) | |
| 178 | 208 | .filter((repo) => slug != null && repo.namespace.toLowerCase() === slug.toLowerCase()) | |
| 179 | 209 | .slice(0, MAX_PROJECTS); | |
| 180 | − | const [batch, logs] = await Promise.all([ | |
| 210 | + | const [batch, logs, pushes] = await Promise.all([ | |
| 181 | 211 | work.pullsForRepos(chosen.map((repo) => repo.id), viewer, PULL_PAGE).catch(() => []), | |
| 182 | 212 | 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(() => []))), | |
| 183 | 216 | ]); | |
| 184 | 217 | const byId = new Map(batch.map((entry) => [entry.repoId, entry])); | |
| 185 | 218 | return Promise.all( | |
| 186 | 219 | chosen.map(async (repo, index) => { | |
| 187 | 220 | 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] ?? [] }; | |
| 189 | 222 | // A fork, which the batch leaves out: asked on its own. | |
| 190 | 223 | const path = { namespace: repo.namespace, name: repo.name }; | |
| 191 | 224 | const [pulls, closed] = await Promise.all([ | |
| 197 | 230 | pulls: pulls?.ok ? pulls.value.slice(0, 60) : null, | |
| 198 | 231 | closed: closed?.ok ? closed.value : null, | |
| 199 | 232 | events: logs[index], | |
| 233 | + | direct: pushes[index] ?? [], | |
| 200 | 234 | }; | |
| 201 | 235 | }), | |
| 202 | 236 | ); | |
| 519 | 553 | ); | |
| 520 | 554 | const twoWeeksAgo = now - 14 * TIME.DAY; | |
| 521 | 555 | 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); | |
| 523 | 562 | const landed = landedToday(merged, now, tz); | |
| 524 | 563 | ||
| 525 | 564 | // --- Activity ------------------------------------------------------------- |
| 15 | 15 | const path = { namespace: params.owner, name: params.repo }; | |
| 16 | 16 | const file = params["*"] ?? ""; | |
| 17 | 17 | const wantsBlame = new URL(request.url).searchParams.has("blame"); | |
| 18 | − | const [found, blame] = await Promise.all([ | |
| 18 | + | const [found, blame, list] = await Promise.all([ | |
| 19 | 19 | repos.blob(path, viewer, params.ref, file), | |
| 20 | 20 | 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), | |
| 21 | 23 | ]); | |
| 24 | + | const branches = list?.ok ? list.value : null; | |
| 22 | 25 | // A branch that was renamed: the same file on its new name. | |
| 23 | 26 | if (!found.ok && found.error.code === "not_found") await redirectIfBranchRenamed(request, path, viewer, params.ref); | |
| 24 | 27 | const blob = unwrap(found); | |
| 27 | 30 | blob, | |
| 28 | 31 | html: null, | |
| 29 | 32 | blame: { blame: blame.value, lines: await highlightLines(blob.path, blob.text) }, | |
| 33 | + | branches, | |
| 30 | 34 | }; | |
| 31 | 35 | } | |
| 32 | 36 | return { | |
| 33 | 37 | blob, | |
| 34 | 38 | html: blob.text == null ? null : await highlightLines(blob.path, blob.text), | |
| 35 | 39 | blame: null, | |
| 40 | + | branches, | |
| 36 | 41 | }; | |
| 37 | 42 | } | |
| 38 | 43 | ||
| 39 | 44 | 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} />; | |
| 41 | 46 | } |
| 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 | + | } |
| 1 | 1 | import type { Route } from "./+types/code"; | |
| 2 | 2 | import { TreeView } from "../../components/repo-view"; | |
| 3 | 3 | import { page } from "../../lib/meta"; | |
| 4 | + | import { lastCommitsFor } from "../../lib/last-commits.server"; | |
| 4 | 5 | import { repos } from "../../lib/services.server"; | |
| 5 | 6 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 6 | 7 | ||
| 16 | 17 | // For the branch menu; the page still shows without it. | |
| 17 | 18 | repos.branches(path, viewer).catch(() => null), | |
| 18 | 19 | ]); | |
| 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 }; | |
| 20 | 23 | } | |
| 21 | 24 | ||
| 22 | 25 | 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} />; | |
| 24 | 27 | } |
| 34 | 34 | ]); | |
| 35 | 35 | // Set not to deploy, in General settings: turning them on waits for that to change. | |
| 36 | 36 | 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; | |
| 39 | 43 | return { can: access.can, settings: unwrap(settings), ...unwrap(list), plan, computeNote, notDeploying }; | |
| 40 | 44 | } | |
| 41 | 45 | ||
| 106 | 110 | {!actionData && <ComputeNote note={loaderData.computeNote} />} | |
| 107 | 111 | </div> | |
| 108 | 112 | ||
| 109 | − | {!plan?.on ? ( | |
| 113 | + | {plan != null && !plan.on && !plan.included && live.length === 0 ? ( | |
| 110 | 114 | <PlanNeeded plan={plan} owner={params.owner} /> | |
| 111 | 115 | ) : !settings.enabled ? ( | |
| 112 | 116 | <section className="mt-2 rounded-xl border border-accent/30 bg-accent/5 p-6"> |
| 156 | 156 | const { pathname } = useLocation(); | |
| 157 | 157 | const tabs = tabsFor(pathname.slice(base.length + 1), member, access.can); | |
| 158 | 158 | // 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)); | |
| 160 | 160 | // Everyone, signed in or not, finds the project's pages in the sidebar; | |
| 161 | 161 | // the page shows its name, and the views of the page it is on as tabs. | |
| 162 | 162 | return ( |
| 64 | 64 | rankNeeds, | |
| 65 | 65 | stuckMinutes, | |
| 66 | 66 | } from "../../lib/mission"; | |
| 67 | − | import { drift } from "../../lib/branches"; | |
| 68 | 67 | import { agentWasAssigned, hasInstructions, productionChecklist, releaseChecklist } from "../../lib/checklist"; | |
| 69 | 68 | import { ECOSYSTEM_LABEL, installCommands } from "../../lib/packages"; | |
| 70 | 69 | import { PUBLISH_GUIDES, hasRelease, libraryPackages, packageName, packagePath, publishGuide } from "../../lib/project-kind"; | |
| 73 | 72 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| 74 | 73 | import { accessTo, countsFor, refusal, repoFor } from "../../lib/access.server"; | |
| 75 | 74 | import { shotVersion } from "./production-screenshot"; | |
| 75 | + | import { readBranches } from "../../lib/branches.server"; | |
| 76 | 76 | ||
| 77 | 77 | const MAX_LANDED = 6; | |
| 78 | 78 | /** Branches read for the Active branches list, and shown. */ | |
| 79 | 79 | const BRANCHES_READ = 10; | |
| 80 | 80 | 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; | |
| 84 | 81 | /** A library's packages shown on its overview; the rest are a link away. */ | |
| 85 | 82 | const PACKAGES_SHOWN = 3; | |
| 86 | 83 | ||
| 136 | 133 | async ([repo, branchList, pulls, deploys]): Promise<{ main: string; total: number; shown: ActiveBranch[] } | null> => { | |
| 137 | 134 | if (!repo?.ok || !branchList?.ok) return null; | |
| 138 | 135 | 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) }; | |
| 171 | 149 | }, | |
| 172 | 150 | ); | |
| 173 | 151 | // Active branches read several logs each: streamed, so the rest shows first. |
| 10 | 10 | import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui"; | |
| 11 | 11 | import { RadioGroup, RadioOption } from "../../components/ui/radio-group"; | |
| 12 | 12 | import { DEPLOYS_CHOICES } from "../../lib/project-kind"; | |
| 13 | − | import { deployments, projects } from "../../lib/services.server"; | |
| 13 | + | import { deployments, identity, projects } from "../../lib/services.server"; | |
| 14 | 14 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 15 | 15 | import { requireCapability, requireInsider } from "../../lib/access.server"; | |
| 16 | 16 | ||
| 28 | 28 | deployments.settings({ workspace: params.owner, slug: params.repo }, viewer).catch(() => null), | |
| 29 | 29 | ]); | |
| 30 | 30 | 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 | + | }; | |
| 33 | 39 | } | |
| 34 | 40 | ||
| 35 | 41 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 48 | 54 | } | |
| 49 | 55 | ||
| 50 | 56 | export default function ProjectSettings({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 51 | − | const { project, deploymentsOn, mine } = loaderData; | |
| 57 | + | const { project, deploymentsOn, mine, creator } = loaderData; | |
| 52 | 58 | const [deploys, setDeploys] = useState<DeploysSetting>(project.deploys); | |
| 53 | 59 | // Not deploying while Deployments are on is refused: they are turned off first. | |
| 54 | 60 | const blocked = deploys === "no" && project.deploys !== "no" && deploymentsOn === true; | |
| 184 | 190 | </Button> | |
| 185 | 191 | {actionData && "saved" in actionData && <span className="text-sm text-accent">Saved.</span>} | |
| 186 | 192 | <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} | |
| 188 | 204 | <TimeAgo at={project.createdAt} /> | |
| 189 | 205 | </span> | |
| 190 | 206 | </div> |
| 2 | 2 | import { page } from "../../lib/meta"; | |
| 3 | 3 | import { TreeView } from "../../components/repo-view"; | |
| 4 | 4 | import { redirectIfBranchRenamed } from "../../lib/branch-redirect.server"; | |
| 5 | + | import { lastCommitsFor } from "../../lib/last-commits.server"; | |
| 5 | 6 | import { repos } from "../../lib/services.server"; | |
| 6 | 7 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 7 | 8 | ||
| 20 | 21 | ]); | |
| 21 | 22 | // A branch that was renamed: the same folder on its new name. | |
| 22 | 23 | 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 }; | |
| 24 | 27 | } | |
| 25 | 28 | ||
| 26 | 29 | 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} />; | |
| 28 | 31 | } |
| 121 | 121 | {commands.registry && <CopyLine prompt text={commands.registry} />} | |
| 122 | 122 | {pkg.visibility === "private" && <CopyLine prompt text={commands.login} />} | |
| 123 | 123 | <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" && ( | |
| 125 | 134 | <p className="text-xs text-faint"> | |
| 126 | 135 | Put an{" "} | |
| 127 | 136 | <Link to="/settings/tokens" className="text-muted hover:text-fg"> |
| 4 | 4 | import { MICROS_PER_DOLLAR, type UsageSlice } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/usage"; | |
| 7 | − | import { foldTasks, usageTask } from "../../lib/billing"; | |
| 7 | + | import { foldTasks, planStatus, usageTask } from "../../lib/billing"; | |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | 9 | import { ButtonLink } from "../../components/ui"; | |
| 10 | 10 | import { billing } from "../../lib/services.server"; | |
| 38 | 38 | const asked = new URL(request.url).searchParams.get("period"); | |
| 39 | 39 | const period: Period = asked && asked in PERIODS ? (asked as Period) : "month"; | |
| 40 | 40 | const since = start(period); | |
| 41 | − | const [usage, account] = await Promise.all([ | |
| 41 | + | const [usage, account, features, entitlements] = await Promise.all([ | |
| 42 | 42 | billing.usage(params.owner, viewer, since.toISOString()), | |
| 43 | 43 | billing.account(params.owner, viewer), | |
| 44 | + | billing.features(params.owner, viewer).catch(() => null), | |
| 45 | + | billing.entitlements(params.owner).catch(() => null), | |
| 44 | 46 | ]); | |
| 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 }; | |
| 46 | 51 | } | |
| 47 | 52 | ||
| 48 | 53 | function dollars(micros: number, digits = 2): string { | |
| 180 | 185 | } | |
| 181 | 186 | ||
| 182 | 187 | export default function UsagePage({ loaderData, params }: Route.ComponentProps) { | |
| 183 | − | const { period, since, usage, account } = loaderData; | |
| 188 | + | const { period, since, usage, account, comped } = loaderData; | |
| 184 | 189 | const byTask = foldTasks(usage.byTask); | |
| 185 | 190 | const base = `/${params.owner}`; | |
| 186 | 191 | const days = Math.max(1, Math.ceil((Date.now() - new Date(since).getTime()) / 86_400_000)); | |
| 232 | 237 | value={usage.runs ? dollars(total / usage.runs, 3) : "—"} | |
| 233 | 238 | note="Making a change, reviewing, revising…" | |
| 234 | 239 | /> | |
| 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 | + | )} | |
| 255 | 269 | </div> | |
| 256 | 270 | ||
| 257 | 271 | <section className="rounded-2xl bg-surface p-5 ring-1 ring-line"> |
| 565 | 565 | pub hash: String, | |
| 566 | 566 | } | |
| 567 | 567 | ||
| 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 | + | ||
| 568 | 615 | /// `branches`: the repository's branches, default branch first. | |
| 569 | 616 | /// Returns `Outcome<Vec<Branch>>`. | |
| 570 | 617 | #[derive(Debug, Serialize, Deserialize)] |
| 285 | 285 | gitAccess: (path, viewer, service) => | |
| 286 | 286 | call("git_access", { path, viewer, service }), | |
| 287 | 287 | 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 }), | |
| 288 | 290 | listFiles: (repoId, ref, limit) => call("list_files", { repoId, ref, skipDirs: [], limit }), | |
| 289 | 291 | rawBlobs: (repoId, hashes, maxBytes) => call("raw_blobs", { repoId, hashes, maxBytes }), | |
| 290 | 292 | commitFile: (repo, actor, file) => call("commit_file", { repo, actor, ...file }), |
| 105 | 105 | * One branch moved by a push. `ref` is the full ref, `after` the commit it | |
| 106 | 106 | * points to now, and `defaultBranch` whether it is the default branch. | |
| 107 | 107 | */ | |
| 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 }; | |
| 109 | 110 | /** | |
| 110 | 111 | * `author` is who opened it: g1t, for one its agent filed while at work, | |
| 111 | 112 | * with `requestedBy` the person it was working for. Every issue and pull |
| 234 | 234 | /** The repository's branches, default branch first. */ | |
| 235 | 235 | branches(path: RepoPath, viewer: Viewer): Promise<Result<Branch[]>>; | |
| 236 | 236 | ||
| 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 | + | ||
| 237 | 243 | /** | |
| 238 | 244 | * Every file at `ref` (the default branch when null), at most `limit` | |
| 239 | 245 | * (10,000 at most). No viewer: check access first. | |
| 291 | 297 | /** A branch and the commit it points to. */ | |
| 292 | 298 | export type Branch = { name: string; hash: string }; | |
| 293 | 299 | ||
| 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 | + | ||
| 294 | 306 | /** Files at a commit, and whether there were more than were listed. */ | |
| 295 | 307 | export type FileList = { commit: string | null; files: { path: string; hash: string | null }[]; truncated: boolean }; | |
| 296 | 308 |
| 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 | + | } |
| 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 | + | } |
| 62 | 62 | pub version_count: u32, | |
| 63 | 63 | pub bytes: u64, | |
| 64 | 64 | 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>, | |
| 65 | 69 | /// Every version, newest published first, one per line: the summary | |
| 66 | 70 | /// picks the highest of them (see `newest_version`). | |
| 67 | 71 | pub latest_version: Option<String>, | |
| 91 | 95 | .or_else(|| list.first().map(|v| (*v).to_owned())) | |
| 92 | 96 | } | |
| 93 | 97 | ||
| 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 | + | ||
| 94 | 107 | #[cfg(test)] | |
| 95 | 108 | 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 | + | } | |
| 97 | 147 | ||
| 98 | 148 | #[test] | |
| 99 | 149 | fn the_latest_is_the_highest_stable_version_not_the_last_published() { | |
| 135 | 185 | /// npm's deprecation message, when the version is deprecated. | |
| 136 | 186 | #[serde(default)] | |
| 137 | 187 | pub deprecated: Option<String>, | |
| 188 | + | /// Cargo: 1 when the version is yanked. | |
| 189 | + | #[serde(default)] | |
| 190 | + | pub yanked: u32, | |
| 138 | 191 | } | |
| 139 | 192 | ||
| 140 | 193 | impl VersionRow { | |
| 194 | + | pub fn is_yanked(&self) -> bool { | |
| 195 | + | self.yanked != 0 | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | impl VersionRow { | |
| 141 | 200 | pub fn meta(&self) -> serde_json::Value { | |
| 142 | 201 | serde_json::from_str(&self.metadata).unwrap_or_default() | |
| 143 | 202 | } | |
| 206 | 265 | "id, workspace, ecosystem, name, repo_id, repo_name, visibility, description, created_by, created_at, updated_at, downloads, workspace_deleted_at"; | |
| 207 | 266 | /// Workspaces that are deleted, waiting to be purged or restored. | |
| 208 | 267 | 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"; | |
| 210 | 269 | ||
| 211 | 270 | pub struct Db { | |
| 212 | 271 | pub db: D1Database, | |
| 226 | 285 | .await | |
| 227 | 286 | } | |
| 228 | 287 | ||
| 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 | + | ||
| 229 | 331 | /// Makes a package unless one of the name is already there (a push | |
| 230 | 332 | /// beside this one may have made it), and answers with the one kept. | |
| 231 | 333 | #[allow(clippy::too_many_arguments)] | |
| 282 | 384 | (SELECT COALESCE(SUM(b.size), 0) FROM blobs b WHERE b.digest IN \ | |
| 283 | 385 | (SELECT vf.digest FROM version_files vf JOIN versions v ON v.id = vf.version_id WHERE v.package_id = p.id)) AS bytes, \ | |
| 284 | 386 | (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 \ | |
| 286 | 391 | FROM packages p WHERE p.workspace = ? AND p.workspace_deleted_at IS NULL", | |
| 287 | 392 | PACKAGE_COLUMNS.split(", ").map(|c| format!("p.{c}")).collect::<Vec<_>>().join(", ") | |
| 288 | 393 | ); |
| 8 | 8 | //! `BlobStore` port (store/), metadata in D1 (db.rs). | |
| 9 | 9 | ||
| 10 | 10 | mod access; | |
| 11 | + | mod cargo; | |
| 12 | + | mod cargo_http; | |
| 11 | 13 | mod composer; | |
| 12 | 14 | mod composer_http; | |
| 13 | 15 | mod db; | |
| 348 | 350 | address: match p.ecosystem.as_str() { | |
| 349 | 351 | "npm" => format!("{}/-/npm/@{}/{}", self.host, p.workspace, p.name), | |
| 350 | 352 | "composer" => format!("{}/-/composer/{}/{}", self.host, p.workspace, p.name), | |
| 353 | + | "cargo" => format!("{}/-/cargo/{}/{}", self.host, p.workspace, p.name), | |
| 351 | 354 | _ => format!("{}/{}/{}", self.host, p.workspace, p.name), | |
| 352 | 355 | }, | |
| 353 | 356 | visibility: Visibility::parse(&p.visibility), | |
| 358 | 361 | }), | |
| 359 | 362 | description: p.description.clone(), | |
| 360 | 363 | 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), | |
| 362 | 365 | size: row.bytes, | |
| 363 | 366 | downloads: p.downloads, | |
| 364 | 367 | created_at: p.created_at.clone(), | |
| 377 | 380 | version_count: 0, | |
| 378 | 381 | bytes: 0, | |
| 379 | 382 | latest_tag: None, | |
| 383 | + | latest_tag_version: None, | |
| 380 | 384 | latest_version: None, | |
| 381 | 385 | })) | |
| 382 | 386 | } | |
| 429 | 433 | subject: version.subject, | |
| 430 | 434 | published_by: version.published_by, | |
| 431 | 435 | 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 | + | }, | |
| 433 | 443 | } | |
| 434 | 444 | }) | |
| 435 | 445 | .collect(); | |
| 669 | 679 | if request.path().starts_with("/-/composer/") { | |
| 670 | 680 | return packages.composer(request, &ctx).await; | |
| 671 | 681 | } | |
| 682 | + | if request.path().starts_with("/-/cargo/") { | |
| 683 | + | return packages.cargo(request, &ctx).await; | |
| 684 | + | } | |
| 672 | 685 | return packages.registry(request, &ctx).await; | |
| 673 | 686 | }; | |
| 674 | 687 | let body: serde_json::Value = request.json().await?; |
| 975 | 975 | ..self.event_of(package) | |
| 976 | 976 | }; | |
| 977 | 977 | 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. | |
| 979 | 979 | let path = if package.ecosystem == "npm" { | |
| 980 | 980 | format!("@{}/{}@{}", package.workspace, package.name, version.version) | |
| 981 | + | } else if package.ecosystem == "cargo" { | |
| 982 | + | format!("{}/{}@{}", package.workspace, package.name, version.version) | |
| 981 | 983 | } else { | |
| 982 | 984 | format!("{}/{}@{}", package.workspace, package.name, version.digest) | |
| 983 | 985 | }; |
| 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 | + | } |
| 15 | 15 | mod git_ops; | |
| 16 | 16 | mod import; | |
| 17 | 17 | mod land; | |
| 18 | + | mod last_commits; | |
| 18 | 19 | mod lifecycle; | |
| 19 | 20 | mod listing; | |
| 20 | 21 | mod meters; | |
| 57 | 58 | const MAX_TEXT_BYTES: usize = 512 * 1024; | |
| 58 | 59 | /// How far back a pull request may have forked and still be landed. | |
| 59 | 60 | 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 | + | } | |
| 60 | 71 | const MAX_DESCRIPTION_CHARS: usize = 200; | |
| 61 | 72 | pub(crate) const SOURCE: &str = "repos"; | |
| 62 | 73 | pub(crate) const UNVERIFIED: &str = "Confirm your email address first. Check your inbox, or resend the link from the banner on g1t.sh."; | |
| 735 | 746 | Ok(Outcome::Ok(git.log(&git_ref, a.limit).await?)) | |
| 736 | 747 | } | |
| 737 | 748 | ||
| 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 | + | ||
| 738 | 816 | /// The repository's branches, default branch first. | |
| 739 | 817 | async fn branches(&self, a: BranchesArgs) -> Result<Outcome<Vec<Branch>>> { | |
| 740 | 818 | let Some(repo) = self.readable(&a.path, &a.viewer).await? else { | |
| 1921 | 1999 | "fork_for_pull" => reply(&repos.fork_for_pull(args(body)?).await?), | |
| 1922 | 2000 | "git_access" => reply(&repos.git_access(args(body)?).await?), | |
| 1923 | 2001 | "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?), | |
| 1924 | 2004 | "head" => reply(&repos.head(args(body)?).await?), | |
| 1925 | 2005 | "behind" => reply(&repos.behind(args(body)?).await?), | |
| 1926 | 2006 | "divergence" => reply(&repos.divergence(args(body)?).await?), |
Binary or large file; its contents are not shown.
This change is too large to show in full.