Skip to main content

reddb_server/runtime/
impl_vcs.rs

1//! Runtime implementation of the VCS ("Git for Data") surface.
2//!
3//! Phase 2: real persistence for commit / branch / tag / refs / log /
4//! status / checkout / lca / resolve_commitish / resolve_as_of.
5//! Merge / cherry-pick / revert / reset / diff / conflict handling
6//! remain stubbed and land in Phase 3.
7//!
8//! Every VCS entity is stored as a plain TableRow in a `red_*`
9//! collection (same pattern as `red_queue_meta` / `red_stats`).
10//! Commits reuse the MVCC snapshot xid as their immutable root, and
11//! pin that xid so VACUUM cannot reclaim historical row versions.
12
13use 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
34// ---------------------------------------------------------------------------
35// Utilities
36// ---------------------------------------------------------------------------
37
38fn 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
170// ---------------------------------------------------------------------------
171// Commit load / save
172// ---------------------------------------------------------------------------
173
174fn 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
259// ---------------------------------------------------------------------------
260// Ref load / save / delete
261// ---------------------------------------------------------------------------
262
263fn 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    // Delete-then-insert gives us upsert semantics over the TableRow
307    // primary-key `_id` used by every red_* collection.
308    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
335// ---------------------------------------------------------------------------
336// Opt-in per-collection versioning (Phase 7 — disk-cost isolation)
337// ---------------------------------------------------------------------------
338
339/// Is this user collection opted in to Git-for-Data?
340///
341/// Default is `false`: a fresh collection stays outside the VCS so
342/// transactional churn (sessions, caches, queues) doesn't pin
343/// extra row versions. Internal `red_*` collections are never
344/// versioned — they store VCS metadata itself.
345fn 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
371/// Enumerate every user collection currently opted in. Order
372/// undefined — callers that need a deterministic iteration should
373/// sort the returned list.
374fn 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
392/// Upsert / delete the `red_vcs_settings` row for `name`. `true`
393/// opts the collection into VCS; `false` opts it out (subsequent
394/// merges / diffs / AS OF queries stop seeing it). Does NOT touch
395/// existing row versions — you control whether history is retained
396/// by deciding *when* to opt out.
397fn 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
446// ---------------------------------------------------------------------------
447// Ancestry helpers
448// ---------------------------------------------------------------------------
449
450fn 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    // Highest height first (newest commits on top).
519    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
527// ---------------------------------------------------------------------------
528// Commitish resolution
529// ---------------------------------------------------------------------------
530
531fn resolve_ref_chain(store: &UnifiedStore, name: &str) -> Option<CommitHash> {
532    // Follow up to 4 levels of ref indirection.
533    let mut current = name.to_string();
534    for _ in 0..4 {
535        let r = load_ref(store, &current)?;
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
570// ---------------------------------------------------------------------------
571// Workset helpers (lightweight — full WIP tracking lands in Phase 3)
572// ---------------------------------------------------------------------------
573
574fn 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    // Delete old workset for this connection
583    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
628// ---------------------------------------------------------------------------
629// Runtime impl
630// ---------------------------------------------------------------------------
631
632impl 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        // Resolve current HEAD for this connection: workset -> HEAD:<conn>
638        // -> default branch.
639        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        // Allocate a fresh xid for this commit and immediately mark
668        // it committed so all future snapshots see the commit record.
669        // Each commit therefore has a unique, monotonic root_xid — a
670        // prerequisite for `AS OF BRANCH` to map to distinct
671        // snapshots across divergent branches.
672        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        // Pin the root xid so VACUUM cannot reclaim history while the
706        // commit is reachable.
707        if root_xid != XID_NONE {
708            self.inner.snapshot_manager.pin(root_xid);
709        }
710
711        // Advance (or create) the branch ref.
712        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                // Fall back to the connection's current HEAD branch, then default.
751                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                // Detached HEAD — we point HEAD directly at a commit via
843                // the workset base; no branch ref is updated.
844                (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        // Update per-connection HEAD pointer for status/introspection.
857        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        // Resolve source commit (the one being merged in).
892        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        // Resolve current HEAD for the connection.
898        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        // Fast-forward check: is HEAD an ancestor of `from`?
921        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        // Non-fast-forward: compute LCA, create a merge commit.
964        // Data-level 3-way merge (with conflict materialisation into
965        // red_conflicts) is deferred to Phase 4 — for now we produce
966        // the merge commit only when both sides have no content
967        // overlap to resolve, i.e. when LCA == head_hash (reverse FF)
968        // we already handled above. Otherwise surface a merge_state
969        // placeholder so callers know data reconciliation is required.
970        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        // Create a merge_state row so Phase 4 can pick up where we
1003        // stopped and actually reconcile data. We still open the
1004        // commit / ref so log() shows the history; worksets gets the
1005        // merge_state_id so status() surfaces the in-progress state.
1006        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        // Materialise conflicts: any entity whose visibility changed
1032        // in BOTH sides relative to the LCA snapshot is a candidate
1033        // conflict. Phase 5 records identifiers + metadata; Phase 6
1034        // will stage the merged body and apply it to the user data.
1035        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        // Resolve the commit being cherry-picked and its parent.
1081        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        // Resolve HEAD for this connection.
1098        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        // Cherry-pick = 3-way merge with base = parent(src), ours = HEAD,
1121        // theirs = src. The new commit records the pick so log() sees
1122        // it; data application is staged in merge_state.pending so
1123        // Phase 6 can apply it transactionally.
1124        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        // Revert = 3-way merge with base = src, ours = HEAD,
1236        // theirs = parent(src). The asymmetry vs cherry-pick flips
1237        // which side provides the "forward" delta so the effect is
1238        // the inverse of the original commit.
1239        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        // Record merge_state for later data apply. No conflict
1280        // materialisation here — revert conflicts are rare when the
1281        // reverted commit touches disjoint entities.
1282        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        // Find the current branch for this connection.
1304        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        // Soft and Mixed both move the branch ref + workset base.
1311        // Hard would additionally revert entity data — deferred to
1312        // Phase 4 because it requires selective MVCC rewind.
1313        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        // Iterate every *user* collection (skip internal red_*).
1371        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            // Group by entity id so we can compare before/after state.
1393            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        // Modified rows in an append-only MVCC are expressed as a pair
1427        // (remove old version + add new version sharing entity_id);
1428        // collapse them into DiffChange::Modified so the wire format
1429        // matches user intuition.
1430        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        // BFS from b, return first hit in a_ancestors with the greatest
1477        // height (closest common ancestor to b).
1478        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                // Don't descend below an ancestor; higher-height hits
1492                // further from root are already captured.
1493                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        // NOTE: Phase 4 will also apply `_resolved` to the user
1561        // collection under the current working set before deleting
1562        // the conflict row. Here we only remove the marker.
1563        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                // Find the commit with the greatest timestamp_ms <= ts.
1605                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        // 1. Exact commit hash.
1653        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        // 2. Full ref name (refs/heads/..., refs/tags/...).
1661        if spec.starts_with("refs/") {
1662            if let Some(hash) = resolve_ref_chain(store, spec) {
1663                return Ok(hash);
1664            }
1665        }
1666
1667        // 3. Short branch / tag name.
1668        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        // 4. Short commit hash prefix (≥ 4 chars, unique match).
1678        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
1688/// Detect entities changed on both sides of a 3-way merge and write
1689/// a `red_conflicts` row for each. Returns the in-memory `Conflict`
1690/// list in the same shape `vcs_conflicts_list` would later read back.
1691///
1692/// Phase 5: metadata-level only — identifiers + which sides moved.
1693/// Phase 6 will stage the merged body (base/ours/theirs payloads +
1694/// JSON 3-way merge result) so `vcs_conflict_resolve` can apply the
1695/// resolved value back to the user collection.
1696fn 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    // Walk every VCS-opted-in user collection, materialising
1717    // per-(entity_id) visible JSON bodies at each of the three
1718    // snapshots. Collections that never opted in are skipped — by
1719    // definition they have no history semantics worth conflicting
1720    // over.
1721    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                // Both sides touched different paths — no conflict
1774                // to record; Phase 6.2 will stage the merged body
1775                // into the workset for automatic apply.
1776                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            // Persist the three bodies as Value::Json blobs so
1804            // vcs_conflicts_list can hydrate them back into the
1805            // Conflict struct for presentation.
1806            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
1831// Collapse Add+Remove pairs sharing the same (collection, entity_id)
1832// into a single `Modified` entry. Called by `vcs_diff` after the
1833// naive pass so the caller sees one row per change, not two.
1834fn 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// Unused-import guards for stubs that reference types via `_`.
1880#[allow(dead_code)]
1881fn _touch_types(_: BTreeSet<()>) {}