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