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). As of #637, the local-filesystem `Match` read-compare-write is
24//! serialized under a shared `refs.lock` in the common dir (the same
25//! blocking kernel-lock primitive `repo_lock` uses elsewhere), so it is
26//! atomic across processes regardless of what other locks each caller
27//! holds — this closes the v1 gap previously documented here.
28
29use std::collections::BTreeSet;
30use std::fs;
31use std::io;
32use std::path::{Path, PathBuf};
33
34use crate::atomic::{write_atomic, write_create_new};
35use crate::hash::{HASH_LEN, HEX_LEN, Hash, to_hex};
36use crate::layout::RepoLayout;
37
38/// Subdirectory holding all refs (`.mkit/refs`).
39pub const REFS_DIR: &str = "refs";
40/// Subdirectory holding branch refs (`.mkit/refs/heads`).
41pub const HEADS_DIR: &str = "refs/heads";
42/// Subdirectory holding tag refs (`.mkit/refs/tags`).
43pub const TAGS_DIR: &str = "refs/tags";
44/// Subdirectory holding remote-tracking refs (`.mkit/refs/remotes`).
45pub const REMOTES_DIR: &str = "refs/remotes";
46/// HEAD file relative to the `.mkit` root.
47pub const HEAD_FILE: &str = "HEAD";
48/// Shallow-boundary file relative to the `.mkit` root.
49pub const SHALLOW_FILE: &str = "shallow";
50
51/// Symbolic-ref prefix written to `HEAD` when pointing at a branch.
52const HEAD_REF_PREFIX: &str = "ref: refs/heads/";
53
54/// Hard cap on how many bytes we are willing to read from `HEAD`.
55const HEAD_MAX_BYTES: u64 = 4 * 1024;
56/// Hard cap on how many bytes we are willing to read from a single ref
57/// file. The wire form is always 65 bytes; a few more is fine if the
58/// file picked up extra whitespace, but anything pathological is
59/// rejected.
60const REF_FILE_MAX_BYTES: u64 = 128;
61/// Hard cap on `.mkit/shallow` (1 MiB).
62const SHALLOW_MAX_BYTES: u64 = 1024 * 1024;
63
64/// Errors raised by this module.
65#[derive(Debug, thiserror::Error)]
66pub enum RefError {
67    /// `name` failed [`validate_ref_name`].
68    #[error("invalid ref name '{0}'")]
69    InvalidRefName(String),
70    /// On-disk bytes were not a valid 65-byte ref wire (length wrong,
71    /// uppercase hex, non-hex byte, etc.).
72    #[error("invalid ref content for '{0}'")]
73    InvalidRef(String),
74    /// `HEAD` content was neither a valid symbolic ref nor a valid
75    /// detached hash.
76    #[error("HEAD is not a valid symbolic-ref or detached-hash file")]
77    InvalidHead,
78    /// `HEAD` is missing.
79    #[error("HEAD is not present")]
80    NoHead,
81    /// CAS condition failed: ref does not match expected state.
82    #[error("ref '{0}' did not satisfy CAS condition")]
83    Conflict(String),
84    /// Tried to delete a ref that does not exist.
85    #[error("ref '{0}' not found")]
86    NotFound(String),
87    /// Tried to delete the branch HEAD currently points to.
88    #[error("cannot delete the current branch '{0}'")]
89    CurrentBranch(String),
90    /// Underlying I/O failure.
91    #[error(transparent)]
92    Io(#[from] io::Error),
93}
94
95/// Result alias used throughout this module.
96pub type RefResult<T> = Result<T, RefError>;
97
98/// CAS condition for [`update_ref`].
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum RefWriteCondition {
101    /// Unconditional write — clobbers any existing value.
102    Any,
103    /// Write only if the ref does not currently exist.
104    Missing,
105    /// Write only if the ref currently contains this exact hash.
106    Match(Hash),
107}
108
109/// HEAD pointer: either a symbolic reference to a branch name, or a
110/// detached hash.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum Head {
113    /// Symbolic — `HEAD` was `ref: refs/heads/<branch>\n`.
114    Branch(String),
115    /// Detached — `HEAD` was a bare 64-char hex hash.
116    Detached(Hash),
117}
118
119/// A listed ref entry: name (with `refs/heads/` or similar prefix
120/// stripped) plus the resolved hash, when readable.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct Ref {
123    /// Ref name relative to the namespace (e.g. `"main"`, `"v1.0"`).
124    pub name: String,
125    /// Resolved 32-byte hash, or `None` if the on-disk bytes were
126    /// malformed (the entry is then silently skipped by callers that
127    /// only care about valid refs).
128    pub hash: Option<Hash>,
129}
130
131/// Validate a ref name per SPEC-REFS §3. Used at every transport
132/// boundary; transports MUST NOT silently lower-case or canonicalise.
133///
134/// Grammar:
135/// - Non-empty.
136/// - Segments split on `/`, each segment matches `[A-Za-z0-9._-]+`.
137/// - No segment may start with `.` (this also rejects the exact `.`
138///   and `..` segments) — git's `check-ref-format` rule, kept for
139///   parity: a dot-leading component is invalid in both grammars, so
140///   nothing on-disk under a dot-leading directory (e.g. crash debris
141///   from a temp-directory rename, or an in-flight `atomic::write_atomic`
142///   temp file) can ever surface as a ref in `collect_refs` / listings.
143///   No empty segments (no `//`, no leading `/`, no trailing `/`).
144/// - No `\\` or NUL bytes.
145/// - No segment may end in `.lock` (the canonical lock-file suffix).
146/// - The final segment may not be the literal `HEAD`, since that
147///   would shadow the repo-level `HEAD` pointer.
148#[must_use]
149pub fn validate_ref_name(name: &str) -> bool {
150    if name.is_empty() {
151        return false;
152    }
153    if name.starts_with('/') {
154        return false;
155    }
156    let mut last_part: &str = "";
157    for part in name.split('/') {
158        if part.is_empty() {
159            return false;
160        }
161        if part.starts_with('.') {
162            return false;
163        }
164        // Reject the canonical lock-file suffix. Byte-level check so
165        // clippy's case-sensitive file-extension lint (which assumes a
166        // path extension) does not fire — ref segments are not paths
167        // and `.lock` is a spec-mandated exact-match suffix.
168        let bytes = part.as_bytes();
169        if bytes.len() >= 5 && &bytes[bytes.len() - 5..] == b".lock" {
170            return false;
171        }
172        for &c in part.as_bytes() {
173            if c == 0 || c == b'\\' {
174                return false;
175            }
176            let allowed = c.is_ascii_alphanumeric() || c == b'.' || c == b'_' || c == b'-';
177            if !allowed {
178                return false;
179            }
180        }
181        last_part = part;
182    }
183    if last_part == "HEAD" {
184        return false;
185    }
186    true
187}
188
189/// Hex-escape an arbitrary ref-like name (branch, tag, remote ref, or
190/// a `refs.rs`-relative path) into a filename-safe token:
191/// `[A-Za-z0-9-_]`-only input, injective, self-delimiting.
192///
193/// Every non-`[A-Za-z0-9-]` byte is encoded as `_xx` where `xx` is the
194/// lowercase-hex byte value; `_` itself encodes as `_5f`, so every `_`
195/// in the output is unambiguously the lead-in of a two-hex-digit
196/// escape, never a literal.
197///
198/// Examples:
199///   `main`        → `main`
200///   `feat/v1.0`   → `feat_2fv1_2e0`
201///   `feat_v1_0`   → `feat_5fv1_5f0`
202///
203/// Deliberately NOT gated behind `history-mmr` (unlike the `history`
204/// module, which is): [`cas_write`]'s per-ref lock naming needs this
205/// in every build, and `history.rs` (when the feature is enabled)
206/// reuses this same implementation for its journal-partition naming —
207/// matching the dependency direction `history.rs` already has on this
208/// module via [`validate_ref_name`], rather than the reverse (which
209/// would make this unavailable exactly where `cas_write` needs it).
210pub(crate) fn sanitize_ref_name(name: &str) -> String {
211    let mut out = String::with_capacity(name.len());
212    for &b in name.as_bytes() {
213        let allowed = b.is_ascii_alphanumeric() || b == b'-';
214        if allowed {
215            out.push(b as char);
216        } else {
217            use core::fmt::Write as _;
218            let _ = write!(&mut out, "_{b:02x}");
219        }
220    }
221    out
222}
223
224/// The `refs-history-<branch>.lock` filename [`update_ref_with_history_locked`]
225/// and [`delete_ref_with_history`] both acquire. A named function
226/// (not an inline `format!` repeated at each call site) so tests can
227/// call the SAME naming decision production makes, rather than
228/// independently reimplementing the formula — a test that recomputes
229/// its own copy of this format string would silently stop catching a
230/// regression if production's naming ever changed without the test's
231/// hand-copied version changing too.
232#[cfg(feature = "history-mmr")]
233pub(crate) fn history_lock_name(branch: &str) -> String {
234    format!("refs-history-{}.lock", sanitize_ref_name(branch))
235}
236
237/// The `refs-<ref>.lock` filename [`cas_write`]'s `Match` arm
238/// acquires, keyed off `path` relative to `common_dir` (not just a
239/// bare name) so a branch and a tag/remote-ref sharing the same bare
240/// name get distinct locks. Named for the same reason as
241/// [`history_lock_name`]: a test that independently recomputes this
242/// formula would stop catching a regression the moment production's
243/// formula changed without the test's copy changing too.
244pub(crate) fn cas_lock_name(common_dir: &Path, path: &Path) -> String {
245    let ref_key = path
246        .strip_prefix(common_dir)
247        .map_or_else(|_| path.to_string_lossy(), |p| p.to_string_lossy());
248    format!("refs-{}.lock", sanitize_ref_name(&ref_key))
249}
250
251/// Validate a prefix passed to `list_refs`. An empty prefix is allowed.
252/// A single trailing `/` is allowed; otherwise the prefix must satisfy
253/// [`validate_ref_name`].
254#[must_use]
255pub fn validate_ref_prefix(prefix: &str) -> bool {
256    if prefix.is_empty() {
257        return true;
258    }
259    let trimmed = prefix.trim_end_matches('/');
260    if trimmed.is_empty() {
261        return false;
262    }
263    validate_ref_name(trimmed)
264}
265
266/// Encode `h` to its 65-byte wire form (lowercase hex + `\n`).
267#[must_use]
268pub fn encode_ref_wire(h: &Hash) -> [u8; 65] {
269    let hex = to_hex(h);
270    let bytes = hex.as_bytes();
271    let mut out = [0u8; 65];
272    out[..HEX_LEN].copy_from_slice(bytes);
273    out[HEX_LEN] = b'\n';
274    out
275}
276
277/// Decode a ref wire blob into a [`Hash`](tyalias@Hash). Tolerates a trailing
278/// newline / `\r` / ASCII whitespace (so files round-tripped through a
279/// Windows editor still parse), but rejects uppercase hex per
280/// SPEC-REFS §1.
281///
282/// Returns `None` for any malformed input; callers wrap the absent
283/// case into a domain-specific [`RefError::InvalidRef`].
284#[must_use]
285pub fn decode_ref_wire(data: &[u8]) -> Option<Hash> {
286    let s = core::str::from_utf8(data).ok()?;
287    let trimmed = s.trim_end_matches(['\n', '\r', ' ', '\t']);
288    if trimmed.len() != HEX_LEN {
289        return None;
290    }
291    parse_lowercase_hash(trimmed.as_bytes())
292}
293
294/// Strict lowercase-only hex parser: exactly [`HEX_LEN`] lowercase-hex
295/// bytes, decoded in a single pass. SPEC-REFS §1 forbids uppercase on read;
296/// the general `hash::from_hex` tolerates both cases for programmatic
297/// callers, so this is the stricter variant every on-the-wire / on-disk
298/// reader (ref wire blobs, the applied-packs record) shares to keep a
299/// hand-edited or foreign-cased line malformed rather than silently accepted.
300#[must_use]
301pub fn parse_lowercase_hash(bytes: &[u8]) -> Option<Hash> {
302    if bytes.len() != HEX_LEN {
303        return None;
304    }
305    let mut out = [0u8; HASH_LEN];
306    for i in 0..HASH_LEN {
307        let hi = lowercase_nibble(bytes[i * 2])?;
308        let lo = lowercase_nibble(bytes[i * 2 + 1])?;
309        out[i] = (hi << 4) | lo;
310    }
311    Some(out)
312}
313
314fn lowercase_nibble(b: u8) -> Option<u8> {
315    match b {
316        b'0'..=b'9' => Some(b - b'0'),
317        b'a'..=b'f' => Some(10 + (b - b'a')),
318        _ => None,
319    }
320}
321
322/// Initialise the ref layout: creates `refs/`, `refs/heads/`,
323/// `refs/tags/`, `refs/remotes/` under the common dir and writes a
324/// default `HEAD = ref: refs/heads/main\n` into the worktree state
325/// dir if `HEAD` does not already exist.
326pub fn init(layout: &RepoLayout) -> RefResult<()> {
327    fs::create_dir_all(layout.refs_dir())?;
328    fs::create_dir_all(layout.heads_dir())?;
329    fs::create_dir_all(layout.tags_dir())?;
330    fs::create_dir_all(layout.remotes_dir())?;
331    let head_path = layout.head_file();
332    if !head_path.exists() {
333        let body = format!("{HEAD_REF_PREFIX}main\n");
334        write_atomic(&head_path, body.as_bytes(), false)?;
335    }
336    Ok(())
337}
338
339// -----------------------------------------------------------------------------
340// HEAD
341// -----------------------------------------------------------------------------
342
343/// Read this worktree's `HEAD`.
344///
345/// # Errors
346/// - [`RefError::NoHead`] if the file is missing.
347/// - [`RefError::InvalidHead`] for malformed content.
348pub fn read_head(layout: &RepoLayout) -> RefResult<Head> {
349    let path = layout.head_file();
350    let meta = match fs::metadata(&path) {
351        Ok(m) => m,
352        Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(RefError::NoHead),
353        Err(e) => return Err(RefError::Io(e)),
354    };
355    if meta.len() > HEAD_MAX_BYTES {
356        return Err(RefError::InvalidHead);
357    }
358    let raw = fs::read(&path)?;
359    let s = core::str::from_utf8(&raw).map_err(|_| RefError::InvalidHead)?;
360    let trimmed = s.trim_end_matches(['\n', '\r', ' ', '\t']);
361    if let Some(branch) = trimmed.strip_prefix(HEAD_REF_PREFIX) {
362        if !validate_ref_name(branch) {
363            return Err(RefError::InvalidHead);
364        }
365        return Ok(Head::Branch(branch.to_string()));
366    }
367    if trimmed.len() == HEX_LEN {
368        let h = parse_lowercase_hash(trimmed.as_bytes()).ok_or(RefError::InvalidHead)?;
369        return Ok(Head::Detached(h));
370    }
371    Err(RefError::InvalidHead)
372}
373
374/// Write `HEAD` as a symbolic ref pointing at `branch`.
375///
376/// # Errors
377/// - [`RefError::InvalidRefName`] if `branch` does not satisfy
378///   [`validate_ref_name`].
379/// - [`RefError::Io`] for filesystem failures.
380pub fn write_head_branch(layout: &RepoLayout, branch: &str) -> RefResult<()> {
381    if !validate_ref_name(branch) {
382        return Err(RefError::InvalidRefName(branch.to_string()));
383    }
384    let body = format!("{HEAD_REF_PREFIX}{branch}\n");
385    write_atomic(&layout.head_file(), body.as_bytes(), false)?;
386    Ok(())
387}
388
389/// Write `HEAD` as a detached hash.
390///
391/// # Errors
392/// - [`RefError::Io`] for filesystem failures.
393pub fn write_head_detached(layout: &RepoLayout, h: &Hash) -> RefResult<()> {
394    let wire = encode_ref_wire(h);
395    write_atomic(&layout.head_file(), &wire, false)?;
396    Ok(())
397}
398
399/// Resolve `HEAD` to a commit hash. Returns `Ok(None)` when HEAD points
400/// at a branch that has no commit yet.
401pub fn resolve_head(layout: &RepoLayout) -> RefResult<Option<Hash>> {
402    let head = match read_head(layout) {
403        Ok(h) => h,
404        Err(RefError::NoHead) => return Ok(None),
405        Err(e) => return Err(e),
406    };
407    match head {
408        Head::Branch(name) => read_ref(layout, &name),
409        Head::Detached(h) => Ok(Some(h)),
410    }
411}
412
413/// Update the ref HEAD currently points at (or HEAD itself, if
414/// detached) to `commit_hash`.
415pub fn update_head(layout: &RepoLayout, commit_hash: &Hash) -> RefResult<()> {
416    let head = read_head(layout)?;
417    match head {
418        Head::Branch(name) => write_ref(layout, &name, commit_hash),
419        Head::Detached(_) => write_head_detached(layout, commit_hash),
420    }
421}
422
423// -----------------------------------------------------------------------------
424// Branch refs (refs/heads/<name>)
425// -----------------------------------------------------------------------------
426
427/// Read the hash a branch ref points to. Returns `Ok(None)` if the ref
428/// file does not exist.
429///
430/// # Errors
431/// - [`RefError::InvalidRefName`] if `branch` does not validate.
432/// - [`RefError::InvalidRef`] if the on-disk bytes are not a valid wire.
433pub fn read_ref(layout: &RepoLayout, branch: &str) -> RefResult<Option<Hash>> {
434    if !validate_ref_name(branch) {
435        return Err(RefError::InvalidRefName(branch.to_string()));
436    }
437    read_ref_under(layout.common_dir(), HEADS_DIR, branch)
438}
439
440/// Write a branch ref (unconditional — equivalent to
441/// `update_ref(branch, RefWriteCondition::Any, h)`).
442pub fn write_ref(layout: &RepoLayout, branch: &str, h: &Hash) -> RefResult<()> {
443    update_ref(layout, branch, RefWriteCondition::Any, h)
444}
445
446/// CAS-aware ref write per SPEC-REFS §5.
447///
448/// # Errors
449/// - [`RefError::InvalidRefName`] if `branch` is not a valid name.
450/// - [`RefError::Conflict`] if `condition` is not satisfied.
451/// - [`RefError::Io`] for filesystem failures.
452pub fn update_ref(
453    layout: &RepoLayout,
454    branch: &str,
455    condition: RefWriteCondition,
456    h: &Hash,
457) -> RefResult<()> {
458    if !validate_ref_name(branch) {
459        return Err(RefError::InvalidRefName(branch.to_string()));
460    }
461    let path = ref_path(layout.common_dir(), HEADS_DIR, branch);
462    let wire = encode_ref_wire(h);
463    cas_write(layout.common_dir(), &path, &wire, branch, condition)
464}
465
466// -----------------------------------------------------------------------------
467// History-coupled ref writes (feature: history-mmr)
468// -----------------------------------------------------------------------------
469
470/// Combined ref-write + history-MMR-append, protected by a single
471/// repo-level lock. Issue #157.
472///
473/// Performs, in order:
474///
475/// 1. Acquire [`crate::repo_lock::RepoLock`] on
476///    `<mkit_dir>/refs-history.lock` so concurrent writers in other
477///    mkit processes block until this caller is done.
478/// 2. [`crate::history::CommitHistory::reopen`] `history` from the
479///    current on-disk journal — the handle may have been opened
480///    before this lock was taken, so this guards against a concurrent
481///    writer's append in that window going unseen.
482/// 3. If the (now-fresh) journal is non-empty and its last leaf
483///    doesn't already verify as the ref's current value, append that
484///    value directly — this heals a prior call that CAS-wrote the ref
485///    (its step 4) but crashed before its own append (its step 5). See
486///    SPEC-HISTORY-PROOF §4.3.
487/// 4. CAS-write the ref under `<mkit_dir>/refs/heads/<branch>`.
488/// 5. Append `hash` to the branch's
489///    [`crate::history::CommitHistory`], which syncs the journal to
490///    disk before returning.
491/// 6. Release the lock.
492///
493/// On any failure between steps 4 and 5 the ref will be ahead of the
494/// history journal by one commit; the next call's step 3 heals it as
495/// above. If the journal was empty going into step 3, this function
496/// alone can't tell a fresh branch's first write apart from a
497/// v0.1.x-era repo's deep, un-migrated history — that needs
498/// `ObjectStore` access this module doesn't have. See
499/// [`update_ref_with_history_and_backfill`], which threads a
500/// `parent_of` walker through so the empty-journal check AND the
501/// backfill itself (SPEC-HISTORY-PROOF §4.5) also run inside this same
502/// locked critical section (issue #638 / INV-18): without that, two
503/// ref-only writers on the same never-before-journaled branch (e.g.
504/// two `update-ref` calls, which deliberately skip the worktree lock)
505/// could both observe an empty journal and both independently
506/// backfill, corrupting the journal's leaf positions.
507///
508/// # Design note (Option B vs Option A)
509///
510/// The original journaled-history plan considered adding an optional
511/// `executor: Option<&dyn Executor>` parameter to [`update_ref`]
512/// itself ("Option A"). Two reasons not to:
513///
514/// - [`update_ref`] is called by [`write_ref`] and [`update_head`]
515///   internally and indirectly by the file transport's
516///   [`crate::protocol::Transport::update_ref`] impl; threading an
517///   executor through all of those would either ripple
518///   `history-mmr` into transport-file's API surface or force a
519///   `None` at every callsite that doesn't care.
520/// - The [`crate::protocol::async_shim::Executor`] trait uses
521///   generic methods so `&dyn Executor` is not object-safe, forcing
522///   us to generic-parameterise the trait anyway.
523///
524/// Adding a dedicated [`update_ref_with_history`] keeps the
525/// existing [`update_ref`] signature intact and confines the
526/// `history-mmr` integration to the one call-site (`mkit-cli`'s
527/// commit path) that actually needs it.
528///
529/// # Errors
530///
531/// - [`RefError::InvalidRefName`] — `branch` failed
532///   [`validate_ref_name`].
533/// - [`RefError::Conflict`] — the CAS precondition was not satisfied.
534/// - [`RefError::Io`] — filesystem failure during ref or lock I/O.
535/// - The wrapped [`crate::history::HistoryError`] is exposed via a
536///   `String` payload on [`RefError::InvalidRef`] (so this function's
537///   signature stays compatible with consumers that pin only
538///   `RefError`). Callers that need structured access to the history
539///   error can drive the two steps themselves via [`update_ref`] +
540///   [`crate::history::CommitHistory::append`].
541#[cfg(feature = "history-mmr")]
542pub fn update_ref_with_history<X: crate::protocol::async_shim::Executor + 'static>(
543    layout: &RepoLayout,
544    branch: &str,
545    condition: RefWriteCondition,
546    hash: &Hash,
547    history: &mut crate::history::CommitHistory<X>,
548) -> RefResult<()> {
549    // No backfill walker: an empty journal here is left untouched (the
550    // ambiguous case documented on `heal_one_ahead_gap`), exactly the
551    // pre-#638 behaviour for callers that don't need the v0.1.x
552    // migration shim.
553    update_ref_with_history_locked(layout, branch, condition, hash, history, |_, _| Ok(()))
554}
555
556/// [`update_ref_with_history`], additionally backfilling an empty
557/// journal from `parent_of` before the CAS-write + append — all inside
558/// the SAME `refs-history.lock` acquisition (issue #638 / INV-18).
559///
560/// `parent_of` is the same walker shape [`crate::history::rebuild_from_chain`]
561/// takes: given a commit hash, `Ok(Some(parent))`, `Ok(None)` at the
562/// root, or `Err(_)` on lookup failure. `mkit-cli`'s
563/// `write_ref_recording_history` is the production caller — it has the
564/// `ObjectStore` access this module deliberately doesn't depend on.
565///
566/// Moving the empty-journal check and the backfill loop inside the
567/// lock (rather than running them before it, as `write_ref_recording_history`
568/// used to) closes the race this function's sibling could not: two
569/// ref-only writers on the same never-before-journaled branch (e.g.
570/// two concurrent `update-ref` calls, which deliberately skip the
571/// worktree lock) used to both observe an empty journal and both
572/// independently backfill + append, writing to overlapping journal
573/// positions from two disagreeing in-memory MMR states. Now only the
574/// first to acquire the lock backfills; the second reopens onto an
575/// already-nonempty journal and proceeds straight to its own append.
576///
577/// # Errors
578///
579/// Same as [`update_ref_with_history`], plus: the backfill's
580/// [`crate::history::RebuildError`] (propagated from `parent_of` or
581/// the underlying append) is surfaced via a `String` payload on
582/// [`RefError::InvalidRef`], for the same signature-stability reason
583/// documented on [`update_ref_with_history`].
584#[cfg(feature = "history-mmr")]
585pub fn update_ref_with_history_and_backfill<X, F, E>(
586    layout: &RepoLayout,
587    branch: &str,
588    condition: RefWriteCondition,
589    hash: &Hash,
590    history: &mut crate::history::CommitHistory<X>,
591    mut parent_of: F,
592) -> RefResult<()>
593where
594    X: crate::protocol::async_shim::Executor + 'static,
595    F: FnMut(&Hash) -> Result<Option<Hash>, E>,
596    E: core::fmt::Display,
597{
598    update_ref_with_history_locked(
599        layout,
600        branch,
601        condition,
602        hash,
603        history,
604        |history, current| {
605            crate::history::rebuild_from_chain(history, current, &mut parent_of)
606                .map(|_| ())
607                .map_err(|e| e.to_string())
608        },
609    )
610}
611
612/// Shared critical section for [`update_ref_with_history`] and
613/// [`update_ref_with_history_and_backfill`].
614///
615/// `on_empty_journal` is called, still under the lock and after
616/// `history` has been [`crate::history::CommitHistory::reopen`]'d, iff
617/// the journal is empty AND `branch` already has a ref value on disk
618/// (`current`). It is the ONLY difference between the two public
619/// entry points: the no-backfill caller passes a no-op, the
620/// backfilling caller passes a [`crate::history::rebuild_from_chain`]
621/// invocation. Everything else — lock acquisition, reopen, the
622/// one-ahead-gap heal, the CAS-write, the final append — is identical
623/// and runs exactly once, inside the same lock.
624#[cfg(feature = "history-mmr")]
625fn update_ref_with_history_locked<X, G>(
626    layout: &RepoLayout,
627    branch: &str,
628    condition: RefWriteCondition,
629    hash: &Hash,
630    history: &mut crate::history::CommitHistory<X>,
631    mut on_empty_journal: G,
632) -> RefResult<()>
633where
634    X: crate::protocol::async_shim::Executor + 'static,
635    G: FnMut(&mut crate::history::CommitHistory<X>, Hash) -> Result<(), String>,
636{
637    // Defence-in-depth: history must be a journaled flavour;
638    // a mem-only flavour would silently drop the appended leaf on
639    // process exit, defeating the whole point of this coupling.
640    let Some(history_dir) = history.common_dir() else {
641        return Err(RefError::InvalidRef(format!(
642            "{branch}: update_ref_with_history requires a journaled CommitHistory (open_at)"
643        )));
644    };
645    if history_dir != layout.common_dir() {
646        return Err(RefError::InvalidRef(format!(
647            "{branch}: CommitHistory's common dir does not match the ref's common dir"
648        )));
649    }
650    if history.branch() != Some(branch) {
651        return Err(RefError::InvalidRef(format!(
652            "{branch}: CommitHistory was opened for a different branch ({:?})",
653            history.branch()
654        )));
655    }
656
657    // Per-branch lock around the ref-write + MMR-append (and, for the
658    // backfilling entry point, the empty-journal check and the
659    // backfill loop itself) critical section. Cross-process
660    // interleaving on THIS branch is impossible while any holder owns
661    // this lock. Scoped per branch (not repo-wide) since nothing about
662    // the history-mmr coupling spans branches — each branch's journal
663    // is its own partition — so an operation on branch A must never
664    // contend with one on unrelated branch B (found during the
665    // epic-#634 code review; matters in practice for linked worktrees,
666    // #493, where different worktrees routinely advance different
667    // branches at the same time).
668    let lock_name = history_lock_name(branch);
669    let _lock = crate::repo_lock::acquire_default(layout.common_dir(), &lock_name).map_err(
670        |e| match e {
671            crate::repo_lock::LockError::Io(io) => RefError::Io(io),
672            other => RefError::InvalidRef(format!("{branch}: lock acquisition: {other}")),
673        },
674    )?;
675
676    // `history` may have been opened (its `CommitHistory::open_at`
677    // bootstrap read the on-disk journal) before this call took the
678    // lock above. Re-derive it from the current on-disk state now that
679    // we hold the lock, so a concurrent writer's append in that window
680    // can't be appended over (SPEC-HISTORY-PROOF §4.3).
681    history
682        .reopen()
683        .map_err(|e| RefError::InvalidRef(format!("{branch}: history reopen: {e}")))?;
684
685    if let Some(current) = read_ref(layout, branch)? {
686        if history.is_empty() {
687            // v0.1.x-style migration (or a crash on this branch's very
688            // first tracked write): the ref already has a value but the
689            // journal has never been touched. Backfill (a no-op for the
690            // non-backfilling entry point) before proceeding, all still
691            // under the lock so no concurrent writer can also observe
692            // "empty" and race this same backfill.
693            on_empty_journal(history, current)
694                .map_err(|e| RefError::InvalidRef(format!("{branch}: history backfill: {e}")))?;
695        } else {
696            // Crash recovery (§4.5): if a prior `update_ref_with_history`
697            // call CAS-wrote the ref but crashed before its own append +
698            // sync, the ref is one commit ahead of the journal. Detect
699            // that by checking whether the journal's last leaf already
700            // covers the ref's CURRENT (pre-this-write) value, and heal
701            // by appending it directly — we know exactly which hash is
702            // missing without walking any parent chain, because it's
703            // precisely the value already sitting in the ref file.
704            heal_one_ahead_gap(history, &current)
705                .map_err(|e| RefError::InvalidRef(format!("{branch}: history recovery: {e}")))?;
706        }
707    }
708
709    update_ref(layout, branch, condition, hash)?;
710    history
711        .append(hash)
712        .map_err(|e| RefError::InvalidRef(format!("{branch}: history append: {e}")))?;
713    Ok(())
714}
715
716/// Heal a journal that is missing its last append relative to
717/// `current_ref_value` (SPEC-HISTORY-PROOF §4.5 crash-recovery case).
718///
719/// If the journal is non-empty, checks whether its last leaf already
720/// verifies as `current_ref_value` via an inclusion proof against the
721/// journal's own root. A verified match means the journal is already
722/// in sync — nothing to do. A failed or unbuildable proof means the
723/// journal's last leaf is stale (or the journal has one fewer leaf
724/// than the ref implies); append `current_ref_value` directly to catch
725/// it up.
726///
727/// An empty journal is left untouched here: it is ambiguous between a
728/// genuinely fresh branch (whose next real write supplies the correct
729/// first leaf) and a deep v0.1.x-style backfill that only a
730/// [`rebuild_from_chain`] with real parent-chain data can resolve —
731/// that migration path needs `ObjectStore` access this module doesn't
732/// have, so it is the caller's (`mkit-cli`) responsibility.
733///
734/// # Errors
735///
736/// Propagates [`HistoryError`] from [`CommitHistory::append`].
737#[cfg(feature = "history-mmr")]
738fn heal_one_ahead_gap<X: crate::protocol::async_shim::Executor + 'static>(
739    history: &mut crate::history::CommitHistory<X>,
740    current_ref_value: &Hash,
741) -> Result<(), crate::history::HistoryError> {
742    let len = history.len();
743    let Some(last) = len.checked_sub(1) else {
744        return Ok(());
745    };
746    let in_sync = history
747        .prove(crate::history::Position(last))
748        .is_ok_and(|proof| {
749            crate::history::verify_inclusion(
750                current_ref_value,
751                crate::history::Position(last),
752                &proof,
753                &history.root(),
754            )
755        });
756    if in_sync {
757        return Ok(());
758    }
759    history.append(current_ref_value)?;
760    Ok(())
761}
762
763/// Delete a branch ref. Errors with [`RefError::NotFound`] if absent.
764pub fn delete_ref(layout: &RepoLayout, branch: &str) -> RefResult<()> {
765    if !validate_ref_name(branch) {
766        return Err(RefError::InvalidRefName(branch.to_string()));
767    }
768    let path = ref_path(layout.common_dir(), HEADS_DIR, branch);
769    match fs::remove_file(&path) {
770        Ok(()) => Ok(()),
771        Err(e) if e.kind() == io::ErrorKind::NotFound => {
772            Err(RefError::NotFound(branch.to_string()))
773        }
774        Err(e) => Err(RefError::Io(e)),
775    }
776}
777
778/// Delete a branch ref unless it is the currently checked-out branch.
779pub fn delete_ref_safe(layout: &RepoLayout, branch: &str) -> RefResult<()> {
780    match read_head(layout) {
781        Ok(Head::Branch(current)) if current == branch => {
782            Err(RefError::CurrentBranch(branch.to_string()))
783        }
784        _ => delete_ref(layout, branch),
785    }
786}
787
788/// CAS-guarded delete: removes a branch ref only if its current on-disk
789/// value is exactly `expected`. Issue #658.
790///
791/// [`delete_ref`] is unconditional — it removes whatever is at `path`
792/// regardless of what a caller last read. That is fine for the
793/// user-initiated `branch -d`/`-D` path (deleting a specific named
794/// branch is meaningful even if its tip moved since the user last
795/// looked), but it is NOT fine for `branch -m`'s rename: a rename reads
796/// the source branch's tip, publishes it under the new name, and then
797/// drops the source ref. If a concurrent `commit` (via
798/// [`RefWriteCondition::Match`], see `mkit-cli`'s `advance_head`)
799/// advances the source branch in the window between the rename's read
800/// and its delete, an unconditional delete destroys that freshly-landed
801/// commit's only ref with no error to either caller — commit reports
802/// success, rename reports success, and the commit becomes unreachable.
803///
804/// This closes that gap by making the delete itself compare-and-swap:
805/// it acquires the SAME per-ref lock `cas_write`'s `Match` arm takes
806/// (via `cas_lock_name`, keyed off the ref's path so it can never
807/// collide with an unrelated ref of the same bare name), reads the
808/// current value under that lock, and only removes the file if it is
809/// still exactly `expected`. Because a concurrent `Match`-conditioned
810/// advance on the SAME ref takes the identical lock, the two can never
811/// interleave: either the advance's CAS write lands first (this call
812/// then sees the new value, doesn't match, and errors `Conflict`
813/// without touching the file) or this delete lands first (the advance's
814/// subsequent `Match(expected)` then sees the ref gone and itself fails
815/// `Conflict`) — never both "succeeding" against the same prior state.
816///
817/// Note this only closes the race against OTHER `Match`-locked writers.
818/// [`RefWriteCondition::Any`] writers never take this lock (by design —
819/// `Any` has no precondition to protect), so an `Any` advance racing a
820/// conditioned delete on the same ref is a caller error the lock cannot
821/// paper over; `mkit-cli`'s `commit` must use `Match`/`Missing` (not
822/// `Any`) for this guarantee to hold end-to-end (see issue #658's Fix
823/// B, `advance_head`).
824///
825/// # Errors
826/// - [`RefError::InvalidRefName`] if `branch` is not a valid name.
827/// - [`RefError::Conflict`] if the ref's current value is not exactly
828///   `expected` — this includes the ref not existing at all. The ref
829///   file is left completely untouched in this case.
830/// - [`RefError::Io`] for filesystem or lock-acquisition failures.
831pub fn delete_ref_if_matches(layout: &RepoLayout, branch: &str, expected: Hash) -> RefResult<()> {
832    if !validate_ref_name(branch) {
833        return Err(RefError::InvalidRefName(branch.to_string()));
834    }
835    let common_dir = layout.common_dir();
836    let path = ref_path(common_dir, HEADS_DIR, branch);
837
838    let lock_name = cas_lock_name(common_dir, &path);
839    let _lock = crate::repo_lock::acquire_default(common_dir, &lock_name).map_err(|e| match e {
840        crate::repo_lock::LockError::Io(io) => RefError::Io(io),
841        other => RefError::InvalidRef(format!("{branch}: refs.lock acquisition: {other}")),
842    })?;
843
844    let current = match fs::read(&path) {
845        Ok(b) => Some(decode_ref_wire(&b).ok_or_else(|| RefError::InvalidRef(branch.to_string()))?),
846        Err(e) if e.kind() == io::ErrorKind::NotFound => None,
847        Err(e) => return Err(RefError::Io(e)),
848    };
849    if current != Some(expected) {
850        return Err(RefError::Conflict(branch.to_string()));
851    }
852    match fs::remove_file(&path) {
853        Ok(()) => Ok(()),
854        // Another caller can't have raced us here — we hold the lock
855        // guarding every write AND delete path to this ref — but treat
856        // a vanished file as a conflict rather than success, matching
857        // this function's fail-closed contract instead of assuming.
858        Err(e) if e.kind() == io::ErrorKind::NotFound => {
859            Err(RefError::Conflict(branch.to_string()))
860        }
861        Err(e) => Err(RefError::Io(e)),
862    }
863}
864
865// -----------------------------------------------------------------------------
866// History-coupled branch delete (feature: history-mmr) — issue #648
867// -----------------------------------------------------------------------------
868//
869// Deleting a branch ref alone leaves its `history/<branch>__*` journal
870// partition on disk. Since the partition is keyed on the branch NAME
871// (sanitized, not on any per-incarnation identifier), a later branch
872// created with the same name reopens the dead incarnation's non-empty
873// journal via `CommitHistory::open_at` and resumes appending on top of
874// its old leaves — the new branch's MMR root then spans two unrelated
875// incarnations, and the deleted incarnation's stale leaves keep
876// producing valid-looking inclusion proofs "on this branch". These
877// functions close that hole by destroying the journal as part of the
878// same delete, so a branch name and its journal are always retired
879// together.
880
881/// Delete a branch ref and permanently destroy its history-MMR journal
882/// partition, so a later branch created with the same name never
883/// resumes a deleted incarnation's leaves (issue #648).
884///
885/// Order: the journal is destroyed **before** the ref file is removed.
886/// That means once this call returns `Ok`, both are gone together with
887/// no window in between. If the process is interrupted between the two
888/// steps, the ref still exists (the caller sees this call never
889/// returned, so the delete visibly did not complete) but its journal is
890/// already gone; retrying re-creates a fresh empty journal via
891/// `CommitHistory::open_at`, destroys that (a cheap no-op — nothing was
892/// appended to it), and finishes the ref removal. There is no ordering
893/// under which `delete_ref` can report success while the old journal
894/// survives on disk, which is exactly the state that would let a
895/// same-named recreated branch inherit it.
896///
897/// Does not check whether `branch` is the currently checked-out branch
898/// — see [`delete_ref_safe_with_history`] for that guard. Used directly
899/// by `mkit branch -m` (rename), which intentionally deletes the OLD
900/// name's ref even when it is the checked-out branch (HEAD is moved to
901/// the new name by the caller).
902///
903/// # Errors
904///
905/// - [`RefError::NotFound`] — `branch` has no ref on disk. Checked
906///   before the journal is touched, so a typo'd/absent branch name
907///   never creates a journal partition just to destroy it.
908/// - [`RefError::InvalidRef`] — the history journal could not be opened
909///   or destroyed. The wrapped [`crate::history::HistoryError`] is
910///   exposed via a `String` payload, matching
911///   [`update_ref_with_history`]'s convention.
912/// - [`RefError::Io`] — filesystem failure removing the ref file
913///   itself, or acquiring the per-branch history lock.
914///
915/// # Locking
916///
917/// Runs the read-check + journal-destroy + ref-delete sequence under
918/// the same per-branch `refs-history-<branch>.lock`
919/// `update_ref_with_history_locked` uses (found during code review
920/// after #638 landed: this function originally ran unlocked, which
921/// reopened exactly the race #638 closed one layer up — a concurrent
922/// ref-only writer that deliberately skips the worktree lock, e.g.
923/// `update-ref`, could interleave its journal append with this
924/// delete's journal destroy). Held for the whole sequence, not just
925/// the destroy, so a concurrent `update_ref_with_history*` call on the
926/// SAME branch either fully precedes or fully follows this delete —
927/// never interleaves with it. Scoped per branch (not repo-wide), so
928/// this never contends with an operation on an unrelated branch.
929#[cfg(feature = "history-mmr")]
930pub fn delete_ref_with_history<X: crate::protocol::async_shim::Executor + 'static>(
931    layout: &RepoLayout,
932    branch: &str,
933    executor: std::sync::Arc<X>,
934) -> RefResult<()> {
935    let lock_name = history_lock_name(branch);
936    let _lock = crate::repo_lock::acquire_default(layout.common_dir(), &lock_name).map_err(
937        |e| match e {
938            crate::repo_lock::LockError::Io(io) => RefError::Io(io),
939            other => RefError::InvalidRef(format!("{branch}: lock acquisition: {other}")),
940        },
941    )?;
942
943    if read_ref(layout, branch)?.is_none() {
944        return Err(RefError::NotFound(branch.to_string()));
945    }
946
947    let history = crate::history::CommitHistory::open_at(executor, layout, branch)
948        .map_err(|e| RefError::InvalidRef(format!("{branch}: open history journal: {e}")))?;
949    history
950        .destroy()
951        .map_err(|e| RefError::InvalidRef(format!("{branch}: destroy history journal: {e}")))?;
952
953    delete_ref(layout, branch)
954}
955
956/// [`delete_ref_with_history`], but CAS-guarded like
957/// [`delete_ref_if_matches`]: only deletes (and destroys the journal of)
958/// `branch` if its current on-disk value is exactly `expected`. Issue
959/// #658 — the `history-mmr` counterpart `mkit branch -m` routes through
960/// so a rename's rollback of a lost race also drops the correct
961/// journal.
962///
963/// # Locking
964///
965/// Lock order MUST stay `history_lock_name` (outer), then
966/// [`delete_ref_if_matches`]'s `cas_lock_name` (inner) — the exact order
967/// `update_ref_with_history_locked` already uses (it calls into
968/// [`update_ref`], which calls `cas_write`, while still holding
969/// `history_lock_name`). Reversing this for just this one caller would
970/// open a deadlock class between this delete and a concurrent
971/// `update_ref_with_history*` call on the same branch — two locks
972/// acquired in opposite orders by different call paths is exactly the
973/// shape a deadlock needs, even though today's callers happen not to
974/// hold both at once. Since `history_lock_name` is taken first and held
975/// for this call's entire body, no concurrent history-mmr writer on
976/// THIS branch can interleave with the check below — so it is safe to
977/// read-then-act non-atomically with respect to those callers; the
978/// still-needed atomicity against a plain (non-history) `Match` writer
979/// on the same ref (relevant on a build with `history-mmr` racing one
980/// without it, or the file transport) is what `cas_lock_name` inside
981/// [`delete_ref_if_matches`] itself continues to provide.
982///
983/// The CAS condition is checked BEFORE the journal is touched (unlike
984/// [`delete_ref_with_history`]'s unconditional destroy-then-delete
985/// ordering): destroying the journal first and only then discovering
986/// the delete itself must fail would leave a live ref with a destroyed
987/// journal — precisely the torn state
988/// `delete_ref_with_history_races_update_without_tearing_ref_and_journal`
989/// guards against. A failed call here leaves BOTH the ref and its
990/// journal completely untouched.
991///
992/// # Errors
993///
994/// - [`RefError::Conflict`] — `branch`'s current value is not exactly
995///   `expected` (including "does not exist"). Neither the ref nor its
996///   journal are touched.
997/// - Otherwise the same errors as [`delete_ref_with_history`].
998#[cfg(feature = "history-mmr")]
999pub fn delete_ref_with_history_if_matches<X: crate::protocol::async_shim::Executor + 'static>(
1000    layout: &RepoLayout,
1001    branch: &str,
1002    expected: Hash,
1003    executor: std::sync::Arc<X>,
1004) -> RefResult<()> {
1005    let lock_name = history_lock_name(branch);
1006    let _lock = crate::repo_lock::acquire_default(layout.common_dir(), &lock_name).map_err(
1007        |e| match e {
1008            crate::repo_lock::LockError::Io(io) => RefError::Io(io),
1009            other => RefError::InvalidRef(format!("{branch}: lock acquisition: {other}")),
1010        },
1011    )?;
1012
1013    match read_ref(layout, branch)? {
1014        Some(current) if current == expected => {}
1015        _ => return Err(RefError::Conflict(branch.to_string())),
1016    }
1017
1018    let history = crate::history::CommitHistory::open_at(executor, layout, branch)
1019        .map_err(|e| RefError::InvalidRef(format!("{branch}: open history journal: {e}")))?;
1020    history
1021        .destroy()
1022        .map_err(|e| RefError::InvalidRef(format!("{branch}: destroy history journal: {e}")))?;
1023
1024    // Re-checks the condition under `cas_lock_name` before removing the
1025    // file — redundant with the read above given `history_lock_name` is
1026    // still held (nothing on this branch could have moved it), but this
1027    // keeps a single source of truth for "delete iff still `expected`"
1028    // rather than duplicating `fs::remove_file` here.
1029    delete_ref_if_matches(layout, branch, expected)
1030}
1031
1032/// [`delete_ref_with_history`] guarded by the same current-branch check
1033/// as [`delete_ref_safe`] — refuses to delete (and does not touch the
1034/// journal of) the branch HEAD currently points at. This is the
1035/// history-mmr counterpart `mkit branch -d`/`-D` route through.
1036///
1037/// # Errors
1038///
1039/// [`RefError::CurrentBranch`] if `branch` is the checked-out branch;
1040/// otherwise the same errors as [`delete_ref_with_history`].
1041#[cfg(feature = "history-mmr")]
1042pub fn delete_ref_safe_with_history<X: crate::protocol::async_shim::Executor + 'static>(
1043    layout: &RepoLayout,
1044    branch: &str,
1045    executor: std::sync::Arc<X>,
1046) -> RefResult<()> {
1047    match read_head(layout) {
1048        Ok(Head::Branch(current)) if current == branch => {
1049            Err(RefError::CurrentBranch(branch.to_string()))
1050        }
1051        _ => delete_ref_with_history(layout, branch, executor),
1052    }
1053}
1054
1055/// List all branch refs, sorted lexicographically by name.
1056pub fn list_refs(layout: &RepoLayout) -> RefResult<Vec<Ref>> {
1057    list_refs_under(layout.common_dir(), HEADS_DIR)
1058}
1059
1060// -----------------------------------------------------------------------------
1061// Remote-tracking refs (refs/remotes/<remote>/<branch>)
1062// -----------------------------------------------------------------------------
1063
1064/// Read a remote-tracking branch ref.
1065pub fn read_remote_ref(layout: &RepoLayout, remote: &str, branch: &str) -> RefResult<Option<Hash>> {
1066    validate_remote_and_branch(remote, branch)?;
1067    read_ref_under(layout.common_dir(), &remote_ref_dir(remote), branch)
1068}
1069
1070/// Write a remote-tracking branch ref unconditionally.
1071pub fn write_remote_ref(
1072    layout: &RepoLayout,
1073    remote: &str,
1074    branch: &str,
1075    h: &Hash,
1076) -> RefResult<()> {
1077    validate_remote_and_branch(remote, branch)?;
1078    let path = ref_path(layout.common_dir(), &remote_ref_dir(remote), branch);
1079    let wire = encode_ref_wire(h);
1080    cas_write(
1081        layout.common_dir(),
1082        &path,
1083        &wire,
1084        branch,
1085        RefWriteCondition::Any,
1086    )
1087}
1088
1089/// Batched writer for remote-tracking refs (#645): amortises the
1090/// parent-directory fsync across every ref written into it, instead of
1091/// paying one per ref like [`write_remote_ref`] in a loop.
1092///
1093/// `push`/`fetch` publish one tracking ref per branch
1094/// (`refs/remotes/<remote>/<branch>`) in a loop; each individual
1095/// [`write_remote_ref`] call goes through `write_atomic`'s
1096/// temp+fsync+rename+**dirsync** pattern, so N branches cost N serial
1097/// directory fsyncs even though every write lands under the same
1098/// `refs/remotes/<remote>/` tree. `RemoteRefBatch` instead:
1099///
1100/// 1. [`Self::write`]s each ref durable-content-before-visible (fsyncs
1101///    the wire bytes, then renames — same invariant
1102///    `crate::atomic::write_content_synced` gives object writes: a
1103///    reader can never observe a torn file), immediately, one call at a
1104///    time, deferring only the directory fsync that makes the RENAME
1105///    itself crash-durable;
1106/// 2. [`Self::commit`] fsyncs every distinct directory touched, once
1107///    each, deduplicated — O(distinct directories) instead of O(refs).
1108///    For the common case (one remote, flat branch names) that is
1109///    exactly one fsync for the whole batch.
1110///
1111/// This is deliberately scoped to `refs/remotes/*` ONLY — see
1112/// `atomic.rs`'s module docs for why branch heads (`refs/heads/*`,
1113/// [`write_ref`]/[`update_ref`]) keep the unbatched per-write contract:
1114/// tracking refs are a locally-cached, recomputable-from-a-re-fetch
1115/// view of another repository, not a pointer anything else orders
1116/// against.
1117///
1118/// # Partial-failure semantics
1119///
1120/// Best-effort, fail-fast — the same contract
1121/// [`crate::batch::WriteBatch::commit`] documents for the object store's
1122/// batched writes. [`Self::write`] renames each ref as soon as it
1123/// validates and its content is durable, so a ref that made it through
1124/// `write` is visible to readers immediately, exactly as it would have
1125/// been under the old per-ref loop. If a later `write` in the same
1126/// batch fails (bad name or I/O error), earlier successful writes are
1127/// **not** rolled back — content-addressed dedup isn't in play here,
1128/// but the same reasoning applies: remote-tracking refs are
1129/// recomputable, so a partially-applied batch is exactly as safe to
1130/// retry as the old loop was after failing at the same point. Callers
1131/// that want the successful prefix to be durable even when the batch as
1132/// a whole errors out should call [`Self::commit`] regardless of
1133/// whether the write loop returned early (see
1134/// `remote_dispatch::push_all_with` / `fetch_objects_inner` for the
1135/// pattern).
1136#[derive(Debug)]
1137pub struct RemoteRefBatch<'a> {
1138    layout: &'a RepoLayout,
1139    sub_dir: String,
1140    touched_dirs: BTreeSet<PathBuf>,
1141}
1142
1143impl<'a> RemoteRefBatch<'a> {
1144    /// Start a batch for `remote`.
1145    ///
1146    /// # Errors
1147    /// [`RefError::InvalidRefName`] if `remote` fails [`validate_ref_name`].
1148    pub fn new(layout: &'a RepoLayout, remote: &str) -> RefResult<Self> {
1149        if !validate_ref_name(remote) {
1150            return Err(RefError::InvalidRefName(remote.to_string()));
1151        }
1152        Ok(Self {
1153            layout,
1154            sub_dir: remote_ref_dir(remote),
1155            touched_dirs: BTreeSet::new(),
1156        })
1157    }
1158
1159    /// Write one remote-tracking ref: validates `branch`, fsyncs its
1160    /// wire-encoded content, then renames it into place — visible
1161    /// immediately, same as [`write_remote_ref`]. Only the directory
1162    /// fsync (rename durability) is deferred to [`Self::commit`].
1163    ///
1164    /// # Errors
1165    /// - [`RefError::InvalidRefName`] if `branch` fails
1166    ///   [`validate_ref_name`] — no I/O is attempted for an invalid name.
1167    /// - [`RefError::Io`] for filesystem failures.
1168    ///
1169    /// # Panics
1170    /// Never in practice: `ref_path` always produces a path with a
1171    /// parent (it is `self.layout.common_dir()` joined with at least the
1172    /// remote's subdirectory and the branch name).
1173    pub fn write(&mut self, branch: &str, h: &Hash) -> RefResult<()> {
1174        if !validate_ref_name(branch) {
1175            return Err(RefError::InvalidRefName(branch.to_string()));
1176        }
1177        let path = ref_path(self.layout.common_dir(), &self.sub_dir, branch);
1178        let parent = path
1179            .parent()
1180            .expect("remote-tracking ref path always has a parent")
1181            .to_path_buf();
1182        fs::create_dir_all(&parent)?;
1183        let wire = encode_ref_wire(h);
1184        crate::atomic::write_content_synced(&path, &wire)?;
1185        self.touched_dirs.insert(parent);
1186        Ok(())
1187    }
1188
1189    /// Fsync every directory touched by a completed [`Self::write`],
1190    /// once each. Idempotent to call on a batch with nothing staged (a
1191    /// no-op). MUST be called after the last `write` a caller intends to
1192    /// make durable — refs written but never committed are visible but
1193    /// not crash-durable (the rename may not have reached stable
1194    /// storage), the same window [`crate::store::BulkWriter::commit`]
1195    /// documents for bulk object writes.
1196    ///
1197    /// # Errors
1198    /// [`RefError::Io`] on the first directory fsync failure. Directories
1199    /// are synced in sorted order for determinism; a failure partway
1200    /// through leaves the remaining directories un-synced (best-effort,
1201    /// matching [`Self::write`]'s partial-failure contract).
1202    pub fn commit(self) -> RefResult<()> {
1203        for dir in &self.touched_dirs {
1204            crate::atomic::sync_dir(dir)?;
1205        }
1206        Ok(())
1207    }
1208}
1209
1210/// Delete a remote-tracking branch ref (e.g. after the upstream
1211/// deleted the branch). Errors with [`RefError::NotFound`] if absent.
1212pub fn delete_remote_ref(layout: &RepoLayout, remote: &str, branch: &str) -> RefResult<()> {
1213    validate_remote_and_branch(remote, branch)?;
1214    let path = ref_path(layout.common_dir(), &remote_ref_dir(remote), branch);
1215    match fs::remove_file(&path) {
1216        Ok(()) => Ok(()),
1217        Err(e) if e.kind() == io::ErrorKind::NotFound => {
1218            Err(RefError::NotFound(format!("{remote}/{branch}")))
1219        }
1220        Err(e) => Err(RefError::Io(e)),
1221    }
1222}
1223
1224/// List all remote-tracking refs for one remote.
1225pub fn list_remote_refs(layout: &RepoLayout, remote: &str) -> RefResult<Vec<Ref>> {
1226    if !validate_ref_name(remote) {
1227        return Err(RefError::InvalidRefName(remote.to_string()));
1228    }
1229    list_refs_under(layout.common_dir(), &remote_ref_dir(remote))
1230}
1231
1232/// List the remote names that have at least one tracking ref on disk
1233/// (the immediate subdirectories of `refs/remotes/`), sorted. A
1234/// missing `refs/remotes/` yields an empty list. Entries whose names
1235/// fail the ref grammar are skipped (consistent with how malformed
1236/// ref files are skipped by [`list_refs`]).
1237pub fn list_remote_names(layout: &RepoLayout) -> RefResult<Vec<String>> {
1238    let dir = layout.remotes_dir();
1239    let entries = match fs::read_dir(&dir) {
1240        Ok(e) => e,
1241        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
1242        Err(e) => return Err(RefError::Io(e)),
1243    };
1244    let mut names = Vec::new();
1245    for entry in entries {
1246        let entry = entry.map_err(RefError::Io)?;
1247        if !entry.file_type().map_err(RefError::Io)?.is_dir() {
1248            continue;
1249        }
1250        if let Some(name) = entry.file_name().to_str()
1251            && validate_ref_name(name)
1252        {
1253            names.push(name.to_owned());
1254        }
1255    }
1256    names.sort();
1257    Ok(names)
1258}
1259
1260// -----------------------------------------------------------------------------
1261// Tags (refs/tags/<name>)
1262// -----------------------------------------------------------------------------
1263
1264/// Read the hash a tag points to.
1265pub fn read_tag(layout: &RepoLayout, name: &str) -> RefResult<Option<Hash>> {
1266    if !validate_ref_name(name) {
1267        return Err(RefError::InvalidRefName(name.to_string()));
1268    }
1269    read_ref_under(layout.common_dir(), TAGS_DIR, name)
1270}
1271
1272/// Write a tag ref (unconditional).
1273pub fn write_tag(layout: &RepoLayout, name: &str, h: &Hash) -> RefResult<()> {
1274    update_tag(layout, name, RefWriteCondition::Any, h)
1275}
1276
1277/// CAS-aware tag write — same semantics as [`update_ref`] but for
1278/// `refs/tags/`.
1279pub fn update_tag(
1280    layout: &RepoLayout,
1281    name: &str,
1282    condition: RefWriteCondition,
1283    h: &Hash,
1284) -> RefResult<()> {
1285    if !validate_ref_name(name) {
1286        return Err(RefError::InvalidRefName(name.to_string()));
1287    }
1288    let path = ref_path(layout.common_dir(), TAGS_DIR, name);
1289    let wire = encode_ref_wire(h);
1290    cas_write(layout.common_dir(), &path, &wire, name, condition)
1291}
1292
1293/// Delete a tag ref.
1294pub fn delete_tag(layout: &RepoLayout, name: &str) -> RefResult<()> {
1295    if !validate_ref_name(name) {
1296        return Err(RefError::InvalidRefName(name.to_string()));
1297    }
1298    let path = ref_path(layout.common_dir(), TAGS_DIR, name);
1299    match fs::remove_file(&path) {
1300        Ok(()) => Ok(()),
1301        Err(e) if e.kind() == io::ErrorKind::NotFound => Err(RefError::NotFound(name.to_string())),
1302        Err(e) => Err(RefError::Io(e)),
1303    }
1304}
1305
1306/// List all tag refs, sorted lexicographically by name.
1307pub fn list_tags(layout: &RepoLayout) -> RefResult<Vec<Ref>> {
1308    list_refs_under(layout.common_dir(), TAGS_DIR)
1309}
1310
1311// -----------------------------------------------------------------------------
1312// Shallow boundaries (.mkit/shallow)
1313// -----------------------------------------------------------------------------
1314
1315/// Load shallow-boundary hashes from `.mkit/shallow`. Returns `Ok(None)`
1316/// if the file does not exist or is empty.
1317pub fn load_shallow_boundaries(layout: &RepoLayout) -> RefResult<Option<Vec<Hash>>> {
1318    let path = layout.shallow_file();
1319    let meta = match fs::metadata(&path) {
1320        Ok(m) => m,
1321        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
1322        Err(e) => return Err(RefError::Io(e)),
1323    };
1324    if meta.len() == 0 {
1325        return Ok(None);
1326    }
1327    if meta.len() > SHALLOW_MAX_BYTES {
1328        return Err(RefError::InvalidRef("shallow file too large".to_string()));
1329    }
1330    let bytes = fs::read(&path)?;
1331    let s = core::str::from_utf8(&bytes).map_err(|_| RefError::InvalidHead)?;
1332    let mut out = Vec::new();
1333    for line in s.split('\n') {
1334        let trimmed = line.trim_end_matches(['\r', ' ', '\t']);
1335        if trimmed.len() != HEX_LEN {
1336            continue;
1337        }
1338        if let Some(h) = parse_lowercase_hash(trimmed.as_bytes()) {
1339            out.push(h);
1340        }
1341    }
1342    if out.is_empty() {
1343        return Ok(None);
1344    }
1345    Ok(Some(out))
1346}
1347
1348/// Write shallow-boundary hashes to `.mkit/shallow`. Passing an empty
1349/// slice removes the file.
1350pub fn write_shallow_boundaries(layout: &RepoLayout, boundaries: &[Hash]) -> RefResult<()> {
1351    let path = layout.shallow_file();
1352    if boundaries.is_empty() {
1353        match fs::remove_file(&path) {
1354            Ok(()) => Ok(()),
1355            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
1356            Err(e) => Err(RefError::Io(e)),
1357        }
1358    } else {
1359        let mut out = Vec::with_capacity(boundaries.len() * 65);
1360        for h in boundaries {
1361            out.extend_from_slice(&encode_ref_wire(h));
1362        }
1363        write_atomic(&path, &out, true)?;
1364        Ok(())
1365    }
1366}
1367
1368// -----------------------------------------------------------------------------
1369// Internals
1370// -----------------------------------------------------------------------------
1371
1372fn ref_path(common_dir: &Path, sub_dir: &str, name: &str) -> PathBuf {
1373    let mut path = common_dir.join(sub_dir);
1374    for segment in name.split('/') {
1375        path.push(segment);
1376    }
1377    path
1378}
1379
1380fn remote_ref_dir(remote: &str) -> String {
1381    format!("{REMOTES_DIR}/{remote}")
1382}
1383
1384fn validate_remote_and_branch(remote: &str, branch: &str) -> RefResult<()> {
1385    if !validate_ref_name(remote) {
1386        return Err(RefError::InvalidRefName(remote.to_string()));
1387    }
1388    if !validate_ref_name(branch) {
1389        return Err(RefError::InvalidRefName(branch.to_string()));
1390    }
1391    Ok(())
1392}
1393
1394fn read_ref_under(common_dir: &Path, sub_dir: &str, name: &str) -> RefResult<Option<Hash>> {
1395    let path = ref_path(common_dir, sub_dir, name);
1396    let meta = match fs::metadata(&path) {
1397        Ok(m) => m,
1398        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
1399        Err(e) => return Err(RefError::Io(e)),
1400    };
1401    if meta.len() > REF_FILE_MAX_BYTES {
1402        return Err(RefError::InvalidRef(name.to_string()));
1403    }
1404    let bytes = fs::read(&path)?;
1405    let h = decode_ref_wire(&bytes).ok_or_else(|| RefError::InvalidRef(name.to_string()))?;
1406    Ok(Some(h))
1407}
1408
1409fn cas_write(
1410    common_dir: &Path,
1411    path: &Path,
1412    wire: &[u8; 65],
1413    name_for_err: &str,
1414    condition: RefWriteCondition,
1415) -> RefResult<()> {
1416    match condition {
1417        RefWriteCondition::Any => {
1418            write_atomic(path, wire, true)?;
1419            Ok(())
1420        }
1421        RefWriteCondition::Missing => {
1422            // O_EXCL — fail if anything is at `path` already.
1423            if let Some(parent) = path.parent() {
1424                fs::create_dir_all(parent)?;
1425            }
1426            let created = write_create_new(path, wire, true)?;
1427            if !created {
1428                return Err(RefError::Conflict(name_for_err.to_string()));
1429            }
1430            Ok(())
1431        }
1432        RefWriteCondition::Match(expected) => {
1433            // INV-15/#637: the read-compare-write below has no
1434            // filesystem-level atomicity of its own, and callers cannot
1435            // be relied on to already share a lock that covers this —
1436            // `branch -m` (`worktrees.lock`) racing `commit`
1437            // (`worktree.lock`), or two linked worktrees each holding
1438            // their own `worktree.lock`, both write `refs/heads/*`
1439            // through this path without ever sharing a lock. Take a
1440            // dedicated per-ref lock (same blocking kernel-lock
1441            // primitive `repo_lock` uses elsewhere, e.g.
1442            // `update_ref_with_history`'s per-branch `refs-history-*.lock`)
1443            // scoped to the whole read-compare-write so any two callers
1444            // on the same repo targeting the SAME ref — regardless of
1445            // what other locks they hold — serialize here and cannot
1446            // both observe a stale-but-still-matching `current` value.
1447            //
1448            // Keyed off `path` (relative to `common_dir`), not just
1449            // `name_for_err`, so a branch and a tag/remote-ref sharing
1450            // the same bare name (e.g. both named "main") get distinct
1451            // locks — `name_for_err` alone can be just the bare name
1452            // (see `write_remote_ref`), which would otherwise cause
1453            // unrelated ref kinds to falsely contend. Scoped per ref
1454            // (not repo-wide) since nothing about this CAS invariant
1455            // spans refs (found during the epic-#634 code review).
1456            let lock_name = cas_lock_name(common_dir, path);
1457            let _lock =
1458                crate::repo_lock::acquire_default(common_dir, &lock_name).map_err(|e| match e {
1459                    crate::repo_lock::LockError::Io(io) => RefError::Io(io),
1460                    other => RefError::InvalidRef(format!(
1461                        "{name_for_err}: refs.lock acquisition: {other}"
1462                    )),
1463                })?;
1464
1465            let current = match fs::read(path) {
1466                Ok(b) => Some(
1467                    decode_ref_wire(&b)
1468                        .ok_or_else(|| RefError::InvalidRef(name_for_err.to_string()))?,
1469                ),
1470                Err(e) if e.kind() == io::ErrorKind::NotFound => None,
1471                Err(e) => return Err(RefError::Io(e)),
1472            };
1473            if current != Some(expected) {
1474                return Err(RefError::Conflict(name_for_err.to_string()));
1475            }
1476            write_atomic(path, wire, true)?;
1477            Ok(())
1478        }
1479    }
1480}
1481
1482fn list_refs_under(common_dir: &Path, sub_dir: &str) -> RefResult<Vec<Ref>> {
1483    let root = common_dir.join(sub_dir);
1484    let mut out = Vec::new();
1485    if !root.is_dir() {
1486        return Ok(out);
1487    }
1488    collect_refs(&root, "", &mut out, 0)?;
1489    out.sort_by(|a, b| a.name.cmp(&b.name));
1490    Ok(out)
1491}
1492
1493/// Cap on ref-tree recursion depth. A malicious or corrupt `.mkit/refs/`
1494/// directory with deeply nested empty dirs should not stack-overflow
1495/// the walker. 32 is far beyond anything a valid ref name (which cannot
1496/// contain more than a few `/` separators given the 1–255 path-segment
1497/// grammar) could ever require.
1498const MAX_REF_DEPTH: usize = 32;
1499
1500fn collect_refs(root: &Path, prefix: &str, out: &mut Vec<Ref>, depth: usize) -> RefResult<()> {
1501    if depth > MAX_REF_DEPTH {
1502        // Silently stop — same "skip malformed" posture as below for
1503        // individual files. Callers get a partial result rather than a
1504        // stack overflow on adversarial input.
1505        return Ok(());
1506    }
1507    let dir_path = if prefix.is_empty() {
1508        root.to_path_buf()
1509    } else {
1510        root.join(prefix)
1511    };
1512    let iter = match fs::read_dir(&dir_path) {
1513        Ok(i) => i,
1514        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
1515        Err(e) => return Err(RefError::Io(e)),
1516    };
1517    for entry in iter {
1518        let entry = entry?;
1519        let file_name = match entry.file_name().to_str() {
1520            Some(s) => s.to_string(),
1521            None => continue, // Non-UTF-8 names cannot be valid ref names.
1522        };
1523        let child_name = if prefix.is_empty() {
1524            file_name.clone()
1525        } else {
1526            format!("{prefix}/{file_name}")
1527        };
1528        let ft = entry.file_type()?;
1529        if ft.is_dir() {
1530            collect_refs(root, &child_name, out, depth + 1)?;
1531            continue;
1532        }
1533        if !ft.is_file() {
1534            continue;
1535        }
1536        if !validate_ref_name(&child_name) {
1537            continue;
1538        }
1539        // Read & decode; silently skip malformed files.
1540        let Ok(bytes) = fs::read(entry.path()) else {
1541            continue;
1542        };
1543        let hash = decode_ref_wire(&bytes);
1544        out.push(Ref {
1545            name: child_name,
1546            hash,
1547        });
1548    }
1549    Ok(())
1550}
1551
1552// -----------------------------------------------------------------------------
1553// Internal hash helper re-exports for goldens
1554// -----------------------------------------------------------------------------
1555
1556/// Internal re-export used by the integration tests to hand-roll wire
1557/// bytes without round-tripping through `hash::from_hex`.
1558#[doc(hidden)]
1559#[must_use]
1560pub fn _hash_from_lowercase_hex_for_tests(s: &str) -> Option<Hash> {
1561    parse_lowercase_hash(s.as_bytes())
1562}
1563
1564#[cfg(test)]
1565mod tests {
1566    use super::*;
1567    use crate::hash;
1568    use std::sync::Barrier;
1569    use tempfile::TempDir;
1570
1571    fn fresh_repo() -> (TempDir, RepoLayout) {
1572        let dir = TempDir::new().unwrap();
1573        let layout = RepoLayout::single(dir.path());
1574        fs::create_dir_all(layout.common_dir()).unwrap();
1575        init(&layout).unwrap();
1576        (dir, layout)
1577    }
1578
1579    fn h(seed: &str) -> Hash {
1580        hash::hash(seed.as_bytes())
1581    }
1582
1583    // --- name grammar ---------------------------------------------------
1584
1585    #[test]
1586    fn validate_accepts_simple_names() {
1587        assert!(validate_ref_name("main"));
1588        assert!(validate_ref_name("feat/v1.0-beta"));
1589        assert!(validate_ref_name("release/2024_09"));
1590    }
1591
1592    #[test]
1593    fn validate_rejects_empty() {
1594        assert!(!validate_ref_name(""));
1595    }
1596
1597    #[test]
1598    fn validate_rejects_leading_slash() {
1599        assert!(!validate_ref_name("/main"));
1600    }
1601
1602    #[test]
1603    fn validate_rejects_dotdot_segment() {
1604        assert!(!validate_ref_name("feat/.."));
1605        assert!(!validate_ref_name("../escape"));
1606        assert!(!validate_ref_name("feat/./topic"));
1607    }
1608
1609    #[test]
1610    fn validate_rejects_dot_leading_segment() {
1611        // git's check-ref-format rule: no slash-separated component may
1612        // begin with '.'. This also inertifies crash debris parked at a
1613        // dot-leading directory (e.g. a `.rename.tmp.<pid>.<seq>` orphan)
1614        // as a ref name, not just the exact `.`/`..` shapes above.
1615        assert!(!validate_ref_name(".hidden"));
1616        assert!(!validate_ref_name("refs/.hidden/main"));
1617        assert!(!validate_ref_name(".rename.tmp.12345.0"));
1618        assert!(!validate_ref_name("refs/remotes/.rename.tmp.12345.0/main"));
1619    }
1620
1621    #[test]
1622    fn validate_rejects_double_slash() {
1623        assert!(!validate_ref_name("refs//heads/main"));
1624        assert!(!validate_ref_name("main/"));
1625    }
1626
1627    #[test]
1628    fn validate_rejects_disallowed_bytes() {
1629        assert!(!validate_ref_name("main@v1"));
1630        assert!(!validate_ref_name("feat\\branch"));
1631        assert!(!validate_ref_name("with space"));
1632    }
1633
1634    #[test]
1635    fn validate_rejects_lock_suffix() {
1636        assert!(!validate_ref_name("refs/heads/main.lock"));
1637    }
1638
1639    #[test]
1640    fn validate_rejects_head_final_segment() {
1641        assert!(!validate_ref_name("refs/heads/HEAD"));
1642        assert!(!validate_ref_name("HEAD"));
1643    }
1644
1645    #[test]
1646    fn validate_accepts_main_regression() {
1647        assert!(validate_ref_name("refs/heads/main"));
1648    }
1649
1650    #[test]
1651    fn validate_accepts_non_lock_suffix_regression() {
1652        // Only trailing ".lock" should reject; "lockfile" is fine.
1653        assert!(validate_ref_name("refs/heads/lockfile"));
1654    }
1655
1656    #[test]
1657    fn validate_accepts_headless_regression() {
1658        // Only the exact final segment "HEAD" is rejected.
1659        assert!(validate_ref_name("refs/heads/HEADless"));
1660    }
1661
1662    #[test]
1663    fn validate_prefix() {
1664        assert!(validate_ref_prefix(""));
1665        assert!(validate_ref_prefix("refs/heads/"));
1666        assert!(validate_ref_prefix("refs/heads"));
1667        assert!(!validate_ref_prefix("refs//heads/"));
1668        assert!(!validate_ref_prefix("/"));
1669    }
1670
1671    // --- wire encoding --------------------------------------------------
1672
1673    #[test]
1674    fn wire_round_trip() {
1675        let original = h("test-ref");
1676        let wire = encode_ref_wire(&original);
1677        assert_eq!(wire.len(), 65);
1678        assert_eq!(wire[64], b'\n');
1679        let parsed = decode_ref_wire(&wire).unwrap();
1680        assert_eq!(parsed, original);
1681    }
1682
1683    #[test]
1684    fn wire_rejects_uppercase() {
1685        let original = h("test-ref");
1686        let mut wire = encode_ref_wire(&original);
1687        // Upper-case the first letter we find; SPEC-REFS §1 forbids
1688        // uppercase hex on read.
1689        let mut flipped = false;
1690        for b in &mut wire[..HEX_LEN] {
1691            if (b'a'..=b'f').contains(b) {
1692                *b -= b'a' - b'A';
1693                flipped = true;
1694                break;
1695            }
1696        }
1697        assert!(flipped, "test fixture should contain at least one a-f");
1698        assert!(decode_ref_wire(&wire).is_none());
1699    }
1700
1701    #[test]
1702    fn wire_rejects_short_input() {
1703        let bad = b"deadbeef\n";
1704        assert!(decode_ref_wire(bad).is_none());
1705    }
1706
1707    #[test]
1708    fn wire_rejects_non_hex() {
1709        let mut wire = encode_ref_wire(&h("x"));
1710        wire[1] = b'g';
1711        assert!(decode_ref_wire(&wire).is_none());
1712    }
1713
1714    #[test]
1715    fn wire_tolerates_trailing_cr() {
1716        // Files round-tripped through Windows editors may pick up CRs.
1717        let original = h("eol");
1718        let mut buf = encode_ref_wire(&original).to_vec();
1719        buf.insert(64, b'\r');
1720        let parsed = decode_ref_wire(&buf).unwrap();
1721        assert_eq!(parsed, original);
1722    }
1723
1724    // --- HEAD ----------------------------------------------------------
1725
1726    #[test]
1727    fn init_writes_default_head() {
1728        let (_dir, mkit) = fresh_repo();
1729        let head = read_head(&mkit).unwrap();
1730        assert_eq!(head, Head::Branch("main".to_string()));
1731    }
1732
1733    #[test]
1734    fn write_and_read_branch_ref() {
1735        let (_dir, mkit) = fresh_repo();
1736        let commit = h("commit1");
1737        write_ref(&mkit, "main", &commit).unwrap();
1738        let read = read_ref(&mkit, "main").unwrap();
1739        assert_eq!(read, Some(commit));
1740    }
1741
1742    #[test]
1743    fn resolve_head_with_no_commits_returns_none() {
1744        let (_dir, mkit) = fresh_repo();
1745        assert_eq!(resolve_head(&mkit).unwrap(), None);
1746    }
1747
1748    #[test]
1749    fn resolve_head_after_commit() {
1750        let (_dir, mkit) = fresh_repo();
1751        let commit = h("commit1");
1752        write_ref(&mkit, "main", &commit).unwrap();
1753        assert_eq!(resolve_head(&mkit).unwrap(), Some(commit));
1754    }
1755
1756    #[test]
1757    fn update_head_updates_current_branch() {
1758        let (_dir, mkit) = fresh_repo();
1759        let h1 = h("c1");
1760        update_head(&mkit, &h1).unwrap();
1761        assert_eq!(resolve_head(&mkit).unwrap(), Some(h1));
1762        let h2 = h("c2");
1763        update_head(&mkit, &h2).unwrap();
1764        assert_eq!(resolve_head(&mkit).unwrap(), Some(h2));
1765    }
1766
1767    #[test]
1768    fn detached_head_round_trip() {
1769        let dir = TempDir::new().unwrap();
1770        let mkit = RepoLayout::single(dir.path());
1771        fs::create_dir_all(mkit.common_dir()).unwrap();
1772        let commit = h("detached");
1773        write_head_detached(&mkit, &commit).unwrap();
1774        match read_head(&mkit).unwrap() {
1775            Head::Detached(got) => assert_eq!(got, commit),
1776            other @ Head::Branch(_) => panic!("expected detached, got {other:?}"),
1777        }
1778        assert_eq!(resolve_head(&mkit).unwrap(), Some(commit));
1779    }
1780
1781    #[test]
1782    fn read_head_rejects_oversize_file() {
1783        // SPEC-REFS §6: HEAD content is capped at 4 KiB.
1784        let dir = TempDir::new().unwrap();
1785        let mkit = RepoLayout::single(dir.path());
1786        fs::create_dir_all(mkit.common_dir()).unwrap();
1787        fs::write(
1788            mkit.head_file(),
1789            vec![b'a'; usize::try_from(HEAD_MAX_BYTES).unwrap() + 1],
1790        )
1791        .unwrap();
1792        let err = read_head(&mkit).unwrap_err();
1793        assert!(matches!(err, RefError::InvalidHead));
1794    }
1795
1796    #[test]
1797    fn nonexistent_branch_returns_none() {
1798        let (_dir, mkit) = fresh_repo();
1799        assert_eq!(read_ref(&mkit, "nonexistent").unwrap(), None);
1800    }
1801
1802    #[test]
1803    fn read_ref_rejects_oversize_file() {
1804        // SPEC-REFS §6: a single ref file is capped at 128 bytes.
1805        let (_dir, mkit) = fresh_repo();
1806        let path = ref_path(mkit.common_dir(), HEADS_DIR, "main");
1807        fs::create_dir_all(path.parent().unwrap()).unwrap();
1808        fs::write(
1809            &path,
1810            vec![b'0'; usize::try_from(REF_FILE_MAX_BYTES).unwrap() + 1],
1811        )
1812        .unwrap();
1813        let err = read_ref(&mkit, "main").unwrap_err();
1814        assert!(matches!(err, RefError::InvalidRef(_)));
1815    }
1816
1817    #[test]
1818    fn list_refs_empty() {
1819        let (_dir, mkit) = fresh_repo();
1820        let refs = list_refs(&mkit).unwrap();
1821        assert!(refs.is_empty());
1822    }
1823
1824    #[test]
1825    fn list_refs_sorted() {
1826        let (_dir, mkit) = fresh_repo();
1827        write_ref(&mkit, "main", &h("m")).unwrap();
1828        write_ref(&mkit, "dev", &h("d")).unwrap();
1829        let refs = list_refs(&mkit).unwrap();
1830        assert_eq!(refs.len(), 2);
1831        assert_eq!(refs[0].name, "dev");
1832        assert_eq!(refs[1].name, "main");
1833    }
1834
1835    #[test]
1836    fn nested_refs_listed_recursively() {
1837        let (_dir, mkit) = fresh_repo();
1838        write_ref(&mkit, "feature/deep/topic", &h("nested")).unwrap();
1839        let refs = list_refs(&mkit).unwrap();
1840        assert_eq!(refs.len(), 1);
1841        assert_eq!(refs[0].name, "feature/deep/topic");
1842    }
1843
1844    #[test]
1845    fn list_refs_silently_skips_entries_beyond_max_depth() {
1846        // SPEC-REFS §6: listing recurses with a hard depth cap of 32
1847        // levels to defeat adversarial nesting; anything deeper is
1848        // silently skipped (not an error, not a stack overflow).
1849        let (_dir, mkit) = fresh_repo();
1850        let deep_name = (0..40)
1851            .map(|i| format!("d{i}"))
1852            .collect::<Vec<_>>()
1853            .join("/");
1854        write_ref(&mkit, &deep_name, &h("deep")).unwrap();
1855        write_ref(&mkit, "main", &h("shallow")).unwrap();
1856
1857        let refs = list_refs(&mkit).unwrap();
1858        let names: Vec<&str> = refs.iter().map(|r| r.name.as_str()).collect();
1859        assert!(names.contains(&"main"), "shallow ref must still be listed");
1860        assert!(
1861            !names.contains(&deep_name.as_str()),
1862            "a ref nested beyond MAX_REF_DEPTH must be silently skipped, got {names:?}"
1863        );
1864    }
1865
1866    #[test]
1867    fn delete_ref_basic() {
1868        let (_dir, mkit) = fresh_repo();
1869        write_ref(&mkit, "feature", &h("f")).unwrap();
1870        delete_ref(&mkit, "feature").unwrap();
1871        assert_eq!(read_ref(&mkit, "feature").unwrap(), None);
1872    }
1873
1874    #[test]
1875    fn delete_nonexistent_ref_errors() {
1876        let (_dir, mkit) = fresh_repo();
1877        let err = delete_ref(&mkit, "nope").unwrap_err();
1878        assert!(matches!(err, RefError::NotFound(_)));
1879    }
1880
1881    #[test]
1882    fn refuse_delete_current_branch() {
1883        let (_dir, mkit) = fresh_repo();
1884        write_ref(&mkit, "main", &h("m")).unwrap();
1885        let err = delete_ref_safe(&mkit, "main").unwrap_err();
1886        assert!(matches!(err, RefError::CurrentBranch(_)));
1887    }
1888
1889    // --- CAS variants ---------------------------------------------------
1890
1891    #[test]
1892    fn cas_any_clobbers() {
1893        let (_dir, mkit) = fresh_repo();
1894        update_ref(&mkit, "main", RefWriteCondition::Any, &h("a")).unwrap();
1895        update_ref(&mkit, "main", RefWriteCondition::Any, &h("b")).unwrap();
1896        assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("b")));
1897    }
1898
1899    #[test]
1900    fn cas_missing_succeeds_when_absent() {
1901        let (_dir, mkit) = fresh_repo();
1902        update_ref(&mkit, "main", RefWriteCondition::Missing, &h("a")).unwrap();
1903        assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("a")));
1904    }
1905
1906    #[test]
1907    fn cas_missing_fails_when_present() {
1908        let (_dir, mkit) = fresh_repo();
1909        write_ref(&mkit, "main", &h("a")).unwrap();
1910        let err = update_ref(&mkit, "main", RefWriteCondition::Missing, &h("b")).unwrap_err();
1911        assert!(matches!(err, RefError::Conflict(_)));
1912    }
1913
1914    #[test]
1915    fn cas_match_succeeds_on_correct_hash() {
1916        let (_dir, mkit) = fresh_repo();
1917        write_ref(&mkit, "main", &h("a")).unwrap();
1918        update_ref(&mkit, "main", RefWriteCondition::Match(h("a")), &h("b")).unwrap();
1919        assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("b")));
1920    }
1921
1922    #[test]
1923    fn cas_match_fails_on_wrong_hash() {
1924        let (_dir, mkit) = fresh_repo();
1925        write_ref(&mkit, "main", &h("a")).unwrap();
1926        let err = update_ref(&mkit, "main", RefWriteCondition::Match(h("z")), &h("b")).unwrap_err();
1927        assert!(matches!(err, RefError::Conflict(_)));
1928    }
1929
1930    #[test]
1931    fn cas_match_fails_on_missing_ref() {
1932        let (_dir, mkit) = fresh_repo();
1933        let err = update_ref(&mkit, "main", RefWriteCondition::Match(h("a")), &h("b")).unwrap_err();
1934        assert!(matches!(err, RefError::Conflict(_)));
1935    }
1936
1937    // --- INV-15 / #637: Match CAS must be atomic across uncoordinated
1938    // callers -------------------------------------------------------------
1939
1940    /// Reproduces the lost-update race described in INV-15: two callers
1941    /// that do NOT share any lock (mimicking `branch -m` under
1942    /// `worktrees.lock` racing `commit` under `worktree.lock`, or two
1943    /// linked worktrees each holding their own `worktree.lock`) both
1944    /// read the ref's current value, both see it matches their
1945    /// expectation, and both `cas_write`. Without cross-process
1946    /// atomicity on the read-compare-write, both calls can report
1947    /// success even though only one write actually survives — silently
1948    /// losing the other caller's update.
1949    ///
1950    /// `layout_a`/`layout_b` are two distinct [`RepoLayout`]s (a
1951    /// "single" layout and a "linked" layout with a different
1952    /// `worktree_root`/`worktree_state_dir`) that share only
1953    /// `common_dir` — exactly the shape of two linked worktrees, each
1954    /// of which would hold its own separate `worktree.lock` and thus
1955    /// share no lock with the other over this CAS. A `Barrier` forces
1956    /// both threads to enter `update_ref` at (as close to) the same
1957    /// instant as the scheduler allows, and the loop repeats many
1958    /// iterations because the race window is a handful of syscalls wide
1959    /// and is not guaranteed to be hit on any single attempt.
1960    ///
1961    /// Before the #637 fix (no shared `refs.lock` around the `Match`
1962    /// arm) this reliably reproduces a "both succeeded" iteration within
1963    /// a few hundred attempts. After the fix, the shared lock makes the
1964    /// read-compare-write atomic across both callers, so this must
1965    /// never happen — exactly one of the two racing writers may
1966    /// succeed.
1967    #[test]
1968    fn cas_match_race_never_loses_an_update_across_uncoordinated_callers() {
1969        let (_dir, layout_a) = fresh_repo();
1970        let layout_b = RepoLayout::linked(
1971            layout_a.worktree_root().join("other-worktree"),
1972            layout_a.common_dir().join("worktrees").join("other"),
1973            layout_a.common_dir(),
1974        );
1975
1976        let base = h("base");
1977        write_ref(&layout_a, "main", &base).unwrap();
1978
1979        let iterations: usize = 500;
1980        let mut double_success_iteration = None;
1981
1982        for i in 0..iterations {
1983            // Reset to a known base before each round. `Any` bypasses
1984            // CAS entirely, so this is not itself part of what's under
1985            // test.
1986            update_ref(&layout_a, "main", RefWriteCondition::Any, &base).unwrap();
1987
1988            let val_a = h(&format!("race-a-{i}"));
1989            let val_b = h(&format!("race-b-{i}"));
1990            let barrier = Barrier::new(2);
1991
1992            let (result_a, result_b) = std::thread::scope(|scope| {
1993                let handle_a = scope.spawn(|| {
1994                    barrier.wait();
1995                    update_ref(&layout_a, "main", RefWriteCondition::Match(base), &val_a)
1996                });
1997                let handle_b = scope.spawn(|| {
1998                    barrier.wait();
1999                    update_ref(&layout_b, "main", RefWriteCondition::Match(base), &val_b)
2000                });
2001                (handle_a.join().unwrap(), handle_b.join().unwrap())
2002            });
2003
2004            if result_a.is_ok() && result_b.is_ok() {
2005                double_success_iteration = Some(i);
2006                break;
2007            }
2008        }
2009
2010        assert!(
2011            double_success_iteration.is_none(),
2012            "both uncoordinated Match CAS callers reported success on iteration \
2013             {double_success_iteration:?} — an update was silently lost \
2014             (INV-15/INV-6 violation)"
2015        );
2016    }
2017
2018    /// Normal-case companion to the race test above: with no
2019    /// contention, a sequence of `Match` CAS writes on the same ref must
2020    /// still succeed every time and never wedge (guards against the
2021    /// `refs.lock` acquire/release added for #637 leaking or
2022    /// deadlocking across repeated calls from the same layout).
2023    #[test]
2024    fn cas_match_succeeds_repeatedly_when_uncontended() {
2025        let (_dir, mkit) = fresh_repo();
2026        let mut current = h("seed");
2027        write_ref(&mkit, "main", &current).unwrap();
2028
2029        for i in 0..20 {
2030            let next = h(&format!("v{i}"));
2031            update_ref(&mkit, "main", RefWriteCondition::Match(current), &next).unwrap();
2032            assert_eq!(read_ref(&mkit, "main").unwrap(), Some(next));
2033            current = next;
2034        }
2035    }
2036
2037    // --- INV-15 / #658: CAS-guarded delete must not lose a concurrent
2038    // Match-conditioned advance ---------------------------------------
2039
2040    /// Primitive-level reproduction of #658's "Race 1": a caller (e.g.
2041    /// `branch -m`) reads a branch's tip `T`, then — before it gets
2042    /// around to deleting the ref — a concurrent `Match(T)` CAS (e.g.
2043    /// `commit`'s fixed advance) lands, moving the ref to `C`. The
2044    /// caller's delete must detect that the ref no longer matches what
2045    /// it read and refuse, leaving `C` on disk untouched.
2046    ///
2047    /// Confirmed against the pre-fix shape of this codebase: pointing
2048    /// this same sequence at plain, unconditional [`delete_ref`] instead
2049    /// of [`delete_ref_if_matches`] removes the ref regardless of `T` vs
2050    /// `C`, silently destroying the concurrently-landed `C` — exactly
2051    /// the bug #658 reports. [`delete_ref_if_matches`] must refuse
2052    /// instead.
2053    #[test]
2054    fn cas_delete_refuses_when_ref_moved_after_read() {
2055        let (_dir, mkit) = fresh_repo();
2056        let t = h("t");
2057        let c = h("c");
2058        write_ref(&mkit, "main", &t).unwrap();
2059
2060        // Caller reads the tip...
2061        let read_t = read_ref(&mkit, "main").unwrap().unwrap();
2062        assert_eq!(read_t, t);
2063
2064        // ...then a concurrent Match(T) CAS (a fixed `commit`) lands
2065        // before the caller's delete runs.
2066        update_ref(&mkit, "main", RefWriteCondition::Match(t), &c).unwrap();
2067
2068        let err = delete_ref_if_matches(&mkit, "main", read_t).unwrap_err();
2069        assert!(
2070            matches!(err, RefError::Conflict(_)),
2071            "expected Conflict, got {err:?}"
2072        );
2073        assert_eq!(
2074            read_ref(&mkit, "main").unwrap(),
2075            Some(c),
2076            "the concurrently-landed commit must survive the refused delete untouched"
2077        );
2078    }
2079
2080    /// `delete_ref_if_matches` refusing to delete must not remove the
2081    /// file at all — a second, correctly-conditioned delete against the
2082    /// NEW value must still succeed.
2083    #[test]
2084    fn cas_delete_refusal_leaves_ref_deletable_against_its_new_value() {
2085        let (_dir, mkit) = fresh_repo();
2086        let t = h("t");
2087        let c = h("c");
2088        write_ref(&mkit, "main", &t).unwrap();
2089        update_ref(&mkit, "main", RefWriteCondition::Match(t), &c).unwrap();
2090
2091        assert!(matches!(
2092            delete_ref_if_matches(&mkit, "main", t).unwrap_err(),
2093            RefError::Conflict(_)
2094        ));
2095        delete_ref_if_matches(&mkit, "main", c).unwrap();
2096        assert_eq!(read_ref(&mkit, "main").unwrap(), None);
2097    }
2098
2099    #[test]
2100    fn cas_delete_fails_on_missing_ref() {
2101        let (_dir, mkit) = fresh_repo();
2102        let err = delete_ref_if_matches(&mkit, "main", h("anything")).unwrap_err();
2103        assert!(matches!(err, RefError::Conflict(_)));
2104    }
2105
2106    /// Racing-loop version of the reproduction above, mirroring
2107    /// [`cas_match_race_never_loses_an_update_across_uncoordinated_callers`]'s
2108    /// pattern: on each iteration, one thread performs a `Match`-guarded
2109    /// advance (mirroring a fixed `commit`) and the other performs a
2110    /// `delete_ref_if_matches` against the pre-advance value (mirroring
2111    /// `branch -m`'s rename), both released by the same `Barrier` so the
2112    /// scheduler is given its best shot at interleaving them. Because
2113    /// both operations serialize under the same per-ref lock
2114    /// ([`cas_lock_name`]), exactly one of the two may ever report
2115    /// success against a given prior value — never both, and the loser
2116    /// must never observe (or cause) a torn/lost state.
2117    #[test]
2118    fn cas_delete_vs_match_advance_race_never_lets_both_win_or_loses_the_advance() {
2119        let (_dir, mkit) = fresh_repo();
2120        let iterations: usize = 500;
2121        let mut bad_iteration: Option<(usize, &'static str)> = None;
2122
2123        for i in 0..iterations {
2124            let base = h(&format!("base-{i}"));
2125            write_ref(&mkit, "main", &base).unwrap();
2126            let new_tip = h(&format!("advanced-{i}"));
2127            let barrier = Barrier::new(2);
2128
2129            let (advance_result, delete_result) = std::thread::scope(|scope| {
2130                let advance_handle = scope.spawn(|| {
2131                    barrier.wait();
2132                    update_ref(&mkit, "main", RefWriteCondition::Match(base), &new_tip)
2133                });
2134                let delete_handle = scope.spawn(|| {
2135                    barrier.wait();
2136                    delete_ref_if_matches(&mkit, "main", base)
2137                });
2138                (
2139                    advance_handle.join().unwrap(),
2140                    delete_handle.join().unwrap(),
2141                )
2142            });
2143
2144            match (&advance_result, &delete_result) {
2145                (Ok(()), Ok(())) => {
2146                    bad_iteration = Some((
2147                        i,
2148                        "both the concurrent advance and the concurrent delete reported success",
2149                    ));
2150                }
2151                (Ok(()), Err(RefError::Conflict(_))) => {
2152                    // The advance won the race; its value must survive.
2153                    if read_ref(&mkit, "main").unwrap() != Some(new_tip) {
2154                        bad_iteration = Some((
2155                            i,
2156                            "advance reported success but its value is not on disk — lost update",
2157                        ));
2158                    }
2159                }
2160                (Err(RefError::Conflict(_)), Ok(())) => {
2161                    // The delete won the race before the advance landed;
2162                    // the ref must be gone (the advance must have then
2163                    // failed its own CAS, which the match arm above
2164                    // already confirms).
2165                    if read_ref(&mkit, "main").unwrap().is_some() {
2166                        bad_iteration = Some((
2167                            i,
2168                            "delete reported success but the ref is still present on disk",
2169                        ));
2170                    }
2171                }
2172                (Err(RefError::Conflict(_)), Err(RefError::Conflict(_))) => {
2173                    // Both lost — impossible on a fresh `base` write each
2174                    // round with only these two writers, but not itself a
2175                    // safety violation; leave unhandled rather than
2176                    // silently accepting on a bug that could produce it.
2177                    bad_iteration = Some((
2178                        i,
2179                        "both the advance and the delete reported Conflict against a freshly-written base",
2180                    ));
2181                }
2182                _ => {
2183                    bad_iteration = Some((i, "unexpected error variant"));
2184                }
2185            }
2186
2187            if bad_iteration.is_some() {
2188                break;
2189            }
2190        }
2191
2192        assert!(
2193            bad_iteration.is_none(),
2194            "iteration {bad_iteration:?}: a concurrent Match-conditioned advance and a \
2195             CAS-guarded delete on the same ref did not serialize correctly (issue #658)"
2196        );
2197    }
2198
2199    /// Cross-ref counterpart to the race test above: `refs.lock` used
2200    /// to be one repo-wide lock, so a `Match` CAS on branch "other"
2201    /// would block one on unrelated branch "main" for no reason —
2202    /// nothing about this CAS invariant spans refs. Now keyed per ref
2203    /// via [`cas_lock_name`]; proves an externally-held lock on
2204    /// "other"'s ref path does NOT block a `Match` CAS on "main"'s.
2205    #[test]
2206    fn cas_match_does_not_contend_across_different_refs() {
2207        let (_dir, mkit) = fresh_repo();
2208        write_ref(&mkit, "main", &h("m0")).unwrap();
2209        write_ref(&mkit, "other", &h("o0")).unwrap();
2210
2211        let other_path = ref_path(mkit.common_dir(), HEADS_DIR, "other");
2212        let other_lock_name = cas_lock_name(mkit.common_dir(), &other_path);
2213        let common_dir = mkit.common_dir().to_path_buf();
2214        let (holding_tx, holding_rx) = std::sync::mpsc::channel();
2215        let (release_tx, release_rx) = std::sync::mpsc::channel();
2216        let holder = std::thread::spawn(move || {
2217            let _lock = crate::repo_lock::acquire_default(&common_dir, &other_lock_name).unwrap();
2218            holding_tx.send(()).unwrap();
2219            release_rx.recv().unwrap();
2220        });
2221        holding_rx.recv().unwrap();
2222
2223        let start = std::time::Instant::now();
2224        update_ref(&mkit, "main", RefWriteCondition::Match(h("m0")), &h("m1")).unwrap();
2225        let elapsed = start.elapsed();
2226
2227        release_tx.send(()).unwrap();
2228        holder.join().unwrap();
2229
2230        assert!(
2231            elapsed < std::time::Duration::from_secs(1),
2232            "Match CAS on \"main\" took {elapsed:?} while \"other\"'s ref lock was held \
2233             elsewhere — refs are contending when they shouldn't be"
2234        );
2235        assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("m1")));
2236    }
2237
2238    // --- name-validation enforcement -----------------------------------
2239
2240    #[test]
2241    fn write_rejects_invalid_branch_name() {
2242        let (_dir, mkit) = fresh_repo();
2243        let err = write_ref(&mkit, "../escape", &h("x")).unwrap_err();
2244        assert!(matches!(err, RefError::InvalidRefName(_)));
2245        let err = write_head_branch(&mkit, "bad//branch").unwrap_err();
2246        assert!(matches!(err, RefError::InvalidRefName(_)));
2247    }
2248
2249    // --- tags ----------------------------------------------------------
2250
2251    #[test]
2252    fn write_and_read_tag() {
2253        let (_dir, mkit) = fresh_repo();
2254        let commit = h("v1.0");
2255        write_tag(&mkit, "v1.0", &commit).unwrap();
2256        assert_eq!(read_tag(&mkit, "v1.0").unwrap(), Some(commit));
2257    }
2258
2259    #[test]
2260    fn list_tags_sorted() {
2261        let (_dir, mkit) = fresh_repo();
2262        write_tag(&mkit, "v2.0", &h("v2")).unwrap();
2263        write_tag(&mkit, "v1.0", &h("v1")).unwrap();
2264        write_tag(&mkit, "alpha", &h("a")).unwrap();
2265        let tags = list_tags(&mkit).unwrap();
2266        assert_eq!(
2267            tags.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
2268            vec!["alpha", "v1.0", "v2.0"]
2269        );
2270    }
2271
2272    #[test]
2273    fn tag_and_branch_same_name_independent() {
2274        let (_dir, mkit) = fresh_repo();
2275        let tag = h("tag");
2276        let branch = h("branch");
2277        write_tag(&mkit, "main", &tag).unwrap();
2278        write_ref(&mkit, "main", &branch).unwrap();
2279        assert_eq!(read_tag(&mkit, "main").unwrap(), Some(tag));
2280        assert_eq!(read_ref(&mkit, "main").unwrap(), Some(branch));
2281    }
2282
2283    #[test]
2284    fn delete_tag_basic() {
2285        let (_dir, mkit) = fresh_repo();
2286        write_tag(&mkit, "release", &h("r")).unwrap();
2287        delete_tag(&mkit, "release").unwrap();
2288        assert_eq!(read_tag(&mkit, "release").unwrap(), None);
2289    }
2290
2291    #[test]
2292    fn delete_nonexistent_tag_errors() {
2293        let (_dir, mkit) = fresh_repo();
2294        let err = delete_tag(&mkit, "missing").unwrap_err();
2295        assert!(matches!(err, RefError::NotFound(_)));
2296    }
2297
2298    // --- shallow boundaries --------------------------------------------
2299
2300    #[test]
2301    fn load_shallow_returns_none_when_missing() {
2302        let (_dir, mkit) = fresh_repo();
2303        assert_eq!(load_shallow_boundaries(&mkit).unwrap(), None);
2304    }
2305
2306    #[test]
2307    fn write_and_load_shallow_round_trip() {
2308        let (_dir, mkit) = fresh_repo();
2309        let bs = vec![h("b1"), h("b2"), h("b3")];
2310        write_shallow_boundaries(&mkit, &bs).unwrap();
2311        let loaded = load_shallow_boundaries(&mkit).unwrap().unwrap();
2312        assert_eq!(loaded.len(), 3);
2313        for b in &bs {
2314            assert!(loaded.contains(b));
2315        }
2316    }
2317
2318    #[test]
2319    fn write_empty_shallow_removes_file() {
2320        let (_dir, mkit) = fresh_repo();
2321        write_shallow_boundaries(&mkit, &[h("x")]).unwrap();
2322        assert!(load_shallow_boundaries(&mkit).unwrap().is_some());
2323        write_shallow_boundaries(&mkit, &[]).unwrap();
2324        assert_eq!(load_shallow_boundaries(&mkit).unwrap(), None);
2325    }
2326
2327    #[test]
2328    fn load_shallow_rejects_oversize_file() {
2329        // SPEC-REFS §6: the shallow file is capped at 1 MiB.
2330        let (_dir, mkit) = fresh_repo();
2331        let path = mkit.shallow_file();
2332        fs::write(
2333            &path,
2334            vec![b'a'; usize::try_from(SHALLOW_MAX_BYTES).unwrap() + 1],
2335        )
2336        .unwrap();
2337        let err = load_shallow_boundaries(&mkit).unwrap_err();
2338        assert!(matches!(err, RefError::InvalidRef(_)));
2339    }
2340
2341    #[test]
2342    fn load_shallow_skips_invalid_lines() {
2343        let (_dir, mkit) = fresh_repo();
2344        let path = mkit.shallow_file();
2345        let valid = h("ok");
2346        let valid_hex = to_hex(&valid);
2347        let mut content = String::new();
2348        content.push_str("short\n");
2349        content.push_str(&valid_hex);
2350        content.push('\n');
2351        content.push_str(&"z".repeat(64));
2352        content.push('\n');
2353        std::fs::write(&path, content).unwrap();
2354        let loaded = load_shallow_boundaries(&mkit).unwrap().unwrap();
2355        assert_eq!(loaded.len(), 1);
2356        assert_eq!(loaded[0], valid);
2357    }
2358
2359    // --- remote-ref batching (#645) --------------------------------------
2360    //
2361    // `push`/`fetch` publish one remote-tracking ref per branch in a loop
2362    // (`mkit-cli`'s `remote_dispatch::push_all_with` /
2363    // `fetch_objects_inner`). Each `write_remote_ref` call goes through
2364    // `cas_write` → `write_atomic`, which pays a parent-directory fsync
2365    // EVERY call (`atomic.rs`'s `sync_parent_dir`) — so N branches cost N
2366    // directory fsyncs, serially, even though they all land in the same
2367    // `refs/remotes/<remote>/` directory. `RemoteRefBatch` amortises that
2368    // into one fsync per distinct directory touched, however many refs
2369    // were written into it.
2370
2371    /// Baseline (pre-#645): today's per-ref loop — exactly what
2372    /// `push_all_with`/`fetch_objects_inner` do today — pays one
2373    /// directory fsync per ref, even though every ref lands in the same
2374    /// `refs/remotes/origin/` directory. This is the O(N) cost #645
2375    /// exists to amortise; it must keep holding after the fix, since
2376    /// `write_remote_ref` itself (used elsewhere for single-ref writes)
2377    /// is intentionally left on the unbatched path.
2378    #[test]
2379    fn write_remote_ref_loop_pays_one_dir_sync_per_ref_today() {
2380        let (_dir, mkit) = fresh_repo();
2381        let n: u64 = 25;
2382        crate::atomic::testing::reset_dir_sync_calls();
2383        for i in 0..n {
2384            write_remote_ref(
2385                &mkit,
2386                "origin",
2387                &format!("branch-{i}"),
2388                &h(&format!("c{i}")),
2389            )
2390            .unwrap();
2391        }
2392        let calls = crate::atomic::testing::dir_sync_calls();
2393        assert_eq!(
2394            calls, n,
2395            "the current per-ref write path must cost exactly one directory \
2396             fsync per ref (O(N)); got {calls} for {n} refs"
2397        );
2398    }
2399
2400    /// The #645 fix: staging N remote-tracking-ref writes into one
2401    /// `RemoteRefBatch` and committing once must cost O(1) directory
2402    /// fsyncs (one per distinct directory touched — here a single flat
2403    /// `refs/remotes/origin/` namespace, so exactly one), not O(N).
2404    #[test]
2405    fn remote_ref_batch_pays_one_dir_sync_for_many_refs() {
2406        let (_dir, mkit) = fresh_repo();
2407        let n = 25;
2408        let entries: Vec<(String, Hash)> = (0..n)
2409            .map(|i| (format!("branch-{i}"), h(&format!("c{i}"))))
2410            .collect();
2411
2412        crate::atomic::testing::reset_dir_sync_calls();
2413        let mut batch = RemoteRefBatch::new(&mkit, "origin").unwrap();
2414        for (branch, hash) in &entries {
2415            batch.write(branch, hash).unwrap();
2416        }
2417        batch.commit().unwrap();
2418
2419        let calls = crate::atomic::testing::dir_sync_calls();
2420        assert_eq!(
2421            calls, 1,
2422            "batching {n} tracking-ref writes into one flat remote \
2423             namespace must cost exactly one directory fsync (O(1)), got {calls}"
2424        );
2425    }
2426
2427    /// Correctness: batched writes must produce the exact same final ref
2428    /// states as the old per-ref `write_remote_ref` loop — same hashes,
2429    /// same readability, for every branch.
2430    #[test]
2431    fn remote_ref_batch_matches_per_ref_loop_final_state() {
2432        let (_dir, old_path) = fresh_repo();
2433        let (_dir2, new_path) = fresh_repo();
2434        let n = 12;
2435        let entries: Vec<(String, Hash)> = (0..n)
2436            .map(|i| (format!("team/branch-{i}"), h(&format!("state{i}"))))
2437            .collect();
2438
2439        for (branch, hash) in &entries {
2440            write_remote_ref(&old_path, "origin", branch, hash).unwrap();
2441        }
2442
2443        let mut batch = RemoteRefBatch::new(&new_path, "origin").unwrap();
2444        for (branch, hash) in &entries {
2445            batch.write(branch, hash).unwrap();
2446        }
2447        batch.commit().unwrap();
2448
2449        for (branch, hash) in &entries {
2450            let old_val = read_remote_ref(&old_path, "origin", branch).unwrap();
2451            let new_val = read_remote_ref(&new_path, "origin", branch).unwrap();
2452            assert_eq!(old_val, Some(*hash));
2453            assert_eq!(new_val, Some(*hash));
2454            assert_eq!(old_val, new_val, "branch {branch} diverged");
2455        }
2456    }
2457
2458    /// Partial-failure semantics: best-effort / fail-fast, matching
2459    /// `WriteBatch::commit`'s documented contract (already-renamed
2460    /// entries stay visible; no rollback). An invalid branch name in the
2461    /// middle of a batch aborts every write from that point on — the
2462    /// refs staged before it stay visible and readable, exactly as they
2463    /// would have under the old per-ref loop had it hit the same
2464    /// mid-loop error (each earlier ref was already independently
2465    /// visible before the loop reached the bad one).
2466    #[test]
2467    fn remote_ref_batch_partial_failure_keeps_already_written_refs_visible() {
2468        let (_dir, mkit) = fresh_repo();
2469        let mut batch = RemoteRefBatch::new(&mkit, "origin").unwrap();
2470        batch.write("good-1", &h("g1")).unwrap();
2471        batch.write("good-2", &h("g2")).unwrap();
2472        let err = batch.write("bad//name", &h("x")).unwrap_err();
2473        assert!(matches!(err, RefError::InvalidRefName(_)));
2474
2475        // Committing what was staged before the bad write must still
2476        // durably publish the good entries.
2477        batch.commit().unwrap();
2478        assert_eq!(
2479            read_remote_ref(&mkit, "origin", "good-1").unwrap(),
2480            Some(h("g1"))
2481        );
2482        assert_eq!(
2483            read_remote_ref(&mkit, "origin", "good-2").unwrap(),
2484            Some(h("g2"))
2485        );
2486        // "bad//name" was never a valid ref name in the first place —
2487        // nothing was ever staged for it, on either the old or new path.
2488        let never_written = read_remote_ref(&mkit, "origin", "bad//name").unwrap_err();
2489        assert!(matches!(never_written, RefError::InvalidRefName(_)));
2490    }
2491
2492    #[test]
2493    fn remote_ref_batch_rejects_invalid_remote_name() {
2494        let (_dir, mkit) = fresh_repo();
2495        let err = RemoteRefBatch::new(&mkit, "../escape").unwrap_err();
2496        assert!(matches!(err, RefError::InvalidRefName(_)));
2497    }
2498
2499    #[test]
2500    fn remote_ref_batch_of_zero_entries_is_a_noop_commit() {
2501        let (_dir, mkit) = fresh_repo();
2502        crate::atomic::testing::reset_dir_sync_calls();
2503        let batch = RemoteRefBatch::new(&mkit, "origin").unwrap();
2504        batch.commit().unwrap();
2505        assert_eq!(
2506            crate::atomic::testing::dir_sync_calls(),
2507            0,
2508            "an empty batch must not touch any directory"
2509        );
2510    }
2511
2512    // --- history-coupled ref writes (history-mmr feature) -------------
2513
2514    #[cfg(feature = "history-mmr")]
2515    mod history_coupling {
2516        use super::*;
2517        use crate::history::{CommitHistory, TokioExecutor};
2518        use std::sync::Arc;
2519
2520        #[test]
2521        fn update_ref_with_history_appends_to_journal_under_lock() {
2522            let (_dir, mkit) = fresh_repo();
2523            let exec = Arc::new(TokioExecutor::new().unwrap());
2524            let mut hist = CommitHistory::open_at(exec.clone(), &mkit, "main").unwrap();
2525
2526            let c1 = h("c1");
2527            let c2 = h("c2");
2528
2529            update_ref_with_history(&mkit, "main", RefWriteCondition::Any, &c1, &mut hist).unwrap();
2530            update_ref_with_history(&mkit, "main", RefWriteCondition::Match(c1), &c2, &mut hist)
2531                .unwrap();
2532
2533            assert_eq!(read_ref(&mkit, "main").unwrap(), Some(c2));
2534            assert_eq!(hist.len(), 2, "two appends → two leaves in the MMR");
2535        }
2536
2537        /// Regression test for the epic-#634 follow-up: `refs-history.lock`
2538        /// (and `refs.lock`) used to be one repo-wide lock, so an
2539        /// operation on branch "other" would block one on unrelated
2540        /// branch "main" for no reason — nothing about the history-mmr
2541        /// coupling spans branches. Locks are now keyed per branch
2542        /// (`refs-history-<sanitized-branch>.lock`); this proves an
2543        /// externally-held lock on branch "other" does NOT block
2544        /// `update_ref_with_history` on branch "main".
2545        #[test]
2546        fn update_ref_with_history_does_not_contend_across_branches() {
2547            let (_dir, mkit) = fresh_repo();
2548            let exec = Arc::new(TokioExecutor::new().unwrap());
2549            let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
2550
2551            // Hold "other" branch's per-branch history lock on a
2552            // separate thread for well longer than this test's timeout
2553            // budget would tolerate if "main" incorrectly contended on
2554            // it. Calls the SAME `history_lock_name` production uses
2555            // (not a hand-copied format! string) so this test can't
2556            // silently decouple from whatever naming decision
2557            // production actually makes.
2558            let other_lock_name = history_lock_name("other");
2559            let common_dir = mkit.common_dir().to_path_buf();
2560            let (holding_tx, holding_rx) = std::sync::mpsc::channel();
2561            let (release_tx, release_rx) = std::sync::mpsc::channel();
2562            let holder = std::thread::spawn(move || {
2563                let _lock =
2564                    crate::repo_lock::acquire_default(&common_dir, &other_lock_name).unwrap();
2565                holding_tx.send(()).unwrap();
2566                release_rx.recv().unwrap();
2567            });
2568            holding_rx.recv().unwrap();
2569
2570            // If this contended on "other"'s lock, it would hang until
2571            // the holder thread released — which we don't do until
2572            // after this call returns. A generous-but-bounded elapsed
2573            // check turns a regression into a fast test failure instead
2574            // of an indefinite hang.
2575            let start = std::time::Instant::now();
2576            update_ref_with_history(&mkit, "main", RefWriteCondition::Any, &h("m1"), &mut hist)
2577                .unwrap();
2578            let elapsed = start.elapsed();
2579
2580            release_tx.send(()).unwrap();
2581            holder.join().unwrap();
2582
2583            assert!(
2584                elapsed < std::time::Duration::from_secs(1),
2585                "update_ref_with_history on \"main\" took {elapsed:?} while \"other\"'s \
2586                 per-branch lock was held elsewhere — branches are contending when they \
2587                 shouldn't be"
2588            );
2589            assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("m1")));
2590        }
2591
2592        /// Sanity control for the test above: the SAME branch's lock
2593        /// must still serialize correctly — per-branch scoping must not
2594        /// have accidentally broken same-branch mutual exclusion.
2595        #[test]
2596        fn update_ref_with_history_still_contends_on_the_same_branch() {
2597            let (_dir, mkit) = fresh_repo();
2598            let exec = Arc::new(TokioExecutor::new().unwrap());
2599            let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
2600
2601            let main_lock_name = history_lock_name("main");
2602            let common_dir = mkit.common_dir().to_path_buf();
2603            let (holding_tx, holding_rx) = std::sync::mpsc::channel();
2604            let held_for = std::time::Duration::from_millis(200);
2605            let holder = std::thread::spawn(move || {
2606                let _lock =
2607                    crate::repo_lock::acquire_default(&common_dir, &main_lock_name).unwrap();
2608                holding_tx.send(()).unwrap();
2609                std::thread::sleep(held_for);
2610            });
2611            holding_rx.recv().unwrap();
2612
2613            let start = std::time::Instant::now();
2614            update_ref_with_history(&mkit, "main", RefWriteCondition::Any, &h("m1"), &mut hist)
2615                .unwrap();
2616            let elapsed = start.elapsed();
2617            holder.join().unwrap();
2618
2619            assert!(
2620                elapsed >= held_for / 2,
2621                "update_ref_with_history on \"main\" returned in {elapsed:?} while \"main\"'s \
2622                 own lock was held for {held_for:?} elsewhere — same-branch mutual exclusion \
2623                 is broken"
2624            );
2625        }
2626
2627        #[test]
2628        fn update_ref_with_history_rejects_mem_history() {
2629            let (_dir, mkit) = fresh_repo();
2630            let mut mem_hist = CommitHistory::open();
2631            let err = update_ref_with_history(
2632                &mkit,
2633                "main",
2634                RefWriteCondition::Any,
2635                &h("x"),
2636                &mut mem_hist,
2637            )
2638            .unwrap_err();
2639            assert!(matches!(err, RefError::InvalidRef(_)));
2640        }
2641
2642        #[test]
2643        fn update_ref_with_history_rejects_branch_mismatch() {
2644            let (_dir, mkit) = fresh_repo();
2645            let exec = Arc::new(TokioExecutor::new().unwrap());
2646            // Open history for "main", but try to update ref "feature".
2647            let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
2648            let err = update_ref_with_history(
2649                &mkit,
2650                "feature",
2651                RefWriteCondition::Any,
2652                &h("x"),
2653                &mut hist,
2654            )
2655            .unwrap_err();
2656            assert!(matches!(err, RefError::InvalidRef(_)));
2657        }
2658
2659        #[test]
2660        fn update_ref_with_history_cas_failure_does_not_append() {
2661            let (_dir, mkit) = fresh_repo();
2662            let exec = Arc::new(TokioExecutor::new().unwrap());
2663            let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
2664
2665            // Pre-seed the ref so a `Missing` CAS will fail.
2666            write_ref(&mkit, "main", &h("existing")).unwrap();
2667
2668            let err = update_ref_with_history(
2669                &mkit,
2670                "main",
2671                RefWriteCondition::Missing,
2672                &h("new"),
2673                &mut hist,
2674            )
2675            .unwrap_err();
2676            assert!(matches!(err, RefError::Conflict(_)));
2677            assert_eq!(
2678                hist.len(),
2679                0,
2680                "CAS failure must NOT have appended to history"
2681            );
2682        }
2683
2684        #[test]
2685        fn update_ref_with_history_heals_one_ahead_gap_before_appending_new_value() {
2686            use crate::history::{Position, verify_inclusion};
2687
2688            let (_dir, mkit) = fresh_repo();
2689            let exec = Arc::new(TokioExecutor::new().unwrap());
2690            let c0 = h("c0");
2691            let c1 = h("c1");
2692            let c2 = h("c2");
2693
2694            // A real, properly-recorded genesis commit — the journal is
2695            // non-empty, which is what distinguishes this crash-recovery
2696            // case (core-level, no `ObjectStore` needed: the missing leaf
2697            // is exactly the ref's current value) from the deeper
2698            // v0.1.x-migration case (`mkit-cli`-level, needs
2699            // `rebuild_from_chain` over the object store).
2700            let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
2701            update_ref_with_history(&mkit, "main", RefWriteCondition::Any, &c0, &mut hist).unwrap();
2702            assert_eq!(hist.len(), 1);
2703
2704            // Simulate steps 1-2 of a PRIOR `update_ref_with_history` call
2705            // that crashed before its own step 3 (append): the ref
2706            // already advanced to `c1`, but the journal never recorded
2707            // it, so `hist` here is one leaf behind what the ref implies.
2708            write_ref(&mkit, "main", &c1).unwrap();
2709            assert_eq!(hist.len(), 1, "journal is still missing c1's append");
2710
2711            // The next write proceeds normally against the ref's actual
2712            // current value.
2713            update_ref_with_history(&mkit, "main", RefWriteCondition::Match(c1), &c2, &mut hist)
2714                .unwrap();
2715
2716            // c0 (already recorded), the backfilled c1, and the new c2
2717            // must all be recorded, in order — proof state has caught up
2718            // to the ref, with no manual intervention.
2719            assert_eq!(hist.len(), 3);
2720            let root = hist.root();
2721            let proof0 = hist.prove(Position(0)).unwrap();
2722            assert!(verify_inclusion(&c0, Position(0), &proof0, &root));
2723            let proof1 = hist.prove(Position(1)).unwrap();
2724            assert!(verify_inclusion(&c1, Position(1), &proof1, &root));
2725            let proof2 = hist.prove(Position(2)).unwrap();
2726            assert!(verify_inclusion(&c2, Position(2), &proof2, &root));
2727        }
2728
2729        #[test]
2730        fn update_ref_with_history_concurrent_handles_do_not_interleave_or_corrupt() {
2731            use crate::history::{Position, verify_inclusion};
2732
2733            let (_dir, mkit) = fresh_repo();
2734            let exec = Arc::new(TokioExecutor::new().unwrap());
2735
2736            // Two independent handles opened before either has taken the
2737            // `refs-history.lock` — simulating two racing processes, each
2738            // of which called `CommitHistory::open_at` first (as
2739            // `mkit-cli`'s `write_ref_recording_history` does) and only
2740            // takes the lock inside `update_ref_with_history`.
2741            let mut hist_a = CommitHistory::open_at(exec.clone(), &mkit, "main").unwrap();
2742            let mut hist_b = CommitHistory::open_at(exec, &mkit, "main").unwrap();
2743
2744            let a1 = h("a1");
2745            let b1 = h("b1");
2746
2747            update_ref_with_history(&mkit, "main", RefWriteCondition::Any, &a1, &mut hist_a)
2748                .unwrap();
2749
2750            // `hist_b` is stale (opened before `hist_a`'s append landed)
2751            // but must re-derive its state from disk under its own lock
2752            // acquisition and append on top of `a1`, not clobber or
2753            // duplicate its leaf.
2754            update_ref_with_history(&mkit, "main", RefWriteCondition::Any, &b1, &mut hist_b)
2755                .unwrap();
2756
2757            assert_eq!(read_ref(&mkit, "main").unwrap(), Some(b1));
2758            assert_eq!(
2759                hist_b.len(),
2760                2,
2761                "b's append must land after a's, not clobber it"
2762            );
2763            let root = hist_b.root();
2764            let proof0 = hist_b.prove(Position(0)).unwrap();
2765            assert!(verify_inclusion(&a1, Position(0), &proof0, &root));
2766            let proof1 = hist_b.prove(Position(1)).unwrap();
2767            assert!(verify_inclusion(&b1, Position(1), &proof1, &root));
2768        }
2769
2770        // --- journal lifecycle on branch delete/rename (issue #648) ---
2771
2772        /// The core regression test: delete a branch, recreate one with
2773        /// the SAME name, and advance it once. The new incarnation's
2774        /// journal must start fresh (one leaf), not resume the deleted
2775        /// incarnation's leaves — otherwise the new branch's MMR root
2776        /// commits to a sequence spanning unrelated incarnations, and
2777        /// stale leaves from the deleted branch keep producing
2778        /// valid-looking inclusion proofs "on this branch".
2779        #[test]
2780        fn delete_ref_with_history_prevents_partition_reuse_on_recreate() {
2781            use crate::history::{Position, verify_inclusion};
2782
2783            let (_dir, mkit) = fresh_repo();
2784            let exec = Arc::new(TokioExecutor::new().unwrap());
2785
2786            // Branch A, advanced twice — journal gets two leaves.
2787            let a1 = h("a1");
2788            let a2 = h("a2");
2789            let mut hist_a = CommitHistory::open_at(exec.clone(), &mkit, "feature").unwrap();
2790            update_ref_with_history(&mkit, "feature", RefWriteCondition::Any, &a1, &mut hist_a)
2791                .unwrap();
2792            update_ref_with_history(
2793                &mkit,
2794                "feature",
2795                RefWriteCondition::Match(a1),
2796                &a2,
2797                &mut hist_a,
2798            )
2799            .unwrap();
2800            assert_eq!(hist_a.len(), 2);
2801            drop(hist_a);
2802
2803            // Delete branch A through the history-aware path.
2804            delete_ref_with_history(&mkit, "feature", exec.clone()).unwrap();
2805            assert_eq!(read_ref(&mkit, "feature").unwrap(), None);
2806
2807            // Recreate a NEW branch also named "feature" and advance it
2808            // once. Its journal must be fresh: one leaf, not three.
2809            let b1 = h("b1");
2810            let mut hist_b = CommitHistory::open_at(exec, &mkit, "feature").unwrap();
2811            assert_eq!(
2812                hist_b.len(),
2813                0,
2814                "recreated branch must not inherit the deleted incarnation's leaves"
2815            );
2816            update_ref_with_history(
2817                &mkit,
2818                "feature",
2819                RefWriteCondition::Missing,
2820                &b1,
2821                &mut hist_b,
2822            )
2823            .unwrap();
2824            assert_eq!(
2825                hist_b.len(),
2826                1,
2827                "the new incarnation's journal must contain exactly its own one leaf"
2828            );
2829
2830            // The old leaves (a1, a2) must not verify against the new
2831            // incarnation's root at any position — no splicing of
2832            // unrelated incarnations.
2833            let root = hist_b.root();
2834            for pos in 0..hist_b.len() {
2835                let proof = hist_b.prove(Position(pos)).unwrap();
2836                assert!(
2837                    !verify_inclusion(&a1, Position(pos), &proof, &root),
2838                    "deleted incarnation's leaf a1 must not verify against the new root"
2839                );
2840                assert!(
2841                    !verify_inclusion(&a2, Position(pos), &proof, &root),
2842                    "deleted incarnation's leaf a2 must not verify against the new root"
2843                );
2844            }
2845        }
2846
2847        /// Regression test for the bug found during the epic-#634 code
2848        /// review: `delete_ref_with_history` originally ran its
2849        /// read-check + journal-destroy + ref-delete sequence with NO
2850        /// lock at all, reopening exactly the race #638 closed one layer
2851        /// up. Races `delete_ref_with_history` against
2852        /// `update_ref_with_history` on the same never-checked-out
2853        /// branch, from two independently-opened `CommitHistory` handles
2854        /// (mirroring how `update_ref_with_history_concurrent_handles_do_not_interleave_or_corrupt`
2855        /// above simulates two racing processes).
2856        ///
2857        /// The assertion is on LEAF COUNT, not raw journal-directory
2858        /// existence: `CommitHistory::open_at`/`reopen` create an empty
2859        /// on-disk partition as a side effect of merely opening it (this
2860        /// is commonware's own open-or-create `init` semantics, already
2861        /// documented as an accepted ambiguity by `heal_one_ahead_gap`'s
2862        /// doc comment above), so a 0-leaf journal directory can
2863        /// legitimately exist even when the branch has no ref — that is
2864        /// not the bug. What must never happen is the branch either (a)
2865        /// having a live ref with an EMPTY journal (a leaf was lost) or
2866        /// (b) having NO ref while the journal still holds leaves from
2867        /// before the delete (exactly #648's stale-incarnation bug,
2868        /// reintroduced one layer up by this unlocked race).
2869        ///
2870        /// Honesty check on this test's power: unlike the analogous
2871        /// `cas_match_race_*` test above, this one did NOT reliably
2872        /// reproduce a torn state against the unlocked code in manual
2873        /// verification (0 failures across 500 iterations) — the
2874        /// vulnerable window (concurrent file-level I/O inside
2875        /// `destroy()`/`append()`) is narrower than what a
2876        /// barrier-synchronized thread start reliably lands in. The fix
2877        /// is correct by construction regardless: it applies the exact
2878        /// same `refs-history.lock` acquisition, in the exact same
2879        /// position (before any read of ref or journal state), that
2880        /// every other history-mmr writer in this file already uses —
2881        /// this test is kept as a standing consistency/defense-in-depth
2882        /// check, not as proof the bug was reliably observed pre-fix.
2883        #[test]
2884        fn delete_ref_with_history_races_update_without_tearing_ref_and_journal() {
2885            let iterations = 50;
2886
2887            for i in 0..iterations {
2888                let (_dir, mkit) = fresh_repo();
2889                let exec = Arc::new(TokioExecutor::new().unwrap());
2890
2891                // Seed the branch so delete_ref_with_history has
2892                // something to delete, and pre-populate its journal so
2893                // update's handle below opens onto a non-empty journal
2894                // (exercising the heal-gap path, not the empty-journal
2895                // backfill path, which is orthogonal to this race).
2896                let seed = h(&format!("seed-{i}"));
2897                let mut seed_hist = CommitHistory::open_at(exec.clone(), &mkit, "feature").unwrap();
2898                update_ref_with_history(
2899                    &mkit,
2900                    "feature",
2901                    RefWriteCondition::Missing,
2902                    &seed,
2903                    &mut seed_hist,
2904                )
2905                .unwrap();
2906                drop(seed_hist);
2907
2908                let next = h(&format!("next-{i}"));
2909                let mut update_hist =
2910                    CommitHistory::open_at(exec.clone(), &mkit, "feature").unwrap();
2911                let barrier = Barrier::new(2);
2912
2913                std::thread::scope(|scope| {
2914                    scope.spawn(|| {
2915                        barrier.wait();
2916                        // Either outcome (deleted first, or raced out by
2917                        // the update landing first and this then failing
2918                        // NotFound) is valid — only a torn final state is
2919                        // a bug.
2920                        let _ = delete_ref_with_history(&mkit, "feature", exec.clone());
2921                    });
2922                    scope.spawn(|| {
2923                        barrier.wait();
2924                        let _ = update_ref_with_history(
2925                            &mkit,
2926                            "feature",
2927                            RefWriteCondition::Match(seed),
2928                            &next,
2929                            &mut update_hist,
2930                        );
2931                    });
2932                });
2933                drop(update_hist);
2934
2935                let ref_exists = read_ref(&mkit, "feature").unwrap().is_some();
2936                let leaves = CommitHistory::open_at(exec, &mkit, "feature")
2937                    .unwrap()
2938                    .len();
2939                assert_eq!(
2940                    ref_exists,
2941                    leaves > 0,
2942                    "iteration {i}: torn state after racing delete vs. update — \
2943                     ref_exists={ref_exists}, journal has {leaves} leaves \
2944                     (INV-4/INV-18-adjacent: a live ref must have a non-empty \
2945                     journal, and a deleted branch's journal must not retain \
2946                     leaves from before the delete)"
2947                );
2948            }
2949        }
2950
2951        #[test]
2952        fn delete_ref_with_history_removes_journal_partition_from_disk() {
2953            let (_dir, mkit) = fresh_repo();
2954            let exec = Arc::new(TokioExecutor::new().unwrap());
2955            let mut hist = CommitHistory::open_at(exec.clone(), &mkit, "feature").unwrap();
2956            update_ref_with_history(&mkit, "feature", RefWriteCondition::Any, &h("a"), &mut hist)
2957                .unwrap();
2958            drop(hist);
2959
2960            let journal_blobs = mkit.history_dir().join("feature__journal-blobs");
2961            assert!(journal_blobs.exists());
2962
2963            delete_ref_with_history(&mkit, "feature", exec).unwrap();
2964            assert!(
2965                !journal_blobs.exists(),
2966                "delete_ref_with_history must remove the on-disk journal partition"
2967            );
2968        }
2969
2970        #[test]
2971        fn delete_ref_with_history_errors_on_missing_branch_without_touching_journal() {
2972            let (_dir, mkit) = fresh_repo();
2973            let exec = Arc::new(TokioExecutor::new().unwrap());
2974            let err = delete_ref_with_history(&mkit, "nope", exec).unwrap_err();
2975            assert!(matches!(err, RefError::NotFound(_)));
2976        }
2977
2978        #[test]
2979        fn delete_ref_safe_with_history_refuses_current_branch() {
2980            let (_dir, mkit) = fresh_repo();
2981            let exec = Arc::new(TokioExecutor::new().unwrap());
2982            let mut hist = CommitHistory::open_at(exec.clone(), &mkit, "main").unwrap();
2983            update_ref_with_history(&mkit, "main", RefWriteCondition::Any, &h("m"), &mut hist)
2984                .unwrap();
2985            drop(hist);
2986
2987            let err = delete_ref_safe_with_history(&mkit, "main", exec).unwrap_err();
2988            assert!(matches!(err, RefError::CurrentBranch(_)));
2989            // Refusing the delete must leave the journal untouched.
2990            assert!(mkit.history_dir().join("main__journal-blobs").exists());
2991        }
2992
2993        /// Rename support: destroying the OLD name's journal after the
2994        /// new name has already been seeded with a fresh journal (by
2995        /// `write_ref_recording_history`, mkit-cli's create/rename path)
2996        /// closes the same reuse hole for `branch -m`.
2997        #[test]
2998        fn delete_ref_with_history_supports_rename_by_destroying_the_old_name_only() {
2999            let (_dir, mkit) = fresh_repo();
3000            let exec = Arc::new(TokioExecutor::new().unwrap());
3001
3002            let mut hist_old = CommitHistory::open_at(exec.clone(), &mkit, "old").unwrap();
3003            update_ref_with_history(
3004                &mkit,
3005                "old",
3006                RefWriteCondition::Any,
3007                &h("o1"),
3008                &mut hist_old,
3009            )
3010            .unwrap();
3011            drop(hist_old);
3012
3013            // Simulate the CLI rename: seed "new" with a fresh journal
3014            // first (mirrors `write_ref_recording_history`'s Missing-CAS
3015            // create), then drop "old" via the history-aware delete.
3016            let mut hist_new = CommitHistory::open_at(exec.clone(), &mkit, "new").unwrap();
3017            update_ref_with_history(
3018                &mkit,
3019                "new",
3020                RefWriteCondition::Missing,
3021                &h("o1"),
3022                &mut hist_new,
3023            )
3024            .unwrap();
3025            assert_eq!(hist_new.len(), 1);
3026            drop(hist_new);
3027
3028            delete_ref_with_history(&mkit, "old", exec.clone()).unwrap();
3029
3030            assert_eq!(read_ref(&mkit, "old").unwrap(), None);
3031            assert_eq!(read_ref(&mkit, "new").unwrap(), Some(h("o1")));
3032            assert!(!mkit.history_dir().join("old__journal-blobs").exists());
3033            assert!(mkit.history_dir().join("new__journal-blobs").exists());
3034
3035            // Recreating "old" afterward must not resume the destroyed
3036            // incarnation's leaf.
3037            let hist_old_reopened = CommitHistory::open_at(exec, &mkit, "old").unwrap();
3038            assert_eq!(hist_old_reopened.len(), 0);
3039        }
3040
3041        /// #658: the CAS-guarded history-mmr delete succeeds and
3042        /// destroys the journal when `expected` still matches.
3043        #[test]
3044        fn delete_ref_with_history_if_matches_succeeds_and_destroys_journal_on_match() {
3045            let (_dir, mkit) = fresh_repo();
3046            let exec = Arc::new(TokioExecutor::new().unwrap());
3047            let mut hist = CommitHistory::open_at(exec.clone(), &mkit, "feature").unwrap();
3048            let tip = h("tip");
3049            update_ref_with_history(&mkit, "feature", RefWriteCondition::Any, &tip, &mut hist)
3050                .unwrap();
3051            drop(hist);
3052
3053            delete_ref_with_history_if_matches(&mkit, "feature", tip, exec).unwrap();
3054
3055            assert_eq!(read_ref(&mkit, "feature").unwrap(), None);
3056            assert!(!mkit.history_dir().join("feature__journal-blobs").exists());
3057        }
3058
3059        /// #658: when `expected` no longer matches (a concurrent
3060        /// history-mmr advance landed first), the CAS-guarded delete
3061        /// must refuse WITHOUT touching either the ref or its journal —
3062        /// unlike the unconditional [`delete_ref_with_history`], which
3063        /// destroys the journal before ever looking at the ref's value.
3064        #[test]
3065        fn delete_ref_with_history_if_matches_leaves_ref_and_journal_untouched_on_conflict() {
3066            let (_dir, mkit) = fresh_repo();
3067            let exec = Arc::new(TokioExecutor::new().unwrap());
3068            let mut hist = CommitHistory::open_at(exec.clone(), &mkit, "feature").unwrap();
3069            let stale = h("stale");
3070            let current = h("current");
3071            update_ref_with_history(&mkit, "feature", RefWriteCondition::Any, &stale, &mut hist)
3072                .unwrap();
3073            update_ref_with_history(
3074                &mkit,
3075                "feature",
3076                RefWriteCondition::Match(stale),
3077                &current,
3078                &mut hist,
3079            )
3080            .unwrap();
3081            let leaves_before = hist.len();
3082            drop(hist);
3083
3084            let err = delete_ref_with_history_if_matches(&mkit, "feature", stale, exec.clone())
3085                .unwrap_err();
3086            assert!(matches!(err, RefError::Conflict(_)));
3087
3088            assert_eq!(read_ref(&mkit, "feature").unwrap(), Some(current));
3089            let hist_reopened = CommitHistory::open_at(exec, &mkit, "feature").unwrap();
3090            assert_eq!(
3091                hist_reopened.len(),
3092                leaves_before,
3093                "a refused conditional delete must not touch the journal"
3094            );
3095        }
3096    }
3097}