OXYGENHUB

Checkpoints: serialized states per bookmark, automatic every core.checkpoint-interval changes, oxy checkpoint, checkpoint-accelerated load_state; states keep change metadata only 252e8cb23fbc

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 22:10 UTC
Signature
✔ signed by Andy McCall
Hash
252e8cb23fbca10a4fa4cb4332bebce7af26f16c91c41985808bc8fc62f0e69b
Depends on
2b5ee371060e 55e5a16a2b9c 614541c3cebd 83e0677b07fe 86e682c242c6 982864068a59 ba160f60219e c07e0e7d2d85 c3069537d7c5 fce44ae9ad29 ff0af1ee299d
Depended on by
80b069facc69 f3811eb20bea d3b270909c78 c2e1d09047db c6e69e4b8d8f 74b4a3c62ea4 b689779e9e74 cb8b8082fa73 79b5a90ffd43 7505194116e7 d8d9367878a6 98a1620a3840 ce14f0d68b15 05059d434aca f9575918477f e83354cdac22 2306a4d24779 f6451fbbae3f 4d6c9c1b358c 01a47cd7f0c8 54cd08a1e7bf e8d38e47e9d7 afaba5f1306d 0bdc3f91a39f d42369abf180 3e5772ccf9da e7ddd552b619 387335ff7f4d 119bbf50cb86 75167672ebcd c6aa5bd9d1c8 4dee277c327a b3c5a59a875a 8f10a07a1dbe 6706bdcfb63e 94084087a876 132b884b9a8d 2863456a0f6d 7421309f5372 135212de5370
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
52 atoms, 1 tree operation, 14 files

Browse the tree at this change

added crates/oxy-core/src/repo/checkpoint.rs +119 −0
1 +
//! Checkpoints (spec §11): a serialized state kept per bookmark so loading
2 +
//! a state costs the changes since the checkpoint rather than all of them.
3 +
//!
4 +
//! The newest checkpoint of each bookmark is pointed at by
5 +
//! `.oxy/checkpoints/<bookmark>`; older ones become unreachable and are
6 +
//! garbage collected. A checkpoint applies to a requested head set when all
7 +
//! of its heads lie in that set's closure, which the loader establishes while
8 +
//! walking from the requested heads and stopping at checkpoint members.
9 +
10 +
use super::*;
11 +
use crate::objects::Checkpoint;
12 +
13 +
impl Repo {
14 +
    fn checkpoint_pointer(&self, bookmark: &str) -> PathBuf {
15 +
        self.oxy.join("checkpoints").join(bookmark)
16 +
    }
17 +
18 +
    /// The newest checkpoint recorded for a bookmark.
19 +
    pub fn latest_checkpoint(&self, bookmark: &str) -> Result<Option<(ObjectRef, Checkpoint)>> {
20 +
        match fs::read_to_string(self.checkpoint_pointer(bookmark)) {
21 +
            Ok(h) => {
22 +
                let r = ObjectRef::from_hex(h.trim()).ok_or_else(|| RepoError::Msg("corrupt checkpoint pointer".into()))?;
23 +
                match self.store.get_typed(&r, ObjectType::Checkpoint) {
24 +
                    Ok(bytes) => Ok(Some((r, Checkpoint::decode(&bytes)?))),
25 +
                    Err(StoreError::NotFound(_)) => Ok(None),
26 +
                    Err(e) => Err(e.into()),
27 +
                }
28 +
            }
29 +
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
30 +
            Err(e) => Err(e.into()),
31 +
        }
32 +
    }
33 +
34 +
    /// Every checkpoint pointer, for garbage collection roots.
35 +
    pub(crate) fn checkpoint_refs(&self) -> Result<Vec<ObjectRef>> {
36 +
        let dir = self.oxy.join("checkpoints");
37 +
        let mut out = Vec::new();
38 +
        if let Ok(rd) = fs::read_dir(&dir) {
39 +
            for e in rd.flatten() {
40 +
                if let Ok(h) = fs::read_to_string(e.path()) {
41 +
                    if let Some(r) = ObjectRef::from_hex(h.trim()) {
42 +
                        out.push(r);
43 +
                    }
44 +
                }
45 +
            }
46 +
        }
47 +
        Ok(out)
48 +
    }
49 +
50 +
    /// Write a checkpoint of `state` (whose heads are `heads`) for `bookmark`.
51 +
    pub(crate) fn write_checkpoint(&self, bookmark: &str, state: &State, heads: &[ChangeHash]) -> Result<(ObjectRef, usize)> {
52 +
        let payload = state.checkpoint_payload();
53 +
        let blob = self.store_blob(&payload)?;
54 +
        let cp = Checkpoint { heads: StateObject::new(heads.to_vec()).heads, count: state.len() as u64, payload: blob };
55 +
        let r = self.store.put(ObjectType::Checkpoint, &cp.encode())?;
56 +
        fs::create_dir_all(self.oxy.join("checkpoints"))?;
57 +
        write_atomic(&self.checkpoint_pointer(bookmark), r.to_hex().as_bytes())?;
58 +
        Ok((r, payload.len()))
59 +
    }
60 +
61 +
    /// Checkpoint the current bookmark's state now.
62 +
    pub fn checkpoint(&self) -> Result<(ObjectRef, usize, usize)> {
63 +
        let heads = self.bookmark_heads()?;
64 +
        let state = self.load_state_full(&heads)?;
65 +
        let (r, bytes) = self.write_checkpoint(&self.config.bookmark, &state, &heads)?;
66 +
        Ok((r, state.len(), bytes))
67 +
    }
68 +
69 +
    /// Load the state of `heads` using a checkpoint when one applies.
70 +
    pub(crate) fn load_state_checkpointed(&self, heads: &[ChangeHash]) -> Result<Option<State>> {
71 +
        let mut bookmarks: Vec<String> = vec![self.config.bookmark.clone()];
72 +
        if let Ok(rd) = fs::read_dir(self.oxy.join("checkpoints")) {
73 +
            for e in rd.flatten() {
74 +
                let name = e.file_name().to_string_lossy().to_string();
75 +
                if !bookmarks.contains(&name) {
76 +
                    bookmarks.push(name);
77 +
                }
78 +
            }
79 +
        }
80 +
        for b in bookmarks {
81 +
            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 +
            let payload = self.read_blob(&cp.payload)?;
87 +
            let base = State::from_checkpoint_payload(&payload)?;
88 +
            let cp_heads: BTreeSet<ChangeHash> = cp.heads.iter().copied().collect();
89 +
            let mut found: BTreeSet<ChangeHash> = BTreeSet::new();
90 +
            let mut delta: Vec<Change> = Vec::new();
91 +
            let mut seen: BTreeSet<ChangeHash> = BTreeSet::new();
92 +
            let mut stack: Vec<ChangeHash> = heads.to_vec();
93 +
            while let Some(h) = stack.pop() {
94 +
                if !seen.insert(h) {
95 +
                    continue;
96 +
                }
97 +
                if base.contains(&h) {
98 +
                    if cp_heads.contains(&h) {
99 +
                        found.insert(h);
100 +
                    }
101 +
                    continue;
102 +
                }
103 +
                let c = self.store.get_change(&h)?;
104 +
                stack.extend(c.deps.iter().copied());
105 +
                delta.push(c);
106 +
            }
107 +
            if found.len() != cp_heads.len() {
108 +
                continue;
109 +
            }
110 +
            let mut state = base;
111 +
            for c in delta {
112 +
                state.add_unchecked(c);
113 +
            }
114 +
            state.validate()?;
115 +
            return Ok(Some(state));
116 +
        }
117 +
        Ok(None)
118 +
    }
119 +
}
modified crates/oxy-core/src/graph.rs +89 −0
…
352 unchanged lines
353 353
    }
354 354
}
355 355
356 +
fn vertex_value(v: &Vertex) -> crate::cbor::Value {
357 +
    use crate::cbor::Value;
358 +
    match v {
359 +
        Vertex::Start => Value::Uint(0),
360 +
        Vertex::End => Value::Uint(1),
361 +
        Vertex::Atom((c, i)) => Value::Array(vec![Value::Bytes(c.0.to_vec()), Value::Uint(*i as u64)]),
362 +
    }
363 +
}
364 +
365 +
fn vertex_from(v: &crate::cbor::Value) -> Result<Vertex, crate::objects::ObjectError> {
366 +
    use crate::cbor::Value;
367 +
    use crate::objects::ObjectError;
368 +
    match v {
369 +
        Value::Uint(0) => Ok(Vertex::Start),
370 +
        Value::Uint(1) => Ok(Vertex::End),
371 +
        Value::Array(a) if a.len() == 2 => Ok(Vertex::Atom((crate::objects::oref(&a[0], "vertex")?, crate::objects::u32_(&a[1], "vertex")?))),
372 +
        _ => Err(ObjectError::Schema("vertex: expected 0, 1, or [hash, index]".into())),
373 +
    }
374 +
}
375 +
376 +
impl FileGraph {
377 +
    /// Serialize for a checkpoint: `{0: atoms [[hash, index, content|null, [deleters]]], 1: edges [[from, to]]}`.
378 +
    pub fn to_value(&self) -> crate::cbor::Value {
379 +
        use crate::cbor::Value;
380 +
        let mut atoms: Vec<(&AtomKey, &AtomInfo)> = self.atoms.iter().collect();
381 +
        atoms.sort_by_key(|(k, _)| **k);
382 +
        let mut edges: Vec<(Vertex, Vertex)> = self.succ.iter().flat_map(|(a, bs)| bs.iter().map(move |b| (*a, *b))).collect();
383 +
        edges.sort();
384 +
        Value::map([
385 +
            (
386 +
                0,
387 +
                Value::Array(
388 +
                    atoms
389 +
                        .into_iter()
390 +
                        .map(|((c, i), info)| {
391 +
                            Value::Array(vec![
392 +
                                Value::Bytes(c.0.to_vec()),
393 +
                                Value::Uint(*i as u64),
394 +
                                info.content.as_ref().map(|b| Value::Bytes(b.clone())).unwrap_or(Value::Null),
395 +
                                Value::Array(info.deleted_by.iter().map(|d| Value::Bytes(d.0.to_vec())).collect()),
396 +
                            ])
397 +
                        })
398 +
                        .collect(),
399 +
                ),
400 +
            ),
401 +
            (1, Value::Array(edges.iter().map(|(a, b)| Value::Array(vec![vertex_value(a), vertex_value(b)])).collect())),
402 +
        ])
403 +
    }
404 +
405 +
    pub fn from_value(v: &crate::cbor::Value) -> Result<FileGraph, crate::objects::ObjectError> {
406 +
        use crate::objects::{array, bytes, field, map, oref, u32_, ObjectError};
407 +
        let m = map(v, "file graph")?;
408 +
        let mut g = FileGraph { atoms: HashMap::new(), succ: HashMap::new(), pred: HashMap::new() };
409 +
        for a in array(field(m, 0, "atoms")?, "atoms")? {
410 +
            let e = array(a, "atom")?;
411 +
            if e.len() != 4 {
412 +
                return Err(ObjectError::Schema("atom: expected 4 fields".into()));
413 +
            }
414 +
            let key = (oref(&e[0], "atom change")?, u32_(&e[1], "atom index")?);
415 +
            let content = if e[2].is_null() { None } else { Some(bytes(&e[2], "atom content")?.to_vec()) };
416 +
            let deleted_by = array(&e[3], "deleters")?.iter().map(|d| oref(d, "deleter")).collect::<Result<BTreeSet<_>, _>>()?;
417 +
            g.atoms.insert(key, AtomInfo { content, deleted_by });
418 +
        }
419 +
        for e in array(field(m, 1, "edges")?, "edges")? {
420 +
            let pair = array(e, "edge")?;
421 +
            if pair.len() != 2 {
422 +
                return Err(ObjectError::Schema("edge: expected [from, to]".into()));
423 +
            }
424 +
            g.edge(vertex_from(&pair[0])?, vertex_from(&pair[1])?);
425 +
        }
426 +
        Ok(g)
427 +
    }
428 +
}
429 +
356 430
/// Split a plain run into `Lines` and `Zombie` segments around ghost atoms.
357 431
fn split_zombies(atoms: Vec<AtomKey>, ghosts: &HashSet<AtomKey>) -> Vec<Segment> {
358 432
    let mut out = Vec::new();
…
187 unchanged lines
546 620
        assert_eq!(text(&g, &g.materialize(&NoDeps)), "no newline");
547 621
    }
548 622
623 +
    #[test]
624 +
    fn serialization_round_trip() {
625 +
        let mut g = FileGraph::new();
626 +
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n", "b\n"]));
627 +
        g.insert(h(2), 0, Vertex::Atom((h(1), 0)), Vertex::Atom((h(1), 1)), &lines(&["x\n"]));
628 +
        g.delete(h(3), &AtomRange { change: h(1), from: 1, to: 2 });
629 +
        let v = g.to_value();
630 +
        let bytes = crate::cbor::encode(&v);
631 +
        let back = FileGraph::from_value(&crate::cbor::decode(&bytes).unwrap()).unwrap();
632 +
        let deps = Deps([(h(2), vec![h(1)]), (h(3), vec![h(1)])].into_iter().collect());
633 +
        assert_eq!(back.materialize(&deps), g.materialize(&deps));
634 +
        assert_eq!(text(&back, &back.materialize(&deps)), "a\nx\n");
635 +
        assert_eq!(crate::cbor::encode(&back.to_value()), bytes, "deterministic");
636 +
    }
637 +
549 638
    /// Spec §7.3: the graph is a function of the set of changes, not their order.
550 639
    #[test]
551 640
    fn application_order_is_irrelevant() {
…
36 unchanged lines
modified crates/oxy-core/src/objects.rs +41 −6
…
101 unchanged lines
102 102
}
103 103
104 104
impl NodeKind {
105 -
    fn tag(self) -> u64 {
105 +
    pub(crate) fn tag(self) -> u64 {
106 106
        match self {
107 107
            NodeKind::File => 0,
108 108
            NodeKind::Dir => 1,
109 109
            NodeKind::Symlink => 2,
110 110
        }
111 111
    }
112 -
    fn from_tag(t: u64) -> Result<NodeKind, ObjectError> {
112 +
    pub(crate) fn from_tag(t: u64) -> Result<NodeKind, ObjectError> {
113 113
        Ok(match t {
114 114
            0 => NodeKind::File,
115 115
            1 => NodeKind::Dir,
…
13 unchanged lines
129 129
impl Mode {
130 130
    pub const TEXT: Mode = Mode { exec: false, opaque: false };
131 131
    pub const OPAQUE: Mode = Mode { exec: false, opaque: true };
132 -
    fn bits(self) -> u64 {
132 +
    pub(crate) fn bits(self) -> u64 {
133 133
        (self.exec as u64) | ((self.opaque as u64) << 1)
134 134
    }
135 -
    fn from_bits(b: u64) -> Result<Mode, ObjectError> {
135 +
    pub(crate) fn from_bits(b: u64) -> Result<Mode, ObjectError> {
136 136
        if b > 3 {
137 137
            return schema(format!("unknown mode bits {b}"));
138 138
        }
…
280 unchanged lines
419 419
}
420 420
421 421
impl ChangeKind {
422 -
    fn tag(self) -> u64 {
422 +
    pub(crate) fn tag(self) -> u64 {
423 423
        match self {
424 424
            ChangeKind::Normal => 0,
425 425
            ChangeKind::Resolution => 1,
426 426
            ChangeKind::WorkingCopy => 2,
427 427
        }
428 428
    }
429 -
    fn from_tag(t: u64) -> Result<ChangeKind, ObjectError> {
429 +
    pub(crate) fn from_tag(t: u64) -> Result<ChangeKind, ObjectError> {
430 430
        Ok(match t {
431 431
            0 => ChangeKind::Normal,
432 432
            1 => ChangeKind::Resolution,
…
123 unchanged lines
556 556
    }
557 557
}
558 558
559 +
// ---------------------------------------------------------------- checkpoint
560 +
561 +
/// A materialized state (spec §11): the heads it stands for, how many changes
562 +
/// it holds, and a blob with the serialized graphs (`State::checkpoint_payload`).
563 +
#[derive(Debug, Clone, PartialEq, Eq)]
564 +
pub struct Checkpoint {
565 +
    pub heads: Vec<ChangeHash>,
566 +
    pub count: u64,
567 +
    pub payload: ObjectRef,
568 +
}
569 +
570 +
impl Checkpoint {
571 +
    pub fn encode(&self) -> Vec<u8> {
572 +
        cbor::encode(&Value::map([
573 +
            (0, Value::Uint(FORMAT_VERSION)),
574 +
            (1, Value::Array(self.heads.iter().map(|h| Value::Bytes(h.0.to_vec())).collect())),
575 +
            (2, Value::Uint(self.count)),
576 +
            (3, Value::Bytes(self.payload.0.to_vec())),
577 +
        ]))
578 +
    }
579 +
    pub fn hash(&self) -> ObjectRef {
580 +
        hash_object(ObjectType::Checkpoint, &self.encode())
581 +
    }
582 +
    pub fn decode(bytes: &[u8]) -> Result<Checkpoint, ObjectError> {
583 +
        let v = cbor::decode(bytes)?;
584 +
        let m = map(&v, "checkpoint")?;
585 +
        check_version(m)?;
586 +
        Ok(Checkpoint {
587 +
            heads: array(field(m, 1, "heads")?, "heads")?.iter().map(|h| oref(h, "head")).collect::<Result<Vec<_>, _>>()?,
588 +
            count: uint(field(m, 2, "count")?, "count")?,
589 +
            payload: oref(field(m, 3, "payload")?, "payload")?,
590 +
        })
591 +
    }
592 +
}
593 +
559 594
// ---------------------------------------------------------------- state
560 595
561 596
/// A state named by its heads (spec §10.1): what a bookmark or tag points at.
…
141 unchanged lines
modified crates/oxy-core/src/repo/config.rs +6 −0
…
26 unchanged lines
27 27
    pub op_retention_days: u64,
28 28
    /// `core.gc-grace`: days an unreachable object survives, protecting concurrent writers (spec §21).
29 29
    pub gc_grace_days: u64,
30 +
    /// `core.checkpoint-interval`: changes between automatic checkpoints (spec §11).
31 +
    pub checkpoint_interval: u64,
30 32
}
31 33
32 34
impl Config {
…
11 unchanged lines
44 46
            remote_cas: BTreeMap::new(),
45 47
            op_retention_days: 90,
46 48
            gc_grace_days: 14,
49 +
            checkpoint_interval: 256,
47 50
        }
48 51
    }
49 52
…
45 unchanged lines
95 98
            "workspace.bookmark" => self.bookmark = value.into(),
96 99
            "core.op-retention" => self.op_retention_days = parse_days(value)?,
97 100
            "core.gc-grace" => self.gc_grace_days = parse_days(value)?,
101 +
            "core.checkpoint-interval" => {
102 +
                self.checkpoint_interval = value.parse().map_err(|_| RepoError::Msg(format!("expected a number, got {value:?}")))?;
103 +
            }
98 104
            k if k.starts_with("remote.") => {
99 105
                let rest = &k["remote.".len()..];
100 106
                let Some((name, field)) = rest.rsplit_once('.') else {
…
81 unchanged lines
modified crates/oxy-core/src/repo/gc.rs +11 −0
…
47 unchanged lines
48 48
                policies.extend(op.view.policy);
49 49
            }
50 50
        }
51 +
        // Checkpoints: the object, its payload blob, and the blob's chunks.
52 +
        for r in self.checkpoint_refs()? {
53 +
            if let Ok(bytes) = self.store.get_typed(&r, ObjectType::Checkpoint) {
54 +
                keep.insert(r);
55 +
                let cp = crate::objects::Checkpoint::decode(&bytes)?;
56 +
                keep.insert(cp.payload);
57 +
                if let Ok(b) = self.store.get_typed(&cp.payload, ObjectType::Blob) {
58 +
                    keep.extend(Blob::decode(&b)?.chunks);
59 +
                }
60 +
            }
61 +
        }
51 62
        // Reference attestations.
52 63
        for name in self.view()?.bookmarks.keys() {
53 64
            if let Some((r, _)) = self.latest_ref_attest(name)? {
…
56 unchanged lines
modified crates/oxy-core/src/repo/history.rs +2 −1
…
89 unchanged lines
90 90
        if old_change.kind == ChangeKind::WorkingCopy {
91 91
            return fail("that is the working copy; commit it instead");
92 92
        }
93 -
        let rewritten = propagate(&state, *old, new, corr).map_err(|e| RepoError::Msg(format!("{e}; rewriting through such references is not implemented yet")))?;
93 +
        let load = |h: &ChangeHash| self.store.get_change(h).ok();
94 +
        let rewritten = propagate(&state, *old, new, corr, &load).map_err(|e| RepoError::Msg(format!("{e}; rewriting through such references is not implemented yet")))?;
94 95
95 96
        let mut mapping: HashMap<ChangeHash, ChangeHash> = HashMap::new();
96 97
        let new_hash = match new {
…
113 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +14 −5
…
51 unchanged lines
52 52
}
53 53
54 54
mod checkout;
55 +
mod checkpoint;
55 56
mod config;
56 57
mod gc;
57 58
mod git;
…
317 unchanged lines
375 376
376 377
    // ------------------------------------------------------------ states
377 378
378 -
    /// Load the dependency closure of `heads` from the store.
379 +
    /// Load the dependency closure of `heads`, through a checkpoint when one applies.
379 380
    pub fn load_state(&self, heads: &[ChangeHash]) -> Result<State> {
381 +
        if let Some(st) = self.load_state_checkpointed(heads)? {
382 +
            return Ok(st);
383 +
        }
384 +
        self.load_state_full(heads)
385 +
    }
386 +
387 +
    /// Load the dependency closure of `heads` change by change.
388 +
    pub fn load_state_full(&self, heads: &[ChangeHash]) -> Result<State> {
380 389
        let mut state = State::new();
381 390
        let mut stack: Vec<ChangeHash> = heads.to_vec();
382 391
        let mut seen: BTreeSet<ChangeHash> = BTreeSet::new();
…
104 unchanged lines
487 496
        heads.push(wc_hash);
488 497
        let state = self.load_state(&heads)?;
489 498
        let head_set: BTreeSet<ChangeHash> = self.bookmark_heads()?.into_iter().collect();
490 -
        let mut entries: Vec<LogEntry> = state
491 -
            .changes()
492 -
            .map(|(h, c)| LogEntry { hash: *h, change: c.clone(), is_working_copy: *h == wc_hash, is_head: head_set.contains(h) })
493 -
            .collect();
499 +
        let mut entries: Vec<LogEntry> = Vec::new();
500 +
        for (h, _) in state.changes() {
501 +
            entries.push(LogEntry { hash: *h, change: self.store.get_change(h)?, is_working_copy: *h == wc_hash, is_head: head_set.contains(h) });
502 +
        }
494 503
        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)));
495 504
        Ok(entries)
496 505
    }
…
80 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +6 −0
…
196 unchanged lines
197 197
        let mut with_new = old_heads.clone();
198 198
        with_new.push(hash);
199 199
        let new_heads = st.maximal(&with_new);
200 +
        // Automatic checkpoint once enough changes accumulated (spec §11).
201 +
        let since = self.latest_checkpoint(&self.config.bookmark)?.map(|(_, cp)| cp.count).unwrap_or(0);
202 +
        if self.config.checkpoint_interval > 0 && (st.len() as u64).saturating_sub(since) >= self.config.checkpoint_interval {
203 +
            let cp_state = self.load_state_full(&new_heads)?;
204 +
            self.write_checkpoint(&self.config.bookmark, &cp_state, &new_heads)?;
205 +
        }
200 206
        let mut wc_base = old_base.clone();
201 207
        wc_base.push(hash);
202 208
        let wc_base = st.maximal(&wc_base);
…
386 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +51 −0
…
1061 unchanged lines
1062 1062
    let out = std::process::Command::new("git").arg("-C").arg(&mirror).args(["show", "main:src/lib.rs"]).output().unwrap();
1063 1063
    assert_eq!(String::from_utf8_lossy(&out.stdout), "pub fn a() {}\npub fn b() {}\npub fn c() {}\n");
1064 1064
}
1065 +
1066 +
#[test]
1067 +
fn checkpoints_accelerate_loading_and_stay_correct() {
1068 +
    hermetic();
1069 +
    let dir = tempfile::tempdir().unwrap();
1070 +
    let root = dir.path();
1071 +
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
1072 +
    repo.config.set("core.checkpoint-interval", "5").unwrap();
1073 +
    repo.save_config().unwrap();
1074 +
    let repo = Repo::open(root).unwrap().with_home(root.join("home"));
1075 +
    let mut hashes = Vec::new();
1076 +
    for i in 0..12 {
1077 +
        write(root, "a.txt", format!("{}\n", (0..=i).map(|k| k.to_string()).collect::<Vec<_>>().join("\n")).as_bytes());
1078 +
        write(root, &format!("f{i}.txt"), b"x\n");
1079 +
        hashes.push(repo.commit(&format!("c{i}"), &[]).unwrap());
1080 +
    }
1081 +
    let (_, cp) = repo.latest_checkpoint("main").unwrap().expect("automatic checkpoint");
1082 +
    assert!(cp.count >= 5 && cp.count <= 12, "{}", cp.count);
1083 +
    // Loading through the checkpoint equals loading everything.
1084 +
    let heads = repo.bookmark_heads().unwrap();
1085 +
    let fast = repo.load_state(&heads).unwrap();
1086 +
    let full = repo.load_state_full(&heads).unwrap();
1087 +
    assert_eq!(fast.len(), full.len());
1088 +
    assert_eq!(fast.heads(), full.heads());
1089 +
    let (a, b) = (fast.materialize(), full.materialize());
1090 +
    assert_eq!(a.files.keys().collect::<Vec<_>>(), b.files.keys().collect::<Vec<_>>());
1091 +
    for (p, f) in &a.files {
1092 +
        assert_eq!(f.content, b.files[p].content, "{p}");
1093 +
    }
1094 +
    assert!(repo.load_state_checkpointed(&heads).unwrap().is_some());
1095 +
    // A head set that does not contain the checkpoint's heads falls back.
1096 +
    assert!(repo.load_state_checkpointed(&[hashes[0]]).unwrap().is_none());
1097 +
    assert_eq!(repo.load_state(&[hashes[0]]).unwrap().len(), 1);
1098 +
    // Everything else keeps working on top of the checkpoint.
1099 +
    assert!(!repo.snapshot("status").unwrap().changed);
1100 +
    let h = repo.describe(&hashes[3], "c3 described").unwrap();
1101 +
    assert_eq!(repo.store().get_change(&h).unwrap().message, "c3 described");
1102 +
    let (r, n, bytes) = repo.checkpoint().unwrap();
1103 +
    assert!(n >= 12 && bytes > 0);
1104 +
    assert_eq!(repo.latest_checkpoint("main").unwrap().unwrap().0, r);
1105 +
    write(root, "a.txt", b"after\n");
1106 +
    let h_after = repo.commit("after", &[]).unwrap();
1107 +
    let st = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap();
1108 +
    assert!(st.contains(&h_after));
1109 +
    assert!(matches!(&st.materialize().files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"after\n"));
1110 +
    assert_eq!(repo.log().unwrap().len(), 14);
1111 +
    // Checkpoints survive gc.
1112 +
    repo.gc(true).unwrap();
1113 +
    assert!(repo.latest_checkpoint("main").unwrap().is_some());
1114 +
    assert!(repo.load_state_checkpointed(&repo.bookmark_heads().unwrap()).unwrap().is_some());
1115 +
}
modified crates/oxy-core/src/rewrite.rs +24 −13
…
64 unchanged lines
65 65
/// `None` when `old` is removed) and the correspondence between them.
66 66
/// Returns `(old hash, rewritten change)` pairs in dependency order, not
67 67
/// including `old` → `new` itself.
68 -
pub fn propagate(state: &State, old: ChangeHash, new: Option<&Change>, corr: &Correspondence) -> Result<Vec<(ChangeHash, Change)>, RewriteError> {
68 +
///
69 +
/// `load` fetches a change's full object (a state keeps only metadata).
70 +
pub fn propagate(state: &State, old: ChangeHash, new: Option<&Change>, corr: &Correspondence, load: &dyn Fn(&ChangeHash) -> Option<Change>) -> Result<Vec<(ChangeHash, Change)>, RewriteError> {
69 71
    let old_change = state.get(&old).ok_or(RewriteError::Missing(old))?;
70 72
    let mut mapping: HashMap<ChangeHash, Replacement> = HashMap::new();
71 73
    mapping.insert(old, match new {
…
10 unchanged lines
82 84
83 85
    let mut out = Vec::new();
84 86
    for h in dependents {
85 -
        let c = state.get(&h).ok_or(RewriteError::Missing(h))?;
86 -
        let rewritten = rewrite_one(&h, c, &mapping, &corrs)?;
87 +
        let c = load(&h).ok_or(RewriteError::Missing(h))?;
88 +
        let rewritten = rewrite_one(&h, &c, &mapping, &corrs)?;
87 89
        let rh = rewritten.hash();
88 90
        corrs.insert(h, Correspondence::identity(&rewritten));
89 91
        mapping.insert(h, Replacement::Change(rh));
…
115 unchanged lines
205 207
        // Rewrite c1's message; everything downstream follows.
206 208
        let mut n1 = c1.clone();
207 209
        n1.message = "one, described".into();
208 -
        let rewritten = propagate(&st, h1, Some(&n1), &Correspondence::identity(&c1)).unwrap();
210 +
        let all: HashMap<ChangeHash, Change> = [(h1, c1.clone()), (h2, c2.clone()), (h3, c3.clone())].into_iter().collect();
211 +
        let load = |h: &ChangeHash| all.get(h).cloned();
212 +
        let rewritten = propagate(&st, h1, Some(&n1), &Correspondence::identity(&c1), &load).unwrap();
209 213
        assert_eq!(rewritten.iter().map(|(h, _)| *h).collect::<Vec<_>>(), vec![h2, h3]);
210 214
        let mut st2 = State::new();
211 215
        st2.add(n1.clone()).unwrap();
…
22 unchanged lines
234 238
        let c1 = change(vec![], vec![TreeOp::AddNode { node: f, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "f".into() }], vec![Edit::Insert { node: f, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"a\n".to_vec(), b"b\n".to_vec()] }], "one");
235 239
        let h1 = st.add(c1.clone()).unwrap();
236 240
        let c2 = change(vec![h1], vec![], vec![Edit::Insert { node: f, after: AtomRef::Atom { change: h1, index: 1 }, before: AtomRef::End, lines: vec![b"c\n".to_vec()] }], "two");
237 -
        let h2 = st.add(c2).unwrap();
241 +
        let h2 = st.add(c2.clone()).unwrap();
242 +
        let all: HashMap<ChangeHash, Change> = [(h1, c1.clone()), (h2, c2)].into_iter().collect();
243 +
        let load = |h: &ChangeHash| all.get(h).cloned();
238 244
        // The rewritten c1 drops line "b": atom 1 has no counterpart.
239 245
        let mut n1 = c1.clone();
240 246
        n1.edits = vec![Edit::Insert { node: f, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"a\n".to_vec()] }];
241 247
        let corr = Correspondence { atoms: [(0, 0)].into_iter().collect(), ops: [(0, 0)].into_iter().collect() };
242 -
        let err = propagate(&st, h1, Some(&n1), &corr).unwrap_err();
248 +
        let err = propagate(&st, h1, Some(&n1), &corr, &load).unwrap_err();
243 249
        assert_eq!(err, RewriteError::Orphaned { change: h2, what: "atom 1".into() });
244 -
        assert_eq!(propagate(&st, ObjectRef([9; 32]), Some(&n1), &corr).unwrap_err(), RewriteError::Missing(ObjectRef([9; 32])));
250 +
        assert_eq!(propagate(&st, ObjectRef([9; 32]), Some(&n1), &corr, &load).unwrap_err(), RewriteError::Missing(ObjectRef([9; 32])));
245 251
    }
246 252
247 253
    #[test]
…
2 unchanged lines
250 256
        let f = NodeId::random();
251 257
        let g = NodeId::random();
252 258
        let c1 = change(vec![], vec![TreeOp::AddNode { node: f, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "f".into() }], vec![], "one");
253 -
        let h1 = st.add(c1).unwrap();
259 +
        let h1 = st.add(c1.clone()).unwrap();
254 260
        // c2 merely depends on c1 explicitly (touches another file).
255 261
        let c2 = change(vec![h1], vec![TreeOp::AddNode { node: g, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "g".into() }], vec![], "two");
256 262
        let h2 = st.add(c2.clone()).unwrap();
257 263
        let c3 = change(vec![h2], vec![], vec![Edit::Insert { node: g, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"x\n".to_vec()] }], "three");
258 -
        let h3 = st.add(c3).unwrap();
264 +
        let h3 = st.add(c3.clone()).unwrap();
265 +
        let mut all: HashMap<ChangeHash, Change> = [(h1, c1), (h2, c2), (h3, c3)].into_iter().collect();
266 +
        let load_all = all.clone();
267 +
        let load = move |h: &ChangeHash| load_all.get(h).cloned();
259 268
        // Removing c2 (an explicit-only dependency of nothing that references it) works: c3 inherits h1.
260 -
        let out = propagate(&st, h2, None, &Correspondence::default()).unwrap();
269 +
        let out = propagate(&st, h2, None, &Correspondence::default(), &load).unwrap();
261 270
        assert_eq!(out.len(), 1);
262 271
        assert_eq!(out[0].0, h3);
263 272
        assert_eq!(out[0].1.deps, vec![h1]);
264 273
        // Removing c1 fails: c2 does not reference c1 (only an explicit dep), but
265 274
        // nothing else does either, so it succeeds and c2 inherits no deps.
266 -
        let out = propagate(&st, h1, None, &Correspondence::default()).unwrap();
275 +
        let out = propagate(&st, h1, None, &Correspondence::default(), &load).unwrap();
267 276
        assert!(out.iter().any(|(h, c)| *h == h2 && c.deps.is_empty()));
268 277
        // Removing c2 when c3 references its placement fails.
269 278
        let c4 = change(vec![h2], vec![TreeOp::RemoveNode { node: g, placement: OpRef { change: h2, index: 0 } }], vec![], "four");
270 -
        let h4 = st.add(c4).unwrap();
271 -
        let err = propagate(&st, h2, None, &Correspondence::default()).unwrap_err();
279 +
        let h4 = st.add(c4.clone()).unwrap();
280 +
        all.insert(h4, c4);
281 +
        let load2 = |h: &ChangeHash| all.get(h).cloned();
282 +
        let err = propagate(&st, h2, None, &Correspondence::default(), &load2).unwrap_err();
272 283
        assert!(matches!(err, RewriteError::Orphaned { change, .. } if change == h4 || change == h3), "{err:?}");
273 284
    }
274 285
}
modified crates/oxy-core/src/state.rs +144 −5
…
3 unchanged lines
4 4
use crate::graph::{AtomKey, DepOracle, FileGraph, Materialized, Vertex};
5 5
use crate::hash::{ChangeHash, ObjectRef};
6 6
use crate::ids::NodeId;
7 -
use crate::objects::{AtomRef, Change, Edit, Mode, NodeKind, TreeOp};
7 +
use crate::cbor::{self, Value};
8 +
use crate::hash::KeyId;
9 +
use crate::objects::{self as o, AtomRef, Change, ChangeKind, Edit, Hlc, Mode, NodeKind, ObjectError, TreeOp};
8 10
use crate::tree::{TreeGraph, TreeView};
9 11
use std::cell::RefCell;
10 12
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
…
8 unchanged lines
19 21
/// One `Replace` edit: `(change, old blob, new blob)`.
20 22
type ReplaceRecord = (ChangeHash, Option<ObjectRef>, Option<ObjectRef>);
21 23
24 +
/// What a state remembers about a change: everything but its operations,
25 +
/// which live in the graphs once applied (and in the store).
26 +
#[derive(Clone, Debug, PartialEq, Eq)]
27 +
pub struct ChangeMeta {
28 +
    pub change_id: crate::ids::ChangeId,
29 +
    pub deps: Vec<ChangeHash>,
30 +
    pub author: KeyId,
31 +
    pub hlc: Hlc,
32 +
    pub message: String,
33 +
    pub kind: ChangeKind,
34 +
}
35 +
36 +
impl ChangeMeta {
37 +
    pub fn of(c: &Change) -> ChangeMeta {
38 +
        ChangeMeta { change_id: c.change_id, deps: c.deps.clone(), author: c.author, hlc: c.hlc, message: c.message.clone(), kind: c.kind }
39 +
    }
40 +
}
41 +
22 42
#[derive(Default, Clone)]
23 43
pub struct State {
24 -
    changes: HashMap<ChangeHash, Change>,
44 +
    changes: HashMap<ChangeHash, ChangeMeta>,
25 45
    tree: TreeGraph,
26 46
    files: HashMap<NodeId, FileGraph>,
27 47
    replaces: HashMap<NodeId, Vec<ReplaceRecord>>,
…
81 unchanged lines
109 129
    pub fn contains(&self, h: &ChangeHash) -> bool {
110 130
        self.changes.contains_key(h)
111 131
    }
112 -
    pub fn get(&self, h: &ChangeHash) -> Option<&Change> {
132 +
    pub fn get(&self, h: &ChangeHash) -> Option<&ChangeMeta> {
113 133
        self.changes.get(h)
114 134
    }
115 -
    pub fn changes(&self) -> impl Iterator<Item = (&ChangeHash, &Change)> {
135 +
    pub fn changes(&self) -> impl Iterator<Item = (&ChangeHash, &ChangeMeta)> {
116 136
        self.changes.iter()
117 137
    }
118 138
    pub fn tree(&self) -> &TreeGraph {
…
24 unchanged lines
143 163
            return h;
144 164
        }
145 165
        self.apply(h, &change);
146 -
        self.changes.insert(h, change);
166 +
        self.changes.insert(h, ChangeMeta::of(&change));
147 167
        self.closure.borrow_mut().clear();
148 168
        h
149 169
    }
150 170
171 +
    // ------------------------------------------------------------ checkpoints (spec §11)
172 +
173 +
    /// Serialize the whole state: change metadata, tree, file graphs, replaces.
174 +
    pub fn checkpoint_payload(&self) -> Vec<u8> {
175 +
        let mut hashes: Vec<&ChangeHash> = self.changes.keys().collect();
176 +
        hashes.sort();
177 +
        let changes = Value::Array(
178 +
            hashes
179 +
                .into_iter()
180 +
                .map(|h| {
181 +
                    let m = &self.changes[h];
182 +
                    Value::Array(vec![
183 +
                        Value::Bytes(h.0.to_vec()),
184 +
                        Value::Bytes(m.change_id.0.to_vec()),
185 +
                        Value::Array(m.deps.iter().map(|d| Value::Bytes(d.0.to_vec())).collect()),
186 +
                        Value::Text(m.message.clone()),
187 +
                        Value::Uint(m.hlc.wall_ms),
188 +
                        Value::Uint(m.hlc.logical as u64),
189 +
                        Value::Uint(m.kind.tag()),
190 +
                        Value::Bytes(m.author.0.to_vec()),
191 +
                    ])
192 +
                })
193 +
                .collect(),
194 +
        );
195 +
        let mut nodes: Vec<&NodeId> = self.files.keys().collect();
196 +
        nodes.sort();
197 +
        let files = Value::Array(nodes.into_iter().map(|n| Value::Array(vec![Value::Bytes(n.0.to_vec()), self.files[n].to_value()])).collect());
198 +
        let mut rnodes: Vec<&NodeId> = self.replaces.keys().collect();
199 +
        rnodes.sort();
200 +
        let opt = |o: &Option<ObjectRef>| o.map(|r| Value::Bytes(r.0.to_vec())).unwrap_or(Value::Null);
201 +
        let replaces = Value::Array(
202 +
            rnodes
203 +
                .into_iter()
204 +
                .map(|n| {
205 +
                    Value::Array(vec![
206 +
                        Value::Bytes(n.0.to_vec()),
207 +
                        Value::Array(self.replaces[n].iter().map(|(c, o, nw)| Value::Array(vec![Value::Bytes(c.0.to_vec()), opt(o), opt(nw)])).collect()),
208 +
                    ])
209 +
                })
210 +
                .collect(),
211 +
        );
212 +
        cbor::encode(&Value::map([(0, Value::Uint(o::FORMAT_VERSION)), (1, changes), (2, self.tree.to_value()), (3, files), (4, replaces)]))
213 +
    }
214 +
215 +
    pub fn from_checkpoint_payload(bytes: &[u8]) -> Result<State, ObjectError> {
216 +
        let v = cbor::decode(bytes)?;
217 +
        let m = o::map(&v, "checkpoint payload")?;
218 +
        o::check_version(m)?;
219 +
        let mut st = State::new();
220 +
        for c in o::array(o::field(m, 1, "changes")?, "changes")? {
221 +
            let e = o::array(c, "change meta")?;
222 +
            if e.len() != 8 {
223 +
                return Err(ObjectError::Schema("change meta: expected 8 fields".into()));
224 +
            }
225 +
            let hash = o::oref(&e[0], "hash")?;
226 +
            let meta = ChangeMeta {
227 +
                change_id: crate::ids::ChangeId::from_slice(o::bytes(&e[1], "change id")?).ok_or_else(|| ObjectError::Schema("change id: expected 16 bytes".into()))?,
228 +
                deps: o::array(&e[2], "deps")?.iter().map(|d| o::oref(d, "dep")).collect::<Result<Vec<_>, _>>()?,
229 +
                message: o::text(&e[3], "message")?.to_owned(),
230 +
                hlc: Hlc { wall_ms: o::uint(&e[4], "wall")?, logical: o::u32_(&e[5], "logical")? },
231 +
                kind: ChangeKind::from_tag(o::uint(&e[6], "kind")?)?,
232 +
                author: o::oref(&e[7], "author")?,
233 +
            };
234 +
            st.changes.insert(hash, meta);
235 +
        }
236 +
        st.tree = TreeGraph::from_value(o::field(m, 2, "tree")?)?;
237 +
        for f in o::array(o::field(m, 3, "files")?, "files")? {
238 +
            let e = o::array(f, "file")?;
239 +
            if e.len() != 2 {
240 +
                return Err(ObjectError::Schema("file: expected [node, graph]".into()));
241 +
            }
242 +
            let node = NodeId::from_slice(o::bytes(&e[0], "node")?).ok_or_else(|| ObjectError::Schema("node: expected 16 bytes".into()))?;
243 +
            st.files.insert(node, FileGraph::from_value(&e[1])?);
244 +
        }
245 +
        let opt = |v: &Value| -> Result<Option<ObjectRef>, ObjectError> { if v.is_null() { Ok(None) } else { o::oref(v, "blob").map(Some) } };
246 +
        for r in o::array(o::field(m, 4, "replaces")?, "replaces")? {
247 +
            let e = o::array(r, "replace")?;
248 +
            if e.len() != 2 {
249 +
                return Err(ObjectError::Schema("replace: expected [node, records]".into()));
250 +
            }
251 +
            let node = NodeId::from_slice(o::bytes(&e[0], "node")?).ok_or_else(|| ObjectError::Schema("node: expected 16 bytes".into()))?;
252 +
            let mut recs = Vec::new();
253 +
            for rec in o::array(&e[1], "records")? {
254 +
                let x = o::array(rec, "record")?;
255 +
                if x.len() != 3 {
256 +
                    return Err(ObjectError::Schema("record: expected 3 fields".into()));
257 +
                }
258 +
                recs.push((o::oref(&x[0], "change")?, opt(&x[1])?, opt(&x[2])?));
259 +
            }
260 +
            st.replaces.insert(node, recs);
261 +
        }
262 +
        Ok(st)
263 +
    }
264 +
151 265
    pub fn validate(&self) -> Result<(), StateError> {
152 266
        for (h, c) in &self.changes {
153 267
            for d in &c.deps {
…
273 unchanged lines
427 541
    }
428 542
429 543
    #[test]
544 +
    fn checkpoint_payload_round_trip_and_replay() {
545 +
        let mut st = State::new();
546 +
        let f = NodeId::random();
547 +
        let h1 = st.add(change(vec![], vec![TreeOp::AddNode { node: f, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "a".into() }], vec![Edit::Insert { node: f, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"1\n".to_vec(), b"2\n".to_vec()] }])).unwrap();
548 +
        let h2 = st.add(change(vec![h1], vec![], vec![Edit::Delete { node: f, atoms: vec![AtomRange { change: h1, from: 0, to: 1 }] }])).unwrap();
549 +
        let payload = st.checkpoint_payload();
550 +
        let back = State::from_checkpoint_payload(&payload).unwrap();
551 +
        assert_eq!(back.len(), 2);
552 +
        assert_eq!(back.heads(), vec![h2]);
553 +
        assert_eq!(back.get(&h1).unwrap().message, "m");
554 +
        assert_eq!(back.checkpoint_payload(), payload, "deterministic");
555 +
        // Replaying a further change onto the restored state equals a full build.
556 +
        let c3 = change(vec![h2], vec![], vec![Edit::Insert { node: f, after: AtomRef::Atom { change: h1, index: 1 }, before: AtomRef::End, lines: vec![b"3\n".to_vec()] }]);
557 +
        let mut full = st.clone();
558 +
        full.add(c3.clone()).unwrap();
559 +
        let mut restored = back;
560 +
        restored.add(c3).unwrap();
561 +
        let (a, b) = (full.materialize(), restored.materialize());
562 +
        assert_eq!(a.files.keys().collect::<Vec<_>>(), b.files.keys().collect::<Vec<_>>());
563 +
        assert_eq!(a.files["a"].content, b.files["a"].content);
564 +
        assert!(matches!(&b.files["a"].content, FileContent::Text { bytes, .. } if bytes == b"2\n3\n"));
565 +
        assert!(State::from_checkpoint_payload(b"nonsense").is_err());
566 +
    }
567 +
568 +
    #[test]
430 569
    fn maximal_elements() {
431 570
        let mut st = State::new();
432 571
        let h1 = st.add(change(vec![], vec![], vec![])).unwrap();
…
10 unchanged lines
modified crates/oxy-core/src/tree.rs +139 −0
…
263 unchanged lines
264 264
    }
265 265
}
266 266
267 +
impl TreeGraph {
268 +
    /// Serialize for a checkpoint.
269 +
    pub fn to_value(&self) -> crate::cbor::Value {
270 +
        use crate::cbor::Value;
271 +
        let opkey = |k: &OpKey| vec![Value::Bytes(k.0 .0.to_vec()), Value::Uint(k.1 as u64)];
272 +
        let deleters = |m: &HashMap<OpKey, BTreeSet<ChangeHash>>| {
273 +
            let mut ks: Vec<&OpKey> = m.keys().collect();
274 +
            ks.sort();
275 +
            Value::Array(
276 +
                ks.into_iter()
277 +
                    .map(|k| {
278 +
                        let mut v = opkey(k);
279 +
                        v.push(Value::Array(m[k].iter().map(|d| Value::Bytes(d.0.to_vec())).collect()));
280 +
                        Value::Array(v)
281 +
                    })
282 +
                    .collect(),
283 +
            )
284 +
        };
285 +
        let mut nodes: Vec<&NodeId> = self.kinds.keys().collect();
286 +
        nodes.sort();
287 +
        let mut pks: Vec<&OpKey> = self.placements.keys().collect();
288 +
        pks.sort();
289 +
        let mut mks: Vec<&OpKey> = self.modes.keys().collect();
290 +
        mks.sort();
291 +
        Value::map([
292 +
            (
293 +
                0,
294 +
                Value::Array(
295 +
                    nodes
296 +
                        .into_iter()
297 +
                        .map(|n| {
298 +
                            Value::Array(vec![
299 +
                                Value::Bytes(n.0.to_vec()),
300 +
                                Value::Uint(self.kinds[n].tag()),
301 +
                                self.introduced.get(n).map(|h| Value::Bytes(h.0.to_vec())).unwrap_or(Value::Null),
302 +
                            ])
303 +
                        })
304 +
                        .collect(),
305 +
                ),
306 +
            ),
307 +
            (
308 +
                1,
309 +
                Value::Array(
310 +
                    pks.into_iter()
311 +
                        .map(|k| {
312 +
                            let p = &self.placements[k];
313 +
                            let mut v = opkey(k);
314 +
                            v.extend([Value::Bytes(p.node.0.to_vec()), Value::Bytes(p.parent.0.to_vec()), Value::Text(p.name.clone())]);
315 +
                            Value::Array(v)
316 +
                        })
317 +
                        .collect(),
318 +
                ),
319 +
            ),
320 +
            (2, deleters(&self.deleted_placements)),
321 +
            (
322 +
                3,
323 +
                Value::Array(
324 +
                    mks.into_iter()
325 +
                        .map(|k| {
326 +
                            let (n, m) = &self.modes[k];
327 +
                            let mut v = opkey(k);
328 +
                            v.extend([Value::Bytes(n.0.to_vec()), Value::Uint(m.bits())]);
329 +
                            Value::Array(v)
330 +
                        })
331 +
                        .collect(),
332 +
                ),
333 +
            ),
334 +
            (4, deleters(&self.deleted_modes)),
335 +
        ])
336 +
    }
337 +
338 +
    pub fn from_value(v: &crate::cbor::Value) -> Result<TreeGraph, crate::objects::ObjectError> {
339 +
        use crate::objects::{array, bytes, field, map, oref, text, u32_, uint, ObjectError};
340 +
        let m = map(v, "tree graph")?;
341 +
        let node = |v: &crate::cbor::Value| NodeId::from_slice(bytes(v, "node")?).ok_or_else(|| ObjectError::Schema("node: expected 16 bytes".into()));
342 +
        let opkey = |e: &[crate::cbor::Value]| -> Result<OpKey, ObjectError> { Ok((oref(&e[0], "op change")?, u32_(&e[1], "op index")?)) };
343 +
        let mut g = TreeGraph::default();
344 +
        for n in array(field(m, 0, "nodes")?, "nodes")? {
345 +
            let e = array(n, "node entry")?;
346 +
            if e.len() != 3 {
347 +
                return Err(ObjectError::Schema("node entry: expected 3 fields".into()));
348 +
            }
349 +
            let id = node(&e[0])?;
350 +
            g.kinds.insert(id, NodeKind::from_tag(uint(&e[1], "kind")?)?);
351 +
            if !e[2].is_null() {
352 +
                g.introduced.insert(id, oref(&e[2], "introducer")?);
353 +
            }
354 +
        }
355 +
        for p in array(field(m, 1, "placements")?, "placements")? {
356 +
            let e = array(p, "placement")?;
357 +
            if e.len() != 5 {
358 +
                return Err(ObjectError::Schema("placement: expected 5 fields".into()));
359 +
            }
360 +
            let k = opkey(e)?;
361 +
            let pl = Placement { node: node(&e[2])?, parent: node(&e[3])?, name: text(&e[4], "name")?.to_owned() };
362 +
            g.placements_of.entry(pl.node).or_default().insert(k);
363 +
            g.placements.insert(k, pl);
364 +
        }
365 +
        let deleters = |v: &crate::cbor::Value, what: &str| -> Result<HashMap<OpKey, BTreeSet<ChangeHash>>, ObjectError> {
366 +
            let mut out = HashMap::new();
367 +
            for d in array(v, what)? {
368 +
                let e = array(d, what)?;
369 +
                if e.len() != 3 {
370 +
                    return Err(ObjectError::Schema(format!("{what}: expected 3 fields")));
371 +
                }
372 +
                out.insert(opkey(e)?, array(&e[2], "deleters")?.iter().map(|x| oref(x, "deleter")).collect::<Result<BTreeSet<_>, _>>()?);
373 +
            }
374 +
            Ok(out)
375 +
        };
376 +
        g.deleted_placements = deleters(field(m, 2, "deleted placements")?, "deleted placement")?;
377 +
        for md in array(field(m, 3, "modes")?, "modes")? {
378 +
            let e = array(md, "mode")?;
379 +
            if e.len() != 4 {
380 +
                return Err(ObjectError::Schema("mode: expected 4 fields".into()));
381 +
            }
382 +
            let k = opkey(e)?;
383 +
            let n = node(&e[2])?;
384 +
            g.modes.insert(k, (n, Mode::from_bits(uint(&e[3], "mode bits")?)?));
385 +
            g.modes_of.entry(n).or_default().insert(k);
386 +
        }
387 +
        g.deleted_modes = deleters(field(m, 4, "deleted modes")?, "deleted mode")?;
388 +
        Ok(g)
389 +
    }
390 +
}
391 +
267 392
pub fn op_key(r: OpRef) -> OpKey {
268 393
    (r.change, r.index)
269 394
}
…
81 unchanged lines
351 476
    }
352 477
353 478
    #[test]
479 +
    fn serialization_round_trip() {
480 +
        let mut g = TreeGraph::new();
481 +
        add(&mut g, 1, 0, 1, NodeKind::Dir, NodeId::ROOT, "src");
482 +
        add(&mut g, 1, 1, 2, NodeKind::File, nid(1), "main.rs");
483 +
        g.apply(h(2), 0, &TreeOp::Move { node: nid(2), old: OpRef { change: h(1), index: 1 }, parent: nid(1), name: "lib.rs".into() });
484 +
        g.apply(h(3), 0, &TreeOp::SetMode { node: nid(2), old: OpRef { change: h(1), index: 1 }, mode: Mode { exec: true, opaque: false } });
485 +
        let bytes = crate::cbor::encode(&g.to_value());
486 +
        let back = TreeGraph::from_value(&crate::cbor::decode(&bytes).unwrap()).unwrap();
487 +
        assert_eq!(back.view(), g.view());
488 +
        assert_eq!(back.introducer(&nid(2)), Some(h(1)));
489 +
        assert_eq!(crate::cbor::encode(&back.to_value()), bytes);
490 +
    }
491 +
492 +
    #[test]
354 493
    fn mode_conflict_and_resolution() {
355 494
        let mut g = TreeGraph::new();
356 495
        add(&mut g, 1, 0, 1, NodeKind::File, NodeId::ROOT, "run.sh");
…
16 unchanged lines
modified src/cli/mod.rs +4 −1
…
62 unchanged lines
63 63
    Resolve,
64 64
    /// Undo the last operation
65 65
    Undo,
66 +
    /// Write a checkpoint of the current bookmark's state now
67 +
    Checkpoint,
66 68
    /// Pack loose objects and prune unreachable ones older than the grace period
67 69
    Gc {
68 70
        /// Ignore the grace period (core.gc-grace) and prune immediately
…
56 unchanged lines
125 127
        #[command(subcommand)]
126 128
        cmd: PolicyCmd,
127 129
    },
128 -
    /// Set a configuration key (author.*, core.sign, core.op-retention, core.gc-grace, workspace.bookmark, remote.<name>.url|token|ca)
130 +
    /// Set a configuration key (author.*, core.sign, core.op-retention, core.gc-grace, core.checkpoint-interval, workspace.bookmark, remote.<name>.url|token|ca)
129 131
    Config {
130 132
        key: String,
131 133
        value: String,
…
85 unchanged lines
217 219
        Cmd::Resolve => workspace::resolve(),
218 220
        Cmd::Undo => workspace::undo(),
219 221
        Cmd::Gc { now } => workspace::gc(now),
222 +
        Cmd::Checkpoint => workspace::checkpoint(),
220 223
        Cmd::Op { cmd: OpCmd::Log } => workspace::op_log(),
221 224
        Cmd::Clone { url, dest } => sync::clone(&url, dest),
222 225
        Cmd::Remote { cmd } => match cmd {
…
25 unchanged lines
modified src/cli/workspace.rs +7 −0
…
243 unchanged lines
244 244
    println!("Abandoned {} #{}; its content remains on disk as uncommitted work if the working copy sat on it.", short_id(&c), h.short(12));
245 245
    Ok(0)
246 246
}
247 +
248 +
pub fn checkpoint() -> Result<i32> {
249 +
    let repo = open()?;
250 +
    let (r, changes, bytes) = repo.checkpoint()?;
251 +
    println!("Checkpoint #{} of {} ({} bytes) written for {}.", r.short(12), plural(changes, "change"), bytes, repo.config.bookmark);
252 +
    Ok(0)
253 +
}