chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
# @pdsjs/git
## 0.1.5
### Patch Changes
- @pdsjs/spaces@2.0.10
## 0.1.4
### Patch Changes
- @pdsjs/spaces@2.0.9
## 0.1.3
### Patch Changes
- d66fcff: The default blob upload limit is 50 MiB. An upload streams to storage, so
the limit bounds cost rather than memory, and 50 MiB stays under the 100 MB
request body a Cloudflare Worker accepts on every plan. `PDS_BLOB_UPLOAD_LIMIT`
still overrides it.
git-remote-atproto uploads a bundle in 8 MiB chunks. A server that refuses
a chunk as too large gets the push again at half the size, down to 64 KiB,
so a reference PDS at 5 MB still takes it. Existing bundles keep their parts
until a repack rewrites them.
- c68858b: The smart HTTP ref advertisement carries `Access-Control-Allow-Origin: *`, the
same as the raw file paths beside it. Sites are served on a different origin
from the PDS, so a page hosted by the account could not ask whether its own
server clones over HTTP. The repository browser asks at load and offers https
clone URLs on the answer, rather than carrying whatever was true of the server
it was published from.
- 04b15d0: The git appview primitives: a forge no longer has to live inside the PDS.
`createGitGuardExtension` mounts the branch-protection write guard alone.
A PDS whose git hosting runs elsewhere mounts it and keeps the enforceable
half: protected branches refuse force pushes and deletion at the record
write, wherever the reads are served from.
`dpop`, `oauth` and `identity` are package exports now. `dpopPost` and
`discoverAuthServer` take an injected fetch, so a service that runs the
authorization-code flow over its own carrier can reuse the nonce dance.
`CONSTELLATION_URL`, `createConstellationDiscovery` and `noDiscovery` are
exported from the package root.
The embedded extension is unchanged. `createGitExtension` grew no new
option; the deduplication it gained is the same factory it was.
- 288063a: A commit credits everyone who wrote it. `coAuthorsOf` reads the
`Co-authored-by` trailers a message ends with, returning each identity once
in the order the message first gives it. Only the last paragraph counts,
which is where git itself looks, so a trailer quoted in a revert credits
nobody.
A claimed identity may also carry a `label` and an `avatar`, for an author
with no atproto account to name it: a tool, a bot, or a person who keeps
none. `parseIdentityRecord` carries both, and a `did` still wins where one is
given, since that account's own profile is the truer answer.
- c3c4caa: A repository's config record carries two DID lists, `collaborators` and
`reviewers`, read by `collaboratorsOf` and `reviewersOf`. A collaborator holds
their own copy of the repository under the same name, and a reader that knows
where to look can gather the open branches across every copy. atproto has no
reverse index, so a copy names no upstream and an upstream finds no copy
without being told. Neither list grants anything: nobody can write into
somebody else's repo whatever they say.
A pull request is a branch in one account's copy, identified by 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 version still names
the work after any of them. A branch canonical main reaches is merged and one
it does not is open, both read from the graph rather than from a stored state.
The `dev.pdsjs.git.pull` lexicon describes what the author says about their own
branch: a title, an optional `status` of draft or withdrawn, and a note. It is
in the author's own repo and nothing is written to the repository it names. A
push with no such record is still a pull request, titled by its tip commit.
`dev.pdsjs.git.pulls` is the index a runner publishes of the records it has
seen, under `pullsKey`, because atproto has no reverse index and the owner has
no inbound address. The config may name several runners (`runnersOf`), and a
reader unions their indexes, so one runner omitting a pull request does not
bury it.
The `dev.pdsjs.git.review` lexicon describes a verdict on one version of one
pull request, published in the reviewer's own repo. `author` and `ref` name the
pull request and `sha` names the version read, so a review of an earlier
version still reads against the work and still says which version it answered.
The verdicts are `approve`, `changesRequested` and `comment`.
The browser reader gains `changedPaths`, `readRange` and `listRange`, which
answer for two commits what `readCommit` and `listCommits` answer for one and
for a ref. A pull request written over a dozen commits is one thing to read,
and the paths it touches are one tree walk from its merge base rather than a
diff per commit.
The repository browser reads all of it on a pull request list. The model, and
what is still unbuilt, is in `docs/plans/2026-08-24-pull-requests.md`.
- bcb5758: `creditAuthors` counts a repository's contributors from its commits.
One person holds one row, keyed on the address they control. The name beside
that address is whatever their git client wrote that day, so a run of commits
under `Claude Opus 5 <noreply@anthropic.com>` and `Claude Fable 5
<noreply@anthropic.com>` counts as one contributor. An ident with no address
has only its name to go on. A commit whose message credits its own author
credits them once.
The git interface's Contributors row reads the tally from this, so a
repository lists the people who wrote it rather than the idents they wrote
under.
- e0b38cd: `dev.pdsjs.git.deleteRepo` takes a repository: the record, the config record
beside it, and the bundle blobs neither of them holds any more, freed at once
rather than at the hourly orphan sweep. The write guard reads the config for
its protected branches before either record goes, so a repository holding one
refuses with the branch named, which is the refusal the guard already had
words for.
A private repository keeps its space, which is keyed by the repository name
and is reused by a repository created under that name later. Its member list
goes with the repository, so the next repository of that name admits nobody
its owner did not add.
`SpaceAdminPort` gains `deleteRecord`, over the `delete` action the space
writer already applies.
- 4fb8f23: A commit's changed files carry both sides of their text whole, and the
repository browser colours each side rather than colouring a hunk on its own.
A hunk is a fragment, and a grammar that reads ahead decides by what it can
see. highlight.js takes `<Component prop={x}>` for JSX only when the matching
close tag is in the same text, so a JSX tag opened near a hunk boundary printed
as plain text, its attributes and strings uncoloured. A block comment or a
template literal cut by the same boundary read the same way.
- 1cf113d: A commit that changes an image shows the picture on each side of the change,
where the repository browser printed "Binary file" with its size.
- 04b15d0: A repository nothing has been pushed to no longer errors when its commits are
read. The browser answers an empty repository as holding no commits rather
than refusing the ref.
- c4af198: A commit claims the issue it fixes with a `Fixes:` trailer naming the
issue record's at-uri. The issue page shows the claim as the fixer's word,
linking to the pull request, merged or open, while the state stays the
owner's alone. The link is stated once, by the fixer; the owner's disposition
record carries no counterpart field, so the two can never disagree. GitHub's
closing keywords, in a model where nothing closes by itself.
- f777a2c: The protected-branch check reads the commits out of the chain instead of
downloading it. The guard answers one question, whether the old tip is an
ancestor of the new one, and every object in the repository was materialized
to answer it. On pds.js's own repository that costs 199 MB, which no
Cloudflare isolate has.
The lazy object store moves to `store.js`, where the write guard reads it as
the repository browser does: the bundle indexes, then ranged reads for the
commits alone. Both platform adapters give the guard a `getBlobRange`
wherever the blob store reads ranges. The same check costs 43 MB and 33 ms,
against 199 MB and 428 ms. A chain with no index, or a store with no ranges,
reads whole as before.
- a981627: Every handle in the repository browser shows an avatar on its left: the
profile picture where the appview knows one, and a deterministic pattern
drawn from the DID where it does not, so any two identities read
apart at a glance. Statement loads learn the profiles behind the
voices they read, one appview batch per visit.
- 3febe1c: Smart HTTP clones stream the merged pack instead of building it in memory.
`upload-pack` held every bundle in the chain, the merged pack, and the
framed response at once, so a clone cost about six times the pack size and
a repository over roughly 20 MB exhausted a Cloudflare isolate's 128 MB. The
response now reads one chunk blob at a time and hashes the trailer as the
bytes go out, and a chain of one bundle passes through without a rehash at
all. Cloning a 128 MB repository moves the server's peak from 640 MB to 43
MB; `bench/clone-memory.mjs` is the measurement.
- ac5c8eb: A bundle's object index splits across blobs when it outgrows one. The index
holds 33 bytes for each object, so a repository near 159,000 objects encodes
past the 5 MiB blob limit and the upload fails. The push still completes, and
every reader then downloads the whole bundle chain to show one page.
bluesky-social/atproto is 661 objects short of that line.
A bundle carries the chunks in `indexParts`, the way `parts` carries the
bundle itself. An index that fits one blob stays in `index`, which is where a
reader written for the single blob looks. `indexPartsOf` reads a bundle of
either shape and gives one list.
- 00bd461: Indexing a pack keeps only the objects the caller asked for. `indexPacks`
held every object it materialized, which costs the whole inflated graph:
bluesky-social/atproto holds 1.3 GB of objects across 158,213 entries, and
the walk peaked at 3.0 GB of resident memory.
A `retain` option names what the returned map keeps. The index builder asks
for 'none', and the write guard's ancestry check asks for 'commits', because
it walks commit parents alone. Both modes hold a bounded cache of delta bases
and build an evicted base again from the pack bytes. `cacheBytes` sets that
bound for a runtime with tighter memory. Only 4% of that repository's objects
are ever wanted as a base at one time, so the same walk peaks at 1.4 GB and
runs 15% faster.
`indexPacks` also names objects in batches of 512. One batch for each pack
held a second copy of every object in it.
- 83083a5: Issues, as the model's one primitive: a `dev.pdsjs.git.issue` record in its
filer's own repo names the repository, a record with `replyTo` answers one,
and the repository owner's record with a `disposition` is what a reader shows
as the issue's state. Nobody closes an issue; the owner states resolved,
declined or reopened, attributed, and disagreement sits beside it. The latest
word wins, so each of the three is answered by another one and none of them
shuts a thread. The repository's own server lists the statements, reading
the listed voices from their own servers and finding anybody else through
its discovery index, and the repository browser lists threads, files, and
replies with the session the reviews use. A thread reads its state as a pill, and a
disposition reads as the event it is rather than as another comment. The
list holds an Open tab and a Closed one, each a real address, and closed
holds both of the words the owner settles a thread with, so the mark on a
row still says which. Both lists draw through one row, so a size moves in
one place.
- 2c68bdc: The browser reader answers which commit last changed one path, with
`lastCommit(did, repo, ref, path)`. A repository page can then name the
change a reader is looking at rather than only the change at the tip.
The walk reads trees rather than diffs and remembers what it finds, so a
path untouched for a hundred commits resolves to the same object in all of
them and the answer costs a handful of reads. A commit counts as changing
the path only when what stands there differs from every one of its parents;
matching a parent means the walk carries on down that parent, which is what
credits a merge's change to the branch it came from rather than to the
merge, the same answer `git log -- path` gives.
- fe7ffe0: The read-only smart HTTP endpoint names the commit that last changed a path:
```
GET /git/<handle-or-did>/<repo>/last-commit/<ref>/<path>
```
The answer is `{"commit": {...}}`, or `{"commit": null}` for a path no commit
holds. A page doing this walk for itself downloads the repository's commits
and trees to reach it, which on a few thousand commits is megabytes; the
server reads them from its own storage and sends back a few hundred bytes.
Every answer is JSON, errors included, so a caller can tell a server that
predates the route from one that has nothing to report. The record cid is the
ETag.
The repository browser asks for this route before walking the history itself,
and falls back to the walk for a server that does not serve it.
- 211c7c8: The path walk behind `lastCommit` widens as it goes deeper. Every run of
commits costs one round trip per level of the path, so a run that finds no
change doubles the next one's reach up to 4096 commits. A path changed near
the tip still pays one small run; a path changed near the root now costs a
round trip per doubling.
On repositories of a few thousand commits, read over a 60ms link, a file
whose last change is deep in history resolves about twice as fast.
- 6cf84d5: A file tracked by Git LFS says so. The repository holds a pointer naming the
file rather than the file, and the repository browser showed the pointer as a
broken image on the file page, in a commit diff, and in a README. Each now
names Git LFS and the size of the file the pointer stands for.
- 35f794f: A review record may carry an anchor: the path, line, side and the line's own
text, in the tree the review's sha names. That is a line comment. On the
version reviewed, placement is exact whatever happens to the branch; after a
rebase the comment reads as belonging to the earlier version instead of being
misplaced, and the snippet is what a later placement searches for. The
repository browser publishes one from the diff itself and renders each under
the line it speaks to.
- 522071f: Present the space credential when reading a space's writer set.
The remote helper read `com.atproto.space.listRepos` with no authentication,
which a host that requires one refuses. It now asks the same way it asks for a
member's records, and the space authority reading its own space mints a
credential for the call rather than offering its session, because the method
admits none.
Without this, cloning or fetching a repository in a space fails with
`A space credential is required` against the atproto reference PDS and against
any pds.js from the release that closed the same gap.
- 32db640: The pull request and issue lists are served by the repository's own server:
`dev.pdsjs.git.listPulls` and `dev.pdsjs.git.listIssues`. The owner's copy
is read through the server's browser, every collaborator the config names
through a browser over their server, and anybody else through a discovery
port that answers which records about the repository exist in accounts the
config does not name. The first provider is Constellation, a hosted reverse
index over the public firehose, asked once per collection on the path
`subject.uri`; a deployment may pass its own port to `createGitExtension`,
or null to read the listed voices alone. Open and merged are computed from
the object graph on every read and stored nowhere. The repository browser
asks its server for the two lists, where it read five servers itself.
The runner is CI only. Its pull request index, issue index and push log
(`dev.pdsjs.git.pulls`, `.issues`, `.versions`) and `PDSJS_CI_PULL_SOURCES`
are gone, since the discovery port answers what the indexes did and the
version numbers the push log fed left the interface. A check or comment
written against a commit the branch has since replaced still reads as
outdated, from the tips the checks and reviews themselves name.
A push into a fork writes `upstream` on the pushed record, the repository
record the copy was taken from, when the checkout's remotes say which that
is. The repository's server asks its discovery index which copies name it and
reads every branch of each, so a bare push into a fork is listed before its
author has written a pull request record.
An author still writes their own words from the page: a title, a note, and
open, draft or withdrawn, as a `dev.pdsjs.git.pull` record in their own
repo. The form appears for the account holding the branch and nobody else.
- 04b15d0: `dev.pdsjs.git.listCommits` takes a `path`, and answers the commits that
changed it, the way `git log -- path` follows a path: a merge that took the
change from a branch credits the branch. The browser's `listCommits` takes the
same path, and `lastCommit` is the first entry of that walk. The repository
browser's History link on a file or a directory opens that history.
- cc4577b: `buildMerge` assembles the push that merges a pull request. It takes the
object stores of the canonical copy and of the copy holding the work, and
answers with a bundle to append, its pack index, and the ref to move. It
writes nothing: the caller owns the blobs and the record.
Neither store holds the other's objects, but a commit id is the same id
everywhere, so the intersection of two walks is the merge base. `mergeable`
then requires that base to be main's own tip, which makes the result tree the
branch's own and leaves no three-way content merge to do.
The bundle carries what the canonical copy lacks and nothing it has. A subtree
whose id is already there is skipped whole, so the cost follows the change
rather than the repository. A merge includes the branch's commits, because
main's history has to reach them; a squash leaves them where they are, because
nothing in the graph will name them and the trailers carry the answer instead.
Merging runs on the authority's own server under that account's own session.
A `repo:` scope is collection-wide, so asking a browser page for one would
grant it every repository record the account holds, which is a wide grant for
one merge. Nothing calls this yet.
- cc4577b: `POST /account/api/git/merge` puts a pull request's work on a repository's
canonical branch, in either merge or squash mode.
Merging is the one act in this model that needs authority over the repository,
so it runs on that account's own server under the session cookie the account
page already answers to. An atproto `repo:` scope names a collection and its
actions and cannot name one record, so asking a browser page for
`repo:dev.pdsjs.git.repo` would hand a token in IndexedDB create, update and
delete over every repository the account holds. The cookie is the narrower
grant, and the repository browser reaches the endpoint because it is served
from the account's own site.
`createGitMerger` is the seam. The pack format is the git package's and the
authority over the record is the PDS's, so the platform builds the merger from
storage it owns and injects it as `gitMerger`, the way it already builds the
write guard. Core carries no dependency on the git package.
A merge advances the canonical branch to a commit whose first parent is its
current tip, so it is a fast-forward by construction and cannot force-push or
delete a protected branch. The write of the repository record consults the
server's composed write guard, the git rules among them, so the same rule a
bearer-token write is held to holds a merge, which is the backstop should the
merge ever produce anything but a fast-forward. The new bundle carries a pack
index, so the chain stays readable through ranged blob reads rather than
downloading whole.
The pull request page gains Merge and Squash for the account that owns the
repository. A branch main has moved under is told to rebase rather than
offered a button that cannot work.
- f5aae7c: Merging a pull request runs under the owner's OAuth session, not a separate
account password login. The git-ui scope names `dev.pdsjs.git.repo` with the
update action, which is what a merge writes; the merge endpoint already
accepted a bearer token whose repo scope covers it. A session granted before
the scope grew is sent back through consent, the way every scope growth is.
The grant reaches no repository but the owner's own, and the server accepts a
merge only from a token whose account is the repository's, so it is narrower
than the whole-account session it replaces.
- cc4577b: `writePack` and `writeBundle` build a packfile and a v2 bundle from objects in
hand, for a caller with no git to shell out to. Every object is stored whole:
finding a good delta base is the whole cost of packing, and a merge writes
one commit and the trees under it. The node remote helper still takes the pack
git hands it over the wire.
`buildMergeCommit` is the commit that puts a branch's work on main, in either
of the two modes whose difference is what main's history keeps. A merge commit
names the branch tip as its second parent, so the graph itself says where the
work came from. A squash names only main, so the graph says nothing and the
commit carries `Merged-from:` and `Merged-ref:` trailers naming the copy and
the ref instead. That is the decentralised form of a forge's `(#123)`, which
works there because a central authority minted the number.
`mergeable` is the precondition both modes share: the branch's merge base must
be main's own tip. Then the result tree is the branch's own and there is no
three-way content merge to get wrong. A branch main has moved under is refused,
and rebasing it is the author's move.
These are the primitives. Nothing calls them yet: merging from the page also
needs the objects a branch brings gathered out of its author's copy, chunked
blob uploads, and a session scoped to write the repository record.
- fe3c9ac: The repository browser colours the files an iOS or Android project holds:
Kotlin, Java, Objective-C, Gradle scripts, Podfiles and Fastfiles,
`.properties`, and the plist, storyboard, xib and xcscheme XML formats.
- b99acf8: A review comment speaks to a range of lines, not one. The
`dev.pdsjs.git.review` anchor gains `endLine` and `endSnippet`: a comment on
one line carries neither, a comment on several carries both. A reader hangs
the thread on the last line, the way a forge does, and marks the span from
the first to it. The snippets keep the range placed the way one snippet kept
a single line, since a rebase moves numbers.
In the diff, a click on a line's gutter opens the composer on that line, and
a shift-click on another line's gutter, on the same side, makes the two ends
of a range. The rows between are marked while the comment is written and
after it is published. A comment on one line reads exactly as before.
- c1ec3d6: Close the OAuth loopback callback server cleanly after login completes.
- 04b15d0: One address for a repository, public or private: `atproto://<handle>/<repo>`.
The helper asks for the public record first and, where there is none, for the
space a private repository keeps under its own name, so the space form of the
address is gone along with the space, type and key options on
`dev.pdsjs.git.createRepo`, which takes `private: true` instead. The listing's
clone URL for a private repository is the same short address, and the account
page's new-repository dialog asks public or private and nothing more.
- 7c06024: Add `git-remote-atproto pr create` to publish a pull request record from the
current checkout. The record lives at the key the branch's ref derives, and
creating refuses when a record already holds that key, so a second create
never clobbers the words already there.
- 6935a55: Add `git-remote-atproto pr merge` to put the current branch's pull request on
the repository's canonical branch, either as a merge commit or a squash.
Merging is the repository owner's act, so a fork merge runs with the owner's
own session; the command refuses when that session is not stored, rather than
letting the server answer with a token error.
- 15c69af: Add `git-remote-atproto pr update` to refresh the current branch's pull
request with a new title, description and draft word, and the tip commit at
HEAD. The record lives at the key the branch derives, so an update rewrites
it in place rather than appending, with a compare-and-swap against the
version read.
A pull request is one record at `dev.pdsjs.git.pull/<repo>:<ref>` (the
record key the branch's ref derives) rather than a newest-wins stack of
appended records. The reader, the CLI and the UI all agree on that key, so
an update is always the current word.
- a56be28: A pull request is identified by the account whose copy holds the work and the
ref that holds it, in place of the `Change-Id:` trailer. The pair costs nothing
to compute, needs no hook in the author's push path, and survives the one case
the trailer did not: a commit added on top of a branch gave the tip a new
trailer, which detached every check and review keyed to the old one.
`git-remote-atproto install-hook` and the `changeId` fields on the check,
review and index records go with it. A `dev.pdsjs.git.review` record names
`author` and `ref` instead, and its verdicts are `approve`, `changesRequested`
and `comment`.
A merged branch is read from the merge that brought it in: the merge commit's
second parent is the tip merged and its first parents are main's 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 opens at its tip alone.
The vocabulary follows the concept where the concept matches. `contributors`
becomes `collaborators` in the config record (`collaboratorsOf`), and
`dev.pdsjs.git.offer` becomes `dev.pdsjs.git.pull`, which gains a `title`. Work
from an account the repository has not named sorts into the one list, marked as
coming from a fork.
The repository browser serves the list at `/<repo>/pulls`, with open and merged
tabs, and one pull request at `/<repo>/pull/<did>/<branch>` with its diffs
under `/files`.
None of these lexicons is published, so there is nothing to migrate.
- 673ec05: A pull request gets a landing page, addressed by the DID of the account whose
copy holds the work and the branch: title, the author's note, branch and
version badges, the review composer, and the whole conversation, with the
diffs under a Files tab. A thread anchored to a line appears as one line that
scrolls to it; a thread about the whole pull request appears in full,
answerable in place. The list slims to rows that say what is happening: counts
of threads, approvals and change requests in place of the conversation itself.
The address survives the rebases that move the shas under it; sha-range
addresses keep working.
- 74819af: Every read the repository browser makes goes to the repository's own server.
The read methods take a `from` parameter, the account whose copy of the
repository to read, so a commit or a range on a collaborator's or a fork's
branch is answered by the owner's server through the browser it holds over
that account. `getCommit` takes a `base..tip` range and answers the changes
across it as one diff; `listCommits` takes `range` in place of a ref. A copy
in a space is refused, since its objects are in the space. The answer names
the copy it came from and carries no record version, since the version is
that account's.
A new `dev.pdsjs.git.listCheckRuns` lists every run the repository's runner
published about it, newest first, read from the runner's own server on the
server rather than in the page. `listRepos` carries each record's version
now, so a read that names it caches immutably.
- 4dfdbfa: A review record may name the review it answers (`replyTo`, a strongref), which
is a comment on a comment. The reply still names the pull request and carries
its own anchor, so a reader that does not know the field shows it beside what
it answers rather than losing it. The repository browser threads a line's
comments oldest first, one level deep, offers Reply on each, and reads
statements from the config's reviewers and collaborators together, because a
conversation needs the author's side.
- 04b15d0: listPulls gathers a review whether it names the canonical repository record
or a collaborator's copy of it, the copy that holds the branch reviewed. The
checks were already read that way; a review written against the copy was
dropped, so a pull request showed no conversation.
- aeb61f9: Repositories in a permissioned space browse with ranged reads. The space
reader served whole blobs only, so the account page downloaded a private
repository's whole bundle chain to list a directory, while a public one read
single objects out of the chain. `SpaceReader` takes an optional
`getBlobRange`, and both platform adapters supply it wherever the blob store
reads ranges. A space repository whose bundles all carry an object index now
reads lazily, as a public one does; a reader without ranges still reads the
chain whole.
- 04b15d0: `createRepo` writes the description onto the empty record, and a private
create that names a type creates the space itself, reusing one of the same
type and key. The space reads, the owner's listing with spaces and every read
naming a space, accept the account's bearer token as well as the session
cookie, so a page served away from the PDS can open the account's own private
repositories.
- 04b15d0: Define `dev.pdsjs.git.space` as the Git space type and use it when the forge
creates the space for a private repository. The type declares the repository
and configuration collections instead of deriving an undeclared type from the
account handle.
- f27dcf7: A git repository's record key is minted at creation, and its name is an
ordinary field. The name was the key, so it was the identity, and renaming a
repository changed its AT-URI: every review, pull request, issue and check
naming it, every fork naming it upstream, and the reverse index all pointed at
an address that no longer existed.
The rule is that the name is an address and the key is an identity. Every
public surface still takes a name, `resolveRepo` is the one place that turns
one into a key, and everything below it works in keys. `dev.pdsjs.git.repo`
already declared `key: 'any'` and already required `name`, so no record's
value changes, only its key.
`dev.pdsjs.git.renameRepo` writes that one field. A repository in a space
keeps the name it has: the space is what an address resolves to and is keyed
by that name.
The config record moves to the repository's key, which the write guard already
assumed when it joins the two. A collaborator's copy is found by the name the
two copies share rather than by assuming their key matches. Pull request keys,
check request keys and latest-check keys all derive from the repository's own
key, so a rename leaves them where they are.
`@pdsjs/git-ci` reads a repository's name from its record rather than from the
firehose write's key, which a minted key would have matched against nothing.
Its two key derivations are duplicated in the server and must agree, so the
runner is deployed with it. Secret grants keep naming repositories the way a
person writes them and resolve to a record key at startup, where a grant
naming a repository the account does not hold is reported.
Existing repositories are moved by `scripts/migrate-git-repo-keys.mjs`. The
resolver finds an unmigrated repository unchanged, so a deploy can precede the
migration. See `docs/plans/2026-09-04-stable-repo-keys.md`.
- c9e3550: The repository browser colours Swift files, and reads a Svelte component as
markup so its script and style blocks take their own colouring.
- c01859b: A pull request merges the way GitHub merges one. Where main has moved since
the branch was taken, the merge is the three-way merge of the two against
their base: text merges line by line with git's own rules, a tree entry only
one side changed takes that side, and a path both changed differently is a
conflict that refuses the merge and is named. Merging no longer asks for a
rebase first.
`listPulls` answers `conflicts` on each open pull request, the paths where
the branch and main cannot be put together, computed the same way when the
list is read. The pull request page says whether the branch can merge and
lists the conflicting files, for everybody, and offers the buttons to the
owner where it can. The overlap warning, which named other open pull
requests touching the same files, is gone: it answered a question nobody
asked.
- 41df38b: The repository browser signs a reviewer in and publishes their review. The
page is an atproto OAuth client of the visitor's own PDS, wherever it is:
PKCE for the code, DPoP for the token bound to a non-extractable key in
IndexedDB, PAR for the request, scoped to exactly the two collections a
participant writes (`dev.pdsjs.git.review`, `dev.pdsjs.git.pull`). The
review record goes into the reviewer's own repo; the repository being
reviewed is never written to, and its config's reviewers list stays the only
thing deciding whose statements render.
The deploy publishes the app's OAuth registration beside the page when
`GIT_ORIGIN` names the site's https origin. On localhost the client states
its registration in the client_id itself, so a dev stack signs in with no
registration at all.
- 411b47b: The git extension serves its reads and writes as XRPC methods under
`dev.pdsjs.git.*`, each described by a lexicon: `listRepos`, `getRepo`,
`getTree`, `listFiles`, `getFile`, `getRaw`, `listCommits`, `getCommit`,
`getLastCommit`, `listChecks`, `getAuthors`, `createRepo`,
`updateSettings` and `requestCheck`. A read of the bundle chain keeps the last four repositories
opened and downloads a chain without object indexes whole only up to
`eagerLimit`, answering `RepositoryTooLarge` past it. A read of
the public repo is anonymous and answers under the record cid as its ETag; a
read of a space repository and every write take the owner's account session,
or for a write a bearer token with the `repo:` scopes of the collections it
writes. `community.lexicon.service.describe` lists the methods a server
serves. The `/account/api/git` routes and `/git/<account>/<repo>/last-commit`
are gone; the methods replace them.
An extension host gains `readAccountSession` and `readJsonBounded`, so a
route that answers in its own shape can take the account session alongside a
bearer token.
The account application's repositories pages call these methods, with the
raw file view served through `getRaw`.
- ca47f96: The repository browser's title bar opens with the account's face, ahead of the
address it belongs to, and a menu that opens over a commit now covers the faces
under it. The author facepile stacks its faces so the author reads whole in
front of anyone credited beside them, and that stacking reached the whole page:
a face drew over the theme, clone and branch menus. The pile isolates its own
order, and each menu takes a layer above the page.
- 617df4a: A commit author the account claims by label reads under that label rather
than under the account. The browser asked for the profile of `claim.did ||
account.did`, so a claim naming a tool, a bot, or a person who keeps no
account fell through to the hosting account's own profile: every commit the
tool co-wrote showed the account's name and face. A label with no DID names
something that holds no account, and now nothing answers in its place.
The account still speaks for a claim that names neither DID nor label, which
is the claim an account makes about its own address.
- 154e7d7: Pushes write an object index beside each git bundle, and a reader that finds one opens the repository from the index blobs alone: trees, files, history and diffs fetch only the objects they show, as ranged blob reads. A chain without indexes downloads whole as before. The PDS CORS allowance now includes the Range header, so a page on another origin can make those reads against getBlob. The repository browser opens an indexed repository near-instantly either way.
- 10c91f6: A repository can name the packages it builds. The `dev.pdsjs.git.config`
record grows a `packages` list of `{registry, name}` entries, which
`linkedPackagesOf` reads and the write guard checks; a package record names no
repository and atproto has no reverse index, so this list is what connects the
two. The account page's repository settings offer the npm packages and
container images the account has published, and `POST /account/api/git/settings`
takes the chosen list. `@pdsjs/npm` and `@pdsjs/oci` export their lexicon
module under `./lexicon`, so a browser reads a package record key without
pulling in the registry server.
- 0e1bf66: The repository browser escapes the text between an inline `<code>` tag in a
README and its closing tag. An unterminated tag in there opened a tag of the
document's own.
- e83838c: A README keeps the tags it writes itself. The repository browser reads them
against an allowlist and writes them again, so a row of screenshots, a centred
banner and a collapsed section render where the tags were printed as text. An
image the tags name by a relative path comes from the repository, the same as
one written in markdown.
- 16ea1fe: The browser reader lists every file at a ref, with the size of each, from the
pack index alone. The walk reads one depth of directories per batch, so a
whole-tree listing costs a set of coalesced range reads per level rather than
one per directory. The repository browser weighs the result by language and
paints the shares as a bar.
- 3d475f8: Permissioned data tracks the October 1 spaces alpha of the reference
implementation (`0.0.0-spaces-alpha-20261001173819`, atproto
`permissioned-data-alpha` at `79d6307e1`).
A space credential is bound to its holder's key with an RFC 9421 HTTP message
signature instead of DPoP. The application signs the `getSpaceCredential`
request's `Authorization` header with a fresh P-256 key, named as a did:key in
`keyid`, and the authority binds the credential to it as `cnf.kid`. A request
presenting the credential uses the `Atproto-Space` scheme and an
`Atproto-Space-Audience` header naming the account it is addressed to, and
signs both. A host refuses a signature made for another account
(`BadSpaceAudience`) or by another key (`BadSpaceSignature`). DPoP credentials,
the `dpopJkt` field and the `DPoP` presentation scheme are no longer accepted.
`@pdsjs/spaces/http-signature` signs and verifies both shapes, and the git
remote helper and the spaces inspector sign with it.
A credential lives ten minutes by default and never more than an hour, and must
carry a finite `iat`, an `exp` after it and a `jti`. A repo host serves
`com.atproto.space.notifyCredentialRevoked`: a space's authority names up to 100
credential ids, and the host refuses those credentials with
`CredentialRevoked` for an hour and ten seconds.
The authority sequences the writer set. Each accepted `notifyWrite` gets a
`spaceRev` that increases across the space, and the notice forwarded to a
syncer carries `repoRev`, `spaceRev` and `prevSpaceRev`. A repeated or older
repo rev is not forwarded again, and a repo rev more than five minutes ahead is
refused with `FutureRev`. `listRepos` entries carry `repoRev` and `spaceRev`,
are ordered by `spaceRev`, and page on a space-revision cursor that every
non-empty page ends on. A writer recorded before this release is sequenced at
its repo rev. The authority accepts a notice addressed to
`<authority>#atproto_space_host` as well as to its bare DID, and a body naming
`rev` instead of `repoRev`.
A repo host stores each write notice it owes an authority before sending it,
and retries a transient failure with backoff for up to a day, keeping only the
newest notice per space. Extensions may declare a `tick`, which core runs
through `runExtensionTicks`: Node and Deno every ten seconds, Cloudflare at its
alarm, which an extension brings forward with the host's new `scheduleWakeup`.
`createSpace` and `listSpaces` take `spaceType`, and still read `type`.
`SpaceStoragePort` changes: `putSpaceWriter` takes a `spaceRev`, writer rows
carry `repoRev` and `spaceRev`, and the port gains `getSpaceWriter`,
`getSpaceRevHead`, the revocation methods and the notice outbox methods. The
schema adds `space_writer.space_rev`, `space_credential_revocation` and
`space_notify_outbox`, migrated in place.
- Updated dependencies [e0b38cd]
- Updated dependencies [93816a1]
- Updated dependencies [3d475f8]
- Updated dependencies [04b15d0]
- Updated dependencies [5bb14a2]
- @pdsjs/spaces@2.0.8
## 0.1.2
### Patch Changes
- cb64c59: Pushes keep the bundle chain free of repeated objects. The smart HTTP
endpoint merges the chain's packfiles byte-for-byte, and a chain that
repeats an object produces a pack a clone rejects: index-pack runs with
`--check-self-contained-and-connected` and dies on "the same object
appears twice in the pack". A plain fetch tolerates the repeat, which is
why only clones broke.
Two pushes could repeat an object. A remote tip absent from the pusher's
repository was silently dropped from the bundle basis, so pushing a new
branch from a stale working copy re-bundled shared history. And history
that only a superseded head reaches (after a force push or a branch
delete) was never excluded, so a ref re-advertising those objects bundled
them again. The basis now covers the current tips plus every recorded
bundle head, and the helper replays the chain to obtain any it lacks
locally, so each new bundle is disjoint from the whole chain.
A chain that already repeats an object needs one repack: push any change
with `ATPROTO_GIT_REPACK_THRESHOLD=2` set.
- 922fb50: Pushes compact the bundle chain geometrically instead of growing it to a
fixed count. After building the push bundle, the helper merges the
smallest run of trailing bundles that keeps every bundle at least twice
the combined size of the bundles after it, the same shape `git repack
--geometric=2` maintains. The chain length stays logarithmic in the
repository size, and a push rewrites only the tail, so per-push cost no
longer grows with the full history. The merged bundle keeps the union of
the tail's recorded heads, so history a superseded head reaches stays in
the chain and the chain stays disjoint.
A merge records the independent subset of the tail's heads (`git
merge-base --independent`, annotated tag objects always kept), so the
head list stays near the ref count instead of growing by one per push.
Existence checks batch through one `cat-file --batch-check` call instead
of one git process per object id.
`ATPROTO_GIT_REPACK_THRESHOLD` keeps its meaning: a chain that still
reaches the threshold repacks into one full bundle, which also drops
history no ref reaches. The new `ATPROTO_GIT_COMPACT_FACTOR` sets the
progression factor; `0` disables tail merging.
- 68ad18f: Commit authors can be shown as atproto identities. A commit records its
author as a name and an address written by that author's own git client, and
nothing signs either, so a reader cannot learn an identity from a commit
alone.
The new `dev.pdsjs.git.identity` record is an account's statement about which
git idents are its own. One record per account, rkey `self`, in the account's
own repo: the claim holds across every repository the account serves, and
nobody can write it on somebody else's behalf. An entry may carry a `did` to
name a different identity, which is how a project account attributes commits
to the people who wrote them.
`@pdsjs/git/authors` matches a commit ident against those claims. An address
matches on its own and beats a bare-name claim, and a name-only claim matches
only a commit that carries no address, so claiming a common name cannot
capture commits that name somebody else's address.
- 68ad18f: The repository browser reads a commit's diff. `readCommit` returns a commit's
metadata plus the files it changed against its first parent, each with
unified-diff hunks, addition and deletion counts, and line numbers on both
sides. A binary file, a submodule, or a change past the size ceiling reports
its status with no hunks.
Line diffing lives in a new `diff.js`. Myers finds the edit script, and then
git's compaction pass places each group of changed lines: a change surrounded
by lines identical to its own can sit at several positions for the same cost,
and inserting a doc comment into a file full of them is the everyday case.
The pass slides each group to the extreme of its freedom and scores the
candidates by the surrounding indentation, using the weights from git's
xdiff, so a block lands on the comment's first line the way `git show` shows
it.
Checked against git over 129 files in the pds.js history: identical statuses,
identical hunks on all but one file, where both sides produce an equally
minimal script by a different path. Every diff rebuilds the file it
describes.
- 68ad18f: Opening a repository is roughly four times faster. On a 7 MB repository the
first listing went from about 4.2 seconds to under one.
Two things were slow. Bundle chunks were fetched one after another, spending
a round trip on each; they are independent reads and now go out together.
Naming every object in a pack meant hashing every byte of it in JavaScript,
and a repository's blobs are almost all of those bytes: 58 MB of the 60 MB
here. `indexPacks` now uses the platform's SHA-1 where there is one, which
does that work in a tenth of the time, and falls back to the bundled
implementation where there is not.
`indexPacks` is async as a result. `createGitBrowser` takes an `onProgress`
callback, called as each chunk arrives with a total known from the record
before the first byte, so a caller can show a repository being read rather
than a spinner.
- ae02630: The git smart HTTP read path answers conditional requests. The ref
advertisement and every `/raw/` response carry the repo record's cid as a
strong ETag, and a request whose `If-None-Match` names the current cid
answers 304 from one record read, before any bundle blob moves. Any push
writes a new record cid, so the validator is exact. Bundle chunk blobs
were already served immutable; an edge cache in front of a PDS can now
revalidate the mutable reads too.
- 68ad18f: The read-only HTTP endpoint serves raw files, so a reader that wants the
source rather than a page around it can fetch one:
```
GET /git/<handle-or-did>/<repo>/raw/<ref>/<path>
```
A path answers with the file's bytes. A directory, with or without a trailing
slash, answers with its entries one per line and a slash on each directory,
so the same route walks a tree as well as it reads a file. An unknown ref or
path is a 404 rather than a server error.
Text is served as `text/plain` whatever the extension, with `nosniff` and a
`default-src 'none'` policy, because a repository can hold a page that would
otherwise run as script on the PDS's own origin. Responses carry
`Access-Control-Allow-Origin: *`, so a browser can read them too.
`createGitBrowser` gained `readBytes`, which returns a file whole rather than
the capped and decoded preview `readFile` gives, and `refNames`, which a
caller needs to tell a ref from the path after it in a URL.
- 86d8eb4: `XrpcClient.uploadBlob` takes the blob's media type, so a caller that uploads
something other than bundle bytes records what it uploaded. Callers that pass
one argument are unaffected.
`runnerOf` reads the `runner` DID from a `dev.pdsjs.git.config` record. It
names the account whose check records describe the repository, which is what
lets a reader find checks that live in another account's repo.
- 715c475: A password session signs in again when the PDS answers 401, then retries
the request once. The client keeps the credentials it signed in with, the
same way the OAuth path refreshes its token pair. Long-lived processes
stay able to write after their access token expires; the git-ci runner
abandoned every check run older than the token lifetime with
"Authentication required".
## 0.1.1
### Patch Changes
- f8e3dff: Private repositories in the account page, and a blob privacy fix. The
repositories page lists space-held repos with a Private badge, browses their
files, and manages the space's members; a New repository dialog creates the
space behind a private repo and shows the push commands. The public
`com.atproto.sync.getBlob` and `sync.listBlobs` endpoints no longer serve or
list a blob that only space records reference; `com.atproto.space.getBlob`
remains the authenticated path to those.
- 9e04a41: Multi-writer for private repositories over spaces. A member's push writes a
repo record in their own member repo on their own PDS; the space authority's
record stays canonical, so its default branch is what a clone checks out.
Fetch enumerates the writer set through the authority, reads each member's
record from their own host with a space credential, lists their branches as
`origin/<handle>/<branch>`, and replays every member's self-contained bundle
chain.
- bdad8c4: Branch protection, enforced server-side. Repository settings on the account
page write a `dev.pdsjs.git.config` record beside the repo record; a write
guard built by the git package and injected by the platforms refuses a repo
record write that force-pushes, deletes, or drops a protected branch,
checking ancestry against the bundle chain. Settings also edit the
description and default branch. The guard rides a new RecordWriteGuardPort
consulted by the public and space record write paths.
- 61f1ce8: OAuth login for the remote helper. `git-remote-atproto login <identifier>`
runs the authorization-code flow as a loopback client, with PKCE and
DPoP-bound tokens, and stores the session under `~/.config/atproto-git`.
Pushes and private reads prefer a stored session and refresh it silently;
the password flow stays as the fallback. `--scope` narrows the grant to
exactly what git needs, for example
`repo:dev.pdsjs.git.repo blob:*/*` for public pushes or a `space:` scope
covering one repo in one space.
- c7f3181: Private git repositories over spaces. A remote of the form
`atproto://<authority>/space/<space-type>/<skey>/<repo>` stores the repo record
and its bundles in a permissioned space instead of the public repo. The owner
pushes and clones with their account session; a space member clones through
the credential chain (delegation token, space credential, DPoP-proofed reads).
`com.atproto.space.putRecord` accepts an optional `swapRecord` compare-and-swap
so concurrent pushes fail cleanly instead of losing bundles.
- 3920c52: Commit history on the repository page. The git browser parses the commit
objects the bundle chain already holds and walks them from a ref in git log
order; the account page shows a History view beside Files with message,
author, age, and short sha, for public and space repositories alike.
- 5125583: Show image files in the repository browser. The git browser reports an image
file's media type and serves its bytes from `/account/api/git/raw`, and the
account page displays the picture where it previously named a binary file.