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