Skip to main content

claude_code_statusline_core/modules/
git_branch.rs

1//! Git branch module for displaying the current branch name
2//!
3//! This module shows the current git branch or commit SHA when
4//! in a git repository.
5
6use super::{Module, ModuleConfig};
7use crate::types::context::Context;
8use std::process::Command;
9
10/// Module that displays the current Git branch
11///
12/// Shows the branch name when on a branch, or a short SHA
13/// when in detached HEAD state. Only displays when inside
14/// a git repository.
15///
16/// # Configuration
17///
18/// ```toml
19/// [git_branch]
20/// format = "[$symbol$branch(:$remote_branch)]($style)"
21/// style = "bold purple"
22/// symbol = ""
23/// disabled = false
24/// ```
25///
26/// # Display Behavior
27///
28/// - Branch name: Shows current branch (e.g., "main", "feature/xyz")
29/// - Detached HEAD: Shows short commit SHA
30/// - Outside repo: Module is hidden
31pub 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        // disabled フラグを確認
57        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        // Display only when inside a Git repository (fallback to `git` command on failure)
67        if context.repo().is_ok() {
68            return true;
69        }
70        // Fallback: `git -C <cwd> rev-parse --is-inside-work-tree`
71        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        // Try git2 first via memoized Context repo
90        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            // Fallback using `git` command
110            let cwd = context.current_dir.to_string_lossy().to_string();
111            // Try branch name first
112            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                        // Detached HEAD -> short sha
122                        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    // Utilize git2 and tempfile to construct a temporary repository
169    use git2::{Repository, Signature};
170    use std::fs::{File, create_dir_all};
171    use std::io::Write as _; // for file writing
172    use std::path::{Path, PathBuf};
173    use tempfile::tempdir;
174
175    // Helper: ClaudeInput -> Context 生成
176    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    // Helper: Create an empty commit and set the main branch
197    fn init_repo_with_branch(path: &Path, _branch: &str) -> Repository {
198        let repo = Repository::init(path).expect("init repo");
199
200        // Create an initial commit
201        let sig = Signature::now("Tester", "tester@example.com").unwrap();
202        let mut index = repo.index().unwrap();
203
204        // Create and add a file to the index
205        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 explicit borrows
220        drop(commit);
221        drop(tree);
222
223        repo
224    }
225
226    // Helper: Create a detached HEAD
227    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        // Test that the module is hidden when outside a Git repository
249        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        // Should include symbol and branch name without ANSI codes
266        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        // Extract the last whitespace-separated token (branch or short SHA)
282        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        // Set `disabled` to `true`
294        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}