1use std::fs;
28use std::io;
29use std::path::{Path, PathBuf};
30
31use crate::atomic::{write_atomic, write_create_new};
32use crate::hash::{HASH_LEN, HEX_LEN, Hash, to_hex};
33
34pub const REFS_DIR: &str = "refs";
36pub const HEADS_DIR: &str = "refs/heads";
38pub const TAGS_DIR: &str = "refs/tags";
40pub const REMOTES_DIR: &str = "refs/remotes";
42pub const HEAD_FILE: &str = "HEAD";
44pub const SHALLOW_FILE: &str = "shallow";
46
47const HEAD_REF_PREFIX: &str = "ref: refs/heads/";
49
50const HEAD_MAX_BYTES: u64 = 4 * 1024;
52const REF_FILE_MAX_BYTES: u64 = 128;
57const SHALLOW_MAX_BYTES: u64 = 1024 * 1024;
59
60#[derive(Debug, thiserror::Error)]
62pub enum RefError {
63 #[error("invalid ref name '{0}'")]
65 InvalidRefName(String),
66 #[error("invalid ref content for '{0}'")]
69 InvalidRef(String),
70 #[error("HEAD is not a valid symbolic-ref or detached-hash file")]
73 InvalidHead,
74 #[error("HEAD is not present")]
76 NoHead,
77 #[error("ref '{0}' did not satisfy CAS condition")]
79 Conflict(String),
80 #[error("ref '{0}' not found")]
82 NotFound(String),
83 #[error("cannot delete the current branch '{0}'")]
85 CurrentBranch(String),
86 #[error(transparent)]
88 Io(#[from] io::Error),
89}
90
91pub type RefResult<T> = Result<T, RefError>;
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum RefWriteCondition {
97 Any,
99 Missing,
101 Match(Hash),
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum Head {
109 Branch(String),
111 Detached(Hash),
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Ref {
119 pub name: String,
121 pub hash: Option<Hash>,
125}
126
127#[must_use]
140pub fn validate_ref_name(name: &str) -> bool {
141 if name.is_empty() {
142 return false;
143 }
144 if name.starts_with('/') {
145 return false;
146 }
147 let mut last_part: &str = "";
148 for part in name.split('/') {
149 if part.is_empty() {
150 return false;
151 }
152 if part == "." || part == ".." {
153 return false;
154 }
155 let bytes = part.as_bytes();
160 if bytes.len() >= 5 && &bytes[bytes.len() - 5..] == b".lock" {
161 return false;
162 }
163 for &c in part.as_bytes() {
164 if c == 0 || c == b'\\' {
165 return false;
166 }
167 let allowed = c.is_ascii_alphanumeric() || c == b'.' || c == b'_' || c == b'-';
168 if !allowed {
169 return false;
170 }
171 }
172 last_part = part;
173 }
174 if last_part == "HEAD" {
175 return false;
176 }
177 true
178}
179
180#[must_use]
184pub fn validate_ref_prefix(prefix: &str) -> bool {
185 if prefix.is_empty() {
186 return true;
187 }
188 let trimmed = prefix.trim_end_matches('/');
189 if trimmed.is_empty() {
190 return false;
191 }
192 validate_ref_name(trimmed)
193}
194
195#[must_use]
197pub fn encode_ref_wire(h: &Hash) -> [u8; 65] {
198 let hex = to_hex(h);
199 let bytes = hex.as_bytes();
200 let mut out = [0u8; 65];
201 out[..HEX_LEN].copy_from_slice(bytes);
202 out[HEX_LEN] = b'\n';
203 out
204}
205
206#[must_use]
214pub fn decode_ref_wire(data: &[u8]) -> Option<Hash> {
215 let s = core::str::from_utf8(data).ok()?;
216 let trimmed = s.trim_end_matches(['\n', '\r', ' ', '\t']);
217 if trimmed.len() != HEX_LEN {
218 return None;
219 }
220 parse_lowercase_hash(trimmed.as_bytes())
221}
222
223fn parse_lowercase_hash(bytes: &[u8]) -> Option<Hash> {
227 if bytes.len() != HEX_LEN {
228 return None;
229 }
230 let mut out = [0u8; HASH_LEN];
231 for i in 0..HASH_LEN {
232 let hi = lowercase_nibble(bytes[i * 2])?;
233 let lo = lowercase_nibble(bytes[i * 2 + 1])?;
234 out[i] = (hi << 4) | lo;
235 }
236 Some(out)
237}
238
239fn lowercase_nibble(b: u8) -> Option<u8> {
240 match b {
241 b'0'..=b'9' => Some(b - b'0'),
242 b'a'..=b'f' => Some(10 + (b - b'a')),
243 _ => None,
244 }
245}
246
247pub fn init(mkit_dir: &Path) -> RefResult<()> {
252 fs::create_dir_all(mkit_dir.join(REFS_DIR))?;
253 fs::create_dir_all(mkit_dir.join(HEADS_DIR))?;
254 fs::create_dir_all(mkit_dir.join(TAGS_DIR))?;
255 fs::create_dir_all(mkit_dir.join(REMOTES_DIR))?;
256 let head_path = mkit_dir.join(HEAD_FILE);
257 if !head_path.exists() {
258 let body = format!("{HEAD_REF_PREFIX}main\n");
259 write_atomic(&head_path, body.as_bytes(), false)?;
260 }
261 Ok(())
262}
263
264pub fn read_head(mkit_dir: &Path) -> RefResult<Head> {
274 let path = mkit_dir.join(HEAD_FILE);
275 let meta = match fs::metadata(&path) {
276 Ok(m) => m,
277 Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(RefError::NoHead),
278 Err(e) => return Err(RefError::Io(e)),
279 };
280 if meta.len() > HEAD_MAX_BYTES {
281 return Err(RefError::InvalidHead);
282 }
283 let raw = fs::read(&path)?;
284 let s = core::str::from_utf8(&raw).map_err(|_| RefError::InvalidHead)?;
285 let trimmed = s.trim_end_matches(['\n', '\r', ' ', '\t']);
286 if let Some(branch) = trimmed.strip_prefix(HEAD_REF_PREFIX) {
287 if !validate_ref_name(branch) {
288 return Err(RefError::InvalidHead);
289 }
290 return Ok(Head::Branch(branch.to_string()));
291 }
292 if trimmed.len() == HEX_LEN {
293 let h = parse_lowercase_hash(trimmed.as_bytes()).ok_or(RefError::InvalidHead)?;
294 return Ok(Head::Detached(h));
295 }
296 Err(RefError::InvalidHead)
297}
298
299pub fn write_head_branch(mkit_dir: &Path, branch: &str) -> RefResult<()> {
306 if !validate_ref_name(branch) {
307 return Err(RefError::InvalidRefName(branch.to_string()));
308 }
309 let body = format!("{HEAD_REF_PREFIX}{branch}\n");
310 write_atomic(&mkit_dir.join(HEAD_FILE), body.as_bytes(), false)?;
311 Ok(())
312}
313
314pub fn write_head_detached(mkit_dir: &Path, h: &Hash) -> RefResult<()> {
319 let wire = encode_ref_wire(h);
320 write_atomic(&mkit_dir.join(HEAD_FILE), &wire, false)?;
321 Ok(())
322}
323
324pub fn resolve_head(mkit_dir: &Path) -> RefResult<Option<Hash>> {
327 let head = match read_head(mkit_dir) {
328 Ok(h) => h,
329 Err(RefError::NoHead) => return Ok(None),
330 Err(e) => return Err(e),
331 };
332 match head {
333 Head::Branch(name) => read_ref(mkit_dir, &name),
334 Head::Detached(h) => Ok(Some(h)),
335 }
336}
337
338pub fn update_head(mkit_dir: &Path, commit_hash: &Hash) -> RefResult<()> {
341 let head = read_head(mkit_dir)?;
342 match head {
343 Head::Branch(name) => write_ref(mkit_dir, &name, commit_hash),
344 Head::Detached(_) => write_head_detached(mkit_dir, commit_hash),
345 }
346}
347
348pub fn read_ref(mkit_dir: &Path, branch: &str) -> RefResult<Option<Hash>> {
359 if !validate_ref_name(branch) {
360 return Err(RefError::InvalidRefName(branch.to_string()));
361 }
362 read_ref_under(mkit_dir, HEADS_DIR, branch)
363}
364
365pub fn write_ref(mkit_dir: &Path, branch: &str, h: &Hash) -> RefResult<()> {
368 update_ref(mkit_dir, branch, RefWriteCondition::Any, h)
369}
370
371pub fn update_ref(
378 mkit_dir: &Path,
379 branch: &str,
380 condition: RefWriteCondition,
381 h: &Hash,
382) -> RefResult<()> {
383 if !validate_ref_name(branch) {
384 return Err(RefError::InvalidRefName(branch.to_string()));
385 }
386 let path = ref_path(mkit_dir, HEADS_DIR, branch);
387 let wire = encode_ref_wire(h);
388 cas_write(&path, &wire, branch, condition)
389}
390
391#[cfg(feature = "history-mmr")]
447pub fn update_ref_with_history<X: crate::protocol::async_shim::Executor + 'static>(
448 mkit_dir: &Path,
449 branch: &str,
450 condition: RefWriteCondition,
451 hash: &Hash,
452 history: &mut crate::history::CommitHistory<X>,
453) -> RefResult<()> {
454 let Some(history_dir) = history.mkit_dir() else {
458 return Err(RefError::InvalidRef(format!(
459 "{branch}: update_ref_with_history requires a journaled CommitHistory (open_at)"
460 )));
461 };
462 if history_dir != mkit_dir {
463 return Err(RefError::InvalidRef(format!(
464 "{branch}: CommitHistory's mkit_dir does not match the ref's mkit_dir"
465 )));
466 }
467 if history.branch() != Some(branch) {
468 return Err(RefError::InvalidRef(format!(
469 "{branch}: CommitHistory was opened for a different branch ({:?})",
470 history.branch()
471 )));
472 }
473
474 let _lock =
478 crate::repo_lock::acquire_default(mkit_dir, "refs-history.lock").map_err(|e| match e {
479 crate::repo_lock::LockError::Io(io) => RefError::Io(io),
480 other => RefError::InvalidRef(format!("{branch}: lock acquisition: {other}")),
481 })?;
482
483 update_ref(mkit_dir, branch, condition, hash)?;
484 history
485 .append(hash)
486 .map_err(|e| RefError::InvalidRef(format!("{branch}: history append: {e}")))?;
487 Ok(())
488}
489
490pub fn delete_ref(mkit_dir: &Path, branch: &str) -> RefResult<()> {
492 if !validate_ref_name(branch) {
493 return Err(RefError::InvalidRefName(branch.to_string()));
494 }
495 let path = ref_path(mkit_dir, HEADS_DIR, branch);
496 match fs::remove_file(&path) {
497 Ok(()) => Ok(()),
498 Err(e) if e.kind() == io::ErrorKind::NotFound => {
499 Err(RefError::NotFound(branch.to_string()))
500 }
501 Err(e) => Err(RefError::Io(e)),
502 }
503}
504
505pub fn delete_ref_safe(mkit_dir: &Path, branch: &str) -> RefResult<()> {
507 match read_head(mkit_dir) {
508 Ok(Head::Branch(current)) if current == branch => {
509 Err(RefError::CurrentBranch(branch.to_string()))
510 }
511 _ => delete_ref(mkit_dir, branch),
512 }
513}
514
515pub fn list_refs(mkit_dir: &Path) -> RefResult<Vec<Ref>> {
517 list_refs_under(mkit_dir, HEADS_DIR)
518}
519
520pub fn read_remote_ref(mkit_dir: &Path, remote: &str, branch: &str) -> RefResult<Option<Hash>> {
526 validate_remote_and_branch(remote, branch)?;
527 read_ref_under(mkit_dir, &remote_ref_dir(remote), branch)
528}
529
530pub fn write_remote_ref(mkit_dir: &Path, remote: &str, branch: &str, h: &Hash) -> RefResult<()> {
532 validate_remote_and_branch(remote, branch)?;
533 let path = ref_path(mkit_dir, &remote_ref_dir(remote), branch);
534 let wire = encode_ref_wire(h);
535 cas_write(&path, &wire, branch, RefWriteCondition::Any)
536}
537
538pub fn delete_remote_ref(mkit_dir: &Path, remote: &str, branch: &str) -> RefResult<()> {
541 validate_remote_and_branch(remote, branch)?;
542 let path = ref_path(mkit_dir, &remote_ref_dir(remote), branch);
543 match fs::remove_file(&path) {
544 Ok(()) => Ok(()),
545 Err(e) if e.kind() == io::ErrorKind::NotFound => {
546 Err(RefError::NotFound(format!("{remote}/{branch}")))
547 }
548 Err(e) => Err(RefError::Io(e)),
549 }
550}
551
552pub fn list_remote_refs(mkit_dir: &Path, remote: &str) -> RefResult<Vec<Ref>> {
554 if !validate_ref_name(remote) {
555 return Err(RefError::InvalidRefName(remote.to_string()));
556 }
557 list_refs_under(mkit_dir, &remote_ref_dir(remote))
558}
559
560pub fn list_remote_names(mkit_dir: &Path) -> RefResult<Vec<String>> {
566 let dir = mkit_dir.join(REMOTES_DIR);
567 let entries = match fs::read_dir(&dir) {
568 Ok(e) => e,
569 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
570 Err(e) => return Err(RefError::Io(e)),
571 };
572 let mut names = Vec::new();
573 for entry in entries {
574 let entry = entry.map_err(RefError::Io)?;
575 if !entry.file_type().map_err(RefError::Io)?.is_dir() {
576 continue;
577 }
578 if let Some(name) = entry.file_name().to_str()
579 && validate_ref_name(name)
580 {
581 names.push(name.to_owned());
582 }
583 }
584 names.sort();
585 Ok(names)
586}
587
588pub fn read_tag(mkit_dir: &Path, name: &str) -> RefResult<Option<Hash>> {
594 if !validate_ref_name(name) {
595 return Err(RefError::InvalidRefName(name.to_string()));
596 }
597 read_ref_under(mkit_dir, TAGS_DIR, name)
598}
599
600pub fn write_tag(mkit_dir: &Path, name: &str, h: &Hash) -> RefResult<()> {
602 update_tag(mkit_dir, name, RefWriteCondition::Any, h)
603}
604
605pub fn update_tag(
608 mkit_dir: &Path,
609 name: &str,
610 condition: RefWriteCondition,
611 h: &Hash,
612) -> RefResult<()> {
613 if !validate_ref_name(name) {
614 return Err(RefError::InvalidRefName(name.to_string()));
615 }
616 let path = ref_path(mkit_dir, TAGS_DIR, name);
617 let wire = encode_ref_wire(h);
618 cas_write(&path, &wire, name, condition)
619}
620
621pub fn delete_tag(mkit_dir: &Path, name: &str) -> RefResult<()> {
623 if !validate_ref_name(name) {
624 return Err(RefError::InvalidRefName(name.to_string()));
625 }
626 let path = ref_path(mkit_dir, TAGS_DIR, name);
627 match fs::remove_file(&path) {
628 Ok(()) => Ok(()),
629 Err(e) if e.kind() == io::ErrorKind::NotFound => Err(RefError::NotFound(name.to_string())),
630 Err(e) => Err(RefError::Io(e)),
631 }
632}
633
634pub fn list_tags(mkit_dir: &Path) -> RefResult<Vec<Ref>> {
636 list_refs_under(mkit_dir, TAGS_DIR)
637}
638
639pub fn load_shallow_boundaries(mkit_dir: &Path) -> RefResult<Option<Vec<Hash>>> {
646 let path = mkit_dir.join(SHALLOW_FILE);
647 let meta = match fs::metadata(&path) {
648 Ok(m) => m,
649 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
650 Err(e) => return Err(RefError::Io(e)),
651 };
652 if meta.len() == 0 {
653 return Ok(None);
654 }
655 if meta.len() > SHALLOW_MAX_BYTES {
656 return Err(RefError::InvalidRef("shallow file too large".to_string()));
657 }
658 let bytes = fs::read(&path)?;
659 let s = core::str::from_utf8(&bytes).map_err(|_| RefError::InvalidHead)?;
660 let mut out = Vec::new();
661 for line in s.split('\n') {
662 let trimmed = line.trim_end_matches(['\r', ' ', '\t']);
663 if trimmed.len() != HEX_LEN {
664 continue;
665 }
666 if let Some(h) = parse_lowercase_hash(trimmed.as_bytes()) {
667 out.push(h);
668 }
669 }
670 if out.is_empty() {
671 return Ok(None);
672 }
673 Ok(Some(out))
674}
675
676pub fn write_shallow_boundaries(mkit_dir: &Path, boundaries: &[Hash]) -> RefResult<()> {
679 let path = mkit_dir.join(SHALLOW_FILE);
680 if boundaries.is_empty() {
681 match fs::remove_file(&path) {
682 Ok(()) => Ok(()),
683 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
684 Err(e) => Err(RefError::Io(e)),
685 }
686 } else {
687 let mut out = Vec::with_capacity(boundaries.len() * 65);
688 for h in boundaries {
689 out.extend_from_slice(&encode_ref_wire(h));
690 }
691 write_atomic(&path, &out, true)?;
692 Ok(())
693 }
694}
695
696fn ref_path(mkit_dir: &Path, sub_dir: &str, name: &str) -> PathBuf {
701 let mut path = mkit_dir.join(sub_dir);
702 for segment in name.split('/') {
703 path.push(segment);
704 }
705 path
706}
707
708fn remote_ref_dir(remote: &str) -> String {
709 format!("{REMOTES_DIR}/{remote}")
710}
711
712fn validate_remote_and_branch(remote: &str, branch: &str) -> RefResult<()> {
713 if !validate_ref_name(remote) {
714 return Err(RefError::InvalidRefName(remote.to_string()));
715 }
716 if !validate_ref_name(branch) {
717 return Err(RefError::InvalidRefName(branch.to_string()));
718 }
719 Ok(())
720}
721
722fn read_ref_under(mkit_dir: &Path, sub_dir: &str, name: &str) -> RefResult<Option<Hash>> {
723 let path = ref_path(mkit_dir, sub_dir, name);
724 let meta = match fs::metadata(&path) {
725 Ok(m) => m,
726 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
727 Err(e) => return Err(RefError::Io(e)),
728 };
729 if meta.len() > REF_FILE_MAX_BYTES {
730 return Err(RefError::InvalidRef(name.to_string()));
731 }
732 let bytes = fs::read(&path)?;
733 let h = decode_ref_wire(&bytes).ok_or_else(|| RefError::InvalidRef(name.to_string()))?;
734 Ok(Some(h))
735}
736
737fn cas_write(
738 path: &Path,
739 wire: &[u8; 65],
740 name_for_err: &str,
741 condition: RefWriteCondition,
742) -> RefResult<()> {
743 match condition {
744 RefWriteCondition::Any => {
745 write_atomic(path, wire, true)?;
746 Ok(())
747 }
748 RefWriteCondition::Missing => {
749 if let Some(parent) = path.parent() {
751 fs::create_dir_all(parent)?;
752 }
753 let created = write_create_new(path, wire, true)?;
754 if !created {
755 return Err(RefError::Conflict(name_for_err.to_string()));
756 }
757 Ok(())
758 }
759 RefWriteCondition::Match(expected) => {
760 let current = match fs::read(path) {
763 Ok(b) => Some(
764 decode_ref_wire(&b)
765 .ok_or_else(|| RefError::InvalidRef(name_for_err.to_string()))?,
766 ),
767 Err(e) if e.kind() == io::ErrorKind::NotFound => None,
768 Err(e) => return Err(RefError::Io(e)),
769 };
770 if current != Some(expected) {
771 return Err(RefError::Conflict(name_for_err.to_string()));
772 }
773 write_atomic(path, wire, true)?;
774 Ok(())
775 }
776 }
777}
778
779fn list_refs_under(mkit_dir: &Path, sub_dir: &str) -> RefResult<Vec<Ref>> {
780 let root = mkit_dir.join(sub_dir);
781 let mut out = Vec::new();
782 if !root.is_dir() {
783 return Ok(out);
784 }
785 collect_refs(&root, "", &mut out, 0)?;
786 out.sort_by(|a, b| a.name.cmp(&b.name));
787 Ok(out)
788}
789
790const MAX_REF_DEPTH: usize = 32;
796
797fn collect_refs(root: &Path, prefix: &str, out: &mut Vec<Ref>, depth: usize) -> RefResult<()> {
798 if depth > MAX_REF_DEPTH {
799 return Ok(());
803 }
804 let dir_path = if prefix.is_empty() {
805 root.to_path_buf()
806 } else {
807 root.join(prefix)
808 };
809 let iter = match fs::read_dir(&dir_path) {
810 Ok(i) => i,
811 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
812 Err(e) => return Err(RefError::Io(e)),
813 };
814 for entry in iter {
815 let entry = entry?;
816 let file_name = match entry.file_name().to_str() {
817 Some(s) => s.to_string(),
818 None => continue, };
820 let child_name = if prefix.is_empty() {
821 file_name.clone()
822 } else {
823 format!("{prefix}/{file_name}")
824 };
825 let ft = entry.file_type()?;
826 if ft.is_dir() {
827 collect_refs(root, &child_name, out, depth + 1)?;
828 continue;
829 }
830 if !ft.is_file() {
831 continue;
832 }
833 if !validate_ref_name(&child_name) {
834 continue;
835 }
836 let Ok(bytes) = fs::read(entry.path()) else {
838 continue;
839 };
840 let hash = decode_ref_wire(&bytes);
841 out.push(Ref {
842 name: child_name,
843 hash,
844 });
845 }
846 Ok(())
847}
848
849#[doc(hidden)]
856#[must_use]
857pub fn _hash_from_lowercase_hex_for_tests(s: &str) -> Option<Hash> {
858 parse_lowercase_hash(s.as_bytes())
859}
860
861#[cfg(test)]
862mod tests {
863 use super::*;
864 use crate::hash;
865 use tempfile::TempDir;
866
867 fn fresh_repo() -> (TempDir, PathBuf) {
868 let dir = TempDir::new().unwrap();
869 let mkit_dir = dir.path().join(".mkit");
870 fs::create_dir_all(&mkit_dir).unwrap();
871 init(&mkit_dir).unwrap();
872 (dir, mkit_dir)
873 }
874
875 fn h(seed: &str) -> Hash {
876 hash::hash(seed.as_bytes())
877 }
878
879 #[test]
882 fn validate_accepts_simple_names() {
883 assert!(validate_ref_name("main"));
884 assert!(validate_ref_name("feat/v1.0-beta"));
885 assert!(validate_ref_name("release/2024_09"));
886 }
887
888 #[test]
889 fn validate_rejects_empty() {
890 assert!(!validate_ref_name(""));
891 }
892
893 #[test]
894 fn validate_rejects_leading_slash() {
895 assert!(!validate_ref_name("/main"));
896 }
897
898 #[test]
899 fn validate_rejects_dotdot_segment() {
900 assert!(!validate_ref_name("feat/.."));
901 assert!(!validate_ref_name("../escape"));
902 assert!(!validate_ref_name("feat/./topic"));
903 }
904
905 #[test]
906 fn validate_rejects_double_slash() {
907 assert!(!validate_ref_name("refs//heads/main"));
908 assert!(!validate_ref_name("main/"));
909 }
910
911 #[test]
912 fn validate_rejects_disallowed_bytes() {
913 assert!(!validate_ref_name("main@v1"));
914 assert!(!validate_ref_name("feat\\branch"));
915 assert!(!validate_ref_name("with space"));
916 }
917
918 #[test]
919 fn validate_rejects_lock_suffix() {
920 assert!(!validate_ref_name("refs/heads/main.lock"));
921 }
922
923 #[test]
924 fn validate_rejects_head_final_segment() {
925 assert!(!validate_ref_name("refs/heads/HEAD"));
926 assert!(!validate_ref_name("HEAD"));
927 }
928
929 #[test]
930 fn validate_accepts_main_regression() {
931 assert!(validate_ref_name("refs/heads/main"));
932 }
933
934 #[test]
935 fn validate_accepts_non_lock_suffix_regression() {
936 assert!(validate_ref_name("refs/heads/lockfile"));
938 }
939
940 #[test]
941 fn validate_accepts_headless_regression() {
942 assert!(validate_ref_name("refs/heads/HEADless"));
944 }
945
946 #[test]
947 fn validate_prefix() {
948 assert!(validate_ref_prefix(""));
949 assert!(validate_ref_prefix("refs/heads/"));
950 assert!(validate_ref_prefix("refs/heads"));
951 assert!(!validate_ref_prefix("refs//heads/"));
952 assert!(!validate_ref_prefix("/"));
953 }
954
955 #[test]
958 fn wire_round_trip() {
959 let original = h("test-ref");
960 let wire = encode_ref_wire(&original);
961 assert_eq!(wire.len(), 65);
962 assert_eq!(wire[64], b'\n');
963 let parsed = decode_ref_wire(&wire).unwrap();
964 assert_eq!(parsed, original);
965 }
966
967 #[test]
968 fn wire_rejects_uppercase() {
969 let original = h("test-ref");
970 let mut wire = encode_ref_wire(&original);
971 let mut flipped = false;
974 for b in &mut wire[..HEX_LEN] {
975 if (b'a'..=b'f').contains(b) {
976 *b -= b'a' - b'A';
977 flipped = true;
978 break;
979 }
980 }
981 assert!(flipped, "test fixture should contain at least one a-f");
982 assert!(decode_ref_wire(&wire).is_none());
983 }
984
985 #[test]
986 fn wire_rejects_short_input() {
987 let bad = b"deadbeef\n";
988 assert!(decode_ref_wire(bad).is_none());
989 }
990
991 #[test]
992 fn wire_rejects_non_hex() {
993 let mut wire = encode_ref_wire(&h("x"));
994 wire[1] = b'g';
995 assert!(decode_ref_wire(&wire).is_none());
996 }
997
998 #[test]
999 fn wire_tolerates_trailing_cr() {
1000 let original = h("eol");
1002 let mut buf = encode_ref_wire(&original).to_vec();
1003 buf.insert(64, b'\r');
1004 let parsed = decode_ref_wire(&buf).unwrap();
1005 assert_eq!(parsed, original);
1006 }
1007
1008 #[test]
1011 fn init_writes_default_head() {
1012 let (_dir, mkit) = fresh_repo();
1013 let head = read_head(&mkit).unwrap();
1014 assert_eq!(head, Head::Branch("main".to_string()));
1015 }
1016
1017 #[test]
1018 fn write_and_read_branch_ref() {
1019 let (_dir, mkit) = fresh_repo();
1020 let commit = h("commit1");
1021 write_ref(&mkit, "main", &commit).unwrap();
1022 let read = read_ref(&mkit, "main").unwrap();
1023 assert_eq!(read, Some(commit));
1024 }
1025
1026 #[test]
1027 fn resolve_head_with_no_commits_returns_none() {
1028 let (_dir, mkit) = fresh_repo();
1029 assert_eq!(resolve_head(&mkit).unwrap(), None);
1030 }
1031
1032 #[test]
1033 fn resolve_head_after_commit() {
1034 let (_dir, mkit) = fresh_repo();
1035 let commit = h("commit1");
1036 write_ref(&mkit, "main", &commit).unwrap();
1037 assert_eq!(resolve_head(&mkit).unwrap(), Some(commit));
1038 }
1039
1040 #[test]
1041 fn update_head_updates_current_branch() {
1042 let (_dir, mkit) = fresh_repo();
1043 let h1 = h("c1");
1044 update_head(&mkit, &h1).unwrap();
1045 assert_eq!(resolve_head(&mkit).unwrap(), Some(h1));
1046 let h2 = h("c2");
1047 update_head(&mkit, &h2).unwrap();
1048 assert_eq!(resolve_head(&mkit).unwrap(), Some(h2));
1049 }
1050
1051 #[test]
1052 fn detached_head_round_trip() {
1053 let dir = TempDir::new().unwrap();
1054 let mkit = dir.path().join(".mkit");
1055 fs::create_dir_all(&mkit).unwrap();
1056 let commit = h("detached");
1057 write_head_detached(&mkit, &commit).unwrap();
1058 match read_head(&mkit).unwrap() {
1059 Head::Detached(got) => assert_eq!(got, commit),
1060 other @ Head::Branch(_) => panic!("expected detached, got {other:?}"),
1061 }
1062 assert_eq!(resolve_head(&mkit).unwrap(), Some(commit));
1063 }
1064
1065 #[test]
1066 fn nonexistent_branch_returns_none() {
1067 let (_dir, mkit) = fresh_repo();
1068 assert_eq!(read_ref(&mkit, "nonexistent").unwrap(), None);
1069 }
1070
1071 #[test]
1072 fn list_refs_empty() {
1073 let (_dir, mkit) = fresh_repo();
1074 let refs = list_refs(&mkit).unwrap();
1075 assert!(refs.is_empty());
1076 }
1077
1078 #[test]
1079 fn list_refs_sorted() {
1080 let (_dir, mkit) = fresh_repo();
1081 write_ref(&mkit, "main", &h("m")).unwrap();
1082 write_ref(&mkit, "dev", &h("d")).unwrap();
1083 let refs = list_refs(&mkit).unwrap();
1084 assert_eq!(refs.len(), 2);
1085 assert_eq!(refs[0].name, "dev");
1086 assert_eq!(refs[1].name, "main");
1087 }
1088
1089 #[test]
1090 fn nested_refs_listed_recursively() {
1091 let (_dir, mkit) = fresh_repo();
1092 write_ref(&mkit, "feature/deep/topic", &h("nested")).unwrap();
1093 let refs = list_refs(&mkit).unwrap();
1094 assert_eq!(refs.len(), 1);
1095 assert_eq!(refs[0].name, "feature/deep/topic");
1096 }
1097
1098 #[test]
1099 fn delete_ref_basic() {
1100 let (_dir, mkit) = fresh_repo();
1101 write_ref(&mkit, "feature", &h("f")).unwrap();
1102 delete_ref(&mkit, "feature").unwrap();
1103 assert_eq!(read_ref(&mkit, "feature").unwrap(), None);
1104 }
1105
1106 #[test]
1107 fn delete_nonexistent_ref_errors() {
1108 let (_dir, mkit) = fresh_repo();
1109 let err = delete_ref(&mkit, "nope").unwrap_err();
1110 assert!(matches!(err, RefError::NotFound(_)));
1111 }
1112
1113 #[test]
1114 fn refuse_delete_current_branch() {
1115 let (_dir, mkit) = fresh_repo();
1116 write_ref(&mkit, "main", &h("m")).unwrap();
1117 let err = delete_ref_safe(&mkit, "main").unwrap_err();
1118 assert!(matches!(err, RefError::CurrentBranch(_)));
1119 }
1120
1121 #[test]
1124 fn cas_any_clobbers() {
1125 let (_dir, mkit) = fresh_repo();
1126 update_ref(&mkit, "main", RefWriteCondition::Any, &h("a")).unwrap();
1127 update_ref(&mkit, "main", RefWriteCondition::Any, &h("b")).unwrap();
1128 assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("b")));
1129 }
1130
1131 #[test]
1132 fn cas_missing_succeeds_when_absent() {
1133 let (_dir, mkit) = fresh_repo();
1134 update_ref(&mkit, "main", RefWriteCondition::Missing, &h("a")).unwrap();
1135 assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("a")));
1136 }
1137
1138 #[test]
1139 fn cas_missing_fails_when_present() {
1140 let (_dir, mkit) = fresh_repo();
1141 write_ref(&mkit, "main", &h("a")).unwrap();
1142 let err = update_ref(&mkit, "main", RefWriteCondition::Missing, &h("b")).unwrap_err();
1143 assert!(matches!(err, RefError::Conflict(_)));
1144 }
1145
1146 #[test]
1147 fn cas_match_succeeds_on_correct_hash() {
1148 let (_dir, mkit) = fresh_repo();
1149 write_ref(&mkit, "main", &h("a")).unwrap();
1150 update_ref(&mkit, "main", RefWriteCondition::Match(h("a")), &h("b")).unwrap();
1151 assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("b")));
1152 }
1153
1154 #[test]
1155 fn cas_match_fails_on_wrong_hash() {
1156 let (_dir, mkit) = fresh_repo();
1157 write_ref(&mkit, "main", &h("a")).unwrap();
1158 let err = update_ref(&mkit, "main", RefWriteCondition::Match(h("z")), &h("b")).unwrap_err();
1159 assert!(matches!(err, RefError::Conflict(_)));
1160 }
1161
1162 #[test]
1163 fn cas_match_fails_on_missing_ref() {
1164 let (_dir, mkit) = fresh_repo();
1165 let err = update_ref(&mkit, "main", RefWriteCondition::Match(h("a")), &h("b")).unwrap_err();
1166 assert!(matches!(err, RefError::Conflict(_)));
1167 }
1168
1169 #[test]
1172 fn write_rejects_invalid_branch_name() {
1173 let (_dir, mkit) = fresh_repo();
1174 let err = write_ref(&mkit, "../escape", &h("x")).unwrap_err();
1175 assert!(matches!(err, RefError::InvalidRefName(_)));
1176 let err = write_head_branch(&mkit, "bad//branch").unwrap_err();
1177 assert!(matches!(err, RefError::InvalidRefName(_)));
1178 }
1179
1180 #[test]
1183 fn write_and_read_tag() {
1184 let (_dir, mkit) = fresh_repo();
1185 let commit = h("v1.0");
1186 write_tag(&mkit, "v1.0", &commit).unwrap();
1187 assert_eq!(read_tag(&mkit, "v1.0").unwrap(), Some(commit));
1188 }
1189
1190 #[test]
1191 fn list_tags_sorted() {
1192 let (_dir, mkit) = fresh_repo();
1193 write_tag(&mkit, "v2.0", &h("v2")).unwrap();
1194 write_tag(&mkit, "v1.0", &h("v1")).unwrap();
1195 write_tag(&mkit, "alpha", &h("a")).unwrap();
1196 let tags = list_tags(&mkit).unwrap();
1197 assert_eq!(
1198 tags.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
1199 vec!["alpha", "v1.0", "v2.0"]
1200 );
1201 }
1202
1203 #[test]
1204 fn tag_and_branch_same_name_independent() {
1205 let (_dir, mkit) = fresh_repo();
1206 let tag = h("tag");
1207 let branch = h("branch");
1208 write_tag(&mkit, "main", &tag).unwrap();
1209 write_ref(&mkit, "main", &branch).unwrap();
1210 assert_eq!(read_tag(&mkit, "main").unwrap(), Some(tag));
1211 assert_eq!(read_ref(&mkit, "main").unwrap(), Some(branch));
1212 }
1213
1214 #[test]
1215 fn delete_tag_basic() {
1216 let (_dir, mkit) = fresh_repo();
1217 write_tag(&mkit, "release", &h("r")).unwrap();
1218 delete_tag(&mkit, "release").unwrap();
1219 assert_eq!(read_tag(&mkit, "release").unwrap(), None);
1220 }
1221
1222 #[test]
1223 fn delete_nonexistent_tag_errors() {
1224 let (_dir, mkit) = fresh_repo();
1225 let err = delete_tag(&mkit, "missing").unwrap_err();
1226 assert!(matches!(err, RefError::NotFound(_)));
1227 }
1228
1229 #[test]
1232 fn load_shallow_returns_none_when_missing() {
1233 let (_dir, mkit) = fresh_repo();
1234 assert_eq!(load_shallow_boundaries(&mkit).unwrap(), None);
1235 }
1236
1237 #[test]
1238 fn write_and_load_shallow_round_trip() {
1239 let (_dir, mkit) = fresh_repo();
1240 let bs = vec![h("b1"), h("b2"), h("b3")];
1241 write_shallow_boundaries(&mkit, &bs).unwrap();
1242 let loaded = load_shallow_boundaries(&mkit).unwrap().unwrap();
1243 assert_eq!(loaded.len(), 3);
1244 for b in &bs {
1245 assert!(loaded.contains(b));
1246 }
1247 }
1248
1249 #[test]
1250 fn write_empty_shallow_removes_file() {
1251 let (_dir, mkit) = fresh_repo();
1252 write_shallow_boundaries(&mkit, &[h("x")]).unwrap();
1253 assert!(load_shallow_boundaries(&mkit).unwrap().is_some());
1254 write_shallow_boundaries(&mkit, &[]).unwrap();
1255 assert_eq!(load_shallow_boundaries(&mkit).unwrap(), None);
1256 }
1257
1258 #[test]
1259 fn load_shallow_skips_invalid_lines() {
1260 let (_dir, mkit) = fresh_repo();
1261 let path = mkit.join(SHALLOW_FILE);
1262 let valid = h("ok");
1263 let valid_hex = to_hex(&valid);
1264 let mut content = String::new();
1265 content.push_str("short\n");
1266 content.push_str(&valid_hex);
1267 content.push('\n');
1268 content.push_str(&"z".repeat(64));
1269 content.push('\n');
1270 std::fs::write(&path, content).unwrap();
1271 let loaded = load_shallow_boundaries(&mkit).unwrap().unwrap();
1272 assert_eq!(loaded.len(), 1);
1273 assert_eq!(loaded[0], valid);
1274 }
1275
1276 #[cfg(feature = "history-mmr")]
1279 mod history_coupling {
1280 use super::*;
1281 use crate::history::{CommitHistory, TokioExecutor};
1282 use std::sync::Arc;
1283
1284 #[test]
1285 fn update_ref_with_history_appends_to_journal_under_lock() {
1286 let (_dir, mkit) = fresh_repo();
1287 let exec = Arc::new(TokioExecutor::new().unwrap());
1288 let mut hist = CommitHistory::open_at(exec.clone(), &mkit, "main").unwrap();
1289
1290 let c1 = h("c1");
1291 let c2 = h("c2");
1292
1293 update_ref_with_history(&mkit, "main", RefWriteCondition::Any, &c1, &mut hist).unwrap();
1294 update_ref_with_history(&mkit, "main", RefWriteCondition::Match(c1), &c2, &mut hist)
1295 .unwrap();
1296
1297 assert_eq!(read_ref(&mkit, "main").unwrap(), Some(c2));
1298 assert_eq!(hist.len(), 2, "two appends → two leaves in the MMR");
1299 }
1300
1301 #[test]
1302 fn update_ref_with_history_rejects_mem_history() {
1303 let (_dir, mkit) = fresh_repo();
1304 let mut mem_hist = CommitHistory::open();
1305 let err = update_ref_with_history(
1306 &mkit,
1307 "main",
1308 RefWriteCondition::Any,
1309 &h("x"),
1310 &mut mem_hist,
1311 )
1312 .unwrap_err();
1313 assert!(matches!(err, RefError::InvalidRef(_)));
1314 }
1315
1316 #[test]
1317 fn update_ref_with_history_rejects_branch_mismatch() {
1318 let (_dir, mkit) = fresh_repo();
1319 let exec = Arc::new(TokioExecutor::new().unwrap());
1320 let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
1322 let err = update_ref_with_history(
1323 &mkit,
1324 "feature",
1325 RefWriteCondition::Any,
1326 &h("x"),
1327 &mut hist,
1328 )
1329 .unwrap_err();
1330 assert!(matches!(err, RefError::InvalidRef(_)));
1331 }
1332
1333 #[test]
1334 fn update_ref_with_history_cas_failure_does_not_append() {
1335 let (_dir, mkit) = fresh_repo();
1336 let exec = Arc::new(TokioExecutor::new().unwrap());
1337 let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
1338
1339 write_ref(&mkit, "main", &h("existing")).unwrap();
1341
1342 let err = update_ref_with_history(
1343 &mkit,
1344 "main",
1345 RefWriteCondition::Missing,
1346 &h("new"),
1347 &mut hist,
1348 )
1349 .unwrap_err();
1350 assert!(matches!(err, RefError::Conflict(_)));
1351 assert_eq!(
1352 hist.len(),
1353 0,
1354 "CAS failure must NOT have appended to history"
1355 );
1356 }
1357 }
1358}