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