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