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
16/// Why a CAS branch advance failed (#262). Surfaced through the
17/// retry loop in `Store::apply_operation` and friends; callers
18/// either retry (on `Mismatch`, after re-reading the head and
19/// rebuilding the candidate op) or propagate (on `Io` /
20/// `UnknownBranch`).
21#[derive(Debug)]
22pub enum CasFailed {
23    /// Read-time `head_op` didn't match the supplied `expected`.
24    /// Another writer advanced the branch between this caller's
25    /// read and write. `actual` is the head we found instead.
26    /// Public field so callers (e.g. an HTTP layer) can surface
27    /// the actual head in a structured error envelope; today's
28    /// retry loop just discards it and re-reads on the next
29    /// iteration.
30    #[allow(dead_code)] // populated for callers that inspect the variant
31    Mismatch { actual: Option<OpId> },
32    /// Branch doesn't exist (and isn't the default branch).
33    UnknownBranch(String),
34    /// Disk I/O failure (lock acquisition, file read, atomic
35    /// write). Stringified at the boundary because `io::Error`
36    /// isn't `Clone`/`PartialEq` and the variant is consumed by
37    /// the retry loop, not pattern-matched on.
38    Io(String),
39}
40
41pub const DEFAULT_BRANCH: &str = "main";
42
43/// Persisted, best-effort cache of `branch_head`'s computed view,
44/// keyed on the head it was computed for. See `Store::branch_head`'s
45/// doc comment for the incremental-replay design this backs.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47struct HeadSnapshot {
48    head_op: OpId,
49    map: BTreeMap<String, String>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53pub struct Branch {
54    pub name: String,
55    pub parent: Option<String>,
56    /// Op DAG head. `None` means the branch has never had an op
57    /// applied (empty branch) *or* it's a predicate-defined branch
58    /// where the head is computed lazily from `predicate`.
59    #[serde(default)]
60    pub head_op: Option<OpId>,
61    /// Predicate over the op log (#133). When `Some`, the branch is
62    /// a saved query rather than a snapshot — `head_op` is the
63    /// optional materialization cache. The predicate's JSON shape
64    /// matches `lex_vcs::Predicate::to_value()`.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub predicate: Option<serde_json::Value>,
67    /// Append-only journal of merges committed *into* this branch.
68    #[serde(default)]
69    pub merges: Vec<MergeRecord>,
70    pub created_at: u64,
71    /// Last op_id through which the producer-block gate (#248) has
72    /// verified the branch's history (#256). When advancing from
73    /// `head_op` to a new tip, the gate walks ops in
74    /// `(last_gate_checkpoint .. head_op]` and runs the
75    /// producer-block check on each ancestor's attestable stages —
76    /// not just the new op. Once that walk passes, the checkpoint
77    /// advances.
78    ///
79    /// Invalidated (set to `None`) when `lex attest retro-block`
80    /// lands a new `ProducerBlock` attestation, forcing the next
81    /// advance to re-walk from genesis once. Steady-state advances
82    /// are `O(new ops)` because the previous advance already
83    /// covered everything up through `last_gate_checkpoint`.
84    ///
85    /// Pre-#256 branch files have no `last_gate_checkpoint` field;
86    /// serde defaults to `None`, which forces a one-time full walk
87    /// on next advance. Same backward-compat trick `intent_id`
88    /// (#131) used.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub last_gate_checkpoint: Option<OpId>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
94pub struct MergeRecord {
95    pub src: String,
96    pub at: u64,
97    pub merged: usize,
98    pub conflicts: usize,
99}
100
101#[derive(Debug, Clone, Serialize)]
102pub struct MergeReport {
103    pub summary: MergeSummary,
104    pub merged: Vec<MergeEntry>,
105    pub conflicts: Vec<MergeConflict>,
106    /// Sigs the merge decided to remove (a side deleted them and that
107    /// deletion won). Kept separate from `merged` (which only carries
108    /// present sig->stage) so `commit_merge` can propagate removals
109    /// into the `Merge` transition's `entries` as `None` (#841). Was
110    /// silently dropped before, so a src-side removal never reached
111    /// dst via `commit_merge`.
112    #[serde(default)]
113    pub removed: Vec<String>,
114}
115
116#[derive(Debug, Clone, Serialize, Default)]
117pub struct MergeSummary {
118    pub total_sigs: usize,
119    pub clean: usize,
120    pub conflicts: usize,
121    pub base: Option<String>,
122    #[serde(default)]
123    pub src: String,
124    #[serde(default)]
125    pub dst: String,
126}
127
128#[derive(Debug, Clone, Serialize)]
129pub struct MergeEntry {
130    pub sig_id: String,
131    pub stage_id: String,
132    pub from: &'static str, // "src" | "dst" | "both"
133}
134
135#[derive(Debug, Clone, Serialize)]
136pub struct MergeConflict {
137    pub sig_id: String,
138    pub kind: &'static str,
139    pub base: Option<String>,
140    pub src: Option<String>,
141    pub dst: Option<String>,
142}
143
144impl Store {
145    fn branches_dir(&self) -> PathBuf { self.root().join("branches") }
146    fn branch_path(&self, name: &str) -> PathBuf {
147        self.branches_dir().join(format!("{name}.json"))
148    }
149    fn current_branch_path(&self) -> PathBuf {
150        self.root().join("current_branch")
151    }
152
153    pub fn current_branch(&self) -> String {
154        match fs::read_to_string(self.current_branch_path()) {
155            Ok(s) => s.trim().to_string(),
156            Err(_) => DEFAULT_BRANCH.to_string(),
157        }
158    }
159
160    pub fn set_current_branch(&self, name: &str) -> Result<(), StoreError> {
161        if name != DEFAULT_BRANCH && self.get_branch(name)?.is_none() {
162            return Err(StoreError::UnknownBranch(name.into()));
163        }
164        fs::write(self.current_branch_path(), name)?;
165        Ok(())
166    }
167
168    pub fn list_branches(&self) -> Result<Vec<String>, StoreError> {
169        let mut out: Vec<String> = vec![DEFAULT_BRANCH.into()];
170        let dir = self.branches_dir();
171        if !dir.exists() { return Ok(out); }
172        for entry in fs::read_dir(&dir)? {
173            let entry = entry?;
174            let path = entry.path();
175            if path.extension().is_some_and(|e| e == "json") {
176                if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
177                    if name != DEFAULT_BRANCH { out.push(name.to_string()); }
178                }
179            }
180        }
181        out.sort();
182        Ok(out)
183    }
184
185    pub fn get_branch(&self, name: &str) -> Result<Option<Branch>, StoreError> {
186        let path = self.branch_path(name);
187        if !path.exists() { return Ok(None); }
188        let raw = fs::read_to_string(&path)?;
189        let b: Branch = serde_json::from_str(&raw)?;
190        Ok(Some(b))
191    }
192
193    fn head_snapshot_path(&self, name: &str) -> PathBuf {
194        self.branches_dir().join(format!("{name}.head_snapshot.json"))
195    }
196
197    /// Best-effort read of the persisted snapshot for `name`. Any
198    /// failure (missing file, corrupt/partial JSON from an unclean
199    /// shutdown) is treated as "no snapshot" rather than an error —
200    /// this is a pure performance optimization, so losing it must
201    /// never break correctness, only fall back to a full walk.
202    fn load_head_snapshot(&self, name: &str) -> Option<HeadSnapshot> {
203        let raw = fs::read_to_string(self.head_snapshot_path(name)).ok()?;
204        serde_json::from_str(&raw).ok()
205    }
206
207    /// Best-effort write; a failure here (e.g. read-only filesystem)
208    /// only costs a future full walk, so it's swallowed rather than
209    /// propagated. Not atomic against a concurrent writer or a crash
210    /// mid-write — same tradeoff `set_branch_head_op`'s own
211    /// `fs::write` already makes for `branch_path`, and a torn write
212    /// just fails `load_head_snapshot`'s parse on next read.
213    fn save_head_snapshot(&self, name: &str, head_op: &OpId, map: &BTreeMap<String, String>) {
214        let snap = HeadSnapshot { head_op: head_op.clone(), map: map.clone() };
215        if let Ok(s) = serde_json::to_string(&snap) {
216            let _ = fs::write(self.head_snapshot_path(name), s);
217        }
218    }
219
220    /// Computed view: walk the op log from the branch head and
221    /// replay each transition into a SigId → StageId map.
222    ///
223    /// Backed by a persisted snapshot (`<branch>.head_snapshot.json`)
224    /// keyed on the head it was computed for. Steady state — this
225    /// call's head_op matches the last call's — replays only the ops
226    /// since the snapshot instead of the whole history: O(ops since
227    /// the last call) instead of O(total branch history). Falls back
228    /// to a full walk (and refreshes the snapshot) whenever there's no
229    /// snapshot yet, or the snapshot's op isn't actually an ancestor
230    /// of the new head (a branch reset, or history reordered by a
231    /// merge) — see `OpLog::walk_forward_since`'s own doc comment.
232    ///
233    /// This existed as a genuine, measured bottleneck before the
234    /// snapshot: a single call over a tenant with 110k+ accumulated
235    /// ops took on the order of an hour, dominated by one disk read
236    /// per ancestor op in the full BFS walk (alpibrusl/lex-lang#813's
237    /// follow-up). Every consumer that used to call this once per
238    /// file in a multi-file publish (fixed separately, also #813) now
239    /// calls it once per publish request — but "once" was still a full
240    /// walk over the *entire* history every time, since nothing
241    /// persisted the result between calls.
242    pub fn branch_head(&self, name: &str) -> Result<BTreeMap<String, String>, StoreError> {
243        let b = match self.get_branch(name)? {
244            Some(b) => b,
245            None if name == DEFAULT_BRANCH => return Ok(BTreeMap::new()),
246            None => return Err(StoreError::UnknownBranch(name.into())),
247        };
248        let Some(head) = b.head_op else { return Ok(BTreeMap::new()); };
249        let log = OpLog::open(self.root())?;
250
251        if let Some(snap) = self.load_head_snapshot(name) {
252            if snap.head_op == head {
253                return Ok(snap.map);
254            }
255            if let Some(new_records) = log.walk_forward_since(&head, &snap.head_op)? {
256                let mut map = snap.map;
257                for rec in &new_records {
258                    apply_transition(&mut map, &rec.produces);
259                }
260                self.save_head_snapshot(name, &head, &map);
261                return Ok(map);
262            }
263            // Snapshot's op isn't an ancestor of the new head — fall
264            // through to a full walk below, which also refreshes it.
265        }
266
267        let mut map = BTreeMap::new();
268        for rec in log.walk_forward(&head, None)? {
269            apply_transition(&mut map, &rec.produces);
270        }
271        self.save_head_snapshot(name, &head, &map);
272        Ok(map)
273    }
274
275    pub fn branch_log(&self, name: &str) -> Result<Vec<MergeRecord>, StoreError> {
276        match self.get_branch(name)? {
277            Some(b) => Ok(b.merges),
278            None if name == DEFAULT_BRANCH => Ok(Vec::new()),
279            None => Err(StoreError::UnknownBranch(name.into())),
280        }
281    }
282
283    /// Snapshot the source branch's head_op into a new named branch.
284    pub fn create_branch(&self, name: &str, from: &str) -> Result<(), StoreError> {
285        if name.is_empty() || name.contains('/') || name.contains('\\') {
286            return Err(StoreError::InvalidTransition(
287                format!("branch name `{name}` rejected (empty or path-like)")));
288        }
289        if self.branch_path(name).exists() {
290            return Err(StoreError::InvalidTransition(
291                format!("branch `{name}` already exists")));
292        }
293        let head_op = self.get_branch(from)?.and_then(|b| b.head_op);
294        fs::create_dir_all(self.branches_dir())?;
295        let b = Branch {
296            name: name.into(),
297            parent: Some(from.into()),
298            head_op,
299            predicate: None,
300            merges: Vec::new(),
301            created_at: now(),
302            last_gate_checkpoint: None,
303        };
304        fs::write(self.branch_path(name), serde_json::to_string_pretty(&b)?)?;
305        Ok(())
306    }
307
308    /// Create a predicate-defined branch (#133). The branch's
309    /// content is the set of ops matching `predicate`; `head_op`
310    /// stays `None` and is materialized lazily by callers when
311    /// they need a single point to apply ops against. Cheap to
312    /// create and discard — it's a saved query, not a snapshot.
313    pub fn create_predicate_branch(
314        &self,
315        name: &str,
316        predicate: serde_json::Value,
317    ) -> Result<(), StoreError> {
318        if name.is_empty() || name.contains('/') || name.contains('\\') {
319            return Err(StoreError::InvalidTransition(
320                format!("branch name `{name}` rejected (empty or path-like)")));
321        }
322        if self.branch_path(name).exists() {
323            return Err(StoreError::InvalidTransition(
324                format!("branch `{name}` already exists")));
325        }
326        fs::create_dir_all(self.branches_dir())?;
327        let b = Branch {
328            name: name.into(),
329            parent: None,
330            head_op: None,
331            predicate: Some(predicate),
332            merges: Vec::new(),
333            created_at: now(),
334            last_gate_checkpoint: None,
335        };
336        fs::write(self.branch_path(name), serde_json::to_string_pretty(&b)?)?;
337        Ok(())
338    }
339
340    pub fn delete_branch(&self, name: &str) -> Result<(), StoreError> {
341        if name == DEFAULT_BRANCH {
342            return Err(StoreError::InvalidTransition(
343                "cannot delete the default branch".into()));
344        }
345        if self.current_branch() == name {
346            return Err(StoreError::InvalidTransition(format!(
347                "cannot delete `{name}`; check out another branch first")));
348        }
349        let path = self.branch_path(name);
350        if !path.exists() {
351            return Err(StoreError::UnknownBranch(name.into()));
352        }
353        fs::remove_file(path)?;
354        Ok(())
355    }
356
357    /// Atomically set a branch's `head_op`. Used by `apply_operation`
358    /// after a successful op apply. Materializes `main`'s branch file
359    /// on first call (creates `branches/main.json`).
360    ///
361    /// Crash safety: the tempfile's data is fsync'd before rename
362    /// (see `write_branch_atomic`), so a successful return implies a
363    /// durable branch file at the final path. The containing directory
364    /// is not fsync'd; on a crash between rename and the directory's
365    /// metadata flush, the rename can be lost — the prior head (or
366    /// missing branch file for a fresh `main`) survives. The op record
367    /// itself is content-addressed and is independently durable in the
368    /// op log.
369    ///
370    /// Concurrency: single-writer per store. Two writers calling this
371    /// for the same branch race on read-modify-write of the JSON file
372    /// (each reads, mutates `head_op`, renames its tempfile in). Last
373    /// writer wins; the loser's head update is silently dropped, even
374    /// though both their op records survive in the op log. Tier-1
375    /// merge / `lex publish` callers run sequentially; multi-writer
376    /// safety (file locking) is on the table once `lex serve` becomes
377    /// a real concurrent producer (#130 territory).
378    pub(crate) fn set_branch_head_op(
379        &self,
380        name: &str,
381        head_op: OpId,
382    ) -> Result<(), StoreError> {
383        let mut b = match self.get_branch(name)? {
384            Some(b) => b,
385            None if name == DEFAULT_BRANCH => Branch {
386                name: DEFAULT_BRANCH.into(),
387                parent: None,
388                head_op: None,
389                predicate: None,
390                merges: Vec::new(),
391                created_at: now(),
392                last_gate_checkpoint: None,
393            },
394            None => return Err(StoreError::UnknownBranch(name.into())),
395        };
396        // #256: every successful advance also moves the gate
397        // checkpoint to the new head. The gate that ran before this
398        // call has already verified everything in
399        // `(last_gate_checkpoint .. new_head]`, so the new head is
400        // now the verified frontier.
401        b.head_op = Some(head_op.clone());
402        b.last_gate_checkpoint = Some(head_op);
403        fs::create_dir_all(self.branches_dir())?;
404        write_branch_atomic(&self.branch_path(name), &b)?;
405        Ok(())
406    }
407
408    /// Atomic compare-and-swap on `branch.head_op` (#262). Holds
409    /// an advisory `flock` on a per-branch lock file across the
410    /// read-compare-write sequence so two concurrent writers can't
411    /// both see the same `head_op`, both decide to advance, and
412    /// both succeed (silently dropping one's lineage).
413    ///
414    /// Returns `Ok(())` when `expected == current head_op` and the
415    /// new value is durably written; `Err(CasFailed { actual })`
416    /// when the actual head doesn't match `expected`. Callers
417    /// should re-read the head, rebuild the candidate op against
418    /// the new parent, and retry. Op records are content-addressed
419    /// and idempotent, so re-persisting under a new parent is safe.
420    ///
421    /// Crash safety: same tempfile + rename + fsync as the
422    /// non-CAS path. The lock is the only addition; on a crashed
423    /// process the OS releases the flock and the next writer can
424    /// proceed.
425    pub(crate) fn set_branch_head_op_cas(
426        &self,
427        name: &str,
428        expected: Option<OpId>,
429        new: OpId,
430    ) -> Result<(), CasFailed> {
431        // Acquire the per-branch advisory lock. Path is
432        // `<branches_dir>/<name>.lock`; the file is created on
433        // first use and reused thereafter. We hold the lock for
434        // the entire RMW sequence.
435        fs::create_dir_all(self.branches_dir())
436            .map_err(|e| CasFailed::Io(e.to_string()))?;
437        let lock_path = self.branches_dir().join(format!("{name}.lock"));
438        let lock_file = fs::OpenOptions::new()
439            .create(true)
440            .truncate(false)
441            .read(true)
442            .write(true)
443            .open(&lock_path)
444            .map_err(|e| CasFailed::Io(e.to_string()))?;
445        use fs2::FileExt;
446        lock_file.lock_exclusive()
447            .map_err(|e| CasFailed::Io(e.to_string()))?;
448
449        // Critical section: read, compare, write. The lock is
450        // released when `lock_file` drops at end of scope (or on
451        // early return).
452        let result = (|| -> Result<(), CasFailed> {
453            let actual = self.get_branch(name)
454                .map_err(|e| CasFailed::Io(format!("{e}")))?
455                .and_then(|b| b.head_op);
456            if actual != expected {
457                return Err(CasFailed::Mismatch { actual });
458            }
459            let mut b = match self.get_branch(name)
460                .map_err(|e| CasFailed::Io(format!("{e}")))?
461            {
462                Some(b) => b,
463                None if name == DEFAULT_BRANCH => Branch {
464                    name: DEFAULT_BRANCH.into(),
465                    parent: None,
466                    head_op: None,
467                    predicate: None,
468                    merges: Vec::new(),
469                    created_at: now(),
470                    last_gate_checkpoint: None,
471                },
472                None => return Err(CasFailed::UnknownBranch(name.into())),
473            };
474            b.head_op = Some(new.clone());
475            b.last_gate_checkpoint = Some(new);
476            write_branch_atomic(&self.branch_path(name), &b)
477                .map_err(|e| CasFailed::Io(format!("{e}")))?;
478            Ok(())
479        })();
480        // Best-effort unlock; OS releases on file close anyway.
481        let _ = fs2::FileExt::unlock(&lock_file);
482        result
483    }
484
485    /// Invalidate every branch's `last_gate_checkpoint` (#256). Run
486    /// when a new `ProducerBlock` attestation lands so the next
487    /// branch advance walks back from genesis once and re-verifies
488    /// the full chain. Returns the number of branches whose
489    /// checkpoint changed.
490    pub fn invalidate_gate_checkpoints(&self) -> Result<usize, StoreError> {
491        let dir = self.branches_dir();
492        if !dir.exists() {
493            return Ok(0);
494        }
495        let mut updated = 0usize;
496        for entry in fs::read_dir(&dir)? {
497            let entry = entry?;
498            let path = entry.path();
499            if path.extension().is_none_or(|e| e != "json") { continue; }
500            let bytes = fs::read(&path)?;
501            let mut b: Branch = match serde_json::from_slice(&bytes) {
502                Ok(b) => b,
503                // Corrupt branch file shouldn't take down the
504                // invalidation pass; the next gate run will surface
505                // the parse error on a real call path.
506                Err(_) => continue,
507            };
508            if b.last_gate_checkpoint.is_some() {
509                b.last_gate_checkpoint = None;
510                write_branch_atomic(&path, &b)?;
511                updated += 1;
512            }
513        }
514        Ok(updated)
515    }
516}
517
518/// Apply a single `StageTransition` to a sig-stage map. Used by
519/// `branch_head` to replay an op log.
520pub(crate) fn apply_transition(map: &mut BTreeMap<String, String>, t: &StageTransition) {
521    match t {
522        StageTransition::Create { sig_id, stage_id }
523        | StageTransition::Replace { sig_id, to: stage_id, .. } => {
524            map.insert(sig_id.clone(), stage_id.clone());
525        }
526        StageTransition::Remove { sig_id, .. } => {
527            map.remove(sig_id);
528        }
529        StageTransition::Rename { from, to, body_stage_id } => {
530            map.remove(from);
531            map.insert(to.clone(), body_stage_id.clone());
532        }
533        StageTransition::ImportOnly => {}
534        StageTransition::Merge { entries } => {
535            for (sig, stage) in entries {
536                match stage {
537                    Some(s) => { map.insert(sig.clone(), s.clone()); }
538                    None    => { map.remove(sig); }
539                }
540            }
541        }
542    }
543}
544
545fn write_branch_atomic(path: &std::path::Path, b: &Branch) -> Result<(), StoreError> {
546    use std::io::Write;
547    let bytes = serde_json::to_vec_pretty(b)?;
548    let tmp = path.with_extension("json.tmp");
549    let mut f = fs::File::create(&tmp)?;
550    f.write_all(&bytes)?;
551    f.sync_all()?;
552    fs::rename(&tmp, path)?;
553    Ok(())
554}
555
556fn now() -> u64 {
557    use std::time::{SystemTime, UNIX_EPOCH};
558    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
559}
560
561impl Store {
562    pub fn merge(&self, src: &str, dst: &str) -> Result<MergeReport, StoreError> {
563        let log = OpLog::open(self.root())?;
564        let src_head = self.get_branch(src)?.and_then(|b| b.head_op);
565        let dst_head = match self.get_branch(dst)? {
566            Some(b) => b.head_op,
567            None if dst == DEFAULT_BRANCH => None,
568            None => return Err(StoreError::UnknownBranch(dst.into())),
569        };
570        let out = lex_vcs::merge(&log, src_head.as_ref(), dst_head.as_ref())?;
571
572        let mut report = MergeReport {
573            summary: MergeSummary {
574                base: out.lca.clone(),
575                src: src.into(),
576                dst: dst.into(),
577                ..Default::default()
578            },
579            merged: Vec::new(),
580            conflicts: Vec::new(),
581            removed: Vec::new(),
582        };
583        for o in out.outcomes {
584            match o {
585                lex_vcs::MergeOutcome::Both { sig_id, stage_id } => {
586                    if let Some(stage_id) = stage_id {
587                        report.merged.push(MergeEntry { sig_id, stage_id, from: "both" });
588                    }
589                }
590                lex_vcs::MergeOutcome::Src { sig_id, stage_id } => match stage_id {
591                    Some(stage_id) => report.merged.push(MergeEntry { sig_id, stage_id, from: "src" }),
592                    None => report.removed.push(sig_id),
593                },
594                lex_vcs::MergeOutcome::Dst { sig_id, stage_id } => match stage_id {
595                    Some(stage_id) => report.merged.push(MergeEntry { sig_id, stage_id, from: "dst" }),
596                    None => report.removed.push(sig_id),
597                },
598                lex_vcs::MergeOutcome::Conflict { sig_id, kind, base: base_stage, src: src_stage, dst: dst_stage } => {
599                    // #838: a ModifyModify where both sides edited the
600                    // same function is not necessarily a conflict — if
601                    // the edits touch disjoint subtrees (different match
602                    // arms, different let bindings) and the merged body
603                    // type-checks, compose them into a new typed stage
604                    // instead. `ours` = dst side, `theirs` = src side.
605                    // (`dst` here is the destination branch name.)
606                    if let (lex_vcs::ConflictKind::ModifyModify, Some(b), Some(s), Some(d)) =
607                        (&kind, &base_stage, &src_stage, &dst_stage)
608                    {
609                        if let Some(merged_id) =
610                            self.try_semantic_body_merge(dst, &sig_id, b, d, s)?
611                        {
612                            report.merged.push(MergeEntry {
613                                sig_id,
614                                stage_id: merged_id,
615                                from: "semantic",
616                            });
617                            continue;
618                        }
619                    }
620                    let kind: &'static str = match kind {
621                        lex_vcs::ConflictKind::ModifyModify => "modify-modify",
622                        lex_vcs::ConflictKind::ModifyDelete => "modify-delete",
623                        lex_vcs::ConflictKind::DeleteModify => "delete-modify",
624                        lex_vcs::ConflictKind::AddAdd       => "add-add",
625                    };
626                    report.conflicts.push(MergeConflict {
627                        sig_id, kind, base: base_stage, src: src_stage, dst: dst_stage,
628                    });
629                }
630            }
631        }
632        report.summary.clean = report.merged.len();
633        report.summary.conflicts = report.conflicts.len();
634        report.summary.total_sigs = report.merged.len() + report.conflicts.len();
635        Ok(report)
636    }
637
638    pub fn commit_merge(&self, dst: &str, report: &MergeReport) -> Result<(), StoreError> {
639        if !report.conflicts.is_empty() {
640            return Err(StoreError::InvalidTransition(format!(
641                "{} conflicts; resolve before committing", report.conflicts.len())));
642        }
643        let dst_head_map = self.branch_head(dst)?;
644        let mut entries: BTreeMap<String, Option<String>> = BTreeMap::new();
645        for m in &report.merged {
646            let cur = dst_head_map.get(&m.sig_id);
647            if cur != Some(&m.stage_id) {
648                entries.insert(m.sig_id.clone(), Some(m.stage_id.clone()));
649            }
650        }
651        // #841: propagate removals the merge decided on. Only those dst
652        // still has need an entry (removing something dst lacks is a
653        // no-op).
654        for sig in &report.removed {
655            if dst_head_map.contains_key(sig) {
656                entries.insert(sig.clone(), None);
657            }
658        }
659        let src_head = self.get_branch(&report.summary.src)?.and_then(|b| b.head_op);
660        let dst_head_op = self.get_branch(dst)?.and_then(|b| b.head_op);
661
662        match (src_head.clone(), dst_head_op.clone()) {
663            // Fast-forward: dst is empty, just adopt src's head.
664            (Some(s), None) => {
665                self.set_branch_head_op(dst, s)?;
666            }
667            // Both sides have heads at the same op: nothing structural
668            // to merge. Skip apply but still journal below.
669            (Some(s), Some(d)) if s == d => { /* no-op */ }
670            (Some(s), Some(d)) => {
671                // Git convention: first parent is the branch being merged
672                // INTO (dst), second is the one merged in (src). The HTTP
673                // and CLI commit handlers already use this order; #841
674                // aligns commit_merge so the same merge yields the same
675                // head on every path.
676                let op = lex_vcs::Operation::new(
677                    lex_vcs::OperationKind::Merge { resolved: entries.len() },
678                    [d, s],
679                );
680                let t = lex_vcs::StageTransition::Merge { entries };
681                // Gated (#833): land the merge op, type-check the real
682                // post-merge head, and roll back if it doesn't compose.
683                let _ = self.apply_merge_op_gated(dst, op, t)?;
684            }
685            // src empty: nothing to merge in. Treat as no-op.
686            (None, _) => { /* no-op */ }
687        }
688
689        // Atomicity note: the merge op is durable after apply_operation
690        // returns; the journal entry below is a separate write. A
691        // crash between leaves the merge in the op DAG but no journal
692        // row — `lex log` will be missing this merge. The branch is
693        // still functionally correct (head_op points at the merge op,
694        // which carries `entries`), so the gap is recoverable by
695        // re-running commit_merge once (which will journal but skip
696        // the apply on the same-head match arm above). Tier-1 single-
697        // writer assumption applies; multi-writer locking is on the
698        // table for #130.
699
700        // Journal the merge so `lex log` can show it.
701        let mut b = self.get_branch(dst)?
702            .ok_or_else(|| StoreError::UnknownBranch(dst.into()))?;
703        if !report.summary.src.is_empty() {
704            b.merges.push(MergeRecord {
705                src: report.summary.src.clone(),
706                at: now(),
707                merged: report.merged.len(),
708                conflicts: 0,
709            });
710            write_branch_atomic(&self.branch_path(dst), &b)?;
711        }
712        Ok(())
713    }
714}
715
716#[cfg(test)]
717mod branch_head_snapshot_tests {
718    use super::*;
719    use lex_vcs::{Operation, OperationKind};
720    use std::collections::BTreeSet;
721
722    fn add(store: &Store, sig: &str, stg: &str) -> OpId {
723        let parent = store.get_branch(DEFAULT_BRANCH).unwrap().and_then(|b| b.head_op);
724        let op = Operation::new(
725            OperationKind::AddFunction {
726                sig_id: sig.into(),
727                stage_id: stg.into(),
728                effects: BTreeSet::new(),
729                budget_cost: None,
730            },
731            parent.into_iter().collect::<Vec<_>>(),
732        );
733        let transition = StageTransition::Create { sig_id: sig.into(), stage_id: stg.into() };
734        store.apply_operation(DEFAULT_BRANCH, op, transition).unwrap()
735    }
736
737    /// The fallback this exercises can't be reached through the public
738    /// API alone: `apply_operation`'s CAS retry always rebuilds a
739    /// single-parent op's `parents` to match the *current* head, so
740    /// there is no ordinary way to advance a branch to an op that
741    /// doesn't descend from its own history. `set_branch_head_op`
742    /// (crate-internal) is what a real reset/rebase operation would
743    /// eventually call, so this directly forces that same shape: a
744    /// head whose ancestry does NOT include the op the persisted
745    /// snapshot was computed for.
746    #[test]
747    fn branch_head_falls_back_to_full_walk_when_snapshot_predates_a_reset() {
748        let tmp = tempfile::tempdir().unwrap();
749        let store = Store::open(tmp.path()).unwrap();
750
751        add(&store, "fn::a", "stage_a");
752        add(&store, "fn::b", "stage_b");
753        let snapshotted = store.branch_head(DEFAULT_BRANCH).unwrap();
754        assert_eq!(snapshotted.len(), 2, "sanity: snapshot covers both ops");
755
756        // Force the branch onto a disconnected, single-op history —
757        // the snapshot's op is not among its ancestors.
758        let reset_op = Operation::new(
759            OperationKind::AddFunction {
760                sig_id: "fn::reset_only".into(),
761                stage_id: "stage_reset".into(),
762                effects: BTreeSet::new(),
763                budget_cost: None,
764            },
765            Vec::new(), // no parents: a fresh root, unrelated to fn::a/fn::b
766        );
767        let reset_op_id = reset_op.op_id();
768        let reset_record = lex_vcs::OperationRecord::new(
769            reset_op,
770            StageTransition::Create {
771                sig_id: "fn::reset_only".into(),
772                stage_id: "stage_reset".into(),
773            },
774        );
775        let log = OpLog::open(store.root()).unwrap();
776        log.put(&reset_record).unwrap();
777        store.set_branch_head_op(DEFAULT_BRANCH, reset_op_id).unwrap();
778
779        let after_reset = store.branch_head(DEFAULT_BRANCH).unwrap();
780        assert_eq!(
781            after_reset.len(), 1,
782            "stale snapshot must not be reused across a non-ancestor head change: {after_reset:?}"
783        );
784        assert_eq!(after_reset.get("fn::reset_only"), Some(&"stage_reset".to_string()));
785        assert!(!after_reset.contains_key("fn::a"));
786        assert!(!after_reset.contains_key("fn::b"));
787
788        // A repeat call must now hit the (correctly refreshed) snapshot
789        // and still agree.
790        let again = store.branch_head(DEFAULT_BRANCH).unwrap();
791        assert_eq!(after_reset, again);
792    }
793}