~ 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.

| 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 `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.