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