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/// Delete a remote-tracking branch ref (e.g. after the upstream
539/// deleted the branch). Errors with [`RefError::NotFound`] if absent.
540pub fn delete_remote_ref(mkit_dir: &Path, remote: &str, branch: &str) -> RefResult<()> {
541    validate_remote_and_branch(remote, branch)?;
542    let path = ref_path(mkit_dir, &remote_ref_dir(remote), branch);
543    match fs::remove_file(&path) {
544        Ok(()) => Ok(()),
545        Err(e) if e.kind() == io::ErrorKind::NotFound => {
546            Err(RefError::NotFound(format!("{remote}/{branch}")))
547        }
548        Err(e) => Err(RefError::Io(e)),
549    }
550}
551
552/// List all remote-tracking refs for one remote.
553pub fn list_remote_refs(mkit_dir: &Path, remote: &str) -> RefResult<Vec<Ref>> {
554    if !validate_ref_name(remote) {
555        return Err(RefError::InvalidRefName(remote.to_string()));
556    }
557    list_refs_under(mkit_dir, &remote_ref_dir(remote))
558}
559
560/// List the remote names that have at least one tracking ref on disk
561/// (the immediate subdirectories of `refs/remotes/`), sorted. A
562/// missing `refs/remotes/` yields an empty list. Entries whose names
563/// fail the ref grammar are skipped (consistent with how malformed
564/// ref files are skipped by [`list_refs`]).
565pub fn list_remote_names(mkit_dir: &Path) -> RefResult<Vec<String>> {
566    let dir = mkit_dir.join(REMOTES_DIR);
567    let entries = match fs::read_dir(&dir) {
568        Ok(e) => e,
569        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
570        Err(e) => return Err(RefError::Io(e)),
571    };
572    let mut names = Vec::new();
573    for entry in entries {
574        let entry = entry.map_err(RefError::Io)?;
575        if !entry.file_type().map_err(RefError::Io)?.is_dir() {
576            continue;
577        }
578        if let Some(name) = entry.file_name().to_str()
579            && validate_ref_name(name)
580        {
581            names.push(name.to_owned());
582        }
583    }
584    names.sort();
585    Ok(names)
586}
587
588// -----------------------------------------------------------------------------
589// Tags (refs/tags/<name>)
590// -----------------------------------------------------------------------------
591
592/// Read the hash a tag points to.
593pub fn read_tag(mkit_dir: &Path, name: &str) -> RefResult<Option<Hash>> {
594    if !validate_ref_name(name) {
595        return Err(RefError::InvalidRefName(name.to_string()));
596    }
597    read_ref_under(mkit_dir, TAGS_DIR, name)
598}
599
600/// Write a tag ref (unconditional).
601pub fn write_tag(mkit_dir: &Path, name: &str, h: &Hash) -> RefResult<()> {
602    update_tag(mkit_dir, name, RefWriteCondition::Any, h)
603}
604
605/// CAS-aware tag write — same semantics as [`update_ref`] but for
606/// `refs/tags/`.
607pub fn update_tag(
608    mkit_dir: &Path,
609    name: &str,
610    condition: RefWriteCondition,
611    h: &Hash,
612) -> RefResult<()> {
613    if !validate_ref_name(name) {
614        return Err(RefError::InvalidRefName(name.to_string()));
615    }
616    let path = ref_path(mkit_dir, TAGS_DIR, name);
617    let wire = encode_ref_wire(h);
618    cas_write(&path, &wire, name, condition)
619}
620
621/// Delete a tag ref.
622pub fn delete_tag(mkit_dir: &Path, name: &str) -> RefResult<()> {
623    if !validate_ref_name(name) {
624        return Err(RefError::InvalidRefName(name.to_string()));
625    }
626    let path = ref_path(mkit_dir, TAGS_DIR, name);
627    match fs::remove_file(&path) {
628        Ok(()) => Ok(()),
629        Err(e) if e.kind() == io::ErrorKind::NotFound => Err(RefError::NotFound(name.to_string())),
630        Err(e) => Err(RefError::Io(e)),
631    }
632}
633
634/// List all tag refs, sorted lexicographically by name.
635pub fn list_tags(mkit_dir: &Path) -> RefResult<Vec<Ref>> {
636    list_refs_under(mkit_dir, TAGS_DIR)
637}
638
639// -----------------------------------------------------------------------------
640// Shallow boundaries (.mkit/shallow)
641// -----------------------------------------------------------------------------
642
643/// Load shallow-boundary hashes from `.mkit/shallow`. Returns `Ok(None)`
644/// if the file does not exist or is empty.
645pub fn load_shallow_boundaries(mkit_dir: &Path) -> RefResult<Option<Vec<Hash>>> {
646    let path = mkit_dir.join(SHALLOW_FILE);
647    let meta = match fs::metadata(&path) {
648        Ok(m) => m,
649        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
650        Err(e) => return Err(RefError::Io(e)),
651    };
652    if meta.len() == 0 {
653        return Ok(None);
654    }
655    if meta.len() > SHALLOW_MAX_BYTES {
656        return Err(RefError::InvalidRef("shallow file too large".to_string()));
657    }
658    let bytes = fs::read(&path)?;
659    let s = core::str::from_utf8(&bytes).map_err(|_| RefError::InvalidHead)?;
660    let mut out = Vec::new();
661    for line in s.split('\n') {
662        let trimmed = line.trim_end_matches(['\r', ' ', '\t']);
663        if trimmed.len() != HEX_LEN {
664            continue;
665        }
666        if let Some(h) = parse_lowercase_hash(trimmed.as_bytes()) {
667            out.push(h);
668        }
669    }
670    if out.is_empty() {
671        return Ok(None);
672    }
673    Ok(Some(out))
674}
675
676/// Write shallow-boundary hashes to `.mkit/shallow`. Passing an empty
677/// slice removes the file.
678pub fn write_shallow_boundaries(mkit_dir: &Path, boundaries: &[Hash]) -> RefResult<()> {
679    let path = mkit_dir.join(SHALLOW_FILE);
680    if boundaries.is_empty() {
681        match fs::remove_file(&path) {
682            Ok(()) => Ok(()),
683            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
684            Err(e) => Err(RefError::Io(e)),
685        }
686    } else {
687        let mut out = Vec::with_capacity(boundaries.len() * 65);
688        for h in boundaries {
689            out.extend_from_slice(&encode_ref_wire(h));
690        }
691        write_atomic(&path, &out, true)?;
692        Ok(())
693    }
694}
695
696// -----------------------------------------------------------------------------
697// Internals
698// -----------------------------------------------------------------------------
699
700fn ref_path(mkit_dir: &Path, sub_dir: &str, name: &str) -> PathBuf {
701    let mut path = mkit_dir.join(sub_dir);
702    for segment in name.split('/') {
703        path.push(segment);
704    }
705    path
706}
707
708fn remote_ref_dir(remote: &str) -> String {
709    format!("{REMOTES_DIR}/{remote}")
710}
711
712fn validate_remote_and_branch(remote: &str, branch: &str) -> RefResult<()> {
713    if !validate_ref_name(remote) {
714        return Err(RefError::InvalidRefName(remote.to_string()));
715    }
716    if !validate_ref_name(branch) {
717        return Err(RefError::InvalidRefName(branch.to_string()));
718    }
719    Ok(())
720}
721
722fn read_ref_under(mkit_dir: &Path, sub_dir: &str, name: &str) -> RefResult<Option<Hash>> {
723    let path = ref_path(mkit_dir, sub_dir, name);
724    let meta = match fs::metadata(&path) {
725        Ok(m) => m,
726        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
727        Err(e) => return Err(RefError::Io(e)),
728    };
729    if meta.len() > REF_FILE_MAX_BYTES {
730        return Err(RefError::InvalidRef(name.to_string()));
731    }
732    let bytes = fs::read(&path)?;
733    let h = decode_ref_wire(&bytes).ok_or_else(|| RefError::InvalidRef(name.to_string()))?;
734    Ok(Some(h))
735}
736
737fn cas_write(
738    path: &Path,
739    wire: &[u8; 65],
740    name_for_err: &str,
741    condition: RefWriteCondition,
742) -> RefResult<()> {
743    match condition {
744        RefWriteCondition::Any => {
745            write_atomic(path, wire, true)?;
746            Ok(())
747        }
748        RefWriteCondition::Missing => {
749            // O_EXCL — fail if anything is at `path` already.
750            if let Some(parent) = path.parent() {
751                fs::create_dir_all(parent)?;
752            }
753            let created = write_create_new(path, wire, true)?;
754            if !created {
755                return Err(RefError::Conflict(name_for_err.to_string()));
756            }
757            Ok(())
758        }
759        RefWriteCondition::Match(expected) => {
760            // Read-then-write — non-atomic per SPEC-REFS §5.1 (file
761            // transport row). The spec explicitly documents this gap.
762            let current = match fs::read(path) {
763                Ok(b) => Some(
764                    decode_ref_wire(&b)
765                        .ok_or_else(|| RefError::InvalidRef(name_for_err.to_string()))?,
766                ),
767                Err(e) if e.kind() == io::ErrorKind::NotFound => None,
768                Err(e) => return Err(RefError::Io(e)),
769            };
770            if current != Some(expected) {
771                return Err(RefError::Conflict(name_for_err.to_string()));
772            }
773            write_atomic(path, wire, true)?;
774            Ok(())
775        }
776    }
777}
778
779fn list_refs_under(mkit_dir: &Path, sub_dir: &str) -> RefResult<Vec<Ref>> {
780    let root = mkit_dir.join(sub_dir);
781    let mut out = Vec::new();
782    if !root.is_dir() {
783        return Ok(out);
784    }
785    collect_refs(&root, "", &mut out, 0)?;
786    out.sort_by(|a, b| a.name.cmp(&b.name));
787    Ok(out)
788}
789
790/// Cap on ref-tree recursion depth. A malicious or corrupt `.mkit/refs/`
791/// directory with deeply nested empty dirs should not stack-overflow
792/// the walker. 32 is far beyond anything a valid ref name (which cannot
793/// contain more than a few `/` separators given the 1–255 path-segment
794/// grammar) could ever require.
795const MAX_REF_DEPTH: usize = 32;
796
797fn collect_refs(root: &Path, prefix: &str, out: &mut Vec<Ref>, depth: usize) -> RefResult<()> {
798    if depth > MAX_REF_DEPTH {
799        // Silently stop — same "skip malformed" posture as below for
800        // individual files. Callers get a partial result rather than a
801        // stack overflow on adversarial input.
802        return Ok(());
803    }
804    let dir_path = if prefix.is_empty() {
805        root.to_path_buf()
806    } else {
807        root.join(prefix)
808    };
809    let iter = match fs::read_dir(&dir_path) {
810        Ok(i) => i,
811        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
812        Err(e) => return Err(RefError::Io(e)),
813    };
814    for entry in iter {
815        let entry = entry?;
816        let file_name = match entry.file_name().to_str() {
817            Some(s) => s.to_string(),
818            None => continue, // Non-UTF-8 names cannot be valid ref names.
819        };
820        let child_name = if prefix.is_empty() {
821            file_name.clone()
822        } else {
823            format!("{prefix}/{file_name}")
824        };
825        let ft = entry.file_type()?;
826        if ft.is_dir() {
827            collect_refs(root, &child_name, out, depth + 1)?;
828            continue;
829        }
830        if !ft.is_file() {
831            continue;
832        }
833        if !validate_ref_name(&child_name) {
834            continue;
835        }
836        // Read & decode; silently skip malformed files.
837        let Ok(bytes) = fs::read(entry.path()) else {
838            continue;
839        };
840        let hash = decode_ref_wire(&bytes);
841        out.push(Ref {
842            name: child_name,
843            hash,
844        });
845    }
846    Ok(())
847}
848
849// -----------------------------------------------------------------------------
850// Internal hash helper re-exports for goldens
851// -----------------------------------------------------------------------------
852
853/// Internal re-export used by the integration tests to hand-roll wire
854/// bytes without round-tripping through `hash::from_hex`.
855#[doc(hidden)]
856#[must_use]
857pub fn _hash_from_lowercase_hex_for_tests(s: &str) -> Option<Hash> {
858    parse_lowercase_hash(s.as_bytes())
859}
860
861#[cfg(test)]
862mod tests {
863    use super::*;
864    use crate::hash;
865    use tempfile::TempDir;
866
867    fn fresh_repo() -> (TempDir, PathBuf) {
868        let dir = TempDir::new().unwrap();
869        let mkit_dir = dir.path().join(".mkit");
870        fs::create_dir_all(&mkit_dir).unwrap();
871        init(&mkit_dir).unwrap();
872        (dir, mkit_dir)
873    }
874
875    fn h(seed: &str) -> Hash {
876        hash::hash(seed.as_bytes())
877    }
878
879    // --- name grammar ---------------------------------------------------
880
881    #[test]
882    fn validate_accepts_simple_names() {
883        assert!(validate_ref_name("main"));
884        assert!(validate_ref_name("feat/v1.0-beta"));
885        assert!(validate_ref_name("release/2024_09"));
886    }
887
888    #[test]
889    fn validate_rejects_empty() {
890        assert!(!validate_ref_name(""));
891    }
892
893    #[test]
894    fn validate_rejects_leading_slash() {
895        assert!(!validate_ref_name("/main"));
896    }
897
898    #[test]
899    fn validate_rejects_dotdot_segment() {
900        assert!(!validate_ref_name("feat/.."));
901        assert!(!validate_ref_name("../escape"));
902        assert!(!validate_ref_name("feat/./topic"));
903    }
904
905    #[test]
906    fn validate_rejects_double_slash() {
907        assert!(!validate_ref_name("refs//heads/main"));
908        assert!(!validate_ref_name("main/"));
909    }
910
911    #[test]
912    fn validate_rejects_disallowed_bytes() {
913        assert!(!validate_ref_name("main@v1"));
914        assert!(!validate_ref_name("feat\\branch"));
915        assert!(!validate_ref_name("with space"));
916    }
917
918    #[test]
919    fn validate_rejects_lock_suffix() {
920        assert!(!validate_ref_name("refs/heads/main.lock"));
921    }
922
923    #[test]
924    fn validate_rejects_head_final_segment() {
925        assert!(!validate_ref_name("refs/heads/HEAD"));
926        assert!(!validate_ref_name("HEAD"));
927    }
928
929    #[test]
930    fn validate_accepts_main_regression() {
931        assert!(validate_ref_name("refs/heads/main"));
932    }
933
934    #[test]
935    fn validate_accepts_non_lock_suffix_regression() {
936        // Only trailing ".lock" should reject; "lockfile" is fine.
937        assert!(validate_ref_name("refs/heads/lockfile"));
938    }
939
940    #[test]
941    fn validate_accepts_headless_regression() {
942        // Only the exact final segment "HEAD" is rejected.
943        assert!(validate_ref_name("refs/heads/HEADless"));
944    }
945
946    #[test]
947    fn validate_prefix() {
948        assert!(validate_ref_prefix(""));
949        assert!(validate_ref_prefix("refs/heads/"));
950        assert!(validate_ref_prefix("refs/heads"));
951        assert!(!validate_ref_prefix("refs//heads/"));
952        assert!(!validate_ref_prefix("/"));
953    }
954
955    // --- wire encoding --------------------------------------------------
956
957    #[test]
958    fn wire_round_trip() {
959        let original = h("test-ref");
960        let wire = encode_ref_wire(&original);
961        assert_eq!(wire.len(), 65);
962        assert_eq!(wire[64], b'\n');
963        let parsed = decode_ref_wire(&wire).unwrap();
964        assert_eq!(parsed, original);
965    }
966
967    #[test]
968    fn wire_rejects_uppercase() {
969        let original = h("test-ref");
970        let mut wire = encode_ref_wire(&original);
971        // Upper-case the first letter we find; SPEC-REFS §1 forbids
972        // uppercase hex on read.
973        let mut flipped = false;
974        for b in &mut wire[..HEX_LEN] {
975            if (b'a'..=b'f').contains(b) {
976                *b -= b'a' - b'A';
977                flipped = true;
978                break;
979            }
980        }
981        assert!(flipped, "test fixture should contain at least one a-f");
982        assert!(decode_ref_wire(&wire).is_none());
983    }
984
985    #[test]
986    fn wire_rejects_short_input() {
987        let bad = b"deadbeef\n";
988        assert!(decode_ref_wire(bad).is_none());
989    }
990
991    #[test]
992    fn wire_rejects_non_hex() {
993        let mut wire = encode_ref_wire(&h("x"));
994        wire[1] = b'g';
995        assert!(decode_ref_wire(&wire).is_none());
996    }
997
998    #[test]
999    fn wire_tolerates_trailing_cr() {
1000        // Files round-tripped through Windows editors may pick up CRs.
1001        let original = h("eol");
1002        let mut buf = encode_ref_wire(&original).to_vec();
1003        buf.insert(64, b'\r');
1004        let parsed = decode_ref_wire(&buf).unwrap();
1005        assert_eq!(parsed, original);
1006    }
1007
1008    // --- HEAD ----------------------------------------------------------
1009
1010    #[test]
1011    fn init_writes_default_head() {
1012        let (_dir, mkit) = fresh_repo();
1013        let head = read_head(&mkit).unwrap();
1014        assert_eq!(head, Head::Branch("main".to_string()));
1015    }
1016
1017    #[test]
1018    fn write_and_read_branch_ref() {
1019        let (_dir, mkit) = fresh_repo();
1020        let commit = h("commit1");
1021        write_ref(&mkit, "main", &commit).unwrap();
1022        let read = read_ref(&mkit, "main").unwrap();
1023        assert_eq!(read, Some(commit));
1024    }
1025
1026    #[test]
1027    fn resolve_head_with_no_commits_returns_none() {
1028        let (_dir, mkit) = fresh_repo();
1029        assert_eq!(resolve_head(&mkit).unwrap(), None);
1030    }
1031
1032    #[test]
1033    fn resolve_head_after_commit() {
1034        let (_dir, mkit) = fresh_repo();
1035        let commit = h("commit1");
1036        write_ref(&mkit, "main", &commit).unwrap();
1037        assert_eq!(resolve_head(&mkit).unwrap(), Some(commit));
1038    }
1039
1040    #[test]
1041    fn update_head_updates_current_branch() {
1042        let (_dir, mkit) = fresh_repo();
1043        let h1 = h("c1");
1044        update_head(&mkit, &h1).unwrap();
1045        assert_eq!(resolve_head(&mkit).unwrap(), Some(h1));
1046        let h2 = h("c2");
1047        update_head(&mkit, &h2).unwrap();
1048        assert_eq!(resolve_head(&mkit).unwrap(), Some(h2));
1049    }
1050
1051    #[test]
1052    fn detached_head_round_trip() {
1053        let dir = TempDir::new().unwrap();
1054        let mkit = dir.path().join(".mkit");
1055        fs::create_dir_all(&mkit).unwrap();
1056        let commit = h("detached");
1057        write_head_detached(&mkit, &commit).unwrap();
1058        match read_head(&mkit).unwrap() {
1059            Head::Detached(got) => assert_eq!(got, commit),
1060            other @ Head::Branch(_) => panic!("expected detached, got {other:?}"),
1061        }
1062        assert_eq!(resolve_head(&mkit).unwrap(), Some(commit));
1063    }
1064
1065    #[test]
1066    fn nonexistent_branch_returns_none() {
1067        let (_dir, mkit) = fresh_repo();
1068        assert_eq!(read_ref(&mkit, "nonexistent").unwrap(), None);
1069    }
1070
1071    #[test]
1072    fn list_refs_empty() {
1073        let (_dir, mkit) = fresh_repo();
1074        let refs = list_refs(&mkit).unwrap();
1075        assert!(refs.is_empty());
1076    }
1077
1078    #[test]
1079    fn list_refs_sorted() {
1080        let (_dir, mkit) = fresh_repo();
1081        write_ref(&mkit, "main", &h("m")).unwrap();
1082        write_ref(&mkit, "dev", &h("d")).unwrap();
1083        let refs = list_refs(&mkit).unwrap();
1084        assert_eq!(refs.len(), 2);
1085        assert_eq!(refs[0].name, "dev");
1086        assert_eq!(refs[1].name, "main");
1087    }
1088
1089    #[test]
1090    fn nested_refs_listed_recursively() {
1091        let (_dir, mkit) = fresh_repo();
1092        write_ref(&mkit, "feature/deep/topic", &h("nested")).unwrap();
1093        let refs = list_refs(&mkit).unwrap();
1094        assert_eq!(refs.len(), 1);
1095        assert_eq!(refs[0].name, "feature/deep/topic");
1096    }
1097
1098    #[test]
1099    fn delete_ref_basic() {
1100        let (_dir, mkit) = fresh_repo();
1101        write_ref(&mkit, "feature", &h("f")).unwrap();
1102        delete_ref(&mkit, "feature").unwrap();
1103        assert_eq!(read_ref(&mkit, "feature").unwrap(), None);
1104    }
1105
1106    #[test]
1107    fn delete_nonexistent_ref_errors() {
1108        let (_dir, mkit) = fresh_repo();
1109        let err = delete_ref(&mkit, "nope").unwrap_err();
1110        assert!(matches!(err, RefError::NotFound(_)));
1111    }
1112
1113    #[test]
1114    fn refuse_delete_current_branch() {
1115        let (_dir, mkit) = fresh_repo();
1116        write_ref(&mkit, "main", &h("m")).unwrap();
1117        let err = delete_ref_safe(&mkit, "main").unwrap_err();
1118        assert!(matches!(err, RefError::CurrentBranch(_)));
1119    }
1120
1121    // --- CAS variants ---------------------------------------------------
1122
1123    #[test]
1124    fn cas_any_clobbers() {
1125        let (_dir, mkit) = fresh_repo();
1126        update_ref(&mkit, "main", RefWriteCondition::Any, &h("a")).unwrap();
1127        update_ref(&mkit, "main", RefWriteCondition::Any, &h("b")).unwrap();
1128        assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("b")));
1129    }
1130
1131    #[test]
1132    fn cas_missing_succeeds_when_absent() {
1133        let (_dir, mkit) = fresh_repo();
1134        update_ref(&mkit, "main", RefWriteCondition::Missing, &h("a")).unwrap();
1135        assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("a")));
1136    }
1137
1138    #[test]
1139    fn cas_missing_fails_when_present() {
1140        let (_dir, mkit) = fresh_repo();
1141        write_ref(&mkit, "main", &h("a")).unwrap();
1142        let err = update_ref(&mkit, "main", RefWriteCondition::Missing, &h("b")).unwrap_err();
1143        assert!(matches!(err, RefError::Conflict(_)));
1144    }
1145
1146    #[test]
1147    fn cas_match_succeeds_on_correct_hash() {
1148        let (_dir, mkit) = fresh_repo();
1149        write_ref(&mkit, "main", &h("a")).unwrap();
1150        update_ref(&mkit, "main", RefWriteCondition::Match(h("a")), &h("b")).unwrap();
1151        assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("b")));
1152    }
1153
1154    #[test]
1155    fn cas_match_fails_on_wrong_hash() {
1156        let (_dir, mkit) = fresh_repo();
1157        write_ref(&mkit, "main", &h("a")).unwrap();
1158        let err = update_ref(&mkit, "main", RefWriteCondition::Match(h("z")), &h("b")).unwrap_err();
1159        assert!(matches!(err, RefError::Conflict(_)));
1160    }
1161
1162    #[test]
1163    fn cas_match_fails_on_missing_ref() {
1164        let (_dir, mkit) = fresh_repo();
1165        let err = update_ref(&mkit, "main", RefWriteCondition::Match(h("a")), &h("b")).unwrap_err();
1166        assert!(matches!(err, RefError::Conflict(_)));
1167    }
1168
1169    // --- name-validation enforcement -----------------------------------
1170
1171    #[test]
1172    fn write_rejects_invalid_branch_name() {
1173        let (_dir, mkit) = fresh_repo();
1174        let err = write_ref(&mkit, "../escape", &h("x")).unwrap_err();
1175        assert!(matches!(err, RefError::InvalidRefName(_)));
1176        let err = write_head_branch(&mkit, "bad//branch").unwrap_err();
1177        assert!(matches!(err, RefError::InvalidRefName(_)));
1178    }
1179
1180    // --- tags ----------------------------------------------------------
1181
1182    #[test]
1183    fn write_and_read_tag() {
1184        let (_dir, mkit) = fresh_repo();
1185        let commit = h("v1.0");
1186        write_tag(&mkit, "v1.0", &commit).unwrap();
1187        assert_eq!(read_tag(&mkit, "v1.0").unwrap(), Some(commit));
1188    }
1189
1190    #[test]
1191    fn list_tags_sorted() {
1192        let (_dir, mkit) = fresh_repo();
1193        write_tag(&mkit, "v2.0", &h("v2")).unwrap();
1194        write_tag(&mkit, "v1.0", &h("v1")).unwrap();
1195        write_tag(&mkit, "alpha", &h("a")).unwrap();
1196        let tags = list_tags(&mkit).unwrap();
1197        assert_eq!(
1198            tags.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
1199            vec!["alpha", "v1.0", "v2.0"]
1200        );
1201    }
1202
1203    #[test]
1204    fn tag_and_branch_same_name_independent() {
1205        let (_dir, mkit) = fresh_repo();
1206        let tag = h("tag");
1207        let branch = h("branch");
1208        write_tag(&mkit, "main", &tag).unwrap();
1209        write_ref(&mkit, "main", &branch).unwrap();
1210        assert_eq!(read_tag(&mkit, "main").unwrap(), Some(tag));
1211        assert_eq!(read_ref(&mkit, "main").unwrap(), Some(branch));
1212    }
1213
1214    #[test]
1215    fn delete_tag_basic() {
1216        let (_dir, mkit) = fresh_repo();
1217        write_tag(&mkit, "release", &h("r")).unwrap();
1218        delete_tag(&mkit, "release").unwrap();
1219        assert_eq!(read_tag(&mkit, "release").unwrap(), None);
1220    }
1221
1222    #[test]
1223    fn delete_nonexistent_tag_errors() {
1224        let (_dir, mkit) = fresh_repo();
1225        let err = delete_tag(&mkit, "missing").unwrap_err();
1226        assert!(matches!(err, RefError::NotFound(_)));
1227    }
1228
1229    // --- shallow boundaries --------------------------------------------
1230
1231    #[test]
1232    fn load_shallow_returns_none_when_missing() {
1233        let (_dir, mkit) = fresh_repo();
1234        assert_eq!(load_shallow_boundaries(&mkit).unwrap(), None);
1235    }
1236
1237    #[test]
1238    fn write_and_load_shallow_round_trip() {
1239        let (_dir, mkit) = fresh_repo();
1240        let bs = vec![h("b1"), h("b2"), h("b3")];
1241        write_shallow_boundaries(&mkit, &bs).unwrap();
1242        let loaded = load_shallow_boundaries(&mkit).unwrap().unwrap();
1243        assert_eq!(loaded.len(), 3);
1244        for b in &bs {
1245            assert!(loaded.contains(b));
1246        }
1247    }
1248
1249    #[test]
1250    fn write_empty_shallow_removes_file() {
1251        let (_dir, mkit) = fresh_repo();
1252        write_shallow_boundaries(&mkit, &[h("x")]).unwrap();
1253        assert!(load_shallow_boundaries(&mkit).unwrap().is_some());
1254        write_shallow_boundaries(&mkit, &[]).unwrap();
1255        assert_eq!(load_shallow_boundaries(&mkit).unwrap(), None);
1256    }
1257
1258    #[test]
1259    fn load_shallow_skips_invalid_lines() {
1260        let (_dir, mkit) = fresh_repo();
1261        let path = mkit.join(SHALLOW_FILE);
1262        let valid = h("ok");
1263        let valid_hex = to_hex(&valid);
1264        let mut content = String::new();
1265        content.push_str("short\n");
1266        content.push_str(&valid_hex);
1267        content.push('\n');
1268        content.push_str(&"z".repeat(64));
1269        content.push('\n');
1270        std::fs::write(&path, content).unwrap();
1271        let loaded = load_shallow_boundaries(&mkit).unwrap().unwrap();
1272        assert_eq!(loaded.len(), 1);
1273        assert_eq!(loaded[0], valid);
1274    }
1275
1276    // --- history-coupled ref writes (history-mmr feature) -------------
1277
1278    #[cfg(feature = "history-mmr")]
1279    mod history_coupling {
1280        use super::*;
1281        use crate::history::{CommitHistory, TokioExecutor};
1282        use std::sync::Arc;
1283
1284        #[test]
1285        fn update_ref_with_history_appends_to_journal_under_lock() {
1286            let (_dir, mkit) = fresh_repo();
1287            let exec = Arc::new(TokioExecutor::new().unwrap());
1288            let mut hist = CommitHistory::open_at(exec.clone(), &mkit, "main").unwrap();
1289
1290            let c1 = h("c1");
1291            let c2 = h("c2");
1292
1293            update_ref_with_history(&mkit, "main", RefWriteCondition::Any, &c1, &mut hist).unwrap();
1294            update_ref_with_history(&mkit, "main", RefWriteCondition::Match(c1), &c2, &mut hist)
1295                .unwrap();
1296
1297            assert_eq!(read_ref(&mkit, "main").unwrap(), Some(c2));
1298            assert_eq!(hist.len(), 2, "two appends → two leaves in the MMR");
1299        }
1300
1301        #[test]
1302        fn update_ref_with_history_rejects_mem_history() {
1303            let (_dir, mkit) = fresh_repo();
1304            let mut mem_hist = CommitHistory::open();
1305            let err = update_ref_with_history(
1306                &mkit,
1307                "main",
1308                RefWriteCondition::Any,
1309                &h("x"),
1310                &mut mem_hist,
1311            )
1312            .unwrap_err();
1313            assert!(matches!(err, RefError::InvalidRef(_)));
1314        }
1315
1316        #[test]
1317        fn update_ref_with_history_rejects_branch_mismatch() {
1318            let (_dir, mkit) = fresh_repo();
1319            let exec = Arc::new(TokioExecutor::new().unwrap());
1320            // Open history for "main", but try to update ref "feature".
1321            let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
1322            let err = update_ref_with_history(
1323                &mkit,
1324                "feature",
1325                RefWriteCondition::Any,
1326                &h("x"),
1327                &mut hist,
1328            )
1329            .unwrap_err();
1330            assert!(matches!(err, RefError::InvalidRef(_)));
1331        }
1332
1333        #[test]
1334        fn update_ref_with_history_cas_failure_does_not_append() {
1335            let (_dir, mkit) = fresh_repo();
1336            let exec = Arc::new(TokioExecutor::new().unwrap());
1337            let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
1338
1339            // Pre-seed the ref so a `Missing` CAS will fail.
1340            write_ref(&mkit, "main", &h("existing")).unwrap();
1341
1342            let err = update_ref_with_history(
1343                &mkit,
1344                "main",
1345                RefWriteCondition::Missing,
1346                &h("new"),
1347                &mut hist,
1348            )
1349            .unwrap_err();
1350            assert!(matches!(err, RefError::Conflict(_)));
1351            assert_eq!(
1352                hist.len(),
1353                0,
1354                "CAS failure must NOT have appended to history"
1355            );
1356        }
1357    }
1358}