We'll make one repository from nothing and watch the bucket as it changes. Every hash, byte and JSON object below came out of a real run. We ran these commands against a local store and copied out what pail wrote. The GCS requests show what the same writes look like on Google Cloud Storage, and the two objects marked example show a shape rather than a recording.
A pail repository is a folder in a bucket. Before init it's just an empty prefix. There's no server to start and nothing to provision beyond the bucket you already have.
$ gcloud storage ls gs://acme-vcs/app/ (nothing)
pail init writes three objectsAn empty first commit, a pointer to it, and a note saying which branch is the default. That's a complete repository.
$ pail init gs://acme-vcs/app
{"parent": null,
"root": "229cbf1334af…",
"generation": 0, "files": 0, "bytes": 0,
"author": "Ada <ada@acme.dev>", "author_date": "1792606331 +0000",
"committer": "Ada <ada@acme.dev>", "committer_date": "1792606331 +0000",
"message": "Initial empty commit",
"changes": {}}{"commit": "419771b4a492…", "root": "229cbf1334af…", "generation": 0}{"branch": "main"}The commit is named by the BLAKE3 hash of its own bytes, so it can never change. refs/main is different. It's the one object pail rewrites, and only with a conditional write, as you'll see in a moment.
Ada clones the repository and adds a README and a main.rs. Staging hashes each file locally. Nothing has gone to the bucket yet.
$ pail clone gs://acme-vcs/app && cd app $ printf '# app\n' > README.md $ cat src/main.rs fn main() { println!("hello from a bucket"); } $ pail add -A staged 2 change(s)
| Path | Size | BLAKE3 of the contents |
|---|---|---|
| README.md | 6 B | 2203daf006dd75de… |
| src/main.rs | 51 B | 5acf4d5be78a652c… |
Both files are 64 bytes or smaller, so their bytes will ride along inside the manifest instead of getting objects of their own.
The manifest is the commit's table of contents: every path, its size, its hash and where its bytes live. It's stored as a prolly tree, a B-tree whose page boundaries depend only on the paths. Our repo fits on one leaf page. Here it is decompressed, 159 bytes:
50 47 01 00 00 02 00 09 52 45 41 44 4d 45 2e 6d PG.....README.m 64 00 06 22 03 da f0 06 dd 75 de 16 54 6b e5 23 d.."....u..Tk.# 73 6d 19 72 a7 55 04 0a a5 d6 59 d4 af d3 9f 15 sm.r.U....Y..... 13 2f 2d 00 06 23 20 61 70 70 0a 00 0b 73 72 63 ./-..# app...src 2f 6d 61 69 6e 2e 72 73 00 33 5a cf 4d 5b e7 8a /main.rs.3Z.M[.. … 26 more bytes of hash, then the file itself: fn main() {…
PG page magic 01 format version paths content hashes inline file bytes
The page is named by the hash of those bytes: pages/74a35af9e29b…. In a repository with a million files there are about 2,000 leaf pages. Change one file and only its leaf and the few pages above it get rewritten.
The commit points at its parent and at the manifest's root page, and it carries its own list of changed files so nobody has to diff manifests to see what changed.
{"parent": "419771b4a492…",
"root": "74a35af9e29b…",
"generation": 1, "files": 2, "bytes": 57,
"author": "Ada <ada@acme.dev>", "author_date": "1792606338 +0000",
"message": "Say hello",
"changes": {
"README.md": {"new": {"k": "f", "s": 6, "h": "2203daf0…", "l": {"i": "23206170700a"}}},
"src/main.rs": {"new": {"k": "f", "s": 51, "h": "5acf4d5b…", "l": {"i": "666e206d61…"}}}}}pail now knows the name of every object it's about to upload, before it has sent a byte.
The page and the commit go up in parallel, in one round trip. Each is a create-only write, so it fails if the name already exists. Since names are hashes, an object that already exists is already correct.
PUT gs://acme-vcs/app/pages/74a35af9e29b… ifGenerationMatch=0 200 PUT gs://acme-vcs/app/commits/2e853ec8ee4b… ifGenerationMatch=0 200
Nobody else can see this commit yet. The objects are in the bucket, but no branch points at them.
This is the commit. pail rewrites refs/main on the condition that it's still the version Ada's clone last saw. On GCS that's the object's generation number, on S3 its ETag.
PUT gs://acme-vcs/app/refs/main ifGenerationMatch=1792606331804211 200 $ pail commit -m "Say hello" [main 2e853ec8ee4b] Say hello
{"commit": "419771b4a492…", "root": "229cbf1334af…", "generation": 0} {"commit": "2e853ec8ee4b…", "root": "74a35af9e29b…", "generation": 1}
Two round trips in total: the upload and the swap. There's no push and nothing to merge. The next pail log anywhere sees the commit.
Ada grows main.rs to 242 bytes. Files over 64 bytes are compressed into a pack, and the manifest records where they sit in it. (Files over 1 MB are split into content-defined chunks instead.)
01 28 b5 2f fd 20 f2 35 05 00 a2 0b 24 1e 60 67 codec 1 = zstd, then the zstd frame … 160 more bytes of compressed main.rs … 01 f2 01 26 00 00 00 50 41 49 4c 50 4b 31 00 index footer, then PAILPK1
The commit's change list says exactly where to read: pack a4eedf01…, offset 0, 176 bytes.
"src/main.rs": {
"old": {"k": "f", "s": 51, "h": "5acf4d5b…"},
"new": {"k": "f", "s": 242, "h": "43c4af5c…", "l": {"p": ["a4eedf0111e0…", 0, 176]}}}Because packs, pages and commits never change, any of them can be cached forever, on your laptop or in a CDN.
Bob cloned back at 2e853ec8 and added a LICENSE. By the time he commits, Ada has already moved main to 8280d571. Bob's swap names an old version, so the store refuses it.
PUT gs://acme-vcs/app/refs/main ifGenerationMatch=1792606338977102 412 Precondition Failed
pail reads the new head and compares. Ada changed src/main.rs and Bob changed LICENSE, so they don't overlap. It rebuilds Bob's commit on top of Ada's and swaps again.
$ pail commit -m "Add a license"
[main 64f6749cb123] Add a license, landed after 2 attempts
Bob's first attempt is still in the bucket as commits/eaaa271dd451…, but nothing points at it, so nobody ever sees it and garbage collection removes it later. If both had changed the same file, Bob would get exit code 4 and the list of paths instead.
With dozens of agents on one branch, retrying gets wasteful. A writer that loses twice leaves an intent instead: its changes and message, filed under intents/main/.
{"id": "01792606347120533000-3f9a2c1e",
"base": "8280d571df6e…",
"puts": "5047010000…", // the new entries, as an encoded leaf page
"deletes": [],
"fields": {"author": "Cy <cy@acme.dev>", "message": "Bump the version", …}}The oldest waiting writer reads every pending intent, builds one commit for each on the current head, uploads them together and lands the whole batch with a single swap. The ref remembers which intents it landed so none lands twice.
{"commit": "…", "root": "…", "generation": 7,
"recent": [{"intent": "01792606347120533000-3f9a2c1e", "commit": "…"},
{"intent": "01792606347122810000-a07d55b2", "commit": "…"}]}On GCS, which allows about one write per second to the same object, this is what keeps throughput up: the batches get bigger as load goes up.
Ada does two commits of work on a branch and lands both onto main. The branch is just another ref. Landing moves main to the tip of the stack in one swap, so other people see both commits or neither.
$ pail switch -c pr/tests $ pail commit -m "Add a test" [pr/tests e72bec753c5b] Add a test $ pail commit -am "Check the greeting" [pr/tests 661d05d47ea7] Check the greeting $ pail land pr/tests --stack --onto main landed 2 commit(s) from pr/tests onto main @ 661d05d47ea7 (fast-forward)
If main had moved in the meantime, pail would replay both commits on top of it, still in one swap. If the land fails halfway, running it again picks up where it left off.
pail's read commands take Git's flags and print Git's output, down to the byte. Tools that parse git log keep working.
$ pail log --oneline 661d05d Check the greeting e72bec7 Add a test 64f6749 Add a license 8280d57 Greet by name 2e853ec Say hello 419771b Initial empty commit $ pail cat-file -p 8280d57 tree e91e4f6a5856271ec67c9093bc1e6700a13f2b12 parent 2e853ec8ee4be53b4c57faa9d9a1d79ad13761cc49eef23e4e3b8878e99238a5 author Ada <ada@acme.dev> 1792606347 +0000 committer Ada <ada@acme.dev> 1792606347 +0000 Greet by name
We check this against a real monorepo: a mirror replays every commit to our own repository into pail and compares the output with Git's, byte for byte.
That's every kind of object pail writes. Only the ones in amber ever change.
You don't need a server for pull requests or CI. Every commit is a write to refs/<branch>, and object stores already send a notification when something is written. A tool listens for those, reads what it needs, and saves its results as commits on a _meta branch. That way every record gets history and conflict checks for free.
| Name | Meaning |
|---|---|
| pr/* | Branches proposed for review. |
| _meta:prs/12.json | A pull request: title, head, base, author, state. Claimed once with pail put --if-absent. |
| _meta:prs/12/reviews/bob.json | A review: approve or request changes. |
| _meta:checks/<commit>/test.json | A check result for one commit, written by CI. |
| main:pail.ci.json | The checks a commit declares, and which are required to merge. |
# wire the bucket's notifications once gcloud storage buckets notifications create gs://acme-vcs \ --topic=pail-refs --object-prefix=app/refs/ # CI: follow ref events, build the exact commit, # record each check on _meta pail-ci run --branch 'pr/*' \ --gcs-subscription projects/acme/subscriptions/pail-ci # under the hood: plain pail commands pail events --branch 'pr/*' pail clone gs://acme-vcs/app src --at 126ebf48 pail put _meta --file checks/126ebf48/test.json=-
pail switch -c pr/hello pail commit -am "Say hello" pail-pr open pr/hello --title "Say hello" opened PR 1: pr/hello → main pail-pr review 1 --approve # as Bob pail-pr merge 1 landed 1 commit(s) from pr/hello onto main merged PR 1 into main @ 126ebf48d9dc
Both are short scripts that only call the pail CLI. On GCS, the notification showed up 1.2 to 1.6 seconds after the commit started. Notifications can arrive twice or out of order, so pail events re-reads the branch and drops duplicates, and CI files every result under the commit it tested.
There's no server in the middle, so a rule only means something if the object store enforces it. That's where pail puts it. Only a lander can write a protected branch. Everyone else can add history but never delete or rewrite it, and the lander only moves refs/main once the reviews and checks pass.
| Object | People and agents | Lander | Why |
|---|---|---|---|
| refs/main, refs/release/* | read | write | Protected branches only move after review. |
| refs/pr/*, refs/<you>/* | write | write | Work in progress doesn't wait on anyone. |
| commits/ pages/ packs/ | create only | create only | Nobody can delete or rewrite history. Only garbage collection deletes. |
| intents/main/* | none | write | No batching on protected branches (more below). |
| _policy | read | admins only | Only admins can change the rules. |
# _policy:policy.json (pail policy set) { "version": 1, "admins": ["role:admin"], "branches": { "main": { "writers": ["lander"], "approvals": 1, "required_checks": ["test", "lint"] }, "release/*": { "writers": ["lander"], "approvals": 2 }, "_policy": { "writers": ["role:admin"] }, "*": { "writers": ["*"] } } }
Sign in and get a token. A broker checks who you are and runs pail policy token --as you. The token it hands back is short-lived and only allows what the policy allows, so committing straight to main fails at the bucket.
Commit to a PR branch. Commits to pr/* work like any other commit. CI hears about them from the bucket's notifications and records its checks.
Get a review. An approval is a record for one exact commit. A review tool writes it and stamps who actually signed in, so you can't approve in someone else's name.
The lander merges. The lander is the only identity that can write refs/main. It reads pail policy requirements main, makes sure someone other than the author approved and the required checks passed on that exact commit, and then lands the stack in one write.
The same policy file works three ways. pail serve checks every request, on any backend. A broker runs pail policy token so GCS enforces it directly. The lander checks reviews and CI before it moves a branch.
When pail batches commits, the writer doing the batching lands everyone else's changes too. On main, that would be a way to sneak in unreviewed work. So only the lander writes there, and it only batches changes it has already approved.
A commit's author is whatever the writer typed in. Policy decisions only look at who actually authenticated, or at records a trusted tool wrote. They never look at the author field.
We tested this on GCS. A token built from these rules can read the repository, add history and write refs/pr/*. It gets a 403 when it tries to write refs/main or refs/release/*, delete or overwrite history, or touch another repository. One requirement: people can't have bucket permissions of their own. They only get tokens from the broker. docs/protecting-branches.md explains how to build the broker and the lander.