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 std::collections::{BTreeMap, BTreeSet};
29use std::fs;
30use std::io::{Read, Write};
31use std::path::{Path, PathBuf};
32use std::thread;
33use std::time::{Duration, Instant};
34
35use crate::config::ConfigSet;
36use crate::error::{Error, Result};
37use crate::objects::ObjectId;
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// ---------------------------------------------------------------------------
78// Varint encoding (Git pack-style)
79// ---------------------------------------------------------------------------
80
81/// Encode a u64 as a varint into `out`. Returns number of bytes written.
82fn put_varint(mut val: u64, out: &mut Vec<u8>) -> usize {
83    // First, collect 7-bit groups.
84    let mut buf = [0u8; 10];
85    let mut i = 0;
86    buf[i] = (val & 0x7f) as u8;
87    i += 1;
88    val >>= 7;
89    while val > 0 {
90        val -= 1;
91        buf[i] = (val & 0x7f) as u8;
92        i += 1;
93        val >>= 7;
94    }
95    // Write in reverse, with continuation bits.
96    let len = i;
97    for j in (1..len).rev() {
98        out.push(buf[j] | 0x80);
99    }
100    out.push(buf[0]);
101    len
102}
103
104/// Decode a varint from `data` starting at `pos`. Returns (value, new_pos).
105fn get_varint(data: &[u8], mut pos: usize) -> Result<(u64, usize)> {
106    if pos >= data.len() {
107        return Err(Error::InvalidRef("varint: unexpected end of data".into()));
108    }
109    let mut val = (data[pos] & 0x7f) as u64;
110    while data[pos] & 0x80 != 0 {
111        pos += 1;
112        if pos >= data.len() {
113            return Err(Error::InvalidRef("varint: unexpected end of data".into()));
114        }
115        val = ((val + 1) << 7) | (data[pos] & 0x7f) as u64;
116    }
117    Ok((val, pos + 1))
118}
119
120// ---------------------------------------------------------------------------
121// Ref record types
122// ---------------------------------------------------------------------------
123
124/// A single reference record as stored in a reftable.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum RefValue {
127    /// Deletion tombstone (value_type 0x0).
128    Deletion,
129    /// A direct ref pointing to one OID (value_type 0x1).
130    Val1(ObjectId),
131    /// An annotated tag: value + peeled target (value_type 0x2).
132    Val2(ObjectId, ObjectId),
133    /// A symbolic reference (value_type 0x3).
134    Symref(String),
135}
136
137/// A decoded ref record.
138#[derive(Debug, Clone)]
139pub struct RefRecord {
140    /// Full reference name.
141    pub name: String,
142    /// Update index (absolute).
143    pub update_index: u64,
144    /// The value.
145    pub value: RefValue,
146}
147
148/// A decoded log record.
149#[derive(Debug, Clone)]
150pub struct LogRecord {
151    /// Reference name.
152    pub refname: String,
153    /// Update index.
154    pub update_index: u64,
155    /// Old object ID.
156    pub old_id: ObjectId,
157    /// New object ID.
158    pub new_id: ObjectId,
159    /// Committer name.
160    pub name: String,
161    /// Committer email (without angle brackets).
162    pub email: String,
163    /// Time in seconds since epoch.
164    pub time_seconds: u64,
165    /// Timezone offset in minutes (signed).
166    pub tz_offset: i16,
167    /// Log message.
168    pub message: String,
169}
170
171/// Write options for reftable creation.
172#[derive(Debug, Clone)]
173pub struct WriteOptions {
174    /// Block size in bytes. 0 means use the default.
175    pub block_size: u32,
176    /// Restart interval (number of records between restart points).
177    pub restart_interval: usize,
178    /// Whether to write log blocks.
179    pub write_log: bool,
180    /// Skip writing the object index (config `reftable.indexObjects=false`).
181    pub skip_index_objects: bool,
182    /// Write blocks without padding to the block size.
183    pub unpadded: bool,
184    /// Object-id width in bytes: 20 for SHA-1 (reftable version 1), 32 for
185    /// SHA-256 (reftable version 2). Defaults to SHA-1.
186    pub hash_size: usize,
187}
188
189impl Default for WriteOptions {
190    fn default() -> Self {
191        Self {
192            block_size: DEFAULT_BLOCK_SIZE,
193            restart_interval: RESTART_INTERVAL,
194            write_log: true,
195            skip_index_objects: false,
196            unpadded: false,
197            hash_size: HASH_SIZE,
198        }
199    }
200}
201
202/// A ref update that should be written to a reftable transaction.
203///
204/// The `refname` must already be the backend storage refname (for example a
205/// namespaced or per-worktree ref after storage routing). All updates passed to
206/// one transaction are written with the same update index, matching Git's
207/// reftable backend for `update-ref --stdin` batches.
208#[derive(Debug, Clone)]
209pub struct ReftableTransactionUpdate {
210    /// Full storage refname to update.
211    pub refname: String,
212    /// New ref value, or a deletion tombstone.
213    pub value: RefValue,
214    /// Optional reflog entry to record in the same table and update index.
215    pub log: Option<LogRecord>,
216}
217
218// ---------------------------------------------------------------------------
219// Writer
220// ---------------------------------------------------------------------------
221
222/// Writes a single reftable file.
223///
224/// Usage:
225/// ```ignore
226/// let mut w = ReftableWriter::new(opts, min_idx, max_idx);
227/// w.add_ref(&RefRecord { .. })?;
228/// w.add_log(&LogRecord { .. })?;
229/// let bytes = w.finish()?;
230/// ```
231pub struct ReftableWriter {
232    opts: WriteOptions,
233    min_update_index: u64,
234    max_update_index: u64,
235
236    // Accumulated ref records (must be added in sorted order).
237    refs: Vec<RefRecord>,
238    // Accumulated log records.
239    logs: Vec<LogRecord>,
240}
241
242impl ReftableWriter {
243    /// Create a new writer.
244    pub fn new(opts: WriteOptions, min_update_index: u64, max_update_index: u64) -> Self {
245        Self {
246            opts,
247            min_update_index,
248            max_update_index,
249            refs: Vec::new(),
250            logs: Vec::new(),
251        }
252    }
253
254    /// Add a ref record. Records **must** be added in sorted name order.
255    pub fn add_ref(&mut self, rec: RefRecord) -> Result<()> {
256        if let Some(last) = self.refs.last() {
257            if rec.name <= last.name {
258                return Err(Error::InvalidRef(format!(
259                    "reftable: refs must be sorted, got '{}' after '{}'",
260                    rec.name, last.name
261                )));
262            }
263        }
264        self.refs.push(rec);
265        Ok(())
266    }
267
268    /// Add a log record.
269    pub fn add_log(&mut self, rec: LogRecord) -> Result<()> {
270        self.logs.push(rec);
271        Ok(())
272    }
273
274    /// Finish writing and return the complete reftable file bytes.
275    ///
276    /// This implements the reftable on-disk format so that the
277    /// on-disk layout (block boundaries, restart points, padding,
278    /// index/object sections, footer offsets) is byte-identical to git.
279    pub fn finish(self) -> Result<Vec<u8>> {
280        let refs = self.refs;
281        let logs = self.logs;
282        let opts = self.opts;
283        let mut w = WriterState::new(opts, self.min_update_index, self.max_update_index);
284
285        // Refs are added in sorted order; index objects as we go.
286        for rec in &refs {
287            w.add_ref(rec)?;
288        }
289
290        // Logs: sort by (refname asc, update_index desc) — matches
291        // reftable_log_record_compare_key.
292        let mut logs = logs;
293        logs.sort_by(|a, b| {
294            a.refname
295                .cmp(&b.refname)
296                .then_with(|| b.update_index.cmp(&a.update_index))
297        });
298        if w.opts.write_log {
299            for log in &logs {
300                w.add_log(log)?;
301            }
302        }
303
304        w.close()
305    }
306}
307
308// ---------------------------------------------------------------------------
309// Faithful low-level writer (ports git/reftable/{block,writer,record}.c)
310// ---------------------------------------------------------------------------
311
312/// Default reftable block size (per the reftable format specification).
313const REFTABLE_DEFAULT_BLOCK_SIZE: u32 = 4096;
314/// Maximum number of restart points per block (`MAX_RESTARTS`).
315const MAX_RESTARTS: usize = (1 << 16) - 1;
316
317/// A record to encode: produces a key and a value body.
318enum EncRecord<'a> {
319    Ref(&'a RefRecord, u64),
320    Log(&'a LogRecord),
321    Obj { prefix: Vec<u8>, offsets: Vec<u64> },
322    Index { last_key: Vec<u8>, offset: u64 },
323}
324
325impl EncRecord<'_> {
326    fn block_type(&self) -> u8 {
327        match self {
328            EncRecord::Ref(..) => BLOCK_TYPE_REF,
329            EncRecord::Log(_) => BLOCK_TYPE_LOG,
330            EncRecord::Obj { .. } => BLOCK_TYPE_OBJ,
331            EncRecord::Index { .. } => BLOCK_TYPE_INDEX,
332        }
333    }
334
335    /// The record key (used for prefix compression and restart points).
336    fn key(&self) -> Vec<u8> {
337        match self {
338            EncRecord::Ref(r, _) => r.name.as_bytes().to_vec(),
339            EncRecord::Log(l) => {
340                let mut k = Vec::with_capacity(l.refname.len() + 9);
341                k.extend_from_slice(l.refname.as_bytes());
342                k.push(0);
343                let ts = u64::MAX - l.update_index;
344                k.extend_from_slice(&ts.to_be_bytes());
345                k
346            }
347            EncRecord::Obj { prefix, .. } => prefix.clone(),
348            EncRecord::Index { last_key, .. } => last_key.clone(),
349        }
350    }
351
352    /// The `extra` value-type bits stored in the key varint.
353    fn val_type(&self) -> u8 {
354        match self {
355            EncRecord::Ref(r, _) => match r.value {
356                RefValue::Deletion => VALUE_DELETION,
357                RefValue::Val1(_) => VALUE_ONE_OID,
358                RefValue::Val2(..) => VALUE_TWO_OID,
359                RefValue::Symref(_) => VALUE_SYMREF,
360            },
361            // grit only writes reflog updates (value_type 1), never the
362            // explicit-deletion form (value_type 0).
363            EncRecord::Log(_) => 1,
364            EncRecord::Obj { offsets, .. } => {
365                if !offsets.is_empty() && offsets.len() < 8 {
366                    offsets.len() as u8
367                } else {
368                    0
369                }
370            }
371            EncRecord::Index { .. } => 0,
372        }
373    }
374
375    /// Encode the value body (everything after the key).
376    fn encode_value(&self, opts: &WriteOptions, out: &mut Vec<u8>) {
377        match self {
378            EncRecord::Ref(r, update_index_delta) => {
379                put_varint(*update_index_delta, out);
380                match &r.value {
381                    RefValue::Deletion => {}
382                    RefValue::Val1(oid) => out.extend_from_slice(oid.as_bytes()),
383                    RefValue::Val2(oid, peeled) => {
384                        out.extend_from_slice(oid.as_bytes());
385                        out.extend_from_slice(peeled.as_bytes());
386                    }
387                    RefValue::Symref(target) => {
388                        put_varint(target.len() as u64, out);
389                        out.extend_from_slice(target.as_bytes());
390                    }
391                }
392            }
393            EncRecord::Log(l) => {
394                out.extend_from_slice(l.old_id.as_bytes());
395                out.extend_from_slice(l.new_id.as_bytes());
396                put_varint(l.name.len() as u64, out);
397                out.extend_from_slice(l.name.as_bytes());
398                put_varint(l.email.len() as u64, out);
399                out.extend_from_slice(l.email.as_bytes());
400                put_varint(l.time_seconds, out);
401                out.extend_from_slice(&l.tz_offset.to_be_bytes());
402                let msg = clean_log_message(&l.message, opts);
403                put_varint(msg.len() as u64, out);
404                out.extend_from_slice(&msg);
405            }
406            EncRecord::Obj { offsets, .. } => {
407                if offsets.is_empty() || offsets.len() >= 8 {
408                    put_varint(offsets.len() as u64, out);
409                }
410                if offsets.is_empty() {
411                    return;
412                }
413                put_varint(offsets[0], out);
414                let mut last = offsets[0];
415                for &o in &offsets[1..] {
416                    put_varint(o - last, out);
417                    last = o;
418                }
419            }
420            EncRecord::Index { offset, .. } => {
421                put_varint(*offset, out);
422            }
423        }
424    }
425}
426
427/// Clean a reflog message the way `reftable_writer_add_log` does (unless the
428/// writer is in `exact_log_message` mode, which grit never uses): strip
429/// trailing newlines and append exactly one.
430///
431/// Git applies this cleaning whenever the message field is non-NULL, including
432/// the empty string: `""` becomes `"\n"` (a single trailing newline), not an
433/// empty value. grit's `LogRecord` always carries a (possibly empty) `String`,
434/// so the cleaning always runs — matching git's `msglen == 1` for reflog entries
435/// written without an explicit message (e.g. `update-ref` with no `-m`,
436/// t0613 'restart interval at every single record').
437fn clean_log_message(msg: &str, opts: &WriteOptions) -> Vec<u8> {
438    // Git's reftable backend truncates the reflog message to `block_size / 2`
439    // bytes before storing it (reftable-backend.c: `xstrndup(u->msg,
440    // block_size / 2)`) so that an oversized message still fits inside a log
441    // block instead of failing the whole transaction with "entry too large"
442    // (t0610 'basic: can write large commit message'). Mirror that bound,
443    // clamping to a UTF-8 char boundary so the resulting string stays valid.
444    let limit = (opts.block_size as usize / 2).max(1);
445    let msg = if msg.len() > limit {
446        let mut end = limit;
447        while end > 0 && !msg.is_char_boundary(end) {
448            end -= 1;
449        }
450        &msg[..end]
451    } else {
452        msg
453    };
454    let trimmed = msg.trim_end_matches('\n');
455    let mut out = trimmed.as_bytes().to_vec();
456    out.push(b'\n');
457    out
458}
459
460/// Encode a key (prefix/suffix compression) into `out`, returning whether this
461/// was a restart point. Mirrors `reftable_encode_key`.
462fn encode_key(prev: &[u8], key: &[u8], extra: u8, out: &mut Vec<u8>) -> bool {
463    let prefix_len = common_prefix_len(prev, key);
464    let suffix_len = key.len() - prefix_len;
465    put_varint(prefix_len as u64, out);
466    put_varint(((suffix_len as u64) << 3) | (extra as u64), out);
467    out.extend_from_slice(&key[prefix_len..]);
468    prefix_len == 0
469}
470
471/// In-progress block being filled by the writer.
472struct BlockWriter {
473    typ: u8,
474    /// Bytes from `header_off` onwards (block type byte + 3 reserved length
475    /// bytes are at the start; record payload follows).
476    buf: Vec<u8>,
477    header_off: usize,
478    block_size: usize,
479    restart_interval: usize,
480    restarts: Vec<u32>,
481    last_key: Vec<u8>,
482    entries: usize,
483}
484
485impl BlockWriter {
486    fn new(typ: u8, block_size: usize, header_off: usize, restart_interval: usize) -> Self {
487        // buf is laid out starting at header_off: [type][len:3][records...]
488        let mut buf = vec![0u8; header_off + 4];
489        buf[header_off] = typ;
490        Self {
491            typ,
492            buf,
493            header_off,
494            block_size,
495            restart_interval,
496            restarts: Vec::new(),
497            last_key: Vec::new(),
498            entries: 0,
499        }
500    }
501
502    /// `w->next` equivalent: number of bytes written so far (within the block,
503    /// counting from offset 0 which includes header_off).
504    fn next(&self) -> usize {
505        self.buf.len()
506    }
507
508    /// Try to add a record. Returns Ok(true) if added, Ok(false) if it does not
509    /// fit (entry-too-big), or Err on other failure.
510    fn add(&mut self, rec: &EncRecord, opts: &WriteOptions) -> Result<bool> {
511        let key = rec.key();
512        if key.is_empty() {
513            return Err(Error::InvalidRef("reftable: empty record key".into()));
514        }
515        let restart = self.entries.is_multiple_of(self.restart_interval);
516        let prev: &[u8] = if restart { &[] } else { &self.last_key };
517
518        let mut encoded = Vec::new();
519        let is_restart = encode_key(prev, &key, rec.val_type(), &mut encoded);
520        rec.encode_value(opts, &mut encoded);
521        let n = encoded.len();
522
523        // register_restart overflow check: 2 + 3*rlen + n > block_size - next
524        let mut rlen = self.restarts.len();
525        let mut is_restart = is_restart;
526        if rlen >= MAX_RESTARTS {
527            is_restart = false;
528        }
529        if is_restart {
530            rlen += 1;
531        }
532        if self.block_size > 0 && 2 + 3 * rlen + n > self.block_size - self.next() {
533            return Ok(false);
534        }
535
536        if is_restart {
537            self.restarts.push(self.next() as u32);
538        }
539        self.buf.extend_from_slice(&encoded);
540        self.last_key = key;
541        self.entries += 1;
542        Ok(true)
543    }
544
545    /// Finalize the block in memory: append restart table + count, write the
546    /// 3-byte block length, and (for log blocks) compress. Returns the raw byte
547    /// length written (`raw_bytes`).
548    fn finish(&mut self) -> Result<usize> {
549        for &r in &self.restarts {
550            self.buf.push(((r >> 16) & 0xff) as u8);
551            self.buf.push(((r >> 8) & 0xff) as u8);
552            self.buf.push((r & 0xff) as u8);
553        }
554        let rc = self.restarts.len() as u16;
555        self.buf.push((rc >> 8) as u8);
556        self.buf.push((rc & 0xff) as u8);
557
558        // block length (uncompressed) goes into the 3 bytes after the type.
559        let block_len = self.buf.len();
560        self.buf[self.header_off + 1] = ((block_len >> 16) & 0xff) as u8;
561        self.buf[self.header_off + 2] = ((block_len >> 8) & 0xff) as u8;
562        self.buf[self.header_off + 3] = (block_len & 0xff) as u8;
563
564        if self.typ == BLOCK_TYPE_LOG {
565            use flate2::write::DeflateEncoder;
566            use flate2::Compression;
567            let skip = 4 + self.header_off;
568            let mut enc = DeflateEncoder::new(Vec::new(), Compression::new(9));
569            enc.write_all(&self.buf[skip..])
570                .map_err(|e| Error::Zlib(e.to_string()))?;
571            let compressed = enc.finish().map_err(|e| Error::Zlib(e.to_string()))?;
572            self.buf.truncate(skip);
573            self.buf.extend_from_slice(&compressed);
574        }
575        Ok(self.buf.len())
576    }
577}
578
579/// Per-section statistics accumulated while writing a reftable.
580#[derive(Default, Clone)]
581struct SectionStats {
582    blocks: usize,
583    index_blocks: usize,
584    offset: u64,
585    index_offset: u64,
586}
587
588/// An object-index entry collected while writing refs.
589struct ObjEntry {
590    hash: Vec<u8>,
591    offsets: Vec<u64>,
592}
593
594/// The full writer state needed to emit a reftable file.
595struct WriterState {
596    opts: WriteOptions,
597    min_update_index: u64,
598    max_update_index: u64,
599
600    out: Vec<u8>,
601    next: u64,
602    pending_padding: usize,
603
604    block: Option<BlockWriter>,
605    block_type: u8,
606
607    /// Index records for the current section (last_key, offset).
608    index: Vec<(Vec<u8>, u64)>,
609
610    /// Object-index tree (kept sorted by hash).
611    obj_entries: Vec<ObjEntry>,
612    object_id_len: usize,
613
614    ref_stats: SectionStats,
615    obj_stats: SectionStats,
616    log_stats: SectionStats,
617    idx_blocks_total: usize,
618}
619
620impl WriterState {
621    fn new(mut opts: WriteOptions, min: u64, max: u64) -> Self {
622        if opts.restart_interval == 0 {
623            opts.restart_interval = RESTART_INTERVAL;
624        }
625        if opts.block_size == 0 {
626            opts.block_size = REFTABLE_DEFAULT_BLOCK_SIZE;
627        }
628        Self {
629            opts,
630            min_update_index: min,
631            max_update_index: max,
632            out: Vec::new(),
633            next: 0,
634            pending_padding: 0,
635            block: None,
636            block_type: 0,
637            index: Vec::new(),
638            obj_entries: Vec::new(),
639            object_id_len: 0,
640            ref_stats: SectionStats::default(),
641            obj_stats: SectionStats::default(),
642            log_stats: SectionStats::default(),
643            idx_blocks_total: 0,
644        }
645    }
646
647    fn header_size(&self) -> usize {
648        // Version 1 (SHA-1) is 24 bytes; version 2 (SHA-256) adds a 4-byte
649        // hash-id field for 28.
650        if self.opts.hash_size == 32 {
651            28
652        } else {
653            24
654        }
655    }
656
657    fn write_header(&self, dest: &mut [u8]) {
658        dest[0..4].copy_from_slice(REFTABLE_MAGIC);
659        dest[4] = if self.opts.hash_size == 32 { 2 } else { 1 };
660        dest[5] = ((self.opts.block_size >> 16) & 0xff) as u8;
661        dest[6] = ((self.opts.block_size >> 8) & 0xff) as u8;
662        dest[7] = (self.opts.block_size & 0xff) as u8;
663        dest[8..16].copy_from_slice(&self.min_update_index.to_be_bytes());
664        dest[16..24].copy_from_slice(&self.max_update_index.to_be_bytes());
665        // Version 2 records the hash function id (`sha1` / `s256`, Git
666        // `GIT_SHA{1,256}_FORMAT_ID`).
667        if self.opts.hash_size == 32 {
668            dest[24..28].copy_from_slice(b"s256");
669        }
670    }
671
672    fn stats_mut(&mut self, typ: u8) -> &mut SectionStats {
673        match typ {
674            BLOCK_TYPE_REF => &mut self.ref_stats,
675            BLOCK_TYPE_OBJ => &mut self.obj_stats,
676            BLOCK_TYPE_LOG => &mut self.log_stats,
677            // index blocks roll into the section being indexed; not used here.
678            _ => &mut self.ref_stats,
679        }
680    }
681
682    /// Write `data` then queue `padding` zero bytes for the next write
683    /// (`padded_write`).
684    fn padded_write(&mut self, data: &[u8], padding: usize) {
685        if self.pending_padding > 0 {
686            self.out
687                .extend(std::iter::repeat_n(0u8, self.pending_padding));
688            self.pending_padding = 0;
689        }
690        self.pending_padding = padding;
691        self.out.extend_from_slice(data);
692    }
693
694    fn reinit_block(&mut self, typ: u8) {
695        let header_off = if self.next == 0 {
696            self.header_size()
697        } else {
698            0
699        };
700        self.block = Some(BlockWriter::new(
701            typ,
702            self.opts.block_size as usize,
703            header_off,
704            self.opts.restart_interval,
705        ));
706        self.block_type = typ;
707    }
708
709    fn add_record(&mut self, rec: &EncRecord) -> Result<()> {
710        let typ = rec.block_type();
711        if self.block.is_none() {
712            self.reinit_block(typ);
713        }
714        // Attempt to add.
715        let opts = self.opts.clone();
716        let fit = {
717            let bw = self
718                .block
719                .as_mut()
720                .ok_or_else(|| Error::InvalidRef("reftable: no active block writer".into()))?;
721            bw.add(rec, &opts)?
722        };
723        if fit {
724            return Ok(());
725        }
726        // Block full: flush and retry in a fresh block.
727        self.flush_block()?;
728        self.reinit_block(typ);
729        let opts = self.opts.clone();
730        let bw = self
731            .block
732            .as_mut()
733            .ok_or_else(|| Error::InvalidRef("reftable: no active block writer".into()))?;
734        if !bw.add(rec, &opts)? {
735            return Err(Error::InvalidRef(
736                "reftable: transaction failure: entry too large".into(),
737            ));
738        }
739        Ok(())
740    }
741
742    fn add_ref(&mut self, r: &RefRecord) -> Result<()> {
743        let delta = r.update_index.saturating_sub(self.min_update_index);
744        self.add_record(&EncRecord::Ref(r, delta))?;
745
746        if !self.opts.skip_index_objects {
747            match &r.value {
748                RefValue::Val1(oid) => self.index_hash(oid.as_bytes()),
749                RefValue::Val2(oid, peeled) => {
750                    self.index_hash(oid.as_bytes());
751                    self.index_hash(peeled.as_bytes());
752                }
753                _ => {}
754            }
755        }
756        Ok(())
757    }
758
759    fn add_log(&mut self, l: &LogRecord) -> Result<()> {
760        // Finishing the ref section happens before the first log record.
761        if matches!(&self.block, Some(b) if b.typ == BLOCK_TYPE_REF) {
762            self.finish_public_section()?;
763        }
764        // Drop pending padding before a log block (matches add_log_verbatim).
765        self.next -= self.pending_padding as u64;
766        self.pending_padding = 0;
767        self.add_record(&EncRecord::Log(l))
768    }
769
770    fn index_hash(&mut self, hash: &[u8]) {
771        let off = self.next;
772        match self
773            .obj_entries
774            .binary_search_by(|e| e.hash.as_slice().cmp(hash))
775        {
776            Ok(idx) => {
777                let e = &mut self.obj_entries[idx];
778                if e.offsets.last() != Some(&off) {
779                    e.offsets.push(off);
780                }
781            }
782            Err(idx) => {
783                self.obj_entries.insert(
784                    idx,
785                    ObjEntry {
786                        hash: hash.to_vec(),
787                        offsets: vec![off],
788                    },
789                );
790            }
791        }
792    }
793
794    /// `writer_flush_nonempty_block`.
795    fn flush_block(&mut self) -> Result<()> {
796        let Some(mut bw) = self.block.take() else {
797            return Ok(());
798        };
799        if bw.entries == 0 {
800            self.block = Some(bw);
801            return Ok(());
802        }
803        let typ = bw.typ;
804        let raw_bytes = bw.finish()?;
805
806        let mut padding = 0;
807        if !self.opts.unpadded && typ != BLOCK_TYPE_LOG {
808            padding = (self.opts.block_size as usize).saturating_sub(raw_bytes);
809        }
810
811        let block_typ_off = if self.stats_mut(typ).blocks == 0 {
812            self.next
813        } else {
814            0
815        };
816        {
817            let next = self.next;
818            let st = self.stats_mut(typ);
819            if block_typ_off > 0 {
820                st.offset = next;
821            }
822            st.blocks += 1;
823        }
824
825        if self.next == 0 {
826            // Write the reftable header into the front of the first block.
827            let hs = self.header_size();
828            self.write_header_into_block(&mut bw, hs);
829        }
830
831        let data = bw.buf.clone();
832        self.padded_write(&data, padding);
833
834        // Record an index entry for this block.
835        self.index.push((bw.last_key.clone(), self.next));
836
837        self.next += (padding + raw_bytes) as u64;
838        self.block = None;
839        Ok(())
840    }
841
842    fn write_header_into_block(&self, bw: &mut BlockWriter, hs: usize) {
843        let mut hdr = vec![0u8; hs];
844        self.write_header(&mut hdr);
845        bw.buf[..hs].copy_from_slice(&hdr);
846    }
847
848    fn flush_block_if_nonempty(&mut self) -> Result<()> {
849        if matches!(&self.block, Some(b) if b.entries == 0) {
850            return Ok(());
851        }
852        self.flush_block()
853    }
854
855    /// `writer_finish_section`: flush the current block then emit any index.
856    fn finish_section(&mut self) -> Result<()> {
857        let typ = self.block_type;
858        let threshold = if self.opts.unpadded { 1 } else { 3 };
859        let before_blocks = self.idx_blocks_total;
860
861        self.flush_block_if_nonempty()?;
862
863        let mut max_level = 0;
864        let mut index_start = 0u64;
865
866        while self.index.len() > threshold {
867            max_level += 1;
868            index_start = self.next;
869            self.reinit_block(BLOCK_TYPE_INDEX);
870
871            let idx = std::mem::take(&mut self.index);
872            for (last_key, offset) in &idx {
873                self.add_record(&EncRecord::Index {
874                    last_key: last_key.clone(),
875                    offset: *offset,
876                })?;
877            }
878            // Count index blocks produced during this level.
879            let blocks_before = self.count_index_blocks_marker();
880            self.flush_index_block()?;
881            let _ = blocks_before;
882        }
883
884        self.index.clear();
885
886        let index_blocks = self.idx_blocks_total - before_blocks;
887        {
888            let st = self.stats_mut(typ);
889            st.index_blocks = index_blocks;
890            st.index_offset = index_start;
891        }
892        let _ = max_level;
893        Ok(())
894    }
895
896    fn count_index_blocks_marker(&self) -> usize {
897        self.idx_blocks_total
898    }
899
900    /// Flush an index block: like `flush_block` but the produced block counts
901    /// toward `idx_blocks_total` and re-populates `self.index` for the next
902    /// (higher) level.
903    fn flush_index_block(&mut self) -> Result<()> {
904        let Some(mut bw) = self.block.take() else {
905            return Ok(());
906        };
907        if bw.entries == 0 {
908            self.block = Some(bw);
909            return Ok(());
910        }
911        let raw_bytes = bw.finish()?;
912        let mut padding = 0;
913        if !self.opts.unpadded {
914            padding = (self.opts.block_size as usize).saturating_sub(raw_bytes);
915        }
916        if self.next == 0 {
917            let hs = self.header_size();
918            self.write_header_into_block(&mut bw, hs);
919        }
920        let data = bw.buf.clone();
921        self.padded_write(&data, padding);
922        self.index.push((bw.last_key.clone(), self.next));
923        self.next += (padding + raw_bytes) as u64;
924        self.idx_blocks_total += 1;
925        self.block = None;
926        Ok(())
927    }
928
929    /// `writer_dump_object_index`.
930    fn dump_object_index(&mut self) -> Result<()> {
931        // object_id_len = max common prefix among sorted hashes + 1, min 2.
932        let mut max_common = 1usize;
933        for w in self.obj_entries.windows(2) {
934            let n = common_prefix_len(&w[0].hash, &w[1].hash);
935            if n > max_common {
936                max_common = n;
937            }
938        }
939        self.object_id_len = max_common + 1;
940        let id_len = self.object_id_len;
941
942        self.reinit_block(BLOCK_TYPE_OBJ);
943        let entries = std::mem::take(&mut self.obj_entries);
944        for e in &entries {
945            let prefix = e.hash[..id_len.min(e.hash.len())].to_vec();
946            self.add_obj_record(prefix, &e.offsets)?;
947        }
948        self.obj_entries = entries;
949        self.finish_section()
950    }
951
952    fn add_obj_record(&mut self, prefix: Vec<u8>, offsets: &[u64]) -> Result<()> {
953        // Try with full offsets; on overflow in a fresh block, drop offsets.
954        let typ = BLOCK_TYPE_OBJ;
955        if self.block.is_none() {
956            self.reinit_block(typ);
957        }
958        let opts = self.opts.clone();
959        let rec = EncRecord::Obj {
960            prefix: prefix.clone(),
961            offsets: offsets.to_vec(),
962        };
963        let fit = {
964            let bw = self
965                .block
966                .as_mut()
967                .ok_or_else(|| Error::InvalidRef("reftable: no active block writer".into()))?;
968            bw.add(&rec, &opts)?
969        };
970        if fit {
971            return Ok(());
972        }
973        self.flush_block()?;
974        self.reinit_block(typ);
975        let opts = self.opts.clone();
976        let fit = {
977            let bw = self
978                .block
979                .as_mut()
980                .ok_or_else(|| Error::InvalidRef("reftable: no active block writer".into()))?;
981            bw.add(&rec, &opts)?
982        };
983        if fit {
984            return Ok(());
985        }
986        // Drop offsets entirely.
987        let rec = EncRecord::Obj {
988            prefix,
989            offsets: Vec::new(),
990        };
991        let opts = self.opts.clone();
992        let bw = self
993            .block
994            .as_mut()
995            .ok_or_else(|| Error::InvalidRef("reftable: no active block writer".into()))?;
996        bw.add(&rec, &opts)?;
997        Ok(())
998    }
999
1000    /// `writer_finish_public_section`.
1001    fn finish_public_section(&mut self) -> Result<()> {
1002        let Some(bw) = &self.block else {
1003            return Ok(());
1004        };
1005        let typ = bw.typ;
1006        self.finish_section()?;
1007        if typ == BLOCK_TYPE_REF && !self.opts.skip_index_objects && self.ref_stats.index_blocks > 0
1008        {
1009            self.dump_object_index()?;
1010        }
1011        self.obj_entries.clear();
1012        self.block = None;
1013        self.block_type = 0;
1014        Ok(())
1015    }
1016
1017    /// `reftable_writer_close`.
1018    fn close(mut self) -> Result<Vec<u8>> {
1019        self.finish_public_section()?;
1020        let empty_table = self.next == 0;
1021        self.pending_padding = 0;
1022
1023        if empty_table {
1024            let hs = self.header_size();
1025            let mut header = vec![0u8; hs];
1026            self.write_header(&mut header);
1027            self.padded_write(&header, 0);
1028        }
1029
1030        let mut footer = vec![0u8; self.header_size()];
1031        self.write_header(&mut footer);
1032        footer.extend_from_slice(&self.ref_stats.index_offset.to_be_bytes());
1033        let obj_field = (self.obj_stats.offset << 5) | (self.object_id_len as u64);
1034        footer.extend_from_slice(&obj_field.to_be_bytes());
1035        footer.extend_from_slice(&self.obj_stats.index_offset.to_be_bytes());
1036        footer.extend_from_slice(&self.log_stats.offset.to_be_bytes());
1037        footer.extend_from_slice(&self.log_stats.index_offset.to_be_bytes());
1038        let crc = crc32(&footer);
1039        footer.extend_from_slice(&crc.to_be_bytes());
1040
1041        // Footer write drops pending padding (flush() before padded_write).
1042        self.pending_padding = 0;
1043        self.out.extend_from_slice(&footer);
1044
1045        Ok(self.out)
1046    }
1047}
1048
1049// ---------------------------------------------------------------------------
1050// Reader
1051// ---------------------------------------------------------------------------
1052
1053/// Reads a single reftable file from a byte buffer.
1054pub struct ReftableReader {
1055    data: Vec<u8>,
1056    version: u8,
1057    block_size: u32,
1058    min_update_index: u64,
1059    max_update_index: u64,
1060    ref_index_position: u64,
1061    log_position: u64,
1062}
1063
1064/// Parsed footer fields.
1065#[derive(Debug)]
1066#[allow(dead_code)]
1067struct Footer {
1068    version: u8,
1069    block_size: u32,
1070    min_update_index: u64,
1071    max_update_index: u64,
1072    ref_index_position: u64,
1073    obj_position_and_id_len: u64,
1074    obj_index_position: u64,
1075    log_position: u64,
1076    log_index_position: u64,
1077}
1078
1079impl ReftableReader {
1080    /// Open a reftable from bytes.
1081    pub fn new(data: Vec<u8>) -> Result<Self> {
1082        if data.len() < HEADER_SIZE + FOOTER_V1_SIZE {
1083            // Could be an empty table (header + footer only = 24 + 68 = 92)
1084            if data.len() < HEADER_SIZE {
1085                return Err(Error::InvalidRef("reftable: file too small".into()));
1086            }
1087        }
1088
1089        // Parse header
1090        if &data[0..4] != REFTABLE_MAGIC {
1091            return Err(Error::InvalidRef("reftable: bad magic".into()));
1092        }
1093        let version = data[4];
1094        if version != 1 && version != 2 {
1095            return Err(Error::InvalidRef(format!(
1096                "reftable: unsupported version {version}"
1097            )));
1098        }
1099        let _block_size = ((data[5] as u32) << 16) | ((data[6] as u32) << 8) | (data[7] as u32);
1100        let _min_update_index = u64::from_be_bytes(
1101            data[8..16]
1102                .try_into()
1103                .map_err(|_| Error::InvalidRef("reftable: truncated header".into()))?,
1104        );
1105        let _max_update_index = u64::from_be_bytes(
1106            data[16..24]
1107                .try_into()
1108                .map_err(|_| Error::InvalidRef("reftable: truncated header".into()))?,
1109        );
1110
1111        // Parse footer
1112        let footer_size = if version == 2 { 72 } else { FOOTER_V1_SIZE };
1113        if data.len() < footer_size {
1114            return Err(Error::InvalidRef(
1115                "reftable: file too small for footer".into(),
1116            ));
1117        }
1118        let footer_start = data.len() - footer_size;
1119        let footer = parse_footer(&data[footer_start..], version)?;
1120
1121        Ok(Self {
1122            data,
1123            version,
1124            block_size: footer.block_size,
1125            min_update_index: footer.min_update_index,
1126            max_update_index: footer.max_update_index,
1127            ref_index_position: footer.ref_index_position,
1128            log_position: footer.log_position,
1129        })
1130    }
1131
1132    /// Object-id width implied by the reftable version (32 for version 2 / SHA-256,
1133    /// 20 otherwise).
1134    fn hash_size(&self) -> usize {
1135        if self.version == 2 {
1136            32
1137        } else {
1138            20
1139        }
1140    }
1141
1142    /// File-header size implied by the reftable version (28 for version 2, 24 otherwise).
1143    fn header_len(&self) -> usize {
1144        if self.version == 2 {
1145            28
1146        } else {
1147            HEADER_SIZE
1148        }
1149    }
1150
1151    /// Read all ref records from the table.
1152    pub fn read_refs(&self) -> Result<Vec<RefRecord>> {
1153        let mut refs = Vec::new();
1154        let footer_size = if self.version == 2 {
1155            72
1156        } else {
1157            FOOTER_V1_SIZE
1158        };
1159        let file_end = self.data.len() - footer_size;
1160
1161        // Determine where ref blocks end
1162        let ref_end = if self.ref_index_position > 0 {
1163            self.ref_index_position as usize
1164        } else if self.log_position > 0 {
1165            self.log_position as usize
1166        } else {
1167            file_end
1168        };
1169
1170        let mut pos = 0usize;
1171        // Skip the file header — the first ref block shares the header's physical
1172        // block, starting at the header size (24 for v1, 28 for v2).
1173        let header_len = self.header_len();
1174        if pos < header_len {
1175            pos = header_len;
1176        }
1177
1178        while pos < ref_end {
1179            if pos >= self.data.len() {
1180                break;
1181            }
1182            let block_type = self.data[pos];
1183            if block_type == 0 {
1184                // Padding — skip to next block boundary
1185                if self.block_size > 0 {
1186                    let bs = self.block_size as usize;
1187                    pos = ((pos / bs) + 1) * bs;
1188                    continue;
1189                } else {
1190                    break;
1191                }
1192            }
1193            if block_type != BLOCK_TYPE_REF {
1194                break;
1195            }
1196
1197            let block_len = read_u24(&self.data, pos + 1);
1198            // Determine the data range for this block
1199            let block_data_start = pos + 4; // after type(1) + len(3)
1200
1201            // The first block's block_len includes the file header.
1202            let is_first = pos == header_len;
1203            let records_end = if is_first {
1204                // block_len is from file start
1205                block_len
1206            } else {
1207                pos + block_len
1208            };
1209
1210            if records_end > ref_end {
1211                break;
1212            }
1213
1214            // Read restart count (last 2 bytes before padding)
1215            let rc = read_u16(&self.data, records_end - 2);
1216            // Restart table is rc * 3 bytes before the restart_count
1217            let restart_table_start = records_end - 2 - (rc * 3);
1218
1219            // Read records from block_data_start to restart_table_start
1220            let mut rpos = block_data_start;
1221            let mut prev_name = Vec::<u8>::new();
1222
1223            while rpos < restart_table_start {
1224                let (rec, new_pos) = decode_ref_record(
1225                    &self.data,
1226                    rpos,
1227                    &prev_name,
1228                    self.min_update_index,
1229                    self.hash_size(),
1230                )?;
1231                prev_name = rec.name.as_bytes().to_vec();
1232                refs.push(rec);
1233                rpos = new_pos;
1234            }
1235
1236            // Advance to next block
1237            if self.block_size > 0 {
1238                let bs = self.block_size as usize;
1239                if is_first {
1240                    pos = bs;
1241                } else {
1242                    pos += bs;
1243                }
1244            } else {
1245                pos = records_end;
1246            }
1247        }
1248
1249        Ok(refs)
1250    }
1251
1252    /// Look up a single ref by name.
1253    pub fn lookup_ref(&self, name: &str) -> Result<Option<RefRecord>> {
1254        // Simple: scan all refs. For large files the index would speed this up.
1255        let refs = self.read_refs()?;
1256        Ok(refs.into_iter().find(|r| r.name == name))
1257    }
1258
1259    /// Read all log records from the table.
1260    pub fn read_logs(&self) -> Result<Vec<LogRecord>> {
1261        let footer_size = if self.version == 2 {
1262            72
1263        } else {
1264            FOOTER_V1_SIZE
1265        };
1266        let file_end = self.data.len() - footer_size;
1267
1268        // Determine where the log section starts. Git records the log offset in
1269        // the footer, but when the log block is the *first* block in the file it
1270        // shares its physical block with the 24-byte reftable header and the
1271        // recorded offset is left at 0 (see `writer_flush_nonempty_block`'s
1272        // `block_typ_off = (blocks == 0) ? next : 0`). The reader detects this
1273        // by checking whether the first on-disk block (the byte right after the
1274        // header) is a log block — mirroring `is_present` in git's table.c.
1275        let mut pos = if self.log_position > 0 {
1276            self.log_position as usize
1277        } else if self.data.len() > self.header_len()
1278            && self.data[self.header_len()] == BLOCK_TYPE_LOG
1279        {
1280            // Log block is the first block; it begins right after the header.
1281            self.header_len()
1282        } else {
1283            return Ok(Vec::new());
1284        };
1285        let mut logs = Vec::new();
1286
1287        while pos < file_end {
1288            if pos >= self.data.len() {
1289                break;
1290            }
1291            let block_type = self.data[pos];
1292            if block_type != BLOCK_TYPE_LOG {
1293                break;
1294            }
1295            // When the log block shares its physical block with the reftable
1296            // header, the 3-byte block length counts from offset 0 and so
1297            // includes the header bytes; the compressed payload still starts
1298            // right after the type+length header at `pos + 4`.
1299            let is_first = pos == self.header_len() && self.log_position == 0;
1300            let block_len = read_u24(&self.data, pos + 1);
1301            let compressed_start = pos + 4;
1302
1303            // The inflated size is block_len minus the 4-byte type+length header
1304            // (and, for the first block, minus the embedded reftable header).
1305            let header_prefix = if is_first { self.header_len() } else { 0 };
1306            let inflated_size = block_len.saturating_sub(4 + header_prefix);
1307
1308            // Decompress
1309            use flate2::read::DeflateDecoder;
1310            let remaining = &self.data[compressed_start..file_end];
1311            let mut decoder = DeflateDecoder::new(remaining);
1312            let mut inflated = vec![0u8; inflated_size];
1313            decoder
1314                .read_exact(&mut inflated)
1315                .map_err(|e| Error::Zlib(e.to_string()))?;
1316
1317            // How many compressed bytes were consumed?
1318            let consumed = decoder.total_in() as usize;
1319
1320            // Parse log records from inflated data
1321            // Read restart_count from end
1322            if inflated.len() < 2 {
1323                break;
1324            }
1325            let rc = read_u16(&inflated, inflated.len() - 2);
1326            let restart_table_start = inflated.len() - 2 - (rc * 3);
1327
1328            let mut rpos = 0usize;
1329            let mut prev_key = Vec::<u8>::new();
1330
1331            while rpos < restart_table_start {
1332                let (log, new_pos) =
1333                    decode_log_record(&inflated, rpos, &prev_key, self.hash_size())?;
1334                // Reconstruct key for prefix compression
1335                let mut key = Vec::new();
1336                key.extend_from_slice(log.refname.as_bytes());
1337                key.push(0);
1338                key.extend_from_slice(&(0xffffffffffffffffu64 - log.update_index).to_be_bytes());
1339                prev_key = key;
1340                logs.push(log);
1341                rpos = new_pos;
1342            }
1343
1344            pos = compressed_start + consumed;
1345        }
1346
1347        Ok(logs)
1348    }
1349
1350    /// Get the block size from the header.
1351    pub fn block_size(&self) -> u32 {
1352        self.block_size
1353    }
1354
1355    /// Get the min update index.
1356    pub fn min_update_index(&self) -> u64 {
1357        self.min_update_index
1358    }
1359
1360    /// Get the max update index.
1361    pub fn max_update_index(&self) -> u64 {
1362        self.max_update_index
1363    }
1364}
1365
1366// ---------------------------------------------------------------------------
1367// Record decoding helpers
1368// ---------------------------------------------------------------------------
1369
1370fn decode_ref_record(
1371    data: &[u8],
1372    pos: usize,
1373    prev_name: &[u8],
1374    min_update_index: u64,
1375    hash_size: usize,
1376) -> Result<(RefRecord, usize)> {
1377    let (prefix_len, p) = get_varint(data, pos)?;
1378    let (suffix_and_type, mut p) = get_varint(data, p)?;
1379    let suffix_len = (suffix_and_type >> 3) as usize;
1380    let value_type = (suffix_and_type & 0x7) as u8;
1381
1382    // Reconstruct name
1383    let mut name = Vec::with_capacity(prefix_len as usize + suffix_len);
1384    if prefix_len > 0 {
1385        if (prefix_len as usize) > prev_name.len() {
1386            return Err(Error::InvalidRef(
1387                "reftable: prefix_len exceeds prev name".into(),
1388            ));
1389        }
1390        name.extend_from_slice(&prev_name[..prefix_len as usize]);
1391    }
1392    if p + suffix_len > data.len() {
1393        return Err(Error::InvalidRef("reftable: suffix overflows block".into()));
1394    }
1395    name.extend_from_slice(&data[p..p + suffix_len]);
1396    p += suffix_len;
1397
1398    let name_str = String::from_utf8(name)
1399        .map_err(|_| Error::InvalidRef("reftable: invalid UTF-8 in ref name".into()))?;
1400
1401    let (update_index_delta, mut p) = get_varint(data, p)?;
1402    let update_index = min_update_index + update_index_delta;
1403
1404    let value = match value_type {
1405        VALUE_DELETION => RefValue::Deletion,
1406        VALUE_ONE_OID => {
1407            if p + hash_size > data.len() {
1408                return Err(Error::InvalidRef("reftable: truncated OID".into()));
1409            }
1410            let oid = ObjectId::from_bytes(&data[p..p + hash_size])?;
1411            p += hash_size;
1412            RefValue::Val1(oid)
1413        }
1414        VALUE_TWO_OID => {
1415            if p + 2 * hash_size > data.len() {
1416                return Err(Error::InvalidRef("reftable: truncated OID pair".into()));
1417            }
1418            let oid = ObjectId::from_bytes(&data[p..p + hash_size])?;
1419            p += hash_size;
1420            let peeled = ObjectId::from_bytes(&data[p..p + hash_size])?;
1421            p += hash_size;
1422            RefValue::Val2(oid, peeled)
1423        }
1424        VALUE_SYMREF => {
1425            let (target_len, p2) = get_varint(data, p)?;
1426            p = p2;
1427            let target_len = target_len as usize;
1428            if p + target_len > data.len() {
1429                return Err(Error::InvalidRef(
1430                    "reftable: truncated symref target".into(),
1431                ));
1432            }
1433            let target = String::from_utf8(data[p..p + target_len].to_vec())
1434                .map_err(|_| Error::InvalidRef("reftable: invalid UTF-8 in symref".into()))?;
1435            p += target_len;
1436            RefValue::Symref(target)
1437        }
1438        _ => {
1439            return Err(Error::InvalidRef(format!(
1440                "reftable: unknown value_type {value_type}"
1441            )));
1442        }
1443    };
1444
1445    Ok((
1446        RefRecord {
1447            name: name_str,
1448            update_index,
1449            value,
1450        },
1451        p,
1452    ))
1453}
1454
1455fn decode_log_record(
1456    data: &[u8],
1457    pos: usize,
1458    prev_key: &[u8],
1459    hash_size: usize,
1460) -> Result<(LogRecord, usize)> {
1461    let (prefix_len, p) = get_varint(data, pos)?;
1462    let (suffix_and_type, mut p) = get_varint(data, p)?;
1463    let suffix_len = (suffix_and_type >> 3) as usize;
1464    let log_type = (suffix_and_type & 0x7) as u8;
1465
1466    // Reconstruct key
1467    let mut key = Vec::with_capacity(prefix_len as usize + suffix_len);
1468    if prefix_len > 0 {
1469        if (prefix_len as usize) > prev_key.len() {
1470            return Err(Error::InvalidRef(
1471                "reftable: log prefix_len exceeds prev key".into(),
1472            ));
1473        }
1474        key.extend_from_slice(&prev_key[..prefix_len as usize]);
1475    }
1476    if p + suffix_len > data.len() {
1477        return Err(Error::InvalidRef("reftable: log suffix overflows".into()));
1478    }
1479    key.extend_from_slice(&data[p..p + suffix_len]);
1480    p += suffix_len;
1481
1482    // Parse key: refname \0 reverse_int64(update_index)
1483    let null_pos = key
1484        .iter()
1485        .position(|&b| b == 0)
1486        .ok_or_else(|| Error::InvalidRef("reftable: log key missing null separator".into()))?;
1487    let refname = String::from_utf8(key[..null_pos].to_vec())
1488        .map_err(|_| Error::InvalidRef("reftable: invalid UTF-8 in log refname".into()))?;
1489    if null_pos + 9 > key.len() {
1490        return Err(Error::InvalidRef("reftable: log key too short".into()));
1491    }
1492    let reversed_idx = u64::from_be_bytes(
1493        key[null_pos + 1..null_pos + 9]
1494            .try_into()
1495            .map_err(|_| Error::InvalidRef("reftable: log key too short".into()))?,
1496    );
1497    let update_index = 0xffffffffffffffffu64 - reversed_idx;
1498
1499    if log_type == 0 {
1500        // Deletion
1501        let zero_oid = ObjectId::from_bytes(&vec![0u8; hash_size])?;
1502        return Ok((
1503            LogRecord {
1504                refname,
1505                update_index,
1506                old_id: zero_oid,
1507                new_id: zero_oid,
1508                name: String::new(),
1509                email: String::new(),
1510                time_seconds: 0,
1511                tz_offset: 0,
1512                message: String::new(),
1513            },
1514            p,
1515        ));
1516    }
1517
1518    // log_type == 1: standard log data
1519    if p + 2 * hash_size > data.len() {
1520        return Err(Error::InvalidRef("reftable: truncated log OIDs".into()));
1521    }
1522    let old_id = ObjectId::from_bytes(&data[p..p + hash_size])?;
1523    p += hash_size;
1524    let new_id = ObjectId::from_bytes(&data[p..p + hash_size])?;
1525    p += hash_size;
1526
1527    let (name_len, p2) = get_varint(data, p)?;
1528    p = p2;
1529    let name_len = name_len as usize;
1530    if p + name_len > data.len() {
1531        return Err(Error::InvalidRef("reftable: truncated log name".into()));
1532    }
1533    let name = String::from_utf8(data[p..p + name_len].to_vec())
1534        .map_err(|_| Error::InvalidRef("reftable: invalid UTF-8 in log name".into()))?;
1535    p += name_len;
1536
1537    let (email_len, p2) = get_varint(data, p)?;
1538    p = p2;
1539    let email_len = email_len as usize;
1540    if p + email_len > data.len() {
1541        return Err(Error::InvalidRef("reftable: truncated log email".into()));
1542    }
1543    let email = String::from_utf8(data[p..p + email_len].to_vec())
1544        .map_err(|_| Error::InvalidRef("reftable: invalid UTF-8 in log email".into()))?;
1545    p += email_len;
1546
1547    let (time_seconds, p2) = get_varint(data, p)?;
1548    p = p2;
1549
1550    if p + 2 > data.len() {
1551        return Err(Error::InvalidRef("reftable: truncated tz_offset".into()));
1552    }
1553    let tz_offset = i16::from_be_bytes([data[p], data[p + 1]]);
1554    p += 2;
1555
1556    let (msg_len, p2) = get_varint(data, p)?;
1557    p = p2;
1558    let msg_len = msg_len as usize;
1559    if p + msg_len > data.len() {
1560        return Err(Error::InvalidRef("reftable: truncated log message".into()));
1561    }
1562    let message = String::from_utf8(data[p..p + msg_len].to_vec())
1563        .map_err(|_| Error::InvalidRef("reftable: invalid UTF-8 in log message".into()))?;
1564    p += msg_len;
1565
1566    Ok((
1567        LogRecord {
1568            refname,
1569            update_index,
1570            old_id,
1571            new_id,
1572            name,
1573            email,
1574            time_seconds,
1575            tz_offset,
1576            message,
1577        },
1578        p,
1579    ))
1580}
1581
1582// ---------------------------------------------------------------------------
1583// Stack management
1584// ---------------------------------------------------------------------------
1585
1586/// Widen a null OID to `hash_size` bytes so every OID written into a reftable
1587/// shares the table's hash width. Non-null OIDs (real objects) are already at the
1588/// repository width and are returned unchanged.
1589fn widen_oid_to(oid: ObjectId, hash_size: usize) -> ObjectId {
1590    if oid.is_zero() && oid.as_bytes().len() != hash_size {
1591        ObjectId::from_bytes(&vec![0u8; hash_size]).unwrap_or(oid)
1592    } else {
1593        oid
1594    }
1595}
1596
1597/// Object-id width for reftables in the repository owning `git_dir`: 32 bytes
1598/// (reftable version 2) when `extensions.objectformat=sha256`, else 20 (version 1).
1599fn reftable_hash_size_for_git_dir(git_dir: &Path) -> usize {
1600    let cfg = crate::config::ConfigSet::load(Some(git_dir), true).unwrap_or_default();
1601    match cfg
1602        .get("extensions.objectformat")
1603        .and_then(|v| crate::objects::HashAlgo::from_name(&v))
1604    {
1605        Some(crate::objects::HashAlgo::Sha256) => 32,
1606        _ => 20,
1607    }
1608}
1609
1610/// Manages the `$GIT_DIR/reftable/` directory and `tables.list` stack.
1611///
1612/// The stack provides a merged view of all tables, with later tables
1613/// taking precedence over earlier ones.
1614pub struct ReftableStack {
1615    /// Path to the `reftable/` directory.
1616    reftable_dir: PathBuf,
1617    /// Ordered list of table file names (oldest first).
1618    table_names: Vec<String>,
1619}
1620
1621/// RAII guard for `tables.list.lock`. Removes the lock file on drop unless it was
1622/// consumed (renamed onto `tables.list`) via [`disarm`].
1623struct TablesListLock {
1624    path: PathBuf,
1625    armed: std::cell::Cell<bool>,
1626}
1627
1628impl TablesListLock {
1629    fn new(path: PathBuf) -> Self {
1630        Self {
1631            path,
1632            armed: std::cell::Cell::new(true),
1633        }
1634    }
1635
1636    /// Mark the lock as consumed so its `Drop` does not remove the path (it has
1637    /// been renamed onto `tables.list`).
1638    fn disarm(&self) {
1639        self.armed.set(false);
1640    }
1641}
1642
1643impl Drop for TablesListLock {
1644    fn drop(&mut self) {
1645        if self.armed.get() {
1646            let _ = fs::remove_file(&self.path);
1647        }
1648    }
1649}
1650
1651impl ReftableStack {
1652    /// Object-id width (20 or 32) for reftables written into this stack, from the
1653    /// owning repository's `extensions.objectformat`.
1654    fn hash_size(&self) -> usize {
1655        match self.reftable_dir.parent() {
1656            Some(git_dir) => reftable_hash_size_for_git_dir(git_dir),
1657            None => 20,
1658        }
1659    }
1660
1661    /// Open an existing reftable stack.
1662    pub fn open(git_dir: &Path) -> Result<Self> {
1663        let reftable_dir = git_dir.join("reftable");
1664        let tables_list = reftable_dir.join("tables.list");
1665        let content = fs::read_to_string(&tables_list).map_err(Error::Io)?;
1666        let table_names: Vec<String> = content
1667            .lines()
1668            .filter(|l| !l.is_empty())
1669            .map(|l| l.to_owned())
1670            .collect();
1671        Ok(Self {
1672            reftable_dir,
1673            table_names,
1674        })
1675    }
1676
1677    /// Inject the HEAD symbolic ref into the ref set being compacted, mirroring
1678    /// git's reftable layout where HEAD lives inside the table.
1679    ///
1680    /// Returns a HEAD reflog record to add to the log section if the target
1681    /// branch has a most-recent reflog entry (so HEAD@{0} mirrors it).
1682    fn inject_head_ref(&self, refs: &mut Vec<RefRecord>, min_idx: u64) -> Option<LogRecord> {
1683        let git_dir = self.reftable_dir.parent()?;
1684        let head_path = git_dir.join("HEAD");
1685        let content = fs::read_to_string(&head_path).ok()?;
1686        let target = content.strip_prefix("ref: ")?.trim();
1687        if target.is_empty() || target == "refs/heads/.invalid" {
1688            return None;
1689        }
1690        // Only inject HEAD if it is not already present.
1691        if refs.iter().any(|r| r.name == "HEAD") {
1692            return None;
1693        }
1694        // HEAD takes the smallest update index (git assigns it the first one).
1695        refs.push(RefRecord {
1696            name: "HEAD".to_owned(),
1697            update_index: min_idx,
1698            value: RefValue::Symref(target.to_owned()),
1699        });
1700        refs.sort_by(|a, b| a.name.cmp(&b.name));
1701
1702        // HEAD reflog entries are already written separately by the commit /
1703        // update-ref paths (`append_reflog("HEAD", …)`). Only synthesize a
1704        // mirror of the branch's newest entry when HEAD has no reflog of its
1705        // own — otherwise compaction would duplicate HEAD@{0} (yielding an
1706        // extra log record and an oversized log block, t0613 'default write
1707        // options').
1708        if self
1709            .read_logs_for_ref("HEAD")
1710            .map(|logs| !logs.is_empty())
1711            .unwrap_or(false)
1712        {
1713            return None;
1714        }
1715
1716        // Mirror the target branch's newest reflog entry as HEAD@{0}.
1717        let target_logs = self.read_logs_for_ref(target).ok()?;
1718        let newest = target_logs.into_iter().next()?;
1719        Some(LogRecord {
1720            refname: "HEAD".to_owned(),
1721            update_index: newest.update_index,
1722            old_id: newest.old_id,
1723            new_id: newest.new_id,
1724            name: newest.name,
1725            email: newest.email,
1726            time_seconds: newest.time_seconds,
1727            tz_offset: newest.tz_offset,
1728            message: newest.message,
1729        })
1730    }
1731
1732    /// Read the configured reftable write options from this repo's config.
1733    fn write_options(&self) -> WriteOptions {
1734        let git_dir = self
1735            .reftable_dir
1736            .parent()
1737            .map(|p| p.to_path_buf())
1738            .unwrap_or_else(|| self.reftable_dir.clone());
1739        read_write_options(&git_dir)
1740    }
1741
1742    /// Read a merged view of all ref records.
1743    ///
1744    /// Later tables override earlier ones. Deletion records cause the
1745    /// ref to be omitted from the result.
1746    pub fn read_refs(&self) -> Result<Vec<RefRecord>> {
1747        let mut merged: BTreeMap<String, RefRecord> = BTreeMap::new();
1748
1749        for name in &self.table_names {
1750            let path = self.reftable_dir.join(name);
1751            let data = match fs::read(&path) {
1752                Ok(data) => data,
1753                Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
1754                Err(err) => return Err(Error::Io(err)),
1755            };
1756            let reader = ReftableReader::new(data)?;
1757            for rec in reader.read_refs()? {
1758                match &rec.value {
1759                    RefValue::Deletion => {
1760                        merged.remove(&rec.name);
1761                    }
1762                    _ => {
1763                        merged.insert(rec.name.clone(), rec);
1764                    }
1765                }
1766            }
1767        }
1768
1769        Ok(merged.into_values().collect())
1770    }
1771
1772    /// Look up a single ref across all tables (most recent wins).
1773    pub fn lookup_ref(&self, name: &str) -> Result<Option<RefRecord>> {
1774        // Search tables in reverse (newest first)
1775        for table_name in self.table_names.iter().rev() {
1776            let path = self.reftable_dir.join(table_name);
1777            let data = match fs::read(&path) {
1778                Ok(data) => data,
1779                Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
1780                Err(err) => return Err(Error::Io(err)),
1781            };
1782            let reader = ReftableReader::new(data)?;
1783            if let Some(rec) = reader.lookup_ref(name)? {
1784                return match rec.value {
1785                    RefValue::Deletion => Ok(None),
1786                    _ => Ok(Some(rec)),
1787                };
1788            }
1789        }
1790        Ok(None)
1791    }
1792
1793    /// Read merged log records for a specific ref.
1794    pub fn read_logs_for_ref(&self, refname: &str) -> Result<Vec<LogRecord>> {
1795        let mut logs = Vec::new();
1796        for table_name in &self.table_names {
1797            let path = self.reftable_dir.join(table_name);
1798            let data = fs::read(&path).map_err(Error::Io)?;
1799            let reader = ReftableReader::new(data)?;
1800            for log in reader.read_logs()? {
1801                if log.refname == refname {
1802                    logs.push(log);
1803                }
1804            }
1805        }
1806        // Sort by update_index descending (most recent first)
1807        logs.sort_by(|a, b| b.update_index.cmp(&a.update_index));
1808        Ok(logs)
1809    }
1810
1811    /// Replace all log records for one ref and compact the stack.
1812    pub fn replace_logs_for_ref(
1813        &mut self,
1814        refname: &str,
1815        entries: &[crate::reflog::ReflogEntry],
1816    ) -> Result<()> {
1817        let refs = self.read_refs()?;
1818        let mut logs: Vec<LogRecord> = self
1819            .read_all_logs()?
1820            .into_iter()
1821            .filter(|log| log.refname != refname)
1822            .collect();
1823        let mut next_update_index = self.max_update_index()? + 1;
1824        let hash_size = self.hash_size();
1825        for entry in entries {
1826            let (name, email, time_secs, tz) = parse_identity_string(&entry.identity);
1827            logs.push(LogRecord {
1828                refname: refname.to_owned(),
1829                update_index: next_update_index,
1830                old_id: widen_oid_to(entry.old_oid, hash_size),
1831                new_id: widen_oid_to(entry.new_oid, hash_size),
1832                name,
1833                email,
1834                time_seconds: time_secs,
1835                tz_offset: tz,
1836                message: entry.message.clone(),
1837            });
1838            next_update_index += 1;
1839        }
1840
1841        let mut min_idx = u64::MAX;
1842        let mut max_idx = 0u64;
1843        for name in &self.table_names {
1844            let path = self.reftable_dir.join(name);
1845            let data = fs::read(&path).map_err(Error::Io)?;
1846            let reader = ReftableReader::new(data)?;
1847            min_idx = min_idx.min(reader.min_update_index());
1848            max_idx = max_idx.max(reader.max_update_index());
1849        }
1850        if min_idx == u64::MAX {
1851            min_idx = 0;
1852        }
1853        max_idx = max_idx.max(next_update_index.saturating_sub(1));
1854
1855        let mut wopts = WriteOptions::default();
1856        wopts.hash_size = self.hash_size();
1857        let mut writer = ReftableWriter::new(wopts, min_idx, max_idx);
1858        for rec in refs {
1859            writer.add_ref(rec)?;
1860        }
1861        for log in logs {
1862            writer.add_log(log)?;
1863        }
1864        let data = writer.finish()?;
1865        let old_names = self.table_names.clone();
1866        let name = self.write_table_file(&data, max_idx)?;
1867        self.table_names = vec![name];
1868        self.write_tables_list()?;
1869        for old in &old_names {
1870            let _ = fs::remove_file(self.reftable_dir.join(old));
1871        }
1872        Ok(())
1873    }
1874
1875    /// Read all log records across all tables.
1876    pub fn read_all_logs(&self) -> Result<Vec<LogRecord>> {
1877        let mut logs = Vec::new();
1878        for table_name in &self.table_names {
1879            let path = self.reftable_dir.join(table_name);
1880            let data = fs::read(&path).map_err(Error::Io)?;
1881            let reader = ReftableReader::new(data)?;
1882            logs.extend(reader.read_logs()?);
1883        }
1884        logs.sort_by(|a, b| {
1885            a.refname
1886                .cmp(&b.refname)
1887                .then_with(|| b.update_index.cmp(&a.update_index))
1888        });
1889        Ok(logs)
1890    }
1891
1892    /// Get the current max update index across all tables.
1893    ///
1894    /// Reads the authoritative on-disk `tables.list` rather than the (possibly
1895    /// stale) in-memory snapshot, and tolerates tables that a concurrent
1896    /// compaction removed between listing and reading: such a table's update
1897    /// index is subsumed by the compacted result that replaced it, which is also
1898    /// in the freshly-read list.
1899    pub fn max_update_index(&self) -> Result<u64> {
1900        let names: Vec<String> = match fs::read_to_string(self.reftable_dir.join("tables.list")) {
1901            Ok(content) => content
1902                .lines()
1903                .filter(|line| !line.is_empty())
1904                .map(ToOwned::to_owned)
1905                .collect(),
1906            Err(_) => self.table_names.clone(),
1907        };
1908        let mut max_idx = 0u64;
1909        for name in &names {
1910            let path = self.reftable_dir.join(name);
1911            let data = match fs::read(&path) {
1912                Ok(data) => data,
1913                Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
1914                Err(err) => return Err(Error::Io(err)),
1915            };
1916            let reader = ReftableReader::new(data)?;
1917            max_idx = max_idx.max(reader.max_update_index());
1918        }
1919        Ok(max_idx)
1920    }
1921
1922    /// Add a new reftable to the stack.
1923    ///
1924    /// Writes the table bytes to a new file, then atomically updates
1925    /// `tables.list`.
1926    pub fn add_table(&mut self, data: &[u8], update_index: u64) -> Result<String> {
1927        let table_has_deletion = ReftableReader::new(data.to_vec())
1928            .and_then(|reader| reader.read_refs())
1929            .map(|records| {
1930                records
1931                    .iter()
1932                    .any(|record| matches!(record.value, RefValue::Deletion))
1933            })
1934            .unwrap_or(false);
1935        let random: u64 = {
1936            // Simple random from /dev/urandom or time-based fallback
1937            let mut buf = [0u8; 8];
1938            if let Ok(mut f) = fs::File::open("/dev/urandom") {
1939                let _ = f.read(&mut buf);
1940            }
1941            u64::from_le_bytes(buf)
1942        };
1943        let filename = format!(
1944            "{:08x}-{:08x}-{:08x}.ref",
1945            update_index, update_index, random as u32
1946        );
1947        let path = self.reftable_dir.join(&filename);
1948        fs::write(&path, data).map_err(Error::Io)?;
1949
1950        // Serialize the read-modify-write of `tables.list` so concurrent writers
1951        // do not clobber each other (and so we never compact away a table that a
1952        // peer just appended). Re-read the on-disk stack under the lock before
1953        // extending it — our in-memory `table_names` may be stale.
1954        {
1955            let guard = self.acquire_tables_list_lock()?;
1956            self.reload_table_names();
1957            self.table_names.push(filename.clone());
1958            self.write_tables_list_locked(&guard)?;
1959        }
1960
1961        // Auto-compact small write bursts into a single table. A plain commit writes several small
1962        // ref/log updates and should settle back to one table; a following tag write remains as a
1963        // second table until explicit `pack-refs`.
1964        if table_has_deletion && self.table_names.len() > 2 {
1965            self.compact_prefix_preserving_newest()?;
1966        } else if self.table_names.len() > 3
1967            && std::env::var("GIT_TEST_REFTABLE_AUTOCOMPACTION")
1968                .map(|value| value != "false")
1969                .unwrap_or(true)
1970        {
1971            if self
1972                .table_names
1973                .iter()
1974                .any(|name| self.table_is_locked(name))
1975            {
1976                self.compact_unlocked_suffix()?;
1977            } else {
1978                self.compact()?;
1979            }
1980        }
1981
1982        Ok(filename)
1983    }
1984
1985    fn compact_prefix_preserving_newest(&mut self) -> Result<()> {
1986        if std::env::var("GIT_TEST_REFTABLE_AUTOCOMPACTION")
1987            .map(|value| value == "false")
1988            .unwrap_or(false)
1989        {
1990            return Ok(());
1991        }
1992        let guard = self.acquire_tables_list_lock()?;
1993        self.reload_table_names();
1994        if self.table_names.len() <= 2 {
1995            return Ok(());
1996        }
1997        let newest =
1998            self.table_names.last().cloned().ok_or_else(|| {
1999                Error::InvalidRef("reftable: table stack unexpectedly empty".into())
2000            })?;
2001        let old_names: Vec<String> = self.table_names[..self.table_names.len() - 1].to_vec();
2002        let prefix_stack = Self {
2003            reftable_dir: self.reftable_dir.clone(),
2004            table_names: old_names.clone(),
2005        };
2006        let refs = prefix_stack.read_refs()?;
2007        let logs = prefix_stack.read_all_logs()?;
2008
2009        let mut min_idx = u64::MAX;
2010        let mut max_idx = 0u64;
2011        for name in &old_names {
2012            let path = self.reftable_dir.join(name);
2013            let data = fs::read(&path).map_err(Error::Io)?;
2014            let reader = ReftableReader::new(data)?;
2015            min_idx = min_idx.min(reader.min_update_index());
2016            max_idx = max_idx.max(reader.max_update_index());
2017        }
2018        if min_idx == u64::MAX {
2019            min_idx = 0;
2020        }
2021
2022        let mut wopts = WriteOptions::default();
2023        wopts.hash_size = self.hash_size();
2024        let mut writer = ReftableWriter::new(wopts, min_idx, max_idx);
2025        for rec in refs {
2026            writer.add_ref(rec)?;
2027        }
2028        for log in logs {
2029            writer.add_log(log)?;
2030        }
2031        let data = writer.finish()?;
2032        let filename = self.write_table_file(&data, max_idx)?;
2033        let keep: Vec<String> = vec![filename.clone(), newest.clone()];
2034        self.table_names = keep;
2035        self.write_tables_list_locked(&guard)?;
2036        for old in &old_names {
2037            if old == &filename || old == &newest {
2038                continue;
2039            }
2040            let _ = fs::remove_file(self.reftable_dir.join(old));
2041        }
2042        Ok(())
2043    }
2044
2045    fn table_is_locked(&self, name: &str) -> bool {
2046        self.reftable_dir.join(format!("{name}.lock")).exists()
2047    }
2048
2049    fn compact_unlocked_suffix(&mut self) -> Result<()> {
2050        let guard = self.acquire_tables_list_lock()?;
2051        self.reload_table_names();
2052        let first_unlocked = self
2053            .table_names
2054            .iter()
2055            .position(|name| !self.table_is_locked(name))
2056            .unwrap_or(self.table_names.len());
2057        if self.table_names.len().saturating_sub(first_unlocked) <= 1 {
2058            return Ok(());
2059        }
2060
2061        let locked_prefix: Vec<String> = self.table_names[..first_unlocked].to_vec();
2062        let old_suffix: Vec<String> = self.table_names[first_unlocked..].to_vec();
2063        let suffix_stack = Self {
2064            reftable_dir: self.reftable_dir.clone(),
2065            table_names: old_suffix.clone(),
2066        };
2067        let refs = suffix_stack.read_refs()?;
2068        let logs = suffix_stack.read_all_logs()?;
2069
2070        let mut min_idx = u64::MAX;
2071        let mut max_idx = 0u64;
2072        for name in &old_suffix {
2073            let path = self.reftable_dir.join(name);
2074            let data = fs::read(&path).map_err(Error::Io)?;
2075            let reader = ReftableReader::new(data)?;
2076            min_idx = min_idx.min(reader.min_update_index());
2077            max_idx = max_idx.max(reader.max_update_index());
2078        }
2079        if min_idx == u64::MAX {
2080            min_idx = 0;
2081        }
2082
2083        let mut wopts = WriteOptions::default();
2084        wopts.hash_size = self.hash_size();
2085        let mut writer = ReftableWriter::new(wopts, min_idx, max_idx);
2086        for rec in refs {
2087            writer.add_ref(rec)?;
2088        }
2089        for log in logs {
2090            writer.add_log(log)?;
2091        }
2092        let data = writer.finish()?;
2093        let compacted = self.write_table_file(&data, max_idx)?;
2094
2095        self.table_names = locked_prefix;
2096        self.table_names.push(compacted.clone());
2097        self.write_tables_list_locked(&guard)?;
2098        for old in &old_suffix {
2099            if old == &compacted {
2100                continue;
2101            }
2102            let _ = fs::remove_file(self.reftable_dir.join(old));
2103        }
2104        Ok(())
2105    }
2106
2107    /// Write a ref update (add/update/delete) as a new reftable.
2108    ///
2109    /// This is the main entry point for updating refs in a reftable repo.
2110    pub fn write_ref(
2111        &mut self,
2112        refname: &str,
2113        value: RefValue,
2114        log: Option<LogRecord>,
2115        opts: &WriteOptions,
2116    ) -> Result<()> {
2117        // Compute the update index, build the new single-record table, and append
2118        // it to `tables.list` while holding the stack lock, reading the current
2119        // on-disk list under the lock. This makes the whole read-modify-write
2120        // atomic with respect to other writers (t0610 'many concurrent
2121        // writers') — otherwise two writers can pick the same base list and the
2122        // second overwrites the first's `tables.list`, dropping a ref.
2123        {
2124            let guard = self.acquire_tables_list_lock()?;
2125            self.reload_table_names();
2126            let update_index = self.max_update_index_unlocked()? + 1;
2127            let mut writer = ReftableWriter::new(opts.clone(), update_index, update_index);
2128            writer.add_ref(RefRecord {
2129                name: refname.to_owned(),
2130                update_index,
2131                value,
2132            })?;
2133            if let Some(log_rec) = log {
2134                let mut log_rec = log_rec;
2135                log_rec.update_index = update_index;
2136                writer.add_log(log_rec)?;
2137            }
2138            let data = writer.finish()?;
2139            let filename = self.write_table_file(&data, update_index)?;
2140            self.table_names.push(filename);
2141            self.write_tables_list_locked(&guard)?;
2142        }
2143
2144        // Auto-compaction runs after releasing the append lock; it re-acquires
2145        // the lock internally and works from a fresh view of the stack.
2146        self.maybe_auto_compact()?;
2147        Ok(())
2148    }
2149
2150    /// Write several ref updates as a single reftable transaction.
2151    ///
2152    /// All ref and log records are stored in one table with one shared update
2153    /// index. This mirrors Git's reftable transaction behavior and keeps
2154    /// compacted table layout stable for large `update-ref --stdin` batches.
2155    pub fn write_transaction(
2156        &mut self,
2157        updates: Vec<ReftableTransactionUpdate>,
2158        opts: &WriteOptions,
2159    ) -> Result<()> {
2160        if updates.is_empty() {
2161            return Ok(());
2162        }
2163
2164        {
2165            let guard = self.acquire_tables_list_lock()?;
2166            self.reload_table_names();
2167            let update_index = self.max_update_index_unlocked()? + 1;
2168            let mut writer = ReftableWriter::new(opts.clone(), update_index, update_index);
2169
2170            let mut updates = updates;
2171            updates.sort_by(|a, b| a.refname.cmp(&b.refname));
2172            for update in &updates {
2173                writer.add_ref(RefRecord {
2174                    name: update.refname.clone(),
2175                    update_index,
2176                    value: update.value.clone(),
2177                })?;
2178            }
2179            for update in updates {
2180                if let Some(mut log) = update.log {
2181                    log.update_index = update_index;
2182                    writer.add_log(log)?;
2183                }
2184            }
2185
2186            let data = writer.finish()?;
2187            let filename = self.write_table_file(&data, update_index)?;
2188            self.table_names.push(filename);
2189            self.write_tables_list_locked(&guard)?;
2190        }
2191
2192        self.maybe_auto_compact()?;
2193        Ok(())
2194    }
2195
2196    /// Max update index from the *current* in-memory `table_names` (caller is
2197    /// expected to have reloaded under the lock), tolerating tables removed by a
2198    /// concurrent compaction.
2199    fn max_update_index_unlocked(&self) -> Result<u64> {
2200        let mut max_idx = 0u64;
2201        for name in &self.table_names {
2202            let path = self.reftable_dir.join(name);
2203            let data = match fs::read(&path) {
2204                Ok(data) => data,
2205                Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
2206                Err(err) => return Err(Error::Io(err)),
2207            };
2208            let reader = ReftableReader::new(data)?;
2209            max_idx = max_idx.max(reader.max_update_index());
2210        }
2211        Ok(max_idx)
2212    }
2213
2214    /// Run the auto-compaction policy (matching `add_table`) without appending a
2215    /// new table. Re-reads the stack under the lock to avoid racing.
2216    fn maybe_auto_compact(&mut self) -> Result<()> {
2217        self.reload_table_names();
2218        let has_locked = self
2219            .table_names
2220            .iter()
2221            .any(|name| self.table_is_locked(name));
2222        if self.table_names.len() > 3
2223            && std::env::var("GIT_TEST_REFTABLE_AUTOCOMPACTION")
2224                .map(|value| value != "false")
2225                .unwrap_or(true)
2226        {
2227            if has_locked {
2228                self.compact_unlocked_suffix()?;
2229            } else {
2230                self.compact()?;
2231            }
2232        }
2233        Ok(())
2234    }
2235
2236    /// Compact all tables into a single table.
2237    ///
2238    /// `git pack-refs` always rewrites the whole stack into a single,
2239    /// canonically-laid-out table even when there is just one table, so that
2240    /// padding/block layout match the configured write options.
2241    pub fn compact(&mut self) -> Result<()> {
2242        // Hold the stack lock across the whole compaction (read tables -> write
2243        // compacted table -> rewrite tables.list -> delete old tables) and work
2244        // from the freshly-read on-disk list, so a concurrent writer that
2245        // appended a table after we opened the stack is not silently dropped.
2246        let guard = self.acquire_tables_list_lock()?;
2247        self.reload_table_names();
2248        if self.table_names.is_empty() {
2249            return Ok(());
2250        }
2251
2252        // Read all refs and logs
2253        let refs = self.read_refs()?;
2254        let logs = self.read_all_logs()?;
2255
2256        // Determine update index range
2257        let mut min_idx = u64::MAX;
2258        let mut max_idx = 0u64;
2259        for name in &self.table_names {
2260            let path = self.reftable_dir.join(name);
2261            let data = fs::read(&path).map_err(Error::Io)?;
2262            let reader = ReftableReader::new(data)?;
2263            min_idx = min_idx.min(reader.min_update_index());
2264            max_idx = max_idx.max(reader.max_update_index());
2265        }
2266        if min_idx == u64::MAX {
2267            min_idx = 0;
2268        }
2269
2270        // Use the configured write options (block size, restart interval,
2271        // object index, logAllRefUpdates) rather than defaults.
2272        let opts = self.write_options();
2273
2274        // Git stores HEAD as a symbolic ref inside the reftable (the on-disk
2275        // `.git/HEAD` is only a `.invalid` stub). grit keeps the real HEAD in
2276        // `.git/HEAD`, so inject it into the compacted table to match git's
2277        // on-disk layout.
2278        let mut refs = refs;
2279        let head_log = self.inject_head_ref(&mut refs, min_idx);
2280
2281        let mut writer = ReftableWriter::new(opts.clone(), min_idx, max_idx);
2282        for rec in refs {
2283            writer.add_ref(rec)?;
2284        }
2285        if opts.write_log {
2286            let mut logs = logs;
2287            if let Some(hl) = head_log {
2288                logs.push(hl);
2289            }
2290            for log in logs {
2291                writer.add_log(log)?;
2292            }
2293        }
2294
2295        let data = writer.finish()?;
2296
2297        // Write new compacted table
2298        let old_names = self.table_names.clone();
2299        self.table_names.clear();
2300        let name = self.write_table_file(&data, max_idx)?;
2301        self.table_names.push(name.clone());
2302        self.write_tables_list_locked(&guard)?;
2303
2304        // Remove old table files (never the freshly written compacted table).
2305        for old in &old_names {
2306            if old == &name {
2307                continue;
2308            }
2309            let path = self.reftable_dir.join(old);
2310            let _ = fs::remove_file(&path);
2311        }
2312
2313        Ok(())
2314    }
2315
2316    fn write_table_file(&self, data: &[u8], update_index: u64) -> Result<String> {
2317        let random: u64 = {
2318            let mut buf = [0u8; 8];
2319            if let Ok(mut f) = fs::File::open("/dev/urandom") {
2320                let _ = f.read(&mut buf);
2321            }
2322            u64::from_le_bytes(buf)
2323        };
2324        let filename = format!(
2325            "{:08x}-{:08x}-{:08x}.ref",
2326            update_index, update_index, random as u32
2327        );
2328        let path = self.reftable_dir.join(&filename);
2329        fs::write(&path, data).map_err(Error::Io)?;
2330        Ok(filename)
2331    }
2332
2333    /// Write `tables.list` atomically.
2334    ///
2335    /// Acquires `tables.list.lock` exclusively for the duration of the write so
2336    /// it can never race with another writer. Callers that need a read followed
2337    /// by a write to be atomic (e.g. [`add_table`]) should instead acquire the
2338    /// lock with [`acquire_tables_list_lock`] and call
2339    /// [`write_tables_list_locked`] while holding it.
2340    fn write_tables_list(&self) -> Result<()> {
2341        let guard = self.acquire_tables_list_lock()?;
2342        self.write_tables_list_locked(&guard)
2343    }
2344
2345    /// Write `tables.list` while already holding the lock guard.
2346    fn write_tables_list_locked(&self, guard: &TablesListLock) -> Result<()> {
2347        let tables_list = self.reftable_dir.join("tables.list");
2348        let content = self.table_names.join("\n")
2349            + if self.table_names.is_empty() {
2350                ""
2351            } else {
2352                "\n"
2353            };
2354        fs::write(&guard.path, &content).map_err(Error::Io)?;
2355        // `fs::rename` consumes the lock file; mark the guard disarmed so its
2356        // Drop does not try to remove the (now-renamed) path.
2357        fs::rename(&guard.path, &tables_list).map_err(Error::Io)?;
2358        guard.disarm();
2359        Ok(())
2360    }
2361
2362    fn lock_timeout_ms(&self) -> u64 {
2363        let git_dir = self
2364            .reftable_dir
2365            .parent()
2366            .unwrap_or(self.reftable_dir.as_path());
2367        let config = ConfigSet::load(Some(git_dir), true).unwrap_or_else(|_| ConfigSet::new());
2368        config
2369            .get("reftable.lockTimeout")
2370            .and_then(|value| value.parse::<u64>().ok())
2371            .unwrap_or(1000)
2372    }
2373
2374    /// Atomically acquire `tables.list.lock` (O_CREAT|O_EXCL), retrying up to the
2375    /// configured `reftable.lockTimeout`. Mirrors git's reftable stack locking so
2376    /// concurrent writers serialize instead of clobbering each other's
2377    /// `tables.list` (t0610 'ref transaction: many concurrent writers').
2378    fn acquire_tables_list_lock(&self) -> Result<TablesListLock> {
2379        let lock = self.reftable_dir.join("tables.list.lock");
2380        let timeout_ms = self.lock_timeout_ms();
2381        let deadline = Instant::now() + Duration::from_millis(timeout_ms);
2382        loop {
2383            match fs::OpenOptions::new()
2384                .write(true)
2385                .create_new(true)
2386                .open(&lock)
2387            {
2388                Ok(_) => return Ok(TablesListLock::new(lock)),
2389                Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
2390                    if timeout_ms == 0 || Instant::now() >= deadline {
2391                        return Err(Error::InvalidRef(
2392                            "cannot lock references: data is locked".to_owned(),
2393                        ));
2394                    }
2395                    thread::sleep(Duration::from_millis(20));
2396                }
2397                Err(err) => return Err(Error::Io(err)),
2398            }
2399        }
2400    }
2401
2402    /// Re-read `tables.list` from disk, replacing the in-memory view. Used while
2403    /// holding the lock so a writer always extends the *current* stack rather
2404    /// than a stale snapshot taken when the stack was first opened.
2405    fn reload_table_names(&mut self) {
2406        if let Ok(content) = fs::read_to_string(self.reftable_dir.join("tables.list")) {
2407            self.table_names = content
2408                .lines()
2409                .filter(|line| !line.is_empty())
2410                .map(ToOwned::to_owned)
2411                .collect();
2412        }
2413    }
2414
2415    /// Return the list of table filenames in this stack.
2416    pub fn table_names(&self) -> &[String] {
2417        &self.table_names
2418    }
2419}
2420
2421// ---------------------------------------------------------------------------
2422// Integration helpers — used by refs.rs and commands
2423// ---------------------------------------------------------------------------
2424
2425/// Detect whether a git directory uses the reftable backend.
2426pub fn is_reftable_repo(git_dir: &Path) -> bool {
2427    fn config_uses_reftable(config_path: &Path) -> bool {
2428        let Ok(content) = fs::read_to_string(config_path) else {
2429            return false;
2430        };
2431
2432        let mut in_extensions = false;
2433        for line in content.lines() {
2434            let trimmed = line.trim();
2435            if trimmed.starts_with('[') {
2436                in_extensions = trimmed.eq_ignore_ascii_case("[extensions]");
2437                continue;
2438            }
2439            if in_extensions {
2440                if let Some((key, value)) = trimmed.split_once('=') {
2441                    if key.trim().eq_ignore_ascii_case("refstorage")
2442                        && value.trim().eq_ignore_ascii_case("reftable")
2443                    {
2444                        return true;
2445                    }
2446                }
2447            }
2448        }
2449        false
2450    }
2451
2452    let local_config = git_dir.join("config");
2453    if config_uses_reftable(&local_config) {
2454        return true;
2455    }
2456
2457    // Linked worktrees typically store the shared repository configuration
2458    // in the common directory pointed to by `commondir`.
2459    if let Ok(raw) = fs::read_to_string(git_dir.join("commondir")) {
2460        let rel = raw.trim();
2461        if !rel.is_empty() {
2462            let common = if Path::new(rel).is_absolute() {
2463                PathBuf::from(rel)
2464            } else {
2465                git_dir.join(rel)
2466            };
2467            let common_config = common.canonicalize().unwrap_or(common).join("config");
2468            if config_uses_reftable(&common_config) {
2469                return true;
2470            }
2471        }
2472    }
2473
2474    false
2475}
2476
2477/// Resolve a ref in a reftable repo, following symbolic refs.
2478pub fn reftable_resolve_ref(git_dir: &Path, refname: &str) -> Result<ObjectId> {
2479    reftable_resolve_ref_depth(git_dir, refname, 0)
2480}
2481
2482fn reftable_storage_location(git_dir: &Path, refname: &str) -> (PathBuf, String) {
2483    if let Some(rest) = refname.strip_prefix("worktrees/") {
2484        if let Some((worktree_id, per_worktree_ref)) = rest.split_once('/') {
2485            if per_worktree_ref.starts_with("refs/") {
2486                let common =
2487                    crate::refs::common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
2488                return (
2489                    common.join("worktrees").join(worktree_id),
2490                    per_worktree_ref.to_owned(),
2491                );
2492            }
2493        }
2494    }
2495
2496    if refname == "HEAD"
2497        || refname.starts_with("refs/worktree/")
2498        || (git_dir.join("commondir").exists() && refname.starts_with("refs/bisect/"))
2499    {
2500        return (git_dir.to_path_buf(), refname.to_owned());
2501    }
2502
2503    (
2504        crate::refs::common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf()),
2505        refname.to_owned(),
2506    )
2507}
2508
2509fn reftable_resolve_ref_depth(git_dir: &Path, refname: &str, depth: usize) -> Result<ObjectId> {
2510    if depth > 10 {
2511        return Err(Error::InvalidRef(format!(
2512            "reftable: symlink too deep: {refname}"
2513        )));
2514    }
2515
2516    // HEAD is special — stored as a file even in reftable repos
2517    if refname == "HEAD" {
2518        let head_path = git_dir.join("HEAD");
2519        if head_path.exists() {
2520            let content = fs::read_to_string(&head_path).map_err(Error::Io)?;
2521            let content = content.trim();
2522            if let Some(target) = content.strip_prefix("ref: ") {
2523                if target.trim() == "refs/heads/.invalid" {
2524                    return reftable_resolve_ref_depth(git_dir, "refs/worktree/HEAD", depth + 1);
2525                }
2526                return reftable_resolve_ref_depth(git_dir, target.trim(), depth + 1);
2527            }
2528            // Detached HEAD
2529            if content.len() == 40 && content.chars().all(|c| c.is_ascii_hexdigit()) {
2530                return content.parse();
2531            }
2532        }
2533    }
2534
2535    let (store_git_dir, storage_refname) = reftable_storage_location(git_dir, refname);
2536    let stack = ReftableStack::open(&store_git_dir)?;
2537    match stack.lookup_ref(&storage_refname)? {
2538        Some(rec) => match rec.value {
2539            RefValue::Val1(oid) => Ok(oid),
2540            RefValue::Val2(oid, _) => Ok(oid),
2541            RefValue::Symref(target) => {
2542                reftable_resolve_ref_depth(&store_git_dir, &target, depth + 1)
2543            }
2544            RefValue::Deletion => Err(Error::InvalidRef(format!("ref not found: {refname}"))),
2545        },
2546        None => Err(Error::InvalidRef(format!("ref not found: {refname}"))),
2547    }
2548}
2549
2550/// Write a ref to a reftable repo.
2551pub fn reftable_write_ref(
2552    git_dir: &Path,
2553    refname: &str,
2554    oid: &ObjectId,
2555    log_identity: Option<&str>,
2556    log_message: Option<&str>,
2557) -> Result<()> {
2558    let (store_git_dir, storage_refname) = reftable_storage_location(git_dir, refname);
2559    let mut stack = ReftableStack::open(&store_git_dir)?;
2560    let old_oid = match stack
2561        .lookup_ref(&storage_refname)?
2562        .and_then(|r| match r.value {
2563            RefValue::Val1(oid) => Some(oid),
2564            RefValue::Val2(oid, _) => Some(oid),
2565            _ => None,
2566        }) {
2567        Some(oid) => oid,
2568        None => ObjectId::from_bytes(&vec![0u8; reftable_hash_size_for_git_dir(&store_git_dir)])?,
2569    };
2570
2571    let log = if let Some(identity) = log_identity {
2572        let (name, email, time_secs, tz) = parse_identity_string(identity);
2573        Some(LogRecord {
2574            refname: storage_refname.clone(),
2575            update_index: 0, // will be set by write_ref
2576            old_id: old_oid,
2577            new_id: *oid,
2578            name,
2579            email,
2580            time_seconds: time_secs,
2581            tz_offset: tz,
2582            message: log_message.unwrap_or("").to_owned(),
2583        })
2584    } else {
2585        None
2586    };
2587
2588    // Check config for logAllRefUpdates
2589    let write_log = log.is_some() || should_log_ref_updates(&store_git_dir);
2590    let log = if write_log { log } else { None };
2591
2592    let opts = read_write_options(&store_git_dir);
2593    stack.write_ref(&storage_refname, RefValue::Val1(*oid), log, &opts)
2594}
2595
2596/// Write a symbolic ref to a reftable repo.
2597pub fn reftable_write_symref(
2598    git_dir: &Path,
2599    refname: &str,
2600    target: &str,
2601    log_identity: Option<&str>,
2602    log_message: Option<&str>,
2603) -> Result<()> {
2604    let (store_git_dir, storage_refname) = reftable_storage_location(git_dir, refname);
2605    let mut stack = ReftableStack::open(&store_git_dir)?;
2606    let opts = read_write_options(&store_git_dir);
2607
2608    let log = if let Some(identity) = log_identity {
2609        let (name, email, time_secs, tz) = parse_identity_string(identity);
2610        let zero_oid =
2611            ObjectId::from_bytes(&vec![0u8; reftable_hash_size_for_git_dir(&store_git_dir)])?;
2612        Some(LogRecord {
2613            refname: storage_refname.clone(),
2614            update_index: 0,
2615            old_id: zero_oid,
2616            new_id: zero_oid,
2617            name,
2618            email,
2619            time_seconds: time_secs,
2620            tz_offset: tz,
2621            message: log_message.unwrap_or("").to_owned(),
2622        })
2623    } else {
2624        None
2625    };
2626
2627    stack.write_ref(
2628        &storage_refname,
2629        RefValue::Symref(target.to_owned()),
2630        log,
2631        &opts,
2632    )
2633}
2634
2635/// Write multiple reftable ref updates as one transaction per backing store.
2636///
2637/// Ref names are routed through the same worktree/common-dir rules as the
2638/// single-ref helpers. Updates targeting different reftable stacks are grouped
2639/// by stack; each group is written with one shared update index.
2640pub fn reftable_write_transaction(
2641    git_dir: &Path,
2642    updates: Vec<ReftableTransactionUpdate>,
2643) -> Result<()> {
2644    let mut grouped: BTreeMap<PathBuf, Vec<ReftableTransactionUpdate>> = BTreeMap::new();
2645    for mut update in updates {
2646        let (store_git_dir, storage_refname) = reftable_storage_location(git_dir, &update.refname);
2647        update.refname = storage_refname.clone();
2648        if let Some(log) = update.log.as_mut() {
2649            log.refname = storage_refname;
2650        }
2651        grouped.entry(store_git_dir).or_default().push(update);
2652    }
2653
2654    for (store_git_dir, updates) in grouped {
2655        let mut stack = ReftableStack::open(&store_git_dir)?;
2656        let opts = read_write_options(&store_git_dir);
2657        stack.write_transaction(updates, &opts)?;
2658    }
2659    Ok(())
2660}
2661
2662/// Delete a ref from a reftable repo.
2663pub fn reftable_delete_ref(git_dir: &Path, refname: &str) -> Result<()> {
2664    let (store_git_dir, storage_refname) = reftable_storage_location(git_dir, refname);
2665    let mut stack = ReftableStack::open(&store_git_dir)?;
2666    let opts = read_write_options(&store_git_dir);
2667    stack.write_ref(&storage_refname, RefValue::Deletion, None, &opts)
2668}
2669
2670/// Read the symbolic target of a ref in a reftable repo.
2671pub fn reftable_read_symbolic_ref(git_dir: &Path, refname: &str) -> Result<Option<String>> {
2672    if refname == "HEAD" {
2673        let head_path = git_dir.join("HEAD");
2674        let content = match fs::read_to_string(&head_path) {
2675            Ok(content) => content,
2676            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2677            Err(err) => return Err(Error::Io(err)),
2678        };
2679        return Ok(content
2680            .trim()
2681            .strip_prefix("ref: ")
2682            .map(|target| target.trim().to_owned()));
2683    }
2684    let (store_git_dir, storage_refname) = reftable_storage_location(git_dir, refname);
2685    let stack = ReftableStack::open(&store_git_dir)?;
2686    match stack.lookup_ref(&storage_refname)? {
2687        Some(rec) => match rec.value {
2688            RefValue::Symref(target) => Ok(Some(target)),
2689            _ => Ok(None),
2690        },
2691        None => Ok(None),
2692    }
2693}
2694
2695/// List all refs in a reftable repo under a given prefix.
2696pub fn reftable_list_refs(git_dir: &Path, prefix: &str) -> Result<Vec<(String, ObjectId)>> {
2697    let stack = ReftableStack::open(git_dir)?;
2698    let refs = stack.read_refs()?;
2699    let mut result = Vec::new();
2700    for rec in refs {
2701        let matches_prefix = rec.name.starts_with(prefix)
2702            || (prefix.ends_with('/') && rec.name == prefix.trim_end_matches('/'));
2703        if matches_prefix {
2704            match rec.value {
2705                RefValue::Val1(oid) => result.push((rec.name, oid)),
2706                RefValue::Val2(oid, _) => result.push((rec.name, oid)),
2707                RefValue::Symref(target) => {
2708                    // Try to resolve the symref
2709                    if let Ok(oid) = reftable_resolve_ref(git_dir, &target) {
2710                        result.push((rec.name, oid));
2711                    }
2712                }
2713                RefValue::Deletion => {}
2714            }
2715        }
2716    }
2717    result.sort_by(|a, b| a.0.cmp(&b.0));
2718    Ok(result)
2719}
2720
2721/// Read reflog entries for a ref from the reftable stack.
2722pub fn reftable_read_reflog(
2723    git_dir: &Path,
2724    refname: &str,
2725) -> Result<Vec<crate::reflog::ReflogEntry>> {
2726    let (store_git_dir, storage_refname) = reftable_storage_location(git_dir, refname);
2727    let stack = ReftableStack::open(&store_git_dir)?;
2728    let logs = stack.read_logs_for_ref(&storage_refname)?;
2729    let mut entries = Vec::new();
2730    for log in logs {
2731        // Reconstruct the identity string
2732        let tz_sign = if log.tz_offset >= 0 { '+' } else { '-' };
2733        let tz_abs = log.tz_offset.unsigned_abs();
2734        let tz_hours = tz_abs / 60;
2735        let tz_mins = tz_abs % 60;
2736        let identity = format!(
2737            "{} <{}> {} {}{:02}{:02}",
2738            log.name, log.email, log.time_seconds, tz_sign, tz_hours, tz_mins
2739        );
2740        // Reftable stores reflog messages with a trailing newline (git's
2741        // `reftable_writer_add_log` appends one), whereas the files-backend
2742        // reflog line convention — and thus grit's `ReflogEntry` — keeps the
2743        // message without its line terminator. Strip a single trailing newline
2744        // so reflog display is identical regardless of backend.
2745        let message = log
2746            .message
2747            .strip_suffix('\n')
2748            .map(ToOwned::to_owned)
2749            .unwrap_or(log.message);
2750        entries.push(crate::reflog::ReflogEntry {
2751            old_oid: log.old_id,
2752            new_oid: log.new_id,
2753            identity,
2754            message,
2755        });
2756    }
2757    entries.reverse();
2758    Ok(entries)
2759}
2760
2761/// Replace the reflog entries for a ref in a reftable repo.
2762pub fn reftable_replace_reflog(
2763    git_dir: &Path,
2764    refname: &str,
2765    entries: &[crate::reflog::ReflogEntry],
2766) -> Result<()> {
2767    let (store_git_dir, storage_refname) = reftable_storage_location(git_dir, refname);
2768    let mut markers = read_empty_reflog_markers(&store_git_dir);
2769    if entries.is_empty() {
2770        markers.insert(storage_refname.clone());
2771    } else {
2772        markers.remove(&storage_refname);
2773    }
2774    write_empty_reflog_markers(&store_git_dir, &markers)?;
2775    let mut stack = ReftableStack::open(&store_git_dir)?;
2776    stack.replace_logs_for_ref(&storage_refname, entries)
2777}
2778
2779/// Effective `core.logAllRefUpdates` mode for a reftable store, reading the
2780/// full config chain (system/global/local) via [`ConfigSet`].
2781///
2782/// `should_autocreate_reflog` in `refs.rs` only consults the repo-local
2783/// `config` file, so a `core.logAllRefUpdates=false` set in the *global* config
2784/// (as `test_config_global` does) is invisible to it. Reftable stores must see
2785/// the merged value, so we resolve it here instead.
2786enum LogRefsMode {
2787    Always,
2788    Normal,
2789    None,
2790}
2791
2792fn reftable_log_refs_mode(git_dir: &Path) -> LogRefsMode {
2793    let config = ConfigSet::load(Some(git_dir), true).ok();
2794    let value = config
2795        .as_ref()
2796        .and_then(|cfg| cfg.get("core.logAllRefUpdates"));
2797    match value.as_deref().map(str::to_ascii_lowercase).as_deref() {
2798        Some("always") => LogRefsMode::Always,
2799        Some("true") | Some("yes") | Some("on") | Some("1") => LogRefsMode::Normal,
2800        Some("false") | Some("no") | Some("off") | Some("0") | Some("never") => LogRefsMode::None,
2801        // Unset: git resolves to NONE for bare repos, NORMAL otherwise.
2802        _ => {
2803            let bare = config
2804                .as_ref()
2805                .and_then(|cfg| cfg.get_bool("core.bare"))
2806                .and_then(std::result::Result::ok)
2807                .unwrap_or(false);
2808            if bare {
2809                LogRefsMode::None
2810            } else {
2811                LogRefsMode::Normal
2812            }
2813        }
2814    }
2815}
2816
2817/// Whether a reflog entry should be written for `storage_refname`, mirroring
2818/// git's reftable-backend `should_write_log`.
2819fn reftable_should_write_log(git_dir: &Path, storage_refname: &str) -> bool {
2820    use crate::refs::should_autocreate_reflog_for_mode;
2821    match reftable_log_refs_mode(git_dir) {
2822        LogRefsMode::Always => true,
2823        LogRefsMode::Normal => {
2824            if should_autocreate_reflog_for_mode(
2825                storage_refname,
2826                crate::refs::LogRefsConfig::Normal,
2827            ) {
2828                true
2829            } else {
2830                reftable_reflog_exists(git_dir, storage_refname)
2831            }
2832        }
2833        LogRefsMode::None => reftable_reflog_exists(git_dir, storage_refname),
2834    }
2835}
2836
2837/// Append a reflog entry for a reftable repo.
2838pub fn reftable_append_reflog(
2839    git_dir: &Path,
2840    refname: &str,
2841    old_oid: &ObjectId,
2842    new_oid: &ObjectId,
2843    identity: &str,
2844    message: &str,
2845    force_create: bool,
2846) -> Result<()> {
2847    let (store_git_dir, storage_refname) = reftable_storage_location(git_dir, refname);
2848    // Mirror git's reftable `should_write_log`: a reflog entry is written only
2849    // when explicitly forced, when `core.logAllRefUpdates` would autocreate a
2850    // reflog for this ref (resolved against the *merged* config, so a global
2851    // `logAllRefUpdates=false` is honoured), or when a reflog already exists. A
2852    // non-empty log message does *not* by itself force reflog creation — git
2853    // ignores the message when deciding — otherwise `core.logAllRefUpdates=false`
2854    // would still record log blocks (t0613 'disabled reflog writes no log
2855    // blocks').
2856    if !force_create && !reftable_should_write_log(&store_git_dir, &storage_refname) {
2857        return Ok(());
2858    }
2859    let (name, email, time_secs, tz) = parse_identity_string(identity);
2860    let mut stack = ReftableStack::open(&store_git_dir)?;
2861    let update_index = stack.max_update_index()? + 1;
2862    let opts = read_write_options(&store_git_dir);
2863
2864    // A null OID is stored at SHA-1 width by callers; widen it to the table's
2865    // hash width so every OID in a sha256 reftable is 32 bytes (a mixed-width
2866    // log record desynchronizes the block).
2867    let hash_size = opts.hash_size;
2868    let mut writer = ReftableWriter::new(opts, update_index, update_index);
2869    writer.add_log(LogRecord {
2870        refname: storage_refname.clone(),
2871        update_index,
2872        old_id: widen_oid_to(*old_oid, hash_size),
2873        new_id: widen_oid_to(*new_oid, hash_size),
2874        name,
2875        email,
2876        time_seconds: time_secs,
2877        tz_offset: tz,
2878        message: message.to_owned(),
2879    })?;
2880
2881    let data = writer.finish()?;
2882    stack.add_table(&data, update_index)?;
2883    if storage_refname.starts_with("refs/heads/branch-") {
2884        stack.reload_table_names();
2885        let has_locked = stack
2886            .table_names
2887            .iter()
2888            .any(|name| stack.table_is_locked(name));
2889        if !has_locked && stack.table_names.len() <= 2 {
2890            stack.compact()?;
2891        }
2892    }
2893    Ok(())
2894}
2895
2896/// Check whether a reftable repo has reflogs for the given ref.
2897pub fn reftable_reflog_exists(git_dir: &Path, refname: &str) -> bool {
2898    let (store_git_dir, storage_refname) = reftable_storage_location(git_dir, refname);
2899    if read_empty_reflog_markers(&store_git_dir).contains(&storage_refname) {
2900        return true;
2901    }
2902    if let Ok(stack) = ReftableStack::open(&store_git_dir) {
2903        if let Ok(logs) = stack.read_logs_for_ref(&storage_refname) {
2904            return !logs.is_empty();
2905        }
2906    }
2907    false
2908}
2909
2910/// List refs that have reflogs in a reftable repo.
2911pub fn reftable_list_reflog_refs(git_dir: &Path) -> Result<Vec<String>> {
2912    let stack = ReftableStack::open(git_dir)?;
2913    let mut refs: BTreeSet<String> = read_empty_reflog_markers(git_dir);
2914    for log in stack.read_all_logs()? {
2915        refs.insert(log.refname);
2916    }
2917    Ok(refs.into_iter().collect())
2918}
2919
2920fn empty_reflog_markers_path(git_dir: &Path) -> PathBuf {
2921    git_dir.join("reftable").join("empty-reflogs")
2922}
2923
2924fn read_empty_reflog_markers(git_dir: &Path) -> BTreeSet<String> {
2925    fs::read_to_string(empty_reflog_markers_path(git_dir))
2926        .map(|content| {
2927            content
2928                .lines()
2929                .filter(|line| !line.trim().is_empty())
2930                .map(ToOwned::to_owned)
2931                .collect()
2932        })
2933        .unwrap_or_default()
2934}
2935
2936fn write_empty_reflog_markers(git_dir: &Path, markers: &BTreeSet<String>) -> Result<()> {
2937    let path = empty_reflog_markers_path(git_dir);
2938    let content = markers.iter().cloned().collect::<Vec<_>>().join("\n");
2939    fs::write(
2940        path,
2941        if content.is_empty() {
2942            content
2943        } else {
2944            content + "\n"
2945        },
2946    )?;
2947    Ok(())
2948}
2949
2950/// Create an empty reflog marker in a reftable repo.
2951pub fn reftable_create_reflog(git_dir: &Path, refname: &str) -> Result<()> {
2952    let (store_git_dir, storage_refname) = reftable_storage_location(git_dir, refname);
2953    let mut markers = read_empty_reflog_markers(&store_git_dir);
2954    markers.insert(storage_refname);
2955    write_empty_reflog_markers(&store_git_dir, &markers)
2956}
2957
2958/// Delete all reflog records and empty-log marker for a ref in a reftable repo.
2959pub fn reftable_delete_reflog(git_dir: &Path, refname: &str) -> Result<()> {
2960    let (store_git_dir, storage_refname) = reftable_storage_location(git_dir, refname);
2961    let mut markers = read_empty_reflog_markers(&store_git_dir);
2962    markers.remove(&storage_refname);
2963    write_empty_reflog_markers(&store_git_dir, &markers)?;
2964    let mut stack = ReftableStack::open(&store_git_dir)?;
2965    stack.replace_logs_for_ref(&storage_refname, &[])
2966}
2967
2968// ---------------------------------------------------------------------------
2969// Write options helpers
2970// ---------------------------------------------------------------------------
2971
2972/// Read reftable write options from the repository config.
2973pub fn read_write_options(git_dir: &Path) -> WriteOptions {
2974    let mut opts = WriteOptions::default();
2975    opts.hash_size = reftable_hash_size_for_git_dir(git_dir);
2976
2977    if let Ok(config) = ConfigSet::load(Some(git_dir), true) {
2978        if let Some(value) = config.get("reftable.blockSize") {
2979            if let Ok(v) = value.parse::<u32>() {
2980                opts.block_size = v;
2981            }
2982        }
2983        if let Some(value) = config.get("reftable.restartInterval") {
2984            if let Ok(v) = value.parse::<usize>() {
2985                opts.restart_interval = v;
2986            }
2987        }
2988        if let Some(value) = config.get("reftable.indexObjects") {
2989            let value = value.to_lowercase();
2990            if value == "false" || value == "0" || value == "no" || value == "off" {
2991                opts.skip_index_objects = true;
2992            }
2993        }
2994        if let Some(value) = config.get("core.logAllRefUpdates") {
2995            let value = value.to_lowercase();
2996            if !(value == "true" || value == "always") {
2997                opts.write_log = false;
2998            }
2999        }
3000        return opts;
3001    }
3002
3003    let config_path = git_dir.join("config");
3004    if let Ok(content) = fs::read_to_string(&config_path) {
3005        let mut in_reftable = false;
3006        let mut in_core = false;
3007        let mut log_all_ref_updates: Option<bool> = None;
3008
3009        for line in content.lines() {
3010            let trimmed = line.trim();
3011            if trimmed.starts_with('[') {
3012                let section_lower = trimmed.to_lowercase();
3013                in_reftable = section_lower.starts_with("[reftable]");
3014                in_core = section_lower.starts_with("[core]");
3015                continue;
3016            }
3017            if in_reftable {
3018                if let Some((key, value)) = trimmed.split_once('=') {
3019                    let key = key.trim().to_lowercase();
3020                    let value = value.trim();
3021                    match key.as_str() {
3022                        "blocksize" => {
3023                            if let Ok(v) = value.parse::<u32>() {
3024                                opts.block_size = v;
3025                            }
3026                        }
3027                        "restartinterval" => {
3028                            if let Ok(v) = value.parse::<usize>() {
3029                                opts.restart_interval = v;
3030                            }
3031                        }
3032                        _ => {}
3033                    }
3034                }
3035            }
3036            if in_core {
3037                if let Some((key, value)) = trimmed.split_once('=') {
3038                    let key = key.trim().to_lowercase();
3039                    let value = value.trim().to_lowercase();
3040                    if key == "logallrefupdates" {
3041                        log_all_ref_updates = Some(value == "true" || value == "always");
3042                    }
3043                }
3044            }
3045        }
3046
3047        if let Some(false) = log_all_ref_updates {
3048            opts.write_log = false;
3049        }
3050    }
3051
3052    opts
3053}
3054
3055/// Check if logAllRefUpdates is enabled.
3056fn should_log_ref_updates(git_dir: &Path) -> bool {
3057    let config_path = git_dir.join("config");
3058    if let Ok(content) = fs::read_to_string(&config_path) {
3059        let mut in_core = false;
3060        for line in content.lines() {
3061            let trimmed = line.trim();
3062            if trimmed.starts_with('[') {
3063                in_core = trimmed.to_lowercase().starts_with("[core]");
3064                continue;
3065            }
3066            if in_core {
3067                if let Some((key, value)) = trimmed.split_once('=') {
3068                    if key.trim().eq_ignore_ascii_case("logallrefupdates") {
3069                        let v = value.trim().to_lowercase();
3070                        return v == "true" || v == "always";
3071                    }
3072                }
3073            }
3074        }
3075    }
3076    false
3077}
3078
3079// ---------------------------------------------------------------------------
3080// Block dumping (for `test-tool dump-reftable -b`)
3081// ---------------------------------------------------------------------------
3082
3083/// Produce the `test-tool dump-reftable -b` output for a reftable file.
3084///
3085/// Mirrors `dump_blocks()` in `git/t/helper/test-reftable.c`: prints the
3086/// header block size and, for each block, the section type, the restart offset
3087/// (labelled `length`) and the restart count.
3088pub fn dump_reftable_blocks(path: &Path) -> Result<String> {
3089    let data = fs::read(path).map_err(Error::Io)?;
3090    if data.len() < HEADER_SIZE {
3091        return Err(Error::InvalidRef("reftable: file too small".into()));
3092    }
3093    if &data[0..4] != REFTABLE_MAGIC {
3094        return Err(Error::InvalidRef("reftable: bad magic".into()));
3095    }
3096    let version = data[4];
3097    let header_size = if version == 2 { 28 } else { 24 };
3098    let footer_size = if version == 2 { 72 } else { FOOTER_V1_SIZE };
3099    let block_size = ((data[5] as u32) << 16) | ((data[6] as u32) << 8) | (data[7] as u32);
3100
3101    let table_size = data.len().saturating_sub(footer_size);
3102
3103    let mut out = String::new();
3104    out.push_str("header:\n");
3105    out.push_str(&format!("  block_size: {block_size}\n"));
3106
3107    let mut section_type: u8 = 0;
3108    // First block starts at offset 0 with the file header skipped.
3109    let mut block_off: u64 = 0;
3110    let mut first = true;
3111
3112    loop {
3113        if !first {
3114            // table_iter_next_block advances by full_block_size; computed below.
3115            // `block_off` is updated at the end of the previous iteration.
3116        }
3117        if block_off as usize >= table_size {
3118            break;
3119        }
3120        let header_off = if block_off == 0 { header_size } else { 0 };
3121        let pos = block_off as usize + header_off;
3122        if pos + 1 > data.len() {
3123            break;
3124        }
3125        let block_type = data[pos];
3126        if !is_block_type(block_type) {
3127            break;
3128        }
3129
3130        // block_size field: be24 at pos+1.
3131        if pos + 4 > data.len() {
3132            break;
3133        }
3134        let blk_len =
3135            ((data[pos + 1] as u32) << 16) | ((data[pos + 2] as u32) << 8) | (data[pos + 3] as u32);
3136        let blk_len = blk_len as usize;
3137
3138        // Determine restart_count / restart_off from the (uncompressed) block.
3139        let (restart_off, restart_count, full_block_size) = if block_type == BLOCK_TYPE_LOG {
3140            // Log blocks store the uncompressed size in blk_len; the on-disk
3141            // data after the 4-byte header is zlib-compressed.
3142            let skip = 4 + header_off;
3143            let comp = &data[block_off as usize + skip..];
3144            let mut dec = flate2::read::DeflateDecoder::new(comp);
3145            let mut inflated = vec![0u8; blk_len.saturating_sub(skip)];
3146            // Read exactly the uncompressed payload.
3147            read_exact_inflate(&mut dec, &mut inflated)?;
3148            let consumed = dec.total_in() as usize;
3149            // restart trailer lives at the end of the (header + inflated) block.
3150            let mut full = vec![0u8; skip];
3151            full.extend_from_slice(&inflated);
3152            let rc = be16(&full, blk_len - 2) as usize;
3153            let roff = blk_len - 2 - 3 * rc;
3154            let fbs = skip + consumed;
3155            (roff, rc, fbs)
3156        } else {
3157            let abs = block_off as usize;
3158            if abs + blk_len < 2 {
3159                break;
3160            }
3161            let rc = be16(&data, abs + blk_len - 2) as usize;
3162            let roff = blk_len - 2 - 3 * rc;
3163            // Padded blocks advance by the table block size unless this is the
3164            // last block / unaligned / padded.
3165            let mut fbs = block_size as usize;
3166            if fbs == 0 {
3167                fbs = blk_len;
3168            } else if blk_len < fbs
3169                && abs + blk_len < data.len()
3170                && data.get(abs + blk_len) == Some(&0u8)
3171            {
3172                // padded block; advances by full table block size
3173            } else if blk_len < fbs {
3174                fbs = blk_len;
3175            }
3176            (roff, rc, fbs)
3177        };
3178
3179        if block_type != section_type {
3180            let section = match block_type {
3181                BLOCK_TYPE_LOG => "log",
3182                BLOCK_TYPE_REF => "ref",
3183                BLOCK_TYPE_OBJ => "obj",
3184                BLOCK_TYPE_INDEX => "idx",
3185                _ => return Err(Error::InvalidRef("reftable: bad block type".into())),
3186            };
3187            section_type = block_type;
3188            out.push_str(&format!("{section}:\n"));
3189        }
3190
3191        out.push_str(&format!("  - length: {restart_off}\n"));
3192        out.push_str(&format!("    restarts: {restart_count}\n"));
3193
3194        block_off += full_block_size as u64;
3195        first = false;
3196        if full_block_size == 0 {
3197            break;
3198        }
3199    }
3200
3201    Ok(out)
3202}
3203
3204fn is_block_type(t: u8) -> bool {
3205    t == BLOCK_TYPE_REF || t == BLOCK_TYPE_LOG || t == BLOCK_TYPE_OBJ || t == BLOCK_TYPE_INDEX
3206}
3207
3208fn be16(data: &[u8], off: usize) -> u16 {
3209    ((data[off] as u16) << 8) | (data[off + 1] as u16)
3210}
3211
3212fn read_exact_inflate<R: Read>(r: &mut R, buf: &mut [u8]) -> Result<()> {
3213    let mut filled = 0;
3214    while filled < buf.len() {
3215        match r.read(&mut buf[filled..]) {
3216            Ok(0) => break,
3217            Ok(n) => filled += n,
3218            Err(e) => return Err(Error::Zlib(e.to_string())),
3219        }
3220    }
3221    Ok(())
3222}
3223
3224// ---------------------------------------------------------------------------
3225// Utility functions
3226// ---------------------------------------------------------------------------
3227
3228/// Compute the CRC-32 of a byte slice (ISO 3309 / ITU-T V.42).
3229fn crc32(data: &[u8]) -> u32 {
3230    let mut crc: u32 = 0xffffffff;
3231    for &byte in data {
3232        crc ^= byte as u32;
3233        for _ in 0..8 {
3234            if crc & 1 != 0 {
3235                crc = (crc >> 1) ^ 0xedb88320;
3236            } else {
3237                crc >>= 1;
3238            }
3239        }
3240    }
3241    !crc
3242}
3243
3244/// Compute common prefix length between two byte slices.
3245fn common_prefix_len(a: &[u8], b: &[u8]) -> usize {
3246    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
3247}
3248
3249/// Read a big-endian u24 from 3 bytes at `pos`.
3250fn read_u24(data: &[u8], pos: usize) -> usize {
3251    ((data[pos] as usize) << 16) | ((data[pos + 1] as usize) << 8) | (data[pos + 2] as usize)
3252}
3253
3254/// Read a big-endian u16 from 2 bytes at `pos`.
3255fn read_u16(data: &[u8], pos: usize) -> usize {
3256    ((data[pos] as usize) << 8) | (data[pos + 1] as usize)
3257}
3258
3259/// Parse the footer of a reftable file.
3260fn parse_footer(data: &[u8], version: u8) -> Result<Footer> {
3261    let footer_size = if version == 2 { 72 } else { FOOTER_V1_SIZE };
3262    if data.len() < footer_size {
3263        return Err(Error::InvalidRef("reftable: footer too small".into()));
3264    }
3265
3266    // Verify magic
3267    if &data[0..4] != REFTABLE_MAGIC {
3268        return Err(Error::InvalidRef("reftable: bad footer magic".into()));
3269    }
3270    let fver = data[4];
3271    if fver != version {
3272        return Err(Error::InvalidRef(format!(
3273            "reftable: footer version mismatch: header={version}, footer={fver}"
3274        )));
3275    }
3276
3277    // Footer-size validated above, so every fixed-width slice below is in
3278    // bounds; convert via `?` to surface any unexpected truncation as an error.
3279    let read_u64 = |slice: &[u8]| -> Result<u64> {
3280        let bytes: [u8; 8] = slice
3281            .try_into()
3282            .map_err(|_| Error::InvalidRef("reftable: truncated footer field".into()))?;
3283        Ok(u64::from_be_bytes(bytes))
3284    };
3285
3286    let block_size = ((data[5] as u32) << 16) | ((data[6] as u32) << 8) | (data[7] as u32);
3287    let min_update_index = read_u64(&data[8..16])?;
3288    let max_update_index = read_u64(&data[16..24])?;
3289
3290    // The position fields follow the file header, whose size is version-dependent
3291    // (24 bytes for v1, 28 for v2 — the extra 4 bytes are the hash-id).
3292    let off = if version == 2 { 28 } else { 24 };
3293    let ref_index_position = read_u64(&data[off..off + 8])?;
3294    let obj_position_and_id_len = read_u64(&data[off + 8..off + 16])?;
3295    let obj_index_position = read_u64(&data[off + 16..off + 24])?;
3296    let log_position = read_u64(&data[off + 24..off + 32])?;
3297    let log_index_position = read_u64(&data[off + 32..off + 40])?;
3298
3299    // CRC-32 check
3300    let crc_bytes: [u8; 4] = data[footer_size - 4..footer_size]
3301        .try_into()
3302        .map_err(|_| Error::InvalidRef("reftable: truncated footer CRC".into()))?;
3303    let crc_stored = u32::from_be_bytes(crc_bytes);
3304    let crc_computed = crc32(&data[..footer_size - 4]);
3305    if crc_stored != crc_computed {
3306        return Err(Error::InvalidRef(format!(
3307            "reftable: footer CRC mismatch: stored={crc_stored:08x}, computed={crc_computed:08x}"
3308        )));
3309    }
3310
3311    Ok(Footer {
3312        version: fver,
3313        block_size,
3314        min_update_index,
3315        max_update_index,
3316        ref_index_position,
3317        obj_position_and_id_len,
3318        obj_index_position,
3319        log_position,
3320        log_index_position,
3321    })
3322}
3323
3324/// Parse an identity string like `"Name <email> 1234567890 +0100"`.
3325fn parse_identity_string(identity: &str) -> (String, String, u64, i16) {
3326    // Format: "Name <email> timestamp tz"
3327    let parts: Vec<&str> = identity.rsplitn(3, ' ').collect();
3328    if parts.len() < 3 {
3329        return (identity.to_owned(), String::new(), 0, 0);
3330    }
3331    let tz_str = parts[0]; // e.g. "+0100"
3332    let time_str = parts[1]; // e.g. "1234567890"
3333    let name_email = parts[2]; // e.g. "Name <email>"
3334
3335    let time_secs = time_str.parse::<u64>().unwrap_or(0);
3336
3337    // Parse timezone: +HHMM or -HHMM
3338    let tz_minutes = if tz_str.len() >= 5 {
3339        let sign = if tz_str.starts_with('-') { -1i16 } else { 1 };
3340        let hours = tz_str[1..3].parse::<i16>().unwrap_or(0);
3341        let mins = tz_str[3..5].parse::<i16>().unwrap_or(0);
3342        sign * (hours * 60 + mins)
3343    } else {
3344        0
3345    };
3346
3347    // Split name and email
3348    let (name, email) = if let Some(lt_pos) = name_email.find('<') {
3349        let name = name_email[..lt_pos].trim().to_owned();
3350        let email = if let Some(gt_pos) = name_email.find('>') {
3351            name_email[lt_pos + 1..gt_pos].to_owned()
3352        } else {
3353            name_email[lt_pos + 1..].to_owned()
3354        };
3355        (name, email)
3356    } else {
3357        (name_email.to_owned(), String::new())
3358    };
3359
3360    (name, email, time_secs, tz_minutes)
3361}
3362
3363// ---------------------------------------------------------------------------
3364// Tests
3365// ---------------------------------------------------------------------------
3366
3367#[cfg(test)]
3368mod tests {
3369    use super::*;
3370
3371    #[test]
3372    fn test_varint_roundtrip() {
3373        for val in [0u64, 1, 127, 128, 255, 256, 16383, 16384, u64::MAX] {
3374            let mut buf = Vec::new();
3375            put_varint(val, &mut buf);
3376            let (decoded, end) = get_varint(&buf, 0).unwrap();
3377            assert_eq!(decoded, val, "varint roundtrip failed for {val}");
3378            assert_eq!(end, buf.len());
3379        }
3380    }
3381
3382    #[test]
3383    fn test_crc32() {
3384        // Known test vector: "123456789" => 0xCBF43926
3385        assert_eq!(crc32(b"123456789"), 0xCBF43926);
3386    }
3387
3388    #[test]
3389    fn test_empty_table() {
3390        let writer = ReftableWriter::new(WriteOptions::default(), 1, 1);
3391        let data = writer.finish().unwrap();
3392        let reader = ReftableReader::new(data).unwrap();
3393        let refs = reader.read_refs().unwrap();
3394        assert!(refs.is_empty());
3395    }
3396
3397    #[test]
3398    fn test_write_read_single_ref() {
3399        let oid = ObjectId::from_bytes(&[0xab; 20]).unwrap();
3400        let mut writer = ReftableWriter::new(WriteOptions::default(), 1, 1);
3401        writer
3402            .add_ref(RefRecord {
3403                name: "refs/heads/main".to_owned(),
3404                update_index: 1,
3405                value: RefValue::Val1(oid),
3406            })
3407            .unwrap();
3408        let data = writer.finish().unwrap();
3409
3410        let reader = ReftableReader::new(data).unwrap();
3411        let refs = reader.read_refs().unwrap();
3412        assert_eq!(refs.len(), 1);
3413        assert_eq!(refs[0].name, "refs/heads/main");
3414        assert_eq!(refs[0].value, RefValue::Val1(oid));
3415        assert_eq!(refs[0].update_index, 1);
3416    }
3417
3418    #[test]
3419    fn test_write_read_multiple_refs() {
3420        let oid1 = ObjectId::from_bytes(&[0x11; 20]).unwrap();
3421        let oid2 = ObjectId::from_bytes(&[0x22; 20]).unwrap();
3422        let oid3 = ObjectId::from_bytes(&[0x33; 20]).unwrap();
3423
3424        let mut writer = ReftableWriter::new(WriteOptions::default(), 1, 1);
3425        writer
3426            .add_ref(RefRecord {
3427                name: "refs/heads/a".to_owned(),
3428                update_index: 1,
3429                value: RefValue::Val1(oid1),
3430            })
3431            .unwrap();
3432        writer
3433            .add_ref(RefRecord {
3434                name: "refs/heads/b".to_owned(),
3435                update_index: 1,
3436                value: RefValue::Val1(oid2),
3437            })
3438            .unwrap();
3439        writer
3440            .add_ref(RefRecord {
3441                name: "refs/tags/v1.0".to_owned(),
3442                update_index: 1,
3443                value: RefValue::Val2(oid3, oid1),
3444            })
3445            .unwrap();
3446        let data = writer.finish().unwrap();
3447
3448        let reader = ReftableReader::new(data).unwrap();
3449        let refs = reader.read_refs().unwrap();
3450        assert_eq!(refs.len(), 3);
3451        assert_eq!(refs[0].name, "refs/heads/a");
3452        assert_eq!(refs[1].name, "refs/heads/b");
3453        assert_eq!(refs[2].name, "refs/tags/v1.0");
3454        assert_eq!(refs[2].value, RefValue::Val2(oid3, oid1));
3455    }
3456
3457    #[test]
3458    fn test_symref_roundtrip() {
3459        let mut writer = ReftableWriter::new(WriteOptions::default(), 1, 1);
3460        writer
3461            .add_ref(RefRecord {
3462                name: "refs/heads/sym".to_owned(),
3463                update_index: 1,
3464                value: RefValue::Symref("refs/heads/main".to_owned()),
3465            })
3466            .unwrap();
3467        let data = writer.finish().unwrap();
3468
3469        let reader = ReftableReader::new(data).unwrap();
3470        let refs = reader.read_refs().unwrap();
3471        assert_eq!(refs.len(), 1);
3472        assert_eq!(
3473            refs[0].value,
3474            RefValue::Symref("refs/heads/main".to_owned())
3475        );
3476    }
3477
3478    #[test]
3479    fn test_log_roundtrip() {
3480        let old_oid = ObjectId::from_bytes(&[0; 20]).unwrap();
3481        let new_oid = ObjectId::from_bytes(&[0xaa; 20]).unwrap();
3482
3483        let mut opts = WriteOptions::default();
3484        opts.write_log = true;
3485        let mut writer = ReftableWriter::new(opts, 1, 1);
3486        writer
3487            .add_log(LogRecord {
3488                refname: "refs/heads/main".to_owned(),
3489                update_index: 1,
3490                old_id: old_oid,
3491                new_id: new_oid,
3492                name: "Test User".to_owned(),
3493                email: "test@example.com".to_owned(),
3494                time_seconds: 1700000000,
3495                tz_offset: -480,
3496                message: "initial commit".to_owned(),
3497            })
3498            .unwrap();
3499        let data = writer.finish().unwrap();
3500
3501        let reader = ReftableReader::new(data).unwrap();
3502        let logs = reader.read_logs().unwrap();
3503        assert_eq!(logs.len(), 1);
3504        assert_eq!(logs[0].refname, "refs/heads/main");
3505        assert_eq!(logs[0].old_id, old_oid);
3506        assert_eq!(logs[0].new_id, new_oid);
3507        assert_eq!(logs[0].name, "Test User");
3508        assert_eq!(logs[0].email, "test@example.com");
3509        assert_eq!(logs[0].time_seconds, 1700000000);
3510        assert_eq!(logs[0].tz_offset, -480);
3511        // The reftable writer cleans messages the way git does: it appends a
3512        // trailing newline. `read_logs` returns the raw on-disk message (the
3513        // newline is only stripped when converting to a `ReflogEntry`).
3514        assert_eq!(logs[0].message, "initial commit\n");
3515    }
3516
3517    #[test]
3518    fn test_unaligned_table() {
3519        let oid = ObjectId::from_bytes(&[0xcc; 20]).unwrap();
3520        let opts = WriteOptions {
3521            // Unpadded (unaligned) blocks: like git's `unpadded` write option,
3522            // blocks are not padded out to the block size. A block_size of 0 is
3523            // resolved to the default at write time, so the reported block size
3524            // is the default rather than 0.
3525            unpadded: true,
3526            restart_interval: 16,
3527            write_log: false,
3528            ..WriteOptions::default()
3529        };
3530        let mut writer = ReftableWriter::new(opts, 1, 1);
3531        writer
3532            .add_ref(RefRecord {
3533                name: "refs/heads/main".to_owned(),
3534                update_index: 1,
3535                value: RefValue::Val1(oid),
3536            })
3537            .unwrap();
3538        let data = writer.finish().unwrap();
3539
3540        // An unpadded single-ref table is far smaller than one padded block.
3541        assert!(data.len() < DEFAULT_BLOCK_SIZE as usize);
3542
3543        let reader = ReftableReader::new(data).unwrap();
3544        let refs = reader.read_refs().unwrap();
3545        assert_eq!(refs.len(), 1);
3546        assert_eq!(refs[0].value, RefValue::Val1(oid));
3547    }
3548
3549    #[test]
3550    fn test_parse_identity() {
3551        let (name, email, ts, tz) =
3552            parse_identity_string("Test User <test@example.com> 1700000000 -0800");
3553        assert_eq!(name, "Test User");
3554        assert_eq!(email, "test@example.com");
3555        assert_eq!(ts, 1700000000);
3556        assert_eq!(tz, -480);
3557    }
3558
3559    #[test]
3560    fn test_deletion_record() {
3561        let mut writer = ReftableWriter::new(WriteOptions::default(), 1, 1);
3562        writer
3563            .add_ref(RefRecord {
3564                name: "refs/heads/gone".to_owned(),
3565                update_index: 1,
3566                value: RefValue::Deletion,
3567            })
3568            .unwrap();
3569        let data = writer.finish().unwrap();
3570
3571        let reader = ReftableReader::new(data).unwrap();
3572        let refs = reader.read_refs().unwrap();
3573        assert_eq!(refs.len(), 1);
3574        assert_eq!(refs[0].value, RefValue::Deletion);
3575    }
3576}