Skip to main content

lex_store/
branches.rs

1//! Branches: each branch is identified by a name and a head OpId.
2//! The SigId → StageId map every consumer reads is computed by
3//! replaying the op log from the head back. No materialized cache.
4//!
5//! `lifecycle.json` (Draft/Active/Deprecated/Tombstone per stage)
6//! survives as orthogonal stage-status metadata; it no longer drives
7//! branch resolution.
8
9use crate::store::{Store, StoreError};
10use lex_vcs::{OpId, OpLog, StageTransition};
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13use std::fs;
14use std::path::PathBuf;
15
16pub const DEFAULT_BRANCH: &str = "main";
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct Branch {
20    pub name: String,
21    pub parent: Option<String>,
22    /// Op DAG head. `None` means the branch has never had an op
23    /// applied (empty branch) *or* it's a predicate-defined branch
24    /// where the head is computed lazily from `predicate`.
25    #[serde(default)]
26    pub head_op: Option<OpId>,
27    /// Predicate over the op log (#133). When `Some`, the branch is
28    /// a saved query rather than a snapshot — `head_op` is the
29    /// optional materialization cache. The predicate's JSON shape
30    /// matches `lex_vcs::Predicate::to_value()`.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub predicate: Option<serde_json::Value>,
33    /// Append-only journal of merges committed *into* this branch.
34    #[serde(default)]
35    pub merges: Vec<MergeRecord>,
36    pub created_at: u64,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct MergeRecord {
41    pub src: String,
42    pub at: u64,
43    pub merged: usize,
44    pub conflicts: usize,
45}
46
47#[derive(Debug, Clone, Serialize)]
48pub struct MergeReport {
49    pub summary: MergeSummary,
50    pub merged: Vec<MergeEntry>,
51    pub conflicts: Vec<MergeConflict>,
52}
53
54#[derive(Debug, Clone, Serialize, Default)]
55pub struct MergeSummary {
56    pub total_sigs: usize,
57    pub clean: usize,
58    pub conflicts: usize,
59    pub base: Option<String>,
60    #[serde(default)]
61    pub src: String,
62    #[serde(default)]
63    pub dst: String,
64}
65
66#[derive(Debug, Clone, Serialize)]
67pub struct MergeEntry {
68    pub sig_id: String,
69    pub stage_id: String,
70    pub from: &'static str, // "src" | "dst" | "both"
71}
72
73#[derive(Debug, Clone, Serialize)]
74pub struct MergeConflict {
75    pub sig_id: String,
76    pub kind: &'static str,
77    pub base: Option<String>,
78    pub src: Option<String>,
79    pub dst: Option<String>,
80}
81
82impl Store {
83    fn branches_dir(&self) -> PathBuf { self.root().join("branches") }
84    fn branch_path(&self, name: &str) -> PathBuf {
85        self.branches_dir().join(format!("{name}.json"))
86    }
87    fn current_branch_path(&self) -> PathBuf {
88        self.root().join("current_branch")
89    }
90
91    pub fn current_branch(&self) -> String {
92        match fs::read_to_string(self.current_branch_path()) {
93            Ok(s) => s.trim().to_string(),
94            Err(_) => DEFAULT_BRANCH.to_string(),
95        }
96    }
97
98    pub fn set_current_branch(&self, name: &str) -> Result<(), StoreError> {
99        if name != DEFAULT_BRANCH && self.get_branch(name)?.is_none() {
100            return Err(StoreError::UnknownBranch(name.into()));
101        }
102        fs::write(self.current_branch_path(), name)?;
103        Ok(())
104    }
105
106    pub fn list_branches(&self) -> Result<Vec<String>, StoreError> {
107        let mut out: Vec<String> = vec![DEFAULT_BRANCH.into()];
108        let dir = self.branches_dir();
109        if !dir.exists() { return Ok(out); }
110        for entry in fs::read_dir(&dir)? {
111            let entry = entry?;
112            let path = entry.path();
113            if path.extension().is_some_and(|e| e == "json") {
114                if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
115                    if name != DEFAULT_BRANCH { out.push(name.to_string()); }
116                }
117            }
118        }
119        out.sort();
120        Ok(out)
121    }
122
123    pub fn get_branch(&self, name: &str) -> Result<Option<Branch>, StoreError> {
124        let path = self.branch_path(name);
125        if !path.exists() { return Ok(None); }
126        let raw = fs::read_to_string(&path)?;
127        let b: Branch = serde_json::from_str(&raw)?;
128        Ok(Some(b))
129    }
130
131    /// Computed view: walk the op log from the branch head and
132    /// replay each transition into a SigId → StageId map.
133    ///
134    /// PERF: O(N) per call where N is the number of ops on this
135    /// branch's history. Each call: re-opens the op log (a `mkdir
136    /// -p ops/` syscall), BFS-walks the full ancestor set, allocates
137    /// a `BTreeSet<OpId>` + `Vec<OperationRecord>` + `BTreeMap`,
138    /// reverses, then linearly replays. No memoization. Tier-1 size
139    /// (a few hundred ops per branch) makes this acceptable; if
140    /// hotter consumers land (e.g. an HTTP-served `branch_head`),
141    /// memoize per-(branch_name, head_op) — the head_op tail of the
142    /// cache key is a content-addressed hash, so cache invalidation
143    /// is free.
144    pub fn branch_head(&self, name: &str) -> Result<BTreeMap<String, String>, StoreError> {
145        let b = match self.get_branch(name)? {
146            Some(b) => b,
147            None if name == DEFAULT_BRANCH => return Ok(BTreeMap::new()),
148            None => return Err(StoreError::UnknownBranch(name.into())),
149        };
150        let Some(head) = b.head_op else { return Ok(BTreeMap::new()); };
151        let log = OpLog::open(self.root())?;
152        let mut map = BTreeMap::new();
153        for rec in log.walk_forward(&head, None)? {
154            apply_transition(&mut map, &rec.produces);
155        }
156        Ok(map)
157    }
158
159    pub fn branch_log(&self, name: &str) -> Result<Vec<MergeRecord>, StoreError> {
160        match self.get_branch(name)? {
161            Some(b) => Ok(b.merges),
162            None if name == DEFAULT_BRANCH => Ok(Vec::new()),
163            None => Err(StoreError::UnknownBranch(name.into())),
164        }
165    }
166
167    /// Snapshot the source branch's head_op into a new named branch.
168    pub fn create_branch(&self, name: &str, from: &str) -> Result<(), StoreError> {
169        if name.is_empty() || name.contains('/') || name.contains('\\') {
170            return Err(StoreError::InvalidTransition(
171                format!("branch name `{name}` rejected (empty or path-like)")));
172        }
173        if self.branch_path(name).exists() {
174            return Err(StoreError::InvalidTransition(
175                format!("branch `{name}` already exists")));
176        }
177        let head_op = self.get_branch(from)?.and_then(|b| b.head_op);
178        fs::create_dir_all(self.branches_dir())?;
179        let b = Branch {
180            name: name.into(),
181            parent: Some(from.into()),
182            head_op,
183            predicate: None,
184            merges: Vec::new(),
185            created_at: now(),
186        };
187        fs::write(self.branch_path(name), serde_json::to_string_pretty(&b)?)?;
188        Ok(())
189    }
190
191    /// Create a predicate-defined branch (#133). The branch's
192    /// content is the set of ops matching `predicate`; `head_op`
193    /// stays `None` and is materialized lazily by callers when
194    /// they need a single point to apply ops against. Cheap to
195    /// create and discard — it's a saved query, not a snapshot.
196    pub fn create_predicate_branch(
197        &self,
198        name: &str,
199        predicate: serde_json::Value,
200    ) -> Result<(), StoreError> {
201        if name.is_empty() || name.contains('/') || name.contains('\\') {
202            return Err(StoreError::InvalidTransition(
203                format!("branch name `{name}` rejected (empty or path-like)")));
204        }
205        if self.branch_path(name).exists() {
206            return Err(StoreError::InvalidTransition(
207                format!("branch `{name}` already exists")));
208        }
209        fs::create_dir_all(self.branches_dir())?;
210        let b = Branch {
211            name: name.into(),
212            parent: None,
213            head_op: None,
214            predicate: Some(predicate),
215            merges: Vec::new(),
216            created_at: now(),
217        };
218        fs::write(self.branch_path(name), serde_json::to_string_pretty(&b)?)?;
219        Ok(())
220    }
221
222    pub fn delete_branch(&self, name: &str) -> Result<(), StoreError> {
223        if name == DEFAULT_BRANCH {
224            return Err(StoreError::InvalidTransition(
225                "cannot delete the default branch".into()));
226        }
227        if self.current_branch() == name {
228            return Err(StoreError::InvalidTransition(format!(
229                "cannot delete `{name}`; check out another branch first")));
230        }
231        let path = self.branch_path(name);
232        if !path.exists() {
233            return Err(StoreError::UnknownBranch(name.into()));
234        }
235        fs::remove_file(path)?;
236        Ok(())
237    }
238
239    /// Atomically set a branch's `head_op`. Used by `apply_operation`
240    /// after a successful op apply. Materializes `main`'s branch file
241    /// on first call (creates `branches/main.json`).
242    ///
243    /// Crash safety: the tempfile's data is fsync'd before rename
244    /// (see `write_branch_atomic`), so a successful return implies a
245    /// durable branch file at the final path. The containing directory
246    /// is not fsync'd; on a crash between rename and the directory's
247    /// metadata flush, the rename can be lost — the prior head (or
248    /// missing branch file for a fresh `main`) survives. The op record
249    /// itself is content-addressed and is independently durable in the
250    /// op log.
251    ///
252    /// Concurrency: single-writer per store. Two writers calling this
253    /// for the same branch race on read-modify-write of the JSON file
254    /// (each reads, mutates `head_op`, renames its tempfile in). Last
255    /// writer wins; the loser's head update is silently dropped, even
256    /// though both their op records survive in the op log. Tier-1
257    /// merge / `lex publish` callers run sequentially; multi-writer
258    /// safety (file locking) is on the table once `lex serve` becomes
259    /// a real concurrent producer (#130 territory).
260    pub(crate) fn set_branch_head_op(
261        &self,
262        name: &str,
263        head_op: OpId,
264    ) -> Result<(), StoreError> {
265        let mut b = match self.get_branch(name)? {
266            Some(b) => b,
267            None if name == DEFAULT_BRANCH => Branch {
268                name: DEFAULT_BRANCH.into(),
269                parent: None,
270                head_op: None,
271                predicate: None,
272                merges: Vec::new(),
273                created_at: now(),
274            },
275            None => return Err(StoreError::UnknownBranch(name.into())),
276        };
277        b.head_op = Some(head_op);
278        fs::create_dir_all(self.branches_dir())?;
279        write_branch_atomic(&self.branch_path(name), &b)?;
280        Ok(())
281    }
282}
283
284/// Apply a single `StageTransition` to a sig-stage map. Used by
285/// `branch_head` to replay an op log.
286fn apply_transition(map: &mut BTreeMap<String, String>, t: &StageTransition) {
287    match t {
288        StageTransition::Create { sig_id, stage_id }
289        | StageTransition::Replace { sig_id, to: stage_id, .. } => {
290            map.insert(sig_id.clone(), stage_id.clone());
291        }
292        StageTransition::Remove { sig_id, .. } => {
293            map.remove(sig_id);
294        }
295        StageTransition::Rename { from, to, body_stage_id } => {
296            map.remove(from);
297            map.insert(to.clone(), body_stage_id.clone());
298        }
299        StageTransition::ImportOnly => {}
300        StageTransition::Merge { entries } => {
301            for (sig, stage) in entries {
302                match stage {
303                    Some(s) => { map.insert(sig.clone(), s.clone()); }
304                    None    => { map.remove(sig); }
305                }
306            }
307        }
308    }
309}
310
311fn write_branch_atomic(path: &std::path::Path, b: &Branch) -> Result<(), StoreError> {
312    use std::io::Write;
313    let bytes = serde_json::to_vec_pretty(b)?;
314    let tmp = path.with_extension("json.tmp");
315    let mut f = fs::File::create(&tmp)?;
316    f.write_all(&bytes)?;
317    f.sync_all()?;
318    fs::rename(&tmp, path)?;
319    Ok(())
320}
321
322fn now() -> u64 {
323    use std::time::{SystemTime, UNIX_EPOCH};
324    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
325}
326
327impl Store {
328    pub fn merge(&self, src: &str, dst: &str) -> Result<MergeReport, StoreError> {
329        let log = OpLog::open(self.root())?;
330        let src_head = self.get_branch(src)?.and_then(|b| b.head_op);
331        let dst_head = match self.get_branch(dst)? {
332            Some(b) => b.head_op,
333            None if dst == DEFAULT_BRANCH => None,
334            None => return Err(StoreError::UnknownBranch(dst.into())),
335        };
336        let out = lex_vcs::merge(&log, src_head.as_ref(), dst_head.as_ref())?;
337
338        let mut report = MergeReport {
339            summary: MergeSummary {
340                base: out.lca.clone(),
341                src: src.into(),
342                dst: dst.into(),
343                ..Default::default()
344            },
345            merged: Vec::new(),
346            conflicts: Vec::new(),
347        };
348        for o in out.outcomes {
349            match o {
350                lex_vcs::MergeOutcome::Both { sig_id, stage_id } => {
351                    if let Some(stage_id) = stage_id {
352                        report.merged.push(MergeEntry { sig_id, stage_id, from: "both" });
353                    }
354                }
355                lex_vcs::MergeOutcome::Src { sig_id, stage_id } => {
356                    if let Some(stage_id) = stage_id {
357                        report.merged.push(MergeEntry { sig_id, stage_id, from: "src" });
358                    }
359                }
360                lex_vcs::MergeOutcome::Dst { sig_id, stage_id } => {
361                    if let Some(stage_id) = stage_id {
362                        report.merged.push(MergeEntry { sig_id, stage_id, from: "dst" });
363                    }
364                }
365                lex_vcs::MergeOutcome::Conflict { sig_id, kind, base, src, dst } => {
366                    let kind: &'static str = match kind {
367                        lex_vcs::ConflictKind::ModifyModify => "modify-modify",
368                        lex_vcs::ConflictKind::ModifyDelete => "modify-delete",
369                        lex_vcs::ConflictKind::DeleteModify => "delete-modify",
370                        lex_vcs::ConflictKind::AddAdd       => "add-add",
371                    };
372                    report.conflicts.push(MergeConflict {
373                        sig_id, kind, base, src, dst,
374                    });
375                }
376            }
377        }
378        report.summary.clean = report.merged.len();
379        report.summary.conflicts = report.conflicts.len();
380        report.summary.total_sigs = report.merged.len() + report.conflicts.len();
381        Ok(report)
382    }
383
384    pub fn commit_merge(&self, dst: &str, report: &MergeReport) -> Result<(), StoreError> {
385        if !report.conflicts.is_empty() {
386            return Err(StoreError::InvalidTransition(format!(
387                "{} conflicts; resolve before committing", report.conflicts.len())));
388        }
389        let dst_head_map = self.branch_head(dst)?;
390        let mut entries: BTreeMap<String, Option<String>> = BTreeMap::new();
391        for m in &report.merged {
392            let cur = dst_head_map.get(&m.sig_id);
393            if cur != Some(&m.stage_id) {
394                entries.insert(m.sig_id.clone(), Some(m.stage_id.clone()));
395            }
396        }
397        let src_head = self.get_branch(&report.summary.src)?.and_then(|b| b.head_op);
398        let dst_head_op = self.get_branch(dst)?.and_then(|b| b.head_op);
399
400        match (src_head.clone(), dst_head_op.clone()) {
401            // Fast-forward: dst is empty, just adopt src's head.
402            (Some(s), None) => {
403                self.set_branch_head_op(dst, s)?;
404            }
405            // Both sides have heads at the same op: nothing structural
406            // to merge. Skip apply but still journal below.
407            (Some(s), Some(d)) if s == d => { /* no-op */ }
408            (Some(s), Some(d)) => {
409                let op = lex_vcs::Operation::new(
410                    lex_vcs::OperationKind::Merge { resolved: entries.len() },
411                    [s, d],
412                );
413                let t = lex_vcs::StageTransition::Merge { entries };
414                let _ = self.apply_operation(dst, op, t)?;
415            }
416            // src empty: nothing to merge in. Treat as no-op.
417            (None, _) => { /* no-op */ }
418        }
419
420        // Atomicity note: the merge op is durable after apply_operation
421        // returns; the journal entry below is a separate write. A
422        // crash between leaves the merge in the op DAG but no journal
423        // row — `lex log` will be missing this merge. The branch is
424        // still functionally correct (head_op points at the merge op,
425        // which carries `entries`), so the gap is recoverable by
426        // re-running commit_merge once (which will journal but skip
427        // the apply on the same-head match arm above). Tier-1 single-
428        // writer assumption applies; multi-writer locking is on the
429        // table for #130.
430
431        // Journal the merge so `lex log` can show it.
432        let mut b = self.get_branch(dst)?
433            .ok_or_else(|| StoreError::UnknownBranch(dst.into()))?;
434        if !report.summary.src.is_empty() {
435            b.merges.push(MergeRecord {
436                src: report.summary.src.clone(),
437                at: now(),
438                merged: report.merged.len(),
439                conflicts: 0,
440            });
441            write_branch_atomic(&self.branch_path(dst), &b)?;
442        }
443        Ok(())
444    }
445}