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::Value as JsonValue;
27use crate::runtime::RedDBRuntime;
28use crate::storage::schema::Value;
29use crate::storage::transaction::snapshot::{Xid, XID_NONE};
30use crate::storage::unified::entity::{EntityData, EntityId, EntityKind, RowData, UnifiedEntity};
31use crate::storage::unified::UnifiedStore;
32use crate::{RedDBError, RedDBResult};
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 root_xid = self.inner.snapshot_manager.begin();
673 self.inner.snapshot_manager.commit(root_xid);
674 let timestamp_ms = now_ms();
675 let committer = input.committer.unwrap_or_else(|| input.author.clone());
676
677 let hash = compute_commit_hash(
678 root_xid,
679 &parents,
680 &input.author,
681 &input.message,
682 timestamp_ms,
683 );
684
685 if load_commit(store, &hash).is_some() {
686 return Err(RedDBError::InvalidConfig(format!(
687 "commit {hash} already exists (duplicate timestamp+content)"
688 )));
689 }
690
691 let commit = Commit {
692 hash: hash.clone(),
693 root_xid,
694 parents,
695 height,
696 author: input.author,
697 committer,
698 message: input.message,
699 timestamp_ms,
700 signature: None,
701 };
702
703 save_commit(store, &commit)?;
704
705 if root_xid != XID_NONE {
708 self.inner.snapshot_manager.pin(root_xid);
709 }
710
711 let branch_name = if branch_ref.is_empty() {
713 vc::DEFAULT_BRANCH_REF.to_string()
714 } else {
715 branch_ref
716 };
717 save_ref(
718 store,
719 &Ref {
720 name: branch_name.clone(),
721 kind: RefKind::Branch,
722 target: hash.clone(),
723 protected: false,
724 },
725 )?;
726
727 upsert_workset(
728 store,
729 input.connection_id,
730 &branch_name,
731 Some(&hash),
732 root_xid,
733 )?;
734
735 Ok(commit)
736 }
737
738 pub fn vcs_branch_create(&self, input: CreateBranchInput) -> RedDBResult<Ref> {
739 let store_arc = self.inner.db.store();
740 let store: &UnifiedStore = &store_arc;
741 let full_name = normalize_branch_name(&input.name);
742 if load_ref(store, &full_name).is_some() {
743 return Err(RedDBError::InvalidConfig(format!(
744 "branch `{full_name}` already exists"
745 )));
746 }
747 let target_hash = match &input.from {
748 Some(spec) => RedDBRuntime::vcs_resolve_commitish(self, spec)?,
749 None => {
750 let workset = load_workset(store, input.connection_id);
752
753 workset
754 .and_then(|(_, base)| base)
755 .or_else(|| load_ref(store, vc::DEFAULT_BRANCH_REF).map(|r| r.target))
756 .unwrap_or_default()
757 }
758 };
759 let r = Ref {
760 name: full_name,
761 kind: RefKind::Branch,
762 target: target_hash,
763 protected: false,
764 };
765 save_ref(store, &r)?;
766 Ok(r)
767 }
768
769 pub fn vcs_branch_delete(&self, name: &str) -> RedDBResult<()> {
770 let store_arc = self.inner.db.store();
771 let store: &UnifiedStore = &store_arc;
772 let full = normalize_branch_name(name);
773 let existing = load_ref(store, &full)
774 .ok_or_else(|| RedDBError::NotFound(format!("branch `{full}` does not exist")))?;
775 if existing.protected {
776 return Err(RedDBError::ReadOnly(format!(
777 "branch `{full}` is protected"
778 )));
779 }
780 delete_ref(store, &full)?;
781 Ok(())
782 }
783
784 pub fn vcs_tag_create(&self, input: CreateTagInput) -> RedDBResult<Ref> {
785 let store_arc = self.inner.db.store();
786 let store: &UnifiedStore = &store_arc;
787 let full_name = normalize_tag_name(&input.name);
788 if load_ref(store, &full_name).is_some() {
789 return Err(RedDBError::InvalidConfig(format!(
790 "tag `{full_name}` already exists"
791 )));
792 }
793 let target_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.target)?;
794 let r = Ref {
795 name: full_name,
796 kind: RefKind::Tag,
797 target: target_hash,
798 protected: false,
799 };
800 save_ref(store, &r)?;
801 Ok(r)
802 }
803
804 pub fn vcs_tag_delete(&self, name: &str) -> RedDBResult<()> {
805 let store_arc = self.inner.db.store();
806 let store: &UnifiedStore = &store_arc;
807 let full = normalize_tag_name(name);
808 let existing = load_ref(store, &full)
809 .ok_or_else(|| RedDBError::NotFound(format!("tag `{full}` does not exist")))?;
810 if existing.protected {
811 return Err(RedDBError::ReadOnly(format!("tag `{full}` is protected")));
812 }
813 delete_ref(store, &full)?;
814 Ok(())
815 }
816
817 pub fn vcs_list_refs(&self, prefix: Option<&str>) -> RedDBResult<Vec<Ref>> {
818 let store_arc = self.inner.db.store();
819 let store: &UnifiedStore = &store_arc;
820 Ok(list_refs_by_prefix(store, prefix))
821 }
822
823 pub fn vcs_checkout(&self, input: CheckoutInput) -> RedDBResult<Ref> {
824 let store_arc = self.inner.db.store();
825 let store: &UnifiedStore = &store_arc;
826 let (branch_ref, target_hash) = match &input.target {
827 CheckoutTarget::Branch(name) => {
828 let full = normalize_branch_name(name);
829 let r = load_ref(store, &full).ok_or_else(|| {
830 RedDBError::NotFound(format!("branch `{full}` does not exist"))
831 })?;
832 (full, r.target)
833 }
834 CheckoutTarget::Tag(name) => {
835 let full = normalize_tag_name(name);
836 let r = load_ref(store, &full)
837 .ok_or_else(|| RedDBError::NotFound(format!("tag `{full}` does not exist")))?;
838 (full, r.target)
839 }
840 CheckoutTarget::Commit(hash) => {
841 let resolved = RedDBRuntime::vcs_resolve_commitish(self, hash)?;
842 (String::new(), resolved)
845 }
846 };
847
848 upsert_workset(
849 store,
850 input.connection_id,
851 &branch_ref,
852 Some(&target_hash),
853 self.inner.snapshot_manager.peek_next_xid(),
854 )?;
855
856 save_ref(
858 store,
859 &Ref {
860 name: head_ref_id(input.connection_id),
861 kind: RefKind::Head,
862 target: if branch_ref.is_empty() {
863 target_hash.clone()
864 } else {
865 branch_ref.clone()
866 },
867 protected: false,
868 },
869 )?;
870
871 Ok(Ref {
872 name: if branch_ref.is_empty() {
873 format!("detached:{target_hash}")
874 } else {
875 branch_ref
876 },
877 kind: if target_hash.is_empty() {
878 RefKind::Head
879 } else {
880 RefKind::Branch
881 },
882 target: target_hash,
883 protected: false,
884 })
885 }
886
887 pub fn vcs_merge(&self, input: MergeInput) -> RedDBResult<MergeOutcome> {
888 let store_arc = self.inner.db.store();
889 let store: &UnifiedStore = &store_arc;
890
891 let from_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.from)?;
893 let from_commit = load_commit(store, &from_hash).ok_or_else(|| {
894 RedDBError::NotFound(format!("source commit `{from_hash}` not found"))
895 })?;
896
897 let workset = load_workset(store, input.connection_id);
899 let (head_branch, head_hash) = match workset {
900 Some((branch, Some(head))) => (branch, head),
901 Some((branch, None)) => {
902 let head = load_ref(store, &branch)
903 .map(|r| r.target)
904 .unwrap_or_default();
905 (branch, head)
906 }
907 None => {
908 let head = load_ref(store, vc::DEFAULT_BRANCH_REF)
909 .map(|r| r.target)
910 .unwrap_or_default();
911 (vc::DEFAULT_BRANCH_REF.to_string(), head)
912 }
913 };
914 if head_hash.is_empty() {
915 return Err(RedDBError::InvalidConfig(
916 "cannot merge: HEAD has no commits".to_string(),
917 ));
918 }
919
920 let from_ancestors = ancestor_set(store, &from_hash, 100_000);
922 let can_fast_forward = from_ancestors.contains(&head_hash);
923
924 match input.opts.strategy {
925 MergeStrategy::FastForwardOnly if !can_fast_forward => {
926 return Err(RedDBError::InvalidConfig(
927 "not a fast-forward — use --strategy auto or no-ff".to_string(),
928 ));
929 }
930 _ => {}
931 }
932
933 if can_fast_forward && input.opts.strategy != MergeStrategy::NoFastForward {
934 if head_branch.is_empty() {
935 return Err(RedDBError::InvalidConfig(
936 "cannot fast-forward a detached HEAD".to_string(),
937 ));
938 }
939 save_ref(
940 store,
941 &Ref {
942 name: head_branch.clone(),
943 kind: RefKind::Branch,
944 target: from_hash.clone(),
945 protected: false,
946 },
947 )?;
948 upsert_workset(
949 store,
950 input.connection_id,
951 &head_branch,
952 Some(&from_hash),
953 from_commit.root_xid,
954 )?;
955 return Ok(MergeOutcome {
956 merge_commit: Some(from_commit),
957 fast_forward: true,
958 conflicts: Vec::new(),
959 merge_state_id: None,
960 });
961 }
962
963 let lca = RedDBRuntime::vcs_lca(self, &head_hash, &from_hash)?;
971
972 let message = input
973 .opts
974 .message
975 .unwrap_or_else(|| format!("Merge {} into {}", input.from, head_branch));
976 let author = input.author.clone();
977 let timestamp_ms = now_ms();
978 let parents = vec![head_hash.clone(), from_hash.clone()];
979 let parent_height = parents
980 .iter()
981 .filter_map(|p| load_commit(store, p).map(|c| c.height))
982 .max()
983 .unwrap_or(0);
984 let height = parent_height + 1;
985 let root_xid = self.inner.snapshot_manager.begin();
986 self.inner.snapshot_manager.commit(root_xid);
987
988 let hash = compute_commit_hash(root_xid, &parents, &author, &message, timestamp_ms);
989
990 let merge_commit = Commit {
991 hash: hash.clone(),
992 root_xid,
993 parents,
994 height,
995 author: author.clone(),
996 committer: author,
997 message,
998 timestamp_ms,
999 signature: None,
1000 };
1001
1002 save_commit(store, &merge_commit)?;
1007 if root_xid != XID_NONE {
1008 self.inner.snapshot_manager.pin(root_xid);
1009 }
1010 if !head_branch.is_empty() {
1011 save_ref(
1012 store,
1013 &Ref {
1014 name: head_branch.clone(),
1015 kind: RefKind::Branch,
1016 target: hash.clone(),
1017 protected: false,
1018 },
1019 )?;
1020 }
1021 upsert_workset(
1022 store,
1023 input.connection_id,
1024 &head_branch,
1025 Some(&hash),
1026 root_xid,
1027 )?;
1028
1029 let merge_state_id = format!("ms:{}", &hash[..16]);
1030
1031 let conflicts = if let Some(lca_hash) = &lca {
1036 materialize_merge_conflicts(
1037 self,
1038 store,
1039 lca_hash,
1040 &head_hash,
1041 &from_hash,
1042 &merge_state_id,
1043 )?
1044 } else {
1045 Vec::new()
1046 };
1047
1048 let mut ms_fields: HashMap<String, Value> = HashMap::new();
1049 ms_fields.insert("id".to_string(), Value::text(merge_state_id.as_str()));
1050 ms_fields.insert("kind".to_string(), Value::text("merge"));
1051 ms_fields.insert("branch".to_string(), Value::text(head_branch.as_str()));
1052 if let Some(base_hash) = &lca {
1053 ms_fields.insert("base".to_string(), Value::text(base_hash.as_str()));
1054 }
1055 ms_fields.insert("ours".to_string(), Value::text(head_hash.as_str()));
1056 ms_fields.insert("theirs".to_string(), Value::text(from_hash.as_str()));
1057 ms_fields.insert(
1058 "conflicts_count".to_string(),
1059 Value::UnsignedInteger(conflicts.len() as u64),
1060 );
1061 insert_meta_row(store, vc::MERGE_STATE, ms_fields)?;
1062
1063 Ok(MergeOutcome {
1064 merge_commit: Some(merge_commit),
1065 fast_forward: false,
1066 conflicts,
1067 merge_state_id: Some(merge_state_id),
1068 })
1069 }
1070
1071 pub fn vcs_cherry_pick(
1072 &self,
1073 connection_id: u64,
1074 commit: &str,
1075 author: Author,
1076 ) -> RedDBResult<MergeOutcome> {
1077 let store_arc = self.inner.db.store();
1078 let store: &UnifiedStore = &store_arc;
1079
1080 let src_hash = RedDBRuntime::vcs_resolve_commitish(self, commit)?;
1082 let src_commit = load_commit(store, &src_hash).ok_or_else(|| {
1083 RedDBError::NotFound(format!("cherry-pick source `{src_hash}` not found"))
1084 })?;
1085 if src_commit.parents.is_empty() {
1086 return Err(RedDBError::InvalidConfig(
1087 "cannot cherry-pick a root commit".to_string(),
1088 ));
1089 }
1090 if src_commit.parents.len() > 1 {
1091 return Err(RedDBError::InvalidConfig(
1092 "cannot cherry-pick a merge commit; resolve manually".to_string(),
1093 ));
1094 }
1095 let parent_hash = src_commit.parents[0].clone();
1096
1097 let workset = load_workset(store, connection_id);
1099 let (head_branch, head_hash) = match workset {
1100 Some((branch, Some(head))) => (branch, head),
1101 Some((branch, None)) => {
1102 let head = load_ref(store, &branch)
1103 .map(|r| r.target)
1104 .unwrap_or_default();
1105 (branch, head)
1106 }
1107 None => {
1108 let head = load_ref(store, vc::DEFAULT_BRANCH_REF)
1109 .map(|r| r.target)
1110 .unwrap_or_default();
1111 (vc::DEFAULT_BRANCH_REF.to_string(), head)
1112 }
1113 };
1114 if head_hash.is_empty() {
1115 return Err(RedDBError::InvalidConfig(
1116 "cannot cherry-pick onto empty HEAD".to_string(),
1117 ));
1118 }
1119
1120 let message = format!("cherry-pick: {}", src_commit.message);
1125 let parents = vec![head_hash.clone()];
1126 let parent_height = load_commit(store, &head_hash)
1127 .map(|c| c.height)
1128 .unwrap_or(0);
1129 let height = parent_height + 1;
1130 let root_xid = self.inner.snapshot_manager.begin();
1131 self.inner.snapshot_manager.commit(root_xid);
1132 let timestamp_ms = now_ms();
1133
1134 let hash = compute_commit_hash(root_xid, &parents, &author, &message, timestamp_ms);
1135 let pick_commit = Commit {
1136 hash: hash.clone(),
1137 root_xid,
1138 parents,
1139 height,
1140 author: author.clone(),
1141 committer: author,
1142 message,
1143 timestamp_ms,
1144 signature: None,
1145 };
1146 save_commit(store, &pick_commit)?;
1147 if root_xid != XID_NONE {
1148 self.inner.snapshot_manager.pin(root_xid);
1149 }
1150 if !head_branch.is_empty() {
1151 save_ref(
1152 store,
1153 &Ref {
1154 name: head_branch.clone(),
1155 kind: RefKind::Branch,
1156 target: hash.clone(),
1157 protected: false,
1158 },
1159 )?;
1160 }
1161 upsert_workset(store, connection_id, &head_branch, Some(&hash), root_xid)?;
1162
1163 let merge_state_id = format!("cp:{}", &hash[..16]);
1164 let conflicts = materialize_merge_conflicts(
1165 self,
1166 store,
1167 &parent_hash,
1168 &head_hash,
1169 &src_hash,
1170 &merge_state_id,
1171 )?;
1172
1173 let mut ms_fields: HashMap<String, Value> = HashMap::new();
1174 ms_fields.insert("id".to_string(), Value::text(merge_state_id.as_str()));
1175 ms_fields.insert("kind".to_string(), Value::text("cherry_pick"));
1176 ms_fields.insert("branch".to_string(), Value::text(head_branch.as_str()));
1177 ms_fields.insert("base".to_string(), Value::text(parent_hash.as_str()));
1178 ms_fields.insert("ours".to_string(), Value::text(head_hash.as_str()));
1179 ms_fields.insert("theirs".to_string(), Value::text(src_hash.as_str()));
1180 ms_fields.insert(
1181 "conflicts_count".to_string(),
1182 Value::UnsignedInteger(conflicts.len() as u64),
1183 );
1184 insert_meta_row(store, vc::MERGE_STATE, ms_fields)?;
1185
1186 Ok(MergeOutcome {
1187 merge_commit: Some(pick_commit),
1188 fast_forward: false,
1189 conflicts,
1190 merge_state_id: Some(merge_state_id),
1191 })
1192 }
1193
1194 pub fn vcs_revert(
1195 &self,
1196 connection_id: u64,
1197 commit: &str,
1198 author: Author,
1199 ) -> RedDBResult<Commit> {
1200 let store_arc = self.inner.db.store();
1201 let store: &UnifiedStore = &store_arc;
1202
1203 let src_hash = RedDBRuntime::vcs_resolve_commitish(self, commit)?;
1204 let src_commit = load_commit(store, &src_hash)
1205 .ok_or_else(|| RedDBError::NotFound(format!("revert source `{src_hash}` not found")))?;
1206 if src_commit.parents.is_empty() {
1207 return Err(RedDBError::InvalidConfig(
1208 "cannot revert a root commit".to_string(),
1209 ));
1210 }
1211 let parent_hash = src_commit.parents[0].clone();
1212
1213 let workset = load_workset(store, connection_id);
1214 let (head_branch, head_hash) = match workset {
1215 Some((branch, Some(head))) => (branch, head),
1216 Some((branch, None)) => {
1217 let head = load_ref(store, &branch)
1218 .map(|r| r.target)
1219 .unwrap_or_default();
1220 (branch, head)
1221 }
1222 None => {
1223 let head = load_ref(store, vc::DEFAULT_BRANCH_REF)
1224 .map(|r| r.target)
1225 .unwrap_or_default();
1226 (vc::DEFAULT_BRANCH_REF.to_string(), head)
1227 }
1228 };
1229 if head_hash.is_empty() {
1230 return Err(RedDBError::InvalidConfig(
1231 "cannot revert onto empty HEAD".to_string(),
1232 ));
1233 }
1234
1235 let message = format!("Revert \"{}\"", src_commit.message);
1240 let parents = vec![head_hash.clone()];
1241 let parent_height = load_commit(store, &head_hash)
1242 .map(|c| c.height)
1243 .unwrap_or(0);
1244 let height = parent_height + 1;
1245 let root_xid = self.inner.snapshot_manager.begin();
1246 self.inner.snapshot_manager.commit(root_xid);
1247 let timestamp_ms = now_ms();
1248
1249 let hash = compute_commit_hash(root_xid, &parents, &author, &message, timestamp_ms);
1250 let rv_commit = Commit {
1251 hash: hash.clone(),
1252 root_xid,
1253 parents,
1254 height,
1255 author: author.clone(),
1256 committer: author,
1257 message,
1258 timestamp_ms,
1259 signature: None,
1260 };
1261 save_commit(store, &rv_commit)?;
1262 if root_xid != XID_NONE {
1263 self.inner.snapshot_manager.pin(root_xid);
1264 }
1265 if !head_branch.is_empty() {
1266 save_ref(
1267 store,
1268 &Ref {
1269 name: head_branch.clone(),
1270 kind: RefKind::Branch,
1271 target: hash.clone(),
1272 protected: false,
1273 },
1274 )?;
1275 }
1276 upsert_workset(store, connection_id, &head_branch, Some(&hash), root_xid)?;
1277
1278 let merge_state_id = format!("rv:{}", &hash[..16]);
1279 let mut ms_fields: HashMap<String, Value> = HashMap::new();
1283 ms_fields.insert("id".to_string(), Value::text(merge_state_id.as_str()));
1284 ms_fields.insert("kind".to_string(), Value::text("revert"));
1285 ms_fields.insert("branch".to_string(), Value::text(head_branch.as_str()));
1286 ms_fields.insert("base".to_string(), Value::text(src_hash.as_str()));
1287 ms_fields.insert("ours".to_string(), Value::text(head_hash.as_str()));
1288 ms_fields.insert("theirs".to_string(), Value::text(parent_hash.as_str()));
1289 ms_fields.insert("conflicts_count".to_string(), Value::UnsignedInteger(0));
1290 insert_meta_row(store, vc::MERGE_STATE, ms_fields)?;
1291
1292 Ok(rv_commit)
1293 }
1294
1295 pub fn vcs_reset(&self, input: ResetInput) -> RedDBResult<()> {
1296 let store_arc = self.inner.db.store();
1297 let store: &UnifiedStore = &store_arc;
1298 let target_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.target)?;
1299 let target_commit = load_commit(store, &target_hash).ok_or_else(|| {
1300 RedDBError::NotFound(format!("target commit `{target_hash}` not found"))
1301 })?;
1302
1303 let workset = load_workset(store, input.connection_id);
1305 let branch = workset
1306 .as_ref()
1307 .map(|(b, _)| b.clone())
1308 .unwrap_or_else(|| vc::DEFAULT_BRANCH_REF.to_string());
1309
1310 match input.mode {
1314 ResetMode::Soft | ResetMode::Mixed => {
1315 if !branch.is_empty() {
1316 save_ref(
1317 store,
1318 &Ref {
1319 name: branch.clone(),
1320 kind: RefKind::Branch,
1321 target: target_hash.clone(),
1322 protected: false,
1323 },
1324 )?;
1325 }
1326 upsert_workset(
1327 store,
1328 input.connection_id,
1329 &branch,
1330 Some(&target_hash),
1331 target_commit.root_xid,
1332 )?;
1333 Ok(())
1334 }
1335 ResetMode::Hard => Err(unimplemented("reset --hard (Phase 4)")),
1336 }
1337 }
1338
1339 pub fn vcs_log(&self, input: LogInput) -> RedDBResult<Vec<Commit>> {
1340 let store_arc = self.inner.db.store();
1341 let store: &UnifiedStore = &store_arc;
1342 let start = match &input.range.to {
1343 Some(spec) => RedDBRuntime::vcs_resolve_commitish(self, spec)?,
1344 None => {
1345 let workset = load_workset(store, input.connection_id);
1346 workset
1347 .and_then(|(_, base)| base)
1348 .or_else(|| load_ref(store, vc::DEFAULT_BRANCH_REF).map(|r| r.target))
1349 .unwrap_or_default()
1350 }
1351 };
1352 if start.is_empty() {
1353 return Ok(Vec::new());
1354 }
1355 Ok(topo_walk(store, &start, &input.range))
1356 }
1357
1358 pub fn vcs_diff(&self, input: DiffInput) -> RedDBResult<Diff> {
1359 let store_arc = self.inner.db.store();
1360 let store: &UnifiedStore = &store_arc;
1361 let from_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.from)?;
1362 let to_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.to)?;
1363 let from_xid = RedDBRuntime::vcs_resolve_as_of(self, AsOfSpec::Commit(from_hash.clone()))?;
1364 let to_xid = RedDBRuntime::vcs_resolve_as_of(self, AsOfSpec::Commit(to_hash.clone()))?;
1365
1366 let sm = &self.inner.snapshot_manager;
1367 let from_snap = sm.snapshot(from_xid);
1368 let to_snap = sm.snapshot(to_xid);
1369
1370 let mut entries: Vec<DiffEntry> = Vec::new();
1372 let mut added = 0usize;
1373 let mut removed = 0usize;
1374 let mut modified = 0usize;
1375 let collections = store.list_collections();
1376 for coll in collections {
1377 if coll.starts_with("red_") {
1378 continue;
1379 }
1380 if !is_versioned(store, &coll) {
1381 continue;
1382 }
1383 if let Some(filter) = &input.collection {
1384 if filter != &coll {
1385 continue;
1386 }
1387 }
1388 let Some(manager) = store.get_collection(&coll) else {
1389 continue;
1390 };
1391 let entities = manager.query_all(|_| true);
1392 for entity in &entities {
1394 let xmin = entity.xmin;
1395 let xmax = entity.xmax;
1396 let in_from = from_snap.sees(xmin, xmax) && !sm.is_aborted(xmin);
1397 let in_to = to_snap.sees(xmin, xmax) && !sm.is_aborted(xmin);
1398 if in_from == in_to {
1399 continue;
1400 }
1401 let entity_id = entity.id.raw().to_string();
1402 let payload = if input.summary_only {
1403 JsonValue::Null
1404 } else {
1405 JsonValue::String(format!("entity#{} xmin={} xmax={}", entity_id, xmin, xmax))
1406 };
1407 let change = match (in_from, in_to) {
1408 (false, true) => {
1409 added += 1;
1410 DiffChange::Added { after: payload }
1411 }
1412 (true, false) => {
1413 removed += 1;
1414 DiffChange::Removed { before: payload }
1415 }
1416 _ => unreachable!(),
1417 };
1418 entries.push(DiffEntry {
1419 collection: coll.clone(),
1420 entity_id,
1421 change,
1422 });
1423 }
1424 }
1425
1426 entries = coalesce_modifications(entries, &mut added, &mut removed, &mut modified);
1431
1432 Ok(Diff {
1433 from: from_hash,
1434 to: to_hash,
1435 entries,
1436 added,
1437 removed,
1438 modified,
1439 })
1440 }
1441
1442 pub fn vcs_status(&self, input: StatusInput) -> RedDBResult<Status> {
1443 let store_arc = self.inner.db.store();
1444 let store: &UnifiedStore = &store_arc;
1445 let workset = load_workset(store, input.connection_id);
1446 let (head_ref, head_commit) = match workset {
1447 Some((branch, base)) => {
1448 let base = base.or_else(|| load_ref(store, &branch).map(|r| r.target));
1449 (Some(branch), base)
1450 }
1451 None => {
1452 let base = load_ref(store, vc::DEFAULT_BRANCH_REF).map(|r| r.target);
1453 (Some(vc::DEFAULT_BRANCH_REF.to_string()), base)
1454 }
1455 };
1456 let detached = matches!(&head_ref, Some(s) if s.is_empty());
1457 Ok(Status {
1458 connection_id: input.connection_id,
1459 head_ref: head_ref.filter(|s| !s.is_empty()),
1460 head_commit,
1461 detached,
1462 staged_changes: 0,
1463 working_changes: 0,
1464 unresolved_conflicts: 0,
1465 merge_state_id: None,
1466 })
1467 }
1468
1469 pub fn vcs_lca(&self, a: &str, b: &str) -> RedDBResult<Option<CommitHash>> {
1470 let store_arc = self.inner.db.store();
1471 let store: &UnifiedStore = &store_arc;
1472 let a_hash = RedDBRuntime::vcs_resolve_commitish(self, a)?;
1473 let b_hash = RedDBRuntime::vcs_resolve_commitish(self, b)?;
1474 let a_ancestors = ancestor_set(store, &a_hash, 100_000);
1475
1476 let mut visited: HashSet<CommitHash> = HashSet::new();
1479 let mut stack: Vec<CommitHash> = vec![b_hash];
1480 let mut best: Option<(u64, CommitHash)> = None;
1481 while let Some(hash) = stack.pop() {
1482 if !visited.insert(hash.clone()) {
1483 continue;
1484 }
1485 if a_ancestors.contains(&hash) {
1486 let height = load_commit(store, &hash).map(|c| c.height).unwrap_or(0);
1487 match &best {
1488 Some((h, _)) if *h >= height => {}
1489 _ => best = Some((height, hash.clone())),
1490 }
1491 continue;
1494 }
1495 if let Some(commit) = load_commit(store, &hash) {
1496 for p in commit.parents {
1497 if !visited.contains(&p) {
1498 stack.push(p);
1499 }
1500 }
1501 }
1502 }
1503 Ok(best.map(|(_, h)| h))
1504 }
1505
1506 pub fn vcs_conflicts_list(&self, merge_state_id: &str) -> RedDBResult<Vec<Conflict>> {
1507 let store_arc = self.inner.db.store();
1508 let store: &UnifiedStore = &store_arc;
1509 let Some(manager) = store.get_collection(vc::CONFLICTS) else {
1510 return Ok(Vec::new());
1511 };
1512 let msid = merge_state_id.to_string();
1513 let out = manager
1514 .query_all(|entity| {
1515 entity
1516 .data
1517 .as_row()
1518 .is_some_and(|row| row_text(row, "merge_state_id").as_deref() == Some(&msid))
1519 })
1520 .into_iter()
1521 .filter_map(|entity| {
1522 let row = entity.data.as_row()?;
1523 Some(Conflict {
1524 id: row_text(row, "id")?,
1525 collection: row_text(row, "collection")?,
1526 entity_id: row_text(row, "entity_id").unwrap_or_default(),
1527 base: row_json(row, "base_json"),
1528 ours: row_json(row, "ours_json"),
1529 theirs: row_json(row, "theirs_json"),
1530 conflicting_paths: row_string_list(row, "conflicting_paths"),
1531 merge_state_id: row_text(row, "merge_state_id").unwrap_or_default(),
1532 })
1533 })
1534 .collect();
1535 Ok(out)
1536 }
1537
1538 pub fn vcs_conflict_resolve(&self, conflict_id: &str, _resolved: JsonValue) -> RedDBResult<()> {
1539 let store_arc = self.inner.db.store();
1540 let store: &UnifiedStore = &store_arc;
1541 let Some(manager) = store.get_collection(vc::CONFLICTS) else {
1542 return Err(RedDBError::NotFound(format!(
1543 "conflict `{conflict_id}` not found"
1544 )));
1545 };
1546 let cid = conflict_id.to_string();
1547 let mut deleted = 0usize;
1548 let matches = manager.query_all(|entity| {
1549 entity
1550 .data
1551 .as_row()
1552 .is_some_and(|row| row_text(row, "id").as_deref() == Some(&cid))
1553 });
1554 for entity in matches {
1555 store
1556 .delete(vc::CONFLICTS, entity.id)
1557 .map_err(|e| RedDBError::Internal(e.to_string()))?;
1558 deleted += 1;
1559 }
1560 if deleted == 0 {
1564 return Err(RedDBError::NotFound(format!(
1565 "conflict `{conflict_id}` not found"
1566 )));
1567 }
1568 Ok(())
1569 }
1570
1571 pub fn vcs_resolve_as_of(&self, spec: AsOfSpec) -> RedDBResult<Xid> {
1572 let store_arc = self.inner.db.store();
1573 let store: &UnifiedStore = &store_arc;
1574 match spec {
1575 AsOfSpec::Snapshot(x) => Ok(x),
1576 AsOfSpec::Commit(h) => {
1577 let c = load_commit(store, &h)
1578 .ok_or_else(|| RedDBError::NotFound(format!("commit `{h}` not found")))?;
1579 Ok(c.root_xid)
1580 }
1581 AsOfSpec::Branch(name) => {
1582 let full = normalize_branch_name(&name);
1583 let r = load_ref(store, &full).ok_or_else(|| {
1584 RedDBError::NotFound(format!("branch `{full}` does not exist"))
1585 })?;
1586 let c = load_commit(store, &r.target).ok_or_else(|| {
1587 RedDBError::NotFound(format!("branch `{full}` points to missing commit"))
1588 })?;
1589 Ok(c.root_xid)
1590 }
1591 AsOfSpec::Tag(name) => {
1592 let full = normalize_tag_name(&name);
1593 let r = load_ref(store, &full)
1594 .ok_or_else(|| RedDBError::NotFound(format!("tag `{full}` does not exist")))?;
1595 let c = load_commit(store, &r.target).ok_or_else(|| {
1596 RedDBError::NotFound(format!("tag `{full}` points to missing commit"))
1597 })?;
1598 Ok(c.root_xid)
1599 }
1600 AsOfSpec::TimestampMs(ts) => {
1601 let manager = store
1602 .get_collection(vc::COMMITS)
1603 .ok_or_else(|| RedDBError::NotFound("no commits exist yet".to_string()))?;
1604 let mut best: Option<(i64, Xid)> = None;
1606 let entities = manager.query_all(|_| true);
1607 for entity in entities {
1608 if let Some(row) = entity.data.as_row() {
1609 let t = row_i64(row, "timestamp_ms").unwrap_or(0);
1610 if t <= ts {
1611 let xid = row_u64(row, "root_xid").unwrap_or(0);
1612 match &best {
1613 Some((bt, _)) if *bt >= t => {}
1614 _ => best = Some((t, xid)),
1615 }
1616 }
1617 }
1618 }
1619 best.map(|(_, x)| x)
1620 .ok_or_else(|| RedDBError::NotFound(format!("no commit at or before ts={ts}")))
1621 }
1622 }
1623 }
1624
1625 pub fn vcs_set_versioned(&self, collection: &str, enabled: bool) -> RedDBResult<()> {
1626 let store_arc = self.inner.db.store();
1627 let store: &UnifiedStore = &store_arc;
1628 set_versioned_flag(store, collection, enabled)
1629 }
1630
1631 pub fn vcs_list_versioned(&self) -> RedDBResult<Vec<String>> {
1632 let store_arc = self.inner.db.store();
1633 let store: &UnifiedStore = &store_arc;
1634 let mut list = versioned_collections(store);
1635 list.sort();
1636 Ok(list)
1637 }
1638
1639 pub fn vcs_is_versioned(&self, collection: &str) -> RedDBResult<bool> {
1640 let store_arc = self.inner.db.store();
1641 let store: &UnifiedStore = &store_arc;
1642 Ok(is_versioned(store, collection))
1643 }
1644
1645 pub fn vcs_resolve_commitish(&self, spec: &str) -> RedDBResult<CommitHash> {
1646 let store_arc = self.inner.db.store();
1647 let store: &UnifiedStore = &store_arc;
1648 if spec.is_empty() {
1649 return Err(RedDBError::InvalidConfig("empty commitish".to_string()));
1650 }
1651
1652 if spec.len() == 64
1654 && spec.chars().all(|c| c.is_ascii_hexdigit())
1655 && load_commit(store, spec).is_some()
1656 {
1657 return Ok(spec.to_string());
1658 }
1659
1660 if spec.starts_with("refs/") {
1662 if let Some(hash) = resolve_ref_chain(store, spec) {
1663 return Ok(hash);
1664 }
1665 }
1666
1667 let normalized_branch = normalize_branch_name(spec);
1669 if let Some(hash) = resolve_ref_chain(store, &normalized_branch) {
1670 return Ok(hash);
1671 }
1672 let normalized_tag = normalize_tag_name(spec);
1673 if let Some(hash) = resolve_ref_chain(store, &normalized_tag) {
1674 return Ok(hash);
1675 }
1676
1677 if let Some(hash) = resolve_short_commit(store, spec) {
1679 return Ok(hash);
1680 }
1681
1682 Err(RedDBError::NotFound(format!(
1683 "commitish `{spec}` did not resolve to any ref or commit"
1684 )))
1685 }
1686}
1687
1688fn materialize_merge_conflicts(
1697 rt: &RedDBRuntime,
1698 store: &UnifiedStore,
1699 base_hash: &str,
1700 ours_hash: &str,
1701 theirs_hash: &str,
1702 merge_state_id: &str,
1703) -> RedDBResult<Vec<Conflict>> {
1704 use crate::application::merge_json::three_way_merge;
1705 use crate::application::vcs::AsOfSpec;
1706
1707 let base_xid = rt.vcs_resolve_as_of(AsOfSpec::Commit(base_hash.to_string()))?;
1708 let ours_xid = rt.vcs_resolve_as_of(AsOfSpec::Commit(ours_hash.to_string()))?;
1709 let theirs_xid = rt.vcs_resolve_as_of(AsOfSpec::Commit(theirs_hash.to_string()))?;
1710
1711 let sm = &rt.inner.snapshot_manager;
1712 let base_snap = sm.snapshot(base_xid);
1713 let ours_snap = sm.snapshot(ours_xid);
1714 let theirs_snap = sm.snapshot(theirs_xid);
1715
1716 let mut conflicts: Vec<Conflict> = Vec::new();
1722 for coll in store.list_collections() {
1723 if coll.starts_with("red_") {
1724 continue;
1725 }
1726 if !is_versioned(store, &coll) {
1727 continue;
1728 }
1729 let Some(manager) = store.get_collection(&coll) else {
1730 continue;
1731 };
1732 let mut at_base: HashMap<u64, JsonValue> = HashMap::new();
1733 let mut at_ours: HashMap<u64, JsonValue> = HashMap::new();
1734 let mut at_theirs: HashMap<u64, JsonValue> = HashMap::new();
1735 for entity in manager.query_all(|_| true) {
1736 let xmin = entity.xmin;
1737 let xmax = entity.xmax;
1738 if sm.is_aborted(xmin) {
1739 continue;
1740 }
1741 let eid = entity.id.raw();
1742 let body = crate::presentation::entity_json::compact_entity_json(&entity);
1743 if base_snap.sees(xmin, xmax) {
1744 at_base.insert(eid, body.clone());
1745 }
1746 if ours_snap.sees(xmin, xmax) {
1747 at_ours.insert(eid, body.clone());
1748 }
1749 if theirs_snap.sees(xmin, xmax) {
1750 at_theirs.insert(eid, body);
1751 }
1752 }
1753
1754 let mut all_ids: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
1755 all_ids.extend(at_base.keys().copied());
1756 all_ids.extend(at_ours.keys().copied());
1757 all_ids.extend(at_theirs.keys().copied());
1758
1759 for eid in all_ids {
1760 let b = at_base.get(&eid).cloned().unwrap_or(JsonValue::Null);
1761 let o = at_ours.get(&eid).cloned().unwrap_or(JsonValue::Null);
1762 let t = at_theirs.get(&eid).cloned().unwrap_or(JsonValue::Null);
1763 let ours_changed = b != o;
1764 let theirs_changed = b != t;
1765 if !(ours_changed && theirs_changed) {
1766 continue;
1767 }
1768 if o == t {
1769 continue;
1770 }
1771 let merge = three_way_merge(&b, &o, &t);
1772 if merge.is_clean() {
1773 continue;
1777 }
1778 let conflict_id = format!("{}:{}/{}", merge_state_id, coll, eid);
1779 let paths: Vec<String> = merge
1780 .conflicts
1781 .iter()
1782 .map(|c| {
1783 if c.path.is_empty() {
1784 "*".to_string()
1785 } else {
1786 c.path.clone()
1787 }
1788 })
1789 .collect();
1790
1791 let mut fields: HashMap<String, Value> = HashMap::new();
1792 fields.insert("id".to_string(), Value::text(conflict_id.as_str()));
1793 fields.insert("collection".to_string(), Value::text(coll.as_str()));
1794 fields.insert(
1795 "entity_id".to_string(),
1796 Value::text(eid.to_string().as_str()),
1797 );
1798 fields.insert("merge_state_id".to_string(), Value::text(merge_state_id));
1799 fields.insert(
1800 "conflicting_paths".to_string(),
1801 Value::Array(paths.iter().map(|p| Value::text(p.as_str())).collect()),
1802 );
1803 if let Ok(bytes) = crate::json::to_vec(&b) {
1807 fields.insert("base_json".to_string(), Value::Json(bytes));
1808 }
1809 if let Ok(bytes) = crate::json::to_vec(&o) {
1810 fields.insert("ours_json".to_string(), Value::Json(bytes));
1811 }
1812 if let Ok(bytes) = crate::json::to_vec(&t) {
1813 fields.insert("theirs_json".to_string(), Value::Json(bytes));
1814 }
1815 insert_meta_row(store, vc::CONFLICTS, fields)?;
1816 conflicts.push(Conflict {
1817 id: conflict_id,
1818 collection: coll.clone(),
1819 entity_id: eid.to_string(),
1820 base: b,
1821 ours: o,
1822 theirs: t,
1823 conflicting_paths: paths,
1824 merge_state_id: merge_state_id.to_string(),
1825 });
1826 }
1827 }
1828 Ok(conflicts)
1829}
1830
1831fn coalesce_modifications(
1835 entries: Vec<DiffEntry>,
1836 added: &mut usize,
1837 removed: &mut usize,
1838 modified: &mut usize,
1839) -> Vec<DiffEntry> {
1840 let mut by_key: HashMap<(String, String), Vec<DiffEntry>> = HashMap::new();
1841 for e in entries {
1842 by_key
1843 .entry((e.collection.clone(), e.entity_id.clone()))
1844 .or_default()
1845 .push(e);
1846 }
1847 let mut out: Vec<DiffEntry> = Vec::new();
1848 for ((coll, eid), group) in by_key {
1849 if group.len() >= 2 {
1850 let mut before = JsonValue::Null;
1851 let mut after = JsonValue::Null;
1852 for item in group {
1853 match item.change {
1854 DiffChange::Removed { before: b } => before = b,
1855 DiffChange::Added { after: a } => after = a,
1856 DiffChange::Modified { .. } => {}
1857 }
1858 }
1859 *added = added.saturating_sub(1);
1860 *removed = removed.saturating_sub(1);
1861 *modified += 1;
1862 out.push(DiffEntry {
1863 collection: coll,
1864 entity_id: eid,
1865 change: DiffChange::Modified { before, after },
1866 });
1867 } else {
1868 out.extend(group);
1869 }
1870 }
1871 out.sort_by(|a, b| {
1872 a.collection
1873 .cmp(&b.collection)
1874 .then(a.entity_id.cmp(&b.entity_id))
1875 });
1876 out
1877}
1878
1879#[allow(dead_code)]
1881fn _touch_types(_: BTreeSet<()>) {}