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