OXYGENHUB

Shallow clone: clone --depth, checkpoint and shallow endpoints, boundary-aware import and negotiation, oxy deepen; log from state metadata (spec 0.6g) 2863456a0f6d

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 22:34 UTC
Signature
✔ signed by Andy McCall
Hash
2863456a0f6d96a6b04f7892b73d0665ff71ad94d14e2824726a9224f679537a
Depends on
096f447ccda0 135212de5370 252e8cb23fbc 2b5ee371060e 5b9c6ad45f6e 7421309f5372 750251b21a96 83e0677b07fe 982864068a59 b3c5a59a875a ba160f60219e c3069537d7c5 fce44ae9ad29 ff0af1ee299d
Depended on by
569802ebae0e 80b069facc69 f3811eb20bea 7bd805ffb418 d36ba2f79f95 767ac6cd0545 2bf22305bc48 14edbaaf5291 4d6c9c1b358c 598009a75f6c 69816d688300 6f8aeb996558 afaba5f1306d d42369abf180 75167672ebcd e02bf0cd0533 8f10a07a1dbe 21b2aec279ff 94084087a876 28fc23d7706a 132b884b9a8d
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
50 atoms, 0 tree operations, 13 files

Browse the tree at this change

modified crates/oxy-core/src/protocol.rs +28 −0
…
93 unchanged lines
94 94
    /// Individual objects by reference (spec §16.1 `objects`); a blob comes
95 95
    /// with its chunks.
96 96
    fn objects(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>>;
97 +
    /// A checkpoint of exactly `heads` (spec §16.1 `checkpoint`): the
98 +
    /// checkpoint object, its payload blob, and the blob's chunks.
99 +
    fn checkpoint(&self, heads: &[ChangeHash]) -> Result<Bundle>;
100 +
    /// The changes within `depth` dependency steps of `heads` (spec §17).
101 +
    fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>>;
97 102
    /// Push changes, bookmark updates, and optionally a newer policy. The
98 103
    /// policy is applied first; a result named `policy` reports it.
99 104
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>>;
…
28 unchanged lines
128 133
    fn objects(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>> {
129 134
        self.repo.objects_for(refs)
130 135
    }
136 +
    fn checkpoint(&self, heads: &[ChangeHash]) -> Result<Bundle> {
137 +
        self.repo.checkpoint_bundle_for(heads)
138 +
    }
139 +
    fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> {
140 +
        self.repo.shallow_changes(heads, depth)
141 +
    }
131 142
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>> {
132 143
        self.repo.receive_push(bundle, updates, policy)
133 144
    }
…
90 unchanged lines
224 235
    fn objects(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>> {
225 236
        Ok(wire::decode_bundle(&self.call(true, "objects", Some(&wire::encode_hashes(refs)))?)?.objects)
226 237
    }
238 +
    fn checkpoint(&self, heads: &[ChangeHash]) -> Result<Bundle> {
239 +
        wire::decode_bundle(&self.call(true, "checkpoint", Some(&wire::encode_hashes(heads)))?)
240 +
    }
241 +
    fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> {
242 +
        wire::decode_hashes(&self.call(true, "shallow", Some(&wire::encode_shallow(heads, depth)))?)
243 +
    }
227 244
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>> {
228 245
        wire::decode_push_response(&self.call(true, "push", Some(&wire::encode_push(&bundle, &updates, policy.as_ref())))?)
229 246
    }
…
204 unchanged lines
434 451
        Ok((hashes_from(field(m, 0, "changes")?)?, max))
435 452
    }
436 453
454 +
    /// `shallow` request: `{0: [heads], 1: depth}`.
455 +
    pub fn encode_shallow(hs: &[ChangeHash], depth: u32) -> Vec<u8> {
456 +
        cbor::encode(&Value::map([(0, hashes_value(hs)), (1, Value::Uint(depth as u64))]))
457 +
    }
458 +
    pub fn decode_shallow(bytes: &[u8]) -> Result<(Vec<ChangeHash>, u32)> {
459 +
        let v = cbor::decode(bytes).map_err(ObjectError::from)?;
460 +
        let m = map(&v, "shallow")?;
461 +
        Ok((hashes_from(field(m, 0, "heads")?)?, o::u32_(field(m, 1, "depth")?, "depth")?))
462 +
    }
463 +
437 464
    pub fn encode_hashes(hs: &[ChangeHash]) -> Vec<u8> {
438 465
        cbor::encode(&Value::map([(0, hashes_value(hs))]))
439 466
    }
…
119 unchanged lines
559 586
        assert_eq!(wire::decode_hashes(&wire::encode_hashes(&[c.hash()])).unwrap(), vec![c.hash()]);
560 587
        assert_eq!(wire::decode_fetch(&wire::encode_fetch(&[c.hash()], Some(4096))).unwrap(), (vec![c.hash()], Some(4096)));
561 588
        assert_eq!(wire::decode_fetch(&wire::encode_fetch(&[], None)).unwrap(), (vec![], None));
589 +
        assert_eq!(wire::decode_shallow(&wire::encode_shallow(&[c.hash()], 3)).unwrap(), (vec![c.hash()], 3));
562 590
        // An old-style fetch request without the filter field still decodes.
563 591
        assert_eq!(wire::decode_fetch(&wire::encode_hashes(&[c.hash()])).unwrap(), (vec![c.hash()], None));
564 592
…
13 unchanged lines
modified crates/oxy-core/src/repo/checkpoint.rs +39 −4
…
65 unchanged lines
66 66
        Ok((r, state.len(), bytes))
67 67
    }
68 68
69 +
    /// Point `bookmark` at an already stored checkpoint (a shallow clone's base).
70 +
    pub(crate) fn install_checkpoint(&self, bookmark: &str, r: &ObjectRef) -> Result<()> {
71 +
        Checkpoint::decode(&self.store.get_typed(r, ObjectType::Checkpoint)?)?;
72 +
        fs::create_dir_all(self.oxy.join("checkpoints"))?;
73 +
        write_atomic(&self.checkpoint_pointer(bookmark), r.to_hex().as_bytes())
74 +
    }
75 +
76 +
    /// A bundle holding a checkpoint of exactly `heads` with its payload
77 +
    /// (spec §16.1 `checkpoint`); the current bookmark checkpoint is reused
78 +
    /// when it matches, otherwise one is made and, when `heads` are the
79 +
    /// bookmark's, kept.
80 +
    pub fn checkpoint_bundle_for(&self, heads: &[ChangeHash]) -> Result<Bundle> {
81 +
        let wanted = StateObject::new(heads.to_vec()).heads;
82 +
        let bookmark = self.config.bookmark.clone();
83 +
        let current = self.bookmark_heads()?;
84 +
        let r = match self.latest_checkpoint(&bookmark)? {
85 +
            Some((r, cp)) if cp.heads == wanted => r,
86 +
            _ => {
87 +
                let state = self.load_state(&wanted)?;
88 +
                if current == wanted {
89 +
                    self.write_checkpoint(&bookmark, &state, &wanted)?.0
90 +
                } else {
91 +
                    let payload = state.checkpoint_payload();
92 +
                    let blob = self.store_blob(&payload)?;
93 +
                    let cp = Checkpoint { heads: wanted.clone(), count: state.len() as u64, payload: blob };
94 +
                    self.store.put(ObjectType::Checkpoint, &cp.encode())?
95 +
                }
96 +
            }
97 +
        };
98 +
        let cp = Checkpoint::decode(&self.store.get_typed(&r, ObjectType::Checkpoint)?)?;
99 +
        let mut bundle = Bundle::default();
100 +
        for c in Blob::decode(&self.store.get_typed(&cp.payload, ObjectType::Blob)?)?.chunks {
101 +
            bundle.objects.push(self.store.get(&c)?);
102 +
        }
103 +
        bundle.objects.push(self.store.get(&cp.payload)?);
104 +
        bundle.objects.push((ObjectType::Checkpoint, cp.encode()));
105 +
        Ok(bundle)
106 +
    }
107 +
69 108
    /// Load the state of `heads` using a checkpoint when one applies.
70 109
    pub(crate) fn load_state_checkpointed(&self, heads: &[ChangeHash]) -> Result<Option<State>> {
71 110
        let mut bookmarks: Vec<String> = vec![self.config.bookmark.clone()];
…
7 unchanged lines
79 118
        }
80 119
        for b in bookmarks {
81 120
            let Some((_, cp)) = self.latest_checkpoint(&b)? else { continue };
82 -
            // Cheap pre-check: every checkpoint head must be stored.
83 -
            if cp.heads.iter().any(|h| !self.store.has(h)) {
84 -
                continue;
85 -
            }
86 121
            let payload = self.read_blob(&cp.payload)?;
87 122
            let base = State::from_checkpoint_payload(&payload)?;
88 123
            let cp_heads: BTreeSet<ChangeHash> = cp.heads.iter().copied().collect();
…
31 unchanged lines
modified crates/oxy-core/src/repo/gc.rs +6 −1
…
89 unchanged lines
90 90
            if !keep.insert(h) {
91 91
                continue;
92 92
            }
93 -
            let c = self.store.get_change(&h)?;
93 +
            // Beyond the shallow boundary there is nothing to keep.
94 +
            let c = match self.store.get_change(&h) {
95 +
                Ok(c) => c,
96 +
                Err(StoreError::NotFound(_)) => continue,
97 +
                Err(e) => return Err(e.into()),
98 +
            };
94 99
            stack.extend(c.deps.iter().copied());
95 100
            if !c.author.is_zero() {
96 101
                keep.insert(c.author);
…
27 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +4 −3
…
86 unchanged lines
87 87
88 88
pub struct LogEntry {
89 89
    pub hash: ChangeHash,
90 -
    pub change: Change,
90 +
    /// Metadata from the state; the full change may be absent in a shallow repository.
91 +
    pub change: crate::state::ChangeMeta,
91 92
    pub is_working_copy: bool,
92 93
    pub is_head: bool,
93 94
}
…
417 unchanged lines
511 512
        let state = self.load_state(&heads)?;
512 513
        let head_set: BTreeSet<ChangeHash> = self.bookmark_heads()?.into_iter().collect();
513 514
        let mut entries: Vec<LogEntry> = Vec::new();
514 -
        for (h, _) in state.changes() {
515 -
            entries.push(LogEntry { hash: *h, change: self.store.get_change(h)?, is_working_copy: *h == wc_hash, is_head: head_set.contains(h) });
515 +
        for (h, meta) in state.changes() {
516 +
            entries.push(LogEntry { hash: *h, change: meta.clone(), is_working_copy: *h == wc_hash, is_head: head_set.contains(h) });
516 517
        }
517 518
        entries.sort_by(|a, b| b.is_working_copy.cmp(&a.is_working_copy).then(b.change.hlc.cmp(&a.change.hlc)).then(b.hash.cmp(&a.hash)));
518 519
        Ok(entries)
…
81 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +168 −3
…
71 unchanged lines
72 72
        Ok(seen)
73 73
    }
74 74
75 +
    /// The changes within `depth` dependency steps of `heads` (spec §17),
76 +
    /// nearest first; depth 1 is the heads themselves.
77 +
    pub fn shallow_changes(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> {
78 +
        let mut out = Vec::new();
79 +
        let mut seen: BTreeSet<ChangeHash> = BTreeSet::new();
80 +
        let mut layer: Vec<ChangeHash> = heads.to_vec();
81 +
        for _ in 0..depth.max(1) {
82 +
            let mut next = Vec::new();
83 +
            for h in layer {
84 +
                if !seen.insert(h) || !self.store.has(&h) {
85 +
                    continue;
86 +
                }
87 +
                let c = self.store.get_change(&h)?;
88 +
                next.extend(c.deps.iter().copied());
89 +
                out.push(h);
90 +
            }
91 +
            if next.is_empty() {
92 +
                break;
93 +
            }
94 +
            layer = next;
95 +
        }
96 +
        out.reverse();
97 +
        Ok(out)
98 +
    }
99 +
100 +
    fn shallow_path(&self) -> PathBuf {
101 +
        self.oxy.join("shallow")
102 +
    }
103 +
104 +
    /// The shallow boundary: dependencies this repository does not hold (spec §17).
105 +
    pub fn boundary(&self) -> BTreeSet<ChangeHash> {
106 +
        fs::read_to_string(self.shallow_path()).map(|t| t.lines().filter_map(ObjectRef::from_hex).collect()).unwrap_or_default()
107 +
    }
108 +
109 +
    pub fn is_shallow(&self) -> bool {
110 +
        !self.boundary().is_empty()
111 +
    }
112 +
113 +
    fn write_boundary(&self, boundary: &BTreeSet<ChangeHash>) -> Result<()> {
114 +
        if boundary.is_empty() {
115 +
            let _ = fs::remove_file(self.shallow_path());
116 +
            return Ok(());
117 +
        }
118 +
        let text: String = boundary.iter().map(|h| format!("{}\n", h.to_hex())).collect();
119 +
        write_atomic(&self.shallow_path(), text.as_bytes())
120 +
    }
121 +
122 +
    /// Recompute the boundary from what is stored: every dependency of a
123 +
    /// stored change that is not itself stored.
124 +
    fn refresh_boundary(&self, imported: &[ChangeHash]) -> Result<()> {
125 +
        let mut boundary = self.boundary();
126 +
        boundary.retain(|h| !self.store.has(h));
127 +
        for h in imported {
128 +
            for d in self.store.get_change(h)?.deps {
129 +
                if !self.store.has(&d) {
130 +
                    boundary.insert(d);
131 +
                }
132 +
            }
133 +
        }
134 +
        self.write_boundary(&boundary)
135 +
    }
136 +
137 +
    /// Fetch `depth` more levels of history below the shallow boundary.
138 +
    pub fn deepen(&self, remote_name: &str, depth: u32) -> Result<usize> {
139 +
        let boundary = self.boundary();
140 +
        if boundary.is_empty() {
141 +
            return Ok(0);
142 +
        }
143 +
        let remote = self.open_named_remote(remote_name)?;
144 +
        let wanted = remote.shallow(&boundary.iter().copied().collect::<Vec<_>>(), depth)?;
145 +
        let bundle = remote.fetch(&wanted, self.config.max_blob)?;
146 +
        let imported = self.import_bundle_shallow(&bundle, false)?;
147 +
        if self.config.max_blob.is_some() {
148 +
            self.register_missing_blobs(&imported, remote_name)?;
149 +
        }
150 +
        self.refresh_boundary(&imported)?;
151 +
        Ok(imported.len())
152 +
    }
153 +
75 154
    /// `closure(want) \ closure(have)`, dependencies first (spec §16.2).
76 155
    pub fn negotiate(&self, have: &[ChangeHash], want: &[ChangeHash]) -> Result<Vec<ChangeHash>> {
77 156
        let have = self.closure_in_store(have)?;
157 +
        // In a shallow repository the walk stops at the boundary, whose
158 +
        // changes the remote is assumed to hold.
159 +
        let boundary = self.boundary();
78 160
        let mut out = Vec::new();
79 161
        let mut done: BTreeSet<ChangeHash> = have.clone();
162 +
        done.extend(boundary.iter().copied());
80 163
        // Iterative post-order DFS.
81 164
        let mut stack: Vec<(ChangeHash, bool)> = want.iter().rev().map(|h| (*h, false)).collect();
82 165
        while let Some((h, expanded)) = stack.pop() {
…
101 unchanged lines
184 267
    /// Store a bundle's contents. With `strict`, every change must
185 268
    /// authenticate (signature or seal by its author or a delegate).
186 269
    pub fn import_bundle(&self, bundle: &Bundle, strict: bool) -> Result<Vec<ChangeHash>> {
270 +
        self.import_bundle_inner(bundle, strict, false)
271 +
    }
272 +
273 +
    /// Like `import_bundle`, accepting changes whose dependencies are absent
274 +
    /// (they join the shallow boundary).
275 +
    pub fn import_bundle_shallow(&self, bundle: &Bundle, strict: bool) -> Result<Vec<ChangeHash>> {
276 +
        self.import_bundle_inner(bundle, strict, true)
277 +
    }
278 +
279 +
    fn import_bundle_inner(&self, bundle: &Bundle, strict: bool, shallow: bool) -> Result<Vec<ChangeHash>> {
187 280
        for (ty, body) in &bundle.objects {
188 281
            match ty {
189 -
                ObjectType::Chunk | ObjectType::Blob | ObjectType::Identity | ObjectType::State => {
282 +
                ObjectType::Chunk | ObjectType::Blob | ObjectType::Identity | ObjectType::State | ObjectType::Checkpoint => {
190 283
                    self.store.put(*ty, body)?;
191 284
                }
192 285
                other => return fail(format!("bundle may not carry {} objects", other.name())),
…
11 unchanged lines
204 297
            }
205 298
        }
206 299
        let incoming: BTreeSet<ChangeHash> = bundle.changes.iter().map(|(c, _)| c.hash()).collect();
300 +
        let boundary = self.boundary();
207 301
        let mut imported = Vec::new();
208 302
        for (c, sigs) in &bundle.changes {
209 303
            let h = c.hash();
210 304
            for d in &c.deps {
211 -
                if !self.store.has(d) && !incoming.contains(d) {
305 +
                if !self.store.has(d) && !incoming.contains(d) && !shallow && !boundary.contains(d) {
212 306
                    return fail(format!("change {} depends on {}, which is neither stored nor in the bundle", h.short(12), d.short(12)));
213 307
                }
214 308
            }
…
352 unchanged lines
567 661
        Ok(FetchReport { refs: report, unverified })
568 662
    }
569 663
664 +
    /// Shallow fetch of the remote's bookmark: its checkpoint as our base,
665 +
    /// the changes within `depth` steps of its heads, and the boundary below.
666 +
    pub fn fetch_shallow(&self, remote_name: &str, depth: u32) -> Result<usize> {
667 +
        let remote = self.open_named_remote(remote_name)?;
668 +
        if remote.repo_id()? != self.id()? {
669 +
            return fail(format!("remote {remote_name:?} is a different repository (repository IDs differ)"));
670 +
        }
671 +
        let resp = remote.refs()?;
672 +
        let bookmark = self.config.bookmark.clone();
673 +
        if let Some(sp) = &resp.policy {
674 +
            let local = self.current_policy()?.map(|(_, sp)| sp.policy);
675 +
            self.check_policy(sp, local.as_ref()).map_err(|e| RepoError::Msg(format!("policy from {remote_name}: {e}")))?;
676 +
            self.set_policy(sp, &format!("oxy clone --depth (policy serial {})", sp.policy.serial))?;
677 +
            self.record_policy_serial(remote_name, sp.policy.serial)?;
678 +
        }
679 +
        let Some(r) = resp.refs.into_iter().find(|r| r.name == bookmark) else {
680 +
            return fail(format!("{remote_name} has no bookmark {bookmark:?}"));
681 +
        };
682 +
        if r.heads.is_empty() {
683 +
            return Ok(0);
684 +
        }
685 +
        // Base: the remote's checkpoint of exactly these heads.
686 +
        let cp_bundle = remote.checkpoint(&r.heads)?;
687 +
        let mut cp_ref = None;
688 +
        for (ty, body) in &cp_bundle.objects {
689 +
            let put = self.store.put(*ty, body)?;
690 +
            if *ty == ObjectType::Checkpoint {
691 +
                cp_ref = Some(put);
692 +
            }
693 +
        }
694 +
        let Some(cp_ref) = cp_ref else { return fail(format!("{remote_name} sent no checkpoint")) };
695 +
        self.install_checkpoint(&bookmark, &cp_ref)?;
696 +
        // History window.
697 +
        let wanted = remote.shallow(&r.heads, depth)?;
698 +
        let bundle = remote.fetch(&wanted, self.config.max_blob)?;
699 +
        let imported = self.import_bundle_shallow(&bundle, false)?;
700 +
        if self.config.max_blob.is_some() {
701 +
            self.register_missing_blobs(&imported, remote_name)?;
702 +
        }
703 +
        self.refresh_boundary(&imported)?;
704 +
        if let Some(sa) = &r.attestation {
705 +
            let ar = self.store.put_attest(&sa.attest)?;
706 +
            for sig in &sa.sigs {
707 +
                self.store.put_signature(&ar, sig)?;
708 +
            }
709 +
            if let AttestBody::Ref { serial, .. } = sa.attest.body {
710 +
                self.record_serial(remote_name, &r.name, serial)?;
711 +
            }
712 +
        }
713 +
        let state = self.store.put_state(&StateObject::new(r.heads.clone()))?;
714 +
        let (op_ref, op) = self.head_op()?;
715 +
        let mut view = op.view.clone();
716 +
        view.remotes.entry(remote_name.to_string()).or_default().insert(r.name.clone(), state);
717 +
        self.record_op(&[op_ref], &format!("oxy fetch {remote_name} --depth {depth}"), view)?;
718 +
        Ok(imported.len())
719 +
    }
720 +
570 721
    /// Fetch, then take the union of the local bookmark's state with the
571 722
    /// remote's: no merge change, conflicts become reified (spec §9.4).
572 723
    pub fn pull(&self, remote_name: &str) -> Result<PullReport> {
…
137 unchanged lines
710 861
    /// read, and materializing only `sparse` paths (which implies lazy blobs
711 862
    /// unless a size is given, so out-of-scope binaries are never fetched).
712 863
    pub fn clone_with(url: &str, dest: &Path, max_blob: Option<u64>, sparse: &[String]) -> Result<Repo> {
864 +
        Self::clone_full(url, dest, max_blob, sparse, None)
865 +
    }
866 +
867 +
    /// Shallow clone (spec §17): a checkpoint of the bookmark's heads plus the
868 +
    /// changes within `depth` steps of them; the rest is the boundary.
869 +
    pub fn clone_shallow(url: &str, dest: &Path, depth: u32, max_blob: Option<u64>, sparse: &[String]) -> Result<Repo> {
870 +
        Self::clone_full(url, dest, max_blob, sparse, Some(depth))
871 +
    }
872 +
873 +
    fn clone_full(url: &str, dest: &Path, max_blob: Option<u64>, sparse: &[String], depth: Option<u32>) -> Result<Repo> {
713 874
        let options = RemoteOptions {
714 875
            token: std::env::var("OXY_TOKEN").ok(),
715 876
            ca: std::env::var("OXY_CA").ok().map(PathBuf::from),
…
11 unchanged lines
727 888
            repo.config.set("filter.paths", &sparse.join(","))?;
728 889
        }
729 890
        repo.save_config()?;
730 -
        repo.fetch("origin")?;
891 +
        if let Some(depth) = depth {
892 +
            repo.fetch_shallow("origin", depth)?;
893 +
        } else {
894 +
            repo.fetch("origin")?;
895 +
        }
731 896
        let (op_ref, op) = repo.head_op()?;
732 897
        let bookmark = repo.config.bookmark.clone();
733 898
        if let Some(state) = op.view.remotes.get("origin").and_then(|b| b.get(&bookmark)).copied() {
…
9 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +53 −0
…
1261 unchanged lines
1262 1262
    assert!(full.files.contains_key("README.md"));
1263 1263
    let _ = h1;
1264 1264
}
1265 +
1266 +
#[test]
1267 +
fn shallow_clone_and_deepen() {
1268 +
    hermetic();
1269 +
    let dir = tempfile::tempdir().unwrap();
1270 +
    let root = dir.path();
1271 +
    let origin_dir = root.join("origin");
1272 +
    let origin = Repo::init(&origin_dir).unwrap().with_home(root.join("home"));
1273 +
    let mut hashes = Vec::new();
1274 +
    for i in 0..8 {
1275 +
        write(&origin_dir, "a.txt", format!("{}\n", (0..=i).map(|k| k.to_string()).collect::<Vec<_>>().join("\n")).as_bytes());
1276 +
        hashes.push(origin.commit(&format!("c{i}"), &[]).unwrap());
1277 +
    }
1278 +
    let url = origin_dir.to_string_lossy().to_string();
1279 +
    let a = Repo::clone_shallow(&url, &root.join("a"), 3, None, &[]).unwrap().with_home(root.join("home"));
1280 +
    assert!(a.is_shallow());
1281 +
    assert_eq!(fs::read_to_string(root.join("a/a.txt")).unwrap(), "0\n1\n2\n3\n4\n5\n6\n7\n");
1282 +
    assert!(a.store().has(&hashes[7]) && a.store().has(&hashes[5]), "three levels present");
1283 +
    assert!(!a.store().has(&hashes[4]), "beyond the depth is absent");
1284 +
    assert_eq!(a.boundary(), [hashes[4]].into_iter().collect());
1285 +
    assert_eq!(a.log().unwrap().len(), 9, "the log shows every change from the checkpoint's metadata");
1286 +
    assert!(!a.snapshot("status").unwrap().changed);
1287 +
    assert_eq!(a.quick_status().unwrap().heads, 1);
1288 +
1289 +
    // Work continues on top of the shallow history and pushes back.
1290 +
    write(&root.join("a"), "a.txt", b"0\n1\n2\n3\n4\n5\n6\n7\n8\n");
1291 +
    let h_new = a.commit("c8", &[]).unwrap();
1292 +
    assert!(a.push("origin", false).unwrap()[0].ok);
1293 +
    assert_eq!(origin.bookmark_heads().unwrap(), vec![h_new]);
1294 +
    // Fetching new work from origin into the shallow clone works too.
1295 +
    write(&origin_dir, "b.txt", b"b\n");
1296 +
    origin.pull("origin").err();
1297 +
    let (_, wc) = origin.working_copy().unwrap();
1298 +
    let _ = wc;
1299 +
    origin.checkout(&origin.bookmark_heads().unwrap(), true, "co").unwrap();
1300 +
    write(&origin_dir, "b.txt", b"b\n");
1301 +
    let h_b = origin.commit("b", &[]).unwrap();
1302 +
    let pr = a.pull("origin").unwrap();
1303 +
    assert!(pr.changed);
1304 +
    assert!(a.store().has(&h_b));
1305 +
    assert!(root.join("a/b.txt").exists());
1306 +
1307 +
    // Deepening fetches older history until the boundary is gone.
1308 +
    // Every commit also depends on the change that created the file, so two
1309 +
    // levels below the boundary hold h4, h3, and the root.
1310 +
    assert!(a.deepen("origin", 2).unwrap() >= 2);
1311 +
    assert!(a.store().has(&hashes[3]));
1312 +
    assert!(a.is_shallow());
1313 +
    a.deepen("origin", 100).unwrap();
1314 +
    assert!(!a.is_shallow(), "boundary reached the root");
1315 +
    assert!(a.store().has(&hashes[0]));
1316 +
    assert_eq!(a.load_state_full(&a.bookmark_heads().unwrap()).unwrap().len(), 10);
1317 +
}
modified crates/oxy-server/src/lib.rs +22 −0
…
136 unchanged lines
137 137
    .await
138 138
}
139 139
140 +
async fn checkpoint(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> {
141 +
    blocking(move || {
142 +
        let r = app.open(&repo)?;
143 +
        let heads = wire::decode_hashes(&body)?;
144 +
        let lock = app.lock_for(&repo);
145 +
        let _guard = lock.lock().unwrap();
146 +
        Ok(ok(wire::encode_bundle(&r.checkpoint_bundle_for(&heads)?)))
147 +
    })
148 +
    .await
149 +
}
150 +
151 +
async fn shallow(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> {
152 +
    blocking(move || {
153 +
        let r = app.open(&repo)?;
154 +
        let (heads, depth) = wire::decode_shallow(&body)?;
155 +
        Ok(ok(wire::encode_hashes(&r.shallow_changes(&heads, depth)?)))
156 +
    })
157 +
    .await
158 +
}
159 +
140 160
async fn objects(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> {
141 161
    blocking(move || {
142 162
        let r = app.open(&repo)?;
…
24 unchanged lines
167 187
        .route("/{repo}/oxy/v1/negotiate", post(negotiate))
168 188
        .route("/{repo}/oxy/v1/fetch", post(fetch))
169 189
        .route("/{repo}/oxy/v1/objects", post(objects))
190 +
        .route("/{repo}/oxy/v1/checkpoint", post(checkpoint))
191 +
        .route("/{repo}/oxy/v1/shallow", post(shallow))
170 192
        .route("/{repo}/oxy/v1/push", post(push))
171 193
        .with_state(app)
172 194
}
…
25 unchanged lines
modified docs/spec.md +8 −4
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.6f |
5 +
| **Status** | Draft 0.6g |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
683 unchanged lines
692 692
| `negotiate` request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order |
693 693
| `fetch` request | `{0: [hashes], 1: max blob size or null}`; response a `Bundle` without blobs above the size |
694 694
| `objects` request | `{0: [refs]}`; response a `Bundle` carrying only objects, a blob accompanied by its chunks |
695 +
| `checkpoint` request | `{0: [heads]}`; response a `Bundle` of objects: the checkpoint of exactly those heads, its payload blob, and the blob's chunks |
696 +
| `shallow` request | `{0: [heads], 1: depth}`; response `{0: [hashes]}`, the changes within `depth` steps of the heads, dependencies first |
695 697
| `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 |
696 698
697 -
The HTTP content type is `application/oxy-cbor`. The `checkpoint` endpoint of §16.1 is not yet specified in this form.
699 +
The HTTP content type is `application/oxy-cbor`.
698 700
699 701
### 16.4 Push validation
700 702
…
12 unchanged lines
713 715
714 716
- **Sparse** repositories hold the full change graph but only materialize the listed paths: the working copy contains nothing else, files outside the filter are neither reported nor removed, and changes touching other paths are stored and applied to the graph. A sparse clone sets `max-blob` to zero unless a size is given, so blobs outside the filter are fetched only if the filter later widens (§13.5). `oxy sparse PATH..` changes the filter of a clean working copy and re-checks it out.
715 717
- **Lazy blobs** are fetched via `objects` on first read. `oxy status` and `oxy commit` MUST NOT need them.
716 -
- **Shallow** repositories store a *shallow boundary*: a set of ChangeHashes whose dependencies are known to be absent. A shallow repository MUST hold a checkpoint at the boundary so that graphs can be reconstructed. Deps into the boundary are accepted as well-formed. Deepening fetches more history and moves the boundary.
718 +
- **Shallow** repositories hold a checkpoint of their bookmark's heads (obtained with the `checkpoint` message) and the changes within `depth` dependency steps of those heads (the `shallow` message lists them). The *shallow boundary* is the set of dependencies of stored changes that are not stored (`.oxy/shallow`). Dependencies into the boundary are accepted as well-formed, negotiation stops at the boundary (the remote is assumed to hold it), and the log shows every change from the checkpoint's metadata. `oxy deepen N` fetches `N` more levels below the boundary; when nothing remains the repository is no longer shallow.
717 719
- Thin clients (§22) always use `depth = 0`: a checkpoint plus zero history.
718 720
719 721
Servers MUST honour `filter` on `negotiate`, `fetch`, and `checkpoint`.
…
120 unchanged lines
840 842
841 843
---
842 844
843 -
*Changelog.* Draft 0.6f (2026-09-27): sparse checkout semantics (§17).
845 +
*Changelog.* Draft 0.6g (2026-09-27): shallow repositories as checkpoint plus history window, the `checkpoint` and `shallow` messages, deepening (§16.3, §17).
846 +
847 +
Draft 0.6f (2026-09-27): sparse checkout semantics (§17).
844 848
845 849
Draft 0.6e (2026-09-27): lazy blobs: the `fetch` filter, the `objects` message, and the absent-blob record (§13.5, §16.3).
846 850
…
25 unchanged lines
modified src/cli/format.rs +10 −1
…
3 unchanged lines
4 4
use oxy_core::hash::ChangeHash;
5 5
use oxy_core::objects::Change;
6 6
use oxy_core::repo::{Repo, SignatureStatus};
7 +
use oxy_core::state::ChangeMeta;
7 8
8 9
pub fn short_id(c: &Change) -> String {
9 10
    c.change_id.short(8)
10 11
}
11 12
13 +
pub fn short_meta_id(c: &ChangeMeta) -> String {
14 +
    c.change_id.short(8)
15 +
}
16 +
12 17
/// `n head` / `n heads`.
13 18
pub fn plural(n: usize, word: &str) -> String {
14 19
    format!("{n} {word}{}", if n == 1 { "" } else { "s" })
…
30 unchanged lines
45 50
}
46 51
47 52
pub fn deps_line(c: &Change) -> String {
48 -
    c.deps.iter().map(|d| format!("#{}", d.short(12))).collect::<Vec<_>>().join(" ")
53 +
    deps_of(&c.deps)
54 +
}
55 +
56 +
pub fn deps_of(deps: &[oxy_core::hash::ChangeHash]) -> String {
57 +
    deps.iter().map(|d| format!("#{}", d.short(12))).collect::<Vec<_>>().join(" ")
49 58
}
50 59
51 60
#[cfg(test)]
…
11 unchanged lines
modified src/cli/identity.rs +4 −1
…
50 unchanged lines
51 51
    };
52 52
    let mut bad = 0;
53 53
    for h in hashes {
54 -
        let c = repo.store().get_change(&h)?;
54 +
        let Ok(c) = repo.store().get_change(&h) else {
55 +
            println!("{}  {}", ui::hash(format!("#{}", h.short(12))), ui::dim("not present (shallow)"));
56 +
            continue;
57 +
        };
55 58
        let line = signature_line(&repo, &h)?;
56 59
        let styled = if line.starts_with("INVALID") {
57 60
            bad += 1;
…
97 unchanged lines
modified src/cli/mod.rs +12 −1
…
34 unchanged lines
35 35
        /// Materialize only these paths (repeatable); implies lazy blobs
36 36
        #[arg(long = "sparse")]
37 37
        sparse: Vec<String>,
38 +
        /// Shallow clone: fetch only this many levels of history below the heads
39 +
        #[arg(long)]
40 +
        depth: Option<u32>,
38 41
    },
42 +
    /// Fetch more history below a shallow clone's boundary
43 +
    Deepen {
44 +
        #[arg(default_value_t = 10)]
45 +
        depth: u32,
46 +
        #[arg(long, default_value = "origin")]
47 +
        remote: String,
48 +
    },
39 49
    /// Show or set the sparse filter (paths to materialize); re-checks out the bookmark
40 50
    Sparse { paths: Vec<String> },
41 51
    /// Show the working copy: changed paths and conflicts
…
202 unchanged lines
244 254
        Cmd::Gc { now } => workspace::gc(now),
245 255
        Cmd::Checkpoint => workspace::checkpoint(),
246 256
        Cmd::Op { cmd: OpCmd::Log } => workspace::op_log(),
247 -
        Cmd::Clone { url, dest, max_blob, sparse } => sync::clone(&url, dest, max_blob.as_deref(), &sparse),
257 +
        Cmd::Clone { url, dest, max_blob, sparse, depth } => sync::clone(&url, dest, max_blob.as_deref(), &sparse, depth),
258 +
        Cmd::Deepen { depth, remote } => sync::deepen(&remote, depth),
248 259
        Cmd::Sparse { paths } => workspace::sparse(&paths),
249 260
        Cmd::Remote { cmd } => match cmd {
250 261
            RemoteCmd::Add { name, url } => sync::remote_add(&name, &url),
…
24 unchanged lines
modified src/cli/sync.rs +17 −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>, max_blob: Option<&str>, sparse: &[String]) -> Result<i32> {
10 +
pub fn clone(url: &str, dest: Option<PathBuf>, max_blob: Option<&str>, sparse: &[String], depth: Option<u32>) -> 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 15
    let max_blob = max_blob.map(oxy_core::repo::parse_size).transpose()?;
16 16
    let sp = ui::spinner(&format!("Cloning {url}…"));
17 -
    let repo = Repo::clone_with(url, &dest, max_blob, sparse)?;
17 +
    let repo = match depth {
18 +
        Some(d) => Repo::clone_shallow(url, &dest, d, max_blob, sparse)?,
19 +
        None => Repo::clone_with(url, &dest, max_blob, sparse)?,
20 +
    };
18 21
    let heads = repo.bookmark_heads()?;
19 22
    sp.done(&format!("{} into {} ({} on {}: {})", ui::ok("Cloned"), ui::path(repo.root().display()), url, ui::id(&repo.config.bookmark), plural(heads.len(), "head")));
20 23
    Ok(0)
…
143 unchanged lines
164 167
    );
165 168
    Ok(0)
166 169
}
170 +
171 +
pub fn deepen(remote: &str, depth: u32) -> Result<i32> {
172 +
    let repo = open()?;
173 +
    if !repo.is_shallow() {
174 +
        println!("{}", ui::dim("This repository is not shallow; nothing to deepen."));
175 +
        return Ok(0);
176 +
    }
177 +
    let sp = ui::spinner(&format!("Fetching {depth} more levels from {remote}…"));
178 +
    let n = repo.deepen(remote, depth)?;
179 +
    sp.done(&format!("{} {}; {}", ui::ok("Deepened by"), plural(n, "change"), if repo.is_shallow() { "still shallow".to_string() } else { ui::ok("history is now complete") }));
180 +
    Ok(0)
181 +
}
modified src/cli/workspace.rs +3 −3
1 1
//! Commands that act on the local repository and working copy.
2 2
3 -
use super::format::{deps_line, plural, short_id, signature_line, when};
3 +
use super::format::{deps_line, deps_of, plural, short_id, short_meta_id, signature_line, when};
4 4
use super::open;
5 5
use super::ui;
6 6
use anyhow::Result;
…
85 unchanged lines
92 92
            flags.push("head");
93 93
        }
94 94
        let flags = if flags.is_empty() { String::new() } else { ui::warn(format!(" ({})", flags.join(", "))) };
95 -
        println!("{} {}{}", ui::id(short_id(c)), ui::hash(format!("#{}", e.hash.short(12))), flags);
95 +
        println!("{} {}{}", ui::id(short_meta_id(c)), ui::hash(format!("#{}", e.hash.short(12))), flags);
96 96
        println!("    {}", ui::dim(when(c.hlc.wall_ms)));
97 97
        if !c.deps.is_empty() {
98 -
            println!("    {}", ui::dim(format!("deps: {}", deps_line(c))));
98 +
            println!("    {}", ui::dim(format!("deps: {}", deps_of(&c.deps))));
99 99
        }
100 100
        let msg = if c.message.is_empty() { "(no description)" } else { &c.message };
101 101
        for line in msg.lines() {
…
202 unchanged lines