1use 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
11pub struct BranchFormatContext<'a> {
13 pub repo: &'a Repository,
15 pub refname_display: &'a str,
17 pub oid: ObjectId,
19 pub full_refname: Option<&'a str>,
21 pub emit_format_color: bool,
23}
24
25#[derive(Debug)]
27pub enum BranchFormatError {
28 Fatal(String),
30}
31
32pub 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}