Skip to main content

sley_core/
lib.rs

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