@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 withuploadBlob, and advances the record with aswapRecordcompare-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 mainCloning and fetching are anonymous. Pushing authenticates with an OAuth session or a password.
OAuth login
git-remote-atproto login alice.example.comThe 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_PASSWORDenvironment variable, or - the git credential store, under
protocol=atprotoandhost=<authority>:
printf 'protocol=atproto\nhost=alice.example.com\nusername=alice.example.com\npassword=app-password-here\n\n' \
| git credential approveEnvironment 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 8192bench/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,128Private 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-repoThe 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.createRepowithprivate: truecreates the space with the repository, and that is the one place a private repository can be. The space type declares thedev.pdsjs.git.repoanddev.pdsjs.git.configcollections.The PDS hosting the repository needs spaces enabled (
spaces: truein the Node adapter,PDS_ENABLE_SPACESon 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.reporecord in their own repo on their own PDS, under the same space and repository name. The space authority's record is the canonical one. Itsmainis the repository'smainand its default branch is what a fresh clone checks out; every other member's branches appear namespaced by their handle, asorigin/<handle>/<branch>. Merging a member's work means the authority mergesorigin/<handle>/mainlocally 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=readin itsspace:scope, or their account password. SetATPROTO_GIT_IDENTIFIERto the member's handle or DID; with a password,ATPROTO_GIT_USER_SERVICEskips 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-projectEnable 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
experimentaloption group, and the Worker underPDS_EXPERIMENTAL_*vars; both are exempt from semver. - The
dev.pdsjs.git.repolexicon is deliberately unpublished (no_lexiconDNS record), so consumers seevalidationStatus: '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_SIZErather than the repository size. - SHA-1 repositories only over HTTP; the helper itself is happy with either object format.
