claude_code_statusline_core/modules/
git_branch.rs1use super::{Module, ModuleConfig};
7use crate::types::context::Context;
8use std::process::Command;
9
10pub struct GitBranchModule;
32
33impl GitBranchModule {
34 pub fn new() -> Self {
35 Self
36 }
37
38 #[allow(dead_code)]
39 pub fn from_context(_context: &Context) -> Self {
40 Self::new()
41 }
42}
43
44impl Default for GitBranchModule {
45 fn default() -> Self {
46 Self::new()
47 }
48}
49
50impl Module for GitBranchModule {
51 fn name(&self) -> &str {
52 "git_branch"
53 }
54
55 fn should_display(&self, context: &Context, config: &dyn ModuleConfig) -> bool {
56 if let Some(cfg) = config
58 .as_any()
59 .downcast_ref::<crate::types::config::GitBranchConfig>()
60 {
61 if cfg.disabled {
62 return false;
63 }
64 }
65
66 if context.repo().is_ok() {
68 return true;
69 }
70 if let Ok(out) = Command::new("git")
72 .args([
73 "-C",
74 context.current_dir.to_string_lossy().as_ref(),
75 "rev-parse",
76 "--is-inside-work-tree",
77 ])
78 .output()
79 {
80 if out.status.success() {
81 let s = String::from_utf8_lossy(&out.stdout);
82 return s.trim() == "true";
83 }
84 }
85 false
86 }
87
88 fn render(&self, context: &Context, config: &dyn ModuleConfig) -> String {
89 let value = match context.repo() {
91 Ok(repo) => {
92 if let Ok(head) = repo.head() {
93 if head.is_branch() {
94 head.shorthand().unwrap_or("").to_string()
95 } else if let Some(oid) = head.target() {
96 let s = oid.to_string();
97 s.chars().take(7).collect()
98 } else {
99 String::new()
100 }
101 } else {
102 String::new()
103 }
104 }
105 Err(_) => String::new(),
106 };
107
108 let value = if value.is_empty() {
109 let cwd = context.current_dir.to_string_lossy().to_string();
111 if let Ok(out) = Command::new("git")
113 .args(["-C", &cwd, "rev-parse", "--abbrev-ref", "HEAD"])
114 .output()
115 {
116 if out.status.success() {
117 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
118 if !s.is_empty() && s != "HEAD" {
119 s
120 } else {
121 if let Ok(out2) = Command::new("git")
123 .args(["-C", &cwd, "rev-parse", "--short", "HEAD"])
124 .output()
125 {
126 if out2.status.success() {
127 String::from_utf8_lossy(&out2.stdout).trim().to_string()
128 } else {
129 String::new()
130 }
131 } else {
132 String::new()
133 }
134 }
135 } else {
136 String::new()
137 }
138 } else {
139 String::new()
140 }
141 } else {
142 value
143 };
144
145 if let Some(cfg) = config
146 .as_any()
147 .downcast_ref::<crate::types::config::GitBranchConfig>()
148 {
149 use std::collections::HashMap;
150 let mut tokens = HashMap::new();
151 tokens.insert("branch", value.clone());
152 tokens.insert("symbol", cfg.symbol.clone());
153 return crate::style::render_with_style_template(cfg.format(), &tokens, cfg.style());
154 }
155
156 value
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use crate::config::Config;
164 use crate::types::claude::{ClaudeInput, ModelInfo, WorkspaceInfo};
165 use crate::types::context::Context;
166 use rstest::*;
167
168 use git2::{Repository, Signature};
170 use std::fs::{File, create_dir_all};
171 use std::io::Write as _; use std::path::{Path, PathBuf};
173 use tempfile::tempdir;
174
175 fn make_context(cwd: &str) -> Context {
177 let input = ClaudeInput {
178 hook_event_name: None,
179 session_id: "test-session".to_string(),
180 transcript_path: None,
181 cwd: cwd.to_string(),
182 model: ModelInfo {
183 id: "claude-opus".to_string(),
184 display_name: "Opus".to_string(),
185 },
186 workspace: Some(WorkspaceInfo {
187 current_dir: cwd.to_string(),
188 project_dir: Some(cwd.to_string()),
189 }),
190 version: Some("1.0.0".to_string()),
191 output_style: None,
192 };
193 Context::new(input, Config::default())
194 }
195
196 fn init_repo_with_branch(path: &Path, _branch: &str) -> Repository {
198 let repo = Repository::init(path).expect("init repo");
199
200 let sig = Signature::now("Tester", "tester@example.com").unwrap();
202 let mut index = repo.index().unwrap();
203
204 let file_path = path.join("README.md");
206 let mut file = File::create(&file_path).unwrap();
207 writeln!(file, "test").unwrap();
208 file.sync_all().unwrap();
209
210 index.add_path(Path::new("README.md")).unwrap();
211 let tree_id = index.write_tree().unwrap();
212 let tree = repo.find_tree(tree_id).unwrap();
213
214 let commit_id = repo
215 .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
216 .unwrap();
217 let commit = repo.find_commit(commit_id).unwrap();
218
219 drop(commit);
221 drop(tree);
222
223 repo
224 }
225
226 fn detach_head(repo: &Repository) {
228 let head = repo.head().unwrap();
229 let target = head.target().unwrap();
230 repo.set_head_detached(target).unwrap();
231 }
232
233 #[fixture]
234 fn temp_repo() -> (tempfile::TempDir, PathBuf) {
235 let dir = tempdir().unwrap();
236 let root = dir.path().to_path_buf();
237 (dir, root)
238 }
239
240 #[rstest]
241 fn repo_outside_should_not_display() {
242 let tmp = tempdir().unwrap();
243 let outside = tmp.path().join("outside");
244 create_dir_all(&outside).unwrap();
245
246 let ctx = make_context(outside.to_str().unwrap());
247
248 let module = crate::modules::git_branch::GitBranchModule::new();
250 let show = module.should_display(&ctx, &ctx.config.git_branch);
251 assert!(!show);
252 }
253
254 #[rstest]
255 fn repo_inside_on_main_should_display_branch(temp_repo: (tempfile::TempDir, PathBuf)) {
256 let (_d, root) = temp_repo;
257 let repo = init_repo_with_branch(&root, "main");
258
259 let ctx = make_context(root.to_str().unwrap());
260 let module = crate::modules::git_branch::GitBranchModule::new();
261 assert!(module.should_display(&ctx, &ctx.config.git_branch));
262
263 let rendered = module.render(&ctx, &ctx.config.git_branch);
264 let plain = String::from_utf8(strip_ansi_escapes::strip(rendered)).unwrap();
265 assert!(plain.contains("🌿"));
267 assert!(plain.contains("main") || plain.contains("master"));
268 drop(repo);
269 }
270
271 #[rstest]
272 fn detached_head_renders_short_sha(temp_repo: (tempfile::TempDir, PathBuf)) {
273 let (_d, root) = temp_repo;
274 let repo = init_repo_with_branch(&root, "main");
275 detach_head(&repo);
276
277 let ctx = make_context(root.to_str().unwrap());
278 let module = crate::modules::git_branch::GitBranchModule::new();
279 let rendered = module.render(&ctx, &ctx.config.git_branch);
280 let plain = String::from_utf8(strip_ansi_escapes::strip(rendered)).unwrap();
281 let last = plain.split_whitespace().last().unwrap_or("");
283 assert!(last.len() >= 7 && last.len() <= 8);
284 assert!(last.chars().all(|c| c.is_ascii_hexdigit()));
285 }
286
287 #[rstest]
288 fn disabled_flag_hides_output(temp_repo: (tempfile::TempDir, PathBuf)) {
289 let (_d, root) = temp_repo;
290 let _repo = init_repo_with_branch(&root, "main");
291 let mut ctx = make_context(root.to_str().unwrap());
292
293 ctx.config.git_branch.disabled = true;
295
296 let module = crate::modules::git_branch::GitBranchModule::new();
297 assert!(!module.should_display(&ctx, &ctx.config.git_branch));
298 }
299}