claude_code_statusline_core/modules/
git_status.rs1use super::{Module, ModuleConfig};
7use crate::types::context::Context;
8
9pub 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 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 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 let mut conflicted = 0u32;
97 let mut deleted = 0u32; let mut renamed = 0u32; let mut modified = 0u32; let mut typechanged = 0u32; let mut staged = 0u32; let mut untracked = 0u32; 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 let mut stash_count = 0u32;
139 let _ = repo.stash_foreach(|_, _, _| {
140 stash_count += 1;
141 true
142 });
143 let stashed = stash_count;
144
145 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 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 all_status.is_empty() && ahead_behind.is_empty() {
198 return String::new();
199 }
200
201 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 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 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 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 let head = repo.head().unwrap();
299 let head_commit = repo.find_commit(head.target().unwrap()).unwrap();
300 let _ = repo.branch("upstream", &head_commit, true).unwrap();
302 let sig = Signature::now("Tester", "tester@example.com").unwrap();
304 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 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 let mut tracked2 = File::create(root.join("tracked.txt")).unwrap();
329 writeln!(tracked2, "t2").unwrap();
330 tracked2.sync_all().unwrap();
331
332 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 assert!(plain.contains("+1"));
344 assert!(plain.contains("!1"));
345 assert!(plain.contains("?1"));
346 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}