From 481103df9764a6629659a78e59afab3470cb6a13 Mon Sep 17 00:00:00 2001 From: em0t <10153971+em0t@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:45:43 +0800 Subject: [PATCH 1/8] fix(git): preserve merge commits and full status paths Signed-off-by: em0t <10153971+em0t@users.noreply.github.com> --- src/cmds/git/git.rs | 236 +++++++++++++++++++++++++------------------- 1 file changed, 132 insertions(+), 104 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 71127895..00c4d132 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; @@ -393,47 +392,8 @@ fn run_log( let mut cmd = git_cmd(global_args); cmd.arg("log"); - // Check if user provided format flags - let has_format_flag = args.iter().any(|arg| { - arg.starts_with("--oneline") || arg.starts_with("--pretty") || arg.starts_with("--format") - }); - - // Check if user provided limit flag (-N, -n N, --max-count=N, --max-count N) - let has_limit_flag = args.iter().any(|arg| { - (arg.starts_with('-') && arg.chars().nth(1).is_some_and(|c| c.is_ascii_digit())) - || arg == "-n" - || arg.starts_with("--max-count") - }); - - // Apply RTK defaults only if user didn't specify them - // Use %b (body) to preserve first line of commit body for agent context - // (BREAKING CHANGE, Closes #xxx, design notes) - if !has_format_flag { - cmd.args(["--pretty=format:%h %s (%ar) <%an>%n%b%n---END---"]); - } - - // Determine limit: respect user's explicit -N flag, use sensible defaults otherwise - let (limit, user_set_limit) = if has_limit_flag { - // User explicitly passed -N / -n N / --max-count=N → respect their choice - let n = parse_user_limit(args).unwrap_or(10); - (n, true) - } else if has_format_flag { - // --oneline / --pretty without -N: user wants compact output, allow more - cmd.arg("-50"); - (50, false) - } else { - // No flags at all: default to 10 - cmd.arg("-10"); - (10, false) - }; - - // Only add --no-merges if user didn't explicitly request merge commits - let wants_merges = args - .iter() - .any(|arg| arg == "--merges" || arg == "--min-parents=2"); - if !wants_merges { - cmd.arg("--no-merges"); - } + let log_plan = build_log_plan(args); + cmd.args(&log_plan.injected_args); // Pass all user arguments for arg in args { @@ -455,7 +415,12 @@ fn run_log( } // Post-process: truncate long messages, cap lines only if RTK set the default - let filtered = filter_log_output(&stdout, limit, user_set_limit, has_format_flag); + let filtered = filter_log_output( + &stdout, + log_plan.limit, + log_plan.user_set_limit, + log_plan.has_format_flag, + ); println!("{}", filtered); timer.track( @@ -468,6 +433,51 @@ fn run_log( Ok(0) } +#[derive(Debug, PartialEq, Eq)] +struct LogPlan { + injected_args: Vec, + limit: usize, + user_set_limit: bool, + has_format_flag: bool, +} + +fn build_log_plan(args: &[String]) -> LogPlan { + let has_format_flag = args.iter().any(|arg| { + arg.starts_with("--oneline") || arg.starts_with("--pretty") || arg.starts_with("--format") + }); + + let has_limit_flag = args.iter().any(|arg| { + (arg.starts_with('-') && arg.chars().nth(1).is_some_and(|c| c.is_ascii_digit())) + || arg == "-n" + || arg.starts_with("--max-count") + }); + + let mut injected_args = Vec::new(); + + // Use %b to preserve the first lines of the commit body for agent context + // (BREAKING CHANGE, Closes #xxx, design notes). + if !has_format_flag { + injected_args.push("--pretty=format:%h %s (%ar) <%an>%n%b%n---END---".to_string()); + } + + let (limit, user_set_limit) = if has_limit_flag { + (parse_user_limit(args).unwrap_or(10), true) + } else if has_format_flag { + injected_args.push("-50".to_string()); + (50, false) + } else { + injected_args.push("-10".to_string()); + (10, false) + }; + + LogPlan { + injected_args, + limit, + user_set_limit, + has_format_flag, + } +} + /// Filter git log output: truncate long messages, cap lines /// Parse the user-specified limit from git log args. /// Handles: -20, -n 20, --max-count=20, --max-count 20 @@ -647,48 +657,25 @@ fn format_status_output(porcelain: &str) -> String { } } - // 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) { + for f in &staged_files { output.push_str(&format!(" {}\n", f)); } - if staged_files.len() > max_files { - output.push_str(&format!( - " ... +{} more\n", - staged_files.len() - max_files - )); - } } if modified > 0 { output.push_str(&format!("~ Modified: {} files\n", modified)); - for f in modified_files.iter().take(max_files) { + for f in &modified_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 untracked > 0 { output.push_str(&format!("? Untracked: {} files\n", untracked)); - for f in untracked_files.iter().take(max_untracked) { + for f in &untracked_files { 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 { @@ -937,25 +924,23 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result15 staged files show "... +N more" + fn test_format_status_output_preserves_all_staged_files() { let mut porcelain = String::from("## main\n"); for i in 1..=20 { porcelain.push_str(&format!("M file{}.rs\n", i)); @@ -1888,14 +1872,13 @@ A added.rs 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")); + assert!(result.contains("file16.rs")); + assert!(result.contains("file20.rs")); + assert!(!result.contains("... +")); } #[test] - fn test_format_status_modified_truncation() { - // Test that >15 modified files show "... +N more" + fn test_format_status_output_preserves_all_modified_files() { let mut porcelain = String::from("## main\n"); for i in 1..=20 { porcelain.push_str(&format!(" M file{}.rs\n", i)); @@ -1904,13 +1887,13 @@ A added.rs 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("file16.rs")); + assert!(result.contains("file20.rs")); + assert!(!result.contains("... +")); } #[test] - fn test_format_status_untracked_truncation() { - // Test that >10 untracked files show "... +N more" + fn test_format_status_output_preserves_all_untracked_files() { let mut porcelain = String::from("## main\n"); for i in 1..=15 { porcelain.push_str(&format!("?? file{}.rs\n", i)); @@ -1919,8 +1902,9 @@ A added.rs 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")); + assert!(result.contains("file11.rs")); + assert!(result.contains("file15.rs")); + assert!(!result.contains("... +")); } #[test] @@ -2050,6 +2034,47 @@ A added.rs assert_eq!(parse_user_limit(&args), None); } + #[test] + fn test_build_log_plan_default_keeps_merge_commits() { + let plan = build_log_plan(&[]); + assert_eq!(plan.limit, 10); + assert!(!plan.user_set_limit); + assert!(!plan.has_format_flag); + assert!( + plan.injected_args + .iter() + .all(|arg| arg != "--no-merges"), + "Default git log plan must not drop merge commits" + ); + assert!( + plan.injected_args + .iter() + .any(|arg| arg == "-10"), + "Default git log plan should still cap at 10 commits" + ); + } + + #[test] + fn test_build_log_plan_user_format_still_keeps_merges() { + let args = vec!["--oneline".to_string()]; + let plan = build_log_plan(&args); + assert!(plan.has_format_flag); + assert_eq!(plan.limit, 50); + assert!(!plan.user_set_limit); + assert!( + plan.injected_args + .iter() + .all(|arg| arg != "--no-merges"), + "User format mode must not silently strip merge commits" + ); + assert!( + plan.injected_args + .iter() + .any(|arg| arg == "-50"), + "User format mode should still expand the default window" + ); + } + #[test] fn test_filter_log_output_token_savings() { fn count_tokens(text: &str) -> usize { @@ -2330,17 +2355,15 @@ 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!( @@ -2348,6 +2371,11 @@ no changes added to commit (use "git add" and/or "git commit -a") "Expected 'Staged: 25 files', got:\n{}", result ); + assert!( + !result.contains("... +"), + "Status output must not hide dirty paths behind overflow markers:\n{}", + result + ); } #[test] From 96f3a7b42f474ab2d204667a420c460b873358f1 Mon Sep 17 00:00:00 2001 From: em0t <10153971+em0t@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:55:01 +0800 Subject: [PATCH 2/8] fix(git): make default status output porcelain-like --- docs/FEATURES.md | 8 +-- src/cmds/git/git.rs | 142 ++++++++++++-------------------------------- src/core/README.md | 2 - src/core/config.rs | 6 -- 4 files changed, 42 insertions(+), 116 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 061a604a..c6b40240 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -295,12 +295,12 @@ rtk git status [args...] # Supporte tous les drapeaux git status **Avant / Apres :** ``` # git status (~20 lignes, ~400 tokens) # rtk git status (~5 lignes, ~80 tokens) -On branch main main | 3M 1? 1A -Your branch is up to date with M src/main.rs +On branch main ## main...origin/main +Your branch is up to date with M src/main.rs 'origin/main'. M src/git.rs M tests/test_git.rs -Changes not staged for commit: ? new_file.txt - (use "git add ..." to update) A staged_file.rs +Changes not staged for commit: ?? new_file.txt + (use "git add ..." to update) A staged_file.rs modified: src/main.rs modified: src/git.rs ... diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 00c4d132..7ba6cc64 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -599,95 +599,18 @@ fn truncate_line(line: &str, width: usize) -> String { } } -/// Format porcelain output into compact RTK status display +/// Keep `git status --porcelain -b` output intact while stripping blank lines. 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(); - - // 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)); - } - } - - // 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); - } - } - - if staged > 0 { - output.push_str(&format!("+ Staged: {} files\n", staged)); - for f in &staged_files { - output.push_str(&format!(" {}\n", f)); - } - } - - if modified > 0 { - output.push_str(&format!("~ Modified: {} files\n", modified)); - for f in &modified_files { - output.push_str(&format!(" {}\n", f)); - } - } - - if untracked > 0 { - output.push_str(&format!("? Untracked: {} files\n", untracked)); - for f in &untracked_files { - output.push_str(&format!(" {}\n", f)); - } - } - - 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() + lines.join("\n") } /// Minimal filtering for git status with user-provided args @@ -1814,17 +1737,18 @@ 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"); } #[test] fn test_format_status_output_modified_files() { let porcelain = "## main...origin/main\n M src/main.rs\n M src/lib.rs\n"; let result = format_status_output(porcelain); - assert!(result.contains("* main...origin/main")); - assert!(result.contains("~ Modified: 2 files")); + assert!(result.contains("## main...origin/main")); + assert!(result.contains(" M src/main.rs")); + assert!(result.contains(" M src/lib.rs")); assert!(result.contains("src/main.rs")); assert!(result.contains("src/lib.rs")); assert!(!result.contains("Staged")); @@ -1835,8 +1759,10 @@ mod tests { 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("## feature/new")); + assert!(result.contains("?? temp.txt")); + assert!(result.contains("?? debug.log")); + assert!(result.contains("?? test.sh")); assert!(result.contains("temp.txt")); assert!(result.contains("debug.log")); assert!(result.contains("test.sh")); @@ -1852,14 +1778,14 @@ A added.rs ?? untracked.txt "#; 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")); + assert!(result.contains("## main")); + 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] @@ -1869,7 +1795,6 @@ A added.rs 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("file16.rs")); @@ -1884,7 +1809,6 @@ A added.rs 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("file16.rs")); @@ -1899,7 +1823,6 @@ A added.rs 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("file11.rs")); @@ -1907,6 +1830,17 @@ A added.rs assert!(!result.contains("... +")); } + #[test] + 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] fn test_run_passthrough_accepts_args() { // Test that run_passthrough compiles and has correct signature @@ -2155,7 +2089,7 @@ no changes added to commit (use "git add" and/or "git commit -a") let porcelain = "## main\n M สวัสดี.txt\n?? ทดสอบ.rs\n"; let result = format_status_output(porcelain); // Should not panic - assert!(result.contains("* main")); + assert!(result.contains("## main")); assert!(result.contains("สวัสดี.txt")); assert!(result.contains("ทดสอบ.rs")); } @@ -2164,7 +2098,7 @@ no changes added to commit (use "git add" and/or "git commit -a") fn test_format_status_output_emoji_filename() { let porcelain = "## main\nA 🎉-party.txt\n M 日本語ファイル.rs\n"; let result = format_status_output(porcelain); - assert!(result.contains("* main")); + assert!(result.contains("## main")); } /// Regression test: --oneline and other user format flags must preserve all commits. @@ -2367,8 +2301,8 @@ no changes added to commit (use "git add" and/or "git commit -a") result ); assert!( - result.contains("Staged: 25 files"), - "Expected 'Staged: 25 files', got:\n{}", + result.lines().count() == 26, + "Expected branch + all 25 staged files, got:\n{}", result ); assert!( diff --git a/src/core/README.md b/src/core/README.md index 6c15314c..33780493 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -87,8 +87,6 @@ exclude_commands = ["curl", "playwright"] # Never auto-rewrite these [limits] grep_max_results = 200 grep_max_per_file = 25 -status_max_files = 15 -status_max_untracked = 10 passthrough_max_chars = 2000 ``` diff --git a/src/core/config.rs b/src/core/config.rs index 248f80a3..b3b8bd64 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -105,10 +105,6 @@ pub struct LimitsConfig { pub grep_max_results: usize, /// Max matches per file in grep output (default: 25) pub grep_max_per_file: usize, - /// Max staged/modified files shown in git status (default: 15) - pub status_max_files: usize, - /// Max untracked files shown in git status (default: 10) - pub status_max_untracked: usize, /// Max chars for parser passthrough fallback (default: 2000) pub passthrough_max_chars: usize, } @@ -118,8 +114,6 @@ impl Default for LimitsConfig { Self { grep_max_results: 200, grep_max_per_file: 25, - status_max_files: 15, - status_max_untracked: 10, passthrough_max_chars: 2000, } } From f4d8eb2d389345cbbe24e226e7c7fa6a300c1e87 Mon Sep 17 00:00:00 2001 From: em0t <10153971+em0t@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:20:46 +0800 Subject: [PATCH 3/8] fix(git): narrow completeness diff --- docs/FEATURES.md | 19 +++++++------- src/cmds/git/git.rs | 64 ++++++++++++++++++++++++++++++++------------- src/core/README.md | 2 ++ src/core/config.rs | 6 +++++ 4 files changed, 63 insertions(+), 28 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index c6b40240..cfc34a39 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -294,16 +294,15 @@ rtk git status [args...] # Supporte tous les drapeaux git status **Avant / Apres :** ``` -# git status (~20 lignes, ~400 tokens) # rtk git status (~5 lignes, ~80 tokens) -On branch main ## main...origin/main -Your branch is up to date with M src/main.rs - 'origin/main'. M src/git.rs - M tests/test_git.rs -Changes not staged for commit: ?? new_file.txt - (use "git add ..." to update) A staged_file.rs - modified: src/main.rs - modified: src/git.rs - ... +# git status (~20 lignes, ~400 tokens) # rtk git status (~3 lignes, ~30 tokens) +On branch main ## main +Changes not staged for commit: M src/main.rs + (use "git restore ..." to discard) ?? c.txt + modified: src/main.rs + +Untracked files: + (use "git add ..." to include in what will be committed) + c.txt ``` --- diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 7ba6cc64..d57ece4e 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -33,6 +33,12 @@ fn git_cmd(global_args: &[String]) -> Command { cmd } +fn build_default_status_command(global_args: &[String]) -> Command { + let mut cmd = git_cmd(global_args); + cmd.args(["status", "--porcelain", "-b", "-uall"]); + cmd +} + pub fn run( cmd: GitCommand, args: &[String], @@ -599,7 +605,7 @@ fn truncate_line(line: &str, width: usize) -> String { } } -/// Keep `git status --porcelain -b` output intact while stripping blank lines. +/// Keep `git status --porcelain -b -uall` output intact while stripping blank lines. fn format_status_output(porcelain: &str) -> String { let lines: Vec<&str> = porcelain .lines() @@ -704,8 +710,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_compact_diff() { let diff = r#"diff --git a/foo.rs b/foo.rs @@ -1769,6 +1783,20 @@ mod tests { assert!(!result.contains("Modified")); } + #[test] + 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")); + 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] fn test_format_status_output_mixed_changes() { let porcelain = r#"## main diff --git a/src/core/README.md b/src/core/README.md index 33780493..6c15314c 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -87,6 +87,8 @@ exclude_commands = ["curl", "playwright"] # Never auto-rewrite these [limits] grep_max_results = 200 grep_max_per_file = 25 +status_max_files = 15 +status_max_untracked = 10 passthrough_max_chars = 2000 ``` diff --git a/src/core/config.rs b/src/core/config.rs index b3b8bd64..248f80a3 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -105,6 +105,10 @@ pub struct LimitsConfig { pub grep_max_results: usize, /// Max matches per file in grep output (default: 25) pub grep_max_per_file: usize, + /// Max staged/modified files shown in git status (default: 15) + pub status_max_files: usize, + /// Max untracked files shown in git status (default: 10) + pub status_max_untracked: usize, /// Max chars for parser passthrough fallback (default: 2000) pub passthrough_max_chars: usize, } @@ -114,6 +118,8 @@ impl Default for LimitsConfig { Self { grep_max_results: 200, grep_max_per_file: 25, + status_max_files: 15, + status_max_untracked: 10, passthrough_max_chars: 2000, } } From 09cd305a63ded5937aa3fd7263946c45ec07520b Mon Sep 17 00:00:00 2001 From: em0t <10153971+em0t@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:28:40 +0800 Subject: [PATCH 4/8] docs(git): align command README with defaults --- src/cmds/git/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cmds/git/README.md b/src/cmds/git/README.md index ec595ca0..3678dc6f 100644 --- a/src/cmds/git/README.md +++ b/src/cmds/git/README.md @@ -5,7 +5,8 @@ ## 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 log` preserves merge commits; RTK only injects format/count defaults when the user did not provide them +- 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 From 58003bb080095c19cd5c3ba20dc1fa2b193c2703 Mon Sep 17 00:00:00 2001 From: em0t <10153971+em0t@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:29:23 +0800 Subject: [PATCH 5/8] chore(git): drop optional feature doc churn --- docs/FEATURES.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index cfc34a39..061a604a 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -294,15 +294,16 @@ rtk git status [args...] # Supporte tous les drapeaux git status **Avant / Apres :** ``` -# git status (~20 lignes, ~400 tokens) # rtk git status (~3 lignes, ~30 tokens) -On branch main ## main -Changes not staged for commit: M src/main.rs - (use "git restore ..." to discard) ?? c.txt - modified: src/main.rs - -Untracked files: - (use "git add ..." to include in what will be committed) - c.txt +# git status (~20 lignes, ~400 tokens) # rtk git status (~5 lignes, ~80 tokens) +On branch main main | 3M 1? 1A +Your branch is up to date with M src/main.rs + 'origin/main'. M src/git.rs + M tests/test_git.rs +Changes not staged for commit: ? new_file.txt + (use "git add ..." to update) A staged_file.rs + modified: src/main.rs + modified: src/git.rs + ... ``` --- From de5894c300bb353d343f3a50e5d07c2b15ff6a24 Mon Sep 17 00:00:00 2001 From: em0t <10153971+em0t@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:32:27 +0800 Subject: [PATCH 6/8] test(git): reduce completeness regression surface --- src/cmds/git/git.rs | 117 +++++++------------------------------------- 1 file changed, 19 insertions(+), 98 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index d57ece4e..2f6893d7 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -33,9 +33,14 @@ fn git_cmd(global_args: &[String]) -> Command { cmd } -fn build_default_status_command(global_args: &[String]) -> Command { +fn build_status_command(args: &[String], global_args: &[String]) -> Command { let mut cmd = git_cmd(global_args); - cmd.args(["status", "--porcelain", "-b", "-uall"]); + cmd.arg("status"); + if args.is_empty() { + cmd.args(["--porcelain", "-b", "-uall"]); + } else { + cmd.args(args); + } cmd } @@ -661,9 +666,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 @@ -1756,33 +1767,6 @@ mod tests { assert_eq!(result, "## main...origin/main"); } - #[test] - fn test_format_status_output_modified_files() { - let porcelain = "## main...origin/main\n M src/main.rs\n M src/lib.rs\n"; - let result = format_status_output(porcelain); - assert!(result.contains("## main...origin/main")); - assert!(result.contains(" M src/main.rs")); - assert!(result.contains(" M src/lib.rs")); - 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("?? temp.txt")); - assert!(result.contains("?? debug.log")); - assert!(result.contains("?? test.sh")); - assert!(result.contains("temp.txt")); - assert!(result.contains("debug.log")); - assert!(result.contains("test.sh")); - assert!(!result.contains("Modified")); - } - #[test] fn test_format_status_output_preserves_nested_untracked_paths() { let porcelain = "## main\n?? tmp/c.txt\n?? tmp/nested/d.txt\n"; @@ -1816,48 +1800,6 @@ A added.rs assert!(!result.contains("Untracked")); } - #[test] - fn test_format_status_output_preserves_all_staged_files() { - 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("file1.rs")); - assert!(result.contains("file15.rs")); - assert!(result.contains("file16.rs")); - assert!(result.contains("file20.rs")); - assert!(!result.contains("... +")); - } - - #[test] - fn test_format_status_output_preserves_all_modified_files() { - 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("file1.rs")); - assert!(result.contains("file15.rs")); - assert!(result.contains("file16.rs")); - assert!(result.contains("file20.rs")); - assert!(!result.contains("... +")); - } - - #[test] - fn test_format_status_output_preserves_all_untracked_files() { - 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("file1.rs")); - assert!(result.contains("file10.rs")); - assert!(result.contains("file11.rs")); - assert!(result.contains("file15.rs")); - assert!(!result.contains("... +")); - } - #[test] 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"; @@ -2016,27 +1958,6 @@ A added.rs ); } - #[test] - fn test_build_log_plan_user_format_still_keeps_merges() { - let args = vec!["--oneline".to_string()]; - let plan = build_log_plan(&args); - assert!(plan.has_format_flag); - assert_eq!(plan.limit, 50); - assert!(!plan.user_set_limit); - assert!( - plan.injected_args - .iter() - .all(|arg| arg != "--no-merges"), - "User format mode must not silently strip merge commits" - ); - assert!( - plan.injected_args - .iter() - .any(|arg| arg == "-50"), - "User format mode should still expand the default window" - ); - } - #[test] fn test_filter_log_output_token_savings() { fn count_tokens(text: &str) -> usize { From c0c78d06df0f231678e9ea8064741c360766a2d7 Mon Sep 17 00:00:00 2001 From: em0t <10153971+em0t@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:42:50 +0800 Subject: [PATCH 7/8] fix(git): preserve legacy status framing --- src/cmds/git/git.rs | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 2f6893d7..5ee33311 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -610,7 +610,7 @@ fn truncate_line(line: &str, width: usize) -> String { } } -/// Keep `git status --porcelain -b -uall` output intact while stripping blank lines. +/// 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() @@ -621,7 +621,26 @@ fn format_status_output(porcelain: &str) -> String { return "Clean working tree".to_string(); } - lines.join("\n") + let mut output = Vec::new(); + + if let Some(branch_line) = lines.first() { + if branch_line.starts_with("##") { + let branch = branch_line.trim_start_matches("## "); + output.push(format!("* {}", branch)); + } else { + output.push((*branch_line).to_string()); + } + } + + for line in lines.iter().skip(1) { + output.push((*line).to_string()); + } + + if lines.len() == 1 && lines[0].starts_with("##") { + output.push("clean — nothing to commit".to_string()); + } + + output.join("\n") } /// Minimal filtering for git status with user-provided args @@ -1764,14 +1783,14 @@ mod tests { fn test_format_status_output_clean() { let porcelain = "## main...origin/main\n"; let result = format_status_output(porcelain); - assert_eq!(result, "## main...origin/main"); + assert_eq!(result, "* main...origin/main\nclean — nothing to commit"); } #[test] 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")); + assert!(result.contains("* main")); assert!(result.contains("?? tmp/c.txt")); assert!(result.contains("?? tmp/nested/d.txt")); assert!( @@ -1790,7 +1809,7 @@ A added.rs ?? untracked.txt "#; let result = format_status_output(porcelain); - assert!(result.contains("## main")); + assert!(result.contains("* main")); assert!(result.contains("M staged.rs")); assert!(result.contains(" M modified.rs")); assert!(result.contains("A added.rs")); @@ -1804,7 +1823,7 @@ A added.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("* main")); assert!(result.contains("R old.rs -> new.rs")); assert!(result.contains("UU conflict.rs")); assert!(result.contains("MM mixed.rs")); @@ -2038,7 +2057,7 @@ no changes added to commit (use "git add" and/or "git commit -a") let porcelain = "## main\n M สวัสดี.txt\n?? ทดสอบ.rs\n"; let result = format_status_output(porcelain); // Should not panic - assert!(result.contains("## main")); + assert!(result.contains("* main")); assert!(result.contains("สวัสดี.txt")); assert!(result.contains("ทดสอบ.rs")); } @@ -2047,7 +2066,7 @@ no changes added to commit (use "git add" and/or "git commit -a") fn test_format_status_output_emoji_filename() { let porcelain = "## main\nA 🎉-party.txt\n M 日本語ファイル.rs\n"; let result = format_status_output(porcelain); - assert!(result.contains("## main")); + assert!(result.contains("* main")); } /// Regression test: --oneline and other user format flags must preserve all commits. From 4c3b1c8db4181d2d5dad0f8f0bf7b39940ba7052 Mon Sep 17 00:00:00 2001 From: em0t <10153971+em0t@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:54:58 +0800 Subject: [PATCH 8/8] fix(git): narrow PR 991 to status completeness --- src/cmds/git/README.md | 1 - src/cmds/git/git.rs | 115 +++++++++++++++-------------------------- 2 files changed, 42 insertions(+), 74 deletions(-) diff --git a/src/cmds/git/README.md b/src/cmds/git/README.md index 3678dc6f..c898e52b 100644 --- a/src/cmds/git/README.md +++ b/src/cmds/git/README.md @@ -5,7 +5,6 @@ ## Specifics - **git.rs** uses `trailing_var_arg = true` + `allow_hyphen_values = true` so native git flags (`--oneline`, `--cached`, etc.) pass through correctly -- Default `git log` preserves merge commits; RTK only injects format/count defaults when the user did not provide them - 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 5ee33311..7674aca9 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -403,8 +403,47 @@ fn run_log( let mut cmd = git_cmd(global_args); cmd.arg("log"); - let log_plan = build_log_plan(args); - cmd.args(&log_plan.injected_args); + // Check if user provided format flags + let has_format_flag = args.iter().any(|arg| { + arg.starts_with("--oneline") || arg.starts_with("--pretty") || arg.starts_with("--format") + }); + + // Check if user provided limit flag (-N, -n N, --max-count=N, --max-count N) + let has_limit_flag = args.iter().any(|arg| { + (arg.starts_with('-') && arg.chars().nth(1).is_some_and(|c| c.is_ascii_digit())) + || arg == "-n" + || arg.starts_with("--max-count") + }); + + // Apply RTK defaults only if user didn't specify them + // Use %b (body) to preserve first line of commit body for agent context + // (BREAKING CHANGE, Closes #xxx, design notes) + if !has_format_flag { + cmd.args(["--pretty=format:%h %s (%ar) <%an>%n%b%n---END---"]); + } + + // Determine limit: respect user's explicit -N flag, use sensible defaults otherwise + let (limit, user_set_limit) = if has_limit_flag { + // User explicitly passed -N / -n N / --max-count=N → respect their choice + let n = parse_user_limit(args).unwrap_or(10); + (n, true) + } else if has_format_flag { + // --oneline / --pretty without -N: user wants compact output, allow more + cmd.arg("-50"); + (50, false) + } else { + // No flags at all: default to 10 + cmd.arg("-10"); + (10, false) + }; + + // Only add --no-merges if user didn't explicitly request merge commits + let wants_merges = args + .iter() + .any(|arg| arg == "--merges" || arg == "--min-parents=2"); + if !wants_merges { + cmd.arg("--no-merges"); + } // Pass all user arguments for arg in args { @@ -426,12 +465,7 @@ fn run_log( } // Post-process: truncate long messages, cap lines only if RTK set the default - let filtered = filter_log_output( - &stdout, - log_plan.limit, - log_plan.user_set_limit, - log_plan.has_format_flag, - ); + let filtered = filter_log_output(&stdout, limit, user_set_limit, has_format_flag); println!("{}", filtered); timer.track( @@ -444,51 +478,6 @@ fn run_log( Ok(0) } -#[derive(Debug, PartialEq, Eq)] -struct LogPlan { - injected_args: Vec, - limit: usize, - user_set_limit: bool, - has_format_flag: bool, -} - -fn build_log_plan(args: &[String]) -> LogPlan { - let has_format_flag = args.iter().any(|arg| { - arg.starts_with("--oneline") || arg.starts_with("--pretty") || arg.starts_with("--format") - }); - - let has_limit_flag = args.iter().any(|arg| { - (arg.starts_with('-') && arg.chars().nth(1).is_some_and(|c| c.is_ascii_digit())) - || arg == "-n" - || arg.starts_with("--max-count") - }); - - let mut injected_args = Vec::new(); - - // Use %b to preserve the first lines of the commit body for agent context - // (BREAKING CHANGE, Closes #xxx, design notes). - if !has_format_flag { - injected_args.push("--pretty=format:%h %s (%ar) <%an>%n%b%n---END---".to_string()); - } - - let (limit, user_set_limit) = if has_limit_flag { - (parse_user_limit(args).unwrap_or(10), true) - } else if has_format_flag { - injected_args.push("-50".to_string()); - (50, false) - } else { - injected_args.push("-10".to_string()); - (10, false) - }; - - LogPlan { - injected_args, - limit, - user_set_limit, - has_format_flag, - } -} - /// Filter git log output: truncate long messages, cap lines /// Parse the user-specified limit from git log args. /// Handles: -20, -n 20, --max-count=20, --max-count 20 @@ -1957,26 +1946,6 @@ A added.rs assert_eq!(parse_user_limit(&args), None); } - #[test] - fn test_build_log_plan_default_keeps_merge_commits() { - let plan = build_log_plan(&[]); - assert_eq!(plan.limit, 10); - assert!(!plan.user_set_limit); - assert!(!plan.has_format_flag); - assert!( - plan.injected_args - .iter() - .all(|arg| arg != "--no-merges"), - "Default git log plan must not drop merge commits" - ); - assert!( - plan.injected_args - .iter() - .any(|arg| arg == "-10"), - "Default git log plan should still cap at 10 commits" - ); - } - #[test] fn test_filter_log_output_token_savings() { fn count_tokens(text: &str) -> usize {