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    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    update_ref_with_history_critical_section(
677        layout,
678        branch,
679        condition,
680        hash,
681        history,
682        on_empty_journal,
683    )
684}
685
686/// The lock-HELD body of [`update_ref_with_history_locked`] — reopen,
687/// the empty-journal/one-ahead-gap check, the CAS-write, and the final
688/// append. Callers MUST already hold `history_lock_name(branch)`
689/// before calling this; it does not acquire any lock itself.
690///
691/// Split out so [`open_and_update_ref_with_history_and_backfill`] can
692/// acquire the lock, THEN open `history` (via `CommitHistory::open_at`)
693/// while already holding it, then run this same critical section —
694/// closing the race [`update_ref_with_history_locked`]'s callers are
695/// still exposed to when they open `history` before locking:
696/// `CommitHistory::open_at` performs real disk reads of the on-disk
697/// metadata blob, and if that
698/// read races a concurrent holder's in-progress `sync()` write to the
699/// same blob (commonware's `Metadata` double-buffer scheme has no
700/// locking of its own — mkit's lock is the only synchronization), the
701/// reader can observe a torn/zeroed blob and fail with
702/// `HistoryError::Corrupted`, even though nothing is actually corrupt
703/// on disk once the writer finishes. `reopen()`'s staleness handling
704/// does not cover this: it protects against reading old-but-valid
705/// state, not against reading torn state produced by a write in
706/// progress.
707#[cfg(feature = "history-mmr")]
708fn update_ref_with_history_critical_section<X, G>(
709    layout: &RepoLayout,
710    branch: &str,
711    condition: RefWriteCondition,
712    hash: &Hash,
713    history: &mut crate::history::CommitHistory<X>,
714    mut on_empty_journal: G,
715) -> RefResult<()>
716where
717    X: crate::protocol::async_shim::Executor + 'static,
718    G: FnMut(&mut crate::history::CommitHistory<X>, Hash) -> Result<(), String>,
719{
720    // `history` may have been opened (its `CommitHistory::open_at`
721    // bootstrap read the on-disk journal) before this call took the
722    // lock above. Re-derive it from the current on-disk state now that
723    // we hold the lock, so a concurrent writer's append in that window
724    // can't be appended over (SPEC-HISTORY-PROOF §4.3). A no-op re-read
725    // when `history` was opened after the lock was already held (see
726    // [`open_and_update_ref_with_history_and_backfill`]), which is
727    // harmless — this call is cheap once the journal is open.
728    history
729        .reopen()
730        .map_err(|e| RefError::InvalidRef(format!("{branch}: history reopen: {e}")))?;
731
732    if let Some(current) = read_ref(layout, branch)? {
733        if history.is_empty() {
734            // v0.1.x-style migration (or a crash on this branch's very
735            // first tracked write): the ref already has a value but the
736            // journal has never been touched. Backfill (a no-op for the
737            // non-backfilling entry point) before proceeding, all still
738            // under the lock so no concurrent writer can also observe
739            // "empty" and race this same backfill.
740            on_empty_journal(history, current)
741                .map_err(|e| RefError::InvalidRef(format!("{branch}: history backfill: {e}")))?;
742        } else {
743            // Crash recovery (§4.5): if a prior `update_ref_with_history`
744            // call CAS-wrote the ref but crashed before its own append +
745            // sync, the ref is one commit ahead of the journal. Detect
746            // that by checking whether the journal's last leaf already
747            // covers the ref's CURRENT (pre-this-write) value, and heal
748            // by appending it directly — we know exactly which hash is
749            // missing without walking any parent chain, because it's
750            // precisely the value already sitting in the ref file.
751            heal_one_ahead_gap(history, &current)
752                .map_err(|e| RefError::InvalidRef(format!("{branch}: history recovery: {e}")))?;
753        }
754    }
755
756    update_ref(layout, branch, condition, hash)?;
757    history
758        .append(hash)
759        .map_err(|e| RefError::InvalidRef(format!("{branch}: history append: {e}")))?;
760    Ok(())
761}
762
763/// [`update_ref_with_history_and_backfill`], but closes a race that
764/// shape leaves open when two concurrent writers have never touched
765/// `branch`'s journal before: `CommitHistory::open_at` runs AFTER the
766/// per-branch lock is acquired here, not before, so a concurrent
767/// writer already holding the lock can never be mid-`sync` (writing
768/// the on-disk metadata blob) while this call's own `open_at` reads it
769/// — see this module's private `update_ref_with_history_critical_section`
770/// helper's doc comment for the full mechanism. `mkit-cli`'s
771/// `write_ref_recording_history` is the production caller.
772///
773/// Prefer this over opening a `CommitHistory` yourself and passing it
774/// to [`update_ref_with_history_and_backfill`] whenever the branch may
775/// never have been journaled before and multiple writers can race —
776/// exactly [`update_ref_with_history_and_backfill`]'s own
777/// never-journaled-branch backfill scenario, just one step earlier (at
778/// the open, not just the backfill).
779///
780/// # Errors
781///
782/// Same as [`update_ref_with_history_and_backfill`], plus
783/// [`RefError::InvalidRef`] if `CommitHistory::open_at` fails (lock
784/// acquisition, corrupt journal, or filesystem error).
785#[cfg(feature = "history-mmr")]
786pub fn open_and_update_ref_with_history_and_backfill<X, F, E>(
787    layout: &RepoLayout,
788    branch: &str,
789    condition: RefWriteCondition,
790    hash: &Hash,
791    executor: std::sync::Arc<X>,
792    mut parent_of: F,
793) -> RefResult<()>
794where
795    X: crate::protocol::async_shim::Executor + 'static,
796    F: FnMut(&Hash) -> Result<Option<Hash>, E>,
797    E: core::fmt::Display,
798{
799    let lock_name = history_lock_name(branch);
800    let _lock = crate::repo_lock::acquire_default(layout.common_dir(), &lock_name).map_err(
801        |e| match e {
802            crate::repo_lock::LockError::Io(io) => RefError::Io(io),
803            other => RefError::InvalidRef(format!("{branch}: lock acquisition: {other}")),
804        },
805    )?;
806
807    let mut history = crate::history::CommitHistory::open_at(executor, layout, branch)
808        .map_err(|e| RefError::InvalidRef(format!("{branch}: open history journal: {e}")))?;
809
810    update_ref_with_history_critical_section(
811        layout,
812        branch,
813        condition,
814        hash,
815        &mut history,
816        |history, current| {
817            crate::history::rebuild_from_chain(history, current, &mut parent_of)
818                .map(|_| ())
819                .map_err(|e| e.to_string())
820        },
821    )
822}
823
824/// Heal a journal that is missing its last append relative to
825/// `current_ref_value` (SPEC-HISTORY-PROOF §4.5 crash-recovery case).
826///
827/// If the journal is non-empty, checks whether its last leaf already
828/// verifies as `current_ref_value` via an inclusion proof against the
829/// journal's own root. A verified match means the journal is already
830/// in sync — nothing to do. A failed or unbuildable proof means the
831/// journal's last leaf is stale (or the journal has one fewer leaf
832/// than the ref implies); append `current_ref_value` directly to catch
833/// it up.
834///
835/// An empty journal is left untouched here: it is ambiguous between a
836/// genuinely fresh branch (whose next real write supplies the correct
837/// first leaf) and a deep v0.1.x-style backfill that only a
838/// [`rebuild_from_chain`] with real parent-chain data can resolve —
839/// that migration path needs `ObjectStore` access this module doesn't
840/// have, so it is the caller's (`mkit-cli`) responsibility.
841///
842/// # Errors
843///
844/// Propagates [`HistoryError`] from [`CommitHistory::append`].
845#[cfg(feature = "history-mmr")]
846fn heal_one_ahead_gap<X: crate::protocol::async_shim::Executor + 'static>(
847    history: &mut crate::history::CommitHistory<X>,
848    current_ref_value: &Hash,
849) -> Result<(), crate::history::HistoryError> {
850    let len = history.len();
851    let Some(last) = len.checked_sub(1) else {
852        return Ok(());
853    };
854    let in_sync = history
855        .prove(crate::history::Position(last))
856        .is_ok_and(|proof| {
857            crate::history::verify_inclusion(
858                current_ref_value,
859                crate::history::Position(last),
860                &proof,
861                &history.root(),
862            )
863        });
864    if in_sync {
865        return Ok(());
866    }
867    history.append(current_ref_value)?;
868    Ok(())
869}
870
871/// Delete a branch ref. Errors with [`RefError::NotFound`] if absent.
872pub fn delete_ref(layout: &RepoLayout, branch: &str) -> RefResult<()> {
873    if !validate_ref_name(branch) {
874        return Err(RefError::InvalidRefName(branch.to_string()));
875    }
876    let path = ref_path(layout.common_dir(), HEADS_DIR, branch);
877    match fs::remove_file(&path) {
878        Ok(()) => Ok(()),
879        Err(e) if e.kind() == io::ErrorKind::NotFound => {
880            Err(RefError::NotFound(branch.to_string()))
881        }
882        Err(e) => Err(RefError::Io(e)),
883    }
884}
885
886/// Delete a branch ref unless it is the currently checked-out branch.
887pub fn delete_ref_safe(layout: &RepoLayout, branch: &str) -> RefResult<()> {
888    match read_head(layout) {
889        Ok(Head::Branch(current)) if current == branch => {
890            Err(RefError::CurrentBranch(branch.to_string()))
891        }
892        _ => delete_ref(layout, branch),
893    }
894}
895
896/// CAS-guarded delete: removes a branch ref only if its current on-disk
897/// value is exactly `expected`. Issue #658.
898///
899/// [`delete_ref`] is unconditional — it removes whatever is at `path`
900/// regardless of what a caller last read. That is fine for the
901/// user-initiated `branch -d`/`-D` path (deleting a specific named
902/// branch is meaningful even if its tip moved since the user last
903/// looked), but it is NOT fine for `branch -m`'s rename: a rename reads
904/// the source branch's tip, publishes it under the new name, and then
905/// drops the source ref. If a concurrent `commit` (via
906/// [`RefWriteCondition::Match`], see `mkit-cli`'s `advance_head`)
907/// advances the source branch in the window between the rename's read
908/// and its delete, an unconditional delete destroys that freshly-landed
909/// commit's only ref with no error to either caller — commit reports
910/// success, rename reports success, and the commit becomes unreachable.
911///
912/// This closes that gap by making the delete itself compare-and-swap:
913/// it acquires the SAME per-ref lock `cas_write`'s `Match` arm takes
914/// (via `cas_lock_name`, keyed off the ref's path so it can never
915/// collide with an unrelated ref of the same bare name), reads the
916/// current value under that lock, and only removes the file if it is
917/// still exactly `expected`. Because a concurrent `Match`-conditioned
918/// advance on the SAME ref takes the identical lock, the two can never
919/// interleave: either the advance's CAS write lands first (this call
920/// then sees the new value, doesn't match, and errors `Conflict`
921/// without touching the file) or this delete lands first (the advance's
922/// subsequent `Match(expected)` then sees the ref gone and itself fails
923/// `Conflict`) — never both "succeeding" against the same prior state.
924///
925/// Note this only closes the race against OTHER `Match`-locked writers.
926/// [`RefWriteCondition::Any`] writers never take this lock (by design —
927/// `Any` has no precondition to protect), so an `Any` advance racing a
928/// conditioned delete on the same ref is a caller error the lock cannot
929/// paper over; `mkit-cli`'s `commit` must use `Match`/`Missing` (not
930/// `Any`) for this guarantee to hold end-to-end (see issue #658's Fix
931/// B, `advance_head`).
932///
933/// # Errors
934/// - [`RefError::InvalidRefName`] if `branch` is not a valid name.
935/// - [`RefError::Conflict`] if the ref's current value is not exactly
936///   `expected` — this includes the ref not existing at all. The ref
937///   file is left completely untouched in this case.
938/// - [`RefError::Io`] for filesystem or lock-acquisition failures.
939pub fn delete_ref_if_matches(layout: &RepoLayout, branch: &str, expected: Hash) -> RefResult<()> {
940    if !validate_ref_name(branch) {
941        return Err(RefError::InvalidRefName(branch.to_string()));
942    }
943    let common_dir = layout.common_dir();
944    let path = ref_path(common_dir, HEADS_DIR, branch);
945
946    let lock_name = cas_lock_name(common_dir, &path);
947    let _lock = crate::repo_lock::acquire_default(common_dir, &lock_name).map_err(|e| match e {
948        crate::repo_lock::LockError::Io(io) => RefError::Io(io),
949        other => RefError::InvalidRef(format!("{branch}: refs.lock acquisition: {other}")),
950    })?;
951
952    let current = match fs::read(&path) {
953        Ok(b) => Some(decode_ref_wire(&b).ok_or_else(|| RefError::InvalidRef(branch.to_string()))?),
954        Err(e) if e.kind() == io::ErrorKind::NotFound => None,
955        Err(e) => return Err(RefError::Io(e)),
956    };
957    if current != Some(expected) {
958        return Err(RefError::Conflict(branch.to_string()));
959    }
960    match fs::remove_file(&path) {
961        Ok(()) => Ok(()),
962        // Another caller can't have raced us here — we hold the lock
963        // guarding every write AND delete path to this ref — but treat
964        // a vanished file as a conflict rather than success, matching
965        // this function's fail-closed contract instead of assuming.
966        Err(e) if e.kind() == io::ErrorKind::NotFound => {
967            Err(RefError::Conflict(branch.to_string()))
968        }
969        Err(e) => Err(RefError::Io(e)),
970    }
971}
972
973// -----------------------------------------------------------------------------
974// History-coupled branch delete (feature: history-mmr) — issue #648
975// -----------------------------------------------------------------------------
976//
977// Deleting a branch ref alone leaves its `history/<branch>__*` journal
978// partition on disk. Since the partition is keyed on the branch NAME
979// (sanitized, not on any per-incarnation identifier), a later branch
980// created with the same name reopens the dead incarnation's non-empty
981// journal via `CommitHistory::open_at` and resumes appending on top of
982// its old leaves — the new branch's MMR root then spans two unrelated
983// incarnations, and the deleted incarnation's stale leaves keep
984// producing valid-looking inclusion proofs "on this branch". These
985// functions close that hole by destroying the journal as part of the
986// same delete, so a branch name and its journal are always retired
987// together.
988
989/// Delete a branch ref and permanently destroy its history-MMR journal
990/// partition, so a later branch created with the same name never
991/// resumes a deleted incarnation's leaves (issue #648).
992///
993/// Order: the journal is destroyed **before** the ref file is removed.
994/// That means once this call returns `Ok`, both are gone together with
995/// no window in between. If the process is interrupted between the two
996/// steps, the ref still exists (the caller sees this call never
997/// returned, so the delete visibly did not complete) but its journal is
998/// already gone; retrying re-creates a fresh empty journal via
999/// `CommitHistory::open_at`, destroys that (a cheap no-op — nothing was
1000/// appended to it), and finishes the ref removal. There is no ordering
1001/// under which `delete_ref` can report success while the old journal
1002/// survives on disk, which is exactly the state that would let a
1003/// same-named recreated branch inherit it.
1004///
1005/// Does not check whether `branch` is the currently checked-out branch
1006/// — see [`delete_ref_safe_with_history`] for that guard. Used directly
1007/// by `mkit branch -m` (rename), which intentionally deletes the OLD
1008/// name's ref even when it is the checked-out branch (HEAD is moved to
1009/// the new name by the caller).
1010///
1011/// # Errors
1012///
1013/// - [`RefError::NotFound`] — `branch` has no ref on disk. Checked
1014///   before the journal is touched, so a typo'd/absent branch name
1015///   never creates a journal partition just to destroy it.
1016/// - [`RefError::InvalidRef`] — the history journal could not be opened
1017///   or destroyed. The wrapped [`crate::history::HistoryError`] is
1018///   exposed via a `String` payload, matching
1019///   [`update_ref_with_history`]'s convention.
1020/// - [`RefError::Io`] — filesystem failure removing the ref file
1021///   itself, or acquiring the per-branch history lock.
1022///
1023/// # Locking
1024///
1025/// Runs the read-check + journal-destroy + ref-delete sequence under
1026/// the same per-branch `refs-history-<branch>.lock`
1027/// `update_ref_with_history_locked` uses (found during code review
1028/// after #638 landed: this function originally ran unlocked, which
1029/// reopened exactly the race #638 closed one layer up — a concurrent
1030/// ref-only writer that deliberately skips the worktree lock, e.g.
1031/// `update-ref`, could interleave its journal append with this
1032/// delete's journal destroy). Held for the whole sequence, not just
1033/// the destroy, so a concurrent `update_ref_with_history*` call on the
1034/// SAME branch either fully precedes or fully follows this delete —
1035/// never interleaves with it. Scoped per branch (not repo-wide), so
1036/// this never contends with an operation on an unrelated branch.
1037#[cfg(feature = "history-mmr")]
1038pub fn delete_ref_with_history<X: crate::protocol::async_shim::Executor + 'static>(
1039    layout: &RepoLayout,
1040    branch: &str,
1041    executor: std::sync::Arc<X>,
1042) -> RefResult<()> {
1043    let lock_name = history_lock_name(branch);
1044    let _lock = crate::repo_lock::acquire_default(layout.common_dir(), &lock_name).map_err(
1045        |e| match e {
1046            crate::repo_lock::LockError::Io(io) => RefError::Io(io),
1047            other => RefError::InvalidRef(format!("{branch}: lock acquisition: {other}")),
1048        },
1049    )?;
1050
1051    if read_ref(layout, branch)?.is_none() {
1052        return Err(RefError::NotFound(branch.to_string()));
1053    }
1054
1055    let history = crate::history::CommitHistory::open_at(executor, layout, branch)
1056        .map_err(|e| RefError::InvalidRef(format!("{branch}: open history journal: {e}")))?;
1057    history
1058        .destroy()
1059        .map_err(|e| RefError::InvalidRef(format!("{branch}: destroy history journal: {e}")))?;
1060
1061    delete_ref(layout, branch)
1062}
1063
1064/// [`delete_ref_with_history`], but CAS-guarded like
1065/// [`delete_ref_if_matches`]: only deletes (and destroys the journal of)
1066/// `branch` if its current on-disk value is exactly `expected`. Issue
1067/// #658 — the `history-mmr` counterpart `mkit branch -m` routes through
1068/// so a rename's rollback of a lost race also drops the correct
1069/// journal.
1070///
1071/// # Locking
1072///
1073/// Lock order MUST stay `history_lock_name` (outer), then
1074/// [`delete_ref_if_matches`]'s `cas_lock_name` (inner) — the exact order
1075/// `update_ref_with_history_locked` already uses (it calls into
1076/// [`update_ref`], which calls `cas_write`, while still holding
1077/// `history_lock_name`). Reversing this for just this one caller would
1078/// open a deadlock class between this delete and a concurrent
1079/// `update_ref_with_history*` call on the same branch — two locks
1080/// acquired in opposite orders by different call paths is exactly the
1081/// shape a deadlock needs, even though today's callers happen not to
1082/// hold both at once. Since `history_lock_name` is taken first and held
1083/// for this call's entire body, no concurrent history-mmr writer on
1084/// THIS branch can interleave with the check below — so it is safe to
1085/// read-then-act non-atomically with respect to those callers; the
1086/// still-needed atomicity against a plain (non-history) `Match` writer
1087/// on the same ref (relevant on a build with `history-mmr` racing one
1088/// without it, or the file transport) is what `cas_lock_name` inside
1089/// [`delete_ref_if_matches`] itself continues to provide.
1090///
1091/// The CAS condition is checked BEFORE the journal is touched (unlike
1092/// [`delete_ref_with_history`]'s unconditional destroy-then-delete
1093/// ordering): destroying the journal first and only then discovering
1094/// the delete itself must fail would leave a live ref with a destroyed
1095/// journal — precisely the torn state
1096/// `delete_ref_with_history_races_update_without_tearing_ref_and_journal`
1097/// guards against. A failed call here leaves BOTH the ref and its
1098/// journal completely untouched.
1099///
1100/// # Errors
1101///
1102/// - [`RefError::Conflict`] — `branch`'s current value is not exactly
1103///   `expected` (including "does not exist"). Neither the ref nor its
1104///   journal are touched.
1105/// - Otherwise the same errors as [`delete_ref_with_history`].
1106#[cfg(feature = "history-mmr")]
1107pub fn delete_ref_with_history_if_matches<X: crate::protocol::async_shim::Executor + 'static>(
1108    layout: &RepoLayout,
1109    branch: &str,
1110    expected: Hash,
1111    executor: std::sync::Arc<X>,
1112) -> RefResult<()> {
1113    let lock_name = history_lock_name(branch);
1114    let _lock = crate::repo_lock::acquire_default(layout.common_dir(), &lock_name).map_err(
1115        |e| match e {
1116            crate::repo_lock::LockError::Io(io) => RefError::Io(io),
1117            other => RefError::InvalidRef(format!("{branch}: lock acquisition: {other}")),
1118        },
1119    )?;
1120
1121    match read_ref(layout, branch)? {
1122        Some(current) if current == expected => {}
1123        _ => return Err(RefError::Conflict(branch.to_string())),
1124    }
1125
1126    let history = crate::history::CommitHistory::open_at(executor, layout, branch)
1127        .map_err(|e| RefError::InvalidRef(format!("{branch}: open history journal: {e}")))?;
1128    history
1129        .destroy()
1130        .map_err(|e| RefError::InvalidRef(format!("{branch}: destroy history journal: {e}")))?;
1131
1132    // Re-checks the condition under `cas_lock_name` before removing the
1133    // file — redundant with the read above given `history_lock_name` is
1134    // still held (nothing on this branch could have moved it), but this
1135    // keeps a single source of truth for "delete iff still `expected`"
1136    // rather than duplicating `fs::remove_file` here.
1137    delete_ref_if_matches(layout, branch, expected)
1138}
1139
1140/// [`delete_ref_with_history`] guarded by the same current-branch check
1141/// as [`delete_ref_safe`] — refuses to delete (and does not touch the
1142/// journal of) the branch HEAD currently points at. This is the
1143/// history-mmr counterpart `mkit branch -d`/`-D` route through.
1144///
1145/// # Errors
1146///
1147/// [`RefError::CurrentBranch`] if `branch` is the checked-out branch;
1148/// otherwise the same errors as [`delete_ref_with_history`].
1149#[cfg(feature = "history-mmr")]
1150pub fn delete_ref_safe_with_history<X: crate::protocol::async_shim::Executor + 'static>(
1151    layout: &RepoLayout,
1152    branch: &str,
1153    executor: std::sync::Arc<X>,
1154) -> RefResult<()> {
1155    match read_head(layout) {
1156        Ok(Head::Branch(current)) if current == branch => {
1157            Err(RefError::CurrentBranch(branch.to_string()))
1158        }
1159        _ => delete_ref_with_history(layout, branch, executor),
1160    }
1161}
1162
1163/// List all branch refs, sorted lexicographically by name.
1164pub fn list_refs(layout: &RepoLayout) -> RefResult<Vec<Ref>> {
1165    list_refs_under(layout.common_dir(), HEADS_DIR)
1166}
1167
1168// -----------------------------------------------------------------------------
1169// Remote-tracking refs (refs/remotes/<remote>/<branch>)
1170// -----------------------------------------------------------------------------
1171
1172/// Read a remote-tracking branch ref.
1173pub fn read_remote_ref(layout: &RepoLayout, remote: &str, branch: &str) -> RefResult<Option<Hash>> {
1174    validate_remote_and_branch(remote, branch)?;
1175    read_ref_under(layout.common_dir(), &remote_ref_dir(remote), branch)
1176}
1177
1178/// Write a remote-tracking branch ref unconditionally.
1179pub fn write_remote_ref(
1180    layout: &RepoLayout,
1181    remote: &str,
1182    branch: &str,
1183    h: &Hash,
1184) -> RefResult<()> {
1185    validate_remote_and_branch(remote, branch)?;
1186    let path = ref_path(layout.common_dir(), &remote_ref_dir(remote), branch);
1187    let wire = encode_ref_wire(h);
1188    cas_write(
1189        layout.common_dir(),
1190        &path,
1191        &wire,
1192        branch,
1193        RefWriteCondition::Any,
1194    )
1195}
1196
1197/// Batched writer for remote-tracking refs (#645): amortises the
1198/// parent-directory fsync across every ref written into it, instead of
1199/// paying one per ref like [`write_remote_ref`] in a loop.
1200///
1201/// `push`/`fetch` publish one tracking ref per branch
1202/// (`refs/remotes/<remote>/<branch>`) in a loop; each individual
1203/// [`write_remote_ref`] call goes through `write_atomic`'s
1204/// temp+fsync+rename+**dirsync** pattern, so N branches cost N serial
1205/// directory fsyncs even though every write lands under the same
1206/// `refs/remotes/<remote>/` tree. `RemoteRefBatch` instead:
1207///
1208/// 1. [`Self::write`]s each ref durable-content-before-visible (fsyncs
1209///    the wire bytes, then renames — same invariant
1210///    `crate::atomic::write_content_synced` gives object writes: a
1211///    reader can never observe a torn file), immediately, one call at a
1212///    time, deferring only the directory fsync that makes the RENAME
1213///    itself crash-durable;
1214/// 2. [`Self::commit`] fsyncs every distinct directory touched, once
1215///    each, deduplicated — O(distinct directories) instead of O(refs).
1216///    For the common case (one remote, flat branch names) that is
1217///    exactly one fsync for the whole batch.
1218///
1219/// This is deliberately scoped to `refs/remotes/*` ONLY — see
1220/// `atomic.rs`'s module docs for why branch heads (`refs/heads/*`,
1221/// [`write_ref`]/[`update_ref`]) keep the unbatched per-write contract:
1222/// tracking refs are a locally-cached, recomputable-from-a-re-fetch
1223/// view of another repository, not a pointer anything else orders
1224/// against.
1225///
1226/// # Partial-failure semantics
1227///
1228/// Best-effort, fail-fast — the same contract
1229/// [`crate::batch::WriteBatch::commit`] documents for the object store's
1230/// batched writes. [`Self::write`] renames each ref as soon as it
1231/// validates and its content is durable, so a ref that made it through
1232/// `write` is visible to readers immediately, exactly as it would have
1233/// been under the old per-ref loop. If a later `write` in the same
1234/// batch fails (bad name or I/O error), earlier successful writes are
1235/// **not** rolled back — content-addressed dedup isn't in play here,
1236/// but the same reasoning applies: remote-tracking refs are
1237/// recomputable, so a partially-applied batch is exactly as safe to
1238/// retry as the old loop was after failing at the same point. Callers
1239/// that want the successful prefix to be durable even when the batch as
1240/// a whole errors out should call [`Self::commit`] regardless of
1241/// whether the write loop returned early (see
1242/// `remote_dispatch::push_all_with` / `fetch_objects_inner` for the
1243/// pattern).
1244#[derive(Debug)]
1245pub struct RemoteRefBatch<'a> {
1246    layout: &'a RepoLayout,
1247    sub_dir: String,
1248    touched_dirs: BTreeSet<PathBuf>,
1249}
1250
1251impl<'a> RemoteRefBatch<'a> {
1252    /// Start a batch for `remote`.
1253    ///
1254    /// # Errors
1255    /// [`RefError::InvalidRefName`] if `remote` fails [`validate_ref_name`].
1256    pub fn new(layout: &'a RepoLayout, remote: &str) -> RefResult<Self> {
1257        if !validate_ref_name(remote) {
1258            return Err(RefError::InvalidRefName(remote.to_string()));
1259        }
1260        Ok(Self {
1261            layout,
1262            sub_dir: remote_ref_dir(remote),
1263            touched_dirs: BTreeSet::new(),
1264        })
1265    }
1266
1267    /// Write one remote-tracking ref: validates `branch`, fsyncs its
1268    /// wire-encoded content, then renames it into place — visible
1269    /// immediately, same as [`write_remote_ref`]. Only the directory
1270    /// fsync (rename durability) is deferred to [`Self::commit`].
1271    ///
1272    /// # Errors
1273    /// - [`RefError::InvalidRefName`] if `branch` fails
1274    ///   [`validate_ref_name`] — no I/O is attempted for an invalid name.
1275    /// - [`RefError::Io`] for filesystem failures.
1276    ///
1277    /// # Panics
1278    /// Never in practice: `ref_path` always produces a path with a
1279    /// parent (it is `self.layout.common_dir()` joined with at least the
1280    /// remote's subdirectory and the branch name).
1281    pub fn write(&mut self, branch: &str, h: &Hash) -> RefResult<()> {
1282        if !validate_ref_name(branch) {
1283            return Err(RefError::InvalidRefName(branch.to_string()));
1284        }
1285        let path = ref_path(self.layout.common_dir(), &self.sub_dir, branch);
1286        let parent = path
1287            .parent()
1288            .expect("remote-tracking ref path always has a parent")
1289            .to_path_buf();
1290        fs::create_dir_all(&parent)?;
1291        let wire = encode_ref_wire(h);
1292        crate::atomic::write_content_synced(&path, &wire)?;
1293        self.touched_dirs.insert(parent);
1294        Ok(())
1295    }
1296
1297    /// Fsync every directory touched by a completed [`Self::write`],
1298    /// once each. Idempotent to call on a batch with nothing staged (a
1299    /// no-op). MUST be called after the last `write` a caller intends to
1300    /// make durable — refs written but never committed are visible but
1301    /// not crash-durable (the rename may not have reached stable
1302    /// storage), the same window [`crate::store::BulkWriter::commit`]
1303    /// documents for bulk object writes.
1304    ///
1305    /// # Errors
1306    /// [`RefError::Io`] on the first directory fsync failure. Directories
1307    /// are synced in sorted order for determinism; a failure partway
1308    /// through leaves the remaining directories un-synced (best-effort,
1309    /// matching [`Self::write`]'s partial-failure contract).
1310    pub fn commit(self) -> RefResult<()> {
1311        for dir in &self.touched_dirs {
1312            crate::atomic::sync_dir(dir)?;
1313        }
1314        Ok(())
1315    }
1316}
1317
1318/// Delete a remote-tracking branch ref (e.g. after the upstream
1319/// deleted the branch). Errors with [`RefError::NotFound`] if absent.
1320pub fn delete_remote_ref(layout: &RepoLayout, remote: &str, branch: &str) -> RefResult<()> {
1321    validate_remote_and_branch(remote, branch)?;
1322    let path = ref_path(layout.common_dir(), &remote_ref_dir(remote), branch);
1323    match fs::remove_file(&path) {
1324        Ok(()) => Ok(()),
1325        Err(e) if e.kind() == io::ErrorKind::NotFound => {
1326            Err(RefError::NotFound(format!("{remote}/{branch}")))
1327        }
1328        Err(e) => Err(RefError::Io(e)),
1329    }
1330}
1331
1332/// List all remote-tracking refs for one remote.
1333pub fn list_remote_refs(layout: &RepoLayout, remote: &str) -> RefResult<Vec<Ref>> {
1334    if !validate_ref_name(remote) {
1335        return Err(RefError::InvalidRefName(remote.to_string()));
1336    }
1337    list_refs_under(layout.common_dir(), &remote_ref_dir(remote))
1338}
1339
1340/// List the remote names that have at least one tracking ref on disk
1341/// (the immediate subdirectories of `refs/remotes/`), sorted. A
1342/// missing `refs/remotes/` yields an empty list. Entries whose names
1343/// fail the ref grammar are skipped (consistent with how malformed
1344/// ref files are skipped by [`list_refs`]).
1345pub fn list_remote_names(layout: &RepoLayout) -> RefResult<Vec<String>> {
1346    let dir = layout.remotes_dir();
1347    let entries = match fs::read_dir(&dir) {
1348        Ok(e) => e,
1349        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
1350        Err(e) => return Err(RefError::Io(e)),
1351    };
1352    let mut names = Vec::new();
1353    for entry in entries {
1354        let entry = entry.map_err(RefError::Io)?;
1355        if !entry.file_type().map_err(RefError::Io)?.is_dir() {
1356            continue;
1357        }
1358        if let Some(name) = entry.file_name().to_str()
1359            && validate_ref_name(name)
1360        {
1361            names.push(name.to_owned());
1362        }
1363    }
1364    names.sort();
1365    Ok(names)
1366}
1367
1368// -----------------------------------------------------------------------------
1369// Tags (refs/tags/<name>)
1370// -----------------------------------------------------------------------------
1371
1372/// Read the hash a tag points to.
1373pub fn read_tag(layout: &RepoLayout, name: &str) -> RefResult<Option<Hash>> {
1374    if !validate_ref_name(name) {
1375        return Err(RefError::InvalidRefName(name.to_string()));
1376    }
1377    read_ref_under(layout.common_dir(), TAGS_DIR, name)
1378}
1379
1380/// Write a tag ref (unconditional).
1381pub fn write_tag(layout: &RepoLayout, name: &str, h: &Hash) -> RefResult<()> {
1382    update_tag(layout, name, RefWriteCondition::Any, h)
1383}
1384
1385/// CAS-aware tag write — same semantics as [`update_ref`] but for
1386/// `refs/tags/`.
1387pub fn update_tag(
1388    layout: &RepoLayout,
1389    name: &str,
1390    condition: RefWriteCondition,
1391    h: &Hash,
1392) -> RefResult<()> {
1393    if !validate_ref_name(name) {
1394        return Err(RefError::InvalidRefName(name.to_string()));
1395    }
1396    let path = ref_path(layout.common_dir(), TAGS_DIR, name);
1397    let wire = encode_ref_wire(h);
1398    cas_write(layout.common_dir(), &path, &wire, name, condition)
1399}
1400
1401/// Delete a tag ref.
1402pub fn delete_tag(layout: &RepoLayout, name: &str) -> RefResult<()> {
1403    if !validate_ref_name(name) {
1404        return Err(RefError::InvalidRefName(name.to_string()));
1405    }
1406    let path = ref_path(layout.common_dir(), TAGS_DIR, name);
1407    match fs::remove_file(&path) {
1408        Ok(()) => Ok(()),
1409        Err(e) if e.kind() == io::ErrorKind::NotFound => Err(RefError::NotFound(name.to_string())),
1410        Err(e) => Err(RefError::Io(e)),
1411    }
1412}
1413
1414/// List all tag refs, sorted lexicographically by name.
1415pub fn list_tags(layout: &RepoLayout) -> RefResult<Vec<Ref>> {
1416    list_refs_under(layout.common_dir(), TAGS_DIR)
1417}
1418
1419// -----------------------------------------------------------------------------
1420// Shallow boundaries (.mkit/shallow)
1421// -----------------------------------------------------------------------------
1422
1423/// Load shallow-boundary hashes from `.mkit/shallow`. Returns `Ok(None)`
1424/// if the file does not exist or is empty.
1425pub fn load_shallow_boundaries(layout: &RepoLayout) -> RefResult<Option<Vec<Hash>>> {
1426    let path = layout.shallow_file();
1427    let meta = match fs::metadata(&path) {
1428        Ok(m) => m,
1429        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
1430        Err(e) => return Err(RefError::Io(e)),
1431    };
1432    if meta.len() == 0 {
1433        return Ok(None);
1434    }
1435    if meta.len() > SHALLOW_MAX_BYTES {
1436        return Err(RefError::InvalidRef("shallow file too large".to_string()));
1437    }
1438    let bytes = fs::read(&path)?;
1439    let s = core::str::from_utf8(&bytes).map_err(|_| RefError::InvalidHead)?;
1440    let mut out = Vec::new();
1441    for line in s.split('\n') {
1442        let trimmed = line.trim_end_matches(['\r', ' ', '\t']);
1443        if trimmed.len() != HEX_LEN {
1444            continue;
1445        }
1446        if let Some(h) = parse_lowercase_hash(trimmed.as_bytes()) {
1447            out.push(h);
1448        }
1449    }
1450    if out.is_empty() {
1451        return Ok(None);
1452    }
1453    Ok(Some(out))
1454}
1455
1456/// Write shallow-boundary hashes to `.mkit/shallow`. Passing an empty
1457/// slice removes the file.
1458pub fn write_shallow_boundaries(layout: &RepoLayout, boundaries: &[Hash]) -> RefResult<()> {
1459    let path = layout.shallow_file();
1460    if boundaries.is_empty() {
1461        match fs::remove_file(&path) {
1462            Ok(()) => Ok(()),
1463            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
1464            Err(e) => Err(RefError::Io(e)),
1465        }
1466    } else {
1467        let mut out = Vec::with_capacity(boundaries.len() * 65);
1468        for h in boundaries {
1469            out.extend_from_slice(&encode_ref_wire(h));
1470        }
1471        write_atomic(&path, &out, true)?;
1472        Ok(())
1473    }
1474}
1475
1476// -----------------------------------------------------------------------------
1477// Internals
1478// -----------------------------------------------------------------------------
1479
1480fn ref_path(common_dir: &Path, sub_dir: &str, name: &str) -> PathBuf {
1481    let mut path = common_dir.join(sub_dir);
1482    for segment in name.split('/') {
1483        path.push(segment);
1484    }
1485    path
1486}
1487
1488fn remote_ref_dir(remote: &str) -> String {
1489    format!("{REMOTES_DIR}/{remote}")
1490}
1491
1492fn validate_remote_and_branch(remote: &str, branch: &str) -> RefResult<()> {
1493    if !validate_ref_name(remote) {
1494        return Err(RefError::InvalidRefName(remote.to_string()));
1495    }
1496    if !validate_ref_name(branch) {
1497        return Err(RefError::InvalidRefName(branch.to_string()));
1498    }
1499    Ok(())
1500}
1501
1502fn read_ref_under(common_dir: &Path, sub_dir: &str, name: &str) -> RefResult<Option<Hash>> {
1503    let path = ref_path(common_dir, sub_dir, name);
1504    let meta = match fs::metadata(&path) {
1505        Ok(m) => m,
1506        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
1507        Err(e) => return Err(RefError::Io(e)),
1508    };
1509    if meta.len() > REF_FILE_MAX_BYTES {
1510        return Err(RefError::InvalidRef(name.to_string()));
1511    }
1512    let bytes = fs::read(&path)?;
1513    let h = decode_ref_wire(&bytes).ok_or_else(|| RefError::InvalidRef(name.to_string()))?;
1514    Ok(Some(h))
1515}
1516
1517fn cas_write(
1518    common_dir: &Path,
1519    path: &Path,
1520    wire: &[u8; 65],
1521    name_for_err: &str,
1522    condition: RefWriteCondition,
1523) -> RefResult<()> {
1524    match condition {
1525        RefWriteCondition::Any => {
1526            write_atomic(path, wire, true)?;
1527            Ok(())
1528        }
1529        RefWriteCondition::Missing => {
1530            // O_EXCL — fail if anything is at `path` already.
1531            if let Some(parent) = path.parent() {
1532                fs::create_dir_all(parent)?;
1533            }
1534            let created = write_create_new(path, wire, true)?;
1535            if !created {
1536                return Err(RefError::Conflict(name_for_err.to_string()));
1537            }
1538            Ok(())
1539        }
1540        RefWriteCondition::Match(expected) => {
1541            // INV-15/#637: the read-compare-write below has no
1542            // filesystem-level atomicity of its own, and callers cannot
1543            // be relied on to already share a lock that covers this —
1544            // `branch -m` (`worktrees.lock`) racing `commit`
1545            // (`worktree.lock`), or two linked worktrees each holding
1546            // their own `worktree.lock`, both write `refs/heads/*`
1547            // through this path without ever sharing a lock. Take a
1548            // dedicated per-ref lock (same blocking kernel-lock
1549            // primitive `repo_lock` uses elsewhere, e.g.
1550            // `update_ref_with_history`'s per-branch `refs-history-*.lock`)
1551            // scoped to the whole read-compare-write so any two callers
1552            // on the same repo targeting the SAME ref — regardless of
1553            // what other locks they hold — serialize here and cannot
1554            // both observe a stale-but-still-matching `current` value.
1555            //
1556            // Keyed off `path` (relative to `common_dir`), not just
1557            // `name_for_err`, so a branch and a tag/remote-ref sharing
1558            // the same bare name (e.g. both named "main") get distinct
1559            // locks — `name_for_err` alone can be just the bare name
1560            // (see `write_remote_ref`), which would otherwise cause
1561            // unrelated ref kinds to falsely contend. Scoped per ref
1562            // (not repo-wide) since nothing about this CAS invariant
1563            // spans refs (found during the epic-#634 code review).
1564            let lock_name = cas_lock_name(common_dir, path);
1565            let _lock =
1566                crate::repo_lock::acquire_default(common_dir, &lock_name).map_err(|e| match e {
1567                    crate::repo_lock::LockError::Io(io) => RefError::Io(io),
1568                    other => RefError::InvalidRef(format!(
1569                        "{name_for_err}: refs.lock acquisition: {other}"
1570                    )),
1571                })?;
1572
1573            let current = match fs::read(path) {
1574                Ok(b) => Some(
1575                    decode_ref_wire(&b)
1576                        .ok_or_else(|| RefError::InvalidRef(name_for_err.to_string()))?,
1577                ),
1578                Err(e) if e.kind() == io::ErrorKind::NotFound => None,
1579                Err(e) => return Err(RefError::Io(e)),
1580            };
1581            if current != Some(expected) {
1582                return Err(RefError::Conflict(name_for_err.to_string()));
1583            }
1584            write_atomic(path, wire, true)?;
1585            Ok(())
1586        }
1587    }
1588}
1589
1590fn list_refs_under(common_dir: &Path, sub_dir: &str) -> RefResult<Vec<Ref>> {
1591    let root = common_dir.join(sub_dir);
1592    let mut out = Vec::new();
1593    if !root.is_dir() {
1594        return Ok(out);
1595    }
1596    collect_refs(&root, "", &mut out, 0)?;
1597    out.sort_by(|a, b| a.name.cmp(&b.name));
1598    Ok(out)
1599}
1600
1601/// Cap on ref-tree recursion depth. A malicious or corrupt `.mkit/refs/`
1602/// directory with deeply nested empty dirs should not stack-overflow
1603/// the walker. 32 is far beyond anything a valid ref name (which cannot
1604/// contain more than a few `/` separators given the 1–255 path-segment
1605/// grammar) could ever require.
1606const MAX_REF_DEPTH: usize = 32;
1607
1608fn collect_refs(root: &Path, prefix: &str, out: &mut Vec<Ref>, depth: usize) -> RefResult<()> {
1609    if depth > MAX_REF_DEPTH {
1610        // Silently stop — same "skip malformed" posture as below for
1611        // individual files. Callers get a partial result rather than a
1612        // stack overflow on adversarial input.
1613        return Ok(());
1614    }
1615    let dir_path = if prefix.is_empty() {
1616        root.to_path_buf()
1617    } else {
1618        root.join(prefix)
1619    };
1620    let iter = match fs::read_dir(&dir_path) {
1621        Ok(i) => i,
1622        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
1623        Err(e) => return Err(RefError::Io(e)),
1624    };
1625    for entry in iter {
1626        let entry = entry?;
1627        let file_name = match entry.file_name().to_str() {
1628            Some(s) => s.to_string(),
1629            None => continue, // Non-UTF-8 names cannot be valid ref names.
1630        };
1631        let child_name = if prefix.is_empty() {
1632            file_name.clone()
1633        } else {
1634            format!("{prefix}/{file_name}")
1635        };
1636        let ft = entry.file_type()?;
1637        if ft.is_dir() {
1638            collect_refs(root, &child_name, out, depth + 1)?;
1639            continue;
1640        }
1641        if !ft.is_file() {
1642            continue;
1643        }
1644        if !validate_ref_name(&child_name) {
1645            continue;
1646        }
1647        // Read & decode; silently skip malformed files.
1648        let Ok(bytes) = fs::read(entry.path()) else {
1649            continue;
1650        };
1651        let hash = decode_ref_wire(&bytes);
1652        out.push(Ref {
1653            name: child_name,
1654            hash,
1655        });
1656    }
1657    Ok(())
1658}
1659
1660// -----------------------------------------------------------------------------
1661// Internal hash helper re-exports for goldens
1662// -----------------------------------------------------------------------------
1663
1664/// Internal re-export used by the integration tests to hand-roll wire
1665/// bytes without round-tripping through `hash::from_hex`.
1666#[doc(hidden)]
1667#[must_use]
1668pub fn _hash_from_lowercase_hex_for_tests(s: &str) -> Option<Hash> {
1669    parse_lowercase_hash(s.as_bytes())
1670}
1671
1672#[cfg(test)]
1673mod tests {
1674    use super::*;
1675    use crate::hash;
1676    use std::sync::Barrier;
1677    use tempfile::TempDir;
1678
1679    fn fresh_repo() -> (TempDir, RepoLayout) {
1680        let dir = TempDir::new().unwrap();
1681        let layout = RepoLayout::single(dir.path());
1682        fs::create_dir_all(layout.common_dir()).unwrap();
1683        init(&layout).unwrap();
1684        (dir, layout)
1685    }
1686
1687    fn h(seed: &str) -> Hash {
1688        hash::hash(seed.as_bytes())
1689    }
1690
1691    // --- name grammar ---------------------------------------------------
1692
1693    #[test]
1694    fn validate_accepts_simple_names() {
1695        assert!(validate_ref_name("main"));
1696        assert!(validate_ref_name("feat/v1.0-beta"));
1697        assert!(validate_ref_name("release/2024_09"));
1698    }
1699
1700    #[test]
1701    fn validate_rejects_empty() {
1702        assert!(!validate_ref_name(""));
1703    }
1704
1705    #[test]
1706    fn validate_rejects_leading_slash() {
1707        assert!(!validate_ref_name("/main"));
1708    }
1709
1710    #[test]
1711    fn validate_rejects_dotdot_segment() {
1712        assert!(!validate_ref_name("feat/.."));
1713        assert!(!validate_ref_name("../escape"));
1714        assert!(!validate_ref_name("feat/./topic"));
1715    }
1716
1717    #[test]
1718    fn validate_rejects_dot_leading_segment() {
1719        // git's check-ref-format rule: no slash-separated component may
1720        // begin with '.'. This also inertifies crash debris parked at a
1721        // dot-leading directory (e.g. a `.rename.tmp.<pid>.<seq>` orphan)
1722        // as a ref name, not just the exact `.`/`..` shapes above.
1723        assert!(!validate_ref_name(".hidden"));
1724        assert!(!validate_ref_name("refs/.hidden/main"));
1725        assert!(!validate_ref_name(".rename.tmp.12345.0"));
1726        assert!(!validate_ref_name("refs/remotes/.rename.tmp.12345.0/main"));
1727    }
1728
1729    #[test]
1730    fn validate_rejects_double_slash() {
1731        assert!(!validate_ref_name("refs//heads/main"));
1732        assert!(!validate_ref_name("main/"));
1733    }
1734
1735    #[test]
1736    fn validate_rejects_disallowed_bytes() {
1737        assert!(!validate_ref_name("main@v1"));
1738        assert!(!validate_ref_name("feat\\branch"));
1739        assert!(!validate_ref_name("with space"));
1740    }
1741
1742    #[test]
1743    fn validate_rejects_lock_suffix() {
1744        assert!(!validate_ref_name("refs/heads/main.lock"));
1745    }
1746
1747    #[test]
1748    fn validate_rejects_head_final_segment() {
1749        assert!(!validate_ref_name("refs/heads/HEAD"));
1750        assert!(!validate_ref_name("HEAD"));
1751    }
1752
1753    #[test]
1754    fn validate_accepts_main_regression() {
1755        assert!(validate_ref_name("refs/heads/main"));
1756    }
1757
1758    #[test]
1759    fn validate_accepts_non_lock_suffix_regression() {
1760        // Only trailing ".lock" should reject; "lockfile" is fine.
1761        assert!(validate_ref_name("refs/heads/lockfile"));
1762    }
1763
1764    #[test]
1765    fn validate_accepts_headless_regression() {
1766        // Only the exact final segment "HEAD" is rejected.
1767        assert!(validate_ref_name("refs/heads/HEADless"));
1768    }
1769
1770    #[test]
1771    fn validate_prefix() {
1772        assert!(validate_ref_prefix(""));
1773        assert!(validate_ref_prefix("refs/heads/"));
1774        assert!(validate_ref_prefix("refs/heads"));
1775        assert!(!validate_ref_prefix("refs//heads/"));
1776        assert!(!validate_ref_prefix("/"));
1777    }
1778
1779    // --- wire encoding --------------------------------------------------
1780
1781    #[test]
1782    fn wire_round_trip() {
1783        let original = h("test-ref");
1784        let wire = encode_ref_wire(&original);
1785        assert_eq!(wire.len(), 65);
1786        assert_eq!(wire[64], b'\n');
1787        let parsed = decode_ref_wire(&wire).unwrap();
1788        assert_eq!(parsed, original);
1789    }
1790
1791    #[test]
1792    fn wire_rejects_uppercase() {
1793        let original = h("test-ref");
1794        let mut wire = encode_ref_wire(&original);
1795        // Upper-case the first letter we find; SPEC-REFS §1 forbids
1796        // uppercase hex on read.
1797        let mut flipped = false;
1798        for b in &mut wire[..HEX_LEN] {
1799            if (b'a'..=b'f').contains(b) {
1800                *b -= b'a' - b'A';
1801                flipped = true;
1802                break;
1803            }
1804        }
1805        assert!(flipped, "test fixture should contain at least one a-f");
1806        assert!(decode_ref_wire(&wire).is_none());
1807    }
1808
1809    #[test]
1810    fn wire_rejects_short_input() {
1811        let bad = b"deadbeef\n";
1812        assert!(decode_ref_wire(bad).is_none());
1813    }
1814
1815    #[test]
1816    fn wire_rejects_non_hex() {
1817        let mut wire = encode_ref_wire(&h("x"));
1818        wire[1] = b'g';
1819        assert!(decode_ref_wire(&wire).is_none());
1820    }
1821
1822    #[test]
1823    fn wire_tolerates_trailing_cr() {
1824        // Files round-tripped through Windows editors may pick up CRs.
1825        let original = h("eol");
1826        let mut buf = encode_ref_wire(&original).to_vec();
1827        buf.insert(64, b'\r');
1828        let parsed = decode_ref_wire(&buf).unwrap();
1829        assert_eq!(parsed, original);
1830    }
1831
1832    // --- HEAD ----------------------------------------------------------
1833
1834    #[test]
1835    fn init_writes_default_head() {
1836        let (_dir, mkit) = fresh_repo();
1837        let head = read_head(&mkit).unwrap();
1838        assert_eq!(head, Head::Branch("main".to_string()));
1839    }
1840
1841    #[test]
1842    fn write_and_read_branch_ref() {
1843        let (_dir, mkit) = fresh_repo();
1844        let commit = h("commit1");
1845        write_ref(&mkit, "main", &commit).unwrap();
1846        let read = read_ref(&mkit, "main").unwrap();
1847        assert_eq!(read, Some(commit));
1848    }
1849
1850    #[test]
1851    fn resolve_head_with_no_commits_returns_none() {
1852        let (_dir, mkit) = fresh_repo();
1853        assert_eq!(resolve_head(&mkit).unwrap(), None);
1854    }
1855
1856    #[test]
1857    fn resolve_head_after_commit() {
1858        let (_dir, mkit) = fresh_repo();
1859        let commit = h("commit1");
1860        write_ref(&mkit, "main", &commit).unwrap();
1861        assert_eq!(resolve_head(&mkit).unwrap(), Some(commit));
1862    }
1863
1864    #[test]
1865    fn update_head_updates_current_branch() {
1866        let (_dir, mkit) = fresh_repo();
1867        let h1 = h("c1");
1868        update_head(&mkit, &h1).unwrap();
1869        assert_eq!(resolve_head(&mkit).unwrap(), Some(h1));
1870        let h2 = h("c2");
1871        update_head(&mkit, &h2).unwrap();
1872        assert_eq!(resolve_head(&mkit).unwrap(), Some(h2));
1873    }
1874
1875    #[test]
1876    fn detached_head_round_trip() {
1877        let dir = TempDir::new().unwrap();
1878        let mkit = RepoLayout::single(dir.path());
1879        fs::create_dir_all(mkit.common_dir()).unwrap();
1880        let commit = h("detached");
1881        write_head_detached(&mkit, &commit).unwrap();
1882        match read_head(&mkit).unwrap() {
1883            Head::Detached(got) => assert_eq!(got, commit),
1884            other @ Head::Branch(_) => panic!("expected detached, got {other:?}"),
1885        }
1886        assert_eq!(resolve_head(&mkit).unwrap(), Some(commit));
1887    }
1888
1889    #[test]
1890    fn read_head_rejects_oversize_file() {
1891        // SPEC-REFS §6: HEAD content is capped at 4 KiB.
1892        let dir = TempDir::new().unwrap();
1893        let mkit = RepoLayout::single(dir.path());
1894        fs::create_dir_all(mkit.common_dir()).unwrap();
1895        fs::write(
1896            mkit.head_file(),
1897            vec![b'a'; usize::try_from(HEAD_MAX_BYTES).unwrap() + 1],
1898        )
1899        .unwrap();
1900        let err = read_head(&mkit).unwrap_err();
1901        assert!(matches!(err, RefError::InvalidHead));
1902    }
1903
1904    #[test]
1905    fn nonexistent_branch_returns_none() {
1906        let (_dir, mkit) = fresh_repo();
1907        assert_eq!(read_ref(&mkit, "nonexistent").unwrap(), None);
1908    }
1909
1910    #[test]
1911    fn read_ref_rejects_oversize_file() {
1912        // SPEC-REFS §6: a single ref file is capped at 128 bytes.
1913        let (_dir, mkit) = fresh_repo();
1914        let path = ref_path(mkit.common_dir(), HEADS_DIR, "main");
1915        fs::create_dir_all(path.parent().unwrap()).unwrap();
1916        fs::write(
1917            &path,
1918            vec![b'0'; usize::try_from(REF_FILE_MAX_BYTES).unwrap() + 1],
1919        )
1920        .unwrap();
1921        let err = read_ref(&mkit, "main").unwrap_err();
1922        assert!(matches!(err, RefError::InvalidRef(_)));
1923    }
1924
1925    #[test]
1926    fn list_refs_empty() {
1927        let (_dir, mkit) = fresh_repo();
1928        let refs = list_refs(&mkit).unwrap();
1929        assert!(refs.is_empty());
1930    }
1931
1932    #[test]
1933    fn list_refs_sorted() {
1934        let (_dir, mkit) = fresh_repo();
1935        write_ref(&mkit, "main", &h("m")).unwrap();
1936        write_ref(&mkit, "dev", &h("d")).unwrap();
1937        let refs = list_refs(&mkit).unwrap();
1938        assert_eq!(refs.len(), 2);
1939        assert_eq!(refs[0].name, "dev");
1940        assert_eq!(refs[1].name, "main");
1941    }
1942
1943    #[test]
1944    fn nested_refs_listed_recursively() {
1945        let (_dir, mkit) = fresh_repo();
1946        write_ref(&mkit, "feature/deep/topic", &h("nested")).unwrap();
1947        let refs = list_refs(&mkit).unwrap();
1948        assert_eq!(refs.len(), 1);
1949        assert_eq!(refs[0].name, "feature/deep/topic");
1950    }
1951
1952    #[test]
1953    fn list_refs_silently_skips_entries_beyond_max_depth() {
1954        // SPEC-REFS §6: listing recurses with a hard depth cap of 32
1955        // levels to defeat adversarial nesting; anything deeper is
1956        // silently skipped (not an error, not a stack overflow).
1957        let (_dir, mkit) = fresh_repo();
1958        let deep_name = (0..40)
1959            .map(|i| format!("d{i}"))
1960            .collect::<Vec<_>>()
1961            .join("/");
1962        write_ref(&mkit, &deep_name, &h("deep")).unwrap();
1963        write_ref(&mkit, "main", &h("shallow")).unwrap();
1964
1965        let refs = list_refs(&mkit).unwrap();
1966        let names: Vec<&str> = refs.iter().map(|r| r.name.as_str()).collect();
1967        assert!(names.contains(&"main"), "shallow ref must still be listed");
1968        assert!(
1969            !names.contains(&deep_name.as_str()),
1970            "a ref nested beyond MAX_REF_DEPTH must be silently skipped, got {names:?}"
1971        );
1972    }
1973
1974    #[test]
1975    fn delete_ref_basic() {
1976        let (_dir, mkit) = fresh_repo();
1977        write_ref(&mkit, "feature", &h("f")).unwrap();
1978        delete_ref(&mkit, "feature").unwrap();
1979        assert_eq!(read_ref(&mkit, "feature").unwrap(), None);
1980    }
1981
1982    #[test]
1983    fn delete_nonexistent_ref_errors() {
1984        let (_dir, mkit) = fresh_repo();
1985        let err = delete_ref(&mkit, "nope").unwrap_err();
1986        assert!(matches!(err, RefError::NotFound(_)));
1987    }
1988
1989    #[test]
1990    fn refuse_delete_current_branch() {
1991        let (_dir, mkit) = fresh_repo();
1992        write_ref(&mkit, "main", &h("m")).unwrap();
1993        let err = delete_ref_safe(&mkit, "main").unwrap_err();
1994        assert!(matches!(err, RefError::CurrentBranch(_)));
1995    }
1996
1997    // --- CAS variants ---------------------------------------------------
1998
1999    #[test]
2000    fn cas_any_clobbers() {
2001        let (_dir, mkit) = fresh_repo();
2002        update_ref(&mkit, "main", RefWriteCondition::Any, &h("a")).unwrap();
2003        update_ref(&mkit, "main", RefWriteCondition::Any, &h("b")).unwrap();
2004        assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("b")));
2005    }
2006
2007    #[test]
2008    fn cas_missing_succeeds_when_absent() {
2009        let (_dir, mkit) = fresh_repo();
2010        update_ref(&mkit, "main", RefWriteCondition::Missing, &h("a")).unwrap();
2011        assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("a")));
2012    }
2013
2014    #[test]
2015    fn cas_missing_fails_when_present() {
2016        let (_dir, mkit) = fresh_repo();
2017        write_ref(&mkit, "main", &h("a")).unwrap();
2018        let err = update_ref(&mkit, "main", RefWriteCondition::Missing, &h("b")).unwrap_err();
2019        assert!(matches!(err, RefError::Conflict(_)));
2020    }
2021
2022    #[test]
2023    fn cas_match_succeeds_on_correct_hash() {
2024        let (_dir, mkit) = fresh_repo();
2025        write_ref(&mkit, "main", &h("a")).unwrap();
2026        update_ref(&mkit, "main", RefWriteCondition::Match(h("a")), &h("b")).unwrap();
2027        assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("b")));
2028    }
2029
2030    #[test]
2031    fn cas_match_fails_on_wrong_hash() {
2032        let (_dir, mkit) = fresh_repo();
2033        write_ref(&mkit, "main", &h("a")).unwrap();
2034        let err = update_ref(&mkit, "main", RefWriteCondition::Match(h("z")), &h("b")).unwrap_err();
2035        assert!(matches!(err, RefError::Conflict(_)));
2036    }
2037
2038    #[test]
2039    fn cas_match_fails_on_missing_ref() {
2040        let (_dir, mkit) = fresh_repo();
2041        let err = update_ref(&mkit, "main", RefWriteCondition::Match(h("a")), &h("b")).unwrap_err();
2042        assert!(matches!(err, RefError::Conflict(_)));
2043    }
2044
2045    // --- INV-15 / #637: Match CAS must be atomic across uncoordinated
2046    // callers -------------------------------------------------------------
2047
2048    /// Reproduces the lost-update race described in INV-15: two callers
2049    /// that do NOT share any lock (mimicking `branch -m` under
2050    /// `worktrees.lock` racing `commit` under `worktree.lock`, or two
2051    /// linked worktrees each holding their own `worktree.lock`) both
2052    /// read the ref's current value, both see it matches their
2053    /// expectation, and both `cas_write`. Without cross-process
2054    /// atomicity on the read-compare-write, both calls can report
2055    /// success even though only one write actually survives — silently
2056    /// losing the other caller's update.
2057    ///
2058    /// `layout_a`/`layout_b` are two distinct [`RepoLayout`]s (a
2059    /// "single" layout and a "linked" layout with a different
2060    /// `worktree_root`/`worktree_state_dir`) that share only
2061    /// `common_dir` — exactly the shape of two linked worktrees, each
2062    /// of which would hold its own separate `worktree.lock` and thus
2063    /// share no lock with the other over this CAS. A `Barrier` forces
2064    /// both threads to enter `update_ref` at (as close to) the same
2065    /// instant as the scheduler allows, and the loop repeats many
2066    /// iterations because the race window is a handful of syscalls wide
2067    /// and is not guaranteed to be hit on any single attempt.
2068    ///
2069    /// Before the #637 fix (no shared `refs.lock` around the `Match`
2070    /// arm) this reliably reproduces a "both succeeded" iteration within
2071    /// a few hundred attempts. After the fix, the shared lock makes the
2072    /// read-compare-write atomic across both callers, so this must
2073    /// never happen — exactly one of the two racing writers may
2074    /// succeed.
2075    #[test]
2076    fn cas_match_race_never_loses_an_update_across_uncoordinated_callers() {
2077        let (_dir, layout_a) = fresh_repo();
2078        let layout_b = RepoLayout::linked(
2079            layout_a.worktree_root().join("other-worktree"),
2080            layout_a.common_dir().join("worktrees").join("other"),
2081            layout_a.common_dir(),
2082        );
2083
2084        let base = h("base");
2085        write_ref(&layout_a, "main", &base).unwrap();
2086
2087        let iterations: usize = 500;
2088        let mut double_success_iteration = None;
2089
2090        for i in 0..iterations {
2091            // Reset to a known base before each round. `Any` bypasses
2092            // CAS entirely, so this is not itself part of what's under
2093            // test.
2094            update_ref(&layout_a, "main", RefWriteCondition::Any, &base).unwrap();
2095
2096            let val_a = h(&format!("race-a-{i}"));
2097            let val_b = h(&format!("race-b-{i}"));
2098            let barrier = Barrier::new(2);
2099
2100            let (result_a, result_b) = std::thread::scope(|scope| {
2101                let handle_a = scope.spawn(|| {
2102                    barrier.wait();
2103                    update_ref(&layout_a, "main", RefWriteCondition::Match(base), &val_a)
2104                });
2105                let handle_b = scope.spawn(|| {
2106                    barrier.wait();
2107                    update_ref(&layout_b, "main", RefWriteCondition::Match(base), &val_b)
2108                });
2109                (handle_a.join().unwrap(), handle_b.join().unwrap())
2110            });
2111
2112            if result_a.is_ok() && result_b.is_ok() {
2113                double_success_iteration = Some(i);
2114                break;
2115            }
2116        }
2117
2118        assert!(
2119            double_success_iteration.is_none(),
2120            "both uncoordinated Match CAS callers reported success on iteration \
2121             {double_success_iteration:?} — an update was silently lost \
2122             (INV-15/INV-6 violation)"
2123        );
2124    }
2125
2126    /// Normal-case companion to the race test above: with no
2127    /// contention, a sequence of `Match` CAS writes on the same ref must
2128    /// still succeed every time and never wedge (guards against the
2129    /// `refs.lock` acquire/release added for #637 leaking or
2130    /// deadlocking across repeated calls from the same layout).
2131    #[test]
2132    fn cas_match_succeeds_repeatedly_when_uncontended() {
2133        let (_dir, mkit) = fresh_repo();
2134        let mut current = h("seed");
2135        write_ref(&mkit, "main", &current).unwrap();
2136
2137        for i in 0..20 {
2138            let next = h(&format!("v{i}"));
2139            update_ref(&mkit, "main", RefWriteCondition::Match(current), &next).unwrap();
2140            assert_eq!(read_ref(&mkit, "main").unwrap(), Some(next));
2141            current = next;
2142        }
2143    }
2144
2145    // --- INV-15 / #658: CAS-guarded delete must not lose a concurrent
2146    // Match-conditioned advance ---------------------------------------
2147
2148    /// Primitive-level reproduction of #658's "Race 1": a caller (e.g.
2149    /// `branch -m`) reads a branch's tip `T`, then — before it gets
2150    /// around to deleting the ref — a concurrent `Match(T)` CAS (e.g.
2151    /// `commit`'s fixed advance) lands, moving the ref to `C`. The
2152    /// caller's delete must detect that the ref no longer matches what
2153    /// it read and refuse, leaving `C` on disk untouched.
2154    ///
2155    /// Confirmed against the pre-fix shape of this codebase: pointing
2156    /// this same sequence at plain, unconditional [`delete_ref`] instead
2157    /// of [`delete_ref_if_matches`] removes the ref regardless of `T` vs
2158    /// `C`, silently destroying the concurrently-landed `C` — exactly
2159    /// the bug #658 reports. [`delete_ref_if_matches`] must refuse
2160    /// instead.
2161    #[test]
2162    fn cas_delete_refuses_when_ref_moved_after_read() {
2163        let (_dir, mkit) = fresh_repo();
2164        let t = h("t");
2165        let c = h("c");
2166        write_ref(&mkit, "main", &t).unwrap();
2167
2168        // Caller reads the tip...
2169        let read_t = read_ref(&mkit, "main").unwrap().unwrap();
2170        assert_eq!(read_t, t);
2171
2172        // ...then a concurrent Match(T) CAS (a fixed `commit`) lands
2173        // before the caller's delete runs.
2174        update_ref(&mkit, "main", RefWriteCondition::Match(t), &c).unwrap();
2175
2176        let err = delete_ref_if_matches(&mkit, "main", read_t).unwrap_err();
2177        assert!(
2178            matches!(err, RefError::Conflict(_)),
2179            "expected Conflict, got {err:?}"
2180        );
2181        assert_eq!(
2182            read_ref(&mkit, "main").unwrap(),
2183            Some(c),
2184            "the concurrently-landed commit must survive the refused delete untouched"
2185        );
2186    }
2187
2188    /// `delete_ref_if_matches` refusing to delete must not remove the
2189    /// file at all — a second, correctly-conditioned delete against the
2190    /// NEW value must still succeed.
2191    #[test]
2192    fn cas_delete_refusal_leaves_ref_deletable_against_its_new_value() {
2193        let (_dir, mkit) = fresh_repo();
2194        let t = h("t");
2195        let c = h("c");
2196        write_ref(&mkit, "main", &t).unwrap();
2197        update_ref(&mkit, "main", RefWriteCondition::Match(t), &c).unwrap();
2198
2199        assert!(matches!(
2200            delete_ref_if_matches(&mkit, "main", t).unwrap_err(),
2201            RefError::Conflict(_)
2202        ));
2203        delete_ref_if_matches(&mkit, "main", c).unwrap();
2204        assert_eq!(read_ref(&mkit, "main").unwrap(), None);
2205    }
2206
2207    #[test]
2208    fn cas_delete_fails_on_missing_ref() {
2209        let (_dir, mkit) = fresh_repo();
2210        let err = delete_ref_if_matches(&mkit, "main", h("anything")).unwrap_err();
2211        assert!(matches!(err, RefError::Conflict(_)));
2212    }
2213
2214    /// Racing-loop version of the reproduction above, mirroring
2215    /// [`cas_match_race_never_loses_an_update_across_uncoordinated_callers`]'s
2216    /// pattern: on each iteration, one thread performs a `Match`-guarded
2217    /// advance (mirroring a fixed `commit`) and the other performs a
2218    /// `delete_ref_if_matches` against the pre-advance value (mirroring
2219    /// `branch -m`'s rename), both released by the same `Barrier` so the
2220    /// scheduler is given its best shot at interleaving them. Because
2221    /// both operations serialize under the same per-ref lock
2222    /// ([`cas_lock_name`]), exactly one of the two may ever report
2223    /// success against a given prior value — never both, and the loser
2224    /// must never observe (or cause) a torn/lost state.
2225    #[test]
2226    fn cas_delete_vs_match_advance_race_never_lets_both_win_or_loses_the_advance() {
2227        let (_dir, mkit) = fresh_repo();
2228        let iterations: usize = 500;
2229        let mut bad_iteration: Option<(usize, &'static str)> = None;
2230
2231        for i in 0..iterations {
2232            let base = h(&format!("base-{i}"));
2233            write_ref(&mkit, "main", &base).unwrap();
2234            let new_tip = h(&format!("advanced-{i}"));
2235            let barrier = Barrier::new(2);
2236
2237            let (advance_result, delete_result) = std::thread::scope(|scope| {
2238                let advance_handle = scope.spawn(|| {
2239                    barrier.wait();
2240                    update_ref(&mkit, "main", RefWriteCondition::Match(base), &new_tip)
2241                });
2242                let delete_handle = scope.spawn(|| {
2243                    barrier.wait();
2244                    delete_ref_if_matches(&mkit, "main", base)
2245                });
2246                (
2247                    advance_handle.join().unwrap(),
2248                    delete_handle.join().unwrap(),
2249                )
2250            });
2251
2252            match (&advance_result, &delete_result) {
2253                (Ok(()), Ok(())) => {
2254                    bad_iteration = Some((
2255                        i,
2256                        "both the concurrent advance and the concurrent delete reported success",
2257                    ));
2258                }
2259                (Ok(()), Err(RefError::Conflict(_))) => {
2260                    // The advance won the race; its value must survive.
2261                    if read_ref(&mkit, "main").unwrap() != Some(new_tip) {
2262                        bad_iteration = Some((
2263                            i,
2264                            "advance reported success but its value is not on disk — lost update",
2265                        ));
2266                    }
2267                }
2268                (Err(RefError::Conflict(_)), Ok(())) => {
2269                    // The delete won the race before the advance landed;
2270                    // the ref must be gone (the advance must have then
2271                    // failed its own CAS, which the match arm above
2272                    // already confirms).
2273                    if read_ref(&mkit, "main").unwrap().is_some() {
2274                        bad_iteration = Some((
2275                            i,
2276                            "delete reported success but the ref is still present on disk",
2277                        ));
2278                    }
2279                }
2280                (Err(RefError::Conflict(_)), Err(RefError::Conflict(_))) => {
2281                    // Both lost — impossible on a fresh `base` write each
2282                    // round with only these two writers, but not itself a
2283                    // safety violation; leave unhandled rather than
2284                    // silently accepting on a bug that could produce it.
2285                    bad_iteration = Some((
2286                        i,
2287                        "both the advance and the delete reported Conflict against a freshly-written base",
2288                    ));
2289                }
2290                _ => {
2291                    bad_iteration = Some((i, "unexpected error variant"));
2292                }
2293            }
2294
2295            if bad_iteration.is_some() {
2296                break;
2297            }
2298        }
2299
2300        assert!(
2301            bad_iteration.is_none(),
2302            "iteration {bad_iteration:?}: a concurrent Match-conditioned advance and a \
2303             CAS-guarded delete on the same ref did not serialize correctly (issue #658)"
2304        );
2305    }
2306
2307    /// Cross-ref counterpart to the race test above: `refs.lock` used
2308    /// to be one repo-wide lock, so a `Match` CAS on branch "other"
2309    /// would block one on unrelated branch "main" for no reason —
2310    /// nothing about this CAS invariant spans refs. Now keyed per ref
2311    /// via [`cas_lock_name`]; proves an externally-held lock on
2312    /// "other"'s ref path does NOT block a `Match` CAS on "main"'s.
2313    #[test]
2314    fn cas_match_does_not_contend_across_different_refs() {
2315        let (_dir, mkit) = fresh_repo();
2316        write_ref(&mkit, "main", &h("m0")).unwrap();
2317        write_ref(&mkit, "other", &h("o0")).unwrap();
2318
2319        let other_path = ref_path(mkit.common_dir(), HEADS_DIR, "other");
2320        let other_lock_name = cas_lock_name(mkit.common_dir(), &other_path);
2321        let common_dir = mkit.common_dir().to_path_buf();
2322        let (holding_tx, holding_rx) = std::sync::mpsc::channel();
2323        let (release_tx, release_rx) = std::sync::mpsc::channel();
2324        let holder = std::thread::spawn(move || {
2325            let _lock = crate::repo_lock::acquire_default(&common_dir, &other_lock_name).unwrap();
2326            holding_tx.send(()).unwrap();
2327            release_rx.recv().unwrap();
2328        });
2329        holding_rx.recv().unwrap();
2330
2331        let start = std::time::Instant::now();
2332        update_ref(&mkit, "main", RefWriteCondition::Match(h("m0")), &h("m1")).unwrap();
2333        let elapsed = start.elapsed();
2334
2335        release_tx.send(()).unwrap();
2336        holder.join().unwrap();
2337
2338        assert!(
2339            elapsed < std::time::Duration::from_secs(1),
2340            "Match CAS on \"main\" took {elapsed:?} while \"other\"'s ref lock was held \
2341             elsewhere — refs are contending when they shouldn't be"
2342        );
2343        assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("m1")));
2344    }
2345
2346    // --- name-validation enforcement -----------------------------------
2347
2348    #[test]
2349    fn write_rejects_invalid_branch_name() {
2350        let (_dir, mkit) = fresh_repo();
2351        let err = write_ref(&mkit, "../escape", &h("x")).unwrap_err();
2352        assert!(matches!(err, RefError::InvalidRefName(_)));
2353        let err = write_head_branch(&mkit, "bad//branch").unwrap_err();
2354        assert!(matches!(err, RefError::InvalidRefName(_)));
2355    }
2356
2357    // --- tags ----------------------------------------------------------
2358
2359    #[test]
2360    fn write_and_read_tag() {
2361        let (_dir, mkit) = fresh_repo();
2362        let commit = h("v1.0");
2363        write_tag(&mkit, "v1.0", &commit).unwrap();
2364        assert_eq!(read_tag(&mkit, "v1.0").unwrap(), Some(commit));
2365    }
2366
2367    #[test]
2368    fn list_tags_sorted() {
2369        let (_dir, mkit) = fresh_repo();
2370        write_tag(&mkit, "v2.0", &h("v2")).unwrap();
2371        write_tag(&mkit, "v1.0", &h("v1")).unwrap();
2372        write_tag(&mkit, "alpha", &h("a")).unwrap();
2373        let tags = list_tags(&mkit).unwrap();
2374        assert_eq!(
2375            tags.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
2376            vec!["alpha", "v1.0", "v2.0"]
2377        );
2378    }
2379
2380    #[test]
2381    fn tag_and_branch_same_name_independent() {
2382        let (_dir, mkit) = fresh_repo();
2383        let tag = h("tag");
2384        let branch = h("branch");
2385        write_tag(&mkit, "main", &tag).unwrap();
2386        write_ref(&mkit, "main", &branch).unwrap();
2387        assert_eq!(read_tag(&mkit, "main").unwrap(), Some(tag));
2388        assert_eq!(read_ref(&mkit, "main").unwrap(), Some(branch));
2389    }
2390
2391    #[test]
2392    fn delete_tag_basic() {
2393        let (_dir, mkit) = fresh_repo();
2394        write_tag(&mkit, "release", &h("r")).unwrap();
2395        delete_tag(&mkit, "release").unwrap();
2396        assert_eq!(read_tag(&mkit, "release").unwrap(), None);
2397    }
2398
2399    #[test]
2400    fn delete_nonexistent_tag_errors() {
2401        let (_dir, mkit) = fresh_repo();
2402        let err = delete_tag(&mkit, "missing").unwrap_err();
2403        assert!(matches!(err, RefError::NotFound(_)));
2404    }
2405
2406    // --- shallow boundaries --------------------------------------------
2407
2408    #[test]
2409    fn load_shallow_returns_none_when_missing() {
2410        let (_dir, mkit) = fresh_repo();
2411        assert_eq!(load_shallow_boundaries(&mkit).unwrap(), None);
2412    }
2413
2414    #[test]
2415    fn write_and_load_shallow_round_trip() {
2416        let (_dir, mkit) = fresh_repo();
2417        let bs = vec![h("b1"), h("b2"), h("b3")];
2418        write_shallow_boundaries(&mkit, &bs).unwrap();
2419        let loaded = load_shallow_boundaries(&mkit).unwrap().unwrap();
2420        assert_eq!(loaded.len(), 3);
2421        for b in &bs {
2422            assert!(loaded.contains(b));
2423        }
2424    }
2425
2426    #[test]
2427    fn write_empty_shallow_removes_file() {
2428        let (_dir, mkit) = fresh_repo();
2429        write_shallow_boundaries(&mkit, &[h("x")]).unwrap();
2430        assert!(load_shallow_boundaries(&mkit).unwrap().is_some());
2431        write_shallow_boundaries(&mkit, &[]).unwrap();
2432        assert_eq!(load_shallow_boundaries(&mkit).unwrap(), None);
2433    }
2434
2435    #[test]
2436    fn load_shallow_rejects_oversize_file() {
2437        // SPEC-REFS §6: the shallow file is capped at 1 MiB.
2438        let (_dir, mkit) = fresh_repo();
2439        let path = mkit.shallow_file();
2440        fs::write(
2441            &path,
2442            vec![b'a'; usize::try_from(SHALLOW_MAX_BYTES).unwrap() + 1],
2443        )
2444        .unwrap();
2445        let err = load_shallow_boundaries(&mkit).unwrap_err();
2446        assert!(matches!(err, RefError::InvalidRef(_)));
2447    }
2448
2449    #[test]
2450    fn load_shallow_skips_invalid_lines() {
2451        let (_dir, mkit) = fresh_repo();
2452        let path = mkit.shallow_file();
2453        let valid = h("ok");
2454        let valid_hex = to_hex(&valid);
2455        let mut content = String::new();
2456        content.push_str("short\n");
2457        content.push_str(&valid_hex);
2458        content.push('\n');
2459        content.push_str(&"z".repeat(64));
2460        content.push('\n');
2461        std::fs::write(&path, content).unwrap();
2462        let loaded = load_shallow_boundaries(&mkit).unwrap().unwrap();
2463        assert_eq!(loaded.len(), 1);
2464        assert_eq!(loaded[0], valid);
2465    }
2466
2467    // --- remote-ref batching (#645) --------------------------------------
2468    //
2469    // `push`/`fetch` publish one remote-tracking ref per branch in a loop
2470    // (`mkit-cli`'s `remote_dispatch::push_all_with` /
2471    // `fetch_objects_inner`). Each `write_remote_ref` call goes through
2472    // `cas_write` → `write_atomic`, which pays a parent-directory fsync
2473    // EVERY call (`atomic.rs`'s `sync_parent_dir`) — so N branches cost N
2474    // directory fsyncs, serially, even though they all land in the same
2475    // `refs/remotes/<remote>/` directory. `RemoteRefBatch` amortises that
2476    // into one fsync per distinct directory touched, however many refs
2477    // were written into it.
2478
2479    /// Baseline (pre-#645): today's per-ref loop — exactly what
2480    /// `push_all_with`/`fetch_objects_inner` do today — pays one
2481    /// directory fsync per ref, even though every ref lands in the same
2482    /// `refs/remotes/origin/` directory. This is the O(N) cost #645
2483    /// exists to amortise; it must keep holding after the fix, since
2484    /// `write_remote_ref` itself (used elsewhere for single-ref writes)
2485    /// is intentionally left on the unbatched path.
2486    #[test]
2487    fn write_remote_ref_loop_pays_one_dir_sync_per_ref_today() {
2488        let (_dir, mkit) = fresh_repo();
2489        let n: u64 = 25;
2490        crate::atomic::testing::reset_dir_sync_calls();
2491        for i in 0..n {
2492            write_remote_ref(
2493                &mkit,
2494                "origin",
2495                &format!("branch-{i}"),
2496                &h(&format!("c{i}")),
2497            )
2498            .unwrap();
2499        }
2500        let calls = crate::atomic::testing::dir_sync_calls();
2501        assert_eq!(
2502            calls, n,
2503            "the current per-ref write path must cost exactly one directory \
2504             fsync per ref (O(N)); got {calls} for {n} refs"
2505        );
2506    }
2507
2508    /// The #645 fix: staging N remote-tracking-ref writes into one
2509    /// `RemoteRefBatch` and committing once must cost O(1) directory
2510    /// fsyncs (one per distinct directory touched — here a single flat
2511    /// `refs/remotes/origin/` namespace, so exactly one), not O(N).
2512    #[test]
2513    fn remote_ref_batch_pays_one_dir_sync_for_many_refs() {
2514        let (_dir, mkit) = fresh_repo();
2515        let n = 25;
2516        let entries: Vec<(String, Hash)> = (0..n)
2517            .map(|i| (format!("branch-{i}"), h(&format!("c{i}"))))
2518            .collect();
2519
2520        crate::atomic::testing::reset_dir_sync_calls();
2521        let mut batch = RemoteRefBatch::new(&mkit, "origin").unwrap();
2522        for (branch, hash) in &entries {
2523            batch.write(branch, hash).unwrap();
2524        }
2525        batch.commit().unwrap();
2526
2527        let calls = crate::atomic::testing::dir_sync_calls();
2528        assert_eq!(
2529            calls, 1,
2530            "batching {n} tracking-ref writes into one flat remote \
2531             namespace must cost exactly one directory fsync (O(1)), got {calls}"
2532        );
2533    }
2534
2535    /// Correctness: batched writes must produce the exact same final ref
2536    /// states as the old per-ref `write_remote_ref` loop — same hashes,
2537    /// same readability, for every branch.
2538    #[test]
2539    fn remote_ref_batch_matches_per_ref_loop_final_state() {
2540        let (_dir, old_path) = fresh_repo();
2541        let (_dir2, new_path) = fresh_repo();
2542        let n = 12;
2543        let entries: Vec<(String, Hash)> = (0..n)
2544            .map(|i| (format!("team/branch-{i}"), h(&format!("state{i}"))))
2545            .collect();
2546
2547        for (branch, hash) in &entries {
2548            write_remote_ref(&old_path, "origin", branch, hash).unwrap();
2549        }
2550
2551        let mut batch = RemoteRefBatch::new(&new_path, "origin").unwrap();
2552        for (branch, hash) in &entries {
2553            batch.write(branch, hash).unwrap();
2554        }
2555        batch.commit().unwrap();
2556
2557        for (branch, hash) in &entries {
2558            let old_val = read_remote_ref(&old_path, "origin", branch).unwrap();
2559            let new_val = read_remote_ref(&new_path, "origin", branch).unwrap();
2560            assert_eq!(old_val, Some(*hash));
2561            assert_eq!(new_val, Some(*hash));
2562            assert_eq!(old_val, new_val, "branch {branch} diverged");
2563        }
2564    }
2565
2566    /// Partial-failure semantics: best-effort / fail-fast, matching
2567    /// `WriteBatch::commit`'s documented contract (already-renamed
2568    /// entries stay visible; no rollback). An invalid branch name in the
2569    /// middle of a batch aborts every write from that point on — the
2570    /// refs staged before it stay visible and readable, exactly as they
2571    /// would have under the old per-ref loop had it hit the same
2572    /// mid-loop error (each earlier ref was already independently
2573    /// visible before the loop reached the bad one).
2574    #[test]
2575    fn remote_ref_batch_partial_failure_keeps_already_written_refs_visible() {
2576        let (_dir, mkit) = fresh_repo();
2577        let mut batch = RemoteRefBatch::new(&mkit, "origin").unwrap();
2578        batch.write("good-1", &h("g1")).unwrap();
2579        batch.write("good-2", &h("g2")).unwrap();
2580        let err = batch.write("bad//name", &h("x")).unwrap_err();
2581        assert!(matches!(err, RefError::InvalidRefName(_)));
2582
2583        // Committing what was staged before the bad write must still
2584        // durably publish the good entries.
2585        batch.commit().unwrap();
2586        assert_eq!(
2587            read_remote_ref(&mkit, "origin", "good-1").unwrap(),
2588            Some(h("g1"))
2589        );
2590        assert_eq!(
2591            read_remote_ref(&mkit, "origin", "good-2").unwrap(),
2592            Some(h("g2"))
2593        );
2594        // "bad//name" was never a valid ref name in the first place —
2595        // nothing was ever staged for it, on either the old or new path.
2596        let never_written = read_remote_ref(&mkit, "origin", "bad//name").unwrap_err();
2597        assert!(matches!(never_written, RefError::InvalidRefName(_)));
2598    }
2599
2600    #[test]
2601    fn remote_ref_batch_rejects_invalid_remote_name() {
2602        let (_dir, mkit) = fresh_repo();
2603        let err = RemoteRefBatch::new(&mkit, "../escape").unwrap_err();
2604        assert!(matches!(err, RefError::InvalidRefName(_)));
2605    }
2606
2607    #[test]
2608    fn remote_ref_batch_of_zero_entries_is_a_noop_commit() {
2609        let (_dir, mkit) = fresh_repo();
2610        crate::atomic::testing::reset_dir_sync_calls();
2611        let batch = RemoteRefBatch::new(&mkit, "origin").unwrap();
2612        batch.commit().unwrap();
2613        assert_eq!(
2614            crate::atomic::testing::dir_sync_calls(),
2615            0,
2616            "an empty batch must not touch any directory"
2617        );
2618    }
2619
2620    // --- history-coupled ref writes (history-mmr feature) -------------
2621
2622    #[cfg(feature = "history-mmr")]
2623    mod history_coupling {
2624        use super::*;
2625        use crate::history::{CommitHistory, TokioExecutor};
2626        use std::sync::Arc;
2627
2628        #[test]
2629        fn update_ref_with_history_appends_to_journal_under_lock() {
2630            let (_dir, mkit) = fresh_repo();
2631            let exec = Arc::new(TokioExecutor::new().unwrap());
2632            let mut hist = CommitHistory::open_at(exec.clone(), &mkit, "main").unwrap();
2633
2634            let c1 = h("c1");
2635            let c2 = h("c2");
2636
2637            update_ref_with_history(&mkit, "main", RefWriteCondition::Any, &c1, &mut hist).unwrap();
2638            update_ref_with_history(&mkit, "main", RefWriteCondition::Match(c1), &c2, &mut hist)
2639                .unwrap();
2640
2641            assert_eq!(read_ref(&mkit, "main").unwrap(), Some(c2));
2642            assert_eq!(hist.len(), 2, "two appends → two leaves in the MMR");
2643        }
2644
2645        /// Regression test for the epic-#634 follow-up: `refs-history.lock`
2646        /// (and `refs.lock`) used to be one repo-wide lock, so an
2647        /// operation on branch "other" would block one on unrelated
2648        /// branch "main" for no reason — nothing about the history-mmr
2649        /// coupling spans branches. Locks are now keyed per branch
2650        /// (`refs-history-<sanitized-branch>.lock`); this proves an
2651        /// externally-held lock on branch "other" does NOT block
2652        /// `update_ref_with_history` on branch "main".
2653        #[test]
2654        fn update_ref_with_history_does_not_contend_across_branches() {
2655            let (_dir, mkit) = fresh_repo();
2656            let exec = Arc::new(TokioExecutor::new().unwrap());
2657            let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
2658
2659            // Hold "other" branch's per-branch history lock on a
2660            // separate thread for well longer than this test's timeout
2661            // budget would tolerate if "main" incorrectly contended on
2662            // it. Calls the SAME `history_lock_name` production uses
2663            // (not a hand-copied format! string) so this test can't
2664            // silently decouple from whatever naming decision
2665            // production actually makes.
2666            let other_lock_name = history_lock_name("other");
2667            let common_dir = mkit.common_dir().to_path_buf();
2668            let (holding_tx, holding_rx) = std::sync::mpsc::channel();
2669            let (release_tx, release_rx) = std::sync::mpsc::channel();
2670            let holder = std::thread::spawn(move || {
2671                let _lock =
2672                    crate::repo_lock::acquire_default(&common_dir, &other_lock_name).unwrap();
2673                holding_tx.send(()).unwrap();
2674                release_rx.recv().unwrap();
2675            });
2676            holding_rx.recv().unwrap();
2677
2678            // If this contended on "other"'s lock, it would hang until
2679            // the holder thread released — which we don't do until
2680            // after this call returns. A generous-but-bounded elapsed
2681            // check turns a regression into a fast test failure instead
2682            // of an indefinite hang.
2683            let start = std::time::Instant::now();
2684            update_ref_with_history(&mkit, "main", RefWriteCondition::Any, &h("m1"), &mut hist)
2685                .unwrap();
2686            let elapsed = start.elapsed();
2687
2688            release_tx.send(()).unwrap();
2689            holder.join().unwrap();
2690
2691            assert!(
2692                elapsed < std::time::Duration::from_secs(1),
2693                "update_ref_with_history on \"main\" took {elapsed:?} while \"other\"'s \
2694                 per-branch lock was held elsewhere — branches are contending when they \
2695                 shouldn't be"
2696            );
2697            assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("m1")));
2698        }
2699
2700        /// Sanity control for the test above: the SAME branch's lock
2701        /// must still serialize correctly — per-branch scoping must not
2702        /// have accidentally broken same-branch mutual exclusion.
2703        #[test]
2704        fn update_ref_with_history_still_contends_on_the_same_branch() {
2705            let (_dir, mkit) = fresh_repo();
2706            let exec = Arc::new(TokioExecutor::new().unwrap());
2707            let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
2708
2709            let main_lock_name = history_lock_name("main");
2710            let common_dir = mkit.common_dir().to_path_buf();
2711            let (holding_tx, holding_rx) = std::sync::mpsc::channel();
2712            let held_for = std::time::Duration::from_millis(200);
2713            let holder = std::thread::spawn(move || {
2714                let _lock =
2715                    crate::repo_lock::acquire_default(&common_dir, &main_lock_name).unwrap();
2716                holding_tx.send(()).unwrap();
2717                std::thread::sleep(held_for);
2718            });
2719            holding_rx.recv().unwrap();
2720
2721            let start = std::time::Instant::now();
2722            update_ref_with_history(&mkit, "main", RefWriteCondition::Any, &h("m1"), &mut hist)
2723                .unwrap();
2724            let elapsed = start.elapsed();
2725            holder.join().unwrap();
2726
2727            assert!(
2728                elapsed >= held_for / 2,
2729                "update_ref_with_history on \"main\" returned in {elapsed:?} while \"main\"'s \
2730                 own lock was held for {held_for:?} elsewhere — same-branch mutual exclusion \
2731                 is broken"
2732            );
2733        }
2734
2735        #[test]
2736        fn update_ref_with_history_rejects_mem_history() {
2737            let (_dir, mkit) = fresh_repo();
2738            let mut mem_hist = CommitHistory::open();
2739            let err = update_ref_with_history(
2740                &mkit,
2741                "main",
2742                RefWriteCondition::Any,
2743                &h("x"),
2744                &mut mem_hist,
2745            )
2746            .unwrap_err();
2747            assert!(matches!(err, RefError::InvalidRef(_)));
2748        }
2749
2750        #[test]
2751        fn update_ref_with_history_rejects_branch_mismatch() {
2752            let (_dir, mkit) = fresh_repo();
2753            let exec = Arc::new(TokioExecutor::new().unwrap());
2754            // Open history for "main", but try to update ref "feature".
2755            let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
2756            let err = update_ref_with_history(
2757                &mkit,
2758                "feature",
2759                RefWriteCondition::Any,
2760                &h("x"),
2761                &mut hist,
2762            )
2763            .unwrap_err();
2764            assert!(matches!(err, RefError::InvalidRef(_)));
2765        }
2766
2767        #[test]
2768        fn update_ref_with_history_cas_failure_does_not_append() {
2769            let (_dir, mkit) = fresh_repo();
2770            let exec = Arc::new(TokioExecutor::new().unwrap());
2771            let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
2772
2773            // Pre-seed the ref so a `Missing` CAS will fail.
2774            write_ref(&mkit, "main", &h("existing")).unwrap();
2775
2776            let err = update_ref_with_history(
2777                &mkit,
2778                "main",
2779                RefWriteCondition::Missing,
2780                &h("new"),
2781                &mut hist,
2782            )
2783            .unwrap_err();
2784            assert!(matches!(err, RefError::Conflict(_)));
2785            assert_eq!(
2786                hist.len(),
2787                0,
2788                "CAS failure must NOT have appended to history"
2789            );
2790        }
2791
2792        #[test]
2793        fn update_ref_with_history_heals_one_ahead_gap_before_appending_new_value() {
2794            use crate::history::{Position, verify_inclusion};
2795
2796            let (_dir, mkit) = fresh_repo();
2797            let exec = Arc::new(TokioExecutor::new().unwrap());
2798            let c0 = h("c0");
2799            let c1 = h("c1");
2800            let c2 = h("c2");
2801
2802            // A real, properly-recorded genesis commit — the journal is
2803            // non-empty, which is what distinguishes this crash-recovery
2804            // case (core-level, no `ObjectStore` needed: the missing leaf
2805            // is exactly the ref's current value) from the deeper
2806            // v0.1.x-migration case (`mkit-cli`-level, needs
2807            // `rebuild_from_chain` over the object store).
2808            let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
2809            update_ref_with_history(&mkit, "main", RefWriteCondition::Any, &c0, &mut hist).unwrap();
2810            assert_eq!(hist.len(), 1);
2811
2812            // Simulate steps 1-2 of a PRIOR `update_ref_with_history` call
2813            // that crashed before its own step 3 (append): the ref
2814            // already advanced to `c1`, but the journal never recorded
2815            // it, so `hist` here is one leaf behind what the ref implies.
2816            write_ref(&mkit, "main", &c1).unwrap();
2817            assert_eq!(hist.len(), 1, "journal is still missing c1's append");
2818
2819            // The next write proceeds normally against the ref's actual
2820            // current value.
2821            update_ref_with_history(&mkit, "main", RefWriteCondition::Match(c1), &c2, &mut hist)
2822                .unwrap();
2823
2824            // c0 (already recorded), the backfilled c1, and the new c2
2825            // must all be recorded, in order — proof state has caught up
2826            // to the ref, with no manual intervention.
2827            assert_eq!(hist.len(), 3);
2828            let root = hist.root();
2829            let proof0 = hist.prove(Position(0)).unwrap();
2830            assert!(verify_inclusion(&c0, Position(0), &proof0, &root));
2831            let proof1 = hist.prove(Position(1)).unwrap();
2832            assert!(verify_inclusion(&c1, Position(1), &proof1, &root));
2833            let proof2 = hist.prove(Position(2)).unwrap();
2834            assert!(verify_inclusion(&c2, Position(2), &proof2, &root));
2835        }
2836
2837        #[test]
2838        fn update_ref_with_history_concurrent_handles_do_not_interleave_or_corrupt() {
2839            use crate::history::{Position, verify_inclusion};
2840
2841            let (_dir, mkit) = fresh_repo();
2842            let exec = Arc::new(TokioExecutor::new().unwrap());
2843
2844            // Two independent handles opened before either has taken the
2845            // `refs-history.lock` — simulating two racing processes, each
2846            // of which called `CommitHistory::open_at` first (as
2847            // `mkit-cli`'s `write_ref_recording_history` does) and only
2848            // takes the lock inside `update_ref_with_history`.
2849            let mut hist_a = CommitHistory::open_at(exec.clone(), &mkit, "main").unwrap();
2850            let mut hist_b = CommitHistory::open_at(exec, &mkit, "main").unwrap();
2851
2852            let a1 = h("a1");
2853            let b1 = h("b1");
2854
2855            update_ref_with_history(&mkit, "main", RefWriteCondition::Any, &a1, &mut hist_a)
2856                .unwrap();
2857
2858            // `hist_b` is stale (opened before `hist_a`'s append landed)
2859            // but must re-derive its state from disk under its own lock
2860            // acquisition and append on top of `a1`, not clobber or
2861            // duplicate its leaf.
2862            update_ref_with_history(&mkit, "main", RefWriteCondition::Any, &b1, &mut hist_b)
2863                .unwrap();
2864
2865            assert_eq!(read_ref(&mkit, "main").unwrap(), Some(b1));
2866            assert_eq!(
2867                hist_b.len(),
2868                2,
2869                "b's append must land after a's, not clobber it"
2870            );
2871            let root = hist_b.root();
2872            let proof0 = hist_b.prove(Position(0)).unwrap();
2873            assert!(verify_inclusion(&a1, Position(0), &proof0, &root));
2874            let proof1 = hist_b.prove(Position(1)).unwrap();
2875            assert!(verify_inclusion(&b1, Position(1), &proof1, &root));
2876        }
2877
2878        // --- journal lifecycle on branch delete/rename (issue #648) ---
2879
2880        /// The core regression test: delete a branch, recreate one with
2881        /// the SAME name, and advance it once. The new incarnation's
2882        /// journal must start fresh (one leaf), not resume the deleted
2883        /// incarnation's leaves — otherwise the new branch's MMR root
2884        /// commits to a sequence spanning unrelated incarnations, and
2885        /// stale leaves from the deleted branch keep producing
2886        /// valid-looking inclusion proofs "on this branch".
2887        #[test]
2888        fn delete_ref_with_history_prevents_partition_reuse_on_recreate() {
2889            use crate::history::{Position, verify_inclusion};
2890
2891            let (_dir, mkit) = fresh_repo();
2892            let exec = Arc::new(TokioExecutor::new().unwrap());
2893
2894            // Branch A, advanced twice — journal gets two leaves.
2895            let a1 = h("a1");
2896            let a2 = h("a2");
2897            let mut hist_a = CommitHistory::open_at(exec.clone(), &mkit, "feature").unwrap();
2898            update_ref_with_history(&mkit, "feature", RefWriteCondition::Any, &a1, &mut hist_a)
2899                .unwrap();
2900            update_ref_with_history(
2901                &mkit,
2902                "feature",
2903                RefWriteCondition::Match(a1),
2904                &a2,
2905                &mut hist_a,
2906            )
2907            .unwrap();
2908            assert_eq!(hist_a.len(), 2);
2909            drop(hist_a);
2910
2911            // Delete branch A through the history-aware path.
2912            delete_ref_with_history(&mkit, "feature", exec.clone()).unwrap();
2913            assert_eq!(read_ref(&mkit, "feature").unwrap(), None);
2914
2915            // Recreate a NEW branch also named "feature" and advance it
2916            // once. Its journal must be fresh: one leaf, not three.
2917            let b1 = h("b1");
2918            let mut hist_b = CommitHistory::open_at(exec, &mkit, "feature").unwrap();
2919            assert_eq!(
2920                hist_b.len(),
2921                0,
2922                "recreated branch must not inherit the deleted incarnation's leaves"
2923            );
2924            update_ref_with_history(
2925                &mkit,
2926                "feature",
2927                RefWriteCondition::Missing,
2928                &b1,
2929                &mut hist_b,
2930            )
2931            .unwrap();
2932            assert_eq!(
2933                hist_b.len(),
2934                1,
2935                "the new incarnation's journal must contain exactly its own one leaf"
2936            );
2937
2938            // The old leaves (a1, a2) must not verify against the new
2939            // incarnation's root at any position — no splicing of
2940            // unrelated incarnations.
2941            let root = hist_b.root();
2942            for pos in 0..hist_b.len() {
2943                let proof = hist_b.prove(Position(pos)).unwrap();
2944                assert!(
2945                    !verify_inclusion(&a1, Position(pos), &proof, &root),
2946                    "deleted incarnation's leaf a1 must not verify against the new root"
2947                );
2948                assert!(
2949                    !verify_inclusion(&a2, Position(pos), &proof, &root),
2950                    "deleted incarnation's leaf a2 must not verify against the new root"
2951                );
2952            }
2953        }
2954
2955        /// Regression test for the bug found during the epic-#634 code
2956        /// review: `delete_ref_with_history` originally ran its
2957        /// read-check + journal-destroy + ref-delete sequence with NO
2958        /// lock at all, reopening exactly the race #638 closed one layer
2959        /// up. Races `delete_ref_with_history` against
2960        /// `update_ref_with_history` on the same never-checked-out
2961        /// branch, from two independently-opened `CommitHistory` handles
2962        /// (mirroring how `update_ref_with_history_concurrent_handles_do_not_interleave_or_corrupt`
2963        /// above simulates two racing processes).
2964        ///
2965        /// The assertion is on LEAF COUNT, not raw journal-directory
2966        /// existence: `CommitHistory::open_at`/`reopen` create an empty
2967        /// on-disk partition as a side effect of merely opening it (this
2968        /// is commonware's own open-or-create `init` semantics, already
2969        /// documented as an accepted ambiguity by `heal_one_ahead_gap`'s
2970        /// doc comment above), so a 0-leaf journal directory can
2971        /// legitimately exist even when the branch has no ref — that is
2972        /// not the bug. What must never happen is the branch either (a)
2973        /// having a live ref with an EMPTY journal (a leaf was lost) or
2974        /// (b) having NO ref while the journal still holds leaves from
2975        /// before the delete (exactly #648's stale-incarnation bug,
2976        /// reintroduced one layer up by this unlocked race).
2977        ///
2978        /// Honesty check on this test's power: unlike the analogous
2979        /// `cas_match_race_*` test above, this one did NOT reliably
2980        /// reproduce a torn state against the unlocked code in manual
2981        /// verification (0 failures across 500 iterations) — the
2982        /// vulnerable window (concurrent file-level I/O inside
2983        /// `destroy()`/`append()`) is narrower than what a
2984        /// barrier-synchronized thread start reliably lands in. The fix
2985        /// is correct by construction regardless: it applies the exact
2986        /// same `refs-history.lock` acquisition, in the exact same
2987        /// position (before any read of ref or journal state), that
2988        /// every other history-mmr writer in this file already uses —
2989        /// this test is kept as a standing consistency/defense-in-depth
2990        /// check, not as proof the bug was reliably observed pre-fix.
2991        #[test]
2992        fn delete_ref_with_history_races_update_without_tearing_ref_and_journal() {
2993            let iterations = 50;
2994
2995            for i in 0..iterations {
2996                let (_dir, mkit) = fresh_repo();
2997                let exec = Arc::new(TokioExecutor::new().unwrap());
2998
2999                // Seed the branch so delete_ref_with_history has
3000                // something to delete, and pre-populate its journal so
3001                // update's handle below opens onto a non-empty journal
3002                // (exercising the heal-gap path, not the empty-journal
3003                // backfill path, which is orthogonal to this race).
3004                let seed = h(&format!("seed-{i}"));
3005                let mut seed_hist = CommitHistory::open_at(exec.clone(), &mkit, "feature").unwrap();
3006                update_ref_with_history(
3007                    &mkit,
3008                    "feature",
3009                    RefWriteCondition::Missing,
3010                    &seed,
3011                    &mut seed_hist,
3012                )
3013                .unwrap();
3014                drop(seed_hist);
3015
3016                let next = h(&format!("next-{i}"));
3017                let mut update_hist =
3018                    CommitHistory::open_at(exec.clone(), &mkit, "feature").unwrap();
3019                let barrier = Barrier::new(2);
3020
3021                std::thread::scope(|scope| {
3022                    scope.spawn(|| {
3023                        barrier.wait();
3024                        // Either outcome (deleted first, or raced out by
3025                        // the update landing first and this then failing
3026                        // NotFound) is valid — only a torn final state is
3027                        // a bug.
3028                        let _ = delete_ref_with_history(&mkit, "feature", exec.clone());
3029                    });
3030                    scope.spawn(|| {
3031                        barrier.wait();
3032                        let _ = update_ref_with_history(
3033                            &mkit,
3034                            "feature",
3035                            RefWriteCondition::Match(seed),
3036                            &next,
3037                            &mut update_hist,
3038                        );
3039                    });
3040                });
3041                drop(update_hist);
3042
3043                let ref_exists = read_ref(&mkit, "feature").unwrap().is_some();
3044                let leaves = CommitHistory::open_at(exec, &mkit, "feature")
3045                    .unwrap()
3046                    .len();
3047                assert_eq!(
3048                    ref_exists,
3049                    leaves > 0,
3050                    "iteration {i}: torn state after racing delete vs. update — \
3051                     ref_exists={ref_exists}, journal has {leaves} leaves \
3052                     (INV-4/INV-18-adjacent: a live ref must have a non-empty \
3053                     journal, and a deleted branch's journal must not retain \
3054                     leaves from before the delete)"
3055                );
3056            }
3057        }
3058
3059        #[test]
3060        fn delete_ref_with_history_removes_journal_partition_from_disk() {
3061            let (_dir, mkit) = fresh_repo();
3062            let exec = Arc::new(TokioExecutor::new().unwrap());
3063            let mut hist = CommitHistory::open_at(exec.clone(), &mkit, "feature").unwrap();
3064            update_ref_with_history(&mkit, "feature", RefWriteCondition::Any, &h("a"), &mut hist)
3065                .unwrap();
3066            drop(hist);
3067
3068            let journal_blobs = mkit.history_dir().join("feature__journal-blobs");
3069            assert!(journal_blobs.exists());
3070
3071            delete_ref_with_history(&mkit, "feature", exec).unwrap();
3072            assert!(
3073                !journal_blobs.exists(),
3074                "delete_ref_with_history must remove the on-disk journal partition"
3075            );
3076        }
3077
3078        #[test]
3079        fn delete_ref_with_history_errors_on_missing_branch_without_touching_journal() {
3080            let (_dir, mkit) = fresh_repo();
3081            let exec = Arc::new(TokioExecutor::new().unwrap());
3082            let err = delete_ref_with_history(&mkit, "nope", exec).unwrap_err();
3083            assert!(matches!(err, RefError::NotFound(_)));
3084        }
3085
3086        #[test]
3087        fn delete_ref_safe_with_history_refuses_current_branch() {
3088            let (_dir, mkit) = fresh_repo();
3089            let exec = Arc::new(TokioExecutor::new().unwrap());
3090            let mut hist = CommitHistory::open_at(exec.clone(), &mkit, "main").unwrap();
3091            update_ref_with_history(&mkit, "main", RefWriteCondition::Any, &h("m"), &mut hist)
3092                .unwrap();
3093            drop(hist);
3094
3095            let err = delete_ref_safe_with_history(&mkit, "main", exec).unwrap_err();
3096            assert!(matches!(err, RefError::CurrentBranch(_)));
3097            // Refusing the delete must leave the journal untouched.
3098            assert!(mkit.history_dir().join("main__journal-blobs").exists());
3099        }
3100
3101        /// Rename support: destroying the OLD name's journal after the
3102        /// new name has already been seeded with a fresh journal (by
3103        /// `write_ref_recording_history`, mkit-cli's create/rename path)
3104        /// closes the same reuse hole for `branch -m`.
3105        #[test]
3106        fn delete_ref_with_history_supports_rename_by_destroying_the_old_name_only() {
3107            let (_dir, mkit) = fresh_repo();
3108            let exec = Arc::new(TokioExecutor::new().unwrap());
3109
3110            let mut hist_old = CommitHistory::open_at(exec.clone(), &mkit, "old").unwrap();
3111            update_ref_with_history(
3112                &mkit,
3113                "old",
3114                RefWriteCondition::Any,
3115                &h("o1"),
3116                &mut hist_old,
3117            )
3118            .unwrap();
3119            drop(hist_old);
3120
3121            // Simulate the CLI rename: seed "new" with a fresh journal
3122            // first (mirrors `write_ref_recording_history`'s Missing-CAS
3123            // create), then drop "old" via the history-aware delete.
3124            let mut hist_new = CommitHistory::open_at(exec.clone(), &mkit, "new").unwrap();
3125            update_ref_with_history(
3126                &mkit,
3127                "new",
3128                RefWriteCondition::Missing,
3129                &h("o1"),
3130                &mut hist_new,
3131            )
3132            .unwrap();
3133            assert_eq!(hist_new.len(), 1);
3134            drop(hist_new);
3135
3136            delete_ref_with_history(&mkit, "old", exec.clone()).unwrap();
3137
3138            assert_eq!(read_ref(&mkit, "old").unwrap(), None);
3139            assert_eq!(read_ref(&mkit, "new").unwrap(), Some(h("o1")));
3140            assert!(!mkit.history_dir().join("old__journal-blobs").exists());
3141            assert!(mkit.history_dir().join("new__journal-blobs").exists());
3142
3143            // Recreating "old" afterward must not resume the destroyed
3144            // incarnation's leaf.
3145            let hist_old_reopened = CommitHistory::open_at(exec, &mkit, "old").unwrap();
3146            assert_eq!(hist_old_reopened.len(), 0);
3147        }
3148
3149        /// #658: the CAS-guarded history-mmr delete succeeds and
3150        /// destroys the journal when `expected` still matches.
3151        #[test]
3152        fn delete_ref_with_history_if_matches_succeeds_and_destroys_journal_on_match() {
3153            let (_dir, mkit) = fresh_repo();
3154            let exec = Arc::new(TokioExecutor::new().unwrap());
3155            let mut hist = CommitHistory::open_at(exec.clone(), &mkit, "feature").unwrap();
3156            let tip = h("tip");
3157            update_ref_with_history(&mkit, "feature", RefWriteCondition::Any, &tip, &mut hist)
3158                .unwrap();
3159            drop(hist);
3160
3161            delete_ref_with_history_if_matches(&mkit, "feature", tip, exec).unwrap();
3162
3163            assert_eq!(read_ref(&mkit, "feature").unwrap(), None);
3164            assert!(!mkit.history_dir().join("feature__journal-blobs").exists());
3165        }
3166
3167        /// #658: when `expected` no longer matches (a concurrent
3168        /// history-mmr advance landed first), the CAS-guarded delete
3169        /// must refuse WITHOUT touching either the ref or its journal —
3170        /// unlike the unconditional [`delete_ref_with_history`], which
3171        /// destroys the journal before ever looking at the ref's value.
3172        #[test]
3173        fn delete_ref_with_history_if_matches_leaves_ref_and_journal_untouched_on_conflict() {
3174            let (_dir, mkit) = fresh_repo();
3175            let exec = Arc::new(TokioExecutor::new().unwrap());
3176            let mut hist = CommitHistory::open_at(exec.clone(), &mkit, "feature").unwrap();
3177            let stale = h("stale");
3178            let current = h("current");
3179            update_ref_with_history(&mkit, "feature", RefWriteCondition::Any, &stale, &mut hist)
3180                .unwrap();
3181            update_ref_with_history(
3182                &mkit,
3183                "feature",
3184                RefWriteCondition::Match(stale),
3185                &current,
3186                &mut hist,
3187            )
3188            .unwrap();
3189            let leaves_before = hist.len();
3190            drop(hist);
3191
3192            let err = delete_ref_with_history_if_matches(&mkit, "feature", stale, exec.clone())
3193                .unwrap_err();
3194            assert!(matches!(err, RefError::Conflict(_)));
3195
3196            assert_eq!(read_ref(&mkit, "feature").unwrap(), Some(current));
3197            let hist_reopened = CommitHistory::open_at(exec, &mkit, "feature").unwrap();
3198            assert_eq!(
3199                hist_reopened.len(),
3200                leaves_before,
3201                "a refused conditional delete must not touch the journal"
3202            );
3203        }
3204    }
3205}