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}