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