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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
885 lines · 51.6 KB Raw History

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