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