Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cmds/git/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
257 changes: 78 additions & 179 deletions src/cmds/git/git.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -745,9 +674,7 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result<i3

// If user provided flags, apply minimal filtering
if !args.is_empty() {
let output = git_cmd(global_args)
.arg("status")
.args(args)
let output = build_status_command(args, global_args)
.output()
.context("Failed to run git status")?;

Expand Down Expand Up @@ -794,8 +721,7 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result<i3
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();

let output = git_cmd(global_args)
.args(["status", "--porcelain", "-b"])
let output = build_status_command(args, global_args)
.output()
.context("Failed to run git status")?;

Expand Down Expand Up @@ -1729,6 +1655,21 @@ mod tests {
assert_eq!(args, vec!["--no-pager", "--bare"]);
}

#[test]
fn test_build_status_command_default_includes_uall() {
let cmd = build_status_command(&[], &[]);
let args: Vec<_> = 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
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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
);
}
Expand Down
Loading