Skip to main content

sley_ref_filter/
lib.rs

1//! Shared ref-filter formatting primitives.
2//!
3//! Git reuses the same identity/date/refname formatting language across
4//! `for-each-ref`, `branch`, `tag`, `log`, `show`, `stash`, and status output.
5//! This crate owns those semantic primitives so the CLI can remain an entry
6//! point instead of a home for every command's formatting state.
7
8use sley_core::{DateMode, GitError, ObjectId, Result};
9use sley_strbuf_expand::{
10    AtomTable, ExpandFormat, ExpandSegment, PaddingAlign, PaddingSpec, apply_magic,
11};
12use std::collections::HashMap;
13use std::io::Write;
14
15mod atoms;
16mod context;
17mod contents;
18mod repo;
19mod sort;
20mod tracking;
21mod versioncmp;
22
23pub use atoms::{
24    ForEachRefEmailOptions, for_each_ref_color_escape, for_each_ref_message,
25    for_each_ref_oid_atom_arg, for_each_ref_oid_atom_width, for_each_ref_push_color_code,
26    for_each_ref_try_date_atom, for_each_ref_try_email_atom, for_each_ref_try_name_atom,
27    for_each_ref_typed_identity, for_each_ref_typed_refname, for_each_ref_write_email,
28    setup_for_each_ref_email_options, write_for_each_ref_signature, write_for_each_ref_typed_atom,
29};
30pub use context::{
31    ForEachRefFormatContext, ForEachRefMailmapRewrite, ForEachRefSignatureVerification,
32};
33pub use contents::{
34    ForEachRefContents, ForEachRefPeeledObject, for_each_ref_contents,
35    for_each_ref_validate_tag_pointer, write_for_each_ref_contents_lines,
36};
37pub use repo::{
38    for_each_ref_loose_object_disk_size, for_each_ref_worktree_path, for_each_ref_worktree_paths,
39};
40pub use sort::{
41    ForEachRefDateSortField, ForEachRefIdentityPart, ForEachRefIdentitySortField,
42    ForEachRefIdentitySource, ForEachRefIdentityRole, for_each_ref_sort_date_key,
43    for_each_ref_sort_identity_key, parse_for_each_ref_identity_sort,
44};
45pub use tracking::{
46    ForEachRefPush, ForEachRefPushRemote, ForEachRefUpstream, expand_local_upstream_merge,
47    for_each_ref_ahead_behind, for_each_ref_ahead_behind_with_diagnostic, for_each_ref_push,
48    for_each_ref_push_remote, for_each_ref_upstream, for_each_ref_upstream_track,
49    map_remote_fetch_refspec, map_remote_push_refspec, map_remote_tracking_ref,
50    remote_display_name, resolve_for_each_ref_target,
51};
52pub use versioncmp::{
53    VsSuffixMatch, version_sort_cmp, vs_digit_class, vs_find_better_matching_suffix,
54    vs_swap_prereleases,
55};
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ForEachRefFormat {
59    inner: ExpandFormat<ForEachRefAtom>,
60    segments: Vec<ForEachRefFormatSegment>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum ForEachRefFormatSegment {
65    Literal(Vec<u8>),
66    Atom(ForEachRefAtom),
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum ForEachRefAtom {
71    Raw(String),
72    Color(String),
73    RefName {
74        source: ForEachRefNameSource,
75        format: ForEachRefNameFormat,
76    },
77    ObjectName {
78        peeled: bool,
79        abbrev: Option<usize>,
80    },
81    Identity {
82        peeled: bool,
83        role: ForEachRefAtomIdentityRole,
84        part: ForEachRefAtomIdentityPart,
85    },
86    ContentsLines {
87        peeled: bool,
88        count: usize,
89    },
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum ForEachRefNameSource {
94    Ref,
95    Upstream,
96    Push,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum ForEachRefNameFormat {
101    Full,
102    Short,
103    Strip(ForEachRefStrip),
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub struct ForEachRefStrip {
108    pub direction: ForEachRefStripDirection,
109    pub count: isize,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum ForEachRefStripDirection {
114    Left,
115    Right,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum ForEachRefAtomIdentityRole {
120    Author,
121    Committer,
122    Tagger,
123    Creator,
124}
125
126/// A date atom used as a `for-each-ref --sort` key.
127///
128/// Bare date atoms are sorted numerically by their timestamp. Once a date
129/// format is supplied, Git sorts the rendered value bytewise instead. Keeping
130/// the parsed mode here lets command frontends share that distinction without
131/// reimplementing the ref-filter date grammar.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct ForEachRefDateSort {
134    pub peeled: bool,
135    pub role: ForEachRefAtomIdentityRole,
136    pub mode: DateMode,
137    pub descending: bool,
138}
139
140/// Parse a date sort atom, returning `None` when `value` names another atom.
141pub fn parse_for_each_ref_date_sort(value: &str) -> Result<Option<ForEachRefDateSort>> {
142    let (value, descending) = value
143        .strip_prefix('-')
144        .map(|value| (value, true))
145        .unwrap_or((value, false));
146    let (value, peeled) = value
147        .strip_prefix('*')
148        .map(|value| (value, true))
149        .unwrap_or((value, false));
150    let (atom, modifier) = value
151        .split_once(':')
152        .map(|(atom, modifier)| (atom, Some(modifier)))
153        .unwrap_or((value, None));
154    let role = match atom {
155        "authordate" => ForEachRefAtomIdentityRole::Author,
156        "committerdate" => ForEachRefAtomIdentityRole::Committer,
157        "taggerdate" => ForEachRefAtomIdentityRole::Tagger,
158        "creatordate" => ForEachRefAtomIdentityRole::Creator,
159        _ => return Ok(None),
160    };
161    let mode = DateMode::parse_atom_modifier(modifier).ok_or_else(|| {
162        GitError::Command(format!(
163            "unrecognized %({atom}) argument: {}",
164            modifier.unwrap_or("")
165        ))
166    })?;
167    Ok(Some(ForEachRefDateSort {
168        peeled,
169        role,
170        mode,
171        descending,
172    }))
173}
174
175/// Select the ref that Git's `%(is-base:<tip>)` heuristic marks.
176///
177/// Histories are ordered from each commit towards its first parent. The best
178/// candidate is the one whose first-parent history intersects the tip history
179/// closest to the tip; candidate order breaks ties, matching ref-array order.
180pub fn select_for_each_ref_is_base_candidate(
181    tip_first_parent_history: &[ObjectId],
182    candidate_first_parent_histories: &[Vec<ObjectId>],
183) -> Option<usize> {
184    let tip_positions = tip_first_parent_history
185        .iter()
186        .enumerate()
187        .map(|(position, oid)| (*oid, position))
188        .collect::<HashMap<_, _>>();
189
190    candidate_first_parent_histories
191        .iter()
192        .enumerate()
193        .filter_map(|(candidate, history)| {
194            history
195                .iter()
196                .filter_map(|oid| tip_positions.get(oid).copied())
197                .min()
198                .map(|tip_distance| (tip_distance, candidate))
199        })
200        .min()
201        .map(|(_, candidate)| candidate)
202}
203
204/// Whether `name` is one of Git's enumerable root refs.
205///
206/// Root-ref syntax alone is broader than the ref-filter surface: `FETCH_HEAD`
207/// and `MERGE_HEAD` are pseudorefs and are deliberately excluded, while HEAD,
208/// `*_HEAD`, and Git's named root refs are included when they resolve.
209pub fn is_for_each_ref_root_ref(name: &str) -> bool {
210    let root_syntax = !name.is_empty()
211        && name
212            .bytes()
213            .all(|byte| byte.is_ascii_uppercase() || byte == b'-' || byte == b'_');
214    if !root_syntax || matches!(name, "FETCH_HEAD" | "MERGE_HEAD") {
215        return false;
216    }
217    name.ends_with("_HEAD")
218        || matches!(
219            name,
220            "HEAD"
221                | "AUTO_MERGE"
222                | "BISECT_EXPECTED_REV"
223                | "NOTES_MERGE_PARTIAL"
224                | "NOTES_MERGE_REF"
225                | "MERGE_AUTOSTASH"
226        )
227}
228
229/// Parse Git's `#rrggbb` color spelling used by `%(color:<value>)` atoms.
230pub fn parse_for_each_ref_hex_color(value: &str) -> Option<(u8, u8, u8)> {
231    let hex = value.strip_prefix('#')?;
232    if hex.len() != 6 {
233        return None;
234    }
235    Some((
236        u8::from_str_radix(&hex[0..2], 16).ok()?,
237        u8::from_str_radix(&hex[2..4], 16).ok()?,
238        u8::from_str_radix(&hex[4..6], 16).ok()?,
239    ))
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub enum ForEachRefAtomIdentityPart {
244    Full,
245    Name,
246    Email(ForEachRefEmailMode),
247    Date(DateMode),
248    DateRaw,
249}
250
251impl ForEachRefAtom {
252    fn parse(value: &str) -> Result<Self> {
253        // git's parse_ref_filter_atom: an empty sub-argument list is treated as
254        // NULL, i.e. `%(atom:)` is equivalent to `%(atom)`. The arg is whatever
255        // follows the FIRST colon, so only a trailing colon at that position is
256        // dropped (e.g. `refname:` -> `refname`).
257        let value = match value.split_once(':') {
258            Some((name, "")) => name,
259            _ => value,
260        };
261        if let Some(color) = value.strip_prefix("color:") {
262            return Ok(Self::Color(color.to_string()));
263        }
264        if let Some(atom) = parse_for_each_ref_refname_atom(value)? {
265            return Ok(atom);
266        }
267        if let Some(atom) = parse_for_each_ref_objectname_atom(value)? {
268            return Ok(atom);
269        }
270        if let Some(atom) = parse_for_each_ref_identity_atom(value) {
271            return Ok(atom);
272        }
273        if let Some(count) = value.strip_prefix("contents:lines=") {
274            return Ok(Self::ContentsLines {
275                peeled: false,
276                count: parse_for_each_ref_contents_lines_count(count)?,
277            });
278        }
279        if let Some(count) = value.strip_prefix("*contents:lines=") {
280            return Ok(Self::ContentsLines {
281                peeled: true,
282                count: parse_for_each_ref_contents_lines_count(count)?,
283            });
284        }
285        Ok(Self::Raw(value.to_string()))
286    }
287}
288
289struct ForEachRefAtomTable;
290
291impl AtomTable for ForEachRefAtomTable {
292    type Atom = ForEachRefAtom;
293
294    fn parse_atom(&self, value: &str) -> Result<Self::Atom> {
295        ForEachRefAtom::parse(value)
296    }
297}
298
299fn parse_for_each_ref_refname_atom(value: &str) -> Result<Option<ForEachRefAtom>> {
300    for (prefix, source) in [
301        ("refname", ForEachRefNameSource::Ref),
302        ("upstream", ForEachRefNameSource::Upstream),
303        ("push", ForEachRefNameSource::Push),
304    ] {
305        if value == prefix {
306            return Ok(Some(ForEachRefAtom::RefName {
307                source,
308                format: ForEachRefNameFormat::Full,
309            }));
310        }
311        let Some(modifier) = value
312            .strip_prefix(prefix)
313            .and_then(|value| value.strip_prefix(':'))
314        else {
315            continue;
316        };
317        let format = if modifier == "short" {
318            ForEachRefNameFormat::Short
319        } else if let Some(count) = modifier
320            .strip_prefix("lstrip=")
321            .or_else(|| modifier.strip_prefix("strip="))
322        {
323            ForEachRefNameFormat::Strip(ForEachRefStrip {
324                direction: ForEachRefStripDirection::Left,
325                count: parse_for_each_ref_strip_count(count)?,
326            })
327        } else if let Some(count) = modifier.strip_prefix("rstrip=") {
328            ForEachRefNameFormat::Strip(ForEachRefStrip {
329                direction: ForEachRefStripDirection::Right,
330                count: parse_for_each_ref_strip_count(count)?,
331            })
332        } else if prefix == "refname" {
333            // git's refname_atom_parser rejects unknown args outright (the
334            // upstream/push variants accept extra modifiers handled later, so
335            // only `refname` is strict here).
336            eprintln!("fatal: unrecognized %({prefix}) argument: {modifier}");
337            return Err(GitError::Exit(128));
338        } else {
339            continue;
340        };
341        return Ok(Some(ForEachRefAtom::RefName { source, format }));
342    }
343    Ok(None)
344}
345
346fn parse_for_each_ref_objectname_atom(value: &str) -> Result<Option<ForEachRefAtom>> {
347    for (prefix, peeled) in [("objectname", false), ("*objectname", true)] {
348        if value == prefix {
349            return Ok(Some(ForEachRefAtom::ObjectName {
350                peeled,
351                abbrev: None,
352            }));
353        }
354        if value.strip_prefix(prefix) == Some(":short") {
355            return Ok(Some(ForEachRefAtom::ObjectName {
356                peeled,
357                abbrev: Some(0),
358            }));
359        }
360        if let Some(width) = value
361            .strip_prefix(prefix)
362            .and_then(|value| value.strip_prefix(":short="))
363        {
364            return Ok(Some(ForEachRefAtom::ObjectName {
365                peeled,
366                abbrev: Some(parse_for_each_ref_abbrev_width(width)?),
367            }));
368        }
369    }
370    Ok(None)
371}
372
373fn parse_for_each_ref_identity_atom(value: &str) -> Option<ForEachRefAtom> {
374    let (value, peeled) = value
375        .strip_prefix('*')
376        .map(|value| (value, true))
377        .unwrap_or((value, false));
378    let (atom, has_modifier) = value
379        .split_once(':')
380        .map_or((value, false), |(atom, _)| (atom, true));
381    // `name` and the bare-identity atoms take no modifier in this typed path;
382    // anything with a `:` (e.g. `authorname:mailmap`, `author:foo`) falls through
383    // to the string/Raw renderer which owns the full option grammar + errors.
384    let plain = |part: ForEachRefAtomIdentityPart| if has_modifier { None } else { Some(part) };
385    let (role, part) = match atom {
386        "author" => (
387            ForEachRefAtomIdentityRole::Author,
388            plain(ForEachRefAtomIdentityPart::Full)?,
389        ),
390        "authorname" => (
391            ForEachRefAtomIdentityRole::Author,
392            plain(ForEachRefAtomIdentityPart::Name)?,
393        ),
394        "committer" => (
395            ForEachRefAtomIdentityRole::Committer,
396            plain(ForEachRefAtomIdentityPart::Full)?,
397        ),
398        "committername" => (
399            ForEachRefAtomIdentityRole::Committer,
400            plain(ForEachRefAtomIdentityPart::Name)?,
401        ),
402        "tagger" => (
403            ForEachRefAtomIdentityRole::Tagger,
404            plain(ForEachRefAtomIdentityPart::Full)?,
405        ),
406        "taggername" => (
407            ForEachRefAtomIdentityRole::Tagger,
408            plain(ForEachRefAtomIdentityPart::Name)?,
409        ),
410        "creator" => (
411            ForEachRefAtomIdentityRole::Creator,
412            plain(ForEachRefAtomIdentityPart::Full)?,
413        ),
414        _ => return None,
415    };
416    Some(ForEachRefAtom::Identity { peeled, role, part })
417}
418
419pub fn parse_for_each_ref_contents_lines_count(value: &str) -> Result<usize> {
420    value
421        .parse::<usize>()
422        .map_err(|_| GitError::Command(format!("invalid for-each-ref contents line count {value}")))
423}
424
425impl ForEachRefFormat {
426    pub fn parse(format_spec: &str) -> Result<Self> {
427        let inner = ExpandFormat::parse(format_spec, &ForEachRefAtomTable)?;
428        let segments = inner
429            .segments()
430            .iter()
431            .filter_map(|segment| match segment {
432                ExpandSegment::Literal(literal) => {
433                    Some(ForEachRefFormatSegment::Literal(literal.clone()))
434                }
435                ExpandSegment::Atom(atom) => Some(ForEachRefFormatSegment::Atom(atom.atom.clone())),
436                ExpandSegment::Padding(_) => None,
437            })
438            .collect();
439        Ok(Self { inner, segments })
440    }
441
442    pub fn segments(&self) -> &[ForEachRefFormatSegment] {
443        &self.segments
444    }
445
446    /// Mirror git's `need_color_reset_at_eol`: true when the format contains at
447    /// least one `%(color:...)` atom and the last such atom is not
448    /// `%(color:reset)`. The caller still gates this on color being enabled.
449    pub fn ends_with_unreset_color(&self) -> bool {
450        let mut need_reset = false;
451        for segment in &self.segments {
452            if let ForEachRefFormatSegment::Atom(ForEachRefAtom::Color(value)) = segment {
453                need_reset = value.trim() != "reset";
454            }
455        }
456        need_reset
457    }
458}
459
460pub fn write_for_each_ref_format(
461    stdout: &mut impl Write,
462    format: &ForEachRefFormat,
463    quote: ForEachRefQuoteMode,
464    reset_color_at_eol: bool,
465    mut write_atom: impl FnMut(&mut Vec<u8>, &ForEachRefAtom) -> Result<()>,
466) -> Result<()> {
467    if !format
468        .inner
469        .segments()
470        .iter()
471        .any(for_each_ref_segment_has_control)
472    {
473        format
474            .inner
475            .write_to(stdout, &mut write_atom, |stdout, value| {
476                write_for_each_ref_quoted_atom(stdout, value, quote)
477            })?;
478        if reset_color_at_eol {
479            stdout.write_all(b"\x1b[m")?;
480        }
481        return Ok(());
482    }
483
484    let mut rendered = Vec::new();
485    let (idx, stop) = write_for_each_ref_format_range(
486        &mut rendered,
487        format.inner.segments(),
488        0,
489        &[],
490        quote,
491        &mut write_atom,
492    )?;
493    if idx != format.inner.segments().len() || stop.is_some() {
494        return Err(GitError::Command(
495            "improper for-each-ref format control atom usage".into(),
496        ));
497    }
498    stdout.write_all(&rendered)?;
499    if reset_color_at_eol {
500        stdout.write_all(b"\x1b[m")?;
501    }
502    Ok(())
503}
504
505fn for_each_ref_segment_has_control(segment: &ExpandSegment<ForEachRefAtom>) -> bool {
506    match segment {
507        ExpandSegment::Atom(atom) => for_each_ref_control_atom(&atom.atom).is_some(),
508        ExpandSegment::Literal(_) | ExpandSegment::Padding(_) => false,
509    }
510}
511
512fn write_for_each_ref_format_range(
513    out: &mut Vec<u8>,
514    segments: &[ExpandSegment<ForEachRefAtom>],
515    mut idx: usize,
516    stops: &[ForEachRefControlStop],
517    quote: ForEachRefQuoteMode,
518    write_atom: &mut impl FnMut(&mut Vec<u8>, &ForEachRefAtom) -> Result<()>,
519) -> Result<(usize, Option<ForEachRefControlStop>)> {
520    let mut pending_padding = None;
521    while idx < segments.len() {
522        match &segments[idx] {
523            ExpandSegment::Literal(literal) => out.extend_from_slice(literal),
524            ExpandSegment::Padding(padding) => pending_padding = Some(*padding),
525            ExpandSegment::Atom(atom) => {
526                if let Some(control) = for_each_ref_control_atom(&atom.atom) {
527                    if let Some(stop) = control.stop()
528                        && stops.contains(&stop)
529                    {
530                        return Ok((idx, Some(stop)));
531                    }
532                    match control {
533                        ForEachRefControlAtom::Align(options) => {
534                            let (value, next) =
535                                render_for_each_ref_align(segments, idx + 1, &options, write_atom)?;
536                            let mut value = value;
537                            apply_for_each_ref_padding(&mut value, pending_padding.take());
538                            apply_magic(out, atom.magic, &value);
539                            write_for_each_ref_quoted_atom(out, &value, quote)?;
540                            idx = next;
541                            continue;
542                        }
543                        ForEachRefControlAtom::If(condition) => {
544                            let (value, next) = render_for_each_ref_if(
545                                segments,
546                                idx + 1,
547                                &condition,
548                                quote,
549                                write_atom,
550                            )?;
551                            let mut value = value;
552                            apply_for_each_ref_padding(&mut value, pending_padding.take());
553                            apply_magic(out, atom.magic, &value);
554                            out.extend_from_slice(&value);
555                            idx = next;
556                            continue;
557                        }
558                        ForEachRefControlAtom::Then
559                        | ForEachRefControlAtom::Else
560                        | ForEachRefControlAtom::End => {
561                            return Err(GitError::Command(
562                                "improper for-each-ref format control atom usage".into(),
563                            ));
564                        }
565                    }
566                }
567
568                let mut value = Vec::new();
569                write_atom(&mut value, &atom.atom)?;
570                apply_for_each_ref_padding(&mut value, pending_padding.take());
571                apply_magic(out, atom.magic, &value);
572                write_for_each_ref_quoted_atom(out, &value, quote)?;
573            }
574        }
575        idx += 1;
576    }
577    Ok((idx, None))
578}
579
580fn render_for_each_ref_align(
581    segments: &[ExpandSegment<ForEachRefAtom>],
582    start: usize,
583    options: &ForEachRefAlignOptions,
584    write_atom: &mut impl FnMut(&mut Vec<u8>, &ForEachRefAtom) -> Result<()>,
585) -> Result<(Vec<u8>, usize)> {
586    let mut value = Vec::new();
587    let (idx, stop) = write_for_each_ref_format_range(
588        &mut value,
589        segments,
590        start,
591        &[ForEachRefControlStop::End],
592        ForEachRefQuoteMode::None,
593        write_atom,
594    )?;
595    if stop != Some(ForEachRefControlStop::End) {
596        return Err(GitError::Command("missing %(end) atom for %(align)".into()));
597    }
598    apply_for_each_ref_align(&mut value, options);
599    Ok((value, idx + 1))
600}
601
602fn render_for_each_ref_if(
603    segments: &[ExpandSegment<ForEachRefAtom>],
604    start: usize,
605    condition: &ForEachRefIfCondition,
606    quote: ForEachRefQuoteMode,
607    write_atom: &mut impl FnMut(&mut Vec<u8>, &ForEachRefAtom) -> Result<()>,
608) -> Result<(Vec<u8>, usize)> {
609    let mut test = Vec::new();
610    let (then_idx, stop) = write_for_each_ref_format_range(
611        &mut test,
612        segments,
613        start,
614        &[ForEachRefControlStop::Then],
615        ForEachRefQuoteMode::None,
616        write_atom,
617    )?;
618    if stop != Some(ForEachRefControlStop::Then) {
619        return Err(GitError::Command("missing %(then) atom for %(if)".into()));
620    }
621
622    let mut true_value = Vec::new();
623    let (branch_idx, branch_stop) = write_for_each_ref_format_range(
624        &mut true_value,
625        segments,
626        then_idx + 1,
627        &[ForEachRefControlStop::Else, ForEachRefControlStop::End],
628        quote,
629        write_atom,
630    )?;
631
632    let mut false_value = Vec::new();
633    let end_idx = match branch_stop {
634        Some(ForEachRefControlStop::End) => branch_idx,
635        Some(ForEachRefControlStop::Else) => {
636            let (idx, stop) = write_for_each_ref_format_range(
637                &mut false_value,
638                segments,
639                branch_idx + 1,
640                &[ForEachRefControlStop::End],
641                quote,
642                write_atom,
643            )?;
644            if stop != Some(ForEachRefControlStop::End) {
645                return Err(GitError::Command("missing %(end) atom for %(if)".into()));
646            }
647            idx
648        }
649        Some(ForEachRefControlStop::Then) | None => {
650            return Err(GitError::Command("missing %(end) atom for %(if)".into()));
651        }
652    };
653
654    let test = trim_ascii(&test);
655    let matched = match condition {
656        ForEachRefIfCondition::NonEmpty => !test.is_empty(),
657        ForEachRefIfCondition::Equals(value) => test == value.as_bytes(),
658        ForEachRefIfCondition::NotEquals(value) => test != value.as_bytes(),
659    };
660    Ok((if matched { true_value } else { false_value }, end_idx + 1))
661}
662
663#[derive(Debug, Clone, Copy, PartialEq, Eq)]
664enum ForEachRefControlStop {
665    Then,
666    Else,
667    End,
668}
669
670enum ForEachRefControlAtom {
671    Align(ForEachRefAlignOptions),
672    If(ForEachRefIfCondition),
673    Then,
674    Else,
675    End,
676}
677
678impl ForEachRefControlAtom {
679    fn stop(&self) -> Option<ForEachRefControlStop> {
680        match self {
681            Self::Then => Some(ForEachRefControlStop::Then),
682            Self::Else => Some(ForEachRefControlStop::Else),
683            Self::End => Some(ForEachRefControlStop::End),
684            Self::Align(_) | Self::If(_) => None,
685        }
686    }
687}
688
689#[derive(Clone, Copy)]
690enum ForEachRefAlignPosition {
691    Left,
692    Middle,
693    Right,
694}
695
696struct ForEachRefAlignOptions {
697    width: usize,
698    position: ForEachRefAlignPosition,
699}
700
701enum ForEachRefIfCondition {
702    NonEmpty,
703    Equals(String),
704    NotEquals(String),
705}
706
707fn for_each_ref_control_atom(atom: &ForEachRefAtom) -> Option<ForEachRefControlAtom> {
708    let ForEachRefAtom::Raw(value) = atom else {
709        return None;
710    };
711    if let Some(options) = value.strip_prefix("align:") {
712        return parse_for_each_ref_align_options(options).map(ForEachRefControlAtom::Align);
713    }
714    if value == "if" {
715        return Some(ForEachRefControlAtom::If(ForEachRefIfCondition::NonEmpty));
716    }
717    if let Some(expected) = value.strip_prefix("if:equals=") {
718        return Some(ForEachRefControlAtom::If(ForEachRefIfCondition::Equals(
719            expected.to_string(),
720        )));
721    }
722    if let Some(expected) = value.strip_prefix("if:notequals=") {
723        return Some(ForEachRefControlAtom::If(ForEachRefIfCondition::NotEquals(
724            expected.to_string(),
725        )));
726    }
727    match value.as_str() {
728        "then" => Some(ForEachRefControlAtom::Then),
729        "else" => Some(ForEachRefControlAtom::Else),
730        "end" => Some(ForEachRefControlAtom::End),
731        _ => None,
732    }
733}
734
735fn parse_for_each_ref_align_options(value: &str) -> Option<ForEachRefAlignOptions> {
736    let mut width = None;
737    let mut position = ForEachRefAlignPosition::Left;
738    for part in value.split(',') {
739        if let Some(rest) = part.strip_prefix("width=") {
740            width = rest.parse::<usize>().ok();
741        } else if let Some(rest) = part.strip_prefix("position=") {
742            position = parse_for_each_ref_align_position(rest)?;
743        } else if let Ok(parsed) = part.parse::<usize>() {
744            width = Some(parsed);
745        } else {
746            position = parse_for_each_ref_align_position(part)?;
747        }
748    }
749    Some(ForEachRefAlignOptions {
750        width: width?,
751        position,
752    })
753}
754
755fn parse_for_each_ref_align_position(value: &str) -> Option<ForEachRefAlignPosition> {
756    match value {
757        "left" => Some(ForEachRefAlignPosition::Left),
758        "middle" => Some(ForEachRefAlignPosition::Middle),
759        "right" => Some(ForEachRefAlignPosition::Right),
760        _ => None,
761    }
762}
763
764fn apply_for_each_ref_align(value: &mut Vec<u8>, options: &ForEachRefAlignOptions) {
765    let width = for_each_ref_display_width(value);
766    if width >= options.width {
767        return;
768    }
769    let extra = options.width - width;
770    let (left, right) = match options.position {
771        ForEachRefAlignPosition::Left => (0, extra),
772        ForEachRefAlignPosition::Middle => (extra / 2, extra - extra / 2),
773        ForEachRefAlignPosition::Right => (extra, 0),
774    };
775    let mut padded = Vec::with_capacity(value.len() + extra);
776    padded.extend(std::iter::repeat_n(b' ', left));
777    padded.extend_from_slice(value);
778    padded.extend(std::iter::repeat_n(b' ', right));
779    *value = padded;
780}
781
782fn apply_for_each_ref_padding(value: &mut Vec<u8>, padding: Option<PaddingSpec>) {
783    let Some(padding) = padding else {
784        return;
785    };
786    let width = for_each_ref_display_width(value);
787    let target = padding.width.max(0) as usize;
788    if width >= target {
789        return;
790    }
791    let extra = target - width;
792    let (left, right) = match padding.align {
793        PaddingAlign::Left => (0, extra),
794        PaddingAlign::Right | PaddingAlign::LeftAndSteal => (extra, 0),
795        PaddingAlign::Center => (extra / 2, extra - extra / 2),
796    };
797    let mut padded = Vec::with_capacity(value.len() + extra);
798    padded.extend(std::iter::repeat_n(b' ', left));
799    padded.extend_from_slice(value);
800    padded.extend(std::iter::repeat_n(b' ', right));
801    *value = padded;
802}
803
804fn for_each_ref_display_width(value: &[u8]) -> usize {
805    let mut width = 0usize;
806    let mut idx = 0usize;
807    while idx < value.len() {
808        if let Some(len) = csi_escape_sequence_len(value, idx) {
809            idx += len;
810            continue;
811        }
812        // Measure the text run up to the next escape by display columns, not
813        // bytes, so multibyte characters (CJK, accents, emoji) pad like git.
814        let mut run = idx + 1;
815        while run < value.len() && csi_escape_sequence_len(value, run).is_none() {
816            run += 1;
817        }
818        width += sley_strbuf_expand::strwidth(&value[idx..run]);
819        idx = run;
820    }
821    width
822}
823
824/// Length of the CSI escape sequence starting at `idx`, if any
825/// (`ESC [ ... final-byte`, final byte in `0x40..=0x7e`).
826fn csi_escape_sequence_len(value: &[u8], idx: usize) -> Option<usize> {
827    if value[idx] != 0x1b || value.get(idx + 1) != Some(&b'[') {
828        return None;
829    }
830    value[idx + 2..]
831        .iter()
832        .position(|byte| (0x40..=0x7e).contains(byte))
833        .map(|end| end + 3)
834}
835
836fn trim_ascii(value: &[u8]) -> &[u8] {
837    let start = value
838        .iter()
839        .position(|byte| !byte.is_ascii_whitespace())
840        .unwrap_or(value.len());
841    let end = value
842        .iter()
843        .rposition(|byte| !byte.is_ascii_whitespace())
844        .map(|idx| idx + 1)
845        .unwrap_or(start);
846    &value[start..end]
847}
848
849#[derive(Debug, Default, Clone, Copy, Eq, PartialEq)]
850pub enum ForEachRefQuoteMode {
851    #[default]
852    None,
853    Shell,
854    Python,
855    Perl,
856    Tcl,
857}
858
859pub fn write_for_each_ref_quoted_atom(
860    stdout: &mut impl Write,
861    value: &[u8],
862    quote: ForEachRefQuoteMode,
863) -> Result<()> {
864    match quote {
865        ForEachRefQuoteMode::None => stdout.write_all(value)?,
866        ForEachRefQuoteMode::Shell => {
867            stdout.write_all(b"'")?;
868            for byte in value {
869                if *byte == b'\'' {
870                    stdout.write_all(br#"'\''"#)?;
871                } else {
872                    stdout.write_all(&[*byte])?;
873                }
874            }
875            stdout.write_all(b"'")?;
876        }
877        ForEachRefQuoteMode::Python | ForEachRefQuoteMode::Perl => {
878            stdout.write_all(b"'")?;
879            for byte in value {
880                match (*byte, quote) {
881                    (b'\\', _) => stdout.write_all(br#"\\"#)?,
882                    (b'\'', _) => stdout.write_all(br#"\'"#)?,
883                    (b'\n', ForEachRefQuoteMode::Python) => stdout.write_all(br#"\n"#)?,
884                    _ => stdout.write_all(&[*byte])?,
885                }
886            }
887            stdout.write_all(b"'")?;
888        }
889        ForEachRefQuoteMode::Tcl => {
890            stdout.write_all(b"\"")?;
891            for byte in value {
892                match *byte {
893                    b'\\' => stdout.write_all(br#"\\"#)?,
894                    b'"' => stdout.write_all(br#"\""#)?,
895                    b'\n' => stdout.write_all(br#"\n"#)?,
896                    _ => stdout.write_all(&[*byte])?,
897                }
898            }
899            stdout.write_all(b"\"")?;
900        }
901    }
902    Ok(())
903}
904
905#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
906pub struct ForEachRefTrack {
907    pub ahead: usize,
908    pub behind: usize,
909    /// The upstream is configured but its ref no longer resolves; git renders
910    /// `%(upstream:track)` as `[gone]` and `%(upstream:trackshort)` as empty.
911    pub gone: bool,
912}
913
914#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
915pub enum ForEachRefEmailMode {
916    #[default]
917    Bracketed,
918    Trim,
919    LocalPart,
920}
921
922pub fn write_for_each_ref_track(
923    stdout: &mut impl Write,
924    track: ForEachRefTrack,
925    bracketed: bool,
926) -> Result<()> {
927    if track.gone {
928        // git emits a literal "[gone]" (or bare "gone" with nobracket) when the
929        // configured upstream no longer resolves.
930        if bracketed {
931            stdout.write_all(b"[gone]")?;
932        } else {
933            stdout.write_all(b"gone")?;
934        }
935        return Ok(());
936    }
937    if bracketed && (track.ahead > 0 || track.behind > 0) {
938        stdout.write_all(b"[")?;
939    }
940    match (track.ahead, track.behind) {
941        (0, _) => {}
942        (ahead, 0) => write!(stdout, "ahead {ahead}")?,
943        (ahead, behind) => write!(stdout, "ahead {ahead}, behind {behind}")?,
944    }
945    if track.ahead == 0 && track.behind > 0 {
946        write!(stdout, "behind {}", track.behind)?;
947    }
948    if bracketed && (track.ahead > 0 || track.behind > 0) {
949        stdout.write_all(b"]")?;
950    }
951    Ok(())
952}
953
954pub fn for_each_ref_track_short(track: ForEachRefTrack) -> &'static str {
955    if track.gone {
956        // git's trackshort is empty for a gone upstream.
957        return "";
958    }
959    match (track.ahead, track.behind) {
960        (0, 0) => "=",
961        (_, 0) => ">",
962        (0, _) => "<",
963        (_, _) => "<>",
964    }
965}
966
967pub fn write_for_each_ref_identity(stdout: &mut impl Write, identity: Option<&[u8]>) -> Result<()> {
968    if let Some(identity) = identity {
969        stdout.write_all(identity)?;
970    }
971    Ok(())
972}
973
974pub fn write_for_each_ref_identity_name(
975    stdout: &mut impl Write,
976    identity: Option<&[u8]>,
977) -> Result<()> {
978    if let Some(identity) = identity
979        && let Some(name) = for_each_ref_identity_name(identity)
980    {
981        stdout.write_all(name)?;
982    }
983    Ok(())
984}
985
986pub fn write_for_each_ref_identity_email(
987    stdout: &mut impl Write,
988    identity: Option<&[u8]>,
989) -> Result<()> {
990    write_for_each_ref_identity_email_mode(stdout, identity, ForEachRefEmailMode::Bracketed)
991}
992
993pub fn write_for_each_ref_identity_email_mode(
994    stdout: &mut impl Write,
995    identity: Option<&[u8]>,
996    mode: ForEachRefEmailMode,
997) -> Result<()> {
998    if let Some(identity) = identity
999        && let Some(email) = for_each_ref_identity_email(identity, mode)
1000    {
1001        stdout.write_all(email)?;
1002    }
1003    Ok(())
1004}
1005
1006pub fn write_for_each_ref_identity_date_raw(
1007    stdout: &mut impl Write,
1008    identity: Option<&[u8]>,
1009) -> Result<()> {
1010    if let Some(identity) = identity
1011        && let Some(date) = for_each_ref_identity_date_raw(identity)
1012    {
1013        stdout.write_all(date)?;
1014    }
1015    Ok(())
1016}
1017
1018pub fn write_for_each_ref_identity_date(
1019    stdout: &mut impl Write,
1020    identity: Option<&[u8]>,
1021) -> Result<()> {
1022    write_for_each_ref_identity_date_mode(stdout, identity, &DateMode::Default)
1023}
1024
1025pub fn write_for_each_ref_identity_date_mode(
1026    stdout: &mut impl Write,
1027    identity: Option<&[u8]>,
1028    mode: &DateMode,
1029) -> Result<()> {
1030    if let Some(identity) = identity
1031        && let Some(date) = for_each_ref_identity_date(identity, mode)
1032    {
1033        stdout.write_all(date.as_bytes())?;
1034    }
1035    Ok(())
1036}
1037
1038pub fn for_each_ref_identity_name(identity: &[u8]) -> Option<&[u8]> {
1039    let marker = identity.windows(2).position(|window| window == b" <")?;
1040    Some(&identity[..marker])
1041}
1042
1043pub fn for_each_ref_identity_email(identity: &[u8], mode: ForEachRefEmailMode) -> Option<&[u8]> {
1044    let start = identity.iter().position(|byte| *byte == b'<')?;
1045    let end = identity[start..].iter().position(|byte| *byte == b'>')?;
1046    let bracketed = &identity[start..=start + end];
1047    match mode {
1048        ForEachRefEmailMode::Bracketed => Some(bracketed),
1049        ForEachRefEmailMode::Trim => Some(&identity[start + 1..start + end]),
1050        ForEachRefEmailMode::LocalPart => {
1051            let trimmed = &identity[start + 1..start + end];
1052            let at = trimmed.iter().position(|byte| *byte == b'@')?;
1053            Some(&trimmed[..at])
1054        }
1055    }
1056}
1057
1058pub fn for_each_ref_identity_date_raw(identity: &[u8]) -> Option<&[u8]> {
1059    // Locate the timestamp+timezone tail git's way (scanning back from the end
1060    // for the last '>'), then return the contiguous `<digits> <tz>` slice.
1061    let fields = sley_core::split_ident_line(identity)?;
1062    let date = fields.date?;
1063    let tz = fields.tz?;
1064    let base = identity.as_ptr() as usize;
1065    let start = date.as_ptr() as usize - base;
1066    let end = (tz.as_ptr() as usize - base) + tz.len();
1067    Some(&identity[start..end])
1068}
1069
1070pub fn for_each_ref_identity_date(identity: &[u8], mode: &DateMode) -> Option<String> {
1071    // git's show_ident_date semantics: an out-of-range timestamp renders the
1072    // epoch sentinel rather than dropping the field; a missing date renders
1073    // nothing (None).
1074    let fields = sley_core::split_ident_line(identity)?;
1075    let date = fields.date?;
1076    let tz = fields.tz.unwrap_or(b"+0000");
1077    Some(sley_core::ident_render_date(date, tz, mode))
1078}
1079
1080pub fn for_each_ref_identity_timestamp(identity: &[u8]) -> Option<i64> {
1081    let fields = sley_core::split_ident_line(identity)?;
1082    let date = fields.date?;
1083    std::str::from_utf8(date).ok()?.parse::<i64>().ok()
1084}
1085
1086/// The signature begin-markers git recognizes (`gpg-interface.c` format table).
1087/// A message line beginning with one of these starts the trailing signature.
1088const FOR_EACH_REF_SIGNATURE_MARKERS: [&[u8]; 4] = [
1089    b"-----BEGIN PGP SIGNATURE-----",
1090    b"-----BEGIN PGP MESSAGE-----",
1091    b"-----BEGIN SIGNED MESSAGE-----",
1092    b"-----BEGIN SSH SIGNATURE-----",
1093];
1094
1095/// Offset into `message` where the trailing signature begins, or the message
1096/// length when unsigned. Mirrors gpg-interface.c `parse_signed_buffer`: the
1097/// LAST line that starts with a signature marker wins.
1098fn for_each_ref_signature_start(message: &[u8]) -> usize {
1099    let mut start = 0;
1100    let mut sig = message.len();
1101    while start < message.len() {
1102        let line = &message[start..];
1103        if FOR_EACH_REF_SIGNATURE_MARKERS
1104            .iter()
1105            .any(|marker| line.starts_with(marker))
1106        {
1107            sig = start;
1108        }
1109        match line.iter().position(|byte| *byte == b'\n') {
1110            Some(eol) => start += eol + 1,
1111            None => break,
1112        }
1113    }
1114    sig
1115}
1116
1117/// The split of a commit/tag message into the regions git's for-each-ref atoms
1118/// expose, mirroring ref-filter.c `find_subpos`.
1119pub struct ForEachRefMessageParts<'a> {
1120    /// The subject line(s), with no trailing newline (raw bytes; callers run
1121    /// `for_each_ref_copy_subject` to collapse embedded newlines).
1122    pub subject: &'a [u8],
1123    /// `%(contents:body)` — body with the signature removed.
1124    pub body_without_sig: &'a [u8],
1125    /// `%(body)` (legacy) — body *including* the signature.
1126    pub body_with_sig: &'a [u8],
1127    /// `%(contents:signature)` — the trailing signature block (may be empty).
1128    pub signature: &'a [u8],
1129    /// `%(contents)` / `%(contents:size)` — the message from the subject start
1130    /// (after leading blank lines) to the end.
1131    pub bare: &'a [u8],
1132}
1133
1134/// Split a commit/tag message into the for-each-ref content regions, mirroring
1135/// ref-filter.c `find_subpos`. `message` is the header-stripped message (sley
1136/// already strips object headers before this point).
1137pub fn for_each_ref_message_parts(message: &[u8]) -> ForEachRefMessageParts<'_> {
1138    // Skip any leading empty lines (the header/body separator is already gone).
1139    let mut start = 0;
1140    while message.get(start) == Some(&b'\n') {
1141        start += 1;
1142    }
1143    let buf = &message[start..];
1144    let bare = buf;
1145    let sigstart = for_each_ref_signature_start(buf);
1146    let signature = &buf[sigstart..];
1147
1148    // Subject runs to the first blank line before the signature, else to the
1149    // signature start (treating the whole pre-sig message as subject).
1150    let subject_region = &buf[..sigstart];
1151    let subject_end = for_each_ref_blank_line(subject_region).unwrap_or(sigstart);
1152    let mut sublen = subject_end;
1153    while sublen > 0 && matches!(buf[sublen - 1], b'\n' | b'\r') {
1154        sublen -= 1;
1155    }
1156    let subject = &buf[..sublen];
1157
1158    // Body begins after the subject's trailing blank lines.
1159    let mut body_start = subject_end;
1160    while body_start < buf.len() && matches!(buf[body_start], b'\n' | b'\r') {
1161        body_start += 1;
1162    }
1163    let body_with_sig = &buf[body_start..];
1164    let body_without_sig = &buf[body_start..sigstart.max(body_start)];
1165    ForEachRefMessageParts {
1166        subject,
1167        body_without_sig,
1168        body_with_sig,
1169        signature,
1170        bare,
1171    }
1172}
1173
1174/// Find the byte offset of the first blank-line separator (`\n\n` or
1175/// `\r\n\r\n`) in `buf`, returning the offset of the first newline of the pair.
1176fn for_each_ref_blank_line(buf: &[u8]) -> Option<usize> {
1177    let lf = buf.windows(2).position(|window| window == b"\n\n");
1178    let crlf = buf.windows(4).position(|window| window == b"\r\n\r\n");
1179    match (lf, crlf) {
1180        (Some(a), Some(b)) => Some(a.min(b)),
1181        (Some(a), None) => Some(a),
1182        (None, Some(b)) => Some(b),
1183        (None, None) => None,
1184    }
1185}
1186
1187/// `copy_subject`: render the subject with embedded newlines turned into single
1188/// spaces (CRLF's CR is dropped), matching ref-filter.c. Multibyte UTF-8
1189/// content passes through byte-exactly; invalid UTF-8 degrades lossily.
1190pub fn for_each_ref_copy_subject(subject: &[u8]) -> String {
1191    let decoded = String::from_utf8_lossy(subject);
1192    let mut out = String::with_capacity(decoded.len());
1193    let mut chars = decoded.chars().peekable();
1194    while let Some(ch) = chars.next() {
1195        if ch == '\r' && chars.peek() == Some(&'\n') {
1196            continue;
1197        }
1198        out.push(if ch == '\n' { ' ' } else { ch });
1199    }
1200    out
1201}
1202
1203/// `format_sanitized_subject`: replace non-title-character runs with a single
1204/// `-`, collapse consecutive `.`, and trim trailing `.`/`-` (pretty.c).
1205pub fn for_each_ref_sanitize_subject(subject: &str) -> String {
1206    let bytes = subject.as_bytes();
1207    let mut out = Vec::with_capacity(bytes.len());
1208    let mut space = 2u8; // git's initial `space = 2`
1209    let mut idx = 0;
1210    while idx < bytes.len() {
1211        let byte = bytes[idx];
1212        if for_each_ref_istitlechar(byte) {
1213            if space == 1 {
1214                out.push(b'-');
1215            }
1216            space = 0;
1217            out.push(byte);
1218            if byte == b'.' {
1219                while bytes.get(idx + 1) == Some(&b'.') {
1220                    idx += 1;
1221                }
1222            }
1223        } else {
1224            space |= 1;
1225        }
1226        idx += 1;
1227    }
1228    while matches!(out.last(), Some(b'.') | Some(b'-')) {
1229        out.pop();
1230    }
1231    String::from_utf8_lossy(&out).into_owned()
1232}
1233
1234fn for_each_ref_istitlechar(byte: u8) -> bool {
1235    byte.is_ascii_alphanumeric() || byte == b'.' || byte == b'_'
1236}
1237
1238pub fn for_each_ref_short_name(refname: &str) -> &str {
1239    if let Some(remote) = refname.strip_prefix("refs/remotes/")
1240        && let Some(remote_name) = remote.strip_suffix("/HEAD")
1241    {
1242        return remote_name;
1243    }
1244    refname
1245        .strip_prefix("refs/heads/")
1246        .or_else(|| refname.strip_prefix("refs/tags/"))
1247        .or_else(|| refname.strip_prefix("refs/remotes/"))
1248        .unwrap_or(refname)
1249}
1250
1251/// git's `ref_rev_parse_rules`: the format patterns tried (shortest-name first)
1252/// when resolving an abbreviated ref, and in reverse when shortening one.
1253const REF_REV_PARSE_RULES: [&str; 6] = [
1254    "{}",
1255    "refs/{}",
1256    "refs/tags/{}",
1257    "refs/heads/{}",
1258    "refs/remotes/{}",
1259    "refs/remotes/{}/HEAD",
1260];
1261
1262fn expand_ref_rule(rule: &str, short: &str) -> String {
1263    rule.replace("{}", short)
1264}
1265
1266/// Strip the prefix/suffix of a rev-parse rule from `refname`, returning the
1267/// `%.*s` portion if the rule matches (git's `match_parse_rule`).
1268fn match_ref_parse_rule<'a>(refname: &'a str, rule: &str) -> Option<&'a str> {
1269    let (prefix, suffix) = rule.split_once("{}")?;
1270    refname
1271        .strip_prefix(prefix)
1272        .and_then(|rest| rest.strip_suffix(suffix))
1273}
1274
1275/// git's `shorten_unambiguous_ref`: find the shortest abbreviation of `refname`
1276/// that, under the rev-parse rules, resolves back to exactly this ref.
1277/// `strict` (git's `core.warnambiguousrefs`, default true) requires *all* other
1278/// rules to fail; otherwise only rules that sort before the matched one matter.
1279/// `ref_exists` reports whether a fully-qualified refname is present.
1280pub fn shorten_unambiguous_ref(
1281    refname: &str,
1282    strict: bool,
1283    ref_exists: impl Fn(&str) -> bool,
1284) -> String {
1285    // Skip rule 0 ("{}"), which always matches.
1286    for matched in (1..REF_REV_PARSE_RULES.len()).rev() {
1287        let Some(short) = match_ref_parse_rule(refname, REF_REV_PARSE_RULES[matched]) else {
1288            continue;
1289        };
1290        let rules_to_fail = if strict {
1291            REF_REV_PARSE_RULES.len()
1292        } else {
1293            matched
1294        };
1295        let ambiguous = (0..rules_to_fail).any(|rule_idx| {
1296            rule_idx != matched
1297                && ref_exists(&expand_ref_rule(REF_REV_PARSE_RULES[rule_idx], short))
1298        });
1299        if !ambiguous {
1300            return short.to_string();
1301        }
1302    }
1303    refname.to_string()
1304}
1305
1306pub fn parse_for_each_ref_strip_count(value: &str) -> Result<isize> {
1307    value
1308        .parse::<isize>()
1309        .map_err(|_| GitError::Command(format!("invalid refname strip count {value}")))
1310}
1311
1312pub fn for_each_ref_lstrip_name(refname: &str, count: isize) -> String {
1313    let components = refname.split('/').collect::<Vec<_>>();
1314    if count == 0 {
1315        return refname.to_string();
1316    }
1317    let start = if count > 0 {
1318        (count as usize).min(components.len())
1319    } else {
1320        components.len().saturating_sub(count.unsigned_abs())
1321    };
1322    components[start..].join("/")
1323}
1324
1325pub fn for_each_ref_rstrip_name(refname: &str, count: isize) -> String {
1326    let components = refname.split('/').collect::<Vec<_>>();
1327    if count == 0 {
1328        return refname.to_string();
1329    }
1330    let end = if count > 0 {
1331        components.len().saturating_sub(count as usize)
1332    } else {
1333        count.unsigned_abs().min(components.len())
1334    };
1335    components[..end].join("/")
1336}
1337
1338pub fn for_each_ref_abbrev_oid(
1339    oid: &ObjectId,
1340    width: Option<usize>,
1341    candidates: &[ObjectId],
1342) -> String {
1343    let hex = oid.to_hex();
1344    let mut width = oid.abbrev_hex_len(width.unwrap_or(hex.len()));
1345    while width < hex.len() {
1346        let prefix = &hex.as_bytes()[..width];
1347        if !candidates
1348            .iter()
1349            .any(|candidate| candidate != oid && candidate.hex_prefix_matches(prefix))
1350        {
1351            break;
1352        }
1353        width += 1;
1354    }
1355    hex[..width].to_string()
1356}
1357
1358pub fn parse_for_each_ref_abbrev_width(value: &str) -> Result<usize> {
1359    let width = value
1360        .parse::<usize>()
1361        .ok()
1362        .filter(|width| *width > 0)
1363        .ok_or_else(|| {
1364            GitError::Command(format!(
1365                "positive value expected in for-each-ref objectname:short format: {value}"
1366            ))
1367        })?;
1368    Ok(width.max(4))
1369}
1370
1371pub fn commit_identity_date(raw: &[u8], mode: &DateMode) -> String {
1372    for_each_ref_identity_date(raw, mode).unwrap_or_default()
1373}
1374
1375/// Render an ident's date for the structured header lines (`Date:`/`AuthorDate:`/
1376/// `CommitDate:`), mirroring pretty.c's `pp_user_info`, which calls
1377/// `show_ident_date` directly: a missing or unparsable date still prints the
1378/// epoch sentinel (`Thu Jan 1 00:00:00 1970 +0000`) rather than an empty string.
1379/// Use this for the medium/full/fuller layouts; use [`commit_identity_date`] for
1380/// the `%ad`/`%cd` placeholders, which suppress a missing date entirely.
1381pub fn commit_identity_date_or_sentinel(raw: &[u8], mode: &DateMode) -> String {
1382    match sley_core::split_ident_line(raw) {
1383        Some(fields) => {
1384            let date = fields.date.unwrap_or(b"0");
1385            let tz = fields.tz.unwrap_or(b"+0000");
1386            sley_core::ident_render_date(date, tz, mode)
1387        }
1388        // No `<…>` pair at all: pp_user_info would skip the whole block, so the
1389        // caller shouldn't reach here for a well-formed commit; fall back to the
1390        // epoch sentinel to stay non-panicking.
1391        None => sley_core::ident_render_date(b"0", b"+0000", mode),
1392    }
1393}
1394
1395#[cfg(test)]
1396mod tests {
1397    use super::*;
1398    use sley_core::ObjectFormat;
1399
1400    #[test]
1401    fn ref_filter_root_refs_exclude_pseudorefs() {
1402        for name in ["HEAD", "ORIG_HEAD", "AUTO_MERGE", "BISECT_EXPECTED_REV"] {
1403            assert!(is_for_each_ref_root_ref(name), "{name}");
1404        }
1405        for name in ["FETCH_HEAD", "MERGE_HEAD", "DANGLING", "refs/heads/main"] {
1406            assert!(!is_for_each_ref_root_ref(name), "{name}");
1407        }
1408    }
1409
1410    #[test]
1411    fn ref_filter_hex_color_requires_six_hex_digits() {
1412        assert_eq!(
1413            parse_for_each_ref_hex_color("#aa22ac"),
1414            Some((0xaa, 0x22, 0xac))
1415        );
1416        assert_eq!(
1417            parse_for_each_ref_hex_color("#AA22AC"),
1418            Some((0xaa, 0x22, 0xac))
1419        );
1420        assert_eq!(parse_for_each_ref_hex_color("#abc"), None);
1421        assert_eq!(parse_for_each_ref_hex_color("#gg22ac"), None);
1422    }
1423
1424    #[test]
1425    fn format_parser_decodes_literals_atoms_and_percent_escapes() {
1426        let format =
1427            ForEachRefFormat::parse("refs/%%/%(refname)%09%(objectname)%q").expect("valid format");
1428        assert_eq!(
1429            format.segments(),
1430            &[
1431                ForEachRefFormatSegment::Literal(b"refs/%/".to_vec()),
1432                ForEachRefFormatSegment::Atom(ForEachRefAtom::RefName {
1433                    source: ForEachRefNameSource::Ref,
1434                    format: ForEachRefNameFormat::Full
1435                }),
1436                ForEachRefFormatSegment::Literal(b"\t".to_vec()),
1437                ForEachRefFormatSegment::Atom(ForEachRefAtom::ObjectName {
1438                    peeled: false,
1439                    abbrev: None
1440                }),
1441                ForEachRefFormatSegment::Literal(b"%q".to_vec()),
1442            ]
1443        );
1444    }
1445
1446    #[test]
1447    fn format_parser_decodes_typed_ref_filter_atoms() {
1448        let format = ForEachRefFormat::parse(
1449            "%(refname:short) %(upstream:lstrip=2) %(*objectname:short=7) %(authoremail:trim) %(authordate:iso8601-strict) %(*contents:lines=2)",
1450        )
1451        .expect("valid format");
1452        assert_eq!(
1453            format.segments(),
1454            &[
1455                ForEachRefFormatSegment::Atom(ForEachRefAtom::RefName {
1456                    source: ForEachRefNameSource::Ref,
1457                    format: ForEachRefNameFormat::Short,
1458                }),
1459                ForEachRefFormatSegment::Literal(b" ".to_vec()),
1460                ForEachRefFormatSegment::Atom(ForEachRefAtom::RefName {
1461                    source: ForEachRefNameSource::Upstream,
1462                    format: ForEachRefNameFormat::Strip(ForEachRefStrip {
1463                        direction: ForEachRefStripDirection::Left,
1464                        count: 2,
1465                    }),
1466                }),
1467                ForEachRefFormatSegment::Literal(b" ".to_vec()),
1468                ForEachRefFormatSegment::Atom(ForEachRefAtom::ObjectName {
1469                    peeled: true,
1470                    abbrev: Some(7),
1471                }),
1472                ForEachRefFormatSegment::Literal(b" ".to_vec()),
1473                // `name`/`email`/`date` atoms that carry a `:modifier` are now
1474                // kept as Raw placeholders; the CLI's string renderer owns the
1475                // full option grammar (mailmap, multi-option, all date modes)
1476                // and the byte-exact bad-argument errors.
1477                ForEachRefFormatSegment::Atom(ForEachRefAtom::Raw("authoremail:trim".to_string())),
1478                ForEachRefFormatSegment::Literal(b" ".to_vec()),
1479                ForEachRefFormatSegment::Atom(ForEachRefAtom::Raw(
1480                    "authordate:iso8601-strict".to_string(),
1481                )),
1482                ForEachRefFormatSegment::Literal(b" ".to_vec()),
1483                ForEachRefFormatSegment::Atom(ForEachRefAtom::ContentsLines {
1484                    peeled: true,
1485                    count: 2,
1486                }),
1487            ]
1488        );
1489    }
1490
1491    #[test]
1492    fn format_parser_rejects_unterminated_atoms() {
1493        assert!(ForEachRefFormat::parse("%(refname").is_err());
1494    }
1495
1496    #[test]
1497    fn format_parser_rejects_invalid_typed_atom_numbers() {
1498        assert!(ForEachRefFormat::parse("%(contents:lines=nope)").is_err());
1499        assert!(ForEachRefFormat::parse("%(objectname:short=0)").is_err());
1500        assert!(ForEachRefFormat::parse("%(refname:lstrip=nope)").is_err());
1501    }
1502
1503    #[test]
1504    fn format_renderer_streams_literals_atoms_and_quotes() {
1505        let format = ForEachRefFormat::parse("branch=%(refname)").expect("valid format");
1506        let mut out = Vec::new();
1507        write_for_each_ref_format(
1508            &mut out,
1509            &format,
1510            ForEachRefQuoteMode::Shell,
1511            false,
1512            |atom, name| {
1513                assert_eq!(
1514                    name,
1515                    &ForEachRefAtom::RefName {
1516                        source: ForEachRefNameSource::Ref,
1517                        format: ForEachRefNameFormat::Full
1518                    }
1519                );
1520                atom.extend_from_slice(b"main's");
1521                Ok(())
1522            },
1523        )
1524        .expect("writes to in-memory buffer");
1525        assert_eq!(out, b"branch='main'\\''s'");
1526    }
1527
1528    #[test]
1529    fn format_renderer_uses_shared_padding_and_magic() {
1530        let format =
1531            ForEachRefFormat::parse("x\n%-(*objectname)%>(6)%(refname)").expect("valid format");
1532        let mut out = Vec::new();
1533        write_for_each_ref_format(
1534            &mut out,
1535            &format,
1536            ForEachRefQuoteMode::None,
1537            false,
1538            |value, atom| {
1539                match atom {
1540                    ForEachRefAtom::ObjectName { peeled: true, .. } => {}
1541                    ForEachRefAtom::RefName { .. } => value.extend_from_slice(b"main"),
1542                    other => panic!("unexpected atom {other:?}"),
1543                }
1544                Ok(())
1545            },
1546        )
1547        .expect("writes to in-memory buffer");
1548        assert_eq!(out, b"x  main");
1549    }
1550
1551    #[test]
1552    fn align_and_padding_measure_multibyte_subjects_by_display_columns() {
1553        use sley_strbuf_expand::TruncateMode;
1554
1555        // copy_subject must pass multibyte content through byte-exactly
1556        // (regression: bytes were re-encoded one Latin-1 char at a time).
1557        assert_eq!(
1558            for_each_ref_copy_subject("日本語テスト".as_bytes()),
1559            "日本語テスト"
1560        );
1561        assert_eq!(
1562            for_each_ref_copy_subject("héllo\r\nwörld\né".as_bytes()),
1563            "héllo wörld é"
1564        );
1565
1566
1567        // "日本語テスト" renders in 12 terminal columns but occupies 18 bytes;
1568        // git's strbuf_utf8_align pads to the column width (oracle:
1569        // `git for-each-ref --format='[%(align:20,left)%(subject)%(end)]'`
1570        // on a commit with this subject emits 18 bytes + 8 spaces).
1571        let cjk = "日本語テスト".as_bytes();
1572        assert_eq!(cjk.len(), 18);
1573        assert_eq!(sley_strbuf_expand::strwidth(cjk), 12);
1574
1575        assert_eq!(for_each_ref_display_width(cjk), 12);
1576        assert_eq!(for_each_ref_display_width("héllo".as_bytes()), 5);
1577        assert_eq!(for_each_ref_display_width(b"\x1b[31mabc\x1b[m"), 3);
1578        assert_eq!(for_each_ref_display_width(b"\x1b[31m\xe6\x97\xa5\x1b[m"), 2);
1579
1580        let mut aligned = cjk.to_vec();
1581        apply_for_each_ref_align(
1582            &mut aligned,
1583            &ForEachRefAlignOptions {
1584                width: 20,
1585                position: ForEachRefAlignPosition::Left,
1586            },
1587        );
1588        assert_eq!(
1589            String::from_utf8(aligned).unwrap_or_default(),
1590            format!("日本語テスト{}", " ".repeat(8))
1591        );
1592
1593        let mut middle = cjk.to_vec();
1594        apply_for_each_ref_align(
1595            &mut middle,
1596            &ForEachRefAlignOptions {
1597                width: 20,
1598                position: ForEachRefAlignPosition::Middle,
1599            },
1600        );
1601        assert_eq!(
1602            String::from_utf8(middle).unwrap_or_default(),
1603            format!("{}日本語テスト{}", " ".repeat(4), " ".repeat(4))
1604        );
1605
1606        let mut padded = cjk.to_vec();
1607        apply_for_each_ref_padding(
1608            &mut padded,
1609            Some(PaddingSpec {
1610                width: 20,
1611                align: PaddingAlign::Right,
1612                truncate: TruncateMode::None,
1613                to_column: false,
1614            }),
1615        );
1616        assert_eq!(
1617            String::from_utf8(padded).unwrap_or_default(),
1618            format!("{}日本語テスト", " ".repeat(8))
1619        );
1620    }
1621
1622    #[test]
1623    fn identity_parts_match_git_identity_layout() {
1624        let ident = b"Ada Lovelace <ada@example.com> 1717430401 -0530";
1625        assert_eq!(
1626            for_each_ref_identity_name(ident),
1627            Some(&b"Ada Lovelace"[..])
1628        );
1629        assert_eq!(
1630            for_each_ref_identity_email(ident, ForEachRefEmailMode::Bracketed),
1631            Some(&b"<ada@example.com>"[..])
1632        );
1633        assert_eq!(
1634            for_each_ref_identity_email(ident, ForEachRefEmailMode::Trim),
1635            Some(&b"ada@example.com"[..])
1636        );
1637        assert_eq!(
1638            for_each_ref_identity_email(ident, ForEachRefEmailMode::LocalPart),
1639            Some(&b"ada"[..])
1640        );
1641        assert_eq!(for_each_ref_identity_timestamp(ident), Some(1717430401));
1642        assert_eq!(
1643            for_each_ref_identity_date(ident, &DateMode::Raw).as_deref(),
1644            Some("1717430401 -0530")
1645        );
1646    }
1647
1648    #[test]
1649    fn dates_use_identity_timezone() {
1650        let ident = b"Ada <ada@example.com> 1717430401 -0530";
1651        assert_eq!(
1652            for_each_ref_identity_date(ident, &DateMode::Short).as_deref(),
1653            Some("2024-06-03")
1654        );
1655        assert_eq!(
1656            for_each_ref_identity_date(ident, &DateMode::IsoStrict).as_deref(),
1657            Some("2024-06-03T10:30:01-05:30")
1658        );
1659    }
1660
1661    #[test]
1662    fn date_sort_parser_preserves_custom_format_semantics() {
1663        let sort = parse_for_each_ref_date_sort("-*creatordate:format:%H:%M:%S")
1664            .expect("valid date sort")
1665            .expect("recognized date atom");
1666        assert!(sort.peeled);
1667        assert!(sort.descending);
1668        assert_eq!(sort.role, ForEachRefAtomIdentityRole::Creator);
1669        assert_eq!(
1670            sort.mode,
1671            DateMode::Strftime {
1672                template: "%H:%M:%S".to_string(),
1673                local: false,
1674            }
1675        );
1676        // Bare `creatordate:format:...` (no peel/desc flags) is the atom used by
1677        // t6300/t1461 "sort by custom date format".
1678        let plain = parse_for_each_ref_date_sort("creatordate:format:%H:%M:%S")
1679            .expect("valid date sort")
1680            .expect("recognized date atom");
1681        assert!(!plain.peeled);
1682        assert!(!plain.descending);
1683        assert_eq!(plain.role, ForEachRefAtomIdentityRole::Creator);
1684        assert_eq!(
1685            plain.mode,
1686            DateMode::Strftime {
1687                template: "%H:%M:%S".to_string(),
1688                local: false,
1689            }
1690        );
1691        assert!(
1692            parse_for_each_ref_date_sort("refname")
1693                .expect("non-date sort is not an error")
1694                .is_none()
1695        );
1696    }
1697
1698    /// Git sorts bare date atoms by raw timestamp, but once a `format:` (or
1699    /// other) modifier is present the *rendered* string is compared bytewise.
1700    /// The t6300 fixture dates reverse order under those two keys; pin that.
1701    #[test]
1702    fn custom_date_format_sort_keys_differ_from_raw_timestamps() {
1703        // Same instants as t/for-each-ref-tests.sh "set up custom date sorting".
1704        let idents = [
1705            b"user <user@example.com> 1707341660 +0000".as_slice(), // 21:34:20
1706            b"user <user@example.com> 945129922 +0000".as_slice(),  // 00:05:22
1707            b"user <user@example.com> 1622806011 +0000".as_slice(), // 11:26:51
1708            b"user <user@example.com> 1169484241 +0000".as_slice(), // 16:44:01
1709        ];
1710        let mode = DateMode::Strftime {
1711            template: "%H:%M:%S".to_string(),
1712            local: false,
1713        };
1714        let mut by_format: Vec<_> = idents
1715            .iter()
1716            .map(|ident| for_each_ref_identity_date(ident, &mode).expect("date"))
1717            .collect();
1718        let mut by_unix: Vec<_> = idents
1719            .iter()
1720            .map(|ident| for_each_ref_identity_timestamp(ident).expect("ts"))
1721            .collect();
1722        by_format.sort();
1723        by_unix.sort();
1724        assert_eq!(
1725            by_format,
1726            vec![
1727                "00:05:22".to_string(),
1728                "11:26:51".to_string(),
1729                "16:44:01".to_string(),
1730                "21:34:20".to_string(),
1731            ]
1732        );
1733        assert_eq!(by_unix, vec![945129922, 1169484241, 1622806011, 1707341660]);
1734        // Timestamp order of the *labels* is not the same as time-of-day order.
1735        let labels_by_unix: Vec<_> = by_unix
1736            .iter()
1737            .map(|ts| {
1738                let ident = format!("user <user@example.com> {ts} +0000");
1739                for_each_ref_identity_date(ident.as_bytes(), &mode).expect("date")
1740            })
1741            .collect();
1742        assert_ne!(
1743            labels_by_unix, by_format,
1744            "format:%H:%M:%S order must not collapse to creatordate order"
1745        );
1746    }
1747
1748    #[test]
1749    fn is_base_selection_minimizes_tip_first_parent_distance_and_keeps_ref_order() {
1750        let oid =
1751            |hex: &str| ObjectId::from_hex(ObjectFormat::Sha1, hex).expect("valid test object id");
1752        let root = oid("0000000000000000000000000000000000000001");
1753        let near = oid("0000000000000000000000000000000000000002");
1754        let tip = oid("0000000000000000000000000000000000000003");
1755        let left = oid("0000000000000000000000000000000000000004");
1756        let right = oid("0000000000000000000000000000000000000005");
1757        let histories = vec![vec![left, near, root], vec![right, near, root], vec![root]];
1758        assert_eq!(
1759            select_for_each_ref_is_base_candidate(&[tip, near, root], &histories),
1760            Some(0),
1761            "nearest intersection wins and the first candidate breaks a tie"
1762        );
1763        assert_eq!(
1764            select_for_each_ref_is_base_candidate(&[tip], &histories),
1765            None
1766        );
1767    }
1768
1769    #[test]
1770    fn tracking_formats_match_ref_filter_atoms() {
1771        assert_eq!(
1772            for_each_ref_track_short(ForEachRefTrack {
1773                ahead: 0,
1774                behind: 0,
1775                gone: false,
1776            }),
1777            "="
1778        );
1779        assert_eq!(
1780            for_each_ref_track_short(ForEachRefTrack {
1781                ahead: 1,
1782                behind: 0,
1783                gone: false,
1784            }),
1785            ">"
1786        );
1787        assert_eq!(
1788            for_each_ref_track_short(ForEachRefTrack {
1789                ahead: 0,
1790                behind: 1,
1791                gone: false,
1792            }),
1793            "<"
1794        );
1795        assert_eq!(
1796            for_each_ref_track_short(ForEachRefTrack {
1797                ahead: 1,
1798                behind: 1,
1799                gone: false,
1800            }),
1801            "<>"
1802        );
1803
1804        let mut out = Vec::new();
1805        write_for_each_ref_track(
1806            &mut out,
1807            ForEachRefTrack {
1808                ahead: 2,
1809                behind: 3,
1810                gone: false,
1811            },
1812            true,
1813        )
1814        .expect("writes to in-memory buffer");
1815        assert_eq!(out, b"[ahead 2, behind 3]");
1816    }
1817
1818    #[test]
1819    fn refname_shortening_and_stripping_match_ref_filter_rules() {
1820        assert_eq!(for_each_ref_short_name("refs/heads/main"), "main");
1821        assert_eq!(for_each_ref_short_name("refs/tags/v1"), "v1");
1822        assert_eq!(
1823            for_each_ref_short_name("refs/remotes/origin/HEAD"),
1824            "origin"
1825        );
1826        assert_eq!(for_each_ref_lstrip_name("refs/heads/main", 2), "main");
1827        assert_eq!(for_each_ref_lstrip_name("refs/heads/main", -1), "main");
1828        assert_eq!(for_each_ref_rstrip_name("refs/heads/main", 1), "refs/heads");
1829        assert_eq!(
1830            for_each_ref_rstrip_name("refs/heads/main", -2),
1831            "refs/heads"
1832        );
1833    }
1834
1835    #[test]
1836    fn abbreviations_extend_to_avoid_ambiguity() {
1837        let one = ObjectId::from_hex(
1838            ObjectFormat::Sha1,
1839            "1111111111111111111111111111111111111111",
1840        )
1841        .expect("valid object id");
1842        let two = ObjectId::from_hex(
1843            ObjectFormat::Sha1,
1844            "1111122222222222222222222222222222222222",
1845        )
1846        .expect("valid object id");
1847        assert_eq!(
1848            parse_for_each_ref_abbrev_width("2").expect("valid abbrev width"),
1849            4
1850        );
1851        assert_eq!(
1852            for_each_ref_abbrev_oid(&one, Some(4), &[one.clone(), two]),
1853            "111111"
1854        );
1855    }
1856}