1#![allow(clippy::missing_errors_doc)]
28
29use std::fmt;
30use std::io::Write;
31use std::path::Path;
32use std::process::{Command, Stdio};
33
34use crate::model::types::GitOid;
35
36pub const EPOCH_CURRENT: &str = "refs/manifold/epoch/current";
45
46pub const HEAD_PREFIX: &str = "refs/manifold/head/";
48
49pub const WORKSPACE_STATE_PREFIX: &str = "refs/manifold/ws/";
51
52pub const WORKSPACE_EPOCH_PREFIX: &str = "refs/manifold/epoch/ws/";
58
59#[must_use]
67pub fn workspace_head_ref(workspace_name: &str) -> String {
68 format!("{HEAD_PREFIX}{workspace_name}")
69}
70
71#[must_use]
79pub fn workspace_state_ref(workspace_name: &str) -> String {
80 format!("{WORKSPACE_STATE_PREFIX}{workspace_name}")
81}
82
83#[must_use]
95pub fn workspace_epoch_ref(workspace_name: &str) -> String {
96 format!("{WORKSPACE_EPOCH_PREFIX}{workspace_name}")
97}
98
99#[derive(Debug)]
105pub enum RefError {
106 GitCommand {
108 command: String,
110 stderr: String,
112 exit_code: Option<i32>,
114 },
115 Io(std::io::Error),
117 InvalidOid {
119 ref_name: String,
121 raw_value: String,
123 },
124 CasMismatch {
128 ref_name: String,
130 },
131}
132
133impl fmt::Display for RefError {
134 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135 match self {
136 Self::GitCommand {
137 command,
138 stderr,
139 exit_code,
140 } => {
141 write!(f, "`{command}` failed")?;
142 if let Some(code) = exit_code {
143 write!(f, " (exit code {code})")?;
144 }
145 if !stderr.is_empty() {
146 write!(f, ": {stderr}")?;
147 }
148 Ok(())
149 }
150 Self::Io(e) => write!(f, "I/O error spawning git: {e}"),
151 Self::InvalidOid {
152 ref_name,
153 raw_value,
154 } => {
155 write!(
156 f,
157 "invalid OID from `{ref_name}`: {raw_value:?} \
158 (expected 40 lowercase hex characters)"
159 )
160 }
161 Self::CasMismatch { ref_name } => {
162 write!(
163 f,
164 "CAS failed for `{ref_name}`: ref was modified concurrently — \
165 read the current value and retry"
166 )
167 }
168 }
169 }
170}
171
172impl std::error::Error for RefError {
173 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
174 if let Self::Io(e) = self {
175 Some(e)
176 } else {
177 None
178 }
179 }
180}
181
182impl From<std::io::Error> for RefError {
183 fn from(e: std::io::Error) -> Self {
184 Self::Io(e)
185 }
186}
187
188pub fn read_ref(root: &Path, name: &str) -> Result<Option<GitOid>, RefError> {
201 let output = Command::new("git")
202 .args(["rev-parse", name])
203 .current_dir(root)
204 .output()?;
205
206 if output.status.success() {
207 let raw = String::from_utf8_lossy(&output.stdout);
208 let oid_str = raw.trim();
209 let oid = GitOid::new(oid_str).map_err(|_| RefError::InvalidOid {
210 ref_name: name.to_owned(),
211 raw_value: oid_str.to_owned(),
212 })?;
213 return Ok(Some(oid));
214 }
215
216 let stderr = String::from_utf8_lossy(&output.stderr);
218 let stderr_trimmed = stderr.trim();
219
220 if stderr_trimmed.contains("unknown revision")
223 || stderr_trimmed.contains("ambiguous argument")
224 || stderr_trimmed.contains("not a valid object")
225 {
226 return Ok(None);
227 }
228
229 Err(RefError::GitCommand {
230 command: format!("git rev-parse {name}"),
231 stderr: stderr_trimmed.to_owned(),
232 exit_code: output.status.code(),
233 })
234}
235
236pub fn write_ref(root: &Path, name: &str, oid: &GitOid) -> Result<(), RefError> {
247 let output = Command::new("git")
248 .args(["update-ref", name, oid.as_str()])
249 .current_dir(root)
250 .output()?;
251
252 if output.status.success() {
253 return Ok(());
254 }
255
256 Err(RefError::GitCommand {
257 command: format!("git update-ref {name} {}", oid.as_str()),
258 stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
259 exit_code: output.status.code(),
260 })
261}
262
263pub fn write_ref_cas(
285 root: &Path,
286 name: &str,
287 old_oid: &GitOid,
288 new_oid: &GitOid,
289) -> Result<(), RefError> {
290 let output = Command::new("git")
291 .args(["update-ref", name, new_oid.as_str(), old_oid.as_str()])
292 .current_dir(root)
293 .output()?;
294
295 if output.status.success() {
296 return Ok(());
297 }
298
299 let stderr = String::from_utf8_lossy(&output.stderr);
300 let stderr_trimmed = stderr.trim();
301
302 if stderr_trimmed.contains("cannot lock ref")
305 || stderr_trimmed.contains("is at")
306 || stderr_trimmed.contains("but expected")
307 {
308 return Err(RefError::CasMismatch {
309 ref_name: name.to_owned(),
310 });
311 }
312
313 Err(RefError::GitCommand {
314 command: format!(
315 "git update-ref {name} {} {}",
316 new_oid.as_str(),
317 old_oid.as_str()
318 ),
319 stderr: stderr_trimmed.to_owned(),
320 exit_code: output.status.code(),
321 })
322}
323
324pub fn update_refs_atomic(
344 root: &Path,
345 updates: &[(&str, &GitOid, &GitOid)],
346) -> Result<(), RefError> {
347 let mut child = Command::new("git")
348 .args(["update-ref", "--stdin"])
349 .current_dir(root)
350 .stdin(Stdio::piped())
351 .stdout(Stdio::piped())
352 .stderr(Stdio::piped())
353 .spawn()?;
354
355 {
356 let stdin = child.stdin.as_mut().ok_or_else(|| RefError::Io(
357 std::io::Error::new(std::io::ErrorKind::BrokenPipe, "failed to open stdin"),
358 ))?;
359
360 writeln!(stdin, "start")?;
361 for (ref_name, old_oid, new_oid) in updates {
362 writeln!(
363 stdin,
364 "update {} {} {}",
365 ref_name,
366 new_oid.as_str(),
367 old_oid.as_str()
368 )?;
369 }
370 writeln!(stdin, "prepare")?;
371 writeln!(stdin, "commit")?;
372 }
373
374 let output = child.wait_with_output()?;
375
376 if output.status.success() {
377 return Ok(());
378 }
379
380 let stderr = String::from_utf8_lossy(&output.stderr);
381 let stderr_trimmed = stderr.trim();
382
383 if stderr_trimmed.contains("cannot lock ref")
384 || stderr_trimmed.contains("is at")
385 || stderr_trimmed.contains("but expected")
386 {
387 let ref_name = updates
389 .iter()
390 .find(|(name, _, _)| stderr_trimmed.contains(name))
391 .map_or_else(
392 || updates[0].0.to_owned(),
393 |(name, _, _)| (*name).to_owned(),
394 );
395 return Err(RefError::CasMismatch { ref_name });
396 }
397
398 Err(RefError::GitCommand {
399 command: "git update-ref --stdin".to_owned(),
400 stderr: stderr_trimmed.to_owned(),
401 exit_code: output.status.code(),
402 })
403}
404
405pub fn delete_ref(root: &Path, name: &str) -> Result<(), RefError> {
416 let output = Command::new("git")
417 .args(["update-ref", "-d", name])
418 .current_dir(root)
419 .output()?;
420
421 if output.status.success() {
422 return Ok(());
423 }
424
425 let stderr = String::from_utf8_lossy(&output.stderr);
426 let stderr_trimmed = stderr.trim();
427
428 Err(RefError::GitCommand {
431 command: format!("git update-ref -d {name}"),
432 stderr: stderr_trimmed.to_owned(),
433 exit_code: output.status.code(),
434 })
435}
436
437pub fn read_epoch_current(root: &Path) -> Result<Option<GitOid>, RefError> {
445 read_ref(root, EPOCH_CURRENT)
446}
447
448pub fn write_epoch_current(root: &Path, oid: &GitOid) -> Result<(), RefError> {
452 write_ref(root, EPOCH_CURRENT, oid)
453}
454
455pub fn advance_epoch(root: &Path, old_epoch: &GitOid, new_epoch: &GitOid) -> Result<(), RefError> {
459 write_ref_cas(root, EPOCH_CURRENT, old_epoch, new_epoch)
460}
461
462#[cfg(test)]
467mod tests {
468 use super::*;
469 use std::fs;
470 use std::process::Command;
471 use tempfile::TempDir;
472
473 fn setup_repo() -> (TempDir, GitOid) {
479 let dir = TempDir::new().unwrap();
480 let root = dir.path();
481
482 Command::new("git")
483 .args(["init"])
484 .current_dir(root)
485 .output()
486 .unwrap();
487 Command::new("git")
488 .args(["config", "user.name", "Test"])
489 .current_dir(root)
490 .output()
491 .unwrap();
492 Command::new("git")
493 .args(["config", "user.email", "test@test.com"])
494 .current_dir(root)
495 .output()
496 .unwrap();
497 Command::new("git")
498 .args(["config", "commit.gpgsign", "false"])
499 .current_dir(root)
500 .output()
501 .unwrap();
502
503 fs::write(root.join("README.md"), "# Test\n").unwrap();
504 Command::new("git")
505 .args(["add", "README.md"])
506 .current_dir(root)
507 .output()
508 .unwrap();
509 Command::new("git")
510 .args(["commit", "-m", "initial"])
511 .current_dir(root)
512 .output()
513 .unwrap();
514
515 let out = Command::new("git")
516 .args(["rev-parse", "HEAD"])
517 .current_dir(root)
518 .output()
519 .unwrap();
520 let oid_str = String::from_utf8_lossy(&out.stdout).trim().to_owned();
521 let oid = GitOid::new(&oid_str).unwrap();
522
523 (dir, oid)
524 }
525
526 fn add_commit(root: &std::path::Path) -> GitOid {
528 fs::write(root.join("extra.txt"), "extra\n").unwrap();
529 Command::new("git")
530 .args(["add", "extra.txt"])
531 .current_dir(root)
532 .output()
533 .unwrap();
534 Command::new("git")
535 .args(["commit", "-m", "second"])
536 .current_dir(root)
537 .output()
538 .unwrap();
539
540 let out = Command::new("git")
541 .args(["rev-parse", "HEAD"])
542 .current_dir(root)
543 .output()
544 .unwrap();
545 let oid_str = String::from_utf8_lossy(&out.stdout).trim().to_owned();
546 GitOid::new(&oid_str).unwrap()
547 }
548
549 #[test]
554 fn workspace_head_ref_format() {
555 assert_eq!(workspace_head_ref("default"), "refs/manifold/head/default");
556 assert_eq!(workspace_head_ref("agent-1"), "refs/manifold/head/agent-1");
557 }
558
559 #[test]
560 fn workspace_state_ref_format() {
561 assert_eq!(workspace_state_ref("default"), "refs/manifold/ws/default");
562 assert_eq!(workspace_state_ref("agent-1"), "refs/manifold/ws/agent-1");
563 }
564
565 #[test]
566 fn workspace_epoch_ref_format() {
567 assert_eq!(
568 workspace_epoch_ref("agent-1"),
569 "refs/manifold/epoch/ws/agent-1"
570 );
571 assert_eq!(
572 workspace_epoch_ref("default"),
573 "refs/manifold/epoch/ws/default"
574 );
575 }
576
577 #[test]
582 fn read_ref_existing() {
583 let (dir, oid) = setup_repo();
584 let root = dir.path();
585
586 Command::new("git")
588 .args(["update-ref", "refs/manifold/epoch/current", oid.as_str()])
589 .current_dir(root)
590 .output()
591 .unwrap();
592
593 let result = read_ref(root, "refs/manifold/epoch/current").unwrap();
594 assert_eq!(result, Some(oid));
595 }
596
597 #[test]
598 fn read_ref_missing_returns_none() {
599 let (dir, _oid) = setup_repo();
600 let root = dir.path();
601
602 let result = read_ref(root, "refs/manifold/does-not-exist").unwrap();
603 assert_eq!(result, None);
604 }
605
606 #[test]
607 fn read_ref_head() {
608 let (dir, oid) = setup_repo();
609 let root = dir.path();
610
611 let result = read_ref(root, "HEAD").unwrap();
613 assert_eq!(result, Some(oid));
614 }
615
616 #[test]
621 fn write_ref_creates_new() {
622 let (dir, oid) = setup_repo();
623 let root = dir.path();
624
625 write_ref(root, EPOCH_CURRENT, &oid).unwrap();
626
627 let result = read_ref(root, EPOCH_CURRENT).unwrap();
628 assert_eq!(result, Some(oid));
629 }
630
631 #[test]
632 fn write_ref_overwrites_existing() {
633 let (dir, first_oid) = setup_repo();
634 let root = dir.path();
635 let second_oid = add_commit(root);
636
637 write_ref(root, EPOCH_CURRENT, &first_oid).unwrap();
638 write_ref(root, EPOCH_CURRENT, &second_oid).unwrap();
639
640 let result = read_ref(root, EPOCH_CURRENT).unwrap();
641 assert_eq!(result, Some(second_oid));
642 }
643
644 #[test]
649 fn write_ref_cas_succeeds_with_correct_old_value() {
650 let (dir, first_oid) = setup_repo();
651 let root = dir.path();
652 let second_oid = add_commit(root);
653
654 write_ref(root, EPOCH_CURRENT, &first_oid).unwrap();
656
657 write_ref_cas(root, EPOCH_CURRENT, &first_oid, &second_oid).unwrap();
659
660 let result = read_ref(root, EPOCH_CURRENT).unwrap();
661 assert_eq!(result, Some(second_oid));
662 }
663
664 #[test]
665 fn write_ref_cas_fails_with_wrong_old_value() {
666 let (dir, first_oid) = setup_repo();
667 let root = dir.path();
668 let second_oid = add_commit(root);
669 let third_oid = add_commit(root);
670
671 write_ref(root, EPOCH_CURRENT, &second_oid).unwrap();
673
674 let err = write_ref_cas(root, EPOCH_CURRENT, &first_oid, &third_oid).unwrap_err();
676 assert!(
677 matches!(err, RefError::CasMismatch { .. }),
678 "expected CasMismatch, got: {err}"
679 );
680
681 let result = read_ref(root, EPOCH_CURRENT).unwrap();
683 assert_eq!(result, Some(second_oid));
684 }
685
686 #[test]
687 fn write_ref_cas_prevents_concurrent_advance() {
688 let (dir, v1) = setup_repo();
693 let root = dir.path();
694 let v2 = add_commit(root);
695 let v3 = add_commit(root);
696
697 write_ref(root, EPOCH_CURRENT, &v1).unwrap();
698
699 write_ref_cas(root, EPOCH_CURRENT, &v1, &v2).unwrap();
701
702 let err = write_ref_cas(root, EPOCH_CURRENT, &v1, &v3).unwrap_err();
704 assert!(
705 matches!(err, RefError::CasMismatch { .. }),
706 "agent B should lose the race: {err}"
707 );
708
709 let result = read_ref(root, EPOCH_CURRENT).unwrap();
711 assert_eq!(result, Some(v2));
712 }
713
714 #[test]
719 fn delete_ref_removes_existing() {
720 let (dir, oid) = setup_repo();
721 let root = dir.path();
722
723 write_ref(root, EPOCH_CURRENT, &oid).unwrap();
724 assert!(read_ref(root, EPOCH_CURRENT).unwrap().is_some());
725
726 delete_ref(root, EPOCH_CURRENT).unwrap();
727 assert!(read_ref(root, EPOCH_CURRENT).unwrap().is_none());
728 }
729
730 #[test]
731 fn delete_ref_idempotent() {
732 let (dir, oid) = setup_repo();
733 let root = dir.path();
734
735 write_ref(root, EPOCH_CURRENT, &oid).unwrap();
736 delete_ref(root, EPOCH_CURRENT).unwrap();
737 delete_ref(root, EPOCH_CURRENT).unwrap();
739 }
740
741 #[test]
742 fn delete_ref_missing_is_noop() {
743 let (dir, _) = setup_repo();
744 let root = dir.path();
745
746 delete_ref(root, "refs/manifold/nonexistent").unwrap();
748 }
749
750 #[test]
755 fn read_epoch_current_missing() {
756 let (dir, _) = setup_repo();
757 assert!(read_epoch_current(dir.path()).unwrap().is_none());
758 }
759
760 #[test]
761 fn write_and_read_epoch_current() {
762 let (dir, oid) = setup_repo();
763 let root = dir.path();
764
765 write_epoch_current(root, &oid).unwrap();
766 let result = read_epoch_current(root).unwrap();
767 assert_eq!(result, Some(oid));
768 }
769
770 #[test]
771 fn advance_epoch_happy_path() {
772 let (dir, v1) = setup_repo();
773 let root = dir.path();
774 let v2 = add_commit(root);
775
776 write_epoch_current(root, &v1).unwrap();
777 advance_epoch(root, &v1, &v2).unwrap();
778
779 assert_eq!(read_epoch_current(root).unwrap(), Some(v2));
780 }
781
782 #[test]
783 fn advance_epoch_stale_fails() {
784 let (dir, v1) = setup_repo();
785 let root = dir.path();
786 let v2 = add_commit(root);
787 let v3 = add_commit(root);
788
789 write_epoch_current(root, &v2).unwrap();
790
791 let err = advance_epoch(root, &v1, &v3).unwrap_err();
793 assert!(
794 matches!(err, RefError::CasMismatch { .. }),
795 "expected CasMismatch: {err}"
796 );
797 }
798
799 #[test]
804 fn update_refs_atomic_moves_both_refs() {
805 let (dir, v1) = setup_repo();
806 let root = dir.path();
807 let v2 = add_commit(root);
808
809 write_ref(root, EPOCH_CURRENT, &v1).unwrap();
810 write_ref(root, "refs/heads/test-branch", &v1).unwrap();
811
812 update_refs_atomic(
813 root,
814 &[
815 (EPOCH_CURRENT, &v1, &v2),
816 ("refs/heads/test-branch", &v1, &v2),
817 ],
818 )
819 .unwrap();
820
821 assert_eq!(read_ref(root, EPOCH_CURRENT).unwrap(), Some(v2.clone()));
822 assert_eq!(
823 read_ref(root, "refs/heads/test-branch").unwrap(),
824 Some(v2)
825 );
826 }
827
828 #[test]
829 fn update_refs_atomic_fails_if_any_ref_stale() {
830 let (dir, v1) = setup_repo();
831 let root = dir.path();
832 let v2 = add_commit(root);
833 let v3 = add_commit(root);
834
835 write_ref(root, EPOCH_CURRENT, &v2).unwrap();
837 write_ref(root, "refs/heads/test-branch", &v1).unwrap();
838
839 let err = update_refs_atomic(
841 root,
842 &[
843 (EPOCH_CURRENT, &v1, &v3),
844 ("refs/heads/test-branch", &v1, &v3),
845 ],
846 )
847 .unwrap_err();
848
849 assert!(
850 matches!(err, RefError::CasMismatch { .. }),
851 "expected CasMismatch, got: {err}"
852 );
853
854 assert_eq!(read_ref(root, EPOCH_CURRENT).unwrap(), Some(v2));
856 assert_eq!(
857 read_ref(root, "refs/heads/test-branch").unwrap(),
858 Some(v1)
859 );
860 }
861
862 #[test]
863 fn update_refs_atomic_single_ref() {
864 let (dir, v1) = setup_repo();
865 let root = dir.path();
866 let v2 = add_commit(root);
867
868 write_ref(root, EPOCH_CURRENT, &v1).unwrap();
869
870 update_refs_atomic(root, &[(EPOCH_CURRENT, &v1, &v2)]).unwrap();
871
872 assert_eq!(read_ref(root, EPOCH_CURRENT).unwrap(), Some(v2));
873 }
874
875 #[test]
880 fn error_display_git_command() {
881 let err = RefError::GitCommand {
882 command: "git update-ref refs/manifold/epoch/current abc123".to_owned(),
883 stderr: "fatal: bad object".to_owned(),
884 exit_code: Some(128),
885 };
886 let msg = format!("{err}");
887 assert!(msg.contains("git update-ref"));
888 assert!(msg.contains("128"));
889 assert!(msg.contains("fatal: bad object"));
890 }
891
892 #[test]
893 fn error_display_cas_mismatch() {
894 let err = RefError::CasMismatch {
895 ref_name: "refs/manifold/epoch/current".to_owned(),
896 };
897 let msg = format!("{err}");
898 assert!(msg.contains("CAS failed"));
899 assert!(msg.contains("refs/manifold/epoch/current"));
900 assert!(msg.contains("concurrently"));
901 }
902
903 #[test]
904 fn error_display_invalid_oid() {
905 let err = RefError::InvalidOid {
906 ref_name: "refs/manifold/epoch/current".to_owned(),
907 raw_value: "garbage".to_owned(),
908 };
909 let msg = format!("{err}");
910 assert!(msg.contains("invalid OID"));
911 assert!(msg.contains("garbage"));
912 }
913}