Skip to main content

grit_lib/
reftable.rs

1//! Reftable format — binary reference storage.
2//!
3//! Implements the [reftable file format](https://git-scm.com/docs/reftable)
4//! for efficient, sorted reference storage.  A reftable file contains
5//! ref blocks (sorted ref records with prefix compression), optional log
6//! blocks (reflog entries), optional index blocks, and a footer.
7//!
8//! # Architecture
9//!
10//! - [`ReftableWriter`] writes a single `.ref` (or `.log`) reftable file.
11//! - [`ReftableReader`] reads and searches a single reftable file.
12//! - [`ReftableStack`] manages the `tables.list` stack, providing a
13//!   merged view of all tables and auto-compaction on writes.
14//!
15//! # On-disk layout
16//!
17//! ```text
18//! first_block { header, first_ref_block }
19//! ref_block*
20//! ref_index?
21//! obj_block*    (not yet implemented)
22//! obj_index?    (not yet implemented)
23//! log_block*
24//! log_index?
25//! footer
26//! ```
27
28use crate::config::ConfigSet;
29use crate::error::{Error, Result};
30use crate::fs_publish::publish_no_clobber;
31use crate::objects::ObjectId;
32use crate::refs::store::RefStoreError;
33use memmap2::Mmap;
34use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
35use std::fs;
36use std::io::Write;
37use std::ops::ControlFlow;
38use std::path::{Path, PathBuf};
39use std::sync::{Arc, Mutex, OnceLock};
40use std::time::{Duration, Instant};
41use tempfile::TempPath;
42
43// ---------------------------------------------------------------------------
44// Constants
45// ---------------------------------------------------------------------------
46
47/// Magic bytes at the start of every reftable file.
48const REFTABLE_MAGIC: &[u8; 4] = b"REFT";
49
50/// File header size (version 1): magic(4) + version(1) + block_size(3)
51/// + min_update_index(8) + max_update_index(8) = 24 bytes.
52const HEADER_SIZE: usize = 24;
53
54/// Footer size for version 1.
55const FOOTER_V1_SIZE: usize = 68;
56
57/// Block type: ref block.
58const BLOCK_TYPE_REF: u8 = b'r';
59/// Block type: index block.
60const BLOCK_TYPE_INDEX: u8 = b'i';
61/// Block type: log block (zlib-compressed).
62const BLOCK_TYPE_LOG: u8 = b'g';
63/// Block type: object index block.
64const BLOCK_TYPE_OBJ: u8 = b'o';
65
66/// Value types encoded in the low 3 bits of the suffix_length varint.
67const VALUE_DELETION: u8 = 0;
68const VALUE_ONE_OID: u8 = 1;
69const VALUE_TWO_OID: u8 = 2;
70const VALUE_SYMREF: u8 = 3;
71
72/// Hash size (SHA-1).
73const HASH_SIZE: usize = 20;
74
75/// Default block size when none is configured (4 KiB).
76const DEFAULT_BLOCK_SIZE: u32 = 4096;
77
78/// How many records between restart points.
79const RESTART_INTERVAL: usize = 16;
80
81/// A reftable failure: malformed table bytes, a locked stack, or a rejected update.
82///
83/// Wrapped by [`Error::Reftable`]. Corruption variants describe what was wrong
84/// with the bytes; [`Self::Locked`] and [`Self::ExpectedMismatch`] are the
85/// conditions callers usually want to react to (retry, or re-read and redo).
86#[derive(Debug, thiserror::Error)]
87#[non_exhaustive]
88pub enum ReftableError {
89    /// The file is shorter than its header and footer require.
90    #[error("reftable: file is too small ({len} bytes)")]
91    TooSmall {
92        /// Number of bytes available.
93        len: usize,
94    },
95
96    /// The header or footer does not start with the `REFT` magic.
97    #[error("reftable: bad magic bytes")]
98    BadMagic,
99
100    /// The header names a format version other than 1 (SHA-1) or 2 (SHA-256).
101    #[error("reftable: unsupported format version {version}")]
102    UnsupportedVersion {
103        /// Version byte from the header.
104        version: u8,
105    },
106
107    /// The footer repeats a different version than the header.
108    #[error("reftable: footer version {footer} does not match header version {header}")]
109    FooterVersionMismatch {
110        /// Version byte from the header.
111        header: u8,
112        /// Version byte from the footer.
113        footer: u8,
114    },
115
116    /// The footer's CRC-32 does not match its contents.
117    #[error("reftable: footer checksum mismatch (stored {stored:08x}, computed {computed:08x})")]
118    FooterChecksumMismatch {
119        /// CRC-32 recorded in the footer.
120        stored: u32,
121        /// CRC-32 computed over the footer bytes.
122        computed: u32,
123    },
124
125    /// The data ends inside a field.
126    #[error("reftable: truncated {field}")]
127    Truncated {
128        /// Field that was cut short.
129        field: ReftableField,
130    },
131
132    /// A text field is not valid UTF-8.
133    #[error("reftable: {field} is not valid UTF-8")]
134    InvalidUtf8 {
135        /// Field holding the invalid bytes.
136        field: ReftableField,
137    },
138
139    /// A prefix-compressed key claims more shared bytes than the previous key has.
140    #[error("reftable: {field} prefix is longer than the previous key")]
141    PrefixTooLong {
142        /// Key whose prefix length is out of range.
143        field: ReftableField,
144    },
145
146    /// A ref record uses a value type outside the format's four kinds.
147    #[error("reftable: unknown ref value type {value_type}")]
148    UnknownValueType {
149        /// The 3-bit value type found in the record.
150        value_type: u8,
151    },
152
153    /// A block's length points outside the file.
154    #[error("reftable: invalid block length")]
155    InvalidBlockLength,
156
157    /// A block's restart table is out of range or overlaps its records.
158    #[error("reftable: invalid restart table")]
159    InvalidRestartTable,
160
161    /// A log record key lacks the `refname NUL update_index` layout.
162    #[error("reftable: malformed log record key")]
163    InvalidLogKey,
164
165    /// `tables.list` names a table that is not part of the loaded stack.
166    #[error("reftable: tables.list names missing table {name}")]
167    MissingTable {
168        /// Table file name from `tables.list`.
169        name: String,
170    },
171
172    /// [`ReftableWriter::add_ref`] was called out of name order.
173    #[error("reftable: refs must be added in sorted order ('{next}' after '{previous}')")]
174    UnsortedRefs {
175        /// Name of the ref added before.
176        previous: String,
177        /// Name of the ref that did not sort after it.
178        next: String,
179    },
180
181    /// A record does not fit in an empty block of the configured size.
182    #[error("reftable: record does not fit in a block")]
183    EntryTooLarge,
184
185    /// A record with an empty key was written.
186    #[error("reftable: record has an empty key")]
187    EmptyRecordKey,
188
189    /// Another writer holds `tables.list.lock`.
190    #[error("reftable: stack is locked by another writer ({})", lock_path.display())]
191    Locked {
192        /// Path of the held lock file.
193        lock_path: PathBuf,
194    },
195
196    /// The `tables.list.lock` guard was used after its lock had been committed.
197    #[error("reftable: tables.list lock was already released")]
198    LockReleased,
199
200    /// A compare-and-swap update found a different value than it required.
201    #[error(
202        "reftable: '{refname}' expected {expected} but found {}",
203        actual.map_or_else(|| "<absent>".to_owned(), |oid| oid.to_hex())
204    )]
205    ExpectedMismatch {
206        /// Ref the update targeted.
207        refname: String,
208        /// Object id the update required.
209        expected: ObjectId,
210        /// Object id found, or `None` when the ref does not exist.
211        actual: Option<ObjectId>,
212    },
213}
214
215/// A field of a reftable file, named by [`ReftableError`] variants.
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217#[non_exhaustive]
218pub enum ReftableField {
219    /// A variable-length integer.
220    Varint,
221    /// The 4-byte block header (type and length).
222    BlockHeader,
223    /// The key of an index record.
224    IndexKey,
225    /// A ref name (in a ref record or a log key).
226    RefName,
227    /// The object id of a one-value ref record.
228    ObjectId,
229    /// The two object ids of a peeled ref record or a log record.
230    ObjectIdPair,
231    /// The target of a symbolic ref record.
232    SymrefTarget,
233    /// The key of a log record.
234    LogKey,
235    /// The committer name of a log record.
236    LogName,
237    /// The committer email of a log record.
238    LogEmail,
239    /// The timezone offset of a log record.
240    LogTimezone,
241    /// The message of a log record.
242    LogMessage,
243    /// The file footer.
244    Footer,
245}
246
247impl std::fmt::Display for ReftableField {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        f.write_str(match self {
250            Self::Varint => "varint",
251            Self::BlockHeader => "block header",
252            Self::IndexKey => "index key",
253            Self::RefName => "ref name",
254            Self::ObjectId => "object id",
255            Self::ObjectIdPair => "object id pair",
256            Self::SymrefTarget => "symref target",
257            Self::LogKey => "log key",
258            Self::LogName => "log name",
259            Self::LogEmail => "log email",
260            Self::LogTimezone => "log timezone offset",
261            Self::LogMessage => "log message",
262            Self::Footer => "footer",
263        })
264    }
265}
266
267#[cold]
268#[inline(never)]
269fn truncated(field: ReftableField) -> Error {
270    ReftableError::Truncated { field }.into()
271}
272
273#[cold]
274#[inline(never)]
275fn invalid_utf8(field: ReftableField) -> Error {
276    ReftableError::InvalidUtf8 { field }.into()
277}
278
279#[cold]
280#[inline(never)]
281fn corrupt(err: ReftableError) -> Error {
282    err.into()
283}
284
285// ---------------------------------------------------------------------------
286// Varint encoding (Git pack-style)
287// ---------------------------------------------------------------------------
288
289/// Encode a u64 as a varint into `out`. Returns number of bytes written.
290fn put_varint(mut val: u64, out: &mut Vec<u8>) -> usize {
291    // First, collect 7-bit groups.
292    let mut buf = [0u8; 10];
293    let mut i = 0;
294    buf[i] = (val & 0x7f) as u8;
295    i += 1;
296    val >>= 7;
297    while val > 0 {
298        val -= 1;
299        buf[i] = (val & 0x7f) as u8;
300        i += 1;
301        val >>= 7;
302    }
303    // Write in reverse, with continuation bits.
304    let len = i;
305    for j in (1..len).rev() {
306        out.push(buf[j] | 0x80);
307    }
308    out.push(buf[0]);
309    len
310}
311
312/// Decode a varint from `data` starting at `pos`. Returns (value, new_pos).
313fn get_varint(data: &[u8], mut pos: usize) -> Result<(u64, usize)> {
314    if pos >= data.len() {
315        return Err(truncated(ReftableField::Varint));
316    }
317    let mut val = (data[pos] & 0x7f) as u64;
318    while data[pos] & 0x80 != 0 {
319        pos += 1;
320        if pos >= data.len() {
321            return Err(truncated(ReftableField::Varint));
322        }
323        val = ((val + 1) << 7) | (data[pos] & 0x7f) as u64;
324    }
325    Ok((val, pos + 1))
326}
327
328// ---------------------------------------------------------------------------
329// Ref record types
330// ---------------------------------------------------------------------------
331
332/// A single reference record as stored in a reftable.
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub enum RefValue {
335    /// Deletion tombstone (value_type 0x0).
336    Deletion,
337    /// A direct ref pointing to one OID (value_type 0x1).
338    Val1(ObjectId),
339    /// An annotated tag: value + peeled target (value_type 0x2).
340    Val2(ObjectId, ObjectId),
341    /// A symbolic reference (value_type 0x3).
342    Symref(String),
343}
344
345/// A decoded ref record.
346#[derive(Debug, Clone)]
347pub struct RefRecord {
348    /// Full reference name.
349    pub name: String,
350    /// Update index (absolute).
351    pub update_index: u64,
352    /// The value.
353    pub value: RefValue,
354}
355
356/// A decoded log record.
357#[derive(Debug, Clone)]
358pub struct LogRecord {
359    /// Reference name.
360    pub refname: String,
361    /// Update index.
362    pub update_index: u64,
363    /// Old object ID.
364    pub old_id: ObjectId,
365    /// New object ID.
366    pub new_id: ObjectId,
367    /// Committer name.
368    pub name: String,
369    /// Committer email (without angle brackets).
370    pub email: String,
371    /// Time in seconds since epoch.
372    pub time_seconds: u64,
373    /// Timezone offset as Git stores it on disk: the `±HHMM` identity field read as a
374    /// signed decimal number (`+0530` is `530`, `-0800` is `-800`), not minutes.
375    pub tz_offset: i16,
376    /// Log message.
377    pub message: String,
378}
379
380/// Whether `record` is an on-disk reflog deletion tombstone (log key value type 0).
381#[must_use]
382pub(crate) fn log_record_is_deletion(record: &LogRecord) -> bool {
383    record.name.is_empty()
384        && record.email.is_empty()
385        && record.message.is_empty()
386        && record.time_seconds == 0
387        && record.tz_offset == 0
388        && record.old_id.is_zero()
389        && record.new_id.is_zero()
390}
391
392/// Write options for reftable creation.
393#[derive(Debug, Clone)]
394pub struct WriteOptions {
395    /// Block size in bytes. 0 means use the default.
396    pub block_size: u32,
397    /// Restart interval (number of records between restart points).
398    pub restart_interval: usize,
399    /// Whether to write log blocks.
400    pub write_log: bool,
401    /// Skip writing the object index (config `reftable.indexObjects=false`).
402    pub skip_index_objects: bool,
403    /// Write blocks without padding to the block size.
404    pub unpadded: bool,
405    /// Object-id width in bytes: 20 for SHA-1 (reftable version 1), 32 for
406    /// SHA-256 (reftable version 2). Defaults to SHA-1.
407    pub hash_size: usize,
408    /// Geometric factor for stack auto-compaction (`reftable.geometricFactor`).
409    /// Defaults to 2 when unset.
410    pub auto_compaction_factor: u8,
411    /// Whether to run automatic stack compaction after writes (Git test knob via
412    /// [`Environment::reftable_autocompaction_enabled`](crate::environment::Environment::reftable_autocompaction_enabled)).
413    pub autocompaction_enabled: bool,
414}
415
416impl Default for WriteOptions {
417    fn default() -> Self {
418        Self {
419            block_size: DEFAULT_BLOCK_SIZE,
420            restart_interval: RESTART_INTERVAL,
421            write_log: true,
422            skip_index_objects: false,
423            unpadded: false,
424            hash_size: HASH_SIZE,
425            auto_compaction_factor: 2,
426            autocompaction_enabled: true,
427        }
428    }
429}
430
431/// A ref update that should be written to a reftable transaction.
432///
433/// The `refname` must already be the backend storage refname (for example a
434/// namespaced or per-worktree ref after storage routing). All updates passed to
435/// one transaction are written with the same update index, matching Git's
436/// reftable backend for `update-ref --stdin` batches.
437#[derive(Debug, Clone)]
438pub struct ReftableTransactionUpdate {
439    /// Full storage refname to update.
440    pub refname: String,
441    /// New ref value when this item updates a ref; `None` writes only a reflog line.
442    pub value: Option<RefValue>,
443    /// Optional reflog entry to record in the same table and update index.
444    pub log: Option<LogRecord>,
445    /// When set, the ref's current oid must match before the update is applied (checked
446    /// while the stack lock is held).
447    pub expected_old: Option<ObjectId>,
448}
449
450// ---------------------------------------------------------------------------
451// Writer
452// ---------------------------------------------------------------------------
453
454/// Writes a single reftable file.
455///
456/// Usage:
457/// ```ignore
458/// let mut w = ReftableWriter::new(opts, min_idx, max_idx);
459/// w.add_ref(&RefRecord { .. })?;
460/// w.add_log(&LogRecord { .. })?;
461/// let bytes = w.finish()?;
462/// ```
463pub struct ReftableWriter {
464    opts: WriteOptions,
465    min_update_index: u64,
466    max_update_index: u64,
467
468    // Accumulated ref records (must be added in sorted order).
469    refs: Vec<RefRecord>,
470    // Accumulated log records.
471    logs: Vec<LogRecord>,
472}
473
474impl ReftableWriter {
475    /// Create a new writer.
476    pub fn new(opts: WriteOptions, min_update_index: u64, max_update_index: u64) -> Self {
477        Self {
478            opts,
479            min_update_index,
480            max_update_index,
481            refs: Vec::new(),
482            logs: Vec::new(),
483        }
484    }
485
486    /// Add a ref record. Records **must** be added in sorted name order.
487    pub fn add_ref(&mut self, rec: RefRecord) -> Result<()> {
488        if let Some(last) = self.refs.last() {
489            if rec.name <= last.name {
490                return Err(ReftableError::UnsortedRefs {
491                    previous: last.name.clone(),
492                    next: rec.name,
493                }
494                .into());
495            }
496        }
497        self.refs.push(rec);
498        Ok(())
499    }
500
501    /// Add a log record.
502    pub fn add_log(&mut self, rec: LogRecord) -> Result<()> {
503        self.logs.push(rec);
504        Ok(())
505    }
506
507    /// Finish writing and return the complete reftable file bytes.
508    ///
509    /// This implements the reftable on-disk format so that the
510    /// on-disk layout (block boundaries, restart points, padding,
511    /// index/object sections, footer offsets) is byte-identical to git.
512    pub fn finish(self) -> Result<Vec<u8>> {
513        let refs = self.refs;
514        let logs = self.logs;
515        let opts = self.opts;
516        let mut w = WriterState::new(opts, self.min_update_index, self.max_update_index);
517
518        // Refs are added in sorted order; index objects as we go.
519        for rec in &refs {
520            w.add_ref(rec)?;
521        }
522
523        // Logs: sort by (refname asc, update_index desc) — matches
524        // reftable_log_record_compare_key.
525        let mut logs = logs;
526        logs.sort_by(|a, b| {
527            a.refname
528                .cmp(&b.refname)
529                .then_with(|| b.update_index.cmp(&a.update_index))
530        });
531        if w.opts.write_log {
532            for log in &logs {
533                w.add_log(log)?;
534            }
535        }
536
537        w.close()
538    }
539}
540
541// ---------------------------------------------------------------------------
542// Faithful low-level writer (ports git/reftable/{block,writer,record}.c)
543// ---------------------------------------------------------------------------
544
545/// Default reftable block size (per the reftable format specification).
546const REFTABLE_DEFAULT_BLOCK_SIZE: u32 = 4096;
547/// Maximum number of restart points per block (`MAX_RESTARTS`).
548const MAX_RESTARTS: usize = (1 << 16) - 1;
549
550/// A record to encode: produces a key and a value body.
551enum EncRecord<'a> {
552    Ref(&'a RefRecord, u64),
553    Log(&'a LogRecord),
554    Obj { prefix: Vec<u8>, offsets: Vec<u64> },
555    Index { last_key: Vec<u8>, offset: u64 },
556}
557
558impl EncRecord<'_> {
559    fn block_type(&self) -> u8 {
560        match self {
561            EncRecord::Ref(..) => BLOCK_TYPE_REF,
562            EncRecord::Log(_) => BLOCK_TYPE_LOG,
563            EncRecord::Obj { .. } => BLOCK_TYPE_OBJ,
564            EncRecord::Index { .. } => BLOCK_TYPE_INDEX,
565        }
566    }
567
568    /// The record key (used for prefix compression and restart points).
569    fn key(&self) -> Vec<u8> {
570        match self {
571            EncRecord::Ref(r, _) => r.name.as_bytes().to_vec(),
572            EncRecord::Log(l) => {
573                let mut k = Vec::with_capacity(l.refname.len() + 9);
574                k.extend_from_slice(l.refname.as_bytes());
575                k.push(0);
576                let ts = u64::MAX - l.update_index;
577                k.extend_from_slice(&ts.to_be_bytes());
578                k
579            }
580            EncRecord::Obj { prefix, .. } => prefix.clone(),
581            EncRecord::Index { last_key, .. } => last_key.clone(),
582        }
583    }
584
585    /// The `extra` value-type bits stored in the key varint.
586    fn val_type(&self) -> u8 {
587        match self {
588            EncRecord::Ref(r, _) => match r.value {
589                RefValue::Deletion => VALUE_DELETION,
590                RefValue::Val1(_) => VALUE_ONE_OID,
591                RefValue::Val2(..) => VALUE_TWO_OID,
592                RefValue::Symref(_) => VALUE_SYMREF,
593            },
594            EncRecord::Log(l) => {
595                if log_record_is_deletion(l) {
596                    VALUE_DELETION
597                } else {
598                    1
599                }
600            }
601            EncRecord::Obj { offsets, .. } => {
602                if !offsets.is_empty() && offsets.len() < 8 {
603                    offsets.len() as u8
604                } else {
605                    0
606                }
607            }
608            EncRecord::Index { .. } => 0,
609        }
610    }
611
612    /// Encode the value body (everything after the key).
613    fn encode_value(&self, opts: &WriteOptions, out: &mut Vec<u8>) {
614        match self {
615            EncRecord::Ref(r, update_index_delta) => {
616                put_varint(*update_index_delta, out);
617                match &r.value {
618                    RefValue::Deletion => {}
619                    RefValue::Val1(oid) => out.extend_from_slice(oid.as_bytes()),
620                    RefValue::Val2(oid, peeled) => {
621                        out.extend_from_slice(oid.as_bytes());
622                        out.extend_from_slice(peeled.as_bytes());
623                    }
624                    RefValue::Symref(target) => {
625                        put_varint(target.len() as u64, out);
626                        out.extend_from_slice(target.as_bytes());
627                    }
628                }
629            }
630            EncRecord::Log(l) => {
631                if log_record_is_deletion(l) {
632                    return;
633                }
634                out.extend_from_slice(l.old_id.as_bytes());
635                out.extend_from_slice(l.new_id.as_bytes());
636                put_varint(l.name.len() as u64, out);
637                out.extend_from_slice(l.name.as_bytes());
638                put_varint(l.email.len() as u64, out);
639                out.extend_from_slice(l.email.as_bytes());
640                put_varint(l.time_seconds, out);
641                out.extend_from_slice(&l.tz_offset.to_be_bytes());
642                let msg = clean_log_message(&l.message, opts);
643                put_varint(msg.len() as u64, out);
644                out.extend_from_slice(&msg);
645            }
646            EncRecord::Obj { offsets, .. } => {
647                if offsets.is_empty() || offsets.len() >= 8 {
648                    put_varint(offsets.len() as u64, out);
649                }
650                if offsets.is_empty() {
651                    return;
652                }
653                put_varint(offsets[0], out);
654                let mut last = offsets[0];
655                for &o in &offsets[1..] {
656                    put_varint(o - last, out);
657                    last = o;
658                }
659            }
660            EncRecord::Index { offset, .. } => {
661                put_varint(*offset, out);
662            }
663        }
664    }
665}
666
667/// Clean a reflog message the way `reftable_writer_add_log` does (unless the
668/// writer is in `exact_log_message` mode, which grit never uses): strip
669/// trailing newlines and append exactly one.
670///
671/// Git applies this cleaning whenever the message field is non-NULL, including
672/// the empty string: `""` becomes `"\n"` (a single trailing newline), not an
673/// empty value. grit's `LogRecord` always carries a (possibly empty) `String`,
674/// so the cleaning always runs — matching git's `msglen == 1` for reflog entries
675/// written without an explicit message (e.g. `update-ref` with no `-m`,
676/// t0613 'restart interval at every single record').
677fn clean_log_message(msg: &str, opts: &WriteOptions) -> Vec<u8> {
678    // Git's reftable backend truncates the reflog message to `block_size / 2`
679    // bytes before storing it (reftable-backend.c: `xstrndup(u->msg,
680    // block_size / 2)`) so that an oversized message still fits inside a log
681    // block instead of failing the whole transaction with "entry too large"
682    // (t0610 'basic: can write large commit message'). Mirror that bound,
683    // clamping to a UTF-8 char boundary so the resulting string stays valid.
684    let limit = (opts.block_size as usize / 2).max(1);
685    let msg = if msg.len() > limit {
686        let mut end = limit;
687        while end > 0 && !msg.is_char_boundary(end) {
688            end -= 1;
689        }
690        &msg[..end]
691    } else {
692        msg
693    };
694    let trimmed = msg.trim_end_matches('\n');
695    let mut out = trimmed.as_bytes().to_vec();
696    out.push(b'\n');
697    out
698}
699
700/// Encode a key (prefix/suffix compression) into `out`, returning whether this
701/// was a restart point. Mirrors `reftable_encode_key`.
702fn encode_key(prev: &[u8], key: &[u8], extra: u8, out: &mut Vec<u8>) -> bool {
703    let prefix_len = common_prefix_len(prev, key);
704    let suffix_len = key.len() - prefix_len;
705    put_varint(prefix_len as u64, out);
706    put_varint(((suffix_len as u64) << 3) | (extra as u64), out);
707    out.extend_from_slice(&key[prefix_len..]);
708    prefix_len == 0
709}
710
711/// In-progress block being filled by the writer.
712struct BlockWriter {
713    typ: u8,
714    /// Bytes from `header_off` onwards (block type byte + 3 reserved length
715    /// bytes are at the start; record payload follows).
716    buf: Vec<u8>,
717    header_off: usize,
718    block_size: usize,
719    restart_interval: usize,
720    restarts: Vec<u32>,
721    last_key: Vec<u8>,
722    entries: usize,
723}
724
725impl BlockWriter {
726    fn new(typ: u8, block_size: usize, header_off: usize, restart_interval: usize) -> Self {
727        // buf is laid out starting at header_off: [type][len:3][records...]
728        let mut buf = vec![0u8; header_off + 4];
729        buf[header_off] = typ;
730        Self {
731            typ,
732            buf,
733            header_off,
734            block_size,
735            restart_interval,
736            restarts: Vec::new(),
737            last_key: Vec::new(),
738            entries: 0,
739        }
740    }
741
742    /// `w->next` equivalent: number of bytes written so far (within the block,
743    /// counting from offset 0 which includes header_off).
744    fn next(&self) -> usize {
745        self.buf.len()
746    }
747
748    /// Try to add a record. Returns Ok(true) if added, Ok(false) if it does not
749    /// fit (entry-too-big), or Err on other failure.
750    fn add(&mut self, rec: &EncRecord, opts: &WriteOptions) -> Result<bool> {
751        let key = rec.key();
752        if key.is_empty() {
753            return Err(ReftableError::EmptyRecordKey.into());
754        }
755        let restart = self.entries.is_multiple_of(self.restart_interval);
756        let prev: &[u8] = if restart { &[] } else { &self.last_key };
757
758        let mut encoded = Vec::new();
759        let is_restart = encode_key(prev, &key, rec.val_type(), &mut encoded);
760        rec.encode_value(opts, &mut encoded);
761        let n = encoded.len();
762
763        // register_restart overflow check: 2 + 3*rlen + n > block_size - next
764        let mut rlen = self.restarts.len();
765        let mut is_restart = is_restart;
766        if rlen >= MAX_RESTARTS {
767            is_restart = false;
768        }
769        if is_restart {
770            rlen += 1;
771        }
772        if self.block_size > 0 && 2 + 3 * rlen + n > self.block_size - self.next() {
773            return Ok(false);
774        }
775
776        if is_restart {
777            self.restarts.push(self.next() as u32);
778        }
779        self.buf.extend_from_slice(&encoded);
780        self.last_key = key;
781        self.entries += 1;
782        Ok(true)
783    }
784
785    /// Finalize the block in memory: append restart table + count, write the
786    /// 3-byte block length, and (for log blocks) compress. Returns the raw byte
787    /// length written (`raw_bytes`).
788    fn finish(&mut self) -> Result<usize> {
789        for &r in &self.restarts {
790            self.buf.push(((r >> 16) & 0xff) as u8);
791            self.buf.push(((r >> 8) & 0xff) as u8);
792            self.buf.push((r & 0xff) as u8);
793        }
794        let rc = self.restarts.len() as u16;
795        self.buf.push((rc >> 8) as u8);
796        self.buf.push((rc & 0xff) as u8);
797
798        // block length (uncompressed) goes into the 3 bytes after the type.
799        let block_len = self.buf.len();
800        self.buf[self.header_off + 1] = ((block_len >> 16) & 0xff) as u8;
801        self.buf[self.header_off + 2] = ((block_len >> 8) & 0xff) as u8;
802        self.buf[self.header_off + 3] = (block_len & 0xff) as u8;
803
804        if self.typ == BLOCK_TYPE_LOG {
805            use flate2::write::ZlibEncoder;
806            use flate2::Compression;
807            let skip = 4 + self.header_off;
808            let mut enc = ZlibEncoder::new(Vec::new(), Compression::new(9));
809            enc.write_all(&self.buf[skip..])
810                .map_err(|e| Error::Zlib(e.to_string()))?;
811            let compressed = enc.finish().map_err(|e| Error::Zlib(e.to_string()))?;
812            self.buf.truncate(skip);
813            self.buf.extend_from_slice(&compressed);
814        }
815        Ok(self.buf.len())
816    }
817}
818
819/// Per-section statistics accumulated while writing a reftable.
820#[derive(Default, Clone)]
821struct SectionStats {
822    blocks: usize,
823    index_blocks: usize,
824    offset: u64,
825    index_offset: u64,
826}
827
828/// An object-index entry collected while writing refs.
829struct ObjEntry {
830    hash: Vec<u8>,
831    offsets: Vec<u64>,
832}
833
834/// The full writer state needed to emit a reftable file.
835struct WriterState {
836    opts: WriteOptions,
837    min_update_index: u64,
838    max_update_index: u64,
839
840    out: Vec<u8>,
841    next: u64,
842    pending_padding: usize,
843
844    block: Option<BlockWriter>,
845    block_type: u8,
846
847    /// Index records for the current section (last_key, offset).
848    index: Vec<(Vec<u8>, u64)>,
849
850    /// Object-index tree (kept sorted by hash).
851    obj_entries: Vec<ObjEntry>,
852    object_id_len: usize,
853
854    ref_stats: SectionStats,
855    obj_stats: SectionStats,
856    log_stats: SectionStats,
857    idx_blocks_total: usize,
858}
859
860impl WriterState {
861    fn new(mut opts: WriteOptions, min: u64, max: u64) -> Self {
862        if opts.restart_interval == 0 {
863            opts.restart_interval = RESTART_INTERVAL;
864        }
865        if opts.block_size == 0 {
866            opts.block_size = REFTABLE_DEFAULT_BLOCK_SIZE;
867        }
868        Self {
869            opts,
870            min_update_index: min,
871            max_update_index: max,
872            out: Vec::new(),
873            next: 0,
874            pending_padding: 0,
875            block: None,
876            block_type: 0,
877            index: Vec::new(),
878            obj_entries: Vec::new(),
879            object_id_len: 0,
880            ref_stats: SectionStats::default(),
881            obj_stats: SectionStats::default(),
882            log_stats: SectionStats::default(),
883            idx_blocks_total: 0,
884        }
885    }
886
887    fn header_size(&self) -> usize {
888        // Version 1 (SHA-1) is 24 bytes; version 2 (SHA-256) adds a 4-byte
889        // hash-id field for 28.
890        if self.opts.hash_size == 32 {
891            28
892        } else {
893            24
894        }
895    }
896
897    fn write_header(&self, dest: &mut [u8]) {
898        dest[0..4].copy_from_slice(REFTABLE_MAGIC);
899        dest[4] = if self.opts.hash_size == 32 { 2 } else { 1 };
900        dest[5] = ((self.opts.block_size >> 16) & 0xff) as u8;
901        dest[6] = ((self.opts.block_size >> 8) & 0xff) as u8;
902        dest[7] = (self.opts.block_size & 0xff) as u8;
903        dest[8..16].copy_from_slice(&self.min_update_index.to_be_bytes());
904        dest[16..24].copy_from_slice(&self.max_update_index.to_be_bytes());
905        // Version 2 records the hash function id (`sha1` / `s256`, Git
906        // `GIT_SHA{1,256}_FORMAT_ID`).
907        if self.opts.hash_size == 32 {
908            dest[24..28].copy_from_slice(b"s256");
909        }
910    }
911
912    fn stats_mut(&mut self, typ: u8) -> &mut SectionStats {
913        match typ {
914            BLOCK_TYPE_REF => &mut self.ref_stats,
915            BLOCK_TYPE_OBJ => &mut self.obj_stats,
916            BLOCK_TYPE_LOG => &mut self.log_stats,
917            // index blocks roll into the section being indexed; not used here.
918            _ => &mut self.ref_stats,
919        }
920    }
921
922    /// Write `data` then queue `padding` zero bytes for the next write
923    /// (`padded_write`).
924    fn padded_write(&mut self, data: &[u8], padding: usize) {
925        if self.pending_padding > 0 {
926            self.out
927                .extend(std::iter::repeat_n(0u8, self.pending_padding));
928            self.pending_padding = 0;
929        }
930        self.pending_padding = padding;
931        self.out.extend_from_slice(data);
932    }
933
934    fn reinit_block(&mut self, typ: u8) {
935        let header_off = if self.next == 0 {
936            self.header_size()
937        } else {
938            0
939        };
940        self.block = Some(BlockWriter::new(
941            typ,
942            self.opts.block_size as usize,
943            header_off,
944            self.opts.restart_interval,
945        ));
946        self.block_type = typ;
947    }
948
949    fn add_record(&mut self, rec: &EncRecord) -> Result<()> {
950        let typ = rec.block_type();
951        if self.block.is_none() {
952            self.reinit_block(typ);
953        }
954        if self.add_to_block(rec)? {
955            return Ok(());
956        }
957        // Block full: flush and retry in a fresh block.
958        self.flush_block()?;
959        self.reinit_block(typ);
960        if !self.add_to_block(rec)? {
961            return Err(ReftableError::EntryTooLarge.into());
962        }
963        Ok(())
964    }
965
966    /// Add `rec` to the open block. Returns `Ok(false)` when it does not fit
967    /// (or no block is open).
968    fn add_to_block(&mut self, rec: &EncRecord) -> Result<bool> {
969        match self.block.as_mut() {
970            Some(bw) => bw.add(rec, &self.opts),
971            None => Ok(false),
972        }
973    }
974
975    fn add_ref(&mut self, r: &RefRecord) -> Result<()> {
976        let delta = r.update_index.saturating_sub(self.min_update_index);
977        self.add_record(&EncRecord::Ref(r, delta))?;
978
979        if !self.opts.skip_index_objects {
980            match &r.value {
981                RefValue::Val1(oid) => self.index_hash(oid.as_bytes()),
982                RefValue::Val2(oid, peeled) => {
983                    self.index_hash(oid.as_bytes());
984                    self.index_hash(peeled.as_bytes());
985                }
986                _ => {}
987            }
988        }
989        Ok(())
990    }
991
992    fn add_log(&mut self, l: &LogRecord) -> Result<()> {
993        // Finishing the ref section happens before the first log record.
994        if matches!(&self.block, Some(b) if b.typ == BLOCK_TYPE_REF) {
995            self.finish_public_section()?;
996        }
997        // Drop pending padding before a log block (matches add_log_verbatim).
998        self.next -= self.pending_padding as u64;
999        self.pending_padding = 0;
1000        self.add_record(&EncRecord::Log(l))
1001    }
1002
1003    fn index_hash(&mut self, hash: &[u8]) {
1004        let off = self.next;
1005        match self
1006            .obj_entries
1007            .binary_search_by(|e| e.hash.as_slice().cmp(hash))
1008        {
1009            Ok(idx) => {
1010                let e = &mut self.obj_entries[idx];
1011                if e.offsets.last() != Some(&off) {
1012                    e.offsets.push(off);
1013                }
1014            }
1015            Err(idx) => {
1016                self.obj_entries.insert(
1017                    idx,
1018                    ObjEntry {
1019                        hash: hash.to_vec(),
1020                        offsets: vec![off],
1021                    },
1022                );
1023            }
1024        }
1025    }
1026
1027    /// `writer_flush_nonempty_block`.
1028    fn flush_block(&mut self) -> Result<()> {
1029        let Some(mut bw) = self.block.take() else {
1030            return Ok(());
1031        };
1032        if bw.entries == 0 {
1033            self.block = Some(bw);
1034            return Ok(());
1035        }
1036        let typ = bw.typ;
1037        let raw_bytes = bw.finish()?;
1038
1039        let mut padding = 0;
1040        if !self.opts.unpadded && typ != BLOCK_TYPE_LOG {
1041            padding = (self.opts.block_size as usize).saturating_sub(raw_bytes);
1042        }
1043
1044        let block_typ_off = if self.stats_mut(typ).blocks == 0 {
1045            self.next
1046        } else {
1047            0
1048        };
1049        {
1050            let next = self.next;
1051            let st = self.stats_mut(typ);
1052            if block_typ_off > 0 {
1053                st.offset = next;
1054            }
1055            st.blocks += 1;
1056        }
1057
1058        if self.next == 0 {
1059            // Write the reftable header into the front of the first block.
1060            let hs = self.header_size();
1061            self.write_header_into_block(&mut bw, hs);
1062        }
1063
1064        let data = bw.buf.clone();
1065        self.padded_write(&data, padding);
1066
1067        // Record an index entry for this block.
1068        self.index.push((bw.last_key.clone(), self.next));
1069
1070        self.next += (padding + raw_bytes) as u64;
1071        self.block = None;
1072        Ok(())
1073    }
1074
1075    fn write_header_into_block(&self, bw: &mut BlockWriter, hs: usize) {
1076        let mut hdr = vec![0u8; hs];
1077        self.write_header(&mut hdr);
1078        bw.buf[..hs].copy_from_slice(&hdr);
1079    }
1080
1081    fn flush_block_if_nonempty(&mut self) -> Result<()> {
1082        if matches!(&self.block, Some(b) if b.entries == 0) {
1083            return Ok(());
1084        }
1085        self.flush_block()
1086    }
1087
1088    /// `writer_finish_section`: flush the current block then emit any index.
1089    fn finish_section(&mut self) -> Result<()> {
1090        let typ = self.block_type;
1091        let threshold = if self.opts.unpadded { 1 } else { 3 };
1092        let before_blocks = self.idx_blocks_total;
1093
1094        self.flush_block_if_nonempty()?;
1095
1096        let mut max_level = 0;
1097        let mut index_start = 0u64;
1098
1099        while self.index.len() > threshold {
1100            max_level += 1;
1101            index_start = self.next;
1102            self.reinit_block(BLOCK_TYPE_INDEX);
1103
1104            let idx = std::mem::take(&mut self.index);
1105            for (last_key, offset) in &idx {
1106                self.add_record(&EncRecord::Index {
1107                    last_key: last_key.clone(),
1108                    offset: *offset,
1109                })?;
1110            }
1111            // Count index blocks produced during this level.
1112            let blocks_before = self.count_index_blocks_marker();
1113            self.flush_index_block()?;
1114            let _ = blocks_before;
1115        }
1116
1117        self.index.clear();
1118
1119        let index_blocks = self.idx_blocks_total - before_blocks;
1120        {
1121            let st = self.stats_mut(typ);
1122            st.index_blocks = index_blocks;
1123            st.index_offset = index_start;
1124        }
1125        let _ = max_level;
1126        Ok(())
1127    }
1128
1129    fn count_index_blocks_marker(&self) -> usize {
1130        self.idx_blocks_total
1131    }
1132
1133    /// Flush an index block: like `flush_block` but the produced block counts
1134    /// toward `idx_blocks_total` and re-populates `self.index` for the next
1135    /// (higher) level.
1136    fn flush_index_block(&mut self) -> Result<()> {
1137        let Some(mut bw) = self.block.take() else {
1138            return Ok(());
1139        };
1140        if bw.entries == 0 {
1141            self.block = Some(bw);
1142            return Ok(());
1143        }
1144        let raw_bytes = bw.finish()?;
1145        let mut padding = 0;
1146        if !self.opts.unpadded {
1147            padding = (self.opts.block_size as usize).saturating_sub(raw_bytes);
1148        }
1149        if self.next == 0 {
1150            let hs = self.header_size();
1151            self.write_header_into_block(&mut bw, hs);
1152        }
1153        let data = bw.buf.clone();
1154        self.padded_write(&data, padding);
1155        self.index.push((bw.last_key.clone(), self.next));
1156        self.next += (padding + raw_bytes) as u64;
1157        self.idx_blocks_total += 1;
1158        self.block = None;
1159        Ok(())
1160    }
1161
1162    /// `writer_dump_object_index`.
1163    fn dump_object_index(&mut self) -> Result<()> {
1164        // object_id_len = max common prefix among sorted hashes + 1, min 2.
1165        let mut max_common = 1usize;
1166        for w in self.obj_entries.windows(2) {
1167            let n = common_prefix_len(&w[0].hash, &w[1].hash);
1168            if n > max_common {
1169                max_common = n;
1170            }
1171        }
1172        self.object_id_len = max_common + 1;
1173        let id_len = self.object_id_len;
1174
1175        self.reinit_block(BLOCK_TYPE_OBJ);
1176        let entries = std::mem::take(&mut self.obj_entries);
1177        for e in &entries {
1178            let prefix = e.hash[..id_len.min(e.hash.len())].to_vec();
1179            self.add_obj_record(prefix, &e.offsets)?;
1180        }
1181        self.obj_entries = entries;
1182        self.finish_section()
1183    }
1184
1185    fn add_obj_record(&mut self, prefix: Vec<u8>, offsets: &[u64]) -> Result<()> {
1186        // Try with full offsets; on overflow in a fresh block, drop offsets.
1187        let typ = BLOCK_TYPE_OBJ;
1188        if self.block.is_none() {
1189            self.reinit_block(typ);
1190        }
1191        let rec = EncRecord::Obj {
1192            prefix: prefix.clone(),
1193            offsets: offsets.to_vec(),
1194        };
1195        if self.add_to_block(&rec)? {
1196            return Ok(());
1197        }
1198        self.flush_block()?;
1199        self.reinit_block(typ);
1200        if self.add_to_block(&rec)? {
1201            return Ok(());
1202        }
1203        // Drop offsets entirely.
1204        let rec = EncRecord::Obj {
1205            prefix,
1206            offsets: Vec::new(),
1207        };
1208        self.add_to_block(&rec)?;
1209        Ok(())
1210    }
1211
1212    /// `writer_finish_public_section`.
1213    fn finish_public_section(&mut self) -> Result<()> {
1214        let Some(bw) = &self.block else {
1215            return Ok(());
1216        };
1217        let typ = bw.typ;
1218        self.finish_section()?;
1219        if typ == BLOCK_TYPE_REF && !self.opts.skip_index_objects && self.ref_stats.index_blocks > 0
1220        {
1221            self.dump_object_index()?;
1222        }
1223        self.obj_entries.clear();
1224        self.block = None;
1225        self.block_type = 0;
1226        Ok(())
1227    }
1228
1229    /// `reftable_writer_close`.
1230    fn close(mut self) -> Result<Vec<u8>> {
1231        self.finish_public_section()?;
1232        let empty_table = self.next == 0;
1233        self.pending_padding = 0;
1234
1235        if empty_table {
1236            let hs = self.header_size();
1237            let mut header = vec![0u8; hs];
1238            self.write_header(&mut header);
1239            self.padded_write(&header, 0);
1240        }
1241
1242        let mut footer = vec![0u8; self.header_size()];
1243        self.write_header(&mut footer);
1244        footer.extend_from_slice(&self.ref_stats.index_offset.to_be_bytes());
1245        let obj_field = (self.obj_stats.offset << 5) | (self.object_id_len as u64);
1246        footer.extend_from_slice(&obj_field.to_be_bytes());
1247        footer.extend_from_slice(&self.obj_stats.index_offset.to_be_bytes());
1248        footer.extend_from_slice(&self.log_stats.offset.to_be_bytes());
1249        footer.extend_from_slice(&self.log_stats.index_offset.to_be_bytes());
1250        let crc = crc32(&footer);
1251        footer.extend_from_slice(&crc.to_be_bytes());
1252
1253        // Footer write drops pending padding (flush() before padded_write).
1254        self.pending_padding = 0;
1255        self.out.extend_from_slice(&footer);
1256
1257        Ok(self.out)
1258    }
1259}
1260
1261// ---------------------------------------------------------------------------
1262// Reader
1263// ---------------------------------------------------------------------------
1264
1265/// Backing storage for a [`ReftableReader`]: owned bytes or a file mapping.
1266enum TableBytes {
1267    Owned(Arc<[u8]>),
1268    Mapped(Arc<Mmap>),
1269}
1270
1271impl TableBytes {
1272    fn as_slice(&self) -> &[u8] {
1273        match self {
1274            TableBytes::Owned(data) => data.as_ref(),
1275            TableBytes::Mapped(map) => map.as_ref(),
1276        }
1277    }
1278
1279    #[cfg(test)]
1280    fn is_mapped(&self) -> bool {
1281        matches!(self, TableBytes::Mapped(_))
1282    }
1283}
1284
1285/// Reads a single reftable file from a byte buffer.
1286pub struct ReftableReader {
1287    data: TableBytes,
1288    version: u8,
1289    block_size: u32,
1290    min_update_index: u64,
1291    max_update_index: u64,
1292    ref_index_position: u64,
1293    log_position: u64,
1294}
1295
1296/// Footer fields the reader uses (object and log index positions are not consulted).
1297#[derive(Debug)]
1298struct Footer {
1299    block_size: u32,
1300    min_update_index: u64,
1301    max_update_index: u64,
1302    ref_index_position: u64,
1303    log_position: u64,
1304}
1305
1306impl ReftableReader {
1307    fn bytes(&self) -> &[u8] {
1308        self.data.as_slice()
1309    }
1310
1311    /// Open a reftable from bytes.
1312    pub fn new(data: Vec<u8>) -> Result<Self> {
1313        Self::from_arc(Arc::from(data))
1314    }
1315
1316    /// Open a reftable from shared bytes (avoids cloning table payloads).
1317    pub fn from_arc(data: Arc<[u8]>) -> Result<Self> {
1318        Self::open(TableBytes::Owned(data))
1319    }
1320
1321    /// Open a reftable from a shared file mapping (no anonymous copy of the table).
1322    pub fn from_mapped(map: Arc<Mmap>) -> Result<Self> {
1323        Self::open(TableBytes::Mapped(map))
1324    }
1325
1326    /// Whether this reader views file-backed storage (stack cache tests).
1327    #[cfg(test)]
1328    pub(crate) fn table_backing_is_mapped(&self) -> bool {
1329        self.data.is_mapped()
1330    }
1331
1332    fn open(data: TableBytes) -> Result<Self> {
1333        let bytes = data.as_slice();
1334        if bytes.len() < HEADER_SIZE + FOOTER_V1_SIZE {
1335            // Could be an empty table (header + footer only = 24 + 68 = 92)
1336            if bytes.len() < HEADER_SIZE {
1337                return Err(ReftableError::TooSmall { len: bytes.len() }.into());
1338            }
1339        }
1340
1341        // Parse header
1342        if &bytes[0..4] != REFTABLE_MAGIC {
1343            return Err(ReftableError::BadMagic.into());
1344        }
1345        let version = bytes[4];
1346        if version != 1 && version != 2 {
1347            return Err(ReftableError::UnsupportedVersion { version }.into());
1348        }
1349        // Parse footer
1350        let footer_size = if version == 2 { 72 } else { FOOTER_V1_SIZE };
1351        if bytes.len() < footer_size {
1352            return Err(ReftableError::TooSmall { len: bytes.len() }.into());
1353        }
1354        let footer_start = bytes.len() - footer_size;
1355        let footer = parse_footer(&bytes[footer_start..], version)?;
1356
1357        Ok(Self {
1358            data,
1359            version,
1360            block_size: footer.block_size,
1361            min_update_index: footer.min_update_index,
1362            max_update_index: footer.max_update_index,
1363            ref_index_position: footer.ref_index_position,
1364            log_position: footer.log_position,
1365        })
1366    }
1367
1368    /// Object-id width implied by the reftable version (32 for version 2 / SHA-256,
1369    /// 20 otherwise).
1370    fn hash_size(&self) -> usize {
1371        if self.version == 2 {
1372            32
1373        } else {
1374            20
1375        }
1376    }
1377
1378    /// File-header size implied by the reftable version (28 for version 2, 24 otherwise).
1379    fn header_len(&self) -> usize {
1380        if self.version == 2 {
1381            28
1382        } else {
1383            HEADER_SIZE
1384        }
1385    }
1386
1387    /// Read all ref records from the table.
1388    pub fn read_refs(&self) -> Result<Vec<RefRecord>> {
1389        let mut refs = Vec::new();
1390        self.for_each_ref(|rec| {
1391            refs.push(rec.clone());
1392            ControlFlow::<()>::Continue(())
1393        })?;
1394        Ok(refs)
1395    }
1396
1397    /// Visit ref records in sorted name order, optionally bounded by `lower` (inclusive)
1398    /// and `upper_exclusive` (exclusive). When `upper_exclusive` is `None`, iteration
1399    /// continues through the last ref block.
1400    pub fn for_each_ref(&self, f: impl FnMut(&RefRecord) -> ControlFlow<()>) -> Result<()> {
1401        self.for_each_ref_in_range("", None, f)
1402    }
1403
1404    /// Visit ref records with name `>= lower` and `< upper_exclusive` when set.
1405    pub fn for_each_ref_in_range(
1406        &self,
1407        lower: &str,
1408        upper_exclusive: Option<&str>,
1409        mut f: impl FnMut(&RefRecord) -> ControlFlow<()>,
1410    ) -> Result<()> {
1411        let footer_size = if self.version == 2 {
1412            72
1413        } else {
1414            FOOTER_V1_SIZE
1415        };
1416        let file_end = self.bytes().len() - footer_size;
1417
1418        let ref_end = if self.ref_index_position > 0 {
1419            self.ref_index_position as usize
1420        } else if self.log_position > 0 {
1421            self.log_position as usize
1422        } else {
1423            file_end
1424        };
1425
1426        let header_len = self.header_len();
1427        let mut pos = if self.ref_index_position > 0 && !lower.is_empty() {
1428            self.locate_ref_block_via_index(lower.as_bytes())?
1429                .unwrap_or(header_len)
1430        } else {
1431            header_len
1432        };
1433
1434        while pos < ref_end {
1435            if pos >= self.bytes().len() {
1436                break;
1437            }
1438            let block_type = self.bytes()[pos];
1439            if block_type == 0 {
1440                if self.block_size > 0 {
1441                    let bs = self.block_size as usize;
1442                    pos = ((pos / bs) + 1) * bs;
1443                    continue;
1444                }
1445                break;
1446            }
1447            if block_type != BLOCK_TYPE_REF {
1448                break;
1449            }
1450
1451            let block_len = read_u24(self.bytes(), pos + 1);
1452            let block_data_start = pos + 4;
1453            let is_first = pos == header_len;
1454            let records_end = if is_first { block_len } else { pos + block_len };
1455
1456            if records_end > ref_end {
1457                break;
1458            }
1459
1460            let restart_table_start =
1461                restart_table_start_for_block(self.bytes(), records_end, block_data_start)?;
1462
1463            let mut rpos = block_data_start;
1464            let mut prev_name = Vec::<u8>::new();
1465
1466            while rpos < restart_table_start {
1467                let (rec, new_pos) = decode_ref_record(
1468                    self.bytes(),
1469                    rpos,
1470                    &prev_name,
1471                    self.min_update_index,
1472                    self.hash_size(),
1473                )?;
1474                prev_name = rec.name.as_bytes().to_vec();
1475                if rec.name.as_str() < lower {
1476                    rpos = new_pos;
1477                    continue;
1478                }
1479                if let Some(upper) = upper_exclusive {
1480                    if rec.name.as_str() >= upper {
1481                        return Ok(());
1482                    }
1483                }
1484                if f(&rec).is_break() {
1485                    return Ok(());
1486                }
1487                rpos = new_pos;
1488            }
1489
1490            if self.block_size > 0 {
1491                let bs = self.block_size as usize;
1492                pos = if is_first { bs } else { pos + bs };
1493            } else {
1494                pos = records_end;
1495            }
1496        }
1497
1498        Ok(())
1499    }
1500
1501    /// Look up a single ref by name.
1502    pub fn lookup_ref(&self, name: &str) -> Result<Option<RefRecord>> {
1503        let mut found = None;
1504        self.for_each_ref_in_range(name, None, |rec| {
1505            if rec.name == name {
1506                found = Some(rec.clone());
1507                return ControlFlow::Break(());
1508            }
1509            if rec.name.as_str() > name {
1510                return ControlFlow::Break(());
1511            }
1512            ControlFlow::Continue(())
1513        })?;
1514        Ok(found)
1515    }
1516
1517    fn locate_ref_block_via_index(&self, name: &[u8]) -> Result<Option<usize>> {
1518        let mut pos = self.ref_index_position as usize;
1519        if pos == 0 {
1520            return Ok(None);
1521        }
1522        for _ in 0..64 {
1523            if pos >= self.bytes().len() {
1524                return Ok(None);
1525            }
1526            match self.bytes()[pos] {
1527                BLOCK_TYPE_REF => return Ok(Some(pos)),
1528                BLOCK_TYPE_INDEX => {
1529                    let Some(offset) = self.index_block_seek_offset(pos, name)? else {
1530                        return Ok(None);
1531                    };
1532                    let next = offset as usize;
1533                    if next == pos {
1534                        return Ok(None);
1535                    }
1536                    pos = next;
1537                }
1538                _ => return Ok(None),
1539            }
1540        }
1541        Ok(None)
1542    }
1543
1544    /// Pick the index entry for `name`: the first record whose `last_key` is >=
1545    /// `name`, or the final entry when every indexed `last_key` is smaller.
1546    fn index_block_seek_offset(&self, block_pos: usize, name: &[u8]) -> Result<Option<u64>> {
1547        let (records_start, records_end) = self.block_record_range(block_pos)?;
1548        let mut rpos = records_start;
1549        let mut prev_key = Vec::<u8>::new();
1550        let mut last: Option<u64> = None;
1551        while rpos < records_end {
1552            let (key, offset, new_pos) = decode_index_record(self.bytes(), rpos, &prev_key)?;
1553            prev_key = key;
1554            if prev_key.as_slice() >= name {
1555                return Ok(Some(offset));
1556            }
1557            last = Some(offset);
1558            rpos = new_pos;
1559        }
1560        Ok(last)
1561    }
1562
1563    fn block_record_range(&self, block_pos: usize) -> Result<(usize, usize)> {
1564        if block_pos + 4 > self.bytes().len() {
1565            return Err(truncated(ReftableField::BlockHeader));
1566        }
1567        let block_len = read_u24(self.bytes(), block_pos + 1);
1568        let header_len = self.header_len();
1569        let is_first = block_pos == header_len;
1570        let records_end = if is_first {
1571            block_len
1572        } else {
1573            block_pos + block_len
1574        };
1575        if records_end > self.bytes().len() || records_end < 2 {
1576            return Err(corrupt(ReftableError::InvalidBlockLength));
1577        }
1578        let records_data_start = block_pos + 4;
1579        let restart_table_start =
1580            restart_table_start_for_block(self.bytes(), records_end, records_data_start)?;
1581        Ok((records_data_start, restart_table_start))
1582    }
1583
1584    /// Read all log records from the table.
1585    pub fn read_logs(&self) -> Result<Vec<LogRecord>> {
1586        let footer_size = if self.version == 2 {
1587            72
1588        } else {
1589            FOOTER_V1_SIZE
1590        };
1591        let file_end = self.bytes().len() - footer_size;
1592
1593        // Determine where the log section starts. Git records the log offset in
1594        // the footer, but when the log block is the *first* block in the file it
1595        // shares its physical block with the 24-byte reftable header and the
1596        // recorded offset is left at 0 (see `writer_flush_nonempty_block`'s
1597        // `block_typ_off = (blocks == 0) ? next : 0`). The reader detects this
1598        // by checking whether the first on-disk block (the byte right after the
1599        // header) is a log block — mirroring `is_present` in git's table.c.
1600        let mut pos = if self.log_position > 0 {
1601            self.log_position as usize
1602        } else if self.bytes().len() > self.header_len()
1603            && self.bytes()[self.header_len()] == BLOCK_TYPE_LOG
1604        {
1605            // Log block is the first block; it begins right after the header.
1606            self.header_len()
1607        } else {
1608            return Ok(Vec::new());
1609        };
1610        let mut logs = Vec::new();
1611
1612        while pos < file_end {
1613            if pos >= self.bytes().len() {
1614                break;
1615            }
1616            let block_type = self.bytes()[pos];
1617            if block_type != BLOCK_TYPE_LOG {
1618                break;
1619            }
1620            // When the log block shares its physical block with the reftable
1621            // header, the 3-byte block length counts from offset 0 and so
1622            // includes the header bytes; the compressed payload still starts
1623            // right after the type+length header at `pos + 4`.
1624            let is_first = pos == self.header_len() && self.log_position == 0;
1625            let block_len = read_u24(self.bytes(), pos + 1);
1626            let compressed_start = pos + 4;
1627
1628            // The inflated size is block_len minus the 4-byte type+length header
1629            // (and, for the first block, minus the embedded reftable header).
1630            let header_prefix = if is_first { self.header_len() } else { 0 };
1631            let inflated_size = block_len.saturating_sub(4 + header_prefix);
1632
1633            let remaining = &self.bytes()[compressed_start..file_end];
1634            let (inflated, consumed) = decompress_log_payload(remaining, inflated_size)?;
1635
1636            // Parse log records from inflated data
1637            // Read restart_count from end
1638            if inflated.len() < 2 {
1639                break;
1640            }
1641            let records_end = inflated.len();
1642            let restart_table_start = restart_table_start_for_block(&inflated, records_end, 0)?;
1643
1644            let mut rpos = 0usize;
1645            let mut prev_key = Vec::<u8>::new();
1646
1647            while rpos < restart_table_start {
1648                let (log, new_pos) =
1649                    decode_log_record(&inflated, rpos, &prev_key, self.hash_size())?;
1650                // Reconstruct key for prefix compression
1651                let mut key = Vec::new();
1652                key.extend_from_slice(log.refname.as_bytes());
1653                key.push(0);
1654                key.extend_from_slice(&(0xffffffffffffffffu64 - log.update_index).to_be_bytes());
1655                prev_key = key;
1656                logs.push(log);
1657                rpos = new_pos;
1658            }
1659
1660            pos = compressed_start + consumed;
1661        }
1662
1663        Ok(logs)
1664    }
1665
1666    /// Get the block size from the header.
1667    pub fn block_size(&self) -> u32 {
1668        self.block_size
1669    }
1670
1671    /// Get the min update index.
1672    pub fn min_update_index(&self) -> u64 {
1673        self.min_update_index
1674    }
1675
1676    /// Get the max update index.
1677    pub fn max_update_index(&self) -> u64 {
1678        self.max_update_index
1679    }
1680
1681    /// Byte offset of the top-level ref index section, or 0 if none.
1682    pub fn ref_index_offset(&self) -> u64 {
1683        self.ref_index_position
1684    }
1685
1686    /// Effective ref payload size for compaction heuristics (file size minus header overhead).
1687    pub fn payload_size(&self) -> u64 {
1688        let overhead = if self.version == 2 { 28 } else { 24 };
1689        self.bytes().len().saturating_sub(overhead) as u64
1690    }
1691}
1692
1693// ---------------------------------------------------------------------------
1694// Record decoding helpers
1695// ---------------------------------------------------------------------------
1696
1697fn decode_index_record(data: &[u8], pos: usize, prev_key: &[u8]) -> Result<(Vec<u8>, u64, usize)> {
1698    let (prefix_len, p) = get_varint(data, pos)?;
1699    let (suffix_and_type, mut p) = get_varint(data, p)?;
1700    let suffix_len = (suffix_and_type >> 3) as usize;
1701
1702    let mut key = Vec::with_capacity(prefix_len as usize + suffix_len);
1703    if prefix_len > 0 {
1704        if (prefix_len as usize) > prev_key.len() {
1705            return Err(corrupt(ReftableError::PrefixTooLong {
1706                field: ReftableField::IndexKey,
1707            }));
1708        }
1709        key.extend_from_slice(&prev_key[..prefix_len as usize]);
1710    }
1711    if p + suffix_len > data.len() {
1712        return Err(truncated(ReftableField::IndexKey));
1713    }
1714    key.extend_from_slice(&data[p..p + suffix_len]);
1715    p += suffix_len;
1716
1717    let (offset, p) = get_varint(data, p)?;
1718    Ok((key, offset, p))
1719}
1720
1721fn decode_ref_record(
1722    data: &[u8],
1723    pos: usize,
1724    prev_name: &[u8],
1725    min_update_index: u64,
1726    hash_size: usize,
1727) -> Result<(RefRecord, usize)> {
1728    let (prefix_len, p) = get_varint(data, pos)?;
1729    let (suffix_and_type, mut p) = get_varint(data, p)?;
1730    let suffix_len = (suffix_and_type >> 3) as usize;
1731    let value_type = (suffix_and_type & 0x7) as u8;
1732
1733    // Reconstruct name
1734    let mut name = Vec::with_capacity(prefix_len as usize + suffix_len);
1735    if prefix_len > 0 {
1736        if (prefix_len as usize) > prev_name.len() {
1737            return Err(corrupt(ReftableError::PrefixTooLong {
1738                field: ReftableField::RefName,
1739            }));
1740        }
1741        name.extend_from_slice(&prev_name[..prefix_len as usize]);
1742    }
1743    if p + suffix_len > data.len() {
1744        return Err(truncated(ReftableField::RefName));
1745    }
1746    name.extend_from_slice(&data[p..p + suffix_len]);
1747    p += suffix_len;
1748
1749    let name_str = String::from_utf8(name).map_err(|_| invalid_utf8(ReftableField::RefName))?;
1750
1751    let (update_index_delta, mut p) = get_varint(data, p)?;
1752    let update_index = min_update_index + update_index_delta;
1753
1754    let value = match value_type {
1755        VALUE_DELETION => RefValue::Deletion,
1756        VALUE_ONE_OID => {
1757            if p + hash_size > data.len() {
1758                return Err(truncated(ReftableField::ObjectId));
1759            }
1760            let oid = ObjectId::from_bytes(&data[p..p + hash_size])?;
1761            p += hash_size;
1762            RefValue::Val1(oid)
1763        }
1764        VALUE_TWO_OID => {
1765            if p + 2 * hash_size > data.len() {
1766                return Err(truncated(ReftableField::ObjectIdPair));
1767            }
1768            let oid = ObjectId::from_bytes(&data[p..p + hash_size])?;
1769            p += hash_size;
1770            let peeled = ObjectId::from_bytes(&data[p..p + hash_size])?;
1771            p += hash_size;
1772            RefValue::Val2(oid, peeled)
1773        }
1774        VALUE_SYMREF => {
1775            let (target_len, p2) = get_varint(data, p)?;
1776            p = p2;
1777            let target_len = target_len as usize;
1778            if p + target_len > data.len() {
1779                return Err(truncated(ReftableField::SymrefTarget));
1780            }
1781            let target = String::from_utf8(data[p..p + target_len].to_vec())
1782                .map_err(|_| invalid_utf8(ReftableField::SymrefTarget))?;
1783            p += target_len;
1784            RefValue::Symref(target)
1785        }
1786        _ => {
1787            return Err(corrupt(ReftableError::UnknownValueType { value_type }));
1788        }
1789    };
1790
1791    Ok((
1792        RefRecord {
1793            name: name_str,
1794            update_index,
1795            value,
1796        },
1797        p,
1798    ))
1799}
1800
1801fn decode_log_record(
1802    data: &[u8],
1803    pos: usize,
1804    prev_key: &[u8],
1805    hash_size: usize,
1806) -> Result<(LogRecord, usize)> {
1807    let (prefix_len, p) = get_varint(data, pos)?;
1808    let (suffix_and_type, mut p) = get_varint(data, p)?;
1809    let suffix_len = (suffix_and_type >> 3) as usize;
1810    let log_type = (suffix_and_type & 0x7) as u8;
1811
1812    // Reconstruct key
1813    let mut key = Vec::with_capacity(prefix_len as usize + suffix_len);
1814    if prefix_len > 0 {
1815        if (prefix_len as usize) > prev_key.len() {
1816            return Err(corrupt(ReftableError::PrefixTooLong {
1817                field: ReftableField::LogKey,
1818            }));
1819        }
1820        key.extend_from_slice(&prev_key[..prefix_len as usize]);
1821    }
1822    if p + suffix_len > data.len() {
1823        return Err(truncated(ReftableField::LogKey));
1824    }
1825    key.extend_from_slice(&data[p..p + suffix_len]);
1826    p += suffix_len;
1827
1828    // Parse key: refname \0 reverse_int64(update_index)
1829    let null_pos = key
1830        .iter()
1831        .position(|&b| b == 0)
1832        .ok_or_else(|| corrupt(ReftableError::InvalidLogKey))?;
1833    let refname = String::from_utf8(key[..null_pos].to_vec())
1834        .map_err(|_| invalid_utf8(ReftableField::RefName))?;
1835    if null_pos + 9 > key.len() {
1836        return Err(corrupt(ReftableError::InvalidLogKey));
1837    }
1838    let reversed_idx = u64::from_be_bytes(
1839        key[null_pos + 1..null_pos + 9]
1840            .try_into()
1841            .map_err(|_| corrupt(ReftableError::InvalidLogKey))?,
1842    );
1843    let update_index = 0xffffffffffffffffu64 - reversed_idx;
1844
1845    if log_type == 0 {
1846        // Deletion
1847        let zero_oid = ObjectId::from_bytes(&vec![0u8; hash_size])?;
1848        return Ok((
1849            LogRecord {
1850                refname,
1851                update_index,
1852                old_id: zero_oid,
1853                new_id: zero_oid,
1854                name: String::new(),
1855                email: String::new(),
1856                time_seconds: 0,
1857                tz_offset: 0,
1858                message: String::new(),
1859            },
1860            p,
1861        ));
1862    }
1863
1864    // log_type == 1: standard log data
1865    if p + 2 * hash_size > data.len() {
1866        return Err(truncated(ReftableField::ObjectIdPair));
1867    }
1868    let old_id = ObjectId::from_bytes(&data[p..p + hash_size])?;
1869    p += hash_size;
1870    let new_id = ObjectId::from_bytes(&data[p..p + hash_size])?;
1871    p += hash_size;
1872
1873    let (name_len, p2) = get_varint(data, p)?;
1874    p = p2;
1875    let name_len = name_len as usize;
1876    if p + name_len > data.len() {
1877        return Err(truncated(ReftableField::LogName));
1878    }
1879    let name = String::from_utf8(data[p..p + name_len].to_vec())
1880        .map_err(|_| invalid_utf8(ReftableField::LogName))?;
1881    p += name_len;
1882
1883    let (email_len, p2) = get_varint(data, p)?;
1884    p = p2;
1885    let email_len = email_len as usize;
1886    if p + email_len > data.len() {
1887        return Err(truncated(ReftableField::LogEmail));
1888    }
1889    let email = String::from_utf8(data[p..p + email_len].to_vec())
1890        .map_err(|_| invalid_utf8(ReftableField::LogEmail))?;
1891    p += email_len;
1892
1893    let (time_seconds, p2) = get_varint(data, p)?;
1894    p = p2;
1895
1896    if p + 2 > data.len() {
1897        return Err(truncated(ReftableField::LogTimezone));
1898    }
1899    let tz_offset = i16::from_be_bytes([data[p], data[p + 1]]);
1900    p += 2;
1901
1902    let (msg_len, p2) = get_varint(data, p)?;
1903    p = p2;
1904    let msg_len = msg_len as usize;
1905    if p + msg_len > data.len() {
1906        return Err(truncated(ReftableField::LogMessage));
1907    }
1908    let message = String::from_utf8(data[p..p + msg_len].to_vec())
1909        .map_err(|_| invalid_utf8(ReftableField::LogMessage))?;
1910    p += msg_len;
1911
1912    Ok((
1913        LogRecord {
1914            refname,
1915            update_index,
1916            old_id,
1917            new_id,
1918            name,
1919            email,
1920            time_seconds,
1921            tz_offset,
1922            message,
1923        },
1924        p,
1925    ))
1926}
1927
1928// ---------------------------------------------------------------------------
1929// Stack management
1930// ---------------------------------------------------------------------------
1931
1932/// Widen a null OID to `hash_size` bytes so every OID written into a reftable
1933/// shares the table's hash width. Non-null OIDs (real objects) are already at the
1934/// repository width and are returned unchanged.
1935pub(crate) fn widen_oid_to(oid: ObjectId, hash_size: usize) -> ObjectId {
1936    if oid.is_zero() && oid.as_bytes().len() != hash_size {
1937        ObjectId::from_bytes(&vec![0u8; hash_size]).unwrap_or(oid)
1938    } else {
1939        oid
1940    }
1941}
1942
1943/// Object-id width for reftables in the repository owning `git_dir`: 32 bytes
1944/// (reftable version 2) when `extensions.objectformat=sha256`, else 20 (version 1).
1945fn reftable_hash_size_for_git_dir(git_dir: &Path) -> usize {
1946    let cfg = crate::config::ConfigSet::load(
1947        &crate::environment::Environment::empty(),
1948        Some(git_dir),
1949        true,
1950    )
1951    .unwrap_or_default();
1952    match cfg
1953        .get("extensions.objectformat")
1954        .and_then(|v| crate::objects::HashAlgo::from_name(&v))
1955    {
1956        Some(crate::objects::HashAlgo::Sha256) => 32,
1957        _ => 20,
1958    }
1959}
1960
1961/// Exclusive upper bound for ref names sharing a prefix without a trailing slash
1962/// (e.g. `refs/heads/b05` → `refs/heads/b06`).
1963fn prefix_upper_exclusive(prefix: &str) -> Option<String> {
1964    if prefix.is_empty() || prefix.ends_with('/') {
1965        return None;
1966    }
1967    let mut end = prefix.as_bytes().to_vec();
1968    while let Some(last) = end.last_mut() {
1969        if *last == b'/' {
1970            break;
1971        }
1972        if *last < 0xff {
1973            *last += 1;
1974            return String::from_utf8(end).ok();
1975        }
1976        *last = 0;
1977        end.pop();
1978    }
1979    None
1980}
1981
1982/// Compaction segment `[start, end)` in table index space (git `suggest_compaction_segment`).
1983fn suggest_compaction_segment(sizes: &[u64], geometric_factor: u8) -> (usize, usize) {
1984    let n = sizes.len();
1985    if n <= 1 {
1986        return (0, 0);
1987    }
1988    let factor = u64::from(geometric_factor.max(2));
1989    let mut seg_end = 0usize;
1990    let mut bytes = 0u64;
1991    let mut i = n - 1;
1992    while i > 0 {
1993        if sizes[i - 1] < sizes[i].saturating_mul(factor) {
1994            seg_end = i + 1;
1995            bytes = sizes[i];
1996            break;
1997        }
1998        i -= 1;
1999    }
2000    if seg_end == 0 {
2001        return (0, 0);
2002    }
2003    let mut seg_start = 0usize;
2004    i = seg_end.saturating_sub(1);
2005    while i > 0 {
2006        let curr = bytes;
2007        bytes = bytes.saturating_add(sizes[i - 1]);
2008        if sizes[i - 1] < curr.saturating_mul(factor) {
2009            seg_start = i - 1;
2010        }
2011        i -= 1;
2012    }
2013    if seg_end <= seg_start + 1 {
2014        return (0, 0);
2015    }
2016    (seg_start, seg_end)
2017}
2018
2019#[derive(Clone, PartialEq, Eq, Hash)]
2020struct StackCacheKey {
2021    reftable_dir: PathBuf,
2022    tables_list_len: u64,
2023    tables_list_modified: i64,
2024}
2025
2026struct CachedStackTables {
2027    table_names: Vec<String>,
2028    readers: Vec<ReftableReader>,
2029}
2030
2031// hygiene: process-wide reftable table cache keyed by tables.list identity (cleared on writes)
2032static STACK_TABLE_CACHE: OnceLock<Mutex<HashMap<StackCacheKey, Arc<CachedStackTables>>>> =
2033    OnceLock::new();
2034
2035// hygiene: `'static` return borrows the process-wide STACK_TABLE_CACHE, not a new global
2036fn stack_table_cache() -> &'static Mutex<HashMap<StackCacheKey, Arc<CachedStackTables>>> {
2037    STACK_TABLE_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
2038}
2039
2040/// How long readers retry when a listed table disappears or is still being written.
2041const STACK_TABLE_RELOAD_DEADLINE: Duration = Duration::from_secs(3);
2042
2043fn is_transient_stack_table_error(err: &Error) -> bool {
2044    match err {
2045        Error::Io(e) if e.kind() == std::io::ErrorKind::NotFound => true,
2046        Error::Reftable(ReftableError::TooSmall { len: 0 }) => true,
2047        _ => false,
2048    }
2049}
2050
2051fn random_table_suffix() -> Result<u32> {
2052    #[cfg(test)]
2053    if let Some(suffix) = reftable_write_test_hooks::take_table_suffix() {
2054        return Ok(suffix);
2055    }
2056    let mut buf = [0u8; 4];
2057    getrandom::getrandom(&mut buf).map_err(|e| Error::Io(std::io::Error::other(e.to_string())))?;
2058    Ok(u32::from_le_bytes(buf))
2059}
2060
2061fn reftable_table_filename(min_update_index: u64, max_update_index: u64, suffix: u32) -> String {
2062    format!(
2063        "{:08x}-{:08x}-{:08x}.ref",
2064        min_update_index, max_update_index, suffix
2065    )
2066}
2067
2068/// Publish `data` as a new table file that must not already exist on disk.
2069fn publish_new_reftable_table_file(
2070    reftable_dir: &Path,
2071    data: &[u8],
2072    min_update_index: u64,
2073    max_update_index: u64,
2074    suffix: u32,
2075) -> Result<String> {
2076    let filename = reftable_table_filename(min_update_index, max_update_index, suffix);
2077    let path = reftable_dir.join(&filename);
2078    if path.exists() {
2079        return Err(Error::Io(std::io::Error::new(
2080            std::io::ErrorKind::AlreadyExists,
2081            "reftable table file already exists",
2082        )));
2083    }
2084    let tmp_path = reftable_dir.join(format!(".writ-{suffix:08x}.tmp"));
2085    let mut file = match fs::OpenOptions::new()
2086        .write(true)
2087        .create_new(true)
2088        .open(&tmp_path)
2089    {
2090        Ok(file) => file,
2091        Err(err) => return Err(Error::Io(err)),
2092    };
2093    if let Err(err) = file.write_all(data).and_then(|_| file.sync_all()) {
2094        drop(file);
2095        let _ = fs::remove_file(&tmp_path);
2096        return Err(Error::Io(err));
2097    }
2098    drop(file);
2099
2100    #[cfg(test)]
2101    reftable_write_test_hooks::run_before_persist_hook(&path);
2102
2103    let staged = TempPath::try_from_path(tmp_path).map_err(Error::Io)?;
2104    match publish_no_clobber(staged, &path) {
2105        Ok(true) => Ok(filename),
2106        Ok(false) => Err(Error::Io(std::io::Error::new(
2107            std::io::ErrorKind::AlreadyExists,
2108            "reftable table file already exists",
2109        ))),
2110        Err(err) => Err(Error::Io(err)),
2111    }
2112}
2113
2114fn invalidate_stack_table_cache(reftable_dir: &Path) {
2115    let Ok(mut guard) = stack_table_cache().lock() else {
2116        return;
2117    };
2118    guard.retain(|k, _| k.reftable_dir != reftable_dir);
2119}
2120
2121fn tables_list_cache_key(reftable_dir: &Path) -> Result<StackCacheKey> {
2122    let tables_list = reftable_dir.join("tables.list");
2123    match fs::metadata(&tables_list) {
2124        Ok(meta) => Ok(StackCacheKey {
2125            reftable_dir: reftable_dir.to_path_buf(),
2126            tables_list_len: meta.len(),
2127            tables_list_modified: meta
2128                .modified()
2129                .ok()
2130                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
2131                .map(|d| d.as_secs() as i64)
2132                .unwrap_or(0),
2133        }),
2134        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(StackCacheKey {
2135            reftable_dir: reftable_dir.to_path_buf(),
2136            tables_list_len: 0,
2137            tables_list_modified: 0,
2138        }),
2139        Err(e) => Err(Error::Io(e)),
2140    }
2141}
2142
2143fn build_cached_stack_tables(
2144    reftable_dir: &Path,
2145    table_names: &[String],
2146) -> Result<CachedStackTables> {
2147    let mut readers = Vec::with_capacity(table_names.len());
2148    for name in table_names {
2149        let path = reftable_dir.join(name);
2150        let file = fs::File::open(&path).map_err(Error::Io)?;
2151        let len = file.metadata().map_err(Error::Io)?.len() as usize;
2152        let map = Arc::new(crate::pack_map::map_read_only_file(&file, len).map_err(Error::Io)?);
2153        readers.push(ReftableReader::from_mapped(map)?);
2154    }
2155    Ok(CachedStackTables {
2156        table_names: table_names.to_owned(),
2157        readers,
2158    })
2159}
2160
2161/// Load every table named in `table_names`, re-reading `tables.list` when compaction
2162/// removes a file or a writer has not finished publishing table bytes yet.
2163fn build_cached_stack_tables_with_reload(
2164    reftable_dir: &Path,
2165    mut table_names: Vec<String>,
2166) -> Result<CachedStackTables> {
2167    let started = Instant::now();
2168    loop {
2169        match build_cached_stack_tables(reftable_dir, &table_names) {
2170            Ok(cached) => return Ok(cached),
2171            Err(e) if is_transient_stack_table_error(&e) => {
2172                if started.elapsed() >= STACK_TABLE_RELOAD_DEADLINE {
2173                    return Err(e);
2174                }
2175                invalidate_stack_table_cache(reftable_dir);
2176                let fresh = read_tables_list_names(reftable_dir)?;
2177                let unchanged = fresh == table_names;
2178                table_names = fresh;
2179                if unchanged {
2180                    return Err(e);
2181                }
2182            }
2183            Err(e) => return Err(e),
2184        }
2185    }
2186}
2187
2188fn load_cached_stack_tables(reftable_dir: &Path) -> Result<Arc<CachedStackTables>> {
2189    let key = tables_list_cache_key(reftable_dir)?;
2190    let mut guard = stack_table_cache()
2191        .lock()
2192        .map_err(|_| Error::Io(std::io::Error::other("reftable stack cache lock poisoned")))?;
2193    if let Some(hit) = guard.get(&key) {
2194        return Ok(Arc::clone(hit));
2195    }
2196
2197    let tables_list = reftable_dir.join("tables.list");
2198    let table_names: Vec<String> = match fs::read_to_string(&tables_list) {
2199        Ok(content) => content
2200            .lines()
2201            .filter(|l| !l.is_empty())
2202            .map(|l| l.to_owned())
2203            .collect(),
2204        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
2205        Err(e) => return Err(Error::Io(e)),
2206    };
2207    let cached = Arc::new(build_cached_stack_tables_with_reload(
2208        reftable_dir,
2209        table_names,
2210    )?);
2211    guard.insert(key, Arc::clone(&cached));
2212    Ok(cached)
2213}
2214
2215/// Cached table bytes for the authoritative on-disk `tables.list` stack.
2216///
2217/// When another process (for example system `git`) appends or compacts reftables, the
2218/// process-wide cache can still hit on a stale `(mtime, len)` key while `tables.list`
2219/// names different files — reload or rebuild when the name list does not match.
2220fn cached_stack_tables_for_names(
2221    reftable_dir: &Path,
2222    table_names: &[String],
2223) -> Result<Arc<CachedStackTables>> {
2224    let cached = load_cached_stack_tables(reftable_dir)?;
2225    if cached.table_names.as_slice() == table_names {
2226        return Ok(cached);
2227    }
2228    invalidate_stack_table_cache(reftable_dir);
2229    let cached = load_cached_stack_tables(reftable_dir)?;
2230    if cached.table_names.as_slice() == table_names {
2231        return Ok(cached);
2232    }
2233    let key = tables_list_cache_key(reftable_dir)?;
2234    let rebuilt = Arc::new(build_cached_stack_tables_with_reload(
2235        reftable_dir,
2236        table_names.to_vec(),
2237    )?);
2238    if let Ok(mut guard) = stack_table_cache().lock() {
2239        guard.insert(key, Arc::clone(&rebuilt));
2240    }
2241    Ok(rebuilt)
2242}
2243
2244fn cached_reader_for_name<'a>(
2245    cached: &'a CachedStackTables,
2246    table_name: &str,
2247) -> Result<&'a ReftableReader> {
2248    let idx = cached
2249        .table_names
2250        .iter()
2251        .position(|n| n == table_name)
2252        .ok_or_else(|| ReftableError::MissingTable {
2253            name: table_name.to_owned(),
2254        })?;
2255    Ok(&cached.readers[idx])
2256}
2257
2258/// Manages the `$GIT_DIR/reftable/` directory and `tables.list` stack.
2259///
2260/// The stack provides a merged view of all tables, with later tables
2261/// taking precedence over earlier ones.
2262pub struct ReftableStack {
2263    /// Path to the `reftable/` directory.
2264    reftable_dir: PathBuf,
2265    /// Ordered list of table file names (oldest first).
2266    table_names: Vec<String>,
2267    /// When true, [`table_names`] is a fixed compaction subset; otherwise reads track on-disk `tables.list`.
2268    fixed_table_names: bool,
2269}
2270
2271fn parse_tables_list_content(content: &str) -> Vec<String> {
2272    content
2273        .lines()
2274        .filter(|l| !l.is_empty())
2275        .map(|l| l.to_owned())
2276        .collect()
2277}
2278
2279fn read_tables_list_names(reftable_dir: &Path) -> Result<Vec<String>> {
2280    let tables_list = reftable_dir.join("tables.list");
2281    match fs::read_to_string(&tables_list) {
2282        Ok(content) => Ok(parse_tables_list_content(&content)),
2283        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
2284        Err(e) => Err(Error::Io(e)),
2285    }
2286}
2287
2288/// RAII guard for `tables.list.lock`. Removes the lock file on drop unless it was
2289/// consumed (renamed onto `tables.list`) via [`disarm`].
2290pub(crate) struct TablesListLock {
2291    lock: Option<crate::lockfile::LockFile>,
2292}
2293
2294impl TablesListLock {
2295    fn new(lock: crate::lockfile::LockFile) -> Self {
2296        Self { lock: Some(lock) }
2297    }
2298
2299    fn path(&self) -> Result<&Path> {
2300        self.lock
2301            .as_ref()
2302            .map(|lock| lock.lock_path())
2303            .ok_or_else(|| ReftableError::LockReleased.into())
2304    }
2305}
2306
2307impl Drop for TablesListLock {
2308    fn drop(&mut self) {
2309        drop(self.lock.take());
2310    }
2311}
2312
2313impl ReftableStack {
2314    /// Object-id width (20 or 32) for reftables written into this stack, from the
2315    /// owning repository's `extensions.objectformat`.
2316    fn hash_size(&self) -> usize {
2317        match self.reftable_dir.parent() {
2318            Some(git_dir) => reftable_hash_size_for_git_dir(git_dir),
2319            None => 20,
2320        }
2321    }
2322
2323    /// Open an existing reftable stack.
2324    ///
2325    /// When `reftable/tables.list` is absent (empty reftable backend), the stack
2326    /// starts with no tables, matching Git's layout before the first ref write.
2327    pub fn open(git_dir: &Path) -> Result<Self> {
2328        let reftable_dir = git_dir.join("reftable");
2329        fs::create_dir_all(&reftable_dir).map_err(Error::Io)?;
2330        let tables_list = reftable_dir.join("tables.list");
2331        let table_names = match fs::read_to_string(&tables_list) {
2332            Ok(content) => parse_tables_list_content(&content),
2333            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
2334            Err(e) => return Err(Error::Io(e)),
2335        };
2336        Ok(Self {
2337            reftable_dir,
2338            table_names,
2339            fixed_table_names: false,
2340        })
2341    }
2342
2343    fn subset_stack(reftable_dir: PathBuf, table_names: Vec<String>) -> Self {
2344        Self {
2345            reftable_dir,
2346            table_names,
2347            fixed_table_names: true,
2348        }
2349    }
2350
2351    fn table_names_for_read(&self) -> Result<Vec<String>> {
2352        if self.fixed_table_names {
2353            Ok(self.table_names.clone())
2354        } else {
2355            read_tables_list_names(&self.reftable_dir)
2356        }
2357    }
2358
2359    /// First ref name under `prefix` (child path), if any.
2360    fn first_ref_name_with_prefix(&self, prefix: &str) -> Result<Option<String>> {
2361        let names = self.table_names_for_read()?;
2362        let cached = cached_stack_tables_for_names(&self.reftable_dir, &names)?;
2363        let readers = self.stack_readers(&cached)?;
2364        let upper = prefix_upper_exclusive(prefix.trim_end_matches('/'));
2365        let mut merged: BTreeMap<String, RefRecord> = BTreeMap::new();
2366        for reader in readers {
2367            reader.for_each_ref_in_range(prefix, upper.as_deref(), |rec| {
2368                if !rec.name.starts_with(prefix) || rec.name.len() <= prefix.len() {
2369                    return ControlFlow::<()>::Continue(());
2370                }
2371                match &rec.value {
2372                    RefValue::Deletion => {
2373                        merged.remove(&rec.name);
2374                    }
2375                    _ => {
2376                        merged.insert(rec.name.clone(), rec.clone());
2377                    }
2378                }
2379                ControlFlow::<()>::Continue(())
2380            })?;
2381        }
2382        Ok(merged.keys().next().cloned())
2383    }
2384
2385    /// Read the configured reftable write options from this repo's config.
2386    fn write_options(&self) -> WriteOptions {
2387        let git_dir = self
2388            .reftable_dir
2389            .parent()
2390            .map(|p| p.to_path_buf())
2391            .unwrap_or_else(|| self.reftable_dir.clone());
2392        read_write_options(&git_dir)
2393    }
2394
2395    fn stack_readers<'a>(&self, cached: &'a CachedStackTables) -> Result<Vec<&'a ReftableReader>> {
2396        cached
2397            .table_names
2398            .iter()
2399            .map(|name| cached_reader_for_name(cached, name))
2400            .collect()
2401    }
2402
2403    /// Read a merged view of all ref records.
2404    ///
2405    /// Later tables override earlier ones. Deletion records cause the
2406    /// ref to be omitted from the result.
2407    pub fn read_refs(&self) -> Result<Vec<RefRecord>> {
2408        let names = self.table_names_for_read()?;
2409        let cached = cached_stack_tables_for_names(&self.reftable_dir, &names)?;
2410        let readers = self.stack_readers(&cached)?;
2411        let mut merged: BTreeMap<String, RefRecord> = BTreeMap::new();
2412        for reader in readers {
2413            reader.for_each_ref(|rec| {
2414                match &rec.value {
2415                    RefValue::Deletion => {
2416                        merged.remove(&rec.name);
2417                    }
2418                    _ => {
2419                        merged.insert(rec.name.clone(), rec.clone());
2420                    }
2421                }
2422                ControlFlow::<()>::Continue(())
2423            })?;
2424        }
2425        Ok(merged.into_values().collect())
2426    }
2427
2428    /// Visit merged ref records under `prefix` across all stack tables.
2429    ///
2430    /// Later tables override earlier ones; deletions omit the ref. Uses index block
2431    /// seeking per table when available. An empty `prefix` is treated as `refs/`.
2432    pub(crate) fn for_each_merged_ref_under_prefix(
2433        &self,
2434        prefix: &str,
2435        mut f: impl FnMut(&RefRecord) -> ControlFlow<()>,
2436    ) -> Result<()> {
2437        let prefix = if prefix.is_empty() { "refs/" } else { prefix };
2438        let names = self.table_names_for_read()?;
2439        let cached = cached_stack_tables_for_names(&self.reftable_dir, &names)?;
2440        let readers = self.stack_readers(&cached)?;
2441        let lower = prefix;
2442        let upper = prefix_upper_exclusive(prefix);
2443
2444        let mut visit = |rec: &RefRecord| -> ControlFlow<()> {
2445            if matches!(rec.value, RefValue::Deletion) {
2446                return ControlFlow::Continue(());
2447            }
2448            if prefix.ends_with('/') && !rec.name.starts_with(prefix) {
2449                return ControlFlow::Continue(());
2450            }
2451            f(rec)
2452        };
2453
2454        if readers.len() == 1 {
2455            return readers[0].for_each_ref_in_range(lower, upper.as_deref(), |rec| visit(rec));
2456        }
2457
2458        let mut merged: BTreeMap<String, RefRecord> = BTreeMap::new();
2459        for reader in readers {
2460            reader.for_each_ref_in_range(lower, upper.as_deref(), |rec| {
2461                if prefix.ends_with('/') && !rec.name.starts_with(prefix) {
2462                    return ControlFlow::<()>::Continue(());
2463                }
2464                match &rec.value {
2465                    RefValue::Deletion => {
2466                        merged.remove(&rec.name);
2467                    }
2468                    _ => {
2469                        merged.insert(rec.name.clone(), rec.clone());
2470                    }
2471                }
2472                ControlFlow::<()>::Continue(())
2473            })?;
2474        }
2475        for rec in merged.values() {
2476            if visit(rec).is_break() {
2477                break;
2478            }
2479        }
2480        Ok(())
2481    }
2482
2483    /// List direct ref names and oids under `prefix`, using cached table bytes and
2484    /// index/restart seeking when the stack is a single table.
2485    ///
2486    /// An empty `prefix` lists the refs under `refs/` only, leaving out root refs such as
2487    /// `HEAD` and `ORIG_HEAD`, the same as [`RefStore::for_each_ref`](crate::refs::store::RefStore::for_each_ref).
2488    pub fn list_refs_under_prefix(
2489        &self,
2490        git_dir: &Path,
2491        prefix: &str,
2492    ) -> Result<Vec<(String, ObjectId)>> {
2493        let mut out = Vec::new();
2494        self.for_each_merged_ref_under_prefix(prefix, |rec| {
2495            push_list_ref_entry(git_dir, rec, &mut out);
2496            ControlFlow::<()>::Continue(())
2497        })?;
2498        out.sort_by(|a, b| a.0.cmp(&b.0));
2499        Ok(out)
2500    }
2501
2502    fn table_payload_sizes(&self) -> Result<Vec<u64>> {
2503        let names = self.table_names_for_read()?;
2504        let cached = cached_stack_tables_for_names(&self.reftable_dir, &names)?;
2505        Ok(self
2506            .stack_readers(&cached)?
2507            .into_iter()
2508            .map(ReftableReader::payload_size)
2509            .collect())
2510    }
2511
2512    fn auto_compact_geometric(&mut self, opts: &WriteOptions) -> Result<()> {
2513        if !opts.autocompaction_enabled {
2514            return Ok(());
2515        }
2516        self.reload_table_names();
2517        if self.table_names.len() < 2 {
2518            return Ok(());
2519        }
2520        let sizes = self.table_payload_sizes()?;
2521        let (start, end) = suggest_compaction_segment(&sizes, opts.auto_compaction_factor);
2522        if end <= start + 1 {
2523            return Ok(());
2524        }
2525        self.compact_range(start, end - 1, opts)
2526    }
2527
2528    /// Merge ref records from `table_names` for compaction output.
2529    ///
2530    /// Newest record wins. When the winner is a deletion, it is written into the
2531    /// compacted table only if `preserve_deletion_tombstones` is set (non-bottom
2532    /// compaction ranges that still have older tables below).
2533    fn merge_refs_for_compaction(
2534        &self,
2535        table_names: &[String],
2536        preserve_deletion_tombstones: bool,
2537    ) -> Result<Vec<RefRecord>> {
2538        let cached = cached_stack_tables_for_names(&self.reftable_dir, table_names)?;
2539        let subset = Self::subset_stack(self.reftable_dir.clone(), table_names.to_vec());
2540        let readers = subset.stack_readers(&cached)?;
2541        let mut merged: BTreeMap<String, RefRecord> = BTreeMap::new();
2542        for reader in readers {
2543            reader.for_each_ref(|rec| {
2544                match &rec.value {
2545                    RefValue::Deletion => {
2546                        if preserve_deletion_tombstones {
2547                            merged.insert(rec.name.clone(), rec.clone());
2548                        } else {
2549                            merged.remove(&rec.name);
2550                        }
2551                    }
2552                    _ => {
2553                        merged.insert(rec.name.clone(), rec.clone());
2554                    }
2555                }
2556                ControlFlow::<()>::Continue(())
2557            })?;
2558        }
2559        Ok(merged.into_values().collect())
2560    }
2561
2562    fn compact_range(&mut self, first: usize, last: usize, opts: &WriteOptions) -> Result<()> {
2563        let mut guard = self.acquire_tables_list_lock()?;
2564        self.reload_table_names();
2565        if first >= self.table_names.len() || last >= self.table_names.len() || first > last {
2566            return Ok(());
2567        }
2568        let compact_names: Vec<String> = self.table_names[first..=last].to_vec();
2569        let refs = self.merge_refs_for_compaction(&compact_names, first > 0)?;
2570        let prefix_stack = Self::subset_stack(self.reftable_dir.clone(), compact_names.clone());
2571        let logs = prefix_stack.read_all_logs()?;
2572
2573        let mut min_idx = u64::MAX;
2574        let mut max_idx = 0u64;
2575        for name in &compact_names {
2576            let path = self.reftable_dir.join(name);
2577            let data = fs::read(&path).map_err(Error::Io)?;
2578            let reader = ReftableReader::new(data)?;
2579            min_idx = min_idx.min(reader.min_update_index());
2580            max_idx = max_idx.max(reader.max_update_index());
2581        }
2582        if min_idx == u64::MAX {
2583            min_idx = 0;
2584        }
2585
2586        let wopts = WriteOptions {
2587            hash_size: self.hash_size(),
2588            ..opts.clone()
2589        };
2590        let mut writer = ReftableWriter::new(wopts, min_idx, max_idx);
2591        for rec in refs {
2592            writer.add_ref(rec)?;
2593        }
2594        for log in logs {
2595            writer.add_log(log)?;
2596        }
2597        let data = writer.finish()?;
2598        let filename = self.write_table_file(&data, min_idx, max_idx)?;
2599
2600        let mut new_names: Vec<String> = self.table_names[..first].to_vec();
2601        new_names.push(filename.clone());
2602        new_names.extend_from_slice(&self.table_names[last + 1..]);
2603        self.table_names = new_names;
2604        self.write_tables_list_locked(&mut guard)?;
2605        invalidate_stack_table_cache(&self.reftable_dir);
2606        for old in &compact_names {
2607            if old == &filename {
2608                continue;
2609            }
2610            let _ = fs::remove_file(self.reftable_dir.join(old));
2611        }
2612        Ok(())
2613    }
2614
2615    /// Resolve the current oid for `name`, or `None` when the ref is absent or deleted.
2616    pub fn lookup_ref_oid(&self, name: &str) -> Result<Option<ObjectId>> {
2617        Ok(self.lookup_ref(name)?.and_then(|rec| match rec.value {
2618            RefValue::Val1(oid) => Some(oid),
2619            RefValue::Val2(oid, _) => Some(oid),
2620            RefValue::Deletion | RefValue::Symref(_) => None,
2621        }))
2622    }
2623
2624    /// Look up a single ref across all tables (most recent wins).
2625    pub fn lookup_ref(&self, name: &str) -> Result<Option<RefRecord>> {
2626        let names = self.table_names_for_read()?;
2627        let cached = cached_stack_tables_for_names(&self.reftable_dir, &names)?;
2628        for reader in self.stack_readers(&cached)?.into_iter().rev() {
2629            if let Some(rec) = reader.lookup_ref(name)? {
2630                return match rec.value {
2631                    RefValue::Deletion => Ok(None),
2632                    _ => Ok(Some(rec)),
2633                };
2634            }
2635        }
2636        Ok(None)
2637    }
2638
2639    /// Read merged log records for a specific ref.
2640    pub fn read_logs_for_ref(&self, refname: &str) -> Result<Vec<LogRecord>> {
2641        let started = Instant::now();
2642        loop {
2643            let table_names = self.table_names_for_read()?;
2644            match self.read_logs_for_ref_from_names(&table_names, refname) {
2645                Ok(logs) => return Ok(logs),
2646                Err(e) if is_transient_stack_table_error(&e) => {
2647                    if started.elapsed() >= STACK_TABLE_RELOAD_DEADLINE {
2648                        return Err(e);
2649                    }
2650                    invalidate_stack_table_cache(&self.reftable_dir);
2651                    let fresh = self.table_names_for_read()?;
2652                    if fresh == table_names {
2653                        return Err(e);
2654                    }
2655                }
2656                Err(e) => return Err(e),
2657            }
2658        }
2659    }
2660
2661    fn read_logs_for_ref_from_names(
2662        &self,
2663        table_names: &[String],
2664        refname: &str,
2665    ) -> Result<Vec<LogRecord>> {
2666        let Some(min_update_index) = self.min_log_update_index_for_live_ref(refname)? else {
2667            return Ok(Vec::new());
2668        };
2669        let mut logs = Vec::new();
2670        for table_name in table_names {
2671            let path = self.reftable_dir.join(table_name);
2672            let data = fs::read(&path).map_err(Error::Io)?;
2673            let reader = ReftableReader::new(data)?;
2674            for log in reader.read_logs()? {
2675                if log.refname == refname
2676                    && log.update_index >= min_update_index
2677                    && !log_record_is_deletion(&log)
2678                {
2679                    logs.push(log);
2680                }
2681            }
2682        }
2683        logs.sort_by_key(|a| std::cmp::Reverse(a.update_index));
2684        Ok(logs)
2685    }
2686
2687    /// Lowest update index for reflog lines that belong to the ref's current life.
2688    ///
2689    /// After a deletion record, older log lines are ignored until the ref is created
2690    /// again. Returns `None` when the ref is absent or deleted.
2691    fn min_log_update_index_for_live_ref(&self, refname: &str) -> Result<Option<u64>> {
2692        let names = self.table_names_for_read()?;
2693        let cached = cached_stack_tables_for_names(&self.reftable_dir, &names)?;
2694        let readers = self.stack_readers(&cached)?;
2695        let mut events = Vec::new();
2696        for reader in &readers {
2697            reader.for_each_ref(|rec| {
2698                if rec.name == refname {
2699                    events.push(rec.clone());
2700                }
2701                ControlFlow::<()>::Continue(())
2702            })?;
2703        }
2704        if events.is_empty() {
2705            return Ok(Some(0));
2706        }
2707        events.sort_by_key(|rec| rec.update_index);
2708        let mut min_log = 0u64;
2709        let mut live = false;
2710        for rec in events {
2711            match rec.value {
2712                RefValue::Deletion => {
2713                    live = false;
2714                    min_log = rec.update_index.saturating_add(1);
2715                }
2716                RefValue::Symref(_) | RefValue::Val1(_) | RefValue::Val2(..) => {
2717                    live = true;
2718                }
2719            }
2720        }
2721        Ok(live.then_some(min_log))
2722    }
2723
2724    /// Replace all log records for one ref and compact the stack.
2725    pub fn replace_logs_for_ref(
2726        &mut self,
2727        refname: &str,
2728        entries: &[crate::reflog::ReflogEntry],
2729    ) -> Result<()> {
2730        let entries = entries.to_vec();
2731        self.rewrite_logs_for_ref(refname, &mut |_| Ok(entries.clone()))
2732    }
2733
2734    /// Replace one ref's log records under `tables.list.lock`, re-reading the stack under the lock.
2735    pub fn rewrite_logs_for_ref(
2736        &mut self,
2737        refname: &str,
2738        transform: &mut dyn FnMut(
2739            Vec<crate::reflog::ReflogEntry>,
2740        ) -> Result<Vec<crate::reflog::ReflogEntry>>,
2741    ) -> Result<()> {
2742        let mut guard = self.acquire_tables_list_lock()?;
2743        self.reload_table_names();
2744        let current = self.read_reflog_entries_for_ref(refname)?;
2745        let entries = transform(current)?;
2746        self.replace_logs_for_ref_under_lock(&mut guard, refname, &entries)
2747    }
2748
2749    fn read_reflog_entries_for_ref(
2750        &self,
2751        refname: &str,
2752    ) -> Result<Vec<crate::reflog::ReflogEntry>> {
2753        let mut entries = Vec::new();
2754        for log in self.read_logs_for_ref(refname)? {
2755            let tz_sign = if log.tz_offset >= 0 { '+' } else { '-' };
2756            let tz_abs = log.tz_offset.unsigned_abs();
2757            let tz_hours = tz_abs / 100;
2758            let tz_mins = tz_abs % 100;
2759            let identity = format!(
2760                "{} <{}> {} {}{:02}{:02}",
2761                log.name, log.email, log.time_seconds, tz_sign, tz_hours, tz_mins
2762            );
2763            let message = log
2764                .message
2765                .strip_suffix('\n')
2766                .map(ToOwned::to_owned)
2767                .unwrap_or_else(|| log.message.clone());
2768            entries.push(crate::reflog::ReflogEntry {
2769                old_oid: log.old_id,
2770                new_oid: log.new_id,
2771                identity,
2772                message,
2773            });
2774        }
2775        entries.reverse();
2776        Ok(entries)
2777    }
2778
2779    fn replace_logs_for_ref_under_lock(
2780        &mut self,
2781        guard: &mut TablesListLock,
2782        refname: &str,
2783        entries: &[crate::reflog::ReflogEntry],
2784    ) -> Result<()> {
2785        let refs = self.read_refs()?;
2786        let mut logs: Vec<LogRecord> = self
2787            .read_all_logs()?
2788            .into_iter()
2789            .filter(|log| log.refname != refname)
2790            .collect();
2791        let mut next_update_index = self.max_update_index_unlocked()? + 1;
2792        let hash_size = self.hash_size();
2793        for entry in entries {
2794            let (name, email, time_secs, tz) = parse_identity_string(&entry.identity);
2795            logs.push(LogRecord {
2796                refname: refname.to_owned(),
2797                update_index: next_update_index,
2798                old_id: widen_oid_to(entry.old_oid, hash_size),
2799                new_id: widen_oid_to(entry.new_oid, hash_size),
2800                name,
2801                email,
2802                time_seconds: time_secs,
2803                tz_offset: tz,
2804                message: entry.message.clone(),
2805            });
2806            next_update_index += 1;
2807        }
2808
2809        let mut min_idx = u64::MAX;
2810        let mut max_idx = 0u64;
2811        for name in &self.table_names {
2812            let path = self.reftable_dir.join(name);
2813            let data = fs::read(&path).map_err(Error::Io)?;
2814            let reader = ReftableReader::new(data)?;
2815            min_idx = min_idx.min(reader.min_update_index());
2816            max_idx = max_idx.max(reader.max_update_index());
2817        }
2818        if min_idx == u64::MAX {
2819            min_idx = 0;
2820        }
2821        max_idx = max_idx.max(next_update_index.saturating_sub(1));
2822
2823        let wopts = WriteOptions {
2824            hash_size: self.hash_size(),
2825            ..Default::default()
2826        };
2827        let mut writer = ReftableWriter::new(wopts, min_idx, max_idx);
2828        for rec in refs {
2829            writer.add_ref(rec)?;
2830        }
2831        for log in logs {
2832            writer.add_log(log)?;
2833        }
2834        let data = writer.finish()?;
2835        let old_names = self.table_names.clone();
2836        let name = self.write_table_file(&data, min_idx, max_idx)?;
2837        self.table_names = vec![name];
2838        self.write_tables_list_locked(guard)?;
2839        for old in &old_names {
2840            let _ = fs::remove_file(self.reftable_dir.join(old));
2841        }
2842        Ok(())
2843    }
2844
2845    /// Read all log records across all tables.
2846    pub fn read_all_logs(&self) -> Result<Vec<LogRecord>> {
2847        let started = Instant::now();
2848        loop {
2849            let table_names = self.table_names_for_read()?;
2850            match self.read_all_logs_from_names(&table_names) {
2851                Ok(logs) => return Ok(logs),
2852                Err(e) if is_transient_stack_table_error(&e) => {
2853                    if started.elapsed() >= STACK_TABLE_RELOAD_DEADLINE {
2854                        return Err(e);
2855                    }
2856                    invalidate_stack_table_cache(&self.reftable_dir);
2857                    let fresh = self.table_names_for_read()?;
2858                    if fresh == table_names {
2859                        return Err(e);
2860                    }
2861                }
2862                Err(e) => return Err(e),
2863            }
2864        }
2865    }
2866
2867    fn read_all_logs_from_names(&self, table_names: &[String]) -> Result<Vec<LogRecord>> {
2868        let mut logs = Vec::new();
2869        for table_name in table_names {
2870            let path = self.reftable_dir.join(table_name);
2871            let data = fs::read(&path).map_err(Error::Io)?;
2872            let reader = ReftableReader::new(data)?;
2873            logs.extend(reader.read_logs()?);
2874        }
2875        logs.sort_by(|a, b| {
2876            a.refname
2877                .cmp(&b.refname)
2878                .then_with(|| b.update_index.cmp(&a.update_index))
2879        });
2880        Ok(logs)
2881    }
2882
2883    /// Get the current max update index across all tables.
2884    ///
2885    /// Reads the authoritative on-disk `tables.list` rather than the (possibly
2886    /// stale) in-memory snapshot, and tolerates tables that a concurrent
2887    /// compaction removed between listing and reading: such a table's update
2888    /// index is subsumed by the compacted result that replaced it, which is also
2889    /// in the freshly-read list.
2890    pub fn max_update_index(&self) -> Result<u64> {
2891        let names: Vec<String> = match fs::read_to_string(self.reftable_dir.join("tables.list")) {
2892            Ok(content) => content
2893                .lines()
2894                .filter(|line| !line.is_empty())
2895                .map(ToOwned::to_owned)
2896                .collect(),
2897            Err(_) => self.table_names.clone(),
2898        };
2899        let mut max_idx = 0u64;
2900        for name in &names {
2901            let path = self.reftable_dir.join(name);
2902            let data = match fs::read(&path) {
2903                Ok(data) => data,
2904                Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
2905                Err(err) => return Err(Error::Io(err)),
2906            };
2907            let reader = ReftableReader::new(data)?;
2908            max_idx = max_idx.max(reader.max_update_index());
2909        }
2910        Ok(max_idx)
2911    }
2912
2913    /// Add a new reftable to the stack.
2914    ///
2915    /// Writes the table bytes to a new file, then atomically updates
2916    /// `tables.list`.
2917    pub fn add_table(
2918        &mut self,
2919        data: &[u8],
2920        update_index: u64,
2921        opts: &WriteOptions,
2922    ) -> Result<String> {
2923        let reader = ReftableReader::new(data.to_vec())?;
2924        let table_has_deletion = reader
2925            .read_refs()?
2926            .iter()
2927            .any(|record| matches!(record.value, RefValue::Deletion));
2928        let min_idx = reader.min_update_index();
2929        let max_idx = reader.max_update_index();
2930        let _ = update_index;
2931        let filename = self.write_table_file(data, min_idx, max_idx)?;
2932
2933        // Serialize the read-modify-write of `tables.list` so concurrent writers
2934        // do not clobber each other (and so we never compact away a table that a
2935        // peer just appended). Re-read the on-disk stack under the lock before
2936        // extending it — our in-memory `table_names` may be stale.
2937        {
2938            let mut guard = self.acquire_tables_list_lock()?;
2939            self.reload_table_names();
2940            self.table_names.push(filename.clone());
2941            self.write_tables_list_locked(&mut guard)?;
2942        }
2943
2944        // Auto-compact small write bursts into a single table. A plain commit writes several small
2945        // ref/log updates and should settle back to one table; a following tag write remains as a
2946        // second table until explicit `pack-refs`.
2947        if table_has_deletion && self.table_names.len() > 2 {
2948            self.compact_prefix_preserving_newest(opts)?;
2949        } else if opts.autocompaction_enabled {
2950            if self
2951                .table_names
2952                .iter()
2953                .any(|name| self.table_is_locked(name))
2954            {
2955                self.compact_unlocked_suffix()?;
2956            } else {
2957                self.auto_compact_geometric(opts)?;
2958            }
2959        }
2960
2961        Ok(filename)
2962    }
2963
2964    fn compact_prefix_preserving_newest(&mut self, opts: &WriteOptions) -> Result<()> {
2965        if !opts.autocompaction_enabled {
2966            return Ok(());
2967        }
2968        let mut guard = self.acquire_tables_list_lock()?;
2969        self.reload_table_names();
2970        if self.table_names.len() <= 2 {
2971            return Ok(());
2972        }
2973        let Some((newest, old_names)) = self.table_names.split_last() else {
2974            return Ok(());
2975        };
2976        let (newest, old_names) = (newest.clone(), old_names.to_vec());
2977        let prefix_stack = Self::subset_stack(self.reftable_dir.clone(), old_names.clone());
2978        let refs = prefix_stack.read_refs()?;
2979        let logs = prefix_stack.read_all_logs()?;
2980
2981        let mut min_idx = u64::MAX;
2982        let mut max_idx = 0u64;
2983        for name in &old_names {
2984            let path = self.reftable_dir.join(name);
2985            let data = fs::read(&path).map_err(Error::Io)?;
2986            let reader = ReftableReader::new(data)?;
2987            min_idx = min_idx.min(reader.min_update_index());
2988            max_idx = max_idx.max(reader.max_update_index());
2989        }
2990        if min_idx == u64::MAX {
2991            min_idx = 0;
2992        }
2993
2994        let wopts = WriteOptions {
2995            hash_size: self.hash_size(),
2996            ..Default::default()
2997        };
2998        let mut writer = ReftableWriter::new(wopts, min_idx, max_idx);
2999        for rec in refs {
3000            writer.add_ref(rec)?;
3001        }
3002        for log in logs {
3003            writer.add_log(log)?;
3004        }
3005        let data = writer.finish()?;
3006        let filename = self.write_table_file(&data, min_idx, max_idx)?;
3007        let keep: Vec<String> = vec![filename.clone(), newest.clone()];
3008        self.table_names = keep;
3009        self.write_tables_list_locked(&mut guard)?;
3010        for old in &old_names {
3011            if old == &filename || old == &newest {
3012                continue;
3013            }
3014            let _ = fs::remove_file(self.reftable_dir.join(old));
3015        }
3016        Ok(())
3017    }
3018
3019    fn table_is_locked(&self, name: &str) -> bool {
3020        self.reftable_dir.join(format!("{name}.lock")).exists()
3021    }
3022
3023    fn compact_unlocked_suffix(&mut self) -> Result<()> {
3024        let mut guard = self.acquire_tables_list_lock()?;
3025        self.reload_table_names();
3026        let first_unlocked = self
3027            .table_names
3028            .iter()
3029            .position(|name| !self.table_is_locked(name))
3030            .unwrap_or(self.table_names.len());
3031        if self.table_names.len().saturating_sub(first_unlocked) <= 1 {
3032            return Ok(());
3033        }
3034
3035        let locked_prefix: Vec<String> = self.table_names[..first_unlocked].to_vec();
3036        let old_suffix: Vec<String> = self.table_names[first_unlocked..].to_vec();
3037        let refs = self.merge_refs_for_compaction(&old_suffix, first_unlocked > 0)?;
3038        let suffix_stack = Self::subset_stack(self.reftable_dir.clone(), old_suffix.clone());
3039        let logs = suffix_stack.read_all_logs()?;
3040
3041        let mut min_idx = u64::MAX;
3042        let mut max_idx = 0u64;
3043        for name in &old_suffix {
3044            let path = self.reftable_dir.join(name);
3045            let data = fs::read(&path).map_err(Error::Io)?;
3046            let reader = ReftableReader::new(data)?;
3047            min_idx = min_idx.min(reader.min_update_index());
3048            max_idx = max_idx.max(reader.max_update_index());
3049        }
3050        if min_idx == u64::MAX {
3051            min_idx = 0;
3052        }
3053
3054        let wopts = WriteOptions {
3055            hash_size: self.hash_size(),
3056            ..Default::default()
3057        };
3058        let mut writer = ReftableWriter::new(wopts, min_idx, max_idx);
3059        for rec in refs {
3060            writer.add_ref(rec)?;
3061        }
3062        for log in logs {
3063            writer.add_log(log)?;
3064        }
3065        let data = writer.finish()?;
3066        let compacted = self.write_table_file(&data, min_idx, max_idx)?;
3067
3068        self.table_names = locked_prefix;
3069        self.table_names.push(compacted.clone());
3070        self.write_tables_list_locked(&mut guard)?;
3071        for old in &old_suffix {
3072            if old == &compacted {
3073                continue;
3074            }
3075            let _ = fs::remove_file(self.reftable_dir.join(old));
3076        }
3077        Ok(())
3078    }
3079
3080    /// Write a ref update (add/update/delete) as a new reftable.
3081    ///
3082    /// This is the main entry point for updating refs in a reftable repo.
3083    pub fn write_ref(
3084        &mut self,
3085        refname: &str,
3086        value: RefValue,
3087        log: Option<LogRecord>,
3088        opts: &WriteOptions,
3089    ) -> Result<()> {
3090        // Compute the update index, build the new single-record table, and append
3091        // it to `tables.list` while holding the stack lock, reading the current
3092        // on-disk list under the lock. This makes the whole read-modify-write
3093        // atomic with respect to other writers (t0610 'many concurrent
3094        // writers') — otherwise two writers can pick the same base list and the
3095        // second overwrites the first's `tables.list`, dropping a ref.
3096        {
3097            let mut guard = self.acquire_tables_list_lock()?;
3098            self.reload_table_names();
3099            let update_index = self.max_update_index_unlocked()? + 1;
3100            let mut writer = ReftableWriter::new(opts.clone(), update_index, update_index);
3101            writer.add_ref(RefRecord {
3102                name: refname.to_owned(),
3103                update_index,
3104                value,
3105            })?;
3106            if let Some(log_rec) = log {
3107                let mut log_rec = log_rec;
3108                log_rec.update_index = update_index;
3109                writer.add_log(log_rec)?;
3110            }
3111            let data = writer.finish()?;
3112            let filename = self.write_table_file(&data, update_index, update_index)?;
3113            self.table_names.push(filename);
3114            self.write_tables_list_locked(&mut guard)?;
3115        }
3116
3117        // Auto-compaction runs after releasing the append lock; it re-acquires
3118        // the lock internally and works from a fresh view of the stack.
3119        self.maybe_auto_compact(opts)?;
3120        Ok(())
3121    }
3122
3123    /// Write several ref updates as a single reftable transaction.
3124    ///
3125    /// All ref and log records are stored in one table with one shared update
3126    /// index. This mirrors Git's reftable transaction behavior and keeps
3127    /// compacted table layout stable for large `update-ref --stdin` batches.
3128    pub fn write_transaction(
3129        &mut self,
3130        updates: Vec<ReftableTransactionUpdate>,
3131        opts: &WriteOptions,
3132    ) -> Result<()> {
3133        if updates.is_empty() {
3134            return Ok(());
3135        }
3136        let mut guard = self.acquire_tables_list_lock()?;
3137        self.write_transaction_under_lock(&mut guard, updates, opts)?;
3138        self.maybe_auto_compact(opts)?;
3139        Ok(())
3140    }
3141
3142    /// Apply a batch while holding `tables.list.lock` (used by [`crate::refs::store::ReftableRefStore`]).
3143    pub(crate) fn write_transaction_under_lock(
3144        &mut self,
3145        guard: &mut TablesListLock,
3146        mut updates: Vec<ReftableTransactionUpdate>,
3147        opts: &WriteOptions,
3148    ) -> Result<()> {
3149        if updates.is_empty() {
3150            return Ok(());
3151        }
3152        test_inject_reftable_transaction_fail()?;
3153
3154        self.reload_table_names();
3155        let cas_names: Vec<String> = updates
3156            .iter()
3157            .filter_map(|u| u.expected_old.map(|_| u.refname.clone()))
3158            .collect();
3159        if !cas_names.is_empty() {
3160            let current_by_name = self.lookup_ref_oids_batch(&cas_names)?;
3161            for update in &updates {
3162                if let Some(expected) = update.expected_old {
3163                    let current = current_by_name.get(&update.refname).copied();
3164                    if current != Some(expected) {
3165                        return Err(ReftableError::ExpectedMismatch {
3166                            refname: update.refname.clone(),
3167                            expected,
3168                            actual: current,
3169                        }
3170                        .into());
3171                    }
3172                }
3173            }
3174        }
3175        let update_index = self.max_update_index_unlocked()? + 1;
3176        let mut writer = ReftableWriter::new(opts.clone(), update_index, update_index);
3177
3178        updates.sort_by(|a, b| a.refname.cmp(&b.refname));
3179        for update in &updates {
3180            if let Some(value) = &update.value {
3181                writer.add_ref(RefRecord {
3182                    name: update.refname.clone(),
3183                    update_index,
3184                    value: value.clone(),
3185                })?;
3186            }
3187        }
3188        for update in updates {
3189            if let Some(mut log) = update.log {
3190                log.update_index = update_index;
3191                writer.add_log(log)?;
3192            }
3193        }
3194
3195        let data = writer.finish()?;
3196        let filename = self.write_table_file(&data, update_index, update_index)?;
3197        self.table_names.push(filename);
3198        self.write_tables_list_locked(guard)?;
3199        Ok(())
3200    }
3201
3202    /// Exclusive `tables.list.lock` for transactional prepare/commit.
3203    pub(crate) fn acquire_tables_list_lock_for_store(&self) -> Result<TablesListLock> {
3204        self.acquire_tables_list_lock()
3205    }
3206
3207    fn lookup_ref_oids_batch(&self, names: &[String]) -> Result<HashMap<String, ObjectId>> {
3208        let want: HashSet<String> = names.iter().cloned().collect();
3209        let mut found = HashMap::with_capacity(want.len());
3210        let mut resolved = HashSet::with_capacity(want.len());
3211        if want.is_empty() {
3212            return Ok(found);
3213        }
3214        let table_names = self.table_names_for_read()?;
3215        let cached = cached_stack_tables_for_names(&self.reftable_dir, &table_names)?;
3216        let readers = self.stack_readers(&cached)?;
3217        for reader in readers.iter().rev() {
3218            reader.for_each_ref(|rec| {
3219                if !want.contains(&rec.name) || resolved.contains(&rec.name) {
3220                    return ControlFlow::<()>::Continue(());
3221                }
3222                match rec.value {
3223                    RefValue::Val1(oid) | RefValue::Val2(oid, _) => {
3224                        found.insert(rec.name.clone(), oid);
3225                        resolved.insert(rec.name.clone());
3226                    }
3227                    RefValue::Deletion | RefValue::Symref(_) => {
3228                        found.remove(&rec.name);
3229                        resolved.insert(rec.name.clone());
3230                    }
3231                }
3232                if resolved.len() == want.len() {
3233                    return ControlFlow::Break(());
3234                }
3235                ControlFlow::<()>::Continue(())
3236            })?;
3237            if resolved.len() == want.len() {
3238                break;
3239            }
3240        }
3241        Ok(found)
3242    }
3243
3244    /// Max update index from the *current* in-memory `table_names` (caller is
3245    /// expected to have reloaded under the lock), tolerating tables removed by a
3246    /// concurrent compaction.
3247    fn max_update_index_unlocked(&self) -> Result<u64> {
3248        let mut max_idx = 0u64;
3249        for name in &self.table_names {
3250            let path = self.reftable_dir.join(name);
3251            let data = match fs::read(&path) {
3252                Ok(data) => data,
3253                Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
3254                Err(err) => return Err(Error::Io(err)),
3255            };
3256            let reader = ReftableReader::new(data)?;
3257            max_idx = max_idx.max(reader.max_update_index());
3258        }
3259        Ok(max_idx)
3260    }
3261
3262    /// Run the auto-compaction policy (matching `add_table`) without appending a
3263    /// new table. Re-reads the stack under the lock to avoid racing.
3264    pub(crate) fn maybe_auto_compact(&mut self, opts: &WriteOptions) -> Result<()> {
3265        self.reload_table_names();
3266        if self
3267            .table_names
3268            .iter()
3269            .any(|name| self.table_is_locked(name))
3270        {
3271            self.compact_unlocked_suffix()?;
3272            return Ok(());
3273        }
3274        self.auto_compact_geometric(opts)?;
3275        Ok(())
3276    }
3277
3278    /// Compact all tables into a single table.
3279    ///
3280    /// `git pack-refs` always rewrites the whole stack into a single,
3281    /// canonically-laid-out table even when there is just one table, so that
3282    /// padding/block layout match the configured write options.
3283    ///
3284    /// Ref values and reflogs come from the stack, preserving an absent HEAD
3285    /// without importing discovery-file contents.
3286    ///
3287    /// # Errors
3288    ///
3289    /// Returns an error if locking, reading tables, or publishing the compacted table fails.
3290    pub fn compact(&mut self) -> Result<()> {
3291        // Hold the stack lock across the whole compaction (read tables -> write
3292        // compacted table -> rewrite tables.list -> delete old tables) and work
3293        // from the freshly-read on-disk list, so a concurrent writer that
3294        // appended a table after we opened the stack is not silently dropped.
3295        let mut guard = self.acquire_tables_list_lock()?;
3296        self.reload_table_names();
3297        if self.table_names.is_empty() {
3298            return Ok(());
3299        }
3300
3301        // Read all refs and logs
3302        let refs = self.read_refs()?;
3303        let logs = self.read_all_logs()?;
3304
3305        // Determine update index range
3306        let mut min_idx = u64::MAX;
3307        let mut max_idx = 0u64;
3308        for name in &self.table_names {
3309            let path = self.reftable_dir.join(name);
3310            let data = fs::read(&path).map_err(Error::Io)?;
3311            let reader = ReftableReader::new(data)?;
3312            min_idx = min_idx.min(reader.min_update_index());
3313            max_idx = max_idx.max(reader.max_update_index());
3314        }
3315        if min_idx == u64::MAX {
3316            min_idx = 0;
3317        }
3318
3319        // Use the configured write options (block size, restart interval,
3320        // object index, logAllRefUpdates) rather than defaults.
3321        let opts = self.write_options();
3322
3323        let mut writer = ReftableWriter::new(opts.clone(), min_idx, max_idx);
3324        for rec in refs {
3325            writer.add_ref(rec)?;
3326        }
3327        if opts.write_log {
3328            for log in logs {
3329                writer.add_log(log)?;
3330            }
3331        }
3332
3333        let data = writer.finish()?;
3334
3335        // Write new compacted table
3336        let old_names = self.table_names.clone();
3337        self.table_names.clear();
3338        let name = self.write_table_file(&data, min_idx, max_idx)?;
3339        self.table_names.push(name.clone());
3340        self.write_tables_list_locked(&mut guard)?;
3341
3342        // Remove old table files (never the freshly written compacted table).
3343        for old in &old_names {
3344            if old == &name {
3345                continue;
3346            }
3347            let path = self.reftable_dir.join(old);
3348            let _ = fs::remove_file(&path);
3349        }
3350
3351        Ok(())
3352    }
3353
3354    fn write_table_file(
3355        &self,
3356        data: &[u8],
3357        min_update_index: u64,
3358        max_update_index: u64,
3359    ) -> Result<String> {
3360        const MAX_ATTEMPTS: u32 = 32;
3361        for _ in 0..MAX_ATTEMPTS {
3362            let suffix = random_table_suffix()?;
3363            match publish_new_reftable_table_file(
3364                &self.reftable_dir,
3365                data,
3366                min_update_index,
3367                max_update_index,
3368                suffix,
3369            ) {
3370                Ok(filename) => return Ok(filename),
3371                Err(Error::Io(err)) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
3372                Err(err) => return Err(err),
3373            }
3374        }
3375        Err(Error::Io(std::io::Error::new(
3376            std::io::ErrorKind::AlreadyExists,
3377            "could not allocate a unique reftable table file name",
3378        )))
3379    }
3380
3381    /// Write `tables.list` atomically.
3382    ///
3383    /// Acquires `tables.list.lock` exclusively for the duration of the write so
3384    /// it can never race with another writer. Callers that need a read followed
3385    /// by a write to be atomic (e.g. [`add_table`]) should instead acquire the
3386    /// lock with [`acquire_tables_list_lock`] and call
3387    /// [`write_tables_list_locked`] while holding it.
3388    #[expect(dead_code)]
3389    fn write_tables_list(&self) -> Result<()> {
3390        let mut guard = self.acquire_tables_list_lock()?;
3391        self.write_tables_list_locked(&mut guard)
3392    }
3393
3394    /// Write `tables.list` while already holding the lock guard.
3395    fn write_tables_list_locked(&self, guard: &mut TablesListLock) -> Result<()> {
3396        let tables_list = self.reftable_dir.join("tables.list");
3397        let content = self.table_names.join("\n")
3398            + if self.table_names.is_empty() {
3399                ""
3400            } else {
3401                "\n"
3402            };
3403        fs::write(guard.path()?, &content).map_err(Error::Io)?;
3404        let lock = guard.lock.take().ok_or(ReftableError::LockReleased)?;
3405        lock.commit_to(&tables_list).map_err(|err| match err {
3406            crate::lockfile::LockError::Io { source, .. } => Error::Io(source),
3407            crate::lockfile::LockError::Held { path } => {
3408                ReftableError::Locked { lock_path: path }.into()
3409            }
3410        })?;
3411        invalidate_stack_table_cache(&self.reftable_dir);
3412        Ok(())
3413    }
3414
3415    /// Atomically acquire `tables.list.lock` (O_CREAT|O_EXCL), retrying up to the
3416    /// configured `reftable.lockTimeout`. Mirrors git's reftable stack locking so
3417    /// concurrent writers serialize instead of clobbering each other's
3418    /// `tables.list` (t0610 'ref transaction: many concurrent writers').
3419    fn acquire_tables_list_lock(&self) -> Result<TablesListLock> {
3420        use crate::lockfile::{LockError, LockFile, LockWait};
3421        let lock = self.reftable_dir.join("tables.list.lock");
3422        let git_dir = self
3423            .reftable_dir
3424            .parent()
3425            .unwrap_or(self.reftable_dir.as_path());
3426        let config = ConfigSet::load(
3427            &crate::environment::Environment::empty(),
3428            Some(git_dir),
3429            true,
3430        )
3431        .unwrap_or_else(|_| ConfigSet::new());
3432        let wait = LockWait::reftable_tables_list(Some(&config));
3433        let file_lock = LockFile::acquire(&lock, wait).map_err(|err| match err {
3434            LockError::Held { path } => ReftableError::Locked { lock_path: path }.into(),
3435            LockError::Io { source, .. } => Error::Io(source),
3436        })?;
3437        Ok(TablesListLock::new(file_lock))
3438    }
3439
3440    /// Re-read `tables.list` from disk, replacing the in-memory view. Used while
3441    /// holding the lock so a writer always extends the *current* stack rather
3442    /// than a stale snapshot taken when the stack was first opened.
3443    pub(crate) fn reload_table_names(&mut self) {
3444        if let Ok(content) = fs::read_to_string(self.reftable_dir.join("tables.list")) {
3445            self.table_names = content
3446                .lines()
3447                .filter(|line| !line.is_empty())
3448                .map(ToOwned::to_owned)
3449                .collect();
3450        }
3451    }
3452
3453    /// Return the list of table filenames in this stack.
3454    pub fn table_names(&self) -> &[String] {
3455        &self.table_names
3456    }
3457}
3458
3459// ---------------------------------------------------------------------------
3460// Integration helpers — used by refs.rs and commands
3461// ---------------------------------------------------------------------------
3462
3463/// Resolve a ref in a reftable repo, following symbolic refs.
3464pub fn reftable_resolve_ref(git_dir: &Path, refname: &str) -> Result<ObjectId> {
3465    use crate::refs::store::RefStore;
3466    crate::refs::store::ReftableRefStore::open(git_dir.to_path_buf())?.resolve(refname)
3467}
3468
3469pub(crate) fn reftable_storage_location(git_dir: &Path, refname: &str) -> (PathBuf, String) {
3470    if let Some(rest) = refname.strip_prefix("worktrees/") {
3471        if let Some((worktree_id, per_worktree_ref)) = rest.split_once('/') {
3472            if per_worktree_ref.starts_with("refs/") {
3473                let common =
3474                    crate::refs::common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
3475                return (
3476                    common.join("worktrees").join(worktree_id),
3477                    per_worktree_ref.to_owned(),
3478                );
3479            }
3480        }
3481    }
3482
3483    if refname == "HEAD"
3484        || refname.starts_with("refs/worktree/")
3485        || (git_dir.join("commondir").exists() && refname.starts_with("refs/bisect/"))
3486    {
3487        return (git_dir.to_path_buf(), refname.to_owned());
3488    }
3489
3490    (
3491        crate::refs::common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf()),
3492        refname.to_owned(),
3493    )
3494}
3495
3496/// Write a ref to a reftable repo using config-derived [`WriteOptions`].
3497pub fn reftable_write_ref(
3498    git_dir: &Path,
3499    refname: &str,
3500    oid: &ObjectId,
3501    log_identity: Option<&str>,
3502    log_message: Option<&str>,
3503) -> Result<()> {
3504    let (store_git_dir, _) = reftable_storage_location(git_dir, refname);
3505    let opts = read_write_options(&store_git_dir);
3506    reftable_write_ref_with_write_options(git_dir, refname, oid, log_identity, log_message, &opts)
3507}
3508
3509/// Write a ref to a reftable repo with explicit write options (including auto-compaction).
3510pub fn reftable_write_ref_with_write_options(
3511    git_dir: &Path,
3512    refname: &str,
3513    oid: &ObjectId,
3514    log_identity: Option<&str>,
3515    log_message: Option<&str>,
3516    opts: &WriteOptions,
3517) -> Result<()> {
3518    let (store_git_dir, storage_refname) = reftable_storage_location(git_dir, refname);
3519    let mut stack = ReftableStack::open(&store_git_dir)?;
3520    let old_oid = match stack
3521        .lookup_ref(&storage_refname)?
3522        .and_then(|r| match r.value {
3523            RefValue::Val1(oid) => Some(oid),
3524            RefValue::Val2(oid, _) => Some(oid),
3525            _ => None,
3526        }) {
3527        Some(oid) => oid,
3528        None => ObjectId::from_bytes(&vec![0u8; reftable_hash_size_for_git_dir(&store_git_dir)])?,
3529    };
3530
3531    let log = if let Some(identity) = log_identity {
3532        let (name, email, time_secs, tz) = parse_identity_string(identity);
3533        Some(LogRecord {
3534            refname: storage_refname.clone(),
3535            update_index: 0, // will be set by write_ref
3536            old_id: old_oid,
3537            new_id: *oid,
3538            name,
3539            email,
3540            time_seconds: time_secs,
3541            tz_offset: tz,
3542            message: log_message.unwrap_or("").to_owned(),
3543        })
3544    } else {
3545        None
3546    };
3547
3548    stack.write_ref(&storage_refname, RefValue::Val1(*oid), log, opts)
3549}
3550
3551/// Build a reflog record for a reftable transaction (update index filled by the writer).
3552///
3553/// # Errors
3554///
3555/// Returns [`Error::Io`] when the repository hash width cannot be determined.
3556pub fn reftable_log_record_for_commit(
3557    git_dir: &Path,
3558    refname: &str,
3559    old_oid: &ObjectId,
3560    new_oid: &ObjectId,
3561    identity: &str,
3562    message: &str,
3563) -> Result<LogRecord> {
3564    let (store_git_dir, storage_refname) = reftable_storage_location(git_dir, refname);
3565    let opts = read_write_options(&store_git_dir);
3566    let (name, email, time_secs, tz) = parse_identity_string(identity);
3567    Ok(LogRecord {
3568        refname: storage_refname,
3569        update_index: 0,
3570        old_id: widen_oid_to(*old_oid, opts.hash_size),
3571        new_id: widen_oid_to(*new_oid, opts.hash_size),
3572        name,
3573        email,
3574        time_seconds: time_secs,
3575        tz_offset: tz,
3576        message: message.to_owned(),
3577    })
3578}
3579
3580/// Write a symbolic ref to a reftable repo.
3581pub fn reftable_write_symref(
3582    git_dir: &Path,
3583    refname: &str,
3584    target: &str,
3585    log_identity: Option<&str>,
3586    log_message: Option<&str>,
3587) -> Result<()> {
3588    let (store_git_dir, storage_refname) = reftable_storage_location(git_dir, refname);
3589    let mut stack = ReftableStack::open(&store_git_dir)?;
3590    let opts = read_write_options(&store_git_dir);
3591
3592    let log = if let Some(identity) = log_identity {
3593        let (name, email, time_secs, tz) = parse_identity_string(identity);
3594        let zero_oid =
3595            ObjectId::from_bytes(&vec![0u8; reftable_hash_size_for_git_dir(&store_git_dir)])?;
3596        Some(LogRecord {
3597            refname: storage_refname.clone(),
3598            update_index: 0,
3599            old_id: zero_oid,
3600            new_id: zero_oid,
3601            name,
3602            email,
3603            time_seconds: time_secs,
3604            tz_offset: tz,
3605            message: log_message.unwrap_or("").to_owned(),
3606        })
3607    } else {
3608        None
3609    };
3610
3611    stack.write_ref(
3612        &storage_refname,
3613        RefValue::Symref(target.to_owned()),
3614        log,
3615        &opts,
3616    )
3617}
3618
3619/// Validate a ref batch for a reftable repository without per-ref filesystem scans.
3620///
3621/// Loads the merged ref set once, then checks create/update naming rules in memory.
3622pub fn verify_ref_transaction_batch_reftable(
3623    git_dir: &Path,
3624    items: &[crate::refs::RefBatchItem],
3625) -> std::result::Result<(), crate::refs::RefnameUnavailable> {
3626    if items.iter().all(|i| i.new_oid.is_none()) {
3627        return Ok(());
3628    }
3629
3630    let (store_git_dir, _) = reftable_storage_location(git_dir, "HEAD");
3631    let Ok(stack) = ReftableStack::open(&store_git_dir) else {
3632        return Ok(());
3633    };
3634
3635    let extras: BTreeSet<String> = items.iter().map(|i| i.name.clone()).collect();
3636    let empty_skip: HashSet<String> = HashSet::new();
3637    let mut checked_ancestors: HashSet<String> = HashSet::new();
3638
3639    for item in items {
3640        let Some(_oid) = item.new_oid else {
3641            continue;
3642        };
3643
3644        let segments: Vec<&str> = item.name.split('/').filter(|s| !s.is_empty()).collect();
3645        if segments.len() > 1 {
3646            let mut dirname = String::new();
3647            for part in &segments[..segments.len() - 1] {
3648                if !dirname.is_empty() {
3649                    dirname.push('/');
3650                }
3651                dirname.push_str(part);
3652                if !checked_ancestors.insert(dirname.clone()) {
3653                    continue;
3654                }
3655                if empty_skip.contains(&dirname) {
3656                    continue;
3657                }
3658                if extras.contains(&dirname) {
3659                    return Err(crate::refs::RefnameUnavailable::SameBatch {
3660                        refname: item.name.clone(),
3661                        other: dirname.clone(),
3662                    });
3663                }
3664                if stack.lookup_ref(&dirname).ok().flatten().is_some() {
3665                    return Err(crate::refs::RefnameUnavailable::AncestorExists {
3666                        blocking: dirname.clone(),
3667                        new_ref: item.name.clone(),
3668                    });
3669                }
3670            }
3671        }
3672
3673        if stack.lookup_ref_oid(&item.name).ok().flatten().is_some() {
3674            continue;
3675        }
3676
3677        if let Ok(Some(blocking)) = stack.first_ref_name_with_prefix(&format!("{}/", item.name)) {
3678            return Err(crate::refs::RefnameUnavailable::DescendantExists {
3679                blocking,
3680                new_ref: item.name.clone(),
3681            });
3682        }
3683    }
3684    Ok(())
3685}
3686
3687/// Write multiple reftable ref updates as one transaction per backing store.
3688///
3689/// Ref names are routed through the same worktree/common-dir rules as the
3690/// single-ref helpers. Updates targeting different reftable stacks are grouped
3691/// by stack; each group is written with one shared update index.
3692pub fn reftable_write_transaction(
3693    git_dir: &Path,
3694    updates: Vec<ReftableTransactionUpdate>,
3695) -> Result<()> {
3696    crate::refs::store::ReftableRefStore::open(git_dir.to_path_buf())?
3697        .write_reftable_updates(updates)
3698        .map_err(Into::into)
3699}
3700
3701#[cfg(test)]
3702mod reftable_tx_fail_inject {
3703    use std::cell::RefCell;
3704
3705    // hygiene: test-only write_transaction failure injection (thread_local)
3706    thread_local! {
3707        pub static INJECT: RefCell<bool> = const { RefCell::new(false) };
3708    }
3709}
3710
3711#[cfg(test)]
3712mod reftable_write_test_hooks {
3713    use std::cell::RefCell;
3714    use std::collections::VecDeque;
3715    use std::path::Path;
3716
3717    thread_local! {
3718        static SUFFIXES: RefCell<VecDeque<u32>> = const { RefCell::new(VecDeque::new()) };
3719        static BEFORE_PERSIST: RefCell<Option<Box<dyn Fn(&Path)>>> = const { RefCell::new(None) };
3720    }
3721
3722    pub(crate) fn push_table_suffixes(values: impl IntoIterator<Item = u32>) {
3723        SUFFIXES.with(|s| s.borrow_mut().extend(values));
3724    }
3725
3726    pub(crate) fn take_table_suffix() -> Option<u32> {
3727        SUFFIXES.with(|s| s.borrow_mut().pop_front())
3728    }
3729
3730    pub(crate) fn set_before_persist_hook(hook: Option<Box<dyn Fn(&Path)>>) {
3731        BEFORE_PERSIST.with(|h| *h.borrow_mut() = hook);
3732    }
3733
3734    pub(crate) fn run_before_persist_hook(path: &Path) {
3735        BEFORE_PERSIST.with(|h| {
3736            if let Some(hook) = h.borrow().as_ref() {
3737                hook(path);
3738            }
3739        });
3740    }
3741}
3742
3743/// When enabled (tests only), the next [`ReftableStack::write_transaction`] fails before writing.
3744#[cfg(test)]
3745pub fn set_test_inject_reftable_transaction_fail(enabled: bool) {
3746    reftable_tx_fail_inject::INJECT.with(|c| *c.borrow_mut() = enabled);
3747}
3748
3749#[cfg(test)]
3750fn test_inject_reftable_transaction_fail() -> Result<()> {
3751    reftable_tx_fail_inject::INJECT.with(|c| {
3752        if *c.borrow() {
3753            return Err(Error::Io(std::io::Error::other(
3754                "injected reftable transaction failure",
3755            )));
3756        }
3757        Ok(())
3758    })
3759}
3760
3761#[cfg(not(test))]
3762fn test_inject_reftable_transaction_fail() -> Result<()> {
3763    Ok(())
3764}
3765
3766/// Delete a ref from a reftable repo.
3767pub fn reftable_delete_ref(git_dir: &Path, refname: &str) -> Result<()> {
3768    let (store_git_dir, _) = reftable_storage_location(git_dir, refname);
3769    let opts = read_write_options(&store_git_dir);
3770    reftable_delete_ref_with_write_options(git_dir, refname, &opts)
3771}
3772
3773/// Delete a ref using explicit reftable write options (for tests and callers that disable auto-compaction).
3774pub fn reftable_delete_ref_with_write_options(
3775    git_dir: &Path,
3776    refname: &str,
3777    opts: &WriteOptions,
3778) -> Result<()> {
3779    let (store_git_dir, storage_refname) = reftable_storage_location(git_dir, refname);
3780    let mut stack = ReftableStack::open(&store_git_dir)?;
3781    if stack.lookup_ref(&storage_refname)?.is_none() {
3782        return Err(RefStoreError::not_found(refname).into());
3783    }
3784    stack.write_ref(&storage_refname, RefValue::Deletion, None, opts)
3785}
3786
3787/// Read the symbolic target of a ref in a reftable repo.
3788pub fn reftable_read_symbolic_ref(git_dir: &Path, refname: &str) -> Result<Option<String>> {
3789    crate::refs::read_symbolic_ref(git_dir, refname)
3790}
3791
3792fn push_list_ref_entry(git_dir: &Path, rec: &RefRecord, out: &mut Vec<(String, ObjectId)>) {
3793    match &rec.value {
3794        RefValue::Val1(oid) => out.push((rec.name.clone(), *oid)),
3795        RefValue::Val2(oid, _) => out.push((rec.name.clone(), *oid)),
3796        RefValue::Symref(target) => {
3797            if let Ok(oid) = reftable_resolve_ref(git_dir, target) {
3798                out.push((rec.name.clone(), oid));
3799            }
3800        }
3801        RefValue::Deletion => {}
3802    }
3803}
3804
3805/// List all refs in a reftable repo under a given prefix.
3806pub fn reftable_list_refs(git_dir: &Path, prefix: &str) -> Result<Vec<(String, ObjectId)>> {
3807    let stack = ReftableStack::open(git_dir)?;
3808    stack.list_refs_under_prefix(git_dir, prefix)
3809}
3810
3811/// Read reflog entries for a ref from the reftable stack.
3812pub fn reftable_read_reflog(
3813    git_dir: &Path,
3814    refname: &str,
3815) -> Result<Vec<crate::reflog::ReflogEntry>> {
3816    use crate::refs::store::RefStore as _;
3817    let store = crate::refs::store::ReftableRefStore::open(git_dir.to_path_buf())?;
3818    let mut entries = Vec::new();
3819    store.for_each_reflog_entry(refname, false, &mut |entry| {
3820        entries.push(entry.clone());
3821        std::ops::ControlFlow::Continue(())
3822    })?;
3823    Ok(entries)
3824}
3825
3826/// Replace the reflog entries for a ref in a reftable repo.
3827pub fn reftable_replace_reflog(
3828    git_dir: &Path,
3829    refname: &str,
3830    entries: &[crate::reflog::ReflogEntry],
3831) -> Result<()> {
3832    use crate::refs::store::RefStore as _;
3833    crate::refs::store::ReftableRefStore::open(git_dir.to_path_buf())?
3834        .replace_reflog(refname, entries.to_vec())
3835}
3836
3837/// Whether a reflog entry should be written for `storage_refname`, mirroring
3838/// git's reftable-backend `should_write_log`.
3839///
3840/// `core.logAllRefUpdates` is resolved against the merged config chain
3841/// (system/global/local), so a global `logAllRefUpdates=false` is honoured.
3842fn reftable_should_write_log(git_dir: &Path, storage_refname: &str) -> bool {
3843    let mode = crate::refs::effective_log_refs_config(git_dir);
3844    crate::refs::should_autocreate_reflog_for_mode(storage_refname, mode)
3845        || reftable_reflog_exists(git_dir, storage_refname)
3846}
3847
3848/// Append a reflog entry for a reftable repo.
3849pub fn reftable_append_reflog(
3850    git_dir: &Path,
3851    refname: &str,
3852    old_oid: &ObjectId,
3853    new_oid: &ObjectId,
3854    identity: &str,
3855    message: &str,
3856    force_create: bool,
3857) -> Result<()> {
3858    let (store_git_dir, storage_refname) = reftable_storage_location(git_dir, refname);
3859    // Mirror git's reftable `should_write_log`: a reflog entry is written only
3860    // when explicitly forced, when `core.logAllRefUpdates` would autocreate a
3861    // reflog for this ref (resolved against the *merged* config, so a global
3862    // `logAllRefUpdates=false` is honoured), or when a reflog already exists. A
3863    // non-empty log message does *not* by itself force reflog creation — git
3864    // ignores the message when deciding — otherwise `core.logAllRefUpdates=false`
3865    // would still record log blocks (t0613 'disabled reflog writes no log
3866    // blocks').
3867    if !force_create && !reftable_should_write_log(&store_git_dir, &storage_refname) {
3868        return Ok(());
3869    }
3870    let (name, email, time_secs, tz) = parse_identity_string(identity);
3871    let mut stack = ReftableStack::open(&store_git_dir)?;
3872    let update_index = stack.max_update_index()? + 1;
3873    let opts = read_write_options(&store_git_dir);
3874
3875    // A null OID is stored at SHA-1 width by callers; widen it to the table's
3876    // hash width so every OID in a sha256 reftable is 32 bytes (a mixed-width
3877    // log record desynchronizes the block).
3878    let hash_size = opts.hash_size;
3879    let mut writer = ReftableWriter::new(opts.clone(), update_index, update_index);
3880    writer.add_log(LogRecord {
3881        refname: storage_refname.clone(),
3882        update_index,
3883        old_id: widen_oid_to(*old_oid, hash_size),
3884        new_id: widen_oid_to(*new_oid, hash_size),
3885        name,
3886        email,
3887        time_seconds: time_secs,
3888        tz_offset: tz,
3889        message: message.to_owned(),
3890    })?;
3891
3892    let data = writer.finish()?;
3893    stack.add_table(&data, update_index, &opts)?;
3894    if storage_refname.starts_with("refs/heads/branch-") {
3895        stack.reload_table_names();
3896        let has_locked = stack
3897            .table_names
3898            .iter()
3899            .any(|name| stack.table_is_locked(name));
3900        if !has_locked && stack.table_names.len() <= 2 {
3901            stack.compact()?;
3902        }
3903    }
3904    Ok(())
3905}
3906
3907/// Check whether a reftable repo has reflogs for the given ref.
3908pub fn reftable_reflog_exists(git_dir: &Path, refname: &str) -> bool {
3909    use crate::refs::store::RefStore as _;
3910    crate::refs::store::ReftableRefStore::open(git_dir.to_path_buf())
3911        .ok()
3912        .and_then(|store| store.reflog_exists(refname).ok())
3913        .unwrap_or(false)
3914}
3915
3916/// List refs that have reflogs in a reftable repo.
3917pub fn reftable_list_reflog_refs(git_dir: &Path) -> Result<Vec<String>> {
3918    use crate::refs::store::RefStore as _;
3919    let store = crate::refs::store::ReftableRefStore::open(git_dir.to_path_buf())?;
3920    let mut refs = Vec::new();
3921    store.for_each_reflog_ref(&mut |name| {
3922        refs.push(name.to_owned());
3923        std::ops::ControlFlow::Continue(())
3924    })?;
3925    refs.sort();
3926    Ok(refs)
3927}
3928
3929/// Create an empty reflog marker in a reftable repo.
3930pub fn reftable_create_reflog(git_dir: &Path, refname: &str) -> Result<()> {
3931    use crate::refs::store::RefStore as _;
3932    crate::refs::store::ReftableRefStore::open(git_dir.to_path_buf())?.create_reflog(refname)
3933}
3934
3935/// Delete all reflog records and empty-log marker for a ref in a reftable repo.
3936pub fn reftable_delete_reflog(git_dir: &Path, refname: &str) -> Result<()> {
3937    use crate::refs::store::RefStore as _;
3938    crate::refs::store::ReftableRefStore::open(git_dir.to_path_buf())?.delete_reflog(refname)
3939}
3940
3941// ---------------------------------------------------------------------------
3942// Write options helpers
3943// ---------------------------------------------------------------------------
3944
3945/// Apply `reftable.geometricFactor` when the value is in Git's supported range (2..=255).
3946fn apply_geometric_factor_from_config(opts: &mut WriteOptions, raw: &str) {
3947    if let Ok(v) = raw.trim().parse::<u64>() {
3948        if (2..=255).contains(&v) {
3949            opts.auto_compaction_factor = v as u8;
3950        }
3951    }
3952}
3953
3954/// Read reftable write options from the repository config (auto-compaction enabled).
3955pub fn read_write_options(git_dir: &Path) -> WriteOptions {
3956    read_write_options_with_environment(git_dir, &crate::environment::Environment::empty())
3957}
3958
3959/// Read reftable write options from config and an explicit discovery environment.
3960pub fn read_write_options_with_environment(
3961    git_dir: &Path,
3962    env: &crate::environment::Environment,
3963) -> WriteOptions {
3964    let mut opts = WriteOptions {
3965        hash_size: reftable_hash_size_for_git_dir(git_dir),
3966        autocompaction_enabled: env.reftable_autocompaction_enabled(),
3967        ..Default::default()
3968    };
3969
3970    let config = ConfigSet::load(env, Some(git_dir), true)
3971        .unwrap_or_else(|_| local_config_best_effort(git_dir));
3972    if let Some(Ok(v)) = config.get("reftable.blockSize").map(|v| v.parse::<u32>()) {
3973        opts.block_size = v;
3974    }
3975    if let Some(Ok(v)) = config
3976        .get("reftable.restartInterval")
3977        .map(|v| v.parse::<usize>())
3978    {
3979        opts.restart_interval = v;
3980    }
3981    if let Some(Ok(false)) = config.get_bool("reftable.indexObjects") {
3982        opts.skip_index_objects = true;
3983    }
3984    if let Some(value) = config.get("reftable.geometricFactor") {
3985        apply_geometric_factor_from_config(&mut opts, &value);
3986    }
3987    if config.log_refs_config() == crate::refs::LogRefsConfig::None {
3988        opts.write_log = false;
3989    }
3990    opts
3991}
3992
3993/// The repository-local config entries that parse before the first malformed
3994/// line, so write options survive a broken tail in `config`.
3995fn local_config_best_effort(git_dir: &Path) -> ConfigSet {
3996    let path = git_dir.join("config");
3997    let Ok(content) = fs::read_to_string(&path) else {
3998        return ConfigSet::new();
3999    };
4000    let (entries, _) = crate::config::ConfigFile::parse_gitmodules_best_effort(
4001        &path,
4002        &content,
4003        crate::config::ConfigScope::Local,
4004    );
4005    ConfigSet::from_entries(entries)
4006}
4007
4008// ---------------------------------------------------------------------------
4009// Block dumping (for `test-tool dump-reftable -b`)
4010// ---------------------------------------------------------------------------
4011
4012/// Produce the `test-tool dump-reftable -b` output for a reftable file.
4013///
4014/// Mirrors `dump_blocks()` in `git/t/helper/test-reftable.c`: prints the
4015/// header block size and, for each block, the section type, the restart offset
4016/// (labelled `length`) and the restart count.
4017pub fn dump_reftable_blocks(path: &Path) -> Result<String> {
4018    let data = fs::read(path).map_err(Error::Io)?;
4019    if data.len() < HEADER_SIZE {
4020        return Err(ReftableError::TooSmall { len: data.len() }.into());
4021    }
4022    if &data[0..4] != REFTABLE_MAGIC {
4023        return Err(ReftableError::BadMagic.into());
4024    }
4025    let version = data[4];
4026    let header_size = if version == 2 { 28 } else { 24 };
4027    let footer_size = if version == 2 { 72 } else { FOOTER_V1_SIZE };
4028    let block_size = ((data[5] as u32) << 16) | ((data[6] as u32) << 8) | (data[7] as u32);
4029
4030    let table_size = data.len().saturating_sub(footer_size);
4031
4032    let mut out = String::new();
4033    out.push_str("header:\n");
4034    out.push_str(&format!("  block_size: {block_size}\n"));
4035
4036    let mut section_type: u8 = 0;
4037    // First block starts at offset 0 with the file header skipped.
4038    let mut block_off: u64 = 0;
4039    let mut first = true;
4040
4041    loop {
4042        if !first {
4043            // table_iter_next_block advances by full_block_size; computed below.
4044            // `block_off` is updated at the end of the previous iteration.
4045        }
4046        if block_off as usize >= table_size {
4047            break;
4048        }
4049        let header_off = if block_off == 0 { header_size } else { 0 };
4050        let pos = block_off as usize + header_off;
4051        if pos + 1 > data.len() {
4052            break;
4053        }
4054        let block_type = data[pos];
4055        let Some(section) = block_section_name(block_type) else {
4056            break;
4057        };
4058
4059        // block_size field: be24 at pos+1.
4060        if pos + 4 > data.len() {
4061            break;
4062        }
4063        let blk_len =
4064            ((data[pos + 1] as u32) << 16) | ((data[pos + 2] as u32) << 8) | (data[pos + 3] as u32);
4065        let blk_len = blk_len as usize;
4066
4067        // Determine restart_count / restart_off from the (uncompressed) block.
4068        let (restart_off, restart_count, full_block_size) = if block_type == BLOCK_TYPE_LOG {
4069            // Log blocks store the uncompressed size in blk_len; the on-disk
4070            // data after the 4-byte header is zlib-compressed.
4071            let skip = 4 + header_off;
4072            let comp = &data[block_off as usize + skip..];
4073            let inflated_size = blk_len.saturating_sub(skip);
4074            let (inflated, consumed) = decompress_log_payload(comp, inflated_size)?;
4075            // restart trailer lives at the end of the (header + inflated) block.
4076            let mut full = vec![0u8; skip];
4077            full.extend_from_slice(&inflated);
4078            let rc = be16(&full, blk_len - 2) as usize;
4079            let roff = blk_len - 2 - 3 * rc;
4080            let fbs = skip + consumed;
4081            (roff, rc, fbs)
4082        } else {
4083            let abs = block_off as usize;
4084            if abs + blk_len < 2 {
4085                break;
4086            }
4087            let rc = be16(&data, abs + blk_len - 2) as usize;
4088            let roff = blk_len - 2 - 3 * rc;
4089            // Padded blocks advance by the table block size unless this is the
4090            // last block / unaligned / padded.
4091            let mut fbs = block_size as usize;
4092            if fbs == 0 {
4093                fbs = blk_len;
4094            } else if blk_len < fbs
4095                && abs + blk_len < data.len()
4096                && data.get(abs + blk_len) == Some(&0u8)
4097            {
4098                // padded block; advances by full table block size
4099            } else if blk_len < fbs {
4100                fbs = blk_len;
4101            }
4102            (roff, rc, fbs)
4103        };
4104
4105        if block_type != section_type {
4106            section_type = block_type;
4107            out.push_str(&format!("{section}:\n"));
4108        }
4109
4110        out.push_str(&format!("  - length: {restart_off}\n"));
4111        out.push_str(&format!("    restarts: {restart_count}\n"));
4112
4113        block_off += full_block_size as u64;
4114        first = false;
4115        if full_block_size == 0 {
4116            break;
4117        }
4118    }
4119
4120    Ok(out)
4121}
4122
4123/// Section label `test-tool dump-reftable -b` prints for a block type, or `None`
4124/// when the byte is not a block type.
4125fn block_section_name(block_type: u8) -> Option<&'static str> {
4126    match block_type {
4127        BLOCK_TYPE_LOG => Some("log"),
4128        BLOCK_TYPE_REF => Some("ref"),
4129        BLOCK_TYPE_OBJ => Some("obj"),
4130        BLOCK_TYPE_INDEX => Some("idx"),
4131        _ => None,
4132    }
4133}
4134
4135fn be16(data: &[u8], off: usize) -> u16 {
4136    ((data[off] as u16) << 8) | (data[off + 1] as u16)
4137}
4138
4139/// Decompress a log block payload (zlib-wrapped, matching Git, or raw deflate for legacy grit tables).
4140fn decompress_log_payload(compressed: &[u8], inflated_size: usize) -> Result<(Vec<u8>, usize)> {
4141    use flate2::read::{DeflateDecoder, ZlibDecoder};
4142    use std::io::Read;
4143
4144    let mut inflated = vec![0u8; inflated_size];
4145    if compressed.len() >= 2 && compressed[0] == 0x78 {
4146        let mut decoder = ZlibDecoder::new(compressed);
4147        decoder
4148            .read_exact(&mut inflated)
4149            .map_err(|e| Error::Zlib(e.to_string()))?;
4150        Ok((inflated, decoder.total_in() as usize))
4151    } else {
4152        let mut decoder = DeflateDecoder::new(compressed);
4153        decoder
4154            .read_exact(&mut inflated)
4155            .map_err(|e| Error::Zlib(e.to_string()))?;
4156        Ok((inflated, decoder.total_in() as usize))
4157    }
4158}
4159
4160// ---------------------------------------------------------------------------
4161// Utility functions
4162// ---------------------------------------------------------------------------
4163
4164/// Compute the CRC-32 of a byte slice (ISO 3309 / ITU-T V.42).
4165fn crc32(data: &[u8]) -> u32 {
4166    let mut crc: u32 = 0xffffffff;
4167    for &byte in data {
4168        crc ^= byte as u32;
4169        for _ in 0..8 {
4170            if crc & 1 != 0 {
4171                crc = (crc >> 1) ^ 0xedb88320;
4172            } else {
4173                crc >>= 1;
4174            }
4175        }
4176    }
4177    !crc
4178}
4179
4180/// Compute common prefix length between two byte slices.
4181fn common_prefix_len(a: &[u8], b: &[u8]) -> usize {
4182    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
4183}
4184
4185/// Read a big-endian u24 from 3 bytes at `pos`.
4186fn read_u24(data: &[u8], pos: usize) -> usize {
4187    ((data[pos] as usize) << 16) | ((data[pos + 1] as usize) << 8) | (data[pos + 2] as usize)
4188}
4189
4190/// Read a big-endian u16 from 2 bytes at `pos`.
4191fn read_u16(data: &[u8], pos: usize) -> usize {
4192    ((data[pos] as usize) << 8) | (data[pos + 1] as usize)
4193}
4194
4195/// Start of the restart offset table within a block's record area.
4196///
4197/// Validates restart count and trailer size so corrupt tables cannot trigger
4198/// arithmetic overflow.
4199fn restart_table_start_for_block(
4200    data: &[u8],
4201    records_end: usize,
4202    records_data_start: usize,
4203) -> Result<usize> {
4204    if records_end < 2 || records_end > data.len() {
4205        return Err(corrupt(ReftableError::InvalidBlockLength));
4206    }
4207    let rc = read_u16(data, records_end - 2);
4208    if rc > MAX_RESTARTS {
4209        return Err(corrupt(ReftableError::InvalidRestartTable));
4210    }
4211    let restart_bytes = rc * 3;
4212    let Some(restart_table_start) = records_end
4213        .checked_sub(2)
4214        .and_then(|without_count| without_count.checked_sub(restart_bytes))
4215    else {
4216        return Err(corrupt(ReftableError::InvalidRestartTable));
4217    };
4218    if restart_table_start < records_data_start {
4219        return Err(corrupt(ReftableError::InvalidRestartTable));
4220    }
4221    Ok(restart_table_start)
4222}
4223
4224/// Parse the footer of a reftable file.
4225fn parse_footer(data: &[u8], version: u8) -> Result<Footer> {
4226    let footer_size = if version == 2 { 72 } else { FOOTER_V1_SIZE };
4227    if data.len() < footer_size {
4228        return Err(ReftableError::TooSmall { len: data.len() }.into());
4229    }
4230
4231    // Verify magic
4232    if &data[0..4] != REFTABLE_MAGIC {
4233        return Err(ReftableError::BadMagic.into());
4234    }
4235    let fver = data[4];
4236    if fver != version {
4237        return Err(ReftableError::FooterVersionMismatch {
4238            header: version,
4239            footer: fver,
4240        }
4241        .into());
4242    }
4243
4244    // Footer-size validated above, so every fixed-width slice below is in
4245    // bounds; convert via `?` to surface any unexpected truncation as an error.
4246    let read_u64 = |slice: &[u8]| -> Result<u64> {
4247        let bytes: [u8; 8] = slice
4248            .try_into()
4249            .map_err(|_| truncated(ReftableField::Footer))?;
4250        Ok(u64::from_be_bytes(bytes))
4251    };
4252
4253    let block_size = ((data[5] as u32) << 16) | ((data[6] as u32) << 8) | (data[7] as u32);
4254    let min_update_index = read_u64(&data[8..16])?;
4255    let max_update_index = read_u64(&data[16..24])?;
4256
4257    // The position fields follow the file header, whose size is version-dependent
4258    // (24 bytes for v1, 28 for v2 — the extra 4 bytes are the hash-id).
4259    let off = if version == 2 { 28 } else { 24 };
4260    let ref_index_position = read_u64(&data[off..off + 8])?;
4261    let log_position = read_u64(&data[off + 24..off + 32])?;
4262
4263    // CRC-32 check
4264    let crc_bytes: [u8; 4] = data[footer_size - 4..footer_size]
4265        .try_into()
4266        .map_err(|_| truncated(ReftableField::Footer))?;
4267    let crc_stored = u32::from_be_bytes(crc_bytes);
4268    let crc_computed = crc32(&data[..footer_size - 4]);
4269    if crc_stored != crc_computed {
4270        return Err(ReftableError::FooterChecksumMismatch {
4271            stored: crc_stored,
4272            computed: crc_computed,
4273        }
4274        .into());
4275    }
4276
4277    Ok(Footer {
4278        block_size,
4279        min_update_index,
4280        max_update_index,
4281        ref_index_position,
4282        log_position,
4283    })
4284}
4285
4286/// Parse an identity string like `"Name <email> 1234567890 +0100"`.
4287pub(crate) fn parse_identity_string(identity: &str) -> (String, String, u64, i16) {
4288    // Format: "Name <email> timestamp tz"
4289    let parts: Vec<&str> = identity.rsplitn(3, ' ').collect();
4290    if parts.len() < 3 {
4291        return (identity.to_owned(), String::new(), 0, 0);
4292    }
4293    let tz_str = parts[0]; // e.g. "+0100"
4294    let time_str = parts[1]; // e.g. "1234567890"
4295    let name_email = parts[2]; // e.g. "Name <email>"
4296
4297    let time_secs = time_str.parse::<u64>().unwrap_or(0);
4298
4299    // Parse timezone: +HHMM or -HHMM. Reftable log records keep the HHMM digits as a
4300    // decimal number (`+0530` -> 530), matching what Git writes.
4301    let tz_hhmm = if tz_str.len() >= 5 {
4302        let sign = if tz_str.starts_with('-') { -1i16 } else { 1 };
4303        let hours = tz_str[1..3].parse::<i16>().unwrap_or(0);
4304        let mins = tz_str[3..5].parse::<i16>().unwrap_or(0);
4305        sign * (hours * 100 + mins)
4306    } else {
4307        0
4308    };
4309
4310    // Split name and email
4311    let (name, email) = if let Some(lt_pos) = name_email.find('<') {
4312        let name = name_email[..lt_pos].trim().to_owned();
4313        let email = if let Some(gt_pos) = name_email.find('>') {
4314            name_email[lt_pos + 1..gt_pos].to_owned()
4315        } else {
4316            name_email[lt_pos + 1..].to_owned()
4317        };
4318        (name, email)
4319    } else {
4320        (name_email.to_owned(), String::new())
4321    };
4322
4323    (name, email, time_secs, tz_hhmm)
4324}
4325
4326// ---------------------------------------------------------------------------
4327// Tests
4328// ---------------------------------------------------------------------------
4329
4330#[cfg(test)]
4331mod tests {
4332    use super::*;
4333
4334    fn reftable_repo_with_config(extra: &str) -> (tempfile::TempDir, PathBuf) {
4335        let dir = tempfile::tempdir().expect("tempdir");
4336        crate::repo::init_repository(
4337            dir.path(),
4338            false,
4339            "main",
4340            None,
4341            crate::RefStorageFormat::Reftable,
4342        )
4343        .expect("init");
4344        let git_dir = dir.path().join(".git");
4345        let mut cfg = fs::read_to_string(git_dir.join("config")).expect("read config");
4346        cfg.push_str(extra);
4347        fs::write(git_dir.join("config"), cfg).expect("write config");
4348        (dir, git_dir)
4349    }
4350
4351    #[test]
4352    fn write_options_log_blocks_follow_log_all_ref_updates() {
4353        for (value, write_log) in [
4354            ("true", true),
4355            ("always", true),
4356            ("yes", true),
4357            ("on", true),
4358            ("1", true),
4359            ("false", false),
4360            ("no", false),
4361            ("0", false),
4362            ("never", false),
4363        ] {
4364            let (_dir, git_dir) =
4365                reftable_repo_with_config(&format!("[core]\n\tlogAllRefUpdates = {value}\n"));
4366            assert_eq!(
4367                read_write_options(&git_dir).write_log,
4368                write_log,
4369                "core.logAllRefUpdates = {value}"
4370            );
4371        }
4372        let (_dir, git_dir) = reftable_repo_with_config("");
4373        assert!(read_write_options(&git_dir).write_log);
4374    }
4375
4376    #[test]
4377    fn write_options_broken_config_keeps_entries_before_bad_line() {
4378        let (_dir, git_dir) = reftable_repo_with_config(
4379            "[reftable]\n\tgeometricFactor = 5\n\tindexObjects = off\n[core]\n\tlogAllRefUpdates = no\n[broken\nblockSize = 1\n",
4380        );
4381        let opts = read_write_options(&git_dir);
4382        assert_eq!(opts.auto_compaction_factor, 5);
4383        assert!(opts.skip_index_objects);
4384        assert!(!opts.write_log);
4385        assert_eq!(opts.block_size, WriteOptions::default().block_size);
4386    }
4387
4388    #[test]
4389    fn should_write_log_follows_mode_and_existing_reflog() {
4390        let (_dir, git_dir) = reftable_repo_with_config("[core]\n\tlogAllRefUpdates = true\n");
4391        assert!(reftable_should_write_log(&git_dir, "refs/heads/main"));
4392        assert!(!reftable_should_write_log(&git_dir, "refs/tags/v1"));
4393
4394        let (_dir, git_dir) = reftable_repo_with_config("[core]\n\tlogAllRefUpdates = always\n");
4395        assert!(reftable_should_write_log(&git_dir, "refs/tags/v1"));
4396
4397        let (_dir, git_dir) = reftable_repo_with_config("[core]\n\tlogAllRefUpdates = false\n");
4398        assert!(!reftable_should_write_log(&git_dir, "refs/heads/topic"));
4399        reftable_create_reflog(&git_dir, "refs/heads/topic").expect("create reflog");
4400        assert!(reftable_should_write_log(&git_dir, "refs/heads/topic"));
4401    }
4402
4403    #[test]
4404    fn stack_table_cache_readers_use_mapped_backing() {
4405        use crate::objects::ObjectId;
4406        use crate::repo::init_repository;
4407
4408        let dir = tempfile::tempdir().expect("tempdir");
4409        init_repository(
4410            dir.path(),
4411            false,
4412            "main",
4413            None,
4414            crate::RefStorageFormat::Reftable,
4415        )
4416        .expect("init");
4417        let git_dir = dir.path().join(".git");
4418        let oid = ObjectId::from_bytes(&[0x44; 20]).expect("oid");
4419        reftable_write_ref(&git_dir, "refs/heads/mapped-test", &oid, None, None)
4420            .expect("write ref");
4421        let reftable_dir = git_dir.join("reftable");
4422        let cached = super::load_cached_stack_tables(&reftable_dir).expect("cache");
4423        assert!(
4424            cached.readers.iter().all(|r| r.table_backing_is_mapped()),
4425            "stack cache must mmap tables instead of copying them into Arc<[u8]>"
4426        );
4427        let again = super::load_cached_stack_tables(&reftable_dir).expect("cache again");
4428        assert!(
4429            Arc::ptr_eq(&cached, &again),
4430            "second load must hit the process-wide stack cache"
4431        );
4432    }
4433
4434    #[test]
4435    fn read_refs_on_empty_reftable_without_tables_list() {
4436        let dir = tempfile::tempdir().expect("tempdir");
4437        let git_dir = dir.path().join(".git");
4438        fs::create_dir_all(git_dir.join("reftable")).expect("reftable dir");
4439        let stack = ReftableStack::open(&git_dir).expect("open empty stack");
4440        assert!(stack.read_refs().expect("read").is_empty());
4441    }
4442
4443    #[test]
4444    fn read_refs_tracks_external_compaction_on_public_stack() {
4445        use crate::objects::ObjectId;
4446        use crate::repo::init_repository;
4447
4448        let dir = tempfile::tempdir().expect("tempdir");
4449        init_repository(
4450            dir.path(),
4451            false,
4452            "main",
4453            None,
4454            crate::RefStorageFormat::Reftable,
4455        )
4456        .expect("init");
4457        let git_dir = dir.path().join(".git");
4458        let mut opts = read_write_options(&git_dir);
4459        opts.autocompaction_enabled = false;
4460        let oid_a = ObjectId::from_bytes(&[0x31; 20]).expect("oid");
4461        let oid_b = ObjectId::from_bytes(&[0x32; 20]).expect("oid");
4462        let identity = "Compact Ext <ext@example.com> 1700000000 +0000";
4463        reftable_write_ref_with_write_options(
4464            &git_dir,
4465            "refs/heads/keep-a",
4466            &oid_a,
4467            Some(identity),
4468            None,
4469            &opts,
4470        )
4471        .expect("write a");
4472        reftable_write_ref_with_write_options(
4473            &git_dir,
4474            "refs/heads/keep-b",
4475            &oid_b,
4476            Some(identity),
4477            None,
4478            &opts,
4479        )
4480        .expect("write b");
4481
4482        let reader = ReftableStack::open(&git_dir).expect("reader stack");
4483        let mut writer = ReftableStack::open(&git_dir).expect("writer stack");
4484        writer.compact().expect("compact via second handle");
4485        let refs = reader.read_refs().expect("read after external compact");
4486        let names: Vec<&str> = refs.iter().map(|r| r.name.as_str()).collect();
4487        assert!(names.iter().any(|n| *n == "refs/heads/keep-a"));
4488        assert!(names.iter().any(|n| *n == "refs/heads/keep-b"));
4489    }
4490
4491    #[test]
4492    fn subset_stack_read_refs_respects_table_names() {
4493        use crate::objects::ObjectId;
4494        use crate::repo::init_repository;
4495
4496        let dir = tempfile::tempdir().expect("tempdir");
4497        init_repository(
4498            dir.path(),
4499            false,
4500            "main",
4501            None,
4502            crate::RefStorageFormat::Reftable,
4503        )
4504        .expect("init");
4505        let git_dir = dir.path().join(".git");
4506        let mut opts = read_write_options(&git_dir);
4507        opts.autocompaction_enabled = false;
4508        let oid_a = ObjectId::from_bytes(&[0x11; 20]).expect("oid");
4509        let oid_b = ObjectId::from_bytes(&[0x22; 20]).expect("oid");
4510        let identity = "Subset Stack <subset@example.com> 1700000000 +0000";
4511        reftable_write_ref_with_write_options(
4512            &git_dir,
4513            "refs/heads/only-in-first-table",
4514            &oid_a,
4515            Some(identity),
4516            None,
4517            &opts,
4518        )
4519        .expect("first ref");
4520        let first_table = ReftableStack::open(&git_dir)
4521            .expect("stack after first ref")
4522            .table_names
4523            .last()
4524            .expect("first ref table")
4525            .clone();
4526        reftable_write_ref_with_write_options(
4527            &git_dir,
4528            "refs/heads/only-in-second-table",
4529            &oid_b,
4530            Some(identity),
4531            None,
4532            &opts,
4533        )
4534        .expect("second ref");
4535
4536        let stack = ReftableStack::open(&git_dir).expect("open");
4537        assert!(
4538            stack.table_names.len() >= 2,
4539            "expected multiple tables without autocompaction, got {}",
4540            stack.table_names.len()
4541        );
4542
4543        let subset = ReftableStack::subset_stack(stack.reftable_dir.clone(), vec![first_table]);
4544        let refs = subset.read_refs().expect("subset read");
4545        let names: Vec<&str> = refs.iter().map(|r| r.name.as_str()).collect();
4546        assert!(names.iter().any(|n| *n == "refs/heads/only-in-first-table"));
4547        assert!(!names
4548            .iter()
4549            .any(|n| *n == "refs/heads/only-in-second-table"));
4550
4551        let full = stack.read_refs().expect("full read");
4552        assert_eq!(full.len(), 3);
4553        assert!(full.iter().any(|record| record.name == "HEAD"
4554            && record.value == RefValue::Symref("refs/heads/main".into())));
4555    }
4556
4557    #[test]
4558    fn compact_range_non_bottom_preserves_deletion_tombstone() {
4559        use crate::objects::ObjectId;
4560        use crate::repo::init_repository;
4561
4562        let dir = tempfile::tempdir().expect("tempdir");
4563        init_repository(
4564            dir.path(),
4565            false,
4566            "main",
4567            None,
4568            crate::RefStorageFormat::Reftable,
4569        )
4570        .expect("init");
4571        let git_dir = dir.path().join(".git");
4572        let mut opts = read_write_options(&git_dir);
4573        opts.autocompaction_enabled = false;
4574        let oid_c = ObjectId::from_bytes(&[0xcc; 20]).expect("oid");
4575        let oid_other = ObjectId::from_bytes(&[0xdd; 20]).expect("oid");
4576        let identity = "Compact Tomb <tomb@example.com> 1700000000 +0000";
4577        reftable_write_ref_with_write_options(
4578            &git_dir,
4579            "refs/heads/c",
4580            &oid_c,
4581            Some(identity),
4582            None,
4583            &opts,
4584        )
4585        .expect("create c");
4586        reftable_write_ref_with_write_options(
4587            &git_dir,
4588            "refs/heads/unrelated",
4589            &oid_other,
4590            Some(identity),
4591            None,
4592            &opts,
4593        )
4594        .expect("update unrelated");
4595        reftable_delete_ref_with_write_options(&git_dir, "refs/heads/c", &opts).expect("delete c");
4596
4597        let mut stack = ReftableStack::open(&git_dir).expect("open");
4598        assert!(
4599            stack.table_names.len() >= 4,
4600            "need init plus three ref tables, got {}",
4601            stack.table_names.len()
4602        );
4603        let bottom = stack.table_names[0].clone();
4604        let table_with_c = stack.table_names[1].clone();
4605        let last = stack.table_names.len() - 1;
4606        stack
4607            .compact_range(last - 1, last, &opts)
4608            .expect("compact suffix with delete");
4609
4610        assert!(stack.lookup_ref("refs/heads/c").expect("lookup").is_none());
4611        let compacted_name = stack.table_names[last - 1].clone();
4612        assert_ne!(compacted_name, bottom);
4613        assert_ne!(compacted_name, table_with_c);
4614        let compacted_data = fs::read(stack.reftable_dir.join(&compacted_name)).expect("read");
4615        let compacted_reader = ReftableReader::new(compacted_data).expect("reader");
4616        let compacted_refs = compacted_reader.read_refs().expect("refs");
4617        assert!(
4618            compacted_refs
4619                .iter()
4620                .any(|rec| { rec.name == "refs/heads/c" && rec.value == RefValue::Deletion }),
4621            "compacted table must retain delete tombstone: {compacted_refs:?}"
4622        );
4623        let bottom_data = fs::read(stack.reftable_dir.join(&bottom)).expect("bottom");
4624        let bottom_reader = ReftableReader::new(bottom_data).expect("bottom reader");
4625        assert!(
4626            !bottom_reader
4627                .read_refs()
4628                .expect("bottom refs")
4629                .iter()
4630                .any(|rec| rec.name == "refs/heads/c"),
4631            "init table must remain untouched"
4632        );
4633        let c_table_data = fs::read(stack.reftable_dir.join(&table_with_c)).expect("c table");
4634        let c_table_reader = ReftableReader::new(c_table_data).expect("c table reader");
4635        assert!(
4636            c_table_reader
4637                .read_refs()
4638                .expect("c table refs")
4639                .iter()
4640                .any(|rec| rec.name == "refs/heads/c"),
4641            "older table must still hold the ref until shadowed"
4642        );
4643    }
4644
4645    #[test]
4646    fn compact_range_from_bottom_omits_deletion_tombstone() {
4647        use crate::objects::ObjectId;
4648        use crate::repo::init_repository;
4649
4650        let dir = tempfile::tempdir().expect("tempdir");
4651        init_repository(
4652            dir.path(),
4653            false,
4654            "main",
4655            None,
4656            crate::RefStorageFormat::Reftable,
4657        )
4658        .expect("init");
4659        let git_dir = dir.path().join(".git");
4660        let mut opts = read_write_options(&git_dir);
4661        opts.autocompaction_enabled = false;
4662        let oid_c = ObjectId::from_bytes(&[0xcc; 20]).expect("oid");
4663        let oid_other = ObjectId::from_bytes(&[0xdd; 20]).expect("oid");
4664        let identity = "Compact Tomb <tomb@example.com> 1700000000 +0000";
4665        reftable_write_ref_with_write_options(
4666            &git_dir,
4667            "refs/heads/c",
4668            &oid_c,
4669            Some(identity),
4670            None,
4671            &opts,
4672        )
4673        .expect("create c");
4674        reftable_write_ref_with_write_options(
4675            &git_dir,
4676            "refs/heads/unrelated",
4677            &oid_other,
4678            Some(identity),
4679            None,
4680            &opts,
4681        )
4682        .expect("update unrelated");
4683        reftable_delete_ref_with_write_options(&git_dir, "refs/heads/c", &opts).expect("delete c");
4684
4685        let mut stack = ReftableStack::open(&git_dir).expect("open");
4686        let table_count = stack.table_names.len();
4687        stack
4688            .compact_range(0, table_count - 1, &opts)
4689            .expect("compact all");
4690        assert!(stack.lookup_ref("refs/heads/c").expect("lookup").is_none());
4691        assert_eq!(stack.table_names.len(), 1);
4692        let only = ReftableReader::new(
4693            fs::read(stack.reftable_dir.join(&stack.table_names[0])).expect("read"),
4694        )
4695        .expect("reader");
4696        assert!(
4697            !only
4698                .read_refs()
4699                .expect("refs")
4700                .iter()
4701                .any(|rec| rec.name == "refs/heads/c"),
4702            "full-stack compaction must not store delete tombstones"
4703        );
4704    }
4705
4706    #[test]
4707    fn suggest_compaction_segment_matches_geometric_break() {
4708        assert_eq!(suggest_compaction_segment(&[10], 2), (0, 0));
4709        assert_eq!(suggest_compaction_segment(&[64, 32, 16], 2), (0, 0));
4710        let (start, end) = suggest_compaction_segment(&[64, 32, 16, 8, 4, 3, 1], 2);
4711        assert!(
4712            end > start + 1,
4713            "expected compaction segment, got {start}..{end}"
4714        );
4715    }
4716
4717    #[test]
4718    fn prefix_upper_exclusive_for_seek_end() {
4719        assert_eq!(
4720            prefix_upper_exclusive("refs/heads/b05").as_deref(),
4721            Some("refs/heads/b06")
4722        );
4723        assert!(prefix_upper_exclusive("refs/heads/").is_none());
4724    }
4725
4726    #[test]
4727    fn test_varint_roundtrip() {
4728        for val in [0u64, 1, 127, 128, 255, 256, 16383, 16384, u64::MAX] {
4729            let mut buf = Vec::new();
4730            put_varint(val, &mut buf);
4731            let (decoded, end) = get_varint(&buf, 0).unwrap();
4732            assert_eq!(decoded, val, "varint roundtrip failed for {val}");
4733            assert_eq!(end, buf.len());
4734        }
4735    }
4736
4737    #[test]
4738    fn test_crc32() {
4739        // Known test vector: "123456789" => 0xCBF43926
4740        assert_eq!(crc32(b"123456789"), 0xCBF43926);
4741    }
4742
4743    #[test]
4744    fn test_empty_table() {
4745        let writer = ReftableWriter::new(WriteOptions::default(), 1, 1);
4746        let data = writer.finish().unwrap();
4747        let reader = ReftableReader::new(data).unwrap();
4748        let refs = reader.read_refs().unwrap();
4749        assert!(refs.is_empty());
4750    }
4751
4752    #[test]
4753    fn test_write_read_single_ref() {
4754        let oid = ObjectId::from_bytes(&[0xab; 20]).unwrap();
4755        let mut writer = ReftableWriter::new(WriteOptions::default(), 1, 1);
4756        writer
4757            .add_ref(RefRecord {
4758                name: "refs/heads/main".to_owned(),
4759                update_index: 1,
4760                value: RefValue::Val1(oid),
4761            })
4762            .unwrap();
4763        let data = writer.finish().unwrap();
4764
4765        let reader = ReftableReader::new(data).unwrap();
4766        let refs = reader.read_refs().unwrap();
4767        assert_eq!(refs.len(), 1);
4768        assert_eq!(refs[0].name, "refs/heads/main");
4769        assert_eq!(refs[0].value, RefValue::Val1(oid));
4770        assert_eq!(refs[0].update_index, 1);
4771    }
4772
4773    #[test]
4774    fn test_write_read_multiple_refs() {
4775        let oid1 = ObjectId::from_bytes(&[0x11; 20]).unwrap();
4776        let oid2 = ObjectId::from_bytes(&[0x22; 20]).unwrap();
4777        let oid3 = ObjectId::from_bytes(&[0x33; 20]).unwrap();
4778
4779        let mut writer = ReftableWriter::new(WriteOptions::default(), 1, 1);
4780        writer
4781            .add_ref(RefRecord {
4782                name: "refs/heads/a".to_owned(),
4783                update_index: 1,
4784                value: RefValue::Val1(oid1),
4785            })
4786            .unwrap();
4787        writer
4788            .add_ref(RefRecord {
4789                name: "refs/heads/b".to_owned(),
4790                update_index: 1,
4791                value: RefValue::Val1(oid2),
4792            })
4793            .unwrap();
4794        writer
4795            .add_ref(RefRecord {
4796                name: "refs/tags/v1.0".to_owned(),
4797                update_index: 1,
4798                value: RefValue::Val2(oid3, oid1),
4799            })
4800            .unwrap();
4801        let data = writer.finish().unwrap();
4802
4803        let reader = ReftableReader::new(data).unwrap();
4804        let refs = reader.read_refs().unwrap();
4805        assert_eq!(refs.len(), 3);
4806        assert_eq!(refs[0].name, "refs/heads/a");
4807        assert_eq!(refs[1].name, "refs/heads/b");
4808        assert_eq!(refs[2].name, "refs/tags/v1.0");
4809        assert_eq!(refs[2].value, RefValue::Val2(oid3, oid1));
4810    }
4811
4812    #[test]
4813    fn test_symref_roundtrip() {
4814        let mut writer = ReftableWriter::new(WriteOptions::default(), 1, 1);
4815        writer
4816            .add_ref(RefRecord {
4817                name: "refs/heads/sym".to_owned(),
4818                update_index: 1,
4819                value: RefValue::Symref("refs/heads/main".to_owned()),
4820            })
4821            .unwrap();
4822        let data = writer.finish().unwrap();
4823
4824        let reader = ReftableReader::new(data).unwrap();
4825        let refs = reader.read_refs().unwrap();
4826        assert_eq!(refs.len(), 1);
4827        assert_eq!(
4828            refs[0].value,
4829            RefValue::Symref("refs/heads/main".to_owned())
4830        );
4831    }
4832
4833    #[test]
4834    fn test_log_roundtrip() {
4835        let old_oid = ObjectId::from_bytes(&[0; 20]).unwrap();
4836        let new_oid = ObjectId::from_bytes(&[0xaa; 20]).unwrap();
4837
4838        let opts = WriteOptions {
4839            write_log: true,
4840            ..Default::default()
4841        };
4842        let mut writer = ReftableWriter::new(opts, 1, 1);
4843        writer
4844            .add_log(LogRecord {
4845                refname: "refs/heads/main".to_owned(),
4846                update_index: 1,
4847                old_id: old_oid,
4848                new_id: new_oid,
4849                name: "Test User".to_owned(),
4850                email: "test@example.com".to_owned(),
4851                time_seconds: 1700000000,
4852                tz_offset: -800,
4853                message: "initial commit".to_owned(),
4854            })
4855            .unwrap();
4856        let data = writer.finish().unwrap();
4857
4858        let reader = ReftableReader::new(data).unwrap();
4859        let logs = reader.read_logs().unwrap();
4860        assert_eq!(logs.len(), 1);
4861        assert_eq!(logs[0].refname, "refs/heads/main");
4862        assert_eq!(logs[0].old_id, old_oid);
4863        assert_eq!(logs[0].new_id, new_oid);
4864        assert_eq!(logs[0].name, "Test User");
4865        assert_eq!(logs[0].email, "test@example.com");
4866        assert_eq!(logs[0].time_seconds, 1700000000);
4867        assert_eq!(logs[0].tz_offset, -800);
4868        // The reftable writer cleans messages the way git does: it appends a
4869        // trailing newline. `read_logs` returns the raw on-disk message (the
4870        // newline is only stripped when converting to a `ReflogEntry`).
4871        assert_eq!(logs[0].message, "initial commit\n");
4872    }
4873
4874    #[test]
4875    fn test_unaligned_table() {
4876        let oid = ObjectId::from_bytes(&[0xcc; 20]).unwrap();
4877        let opts = WriteOptions {
4878            // Unpadded (unaligned) blocks: like git's `unpadded` write option,
4879            // blocks are not padded out to the block size. A block_size of 0 is
4880            // resolved to the default at write time, so the reported block size
4881            // is the default rather than 0.
4882            unpadded: true,
4883            restart_interval: 16,
4884            write_log: false,
4885            ..WriteOptions::default()
4886        };
4887        let mut writer = ReftableWriter::new(opts, 1, 1);
4888        writer
4889            .add_ref(RefRecord {
4890                name: "refs/heads/main".to_owned(),
4891                update_index: 1,
4892                value: RefValue::Val1(oid),
4893            })
4894            .unwrap();
4895        let data = writer.finish().unwrap();
4896
4897        // An unpadded single-ref table is far smaller than one padded block.
4898        assert!(data.len() < DEFAULT_BLOCK_SIZE as usize);
4899
4900        let reader = ReftableReader::new(data).unwrap();
4901        let refs = reader.read_refs().unwrap();
4902        assert_eq!(refs.len(), 1);
4903        assert_eq!(refs[0].value, RefValue::Val1(oid));
4904    }
4905
4906    #[test]
4907    fn test_parse_identity() {
4908        let (name, email, ts, tz) =
4909            parse_identity_string("Test User <test@example.com> 1700000000 -0800");
4910        assert_eq!(name, "Test User");
4911        assert_eq!(email, "test@example.com");
4912        assert_eq!(ts, 1700000000);
4913        // Git stores the HHMM digits as a decimal number, not minutes.
4914        assert_eq!(tz, -800);
4915        let (_, _, _, tz) = parse_identity_string("Test User <test@example.com> 1700000000 +0530");
4916        assert_eq!(tz, 530);
4917    }
4918
4919    #[test]
4920    fn test_deletion_record() {
4921        let mut writer = ReftableWriter::new(WriteOptions::default(), 1, 1);
4922        writer
4923            .add_ref(RefRecord {
4924                name: "refs/heads/gone".to_owned(),
4925                update_index: 1,
4926                value: RefValue::Deletion,
4927            })
4928            .unwrap();
4929        let data = writer.finish().unwrap();
4930
4931        let reader = ReftableReader::new(data).unwrap();
4932        let refs = reader.read_refs().unwrap();
4933        assert_eq!(refs.len(), 1);
4934        assert_eq!(refs[0].value, RefValue::Deletion);
4935    }
4936
4937    #[test]
4938    fn verify_batch_reftable_symref_ancestor_blocks_child_create() {
4939        use crate::objects::ObjectId;
4940        use crate::refs::{verify_ref_transaction_batch, RefBatchItem, RefnameUnavailable};
4941        use crate::repo::init_repository;
4942
4943        let dir = tempfile::tempdir().expect("tempdir");
4944        init_repository(
4945            dir.path(),
4946            false,
4947            "main",
4948            None,
4949            crate::RefStorageFormat::Reftable,
4950        )
4951        .expect("init");
4952        let git_dir = dir.path().join(".git");
4953        reftable_write_symref(
4954            &git_dir,
4955            "refs/heads/parent",
4956            "refs/heads/missing-target",
4957            None,
4958            None,
4959        )
4960        .expect("symref parent");
4961
4962        let child_oid = ObjectId::from_bytes(&[0x44; 20]).expect("oid");
4963        let items = vec![RefBatchItem {
4964            name: "refs/heads/parent/child".to_owned(),
4965            new_oid: Some(child_oid),
4966        }];
4967        let err = verify_ref_transaction_batch(&git_dir, &items).unwrap_err();
4968        assert!(
4969            matches!(
4970                err,
4971                RefnameUnavailable::AncestorExists {
4972                    ref blocking,
4973                    ref new_ref,
4974                } if blocking == "refs/heads/parent" && new_ref == "refs/heads/parent/child"
4975            ),
4976            "unexpected {err:?}"
4977        );
4978    }
4979
4980    #[test]
4981    fn update_refs_reftable_symref_ancestor_blocks_child_create() {
4982        use crate::error::Error;
4983        use crate::refs::gc::{update_refs, RefTransactionItem};
4984        use crate::refs::store::RefStoreError;
4985        use crate::refs::RefnameUnavailable;
4986        use crate::repo::init_repository;
4987
4988        let dir = tempfile::tempdir().expect("tempdir");
4989        init_repository(
4990            dir.path(),
4991            false,
4992            "main",
4993            None,
4994            crate::RefStorageFormat::Reftable,
4995        )
4996        .expect("init");
4997        let git_dir = dir.path().join(".git");
4998        reftable_write_symref(
4999            &git_dir,
5000            "refs/heads/parent",
5001            "refs/heads/missing-target",
5002            None,
5003            None,
5004        )
5005        .expect("symref parent");
5006
5007        let child_oid = crate::refs::test_commit(&git_dir, "child");
5008        let err = update_refs(
5009            &git_dir,
5010            &[RefTransactionItem {
5011                name: "refs/heads/parent/child".to_owned(),
5012                new_oid: Some(child_oid),
5013                expected_old: None,
5014            }],
5015        )
5016        .unwrap_err();
5017        match err {
5018            Error::RefStore(RefStoreError::NameUnavailable {
5019                reason: RefnameUnavailable::AncestorExists { blocking, new_ref },
5020            }) => {
5021                assert_eq!(blocking, "refs/heads/parent");
5022                assert_eq!(new_ref, "refs/heads/parent/child");
5023            }
5024            other => panic!("unexpected {other:?}"),
5025        }
5026    }
5027
5028    #[test]
5029    fn write_transaction_inject_failure_leaves_tables_list_unchanged() {
5030        use crate::objects::ObjectId;
5031        use crate::repo::init_repository;
5032        use tempfile::TempDir;
5033
5034        let tmp = TempDir::new().unwrap();
5035        let repo = init_repository(
5036            tmp.path(),
5037            false,
5038            "main",
5039            None,
5040            crate::RefStorageFormat::Reftable,
5041        )
5042        .unwrap();
5043        let git_dir = repo.git_dir();
5044        let tables_list = git_dir.join("reftable/tables.list");
5045        let before = fs::read_to_string(&tables_list).unwrap_or_default();
5046
5047        let oid = ObjectId::from_bytes(&[0xcd; 20]).unwrap();
5048        set_test_inject_reftable_transaction_fail(true);
5049        let mut stack = ReftableStack::open(&git_dir).unwrap();
5050        let opts = read_write_options(&git_dir);
5051        let err = stack.write_transaction(
5052            vec![ReftableTransactionUpdate {
5053                refname: "refs/heads/inject-fail".to_owned(),
5054                value: Some(RefValue::Val1(oid)),
5055                log: None,
5056                expected_old: None,
5057            }],
5058            &opts,
5059        );
5060        set_test_inject_reftable_transaction_fail(false);
5061        assert!(err.is_err());
5062
5063        let after = fs::read_to_string(&tables_list).unwrap_or_default();
5064        assert_eq!(after, before);
5065    }
5066
5067    #[test]
5068    fn truncated_varint_is_typed() {
5069        let err = get_varint(&[0x80], 0).expect_err("continuation byte without a successor");
5070        assert!(matches!(
5071            err,
5072            Error::Reftable(ReftableError::Truncated {
5073                field: ReftableField::Varint
5074            })
5075        ));
5076        assert!(matches!(
5077            get_varint(&[], 0),
5078            Err(Error::Reftable(ReftableError::Truncated {
5079                field: ReftableField::Varint
5080            }))
5081        ));
5082    }
5083
5084    #[test]
5085    fn writer_rejects_unsorted_refs_with_both_names() {
5086        let oid = ObjectId::from_bytes(&[0x11; 20]).expect("oid");
5087        let mut writer = ReftableWriter::new(WriteOptions::default(), 1, 1);
5088        writer
5089            .add_ref(RefRecord {
5090                name: "refs/heads/b".to_owned(),
5091                update_index: 1,
5092                value: RefValue::Val1(oid),
5093            })
5094            .expect("first ref");
5095        let err = writer
5096            .add_ref(RefRecord {
5097                name: "refs/heads/a".to_owned(),
5098                update_index: 1,
5099                value: RefValue::Val1(oid),
5100            })
5101            .expect_err("out of order");
5102        match err {
5103            Error::Reftable(ReftableError::UnsortedRefs { previous, next }) => {
5104                assert_eq!(previous, "refs/heads/b");
5105                assert_eq!(next, "refs/heads/a");
5106            }
5107            other => panic!("unexpected {other:?}"),
5108        }
5109    }
5110
5111    #[test]
5112    fn reader_reports_footer_version_mismatch_and_short_files() {
5113        let data = ReftableWriter::new(WriteOptions::default(), 1, 1)
5114            .finish()
5115            .expect("empty table");
5116        let footer_start = data.len() - FOOTER_V1_SIZE;
5117
5118        let mut mismatched = data.clone();
5119        mismatched[footer_start + 4] = 2;
5120        assert!(matches!(
5121            ReftableReader::new(mismatched),
5122            Err(Error::Reftable(ReftableError::FooterVersionMismatch {
5123                header: 1,
5124                footer: 2
5125            }))
5126        ));
5127
5128        assert!(matches!(
5129            ReftableReader::new(data[..10].to_vec()),
5130            Err(Error::Reftable(ReftableError::TooSmall { len: 10 }))
5131        ));
5132        assert!(matches!(
5133            ReftableReader::new(data[..HEADER_SIZE + 4].to_vec()),
5134            Err(Error::Reftable(ReftableError::TooSmall { .. }))
5135        ));
5136    }
5137
5138    #[test]
5139    fn write_transaction_reports_expected_mismatch() {
5140        use crate::repo::init_repository;
5141
5142        let tmp = tempfile::tempdir().expect("tempdir");
5143        let repo = init_repository(
5144            tmp.path(),
5145            false,
5146            "main",
5147            None,
5148            crate::RefStorageFormat::Reftable,
5149        )
5150        .expect("init");
5151        let git_dir = repo.git_dir();
5152        let opts = read_write_options(&git_dir);
5153        let current = ObjectId::from_bytes(&[0x21; 20]).expect("oid");
5154        let stale = ObjectId::from_bytes(&[0x22; 20]).expect("oid");
5155        let mut stack = ReftableStack::open(&git_dir).expect("open");
5156        stack
5157            .write_transaction(
5158                vec![ReftableTransactionUpdate {
5159                    refname: "refs/heads/topic".to_owned(),
5160                    value: Some(RefValue::Val1(current)),
5161                    log: None,
5162                    expected_old: None,
5163                }],
5164                &opts,
5165            )
5166            .expect("seed topic");
5167
5168        let update = |refname: &str| ReftableTransactionUpdate {
5169            refname: refname.to_owned(),
5170            value: Some(RefValue::Val1(stale)),
5171            log: None,
5172            expected_old: Some(stale),
5173        };
5174        match stack.write_transaction(vec![update("refs/heads/topic")], &opts) {
5175            Err(Error::Reftable(ReftableError::ExpectedMismatch {
5176                refname,
5177                expected,
5178                actual,
5179            })) => {
5180                assert_eq!(refname, "refs/heads/topic");
5181                assert_eq!(expected, stale);
5182                assert_eq!(actual, Some(current));
5183            }
5184            other => panic!("unexpected {other:?}"),
5185        }
5186        let err = stack
5187            .write_transaction(vec![update("refs/heads/absent")], &opts)
5188            .expect_err("absent ref");
5189        assert!(matches!(
5190            &err,
5191            Error::Reftable(ReftableError::ExpectedMismatch { actual: None, .. })
5192        ));
5193        assert!(err.to_string().ends_with("but found <absent>"), "{err}");
5194    }
5195
5196    #[test]
5197    fn error_display_names_the_field() {
5198        assert_eq!(
5199            ReftableError::Truncated {
5200                field: ReftableField::LogEmail
5201            }
5202            .to_string(),
5203            "reftable: truncated log email"
5204        );
5205        assert_eq!(
5206            Error::from(ReftableError::InvalidUtf8 {
5207                field: ReftableField::SymrefTarget
5208            })
5209            .to_string(),
5210            "reftable: symref target is not valid UTF-8"
5211        );
5212    }
5213
5214    #[test]
5215    fn write_table_file_retries_when_destination_races_before_persist() {
5216        use crate::repo::init_repository;
5217
5218        let dir = tempfile::tempdir().expect("tempdir");
5219        init_repository(
5220            dir.path(),
5221            false,
5222            "main",
5223            None,
5224            crate::RefStorageFormat::Reftable,
5225        )
5226        .expect("init");
5227        let git_dir = dir.path().join(".git");
5228        let stack = ReftableStack::open(&git_dir).expect("open");
5229        let reftable_dir = git_dir.join("reftable");
5230        let data = ReftableWriter::new(WriteOptions::default(), 1, 1)
5231            .finish()
5232            .expect("empty table");
5233        let colliding = 0x1234_5678u32;
5234        let fallback = 0x8765_4321u32;
5235        let raced_name = reftable_table_filename(1, 1, colliding);
5236        struct ClearPersistHook;
5237        impl Drop for ClearPersistHook {
5238            fn drop(&mut self) {
5239                reftable_write_test_hooks::set_before_persist_hook(None);
5240            }
5241        }
5242        let _clear_hook = ClearPersistHook;
5243        reftable_write_test_hooks::set_before_persist_hook(Some(Box::new({
5244            let raced_name = raced_name.clone();
5245            move |path| {
5246                if path.file_name().and_then(|n| n.to_str()) == Some(raced_name.as_str()) {
5247                    fs::write(path, b"raced-in").expect("race destination into place");
5248                }
5249            }
5250        })));
5251        reftable_write_test_hooks::push_table_suffixes([colliding, fallback]);
5252        let published = stack
5253            .write_table_file(&data, 1, 1)
5254            .expect("publish with new suffix after race");
5255        assert_eq!(published, reftable_table_filename(1, 1, fallback));
5256        assert_eq!(
5257            fs::read(reftable_dir.join(&raced_name)).expect("read raced file"),
5258            b"raced-in"
5259        );
5260        let leftovers: Vec<_> = fs::read_dir(&reftable_dir)
5261            .expect("read reftable dir")
5262            .flatten()
5263            .map(|e| e.file_name())
5264            .filter(|name| {
5265                name.to_str()
5266                    .is_some_and(|s| s.starts_with(".writ-") && s.ends_with(".tmp"))
5267            })
5268            .collect();
5269        assert!(
5270            leftovers.is_empty(),
5271            "stale suffix reservation temps: {leftovers:?}"
5272        );
5273    }
5274
5275    #[test]
5276    fn write_table_file_does_not_clobber_existing_destination() {
5277        use crate::repo::init_repository;
5278
5279        let dir = tempfile::tempdir().expect("tempdir");
5280        init_repository(
5281            dir.path(),
5282            false,
5283            "main",
5284            None,
5285            crate::RefStorageFormat::Reftable,
5286        )
5287        .expect("init");
5288        let git_dir = dir.path().join(".git");
5289        let stack = ReftableStack::open(&git_dir).expect("open");
5290        let reftable_dir = git_dir.join("reftable");
5291        let data = ReftableWriter::new(WriteOptions::default(), 1, 1)
5292            .finish()
5293            .expect("empty table");
5294        let colliding = 0x1234_5678u32;
5295        let fallback = 0x8765_4321u32;
5296        let blocked = reftable_table_filename(1, 1, colliding);
5297        fs::write(reftable_dir.join(&blocked), b"keep").expect("seed existing table");
5298        reftable_write_test_hooks::push_table_suffixes([colliding, fallback]);
5299        let published = stack
5300            .write_table_file(&data, 1, 1)
5301            .expect("publish with new suffix");
5302        assert_eq!(published, reftable_table_filename(1, 1, fallback));
5303        assert_eq!(
5304            fs::read(reftable_dir.join(&blocked)).expect("read blocked file"),
5305            b"keep"
5306        );
5307    }
5308
5309    #[test]
5310    fn write_table_file_uses_distinct_suffixes() {
5311        use crate::repo::init_repository;
5312
5313        let dir = tempfile::tempdir().expect("tempdir");
5314        init_repository(
5315            dir.path(),
5316            false,
5317            "main",
5318            None,
5319            crate::RefStorageFormat::Reftable,
5320        )
5321        .expect("init");
5322        let git_dir = dir.path().join(".git");
5323        let stack = ReftableStack::open(&git_dir).expect("open");
5324        let data = ReftableWriter::new(WriteOptions::default(), 1, 1)
5325            .finish()
5326            .expect("empty table");
5327        let first = stack
5328            .write_table_file(&data, 1, 1)
5329            .expect("first table file");
5330        let second = stack
5331            .write_table_file(&data, 1, 1)
5332            .expect("second table file");
5333        assert_ne!(first, second);
5334    }
5335
5336    #[test]
5337    fn concurrent_readers_survive_autocompact_writes() {
5338        use crate::objects::ObjectId;
5339        use crate::repo::init_repository;
5340        use std::sync::atomic::{AtomicBool, Ordering};
5341        use std::sync::Arc;
5342        use std::thread;
5343        use std::time::Duration;
5344
5345        let dir = tempfile::tempdir().expect("tempdir");
5346        init_repository(
5347            dir.path(),
5348            false,
5349            "main",
5350            None,
5351            crate::RefStorageFormat::Reftable,
5352        )
5353        .expect("init");
5354        let git_dir = dir.path().join(".git");
5355        let opts = read_write_options(&git_dir);
5356        let stop = Arc::new(AtomicBool::new(false));
5357
5358        let git_dir_writer = git_dir.clone();
5359        let opts_writer = opts.clone();
5360        let stop_writer = Arc::clone(&stop);
5361        let writer = thread::spawn(move || {
5362            let mut stack = ReftableStack::open(&git_dir_writer).expect("writer open");
5363            for i in 0..120u8 {
5364                let oid = ObjectId::from_bytes(&[i; 20]).expect("oid");
5365                stack
5366                    .write_ref(
5367                        &format!("refs/heads/stress/{i}"),
5368                        RefValue::Val1(oid),
5369                        None,
5370                        &opts_writer,
5371                    )
5372                    .expect("write_ref");
5373            }
5374            stop_writer.store(true, Ordering::Release);
5375        });
5376
5377        let mut readers = Vec::new();
5378        for _ in 0..4 {
5379            let git_dir_reader = git_dir.clone();
5380            let stop_reader = Arc::clone(&stop);
5381            readers.push(thread::spawn(move || {
5382                let stack = ReftableStack::open(&git_dir_reader).expect("reader open");
5383                while !stop_reader.load(Ordering::Acquire) {
5384                    stack.read_refs().expect("read_refs");
5385                    stack
5386                        .list_refs_under_prefix(&git_dir_reader, "refs/heads/stress/")
5387                        .expect("list refs");
5388                    thread::sleep(Duration::from_millis(1));
5389                }
5390                for _ in 0..30 {
5391                    stack.read_refs().expect("read_refs tail");
5392                    stack.read_all_logs().expect("read_all_logs tail");
5393                }
5394            }));
5395        }
5396
5397        writer.join().expect("writer join");
5398        for reader in readers {
5399            reader.join().expect("reader join");
5400        }
5401
5402        let refs = ReftableStack::open(&git_dir)
5403            .expect("final open")
5404            .read_refs()
5405            .expect("final read");
5406        assert!(
5407            refs.iter()
5408                .filter(|r| r.name.starts_with("refs/heads/stress/"))
5409                .count()
5410                >= 120,
5411            "expected every stress ref to survive concurrent autocompaction"
5412        );
5413    }
5414}