1use std::collections::BTreeMap;
10
11use super::extras::parse_bool;
12use super::{RenderContext, RenderResult, RenderedSegment, Segment, SegmentDefaults};
13use crate::data_context::{DataContext, DataDep, GitContext, Head, RepoKind};
14use crate::theme::Role;
15
16#[derive(Default)]
17pub struct GitBranchSegment {
18 cfg: Config,
19}
20
21const PRIORITY: u8 = 48;
25
26const ID: &str = "git_branch";
27const DEFAULT_DIRTY_INDICATOR: &str = "*";
28const DEFAULT_TRUNCATION_MARKER: &str = "…";
29const DEFAULT_SHORT_SHA_LEN: u8 = 7;
30const DEFAULT_MAX_BRANCH_LEN: u16 = 40;
31const DEFAULT_AHEAD_FORMAT: &str = "↑{n}";
32const DEFAULT_BEHIND_FORMAT: &str = "↓{n}";
33const NO_UPSTREAM_MARKER: &str = "?";
34
35#[derive(Debug, Clone, PartialEq, Eq)]
38pub(crate) struct Config {
39 pub(crate) icon: String,
40 pub(crate) label: String,
41 pub(crate) max_length: u16,
42 pub(crate) truncation_marker: String,
43 pub(crate) short_sha_length: u8,
44 pub(crate) dirty_enabled: bool,
45 pub(crate) dirty_indicator: String,
46 pub(crate) clean_indicator: String,
47 pub(crate) dirty_hide_below_cells: u16,
50 pub(crate) ahead_behind: AheadBehindConfig,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54#[non_exhaustive]
55pub(crate) struct AheadBehindConfig {
56 pub(crate) enabled: bool,
57 pub(crate) ahead_format: FormatTemplate,
58 pub(crate) behind_format: FormatTemplate,
59 pub(crate) hide_when_zero: bool,
60 pub(crate) hide_when_no_upstream: bool,
61 pub(crate) hide_below_cells: u16,
64}
65
66impl Default for AheadBehindConfig {
67 fn default() -> Self {
68 Self {
69 enabled: true,
70 ahead_format: FormatTemplate::parse(DEFAULT_AHEAD_FORMAT)
71 .expect("DEFAULT_AHEAD_FORMAT must contain FormatTemplate::PLACEHOLDER"),
72 behind_format: FormatTemplate::parse(DEFAULT_BEHIND_FORMAT)
73 .expect("DEFAULT_BEHIND_FORMAT must contain FormatTemplate::PLACEHOLDER"),
74 hide_when_zero: true,
75 hide_when_no_upstream: true,
76 hide_below_cells: 0,
77 }
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
86pub(crate) struct FormatTemplate(String);
87
88impl FormatTemplate {
89 pub(crate) const PLACEHOLDER: &'static str = "{n}";
91
92 pub(crate) fn parse(s: &str) -> Option<Self> {
95 if s.contains(Self::PLACEHOLDER) {
96 Some(Self(s.to_string()))
97 } else {
98 None
99 }
100 }
101
102 pub(crate) fn render(&self, n: u32) -> String {
103 self.0.replace(Self::PLACEHOLDER, &n.to_string())
104 }
105}
106
107impl Default for Config {
108 fn default() -> Self {
109 Self {
110 icon: String::new(),
111 label: String::new(),
112 max_length: DEFAULT_MAX_BRANCH_LEN,
113 truncation_marker: DEFAULT_TRUNCATION_MARKER.into(),
114 short_sha_length: DEFAULT_SHORT_SHA_LEN,
115 dirty_enabled: true,
116 dirty_indicator: DEFAULT_DIRTY_INDICATOR.into(),
117 clean_indicator: String::new(),
118 dirty_hide_below_cells: 0,
119 ahead_behind: AheadBehindConfig::default(),
120 }
121 }
122}
123
124impl GitBranchSegment {
125 pub fn from_extras(
129 extras: &BTreeMap<String, toml::Value>,
130 warn: &mut impl FnMut(&str),
131 ) -> Self {
132 let mut cfg = Config::default();
133
134 if let Some(v) = extras.get("icon").and_then(|v| v.as_str()) {
135 cfg.icon = v.to_string();
136 }
137 if let Some(v) = extras.get("label").and_then(|v| v.as_str()) {
138 cfg.label = v.to_string();
139 }
140 if let Some(v) = extras.get("max_length") {
141 match v.as_integer().and_then(|n| u16::try_from(n).ok()) {
142 Some(n) if n >= 1 => cfg.max_length = n,
144 _ => warn(&format!(
145 "segments.{ID}.max_length: expected 1..=65535; ignoring"
146 )),
147 }
148 }
149 if let Some(v) = extras.get("truncation_marker").and_then(|v| v.as_str()) {
150 cfg.truncation_marker = v.to_string();
151 }
152 if let Some(v) = extras.get("short_sha_length").and_then(|v| v.as_integer()) {
153 match u8::try_from(v) {
154 Ok(n) if (1..=40).contains(&n) => cfg.short_sha_length = n,
156 _ => warn(&format!(
157 "segments.{ID}.short_sha_length: expected 1..=40; ignoring"
158 )),
159 }
160 }
161
162 if let Some(dirty) = extras.get("dirty").and_then(|v| v.as_table()) {
163 let dirty_map: BTreeMap<String, toml::Value> =
164 dirty.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
165 if let Some(v) = parse_bool(&dirty_map, "enabled", "git_branch.dirty", warn) {
166 cfg.dirty_enabled = v;
167 }
168 if let Some(fmt) = dirty_map.get("format").and_then(|v| v.as_str()) {
169 match fmt {
170 "indicator" | "hidden" => {
171 if fmt == "hidden" {
172 cfg.dirty_enabled = false;
173 }
174 }
175 "counts" => {
176 warn("segments.git_branch.dirty.format=\"counts\" is not yet implemented; falling back to \"indicator\" (follow-up: lsm-kjj counts mode)");
177 }
178 _ => warn(
179 "segments.git_branch.dirty.format: expected \"indicator\"|\"counts\"|\"hidden\"; ignoring",
180 ),
181 }
182 }
183 if let Some(v) = dirty_map.get("indicator").and_then(|v| v.as_str()) {
184 cfg.dirty_indicator = v.to_string();
185 }
186 if let Some(v) = dirty_map.get("clean_indicator").and_then(|v| v.as_str()) {
187 cfg.clean_indicator = v.to_string();
188 }
189 if let Some(v) = parse_hide_below_cells(&dirty_map, "git_branch.dirty", warn) {
190 cfg.dirty_hide_below_cells = v;
191 }
192 }
193
194 if let Some(ab) = extras.get("ahead_behind").and_then(|v| v.as_table()) {
195 let ab_map: BTreeMap<String, toml::Value> =
196 ab.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
197 if let Some(v) = parse_bool(&ab_map, "enabled", "git_branch.ahead_behind", warn) {
198 cfg.ahead_behind.enabled = v;
199 }
200 let placeholder = FormatTemplate::PLACEHOLDER;
201 if let Some(v) = ab_map.get("ahead_format").and_then(|v| v.as_str()) {
202 match FormatTemplate::parse(v) {
203 Some(tpl) => cfg.ahead_behind.ahead_format = tpl,
204 None => warn(&format!(
205 "segments.{ID}.ahead_behind.ahead_format: missing `{placeholder}` placeholder in {v:?}; ignoring"
206 )),
207 }
208 }
209 if let Some(v) = ab_map.get("behind_format").and_then(|v| v.as_str()) {
210 match FormatTemplate::parse(v) {
211 Some(tpl) => cfg.ahead_behind.behind_format = tpl,
212 None => warn(&format!(
213 "segments.{ID}.ahead_behind.behind_format: missing `{placeholder}` placeholder in {v:?}; ignoring"
214 )),
215 }
216 }
217 if let Some(v) = parse_bool(&ab_map, "hide_when_zero", "git_branch.ahead_behind", warn)
218 {
219 cfg.ahead_behind.hide_when_zero = v;
220 }
221 if let Some(v) = parse_bool(
222 &ab_map,
223 "hide_when_no_upstream",
224 "git_branch.ahead_behind",
225 warn,
226 ) {
227 cfg.ahead_behind.hide_when_no_upstream = v;
228 }
229 if let Some(v) = parse_hide_below_cells(&ab_map, "git_branch.ahead_behind", warn) {
230 cfg.ahead_behind.hide_below_cells = v;
231 }
232 }
233
234 Self { cfg }
235 }
236}
237
238fn is_below_threshold(rc: &RenderContext, threshold: u16) -> bool {
242 threshold > 0 && rc.terminal_width < threshold
243}
244
245fn parse_hide_below_cells(
250 table: &BTreeMap<String, toml::Value>,
251 scope: &str,
252 warn: &mut impl FnMut(&str),
253) -> Option<u16> {
254 let v = table.get("hide_below_cells")?;
255 match v.as_integer().and_then(|n| u16::try_from(n).ok()) {
256 Some(n) => Some(n),
257 None => {
258 warn(&format!(
259 "segments.{scope}.hide_below_cells: expected 0..=65535; ignoring"
260 ));
261 None
262 }
263 }
264}
265
266impl Segment for GitBranchSegment {
267 fn data_deps(&self) -> &'static [DataDep] {
268 &[DataDep::Git]
269 }
270
271 fn defaults(&self) -> SegmentDefaults {
272 SegmentDefaults::with_priority(PRIORITY)
273 }
274
275 fn render(&self, ctx: &DataContext, rc: &RenderContext) -> RenderResult {
276 let arc = ctx.git();
277 match &*arc {
278 Err(_) | Ok(None) => Ok(None),
279 Ok(Some(gc)) if matches!(gc.repo_kind, RepoKind::Bare) => {
283 crate::lsm_debug!("git_branch: bare repo; hiding");
284 Ok(None)
285 }
286 Ok(Some(gc)) => {
287 let text = self.assemble(gc, rc);
288 if text.is_empty() {
289 return Ok(None);
290 }
291 Ok(Some(RenderedSegment::new(text).with_role(Role::Accent)))
292 }
293 }
294 }
295
296 fn shrink_to_fit(
297 &self,
298 ctx: &DataContext,
299 _rc: &RenderContext,
300 target: u16,
301 ) -> Option<RenderedSegment> {
302 let arc = ctx.git();
306 let gc = match &*arc {
307 Err(_) | Ok(None) => return None,
308 Ok(Some(gc)) if matches!(gc.repo_kind, RepoKind::Bare) => return None,
309 Ok(Some(gc)) => gc,
310 };
311 let text = self.assemble_compact(gc);
312 if text.is_empty() {
313 return None;
314 }
315 let rendered = RenderedSegment::new(text).with_role(Role::Accent);
316 (rendered.width <= target).then_some(rendered)
317 }
318}
319
320impl GitBranchSegment {
321 fn assemble(&self, gc: &GitContext, rc: &RenderContext) -> String {
322 let mut parts: Vec<String> = Vec::new();
323 if !self.cfg.icon.is_empty() {
324 parts.push(self.cfg.icon.clone());
325 }
326 if !self.cfg.label.is_empty() {
327 parts.push(self.cfg.label.clone());
328 }
329
330 let head = self.render_head(&gc.head);
331 if !head.is_empty() {
332 parts.push(head);
333 }
334
335 if self.cfg.dirty_enabled && !is_below_threshold(rc, self.cfg.dirty_hide_below_cells) {
336 if let Some(marker) = self.render_dirty(gc) {
337 parts.push(marker);
338 }
339 }
340
341 if self.cfg.ahead_behind.enabled
342 && !is_below_threshold(rc, self.cfg.ahead_behind.hide_below_cells)
343 {
344 if let Some(marker) = self.render_ahead_behind(gc) {
345 parts.push(marker);
346 }
347 }
348
349 parts.join(" ")
350 }
351
352 fn assemble_compact(&self, gc: &GitContext) -> String {
358 let mut parts: Vec<String> = Vec::new();
359 if !self.cfg.icon.is_empty() {
360 parts.push(self.cfg.icon.clone());
361 }
362 if !self.cfg.label.is_empty() {
363 parts.push(self.cfg.label.clone());
364 }
365 let head = self.render_head(&gc.head);
366 if !head.is_empty() {
367 parts.push(head);
368 }
369 parts.join(" ")
370 }
371
372 fn render_ahead_behind(&self, gc: &GitContext) -> Option<String> {
373 if !matches!(gc.head, Head::Branch(_)) {
378 return None;
379 }
380 match &*gc.upstream() {
381 None => {
382 if self.cfg.ahead_behind.hide_when_no_upstream {
383 None
384 } else {
385 Some(NO_UPSTREAM_MARKER.to_string())
386 }
387 }
388 Some(state) => {
389 if state.ahead == 0 && state.behind == 0 && self.cfg.ahead_behind.hide_when_zero {
390 return None;
391 }
392 let mut out = String::new();
393 if state.ahead > 0 || !self.cfg.ahead_behind.hide_when_zero {
394 out.push_str(&self.cfg.ahead_behind.ahead_format.render(state.ahead));
395 }
396 if state.behind > 0 || !self.cfg.ahead_behind.hide_when_zero {
397 if !out.is_empty() {
398 out.push(' ');
399 }
400 out.push_str(&self.cfg.ahead_behind.behind_format.render(state.behind));
401 }
402 if out.is_empty() {
403 None
404 } else {
405 Some(out)
406 }
407 }
408 }
409 }
410
411 fn render_head(&self, head: &Head) -> String {
412 match head {
413 Head::Branch(name) => {
414 truncate_middle(name, self.cfg.max_length, &self.cfg.truncation_marker)
415 }
416 Head::Detached(oid) => {
417 let s = oid.to_string();
418 let n = usize::from(self.cfg.short_sha_length).min(s.len());
419 format!("({})", &s[..n])
420 }
421 Head::Unborn { symbolic_ref } => truncate_middle(
422 symbolic_ref,
423 self.cfg.max_length,
424 &self.cfg.truncation_marker,
425 ),
426 Head::OtherRef { full_name } => {
427 truncate_middle(full_name, self.cfg.max_length, &self.cfg.truncation_marker)
428 }
429 }
430 }
431
432 fn render_dirty(&self, gc: &GitContext) -> Option<String> {
433 if gc.dirty().is_dirty() {
434 if self.cfg.dirty_indicator.is_empty() {
435 None
436 } else {
437 Some(self.cfg.dirty_indicator.clone())
438 }
439 } else if self.cfg.clean_indicator.is_empty() {
440 None
441 } else {
442 Some(self.cfg.clean_indicator.clone())
443 }
444 }
445}
446
447fn truncate_middle(s: &str, max: u16, marker: &str) -> String {
451 use unicode_segmentation::UnicodeSegmentation;
452 use unicode_width::UnicodeWidthStr;
453
454 let max_usize = usize::from(max);
455 let cur_width = UnicodeWidthStr::width(s);
456 if max == 0 || cur_width <= max_usize {
457 return s.to_string();
458 }
459 let marker_width = UnicodeWidthStr::width(marker);
460 if marker_width >= max_usize {
461 let mut out = String::new();
464 let mut w = 0usize;
465 for g in s.graphemes(true) {
466 let gw = UnicodeWidthStr::width(g);
467 if w + gw > max_usize {
468 break;
469 }
470 out.push_str(g);
471 w += gw;
472 }
473 return out;
474 }
475 let budget = max_usize - marker_width;
476 let head_budget = budget.div_ceil(2);
477 let tail_budget = budget - head_budget;
478
479 let mut head = String::new();
480 let mut head_w = 0usize;
481 for g in s.graphemes(true) {
482 let gw = UnicodeWidthStr::width(g);
483 if head_w + gw > head_budget {
484 break;
485 }
486 head.push_str(g);
487 head_w += gw;
488 }
489 let mut tail_graphemes: Vec<&str> = Vec::new();
490 let mut tail_w = 0usize;
491 for g in s.graphemes(true).rev() {
492 let gw = UnicodeWidthStr::width(g);
493 if tail_w + gw > tail_budget {
494 break;
495 }
496 tail_graphemes.push(g);
497 tail_w += gw;
498 }
499 tail_graphemes.reverse();
500 let mut out = head;
501 out.push_str(marker);
502 for g in tail_graphemes {
503 out.push_str(g);
504 }
505 out
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511 use crate::data_context::{DirtyState, GitContext, Head, RepoKind, UpstreamState};
512 use crate::input::{ModelInfo, StatusContext, Tool, WorkspaceInfo};
513 use std::path::PathBuf;
514 use std::sync::Arc;
515
516 fn minimal_status() -> StatusContext {
517 StatusContext {
518 tool: Tool::ClaudeCode,
519 model: Some(ModelInfo {
520 display_name: "Claude".into(),
521 }),
522 workspace: Some(WorkspaceInfo {
523 project_dir: PathBuf::from("/repo"),
524 git_worktree: None,
525 }),
526 context_window: None,
527 cost: None,
528 effort: None,
529 vim: None,
530 output_style: None,
531 agent_name: None,
532 version: None,
533 raw: Arc::new(serde_json::Value::Null),
534 }
535 }
536
537 fn rc() -> RenderContext {
538 RenderContext::new(80)
539 }
540
541 fn ctx_with_git(
542 result: Result<Option<GitContext>, crate::data_context::GitError>,
543 ) -> DataContext {
544 let dc = DataContext::with_cwd(minimal_status(), None);
545 dc.preseed_git(result).expect("seed");
546 dc
547 }
548
549 #[test]
550 fn hides_when_not_in_repo() {
551 assert!(GitBranchSegment::default()
552 .render(&ctx_with_git(Ok(None)), &rc())
553 .unwrap()
554 .is_none());
555 }
556
557 #[test]
558 fn hides_on_gix_error() {
559 let err = crate::data_context::GitError::CorruptRepo {
560 path: PathBuf::from("/x"),
561 message: "synthetic".into(),
562 };
563 assert!(GitBranchSegment::default()
564 .render(&ctx_with_git(Err(err)), &rc())
565 .unwrap()
566 .is_none());
567 }
568
569 #[test]
570 fn hides_on_bare_repo() {
571 let gc = GitContext::new(
572 RepoKind::Bare,
573 PathBuf::from("/tmp/bare.git"),
574 Head::Unborn {
575 symbolic_ref: "main".into(),
576 },
577 );
578 assert!(GitBranchSegment::default()
579 .render(&ctx_with_git(Ok(Some(gc))), &rc())
580 .unwrap()
581 .is_none());
582 }
583
584 #[test]
585 fn renders_branch_name() {
586 let gc = GitContext::new(
587 RepoKind::Main,
588 PathBuf::from("/repo/.git"),
589 Head::Branch("main".into()),
590 );
591 let rendered = GitBranchSegment::default()
592 .render(&ctx_with_git(Ok(Some(gc))), &rc())
593 .unwrap()
594 .expect("rendered");
595 assert_eq!(rendered.text(), "main");
596 assert_eq!(rendered.style().role, Some(Role::Accent));
597 }
598
599 #[test]
600 fn renders_detached_as_short_sha_in_parens() {
601 let gc = GitContext::new(
602 RepoKind::Main,
603 PathBuf::from("/repo/.git"),
604 Head::Detached(gix::ObjectId::empty_tree(gix::hash::Kind::Sha1)),
605 );
606 let rendered = GitBranchSegment::default()
607 .render(&ctx_with_git(Ok(Some(gc))), &rc())
608 .unwrap()
609 .expect("rendered");
610 assert!(rendered.text().starts_with('('));
614 assert!(rendered.text().ends_with(')'));
615 assert_eq!(rendered.text().chars().count(), 9);
617 }
618
619 #[test]
620 fn renders_other_ref_full_name() {
621 let gc = GitContext::new(
622 RepoKind::Main,
623 PathBuf::from("/repo/.git"),
624 Head::OtherRef {
625 full_name: "refs/remotes/origin/feature".into(),
626 },
627 );
628 let rendered = GitBranchSegment::default()
629 .render(&ctx_with_git(Ok(Some(gc))), &rc())
630 .unwrap()
631 .expect("rendered");
632 assert_eq!(rendered.text(), "refs/remotes/origin/feature");
633 }
634
635 fn ctx_with_upstream(head: Head, upstream: Option<UpstreamState>) -> DataContext {
638 let gc = GitContext::new(RepoKind::Main, PathBuf::from("/repo/.git"), head);
639 gc.preseed_upstream(upstream).expect("fresh onceCell");
640 let dc = DataContext::with_cwd(minimal_status(), None);
641 dc.preseed_git(Ok(Some(gc))).expect("seed");
642 dc
643 }
644
645 #[test]
646 fn renders_ahead_when_local_leads() {
647 let rendered = GitBranchSegment::default()
648 .render(
649 &ctx_with_upstream(
650 Head::Branch("main".into()),
651 Some(UpstreamState {
652 ahead: 2,
653 behind: 0,
654 upstream_branch: "origin/main".into(),
655 }),
656 ),
657 &rc(),
658 )
659 .unwrap()
660 .expect("rendered");
661 assert_eq!(rendered.text(), "main ↑2");
662 }
663
664 #[test]
665 fn renders_behind_when_remote_leads() {
666 let rendered = GitBranchSegment::default()
667 .render(
668 &ctx_with_upstream(
669 Head::Branch("main".into()),
670 Some(UpstreamState {
671 ahead: 0,
672 behind: 3,
673 upstream_branch: "origin/main".into(),
674 }),
675 ),
676 &rc(),
677 )
678 .unwrap()
679 .expect("rendered");
680 assert_eq!(rendered.text(), "main ↓3");
681 }
682
683 #[test]
684 fn renders_both_when_diverged() {
685 let rendered = GitBranchSegment::default()
686 .render(
687 &ctx_with_upstream(
688 Head::Branch("main".into()),
689 Some(UpstreamState {
690 ahead: 2,
691 behind: 3,
692 upstream_branch: "origin/main".into(),
693 }),
694 ),
695 &rc(),
696 )
697 .unwrap()
698 .expect("rendered");
699 assert_eq!(rendered.text(), "main ↑2 ↓3");
700 }
701
702 #[test]
703 fn hides_ahead_behind_when_zero_by_default() {
704 let rendered = GitBranchSegment::default()
705 .render(
706 &ctx_with_upstream(
707 Head::Branch("main".into()),
708 Some(UpstreamState {
709 ahead: 0,
710 behind: 0,
711 upstream_branch: "origin/main".into(),
712 }),
713 ),
714 &rc(),
715 )
716 .unwrap()
717 .expect("rendered");
718 assert_eq!(rendered.text(), "main");
719 }
720
721 #[test]
722 fn shows_zeros_when_configured() {
723 let mut seg = GitBranchSegment::default();
724 seg.cfg.ahead_behind.hide_when_zero = false;
725 let rendered = seg
726 .render(
727 &ctx_with_upstream(
728 Head::Branch("main".into()),
729 Some(UpstreamState {
730 ahead: 0,
731 behind: 0,
732 upstream_branch: "origin/main".into(),
733 }),
734 ),
735 &rc(),
736 )
737 .unwrap()
738 .expect("rendered");
739 assert_eq!(rendered.text(), "main ↑0 ↓0");
740 }
741
742 #[test]
743 fn hides_ahead_behind_when_no_upstream_by_default() {
744 let rendered = GitBranchSegment::default()
745 .render(&ctx_with_upstream(Head::Branch("main".into()), None), &rc())
746 .unwrap()
747 .expect("rendered");
748 assert_eq!(rendered.text(), "main");
749 }
750
751 #[test]
752 fn renders_question_mark_when_no_upstream_opted_in() {
753 let mut seg = GitBranchSegment::default();
754 seg.cfg.ahead_behind.hide_when_no_upstream = false;
755 let rendered = seg
756 .render(&ctx_with_upstream(Head::Branch("main".into()), None), &rc())
757 .unwrap()
758 .expect("rendered");
759 assert_eq!(rendered.text(), "main ?");
760 }
761
762 #[test]
763 fn skips_ahead_behind_on_detached_head() {
764 let gc = GitContext::new(
765 RepoKind::Main,
766 PathBuf::from("/repo/.git"),
767 Head::Detached(gix::ObjectId::empty_tree(gix::hash::Kind::Sha1)),
768 );
769 let dc = DataContext::with_cwd(minimal_status(), None);
770 dc.preseed_git(Ok(Some(gc))).expect("seed");
771 let rendered = GitBranchSegment::default()
772 .render(&dc, &rc())
773 .unwrap()
774 .expect("rendered");
775 assert!(
776 !rendered.text().contains('↑') && !rendered.text().contains('↓'),
777 "expected no ahead/behind on detached HEAD, got {:?}",
778 rendered.text()
779 );
780 }
781
782 #[test]
783 fn from_extras_warns_on_ahead_format_missing_placeholder() {
784 let mut extras = BTreeMap::new();
785 let mut ab = toml::value::Table::new();
786 ab.insert(
787 "ahead_format".into(),
788 toml::Value::String("↑{count}".into()),
789 );
790 extras.insert("ahead_behind".into(), toml::Value::Table(ab));
791 let mut warnings = Vec::<String>::new();
792 let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
793 assert_eq!(warnings.len(), 1);
794 assert!(warnings[0].contains("ahead_format"));
795 assert!(warnings[0].contains("{n}"));
796 assert_eq!(seg.cfg.ahead_behind.ahead_format.render(2), "↑2");
797 }
798
799 #[test]
800 fn from_extras_warns_on_behind_format_missing_placeholder() {
801 let mut extras = BTreeMap::new();
802 let mut ab = toml::value::Table::new();
803 ab.insert(
804 "behind_format".into(),
805 toml::Value::String("↓{count}".into()),
806 );
807 extras.insert("ahead_behind".into(), toml::Value::Table(ab));
808 let mut warnings = Vec::<String>::new();
809 let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
810 assert_eq!(warnings.len(), 1);
811 assert!(warnings[0].contains("behind_format"));
812 assert!(warnings[0].contains("{n}"));
813 assert_eq!(seg.cfg.ahead_behind.behind_format.render(3), "↓3");
814 }
815
816 #[test]
817 fn format_template_parse_rejects_missing_placeholder() {
818 assert!(FormatTemplate::parse("no placeholder").is_none());
819 assert!(FormatTemplate::parse("↑{count}").is_none());
820 assert!(FormatTemplate::parse("↑{n}").is_some());
821 }
822
823 #[test]
824 fn format_template_render_substitutes_placeholder() {
825 let tpl = FormatTemplate::parse("↑{n} commits").expect("valid");
826 assert_eq!(tpl.render(7), "↑7 commits");
827 }
828
829 #[test]
830 fn default_templates_contain_placeholder() {
831 let default = AheadBehindConfig::default();
836 assert_eq!(default.ahead_format.render(2), "↑2");
837 assert_eq!(default.behind_format.render(3), "↓3");
838 }
839
840 #[test]
841 fn skips_ahead_behind_on_unborn_head() {
842 let rendered = GitBranchSegment::default()
843 .render(
844 &ctx_with_upstream(
845 Head::Unborn {
846 symbolic_ref: "main".into(),
847 },
848 None,
849 ),
850 &rc(),
851 )
852 .unwrap()
853 .expect("rendered");
854 assert!(
855 !rendered.text().contains('↑')
856 && !rendered.text().contains('↓')
857 && !rendered.text().contains('?'),
858 "expected no ahead/behind marker on Unborn HEAD, got {:?}",
859 rendered.text()
860 );
861 }
862
863 #[test]
864 fn skips_ahead_behind_on_other_ref_head() {
865 let rendered = GitBranchSegment::default()
866 .render(
867 &ctx_with_upstream(
868 Head::OtherRef {
869 full_name: "refs/remotes/origin/feature".into(),
870 },
871 None,
872 ),
873 &rc(),
874 )
875 .unwrap()
876 .expect("rendered");
877 assert!(
878 !rendered.text().contains('↑')
879 && !rendered.text().contains('↓')
880 && !rendered.text().contains('?'),
881 "expected no ahead/behind marker on OtherRef HEAD, got {:?}",
882 rendered.text()
883 );
884 }
885
886 #[test]
887 fn skips_ahead_behind_on_unborn_head_even_with_hide_when_no_upstream_false() {
888 let mut seg = GitBranchSegment::default();
892 seg.cfg.ahead_behind.hide_when_no_upstream = false;
893 let rendered = seg
894 .render(
895 &ctx_with_upstream(
896 Head::Unborn {
897 symbolic_ref: "main".into(),
898 },
899 None,
900 ),
901 &rc(),
902 )
903 .unwrap()
904 .expect("rendered");
905 assert!(
906 !rendered.text().contains('?'),
907 "Unborn HEAD should not render '?' even with hide_when_no_upstream=false; got {:?}",
908 rendered.text()
909 );
910 }
911
912 #[test]
913 fn renders_ahead_with_custom_format() {
914 let mut seg = GitBranchSegment::default();
915 seg.cfg.ahead_behind.ahead_format = FormatTemplate::parse(">>{n}").expect("valid");
916 let rendered = seg
917 .render(
918 &ctx_with_upstream(
919 Head::Branch("main".into()),
920 Some(UpstreamState {
921 ahead: 5,
922 behind: 0,
923 upstream_branch: "origin/main".into(),
924 }),
925 ),
926 &rc(),
927 )
928 .unwrap()
929 .expect("rendered");
930 assert_eq!(rendered.text(), "main >>5");
931 }
932
933 #[test]
934 fn from_extras_reads_ahead_behind_knobs() {
935 let mut extras = BTreeMap::new();
936 let mut ab = toml::value::Table::new();
937 ab.insert("enabled".into(), toml::Value::Boolean(true));
938 ab.insert("ahead_format".into(), toml::Value::String(">>{n}".into()));
939 ab.insert("behind_format".into(), toml::Value::String("<<{n}".into()));
940 ab.insert("hide_when_zero".into(), toml::Value::Boolean(false));
941 ab.insert("hide_when_no_upstream".into(), toml::Value::Boolean(false));
942 extras.insert("ahead_behind".into(), toml::Value::Table(ab));
943 let mut warnings = Vec::<String>::new();
944 let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
945 assert!(warnings.is_empty(), "{warnings:?}");
946 assert!(seg.cfg.ahead_behind.enabled);
947 assert_eq!(seg.cfg.ahead_behind.ahead_format.render(3), ">>3");
948 assert_eq!(seg.cfg.ahead_behind.behind_format.render(5), "<<5");
949 assert!(!seg.cfg.ahead_behind.hide_when_zero);
950 assert!(!seg.cfg.ahead_behind.hide_when_no_upstream);
951 }
952
953 #[test]
954 fn renders_submodule_like_main() {
955 let gc = GitContext::new(
956 RepoKind::Submodule,
957 PathBuf::from("/parent/.git/modules/child"),
958 Head::Branch("main".into()),
959 );
960 let rendered = GitBranchSegment::default()
961 .render(&ctx_with_git(Ok(Some(gc))), &rc())
962 .unwrap()
963 .expect("rendered");
964 assert_eq!(rendered.text(), "main");
965 }
966
967 #[test]
968 fn from_extras_rejects_max_length_zero() {
969 let mut extras = BTreeMap::new();
970 extras.insert("max_length".into(), toml::Value::Integer(0));
971 let mut warnings = Vec::<String>::new();
972 let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
973 assert_eq!(warnings.len(), 1);
974 assert!(warnings[0].contains("max_length"));
975 assert_eq!(seg.cfg.max_length, DEFAULT_MAX_BRANCH_LEN);
976 }
977
978 #[test]
979 fn from_extras_rejects_max_length_wrong_type() {
980 let mut extras = BTreeMap::new();
981 extras.insert("max_length".into(), toml::Value::String("wide".into()));
982 let mut warnings = Vec::<String>::new();
983 let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
984 assert_eq!(warnings.len(), 1);
985 assert!(warnings[0].contains("max_length"));
986 assert_eq!(seg.cfg.max_length, DEFAULT_MAX_BRANCH_LEN);
987 }
988
989 #[test]
990 fn renders_unborn_as_symbolic_ref_name() {
991 let gc = GitContext::new(
992 RepoKind::Main,
993 PathBuf::from("/repo/.git"),
994 Head::Unborn {
995 symbolic_ref: "master".into(),
996 },
997 );
998 let rendered = GitBranchSegment::default()
999 .render(&ctx_with_git(Ok(Some(gc))), &rc())
1000 .unwrap()
1001 .expect("rendered");
1002 assert_eq!(rendered.text(), "master");
1003 }
1004
1005 #[test]
1006 fn applies_icon_and_label_when_configured() {
1007 let mut seg = GitBranchSegment::default();
1008 seg.cfg.icon = ">>".into();
1009 seg.cfg.label = "branch:".into();
1010 let gc = GitContext::new(
1011 RepoKind::Main,
1012 PathBuf::from("/repo/.git"),
1013 Head::Branch("main".into()),
1014 );
1015 let rendered = seg
1016 .render(&ctx_with_git(Ok(Some(gc))), &rc())
1017 .unwrap()
1018 .expect("rendered");
1019 assert_eq!(rendered.text(), ">> branch: main");
1020 }
1021
1022 #[test]
1023 fn defaults_use_expected_priority() {
1024 assert_eq!(GitBranchSegment::default().defaults().priority, PRIORITY);
1025 }
1026
1027 #[test]
1028 fn declares_git_data_dep() {
1029 assert_eq!(GitBranchSegment::default().data_deps(), &[DataDep::Git]);
1030 }
1031
1032 #[test]
1033 fn from_extras_reads_icon_label_and_dirty_knobs() {
1034 let mut extras = BTreeMap::new();
1035 extras.insert("icon".into(), toml::Value::String("".into()));
1036 extras.insert("label".into(), toml::Value::String("br".into()));
1037 extras.insert("max_length".into(), toml::Value::Integer(10));
1038 extras.insert("truncation_marker".into(), toml::Value::String("..".into()));
1039 extras.insert("short_sha_length".into(), toml::Value::Integer(12));
1040
1041 let mut dirty = toml::value::Table::new();
1042 dirty.insert("enabled".into(), toml::Value::Boolean(true));
1043 dirty.insert("format".into(), toml::Value::String("indicator".into()));
1044 dirty.insert("indicator".into(), toml::Value::String("●".into()));
1045 dirty.insert("clean_indicator".into(), toml::Value::String("✓".into()));
1046 extras.insert("dirty".into(), toml::Value::Table(dirty));
1047
1048 let mut warnings = Vec::<String>::new();
1049 let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
1050 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1051 assert_eq!(seg.cfg.icon, "");
1052 assert_eq!(seg.cfg.label, "br");
1053 assert_eq!(seg.cfg.max_length, 10);
1054 assert_eq!(seg.cfg.truncation_marker, "..");
1055 assert_eq!(seg.cfg.short_sha_length, 12);
1056 assert!(seg.cfg.dirty_enabled);
1057 assert_eq!(seg.cfg.dirty_indicator, "●");
1058 assert_eq!(seg.cfg.clean_indicator, "✓");
1059 }
1060
1061 #[test]
1062 fn from_extras_counts_mode_warns_and_falls_back_to_indicator() {
1063 let mut extras = BTreeMap::new();
1064 let mut dirty = toml::value::Table::new();
1065 dirty.insert("format".into(), toml::Value::String("counts".into()));
1066 extras.insert("dirty".into(), toml::Value::Table(dirty));
1067
1068 let mut warnings = Vec::<String>::new();
1069 let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
1070 assert_eq!(warnings.len(), 1);
1071 assert!(warnings[0].contains("counts"));
1072 assert!(seg.cfg.dirty_enabled);
1073 }
1074
1075 #[test]
1076 fn from_extras_hidden_format_turns_dirty_off() {
1077 let mut extras = BTreeMap::new();
1078 let mut dirty = toml::value::Table::new();
1079 dirty.insert("format".into(), toml::Value::String("hidden".into()));
1080 extras.insert("dirty".into(), toml::Value::Table(dirty));
1081
1082 let mut warnings = Vec::<String>::new();
1083 let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
1084 assert!(warnings.is_empty());
1085 assert!(!seg.cfg.dirty_enabled);
1086 }
1087
1088 #[test]
1089 fn from_extras_rejects_short_sha_length_out_of_range() {
1090 for bad in [0i64, 41, -5, 999] {
1091 let mut extras = BTreeMap::new();
1092 extras.insert("short_sha_length".into(), toml::Value::Integer(bad));
1093 let mut warnings = Vec::<String>::new();
1094 let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
1095 assert_eq!(warnings.len(), 1, "{bad}: {warnings:?}");
1096 assert_eq!(seg.cfg.short_sha_length, DEFAULT_SHORT_SHA_LEN);
1097 }
1098 }
1099
1100 #[test]
1103 fn truncate_middle_keeps_short_strings_verbatim() {
1104 assert_eq!(truncate_middle("main", 10, "…"), "main");
1105 assert_eq!(truncate_middle("feature/x", 9, "…"), "feature/x");
1106 }
1107
1108 #[test]
1109 fn truncate_middle_preserves_prefix_and_suffix() {
1110 let out = truncate_middle("feature/authentication-v3", 10, "…");
1113 assert!(out.contains('…'));
1114 assert!(out.len() <= 25);
1115 assert!(out.starts_with("feat"), "expected prefix kept, got {out}");
1116 assert!(out.ends_with("-v3") || out.ends_with("v3"));
1117 }
1118
1119 #[test]
1120 fn truncate_middle_handles_zero_budget() {
1121 assert_eq!(truncate_middle("main", 0, "…"), "main");
1122 }
1123
1124 #[test]
1125 fn truncate_middle_degrades_when_marker_exceeds_budget() {
1126 assert_eq!(truncate_middle("hello-world", 3, "[truncated]"), "hel");
1129 }
1130
1131 fn ctx_with_dirty_and_upstream(ahead: u32, behind: u32) -> DataContext {
1136 let gc = GitContext::new(
1137 RepoKind::Main,
1138 PathBuf::from("/repo/.git"),
1139 Head::Branch("main".into()),
1140 );
1141 gc.preseed_dirty_state(DirtyState::Dirty(None))
1142 .expect("fresh dirty cell");
1143 gc.preseed_upstream(Some(UpstreamState {
1144 ahead,
1145 behind,
1146 upstream_branch: "origin/main".into(),
1147 }))
1148 .expect("fresh upstream cell");
1149 let dc = DataContext::with_cwd(minimal_status(), None);
1150 dc.preseed_git(Ok(Some(gc))).expect("seed");
1151 dc
1152 }
1153
1154 fn render_at(seg: &GitBranchSegment, terminal_width: u16, dc: &DataContext) -> String {
1155 let rendered = seg
1156 .render(dc, &RenderContext::new(terminal_width))
1157 .unwrap()
1158 .expect("rendered");
1159 rendered.text().to_string()
1160 }
1161
1162 #[test]
1163 fn dirty_hide_below_cells_default_zero_keeps_existing_behavior() {
1164 let seg = GitBranchSegment::default();
1167 let dc = ctx_with_dirty_and_upstream(0, 0);
1168 assert_eq!(render_at(&seg, 1, &dc), "main *");
1169 assert_eq!(render_at(&seg, 200, &dc), "main *");
1170 }
1171
1172 #[test]
1173 fn dirty_marker_hidden_when_terminal_width_below_threshold() {
1174 let mut seg = GitBranchSegment::default();
1175 seg.cfg.dirty_hide_below_cells = 50;
1176 let dc = ctx_with_dirty_and_upstream(0, 0);
1177 assert_eq!(render_at(&seg, 49, &dc), "main");
1179 assert_eq!(render_at(&seg, 50, &dc), "main *");
1181 assert_eq!(render_at(&seg, 100, &dc), "main *");
1182 }
1183
1184 #[test]
1185 fn ahead_behind_hidden_when_terminal_width_below_threshold() {
1186 let mut seg = GitBranchSegment::default();
1187 seg.cfg.ahead_behind.hide_below_cells = 80;
1188 let dc = ctx_with_dirty_and_upstream(2, 1);
1189 assert_eq!(render_at(&seg, 79, &dc), "main *");
1192 assert_eq!(render_at(&seg, 80, &dc), "main * ↑2 ↓1");
1194 }
1195
1196 #[test]
1197 fn per_marker_thresholds_compose_independently() {
1198 let mut seg = GitBranchSegment::default();
1201 seg.cfg.dirty_hide_below_cells = 50;
1202 seg.cfg.ahead_behind.hide_below_cells = 80;
1203 let dc = ctx_with_dirty_and_upstream(2, 1);
1204 assert_eq!(render_at(&seg, 100, &dc), "main * ↑2 ↓1"); assert_eq!(render_at(&seg, 60, &dc), "main *"); assert_eq!(render_at(&seg, 40, &dc), "main"); }
1208
1209 #[test]
1210 fn enabled_false_overrides_hide_below_cells() {
1211 let mut seg = GitBranchSegment::default();
1213 seg.cfg.dirty_enabled = false;
1214 seg.cfg.dirty_hide_below_cells = 50;
1215 let dc = ctx_with_dirty_and_upstream(0, 0);
1216 assert_eq!(render_at(&seg, 200, &dc), "main");
1218 }
1219
1220 #[test]
1221 fn from_extras_reads_dirty_hide_below_cells() {
1222 let mut dirty = toml::value::Table::new();
1223 dirty.insert("hide_below_cells".to_string(), toml::Value::Integer(60));
1224 let extras = BTreeMap::from([("dirty".to_string(), toml::Value::Table(dirty))]);
1225 let seg = GitBranchSegment::from_extras(&extras, &mut |_| {});
1226 assert_eq!(seg.cfg.dirty_hide_below_cells, 60);
1227 }
1228
1229 #[test]
1230 fn from_extras_reads_ahead_behind_hide_below_cells() {
1231 let mut ab = toml::value::Table::new();
1232 ab.insert("hide_below_cells".to_string(), toml::Value::Integer(90));
1233 let extras = BTreeMap::from([("ahead_behind".to_string(), toml::Value::Table(ab))]);
1234 let seg = GitBranchSegment::from_extras(&extras, &mut |_| {});
1235 assert_eq!(seg.cfg.ahead_behind.hide_below_cells, 90);
1236 }
1237
1238 #[test]
1239 fn ahead_behind_hide_when_zero_and_hide_below_cells_compose_multiplicatively() {
1240 let mut seg = GitBranchSegment::default();
1246 seg.cfg.ahead_behind.hide_below_cells = 80;
1247 let dc_diverged = ctx_with_dirty_and_upstream(2, 1);
1249 assert_eq!(render_at(&seg, 79, &dc_diverged), "main *");
1250 let dc_zero = ctx_with_dirty_and_upstream(0, 0);
1253 assert_eq!(render_at(&seg, 100, &dc_zero), "main *");
1254 }
1255
1256 #[test]
1257 fn from_extras_warns_on_negative_hide_below_cells_and_keeps_default() {
1258 let mut dirty = toml::value::Table::new();
1259 dirty.insert("hide_below_cells".to_string(), toml::Value::Integer(-5));
1260 let extras = BTreeMap::from([("dirty".to_string(), toml::Value::Table(dirty))]);
1261 let mut warnings = vec![];
1262 let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
1263 assert_eq!(seg.cfg.dirty_hide_below_cells, 0);
1264 assert!(warnings
1265 .iter()
1266 .any(|w| w.contains("segments.git_branch.dirty.hide_below_cells")));
1267 }
1268
1269 #[test]
1272 fn shrink_to_fit_returns_compact_form_when_target_fits() {
1273 let seg = GitBranchSegment::default();
1277 let dc = ctx_with_dirty_and_upstream(2, 1);
1278 let dummy_rc = RenderContext::new(80);
1279 let shrunk = seg
1280 .shrink_to_fit(&dc, &dummy_rc, 4)
1281 .expect("compact form fits");
1282 assert_eq!(shrunk.text(), "main");
1283 assert_eq!(shrunk.style().role, Some(Role::Accent));
1284 }
1285
1286 #[test]
1287 fn shrink_to_fit_returns_none_when_even_compact_form_overflows() {
1288 let seg = GitBranchSegment::default();
1293 let dc = ctx_with_dirty_and_upstream(2, 1);
1294 let dummy_rc = RenderContext::new(80);
1295 assert!(seg.shrink_to_fit(&dc, &dummy_rc, 3).is_none());
1296 }
1297
1298 #[test]
1299 fn shrink_to_fit_returns_none_outside_repo() {
1300 let seg = GitBranchSegment::default();
1301 let dc = ctx_with_git(Ok(None));
1302 let dummy_rc = RenderContext::new(80);
1303 assert!(seg.shrink_to_fit(&dc, &dummy_rc, 100).is_none());
1304 }
1305
1306 #[test]
1307 fn shrink_to_fit_returns_none_in_bare_repo() {
1308 let seg = GitBranchSegment::default();
1309 let gc = GitContext::new(
1310 RepoKind::Bare,
1311 PathBuf::from("/tmp/bare.git"),
1312 Head::Unborn {
1313 symbolic_ref: "main".into(),
1314 },
1315 );
1316 let dc = ctx_with_git(Ok(Some(gc)));
1317 let dummy_rc = RenderContext::new(80);
1318 assert!(seg.shrink_to_fit(&dc, &dummy_rc, 100).is_none());
1319 }
1320
1321 #[test]
1322 fn shrink_to_fit_keeps_configured_icon_and_label_in_compact_form() {
1323 let mut seg = GitBranchSegment::default();
1330 seg.cfg.icon = "@".into();
1331 seg.cfg.label = "br:".into();
1332 let dc = ctx_with_dirty_and_upstream(2, 1);
1333 let dummy_rc = RenderContext::new(80);
1334 let shrunk = seg
1335 .shrink_to_fit(&dc, &dummy_rc, 50)
1336 .expect("compact form fits");
1337 assert_eq!(shrunk.text(), "@ br: main");
1338 }
1339
1340 #[test]
1341 fn shrink_to_fit_strips_markers_even_when_thresholds_would_keep_them() {
1342 let seg = GitBranchSegment::default();
1348 let dc = ctx_with_dirty_and_upstream(2, 1);
1349 let wide_rc = RenderContext::new(200);
1350 let shrunk = seg
1351 .shrink_to_fit(&dc, &wide_rc, 50)
1352 .expect("compact form fits 50 cells");
1353 assert_eq!(shrunk.text(), "main");
1354 }
1355}