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