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