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