Skip to main content

sley_formats/
lib.rs

1//! git-formats — Git's remaining on-disk and wire formats: reftables, the
2//! commit-graph, bundles, and repository layout.
3//!
4//! The object model, configuration system, and index format that used to live
5//! here now have dedicated crates: [`sley_object`], [`sley_config`], and
6//! [`sley_index`].
7
8// sley#7: untrusted-input parsing crate — fallible ops propagate errors;
9// the only retained `expect`s would be documented compile-time invariants.
10#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
11
12use sley_config::{ConfigEntry, ConfigSection, GitConfig};
13use sley_core::{GitError, ObjectFormat, ObjectId, Result};
14use std::collections::{BTreeMap, HashMap};
15use std::env;
16use std::fs;
17use std::path::{Path, PathBuf};
18
19const REFTABLE_MAGIC: &[u8; 4] = b"REFT";
20const REFTABLE_DEFAULT_BLOCK_SIZE: u32 = 4096;
21const REFTABLE_MAX_BLOCK_SIZE: u32 = 0x00ff_ffff;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum ReftableVersion {
25    V1,
26    V2,
27}
28
29impl ReftableVersion {
30    fn number(self) -> u8 {
31        match self {
32            Self::V1 => 1,
33            Self::V2 => 2,
34        }
35    }
36
37    fn header_len(self) -> usize {
38        match self {
39            Self::V1 => 24,
40            Self::V2 => 28,
41        }
42    }
43
44    fn footer_len(self) -> usize {
45        match self {
46            Self::V1 => 68,
47            Self::V2 => 72,
48        }
49    }
50
51    fn from_number(value: u8) -> Result<Self> {
52        match value {
53            1 => Ok(Self::V1),
54            2 => Ok(Self::V2),
55            other => Err(GitError::InvalidFormat(format!(
56                "unsupported reftable version {other}"
57            ))),
58        }
59    }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct ReftableHeader {
64    pub version: ReftableVersion,
65    pub block_size: u32,
66    pub min_update_index: u64,
67    pub max_update_index: u64,
68    pub object_format: ObjectFormat,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum ReftableRefValue {
73    Deletion,
74    Direct(ObjectId),
75    Peeled { target: ObjectId, peeled: ObjectId },
76    Symbolic(String),
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct ReftableRefRecord {
81    pub name: String,
82    pub update_index: u64,
83    pub value: ReftableRefValue,
84}
85
86/// The value of a reftable log record — either a reflog update entry or a
87/// tombstone that masks an entry from an earlier table in the stack. Mirrors
88/// `reftable_log_record`'s `value_type` discriminant (reftable/record.c).
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum ReftableLogValue {
91    /// A log deletion (`REFTABLE_LOG_DELETION`): a placeholder that hides the
92    /// `(refname, update_index)` entry recorded in an older table.
93    Deletion,
94    /// A reflog entry (`REFTABLE_LOG_UPDATE`).
95    Update(ReftableLogUpdate),
96}
97
98/// A single reflog entry as stored in a reftable log block. The field set is
99/// exactly `reftable_log_record`'s `update` arm: old/new object ids, committer
100/// identity, and the message.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct ReftableLogUpdate {
103    pub old_oid: ObjectId,
104    pub new_oid: ObjectId,
105    pub name: String,
106    pub email: String,
107    /// Seconds since the Unix epoch.
108    pub time: u64,
109    /// Committer timezone offset in `[+-]HHMM` minutes, encoded as a signed
110    /// 16-bit big-endian quantity on disk.
111    pub tz_offset: i16,
112    pub message: String,
113}
114
115/// A reftable log record: the reflog of `refname` at a given `update_index`.
116///
117/// Log records are keyed by `refname` plus the *reversed* update index
118/// (`~0 - update_index`), so newer entries sort first — matching
119/// `reftable_log_record_key` in reftable/record.c.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct ReftableLogRecord {
122    pub refname: String,
123    pub update_index: u64,
124    pub value: ReftableLogValue,
125}
126
127/// Serialization controls for a reftable table.
128///
129/// These are the writer settings exposed by Git's reftable backend. A zero
130/// block size or restart interval is normalized to Git's defaults.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub struct ReftableWriteOptions {
133    /// Maximum uncompressed block size, including the table header in the
134    /// first block.
135    pub block_size: u32,
136    /// Maximum number of records between forced restart points.
137    pub restart_interval: u16,
138    /// Whether to emit the object-to-ref-block index when the ref section has
139    /// enough blocks to require a ref index.
140    pub index_objects: bool,
141}
142
143impl Default for ReftableWriteOptions {
144    fn default() -> Self {
145        Self {
146            block_size: REFTABLE_DEFAULT_BLOCK_SIZE,
147            restart_interval: 16,
148            index_objects: true,
149        }
150    }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct Reftable {
155    pub header: ReftableHeader,
156    pub refs: Vec<ReftableRefRecord>,
157    /// Reflog records carried by the table's log blocks, in on-disk order
158    /// (newest `update_index` first within each refname).
159    pub logs: Vec<ReftableLogRecord>,
160}
161
162impl Reftable {
163    pub fn parse(bytes: &[u8]) -> Result<Self> {
164        let header = parse_reftable_header(bytes)?;
165        let footer = parse_reftable_footer(bytes, header.version)?;
166        if footer.version != header.version
167            || footer.block_size != header.block_size
168            || footer.min_update_index != header.min_update_index
169            || footer.max_update_index != header.max_update_index
170            || footer.object_format != header.object_format
171        {
172            return Err(GitError::InvalidFormat(
173                "reftable footer header does not match file header".into(),
174            ));
175        }
176        let footer_start = bytes.len() - header.version.footer_len();
177        if footer.obj_id_len > header.object_format.raw_len() as u8 {
178            return Err(GitError::InvalidFormat(
179                "reftable object id abbreviation length exceeds hash length".into(),
180            ));
181        }
182        let ref_end = [
183            footer.ref_index_position,
184            footer.obj_position,
185            footer.obj_index_position,
186            footer.log_position,
187            footer.log_index_position,
188            footer_start as u64,
189        ]
190        .into_iter()
191        .filter(|position| *position != 0)
192        .min()
193        .unwrap_or(footer_start as u64) as usize;
194        let mut refs = Vec::new();
195        let mut offset = header.version.header_len();
196        while offset < ref_end {
197            if bytes[offset] == 0 {
198                offset += 1;
199                continue;
200            }
201            let block_type = bytes[offset];
202            if block_type != b'r' {
203                break;
204            }
205            let block_len = read_u24(bytes, offset + 1)? as usize;
206            let block_end = if offset == header.version.header_len() {
207                block_len
208            } else {
209                offset
210                    .checked_add(block_len)
211                    .ok_or_else(|| GitError::InvalidFormat("reftable block overflow".into()))?
212            };
213            if block_end > ref_end || block_end > bytes.len() {
214                return Err(GitError::InvalidFormat(
215                    "reftable ref block extends past section".into(),
216                ));
217            }
218            refs.extend(parse_reftable_ref_block(
219                &bytes[offset..block_end],
220                offset,
221                header,
222            )?);
223            offset = block_end;
224        }
225        let logs = parse_reftable_log_section(bytes, &footer, footer_start, header)?;
226        Ok(Self { header, refs, logs })
227    }
228
229    pub fn write_ref_only(
230        format: ObjectFormat,
231        min_update_index: u64,
232        max_update_index: u64,
233        refs: &[ReftableRefRecord],
234    ) -> Result<Vec<u8>> {
235        Self::write(format, min_update_index, max_update_index, refs, &[])
236    }
237
238    /// Serialize a single reftable carrying both refs and reflog records.
239    ///
240    /// The layout follows reftable/writer.c: a `ReftableHeader`, the `'r'` ref
241    /// block(s), then the `'g'` log block(s) (zlib-deflated bodies), then the
242    /// footer with `log_position` set to the start of the first log block. The
243    /// reflog records are keyed by `refname` and the *reversed* update index so
244    /// newer entries sort first, matching `reftable_log_record_key`.
245    pub fn write(
246        format: ObjectFormat,
247        min_update_index: u64,
248        max_update_index: u64,
249        refs: &[ReftableRefRecord],
250        logs: &[ReftableLogRecord],
251    ) -> Result<Vec<u8>> {
252        Self::write_with_options(
253            format,
254            min_update_index,
255            max_update_index,
256            refs,
257            logs,
258            ReftableWriteOptions::default(),
259        )
260    }
261
262    /// Serialize refs and logs using explicit reftable writer settings.
263    pub fn write_with_options(
264        format: ObjectFormat,
265        min_update_index: u64,
266        max_update_index: u64,
267        refs: &[ReftableRefRecord],
268        logs: &[ReftableLogRecord],
269        mut options: ReftableWriteOptions,
270    ) -> Result<Vec<u8>> {
271        if options.block_size == 0 {
272            options.block_size = REFTABLE_DEFAULT_BLOCK_SIZE;
273        }
274        if options.restart_interval == 0 {
275            options.restart_interval = 16;
276        }
277        if options.block_size > REFTABLE_MAX_BLOCK_SIZE {
278            return Err(GitError::InvalidFormat(
279                "reftable block size exceeds maximum".into(),
280            ));
281        }
282        let version = match format {
283            ObjectFormat::Sha1 => ReftableVersion::V1,
284            ObjectFormat::Sha256 => ReftableVersion::V2,
285        };
286        let header = ReftableHeader {
287            version,
288            block_size: options.block_size,
289            min_update_index,
290            max_update_index,
291            object_format: format,
292        };
293        write_reftable_with_options(header, refs, logs, options)
294    }
295}
296
297#[derive(Debug, Clone)]
298struct ReftableWriterRecord {
299    key: Vec<u8>,
300    value_type: u8,
301    value: Vec<u8>,
302    object_ids: Vec<ObjectId>,
303}
304
305#[derive(Debug)]
306struct ReftableWriterBlock {
307    bytes: Vec<u8>,
308    last_key: Vec<u8>,
309    record_indices: Vec<usize>,
310    offset: u64,
311}
312
313fn write_reftable_with_options(
314    header: ReftableHeader,
315    refs: &[ReftableRefRecord],
316    logs: &[ReftableLogRecord],
317    options: ReftableWriteOptions,
318) -> Result<Vec<u8>> {
319    let mut ref_records = refs
320        .iter()
321        .map(|record| reftable_ref_writer_record(header, record))
322        .collect::<Result<Vec<_>>>()?;
323    ref_records.sort_by(|left, right| left.key.cmp(&right.key));
324
325    let mut log_records = logs
326        .iter()
327        .map(|record| reftable_log_writer_record(header.object_format, record))
328        .collect::<Result<Vec<_>>>()?;
329    log_records.sort_by(|left, right| left.key.cmp(&right.key));
330
331    let mut out = write_reftable_header(header)?;
332    let mut ref_index_position = 0;
333    let mut obj_position = 0;
334    let mut obj_id_len = 0;
335    let mut obj_index_position = 0;
336    let mut log_position = 0;
337    let mut log_index_position = 0;
338
339    let first_header_len = header.version.header_len();
340    let mut ref_blocks = if ref_records.is_empty() {
341        Vec::new()
342    } else {
343        build_reftable_blocks(
344            b'r',
345            &ref_records,
346            options.block_size,
347            first_header_len,
348            options.restart_interval,
349            false,
350        )?
351    };
352    if !ref_blocks.is_empty() {
353        append_first_reftable_section(&mut out, &mut ref_blocks, options.block_size)?;
354    }
355
356    if ref_blocks.len() > 3 {
357        ref_index_position = append_reftable_index_levels(&mut out, &ref_blocks, options, true)?;
358
359        if options.index_objects {
360            let object_records =
361                reftable_object_writer_records(&ref_records, &ref_blocks, header.object_format);
362            if !object_records.is_empty() {
363                obj_id_len = object_records[0].key.len() as u8;
364                let mut object_blocks = build_reftable_blocks(
365                    b'o',
366                    &object_records,
367                    options.block_size,
368                    0,
369                    options.restart_interval,
370                    false,
371                )?;
372                align_reftable_output(&mut out, options.block_size);
373                obj_position = out.len() as u64;
374                append_reftable_blocks(&mut out, &mut object_blocks, options.block_size, false);
375                if object_blocks.len() > 3 {
376                    obj_index_position =
377                        append_reftable_index_levels(&mut out, &object_blocks, options, true)?;
378                }
379            }
380        }
381    }
382
383    let first_log_header = if ref_blocks.is_empty() {
384        first_header_len
385    } else {
386        0
387    };
388    let mut log_blocks = if log_records.is_empty() {
389        Vec::new()
390    } else {
391        build_reftable_blocks(
392            b'g',
393            &log_records,
394            options.block_size,
395            first_log_header,
396            options.restart_interval,
397            true,
398        )?
399    };
400    if !log_blocks.is_empty() {
401        log_position = if first_log_header == 0 {
402            out.len() as u64
403        } else {
404            // Git records a zero section offset when the log block is the
405            // table's first block; readers infer that it begins immediately
406            // after the table header. Non-zero offsets are only used when a
407            // ref/object section precedes the logs.
408            0
409        };
410        if first_log_header == 0 {
411            append_reftable_blocks(&mut out, &mut log_blocks, options.block_size, true);
412        } else {
413            append_first_reftable_section(&mut out, &mut log_blocks, options.block_size)?;
414        }
415        if log_blocks.len() > 3 {
416            log_index_position =
417                append_reftable_index_levels(&mut out, &log_blocks, options, false)?;
418        }
419    }
420
421    out.extend_from_slice(&write_reftable_footer(
422        header,
423        ref_index_position,
424        obj_position,
425        obj_id_len,
426        obj_index_position,
427        log_position,
428        log_index_position,
429    )?);
430    Ok(out)
431}
432
433fn reftable_ref_writer_record(
434    header: ReftableHeader,
435    record: &ReftableRefRecord,
436) -> Result<ReftableWriterRecord> {
437    if record.update_index < header.min_update_index
438        || record.update_index > header.max_update_index
439    {
440        return Err(GitError::InvalidFormat(format!(
441            "reftable ref {} update index {} outside header bounds",
442            record.name, record.update_index
443        )));
444    }
445    let mut value = Vec::new();
446    write_reftable_varint(&mut value, record.update_index - header.min_update_index);
447    let (value_type, object_ids) = match &record.value {
448        ReftableRefValue::Deletion => (0, Vec::new()),
449        ReftableRefValue::Direct(oid) => {
450            if oid.format() != header.object_format {
451                return Err(GitError::InvalidFormat(
452                    "reftable direct ref object format mismatch".into(),
453                ));
454            }
455            value.extend_from_slice(oid.as_bytes());
456            (1, vec![*oid])
457        }
458        ReftableRefValue::Peeled { target, peeled } => {
459            if target.format() != header.object_format || peeled.format() != header.object_format {
460                return Err(GitError::InvalidFormat(
461                    "reftable peeled ref object format mismatch".into(),
462                ));
463            }
464            value.extend_from_slice(target.as_bytes());
465            value.extend_from_slice(peeled.as_bytes());
466            (2, vec![*target, *peeled])
467        }
468        ReftableRefValue::Symbolic(target) => {
469            write_reftable_string(&mut value, target.as_bytes());
470            (3, Vec::new())
471        }
472    };
473    Ok(ReftableWriterRecord {
474        key: record.name.as_bytes().to_vec(),
475        value_type,
476        value,
477        object_ids,
478    })
479}
480
481fn reftable_log_writer_record(
482    format: ObjectFormat,
483    record: &ReftableLogRecord,
484) -> Result<ReftableWriterRecord> {
485    let mut value = Vec::new();
486    let value_type = match &record.value {
487        ReftableLogValue::Deletion => 0,
488        ReftableLogValue::Update(update) => {
489            write_reftable_log_value(&mut value, format, update)?;
490            1
491        }
492    };
493    Ok(ReftableWriterRecord {
494        key: reftable_log_key(&record.refname, record.update_index),
495        value_type,
496        value,
497        object_ids: Vec::new(),
498    })
499}
500
501fn build_reftable_blocks(
502    block_type: u8,
503    records: &[ReftableWriterRecord],
504    block_size: u32,
505    first_header_len: usize,
506    restart_interval: u16,
507    compress: bool,
508) -> Result<Vec<ReftableWriterBlock>> {
509    let block_size = block_size as usize;
510    let mut blocks = Vec::new();
511    let mut next_record = 0;
512    let mut header_len = first_header_len;
513    while next_record < records.len() {
514        let mut encoded = Vec::new();
515        let mut restart_offsets = Vec::new();
516        let mut record_indices = Vec::new();
517        let mut previous_key = Vec::new();
518        let block_begin = next_record;
519        while next_record < records.len() {
520            let record = &records[next_record];
521            let force_restart = record_indices.len() % usize::from(restart_interval) == 0;
522            let prefix_len = if force_restart {
523                0
524            } else {
525                common_prefix_len(&previous_key, &record.key)
526            };
527            let mut entry = Vec::new();
528            write_reftable_varint(&mut entry, prefix_len as u64);
529            let suffix = &record.key[prefix_len..];
530            write_reftable_varint(
531                &mut entry,
532                ((suffix.len() as u64) << 3) | u64::from(record.value_type),
533            );
534            entry.extend_from_slice(suffix);
535            entry.extend_from_slice(&record.value);
536            let is_restart = prefix_len == 0;
537            let restart_count = restart_offsets.len() + usize::from(is_restart);
538            let raw_len = header_len + 4 + encoded.len() + entry.len() + restart_count * 3 + 2;
539            if raw_len > block_size {
540                if record_indices.is_empty() {
541                    return Err(GitError::InvalidFormat("entry too large".into()));
542                }
543                break;
544            }
545            if is_restart {
546                restart_offsets.push((header_len + 4 + encoded.len()) as u32);
547            }
548            encoded.extend_from_slice(&entry);
549            record_indices.push(next_record);
550            previous_key.clone_from(&record.key);
551            next_record += 1;
552        }
553
554        if next_record == block_begin {
555            return Err(GitError::InvalidFormat("entry too large".into()));
556        }
557        let last_key = records[next_record - 1].key.clone();
558        let mut body = Vec::new();
559        body.push(block_type);
560        body.extend_from_slice(&[0, 0, 0]);
561        body.extend_from_slice(&encoded);
562        for offset in &restart_offsets {
563            write_u24(&mut body, *offset)?;
564        }
565        let restart_count = u16::try_from(restart_offsets.len())
566            .map_err(|_| GitError::InvalidFormat("too many reftable restart offsets".into()))?;
567        body.extend_from_slice(&restart_count.to_be_bytes());
568        let raw_len = header_len + body.len();
569        write_u24_at(&mut body, 1, raw_len as u32)?;
570        if compress {
571            let compressed = deflate_zlib(&body[4..])?;
572            body.truncate(4);
573            body.extend_from_slice(&compressed);
574        }
575        blocks.push(ReftableWriterBlock {
576            bytes: body,
577            last_key,
578            record_indices,
579            offset: 0,
580        });
581        header_len = 0;
582    }
583    Ok(blocks)
584}
585
586fn append_first_reftable_section(
587    out: &mut Vec<u8>,
588    blocks: &mut [ReftableWriterBlock],
589    block_size: u32,
590) -> Result<()> {
591    for (index, block) in blocks.iter_mut().enumerate() {
592        if index == 0 {
593            block.offset = 0;
594        } else {
595            align_reftable_output(out, block_size);
596            block.offset = out.len() as u64;
597        }
598        out.extend_from_slice(&block.bytes);
599    }
600    Ok(())
601}
602
603fn append_reftable_blocks(
604    out: &mut Vec<u8>,
605    blocks: &mut [ReftableWriterBlock],
606    block_size: u32,
607    unpadded: bool,
608) {
609    for (index, block) in blocks.iter_mut().enumerate() {
610        if index != 0 && !unpadded {
611            align_reftable_output(out, block_size);
612        }
613        block.offset = out.len() as u64;
614        out.extend_from_slice(&block.bytes);
615    }
616}
617
618fn align_reftable_output(out: &mut Vec<u8>, block_size: u32) {
619    let block_size = block_size as usize;
620    let remainder = out.len() % block_size;
621    if remainder != 0 {
622        out.resize(out.len() + block_size - remainder, 0);
623    }
624}
625
626fn reftable_index_writer_records(blocks: &[ReftableWriterBlock]) -> Vec<ReftableWriterRecord> {
627    blocks
628        .iter()
629        .map(|block| {
630            let mut value = Vec::new();
631            write_reftable_varint(&mut value, block.offset);
632            ReftableWriterRecord {
633                key: block.last_key.clone(),
634                value_type: 0,
635                value,
636                object_ids: Vec::new(),
637            }
638        })
639        .collect()
640}
641
642fn append_reftable_index_levels(
643    out: &mut Vec<u8>,
644    indexed_blocks: &[ReftableWriterBlock],
645    options: ReftableWriteOptions,
646    align_first: bool,
647) -> Result<u64> {
648    let mut records = reftable_index_writer_records(indexed_blocks);
649    let mut first_level = true;
650    loop {
651        if (first_level && align_first) || !first_level {
652            align_reftable_output(out, options.block_size);
653        }
654        let highest_position = out.len() as u64;
655        let mut blocks = build_reftable_blocks(
656            b'i',
657            &records,
658            options.block_size,
659            0,
660            options.restart_interval,
661            false,
662        )?;
663        append_reftable_blocks(out, &mut blocks, options.block_size, false);
664        if blocks.len() <= 3 {
665            return Ok(highest_position);
666        }
667        records = reftable_index_writer_records(&blocks);
668        first_level = false;
669    }
670}
671
672fn reftable_object_writer_records(
673    records: &[ReftableWriterRecord],
674    blocks: &[ReftableWriterBlock],
675    format: ObjectFormat,
676) -> Vec<ReftableWriterRecord> {
677    let mut offsets = BTreeMap::<Vec<u8>, Vec<u64>>::new();
678    for block in blocks {
679        for index in &block.record_indices {
680            for oid in &records[*index].object_ids {
681                let entry = offsets.entry(oid.as_bytes().to_vec()).or_default();
682                if entry.last() != Some(&block.offset) {
683                    entry.push(block.offset);
684                }
685            }
686        }
687    }
688    if offsets.is_empty() {
689        return Vec::new();
690    }
691    let keys = offsets.keys().cloned().collect::<Vec<_>>();
692    let common = keys
693        .windows(2)
694        .map(|pair| common_prefix_len(&pair[0], &pair[1]))
695        .max()
696        .unwrap_or(1);
697    let prefix_len = (common + 1).max(2).min(format.raw_len());
698    offsets
699        .into_iter()
700        .map(|(oid, block_offsets)| {
701            let count = block_offsets.len();
702            let value_type = if (1..8).contains(&count) {
703                count as u8
704            } else {
705                0
706            };
707            let mut value = Vec::new();
708            if value_type == 0 {
709                write_reftable_varint(&mut value, count as u64);
710            }
711            if let Some(first) = block_offsets.first() {
712                write_reftable_varint(&mut value, *first);
713                for pair in block_offsets.windows(2) {
714                    write_reftable_varint(&mut value, pair[1] - pair[0]);
715                }
716            }
717            ReftableWriterRecord {
718                key: oid[..prefix_len].to_vec(),
719                value_type,
720                value,
721                object_ids: Vec::new(),
722            }
723        })
724        .collect()
725}
726
727#[derive(Debug, Clone, Copy)]
728struct ReftableFooter {
729    version: ReftableVersion,
730    block_size: u32,
731    min_update_index: u64,
732    max_update_index: u64,
733    object_format: ObjectFormat,
734    ref_index_position: u64,
735    obj_position: u64,
736    obj_id_len: u8,
737    obj_index_position: u64,
738    log_position: u64,
739    log_index_position: u64,
740}
741
742fn parse_reftable_header(bytes: &[u8]) -> Result<ReftableHeader> {
743    if bytes.len() < 24 {
744        return Err(GitError::InvalidFormat("truncated reftable header".into()));
745    }
746    if &bytes[..4] != REFTABLE_MAGIC {
747        return Err(GitError::InvalidFormat("missing reftable magic".into()));
748    }
749    let version = ReftableVersion::from_number(bytes[4])?;
750    if bytes.len() < version.header_len() {
751        return Err(GitError::InvalidFormat("truncated reftable header".into()));
752    }
753    let block_size = read_u24(bytes, 5)?;
754    let min_update_index = read_u64(bytes, 8)?;
755    let max_update_index = read_u64(bytes, 16)?;
756    let object_format = match version {
757        ReftableVersion::V1 => ObjectFormat::Sha1,
758        ReftableVersion::V2 => match bytes.get(24..28) {
759            Some(b"sha1") => ObjectFormat::Sha1,
760            Some(b"s256") => ObjectFormat::Sha256,
761            Some(value) => {
762                return Err(GitError::InvalidFormat(format!(
763                    "unsupported reftable hash id {}",
764                    String::from_utf8_lossy(value)
765                )));
766            }
767            None => return Err(GitError::InvalidFormat("truncated reftable hash id".into())),
768        },
769    };
770    Ok(ReftableHeader {
771        version,
772        block_size,
773        min_update_index,
774        max_update_index,
775        object_format,
776    })
777}
778
779fn write_reftable_header(header: ReftableHeader) -> Result<Vec<u8>> {
780    let mut out = Vec::with_capacity(header.version.header_len());
781    out.extend_from_slice(REFTABLE_MAGIC);
782    out.push(header.version.number());
783    write_u24(&mut out, header.block_size)?;
784    out.extend_from_slice(&header.min_update_index.to_be_bytes());
785    out.extend_from_slice(&header.max_update_index.to_be_bytes());
786    if header.version == ReftableVersion::V2 {
787        out.extend_from_slice(match header.object_format {
788            ObjectFormat::Sha1 => b"sha1",
789            ObjectFormat::Sha256 => b"s256",
790        });
791    }
792    Ok(out)
793}
794
795fn parse_reftable_footer(bytes: &[u8], version: ReftableVersion) -> Result<ReftableFooter> {
796    let footer_len = version.footer_len();
797    if bytes.len() < footer_len {
798        return Err(GitError::InvalidFormat("truncated reftable footer".into()));
799    }
800    let start = bytes.len() - footer_len;
801    let crc_start = bytes.len() - 4;
802    let expected = read_u32(bytes, crc_start)?;
803    let actual = crc32(&bytes[start..crc_start]);
804    if expected != actual {
805        return Err(GitError::InvalidFormat(format!(
806            "reftable footer crc mismatch: expected {expected:08x}, got {actual:08x}"
807        )));
808    }
809    let header = parse_reftable_header(&bytes[start..])?;
810    let mut offset = start + version.header_len();
811    let ref_index_position = read_u64(bytes, offset)?;
812    offset += 8;
813    let obj_position_and_len = read_u64(bytes, offset)?;
814    offset += 8;
815    let obj_index_position = read_u64(bytes, offset)?;
816    offset += 8;
817    let log_position = read_u64(bytes, offset)?;
818    offset += 8;
819    let log_index_position = read_u64(bytes, offset)?;
820    Ok(ReftableFooter {
821        version: header.version,
822        block_size: header.block_size,
823        min_update_index: header.min_update_index,
824        max_update_index: header.max_update_index,
825        object_format: header.object_format,
826        ref_index_position,
827        obj_position: obj_position_and_len >> 5,
828        obj_id_len: (obj_position_and_len & 0x1f) as u8,
829        obj_index_position,
830        log_position,
831        log_index_position,
832    })
833}
834
835fn write_reftable_footer(
836    header: ReftableHeader,
837    ref_index_position: u64,
838    obj_position: u64,
839    obj_id_len: u8,
840    obj_index_position: u64,
841    log_position: u64,
842    log_index_position: u64,
843) -> Result<Vec<u8>> {
844    if obj_id_len > 31 {
845        return Err(GitError::InvalidFormat(
846            "reftable object id abbreviation length exceeds 31".into(),
847        ));
848    }
849    let mut out = write_reftable_header(header)?;
850    out.extend_from_slice(&ref_index_position.to_be_bytes());
851    out.extend_from_slice(&((obj_position << 5) | u64::from(obj_id_len)).to_be_bytes());
852    out.extend_from_slice(&obj_index_position.to_be_bytes());
853    out.extend_from_slice(&log_position.to_be_bytes());
854    out.extend_from_slice(&log_index_position.to_be_bytes());
855    let crc = crc32(&out);
856    out.extend_from_slice(&crc.to_be_bytes());
857    Ok(out)
858}
859
860fn parse_reftable_ref_block(
861    block: &[u8],
862    block_start: usize,
863    header: ReftableHeader,
864) -> Result<Vec<ReftableRefRecord>> {
865    if block.len() < 6 || block[0] != b'r' {
866        return Err(GitError::InvalidFormat("invalid reftable ref block".into()));
867    }
868    let restart_count = read_u16(block, block.len() - 2)? as usize;
869    if restart_count == 0 {
870        return Err(GitError::InvalidFormat(
871            "reftable ref block has no restart offsets".into(),
872        ));
873    }
874    let restart_table_start = block
875        .len()
876        .checked_sub(2 + restart_count * 3)
877        .ok_or_else(|| GitError::InvalidFormat("truncated reftable restart table".into()))?;
878    let mut restart_offsets = Vec::with_capacity(restart_count);
879    for idx in 0..restart_count {
880        restart_offsets.push(read_u24(block, restart_table_start + idx * 3)? as usize);
881    }
882    if restart_offsets.windows(2).any(|pair| pair[0] > pair[1]) {
883        return Err(GitError::InvalidFormat(
884            "reftable restart offsets are not sorted".into(),
885        ));
886    }
887    let mut offset = 4;
888    let mut previous_name = Vec::new();
889    let mut records = Vec::new();
890    while offset < restart_table_start {
891        let record_offset = block_start + offset;
892        let restart = restart_offsets.contains(&record_offset);
893        let record = parse_reftable_ref_record(
894            block,
895            &mut offset,
896            restart_table_start,
897            header,
898            &previous_name,
899            restart,
900        )?;
901        previous_name = record.name.as_bytes().to_vec();
902        records.push(record);
903    }
904    if offset != restart_table_start {
905        return Err(GitError::InvalidFormat(
906            "reftable ref block ended inside record".into(),
907        ));
908    }
909    Ok(records)
910}
911
912fn parse_reftable_ref_record(
913    block: &[u8],
914    offset: &mut usize,
915    end: usize,
916    header: ReftableHeader,
917    previous_name: &[u8],
918    restart: bool,
919) -> Result<ReftableRefRecord> {
920    let prefix_len = read_reftable_varint(block, offset, end)? as usize;
921    if prefix_len > previous_name.len() {
922        return Err(GitError::InvalidFormat(
923            "reftable ref prefix exceeds previous name".into(),
924        ));
925    }
926    if restart && prefix_len != 0 {
927        return Err(GitError::InvalidFormat(
928            "reftable restart record uses prefix compression".into(),
929        ));
930    }
931    let suffix_len_and_type = read_reftable_varint(block, offset, end)?;
932    let suffix_len = (suffix_len_and_type >> 3) as usize;
933    let value_type = (suffix_len_and_type & 0x7) as u8;
934    let suffix_end = offset
935        .checked_add(suffix_len)
936        .ok_or_else(|| GitError::InvalidFormat("reftable suffix overflow".into()))?;
937    if suffix_end > end {
938        return Err(GitError::InvalidFormat("truncated reftable suffix".into()));
939    }
940    let mut name = previous_name[..prefix_len].to_vec();
941    name.extend_from_slice(&block[*offset..suffix_end]);
942    *offset = suffix_end;
943    let update_index_delta = read_reftable_varint(block, offset, end)?;
944    let update_index = header
945        .min_update_index
946        .checked_add(update_index_delta)
947        .ok_or_else(|| GitError::InvalidFormat("reftable update index overflow".into()))?;
948    let value = match value_type {
949        0 => ReftableRefValue::Deletion,
950        1 => ReftableRefValue::Direct(read_reftable_oid(block, offset, end, header.object_format)?),
951        2 => ReftableRefValue::Peeled {
952            target: read_reftable_oid(block, offset, end, header.object_format)?,
953            peeled: read_reftable_oid(block, offset, end, header.object_format)?,
954        },
955        3 => {
956            let target_len = read_reftable_varint(block, offset, end)? as usize;
957            let target_end = offset.checked_add(target_len).ok_or_else(|| {
958                GitError::InvalidFormat("reftable symbolic target overflow".into())
959            })?;
960            if target_end > end {
961                return Err(GitError::InvalidFormat(
962                    "truncated reftable symbolic target".into(),
963                ));
964            }
965            let target = std::str::from_utf8(&block[*offset..target_end])
966                .map_err(|err| GitError::InvalidFormat(err.to_string()))?
967                .to_string();
968            *offset = target_end;
969            ReftableRefValue::Symbolic(target)
970        }
971        other => {
972            return Err(GitError::InvalidFormat(format!(
973                "unsupported reftable ref value type {other}"
974            )));
975        }
976    };
977    let name = std::str::from_utf8(&name)
978        .map_err(|err| GitError::InvalidFormat(err.to_string()))?
979        .to_string();
980    Ok(ReftableRefRecord {
981        name,
982        update_index,
983        value,
984    })
985}
986
987/// Encode the on-disk key for a log record: `refname \0 <8-byte big-endian ts>`,
988/// where `ts = ~0 - update_index` so larger update indices sort first
989/// (reftable/record.c::reftable_log_record_key).
990fn reftable_log_key(refname: &str, update_index: u64) -> Vec<u8> {
991    let mut key = Vec::with_capacity(refname.len() + 9);
992    key.extend_from_slice(refname.as_bytes());
993    key.push(0);
994    let ts = u64::MAX - update_index;
995    key.extend_from_slice(&ts.to_be_bytes());
996    key
997}
998
999fn write_reftable_log_value(
1000    out: &mut Vec<u8>,
1001    format: ObjectFormat,
1002    update: &ReftableLogUpdate,
1003) -> Result<()> {
1004    if update.old_oid.format() != format || update.new_oid.format() != format {
1005        return Err(GitError::InvalidFormat(
1006            "reftable log object format mismatch".into(),
1007        ));
1008    }
1009    out.extend_from_slice(update.old_oid.as_bytes());
1010    out.extend_from_slice(update.new_oid.as_bytes());
1011    write_reftable_string(out, update.name.as_bytes());
1012    write_reftable_string(out, update.email.as_bytes());
1013    write_reftable_varint(out, update.time);
1014    out.extend_from_slice(&update.tz_offset.to_be_bytes());
1015    write_reftable_string(out, update.message.as_bytes());
1016    Ok(())
1017}
1018
1019/// A varint-length-prefixed byte string (reftable/record.c::encode_string).
1020fn write_reftable_string(out: &mut Vec<u8>, bytes: &[u8]) {
1021    write_reftable_varint(out, bytes.len() as u64);
1022    out.extend_from_slice(bytes);
1023}
1024
1025fn read_reftable_string(block: &[u8], offset: &mut usize, end: usize) -> Result<String> {
1026    let len = read_reftable_varint(block, offset, end)? as usize;
1027    let str_end = offset
1028        .checked_add(len)
1029        .ok_or_else(|| GitError::InvalidFormat("reftable string overflow".into()))?;
1030    if str_end > end {
1031        return Err(GitError::InvalidFormat("truncated reftable string".into()));
1032    }
1033    let value = std::str::from_utf8(&block[*offset..str_end])
1034        .map_err(|err| GitError::InvalidFormat(err.to_string()))?
1035        .to_string();
1036    *offset = str_end;
1037    Ok(value)
1038}
1039
1040fn common_prefix_len(left: &[u8], right: &[u8]) -> usize {
1041    left.iter()
1042        .zip(right.iter())
1043        .take_while(|(a, b)| a == b)
1044        .count()
1045}
1046
1047/// Walk the `'g'` log section starting at `footer.log_position`, inflating each
1048/// block and decoding its log records. Returns an empty vector when the table
1049/// carries no logs.
1050fn parse_reftable_log_section(
1051    bytes: &[u8],
1052    footer: &ReftableFooter,
1053    footer_start: usize,
1054    header: ReftableHeader,
1055) -> Result<Vec<ReftableLogRecord>> {
1056    let first_block = header.version.header_len();
1057    let log_position = if footer.log_position != 0 {
1058        footer.log_position as usize
1059    } else if bytes.get(first_block) == Some(&b'g') {
1060        first_block
1061    } else {
1062        return Ok(Vec::new());
1063    };
1064    let log_end = [footer.log_index_position, footer_start as u64]
1065        .into_iter()
1066        .filter(|position| *position != 0)
1067        .min()
1068        .unwrap_or(footer_start as u64) as usize;
1069    let mut logs = Vec::new();
1070    let mut offset = log_position;
1071    while offset < log_end {
1072        if bytes[offset] == 0 {
1073            offset += 1;
1074            continue;
1075        }
1076        if bytes[offset] != b'g' {
1077            break;
1078        }
1079        let uncompressed_len = read_u24(bytes, offset + 1)? as usize;
1080        // The on-disk block body after the 4-byte header is zlib-compressed and
1081        // runs to the next block (or the log index / footer). We inflate the
1082        // remaining bytes of the section; zlib stops at the stream end.
1083        let compressed = &bytes[offset + 4..log_end];
1084        let (inflated, consumed) = inflate_zlib(compressed, uncompressed_len.saturating_sub(4))?;
1085        // Reconstruct the logical block: 4-byte header + inflated body.
1086        let mut block = Vec::with_capacity(uncompressed_len);
1087        block.extend_from_slice(&bytes[offset..offset + 4]);
1088        block.extend_from_slice(&inflated);
1089        logs.extend(parse_reftable_log_block(&block, header)?);
1090        offset += 4 + consumed;
1091    }
1092    Ok(logs)
1093}
1094
1095fn parse_reftable_log_block(
1096    block: &[u8],
1097    header: ReftableHeader,
1098) -> Result<Vec<ReftableLogRecord>> {
1099    if block.len() < 6 || block[0] != b'g' {
1100        return Err(GitError::InvalidFormat("invalid reftable log block".into()));
1101    }
1102    let restart_count = read_u16(block, block.len() - 2)? as usize;
1103    if restart_count == 0 {
1104        return Err(GitError::InvalidFormat(
1105            "reftable log block has no restart offsets".into(),
1106        ));
1107    }
1108    let restart_table_start = block
1109        .len()
1110        .checked_sub(2 + restart_count * 3)
1111        .ok_or_else(|| GitError::InvalidFormat("truncated reftable log restart table".into()))?;
1112    let mut offset = 4;
1113    let mut previous_key: Vec<u8> = Vec::new();
1114    let mut records = Vec::new();
1115    while offset < restart_table_start {
1116        let record = parse_reftable_log_record(
1117            block,
1118            &mut offset,
1119            restart_table_start,
1120            header,
1121            &mut previous_key,
1122        )?;
1123        records.push(record);
1124    }
1125    if offset != restart_table_start {
1126        return Err(GitError::InvalidFormat(
1127            "reftable log block ended inside record".into(),
1128        ));
1129    }
1130    Ok(records)
1131}
1132
1133fn parse_reftable_log_record(
1134    block: &[u8],
1135    offset: &mut usize,
1136    end: usize,
1137    header: ReftableHeader,
1138    previous_key: &mut Vec<u8>,
1139) -> Result<ReftableLogRecord> {
1140    let prefix_len = read_reftable_varint(block, offset, end)? as usize;
1141    if prefix_len > previous_key.len() {
1142        return Err(GitError::InvalidFormat(
1143            "reftable log prefix exceeds previous key".into(),
1144        ));
1145    }
1146    let suffix_len_and_type = read_reftable_varint(block, offset, end)?;
1147    let suffix_len = (suffix_len_and_type >> 3) as usize;
1148    let value_type = (suffix_len_and_type & 0x7) as u8;
1149    let suffix_end = offset
1150        .checked_add(suffix_len)
1151        .ok_or_else(|| GitError::InvalidFormat("reftable log suffix overflow".into()))?;
1152    if suffix_end > end {
1153        return Err(GitError::InvalidFormat(
1154            "truncated reftable log suffix".into(),
1155        ));
1156    }
1157    let mut key = previous_key[..prefix_len].to_vec();
1158    key.extend_from_slice(&block[*offset..suffix_end]);
1159    *offset = suffix_end;
1160    // The key is `refname \0 <8-byte BE ts>`: at least 9 trailing bytes, and the
1161    // byte before the timestamp must be the NUL separator.
1162    if key.len() < 9 || key[key.len() - 9] != 0 {
1163        return Err(GitError::InvalidFormat("malformed reftable log key".into()));
1164    }
1165    let refname = std::str::from_utf8(&key[..key.len() - 9])
1166        .map_err(|err| GitError::InvalidFormat(err.to_string()))?
1167        .to_string();
1168    let ts_bytes: [u8; 8] = key[key.len() - 8..]
1169        .try_into()
1170        .map_err(|_| GitError::InvalidFormat("truncated reftable log timestamp".into()))?;
1171    let update_index = u64::MAX - u64::from_be_bytes(ts_bytes);
1172    *previous_key = key;
1173    let value = match value_type {
1174        0 => ReftableLogValue::Deletion,
1175        1 => {
1176            let old_oid = read_reftable_oid(block, offset, end, header.object_format)?;
1177            let new_oid = read_reftable_oid(block, offset, end, header.object_format)?;
1178            let name = read_reftable_string(block, offset, end)?;
1179            let email = read_reftable_string(block, offset, end)?;
1180            let time = read_reftable_varint(block, offset, end)?;
1181            let tz_offset = i16::from_be_bytes([
1182                *block
1183                    .get(*offset)
1184                    .ok_or_else(|| GitError::InvalidFormat("truncated reftable log tz".into()))?,
1185                *block
1186                    .get(*offset + 1)
1187                    .ok_or_else(|| GitError::InvalidFormat("truncated reftable log tz".into()))?,
1188            ]);
1189            *offset += 2;
1190            let message = read_reftable_string(block, offset, end)?;
1191            ReftableLogValue::Update(ReftableLogUpdate {
1192                old_oid,
1193                new_oid,
1194                name,
1195                email,
1196                time,
1197                tz_offset,
1198                message,
1199            })
1200        }
1201        other => {
1202            return Err(GitError::InvalidFormat(format!(
1203                "unsupported reftable log value type {other}"
1204            )));
1205        }
1206    };
1207    Ok(ReftableLogRecord {
1208        refname,
1209        update_index,
1210        value,
1211    })
1212}
1213
1214/// Deflate `data` with a zlib header/trailer at level 9, matching git's
1215/// `deflateInit(stream, 9)` for reftable log blocks.
1216fn deflate_zlib(data: &[u8]) -> Result<Vec<u8>> {
1217    use flate2::{Compression, write::ZlibEncoder};
1218    use std::io::Write;
1219    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::new(9));
1220    encoder
1221        .write_all(data)
1222        .map_err(|err| GitError::InvalidFormat(format!("reftable log deflate failed: {err}")))?;
1223    encoder
1224        .finish()
1225        .map_err(|err| GitError::InvalidFormat(format!("reftable log deflate failed: {err}")))
1226}
1227
1228/// Inflate a single zlib stream from `data`, expecting `expected_len`
1229/// decompressed bytes. Returns the decompressed body and the number of
1230/// compressed bytes consumed, so the caller can find the next log block.
1231fn inflate_zlib(data: &[u8], expected_len: usize) -> Result<(Vec<u8>, usize)> {
1232    use flate2::Decompress;
1233    use flate2::FlushDecompress;
1234    let mut decoder = Decompress::new(true);
1235    let mut out = Vec::with_capacity(expected_len);
1236    decoder
1237        .decompress_vec(data, &mut out, FlushDecompress::Finish)
1238        .map_err(|err| GitError::InvalidFormat(format!("reftable log inflate failed: {err}")))?;
1239    Ok((out, decoder.total_in() as usize))
1240}
1241
1242fn read_reftable_oid(
1243    block: &[u8],
1244    offset: &mut usize,
1245    end: usize,
1246    format: ObjectFormat,
1247) -> Result<ObjectId> {
1248    let oid_end = offset
1249        .checked_add(format.raw_len())
1250        .ok_or_else(|| GitError::InvalidFormat("reftable object id overflow".into()))?;
1251    if oid_end > end {
1252        return Err(GitError::InvalidFormat(
1253            "truncated reftable object id".into(),
1254        ));
1255    }
1256    let oid = ObjectId::from_raw(format, &block[*offset..oid_end])?;
1257    *offset = oid_end;
1258    Ok(oid)
1259}
1260
1261fn read_reftable_varint(bytes: &[u8], offset: &mut usize, end: usize) -> Result<u64> {
1262    if *offset >= end {
1263        return Err(GitError::InvalidFormat("truncated reftable varint".into()));
1264    }
1265    let mut value = u64::from(bytes[*offset] & 0x7f);
1266    while bytes[*offset] & 0x80 != 0 {
1267        *offset += 1;
1268        if *offset >= end {
1269            return Err(GitError::InvalidFormat("truncated reftable varint".into()));
1270        }
1271        value = value
1272            .checked_add(1)
1273            .and_then(|value| value.checked_shl(7))
1274            .ok_or_else(|| GitError::InvalidFormat("reftable varint overflow".into()))?
1275            | u64::from(bytes[*offset] & 0x7f);
1276    }
1277    *offset += 1;
1278    Ok(value)
1279}
1280
1281fn write_reftable_varint(out: &mut Vec<u8>, mut value: u64) {
1282    let mut bytes = [0u8; 10];
1283    let mut pos = bytes.len() - 1;
1284    bytes[pos] = (value & 0x7f) as u8;
1285    while value > 0x7f {
1286        value = (value >> 7) - 1;
1287        pos -= 1;
1288        bytes[pos] = ((value & 0x7f) as u8) | 0x80;
1289    }
1290    out.extend_from_slice(&bytes[pos..]);
1291}
1292
1293fn read_u16(bytes: &[u8], offset: usize) -> Result<u16> {
1294    let raw = bytes
1295        .get(offset..offset + 2)
1296        .ok_or_else(|| GitError::InvalidFormat("truncated uint16".into()))?;
1297    Ok(u16::from_be_bytes([raw[0], raw[1]]))
1298}
1299
1300fn read_u24(bytes: &[u8], offset: usize) -> Result<u32> {
1301    let raw = bytes
1302        .get(offset..offset + 3)
1303        .ok_or_else(|| GitError::InvalidFormat("truncated uint24".into()))?;
1304    Ok((u32::from(raw[0]) << 16) | (u32::from(raw[1]) << 8) | u32::from(raw[2]))
1305}
1306
1307fn write_u24(out: &mut Vec<u8>, value: u32) -> Result<()> {
1308    if value > REFTABLE_MAX_BLOCK_SIZE {
1309        return Err(GitError::InvalidFormat(format!(
1310            "uint24 value {value} exceeds maximum"
1311        )));
1312    }
1313    out.push((value >> 16) as u8);
1314    out.push((value >> 8) as u8);
1315    out.push(value as u8);
1316    Ok(())
1317}
1318
1319fn write_u24_at(out: &mut [u8], offset: usize, value: u32) -> Result<()> {
1320    if value > REFTABLE_MAX_BLOCK_SIZE {
1321        return Err(GitError::InvalidFormat(format!(
1322            "uint24 value {value} exceeds maximum"
1323        )));
1324    }
1325    let target = out
1326        .get_mut(offset..offset + 3)
1327        .ok_or_else(|| GitError::InvalidFormat("uint24 write is out of bounds".into()))?;
1328    target[0] = (value >> 16) as u8;
1329    target[1] = (value >> 8) as u8;
1330    target[2] = value as u8;
1331    Ok(())
1332}
1333
1334fn read_u32(bytes: &[u8], offset: usize) -> Result<u32> {
1335    let raw = bytes
1336        .get(offset..offset + 4)
1337        .ok_or_else(|| GitError::InvalidFormat("truncated uint32".into()))?;
1338    Ok(u32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]]))
1339}
1340
1341fn read_u64(bytes: &[u8], offset: usize) -> Result<u64> {
1342    let raw = bytes
1343        .get(offset..offset + 8)
1344        .ok_or_else(|| GitError::InvalidFormat("truncated uint64".into()))?;
1345    Ok(u64::from_be_bytes([
1346        raw[0], raw[1], raw[2], raw[3], raw[4], raw[5], raw[6], raw[7],
1347    ]))
1348}
1349
1350fn crc32(bytes: &[u8]) -> u32 {
1351    let mut crc = 0xffff_ffffu32;
1352    for byte in bytes {
1353        crc ^= u32::from(*byte);
1354        for _ in 0..8 {
1355            let mask = 0u32.wrapping_sub(crc & 1);
1356            crc = (crc >> 1) ^ (0xedb8_8320 & mask);
1357        }
1358    }
1359    !crc
1360}
1361
1362#[derive(Debug, Clone, PartialEq, Eq)]
1363pub struct CommitGraph {
1364    pub version: u8,
1365    pub format: ObjectFormat,
1366    pub base_graph_count: u8,
1367    pub fanout: [u32; 256],
1368    pub commits: Vec<CommitGraphEntry>,
1369    pub chunks: Vec<CommitGraphChunk>,
1370    pub base_graphs: Vec<ObjectId>,
1371    pub bloom_filters: Option<CommitGraphBloomFilters>,
1372    pub checksum: ObjectId,
1373}
1374
1375#[derive(Debug, Clone, PartialEq, Eq)]
1376pub struct CommitGraphEntry {
1377    pub oid: ObjectId,
1378    pub tree: ObjectId,
1379    pub parents: Vec<u32>,
1380    pub generation: u32,
1381    pub commit_time: u64,
1382    pub corrected_commit_date_offset: Option<u64>,
1383}
1384
1385impl CommitGraphEntry {
1386    /// Borrowed parent positions into the containing commit-graph's sorted commit
1387    /// table.
1388    pub fn parent_indices(&self) -> impl ExactSizeIterator<Item = u32> + '_ {
1389        self.parents.iter().copied()
1390    }
1391
1392    pub fn first_parent_index(&self) -> Option<u32> {
1393        self.parents.first().copied()
1394    }
1395}
1396
1397/// Borrowed iterator over a commit-graph entry's parent object ids.
1398pub struct CommitGraphParentOids<'a> {
1399    graph: &'a CommitGraph,
1400    parents: std::slice::Iter<'a, u32>,
1401}
1402
1403impl Iterator for CommitGraphParentOids<'_> {
1404    type Item = ObjectId;
1405
1406    fn next(&mut self) -> Option<Self::Item> {
1407        let parent = *self.parents.next()?;
1408        let idx = usize::try_from(parent).ok()?;
1409        self.graph.commits.get(idx).map(|entry| entry.oid)
1410    }
1411
1412    fn size_hint(&self) -> (usize, Option<usize>) {
1413        self.parents.size_hint()
1414    }
1415}
1416
1417impl ExactSizeIterator for CommitGraphParentOids<'_> {}
1418
1419#[derive(Debug, Clone, PartialEq, Eq)]
1420pub struct CommitGraphChunk {
1421    pub id: [u8; 4],
1422    pub offset: u64,
1423    pub len: u64,
1424}
1425
1426#[derive(Debug, Clone, PartialEq, Eq)]
1427pub struct CommitGraphBloomFilters {
1428    pub hash_version: u32,
1429    pub hash_count: u32,
1430    pub bits_per_entry: u32,
1431    pub filters: Vec<Vec<u8>>,
1432}
1433
1434#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1435pub struct CommitGraphBloomSettings {
1436    pub hash_version: u32,
1437    pub hash_count: u32,
1438    pub bits_per_entry: u32,
1439    pub max_changed_paths: usize,
1440}
1441
1442pub const DEFAULT_COMMIT_GRAPH_BLOOM_SETTINGS: CommitGraphBloomSettings =
1443    CommitGraphBloomSettings {
1444        hash_version: 1,
1445        hash_count: 7,
1446        bits_per_entry: 10,
1447        max_changed_paths: 512,
1448    };
1449
1450#[derive(Debug, Clone, PartialEq, Eq)]
1451pub struct CommitGraphWriteEntry {
1452    pub oid: ObjectId,
1453    pub tree: ObjectId,
1454    pub parents: Vec<ObjectId>,
1455    pub generation: u32,
1456    pub commit_time: u64,
1457    pub bloom_filter: Option<Vec<u8>>,
1458}
1459
1460impl CommitGraph {
1461    pub fn write(format: ObjectFormat, entries: &[CommitGraphWriteEntry]) -> Result<Vec<u8>> {
1462        Self::write_with_bloom_settings(format, entries, DEFAULT_COMMIT_GRAPH_BLOOM_SETTINGS)
1463    }
1464
1465    pub fn write_with_bloom_settings(
1466        format: ObjectFormat,
1467        entries: &[CommitGraphWriteEntry],
1468        bloom_settings: CommitGraphBloomSettings,
1469    ) -> Result<Vec<u8>> {
1470        // Default generation version is 2 ⇒ write the GDA2 corrected-commit-date
1471        // chunk (matches git's `commitGraph.generationVersion` default).
1472        Self::write_with_options(format, entries, bloom_settings, true)
1473    }
1474
1475    /// Write a commit-graph, optionally including the GDA2/GDO2 generation-data
1476    /// chunks. `write_generation_data == false` mirrors
1477    /// `commitGraph.generationVersion=1`, in which git stores only the
1478    /// topological level (in CDAT) and omits the corrected-commit-date chunks.
1479    pub fn write_with_options(
1480        format: ObjectFormat,
1481        entries: &[CommitGraphWriteEntry],
1482        bloom_settings: CommitGraphBloomSettings,
1483        write_generation_data: bool,
1484    ) -> Result<Vec<u8>> {
1485        validate_commit_graph_bloom_settings(bloom_settings)?;
1486        let mut entries = entries.to_vec();
1487        entries.sort_by(|left, right| left.oid.as_bytes().cmp(right.oid.as_bytes()));
1488        validate_commit_graph_write_entries(format, &entries)?;
1489        let object_ids = entries.iter().map(|entry| entry.oid).collect::<Vec<_>>();
1490        let (cdat, edge) = write_commit_graph_commit_data(&entries)?;
1491        let mut chunks = vec![
1492            (*b"OIDF", write_commit_graph_fanout(&object_ids)?),
1493            (*b"OIDL", write_commit_graph_oid_lookup(&object_ids)),
1494            (*b"CDAT", cdat),
1495        ];
1496        if write_generation_data {
1497            chunks.push((*b"GDA2", write_commit_graph_generation_data(&entries)?));
1498            let gdo2 = write_commit_graph_generation_overflow(&entries)?;
1499            if !gdo2.is_empty() {
1500                chunks.push((*b"GDO2", gdo2));
1501            }
1502        }
1503        if !edge.is_empty() {
1504            chunks.push((*b"EDGE", edge));
1505        }
1506        let bloom = write_commit_graph_bloom_filters(&entries, bloom_settings);
1507        if let Some((bidx, bdat)) = bloom {
1508            chunks.push((*b"BIDX", bidx));
1509            chunks.push((*b"BDAT", bdat));
1510        }
1511        write_commit_graph_chunks(format, 0, &chunks)
1512    }
1513
1514    /// Write an incremental commit-graph layer.
1515    ///
1516    /// `base_graphs` are the chain layer ids, base first, and `base_oids` are
1517    /// the commit ids from those base layers in graph-position order. Parent
1518    /// positions in this layer are encoded against `base_oids + entries`, which
1519    /// matches Git's split commit-graph addressing model.
1520    pub fn write_with_base_options(
1521        format: ObjectFormat,
1522        entries: &[CommitGraphWriteEntry],
1523        bloom_settings: CommitGraphBloomSettings,
1524        write_generation_data: bool,
1525        base_graphs: &[ObjectId],
1526        base_oids: &[ObjectId],
1527    ) -> Result<Vec<u8>> {
1528        validate_commit_graph_bloom_settings(bloom_settings)?;
1529        if base_graphs.len() > u8::MAX as usize {
1530            return Err(GitError::InvalidFormat(
1531                "too many commit-graph base layers".into(),
1532            ));
1533        }
1534        for oid in base_graphs.iter().chain(base_oids.iter()) {
1535            if oid.format() != format {
1536                return Err(GitError::InvalidObjectId(
1537                    "commit-graph base format does not match graph format".into(),
1538                ));
1539            }
1540        }
1541        let mut entries = entries.to_vec();
1542        entries.sort_by(|left, right| left.oid.as_bytes().cmp(right.oid.as_bytes()));
1543        validate_commit_graph_write_entries(format, &entries)?;
1544        let object_ids = entries.iter().map(|entry| entry.oid).collect::<Vec<_>>();
1545        let parent_positions = commit_graph_parent_positions(&entries, base_oids)?;
1546        let (cdat, edge) =
1547            write_commit_graph_commit_data_with_positions(&entries, &parent_positions)?;
1548        let mut chunks = vec![
1549            (*b"OIDF", write_commit_graph_fanout(&object_ids)?),
1550            (*b"OIDL", write_commit_graph_oid_lookup(&object_ids)),
1551            (*b"CDAT", cdat),
1552        ];
1553        if write_generation_data {
1554            if base_oids.is_empty() {
1555                chunks.push((*b"GDA2", write_commit_graph_generation_data(&entries)?));
1556                let gdo2 = write_commit_graph_generation_overflow(&entries)?;
1557                if !gdo2.is_empty() {
1558                    chunks.push((*b"GDO2", gdo2));
1559                }
1560            } else {
1561                chunks.push((*b"GDA2", vec![0; entries.len() * 4]));
1562            }
1563        }
1564        if !edge.is_empty() {
1565            chunks.push((*b"EDGE", edge));
1566        }
1567        let bloom = write_commit_graph_bloom_filters(&entries, bloom_settings);
1568        if let Some((bidx, bdat)) = bloom {
1569            chunks.push((*b"BIDX", bidx));
1570            chunks.push((*b"BDAT", bdat));
1571        }
1572        if !base_graphs.is_empty() {
1573            let mut base = Vec::with_capacity(base_graphs.len() * format.raw_len());
1574            for oid in base_graphs {
1575                base.extend_from_slice(oid.as_bytes());
1576            }
1577            chunks.push((*b"BASE", base));
1578        }
1579        write_commit_graph_chunks(format, base_graphs.len() as u8, &chunks)
1580    }
1581
1582    pub fn parse(bytes: &[u8], format: ObjectFormat) -> Result<Self> {
1583        let hash_len = format.raw_len();
1584        if bytes.len() < 8 + 12 + hash_len {
1585            return Err(GitError::InvalidFormat(
1586                "commit-graph file too short".into(),
1587            ));
1588        }
1589        if &bytes[..4] != b"CGPH" {
1590            return Err(GitError::InvalidFormat(
1591                "missing commit-graph signature".into(),
1592            ));
1593        }
1594        let version = bytes[4];
1595        if version != 1 {
1596            return Err(GitError::Unsupported(format!(
1597                "commit-graph version {version}"
1598            )));
1599        }
1600        let hash_id = bytes[5];
1601        if u32::from(hash_id) != hash_function_id(format) {
1602            return Err(GitError::InvalidFormat(format!(
1603                "commit-graph hash id {hash_id} does not match {}",
1604                format.name()
1605            )));
1606        }
1607        let chunk_count = bytes[6] as usize;
1608        let base_graph_count = bytes[7];
1609        let lookup_len = (chunk_count + 1)
1610            .checked_mul(12)
1611            .ok_or_else(|| GitError::InvalidFormat("commit-graph lookup overflow".into()))?;
1612        let data_start = 8usize
1613            .checked_add(lookup_len)
1614            .ok_or_else(|| GitError::InvalidFormat("commit-graph lookup overflow".into()))?;
1615        let checksum_offset = bytes.len() - hash_len;
1616        if data_start > checksum_offset {
1617            return Err(GitError::InvalidFormat(
1618                "truncated commit-graph chunk lookup".into(),
1619            ));
1620        }
1621
1622        let actual_checksum = sley_core::digest_bytes(format, &bytes[..checksum_offset])?;
1623        let checksum = ObjectId::from_raw(format, &bytes[checksum_offset..])?;
1624        if actual_checksum != checksum {
1625            return Err(GitError::InvalidFormat(format!(
1626                "commit-graph checksum mismatch: expected {checksum}, got {actual_checksum}"
1627            )));
1628        }
1629
1630        let mut lookup = Vec::with_capacity(chunk_count + 1);
1631        let mut offset = 8usize;
1632        for _ in 0..=chunk_count {
1633            let id = [
1634                bytes[offset],
1635                bytes[offset + 1],
1636                bytes[offset + 2],
1637                bytes[offset + 3],
1638            ];
1639            let chunk_offset = u64_be(&bytes[offset + 4..offset + 12]);
1640            lookup.push((id, chunk_offset));
1641            offset += 12;
1642        }
1643        let Some((terminator_id, terminator_offset)) = lookup.last().copied() else {
1644            return Err(GitError::InvalidFormat(
1645                "commit-graph chunk lookup is empty".into(),
1646            ));
1647        };
1648        if terminator_id != [0, 0, 0, 0] {
1649            return Err(GitError::InvalidFormat(
1650                "commit-graph chunk lookup missing terminator".into(),
1651            ));
1652        }
1653        if terminator_offset != checksum_offset as u64 {
1654            return Err(GitError::InvalidFormat(
1655                "commit-graph terminator does not point at checksum".into(),
1656            ));
1657        }
1658
1659        let mut chunks = Vec::with_capacity(chunk_count);
1660        let mut previous_offset = data_start as u64;
1661        for pair in lookup.windows(2) {
1662            let (id, chunk_offset) = pair[0];
1663            let (_next_id, next_offset) = pair[1];
1664            if id == [0, 0, 0, 0] {
1665                return Err(GitError::InvalidFormat(
1666                    "commit-graph chunk id is zero before terminator".into(),
1667                ));
1668            }
1669            if chunks.iter().any(|chunk: &CommitGraphChunk| chunk.id == id) {
1670                return Err(GitError::InvalidFormat(
1671                    "commit-graph chunk id is duplicated".into(),
1672                ));
1673            }
1674            if chunk_offset < data_start as u64 || chunk_offset < previous_offset {
1675                return Err(GitError::InvalidFormat(
1676                    "commit-graph chunk offsets are not monotonic".into(),
1677                ));
1678            }
1679            if next_offset < chunk_offset || next_offset > checksum_offset as u64 {
1680                return Err(GitError::InvalidFormat(
1681                    "commit-graph chunk length is invalid".into(),
1682                ));
1683            }
1684            chunks.push(CommitGraphChunk {
1685                id,
1686                offset: chunk_offset,
1687                len: next_offset - chunk_offset,
1688            });
1689            previous_offset = chunk_offset;
1690        }
1691
1692        let (fanout, commit_count) = parse_commit_graph_fanout(bytes, &chunks)?;
1693        let oids = parse_commit_graph_oids(bytes, &chunks, format, commit_count, &fanout)?;
1694        let mut commits =
1695            parse_commit_graph_commit_data(bytes, &chunks, format, oids, base_graph_count)?;
1696        apply_commit_graph_generation_data(bytes, &chunks, &mut commits)?;
1697        let bloom_filters = parse_commit_graph_bloom_filters(bytes, &chunks, commits.len())?;
1698        let base_graphs =
1699            parse_commit_graph_base_graphs(bytes, &chunks, format, base_graph_count as usize)?;
1700
1701        Ok(Self {
1702            version,
1703            format,
1704            base_graph_count,
1705            fanout,
1706            commits,
1707            chunks,
1708            base_graphs,
1709            bloom_filters,
1710            checksum,
1711        })
1712    }
1713
1714    pub fn find(&self, oid: &ObjectId) -> Option<&CommitGraphEntry> {
1715        self.commits
1716            .binary_search_by(|entry| entry.oid.as_bytes().cmp(oid.as_bytes()))
1717            .ok()
1718            .map(|idx| &self.commits[idx])
1719    }
1720
1721    /// Borrow parent object ids for `entry` without allocating a `Vec`.
1722    ///
1723    /// The parser validates parent indices while reading `CDAT`/`EDGE`, but this
1724    /// method rechecks them so callers using manually constructed graphs get a
1725    /// structured error instead of a truncated iterator.
1726    pub fn parent_oids<'a>(
1727        &'a self,
1728        entry: &'a CommitGraphEntry,
1729    ) -> Result<CommitGraphParentOids<'a>> {
1730        for parent in entry.parent_indices() {
1731            let idx = usize::try_from(parent).map_err(|_| {
1732                GitError::InvalidFormat("commit-graph parent index overflow".into())
1733            })?;
1734            if idx >= self.commits.len() {
1735                return Err(GitError::InvalidFormat(
1736                    "commit-graph parent points past commit table".into(),
1737                ));
1738            }
1739        }
1740        Ok(CommitGraphParentOids {
1741            graph: self,
1742            parents: entry.parents.iter(),
1743        })
1744    }
1745}
1746
1747impl CommitGraphBloomFilters {
1748    pub fn filter_for_commit(&self, commit_index: usize) -> Option<&[u8]> {
1749        self.filters.get(commit_index).map(Vec::as_slice)
1750    }
1751
1752    pub fn contains_path(&self, commit_index: usize, path: &[u8]) -> Option<bool> {
1753        let filter = self.filter_for_commit(commit_index)?;
1754        Some(commit_graph_bloom_filter_contains(
1755            filter,
1756            path,
1757            CommitGraphBloomSettings {
1758                hash_version: self.hash_version,
1759                hash_count: self.hash_count,
1760                bits_per_entry: self.bits_per_entry,
1761                max_changed_paths: DEFAULT_COMMIT_GRAPH_BLOOM_SETTINGS.max_changed_paths,
1762            },
1763        ))
1764    }
1765}
1766
1767fn validate_commit_graph_bloom_settings(settings: CommitGraphBloomSettings) -> Result<()> {
1768    match settings.hash_version {
1769        1 | 2 => Ok(()),
1770        other => Err(GitError::Unsupported(format!(
1771            "commit-graph changed-path Bloom filter hash version {other}"
1772        ))),
1773    }
1774}
1775
1776fn validate_commit_graph_write_entries(
1777    format: ObjectFormat,
1778    entries: &[CommitGraphWriteEntry],
1779) -> Result<()> {
1780    let mut previous_oid: Option<&ObjectId> = None;
1781    for entry in entries {
1782        if entry.oid.format() != format
1783            || entry.tree.format() != format
1784            || entry.parents.iter().any(|parent| parent.format() != format)
1785        {
1786            return Err(GitError::InvalidObjectId(
1787                "commit-graph entry format does not match graph format".into(),
1788            ));
1789        }
1790        if let Some(previous) = previous_oid
1791            && previous.as_bytes() == entry.oid.as_bytes()
1792        {
1793            return Err(GitError::InvalidFormat(
1794                "commit-graph contains duplicate object ids".into(),
1795            ));
1796        }
1797        if entry.generation >= (1 << 30) {
1798            return Err(GitError::InvalidFormat(
1799                "commit-graph generation is too large".into(),
1800            ));
1801        }
1802        previous_oid = Some(&entry.oid);
1803    }
1804    Ok(())
1805}
1806
1807fn write_commit_graph_fanout(object_ids: &[ObjectId]) -> Result<Vec<u8>> {
1808    let mut counts = [0u32; 256];
1809    for oid in object_ids {
1810        let first = oid.as_bytes()[0] as usize;
1811        counts[first] = counts[first]
1812            .checked_add(1)
1813            .ok_or_else(|| GitError::InvalidFormat("commit-graph fanout overflow".into()))?;
1814    }
1815    let mut running = 0u32;
1816    let mut out = Vec::with_capacity(256 * 4);
1817    for count in counts {
1818        running = running
1819            .checked_add(count)
1820            .ok_or_else(|| GitError::InvalidFormat("commit-graph fanout overflow".into()))?;
1821        out.extend_from_slice(&running.to_be_bytes());
1822    }
1823    Ok(out)
1824}
1825
1826fn write_commit_graph_oid_lookup(object_ids: &[ObjectId]) -> Vec<u8> {
1827    let mut out = Vec::new();
1828    for oid in object_ids {
1829        out.extend_from_slice(oid.as_bytes());
1830    }
1831    out
1832}
1833
1834fn write_commit_graph_commit_data(entries: &[CommitGraphWriteEntry]) -> Result<(Vec<u8>, Vec<u8>)> {
1835    let parent_positions = commit_graph_parent_positions(entries, &[])?;
1836    write_commit_graph_commit_data_with_positions(entries, &parent_positions)
1837}
1838
1839fn commit_graph_parent_positions(
1840    entries: &[CommitGraphWriteEntry],
1841    base_oids: &[ObjectId],
1842) -> Result<HashMap<ObjectId, u32>> {
1843    let mut positions = HashMap::with_capacity(base_oids.len() + entries.len());
1844    for (idx, oid) in base_oids.iter().enumerate() {
1845        let idx = u32::try_from(idx)
1846            .map_err(|_| GitError::InvalidFormat("commit-graph base position overflow".into()))?;
1847        positions.entry(*oid).or_insert(idx);
1848    }
1849    let base_len = u32::try_from(base_oids.len())
1850        .map_err(|_| GitError::InvalidFormat("commit-graph base position overflow".into()))?;
1851    for (idx, entry) in entries.iter().enumerate() {
1852        let idx = u32::try_from(idx)
1853            .map_err(|_| GitError::InvalidFormat("commit-graph position overflow".into()))?;
1854        let pos = base_len
1855            .checked_add(idx)
1856            .ok_or_else(|| GitError::InvalidFormat("commit-graph position overflow".into()))?;
1857        if positions.insert(entry.oid, pos).is_some() {
1858            return Err(GitError::InvalidFormat(
1859                "commit-graph contains duplicate object ids".into(),
1860            ));
1861        }
1862    }
1863    Ok(positions)
1864}
1865
1866fn write_commit_graph_commit_data_with_positions(
1867    entries: &[CommitGraphWriteEntry],
1868    parent_positions_by_oid: &HashMap<ObjectId, u32>,
1869) -> Result<(Vec<u8>, Vec<u8>)> {
1870    let mut cdat = Vec::new();
1871    let mut edge = Vec::new();
1872    for entry in entries {
1873        cdat.extend_from_slice(entry.tree.as_bytes());
1874        let parent_positions = entry
1875            .parents
1876            .iter()
1877            .map(|parent| {
1878                parent_positions_by_oid.get(parent).copied().ok_or_else(|| {
1879                    GitError::InvalidFormat(format!(
1880                        "commit-graph parent {parent} is missing from graph"
1881                    ))
1882                })
1883            })
1884            .collect::<Result<Vec<_>>>()?;
1885        let first_parent = parent_positions
1886            .first()
1887            .copied()
1888            .unwrap_or(COMMIT_GRAPH_PARENT_NONE);
1889        let second_parent = match parent_positions.len() {
1890            0 | 1 => COMMIT_GRAPH_PARENT_NONE,
1891            2 => parent_positions[1],
1892            _ => {
1893                let edge_start = u32::try_from(edge.len() / 4).map_err(|_| {
1894                    GitError::InvalidFormat("commit-graph EDGE chunk overflow".into())
1895                })?;
1896                for (idx, parent) in parent_positions[1..].iter().enumerate() {
1897                    let mut value = *parent;
1898                    if idx == parent_positions.len() - 2 {
1899                        value |= COMMIT_GRAPH_EXTRA_EDGE;
1900                    }
1901                    edge.extend_from_slice(&value.to_be_bytes());
1902                }
1903                COMMIT_GRAPH_EXTRA_EDGE | edge_start
1904            }
1905        };
1906        cdat.extend_from_slice(&first_parent.to_be_bytes());
1907        cdat.extend_from_slice(&second_parent.to_be_bytes());
1908        let generation_and_time_high =
1909            (entry.generation << 2) | (((entry.commit_time >> 32) as u32) & 0x3);
1910        cdat.extend_from_slice(&generation_and_time_high.to_be_bytes());
1911        cdat.extend_from_slice(&(entry.commit_time as u32).to_be_bytes());
1912    }
1913    Ok((cdat, edge))
1914}
1915
1916fn write_commit_graph_generation_data(entries: &[CommitGraphWriteEntry]) -> Result<Vec<u8>> {
1917    let corrected_dates = corrected_commit_dates(entries)?;
1918    let mut overflow_index = 0u32;
1919    let mut out = Vec::with_capacity(entries.len() * 4);
1920    for (entry, corrected) in entries.iter().zip(corrected_dates) {
1921        let offset = corrected.saturating_sub(entry.commit_time);
1922        if offset > COMMIT_GRAPH_GENERATION_DATA_MAX {
1923            out.extend_from_slice(
1924                &(COMMIT_GRAPH_GENERATION_DATA_OVERFLOW | overflow_index).to_be_bytes(),
1925            );
1926            overflow_index = overflow_index.checked_add(1).ok_or_else(|| {
1927                GitError::InvalidFormat("commit-graph GDO2 chunk overflow".into())
1928            })?;
1929        } else {
1930            out.extend_from_slice(&(offset as u32).to_be_bytes());
1931        }
1932    }
1933    Ok(out)
1934}
1935
1936fn write_commit_graph_generation_overflow(entries: &[CommitGraphWriteEntry]) -> Result<Vec<u8>> {
1937    let corrected_dates = corrected_commit_dates(entries)?;
1938    let mut out = Vec::new();
1939    for (entry, corrected) in entries.iter().zip(corrected_dates) {
1940        let offset = corrected.saturating_sub(entry.commit_time);
1941        if offset > COMMIT_GRAPH_GENERATION_DATA_MAX {
1942            out.extend_from_slice(&offset.to_be_bytes());
1943        }
1944    }
1945    Ok(out)
1946}
1947
1948fn corrected_commit_dates(entries: &[CommitGraphWriteEntry]) -> Result<Vec<u64>> {
1949    // corrected[idx] = max(commit_time, max over parents of corrected[parent] + 1)
1950    //
1951    // Computed with an explicit work stack rather than recursion: a recursive
1952    // post-order walk overflows the call stack on deep histories (the commit-graph
1953    // write covers every reachable commit, which can be tens of thousands deep —
1954    // e.g. `git describe`'s deep-repo fixtures). The memoised result is identical.
1955    let resolve_parent = |parent: &ObjectId| -> Result<usize> {
1956        entries
1957            .binary_search_by(|entry| entry.oid.as_bytes().cmp(parent.as_bytes()))
1958            .map_err(|_| {
1959                GitError::InvalidFormat(format!(
1960                    "commit-graph parent {parent} is missing from graph"
1961                ))
1962            })
1963    };
1964
1965    let mut cache: Vec<Option<u64>> = vec![None; entries.len()];
1966    let mut stack: Vec<usize> = Vec::new();
1967    for start in 0..entries.len() {
1968        if cache[start].is_some() {
1969            continue;
1970        }
1971        stack.push(start);
1972        while let Some(&idx) = stack.last() {
1973            if cache[idx].is_some() {
1974                stack.pop();
1975                continue;
1976            }
1977            let entry = &entries[idx];
1978            let mut corrected = entry.commit_time;
1979            let mut ready = true;
1980            for parent in &entry.parents {
1981                let parent_idx = resolve_parent(parent)?;
1982                match cache[parent_idx] {
1983                    Some(value) => corrected = corrected.max(value.saturating_add(1)),
1984                    None => {
1985                        // Defer until the parent is resolved; it is pushed above
1986                        // `idx`, which is re-examined once all parents are ready.
1987                        stack.push(parent_idx);
1988                        ready = false;
1989                    }
1990                }
1991            }
1992            if ready {
1993                cache[idx] = Some(corrected);
1994                stack.pop();
1995            }
1996        }
1997    }
1998    cache
1999        .into_iter()
2000        .map(|value| {
2001            value.ok_or_else(|| {
2002                GitError::InvalidFormat("commit-graph corrected date missing".into())
2003            })
2004        })
2005        .collect()
2006}
2007
2008fn write_commit_graph_bloom_filters(
2009    entries: &[CommitGraphWriteEntry],
2010    settings: CommitGraphBloomSettings,
2011) -> Option<(Vec<u8>, Vec<u8>)> {
2012    if entries.iter().all(|entry| entry.bloom_filter.is_none()) {
2013        return None;
2014    }
2015    let mut bidx = Vec::with_capacity(entries.len() * 4);
2016    let mut bdat = Vec::new();
2017    bdat.extend_from_slice(&settings.hash_version.to_be_bytes());
2018    bdat.extend_from_slice(&settings.hash_count.to_be_bytes());
2019    bdat.extend_from_slice(&settings.bits_per_entry.to_be_bytes());
2020    let mut offset = 0u32;
2021    for entry in entries {
2022        if let Some(filter) = &entry.bloom_filter {
2023            offset = offset.checked_add(filter.len() as u32)?;
2024            bdat.extend_from_slice(filter);
2025        }
2026        bidx.extend_from_slice(&offset.to_be_bytes());
2027    }
2028    Some((bidx, bdat))
2029}
2030
2031pub fn commit_graph_bloom_filter_for_paths<I, P>(
2032    paths: I,
2033    settings: CommitGraphBloomSettings,
2034) -> Vec<u8>
2035where
2036    I: IntoIterator<Item = P>,
2037    P: AsRef<[u8]>,
2038{
2039    let mut unique = std::collections::BTreeSet::new();
2040    for path in paths {
2041        let path = path.as_ref();
2042        if path.is_empty() {
2043            continue;
2044        }
2045        unique.insert(path.to_vec());
2046        for idx in path
2047            .iter()
2048            .enumerate()
2049            .filter_map(|(idx, byte)| (*byte == b'/').then_some(idx))
2050        {
2051            if idx > 0 {
2052                unique.insert(path[..idx].to_vec());
2053            }
2054        }
2055    }
2056    if unique.len() > settings.max_changed_paths {
2057        return vec![0xff];
2058    }
2059    let filter_len = (unique.len() as u64 * u64::from(settings.bits_per_entry)).div_ceil(8);
2060    let mut filter = vec![0u8; usize::try_from(filter_len).unwrap_or(usize::MAX).max(1)];
2061    for path in unique {
2062        add_commit_graph_bloom_key(&mut filter, &path, settings);
2063    }
2064    filter
2065}
2066
2067pub fn commit_graph_bloom_filter_contains(
2068    filter: &[u8],
2069    path: &[u8],
2070    settings: CommitGraphBloomSettings,
2071) -> bool {
2072    if filter.is_empty() {
2073        return false;
2074    }
2075    commit_graph_bloom_key_hashes(path, settings)
2076        .into_iter()
2077        .all(|hash| {
2078            let bit = u64::from(hash) % ((filter.len() as u64) * 8);
2079            let byte = (bit / 8) as usize;
2080            let mask = 1u8 << (bit & 7);
2081            filter.get(byte).is_some_and(|value| value & mask != 0)
2082        })
2083}
2084
2085fn add_commit_graph_bloom_key(filter: &mut [u8], path: &[u8], settings: CommitGraphBloomSettings) {
2086    if filter.is_empty() {
2087        return;
2088    }
2089    for hash in commit_graph_bloom_key_hashes(path, settings) {
2090        let bit = u64::from(hash) % ((filter.len() as u64) * 8);
2091        let byte = (bit / 8) as usize;
2092        let mask = 1u8 << (bit & 7);
2093        if let Some(value) = filter.get_mut(byte) {
2094            *value |= mask;
2095        }
2096    }
2097}
2098
2099fn commit_graph_bloom_key_hashes(path: &[u8], settings: CommitGraphBloomSettings) -> Vec<u32> {
2100    let seed0 = 0x293a_e76f;
2101    let seed1 = 0x7e64_6e2c;
2102    let hash0 = commit_graph_bloom_murmur3_seeded(seed0, path, settings.hash_version);
2103    let hash1 = commit_graph_bloom_murmur3_seeded(seed1, path, settings.hash_version);
2104    (0..settings.hash_count)
2105        .map(|idx| hash0.wrapping_add(idx.wrapping_mul(hash1)))
2106        .collect()
2107}
2108
2109fn commit_graph_bloom_murmur3_seeded(seed: u32, data: &[u8], version: u32) -> u32 {
2110    let c1 = 0xcc9e_2d51u32;
2111    let c2 = 0x1b87_3593u32;
2112    let r1 = 15;
2113    let r2 = 13;
2114    let m = 5u32;
2115    let n = 0xe654_6b64u32;
2116    let mut seed = seed;
2117    for chunk in data.chunks_exact(4) {
2118        let mut k = if version == 2 {
2119            u32::from(chunk[0])
2120                | (u32::from(chunk[1]) << 8)
2121                | (u32::from(chunk[2]) << 16)
2122                | (u32::from(chunk[3]) << 24)
2123        } else {
2124            (chunk[0] as i8 as i32 as u32)
2125                | ((chunk[1] as i8 as i32 as u32) << 8)
2126                | ((chunk[2] as i8 as i32 as u32) << 16)
2127                | ((chunk[3] as i8 as i32 as u32) << 24)
2128        };
2129        k = k.wrapping_mul(c1);
2130        k = k.rotate_left(r1);
2131        k = k.wrapping_mul(c2);
2132        seed ^= k;
2133        seed = seed.rotate_left(r2).wrapping_mul(m).wrapping_add(n);
2134    }
2135
2136    let tail = data.chunks_exact(4).remainder();
2137    let mut k1 = 0u32;
2138    let tail_byte = |idx: usize| -> u32 {
2139        if version == 2 {
2140            u32::from(tail[idx])
2141        } else {
2142            tail[idx] as i8 as i32 as u32
2143        }
2144    };
2145    match tail.len() {
2146        3 => {
2147            k1 ^= tail_byte(2) << 16;
2148            k1 ^= tail_byte(1) << 8;
2149            k1 ^= tail_byte(0);
2150        }
2151        2 => {
2152            k1 ^= tail_byte(1) << 8;
2153            k1 ^= tail_byte(0);
2154        }
2155        1 => {
2156            k1 ^= tail_byte(0);
2157        }
2158        _ => {}
2159    }
2160    if !tail.is_empty() {
2161        k1 = k1.wrapping_mul(c1);
2162        k1 = k1.rotate_left(r1);
2163        k1 = k1.wrapping_mul(c2);
2164        seed ^= k1;
2165    }
2166
2167    seed ^= data.len() as u32;
2168    seed ^= seed >> 16;
2169    seed = seed.wrapping_mul(0x85eb_ca6b);
2170    seed ^= seed >> 13;
2171    seed = seed.wrapping_mul(0xc2b2_ae35);
2172    seed ^ (seed >> 16)
2173}
2174
2175fn write_commit_graph_chunks(
2176    format: ObjectFormat,
2177    base_graph_count: u8,
2178    chunks: &[([u8; 4], Vec<u8>)],
2179) -> Result<Vec<u8>> {
2180    if chunks.len() > u8::MAX as usize {
2181        return Err(GitError::InvalidFormat(
2182            "too many commit-graph chunks".into(),
2183        ));
2184    }
2185    let lookup_len = (chunks.len() + 1)
2186        .checked_mul(12)
2187        .ok_or_else(|| GitError::InvalidFormat("commit-graph lookup overflow".into()))?;
2188    let mut out = Vec::new();
2189    out.extend_from_slice(b"CGPH");
2190    out.push(1);
2191    out.push(hash_function_id(format) as u8);
2192    out.push(chunks.len() as u8);
2193    out.push(base_graph_count);
2194    let mut chunk_offset = (8usize)
2195        .checked_add(lookup_len)
2196        .ok_or_else(|| GitError::InvalidFormat("commit-graph lookup overflow".into()))?
2197        as u64;
2198    for (id, data) in chunks {
2199        out.extend_from_slice(id);
2200        out.extend_from_slice(&chunk_offset.to_be_bytes());
2201        chunk_offset = chunk_offset
2202            .checked_add(data.len() as u64)
2203            .ok_or_else(|| GitError::InvalidFormat("commit-graph size overflow".into()))?;
2204    }
2205    out.extend_from_slice(&[0, 0, 0, 0]);
2206    out.extend_from_slice(&chunk_offset.to_be_bytes());
2207    for (_id, data) in chunks {
2208        out.extend_from_slice(data);
2209    }
2210    let checksum = sley_core::digest_bytes(format, &out)?;
2211    out.extend_from_slice(checksum.as_bytes());
2212    Ok(out)
2213}
2214
2215const COMMIT_GRAPH_PARENT_NONE: u32 = 0x7000_0000;
2216const COMMIT_GRAPH_EXTRA_EDGE: u32 = 0x8000_0000;
2217const COMMIT_GRAPH_EXTRA_EDGE_MASK: u32 = 0x7fff_ffff;
2218const COMMIT_GRAPH_GENERATION_DATA_OVERFLOW: u32 = 0x8000_0000;
2219const COMMIT_GRAPH_GENERATION_DATA_MAX: u64 = 0x7fff_ffff;
2220
2221fn parse_commit_graph_fanout(
2222    bytes: &[u8],
2223    chunks: &[CommitGraphChunk],
2224) -> Result<([u32; 256], usize)> {
2225    let data = commit_graph_chunk_data(bytes, chunks, *b"OIDF", true)?
2226        .ok_or_else(|| GitError::InvalidFormat("commit-graph missing OIDF chunk".into()))?;
2227    if data.len() != 256 * 4 {
2228        return Err(GitError::InvalidFormat(
2229            "commit-graph OIDF chunk has invalid length".into(),
2230        ));
2231    }
2232    let mut fanout = [0u32; 256];
2233    let mut previous = 0u32;
2234    for (idx, slot) in fanout.iter_mut().enumerate() {
2235        let start = idx * 4;
2236        *slot = u32_be(&data[start..start + 4]);
2237        if *slot < previous {
2238            return Err(GitError::InvalidFormat(
2239                "commit-graph OIDF fanout is not monotonic".into(),
2240            ));
2241        }
2242        previous = *slot;
2243    }
2244    Ok((fanout, fanout[255] as usize))
2245}
2246
2247fn parse_commit_graph_oids(
2248    bytes: &[u8],
2249    chunks: &[CommitGraphChunk],
2250    format: ObjectFormat,
2251    commit_count: usize,
2252    fanout: &[u32; 256],
2253) -> Result<Vec<ObjectId>> {
2254    let data = commit_graph_chunk_data(bytes, chunks, *b"OIDL", true)?
2255        .ok_or_else(|| GitError::InvalidFormat("commit-graph missing OIDL chunk".into()))?;
2256    let expected_len = commit_count
2257        .checked_mul(format.raw_len())
2258        .ok_or_else(|| GitError::InvalidFormat("commit-graph OIDL chunk overflow".into()))?;
2259    if data.len() != expected_len {
2260        return Err(GitError::InvalidFormat(
2261            "commit-graph OIDL chunk has invalid length".into(),
2262        ));
2263    }
2264
2265    let mut oids = Vec::with_capacity(commit_count);
2266    let mut counts = [0u32; 256];
2267    let mut previous_oid: Option<ObjectId> = None;
2268    for idx in 0..commit_count {
2269        let start = idx * format.raw_len();
2270        let oid = ObjectId::from_raw(format, &data[start..start + format.raw_len()])?;
2271        if let Some(previous) = &previous_oid
2272            && previous.as_bytes() >= oid.as_bytes()
2273        {
2274            return Err(GitError::InvalidFormat(
2275                "commit-graph OIDL object ids are not strictly sorted".into(),
2276            ));
2277        }
2278        counts[oid.as_bytes()[0] as usize] = counts[oid.as_bytes()[0] as usize]
2279            .checked_add(1)
2280            .ok_or_else(|| GitError::InvalidFormat("commit-graph fanout overflow".into()))?;
2281        previous_oid = Some(oid);
2282        oids.push(oid);
2283    }
2284
2285    let mut running = 0u32;
2286    for (idx, count) in counts.iter().enumerate() {
2287        running = running
2288            .checked_add(*count)
2289            .ok_or_else(|| GitError::InvalidFormat("commit-graph fanout overflow".into()))?;
2290        if fanout[idx] != running {
2291            return Err(GitError::InvalidFormat(
2292                "commit-graph OIDF fanout does not match OIDL".into(),
2293            ));
2294        }
2295    }
2296    Ok(oids)
2297}
2298
2299fn parse_commit_graph_commit_data(
2300    bytes: &[u8],
2301    chunks: &[CommitGraphChunk],
2302    format: ObjectFormat,
2303    oids: Vec<ObjectId>,
2304    base_graph_count: u8,
2305) -> Result<Vec<CommitGraphEntry>> {
2306    let data = commit_graph_chunk_data(bytes, chunks, *b"CDAT", true)?
2307        .ok_or_else(|| GitError::InvalidFormat("commit-graph missing CDAT chunk".into()))?;
2308    let entry_len = format
2309        .raw_len()
2310        .checked_add(16)
2311        .ok_or_else(|| GitError::InvalidFormat("commit-graph CDAT entry overflow".into()))?;
2312    let expected_len = oids
2313        .len()
2314        .checked_mul(entry_len)
2315        .ok_or_else(|| GitError::InvalidFormat("commit-graph CDAT chunk overflow".into()))?;
2316    if data.len() != expected_len {
2317        return Err(GitError::InvalidFormat(
2318            "commit-graph CDAT chunk has invalid length".into(),
2319        ));
2320    }
2321    let extra_edges = commit_graph_chunk_data(bytes, chunks, *b"EDGE", false)?;
2322    if let Some(extra_edges) = extra_edges
2323        && extra_edges.len() % 4 != 0
2324    {
2325        return Err(GitError::InvalidFormat(
2326            "commit-graph EDGE chunk has invalid length".into(),
2327        ));
2328    }
2329
2330    let commit_count = oids.len();
2331    let parent_limit = if base_graph_count == 0 {
2332        commit_count
2333    } else {
2334        usize::MAX
2335    };
2336    let mut entries = Vec::with_capacity(commit_count);
2337    for (idx, oid) in oids.into_iter().enumerate() {
2338        let start = idx * entry_len;
2339        let tree = ObjectId::from_raw(format, &data[start..start + format.raw_len()])?;
2340        let parent_one = u32_be(&data[start + format.raw_len()..start + format.raw_len() + 4]);
2341        let parent_two = u32_be(&data[start + format.raw_len() + 4..start + format.raw_len() + 8]);
2342        let generation_and_time_high =
2343            u32_be(&data[start + format.raw_len() + 8..start + format.raw_len() + 12]);
2344        let time_low = u32_be(&data[start + format.raw_len() + 12..start + entry_len]);
2345        let generation = generation_and_time_high >> 2;
2346        let commit_time = (u64::from(generation_and_time_high & 0x3) << 32) | u64::from(time_low);
2347        let parents = commit_graph_parents(parent_one, parent_two, extra_edges, parent_limit)?;
2348        entries.push(CommitGraphEntry {
2349            oid,
2350            tree,
2351            parents,
2352            generation,
2353            commit_time,
2354            corrected_commit_date_offset: None,
2355        });
2356    }
2357    Ok(entries)
2358}
2359
2360fn apply_commit_graph_generation_data(
2361    bytes: &[u8],
2362    chunks: &[CommitGraphChunk],
2363    commits: &mut [CommitGraphEntry],
2364) -> Result<()> {
2365    let data = commit_graph_chunk_data(bytes, chunks, *b"GDA2", false)?;
2366    let overflow = commit_graph_chunk_data(bytes, chunks, *b"GDO2", false)?;
2367    let Some(data) = data else {
2368        if overflow.is_some() {
2369            return Err(GitError::InvalidFormat(
2370                "commit-graph GDO2 chunk exists without GDA2 chunk".into(),
2371            ));
2372        }
2373        return Ok(());
2374    };
2375    let expected_len = commits
2376        .len()
2377        .checked_mul(4)
2378        .ok_or_else(|| GitError::InvalidFormat("commit-graph GDA2 chunk overflow".into()))?;
2379    if data.len() != expected_len {
2380        return Err(GitError::InvalidFormat(
2381            "commit-graph GDA2 chunk has invalid length".into(),
2382        ));
2383    }
2384    if let Some(overflow) = overflow
2385        && overflow.len() % 8 != 0
2386    {
2387        return Err(GitError::InvalidFormat(
2388            "commit-graph GDO2 chunk has invalid length".into(),
2389        ));
2390    }
2391
2392    let mut used_overflow = false;
2393    for (idx, commit) in commits.iter_mut().enumerate() {
2394        let start = idx * 4;
2395        let raw = u32_be(&data[start..start + 4]);
2396        let offset = if raw & 0x8000_0000 == 0 {
2397            u64::from(raw)
2398        } else {
2399            used_overflow = true;
2400            let Some(overflow) = overflow else {
2401                return Err(GitError::InvalidFormat(
2402                    "commit-graph GDA2 overflow entry missing GDO2 chunk".into(),
2403                ));
2404            };
2405            let overflow_idx = (raw & 0x7fff_ffff) as usize;
2406            let overflow_start = overflow_idx.checked_mul(8).ok_or_else(|| {
2407                GitError::InvalidFormat("commit-graph GDO2 index overflow".into())
2408            })?;
2409            let overflow_end = overflow_start.checked_add(8).ok_or_else(|| {
2410                GitError::InvalidFormat("commit-graph GDO2 index overflow".into())
2411            })?;
2412            if overflow_end > overflow.len() {
2413                return Err(GitError::InvalidFormat(
2414                    "commit-graph GDA2 overflow points past GDO2 chunk".into(),
2415                ));
2416            }
2417            u64_be(&overflow[overflow_start..overflow_end])
2418        };
2419        commit.corrected_commit_date_offset = Some(offset);
2420    }
2421    if overflow.is_some() && !used_overflow {
2422        return Err(GitError::InvalidFormat(
2423            "commit-graph GDO2 chunk is unused by GDA2".into(),
2424        ));
2425    }
2426    Ok(())
2427}
2428
2429fn parse_commit_graph_bloom_filters(
2430    bytes: &[u8],
2431    chunks: &[CommitGraphChunk],
2432    commit_count: usize,
2433) -> Result<Option<CommitGraphBloomFilters>> {
2434    let index = commit_graph_chunk_data(bytes, chunks, *b"BIDX", false)?;
2435    let data = commit_graph_chunk_data(bytes, chunks, *b"BDAT", false)?;
2436    let Some(data) = data else {
2437        return Ok(None);
2438    };
2439    let Some(index) = index else {
2440        return Err(GitError::InvalidFormat(
2441            "commit-graph BDAT chunk exists without BIDX chunk".into(),
2442        ));
2443    };
2444    let expected_index_len = commit_count
2445        .checked_mul(4)
2446        .ok_or_else(|| GitError::InvalidFormat("commit-graph BIDX chunk overflow".into()))?;
2447    if index.len() != expected_index_len {
2448        return Err(GitError::InvalidFormat(
2449            "commit-graph BIDX chunk has invalid length".into(),
2450        ));
2451    }
2452    if data.len() < 12 {
2453        return Err(GitError::InvalidFormat(
2454            "commit-graph BDAT chunk has invalid length".into(),
2455        ));
2456    }
2457    let hash_version = u32_be(&data[0..4]);
2458    let hash_count = u32_be(&data[4..8]);
2459    let bits_per_entry = u32_be(&data[8..12]);
2460    let payload = &data[12..];
2461
2462    let mut filters = Vec::with_capacity(commit_count);
2463    let mut previous = 0usize;
2464    for idx in 0..commit_count {
2465        let start = idx * 4;
2466        let cumulative = u32_be(&index[start..start + 4]) as usize;
2467        if cumulative < previous {
2468            return Err(GitError::InvalidFormat(
2469                "commit-graph BIDX offsets are not monotonic".into(),
2470            ));
2471        }
2472        if cumulative > payload.len() {
2473            return Err(GitError::InvalidFormat(
2474                "commit-graph BIDX offset points past BDAT payload".into(),
2475            ));
2476        }
2477        filters.push(payload[previous..cumulative].to_vec());
2478        previous = cumulative;
2479    }
2480    if previous != payload.len() {
2481        return Err(GitError::InvalidFormat(
2482            "commit-graph BDAT payload has trailing bytes".into(),
2483        ));
2484    }
2485
2486    Ok(Some(CommitGraphBloomFilters {
2487        hash_version,
2488        hash_count,
2489        bits_per_entry,
2490        filters,
2491    }))
2492}
2493
2494fn commit_graph_parents(
2495    parent_one: u32,
2496    parent_two: u32,
2497    extra_edges: Option<&[u8]>,
2498    parent_limit: usize,
2499) -> Result<Vec<u32>> {
2500    let mut parents = Vec::new();
2501    if parent_one != COMMIT_GRAPH_PARENT_NONE {
2502        validate_commit_graph_parent_position(parent_one, parent_limit)?;
2503        parents.push(parent_one);
2504    }
2505    if parent_two == COMMIT_GRAPH_PARENT_NONE {
2506        return Ok(parents);
2507    }
2508    if parent_two & COMMIT_GRAPH_EXTRA_EDGE == 0 {
2509        validate_commit_graph_parent_position(parent_two, parent_limit)?;
2510        parents.push(parent_two);
2511        return Ok(parents);
2512    }
2513
2514    let Some(extra_edges) = extra_edges else {
2515        return Err(GitError::InvalidFormat(
2516            "commit-graph octopus edge missing EDGE chunk".into(),
2517        ));
2518    };
2519    let mut edge_idx = (parent_two & COMMIT_GRAPH_EXTRA_EDGE_MASK) as usize;
2520    loop {
2521        let start = edge_idx
2522            .checked_mul(4)
2523            .ok_or_else(|| GitError::InvalidFormat("commit-graph EDGE index overflow".into()))?;
2524        let end = start
2525            .checked_add(4)
2526            .ok_or_else(|| GitError::InvalidFormat("commit-graph EDGE index overflow".into()))?;
2527        if end > extra_edges.len() {
2528            return Err(GitError::InvalidFormat(
2529                "commit-graph EDGE entry points past chunk".into(),
2530            ));
2531        }
2532        let edge = u32_be(&extra_edges[start..end]);
2533        let parent = edge & COMMIT_GRAPH_EXTRA_EDGE_MASK;
2534        validate_commit_graph_parent_position(parent, parent_limit)?;
2535        parents.push(parent);
2536        if edge & COMMIT_GRAPH_EXTRA_EDGE != 0 {
2537            return Ok(parents);
2538        }
2539        edge_idx = edge_idx
2540            .checked_add(1)
2541            .ok_or_else(|| GitError::InvalidFormat("commit-graph EDGE index overflow".into()))?;
2542    }
2543}
2544
2545fn validate_commit_graph_parent_position(parent: u32, commit_count: usize) -> Result<()> {
2546    if parent as usize >= commit_count {
2547        return Err(GitError::InvalidFormat(
2548            "commit-graph parent points past commit table".into(),
2549        ));
2550    }
2551    Ok(())
2552}
2553
2554fn parse_commit_graph_base_graphs(
2555    bytes: &[u8],
2556    chunks: &[CommitGraphChunk],
2557    format: ObjectFormat,
2558    base_graph_count: usize,
2559) -> Result<Vec<ObjectId>> {
2560    let data = commit_graph_chunk_data(bytes, chunks, *b"BASE", base_graph_count != 0)?;
2561    let Some(data) = data else {
2562        return Ok(Vec::new());
2563    };
2564    let expected_len = base_graph_count
2565        .checked_mul(format.raw_len())
2566        .ok_or_else(|| GitError::InvalidFormat("commit-graph BASE chunk overflow".into()))?;
2567    if data.len() != expected_len {
2568        return Err(GitError::InvalidFormat(
2569            "commit-graph BASE chunk has invalid length".into(),
2570        ));
2571    }
2572    let mut base_graphs = Vec::with_capacity(base_graph_count);
2573    for idx in 0..base_graph_count {
2574        let start = idx * format.raw_len();
2575        base_graphs.push(ObjectId::from_raw(
2576            format,
2577            &data[start..start + format.raw_len()],
2578        )?);
2579    }
2580    Ok(base_graphs)
2581}
2582
2583fn commit_graph_chunk_data<'a>(
2584    bytes: &'a [u8],
2585    chunks: &[CommitGraphChunk],
2586    id: [u8; 4],
2587    required: bool,
2588) -> Result<Option<&'a [u8]>> {
2589    let Some(chunk) = chunks.iter().find(|chunk| chunk.id == id) else {
2590        if required {
2591            return Err(GitError::InvalidFormat(format!(
2592                "commit-graph missing {} chunk",
2593                std::str::from_utf8(&id).unwrap_or("required")
2594            )));
2595        }
2596        return Ok(None);
2597    };
2598    let start = usize::try_from(chunk.offset)
2599        .map_err(|_| GitError::InvalidFormat("commit-graph chunk offset overflow".into()))?;
2600    let len = usize::try_from(chunk.len)
2601        .map_err(|_| GitError::InvalidFormat("commit-graph chunk length overflow".into()))?;
2602    let end = start
2603        .checked_add(len)
2604        .ok_or_else(|| GitError::InvalidFormat("commit-graph chunk range overflow".into()))?;
2605    let Some(data) = bytes.get(start..end) else {
2606        return Err(GitError::InvalidFormat(
2607            "commit-graph chunk extends past file".into(),
2608        ));
2609    };
2610    Ok(Some(data))
2611}
2612
2613#[derive(Debug, Clone, PartialEq, Eq)]
2614pub struct Bundle {
2615    pub version: u8,
2616    pub format: ObjectFormat,
2617    pub capabilities: Vec<BundleCapability>,
2618    pub prerequisites: Vec<BundlePrerequisite>,
2619    pub references: Vec<BundleReference>,
2620    pub pack: Vec<u8>,
2621}
2622
2623#[derive(Debug, Clone, PartialEq, Eq)]
2624pub struct BundleCapability {
2625    pub key: String,
2626    pub value: Option<Vec<u8>>,
2627}
2628
2629#[derive(Debug, Clone, PartialEq, Eq)]
2630pub struct BundlePrerequisite {
2631    pub oid: ObjectId,
2632    pub comment: Vec<u8>,
2633}
2634
2635#[derive(Debug, Clone, PartialEq, Eq)]
2636pub struct BundleReference {
2637    pub oid: ObjectId,
2638    pub name: String,
2639}
2640
2641impl Bundle {
2642    pub fn parse_standalone(bytes: &[u8]) -> Result<Self> {
2643        Self::parse(bytes, ObjectFormat::Sha1)
2644    }
2645
2646    pub fn parse(bytes: &[u8], default_format: ObjectFormat) -> Result<Self> {
2647        let (signature, mut offset) = next_lf_line(bytes, 0)
2648            .ok_or_else(|| GitError::InvalidFormat("bundle missing signature".into()))?;
2649        let version = match signature {
2650            b"# v2 git bundle" => 2,
2651            b"# v3 git bundle" => 3,
2652            _ => {
2653                return Err(GitError::InvalidFormat("missing bundle signature".into()));
2654            }
2655        };
2656
2657        let mut format = default_format;
2658        let mut capabilities = Vec::new();
2659        let mut prerequisites = Vec::new();
2660        let mut references = Vec::new();
2661        let mut seen_non_capability = false;
2662        loop {
2663            let Some((line, next_offset)) = next_lf_line(bytes, offset) else {
2664                return Err(GitError::InvalidFormat(
2665                    "bundle header missing pack separator".into(),
2666                ));
2667            };
2668            offset = next_offset;
2669            if line.is_empty() {
2670                break;
2671            }
2672            match line[0] {
2673                b'@' => {
2674                    if version != 3 {
2675                        return Err(GitError::InvalidFormat(
2676                            "bundle v2 cannot contain capabilities".into(),
2677                        ));
2678                    }
2679                    if seen_non_capability {
2680                        return Err(GitError::InvalidFormat(
2681                            "bundle capability appears after prerequisites or references".into(),
2682                        ));
2683                    }
2684                    let capability = parse_bundle_capability(&line[1..])?;
2685                    if capability.key == "object-format" {
2686                        let Some(value) = &capability.value else {
2687                            return Err(GitError::InvalidFormat(
2688                                "bundle object-format capability is missing a value".into(),
2689                            ));
2690                        };
2691                        let text = std::str::from_utf8(value)
2692                            .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
2693                        format = text.parse()?;
2694                    }
2695                    capabilities.push(capability);
2696                }
2697                b'-' => {
2698                    seen_non_capability = true;
2699                    prerequisites.push(parse_bundle_prerequisite(&line[1..], format)?);
2700                }
2701                _ => {
2702                    seen_non_capability = true;
2703                    references.push(parse_bundle_reference(line, format)?);
2704                }
2705            }
2706        }
2707
2708        Ok(Self {
2709            version,
2710            format,
2711            capabilities,
2712            prerequisites,
2713            references,
2714            pack: bytes[offset..].to_vec(),
2715        })
2716    }
2717
2718    pub fn write(&self) -> Result<Vec<u8>> {
2719        if self.version != 2 && self.version != 3 {
2720            return Err(GitError::Unsupported(format!(
2721                "bundle version {}",
2722                self.version
2723            )));
2724        }
2725        if self.version == 2 && !self.capabilities.is_empty() {
2726            return Err(GitError::InvalidFormat(
2727                "bundle v2 cannot contain capabilities".into(),
2728            ));
2729        }
2730        let mut out = Vec::new();
2731        match self.version {
2732            2 => out.extend_from_slice(b"# v2 git bundle\n"),
2733            3 => out.extend_from_slice(b"# v3 git bundle\n"),
2734            _ => unreachable!(),
2735        }
2736        if self.version == 3 {
2737            let mut wrote_object_format = false;
2738            for capability in &self.capabilities {
2739                write_bundle_capability(&mut out, capability)?;
2740                if capability.key == "object-format" {
2741                    let Some(value) = &capability.value else {
2742                        return Err(GitError::InvalidFormat(
2743                            "bundle object-format capability is missing a value".into(),
2744                        ));
2745                    };
2746                    let text = std::str::from_utf8(value)
2747                        .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
2748                    let format: ObjectFormat = text.parse()?;
2749                    if format != self.format {
2750                        return Err(GitError::InvalidFormat(format!(
2751                            "bundle object-format capability is {}, bundle uses {}",
2752                            format.name(),
2753                            self.format.name()
2754                        )));
2755                    }
2756                    wrote_object_format = true;
2757                }
2758            }
2759            if self.format != ObjectFormat::Sha1 && !wrote_object_format {
2760                out.extend_from_slice(b"@object-format=");
2761                out.extend_from_slice(self.format.name().as_bytes());
2762                out.push(b'\n');
2763            }
2764        }
2765        for prerequisite in &self.prerequisites {
2766            ensure_bundle_oid_format(&prerequisite.oid, self.format, "prerequisite")?;
2767            out.push(b'-');
2768            out.extend_from_slice(prerequisite.oid.to_hex().as_bytes());
2769            out.push(b' ');
2770            out.extend_from_slice(&prerequisite.comment);
2771            out.push(b'\n');
2772        }
2773        for reference in &self.references {
2774            ensure_bundle_oid_format(&reference.oid, self.format, "reference")?;
2775            if reference.name.is_empty() || reference.name.as_bytes().contains(&b'\n') {
2776                return Err(GitError::InvalidFormat(
2777                    "bundle reference has invalid name".into(),
2778                ));
2779            }
2780            out.extend_from_slice(reference.oid.to_hex().as_bytes());
2781            out.push(b' ');
2782            out.extend_from_slice(reference.name.as_bytes());
2783            out.push(b'\n');
2784        }
2785        out.push(b'\n');
2786        out.extend_from_slice(&self.pack);
2787        Ok(out)
2788    }
2789}
2790
2791fn next_lf_line(bytes: &[u8], offset: usize) -> Option<(&[u8], usize)> {
2792    let relative = bytes
2793        .get(offset..)?
2794        .iter()
2795        .position(|byte| *byte == b'\n')?;
2796    let end = offset + relative;
2797    Some((&bytes[offset..end], end + 1))
2798}
2799
2800fn parse_bundle_capability(line: &[u8]) -> Result<BundleCapability> {
2801    let (key, value) = match line.iter().position(|byte| *byte == b'=') {
2802        Some(idx) => (&line[..idx], Some(line[idx + 1..].to_vec())),
2803        None => (line, None),
2804    };
2805    if key.is_empty()
2806        || !key
2807            .iter()
2808            .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-')
2809    {
2810        return Err(GitError::InvalidFormat(
2811            "bundle capability has invalid key".into(),
2812        ));
2813    }
2814    let key = std::str::from_utf8(key)
2815        .map_err(|err| GitError::InvalidFormat(err.to_string()))?
2816        .to_string();
2817    Ok(BundleCapability { key, value })
2818}
2819
2820fn write_bundle_capability(out: &mut Vec<u8>, capability: &BundleCapability) -> Result<()> {
2821    if capability.key.is_empty()
2822        || !capability
2823            .key
2824            .bytes()
2825            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
2826    {
2827        return Err(GitError::InvalidFormat(
2828            "bundle capability has invalid key".into(),
2829        ));
2830    }
2831    if capability.key.as_bytes().contains(&b'\n') {
2832        return Err(GitError::InvalidFormat(
2833            "bundle capability has invalid key".into(),
2834        ));
2835    }
2836    out.push(b'@');
2837    out.extend_from_slice(capability.key.as_bytes());
2838    if let Some(value) = &capability.value {
2839        if value.contains(&b'\n') {
2840            return Err(GitError::InvalidFormat(
2841                "bundle capability has invalid value".into(),
2842            ));
2843        }
2844        out.push(b'=');
2845        out.extend_from_slice(value);
2846    }
2847    out.push(b'\n');
2848    Ok(())
2849}
2850
2851fn ensure_bundle_oid_format(oid: &ObjectId, format: ObjectFormat, kind: &str) -> Result<()> {
2852    if oid.format() != format {
2853        return Err(GitError::InvalidObjectId(format!(
2854            "bundle {kind} {oid} uses {}, bundle uses {}",
2855            oid.format().name(),
2856            format.name()
2857        )));
2858    }
2859    Ok(())
2860}
2861
2862fn parse_bundle_prerequisite(line: &[u8], format: ObjectFormat) -> Result<BundlePrerequisite> {
2863    let hex_len = format.hex_len();
2864    if line.len() < hex_len + 1 || line.get(hex_len).copied() != Some(b' ') {
2865        return Err(GitError::InvalidFormat(
2866            "bundle prerequisite line is malformed".into(),
2867        ));
2868    }
2869    let hex = std::str::from_utf8(&line[..hex_len])
2870        .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
2871    Ok(BundlePrerequisite {
2872        oid: ObjectId::from_hex(format, hex)?,
2873        comment: line[hex_len + 1..].to_vec(),
2874    })
2875}
2876
2877fn parse_bundle_reference(line: &[u8], format: ObjectFormat) -> Result<BundleReference> {
2878    let hex_len = format.hex_len();
2879    if line.len() <= hex_len + 1 || line.get(hex_len).copied() != Some(b' ') {
2880        return Err(GitError::InvalidFormat(
2881            "bundle reference line is malformed".into(),
2882        ));
2883    }
2884    let hex = std::str::from_utf8(&line[..hex_len])
2885        .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
2886    let name = std::str::from_utf8(&line[hex_len + 1..])
2887        .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
2888    if name.is_empty() {
2889        return Err(GitError::InvalidFormat(
2890            "bundle reference has empty name".into(),
2891        ));
2892    }
2893    Ok(BundleReference {
2894        oid: ObjectId::from_hex(format, hex)?,
2895        name: name.to_string(),
2896    })
2897}
2898
2899/// How refs are stored in a newly initialized repository.
2900#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2901pub enum RefStorageFormat {
2902    Files,
2903    Reftable,
2904}
2905
2906impl RefStorageFormat {
2907    pub fn parse(value: &str) -> Result<Self> {
2908        match value {
2909            "" | "files" => Ok(Self::Files),
2910            "reftable" => Ok(Self::Reftable),
2911            other => Err(GitError::Command(format!(
2912                "unknown ref storage format '{other}'"
2913            ))),
2914        }
2915    }
2916
2917    pub const fn name(self) -> &'static str {
2918        match self {
2919            Self::Files => "files",
2920            Self::Reftable => "reftable",
2921        }
2922    }
2923}
2924
2925/// Fully-resolved options for [`RepositoryBootstrap::init`].
2926#[derive(Debug, Clone, PartialEq, Eq)]
2927pub struct InitOptions {
2928    pub worktree: PathBuf,
2929    /// Explicit git directory (e.g. from `GIT_DIR` during `git init`). When set,
2930    /// the repository's git directory lives here instead of `<worktree>/.git`,
2931    /// and *no* `gitdir:` link file is written into the worktree — exactly like
2932    /// `GIT_DIR=<dir> git init` (init-db.c leaves the worktree untouched).
2933    pub git_dir_override: Option<PathBuf>,
2934    /// Value to record as `core.worktree` in the new repository's config
2935    /// (init-db.c writes it when the git directory is not `<worktree>/.git`).
2936    pub core_worktree: Option<String>,
2937    /// Object directory to initialize instead of `<git_dir>/objects`, as
2938    /// supplied by `GIT_OBJECT_DIRECTORY`. The repository metadata remains in
2939    /// `git_dir`; only the object store's `info` and `pack` directories are
2940    /// created at this path.
2941    pub object_dir: Option<PathBuf>,
2942    pub object_format: ObjectFormat,
2943    /// Whether `object_format` came from an explicit `--object-format` flag (as
2944    /// opposed to an env/config default). On reinit, an explicit format that differs
2945    /// from the existing repository is a fatal error; a defaulted one is ignored.
2946    pub object_format_explicit: bool,
2947    pub bare: bool,
2948    pub initial_branch: String,
2949    pub template_dir: Option<PathBuf>,
2950    pub copy_template_config: bool,
2951    pub separate_git_dir: Option<PathBuf>,
2952    pub shared_repository: Option<String>,
2953    pub ref_storage: RefStorageFormat,
2954    /// Whether `ref_storage` came from an explicit `--ref-format` flag (as opposed to
2955    /// an env/config default). On reinit, an explicit format that differs from the
2956    /// existing repository is a fatal error; a defaulted one is ignored.
2957    pub ref_storage_explicit: bool,
2958}
2959
2960#[derive(Debug, Clone, PartialEq, Eq)]
2961pub struct RepositoryLayout {
2962    pub git_dir: PathBuf,
2963    pub worktree: PathBuf,
2964    pub object_format: ObjectFormat,
2965    pub bare: bool,
2966    pub ref_storage: RefStorageFormat,
2967    pub reinitialized: bool,
2968}
2969
2970pub struct RepositoryBootstrap;
2971
2972struct ReferenceBackendEnv {
2973    raw: String,
2974    format: RefStorageFormat,
2975    path: PathBuf,
2976}
2977
2978fn reference_backend_env() -> Result<Option<ReferenceBackendEnv>> {
2979    let Ok(raw) = env::var("GIT_REFERENCE_BACKEND") else {
2980        return Ok(None);
2981    };
2982    let Some((backend, path)) = raw.split_once("://") else {
2983        return Err(GitError::Command(format!(
2984            "invalid value for 'extensions.refstorage': '{raw}'"
2985        )));
2986    };
2987    if path.is_empty() {
2988        return Err(GitError::Command(format!(
2989            "invalid value for 'extensions.refstorage': '{raw}'"
2990        )));
2991    }
2992    let format = match backend {
2993        "files" => RefStorageFormat::Files,
2994        "reftable" => RefStorageFormat::Reftable,
2995        _ => {
2996            return Err(GitError::Command(format!(
2997                "invalid value for 'extensions.refstorage': '{raw}'"
2998            )));
2999        }
3000    };
3001    let path = PathBuf::from(path);
3002    Ok(Some(ReferenceBackendEnv { raw, format, path }))
3003}
3004
3005impl RepositoryBootstrap {
3006    pub fn init(options: InitOptions) -> Result<RepositoryLayout> {
3007        if options.bare && options.separate_git_dir.is_some() {
3008            return Err(GitError::Command(
3009                "options '--bare' and '--separate-git-dir' cannot be used together".into(),
3010            ));
3011        }
3012
3013        let worktree = options.worktree;
3014        if !options.bare {
3015            fs::create_dir_all(&worktree)?;
3016        }
3017
3018        let dot_git = worktree.join(".git");
3019        // git computes `original_git_dir = real_pathdup(git_dir)` and writes the
3020        // `gitdir:` link there. When `.git` is a symlink to the real git directory,
3021        // git therefore moves the *resolved* directory and rewrites the gitlink
3022        // through the symlink (so `.git` stays a symlink and its target becomes the
3023        // gitlink file). `git_link` is the path that receives the `gitdir:` line.
3024        let dot_git_is_symlink = fs::symlink_metadata(&dot_git)
3025            .map(|meta| meta.file_type().is_symlink())
3026            .unwrap_or(false);
3027        let git_link = if dot_git_is_symlink {
3028            fs::canonicalize(&dot_git).unwrap_or_else(|_| dot_git.clone())
3029        } else {
3030            dot_git.clone()
3031        };
3032        let mut write_git_link = false;
3033        let git_dir = if let Some(git_dir_override) = options.git_dir_override.clone() {
3034            git_dir_override
3035        } else if options.bare {
3036            worktree.clone()
3037        } else if git_link.is_file() {
3038            let existing = read_gitdir_file(&git_link)?.ok_or_else(|| {
3039                GitError::InvalidFormat(format!("invalid gitfile {}", git_link.display()))
3040            })?;
3041            if let Some(new_separate) = options.separate_git_dir.clone() {
3042                if existing != new_separate {
3043                    if existing.exists() && !new_separate.exists() {
3044                        if let Some(parent) = new_separate.parent() {
3045                            create_shared_dir(parent, options.shared_repository.as_deref())?;
3046                        }
3047                        fs::rename(&existing, &new_separate)?;
3048                        // git's `separate_git_dir()` calls
3049                        // `repair_worktrees_after_gitdir_move()` so the linked
3050                        // worktrees keep resolving to the relocated git dir.
3051                        repair_worktrees_after_gitdir_move(&existing, &new_separate)?;
3052                    }
3053                    write_git_link = true;
3054                    new_separate
3055                } else {
3056                    existing
3057                }
3058            } else {
3059                existing
3060            }
3061        } else if let Some(separate_git_dir) = options.separate_git_dir.clone() {
3062            write_git_link = true;
3063            if let Some(parent) = separate_git_dir.parent() {
3064                create_shared_dir(parent, options.shared_repository.as_deref())?;
3065            }
3066            // Move the *resolved* git directory (`git_link`), which is `.git` itself
3067            // when it is a real directory or its symlink target when `.git` is a
3068            // symlink. `is_dir()` follows symlinks, so it is true in both cases.
3069            if git_link.is_dir() && !separate_git_dir.exists() {
3070                fs::rename(&git_link, &separate_git_dir)?;
3071                // After moving the common git dir, repoint every linked
3072                // worktree's `.git`/`gitdir` link pair (init-db.c →
3073                // `separate_git_dir` → `repair_worktrees_after_gitdir_move`).
3074                repair_worktrees_after_gitdir_move(&git_link, &separate_git_dir)?;
3075            }
3076            separate_git_dir
3077        } else {
3078            worktree.join(".git")
3079        };
3080
3081        let reinitialized = git_dir.join("HEAD").exists();
3082
3083        // On reinit the existing repository's formats are authoritative. Git refuses an
3084        // explicit `--object-format`/`--ref-format` that disagrees with the existing
3085        // repository (it could corrupt the object store), but silently ignores env/config
3086        // defaults that disagree. A defaulted format never rewrites an existing repo, so
3087        // the effective formats below drive both the on-disk layout and the returned
3088        // `RepositoryLayout`.
3089        let alt_ref_storage = if reinitialized {
3090            None
3091        } else {
3092            reference_backend_env()?
3093        };
3094        let (object_format, ref_storage) = if reinitialized {
3095            let existing = read_existing_repository_formats(&git_dir)?;
3096            if options.object_format_explicit && options.object_format != existing.0 {
3097                return Err(GitError::Command(
3098                    "attempt to reinitialize repository with different hash".into(),
3099                ));
3100            }
3101            if options.ref_storage_explicit && options.ref_storage != existing.1 {
3102                return Err(GitError::Command(
3103                    "attempt to reinitialize repository with different reference storage format"
3104                        .into(),
3105                ));
3106            }
3107            existing
3108        } else {
3109            (
3110                options.object_format,
3111                alt_ref_storage
3112                    .as_ref()
3113                    .map(|backend| backend.format)
3114                    .unwrap_or(options.ref_storage),
3115            )
3116        };
3117        let ref_storage_dir = alt_ref_storage
3118            .as_ref()
3119            .map(|backend| backend.path.clone())
3120            .unwrap_or_else(|| git_dir.clone());
3121
3122        create_shared_dir(&git_dir, options.shared_repository.as_deref())?;
3123        let object_dir = options
3124            .object_dir
3125            .as_deref()
3126            .map(Path::to_path_buf)
3127            .unwrap_or_else(|| git_dir.join("objects"));
3128        create_shared_dir(
3129            object_dir.join("info"),
3130            options.shared_repository.as_deref(),
3131        )?;
3132        create_shared_dir(
3133            object_dir.join("pack"),
3134            options.shared_repository.as_deref(),
3135        )?;
3136
3137        if ref_storage == RefStorageFormat::Files {
3138            create_shared_dir(
3139                ref_storage_dir.join("refs/heads"),
3140                options.shared_repository.as_deref(),
3141            )?;
3142            create_shared_dir(
3143                ref_storage_dir.join("refs/tags"),
3144                options.shared_repository.as_deref(),
3145            )?;
3146        } else {
3147            create_shared_dir(
3148                ref_storage_dir.join("reftable"),
3149                options.shared_repository.as_deref(),
3150            )?;
3151        }
3152
3153        let head_path = git_dir.join("HEAD");
3154        if let Some(backend) = alt_ref_storage.as_ref() {
3155            fs::create_dir_all(git_dir.join("refs"))?;
3156            fs::write(
3157                git_dir.join("refs").join("heads"),
3158                b"repository uses alternate refs storage\n",
3159            )?;
3160            if !head_path.exists() {
3161                fs::write(&head_path, b"ref: refs/heads/.invalid\n")?;
3162            }
3163            if ref_storage == RefStorageFormat::Files {
3164                fs::write(
3165                    ref_storage_dir.join("HEAD"),
3166                    format!("ref: refs/heads/{}\n", options.initial_branch),
3167                )?;
3168            } else {
3169                write_initial_reftable(
3170                    &ref_storage_dir,
3171                    object_format,
3172                    &options.initial_branch,
3173                    options.shared_repository.as_deref(),
3174                )?;
3175            }
3176            let _ = backend;
3177        } else if ref_storage == RefStorageFormat::Files {
3178            if !head_path.exists() {
3179                fs::write(
3180                    &head_path,
3181                    format!("ref: refs/heads/{}\n", options.initial_branch),
3182                )?;
3183            }
3184        } else if !head_path.exists() {
3185            fs::write(&head_path, b"ref: refs/heads/.invalid\n")?;
3186            write_initial_reftable(
3187                &ref_storage_dir,
3188                object_format,
3189                &options.initial_branch,
3190                options.shared_repository.as_deref(),
3191            )?;
3192        }
3193
3194        let config_path = git_dir.join("config");
3195        if !config_path.exists() {
3196            let config_context = sley_config::ConfigIncludeContext::new(
3197                Some(sley_config::git_dir_for_include_context(&git_dir)),
3198                None,
3199            );
3200            let log_all_ref_updates_is_configured =
3201                sley_config::load_pre_dispatch_config(None, &config_context)
3202                    .ok()
3203                    .and_then(|config| {
3204                        config
3205                            .get("core", None, "logAllRefUpdates")
3206                            .map(str::to_owned)
3207                    })
3208                    .is_some();
3209            fs::write(
3210                &config_path,
3211                build_init_config(
3212                    object_format,
3213                    options.bare,
3214                    &options.shared_repository,
3215                    ref_storage,
3216                    alt_ref_storage.as_ref().map(|backend| backend.raw.as_str()),
3217                    options.core_worktree.as_deref(),
3218                    log_all_ref_updates_is_configured,
3219                )
3220                .to_canonical_bytes(),
3221            )?;
3222        }
3223
3224        if let Some(template_dir) = options.template_dir.as_deref() {
3225            apply_init_template(
3226                template_dir,
3227                &git_dir,
3228                options.copy_template_config,
3229                options.shared_repository.as_deref(),
3230            )?;
3231        }
3232
3233        // A command-line/reinit sharing mode overrides a template value and is
3234        // persisted even when the repository config already existed.
3235        if let Some(shared) = options.shared_repository.as_deref() {
3236            upsert_shared_repository_config(&config_path, shared)?;
3237        }
3238        let effective_shared = GitConfig::read(&config_path).ok().and_then(|config| {
3239            config
3240                .get("core", None, "sharedRepository")
3241                .map(str::to_owned)
3242        });
3243        apply_shared_file_mode(&head_path, effective_shared.as_deref())?;
3244        apply_shared_file_mode(&config_path, effective_shared.as_deref())?;
3245
3246        if write_git_link {
3247            let link_target = gitdir_link_path(&worktree, &git_dir)?;
3248            // Write through `git_link`: identical to `.git` in the common case, but
3249            // the symlink *target* when `.git` is a symlink (git leaves the symlink
3250            // itself intact and turns its target into the gitlink file).
3251            fs::write(&git_link, format!("gitdir: {link_target}\n"))?;
3252            apply_shared_file_mode(&git_link, options.shared_repository.as_deref())?;
3253        }
3254
3255        Ok(RepositoryLayout {
3256            git_dir,
3257            worktree,
3258            object_format,
3259            bare: options.bare,
3260            ref_storage,
3261            reinitialized,
3262        })
3263    }
3264}
3265
3266fn upsert_shared_repository_config(path: &Path, value: &str) -> Result<()> {
3267    let mut config = GitConfig::read(path)?;
3268    let canonical = shared_repository_config_value(value)
3269        .ok_or_else(|| GitError::Command(format!("unknown sharing mode '{value}'")))?;
3270    let section =
3271        config.sections.iter_mut().rev().find(|section| {
3272            section.name.eq_ignore_ascii_case("core") && section.subsection.is_none()
3273        });
3274    if let Some(section) = section {
3275        if let Some(entry) = section
3276            .entries
3277            .iter_mut()
3278            .rev()
3279            .find(|entry| entry.key.eq_ignore_ascii_case("sharedRepository"))
3280        {
3281            entry.value = Some(canonical);
3282        } else {
3283            section
3284                .entries
3285                .push(ConfigEntry::new("sharedRepository", Some(canonical)));
3286        }
3287    } else {
3288        config.sections.push(ConfigSection::new(
3289            "core",
3290            None,
3291            vec![ConfigEntry::new("sharedRepository", Some(canonical))],
3292        ));
3293    }
3294    fs::write(path, config.to_canonical_bytes())?;
3295    Ok(())
3296}
3297
3298/// Read the object and ref storage formats recorded in an existing repository's config.
3299///
3300/// `git_dir` must be the repository's (common) git directory. A missing or unreadable
3301/// config — or an absent `extensions.*` key — falls back to git's defaults (sha1 /
3302/// files), matching how git treats a freshly created v0 repository.
3303fn read_existing_repository_formats(git_dir: &Path) -> Result<(ObjectFormat, RefStorageFormat)> {
3304    let Ok(config) = GitConfig::read(git_dir.join("config")) else {
3305        return Ok((ObjectFormat::Sha1, RefStorageFormat::Files));
3306    };
3307    let object_format = config.repository_object_format()?;
3308    let ref_storage = match config.get("extensions", None, "refStorage") {
3309        Some(value) if value.eq_ignore_ascii_case("reftable") => RefStorageFormat::Reftable,
3310        _ => RefStorageFormat::Files,
3311    };
3312    Ok((object_format, ref_storage))
3313}
3314
3315impl RepositoryLayout {
3316    pub fn init_at(
3317        path: impl AsRef<Path>,
3318        object_format: ObjectFormat,
3319        bare: bool,
3320    ) -> Result<Self> {
3321        Self::init_at_with_initial_branch(path, object_format, bare, "main")
3322    }
3323
3324    pub fn init_at_with_initial_branch(
3325        path: impl AsRef<Path>,
3326        object_format: ObjectFormat,
3327        bare: bool,
3328        initial_branch: &str,
3329    ) -> Result<Self> {
3330        RepositoryBootstrap::init(InitOptions {
3331            git_dir_override: None,
3332            core_worktree: None,
3333            object_dir: None,
3334            worktree: path.as_ref().to_path_buf(),
3335            object_format,
3336            object_format_explicit: false,
3337            bare,
3338            initial_branch: initial_branch.into(),
3339            template_dir: None,
3340            copy_template_config: false,
3341            separate_git_dir: None,
3342            shared_repository: None,
3343            ref_storage: RefStorageFormat::Files,
3344            ref_storage_explicit: false,
3345        })
3346    }
3347}
3348
3349fn build_init_config(
3350    object_format: ObjectFormat,
3351    bare: bool,
3352    shared_repository: &Option<String>,
3353    ref_storage: RefStorageFormat,
3354    ref_storage_value: Option<&str>,
3355    core_worktree: Option<&str>,
3356    log_all_ref_updates_is_configured: bool,
3357) -> GitConfig {
3358    let uses_extensions = object_format == ObjectFormat::Sha256
3359        || ref_storage == RefStorageFormat::Reftable
3360        || ref_storage_value.is_some();
3361    let mut core_entries = vec![
3362        ConfigEntry::new(
3363            "repositoryformatversion",
3364            Some(if uses_extensions { "1" } else { "0" }.into()),
3365        ),
3366        ConfigEntry::new("filemode", Some("true".into())),
3367        ConfigEntry::new("bare", Some(if bare { "true" } else { "false" }.into())),
3368    ];
3369    if !bare {
3370        // init-db.c `create_default_files`: a non-bare init records
3371        // `core.logallrefupdates = true` only when no earlier config layer
3372        // chose a value. In particular, a global `false` must remain effective
3373        // instead of being shadowed by a freshly-written local `true`.
3374        if !log_all_ref_updates_is_configured {
3375            core_entries.push(ConfigEntry::new("logallrefupdates", Some("true".into())));
3376        }
3377        if let Some(worktree) = core_worktree {
3378            core_entries.push(ConfigEntry::new("worktree", Some(worktree.into())));
3379        }
3380    }
3381    if let Some(shared) = shared_repository
3382        && let Some(value) = shared_repository_config_value(shared)
3383    {
3384        core_entries.push(ConfigEntry::new("sharedRepository", Some(value)));
3385    }
3386    let mut sections = vec![ConfigSection::new("core", None, core_entries)];
3387    let mut extension_entries = Vec::new();
3388    if object_format == ObjectFormat::Sha256 {
3389        extension_entries.push(ConfigEntry::new("objectformat", Some("sha256".into())));
3390    }
3391    if let Some(value) = ref_storage_value {
3392        extension_entries.push(ConfigEntry::new("refStorage", Some(value.into())));
3393    } else if ref_storage == RefStorageFormat::Reftable {
3394        extension_entries.push(ConfigEntry::new("refStorage", Some("reftable".into())));
3395    }
3396    if !extension_entries.is_empty() {
3397        sections.push(ConfigSection::new("extensions", None, extension_entries));
3398    }
3399    GitConfig {
3400        preamble: Vec::new(),
3401        suffix: Vec::new(),
3402        sections,
3403    }
3404}
3405
3406fn write_initial_reftable(
3407    git_dir: &Path,
3408    object_format: ObjectFormat,
3409    initial_branch: &str,
3410    shared_repository: Option<&str>,
3411) -> Result<()> {
3412    let update_index = 1;
3413    let table_name = format!("0x{update_index:012x}-0x{update_index:012x}-00000000.ref");
3414    let refs = vec![ReftableRefRecord {
3415        name: "HEAD".into(),
3416        update_index,
3417        value: ReftableRefValue::Symbolic(format!("refs/heads/{initial_branch}")),
3418    }];
3419    let bytes = Reftable::write_ref_only(object_format, update_index, update_index, &refs)?;
3420    let reftable_dir = git_dir.join("reftable");
3421    let table_path = reftable_dir.join(&table_name);
3422    fs::write(&table_path, bytes)?;
3423    apply_shared_file_mode(&table_path, shared_repository)?;
3424    let list_path = reftable_dir.join("tables.list");
3425    fs::write(&list_path, format!("{table_name}\n"))?;
3426    apply_shared_file_mode(&list_path, shared_repository)?;
3427    let refs_dir = git_dir.join("refs");
3428    fs::create_dir_all(&refs_dir)?;
3429    let refs_heads = refs_dir.join("heads");
3430    fs::write(
3431        &refs_heads,
3432        b"this repository uses the reftable format\n" as &[u8],
3433    )?;
3434    apply_shared_file_mode(&refs_heads, shared_repository)?;
3435    Ok(())
3436}
3437
3438fn apply_init_template(
3439    template_dir: &Path,
3440    git_dir: &Path,
3441    copy_config: bool,
3442    shared_repository: Option<&str>,
3443) -> Result<()> {
3444    if !template_dir.is_dir() {
3445        return Ok(());
3446    }
3447    copy_init_template_entries(template_dir, git_dir, shared_repository)?;
3448    let template_config_path = template_dir.join("config");
3449    if copy_config && template_config_path.is_file() {
3450        let mut template_config = GitConfig::read(template_config_path)?;
3451        let current_config = GitConfig::read(git_dir.join("config"))?;
3452        template_config.sections.extend(current_config.sections);
3453        fs::write(git_dir.join("config"), template_config.to_canonical_bytes())?;
3454    }
3455    Ok(())
3456}
3457
3458fn copy_init_template_entries(
3459    source: &Path,
3460    destination: &Path,
3461    shared_repository: Option<&str>,
3462) -> Result<()> {
3463    for entry in fs::read_dir(source)? {
3464        let entry = entry?;
3465        let name = entry.file_name();
3466        let source_path = entry.path();
3467        let destination_path = destination.join(&name);
3468        if source_path.is_dir() {
3469            if !destination_path.exists() {
3470                create_shared_dir(&destination_path, shared_repository)?;
3471            }
3472            copy_init_template_entries(&source_path, &destination_path, shared_repository)?;
3473        } else if name != "config" && !destination_path.exists() {
3474            if let Some(parent) = destination_path.parent() {
3475                create_shared_dir(parent, shared_repository)?;
3476            }
3477            fs::copy(source_path, &destination_path)?;
3478            apply_shared_file_mode(&destination_path, shared_repository)?;
3479        }
3480    }
3481    Ok(())
3482}
3483
3484/// Port of git's `repair_worktrees_after_gitdir_move()` (worktree.c). After the
3485/// common git directory is relocated from `old_git_dir` to `new_git_dir`
3486/// (`git init --separate-git-dir` on a repository that owns linked worktrees),
3487/// every linked worktree's `.git` gitfile and its admin `gitdir` backlink must
3488/// be rewritten so the two keep pointing at each other through the new location.
3489fn repair_worktrees_after_gitdir_move(old_git_dir: &Path, new_git_dir: &Path) -> Result<()> {
3490    let worktrees_dir = new_git_dir.join("worktrees");
3491    let entries = match fs::read_dir(&worktrees_dir) {
3492        Ok(entries) => entries,
3493        // No linked worktrees: nothing to repair (matches git skipping the loop).
3494        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
3495        Err(err) => return Err(err.into()),
3496    };
3497    for entry in entries {
3498        let entry = entry?;
3499        let admin_dir = entry.path();
3500        if !admin_dir.is_dir() {
3501            continue;
3502        }
3503        repair_worktree_after_gitdir_move(&admin_dir, old_git_dir)?;
3504    }
3505    Ok(())
3506}
3507
3508/// Port of git's `repair_worktree_after_gitdir_move()`. `admin_dir` is the
3509/// already-relocated `<new_git_dir>/worktrees/<id>` directory; `old_git_dir` is
3510/// the pre-move common git dir, used to resolve a *relative* backlink that was
3511/// written against the old location.
3512fn repair_worktree_after_gitdir_move(admin_dir: &Path, old_git_dir: &Path) -> Result<()> {
3513    let gitdir_file = admin_dir.join("gitdir");
3514    let Ok(raw) = fs::read_to_string(&gitdir_file) else {
3515        return Ok(());
3516    };
3517    let backlink = raw.trim();
3518    if backlink.is_empty() {
3519        return Ok(());
3520    }
3521    let backlink_path = PathBuf::from(backlink);
3522    let is_relative = backlink_path.is_relative();
3523    // Resolve the worktree's `.git` path. git stores the backlink either
3524    // absolute or relative to the worktree's *old* admin dir
3525    // (`<old_git_dir>/worktrees/<id>/`).
3526    let id = admin_dir.file_name().unwrap_or_default();
3527    let dotgit_abs = if is_relative {
3528        let old_admin = old_git_dir.join("worktrees").join(id);
3529        normalize_lexical(&old_admin.join(&backlink_path))
3530    } else {
3531        normalize_lexical(&backlink_path)
3532    };
3533    if !dotgit_abs.exists() {
3534        return Ok(());
3535    }
3536    write_worktree_linking_files(&dotgit_abs, &gitdir_file, is_relative)?;
3537    Ok(())
3538}
3539
3540/// Port of git's `write_worktree_linking_files()`. `dotgit` is the worktree's
3541/// `.git` gitfile (absolute); `gitdir_file` is the admin `gitdir` backlink. When
3542/// `use_relative` both links are written relative to one another, otherwise both
3543/// are absolute. The two paths used by git are the worktree root (`dotgit`
3544/// minus the trailing `/.git`) and the admin dir (`gitdir_file` minus the
3545/// trailing `/gitdir`), each `realpath`-resolved.
3546fn write_worktree_linking_files(
3547    dotgit: &Path,
3548    gitdir_file: &Path,
3549    use_relative: bool,
3550) -> Result<()> {
3551    let worktree_root = dotgit.parent().unwrap_or(Path::new("."));
3552    let worktree_root =
3553        fs::canonicalize(worktree_root).unwrap_or_else(|_| worktree_root.to_path_buf());
3554    let admin_dir = gitdir_file.parent().unwrap_or(Path::new("."));
3555    let admin_dir = fs::canonicalize(admin_dir).unwrap_or_else(|_| admin_dir.to_path_buf());
3556    let worktree_dotgit = worktree_root.join(".git");
3557    if use_relative {
3558        // gitdir backlink: path to the worktree's `.git`, relative to the admin dir.
3559        let backlink = relative_path_lexical(&worktree_dotgit, &admin_dir);
3560        fs::write(gitdir_file, format!("{backlink}\n"))?;
3561        // worktree `.git`: path to the admin dir, relative to the worktree root.
3562        let link = relative_path_lexical(&admin_dir, &worktree_root);
3563        fs::write(&worktree_dotgit, format!("gitdir: {link}\n"))?;
3564    } else {
3565        fs::write(gitdir_file, format!("{}\n", worktree_dotgit.display()))?;
3566        fs::write(
3567            &worktree_dotgit,
3568            format!("gitdir: {}\n", admin_dir.display()),
3569        )?;
3570    }
3571    Ok(())
3572}
3573
3574/// Lexically normalize a path (collapse `.`/`..`, no filesystem access), so a
3575/// path built from a relative backlink against a non-existent (already-moved)
3576/// old admin dir still resolves. Mirrors the cleanup git's
3577/// `strbuf_realpath_forgiving` performs on the components that do exist.
3578fn normalize_lexical(path: &Path) -> PathBuf {
3579    use std::path::Component;
3580    let mut out = PathBuf::new();
3581    for component in path.components() {
3582        match component {
3583            Component::ParentDir => {
3584                if !out.pop() {
3585                    out.push("..");
3586                }
3587            }
3588            Component::CurDir => {}
3589            other => out.push(other.as_os_str()),
3590        }
3591    }
3592    out
3593}
3594
3595/// Compute `target` expressed relative to `base`, both absolute. Mirrors git's
3596/// `relative_path()` for the common ancestor case (the only one that arises for
3597/// sibling worktree/admin directories under a shared parent).
3598fn relative_path_lexical(target: &Path, base: &Path) -> String {
3599    let target = normalize_lexical(target);
3600    let base = normalize_lexical(base);
3601    let target_components: Vec<_> = target.components().collect();
3602    let base_components: Vec<_> = base.components().collect();
3603    let common = target_components
3604        .iter()
3605        .zip(base_components.iter())
3606        .take_while(|(a, b)| a == b)
3607        .count();
3608    let mut result = PathBuf::new();
3609    for _ in common..base_components.len() {
3610        result.push("..");
3611    }
3612    for component in &target_components[common..] {
3613        result.push(component.as_os_str());
3614    }
3615    if result.as_os_str().is_empty() {
3616        ".".to_string()
3617    } else {
3618        result.display().to_string()
3619    }
3620}
3621
3622fn read_gitdir_file(path: &Path) -> Result<Option<PathBuf>> {
3623    let contents = fs::read_to_string(path)?;
3624    let Some(target) = contents.trim().strip_prefix("gitdir:") else {
3625        return Ok(None);
3626    };
3627    let target = PathBuf::from(target.trim());
3628    if target.is_absolute() {
3629        Ok(Some(target))
3630    } else {
3631        Ok(Some(
3632            path.parent().unwrap_or_else(|| Path::new("")).join(target),
3633        ))
3634    }
3635}
3636
3637/// Compute the value git writes into the worktree's `.git` link file.
3638///
3639/// git's `separate_git_dir()` writes `gitdir: <real_git_dir>`, where `real_git_dir`
3640/// was made absolute via `real_pathdup()` (resolving symlinks) up front in
3641/// `init-db.c`. So the link always holds an *absolute, symlink-resolved* path — never
3642/// a relative one. We mirror that by canonicalizing the now-created git directory.
3643fn gitdir_link_path(_worktree: &Path, git_dir: &Path) -> Result<String> {
3644    let resolved = fs::canonicalize(git_dir).unwrap_or_else(|_| git_dir.to_path_buf());
3645    Ok(resolved.display().to_string())
3646}
3647
3648fn create_shared_dir(path: impl AsRef<Path>, shared_repository: Option<&str>) -> Result<()> {
3649    let path = path.as_ref();
3650    let mut created = Vec::new();
3651    let mut ancestor = path;
3652    while !ancestor.exists() {
3653        created.push(ancestor.to_path_buf());
3654        let Some(parent) = ancestor.parent() else {
3655            break;
3656        };
3657        ancestor = parent;
3658    }
3659    fs::create_dir_all(path)?;
3660    if created.is_empty() {
3661        apply_shared_dir_mode(path, shared_repository)?;
3662    } else {
3663        for directory in created.iter().rev() {
3664            apply_shared_dir_mode(directory, shared_repository)?;
3665        }
3666    }
3667    Ok(())
3668}
3669
3670fn apply_shared_dir_mode(path: &Path, shared_repository: Option<&str>) -> Result<()> {
3671    #[cfg(unix)]
3672    {
3673        use std::os::unix::fs::PermissionsExt;
3674        let Some(perm) = shared_repository.and_then(parse_shared_repository_perm) else {
3675            return Ok(());
3676        };
3677        let metadata = fs::metadata(path)?;
3678        if metadata.is_dir() {
3679            let old_mode = metadata.permissions().mode();
3680            let new_mode = calc_shared_perm(perm, old_mode, true);
3681            if new_mode & 0o7777 != old_mode & 0o7777 {
3682                let mut permissions = metadata.permissions();
3683                permissions.set_mode(new_mode & 0o7777);
3684                fs::set_permissions(path, permissions)?;
3685            }
3686        }
3687    }
3688    #[cfg(not(unix))]
3689    {
3690        let _ = (path, shared_repository);
3691    }
3692    Ok(())
3693}
3694
3695fn apply_shared_file_mode(path: &Path, shared_repository: Option<&str>) -> Result<()> {
3696    #[cfg(unix)]
3697    {
3698        use std::os::unix::fs::PermissionsExt;
3699        let Some(perm) = shared_repository.and_then(parse_shared_repository_perm) else {
3700            return Ok(());
3701        };
3702        let metadata = fs::metadata(path)?;
3703        if metadata.is_file() {
3704            let old_mode = metadata.permissions().mode();
3705            let new_mode = calc_shared_perm(perm, old_mode, false);
3706            if new_mode & 0o7777 != old_mode & 0o7777 {
3707                let mut permissions = metadata.permissions();
3708                permissions.set_mode(new_mode & 0o7777);
3709                fs::set_permissions(path, permissions)?;
3710            }
3711        }
3712    }
3713    #[cfg(not(unix))]
3714    {
3715        let _ = (path, shared_repository);
3716    }
3717    Ok(())
3718}
3719
3720/// Adjust an existing file using Git's `core.sharedRepository` permission
3721/// rules. Engine crates call this after atomically publishing repository files.
3722pub fn adjust_shared_repository_file(path: &Path, shared_repository: Option<&str>) -> Result<()> {
3723    apply_shared_file_mode(path, shared_repository)
3724}
3725
3726/// Repository permission policy captured once and reused by storage writers.
3727///
3728/// Keeping this typed policy on an open object/reference store avoids rereading
3729/// config for every loose object, ref, or reflog while ensuring every newly
3730/// published path receives Git's `core.sharedRepository` adjustment.
3731#[derive(Debug, Clone, Default)]
3732pub struct SharedRepositoryPermissions {
3733    git_dir: Option<PathBuf>,
3734    value: std::sync::Arc<std::sync::OnceLock<Option<String>>>,
3735}
3736
3737impl SharedRepositoryPermissions {
3738    /// Capture a repository path; the sharing mode is loaded lazily on the
3739    /// first write and then reused by every clone of the policy.
3740    #[must_use]
3741    pub fn from_git_dir(git_dir: &Path) -> Self {
3742        Self {
3743            git_dir: Some(git_dir.to_path_buf()),
3744            value: std::sync::Arc::default(),
3745        }
3746    }
3747
3748    /// Apply the policy to a file after it is atomically published.
3749    pub fn adjust_file(&self, path: &Path) -> Result<()> {
3750        apply_shared_file_mode(path, self.value())
3751    }
3752
3753    /// Create a directory hierarchy and apply the policy to every directory
3754    /// created by this call, including intermediate components.
3755    pub fn create_dir_all(&self, path: &Path) -> Result<()> {
3756        create_shared_dir(path, self.value())
3757    }
3758
3759    /// Apply the policy to an existing directory.
3760    pub fn adjust_dir(&self, path: &Path) -> Result<()> {
3761        apply_shared_dir_mode(path, self.value())
3762    }
3763
3764    fn value(&self) -> Option<&str> {
3765        self.value
3766            .get_or_init(|| {
3767                self.git_dir.as_ref().and_then(|git_dir| {
3768                    GitConfig::read(git_dir.join("config"))
3769                        .ok()
3770                        .and_then(|config| {
3771                            config
3772                                .get("core", None, "sharedRepository")
3773                                .map(str::to_string)
3774                        })
3775                })
3776            })
3777            .as_deref()
3778    }
3779}
3780
3781/// Validate and canonicalize a `core.sharedRepository` value as `git init`
3782/// does. `Ok(None)` denotes the normal umask-controlled mode.
3783pub fn canonical_shared_repository_value(value: &str) -> Result<Option<String>> {
3784    if matches!(value, "false" | "no" | "off" | "0" | "umask") {
3785        return Ok(None);
3786    }
3787    if let Some(value) = shared_repository_config_value(value) {
3788        if value.starts_with('0') {
3789            let mode = u32::from_str_radix(&value, 8)
3790                .map_err(|_| GitError::Command(format!("unknown sharing mode '{value}'")))?;
3791            if mode & 0o600 != 0o600 {
3792                return Err(GitError::Command(
3793                    "permission denied while setting up shared repository".into(),
3794                ));
3795            }
3796        }
3797        return Ok(Some(value));
3798    }
3799    Err(GitError::Command(format!("unknown sharing mode '{value}'")))
3800}
3801
3802/// Canonical `core.sharedRepository` value written at init, matching git's
3803/// numeric compatibility encoding (`group` → `1`, `all` → `2`, octal filemodes
3804/// verbatim).
3805fn shared_repository_config_value(shared_repository: &str) -> Option<String> {
3806    match shared_repository {
3807        "false" | "0" | "umask" => None,
3808        "group" | "1" | "true" => Some("1".into()),
3809        "all" | "world" | "everybody" | "2" | "3" => Some("2".into()),
3810        value if value.starts_with('0') && value.len() > 1 => Some(value.to_string()),
3811        _ => None,
3812    }
3813}
3814
3815/// A parsed `core.sharedRepository` permission request, mirroring git's
3816/// `git_config_perm` (setup.c): keywords map to the group/everybody OR-in
3817/// tweaks, while an octal filemode (other than the 0/1/2 compatibility values)
3818/// *replaces* the permission bits outright.
3819#[cfg(unix)]
3820#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3821enum SharedPerm {
3822    /// `PERM_GROUP` (0660): OR group read/write into the existing mode.
3823    Group,
3824    /// `PERM_EVERYBODY` (0664): OR group rw + world read into the existing mode.
3825    Everybody,
3826    /// A literal octal filemode (`-(mode & 0666)` in git): replace the bits.
3827    Exact(u32),
3828}
3829
3830#[cfg(unix)]
3831fn parse_shared_repository_perm(value: &str) -> Option<SharedPerm> {
3832    match value {
3833        "umask" | "false" | "no" | "off" => None,
3834        "group" | "true" | "yes" | "on" => Some(SharedPerm::Group),
3835        "all" | "world" | "everybody" => Some(SharedPerm::Everybody),
3836        _ => {
3837            let parsed = u32::from_str_radix(value, 8).ok()?;
3838            match parsed {
3839                0 => None,
3840                1 => Some(SharedPerm::Group),
3841                2 => Some(SharedPerm::Everybody),
3842                // git dies when the owner would lose read/write; we skip the
3843                // adjustment instead (init has already validated the value).
3844                mode if (mode & 0o600) == 0o600 => Some(SharedPerm::Exact(mode & 0o666)),
3845                _ => None,
3846            }
3847        }
3848    }
3849}
3850
3851/// Port of git's `calc_shared_perm` + the directory tweaks from
3852/// `adjust_shared_perm` (path.c): never grant group/other write to a file the
3853/// owner cannot write, copy read bits to execute bits for executables and
3854/// directories, and set-gid directories that grant any group access.
3855#[cfg(unix)]
3856fn calc_shared_perm(perm: SharedPerm, mode: u32, is_dir: bool) -> u32 {
3857    let (base, exact) = match perm {
3858        SharedPerm::Group => (0o660, false),
3859        SharedPerm::Everybody => (0o664, false),
3860        SharedPerm::Exact(bits) => (bits, true),
3861    };
3862    let mut tweak = base;
3863    if mode & 0o200 == 0 {
3864        tweak &= !0o222;
3865    }
3866    if mode & 0o100 != 0 {
3867        tweak |= (tweak & 0o444) >> 2;
3868    }
3869    let mut new_mode = if exact {
3870        (mode & !0o777) | tweak
3871    } else {
3872        mode | tweak
3873    };
3874    if is_dir {
3875        new_mode |= (new_mode & 0o444) >> 2;
3876        if new_mode & 0o060 != 0 {
3877            new_mode |= 0o2000;
3878        }
3879    }
3880    new_mode
3881}
3882
3883fn u32_be(bytes: &[u8]) -> u32 {
3884    u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
3885}
3886
3887fn u64_be(bytes: &[u8]) -> u64 {
3888    u64::from_be_bytes([
3889        bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
3890    ])
3891}
3892
3893fn hash_function_id(format: ObjectFormat) -> u32 {
3894    match format {
3895        ObjectFormat::Sha1 => 1,
3896        ObjectFormat::Sha256 => 2,
3897    }
3898}
3899
3900#[cfg(test)]
3901mod tests {
3902    use super::*;
3903    use std::io::Write;
3904    use std::process::Command;
3905    use std::time::{SystemTime, UNIX_EPOCH};
3906
3907    #[test]
3908    fn reftable_empty_table_round_trips() {
3909        let bytes = Reftable::write_ref_only(ObjectFormat::Sha1, 1, 1, &[])
3910            .expect("test operation should succeed");
3911        let table = Reftable::parse(&bytes).expect("test operation should succeed");
3912
3913        assert_eq!(table.header.version, ReftableVersion::V1);
3914        assert_eq!(table.header.object_format, ObjectFormat::Sha1);
3915        assert_eq!(table.refs, Vec::new());
3916    }
3917
3918    #[test]
3919    fn reftable_ref_only_table_round_trips_refs() {
3920        let head = oid("1111111111111111111111111111111111111111");
3921        let tag = oid("2222222222222222222222222222222222222222");
3922        let peeled = oid("3333333333333333333333333333333333333333");
3923        let refs = vec![
3924            ReftableRefRecord {
3925                name: "refs/tags/v1".into(),
3926                update_index: 7,
3927                value: ReftableRefValue::Peeled {
3928                    target: tag,
3929                    peeled: peeled.clone(),
3930                },
3931            },
3932            ReftableRefRecord {
3933                name: "HEAD".into(),
3934                update_index: 7,
3935                value: ReftableRefValue::Symbolic("refs/heads/main".into()),
3936            },
3937            ReftableRefRecord {
3938                name: "refs/heads/main".into(),
3939                update_index: 7,
3940                value: ReftableRefValue::Direct(head.clone()),
3941            },
3942        ];
3943
3944        let bytes = Reftable::write_ref_only(ObjectFormat::Sha1, 7, 7, &refs)
3945            .expect("test operation should succeed");
3946        let table = Reftable::parse(&bytes).expect("test operation should succeed");
3947
3948        assert_eq!(table.header.min_update_index, 7);
3949        assert_eq!(table.header.max_update_index, 7);
3950        assert_eq!(
3951            table.refs,
3952            vec![
3953                ReftableRefRecord {
3954                    name: "HEAD".into(),
3955                    update_index: 7,
3956                    value: ReftableRefValue::Symbolic("refs/heads/main".into()),
3957                },
3958                ReftableRefRecord {
3959                    name: "refs/heads/main".into(),
3960                    update_index: 7,
3961                    value: ReftableRefValue::Direct(head),
3962                },
3963                ReftableRefRecord {
3964                    name: "refs/tags/v1".into(),
3965                    update_index: 7,
3966                    value: ReftableRefValue::Peeled {
3967                        target: tag,
3968                        peeled,
3969                    },
3970                },
3971            ]
3972        );
3973    }
3974
3975    #[test]
3976    fn reftable_small_blocks_write_ref_and_object_indexes() {
3977        let commit = oid("24b24cf8a829f5b8c30dfc018b0a459a2ccaf380");
3978        let mut refs = vec![
3979            ReftableRefRecord {
3980                name: "HEAD".into(),
3981                update_index: 8,
3982                value: ReftableRefValue::Symbolic("refs/heads/master".into()),
3983            },
3984            ReftableRefRecord {
3985                name: "refs/heads/master".into(),
3986                update_index: 8,
3987                value: ReftableRefValue::Direct(commit),
3988            },
3989            ReftableRefRecord {
3990                name: "refs/tags/initial".into(),
3991                update_index: 8,
3992                value: ReftableRefValue::Direct(commit),
3993            },
3994        ];
3995        refs.extend((1..=5).map(|index| ReftableRefRecord {
3996            name: format!("refs/heads/branch-{index}"),
3997            update_index: 8,
3998            value: ReftableRefValue::Direct(commit),
3999        }));
4000        let options = ReftableWriteOptions {
4001            block_size: 100,
4002            ..ReftableWriteOptions::default()
4003        };
4004        let bytes = Reftable::write_with_options(ObjectFormat::Sha1, 1, 8, &refs, &[], options)
4005            .expect("small-block table should serialize");
4006        let footer = parse_reftable_footer(&bytes, ReftableVersion::V1)
4007            .expect("small-block footer should parse");
4008        let table = Reftable::parse(&bytes).expect("small-block table should round-trip");
4009
4010        assert_eq!(table.refs.len(), refs.len());
4011        assert_eq!(footer.ref_index_position, 400);
4012        assert_eq!(footer.obj_position, 500);
4013        assert_eq!(footer.obj_id_len, 2);
4014        assert_eq!(bytes[24], b'r');
4015        assert_eq!(bytes[100], b'r');
4016        assert_eq!(bytes[200], b'r');
4017        assert_eq!(bytes[300], b'r');
4018        assert_eq!(bytes[400], b'i');
4019        assert_eq!(bytes[500], b'o');
4020
4021        let without_objects = Reftable::write_with_options(
4022            ObjectFormat::Sha1,
4023            1,
4024            8,
4025            &refs,
4026            &[],
4027            ReftableWriteOptions {
4028                index_objects: false,
4029                ..options
4030            },
4031        )
4032        .expect("object index can be disabled");
4033        let footer = parse_reftable_footer(&without_objects, ReftableVersion::V1)
4034            .expect("footer without object index should parse");
4035        assert_eq!(footer.ref_index_position, 400);
4036        assert_eq!(footer.obj_position, 0);
4037        assert_eq!(footer.obj_id_len, 0);
4038    }
4039
4040    #[test]
4041    fn reftable_sha256_uses_version_2_hash_id() {
4042        let oid = ObjectId::from_hex(
4043            ObjectFormat::Sha256,
4044            "1111111111111111111111111111111111111111111111111111111111111111",
4045        )
4046        .expect("test operation should succeed");
4047        let refs = vec![ReftableRefRecord {
4048            name: "refs/heads/main".into(),
4049            update_index: 3,
4050            value: ReftableRefValue::Direct(oid),
4051        }];
4052
4053        let bytes = Reftable::write_ref_only(ObjectFormat::Sha256, 3, 3, &refs)
4054            .expect("test operation should succeed");
4055        let table = Reftable::parse(&bytes).expect("test operation should succeed");
4056
4057        assert_eq!(table.header.version, ReftableVersion::V2);
4058        assert_eq!(table.header.object_format, ObjectFormat::Sha256);
4059        assert_eq!(table.refs[0].value, ReftableRefValue::Direct(oid));
4060    }
4061
4062    #[test]
4063    fn upstream_git_reads_rust_written_minimal_reftable() {
4064        let root = unique_temp_dir("reftable-upstream");
4065        fs::create_dir_all(&root).expect("create temp repo");
4066        {
4067            run_success("git", &root, &["init", "-q"]);
4068            let oid = run_success_with_stdin(
4069                "git",
4070                &root,
4071                &["hash-object", "-w", "--stdin"],
4072                b"payload\n",
4073            );
4074            let oid = String::from_utf8(oid).expect("oid is utf8");
4075            let oid = ObjectId::from_hex(ObjectFormat::Sha1, oid.trim())
4076                .expect("test operation should succeed");
4077            let git_dir = root.join(".git");
4078            fs::write(
4079                git_dir.join("config"),
4080                b"[core]\n\trepositoryformatversion = 1\n[extensions]\n\trefStorage = reftable\n",
4081            )
4082            .expect("write config");
4083            fs::write(git_dir.join("HEAD"), b"ref: refs/heads/.invalid\n").expect("write HEAD");
4084            let reftable_dir = git_dir.join("reftable");
4085            fs::create_dir_all(&reftable_dir).expect("create reftable dir");
4086            let table_name = "0x000000000001-0x000000000001-00000000.ref";
4087            let table = Reftable::write_ref_only(
4088                ObjectFormat::Sha1,
4089                1,
4090                1,
4091                &[ReftableRefRecord {
4092                    name: "refs/heads/main".into(),
4093                    update_index: 1,
4094                    value: ReftableRefValue::Direct(oid),
4095                }],
4096            )
4097            .expect("test operation should succeed");
4098            fs::write(reftable_dir.join(table_name), table).expect("write reftable");
4099            fs::write(reftable_dir.join("tables.list"), format!("{table_name}\n"))
4100                .expect("write tables.list");
4101
4102            let output = run_success("git", &root, &["show-ref"]);
4103            assert_eq!(
4104                String::from_utf8(output).expect("show-ref output is utf8"),
4105                format!("{oid} refs/heads/main\n")
4106            );
4107        };
4108        let _ = fs::remove_dir_all(&root);
4109    }
4110
4111    #[test]
4112    fn reftable_log_records_round_trip() {
4113        let old = oid("0000000000000000000000000000000000000000");
4114        let new1 = oid("1111111111111111111111111111111111111111");
4115        let new2 = oid("2222222222222222222222222222222222222222");
4116        let refs = vec![ReftableRefRecord {
4117            name: "refs/heads/main".into(),
4118            update_index: 2,
4119            value: ReftableRefValue::Direct(new2.clone()),
4120        }];
4121        let logs = vec![
4122            ReftableLogRecord {
4123                refname: "refs/heads/main".into(),
4124                update_index: 1,
4125                value: ReftableLogValue::Update(ReftableLogUpdate {
4126                    old_oid: old.clone(),
4127                    new_oid: new1.clone(),
4128                    name: "Committer One".into(),
4129                    email: "one@example.com".into(),
4130                    time: 1_700_000_000,
4131                    tz_offset: 120,
4132                    message: "commit (initial): first".into(),
4133                }),
4134            },
4135            ReftableLogRecord {
4136                refname: "refs/heads/main".into(),
4137                update_index: 2,
4138                value: ReftableLogValue::Update(ReftableLogUpdate {
4139                    old_oid: new1.clone(),
4140                    new_oid: new2.clone(),
4141                    name: "Committer Two".into(),
4142                    email: "two@example.com".into(),
4143                    time: 1_700_000_100,
4144                    tz_offset: -300,
4145                    message: "commit: second".into(),
4146                }),
4147            },
4148        ];
4149
4150        let bytes = Reftable::write(ObjectFormat::Sha1, 1, 2, &refs, &logs)
4151            .expect("test operation should succeed");
4152        let table = Reftable::parse(&bytes).expect("test operation should succeed");
4153
4154        assert_eq!(table.refs.len(), 1);
4155        // Newest update index sorts first within a refname.
4156        assert_eq!(table.logs.len(), 2);
4157        assert_eq!(table.logs[0].update_index, 2);
4158        assert_eq!(table.logs[1].update_index, 1);
4159        let ReftableLogValue::Update(update0) = &table.logs[0].value else {
4160            panic!("expected update");
4161        };
4162        assert_eq!(update0.tz_offset, -300);
4163        assert_eq!(update0.old_oid, new1);
4164        assert_eq!(update0.new_oid, new2);
4165        assert_eq!(update0.message, "commit: second");
4166        let ReftableLogValue::Update(update1) = &table.logs[1].value else {
4167            panic!("expected update");
4168        };
4169        assert_eq!(update1.tz_offset, 120);
4170        assert_eq!(update1.old_oid, old);
4171        assert_eq!(update1.message, "commit (initial): first");
4172    }
4173
4174    /// Anti-stub conformance: upstream git must read the reflog out of a reftable
4175    /// our writer produced, log blocks and all.
4176    #[test]
4177    fn upstream_git_reads_rust_written_reftable_log() {
4178        let root = unique_temp_dir("reftable-log-upstream");
4179        fs::create_dir_all(&root).expect("create temp repo");
4180        {
4181            run_success("git", &root, &["init", "-q"]);
4182            // Build a real commit so the ref points at a commit (git's reflog show
4183            // requires it). Empty tree -> commit, both written into the loose ODB.
4184            let empty_tree = run_success_with_stdin("git", &root, &["mktree"], b"");
4185            let empty_tree = String::from_utf8(empty_tree).expect("tree oid is utf8");
4186            let commit_oid = run_success(
4187                "git",
4188                &root,
4189                &[
4190                    "-c",
4191                    "user.name=Reftable Writer",
4192                    "-c",
4193                    "user.email=writer@example.com",
4194                    "commit-tree",
4195                    empty_tree.trim(),
4196                    "-m",
4197                    "seed",
4198                ],
4199            );
4200            let commit_str = String::from_utf8(commit_oid).expect("commit oid is utf8");
4201            let new_oid = ObjectId::from_hex(ObjectFormat::Sha1, commit_str.trim())
4202                .expect("test operation should succeed");
4203            let zero = oid("0000000000000000000000000000000000000000");
4204            let git_dir = root.join(".git");
4205            fs::write(
4206                git_dir.join("config"),
4207                b"[core]\n\trepositoryformatversion = 1\n[extensions]\n\trefStorage = reftable\n",
4208            )
4209            .expect("write config");
4210            fs::write(git_dir.join("HEAD"), b"ref: refs/heads/.invalid\n").expect("write HEAD");
4211            let reftable_dir = git_dir.join("reftable");
4212            fs::create_dir_all(&reftable_dir).expect("create reftable dir");
4213            // git's `table_has_valid_name` parses all three dash-separated fields
4214            // as hex; the trailing token must be pure hex, ending in `.ref`.
4215            let table_name = "0x000000000001-0x000000000001-00000001.ref";
4216            let table = Reftable::write(
4217                ObjectFormat::Sha1,
4218                1,
4219                1,
4220                &[ReftableRefRecord {
4221                    name: "refs/heads/main".into(),
4222                    update_index: 1,
4223                    value: ReftableRefValue::Direct(new_oid.clone()),
4224                }],
4225                &[ReftableLogRecord {
4226                    refname: "refs/heads/main".into(),
4227                    update_index: 1,
4228                    value: ReftableLogValue::Update(ReftableLogUpdate {
4229                        old_oid: zero,
4230                        new_oid: new_oid.clone(),
4231                        name: "Reftable Writer".into(),
4232                        email: "writer@example.com".into(),
4233                        time: 1_700_000_000,
4234                        tz_offset: 0,
4235                        // git stores reflog messages with a trailing newline.
4236                        message: "commit (initial): seed\n".into(),
4237                    }),
4238                }],
4239            )
4240            .expect("test operation should succeed");
4241            fs::write(reftable_dir.join(table_name), table).expect("write reftable");
4242            fs::write(reftable_dir.join("tables.list"), format!("{table_name}\n"))
4243                .expect("write tables.list");
4244
4245            // git parses the log block and surfaces the reflog message.
4246            let output = run_success(
4247                "git",
4248                &root,
4249                &["reflog", "show", "--format=%H %gs", "refs/heads/main"],
4250            );
4251            let text = String::from_utf8(output).expect("reflog output is utf8");
4252            assert!(
4253                text.contains("commit (initial): seed"),
4254                "git reflog did not surface our log message: {text:?}"
4255            );
4256            assert!(
4257                text.contains(&new_oid.to_string()),
4258                "git reflog did not surface our new oid: {text:?}"
4259            );
4260        };
4261        if std::env::var_os("SLEY_KEEP").is_none() {
4262            let _ = fs::remove_dir_all(&root);
4263        }
4264    }
4265
4266    #[test]
4267    fn parses_commit_graph_core_chunks() {
4268        let tree = oid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
4269        let commits = vec![
4270            (
4271                oid("1111111111111111111111111111111111111111"),
4272                tree,
4273                Vec::new(),
4274                1,
4275                1,
4276            ),
4277            (
4278                oid("2222222222222222222222222222222222222222"),
4279                tree,
4280                vec![0],
4281                2,
4282                2,
4283            ),
4284            (
4285                oid("3333333333333333333333333333333333333333"),
4286                tree,
4287                vec![1],
4288                3,
4289                3,
4290            ),
4291            (
4292                oid("4444444444444444444444444444444444444444"),
4293                tree,
4294                vec![0, 1, 2],
4295                4,
4296                0x1_0000_0001,
4297            ),
4298        ];
4299        let bytes = commit_graph(ObjectFormat::Sha1, 0, &commit_graph_chunks(&commits));
4300
4301        let parsed =
4302            CommitGraph::parse(&bytes, ObjectFormat::Sha1).expect("test operation should succeed");
4303        assert_eq!(parsed.version, 1);
4304        assert_eq!(parsed.format, ObjectFormat::Sha1);
4305        assert_eq!(parsed.base_graph_count, 0);
4306        assert_eq!(parsed.commits.len(), 4);
4307        let merge = parsed
4308            .find(&commits[3].0)
4309            .expect("test operation should succeed");
4310        assert_eq!(merge.parents, vec![0, 1, 2]);
4311        assert_eq!(merge.generation, 4);
4312        assert_eq!(merge.commit_time, 0x1_0000_0001);
4313        assert_eq!(merge.corrected_commit_date_offset, None);
4314        assert!(parsed.base_graphs.is_empty());
4315        assert_eq!(parsed.bloom_filters, None);
4316        assert_eq!(parsed.chunks[0].id, *b"OIDF");
4317    }
4318
4319    #[test]
4320    fn writes_commit_graph_core_chunks_that_round_trip() {
4321        let base = oid("1111111111111111111111111111111111111111");
4322        let main = oid("2222222222222222222222222222222222222222");
4323        let side = oid("3333333333333333333333333333333333333333");
4324        let merge = oid("4444444444444444444444444444444444444444");
4325        let tree = oid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
4326        let bytes = CommitGraph::write(
4327            ObjectFormat::Sha1,
4328            &[
4329                CommitGraphWriteEntry {
4330                    oid: merge.clone(),
4331                    tree,
4332                    parents: vec![main.clone(), side.clone()],
4333                    generation: 3,
4334                    commit_time: 30,
4335                    bloom_filter: None,
4336                },
4337                CommitGraphWriteEntry {
4338                    oid: base,
4339                    tree,
4340                    parents: Vec::new(),
4341                    generation: 1,
4342                    commit_time: 10,
4343                    bloom_filter: None,
4344                },
4345                CommitGraphWriteEntry {
4346                    oid: main.clone(),
4347                    tree,
4348                    parents: vec![base],
4349                    generation: 2,
4350                    commit_time: 20,
4351                    bloom_filter: None,
4352                },
4353                CommitGraphWriteEntry {
4354                    oid: side.clone(),
4355                    tree,
4356                    parents: vec![base],
4357                    generation: 2,
4358                    commit_time: 21,
4359                    bloom_filter: None,
4360                },
4361            ],
4362        )
4363        .expect("test operation should succeed");
4364
4365        let parsed =
4366            CommitGraph::parse(&bytes, ObjectFormat::Sha1).expect("test operation should succeed");
4367        assert_eq!(parsed.commits.len(), 4);
4368        assert_eq!(
4369            parsed
4370                .find(&base)
4371                .expect("test operation should succeed")
4372                .parents,
4373            Vec::<u32>::new()
4374        );
4375        assert_eq!(
4376            parsed
4377                .find(&main)
4378                .expect("test operation should succeed")
4379                .parents,
4380            vec![0]
4381        );
4382        assert_eq!(
4383            parsed
4384                .find(&side)
4385                .expect("test operation should succeed")
4386                .parents,
4387            vec![0]
4388        );
4389        assert_eq!(
4390            parsed
4391                .find(&merge)
4392                .expect("test operation should succeed")
4393                .parents,
4394            vec![1, 2]
4395        );
4396        assert_eq!(
4397            parsed
4398                .find(&merge)
4399                .expect("test operation should succeed")
4400                .generation,
4401            3
4402        );
4403        assert_eq!(
4404            parsed
4405                .find(&merge)
4406                .expect("test operation should succeed")
4407                .commit_time,
4408            30
4409        );
4410    }
4411
4412    #[test]
4413    fn parses_commit_graph_bloom_filters() {
4414        let tree = oid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
4415        let commits = vec![
4416            (
4417                oid("1111111111111111111111111111111111111111"),
4418                tree,
4419                Vec::new(),
4420                1,
4421                1,
4422            ),
4423            (
4424                oid("2222222222222222222222222222222222222222"),
4425                tree,
4426                vec![0],
4427                2,
4428                2,
4429            ),
4430        ];
4431        let mut chunks = commit_graph_chunks(&commits);
4432        chunks.push((*b"BIDX", commit_graph_bidx(&[2, 3])));
4433        chunks.push((*b"BDAT", commit_graph_bdat(2, 7, 10, &[0xaa, 0xbb, 0xcc])));
4434        let bytes = commit_graph(ObjectFormat::Sha1, 0, &chunks);
4435
4436        let parsed =
4437            CommitGraph::parse(&bytes, ObjectFormat::Sha1).expect("test operation should succeed");
4438        assert_eq!(
4439            parsed.bloom_filters,
4440            Some(CommitGraphBloomFilters {
4441                hash_version: 2,
4442                hash_count: 7,
4443                bits_per_entry: 10,
4444                filters: vec![vec![0xaa, 0xbb], vec![0xcc]],
4445            })
4446        );
4447    }
4448
4449    #[test]
4450    fn writes_commit_graph_bloom_filters_with_requested_hash_version() {
4451        let tree = oid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
4452        let mut settings = DEFAULT_COMMIT_GRAPH_BLOOM_SETTINGS;
4453        settings.hash_version = 2;
4454        let bytes = CommitGraph::write_with_bloom_settings(
4455            ObjectFormat::Sha1,
4456            &[CommitGraphWriteEntry {
4457                oid: oid("1111111111111111111111111111111111111111"),
4458                tree,
4459                parents: Vec::new(),
4460                generation: 1,
4461                commit_time: 1,
4462                bloom_filter: Some(vec![0xaa, 0xbb]),
4463            }],
4464            settings,
4465        )
4466        .expect("test operation should succeed");
4467
4468        let parsed =
4469            CommitGraph::parse(&bytes, ObjectFormat::Sha1).expect("test operation should succeed");
4470        let bloom = parsed
4471            .bloom_filters
4472            .expect("test operation should parse bloom filters");
4473        assert_eq!(bloom.hash_version, 2);
4474        assert_eq!(bloom.hash_count, settings.hash_count);
4475        assert_eq!(bloom.bits_per_entry, settings.bits_per_entry);
4476        assert_eq!(bloom.filters, vec![vec![0xaa, 0xbb]]);
4477    }
4478
4479    #[test]
4480    fn rejects_unsupported_commit_graph_bloom_hash_version_on_write() {
4481        let tree = oid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
4482        let mut settings = DEFAULT_COMMIT_GRAPH_BLOOM_SETTINGS;
4483        settings.hash_version = 3;
4484
4485        assert!(
4486            CommitGraph::write_with_bloom_settings(
4487                ObjectFormat::Sha1,
4488                &[CommitGraphWriteEntry {
4489                    oid: oid("1111111111111111111111111111111111111111"),
4490                    tree,
4491                    parents: Vec::new(),
4492                    generation: 1,
4493                    commit_time: 1,
4494                    bloom_filter: Some(vec![0xaa]),
4495                }],
4496                settings,
4497            )
4498            .is_err()
4499        );
4500    }
4501
4502    #[test]
4503    fn parses_commit_graph_generation_data() {
4504        let tree = oid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
4505        let commits = vec![
4506            (
4507                oid("1111111111111111111111111111111111111111"),
4508                tree,
4509                Vec::new(),
4510                1,
4511                1,
4512            ),
4513            (
4514                oid("2222222222222222222222222222222222222222"),
4515                tree,
4516                vec![0],
4517                2,
4518                2,
4519            ),
4520        ];
4521        let mut chunks = commit_graph_chunks(&commits);
4522        chunks.push((*b"GDA2", commit_graph_gda2(&[7, 0x8000_0000])));
4523        chunks.push((*b"GDO2", commit_graph_gdo2(&[0x1_0000_0007])));
4524        let bytes = commit_graph(ObjectFormat::Sha1, 0, &chunks);
4525
4526        let parsed =
4527            CommitGraph::parse(&bytes, ObjectFormat::Sha1).expect("test operation should succeed");
4528        assert_eq!(parsed.commits[0].corrected_commit_date_offset, Some(7));
4529        assert_eq!(
4530            parsed.commits[1].corrected_commit_date_offset,
4531            Some(0x1_0000_0007)
4532        );
4533    }
4534
4535    #[test]
4536    fn parses_commit_graph_base_graph_hashes() {
4537        let commit = (
4538            oid("1111111111111111111111111111111111111111"),
4539            oid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
4540            Vec::new(),
4541            1,
4542            1,
4543        );
4544        let base_graph = oid("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
4545        let mut chunks = commit_graph_chunks(&[commit]);
4546        chunks.push((*b"BASE", base_graph.as_bytes().to_vec()));
4547        let bytes = commit_graph(ObjectFormat::Sha1, 1, &chunks);
4548
4549        let parsed =
4550            CommitGraph::parse(&bytes, ObjectFormat::Sha1).expect("test operation should succeed");
4551        assert_eq!(parsed.base_graph_count, 1);
4552        assert_eq!(parsed.base_graphs, vec![base_graph]);
4553    }
4554
4555    #[test]
4556    fn rejects_bad_commit_graph_shape() {
4557        let commit = (
4558            oid("1111111111111111111111111111111111111111"),
4559            oid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
4560            Vec::new(),
4561            1,
4562            1,
4563        );
4564        let chunks = commit_graph_chunks(std::slice::from_ref(&commit));
4565        let mut bad_checksum = commit_graph(ObjectFormat::Sha1, 0, &chunks);
4566        let last = bad_checksum.len() - 1;
4567        bad_checksum[last] ^= 1;
4568        assert!(CommitGraph::parse(&bad_checksum, ObjectFormat::Sha1).is_err());
4569
4570        let missing_cdat = commit_graph(
4571            ObjectFormat::Sha1,
4572            0,
4573            &chunks
4574                .iter()
4575                .filter(|(id, _data)| id != b"CDAT")
4576                .cloned()
4577                .collect::<Vec<_>>(),
4578        );
4579        assert!(CommitGraph::parse(&missing_cdat, ObjectFormat::Sha1).is_err());
4580
4581        let bad_fanout = commit_graph(
4582            ObjectFormat::Sha1,
4583            0,
4584            &[
4585                (*b"OIDF", vec![0; 256 * 4]),
4586                (*b"OIDL", commit.0.as_bytes().to_vec()),
4587                (*b"CDAT", commit_graph_cdat(std::slice::from_ref(&commit)).0),
4588            ],
4589        );
4590        assert!(CommitGraph::parse(&bad_fanout, ObjectFormat::Sha1).is_err());
4591
4592        let octopus = (
4593            oid("2222222222222222222222222222222222222222"),
4594            oid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
4595            vec![0, 0, 0],
4596            2,
4597            2,
4598        );
4599        let missing_edge = commit_graph(
4600            ObjectFormat::Sha1,
4601            0,
4602            &commit_graph_chunks(&[commit.clone(), octopus])
4603                .into_iter()
4604                .filter(|(id, _data)| id != b"EDGE")
4605                .collect::<Vec<_>>(),
4606        );
4607        assert!(CommitGraph::parse(&missing_edge, ObjectFormat::Sha1).is_err());
4608
4609        let mut bad_base = commit_graph_chunks(&[commit]);
4610        bad_base.push((*b"BASE", vec![0]));
4611        let bad_base = commit_graph(ObjectFormat::Sha1, 1, &bad_base);
4612        assert!(CommitGraph::parse(&bad_base, ObjectFormat::Sha1).is_err());
4613    }
4614
4615    #[test]
4616    fn rejects_bad_commit_graph_generation_data() {
4617        let commit = (
4618            oid("1111111111111111111111111111111111111111"),
4619            oid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
4620            Vec::new(),
4621            1,
4622            1,
4623        );
4624
4625        let mut short_gda2 = commit_graph_chunks(std::slice::from_ref(&commit));
4626        short_gda2.push((*b"GDA2", vec![0]));
4627        let short_gda2 = commit_graph(ObjectFormat::Sha1, 0, &short_gda2);
4628        assert!(CommitGraph::parse(&short_gda2, ObjectFormat::Sha1).is_err());
4629
4630        let mut missing_gdo2 = commit_graph_chunks(std::slice::from_ref(&commit));
4631        missing_gdo2.push((*b"GDA2", commit_graph_gda2(&[0x8000_0000])));
4632        let missing_gdo2 = commit_graph(ObjectFormat::Sha1, 0, &missing_gdo2);
4633        assert!(CommitGraph::parse(&missing_gdo2, ObjectFormat::Sha1).is_err());
4634
4635        let mut bad_gdo2 = commit_graph_chunks(std::slice::from_ref(&commit));
4636        bad_gdo2.push((*b"GDA2", commit_graph_gda2(&[0x8000_0000])));
4637        bad_gdo2.push((*b"GDO2", vec![0]));
4638        let bad_gdo2 = commit_graph(ObjectFormat::Sha1, 0, &bad_gdo2);
4639        assert!(CommitGraph::parse(&bad_gdo2, ObjectFormat::Sha1).is_err());
4640
4641        let mut unused_gdo2 = commit_graph_chunks(std::slice::from_ref(&commit));
4642        unused_gdo2.push((*b"GDA2", commit_graph_gda2(&[1])));
4643        unused_gdo2.push((*b"GDO2", commit_graph_gdo2(&[2])));
4644        let unused_gdo2 = commit_graph(ObjectFormat::Sha1, 0, &unused_gdo2);
4645        assert!(CommitGraph::parse(&unused_gdo2, ObjectFormat::Sha1).is_err());
4646
4647        let mut orphan_gdo2 = commit_graph_chunks(&[commit]);
4648        orphan_gdo2.push((*b"GDO2", commit_graph_gdo2(&[2])));
4649        let orphan_gdo2 = commit_graph(ObjectFormat::Sha1, 0, &orphan_gdo2);
4650        assert!(CommitGraph::parse(&orphan_gdo2, ObjectFormat::Sha1).is_err());
4651    }
4652
4653    #[test]
4654    fn rejects_bad_commit_graph_bloom_filters() {
4655        let commit = (
4656            oid("1111111111111111111111111111111111111111"),
4657            oid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
4658            Vec::new(),
4659            1,
4660            1,
4661        );
4662
4663        let mut bidx_without_bdat = commit_graph_chunks(std::slice::from_ref(&commit));
4664        bidx_without_bdat.push((*b"BIDX", commit_graph_bidx(&[1])));
4665        let bidx_without_bdat = commit_graph(ObjectFormat::Sha1, 0, &bidx_without_bdat);
4666        assert_eq!(
4667            CommitGraph::parse(&bidx_without_bdat, ObjectFormat::Sha1)
4668                .expect("test operation should succeed")
4669                .bloom_filters,
4670            None
4671        );
4672
4673        let mut bdat_without_bidx = commit_graph_chunks(std::slice::from_ref(&commit));
4674        bdat_without_bidx.push((*b"BDAT", commit_graph_bdat(2, 7, 10, &[0xaa])));
4675        let bdat_without_bidx = commit_graph(ObjectFormat::Sha1, 0, &bdat_without_bidx);
4676        assert!(CommitGraph::parse(&bdat_without_bidx, ObjectFormat::Sha1).is_err());
4677
4678        let mut short_bidx = commit_graph_chunks(std::slice::from_ref(&commit));
4679        short_bidx.push((*b"BIDX", vec![0]));
4680        short_bidx.push((*b"BDAT", commit_graph_bdat(2, 7, 10, &[0xaa])));
4681        let short_bidx = commit_graph(ObjectFormat::Sha1, 0, &short_bidx);
4682        assert!(CommitGraph::parse(&short_bidx, ObjectFormat::Sha1).is_err());
4683
4684        let mut short_bdat = commit_graph_chunks(std::slice::from_ref(&commit));
4685        short_bdat.push((*b"BIDX", commit_graph_bidx(&[0])));
4686        short_bdat.push((*b"BDAT", vec![0]));
4687        let short_bdat = commit_graph(ObjectFormat::Sha1, 0, &short_bdat);
4688        assert!(CommitGraph::parse(&short_bdat, ObjectFormat::Sha1).is_err());
4689
4690        let mut bidx_past_payload = commit_graph_chunks(std::slice::from_ref(&commit));
4691        bidx_past_payload.push((*b"BIDX", commit_graph_bidx(&[2])));
4692        bidx_past_payload.push((*b"BDAT", commit_graph_bdat(2, 7, 10, &[0xaa])));
4693        let bidx_past_payload = commit_graph(ObjectFormat::Sha1, 0, &bidx_past_payload);
4694        assert!(CommitGraph::parse(&bidx_past_payload, ObjectFormat::Sha1).is_err());
4695
4696        let mut trailing_payload = commit_graph_chunks(&[commit]);
4697        trailing_payload.push((*b"BIDX", commit_graph_bidx(&[1])));
4698        trailing_payload.push((*b"BDAT", commit_graph_bdat(2, 7, 10, &[0xaa, 0xbb])));
4699        let trailing_payload = commit_graph(ObjectFormat::Sha1, 0, &trailing_payload);
4700        assert!(CommitGraph::parse(&trailing_payload, ObjectFormat::Sha1).is_err());
4701    }
4702
4703    #[test]
4704    fn parses_bundle_v2_header_and_pack() {
4705        let prerequisite = oid("1111111111111111111111111111111111111111");
4706        let reference = oid("2222222222222222222222222222222222222222");
4707        let bytes = format!(
4708            "# v2 git bundle\n-{prerequisite} prerequisite comment\n{reference} refs/heads/main\n\n"
4709        )
4710        .into_bytes()
4711        .into_iter()
4712        .chain(b"PACKdata".iter().copied())
4713        .collect::<Vec<_>>();
4714
4715        let parsed =
4716            Bundle::parse(&bytes, ObjectFormat::Sha1).expect("test operation should succeed");
4717        assert_eq!(parsed.version, 2);
4718        assert_eq!(parsed.format, ObjectFormat::Sha1);
4719        assert!(parsed.capabilities.is_empty());
4720        assert_eq!(
4721            parsed.prerequisites,
4722            vec![BundlePrerequisite {
4723                oid: prerequisite,
4724                comment: b"prerequisite comment".to_vec(),
4725            }]
4726        );
4727        assert_eq!(
4728            parsed.references,
4729            vec![BundleReference {
4730                oid: reference,
4731                name: "refs/heads/main".into(),
4732            }]
4733        );
4734        assert_eq!(parsed.pack, b"PACKdata");
4735    }
4736
4737    #[test]
4738    fn parses_bundle_v3_capabilities_and_sha256_ids() {
4739        let prerequisite = ObjectId::from_hex(
4740            ObjectFormat::Sha256,
4741            "1111111111111111111111111111111111111111111111111111111111111111",
4742        )
4743        .expect("test operation should succeed");
4744        let reference = ObjectId::from_hex(
4745            ObjectFormat::Sha256,
4746            "2222222222222222222222222222222222222222222222222222222222222222",
4747        )
4748        .expect("test operation should succeed");
4749        let bytes = format!(
4750            "# v3 git bundle\n@object-format=sha256\n@filter=blob:none\n-{prerequisite} base\n{reference} refs/heads/main\n\n"
4751        )
4752        .into_bytes();
4753
4754        let parsed =
4755            Bundle::parse(&bytes, ObjectFormat::Sha1).expect("test operation should succeed");
4756        assert_eq!(parsed.version, 3);
4757        assert_eq!(parsed.format, ObjectFormat::Sha256);
4758        assert_eq!(
4759            parsed.capabilities,
4760            vec![
4761                BundleCapability {
4762                    key: "object-format".into(),
4763                    value: Some(b"sha256".to_vec()),
4764                },
4765                BundleCapability {
4766                    key: "filter".into(),
4767                    value: Some(b"blob:none".to_vec()),
4768                },
4769            ]
4770        );
4771        assert_eq!(parsed.prerequisites[0].oid, prerequisite);
4772        assert_eq!(parsed.references[0].oid, reference);
4773    }
4774
4775    #[test]
4776    fn standalone_bundle_parse_uses_sha1_default_and_header_object_format_override() {
4777        let sha1 = sley_core::object_id_for_bytes(ObjectFormat::Sha1, "blob", b"tip\n")
4778            .expect("test operation should succeed");
4779        let sha256 = sley_core::object_id_for_bytes(ObjectFormat::Sha256, "blob", b"tip\n")
4780            .expect("test operation should succeed");
4781
4782        let sha1_bytes = format!("# v2 git bundle\n{sha1} refs/heads/main\n\nPACK").into_bytes();
4783        let sha1_bundle =
4784            Bundle::parse_standalone(&sha1_bytes).expect("test operation should succeed");
4785        assert_eq!(sha1_bundle.format, ObjectFormat::Sha1);
4786        assert_eq!(sha1_bundle.references[0].oid, sha1);
4787
4788        let sha256_bytes =
4789            format!("# v3 git bundle\n@object-format=sha256\n{sha256} refs/heads/main\n\nPACK")
4790                .into_bytes();
4791        let sha256_bundle =
4792            Bundle::parse_standalone(&sha256_bytes).expect("test operation should succeed");
4793        assert_eq!(sha256_bundle.format, ObjectFormat::Sha256);
4794        assert_eq!(sha256_bundle.references[0].oid, sha256);
4795    }
4796
4797    #[test]
4798    fn writes_bundle_v2_header_and_pack() {
4799        let prerequisite = sley_core::object_id_for_bytes(ObjectFormat::Sha1, "blob", b"base\n")
4800            .expect("test operation should succeed");
4801        let reference = sley_core::object_id_for_bytes(ObjectFormat::Sha1, "blob", b"tip\n")
4802            .expect("test operation should succeed");
4803        let bundle = Bundle {
4804            version: 2,
4805            format: ObjectFormat::Sha1,
4806            capabilities: Vec::new(),
4807            prerequisites: vec![BundlePrerequisite {
4808                oid: prerequisite.clone(),
4809                comment: b"base comment".to_vec(),
4810            }],
4811            references: vec![BundleReference {
4812                oid: reference.clone(),
4813                name: "refs/heads/main".into(),
4814            }],
4815            pack: b"PACKv2".to_vec(),
4816        };
4817
4818        let bytes = bundle.write().expect("test operation should succeed");
4819        let expected = format!(
4820            "# v2 git bundle\n-{prerequisite} base comment\n{reference} refs/heads/main\n\n"
4821        )
4822        .into_bytes()
4823        .into_iter()
4824        .chain(b"PACKv2".iter().copied())
4825        .collect::<Vec<_>>();
4826        assert_eq!(bytes, expected);
4827        assert_eq!(
4828            Bundle::parse(&bytes, ObjectFormat::Sha1).expect("test operation should succeed"),
4829            bundle
4830        );
4831    }
4832
4833    #[test]
4834    fn writes_bundle_v3_sha256_object_format_capability() {
4835        let oid = sley_core::object_id_for_bytes(ObjectFormat::Sha256, "blob", b"tip\n")
4836            .expect("test operation should succeed");
4837        let bundle = Bundle {
4838            version: 3,
4839            format: ObjectFormat::Sha256,
4840            capabilities: vec![BundleCapability {
4841                key: "filter".into(),
4842                value: Some(b"blob:none".to_vec()),
4843            }],
4844            prerequisites: Vec::new(),
4845            references: vec![BundleReference {
4846                oid,
4847                name: "refs/heads/main".into(),
4848            }],
4849            pack: b"PACKv3".to_vec(),
4850        };
4851
4852        let bytes = bundle.write().expect("test operation should succeed");
4853        let text = String::from_utf8(bytes.clone()).expect("test operation should succeed");
4854        assert!(text.starts_with("# v3 git bundle\n@filter=blob:none\n@object-format=sha256\n"));
4855        let parsed =
4856            Bundle::parse(&bytes, ObjectFormat::Sha1).expect("test operation should succeed");
4857        assert_eq!(parsed.format, ObjectFormat::Sha256);
4858        assert_eq!(parsed.references[0].oid, oid);
4859        assert_eq!(parsed.pack, b"PACKv3");
4860    }
4861
4862    #[test]
4863    fn rejects_bad_bundle_write_inputs() {
4864        let sha1 = sley_core::object_id_for_bytes(ObjectFormat::Sha1, "blob", b"tip\n")
4865            .expect("test operation should succeed");
4866        let sha256 = sley_core::object_id_for_bytes(ObjectFormat::Sha256, "blob", b"tip\n")
4867            .expect("test operation should succeed");
4868        let mut bundle = Bundle {
4869            version: 2,
4870            format: ObjectFormat::Sha1,
4871            capabilities: vec![BundleCapability {
4872                key: "filter".into(),
4873                value: Some(b"blob:none".to_vec()),
4874            }],
4875            prerequisites: Vec::new(),
4876            references: Vec::new(),
4877            pack: Vec::new(),
4878        };
4879        assert!(bundle.write().is_err());
4880
4881        bundle.version = 3;
4882        bundle.capabilities = vec![BundleCapability {
4883            key: "bad_key".into(),
4884            value: None,
4885        }];
4886        assert!(bundle.write().is_err());
4887
4888        bundle.capabilities = Vec::new();
4889        bundle.references = vec![BundleReference {
4890            oid: sha256,
4891            name: "refs/heads/main".into(),
4892        }];
4893        assert!(bundle.write().is_err());
4894
4895        bundle.references = vec![BundleReference {
4896            oid: sha1,
4897            name: "bad\nname".into(),
4898        }];
4899        assert!(bundle.write().is_err());
4900    }
4901
4902    #[test]
4903    fn rejects_bad_bundle_headers() {
4904        assert!(Bundle::parse(b"# v4 git bundle\n\n", ObjectFormat::Sha1).is_err());
4905        assert!(
4906            Bundle::parse(
4907                b"# v2 git bundle\n@filter=blob:none\n\n",
4908                ObjectFormat::Sha1
4909            )
4910            .is_err()
4911        );
4912        assert!(Bundle::parse(b"# v3 git bundle\n@bad_key=value\n\n", ObjectFormat::Sha1).is_err());
4913        assert!(
4914            Bundle::parse(
4915                b"# v3 git bundle\n1111111111111111111111111111111111111111 refs/heads/main\n@filter=blob:none\n\n",
4916                ObjectFormat::Sha1,
4917            )
4918            .is_err()
4919        );
4920        assert!(
4921            Bundle::parse(
4922                b"# v3 git bundle\n@object-format=unknown\n\n",
4923                ObjectFormat::Sha1,
4924            )
4925            .is_err()
4926        );
4927        assert!(
4928            Bundle::parse(
4929                b"# v2 git bundle\n1111111111111111111111111111111111111111 refs/heads/main",
4930                ObjectFormat::Sha1,
4931            )
4932            .is_err()
4933        );
4934        assert!(
4935            Bundle::parse(
4936                b"# v2 git bundle\n1111111111111111111111111111111111111111 \n\n",
4937                ObjectFormat::Sha1,
4938            )
4939            .is_err()
4940        );
4941    }
4942
4943    #[test]
4944    fn repository_bootstrap_copies_template_files() {
4945        let root = unique_temp_dir("init-template");
4946        let template = root.join("template");
4947        let repo = root.join("repo");
4948        fs::create_dir_all(template.join("hooks")).expect("create template hooks");
4949        fs::write(template.join("description"), b"template repo\n").expect("write description");
4950        fs::write(template.join("hooks/pre-commit"), b"#!/bin/sh\n").expect("write hook");
4951        fs::write(template.join("config"), b"[user]\n\tname = Template User\n")
4952            .expect("write template config");
4953
4954        let layout = RepositoryBootstrap::init(InitOptions {
4955            git_dir_override: None,
4956            core_worktree: None,
4957            object_dir: None,
4958            worktree: repo.clone(),
4959            object_format: ObjectFormat::Sha1,
4960            object_format_explicit: false,
4961            bare: false,
4962            initial_branch: "main".into(),
4963            template_dir: Some(template),
4964            copy_template_config: true,
4965            separate_git_dir: None,
4966            shared_repository: None,
4967            ref_storage: RefStorageFormat::Files,
4968            ref_storage_explicit: false,
4969        })
4970        .expect("init should succeed");
4971
4972        assert_eq!(
4973            fs::read_to_string(layout.git_dir.join("description")).expect("read description"),
4974            "template repo\n"
4975        );
4976        assert!(layout.git_dir.join("hooks/pre-commit").is_file());
4977        let config = GitConfig::read(layout.git_dir.join("config")).expect("read config");
4978        assert_eq!(config.get("user", None, "name"), Some("Template User"));
4979        let _ = fs::remove_dir_all(&root);
4980    }
4981
4982    #[test]
4983    fn repository_bootstrap_separate_git_dir_writes_gitfile() {
4984        let root = unique_temp_dir("init-separate-gitdir");
4985        let worktree = root.join("worktree");
4986        let gitdir = root.join("external.git");
4987        let layout = RepositoryBootstrap::init(InitOptions {
4988            git_dir_override: None,
4989            core_worktree: None,
4990            object_dir: None,
4991            worktree: worktree.clone(),
4992            object_format: ObjectFormat::Sha1,
4993            object_format_explicit: false,
4994            bare: false,
4995            initial_branch: "main".into(),
4996            template_dir: None,
4997            copy_template_config: false,
4998            separate_git_dir: Some(gitdir.clone()),
4999            shared_repository: None,
5000            ref_storage: RefStorageFormat::Files,
5001            ref_storage_explicit: false,
5002        })
5003        .expect("init should succeed");
5004
5005        assert_eq!(layout.git_dir, gitdir);
5006        assert!(gitdir.join("HEAD").is_file());
5007        let gitfile = fs::read_to_string(worktree.join(".git")).expect("read gitfile");
5008        assert!(gitfile.starts_with("gitdir: "));
5009        let _ = fs::remove_dir_all(&root);
5010    }
5011
5012    #[test]
5013    fn repository_bootstrap_initializes_an_external_object_directory() {
5014        let root = unique_temp_dir("init-external-object-dir");
5015        let repo = root.join("repo");
5016        let object_dir = root.join("objects");
5017        let layout = RepositoryBootstrap::init(InitOptions {
5018            git_dir_override: None,
5019            core_worktree: None,
5020            object_dir: Some(object_dir.clone()),
5021            worktree: repo,
5022            object_format: ObjectFormat::Sha1,
5023            object_format_explicit: false,
5024            bare: false,
5025            initial_branch: "main".into(),
5026            template_dir: None,
5027            copy_template_config: false,
5028            separate_git_dir: None,
5029            shared_repository: None,
5030            ref_storage: RefStorageFormat::Files,
5031            ref_storage_explicit: false,
5032        })
5033        .expect("init should succeed");
5034
5035        assert!(object_dir.join("info").is_dir());
5036        assert!(object_dir.join("pack").is_dir());
5037        assert!(!layout.git_dir.join("objects/pack").exists());
5038        let _ = fs::remove_dir_all(&root);
5039    }
5040
5041    #[test]
5042    fn repository_bootstrap_reftable_writes_extension_and_table() {
5043        let root = unique_temp_dir("init-reftable");
5044        let repo = root.join("repo");
5045        let layout = RepositoryBootstrap::init(InitOptions {
5046            git_dir_override: None,
5047            core_worktree: None,
5048            object_dir: None,
5049            worktree: repo,
5050            object_format: ObjectFormat::Sha1,
5051            object_format_explicit: false,
5052            bare: false,
5053            initial_branch: "main".into(),
5054            template_dir: None,
5055            copy_template_config: false,
5056            separate_git_dir: None,
5057            shared_repository: None,
5058            ref_storage: RefStorageFormat::Reftable,
5059            ref_storage_explicit: false,
5060        })
5061        .expect("init should succeed");
5062
5063        let config = GitConfig::read(layout.git_dir.join("config")).expect("read config");
5064        assert_eq!(
5065            config.get("extensions", None, "refStorage"),
5066            Some("reftable")
5067        );
5068        assert!(layout.git_dir.join("reftable/tables.list").is_file());
5069        assert_eq!(
5070            fs::read(layout.git_dir.join("refs/heads")).expect("read reftable sentinel"),
5071            b"this repository uses the reftable format\n"
5072        );
5073        let _ = fs::remove_dir_all(&root);
5074    }
5075
5076    #[test]
5077    fn init_config_preserves_an_existing_log_all_ref_updates_choice() {
5078        let inherited = build_init_config(
5079            ObjectFormat::Sha1,
5080            false,
5081            &None,
5082            RefStorageFormat::Reftable,
5083            None,
5084            None,
5085            true,
5086        );
5087        assert_eq!(inherited.get("core", None, "logAllRefUpdates"), None);
5088
5089        let defaulted = build_init_config(
5090            ObjectFormat::Sha1,
5091            false,
5092            &None,
5093            RefStorageFormat::Reftable,
5094            None,
5095            None,
5096            false,
5097        );
5098        assert_eq!(
5099            defaulted.get("core", None, "logAllRefUpdates"),
5100            Some("true")
5101        );
5102    }
5103
5104    #[test]
5105    fn repository_bootstrap_honors_shared_repository_config() {
5106        let root = unique_temp_dir("init-shared");
5107        let repo = root.join("repo");
5108        let layout = RepositoryBootstrap::init(InitOptions {
5109            git_dir_override: None,
5110            core_worktree: None,
5111            object_dir: None,
5112            worktree: repo,
5113            object_format: ObjectFormat::Sha1,
5114            object_format_explicit: false,
5115            bare: false,
5116            initial_branch: "main".into(),
5117            template_dir: None,
5118            copy_template_config: false,
5119            separate_git_dir: None,
5120            shared_repository: Some("group".into()),
5121            ref_storage: RefStorageFormat::Files,
5122            ref_storage_explicit: false,
5123        })
5124        .expect("init should succeed");
5125
5126        let config = GitConfig::read(layout.git_dir.join("config")).expect("read config");
5127        assert_eq!(config.get("core", None, "sharedRepository"), Some("1"));
5128        let _ = fs::remove_dir_all(&root);
5129    }
5130
5131    #[cfg(unix)]
5132    #[test]
5133    fn exact_shared_repository_adjusts_intermediate_layout_directories() {
5134        use std::os::unix::fs::PermissionsExt;
5135
5136        let root = unique_temp_dir("init-shared-exact-dirs");
5137        let repo = root.join("repo");
5138        let layout = RepositoryBootstrap::init(InitOptions {
5139            git_dir_override: None,
5140            core_worktree: None,
5141            object_dir: None,
5142            worktree: repo,
5143            object_format: ObjectFormat::Sha1,
5144            object_format_explicit: false,
5145            bare: false,
5146            initial_branch: "main".into(),
5147            template_dir: None,
5148            copy_template_config: false,
5149            separate_git_dir: None,
5150            shared_repository: Some("0660".into()),
5151            ref_storage: RefStorageFormat::Files,
5152            ref_storage_explicit: false,
5153        })
5154        .expect("init shared repository");
5155
5156        for relative in [
5157            "",
5158            "objects",
5159            "objects/info",
5160            "objects/pack",
5161            "refs",
5162            "refs/heads",
5163        ] {
5164            let path = layout.git_dir.join(relative);
5165            let mode = fs::metadata(&path)
5166                .expect("shared directory")
5167                .permissions()
5168                .mode();
5169            assert_eq!(
5170                mode & 0o7777,
5171                0o2770,
5172                "unexpected mode for {}",
5173                path.display()
5174            );
5175        }
5176        let _ = fs::remove_dir_all(&root);
5177    }
5178
5179    #[test]
5180    fn shared_repository_values_are_canonical_and_owner_safe() {
5181        assert_eq!(
5182            canonical_shared_repository_value("group").expect("group is valid"),
5183            Some("1".into())
5184        );
5185        assert_eq!(
5186            canonical_shared_repository_value("all").expect("all is valid"),
5187            Some("2".into())
5188        );
5189        assert_eq!(
5190            canonical_shared_repository_value("0660").expect("owner rw mode is valid"),
5191            Some("0660".into())
5192        );
5193        assert_eq!(
5194            canonical_shared_repository_value("umask").expect("umask is valid"),
5195            None
5196        );
5197        assert!(canonical_shared_repository_value("0400").is_err());
5198        assert!(canonical_shared_repository_value("mystery").is_err());
5199    }
5200
5201    #[cfg(unix)]
5202    #[test]
5203    fn shared_repository_file_adjustment_preserves_read_only_intent() {
5204        use std::os::unix::fs::PermissionsExt;
5205
5206        let root = unique_temp_dir("shared-file-mode");
5207        fs::create_dir_all(&root).expect("create temp root");
5208        let path = root.join("refs");
5209        fs::write(&path, b"refs\n").expect("write refs");
5210
5211        fs::set_permissions(&path, fs::Permissions::from_mode(0o400)).expect("set read-only mode");
5212        adjust_shared_repository_file(&path, Some("0660")).expect("adjust read-only mode");
5213        assert_eq!(
5214            fs::metadata(&path).expect("metadata").permissions().mode() & 0o777,
5215            0o440
5216        );
5217
5218        fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("set writable mode");
5219        adjust_shared_repository_file(&path, Some("0660")).expect("adjust writable mode");
5220        assert_eq!(
5221            fs::metadata(&path).expect("metadata").permissions().mode() & 0o777,
5222            0o660
5223        );
5224        let _ = fs::remove_dir_all(root);
5225    }
5226
5227    fn oid(hex: &str) -> ObjectId {
5228        ObjectId::from_hex(ObjectFormat::Sha1, hex).expect("test operation should succeed")
5229    }
5230
5231    fn unique_temp_dir(name: &str) -> PathBuf {
5232        let nanos = SystemTime::now()
5233            .duration_since(UNIX_EPOCH)
5234            .expect("system time before unix epoch")
5235            .as_nanos();
5236        std::env::temp_dir().join(format!("sley-{name}-{}-{nanos}", std::process::id()))
5237    }
5238
5239    fn run_success(program: &str, cwd: &Path, args: &[&str]) -> Vec<u8> {
5240        let output = Command::new(program)
5241            .current_dir(cwd)
5242            .args(args)
5243            .output()
5244            .unwrap_or_else(|err| panic!("failed to run {program} {args:?}: {err}"));
5245        assert!(
5246            output.status.success(),
5247            "{program} {args:?} failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
5248            output.status.code(),
5249            String::from_utf8_lossy(&output.stdout),
5250            String::from_utf8_lossy(&output.stderr)
5251        );
5252        output.stdout
5253    }
5254
5255    fn run_success_with_stdin(program: &str, cwd: &Path, args: &[&str], stdin: &[u8]) -> Vec<u8> {
5256        let mut child = Command::new(program)
5257            .current_dir(cwd)
5258            .args(args)
5259            .stdin(std::process::Stdio::piped())
5260            .stdout(std::process::Stdio::piped())
5261            .stderr(std::process::Stdio::piped())
5262            .spawn()
5263            .unwrap_or_else(|err| panic!("failed to spawn {program} {args:?}: {err}"));
5264        child
5265            .stdin
5266            .as_mut()
5267            .expect("stdin is piped")
5268            .write_all(stdin)
5269            .expect("write stdin");
5270        let output = child
5271            .wait_with_output()
5272            .unwrap_or_else(|err| panic!("failed to wait for {program} {args:?}: {err}"));
5273        assert!(
5274            output.status.success(),
5275            "{program} {args:?} failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
5276            output.status.code(),
5277            String::from_utf8_lossy(&output.stdout),
5278            String::from_utf8_lossy(&output.stderr)
5279        );
5280        output.stdout
5281    }
5282
5283    fn commit_graph(
5284        format: ObjectFormat,
5285        base_graph_count: u8,
5286        chunks: &[([u8; 4], Vec<u8>)],
5287    ) -> Vec<u8> {
5288        let lookup_len = (chunks.len() + 1) * 12;
5289        let mut out = Vec::new();
5290        out.extend_from_slice(b"CGPH");
5291        out.push(1);
5292        out.push(hash_function_id(format) as u8);
5293        out.push(chunks.len() as u8);
5294        out.push(base_graph_count);
5295        let mut chunk_offset = (8 + lookup_len) as u64;
5296        for (id, data) in chunks {
5297            out.extend_from_slice(id);
5298            out.extend_from_slice(&chunk_offset.to_be_bytes());
5299            chunk_offset += data.len() as u64;
5300        }
5301        out.extend_from_slice(&[0, 0, 0, 0]);
5302        out.extend_from_slice(&chunk_offset.to_be_bytes());
5303        for (_id, data) in chunks {
5304            out.extend_from_slice(data);
5305        }
5306        let checksum =
5307            sley_core::digest_bytes(format, &out).expect("test operation should succeed");
5308        out.extend_from_slice(checksum.as_bytes());
5309        out
5310    }
5311
5312    fn commit_graph_chunks(
5313        entries: &[(ObjectId, ObjectId, Vec<u32>, u32, u64)],
5314    ) -> Vec<([u8; 4], Vec<u8>)> {
5315        let mut entries = entries.to_vec();
5316        entries.sort_by(|left, right| left.0.as_bytes().cmp(right.0.as_bytes()));
5317        let object_ids: Vec<ObjectId> = entries.iter().map(|entry| entry.0).collect();
5318        let (cdat, edge) = commit_graph_cdat(&entries);
5319        let mut chunks = vec![
5320            (*b"OIDF", commit_graph_fanout(&object_ids)),
5321            (*b"OIDL", commit_graph_oid_lookup(&object_ids)),
5322            (*b"CDAT", cdat),
5323        ];
5324        if !edge.is_empty() {
5325            chunks.push((*b"EDGE", edge));
5326        }
5327        chunks
5328    }
5329
5330    fn commit_graph_fanout(object_ids: &[ObjectId]) -> Vec<u8> {
5331        let mut counts = [0u32; 256];
5332        for oid in object_ids {
5333            counts[oid.as_bytes()[0] as usize] += 1;
5334        }
5335        let mut running = 0u32;
5336        let mut out = Vec::new();
5337        for count in counts {
5338            running += count;
5339            out.extend_from_slice(&running.to_be_bytes());
5340        }
5341        out
5342    }
5343
5344    fn commit_graph_oid_lookup(object_ids: &[ObjectId]) -> Vec<u8> {
5345        let mut out = Vec::new();
5346        for oid in object_ids {
5347            out.extend_from_slice(oid.as_bytes());
5348        }
5349        out
5350    }
5351
5352    fn commit_graph_cdat(
5353        entries: &[(ObjectId, ObjectId, Vec<u32>, u32, u64)],
5354    ) -> (Vec<u8>, Vec<u8>) {
5355        let mut cdat = Vec::new();
5356        let mut edge = Vec::new();
5357        for (_oid, tree, parents, generation, commit_time) in entries {
5358            cdat.extend_from_slice(tree.as_bytes());
5359            let first_parent = parents.first().copied().unwrap_or(COMMIT_GRAPH_PARENT_NONE);
5360            let second_parent = match parents.len() {
5361                0 | 1 => COMMIT_GRAPH_PARENT_NONE,
5362                2 => parents[1],
5363                _ => {
5364                    let edge_start = (edge.len() / 4) as u32;
5365                    for (idx, parent) in parents[1..].iter().enumerate() {
5366                        let mut value = *parent;
5367                        if idx == parents.len() - 2 {
5368                            value |= COMMIT_GRAPH_EXTRA_EDGE;
5369                        }
5370                        edge.extend_from_slice(&value.to_be_bytes());
5371                    }
5372                    COMMIT_GRAPH_EXTRA_EDGE | edge_start
5373                }
5374            };
5375            cdat.extend_from_slice(&first_parent.to_be_bytes());
5376            cdat.extend_from_slice(&second_parent.to_be_bytes());
5377            let generation_and_time_high =
5378                (*generation << 2) | (((*commit_time >> 32) as u32) & 0x3);
5379            cdat.extend_from_slice(&generation_and_time_high.to_be_bytes());
5380            cdat.extend_from_slice(&(*commit_time as u32).to_be_bytes());
5381        }
5382        (cdat, edge)
5383    }
5384
5385    fn commit_graph_gda2(values: &[u32]) -> Vec<u8> {
5386        let mut out = Vec::new();
5387        for value in values {
5388            out.extend_from_slice(&value.to_be_bytes());
5389        }
5390        out
5391    }
5392
5393    fn commit_graph_gdo2(values: &[u64]) -> Vec<u8> {
5394        let mut out = Vec::new();
5395        for value in values {
5396            out.extend_from_slice(&value.to_be_bytes());
5397        }
5398        out
5399    }
5400
5401    fn commit_graph_bidx(values: &[u32]) -> Vec<u8> {
5402        let mut out = Vec::new();
5403        for value in values {
5404            out.extend_from_slice(&value.to_be_bytes());
5405        }
5406        out
5407    }
5408
5409    fn commit_graph_bdat(
5410        hash_version: u32,
5411        hash_count: u32,
5412        bits_per_entry: u32,
5413        payload: &[u8],
5414    ) -> Vec<u8> {
5415        let mut out = Vec::new();
5416        out.extend_from_slice(&hash_version.to_be_bytes());
5417        out.extend_from_slice(&hash_count.to_be_bytes());
5418        out.extend_from_slice(&bits_per_entry.to_be_bytes());
5419        out.extend_from_slice(payload);
5420        out
5421    }
5422}