1use std::collections::BTreeSet;
37use std::fs;
38use std::io;
39use std::path::{Path, PathBuf};
40
41use crate::atomic::{write_atomic, write_create_new};
42use crate::hash::{HASH_LEN, HEX_LEN, Hash, to_hex};
43use crate::layout::RepoLayout;
44
45pub const REFS_DIR: &str = "refs";
47pub const HEADS_DIR: &str = "refs/heads";
49pub const TAGS_DIR: &str = "refs/tags";
51pub const REMOTES_DIR: &str = "refs/remotes";
53pub const HEAD_FILE: &str = "HEAD";
55pub const SHALLOW_FILE: &str = "shallow";
57
58const HEAD_REF_PREFIX: &str = "ref: refs/heads/";
60
61const HEAD_MAX_BYTES: u64 = 4 * 1024;
63const REF_FILE_MAX_BYTES: u64 = 128;
68const SHALLOW_MAX_BYTES: u64 = 1024 * 1024;
70
71#[derive(Debug, thiserror::Error)]
73#[non_exhaustive]
74pub enum RefError {
75 #[error("invalid ref name '{0}'")]
77 InvalidRefName(String),
78 #[error("{}", too_long_message(.name, *.len, *.kind))]
82 RefNameTooLong {
83 name: String,
85 len: usize,
87 kind: RefNameKind,
89 },
90 #[error("invalid ref content for '{0}'")]
93 InvalidRef(String),
94 #[error("HEAD is not a valid symbolic-ref or detached-hash file")]
97 InvalidHead,
98 #[error("HEAD is not present")]
100 NoHead,
101 #[error("ref '{0}' did not satisfy CAS condition")]
103 Conflict(String),
104 #[error("ref '{0}' not found")]
106 NotFound(String),
107 #[error("cannot delete the current branch '{0}'")]
109 CurrentBranch(String),
110 #[error(transparent)]
112 Io(#[from] io::Error),
113 #[error("read_batch callback returned {actual} outcomes for a {expected}-candidate batch")]
118 RefBatchLengthMismatch { expected: usize, actual: usize },
119}
120
121pub type RefResult<T> = Result<T, RefError>;
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum RefWriteCondition {
127 Any,
129 Missing,
131 Match(Hash),
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum Head {
139 Branch(String),
141 Detached(Hash),
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct Ref {
149 pub name: String,
151 pub hash: Option<Hash>,
155}
156
157pub const MAX_REF_NAME_BYTES: usize = 512;
161
162pub const BRANCH_REF_PREFIX: &str = "refs/heads/";
164pub const PACKMAP_REF_PREFIX: &str = "refs/mkit/packmap/";
167pub const TAG_REF_PREFIX: &str = "refs/tags/";
169
170pub const MAX_BRANCH_NAME_BYTES: usize = MAX_REF_NAME_BYTES - PACKMAP_REF_PREFIX.len();
174pub const MAX_TAG_NAME_BYTES: usize = MAX_REF_NAME_BYTES - TAG_REF_PREFIX.len();
177
178const _: () = assert!(PACKMAP_REF_PREFIX.len() >= BRANCH_REF_PREFIX.len());
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182#[non_exhaustive]
183pub enum RefNameKind {
184 Ref,
187 Branch,
189 Tag,
191}
192
193impl RefNameKind {
194 #[must_use]
196 pub const fn max_bytes(self) -> usize {
197 match self {
198 Self::Ref => MAX_REF_NAME_BYTES,
199 Self::Branch => MAX_BRANCH_NAME_BYTES,
200 Self::Tag => MAX_TAG_NAME_BYTES,
201 }
202 }
203
204 const fn label(self) -> &'static str {
205 match self {
206 Self::Ref => "ref",
207 Self::Branch => "branch",
208 Self::Tag => "tag",
209 }
210 }
211
212 const fn why(self) -> &'static str {
214 match self {
215 Self::Ref => "",
216 Self::Branch => {
217 ", because refs/heads/<name> and refs/mkit/packmap/<name> must \
218 each fit the 512-byte ref-name limit"
219 }
220 Self::Tag => ", because refs/tags/<name> must fit the 512-byte ref-name limit",
221 }
222 }
223}
224
225fn name_preview(name: &str) -> String {
227 const SHOWN: usize = 64;
228 match name.get(..SHOWN) {
229 Some(head) if name.len() > SHOWN => format!("{head}..."),
230 _ => name.to_owned(),
231 }
232}
233
234fn too_long_message(name: &str, len: usize, kind: RefNameKind) -> String {
237 format!(
238 "{} name too long ({len} bytes; at most {}{}, SPEC-REFS §3): '{}'",
239 kind.label(),
240 kind.max_bytes(),
241 kind.why(),
242 name_preview(name)
243 )
244}
245
246#[must_use]
251pub fn validate_ref_name(name: &str) -> bool {
252 name.len() <= MAX_REF_NAME_BYTES && validate_ref_name_grammar(name)
253}
254
255pub fn check_new_ref_name(name: &str) -> RefResult<()> {
262 check_new_name(name, RefNameKind::Ref)
263}
264
265pub fn check_new_name(name: &str, kind: RefNameKind) -> RefResult<()> {
270 if !validate_ref_name_grammar(name) {
271 return Err(RefError::InvalidRefName(name.to_string()));
272 }
273 if name.len() > kind.max_bytes() {
274 return Err(RefError::RefNameTooLong {
275 name: name.to_string(),
276 len: name.len(),
277 kind,
278 });
279 }
280 Ok(())
281}
282
283pub fn check_pushable_branch(branch: &str) -> RefResult<()> {
291 check_new_name(&format!("{PACKMAP_REF_PREFIX}{branch}"), RefNameKind::Ref)?;
292 check_new_name(&format!("{BRANCH_REF_PREFIX}{branch}"), RefNameKind::Ref)
293}
294
295fn check_local_write(
301 common_dir: &Path,
302 sub_dir: &str,
303 name: &str,
304 kind: RefNameKind,
305) -> RefResult<()> {
306 match check_new_name(name, kind) {
307 Err(RefError::RefNameTooLong { .. })
308 if name.len() <= MAX_REF_NAME_BYTES
309 && ref_path(common_dir, sub_dir, name).is_file() =>
310 {
311 Ok(())
312 }
313 other => other,
314 }
315}
316
317fn check_existing_ref_name(name: &str) -> RefResult<()> {
321 if validate_ref_name_grammar(name) {
322 Ok(())
323 } else {
324 Err(RefError::InvalidRefName(name.to_string()))
325 }
326}
327
328#[must_use]
347pub fn validate_ref_name_grammar(name: &str) -> bool {
348 if name.is_empty() {
349 return false;
350 }
351 if name.starts_with('/') {
352 return false;
353 }
354 let mut last_part: &str = "";
355 for part in name.split('/') {
356 if part.is_empty() {
357 return false;
358 }
359 if part.starts_with('.') {
360 return false;
361 }
362 let bytes = part.as_bytes();
367 if bytes.len() >= 5 && &bytes[bytes.len() - 5..] == b".lock" {
368 return false;
369 }
370 for &c in part.as_bytes() {
371 if c == 0 || c == b'\\' {
372 return false;
373 }
374 let allowed = c.is_ascii_alphanumeric() || c == b'.' || c == b'_' || c == b'-';
375 if !allowed {
376 return false;
377 }
378 }
379 last_part = part;
380 }
381 if last_part == "HEAD" {
382 return false;
383 }
384 true
385}
386
387#[cfg(feature = "history-mmr")]
389pub(crate) fn history_lock_name(branch: &str) -> String {
390 let full_ref = format!("refs/heads/{branch}");
391 format!(
392 "refs-history-{}.lock",
393 to_hex(&crate::hash::hash(full_ref.as_bytes()))
394 )
395}
396
397pub(crate) mod ancestry_state;
398
399pub fn pending_history_roots(layout: &RepoLayout) -> RefResult<BTreeSet<Hash>> {
402 ancestry_state::pending_roots(layout.common_dir())
403}
404
405pub(crate) fn cas_lock_name(common_dir: &Path, path: &Path) -> String {
416 let ref_key = path
417 .strip_prefix(common_dir)
418 .map_or_else(|_| path.to_string_lossy(), |p| p.to_string_lossy());
419 format!(
420 "refs-{}.lock",
421 to_hex(&crate::hash::hash(ref_key.as_bytes()))
422 )
423}
424
425#[must_use]
429pub fn validate_ref_prefix(prefix: &str) -> bool {
430 if prefix.is_empty() {
431 return true;
432 }
433 let trimmed = prefix.trim_end_matches('/');
434 if trimmed.is_empty() {
435 return false;
436 }
437 validate_ref_name(trimmed)
438}
439
440#[must_use]
442pub fn encode_ref_wire(h: &Hash) -> [u8; 65] {
443 let hex = to_hex(h);
444 let bytes = hex.as_bytes();
445 let mut out = [0u8; 65];
446 out[..HEX_LEN].copy_from_slice(bytes);
447 out[HEX_LEN] = b'\n';
448 out
449}
450
451#[must_use]
459pub fn decode_ref_wire(data: &[u8]) -> Option<Hash> {
460 let s = core::str::from_utf8(data).ok()?;
461 let trimmed = s.trim_end_matches(['\n', '\r', ' ', '\t']);
462 if trimmed.len() != HEX_LEN {
463 return None;
464 }
465 parse_lowercase_hash(trimmed.as_bytes())
466}
467
468#[must_use]
475pub fn parse_lowercase_hash(bytes: &[u8]) -> Option<Hash> {
476 if bytes.len() != HEX_LEN {
477 return None;
478 }
479 let mut out = [0u8; HASH_LEN];
480 for i in 0..HASH_LEN {
481 let hi = lowercase_nibble(bytes[i * 2])?;
482 let lo = lowercase_nibble(bytes[i * 2 + 1])?;
483 out[i] = (hi << 4) | lo;
484 }
485 Some(out)
486}
487
488fn lowercase_nibble(b: u8) -> Option<u8> {
489 match b {
490 b'0'..=b'9' => Some(b - b'0'),
491 b'a'..=b'f' => Some(10 + (b - b'a')),
492 _ => None,
493 }
494}
495
496pub fn init(layout: &RepoLayout) -> RefResult<()> {
501 fs::create_dir_all(layout.refs_dir())?;
502 fs::create_dir_all(layout.heads_dir())?;
503 fs::create_dir_all(layout.tags_dir())?;
504 fs::create_dir_all(layout.remotes_dir())?;
505 let head_path = layout.head_file();
506 if !head_path.exists() {
507 let body = format!("{HEAD_REF_PREFIX}main\n");
508 write_atomic(&head_path, body.as_bytes(), false)?;
509 }
510 Ok(())
511}
512
513pub fn read_head(layout: &RepoLayout) -> RefResult<Head> {
523 let path = layout.head_file();
524 let meta = match fs::metadata(&path) {
525 Ok(m) => m,
526 Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(RefError::NoHead),
527 Err(e) => return Err(RefError::Io(e)),
528 };
529 if meta.len() > HEAD_MAX_BYTES {
530 return Err(RefError::InvalidHead);
531 }
532 let raw = fs::read(&path)?;
533 let s = core::str::from_utf8(&raw).map_err(|_| RefError::InvalidHead)?;
534 let trimmed = s.trim_end_matches(['\n', '\r', ' ', '\t']);
535 if let Some(branch) = trimmed.strip_prefix(HEAD_REF_PREFIX) {
536 if !validate_ref_name_grammar(branch) {
539 return Err(RefError::InvalidHead);
540 }
541 return Ok(Head::Branch(branch.to_string()));
542 }
543 if trimmed.len() == HEX_LEN {
544 let h = parse_lowercase_hash(trimmed.as_bytes()).ok_or(RefError::InvalidHead)?;
545 return Ok(Head::Detached(h));
546 }
547 Err(RefError::InvalidHead)
548}
549
550pub fn write_head_branch(layout: &RepoLayout, branch: &str) -> RefResult<()> {
557 check_local_write(layout.common_dir(), HEADS_DIR, branch, RefNameKind::Branch)?;
558 let body = format!("{HEAD_REF_PREFIX}{branch}\n");
559 write_atomic(&layout.head_file(), body.as_bytes(), false)?;
560 Ok(())
561}
562
563pub fn write_head_detached(layout: &RepoLayout, h: &Hash) -> RefResult<()> {
568 let wire = encode_ref_wire(h);
569 write_atomic(&layout.head_file(), &wire, false)?;
570 Ok(())
571}
572
573pub fn resolve_head(layout: &RepoLayout) -> RefResult<Option<Hash>> {
576 let head = match read_head(layout) {
577 Ok(h) => h,
578 Err(RefError::NoHead) => return Ok(None),
579 Err(e) => return Err(e),
580 };
581 match head {
582 Head::Branch(name) => read_ref(layout, &name),
583 Head::Detached(h) => Ok(Some(h)),
584 }
585}
586
587pub fn update_head(layout: &RepoLayout, commit_hash: &Hash) -> RefResult<()> {
590 let head = read_head(layout)?;
591 match head {
592 Head::Branch(name) => write_ref(layout, &name, commit_hash),
593 Head::Detached(_) => write_head_detached(layout, commit_hash),
594 }
595}
596
597pub fn read_ref(layout: &RepoLayout, branch: &str) -> RefResult<Option<Hash>> {
608 check_existing_ref_name(branch)?;
609 read_ref_under(layout.common_dir(), HEADS_DIR, branch)
610}
611
612pub fn write_ref(layout: &RepoLayout, branch: &str, h: &Hash) -> RefResult<()> {
615 update_ref(layout, branch, RefWriteCondition::Any, h)
616}
617
618pub fn update_ref(
625 layout: &RepoLayout,
626 branch: &str,
627 condition: RefWriteCondition,
628 h: &Hash,
629) -> RefResult<()> {
630 check_local_write(layout.common_dir(), HEADS_DIR, branch, RefNameKind::Branch)?;
631 let path = ref_path(layout.common_dir(), HEADS_DIR, branch);
632 let wire = encode_ref_wire(h);
633 cas_write(layout.common_dir(), &path, &wire, branch, condition)
634}
635
636#[cfg(feature = "history-mmr")]
642pub(crate) fn acquire_history_mutation(
643 layout: &RepoLayout,
644 branch: &str,
645) -> RefResult<(crate::repo_lock::RepoLock, RefMutation)> {
646 check_existing_ref_name(branch)?;
649 let history =
650 crate::repo_lock::acquire_default(layout.common_dir(), &history_lock_name(branch))
651 .map_err(|e| RefError::InvalidRef(format!("{branch}: history lock: {e}")))?;
652 let path = ref_path(layout.common_dir(), HEADS_DIR, branch);
653 let mutation = RefMutation::acquire(layout.common_dir(), &path, branch)?;
654 Ok((history, mutation))
655}
656
657#[cfg(feature = "history-mmr")]
660pub fn update_ref_with_ancestry(
661 layout: &RepoLayout,
662 branch: &str,
663 condition: RefWriteCondition,
664 target: &Hash,
665 store: &crate::store::ObjectStore,
666) -> RefResult<()> {
667 check_local_write(layout.common_dir(), HEADS_DIR, branch, RefNameKind::Branch)?;
668 let (_history, mutation) = acquire_history_mutation(layout, branch)?;
669 crate::history::ancestry::advance(layout, branch, &mutation, condition, *target, store).map_err(
670 |e| match e {
671 crate::history::HistoryError::Ref(e) => e,
672 other => RefError::InvalidRef(format!("{branch}: ancestry publication: {other}")),
673 },
674 )
675}
676
677#[cfg(feature = "history-mmr")]
681pub fn delete_ref_with_ancestry(
682 layout: &RepoLayout,
683 branch: &str,
684 expected: Option<Hash>,
685 store: &crate::store::ObjectStore,
686) -> RefResult<()> {
687 let (_history, mutation) = acquire_history_mutation(layout, branch)?;
688 crate::history::ancestry::recover(layout, branch, &mutation, store)
689 .map_err(|e| RefError::InvalidRef(format!("{branch}: history recovery: {e}")))?;
690 mutation.delete(expected)
691}
692
693pub fn delete_ref(layout: &RepoLayout, branch: &str) -> RefResult<()> {
695 check_existing_ref_name(branch)?;
696 let path = ref_path(layout.common_dir(), HEADS_DIR, branch);
697 RefMutation::acquire(layout.common_dir(), &path, branch)?.delete(None)
698}
699
700pub fn delete_ref_safe(layout: &RepoLayout, branch: &str) -> RefResult<()> {
702 match read_head(layout) {
703 Ok(Head::Branch(current)) if current == branch => {
704 Err(RefError::CurrentBranch(branch.to_string()))
705 }
706 _ => delete_ref(layout, branch),
707 }
708}
709
710pub fn delete_ref_if_matches(layout: &RepoLayout, branch: &str, expected: Hash) -> RefResult<()> {
749 check_existing_ref_name(branch)?;
750 let path = ref_path(layout.common_dir(), HEADS_DIR, branch);
751 RefMutation::acquire(layout.common_dir(), &path, branch)?.delete(Some(expected))
752}
753
754pub fn list_refs(layout: &RepoLayout) -> RefResult<Vec<Ref>> {
756 list_refs_under(layout.common_dir(), HEADS_DIR)
757}
758
759pub fn read_remote_ref(layout: &RepoLayout, remote: &str, branch: &str) -> RefResult<Option<Hash>> {
765 check_existing_ref_name(remote)?;
766 check_existing_ref_name(branch)?;
767 read_ref_under(layout.common_dir(), &remote_ref_dir(remote), branch)
768}
769
770pub fn write_remote_ref(
772 layout: &RepoLayout,
773 remote: &str,
774 branch: &str,
775 h: &Hash,
776) -> RefResult<()> {
777 check_new_ref_name(remote)?;
778 check_new_ref_name(branch)?;
779 let path = ref_path(layout.common_dir(), &remote_ref_dir(remote), branch);
780 let wire = encode_ref_wire(h);
781 cas_write(
782 layout.common_dir(),
783 &path,
784 &wire,
785 branch,
786 RefWriteCondition::Any,
787 )
788}
789
790#[derive(Debug)]
838pub struct RemoteRefBatch<'a> {
839 layout: &'a RepoLayout,
840 sub_dir: String,
841 touched_dirs: BTreeSet<PathBuf>,
842}
843
844impl<'a> RemoteRefBatch<'a> {
845 pub fn new(layout: &'a RepoLayout, remote: &str) -> RefResult<Self> {
850 check_new_ref_name(remote)?;
851 Ok(Self {
852 layout,
853 sub_dir: remote_ref_dir(remote),
854 touched_dirs: BTreeSet::new(),
855 })
856 }
857
858 pub fn write(&mut self, branch: &str, h: &Hash) -> RefResult<()> {
873 check_new_ref_name(branch)?;
874 let path = ref_path(self.layout.common_dir(), &self.sub_dir, branch);
875 let guard = RefMutation::acquire(self.layout.common_dir(), &path, branch)?;
876 guard.invalidate_history()?;
877 let parent = path
878 .parent()
879 .expect("remote-tracking ref path always has a parent")
880 .to_path_buf();
881 fs::create_dir_all(&parent)?;
882 let wire = encode_ref_wire(h);
883 crate::atomic::write_content_synced(&path, &wire)?;
884 self.touched_dirs.insert(parent);
885 Ok(())
886 }
887
888 pub fn commit(self) -> RefResult<()> {
902 for dir in &self.touched_dirs {
903 crate::atomic::sync_dir(dir)?;
904 }
905 Ok(())
906 }
907}
908
909pub fn delete_remote_ref(layout: &RepoLayout, remote: &str, branch: &str) -> RefResult<()> {
912 check_existing_ref_name(remote)?;
913 check_existing_ref_name(branch)?;
914 let path = ref_path(layout.common_dir(), &remote_ref_dir(remote), branch);
915 RefMutation::acquire(layout.common_dir(), &path, &format!("{remote}/{branch}"))?.delete(None)
916}
917
918pub fn list_remote_refs(layout: &RepoLayout, remote: &str) -> RefResult<Vec<Ref>> {
920 check_existing_ref_name(remote)?;
921 list_refs_under(layout.common_dir(), &remote_ref_dir(remote))
922}
923
924pub fn list_remote_refs_with(
928 layout: &RepoLayout,
929 remote: &str,
930 read_batch: impl FnOnce(&[RefCandidate]) -> Vec<RefReadOutcome>,
931) -> RefResult<Vec<Ref>> {
932 check_existing_ref_name(remote)?;
933 list_refs_under_with(layout.common_dir(), &remote_ref_dir(remote), read_batch)
934}
935
936pub fn list_remote_names(layout: &RepoLayout) -> RefResult<Vec<String>> {
942 let dir = layout.remotes_dir();
943 let entries = match fs::read_dir(&dir) {
944 Ok(e) => e,
945 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
946 Err(e) => return Err(RefError::Io(e)),
947 };
948 let mut names = Vec::new();
949 for entry in entries {
950 let entry = entry.map_err(RefError::Io)?;
951 if !entry.file_type().map_err(RefError::Io)?.is_dir() {
952 continue;
953 }
954 if let Some(name) = entry.file_name().to_str()
955 && validate_ref_name_grammar(name)
956 {
957 names.push(name.to_owned());
958 }
959 }
960 names.sort();
961 Ok(names)
962}
963
964pub fn read_tag(layout: &RepoLayout, name: &str) -> RefResult<Option<Hash>> {
970 check_existing_ref_name(name)?;
971 read_ref_under(layout.common_dir(), TAGS_DIR, name)
972}
973
974pub fn write_tag(layout: &RepoLayout, name: &str, h: &Hash) -> RefResult<()> {
976 update_tag(layout, name, RefWriteCondition::Any, h)
977}
978
979pub fn update_tag(
982 layout: &RepoLayout,
983 name: &str,
984 condition: RefWriteCondition,
985 h: &Hash,
986) -> RefResult<()> {
987 check_local_write(layout.common_dir(), TAGS_DIR, name, RefNameKind::Tag)?;
988 let path = ref_path(layout.common_dir(), TAGS_DIR, name);
989 let wire = encode_ref_wire(h);
990 cas_write(layout.common_dir(), &path, &wire, name, condition)
991}
992
993pub fn delete_tag(layout: &RepoLayout, name: &str) -> RefResult<()> {
995 check_existing_ref_name(name)?;
996 let path = ref_path(layout.common_dir(), TAGS_DIR, name);
997 RefMutation::acquire(layout.common_dir(), &path, name)?.delete(None)
998}
999
1000pub fn list_tags(layout: &RepoLayout) -> RefResult<Vec<Ref>> {
1002 list_refs_under(layout.common_dir(), TAGS_DIR)
1003}
1004
1005pub fn list_tags_with(
1009 layout: &RepoLayout,
1010 read_batch: impl FnOnce(&[RefCandidate]) -> Vec<RefReadOutcome>,
1011) -> RefResult<Vec<Ref>> {
1012 list_refs_under_with(layout.common_dir(), TAGS_DIR, read_batch)
1013}
1014
1015pub fn load_shallow_boundaries(layout: &RepoLayout) -> RefResult<Option<Vec<Hash>>> {
1022 let path = layout.shallow_file();
1023 let meta = match fs::metadata(&path) {
1024 Ok(m) => m,
1025 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
1026 Err(e) => return Err(RefError::Io(e)),
1027 };
1028 if meta.len() == 0 {
1029 return Ok(None);
1030 }
1031 if meta.len() > SHALLOW_MAX_BYTES {
1032 return Err(RefError::InvalidRef("shallow file too large".to_string()));
1033 }
1034 let bytes = fs::read(&path)?;
1035 let s = core::str::from_utf8(&bytes).map_err(|_| RefError::InvalidHead)?;
1036 let mut out = Vec::new();
1037 for line in s.split('\n') {
1038 let trimmed = line.trim_end_matches(['\r', ' ', '\t']);
1039 if trimmed.len() != HEX_LEN {
1040 continue;
1041 }
1042 if let Some(h) = parse_lowercase_hash(trimmed.as_bytes()) {
1043 out.push(h);
1044 }
1045 }
1046 if out.is_empty() {
1047 return Ok(None);
1048 }
1049 Ok(Some(out))
1050}
1051
1052pub fn write_shallow_boundaries(layout: &RepoLayout, boundaries: &[Hash]) -> RefResult<()> {
1055 let path = layout.shallow_file();
1056 if boundaries.is_empty() {
1057 match fs::remove_file(&path) {
1058 Ok(()) => Ok(()),
1059 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
1060 Err(e) => Err(RefError::Io(e)),
1061 }
1062 } else {
1063 let mut out = Vec::with_capacity(boundaries.len() * 65);
1064 for h in boundaries {
1065 out.extend_from_slice(&encode_ref_wire(h));
1066 }
1067 write_atomic(&path, &out, true)?;
1068 Ok(())
1069 }
1070}
1071
1072fn ref_path(common_dir: &Path, sub_dir: &str, name: &str) -> PathBuf {
1077 let mut path = common_dir.join(sub_dir);
1078 for segment in name.split('/') {
1079 path.push(segment);
1080 }
1081 path
1082}
1083
1084fn remote_ref_dir(remote: &str) -> String {
1085 format!("{REMOTES_DIR}/{remote}")
1086}
1087
1088fn read_ref_under(common_dir: &Path, sub_dir: &str, name: &str) -> RefResult<Option<Hash>> {
1089 let path = ref_path(common_dir, sub_dir, name);
1090 let meta = match fs::metadata(&path) {
1091 Ok(m) => m,
1092 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
1093 Err(e) => return Err(RefError::Io(e)),
1094 };
1095 if meta.len() > REF_FILE_MAX_BYTES {
1096 return Err(RefError::InvalidRef(name.to_string()));
1097 }
1098 let bytes = fs::read(&path)?;
1099 let h = decode_ref_wire(&bytes).ok_or_else(|| RefError::InvalidRef(name.to_string()))?;
1100 Ok(Some(h))
1101}
1102
1103pub(crate) struct RefMutation {
1107 path: PathBuf,
1108 common_dir: PathBuf,
1109 name: String,
1110 _lock: crate::repo_lock::RepoLock,
1111}
1112
1113impl RefMutation {
1114 fn acquire(common_dir: &Path, path: &Path, name: &str) -> RefResult<Self> {
1115 let lock = crate::repo_lock::acquire_default(common_dir, &cas_lock_name(common_dir, path))
1116 .map_err(|e| match e {
1117 crate::repo_lock::LockError::Io(io) => RefError::Io(io),
1118 other => RefError::InvalidRef(format!("{name}: ref mutation lock: {other}")),
1119 })?;
1120 Ok(Self {
1121 path: path.to_path_buf(),
1122 common_dir: common_dir.to_path_buf(),
1123 name: name.to_string(),
1124 _lock: lock,
1125 })
1126 }
1127
1128 pub(crate) fn current(&self) -> RefResult<Option<Hash>> {
1129 match fs::read(&self.path) {
1130 Ok(bytes) => decode_ref_wire(&bytes)
1131 .map(Some)
1132 .ok_or_else(|| RefError::InvalidRef(self.name.clone())),
1133 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
1134 Err(e) => Err(RefError::Io(e)),
1135 }
1136 }
1137
1138 pub(crate) fn check(&self, condition: RefWriteCondition) -> RefResult<()> {
1139 let matches = match condition {
1140 RefWriteCondition::Any => true,
1141 RefWriteCondition::Missing => !self.path.try_exists()?,
1142 RefWriteCondition::Match(expected) => self.current()? == Some(expected),
1143 };
1144 if matches {
1145 Ok(())
1146 } else {
1147 Err(RefError::Conflict(self.name.clone()))
1148 }
1149 }
1150
1151 fn invalidate_history(&self) -> RefResult<()> {
1152 if let Ok(full_ref) = self.path.strip_prefix(&self.common_dir) {
1153 ancestry_state::invalidate(&self.common_dir, &full_ref.to_string_lossy())?;
1154 }
1155 Ok(())
1156 }
1157
1158 fn write(&self, wire: &[u8; 65], condition: RefWriteCondition) -> RefResult<()> {
1159 self.check(condition)?;
1160 if self.current().ok().flatten() != decode_ref_wire(wire) {
1163 self.invalidate_history()?;
1164 } else if ancestry_state::Transaction::read(&ancestry_state::branch_dir(
1165 &self.common_dir,
1166 &self
1167 .path
1168 .strip_prefix(&self.common_dir)
1169 .unwrap_or(&self.path)
1170 .to_string_lossy(),
1171 ))?
1172 .is_some()
1173 {
1174 return Err(RefError::InvalidRef("pending history publication".into()));
1175 }
1176 self.write_preserving_history(wire, condition)
1177 }
1178
1179 pub(crate) fn write_preserving_history(
1180 &self,
1181 wire: &[u8; 65],
1182 condition: RefWriteCondition,
1183 ) -> RefResult<()> {
1184 self.check(condition)?;
1185 if matches!(condition, RefWriteCondition::Missing) {
1186 if !write_create_new(&self.path, wire, true)? {
1187 return Err(RefError::Conflict(self.name.clone()));
1188 }
1189 } else {
1190 write_atomic(&self.path, wire, true)?;
1191 }
1192 Ok(())
1193 }
1194
1195 fn delete(&self, expected: Option<Hash>) -> RefResult<()> {
1196 if let Some(expected) = expected {
1197 self.check(RefWriteCondition::Match(expected))?;
1198 }
1199 self.invalidate_history()?;
1200 match fs::remove_file(&self.path) {
1201 Ok(()) => {
1202 crate::atomic::sync_dir(self.path.parent().expect("ref path has parent"))?;
1203 Ok(())
1204 }
1205 Err(e) if e.kind() == io::ErrorKind::NotFound => {
1206 if expected.is_some() {
1207 Err(RefError::Conflict(self.name.clone()))
1208 } else {
1209 Err(RefError::NotFound(self.name.clone()))
1210 }
1211 }
1212 Err(e) => Err(RefError::Io(e)),
1213 }
1214 }
1215}
1216
1217fn cas_write(
1218 common_dir: &Path,
1219 path: &Path,
1220 wire: &[u8; 65],
1221 name_for_err: &str,
1222 condition: RefWriteCondition,
1223) -> RefResult<()> {
1224 RefMutation::acquire(common_dir, path, name_for_err)?.write(wire, condition)
1225}
1226
1227fn list_refs_under(common_dir: &Path, sub_dir: &str) -> RefResult<Vec<Ref>> {
1228 list_refs_under_with(common_dir, sub_dir, sequential_read_batch)
1229}
1230
1231fn sequential_read_batch(candidates: &[RefCandidate]) -> Vec<RefReadOutcome> {
1236 candidates.iter().map(read_ref_candidate).collect()
1237}
1238
1239#[must_use]
1247pub fn read_ref_candidate(candidate: &RefCandidate) -> RefReadOutcome {
1248 use std::io::Read;
1249
1250 let mut buf = [0u8; 256];
1258 let read = fs::File::open(&candidate.path).and_then(|mut f| {
1259 loop {
1260 match f.read(&mut buf) {
1261 Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
1262 r => break r,
1263 }
1264 }
1265 });
1266 match read {
1267 Ok(n) if n < buf.len() => RefReadOutcome::Decoded(decode_ref_wire(&buf[..n])),
1268 Ok(_) => match fs::read(&candidate.path) {
1269 Ok(bytes) => RefReadOutcome::Decoded(decode_ref_wire(&bytes)),
1270 Err(_) => RefReadOutcome::Unreadable,
1271 },
1272 Err(_) => RefReadOutcome::Unreadable,
1273 }
1274}
1275
1276#[derive(Debug, Clone)]
1280pub struct RefCandidate {
1281 pub name: String,
1282 pub path: PathBuf,
1283}
1284
1285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1292pub enum RefReadOutcome {
1293 Unreadable,
1294 Decoded(Option<Hash>),
1295}
1296
1297pub fn list_refs_with(
1310 layout: &RepoLayout,
1311 read_batch: impl FnOnce(&[RefCandidate]) -> Vec<RefReadOutcome>,
1312) -> RefResult<Vec<Ref>> {
1313 list_refs_under_with(layout.common_dir(), HEADS_DIR, read_batch)
1314}
1315
1316fn list_refs_under_with(
1317 common_dir: &Path,
1318 sub_dir: &str,
1319 read_batch: impl FnOnce(&[RefCandidate]) -> Vec<RefReadOutcome>,
1320) -> RefResult<Vec<Ref>> {
1321 let root = common_dir.join(sub_dir);
1322 let mut candidates = Vec::new();
1323 if !root.is_dir() {
1324 return Ok(Vec::new());
1325 }
1326 collect_ref_candidates(&root, "", &mut candidates, 0)?;
1327 let outcomes = read_batch(&candidates);
1328 if outcomes.len() != candidates.len() {
1329 return Err(RefError::RefBatchLengthMismatch {
1330 expected: candidates.len(),
1331 actual: outcomes.len(),
1332 });
1333 }
1334 let mut out = Vec::with_capacity(candidates.len());
1335 for (candidate, outcome) in candidates.into_iter().zip(outcomes) {
1336 match outcome {
1337 RefReadOutcome::Unreadable => {}
1338 RefReadOutcome::Decoded(hash) => out.push(Ref {
1339 name: candidate.name,
1340 hash,
1341 }),
1342 }
1343 }
1344 out.sort_by(|a, b| a.name.cmp(&b.name));
1345 Ok(out)
1346}
1347
1348const MAX_REF_DEPTH: usize = 32;
1354
1355fn collect_ref_candidates(
1356 root: &Path,
1357 prefix: &str,
1358 out: &mut Vec<RefCandidate>,
1359 depth: usize,
1360) -> RefResult<()> {
1361 if depth > MAX_REF_DEPTH {
1362 return Ok(());
1366 }
1367 let dir_path = if prefix.is_empty() {
1368 root.to_path_buf()
1369 } else {
1370 root.join(prefix)
1371 };
1372 let iter = match fs::read_dir(&dir_path) {
1373 Ok(i) => i,
1374 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
1375 Err(e) => return Err(RefError::Io(e)),
1376 };
1377 for entry in iter {
1378 let entry = entry?;
1379 let file_name = match entry.file_name().to_str() {
1380 Some(s) => s.to_string(),
1381 None => continue, };
1383 let child_name = if prefix.is_empty() {
1384 file_name.clone()
1385 } else {
1386 format!("{prefix}/{file_name}")
1387 };
1388 let ft = entry.file_type()?;
1389 if ft.is_dir() {
1390 collect_ref_candidates(root, &child_name, out, depth + 1)?;
1391 continue;
1392 }
1393 if !ft.is_file() {
1394 continue;
1395 }
1396 if !validate_ref_name_grammar(&child_name) {
1399 continue;
1400 }
1401 out.push(RefCandidate {
1402 name: child_name,
1403 path: entry.path(),
1404 });
1405 }
1406 Ok(())
1407}
1408
1409#[doc(hidden)]
1416#[must_use]
1417pub fn _hash_from_lowercase_hex_for_tests(s: &str) -> Option<Hash> {
1418 parse_lowercase_hash(s.as_bytes())
1419}
1420
1421#[cfg(test)]
1422mod tests {
1423 use super::*;
1424 use crate::hash;
1425 use std::sync::Barrier;
1426 use tempfile::TempDir;
1427
1428 fn fresh_repo() -> (TempDir, RepoLayout) {
1429 let dir = TempDir::new().unwrap();
1430 let layout = RepoLayout::single(dir.path());
1431 fs::create_dir_all(layout.common_dir()).unwrap();
1432 init(&layout).unwrap();
1433 (dir, layout)
1434 }
1435
1436 fn h(seed: &str) -> Hash {
1437 hash::hash(seed.as_bytes())
1438 }
1439
1440 #[test]
1443 fn validate_accepts_simple_names() {
1444 assert!(validate_ref_name("main"));
1445 assert!(validate_ref_name("feat/v1.0-beta"));
1446 assert!(validate_ref_name("release/2024_09"));
1447 }
1448
1449 #[test]
1450 fn validate_rejects_empty() {
1451 assert!(!validate_ref_name(""));
1452 }
1453
1454 #[test]
1455 fn validate_rejects_leading_slash() {
1456 assert!(!validate_ref_name("/main"));
1457 }
1458
1459 #[test]
1460 fn validate_rejects_dotdot_segment() {
1461 assert!(!validate_ref_name("feat/.."));
1462 assert!(!validate_ref_name("../escape"));
1463 assert!(!validate_ref_name("feat/./topic"));
1464 }
1465
1466 #[test]
1467 fn validate_rejects_dot_leading_segment() {
1468 assert!(!validate_ref_name(".hidden"));
1473 assert!(!validate_ref_name("refs/.hidden/main"));
1474 assert!(!validate_ref_name(".rename.tmp.12345.0"));
1475 assert!(!validate_ref_name("refs/remotes/.rename.tmp.12345.0/main"));
1476 }
1477
1478 #[test]
1479 fn validate_rejects_double_slash() {
1480 assert!(!validate_ref_name("refs//heads/main"));
1481 assert!(!validate_ref_name("main/"));
1482 }
1483
1484 #[test]
1485 fn validate_rejects_disallowed_bytes() {
1486 assert!(!validate_ref_name("main@v1"));
1487 assert!(!validate_ref_name("feat\\branch"));
1488 assert!(!validate_ref_name("with space"));
1489 }
1490
1491 #[test]
1492 fn validate_rejects_lock_suffix() {
1493 assert!(!validate_ref_name("refs/heads/main.lock"));
1494 }
1495
1496 #[test]
1497 fn validate_rejects_head_final_segment() {
1498 assert!(!validate_ref_name("refs/heads/HEAD"));
1499 assert!(!validate_ref_name("HEAD"));
1500 }
1501
1502 #[test]
1503 fn validate_accepts_main_regression() {
1504 assert!(validate_ref_name("refs/heads/main"));
1505 }
1506
1507 #[test]
1508 fn validate_accepts_non_lock_suffix_regression() {
1509 assert!(validate_ref_name("refs/heads/lockfile"));
1511 }
1512
1513 #[test]
1514 fn validate_accepts_headless_regression() {
1515 assert!(validate_ref_name("refs/heads/HEADless"));
1517 }
1518
1519 fn long_name(len: usize) -> String {
1522 let mut name = String::new();
1523 while name.len() < len {
1524 if !name.is_empty() {
1525 name.push('/');
1526 }
1527 let seg = (len - name.len()).min(100);
1528 name.push_str(&"a".repeat(seg));
1529 }
1530 assert_eq!(name.len(), len);
1531 name
1532 }
1533
1534 #[test]
1537 fn validate_bounds_name_length() {
1538 let longest = long_name(MAX_REF_NAME_BYTES);
1539 let over = long_name(MAX_REF_NAME_BYTES + 1);
1540 assert!(validate_ref_name(&longest));
1541 assert!(!validate_ref_name(&over));
1542 assert!(validate_ref_name_grammar(&over));
1543 assert!(check_new_ref_name(&longest).is_ok());
1544 let err = check_new_ref_name(&over).unwrap_err();
1545 assert!(matches!(err, RefError::RefNameTooLong { len: 513, .. }));
1546 let shown = err.to_string();
1547 assert!(
1548 shown.contains("ref name too long (513 bytes; at most 512"),
1549 "{shown}"
1550 );
1551 assert!(shown.len() < 200, "the name is shortened: {shown}");
1552 assert!(matches!(
1553 check_new_ref_name("bad..name/.x"),
1554 Err(RefError::InvalidRefName(_))
1555 ));
1556 assert!(validate_ref_prefix(&format!("{longest}/")));
1557 assert!(!validate_ref_prefix(&over));
1558 }
1559
1560 #[test]
1565 fn branch_and_tag_bounds_derive_from_their_wire_names() {
1566 assert_eq!(MAX_BRANCH_NAME_BYTES, 494);
1567 assert_eq!(MAX_TAG_NAME_BYTES, 502);
1568 let (_dir, layout) = fresh_repo();
1569 let id = h("c");
1570 update_ref(&layout, &long_name(494), RefWriteCondition::Missing, &id).unwrap();
1571 check_pushable_branch(&long_name(494)).unwrap();
1572 let err =
1573 update_ref(&layout, &long_name(495), RefWriteCondition::Missing, &id).unwrap_err();
1574 assert!(matches!(
1575 err,
1576 RefError::RefNameTooLong {
1577 len: 495,
1578 kind: RefNameKind::Branch,
1579 ..
1580 }
1581 ));
1582 let shown = err.to_string();
1583 assert!(
1584 shown.contains("branch name too long (495 bytes; at most 494")
1585 && shown.contains("refs/mkit/packmap/<name>"),
1586 "{shown}"
1587 );
1588 assert!(matches!(
1589 write_head_branch(&layout, &long_name(495)),
1590 Err(RefError::RefNameTooLong { .. })
1591 ));
1592 write_tag(&layout, &long_name(502), &id).unwrap();
1593 let err = write_tag(&layout, &long_name(503), &id).unwrap_err();
1594 assert!(
1595 err.to_string()
1596 .contains("tag name too long (503 bytes; at most 502"),
1597 "{err}"
1598 );
1599
1600 let old = long_name(500);
1602 let path = ref_path(layout.common_dir(), HEADS_DIR, &old);
1603 fs::create_dir_all(path.parent().unwrap()).unwrap();
1604 fs::write(&path, encode_ref_wire(&id)).unwrap();
1605 write_ref(&layout, &old, &h("next")).unwrap();
1606 write_head_branch(&layout, &old).unwrap();
1607 let err = check_pushable_branch(&old).unwrap_err();
1608 assert!(
1609 err.to_string()
1610 .contains("ref name too long (518 bytes; at most 512")
1611 && err.to_string().contains("'refs/mkit/packmap/"),
1612 "{err}"
1613 );
1614 }
1615
1616 #[test]
1619 fn new_refs_over_the_bound_are_refused() {
1620 let (_dir, layout) = fresh_repo();
1621 let over = long_name(MAX_REF_NAME_BYTES + 1);
1622 let id = h("c");
1623 let too_long = |r: RefResult<()>| matches!(r, Err(RefError::RefNameTooLong { .. }));
1624 assert!(too_long(update_ref(
1625 &layout,
1626 &over,
1627 RefWriteCondition::Missing,
1628 &id
1629 )));
1630 assert!(too_long(write_ref(&layout, &over, &id)));
1631 assert!(too_long(write_tag(&layout, &over, &id)));
1632 assert!(too_long(write_remote_ref(&layout, "origin", &over, &id)));
1633 assert!(too_long(write_head_branch(&layout, &over)));
1634 let mut batch = RemoteRefBatch::new(&layout, "origin").unwrap();
1635 assert!(too_long(batch.write(&over, &id)));
1636 assert!(!layout.heads_dir().join("a".repeat(100)).exists());
1637 }
1638
1639 #[test]
1643 fn existing_ref_over_the_bound_stays_usable_and_deletable() {
1644 let (_dir, layout) = fresh_repo();
1645 let over = long_name(MAX_REF_NAME_BYTES + 1);
1646 let id = h("legacy");
1647 let path = ref_path(layout.common_dir(), HEADS_DIR, &over);
1648 fs::create_dir_all(path.parent().unwrap()).unwrap();
1649 fs::write(&path, encode_ref_wire(&id)).unwrap();
1650 write_ref(&layout, "main", &h("main")).unwrap();
1651
1652 let listed: Vec<_> = list_refs(&layout)
1653 .unwrap()
1654 .into_iter()
1655 .map(|r| r.name)
1656 .collect();
1657 assert!(listed.contains(&over) && listed.contains(&"main".to_owned()));
1658 assert_eq!(read_ref(&layout, &over).unwrap(), Some(id));
1659 fs::write(layout.head_file(), format!("{HEAD_REF_PREFIX}{over}\n")).unwrap();
1660 assert_eq!(read_head(&layout).unwrap(), Head::Branch(over.clone()));
1661 assert_eq!(resolve_head(&layout).unwrap(), Some(id));
1662 assert!(matches!(
1663 update_head(&layout, &h("next")),
1664 Err(RefError::RefNameTooLong { .. })
1665 ));
1666 assert_eq!(read_ref(&layout, &over).unwrap(), Some(id), "unchanged");
1667
1668 write_head_branch(&layout, "main").unwrap();
1670 delete_ref_if_matches(&layout, &over, id).unwrap();
1671 assert_eq!(read_ref(&layout, &over).unwrap(), None);
1672 fs::create_dir_all(path.parent().unwrap()).unwrap();
1673 fs::write(&path, encode_ref_wire(&id)).unwrap();
1674 delete_ref_safe(&layout, &over).unwrap();
1675 assert!(!path.exists());
1676
1677 let tag = ref_path(layout.common_dir(), TAGS_DIR, &over);
1679 fs::create_dir_all(tag.parent().unwrap()).unwrap();
1680 fs::write(&tag, encode_ref_wire(&id)).unwrap();
1681 assert!(list_tags(&layout).unwrap().iter().any(|r| r.name == over));
1682 assert_eq!(read_tag(&layout, &over).unwrap(), Some(id));
1683 delete_tag(&layout, &over).unwrap();
1684 let remote = ref_path(layout.common_dir(), &remote_ref_dir("origin"), &over);
1685 fs::create_dir_all(remote.parent().unwrap()).unwrap();
1686 fs::write(&remote, encode_ref_wire(&id)).unwrap();
1687 assert!(
1688 list_remote_refs(&layout, "origin")
1689 .unwrap()
1690 .iter()
1691 .any(|r| r.name == over)
1692 );
1693 assert_eq!(read_remote_ref(&layout, "origin", &over).unwrap(), Some(id));
1694 delete_remote_ref(&layout, "origin", &over).unwrap();
1695 }
1696
1697 #[test]
1698 fn validate_prefix() {
1699 assert!(validate_ref_prefix(""));
1700 assert!(validate_ref_prefix("refs/heads/"));
1701 assert!(validate_ref_prefix("refs/heads"));
1702 assert!(!validate_ref_prefix("refs//heads/"));
1703 assert!(!validate_ref_prefix("/"));
1704 }
1705
1706 #[test]
1709 fn wire_round_trip() {
1710 let original = h("test-ref");
1711 let wire = encode_ref_wire(&original);
1712 assert_eq!(wire.len(), 65);
1713 assert_eq!(wire[64], b'\n');
1714 let parsed = decode_ref_wire(&wire).unwrap();
1715 assert_eq!(parsed, original);
1716 }
1717
1718 #[test]
1719 fn wire_rejects_uppercase() {
1720 let original = h("test-ref");
1721 let mut wire = encode_ref_wire(&original);
1722 let mut flipped = false;
1725 for b in &mut wire[..HEX_LEN] {
1726 if (b'a'..=b'f').contains(b) {
1727 *b -= b'a' - b'A';
1728 flipped = true;
1729 break;
1730 }
1731 }
1732 assert!(flipped, "test fixture should contain at least one a-f");
1733 assert!(decode_ref_wire(&wire).is_none());
1734 }
1735
1736 #[test]
1737 fn wire_rejects_short_input() {
1738 let bad = b"deadbeef\n";
1739 assert!(decode_ref_wire(bad).is_none());
1740 }
1741
1742 #[test]
1743 fn wire_rejects_non_hex() {
1744 let mut wire = encode_ref_wire(&h("x"));
1745 wire[1] = b'g';
1746 assert!(decode_ref_wire(&wire).is_none());
1747 }
1748
1749 #[test]
1750 fn wire_tolerates_trailing_cr() {
1751 let original = h("eol");
1753 let mut buf = encode_ref_wire(&original).to_vec();
1754 buf.insert(64, b'\r');
1755 let parsed = decode_ref_wire(&buf).unwrap();
1756 assert_eq!(parsed, original);
1757 }
1758
1759 #[test]
1762 fn init_writes_default_head() {
1763 let (_dir, mkit) = fresh_repo();
1764 let head = read_head(&mkit).unwrap();
1765 assert_eq!(head, Head::Branch("main".to_string()));
1766 }
1767
1768 #[test]
1769 fn write_and_read_branch_ref() {
1770 let (_dir, mkit) = fresh_repo();
1771 let commit = h("commit1");
1772 write_ref(&mkit, "main", &commit).unwrap();
1773 let read = read_ref(&mkit, "main").unwrap();
1774 assert_eq!(read, Some(commit));
1775 }
1776
1777 #[test]
1778 fn resolve_head_with_no_commits_returns_none() {
1779 let (_dir, mkit) = fresh_repo();
1780 assert_eq!(resolve_head(&mkit).unwrap(), None);
1781 }
1782
1783 #[test]
1784 fn resolve_head_after_commit() {
1785 let (_dir, mkit) = fresh_repo();
1786 let commit = h("commit1");
1787 write_ref(&mkit, "main", &commit).unwrap();
1788 assert_eq!(resolve_head(&mkit).unwrap(), Some(commit));
1789 }
1790
1791 #[test]
1792 fn update_head_updates_current_branch() {
1793 let (_dir, mkit) = fresh_repo();
1794 let h1 = h("c1");
1795 update_head(&mkit, &h1).unwrap();
1796 assert_eq!(resolve_head(&mkit).unwrap(), Some(h1));
1797 let h2 = h("c2");
1798 update_head(&mkit, &h2).unwrap();
1799 assert_eq!(resolve_head(&mkit).unwrap(), Some(h2));
1800 }
1801
1802 #[test]
1803 fn detached_head_round_trip() {
1804 let dir = TempDir::new().unwrap();
1805 let mkit = RepoLayout::single(dir.path());
1806 fs::create_dir_all(mkit.common_dir()).unwrap();
1807 let commit = h("detached");
1808 write_head_detached(&mkit, &commit).unwrap();
1809 match read_head(&mkit).unwrap() {
1810 Head::Detached(got) => assert_eq!(got, commit),
1811 other @ Head::Branch(_) => panic!("expected detached, got {other:?}"),
1812 }
1813 assert_eq!(resolve_head(&mkit).unwrap(), Some(commit));
1814 }
1815
1816 #[test]
1817 fn read_head_rejects_oversize_file() {
1818 let dir = TempDir::new().unwrap();
1820 let mkit = RepoLayout::single(dir.path());
1821 fs::create_dir_all(mkit.common_dir()).unwrap();
1822 fs::write(
1823 mkit.head_file(),
1824 vec![b'a'; usize::try_from(HEAD_MAX_BYTES).unwrap() + 1],
1825 )
1826 .unwrap();
1827 let err = read_head(&mkit).unwrap_err();
1828 assert!(matches!(err, RefError::InvalidHead));
1829 }
1830
1831 #[test]
1832 fn nonexistent_branch_returns_none() {
1833 let (_dir, mkit) = fresh_repo();
1834 assert_eq!(read_ref(&mkit, "nonexistent").unwrap(), None);
1835 }
1836
1837 #[test]
1838 fn read_ref_rejects_oversize_file() {
1839 let (_dir, mkit) = fresh_repo();
1841 let path = ref_path(mkit.common_dir(), HEADS_DIR, "main");
1842 fs::create_dir_all(path.parent().unwrap()).unwrap();
1843 fs::write(
1844 &path,
1845 vec![b'0'; usize::try_from(REF_FILE_MAX_BYTES).unwrap() + 1],
1846 )
1847 .unwrap();
1848 let err = read_ref(&mkit, "main").unwrap_err();
1849 assert!(matches!(err, RefError::InvalidRef(_)));
1850 }
1851
1852 #[test]
1853 fn list_refs_empty() {
1854 let (_dir, mkit) = fresh_repo();
1855 let refs = list_refs(&mkit).unwrap();
1856 assert!(refs.is_empty());
1857 }
1858
1859 #[test]
1860 fn list_refs_sorted() {
1861 let (_dir, mkit) = fresh_repo();
1862 write_ref(&mkit, "main", &h("m")).unwrap();
1863 write_ref(&mkit, "dev", &h("d")).unwrap();
1864 let refs = list_refs(&mkit).unwrap();
1865 assert_eq!(refs.len(), 2);
1866 assert_eq!(refs[0].name, "dev");
1867 assert_eq!(refs[1].name, "main");
1868 }
1869
1870 #[test]
1871 fn nested_refs_listed_recursively() {
1872 let (_dir, mkit) = fresh_repo();
1873 write_ref(&mkit, "feature/deep/topic", &h("nested")).unwrap();
1874 let refs = list_refs(&mkit).unwrap();
1875 assert_eq!(refs.len(), 1);
1876 assert_eq!(refs[0].name, "feature/deep/topic");
1877 }
1878
1879 #[test]
1880 fn list_refs_silently_skips_entries_beyond_max_depth() {
1881 let (_dir, mkit) = fresh_repo();
1885 let deep_name = (0..40)
1886 .map(|i| format!("d{i}"))
1887 .collect::<Vec<_>>()
1888 .join("/");
1889 write_ref(&mkit, &deep_name, &h("deep")).unwrap();
1890 write_ref(&mkit, "main", &h("shallow")).unwrap();
1891
1892 let refs = list_refs(&mkit).unwrap();
1893 let names: Vec<&str> = refs.iter().map(|r| r.name.as_str()).collect();
1894 assert!(names.contains(&"main"), "shallow ref must still be listed");
1895 assert!(
1896 !names.contains(&deep_name.as_str()),
1897 "a ref nested beyond MAX_REF_DEPTH must be silently skipped, got {names:?}"
1898 );
1899 }
1900
1901 #[test]
1907 fn list_refs_with_sequential_batch_matches_list_refs() {
1908 let (_dir, mkit) = fresh_repo();
1909 write_ref(&mkit, "main", &h("m")).unwrap();
1910 write_ref(&mkit, "dev", &h("d")).unwrap();
1911 write_ref(&mkit, "feature/deep/topic", &h("nested")).unwrap();
1912
1913 let via_list_refs = list_refs(&mkit).unwrap();
1914 let via_with = list_refs_with(&mkit, sequential_read_batch).unwrap();
1915 assert_eq!(via_list_refs, via_with);
1916 }
1917
1918 #[test]
1926 fn list_refs_with_distinguishes_unreadable_from_malformed() {
1927 let (_dir, mkit) = fresh_repo();
1928 write_ref(&mkit, "ok", &h("ok")).unwrap();
1929 write_ref(&mkit, "will-be-dropped", &h("d")).unwrap();
1930 write_ref(&mkit, "will-be-malformed", &h("m")).unwrap();
1931
1932 let refs = list_refs_with(&mkit, |candidates| {
1933 candidates
1934 .iter()
1935 .map(|c| {
1936 if c.name == "will-be-dropped" {
1937 RefReadOutcome::Unreadable
1938 } else if c.name == "will-be-malformed" {
1939 RefReadOutcome::Decoded(None)
1940 } else {
1941 sequential_read_batch(std::slice::from_ref(c))
1942 .into_iter()
1943 .next()
1944 .unwrap()
1945 }
1946 })
1947 .collect()
1948 })
1949 .unwrap();
1950
1951 let names: Vec<&str> = refs.iter().map(|r| r.name.as_str()).collect();
1952 assert!(
1953 !names.contains(&"will-be-dropped"),
1954 "Unreadable must drop the entry entirely, got {names:?}"
1955 );
1956 let malformed = refs
1957 .iter()
1958 .find(|r| r.name == "will-be-malformed")
1959 .expect("Decoded(None) must still be listed");
1960 assert_eq!(malformed.hash, None);
1961 let ok = refs.iter().find(|r| r.name == "ok").unwrap();
1962 assert_eq!(ok.hash, Some(h("ok")));
1963 }
1964
1965 #[test]
1970 fn list_refs_with_rejects_mismatched_batch_length() {
1971 let (_dir, mkit) = fresh_repo();
1972 write_ref(&mkit, "main", &h("m")).unwrap();
1973 write_ref(&mkit, "dev", &h("d")).unwrap();
1974
1975 let err =
1976 list_refs_with(&mkit, |_candidates| vec![RefReadOutcome::Decoded(None)]).unwrap_err();
1977 assert!(matches!(
1978 err,
1979 RefError::RefBatchLengthMismatch {
1980 expected: 2,
1981 actual: 1
1982 }
1983 ));
1984 }
1985
1986 #[test]
1987 fn delete_ref_basic() {
1988 let (_dir, mkit) = fresh_repo();
1989 write_ref(&mkit, "feature", &h("f")).unwrap();
1990 delete_ref(&mkit, "feature").unwrap();
1991 assert_eq!(read_ref(&mkit, "feature").unwrap(), None);
1992 }
1993
1994 #[test]
1995 fn delete_nonexistent_ref_errors() {
1996 let (_dir, mkit) = fresh_repo();
1997 let err = delete_ref(&mkit, "nope").unwrap_err();
1998 assert!(matches!(err, RefError::NotFound(_)));
1999 }
2000
2001 #[test]
2002 fn refuse_delete_current_branch() {
2003 let (_dir, mkit) = fresh_repo();
2004 write_ref(&mkit, "main", &h("m")).unwrap();
2005 let err = delete_ref_safe(&mkit, "main").unwrap_err();
2006 assert!(matches!(err, RefError::CurrentBranch(_)));
2007 }
2008
2009 #[test]
2012 fn cas_any_clobbers() {
2013 let (_dir, mkit) = fresh_repo();
2014 update_ref(&mkit, "main", RefWriteCondition::Any, &h("a")).unwrap();
2015 update_ref(&mkit, "main", RefWriteCondition::Any, &h("b")).unwrap();
2016 assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("b")));
2017 }
2018
2019 #[test]
2020 fn cas_missing_succeeds_when_absent() {
2021 let (_dir, mkit) = fresh_repo();
2022 update_ref(&mkit, "main", RefWriteCondition::Missing, &h("a")).unwrap();
2023 assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("a")));
2024 }
2025
2026 #[test]
2027 fn cas_missing_fails_when_present() {
2028 let (_dir, mkit) = fresh_repo();
2029 write_ref(&mkit, "main", &h("a")).unwrap();
2030 let err = update_ref(&mkit, "main", RefWriteCondition::Missing, &h("b")).unwrap_err();
2031 assert!(matches!(err, RefError::Conflict(_)));
2032 }
2033
2034 #[test]
2035 fn cas_match_succeeds_on_correct_hash() {
2036 let (_dir, mkit) = fresh_repo();
2037 write_ref(&mkit, "main", &h("a")).unwrap();
2038 update_ref(&mkit, "main", RefWriteCondition::Match(h("a")), &h("b")).unwrap();
2039 assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("b")));
2040 }
2041
2042 #[test]
2043 fn cas_match_fails_on_wrong_hash() {
2044 let (_dir, mkit) = fresh_repo();
2045 write_ref(&mkit, "main", &h("a")).unwrap();
2046 let err = update_ref(&mkit, "main", RefWriteCondition::Match(h("z")), &h("b")).unwrap_err();
2047 assert!(matches!(err, RefError::Conflict(_)));
2048 }
2049
2050 #[test]
2051 fn cas_match_fails_on_missing_ref() {
2052 let (_dir, mkit) = fresh_repo();
2053 let err = update_ref(&mkit, "main", RefWriteCondition::Match(h("a")), &h("b")).unwrap_err();
2054 assert!(matches!(err, RefError::Conflict(_)));
2055 }
2056
2057 #[test]
2089 fn cas_match_race_never_loses_an_update_across_uncoordinated_callers() {
2090 let (_dir, layout_a) = fresh_repo();
2091 let layout_b = RepoLayout::linked(
2092 layout_a.worktree_root().join("other-worktree"),
2093 layout_a.common_dir().join("worktrees").join("other"),
2094 layout_a.common_dir(),
2095 );
2096
2097 let base = h("base");
2098 write_ref(&layout_a, "main", &base).unwrap();
2099
2100 let iterations: usize = 500;
2101 let mut double_success_iteration = None;
2102
2103 for i in 0..iterations {
2104 update_ref(&layout_a, "main", RefWriteCondition::Any, &base).unwrap();
2108
2109 let val_a = h(&format!("race-a-{i}"));
2110 let val_b = h(&format!("race-b-{i}"));
2111 let barrier = Barrier::new(2);
2112
2113 let (result_a, result_b) = std::thread::scope(|scope| {
2114 let handle_a = scope.spawn(|| {
2115 barrier.wait();
2116 update_ref(&layout_a, "main", RefWriteCondition::Match(base), &val_a)
2117 });
2118 let handle_b = scope.spawn(|| {
2119 barrier.wait();
2120 update_ref(&layout_b, "main", RefWriteCondition::Match(base), &val_b)
2121 });
2122 (handle_a.join().unwrap(), handle_b.join().unwrap())
2123 });
2124
2125 if result_a.is_ok() && result_b.is_ok() {
2126 double_success_iteration = Some(i);
2127 break;
2128 }
2129 }
2130
2131 assert!(
2132 double_success_iteration.is_none(),
2133 "both uncoordinated Match CAS callers reported success on iteration \
2134 {double_success_iteration:?} — an update was silently lost \
2135 (INV-15/INV-6 violation)"
2136 );
2137 }
2138
2139 #[test]
2140 fn valid_long_ref_names_support_every_mutation() {
2141 let (_dir, layout) = fresh_repo();
2142 for name in [
2143 "_".repeat(80),
2144 format!("{}/{}", "a".repeat(130), "b".repeat(130)),
2145 ] {
2146 assert!(validate_ref_name(&name));
2147 update_ref(&layout, &name, RefWriteCondition::Missing, &h("base")).unwrap();
2148 assert!(matches!(
2149 update_ref(&layout, &name, RefWriteCondition::Missing, &h("other")),
2150 Err(RefError::Conflict(_))
2151 ));
2152 update_ref(
2153 &layout,
2154 &name,
2155 RefWriteCondition::Match(h("base")),
2156 &h("next"),
2157 )
2158 .unwrap();
2159 assert!(matches!(
2160 delete_ref_if_matches(&layout, &name, h("base")),
2161 Err(RefError::Conflict(_))
2162 ));
2163 assert_eq!(read_ref(&layout, &name).unwrap(), Some(h("next")));
2164 delete_ref_if_matches(&layout, &name, h("next")).unwrap();
2165 write_ref(&layout, &name, &h("base")).unwrap();
2166 delete_ref(&layout, &name).unwrap();
2167
2168 update_tag(&layout, &name, RefWriteCondition::Missing, &h("base")).unwrap();
2169 update_tag(
2170 &layout,
2171 &name,
2172 RefWriteCondition::Match(h("base")),
2173 &h("next"),
2174 )
2175 .unwrap();
2176 write_tag(&layout, &name, &h("base")).unwrap();
2177 delete_tag(&layout, &name).unwrap();
2178
2179 write_remote_ref(&layout, "default", &name, &h("base")).unwrap();
2180 let mut batch = RemoteRefBatch::new(&layout, "default").unwrap();
2181 batch.write(&name, &h("next")).unwrap();
2182 batch.commit().unwrap();
2183 assert_eq!(
2184 read_remote_ref(&layout, "default", &name).unwrap(),
2185 Some(h("next"))
2186 );
2187 delete_remote_ref(&layout, "default", &name).unwrap();
2188
2189 #[cfg(feature = "history-mmr")]
2190 let _guards = acquire_history_mutation(&layout, &name).unwrap();
2191 }
2192 }
2193
2194 #[test]
2195 fn ref_lock_keys_preserve_full_identity_without_filename_overflow() {
2196 let (_dir, layout) = fresh_repo();
2197 let names = ["_".repeat(80), "_".repeat(79), "a/b".into(), "a_2fb".into()];
2198 let mut keys = BTreeSet::new();
2199 for namespace in [HEADS_DIR, TAGS_DIR, "refs/remotes/default"] {
2200 for name in &names {
2201 let path = ref_path(layout.common_dir(), namespace, name);
2202 let key = cas_lock_name(layout.common_dir(), &path);
2203 assert!(key.len() <= 255);
2204 assert_eq!(key, cas_lock_name(layout.common_dir(), &path));
2205 assert!(
2206 keys.insert(key),
2207 "different full refs must have different keys"
2208 );
2209 }
2210 }
2211 #[cfg(feature = "history-mmr")]
2212 for name in &names {
2213 let key = history_lock_name(name);
2214 assert!(key.len() <= 255);
2215 assert!(
2216 keys.insert(key),
2217 "history and mutation guards must be distinct"
2218 );
2219 }
2220 }
2221
2222 #[test]
2225 fn every_ref_mutation_waits_for_the_cas_guard() {
2226 use std::sync::mpsc;
2227 use std::time::Duration;
2228 for operation in 0..10 {
2229 let (_dir, layout) = fresh_repo();
2230 let branch = "mutation-guard";
2231 let sub_dir = match operation {
2232 5 | 6 => TAGS_DIR.to_string(),
2233 7..=9 => remote_ref_dir("default"),
2234 _ => HEADS_DIR.to_string(),
2235 };
2236 let path = ref_path(layout.common_dir(), &sub_dir, branch);
2237 cas_write(
2238 layout.common_dir(),
2239 &path,
2240 &encode_ref_wire(&h("base")),
2241 branch,
2242 RefWriteCondition::Any,
2243 )
2244 .unwrap();
2245 let lock = crate::repo_lock::acquire_default(
2246 layout.common_dir(),
2247 &cas_lock_name(layout.common_dir(), &path),
2248 )
2249 .unwrap();
2250 let linked = RepoLayout::linked(
2251 layout.worktree_root().join("linked"),
2252 layout.common_dir().join("worktrees/linked"),
2253 layout.common_dir(),
2254 );
2255 let (done_tx, done_rx) = mpsc::channel();
2256 let worker = std::thread::spawn(move || {
2257 let result = match operation {
2258 0 => update_ref(&linked, branch, RefWriteCondition::Any, &h("next")),
2259 1 => update_ref(&linked, branch, RefWriteCondition::Missing, &h("next")),
2260 2 => delete_ref(&linked, branch),
2261 3 => delete_ref_if_matches(&linked, branch, h("base")),
2262 4 => update_ref(
2263 &linked,
2264 branch,
2265 RefWriteCondition::Match(h("base")),
2266 &h("next"),
2267 ),
2268 5 => write_tag(&linked, branch, &h("next")),
2269 6 => delete_tag(&linked, branch),
2270 7 => write_remote_ref(&linked, "default", branch, &h("next")),
2271 8 => delete_remote_ref(&linked, "default", branch),
2272 _ => {
2273 let mut batch = RemoteRefBatch::new(&linked, "default").unwrap();
2274 batch
2275 .write(branch, &h("next"))
2276 .and_then(|()| batch.commit())
2277 }
2278 };
2279 done_tx.send(result).unwrap();
2280 });
2281 let premature = done_rx.recv_timeout(Duration::from_millis(100));
2282 let observed = fs::read(&path).ok().and_then(|b| decode_ref_wire(&b));
2283 drop(lock);
2284 worker.join().unwrap();
2285 assert!(
2286 matches!(premature, Err(mpsc::RecvTimeoutError::Timeout)),
2287 "operation {operation} bypassed the held CAS guard: {premature:?}"
2288 );
2289 assert_eq!(
2290 observed,
2291 Some(h("base")),
2292 "mutation must not precede guard acquisition"
2293 );
2294 let result = done_rx.recv_timeout(Duration::from_secs(2)).unwrap();
2295 assert_eq!(result.is_ok(), operation != 1);
2296 }
2297 }
2298
2299 #[test]
2300 fn remote_batches_with_reversed_ref_order_finish_without_nested_ref_locks() {
2301 let (_dir, layout) = fresh_repo();
2302 let barrier = Barrier::new(2);
2303 std::thread::scope(|scope| {
2304 let a = scope.spawn(|| {
2305 barrier.wait();
2306 let mut batch = RemoteRefBatch::new(&layout, "default").unwrap();
2307 for branch in ["z", "a"] {
2308 batch.write(branch, &h("a")).unwrap();
2309 }
2310 batch.commit().unwrap();
2311 });
2312 let b = scope.spawn(|| {
2313 barrier.wait();
2314 let mut batch = RemoteRefBatch::new(&layout, "default").unwrap();
2315 for branch in ["a", "z"] {
2316 batch.write(branch, &h("b")).unwrap();
2317 }
2318 batch.commit().unwrap();
2319 });
2320 a.join().unwrap();
2321 b.join().unwrap();
2322 });
2323 for branch in ["a", "z"] {
2324 let value = read_remote_ref(&layout, "default", branch)
2325 .unwrap()
2326 .unwrap();
2327 assert!(value == h("a") || value == h("b"));
2328 }
2329 }
2330
2331 #[test]
2338 fn cas_match_succeeds_repeatedly_when_uncontended() {
2339 let (_dir, mkit) = fresh_repo();
2340 let mut current = h("seed");
2341 write_ref(&mkit, "main", ¤t).unwrap();
2342
2343 for i in 0..20 {
2344 let next = h(&format!("v{i}"));
2345 update_ref(&mkit, "main", RefWriteCondition::Match(current), &next).unwrap();
2346 assert_eq!(read_ref(&mkit, "main").unwrap(), Some(next));
2347 current = next;
2348 }
2349 }
2350
2351 #[test]
2368 fn cas_delete_refuses_when_ref_moved_after_read() {
2369 let (_dir, mkit) = fresh_repo();
2370 let t = h("t");
2371 let c = h("c");
2372 write_ref(&mkit, "main", &t).unwrap();
2373
2374 let read_t = read_ref(&mkit, "main").unwrap().unwrap();
2376 assert_eq!(read_t, t);
2377
2378 update_ref(&mkit, "main", RefWriteCondition::Match(t), &c).unwrap();
2381
2382 let err = delete_ref_if_matches(&mkit, "main", read_t).unwrap_err();
2383 assert!(
2384 matches!(err, RefError::Conflict(_)),
2385 "expected Conflict, got {err:?}"
2386 );
2387 assert_eq!(
2388 read_ref(&mkit, "main").unwrap(),
2389 Some(c),
2390 "the concurrently-landed commit must survive the refused delete untouched"
2391 );
2392 }
2393
2394 #[test]
2398 fn cas_delete_refusal_leaves_ref_deletable_against_its_new_value() {
2399 let (_dir, mkit) = fresh_repo();
2400 let t = h("t");
2401 let c = h("c");
2402 write_ref(&mkit, "main", &t).unwrap();
2403 update_ref(&mkit, "main", RefWriteCondition::Match(t), &c).unwrap();
2404
2405 assert!(matches!(
2406 delete_ref_if_matches(&mkit, "main", t).unwrap_err(),
2407 RefError::Conflict(_)
2408 ));
2409 delete_ref_if_matches(&mkit, "main", c).unwrap();
2410 assert_eq!(read_ref(&mkit, "main").unwrap(), None);
2411 }
2412
2413 #[test]
2414 fn cas_delete_fails_on_missing_ref() {
2415 let (_dir, mkit) = fresh_repo();
2416 let err = delete_ref_if_matches(&mkit, "main", h("anything")).unwrap_err();
2417 assert!(matches!(err, RefError::Conflict(_)));
2418 }
2419
2420 #[test]
2432 fn cas_delete_vs_match_advance_race_never_lets_both_win_or_loses_the_advance() {
2433 let (_dir, mkit) = fresh_repo();
2434 let iterations: usize = 500;
2435 let mut bad_iteration: Option<(usize, &'static str)> = None;
2436
2437 for i in 0..iterations {
2438 let base = h(&format!("base-{i}"));
2439 write_ref(&mkit, "main", &base).unwrap();
2440 let new_tip = h(&format!("advanced-{i}"));
2441 let barrier = Barrier::new(2);
2442
2443 let (advance_result, delete_result) = std::thread::scope(|scope| {
2444 let advance_handle = scope.spawn(|| {
2445 barrier.wait();
2446 update_ref(&mkit, "main", RefWriteCondition::Match(base), &new_tip)
2447 });
2448 let delete_handle = scope.spawn(|| {
2449 barrier.wait();
2450 delete_ref_if_matches(&mkit, "main", base)
2451 });
2452 (
2453 advance_handle.join().unwrap(),
2454 delete_handle.join().unwrap(),
2455 )
2456 });
2457
2458 match (&advance_result, &delete_result) {
2459 (Ok(()), Ok(())) => {
2460 bad_iteration = Some((
2461 i,
2462 "both the concurrent advance and the concurrent delete reported success",
2463 ));
2464 }
2465 (Ok(()), Err(RefError::Conflict(_))) => {
2466 if read_ref(&mkit, "main").unwrap() != Some(new_tip) {
2468 bad_iteration = Some((
2469 i,
2470 "advance reported success but its value is not on disk — lost update",
2471 ));
2472 }
2473 }
2474 (Err(RefError::Conflict(_)), Ok(())) => {
2475 if read_ref(&mkit, "main").unwrap().is_some() {
2480 bad_iteration = Some((
2481 i,
2482 "delete reported success but the ref is still present on disk",
2483 ));
2484 }
2485 }
2486 (Err(RefError::Conflict(_)), Err(RefError::Conflict(_))) => {
2487 bad_iteration = Some((
2492 i,
2493 "both the advance and the delete reported Conflict against a freshly-written base",
2494 ));
2495 }
2496 _ => {
2497 bad_iteration = Some((i, "unexpected error variant"));
2498 }
2499 }
2500
2501 if bad_iteration.is_some() {
2502 break;
2503 }
2504 }
2505
2506 assert!(
2507 bad_iteration.is_none(),
2508 "iteration {bad_iteration:?}: a concurrent Match-conditioned advance and a \
2509 CAS-guarded delete on the same ref did not serialize correctly (issue #658)"
2510 );
2511 }
2512
2513 #[test]
2520 fn cas_match_does_not_contend_across_different_refs() {
2521 let (_dir, mkit) = fresh_repo();
2522 write_ref(&mkit, "main", &h("m0")).unwrap();
2523 write_ref(&mkit, "other", &h("o0")).unwrap();
2524
2525 let other_path = ref_path(mkit.common_dir(), HEADS_DIR, "other");
2526 let other_lock_name = cas_lock_name(mkit.common_dir(), &other_path);
2527 let common_dir = mkit.common_dir().to_path_buf();
2528 let (holding_tx, holding_rx) = std::sync::mpsc::channel();
2529 let (release_tx, release_rx) = std::sync::mpsc::channel();
2530 let holder = std::thread::spawn(move || {
2531 let _lock = crate::repo_lock::acquire_default(&common_dir, &other_lock_name).unwrap();
2532 holding_tx.send(()).unwrap();
2533 release_rx.recv().unwrap();
2534 });
2535 holding_rx.recv().unwrap();
2536
2537 let start = std::time::Instant::now();
2538 update_ref(&mkit, "main", RefWriteCondition::Match(h("m0")), &h("m1")).unwrap();
2539 let elapsed = start.elapsed();
2540
2541 release_tx.send(()).unwrap();
2542 holder.join().unwrap();
2543
2544 assert!(
2545 elapsed < std::time::Duration::from_secs(1),
2546 "Match CAS on \"main\" took {elapsed:?} while \"other\"'s ref lock was held \
2547 elsewhere — refs are contending when they shouldn't be"
2548 );
2549 assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("m1")));
2550 }
2551
2552 #[test]
2555 fn write_rejects_invalid_branch_name() {
2556 let (_dir, mkit) = fresh_repo();
2557 let err = write_ref(&mkit, "../escape", &h("x")).unwrap_err();
2558 assert!(matches!(err, RefError::InvalidRefName(_)));
2559 let err = write_head_branch(&mkit, "bad//branch").unwrap_err();
2560 assert!(matches!(err, RefError::InvalidRefName(_)));
2561 }
2562
2563 #[test]
2566 fn write_and_read_tag() {
2567 let (_dir, mkit) = fresh_repo();
2568 let commit = h("v1.0");
2569 write_tag(&mkit, "v1.0", &commit).unwrap();
2570 assert_eq!(read_tag(&mkit, "v1.0").unwrap(), Some(commit));
2571 }
2572
2573 #[test]
2574 fn list_tags_sorted() {
2575 let (_dir, mkit) = fresh_repo();
2576 write_tag(&mkit, "v2.0", &h("v2")).unwrap();
2577 write_tag(&mkit, "v1.0", &h("v1")).unwrap();
2578 write_tag(&mkit, "alpha", &h("a")).unwrap();
2579 let tags = list_tags(&mkit).unwrap();
2580 assert_eq!(
2581 tags.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
2582 vec!["alpha", "v1.0", "v2.0"]
2583 );
2584 }
2585
2586 #[test]
2587 fn tag_and_branch_same_name_independent() {
2588 let (_dir, mkit) = fresh_repo();
2589 let tag = h("tag");
2590 let branch = h("branch");
2591 write_tag(&mkit, "main", &tag).unwrap();
2592 write_ref(&mkit, "main", &branch).unwrap();
2593 assert_eq!(read_tag(&mkit, "main").unwrap(), Some(tag));
2594 assert_eq!(read_ref(&mkit, "main").unwrap(), Some(branch));
2595 }
2596
2597 #[test]
2598 fn delete_tag_basic() {
2599 let (_dir, mkit) = fresh_repo();
2600 write_tag(&mkit, "release", &h("r")).unwrap();
2601 delete_tag(&mkit, "release").unwrap();
2602 assert_eq!(read_tag(&mkit, "release").unwrap(), None);
2603 }
2604
2605 #[test]
2606 fn delete_nonexistent_tag_errors() {
2607 let (_dir, mkit) = fresh_repo();
2608 let err = delete_tag(&mkit, "missing").unwrap_err();
2609 assert!(matches!(err, RefError::NotFound(_)));
2610 }
2611
2612 #[test]
2615 fn load_shallow_returns_none_when_missing() {
2616 let (_dir, mkit) = fresh_repo();
2617 assert_eq!(load_shallow_boundaries(&mkit).unwrap(), None);
2618 }
2619
2620 #[test]
2621 fn write_and_load_shallow_round_trip() {
2622 let (_dir, mkit) = fresh_repo();
2623 let bs = vec![h("b1"), h("b2"), h("b3")];
2624 write_shallow_boundaries(&mkit, &bs).unwrap();
2625 let loaded = load_shallow_boundaries(&mkit).unwrap().unwrap();
2626 assert_eq!(loaded.len(), 3);
2627 for b in &bs {
2628 assert!(loaded.contains(b));
2629 }
2630 }
2631
2632 #[test]
2633 fn write_empty_shallow_removes_file() {
2634 let (_dir, mkit) = fresh_repo();
2635 write_shallow_boundaries(&mkit, &[h("x")]).unwrap();
2636 assert!(load_shallow_boundaries(&mkit).unwrap().is_some());
2637 write_shallow_boundaries(&mkit, &[]).unwrap();
2638 assert_eq!(load_shallow_boundaries(&mkit).unwrap(), None);
2639 }
2640
2641 #[test]
2642 fn load_shallow_rejects_oversize_file() {
2643 let (_dir, mkit) = fresh_repo();
2645 let path = mkit.shallow_file();
2646 fs::write(
2647 &path,
2648 vec![b'a'; usize::try_from(SHALLOW_MAX_BYTES).unwrap() + 1],
2649 )
2650 .unwrap();
2651 let err = load_shallow_boundaries(&mkit).unwrap_err();
2652 assert!(matches!(err, RefError::InvalidRef(_)));
2653 }
2654
2655 #[test]
2656 fn load_shallow_skips_invalid_lines() {
2657 let (_dir, mkit) = fresh_repo();
2658 let path = mkit.shallow_file();
2659 let valid = h("ok");
2660 let valid_hex = to_hex(&valid);
2661 let mut content = String::new();
2662 content.push_str("short\n");
2663 content.push_str(&valid_hex);
2664 content.push('\n');
2665 content.push_str(&"z".repeat(64));
2666 content.push('\n');
2667 std::fs::write(&path, content).unwrap();
2668 let loaded = load_shallow_boundaries(&mkit).unwrap().unwrap();
2669 assert_eq!(loaded.len(), 1);
2670 assert_eq!(loaded[0], valid);
2671 }
2672
2673 #[test]
2693 fn write_remote_ref_loop_pays_one_dir_sync_per_ref_today() {
2694 let (_dir, mkit) = fresh_repo();
2695 let n: u64 = 25;
2696 crate::atomic::testing::reset_dir_sync_calls();
2697 for i in 0..n {
2698 write_remote_ref(
2699 &mkit,
2700 "origin",
2701 &format!("branch-{i}"),
2702 &h(&format!("c{i}")),
2703 )
2704 .unwrap();
2705 }
2706 let calls = crate::atomic::testing::dir_sync_calls();
2707 assert_eq!(
2708 calls, n,
2709 "the current per-ref write path must cost exactly one directory \
2710 fsync per ref (O(N)); got {calls} for {n} refs"
2711 );
2712 }
2713
2714 #[test]
2719 fn remote_ref_batch_pays_one_dir_sync_for_many_refs() {
2720 let (_dir, mkit) = fresh_repo();
2721 let n = 25;
2722 let entries: Vec<(String, Hash)> = (0..n)
2723 .map(|i| (format!("branch-{i}"), h(&format!("c{i}"))))
2724 .collect();
2725
2726 crate::atomic::testing::reset_dir_sync_calls();
2727 let mut batch = RemoteRefBatch::new(&mkit, "origin").unwrap();
2728 for (branch, hash) in &entries {
2729 batch.write(branch, hash).unwrap();
2730 }
2731 batch.commit().unwrap();
2732
2733 let calls = crate::atomic::testing::dir_sync_calls();
2734 assert_eq!(
2735 calls, 1,
2736 "batching {n} tracking-ref writes into one flat remote \
2737 namespace must cost exactly one directory fsync (O(1)), got {calls}"
2738 );
2739 }
2740
2741 #[test]
2745 fn remote_ref_batch_matches_per_ref_loop_final_state() {
2746 let (_dir, old_path) = fresh_repo();
2747 let (_dir2, new_path) = fresh_repo();
2748 let n = 12;
2749 let entries: Vec<(String, Hash)> = (0..n)
2750 .map(|i| (format!("team/branch-{i}"), h(&format!("state{i}"))))
2751 .collect();
2752
2753 for (branch, hash) in &entries {
2754 write_remote_ref(&old_path, "origin", branch, hash).unwrap();
2755 }
2756
2757 let mut batch = RemoteRefBatch::new(&new_path, "origin").unwrap();
2758 for (branch, hash) in &entries {
2759 batch.write(branch, hash).unwrap();
2760 }
2761 batch.commit().unwrap();
2762
2763 for (branch, hash) in &entries {
2764 let old_val = read_remote_ref(&old_path, "origin", branch).unwrap();
2765 let new_val = read_remote_ref(&new_path, "origin", branch).unwrap();
2766 assert_eq!(old_val, Some(*hash));
2767 assert_eq!(new_val, Some(*hash));
2768 assert_eq!(old_val, new_val, "branch {branch} diverged");
2769 }
2770 }
2771
2772 #[test]
2781 fn remote_ref_batch_partial_failure_keeps_already_written_refs_visible() {
2782 let (_dir, mkit) = fresh_repo();
2783 let mut batch = RemoteRefBatch::new(&mkit, "origin").unwrap();
2784 batch.write("good-1", &h("g1")).unwrap();
2785 batch.write("good-2", &h("g2")).unwrap();
2786 let err = batch.write("bad//name", &h("x")).unwrap_err();
2787 assert!(matches!(err, RefError::InvalidRefName(_)));
2788
2789 batch.commit().unwrap();
2792 assert_eq!(
2793 read_remote_ref(&mkit, "origin", "good-1").unwrap(),
2794 Some(h("g1"))
2795 );
2796 assert_eq!(
2797 read_remote_ref(&mkit, "origin", "good-2").unwrap(),
2798 Some(h("g2"))
2799 );
2800 let never_written = read_remote_ref(&mkit, "origin", "bad//name").unwrap_err();
2803 assert!(matches!(never_written, RefError::InvalidRefName(_)));
2804 }
2805
2806 #[test]
2807 fn remote_ref_batch_rejects_invalid_remote_name() {
2808 let (_dir, mkit) = fresh_repo();
2809 let err = RemoteRefBatch::new(&mkit, "../escape").unwrap_err();
2810 assert!(matches!(err, RefError::InvalidRefName(_)));
2811 }
2812
2813 #[test]
2814 fn remote_ref_batch_of_zero_entries_is_a_noop_commit() {
2815 let (_dir, mkit) = fresh_repo();
2816 crate::atomic::testing::reset_dir_sync_calls();
2817 let batch = RemoteRefBatch::new(&mkit, "origin").unwrap();
2818 batch.commit().unwrap();
2819 assert_eq!(
2820 crate::atomic::testing::dir_sync_calls(),
2821 0,
2822 "an empty batch must not touch any directory"
2823 );
2824 }
2825}