diff --git a/src/cmds/git/README.md b/src/cmds/git/README.md index ec595ca0..c898e52b 100644 --- a/src/cmds/git/README.md +++ b/src/cmds/git/README.md @@ -5,7 +5,7 @@ ## Specifics - **git.rs** uses `trailing_var_arg = true` + `allow_hyphen_values = true` so native git flags (`--oneline`, `--cached`, etc.) pass through correctly -- Auto-detects `--merges` flag to avoid conflicting with `--no-merges` injection +- Default `git status` uses `--porcelain -b -uall` so nested untracked files stay visible; explicit user args still pass through unchanged - Global git options (`-C`, `--git-dir`, `--work-tree`, `--no-pager`) are prepended before the subcommand - Exit code propagation is critical for CI/CD pipelines diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 71127895..7674aca9 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1,6 +1,5 @@ //! Filters git output — log, status, diff, and more — keeping just the essential info. -use crate::core::config; use crate::core::tracking; use crate::core::utils::{exit_code_from_output, exit_code_from_status, resolved_command}; use std::process::Stdio; @@ -34,6 +33,17 @@ fn git_cmd(global_args: &[String]) -> Command { cmd } +fn build_status_command(args: &[String], global_args: &[String]) -> Command { + let mut cmd = git_cmd(global_args); + cmd.arg("status"); + if args.is_empty() { + cmd.args(["--porcelain", "-b", "-uall"]); + } else { + cmd.args(args); + } + cmd +} + pub fn run( cmd: GitCommand, args: &[String], @@ -589,118 +599,37 @@ fn truncate_line(line: &str, width: usize) -> String { } } -/// Format porcelain output into compact RTK status display +/// Preserve RTK's branch/clean framing while keeping porcelain file lines intact. fn format_status_output(porcelain: &str) -> String { - let lines: Vec<&str> = porcelain.lines().collect(); + let lines: Vec<&str> = porcelain + .lines() + .filter(|line| !line.trim().is_empty()) + .collect(); if lines.is_empty() { return "Clean working tree".to_string(); } - let mut output = String::new(); + let mut output = Vec::new(); - // Parse branch info if let Some(branch_line) = lines.first() { if branch_line.starts_with("##") { let branch = branch_line.trim_start_matches("## "); - output.push_str(&format!("* {}\n", branch)); + output.push(format!("* {}", branch)); + } else { + output.push((*branch_line).to_string()); } } - // Count changes by type - let mut staged = 0; - let mut modified = 0; - let mut untracked = 0; - let mut conflicts = 0; - - let mut staged_files = Vec::new(); - let mut modified_files = Vec::new(); - let mut untracked_files = Vec::new(); - for line in lines.iter().skip(1) { - if line.len() < 3 { - continue; - } - let status = line.get(0..2).unwrap_or(" "); - let file = line.get(3..).unwrap_or(""); - - match status.chars().next().unwrap_or(' ') { - 'M' | 'A' | 'D' | 'R' | 'C' => { - staged += 1; - staged_files.push(file); - } - 'U' => conflicts += 1, - _ => {} - } - - match status.chars().nth(1).unwrap_or(' ') { - 'M' | 'D' => { - modified += 1; - modified_files.push(file); - } - _ => {} - } - - if status == "??" { - untracked += 1; - untracked_files.push(file); - } - } - - // Build summary - let limits = config::limits(); - let max_files = limits.status_max_files; - let max_untracked = limits.status_max_untracked; - - if staged > 0 { - output.push_str(&format!("+ Staged: {} files\n", staged)); - for f in staged_files.iter().take(max_files) { - output.push_str(&format!(" {}\n", f)); - } - if staged_files.len() > max_files { - output.push_str(&format!( - " ... +{} more\n", - staged_files.len() - max_files - )); - } + output.push((*line).to_string()); } - if modified > 0 { - output.push_str(&format!("~ Modified: {} files\n", modified)); - for f in modified_files.iter().take(max_files) { - output.push_str(&format!(" {}\n", f)); - } - if modified_files.len() > max_files { - output.push_str(&format!( - " ... +{} more\n", - modified_files.len() - max_files - )); - } + if lines.len() == 1 && lines[0].starts_with("##") { + output.push("clean — nothing to commit".to_string()); } - if untracked > 0 { - output.push_str(&format!("? Untracked: {} files\n", untracked)); - for f in untracked_files.iter().take(max_untracked) { - output.push_str(&format!(" {}\n", f)); - } - if untracked_files.len() > max_untracked { - output.push_str(&format!( - " ... +{} more\n", - untracked_files.len() - max_untracked - )); - } - } - - if conflicts > 0 { - output.push_str(&format!("conflicts: {} files\n", conflicts)); - } - - // When working tree is clean (only branch line, no changes) - if staged == 0 && modified == 0 && untracked == 0 && conflicts == 0 { - output.push_str("clean — nothing to commit\n"); - } - - output.trim_end().to_string() + output.join("\n") } /// Minimal filtering for git status with user-provided args @@ -745,9 +674,7 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result Result = cmd.get_args().collect(); + assert_eq!(args, vec!["status", "--porcelain", "-b", "-uall"]); + } + + #[test] + fn test_build_status_command_with_user_args_passthrough() { + let args = vec!["--short".to_string(), "--branch".to_string()]; + let cmd = build_status_command(&args, &[]); + let cmd_args: Vec<_> = cmd.get_args().collect(); + assert_eq!(cmd_args, vec!["status", "--short", "--branch"]); + } + #[test] fn test_compact_diff() { let diff = r#"diff --git a/foo.rs b/foo.rs @@ -1829,33 +1770,23 @@ mod tests { #[test] fn test_format_status_output_clean() { - let porcelain = ""; + let porcelain = "## main...origin/main\n"; let result = format_status_output(porcelain); - assert_eq!(result, "Clean working tree"); + assert_eq!(result, "* main...origin/main\nclean — nothing to commit"); } #[test] - fn test_format_status_output_modified_files() { - let porcelain = "## main...origin/main\n M src/main.rs\n M src/lib.rs\n"; + fn test_format_status_output_preserves_nested_untracked_paths() { + let porcelain = "## main\n?? tmp/c.txt\n?? tmp/nested/d.txt\n"; let result = format_status_output(porcelain); - assert!(result.contains("* main...origin/main")); - assert!(result.contains("~ Modified: 2 files")); - assert!(result.contains("src/main.rs")); - assert!(result.contains("src/lib.rs")); - assert!(!result.contains("Staged")); - assert!(!result.contains("Untracked")); - } - - #[test] - fn test_format_status_output_untracked_files() { - let porcelain = "## feature/new\n?? temp.txt\n?? debug.log\n?? test.sh\n"; - let result = format_status_output(porcelain); - assert!(result.contains("* feature/new")); - assert!(result.contains("? Untracked: 3 files")); - assert!(result.contains("temp.txt")); - assert!(result.contains("debug.log")); - assert!(result.contains("test.sh")); - assert!(!result.contains("Modified")); + assert!(result.contains("* main")); + assert!(result.contains("?? tmp/c.txt")); + assert!(result.contains("?? tmp/nested/d.txt")); + assert!( + result.lines().all(|line| line != "?? tmp/"), + "Nested untracked files must not collapse back to a directory marker:\n{}", + result + ); } #[test] @@ -1868,59 +1799,24 @@ A added.rs "#; let result = format_status_output(porcelain); assert!(result.contains("* main")); - assert!(result.contains("+ Staged: 2 files")); - assert!(result.contains("staged.rs")); - assert!(result.contains("added.rs")); - assert!(result.contains("~ Modified: 1 files")); - assert!(result.contains("modified.rs")); - assert!(result.contains("? Untracked: 1 files")); - assert!(result.contains("untracked.txt")); - } - - #[test] - fn test_format_status_output_truncation() { - // Test that >15 staged files show "... +N more" - let mut porcelain = String::from("## main\n"); - for i in 1..=20 { - porcelain.push_str(&format!("M file{}.rs\n", i)); - } - let result = format_status_output(&porcelain); - assert!(result.contains("+ Staged: 20 files")); - assert!(result.contains("file1.rs")); - assert!(result.contains("file15.rs")); - assert!(result.contains("... +5 more")); - assert!(!result.contains("file16.rs")); - assert!(!result.contains("file20.rs")); - } - - #[test] - fn test_format_status_modified_truncation() { - // Test that >15 modified files show "... +N more" - let mut porcelain = String::from("## main\n"); - for i in 1..=20 { - porcelain.push_str(&format!(" M file{}.rs\n", i)); - } - let result = format_status_output(&porcelain); - assert!(result.contains("~ Modified: 20 files")); - assert!(result.contains("file1.rs")); - assert!(result.contains("file15.rs")); - assert!(result.contains("... +5 more")); - assert!(!result.contains("file16.rs")); + assert!(result.contains("M staged.rs")); + assert!(result.contains(" M modified.rs")); + assert!(result.contains("A added.rs")); + assert!(result.contains("?? untracked.txt")); + assert!(!result.contains("Staged")); + assert!(!result.contains("Modified")); + assert!(!result.contains("Untracked")); } #[test] - fn test_format_status_untracked_truncation() { - // Test that >10 untracked files show "... +N more" - let mut porcelain = String::from("## main\n"); - for i in 1..=15 { - porcelain.push_str(&format!("?? file{}.rs\n", i)); - } - let result = format_status_output(&porcelain); - assert!(result.contains("? Untracked: 15 files")); - assert!(result.contains("file1.rs")); - assert!(result.contains("file10.rs")); - assert!(result.contains("... +5 more")); - assert!(!result.contains("file11.rs")); + fn test_format_status_output_preserves_rename_and_conflict_lines() { + let porcelain = "## main\nR old.rs -> new.rs\nUU conflict.rs\nMM mixed.rs\n"; + let result = format_status_output(porcelain); + assert!(result.contains("* main")); + assert!(result.contains("R old.rs -> new.rs")); + assert!(result.contains("UU conflict.rs")); + assert!(result.contains("MM mixed.rs")); + assert!(!result.contains("conflicts:")); } #[test] @@ -2330,22 +2226,25 @@ no changes added to commit (use "git add" and/or "git commit -a") // --- truncation accuracy --- #[test] - fn test_format_status_overflow_count_exact() { - // 25 staged files, default status_max_files = 15 - // Should show 15, overflow = 25 - 15 = 10, report "+10 more" + fn test_format_status_output_shows_every_file_when_many_are_dirty() { let mut porcelain = String::from("## main...origin/main\n"); for i in 0..25 { porcelain.push_str(&format!("M staged_file_{}.rs\n", i)); } let result = format_status_output(&porcelain); assert!( - result.contains("+10 more"), - "Expected '+10 more' for 25 staged files (max_files=15), got:\n{}", + result.contains("staged_file_24.rs"), + "Expected the last staged file to remain visible, got:\n{}", + result + ); + assert!( + result.lines().count() == 26, + "Expected branch + all 25 staged files, got:\n{}", result ); assert!( - result.contains("Staged: 25 files"), - "Expected 'Staged: 25 files', got:\n{}", + !result.contains("... +"), + "Status output must not hide dirty paths behind overflow markers:\n{}", result ); }