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