Version control that lives in a bucket. Clients talk to S3, GCS or a local directory directly. There is no server.
curl -fsSL https://pail.build/install.sh | sh # macOS and Linux, arm64 and x86_64
pail auth install gcloud # a credential provider (see Auth below)Or build from source: cargo install --git https://github.com/ArchAstro/pail pail.
Releases: tag vX.Y.Z (matching Cargo.toml) and .github/workflows/release.yml builds the
four binaries, SHA256SUMS and the GitHub release that install.sh downloads from.
- Linear history. Branches move only by compare-and-swap (S3
If-Match, GCSifGenerationMatch). There are no merge commits; landing a branch squashes it. - Git-style stage.
add,add -A,commit -aandrestorebehave as in Git. The stage stores only the staged delta, so its size depends on the change, not the repo. - Local repositories like
.git. Everything kept on a machine lives in a repository directory: a barename.pail/, or a working copy's.pail/. Nothing is global.
<store>/
├─ HEAD {"branch": "main"}: the default branch
├─ refs/<branch> {"commit": "<id>", "epoch": n}: the only mutable objects, moved by CAS
├─ reflog/<branch>/… one record per rewrite: old → new tip and each old commit's successor
├─ commits/<blake3> commit objects (JSON)
├─ pages/<blake3> manifest pages: a prolly tree over sorted full paths
├─ packs/<blake3>.pack small files, concatenated, read with range GETs
└─ chunks/<blake3> large files (≥1 MB) as FastCDC chunks, deduped globally
Everything but HEAD, refs/ and reflog/ is immutable and named by the BLAKE3 hash of its
bytes, so any copy of it anywhere is valid forever.
A commit is:
parent previous commit id (linear history: one parent at most)
root manifest root page: the full snapshot
generation depth from the first commit (fast ancestor checks)
files, bytes snapshot totals
author, author_date, committer, committer_date, message (Git's exact bytes)
meta free-form; mirrored commits keep git.commit, git.parents, git.tree, …
changes the change list: path → {old: kind/size/hash, new: kind/size/hash/location}
(Mononoke's Bonsai model), computed against the parent it lands on
A manifest maps every path to its entry (kind, size, content hash, location), where the
location is inline bytes (≤64 B, symlinks), a pack range, or a chunk list. Pages split at
key-derived boundaries, so identical subtrees share pages across commits and diffs skip them.
name.pail/ bare: pail clone --bare <store> name.pail
work/.pail/ or the first working copy's .pail/
├─ config Git-format config: core.store, core.bare, plus any local options
├─ HEAD the store's default branch, remembered
├─ objects/{commits,pages} verified copies of store objects
├─ blobs/<hh>/<blake3> file contents: read cache and the source checkouts clone from
├─ graph/ commit graph: metadata, changed paths, id index (mmap)
├─ index/ Git blob ids by content hash, Git tree ids by manifest root
└─ worktrees.json working copies attached to this repository
work/.pail/ a working copy's own state, next to (or instead of) the repository:
├─ HEAD.json {"branch", "base"}
├─ stage.json, pending/ the stage: staged delta + snapshotted contents
├─ statcache.bin stat cache for fast status
└─ repo linked working copies only: path of the shared repository
Everything in it is derived from immutable store objects and verified on read, so it never
goes stale and any part can be deleted. Reading a store with --store <url> keeps nothing.
Git's format and layering: ~/.config/pail/config (global; PAIL_CONFIG_GLOBAL moves it),
then the repository's config (local).
pail config --global user.name "Ada Lovelace"
pail config --global user.email ada@example.com
pail config --global init.defaultBranch trunk
pail config user.name "Ada (work)" # this repository only
pail config --list --show-scope | pail config --unset log.date| key | used for |
|---|---|
user.name, user.email |
author and committer (required, as in Git; PAIL_AUTHOR_NAME/_EMAIL, PAIL_COMMITTER_* override) |
init.defaultBranch |
the first branch of pail init (else main) |
core.pager, color.ui, core.abbrev |
output (PAIL_PAGER overrides the pager) |
log.date, log.decorate, diff.renames |
log/show defaults; flags override |
No branch name is assumed: the store's HEAD names its default branch (set by init,
changed with pail branch --default <name>). clone checks it out and land lands onto it
unless --branch/--onto say otherwise.
pail init s3://bucket/repo # or gs://…, file://…, a directory
pail clone s3://bucket/repo work && cd work # working copy; its .pail/ is the repository
echo hi > a.txt && pail add -A && pail commit -m "first"
pail status | pail diff [--staged] | pail log | pail ls | pail cat <path>
pail switch -c feature … pail commit -a -m wip
pail land feature && pail sync # onto the default branch
pail clone --bare s3://bucket/repo repo.pail # repository without a working copy
pail --repo repo.pail log --oneline # or cd repo.pail; or PAIL_DIRA commit that loses the CAS race is replayed on the new head when the paths don't overlap. If they overlap, the commit stops with a conflict.
Help, agents and completion:
pail --helpgroups the commands;pail help <command>(or<command> --help) gives options and examples. Exit codes: 0 ok, 1 error, 2 usage, 3 a hook refused, 4 conflict (pail sync, then retry), 128 fatal from a Git-compatible command.pail --skillprints a SKILL.md for coding agents (rules that differ from Git: commit publishes, no merges, exit codes, safety).pail completion bash|zsh|fish|elvish|powershellprints a script that asks pail as you type, so branch names come from the live store:source <(pail completion zsh).
Commit as often as you like on a branch, then land the whole stack on main:
pail switch -c feature
pail commit -a -m "step 1" … pail commit -a -m "step 2" … pail commit -a -m "step 3"
pail land feature --stack # every commit, in order, onto the default branch
pail land feature # or: one squashed commit (--onto <branch> to choose)- Main untouched since the stack started: main fast-forwards to the stack (same commits).
- Main moved: each stack commit is replayed on the new head from its change list, keeping
author, date and message (committer: the lander;
rewritten.fromnames the original). - A stack commit touching a path main changed meanwhile is a conflict: nothing lands.
- One CAS: the replayed commits are written first (content-addressed, create-only), then main moves once. That is the only ref write: the branch and the reflog are untouched. A lost race retries from the new head.
- Idempotent and incremental: commits main already has (by
rewritten.from) are skipped, so landing twice is a no-op and commits added to the stack later land on top of their landed predecessors.
Built from two store primitives: write_commit (store a commit on any parent without moving a
ref) and advance_ref (one CAS from the tip the caller saw). commit is one of each;
land --stack is N write_commits and one advance_ref.
Interactive and scripted rebases are one thing: a plan (Git's todo verbs) carried out in
memory from each commit's change list, then published in one CAS. No working copy, no
stopped state, no --continue: a conflict (exit 4) publishes nothing.
pail rebase # onto the default branch's tip
pail rebase -i HEAD~3 # pick / reword / squash / fixup / drop, reorder
pail rebase --autosquash # `fixup! <subject>` commits meld into their targets
pail rebase --print-plan > plan.json # agents: edit the JSON, then
pail rebase --plan plan.json --dry-run --json
pail rebase --undo # itself a rewrite, so it can be undone tooplan ─▶ build (prepare commits, check each path) ─▶ store ─▶ reflog record ─▶ CAS: epoch + 1
│ conflict: nothing written (old → new map) (commits added
▼ meanwhile are
exit 4 carried over)
- Conflicts are per path: a commit conflicts when a path it changes is not, on the new history, as its parent had it (or already as the commit leaves it). A commit whose changes are all there already is dropped; an unchanged commit on its original parent is reused.
- Versioned refs: every rewrite bumps the branch's
epochand writes a record mapping each old commit to its successor (or none: dropped). Fast-forwards carry the epoch unchanged. - Nobody has to be told first. There is no list of who has a working copy (no server, and
readers may not write), so instead every client can tell afterwards:
pail sync/pail commitfrom a base of an older epoch say the branch was rewritten and where the base went; the files move by tree diff as always, local changes kept.- A branch built on the old history (a stack) starts where it left it:
landandrebase <upstream>take the fork point from the upstream's rewrite records, not the merge base, so the old commits are not counted as the stack's own.pail rebasenames the branches it left behind (hint: fb is built on the old fa: …). - Copies of copies are recognised:
rewritten.from(several ids for a squash) is followed through every rewrite, so landing a stack after its base was rebased and landed skips it.
- Safety: commits added on top of the planned tip meanwhile are carried onto the new
history (or the rewrite conflicts); any other move of the branch fails the rewrite. The
default branch is refused without
--rewrite-default; policy still decides who may write.
S3 settings come from the usual AWS_* variables (AWS_ENDPOINT, AWS_ALLOW_HTTP for
emulators). GCS settings come from GOOGLE_*.
pail inspect # in a working copy or bare repository; opens a browser
pail --store s3://bucket/repo inspect --addr 127.0.0.1:7777 --no-openThree panes: the logical view (the files and folders of a commit) on the left, the physical objects that store them in the middle, details on the right. Click a folder and the graph shows the index pages and file lists covering its paths and the packs holding its bytes; select a page and the files it holds light up on the left. "How it works" walks through the model step by step on your repository's real objects.
The graph draws objects in columns by how they reference each other: branches → commits (parent chain) → manifest pages by level → leaf pages → files → packs and chunks. Click an object to expand what it references; big fan-outs open 30 at a time. Objects are drawn once, so pages that several commits share show up as one node with a "shared by N" tag. Dashed edges are a commit's change list. "Trace" expands the whole chain from a commit down to one file's byte range in its pack. The side panel shows each object's key, size and role, a pack's byte map, and file contents.
bucket ──fetch once──▶ <repo>/blobs/<blake3> (read-only, one copy per content)
│ clone │ clone │ clone APFS clonefile / Linux FICLONE
▼ ▼ ▼
wt/a wt/b wt/c share blocks until edited
seed: wt/a/target, node_modules … ──clone──▶ wt/b/… warm build, no extra disk
pail worktree add ../feature -b feature # new branch at this base, seeded from here
pail worktree add ../review --branch main --seed-from ~/src/other-checkout
pail --repo repo.pail worktree add ../wt # from a bare repository
pail worktree list | pail worktree remove ../feature- Working copies attach to one repository and their files are copy-on-write clones of its
blobs/, so extra worktrees cost inodes, not bytes. Editing a file in place is safe: the clone gets its own blocks. A working copy on another volume (or a filesystem without clones) gets copies. --seed-from(default: the current worktree) clones ignored build output into the new worktree:target/,node_modules/,_build/,.next/,.env*… (override with a.pailseedfile in gitignore syntax). Nested checkouts and other ignored paths are skipped.- Files identical to the seed source get its mtimes, so cargo/make see the seeded build as up to date. Files that differ get a fresh mtime and rebuild normally.
- The first clone's
.pail/holds the repository, so it can't be removed while linked working copies use it (as Git's main worktree).
Measured on firstlanding (28,877 files, 459 MB; APFS):
| real disk | time | |
|---|---|---|
| plain clone | 529 MB | 4.9s |
worktree add |
26 MB | 2.4s |
worktree add seeded with archdev's 10 GB target/ |
29 MB | 3.6s |
cargo build in it (cold build: 446 crates, 147s) |
0 MB, 0 crates | 0.6s |
| same, but target cloned without mtime matching | 5.6 GB, 8 crates | 112s |
seeded from a real checkout: 140 node_modules/_build/.next, 5.3 GB apparent |
157 MB | 15s |
Read commands run in a working copy, in a bare repository (--repo, PAIL_DIR, or from
inside it), or straight against a store (--store <url>, PAIL_STORE; nothing is kept). A
bare read's HEAD is the store's default branch.
pail --repo repo.pail log --oneline -20 -- services/rust
pail --repo repo.pail log -p --stat main~10..main
pail --repo repo.pail show HEAD~3 --name-status
pail --repo repo.pail ls-tree -r -l HEAD:src
pail --repo repo.pail cat-file -p HEAD:package.json
printf 'HEAD:Cargo.toml\nHEAD:src\n' | pail --repo repo.pail cat-file --batch
pail --repo repo.pail rev-parse --short=12 HEAD HEAD:src
pail --store s3://bucket/repo ls -l HEAD:src | pail --store … cat HEAD~2:README.mdlog,show,cat-file,ls-tree,rev-parsetake Git's arguments and print Git's output: revisions (~,^,A..B,A...B,^A), formats (--oneline,--pretty=…,--formatplaceholders,--date=…), filters (-n,--skip,--since/--until,--author,--grep,--merges, paths/globs), diffs (--name-status,--raw,--numstat,--stat,--shortstat,--summary,-p,-U<n>, rename detection), pager and colors on a terminal.- Line diffs come from a port of Git's xdiff (Myers, indent heuristic), so patches and
stats are Git's, not merely similar. Git object ids (blobs, trees, commits) are computed
from pail data;
--ids=gitprints the Git commit ids of mirrored history. snapshot::Snapshotis the library API behind it:stat,list_dir,walk,read,read_rangefor any commit, reading only the manifest pages and packs it needs.
Like git fetch into a bare repository: reads keep what they fetch, and fetch fills ahead.
pail --repo repo.pail fetch # every file at HEAD
pail --repo repo.pail fetch --history # every version along HEAD's history
pail --repo repo.pail fetch --all --history -n 500
pail --repo repo.pail cache stats # what the repository holds
pail --repo repo.pail cache prune --max-size 20G # drop least recently used contentsOn firstlanding (2,231 commits), fetch --history pulled 36,664 file versions (313 MB) in 27s.
Derived, rebuildable, never stale (everything they index is immutable):
- Commit graph (per repository, like Git's commit-graph): every commit's metadata and changed
paths in one append-only file plus a sorted, memory-mapped index by pail id and Git id.
Walks,
A..B, id/prefix lookups and abbreviation lengths read it, never commit objects;log -- <path>filters on its changed paths. - HEAD resolves only when used; the store's default branch is remembered.
- Git ids: blob ids in one index by content hash; root tree ids by manifest root (mirrored
commits record
git.tree, checked by the proof). - Line diffs for a batch of commits are computed in parallel, then printed in order.
Same range as git log --first-parent (2,221 commits), warm caches, median of 15:
| ms | before | pail | git |
|---|---|---|---|
log --oneline -1 |
77 | 6 | 12 |
log --format=%s |
56 | 13 | 32 |
log --name-status -n 200 |
169 | 20 | 42 |
log --stat -n 200 |
343 | 93 | 112 |
log -p -n 200 |
507 | 126 | 122 |
log -- services/rust |
1,623 | 14 | 44 |
cat-file -p <id>:path |
26 | 6 | 10 |
cat-file -p <commit> |
83 | 5 | 9 |
rev-parse <short id> |
233 | 5 | 10 |
Proof: scripts/gitlog-compat.sh and scripts/gitinspect-compat.sh run each command with the
same arguments through git and pail on mirrored history and compare bytes. On firstlanding
(2,192 commits; a second window with 139 merges): 55/55 log cases, including -p over
the whole history (45 MB of patches, identical), and 127/127 show/cat-file/ls-tree/
rev-parse cases at four depths.
Differences from Git:
- History is first-parent, so output matches
git log --first-parent.showon a merge prints no combined (--cc) diff. - The default abbreviation is 7 characters (longer if needed to stay unique). Git sizes
its default from the local clone's object count, so pass
--abbrev=<n>when the length matters. - Not yet:
--graph,--follow,-L,-S/-G,--word-diff, notes, mailmap (%aNis%an),.gitattributesdiff drivers,%w()/%<()layout (accepted, not applied).
pail-mirror watches a GitHub branch, forwards each first-parent commit into pail, and
proves every forwarded commit byte for byte. It rebuilds the Git commit id from data read
back from the store and requires it to match:
- Every file's bytes give its Git blob id.
- The manifest's paths, modes and blob ids give the Git tree id.
- The commit's author, committer, dates, message, Git parents and extra headers, plus that tree id, give the Git commit id.
Periodically it also does a cold pail clone and compares every file with Git's raw objects.
source env.sh # AWS_* for the store
pail-mirror run --github ArchAstro/firstlanding \
--git-dir ~/.cache/pail-mirror/firstlanding.git --reference ~/archastro/firstlanding \
--store s3://pail/firstlanding --state-dir ./state \
--backfill 2000 --poll 30 --deep-every 250 --serve 127.0.0.1:8790
pail-mirror verify --git-dir … --store … --state-dir ./state # re-prove the whole branchThe dashboard at --serve shows sync state, per-commit import and proof times, upload
costs, and deep-check results. state/mirror.jsonl holds one record per commit.
Features like pull requests and CI are programs that agree on names, not server code:
pail put writes records to a _meta branch atomically, pail events streams ref changes
from the bucket's own notifications (GCS Pub/Sub) or by polling, and pail clone --at
checks out the exact commit an event names. extensions/pail-pr and extensions/pail-ci
are working prototypes; see docs/extending.md.
Most people have no bucket credentials. Pail asks an installed credential provider for
short-lived, delegated ones; pail itself only knows the protocol. Without a provider it uses
the cloud SDK's chain (gcloud ADC, GOOGLE_*, AWS_*).
┌ pail-credential-gcloud your gcloud login
pail ─▶ provider for ────┼ pail-credential-gcs-downscoped gcloud, narrowed to one repo (CAB)
this store URL ├ pail-credential-archdev your ArchDev login → pail.archdev.ai/<org>/<repo>
(config) └ pail-credential-<yours> any SSO / broker / vault
│
├─ gs://, s3:// ─▶ token or keys, straight to the bucket (no server)
└─ https://host ─▶ bearer token to `pail serve`, a stateless proxy holding the bucket
credentials and asking --auth-command per (token, repo, read|write)
pail auth list # installed providers and their source
pail auth install gcs-downscoped # ArchAstro/pail-credential-gcs-downscoped
pail auth install acme/pail-credential-sso@v1.2 # any GitHub repo; or a path: ./pail-credential-acme
pail auth upgrade --all # newest releases; pinned installs stay
pail auth use gcs-downscoped --for gs://acme-vcs --global
pail auth status # provider, expiry, and a read of the store
pail auth logout # forget cached credentials- Extension: its own GitHub repository
pail-credential-<name>, installed like a gh extension. The repository'spail-extension.jsonnames a script ({"name","protocol":"pail-credential/1","description","script":"<path>"}) or release binaries per platform ("binaries": {"darwin-arm64": "<asset>", …}); without one, an executablepail-credential-<name>at the root, else a release assetpail-credential-<name>-<os>-<arch>. The ref is@ref(pinned), else the latest release, else the default branch, resolved to a commit first. Installs land in~/.local/share/pail/extensions/pail-credential-<name>/(or$PAIL_EXTENSIONS_DIR) with amanifest.json(repo, ref, commit, sha256);$PATHis searched too. Private repositories work withGH_TOKEN,GITHUB_TOKENor aghlogin;PAIL_GITHUB_APIpoints at GitHub Enterprise. Official providers:ArchAstro/pail-credential-{gcloud,gcs-downscoped,archdev}.getreads{"protocol":"pail-credential/1","store":…,"scheme":…}on stdin and prints{"token":…,"expires_at":…}(GCS, https),{"aws":{…},"expires_at":…}(S3) or a bare token.describe(optional) prints one line forpail auth list. - Choice:
[credential "<url-prefix>"] provider = <name>, most specific prefix first, then[credential];helper = <cmd>runs an uninstalled command.PAIL_CREDENTIAL_PROVIDER/PAIL_CREDENTIAL_HELPERoverride. - Cache: until a minute before
expires_at, 0600 in~/.cache/pail/credentials. - Scope is the provider's job.
gcs-downscopedshows how: it exchanges the user's token at Google STS for one bound by a Credential Access Boundary togs://<bucket>/<repo>/. On a real bucket, pail works fully with it, and the same token gets 403 writing or listing outside the prefix. - Proxy:
pail serve --store gs://acme-vcs --auth-command <cmd>serveshttps://host/<repo>(GET/HEAD/PUT with If-Match/If-None-Match, DELETE, list). The CAS contract tests pass through it. The auth command getsPAIL_AUTH_TOKEN,PAIL_AUTH_REPOandPAIL_AUTH_OP: exit 0 allows, 3 forbids (403), anything else rejects the token (401). Answers are cached a minute. On a 401, pail drops its cached credential, asks the provider again and retries once.
One policy (_policy:policy.json: readers, admins, gc, per-branch writers, approvals,
required checks) and three enforcement points, none stateful:
pail serve --auth-commandchecks every request against it (any backend).- A broker runs
pail policy token --as <principal>: a GCS token whose Credential Access Boundary encodes the policy, so GCS refusesrefs/mainto anyone but its writers. - A lander (
pail policy requirements main, thenpail land) is the only writer of protected branches and checks reviews and checks first.
pail policy show|set|check|requirements|boundary|token. How to build each piece, IAM setup
and STS limits: docs/protecting-branches.md.
Executables pail runs at fixed points, like Git's, but shaped by pail's model: a commit is published the moment it is made, so pre-commit and pre-push become one flow, and the publish check sees the exact commit before the single CAS that publishes it.
pail commit pre-commit ─▶ commit-msg $1 ─▶ [objects written] ─▶ pre-publish ─▶ CAS
─▶ post-rewrite replay (only if another writer landed after the check) ─▶ post-commit
pail land pre-publish ─▶ CAS ─▶ post-rewrite land
pail rebase pre-publish ─▶ CAS ─▶ post-rewrite rebase
switch, clone, worktree add post-checkout <prev> <new> 1
sync post-sync <old> <new>
| Hook | Args / stdin | Can refuse | Git counterpart |
|---|---|---|---|
pre-commit |
none; may restage with pail add |
yes | pre-commit |
commit-msg |
$1 = message file, may be edited |
yes | commit-msg |
pre-publish |
<branch> commit|land|rebase; stdin <branch> <old> <new> |
yes | pre-push |
post-commit |
none | no | post-commit |
post-rewrite |
replay|land|rebase; stdin <old> <new> lines |
no | post-rewrite |
post-checkout |
<prev> <new> 1 |
no | post-checkout |
post-sync |
<old> <new> (and ORIG_HEAD) |
no | post-merge |
- Where:
core.hooksPath(relative to the working copy, so checked-in.githooks-style dirs work); default<repo>/hooks; empty or/dev/nullturns hooks off. Only files named exactly after a hook and executable run, so helpers like_libcan sit beside them. - Skip:
pail commit -n/--no-verifyskips pre-commit, commit-msg and pre-publish;pail land --no-verifyandpail rebase --no-verifyskip pre-publish. post-* hooks always run and never fail a command. - Environment: the caller's, plus
PAIL_HOOK=<name>,PAIL_HOOK_BRANCH,PAIL_HOOK_WORKTREE, with the running pail first onPATH. Pail exports nothing that locates a repository, so a hook's build/test children cannot be retargeted at the real repository (theGIT_DIRproblem firstlanding's_git-scrubbedworks around). - Results: hook stdout+stderr go to pail's stderr. A refusing hook fails the command with
exit code 3;
PAIL_TRACE_HOOKS=<file>appends{"hook","args","exit","ms"}per run, so callers tell a hook refusal from any other failure without parsing text. - Replays: a commit on a stale base is built on the current head before the check, so pre-publish normally sees exactly what is published. Only a writer landing between the check and the CAS causes a replay; it is not re-checked (it adds only others' disjoint changes) and is reported through post-rewrite.
- Helpers for hook scripts:
pail diff --staged --name-only -z --diff-filter=AM,pail rev-parse --show-toplevel,pail rev-parse ORIG_HEAD.
.githooks/ |
In pail |
|---|---|
pre-commit (aster format → restage, lint, build, related tests) |
pre-commit; restage with pail add; staged list from pail diff --staged --name-only -z --diff-filter=ACMRT |
pre-push (changed archdev e2e tests, opt-in Dialyzer) |
pre-publish; base is $old from stdin instead of merge-base HEAD origin/main |
post-checkout, post-merge, post-rewrite → _clear-stale-expert |
post-checkout, post-sync, post-rewrite; pail diff --name-only <old> <new> |
_git-scrubbed |
not needed: pail exports no repository-locating variables |
archdev's post-receive on the daemon's bare mirror |
no server to run it; a watcher on the store's refs and reflog is the equivalent (not built yet) |
pail-bench imports a real snapshot (firstlanding: 28.9k files, 460 MB) once, then runs
each scenario on its own branch and counts time, requests and bytes uploaded. Git runs the same edits in
working copies pushing to a bare remote (concurrent writers retry with pull --rebase).
pail-bench --git-dir ~/.cache/pail-mirror/firstlanding.git --store s3://pail/bench-1 \
--work /tmp/bench [--only small,wide,writers,hot,stacks,bigfile,reads,wc] [--no-git] [--out r.json]The comparison that matters: the same edits through each CLI (git add/commit/push to a
private github.com repository, pail add/commit to a GCS bucket), N writers on one branch,
20 commits each, from a residential client in Phoenix (RTT ~90 ms to github.com, ~35 ms to
storage.googleapis.com), 2026-10-20.
| Writers, one branch | git → github.com | pail → GCS |
|---|---|---|
| 1 | 0.64–0.74 commits/s, p50 1.4 s | 2.0 commits/s, p50 0.45 s |
| 4 | 0.65–0.66 | 2.1 |
| 16 | 0.68–0.70 (7.9 pushes per commit, p95 44 s) | 3.6, p95 7.4 s, 0 failed |
Every pail commit reported as landed was checked on main afterwards (exactly once each). The library path, without a process per commit, reaches 7.1 commits/s with 16 writers. Both are dominated by round trips to distant regions; from a machine in the bucket's region they are tens of milliseconds each.
How a commit gets there:
uncontended [pages ∥ commit object ∥ packs] ──▶ CAS ref two round trips
(the working copy remembers the ref's version: no read first)
contended after two lost swaps, the writer PUTs an intent (its delta) under
intents/<branch>/; the writer of the oldest pending intent combines:
GET ref ∥ LIST intents → one commit per intent, in order, on the head
→ [all objects] → ONE CAS lands the batch, recording the intents in the
ref's `recent` list (so none lands twice); waiting writers watch the ref,
and take over if it stands still for 1.5 s
- Linear history, nothing squashed: each intent is its own commit; an intent whose paths changed since its base is skipped and its writer gets the conflict (exit 4).
- No starvation: a direct attempt lists pending intents while its objects upload, and joins the batch instead of racing past it. (Before this, 16 writers lost 5 of 320 commits to a 60 s give-up.)
- Remembered heads are exact: a commit that landed inside a batch does not become a known head (the ref's version belongs to the batch's tip); a regression test drives concurrent CLI writers and checks every reported commit is on main.
Earlier results (2026-10-20, laptop under load; pail on the local S3 emulator, Git to a local bare remote, so the Git push cost here is far below github.com's):
| Scenario | pail (S3 emulator) | Git (local bare remote) |
|---|---|---|
| Edit 2,000 files, one commit | 0.25 s, 53 PUTs | 4.2 s |
| Add 5,000 files | 0.27 s | 5.7 s |
| Delete 3,200-file dir / rename 2,100-file dir | 0.04 s / 0.05 s | 1.1 s / 0.8 s |
| Land 40 stacks while main moves, p50 | 118 ms | n/a |
| Change 1 MB of a 256 MB file | 3.8 MB uploaded | n/a |
log 240 commits / log -- path |
20 ms / 30 ms | n/a |
| Clone a working copy (29k files) | 22 s | 9.8 s (--shared) |
Notes:
- Writes are proportional to the change: a few pages per commit, content reused on rename.
- A clone writes each file twice (into the shared content cache, then a CoW clone into the worktree). That is the price of worktrees costing ~0 extra disk.
PAIL_TRACE_STORE=1prints every store request with its timing;PAIL_TRACE_COMBINE=1prints each combining round.
cargo test --release
# CAS contract against real object-store APIs:
PAIL_TEST_STORES="s3://pail/test" AWS_ENDPOINT=http://localhost:19000 AWS_ALLOW_HTTP=true \
AWS_ACCESS_KEY_ID=x AWS_SECRET_ACCESS_KEY=x AWS_REGION=us-east-1 cargo test --release --test backendsScenario tests (tests/scenarios/*.t) are shell transcripts of common Git workflows run in pail
(often beside Git itself): conflicts, feature branches, stacked PRs, interactive rebase.
PAIL_BLESS=1 cargo test --test scenarios records new output; see
tests/scenarios/README.md.
Publishing is also modelled in Quint (verify/): the model's properties are
checked over random interleavings of writers and lost CAS replies, and tests/refinement.rs
runs real clients through the model's scenarios one store request at a time. Store backends
and branch policy are checked against their models the same way (tests/store_refinement.rs,
tests/policy_refinement.rs).
MIT. See LICENSE.