Skip to main content

sley_core/
lib.rs

1use std::borrow::Borrow;
2use std::error::Error;
3use std::fmt;
4use std::ops::Deref;
5use std::path::{Path, PathBuf};
6use std::str::FromStr;
7
8pub const UPSTREAM_GIT_COMPAT_VERSION: &str = "2.54.0";
9
10#[derive(Debug, Default, Clone, PartialEq, Eq)]
11pub enum DateMode {
12    #[default]
13    Default,
14    Local,
15    Raw,
16    RawLocal,
17    Unix,
18    Short,
19    ShortLocal,
20    Iso,
21    IsoLocal,
22    IsoStrict,
23    IsoStrictLocal,
24    Rfc2822,
25    Rfc2822Local,
26    Relative,
27    Human,
28    HumanLocal,
29    Strftime {
30        template: String,
31        local: bool,
32    },
33}
34
35impl DateMode {
36    pub fn parse(value: &str) -> Option<Self> {
37        if let Some(template) = value.strip_prefix("format:") {
38            return Some(Self::Strftime {
39                template: template.to_string(),
40                local: false,
41            });
42        }
43        if let Some(template) = value.strip_prefix("format-local:") {
44            return Some(Self::Strftime {
45                template: template.to_string(),
46                local: true,
47            });
48        }
49        if value == "tformat:" || value.starts_with("tformat:") {
50            return Some(Self::Strftime {
51                template: value["tformat:".len()..].to_string(),
52                local: false,
53            });
54        }
55        if value == "auto:" || value.starts_with("auto:") {
56            return Some(Self::Default);
57        }
58        Some(match value {
59            "default" => Self::Default,
60            "default-local" | "local" => Self::Local,
61            "raw" => Self::Raw,
62            "raw-local" => Self::RawLocal,
63            "unix" => Self::Unix,
64            "short" => Self::Short,
65            "short-local" => Self::ShortLocal,
66            "iso" | "iso8601" => Self::Iso,
67            "iso-local" | "iso8601-local" => Self::IsoLocal,
68            "iso-strict" | "iso8601-strict" => Self::IsoStrict,
69            "iso-strict-local" | "iso8601-strict-local" => Self::IsoStrictLocal,
70            "rfc" | "rfc2822" => Self::Rfc2822,
71            "rfc-local" | "rfc2822-local" => Self::Rfc2822Local,
72            "relative" | "relative-local" => Self::Relative,
73            "human" => Self::Human,
74            "human-local" => Self::HumanLocal,
75            _ => return None,
76        })
77    }
78
79    pub fn parse_atom_modifier(modifier: Option<&str>) -> Option<Self> {
80        modifier.map_or(Some(Self::Default), Self::parse)
81    }
82
83    pub fn render(&self, timestamp: i64, timezone: &str) -> Option<String> {
84        let tz = if self.is_local() { "+0000" } else { timezone };
85        let parts = DateParts::from_timestamp(timestamp, tz)?;
86        Some(match self {
87            Self::Default | Self::Local => {
88                let base = format!(
89                    "{} {} {} {:02}:{:02}:{:02} {}",
90                    parts.weekday,
91                    MONTHS_ABBR[(parts.month - 1) as usize],
92                    parts.day,
93                    parts.hour,
94                    parts.minute,
95                    parts.second,
96                    parts.year,
97                );
98                if self.is_local() {
99                    base
100                } else {
101                    format!("{base} {}", parts.timezone)
102                }
103            }
104            Self::Raw | Self::RawLocal => format!("{} {}", parts.timestamp, parts.timezone),
105            Self::Unix => parts.timestamp.to_string(),
106            Self::Short | Self::ShortLocal => {
107                format!("{:04}-{:02}-{:02}", parts.year, parts.month, parts.day)
108            }
109            Self::Iso | Self::IsoLocal => format!(
110                "{:04}-{:02}-{:02} {:02}:{:02}:{:02} {}",
111                parts.year,
112                parts.month,
113                parts.day,
114                parts.hour,
115                parts.minute,
116                parts.second,
117                parts.timezone,
118            ),
119            Self::IsoStrict | Self::IsoStrictLocal => format!(
120                "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}",
121                parts.year,
122                parts.month,
123                parts.day,
124                parts.hour,
125                parts.minute,
126                parts.second,
127                strict_timezone(parts.timezone),
128            ),
129            Self::Rfc2822 | Self::Rfc2822Local => format!(
130                "{}, {} {} {:04} {:02}:{:02}:{:02} {}",
131                parts.weekday,
132                parts.day,
133                MONTHS_ABBR[(parts.month - 1) as usize],
134                parts.year,
135                parts.hour,
136                parts.minute,
137                parts.second,
138                parts.timezone,
139            ),
140            Self::Relative => relative_date(parts.timestamp),
141            Self::Human | Self::HumanLocal => format!(
142                "{} {} {} {:02}:{:02}:{:02} {} {}",
143                parts.weekday,
144                MONTHS_ABBR[(parts.month - 1) as usize],
145                parts.day,
146                parts.hour,
147                parts.minute,
148                parts.second,
149                parts.year,
150                parts.timezone,
151            ),
152            Self::Strftime { template, .. } => strftime(template, &parts),
153        })
154    }
155
156    pub fn is_local(&self) -> bool {
157        matches!(
158            self,
159            Self::Local
160                | Self::RawLocal
161                | Self::ShortLocal
162                | Self::IsoLocal
163                | Self::IsoStrictLocal
164                | Self::Rfc2822Local
165                | Self::HumanLocal
166                | Self::Strftime { local: true, .. }
167        )
168    }
169}
170
171const MONTHS_ABBR: [&str; 12] = [
172    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
173];
174
175const MONTHS_FULL: [&str; 12] = [
176    "January",
177    "February",
178    "March",
179    "April",
180    "May",
181    "June",
182    "July",
183    "August",
184    "September",
185    "October",
186    "November",
187    "December",
188];
189
190const WEEKDAYS_FULL: [&str; 7] = [
191    "Sunday",
192    "Monday",
193    "Tuesday",
194    "Wednesday",
195    "Thursday",
196    "Friday",
197    "Saturday",
198];
199
200struct DateParts<'a> {
201    timestamp: i64,
202    timezone: &'a str,
203    weekday: &'static str,
204    year: i64,
205    month: u32,
206    day: u32,
207    hour: i64,
208    minute: i64,
209    second: i64,
210}
211
212impl<'a> DateParts<'a> {
213    fn from_timestamp(timestamp: i64, timezone: &'a str) -> Option<Self> {
214        const WEEKDAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
215        let offset_seconds = timezone_offset_seconds(timezone)?;
216        let local = timestamp + offset_seconds;
217        let days = local.div_euclid(86_400);
218        let seconds = local.rem_euclid(86_400);
219        let (year, month, day) = civil_from_days(days);
220        Some(Self {
221            timestamp,
222            timezone,
223            weekday: WEEKDAYS[(days + 4).rem_euclid(7) as usize],
224            year,
225            month,
226            day,
227            hour: seconds / 3_600,
228            minute: (seconds % 3_600) / 60,
229            second: seconds % 60,
230        })
231    }
232}
233
234fn timezone_offset_seconds(timezone: &str) -> Option<i64> {
235    if timezone.len() != 5 {
236        return None;
237    }
238    let sign = match timezone.as_bytes()[0] {
239        b'+' => 1,
240        b'-' => -1,
241        _ => return None,
242    };
243    let hours = timezone[1..3].parse::<i64>().ok()?;
244    let minutes = timezone[3..5].parse::<i64>().ok()?;
245    Some(sign * (hours * 3_600 + minutes * 60))
246}
247
248fn strict_timezone(timezone: &str) -> String {
249    let digits = timezone.strip_prefix(['+', '-']).unwrap_or(timezone);
250    if digits == "0000" {
251        "Z".to_string()
252    } else if timezone.len() == 5 {
253        format!("{}{}:{}", &timezone[..1], &timezone[1..3], &timezone[3..5])
254    } else {
255        timezone.to_string()
256    }
257}
258
259fn strftime(template: &str, parts: &DateParts<'_>) -> String {
260    let weekday_index = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
261        .iter()
262        .position(|day| *day == parts.weekday)
263        .unwrap_or(0);
264    let mut out = String::with_capacity(template.len());
265    let mut chars = template.chars().peekable();
266    while let Some(ch) = chars.next() {
267        if ch != '%' {
268            out.push(ch);
269            continue;
270        }
271        match chars.next() {
272            Some('Y') => out.push_str(&format!("{:04}", parts.year)),
273            Some('y') => out.push_str(&format!("{:02}", parts.year.rem_euclid(100))),
274            Some('m') => out.push_str(&format!("{:02}", parts.month)),
275            Some('d') => out.push_str(&format!("{:02}", parts.day)),
276            Some('e') => out.push_str(&format!("{:2}", parts.day)),
277            Some('H') => out.push_str(&format!("{:02}", parts.hour)),
278            Some('M') => out.push_str(&format!("{:02}", parts.minute)),
279            Some('S') => out.push_str(&format!("{:02}", parts.second)),
280            Some('b') | Some('h') => out.push_str(MONTHS_ABBR[(parts.month - 1) as usize]),
281            Some('B') => out.push_str(MONTHS_FULL[(parts.month - 1) as usize]),
282            Some('a') => out.push_str(parts.weekday),
283            Some('A') => out.push_str(WEEKDAYS_FULL[weekday_index]),
284            Some('%') => out.push('%'),
285            Some('n') => out.push('\n'),
286            Some('t') => out.push('\t'),
287            Some(other) => {
288                out.push('%');
289                out.push(other);
290            }
291            None => out.push('%'),
292        }
293    }
294    out
295}
296
297fn relative_date(timestamp: i64) -> String {
298    let now = std::time::SystemTime::now()
299        .duration_since(std::time::UNIX_EPOCH)
300        .map(|duration| duration.as_secs() as i64)
301        .unwrap_or(timestamp);
302    if timestamp > now {
303        return "in the future".to_string();
304    }
305    let diff = (now - timestamp) as u64;
306    if diff < 90 {
307        return format!("{diff} seconds ago");
308    }
309    let minutes = (diff + 30) / 60;
310    if minutes < 90 {
311        return format!("{minutes} minutes ago");
312    }
313    let hours = (diff + 1800) / 3600;
314    if hours < 36 {
315        return format!("{hours} hours ago");
316    }
317    let days = (diff + 43200) / 86400;
318    if days < 14 {
319        return format!("{days} days ago");
320    }
321    if days < 70 {
322        return format!("{} weeks ago", (days + 3) / 7);
323    }
324    if days < 365 {
325        return format!("{} months ago", (days + 15) / 30);
326    }
327    let years_scaled = (days * 10 + 183) / 365;
328    if days < 365 * 2 {
329        let months = ((days - 365) + 15) / 30;
330        if months > 0 {
331            return format!("1 year, {months} months ago");
332        }
333        return "1 year ago".to_string();
334    }
335    if years_scaled.is_multiple_of(10) {
336        format!("{} years ago", years_scaled / 10)
337    } else {
338        format!("{}.{} years ago", years_scaled / 10, years_scaled % 10)
339    }
340}
341
342fn civil_from_days(days: i64) -> (i64, u32, u32) {
343    let days = days + 719_468;
344    let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
345    let day_of_era = days - era * 146_097;
346    let year_of_era =
347        (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
348    let year = year_of_era + era * 400;
349    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
350    let month_prime = (5 * day_of_year + 2) / 153;
351    let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
352    let month = month_prime + if month_prime < 10 { 3 } else { -9 };
353    let year = year + i64::from(month <= 2);
354    (year, month as u32, day as u32)
355}
356
357/// Minimal trace2 event-target support (`GIT_TRACE2_EVENT`).
358///
359/// Upstream's trace2 event target writes one JSON object per line to the file
360/// named by `GIT_TRACE2_EVENT`. sley emits only the `data` events the test
361/// suite asserts on (`test_trace2_data` greps for the contiguous
362/// `"category":"...","key":"...","value":"..."` triple), with the same field
363/// order trace2's `fn_data_fl` produces. Unset/unwritable targets are
364/// silently ignored, like upstream's best-effort tracing.
365pub mod trace2 {
366    use std::fmt::Display;
367    use std::fmt::Write as _;
368    use std::io::Write;
369
370    fn escape_json(raw: &str) -> String {
371        let mut out = String::with_capacity(raw.len());
372        for ch in raw.chars() {
373            match ch {
374                '"' => out.push_str("\\\""),
375                '\\' => out.push_str("\\\\"),
376                '\n' => out.push_str("\\n"),
377                '\t' => out.push_str("\\t"),
378                ch if (ch as u32) < 0x20 => {
379                    let _ = write!(out, "\\u{:04x}", ch as u32);
380                }
381                ch => out.push(ch),
382            }
383        }
384        out
385    }
386
387    /// Emit a trace2 `data` event (upstream `trace2_data_string` /
388    /// `trace2_data_intmax`): a JSON line appended to the `GIT_TRACE2_EVENT`
389    /// file when that target is enabled.
390    pub fn data(category: &str, key: &str, value: impl Display) {
391        let Some(target) = std::env::var_os("GIT_TRACE2_EVENT") else {
392            return;
393        };
394        let target = target.to_string_lossy().into_owned();
395        // Upstream accepts absolute paths (and fd/unix-socket forms sley does
396        // not support); only path-like targets are honored here.
397        if !target.starts_with('/') {
398            return;
399        }
400        let line = format!(
401            "{{\"event\":\"data\",\"sid\":\"sley\",\"thread\":\"main\",\"nesting\":1,\"category\":\"{}\",\"key\":\"{}\",\"value\":\"{}\"}}\n",
402            escape_json(category),
403            escape_json(key),
404            escape_json(&value.to_string()),
405        );
406        if let Ok(mut file) = std::fs::OpenOptions::new()
407            .create(true)
408            .append(true)
409            .open(&target)
410        {
411            let _ = file.write_all(line.as_bytes());
412        }
413    }
414
415    /// Emit the trace2 perf payload used by Git's changed-path Bloom filter
416    /// tests. This intentionally writes only the grep-stable statistics string.
417    pub fn bloom_statistics(
418        filter_not_present: usize,
419        maybe: usize,
420        definitely_not: usize,
421        false_positive: usize,
422    ) {
423        let Some(target) = std::env::var_os("GIT_TRACE2_PERF") else {
424            return;
425        };
426        let target = target.to_string_lossy().into_owned();
427        if !target.starts_with('/') {
428            return;
429        }
430        let line = format!(
431            "statistics:{{\"filter_not_present\":{filter_not_present},\"maybe\":{maybe},\"definitely_not\":{definitely_not},\"false_positive\":{false_positive}}}\n"
432        );
433        if let Ok(mut file) = std::fs::OpenOptions::new()
434            .create(true)
435            .append(true)
436            .open(&target)
437        {
438            let _ = file.write_all(line.as_bytes());
439        }
440    }
441}
442
443#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
444pub enum ObjectFormat {
445    Sha1,
446    Sha256,
447}
448
449impl ObjectFormat {
450    pub const fn raw_len(self) -> usize {
451        match self {
452            Self::Sha1 => 20,
453            Self::Sha256 => 32,
454        }
455    }
456
457    pub const fn hex_len(self) -> usize {
458        self.raw_len() * 2
459    }
460
461    pub const fn name(self) -> &'static str {
462        match self {
463            Self::Sha1 => "sha1",
464            Self::Sha256 => "sha256",
465        }
466    }
467}
468
469impl FromStr for ObjectFormat {
470    type Err = GitError;
471
472    fn from_str(value: &str) -> Result<Self> {
473        match value {
474            "sha1" => Ok(Self::Sha1),
475            "sha256" => Ok(Self::Sha256),
476            other => Err(GitError::Unsupported(format!("object format {other}"))),
477        }
478    }
479}
480
481#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
482pub struct ObjectId {
483    format: ObjectFormat,
484    bytes: [u8; 32],
485}
486
487impl ObjectId {
488    pub fn from_raw(format: ObjectFormat, raw: &[u8]) -> Result<Self> {
489        if raw.len() != format.raw_len() {
490            return Err(GitError::InvalidObjectId(format!(
491                "expected {} bytes for {}, got {}",
492                format.raw_len(),
493                format.name(),
494                raw.len()
495            )));
496        }
497        let mut bytes = [0; 32];
498        bytes[..raw.len()].copy_from_slice(raw);
499        Ok(Self { format, bytes })
500    }
501
502    pub fn from_hex(format: ObjectFormat, hex: &str) -> Result<Self> {
503        if hex.len() != format.hex_len() {
504            return Err(GitError::InvalidObjectId(format!(
505                "expected {} hex digits for {}, got {}",
506                format.hex_len(),
507                format.name(),
508                hex.len()
509            )));
510        }
511        let mut raw = [0; 32];
512        for (i, pair) in hex.as_bytes().chunks_exact(2).enumerate() {
513            raw[i] = (hex_nibble(pair[0])? << 4) | hex_nibble(pair[1])?;
514        }
515        Ok(Self { format, bytes: raw })
516    }
517
518    pub const fn format(&self) -> ObjectFormat {
519        self.format
520    }
521
522    pub fn as_bytes(&self) -> &[u8] {
523        &self.bytes[..self.format.raw_len()]
524    }
525
526    pub fn to_hex(&self) -> String {
527        let mut out = String::with_capacity(self.format.hex_len());
528        self.write_hex(&mut out)
529            .expect("writing object id hex to a String cannot fail");
530        out
531    }
532
533    pub fn write_hex(&self, out: &mut impl fmt::Write) -> fmt::Result {
534        write_hex_bytes(self.as_bytes(), out)
535    }
536
537    pub fn hex_prefix_matches(&self, prefix: &[u8]) -> bool {
538        if prefix.len() > self.format.hex_len() {
539            return false;
540        }
541
542        prefix.iter().enumerate().all(|(index, expected)| {
543            let Some(expected) = hex_nibble_value(*expected) else {
544                return false;
545            };
546            let byte = self.as_bytes()[index / 2];
547            let actual = if index % 2 == 0 {
548                byte >> 4
549            } else {
550                byte & 0x0f
551            };
552            actual == expected
553        })
554    }
555
556    pub const fn abbrev_hex_len(&self, width: usize) -> usize {
557        let hex_len = self.format.hex_len();
558        if width < hex_len { width } else { hex_len }
559    }
560
561    /// The all-zero ("null") object id for `format`.
562    pub fn null(format: ObjectFormat) -> Self {
563        Self {
564            format,
565            bytes: [0; 32],
566        }
567    }
568
569    /// True when every byte is zero (the null oid).
570    pub fn is_null(&self) -> bool {
571        self.as_bytes().iter().all(|byte| *byte == 0)
572    }
573
574    /// The id of the canonical empty tree for `format` (`4b825dc6…` for SHA-1).
575    pub fn empty_tree(format: ObjectFormat) -> Self {
576        Self::digest_object(format, "tree", b"")
577    }
578
579    /// The id of the canonical empty blob for `format` (`e69de29b…` for SHA-1).
580    pub fn empty_blob(format: ObjectFormat) -> Self {
581        Self::digest_object(format, "blob", b"")
582    }
583
584    /// Hash `"<type> <len>\0<body>"` straight into an id, bypassing the
585    /// fallible length check in [`ObjectId::from_raw`] (our own digests are
586    /// always the right length) so the well-known constants stay infallible.
587    fn digest_object(format: ObjectFormat, object_type: &str, body: &[u8]) -> Self {
588        let mut framed = Vec::with_capacity(object_type.len() + body.len() + 32);
589        framed.extend_from_slice(object_type.as_bytes());
590        framed.push(b' ');
591        framed.extend_from_slice(body.len().to_string().as_bytes());
592        framed.push(0);
593        framed.extend_from_slice(body);
594        let mut bytes = [0u8; 32];
595        match format {
596            ObjectFormat::Sha1 => bytes[..20].copy_from_slice(&sha1(&framed)),
597            ObjectFormat::Sha256 => bytes[..32].copy_from_slice(&sha256(&framed)),
598        }
599        Self { format, bytes }
600    }
601}
602
603impl fmt::Debug for ObjectId {
604    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
605        f.debug_tuple("ObjectId").field(&self.to_hex()).finish()
606    }
607}
608
609impl fmt::Display for ObjectId {
610    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
611        self.write_hex(f)
612    }
613}
614
615impl FromStr for ObjectId {
616    type Err = GitError;
617
618    /// Parse a full hex id, inferring the hash from its length (40 hex digits =
619    /// SHA-1, 64 = SHA-256).
620    fn from_str(text: &str) -> Result<Self> {
621        let format = match text.len() {
622            40 => ObjectFormat::Sha1,
623            64 => ObjectFormat::Sha256,
624            other => {
625                return Err(GitError::InvalidObjectId(format!(
626                    "expected 40 or 64 hex digits, got {other}"
627                )));
628            }
629        };
630        Self::from_hex(format, text)
631    }
632}
633
634#[derive(Debug, Clone, PartialEq, Eq)]
635pub struct ByteString(Vec<u8>);
636
637impl ByteString {
638    pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
639        Self(bytes.into())
640    }
641
642    pub fn as_bytes(&self) -> &[u8] {
643        &self.0
644    }
645}
646
647impl From<&str> for ByteString {
648    fn from(value: &str) -> Self {
649        Self(value.as_bytes().to_vec())
650    }
651}
652
653/// A validated git ref name (e.g. `refs/heads/main`, `HEAD`).
654#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
655pub struct FullName(String);
656
657impl FullName {
658    /// Construct a ref name, rejecting empty names, ASCII control characters,
659    /// leading/trailing whitespace, and consecutive slashes.
660    pub fn new(name: impl AsRef<str>) -> Result<Self> {
661        let name = name.as_ref();
662        validate_full_name(name)?;
663        Ok(Self(name.to_string()))
664    }
665
666    pub fn as_str(&self) -> &str {
667        &self.0
668    }
669}
670
671impl fmt::Debug for FullName {
672    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
673        f.debug_tuple("FullName").field(&self.0).finish()
674    }
675}
676
677impl fmt::Display for FullName {
678    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
679        f.write_str(&self.0)
680    }
681}
682
683impl From<FullName> for String {
684    fn from(value: FullName) -> Self {
685        value.0
686    }
687}
688
689impl Borrow<str> for FullName {
690    fn borrow(&self) -> &str {
691        &self.0
692    }
693}
694
695impl AsRef<str> for FullName {
696    fn as_ref(&self) -> &str {
697        &self.0
698    }
699}
700
701impl TryFrom<&str> for FullName {
702    type Error = GitError;
703
704    fn try_from(value: &str) -> Result<Self> {
705        Self::new(value)
706    }
707}
708
709impl TryFrom<String> for FullName {
710    type Error = GitError;
711
712    fn try_from(value: String) -> Result<Self> {
713        validate_full_name(&value)?;
714        Ok(Self(value))
715    }
716}
717
718impl PartialEq<&str> for FullName {
719    fn eq(&self, other: &&str) -> bool {
720        self.0 == *other
721    }
722}
723
724impl PartialEq<FullName> for &str {
725    fn eq(&self, other: &FullName) -> bool {
726        *self == other.0
727    }
728}
729
730fn validate_full_name(name: &str) -> Result<()> {
731    if name.is_empty() {
732        return Err(GitError::InvalidFormat("ref name must not be empty".into()));
733    }
734    if name.chars().next().is_some_and(|ch| ch.is_whitespace())
735        || name.chars().last().is_some_and(|ch| ch.is_whitespace())
736    {
737        return Err(GitError::InvalidFormat(
738            "ref name must not have leading or trailing whitespace".into(),
739        ));
740    }
741    if name.contains("//") {
742        return Err(GitError::InvalidFormat(
743            "ref name must not contain consecutive slashes".into(),
744        ));
745    }
746    if name.bytes().any(|byte| byte.is_ascii_control()) {
747        return Err(GitError::InvalidFormat(
748            "ref name must not contain control characters".into(),
749        ));
750    }
751    Ok(())
752}
753
754/// A byte string for git paths and similar on-disk identifiers.
755#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
756pub struct BString(Vec<u8>);
757
758impl BString {
759    pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
760        Self(bytes.into())
761    }
762    pub fn from_bytes(bytes: &[u8]) -> Self {
763        Self(bytes.to_vec())
764    }
765    pub fn as_bytes(&self) -> &[u8] {
766        &self.0
767    }
768    pub fn len(&self) -> usize {
769        self.0.len()
770    }
771    pub fn is_empty(&self) -> bool {
772        self.0.is_empty()
773    }
774    pub fn into_bytes(self) -> Vec<u8> {
775        self.0
776    }
777}
778
779impl From<&str> for BString {
780    fn from(v: &str) -> Self {
781        Self::from_bytes(v.as_bytes())
782    }
783}
784impl From<&[u8]> for BString {
785    fn from(v: &[u8]) -> Self {
786        Self::from_bytes(v)
787    }
788}
789impl<const N: usize> From<&[u8; N]> for BString {
790    fn from(v: &[u8; N]) -> Self {
791        Self::from_bytes(v.as_slice())
792    }
793}
794impl From<Vec<u8>> for BString {
795    fn from(v: Vec<u8>) -> Self {
796        Self(v)
797    }
798}
799impl PartialEq<&[u8]> for BString {
800    fn eq(&self, o: &&[u8]) -> bool {
801        self.0.as_slice() == *o
802    }
803}
804impl<const N: usize> PartialEq<&[u8; N]> for BString {
805    fn eq(&self, o: &&[u8; N]) -> bool {
806        self.as_bytes() == o.as_slice()
807    }
808}
809impl PartialEq<BString> for &[u8] {
810    fn eq(&self, o: &BString) -> bool {
811        *self == o.as_bytes()
812    }
813}
814impl<const N: usize> PartialEq<BString> for &[u8; N] {
815    fn eq(&self, o: &BString) -> bool {
816        self.as_slice() == o.as_bytes()
817    }
818}
819impl PartialEq<Vec<u8>> for BString {
820    fn eq(&self, o: &Vec<u8>) -> bool {
821        self.0 == *o
822    }
823}
824impl PartialEq<BString> for Vec<u8> {
825    fn eq(&self, o: &BString) -> bool {
826        *self == o.0
827    }
828}
829
830impl fmt::Display for BString {
831    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
832        write!(f, "{}", String::from_utf8_lossy(&self.0))
833    }
834}
835
836impl Borrow<[u8]> for BString {
837    fn borrow(&self) -> &[u8] {
838        self.as_bytes()
839    }
840}
841
842impl Deref for BString {
843    type Target = [u8];
844
845    fn deref(&self) -> &[u8] {
846        self.as_bytes()
847    }
848}
849
850impl AsRef<[u8]> for BString {
851    fn as_ref(&self) -> &[u8] {
852        self.as_bytes()
853    }
854}
855
856#[derive(Debug, Clone, PartialEq, Eq, Hash)]
857pub struct RepoPath(PathBuf);
858
859impl RepoPath {
860    pub fn new(path: impl Into<PathBuf>) -> Result<Self> {
861        let path = path.into();
862        if path.is_absolute() {
863            return Err(GitError::InvalidPath(
864                "repository paths must be relative".into(),
865            ));
866        }
867        if path.components().any(|component| {
868            matches!(
869                component,
870                std::path::Component::ParentDir | std::path::Component::Prefix(_)
871            )
872        }) {
873            return Err(GitError::InvalidPath(
874                "repository paths must not escape".into(),
875            ));
876        }
877        Ok(Self(path))
878    }
879
880    pub fn as_path(&self) -> &Path {
881        &self.0
882    }
883}
884
885/// A typed *parse-view* of a git identity line (`Name <email> <secs> <tz>`) as
886/// found on a commit's `author`/`committer` or a tag's `tagger` header.
887///
888/// This is a read-only lens over bytes that are stored and re-serialized
889/// verbatim elsewhere (see [`Signature::raw`]). It exists so callers can read
890/// the typed `name`/`email`/`time` of an identity without re-implementing git's
891/// ident-splitting rules, *not* as a storage format: the object model keeps the
892/// original raw bytes as its source of truth, and round-tripping through this
893/// view is byte-exact precisely because the raw line is retained alongside the
894/// parsed fields (see [`Signature::to_ident_bytes`]).
895///
896/// Parse one with [`Signature::from_ident_line`]. The `time`'s timezone
897/// preserves git's distinction between `+0000` (UTC) and `-0000` (a sentinel git
898/// writes to mean "timezone unknown"); see [`GitTime`].
899#[derive(Debug, Clone, PartialEq, Eq)]
900pub struct Signature {
901    /// The identity's name: the bytes before the ` <` that opens the email,
902    /// with one trailing space (the separator) removed. May be empty.
903    pub name: ByteString,
904    /// The identity's email: the bytes between the `<` and `>` delimiters. May
905    /// be empty.
906    pub email: ByteString,
907    /// The commit/authorship time and its timezone offset.
908    pub time: GitTime,
909    /// The exact original ident-line bytes this view was parsed from, retained
910    /// so [`Signature::to_ident_bytes`] can reproduce the input byte-for-byte
911    /// regardless of any non-canonical whitespace or formatting it contained.
912    pub raw: Vec<u8>,
913}
914
915impl Signature {
916    /// Parse a raw git identity line (`Name <email> <unix-secs> <tz>`) into a
917    /// typed view, returning `None` when the bytes do not form a well-formed
918    /// identity.
919    ///
920    /// The splitting mirrors git's own `split_ident_line`: the email is the run
921    /// of bytes between the last `<` and the first following `>`; the name is
922    /// everything before that `<` (one separating space is dropped); after the
923    /// `>` come a space, the decimal Unix timestamp, a space, and the timezone
924    /// token. The name and email may legitimately be empty, but a missing
925    /// `<`/`>` pair, a non-numeric timestamp, or a malformed timezone token all
926    /// yield `None` rather than a lossy guess — this is a *best-effort* parse
927    /// that never panics. The original bytes are retained in
928    /// [`Signature::raw`] so the parsed view re-serializes byte-identically.
929    pub fn from_ident_line(line: &[u8]) -> Option<Self> {
930        // Email is delimited by the last '<' whose matching '>' follows it, the
931        // way git scans an ident from the right. Find the last '>' first, then
932        // the last '<' before it.
933        let mail_end = line.iter().rposition(|byte| *byte == b'>')?;
934        let mail_begin = line[..mail_end].iter().rposition(|byte| *byte == b'<')? + 1;
935        let email = &line[mail_begin..mail_end];
936
937        // The name is everything before the '<', with a single trailing space
938        // (the separator git inserts) trimmed if present.
939        let mut name_end = mail_begin.saturating_sub(1);
940        if name_end > 0 && line[name_end - 1] == b' ' {
941            name_end -= 1;
942        }
943        let name = &line[..name_end];
944
945        // After '>' git expects "<space><secs><space><tz>". Trim the single
946        // separating space, then split the timestamp from the timezone token.
947        let rest = line.get(mail_end + 1..)?;
948        let rest = rest.strip_prefix(b" ")?;
949        let time = GitTime::from_time_fields(rest)?;
950
951        Some(Self {
952            name: ByteString::new(name.to_vec()),
953            email: ByteString::new(email.to_vec()),
954            time,
955            raw: line.to_vec(),
956        })
957    }
958
959    /// Reproduce the original identity-line bytes.
960    ///
961    /// This returns [`Signature::raw`] verbatim, so for any line that
962    /// [`Signature::from_ident_line`] accepted, `from_ident_line(line)?
963    /// .to_ident_bytes() == line` holds byte-for-byte — including the `-0000`
964    /// timezone and any non-canonical spacing the source contained.
965    pub fn to_ident_bytes(&self) -> Vec<u8> {
966        self.raw.clone()
967    }
968
969    /// Re-derive the canonical ident line from the parsed fields alone
970    /// (`name <email> secs tz`), ignoring [`Signature::raw`].
971    ///
972    /// For an identity in git's canonical form this equals
973    /// [`Signature::to_ident_bytes`]; it differs only when the source line
974    /// carried non-canonical whitespace. Callers wanting byte-exact
975    /// reproduction should use [`Signature::to_ident_bytes`]; this is provided
976    /// for constructing a normalized line from typed parts.
977    pub fn to_canonical_ident_bytes(&self) -> Vec<u8> {
978        let mut out = Vec::with_capacity(self.raw.len());
979        out.extend_from_slice(self.name.as_bytes());
980        out.extend_from_slice(b" <");
981        out.extend_from_slice(self.email.as_bytes());
982        out.extend_from_slice(b"> ");
983        out.extend_from_slice(self.time.to_ident_suffix().as_bytes());
984        out
985    }
986}
987
988impl fmt::Display for Signature {
989    /// Renders the original ident line (lossy only for bytes that are not valid
990    /// UTF-8, which are replaced with `U+FFFD`). Use
991    /// [`Signature::to_ident_bytes`] for the exact bytes.
992    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
993        write!(f, "{}", String::from_utf8_lossy(&self.raw))
994    }
995}
996
997/// A git timestamp: a Unix time plus the committer's timezone offset.
998///
999/// The offset is stored as signed minutes east of UTC ([`timezone_offset_minutes`])
1000/// *and* a separate [`negative_utc`] flag. The flag exists because git
1001/// distinguishes the timezone token `-0000` from `+0000`: both are zero minutes
1002/// from UTC, but git writes `-0000` as a sentinel meaning "timezone unknown"
1003/// (e.g. for dates parsed without zone information), and that distinction is
1004/// part of a commit's byte-exact identity. `timezone_offset_minutes` alone
1005/// cannot represent it, so `negative_utc` carries the sign of a zero offset.
1006///
1007/// [`timezone_offset_minutes`]: GitTime::timezone_offset_minutes
1008/// [`negative_utc`]: GitTime::negative_utc
1009#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1010pub struct GitTime {
1011    /// Seconds since the Unix epoch.
1012    pub seconds: i64,
1013    /// Timezone offset east of UTC, in minutes (e.g. `+0530` -> `330`,
1014    /// `-0500` -> `-300`). Zero for both `+0000` and `-0000`; consult
1015    /// [`GitTime::negative_utc`] to tell those apart.
1016    pub timezone_offset_minutes: i16,
1017    /// `true` only when the timezone token had a negative sign with a zero
1018    /// magnitude (`-0000`), git's "timezone unknown" sentinel. Always `false`
1019    /// for any non-zero offset.
1020    pub negative_utc: bool,
1021}
1022
1023impl GitTime {
1024    /// A `GitTime` with the given seconds and minute offset, treating a zero
1025    /// offset as the ordinary `+0000` (not the `-0000` sentinel). Use
1026    /// [`GitTime::with_negative_utc`] to construct the `-0000` case.
1027    pub const fn new(seconds: i64, timezone_offset_minutes: i16) -> Self {
1028        Self {
1029            seconds,
1030            timezone_offset_minutes,
1031            negative_utc: false,
1032        }
1033    }
1034
1035    /// A `GitTime` whose timezone is the `-0000` sentinel ("timezone unknown").
1036    /// The minute offset is zero; `negative_utc` is `true`.
1037    pub const fn with_negative_utc(seconds: i64) -> Self {
1038        Self {
1039            seconds,
1040            timezone_offset_minutes: 0,
1041            negative_utc: true,
1042        }
1043    }
1044
1045    /// Parse the `<secs> <tz>` tail of an ident line (the bytes after the
1046    /// `"> "` separating the email from the time), returning `None` if either
1047    /// field is malformed.
1048    fn from_time_fields(bytes: &[u8]) -> Option<Self> {
1049        let text = std::str::from_utf8(bytes).ok()?;
1050        let (seconds_text, tz_text) = text.split_once(' ')?;
1051        let seconds = seconds_text.parse::<i64>().ok()?;
1052        let (timezone_offset_minutes, negative_utc) = parse_timezone_token(tz_text)?;
1053        Some(Self {
1054            seconds,
1055            timezone_offset_minutes,
1056            negative_utc,
1057        })
1058    }
1059
1060    /// The canonical `<secs> <±HHMM>` rendering of this time, as git writes it.
1061    /// Preserves the `-0000` sentinel.
1062    fn to_ident_suffix(self) -> String {
1063        format!("{} {}", self.seconds, self.offset_token())
1064    }
1065
1066    /// The canonical 5-character timezone token for this offset (sign plus four
1067    /// digits), e.g. `+0000`, `-0500`, `+0530`. Returns `-0000` when
1068    /// [`GitTime::negative_utc`] is set.
1069    pub fn offset_token(self) -> String {
1070        let sign = if self.negative_utc || self.timezone_offset_minutes < 0 {
1071            '-'
1072        } else {
1073            '+'
1074        };
1075        let magnitude = self.timezone_offset_minutes.unsigned_abs();
1076        format!("{sign}{:02}{:02}", magnitude / 60, magnitude % 60)
1077    }
1078}
1079
1080/// Parse a git timezone token (`±HHMM`) into `(minutes east of UTC, negative_utc)`.
1081///
1082/// Git accepts a leading `+`/`-` followed by four digits where the last two are
1083/// minutes. A negative sign with a zero magnitude (`-0000`) sets `negative_utc`.
1084/// Returns `None` for anything that is not a well-formed token.
1085fn parse_timezone_token(token: &str) -> Option<(i16, bool)> {
1086    let bytes = token.as_bytes();
1087    if bytes.len() != 5 {
1088        return None;
1089    }
1090    let negative = match bytes[0] {
1091        b'+' => false,
1092        b'-' => true,
1093        _ => return None,
1094    };
1095    if !bytes[1..].iter().all(u8::is_ascii_digit) {
1096        return None;
1097    }
1098    let hours = i16::from(bytes[1] - b'0') * 10 + i16::from(bytes[2] - b'0');
1099    let minutes = i16::from(bytes[3] - b'0') * 10 + i16::from(bytes[4] - b'0');
1100    let total = hours * 60 + minutes;
1101    let negative_utc = negative && total == 0;
1102    let signed = if negative { -total } else { total };
1103    Some((signed, negative_utc))
1104}
1105
1106#[derive(Debug, Clone, PartialEq, Eq)]
1107pub struct Capability {
1108    pub name: String,
1109    pub value: Option<String>,
1110}
1111
1112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1113pub enum MissingObjectKind {
1114    Object,
1115    Blob,
1116    Tree,
1117    Commit,
1118    Tag,
1119}
1120
1121impl MissingObjectKind {
1122    pub const fn as_str(self) -> &'static str {
1123        match self {
1124            Self::Object => "object",
1125            Self::Blob => "blob",
1126            Self::Tree => "tree",
1127            Self::Commit => "commit",
1128            Self::Tag => "tag",
1129        }
1130    }
1131}
1132
1133impl fmt::Display for MissingObjectKind {
1134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1135        f.write_str(self.as_str())
1136    }
1137}
1138
1139#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1140pub enum MissingObjectContext {
1141    Read,
1142    Traversal,
1143    PackInstall,
1144    RevisionWalk,
1145    WorktreeMaterialize,
1146    RemoteBoundary,
1147}
1148
1149impl MissingObjectContext {
1150    pub const fn as_str(self) -> &'static str {
1151        match self {
1152            Self::Read => "read",
1153            Self::Traversal => "traversal",
1154            Self::PackInstall => "pack-install",
1155            Self::RevisionWalk => "revision-walk",
1156            Self::WorktreeMaterialize => "worktree-materialize",
1157            Self::RemoteBoundary => "remote-boundary",
1158        }
1159    }
1160}
1161
1162impl fmt::Display for MissingObjectContext {
1163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1164        f.write_str(self.as_str())
1165    }
1166}
1167
1168#[derive(Debug, Clone, PartialEq, Eq)]
1169pub enum NotFoundKind {
1170    Message(String),
1171    Remote {
1172        name: String,
1173    },
1174    Object {
1175        oid: ObjectId,
1176        kind: MissingObjectKind,
1177        context: Option<MissingObjectContext>,
1178    },
1179    Reference {
1180        name: String,
1181    },
1182    Repository {
1183        path: String,
1184    },
1185}
1186
1187impl fmt::Display for NotFoundKind {
1188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1189        match self {
1190            Self::Message(msg) => write!(f, "{msg}"),
1191            Self::Remote { name } => write!(f, "remote {name}"),
1192            Self::Object {
1193                oid,
1194                kind: MissingObjectKind::Object,
1195                ..
1196            } => write!(f, "object {oid}"),
1197            Self::Object { oid, kind, .. } => write!(f, "{kind} object {oid}"),
1198            Self::Reference { name } => write!(f, "{name}"),
1199            Self::Repository { path } => write!(f, "{path}"),
1200        }
1201    }
1202}
1203
1204impl NotFoundKind {
1205    pub fn object_id(&self) -> Option<ObjectId> {
1206        match self {
1207            Self::Object { oid, .. } => Some(*oid),
1208            _ => None,
1209        }
1210    }
1211
1212    pub fn missing_object_kind(&self) -> Option<MissingObjectKind> {
1213        match self {
1214            Self::Object { kind, .. } => Some(*kind),
1215            _ => None,
1216        }
1217    }
1218
1219    pub fn missing_object_context(&self) -> Option<MissingObjectContext> {
1220        match self {
1221            Self::Object { context, .. } => *context,
1222            _ => None,
1223        }
1224    }
1225}
1226
1227/// Git-compatible CLI exit status. See `git help exit-code` for the upstream taxonomy.
1228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1229pub enum CliExit {
1230    /// Success (exit 0).
1231    Ok,
1232    /// User-facing fatal error (exit 128).
1233    UserError,
1234    /// Invalid usage / bad arguments (exit 129).
1235    Usage,
1236    /// Command-specific exit code (e.g. grep returning 1 when no matches).
1237    Custom(i32),
1238}
1239
1240impl CliExit {
1241    pub const fn code(self) -> i32 {
1242        match self {
1243            Self::Ok => 0,
1244            Self::UserError => 128,
1245            Self::Usage => 129,
1246            Self::Custom(code) => code,
1247        }
1248    }
1249}
1250
1251#[derive(Debug, Clone, PartialEq, Eq)]
1252pub enum GitError {
1253    Io(String),
1254    InvalidObjectId(String),
1255    InvalidObject(String),
1256    InvalidFormat(String),
1257    InvalidPath(String),
1258    Unsupported(String),
1259    NotFound(NotFoundKind),
1260    Transaction(String),
1261    Command(String),
1262    /// Typed CLI exit with a user-facing message printed by the binary entrypoint.
1263    Cli(CliExit, String),
1264    /// Legacy explicit exit code; the message (if any) was already printed by the command.
1265    Exit(i32),
1266}
1267
1268pub type Result<T> = std::result::Result<T, GitError>;
1269
1270impl fmt::Display for GitError {
1271    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1272        match self {
1273            Self::Io(msg) => write!(f, "io error: {msg}"),
1274            Self::InvalidObjectId(msg) => write!(f, "invalid object id: {msg}"),
1275            Self::InvalidObject(msg) => write!(f, "invalid object: {msg}"),
1276            Self::InvalidFormat(msg) => write!(f, "invalid format: {msg}"),
1277            Self::InvalidPath(msg) => write!(f, "invalid path: {msg}"),
1278            Self::Unsupported(msg) => write!(f, "unsupported: {msg}"),
1279            Self::NotFound(kind) => write!(f, "not found: {kind}"),
1280            Self::Transaction(msg) => write!(f, "transaction failed: {msg}"),
1281            Self::Command(msg) => write!(f, "command failed: {msg}"),
1282            Self::Cli(_, msg) => f.write_str(msg),
1283            Self::Exit(code) => write!(f, "exit {code}"),
1284        }
1285    }
1286}
1287
1288impl Error for GitError {}
1289
1290impl GitError {
1291    pub fn usage(msg: impl Into<String>) -> Self {
1292        Self::Cli(CliExit::Usage, msg.into())
1293    }
1294
1295    pub fn user_error(msg: impl Into<String>) -> Self {
1296        Self::Cli(CliExit::UserError, msg.into())
1297    }
1298
1299    pub fn cli_exit(kind: CliExit, msg: impl Into<String>) -> Self {
1300        Self::Cli(kind, msg.into())
1301    }
1302
1303    pub fn cli_exit_code(&self) -> i32 {
1304        cli_exit_code(self)
1305    }
1306
1307    pub fn not_found(msg: impl Into<String>) -> Self {
1308        Self::NotFound(NotFoundKind::Message(msg.into()))
1309    }
1310
1311    pub fn remote_not_found(name: impl Into<String>) -> Self {
1312        Self::NotFound(NotFoundKind::Remote { name: name.into() })
1313    }
1314
1315    pub fn object_not_found(oid: ObjectId) -> Self {
1316        Self::object_kind_not_found(oid, MissingObjectKind::Object)
1317    }
1318
1319    pub fn object_kind_not_found(oid: ObjectId, kind: MissingObjectKind) -> Self {
1320        Self::NotFound(NotFoundKind::Object {
1321            oid,
1322            kind,
1323            context: None,
1324        })
1325    }
1326
1327    pub fn object_not_found_in(oid: ObjectId, context: MissingObjectContext) -> Self {
1328        Self::object_kind_not_found_in(oid, MissingObjectKind::Object, context)
1329    }
1330
1331    pub fn object_kind_not_found_in(
1332        oid: ObjectId,
1333        kind: MissingObjectKind,
1334        context: MissingObjectContext,
1335    ) -> Self {
1336        Self::NotFound(NotFoundKind::Object {
1337            oid,
1338            kind,
1339            context: Some(context),
1340        })
1341    }
1342
1343    pub fn reference_not_found(name: impl Into<String>) -> Self {
1344        Self::NotFound(NotFoundKind::Reference { name: name.into() })
1345    }
1346
1347    pub fn repository_not_found(path: impl Into<String>) -> Self {
1348        Self::NotFound(NotFoundKind::Repository { path: path.into() })
1349    }
1350
1351    pub fn not_found_kind(&self) -> Option<&NotFoundKind> {
1352        match self {
1353            Self::NotFound(kind) => Some(kind),
1354            _ => None,
1355        }
1356    }
1357}
1358
1359impl From<std::io::Error> for GitError {
1360    fn from(value: std::io::Error) -> Self {
1361        Self::Io(value.to_string())
1362    }
1363}
1364
1365/// Map a [`GitError`] to the process exit code the CLI should use.
1366pub fn cli_exit_code(err: &GitError) -> i32 {
1367    match err {
1368        GitError::Exit(code) => *code,
1369        GitError::Cli(kind, _) => kind.code(),
1370        // During migration, usage-style validation still returns `Command`; treat as
1371        // general failure until those call sites adopt `GitError::usage`.
1372        GitError::Command(_) => 1,
1373        _ => 1,
1374    }
1375}
1376
1377pub fn object_id_for_bytes(
1378    format: ObjectFormat,
1379    object_type: &str,
1380    body: &[u8],
1381) -> Result<ObjectId> {
1382    match format {
1383        // Hash the `"<type> <len>\0"` header and the body as separate updates so
1384        // the (potentially large) body is never copied into a combined buffer just
1385        // to feed the digest.
1386        ObjectFormat::Sha1 => ObjectId::from_raw(format, &sha1_object_digest(object_type, body)),
1387        ObjectFormat::Sha256 => {
1388            let mut framed = Vec::with_capacity(object_type.len() + body.len() + 32);
1389            framed.extend_from_slice(object_type.as_bytes());
1390            framed.push(b' ');
1391            framed.extend_from_slice(body.len().to_string().as_bytes());
1392            framed.push(0);
1393            framed.extend_from_slice(body);
1394            ObjectId::from_raw(format, &sha256(&framed))
1395        }
1396    }
1397}
1398
1399pub fn digest_bytes(format: ObjectFormat, bytes: &[u8]) -> Result<ObjectId> {
1400    match format {
1401        ObjectFormat::Sha1 => ObjectId::from_raw(format, &sha1(bytes)),
1402        ObjectFormat::Sha256 => ObjectId::from_raw(format, &sha256(bytes)),
1403    }
1404}
1405
1406pub fn to_hex(bytes: &[u8]) -> String {
1407    let mut out = String::with_capacity(bytes.len() * 2);
1408    write_hex_bytes(bytes, &mut out).expect("writing hex to a String cannot fail");
1409    out
1410}
1411
1412fn write_hex_bytes(bytes: &[u8], out: &mut impl fmt::Write) -> fmt::Result {
1413    const HEX: &[u8; 16] = b"0123456789abcdef";
1414    for byte in bytes {
1415        out.write_char(HEX[(byte >> 4) as usize] as char)?;
1416        out.write_char(HEX[(byte & 0x0f) as usize] as char)?;
1417    }
1418    Ok(())
1419}
1420
1421fn hex_nibble_value(byte: u8) -> Option<u8> {
1422    match byte {
1423        b'0'..=b'9' => Some(byte - b'0'),
1424        b'a'..=b'f' => Some(byte - b'a' + 10),
1425        b'A'..=b'F' => Some(byte - b'A' + 10),
1426        _ => None,
1427    }
1428}
1429
1430fn hex_nibble(byte: u8) -> Result<u8> {
1431    hex_nibble_value(byte)
1432        .ok_or_else(|| GitError::InvalidObjectId(format!("non-hex byte {:?}", byte as char)))
1433}
1434
1435// ---------------------------------------------------------------------------
1436// SHA-1
1437//
1438// The default is a pure-Rust streaming implementation that hashes 64-byte blocks
1439// straight from the caller's slices, so neither the body nor the framed object is
1440// copied just to be digested. Enabling the `fast-sha1` feature swaps in the
1441// RustCrypto `sha1` crate, which dispatches to ARMv8-SHA1 / x86 SHA-NI at runtime;
1442// the digests are byte-identical, so OIDs are unchanged either way.
1443// ---------------------------------------------------------------------------
1444
1445/// SHA-1 of a raw byte slice (already-framed object, bundle prerequisite, etc.).
1446#[cfg(not(feature = "fast-sha1"))]
1447fn sha1(input: &[u8]) -> [u8; 20] {
1448    let mut hasher = Sha1Hasher::new();
1449    hasher.update(input);
1450    hasher.finalize()
1451}
1452
1453/// SHA-1 of a raw byte slice using the hardware-accelerated backend.
1454#[cfg(feature = "fast-sha1")]
1455fn sha1(input: &[u8]) -> [u8; 20] {
1456    use sha1::{Digest, Sha1};
1457    let mut hasher = Sha1::new();
1458    hasher.update(input);
1459    hasher.finalize().into()
1460}
1461
1462/// SHA-1 of a git object framed as `"<type> <len>\0<body>"`, fed as separate
1463/// updates so the body is never copied into a combined buffer.
1464#[cfg(not(feature = "fast-sha1"))]
1465fn sha1_object_digest(object_type: &str, body: &[u8]) -> [u8; 20] {
1466    let mut hasher = Sha1Hasher::new();
1467    hasher.update(object_type.as_bytes());
1468    hasher.update(b" ");
1469    hasher.update(body.len().to_string().as_bytes());
1470    hasher.update(&[0u8]);
1471    hasher.update(body);
1472    hasher.finalize()
1473}
1474
1475#[cfg(feature = "fast-sha1")]
1476fn sha1_object_digest(object_type: &str, body: &[u8]) -> [u8; 20] {
1477    use sha1::{Digest, Sha1};
1478    let mut hasher = Sha1::new();
1479    hasher.update(object_type.as_bytes());
1480    hasher.update(b" ");
1481    hasher.update(body.len().to_string().as_bytes());
1482    hasher.update([0u8]);
1483    hasher.update(body);
1484    hasher.finalize().into()
1485}
1486
1487/// Streaming pure-Rust SHA-1: feeds full 64-byte blocks directly from each
1488/// `update` slice and buffers only the sub-block remainder, so large inputs are
1489/// hashed without an intermediate copy.
1490#[cfg(not(feature = "fast-sha1"))]
1491struct Sha1Hasher {
1492    state: [u32; 5],
1493    block: [u8; 64],
1494    block_len: usize,
1495    total_len: u64,
1496}
1497
1498#[cfg(not(feature = "fast-sha1"))]
1499impl Sha1Hasher {
1500    fn new() -> Self {
1501        Self {
1502            state: [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0],
1503            block: [0u8; 64],
1504            block_len: 0,
1505            total_len: 0,
1506        }
1507    }
1508
1509    fn update(&mut self, mut data: &[u8]) {
1510        self.total_len = self.total_len.wrapping_add(data.len() as u64);
1511        if self.block_len > 0 {
1512            let take = (64 - self.block_len).min(data.len());
1513            self.block[self.block_len..self.block_len + take].copy_from_slice(&data[..take]);
1514            self.block_len += take;
1515            data = &data[take..];
1516            if self.block_len == 64 {
1517                let block = self.block;
1518                sha1_compress(&mut self.state, &block);
1519                self.block_len = 0;
1520            }
1521        }
1522        while data.len() >= 64 {
1523            sha1_compress(&mut self.state, &data[..64]);
1524            data = &data[64..];
1525        }
1526        if !data.is_empty() {
1527            self.block[..data.len()].copy_from_slice(data);
1528            self.block_len = data.len();
1529        }
1530    }
1531
1532    fn finalize(mut self) -> [u8; 20] {
1533        let bit_len = self.total_len.wrapping_mul(8);
1534        // 0x80, zero pad to a 56 mod 64 boundary, then the 64-bit big-endian length.
1535        // From a sub-block remainder this is at most two more blocks (128 bytes).
1536        let mut tail = [0u8; 128];
1537        tail[..self.block_len].copy_from_slice(&self.block[..self.block_len]);
1538        tail[self.block_len] = 0x80;
1539        let total = if self.block_len < 56 { 64 } else { 128 };
1540        tail[total - 8..total].copy_from_slice(&bit_len.to_be_bytes());
1541        sha1_compress(&mut self.state, &tail[..64]);
1542        if total == 128 {
1543            sha1_compress(&mut self.state, &tail[64..128]);
1544        }
1545        let mut out = [0u8; 20];
1546        out[0..4].copy_from_slice(&self.state[0].to_be_bytes());
1547        out[4..8].copy_from_slice(&self.state[1].to_be_bytes());
1548        out[8..12].copy_from_slice(&self.state[2].to_be_bytes());
1549        out[12..16].copy_from_slice(&self.state[3].to_be_bytes());
1550        out[16..20].copy_from_slice(&self.state[4].to_be_bytes());
1551        out
1552    }
1553}
1554
1555/// Mix one 64-byte block into the SHA-1 state. `block` must be at least 64 bytes.
1556#[cfg(not(feature = "fast-sha1"))]
1557fn sha1_compress(state: &mut [u32; 5], block: &[u8]) {
1558    let mut w = [0u32; 80];
1559    for (i, word) in w.iter_mut().take(16).enumerate() {
1560        let offset = i * 4;
1561        *word = u32::from_be_bytes([
1562            block[offset],
1563            block[offset + 1],
1564            block[offset + 2],
1565            block[offset + 3],
1566        ]);
1567    }
1568    for i in 16..80 {
1569        w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
1570    }
1571
1572    let mut a = state[0];
1573    let mut b = state[1];
1574    let mut c = state[2];
1575    let mut d = state[3];
1576    let mut e = state[4];
1577
1578    for (i, word) in w.iter().enumerate() {
1579        let (f, k) = match i {
1580            0..=19 => ((b & c) | ((!b) & d), 0x5a827999u32),
1581            20..=39 => (b ^ c ^ d, 0x6ed9eba1),
1582            40..=59 => ((b & c) | (b & d) | (c & d), 0x8f1bbcdc),
1583            _ => (b ^ c ^ d, 0xca62c1d6),
1584        };
1585        let temp = a
1586            .rotate_left(5)
1587            .wrapping_add(f)
1588            .wrapping_add(e)
1589            .wrapping_add(k)
1590            .wrapping_add(*word);
1591        e = d;
1592        d = c;
1593        c = b.rotate_left(30);
1594        b = a;
1595        a = temp;
1596    }
1597
1598    state[0] = state[0].wrapping_add(a);
1599    state[1] = state[1].wrapping_add(b);
1600    state[2] = state[2].wrapping_add(c);
1601    state[3] = state[3].wrapping_add(d);
1602    state[4] = state[4].wrapping_add(e);
1603}
1604
1605fn sha256(input: &[u8]) -> [u8; 32] {
1606    const K: [u32; 64] = [
1607        0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
1608        0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
1609        0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
1610        0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
1611        0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
1612        0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
1613        0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
1614        0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
1615        0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
1616        0xc67178f2,
1617    ];
1618
1619    let mut h = [
1620        0x6a09e667u32,
1621        0xbb67ae85,
1622        0x3c6ef372,
1623        0xa54ff53a,
1624        0x510e527f,
1625        0x9b05688c,
1626        0x1f83d9ab,
1627        0x5be0cd19,
1628    ];
1629
1630    let bit_len = (input.len() as u64) * 8;
1631    let mut msg = input.to_vec();
1632    msg.push(0x80);
1633    while msg.len() % 64 != 56 {
1634        msg.push(0);
1635    }
1636    msg.extend_from_slice(&bit_len.to_be_bytes());
1637
1638    for chunk in msg.chunks_exact(64) {
1639        let mut w = [0u32; 64];
1640        for (i, word) in w.iter_mut().take(16).enumerate() {
1641            let offset = i * 4;
1642            *word = u32::from_be_bytes([
1643                chunk[offset],
1644                chunk[offset + 1],
1645                chunk[offset + 2],
1646                chunk[offset + 3],
1647            ]);
1648        }
1649        for i in 16..64 {
1650            let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
1651            let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
1652            w[i] = w[i - 16]
1653                .wrapping_add(s0)
1654                .wrapping_add(w[i - 7])
1655                .wrapping_add(s1);
1656        }
1657
1658        let mut a = h[0];
1659        let mut b = h[1];
1660        let mut c = h[2];
1661        let mut d = h[3];
1662        let mut e = h[4];
1663        let mut f = h[5];
1664        let mut g = h[6];
1665        let mut hh = h[7];
1666
1667        for i in 0..64 {
1668            let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
1669            let ch = (e & f) ^ ((!e) & g);
1670            let temp1 = hh
1671                .wrapping_add(s1)
1672                .wrapping_add(ch)
1673                .wrapping_add(K[i])
1674                .wrapping_add(w[i]);
1675            let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
1676            let maj = (a & b) ^ (a & c) ^ (b & c);
1677            let temp2 = s0.wrapping_add(maj);
1678
1679            hh = g;
1680            g = f;
1681            f = e;
1682            e = d.wrapping_add(temp1);
1683            d = c;
1684            c = b;
1685            b = a;
1686            a = temp1.wrapping_add(temp2);
1687        }
1688
1689        h[0] = h[0].wrapping_add(a);
1690        h[1] = h[1].wrapping_add(b);
1691        h[2] = h[2].wrapping_add(c);
1692        h[3] = h[3].wrapping_add(d);
1693        h[4] = h[4].wrapping_add(e);
1694        h[5] = h[5].wrapping_add(f);
1695        h[6] = h[6].wrapping_add(g);
1696        h[7] = h[7].wrapping_add(hh);
1697    }
1698
1699    let mut out = [0; 32];
1700    for (idx, word) in h.iter().enumerate() {
1701        out[idx * 4..idx * 4 + 4].copy_from_slice(&word.to_be_bytes());
1702    }
1703    out
1704}
1705
1706#[cfg(test)]
1707mod tests {
1708    use super::*;
1709
1710    #[test]
1711    fn sha1_blob_matches_git_known_value() {
1712        let oid = object_id_for_bytes(ObjectFormat::Sha1, "blob", b"hello\n")
1713            .expect("known blob should hash as sha1");
1714        assert_eq!(oid.to_hex(), "ce013625030ba8dba906f756967f9e9ca394464a");
1715    }
1716
1717    #[test]
1718    fn sha256_blob_matches_git_known_value() {
1719        let oid = object_id_for_bytes(ObjectFormat::Sha256, "blob", b"hello\n")
1720            .expect("known blob should hash as sha256");
1721        assert_eq!(
1722            oid.to_hex(),
1723            "2cf8d83d9ee29543b34a87727421fdecb7e3f3a183d337639025de576db9ebb4"
1724        );
1725    }
1726
1727    #[test]
1728    fn object_id_round_trips_hex() {
1729        let oid = ObjectId::from_hex(
1730            ObjectFormat::Sha1,
1731            "ce013625030ba8dba906f756967f9e9ca394464a",
1732        )
1733        .expect("valid sha1 hex");
1734        assert_eq!(oid.to_hex(), "ce013625030ba8dba906f756967f9e9ca394464a");
1735    }
1736
1737    #[test]
1738    fn object_id_writes_hex_without_allocating_in_the_writer() {
1739        let oid = ObjectId::from_hex(
1740            ObjectFormat::Sha1,
1741            "CE013625030BA8DBA906F756967F9E9CA394464A",
1742        )
1743        .expect("valid uppercase sha1 hex");
1744
1745        let mut out = String::new();
1746        oid.write_hex(&mut out)
1747            .expect("writing object id hex to a String should not fail");
1748
1749        assert_eq!(out, "ce013625030ba8dba906f756967f9e9ca394464a");
1750        assert_eq!(oid.to_hex(), out);
1751        assert_eq!(format!("{oid}"), out);
1752    }
1753
1754    #[test]
1755    fn object_id_matches_hex_prefixes_by_nibble() {
1756        let oid = ObjectId::from_hex(
1757            ObjectFormat::Sha1,
1758            "ce013625030ba8dba906f756967f9e9ca394464a",
1759        )
1760        .expect("valid sha1 hex");
1761
1762        assert!(oid.hex_prefix_matches(b""));
1763        assert!(oid.hex_prefix_matches(b"c"));
1764        assert!(oid.hex_prefix_matches(b"ce013"));
1765        assert!(oid.hex_prefix_matches(b"CE013625"));
1766        assert!(oid.hex_prefix_matches(b"ce013625030ba8dba906f756967f9e9ca394464a"));
1767
1768        assert!(!oid.hex_prefix_matches(b"d"));
1769        assert!(!oid.hex_prefix_matches(b"ce014"));
1770        assert!(!oid.hex_prefix_matches(b"ce01x"));
1771
1772        let mut too_long = oid.to_hex();
1773        too_long.push('0');
1774        assert!(!oid.hex_prefix_matches(too_long.as_bytes()));
1775    }
1776
1777    #[test]
1778    fn object_id_abbrev_hex_len_clamps_to_format_width() {
1779        let sha1 = ObjectId::null(ObjectFormat::Sha1);
1780        let sha256 = ObjectId::null(ObjectFormat::Sha256);
1781
1782        assert_eq!(sha1.abbrev_hex_len(0), 0);
1783        assert_eq!(sha1.abbrev_hex_len(12), 12);
1784        assert_eq!(sha1.abbrev_hex_len(80), ObjectFormat::Sha1.hex_len());
1785        assert_eq!(sha256.abbrev_hex_len(80), ObjectFormat::Sha256.hex_len());
1786    }
1787
1788    #[test]
1789    fn signature_parses_a_normal_ident_and_round_trips() {
1790        let line = b"A U Thor <author@example.com> 1700000000 +0000";
1791        let sig = Signature::from_ident_line(line).expect("well-formed ident parses");
1792        assert_eq!(sig.name.as_bytes(), b"A U Thor");
1793        assert_eq!(sig.email.as_bytes(), b"author@example.com");
1794        assert_eq!(sig.time.seconds, 1_700_000_000);
1795        assert_eq!(sig.time.timezone_offset_minutes, 0);
1796        assert!(!sig.time.negative_utc);
1797        // Byte-exact round-trip, and the canonical form matches here too.
1798        assert_eq!(sig.to_ident_bytes(), line);
1799        assert_eq!(sig.to_canonical_ident_bytes(), line);
1800    }
1801
1802    #[test]
1803    fn signature_parses_positive_half_hour_offset() {
1804        let line = b"Half Hour <hh@example.com> 1500000000 +0530";
1805        let sig = Signature::from_ident_line(line).expect("offset ident parses");
1806        assert_eq!(sig.time.timezone_offset_minutes, 330);
1807        assert!(!sig.time.negative_utc);
1808        assert_eq!(sig.time.offset_token(), "+0530");
1809        assert_eq!(sig.to_ident_bytes(), line);
1810        assert_eq!(sig.to_canonical_ident_bytes(), line);
1811    }
1812
1813    #[test]
1814    fn signature_parses_negative_offset() {
1815        let line = b"Western <w@example.com> 1500000000 -0500";
1816        let sig = Signature::from_ident_line(line).expect("negative offset parses");
1817        assert_eq!(sig.time.timezone_offset_minutes, -300);
1818        assert!(!sig.time.negative_utc);
1819        assert_eq!(sig.time.offset_token(), "-0500");
1820        assert_eq!(sig.to_ident_bytes(), line);
1821    }
1822
1823    #[test]
1824    fn signature_preserves_negative_zero_timezone_distinct_from_positive_zero() {
1825        let negative = b"Unknown Zone <uz@example.com> 1500000000 -0000";
1826        let positive = b"Known Zone <kz@example.com> 1500000000 +0000";
1827
1828        let neg = Signature::from_ident_line(negative).expect("-0000 parses");
1829        let pos = Signature::from_ident_line(positive).expect("+0000 parses");
1830
1831        // Both are zero minutes from UTC...
1832        assert_eq!(neg.time.timezone_offset_minutes, 0);
1833        assert_eq!(pos.time.timezone_offset_minutes, 0);
1834        // ...but the sentinel flag distinguishes them, so the times differ.
1835        assert!(neg.time.negative_utc);
1836        assert!(!pos.time.negative_utc);
1837        assert_ne!(neg.time, pos.time);
1838
1839        // And the distinction survives re-serialization, byte-for-byte.
1840        assert_eq!(neg.time.offset_token(), "-0000");
1841        assert_eq!(pos.time.offset_token(), "+0000");
1842        assert_eq!(neg.to_ident_bytes(), negative);
1843        assert_eq!(pos.to_ident_bytes(), positive);
1844        assert_eq!(neg.to_canonical_ident_bytes(), negative);
1845        assert_eq!(pos.to_canonical_ident_bytes(), positive);
1846        assert_ne!(neg.to_ident_bytes(), pos.to_ident_bytes());
1847    }
1848
1849    #[test]
1850    fn signature_handles_empty_name_and_email() {
1851        // git permits an empty name and/or empty email; the delimiters still
1852        // anchor the parse.
1853        let line = b" <> 0 +0000";
1854        let sig = Signature::from_ident_line(line).expect("empty name/email parses");
1855        assert_eq!(sig.name.as_bytes(), b"");
1856        assert_eq!(sig.email.as_bytes(), b"");
1857        assert_eq!(sig.time.seconds, 0);
1858        assert_eq!(sig.to_ident_bytes(), line);
1859    }
1860
1861    #[test]
1862    fn signature_keeps_angle_brackets_inside_the_name() {
1863        // The email is delimited by the *last* '<'/'>' pair, so a name that
1864        // itself contains angle brackets parses with the trailing pair as the
1865        // email and round-trips exactly.
1866        let line = b"Weird <Name> <weird@example.com> 1 +0000";
1867        let sig = Signature::from_ident_line(line).expect("bracketed name parses");
1868        assert_eq!(sig.name.as_bytes(), b"Weird <Name>");
1869        assert_eq!(sig.email.as_bytes(), b"weird@example.com");
1870        assert_eq!(sig.to_ident_bytes(), line);
1871    }
1872
1873    #[test]
1874    fn signature_round_trips_non_canonical_whitespace_via_raw() {
1875        // An ident with two spaces before the email is not git's canonical form,
1876        // but the parse-view must still reproduce it byte-for-byte from `raw`.
1877        // (Only the canonical renderer normalizes the spacing.)
1878        let line = b"Spaced  <spaced@example.com> 5 +0000";
1879        let sig = Signature::from_ident_line(line).expect("non-canonical ident parses");
1880        // The name keeps the extra space (only one separator space is trimmed).
1881        assert_eq!(sig.name.as_bytes(), b"Spaced ");
1882        assert_eq!(sig.to_ident_bytes(), line);
1883    }
1884
1885    #[test]
1886    fn signature_rejects_malformed_idents() {
1887        // No email delimiters.
1888        assert!(Signature::from_ident_line(b"No Email Here 0 +0000").is_none());
1889        // Missing the time tail entirely.
1890        assert!(Signature::from_ident_line(b"A U Thor <a@example.com>").is_none());
1891        // Non-numeric timestamp.
1892        assert!(Signature::from_ident_line(b"A U Thor <a@example.com> later +0000").is_none());
1893        // Malformed timezone token (wrong width).
1894        assert!(Signature::from_ident_line(b"A U Thor <a@example.com> 0 +00").is_none());
1895        // Timezone token missing a sign.
1896        assert!(Signature::from_ident_line(b"A U Thor <a@example.com> 0 0000").is_none());
1897    }
1898
1899    #[test]
1900    fn git_time_constructors_set_the_sentinel() {
1901        assert!(!GitTime::new(0, 0).negative_utc);
1902        assert_eq!(GitTime::new(0, 330).offset_token(), "+0530");
1903        let unknown = GitTime::with_negative_utc(42);
1904        assert!(unknown.negative_utc);
1905        assert_eq!(unknown.seconds, 42);
1906        assert_eq!(unknown.offset_token(), "-0000");
1907    }
1908
1909    #[test]
1910    fn full_name_accepts_valid_ref_names() {
1911        let name = FullName::new("refs/heads/main").expect("valid ref name");
1912        assert_eq!(name.as_str(), "refs/heads/main");
1913        assert_eq!(name, "refs/heads/main");
1914        assert_eq!(format!("{name}"), "refs/heads/main");
1915        assert_eq!(String::from(name.clone()), "refs/heads/main");
1916        let borrowed: &str = name.borrow();
1917        assert_eq!(borrowed, "refs/heads/main");
1918    }
1919
1920    #[test]
1921    fn full_name_rejects_invalid_ref_names() {
1922        assert!(FullName::new("").is_err());
1923        assert!(FullName::new(" refs/heads/main").is_err());
1924        assert!(FullName::new("refs/heads/main ").is_err());
1925        assert!(FullName::new("refs//heads/main").is_err());
1926        assert!(FullName::new("refs/heads/\nmain").is_err());
1927    }
1928
1929    #[test]
1930    fn cli_exit_codes_match_git_taxonomy() {
1931        assert_eq!(CliExit::Ok.code(), 0);
1932        assert_eq!(CliExit::UserError.code(), 128);
1933        assert_eq!(CliExit::Usage.code(), 129);
1934        assert_eq!(CliExit::Custom(1).code(), 1);
1935        assert_eq!(CliExit::Custom(5).code(), 5);
1936    }
1937
1938    #[test]
1939    fn git_error_cli_exit_code_mapping() {
1940        assert_eq!(GitError::Exit(129).cli_exit_code(), 129);
1941        assert_eq!(GitError::Exit(128).cli_exit_code(), 128);
1942        assert_eq!(GitError::usage("unknown option").cli_exit_code(), 129);
1943        assert_eq!(
1944            GitError::user_error("not a git repository").cli_exit_code(),
1945            128
1946        );
1947        assert_eq!(
1948            GitError::cli_exit(CliExit::Custom(2), "diff found changes").cli_exit_code(),
1949            2
1950        );
1951        assert_eq!(GitError::Command("bad value".into()).cli_exit_code(), 1);
1952        assert_eq!(GitError::not_found("missing ref").cli_exit_code(), 1);
1953    }
1954
1955    #[test]
1956    fn git_error_cli_displays_message_only() {
1957        let err = GitError::usage("unknown option `--foo'");
1958        assert_eq!(err.to_string(), "unknown option `--foo'");
1959    }
1960
1961    #[test]
1962    fn bstring_round_trips_bytes_and_displays_lossily() {
1963        let path = BString::from_bytes(b"src/\xFF.txt");
1964        assert_eq!(path.as_bytes(), b"src/\xFF.txt");
1965        let borrowed: &[u8] = path.borrow();
1966        assert_eq!(borrowed, b"src/\xFF.txt".as_slice());
1967        assert_eq!(format!("{path}"), "src/\u{FFFD}.txt");
1968        assert_eq!(path, b"src/\xFF.txt");
1969        assert_eq!(path.clone().into_bytes(), b"src/\xFF.txt".to_vec());
1970    }
1971}