chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
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.
| mode | what main carries | what it costs |
|---|---|---|
| merge commit | the merge's second parent is the branch tip | nothing |
| squash | one commit, no edge | the merger writes a trailer |
| rebase | commits replayed, no edge | a 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.
| now | to |
|---|---|
| change | pull request |
| landed | merged |
| live, in flight | open |
/<repo>/work | /<repo>/pulls |
| contributors | collaborators |
dev.pdsjs.git.offer | dev.pdsjs.git.pull |
verdict reject | changesRequested |
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
versionsOfalready 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 amapplies 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
Identity toDone.(author DID, ref), the lexicon edits, the deletions and the vocabulary pass.The push log over every push.Done.dev.pdsjs.git.versions, one record per watched copy underversionsKey, 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.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.A record from a named collaborator, so a pull request carries a title, a body and a draft state of its own.Done.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.