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