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