npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@pdsjs/git

v0.1.5

Published

Host git repositories natively in an atproto account. The repository's refs and history live in the account's PDS as a `dev.pdsjs.git.repo` record plus chunked blob storage, so a repo rides along in CAR backups, migrates with the account, and is served by

Readme

@pdsjs/git

Host git repositories natively in an atproto account. The repository's refs and history live in the account's PDS as a dev.pdsjs.git.repo record plus chunked blob storage, so a repo rides along in CAR backups, migrates with the account, and is served by any stock PDS with zero server-side changes.

How it works

  • One record per repository (collection dev.pdsjs.git.repo, rkey minted at creation). The name is a field, so it is the address a clone URL and a page path carry while the key is the identity every statement about the repository names. The record holds the ref list and an ordered chain of git bundles.
  • Each push creates an incremental bundle (git bundle), splits it into chunks under the PDS blob upload limit, uploads them with uploadBlob, and advances the record with a swapRecord compare-and-swap. Concurrent pushes lose the swap and are told to fetch and retry.
  • Fetch and clone read the record, download only the bundles whose heads are missing locally, and unbundle them into the object database.
  • Each push also compacts the chain's tail: the smallest run of trailing bundles merges into one, keeping every bundle at least twice the combined size of the bundles after it. The chain length stays logarithmic in the repository size, and a push rewrites only the tail. Once the chain grows past a threshold anyway, the helper repacks everything into a single full bundle, which also drops history no ref reaches. Blobs from dropped bundles lose their record reference and are reaped by the PDS's orphan cleanup.

Usage

Install so git-remote-atproto is on your PATH, then use atproto:// remote URLs:

npm install -g @pdsjs/git

git clone atproto://alice.example.com/my-project
git remote add origin atproto://alice.example.com/my-project
git push origin main

Cloning and fetching are anonymous. Pushing authenticates with an OAuth session or a password.

OAuth login

git-remote-atproto login alice.example.com

The browser opens the PDS's own consent page; approving stores a session under ~/.config/atproto-git/, and pushes use it from then on. Tokens are DPoP-bound and refresh silently for two weeks before the login must be repeated. logout <identifier> deletes the session.

The default grant is the full atproto scope. --scope narrows it to exactly what git needs, so the stored session can push bundles and nothing else:

git-remote-atproto login alice.example.com \
  --scope 'repo:dev.pdsjs.git.repo blob:*/*'

Create a pull request

Push the branch to the author's copy, then create its pull request record from the checkout:

git push origin feature/my-change
git-remote-atproto pr create --title 'Describe the change' --body 'Why it is needed'

The command uses the current branch and HEAD. It writes a dev.pdsjs.git.pull record in the account named by the selected remote. Use --remote <name> when the branch's configured remote is not the target copy. Use --draft to create a draft pull request. The session needs permission to write dev.pdsjs.git.pull records:

git-remote-atproto login alice.example.com \
  --scope 'atproto repo:dev.pdsjs.git.pull'

The command prints the created record's AT-URI.

Password fallback

Without a stored session, pushing reads an app password from either:

  • the ATPROTO_GIT_PASSWORD environment variable, or
  • the git credential store, under protocol=atproto and host=<authority>:
printf 'protocol=atproto\nhost=alice.example.com\nusername=alice.example.com\npassword=app-password-here\n\n' \
  | git credential approve

Environment variables

| Variable | Purpose | Default | | --- | --- | --- | | ATPROTO_GIT_PASSWORD | Password for pushes and private reads | credential store | | ATPROTO_GIT_IDENTIFIER | Session identifier | remote URL authority | | ATPROTO_GIT_SERVICE | PDS base URL, skips handle/DID resolution | resolved from identity | | ATPROTO_GIT_USER_SERVICE | The caller's own PDS base URL, for private reads by a member | resolved from identity | | ATPROTO_GIT_PLC_URL | PLC directory for DID resolution | https://plc.directory | | ATPROTO_GIT_CONFIG_DIR | Where OAuth sessions are stored | ~/.config/atproto-git | | ATPROTO_GIT_CHUNK_SIZE | Blob chunk size in bytes | 8388608 | | ATPROTO_GIT_REPACK_THRESHOLD | Bundle count that triggers a full repack | 16 | | ATPROTO_GIT_COMPACT_FACTOR | Geometric factor for tail compaction, 0 disables it | 2 |

A chunk has to fit the PDS blob upload limit, 50 MiB by default in pds.js. A server that refuses a chunk as too large gets the push again at half the size, so the default suits a reference PDS at 5 MB as well.

Benchmark

bench/push-costs.mjs measures the chain policies against a local PDS: uploaded bytes per push, worst-case push, chain length, and smart HTTP read latency with and without ETag revalidation. From the workspace root:

node packages/git/bench/push-costs.mjs --commits 60 --bytes 8192

bench/clone-memory.mjs measures what a clone costs the server: peak memory and wall time by repository size, for a repacked chain and an unmerged one. The peak is what a Cloudflare isolate must hold, against its 128 MB.

node --expose-gc packages/git/bench/clone-memory.mjs --sizes 8,48,128

Private repositories

A repository can live in a permissioned space (proposal 0016, implemented by @pdsjs/spaces) instead of the public repo. The address is the same as a public repository's:

git clone atproto://alice.example.com/secret-repo

The helper asks for the public record first, anonymously. Where there is none it looks for the space a private repository keeps, of the dev.pdsjs.git.space type and keyed by the repository's own name, and a space that answers is the repository's. A name with neither is a public repository about to take its first push. The record format is unchanged; only where it is stored and who can read it differ. Requirements and behavior:

  • dev.pdsjs.git.createRepo with private: true creates the space with the repository, and that is the one place a private repository can be. The space type declares the dev.pdsjs.git.repo and dev.pdsjs.git.config collections.

  • The PDS hosting the repository needs spaces enabled (spaces: true in the Node adapter, PDS_ENABLE_SPACES on Cloudflare), and the space must exist before the first push (com.atproto.simplespace.createSpace).

  • Every member writes their own copy. The space model permits no other write: a member's push goes to a dev.pdsjs.git.repo record in their own repo on their own PDS, under the same space and repository name. The space authority's record is the canonical one. Its main is the repository's main and its default branch is what a fresh clone checks out; every other member's branches appear namespaced by their handle, as origin/<handle>/<branch>. Merging a member's work means the authority merges origin/<handle>/main locally and pushes.

  • Space writes need either an OAuth session whose space: scope covers the repo, or the account password. An app password session deliberately has no space access, matching the scope rules in @pdsjs/core. The narrowest push grant:

    git-remote-atproto login alice.example.com --scope \
      'space:com.example.devteam?authority=did:plc:...&skey=team&collection=dev.pdsjs.git.repo blob:*/*'
  • The owner reads with the same session. A space member reads through the credential chain: the helper authenticates to the member's own PDS, obtains a delegation token, exchanges it at the space authority for a credential bound to an ephemeral key, and presents that credential with a DPoP proof per request. A member's OAuth login needs action=read in its space: scope, or their account password. Set ATPROTO_GIT_IDENTIFIER to the member's handle or DID; with a password, ATPROTO_GIT_USER_SERVICE skips resolving their PDS (an OAuth session already knows it).

  • Pushes use a compare-and-swap the same way public pushes do. The swap field is a pds.js extension to com.atproto.space.putRecord; on a server without it, concurrent pushes fall back to last-write-wins.

  • The read-only smart HTTP endpoint and the public repository browser serve public records only, so over the network a space repository is reachable through the helper alone. The account page is the owner's surface for it: the repositories section lists private repos with the space they live in, browses their files, manages the space's members, and its New repository dialog creates the space behind a private repo and shows the push commands.

Each member's bundle chain is self-contained: a member's first push uploads their full history rather than an increment over the objects other chains hold. That costs some duplicate storage at personal scale, and buys chains that survive any other member's repack or force push. A fetch replays every member's chain, skipping bundles whose heads are already present, and a member whose host cannot be reached costs their branches, not the fetch.

Branch protection

The account page's repository settings can protect branches, the enforceable half of a forge's rules. A protected branch refuses force pushes and deletion, and a repository holding one refuses deletion outright; the server checks ancestry against the bundle chain before it accepts the record, so a compliant helper is not what the guarantee rests on.

Rules live in a dev.pdsjs.git.config record beside the repo record, under the same rkey. A separate record because every push rebuilds the repo record whole; the config survives them all, rides in backups, and migrates with the account. Enforcement binds the copy on the enforcing PDS: your canonical branches on your server. A member's copy on their own server answers to that server, the same way a fork does.

Rules that need a central merge authority, such as required reviews, have no enforcement point in this model and are deliberately absent.

The config record also carries a runner DID, naming the account whose check records describe this repository, or a runners list naming several. runnerOf reads the first; runnersOf reads them all. Several matter for the offer index below: one runner cannot forge an offer, because a reader follows the index back to the records it names, but one runner could omit. A reader unions every named runner's index, so an offer is found if any one indexed it.

Checks themselves come from @pdsjs/git-ci, which a PDS needs no part of.

Collaborators and reviewers

The config record carries two DID lists, which are what let a reader gather work spread across several servers:

{
  "collaborators": ["did:plc:bob", "did:plc:carol"],
  "reviewers": ["did:plc:carol", "did:plc:nova"]
}

A collaborator holds their own copy of the repository under the same name, in their own account and under their own record key: the name is what the two copies share, and what finds one from the other. Their branches are pull requests to this repository, and a dev.pdsjs.git.review record in a reviewer's own repo is a verdict on one of them. collaboratorsOf and reviewersOf read the lists.

Neither list grants anything. Nobody can write into anybody else's repo whatever these say, no verdict gates a push, and the account holding the config is not in its own collaborator list. What they buy is a reader that knows where to look: atproto has no reverse index, so a copy names no upstream and an upstream finds no copy without being told.

What identifies a pull request

The account whose copy holds the work, and the ref that holds it. Nothing else, and nothing stored: a branch canonical main does not reach is open, one it reaches is merged, and both are read from the object graph.

The pair survives amend, rebase, force-push and a commit added on top, which is every way a branch is revised. A dev.pdsjs.git.review record names it with author and ref, and names the version it read with sha, so a review of an earlier version still reads against the work and still says which version it answered.

What the author says

A dev.pdsjs.git.pull record in the author's own repo carries the words the graph cannot: a title, a note, and an optional status of draft or withdrawn. It names the repository and the branch of the author's own copy that holds the work. Nothing is written to the repository it names, and a push with no such record is still a pull request, titled by its tip commit. A reader decorates with the record and hides nothing.

An account in neither list reaches the repository the same way. Finding its record is the runner's job, because atproto has no reverse index: see @pdsjs/git-ci for the dev.pdsjs.git.pulls index it publishes.

Renaming

A rename writes one field. The record key is the identity, so every issue, pull request, review, check and fork that names the repository still finds it; what moves is the address, so a clone remote is re-pointed by hand. A repository in a space keeps the name it has: the space is what an address resolves to, and a space is keyed by the name it was made under.

Forks

A fork is a clone of somebody else's repository pushed to one's own. The checkout knows both remotes, so a push to a copy under the same name at another account writes upstream on the pushed record: the repository record the copy was taken from, by its address. That is what makes a bare push into a fork findable. The repository's server asks its discovery index which copies name it as upstream and reads every branch of each, so a fork's work is listed before its author has said a word about it. A pull request record adds the author's title and status, and finds a branch in a copy that carries no upstream.

Issues

An issue is the same shape as everything else here: a dev.pdsjs.git.issue record in its filer's own repo, naming the repository. A record with a title opens a thread, a record with replyTo answers one, and the repository owner's record with a disposition (resolved, declined, reopened) is what a reader shows as the issue's state. The latest one wins, so every word is answered by another word. Nobody closes an issue; the owner states a disposition, attributed, and the filer's disagreement can sit beside it. Discovery is the runner's job, the same as pull requests: see the dev.pdsjs.git.issues index in @pdsjs/git-ci.

The repository browser reads all of it on its pull request list. See docs/plans/2026-08-24-pull-requests.md for the model and what is unbuilt.

Linked packages

The same config record carries a packages list, naming what this repository builds in the account's own registries:

{
  "packages": [
    { "registry": "npm", "name": "@pdsjs/core" },
    { "registry": "oci", "name": "pds" }
  ]
}

linkedPackagesOf reads it. The list is the whole link: a dev.pdsjs.npm.package or dev.pdsjs.oci.repo record names no repository, and atproto has no reverse index, so nothing else connects the two. Set it in the account page's repository settings, which offers the packages the account has published. The repository browser reads the list to show each package with its versions and its install command.

Read-only smart HTTP

A PDS can also serve these repositories to stock git over plain HTTP, so nobody needs the helper installed to read:

git clone https://pds.example.com/git/alice.example.com/my-project

Enable it with experimental: { git: { http: true } } in the Node adapter's createServer options, or PDS_EXPERIMENTAL_GIT_HTTP = "true" in the Cloudflare Worker's vars. Both mount createGitExtension({ http, browse }) from @pdsjs/git/extension through core's extensions option. The extension also carries the branch-protection write guard and the dev.pdsjs.git.* XRPC methods the repositories page reads and writes through, which need no option, and the file browser behind them (browse, default on). The handler advertises refs straight from the record and answers fetches by merging the bundle chain's packfiles byte-for-byte (no delta recomputation, no git binary server-side), which keeps it comfortably inside Cloudflare Worker CPU and memory budgets for personal-scale repositories.

The same endpoint serves raw files, for a reader that wants the source rather than a page around it:

GET /git/<handle-or-did>/<repo>/raw/<ref>/<path>

A path answers with the file's bytes, and a directory answers with its entries one per line, each directory carrying a trailing slash. That is enough for a crawler to walk a whole tree through one route. Text is served as text/plain whatever the extension, so a page held in a repository cannot run as script on the PDS's own origin.

XRPC methods

The same reads and writes are XRPC methods under dev.pdsjs.git.*, with lexicons in api-lexicon.js (exported as gitDefsLexicon and gitMethodLexicons). community.lexicon.service.describe lists the ones a server serves, so a client asks once rather than probing routes.

| Method | Answers | | --- | --- | | listRepos | the hosted repositories as dev.pdsjs.git.defs#repoView; anonymous, the public repo alone, and with the account session the repositories in spaces too | | getRepo | one repoView | | getTree, listFiles, getFile, getRaw | a directory, every file with its size, a file for display, a file's bytes | | listCommits, getCommit, getLastCommit | history, one commit with its diffs, the commit that last changed a path | | listChecks | the latest check on each branch, read from the runner's PDS | | getAuthors | the identities the account claims in dev.pdsjs.git.identity, each with the profile behind it, and the account's own profile; profiles are read from the AppView and kept five minutes | | createRepo, updateSettings, deleteRepo, requestCheck | the writes the repositories page makes |

Every read takes repo (the repository's name, which the server resolves to its record key) and, for a repository in a space, space, which needs the account session. The reads of the bundle chain are registered only with browse on. They answer under the record cid as their ETag; given the cid as cid=, the answer is immutable and a stale cid is RepoNotFound, so a page that read the listing can cache every view under it for as long as the record stands.

GitClient from @pdsjs/git/client calls the methods from a page or a tool, one call per method answering its view, and asks describe once so serves('getTree') tells a server that predates a method from one that answers it.

A read of the bundle chain opens the repository on the server: by ranged reads where every bundle carries an object index, and by downloading the chain whole where one does not. The whole download stops at eagerLimit (64 MiB by default) and answers RepositoryTooLarge past it, for a client with a pack reader of its own to read the bundles instead. The server keeps the last four repositories it opened, keyed by record cid, so a push invalidates them.

A procedure takes the owner's account session from a same-origin request, or a bearer token whose repo: scopes cover the collections it writes: dev.pdsjs.git.repo to create, that and dev.pdsjs.git.config for settings and for a delete, dev.pdsjs.git.checkRequest to request a check. A space is reachable through the session only.

deleteRepo takes the repository 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 until the protection is dropped. A private repository keeps its space, which is keyed by the name and would be reused by a repository created under that name later; its member list goes with the repository, so nobody is readmitted to something they were never added to.

The endpoint is read-only. Pushes over HTTP are refused with a pointer to the atproto:// remote. Shallow clones (--depth) are not supported, and every fetch response is built from whole bundles, so a client several pushes behind downloads a bit more than a negotiating server would send.

Hosting outside the PDS

Everything the reads need — the record, the bundle blobs, the identity — reaches this package's handlers over an injected fetch, which is how they run in a browser page and in a Worker. The same seam lets the forge run as a separate service: @pdsjs/git-host reaches the PDS over the public XRPC surface and serves the smart HTTP endpoint, the file browser, the dev.pdsjs.git.* methods and the browser app from its own origin, with discovery answered from its own Jetstream index instead of Constellation. The PDS then mounts only the branch-protection write guard (createGitGuardExtension), which is the one piece that cannot move, because enforcement only means something at the server that holds the record.

Stability

This whole feature is an experiment, and each layer says so in its own way:

  • The package versions at 0.x, so its exports may change in any release.
  • The server adapters take the feature under the experimental option group, and the Worker under PDS_EXPERIMENTAL_* vars; both are exempt from semver.
  • The dev.pdsjs.git.repo lexicon is deliberately unpublished (no _lexicon DNS record), so consumers see validationStatus: 'unknown' and know there is no contract yet. Publishing the lexicon is the record format's 1.0 moment.

The record format still evolves conservatively even while experimental, because records outlive software: new fields are optional, existing fields never change meaning, and readers ignore what they do not recognize. The record is fully derivable from the git data itself, so if a breaking change is ever unavoidable, old repositories heal on their next push and a truly incompatible design would ship as a sibling collection rather than a mutation of this one.

Record validation

The record lexicons ship as gitRepoLexicon and gitIdentityLexicon. The gitSpaceLexicon export declares the collections in dev.pdsjs.git.space. A pds.js instance validates the records strictly if you seed the resolver:

import { defineLexicon } from '@bigmoves/lexicon';
import { gitIdentityLexicon, gitRepoLexicon } from '@pdsjs/git';
import { LexiconResolver } from '@pdsjs/lexicon-resolver';

const lexiconResolver = new LexiconResolver({
  schemas: [defineLexicon(gitRepoLexicon), defineLexicon(gitIdentityLexicon)],
});

Unseeded servers accept the records with validationStatus: 'unknown', matching how the reference PDS treats unresolvable lexicons.

Limits

  • A repository under the two-segment URL form is public, like all PDS records and blobs. Privacy comes from the space form above.
  • Writing requires the helper; reading works with stock git wherever the PDS enables the smart HTTP endpoint, and with the helper everywhere else.
  • A fresh clone downloads the whole bundle chain. Fetches skip bundles whose heads are already present, which approximates negotiation at personal scale. The server streams that response one chunk blob at a time, so its memory follows ATPROTO_GIT_CHUNK_SIZE rather than the repository size.
  • SHA-1 repositories only over HTTP; the helper itself is happy with either object format.