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.55.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    use std::path::PathBuf;
383
384    fn escape_json(raw: &str) -> String {
385        let mut out = String::with_capacity(raw.len());
386        for ch in raw.chars() {
387            match ch {
388                '"' => out.push_str("\\\""),
389                '\\' => out.push_str("\\\\"),
390                '\n' => out.push_str("\\n"),
391                '\t' => out.push_str("\\t"),
392                ch if (ch as u32) < 0x20 => {
393                    let _ = write!(out, "\\u{:04x}", ch as u32);
394                }
395                ch => out.push(ch),
396            }
397        }
398        out
399    }
400
401    fn trace_target(var: &str) -> Option<String> {
402        let target = std::env::var_os(var)?.to_string_lossy().into_owned();
403        // Upstream accepts fd and socket target spellings too; sley only honors
404        // absolute path targets in the test harness.
405        target.starts_with('/').then_some(target)
406    }
407
408    fn append_to_target(var: &str, line: &str) {
409        let Some(target) = trace_target(var) else {
410            return;
411        };
412        if let Ok(mut file) = std::fs::OpenOptions::new()
413            .create(true)
414            .append(true)
415            .open(target)
416        {
417            let _ = file.write_all(line.as_bytes());
418            let _ = file.write_all(b"\n");
419        }
420    }
421
422    fn redact_enabled() -> bool {
423        std::env::var("GIT_TRACE2_REDACT").map_or(true, |value| value != "0")
424    }
425
426    fn is_scheme_char(ch: char) -> bool {
427        ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.')
428    }
429
430    fn redact_unsafe_urls(raw: &str) -> String {
431        let mut out = String::with_capacity(raw.len());
432        let mut rest = raw;
433        while let Some(scheme_end) = rest.find("://") {
434            let scheme_start = rest[..scheme_end]
435                .char_indices()
436                .rev()
437                .find_map(|(idx, ch)| (!is_scheme_char(ch)).then_some(idx + ch.len_utf8()))
438                .unwrap_or(0);
439            out.push_str(&rest[..scheme_start]);
440
441            let authority_start = scheme_end + 3;
442            let authority_end = rest[authority_start..]
443                .find(|ch: char| matches!(ch, '/' | '?' | '#' | ' ' | '\t' | '\r' | '\n'))
444                .map(|idx| authority_start + idx)
445                .unwrap_or(rest.len());
446            let authority = &rest[authority_start..authority_end];
447            if let Some(at) = authority.rfind('@') {
448                out.push_str(&rest[scheme_start..authority_start]);
449                out.push_str("<redacted>@");
450                out.push_str(&authority[at + 1..]);
451            } else {
452                out.push_str(&rest[scheme_start..authority_end]);
453            }
454            rest = &rest[authority_end..];
455        }
456        out.push_str(rest);
457        out
458    }
459
460    fn maybe_redact(raw: &str) -> String {
461        if redact_enabled() {
462            redact_unsafe_urls(raw)
463        } else {
464            raw.to_string()
465        }
466    }
467
468    fn quote_arg(arg: &str) -> String {
469        if !arg.is_empty()
470            && !arg
471                .chars()
472                .any(|ch| ch.is_whitespace() || matches!(ch, '\'' | '"' | '\\'))
473        {
474            return arg.to_string();
475        }
476        let mut out = String::with_capacity(arg.len() + 2);
477        out.push('\'');
478        for ch in arg.chars() {
479            if ch == '\'' {
480                out.push_str("'\\''");
481            } else {
482                out.push(ch);
483            }
484        }
485        out.push('\'');
486        out
487    }
488
489    fn argv0() -> String {
490        let Some(arg0) = std::env::args_os().next() else {
491            return "sley".to_string();
492        };
493        let path = PathBuf::from(arg0);
494        path.file_name()
495            .map(|name| name.to_string_lossy().into_owned())
496            .filter(|name| !name.is_empty())
497            .unwrap_or_else(|| "sley".to_string())
498    }
499
500    fn render_argv(args: &[String]) -> String {
501        let mut rendered = Vec::with_capacity(args.len() + 1);
502        rendered.push(quote_arg(&argv0()));
503        rendered.extend(args.iter().map(|arg| quote_arg(arg)));
504        rendered.join(" ")
505    }
506
507    pub fn depth() -> usize {
508        std::env::var("SLEY_TRACE2_DEPTH")
509            .ok()
510            .and_then(|value| value.parse().ok())
511            .unwrap_or(0)
512    }
513
514    fn perf_line(depth: usize, event: &str, rest: &str) {
515        append_to_target(
516            "GIT_TRACE2_PERF",
517            &format!("d{depth} | main | {event} |  |  |  |  | {rest}"),
518        );
519    }
520
521    /// Create the trace2 targets when tracing is enabled, even if this command
522    /// emits no data/region/perf events — git opens the `GIT_TRACE2_EVENT` and
523    /// `GIT_TRACE2_PERF` files at startup, so consumers (and test cleanups that
524    /// `rm` the file) can rely on their existence.
525    pub fn touch() {
526        for var in ["GIT_TRACE2", "GIT_TRACE2_EVENT", "GIT_TRACE2_PERF"] {
527            let Some(target) = trace_target(var) else {
528                continue;
529            };
530            let _ = std::fs::OpenOptions::new()
531                .create(true)
532                .append(true)
533                .open(target);
534        }
535    }
536
537    /// Emit the small normal/perf `start` records that downstream tools commonly
538    /// use for argv auditing. Full trace2 lifecycle modelling remains out of
539    /// scope; these records intentionally cover the stable clone/status tests.
540    pub fn start(args: &[String]) {
541        let argv = maybe_redact(&render_argv(args));
542        append_to_target("GIT_TRACE2", &format!("start {argv}"));
543        perf_line(depth(), "start", &argv);
544    }
545
546    pub fn cmd_ancestry_at_depth(depth: usize, ancestry: &[String]) {
547        if ancestry.is_empty() {
548            return;
549        }
550        append_to_target(
551            "GIT_TRACE2",
552            &format!("cmd_ancestry {}", ancestry.join(" <- ")),
553        );
554        perf_line(
555            depth,
556            "cmd_ancestry",
557            &format!("ancestry:[{}]", ancestry.join(" ")),
558        );
559        let event_ancestry = ancestry
560            .iter()
561            .map(|name| format!("\"{}\"", escape_json(name)))
562            .collect::<Vec<_>>()
563            .join(",");
564        append_to_target(
565            "GIT_TRACE2_EVENT",
566            &format!(
567                "{{\"event\":\"cmd_ancestry\",\"sid\":\"sley\",\"thread\":\"main\",\"ancestry\":[{event_ancestry}]}}"
568            ),
569        );
570    }
571
572    pub fn cmd_name(name: &str, hierarchy: Option<&str>) {
573        let rest = match hierarchy {
574            Some(hierarchy) => format!("{name} ({hierarchy})"),
575            None => name.to_string(),
576        };
577        perf_line(depth(), "cmd_name", &rest);
578    }
579
580    pub fn cmd_name_at_depth(depth: usize, name: &str, hierarchy: Option<&str>) {
581        let rest = match hierarchy {
582            Some(hierarchy) => format!("{name} ({hierarchy})"),
583            None => name.to_string(),
584        };
585        perf_line(depth, "cmd_name", &rest);
586    }
587
588    pub fn child_start(class: &str, argv: &[String]) {
589        let argv = argv
590            .iter()
591            .map(|arg| maybe_redact(arg))
592            .collect::<Vec<_>>()
593            .join(" ");
594        perf_line(
595            depth(),
596            "child_start",
597            &format!("child_id:0 class:{class} argv:[{argv}]"),
598        );
599    }
600
601    pub fn alias(name: &str, argv: &[String]) {
602        let argv = argv
603            .iter()
604            .map(|arg| maybe_redact(arg))
605            .collect::<Vec<_>>()
606            .join(" ");
607        perf_line(depth(), "alias", &format!("alias:{name} argv:[{argv}]"));
608    }
609
610    /// Emit a trace2 config-parameter record to the normal and perf targets.
611    pub fn def_param(key: &str, value: impl Display) {
612        def_param_at_depth(depth(), key, value);
613    }
614
615    pub fn def_param_at_depth(depth: usize, key: &str, value: impl Display) {
616        let value = value.to_string();
617        let normal = maybe_redact(&format!("{key}={value}"));
618        append_to_target("GIT_TRACE2", &format!("def_param {normal}"));
619        let perf = maybe_redact(&format!("{key}:{value}"));
620        perf_line(depth, "def_param", &perf);
621    }
622
623    /// Emit a trace2 `data` event (upstream `trace2_data_string` /
624    /// `trace2_data_intmax`): a JSON line appended to the `GIT_TRACE2_EVENT`
625    /// file when that target is enabled.
626    pub fn data(category: &str, key: &str, value: impl Display) {
627        let Some(target) = trace_target("GIT_TRACE2_EVENT") else {
628            return;
629        };
630        let line = format!(
631            "{{\"event\":\"data\",\"sid\":\"sley\",\"thread\":\"main\",\"nesting\":1,\"category\":\"{}\",\"key\":\"{}\",\"value\":\"{}\"}}\n",
632            escape_json(category),
633            escape_json(key),
634            escape_json(&value.to_string()),
635        );
636        if let Ok(mut file) = std::fs::OpenOptions::new()
637            .create(true)
638            .append(true)
639            .open(&target)
640        {
641            let _ = file.write_all(line.as_bytes());
642        }
643    }
644
645    /// Emit a trace2 `counter` event. Git writes these for accumulated counters
646    /// such as fsync hardware flushes when the event target is enabled.
647    pub fn counter(category: &str, name: &str, count: impl Display) {
648        let Some(target) = trace_target("GIT_TRACE2_EVENT") else {
649            return;
650        };
651        let line = format!(
652            "{{\"event\":\"counter\",\"sid\":\"sley\",\"thread\":\"main\",\"category\":\"{}\",\"name\":\"{}\",\"count\":{}}}\n",
653            escape_json(category),
654            escape_json(name),
655            count,
656        );
657        if let Ok(mut file) = std::fs::OpenOptions::new()
658            .create(true)
659            .append(true)
660            .open(&target)
661        {
662            let _ = file.write_all(line.as_bytes());
663        }
664    }
665
666    /// Emit a trace2 region enter/leave pair. This is the minimal event shape
667    /// Git's `test_region` helper greps for when asserting sparse-index
668    /// expansion and conversion behaviour.
669    pub fn region(category: &str, label: &str) {
670        region_event("region_enter", category, label);
671        region_event("region_leave", category, label);
672    }
673
674    fn region_event(event: &str, category: &str, label: &str) {
675        let Some(target) = trace_target("GIT_TRACE2_EVENT") else {
676            return;
677        };
678        let line = format!(
679            "{{\"event\":\"{}\",\"sid\":\"sley\",\"thread\":\"main\",\"nesting\":1,\"category\":\"{}\",\"label\":\"{}\"}}\n",
680            escape_json(event),
681            escape_json(category),
682            escape_json(label),
683        );
684        if let Ok(mut file) = std::fs::OpenOptions::new()
685            .create(true)
686            .append(true)
687            .open(&target)
688        {
689            let _ = file.write_all(line.as_bytes());
690        }
691    }
692
693    /// Emit the trace2 perf payload used by Git's changed-path Bloom filter
694    /// tests. This intentionally writes only the grep-stable statistics string.
695    pub fn bloom_statistics(
696        filter_not_present: usize,
697        maybe: usize,
698        definitely_not: usize,
699        false_positive: usize,
700    ) {
701        let Some(target) = trace_target("GIT_TRACE2_PERF") else {
702            return;
703        };
704        let line = format!(
705            "statistics:{{\"filter_not_present\":{filter_not_present},\"maybe\":{maybe},\"definitely_not\":{definitely_not},\"false_positive\":{false_positive}}}\n"
706        );
707        if let Ok(mut file) = std::fs::OpenOptions::new()
708            .create(true)
709            .append(true)
710            .open(&target)
711        {
712            let _ = file.write_all(line.as_bytes());
713        }
714    }
715
716    /// Emit a compact trace2 perf `data` row for tests that extract the
717    /// read-directory statistics with pipe-field parsing.
718    pub fn perf_read_directory_data(key: &str, value: impl Display) {
719        let Some(target) = trace_target("GIT_TRACE2_PERF") else {
720            return;
721        };
722        let line = format!(
723            "19:00:00.000000 file.c:1 | d0 | main | data | r1 | ? | ? | read_directory | ....{key}:{value}\n"
724        );
725        if let Ok(mut file) = std::fs::OpenOptions::new()
726            .create(true)
727            .append(true)
728            .open(&target)
729        {
730            let _ = file.write_all(line.as_bytes());
731        }
732    }
733
734    /// Emit a trace2 perf `data` row tagged to the `setup` category (git's
735    /// `trace2_data_string("setup", ...)`), used for the
736    /// `implicit-bare-repository:<dir>` marker the safe.bareRepository tests
737    /// grep for. Only the grep-stable `<key>:<value>` tail is significant.
738    pub fn perf_setup_data(key: &str, value: impl Display) {
739        let Some(target) = trace_target("GIT_TRACE2_PERF") else {
740            return;
741        };
742        let line = format!(
743            "19:00:00.000000 setup.c:1 | d0 | main | data | r0 | ? | ? | setup | ....{key}:{value}\n"
744        );
745        if let Ok(mut file) = std::fs::OpenOptions::new()
746            .create(true)
747            .append(true)
748            .open(&target)
749        {
750            let _ = file.write_all(line.as_bytes());
751        }
752    }
753}
754
755#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
756pub enum ObjectFormat {
757    Sha1,
758    Sha256,
759}
760
761impl ObjectFormat {
762    pub const fn raw_len(self) -> usize {
763        match self {
764            Self::Sha1 => 20,
765            Self::Sha256 => 32,
766        }
767    }
768
769    pub const fn hex_len(self) -> usize {
770        self.raw_len() * 2
771    }
772
773    pub const fn name(self) -> &'static str {
774        match self {
775            Self::Sha1 => "sha1",
776            Self::Sha256 => "sha256",
777        }
778    }
779}
780
781impl FromStr for ObjectFormat {
782    type Err = GitError;
783
784    fn from_str(value: &str) -> Result<Self> {
785        match value {
786            "sha1" => Ok(Self::Sha1),
787            "sha256" => Ok(Self::Sha256),
788            other => Err(GitError::Unsupported(format!("object format {other}"))),
789        }
790    }
791}
792
793#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
794pub struct ObjectId {
795    format: ObjectFormat,
796    bytes: [u8; 32],
797}
798
799impl ObjectId {
800    pub fn from_raw(format: ObjectFormat, raw: &[u8]) -> Result<Self> {
801        if raw.len() != format.raw_len() {
802            return Err(GitError::InvalidObjectId(format!(
803                "expected {} bytes for {}, got {}",
804                format.raw_len(),
805                format.name(),
806                raw.len()
807            )));
808        }
809        let mut bytes = [0; 32];
810        bytes[..raw.len()].copy_from_slice(raw);
811        Ok(Self { format, bytes })
812    }
813
814    pub fn from_hex(format: ObjectFormat, hex: &str) -> Result<Self> {
815        if hex.len() != format.hex_len() {
816            return Err(GitError::InvalidObjectId(format!(
817                "expected {} hex digits for {}, got {}",
818                format.hex_len(),
819                format.name(),
820                hex.len()
821            )));
822        }
823        let mut raw = [0; 32];
824        for (i, pair) in hex.as_bytes().chunks_exact(2).enumerate() {
825            raw[i] = (hex_nibble(pair[0])? << 4) | hex_nibble(pair[1])?;
826        }
827        Ok(Self { format, bytes: raw })
828    }
829
830    pub const fn format(&self) -> ObjectFormat {
831        self.format
832    }
833
834    pub fn as_bytes(&self) -> &[u8] {
835        &self.bytes[..self.format.raw_len()]
836    }
837
838    pub fn to_hex(&self) -> String {
839        let mut out = String::with_capacity(self.format.hex_len());
840        self.write_hex(&mut out)
841            .expect("writing object id hex to a String cannot fail");
842        out
843    }
844
845    pub fn write_hex(&self, out: &mut impl fmt::Write) -> fmt::Result {
846        write_hex_bytes(self.as_bytes(), out)
847    }
848
849    pub fn hex_prefix_matches(&self, prefix: &[u8]) -> bool {
850        if prefix.len() > self.format.hex_len() {
851            return false;
852        }
853
854        prefix.iter().enumerate().all(|(index, expected)| {
855            let Some(expected) = hex_nibble_value(*expected) else {
856                return false;
857            };
858            let byte = self.as_bytes()[index / 2];
859            let actual = if index % 2 == 0 {
860                byte >> 4
861            } else {
862                byte & 0x0f
863            };
864            actual == expected
865        })
866    }
867
868    pub const fn abbrev_hex_len(&self, width: usize) -> usize {
869        let hex_len = self.format.hex_len();
870        if width < hex_len { width } else { hex_len }
871    }
872
873    /// The all-zero ("null") object id for `format`.
874    pub fn null(format: ObjectFormat) -> Self {
875        Self {
876            format,
877            bytes: [0; 32],
878        }
879    }
880
881    /// True when every byte is zero (the null oid).
882    pub fn is_null(&self) -> bool {
883        self.as_bytes().iter().all(|byte| *byte == 0)
884    }
885
886    /// The id of the canonical empty tree for `format` (`4b825dc6…` for SHA-1).
887    pub fn empty_tree(format: ObjectFormat) -> Self {
888        Self::digest_object(format, "tree", b"")
889    }
890
891    /// The id of the canonical empty blob for `format` (`e69de29b…` for SHA-1).
892    pub fn empty_blob(format: ObjectFormat) -> Self {
893        Self::digest_object(format, "blob", b"")
894    }
895
896    /// Hash `"<type> <len>\0<body>"` straight into an id, bypassing the
897    /// fallible length check in [`ObjectId::from_raw`] (our own digests are
898    /// always the right length) so the well-known constants stay infallible.
899    fn digest_object(format: ObjectFormat, object_type: &str, body: &[u8]) -> Self {
900        let mut framed = Vec::with_capacity(object_type.len() + body.len() + 32);
901        framed.extend_from_slice(object_type.as_bytes());
902        framed.push(b' ');
903        framed.extend_from_slice(body.len().to_string().as_bytes());
904        framed.push(0);
905        framed.extend_from_slice(body);
906        let mut bytes = [0u8; 32];
907        match format {
908            ObjectFormat::Sha1 => bytes[..20].copy_from_slice(&sha1(&framed)),
909            ObjectFormat::Sha256 => bytes[..32].copy_from_slice(&sha256(&framed)),
910        }
911        Self { format, bytes }
912    }
913}
914
915impl fmt::Debug for ObjectId {
916    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
917        f.debug_tuple("ObjectId").field(&self.to_hex()).finish()
918    }
919}
920
921impl fmt::Display for ObjectId {
922    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
923        self.write_hex(f)
924    }
925}
926
927impl FromStr for ObjectId {
928    type Err = GitError;
929
930    /// Parse a full hex id, inferring the hash from its length (40 hex digits =
931    /// SHA-1, 64 = SHA-256).
932    fn from_str(text: &str) -> Result<Self> {
933        let format = match text.len() {
934            40 => ObjectFormat::Sha1,
935            64 => ObjectFormat::Sha256,
936            other => {
937                return Err(GitError::InvalidObjectId(format!(
938                    "expected 40 or 64 hex digits, got {other}"
939                )));
940            }
941        };
942        Self::from_hex(format, text)
943    }
944}
945
946#[derive(Debug, Clone, PartialEq, Eq)]
947pub struct ByteString(Vec<u8>);
948
949impl ByteString {
950    pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
951        Self(bytes.into())
952    }
953
954    pub fn as_bytes(&self) -> &[u8] {
955        &self.0
956    }
957}
958
959impl From<&str> for ByteString {
960    fn from(value: &str) -> Self {
961        Self(value.as_bytes().to_vec())
962    }
963}
964
965/// A validated git ref name (e.g. `refs/heads/main`, `HEAD`).
966#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
967pub struct FullName(String);
968
969impl FullName {
970    /// Construct a ref name, rejecting empty names, ASCII control characters,
971    /// leading/trailing whitespace, and consecutive slashes.
972    pub fn new(name: impl AsRef<str>) -> Result<Self> {
973        let name = name.as_ref();
974        validate_full_name(name)?;
975        Ok(Self(name.to_string()))
976    }
977
978    pub fn as_str(&self) -> &str {
979        &self.0
980    }
981}
982
983impl fmt::Debug for FullName {
984    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
985        f.debug_tuple("FullName").field(&self.0).finish()
986    }
987}
988
989impl fmt::Display for FullName {
990    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
991        f.write_str(&self.0)
992    }
993}
994
995impl From<FullName> for String {
996    fn from(value: FullName) -> Self {
997        value.0
998    }
999}
1000
1001impl Borrow<str> for FullName {
1002    fn borrow(&self) -> &str {
1003        &self.0
1004    }
1005}
1006
1007impl AsRef<str> for FullName {
1008    fn as_ref(&self) -> &str {
1009        &self.0
1010    }
1011}
1012
1013impl TryFrom<&str> for FullName {
1014    type Error = GitError;
1015
1016    fn try_from(value: &str) -> Result<Self> {
1017        Self::new(value)
1018    }
1019}
1020
1021impl TryFrom<String> for FullName {
1022    type Error = GitError;
1023
1024    fn try_from(value: String) -> Result<Self> {
1025        validate_full_name(&value)?;
1026        Ok(Self(value))
1027    }
1028}
1029
1030impl PartialEq<&str> for FullName {
1031    fn eq(&self, other: &&str) -> bool {
1032        self.0 == *other
1033    }
1034}
1035
1036impl PartialEq<FullName> for &str {
1037    fn eq(&self, other: &FullName) -> bool {
1038        *self == other.0
1039    }
1040}
1041
1042fn validate_full_name(name: &str) -> Result<()> {
1043    if name.is_empty() {
1044        return Err(GitError::InvalidFormat("ref name must not be empty".into()));
1045    }
1046    if name.chars().next().is_some_and(|ch| ch.is_whitespace())
1047        || name.chars().last().is_some_and(|ch| ch.is_whitespace())
1048    {
1049        return Err(GitError::InvalidFormat(
1050            "ref name must not have leading or trailing whitespace".into(),
1051        ));
1052    }
1053    if name.contains("//") {
1054        return Err(GitError::InvalidFormat(
1055            "ref name must not contain consecutive slashes".into(),
1056        ));
1057    }
1058    if name.bytes().any(|byte| byte.is_ascii_control()) {
1059        return Err(GitError::InvalidFormat(
1060            "ref name must not contain control characters".into(),
1061        ));
1062    }
1063    Ok(())
1064}
1065
1066/// A byte string for git paths and similar on-disk identifiers.
1067#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
1068pub struct BString(Vec<u8>);
1069
1070impl BString {
1071    pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
1072        Self(bytes.into())
1073    }
1074    pub fn from_bytes(bytes: &[u8]) -> Self {
1075        Self(bytes.to_vec())
1076    }
1077    pub fn as_bytes(&self) -> &[u8] {
1078        &self.0
1079    }
1080    pub fn len(&self) -> usize {
1081        self.0.len()
1082    }
1083    pub fn is_empty(&self) -> bool {
1084        self.0.is_empty()
1085    }
1086    pub fn into_bytes(self) -> Vec<u8> {
1087        self.0
1088    }
1089}
1090
1091impl From<&str> for BString {
1092    fn from(v: &str) -> Self {
1093        Self::from_bytes(v.as_bytes())
1094    }
1095}
1096impl From<&[u8]> for BString {
1097    fn from(v: &[u8]) -> Self {
1098        Self::from_bytes(v)
1099    }
1100}
1101impl<const N: usize> From<&[u8; N]> for BString {
1102    fn from(v: &[u8; N]) -> Self {
1103        Self::from_bytes(v.as_slice())
1104    }
1105}
1106impl From<Vec<u8>> for BString {
1107    fn from(v: Vec<u8>) -> Self {
1108        Self(v)
1109    }
1110}
1111impl PartialEq<&[u8]> for BString {
1112    fn eq(&self, o: &&[u8]) -> bool {
1113        self.0.as_slice() == *o
1114    }
1115}
1116impl<const N: usize> PartialEq<&[u8; N]> for BString {
1117    fn eq(&self, o: &&[u8; N]) -> bool {
1118        self.as_bytes() == o.as_slice()
1119    }
1120}
1121impl PartialEq<BString> for &[u8] {
1122    fn eq(&self, o: &BString) -> bool {
1123        *self == o.as_bytes()
1124    }
1125}
1126impl<const N: usize> PartialEq<BString> for &[u8; N] {
1127    fn eq(&self, o: &BString) -> bool {
1128        self.as_slice() == o.as_bytes()
1129    }
1130}
1131impl PartialEq<Vec<u8>> for BString {
1132    fn eq(&self, o: &Vec<u8>) -> bool {
1133        self.0 == *o
1134    }
1135}
1136impl PartialEq<BString> for Vec<u8> {
1137    fn eq(&self, o: &BString) -> bool {
1138        *self == o.0
1139    }
1140}
1141
1142impl fmt::Display for BString {
1143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1144        write!(f, "{}", String::from_utf8_lossy(&self.0))
1145    }
1146}
1147
1148impl Borrow<[u8]> for BString {
1149    fn borrow(&self) -> &[u8] {
1150        self.as_bytes()
1151    }
1152}
1153
1154impl Deref for BString {
1155    type Target = [u8];
1156
1157    fn deref(&self) -> &[u8] {
1158        self.as_bytes()
1159    }
1160}
1161
1162impl AsRef<[u8]> for BString {
1163    fn as_ref(&self) -> &[u8] {
1164        self.as_bytes()
1165    }
1166}
1167
1168#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1169pub struct RepoPath(PathBuf);
1170
1171impl RepoPath {
1172    pub fn new(path: impl Into<PathBuf>) -> Result<Self> {
1173        let path = path.into();
1174        if path.is_absolute() {
1175            return Err(GitError::InvalidPath(
1176                "repository paths must be relative".into(),
1177            ));
1178        }
1179        if path.components().any(|component| {
1180            matches!(
1181                component,
1182                std::path::Component::ParentDir | std::path::Component::Prefix(_)
1183            )
1184        }) {
1185            return Err(GitError::InvalidPath(
1186                "repository paths must not escape".into(),
1187            ));
1188        }
1189        Ok(Self(path))
1190    }
1191
1192    pub fn as_path(&self) -> &Path {
1193        &self.0
1194    }
1195}
1196
1197/// A typed *parse-view* of a git identity line (`Name <email> <secs> <tz>`) as
1198/// found on a commit's `author`/`committer` or a tag's `tagger` header.
1199///
1200/// This is a read-only lens over bytes that are stored and re-serialized
1201/// verbatim elsewhere (see [`Signature::raw`]). It exists so callers can read
1202/// the typed `name`/`email`/`time` of an identity without re-implementing git's
1203/// ident-splitting rules, *not* as a storage format: the object model keeps the
1204/// original raw bytes as its source of truth, and round-tripping through this
1205/// view is byte-exact precisely because the raw line is retained alongside the
1206/// parsed fields (see [`Signature::to_ident_bytes`]).
1207///
1208/// Parse one with [`Signature::from_ident_line`]. The `time`'s timezone
1209/// preserves git's distinction between `+0000` (UTC) and `-0000` (a sentinel git
1210/// writes to mean "timezone unknown"); see [`GitTime`].
1211#[derive(Debug, Clone, PartialEq, Eq)]
1212pub struct Signature {
1213    /// The identity's name: the bytes before the ` <` that opens the email,
1214    /// with one trailing space (the separator) removed. May be empty.
1215    pub name: ByteString,
1216    /// The identity's email: the bytes between the `<` and `>` delimiters. May
1217    /// be empty.
1218    pub email: ByteString,
1219    /// The commit/authorship time and its timezone offset.
1220    pub time: GitTime,
1221    /// The exact original ident-line bytes this view was parsed from, retained
1222    /// so [`Signature::to_ident_bytes`] can reproduce the input byte-for-byte
1223    /// regardless of any non-canonical whitespace or formatting it contained.
1224    pub raw: Vec<u8>,
1225}
1226
1227impl Signature {
1228    /// Parse a raw git identity line (`Name <email> <unix-secs> <tz>`) into a
1229    /// typed view, returning `None` when the bytes do not form a well-formed
1230    /// identity.
1231    ///
1232    /// The splitting mirrors git's own `split_ident_line`: the email is the run
1233    /// of bytes between the last `<` and the first following `>`; the name is
1234    /// everything before that `<` (one separating space is dropped); after the
1235    /// `>` come a space, the decimal Unix timestamp, a space, and the timezone
1236    /// token. The name and email may legitimately be empty, but a missing
1237    /// `<`/`>` pair, a non-numeric timestamp, or a malformed timezone token all
1238    /// yield `None` rather than a lossy guess — this is a *best-effort* parse
1239    /// that never panics. The original bytes are retained in
1240    /// [`Signature::raw`] so the parsed view re-serializes byte-identically.
1241    pub fn from_ident_line(line: &[u8]) -> Option<Self> {
1242        // Email is delimited by the last '<' whose matching '>' follows it, the
1243        // way git scans an ident from the right. Find the last '>' first, then
1244        // the last '<' before it.
1245        let mail_end = line.iter().rposition(|byte| *byte == b'>')?;
1246        let mail_begin = line[..mail_end].iter().rposition(|byte| *byte == b'<')? + 1;
1247        let email = &line[mail_begin..mail_end];
1248
1249        // The name is everything before the '<', with a single trailing space
1250        // (the separator git inserts) trimmed if present.
1251        let mut name_end = mail_begin.saturating_sub(1);
1252        if name_end > 0 && line[name_end - 1] == b' ' {
1253            name_end -= 1;
1254        }
1255        let name = &line[..name_end];
1256
1257        // After '>' git expects "<space><secs><space><tz>". Trim the single
1258        // separating space, then split the timestamp from the timezone token.
1259        let rest = line.get(mail_end + 1..)?;
1260        let rest = rest.strip_prefix(b" ")?;
1261        let time = GitTime::from_time_fields(rest)?;
1262
1263        Some(Self {
1264            name: ByteString::new(name.to_vec()),
1265            email: ByteString::new(email.to_vec()),
1266            time,
1267            raw: line.to_vec(),
1268        })
1269    }
1270
1271    /// Reproduce the original identity-line bytes.
1272    ///
1273    /// This returns [`Signature::raw`] verbatim, so for any line that
1274    /// [`Signature::from_ident_line`] accepted, `from_ident_line(line)?
1275    /// .to_ident_bytes() == line` holds byte-for-byte — including the `-0000`
1276    /// timezone and any non-canonical spacing the source contained.
1277    pub fn to_ident_bytes(&self) -> Vec<u8> {
1278        self.raw.clone()
1279    }
1280
1281    /// Re-derive the canonical ident line from the parsed fields alone
1282    /// (`name <email> secs tz`), ignoring [`Signature::raw`].
1283    ///
1284    /// For an identity in git's canonical form this equals
1285    /// [`Signature::to_ident_bytes`]; it differs only when the source line
1286    /// carried non-canonical whitespace. Callers wanting byte-exact
1287    /// reproduction should use [`Signature::to_ident_bytes`]; this is provided
1288    /// for constructing a normalized line from typed parts.
1289    pub fn to_canonical_ident_bytes(&self) -> Vec<u8> {
1290        let mut out = Vec::with_capacity(self.raw.len());
1291        out.extend_from_slice(self.name.as_bytes());
1292        out.extend_from_slice(b" <");
1293        out.extend_from_slice(self.email.as_bytes());
1294        out.extend_from_slice(b"> ");
1295        out.extend_from_slice(self.time.to_ident_suffix().as_bytes());
1296        out
1297    }
1298}
1299
1300/// A tolerant parse-view of a git identity line split git's way (ident.c's
1301/// `split_ident_line`). Unlike [`Signature::from_ident_line`] — which is a
1302/// strict, byte-exact round-trip parser — this mirrors how git's pretty-printer
1303/// recovers fields from *broken* idents: the email is the run between the
1304/// **first** `<` and the **first** following `>`, while the timestamp is located
1305/// by scanning **backwards** from the end of the line for the **last** `>`. That
1306/// split lets a corrupt ident like `Name <a@b>-<> 123 +0000` still surrender the
1307/// correct name (`Name`), email (`a@b`), and date (`123 +0000`).
1308pub struct IdentFields<'a> {
1309    /// Everything before the first `<`, with one trailing separator space removed.
1310    pub name: &'a [u8],
1311    /// The bytes between the first `<` and the first following `>`.
1312    pub email: &'a [u8],
1313    /// The decimal timestamp digit-run, or `None` when the line has no parseable
1314    /// `<digits> <±digits>` date tail (git's "person only" case).
1315    pub date: Option<&'a [u8]>,
1316    /// The timezone token (`±` plus digits), present iff `date` is.
1317    pub tz: Option<&'a [u8]>,
1318}
1319
1320/// True for the whitespace bytes git's `isspace` recognizes (space, tab,
1321/// newline, carriage return). This deliberately excludes vertical tab (`0x0b`)
1322/// and form feed (`0x0c`), matching git's `sane_ctype` table — the distinction
1323/// that makes a vertical-tab-only date a sentinel rather than valid whitespace.
1324fn ident_isspace(byte: u8) -> bool {
1325    matches!(byte, b' ' | b'\t' | b'\n' | b'\r')
1326}
1327
1328/// Split a git identity line the way ident.c's `split_ident_line` does,
1329/// returning `None` only when the line has no `<` or no following `>` (git's
1330/// `status < 0`). The date/timezone fields are `None` for the "person only"
1331/// case where no valid timestamp follows the final `>`.
1332pub fn split_ident_line(line: &[u8]) -> Option<IdentFields<'_>> {
1333    let len = line.len();
1334    // mail_begin: just past the first '<'.
1335    let lt = line.iter().position(|&byte| byte == b'<')?;
1336    let mail_begin = lt + 1;
1337
1338    // name_end: the last non-space byte before '<' (git scans down from
1339    // mail_begin-2); default to the '<' position when only spaces precede it.
1340    let mut name_end = mail_begin - 1;
1341    if mail_begin >= 2 {
1342        let mut i = mail_begin - 2;
1343        loop {
1344            if !ident_isspace(line[i]) {
1345                name_end = i + 1;
1346                break;
1347            }
1348            if i == 0 {
1349                break;
1350            }
1351            i -= 1;
1352        }
1353    }
1354    let name = &line[..name_end];
1355
1356    // mail_end: first '>' at or after mail_begin.
1357    let gt = line[mail_begin..].iter().position(|&byte| byte == b'>')? + mail_begin;
1358    let email = &line[mail_begin..gt];
1359
1360    let person_only = IdentFields {
1361        name,
1362        email,
1363        date: None,
1364        tz: None,
1365    };
1366
1367    // Date: scan from the end of the line for the LAST '>', then parse a
1368    // "<digits> <±digits>" tail after it (git assumes the timestamp has no '>').
1369    let mut cp = len - 1;
1370    while line[cp] != b'>' {
1371        if cp == 0 {
1372            return Some(person_only);
1373        }
1374        cp -= 1;
1375    }
1376    let mut i = cp + 1;
1377    while i < len && ident_isspace(line[i]) {
1378        i += 1;
1379    }
1380    let date_begin = i;
1381    while i < len && line[i].is_ascii_digit() {
1382        i += 1;
1383    }
1384    if i == date_begin {
1385        return Some(person_only);
1386    }
1387    let date = &line[date_begin..i];
1388
1389    while i < len && ident_isspace(line[i]) {
1390        i += 1;
1391    }
1392    if i >= len || (line[i] != b'+' && line[i] != b'-') {
1393        return Some(person_only);
1394    }
1395    let tz_begin = i;
1396    i += 1;
1397    let tz_digits = i;
1398    while i < len && line[i].is_ascii_digit() {
1399        i += 1;
1400    }
1401    if i == tz_digits {
1402        return Some(person_only);
1403    }
1404    Some(IdentFields {
1405        name,
1406        email,
1407        date: Some(date),
1408        tz: Some(&line[tz_begin..i]),
1409    })
1410}
1411
1412/// True when a timestamp is too large to be a valid `time_t`, mirroring git's
1413/// `date_overflows` for a 64-bit signed `time_t`.
1414fn ident_date_overflows(seconds: u64) -> bool {
1415    seconds >= i64::MAX as u64
1416}
1417
1418/// Render an ident's date the way pretty.c's `show_ident_date` does: parse the
1419/// timestamp (git's `parse_timestamp` is unsigned/base-10 and clamps on
1420/// overflow), substitute the epoch sentinel (`time = 0`, timezone `+0000`) when
1421/// the value overflows what a `time_t` can hold, then format per `mode`. `date`
1422/// is the timestamp digit-run and `tz` its timezone token (as returned by
1423/// [`split_ident_line`]).
1424pub fn ident_render_date(date: &[u8], tz: &[u8], mode: &DateMode) -> String {
1425    let parsed = std::str::from_utf8(date)
1426        .ok()
1427        .and_then(|text| text.parse::<u64>().ok());
1428    let (seconds, tz_text) = match parsed {
1429        Some(value) if !ident_date_overflows(value) => {
1430            (value as i64, std::str::from_utf8(tz).unwrap_or("+0000"))
1431        }
1432        // Overflow, or a digit-run too long for u64: the epoch sentinel with a
1433        // forced `+0000` timezone, exactly like git's show_ident_date.
1434        _ => (0, "+0000"),
1435    };
1436    mode.render(seconds, tz_text).unwrap_or_default()
1437}
1438
1439impl fmt::Display for Signature {
1440    /// Renders the original ident line (lossy only for bytes that are not valid
1441    /// UTF-8, which are replaced with `U+FFFD`). Use
1442    /// [`Signature::to_ident_bytes`] for the exact bytes.
1443    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1444        write!(f, "{}", String::from_utf8_lossy(&self.raw))
1445    }
1446}
1447
1448/// A git timestamp: a Unix time plus the committer's timezone offset.
1449///
1450/// The offset is stored as signed minutes east of UTC ([`timezone_offset_minutes`])
1451/// *and* a separate [`negative_utc`] flag. The flag exists because git
1452/// distinguishes the timezone token `-0000` from `+0000`: both are zero minutes
1453/// from UTC, but git writes `-0000` as a sentinel meaning "timezone unknown"
1454/// (e.g. for dates parsed without zone information), and that distinction is
1455/// part of a commit's byte-exact identity. `timezone_offset_minutes` alone
1456/// cannot represent it, so `negative_utc` carries the sign of a zero offset.
1457///
1458/// [`timezone_offset_minutes`]: GitTime::timezone_offset_minutes
1459/// [`negative_utc`]: GitTime::negative_utc
1460#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1461pub struct GitTime {
1462    /// Seconds since the Unix epoch.
1463    pub seconds: i64,
1464    /// Timezone offset east of UTC, in minutes (e.g. `+0530` -> `330`,
1465    /// `-0500` -> `-300`). Zero for both `+0000` and `-0000`; consult
1466    /// [`GitTime::negative_utc`] to tell those apart.
1467    pub timezone_offset_minutes: i16,
1468    /// `true` only when the timezone token had a negative sign with a zero
1469    /// magnitude (`-0000`), git's "timezone unknown" sentinel. Always `false`
1470    /// for any non-zero offset.
1471    pub negative_utc: bool,
1472}
1473
1474impl GitTime {
1475    /// A `GitTime` with the given seconds and minute offset, treating a zero
1476    /// offset as the ordinary `+0000` (not the `-0000` sentinel). Use
1477    /// [`GitTime::with_negative_utc`] to construct the `-0000` case.
1478    pub const fn new(seconds: i64, timezone_offset_minutes: i16) -> Self {
1479        Self {
1480            seconds,
1481            timezone_offset_minutes,
1482            negative_utc: false,
1483        }
1484    }
1485
1486    /// A `GitTime` whose timezone is the `-0000` sentinel ("timezone unknown").
1487    /// The minute offset is zero; `negative_utc` is `true`.
1488    pub const fn with_negative_utc(seconds: i64) -> Self {
1489        Self {
1490            seconds,
1491            timezone_offset_minutes: 0,
1492            negative_utc: true,
1493        }
1494    }
1495
1496    /// Parse the `<secs> <tz>` tail of an ident line (the bytes after the
1497    /// `"> "` separating the email from the time), returning `None` if either
1498    /// field is malformed.
1499    fn from_time_fields(bytes: &[u8]) -> Option<Self> {
1500        let text = std::str::from_utf8(bytes).ok()?;
1501        let (seconds_text, tz_text) = text.split_once(' ')?;
1502        let seconds = seconds_text.parse::<i64>().ok()?;
1503        let (timezone_offset_minutes, negative_utc) = parse_timezone_token(tz_text)?;
1504        Some(Self {
1505            seconds,
1506            timezone_offset_minutes,
1507            negative_utc,
1508        })
1509    }
1510
1511    /// The canonical `<secs> <±HHMM>` rendering of this time, as git writes it.
1512    /// Preserves the `-0000` sentinel.
1513    fn to_ident_suffix(self) -> String {
1514        format!("{} {}", self.seconds, self.offset_token())
1515    }
1516
1517    /// The canonical 5-character timezone token for this offset (sign plus four
1518    /// digits), e.g. `+0000`, `-0500`, `+0530`. Returns `-0000` when
1519    /// [`GitTime::negative_utc`] is set.
1520    pub fn offset_token(self) -> String {
1521        let sign = if self.negative_utc || self.timezone_offset_minutes < 0 {
1522            '-'
1523        } else {
1524            '+'
1525        };
1526        let magnitude = self.timezone_offset_minutes.unsigned_abs();
1527        format!("{sign}{:02}{:02}", magnitude / 60, magnitude % 60)
1528    }
1529}
1530
1531/// Parse a git timezone token (`±HHMM`) into `(minutes east of UTC, negative_utc)`.
1532///
1533/// Git accepts a leading `+`/`-` followed by four digits where the last two are
1534/// minutes. A negative sign with a zero magnitude (`-0000`) sets `negative_utc`.
1535/// Returns `None` for anything that is not a well-formed token.
1536fn parse_timezone_token(token: &str) -> Option<(i16, bool)> {
1537    let bytes = token.as_bytes();
1538    if bytes.len() != 5 {
1539        return None;
1540    }
1541    let negative = match bytes[0] {
1542        b'+' => false,
1543        b'-' => true,
1544        _ => return None,
1545    };
1546    if !bytes[1..].iter().all(u8::is_ascii_digit) {
1547        return None;
1548    }
1549    let hours = i16::from(bytes[1] - b'0') * 10 + i16::from(bytes[2] - b'0');
1550    let minutes = i16::from(bytes[3] - b'0') * 10 + i16::from(bytes[4] - b'0');
1551    let total = hours * 60 + minutes;
1552    let negative_utc = negative && total == 0;
1553    let signed = if negative { -total } else { total };
1554    Some((signed, negative_utc))
1555}
1556
1557#[derive(Debug, Clone, PartialEq, Eq)]
1558pub struct Capability {
1559    pub name: String,
1560    pub value: Option<String>,
1561}
1562
1563#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1564pub enum MissingObjectKind {
1565    Object,
1566    Blob,
1567    Tree,
1568    Commit,
1569    Tag,
1570}
1571
1572impl MissingObjectKind {
1573    pub const fn as_str(self) -> &'static str {
1574        match self {
1575            Self::Object => "object",
1576            Self::Blob => "blob",
1577            Self::Tree => "tree",
1578            Self::Commit => "commit",
1579            Self::Tag => "tag",
1580        }
1581    }
1582}
1583
1584impl fmt::Display for MissingObjectKind {
1585    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1586        f.write_str(self.as_str())
1587    }
1588}
1589
1590#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1591pub enum MissingObjectContext {
1592    Read,
1593    Traversal,
1594    PackInstall,
1595    RevisionWalk,
1596    WorktreeMaterialize,
1597    RemoteBoundary,
1598}
1599
1600impl MissingObjectContext {
1601    pub const fn as_str(self) -> &'static str {
1602        match self {
1603            Self::Read => "read",
1604            Self::Traversal => "traversal",
1605            Self::PackInstall => "pack-install",
1606            Self::RevisionWalk => "revision-walk",
1607            Self::WorktreeMaterialize => "worktree-materialize",
1608            Self::RemoteBoundary => "remote-boundary",
1609        }
1610    }
1611}
1612
1613impl fmt::Display for MissingObjectContext {
1614    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1615        f.write_str(self.as_str())
1616    }
1617}
1618
1619#[derive(Debug, Clone, PartialEq, Eq)]
1620pub enum NotFoundKind {
1621    Message(String),
1622    Remote {
1623        name: String,
1624    },
1625    Object {
1626        oid: ObjectId,
1627        kind: MissingObjectKind,
1628        context: Option<MissingObjectContext>,
1629    },
1630    Reference {
1631        name: String,
1632    },
1633    Repository {
1634        path: String,
1635    },
1636}
1637
1638impl fmt::Display for NotFoundKind {
1639    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1640        match self {
1641            Self::Message(msg) => write!(f, "{msg}"),
1642            Self::Remote { name } => write!(f, "remote {name}"),
1643            Self::Object {
1644                oid,
1645                kind: MissingObjectKind::Object,
1646                ..
1647            } => write!(f, "object {oid}"),
1648            Self::Object { oid, kind, .. } => write!(f, "{kind} object {oid}"),
1649            Self::Reference { name } => write!(f, "{name}"),
1650            Self::Repository { path } => write!(f, "{path}"),
1651        }
1652    }
1653}
1654
1655impl NotFoundKind {
1656    pub fn object_id(&self) -> Option<ObjectId> {
1657        match self {
1658            Self::Object { oid, .. } => Some(*oid),
1659            _ => None,
1660        }
1661    }
1662
1663    pub fn missing_object_kind(&self) -> Option<MissingObjectKind> {
1664        match self {
1665            Self::Object { kind, .. } => Some(*kind),
1666            _ => None,
1667        }
1668    }
1669
1670    pub fn missing_object_context(&self) -> Option<MissingObjectContext> {
1671        match self {
1672            Self::Object { context, .. } => *context,
1673            _ => None,
1674        }
1675    }
1676}
1677
1678/// Git-compatible CLI exit status. See `git help exit-code` for the upstream taxonomy.
1679#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1680pub enum CliExit {
1681    /// Success (exit 0).
1682    Ok,
1683    /// User-facing fatal error (exit 128).
1684    UserError,
1685    /// Invalid usage / bad arguments (exit 129).
1686    Usage,
1687    /// Command-specific exit code (e.g. grep returning 1 when no matches).
1688    Custom(i32),
1689}
1690
1691impl CliExit {
1692    pub const fn code(self) -> i32 {
1693        match self {
1694            Self::Ok => 0,
1695            Self::UserError => 128,
1696            Self::Usage => 129,
1697            Self::Custom(code) => code,
1698        }
1699    }
1700}
1701
1702#[derive(Debug, Clone, PartialEq, Eq)]
1703pub enum GitError {
1704    Io(String),
1705    InvalidObjectId(String),
1706    InvalidObject(String),
1707    InvalidFormat(String),
1708    InvalidPath(String),
1709    Unsupported(String),
1710    NotFound(NotFoundKind),
1711    Transaction(String),
1712    Command(String),
1713    /// Typed CLI exit with a user-facing message printed by the binary entrypoint.
1714    Cli(CliExit, String),
1715    /// Legacy explicit exit code; the message (if any) was already printed by the command.
1716    Exit(i32),
1717}
1718
1719pub type Result<T> = std::result::Result<T, GitError>;
1720
1721impl fmt::Display for GitError {
1722    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1723        match self {
1724            Self::Io(msg) => write!(f, "io error: {msg}"),
1725            Self::InvalidObjectId(msg) => write!(f, "invalid object id: {msg}"),
1726            Self::InvalidObject(msg) => write!(f, "invalid object: {msg}"),
1727            Self::InvalidFormat(msg) => write!(f, "invalid format: {msg}"),
1728            Self::InvalidPath(msg) => write!(f, "invalid path: {msg}"),
1729            Self::Unsupported(msg) => write!(f, "unsupported: {msg}"),
1730            Self::NotFound(kind) => write!(f, "not found: {kind}"),
1731            Self::Transaction(msg) => write!(f, "transaction failed: {msg}"),
1732            Self::Command(msg) => write!(f, "command failed: {msg}"),
1733            Self::Cli(_, msg) => f.write_str(msg),
1734            Self::Exit(code) => write!(f, "exit {code}"),
1735        }
1736    }
1737}
1738
1739impl Error for GitError {}
1740
1741impl GitError {
1742    pub fn usage(msg: impl Into<String>) -> Self {
1743        Self::Cli(CliExit::Usage, msg.into())
1744    }
1745
1746    pub fn user_error(msg: impl Into<String>) -> Self {
1747        Self::Cli(CliExit::UserError, msg.into())
1748    }
1749
1750    pub fn cli_exit(kind: CliExit, msg: impl Into<String>) -> Self {
1751        Self::Cli(kind, msg.into())
1752    }
1753
1754    pub fn cli_exit_code(&self) -> i32 {
1755        cli_exit_code(self)
1756    }
1757
1758    pub fn not_found(msg: impl Into<String>) -> Self {
1759        Self::NotFound(NotFoundKind::Message(msg.into()))
1760    }
1761
1762    pub fn remote_not_found(name: impl Into<String>) -> Self {
1763        Self::NotFound(NotFoundKind::Remote { name: name.into() })
1764    }
1765
1766    pub fn object_not_found(oid: ObjectId) -> Self {
1767        Self::object_kind_not_found(oid, MissingObjectKind::Object)
1768    }
1769
1770    pub fn object_kind_not_found(oid: ObjectId, kind: MissingObjectKind) -> Self {
1771        Self::NotFound(NotFoundKind::Object {
1772            oid,
1773            kind,
1774            context: None,
1775        })
1776    }
1777
1778    pub fn object_not_found_in(oid: ObjectId, context: MissingObjectContext) -> Self {
1779        Self::object_kind_not_found_in(oid, MissingObjectKind::Object, context)
1780    }
1781
1782    pub fn object_kind_not_found_in(
1783        oid: ObjectId,
1784        kind: MissingObjectKind,
1785        context: MissingObjectContext,
1786    ) -> Self {
1787        Self::NotFound(NotFoundKind::Object {
1788            oid,
1789            kind,
1790            context: Some(context),
1791        })
1792    }
1793
1794    pub fn reference_not_found(name: impl Into<String>) -> Self {
1795        Self::NotFound(NotFoundKind::Reference { name: name.into() })
1796    }
1797
1798    pub fn repository_not_found(path: impl Into<String>) -> Self {
1799        Self::NotFound(NotFoundKind::Repository { path: path.into() })
1800    }
1801
1802    pub fn not_found_kind(&self) -> Option<&NotFoundKind> {
1803        match self {
1804            Self::NotFound(kind) => Some(kind),
1805            _ => None,
1806        }
1807    }
1808}
1809
1810impl From<std::io::Error> for GitError {
1811    fn from(value: std::io::Error) -> Self {
1812        Self::Io(value.to_string())
1813    }
1814}
1815
1816/// Map a [`GitError`] to the process exit code the CLI should use.
1817pub fn cli_exit_code(err: &GitError) -> i32 {
1818    match err {
1819        GitError::Exit(code) => *code,
1820        GitError::Cli(kind, _) => kind.code(),
1821        // During migration, usage-style validation still returns `Command`; treat as
1822        // general failure until those call sites adopt `GitError::usage`.
1823        GitError::Command(_) => 1,
1824        _ => 1,
1825    }
1826}
1827
1828pub fn object_id_for_bytes(
1829    format: ObjectFormat,
1830    object_type: &str,
1831    body: &[u8],
1832) -> Result<ObjectId> {
1833    match format {
1834        // Hash the `"<type> <len>\0"` header and the body as separate updates so
1835        // the (potentially large) body is never copied into a combined buffer just
1836        // to feed the digest.
1837        ObjectFormat::Sha1 => ObjectId::from_raw(format, &sha1_object_digest(object_type, body)),
1838        ObjectFormat::Sha256 => {
1839            let mut framed = Vec::with_capacity(object_type.len() + body.len() + 32);
1840            framed.extend_from_slice(object_type.as_bytes());
1841            framed.push(b' ');
1842            framed.extend_from_slice(body.len().to_string().as_bytes());
1843            framed.push(0);
1844            framed.extend_from_slice(body);
1845            ObjectId::from_raw(format, &sha256(&framed))
1846        }
1847    }
1848}
1849
1850pub fn digest_bytes(format: ObjectFormat, bytes: &[u8]) -> Result<ObjectId> {
1851    match format {
1852        ObjectFormat::Sha1 => ObjectId::from_raw(format, &sha1(bytes)),
1853        ObjectFormat::Sha256 => ObjectId::from_raw(format, &sha256(bytes)),
1854    }
1855}
1856
1857pub struct StreamingDigest {
1858    format: ObjectFormat,
1859    inner: StreamingDigestInner,
1860}
1861
1862enum StreamingDigestInner {
1863    #[cfg(not(feature = "fast-sha1"))]
1864    Sha1(Sha1Hasher),
1865    #[cfg(feature = "fast-sha1")]
1866    Sha1(sha1::Sha1),
1867    Sha256(Sha256Hasher),
1868}
1869
1870impl StreamingDigest {
1871    pub fn new(format: ObjectFormat) -> Self {
1872        let inner = match format {
1873            #[cfg(not(feature = "fast-sha1"))]
1874            ObjectFormat::Sha1 => StreamingDigestInner::Sha1(Sha1Hasher::new()),
1875            #[cfg(feature = "fast-sha1")]
1876            ObjectFormat::Sha1 => {
1877                use sha1::Digest;
1878                StreamingDigestInner::Sha1(sha1::Sha1::new())
1879            }
1880            ObjectFormat::Sha256 => StreamingDigestInner::Sha256(Sha256Hasher::new()),
1881        };
1882        Self { format, inner }
1883    }
1884
1885    pub fn update(&mut self, data: &[u8]) {
1886        match &mut self.inner {
1887            #[cfg(not(feature = "fast-sha1"))]
1888            StreamingDigestInner::Sha1(hasher) => hasher.update(data),
1889            #[cfg(feature = "fast-sha1")]
1890            StreamingDigestInner::Sha1(hasher) => {
1891                use sha1::Digest;
1892                hasher.update(data);
1893            }
1894            StreamingDigestInner::Sha256(hasher) => hasher.update(data),
1895        }
1896    }
1897
1898    pub fn finalize(self) -> Result<ObjectId> {
1899        match self.inner {
1900            #[cfg(not(feature = "fast-sha1"))]
1901            StreamingDigestInner::Sha1(hasher) => {
1902                ObjectId::from_raw(self.format, &hasher.finalize())
1903            }
1904            #[cfg(feature = "fast-sha1")]
1905            StreamingDigestInner::Sha1(hasher) => {
1906                use sha1::Digest;
1907                let bytes: [u8; 20] = hasher.finalize().into();
1908                ObjectId::from_raw(self.format, &bytes)
1909            }
1910            StreamingDigestInner::Sha256(hasher) => {
1911                ObjectId::from_raw(self.format, &hasher.finalize())
1912            }
1913        }
1914    }
1915}
1916
1917pub fn to_hex(bytes: &[u8]) -> String {
1918    let mut out = String::with_capacity(bytes.len() * 2);
1919    write_hex_bytes(bytes, &mut out).expect("writing hex to a String cannot fail");
1920    out
1921}
1922
1923fn write_hex_bytes(bytes: &[u8], out: &mut impl fmt::Write) -> fmt::Result {
1924    const HEX: &[u8; 16] = b"0123456789abcdef";
1925    for byte in bytes {
1926        out.write_char(HEX[(byte >> 4) as usize] as char)?;
1927        out.write_char(HEX[(byte & 0x0f) as usize] as char)?;
1928    }
1929    Ok(())
1930}
1931
1932fn hex_nibble_value(byte: u8) -> Option<u8> {
1933    match byte {
1934        b'0'..=b'9' => Some(byte - b'0'),
1935        b'a'..=b'f' => Some(byte - b'a' + 10),
1936        b'A'..=b'F' => Some(byte - b'A' + 10),
1937        _ => None,
1938    }
1939}
1940
1941fn hex_nibble(byte: u8) -> Result<u8> {
1942    hex_nibble_value(byte)
1943        .ok_or_else(|| GitError::InvalidObjectId(format!("non-hex byte {:?}", byte as char)))
1944}
1945
1946// ---------------------------------------------------------------------------
1947// SHA-1
1948//
1949// The default is a pure-Rust streaming implementation that hashes 64-byte blocks
1950// straight from the caller's slices, so neither the body nor the framed object is
1951// copied just to be digested. Enabling the `fast-sha1` feature swaps in the
1952// RustCrypto `sha1` crate, which dispatches to ARMv8-SHA1 / x86 SHA-NI at runtime;
1953// the digests are byte-identical, so OIDs are unchanged either way.
1954// ---------------------------------------------------------------------------
1955
1956/// SHA-1 of a raw byte slice (already-framed object, bundle prerequisite, etc.).
1957#[cfg(not(feature = "fast-sha1"))]
1958fn sha1(input: &[u8]) -> [u8; 20] {
1959    let mut hasher = Sha1Hasher::new();
1960    hasher.update(input);
1961    hasher.finalize()
1962}
1963
1964/// SHA-1 of a raw byte slice using the hardware-accelerated backend.
1965#[cfg(feature = "fast-sha1")]
1966fn sha1(input: &[u8]) -> [u8; 20] {
1967    use sha1::{Digest, Sha1};
1968    let mut hasher = Sha1::new();
1969    hasher.update(input);
1970    hasher.finalize().into()
1971}
1972
1973/// SHA-1 of a git object framed as `"<type> <len>\0<body>"`, fed as separate
1974/// updates so the body is never copied into a combined buffer.
1975#[cfg(not(feature = "fast-sha1"))]
1976fn sha1_object_digest(object_type: &str, body: &[u8]) -> [u8; 20] {
1977    let mut hasher = Sha1Hasher::new();
1978    hasher.update(object_type.as_bytes());
1979    hasher.update(b" ");
1980    hasher.update(body.len().to_string().as_bytes());
1981    hasher.update(&[0u8]);
1982    hasher.update(body);
1983    hasher.finalize()
1984}
1985
1986#[cfg(feature = "fast-sha1")]
1987fn sha1_object_digest(object_type: &str, body: &[u8]) -> [u8; 20] {
1988    use sha1::{Digest, Sha1};
1989    let mut hasher = Sha1::new();
1990    hasher.update(object_type.as_bytes());
1991    hasher.update(b" ");
1992    hasher.update(body.len().to_string().as_bytes());
1993    hasher.update([0u8]);
1994    hasher.update(body);
1995    hasher.finalize().into()
1996}
1997
1998/// Streaming pure-Rust SHA-1: feeds full 64-byte blocks directly from each
1999/// `update` slice and buffers only the sub-block remainder, so large inputs are
2000/// hashed without an intermediate copy.
2001#[cfg(not(feature = "fast-sha1"))]
2002struct Sha1Hasher {
2003    state: [u32; 5],
2004    block: [u8; 64],
2005    block_len: usize,
2006    total_len: u64,
2007}
2008
2009#[cfg(not(feature = "fast-sha1"))]
2010impl Sha1Hasher {
2011    fn new() -> Self {
2012        Self {
2013            state: [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0],
2014            block: [0u8; 64],
2015            block_len: 0,
2016            total_len: 0,
2017        }
2018    }
2019
2020    fn update(&mut self, mut data: &[u8]) {
2021        self.total_len = self.total_len.wrapping_add(data.len() as u64);
2022        if self.block_len > 0 {
2023            let take = (64 - self.block_len).min(data.len());
2024            self.block[self.block_len..self.block_len + take].copy_from_slice(&data[..take]);
2025            self.block_len += take;
2026            data = &data[take..];
2027            if self.block_len == 64 {
2028                let block = self.block;
2029                sha1_compress(&mut self.state, &block);
2030                self.block_len = 0;
2031            }
2032        }
2033        while data.len() >= 64 {
2034            sha1_compress(&mut self.state, &data[..64]);
2035            data = &data[64..];
2036        }
2037        if !data.is_empty() {
2038            self.block[..data.len()].copy_from_slice(data);
2039            self.block_len = data.len();
2040        }
2041    }
2042
2043    fn finalize(mut self) -> [u8; 20] {
2044        let bit_len = self.total_len.wrapping_mul(8);
2045        // 0x80, zero pad to a 56 mod 64 boundary, then the 64-bit big-endian length.
2046        // From a sub-block remainder this is at most two more blocks (128 bytes).
2047        let mut tail = [0u8; 128];
2048        tail[..self.block_len].copy_from_slice(&self.block[..self.block_len]);
2049        tail[self.block_len] = 0x80;
2050        let total = if self.block_len < 56 { 64 } else { 128 };
2051        tail[total - 8..total].copy_from_slice(&bit_len.to_be_bytes());
2052        sha1_compress(&mut self.state, &tail[..64]);
2053        if total == 128 {
2054            sha1_compress(&mut self.state, &tail[64..128]);
2055        }
2056        let mut out = [0u8; 20];
2057        out[0..4].copy_from_slice(&self.state[0].to_be_bytes());
2058        out[4..8].copy_from_slice(&self.state[1].to_be_bytes());
2059        out[8..12].copy_from_slice(&self.state[2].to_be_bytes());
2060        out[12..16].copy_from_slice(&self.state[3].to_be_bytes());
2061        out[16..20].copy_from_slice(&self.state[4].to_be_bytes());
2062        out
2063    }
2064}
2065
2066/// Mix one 64-byte block into the SHA-1 state. `block` must be at least 64 bytes.
2067#[cfg(not(feature = "fast-sha1"))]
2068fn sha1_compress(state: &mut [u32; 5], block: &[u8]) {
2069    let mut w = [0u32; 80];
2070    for (i, word) in w.iter_mut().take(16).enumerate() {
2071        let offset = i * 4;
2072        *word = u32::from_be_bytes([
2073            block[offset],
2074            block[offset + 1],
2075            block[offset + 2],
2076            block[offset + 3],
2077        ]);
2078    }
2079    for i in 16..80 {
2080        w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
2081    }
2082
2083    let mut a = state[0];
2084    let mut b = state[1];
2085    let mut c = state[2];
2086    let mut d = state[3];
2087    let mut e = state[4];
2088
2089    for (i, word) in w.iter().enumerate() {
2090        let (f, k) = match i {
2091            0..=19 => ((b & c) | ((!b) & d), 0x5a827999u32),
2092            20..=39 => (b ^ c ^ d, 0x6ed9eba1),
2093            40..=59 => ((b & c) | (b & d) | (c & d), 0x8f1bbcdc),
2094            _ => (b ^ c ^ d, 0xca62c1d6),
2095        };
2096        let temp = a
2097            .rotate_left(5)
2098            .wrapping_add(f)
2099            .wrapping_add(e)
2100            .wrapping_add(k)
2101            .wrapping_add(*word);
2102        e = d;
2103        d = c;
2104        c = b.rotate_left(30);
2105        b = a;
2106        a = temp;
2107    }
2108
2109    state[0] = state[0].wrapping_add(a);
2110    state[1] = state[1].wrapping_add(b);
2111    state[2] = state[2].wrapping_add(c);
2112    state[3] = state[3].wrapping_add(d);
2113    state[4] = state[4].wrapping_add(e);
2114}
2115
2116fn sha256(input: &[u8]) -> [u8; 32] {
2117    let mut hasher = Sha256Hasher::new();
2118    hasher.update(input);
2119    hasher.finalize()
2120}
2121
2122struct Sha256Hasher {
2123    state: [u32; 8],
2124    block: [u8; 64],
2125    block_len: usize,
2126    total_len: u64,
2127}
2128
2129impl Sha256Hasher {
2130    const K: [u32; 64] = [
2131        0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
2132        0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
2133        0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
2134        0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
2135        0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
2136        0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
2137        0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
2138        0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
2139        0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
2140        0xc67178f2,
2141    ];
2142
2143    fn new() -> Self {
2144        Self {
2145            state: [
2146                0x6a09e667u32,
2147                0xbb67ae85,
2148                0x3c6ef372,
2149                0xa54ff53a,
2150                0x510e527f,
2151                0x9b05688c,
2152                0x1f83d9ab,
2153                0x5be0cd19,
2154            ],
2155            block: [0u8; 64],
2156            block_len: 0,
2157            total_len: 0,
2158        }
2159    }
2160
2161    fn update(&mut self, mut data: &[u8]) {
2162        self.total_len = self.total_len.wrapping_add(data.len() as u64);
2163        if self.block_len > 0 {
2164            let take = (64 - self.block_len).min(data.len());
2165            self.block[self.block_len..self.block_len + take].copy_from_slice(&data[..take]);
2166            self.block_len += take;
2167            data = &data[take..];
2168            if self.block_len == 64 {
2169                let block = self.block;
2170                self.compress(&block);
2171                self.block_len = 0;
2172            }
2173        }
2174        while data.len() >= 64 {
2175            self.compress(&data[..64]);
2176            data = &data[64..];
2177        }
2178        if !data.is_empty() {
2179            self.block[..data.len()].copy_from_slice(data);
2180            self.block_len = data.len();
2181        }
2182    }
2183
2184    fn finalize(mut self) -> [u8; 32] {
2185        let bit_len = self.total_len.wrapping_mul(8);
2186        let mut tail = [0u8; 128];
2187        tail[..self.block_len].copy_from_slice(&self.block[..self.block_len]);
2188        tail[self.block_len] = 0x80;
2189        let total = if self.block_len < 56 { 64 } else { 128 };
2190        tail[total - 8..total].copy_from_slice(&bit_len.to_be_bytes());
2191        self.compress(&tail[..64]);
2192        if total == 128 {
2193            self.compress(&tail[64..128]);
2194        }
2195
2196        let mut out = [0; 32];
2197        for (idx, word) in self.state.iter().enumerate() {
2198            out[idx * 4..idx * 4 + 4].copy_from_slice(&word.to_be_bytes());
2199        }
2200        out
2201    }
2202
2203    fn compress(&mut self, chunk: &[u8]) {
2204        let mut w = [0u32; 64];
2205        for (i, word) in w.iter_mut().take(16).enumerate() {
2206            let offset = i * 4;
2207            *word = u32::from_be_bytes([
2208                chunk[offset],
2209                chunk[offset + 1],
2210                chunk[offset + 2],
2211                chunk[offset + 3],
2212            ]);
2213        }
2214        for i in 16..64 {
2215            let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
2216            let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
2217            w[i] = w[i - 16]
2218                .wrapping_add(s0)
2219                .wrapping_add(w[i - 7])
2220                .wrapping_add(s1);
2221        }
2222
2223        let mut a = self.state[0];
2224        let mut b = self.state[1];
2225        let mut c = self.state[2];
2226        let mut d = self.state[3];
2227        let mut e = self.state[4];
2228        let mut f = self.state[5];
2229        let mut g = self.state[6];
2230        let mut hh = self.state[7];
2231
2232        for (&word, &constant) in w.iter().zip(Self::K.iter()) {
2233            let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
2234            let ch = (e & f) ^ ((!e) & g);
2235            let temp1 = hh
2236                .wrapping_add(s1)
2237                .wrapping_add(ch)
2238                .wrapping_add(constant)
2239                .wrapping_add(word);
2240            let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
2241            let maj = (a & b) ^ (a & c) ^ (b & c);
2242            let temp2 = s0.wrapping_add(maj);
2243
2244            hh = g;
2245            g = f;
2246            f = e;
2247            e = d.wrapping_add(temp1);
2248            d = c;
2249            c = b;
2250            b = a;
2251            a = temp1.wrapping_add(temp2);
2252        }
2253
2254        self.state[0] = self.state[0].wrapping_add(a);
2255        self.state[1] = self.state[1].wrapping_add(b);
2256        self.state[2] = self.state[2].wrapping_add(c);
2257        self.state[3] = self.state[3].wrapping_add(d);
2258        self.state[4] = self.state[4].wrapping_add(e);
2259        self.state[5] = self.state[5].wrapping_add(f);
2260        self.state[6] = self.state[6].wrapping_add(g);
2261        self.state[7] = self.state[7].wrapping_add(hh);
2262    }
2263}
2264
2265#[cfg(test)]
2266mod tests {
2267    use super::*;
2268
2269    #[test]
2270    fn sha1_blob_matches_git_known_value() {
2271        let oid = object_id_for_bytes(ObjectFormat::Sha1, "blob", b"hello\n")
2272            .expect("known blob should hash as sha1");
2273        assert_eq!(oid.to_hex(), "ce013625030ba8dba906f756967f9e9ca394464a");
2274    }
2275
2276    #[test]
2277    fn sha256_blob_matches_git_known_value() {
2278        let oid = object_id_for_bytes(ObjectFormat::Sha256, "blob", b"hello\n")
2279            .expect("known blob should hash as sha256");
2280        assert_eq!(
2281            oid.to_hex(),
2282            "2cf8d83d9ee29543b34a87727421fdecb7e3f3a183d337639025de576db9ebb4"
2283        );
2284    }
2285
2286    #[test]
2287    fn object_id_round_trips_hex() {
2288        let oid = ObjectId::from_hex(
2289            ObjectFormat::Sha1,
2290            "ce013625030ba8dba906f756967f9e9ca394464a",
2291        )
2292        .expect("valid sha1 hex");
2293        assert_eq!(oid.to_hex(), "ce013625030ba8dba906f756967f9e9ca394464a");
2294    }
2295
2296    #[test]
2297    fn object_id_writes_hex_without_allocating_in_the_writer() {
2298        let oid = ObjectId::from_hex(
2299            ObjectFormat::Sha1,
2300            "CE013625030BA8DBA906F756967F9E9CA394464A",
2301        )
2302        .expect("valid uppercase sha1 hex");
2303
2304        let mut out = String::new();
2305        oid.write_hex(&mut out)
2306            .expect("writing object id hex to a String should not fail");
2307
2308        assert_eq!(out, "ce013625030ba8dba906f756967f9e9ca394464a");
2309        assert_eq!(oid.to_hex(), out);
2310        assert_eq!(format!("{oid}"), out);
2311    }
2312
2313    #[test]
2314    fn object_id_matches_hex_prefixes_by_nibble() {
2315        let oid = ObjectId::from_hex(
2316            ObjectFormat::Sha1,
2317            "ce013625030ba8dba906f756967f9e9ca394464a",
2318        )
2319        .expect("valid sha1 hex");
2320
2321        assert!(oid.hex_prefix_matches(b""));
2322        assert!(oid.hex_prefix_matches(b"c"));
2323        assert!(oid.hex_prefix_matches(b"ce013"));
2324        assert!(oid.hex_prefix_matches(b"CE013625"));
2325        assert!(oid.hex_prefix_matches(b"ce013625030ba8dba906f756967f9e9ca394464a"));
2326
2327        assert!(!oid.hex_prefix_matches(b"d"));
2328        assert!(!oid.hex_prefix_matches(b"ce014"));
2329        assert!(!oid.hex_prefix_matches(b"ce01x"));
2330
2331        let mut too_long = oid.to_hex();
2332        too_long.push('0');
2333        assert!(!oid.hex_prefix_matches(too_long.as_bytes()));
2334    }
2335
2336    #[test]
2337    fn object_id_abbrev_hex_len_clamps_to_format_width() {
2338        let sha1 = ObjectId::null(ObjectFormat::Sha1);
2339        let sha256 = ObjectId::null(ObjectFormat::Sha256);
2340
2341        assert_eq!(sha1.abbrev_hex_len(0), 0);
2342        assert_eq!(sha1.abbrev_hex_len(12), 12);
2343        assert_eq!(sha1.abbrev_hex_len(80), ObjectFormat::Sha1.hex_len());
2344        assert_eq!(sha256.abbrev_hex_len(80), ObjectFormat::Sha256.hex_len());
2345    }
2346
2347    #[test]
2348    fn signature_parses_a_normal_ident_and_round_trips() {
2349        let line = b"A U Thor <author@example.com> 1700000000 +0000";
2350        let sig = Signature::from_ident_line(line).expect("well-formed ident parses");
2351        assert_eq!(sig.name.as_bytes(), b"A U Thor");
2352        assert_eq!(sig.email.as_bytes(), b"author@example.com");
2353        assert_eq!(sig.time.seconds, 1_700_000_000);
2354        assert_eq!(sig.time.timezone_offset_minutes, 0);
2355        assert!(!sig.time.negative_utc);
2356        // Byte-exact round-trip, and the canonical form matches here too.
2357        assert_eq!(sig.to_ident_bytes(), line);
2358        assert_eq!(sig.to_canonical_ident_bytes(), line);
2359    }
2360
2361    #[test]
2362    fn signature_parses_positive_half_hour_offset() {
2363        let line = b"Half Hour <hh@example.com> 1500000000 +0530";
2364        let sig = Signature::from_ident_line(line).expect("offset ident parses");
2365        assert_eq!(sig.time.timezone_offset_minutes, 330);
2366        assert!(!sig.time.negative_utc);
2367        assert_eq!(sig.time.offset_token(), "+0530");
2368        assert_eq!(sig.to_ident_bytes(), line);
2369        assert_eq!(sig.to_canonical_ident_bytes(), line);
2370    }
2371
2372    #[test]
2373    fn signature_parses_negative_offset() {
2374        let line = b"Western <w@example.com> 1500000000 -0500";
2375        let sig = Signature::from_ident_line(line).expect("negative offset parses");
2376        assert_eq!(sig.time.timezone_offset_minutes, -300);
2377        assert!(!sig.time.negative_utc);
2378        assert_eq!(sig.time.offset_token(), "-0500");
2379        assert_eq!(sig.to_ident_bytes(), line);
2380    }
2381
2382    #[test]
2383    fn signature_preserves_negative_zero_timezone_distinct_from_positive_zero() {
2384        let negative = b"Unknown Zone <uz@example.com> 1500000000 -0000";
2385        let positive = b"Known Zone <kz@example.com> 1500000000 +0000";
2386
2387        let neg = Signature::from_ident_line(negative).expect("-0000 parses");
2388        let pos = Signature::from_ident_line(positive).expect("+0000 parses");
2389
2390        // Both are zero minutes from UTC...
2391        assert_eq!(neg.time.timezone_offset_minutes, 0);
2392        assert_eq!(pos.time.timezone_offset_minutes, 0);
2393        // ...but the sentinel flag distinguishes them, so the times differ.
2394        assert!(neg.time.negative_utc);
2395        assert!(!pos.time.negative_utc);
2396        assert_ne!(neg.time, pos.time);
2397
2398        // And the distinction survives re-serialization, byte-for-byte.
2399        assert_eq!(neg.time.offset_token(), "-0000");
2400        assert_eq!(pos.time.offset_token(), "+0000");
2401        assert_eq!(neg.to_ident_bytes(), negative);
2402        assert_eq!(pos.to_ident_bytes(), positive);
2403        assert_eq!(neg.to_canonical_ident_bytes(), negative);
2404        assert_eq!(pos.to_canonical_ident_bytes(), positive);
2405        assert_ne!(neg.to_ident_bytes(), pos.to_ident_bytes());
2406    }
2407
2408    #[test]
2409    fn signature_handles_empty_name_and_email() {
2410        // git permits an empty name and/or empty email; the delimiters still
2411        // anchor the parse.
2412        let line = b" <> 0 +0000";
2413        let sig = Signature::from_ident_line(line).expect("empty name/email parses");
2414        assert_eq!(sig.name.as_bytes(), b"");
2415        assert_eq!(sig.email.as_bytes(), b"");
2416        assert_eq!(sig.time.seconds, 0);
2417        assert_eq!(sig.to_ident_bytes(), line);
2418    }
2419
2420    #[test]
2421    fn signature_keeps_angle_brackets_inside_the_name() {
2422        // The email is delimited by the *last* '<'/'>' pair, so a name that
2423        // itself contains angle brackets parses with the trailing pair as the
2424        // email and round-trips exactly.
2425        let line = b"Weird <Name> <weird@example.com> 1 +0000";
2426        let sig = Signature::from_ident_line(line).expect("bracketed name parses");
2427        assert_eq!(sig.name.as_bytes(), b"Weird <Name>");
2428        assert_eq!(sig.email.as_bytes(), b"weird@example.com");
2429        assert_eq!(sig.to_ident_bytes(), line);
2430    }
2431
2432    #[test]
2433    fn signature_round_trips_non_canonical_whitespace_via_raw() {
2434        // An ident with two spaces before the email is not git's canonical form,
2435        // but the parse-view must still reproduce it byte-for-byte from `raw`.
2436        // (Only the canonical renderer normalizes the spacing.)
2437        let line = b"Spaced  <spaced@example.com> 5 +0000";
2438        let sig = Signature::from_ident_line(line).expect("non-canonical ident parses");
2439        // The name keeps the extra space (only one separator space is trimmed).
2440        assert_eq!(sig.name.as_bytes(), b"Spaced ");
2441        assert_eq!(sig.to_ident_bytes(), line);
2442    }
2443
2444    #[test]
2445    fn signature_rejects_malformed_idents() {
2446        // No email delimiters.
2447        assert!(Signature::from_ident_line(b"No Email Here 0 +0000").is_none());
2448        // Missing the time tail entirely.
2449        assert!(Signature::from_ident_line(b"A U Thor <a@example.com>").is_none());
2450        // Non-numeric timestamp.
2451        assert!(Signature::from_ident_line(b"A U Thor <a@example.com> later +0000").is_none());
2452        // Malformed timezone token (wrong width).
2453        assert!(Signature::from_ident_line(b"A U Thor <a@example.com> 0 +00").is_none());
2454        // Timezone token missing a sign.
2455        assert!(Signature::from_ident_line(b"A U Thor <a@example.com> 0 0000").is_none());
2456    }
2457
2458    #[test]
2459    fn git_time_constructors_set_the_sentinel() {
2460        assert!(!GitTime::new(0, 0).negative_utc);
2461        assert_eq!(GitTime::new(0, 330).offset_token(), "+0530");
2462        let unknown = GitTime::with_negative_utc(42);
2463        assert!(unknown.negative_utc);
2464        assert_eq!(unknown.seconds, 42);
2465        assert_eq!(unknown.offset_token(), "-0000");
2466    }
2467
2468    #[test]
2469    fn full_name_accepts_valid_ref_names() {
2470        let name = FullName::new("refs/heads/main").expect("valid ref name");
2471        assert_eq!(name.as_str(), "refs/heads/main");
2472        assert_eq!(name, "refs/heads/main");
2473        assert_eq!(format!("{name}"), "refs/heads/main");
2474        assert_eq!(String::from(name.clone()), "refs/heads/main");
2475        let borrowed: &str = name.borrow();
2476        assert_eq!(borrowed, "refs/heads/main");
2477    }
2478
2479    #[test]
2480    fn full_name_rejects_invalid_ref_names() {
2481        assert!(FullName::new("").is_err());
2482        assert!(FullName::new(" refs/heads/main").is_err());
2483        assert!(FullName::new("refs/heads/main ").is_err());
2484        assert!(FullName::new("refs//heads/main").is_err());
2485        assert!(FullName::new("refs/heads/\nmain").is_err());
2486    }
2487
2488    #[test]
2489    fn cli_exit_codes_match_git_taxonomy() {
2490        assert_eq!(CliExit::Ok.code(), 0);
2491        assert_eq!(CliExit::UserError.code(), 128);
2492        assert_eq!(CliExit::Usage.code(), 129);
2493        assert_eq!(CliExit::Custom(1).code(), 1);
2494        assert_eq!(CliExit::Custom(5).code(), 5);
2495    }
2496
2497    #[test]
2498    fn git_error_cli_exit_code_mapping() {
2499        assert_eq!(GitError::Exit(129).cli_exit_code(), 129);
2500        assert_eq!(GitError::Exit(128).cli_exit_code(), 128);
2501        assert_eq!(GitError::usage("unknown option").cli_exit_code(), 129);
2502        assert_eq!(
2503            GitError::user_error("not a git repository").cli_exit_code(),
2504            128
2505        );
2506        assert_eq!(
2507            GitError::cli_exit(CliExit::Custom(2), "diff found changes").cli_exit_code(),
2508            2
2509        );
2510        assert_eq!(GitError::Command("bad value".into()).cli_exit_code(), 1);
2511        assert_eq!(GitError::not_found("missing ref").cli_exit_code(), 1);
2512    }
2513
2514    #[test]
2515    fn git_error_cli_displays_message_only() {
2516        let err = GitError::usage("unknown option `--foo'");
2517        assert_eq!(err.to_string(), "unknown option `--foo'");
2518    }
2519
2520    #[test]
2521    fn bstring_round_trips_bytes_and_displays_lossily() {
2522        let path = BString::from_bytes(b"src/\xFF.txt");
2523        assert_eq!(path.as_bytes(), b"src/\xFF.txt");
2524        let borrowed: &[u8] = path.borrow();
2525        assert_eq!(borrowed, b"src/\xFF.txt".as_slice());
2526        assert_eq!(format!("{path}"), "src/\u{FFFD}.txt");
2527        assert_eq!(path, b"src/\xFF.txt");
2528        assert_eq!(path.clone().into_bytes(), b"src/\xFF.txt".to_vec());
2529    }
2530
2531    #[test]
2532    fn split_ident_line_parses_well_formed_ident() {
2533        let f = split_ident_line(b"A U Thor <author@example.com> 1112911993 -0700")
2534            .expect("well formed ident should parse");
2535        assert_eq!(f.name, b"A U Thor");
2536        assert_eq!(f.email, b"author@example.com");
2537        assert_eq!(f.date, Some(&b"1112911993"[..]));
2538        assert_eq!(f.tz, Some(&b"-0700"[..]));
2539    }
2540
2541    #[test]
2542    fn split_ident_line_recovers_broken_email() {
2543        // git inserts junk after the '>': email stops at the first '>', but the
2544        // timestamp is found by scanning back from the end for the last '>'.
2545        let f = split_ident_line(b"A U Thor <author@example.com>-<> 1112911993 -0700")
2546            .expect("broken-email ident should parse");
2547        assert_eq!(f.name, b"A U Thor");
2548        assert_eq!(f.email, b"author@example.com");
2549        assert_eq!(f.date, Some(&b"1112911993"[..]));
2550        assert_eq!(f.tz, Some(&b"-0700"[..]));
2551    }
2552
2553    #[test]
2554    fn split_ident_line_non_numeric_date_is_person_only() {
2555        let f = split_ident_line(b"A U Thor <author@example.com> totally_bogus -0700")
2556            .expect("ident without numeric date should still parse person");
2557        assert_eq!(f.email, b"author@example.com");
2558        assert_eq!(f.date, None);
2559        assert_eq!(f.tz, None);
2560    }
2561
2562    #[test]
2563    fn split_ident_line_whitespace_date_is_person_only() {
2564        // Trailing spaces after '>' with no timestamp -> no date.
2565        let f = split_ident_line(b"A U Thor <author@example.com>    ")
2566            .expect("ident with trailing whitespace should parse person");
2567        assert_eq!(f.date, None);
2568        // A vertical tab is NOT git-isspace, so it stops the space-skip and the
2569        // (non-digit) VT yields no date either.
2570        let f = split_ident_line(b"A U Thor <author@example.com>   \x0b")
2571            .expect("ident with non-git-whitespace suffix should parse person");
2572        assert_eq!(f.date, None);
2573    }
2574
2575    #[test]
2576    fn split_ident_line_requires_angle_brackets() {
2577        assert!(split_ident_line(b"no brackets here 123 +0000").is_none());
2578    }
2579
2580    #[test]
2581    fn ident_render_date_overflow_is_epoch_sentinel() {
2582        // 2^64 + 1 (clamps in u64 parse) and 2^64 - 2 (fits u64 but past time_t)
2583        // both render the epoch sentinel with a forced +0000 timezone.
2584        assert_eq!(
2585            ident_render_date(b"18446744073709551617", b"-0700", &DateMode::Default),
2586            "Thu Jan 1 00:00:00 1970 +0000"
2587        );
2588        assert_eq!(
2589            ident_render_date(b"18446744073709551614", b"-0700", &DateMode::Default),
2590            "Thu Jan 1 00:00:00 1970 +0000"
2591        );
2592    }
2593
2594    #[test]
2595    fn ident_render_date_valid_value_uses_original_timezone() {
2596        assert_eq!(
2597            ident_render_date(b"0", b"+0000", &DateMode::Default),
2598            "Thu Jan 1 00:00:00 1970 +0000"
2599        );
2600    }
2601}