pail pail.build

How pail works

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.

  1. An empty bucket

    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)
  2. pail init writes three objects

    An 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
    commits/419771b4a492…
    {"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": {}}
    refs/main
    {"commit": "419771b4a492…", "root": "229cbf1334af…", "generation": 0}
    HEAD
    {"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.

  3. Write two files and stage them

    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)
    PathSizeBLAKE3 of the contents
    README.md6 B2203daf006dd75de…
    src/main.rs51 B5acf4d5be78a652c…

    Both files are 64 bytes or smaller, so their bytes will ride along inside the manifest instead of getting objects of their own.

  4. Build the manifest page

    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.

  5. Write the commit object

    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.

    commits/2e853ec8ee4b…
    {"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.

  6. Upload everything at once

    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.

  7. Move the branch

    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
    refs/main
    {"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.

  8. A bigger file goes into a pack

    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.

    commits/8280d571df6e… (changes)
    "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.

  9. Two people commit at the same time

    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.

  10. When lots of writers collide

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

    intents/main/01792606347120533000-3f9a2c1e.json example
    {"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.

    refs/main example
    {"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.

  11. Land a whole stack

    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.

  12. Read it back like Git

    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.

Everything in the bucket

That's every kind of object pail writes. Only the ones in amber ever change.

PRs and CI are just programs that read and write branches

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.

NameMeaning
pr/*Branches proposed for review.
_meta:prs/12.jsonA pull request: title, head, base, author, state. Claimed once with pail put --if-absent.
_meta:prs/12/reviews/bob.jsonA review: approve or request changes.
_meta:checks/<commit>/test.jsonA check result for one commit, written by CI.
main:pail.ci.jsonThe 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.

The bucket itself enforces branch protection

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.

ObjectPeople and agentsLanderWhy
refs/main, refs/release/*readwriteProtected branches only move after review.
refs/pr/*, refs/<you>/*writewriteWork in progress doesn't wait on anyone.
commits/ pages/ packs/create onlycreate onlyNobody can delete or rewrite history. Only garbage collection deletes.
intents/main/*nonewriteNo batching on protected branches (more below).
_policyreadadmins onlyOnly 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": ["*"] }
} }
  1. 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.

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

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

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

Where the rules are checked

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.

No batching on protected branches

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.

The author field isn't trusted

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.