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