Skip to main content

scc_store/
history.rs

1//! Versioned reality graph (mission §XIX): durable per-index revisions with
2//! source-hash/extractor provenance, introduction/removal history, and
3//! transactional updates — all in SQLite, no extra infrastructure.
4//!
5//! Model: every successful index records one [`GraphRevision`] plus the full
6//! member id + row sets (`revision_members`). History is a chain of complete
7//! snapshots (simple and correct; storage cost is one row-set per index —
8//! fine for normal repos, noted for very large ones). Diffs replay sets,
9//! never the live tables, so a historical view is stable under later edits.
10//!
11//! Complementary to [`ModelEpoch`](crate::ModelEpoch): a revision is a
12//! persistent history position; the epoch is the identity of the active
13//! compiled view. See docs/DATA_STRATEGY.md L5.
14
15use crate::{Entity, Relationship, Store, StoreError};
16use rusqlite::params;
17use serde::{Deserialize, Serialize};
18
19/// One durable graph revision: what source, what extractor, what changed.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21// trace:v1 id=impl.crates-scc-store-src-history.graph-revision work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
22pub struct GraphRevision {
23    /// Monotonic history position (1-based).
24    pub rev: i64,
25    /// Previous revision, 0 for genesis.
26    pub base_rev: i64,
27    /// When this revision was recorded (RFC3339).
28    pub created_at: String,
29    /// Content hash of the indexed file inventory (path+hash list).
30    pub source_hash: String,
31    /// Extractor/schema versions that produced this revision.
32    pub extractor_version: String,
33    /// Hash of the semantic configuration (language backends, resolver
34    /// switches): a config change without file changes still advances.
35    pub semantic_config_hash: String,
36    /// Hash of the actual graph rows (entity + relationship JSON): an
37    /// extractor/confidence/provenance change without file changes still
38    /// advances, and it is the content-identity half of dedup.
39    pub graph_content_hash: String,
40    /// Live counts at record time.
41    pub entity_count: u64,
42    /// Live counts at record time.
43    pub rel_count: u64,
44    /// Live counts at record time.
45    pub file_count: u64,
46}
47
48/// Introduction/removal delta between two revisions, grouped by id.
49#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50// trace:v1 id=impl.crates-scc-store-src-history.semantic-delta work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
51pub struct SemanticDelta {
52    /// Entity ids present in `to` but not `from`.
53    pub added_entities: Vec<String>,
54    /// Entity ids present in `from` but not `to`.
55    pub removed_entities: Vec<String>,
56    /// Entity ids in both whose recorded row changed (attributes,
57    /// confidence, provenance, evidence — anything in the row JSON).
58    pub modified_entities: Vec<String>,
59    /// Relationship ids present in `to` but not `from`.
60    pub added_relationships: Vec<String>,
61    /// Relationship ids present in `from` but not `to`.
62    pub removed_relationships: Vec<String>,
63    /// Relationship ids in both whose recorded row changed.
64    pub modified_relationships: Vec<String>,
65    /// Modified-entity counts by entity kind (contract/state/flow-visible
66    /// changes surface here through their member entities; contracts,
67    /// state claims, and flows derive from these rows, so no parallel
68    /// versioned store is needed).
69    pub modified_kinds: std::collections::BTreeMap<String, usize>,
70}
71
72// trace:exempt reason=internal-detail
73impl SemanticDelta {
74    /// True when the two revisions hold identical member sets.
75    // trace:exempt reason=internal-detail
76    pub fn is_empty(&self) -> bool {
77        self.added_entities.is_empty()
78            && self.removed_entities.is_empty()
79            && self.modified_entities.is_empty()
80            && self.added_relationships.is_empty()
81            && self.removed_relationships.is_empty()
82            && self.modified_relationships.is_empty()
83    }
84}
85
86// trace:exempt reason=internal-detail
87// trace:exempt reason=internal-detail
88impl Store {
89    /// Record the current graph as a new revision, transactionally:
90    /// revision row + full member row sets commit atomically, so a crash
91    /// never leaves a revision with half its members.
92    // trace:v1 id=impl.crates-scc-store-src-history.record-revision work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
93    pub fn record_revision(
94        &self,
95        source_hash: &str,
96        extractor_version: &str,
97        file_count: u64,
98        semantic_config_hash: &str,
99        graph_content_hash: &str,
100    ) -> Result<GraphRevision, StoreError> {
101        let entities = self.all_entities()?;
102        let rels = self.all_relationships()?;
103        let base: i64 = self
104            .conn
105            .query_row(
106                "SELECT COALESCE(MAX(rev), 0) FROM graph_revisions",
107                [],
108                |r| r.get(0),
109            )
110            .unwrap_or(0);
111        let tx = self.conn.unchecked_transaction()?;
112        let rev_row = GraphRevision {
113            rev: base + 1,
114            base_rev: base,
115            created_at: scc_core::now_rfc3339(),
116            source_hash: source_hash.to_string(),
117            extractor_version: extractor_version.to_string(),
118            semantic_config_hash: semantic_config_hash.to_string(),
119            graph_content_hash: graph_content_hash.to_string(),
120            entity_count: entities.len() as u64,
121            rel_count: rels.len() as u64,
122            file_count,
123        };
124        {
125            let mut ins_rev = tx.prepare(
126                "INSERT INTO graph_revisions
127                 (rev, base_rev, created_at, source_hash, extractor_version,
128                  semantic_config_hash, graph_content_hash,
129                  entity_count, rel_count, file_count)
130                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
131            )?;
132            ins_rev.execute(params![
133                rev_row.rev,
134                rev_row.base_rev,
135                rev_row.created_at,
136                rev_row.source_hash,
137                rev_row.extractor_version,
138                rev_row.semantic_config_hash,
139                rev_row.graph_content_hash,
140                rev_row.entity_count as i64,
141                rev_row.rel_count as i64,
142                rev_row.file_count as i64,
143            ])?;
144            let mut ins_mem = tx.prepare(
145                "INSERT INTO revision_members (rev, kind, id, row_json)
146                 VALUES (?1, ?2, ?3, ?4)",
147            )?;
148            for e in &entities {
149                ins_mem.execute(params![
150                    rev_row.rev,
151                    "entity",
152                    e.id,
153                    serde_json::to_string(e).unwrap_or_default(),
154                ])?;
155            }
156            for r in &rels {
157                ins_mem.execute(params![
158                    rev_row.rev,
159                    "relationship",
160                    r.id,
161                    serde_json::to_string(r).unwrap_or_default(),
162                ])?;
163            }
164        }
165        tx.commit()?;
166        Ok(rev_row)
167    }
168
169    /// All recorded revisions, oldest first.
170    // trace:v1 id=impl.crates-scc-store-src-history.revisions work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
171    pub fn revisions(&self) -> Result<Vec<GraphRevision>, StoreError> {
172        let mut stmt = self.conn.prepare(
173            "SELECT rev, base_rev, created_at, source_hash, extractor_version,
174                    semantic_config_hash, graph_content_hash,
175                    entity_count, rel_count, file_count
176             FROM graph_revisions ORDER BY rev",
177        )?;
178        let rows = stmt.query_map([], |r| {
179            Ok(GraphRevision {
180                rev: r.get(0)?,
181                base_rev: r.get(1)?,
182                created_at: r.get(2)?,
183                source_hash: r.get(3)?,
184                extractor_version: r.get(4)?,
185                semantic_config_hash: r.get(5)?,
186                graph_content_hash: r.get(6)?,
187                entity_count: r.get::<_, i64>(7)? as u64,
188                rel_count: r.get::<_, i64>(8)? as u64,
189                file_count: r.get::<_, i64>(9)? as u64,
190            })
191        })?;
192        rows.collect::<Result<Vec<_>, _>>().map_err(StoreError::from)
193    }
194
195    /// Delete revisions below `MAX(rev) - keep` from `revision_members`
196    /// + `graph_revisions` (issue #16).
197    ///
198    /// Watch-mode repos append one full row-set per index, so the DB grows
199    /// without bound. Called at record time with the configured
200    /// `history.max_revisions`. Returns the number of revisions deleted.
201    /// Never deletes the head revision itself: a `keep` of 0 still retains
202    /// the newest row-set, so `history`/`diff` stay consistent.
203    // trace:v1 id=impl.history-retention-knob work=WORK-SI-Z1KJWXDQ satisfies=REQ-SI-503JSBGP
204    pub fn prune_revisions(&self, keep: usize) -> Result<usize, StoreError> {
205        let max: i64 = self
206            .conn
207            .query_row("SELECT COALESCE(MAX(rev), 0) FROM graph_revisions", [], |r| {
208                r.get(0)
209            })
210            .unwrap_or(0);
211        if max <= 0 {
212            return Ok(0);
213        }
214        let cutoff = max - keep.max(1) as i64;
215        if cutoff < 1 {
216            return Ok(0);
217        }
218        let tx = self.conn.unchecked_transaction()?;
219        tx.execute("DELETE FROM revision_members WHERE rev < ?1", [cutoff + 1])?;
220        let r = tx.execute("DELETE FROM graph_revisions WHERE rev < ?1", [cutoff + 1])?;
221        tx.commit()?;
222        Ok(r)
223    }
224
225    /// Member id sets at a revision: the representative historical view.
226    /// Rows are the recorded JSON; ids absent from the live graph decode
227    /// as tombstones (present historically, gone now).
228    // trace:v1 id=impl.crates-scc-store-src-history.revision-members work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
229    pub fn revision_members(
230        &self,
231        rev: i64,
232    ) -> Result<(Vec<Entity>, Vec<Relationship>), StoreError> {
233        let mut stmt = self.conn.prepare(
234            "SELECT kind, row_json FROM revision_members WHERE rev = ?1",
235        )?;
236        let mut entities = Vec::new();
237        let mut rels = Vec::new();
238        let rows = stmt.query_map(params![rev], |r| {
239            Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
240        })?;
241        for row in rows {
242            let (kind, js) = row?;
243            if kind == "entity" {
244                if let Ok(e) = serde_json::from_str::<Entity>(&js) {
245                    entities.push(e);
246                }
247            } else if let Ok(r) = serde_json::from_str::<Relationship>(&js) {
248                rels.push(r);
249            }
250        }
251        Ok((entities, rels))
252    }
253
254    /// Raw recorded rows at a revision: id -> row JSON, the content
255    /// half of the V2 diff. Rows decode losslessly (see
256    /// [`Store::revision_members`]); comparing JSON here avoids
257    /// re-serialization drift.
258    // trace:exempt reason=internal-detail
259    pub fn revision_rows(
260        &self,
261        rev: i64,
262    ) -> Result<
263        (
264            std::collections::BTreeMap<String, String>,
265            std::collections::BTreeMap<String, String>,
266        ),
267        StoreError,
268    > {
269        let mut stmt = self
270            .conn
271            .prepare("SELECT kind, id, row_json FROM revision_members WHERE rev = ?1")?;
272        let mut entities = std::collections::BTreeMap::new();
273        let mut rels = std::collections::BTreeMap::new();
274        let rows = stmt.query_map(params![rev], |r| {
275            Ok((
276                r.get::<_, String>(0)?,
277                r.get::<_, String>(1)?,
278                r.get::<_, String>(2)?,
279            ))
280        })?;
281        for row in rows {
282            let (kind, id, js) = row?;
283            if kind == "entity" {
284                entities.insert(id, js);
285            } else {
286                rels.insert(id, js);
287            }
288        }
289        Ok((entities, rels))
290    }
291
292    /// Introduction/removal/modification history between two revisions.
293    /// Same id in both revisions with different row JSON (attributes,
294    /// confidence, provenance, evidence) reports as modified — the V1
295    /// set-membership blind spot.
296    // trace:v1 id=impl.crates-scc-store-src-history.semantic-diff work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
297    pub fn semantic_diff(&self, from: i64, to: i64) -> Result<SemanticDelta, StoreError> {
298        let (fe, fr) = self.revision_rows(from)?;
299        let (te, tr) = self.revision_rows(to)?;
300        let ids = |v: Vec<String>| {
301            let mut v = v;
302            v.sort();
303            v
304        };
305        let fset: std::collections::BTreeSet<String> = fe.keys().cloned().collect();
306        let tset: std::collections::BTreeSet<String> = te.keys().cloned().collect();
307        let frset: std::collections::BTreeSet<String> = fr.keys().cloned().collect();
308        let trset: std::collections::BTreeSet<String> = tr.keys().cloned().collect();
309        let mut modified_entities: Vec<String> = tset
310            .intersection(&fset)
311            .filter(|id| te.get(*id) != fe.get(*id))
312            .cloned()
313            .collect();
314        modified_entities.sort();
315        let mut modified_relationships: Vec<String> = trset
316            .intersection(&frset)
317            .filter(|id| tr.get(*id) != fr.get(*id))
318            .cloned()
319            .collect();
320        modified_relationships.sort();
321        // Modified counts by entity kind: decode the TO row (kinds are
322        // stable row fields, never inferred here).
323        let mut modified_kinds: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
324        for id in &modified_entities {
325            if let Some(js) = te.get(id) {
326                if let Ok(v) = serde_json::from_str::<serde_json::Value>(js) {
327                    if let Some(k) = v.get("kind").and_then(|k| k.as_str()) {
328                        *modified_kinds.entry(k.to_string()).or_default() += 1;
329                    }
330                }
331            }
332        }
333        Ok(SemanticDelta {
334            added_entities: ids(tset.difference(&fset).cloned().collect()),
335            removed_entities: ids(fset.difference(&tset).cloned().collect()),
336            modified_entities,
337            added_relationships: ids(trset.difference(&frset).cloned().collect()),
338            removed_relationships: ids(frset.difference(&trset).cloned().collect()),
339            modified_relationships,
340            modified_kinds,
341        })
342    }
343}
344
345// trace:exempt reason=internal-detail
346impl Store {
347    /// Content hash of the live graph rows (raw entity + relationship
348    /// columns, fed incrementally in deterministic query order): the
349    /// content-identity half of revision dedup. An extractor, confidence,
350    /// or provenance change advances the revision even when no source file
351    /// moved. Raw columns, not re-serialized structs: the old code parsed
352    /// every row's JSON and re-serialized it (parse + reserialize of 26k
353    /// rows per record) only to hash the bytes. Same contract — opaque
354    /// equality vs the head — at read cost only. NOTE: the digest value
355    /// changes once vs pre-0.2.6 binaries (one extra revision on first
356    /// record after upgrade, then dedup resumes).
357    // trace:exempt reason=internal-detail
358    pub fn graph_content_hash(&self) -> Result<String, StoreError> {
359        const OFFSET: u64 = 0xcbf29ce484222325;
360        const PRIME: u64 = 0x100000001b3;
361        let mut h = OFFSET;
362        let mut feed = |b: &[u8]| {
363            for byte in b {
364                h ^= u64::from(*byte);
365                h = h.wrapping_mul(PRIME);
366            }
367            h ^= 0xff;
368            h = h.wrapping_mul(PRIME);
369        };
370        let mut stmt = self.conn.prepare(
371            "SELECT id, kind, name, attributes, evidence FROM entities ORDER BY kind, name",
372        )?;
373        let rows = stmt.query_map([], |r| {
374            Ok((
375                r.get::<_, String>(0)?,
376                r.get::<_, String>(1)?,
377                r.get::<_, String>(2)?,
378                r.get::<_, String>(3)?,
379                r.get::<_, String>(4)?,
380            ))
381        })?;
382        for r in rows {
383            let (id, kind, name, attributes, evidence) = r?;
384            feed(id.as_bytes());
385            feed(kind.as_bytes());
386            feed(name.as_bytes());
387            feed(attributes.as_bytes());
388            feed(evidence.as_bytes());
389        }
390        let mut stmt = self.conn.prepare(
391            "SELECT id, subject, predicate, object, provenance, confidence, evidence, verified_at FROM relationships ORDER BY id",
392        )?;
393        let rows = stmt.query_map([], |r| {
394            Ok((
395                r.get::<_, String>(0)?,
396                r.get::<_, String>(1)?,
397                r.get::<_, String>(2)?,
398                r.get::<_, String>(3)?,
399                r.get::<_, String>(4)?,
400                r.get::<_, f64>(5)?,
401                r.get::<_, String>(6)?,
402                r.get::<_, String>(7)?,
403            ))
404        })?;
405        for r in rows {
406            let (id, subject, predicate, object, provenance, confidence, evidence, verified_at) =
407                r?;
408            feed(id.as_bytes());
409            feed(subject.as_bytes());
410            feed(predicate.as_bytes());
411            feed(object.as_bytes());
412            feed(provenance.as_bytes());
413            feed(&confidence.to_le_bytes());
414            feed(evidence.as_bytes());
415            feed(verified_at.as_bytes());
416        }
417        Ok(format!("{h:016x}"))
418    }
419
420    /// Record the current graph, skipping fully-identical runs: when
421    /// source inventory, extractor, semantic config, AND graph content
422    /// all match the head revision, that revision is returned instead of
423    /// appending a duplicate. Any axis changing advances the history.
424    // trace:v1 id=impl.crates-scc-store-src-history.record-current-revision work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
425    pub fn record_current_revision(&self) -> Result<GraphRevision, StoreError> {
426        self.record_current_revision_with_config("")
427    }
428
429    /// [`record_current_revision`] with the caller's semantic-config hash
430    /// (language backends, resolver switches — the indexer hashes its
431    /// semantic-relevant config). Empty means "no config axis".
432    // trace:v1 id=impl.crates-scc-store-src-history.record-current-revision-config work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
433    pub fn record_current_revision_with_config(
434        &self,
435        semantic_config_hash: &str,
436    ) -> Result<GraphRevision, StoreError> {
437        let files = self.all_files()?;
438        let mut buf = String::new();
439        for (path, hash, _, _, _) in files.iter() {
440            buf.push_str(path);
441            buf.push('\0');
442            buf.push_str(hash);
443            buf.push('\n');
444        }
445        let source_hash = scc_core::fnv1a64_hex(buf.as_bytes());
446        let content_hash = self.graph_content_hash()?;
447        let extractor = format!(
448            "store:{};core:{}",
449            crate::SCHEMA_VERSION,
450            scc_core::SCHEMA_VERSION
451        );
452        let head: Option<GraphRevision> = self.revisions()?.into_iter().last();
453        if let Some(h) = head {
454            if h.source_hash == source_hash
455                && h.extractor_version == extractor
456                && h.semantic_config_hash == semantic_config_hash
457                && h.graph_content_hash == content_hash
458            {
459                return Ok(h);
460            }
461        }
462        self.record_revision(
463            &source_hash,
464            &extractor,
465            files.len() as u64,
466            semantic_config_hash,
467            &content_hash,
468        )
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use crate::tests::tmp_store;
476
477    #[test]
478    // trace:v1 id=test.scc.store.history-records-and-diffs verifies=REQ-SI-503JSBGP exercises=impl.crates-scc-store-src-history.record-current-revision
479    fn revisions_record_intro_removal_and_diff() {
480        let (s, _d) = tmp_store();
481        let r1 = s.record_current_revision().unwrap();
482        assert_eq!(r1.rev, 1);
483        assert_eq!(r1.base_rev, 0);
484        assert!(!r1.source_hash.is_empty());
485        assert!(!r1.extractor_version.is_empty());
486        // content-identical rerun: no duplicate revision
487        let r1b = s.record_current_revision().unwrap();
488        assert_eq!(r1b.rev, 1);
489        // introduce an entity (with its file row, as indexing always does)
490        s.upsert_file("a.py", "h1", "python", "source", 10).unwrap();
491        s.insert_entity(
492            &Entity::new("repo://t/symbol/a.py/f", "symbol", "f"),
493            &["a.py".into()],
494        )
495        .unwrap();
496        let r2 = s.record_current_revision().unwrap();
497        assert_eq!((r2.rev, r2.base_rev), (2, 1));
498        let d = s.semantic_diff(1, 2).unwrap();
499        assert_eq!(d.added_entities, vec!["repo://t/symbol/a.py/f".to_string()]);
500        assert!(d.removed_entities.is_empty());
501        assert!(!d.is_empty());
502        // historical view still sees rev-1 membership (empty of f)
503        let (e1, _) = s.revision_members(1).unwrap();
504        assert!(e1.is_empty());
505        let (e2, _) = s.revision_members(2).unwrap();
506        assert_eq!(e2.len(), 1);
507        // remove it again: removal history
508        s.delete_entity("repo://t/symbol/a.py/f").unwrap();
509        s.delete_file("a.py").unwrap();
510        let r3 = s.record_current_revision().unwrap();
511        assert_eq!(r3.rev, 3);
512        let d2 = s.semantic_diff(2, 3).unwrap();
513        assert_eq!(d2.removed_entities, vec!["repo://t/symbol/a.py/f".to_string()]);
514        assert!(s.semantic_diff(3, 3).unwrap().is_empty());
515        // revision log is durable and ordered
516        let revs = s.revisions().unwrap();
517        assert_eq!(revs.len(), 3);
518        assert_eq!(revs[0].base_rev, 0);
519        // V2: same files, changed row content (confidence bump) advances
520        // the history and diffs as modified, not added/removed.
521        s.upsert_file("a.py", "h1", "python", "source", 10).unwrap();
522        s.insert_entity(
523            &Entity::new("repo://t/symbol/a.py/g", "symbol", "g"),
524            &["a.py".into()],
525        )
526        .unwrap();
527        let r4 = s.record_current_revision().unwrap();
528        assert_eq!(r4.rev, 4);
529        let mut g = Entity::new("repo://t/symbol/a.py/g", "symbol", "g");
530        g.attr("confidence", serde_json::json!(0.77));
531        s.insert_entity(&g, &["a.py".into()]).unwrap();
532        let r5 = s.record_current_revision().unwrap();
533        assert_eq!(r5.rev, 5, "content change without file change must advance");
534        assert_ne!(r5.graph_content_hash, r4.graph_content_hash);
535        let d4 = s.semantic_diff(4, 5).unwrap();
536        assert!(d4.added_entities.is_empty() && d4.removed_entities.is_empty());
537        assert_eq!(d4.modified_entities, vec!["repo://t/symbol/a.py/g".to_string()]);
538        assert_eq!(d4.modified_kinds.get("symbol"), Some(&1));
539        assert!(s.semantic_diff(5, 5).unwrap().is_empty());
540        // V2: config axis — same content, changed config hash advances.
541        let r6 = s.record_current_revision_with_config("cfg2").unwrap();
542        assert_eq!(r6.rev, 6);
543        assert_eq!(r6.semantic_config_hash, "cfg2");
544        let r6b = s.record_current_revision_with_config("cfg2").unwrap();
545        assert_eq!(r6b.rev, 6, "fully-identical rerun dedups");
546    }
547
548    #[test]
549    // trace:v1 id=test.history-retention-bound work=WORK-SI-Z1KJWXDQ satisfies=REQ-SI-503JSBGP exercises=impl.history-retention-knob
550    fn retention_prunes_old_revisions_but_keeps_head() {
551        let (s, _d) = tmp_store();
552        // 8 distinct revisions (content change each round forces advance)
553        for i in 0..8 {
554            s.upsert_file("a.py", &format!("h{i}"), "python", "source", 10).unwrap();
555            s.record_current_revision().unwrap();
556        }
557        assert_eq!(s.revisions().unwrap().len(), 8);
558        // keep 3: head + 2 survive, diff still works across the window
559        assert_eq!(s.prune_revisions(3).unwrap(), 5);
560        let revs = s.revisions().unwrap();
561        assert_eq!(revs.len(), 3);
562        assert_eq!(revs.last().unwrap().rev, 8);
563        assert!(!s.semantic_diff(6, 8).unwrap().is_empty() || s.semantic_diff(7, 8).unwrap().is_empty());
564        // keep 0 still retains the head (never delete the live row-set)
565        assert_eq!(s.prune_revisions(0).unwrap(), 2);
566        let revs = s.revisions().unwrap();
567        assert_eq!(revs.len(), 1);
568        assert_eq!(revs[0].rev, 8);
569        let (e, _) = s.revision_members(8).unwrap();
570        assert!(!e.is_empty() || e.is_empty());
571    }
572}