OXYGENHUB

Lazy blobs: clone --max-blob / filter.max-blob, absent-blob registry fetched on first read, objects endpoint (spec 0.6e) b3c5a59a875a

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 12:56 UTC
Signature
✔ signed by Andy McCall
Hash
b3c5a59a875ad8c743bf5622a247d362654f96581948c75a3af2ec8082f76073
Depends on
096f447ccda0 135212de5370 252e8cb23fbc 2b5ee371060e 5b9c6ad45f6e 750251b21a96 83e0677b07fe 982864068a59 ba160f60219e cfb7d414eae6 fce44ae9ad29 ff0af1ee299d
Depended on by
2f0b4b0651ea 80b069facc69 570e35310f31 e30c081a5064 f3811eb20bea d3b270909c78 d36ba2f79f95 cb8b8082fa73 d069701a4997 827281eed2ed 14edbaaf5291 ff1f62fee654 091fef80755d a07f60a37fb2 2c75672fb2fe afaba5f1306d d42369abf180 3e5772ccf9da 387335ff7f4d 75167672ebcd e02bf0cd0533 6d3eefbce2f0 2863456a0f6d 7421309f5372
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
44 atoms, 0 tree operations, 10 files

Browse the tree at this change

modified crates/oxy-core/src/protocol.rs +34 −5
…
87 unchanged lines
88 88
    fn refs(&self) -> Result<RefsResponse>;
89 89
    /// Changes in the closure of `want` but not of `have`, in dependency order.
90 90
    fn negotiate(&self, have: &[ChangeHash], want: &[ChangeHash]) -> Result<Vec<ChangeHash>>;
91 -
    fn fetch(&self, changes: &[ChangeHash]) -> Result<Bundle>;
91 +
    /// The changes with their signatures, seals, identities, and blobs; blobs
92 +
    /// larger than `max_blob` bytes are left out for lazy fetching (spec §13.5).
93 +
    fn fetch(&self, changes: &[ChangeHash], max_blob: Option<u64>) -> Result<Bundle>;
94 +
    /// Individual objects by reference (spec §16.1 `objects`); a blob comes
95 +
    /// with its chunks.
96 +
    fn objects(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>>;
92 97
    /// Push changes, bookmark updates, and optionally a newer policy. The
93 98
    /// policy is applied first; a result named `policy` reports it.
94 99
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>>;
…
22 unchanged lines
117 122
    fn negotiate(&self, have: &[ChangeHash], want: &[ChangeHash]) -> Result<Vec<ChangeHash>> {
118 123
        self.repo.negotiate(have, want)
119 124
    }
120 -
    fn fetch(&self, changes: &[ChangeHash]) -> Result<Bundle> {
121 -
        self.repo.bundle_for(changes)
125 +
    fn fetch(&self, changes: &[ChangeHash], max_blob: Option<u64>) -> Result<Bundle> {
126 +
        self.repo.bundle_for_filtered(changes, max_blob)
127 +
    }
128 +
    fn objects(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>> {
129 +
        self.repo.objects_for(refs)
122 130
    }
123 131
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>> {
124 132
        self.repo.receive_push(bundle, updates, policy)
…
85 unchanged lines
210 218
    fn negotiate(&self, have: &[ChangeHash], want: &[ChangeHash]) -> Result<Vec<ChangeHash>> {
211 219
        wire::decode_hashes(&self.call(true, "negotiate", Some(&wire::encode_negotiate(have, want)))?)
212 220
    }
213 -
    fn fetch(&self, changes: &[ChangeHash]) -> Result<Bundle> {
214 -
        wire::decode_bundle(&self.call(true, "fetch", Some(&wire::encode_hashes(changes)))?)
221 +
    fn fetch(&self, changes: &[ChangeHash], max_blob: Option<u64>) -> Result<Bundle> {
222 +
        wire::decode_bundle(&self.call(true, "fetch", Some(&wire::encode_fetch(changes, max_blob)))?)
215 223
    }
224 +
    fn objects(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>> {
225 +
        Ok(wire::decode_bundle(&self.call(true, "objects", Some(&wire::encode_hashes(refs)))?)?.objects)
226 +
    }
216 227
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>> {
217 228
        wire::decode_push_response(&self.call(true, "push", Some(&wire::encode_push(&bundle, &updates, policy.as_ref())))?)
218 229
    }
…
190 unchanged lines
409 420
        Ok((hashes_from(field(m, 0, "have")?)?, hashes_from(field(m, 1, "want")?)?))
410 421
    }
411 422
423 +
    /// `fetch` request: `{0: [changes], 1: max blob size or null}`.
424 +
    pub fn encode_fetch(hs: &[ChangeHash], max_blob: Option<u64>) -> Vec<u8> {
425 +
        cbor::encode(&Value::map([(0, hashes_value(hs)), (1, max_blob.map(Value::Uint).unwrap_or(Value::Null))]))
426 +
    }
427 +
    pub fn decode_fetch(bytes: &[u8]) -> Result<(Vec<ChangeHash>, Option<u64>)> {
428 +
        let v = cbor::decode(bytes).map_err(ObjectError::from)?;
429 +
        let m = map(&v, "fetch")?;
430 +
        let max = match m.get(&1) {
431 +
            Some(x) if !x.is_null() => Some(o::uint(x, "max blob")?),
432 +
            _ => None,
433 +
        };
434 +
        Ok((hashes_from(field(m, 0, "changes")?)?, max))
435 +
    }
436 +
412 437
    pub fn encode_hashes(hs: &[ChangeHash]) -> Vec<u8> {
413 438
        cbor::encode(&Value::map([(0, hashes_value(hs))]))
414 439
    }
…
117 unchanged lines
532 557
        let (h, w) = wire::decode_negotiate(&wire::encode_negotiate(&[ObjectRef([1; 32])], &[ObjectRef([2; 32])])).unwrap();
533 558
        assert_eq!((h, w), (vec![ObjectRef([1; 32])], vec![ObjectRef([2; 32])]));
534 559
        assert_eq!(wire::decode_hashes(&wire::encode_hashes(&[c.hash()])).unwrap(), vec![c.hash()]);
560 +
        assert_eq!(wire::decode_fetch(&wire::encode_fetch(&[c.hash()], Some(4096))).unwrap(), (vec![c.hash()], Some(4096)));
561 +
        assert_eq!(wire::decode_fetch(&wire::encode_fetch(&[], None)).unwrap(), (vec![], None));
562 +
        // An old-style fetch request without the filter field still decodes.
563 +
        assert_eq!(wire::decode_fetch(&wire::encode_hashes(&[c.hash()])).unwrap(), (vec![c.hash()], None));
535 564
536 565
        let up = RefUpdate { name: "main".into(), old_state: ObjectRef([1; 32]), new_state: ObjectRef([2; 32]), force: true, attestation: Some(SignedAttest { attest: att, sigs: vec![sig] }) };
537 566
        let sp = SignedPolicy { policy, sigs: vec![psig] };
…
11 unchanged lines
modified crates/oxy-core/src/repo/config.rs +17 −0
…
28 unchanged lines
29 29
    pub gc_grace_days: u64,
30 30
    /// `core.checkpoint-interval`: changes between automatic checkpoints (spec §11).
31 31
    pub checkpoint_interval: u64,
32 +
    /// `filter.max-blob`: blobs larger than this many bytes are fetched lazily (spec §17).
33 +
    pub max_blob: Option<u64>,
32 34
}
33 35
34 36
impl Config {
…
12 unchanged lines
47 49
            op_retention_days: 90,
48 50
            gc_grace_days: 14,
49 51
            checkpoint_interval: 256,
52 +
            max_blob: None,
50 53
        }
51 54
    }
52 55
…
45 unchanged lines
98 101
            "workspace.bookmark" => self.bookmark = value.into(),
99 102
            "core.op-retention" => self.op_retention_days = parse_days(value)?,
100 103
            "core.gc-grace" => self.gc_grace_days = parse_days(value)?,
104 +
            "filter.max-blob" => self.max_blob = if value.is_empty() { None } else { Some(parse_size(value)?) },
101 105
            "core.checkpoint-interval" => {
102 106
                self.checkpoint_interval = value.parse().map_err(|_| RepoError::Msg(format!("expected a number, got {value:?}")))?;
103 107
            }
…
52 unchanged lines
156 160
    }
157 161
}
158 162
163 +
/// A byte count with an optional `K`, `M`, or `G` suffix (binary units).
164 +
pub fn parse_size(v: &str) -> Result<u64> {
165 +
    let v = v.trim();
166 +
    let (num, mult) = match v.chars().last() {
167 +
        Some('K' | 'k') => (&v[..v.len() - 1], 1u64 << 10),
168 +
        Some('M' | 'm') => (&v[..v.len() - 1], 1u64 << 20),
169 +
        Some('G' | 'g') => (&v[..v.len() - 1], 1u64 << 30),
170 +
        _ => (v, 1),
171 +
    };
172 +
    let n: u64 = num.trim().parse().map_err(|_| RepoError::Msg(format!("expected a size like 1M, got {v:?}")))?;
173 +
    Ok(n * mult)
174 +
}
175 +
159 176
/// A count of days, optionally suffixed `d`.
160 177
fn parse_days(v: &str) -> Result<u64> {
161 178
    v.trim_end_matches('d').parse().map_err(|_| RepoError::Msg(format!("expected a number of days, got {v:?}")))
…
26 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +6 −1
…
65 unchanged lines
66 66
mod tests;
67 67
68 68
pub use checkout::CheckoutReport;
69 -
pub use config::{read_config_file, Config};
69 +
pub use config::{parse_size, read_config_file, Config};
70 70
pub use gc::GcReport;
71 71
pub use prompt::{PromptInfo, DEFAULT_PROMPT_FORMAT};
72 72
pub use git::{GitExportReport, GitImportReport};
…
373 unchanged lines
446 446
        Ok(self.store.put(ObjectType::Blob, &Self::blob_for(content).encode())?)
447 447
    }
448 448
449 +
    /// Read a blob's content, fetching it from the remote that withheld it
450 +
    /// when it is lazily absent (spec §13.5).
449 451
    pub fn read_blob(&self, r: &ObjectRef) -> Result<Vec<u8>> {
452 +
        if !self.store.has(r) && self.lazy_remote(r).is_some() {
453 +
            self.fetch_lazy_blob(r)?;
454 +
        }
450 455
        let blob = Blob::decode(&self.store.get_typed(r, ObjectType::Blob)?)?;
451 456
        let mut out = Vec::with_capacity(blob.length as usize);
452 457
        for c in &blob.chunks {
…
137 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +98 −2
…
103 unchanged lines
104 104
    /// Bundle changes (in the given dependency order) with their signatures,
105 105
    /// seals, identities, blobs, chunks.
106 106
    pub fn bundle_for(&self, changes: &[ChangeHash]) -> Result<Bundle> {
107 +
        self.bundle_for_filtered(changes, None)
108 +
    }
109 +
110 +
    /// Objects by reference for the `objects` endpoint; a blob brings its chunks.
111 +
    pub fn objects_for(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>> {
112 +
        let mut out = Vec::new();
113 +
        for r in refs {
114 +
            let (ty, body) = match self.store.get(r) {
115 +
                Ok(x) => x,
116 +
                Err(StoreError::NotFound(_)) => continue,
117 +
                Err(e) => return Err(e.into()),
118 +
            };
119 +
            if ty == ObjectType::Blob {
120 +
                for c in Blob::decode(&body)?.chunks {
121 +
                    if let Ok((cty, cb)) = self.store.get(&c) {
122 +
                        out.push((cty, cb));
123 +
                    }
124 +
                }
125 +
            }
126 +
            out.push((ty, body));
127 +
        }
128 +
        Ok(out)
129 +
    }
130 +
131 +
    /// Like `bundle_for`, leaving out blobs larger than `max_blob` bytes
132 +
    /// (and their chunks) so the receiver fetches them lazily (spec §13.5).
133 +
    pub fn bundle_for_filtered(&self, changes: &[ChangeHash], max_blob: Option<u64>) -> Result<Bundle> {
107 134
        let mut bundle = Bundle::default();
108 135
        let mut objects: BTreeSet<ObjectRef> = BTreeSet::new();
109 136
        let mut seals: BTreeSet<ObjectRef> = BTreeSet::new();
…
13 unchanged lines
123 150
                if let Edit::Replace { old, new, .. } = e {
124 151
                    for b in [old, new].into_iter().flatten() {
125 152
                        if self.store.has(b) {
126 -
                            objects.insert(*b);
127 153
                            if let Ok(blob) = Blob::decode(&self.store.get_typed(b, ObjectType::Blob)?) {
154 +
                                if max_blob.is_some_and(|m| blob.length > m) {
155 +
                                    continue;
156 +
                                }
157 +
                                objects.insert(*b);
128 158
                                objects.extend(blob.chunks.iter().copied());
129 159
                            }
130 160
                        }
…
239 unchanged lines
370 400
        write_atomic(&p, serial.to_string().as_bytes())
371 401
    }
372 402
403 +
    fn lazy_dir(&self) -> PathBuf {
404 +
        self.oxy.join("lazy")
405 +
    }
406 +
407 +
    /// Remember that `blob` was withheld by `remote` (spec §13.5).
408 +
    fn register_lazy(&self, blob: &ObjectRef, remote: &str) -> Result<()> {
409 +
        fs::create_dir_all(self.lazy_dir())?;
410 +
        write_atomic(&self.lazy_dir().join(blob.to_hex()), remote.as_bytes())
411 +
    }
412 +
413 +
    /// Which remote promised an absent blob, if any.
414 +
    pub fn lazy_remote(&self, blob: &ObjectRef) -> Option<String> {
415 +
        fs::read_to_string(self.lazy_dir().join(blob.to_hex())).ok().map(|s| s.trim().to_string())
416 +
    }
417 +
418 +
    /// Fetch an absent blob (and its chunks) from the remote that promised it.
419 +
    pub(crate) fn fetch_lazy_blob(&self, blob: &ObjectRef) -> Result<()> {
420 +
        let Some(remote_name) = self.lazy_remote(blob) else {
421 +
            return Err(RepoError::Store(StoreError::NotFound(*blob)));
422 +
        };
423 +
        let remote = self.open_named_remote(&remote_name)?;
424 +
        let objects = remote.objects(&[*blob])?;
425 +
        for (ty, body) in objects {
426 +
            if matches!(ty, ObjectType::Blob | ObjectType::Chunk) {
427 +
                self.store.put(ty, &body)?;
428 +
            }
429 +
        }
430 +
        if !self.store.has(blob) {
431 +
            return fail(format!("{remote_name} did not send blob {}", blob.short(12)));
432 +
        }
433 +
        let _ = fs::remove_file(self.lazy_dir().join(blob.to_hex()));
434 +
        Ok(())
435 +
    }
436 +
437 +
    /// After importing from `remote`, record every blob the changes reference
438 +
    /// that the store lacks as lazily available from that remote.
439 +
    fn register_missing_blobs(&self, changes: &[ChangeHash], remote: &str) -> Result<usize> {
440 +
        let mut n = 0;
441 +
        for h in changes {
442 +
            let c = self.store.get_change(h)?;
443 +
            for e in &c.edits {
444 +
                if let Edit::Replace { old, new, .. } = e {
445 +
                    for b in [old, new].into_iter().flatten() {
446 +
                        if !self.store.has(b) {
447 +
                            self.register_lazy(b, remote)?;
448 +
                            n += 1;
449 +
                        }
450 +
                    }
451 +
                }
452 +
            }
453 +
        }
454 +
        Ok(n)
455 +
    }
456 +
373 457
    /// Every head this repository knows: bookmarks, remote tracking, working copy.
374 458
    fn known_heads(&self, view: &View) -> Result<Vec<ChangeHash>> {
375 459
        let mut have: BTreeSet<ChangeHash> = BTreeSet::new();
…
52 unchanged lines
428 512
        let mut view = op.view.clone();
429 513
        let have = self.known_heads(&view)?;
430 514
        let mut report = Vec::new();
515 +
        let max_blob = self.config.max_blob;
431 516
        for r in resp.refs {
432 517
            let needed = remote.negotiate(&have, &r.heads)?;
433 -
            let bundle = remote.fetch(&needed)?;
518 +
            let bundle = remote.fetch(&needed, max_blob)?;
434 519
            // Changes are not individually re-verified here: the bookmark's
435 520
            // attestation (checked below) binds its whole state, and the
436 521
            // receiver of the push verified the added changes at that time.
437 522
            let imported = self.import_bundle(&bundle, false)?;
523 +
            if max_blob.is_some() {
524 +
                self.register_missing_blobs(&imported, remote_name)?;
525 +
            }
438 526
            for h in &r.heads {
439 527
                if !self.store.has(h) {
440 528
                    return fail(format!("remote did not send head {}", h.short(12)));
…
174 unchanged lines
615 703
    /// Clone a remote into `dest`: same repository ID, `origin` remote,
616 704
    /// bookmark set to the remote's, working copy checked out.
617 705
    pub fn clone(url: &str, dest: &Path) -> Result<Repo> {
706 +
        Self::clone_with(url, dest, None)
707 +
    }
708 +
709 +
    /// Clone, leaving blobs above `max_blob` bytes to be fetched on first read.
710 +
    pub fn clone_with(url: &str, dest: &Path, max_blob: Option<u64>) -> Result<Repo> {
618 711
        let options = RemoteOptions {
619 712
            token: std::env::var("OXY_TOKEN").ok(),
620 713
            ca: std::env::var("OXY_CA").ok().map(PathBuf::from),
…
2 unchanged lines
623 716
        let id = remote.repo_id()?;
624 717
        let mut repo = Repo::init_with_id(dest, id)?;
625 718
        repo.config.set("remote.origin.url", url)?;
719 +
        if let Some(m) = max_blob {
720 +
            repo.config.set("filter.max-blob", &m.to_string())?;
721 +
        }
626 722
        repo.save_config()?;
627 723
        repo.fetch("origin")?;
628 724
        let (op_ref, op) = repo.head_op()?;
…
11 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +52 −0
…
1158 unchanged lines
1159 1159
    assert_eq!(info.format("%A%D"), "-1");
1160 1160
    let _ = origin;
1161 1161
}
1162 +
1163 +
#[test]
1164 +
fn lazy_blobs_are_fetched_on_first_read() {
1165 +
    hermetic();
1166 +
    let dir = tempfile::tempdir().unwrap();
1167 +
    let root = dir.path();
1168 +
    let origin_dir = root.join("origin");
1169 +
    let origin = Repo::init(&origin_dir).unwrap().with_home(root.join("home"));
1170 +
    let url = origin_dir.to_string_lossy().to_string();
1171 +
    let big: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect(); // has NUL bytes: opaque
1172 +
    write(&origin_dir, "big.bin", &big);
1173 +
    write(&origin_dir, "small.bin", &[0u8, 1, 2]);
1174 +
    write(&origin_dir, "t.txt", b"t\n");
1175 +
    let h = origin.commit("blobs", &[]).unwrap();
1176 +
    let big_ref = Repo::blob_ref(&big);
1177 +
1178 +
    // Fetch only (no checkout) with a filter: the big blob stays absent.
1179 +
    let a = Repo::init_with_id(&root.join("a"), origin.id().unwrap()).unwrap().with_home(root.join("home"));
1180 +
    let a = {
1181 +
        let mut a = a;
1182 +
        a.config.set("remote.origin.url", &url).unwrap();
1183 +
        a.config.set("filter.max-blob", "100K").unwrap();
1184 +
        a.save_config().unwrap();
1185 +
        a
1186 +
    };
1187 +
    assert_eq!(a.config.max_blob, Some(100 * 1024));
1188 +
    a.fetch("origin").unwrap();
1189 +
    assert!(a.store().has(&h));
1190 +
    assert!(!a.store().has(&big_ref), "withheld");
1191 +
    assert!(a.store().has(&Repo::blob_ref(&[0u8, 1, 2])), "small blob came along");
1192 +
    assert_eq!(a.lazy_remote(&big_ref).as_deref(), Some("origin"));
1193 +
1194 +
    // status and commit do not need it: adopt the state, edit a text file, commit.
1195 +
    let (op_ref, op) = a.head_op().unwrap();
1196 +
    let mut view = op.view.clone();
1197 +
    view.bookmarks.insert("main".into(), a.store().put_state(&StateObject::new(vec![h])).unwrap());
1198 +
    a.record_op(&[op_ref], "adopt", view).unwrap();
1199 +
    // Checkout reads the blob lazily, which fetches it.
1200 +
    a.checkout(&[h], true, "checkout").unwrap();
1201 +
    assert!(a.store().has(&big_ref), "fetched on first read");
1202 +
    assert_eq!(fs::read(root.join("a/big.bin")).unwrap(), big);
1203 +
    assert!(a.lazy_remote(&big_ref).is_none(), "no longer registered");
1204 +
    write(&root.join("a"), "t.txt", b"t2\n");
1205 +
    a.commit("text edit", &[]).unwrap();
1206 +
1207 +
    // clone --max-blob records the filter; the checkout fetches lazily.
1208 +
    let b = Repo::clone_with(&url, &root.join("b"), Some(1024)).unwrap();
1209 +
    assert_eq!(b.config.max_blob, Some(1024));
1210 +
    assert_eq!(fs::read(root.join("b/big.bin")).unwrap(), big);
1211 +
    assert_eq!(parse_size("2M").unwrap(), 2 << 20);
1212 +
    assert!(parse_size("lots").is_err());
1213 +
}
modified crates/oxy-server/src/lib.rs +13 −2
…
130 unchanged lines
131 131
async fn fetch(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> {
132 132
    blocking(move || {
133 133
        let r = app.open(&repo)?;
134 -
        let changes = wire::decode_hashes(&body)?;
135 -
        Ok(ok(wire::encode_bundle(&r.bundle_for(&changes)?)))
134 +
        let (changes, max_blob) = wire::decode_fetch(&body)?;
135 +
        Ok(ok(wire::encode_bundle(&r.bundle_for_filtered(&changes, max_blob)?)))
136 136
    })
137 137
    .await
138 138
}
139 139
140 +
async fn objects(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> {
141 +
    blocking(move || {
142 +
        let r = app.open(&repo)?;
143 +
        let refs = wire::decode_hashes(&body)?;
144 +
        let bundle = oxy_core::protocol::Bundle { objects: r.objects_for(&refs)?, ..Default::default() };
145 +
        Ok(ok(wire::encode_bundle(&bundle)))
146 +
    })
147 +
    .await
148 +
}
149 +
140 150
async fn push(State(app): State<Shared>, Path(repo): Path<String>, headers: HeaderMap, body: Bytes) -> Result<Response, Failure> {
141 151
    app.authorize_push(&headers)?;
142 152
    blocking(move || {
…
13 unchanged lines
156 166
        .route("/{repo}/oxy/v1/refs", get(refs))
157 167
        .route("/{repo}/oxy/v1/negotiate", post(negotiate))
158 168
        .route("/{repo}/oxy/v1/fetch", post(fetch))
169 +
        .route("/{repo}/oxy/v1/objects", post(objects))
159 170
        .route("/{repo}/oxy/v1/push", post(push))
160 171
        .with_state(app)
161 172
}
…
25 unchanged lines
modified crates/oxy-server/tests/http.rs +18 −0
…
92 unchanged lines
93 93
}
94 94
95 95
#[test]
96 +
fn objects_endpoint_serves_lazy_blobs() {
97 +
    hermetic();
98 +
    let dir = tempfile::tempdir().unwrap();
99 +
    let root = dir.path();
100 +
    let served = root.join("served");
101 +
    let origin = Repo::init(&served.join("origin")).unwrap();
102 +
    let big: Vec<u8> = (0..300_000u32).map(|i| (i % 253) as u8).collect(); // has NUL bytes: opaque
103 +
    write(&served.join("origin"), "big.bin", &big);
104 +
    write(&served.join("origin"), "t.txt", b"t\n");
105 +
    origin.commit("big", &[]).unwrap();
106 +
    let base = start(&served, vec![]);
107 +
    let url = format!("{base}/origin");
108 +
    let a = Repo::clone_with(&url, &root.join("a"), Some(1000)).unwrap();
109 +
    assert_eq!(fs::read(root.join("a/big.bin")).unwrap(), big, "fetched through the objects endpoint during checkout");
110 +
    assert!(!a.snapshot("status").unwrap().changed);
111 +
}
112 +
113 +
#[test]
96 114
fn push_requires_a_token_when_configured() {
97 115
    hermetic();
98 116
    let dir = tempfile::tempdir().unwrap();
…
55 unchanged lines
modified docs/spec.md +8 −5
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.6d |
5 +
| **Status** | Draft 0.6e |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
515 unchanged lines
524 524
525 525
### 13.5 Lazy objects
526 526
527 -
A blob or chunk may be **absent** in a partial repository (§17). The object store records absent refs it has been told about with the remote that promised them, and fetches on first read. Changes and checkpoints are never lazy: a repository that has a change has all of its inline data.
527 +
A blob or chunk may be **absent** in a partial repository (§17). After a fetch with a blob filter, the repository records every blob its new changes reference but did not receive, together with the remote that withheld it (`.oxy/lazy/<ref>`), and fetches it through `objects` on first read, dropping the record once stored. Changes and checkpoints are never lazy: a repository that has a change has all of its inline data.
528 528
529 529
## 14. Identity, signatures, and trust policy
530 530
…
159 unchanged lines
690 690
| Signed policy | `[policy bytes, [Signature]]` |
691 691
| `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null}], 1: repo id, 2: signed policy or null}`; the attestation is present only when it describes the current state |
692 692
| `negotiate` request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order |
693 -
| `fetch` request | `{0: [hashes]}`; response a `Bundle` |
693 +
| `fetch` request | `{0: [hashes], 1: max blob size or null}`; response a `Bundle` without blobs above the size |
694 +
| `objects` request | `{0: [refs]}`; response a `Bundle` carrying only objects, a blob accompanied by its chunks |
694 695
| `push` request | `{0: Bundle, 1: [{0: name, 1: old state, 2: new state, 3: force, 4: signed attestation or null}], 2: signed policy or null}`; response `{0: [{0: name, 1: ok, 2: reason}]}`, where a result named `policy` reports the policy update |
695 696
696 -
The HTTP content type is `application/oxy-cbor`. The `objects` and `checkpoint` endpoints of §16.1 are not yet specified in this form.
697 +
The HTTP content type is `application/oxy-cbor`. The `checkpoint` endpoint of §16.1 is not yet specified in this form.
697 698
698 699
### 16.4 Push validation
699 700
…
139 unchanged lines
839 840
840 841
---
841 842
842 -
*Changelog.* Draft 0.6d (2026-09-27): §11 checkpoint format as implemented, with the loading rule.
843 +
*Changelog.* Draft 0.6e (2026-09-27): lazy blobs: the `fetch` filter, the `objects` message, and the absent-blob record (§13.5, §16.3).
844 +
845 +
Draft 0.6d (2026-09-27): §11 checkpoint format as implemented, with the loading rule.
843 846
844 847
Draft 0.6c (2026-09-26): §23 import defines the base as the union of parent states, records author and commit trailers, skips submodules, and states the closure-versus-snapshot difference.
845 848
…
21 unchanged lines
modified src/cli/mod.rs +9 −3
…
25 unchanged lines
26 26
    /// Create a repository in PATH (default: current directory)
27 27
    Init { path: Option<PathBuf> },
28 28
    /// Clone a repository from a local path or an http(s) URL
29 -
    Clone { url: String, dest: Option<PathBuf> },
29 +
    Clone {
30 +
        url: String,
31 +
        dest: Option<PathBuf>,
32 +
        /// Leave blobs larger than this (e.g. 1M) to be fetched on first read
33 +
        #[arg(long)]
34 +
        max_blob: Option<String>,
35 +
    },
30 36
    /// Show the working copy: changed paths and conflicts
31 37
    Status,
32 38
    /// One-line summary for shell prompts; fast and records nothing
…
104 unchanged lines
137 143
        #[command(subcommand)]
138 144
        cmd: PolicyCmd,
139 145
    },
140 -
    /// Set a configuration key (author.*, core.sign, core.op-retention, core.gc-grace, core.checkpoint-interval, workspace.bookmark, remote.<name>.url|token|ca)
146 +
    /// Set a configuration key (author.*, core.sign, core.op-retention, core.gc-grace, core.checkpoint-interval, filter.max-blob, workspace.bookmark, remote.<name>.url|token|ca)
141 147
    Config {
142 148
        key: String,
143 149
        value: String,
…
89 unchanged lines
233 239
        Cmd::Gc { now } => workspace::gc(now),
234 240
        Cmd::Checkpoint => workspace::checkpoint(),
235 241
        Cmd::Op { cmd: OpCmd::Log } => workspace::op_log(),
236 -
        Cmd::Clone { url, dest } => sync::clone(&url, dest),
242 +
        Cmd::Clone { url, dest, max_blob } => sync::clone(&url, dest, max_blob.as_deref()),
237 243
        Cmd::Remote { cmd } => match cmd {
238 244
            RemoteCmd::Add { name, url } => sync::remote_add(&name, &url),
239 245
            RemoteCmd::Remove { name } => sync::remote_remove(&name),
…
23 unchanged lines
modified src/cli/sync.rs +3 −2
…
6 unchanged lines
7 7
use oxy_core::repo::Repo;
8 8
use std::path::PathBuf;
9 9
10 -
pub fn clone(url: &str, dest: Option<PathBuf>) -> Result<i32> {
10 +
pub fn clone(url: &str, dest: Option<PathBuf>, max_blob: Option<&str>) -> Result<i32> {
11 11
    let dest = dest.unwrap_or_else(|| {
12 12
        let base = url.trim_end_matches('/').rsplit('/').next().unwrap_or("repo");
13 13
        PathBuf::from(base)
14 14
    });
15 +
    let max_blob = max_blob.map(oxy_core::repo::parse_size).transpose()?;
15 16
    let sp = ui::spinner(&format!("Cloning {url}…"));
16 -
    let repo = Repo::clone(url, &dest)?;
17 +
    let repo = Repo::clone_with(url, &dest, max_blob)?;
17 18
    let heads = repo.bookmark_heads()?;
18 19
    sp.done(&format!("{} into {} ({} on {}: {})", ui::ok("Cloned"), ui::path(repo.root().display()), url, ui::id(&repo.config.bookmark), plural(heads.len(), "head")));
19 20
    Ok(0)
…
146 unchanged lines