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