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