Skip to main content

mkit_core/
refs.rs

1//! Refs subsystem.
2//!
3//! Implements the local-disk side of `docs/specs/SPEC-REFS.md`: ref names,
4//! the 65-byte wire encoding, the symbolic-or-detached `HEAD` file, and
5//! shallow-boundary persistence at `.mkit/shallow`.
6//!
7//! Wire format (per SPEC-REFS §1): exactly 64 lowercase-hex characters
8//! plus a trailing `0x0A` newline = 65 bytes. Uppercase hex is rejected
9//! on read. Trailing `\r` and ASCII whitespace are tolerated when
10//! parsing local files (so a Windows-edited HEAD does not brick a
11//! repo), but fresh writes always emit the strict 65-byte form.
12//!
13//! Ref name grammar (SPEC-REFS §3): `[A-Za-z0-9._-]+` segments joined
14//! by `/`, with no leading/trailing `/`, no `.`/`..` segments, no
15//! backslashes, no NULs. In addition, no segment may end in `.lock`
16//! (the canonical lock-file suffix) and the final segment may not be
17//! the literal `HEAD` (which would shadow the repo-level HEAD
18//! pointer). We share the validator with the future transport layer
19//! via [`validate_ref_name`] / [`validate_ref_prefix`].
20//!
21//! CAS variants for [`update_ref`] follow SPEC-REFS §5: `Any` (clobber),
22//! `Missing` (fail if it exists), `Match(H)` (fail if current value !=
23//! H). Every local-filesystem ref mutation (all three write conditions,
24//! conditional and unconditional deletes, tags, remote-tracking refs and
25//! [`RemoteRefBatch`] writes) runs its critical section under a per-ref
26//! `refs-<digest>.lock` in the common dir, where `<digest>` is the
27//! lowercase BLAKE3 hex of the ref's path relative to the common dir
28//! (`cas_lock_name`; SPEC-CONCURRENCY §2). It uses the same blocking
29//! kernel-lock primitive as `repo_lock` elsewhere, so a mutation is atomic
30//! across processes regardless of what other locks each caller holds, and
31//! mutations of different refs never contend. #637 introduced the lock as
32//! one repo-wide `refs.lock`; it is now keyed per ref. History-aware
33//! branch writers take `refs-history-<digest>.lock` first
34//! (SPEC-CONCURRENCY §4).
35
36use 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
45/// Subdirectory holding all refs (`.mkit/refs`).
46pub const REFS_DIR: &str = "refs";
47/// Subdirectory holding branch refs (`.mkit/refs/heads`).
48pub const HEADS_DIR: &str = "refs/heads";
49/// Subdirectory holding tag refs (`.mkit/refs/tags`).
50pub const TAGS_DIR: &str = "refs/tags";
51/// Subdirectory holding remote-tracking refs (`.mkit/refs/remotes`).
52pub const REMOTES_DIR: &str = "refs/remotes";
53/// HEAD file relative to the `.mkit` root.
54pub const HEAD_FILE: &str = "HEAD";
55/// Shallow-boundary file relative to the `.mkit` root.
56pub const SHALLOW_FILE: &str = "shallow";
57
58/// Symbolic-ref prefix written to `HEAD` when pointing at a branch.
59const HEAD_REF_PREFIX: &str = "ref: refs/heads/";
60
61/// Hard cap on how many bytes we are willing to read from `HEAD`.
62const HEAD_MAX_BYTES: u64 = 4 * 1024;
63/// Hard cap on how many bytes we are willing to read from a single ref
64/// file. The wire form is always 65 bytes; a few more is fine if the
65/// file picked up extra whitespace, but anything pathological is
66/// rejected.
67const REF_FILE_MAX_BYTES: u64 = 128;
68/// Hard cap on `.mkit/shallow` (1 MiB).
69const SHALLOW_MAX_BYTES: u64 = 1024 * 1024;
70
71/// Errors raised by this module.
72#[derive(Debug, thiserror::Error)]
73#[non_exhaustive]
74pub enum RefError {
75    /// `name` failed [`validate_ref_name_grammar`].
76    #[error("invalid ref name '{0}'")]
77    InvalidRefName(String),
78    /// A new ref's name is over its kind's bound: [`MAX_REF_NAME_BYTES`]
79    /// for a ref name, [`MAX_BRANCH_NAME_BYTES`] for a branch and
80    /// [`MAX_TAG_NAME_BYTES`] for a tag (SPEC-REFS §3).
81    #[error("{}", too_long_message(.name, *.len, *.kind))]
82    RefNameTooLong {
83        /// The name.
84        name: String,
85        /// Its length in bytes.
86        len: usize,
87        /// Which bound it broke.
88        kind: RefNameKind,
89    },
90    /// On-disk bytes were not a valid 65-byte ref wire (length wrong,
91    /// uppercase hex, non-hex byte, etc.).
92    #[error("invalid ref content for '{0}'")]
93    InvalidRef(String),
94    /// `HEAD` content was neither a valid symbolic ref nor a valid
95    /// detached hash.
96    #[error("HEAD is not a valid symbolic-ref or detached-hash file")]
97    InvalidHead,
98    /// `HEAD` is missing.
99    #[error("HEAD is not present")]
100    NoHead,
101    /// CAS condition failed: ref does not match expected state.
102    #[error("ref '{0}' did not satisfy CAS condition")]
103    Conflict(String),
104    /// Tried to delete a ref that does not exist.
105    #[error("ref '{0}' not found")]
106    NotFound(String),
107    /// Tried to delete the branch HEAD currently points to.
108    #[error("cannot delete the current branch '{0}'")]
109    CurrentBranch(String),
110    /// Underlying I/O failure.
111    #[error(transparent)]
112    Io(#[from] io::Error),
113    /// A [`list_refs_with`]-style batch callback returned a different
114    /// number of outcomes than candidates it was given — the caller's
115    /// contract violation, not a clean exit-coded error, but caught here
116    /// rather than silently desyncing names from hashes.
117    #[error("read_batch callback returned {actual} outcomes for a {expected}-candidate batch")]
118    RefBatchLengthMismatch { expected: usize, actual: usize },
119}
120
121/// Result alias used throughout this module.
122pub type RefResult<T> = Result<T, RefError>;
123
124/// CAS condition for [`update_ref`].
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum RefWriteCondition {
127    /// Unconditional write — clobbers any existing value.
128    Any,
129    /// Write only if the ref does not currently exist.
130    Missing,
131    /// Write only if the ref currently contains this exact hash.
132    Match(Hash),
133}
134
135/// HEAD pointer: either a symbolic reference to a branch name, or a
136/// detached hash.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum Head {
139    /// Symbolic — `HEAD` was `ref: refs/heads/<branch>\n`.
140    Branch(String),
141    /// Detached — `HEAD` was a bare 64-char hex hash.
142    Detached(Hash),
143}
144
145/// A listed ref entry: name (with `refs/heads/` or similar prefix
146/// stripped) plus the resolved hash, when readable.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct Ref {
149    /// Ref name relative to the namespace (e.g. `"main"`, `"v1.0"`).
150    pub name: String,
151    /// Resolved 32-byte hash, or `None` if the on-disk bytes were
152    /// malformed (the entry is then silently skipped by callers that
153    /// only care about valid refs).
154    pub hash: Option<Hash>,
155}
156
157/// Longest ref name, in bytes (SPEC-REFS §3). The bound keeps every ref
158/// inside a server's storage key; clients check it before sending
159/// (`mkit_rpc::MAX_REF_NAME`).
160pub const MAX_REF_NAME_BYTES: usize = 512;
161
162/// The wire name prefix of a branch head.
163pub const BRANCH_REF_PREFIX: &str = "refs/heads/";
164/// The wire name prefix of a branch's packmap ref, which a push writes
165/// next to its head (`refs/mkit/packmap/<branch>`).
166pub const PACKMAP_REF_PREFIX: &str = "refs/mkit/packmap/";
167/// The wire name prefix of a tag.
168pub const TAG_REF_PREFIX: &str = "refs/tags/";
169
170/// Longest new branch name, in bytes: a push writes both
171/// `refs/heads/<branch>` and `refs/mkit/packmap/<branch>`, and each must
172/// fit [`MAX_REF_NAME_BYTES`], so the longer prefix sets the bound (494).
173pub const MAX_BRANCH_NAME_BYTES: usize = MAX_REF_NAME_BYTES - PACKMAP_REF_PREFIX.len();
174/// Longest new tag name, in bytes: `refs/tags/<tag>` must fit
175/// [`MAX_REF_NAME_BYTES`] (502).
176pub 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/// Which name bound a [`RefError::RefNameTooLong`] broke.
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182#[non_exhaustive]
183pub enum RefNameKind {
184    /// A full ref name, or a remote-tracking or remote name:
185    /// [`MAX_REF_NAME_BYTES`].
186    Ref,
187    /// A local branch name: [`MAX_BRANCH_NAME_BYTES`].
188    Branch,
189    /// A local tag name: [`MAX_TAG_NAME_BYTES`].
190    Tag,
191}
192
193impl RefNameKind {
194    /// The bound, in bytes.
195    #[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    /// Where the bound comes from.
213    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
225/// The first 64 bytes of a name, for error messages about long names.
226fn 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
234/// `<kind> name too long (<len> bytes; at most <max><why>, SPEC-REFS §3):
235/// '<preview>'`.
236fn 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/// Validate a ref name per SPEC-REFS §3: [`validate_ref_name_grammar`]
247/// and at most [`MAX_REF_NAME_BYTES`] bytes. Used at every transport
248/// boundary and for every ref written; transports MUST NOT silently
249/// lower-case or canonicalise.
250#[must_use]
251pub fn validate_ref_name(name: &str) -> bool {
252    name.len() <= MAX_REF_NAME_BYTES && validate_ref_name_grammar(name)
253}
254
255/// Check the name of a ref about to be created or written: the
256/// SPEC-REFS §3 grammar and length bound.
257///
258/// # Errors
259/// [`RefError::RefNameTooLong`] for a grammatical name over
260/// [`MAX_REF_NAME_BYTES`], [`RefError::InvalidRefName`] otherwise.
261pub fn check_new_ref_name(name: &str) -> RefResult<()> {
262    check_new_name(name, RefNameKind::Ref)
263}
264
265/// Check a new name of `kind` against its grammar and bound.
266///
267/// # Errors
268/// As [`check_new_ref_name`], with `kind`'s bound.
269pub 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
283/// Check the wire names a push of `branch` writes, `refs/heads/<branch>`
284/// and `refs/mkit/packmap/<branch>`, against [`MAX_REF_NAME_BYTES`]. A
285/// local branch named before the branch bound existed can be longer than
286/// [`MAX_BRANCH_NAME_BYTES`] and still be used locally, but not pushed.
287///
288/// # Errors
289/// [`RefError::RefNameTooLong`] naming the longer wire name.
290pub 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
295/// Check a local branch or tag name about to be written under `sub_dir`.
296/// A name within `kind`'s bound always passes. A longer one passes only
297/// while the ref already exists and fits [`MAX_REF_NAME_BYTES`]: a ref
298/// created before the per-kind bound keeps working locally, while a new
299/// one is refused.
300fn 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
317/// Check the name of a ref that may already exist, for a read, a listing
318/// or a delete: the grammar only, so a local ref written before
319/// SPEC-REFS §3 bounded names stays visible, resolvable and deletable.
320fn 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/// The SPEC-REFS §3 grammar without its length bound: what a local ref
329/// that already exists must satisfy to be read, listed or deleted. New
330/// refs and every transport use [`validate_ref_name`].
331///
332/// Grammar:
333/// - Non-empty.
334/// - Segments split on `/`, each segment matches `[A-Za-z0-9._-]+`.
335/// - No segment may start with `.` (this also rejects the exact `.`
336///   and `..` segments) — git's `check-ref-format` rule, kept for
337///   parity: a dot-leading component is invalid in both grammars, so
338///   nothing on-disk under a dot-leading directory (e.g. crash debris
339///   from a temp-directory rename, or an in-flight `atomic::write_atomic`
340///   temp file) can ever surface as a ref in `collect_refs` / listings.
341///   No empty segments (no `//`, no leading `/`, no trailing `/`).
342/// - No `\\` or NUL bytes.
343/// - No segment may end in `.lock` (the canonical lock-file suffix).
344/// - The final segment may not be the literal `HEAD`, since that
345///   would shadow the repo-level `HEAD` pointer.
346#[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        // Reject the canonical lock-file suffix. Byte-level check so
363        // clippy's case-sensitive file-extension lint (which assumes a
364        // path extension) does not fire — ref segments are not paths
365        // and `.lock` is a spec-mandated exact-match suffix.
366        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/// Canonical per-branch lock name for ancestry publication and recovery.
388#[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
399/// Retention roots of unfinished history publications, including when the
400/// history-mmr feature is disabled. Corruption must abort GC.
401pub fn pending_history_roots(layout: &RepoLayout) -> RefResult<BTreeSet<Hash>> {
402    ancestry_state::pending_roots(layout.common_dir())
403}
404
405/// The `refs-<ref>.lock` filename every ref mutation
406/// acquires, keyed off `path` relative to `common_dir` (not just a
407/// bare name) so a branch and a tag/remote-ref sharing the same bare
408/// name get distinct locks. Named for the same reason as
409/// [`history_lock_name`]: a test that independently recomputes this
410/// formula would stop catching a regression the moment production's
411/// formula changed without the test's copy changing too.
412/// Hash the complete identity so nested or punctuation-heavy names cannot
413/// overflow a single lock filename. Namespace prefixes remain in the digest;
414/// history and mutation guards also have distinct filename prefixes.
415pub(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/// Validate a prefix passed to `list_refs`. An empty prefix is allowed.
426/// A single trailing `/` is allowed; otherwise the prefix must satisfy
427/// [`validate_ref_name`].
428#[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/// Encode `h` to its 65-byte wire form (lowercase hex + `\n`).
441#[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/// Decode a ref wire blob into a [`Hash`](tyalias@Hash). Tolerates a trailing
452/// newline / `\r` / ASCII whitespace (so files round-tripped through a
453/// Windows editor still parse), but rejects uppercase hex per
454/// SPEC-REFS §1.
455///
456/// Returns `None` for any malformed input; callers wrap the absent
457/// case into a domain-specific [`RefError::InvalidRef`].
458#[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/// Strict lowercase-only hex parser: exactly [`HEX_LEN`] lowercase-hex
469/// bytes, decoded in a single pass. SPEC-REFS §1 forbids uppercase on read;
470/// the general `hash::from_hex` tolerates both cases for programmatic
471/// callers, so this is the stricter variant every on-the-wire / on-disk
472/// reader (ref wire blobs, the applied-packs record) shares to keep a
473/// hand-edited or foreign-cased line malformed rather than silently accepted.
474#[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
496/// Initialise the ref layout: creates `refs/`, `refs/heads/`,
497/// `refs/tags/`, `refs/remotes/` under the common dir and writes a
498/// default `HEAD = ref: refs/heads/main\n` into the worktree state
499/// dir if `HEAD` does not already exist.
500pub 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
513// -----------------------------------------------------------------------------
514// HEAD
515// -----------------------------------------------------------------------------
516
517/// Read this worktree's `HEAD`.
518///
519/// # Errors
520/// - [`RefError::NoHead`] if the file is missing.
521/// - [`RefError::InvalidHead`] for malformed content.
522pub 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        // Grammar only: a branch named before SPEC-REFS §3 bounded names
537        // stays checked out; a write to it names the limit.
538        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
550/// Write `HEAD` as a symbolic ref pointing at `branch`.
551///
552/// # Errors
553/// - [`RefError::InvalidRefName`] if `branch` does not satisfy
554///   [`validate_ref_name`].
555/// - [`RefError::Io`] for filesystem failures.
556pub 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
563/// Write `HEAD` as a detached hash.
564///
565/// # Errors
566/// - [`RefError::Io`] for filesystem failures.
567pub 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
573/// Resolve `HEAD` to a commit hash. Returns `Ok(None)` when HEAD points
574/// at a branch that has no commit yet.
575pub 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
587/// Update the ref HEAD currently points at (or HEAD itself, if
588/// detached) to `commit_hash`.
589pub 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
597// -----------------------------------------------------------------------------
598// Branch refs (refs/heads/<name>)
599// -----------------------------------------------------------------------------
600
601/// Read the hash a branch ref points to. Returns `Ok(None)` if the ref
602/// file does not exist.
603///
604/// # Errors
605/// - [`RefError::InvalidRefName`] if `branch` does not validate.
606/// - [`RefError::InvalidRef`] if the on-disk bytes are not a valid wire.
607pub 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
612/// Write a branch ref (unconditional — equivalent to
613/// `update_ref(branch, RefWriteCondition::Any, h)`).
614pub fn write_ref(layout: &RepoLayout, branch: &str, h: &Hash) -> RefResult<()> {
615    update_ref(layout, branch, RefWriteCondition::Any, h)
616}
617
618/// CAS-aware ref write per SPEC-REFS §5.
619///
620/// # Errors
621/// - [`RefError::InvalidRefName`] if `branch` is not a valid name.
622/// - [`RefError::Conflict`] if `condition` is not satisfied.
623/// - [`RefError::Io`] for filesystem failures.
624pub 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// -----------------------------------------------------------------------------
637// History-coupled ref writes (feature: history-mmr)
638// -----------------------------------------------------------------------------
639
640/// Acquire the complete nested guard for a history transaction or snapshot.
641#[cfg(feature = "history-mmr")]
642pub(crate) fn acquire_history_mutation(
643    layout: &RepoLayout,
644    branch: &str,
645) -> RefResult<(crate::repo_lock::RepoLock, RefMutation)> {
646    // Grammar only: deletes take this guard too; writes check the bound
647    // first.
648    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/// Publish a branch's canonical first-parent ancestry, with an explicit
658/// generation and crash-recoverable descriptor.
659#[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/// Delete a branch after recovering any pending ancestry publication. Archived
678/// generation snapshots remain evidence; the current
679/// descriptor is invalidated before the ref disappears.
680#[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
693/// Delete a branch ref. Errors with [`RefError::NotFound`] if absent.
694pub 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
700/// Delete a branch ref unless it is the currently checked-out branch.
701pub 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
710/// CAS-guarded delete: removes a branch ref only if its current on-disk
711/// value is exactly `expected`. Issue #658.
712///
713/// [`delete_ref`] is unconditional — it removes whatever is at `path`
714/// regardless of what a caller last read. That is fine for the
715/// user-initiated `branch -d`/`-D` path (deleting a specific named
716/// branch is meaningful even if its tip moved since the user last
717/// looked), but it is NOT fine for `branch -m`'s rename: a rename reads
718/// the source branch's tip, publishes it under the new name, and then
719/// drops the source ref. If a concurrent `commit` (via
720/// [`RefWriteCondition::Match`], see `mkit-cli`'s `advance_head`)
721/// advances the source branch in the window between the rename's read
722/// and its delete, an unconditional delete destroys that freshly-landed
723/// commit's only ref with no error to either caller — commit reports
724/// success, rename reports success, and the commit becomes unreachable.
725///
726/// This closes that gap by making the delete itself compare-and-swap:
727/// it acquires the SAME per-ref lock every `cas_write` condition takes
728/// (via `cas_lock_name`, keyed off the ref's path so it can never
729/// collide with an unrelated ref of the same bare name), reads the
730/// current value under that lock, and only removes the file if it is
731/// still exactly `expected`. Because a concurrent `Match`-conditioned
732/// advance on the SAME ref takes the identical lock, the two can never
733/// interleave: either the advance's CAS write lands first (this call
734/// then sees the new value, doesn't match, and errors `Conflict`
735/// without touching the file) or this delete lands first (the advance's
736/// subsequent `Match(expected)` then sees the ref gone and itself fails
737/// `Conflict`) — never both "succeeding" against the same prior state.
738///
739/// All writers, including `Any`, and both delete forms take the same
740/// per-ref guard. Callers do not need to establish a separate exclusion rule.
741///
742/// # Errors
743/// - [`RefError::InvalidRefName`] if `branch` is not a valid name.
744/// - [`RefError::Conflict`] if the ref's current value is not exactly
745///   `expected` — this includes the ref not existing at all. The ref
746///   file is left completely untouched in this case.
747/// - [`RefError::Io`] for filesystem or lock-acquisition failures.
748pub 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
754/// List all branch refs, sorted lexicographically by name.
755pub fn list_refs(layout: &RepoLayout) -> RefResult<Vec<Ref>> {
756    list_refs_under(layout.common_dir(), HEADS_DIR)
757}
758
759// -----------------------------------------------------------------------------
760// Remote-tracking refs (refs/remotes/<remote>/<branch>)
761// -----------------------------------------------------------------------------
762
763/// Read a remote-tracking branch ref.
764pub 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
770/// Write a remote-tracking branch ref unconditionally.
771pub 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/// Batched writer for remote-tracking refs (#645): amortises the
791/// parent-directory fsync across every ref written into it, instead of
792/// paying one per ref like [`write_remote_ref`] in a loop.
793///
794/// `push`/`fetch` publish one tracking ref per branch
795/// (`refs/remotes/<remote>/<branch>`) in a loop; each individual
796/// [`write_remote_ref`] call goes through `write_atomic`'s
797/// temp+fsync+rename+**dirsync** pattern, so N branches cost N serial
798/// directory fsyncs even though every write lands under the same
799/// `refs/remotes/<remote>/` tree. `RemoteRefBatch` instead:
800///
801/// 1. [`Self::write`]s each ref durable-content-before-visible (fsyncs
802///    the wire bytes, then renames — same invariant
803///    `crate::atomic::write_content_synced` gives object writes: a
804///    reader can never observe a torn file), immediately, one call at a
805///    time, deferring only the directory fsync that makes the RENAME
806///    itself crash-durable;
807/// 2. [`Self::commit`] fsyncs every distinct directory touched, once
808///    each, deduplicated — O(distinct directories) instead of O(refs).
809///    For the common case (one remote, flat branch names) that is
810///    exactly one fsync for the whole batch.
811///
812/// This is deliberately scoped to `refs/remotes/*` ONLY — see
813/// `atomic.rs`'s module docs for why branch heads (`refs/heads/*`,
814/// [`write_ref`]/[`update_ref`]) keep the unbatched per-write contract:
815/// tracking refs are a locally-cached, recomputable-from-a-re-fetch
816/// view of another repository, not a pointer anything else orders
817/// against.
818///
819/// # Partial-failure semantics
820///
821/// Best-effort, fail-fast — the same contract
822/// [`crate::batch::WriteBatch::commit`] documents for the object store's
823/// batched writes. [`Self::write`] renames each ref as soon as it
824/// validates and its content is durable, so a ref that made it through
825/// `write` is visible to readers immediately, exactly as it would have
826/// been under the old per-ref loop. If a later `write` in the same
827/// batch fails (bad name or I/O error), earlier successful writes are
828/// **not** rolled back — content-addressed dedup isn't in play here,
829/// but the same reasoning applies: remote-tracking refs are
830/// recomputable, so a partially-applied batch is exactly as safe to
831/// retry as the old loop was after failing at the same point. Callers
832/// that want the successful prefix to be durable even when the batch as
833/// a whole errors out should call [`Self::commit`] regardless of
834/// whether the write loop returned early (see
835/// `remote_dispatch::push_all_with` / `fetch_objects_inner` for the
836/// pattern).
837#[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    /// Start a batch for `remote`.
846    ///
847    /// # Errors
848    /// [`RefError::InvalidRefName`] if `remote` fails [`validate_ref_name`].
849    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    /// Write one remote-tracking ref: validates `branch`, fsyncs its
859    /// wire-encoded content, then renames it into place — visible
860    /// immediately, same as [`write_remote_ref`]. Only the directory
861    /// fsync (rename durability) is deferred to [`Self::commit`].
862    ///
863    /// # Errors
864    /// - [`RefError::InvalidRefName`] if `branch` fails
865    ///   [`validate_ref_name`] — no I/O is attempted for an invalid name.
866    /// - [`RefError::Io`] for filesystem failures.
867    ///
868    /// # Panics
869    /// Never in practice: `ref_path` always produces a path with a
870    /// parent (it is `self.layout.common_dir()` joined with at least the
871    /// remote's subdirectory and the branch name).
872    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    /// Fsync every directory touched by a completed [`Self::write`],
889    /// once each. Idempotent to call on a batch with nothing staged (a
890    /// no-op). MUST be called after the last `write` a caller intends to
891    /// make durable — refs written but never committed are visible but
892    /// not crash-durable (the rename may not have reached stable
893    /// storage), the same window [`crate::store::BulkWriter::commit`]
894    /// documents for bulk object writes.
895    ///
896    /// # Errors
897    /// [`RefError::Io`] on the first directory fsync failure. Directories
898    /// are synced in sorted order for determinism; a failure partway
899    /// through leaves the remaining directories un-synced (best-effort,
900    /// matching [`Self::write`]'s partial-failure contract).
901    pub fn commit(self) -> RefResult<()> {
902        for dir in &self.touched_dirs {
903            crate::atomic::sync_dir(dir)?;
904        }
905        Ok(())
906    }
907}
908
909/// Delete a remote-tracking branch ref (e.g. after the upstream
910/// deleted the branch). Errors with [`RefError::NotFound`] if absent.
911pub 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
918/// List all remote-tracking refs for one remote.
919pub 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
924/// Like [`list_remote_refs`], but hands the batch of discovered ref files
925/// to a caller-supplied `read_batch` — see [`list_refs_with`]'s docs for
926/// the shape and the `RefBatchLengthMismatch` contract.
927pub 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
936/// List the remote names that have at least one tracking ref on disk
937/// (the immediate subdirectories of `refs/remotes/`), sorted. A
938/// missing `refs/remotes/` yields an empty list. Entries whose names
939/// fail the ref grammar are skipped (consistent with how malformed
940/// ref files are skipped by [`list_refs`]).
941pub 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
964// -----------------------------------------------------------------------------
965// Tags (refs/tags/<name>)
966// -----------------------------------------------------------------------------
967
968/// Read the hash a tag points to.
969pub 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
974/// Write a tag ref (unconditional).
975pub fn write_tag(layout: &RepoLayout, name: &str, h: &Hash) -> RefResult<()> {
976    update_tag(layout, name, RefWriteCondition::Any, h)
977}
978
979/// CAS-aware tag write — same semantics as [`update_ref`] but for
980/// `refs/tags/`.
981pub 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
993/// Delete a tag ref.
994pub 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
1000/// List all tag refs, sorted lexicographically by name.
1001pub fn list_tags(layout: &RepoLayout) -> RefResult<Vec<Ref>> {
1002    list_refs_under(layout.common_dir(), TAGS_DIR)
1003}
1004
1005/// Like [`list_tags`], but hands the batch of discovered ref files to a
1006/// caller-supplied `read_batch` — see [`list_refs_with`]'s docs for the
1007/// shape and the `RefBatchLengthMismatch` contract.
1008pub 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
1015// -----------------------------------------------------------------------------
1016// Shallow boundaries (.mkit/shallow)
1017// -----------------------------------------------------------------------------
1018
1019/// Load shallow-boundary hashes from `.mkit/shallow`. Returns `Ok(None)`
1020/// if the file does not exist or is empty.
1021pub 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
1052/// Write shallow-boundary hashes to `.mkit/shallow`. Passing an empty
1053/// slice removes the file.
1054pub 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
1072// -----------------------------------------------------------------------------
1073// Internals
1074// -----------------------------------------------------------------------------
1075
1076fn 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
1103/// A held mutation transaction for exactly one full ref identity. Every
1104/// write condition and delete uses this guard; history callers acquire their
1105/// history lock first and may call the guarded primitives without relocking.
1106pub(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        // Even a same-tip raw write refuses to bypass an outstanding intent.
1161        // Ordinary writes preserve a matching snapshot only for a true no-op.
1162        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
1231/// The default, sequential `read_batch`: reads and decodes each candidate
1232/// one at a time, exactly `collect_refs`'s old inline behavior. What
1233/// [`list_refs_under`] (and therefore [`list_refs`]/[`list_remote_refs`]/
1234/// tag listing) still uses.
1235fn sequential_read_batch(candidates: &[RefCandidate]) -> Vec<RefReadOutcome> {
1236    candidates.iter().map(read_ref_candidate).collect()
1237}
1238
1239/// Read and decode one [`RefCandidate`]'s wire content. The single
1240/// definition of "how to turn a candidate into a [`RefReadOutcome`]",
1241/// shared by `sequential_read_batch` and every caller-supplied
1242/// `read_batch` in mkit-cli's rayon fan-out and its bench — so the
1243/// `Unreadable`-vs-`Decoded(None)` policy (an I/O failure drops the
1244/// entry; malformed-but-readable content keeps it with `hash: None`)
1245/// can't drift between a sequential and a parallel caller.
1246#[must_use]
1247pub fn read_ref_candidate(candidate: &RefCandidate) -> RefReadOutcome {
1248    use std::io::Read;
1249
1250    // A valid ref is HEX_LEN bytes plus optional trailing whitespace, and
1251    // ref files are written whole and renamed into place (never appended
1252    // to), so one `read` into a stack buffer sees the entire file. This is
1253    // open+read+close, versus `fs::read`'s extra size-hint `statx` and
1254    // EOF-probe `read` — the dominant per-ref cost when listing thousands.
1255    // A file that fills the buffer is oversized and falls back to the
1256    // unbounded `fs::read`, so the malformed-content policy is unchanged.
1257    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/// One ref file discovered by `list_refs_under_with`'s directory walk:
1277/// its logical name (relative to the listed namespace, e.g. a branch or
1278/// tag name) and the on-disk path to read its wire content from.
1279#[derive(Debug, Clone)]
1280pub struct RefCandidate {
1281    pub name: String,
1282    pub path: PathBuf,
1283}
1284
1285/// The outcome of reading and decoding one [`RefCandidate`]'s wire
1286/// content — matching `collect_refs`'s old per-entry handling exactly:
1287/// an I/O failure drops the entry from the listing entirely (the same
1288/// silently-skip posture the sequential path already had for a transient
1289/// read failure or a race with concurrent deletion); malformed-but-
1290/// readable content keeps the entry with [`Ref::hash`] `None`.
1291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1292pub enum RefReadOutcome {
1293    Unreadable,
1294    Decoded(Option<Hash>),
1295}
1296
1297/// Like [`list_refs`], but hands the *whole* batch of discovered ref
1298/// files to a caller-supplied `read_batch` instead of reading and
1299/// decoding each one sequentially inside the same call that walks the
1300/// directory tree — e.g. a rayon fan-out, the same parallel-batch shape
1301/// already used for pack-entry compression, delta encoding, and
1302/// post-fetch signature verification (`mkit-cli`'s `remote_dispatch`).
1303/// The directory walk itself (`fs::read_dir`, cheap metadata-only calls)
1304/// stays sequential; only the per-file read-and-decode step, one syscall
1305/// each and independent of every other entry, is worth fanning out.
1306/// `read_batch` must return exactly one [`RefReadOutcome`] per candidate,
1307/// in the same order — a mismatched length is
1308/// [`RefError::RefBatchLengthMismatch`], not a silently desynced listing.
1309pub 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
1348/// Cap on ref-tree recursion depth. A malicious or corrupt `.mkit/refs/`
1349/// directory with deeply nested empty dirs should not stack-overflow
1350/// the walker. 32 is far beyond anything a valid ref name (which cannot
1351/// contain more than a few `/` separators given the 1–255 path-segment
1352/// grammar) could ever require.
1353const 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        // Silently stop — same "skip malformed" posture as below for
1363        // individual files. Callers get a partial result rather than a
1364        // stack overflow on adversarial input.
1365        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, // Non-UTF-8 names cannot be valid ref names.
1382        };
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        // Grammar only: a ref named before SPEC-REFS §3 bounded names is
1397        // still listed, so it can be seen, renamed or deleted.
1398        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// -----------------------------------------------------------------------------
1410// Internal hash helper re-exports for goldens
1411// -----------------------------------------------------------------------------
1412
1413/// Internal re-export used by the integration tests to hand-roll wire
1414/// bytes without round-tripping through `hash::from_hex`.
1415#[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    // --- name grammar ---------------------------------------------------
1441
1442    #[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        // git's check-ref-format rule: no slash-separated component may
1469        // begin with '.'. This also inertifies crash debris parked at a
1470        // dot-leading directory (e.g. a `.rename.tmp.<pid>.<seq>` orphan)
1471        // as a ref name, not just the exact `.`/`..` shapes above.
1472        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        // Only trailing ".lock" should reject; "lockfile" is fine.
1510        assert!(validate_ref_name("refs/heads/lockfile"));
1511    }
1512
1513    #[test]
1514    fn validate_accepts_headless_regression() {
1515        // Only the exact final segment "HEAD" is rejected.
1516        assert!(validate_ref_name("refs/heads/HEADless"));
1517    }
1518
1519    /// A name of `len` bytes in 100-byte segments (each well under a file
1520    /// name's limit).
1521    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    /// SPEC-REFS §3: at most `MAX_REF_NAME_BYTES` bytes; the grammar alone
1535    /// has no bound.
1536    #[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    /// A new branch name must leave `refs/heads/<b>` and
1561    /// `refs/mkit/packmap/<b>` within 512 bytes (494), a new tag
1562    /// `refs/tags/<t>` (502); a branch created before that bound stays
1563    /// writable locally but cannot be pushed.
1564    #[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        // A 500-byte branch made before the bound: writable, not pushable.
1601        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    /// A new branch, tag, remote-tracking ref or HEAD target over the bound
1617    /// is refused, naming the limit.
1618    #[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    /// A branch file named over the bound, written before SPEC-REFS §3
1640    /// bounded names, keeps the repository working: it is listed, read,
1641    /// resolved through HEAD and deletable; only writes to it are refused.
1642    #[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        // The way out: switch away, then delete it (or rename it).
1669        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        // Tags and remote-tracking refs the same way.
1678        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    // --- wire encoding --------------------------------------------------
1707
1708    #[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        // Upper-case the first letter we find; SPEC-REFS §1 forbids
1723        // uppercase hex on read.
1724        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        // Files round-tripped through Windows editors may pick up CRs.
1752        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    // --- HEAD ----------------------------------------------------------
1760
1761    #[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        // SPEC-REFS §6: HEAD content is capped at 4 KiB.
1819        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        // SPEC-REFS §6: a single ref file is capped at 128 bytes.
1840        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        // SPEC-REFS §6: listing recurses with a hard depth cap of 32
1882        // levels to defeat adversarial nesting; anything deeper is
1883        // silently skipped (not an error, not a stack overflow).
1884        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    /// A `read_batch` that behaves exactly like the sequential default
1902    /// (the same read-and-decode each candidate gets in `list_refs`) must
1903    /// produce byte-for-byte the same listing — this is `list_refs_with`'s
1904    /// core contract: the caller's batching/parallelism strategy is not
1905    /// supposed to be observable in the result, only in how it's computed.
1906    #[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    /// `list_refs_with`'s two outcome kinds must be handled distinctly:
1919    /// `Unreadable` drops the candidate from the listing entirely (as the
1920    /// old inline `fs::read` failure did), while `Decoded(None)` keeps it
1921    /// with `Ref::hash == None` (malformed-but-readable content) — the two
1922    /// are not interchangeable, so a `read_batch` that collapsed one into
1923    /// the other would be a real, if narrow, behavior change from the
1924    /// sequential path it replaced.
1925    #[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    /// A `read_batch` that violates the "exactly one outcome per
1966    /// candidate" contract must fail closed with
1967    /// [`RefError::RefBatchLengthMismatch`], not silently zip a short or
1968    /// long outcome list against the wrong candidates.
1969    #[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    // --- CAS variants ---------------------------------------------------
2010
2011    #[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    // --- INV-15 / #637: Match CAS must be atomic across uncoordinated
2058    // callers -------------------------------------------------------------
2059
2060    /// Reproduces the lost-update race described in INV-15: two callers
2061    /// that do NOT share any lock (mimicking `branch -m` under
2062    /// `worktrees.lock` racing `commit` under `worktree.lock`, or two
2063    /// linked worktrees each holding their own `worktree.lock`) both
2064    /// read the ref's current value, both see it matches their
2065    /// expectation, and both `cas_write`. Without cross-process
2066    /// atomicity on the read-compare-write, both calls can report
2067    /// success even though only one write actually survives — silently
2068    /// losing the other caller's update.
2069    ///
2070    /// `layout_a`/`layout_b` are two distinct [`RepoLayout`]s (a
2071    /// "single" layout and a "linked" layout with a different
2072    /// `worktree_root`/`worktree_state_dir`) that share only
2073    /// `common_dir` — exactly the shape of two linked worktrees, each
2074    /// of which would hold its own separate `worktree.lock` and thus
2075    /// share no lock with the other over this CAS. A `Barrier` forces
2076    /// both threads to enter `update_ref` at (as close to) the same
2077    /// instant as the scheduler allows, and the loop repeats many
2078    /// iterations because the race window is a handful of syscalls wide
2079    /// and is not guaranteed to be hit on any single attempt.
2080    ///
2081    /// Before the #637 fix (no lock around the `Match` arm) this
2082    /// reliably reproduces a "both succeeded" iteration within a few
2083    /// hundred attempts. After the fix, the per-ref
2084    /// `refs-<digest>.lock` both callers share makes the
2085    /// read-compare-write atomic across both callers, so this must
2086    /// never happen — exactly one of the two racing writers may
2087    /// succeed.
2088    #[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            // Reset to a known base before each round. `Any` bypasses
2105            // CAS entirely, so this is not itself part of what's under
2106            // test.
2107            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    /// All mutations must join the CAS critical section, including calls
2223    /// from a linked tree with its own unrelated worktree lock.
2224    #[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    /// Normal-case companion to the race test above: with no
2332    /// contention, a sequence of `Match` CAS writes on the same ref must
2333    /// still succeed every time and never wedge (guards against the
2334    /// per-ref `refs-<digest>.lock` acquire/release, added for #637,
2335    /// leaking or deadlocking across repeated calls from the same
2336    /// layout).
2337    #[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", &current).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    // --- INV-15 / #658: CAS-guarded delete must not lose a concurrent
2352    // Match-conditioned advance ---------------------------------------
2353
2354    /// Primitive-level reproduction of #658's "Race 1": a caller (e.g.
2355    /// `branch -m`) reads a branch's tip `T`, then — before it gets
2356    /// around to deleting the ref — a concurrent `Match(T)` CAS (e.g.
2357    /// `commit`'s fixed advance) lands, moving the ref to `C`. The
2358    /// caller's delete must detect that the ref no longer matches what
2359    /// it read and refuse, leaving `C` on disk untouched.
2360    ///
2361    /// Confirmed against the pre-fix shape of this codebase: pointing
2362    /// this same sequence at plain, unconditional [`delete_ref`] instead
2363    /// of [`delete_ref_if_matches`] removes the ref regardless of `T` vs
2364    /// `C`, silently destroying the concurrently-landed `C` — exactly
2365    /// the bug #658 reports. [`delete_ref_if_matches`] must refuse
2366    /// instead.
2367    #[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        // Caller reads the tip...
2375        let read_t = read_ref(&mkit, "main").unwrap().unwrap();
2376        assert_eq!(read_t, t);
2377
2378        // ...then a concurrent Match(T) CAS (a fixed `commit`) lands
2379        // before the caller's delete runs.
2380        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    /// `delete_ref_if_matches` refusing to delete must not remove the
2395    /// file at all — a second, correctly-conditioned delete against the
2396    /// NEW value must still succeed.
2397    #[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    /// Racing-loop version of the reproduction above, mirroring
2421    /// [`cas_match_race_never_loses_an_update_across_uncoordinated_callers`]'s
2422    /// pattern: on each iteration, one thread performs a `Match`-guarded
2423    /// advance (mirroring a fixed `commit`) and the other performs a
2424    /// `delete_ref_if_matches` against the pre-advance value (mirroring
2425    /// `branch -m`'s rename), both released by the same `Barrier` so the
2426    /// scheduler is given its best shot at interleaving them. Because
2427    /// both operations serialize under the same per-ref lock
2428    /// ([`cas_lock_name`]), exactly one of the two may ever report
2429    /// success against a given prior value — never both, and the loser
2430    /// must never observe (or cause) a torn/lost state.
2431    #[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                    // The advance won the race; its value must survive.
2467                    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                    // The delete won the race before the advance landed;
2476                    // the ref must be gone (the advance must have then
2477                    // failed its own CAS, which the match arm above
2478                    // already confirms).
2479                    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                    // Both lost — impossible on a fresh `base` write each
2488                    // round with only these two writers, but not itself a
2489                    // safety violation; leave unhandled rather than
2490                    // silently accepting on a bug that could produce it.
2491                    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    /// Cross-ref counterpart to the race test above: the #637 lock
2514    /// was first one repo-wide `refs.lock`, so a `Match` CAS on branch "other"
2515    /// would block one on unrelated branch "main" for no reason —
2516    /// nothing about this CAS invariant spans refs. Now keyed per ref
2517    /// via [`cas_lock_name`]; proves an externally-held lock on
2518    /// "other"'s ref path does NOT block a `Match` CAS on "main"'s.
2519    #[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    // --- name-validation enforcement -----------------------------------
2553
2554    #[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    // --- tags ----------------------------------------------------------
2564
2565    #[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    // --- shallow boundaries --------------------------------------------
2613
2614    #[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        // SPEC-REFS §6: the shallow file is capped at 1 MiB.
2644        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    // --- remote-ref batching (#645) --------------------------------------
2674    //
2675    // `push`/`fetch` publish one remote-tracking ref per branch in a loop
2676    // (`mkit-cli`'s `remote_dispatch::push_all_with` /
2677    // `fetch_objects_inner`). Each `write_remote_ref` call goes through
2678    // `cas_write` → `write_atomic`, which pays a parent-directory fsync
2679    // EVERY call (`atomic.rs`'s `sync_parent_dir`) — so N branches cost N
2680    // directory fsyncs, serially, even though they all land in the same
2681    // `refs/remotes/<remote>/` directory. `RemoteRefBatch` amortises that
2682    // into one fsync per distinct directory touched, however many refs
2683    // were written into it.
2684
2685    /// Baseline (pre-#645): today's per-ref loop — exactly what
2686    /// `push_all_with`/`fetch_objects_inner` do today — pays one
2687    /// directory fsync per ref, even though every ref lands in the same
2688    /// `refs/remotes/origin/` directory. This is the O(N) cost #645
2689    /// exists to amortise; it must keep holding after the fix, since
2690    /// `write_remote_ref` itself (used elsewhere for single-ref writes)
2691    /// is intentionally left on the unbatched path.
2692    #[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    /// The #645 fix: staging N remote-tracking-ref writes into one
2715    /// `RemoteRefBatch` and committing once must cost O(1) directory
2716    /// fsyncs (one per distinct directory touched — here a single flat
2717    /// `refs/remotes/origin/` namespace, so exactly one), not O(N).
2718    #[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    /// Correctness: batched writes must produce the exact same final ref
2742    /// states as the old per-ref `write_remote_ref` loop — same hashes,
2743    /// same readability, for every branch.
2744    #[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    /// Partial-failure semantics: best-effort / fail-fast, matching
2773    /// `WriteBatch::commit`'s documented contract (already-renamed
2774    /// entries stay visible; no rollback). An invalid branch name in the
2775    /// middle of a batch aborts every write from that point on — the
2776    /// refs staged before it stay visible and readable, exactly as they
2777    /// would have under the old per-ref loop had it hit the same
2778    /// mid-loop error (each earlier ref was already independently
2779    /// visible before the loop reached the bad one).
2780    #[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        // Committing what was staged before the bad write must still
2790        // durably publish the good entries.
2791        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        // "bad//name" was never a valid ref name in the first place —
2801        // nothing was ever staged for it, on either the old or new path.
2802        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}