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