Skip to main content

mkit_core/
refs.rs

1//! Refs subsystem.
2//!
3//! Implements the local-disk side of `docs/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). The local-filesystem `Match` is **not atomic** across processes
24//! — that is the documented v1 gap; transports that need true atomicity
25//! must use s3/http/ssh.
26
27use std::fs;
28use std::io;
29use std::path::{Path, PathBuf};
30
31use crate::atomic::{write_atomic, write_create_new};
32use crate::hash::{HASH_LEN, HEX_LEN, Hash, to_hex};
33
34/// Subdirectory holding all refs (`.mkit/refs`).
35pub const REFS_DIR: &str = "refs";
36/// Subdirectory holding branch refs (`.mkit/refs/heads`).
37pub const HEADS_DIR: &str = "refs/heads";
38/// Subdirectory holding tag refs (`.mkit/refs/tags`).
39pub const TAGS_DIR: &str = "refs/tags";
40/// Subdirectory holding remote-tracking refs (`.mkit/refs/remotes`).
41pub const REMOTES_DIR: &str = "refs/remotes";
42/// HEAD file relative to the `.mkit` root.
43pub const HEAD_FILE: &str = "HEAD";
44/// Shallow-boundary file relative to the `.mkit` root.
45pub const SHALLOW_FILE: &str = "shallow";
46
47/// Symbolic-ref prefix written to `HEAD` when pointing at a branch.
48const HEAD_REF_PREFIX: &str = "ref: refs/heads/";
49
50/// Hard cap on how many bytes we are willing to read from `HEAD`.
51const HEAD_MAX_BYTES: u64 = 4 * 1024;
52/// Hard cap on how many bytes we are willing to read from a single ref
53/// file. The wire form is always 65 bytes; a few more is fine if the
54/// file picked up extra whitespace, but anything pathological is
55/// rejected.
56const REF_FILE_MAX_BYTES: u64 = 128;
57/// Hard cap on `.mkit/shallow` (1 MiB).
58const SHALLOW_MAX_BYTES: u64 = 1024 * 1024;
59
60/// Errors raised by this module.
61#[derive(Debug, thiserror::Error)]
62pub enum RefError {
63    /// `name` failed [`validate_ref_name`].
64    #[error("invalid ref name '{0}'")]
65    InvalidRefName(String),
66    /// On-disk bytes were not a valid 65-byte ref wire (length wrong,
67    /// uppercase hex, non-hex byte, etc.).
68    #[error("invalid ref content for '{0}'")]
69    InvalidRef(String),
70    /// `HEAD` content was neither a valid symbolic ref nor a valid
71    /// detached hash.
72    #[error("HEAD is not a valid symbolic-ref or detached-hash file")]
73    InvalidHead,
74    /// `HEAD` is missing.
75    #[error("HEAD is not present")]
76    NoHead,
77    /// CAS condition failed: ref does not match expected state.
78    #[error("ref '{0}' did not satisfy CAS condition")]
79    Conflict(String),
80    /// Tried to delete a ref that does not exist.
81    #[error("ref '{0}' not found")]
82    NotFound(String),
83    /// Tried to delete the branch HEAD currently points to.
84    #[error("cannot delete the current branch '{0}'")]
85    CurrentBranch(String),
86    /// Underlying I/O failure.
87    #[error(transparent)]
88    Io(#[from] io::Error),
89}
90
91/// Result alias used throughout this module.
92pub type RefResult<T> = Result<T, RefError>;
93
94/// CAS condition for [`update_ref`].
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum RefWriteCondition {
97    /// Unconditional write — clobbers any existing value.
98    Any,
99    /// Write only if the ref does not currently exist.
100    Missing,
101    /// Write only if the ref currently contains this exact hash.
102    Match(Hash),
103}
104
105/// HEAD pointer: either a symbolic reference to a branch name, or a
106/// detached hash.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum Head {
109    /// Symbolic — `HEAD` was `ref: refs/heads/<branch>\n`.
110    Branch(String),
111    /// Detached — `HEAD` was a bare 64-char hex hash.
112    Detached(Hash),
113}
114
115/// A listed ref entry: name (with `refs/heads/` or similar prefix
116/// stripped) plus the resolved hash, when readable.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Ref {
119    /// Ref name relative to the namespace (e.g. `"main"`, `"v1.0"`).
120    pub name: String,
121    /// Resolved 32-byte hash, or `None` if the on-disk bytes were
122    /// malformed (the entry is then silently skipped by callers that
123    /// only care about valid refs).
124    pub hash: Option<Hash>,
125}
126
127/// Validate a ref name per SPEC-REFS §3. Used at every transport
128/// boundary; transports MUST NOT silently lower-case or canonicalise.
129///
130/// Grammar:
131/// - Non-empty.
132/// - Segments split on `/`, each segment matches `[A-Za-z0-9._-]+`.
133/// - No `.` or `..` segments. No empty segments (no `//`, no leading
134///   `/`, no trailing `/`).
135/// - No `\\` or NUL bytes.
136/// - No segment may end in `.lock` (the canonical lock-file suffix).
137/// - The final segment may not be the literal `HEAD`, since that
138///   would shadow the repo-level `HEAD` pointer.
139#[must_use]
140pub fn validate_ref_name(name: &str) -> bool {
141    if name.is_empty() {
142        return false;
143    }
144    if name.starts_with('/') {
145        return false;
146    }
147    let mut last_part: &str = "";
148    for part in name.split('/') {
149        if part.is_empty() {
150            return false;
151        }
152        if part == "." || part == ".." {
153            return false;
154        }
155        // Reject the canonical lock-file suffix. Byte-level check so
156        // clippy's case-sensitive file-extension lint (which assumes a
157        // path extension) does not fire — ref segments are not paths
158        // and `.lock` is a spec-mandated exact-match suffix.
159        let bytes = part.as_bytes();
160        if bytes.len() >= 5 && &bytes[bytes.len() - 5..] == b".lock" {
161            return false;
162        }
163        for &c in part.as_bytes() {
164            if c == 0 || c == b'\\' {
165                return false;
166            }
167            let allowed = c.is_ascii_alphanumeric() || c == b'.' || c == b'_' || c == b'-';
168            if !allowed {
169                return false;
170            }
171        }
172        last_part = part;
173    }
174    if last_part == "HEAD" {
175        return false;
176    }
177    true
178}
179
180/// Validate a prefix passed to `list_refs`. An empty prefix is allowed.
181/// A single trailing `/` is allowed; otherwise the prefix must satisfy
182/// [`validate_ref_name`].
183#[must_use]
184pub fn validate_ref_prefix(prefix: &str) -> bool {
185    if prefix.is_empty() {
186        return true;
187    }
188    let trimmed = prefix.trim_end_matches('/');
189    if trimmed.is_empty() {
190        return false;
191    }
192    validate_ref_name(trimmed)
193}
194
195/// Encode `h` to its 65-byte wire form (lowercase hex + `\n`).
196#[must_use]
197pub fn encode_ref_wire(h: &Hash) -> [u8; 65] {
198    let hex = to_hex(h);
199    let bytes = hex.as_bytes();
200    let mut out = [0u8; 65];
201    out[..HEX_LEN].copy_from_slice(bytes);
202    out[HEX_LEN] = b'\n';
203    out
204}
205
206/// Decode a ref wire blob into a [`Hash`](tyalias@Hash). Tolerates a trailing
207/// newline / `\r` / ASCII whitespace (so files round-tripped through a
208/// Windows editor still parse), but rejects uppercase hex per
209/// SPEC-REFS §1.
210///
211/// Returns `None` for any malformed input; callers wrap the absent
212/// case into a domain-specific [`RefError::InvalidRef`].
213#[must_use]
214pub fn decode_ref_wire(data: &[u8]) -> Option<Hash> {
215    let s = core::str::from_utf8(data).ok()?;
216    let trimmed = s.trim_end_matches(['\n', '\r', ' ', '\t']);
217    if trimmed.len() != HEX_LEN {
218        return None;
219    }
220    parse_lowercase_hash(trimmed.as_bytes())
221}
222
223/// Strict lowercase-only hex parser. SPEC-REFS §1 forbids uppercase on
224/// read; the general `hash::from_hex` tolerates both cases for
225/// programmatic callers, so we hand-roll a stricter variant here.
226fn parse_lowercase_hash(bytes: &[u8]) -> Option<Hash> {
227    if bytes.len() != HEX_LEN {
228        return None;
229    }
230    let mut out = [0u8; HASH_LEN];
231    for i in 0..HASH_LEN {
232        let hi = lowercase_nibble(bytes[i * 2])?;
233        let lo = lowercase_nibble(bytes[i * 2 + 1])?;
234        out[i] = (hi << 4) | lo;
235    }
236    Some(out)
237}
238
239fn lowercase_nibble(b: u8) -> Option<u8> {
240    match b {
241        b'0'..=b'9' => Some(b - b'0'),
242        b'a'..=b'f' => Some(10 + (b - b'a')),
243        _ => None,
244    }
245}
246
247/// Initialise the ref directory layout under `mkit_dir` (the
248/// `.mkit/` directory). Creates the `refs/`, `refs/heads/`,
249/// `refs/tags/`, `refs/remotes/` subdirectories and writes a default
250/// `HEAD = ref: refs/heads/main\n` if `HEAD` does not already exist.
251pub fn init(mkit_dir: &Path) -> RefResult<()> {
252    fs::create_dir_all(mkit_dir.join(REFS_DIR))?;
253    fs::create_dir_all(mkit_dir.join(HEADS_DIR))?;
254    fs::create_dir_all(mkit_dir.join(TAGS_DIR))?;
255    fs::create_dir_all(mkit_dir.join(REMOTES_DIR))?;
256    let head_path = mkit_dir.join(HEAD_FILE);
257    if !head_path.exists() {
258        let body = format!("{HEAD_REF_PREFIX}main\n");
259        write_atomic(&head_path, body.as_bytes(), false)?;
260    }
261    Ok(())
262}
263
264// -----------------------------------------------------------------------------
265// HEAD
266// -----------------------------------------------------------------------------
267
268/// Read `HEAD` from `<mkit_dir>/HEAD`.
269///
270/// # Errors
271/// - [`RefError::NoHead`] if the file is missing.
272/// - [`RefError::InvalidHead`] for malformed content.
273pub fn read_head(mkit_dir: &Path) -> RefResult<Head> {
274    let path = mkit_dir.join(HEAD_FILE);
275    let meta = match fs::metadata(&path) {
276        Ok(m) => m,
277        Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(RefError::NoHead),
278        Err(e) => return Err(RefError::Io(e)),
279    };
280    if meta.len() > HEAD_MAX_BYTES {
281        return Err(RefError::InvalidHead);
282    }
283    let raw = fs::read(&path)?;
284    let s = core::str::from_utf8(&raw).map_err(|_| RefError::InvalidHead)?;
285    let trimmed = s.trim_end_matches(['\n', '\r', ' ', '\t']);
286    if let Some(branch) = trimmed.strip_prefix(HEAD_REF_PREFIX) {
287        if !validate_ref_name(branch) {
288            return Err(RefError::InvalidHead);
289        }
290        return Ok(Head::Branch(branch.to_string()));
291    }
292    if trimmed.len() == HEX_LEN {
293        let h = parse_lowercase_hash(trimmed.as_bytes()).ok_or(RefError::InvalidHead)?;
294        return Ok(Head::Detached(h));
295    }
296    Err(RefError::InvalidHead)
297}
298
299/// Write `HEAD` as a symbolic ref pointing at `branch`.
300///
301/// # Errors
302/// - [`RefError::InvalidRefName`] if `branch` does not satisfy
303///   [`validate_ref_name`].
304/// - [`RefError::Io`] for filesystem failures.
305pub fn write_head_branch(mkit_dir: &Path, branch: &str) -> RefResult<()> {
306    if !validate_ref_name(branch) {
307        return Err(RefError::InvalidRefName(branch.to_string()));
308    }
309    let body = format!("{HEAD_REF_PREFIX}{branch}\n");
310    write_atomic(&mkit_dir.join(HEAD_FILE), body.as_bytes(), false)?;
311    Ok(())
312}
313
314/// Write `HEAD` as a detached hash.
315///
316/// # Errors
317/// - [`RefError::Io`] for filesystem failures.
318pub fn write_head_detached(mkit_dir: &Path, h: &Hash) -> RefResult<()> {
319    let wire = encode_ref_wire(h);
320    write_atomic(&mkit_dir.join(HEAD_FILE), &wire, false)?;
321    Ok(())
322}
323
324/// Resolve `HEAD` to a commit hash. Returns `Ok(None)` when HEAD points
325/// at a branch that has no commit yet.
326pub fn resolve_head(mkit_dir: &Path) -> RefResult<Option<Hash>> {
327    let head = match read_head(mkit_dir) {
328        Ok(h) => h,
329        Err(RefError::NoHead) => return Ok(None),
330        Err(e) => return Err(e),
331    };
332    match head {
333        Head::Branch(name) => read_ref(mkit_dir, &name),
334        Head::Detached(h) => Ok(Some(h)),
335    }
336}
337
338/// Update the ref HEAD currently points at (or HEAD itself, if
339/// detached) to `commit_hash`.
340pub fn update_head(mkit_dir: &Path, commit_hash: &Hash) -> RefResult<()> {
341    let head = read_head(mkit_dir)?;
342    match head {
343        Head::Branch(name) => write_ref(mkit_dir, &name, commit_hash),
344        Head::Detached(_) => write_head_detached(mkit_dir, commit_hash),
345    }
346}
347
348// -----------------------------------------------------------------------------
349// Branch refs (refs/heads/<name>)
350// -----------------------------------------------------------------------------
351
352/// Read the hash a branch ref points to. Returns `Ok(None)` if the ref
353/// file does not exist.
354///
355/// # Errors
356/// - [`RefError::InvalidRefName`] if `branch` does not validate.
357/// - [`RefError::InvalidRef`] if the on-disk bytes are not a valid wire.
358pub fn read_ref(mkit_dir: &Path, branch: &str) -> RefResult<Option<Hash>> {
359    if !validate_ref_name(branch) {
360        return Err(RefError::InvalidRefName(branch.to_string()));
361    }
362    read_ref_under(mkit_dir, HEADS_DIR, branch)
363}
364
365/// Write a branch ref (unconditional — equivalent to
366/// `update_ref(branch, RefWriteCondition::Any, h)`).
367pub fn write_ref(mkit_dir: &Path, branch: &str, h: &Hash) -> RefResult<()> {
368    update_ref(mkit_dir, branch, RefWriteCondition::Any, h)
369}
370
371/// CAS-aware ref write per SPEC-REFS §5.
372///
373/// # Errors
374/// - [`RefError::InvalidRefName`] if `branch` is not a valid name.
375/// - [`RefError::Conflict`] if `condition` is not satisfied.
376/// - [`RefError::Io`] for filesystem failures.
377pub fn update_ref(
378    mkit_dir: &Path,
379    branch: &str,
380    condition: RefWriteCondition,
381    h: &Hash,
382) -> RefResult<()> {
383    if !validate_ref_name(branch) {
384        return Err(RefError::InvalidRefName(branch.to_string()));
385    }
386    let path = ref_path(mkit_dir, HEADS_DIR, branch);
387    let wire = encode_ref_wire(h);
388    cas_write(&path, &wire, branch, condition)
389}
390
391// -----------------------------------------------------------------------------
392// History-coupled ref writes (feature: history-mmr)
393// -----------------------------------------------------------------------------
394
395/// Combined ref-write + history-MMR-append, protected by a single
396/// repo-level lock. Issue #157, Phase 2.
397///
398/// Performs, in order:
399///
400/// 1. Acquire [`crate::repo_lock::RepoLock`] on
401///    `<mkit_dir>/refs-history.lock` so concurrent writers in other
402///    mkit processes block until this caller is done.
403/// 2. CAS-write the ref under `<mkit_dir>/refs/heads/<branch>`.
404/// 3. Append `hash` to the branch's
405///    [`crate::history::CommitHistory`], which syncs the journal to
406///    disk before returning.
407/// 4. Release the lock.
408///
409/// On any failure between steps 2 and 3 the ref will be ahead of the
410/// history journal by one commit; the v0.1.x rebuild shim
411/// ([`crate::history::rebuild_from_chain`]) recovers from this state.
412///
413/// # Design note (Option B vs Option A)
414///
415/// The original Phase-2 plan considered adding an optional
416/// `executor: Option<&dyn Executor>` parameter to [`update_ref`]
417/// itself ("Option A"). Two reasons not to:
418///
419/// - [`update_ref`] is called by [`write_ref`] and [`update_head`]
420///   internally and indirectly by the file transport's
421///   [`crate::protocol::Transport::update_ref`] impl; threading an
422///   executor through all of those would either ripple
423///   `history-mmr` into transport-file's API surface or force a
424///   `None` at every callsite that doesn't care.
425/// - The [`crate::protocol::async_shim::Executor`] trait uses
426///   generic methods so `&dyn Executor` is not object-safe, forcing
427///   us to generic-parameterise the trait anyway.
428///
429/// Adding a dedicated [`update_ref_with_history`] keeps the
430/// existing [`update_ref`] signature intact and confines the
431/// `history-mmr` integration to the one call-site (`mkit-cli`'s
432/// commit path) that actually needs it.
433///
434/// # Errors
435///
436/// - [`RefError::InvalidRefName`] — `branch` failed
437///   [`validate_ref_name`].
438/// - [`RefError::Conflict`] — the CAS precondition was not satisfied.
439/// - [`RefError::Io`] — filesystem failure during ref or lock I/O.
440/// - The wrapped [`crate::history::HistoryError`] is exposed via a
441///   `String` payload on [`RefError::InvalidRef`] (so this function's
442///   signature stays compatible with consumers that pin only
443///   `RefError`). Callers that need structured access to the history
444///   error can drive the two steps themselves via [`update_ref`] +
445///   [`crate::history::CommitHistory::append`].
446#[cfg(feature = "history-mmr")]
447pub fn update_ref_with_history<X: crate::protocol::async_shim::Executor + 'static>(
448    mkit_dir: &Path,
449    branch: &str,
450    condition: RefWriteCondition,
451    hash: &Hash,
452    history: &mut crate::history::CommitHistory<X>,
453) -> RefResult<()> {
454    // Defence-in-depth: history must be a Phase-2 journaled flavour;
455    // a mem-only flavour would silently drop the appended leaf on
456    // process exit, defeating the whole point of this coupling.
457    let Some(history_dir) = history.mkit_dir() else {
458        return Err(RefError::InvalidRef(format!(
459            "{branch}: update_ref_with_history requires a journaled CommitHistory (open_at)"
460        )));
461    };
462    if history_dir != mkit_dir {
463        return Err(RefError::InvalidRef(format!(
464            "{branch}: CommitHistory's mkit_dir does not match the ref's mkit_dir"
465        )));
466    }
467    if history.branch() != Some(branch) {
468        return Err(RefError::InvalidRef(format!(
469            "{branch}: CommitHistory was opened for a different branch ({:?})",
470            history.branch()
471        )));
472    }
473
474    // Single repo-level lock around the ref-write + MMR-append
475    // critical section. Cross-process interleaving is impossible
476    // while any holder owns this lock.
477    let _lock =
478        crate::repo_lock::acquire_default(mkit_dir, "refs-history.lock").map_err(|e| match e {
479            crate::repo_lock::LockError::Io(io) => RefError::Io(io),
480            other => RefError::InvalidRef(format!("{branch}: lock acquisition: {other}")),
481        })?;
482
483    update_ref(mkit_dir, branch, condition, hash)?;
484    history
485        .append(hash)
486        .map_err(|e| RefError::InvalidRef(format!("{branch}: history append: {e}")))?;
487    Ok(())
488}
489
490/// Delete a branch ref. Errors with [`RefError::NotFound`] if absent.
491pub fn delete_ref(mkit_dir: &Path, branch: &str) -> RefResult<()> {
492    if !validate_ref_name(branch) {
493        return Err(RefError::InvalidRefName(branch.to_string()));
494    }
495    let path = ref_path(mkit_dir, HEADS_DIR, branch);
496    match fs::remove_file(&path) {
497        Ok(()) => Ok(()),
498        Err(e) if e.kind() == io::ErrorKind::NotFound => {
499            Err(RefError::NotFound(branch.to_string()))
500        }
501        Err(e) => Err(RefError::Io(e)),
502    }
503}
504
505/// Delete a branch ref unless it is the currently checked-out branch.
506pub fn delete_ref_safe(mkit_dir: &Path, branch: &str) -> RefResult<()> {
507    match read_head(mkit_dir) {
508        Ok(Head::Branch(current)) if current == branch => {
509            Err(RefError::CurrentBranch(branch.to_string()))
510        }
511        _ => delete_ref(mkit_dir, branch),
512    }
513}
514
515/// List all branch refs, sorted lexicographically by name.
516pub fn list_refs(mkit_dir: &Path) -> RefResult<Vec<Ref>> {
517    list_refs_under(mkit_dir, HEADS_DIR)
518}
519
520// -----------------------------------------------------------------------------
521// Remote-tracking refs (refs/remotes/<remote>/<branch>)
522// -----------------------------------------------------------------------------
523
524/// Read a remote-tracking branch ref.
525pub fn read_remote_ref(mkit_dir: &Path, remote: &str, branch: &str) -> RefResult<Option<Hash>> {
526    validate_remote_and_branch(remote, branch)?;
527    read_ref_under(mkit_dir, &remote_ref_dir(remote), branch)
528}
529
530/// Write a remote-tracking branch ref unconditionally.
531pub fn write_remote_ref(mkit_dir: &Path, remote: &str, branch: &str, h: &Hash) -> RefResult<()> {
532    validate_remote_and_branch(remote, branch)?;
533    let path = ref_path(mkit_dir, &remote_ref_dir(remote), branch);
534    let wire = encode_ref_wire(h);
535    cas_write(&path, &wire, branch, RefWriteCondition::Any)
536}
537
538/// List all remote-tracking refs for one remote.
539pub fn list_remote_refs(mkit_dir: &Path, remote: &str) -> RefResult<Vec<Ref>> {
540    if !validate_ref_name(remote) {
541        return Err(RefError::InvalidRefName(remote.to_string()));
542    }
543    list_refs_under(mkit_dir, &remote_ref_dir(remote))
544}
545
546// -----------------------------------------------------------------------------
547// Tags (refs/tags/<name>)
548// -----------------------------------------------------------------------------
549
550/// Read the hash a tag points to.
551pub fn read_tag(mkit_dir: &Path, name: &str) -> RefResult<Option<Hash>> {
552    if !validate_ref_name(name) {
553        return Err(RefError::InvalidRefName(name.to_string()));
554    }
555    read_ref_under(mkit_dir, TAGS_DIR, name)
556}
557
558/// Write a tag ref (unconditional).
559pub fn write_tag(mkit_dir: &Path, name: &str, h: &Hash) -> RefResult<()> {
560    update_tag(mkit_dir, name, RefWriteCondition::Any, h)
561}
562
563/// CAS-aware tag write — same semantics as [`update_ref`] but for
564/// `refs/tags/`.
565pub fn update_tag(
566    mkit_dir: &Path,
567    name: &str,
568    condition: RefWriteCondition,
569    h: &Hash,
570) -> RefResult<()> {
571    if !validate_ref_name(name) {
572        return Err(RefError::InvalidRefName(name.to_string()));
573    }
574    let path = ref_path(mkit_dir, TAGS_DIR, name);
575    let wire = encode_ref_wire(h);
576    cas_write(&path, &wire, name, condition)
577}
578
579/// Delete a tag ref.
580pub fn delete_tag(mkit_dir: &Path, name: &str) -> RefResult<()> {
581    if !validate_ref_name(name) {
582        return Err(RefError::InvalidRefName(name.to_string()));
583    }
584    let path = ref_path(mkit_dir, TAGS_DIR, name);
585    match fs::remove_file(&path) {
586        Ok(()) => Ok(()),
587        Err(e) if e.kind() == io::ErrorKind::NotFound => Err(RefError::NotFound(name.to_string())),
588        Err(e) => Err(RefError::Io(e)),
589    }
590}
591
592/// List all tag refs, sorted lexicographically by name.
593pub fn list_tags(mkit_dir: &Path) -> RefResult<Vec<Ref>> {
594    list_refs_under(mkit_dir, TAGS_DIR)
595}
596
597// -----------------------------------------------------------------------------
598// Shallow boundaries (.mkit/shallow)
599// -----------------------------------------------------------------------------
600
601/// Load shallow-boundary hashes from `.mkit/shallow`. Returns `Ok(None)`
602/// if the file does not exist or is empty.
603pub fn load_shallow_boundaries(mkit_dir: &Path) -> RefResult<Option<Vec<Hash>>> {
604    let path = mkit_dir.join(SHALLOW_FILE);
605    let meta = match fs::metadata(&path) {
606        Ok(m) => m,
607        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
608        Err(e) => return Err(RefError::Io(e)),
609    };
610    if meta.len() == 0 {
611        return Ok(None);
612    }
613    if meta.len() > SHALLOW_MAX_BYTES {
614        return Err(RefError::InvalidRef("shallow file too large".to_string()));
615    }
616    let bytes = fs::read(&path)?;
617    let s = core::str::from_utf8(&bytes).map_err(|_| RefError::InvalidHead)?;
618    let mut out = Vec::new();
619    for line in s.split('\n') {
620        let trimmed = line.trim_end_matches(['\r', ' ', '\t']);
621        if trimmed.len() != HEX_LEN {
622            continue;
623        }
624        if let Some(h) = parse_lowercase_hash(trimmed.as_bytes()) {
625            out.push(h);
626        }
627    }
628    if out.is_empty() {
629        return Ok(None);
630    }
631    Ok(Some(out))
632}
633
634/// Write shallow-boundary hashes to `.mkit/shallow`. Passing an empty
635/// slice removes the file.
636pub fn write_shallow_boundaries(mkit_dir: &Path, boundaries: &[Hash]) -> RefResult<()> {
637    let path = mkit_dir.join(SHALLOW_FILE);
638    if boundaries.is_empty() {
639        match fs::remove_file(&path) {
640            Ok(()) => Ok(()),
641            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
642            Err(e) => Err(RefError::Io(e)),
643        }
644    } else {
645        let mut out = Vec::with_capacity(boundaries.len() * 65);
646        for h in boundaries {
647            out.extend_from_slice(&encode_ref_wire(h));
648        }
649        write_atomic(&path, &out, true)?;
650        Ok(())
651    }
652}
653
654// -----------------------------------------------------------------------------
655// Internals
656// -----------------------------------------------------------------------------
657
658fn ref_path(mkit_dir: &Path, sub_dir: &str, name: &str) -> PathBuf {
659    let mut path = mkit_dir.join(sub_dir);
660    for segment in name.split('/') {
661        path.push(segment);
662    }
663    path
664}
665
666fn remote_ref_dir(remote: &str) -> String {
667    format!("{REMOTES_DIR}/{remote}")
668}
669
670fn validate_remote_and_branch(remote: &str, branch: &str) -> RefResult<()> {
671    if !validate_ref_name(remote) {
672        return Err(RefError::InvalidRefName(remote.to_string()));
673    }
674    if !validate_ref_name(branch) {
675        return Err(RefError::InvalidRefName(branch.to_string()));
676    }
677    Ok(())
678}
679
680fn read_ref_under(mkit_dir: &Path, sub_dir: &str, name: &str) -> RefResult<Option<Hash>> {
681    let path = ref_path(mkit_dir, sub_dir, name);
682    let meta = match fs::metadata(&path) {
683        Ok(m) => m,
684        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
685        Err(e) => return Err(RefError::Io(e)),
686    };
687    if meta.len() > REF_FILE_MAX_BYTES {
688        return Err(RefError::InvalidRef(name.to_string()));
689    }
690    let bytes = fs::read(&path)?;
691    let h = decode_ref_wire(&bytes).ok_or_else(|| RefError::InvalidRef(name.to_string()))?;
692    Ok(Some(h))
693}
694
695fn cas_write(
696    path: &Path,
697    wire: &[u8; 65],
698    name_for_err: &str,
699    condition: RefWriteCondition,
700) -> RefResult<()> {
701    match condition {
702        RefWriteCondition::Any => {
703            write_atomic(path, wire, true)?;
704            Ok(())
705        }
706        RefWriteCondition::Missing => {
707            // O_EXCL — fail if anything is at `path` already.
708            if let Some(parent) = path.parent() {
709                fs::create_dir_all(parent)?;
710            }
711            let created = write_create_new(path, wire, true)?;
712            if !created {
713                return Err(RefError::Conflict(name_for_err.to_string()));
714            }
715            Ok(())
716        }
717        RefWriteCondition::Match(expected) => {
718            // Read-then-write — non-atomic per SPEC-REFS §5.1 (file
719            // transport row). The spec explicitly documents this gap.
720            let current = match fs::read(path) {
721                Ok(b) => Some(
722                    decode_ref_wire(&b)
723                        .ok_or_else(|| RefError::InvalidRef(name_for_err.to_string()))?,
724                ),
725                Err(e) if e.kind() == io::ErrorKind::NotFound => None,
726                Err(e) => return Err(RefError::Io(e)),
727            };
728            if current != Some(expected) {
729                return Err(RefError::Conflict(name_for_err.to_string()));
730            }
731            write_atomic(path, wire, true)?;
732            Ok(())
733        }
734    }
735}
736
737fn list_refs_under(mkit_dir: &Path, sub_dir: &str) -> RefResult<Vec<Ref>> {
738    let root = mkit_dir.join(sub_dir);
739    let mut out = Vec::new();
740    if !root.is_dir() {
741        return Ok(out);
742    }
743    collect_refs(&root, "", &mut out, 0)?;
744    out.sort_by(|a, b| a.name.cmp(&b.name));
745    Ok(out)
746}
747
748/// Cap on ref-tree recursion depth. A malicious or corrupt `.mkit/refs/`
749/// directory with deeply nested empty dirs should not stack-overflow
750/// the walker. 32 is far beyond anything a valid ref name (which cannot
751/// contain more than a few `/` separators given the 1–255 path-segment
752/// grammar) could ever require.
753const MAX_REF_DEPTH: usize = 32;
754
755fn collect_refs(root: &Path, prefix: &str, out: &mut Vec<Ref>, depth: usize) -> RefResult<()> {
756    if depth > MAX_REF_DEPTH {
757        // Silently stop — same "skip malformed" posture as below for
758        // individual files. Callers get a partial result rather than a
759        // stack overflow on adversarial input.
760        return Ok(());
761    }
762    let dir_path = if prefix.is_empty() {
763        root.to_path_buf()
764    } else {
765        root.join(prefix)
766    };
767    let iter = match fs::read_dir(&dir_path) {
768        Ok(i) => i,
769        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
770        Err(e) => return Err(RefError::Io(e)),
771    };
772    for entry in iter {
773        let entry = entry?;
774        let file_name = match entry.file_name().to_str() {
775            Some(s) => s.to_string(),
776            None => continue, // Non-UTF-8 names cannot be valid ref names.
777        };
778        let child_name = if prefix.is_empty() {
779            file_name.clone()
780        } else {
781            format!("{prefix}/{file_name}")
782        };
783        let ft = entry.file_type()?;
784        if ft.is_dir() {
785            collect_refs(root, &child_name, out, depth + 1)?;
786            continue;
787        }
788        if !ft.is_file() {
789            continue;
790        }
791        if !validate_ref_name(&child_name) {
792            continue;
793        }
794        // Read & decode; silently skip malformed files.
795        let Ok(bytes) = fs::read(entry.path()) else {
796            continue;
797        };
798        let hash = decode_ref_wire(&bytes);
799        out.push(Ref {
800            name: child_name,
801            hash,
802        });
803    }
804    Ok(())
805}
806
807// -----------------------------------------------------------------------------
808// Internal hash helper re-exports for goldens
809// -----------------------------------------------------------------------------
810
811/// Internal re-export used by the integration tests to hand-roll wire
812/// bytes without round-tripping through `hash::from_hex`.
813#[doc(hidden)]
814#[must_use]
815pub fn _hash_from_lowercase_hex_for_tests(s: &str) -> Option<Hash> {
816    parse_lowercase_hash(s.as_bytes())
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822    use crate::hash;
823    use tempfile::TempDir;
824
825    fn fresh_repo() -> (TempDir, PathBuf) {
826        let dir = TempDir::new().unwrap();
827        let mkit_dir = dir.path().join(".mkit");
828        fs::create_dir_all(&mkit_dir).unwrap();
829        init(&mkit_dir).unwrap();
830        (dir, mkit_dir)
831    }
832
833    fn h(seed: &str) -> Hash {
834        hash::hash(seed.as_bytes())
835    }
836
837    // --- name grammar ---------------------------------------------------
838
839    #[test]
840    fn validate_accepts_simple_names() {
841        assert!(validate_ref_name("main"));
842        assert!(validate_ref_name("feat/v1.0-beta"));
843        assert!(validate_ref_name("release/2024_09"));
844    }
845
846    #[test]
847    fn validate_rejects_empty() {
848        assert!(!validate_ref_name(""));
849    }
850
851    #[test]
852    fn validate_rejects_leading_slash() {
853        assert!(!validate_ref_name("/main"));
854    }
855
856    #[test]
857    fn validate_rejects_dotdot_segment() {
858        assert!(!validate_ref_name("feat/.."));
859        assert!(!validate_ref_name("../escape"));
860        assert!(!validate_ref_name("feat/./topic"));
861    }
862
863    #[test]
864    fn validate_rejects_double_slash() {
865        assert!(!validate_ref_name("refs//heads/main"));
866        assert!(!validate_ref_name("main/"));
867    }
868
869    #[test]
870    fn validate_rejects_disallowed_bytes() {
871        assert!(!validate_ref_name("main@v1"));
872        assert!(!validate_ref_name("feat\\branch"));
873        assert!(!validate_ref_name("with space"));
874    }
875
876    #[test]
877    fn validate_rejects_lock_suffix() {
878        assert!(!validate_ref_name("refs/heads/main.lock"));
879    }
880
881    #[test]
882    fn validate_rejects_head_final_segment() {
883        assert!(!validate_ref_name("refs/heads/HEAD"));
884        assert!(!validate_ref_name("HEAD"));
885    }
886
887    #[test]
888    fn validate_accepts_main_regression() {
889        assert!(validate_ref_name("refs/heads/main"));
890    }
891
892    #[test]
893    fn validate_accepts_non_lock_suffix_regression() {
894        // Only trailing ".lock" should reject; "lockfile" is fine.
895        assert!(validate_ref_name("refs/heads/lockfile"));
896    }
897
898    #[test]
899    fn validate_accepts_headless_regression() {
900        // Only the exact final segment "HEAD" is rejected.
901        assert!(validate_ref_name("refs/heads/HEADless"));
902    }
903
904    #[test]
905    fn validate_prefix() {
906        assert!(validate_ref_prefix(""));
907        assert!(validate_ref_prefix("refs/heads/"));
908        assert!(validate_ref_prefix("refs/heads"));
909        assert!(!validate_ref_prefix("refs//heads/"));
910        assert!(!validate_ref_prefix("/"));
911    }
912
913    // --- wire encoding --------------------------------------------------
914
915    #[test]
916    fn wire_round_trip() {
917        let original = h("test-ref");
918        let wire = encode_ref_wire(&original);
919        assert_eq!(wire.len(), 65);
920        assert_eq!(wire[64], b'\n');
921        let parsed = decode_ref_wire(&wire).unwrap();
922        assert_eq!(parsed, original);
923    }
924
925    #[test]
926    fn wire_rejects_uppercase() {
927        let original = h("test-ref");
928        let mut wire = encode_ref_wire(&original);
929        // Upper-case the first letter we find; SPEC-REFS §1 forbids
930        // uppercase hex on read.
931        let mut flipped = false;
932        for b in &mut wire[..HEX_LEN] {
933            if (b'a'..=b'f').contains(b) {
934                *b -= b'a' - b'A';
935                flipped = true;
936                break;
937            }
938        }
939        assert!(flipped, "test fixture should contain at least one a-f");
940        assert!(decode_ref_wire(&wire).is_none());
941    }
942
943    #[test]
944    fn wire_rejects_short_input() {
945        let bad = b"deadbeef\n";
946        assert!(decode_ref_wire(bad).is_none());
947    }
948
949    #[test]
950    fn wire_rejects_non_hex() {
951        let mut wire = encode_ref_wire(&h("x"));
952        wire[1] = b'g';
953        assert!(decode_ref_wire(&wire).is_none());
954    }
955
956    #[test]
957    fn wire_tolerates_trailing_cr() {
958        // Files round-tripped through Windows editors may pick up CRs.
959        let original = h("eol");
960        let mut buf = encode_ref_wire(&original).to_vec();
961        buf.insert(64, b'\r');
962        let parsed = decode_ref_wire(&buf).unwrap();
963        assert_eq!(parsed, original);
964    }
965
966    // --- HEAD ----------------------------------------------------------
967
968    #[test]
969    fn init_writes_default_head() {
970        let (_dir, mkit) = fresh_repo();
971        let head = read_head(&mkit).unwrap();
972        assert_eq!(head, Head::Branch("main".to_string()));
973    }
974
975    #[test]
976    fn write_and_read_branch_ref() {
977        let (_dir, mkit) = fresh_repo();
978        let commit = h("commit1");
979        write_ref(&mkit, "main", &commit).unwrap();
980        let read = read_ref(&mkit, "main").unwrap();
981        assert_eq!(read, Some(commit));
982    }
983
984    #[test]
985    fn resolve_head_with_no_commits_returns_none() {
986        let (_dir, mkit) = fresh_repo();
987        assert_eq!(resolve_head(&mkit).unwrap(), None);
988    }
989
990    #[test]
991    fn resolve_head_after_commit() {
992        let (_dir, mkit) = fresh_repo();
993        let commit = h("commit1");
994        write_ref(&mkit, "main", &commit).unwrap();
995        assert_eq!(resolve_head(&mkit).unwrap(), Some(commit));
996    }
997
998    #[test]
999    fn update_head_updates_current_branch() {
1000        let (_dir, mkit) = fresh_repo();
1001        let h1 = h("c1");
1002        update_head(&mkit, &h1).unwrap();
1003        assert_eq!(resolve_head(&mkit).unwrap(), Some(h1));
1004        let h2 = h("c2");
1005        update_head(&mkit, &h2).unwrap();
1006        assert_eq!(resolve_head(&mkit).unwrap(), Some(h2));
1007    }
1008
1009    #[test]
1010    fn detached_head_round_trip() {
1011        let dir = TempDir::new().unwrap();
1012        let mkit = dir.path().join(".mkit");
1013        fs::create_dir_all(&mkit).unwrap();
1014        let commit = h("detached");
1015        write_head_detached(&mkit, &commit).unwrap();
1016        match read_head(&mkit).unwrap() {
1017            Head::Detached(got) => assert_eq!(got, commit),
1018            other @ Head::Branch(_) => panic!("expected detached, got {other:?}"),
1019        }
1020        assert_eq!(resolve_head(&mkit).unwrap(), Some(commit));
1021    }
1022
1023    #[test]
1024    fn nonexistent_branch_returns_none() {
1025        let (_dir, mkit) = fresh_repo();
1026        assert_eq!(read_ref(&mkit, "nonexistent").unwrap(), None);
1027    }
1028
1029    #[test]
1030    fn list_refs_empty() {
1031        let (_dir, mkit) = fresh_repo();
1032        let refs = list_refs(&mkit).unwrap();
1033        assert!(refs.is_empty());
1034    }
1035
1036    #[test]
1037    fn list_refs_sorted() {
1038        let (_dir, mkit) = fresh_repo();
1039        write_ref(&mkit, "main", &h("m")).unwrap();
1040        write_ref(&mkit, "dev", &h("d")).unwrap();
1041        let refs = list_refs(&mkit).unwrap();
1042        assert_eq!(refs.len(), 2);
1043        assert_eq!(refs[0].name, "dev");
1044        assert_eq!(refs[1].name, "main");
1045    }
1046
1047    #[test]
1048    fn nested_refs_listed_recursively() {
1049        let (_dir, mkit) = fresh_repo();
1050        write_ref(&mkit, "feature/deep/topic", &h("nested")).unwrap();
1051        let refs = list_refs(&mkit).unwrap();
1052        assert_eq!(refs.len(), 1);
1053        assert_eq!(refs[0].name, "feature/deep/topic");
1054    }
1055
1056    #[test]
1057    fn delete_ref_basic() {
1058        let (_dir, mkit) = fresh_repo();
1059        write_ref(&mkit, "feature", &h("f")).unwrap();
1060        delete_ref(&mkit, "feature").unwrap();
1061        assert_eq!(read_ref(&mkit, "feature").unwrap(), None);
1062    }
1063
1064    #[test]
1065    fn delete_nonexistent_ref_errors() {
1066        let (_dir, mkit) = fresh_repo();
1067        let err = delete_ref(&mkit, "nope").unwrap_err();
1068        assert!(matches!(err, RefError::NotFound(_)));
1069    }
1070
1071    #[test]
1072    fn refuse_delete_current_branch() {
1073        let (_dir, mkit) = fresh_repo();
1074        write_ref(&mkit, "main", &h("m")).unwrap();
1075        let err = delete_ref_safe(&mkit, "main").unwrap_err();
1076        assert!(matches!(err, RefError::CurrentBranch(_)));
1077    }
1078
1079    // --- CAS variants ---------------------------------------------------
1080
1081    #[test]
1082    fn cas_any_clobbers() {
1083        let (_dir, mkit) = fresh_repo();
1084        update_ref(&mkit, "main", RefWriteCondition::Any, &h("a")).unwrap();
1085        update_ref(&mkit, "main", RefWriteCondition::Any, &h("b")).unwrap();
1086        assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("b")));
1087    }
1088
1089    #[test]
1090    fn cas_missing_succeeds_when_absent() {
1091        let (_dir, mkit) = fresh_repo();
1092        update_ref(&mkit, "main", RefWriteCondition::Missing, &h("a")).unwrap();
1093        assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("a")));
1094    }
1095
1096    #[test]
1097    fn cas_missing_fails_when_present() {
1098        let (_dir, mkit) = fresh_repo();
1099        write_ref(&mkit, "main", &h("a")).unwrap();
1100        let err = update_ref(&mkit, "main", RefWriteCondition::Missing, &h("b")).unwrap_err();
1101        assert!(matches!(err, RefError::Conflict(_)));
1102    }
1103
1104    #[test]
1105    fn cas_match_succeeds_on_correct_hash() {
1106        let (_dir, mkit) = fresh_repo();
1107        write_ref(&mkit, "main", &h("a")).unwrap();
1108        update_ref(&mkit, "main", RefWriteCondition::Match(h("a")), &h("b")).unwrap();
1109        assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("b")));
1110    }
1111
1112    #[test]
1113    fn cas_match_fails_on_wrong_hash() {
1114        let (_dir, mkit) = fresh_repo();
1115        write_ref(&mkit, "main", &h("a")).unwrap();
1116        let err = update_ref(&mkit, "main", RefWriteCondition::Match(h("z")), &h("b")).unwrap_err();
1117        assert!(matches!(err, RefError::Conflict(_)));
1118    }
1119
1120    #[test]
1121    fn cas_match_fails_on_missing_ref() {
1122        let (_dir, mkit) = fresh_repo();
1123        let err = update_ref(&mkit, "main", RefWriteCondition::Match(h("a")), &h("b")).unwrap_err();
1124        assert!(matches!(err, RefError::Conflict(_)));
1125    }
1126
1127    // --- name-validation enforcement -----------------------------------
1128
1129    #[test]
1130    fn write_rejects_invalid_branch_name() {
1131        let (_dir, mkit) = fresh_repo();
1132        let err = write_ref(&mkit, "../escape", &h("x")).unwrap_err();
1133        assert!(matches!(err, RefError::InvalidRefName(_)));
1134        let err = write_head_branch(&mkit, "bad//branch").unwrap_err();
1135        assert!(matches!(err, RefError::InvalidRefName(_)));
1136    }
1137
1138    // --- tags ----------------------------------------------------------
1139
1140    #[test]
1141    fn write_and_read_tag() {
1142        let (_dir, mkit) = fresh_repo();
1143        let commit = h("v1.0");
1144        write_tag(&mkit, "v1.0", &commit).unwrap();
1145        assert_eq!(read_tag(&mkit, "v1.0").unwrap(), Some(commit));
1146    }
1147
1148    #[test]
1149    fn list_tags_sorted() {
1150        let (_dir, mkit) = fresh_repo();
1151        write_tag(&mkit, "v2.0", &h("v2")).unwrap();
1152        write_tag(&mkit, "v1.0", &h("v1")).unwrap();
1153        write_tag(&mkit, "alpha", &h("a")).unwrap();
1154        let tags = list_tags(&mkit).unwrap();
1155        assert_eq!(
1156            tags.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
1157            vec!["alpha", "v1.0", "v2.0"]
1158        );
1159    }
1160
1161    #[test]
1162    fn tag_and_branch_same_name_independent() {
1163        let (_dir, mkit) = fresh_repo();
1164        let tag = h("tag");
1165        let branch = h("branch");
1166        write_tag(&mkit, "main", &tag).unwrap();
1167        write_ref(&mkit, "main", &branch).unwrap();
1168        assert_eq!(read_tag(&mkit, "main").unwrap(), Some(tag));
1169        assert_eq!(read_ref(&mkit, "main").unwrap(), Some(branch));
1170    }
1171
1172    #[test]
1173    fn delete_tag_basic() {
1174        let (_dir, mkit) = fresh_repo();
1175        write_tag(&mkit, "release", &h("r")).unwrap();
1176        delete_tag(&mkit, "release").unwrap();
1177        assert_eq!(read_tag(&mkit, "release").unwrap(), None);
1178    }
1179
1180    #[test]
1181    fn delete_nonexistent_tag_errors() {
1182        let (_dir, mkit) = fresh_repo();
1183        let err = delete_tag(&mkit, "missing").unwrap_err();
1184        assert!(matches!(err, RefError::NotFound(_)));
1185    }
1186
1187    // --- shallow boundaries --------------------------------------------
1188
1189    #[test]
1190    fn load_shallow_returns_none_when_missing() {
1191        let (_dir, mkit) = fresh_repo();
1192        assert_eq!(load_shallow_boundaries(&mkit).unwrap(), None);
1193    }
1194
1195    #[test]
1196    fn write_and_load_shallow_round_trip() {
1197        let (_dir, mkit) = fresh_repo();
1198        let bs = vec![h("b1"), h("b2"), h("b3")];
1199        write_shallow_boundaries(&mkit, &bs).unwrap();
1200        let loaded = load_shallow_boundaries(&mkit).unwrap().unwrap();
1201        assert_eq!(loaded.len(), 3);
1202        for b in &bs {
1203            assert!(loaded.contains(b));
1204        }
1205    }
1206
1207    #[test]
1208    fn write_empty_shallow_removes_file() {
1209        let (_dir, mkit) = fresh_repo();
1210        write_shallow_boundaries(&mkit, &[h("x")]).unwrap();
1211        assert!(load_shallow_boundaries(&mkit).unwrap().is_some());
1212        write_shallow_boundaries(&mkit, &[]).unwrap();
1213        assert_eq!(load_shallow_boundaries(&mkit).unwrap(), None);
1214    }
1215
1216    #[test]
1217    fn load_shallow_skips_invalid_lines() {
1218        let (_dir, mkit) = fresh_repo();
1219        let path = mkit.join(SHALLOW_FILE);
1220        let valid = h("ok");
1221        let valid_hex = to_hex(&valid);
1222        let mut content = String::new();
1223        content.push_str("short\n");
1224        content.push_str(&valid_hex);
1225        content.push('\n');
1226        content.push_str(&"z".repeat(64));
1227        content.push('\n');
1228        std::fs::write(&path, content).unwrap();
1229        let loaded = load_shallow_boundaries(&mkit).unwrap().unwrap();
1230        assert_eq!(loaded.len(), 1);
1231        assert_eq!(loaded[0], valid);
1232    }
1233
1234    // --- history-coupled ref writes (history-mmr feature) -------------
1235
1236    #[cfg(feature = "history-mmr")]
1237    mod history_coupling {
1238        use super::*;
1239        use crate::history::{CommitHistory, TokioExecutor};
1240        use std::sync::Arc;
1241
1242        #[test]
1243        fn update_ref_with_history_appends_to_journal_under_lock() {
1244            let (_dir, mkit) = fresh_repo();
1245            let exec = Arc::new(TokioExecutor::new().unwrap());
1246            let mut hist = CommitHistory::open_at(exec.clone(), &mkit, "main").unwrap();
1247
1248            let c1 = h("c1");
1249            let c2 = h("c2");
1250
1251            update_ref_with_history(&mkit, "main", RefWriteCondition::Any, &c1, &mut hist).unwrap();
1252            update_ref_with_history(&mkit, "main", RefWriteCondition::Match(c1), &c2, &mut hist)
1253                .unwrap();
1254
1255            assert_eq!(read_ref(&mkit, "main").unwrap(), Some(c2));
1256            assert_eq!(hist.len(), 2, "two appends → two leaves in the MMR");
1257        }
1258
1259        #[test]
1260        fn update_ref_with_history_rejects_mem_history() {
1261            let (_dir, mkit) = fresh_repo();
1262            let mut mem_hist = CommitHistory::open();
1263            let err = update_ref_with_history(
1264                &mkit,
1265                "main",
1266                RefWriteCondition::Any,
1267                &h("x"),
1268                &mut mem_hist,
1269            )
1270            .unwrap_err();
1271            assert!(matches!(err, RefError::InvalidRef(_)));
1272        }
1273
1274        #[test]
1275        fn update_ref_with_history_rejects_branch_mismatch() {
1276            let (_dir, mkit) = fresh_repo();
1277            let exec = Arc::new(TokioExecutor::new().unwrap());
1278            // Open history for "main", but try to update ref "feature".
1279            let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
1280            let err = update_ref_with_history(
1281                &mkit,
1282                "feature",
1283                RefWriteCondition::Any,
1284                &h("x"),
1285                &mut hist,
1286            )
1287            .unwrap_err();
1288            assert!(matches!(err, RefError::InvalidRef(_)));
1289        }
1290
1291        #[test]
1292        fn update_ref_with_history_cas_failure_does_not_append() {
1293            let (_dir, mkit) = fresh_repo();
1294            let exec = Arc::new(TokioExecutor::new().unwrap());
1295            let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
1296
1297            // Pre-seed the ref so a `Missing` CAS will fail.
1298            write_ref(&mkit, "main", &h("existing")).unwrap();
1299
1300            let err = update_ref_with_history(
1301                &mkit,
1302                "main",
1303                RefWriteCondition::Missing,
1304                &h("new"),
1305                &mut hist,
1306            )
1307            .unwrap_err();
1308            assert!(matches!(err, RefError::Conflict(_)));
1309            assert_eq!(
1310                hist.len(),
1311                0,
1312                "CAS failure must NOT have appended to history"
1313            );
1314        }
1315    }
1316}