Skip to main content

claude_code_statusline_core/modules/
git_status.rs

1//! Git status module for displaying repository state
2//!
3//! This module shows the current state of the git repository including
4//! modified files, staged changes, and branch divergence.
5
6use super::{Module, ModuleConfig};
7use crate::types::context::Context;
8
9/// Module that summarizes Git working tree and index state
10///
11/// Displays indicators for:
12/// - Modified files (working tree changes)
13/// - Staged files (index changes)
14/// - Untracked files
15/// - Branch ahead/behind status relative to upstream
16/// - Conflicted files during merge
17///
18/// # Configuration
19///
20/// ```toml
21/// [git_status]
22/// format = "[$all_status$ahead_behind]($style)"
23/// style = "bold red"
24/// conflicted = "="
25/// ahead = "⇡"
26/// behind = "⇣"
27/// diverged = "⇕"
28/// untracked = "?"
29/// stashed = "$"
30/// modified = "!"
31/// staged = "+"
32/// renamed = "»"
33/// deleted = "✘"
34/// disabled = false
35/// ```
36///
37/// # Display Format
38///
39/// Shows compact symbols for repository state, e.g.:
40/// - `[!]` - Has modified files
41/// - `[+]` - Has staged changes
42/// - `[⇡3]` - Ahead by 3 commits
43/// - `[⇣2]` - Behind by 2 commits
44pub struct GitStatusModule;
45
46impl GitStatusModule {
47    pub fn new() -> Self {
48        Self
49    }
50
51    pub fn from_context(_context: &Context) -> Self {
52        Self::new()
53    }
54}
55
56impl Default for GitStatusModule {
57    fn default() -> Self {
58        Self::new()
59    }
60}
61
62impl Module for GitStatusModule {
63    fn name(&self) -> &str {
64        "git_status"
65    }
66
67    fn should_display(&self, context: &Context, config: &dyn ModuleConfig) -> bool {
68        // disabled フラグ
69        if let Some(cfg) = config
70            .as_any()
71            .downcast_ref::<crate::types::config::GitStatusConfig>()
72        {
73            if cfg.disabled {
74                return false;
75            }
76        }
77        context.repo().is_ok()
78    }
79
80    fn render(&self, context: &Context, config: &dyn ModuleConfig) -> String {
81        let mut repo = match context.repo() {
82            Ok(r) => r,
83            Err(_) => return String::new(),
84        };
85
86        // Resolve config
87        let cfg = match config
88            .as_any()
89            .downcast_ref::<crate::types::config::GitStatusConfig>()
90        {
91            Some(c) => c,
92            None => return String::new(),
93        };
94
95        // Count statuses (index vs worktree)
96        let mut conflicted = 0u32;
97        // presence only (count stashes)
98        let mut deleted = 0u32; // staged deletions
99        let mut renamed = 0u32; // staged renames
100        let mut modified = 0u32; // working tree modifications (unstaged)
101        let mut typechanged = 0u32; // staged type changes
102        let mut staged = 0u32; // staged (added/modified/renamed/deleted)
103        let mut untracked = 0u32; // untracked files
104
105        // Statuses
106        if let Ok(stats) = repo.statuses(None) {
107            use git2::Status;
108            for s in stats.iter().map(|e| e.status()) {
109                if s.intersects(Status::CONFLICTED) {
110                    conflicted += 1;
111                    continue;
112                }
113                if s.intersects(Status::WT_NEW) {
114                    untracked += 1;
115                }
116                if s.intersects(Status::WT_MODIFIED) {
117                    modified += 1;
118                }
119                if s.intersects(Status::INDEX_NEW | Status::INDEX_MODIFIED) {
120                    staged += 1;
121                }
122                if s.intersects(Status::INDEX_RENAMED) {
123                    renamed += 1;
124                    staged += 1;
125                }
126                if s.intersects(Status::INDEX_DELETED) {
127                    deleted += 1;
128                    staged += 1;
129                }
130                if s.intersects(Status::INDEX_TYPECHANGE) {
131                    typechanged += 1;
132                    staged += 1;
133                }
134            }
135        }
136
137        // Stash presence (count stashes)
138        let mut stash_count = 0u32;
139        let _ = repo.stash_foreach(|_, _, _| {
140            stash_count += 1;
141            true
142        });
143        let stashed = stash_count;
144
145        // Ahead/behind/diverged
146        let mut ahead_behind = String::new();
147        if let Ok(head) = repo.head() {
148            if head.is_branch() {
149                if let Some(local_oid) = head.target() {
150                    let shorthand = head.shorthand().unwrap_or("");
151                    if let Ok(local_branch) = repo.find_branch(shorthand, git2::BranchType::Local) {
152                        if let Ok(up_branch) = local_branch.upstream() {
153                            if let Some(up_oid) = up_branch.get().target() {
154                                if let Ok((ahead, behind)) =
155                                    repo.graph_ahead_behind(local_oid, up_oid)
156                                {
157                                    if ahead > 0 && behind > 0 {
158                                        if !cfg.symbols.diverged.is_empty() {
159                                            ahead_behind = cfg.symbols.diverged.clone();
160                                        }
161                                    } else if ahead > 0 {
162                                        if !cfg.symbols.ahead.is_empty() {
163                                            ahead_behind =
164                                                format!("{}{}", cfg.symbols.ahead, ahead);
165                                        }
166                                    } else if behind > 0 && !cfg.symbols.behind.is_empty() {
167                                        ahead_behind = format!("{}{}", cfg.symbols.behind, behind);
168                                    }
169                                }
170                            }
171                        }
172                    }
173                }
174            }
175        }
176
177        // Compose $all_status: conflicted stashed deleted renamed modified typechanged staged untracked
178        let mut all_status = String::new();
179        let mut push_sym = |sym: &str, count: u32| {
180            if count > 0 && !sym.is_empty() {
181                use std::fmt::Write as _;
182                let _ = write!(all_status, "{sym}{count}");
183            }
184        };
185
186        push_sym(&cfg.symbols.conflicted, conflicted);
187        push_sym(&cfg.symbols.stashed, stashed);
188        push_sym(&cfg.symbols.deleted, deleted);
189        push_sym(&cfg.symbols.renamed, renamed);
190        push_sym(&cfg.symbols.modified, modified);
191        push_sym(&cfg.symbols.typechanged, typechanged);
192        push_sym(&cfg.symbols.staged, staged);
193        push_sym(&cfg.symbols.untracked, untracked);
194
195        // If repository is completely clean (no status symbols and no ahead/behind),
196        // suppress the entire module output to avoid showing empty parentheses like `()`.
197        if all_status.is_empty() && ahead_behind.is_empty() {
198            return String::new();
199        }
200
201        // Tokens for template
202        use std::collections::HashMap;
203        let mut tokens = HashMap::new();
204        tokens.insert("all_status", all_status);
205        tokens.insert("ahead_behind", ahead_behind);
206        tokens.insert("style", cfg.style.clone());
207
208        crate::style::render_with_style_template(cfg.format(), &tokens, cfg.style())
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::config::Config;
216    use crate::types::claude::{ClaudeInput, ModelInfo, WorkspaceInfo};
217    use crate::types::context::Context;
218    use git2::{BranchType, Repository, Signature};
219    use rstest::*;
220    use std::fs::{File, create_dir_all};
221    use std::io::Write as _;
222    use std::path::{Path, PathBuf};
223    use tempfile::tempdir;
224
225    fn make_context(cwd: &str) -> Context {
226        let input = ClaudeInput {
227            hook_event_name: None,
228            session_id: "test-session".to_string(),
229            transcript_path: None,
230            cwd: cwd.to_string(),
231            model: ModelInfo {
232                id: "claude-opus".into(),
233                display_name: "Opus".into(),
234            },
235            workspace: Some(WorkspaceInfo {
236                current_dir: cwd.to_string(),
237                project_dir: Some(cwd.to_string()),
238            }),
239            version: Some("1.0.0".into()),
240            output_style: None,
241        };
242        Context::new(input, Config::default())
243    }
244
245    fn initial_commit(repo: &Repository, path: &Path) -> git2::Oid {
246        let sig = Signature::now("Tester", "tester@example.com").unwrap();
247        // create file
248        let file_path = path.join("README.md");
249        let mut f = File::create(&file_path).unwrap();
250        writeln!(f, "init").unwrap();
251        f.sync_all().unwrap();
252
253        let mut index = repo.index().unwrap();
254        index.add_path(Path::new("README.md")).unwrap();
255        // Persist index to disk so status reflects a clean state
256        index.write().unwrap();
257        let tree_id = index.write_tree().unwrap();
258        let tree = repo.find_tree(tree_id).unwrap();
259        repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
260            .unwrap()
261    }
262
263    #[fixture]
264    fn temp_repo() -> (tempfile::TempDir, PathBuf, Repository) {
265        let dir = tempdir().unwrap();
266        let root = dir.path().to_path_buf();
267        let repo = Repository::init(&root).unwrap();
268        let c0 = initial_commit(&repo, &root);
269        // create main branch if needed and point HEAD to it
270        let commit0 = repo.find_commit(c0).unwrap();
271        let main_exists = repo.find_branch("main", BranchType::Local).is_ok();
272        if !main_exists {
273            let _ = repo.branch("main", &commit0, true).unwrap();
274        }
275        drop(commit0);
276        let _ = repo.set_head("refs/heads/main");
277        (dir, root, repo)
278    }
279
280    #[rstest]
281    fn repo_outside_should_not_display() {
282        let tmp = tempdir().unwrap();
283        let outside = tmp.path().join("outside");
284        create_dir_all(&outside).unwrap();
285
286        let ctx = make_context(outside.to_str().unwrap());
287        let module = GitStatusModule::new();
288        let show = module.should_display(&ctx, &ctx.config.git_status);
289        assert!(!show);
290    }
291
292    #[rstest]
293    fn renders_counts_and_ahead(temp_repo: (tempfile::TempDir, PathBuf, Repository)) {
294        use strip_ansi_escapes::strip;
295        let (_d, root, repo) = temp_repo;
296
297        // ahead: create local branch upstream at current commit
298        let head = repo.head().unwrap();
299        let head_commit = repo.find_commit(head.target().unwrap()).unwrap();
300        // create an "upstream" branch at the current commit (will be behind after next commit)
301        let _ = repo.branch("upstream", &head_commit, true).unwrap();
302        // make an extra commit on main so it's ahead by 1
303        let sig = Signature::now("Tester", "tester@example.com").unwrap();
304        // create a tracked file and commit it as the second commit
305        let mut tracked = File::create(root.join("tracked.txt")).unwrap();
306        writeln!(tracked, "t1").unwrap();
307        tracked.sync_all().unwrap();
308        let mut index = repo.index().unwrap();
309        index.add_path(Path::new("tracked.txt")).unwrap();
310        let tree_id2 = index.write_tree().unwrap();
311        let tree2 = repo.find_tree(tree_id2).unwrap();
312        let _c1 = repo
313            .commit(Some("HEAD"), &sig, &sig, "second", &tree2, &[&head_commit])
314            .unwrap();
315        let mut main = repo.find_branch("main", BranchType::Local).unwrap();
316        main.set_upstream(Some("upstream")).unwrap();
317
318        // Now create working-tree changes relative to HEAD:
319        // staged: add a new file to index (but do not commit)
320        let mut f1 = File::create(root.join("staged.txt")).unwrap();
321        writeln!(f1, "staged").unwrap();
322        f1.sync_all().unwrap();
323        let mut index2 = repo.index().unwrap();
324        index2.add_path(Path::new("staged.txt")).unwrap();
325        index2.write().unwrap();
326
327        // modified: modify the tracked file without staging
328        let mut tracked2 = File::create(root.join("tracked.txt")).unwrap();
329        writeln!(tracked2, "t2").unwrap();
330        tracked2.sync_all().unwrap();
331
332        // untracked: create without adding
333        let mut f3 = File::create(root.join("untracked.txt")).unwrap();
334        writeln!(f3, "u").unwrap();
335        f3.sync_all().unwrap();
336
337        let ctx = make_context(root.to_str().unwrap());
338        let module = GitStatusModule::new();
339        assert!(module.should_display(&ctx, &ctx.config.git_status));
340        let rendered = module.render(&ctx, &ctx.config.git_status);
341        let plain = String::from_utf8(strip(rendered)).unwrap();
342        // expect substrings: +1 (staged), !1 (modified), ?1 (untracked), ⇡1 (ahead)
343        assert!(plain.contains("+1"));
344        assert!(plain.contains("!1"));
345        assert!(plain.contains("?1"));
346        // ahead may be computed; assert presence when upstream is set
347        assert!(plain.contains("⇡1"));
348    }
349
350    #[rstest]
351    fn disabled_flag_hides_output(temp_repo: (tempfile::TempDir, PathBuf, Repository)) {
352        let (_d, root, _repo) = temp_repo;
353        let mut ctx = make_context(root.to_str().unwrap());
354        ctx.config.git_status.disabled = true;
355        let module = GitStatusModule::new();
356        assert!(!module.should_display(&ctx, &ctx.config.git_status));
357    }
358
359    #[rstest]
360    fn clean_repo_renders_nothing(temp_repo: (tempfile::TempDir, PathBuf, Repository)) {
361        use strip_ansi_escapes::strip;
362        let (_d, root, _repo) = temp_repo;
363        let ctx = make_context(root.to_str().unwrap());
364        let module = GitStatusModule::new();
365        let rendered = module.render(&ctx, &ctx.config.git_status);
366        let plain = String::from_utf8(strip(rendered)).unwrap();
367        println!("clean repo git_status plain='{plain}'");
368        assert!(plain.is_empty());
369    }
370}