1use 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
10pub struct BranchFormatContext<'a> {
12 pub repo: &'a Repository,
14 pub refname_display: &'a str,
16 pub oid: ObjectId,
18 pub full_refname: Option<&'a str>,
20 pub emit_format_color: bool,
22}
23
24#[derive(Debug)]
26pub enum BranchFormatError {
27 Fatal(String),
29}
30
31pub 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}