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 list_remote_refs(mkit_dir: &Path, remote: &str) -> RefResult<Vec<Ref>> {
540 if !validate_ref_name(remote) {
541 return Err(RefError::InvalidRefName(remote.to_string()));
542 }
543 list_refs_under(mkit_dir, &remote_ref_dir(remote))
544}
545
546pub fn read_tag(mkit_dir: &Path, name: &str) -> RefResult<Option<Hash>> {
552 if !validate_ref_name(name) {
553 return Err(RefError::InvalidRefName(name.to_string()));
554 }
555 read_ref_under(mkit_dir, TAGS_DIR, name)
556}
557
558pub fn write_tag(mkit_dir: &Path, name: &str, h: &Hash) -> RefResult<()> {
560 update_tag(mkit_dir, name, RefWriteCondition::Any, h)
561}
562
563pub fn update_tag(
566 mkit_dir: &Path,
567 name: &str,
568 condition: RefWriteCondition,
569 h: &Hash,
570) -> RefResult<()> {
571 if !validate_ref_name(name) {
572 return Err(RefError::InvalidRefName(name.to_string()));
573 }
574 let path = ref_path(mkit_dir, TAGS_DIR, name);
575 let wire = encode_ref_wire(h);
576 cas_write(&path, &wire, name, condition)
577}
578
579pub fn delete_tag(mkit_dir: &Path, name: &str) -> RefResult<()> {
581 if !validate_ref_name(name) {
582 return Err(RefError::InvalidRefName(name.to_string()));
583 }
584 let path = ref_path(mkit_dir, TAGS_DIR, name);
585 match fs::remove_file(&path) {
586 Ok(()) => Ok(()),
587 Err(e) if e.kind() == io::ErrorKind::NotFound => Err(RefError::NotFound(name.to_string())),
588 Err(e) => Err(RefError::Io(e)),
589 }
590}
591
592pub fn list_tags(mkit_dir: &Path) -> RefResult<Vec<Ref>> {
594 list_refs_under(mkit_dir, TAGS_DIR)
595}
596
597pub fn load_shallow_boundaries(mkit_dir: &Path) -> RefResult<Option<Vec<Hash>>> {
604 let path = mkit_dir.join(SHALLOW_FILE);
605 let meta = match fs::metadata(&path) {
606 Ok(m) => m,
607 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
608 Err(e) => return Err(RefError::Io(e)),
609 };
610 if meta.len() == 0 {
611 return Ok(None);
612 }
613 if meta.len() > SHALLOW_MAX_BYTES {
614 return Err(RefError::InvalidRef("shallow file too large".to_string()));
615 }
616 let bytes = fs::read(&path)?;
617 let s = core::str::from_utf8(&bytes).map_err(|_| RefError::InvalidHead)?;
618 let mut out = Vec::new();
619 for line in s.split('\n') {
620 let trimmed = line.trim_end_matches(['\r', ' ', '\t']);
621 if trimmed.len() != HEX_LEN {
622 continue;
623 }
624 if let Some(h) = parse_lowercase_hash(trimmed.as_bytes()) {
625 out.push(h);
626 }
627 }
628 if out.is_empty() {
629 return Ok(None);
630 }
631 Ok(Some(out))
632}
633
634pub fn write_shallow_boundaries(mkit_dir: &Path, boundaries: &[Hash]) -> RefResult<()> {
637 let path = mkit_dir.join(SHALLOW_FILE);
638 if boundaries.is_empty() {
639 match fs::remove_file(&path) {
640 Ok(()) => Ok(()),
641 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
642 Err(e) => Err(RefError::Io(e)),
643 }
644 } else {
645 let mut out = Vec::with_capacity(boundaries.len() * 65);
646 for h in boundaries {
647 out.extend_from_slice(&encode_ref_wire(h));
648 }
649 write_atomic(&path, &out, true)?;
650 Ok(())
651 }
652}
653
654fn ref_path(mkit_dir: &Path, sub_dir: &str, name: &str) -> PathBuf {
659 let mut path = mkit_dir.join(sub_dir);
660 for segment in name.split('/') {
661 path.push(segment);
662 }
663 path
664}
665
666fn remote_ref_dir(remote: &str) -> String {
667 format!("{REMOTES_DIR}/{remote}")
668}
669
670fn validate_remote_and_branch(remote: &str, branch: &str) -> RefResult<()> {
671 if !validate_ref_name(remote) {
672 return Err(RefError::InvalidRefName(remote.to_string()));
673 }
674 if !validate_ref_name(branch) {
675 return Err(RefError::InvalidRefName(branch.to_string()));
676 }
677 Ok(())
678}
679
680fn read_ref_under(mkit_dir: &Path, sub_dir: &str, name: &str) -> RefResult<Option<Hash>> {
681 let path = ref_path(mkit_dir, sub_dir, name);
682 let meta = match fs::metadata(&path) {
683 Ok(m) => m,
684 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
685 Err(e) => return Err(RefError::Io(e)),
686 };
687 if meta.len() > REF_FILE_MAX_BYTES {
688 return Err(RefError::InvalidRef(name.to_string()));
689 }
690 let bytes = fs::read(&path)?;
691 let h = decode_ref_wire(&bytes).ok_or_else(|| RefError::InvalidRef(name.to_string()))?;
692 Ok(Some(h))
693}
694
695fn cas_write(
696 path: &Path,
697 wire: &[u8; 65],
698 name_for_err: &str,
699 condition: RefWriteCondition,
700) -> RefResult<()> {
701 match condition {
702 RefWriteCondition::Any => {
703 write_atomic(path, wire, true)?;
704 Ok(())
705 }
706 RefWriteCondition::Missing => {
707 if let Some(parent) = path.parent() {
709 fs::create_dir_all(parent)?;
710 }
711 let created = write_create_new(path, wire, true)?;
712 if !created {
713 return Err(RefError::Conflict(name_for_err.to_string()));
714 }
715 Ok(())
716 }
717 RefWriteCondition::Match(expected) => {
718 let current = match fs::read(path) {
721 Ok(b) => Some(
722 decode_ref_wire(&b)
723 .ok_or_else(|| RefError::InvalidRef(name_for_err.to_string()))?,
724 ),
725 Err(e) if e.kind() == io::ErrorKind::NotFound => None,
726 Err(e) => return Err(RefError::Io(e)),
727 };
728 if current != Some(expected) {
729 return Err(RefError::Conflict(name_for_err.to_string()));
730 }
731 write_atomic(path, wire, true)?;
732 Ok(())
733 }
734 }
735}
736
737fn list_refs_under(mkit_dir: &Path, sub_dir: &str) -> RefResult<Vec<Ref>> {
738 let root = mkit_dir.join(sub_dir);
739 let mut out = Vec::new();
740 if !root.is_dir() {
741 return Ok(out);
742 }
743 collect_refs(&root, "", &mut out, 0)?;
744 out.sort_by(|a, b| a.name.cmp(&b.name));
745 Ok(out)
746}
747
748const MAX_REF_DEPTH: usize = 32;
754
755fn collect_refs(root: &Path, prefix: &str, out: &mut Vec<Ref>, depth: usize) -> RefResult<()> {
756 if depth > MAX_REF_DEPTH {
757 return Ok(());
761 }
762 let dir_path = if prefix.is_empty() {
763 root.to_path_buf()
764 } else {
765 root.join(prefix)
766 };
767 let iter = match fs::read_dir(&dir_path) {
768 Ok(i) => i,
769 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
770 Err(e) => return Err(RefError::Io(e)),
771 };
772 for entry in iter {
773 let entry = entry?;
774 let file_name = match entry.file_name().to_str() {
775 Some(s) => s.to_string(),
776 None => continue, };
778 let child_name = if prefix.is_empty() {
779 file_name.clone()
780 } else {
781 format!("{prefix}/{file_name}")
782 };
783 let ft = entry.file_type()?;
784 if ft.is_dir() {
785 collect_refs(root, &child_name, out, depth + 1)?;
786 continue;
787 }
788 if !ft.is_file() {
789 continue;
790 }
791 if !validate_ref_name(&child_name) {
792 continue;
793 }
794 let Ok(bytes) = fs::read(entry.path()) else {
796 continue;
797 };
798 let hash = decode_ref_wire(&bytes);
799 out.push(Ref {
800 name: child_name,
801 hash,
802 });
803 }
804 Ok(())
805}
806
807#[doc(hidden)]
814#[must_use]
815pub fn _hash_from_lowercase_hex_for_tests(s: &str) -> Option<Hash> {
816 parse_lowercase_hash(s.as_bytes())
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822 use crate::hash;
823 use tempfile::TempDir;
824
825 fn fresh_repo() -> (TempDir, PathBuf) {
826 let dir = TempDir::new().unwrap();
827 let mkit_dir = dir.path().join(".mkit");
828 fs::create_dir_all(&mkit_dir).unwrap();
829 init(&mkit_dir).unwrap();
830 (dir, mkit_dir)
831 }
832
833 fn h(seed: &str) -> Hash {
834 hash::hash(seed.as_bytes())
835 }
836
837 #[test]
840 fn validate_accepts_simple_names() {
841 assert!(validate_ref_name("main"));
842 assert!(validate_ref_name("feat/v1.0-beta"));
843 assert!(validate_ref_name("release/2024_09"));
844 }
845
846 #[test]
847 fn validate_rejects_empty() {
848 assert!(!validate_ref_name(""));
849 }
850
851 #[test]
852 fn validate_rejects_leading_slash() {
853 assert!(!validate_ref_name("/main"));
854 }
855
856 #[test]
857 fn validate_rejects_dotdot_segment() {
858 assert!(!validate_ref_name("feat/.."));
859 assert!(!validate_ref_name("../escape"));
860 assert!(!validate_ref_name("feat/./topic"));
861 }
862
863 #[test]
864 fn validate_rejects_double_slash() {
865 assert!(!validate_ref_name("refs//heads/main"));
866 assert!(!validate_ref_name("main/"));
867 }
868
869 #[test]
870 fn validate_rejects_disallowed_bytes() {
871 assert!(!validate_ref_name("main@v1"));
872 assert!(!validate_ref_name("feat\\branch"));
873 assert!(!validate_ref_name("with space"));
874 }
875
876 #[test]
877 fn validate_rejects_lock_suffix() {
878 assert!(!validate_ref_name("refs/heads/main.lock"));
879 }
880
881 #[test]
882 fn validate_rejects_head_final_segment() {
883 assert!(!validate_ref_name("refs/heads/HEAD"));
884 assert!(!validate_ref_name("HEAD"));
885 }
886
887 #[test]
888 fn validate_accepts_main_regression() {
889 assert!(validate_ref_name("refs/heads/main"));
890 }
891
892 #[test]
893 fn validate_accepts_non_lock_suffix_regression() {
894 assert!(validate_ref_name("refs/heads/lockfile"));
896 }
897
898 #[test]
899 fn validate_accepts_headless_regression() {
900 assert!(validate_ref_name("refs/heads/HEADless"));
902 }
903
904 #[test]
905 fn validate_prefix() {
906 assert!(validate_ref_prefix(""));
907 assert!(validate_ref_prefix("refs/heads/"));
908 assert!(validate_ref_prefix("refs/heads"));
909 assert!(!validate_ref_prefix("refs//heads/"));
910 assert!(!validate_ref_prefix("/"));
911 }
912
913 #[test]
916 fn wire_round_trip() {
917 let original = h("test-ref");
918 let wire = encode_ref_wire(&original);
919 assert_eq!(wire.len(), 65);
920 assert_eq!(wire[64], b'\n');
921 let parsed = decode_ref_wire(&wire).unwrap();
922 assert_eq!(parsed, original);
923 }
924
925 #[test]
926 fn wire_rejects_uppercase() {
927 let original = h("test-ref");
928 let mut wire = encode_ref_wire(&original);
929 let mut flipped = false;
932 for b in &mut wire[..HEX_LEN] {
933 if (b'a'..=b'f').contains(b) {
934 *b -= b'a' - b'A';
935 flipped = true;
936 break;
937 }
938 }
939 assert!(flipped, "test fixture should contain at least one a-f");
940 assert!(decode_ref_wire(&wire).is_none());
941 }
942
943 #[test]
944 fn wire_rejects_short_input() {
945 let bad = b"deadbeef\n";
946 assert!(decode_ref_wire(bad).is_none());
947 }
948
949 #[test]
950 fn wire_rejects_non_hex() {
951 let mut wire = encode_ref_wire(&h("x"));
952 wire[1] = b'g';
953 assert!(decode_ref_wire(&wire).is_none());
954 }
955
956 #[test]
957 fn wire_tolerates_trailing_cr() {
958 let original = h("eol");
960 let mut buf = encode_ref_wire(&original).to_vec();
961 buf.insert(64, b'\r');
962 let parsed = decode_ref_wire(&buf).unwrap();
963 assert_eq!(parsed, original);
964 }
965
966 #[test]
969 fn init_writes_default_head() {
970 let (_dir, mkit) = fresh_repo();
971 let head = read_head(&mkit).unwrap();
972 assert_eq!(head, Head::Branch("main".to_string()));
973 }
974
975 #[test]
976 fn write_and_read_branch_ref() {
977 let (_dir, mkit) = fresh_repo();
978 let commit = h("commit1");
979 write_ref(&mkit, "main", &commit).unwrap();
980 let read = read_ref(&mkit, "main").unwrap();
981 assert_eq!(read, Some(commit));
982 }
983
984 #[test]
985 fn resolve_head_with_no_commits_returns_none() {
986 let (_dir, mkit) = fresh_repo();
987 assert_eq!(resolve_head(&mkit).unwrap(), None);
988 }
989
990 #[test]
991 fn resolve_head_after_commit() {
992 let (_dir, mkit) = fresh_repo();
993 let commit = h("commit1");
994 write_ref(&mkit, "main", &commit).unwrap();
995 assert_eq!(resolve_head(&mkit).unwrap(), Some(commit));
996 }
997
998 #[test]
999 fn update_head_updates_current_branch() {
1000 let (_dir, mkit) = fresh_repo();
1001 let h1 = h("c1");
1002 update_head(&mkit, &h1).unwrap();
1003 assert_eq!(resolve_head(&mkit).unwrap(), Some(h1));
1004 let h2 = h("c2");
1005 update_head(&mkit, &h2).unwrap();
1006 assert_eq!(resolve_head(&mkit).unwrap(), Some(h2));
1007 }
1008
1009 #[test]
1010 fn detached_head_round_trip() {
1011 let dir = TempDir::new().unwrap();
1012 let mkit = dir.path().join(".mkit");
1013 fs::create_dir_all(&mkit).unwrap();
1014 let commit = h("detached");
1015 write_head_detached(&mkit, &commit).unwrap();
1016 match read_head(&mkit).unwrap() {
1017 Head::Detached(got) => assert_eq!(got, commit),
1018 other @ Head::Branch(_) => panic!("expected detached, got {other:?}"),
1019 }
1020 assert_eq!(resolve_head(&mkit).unwrap(), Some(commit));
1021 }
1022
1023 #[test]
1024 fn nonexistent_branch_returns_none() {
1025 let (_dir, mkit) = fresh_repo();
1026 assert_eq!(read_ref(&mkit, "nonexistent").unwrap(), None);
1027 }
1028
1029 #[test]
1030 fn list_refs_empty() {
1031 let (_dir, mkit) = fresh_repo();
1032 let refs = list_refs(&mkit).unwrap();
1033 assert!(refs.is_empty());
1034 }
1035
1036 #[test]
1037 fn list_refs_sorted() {
1038 let (_dir, mkit) = fresh_repo();
1039 write_ref(&mkit, "main", &h("m")).unwrap();
1040 write_ref(&mkit, "dev", &h("d")).unwrap();
1041 let refs = list_refs(&mkit).unwrap();
1042 assert_eq!(refs.len(), 2);
1043 assert_eq!(refs[0].name, "dev");
1044 assert_eq!(refs[1].name, "main");
1045 }
1046
1047 #[test]
1048 fn nested_refs_listed_recursively() {
1049 let (_dir, mkit) = fresh_repo();
1050 write_ref(&mkit, "feature/deep/topic", &h("nested")).unwrap();
1051 let refs = list_refs(&mkit).unwrap();
1052 assert_eq!(refs.len(), 1);
1053 assert_eq!(refs[0].name, "feature/deep/topic");
1054 }
1055
1056 #[test]
1057 fn delete_ref_basic() {
1058 let (_dir, mkit) = fresh_repo();
1059 write_ref(&mkit, "feature", &h("f")).unwrap();
1060 delete_ref(&mkit, "feature").unwrap();
1061 assert_eq!(read_ref(&mkit, "feature").unwrap(), None);
1062 }
1063
1064 #[test]
1065 fn delete_nonexistent_ref_errors() {
1066 let (_dir, mkit) = fresh_repo();
1067 let err = delete_ref(&mkit, "nope").unwrap_err();
1068 assert!(matches!(err, RefError::NotFound(_)));
1069 }
1070
1071 #[test]
1072 fn refuse_delete_current_branch() {
1073 let (_dir, mkit) = fresh_repo();
1074 write_ref(&mkit, "main", &h("m")).unwrap();
1075 let err = delete_ref_safe(&mkit, "main").unwrap_err();
1076 assert!(matches!(err, RefError::CurrentBranch(_)));
1077 }
1078
1079 #[test]
1082 fn cas_any_clobbers() {
1083 let (_dir, mkit) = fresh_repo();
1084 update_ref(&mkit, "main", RefWriteCondition::Any, &h("a")).unwrap();
1085 update_ref(&mkit, "main", RefWriteCondition::Any, &h("b")).unwrap();
1086 assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("b")));
1087 }
1088
1089 #[test]
1090 fn cas_missing_succeeds_when_absent() {
1091 let (_dir, mkit) = fresh_repo();
1092 update_ref(&mkit, "main", RefWriteCondition::Missing, &h("a")).unwrap();
1093 assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("a")));
1094 }
1095
1096 #[test]
1097 fn cas_missing_fails_when_present() {
1098 let (_dir, mkit) = fresh_repo();
1099 write_ref(&mkit, "main", &h("a")).unwrap();
1100 let err = update_ref(&mkit, "main", RefWriteCondition::Missing, &h("b")).unwrap_err();
1101 assert!(matches!(err, RefError::Conflict(_)));
1102 }
1103
1104 #[test]
1105 fn cas_match_succeeds_on_correct_hash() {
1106 let (_dir, mkit) = fresh_repo();
1107 write_ref(&mkit, "main", &h("a")).unwrap();
1108 update_ref(&mkit, "main", RefWriteCondition::Match(h("a")), &h("b")).unwrap();
1109 assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("b")));
1110 }
1111
1112 #[test]
1113 fn cas_match_fails_on_wrong_hash() {
1114 let (_dir, mkit) = fresh_repo();
1115 write_ref(&mkit, "main", &h("a")).unwrap();
1116 let err = update_ref(&mkit, "main", RefWriteCondition::Match(h("z")), &h("b")).unwrap_err();
1117 assert!(matches!(err, RefError::Conflict(_)));
1118 }
1119
1120 #[test]
1121 fn cas_match_fails_on_missing_ref() {
1122 let (_dir, mkit) = fresh_repo();
1123 let err = update_ref(&mkit, "main", RefWriteCondition::Match(h("a")), &h("b")).unwrap_err();
1124 assert!(matches!(err, RefError::Conflict(_)));
1125 }
1126
1127 #[test]
1130 fn write_rejects_invalid_branch_name() {
1131 let (_dir, mkit) = fresh_repo();
1132 let err = write_ref(&mkit, "../escape", &h("x")).unwrap_err();
1133 assert!(matches!(err, RefError::InvalidRefName(_)));
1134 let err = write_head_branch(&mkit, "bad//branch").unwrap_err();
1135 assert!(matches!(err, RefError::InvalidRefName(_)));
1136 }
1137
1138 #[test]
1141 fn write_and_read_tag() {
1142 let (_dir, mkit) = fresh_repo();
1143 let commit = h("v1.0");
1144 write_tag(&mkit, "v1.0", &commit).unwrap();
1145 assert_eq!(read_tag(&mkit, "v1.0").unwrap(), Some(commit));
1146 }
1147
1148 #[test]
1149 fn list_tags_sorted() {
1150 let (_dir, mkit) = fresh_repo();
1151 write_tag(&mkit, "v2.0", &h("v2")).unwrap();
1152 write_tag(&mkit, "v1.0", &h("v1")).unwrap();
1153 write_tag(&mkit, "alpha", &h("a")).unwrap();
1154 let tags = list_tags(&mkit).unwrap();
1155 assert_eq!(
1156 tags.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
1157 vec!["alpha", "v1.0", "v2.0"]
1158 );
1159 }
1160
1161 #[test]
1162 fn tag_and_branch_same_name_independent() {
1163 let (_dir, mkit) = fresh_repo();
1164 let tag = h("tag");
1165 let branch = h("branch");
1166 write_tag(&mkit, "main", &tag).unwrap();
1167 write_ref(&mkit, "main", &branch).unwrap();
1168 assert_eq!(read_tag(&mkit, "main").unwrap(), Some(tag));
1169 assert_eq!(read_ref(&mkit, "main").unwrap(), Some(branch));
1170 }
1171
1172 #[test]
1173 fn delete_tag_basic() {
1174 let (_dir, mkit) = fresh_repo();
1175 write_tag(&mkit, "release", &h("r")).unwrap();
1176 delete_tag(&mkit, "release").unwrap();
1177 assert_eq!(read_tag(&mkit, "release").unwrap(), None);
1178 }
1179
1180 #[test]
1181 fn delete_nonexistent_tag_errors() {
1182 let (_dir, mkit) = fresh_repo();
1183 let err = delete_tag(&mkit, "missing").unwrap_err();
1184 assert!(matches!(err, RefError::NotFound(_)));
1185 }
1186
1187 #[test]
1190 fn load_shallow_returns_none_when_missing() {
1191 let (_dir, mkit) = fresh_repo();
1192 assert_eq!(load_shallow_boundaries(&mkit).unwrap(), None);
1193 }
1194
1195 #[test]
1196 fn write_and_load_shallow_round_trip() {
1197 let (_dir, mkit) = fresh_repo();
1198 let bs = vec![h("b1"), h("b2"), h("b3")];
1199 write_shallow_boundaries(&mkit, &bs).unwrap();
1200 let loaded = load_shallow_boundaries(&mkit).unwrap().unwrap();
1201 assert_eq!(loaded.len(), 3);
1202 for b in &bs {
1203 assert!(loaded.contains(b));
1204 }
1205 }
1206
1207 #[test]
1208 fn write_empty_shallow_removes_file() {
1209 let (_dir, mkit) = fresh_repo();
1210 write_shallow_boundaries(&mkit, &[h("x")]).unwrap();
1211 assert!(load_shallow_boundaries(&mkit).unwrap().is_some());
1212 write_shallow_boundaries(&mkit, &[]).unwrap();
1213 assert_eq!(load_shallow_boundaries(&mkit).unwrap(), None);
1214 }
1215
1216 #[test]
1217 fn load_shallow_skips_invalid_lines() {
1218 let (_dir, mkit) = fresh_repo();
1219 let path = mkit.join(SHALLOW_FILE);
1220 let valid = h("ok");
1221 let valid_hex = to_hex(&valid);
1222 let mut content = String::new();
1223 content.push_str("short\n");
1224 content.push_str(&valid_hex);
1225 content.push('\n');
1226 content.push_str(&"z".repeat(64));
1227 content.push('\n');
1228 std::fs::write(&path, content).unwrap();
1229 let loaded = load_shallow_boundaries(&mkit).unwrap().unwrap();
1230 assert_eq!(loaded.len(), 1);
1231 assert_eq!(loaded[0], valid);
1232 }
1233
1234 #[cfg(feature = "history-mmr")]
1237 mod history_coupling {
1238 use super::*;
1239 use crate::history::{CommitHistory, TokioExecutor};
1240 use std::sync::Arc;
1241
1242 #[test]
1243 fn update_ref_with_history_appends_to_journal_under_lock() {
1244 let (_dir, mkit) = fresh_repo();
1245 let exec = Arc::new(TokioExecutor::new().unwrap());
1246 let mut hist = CommitHistory::open_at(exec.clone(), &mkit, "main").unwrap();
1247
1248 let c1 = h("c1");
1249 let c2 = h("c2");
1250
1251 update_ref_with_history(&mkit, "main", RefWriteCondition::Any, &c1, &mut hist).unwrap();
1252 update_ref_with_history(&mkit, "main", RefWriteCondition::Match(c1), &c2, &mut hist)
1253 .unwrap();
1254
1255 assert_eq!(read_ref(&mkit, "main").unwrap(), Some(c2));
1256 assert_eq!(hist.len(), 2, "two appends → two leaves in the MMR");
1257 }
1258
1259 #[test]
1260 fn update_ref_with_history_rejects_mem_history() {
1261 let (_dir, mkit) = fresh_repo();
1262 let mut mem_hist = CommitHistory::open();
1263 let err = update_ref_with_history(
1264 &mkit,
1265 "main",
1266 RefWriteCondition::Any,
1267 &h("x"),
1268 &mut mem_hist,
1269 )
1270 .unwrap_err();
1271 assert!(matches!(err, RefError::InvalidRef(_)));
1272 }
1273
1274 #[test]
1275 fn update_ref_with_history_rejects_branch_mismatch() {
1276 let (_dir, mkit) = fresh_repo();
1277 let exec = Arc::new(TokioExecutor::new().unwrap());
1278 let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
1280 let err = update_ref_with_history(
1281 &mkit,
1282 "feature",
1283 RefWriteCondition::Any,
1284 &h("x"),
1285 &mut hist,
1286 )
1287 .unwrap_err();
1288 assert!(matches!(err, RefError::InvalidRef(_)));
1289 }
1290
1291 #[test]
1292 fn update_ref_with_history_cas_failure_does_not_append() {
1293 let (_dir, mkit) = fresh_repo();
1294 let exec = Arc::new(TokioExecutor::new().unwrap());
1295 let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
1296
1297 write_ref(&mkit, "main", &h("existing")).unwrap();
1299
1300 let err = update_ref_with_history(
1301 &mkit,
1302 "main",
1303 RefWriteCondition::Missing,
1304 &h("new"),
1305 &mut hist,
1306 )
1307 .unwrap_err();
1308 assert!(matches!(err, RefError::Conflict(_)));
1309 assert_eq!(
1310 hist.len(),
1311 0,
1312 "CAS failure must NOT have appended to history"
1313 );
1314 }
1315 }
1316}