Branches and Tags pages, each file's last commit, and the branch menu on files
- Files: every row shows the commit that last changed it and when, from a new repos last_commits walk (300 commits, first parent, trees read only where the folder changed), kept in the colo cache by head commit and path and streamed in after the list. - Branches: default, Active and Stale, each with its last commit, ahead and behind, the open pull request and its checks, and its preview; search by name. The overview's Active branches share the same reader. - Tags: newest first with their commit and a ZIP, from a new repos tags RPC. - Blob pages get the branch menu, which keeps the file when switching. - Web tests import with .ts as plain node needs.
20 files+820−1070/20 viewed
| 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: |
| 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 | + | } |
| 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"), |
| 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 | + | const all = unwrap(list); | |
| 35 | + | const matching = query ? all.filter((branch) => branch.name === found.defaultBranch || branch.name.toLowerCase().includes(query)) : all; | |
| 36 | + | const read = await readBranches( | |
| 37 | + | path, | |
| 38 | + | viewer, | |
| 39 | + | { | |
| 40 | + | defaultBranch: found.defaultBranch, | |
| 41 | + | branches: matching, | |
| 42 | + | pulls: pulls?.ok ? pulls.value : [], | |
| 43 | + | previews: deploys?.ok ? deploys.value.live.filter((app) => app.kind === "preview") : [], | |
| 44 | + | }, | |
| 45 | + | BRANCHES_READ, | |
| 46 | + | ); | |
| 47 | + | const staleBefore = Date.now() - STALE_DAYS * 86_400_000; | |
| 48 | + | const isStale = (at: string | undefined) => at != null && Date.parse(at) < staleBefore; | |
| 49 | + | return { | |
| 50 | + | main: found.defaultBranch, | |
| 51 | + | head: read.head, | |
| 52 | + | active: read.shown.filter((branch) => !isStale(branch.commit?.at)), | |
| 53 | + | stale: read.shown.filter((branch) => isStale(branch.commit?.at)), | |
| 54 | + | // Every branch but the default, and how many matched the search. | |
| 55 | + | total: all.length - 1, | |
| 56 | + | matched: read.total, | |
| 57 | + | unread: Math.max(0, read.total - read.shown.length), | |
| 58 | + | query, | |
| 59 | + | }; | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | export default function Branches({ loaderData, params }: Route.ComponentProps) { | |
| 63 | + | const { main, head, active, stale, total, matched, unread, query } = loaderData; | |
| 64 | + | const base = `/${params.owner}/${params.repo}`; | |
| 65 | + | return ( | |
| 66 | + | <div className="space-y-6"> | |
| 67 | + | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 68 | + | <h2 className="text-lg font-semibold tracking-tight"> | |
| 69 | + | Branches <span className="font-normal text-faint">{total + 1}</span> | |
| 70 | + | </h2> | |
| 71 | + | <Form method="get" role="search" className="relative w-full sm:w-72"> | |
| 72 | + | <Search size={14} className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-faint" /> | |
| 73 | + | <input | |
| 74 | + | name="q" | |
| 75 | + | defaultValue={query ?? ""} | |
| 76 | + | {...notACredential()} | |
| 77 | + | placeholder="Search branches" | |
| 78 | + | aria-label="Search branches" | |
| 79 | + | 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" | |
| 80 | + | /> | |
| 81 | + | </Form> | |
| 82 | + | </div> | |
| 83 | + | ||
| 84 | + | <section> | |
| 85 | + | <h3 className="mb-2 text-sm font-medium text-muted">Default</h3> | |
| 86 | + | <div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-xl border border-line bg-surface px-4 py-3"> | |
| 87 | + | <GitBranch size={15} className="shrink-0 text-faint" /> | |
| 88 | + | <Link to={`${base}/tree/${encodeURIComponent(main)}`} className="font-mono text-[0.8125rem] font-medium hover:text-accent"> | |
| 89 | + | {main} | |
| 90 | + | </Link> | |
| 91 | + | <span className="rounded-full border border-line px-2 py-px text-xs text-muted">default</span> | |
| 92 | + | {head && ( | |
| 93 | + | <span className="flex min-w-0 grow basis-64 items-center gap-1.5 text-xs text-muted"> | |
| 94 | + | <Avatar name={head.author} size={13} /> | |
| 95 | + | <span className="shrink-0">{head.author}</span> | |
| 96 | + | <span className="text-faint">·</span> | |
| 97 | + | <Link to={`${base}/commit/${head.hash}`} className="min-w-0 truncate hover:text-fg" title={head.message}> | |
| 98 | + | {head.message} | |
| 99 | + | </Link> | |
| 100 | + | <span className="shrink-0 text-faint"> | |
| 101 | + | · <TimeAgo at={head.at} /> | |
| 102 | + | </span> | |
| 103 | + | </span> | |
| 104 | + | )} | |
| 105 | + | </div> | |
| 106 | + | </section> | |
| 107 | + | ||
| 108 | + | {query && ( | |
| 109 | + | <p className="text-sm text-muted"> | |
| 110 | + | {matched} of {total} {total === 1 ? "branch matches" : "branches match"} “{query}”.{" "} | |
| 111 | + | <Link to={`${base}/branches`} className="text-fg hover:underline"> | |
| 112 | + | Clear | |
| 113 | + | </Link> | |
| 114 | + | </p> | |
| 115 | + | )} | |
| 116 | + | ||
| 117 | + | {active.length > 0 && ( | |
| 118 | + | <section> | |
| 119 | + | <h3 className="mb-2 text-sm font-medium text-muted">Active</h3> | |
| 120 | + | <ActiveBranches branches={active} base={base} main={main} /> | |
| 121 | + | </section> | |
| 122 | + | )} | |
| 123 | + | {stale.length > 0 && ( | |
| 124 | + | <section> | |
| 125 | + | <h3 className="mb-2 text-sm font-medium text-muted">Stale</h3> | |
| 126 | + | <p className="-mt-1 mb-2 text-xs text-faint">No commits in the last 90 days.</p> | |
| 127 | + | <ActiveBranches branches={stale} base={base} main={main} /> | |
| 128 | + | </section> | |
| 129 | + | )} | |
| 130 | + | {active.length === 0 && stale.length === 0 && ( | |
| 131 | + | <EmptyState title={query ? "No branch matches" : "Only the default branch"}> | |
| 132 | + | {query | |
| 133 | + | ? "Try another part of the name." | |
| 134 | + | : "Branches agents and people push show here, each with its pull request, checks and how far it has moved from the default branch."} | |
| 135 | + | </EmptyState> | |
| 136 | + | )} | |
| 137 | + | {unread > 0 && ( | |
| 138 | + | <p className="text-xs text-faint"> | |
| 139 | + | {unread} more {unread === 1 ? "branch is" : "branches are"} not shown. Search to find one by name. | |
| 140 | + | </p> | |
| 141 | + | )} | |
| 142 | + | </div> | |
| 143 | + | ); | |
| 144 | + | } |
| 16 | 16 | // For the branch menu; the page still shows without it. | |
| 17 | 17 | repos.branches(path, viewer).catch(() => null), | |
| 18 | 18 | ]); | |
| 19 | − | return { tree: unwrap(tree), branches: branches?.ok ? branches.value : null }; | |
| 19 | + | // Each entry's last commit walks history: streamed in after the list. | |
| 20 | + | const lastCommits = repos | |
| 21 | + | .lastCommits(path, viewer, null, "") | |
| 22 | + | .then((found) => (found.ok ? found.value : null)) | |
| 23 | + | .catch(() => null); | |
| 24 | + | return { tree: unwrap(tree), branches: branches?.ok ? branches.value : null, lastCommits }; | |
| 20 | 25 | } | |
| 21 | 26 | ||
| 22 | 27 | export default function Code({ loaderData }: Route.ComponentProps) { | |
| 23 | − | return <TreeView tree={loaderData.tree} branches={loaderData.branches} />; | |
| 28 | + | return <TreeView tree={loaderData.tree} branches={loaderData.branches} lastCommits={loaderData.lastCommits} />; | |
| 24 | 29 | } |
| 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. |
| 20 | 20 | ]); | |
| 21 | 21 | // A branch that was renamed: the same folder on its new name. | |
| 22 | 22 | 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 }; | |
| 23 | + | // Each entry's last commit walks history: streamed in after the list. | |
| 24 | + | const lastCommits = repos | |
| 25 | + | .lastCommits(path, viewer, params.ref, params["*"] ?? "") | |
| 26 | + | .then((found) => (found.ok ? found.value : null)) | |
| 27 | + | .catch(() => null); | |
| 28 | + | return { tree: unwrap(tree), branches: branches?.ok ? branches.value : null, lastCommits }; | |
| 24 | 29 | } | |
| 25 | 30 | ||
| 26 | 31 | export default function Tree({ loaderData }: Route.ComponentProps) { | |
| 27 | − | return <TreeView tree={loaderData.tree} branches={loaderData.branches} />; | |
| 32 | + | return <TreeView tree={loaderData.tree} branches={loaderData.branches} lastCommits={loaderData.lastCommits} />; | |
| 28 | 33 | } |
| 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 | + | } | |
| 580 | + | ||
| 581 | + | /// An entry of a directory and the commit that last changed it. | |
| 582 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 583 | + | pub struct LastCommit { | |
| 584 | + | pub name: String, | |
| 585 | + | pub commit: Commit, | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | /// The entries' last commits. `complete` is false when the history walked | |
| 589 | + | /// ran out before every entry was placed; those entries are left out. | |
| 590 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 591 | + | pub struct LastCommits { | |
| 592 | + | pub entries: Vec<LastCommit>, | |
| 593 | + | pub complete: bool, | |
| 594 | + | } | |
| 595 | + | ||
| 596 | + | /// `tags`: the repository's tags, newest commit first, each with the | |
| 597 | + | /// commit it names. Returns `Outcome<Vec<Tag>>`. | |
| 598 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 599 | + | pub struct TagsArgs { | |
| 600 | + | pub path: RepoPath, | |
| 601 | + | pub viewer: Viewer, | |
| 602 | + | } | |
| 603 | + | ||
| 604 | + | /// A tag, and its commit when it could be read. | |
| 605 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 606 | + | pub struct Tag { | |
| 607 | + | pub name: String, | |
| 608 | + | pub commit: Option<Commit>, | |
| 609 | + | } | |
| 610 | + | ||
| 568 | 611 | /// `branches`: the repository's branches, default branch first. | |
| 569 | 612 | /// Returns `Outcome<Vec<Branch>>`. | |
| 570 | 613 | #[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 }), |
| 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 | + | //! 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 | + | ||
| 20 | + | /// Reads trees, remembering those already read: commits share most of them. | |
| 21 | + | struct Trees<'a, R: GitRepo> { | |
| 22 | + | repo: &'a R, | |
| 23 | + | read: HashMap<String, Option<Vec<TreeEntry>>>, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | impl<'a, R: GitRepo> Trees<'a, R> { | |
| 27 | + | async fn get(&mut self, hash: &str) -> Result<Option<Vec<TreeEntry>>> { | |
| 28 | + | if let Some(found) = self.read.get(hash) { | |
| 29 | + | return Ok(found.clone()); | |
| 30 | + | } | |
| 31 | + | let found = self.repo.read_tree(hash).await?; | |
| 32 | + | self.read.insert(hash.to_owned(), found.clone()); | |
| 33 | + | Ok(found) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// The tree hash of `path` in a commit's root tree; the root for an empty path. | |
| 37 | + | async fn dir(&mut self, root: &str, path: &str) -> Result<Option<String>> { | |
| 38 | + | let mut hash = root.to_owned(); | |
| 39 | + | for segment in path.split('/').filter(|segment| !segment.is_empty()) { | |
| 40 | + | let Some(entries) = self.get(&hash).await? else { | |
| 41 | + | return Ok(None); | |
| 42 | + | }; | |
| 43 | + | match entries.into_iter().find(|entry| entry.name == segment && entry.kind == EntryKind::Tree) { | |
| 44 | + | Some(entry) => hash = entry.hash, | |
| 45 | + | None => return Ok(None), | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | Ok(Some(hash)) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | async fn entries(&mut self, dir: Option<&str>) -> Result<HashMap<String, String>> { | |
| 52 | + | let Some(dir) = dir else { | |
| 53 | + | return Ok(HashMap::new()); | |
| 54 | + | }; | |
| 55 | + | Ok(self.get(dir).await?.unwrap_or_default().into_iter().map(|entry| (entry.name, entry.hash)).collect()) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// The last commit of each entry of `path` at `git_ref`, and whether every | |
| 60 | + | /// entry was given one. | |
| 61 | + | pub async fn last_commits<R: GitRepo>(repo: &R, git_ref: &str, path: &str) -> Result<(Vec<LastCommit>, bool)> { | |
| 62 | + | let history = repo.log(git_ref, MAX_COMMITS).await?; | |
| 63 | + | let Some(head) = history.first() else { | |
| 64 | + | return Ok((Vec::new(), true)); | |
| 65 | + | }; | |
| 66 | + | let mut trees = Trees { repo, read: HashMap::new() }; | |
| 67 | + | let mut dir = trees.dir(&head.tree_hash, path).await?; | |
| 68 | + | let mut current = trees.entries(dir.as_deref()).await?; | |
| 69 | + | let mut open: Vec<String> = current.keys().cloned().collect(); | |
| 70 | + | let mut found: Vec<LastCommit> = Vec::new(); | |
| 71 | + | let give = |found: &mut Vec<LastCommit>, name: String, commit: &Commit| found.push(LastCommit { name, commit: commit.clone() }); | |
| 72 | + | for (index, commit) in history.iter().enumerate() { | |
| 73 | + | if open.is_empty() { | |
| 74 | + | break; | |
| 75 | + | } | |
| 76 | + | let Some(parent) = history.get(index + 1) else { | |
| 77 | + | // The oldest commit read. If it is the first commit there is, | |
| 78 | + | // what is left was added by it. | |
| 79 | + | if commit.parents.is_empty() { | |
| 80 | + | for name in open.drain(..) { | |
| 81 | + | give(&mut found, name, commit); | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | break; | |
| 85 | + | }; | |
| 86 | + | let parent_dir = trees.dir(&parent.tree_hash, path).await?; | |
| 87 | + | if parent_dir == dir { | |
| 88 | + | continue; | |
| 89 | + | } | |
| 90 | + | let before = trees.entries(parent_dir.as_deref()).await?; | |
| 91 | + | let (changed, still): (Vec<String>, Vec<String>) = open.into_iter().partition(|name| before.get(name) != current.get(name)); | |
| 92 | + | for name in changed { | |
| 93 | + | give(&mut found, name, commit); | |
| 94 | + | } | |
| 95 | + | open = still; | |
| 96 | + | dir = parent_dir; | |
| 97 | + | current = before; | |
| 98 | + | } | |
| 99 | + | let complete = open.is_empty(); | |
| 100 | + | Ok((found, complete)) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | #[cfg(test)] | |
| 104 | + | mod tests { | |
| 105 | + | use std::future::Future; | |
| 106 | + | use std::pin::pin; | |
| 107 | + | use std::task::{Context, Poll, Waker}; | |
| 108 | + | ||
| 109 | + | use g1t_contracts::repos::{Branch, GitAccess, Signature}; | |
| 110 | + | ||
| 111 | + | use super::*; | |
| 112 | + | use crate::store::Scope; | |
| 113 | + | ||
| 114 | + | fn run<F: Future>(future: F) -> F::Output { | |
| 115 | + | match pin!(future).as_mut().poll(&mut Context::from_waker(Waker::noop())) { | |
| 116 | + | Poll::Ready(output) => output, | |
| 117 | + | Poll::Pending => panic!("the fake store never waits"), | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | #[derive(Default)] | |
| 122 | + | struct Fake { | |
| 123 | + | trees: HashMap<String, Vec<TreeEntry>>, | |
| 124 | + | history: Vec<Commit>, | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | impl GitRepo for Fake { | |
| 128 | + | async fn access(&self, _scope: Scope) -> Result<GitAccess> { | |
| 129 | + | unimplemented!() | |
| 130 | + | } | |
| 131 | + | async fn branches(&self) -> Result<Vec<Branch>> { | |
| 132 | + | Ok(Vec::new()) | |
| 133 | + | } | |
| 134 | + | async fn log(&self, _git_ref: &str, limit: u32) -> Result<Vec<Commit>> { | |
| 135 | + | Ok(self.history.iter().take(limit as usize).cloned().collect()) | |
| 136 | + | } | |
| 137 | + | async fn parents(&self, _commit_hash: &str) -> Result<Option<Vec<String>>> { | |
| 138 | + | Ok(None) | |
| 139 | + | } | |
| 140 | + | async fn read_tree(&self, tree_hash: &str) -> Result<Option<Vec<TreeEntry>>> { | |
| 141 | + | Ok(self.trees.get(tree_hash).cloned()) | |
| 142 | + | } | |
| 143 | + | async fn read_blob(&self, _blob_hash: &str) -> Result<Option<Vec<u8>>> { | |
| 144 | + | Ok(None) | |
| 145 | + | } | |
| 146 | + | async fn read_file(&self, _git_ref: &str, _path: &str) -> Result<Option<Vec<u8>>> { | |
| 147 | + | Ok(None) | |
| 148 | + | } | |
| 149 | + | async fn fork(&self, _target_key: &str) -> Result<()> { | |
| 150 | + | Ok(()) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | fn entry(name: &str, hash: &str, kind: EntryKind) -> TreeEntry { | |
| 155 | + | TreeEntry { name: name.into(), hash: hash.into(), kind } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | fn commit(hash: &str, tree: &str, parent: Option<&str>) -> Commit { | |
| 159 | + | Commit { | |
| 160 | + | hash: hash.into(), | |
| 161 | + | tree_hash: tree.into(), | |
| 162 | + | message: format!("commit {hash}"), | |
| 163 | + | author: Signature { name: "a".into(), email: "a@example.com".into() }, | |
| 164 | + | parents: parent.map(|p| vec![p.to_owned()]).unwrap_or_default(), | |
| 165 | + | authored_at: String::new(), | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | /// c1 adds README and src/a.rs; c2 changes src/a.rs; c3 changes README. | |
| 170 | + | fn repo() -> Fake { | |
| 171 | + | let mut fake = Fake::default(); | |
| 172 | + | fake.trees.insert("src1".into(), vec![entry("a.rs", "a1", EntryKind::Blob)]); | |
| 173 | + | fake.trees.insert("src2".into(), vec![entry("a.rs", "a2", EntryKind::Blob)]); | |
| 174 | + | fake.trees.insert("root1".into(), vec![entry("README.md", "r1", EntryKind::Blob), entry("src", "src1", EntryKind::Tree)]); | |
| 175 | + | fake.trees.insert("root2".into(), vec![entry("README.md", "r1", EntryKind::Blob), entry("src", "src2", EntryKind::Tree)]); | |
| 176 | + | fake.trees.insert("root3".into(), vec![entry("README.md", "r2", EntryKind::Blob), entry("src", "src2", EntryKind::Tree)]); | |
| 177 | + | fake.history = vec![commit("c3", "root3", Some("c2")), commit("c2", "root2", Some("c1")), commit("c1", "root1", None)]; | |
| 178 | + | fake | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | fn by_name(found: Vec<LastCommit>) -> HashMap<String, String> { | |
| 182 | + | found.into_iter().map(|last| (last.name, last.commit.hash)).collect() | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | #[test] | |
| 186 | + | fn each_root_entry_gets_the_newest_commit_that_changed_it() { | |
| 187 | + | let (found, complete) = run(last_commits(&repo(), "main", "")).unwrap(); | |
| 188 | + | assert!(complete); | |
| 189 | + | let found = by_name(found); | |
| 190 | + | assert_eq!(found["README.md"], "c3"); | |
| 191 | + | assert_eq!(found["src"], "c2"); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[test] | |
| 195 | + | fn a_subdirectory_is_walked_by_its_own_tree() { | |
| 196 | + | let (found, complete) = run(last_commits(&repo(), "main", "src")).unwrap(); | |
| 197 | + | assert!(complete); | |
| 198 | + | assert_eq!(by_name(found)["a.rs"], "c2"); | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | #[test] | |
| 202 | + | fn an_entry_unchanged_since_the_first_commit_belongs_to_it() { | |
| 203 | + | let mut fake = repo(); | |
| 204 | + | fake.trees.insert("root2".into(), vec![entry("README.md", "r1", EntryKind::Blob), entry("src", "src1", EntryKind::Tree)]); | |
| 205 | + | fake.trees.insert("root3".into(), vec![entry("README.md", "r2", EntryKind::Blob), entry("src", "src1", EntryKind::Tree)]); | |
| 206 | + | let (found, complete) = run(last_commits(&fake, "main", "")).unwrap(); | |
| 207 | + | assert!(complete); | |
| 208 | + | assert_eq!(by_name(found)["src"], "c1"); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | #[test] | |
| 212 | + | fn a_walk_cut_short_leaves_the_rest_unknown() { | |
| 213 | + | let mut fake = repo(); | |
| 214 | + | // c1 has a parent the walk never reaches. | |
| 215 | + | fake.history[2].parents = vec!["c0".into()]; | |
| 216 | + | fake.trees.insert("root2".into(), vec![entry("README.md", "r1", EntryKind::Blob), entry("src", "src1", EntryKind::Tree)]); | |
| 217 | + | fake.trees.insert("root3".into(), vec![entry("README.md", "r2", EntryKind::Blob), entry("src", "src1", EntryKind::Tree)]); | |
| 218 | + | let (found, complete) = run(last_commits(&fake, "main", "")).unwrap(); | |
| 219 | + | assert!(!complete); | |
| 220 | + | let found = by_name(found); | |
| 221 | + | assert_eq!(found["README.md"], "c3"); | |
| 222 | + | assert!(!found.contains_key("src")); | |
| 223 | + | } | |
| 224 | + | } |
| 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 | + | let (entries, complete) = last_commits::last_commits(&git, &head.hash, &a.tree_path).await?; | |
| 774 | + | let found = g1t_contracts::repos::LastCommits { entries, complete }; | |
| 775 | + | if let Ok(mut response) = worker::Response::from_json(&found) { | |
| 776 | + | let _ = response.headers_mut().set("cache-control", "max-age=604800"); | |
| 777 | + | let _ = cache.put(key.as_str(), response).await; | |
| 778 | + | } | |
| 779 | + | Ok(Outcome::Ok(found)) | |
| 780 | + | } | |
| 781 | + | ||
| 782 | + | /// The repository's tags, newest commit first, at most 100. | |
| 783 | + | async fn tags(&self, a: g1t_contracts::repos::TagsArgs) -> Result<Outcome<Vec<g1t_contracts::repos::Tag>>> { | |
| 784 | + | let Some(repo) = self.readable(&a.path, &a.viewer).await? else { | |
| 785 | + | return Ok(not_found()); | |
| 786 | + | }; | |
| 787 | + | let git = self.store.open(&store_key(&repo)).await?; | |
| 788 | + | let access = git.access(Scope::Read).await?; | |
| 789 | + | let named: Vec<(String, String)> = refs::heads_and_tags(refs::all(&access).await?) | |
| 790 | + | .into_iter() | |
| 791 | + | .filter_map(|(name, hash)| name.strip_prefix("refs/tags/").map(|tag| (tag.to_owned(), hash))) | |
| 792 | + | .collect(); | |
| 793 | + | let read = self.read_git(&repo).await?; | |
| 794 | + | let commits = futures_util::future::join_all(named.iter().take(MAX_TAGS_READ).map(|(_, hash)| read.log(hash, 1))).await; | |
| 795 | + | let mut tags: Vec<g1t_contracts::repos::Tag> = named | |
| 796 | + | .into_iter() | |
| 797 | + | .zip(commits.into_iter().map(|found| found.ok().and_then(|list| list.into_iter().next())).chain(std::iter::repeat(None))) | |
| 798 | + | .map(|((name, _), commit)| g1t_contracts::repos::Tag { name, commit }) | |
| 799 | + | .collect(); | |
| 800 | + | tags.sort_by(|a, b| { | |
| 801 | + | let at = |tag: &g1t_contracts::repos::Tag| tag.commit.as_ref().map(|c| c.authored_at.clone()).unwrap_or_default(); | |
| 802 | + | at(b).cmp(&at(a)).then_with(|| b.name.cmp(&a.name)) | |
| 803 | + | }); | |
| 804 | + | tags.truncate(MAX_TAGS_READ); | |
| 805 | + | Ok(Outcome::Ok(tags)) | |
| 806 | + | } | |
| 807 | + | ||
| 738 | 808 | /// The repository's branches, default branch first. | |
| 739 | 809 | async fn branches(&self, a: BranchesArgs) -> Result<Outcome<Vec<Branch>>> { | |
| 740 | 810 | let Some(repo) = self.readable(&a.path, &a.viewer).await? else { | |
| 1921 | 1991 | "fork_for_pull" => reply(&repos.fork_for_pull(args(body)?).await?), | |
| 1922 | 1992 | "git_access" => reply(&repos.git_access(args(body)?).await?), | |
| 1923 | 1993 | "branches" => reply(&repos.branches(args(body)?).await?), | |
| 1994 | + | "last_commits" => reply(&repos.last_commits(args(body)?).await?), | |
| 1995 | + | "tags" => reply(&repos.tags(args(body)?).await?), | |
| 1924 | 1996 | "head" => reply(&repos.head(args(body)?).await?), | |
| 1925 | 1997 | "behind" => reply(&repos.behind(args(body)?).await?), | |
| 1926 | 1998 | "divergence" => reply(&repos.divergence(args(body)?).await?), |