~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
265 lines · 12.8 KB Raw History

Pull requests: a branch, named by who pushed it

Status: design, 2026-08-24. Successor to docs/plans/2026-08-21-git-collaboration.md, which built the collaboration prototype and named the Change-Id as the thing every other artifact hangs on. This replaces that decision.

What the prototype got wrong

The prototype borrowed Gerrit's Change-Id and built GitHub's branch model on top of it, then derived the change's identity from the tip commit's trailer. That combination fails in four ways, and three of them are visible.

The identity moves when a commit is added. git-remote-atproto install-hook gives every commit its own id: the hook hashes HEAD, the message, the pid and the clock. Amend the tip and the id survives. Push one more commit on top, which is how a branch is revised, and the tip's id is new. The change's id changes with it, the old address renders "No branch carries this change", and every review and check keyed to the old id detaches.

The hook and the reader disagree about what a Change-Id means. changeRun walks back from the tip while consecutive commits carry one id, so it wants one id per branch. The hook writes one id per commit. Against a real history that loop stops at the first step, and a merged pull request of eleven commits opens as its tip commit alone. It reads correctly only against scripts/dev-git-scale-demo.mjs, which seeds one id across a whole branch. No client produces that. jj does not either: jj's change ids are per commit.

A comment on a single commit page lands nowhere. The composer takes its id from the commit in view. On the third commit of a branch that is the third commit's id, not the pull request's, so the comment never joins the conversation.

Versions are a fossil record. versionsOf reconstructs earlier versions from whatever shas the checks and reviews still name. A version nobody reviewed and no runner checked never existed. There is no interdiff, and no event on the timeline for the one thing a reviewer waits for, which is the author pushing again.

What identifies a pull request

(author DID, ref). The DID of the account whose copy holds the work, and the full ref name of the branch holding it.

It survives amend, rebase, appending a commit and force-push, which is every case the trailer was bought for and one it fails. It costs nothing to compute, it needs no hook on a contributor's machine, and a push is still the whole proposal.

dev.pdsjs.git.offer already keys on it. The record is in the proposing account's own repo, so the DID is the record's owner, and it requires repo and ref. The record needs no new field to carry the identity. It only needs to drop changeId.

Two cases the pair does not cover, and what covers them:

  • A branch reused for other work. Delete agent/fix, push different work under the name, and the first branch's history would attach to the second. Ancestry cannot answer this: a rebase leaves a tip the last one cannot reach and so does a name reused, and the graph shows the same thing for both. The deletion is what separates them, and the runner sees it. The push log marks the ref ended, keeps the tips it carried so a merged branch tidied away is still readable, and starts a fresh list on the next push under the name.

    What this does not fix is the reviews. They name the account and the ref, so a review of the first branch still reads as a review of the second. Closing that needs a review to name something that separates one branch of a name from the next, which no record carries today.

  • A merged pull request whose branch is gone. The graph answers it where a merge commit carried the work in. Where a squash did, the merger writes the answer into the commit. See "Merging".

Versions come from the push log

The daemon is on a firehose already and every ref that moves passes through it, so it writes each tip down: a dev.pdsjs.git.versions record in its own repo, under versionsKey(did, repoName), holding one entry per ref and the tips that ref has carried, newest first.

One record per copy, not per repository. A push says nothing about which copy is canonical; the account that made it is the only thing the event carries. The reader asks for one per collaborator, all to the runner's own server.

The log is written before the run that clones and executes, so a reader asking what the author pushed does not wait on CI. A replayed event finds its tip already at the head and writes nothing.

It answers three questions that have no answer in the graph:

  • the versions of a pull request, in the order they were pushed;
  • what changed between any two of them;
  • the timeline event "bob pushed version 2", which is the event a reviewer waits for and the one thing the graph cannot date.

A reader that cannot reach the runner unions nothing and falls back on the tips the checks and the reviews name, which is a shorter history rather than a wrong one.

An entry carries the tip and when it was seen, and no base. What that costs is the finer comparison: two versions are compared by diffing their trees, so a rebase across a moving main carries main's own changes into the answer. The screen says so rather than letting a reader take all of it for the author's work. Diffing the two patches instead needs each version's own base.

Merging

Three modes, and each leaves a different amount of the link in main's history.

modewhat main carrieswhat it costs
merge committhe merge's second parent is the branch tipnothing
squashone commit, no edgethe merger writes a trailer
rebasecommits replayed, no edgea trailer on each

Build merge commit first, squash second, and leave rebase out. Rebase is the rarest of the three, it needs a trailer on every commit it moves, and GitHub loses the same link in the same mode.

The trailer for a squash is written by the merger, not by the author. This is the difference that matters. Gerrit's hook runs on every contributor's machine at commit time and needs discipline from everyone who pushes. A merge trailer is written once, by the code performing the merge, at the moment the graph link would otherwise be lost. It names the identity above:

Merged-from: at://did:plc:xxx/dev.pdsjs.git.repo/pds.js
Merged-ref: refs/heads/agent/rename-ports

A merge commit carries them too. The graph already says where the work came from, and the trailers say it in the vocabulary a reader of the records speaks: an account and a ref rather than a commit id.

Both modes require the branch's merge base to be main's own tip. Then the result tree is the branch's own, there is no three-way content merge to do, and nothing about the merge can be wrong. A branch main has moved under is refused and rebasing it is the author's move.

That is the decentralised form of GitHub's (#123). GitHub recovers a squashed pull request from a number that a central authority minted. No such authority exists here, so the commit carries the answer itself.

The Change-Id, and what replaces the hook

packages/git/src/hook.js and the install-hook verb go. Contributor-side discipline was the weak part of the design and the merge trailer needs none of it.

jj writes a change-id header into the git commit object, where a reader sees it without a hook and without it appearing in the message. Tangled reads that header to trace a commit across rewrites. Reading it here too is the one piece of Change-Id worth keeping, as an optional enrichment on a version and never as the identity. It is unbuilt, and it stays a nice-to-have as long as the identity does not depend on it.

Vocabulary

The lexicons are unpublished and nothing is deployed, so the words are free to fix now and a migration later. Rename where the concept matches what a reader already knows. Keep the word where the concept differs, because a familiar word on a different concept predicts a button that is not there.

nowto
changepull request
landedmerged
live, in flightopen
/<repo>/work/<repo>/pulls
contributorscollaborators
dev.pdsjs.git.offerdev.pdsjs.git.pull
verdict rejectchangesRequested

reject renders as "asked for changes" in verdicts.js today, so the wire and the screen name one thing twice. The rename settles them on the screen's word, which is also GitHub's.

The offered list folds into the open list. A pull request from an account the repository has not named is marked as being from a fork and sorts with the rest, the way a forge shows one.

Two words stay:

  • version, not Tangled's "round" and not Gerrit's "patch set". It is the word versionsOf already uses and it needs no glossary. GitHub has the feature and no noun for it, so there is nothing to copy.
  • withdrawn, not "closed". On a forge, closed usually means the maintainer closed it. No maintainer here can, because no account is the merge authority for another. Withdrawn is the author's word about the author's own work, which is the part that is true.

ahead, behind, base and tip stay. They are git's words and a reader arrives with them.

What Tangled does

Worth recording, because it is the only other forge built under these constraints and it did not take GitHub's model.

  • The pull request is a record, sh.tangled.repo.pull, in the proposing account's repo, updated as the patch changes.
  • Review is round based. A submission is immutable, the first is round 0, and resubmitting advances the round. An interdiff button compares two rounds. That is Gerrit's patch sets under another name, and it is the piece worth taking.
  • Change ids come from jj, as a commit header, and map a jj stack to one pull request per commit.
  • Merge is fast forward, as git am applies a series. There is no squash, so the question this document answers under "Merging" does not arise for them.

Their knot holds the git data and their appview indexes it. Neither exists here, which is why the fan-in and the daemon's derived records do that work. The interop question stands where the previous document left it, a decision for before these lexicons publish.

Order to build in

  1. Identity to (author DID, ref), the lexicon edits, the deletions and the vocabulary pass. Done.
  2. The push log over every push. Done. dev.pdsjs.git.versions, one record per watched copy under versionsKey, written by the runner before the run that clones and executes. The reader unions it with the tips the checks and reviews name, so an unreachable runner costs history rather than accuracy.
  3. The version picker, the comparison, and the push event on the timeline. Done. The comparison is a range read of <older>..<tip>, which is a diff of two trees; the bar says so, because a rebase across a moving main carries main's own changes into it. A finer comparison would diff the two patches rather than the two trees, and needs each version's own base.
  4. A record from a named collaborator, so a pull request carries a title, a body and a draft state of its own. Done.
  5. Merging: the merge commit, then the squash with the merger's trailer. Done. POST /account/api/git/merge, both modes, read back by git itself.

Where merging runs, and why

Not in the page. An atproto repo: scope names a collection and its actions and cannot name one record, so a page asking to write dev.pdsjs.git.repo would hold create, update and delete over every repository the account owns, in a token in IndexedDB. Merging is the authority's own act, so it runs on the authority's own server under the session cookie the account page already answers to. The repository browser reaches it because the browser is served from that account's site.

createGitMerger is the seam. The pack format belongs to @pdsjs/git and the authority over the record belongs to the PDS, so the platform builds the merger out of storage it owns and injects it, the way it already builds the write guard. Core carries no dependency on the git package.

The write goes through the ordinary commit path, so the guard sees a merge and a protected branch still refuses anything but a fast-forward. The new bundle carries a pack index, so the chain stays readable through ranged reads.

What a branch has to be to merge. Its merge base must be main's own tip. Then the result tree is the branch's own, and there is nothing about the merge to get wrong. A branch main has moved under is told to rebase, rather than offered a button that cannot work. A three-way content merge would lift that and is a build of its own.

A merge carries the tree of every commit it makes reachable, not the tip's alone. Git walks every commit the merge reaches and opens each one's tree, so a tree left out fails the whole fetch. Driving the endpoint against a real stack is what found this; the unit tests had passed because their branches were one commit deep.

Steps 1 and 2 carry the design. Everything after is what a reader sees for it.