1use std::collections::{BTreeSet, HashMap, HashSet};
12use std::sync::Arc;
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use sha2::{Digest, Sha256};
16
17use crate::application::vcs::{
18 AsOfSpec, Author, CheckoutInput, CheckoutTarget, Commit, CommitHash, Conflict,
19 CreateBranchInput, CreateCommitInput, CreateTagInput, Diff, DiffChange, DiffEntry, DiffInput,
20 LogInput, LogRange, MergeInput, MergeOutcome, MergeStrategy, Ref, RefKind, RefName, ResetInput,
21 ResetMode, Status, StatusInput,
22};
23use crate::application::vcs_collections as vc;
24use crate::json::{Map, Value as JsonValue};
25use crate::runtime::RedDBRuntime;
26use crate::storage::transaction::snapshot::{Snapshot, SnapshotManager, Xid, XID_NONE};
27use crate::storage::unified::entity::{EntityData, EntityId, EntityKind, RowData, UnifiedEntity};
28use crate::storage::unified::UnifiedStore;
29use crate::{RedDBError, RedDBResult};
30use reddb_types::Value;
31
32#[path = "vcs_branches.rs"]
33mod branches;
34#[path = "vcs_merge_data.rs"]
35mod merge_data;
36#[cfg(test)]
37#[path = "vcs_metadata_tests.rs"]
38mod metadata_tests;
39
40impl RedDBRuntime {
41 pub(crate) fn vcs_live_graph_validation_contexts(
42 &self,
43 collection: &str,
44 ) -> RedDBResult<Vec<reddb_engine::transaction::read::SnapshotContext>> {
45 let (mut context, _retention) = self.native_read_snapshot()?;
46 context.snapshot = self.snapshot_manager().fresh_read_snapshot();
47 context.own_xids.clear();
48 if !self.vcs_is_versioned(collection)? {
49 return Ok(vec![context]);
50 }
51 let store = self.db().store();
52 let mut names = BTreeSet::from([vc::DEFAULT_BRANCH_REF.to_string()]);
53 if let Some(manager) = store.get_collection(vc::REFS) {
54 manager.try_for_each_entity(usize::MAX, |entity| {
55 if let Some(name) = entity.data.as_row().and_then(|row| row_text(row, "id")) {
56 if name.starts_with(vc::BRANCH_REF_PREFIX) {
57 names.insert(name);
58 }
59 }
60 true
61 })?;
62 }
63 names
64 .into_iter()
65 .map(|name| {
66 let mut branch = match branches::load_view(&store, &name)? {
67 Some(branch) => branch,
68 None if name == vc::DEFAULT_BRANCH_REF => {
69 reddb_engine::transaction::read::BranchReadView {
70 writer_epoch: 0,
71 epochs: Default::default(),
72 }
73 }
74 None => {
75 return Err(RedDBError::InvalidOperation(format!(
76 "checkout legacy branch '{name}' before enabling a graph contract"
77 )))
78 }
79 };
80 branch
81 .epochs
82 .insert(branch.writer_epoch, context.snapshot.clone());
83 let mut snapshot = context.clone();
84 snapshot.branch = Some(Arc::new(branch));
85 Ok(snapshot)
86 })
87 .collect()
88 }
89}
90
91fn now_ms() -> i64 {
96 SystemTime::now()
97 .duration_since(UNIX_EPOCH)
98 .map(|d| d.as_millis() as i64)
99 .unwrap_or(0)
100}
101
102fn row_text(row: &RowData, field: &str) -> Option<String> {
103 match row.get_field(field)?.clone() {
104 Value::Text(v) => Some(v.to_string()),
105 _ => None,
106 }
107}
108
109fn row_u64(row: &RowData, field: &str) -> Option<u64> {
110 match row.get_field(field)?.clone() {
111 Value::UnsignedInteger(v) => Some(v),
112 Value::Integer(v) if v >= 0 => Some(v as u64),
113 _ => None,
114 }
115}
116
117fn row_i64(row: &RowData, field: &str) -> Option<i64> {
118 match row.get_field(field)?.clone() {
119 Value::Integer(v) => Some(v),
120 Value::UnsignedInteger(v) => Some(v as i64),
121 Value::TimestampMs(v) | Value::Timestamp(v) => Some(v),
122 _ => None,
123 }
124}
125
126fn row_json(row: &RowData, field: &str) -> JsonValue {
127 match row.get_field(field) {
128 Some(Value::Json(bytes)) => {
129 crate::json::from_slice::<JsonValue>(bytes).unwrap_or(JsonValue::Null)
130 }
131 Some(Value::Text(s)) => crate::json::from_str::<JsonValue>(s)
132 .unwrap_or_else(|_| JsonValue::String(s.to_string())),
133 _ => JsonValue::Null,
134 }
135}
136
137fn row_string_list(row: &RowData, field: &str) -> Vec<String> {
138 match row.get_field(field) {
139 Some(Value::Array(items)) => items
140 .iter()
141 .filter_map(|v| match v {
142 Value::Text(s) => Some(s.to_string()),
143 _ => None,
144 })
145 .collect(),
146 _ => Vec::new(),
147 }
148}
149
150fn compute_commit_hash(
151 root_xid: Xid,
152 parents: &[CommitHash],
153 author: &Author,
154 message: &str,
155 timestamp_ms: i64,
156) -> CommitHash {
157 let mut h = Sha256::new();
158 h.update(b"reddb-commit-v1\n");
159 h.update(root_xid.to_be_bytes());
160 let mut sorted = parents.to_vec();
161 sorted.sort();
162 for p in &sorted {
163 h.update(b"\np=");
164 h.update(p.as_bytes());
165 }
166 h.update(b"\na=");
167 h.update(author.name.as_bytes());
168 h.update(b"\n");
169 h.update(author.email.as_bytes());
170 h.update(b"\nm=");
171 h.update(message.as_bytes());
172 h.update(b"\nt=");
173 h.update(timestamp_ms.to_be_bytes());
174 let digest = h.finalize();
175 hex::encode(digest)
176}
177
178fn normalize_branch_name(raw: &str) -> String {
179 if raw.starts_with(vc::BRANCH_REF_PREFIX) {
180 raw.to_string()
181 } else {
182 format!("{}{}", vc::BRANCH_REF_PREFIX, raw)
183 }
184}
185
186fn normalize_tag_name(raw: &str) -> String {
187 if raw.starts_with(vc::TAG_REF_PREFIX) {
188 raw.to_string()
189 } else {
190 format!("{}{}", vc::TAG_REF_PREFIX, raw)
191 }
192}
193
194fn head_ref_id(connection_id: u64) -> String {
195 format!("{}{}", vc::HEAD_ID_PREFIX, connection_id)
196}
197
198fn load_metadata_entity(
203 store: &UnifiedStore,
204 collection: &str,
205 key: &str,
206) -> RedDBResult<Option<UnifiedEntity>> {
207 let mut result = None;
208 if let Some(manager) = store.get_collection(collection) {
209 super::function_budget::try_scan(
210 |visit| manager.try_for_each_entity(usize::MAX, visit),
211 |entity| {
212 if entity
213 .data
214 .as_row()
215 .is_some_and(|row| row_text(row, "id").as_deref() == Some(key))
216 {
217 result = Some(entity.clone());
218 return false;
219 }
220 true
221 },
222 )?;
223 }
224 Ok(result)
225}
226
227fn load_commit_entity(store: &UnifiedStore, hash: &str) -> RedDBResult<Option<UnifiedEntity>> {
228 load_metadata_entity(store, vc::COMMITS, hash)
229}
230
231fn commit_from_row(row: &RowData) -> Option<Commit> {
232 Some(Commit {
233 hash: row_text(row, "id")?,
234 root_xid: row_u64(row, "root_xid")?,
235 parents: row_string_list(row, "parents"),
236 height: row_u64(row, "height").unwrap_or(0),
237 author: Author {
238 name: row_text(row, "author_name").unwrap_or_default(),
239 email: row_text(row, "author_email").unwrap_or_default(),
240 },
241 committer: Author {
242 name: row_text(row, "committer_name").unwrap_or_default(),
243 email: row_text(row, "committer_email").unwrap_or_default(),
244 },
245 message: row_text(row, "message").unwrap_or_default(),
246 timestamp_ms: row_i64(row, "timestamp_ms").unwrap_or(0),
247 signature: row_text(row, "signature"),
248 })
249}
250
251fn load_commit(store: &UnifiedStore, hash: &str) -> RedDBResult<Option<Commit>> {
252 load_commit_entity(store, hash)?
253 .map(|entity| {
254 entity
255 .data
256 .as_row()
257 .and_then(commit_from_row)
258 .ok_or_else(|| RedDBError::Internal(format!("vcs: invalid commit '{hash}'")))
259 })
260 .transpose()
261}
262
263struct CommitSnapshot<'a> {
264 manager: &'a SnapshotManager,
265 snapshot: Snapshot,
266 retained: bool,
267}
268
269impl std::ops::Deref for CommitSnapshot<'_> {
270 type Target = Snapshot;
271 fn deref(&self) -> &Snapshot {
272 &self.snapshot
273 }
274}
275
276impl Drop for CommitSnapshot<'_> {
277 fn drop(&mut self) {
278 if !self.retained {
279 self.manager.unpin(
280 self.snapshot
281 .in_progress
282 .iter()
283 .copied()
284 .fold(self.snapshot.xid, Xid::min),
285 );
286 }
287 }
288}
289
290fn capture_commit_snapshot(manager: &SnapshotManager) -> CommitSnapshot<'_> {
291 let xid = manager.begin();
292 let mut snapshot = manager.snapshot(xid);
293 snapshot.in_progress.retain(|pending| *pending < xid);
294 let horizon = snapshot.in_progress.iter().copied().fold(xid, Xid::min);
297 manager.pin(horizon);
298 manager.commit(xid);
299 CommitSnapshot {
300 manager,
301 snapshot,
302 retained: false,
303 }
304}
305
306fn commit_snapshot_from_row(row: &RowData) -> RedDBResult<Snapshot> {
307 let xid = row_u64(row, "root_xid")
308 .ok_or_else(|| RedDBError::Internal("vcs: commit has no root snapshot".to_string()))?;
309 let mut in_progress = HashSet::new();
310 if let Some(value) = row.get_field("snapshot_in_progress") {
312 let Value::Array(values) = value else {
313 return Err(RedDBError::Internal(
314 "vcs: invalid commit snapshot exclusions".to_string(),
315 ));
316 };
317 for value in values {
318 let pending = match value {
319 Value::UnsignedInteger(value) => *value,
320 Value::Integer(value) if *value > 0 => *value as u64,
321 _ => {
322 return Err(RedDBError::Internal(
323 "vcs: invalid commit snapshot exclusion".to_string(),
324 ));
325 }
326 };
327 if pending == XID_NONE || pending >= xid {
328 return Err(RedDBError::Internal(
329 "vcs: commit snapshot exclusion outside its horizon".to_string(),
330 ));
331 }
332 in_progress.insert(pending);
333 }
334 }
335 Ok(Snapshot { xid, in_progress })
336}
337
338fn snapshot_sees_entity(
339 context: &reddb_engine::transaction::read::SnapshotContext,
340 entity: &UnifiedEntity,
341) -> bool {
342 reddb_engine::transaction::read::entity_visibility_check(context, entity)
343}
344
345fn commit_fields(
346 commit: &Commit,
347 snapshot: &Snapshot,
348 view: &reddb_engine::transaction::read::BranchReadView,
349) -> HashMap<String, Value> {
350 let mut fields: HashMap<String, Value> = HashMap::new();
351 fields.insert("branch_lineage".into(), branches::encode_epochs(view));
352 fields.insert(
353 "writer_epoch".into(),
354 Value::UnsignedInteger(view.writer_epoch),
355 );
356 fields.insert("id".to_string(), Value::text(commit.hash.as_str()));
357 fields.insert(
358 "root_xid".to_string(),
359 Value::UnsignedInteger(commit.root_xid),
360 );
361 let mut pending: Vec<Xid> = snapshot.in_progress.iter().copied().collect();
362 pending.sort_unstable();
363 fields.insert(
364 "snapshot_in_progress".to_string(),
365 Value::Array(pending.into_iter().map(Value::UnsignedInteger).collect()),
366 );
367 fields.insert(
368 "parents".to_string(),
369 Value::Array(
370 commit
371 .parents
372 .iter()
373 .map(|p| Value::text(p.as_str()))
374 .collect(),
375 ),
376 );
377 fields.insert("height".to_string(), Value::UnsignedInteger(commit.height));
378 fields.insert(
379 "author_name".to_string(),
380 Value::text(commit.author.name.as_str()),
381 );
382 fields.insert(
383 "author_email".to_string(),
384 Value::text(commit.author.email.as_str()),
385 );
386 fields.insert(
387 "committer_name".to_string(),
388 Value::text(commit.committer.name.as_str()),
389 );
390 fields.insert(
391 "committer_email".to_string(),
392 Value::text(commit.committer.email.as_str()),
393 );
394 fields.insert("message".to_string(), Value::text(commit.message.as_str()));
395 fields.insert(
396 "timestamp_ms".to_string(),
397 Value::TimestampMs(commit.timestamp_ms),
398 );
399 if let Some(sig) = &commit.signature {
400 fields.insert("signature".to_string(), Value::text(sig.as_str()));
401 }
402 fields
403}
404
405fn ref_kind_from_str(s: &str) -> Option<RefKind> {
410 match s {
411 "branch" => Some(RefKind::Branch),
412 "tag" => Some(RefKind::Tag),
413 "head" => Some(RefKind::Head),
414 _ => None,
415 }
416}
417
418fn ref_from_row(row: &RowData) -> Option<Ref> {
419 Some(Ref {
420 name: row_text(row, "id")?,
421 kind: ref_kind_from_str(&row_text(row, "type")?)?,
422 target: row_text(row, "target")?,
423 protected: row
424 .get_field("protected")
425 .and_then(|v| match v {
426 Value::Boolean(b) => Some(*b),
427 _ => None,
428 })
429 .unwrap_or(false),
430 })
431}
432
433fn load_ref_entity(
434 store: &UnifiedStore,
435 name: &str,
436) -> RedDBResult<Option<(EntityId, UnifiedEntity)>> {
437 Ok(load_metadata_entity(store, vc::REFS, name)?.map(|entity| (entity.id, entity)))
438}
439
440fn load_ref(store: &UnifiedStore, name: &str) -> RedDBResult<Option<Ref>> {
441 load_ref_entity(store, name)?
442 .map(|(_, entity)| {
443 entity
444 .data
445 .as_row()
446 .and_then(ref_from_row)
447 .ok_or_else(|| RedDBError::Internal(format!("vcs: invalid reference '{name}'")))
448 })
449 .transpose()
450}
451
452fn stage_ref(
453 store: &UnifiedStore,
454 publication: &mut crate::storage::unified::store::RowPublication,
455 reference: &Ref,
456) -> RedDBResult<()> {
457 let kind = match reference.kind {
458 RefKind::Branch => "branch",
459 RefKind::Tag => "tag",
460 RefKind::Head => "head",
461 };
462 branches::stage_metadata(
463 store,
464 publication,
465 vc::REFS,
466 "id",
467 &reference.name,
468 HashMap::from([
469 ("id".into(), Value::text(reference.name.as_str())),
470 ("type".into(), Value::text(kind)),
471 ("target".into(), Value::text(reference.target.as_str())),
472 ("protected".into(), Value::Boolean(reference.protected)),
473 ]),
474 )
475}
476
477fn save_ref(store: &UnifiedStore, reference: &Ref) -> RedDBResult<()> {
478 let mut publication = crate::storage::unified::store::RowPublication::default();
479 stage_ref(store, &mut publication, reference)?;
480 store.publish_rows(publication)?;
481 Ok(())
482}
483
484fn delete_ref(store: &UnifiedStore, name: &str) -> RedDBResult<bool> {
485 let Some((id, _)) = load_ref_entity(store, name)? else {
486 return Ok(false);
487 };
488 let mut publication = crate::storage::unified::store::RowPublication::default();
489 publication.deletes.push((vc::REFS.into(), id));
490 store.publish_rows(publication)?;
491 Ok(true)
492}
493
494fn is_versioned(store: &UnifiedStore, name: &str) -> RedDBResult<bool> {
505 if name.starts_with("red_") || name.starts_with("red.") {
506 return Ok(false);
507 }
508 let Some(manager) = store.get_collection(vc::SETTINGS) else {
509 return Ok(false);
510 };
511 let _publication = manager
512 .has_backing()
513 .then(|| store.vault_publication_lock.lock());
514 let mut versioned = false;
515 super::function_budget::try_scan(
516 |visit| manager.try_for_each_entity(usize::MAX, visit),
517 |entity| {
518 versioned = entity.data.as_row().is_some_and(|row| {
519 row.get_field("id")
520 .is_some_and(|id| matches!(id, Value::Text(id) if id.as_ref() == name))
521 && matches!(row.get_field("versioned"), Some(Value::Boolean(true)))
522 });
523 !versioned
524 },
525 )?;
526 Ok(versioned)
527}
528
529fn versioned_collections(store: &UnifiedStore) -> RedDBResult<Vec<String>> {
533 let Some(manager) = store.get_collection(vc::SETTINGS) else {
534 return Ok(Vec::new());
535 };
536 let _publication = manager
537 .has_backing()
538 .then(|| store.vault_publication_lock.lock());
539 let mut collections = Vec::new();
540 super::function_budget::try_scan(
541 |visit| manager.try_for_each_entity(usize::MAX, visit),
542 |entity| {
543 if let Some(row) = entity.data.as_row() {
544 if matches!(row.get_field("versioned"), Some(Value::Boolean(true))) {
545 if let Some(name) = row_text(row, "id") {
546 collections.push(name);
547 }
548 }
549 }
550 true
551 },
552 )?;
553 Ok(collections)
554}
555
556fn list_refs_by_prefix(store: &UnifiedStore, prefix: Option<&str>) -> RedDBResult<Vec<Ref>> {
557 let mut refs = Vec::new();
558 if let Some(manager) = store.get_collection(vc::REFS) {
559 super::function_budget::try_scan_result(
560 |visit| manager.try_for_each_entity(usize::MAX, visit),
561 |entity| {
562 let row = entity
563 .data
564 .as_row()
565 .ok_or_else(|| RedDBError::Internal("vcs: invalid reference row".into()))?;
566 let reference = ref_from_row(row)
567 .ok_or_else(|| RedDBError::Internal("vcs: invalid reference".into()))?;
568 if prefix.is_none_or(|prefix| reference.name.starts_with(prefix)) {
569 refs.push(reference);
570 }
571 Ok(true)
572 },
573 )?;
574 }
575 Ok(refs)
576}
577
578fn ancestor_set(
583 store: &UnifiedStore,
584 start: &str,
585 max_steps: usize,
586) -> RedDBResult<HashSet<CommitHash>> {
587 let mut visited: HashSet<CommitHash> = HashSet::new();
588 let mut stack: Vec<CommitHash> = vec![start.to_string()];
589 let mut steps = 0usize;
590 while let Some(hash) = stack.pop() {
591 if !visited.insert(hash.clone()) {
592 continue;
593 }
594 {
595 let c = load_commit(store, &hash)?
596 .ok_or_else(|| RedDBError::Internal(format!("vcs: missing ancestor '{hash}'")))?;
597 for p in c.parents {
598 if !visited.contains(&p) {
599 stack.push(p);
600 }
601 }
602 }
603 steps += 1;
604 if steps >= max_steps && !stack.is_empty() {
605 return Err(RedDBError::InvalidOperation(
606 "vcs: ancestry exceeds traversal budget".into(),
607 ));
608 }
609 }
610 Ok(visited)
611}
612
613fn topo_walk(store: &UnifiedStore, start: &str, range: &LogRange) -> RedDBResult<Vec<Commit>> {
614 let limit = range.limit.unwrap_or(usize::MAX);
615 let skip = range.skip.unwrap_or(0);
616 let exclude = range
617 .from
618 .as_ref()
619 .map(|h| ancestor_set(store, h, 100_000))
620 .transpose()?
621 .unwrap_or_default();
622
623 let mut visited: HashSet<CommitHash> = HashSet::new();
624 let mut stack: Vec<CommitHash> = vec![start.to_string()];
625 let mut out: Vec<Commit> = Vec::new();
626 let mut skipped = 0usize;
627 while let Some(hash) = stack.pop() {
628 if !visited.insert(hash.clone()) {
629 continue;
630 }
631 if exclude.contains(&hash) {
632 continue;
633 }
634 let commit = load_commit(store, &hash)?
635 .ok_or_else(|| RedDBError::Internal(format!("vcs: missing ancestor '{hash}'")))?;
636 if range.no_merges && commit.parents.len() > 1 {
637 for p in &commit.parents {
638 if !visited.contains(p) {
639 stack.push(p.clone());
640 }
641 }
642 continue;
643 }
644 for p in &commit.parents {
645 if !visited.contains(p) {
646 stack.push(p.clone());
647 }
648 }
649 if skipped < skip {
650 skipped += 1;
651 continue;
652 }
653 if out.len() >= limit {
654 break;
655 }
656 out.push(commit);
657 }
658 out.sort_by(|a, b| {
660 b.height
661 .cmp(&a.height)
662 .then(b.timestamp_ms.cmp(&a.timestamp_ms))
663 });
664 Ok(out)
665}
666
667fn resolve_ref_chain(store: &UnifiedStore, name: &str) -> RedDBResult<Option<CommitHash>> {
672 let mut current = name.to_string();
673 for _ in 0..4 {
674 let Some(reference) = load_ref(store, ¤t)? else {
675 return Ok(None);
676 };
677 match reference.kind {
678 RefKind::Branch | RefKind::Tag => return Ok(Some(reference.target)),
679 RefKind::Head => current = reference.target,
680 }
681 }
682 Err(RedDBError::InvalidOperation(
683 "vcs: reference chain exceeds four levels".into(),
684 ))
685}
686
687fn resolve_short_commit(store: &UnifiedStore, prefix: &str) -> RedDBResult<Option<CommitHash>> {
688 if prefix.len() < 4 {
689 return Ok(None);
690 }
691 let mut found = None;
692 let mut ambiguous = false;
693 if let Some(manager) = store.get_collection(vc::COMMITS) {
694 super::function_budget::try_scan(
695 |visit| manager.try_for_each_entity(usize::MAX, visit),
696 |entity| {
697 if let Some(hash) = entity.data.as_row().and_then(|row| row_text(row, "id")) {
698 if hash.starts_with(prefix) {
699 ambiguous = found.is_some();
700 found = Some(hash);
701 }
702 }
703 !ambiguous
704 },
705 )?;
706 }
707 Ok(if ambiguous { None } else { found })
708}
709
710fn stage_workset(
715 store: &UnifiedStore,
716 publication: &mut crate::storage::unified::store::RowPublication,
717 connection_id: u64,
718 branch: &str,
719 base_commit: Option<&str>,
720 working_xid: Xid,
721) -> RedDBResult<()> {
722 let connection = connection_id.to_string();
723 let mut fields = HashMap::from([
724 ("id".into(), Value::text(connection.as_str())),
725 ("branch".into(), Value::text(branch)),
726 ("working_xid".into(), Value::UnsignedInteger(working_xid)),
727 ]);
728 if let Some(base) = base_commit {
729 fields.insert("base_commit".into(), Value::text(base));
730 }
731 branches::stage_metadata(store, publication, vc::WORKSETS, "id", &connection, fields)
732}
733
734fn load_workset(
735 store: &UnifiedStore,
736 connection_id: u64,
737) -> RedDBResult<Option<(RefName, Option<CommitHash>)>> {
738 Ok(load_workset_state(store, connection_id)?.map(|(branch, base, _)| (branch, base)))
739}
740
741fn load_workset_state(
742 store: &UnifiedStore,
743 connection_id: u64,
744) -> RedDBResult<Option<(RefName, Option<CommitHash>, Option<String>)>> {
745 let Some(entity) = load_metadata_entity(store, vc::WORKSETS, &connection_id.to_string())?
746 else {
747 return Ok(None);
748 };
749 let row = entity
750 .data
751 .as_row()
752 .ok_or_else(|| RedDBError::Internal("vcs: invalid workset".into()))?;
753 let branch = row_text(row, "branch")
754 .ok_or_else(|| RedDBError::Internal("vcs: workset has no branch".into()))?;
755 let base = row_text(row, "base_commit");
756 let pending_merge = row_text(row, "merge_state_id");
757 let head = if branch.starts_with(vc::BRANCH_REF_PREFIX) && pending_merge.is_none() {
758 load_ref(store, &branch)?
759 .map(|reference| reference.target)
760 .or(base)
761 } else {
762 base
763 };
764 Ok(Some((branch, head, pending_merge)))
765}
766
767fn ensure_no_pending_merge(store: &UnifiedStore, connection_id: u64) -> RedDBResult<()> {
768 let branch = load_workset(store, connection_id)?
769 .map(|(branch, _)| branch)
770 .unwrap_or_else(|| vc::DEFAULT_BRANCH_REF.into());
771 let mut pending = None;
772 if let Some(manager) = store.get_collection(vc::WORKSETS) {
773 manager.try_for_each_entity(usize::MAX, |entity| {
774 if let Some(row) = entity
775 .data
776 .as_row()
777 .filter(|row| row_text(row, "branch").as_deref() == Some(&branch))
778 {
779 pending = row_text(row, "merge_state_id");
780 }
781 pending.is_none()
782 })?;
783 }
784 if let Some(state) = pending {
785 return Err(RedDBError::InvalidOperation(format!(
786 "cannot change branch data or HEAD while merge `{state}` is pending"
787 )));
788 }
789 Ok(())
790}
791
792impl RedDBRuntime {
797 pub(crate) fn vcs_has_versioned_collections(&self) -> RedDBResult<bool> {
798 Ok(!versioned_collections(&self.db().store())?.is_empty())
799 }
800
801 pub(crate) fn vcs_reject_destructive_ddl(&self, collection: &str) -> RedDBResult<()> {
802 if self.vcs_is_versioned(collection)? {
803 return Err(RedDBError::InvalidOperation(format!(
804 "cannot physically drop or truncate versioned collection '{collection}'; use versioned deletes or QUEUE PURGE"
805 )));
806 }
807 Ok(())
808 }
809
810 pub(super) fn rehydrate_vcs_snapshot_pins(&self) -> RedDBResult<()> {
811 let store = self.inner.db.store();
812 let Some(commits) = store.get_collection(vc::COMMITS) else {
813 return Ok(());
814 };
815 let manager = self.inner.transaction_state.snapshot_manager();
816 let mut failure = None;
817 commits.try_for_each_entity(usize::MAX, |entity| {
818 let Some(row) = entity.data.as_row() else {
819 failure = Some(RedDBError::Internal(
820 "vcs: invalid commit record".to_string(),
821 ));
822 return false;
823 };
824 match commit_snapshot_from_row(row) {
825 Ok(snapshot) => {
826 manager.observe_committed_xid(snapshot.xid);
827 let horizon = snapshot
828 .in_progress
829 .iter()
830 .copied()
831 .fold(snapshot.xid, Xid::min);
832 manager.pin(horizon);
833 true
834 }
835 Err(error) => {
836 failure = Some(error);
837 false
838 }
839 }
840 })?;
841 match failure {
842 Some(error) => Err(error),
843 None => Ok(()),
844 }
845 }
846
847 pub fn vcs_commit(&self, input: CreateCommitInput) -> RedDBResult<Commit> {
848 let store_arc = self.inner.db.store();
849 let store: &UnifiedStore = &store_arc;
850 let _publication = store.vault_publication_lock.lock();
851
852 if let Some((_, _, Some(state))) = load_workset_state(store, input.connection_id)? {
853 return merge_data::finish_pending(self, input, &state);
854 }
855
856 ensure_no_pending_merge(store, input.connection_id)?;
857
858 let workset = load_workset(store, input.connection_id)?;
861 let branch_ref = workset
862 .as_ref()
863 .map(|(b, _)| b.clone())
864 .unwrap_or_else(|| vc::DEFAULT_BRANCH_REF.to_string());
865
866 if !branch_ref.starts_with(vc::BRANCH_REF_PREFIX) {
867 return Err(RedDBError::InvalidOperation(
868 "cannot commit a detached or tag checkout".into(),
869 ));
870 }
871 let parent_hash = load_ref(store, &branch_ref)?
872 .map(|reference| reference.target)
873 .or_else(|| workset.as_ref().and_then(|(_, base)| base.clone()));
874
875 let parents: Vec<CommitHash> = match parent_hash.clone() {
876 Some(h) if !h.is_empty() => vec![h],
877 _ => Vec::new(),
878 };
879
880 let mut parent_height = 0;
881 for parent in &parents {
882 let commit = load_commit(store, parent)?
883 .ok_or_else(|| RedDBError::Internal("vcs: missing parent commit".into()))?;
884 parent_height = parent_height.max(commit.height);
885 }
886 let height = if parents.is_empty() {
887 0
888 } else {
889 parent_height + 1
890 };
891
892 let snapshot_manager = self.inner.transaction_state.snapshot_manager();
898 let mut snapshot = capture_commit_snapshot(&snapshot_manager);
899 let root_xid = snapshot.xid;
900 let timestamp_ms = now_ms();
901 let committer = input.committer.unwrap_or_else(|| input.author.clone());
902
903 let hash = compute_commit_hash(
904 root_xid,
905 &parents,
906 &input.author,
907 &input.message,
908 timestamp_ms,
909 );
910
911 if load_commit(store, &hash)?.is_some() {
912 return Err(RedDBError::InvalidConfig(format!(
913 "commit {hash} already exists (duplicate timestamp+content)"
914 )));
915 }
916
917 let commit = Commit {
918 hash: hash.clone(),
919 root_xid,
920 parents,
921 height,
922 author: input.author,
923 committer,
924 message: input.message,
925 timestamp_ms,
926 signature: None,
927 };
928
929 if !branch_ref.starts_with(vc::BRANCH_REF_PREFIX) {
930 return Err(RedDBError::InvalidOperation(
931 "cannot commit a detached or tag checkout".into(),
932 ));
933 }
934 merge_data::publish_commit(
935 self,
936 input.connection_id,
937 &branch_ref,
938 &commit,
939 &snapshot,
940 None,
941 )?;
942
943 snapshot.retained = true;
944 Ok(commit)
945 }
946
947 pub fn vcs_branch_create(&self, input: CreateBranchInput) -> RedDBResult<Ref> {
948 let store_arc = self.inner.db.store();
949 let store: &UnifiedStore = &store_arc;
950 let _publication = store.vault_publication_lock.lock();
951 let full_name = normalize_branch_name(&input.name);
952 if load_ref(store, &full_name)?.is_some() {
953 return Err(RedDBError::InvalidConfig(format!(
954 "branch `{full_name}` already exists"
955 )));
956 }
957 let target_hash = match &input.from {
958 Some(spec) => RedDBRuntime::vcs_resolve_commitish(self, spec)?,
959 None => {
960 let workset = load_workset(store, input.connection_id)?;
962
963 workset
964 .and_then(|(_, base)| base)
965 .or(load_ref(store, vc::DEFAULT_BRANCH_REF)?.map(|r| r.target))
966 .unwrap_or_default()
967 }
968 };
969 let r = Ref {
970 name: full_name,
971 kind: RefKind::Branch,
972 target: target_hash,
973 protected: false,
974 };
975 let mut publication = crate::storage::unified::store::RowPublication::default();
976 branches::stage_new_view(store, &mut publication, &r.name, &r.target)?;
977 stage_ref(store, &mut publication, &r)?;
978 store.publish_rows(publication)?;
979 Ok(r)
980 }
981
982 pub fn vcs_branch_delete(&self, name: &str) -> RedDBResult<()> {
983 let store_arc = self.inner.db.store();
984 let store: &UnifiedStore = &store_arc;
985 let _publication = store.vault_publication_lock.lock();
986 let full = normalize_branch_name(name);
987 let existing = load_ref(store, &full)?
988 .ok_or_else(|| RedDBError::NotFound(format!("branch `{full}` does not exist")))?;
989 if existing.protected {
990 return Err(RedDBError::ReadOnly(format!(
991 "branch `{full}` is protected"
992 )));
993 }
994 if full == vc::DEFAULT_BRANCH_REF {
995 return Err(RedDBError::InvalidOperation(
996 "cannot delete the default branch".into(),
997 ));
998 }
999 let mut publication = crate::storage::unified::store::RowPublication::default();
1000 if let Some((id, _)) = load_ref_entity(store, &full)? {
1001 publication.deletes.push((vc::REFS.into(), id));
1002 }
1003 branches::stage_delete_view(store, &mut publication, &full)?;
1004 store.publish_rows(publication)?;
1005 Ok(())
1006 }
1007
1008 pub fn vcs_tag_create(&self, input: CreateTagInput) -> RedDBResult<Ref> {
1009 let store_arc = self.inner.db.store();
1010 let store: &UnifiedStore = &store_arc;
1011 let _publication = store.vault_publication_lock.lock();
1012 let full_name = normalize_tag_name(&input.name);
1013 if load_ref(store, &full_name)?.is_some() {
1014 return Err(RedDBError::InvalidConfig(format!(
1015 "tag `{full_name}` already exists"
1016 )));
1017 }
1018 let target_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.target)?;
1019 let r = Ref {
1020 name: full_name,
1021 kind: RefKind::Tag,
1022 target: target_hash,
1023 protected: false,
1024 };
1025 save_ref(store, &r)?;
1026 Ok(r)
1027 }
1028
1029 pub fn vcs_tag_delete(&self, name: &str) -> RedDBResult<()> {
1030 let store_arc = self.inner.db.store();
1031 let store: &UnifiedStore = &store_arc;
1032 let _publication = store.vault_publication_lock.lock();
1033 let full = normalize_tag_name(name);
1034 let existing = load_ref(store, &full)?
1035 .ok_or_else(|| RedDBError::NotFound(format!("tag `{full}` does not exist")))?;
1036 if existing.protected {
1037 return Err(RedDBError::ReadOnly(format!("tag `{full}` is protected")));
1038 }
1039 delete_ref(store, &full)?;
1040 Ok(())
1041 }
1042
1043 pub fn vcs_list_refs(&self, prefix: Option<&str>) -> RedDBResult<Vec<Ref>> {
1044 let store_arc = self.inner.db.store();
1045 let store: &UnifiedStore = &store_arc;
1046 Ok(list_refs_by_prefix(store, prefix)?)
1047 }
1048
1049 pub fn vcs_checkout(&self, input: CheckoutInput) -> RedDBResult<Ref> {
1050 let store_arc = self.inner.db.store();
1051 let store: &UnifiedStore = &store_arc;
1052 let _publication = store.vault_publication_lock.lock();
1053 if self
1054 .inner
1055 .transaction_state
1056 .context(input.connection_id)
1057 .is_some()
1058 {
1059 return Err(RedDBError::InvalidOperation(
1060 "cannot checkout during an active transaction".into(),
1061 ));
1062 }
1063 if !input.force {
1064 ensure_no_pending_merge(store, input.connection_id)?;
1065 }
1066 let mut publication = crate::storage::unified::store::RowPublication::default();
1067 let (branch_ref, target_hash) = match &input.target {
1068 CheckoutTarget::Branch(name) => {
1069 let full = normalize_branch_name(name);
1070 let r = load_ref(store, &full)?.ok_or_else(|| {
1071 RedDBError::NotFound(format!("branch `{full}` does not exist"))
1072 })?;
1073 if full != vc::DEFAULT_BRANCH_REF && branches::load_view(store, &full)?.is_none() {
1074 branches::stage_new_view(store, &mut publication, &full, &r.target)?;
1075 }
1076 (full, r.target)
1077 }
1078 CheckoutTarget::Tag(name) => {
1079 let full = normalize_tag_name(name);
1080 let r = load_ref(store, &full)?
1081 .ok_or_else(|| RedDBError::NotFound(format!("tag `{full}` does not exist")))?;
1082 (full, r.target)
1083 }
1084 CheckoutTarget::Commit(hash) => {
1085 let resolved = RedDBRuntime::vcs_resolve_commitish(self, hash)?;
1086 (String::new(), resolved)
1089 }
1090 };
1091
1092 stage_workset(
1093 store,
1094 &mut publication,
1095 input.connection_id,
1096 &branch_ref,
1097 Some(&target_hash),
1098 self.inner
1099 .transaction_state
1100 .snapshot_manager()
1101 .peek_next_xid(),
1102 )?;
1103
1104 stage_ref(
1106 store,
1107 &mut publication,
1108 &Ref {
1109 name: head_ref_id(input.connection_id),
1110 kind: RefKind::Head,
1111 target: if branch_ref.is_empty() {
1112 target_hash.clone()
1113 } else {
1114 branch_ref.clone()
1115 },
1116 protected: false,
1117 },
1118 )?;
1119
1120 store.publish_rows(publication)?;
1121 Ok(Ref {
1122 name: if branch_ref.is_empty() {
1123 format!("detached:{target_hash}")
1124 } else {
1125 branch_ref
1126 },
1127 kind: if target_hash.is_empty() {
1128 RefKind::Head
1129 } else {
1130 RefKind::Branch
1131 },
1132 target: target_hash,
1133 protected: false,
1134 })
1135 }
1136
1137 pub fn vcs_merge(&self, input: MergeInput) -> RedDBResult<MergeOutcome> {
1138 let store_arc = self.inner.db.store();
1139 let store: &UnifiedStore = &store_arc;
1140 let _publication = store.vault_publication_lock.lock();
1141 ensure_no_pending_merge(store, input.connection_id)?;
1142
1143 let from_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.from)?;
1145 let from_commit = load_commit(store, &from_hash)?.ok_or_else(|| {
1146 RedDBError::NotFound(format!("source commit `{from_hash}` not found"))
1147 })?;
1148
1149 let workset = load_workset(store, input.connection_id)?;
1151 let (head_branch, head_hash) = match workset {
1152 Some((branch, Some(head))) => (branch, head),
1153 Some((branch, None)) => {
1154 let head = load_ref(store, &branch)?
1155 .map(|r| r.target)
1156 .unwrap_or_default();
1157 (branch, head)
1158 }
1159 None => {
1160 let head = load_ref(store, vc::DEFAULT_BRANCH_REF)?
1161 .map(|r| r.target)
1162 .unwrap_or_default();
1163 (vc::DEFAULT_BRANCH_REF.to_string(), head)
1164 }
1165 };
1166 if head_hash.is_empty() {
1167 return Err(RedDBError::InvalidConfig(
1168 "cannot merge: HEAD has no commits".to_string(),
1169 ));
1170 }
1171
1172 let from_ancestors = ancestor_set(store, &from_hash, 100_000)?;
1174 let can_fast_forward = from_ancestors.contains(&head_hash);
1175
1176 match input.opts.strategy {
1177 MergeStrategy::FastForwardOnly if !can_fast_forward => {
1178 return Err(RedDBError::InvalidConfig(
1179 "not a fast-forward — use --strategy auto or no-ff".to_string(),
1180 ));
1181 }
1182 _ => {}
1183 }
1184
1185 if can_fast_forward && input.opts.strategy != MergeStrategy::NoFastForward {
1186 if head_branch.is_empty() {
1187 return Err(RedDBError::InvalidConfig(
1188 "cannot fast-forward a detached HEAD".to_string(),
1189 ));
1190 }
1191 branches::ensure_entity_history_supported(self)?;
1192 branches::ensure_clean(self, input.connection_id, &head_hash)?;
1193 merge_data::plan(self, &head_hash, &head_hash, &from_hash, "head-change")?;
1194 branches::publish_head(
1195 store,
1196 input.connection_id,
1197 &head_branch,
1198 &from_commit,
1199 branches::commit_view(store, &from_hash)?,
1200 )?;
1201 return Ok(MergeOutcome {
1202 merge_commit: Some(from_commit),
1203 fast_forward: true,
1204 conflicts: Vec::new(),
1205 merge_state_id: None,
1206 });
1207 }
1208
1209 if !head_branch.starts_with(vc::BRANCH_REF_PREFIX) {
1210 return Err(RedDBError::InvalidOperation(
1211 "cannot merge into a detached or tag checkout".into(),
1212 ));
1213 }
1214 branches::ensure_entity_history_supported(self)?;
1215 branches::ensure_clean(self, input.connection_id, &head_hash)?;
1216 let lca = RedDBRuntime::vcs_lca(self, &head_hash, &from_hash)?.ok_or_else(|| {
1217 RedDBError::InvalidOperation("cannot merge unrelated histories".into())
1218 })?;
1219
1220 let message = input
1221 .opts
1222 .message
1223 .unwrap_or_else(|| format!("Merge {} into {}", input.from, head_branch));
1224 let author = input.author.clone();
1225 let timestamp_ms = now_ms();
1226 let parents = vec![head_hash.clone(), from_hash.clone()];
1227 let mut parent_height = 0;
1228 for parent in &parents {
1229 let commit = load_commit(store, parent)?
1230 .ok_or_else(|| RedDBError::Internal("vcs: missing parent commit".into()))?;
1231 parent_height = parent_height.max(commit.height);
1232 }
1233 let height = parent_height + 1;
1234 let snapshot_manager = self.inner.transaction_state.snapshot_manager();
1235 let mut snapshot = capture_commit_snapshot(&snapshot_manager);
1236 let root_xid = snapshot.xid;
1237
1238 let hash = compute_commit_hash(root_xid, &parents, &author, &message, timestamp_ms);
1239
1240 let merge_commit = Commit {
1241 hash: hash.clone(),
1242 root_xid,
1243 parents,
1244 height,
1245 author: author.clone(),
1246 committer: author,
1247 message,
1248 timestamp_ms,
1249 signature: None,
1250 };
1251
1252 let merge_state_id = format!("ms:{}", &hash[..16]);
1253
1254 let plan = merge_data::plan(self, &lca, &head_hash, &from_hash, &merge_state_id)?;
1255 let conflicts = plan.conflicts.clone();
1256 if conflicts.is_empty() {
1257 let result = merge_data::publish_commit(
1258 self,
1259 input.connection_id,
1260 &head_branch,
1261 &merge_commit,
1262 &snapshot,
1263 Some(plan),
1264 );
1265 result?;
1266 snapshot.retained = true;
1267 return Ok(MergeOutcome {
1268 merge_commit: Some(merge_commit),
1269 fast_forward: false,
1270 conflicts,
1271 merge_state_id: None,
1272 });
1273 }
1274 let mut publication = crate::storage::unified::store::RowPublication::default();
1275 stage_merge_conflicts(store, &mut publication, &conflicts)?;
1276
1277 let mut ms_fields: HashMap<String, Value> = HashMap::new();
1278 ms_fields.insert("id".to_string(), Value::text(merge_state_id.as_str()));
1279 ms_fields.insert("kind".to_string(), Value::text("merge"));
1280 ms_fields.insert("branch".to_string(), Value::text(head_branch.as_str()));
1281 ms_fields.insert("base".to_string(), Value::text(lca.as_str()));
1282 ms_fields.insert("ours".to_string(), Value::text(head_hash.as_str()));
1283 ms_fields.insert("theirs".to_string(), Value::text(from_hash.as_str()));
1284 ms_fields.insert(
1285 "conflicts_count".to_string(),
1286 Value::UnsignedInteger(conflicts.len() as u64),
1287 );
1288 branches::stage_metadata(
1289 store,
1290 &mut publication,
1291 vc::MERGE_STATE,
1292 "id",
1293 &merge_state_id,
1294 ms_fields,
1295 )?;
1296
1297 let head_xid = self.vcs_resolve_as_of(AsOfSpec::Commit(head_hash.clone()))?;
1298 let conn_id = input.connection_id.to_string();
1299 branches::stage_metadata(
1300 store,
1301 &mut publication,
1302 vc::WORKSETS,
1303 "id",
1304 &conn_id,
1305 HashMap::from([
1306 ("id".into(), Value::text(conn_id.as_str())),
1307 ("branch".into(), Value::text(head_branch.as_str())),
1308 ("base_commit".into(), Value::text(head_hash.as_str())),
1309 ("working_xid".into(), Value::UnsignedInteger(head_xid)),
1310 (
1311 "merge_state_id".into(),
1312 Value::text(merge_state_id.as_str()),
1313 ),
1314 ]),
1315 )?;
1316 store.publish_rows(publication)?;
1317 Ok(MergeOutcome {
1318 merge_commit: None,
1319 fast_forward: false,
1320 conflicts,
1321 merge_state_id: Some(merge_state_id),
1322 })
1323 }
1324
1325 pub fn vcs_cherry_pick(
1326 &self,
1327 connection_id: u64,
1328 commit: &str,
1329 author: Author,
1330 ) -> RedDBResult<MergeOutcome> {
1331 let store_arc = self.inner.db.store();
1332 let store: &UnifiedStore = &store_arc;
1333 let _publication = store.vault_publication_lock.lock();
1334 ensure_no_pending_merge(store, connection_id)?;
1335
1336 let src_hash = RedDBRuntime::vcs_resolve_commitish(self, commit)?;
1338 let src_commit = load_commit(store, &src_hash)?.ok_or_else(|| {
1339 RedDBError::NotFound(format!("cherry-pick source `{src_hash}` not found"))
1340 })?;
1341 if src_commit.parents.is_empty() {
1342 return Err(RedDBError::InvalidConfig(
1343 "cannot cherry-pick a root commit".to_string(),
1344 ));
1345 }
1346 if src_commit.parents.len() > 1 {
1347 return Err(RedDBError::InvalidConfig(
1348 "cannot cherry-pick a merge commit; resolve manually".to_string(),
1349 ));
1350 }
1351 let parent_hash = src_commit.parents[0].clone();
1352
1353 let picked = merge_data::apply_delta(
1354 self,
1355 connection_id,
1356 &parent_hash,
1357 &src_hash,
1358 author,
1359 format!("cherry-pick: {}", src_commit.message),
1360 )?;
1361 Ok(MergeOutcome {
1362 merge_commit: Some(picked),
1363 fast_forward: false,
1364 conflicts: Vec::new(),
1365 merge_state_id: None,
1366 })
1367 }
1368
1369 pub fn vcs_revert(
1370 &self,
1371 connection_id: u64,
1372 commit: &str,
1373 author: Author,
1374 ) -> RedDBResult<Commit> {
1375 let store_arc = self.inner.db.store();
1376 let store: &UnifiedStore = &store_arc;
1377 let _publication = store.vault_publication_lock.lock();
1378 ensure_no_pending_merge(store, connection_id)?;
1379
1380 let src_hash = RedDBRuntime::vcs_resolve_commitish(self, commit)?;
1381 let src_commit = load_commit(store, &src_hash)?
1382 .ok_or_else(|| RedDBError::NotFound(format!("revert source `{src_hash}` not found")))?;
1383 if src_commit.parents.is_empty() {
1384 return Err(RedDBError::InvalidConfig(
1385 "cannot revert a root commit".to_string(),
1386 ));
1387 }
1388 let parent_hash = src_commit.parents[0].clone();
1389
1390 if src_commit.parents.len() != 1 {
1391 return Err(RedDBError::InvalidOperation(
1392 "cannot revert a merge commit without selecting a parent".into(),
1393 ));
1394 }
1395 merge_data::apply_delta(
1396 self,
1397 connection_id,
1398 &src_hash,
1399 &parent_hash,
1400 author,
1401 format!("Revert \"{}\"", src_commit.message),
1402 )
1403 }
1404
1405 pub fn vcs_reset(&self, input: ResetInput) -> RedDBResult<()> {
1406 let store_arc = self.inner.db.store();
1407 let store: &UnifiedStore = &store_arc;
1408 let _publication = store.vault_publication_lock.lock();
1409 ensure_no_pending_merge(store, input.connection_id)?;
1410 let target_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.target)?;
1411 let target_commit = load_commit(store, &target_hash)?.ok_or_else(|| {
1412 RedDBError::NotFound(format!("target commit `{target_hash}` not found"))
1413 })?;
1414
1415 let workset = load_workset(store, input.connection_id)?;
1417 let branch = workset
1418 .as_ref()
1419 .map(|(b, _)| b.clone())
1420 .unwrap_or_else(|| vc::DEFAULT_BRANCH_REF.to_string());
1421
1422 if !branch.starts_with(vc::BRANCH_REF_PREFIX) {
1423 return Err(RedDBError::InvalidOperation(
1424 "reset requires a branch".into(),
1425 ));
1426 }
1427 if self.snapshot_manager().oldest_active_xid().is_some() {
1428 return Err(RedDBError::InvalidOperation(
1429 "finish active transactions before reset".into(),
1430 ));
1431 }
1432 match input.mode {
1434 ResetMode::Soft | ResetMode::Mixed => {
1435 let mut publication = crate::storage::unified::store::RowPublication::default();
1436 branches::stage_head(
1437 store,
1438 &mut publication,
1439 input.connection_id,
1440 &branch,
1441 &target_commit,
1442 None,
1443 )?;
1444 store.publish_rows(publication)?;
1445 Ok(())
1446 }
1447 ResetMode::Hard => {
1448 branches::ensure_entity_history_supported(self)?;
1449 merge_data::validate_head_change(self, input.connection_id, &target_hash)?;
1450 branches::publish_head(
1451 store,
1452 input.connection_id,
1453 &branch,
1454 &target_commit,
1455 branches::commit_view(store, &target_hash)?,
1456 )
1457 }
1458 }
1459 }
1460
1461 pub fn vcs_log(&self, input: LogInput) -> RedDBResult<Vec<Commit>> {
1462 let store_arc = self.inner.db.store();
1463 let store: &UnifiedStore = &store_arc;
1464 let start = match &input.range.to {
1465 Some(spec) => RedDBRuntime::vcs_resolve_commitish(self, spec)?,
1466 None => {
1467 let workset = load_workset(store, input.connection_id)?;
1468 workset
1469 .and_then(|(_, base)| base)
1470 .or(load_ref(store, vc::DEFAULT_BRANCH_REF)?.map(|r| r.target))
1471 .unwrap_or_default()
1472 }
1473 };
1474 if start.is_empty() {
1475 return Ok(Vec::new());
1476 }
1477 Ok(topo_walk(store, &start, &input.range)?)
1478 }
1479
1480 pub fn vcs_diff(&self, input: DiffInput) -> RedDBResult<Diff> {
1481 let store_arc = self.inner.db.store();
1482 let store: &UnifiedStore = &store_arc;
1483 let from_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.from)?;
1484 let to_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.to)?;
1485 let from_snap = self.vcs_commit_read_context(&from_hash)?;
1486 let to_snap = self.vcs_commit_read_context(&to_hash)?;
1487
1488 let mut entries: Vec<DiffEntry> = Vec::new();
1490 let mut added = 0usize;
1491 let mut removed = 0usize;
1492 let mut modified = 0usize;
1493 let collections = store.list_collections();
1494 for coll in collections {
1495 if coll.starts_with("red_") || coll.starts_with("red.") {
1496 continue;
1497 }
1498 if !is_versioned(store, &coll)? {
1499 continue;
1500 }
1501 if let Some(filter) = &input.collection {
1502 if filter != &coll {
1503 continue;
1504 }
1505 }
1506 let Some(manager) = store.get_collection(&coll) else {
1507 continue;
1508 };
1509 self.check_collection_privilege(reddb_rql::ast::PolicyAction::Select, &coll)?;
1510 let mut entities = Vec::new();
1511 manager.try_for_each_entity(usize::MAX, |entity| {
1512 entities.push(entity.clone());
1513 true
1514 })?;
1515 for entity in &entities {
1517 let in_from = snapshot_sees_entity(&from_snap, entity);
1518 let in_to = snapshot_sees_entity(&to_snap, entity);
1519 if in_from == in_to {
1520 continue;
1521 }
1522 ensure_vcs_entity_access(
1523 self,
1524 &coll,
1525 entity,
1526 reddb_rql::ast::PolicyAction::Select,
1527 )?;
1528 let entity_id = entity.logical_id().raw().to_string();
1529 let payload = if input.summary_only {
1530 JsonValue::Null
1531 } else {
1532 vcs_entity_body(self, &coll, entity)
1533 };
1534 let change = match (in_from, in_to) {
1535 (false, true) => {
1536 added += 1;
1537 DiffChange::Added { after: payload }
1538 }
1539 (true, false) => {
1540 removed += 1;
1541 DiffChange::Removed { before: payload }
1542 }
1543 _ => unreachable!(),
1544 };
1545 entries.push(DiffEntry {
1546 collection: coll.clone(),
1547 entity_id,
1548 change,
1549 });
1550 }
1551 }
1552
1553 entries = coalesce_modifications(entries, &mut added, &mut removed, &mut modified);
1558
1559 Ok(Diff {
1560 from: from_hash,
1561 to: to_hash,
1562 entries,
1563 added,
1564 removed,
1565 modified,
1566 })
1567 }
1568
1569 pub fn vcs_status(&self, input: StatusInput) -> RedDBResult<Status> {
1570 let store_arc = self.inner.db.store();
1571 let store: &UnifiedStore = &store_arc;
1572 let workset = load_workset_state(store, input.connection_id)?;
1573 let (head_ref, head_commit, merge_state_id) = match workset {
1574 Some((branch, base, merge_state_id)) => {
1575 let base = base.or(load_ref(store, &branch)?.map(|r| r.target));
1576 (Some(branch), base, merge_state_id)
1577 }
1578 None => {
1579 let base = load_ref(store, vc::DEFAULT_BRANCH_REF)?.map(|r| r.target);
1580 (Some(vc::DEFAULT_BRANCH_REF.to_string()), base, None)
1581 }
1582 };
1583 let detached = matches!(&head_ref, Some(s) if s.is_empty());
1584 let unresolved_conflicts = match &merge_state_id {
1585 Some(id) => self.vcs_conflicts_list(id)?.len(),
1586 None => 0,
1587 };
1588 Ok(Status {
1589 connection_id: input.connection_id,
1590 head_ref: head_ref.filter(|s| !s.is_empty()),
1591 head_commit,
1592 detached,
1593 staged_changes: 0,
1594 working_changes: 0,
1595 unresolved_conflicts,
1596 merge_state_id,
1597 })
1598 }
1599
1600 pub fn vcs_lca(&self, a: &str, b: &str) -> RedDBResult<Option<CommitHash>> {
1601 let store_arc = self.inner.db.store();
1602 let store: &UnifiedStore = &store_arc;
1603 let a_hash = RedDBRuntime::vcs_resolve_commitish(self, a)?;
1604 let b_hash = RedDBRuntime::vcs_resolve_commitish(self, b)?;
1605 let a_ancestors = ancestor_set(store, &a_hash, 100_000)?;
1606
1607 let mut visited: HashSet<CommitHash> = HashSet::new();
1610 let mut stack: Vec<CommitHash> = vec![b_hash];
1611 let mut best: Option<(u64, CommitHash)> = None;
1612 while let Some(hash) = stack.pop() {
1613 if !visited.insert(hash.clone()) {
1614 continue;
1615 }
1616 if a_ancestors.contains(&hash) {
1617 let height = load_commit(store, &hash)?.map(|c| c.height).unwrap_or(0);
1618 match &best {
1619 Some((h, _)) if *h >= height => {}
1620 _ => best = Some((height, hash.clone())),
1621 }
1622 continue;
1625 }
1626 {
1627 let commit = load_commit(store, &hash)?.ok_or_else(|| {
1628 RedDBError::Internal(format!("vcs: missing ancestor '{hash}'"))
1629 })?;
1630 for p in commit.parents {
1631 if !visited.contains(&p) {
1632 stack.push(p);
1633 }
1634 }
1635 }
1636 }
1637 Ok(best.map(|(_, h)| h))
1638 }
1639
1640 pub fn vcs_conflicts_list(&self, merge_state_id: &str) -> RedDBResult<Vec<Conflict>> {
1641 let store = self.db().store();
1642 let _publication = store.vault_publication_lock.lock();
1643 merge_data::read_conflicts(self, merge_state_id)
1644 }
1645
1646 pub fn vcs_conflict_resolve(&self, conflict_id: &str, resolved: JsonValue) -> RedDBResult<()> {
1647 let store = self.db().store();
1648 let _publication = store.vault_publication_lock.lock();
1649 let row = merge_data::metadata_row(&store, vc::CONFLICTS, conflict_id)?
1650 .ok_or_else(|| RedDBError::NotFound(format!("conflict `{conflict_id}` not found")))?;
1651 let choice = match &resolved {
1652 JsonValue::String(choice)
1653 if matches!(choice.as_str(), "ours" | "theirs" | "base" | "delete") =>
1654 {
1655 choice
1656 }
1657 _ => {
1658 return Err(RedDBError::InvalidOperation(
1659 "resolve with ours, theirs, base or delete; conflict retained".into(),
1660 ))
1661 }
1662 };
1663 let connection = crate::runtime::impl_core::current_connection_id();
1664 let active_state = load_workset_state(&store, connection)?.and_then(|(_, _, state)| state);
1665 if active_state != row_text(&row, "merge_state_id") {
1666 return Err(RedDBError::InvalidOperation(
1667 "conflict belongs to another pending merge".into(),
1668 ));
1669 }
1670 let mut fields: HashMap<_, _> = row
1671 .iter_fields()
1672 .map(|(name, value)| (name.into(), value.clone()))
1673 .collect();
1674 fields.insert("resolution".into(), Value::text(choice.as_str()));
1675 let mut publication = crate::storage::unified::store::RowPublication::default();
1676 branches::stage_metadata(
1677 &store,
1678 &mut publication,
1679 vc::CONFLICTS,
1680 "id",
1681 conflict_id,
1682 fields,
1683 )?;
1684 store.publish_rows(publication)?;
1685 Ok(())
1686 }
1687
1688 pub fn vcs_resolve_as_of(&self, spec: AsOfSpec) -> RedDBResult<Xid> {
1689 Ok(self.vcs_resolve_snapshot(spec)?.xid)
1690 }
1691
1692 pub(super) fn vcs_resolve_snapshot(&self, spec: AsOfSpec) -> RedDBResult<Snapshot> {
1693 let store_arc = self.inner.db.store();
1694 let store: &UnifiedStore = &store_arc;
1695 match spec {
1696 AsOfSpec::Snapshot(xid) => Ok(Snapshot {
1697 xid,
1698 in_progress: HashSet::new(),
1699 }),
1700 AsOfSpec::Commit(h) => {
1701 let entity = load_commit_entity(store, &h)?
1702 .ok_or_else(|| RedDBError::NotFound(format!("commit `{h}` not found")))?;
1703 let row = entity.data.as_row().ok_or_else(|| {
1704 RedDBError::Internal("vcs: invalid commit record".to_string())
1705 })?;
1706 commit_snapshot_from_row(row)
1707 }
1708 AsOfSpec::Branch(name) => {
1709 let full = normalize_branch_name(&name);
1710 let r = load_ref(store, &full)?.ok_or_else(|| {
1711 RedDBError::NotFound(format!("branch `{full}` does not exist"))
1712 })?;
1713 let entity = load_commit_entity(store, &r.target)?.ok_or_else(|| {
1714 RedDBError::NotFound(format!("branch `{full}` points to missing commit"))
1715 })?;
1716 let row = entity.data.as_row().ok_or_else(|| {
1717 RedDBError::Internal("vcs: invalid commit record".to_string())
1718 })?;
1719 commit_snapshot_from_row(row)
1720 }
1721 AsOfSpec::Tag(name) => {
1722 let full = normalize_tag_name(&name);
1723 let r = load_ref(store, &full)?
1724 .ok_or_else(|| RedDBError::NotFound(format!("tag `{full}` does not exist")))?;
1725 let entity = load_commit_entity(store, &r.target)?.ok_or_else(|| {
1726 RedDBError::NotFound(format!("tag `{full}` points to missing commit"))
1727 })?;
1728 let row = entity.data.as_row().ok_or_else(|| {
1729 RedDBError::Internal("vcs: invalid commit record".to_string())
1730 })?;
1731 commit_snapshot_from_row(row)
1732 }
1733 AsOfSpec::TimestampMs(ts) => {
1734 let manager = store
1735 .get_collection(vc::COMMITS)
1736 .ok_or_else(|| RedDBError::NotFound("no commits exist yet".to_string()))?;
1737 let mut best: Option<(i64, UnifiedEntity)> = None;
1739 let mut entities = Vec::new();
1740 manager.try_for_each_entity(usize::MAX, |entity| {
1741 entities.push(entity.clone());
1742 true
1743 })?;
1744 for entity in entities {
1745 if let Some(row) = entity.data.as_row() {
1746 let t = row_i64(row, "timestamp_ms").unwrap_or(0);
1747 if t <= ts {
1748 match &best {
1749 Some((bt, _)) if *bt >= t => {}
1750 _ => best = Some((t, entity)),
1751 }
1752 }
1753 }
1754 }
1755 let (_, entity) = best.ok_or_else(|| {
1756 RedDBError::NotFound(format!("no commit at or before ts={ts}"))
1757 })?;
1758 let row = entity.data.as_row().ok_or_else(|| {
1759 RedDBError::Internal("vcs: invalid commit record".to_string())
1760 })?;
1761 commit_snapshot_from_row(row)
1762 }
1763 }
1764 }
1765
1766 pub fn vcs_set_versioned(&self, collection: &str, enabled: bool) -> RedDBResult<()> {
1767 let store_arc = self.inner.db.store();
1768 let store: &UnifiedStore = &store_arc;
1769 let _publication = store.vault_publication_lock.lock();
1770 if collection.starts_with("red_") || collection.starts_with("red.") {
1771 return Err(RedDBError::InvalidConfig(format!(
1772 "cannot version internal collection `{collection}`"
1773 )));
1774 }
1775 if is_versioned(store, collection)? == enabled {
1776 return Ok(());
1777 }
1778 if self.snapshot_manager().oldest_active_xid().is_some() {
1779 return Err(RedDBError::InvalidOperation(
1780 "finish active transactions before changing version history".into(),
1781 ));
1782 }
1783 if !enabled
1784 && self
1785 .db()
1786 .collection_contract(collection)
1787 .is_some_and(|contract| {
1788 matches!(
1789 contract.declared_model,
1790 crate::catalog::CollectionModel::Hll
1791 | crate::catalog::CollectionModel::Sketch
1792 | crate::catalog::CollectionModel::Filter
1793 )
1794 })
1795 {
1796 return Err(RedDBError::InvalidOperation(
1797 "export probabilistic state before discarding its version history".into(),
1798 ));
1799 }
1800 if !enabled && list_refs_by_prefix(store, Some(vc::BRANCH_REF_PREFIX))?.len() > 1 {
1801 return Err(RedDBError::InvalidOperation(
1802 "cannot disable history while branches share versions".into(),
1803 ));
1804 }
1805 if enabled && store.format_version() < reddb_file::native_store::STORE_VERSION_V14 {
1806 let previous_format = store.format_version();
1807 store.set_format_version(reddb_file::native_store::STORE_VERSION_V14);
1808 if store.pager().is_some() {
1809 if let Err(error) = store.persist() {
1810 store.set_format_version(previous_format);
1811 return Err(RedDBError::Internal(error.to_string()));
1812 }
1813 }
1814 }
1815 let mut publication = crate::storage::unified::store::RowPublication::default();
1816 if enabled {
1817 if let Some(mut entity) = self.vcs_initial_probabilistic_state(collection)? {
1818 entity.id = store.next_entity_id();
1819 publication.inserts.push((collection.into(), entity, None));
1820 }
1821 }
1822 if self
1823 .db()
1824 .collection_contract(collection)
1825 .is_some_and(|contract| {
1826 contract.declared_model == crate::catalog::CollectionModel::Queue
1827 })
1828 {
1829 super::vcs_queue::stage_metadata_scope(store, &mut publication, collection, enabled)?;
1830 }
1831 if let Some(manager) = store.get_collection(collection) {
1832 if !enabled {
1833 let mut shared = false;
1834 manager.try_for_each_entity(usize::MAX, |entity| {
1835 shared = entity.branch_version().is_some_and(|version| {
1836 version.origin != 0 || !version.deletions.is_empty()
1837 });
1838 !shared
1839 })?;
1840 if shared {
1841 return Err(RedDBError::InvalidOperation("cannot discard branch provenance; export the selected branch to an unversioned collection".into()));
1842 }
1843 }
1844 let mut ids = Vec::new();
1845 manager.try_for_each_entity(usize::MAX, |entity| {
1846 if entity.branch_version().is_some() != enabled {
1847 ids.push(entity.id);
1848 }
1849 true
1850 })?;
1851 for id in ids {
1852 let mut entity = manager
1853 .try_get(id, usize::MAX)?
1854 .ok_or_else(|| RedDBError::NotFound(id.to_string()))?;
1855 if enabled {
1856 entity.set_branch_origin(0);
1857 } else {
1858 entity.clear_branch_version();
1859 }
1860 let metadata = store.try_get_metadata(collection, id, usize::MAX)?;
1861 publication
1862 .replacements
1863 .push((collection.into(), entity, metadata));
1864 }
1865 }
1866 branches::stage_metadata(
1867 store,
1868 &mut publication,
1869 vc::SETTINGS,
1870 "id",
1871 collection,
1872 HashMap::from([
1873 ("id".into(), Value::text(collection)),
1874 ("versioned".into(), Value::Boolean(enabled)),
1875 ("ts_ms".into(), Value::TimestampMs(now_ms())),
1876 ]),
1877 )?;
1878 store.publish_rows(publication)?;
1879 self.invalidate_result_cache();
1880 Ok(())
1881 }
1882
1883 pub fn vcs_list_versioned(&self) -> RedDBResult<Vec<String>> {
1884 let store_arc = self.inner.db.store();
1885 let store: &UnifiedStore = &store_arc;
1886 let mut list = versioned_collections(store)?;
1887 list.sort();
1888 Ok(list)
1889 }
1890
1891 pub fn vcs_is_versioned(&self, collection: &str) -> RedDBResult<bool> {
1892 let store_arc = self.inner.db.store();
1893 let store: &UnifiedStore = &store_arc;
1894 is_versioned(store, collection)
1895 }
1896
1897 pub fn vcs_resolve_commitish(&self, spec: &str) -> RedDBResult<CommitHash> {
1898 let store_arc = self.inner.db.store();
1899 let store: &UnifiedStore = &store_arc;
1900 if spec.is_empty() {
1901 return Err(RedDBError::InvalidConfig("empty commitish".to_string()));
1902 }
1903
1904 if spec.len() == 64
1906 && spec.chars().all(|c| c.is_ascii_hexdigit())
1907 && load_commit(store, spec)?.is_some()
1908 {
1909 return Ok(spec.to_string());
1910 }
1911
1912 if spec.starts_with("refs/") {
1914 if let Some(hash) = resolve_ref_chain(store, spec)? {
1915 return Ok(hash);
1916 }
1917 }
1918
1919 let normalized_branch = normalize_branch_name(spec);
1921 if let Some(hash) = resolve_ref_chain(store, &normalized_branch)? {
1922 return Ok(hash);
1923 }
1924 let normalized_tag = normalize_tag_name(spec);
1925 if let Some(hash) = resolve_ref_chain(store, &normalized_tag)? {
1926 return Ok(hash);
1927 }
1928
1929 if let Some(hash) = resolve_short_commit(store, spec)? {
1931 return Ok(hash);
1932 }
1933
1934 Err(RedDBError::NotFound(format!(
1935 "commitish `{spec}` did not resolve to any ref or commit"
1936 )))
1937 }
1938}
1939
1940fn stage_merge_conflicts(
1942 store: &UnifiedStore,
1943 publication: &mut crate::storage::unified::store::RowPublication,
1944 conflicts: &[Conflict],
1945) -> RedDBResult<()> {
1946 for conflict in conflicts {
1947 let fields = HashMap::from([
1948 ("id".into(), Value::text(conflict.id.as_str())),
1949 (
1950 "collection".into(),
1951 Value::text(conflict.collection.as_str()),
1952 ),
1953 ("entity_id".into(), Value::text(conflict.entity_id.as_str())),
1954 (
1955 "merge_state_id".into(),
1956 Value::text(conflict.merge_state_id.as_str()),
1957 ),
1958 (
1959 "conflicting_paths".into(),
1960 Value::Array(
1961 conflict
1962 .conflicting_paths
1963 .iter()
1964 .map(|path| Value::text(path.as_str()))
1965 .collect(),
1966 ),
1967 ),
1968 (
1969 "base_json".into(),
1970 Value::Json(
1971 crate::json::to_vec(&conflict.base)
1972 .map_err(|e| RedDBError::Internal(e.to_string()))?,
1973 ),
1974 ),
1975 (
1976 "ours_json".into(),
1977 Value::Json(
1978 crate::json::to_vec(&conflict.ours)
1979 .map_err(|e| RedDBError::Internal(e.to_string()))?,
1980 ),
1981 ),
1982 (
1983 "theirs_json".into(),
1984 Value::Json(
1985 crate::json::to_vec(&conflict.theirs)
1986 .map_err(|e| RedDBError::Internal(e.to_string()))?,
1987 ),
1988 ),
1989 ]);
1990 branches::stage_metadata(
1991 store,
1992 publication,
1993 vc::CONFLICTS,
1994 "id",
1995 &conflict.id,
1996 fields,
1997 )?;
1998 }
1999 Ok(())
2000}
2001
2002fn ensure_vcs_entity_access(
2003 runtime: &RedDBRuntime,
2004 collection: &str,
2005 entity: &UnifiedEntity,
2006 action: reddb_rql::ast::PolicyAction,
2007) -> RedDBResult<()> {
2008 use reddb_rql::ast::PolicyAction;
2009 runtime.check_collection_privilege(action, collection)?;
2010 if !crate::runtime::rls_injection::entity_passes_rls_action(runtime, collection, action, entity)
2011 {
2012 return Err(RedDBError::Query(format!(
2013 "VCS access to collection '{collection}' denied by row-level security"
2014 )));
2015 }
2016 if let Some(row) = entity.data.as_row() {
2017 if let Some(key) = row_text(row, "key") {
2018 match runtime
2019 .db()
2020 .collection_contract(collection)
2021 .map(|c| c.declared_model)
2022 {
2023 Some(crate::catalog::CollectionModel::Vault) => {
2024 let capability = if action == PolicyAction::Select {
2025 "vault:read_metadata"
2026 } else {
2027 "vault:write"
2028 };
2029 runtime
2030 .check_vault_capability_in_tenant(
2031 capability,
2032 collection,
2033 &key,
2034 row_text(row, "_vault_tenant").as_deref(),
2035 )
2036 .map_err(RedDBError::Query)?;
2037 }
2038 Some(crate::catalog::CollectionModel::Config) => {
2039 let capability = if action == PolicyAction::Select {
2040 "config:read"
2041 } else {
2042 "config:write"
2043 };
2044 runtime
2045 .check_config_capability(capability, collection, &key)
2046 .map_err(RedDBError::Query)?;
2047 }
2048 _ => {}
2049 }
2050 }
2051 }
2052 Ok(())
2053}
2054
2055fn vcs_entity_body(runtime: &RedDBRuntime, collection: &str, entity: &UnifiedEntity) -> JsonValue {
2056 if runtime
2057 .db()
2058 .collection_contract(collection)
2059 .is_some_and(|contract| contract.declared_model == crate::catalog::CollectionModel::Vault)
2060 {
2061 let mut fields = Map::new();
2064 fields.insert(
2065 "id".into(),
2066 JsonValue::String(entity.logical_id().raw().to_string()),
2067 );
2068 if let Some(row) = entity.data.as_row() {
2069 for name in ["key", "version", "tombstone"] {
2070 if let Some(value) = row.get_field(name) {
2071 fields.insert(name.into(), value.to_json());
2072 }
2073 }
2074 }
2075 return JsonValue::Object(fields);
2076 }
2077 let mut body = crate::entity_render::compact_entity_json(entity);
2078 if let JsonValue::Object(fields) = &mut body {
2079 fields.insert(
2080 "id".to_string(),
2081 JsonValue::Number(entity.logical_id().raw() as f64),
2082 );
2083 match &entity.kind {
2084 EntityKind::GraphNode(node) => {
2085 fields.insert("label".to_string(), JsonValue::String(node.label.clone()));
2086 fields.insert(
2087 "node_type".to_string(),
2088 JsonValue::String(node.node_type.clone()),
2089 );
2090 }
2091 EntityKind::GraphEdge(edge) => {
2092 fields.insert("label".to_string(), JsonValue::String(edge.label.clone()));
2093 fields.insert(
2094 "from_node".to_string(),
2095 JsonValue::String(edge.from_node.clone()),
2096 );
2097 fields.insert(
2098 "to_node".to_string(),
2099 JsonValue::String(edge.to_node.clone()),
2100 );
2101 }
2102 _ => {}
2103 }
2104 if let EntityData::Row(row) = &entity.data {
2105 if row.named.is_none() {
2106 fields.insert(
2107 "row".to_string(),
2108 JsonValue::Object(
2109 row.iter_fields()
2110 .map(|(key, value)| (key.to_string(), value.to_json()))
2111 .collect(),
2112 ),
2113 );
2114 }
2115 }
2116 }
2117 body
2118}
2119
2120fn coalesce_modifications(
2124 entries: Vec<DiffEntry>,
2125 added: &mut usize,
2126 removed: &mut usize,
2127 modified: &mut usize,
2128) -> Vec<DiffEntry> {
2129 let mut by_key: HashMap<(String, String), Vec<DiffEntry>> = HashMap::new();
2130 for e in entries {
2131 by_key
2132 .entry((e.collection.clone(), e.entity_id.clone()))
2133 .or_default()
2134 .push(e);
2135 }
2136 let mut out: Vec<DiffEntry> = Vec::new();
2137 for ((coll, eid), group) in by_key {
2138 if group.len() >= 2 {
2139 let mut before = JsonValue::Null;
2140 let mut after = JsonValue::Null;
2141 for item in group {
2142 match item.change {
2143 DiffChange::Removed { before: b } => before = b,
2144 DiffChange::Added { after: a } => after = a,
2145 DiffChange::Modified { .. } => {}
2146 }
2147 }
2148 *added = added.saturating_sub(1);
2149 *removed = removed.saturating_sub(1);
2150 *modified += 1;
2151 out.push(DiffEntry {
2152 collection: coll,
2153 entity_id: eid,
2154 change: DiffChange::Modified { before, after },
2155 });
2156 } else {
2157 out.extend(group);
2158 }
2159 }
2160 out.sort_by(|a, b| {
2161 a.collection
2162 .cmp(&b.collection)
2163 .then(a.entity_id.cmp(&b.entity_id))
2164 });
2165 out
2166}
2167
2168#[allow(dead_code)]
2170fn _touch_types(_: BTreeSet<()>) {}