Skip to main content

grit_lib/
reftable.rs

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