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