# @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: ```sh 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 ```sh 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 ` 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: ```sh 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: ```sh 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 ` 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: ```sh 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=`: ```sh 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: ```sh 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. ```sh 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](https://github.com/bluesky-social/proposals/tree/main/0016-permissioned-data), implemented by `@pdsjs/spaces`) instead of the public repo. The address is the same as a public repository's: ```sh 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.space.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//`. Merging a member's work means the authority merges `origin//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: ```sh 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`](../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: ```json { "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`](../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`](../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: ```json { "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: ```sh 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///raw// ``` 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`](../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: ```js 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.