Skip to main content

sley_core/
lib.rs

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