1use std::collections::{BTreeSet, HashMap, HashSet};
14use std::sync::Arc;
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use sha2::{Digest, Sha256};
18
19use crate::application::vcs::{
20 AsOfSpec, Author, CheckoutInput, CheckoutTarget, Commit, CommitHash, Conflict,
21 CreateBranchInput, CreateCommitInput, CreateTagInput, Diff, DiffChange, DiffEntry, DiffInput,
22 LogInput, LogRange, MergeInput, MergeOutcome, MergeStrategy, Ref, RefKind, RefName, ResetInput,
23 ResetMode, Status, StatusInput,
24};
25use crate::application::vcs_collections as vc;
26use crate::json::{Map, Value as JsonValue};
27use crate::runtime::RedDBRuntime;
28use crate::storage::transaction::snapshot::{Xid, XID_NONE};
29use crate::storage::unified::entity::{EntityData, EntityId, EntityKind, RowData, UnifiedEntity};
30use crate::storage::unified::UnifiedStore;
31use crate::{RedDBError, RedDBResult};
32use reddb_types::Value;
33
34fn unimplemented(method: &str) -> RedDBError {
39 RedDBError::Internal(format!("vcs: {method} not yet implemented"))
40}
41
42fn now_ms() -> i64 {
43 SystemTime::now()
44 .duration_since(UNIX_EPOCH)
45 .map(|d| d.as_millis() as i64)
46 .unwrap_or(0)
47}
48
49fn row_text(row: &RowData, field: &str) -> Option<String> {
50 match row.get_field(field)?.clone() {
51 Value::Text(v) => Some(v.to_string()),
52 _ => None,
53 }
54}
55
56fn row_u64(row: &RowData, field: &str) -> Option<u64> {
57 match row.get_field(field)?.clone() {
58 Value::UnsignedInteger(v) => Some(v),
59 Value::Integer(v) if v >= 0 => Some(v as u64),
60 _ => None,
61 }
62}
63
64fn row_i64(row: &RowData, field: &str) -> Option<i64> {
65 match row.get_field(field)?.clone() {
66 Value::Integer(v) => Some(v),
67 Value::UnsignedInteger(v) => Some(v as i64),
68 Value::TimestampMs(v) | Value::Timestamp(v) => Some(v),
69 _ => None,
70 }
71}
72
73fn row_json(row: &RowData, field: &str) -> JsonValue {
74 match row.get_field(field) {
75 Some(Value::Json(bytes)) => {
76 crate::json::from_slice::<JsonValue>(bytes).unwrap_or(JsonValue::Null)
77 }
78 Some(Value::Text(s)) => crate::json::from_str::<JsonValue>(s)
79 .unwrap_or_else(|_| JsonValue::String(s.to_string())),
80 _ => JsonValue::Null,
81 }
82}
83
84fn row_string_list(row: &RowData, field: &str) -> Vec<String> {
85 match row.get_field(field) {
86 Some(Value::Array(items)) => items
87 .iter()
88 .filter_map(|v| match v {
89 Value::Text(s) => Some(s.to_string()),
90 _ => None,
91 })
92 .collect(),
93 _ => Vec::new(),
94 }
95}
96
97fn insert_meta_row(
98 store: &UnifiedStore,
99 collection: &str,
100 fields: HashMap<String, Value>,
101) -> RedDBResult<EntityId> {
102 let _ = store.get_or_create_collection(collection);
103 store
104 .insert_auto(
105 collection,
106 UnifiedEntity::new(
107 EntityId::new(0),
108 EntityKind::TableRow {
109 table: Arc::from(collection),
110 row_id: 0,
111 },
112 EntityData::Row(RowData {
113 columns: Vec::new(),
114 named: Some(fields),
115 schema: None,
116 }),
117 ),
118 )
119 .map_err(|e| RedDBError::Internal(e.to_string()))
120}
121
122fn compute_commit_hash(
123 root_xid: Xid,
124 parents: &[CommitHash],
125 author: &Author,
126 message: &str,
127 timestamp_ms: i64,
128) -> CommitHash {
129 let mut h = Sha256::new();
130 h.update(b"reddb-commit-v1\n");
131 h.update(root_xid.to_be_bytes());
132 let mut sorted = parents.to_vec();
133 sorted.sort();
134 for p in &sorted {
135 h.update(b"\np=");
136 h.update(p.as_bytes());
137 }
138 h.update(b"\na=");
139 h.update(author.name.as_bytes());
140 h.update(b"\n");
141 h.update(author.email.as_bytes());
142 h.update(b"\nm=");
143 h.update(message.as_bytes());
144 h.update(b"\nt=");
145 h.update(timestamp_ms.to_be_bytes());
146 let digest = h.finalize();
147 hex::encode(digest)
148}
149
150fn normalize_branch_name(raw: &str) -> String {
151 if raw.starts_with(vc::BRANCH_REF_PREFIX) {
152 raw.to_string()
153 } else {
154 format!("{}{}", vc::BRANCH_REF_PREFIX, raw)
155 }
156}
157
158fn normalize_tag_name(raw: &str) -> String {
159 if raw.starts_with(vc::TAG_REF_PREFIX) {
160 raw.to_string()
161 } else {
162 format!("{}{}", vc::TAG_REF_PREFIX, raw)
163 }
164}
165
166fn head_ref_id(connection_id: u64) -> String {
167 format!("{}{}", vc::HEAD_ID_PREFIX, connection_id)
168}
169
170fn load_commit_entity(store: &UnifiedStore, hash: &str) -> Option<UnifiedEntity> {
175 let manager = store.get_collection(vc::COMMITS)?;
176 manager
177 .query_all(|entity| {
178 entity
179 .data
180 .as_row()
181 .is_some_and(|row| row_text(row, "id").as_deref() == Some(hash))
182 })
183 .into_iter()
184 .next()
185}
186
187fn commit_from_row(row: &RowData) -> Option<Commit> {
188 Some(Commit {
189 hash: row_text(row, "id")?,
190 root_xid: row_u64(row, "root_xid")?,
191 parents: row_string_list(row, "parents"),
192 height: row_u64(row, "height").unwrap_or(0),
193 author: Author {
194 name: row_text(row, "author_name").unwrap_or_default(),
195 email: row_text(row, "author_email").unwrap_or_default(),
196 },
197 committer: Author {
198 name: row_text(row, "committer_name").unwrap_or_default(),
199 email: row_text(row, "committer_email").unwrap_or_default(),
200 },
201 message: row_text(row, "message").unwrap_or_default(),
202 timestamp_ms: row_i64(row, "timestamp_ms").unwrap_or(0),
203 signature: row_text(row, "signature"),
204 })
205}
206
207fn load_commit(store: &UnifiedStore, hash: &str) -> Option<Commit> {
208 let entity = load_commit_entity(store, hash)?;
209 let row = entity.data.as_row()?;
210 commit_from_row(row)
211}
212
213fn save_commit(store: &UnifiedStore, commit: &Commit) -> RedDBResult<()> {
214 let mut fields: HashMap<String, Value> = HashMap::new();
215 fields.insert("id".to_string(), Value::text(commit.hash.as_str()));
216 fields.insert(
217 "root_xid".to_string(),
218 Value::UnsignedInteger(commit.root_xid),
219 );
220 fields.insert(
221 "parents".to_string(),
222 Value::Array(
223 commit
224 .parents
225 .iter()
226 .map(|p| Value::text(p.as_str()))
227 .collect(),
228 ),
229 );
230 fields.insert("height".to_string(), Value::UnsignedInteger(commit.height));
231 fields.insert(
232 "author_name".to_string(),
233 Value::text(commit.author.name.as_str()),
234 );
235 fields.insert(
236 "author_email".to_string(),
237 Value::text(commit.author.email.as_str()),
238 );
239 fields.insert(
240 "committer_name".to_string(),
241 Value::text(commit.committer.name.as_str()),
242 );
243 fields.insert(
244 "committer_email".to_string(),
245 Value::text(commit.committer.email.as_str()),
246 );
247 fields.insert("message".to_string(), Value::text(commit.message.as_str()));
248 fields.insert(
249 "timestamp_ms".to_string(),
250 Value::TimestampMs(commit.timestamp_ms),
251 );
252 if let Some(sig) = &commit.signature {
253 fields.insert("signature".to_string(), Value::text(sig.as_str()));
254 }
255 insert_meta_row(store, vc::COMMITS, fields)?;
256 Ok(())
257}
258
259fn ref_kind_from_str(s: &str) -> RefKind {
264 match s {
265 "tag" => RefKind::Tag,
266 "head" => RefKind::Head,
267 _ => RefKind::Branch,
268 }
269}
270
271fn ref_from_row(row: &RowData) -> Option<Ref> {
272 Some(Ref {
273 name: row_text(row, "id")?,
274 kind: ref_kind_from_str(&row_text(row, "type").unwrap_or_default()),
275 target: row_text(row, "target").unwrap_or_default(),
276 protected: row
277 .get_field("protected")
278 .and_then(|v| match v {
279 Value::Boolean(b) => Some(*b),
280 _ => None,
281 })
282 .unwrap_or(false),
283 })
284}
285
286fn load_ref_entity(store: &UnifiedStore, name: &str) -> Option<(EntityId, UnifiedEntity)> {
287 let manager = store.get_collection(vc::REFS)?;
288 manager
289 .query_all(|entity| {
290 entity
291 .data
292 .as_row()
293 .is_some_and(|row| row_text(row, "id").as_deref() == Some(name))
294 })
295 .into_iter()
296 .next()
297 .map(|entity| (entity.id, entity))
298}
299
300fn load_ref(store: &UnifiedStore, name: &str) -> Option<Ref> {
301 let (_, entity) = load_ref_entity(store, name)?;
302 ref_from_row(entity.data.as_row()?)
303}
304
305fn save_ref(store: &UnifiedStore, r: &Ref) -> RedDBResult<()> {
306 if let Some((id, _)) = load_ref_entity(store, &r.name) {
309 let _ = store.delete(vc::REFS, id);
310 }
311 let mut fields: HashMap<String, Value> = HashMap::new();
312 fields.insert("id".to_string(), Value::text(r.name.as_str()));
313 let kind_str = match r.kind {
314 RefKind::Branch => "branch",
315 RefKind::Tag => "tag",
316 RefKind::Head => "head",
317 };
318 fields.insert("type".to_string(), Value::text(kind_str));
319 fields.insert("target".to_string(), Value::text(r.target.as_str()));
320 fields.insert("protected".to_string(), Value::Boolean(r.protected));
321 insert_meta_row(store, vc::REFS, fields)?;
322 Ok(())
323}
324
325fn delete_ref(store: &UnifiedStore, name: &str) -> RedDBResult<bool> {
326 let Some((id, _)) = load_ref_entity(store, name) else {
327 return Ok(false);
328 };
329 store
330 .delete(vc::REFS, id)
331 .map_err(|e| RedDBError::Internal(e.to_string()))?;
332 Ok(true)
333}
334
335fn is_versioned(store: &UnifiedStore, name: &str) -> RedDBResult<bool> {
346 if name.starts_with("red_") {
347 return Ok(false);
348 }
349 let Some(manager) = store.get_collection(vc::SETTINGS) else {
350 return Ok(false);
351 };
352 let _publication = manager
353 .has_backing()
354 .then(|| store.vault_publication_lock.lock());
355 let mut versioned = false;
356 super::function_budget::try_scan(
357 |visit| manager.try_for_each_entity(usize::MAX, visit),
358 |entity| {
359 versioned = entity.data.as_row().is_some_and(|row| {
360 row.get_field("id")
361 .is_some_and(|id| matches!(id, Value::Text(id) if id.as_ref() == name))
362 && matches!(row.get_field("versioned"), Some(Value::Boolean(true)))
363 });
364 !versioned
365 },
366 )?;
367 Ok(versioned)
368}
369
370fn versioned_collections(store: &UnifiedStore) -> RedDBResult<Vec<String>> {
374 let Some(manager) = store.get_collection(vc::SETTINGS) else {
375 return Ok(Vec::new());
376 };
377 let _publication = manager
378 .has_backing()
379 .then(|| store.vault_publication_lock.lock());
380 let mut collections = Vec::new();
381 super::function_budget::try_scan(
382 |visit| manager.try_for_each_entity(usize::MAX, visit),
383 |entity| {
384 if let Some(row) = entity.data.as_row() {
385 if matches!(row.get_field("versioned"), Some(Value::Boolean(true))) {
386 if let Some(name) = row_text(row, "id") {
387 collections.push(name);
388 }
389 }
390 }
391 true
392 },
393 )?;
394 Ok(collections)
395}
396
397fn set_versioned_flag(store: &UnifiedStore, name: &str, enabled: bool) -> RedDBResult<()> {
403 if name.starts_with("red_") {
404 return Err(RedDBError::InvalidConfig(format!(
405 "cannot version internal collection `{name}`"
406 )));
407 }
408 let _publication = store.vault_publication_lock.lock();
409 let mut previous = Vec::new();
410 if let Some(manager) = store.get_collection(vc::SETTINGS) {
411 super::function_budget::try_scan(
412 |visit| manager.try_for_each_entity(usize::MAX, visit),
413 |entity| {
414 if entity.data.as_row().is_some_and(|row| {
415 matches!(row.get_field("id"), Some(Value::Text(stored)) if stored.as_ref() == name)
416 }) {
417 previous.push(entity.id);
418 }
419 true
420 },
421 )?;
422 }
423 if enabled {
424 let mut fields: HashMap<String, Value> = HashMap::new();
425 fields.insert("id".to_string(), Value::text(name));
426 fields.insert("versioned".to_string(), Value::Boolean(true));
427 fields.insert("ts_ms".to_string(), Value::TimestampMs(now_ms()));
428 insert_meta_row(store, vc::SETTINGS, fields)?;
431 }
432 for id in previous {
433 store.delete(vc::SETTINGS, id)?;
434 }
435 Ok(())
436}
437
438fn list_refs_by_prefix(store: &UnifiedStore, prefix: Option<&str>) -> Vec<Ref> {
439 let Some(manager) = store.get_collection(vc::REFS) else {
440 return Vec::new();
441 };
442 let prefix_owned = prefix.map(|s| s.to_string());
443 manager
444 .query_all(|entity| {
445 entity.data.as_row().is_some_and(|row| {
446 let id = row_text(row, "id").unwrap_or_default();
447 match &prefix_owned {
448 Some(p) => id.starts_with(p),
449 None => true,
450 }
451 })
452 })
453 .into_iter()
454 .filter_map(|entity| ref_from_row(entity.data.as_row()?))
455 .collect()
456}
457
458fn ancestor_set(store: &UnifiedStore, start: &str, max_steps: usize) -> HashSet<CommitHash> {
463 let mut visited: HashSet<CommitHash> = HashSet::new();
464 let mut stack: Vec<CommitHash> = vec![start.to_string()];
465 let mut steps = 0usize;
466 while let Some(hash) = stack.pop() {
467 if !visited.insert(hash.clone()) {
468 continue;
469 }
470 if let Some(c) = load_commit(store, &hash) {
471 for p in c.parents {
472 if !visited.contains(&p) {
473 stack.push(p);
474 }
475 }
476 }
477 steps += 1;
478 if steps >= max_steps {
479 break;
480 }
481 }
482 visited
483}
484
485fn topo_walk(store: &UnifiedStore, start: &str, range: &LogRange) -> Vec<Commit> {
486 let limit = range.limit.unwrap_or(usize::MAX);
487 let skip = range.skip.unwrap_or(0);
488 let exclude = range
489 .from
490 .as_ref()
491 .map(|h| ancestor_set(store, h, 100_000))
492 .unwrap_or_default();
493
494 let mut visited: HashSet<CommitHash> = HashSet::new();
495 let mut stack: Vec<CommitHash> = vec![start.to_string()];
496 let mut out: Vec<Commit> = Vec::new();
497 let mut skipped = 0usize;
498 while let Some(hash) = stack.pop() {
499 if !visited.insert(hash.clone()) {
500 continue;
501 }
502 if exclude.contains(&hash) {
503 continue;
504 }
505 let Some(commit) = load_commit(store, &hash) else {
506 continue;
507 };
508 if range.no_merges && commit.parents.len() > 1 {
509 for p in &commit.parents {
510 if !visited.contains(p) {
511 stack.push(p.clone());
512 }
513 }
514 continue;
515 }
516 for p in &commit.parents {
517 if !visited.contains(p) {
518 stack.push(p.clone());
519 }
520 }
521 if skipped < skip {
522 skipped += 1;
523 continue;
524 }
525 if out.len() >= limit {
526 break;
527 }
528 out.push(commit);
529 }
530 out.sort_by(|a, b| {
532 b.height
533 .cmp(&a.height)
534 .then(b.timestamp_ms.cmp(&a.timestamp_ms))
535 });
536 out
537}
538
539fn resolve_ref_chain(store: &UnifiedStore, name: &str) -> Option<CommitHash> {
544 let mut current = name.to_string();
546 for _ in 0..4 {
547 let r = load_ref(store, ¤t)?;
548 match r.kind {
549 RefKind::Branch | RefKind::Tag => return Some(r.target),
550 RefKind::Head => {
551 current = r.target;
552 continue;
553 }
554 }
555 }
556 None
557}
558
559fn resolve_short_commit(store: &UnifiedStore, prefix: &str) -> Option<CommitHash> {
560 if prefix.len() < 4 {
561 return None;
562 }
563 let manager = store.get_collection(vc::COMMITS)?;
564 let matches: Vec<String> = manager
565 .query_all(|entity| {
566 entity.data.as_row().is_some_and(|row| {
567 row_text(row, "id")
568 .map(|id| id.starts_with(prefix))
569 .unwrap_or(false)
570 })
571 })
572 .into_iter()
573 .filter_map(|entity| row_text(entity.data.as_row()?, "id"))
574 .collect();
575 if matches.len() == 1 {
576 matches.into_iter().next()
577 } else {
578 None
579 }
580}
581
582fn upsert_workset(
587 store: &UnifiedStore,
588 connection_id: u64,
589 branch: &str,
590 base_commit: Option<&str>,
591 working_xid: Xid,
592) -> RedDBResult<()> {
593 let conn_id = connection_id.to_string();
594 if let Some(manager) = store.get_collection(vc::WORKSETS) {
596 let rows = manager.query_all(|entity| {
597 entity
598 .data
599 .as_row()
600 .is_some_and(|row| row_text(row, "id").as_deref() == Some(&conn_id))
601 });
602 for row in rows {
603 let _ = store.delete(vc::WORKSETS, row.id);
604 }
605 }
606 let mut fields: HashMap<String, Value> = HashMap::new();
607 fields.insert("id".to_string(), Value::text(conn_id.as_str()));
608 fields.insert("branch".to_string(), Value::text(branch));
609 if let Some(base) = base_commit {
610 fields.insert("base_commit".to_string(), Value::text(base));
611 }
612 fields.insert(
613 "working_xid".to_string(),
614 Value::UnsignedInteger(working_xid),
615 );
616 insert_meta_row(store, vc::WORKSETS, fields)?;
617 Ok(())
618}
619
620fn load_workset(store: &UnifiedStore, connection_id: u64) -> Option<(RefName, Option<CommitHash>)> {
621 let manager = store.get_collection(vc::WORKSETS)?;
622 let conn_id = connection_id.to_string();
623 manager
624 .query_all(|entity| {
625 entity
626 .data
627 .as_row()
628 .is_some_and(|row| row_text(row, "id").as_deref() == Some(&conn_id))
629 })
630 .into_iter()
631 .find_map(|entity| {
632 let row = entity.data.as_row()?;
633 Some((
634 row_text(row, "branch").unwrap_or_else(|| vc::DEFAULT_BRANCH_REF.to_string()),
635 row_text(row, "base_commit"),
636 ))
637 })
638}
639
640impl RedDBRuntime {
645 pub fn vcs_commit(&self, input: CreateCommitInput) -> RedDBResult<Commit> {
646 let store_arc = self.inner.db.store();
647 let store: &UnifiedStore = &store_arc;
648
649 let workset = load_workset(store, input.connection_id);
652 let branch_ref = workset
653 .as_ref()
654 .map(|(b, _)| b.clone())
655 .or_else(|| resolve_ref_chain(store, &head_ref_id(input.connection_id)).and(None))
656 .unwrap_or_else(|| vc::DEFAULT_BRANCH_REF.to_string());
657
658 let parent_hash = workset
659 .as_ref()
660 .and_then(|(_, base)| base.clone())
661 .or_else(|| load_ref(store, &branch_ref).map(|r| r.target));
662
663 let parents: Vec<CommitHash> = match parent_hash.clone() {
664 Some(h) if !h.is_empty() => vec![h],
665 _ => Vec::new(),
666 };
667
668 let parent_height = parents
669 .iter()
670 .filter_map(|p| load_commit(store, p).map(|c| c.height))
671 .max()
672 .unwrap_or(0);
673 let height = if parents.is_empty() {
674 0
675 } else {
676 parent_height + 1
677 };
678
679 let snapshot_manager = self.inner.transaction_state.snapshot_manager();
685 let root_xid = snapshot_manager.begin();
686 snapshot_manager.commit(root_xid);
687 let timestamp_ms = now_ms();
688 let committer = input.committer.unwrap_or_else(|| input.author.clone());
689
690 let hash = compute_commit_hash(
691 root_xid,
692 &parents,
693 &input.author,
694 &input.message,
695 timestamp_ms,
696 );
697
698 if load_commit(store, &hash).is_some() {
699 return Err(RedDBError::InvalidConfig(format!(
700 "commit {hash} already exists (duplicate timestamp+content)"
701 )));
702 }
703
704 let commit = Commit {
705 hash: hash.clone(),
706 root_xid,
707 parents,
708 height,
709 author: input.author,
710 committer,
711 message: input.message,
712 timestamp_ms,
713 signature: None,
714 };
715
716 save_commit(store, &commit)?;
717
718 if root_xid != XID_NONE {
721 snapshot_manager.pin(root_xid);
722 }
723
724 let branch_name = if branch_ref.is_empty() {
726 vc::DEFAULT_BRANCH_REF.to_string()
727 } else {
728 branch_ref
729 };
730 save_ref(
731 store,
732 &Ref {
733 name: branch_name.clone(),
734 kind: RefKind::Branch,
735 target: hash.clone(),
736 protected: false,
737 },
738 )?;
739
740 upsert_workset(
741 store,
742 input.connection_id,
743 &branch_name,
744 Some(&hash),
745 root_xid,
746 )?;
747
748 Ok(commit)
749 }
750
751 pub fn vcs_branch_create(&self, input: CreateBranchInput) -> RedDBResult<Ref> {
752 let store_arc = self.inner.db.store();
753 let store: &UnifiedStore = &store_arc;
754 let full_name = normalize_branch_name(&input.name);
755 if load_ref(store, &full_name).is_some() {
756 return Err(RedDBError::InvalidConfig(format!(
757 "branch `{full_name}` already exists"
758 )));
759 }
760 let target_hash = match &input.from {
761 Some(spec) => RedDBRuntime::vcs_resolve_commitish(self, spec)?,
762 None => {
763 let workset = load_workset(store, input.connection_id);
765
766 workset
767 .and_then(|(_, base)| base)
768 .or_else(|| load_ref(store, vc::DEFAULT_BRANCH_REF).map(|r| r.target))
769 .unwrap_or_default()
770 }
771 };
772 let r = Ref {
773 name: full_name,
774 kind: RefKind::Branch,
775 target: target_hash,
776 protected: false,
777 };
778 save_ref(store, &r)?;
779 Ok(r)
780 }
781
782 pub fn vcs_branch_delete(&self, name: &str) -> RedDBResult<()> {
783 let store_arc = self.inner.db.store();
784 let store: &UnifiedStore = &store_arc;
785 let full = normalize_branch_name(name);
786 let existing = load_ref(store, &full)
787 .ok_or_else(|| RedDBError::NotFound(format!("branch `{full}` does not exist")))?;
788 if existing.protected {
789 return Err(RedDBError::ReadOnly(format!(
790 "branch `{full}` is protected"
791 )));
792 }
793 delete_ref(store, &full)?;
794 Ok(())
795 }
796
797 pub fn vcs_tag_create(&self, input: CreateTagInput) -> RedDBResult<Ref> {
798 let store_arc = self.inner.db.store();
799 let store: &UnifiedStore = &store_arc;
800 let full_name = normalize_tag_name(&input.name);
801 if load_ref(store, &full_name).is_some() {
802 return Err(RedDBError::InvalidConfig(format!(
803 "tag `{full_name}` already exists"
804 )));
805 }
806 let target_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.target)?;
807 let r = Ref {
808 name: full_name,
809 kind: RefKind::Tag,
810 target: target_hash,
811 protected: false,
812 };
813 save_ref(store, &r)?;
814 Ok(r)
815 }
816
817 pub fn vcs_tag_delete(&self, name: &str) -> RedDBResult<()> {
818 let store_arc = self.inner.db.store();
819 let store: &UnifiedStore = &store_arc;
820 let full = normalize_tag_name(name);
821 let existing = load_ref(store, &full)
822 .ok_or_else(|| RedDBError::NotFound(format!("tag `{full}` does not exist")))?;
823 if existing.protected {
824 return Err(RedDBError::ReadOnly(format!("tag `{full}` is protected")));
825 }
826 delete_ref(store, &full)?;
827 Ok(())
828 }
829
830 pub fn vcs_list_refs(&self, prefix: Option<&str>) -> RedDBResult<Vec<Ref>> {
831 let store_arc = self.inner.db.store();
832 let store: &UnifiedStore = &store_arc;
833 Ok(list_refs_by_prefix(store, prefix))
834 }
835
836 pub fn vcs_checkout(&self, input: CheckoutInput) -> RedDBResult<Ref> {
837 let store_arc = self.inner.db.store();
838 let store: &UnifiedStore = &store_arc;
839 let (branch_ref, target_hash) = match &input.target {
840 CheckoutTarget::Branch(name) => {
841 let full = normalize_branch_name(name);
842 let r = load_ref(store, &full).ok_or_else(|| {
843 RedDBError::NotFound(format!("branch `{full}` does not exist"))
844 })?;
845 (full, r.target)
846 }
847 CheckoutTarget::Tag(name) => {
848 let full = normalize_tag_name(name);
849 let r = load_ref(store, &full)
850 .ok_or_else(|| RedDBError::NotFound(format!("tag `{full}` does not exist")))?;
851 (full, r.target)
852 }
853 CheckoutTarget::Commit(hash) => {
854 let resolved = RedDBRuntime::vcs_resolve_commitish(self, hash)?;
855 (String::new(), resolved)
858 }
859 };
860
861 upsert_workset(
862 store,
863 input.connection_id,
864 &branch_ref,
865 Some(&target_hash),
866 self.inner
867 .transaction_state
868 .snapshot_manager()
869 .peek_next_xid(),
870 )?;
871
872 save_ref(
874 store,
875 &Ref {
876 name: head_ref_id(input.connection_id),
877 kind: RefKind::Head,
878 target: if branch_ref.is_empty() {
879 target_hash.clone()
880 } else {
881 branch_ref.clone()
882 },
883 protected: false,
884 },
885 )?;
886
887 Ok(Ref {
888 name: if branch_ref.is_empty() {
889 format!("detached:{target_hash}")
890 } else {
891 branch_ref
892 },
893 kind: if target_hash.is_empty() {
894 RefKind::Head
895 } else {
896 RefKind::Branch
897 },
898 target: target_hash,
899 protected: false,
900 })
901 }
902
903 pub fn vcs_merge(&self, input: MergeInput) -> RedDBResult<MergeOutcome> {
904 let store_arc = self.inner.db.store();
905 let store: &UnifiedStore = &store_arc;
906
907 let from_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.from)?;
909 let from_commit = load_commit(store, &from_hash).ok_or_else(|| {
910 RedDBError::NotFound(format!("source commit `{from_hash}` not found"))
911 })?;
912
913 let workset = load_workset(store, input.connection_id);
915 let (head_branch, head_hash) = match workset {
916 Some((branch, Some(head))) => (branch, head),
917 Some((branch, None)) => {
918 let head = load_ref(store, &branch)
919 .map(|r| r.target)
920 .unwrap_or_default();
921 (branch, head)
922 }
923 None => {
924 let head = load_ref(store, vc::DEFAULT_BRANCH_REF)
925 .map(|r| r.target)
926 .unwrap_or_default();
927 (vc::DEFAULT_BRANCH_REF.to_string(), head)
928 }
929 };
930 if head_hash.is_empty() {
931 return Err(RedDBError::InvalidConfig(
932 "cannot merge: HEAD has no commits".to_string(),
933 ));
934 }
935
936 let from_ancestors = ancestor_set(store, &from_hash, 100_000);
938 let can_fast_forward = from_ancestors.contains(&head_hash);
939
940 match input.opts.strategy {
941 MergeStrategy::FastForwardOnly if !can_fast_forward => {
942 return Err(RedDBError::InvalidConfig(
943 "not a fast-forward — use --strategy auto or no-ff".to_string(),
944 ));
945 }
946 _ => {}
947 }
948
949 if can_fast_forward && input.opts.strategy != MergeStrategy::NoFastForward {
950 if head_branch.is_empty() {
951 return Err(RedDBError::InvalidConfig(
952 "cannot fast-forward a detached HEAD".to_string(),
953 ));
954 }
955 save_ref(
956 store,
957 &Ref {
958 name: head_branch.clone(),
959 kind: RefKind::Branch,
960 target: from_hash.clone(),
961 protected: false,
962 },
963 )?;
964 upsert_workset(
965 store,
966 input.connection_id,
967 &head_branch,
968 Some(&from_hash),
969 from_commit.root_xid,
970 )?;
971 return Ok(MergeOutcome {
972 merge_commit: Some(from_commit),
973 fast_forward: true,
974 conflicts: Vec::new(),
975 merge_state_id: None,
976 });
977 }
978
979 let lca = RedDBRuntime::vcs_lca(self, &head_hash, &from_hash)?;
987
988 let message = input
989 .opts
990 .message
991 .unwrap_or_else(|| format!("Merge {} into {}", input.from, head_branch));
992 let author = input.author.clone();
993 let timestamp_ms = now_ms();
994 let parents = vec![head_hash.clone(), from_hash.clone()];
995 let parent_height = parents
996 .iter()
997 .filter_map(|p| load_commit(store, p).map(|c| c.height))
998 .max()
999 .unwrap_or(0);
1000 let height = parent_height + 1;
1001 let snapshot_manager = self.inner.transaction_state.snapshot_manager();
1002 let root_xid = snapshot_manager.begin();
1003 snapshot_manager.commit(root_xid);
1004
1005 let hash = compute_commit_hash(root_xid, &parents, &author, &message, timestamp_ms);
1006
1007 let merge_commit = Commit {
1008 hash: hash.clone(),
1009 root_xid,
1010 parents,
1011 height,
1012 author: author.clone(),
1013 committer: author,
1014 message,
1015 timestamp_ms,
1016 signature: None,
1017 };
1018
1019 save_commit(store, &merge_commit)?;
1024 if root_xid != XID_NONE {
1025 snapshot_manager.pin(root_xid);
1026 }
1027 if !head_branch.is_empty() {
1028 save_ref(
1029 store,
1030 &Ref {
1031 name: head_branch.clone(),
1032 kind: RefKind::Branch,
1033 target: hash.clone(),
1034 protected: false,
1035 },
1036 )?;
1037 }
1038 upsert_workset(
1039 store,
1040 input.connection_id,
1041 &head_branch,
1042 Some(&hash),
1043 root_xid,
1044 )?;
1045
1046 let merge_state_id = format!("ms:{}", &hash[..16]);
1047
1048 let conflicts = if let Some(lca_hash) = &lca {
1053 materialize_merge_conflicts(
1054 self,
1055 store,
1056 lca_hash,
1057 &head_hash,
1058 &from_hash,
1059 &merge_state_id,
1060 )?
1061 } else {
1062 Vec::new()
1063 };
1064
1065 let mut ms_fields: HashMap<String, Value> = HashMap::new();
1066 ms_fields.insert("id".to_string(), Value::text(merge_state_id.as_str()));
1067 ms_fields.insert("kind".to_string(), Value::text("merge"));
1068 ms_fields.insert("branch".to_string(), Value::text(head_branch.as_str()));
1069 if let Some(base_hash) = &lca {
1070 ms_fields.insert("base".to_string(), Value::text(base_hash.as_str()));
1071 }
1072 ms_fields.insert("ours".to_string(), Value::text(head_hash.as_str()));
1073 ms_fields.insert("theirs".to_string(), Value::text(from_hash.as_str()));
1074 ms_fields.insert(
1075 "conflicts_count".to_string(),
1076 Value::UnsignedInteger(conflicts.len() as u64),
1077 );
1078 insert_meta_row(store, vc::MERGE_STATE, ms_fields)?;
1079
1080 Ok(MergeOutcome {
1081 merge_commit: Some(merge_commit),
1082 fast_forward: false,
1083 conflicts,
1084 merge_state_id: Some(merge_state_id),
1085 })
1086 }
1087
1088 pub fn vcs_cherry_pick(
1089 &self,
1090 connection_id: u64,
1091 commit: &str,
1092 author: Author,
1093 ) -> RedDBResult<MergeOutcome> {
1094 let store_arc = self.inner.db.store();
1095 let store: &UnifiedStore = &store_arc;
1096
1097 let src_hash = RedDBRuntime::vcs_resolve_commitish(self, commit)?;
1099 let src_commit = load_commit(store, &src_hash).ok_or_else(|| {
1100 RedDBError::NotFound(format!("cherry-pick source `{src_hash}` not found"))
1101 })?;
1102 if src_commit.parents.is_empty() {
1103 return Err(RedDBError::InvalidConfig(
1104 "cannot cherry-pick a root commit".to_string(),
1105 ));
1106 }
1107 if src_commit.parents.len() > 1 {
1108 return Err(RedDBError::InvalidConfig(
1109 "cannot cherry-pick a merge commit; resolve manually".to_string(),
1110 ));
1111 }
1112 let parent_hash = src_commit.parents[0].clone();
1113
1114 let workset = load_workset(store, connection_id);
1116 let (head_branch, head_hash) = match workset {
1117 Some((branch, Some(head))) => (branch, head),
1118 Some((branch, None)) => {
1119 let head = load_ref(store, &branch)
1120 .map(|r| r.target)
1121 .unwrap_or_default();
1122 (branch, head)
1123 }
1124 None => {
1125 let head = load_ref(store, vc::DEFAULT_BRANCH_REF)
1126 .map(|r| r.target)
1127 .unwrap_or_default();
1128 (vc::DEFAULT_BRANCH_REF.to_string(), head)
1129 }
1130 };
1131 if head_hash.is_empty() {
1132 return Err(RedDBError::InvalidConfig(
1133 "cannot cherry-pick onto empty HEAD".to_string(),
1134 ));
1135 }
1136
1137 let message = format!("cherry-pick: {}", src_commit.message);
1142 let parents = vec![head_hash.clone()];
1143 let parent_height = load_commit(store, &head_hash)
1144 .map(|c| c.height)
1145 .unwrap_or(0);
1146 let height = parent_height + 1;
1147 let snapshot_manager = self.inner.transaction_state.snapshot_manager();
1148 let root_xid = snapshot_manager.begin();
1149 snapshot_manager.commit(root_xid);
1150 let timestamp_ms = now_ms();
1151
1152 let hash = compute_commit_hash(root_xid, &parents, &author, &message, timestamp_ms);
1153 let pick_commit = Commit {
1154 hash: hash.clone(),
1155 root_xid,
1156 parents,
1157 height,
1158 author: author.clone(),
1159 committer: author,
1160 message,
1161 timestamp_ms,
1162 signature: None,
1163 };
1164 save_commit(store, &pick_commit)?;
1165 if root_xid != XID_NONE {
1166 snapshot_manager.pin(root_xid);
1167 }
1168 if !head_branch.is_empty() {
1169 save_ref(
1170 store,
1171 &Ref {
1172 name: head_branch.clone(),
1173 kind: RefKind::Branch,
1174 target: hash.clone(),
1175 protected: false,
1176 },
1177 )?;
1178 }
1179 upsert_workset(store, connection_id, &head_branch, Some(&hash), root_xid)?;
1180
1181 let merge_state_id = format!("cp:{}", &hash[..16]);
1182 let conflicts = materialize_merge_conflicts(
1183 self,
1184 store,
1185 &parent_hash,
1186 &head_hash,
1187 &src_hash,
1188 &merge_state_id,
1189 )?;
1190
1191 let mut ms_fields: HashMap<String, Value> = HashMap::new();
1192 ms_fields.insert("id".to_string(), Value::text(merge_state_id.as_str()));
1193 ms_fields.insert("kind".to_string(), Value::text("cherry_pick"));
1194 ms_fields.insert("branch".to_string(), Value::text(head_branch.as_str()));
1195 ms_fields.insert("base".to_string(), Value::text(parent_hash.as_str()));
1196 ms_fields.insert("ours".to_string(), Value::text(head_hash.as_str()));
1197 ms_fields.insert("theirs".to_string(), Value::text(src_hash.as_str()));
1198 ms_fields.insert(
1199 "conflicts_count".to_string(),
1200 Value::UnsignedInteger(conflicts.len() as u64),
1201 );
1202 insert_meta_row(store, vc::MERGE_STATE, ms_fields)?;
1203
1204 Ok(MergeOutcome {
1205 merge_commit: Some(pick_commit),
1206 fast_forward: false,
1207 conflicts,
1208 merge_state_id: Some(merge_state_id),
1209 })
1210 }
1211
1212 pub fn vcs_revert(
1213 &self,
1214 connection_id: u64,
1215 commit: &str,
1216 author: Author,
1217 ) -> RedDBResult<Commit> {
1218 let store_arc = self.inner.db.store();
1219 let store: &UnifiedStore = &store_arc;
1220
1221 let src_hash = RedDBRuntime::vcs_resolve_commitish(self, commit)?;
1222 let src_commit = load_commit(store, &src_hash)
1223 .ok_or_else(|| RedDBError::NotFound(format!("revert source `{src_hash}` not found")))?;
1224 if src_commit.parents.is_empty() {
1225 return Err(RedDBError::InvalidConfig(
1226 "cannot revert a root commit".to_string(),
1227 ));
1228 }
1229 let parent_hash = src_commit.parents[0].clone();
1230
1231 let workset = load_workset(store, connection_id);
1232 let (head_branch, head_hash) = match workset {
1233 Some((branch, Some(head))) => (branch, head),
1234 Some((branch, None)) => {
1235 let head = load_ref(store, &branch)
1236 .map(|r| r.target)
1237 .unwrap_or_default();
1238 (branch, head)
1239 }
1240 None => {
1241 let head = load_ref(store, vc::DEFAULT_BRANCH_REF)
1242 .map(|r| r.target)
1243 .unwrap_or_default();
1244 (vc::DEFAULT_BRANCH_REF.to_string(), head)
1245 }
1246 };
1247 if head_hash.is_empty() {
1248 return Err(RedDBError::InvalidConfig(
1249 "cannot revert onto empty HEAD".to_string(),
1250 ));
1251 }
1252
1253 let message = format!("Revert \"{}\"", src_commit.message);
1258 let parents = vec![head_hash.clone()];
1259 let parent_height = load_commit(store, &head_hash)
1260 .map(|c| c.height)
1261 .unwrap_or(0);
1262 let height = parent_height + 1;
1263 let snapshot_manager = self.inner.transaction_state.snapshot_manager();
1264 let root_xid = snapshot_manager.begin();
1265 snapshot_manager.commit(root_xid);
1266 let timestamp_ms = now_ms();
1267
1268 let hash = compute_commit_hash(root_xid, &parents, &author, &message, timestamp_ms);
1269 let rv_commit = Commit {
1270 hash: hash.clone(),
1271 root_xid,
1272 parents,
1273 height,
1274 author: author.clone(),
1275 committer: author,
1276 message,
1277 timestamp_ms,
1278 signature: None,
1279 };
1280 save_commit(store, &rv_commit)?;
1281 if root_xid != XID_NONE {
1282 snapshot_manager.pin(root_xid);
1283 }
1284 if !head_branch.is_empty() {
1285 save_ref(
1286 store,
1287 &Ref {
1288 name: head_branch.clone(),
1289 kind: RefKind::Branch,
1290 target: hash.clone(),
1291 protected: false,
1292 },
1293 )?;
1294 }
1295 upsert_workset(store, connection_id, &head_branch, Some(&hash), root_xid)?;
1296
1297 let merge_state_id = format!("rv:{}", &hash[..16]);
1298 let mut ms_fields: HashMap<String, Value> = HashMap::new();
1302 ms_fields.insert("id".to_string(), Value::text(merge_state_id.as_str()));
1303 ms_fields.insert("kind".to_string(), Value::text("revert"));
1304 ms_fields.insert("branch".to_string(), Value::text(head_branch.as_str()));
1305 ms_fields.insert("base".to_string(), Value::text(src_hash.as_str()));
1306 ms_fields.insert("ours".to_string(), Value::text(head_hash.as_str()));
1307 ms_fields.insert("theirs".to_string(), Value::text(parent_hash.as_str()));
1308 ms_fields.insert("conflicts_count".to_string(), Value::UnsignedInteger(0));
1309 insert_meta_row(store, vc::MERGE_STATE, ms_fields)?;
1310
1311 Ok(rv_commit)
1312 }
1313
1314 pub fn vcs_reset(&self, input: ResetInput) -> RedDBResult<()> {
1315 let store_arc = self.inner.db.store();
1316 let store: &UnifiedStore = &store_arc;
1317 let target_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.target)?;
1318 let target_commit = load_commit(store, &target_hash).ok_or_else(|| {
1319 RedDBError::NotFound(format!("target commit `{target_hash}` not found"))
1320 })?;
1321
1322 let workset = load_workset(store, input.connection_id);
1324 let branch = workset
1325 .as_ref()
1326 .map(|(b, _)| b.clone())
1327 .unwrap_or_else(|| vc::DEFAULT_BRANCH_REF.to_string());
1328
1329 match input.mode {
1333 ResetMode::Soft | ResetMode::Mixed => {
1334 if !branch.is_empty() {
1335 save_ref(
1336 store,
1337 &Ref {
1338 name: branch.clone(),
1339 kind: RefKind::Branch,
1340 target: target_hash.clone(),
1341 protected: false,
1342 },
1343 )?;
1344 }
1345 upsert_workset(
1346 store,
1347 input.connection_id,
1348 &branch,
1349 Some(&target_hash),
1350 target_commit.root_xid,
1351 )?;
1352 Ok(())
1353 }
1354 ResetMode::Hard => Err(unimplemented("reset --hard (Phase 4)")),
1355 }
1356 }
1357
1358 pub fn vcs_log(&self, input: LogInput) -> RedDBResult<Vec<Commit>> {
1359 let store_arc = self.inner.db.store();
1360 let store: &UnifiedStore = &store_arc;
1361 let start = match &input.range.to {
1362 Some(spec) => RedDBRuntime::vcs_resolve_commitish(self, spec)?,
1363 None => {
1364 let workset = load_workset(store, input.connection_id);
1365 workset
1366 .and_then(|(_, base)| base)
1367 .or_else(|| load_ref(store, vc::DEFAULT_BRANCH_REF).map(|r| r.target))
1368 .unwrap_or_default()
1369 }
1370 };
1371 if start.is_empty() {
1372 return Ok(Vec::new());
1373 }
1374 Ok(topo_walk(store, &start, &input.range))
1375 }
1376
1377 pub fn vcs_diff(&self, input: DiffInput) -> RedDBResult<Diff> {
1378 let store_arc = self.inner.db.store();
1379 let store: &UnifiedStore = &store_arc;
1380 let from_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.from)?;
1381 let to_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.to)?;
1382 let from_xid = RedDBRuntime::vcs_resolve_as_of(self, AsOfSpec::Commit(from_hash.clone()))?;
1383 let to_xid = RedDBRuntime::vcs_resolve_as_of(self, AsOfSpec::Commit(to_hash.clone()))?;
1384
1385 let sm = self.inner.transaction_state.snapshot_manager();
1386 let from_snap = sm.snapshot(from_xid);
1387 let to_snap = sm.snapshot(to_xid);
1388
1389 let mut entries: Vec<DiffEntry> = Vec::new();
1391 let mut added = 0usize;
1392 let mut removed = 0usize;
1393 let mut modified = 0usize;
1394 let collections = store.list_collections();
1395 for coll in collections {
1396 if coll.starts_with("red_") {
1397 continue;
1398 }
1399 if !is_versioned(store, &coll)? {
1400 continue;
1401 }
1402 if let Some(filter) = &input.collection {
1403 if filter != &coll {
1404 continue;
1405 }
1406 }
1407 let Some(manager) = store.get_collection(&coll) else {
1408 continue;
1409 };
1410 let entities = manager.query_all(|_| true);
1411 for entity in &entities {
1413 let xmin = entity.xmin;
1414 let xmax = entity.xmax;
1415 let in_from = from_snap.sees(xmin, xmax) && !sm.is_aborted(xmin);
1416 let in_to = to_snap.sees(xmin, xmax) && !sm.is_aborted(xmin);
1417 if in_from == in_to {
1418 continue;
1419 }
1420 let entity_id = entity.id.raw().to_string();
1421 let payload = if input.summary_only {
1422 JsonValue::Null
1423 } else {
1424 JsonValue::String(format!("entity#{} xmin={} xmax={}", entity_id, xmin, xmax))
1425 };
1426 let change = match (in_from, in_to) {
1427 (false, true) => {
1428 added += 1;
1429 DiffChange::Added { after: payload }
1430 }
1431 (true, false) => {
1432 removed += 1;
1433 DiffChange::Removed { before: payload }
1434 }
1435 _ => unreachable!(),
1436 };
1437 entries.push(DiffEntry {
1438 collection: coll.clone(),
1439 entity_id,
1440 change,
1441 });
1442 }
1443 }
1444
1445 entries = coalesce_modifications(entries, &mut added, &mut removed, &mut modified);
1450
1451 Ok(Diff {
1452 from: from_hash,
1453 to: to_hash,
1454 entries,
1455 added,
1456 removed,
1457 modified,
1458 })
1459 }
1460
1461 pub fn vcs_status(&self, input: StatusInput) -> RedDBResult<Status> {
1462 let store_arc = self.inner.db.store();
1463 let store: &UnifiedStore = &store_arc;
1464 let workset = load_workset(store, input.connection_id);
1465 let (head_ref, head_commit) = match workset {
1466 Some((branch, base)) => {
1467 let base = base.or_else(|| load_ref(store, &branch).map(|r| r.target));
1468 (Some(branch), base)
1469 }
1470 None => {
1471 let base = load_ref(store, vc::DEFAULT_BRANCH_REF).map(|r| r.target);
1472 (Some(vc::DEFAULT_BRANCH_REF.to_string()), base)
1473 }
1474 };
1475 let detached = matches!(&head_ref, Some(s) if s.is_empty());
1476 Ok(Status {
1477 connection_id: input.connection_id,
1478 head_ref: head_ref.filter(|s| !s.is_empty()),
1479 head_commit,
1480 detached,
1481 staged_changes: 0,
1482 working_changes: 0,
1483 unresolved_conflicts: 0,
1484 merge_state_id: None,
1485 })
1486 }
1487
1488 pub fn vcs_lca(&self, a: &str, b: &str) -> RedDBResult<Option<CommitHash>> {
1489 let store_arc = self.inner.db.store();
1490 let store: &UnifiedStore = &store_arc;
1491 let a_hash = RedDBRuntime::vcs_resolve_commitish(self, a)?;
1492 let b_hash = RedDBRuntime::vcs_resolve_commitish(self, b)?;
1493 let a_ancestors = ancestor_set(store, &a_hash, 100_000);
1494
1495 let mut visited: HashSet<CommitHash> = HashSet::new();
1498 let mut stack: Vec<CommitHash> = vec![b_hash];
1499 let mut best: Option<(u64, CommitHash)> = None;
1500 while let Some(hash) = stack.pop() {
1501 if !visited.insert(hash.clone()) {
1502 continue;
1503 }
1504 if a_ancestors.contains(&hash) {
1505 let height = load_commit(store, &hash).map(|c| c.height).unwrap_or(0);
1506 match &best {
1507 Some((h, _)) if *h >= height => {}
1508 _ => best = Some((height, hash.clone())),
1509 }
1510 continue;
1513 }
1514 if let Some(commit) = load_commit(store, &hash) {
1515 for p in commit.parents {
1516 if !visited.contains(&p) {
1517 stack.push(p);
1518 }
1519 }
1520 }
1521 }
1522 Ok(best.map(|(_, h)| h))
1523 }
1524
1525 pub fn vcs_conflicts_list(&self, merge_state_id: &str) -> RedDBResult<Vec<Conflict>> {
1526 let store_arc = self.inner.db.store();
1527 let store: &UnifiedStore = &store_arc;
1528 let Some(manager) = store.get_collection(vc::CONFLICTS) else {
1529 return Ok(Vec::new());
1530 };
1531 let msid = merge_state_id.to_string();
1532 let out = manager
1533 .query_all(|entity| {
1534 entity
1535 .data
1536 .as_row()
1537 .is_some_and(|row| row_text(row, "merge_state_id").as_deref() == Some(&msid))
1538 })
1539 .into_iter()
1540 .filter_map(|entity| {
1541 let row = entity.data.as_row()?;
1542 Some(Conflict {
1543 id: row_text(row, "id")?,
1544 collection: row_text(row, "collection")?,
1545 entity_id: row_text(row, "entity_id").unwrap_or_default(),
1546 base: row_json(row, "base_json"),
1547 ours: row_json(row, "ours_json"),
1548 theirs: row_json(row, "theirs_json"),
1549 conflicting_paths: row_string_list(row, "conflicting_paths"),
1550 merge_state_id: row_text(row, "merge_state_id").unwrap_or_default(),
1551 })
1552 })
1553 .collect();
1554 Ok(out)
1555 }
1556
1557 pub fn vcs_conflict_resolve(&self, conflict_id: &str, _resolved: JsonValue) -> RedDBResult<()> {
1558 let store_arc = self.inner.db.store();
1559 let store: &UnifiedStore = &store_arc;
1560 let Some(manager) = store.get_collection(vc::CONFLICTS) else {
1561 return Err(RedDBError::NotFound(format!(
1562 "conflict `{conflict_id}` not found"
1563 )));
1564 };
1565 let cid = conflict_id.to_string();
1566 let mut deleted = 0usize;
1567 let matches = manager.query_all(|entity| {
1568 entity
1569 .data
1570 .as_row()
1571 .is_some_and(|row| row_text(row, "id").as_deref() == Some(&cid))
1572 });
1573 for entity in matches {
1574 store
1575 .delete(vc::CONFLICTS, entity.id)
1576 .map_err(|e| RedDBError::Internal(e.to_string()))?;
1577 deleted += 1;
1578 }
1579 if deleted == 0 {
1583 return Err(RedDBError::NotFound(format!(
1584 "conflict `{conflict_id}` not found"
1585 )));
1586 }
1587 Ok(())
1588 }
1589
1590 pub fn vcs_resolve_as_of(&self, spec: AsOfSpec) -> RedDBResult<Xid> {
1591 let store_arc = self.inner.db.store();
1592 let store: &UnifiedStore = &store_arc;
1593 match spec {
1594 AsOfSpec::Snapshot(x) => Ok(x),
1595 AsOfSpec::Commit(h) => {
1596 let c = load_commit(store, &h)
1597 .ok_or_else(|| RedDBError::NotFound(format!("commit `{h}` not found")))?;
1598 Ok(c.root_xid)
1599 }
1600 AsOfSpec::Branch(name) => {
1601 let full = normalize_branch_name(&name);
1602 let r = load_ref(store, &full).ok_or_else(|| {
1603 RedDBError::NotFound(format!("branch `{full}` does not exist"))
1604 })?;
1605 let c = load_commit(store, &r.target).ok_or_else(|| {
1606 RedDBError::NotFound(format!("branch `{full}` points to missing commit"))
1607 })?;
1608 Ok(c.root_xid)
1609 }
1610 AsOfSpec::Tag(name) => {
1611 let full = normalize_tag_name(&name);
1612 let r = load_ref(store, &full)
1613 .ok_or_else(|| RedDBError::NotFound(format!("tag `{full}` does not exist")))?;
1614 let c = load_commit(store, &r.target).ok_or_else(|| {
1615 RedDBError::NotFound(format!("tag `{full}` points to missing commit"))
1616 })?;
1617 Ok(c.root_xid)
1618 }
1619 AsOfSpec::TimestampMs(ts) => {
1620 let manager = store
1621 .get_collection(vc::COMMITS)
1622 .ok_or_else(|| RedDBError::NotFound("no commits exist yet".to_string()))?;
1623 let mut best: Option<(i64, Xid)> = None;
1625 let entities = manager.query_all(|_| true);
1626 for entity in entities {
1627 if let Some(row) = entity.data.as_row() {
1628 let t = row_i64(row, "timestamp_ms").unwrap_or(0);
1629 if t <= ts {
1630 let xid = row_u64(row, "root_xid").unwrap_or(0);
1631 match &best {
1632 Some((bt, _)) if *bt >= t => {}
1633 _ => best = Some((t, xid)),
1634 }
1635 }
1636 }
1637 }
1638 best.map(|(_, x)| x)
1639 .ok_or_else(|| RedDBError::NotFound(format!("no commit at or before ts={ts}")))
1640 }
1641 }
1642 }
1643
1644 pub fn vcs_set_versioned(&self, collection: &str, enabled: bool) -> RedDBResult<()> {
1645 let store_arc = self.inner.db.store();
1646 let store: &UnifiedStore = &store_arc;
1647 set_versioned_flag(store, collection, enabled)
1648 }
1649
1650 pub fn vcs_list_versioned(&self) -> RedDBResult<Vec<String>> {
1651 let store_arc = self.inner.db.store();
1652 let store: &UnifiedStore = &store_arc;
1653 let mut list = versioned_collections(store)?;
1654 list.sort();
1655 Ok(list)
1656 }
1657
1658 pub fn vcs_is_versioned(&self, collection: &str) -> RedDBResult<bool> {
1659 let store_arc = self.inner.db.store();
1660 let store: &UnifiedStore = &store_arc;
1661 is_versioned(store, collection)
1662 }
1663
1664 pub fn vcs_resolve_commitish(&self, spec: &str) -> RedDBResult<CommitHash> {
1665 let store_arc = self.inner.db.store();
1666 let store: &UnifiedStore = &store_arc;
1667 if spec.is_empty() {
1668 return Err(RedDBError::InvalidConfig("empty commitish".to_string()));
1669 }
1670
1671 if spec.len() == 64
1673 && spec.chars().all(|c| c.is_ascii_hexdigit())
1674 && load_commit(store, spec).is_some()
1675 {
1676 return Ok(spec.to_string());
1677 }
1678
1679 if spec.starts_with("refs/") {
1681 if let Some(hash) = resolve_ref_chain(store, spec) {
1682 return Ok(hash);
1683 }
1684 }
1685
1686 let normalized_branch = normalize_branch_name(spec);
1688 if let Some(hash) = resolve_ref_chain(store, &normalized_branch) {
1689 return Ok(hash);
1690 }
1691 let normalized_tag = normalize_tag_name(spec);
1692 if let Some(hash) = resolve_ref_chain(store, &normalized_tag) {
1693 return Ok(hash);
1694 }
1695
1696 if let Some(hash) = resolve_short_commit(store, spec) {
1698 return Ok(hash);
1699 }
1700
1701 Err(RedDBError::NotFound(format!(
1702 "commitish `{spec}` did not resolve to any ref or commit"
1703 )))
1704 }
1705}
1706
1707fn materialize_merge_conflicts(
1716 rt: &RedDBRuntime,
1717 store: &UnifiedStore,
1718 base_hash: &str,
1719 ours_hash: &str,
1720 theirs_hash: &str,
1721 merge_state_id: &str,
1722) -> RedDBResult<Vec<Conflict>> {
1723 use crate::application::merge_json::three_way_merge;
1724 use crate::application::vcs::AsOfSpec;
1725
1726 let base_xid = rt.vcs_resolve_as_of(AsOfSpec::Commit(base_hash.to_string()))?;
1727 let ours_xid = rt.vcs_resolve_as_of(AsOfSpec::Commit(ours_hash.to_string()))?;
1728 let theirs_xid = rt.vcs_resolve_as_of(AsOfSpec::Commit(theirs_hash.to_string()))?;
1729
1730 let sm = rt.inner.transaction_state.snapshot_manager();
1731 let base_snap = sm.snapshot(base_xid);
1732 let ours_snap = sm.snapshot(ours_xid);
1733 let theirs_snap = sm.snapshot(theirs_xid);
1734
1735 let mut conflicts: Vec<Conflict> = Vec::new();
1741 for coll in store.list_collections() {
1742 if coll.starts_with("red_") {
1743 continue;
1744 }
1745 if !is_versioned(store, &coll)? {
1746 continue;
1747 }
1748 let Some(manager) = store.get_collection(&coll) else {
1749 continue;
1750 };
1751 let mut at_base: HashMap<u64, JsonValue> = HashMap::new();
1752 let mut at_ours: HashMap<u64, JsonValue> = HashMap::new();
1753 let mut at_theirs: HashMap<u64, JsonValue> = HashMap::new();
1754 for entity in manager.query_all(|_| true) {
1755 let xmin = entity.xmin;
1756 let xmax = entity.xmax;
1757 if sm.is_aborted(xmin) {
1758 continue;
1759 }
1760 let eid = entity.id.raw();
1761 let body = crate::entity_render::compact_entity_json(&entity);
1762 if base_snap.sees(xmin, xmax) {
1763 at_base.insert(eid, body.clone());
1764 }
1765 if ours_snap.sees(xmin, xmax) {
1766 at_ours.insert(eid, body.clone());
1767 }
1768 if theirs_snap.sees(xmin, xmax) {
1769 at_theirs.insert(eid, body);
1770 }
1771 }
1772
1773 let mut all_ids: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
1774 all_ids.extend(at_base.keys().copied());
1775 all_ids.extend(at_ours.keys().copied());
1776 all_ids.extend(at_theirs.keys().copied());
1777
1778 for eid in all_ids {
1779 let b = at_base.get(&eid).cloned().unwrap_or(JsonValue::Null);
1780 let o = at_ours.get(&eid).cloned().unwrap_or(JsonValue::Null);
1781 let t = at_theirs.get(&eid).cloned().unwrap_or(JsonValue::Null);
1782 let ours_changed = b != o;
1783 let theirs_changed = b != t;
1784 if !(ours_changed && theirs_changed) {
1785 continue;
1786 }
1787 if o == t {
1788 continue;
1789 }
1790 let merge = three_way_merge(&b, &o, &t);
1791 if merge.is_clean() {
1792 continue;
1796 }
1797 let conflict_id = format!("{}:{}/{}", merge_state_id, coll, eid);
1798 let paths: Vec<String> = merge
1799 .conflicts
1800 .iter()
1801 .map(|c| {
1802 if c.path.is_empty() {
1803 "*".to_string()
1804 } else {
1805 c.path.clone()
1806 }
1807 })
1808 .collect();
1809
1810 let mut fields: HashMap<String, Value> = HashMap::new();
1811 fields.insert("id".to_string(), Value::text(conflict_id.as_str()));
1812 fields.insert("collection".to_string(), Value::text(coll.as_str()));
1813 fields.insert(
1814 "entity_id".to_string(),
1815 Value::text(eid.to_string().as_str()),
1816 );
1817 fields.insert("merge_state_id".to_string(), Value::text(merge_state_id));
1818 fields.insert(
1819 "conflicting_paths".to_string(),
1820 Value::Array(paths.iter().map(|p| Value::text(p.as_str())).collect()),
1821 );
1822 if let Ok(bytes) = crate::json::to_vec(&b) {
1826 fields.insert("base_json".to_string(), Value::Json(bytes));
1827 }
1828 if let Ok(bytes) = crate::json::to_vec(&o) {
1829 fields.insert("ours_json".to_string(), Value::Json(bytes));
1830 }
1831 if let Ok(bytes) = crate::json::to_vec(&t) {
1832 fields.insert("theirs_json".to_string(), Value::Json(bytes));
1833 }
1834 insert_meta_row(store, vc::CONFLICTS, fields)?;
1835 conflicts.push(Conflict {
1836 id: conflict_id,
1837 collection: coll.clone(),
1838 entity_id: eid.to_string(),
1839 base: b,
1840 ours: o,
1841 theirs: t,
1842 conflicting_paths: paths,
1843 merge_state_id: merge_state_id.to_string(),
1844 });
1845 }
1846 }
1847 Ok(conflicts)
1848}
1849
1850fn coalesce_modifications(
1854 entries: Vec<DiffEntry>,
1855 added: &mut usize,
1856 removed: &mut usize,
1857 modified: &mut usize,
1858) -> Vec<DiffEntry> {
1859 let mut by_key: HashMap<(String, String), Vec<DiffEntry>> = HashMap::new();
1860 for e in entries {
1861 by_key
1862 .entry((e.collection.clone(), e.entity_id.clone()))
1863 .or_default()
1864 .push(e);
1865 }
1866 let mut out: Vec<DiffEntry> = Vec::new();
1867 for ((coll, eid), group) in by_key {
1868 if group.len() >= 2 {
1869 let mut before = JsonValue::Null;
1870 let mut after = JsonValue::Null;
1871 for item in group {
1872 match item.change {
1873 DiffChange::Removed { before: b } => before = b,
1874 DiffChange::Added { after: a } => after = a,
1875 DiffChange::Modified { .. } => {}
1876 }
1877 }
1878 *added = added.saturating_sub(1);
1879 *removed = removed.saturating_sub(1);
1880 *modified += 1;
1881 out.push(DiffEntry {
1882 collection: coll,
1883 entity_id: eid,
1884 change: DiffChange::Modified { before, after },
1885 });
1886 } else {
1887 out.extend(group);
1888 }
1889 }
1890 out.sort_by(|a, b| {
1891 a.collection
1892 .cmp(&b.collection)
1893 .then(a.entity_id.cmp(&b.entity_id))
1894 });
1895 out
1896}
1897
1898#[allow(dead_code)]
1900fn _touch_types(_: BTreeSet<()>) {}