chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
Git collaboration without a pull request
Status: prototype, 2026-08-21. Runs locally with just dev-git-collab.
Nothing here is deployed and no lexicon is published.
The question
A forge's pull request is one mutable row that several people write: the
branch, the review state, the merge button. A repository in an atproto account
has no such row. The space model gives every member their own copy on their own
server, the authority's copy is canonical, and no PDS is the merge authority
for the others. packages/git/README.md says as much: rules that need a
central merge authority, such as required reviews, have no enforcement point
and are deliberately absent.
Agents make the same point from the other side. A change is force-pushed tens of times before anyone looks at it, so an artifact keyed to a commit id is lost on every rewrite, and a state field somebody maintains by hand is stale before it is read.
So the model here is: a push is the proposal, and every other artifact is a signed statement by a DID about an immutable subject. The reader aggregates.
What the prototype builds
contributorsOfandreviewersOfinpackages/git/src/rules.js. Two DID lists in thedev.pdsjs.git.configrecord beside the repository. atproto has no reverse index, so this list is the only thing connecting the canonical repository to the copies on other servers.dev.pdsjs.git.reviewinpackages/git/src/lexicon.js. A verdict, a note, a strongref to the repository record version, a sha, and a Change-Id. It is written into the reviewer's own repo, so a review needs no write access to the repository it is about.- An optional
changeIdfield on the check record, and theChange-Id:trailer read from a commit message. Gerrit's spelling, which jj already writes. The runner reads it from the commit it checks out. packages/git-ui/src/lib/collab.js, which fans in: contributor DIDs from the config, their PDS from the DID document, and their records and bundle blobs from that server directly. Every read is anonymous and cross-origin.- A work screen at
/<repo>/work, and a commit page that reads a contributor's copy at/<repo>/commit/<did>/<sha>. scripts/dev-git-collab-demo.mjs: four accounts on four servers, one repository, a landed change, a rebased change, a conflicting pair, a change behind main, and reviews from a human and from an agent.
What it settles
Fan-in works, with no server support. A page served from alice's site
renders the diff of a commit that exists only in bob's bundles on bob's server.
The reader needed one thing the app did not have: an XRPC handler that honours
the did parameter rather than answering from this account's own records.
Live and landed need no stored state. A tip canonical main reaches has landed; one it does not reach is in flight, ahead by the commits main lacks and behind by the commits it lacks. Both walks come from different servers and neither holds the other's objects, but a commit id is the same id everywhere, so the intersection of the two walks is the merge base. There is no open state to close and no closed state to reopen.
A landed change needs the Change-Id to be readable at all. Once main reaches a branch there is no divergence left to measure: the change's commits are main's commits, and nothing in the graph says where the work started. The trailer does, so the run of commits at the tip carrying one Change-Id is the change, and a landed change opens as a diff like any other. Without it the only honest answer is the tip commit alone.
The Change-Id earns its place at once. The seed rebases one change and
checks each version. Keyed by sha, the screen shows a green check and forgets
that the first version failed review. Keyed by Change-Id, it shows v2, a
green check, an approval, and the earlier rejection against v1. That is the
single cheapest thing to adopt and everything else composes on it.
An agent reviewer is a DID like any other. Nova publishes reviews and pushes no code. Adding it is one entry in the config's reviewer list, and its verdicts render beside a human's with the source visible.
File overlap is a useful signal at zero cost. Two changes touching one file is not a textual conflict, and the screen says "touches the same files" rather than claiming one. It is computed from the commit lists already read, and it tells you where to look. "Main has changed README.md since this branched" comes from the same pass and is the honest form of "needs rebase".
What it does not settle
- Landing. The screen has no merge button. The authority's
mainis the only canonical branch and only the authority writes it. A browser-side land is plausible (pack.js,bundle.jsandobjects.jsalready run in the page, and a push is a bundle plus aswapRecord) but it is unbuilt. - The integration branch. The screen counts how many changes touch the same
files as another, which is a fact about now. What could land together is the
next question and it needs a build to answer: a greedy set over a
newest-first list picks between two changes by push order and presents the
coin flip as advice. Building it for real belongs in
@pdsjs/git-ci, which already reads the firehose and sees every contributor's push: rebuild the merge on any push and check the combination, so a cross-agent conflict is found before anything lands. - Intent from the page. A review publishes from the browser now; the author's own draft or withdrawal still comes from the seed. Same record, same session machinery, one more form.
- Private repositories.
git-uiserves public records only, so a space repository's work is out of reach from this page. - A stack's title. The card shows the tip commit's subject, which for a twelve-commit change written by an agent is whatever the last commit said. The branch name is the more stable label and it is the second line.
- A change page that stands on its own. A change has a diff and a commit list. The other two things a forge puts beside them, the checks and the conversation, exist as records already and have no page here.
What it costs at the size of this repository
scripts/dev-git-scale-demo.mjs pushes pds.js itself into the same four
accounts: 566 commits, 722 files. Two changes of the size an agent writes (41
files over 11 commits, and 24 files over 4), overlapping on 24 files, with main
three commits ahead of both.
- A copy is 4.8 MB in 5 blobs. Three copies of the repository, pushed from three accounts, take about a second each against a local PDS.
- The work screen renders in well under a second over a blank page, reading three chains from three servers. Localhost with warm caches, so it is a floor rather than a figure to quote.
- A change's file list is one tree walk from its merge base to its tip, and what main has moved under it is a second walk. Both skip any subtree whose object id matches, so the cost follows the change rather than the repository. Reading each commit instead, which is what the first cut did, fetches every blob on both sides of every diff to build hunks the screen never shows.
The presentation is what breaks first, and it broke here: 41 paths printed in a row buried the check, the review and the conflict under them. A file list is a count and where the files are, opening to the list.
The other thing size broke is what a change opens as. The card led to the tip
commit, which for eleven commits of work is the last four files somebody
touched. A change opens as base..tip instead, one diff over the whole thing,
which for these 41 files is 180 ms and 18 requests against a contributor's
server.
Offers: how a stranger reaches a repository
A contributor is a DID the repository typed into its config. That covers a team and covers nothing else, so there are two tiers rather than one gate, the way a forge already has two: a collaborator pushes a branch, an outsider proposes from a fork.
dev.pdsjs.git.offeris in the offering account's own repo. It names the repository, the branch in their own copy that holds the work, and why. The repository being offered to is untouched: no write to accept, nothing to delete, and moderation is a read-side filter rather than a queue to clean.- Nothing can find that record, because atproto has no reverse index. The
runner is what closes the loop: it already watches a firehose and already
publishes to its own repo, so it publishes
dev.pdsjs.git.offers, one record per repository, holding what it saw. A reader trusts it exactly as far as it trusts the runner, which is the bar the checks are already read at. - The work screen shows offers in their own section, read from the offering
account's own copy, marked as accepted by nobody. Promotion is one edit: add
the DID to
contributorsand the work moves into the list above.
Indexing every offer on the network costs a socket. Jetstream filters by
collection server-side, so a consumer asking for dev.pdsjs.git.offer alone
receives nothing else. Measured against jetstream1.us-east.bsky.network:
| subscription | rate |
|---|---|
| unfiltered | 4012 events, 219 KB/s |
wantedCollections=dev.pdsjs.git.offer | 10 events, 0.1 KB/s |
The 10 are the account and identity events every subscriber receives whatever
it asked for. wantedCollections takes up to 100 NSIDs or prefixes and can be
changed on a live socket, so one connection covers the whole scheme and the
runner filters to the repositories it watches in memory. There is no appview
here, and none needed: the index is per repository, kept by an account the
repository already names.
What this leaves open is the spam question, and the honest answer is that it is the same question a forge answers with account bans and rate limits, which nobody here can do. The filter has to be a policy somebody picks: an allowlist, the follow graph, a moderation list, an account-age floor, a check that passed. The default in the screen today is a forge's default, which is to show them. The runner's compute is the other exposure, and the same one a forge gates by withholding CI from a first-time contributor.
Deliberately absent
- No verdict gates a push.
@pdsjs/gitenforces branch protection on the server that holds the branch, and that is the whole enforceable set. A reviewer's approval is worth what the reader thinks the reviewer is worth. - Line comments and replies exist. A review may carry an anchor (path, line, side, and the line's own text) in the tree its sha names, so placement on the reviewed version is exact and a rebase demotes the comment to "on v1" rather than misplacing it. A reply names the review it answers by strongref and still names the change, so a reader without the field degrades to a flat stack instead of losing words. The snippet is there for a later, smarter placement to search for.
- No "request changes" as a state. A reviewer holding their own copy can push a fix branch instead, and it costs them nothing.
Design review, end of day one
The prototype was reviewed against alternatives (a PR-record model, a global appview, patch-based changes, and Tangled's knot-based forge). The direction holds: every alternative trades away either the zero-infrastructure storage model or the derivability of state. Four weaknesses came out of the review; three are addressed in code, one is designed here and built with the daemon.
One runner could omit. A runner cannot forge an offer, because a reader
follows its index to the records it names and then to the branches themselves.
It could quietly drop one. The config now takes a runners list
(runnersOf), the reader unions every named runner's offer index, and an
offer is found if any one of them indexed it. Checks still read from the
first.
Nothing carried intent. The graph says a change is in flight; it cannot
say "draft" or "withdrawn", and deleting the branch was the only withdrawal.
The offer record now carries an optional status (draft, withdrawn), and a
named contributor writes the same record about their own branch as pure
intent. The reader decorates; it never hides. Derivation still owns the facts,
the author owns the words.
Change-Id relied on our own tooling. jj writes change ids natively and the
runner can trail its own commits, but a stock-git commit got an id only from
discipline. git-remote-atproto install-hook now installs a commit-msg hook
(Gerrit's approach): a trailer on every commit, preserved through amend,
rebase and cherry-pick, covered by tests against real git.
Read amplification, deferred to the daemon. Every visitor pays one DID
resolution and one repo read per contributor, plus commit walks. Measured fine
at this scale; it dies somewhere past a dozen contributors with cold caches.
The fix is the pattern this design already uses three times: the runner
publishes a derived record. A dev.pdsjs.git.work record per repository,
holding the Work shape the screen computes today (changes, placement, files,
overlap), written by the daemon on every push it sees, read the way
latestCheck is read, with the fan-in kept as fallback and as spot-check.
Design it with the offer indexer; they are one daemon session.
What would falsify the design.
- Repositories routinely exceeding a dozen active contributors, or mostly anonymous cold-cache readers, before the work index exists. The fan-in dies first; the model survives via the runner index.
- The substrate losing: if the people this is for are already on Tangled,
the collaboration layer has no repositories to collaborate on. The knot
model is the opposite bet (code on separate git servers, not in the PDS);
interop with
sh.tangled.*collaboration records is worth a decision before this design's lexicons publish, not after. - The canonical-main question resolving toward unattended landing. A runner that advances refs needs a delegated write grant, which changes the trust analysis above and promotes the runner from witness to actor.
The daemon closed the loop
The build-order step 1 landed the same day. @pdsjs/git-ci now:
- reads the
Change-Id:trailer of the commit it checks out and writes it on the check record, so a rebase keeps its runs with no seeding anywhere; - indexes offers from the wire: a Jetstream consumer (
jetstream.js) for the network, the native firehose for local stacks,PDSJS_CI_OFFER_SOURCESnaming the sources, and a read-modify-write of the offers record with the same swap-retry shapelatestCheckuses.
just dev-git-collab runs the whole flow with nothing faked: nova's daemon
watches the three copies, clones every push over smart HTTP, runs the
repository's own .pdsjs/workflows/ci.yml, and publishes what happened. Bob's
farewell v1 fails its check because the test really fails ("farewell is
wrong", exit 1, logs in the blob); the rebase to v2 passes; both runs carry
the Change-Id read from the commits. Dan's offer appears on the offered tab
because the daemon saw the record on his firehose and indexed it. The only
seeded artifacts left are the reviews and carol's draft intent, which are the
two records whose writing surface (a signed-in page) is build-order step 2.
Step 2 landed the same evening for reviews. The browser is an atproto OAuth
client of the visitor's own PDS (PKCE, DPoP, PAR; the client spaces-ui
established), and the change page carries a composer. The proof drove real
Chrome end to end: sign in as carol from the change page, consent on her PDS,
publish a comment, and the record appears in her repo and on the work screen.
Intent (draft/withdraw) from the page is the remaining form.
Issues
Issues landed as the same primitive as everything else: a
dev.pdsjs.git.issue record in the filer's own repo, replies naming their
parent by strongref, and the state as the owner's published disposition
rather than a mutable field. Closed is a statement attributed, and the
filer's disagreement sits beside it; the page renders a non-owner's "marked
resolved" as the comment it is. Discovery reuses the offer machinery whole:
the daemon indexes issue records off the firehose and Jetstream into a
per-repository dev.pdsjs.git.issues record, replies included, and the page
fetches each indexed record from the server that holds it, so the index can
point at nothing but cannot forge a word.
Tangled's sh.tangled.repo.issue ecosystem was deliberately not adopted;
the interop question stands where it stood, a decision for before these
lexicons publish.
Order to build in
The offer indexer, andDone; see above. The work index (the read-amplification fix) still rides the same write path and is unbuilt.Change-Idwritten on every run.- Review writing from a signed-in surface, which is also how an offer gets written by hand rather than by a seed.
- The integration branch in
git-ci, and the conflict matrix on real merges rather than on path overlap. - Landing from the page.