1use 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#[derive(Debug)]
22pub enum CasFailed {
23 #[allow(dead_code)] Mismatch { actual: Option<OpId> },
32 UnknownBranch(String),
34 Io(String),
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
43#[serde(rename_all = "snake_case")]
44pub enum BranchAdvance {
45 Created,
47 UpToDate,
49 FastForward,
51}
52
53pub const DEFAULT_BRANCH: &str = "main";
54
55#[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 #[serde(default)]
72 pub head_op: Option<OpId>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub predicate: Option<serde_json::Value>,
79 #[serde(default)]
81 pub merges: Vec<MergeRecord>,
82 pub created_at: u64,
83 #[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 #[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, }
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 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 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 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 }
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 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 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 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 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 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 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 pub(crate) fn set_branch_head_op_cas(
496 &self,
497 name: &str,
498 expected: Option<OpId>,
499 new: OpId,
500 ) -> Result<(), CasFailed> {
501 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 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 let _ = fs2::FileExt::unlock(&lock_file);
552 result
553 }
554
555 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 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
588pub(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 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 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 (Some(s), None) => {
735 self.set_branch_head_op(dst, s)?;
736 }
737 (Some(s), Some(d)) if s == d => { }
740 (Some(s), Some(d)) => {
741 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 let _ = self.apply_merge_op_gated(dst, op, t)?;
754 }
755 (None, _) => { }
757 }
758
759 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 #[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 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(), );
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 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 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 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 assert_eq!(store.advance_branch_head_ff("feat", &c).unwrap(), BranchAdvance::UpToDate);
881
882 store.set_branch_head_op("feat", b.clone()).unwrap();
884 assert_eq!(store.advance_branch_head_ff("feat", &c).unwrap(), BranchAdvance::FastForward);
885
886 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}