OXYGENHUB

A shallow clone of a big repository works, and quickly (closes #137) 7bd805ffb418

A depth-0 clone now fetches the blobs of the state's binary files (or
leaves them lazy under --max-blob): no change it takes names them. The
checkpoint is stored in one batch instead of object by object.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 175cad1d596c
When
· 2026-10-25 00:53 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
7bd805ffb418c08c7e31385c5823b239cf46df3191bf326652ae31fa403cff77
Depends on
2863456a0f6d 75167672ebcd 8fd78eb52ad2 982864068a59 d3b270909c78 e4595038502a ff0af1ee299d
Depended on by
80b069facc69 b260109d27c5 f3811eb20bea 2aa1854b0d13
In bookmarks
identical-sides main push-notes subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
8 atoms, 1 tree operation, 5 files

Browse the tree at this change

added docs/lazy-clone.md +172 −0
1 +
# Instant clone: a lazy working copy (oxygen#132)
2 +
3 +
Status: design, 2026-10-25. Nothing here is built yet beyond what it names
4 +
as existing. An experimental `oxy clone --lazy` (Linux) follows if this
5 +
measures well (§9).
6 +
7 +
## 1. The aim
8 +
9 +
`oxy clone URL` of any repository, however big, gives a working copy in
10 +
about a second: `ls`, `cd`, editors and builds see every file at once, a
11 +
file's bytes arrive the first time something reads it, and `status`,
12 +
`diff`, `commit` and `push` work at once and cost what the edits cost, not
13 +
what the repository weighs. Git's partial clone and VFS for Git do part of
14 +
this (VFS needs Windows and a special server); Oxygen can do all of it
15 +
with what it already has, because every object is content addressed and
16 +
a state's tree is known without its file contents.
17 +
18 +
## 2. What exists
19 +
20 +
- **Shallow clones** (spec §17): `oxy clone --depth 0` takes a checkpoint
21 +
  of the bookmark's heads and no history. The `checkpoint` message brings
22 +
  the checkpoint, its payload blob and **every file's graph blob** (spec
23 +
  §11, payload version 2; `Repo::checkpoint_bundle_for`,
24 +
  crates/oxy-core/src/repo/checkpoint.rs). A file graph holds the file's
25 +
  current text and its tombstones, so this is the whole state's text and
26 +
  more: for VS Code most of the clone's bytes.
27 +
- **Lazy blobs** (spec §13.5, §17): `--max-blob N` leaves opaque blobs
28 +
  larger than N to be fetched with `objects` on first read; `status` and
29 +
  `commit` never need them.
30 +
- **Sparse filters** (`--sparse PATH`): only those paths are materialized.
31 +
- **flow-casfs** (OxygenFlow, `crates/flow-casfs`): a read-only FUSE mount
32 +
  of a state from a local repository. The tree comes from the state
33 +
  index; a text file is rendered from its graph on first read or stat and
34 +
  cached on disk by graph key; an opaque file is its blob.
35 +
- **Content addressing everywhere**: an object fetched later is checked
36 +
  against the hash that named it, so a lazily fetched byte is as
37 +
  trustworthy as one cloned eagerly (spec §13).
38 +
39 +
## 3. The design
40 +
41 +
`oxy clone --lazy URL DIR`:
42 +
43 +
1. **Refs and the checkpoint skeleton.** Fetch `refs` (with the bookmark's
44 +
   attestation, checked against the policy as any clone does), then the
45 +
   checkpoint of its heads **without its graph blobs**: a `checkpoint`
46 +
   request with a new `graphs: false` field (additive; an older server
47 +
   ignores it and sends them, so the clone is merely not lazy). The
48 +
   payload's `files` entries name each graph blob by ObjectRef (graphs
49 +
   under 2 KiB are inline and come anyway), and its `tree` gives every
50 +
   path, mode and node. Clone writes the repository as a shallow one at
51 +
   depth 0 whose missing graph blobs are **lazy objects**, like lazy blobs
52 +
   today.
53 +
2. **The mount.** DIR is a FUSE mount (lower layer) of that state, built on
54 +
   flow-casfs: the directory tree is answered from the payload's tree at
55 +
   once; `stat` of a text file needs its length, so the payload gains
56 +
   (payload version 3, writer-optional) each file's rendered length, and
57 +
   until a server writes it the length comes with the graph on first
58 +
   `stat`. A read of a file whose graph is not stored fetches it with
59 +
   `objects` (hash checked), renders it, keeps both, and answers. Opaque
60 +
   files fetch their blob the same way. A small daemon (`oxy mount`,
61 +
   started by the clone, restarted by any oxy command that finds it gone)
62 +
   serves the mount; the repository itself stays in `DIR/.oxy`, which is
63 +
   a real directory beside the mount (bind or an overlay entry), so oxy
64 +
   never reads through FUSE.
65 +
3. **The writable layer.** Writes go to an upper directory
66 +
   (`.oxy/overlay/`): a written file is copied up whole on first write
67 +
   (its bytes fetched first if they were never read), a new file is
68 +
   created there, a removal is a whiteout, a rename is a whiteout plus a
69 +
   copy up. This is the ordinary overlay model, done in the daemon so it
70 +
   needs no privileges (kernel overlayfs over FUSE needs a user namespace
71 +
   and is not available everywhere).
72 +
4. **Status, diff and commit read the overlay only.** The upper layer is
73 +
   exactly the set of paths that may differ from the base state, so
74 +
   `status` lists it (comparing each copied-up file's bytes with the
75 +
   base rendering, which is stored, since copying up read it) and never
76 +
   walks the tree. `diff` and `commit` do the same, and the changes they
77 +
   build need only the touched files' graphs, all stored by then.
78 +
   `commit` clears the overlay entries it recorded: the mount's base
79 +
   state moves to the new heads.
80 +
5. **Fetch, pull, checkout.** A fetch brings changes as today (their
81 +
   blobs lazy). Moving the working copy to another state swaps the
82 +
   mount's base: the new state's tree is known from the changes and the
83 +
   stored checkpoint, the overlay is kept when its paths are unchanged
84 +
   between the two states (the `--carry` rule, §10.1), and graphs needed
85 +
   to render the new state are lazy again.
86 +
6. **Push.** As today: the pushed changes and the blobs they bring. A
87 +
   change built on a lazy base never needs the base's unread files.
88 +
89 +
## 4. Offline and failures
90 +
91 +
A file read once is kept, so it reads offline. Reading a file never read
92 +
while the hub cannot be reached fails with `EIO`, and the daemon logs the
93 +
path and the reason; `oxy status` says how many files are not local yet.
94 +
`oxy lazy fetch [PATH..]` brings them in ahead (a directory, the whole
95 +
state, or the files a build reads, as a background job), and `oxy lazy
96 +
materialize` turns the working copy into an ordinary one (unmounted, every
97 +
file written), after which nothing is lazy. A hash that does not match is
98 +
an error for that read, never a wrong byte.
99 +
100 +
## 5. Without FUSE
101 +
102 +
Where FUSE is not available (macOS without macFUSE, Windows, a container
103 +
without /dev/fuse), `--lazy` falls back to what exists: a depth-0 clone
104 +
with lazy blobs, and `--sparse` paths when given, saying so. Windows
105 +
ProjFS and macOS File Provider are later backends behind the same daemon
106 +
interface (lookup, read, write-through to the upper layer).
107 +
108 +
## 6. The protocol
109 +
110 +
- `checkpoint` gains `graphs: false`: leave out graph blobs (and their
111 +
  chunks) the payload names by reference.
112 +
- `objects` already fetches any object by hash, with a blob's chunks; it
113 +
  needs batching limits only (as `push.batch-bytes` bounds pushes).
114 +
- Optional later: `render` by graph key (the rendered bytes rather than
115 +
  the graph with its tombstones), smaller for long-lived files; a client
116 +
  checks it by rendering the graph when it has it, so it is an
117 +
  optimization a client may refuse.
118 +
- A host should keep `objects` cheap: it is now on the read path of
119 +
  editors and builds. OxygenHub's state cache and bucket stores (Strata)
120 +
  already serve objects by hash.
121 +
122 +
## 7. Trust
123 +
124 +
Nothing is weaker than an eager clone. The clone checks the bookmark's
125 +
attestation and the checkpoint against its heads (a checkpoint is valid
126 +
only if it reproduces from its heads, spec §11, which a lazy client cannot
127 +
check without the graphs: it trusts the attested heads and checks each
128 +
graph it fetches against the checkpoint's reference). `oxy verify` on a
129 +
lazy clone checks what is stored and says how much is not.
130 +
131 +
## 8. Not in scope
132 +
133 +
History (`log`, `blame` beyond the checkpoint) on a lazy clone fetches
134 +
changes on demand later (`oxy deepen` exists); the first version answers
135 +
`log` from the checkpoint's change list as a shallow clone does.
136 +
137 +
## 9. Measurements
138 +
139 +
On Earth, with the VS Code history (`~/bench/vscode-full9.oxy`, 166k
140 +
changes, served by `oxy serve` on the LAN so the network is the same for
141 +
every case), each timed cold (`echo 3 > drop_caches` not available: a
142 +
fresh clone directory each time, and the server warm), median of three:
143 +
144 +
| Step | git clone | oxy clone | oxy clone --depth 0 | oxy clone --lazy |
145 +
|---|---|---|---|---|
146 +
| clone | | | | |
147 +
| `ls -R \| wc -l` | | | | |
148 +
| read one file (`src/vs/code/electron-main/main.ts`) | | | | |
149 +
| edit it, `status` | | | | |
150 +
| `diff` | | | | |
151 +
| `commit` | | | | |
152 +
| `push` (to a fresh bare repo) | | | | |
153 +
| bytes over the wire | | | | |
154 +
| disk after | | | | |
155 +
156 +
Git is `git clone --no-local` from a `git daemon` of `~/bench/vscode.git`
157 +
(and `--filter=blob:none` as a second row, Git's own lazy clone). The
158 +
first three oxy columns can be measured now; they set the bar the
159 +
prototype must clear: the lazy clone should finish in seconds and its
160 +
first `status` in milliseconds, and nothing else may get slower.
161 +
162 +
## 10. The prototype (L2)
163 +
164 +
1. `checkpoint` with `graphs: false` in oxy-core and oxy-server, and a
165 +
   repository flag for lazy graph objects (fetch on load, like lazy blobs).
166 +
2. `oxy mount` (Linux, `fuser`, as flow-casfs) with the upper layer, in a
167 +
   new crate `oxy-mount` beside the CLI, behind a cargo feature so builds
168 +
   without FUSE are unaffected.
169 +
3. `status`/`diff`/`commit` from the overlay when the working copy is
170 +
   mounted.
171 +
4. `oxy clone --lazy`, `oxy lazy fetch`, `oxy lazy materialize`.
172 +
5. Measured as in §9; numbers on oxygen#132.
modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **A shallow clone of a big repository works, and quickly (#137).** `oxy clone --depth 0` of the VS Code history failed after eleven minutes ("object … not found"): the blobs of the state's binary files are named only by the changes that set them, which a depth-0 clone does not take, so nothing brought them. They are now fetched with the checkpoint (in parts), or left to be fetched on read under `--max-blob`. The checkpoint itself was stored object by object, each written loose and flushed (57,498 objects at 27 a second); it is now stored in one batch, as a fetch's objects are.
5 6
- **A push no longer passes a proxy's request limit (#133).** A push to a hub behind Cloudflare, which takes at most 100 MB in one request, was refused `HTTP 413` with Cloudflare's HTML page printed raw: parts of a long push were about 256 MiB, and one change's blobs went in one request however many there were. Parts are now about 64 MiB (`push.batch-bytes`), a part whose blobs pass that sends them first in parts of objects alone, an HTML error page is shown by its title, and a 413 says how to push in smaller parts.
6 7
- **A keyless agent's grant survives its owner's key rotation (#135).** Every delegation an owner adds or removes renews their identity, and a repository that names the newest key as `author.key` read every change their agent sealed as INVALID ("key … is neither the author nor a delegate"), and its pushes were refused, because the agent's grant was signed by the owner's earlier key. A grant by a key the author's identity was rotated or renewed from now counts when it was made before that rotation; one an old key made after it does not.
7 8
- **A tag can be withdrawn, by a signed withdrawal (oxygenhub#301).** There was no way to remove a tag anywhere: a mistaken release tag stayed for good. `oxy tag delete NAME [-r REASON]` records an `untag` attestation naming the tag's state, signed like a tag (a ten-minute keyless key too), and the next `oxy push` sends it to a remote that still has the tag; `oxy push --delete tags/NAME` does both at once. A receiver under a policy accepts it only from a key that may create the tag, keeps it beside the tag's creation, and lists withdrawn tags in its refs answer (key 8), so a `fetch` withdraws the tag there too (`tag v1 was withdrawn on origin, and here`). A withdrawn name is never used again, so a tag name never names two states. `oxy-server`'s `Backend` gains `vet_tag_withdrawal` (OxygenHub refuses while a release uses the tag), and `Repo::host_tag_create` and `host_tag_withdrawal` let a host make or withdraw a tag for a signed-in user. The wire change is additive, but an older server refuses a withdrawal (wrong attestation kind), and an older client keeps a withdrawn tag.
…
258 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +52 −7
…
1849 unchanged lines
1850 1850
        }
1851 1851
        // Base: the remote's checkpoint of exactly these heads.
1852 1852
        let cp_bundle = remote.checkpoint(&r.heads)?;
1853 -
        let mut cp_ref = None;
1854 -
        for (ty, body) in &cp_bundle.objects {
1855 -
            let put = self.store.put(*ty, body)?;
1856 -
            if *ty == ObjectType::Checkpoint {
1857 -
                cp_ref = Some(put);
1858 -
            }
1859 -
        }
1853 +
        // In one batch: a big state's checkpoint is tens of thousands of
1854 +
        // graph blobs and chunks, which stored one by one were each written
1855 +
        // loose and flushed (a VS Code clone ran at 27 objects a second).
1856 +
        let objects: Vec<(ObjectType, &[u8])> = cp_bundle
1857 +
            .objects
1858 +
            .iter()
1859 +
            .map(|(ty, body)| (*ty, body.as_slice()))
1860 +
            .collect();
1861 +
        let batch = self.store.begin_batch()?;
1862 +
        let stored = self.store.put_many(&objects);
1863 +
        batch.finish()?;
1864 +
        let cp_ref = objects
1865 +
            .iter()
1866 +
            .zip(stored?)
1867 +
            .find(|((ty, _), _)| *ty == ObjectType::Checkpoint)
1868 +
            .map(|(_, r)| r);
1860 1869
        let Some(cp_ref) = cp_ref else {
1861 1870
            return fail(format!("{remote_name} sent no checkpoint"));
1862 1871
        };
1863 1872
        self.install_checkpoint(&bookmark, &cp_ref)?;
1873 +
        // The blobs of the state's opaque files come with no change when the
1874 +
        // window holds none of the changes that set them (#137): fetch them
1875 +
        // now, or under `filter.max-blob` leave them to be fetched on read.
1876 +
        let cp = crate::objects::Checkpoint::decode(
1877 +
            &self.store.get_typed(&cp_ref, ObjectType::Checkpoint)?,
1878 +
        )?;
1879 +
        let base = State::from_checkpoint_history(&self.read_blob(&cp.payload)?)?;
1880 +
        let mut missing: Vec<ObjectRef> = base
1881 +
            .opaque_nodes()
1882 +
            .iter()
1883 +
            .flat_map(|n| base.live_blobs(n))
1884 +
            .filter(|b| !self.store.has(b))
1885 +
            .collect();
1886 +
        missing.sort();
1887 +
        missing.dedup();
1888 +
        if self.config.max_blob.is_some() {
1889 +
            for b in &missing {
1890 +
                self.register_lazy(b, remote_name)?;
1891 +
            }
1892 +
        } else {
1893 +
            for part in missing.chunks(512) {
1894 +
                let objects = remote.objects(part)?;
1895 +
                let objects: Vec<(ObjectType, &[u8])> = objects
1896 +
                    .iter()
1897 +
                    .filter(|(ty, _)| matches!(ty, ObjectType::Blob | ObjectType::Chunk))
1898 +
                    .map(|(ty, body)| (*ty, body.as_slice()))
1899 +
                    .collect();
1900 +
                let batch = self.store.begin_batch()?;
1901 +
                let stored = self.store.put_many(&objects);
1902 +
                batch.finish()?;
1903 +
                stored?;
1904 +
            }
1905 +
            if let Some(b) = missing.iter().find(|b| !self.store.has(b)) {
1906 +
                return fail(format!("{remote_name} did not send blob {}", b.short(12)));
1907 +
            }
1908 +
        }
1864 1909
        // History window.
1865 1910
        let wanted = remote.shallow(&r.heads, depth)?;
1866 1911
        let bundle = remote.fetch(&wanted, self.config.max_blob)?;
…
870 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +42 −0
…
3024 unchanged lines
3025 3025
    let _ = h1;
3026 3026
}
3027 3027
3028 +
/// #137: a depth-0 clone takes no change, so the blobs of the state's
3029 +
/// binary files came with nothing and the checkout failed ("object … not
3030 +
/// found"); they are fetched with the checkpoint, or left lazy under
3031 +
/// `--max-blob`.
3028 3032
#[test]
3033 +
fn a_depth_0_clone_brings_its_binary_files() {
3034 +
    hermetic();
3035 +
    let dir = tempfile::tempdir().unwrap();
3036 +
    let root = dir.path();
3037 +
    let origin_dir = root.join("origin");
3038 +
    let origin = Repo::init(&origin_dir)
3039 +
        .unwrap()
3040 +
        .with_home(root.join("home"));
3041 +
    let png: Vec<u8> = (0..4000u32)
3042 +
        .map(|i| (i * 7 % 251) as u8)
3043 +
        .chain([0, 0xff])
3044 +
        .collect();
3045 +
    // The logo comes with a.txt's first line, and the head edits that line,
3046 +
    // so the change that set the logo is below the head, not a head itself.
3047 +
    write(&origin_dir, "logo.png", &png);
3048 +
    write(&origin_dir, "a.txt", b"first\n");
3049 +
    origin.commit("logo", &[]).unwrap();
3050 +
    write(&origin_dir, "a.txt", b"later\n");
3051 +
    origin.commit("later", &[]).unwrap();
3052 +
    assert_eq!(origin.bookmark_heads().unwrap().len(), 1);
3053 +
    let url = origin_dir.to_string_lossy().to_string();
3054 +
    Repo::clone_shallow(&url, &root.join("a"), 0, None, &[]).unwrap();
3055 +
    assert_eq!(fs::read(root.join("a/logo.png")).unwrap(), png);
3056 +
    let lazy = Repo::clone_shallow(&url, &root.join("b"), 0, Some(0), &[]).unwrap();
3057 +
    assert_eq!(fs::read(root.join("b/a.txt")).unwrap(), b"later\n");
3058 +
    let blob = {
3059 +
        let heads = lazy.bookmark_heads().unwrap();
3060 +
        let st = lazy.load_state_history(&heads).unwrap();
3061 +
        st.opaque_nodes()
3062 +
            .iter()
3063 +
            .flat_map(|n| st.live_blobs(n))
3064 +
            .next()
3065 +
            .unwrap()
3066 +
    };
3067 +
    assert_eq!(lazy.read_blob(&blob).unwrap(), png, "fetched on read");
3068 +
}
3069 +
3070 +
#[test]
3029 3071
fn shallow_clone_and_deepen() {
3030 3072
    hermetic();
3031 3073
    let dir = tempfile::tempdir().unwrap();
…
6981 unchanged lines
modified crates/oxy-core/src/state.rs +7 −0
…
1889 unchanged lines
1890 1890
            .collect()
1891 1891
    }
1892 1892
1893 +
    /// The nodes with replace records: opaque files, as their blobs go.
1894 +
    pub fn opaque_nodes(&self) -> Vec<NodeId> {
1895 +
        let mut nodes: Vec<NodeId> = self.replaces.keys().copied().collect();
1896 +
        nodes.sort();
1897 +
        nodes
1898 +
    }
1899 +
1893 1900
    /// Live blobs of an opaque node: every `new` that no later `Replace` retired.
1894 1901
    pub fn live_blobs(&self, node: &NodeId) -> Vec<ObjectRef> {
1895 1902
        let mut live: Vec<ObjectRef> = self
…
1048 unchanged lines