# @pdsjs/git-ui A public repository browser for the git repositories `@pdsjs/git` hosts in an atproto account. It deploys to the account's own site, so a visitor browses trees, files, history and commit diffs with no sign-in. Everything it shows, it can read for itself; one read takes a shortcut through the server where the server offers one. React, shadcn conventions and Base UI, the same stack the account pages use. ## The shape of a screen The window is the app. The bar and the repository's sections hold their place, and each screen takes what is left and scrolls inside itself, so nothing but the reading area moves. A screen is `Screen`: a band fixed at the top and a body with its own scroll. A listing fixes a `PageBar`, one height on every route, holding the ref picker, the breadcrumb or the row of tabs on the left and the one action on the right. A detail page fixes a `PageHead` instead: the title at one size, a line of facts under it, and the way back. A screen with nothing to show says so in a `PageNotice`, which takes the same room the screen would. Everything a reader reads sits in one centred column, the way a forge lays out a list, a conversation or a run: the rules under a band run the width of the window, and the words inside it align to the column. Two screens take the whole window instead, because each holds a listing beside the thing it opens: the source view inside a directory, with the listing beside the file, and a diff, with the changed files beside the hunks. The repository's root is read about rather than navigated, so it stays in the column: the files in one panel with the latest commit on its header, the readme in the panel under it, and an About column beside them with the description and the gauges. A pull request is read in the column and its files changed tab is read wide, so the head above it follows the tab. `wide` on `Screen`, `PageBar` and `PageHead` is what says which; `lib/layout.js` holds the measure. A listing sits in a `ListBox`: one bordered panel in the column, the rows divided by rules inside it, the same panel whether the rows have arrived, are absent or are still loading. A row inside takes its edge from the panel, so it carries a fixed inset rather than the gutter. The controls are atoms. A form takes `Input` and `Textarea`, which share one border and the focus ring a `Button` has; a link that stands in a row of buttons is a `ButtonLink`; a number beside a label is a `Count`, the same pill on a section tab, a list tab and a filter. ## How it reads a repository Two layers feed the app, and only one of them is a record read. The repository list, the ref lists, the default branch and the bundle sizes come from a single `dev.pdsjs.query.run` call against a `dev.pdsjs.query.def` record. Reading is anonymous, so the list page, the branch menu and the clone box cost no blob downloads at all. Trees, file bodies, history and diffs live inside the git bundles in blob storage, which only a pack reader can open. A server that serves the `dev.pdsjs.git` reads (`community.lexicon.service.describe` lists them) opens the repository from its own storage and answers each screen in one request, through `GitClient` from `@pdsjs/git/client`; the answers are cached under the record cid the listing named. Otherwise `@pdsjs/git/browser` does that work in the page, and it also takes over a repository the server answers `RepositoryTooLarge` for. A repository whose bundles carry object indexes opens off those small blobs alone, and each screen then fetches just the objects it shows, as ranged reads. A repository pushed before the index existed downloads its whole chain instead. Blobs are fetched from this origin's `/.blobs/` route, which the site handler serves immutably, so a second visit reads them from the browser cache. Having the refs in hand before any screen renders is what lets a URL like `/repo/tree/feature/x/src/index.js` be split correctly: the ref and the path both hold slashes, and the only reliable split is against the repository's own ref names. ## Syntax colouring File bodies, READMEs and diffs are coloured by highlight.js. The grammars are chosen one by one rather than imported wholesale, because the build inlines everything into one document and the full package is over a megabyte. A file whose extension names no grammar renders plain. A diff hunk is not a valid program: it holds the lines before a change and the lines after it, interleaved. Colouring each row alone would end a block comment at every line break, which in a JSDoc-heavy repository is most of them. Each side of a hunk is rebuilt as its own text, coloured whole, and handed back a row at a time. ## Markdown A README renders rather than being shown as source. The text belongs to whoever wrote the repository and the result goes straight into this page, so two rules hold the line: raw HTML in the document is printed rather than parsed, and a link or image address must name a scheme worth following. Everything left is markup the renderer wrote itself, from a fixed set of tags. A fenced code block is coloured the same way a file is. A link to another file in the repository opens that file in this app rather than leaving it, and an image named by a relative path is read out of the repository's own objects, which are already in memory: a README's diagrams appear without the server serving anything. ## Commit authors A commit records its author as a name and an email address, written by that author's own git client. Nothing signs either, so a commit on its own says nothing about who wrote it. The page shows an avatar and a handle only for an author the hosting account has claimed, in its `dev.pdsjs.git.identity` record. Every other author reads as the plain name the commit carries, beside an initial. That record lives in the account's own repo under rkey `self`, so it covers every repository the account serves and nobody can write it for somebody else. An entry may name a `did`, which is how a project account attributes commits to the people who wrote them rather than to itself. `GIT_IDENTS` on a deploy writes the record: ```sh GIT_IDENTS='me@example.com,them@example.com=did:plc:…' ``` The trust this buys is worth stating plainly. A visitor is trusting the account whose repositories they are reading, and that account is saying which addresses belong to whom. It is not proof that the person holding the handle wrote the commit. ## Pull requests A repository whose config record names collaborators gets a pull request list, at `//pulls`. It stores nothing. Two tabs, open and merged, at `//pulls` and `//pulls/merged`. Each is an address rather than component state, so a reload and a shared link both land where the reader was. One read fills both, so moving between them costs nothing. A pull request is a branch in one account's own copy of the repository, on their own server, and what identifies it is that account's DID and the ref name. The pair survives amend, rebase, force-push and a commit added on top, so a check or a review written against one commit still names the work after any of them. A branch canonical main does not reach is open; a tip main reaches is merged. Both come from the object graph, so the screen cannot show a pull request that was merged last week or miss one pushed a minute ago. Ahead and behind are counted by intersecting the two commit walks: neither server holds the other's objects, but a commit id is the same id everywhere. A force-push gives the same work a new sha and the graph keeps no record of the one before it. Two things remember: the runner's push log, which saw every tip the branch carried, and the checks and reviews written against a tip. The union is what says a commit was replaced rather than lost, so a comment on a rewritten commit reads as answering one and a check against it reads as stale. The pushes read on the conversation, each one a rule across the story with what came before it above and what answers it below. A commit that was a tip when the runner saw it carries what the run said, so the commit that broke the build is the one with the mark against it. One that was never a tip carries nothing: the runner checks a push, and a commit arriving under a later one is checked only as part of what it arrived with. There is no numbering: a forge does not number them either, and a reader following an argument wants to know a push happened, not which ordinal it was. Reviews come from the reviewer's own repo, in `dev.pdsjs.git.review` records naming the account, the ref and the commit read. Nothing here gates a push: a verdict is a signed statement and the config's reviewer list is the repository's answer to whose statements it shows. A review agent is a DID in that list like any other account. A pull request may carry its author's own words beside the derived facts: a title, a `draft` or `withdrawn` badge and a note, from a `dev.pdsjs.git.pull` record in the author's repo. The note is markdown, as is a review, an issue and every word under one, so a description carries a code block and a table the way a readme does. It goes through the renderer a readme goes through, for the reason a readme needs it: the words come from an account this site does not control, so raw HTML in them is printed rather than parsed and a link may only name a scheme this page will follow. A relative address is left as its words, because prose about the work names no tree for a path to be relative to. The graph says whether the branch is open; only the author can say whether they want it read. The badge decorates and never hides. The form that writes the record is on the page, for the account holding the branch and nobody else. Work from accounts the repository has not named appears in the same list, marked as coming from a fork. Nothing finds those records on its own, so the repository's runner publishes the index it saw and this reads it, the same way it reads the runner's checks. The indexes of every runner the config names are unioned, so a single runner quietly omitting one does not bury it. Adding the account to `collaborators` drops the fork mark. A shared path is not a claim that two branches conflict textually, which would need a three-way merge per pair; it is the cheap half of the question, and the half that says where to look, at the moment a reader is deciding. The same pass reports the files main has changed since a branch was taken. A commit on a collaborator's branch is in their bundles on their server and nowhere else, so it opens at `//commit//`, read from that account directly. One reached from a pull request opens under it instead, at `//pull///~/commit/`, so the section a reader is in is the section they came from, and the way back names the pull request. A pull request opens whole rather than at its tip, addressed `..`: eleven commits of work is one thing to read, and reading it a commit at a time shows a file once per commit that touched it. The account that owns the repository gets Merge and Squash on the page. A merge keeps the branch in the graph, as a second parent; a squash writes one commit and names the branch in `Merged-from` and `Merged-ref` trailers, which is the decentralised form of a forge's `(#123)`. Both need the branch to be up to date with main, so a branch main has moved under is told to rebase rather than offered a button that cannot work. Merging runs on the account's own server, at `/account/api/git/merge`, under the session cookie the account page answers to. The page is served from that account's site, so the endpoint is same-origin. It is the one act here that needs authority over the repository, and an atproto `repo:` scope names a whole collection: a page holding one could rewrite every repository the account owns. A merged pull request opens from the merge that brought it in. The merge commit's second parent is the tip that was merged and its first parents are main's own trunk, so the branch's earliest commit outside that trunk is where the work began. A branch fast-forwarded into main leaves no such commit, and it opens at its tip alone. A diff opened off the list carries a breadcrumb back to the tab it came from. Its commits are its own, at `//commits//..`: the commits the tip reaches that the base does not. That is a different question from the branch's history, which reaches back through everything the branch was cut from. A pull request written by an agent touches tens of files, so a file list on a card is a count and the directories the files are in, opening to the paths. `just dev-git-scale-demo` is that case: pds.js itself, with pull requests of 41 and 24 files overlapping on 24. `just dev-git-collab` runs the whole thing locally: four accounts on four servers, one repository, and the records to read. ## Reviewing from the page The header holds the visitor's session: who they are and the way out, or a button that opens a dialog asking for a handle or DID. Signing in is the one thing on the page that leaves it, so the dialog has room to say where the visitor is going, what their words are written to, and what failed when something does. The header had room for none of that: a form there widened the bar as it opened and left an error to be read from a tooltip. The app is an atproto OAuth client with no backend, PKCE for the code and DPoP for the token, the same client shape `@pdsjs/spaces-ui` uses. The scope names the collections the page writes and the action it writes each with: the reviews, pull requests and issues a participant publishes, and the repository and config records the owner's own creates, merges and settings touch. A pull request page carries the review composer above its diffs, so the verdict is given where the work is summarised rather than after every hunk. A diff line takes a comment of its own: the anchor names the path, the line, the side, and the line's own text, in the tree the review's sha names. On that that commit placement is exact whatever happens to the branch; a comment made against a commit the branch has replaced is counted rather than misplaced, and marked as answering one. A comment answers to a reply: a review record naming the review it answers by its exact version, still naming the pull request and carrying its own anchor, so a reader that does not know `replyTo` shows the reply beside what it answers rather than losing it. A line's comments read as a conversation, oldest first, each reply under its parent, one level deep. The statements shown come from the config's reviewers and collaborators together, because a conversation needs the author's side. The review is written to the reviewer's own repo on their own PDS. This repository is never written to, and publishing carries no promise of being displayed: the config's reviewers list is the repository's answer to whose statements its readers see. Sign-in needs the app's OAuth registration served beside the page, which the deploy writes when `GIT_ORIGIN` names the site's https origin. On localhost the client states its registration in the client_id itself, so `npm run dev` signs in with no registration at all. ## Issues `//issues` lists every thread, newest activity first; a thread is a page at `//issues//`, addressed by its opener. Filing one is a page too, at `//issues/new`, with a tab that reads the words back the way the thread will. An issue is somebody's account of what went wrong and it is markdown wherever it is shown, so it is worth the room to write and the tab to check; a review takes the same field. Filing and replying write `dev.pdsjs.git.issue` records to the visitor's own repo, the same session the reviews use. The state shown is the owner's latest disposition and nobody else's; another account's "marked resolved" renders as the comment it is. Listed voices read directly; everybody else arrives through the runners' issue index, each record fetched from the server that holds it. ## Packages A repository shows what it publishes to the same account's registries: the npm packages and container images its `dev.pdsjs.git.config` record names in its `packages` list. Each one appears with the command that installs it from this account, and with every version or tag it holds. A reader installs from the host that serves this page. The link is that list and nothing else. A `dev.pdsjs.npm.package` record carries no repository name and atproto has no reverse index, so the owner sets the list in the repository's settings, which offers the packages the account has already published. ## Settings `//settings` is the owner's, and the tab appears for their sign-in alone. Four groups, each saving on its own, because `dev.pdsjs.git.updateSettings` takes one setting per request and leaves the rest as it found them: the description and the default branch, the protected branches, the check runner, and the packages the repository builds. Below them the repository is renamed and deleted. Where a setting is written decides what a push can do to it. The description and the default branch are fields of the `dev.pdsjs.git.repo` record, which every push rewrites, so the server reads that record and patches the two rather than replacing it. The rest are in the `dev.pdsjs.git.config` record beside it, which no push touches, so a protected branch outlives every push it refuses. The runner field takes a handle or a DID, and resolves a handle to the DID the record holds. A private repository is configured the same way: the write names the space, and the server's space admin makes it. Renaming writes one field. The record key is the repository's identity, so its issues, pull requests and checks stay with it; the address is what moves, and the panel says the clone remotes have to be re-pointed. A private repository keeps its name, because the space holding it is what an address resolves to and a space is keyed by that name. Deleting is the last panel, in its own colour, and asks for the repository's name to be typed before the button works. What goes is stated rather than implied: the record, its settings and every bundle blob. A repository holding a protected branch refuses, and the server says which branch. The write runs as the owner, on the owner's own PDS, under the owner's grant. The server accepts it only from a token whose account is the site's, so nobody else's session configures a repository here. ## Running it against a local PDS `npm run dev` serves the app on a port of its own, so two routes the account's site would answer come from the PDS through the dev server's proxy: the DID the hostname publishes, and the blob route the pack reader opens. `PDS_URL` names that server, default `http://localhost:2471`. A did:plc resolves against `https://plc.directory`. A local stack runs a PLC of its own, holding DIDs the public directory has never seen, so point the build at it: ```sh PDS_URL=http://localhost:2471 VITE_PLC_URL=http://localhost:2582 \ npm run dev --workspace @pdsjs/git-ui ``` Both are read at build time. A deploy that sets neither resolves against the public directory and reads the account whose site serves it. ## Deploying ```sh PDS_URL=https://pds.example.com \ PDS_DID=did:plc:… \ PDS_APP_PASSWORD=… \ npm run deploy --workspace @pdsjs/git-ui ``` The site name comes from `GIT_SITE`, default `git`, so it answers at `git.`. The deploy also writes the query def it reads and publishes a `dev.pdsjs.app.manifest`, which makes it installable by anyone else running pds.js. The build is one self-contained document. The site record names a file by path and the page HTML caches for minutes, so a build split across hashed assets would leave a visitor holding old HTML asking for a script the record no longer names, and the SPA fallback would answer with HTML where the page expects JavaScript. A single document cannot fall out of step with itself. Fonts stay on the system stacks for the same reason: every asset is inlined, and a webfont would multiply the document's size. ## Raw files The page is rendered in the browser, so a reader that is not a browser gets the shell and no content. Raw bytes come from the PDS instead: ``` GET https:///git///raw// ``` A directory lists its entries one per line, so the same route walks a tree. The file view links to it, but only where the deploy found the route answering: it arrived after the smart HTTP endpoint, so a PDS can serve one and not the other. ## The clone URLs `atproto://` always works wherever the helper is installed. The `https://` form needs the read-only smart HTTP endpoint (`PDS_EXPERIMENTAL_GIT_HTTP`). The deploy probes for it and bakes the answer into the page, because that endpoint answers before the CORS wrapper and the page cannot ask on its own. ## Limits - Public repositories only. A repository in a permissioned space needs storage access the page does not have. - A repository whose record predates the bundle index downloads its whole chain to open. Any push with a current helper writes the index.