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) -> bool {
346 if name.starts_with("red_") {
347 return false;
348 }
349 let Some(manager) = store.get_collection(vc::SETTINGS) else {
350 return false;
351 };
352 let target = name.to_string();
353 manager
354 .query_all(|entity| {
355 entity
356 .data
357 .as_row()
358 .is_some_and(|row| row_text(row, "id").as_deref() == Some(&target))
359 })
360 .into_iter()
361 .any(|entity| {
362 entity
363 .data
364 .as_row()
365 .and_then(|row| row.get_field("versioned"))
366 .map(|v| matches!(v, Value::Boolean(true)))
367 .unwrap_or(false)
368 })
369}
370
371fn versioned_collections(store: &UnifiedStore) -> Vec<String> {
375 let Some(manager) = store.get_collection(vc::SETTINGS) else {
376 return Vec::new();
377 };
378 manager
379 .query_all(|entity| {
380 entity
381 .data
382 .as_row()
383 .and_then(|row| row.get_field("versioned"))
384 .map(|v| matches!(v, Value::Boolean(true)))
385 .unwrap_or(false)
386 })
387 .into_iter()
388 .filter_map(|entity| row_text(entity.data.as_row()?, "id"))
389 .collect()
390}
391
392fn set_versioned_flag(store: &UnifiedStore, name: &str, enabled: bool) -> RedDBResult<()> {
398 if name.starts_with("red_") {
399 return Err(RedDBError::InvalidConfig(format!(
400 "cannot version internal collection `{name}`"
401 )));
402 }
403 let target = name.to_string();
404 if let Some(manager) = store.get_collection(vc::SETTINGS) {
405 let rows = manager.query_all(|entity| {
406 entity
407 .data
408 .as_row()
409 .is_some_and(|row| row_text(row, "id").as_deref() == Some(&target))
410 });
411 for row in rows {
412 let _ = store.delete(vc::SETTINGS, row.id);
413 }
414 }
415 if !enabled {
416 return Ok(());
417 }
418 let mut fields: HashMap<String, Value> = HashMap::new();
419 fields.insert("id".to_string(), Value::text(name));
420 fields.insert("versioned".to_string(), Value::Boolean(true));
421 fields.insert("ts_ms".to_string(), Value::TimestampMs(now_ms()));
422 insert_meta_row(store, vc::SETTINGS, fields)?;
423 Ok(())
424}
425
426fn list_refs_by_prefix(store: &UnifiedStore, prefix: Option<&str>) -> Vec<Ref> {
427 let Some(manager) = store.get_collection(vc::REFS) else {
428 return Vec::new();
429 };
430 let prefix_owned = prefix.map(|s| s.to_string());
431 manager
432 .query_all(|entity| {
433 entity.data.as_row().is_some_and(|row| {
434 let id = row_text(row, "id").unwrap_or_default();
435 match &prefix_owned {
436 Some(p) => id.starts_with(p),
437 None => true,
438 }
439 })
440 })
441 .into_iter()
442 .filter_map(|entity| ref_from_row(entity.data.as_row()?))
443 .collect()
444}
445
446fn ancestor_set(store: &UnifiedStore, start: &str, max_steps: usize) -> HashSet<CommitHash> {
451 let mut visited: HashSet<CommitHash> = HashSet::new();
452 let mut stack: Vec<CommitHash> = vec![start.to_string()];
453 let mut steps = 0usize;
454 while let Some(hash) = stack.pop() {
455 if !visited.insert(hash.clone()) {
456 continue;
457 }
458 if let Some(c) = load_commit(store, &hash) {
459 for p in c.parents {
460 if !visited.contains(&p) {
461 stack.push(p);
462 }
463 }
464 }
465 steps += 1;
466 if steps >= max_steps {
467 break;
468 }
469 }
470 visited
471}
472
473fn topo_walk(store: &UnifiedStore, start: &str, range: &LogRange) -> Vec<Commit> {
474 let limit = range.limit.unwrap_or(usize::MAX);
475 let skip = range.skip.unwrap_or(0);
476 let exclude = range
477 .from
478 .as_ref()
479 .map(|h| ancestor_set(store, h, 100_000))
480 .unwrap_or_default();
481
482 let mut visited: HashSet<CommitHash> = HashSet::new();
483 let mut stack: Vec<CommitHash> = vec![start.to_string()];
484 let mut out: Vec<Commit> = Vec::new();
485 let mut skipped = 0usize;
486 while let Some(hash) = stack.pop() {
487 if !visited.insert(hash.clone()) {
488 continue;
489 }
490 if exclude.contains(&hash) {
491 continue;
492 }
493 let Some(commit) = load_commit(store, &hash) else {
494 continue;
495 };
496 if range.no_merges && commit.parents.len() > 1 {
497 for p in &commit.parents {
498 if !visited.contains(p) {
499 stack.push(p.clone());
500 }
501 }
502 continue;
503 }
504 for p in &commit.parents {
505 if !visited.contains(p) {
506 stack.push(p.clone());
507 }
508 }
509 if skipped < skip {
510 skipped += 1;
511 continue;
512 }
513 if out.len() >= limit {
514 break;
515 }
516 out.push(commit);
517 }
518 out.sort_by(|a, b| {
520 b.height
521 .cmp(&a.height)
522 .then(b.timestamp_ms.cmp(&a.timestamp_ms))
523 });
524 out
525}
526
527fn resolve_ref_chain(store: &UnifiedStore, name: &str) -> Option<CommitHash> {
532 let mut current = name.to_string();
534 for _ in 0..4 {
535 let r = load_ref(store, ¤t)?;
536 match r.kind {
537 RefKind::Branch | RefKind::Tag => return Some(r.target),
538 RefKind::Head => {
539 current = r.target;
540 continue;
541 }
542 }
543 }
544 None
545}
546
547fn resolve_short_commit(store: &UnifiedStore, prefix: &str) -> Option<CommitHash> {
548 if prefix.len() < 4 {
549 return None;
550 }
551 let manager = store.get_collection(vc::COMMITS)?;
552 let matches: Vec<String> = manager
553 .query_all(|entity| {
554 entity.data.as_row().is_some_and(|row| {
555 row_text(row, "id")
556 .map(|id| id.starts_with(prefix))
557 .unwrap_or(false)
558 })
559 })
560 .into_iter()
561 .filter_map(|entity| row_text(entity.data.as_row()?, "id"))
562 .collect();
563 if matches.len() == 1 {
564 matches.into_iter().next()
565 } else {
566 None
567 }
568}
569
570fn upsert_workset(
575 store: &UnifiedStore,
576 connection_id: u64,
577 branch: &str,
578 base_commit: Option<&str>,
579 working_xid: Xid,
580) -> RedDBResult<()> {
581 let conn_id = connection_id.to_string();
582 if let Some(manager) = store.get_collection(vc::WORKSETS) {
584 let rows = manager.query_all(|entity| {
585 entity
586 .data
587 .as_row()
588 .is_some_and(|row| row_text(row, "id").as_deref() == Some(&conn_id))
589 });
590 for row in rows {
591 let _ = store.delete(vc::WORKSETS, row.id);
592 }
593 }
594 let mut fields: HashMap<String, Value> = HashMap::new();
595 fields.insert("id".to_string(), Value::text(conn_id.as_str()));
596 fields.insert("branch".to_string(), Value::text(branch));
597 if let Some(base) = base_commit {
598 fields.insert("base_commit".to_string(), Value::text(base));
599 }
600 fields.insert(
601 "working_xid".to_string(),
602 Value::UnsignedInteger(working_xid),
603 );
604 insert_meta_row(store, vc::WORKSETS, fields)?;
605 Ok(())
606}
607
608fn load_workset(store: &UnifiedStore, connection_id: u64) -> Option<(RefName, Option<CommitHash>)> {
609 let manager = store.get_collection(vc::WORKSETS)?;
610 let conn_id = connection_id.to_string();
611 manager
612 .query_all(|entity| {
613 entity
614 .data
615 .as_row()
616 .is_some_and(|row| row_text(row, "id").as_deref() == Some(&conn_id))
617 })
618 .into_iter()
619 .find_map(|entity| {
620 let row = entity.data.as_row()?;
621 Some((
622 row_text(row, "branch").unwrap_or_else(|| vc::DEFAULT_BRANCH_REF.to_string()),
623 row_text(row, "base_commit"),
624 ))
625 })
626}
627
628impl RedDBRuntime {
633 pub fn vcs_commit(&self, input: CreateCommitInput) -> RedDBResult<Commit> {
634 let store_arc = self.inner.db.store();
635 let store: &UnifiedStore = &store_arc;
636
637 let workset = load_workset(store, input.connection_id);
640 let branch_ref = workset
641 .as_ref()
642 .map(|(b, _)| b.clone())
643 .or_else(|| resolve_ref_chain(store, &head_ref_id(input.connection_id)).and(None))
644 .unwrap_or_else(|| vc::DEFAULT_BRANCH_REF.to_string());
645
646 let parent_hash = workset
647 .as_ref()
648 .and_then(|(_, base)| base.clone())
649 .or_else(|| load_ref(store, &branch_ref).map(|r| r.target));
650
651 let parents: Vec<CommitHash> = match parent_hash.clone() {
652 Some(h) if !h.is_empty() => vec![h],
653 _ => Vec::new(),
654 };
655
656 let parent_height = parents
657 .iter()
658 .filter_map(|p| load_commit(store, p).map(|c| c.height))
659 .max()
660 .unwrap_or(0);
661 let height = if parents.is_empty() {
662 0
663 } else {
664 parent_height + 1
665 };
666
667 let snapshot_manager = self.inner.transaction_state.snapshot_manager();
673 let root_xid = snapshot_manager.begin();
674 snapshot_manager.commit(root_xid);
675 let timestamp_ms = now_ms();
676 let committer = input.committer.unwrap_or_else(|| input.author.clone());
677
678 let hash = compute_commit_hash(
679 root_xid,
680 &parents,
681 &input.author,
682 &input.message,
683 timestamp_ms,
684 );
685
686 if load_commit(store, &hash).is_some() {
687 return Err(RedDBError::InvalidConfig(format!(
688 "commit {hash} already exists (duplicate timestamp+content)"
689 )));
690 }
691
692 let commit = Commit {
693 hash: hash.clone(),
694 root_xid,
695 parents,
696 height,
697 author: input.author,
698 committer,
699 message: input.message,
700 timestamp_ms,
701 signature: None,
702 };
703
704 save_commit(store, &commit)?;
705
706 if root_xid != XID_NONE {
709 snapshot_manager.pin(root_xid);
710 }
711
712 let branch_name = if branch_ref.is_empty() {
714 vc::DEFAULT_BRANCH_REF.to_string()
715 } else {
716 branch_ref
717 };
718 save_ref(
719 store,
720 &Ref {
721 name: branch_name.clone(),
722 kind: RefKind::Branch,
723 target: hash.clone(),
724 protected: false,
725 },
726 )?;
727
728 upsert_workset(
729 store,
730 input.connection_id,
731 &branch_name,
732 Some(&hash),
733 root_xid,
734 )?;
735
736 Ok(commit)
737 }
738
739 pub fn vcs_branch_create(&self, input: CreateBranchInput) -> RedDBResult<Ref> {
740 let store_arc = self.inner.db.store();
741 let store: &UnifiedStore = &store_arc;
742 let full_name = normalize_branch_name(&input.name);
743 if load_ref(store, &full_name).is_some() {
744 return Err(RedDBError::InvalidConfig(format!(
745 "branch `{full_name}` already exists"
746 )));
747 }
748 let target_hash = match &input.from {
749 Some(spec) => RedDBRuntime::vcs_resolve_commitish(self, spec)?,
750 None => {
751 let workset = load_workset(store, input.connection_id);
753
754 workset
755 .and_then(|(_, base)| base)
756 .or_else(|| load_ref(store, vc::DEFAULT_BRANCH_REF).map(|r| r.target))
757 .unwrap_or_default()
758 }
759 };
760 let r = Ref {
761 name: full_name,
762 kind: RefKind::Branch,
763 target: target_hash,
764 protected: false,
765 };
766 save_ref(store, &r)?;
767 Ok(r)
768 }
769
770 pub fn vcs_branch_delete(&self, name: &str) -> RedDBResult<()> {
771 let store_arc = self.inner.db.store();
772 let store: &UnifiedStore = &store_arc;
773 let full = normalize_branch_name(name);
774 let existing = load_ref(store, &full)
775 .ok_or_else(|| RedDBError::NotFound(format!("branch `{full}` does not exist")))?;
776 if existing.protected {
777 return Err(RedDBError::ReadOnly(format!(
778 "branch `{full}` is protected"
779 )));
780 }
781 delete_ref(store, &full)?;
782 Ok(())
783 }
784
785 pub fn vcs_tag_create(&self, input: CreateTagInput) -> RedDBResult<Ref> {
786 let store_arc = self.inner.db.store();
787 let store: &UnifiedStore = &store_arc;
788 let full_name = normalize_tag_name(&input.name);
789 if load_ref(store, &full_name).is_some() {
790 return Err(RedDBError::InvalidConfig(format!(
791 "tag `{full_name}` already exists"
792 )));
793 }
794 let target_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.target)?;
795 let r = Ref {
796 name: full_name,
797 kind: RefKind::Tag,
798 target: target_hash,
799 protected: false,
800 };
801 save_ref(store, &r)?;
802 Ok(r)
803 }
804
805 pub fn vcs_tag_delete(&self, name: &str) -> RedDBResult<()> {
806 let store_arc = self.inner.db.store();
807 let store: &UnifiedStore = &store_arc;
808 let full = normalize_tag_name(name);
809 let existing = load_ref(store, &full)
810 .ok_or_else(|| RedDBError::NotFound(format!("tag `{full}` does not exist")))?;
811 if existing.protected {
812 return Err(RedDBError::ReadOnly(format!("tag `{full}` is protected")));
813 }
814 delete_ref(store, &full)?;
815 Ok(())
816 }
817
818 pub fn vcs_list_refs(&self, prefix: Option<&str>) -> RedDBResult<Vec<Ref>> {
819 let store_arc = self.inner.db.store();
820 let store: &UnifiedStore = &store_arc;
821 Ok(list_refs_by_prefix(store, prefix))
822 }
823
824 pub fn vcs_checkout(&self, input: CheckoutInput) -> RedDBResult<Ref> {
825 let store_arc = self.inner.db.store();
826 let store: &UnifiedStore = &store_arc;
827 let (branch_ref, target_hash) = match &input.target {
828 CheckoutTarget::Branch(name) => {
829 let full = normalize_branch_name(name);
830 let r = load_ref(store, &full).ok_or_else(|| {
831 RedDBError::NotFound(format!("branch `{full}` does not exist"))
832 })?;
833 (full, r.target)
834 }
835 CheckoutTarget::Tag(name) => {
836 let full = normalize_tag_name(name);
837 let r = load_ref(store, &full)
838 .ok_or_else(|| RedDBError::NotFound(format!("tag `{full}` does not exist")))?;
839 (full, r.target)
840 }
841 CheckoutTarget::Commit(hash) => {
842 let resolved = RedDBRuntime::vcs_resolve_commitish(self, hash)?;
843 (String::new(), resolved)
846 }
847 };
848
849 upsert_workset(
850 store,
851 input.connection_id,
852 &branch_ref,
853 Some(&target_hash),
854 self.inner
855 .transaction_state
856 .snapshot_manager()
857 .peek_next_xid(),
858 )?;
859
860 save_ref(
862 store,
863 &Ref {
864 name: head_ref_id(input.connection_id),
865 kind: RefKind::Head,
866 target: if branch_ref.is_empty() {
867 target_hash.clone()
868 } else {
869 branch_ref.clone()
870 },
871 protected: false,
872 },
873 )?;
874
875 Ok(Ref {
876 name: if branch_ref.is_empty() {
877 format!("detached:{target_hash}")
878 } else {
879 branch_ref
880 },
881 kind: if target_hash.is_empty() {
882 RefKind::Head
883 } else {
884 RefKind::Branch
885 },
886 target: target_hash,
887 protected: false,
888 })
889 }
890
891 pub fn vcs_merge(&self, input: MergeInput) -> RedDBResult<MergeOutcome> {
892 let store_arc = self.inner.db.store();
893 let store: &UnifiedStore = &store_arc;
894
895 let from_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.from)?;
897 let from_commit = load_commit(store, &from_hash).ok_or_else(|| {
898 RedDBError::NotFound(format!("source commit `{from_hash}` not found"))
899 })?;
900
901 let workset = load_workset(store, input.connection_id);
903 let (head_branch, head_hash) = match workset {
904 Some((branch, Some(head))) => (branch, head),
905 Some((branch, None)) => {
906 let head = load_ref(store, &branch)
907 .map(|r| r.target)
908 .unwrap_or_default();
909 (branch, head)
910 }
911 None => {
912 let head = load_ref(store, vc::DEFAULT_BRANCH_REF)
913 .map(|r| r.target)
914 .unwrap_or_default();
915 (vc::DEFAULT_BRANCH_REF.to_string(), head)
916 }
917 };
918 if head_hash.is_empty() {
919 return Err(RedDBError::InvalidConfig(
920 "cannot merge: HEAD has no commits".to_string(),
921 ));
922 }
923
924 let from_ancestors = ancestor_set(store, &from_hash, 100_000);
926 let can_fast_forward = from_ancestors.contains(&head_hash);
927
928 match input.opts.strategy {
929 MergeStrategy::FastForwardOnly if !can_fast_forward => {
930 return Err(RedDBError::InvalidConfig(
931 "not a fast-forward — use --strategy auto or no-ff".to_string(),
932 ));
933 }
934 _ => {}
935 }
936
937 if can_fast_forward && input.opts.strategy != MergeStrategy::NoFastForward {
938 if head_branch.is_empty() {
939 return Err(RedDBError::InvalidConfig(
940 "cannot fast-forward a detached HEAD".to_string(),
941 ));
942 }
943 save_ref(
944 store,
945 &Ref {
946 name: head_branch.clone(),
947 kind: RefKind::Branch,
948 target: from_hash.clone(),
949 protected: false,
950 },
951 )?;
952 upsert_workset(
953 store,
954 input.connection_id,
955 &head_branch,
956 Some(&from_hash),
957 from_commit.root_xid,
958 )?;
959 return Ok(MergeOutcome {
960 merge_commit: Some(from_commit),
961 fast_forward: true,
962 conflicts: Vec::new(),
963 merge_state_id: None,
964 });
965 }
966
967 let lca = RedDBRuntime::vcs_lca(self, &head_hash, &from_hash)?;
975
976 let message = input
977 .opts
978 .message
979 .unwrap_or_else(|| format!("Merge {} into {}", input.from, head_branch));
980 let author = input.author.clone();
981 let timestamp_ms = now_ms();
982 let parents = vec![head_hash.clone(), from_hash.clone()];
983 let parent_height = parents
984 .iter()
985 .filter_map(|p| load_commit(store, p).map(|c| c.height))
986 .max()
987 .unwrap_or(0);
988 let height = parent_height + 1;
989 let snapshot_manager = self.inner.transaction_state.snapshot_manager();
990 let root_xid = snapshot_manager.begin();
991 snapshot_manager.commit(root_xid);
992
993 let hash = compute_commit_hash(root_xid, &parents, &author, &message, timestamp_ms);
994
995 let merge_commit = Commit {
996 hash: hash.clone(),
997 root_xid,
998 parents,
999 height,
1000 author: author.clone(),
1001 committer: author,
1002 message,
1003 timestamp_ms,
1004 signature: None,
1005 };
1006
1007 save_commit(store, &merge_commit)?;
1012 if root_xid != XID_NONE {
1013 snapshot_manager.pin(root_xid);
1014 }
1015 if !head_branch.is_empty() {
1016 save_ref(
1017 store,
1018 &Ref {
1019 name: head_branch.clone(),
1020 kind: RefKind::Branch,
1021 target: hash.clone(),
1022 protected: false,
1023 },
1024 )?;
1025 }
1026 upsert_workset(
1027 store,
1028 input.connection_id,
1029 &head_branch,
1030 Some(&hash),
1031 root_xid,
1032 )?;
1033
1034 let merge_state_id = format!("ms:{}", &hash[..16]);
1035
1036 let conflicts = if let Some(lca_hash) = &lca {
1041 materialize_merge_conflicts(
1042 self,
1043 store,
1044 lca_hash,
1045 &head_hash,
1046 &from_hash,
1047 &merge_state_id,
1048 )?
1049 } else {
1050 Vec::new()
1051 };
1052
1053 let mut ms_fields: HashMap<String, Value> = HashMap::new();
1054 ms_fields.insert("id".to_string(), Value::text(merge_state_id.as_str()));
1055 ms_fields.insert("kind".to_string(), Value::text("merge"));
1056 ms_fields.insert("branch".to_string(), Value::text(head_branch.as_str()));
1057 if let Some(base_hash) = &lca {
1058 ms_fields.insert("base".to_string(), Value::text(base_hash.as_str()));
1059 }
1060 ms_fields.insert("ours".to_string(), Value::text(head_hash.as_str()));
1061 ms_fields.insert("theirs".to_string(), Value::text(from_hash.as_str()));
1062 ms_fields.insert(
1063 "conflicts_count".to_string(),
1064 Value::UnsignedInteger(conflicts.len() as u64),
1065 );
1066 insert_meta_row(store, vc::MERGE_STATE, ms_fields)?;
1067
1068 Ok(MergeOutcome {
1069 merge_commit: Some(merge_commit),
1070 fast_forward: false,
1071 conflicts,
1072 merge_state_id: Some(merge_state_id),
1073 })
1074 }
1075
1076 pub fn vcs_cherry_pick(
1077 &self,
1078 connection_id: u64,
1079 commit: &str,
1080 author: Author,
1081 ) -> RedDBResult<MergeOutcome> {
1082 let store_arc = self.inner.db.store();
1083 let store: &UnifiedStore = &store_arc;
1084
1085 let src_hash = RedDBRuntime::vcs_resolve_commitish(self, commit)?;
1087 let src_commit = load_commit(store, &src_hash).ok_or_else(|| {
1088 RedDBError::NotFound(format!("cherry-pick source `{src_hash}` not found"))
1089 })?;
1090 if src_commit.parents.is_empty() {
1091 return Err(RedDBError::InvalidConfig(
1092 "cannot cherry-pick a root commit".to_string(),
1093 ));
1094 }
1095 if src_commit.parents.len() > 1 {
1096 return Err(RedDBError::InvalidConfig(
1097 "cannot cherry-pick a merge commit; resolve manually".to_string(),
1098 ));
1099 }
1100 let parent_hash = src_commit.parents[0].clone();
1101
1102 let workset = load_workset(store, connection_id);
1104 let (head_branch, head_hash) = match workset {
1105 Some((branch, Some(head))) => (branch, head),
1106 Some((branch, None)) => {
1107 let head = load_ref(store, &branch)
1108 .map(|r| r.target)
1109 .unwrap_or_default();
1110 (branch, head)
1111 }
1112 None => {
1113 let head = load_ref(store, vc::DEFAULT_BRANCH_REF)
1114 .map(|r| r.target)
1115 .unwrap_or_default();
1116 (vc::DEFAULT_BRANCH_REF.to_string(), head)
1117 }
1118 };
1119 if head_hash.is_empty() {
1120 return Err(RedDBError::InvalidConfig(
1121 "cannot cherry-pick onto empty HEAD".to_string(),
1122 ));
1123 }
1124
1125 let message = format!("cherry-pick: {}", src_commit.message);
1130 let parents = vec![head_hash.clone()];
1131 let parent_height = load_commit(store, &head_hash)
1132 .map(|c| c.height)
1133 .unwrap_or(0);
1134 let height = parent_height + 1;
1135 let snapshot_manager = self.inner.transaction_state.snapshot_manager();
1136 let root_xid = snapshot_manager.begin();
1137 snapshot_manager.commit(root_xid);
1138 let timestamp_ms = now_ms();
1139
1140 let hash = compute_commit_hash(root_xid, &parents, &author, &message, timestamp_ms);
1141 let pick_commit = Commit {
1142 hash: hash.clone(),
1143 root_xid,
1144 parents,
1145 height,
1146 author: author.clone(),
1147 committer: author,
1148 message,
1149 timestamp_ms,
1150 signature: None,
1151 };
1152 save_commit(store, &pick_commit)?;
1153 if root_xid != XID_NONE {
1154 snapshot_manager.pin(root_xid);
1155 }
1156 if !head_branch.is_empty() {
1157 save_ref(
1158 store,
1159 &Ref {
1160 name: head_branch.clone(),
1161 kind: RefKind::Branch,
1162 target: hash.clone(),
1163 protected: false,
1164 },
1165 )?;
1166 }
1167 upsert_workset(store, connection_id, &head_branch, Some(&hash), root_xid)?;
1168
1169 let merge_state_id = format!("cp:{}", &hash[..16]);
1170 let conflicts = materialize_merge_conflicts(
1171 self,
1172 store,
1173 &parent_hash,
1174 &head_hash,
1175 &src_hash,
1176 &merge_state_id,
1177 )?;
1178
1179 let mut ms_fields: HashMap<String, Value> = HashMap::new();
1180 ms_fields.insert("id".to_string(), Value::text(merge_state_id.as_str()));
1181 ms_fields.insert("kind".to_string(), Value::text("cherry_pick"));
1182 ms_fields.insert("branch".to_string(), Value::text(head_branch.as_str()));
1183 ms_fields.insert("base".to_string(), Value::text(parent_hash.as_str()));
1184 ms_fields.insert("ours".to_string(), Value::text(head_hash.as_str()));
1185 ms_fields.insert("theirs".to_string(), Value::text(src_hash.as_str()));
1186 ms_fields.insert(
1187 "conflicts_count".to_string(),
1188 Value::UnsignedInteger(conflicts.len() as u64),
1189 );
1190 insert_meta_row(store, vc::MERGE_STATE, ms_fields)?;
1191
1192 Ok(MergeOutcome {
1193 merge_commit: Some(pick_commit),
1194 fast_forward: false,
1195 conflicts,
1196 merge_state_id: Some(merge_state_id),
1197 })
1198 }
1199
1200 pub fn vcs_revert(
1201 &self,
1202 connection_id: u64,
1203 commit: &str,
1204 author: Author,
1205 ) -> RedDBResult<Commit> {
1206 let store_arc = self.inner.db.store();
1207 let store: &UnifiedStore = &store_arc;
1208
1209 let src_hash = RedDBRuntime::vcs_resolve_commitish(self, commit)?;
1210 let src_commit = load_commit(store, &src_hash)
1211 .ok_or_else(|| RedDBError::NotFound(format!("revert source `{src_hash}` not found")))?;
1212 if src_commit.parents.is_empty() {
1213 return Err(RedDBError::InvalidConfig(
1214 "cannot revert a root commit".to_string(),
1215 ));
1216 }
1217 let parent_hash = src_commit.parents[0].clone();
1218
1219 let workset = load_workset(store, connection_id);
1220 let (head_branch, head_hash) = match workset {
1221 Some((branch, Some(head))) => (branch, head),
1222 Some((branch, None)) => {
1223 let head = load_ref(store, &branch)
1224 .map(|r| r.target)
1225 .unwrap_or_default();
1226 (branch, head)
1227 }
1228 None => {
1229 let head = load_ref(store, vc::DEFAULT_BRANCH_REF)
1230 .map(|r| r.target)
1231 .unwrap_or_default();
1232 (vc::DEFAULT_BRANCH_REF.to_string(), head)
1233 }
1234 };
1235 if head_hash.is_empty() {
1236 return Err(RedDBError::InvalidConfig(
1237 "cannot revert onto empty HEAD".to_string(),
1238 ));
1239 }
1240
1241 let message = format!("Revert \"{}\"", src_commit.message);
1246 let parents = vec![head_hash.clone()];
1247 let parent_height = load_commit(store, &head_hash)
1248 .map(|c| c.height)
1249 .unwrap_or(0);
1250 let height = parent_height + 1;
1251 let snapshot_manager = self.inner.transaction_state.snapshot_manager();
1252 let root_xid = snapshot_manager.begin();
1253 snapshot_manager.commit(root_xid);
1254 let timestamp_ms = now_ms();
1255
1256 let hash = compute_commit_hash(root_xid, &parents, &author, &message, timestamp_ms);
1257 let rv_commit = Commit {
1258 hash: hash.clone(),
1259 root_xid,
1260 parents,
1261 height,
1262 author: author.clone(),
1263 committer: author,
1264 message,
1265 timestamp_ms,
1266 signature: None,
1267 };
1268 save_commit(store, &rv_commit)?;
1269 if root_xid != XID_NONE {
1270 snapshot_manager.pin(root_xid);
1271 }
1272 if !head_branch.is_empty() {
1273 save_ref(
1274 store,
1275 &Ref {
1276 name: head_branch.clone(),
1277 kind: RefKind::Branch,
1278 target: hash.clone(),
1279 protected: false,
1280 },
1281 )?;
1282 }
1283 upsert_workset(store, connection_id, &head_branch, Some(&hash), root_xid)?;
1284
1285 let merge_state_id = format!("rv:{}", &hash[..16]);
1286 let mut ms_fields: HashMap<String, Value> = HashMap::new();
1290 ms_fields.insert("id".to_string(), Value::text(merge_state_id.as_str()));
1291 ms_fields.insert("kind".to_string(), Value::text("revert"));
1292 ms_fields.insert("branch".to_string(), Value::text(head_branch.as_str()));
1293 ms_fields.insert("base".to_string(), Value::text(src_hash.as_str()));
1294 ms_fields.insert("ours".to_string(), Value::text(head_hash.as_str()));
1295 ms_fields.insert("theirs".to_string(), Value::text(parent_hash.as_str()));
1296 ms_fields.insert("conflicts_count".to_string(), Value::UnsignedInteger(0));
1297 insert_meta_row(store, vc::MERGE_STATE, ms_fields)?;
1298
1299 Ok(rv_commit)
1300 }
1301
1302 pub fn vcs_reset(&self, input: ResetInput) -> RedDBResult<()> {
1303 let store_arc = self.inner.db.store();
1304 let store: &UnifiedStore = &store_arc;
1305 let target_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.target)?;
1306 let target_commit = load_commit(store, &target_hash).ok_or_else(|| {
1307 RedDBError::NotFound(format!("target commit `{target_hash}` not found"))
1308 })?;
1309
1310 let workset = load_workset(store, input.connection_id);
1312 let branch = workset
1313 .as_ref()
1314 .map(|(b, _)| b.clone())
1315 .unwrap_or_else(|| vc::DEFAULT_BRANCH_REF.to_string());
1316
1317 match input.mode {
1321 ResetMode::Soft | ResetMode::Mixed => {
1322 if !branch.is_empty() {
1323 save_ref(
1324 store,
1325 &Ref {
1326 name: branch.clone(),
1327 kind: RefKind::Branch,
1328 target: target_hash.clone(),
1329 protected: false,
1330 },
1331 )?;
1332 }
1333 upsert_workset(
1334 store,
1335 input.connection_id,
1336 &branch,
1337 Some(&target_hash),
1338 target_commit.root_xid,
1339 )?;
1340 Ok(())
1341 }
1342 ResetMode::Hard => Err(unimplemented("reset --hard (Phase 4)")),
1343 }
1344 }
1345
1346 pub fn vcs_log(&self, input: LogInput) -> RedDBResult<Vec<Commit>> {
1347 let store_arc = self.inner.db.store();
1348 let store: &UnifiedStore = &store_arc;
1349 let start = match &input.range.to {
1350 Some(spec) => RedDBRuntime::vcs_resolve_commitish(self, spec)?,
1351 None => {
1352 let workset = load_workset(store, input.connection_id);
1353 workset
1354 .and_then(|(_, base)| base)
1355 .or_else(|| load_ref(store, vc::DEFAULT_BRANCH_REF).map(|r| r.target))
1356 .unwrap_or_default()
1357 }
1358 };
1359 if start.is_empty() {
1360 return Ok(Vec::new());
1361 }
1362 Ok(topo_walk(store, &start, &input.range))
1363 }
1364
1365 pub fn vcs_diff(&self, input: DiffInput) -> RedDBResult<Diff> {
1366 let store_arc = self.inner.db.store();
1367 let store: &UnifiedStore = &store_arc;
1368 let from_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.from)?;
1369 let to_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.to)?;
1370 let from_xid = RedDBRuntime::vcs_resolve_as_of(self, AsOfSpec::Commit(from_hash.clone()))?;
1371 let to_xid = RedDBRuntime::vcs_resolve_as_of(self, AsOfSpec::Commit(to_hash.clone()))?;
1372
1373 let sm = self.inner.transaction_state.snapshot_manager();
1374 let from_snap = sm.snapshot(from_xid);
1375 let to_snap = sm.snapshot(to_xid);
1376
1377 let mut entries: Vec<DiffEntry> = Vec::new();
1379 let mut added = 0usize;
1380 let mut removed = 0usize;
1381 let mut modified = 0usize;
1382 let collections = store.list_collections();
1383 for coll in collections {
1384 if coll.starts_with("red_") {
1385 continue;
1386 }
1387 if !is_versioned(store, &coll) {
1388 continue;
1389 }
1390 if let Some(filter) = &input.collection {
1391 if filter != &coll {
1392 continue;
1393 }
1394 }
1395 let Some(manager) = store.get_collection(&coll) else {
1396 continue;
1397 };
1398 let entities = manager.query_all(|_| true);
1399 for entity in &entities {
1401 let xmin = entity.xmin;
1402 let xmax = entity.xmax;
1403 let in_from = from_snap.sees(xmin, xmax) && !sm.is_aborted(xmin);
1404 let in_to = to_snap.sees(xmin, xmax) && !sm.is_aborted(xmin);
1405 if in_from == in_to {
1406 continue;
1407 }
1408 let entity_id = entity.id.raw().to_string();
1409 let payload = if input.summary_only {
1410 JsonValue::Null
1411 } else {
1412 JsonValue::String(format!("entity#{} xmin={} xmax={}", entity_id, xmin, xmax))
1413 };
1414 let change = match (in_from, in_to) {
1415 (false, true) => {
1416 added += 1;
1417 DiffChange::Added { after: payload }
1418 }
1419 (true, false) => {
1420 removed += 1;
1421 DiffChange::Removed { before: payload }
1422 }
1423 _ => unreachable!(),
1424 };
1425 entries.push(DiffEntry {
1426 collection: coll.clone(),
1427 entity_id,
1428 change,
1429 });
1430 }
1431 }
1432
1433 entries = coalesce_modifications(entries, &mut added, &mut removed, &mut modified);
1438
1439 Ok(Diff {
1440 from: from_hash,
1441 to: to_hash,
1442 entries,
1443 added,
1444 removed,
1445 modified,
1446 })
1447 }
1448
1449 pub fn vcs_status(&self, input: StatusInput) -> RedDBResult<Status> {
1450 let store_arc = self.inner.db.store();
1451 let store: &UnifiedStore = &store_arc;
1452 let workset = load_workset(store, input.connection_id);
1453 let (head_ref, head_commit) = match workset {
1454 Some((branch, base)) => {
1455 let base = base.or_else(|| load_ref(store, &branch).map(|r| r.target));
1456 (Some(branch), base)
1457 }
1458 None => {
1459 let base = load_ref(store, vc::DEFAULT_BRANCH_REF).map(|r| r.target);
1460 (Some(vc::DEFAULT_BRANCH_REF.to_string()), base)
1461 }
1462 };
1463 let detached = matches!(&head_ref, Some(s) if s.is_empty());
1464 Ok(Status {
1465 connection_id: input.connection_id,
1466 head_ref: head_ref.filter(|s| !s.is_empty()),
1467 head_commit,
1468 detached,
1469 staged_changes: 0,
1470 working_changes: 0,
1471 unresolved_conflicts: 0,
1472 merge_state_id: None,
1473 })
1474 }
1475
1476 pub fn vcs_lca(&self, a: &str, b: &str) -> RedDBResult<Option<CommitHash>> {
1477 let store_arc = self.inner.db.store();
1478 let store: &UnifiedStore = &store_arc;
1479 let a_hash = RedDBRuntime::vcs_resolve_commitish(self, a)?;
1480 let b_hash = RedDBRuntime::vcs_resolve_commitish(self, b)?;
1481 let a_ancestors = ancestor_set(store, &a_hash, 100_000);
1482
1483 let mut visited: HashSet<CommitHash> = HashSet::new();
1486 let mut stack: Vec<CommitHash> = vec![b_hash];
1487 let mut best: Option<(u64, CommitHash)> = None;
1488 while let Some(hash) = stack.pop() {
1489 if !visited.insert(hash.clone()) {
1490 continue;
1491 }
1492 if a_ancestors.contains(&hash) {
1493 let height = load_commit(store, &hash).map(|c| c.height).unwrap_or(0);
1494 match &best {
1495 Some((h, _)) if *h >= height => {}
1496 _ => best = Some((height, hash.clone())),
1497 }
1498 continue;
1501 }
1502 if let Some(commit) = load_commit(store, &hash) {
1503 for p in commit.parents {
1504 if !visited.contains(&p) {
1505 stack.push(p);
1506 }
1507 }
1508 }
1509 }
1510 Ok(best.map(|(_, h)| h))
1511 }
1512
1513 pub fn vcs_conflicts_list(&self, merge_state_id: &str) -> RedDBResult<Vec<Conflict>> {
1514 let store_arc = self.inner.db.store();
1515 let store: &UnifiedStore = &store_arc;
1516 let Some(manager) = store.get_collection(vc::CONFLICTS) else {
1517 return Ok(Vec::new());
1518 };
1519 let msid = merge_state_id.to_string();
1520 let out = manager
1521 .query_all(|entity| {
1522 entity
1523 .data
1524 .as_row()
1525 .is_some_and(|row| row_text(row, "merge_state_id").as_deref() == Some(&msid))
1526 })
1527 .into_iter()
1528 .filter_map(|entity| {
1529 let row = entity.data.as_row()?;
1530 Some(Conflict {
1531 id: row_text(row, "id")?,
1532 collection: row_text(row, "collection")?,
1533 entity_id: row_text(row, "entity_id").unwrap_or_default(),
1534 base: row_json(row, "base_json"),
1535 ours: row_json(row, "ours_json"),
1536 theirs: row_json(row, "theirs_json"),
1537 conflicting_paths: row_string_list(row, "conflicting_paths"),
1538 merge_state_id: row_text(row, "merge_state_id").unwrap_or_default(),
1539 })
1540 })
1541 .collect();
1542 Ok(out)
1543 }
1544
1545 pub fn vcs_conflict_resolve(&self, conflict_id: &str, _resolved: JsonValue) -> RedDBResult<()> {
1546 let store_arc = self.inner.db.store();
1547 let store: &UnifiedStore = &store_arc;
1548 let Some(manager) = store.get_collection(vc::CONFLICTS) else {
1549 return Err(RedDBError::NotFound(format!(
1550 "conflict `{conflict_id}` not found"
1551 )));
1552 };
1553 let cid = conflict_id.to_string();
1554 let mut deleted = 0usize;
1555 let matches = manager.query_all(|entity| {
1556 entity
1557 .data
1558 .as_row()
1559 .is_some_and(|row| row_text(row, "id").as_deref() == Some(&cid))
1560 });
1561 for entity in matches {
1562 store
1563 .delete(vc::CONFLICTS, entity.id)
1564 .map_err(|e| RedDBError::Internal(e.to_string()))?;
1565 deleted += 1;
1566 }
1567 if deleted == 0 {
1571 return Err(RedDBError::NotFound(format!(
1572 "conflict `{conflict_id}` not found"
1573 )));
1574 }
1575 Ok(())
1576 }
1577
1578 pub fn vcs_resolve_as_of(&self, spec: AsOfSpec) -> RedDBResult<Xid> {
1579 let store_arc = self.inner.db.store();
1580 let store: &UnifiedStore = &store_arc;
1581 match spec {
1582 AsOfSpec::Snapshot(x) => Ok(x),
1583 AsOfSpec::Commit(h) => {
1584 let c = load_commit(store, &h)
1585 .ok_or_else(|| RedDBError::NotFound(format!("commit `{h}` not found")))?;
1586 Ok(c.root_xid)
1587 }
1588 AsOfSpec::Branch(name) => {
1589 let full = normalize_branch_name(&name);
1590 let r = load_ref(store, &full).ok_or_else(|| {
1591 RedDBError::NotFound(format!("branch `{full}` does not exist"))
1592 })?;
1593 let c = load_commit(store, &r.target).ok_or_else(|| {
1594 RedDBError::NotFound(format!("branch `{full}` points to missing commit"))
1595 })?;
1596 Ok(c.root_xid)
1597 }
1598 AsOfSpec::Tag(name) => {
1599 let full = normalize_tag_name(&name);
1600 let r = load_ref(store, &full)
1601 .ok_or_else(|| RedDBError::NotFound(format!("tag `{full}` does not exist")))?;
1602 let c = load_commit(store, &r.target).ok_or_else(|| {
1603 RedDBError::NotFound(format!("tag `{full}` points to missing commit"))
1604 })?;
1605 Ok(c.root_xid)
1606 }
1607 AsOfSpec::TimestampMs(ts) => {
1608 let manager = store
1609 .get_collection(vc::COMMITS)
1610 .ok_or_else(|| RedDBError::NotFound("no commits exist yet".to_string()))?;
1611 let mut best: Option<(i64, Xid)> = None;
1613 let entities = manager.query_all(|_| true);
1614 for entity in entities {
1615 if let Some(row) = entity.data.as_row() {
1616 let t = row_i64(row, "timestamp_ms").unwrap_or(0);
1617 if t <= ts {
1618 let xid = row_u64(row, "root_xid").unwrap_or(0);
1619 match &best {
1620 Some((bt, _)) if *bt >= t => {}
1621 _ => best = Some((t, xid)),
1622 }
1623 }
1624 }
1625 }
1626 best.map(|(_, x)| x)
1627 .ok_or_else(|| RedDBError::NotFound(format!("no commit at or before ts={ts}")))
1628 }
1629 }
1630 }
1631
1632 pub fn vcs_set_versioned(&self, collection: &str, enabled: bool) -> RedDBResult<()> {
1633 let store_arc = self.inner.db.store();
1634 let store: &UnifiedStore = &store_arc;
1635 set_versioned_flag(store, collection, enabled)
1636 }
1637
1638 pub fn vcs_list_versioned(&self) -> RedDBResult<Vec<String>> {
1639 let store_arc = self.inner.db.store();
1640 let store: &UnifiedStore = &store_arc;
1641 let mut list = versioned_collections(store);
1642 list.sort();
1643 Ok(list)
1644 }
1645
1646 pub fn vcs_is_versioned(&self, collection: &str) -> RedDBResult<bool> {
1647 let store_arc = self.inner.db.store();
1648 let store: &UnifiedStore = &store_arc;
1649 Ok(is_versioned(store, collection))
1650 }
1651
1652 pub fn vcs_resolve_commitish(&self, spec: &str) -> RedDBResult<CommitHash> {
1653 let store_arc = self.inner.db.store();
1654 let store: &UnifiedStore = &store_arc;
1655 if spec.is_empty() {
1656 return Err(RedDBError::InvalidConfig("empty commitish".to_string()));
1657 }
1658
1659 if spec.len() == 64
1661 && spec.chars().all(|c| c.is_ascii_hexdigit())
1662 && load_commit(store, spec).is_some()
1663 {
1664 return Ok(spec.to_string());
1665 }
1666
1667 if spec.starts_with("refs/") {
1669 if let Some(hash) = resolve_ref_chain(store, spec) {
1670 return Ok(hash);
1671 }
1672 }
1673
1674 let normalized_branch = normalize_branch_name(spec);
1676 if let Some(hash) = resolve_ref_chain(store, &normalized_branch) {
1677 return Ok(hash);
1678 }
1679 let normalized_tag = normalize_tag_name(spec);
1680 if let Some(hash) = resolve_ref_chain(store, &normalized_tag) {
1681 return Ok(hash);
1682 }
1683
1684 if let Some(hash) = resolve_short_commit(store, spec) {
1686 return Ok(hash);
1687 }
1688
1689 Err(RedDBError::NotFound(format!(
1690 "commitish `{spec}` did not resolve to any ref or commit"
1691 )))
1692 }
1693}
1694
1695fn materialize_merge_conflicts(
1704 rt: &RedDBRuntime,
1705 store: &UnifiedStore,
1706 base_hash: &str,
1707 ours_hash: &str,
1708 theirs_hash: &str,
1709 merge_state_id: &str,
1710) -> RedDBResult<Vec<Conflict>> {
1711 use crate::application::merge_json::three_way_merge;
1712 use crate::application::vcs::AsOfSpec;
1713
1714 let base_xid = rt.vcs_resolve_as_of(AsOfSpec::Commit(base_hash.to_string()))?;
1715 let ours_xid = rt.vcs_resolve_as_of(AsOfSpec::Commit(ours_hash.to_string()))?;
1716 let theirs_xid = rt.vcs_resolve_as_of(AsOfSpec::Commit(theirs_hash.to_string()))?;
1717
1718 let sm = rt.inner.transaction_state.snapshot_manager();
1719 let base_snap = sm.snapshot(base_xid);
1720 let ours_snap = sm.snapshot(ours_xid);
1721 let theirs_snap = sm.snapshot(theirs_xid);
1722
1723 let mut conflicts: Vec<Conflict> = Vec::new();
1729 for coll in store.list_collections() {
1730 if coll.starts_with("red_") {
1731 continue;
1732 }
1733 if !is_versioned(store, &coll) {
1734 continue;
1735 }
1736 let Some(manager) = store.get_collection(&coll) else {
1737 continue;
1738 };
1739 let mut at_base: HashMap<u64, JsonValue> = HashMap::new();
1740 let mut at_ours: HashMap<u64, JsonValue> = HashMap::new();
1741 let mut at_theirs: HashMap<u64, JsonValue> = HashMap::new();
1742 for entity in manager.query_all(|_| true) {
1743 let xmin = entity.xmin;
1744 let xmax = entity.xmax;
1745 if sm.is_aborted(xmin) {
1746 continue;
1747 }
1748 let eid = entity.id.raw();
1749 let body = crate::entity_render::compact_entity_json(&entity);
1750 if base_snap.sees(xmin, xmax) {
1751 at_base.insert(eid, body.clone());
1752 }
1753 if ours_snap.sees(xmin, xmax) {
1754 at_ours.insert(eid, body.clone());
1755 }
1756 if theirs_snap.sees(xmin, xmax) {
1757 at_theirs.insert(eid, body);
1758 }
1759 }
1760
1761 let mut all_ids: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
1762 all_ids.extend(at_base.keys().copied());
1763 all_ids.extend(at_ours.keys().copied());
1764 all_ids.extend(at_theirs.keys().copied());
1765
1766 for eid in all_ids {
1767 let b = at_base.get(&eid).cloned().unwrap_or(JsonValue::Null);
1768 let o = at_ours.get(&eid).cloned().unwrap_or(JsonValue::Null);
1769 let t = at_theirs.get(&eid).cloned().unwrap_or(JsonValue::Null);
1770 let ours_changed = b != o;
1771 let theirs_changed = b != t;
1772 if !(ours_changed && theirs_changed) {
1773 continue;
1774 }
1775 if o == t {
1776 continue;
1777 }
1778 let merge = three_way_merge(&b, &o, &t);
1779 if merge.is_clean() {
1780 continue;
1784 }
1785 let conflict_id = format!("{}:{}/{}", merge_state_id, coll, eid);
1786 let paths: Vec<String> = merge
1787 .conflicts
1788 .iter()
1789 .map(|c| {
1790 if c.path.is_empty() {
1791 "*".to_string()
1792 } else {
1793 c.path.clone()
1794 }
1795 })
1796 .collect();
1797
1798 let mut fields: HashMap<String, Value> = HashMap::new();
1799 fields.insert("id".to_string(), Value::text(conflict_id.as_str()));
1800 fields.insert("collection".to_string(), Value::text(coll.as_str()));
1801 fields.insert(
1802 "entity_id".to_string(),
1803 Value::text(eid.to_string().as_str()),
1804 );
1805 fields.insert("merge_state_id".to_string(), Value::text(merge_state_id));
1806 fields.insert(
1807 "conflicting_paths".to_string(),
1808 Value::Array(paths.iter().map(|p| Value::text(p.as_str())).collect()),
1809 );
1810 if let Ok(bytes) = crate::json::to_vec(&b) {
1814 fields.insert("base_json".to_string(), Value::Json(bytes));
1815 }
1816 if let Ok(bytes) = crate::json::to_vec(&o) {
1817 fields.insert("ours_json".to_string(), Value::Json(bytes));
1818 }
1819 if let Ok(bytes) = crate::json::to_vec(&t) {
1820 fields.insert("theirs_json".to_string(), Value::Json(bytes));
1821 }
1822 insert_meta_row(store, vc::CONFLICTS, fields)?;
1823 conflicts.push(Conflict {
1824 id: conflict_id,
1825 collection: coll.clone(),
1826 entity_id: eid.to_string(),
1827 base: b,
1828 ours: o,
1829 theirs: t,
1830 conflicting_paths: paths,
1831 merge_state_id: merge_state_id.to_string(),
1832 });
1833 }
1834 }
1835 Ok(conflicts)
1836}
1837
1838fn coalesce_modifications(
1842 entries: Vec<DiffEntry>,
1843 added: &mut usize,
1844 removed: &mut usize,
1845 modified: &mut usize,
1846) -> Vec<DiffEntry> {
1847 let mut by_key: HashMap<(String, String), Vec<DiffEntry>> = HashMap::new();
1848 for e in entries {
1849 by_key
1850 .entry((e.collection.clone(), e.entity_id.clone()))
1851 .or_default()
1852 .push(e);
1853 }
1854 let mut out: Vec<DiffEntry> = Vec::new();
1855 for ((coll, eid), group) in by_key {
1856 if group.len() >= 2 {
1857 let mut before = JsonValue::Null;
1858 let mut after = JsonValue::Null;
1859 for item in group {
1860 match item.change {
1861 DiffChange::Removed { before: b } => before = b,
1862 DiffChange::Added { after: a } => after = a,
1863 DiffChange::Modified { .. } => {}
1864 }
1865 }
1866 *added = added.saturating_sub(1);
1867 *removed = removed.saturating_sub(1);
1868 *modified += 1;
1869 out.push(DiffEntry {
1870 collection: coll,
1871 entity_id: eid,
1872 change: DiffChange::Modified { before, after },
1873 });
1874 } else {
1875 out.extend(group);
1876 }
1877 }
1878 out.sort_by(|a, b| {
1879 a.collection
1880 .cmp(&b.collection)
1881 .then(a.entity_id.cmp(&b.entity_id))
1882 });
1883 out
1884}
1885
1886#[allow(dead_code)]
1888fn _touch_types(_: BTreeSet<()>) {}