OXYGENHUB

History-only states: log, log of a path and blame_in load changes and tree only, deferring file edits; load_graphs builds the graphs a command needs from checkpoint blobs or by replay, identical to a full load; an unloaded graph is never read as empty e8d38e47e9d7

closes #10
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 20:20 UTC
Signature
✔ signed by Andy McCall
Hash
e8d38e47e9d796a9ead60d554c5e6eb223acaa1eec5922e59bfbecaa5fa939a0
Depends on
0bdc3f91a39f 119bbf50cb86 20247a03a596 252e8cb23fbc 75167672ebcd 957fee30826e 982864068a59 afaba5f1306d bc489b6c7f69 e02bf0cd0533 e7ddd552b619 ff0af1ee299d
Depended on by
c2e1d09047db 5552e69c7325 d45dab0484ea 36e51aa806c6 3f1b8fe25ec9 f9575918477f dbf06d342e1d e83354cdac22 f6451fbbae3f 4d6c9c1b358c 57d8dc05c9d4 519c7c061bcb 01a47cd7f0c8 69816d688300 a4fad5b085c7 86e1824f2d0b dfb23a3a8cfa cc7a00565161
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
29 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified crates/oxy-core/src/repo/checkpoint.rs +14 −1
…
155 unchanged lines
156 156
157 157
    /// Load the state of `heads` using a checkpoint when one applies.
158 158
    pub(crate) fn load_state_checkpointed(&self, heads: &[ChangeHash]) -> Result<Option<State>> {
159 +
        self.load_state_checkpointed_as(heads, false)
160 +
    }
161 +
162 +
    /// As [`Repo::load_state_checkpointed`]; `history` builds no file graph.
163 +
    pub(crate) fn load_state_checkpointed_as(
164 +
        &self,
165 +
        heads: &[ChangeHash],
166 +
        history: bool,
167 +
    ) -> Result<Option<State>> {
159 168
        let mut bookmarks: Vec<String> = vec![self.config.bookmark.clone()];
160 169
        if let Ok(rd) = fs::read_dir(self.oxy.join("checkpoints")) {
161 170
            for e in rd.flatten() {
…
13 unchanged lines
175 184
            };
176 185
            let base = {
177 186
                let _t = crate::timing::phase("load: decode checkpoint");
178 -
                self.state_from_checkpoint(&payload)?
187 +
                if history {
188 +
                    State::from_checkpoint_history(&payload)?
189 +
                } else {
190 +
                    self.state_from_checkpoint(&payload)?
191 +
                }
179 192
            };
180 193
            let cp_heads: BTreeSet<ChangeHash> = cp.heads.iter().copied().collect();
181 194
            let mut found: BTreeSet<ChangeHash> = BTreeSet::new();
…
31 unchanged lines
modified crates/oxy-core/src/repo/inspect.rs +7 −2
…
69 unchanged lines
70 70
71 71
    /// Blame `path` in the state with these heads (hosted repositories).
72 72
    pub fn blame_in(&self, path: &str, heads: &[ChangeHash]) -> Result<Vec<BlameLine>> {
73 -
        let state = self.load_state(heads)?;
74 -
        self.blame_of(&state, &state.tree().view(), path)
73 +
        // Only the one file's graph is built.
74 +
        let mut state = self.load_state_history(heads)?;
75 +
        let tree = state.tree().view();
76 +
        if let Some(e) = tree.entries.get(path) {
77 +
            self.load_graphs_of(&mut state, &[e.node])?;
78 +
        }
79 +
        self.blame_of(&state, &tree, path)
75 80
    }
76 81
77 82
    /// Blame `path` in a loaded state whose tree view the caller holds; no
…
294 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +27 −3
…
777 unchanged lines
778 778
        self.load_state_full(heads)
779 779
    }
780 780
781 +
    /// The state of `heads` for its history (changes, tree, metadata) with
782 +
    /// no file graph built: much cheaper than [`Repo::load_state`] for log
783 +
    /// and friends. Build the graphs a command needs with
784 +
    /// [`Repo::load_graphs_of`].
785 +
    pub fn load_state_history(&self, heads: &[ChangeHash]) -> Result<State> {
786 +
        if let Some(st) = self.load_state_checkpointed_as(heads, true)? {
787 +
            return Ok(st);
788 +
        }
789 +
        self.load_state_replayed(heads, State::new_history_only())
790 +
    }
791 +
792 +
    /// Build these file graphs in a history-only state.
793 +
    pub fn load_graphs_of(&self, state: &mut State, nodes: &[crate::ids::NodeId]) -> Result<()> {
794 +
        Ok(state.load_graphs(nodes, |refs| {
795 +
            self.read_blobs(refs).map_err(|e| {
796 +
                crate::objects::ObjectError::Schema(format!("checkpoint graph blobs: {e}"))
797 +
            })
798 +
        })?)
799 +
    }
800 +
781 801
    /// Load the dependency closure of `heads` change by change.
782 802
    pub fn load_state_full(&self, heads: &[ChangeHash]) -> Result<State> {
783 -
        let mut state = State::new();
803 +
        self.load_state_replayed(heads, State::new())
804 +
    }
805 +
806 +
    fn load_state_replayed(&self, heads: &[ChangeHash], mut state: State) -> Result<State> {
784 807
        let mut stack: Vec<ChangeHash> = heads.to_vec();
785 808
        let mut seen: BTreeSet<ChangeHash> = BTreeSet::new();
786 809
        {
…
486 unchanged lines
1273 1296
        let (wc_hash, _) = self.working_copy()?;
1274 1297
        let mut all = heads.clone();
1275 1298
        all.push(wc_hash);
1276 -
        let state = self.load_state(&all)?;
1299 +
        let state = self.load_state_history(&all)?;
1277 1300
        let head_set: BTreeSet<ChangeHash> = heads.into_iter().collect();
1278 1301
        let mut entries: Vec<LogEntry> = Vec::new();
1279 1302
        for (h, meta) in state.changes() {
…
29 unchanged lines
1309 1332
    /// The log of the state with these heads, newest first, with no working
1310 1333
    /// copy involved (for hosted repositories and any explicit state).
1311 1334
    pub fn log_state(&self, heads: &[ChangeHash]) -> Result<Vec<LogEntry>> {
1312 -
        Ok(self.log_of(&self.load_state(heads)?))
1335 +
        // The log needs change metadata only: no file graph is built.
1336 +
        Ok(self.log_of(&self.load_state_history(heads)?))
1313 1337
    }
1314 1338
1315 1339
    /// The log of a state already loaded, newest first.
…
187 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +1 −1
…
1138 unchanged lines
1139 1139
    /// The log of the state with these heads restricted to changes touching
1140 1140
    /// `path` (its node, so renames are followed).
1141 1141
    pub fn log_for_path_in(&self, path: &str, heads: &[ChangeHash]) -> Result<Vec<LogEntry>> {
1142 -
        self.log_for_path_of(&self.load_state(heads)?, path, None)
1142 +
        self.log_for_path_of(&self.load_state_history(heads)?, path, None)
1143 1143
    }
1144 1144
1145 1145
    /// The changes of a loaded state touching `path`, newest first, at most
…
944 unchanged lines
modified crates/oxy-core/src/state.rs +393 −6
…
61 unchanged lines
62 62
    /// Memoised transitive dependencies. A mutex, not a `RefCell`, so a
63 63
    /// loaded state can be shared across threads by a host that caches it.
64 64
    closure: Mutex<HashMap<ChangeHash, Arc<HashSet<ChangeHash>>>>,
65 +
    /// `None` for a full state. For a history-only state (loaded for its
66 +
    /// changes and tree), the file graphs not built yet, by node: `files`
67 +
    /// holds only those loaded with [`State::load_graphs`].
68 +
    deferred: Option<HashMap<NodeId, Deferred>>,
65 69
}
66 70
71 +
/// A file graph a history-only state has not built: its base from a
72 +
/// version 2 checkpoint, if any, and the edits of later changes in the
73 +
/// order they were applied (spec §11). Building it later gives the same
74 +
/// graph, since graph construction does not depend on order (§8).
75 +
#[derive(Clone, Default)]
76 +
struct Deferred {
77 +
    base: Option<DeferredBase>,
78 +
    edits: Vec<(ChangeHash, Vec<Edit>)>,
79 +
}
80 +
81 +
#[derive(Clone)]
82 +
enum DeferredBase {
83 +
    Inline(Value),
84 +
    Blob(ObjectRef),
85 +
}
86 +
67 87
impl Clone for State {
68 88
    fn clone(&self) -> State {
69 89
        State {
…
2 unchanged lines
72 92
            files: self.files.clone(),
73 93
            replaces: self.replaces.clone(),
74 94
            closure: Mutex::new(self.closure.lock().unwrap().clone()),
95 +
            deferred: self.deferred.clone(),
75 96
        }
76 97
    }
77 98
}
…
82 unchanged lines
160 181
            mode: *mode,
161 182
        },
162 183
        add @ TreeOp::AddNode { .. } => add.clone(),
184 +
    }
185 +
}
186 +
187 +
/// Apply one change's edits of one file (inserts and deletes; replaces
188 +
/// live beside the graphs) to that file's graph.
189 +
fn apply_file_edits(g: &mut FileGraph, h: ChangeHash, edits: Vec<Edit>) {
190 +
    let mut next = 0u32;
191 +
    for e in edits {
192 +
        match e {
193 +
            Edit::Insert {
194 +
                after,
195 +
                before,
196 +
                lines,
197 +
                ..
198 +
            } => {
199 +
                let first = next;
200 +
                next += lines.len() as u32;
201 +
                g.insert_owned(
202 +
                    h,
203 +
                    first,
204 +
                    vertex(after.resolve(h)),
205 +
                    vertex(before.resolve(h)),
206 +
                    lines,
207 +
                );
208 +
            }
209 +
            Edit::Delete { atoms, .. } => {
210 +
                for r in atoms {
211 +
                    g.delete(h, &r.resolve(h));
212 +
                }
213 +
            }
214 +
            Edit::Replace { .. } => {}
215 +
        }
163 216
    }
164 217
}
165 218
166 219
impl State {
167 220
    pub fn new() -> Self {
168 221
        Self::default()
222 +
    }
223 +
224 +
    /// An empty state for history only: changes and tree are kept, file
225 +
    /// edits are recorded but no file graph is built until
226 +
    /// [`State::load_graphs`] asks for it. For log and other commands that
227 +
    /// need no file contents, or only a few files.
228 +
    pub fn new_history_only() -> Self {
229 +
        State {
230 +
            deferred: Some(HashMap::new()),
231 +
            ..Self::default()
232 +
        }
233 +
    }
234 +
235 +
    /// Whether this state holds every file graph.
236 +
    pub fn is_full(&self) -> bool {
237 +
        self.deferred.as_ref().is_none_or(|d| d.is_empty())
238 +
    }
239 +
240 +
    /// The graph of `node`, which must be loaded: asking a history-only
241 +
    /// state for a graph it has not built is a bug, never an empty file.
242 +
    fn graph(&self, node: &NodeId) -> Option<&FileGraph> {
243 +
        if let Some(d) = &self.deferred {
244 +
            assert!(
245 +
                !d.contains_key(node),
246 +
                "file graph {} of a history-only state was not loaded (State::load_graphs)",
247 +
                node.short(12)
248 +
            );
249 +
        }
250 +
        self.files.get(node)
251 +
    }
252 +
253 +
    /// Operations over every file need every graph.
254 +
    fn require_full(&self, what: &str) {
255 +
        assert!(
256 +
            self.is_full(),
257 +
            "{what} needs every file graph; this state was loaded for history only"
258 +
        );
259 +
    }
260 +
261 +
    /// Build the graphs of `nodes` in a history-only state: from their
262 +
    /// checkpoint base (blobs read in one batch through `load`) and their
263 +
    /// deferred edits. Nodes already built, or with no graph, are skipped.
264 +
    pub fn load_graphs(
265 +
        &mut self,
266 +
        nodes: &[NodeId],
267 +
        load: impl FnOnce(&[ObjectRef]) -> Result<Vec<Vec<u8>>, ObjectError>,
268 +
    ) -> Result<(), ObjectError> {
269 +
        let Some(deferred) = self.deferred.as_mut() else {
270 +
            return Ok(());
271 +
        };
272 +
        let mut taken: Vec<(NodeId, Deferred)> = nodes
273 +
            .iter()
274 +
            .filter_map(|n| deferred.remove(n).map(|d| (*n, d)))
275 +
            .collect();
276 +
        let refs: Vec<ObjectRef> = taken
277 +
            .iter()
278 +
            .filter_map(|(_, d)| match &d.base {
279 +
                Some(DeferredBase::Blob(r)) => Some(*r),
280 +
                _ => None,
281 +
            })
282 +
            .collect();
283 +
        let mut blobs = if refs.is_empty() {
284 +
            Vec::new()
285 +
        } else {
286 +
            load(&refs)?
287 +
        }
288 +
        .into_iter();
289 +
        for (node, d) in taken.iter_mut() {
290 +
            let mut graph = match d.base.take() {
291 +
                Some(DeferredBase::Inline(v)) => FileGraph::from_value(&v)?,
292 +
                Some(DeferredBase::Blob(_)) => {
293 +
                    let bytes = blobs
294 +
                        .next()
295 +
                        .ok_or_else(|| ObjectError::Schema("graph blobs: wrong count".into()))?;
296 +
                    FileGraph::from_value(&cbor::decode(&bytes)?)?
297 +
                }
298 +
                None => FileGraph::default(),
299 +
            };
300 +
            for (h, edits) in std::mem::take(&mut d.edits) {
301 +
                apply_file_edits(&mut graph, h, edits);
302 +
            }
303 +
            self.files.insert(*node, graph);
304 +
        }
305 +
        Ok(())
169 306
    }
170 307
171 308
    pub fn len(&self) -> usize {
…
15 unchanged lines
187 324
        &self.tree
188 325
    }
189 326
    pub fn file(&self, node: &NodeId) -> Option<&FileGraph> {
190 -
        self.files.get(node)
327 +
        self.graph(node)
191 328
    }
192 329
193 330
    /// Add a change whose dependencies are already present.
…
28 unchanged lines
222 359
    /// Serialize the whole state in payload version 1: change metadata,
223 360
    /// tree, file graphs, replaces, all in one value (spec §11).
224 361
    pub fn checkpoint_payload(&self) -> Vec<u8> {
362 +
        self.require_full("a checkpoint");
225 363
        let mut nodes: Vec<&NodeId> = self.files.keys().collect();
226 364
        nodes.sort();
227 365
        let files = Value::Array(
…
9 unchanged lines
237 375
    /// (spec §11): the graphs of large files become blobs of their own.
238 376
    pub fn checkpoint_file_graphs(&self) -> Vec<(NodeId, Vec<u8>)> {
239 377
        use rayon::prelude::*;
378 +
        self.require_full("a checkpoint");
240 379
        let mut nodes: Vec<(&NodeId, &FileGraph)> = self.files.iter().collect();
241 380
        nodes.sort_by_key(|(n, _)| **n);
242 381
        nodes
…
168 unchanged lines
411 550
            }
412 551
        }
413 552
        Ok(out)
553 +
    }
554 +
555 +
    /// Decode a checkpoint payload for history only: a version 2 payload's
556 +
    /// graphs are noted, not read or built, and come later through
557 +
    /// [`State::load_graphs`]. A version 1 payload holds them inline and is
558 +
    /// decoded in full.
559 +
    pub fn from_checkpoint_history(bytes: &[u8]) -> Result<State, ObjectError> {
560 +
        let v = cbor::decode(bytes)?;
561 +
        let m = o::map(&v, "checkpoint payload")?;
562 +
        let version = o::uint(o::field(m, 0, "version")?, "version")?;
563 +
        if version != CHECKPOINT_INDEX_VERSION {
564 +
            return State::from_checkpoint_payload(bytes);
565 +
        }
566 +
        let mut st = State::payload_common(m)?;
567 +
        let mut deferred = HashMap::new();
568 +
        for f in o::array(o::field(m, 3, "files")?, "files")? {
569 +
            let e = o::array(f, "file")?;
570 +
            if e.len() != 2 {
571 +
                return Err(ObjectError::Schema("file: expected [node, graph]".into()));
572 +
            }
573 +
            let node = NodeId::from_slice(o::bytes(&e[0], "node")?)
574 +
                .ok_or_else(|| ObjectError::Schema("node: expected 16 bytes".into()))?;
575 +
            let base = match &e[1] {
576 +
                Value::Bytes(_) => DeferredBase::Blob(o::oref(&e[1], "graph blob")?),
577 +
                other => DeferredBase::Inline(other.clone()),
578 +
            };
579 +
            deferred.insert(
580 +
                node,
581 +
                Deferred {
582 +
                    base: Some(base),
583 +
                    edits: Vec::new(),
584 +
                },
585 +
            );
586 +
        }
587 +
        st.deferred = Some(deferred);
588 +
        Ok(st)
414 589
    }
415 590
416 591
    /// Changes, tree and replaces of a payload of either version.
…
67 unchanged lines
484 659
        for (i, op) in c.tree_ops.iter().enumerate() {
485 660
            self.tree.apply(h, i as u32, &resolve_tree_op(op, h));
486 661
        }
662 +
        if let Some(deferred) = self.deferred.as_mut() {
663 +
            // History only: edits of files not built wait, grouped per file.
664 +
            let mut per_node: HashMap<NodeId, Vec<Edit>> = HashMap::new();
665 +
            for e in c.edits {
666 +
                match e {
667 +
                    Edit::Replace { node, old, new } => {
668 +
                        self.replaces.entry(node).or_default().push((h, old, new))
669 +
                    }
670 +
                    e => per_node.entry(e.node()).or_default().push(e),
671 +
                }
672 +
            }
673 +
            for (node, edits) in per_node {
674 +
                match self.files.get_mut(&node) {
675 +
                    Some(g) => apply_file_edits(g, h, edits),
676 +
                    None => deferred.entry(node).or_default().edits.push((h, edits)),
677 +
                }
678 +
            }
679 +
            return;
680 +
        }
487 681
        let mut counters: HashMap<NodeId, u32> = HashMap::new();
488 682
        for e in c.edits {
489 683
            match e {
…
88 unchanged lines
578 772
    }
579 773
580 774
    pub fn materialize_file(&self, node: &NodeId) -> Materialized {
581 -
        self.files
582 -
            .get(node)
775 +
        self.graph(node)
583 776
            .map(|g| g.materialize(self))
584 777
            .unwrap_or_default()
585 778
    }
586 779
587 780
    /// Live lines of a text node in materialized order.
588 781
    pub fn live_lines(&self, node: &NodeId) -> Vec<(AtomKey, Vec<u8>)> {
589 -
        let Some(g) = self.files.get(node) else {
782 +
        let Some(g) = self.graph(node) else {
590 783
            return vec![];
591 784
        };
592 785
        let m = g.materialize(self);
…
74 unchanged lines
667 860
668 861
    pub fn conflict_regions(&self, node: &NodeId) -> ConflictRegions {
669 862
        let mut out = ConflictRegions::default();
670 -
        let Some(g) = self.files.get(node) else {
863 +
        let Some(g) = self.graph(node) else {
671 864
            return out;
672 865
        };
673 866
        let m = g.materialize(self);
…
31 unchanged lines
705 898
    }
706 899
707 900
    pub fn render_file(&self, node: &NodeId) -> (Vec<u8>, Materialized) {
708 -
        let Some(g) = self.files.get(node) else {
901 +
        let Some(g) = self.graph(node) else {
709 902
            return (Vec::new(), Materialized::default());
710 903
        };
711 904
        let m = g.materialize(self);
…
5 unchanged lines
717 910
    /// rendering them is most of a snapshot's cost in a large tree.
718 911
    pub fn materialize(&self) -> Snapshot {
719 912
        use rayon::prelude::*;
913 +
        self.require_full("materializing a state");
720 914
        let tree = self.tree.view();
721 915
        let entries: Vec<(&String, &crate::tree::TreeEntry)> = tree.entries.iter().collect();
722 916
        let rendered: Vec<(String, FileState)> = entries
…
309 unchanged lines
1032 1226
            matches!(&b.files["a"].content, FileContent::Text { bytes, .. } if bytes == b"2\n3\n")
1033 1227
        );
1034 1228
        assert!(State::from_checkpoint_payload(b"nonsense").is_err());
1229 +
    }
1230 +
1231 +
    /// History-only states (O10f): the same state as a full load once its
1232 +
    /// graphs are built, by replay and from a version 2 checkpoint with
1233 +
    /// later changes; unloaded graphs are never read as empty.
1234 +
    #[test]
1235 +
    fn history_only_states_build_the_same_graphs_on_demand() {
1236 +
        let small = NodeId::random();
1237 +
        let big = NodeId::random();
1238 +
        let add = |node: NodeId, name: &str| TreeOp::AddNode {
1239 +
            node,
1240 +
            kind: NodeKind::File,
1241 +
            mode: Mode::TEXT,
1242 +
            parent: NodeId::ROOT,
1243 +
            name: name.into(),
1244 +
        };
1245 +
        let lines: Vec<Vec<u8>> = (0..300)
1246 +
            .map(|i| format!("line {i}\n").into_bytes())
1247 +
            .collect();
1248 +
        let c1 = change(
1249 +
            vec![],
1250 +
            vec![add(small, "small"), add(big, "big")],
1251 +
            vec![
1252 +
                Edit::Insert {
1253 +
                    node: small,
1254 +
                    after: AtomRef::Start,
1255 +
                    before: AtomRef::End,
1256 +
                    lines: vec![b"x\n".to_vec()],
1257 +
                },
1258 +
                Edit::Insert {
1259 +
                    node: big,
1260 +
                    after: AtomRef::Start,
1261 +
                    before: AtomRef::End,
1262 +
                    lines,
1263 +
                },
1264 +
            ],
1265 +
        );
1266 +
        let h1 = c1.hash();
1267 +
        let c2 = change(
1268 +
            vec![h1],
1269 +
            vec![],
1270 +
            vec![
1271 +
                Edit::Delete {
1272 +
                    node: big,
1273 +
                    atoms: vec![AtomRange {
1274 +
                        change: h1,
1275 +
                        from: 2,
1276 +
                        to: 9,
1277 +
                    }],
1278 +
                },
1279 +
                Edit::Insert {
1280 +
                    node: big,
1281 +
                    after: AtomRef::Atom {
1282 +
                        change: h1,
1283 +
                        index: 20,
1284 +
                    },
1285 +
                    before: AtomRef::Atom {
1286 +
                        change: h1,
1287 +
                        index: 21,
1288 +
                    },
1289 +
                    lines: vec![b"new a\n".to_vec(), b"new b\n".to_vec()],
1290 +
                },
1291 +
                Edit::Insert {
1292 +
                    node: small,
1293 +
                    after: AtomRef::Atom {
1294 +
                        change: h1,
1295 +
                        index: 0,
1296 +
                    },
1297 +
                    before: AtomRef::End,
1298 +
                    lines: vec![b"y\n".to_vec()],
1299 +
                },
1300 +
            ],
1301 +
        );
1302 +
        let h2 = c2.hash();
1303 +
        let c3 = change(
1304 +
            vec![h2],
1305 +
            vec![],
1306 +
            vec![Edit::Delete {
1307 +
                node: small,
1308 +
                atoms: vec![AtomRange {
1309 +
                    change: h1,
1310 +
                    from: 0,
1311 +
                    to: 1,
1312 +
                }],
1313 +
            }],
1314 +
        );
1315 +
1316 +
        let mut full = State::new();
1317 +
        for c in [&c1, &c2, &c3] {
1318 +
            full.add(c.clone()).unwrap();
1319 +
        }
1320 +
        // By replay: nothing built until asked, then exactly the full graphs.
1321 +
        let mut hist = State::new_history_only();
1322 +
        for c in [&c1, &c2, &c3] {
1323 +
            hist.add(c.clone()).unwrap();
1324 +
        }
1325 +
        assert!(!hist.is_full() && hist.files.is_empty(), "no graph built");
1326 +
        assert_eq!(hist.len(), 3);
1327 +
        hist.load_graphs(&[big], |_| unreachable!("no checkpoint"))
1328 +
            .unwrap();
1329 +
        assert_eq!(
1330 +
            hist.render_file(&big).0,
1331 +
            full.render_file(&big).0,
1332 +
            "one file on its own"
1333 +
        );
1334 +
        assert!(!hist.is_full());
1335 +
        hist.load_graphs(&[small, big], |_| unreachable!()).unwrap();
1336 +
        assert!(hist.is_full());
1337 +
        assert_eq!(hist.checkpoint_payload(), full.checkpoint_payload());
1338 +
1339 +
        // From a version 2 checkpoint of c1+c2, then c3 applied later.
1340 +
        let mut base = State::new();
1341 +
        base.add(c1.clone()).unwrap();
1342 +
        base.add(c2.clone()).unwrap();
1343 +
        let graphs = base.checkpoint_file_graphs();
1344 +
        let mut blobs: HashMap<ObjectRef, Vec<u8>> = HashMap::new();
1345 +
        let index = base.checkpoint_index(&graphs, |enc| {
1346 +
            let r = crate::hash::hash_object(crate::hash::ObjectType::Blob, enc);
1347 +
            blobs.insert(r, enc.to_vec());
1348 +
            r
1349 +
        });
1350 +
        let mut from_cp = State::from_checkpoint_history(&index).unwrap();
1351 +
        from_cp.add(c3.clone()).unwrap();
1352 +
        assert!(!from_cp.is_full());
1353 +
        let mut fetched = 0;
1354 +
        from_cp
1355 +
            .load_graphs(&[small, big], |refs| {
1356 +
                fetched += refs.len();
1357 +
                refs.iter()
1358 +
                    .map(|r| {
1359 +
                        blobs
1360 +
                            .get(r)
1361 +
                            .cloned()
1362 +
                            .ok_or_else(|| ObjectError::Schema("missing".into()))
1363 +
                    })
1364 +
                    .collect()
1365 +
            })
1366 +
            .unwrap();
1367 +
        assert_eq!(
1368 +
            fetched, 1,
1369 +
            "the large graph's blob, in one batch; the small one was inline"
1370 +
        );
1371 +
        assert_eq!(from_cp.checkpoint_payload(), full.checkpoint_payload());
1372 +
    }
1373 +
1374 +
    #[test]
1375 +
    #[should_panic(expected = "was not loaded")]
1376 +
    fn a_history_only_state_never_reads_an_unloaded_graph_as_empty() {
1377 +
        let f = NodeId::random();
1378 +
        let mut st = State::new_history_only();
1379 +
        st.add(change(
1380 +
            vec![],
1381 +
            vec![TreeOp::AddNode {
1382 +
                node: f,
1383 +
                kind: NodeKind::File,
1384 +
                mode: Mode::TEXT,
1385 +
                parent: NodeId::ROOT,
1386 +
                name: "a".into(),
1387 +
            }],
1388 +
            vec![Edit::Insert {
1389 +
                node: f,
1390 +
                after: AtomRef::Start,
1391 +
                before: AtomRef::End,
1392 +
                lines: vec![b"a\n".to_vec()],
1393 +
            }],
1394 +
        ))
1395 +
        .unwrap();
1396 +
        st.render_file(&f);
1397 +
    }
1398 +
1399 +
    #[test]
1400 +
    #[should_panic(expected = "needs every file graph")]
1401 +
    fn materializing_a_history_only_state_is_refused() {
1402 +
        let f = NodeId::random();
1403 +
        let mut st = State::new_history_only();
1404 +
        st.add(change(
1405 +
            vec![],
1406 +
            vec![TreeOp::AddNode {
1407 +
                node: f,
1408 +
                kind: NodeKind::File,
1409 +
                mode: Mode::TEXT,
1410 +
                parent: NodeId::ROOT,
1411 +
                name: "a".into(),
1412 +
            }],
1413 +
            vec![Edit::Insert {
1414 +
                node: f,
1415 +
                after: AtomRef::Start,
1416 +
                before: AtomRef::End,
1417 +
                lines: vec![b"a\n".to_vec()],
1418 +
            }],
1419 +
        ))
1420 +
        .unwrap();
1421 +
        st.materialize();
1035 1422
    }
1036 1423
1037 1424
    /// Version 2 payloads (spec §11): small graphs inline, large ones by
…
158 unchanged lines