Skip to main content

grit_lib/
branch_ref_format.rs

1//! `git branch --format` subset for t3203: atoms and nested-safe `%(if)...%(then)...%(else)...%(end)`.
2
3use crate::config::{parse_color, ConfigSet};
4use crate::merge_base::count_symmetric_ahead_behind;
5use crate::objects::ObjectId;
6use crate::refs::read_head;
7use crate::repo::Repository;
8use crate::rev_parse::resolve_revision;
9
10/// Context used when expanding a `git branch --format` template for one branch.
11pub struct BranchFormatContext<'a> {
12    /// Repository containing the branch.
13    pub repo: &'a Repository,
14    /// Ref name as displayed by the branch listing code.
15    pub refname_display: &'a str,
16    /// Object id at the branch tip.
17    pub oid: ObjectId,
18    /// Full ref name, when this entry has one.
19    pub full_refname: Option<&'a str>,
20    /// When false, `%(color:...)` atoms expand to empty (non-TTY auto).
21    pub emit_format_color: bool,
22}
23
24/// Errors produced while expanding branch format atoms.
25#[derive(Debug)]
26pub enum BranchFormatError {
27    /// Fatal format error with a Git-compatible message.
28    Fatal(String),
29}
30
31/// Expand a `git branch --format` template for a single branch.
32///
33/// When `omit_empty` is true, all-whitespace expansion results are returned as an empty string.
34pub fn expand_branch_format(
35    ctx: &BranchFormatContext<'_>,
36    format: &str,
37    omit_empty: bool,
38) -> Result<String, BranchFormatError> {
39    let expanded = expand_all(ctx, format)?;
40    Ok(
41        if omit_empty && expanded.chars().all(|c| c.is_whitespace()) {
42            String::new()
43        } else {
44            expanded
45        },
46    )
47}
48
49fn expand_all(ctx: &BranchFormatContext<'_>, s: &str) -> Result<String, BranchFormatError> {
50    let head_ref = read_head(&ctx.repo.git_dir).ok().flatten();
51    let mut out = String::new();
52    let mut i = 0usize;
53    let b = s.as_bytes();
54    while i < s.len() {
55        if i + 1 < s.len() && b[i] == b'%' && b[i + 1] == b'%' {
56            out.push('%');
57            i += 2;
58            continue;
59        }
60        if i + 1 < s.len() && b[i] == b'%' && b[i + 1] == b'(' {
61            let (n, piece) = expand_delimited(ctx, &s[i..], &head_ref)?;
62            out.push_str(&piece);
63            i += n;
64            continue;
65        }
66        let ch = s[i..].chars().next().unwrap_or_default();
67        out.push(ch);
68        i += ch.len_utf8();
69    }
70    Ok(out)
71}
72
73fn expand_delimited(
74    ctx: &BranchFormatContext<'_>,
75    s: &str,
76    head_ref: &Option<String>,
77) -> Result<(usize, String), BranchFormatError> {
78    if !s.starts_with("%(") {
79        return Ok((1, "%".to_owned()));
80    }
81    let inner = &s[2..];
82    let close = find_matching_paren(inner)
83        .ok_or_else(|| BranchFormatError::Fatal("unterminated format atom".into()))?;
84    let atom = &inner[..close];
85    let total_atom = 2 + close + 1;
86
87    if atom == "then" || atom == "else" || atom == "end" {
88        return Err(BranchFormatError::Fatal(format!(
89            "format: %({atom}) atom used without an %(if) atom"
90        )));
91    }
92
93    if let Some(rest) = atom.strip_prefix("if") {
94        let tail = &s[total_atom..];
95        let (body, consumed_tail) = expand_if(ctx, rest, tail)?;
96        return Ok((total_atom + consumed_tail, body));
97    }
98
99    Ok((total_atom, expand_atom(ctx, atom, head_ref)?))
100}
101
102fn expand_if(
103    ctx: &BranchFormatContext<'_>,
104    after_if_colon: &str,
105    tail: &str,
106) -> Result<(String, usize), BranchFormatError> {
107    let modifier = after_if_colon.strip_prefix(':').unwrap_or("").trim();
108
109    let then_pos = find_at_if_depth(tail, "%(then)").ok_or_else(|| {
110        BranchFormatError::Fatal("format: %(if) atom used without a %(then) atom".into())
111    })?;
112    let cond_fmt = &tail[..then_pos];
113    let after_then = &tail[then_pos + "%(then)".len()..];
114
115    let (else_at, end_at) = find_else_and_end(after_then)?;
116    let (then_fmt, else_fmt) = match else_at {
117        Some(e) => (&after_then[..e], &after_then[e + "%(else)".len()..end_at]),
118        None => (&after_then[..end_at], ""),
119    };
120
121    let cond_val = expand_all(ctx, cond_fmt)?;
122    let take_then = if modifier.is_empty() {
123        !cond_val.is_empty()
124    } else if let Some(v) = modifier.strip_prefix("equals=") {
125        cond_val == v
126    } else if let Some(v) = modifier.strip_prefix("notequals=") {
127        cond_val != v
128    } else {
129        return Err(BranchFormatError::Fatal(format!(
130            "unrecognized %(if) argument: {modifier}"
131        )));
132    };
133
134    let body = if take_then {
135        expand_all(ctx, then_fmt)?
136    } else {
137        expand_all(ctx, else_fmt)?
138    };
139
140    let consumed = then_pos + "%(then)".len() + end_at + "%(end)".len();
141    Ok((body, consumed))
142}
143
144fn find_else_and_end(s: &str) -> Result<(Option<usize>, usize), BranchFormatError> {
145    let mut i = 0usize;
146    let mut depth = 0usize;
147    let mut else_at = None::<usize>;
148    while i < s.len() {
149        if let Some(j) = scan_if_open(s, i) {
150            depth += 1;
151            i = j;
152            continue;
153        }
154        if depth > 0 && s[i..].starts_with("%(end)") {
155            depth -= 1;
156            i += "%(end)".len();
157            continue;
158        }
159        if depth == 0 && else_at.is_none() && s[i..].starts_with("%(else)") {
160            else_at = Some(i);
161            i += "%(else)".len();
162            continue;
163        }
164        if depth == 0 && s[i..].starts_with("%(end)") {
165            return Ok((else_at, i));
166        }
167        i += s[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1);
168    }
169    Err(BranchFormatError::Fatal(
170        "format: %(if) atom used without a %(end) atom".into(),
171    ))
172}
173
174fn find_at_if_depth(s: &str, pat: &str) -> Option<usize> {
175    let mut i = 0usize;
176    let mut depth = 0usize;
177    while i < s.len() {
178        if let Some(j) = scan_if_open(s, i) {
179            depth += 1;
180            i = j;
181            continue;
182        }
183        if depth > 0 && s[i..].starts_with("%(end)") {
184            depth -= 1;
185            i += "%(end)".len();
186            continue;
187        }
188        if depth == 0 && s[i..].starts_with(pat) {
189            return Some(i);
190        }
191        i += s[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1);
192    }
193    None
194}
195
196fn scan_if_open(s: &str, i: usize) -> Option<usize> {
197    if !s[i..].starts_with("%(") {
198        return None;
199    }
200    let inner = &s[i + 2..];
201    let close = find_matching_paren(inner)?;
202    let atom = &inner[..close];
203    if atom.starts_with("if") {
204        Some(i + 2 + close + 1)
205    } else {
206        None
207    }
208}
209
210fn find_matching_paren(s: &str) -> Option<usize> {
211    let mut d = 1usize;
212    for (i, c) in s.char_indices() {
213        match c {
214            '(' => d += 1,
215            ')' => {
216                d -= 1;
217                if d == 0 {
218                    return Some(i);
219                }
220            }
221            _ => {}
222        }
223    }
224    None
225}
226
227fn expand_atom(
228    ctx: &BranchFormatContext<'_>,
229    atom: &str,
230    head_ref: &Option<String>,
231) -> Result<String, BranchFormatError> {
232    let (base, modifier) = atom
233        .find(':')
234        .map(|p| (&atom[..p], Some(&atom[p + 1..])))
235        .unwrap_or((atom, None));
236
237    match base {
238        "refname" => match modifier {
239            Some("short") => Ok(short_ref_display(ctx.refname_display)),
240            Some(m) => Err(BranchFormatError::Fatal(format!(
241                "unrecognized %(refname) argument: {m}"
242            ))),
243            None => Ok(ctx.refname_display.to_owned()),
244        },
245        "HEAD" => {
246            let is_head = ctx.full_refname.is_none()
247                || head_ref
248                    .as_deref()
249                    .zip(ctx.full_refname)
250                    .is_some_and(|(h, r)| h == r);
251            Ok(if is_head {
252                "*".to_owned()
253            } else {
254                " ".to_owned()
255            })
256        }
257        "objectname" => match modifier {
258            None => Ok(ctx.oid.to_hex()),
259            Some("short") => Ok(ctx.oid.to_hex()[..7].to_owned()),
260            Some(m) if m.starts_with("short=") => {
261                let n: usize = m["short=".len()..].parse().unwrap_or(7);
262                let n = n.clamp(4, 40);
263                Ok(ctx.oid.to_hex()[..n].to_owned())
264            }
265            Some(other) => Err(BranchFormatError::Fatal(format!(
266                "unrecognized %(objectname) argument: {other}"
267            ))),
268        },
269        "ahead-behind" => {
270            let Some(spec) = modifier else {
271                return Err(BranchFormatError::Fatal(
272                    "expected format: %(ahead-behind:<committish>)".to_owned(),
273                ));
274            };
275            let base = resolve_revision(ctx.repo, spec)
276                .map_err(|_| BranchFormatError::Fatal(format!("failed to find '{spec}'")))?;
277            let (a, b) = count_symmetric_ahead_behind(ctx.repo, ctx.oid, base)
278                .map_err(|e| BranchFormatError::Fatal(e.to_string()))?;
279            Ok(format!("{a} {b}"))
280        }
281        "color" => {
282            if !ctx.emit_format_color {
283                return Ok(String::new());
284            }
285            let slot = modifier.unwrap_or("");
286            let cfg = ConfigSet::load(Some(&ctx.repo.git_dir), true).ok();
287            if matches!(
288                slot,
289                "reset" | "bold" | "red" | "green" | "yellow" | "blue" | "magenta" | "cyan"
290            ) {
291                let key = format!("color.{slot}");
292                let raw = cfg
293                    .as_ref()
294                    .and_then(|c| c.get(&key))
295                    .unwrap_or_else(|| slot.to_string());
296                return Ok(parse_color(&raw).unwrap_or_default());
297            }
298            let key = format!("color.branch.{slot}");
299            let default = match slot {
300                "current" => "green",
301                "local" => "normal",
302                "remote" => "red",
303                "plain" => "normal",
304                "upstream" => "blue",
305                "worktree" => "cyan",
306                _ => "",
307            };
308            let raw = cfg
309                .as_ref()
310                .and_then(|c| c.get(&key))
311                .unwrap_or_else(|| default.to_string());
312            Ok(parse_color(&raw).unwrap_or_default())
313        }
314        "rest" => Err(BranchFormatError::Fatal("invalid atom: %(rest)".to_owned())),
315        _ => Err(BranchFormatError::Fatal(format!(
316            "unsupported format atom: {base}"
317        ))),
318    }
319}
320
321fn short_ref_display(full: &str) -> String {
322    for prefix in ["refs/heads/", "refs/tags/", "refs/remotes/"] {
323        if let Some(s) = full.strip_prefix(prefix) {
324            return s.to_owned();
325        }
326    }
327    full.to_owned()
328}