From e74bfd53a2db1b9ce3574657e5f213cf9de5867a Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 01:42:16 +0800 Subject: [PATCH 1/9] feat: default to all supported languages --- src/cli.rs | 4 ++-- src/lang/mod.rs | 8 +++++++- src/test_filter.rs | 1 + tests/cli_smoke.rs | 35 ++++++++++++++++++++++++++--------- tests/lang_filter.rs | 7 ++++++- 5 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 2359dcd..f8439c4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,7 +11,7 @@ pub enum TestFilterMode { #[command(name = "git-diff-stat")] #[command(about = "Enhanced git diff --stat with untracked and test filtering")] #[command( - after_help = "Examples:\n git diff-stat\n git diff-stat --commit HEAD\n git diff-stat --last\n git diff-stat --last --no-test-filter\n git diff-stat HEAD~1..HEAD --lang py --no-test-filter\n git diff-stat --lang py --test\n git diff-stat --test\n\nDefaults:\n --lang rs,py\n test filter: --no-test" + after_help = "Examples:\n git diff-stat\n git diff-stat --commit HEAD\n git diff-stat --last\n git diff-stat --last --no-test-filter\n git diff-stat HEAD~1..HEAD --lang py --no-test-filter\n git diff-stat --lang py --test\n git diff-stat --test\n\nDefaults:\n --lang rs,py,js,ts\n test filter: --no-test" )] pub struct Cli { #[arg(long, conflicts_with_all = ["no_test", "no_test_filter"])] @@ -29,7 +29,7 @@ pub struct Cli { #[arg(long, conflicts_with_all = ["commit", "revisions"])] pub last: bool, - #[arg(long, value_name = "LANGS", default_value = "rs,py")] + #[arg(long, value_name = "LANGS")] pub lang: Option, #[arg(value_name = "REVISION")] diff --git a/src/lang/mod.rs b/src/lang/mod.rs index a275be9..1fcd1e4 100644 --- a/src/lang/mod.rs +++ b/src/lang/mod.rs @@ -2,6 +2,8 @@ use std::path::Path; use crate::change::FileChange; +const SUPPORTED_LANGS: &[&str] = &["rs", "py", "js", "ts"]; + pub mod python; pub mod rust; @@ -31,7 +33,11 @@ pub fn parse_langs(value: Option<&str>) -> Vec<&str> { .filter(|value| !value.is_empty()) .collect() }) - .unwrap_or_default() + .unwrap_or_else(|| supported_langs().to_vec()) +} + +pub fn supported_langs() -> &'static [&'static str] { + SUPPORTED_LANGS } pub fn detect_language(path: &str) -> Option<&'static str> { diff --git a/src/test_filter.rs b/src/test_filter.rs index 94da4b3..a3a4a66 100644 --- a/src/test_filter.rs +++ b/src/test_filter.rs @@ -44,6 +44,7 @@ pub fn build_test_filtered_stats( let (added, deleted) = match language { "rs" => build_counts_for_rust(&context, &whole_test_paths, change)?, "py" => build_counts_for_python(&context, &whole_test_paths, change)?, + "js" | "ts" => select_counts_from_whole_file(change, false, false, context.mode), _ => continue, }; diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 1091f0e..a6d56ff 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -26,6 +26,11 @@ fn working_tree_output_mentions_scope_lang_and_test_mode() { "pub fn answer() -> i32 {\n 41\n}\n", ) .unwrap(); + fs::write( + tempdir.path().join("web.js"), + "export const answer = () => 41;\n", + ) + .unwrap(); run_git(tempdir.path(), ["add", "src/lib.rs"]); run_git(tempdir.path(), ["commit", "-m", "initial"]); @@ -41,9 +46,10 @@ fn working_tree_output_mentions_scope_lang_and_test_mode() { .assert() .success() .stdout(predicate::str::contains( - "未提交的 rs,py 文件中,非测试代码统计如下:", + "未提交的 rs,py,js,ts 文件中,非测试代码统计如下:", )) - .stdout(predicate::str::contains("src/lib.rs")); + .stdout(predicate::str::contains("src/lib.rs")) + .stdout(predicate::str::contains("web.js")); } #[test] @@ -90,7 +96,7 @@ fn last_flag_reports_head_patch() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs,py 文件中,测试与非测试代码统计如下:", + "最后一次提交的 rs,py,js,ts 文件中,测试与非测试代码统计如下:", )) .stdout(predicate::str::contains("src/tracked.rs")) .stdout(predicate::str::contains("1 insertion")); @@ -152,11 +158,11 @@ fn default_filters_to_rust_non_test_changes() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs,py 文件中,非测试代码统计如下:", + "最后一次提交的 rs,py,js,ts 文件中,非测试代码统计如下:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("tests/integration.rs").not()) - .stdout(predicate::str::contains("web.js").not()); + .stdout(predicate::str::contains("web.js")); } #[test] @@ -215,11 +221,11 @@ fn no_test_filter_includes_all_rust_changes_but_keeps_default_lang() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs,py 文件中,测试与非测试代码统计如下:", + "最后一次提交的 rs,py,js,ts 文件中,测试与非测试代码统计如下:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("tests/integration.rs")) - .stdout(predicate::str::contains("web.js").not()); + .stdout(predicate::str::contains("web.js")); } #[test] @@ -240,6 +246,11 @@ fn default_lang_includes_rust_and_python_non_test_changes() { "def answer() -> int:\n return 41\n\n\ndef test_inline() -> None:\n assert True\n", ) .unwrap(); + fs::write( + tempdir.path().join("web.js"), + "export const answer = () => 41;\n", + ) + .unwrap(); fs::write( tempdir.path().join("tests/test_app.py"), "def test_external() -> None:\n assert True\n", @@ -247,7 +258,7 @@ fn default_lang_includes_rust_and_python_non_test_changes() { .unwrap(); run_git( tempdir.path(), - ["add", "src/lib.rs", "app/main.py", "tests/test_app.py"], + ["add", "src/lib.rs", "app/main.py", "web.js", "tests/test_app.py"], ); run_git(tempdir.path(), ["commit", "-m", "initial"]); @@ -261,6 +272,11 @@ fn default_lang_includes_rust_and_python_non_test_changes() { "def answer() -> int:\n return 42\n\n\ndef test_inline() -> None:\n assert False\n", ) .unwrap(); + fs::write( + tempdir.path().join("web.js"), + "export const answer = () => 42;\n", + ) + .unwrap(); fs::write( tempdir.path().join("tests/test_app.py"), "def test_external() -> None:\n assert False\n", @@ -273,10 +289,11 @@ fn default_lang_includes_rust_and_python_non_test_changes() { .assert() .success() .stdout(predicate::str::contains( - "未提交的 rs,py 文件中,非测试代码统计如下:", + "未提交的 rs,py,js,ts 文件中,非测试代码统计如下:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("app/main.py")) + .stdout(predicate::str::contains("web.js")) .stdout(predicate::str::contains("tests/test_app.py").not()); } diff --git a/tests/lang_filter.rs b/tests/lang_filter.rs index 2e40eea..b7b756b 100644 --- a/tests/lang_filter.rs +++ b/tests/lang_filter.rs @@ -1,5 +1,5 @@ use git_diff_stat::change::FileChange; -use git_diff_stat::lang::filter_by_langs; +use git_diff_stat::lang::{filter_by_langs, parse_langs}; #[test] fn filters_to_requested_extensions() { @@ -54,3 +54,8 @@ fn filters_to_python_extension() { assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].path, "app/main.py"); } + +#[test] +fn omitted_lang_defaults_to_all_supported_languages() { + assert_eq!(parse_langs(None), vec!["rs", "py", "js", "ts"]); +} From f4520ae04f3bf8e3f66cf58218d91f528367cd28 Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 01:47:16 +0800 Subject: [PATCH 2/9] feat: add js and ts family language detection --- src/lang/javascript.rs | 13 ++++++++ src/lang/mod.rs | 11 ++----- tests/lang_filter.rs | 73 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 src/lang/javascript.rs diff --git a/src/lang/javascript.rs b/src/lang/javascript.rs new file mode 100644 index 0000000..0901b16 --- /dev/null +++ b/src/lang/javascript.rs @@ -0,0 +1,13 @@ +use std::path::Path; + +pub fn detect_language(path: &str) -> Option<&'static str> { + match Path::new(path).extension().and_then(|ext| ext.to_str()) { + Some("js") => Some("js"), + Some("ts") => Some("ts"), + Some("jsx") => Some("jsx"), + Some("tsx") => Some("tsx"), + Some("cjs") => Some("cjs"), + Some("mjs") => Some("mjs"), + _ => None, + } +} diff --git a/src/lang/mod.rs b/src/lang/mod.rs index 1fcd1e4..f488193 100644 --- a/src/lang/mod.rs +++ b/src/lang/mod.rs @@ -1,9 +1,8 @@ -use std::path::Path; - use crate::change::FileChange; -const SUPPORTED_LANGS: &[&str] = &["rs", "py", "js", "ts"]; +const SUPPORTED_LANGS: &[&str] = &["rs", "py", "js", "ts", "jsx", "tsx", "cjs", "mjs"]; +pub mod javascript; pub mod python; pub mod rust; @@ -46,11 +45,7 @@ pub fn detect_language(path: &str) -> Option<&'static str> { } else if python::matches_path(path) { Some("py") } else { - match Path::new(path).extension().and_then(|ext| ext.to_str()) { - Some("js") => Some("js"), - Some("ts") => Some("ts"), - _ => None, - } + javascript::detect_language(path) } } diff --git a/tests/lang_filter.rs b/tests/lang_filter.rs index b7b756b..5e1ef87 100644 --- a/tests/lang_filter.rs +++ b/tests/lang_filter.rs @@ -57,5 +57,76 @@ fn filters_to_python_extension() { #[test] fn omitted_lang_defaults_to_all_supported_languages() { - assert_eq!(parse_langs(None), vec!["rs", "py", "js", "ts"]); + assert_eq!( + parse_langs(None), + vec!["rs", "py", "js", "ts", "jsx", "tsx", "cjs", "mjs"] + ); +} + +#[test] +fn filters_js_ts_family_extensions_individually() { + let changes = vec![ + FileChange { + path: "web/app.js".to_string(), + old_path: "web/app.js".to_string(), + new_path: "web/app.js".to_string(), + added: 1, + deleted: 0, + untracked: false, + }, + FileChange { + path: "web/app.ts".to_string(), + old_path: "web/app.ts".to_string(), + new_path: "web/app.ts".to_string(), + added: 1, + deleted: 0, + untracked: false, + }, + FileChange { + path: "web/component.jsx".to_string(), + old_path: "web/component.jsx".to_string(), + new_path: "web/component.jsx".to_string(), + added: 1, + deleted: 0, + untracked: false, + }, + FileChange { + path: "web/component.tsx".to_string(), + old_path: "web/component.tsx".to_string(), + new_path: "web/component.tsx".to_string(), + added: 1, + deleted: 0, + untracked: false, + }, + FileChange { + path: "web/config.cjs".to_string(), + old_path: "web/config.cjs".to_string(), + new_path: "web/config.cjs".to_string(), + added: 1, + deleted: 0, + untracked: false, + }, + FileChange { + path: "web/entry.mjs".to_string(), + old_path: "web/entry.mjs".to_string(), + new_path: "web/entry.mjs".to_string(), + added: 1, + deleted: 0, + untracked: false, + }, + ]; + + let jsx = filter_by_langs(&changes, &["jsx"]).unwrap(); + let tsx = filter_by_langs(&changes, &["tsx"]).unwrap(); + let cjs = filter_by_langs(&changes, &["cjs"]).unwrap(); + let mjs = filter_by_langs(&changes, &["mjs"]).unwrap(); + + assert_eq!(jsx.len(), 1); + assert_eq!(jsx[0].path, "web/component.jsx"); + assert_eq!(tsx.len(), 1); + assert_eq!(tsx[0].path, "web/component.tsx"); + assert_eq!(cjs.len(), 1); + assert_eq!(cjs[0].path, "web/config.cjs"); + assert_eq!(mjs.len(), 1); + assert_eq!(mjs[0].path, "web/entry.mjs"); } From 18efcea8d92d8e51410a830b412387d6ad2886e7 Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 01:48:24 +0800 Subject: [PATCH 3/9] feat: classify js and ts family test paths --- src/lang/javascript.rs | 37 +++++++++++++++++++++++++++++++++++++ tests/javascript_tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 tests/javascript_tests.rs diff --git a/src/lang/javascript.rs b/src/lang/javascript.rs index 0901b16..4a1e056 100644 --- a/src/lang/javascript.rs +++ b/src/lang/javascript.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::path::Path; pub fn detect_language(path: &str) -> Option<&'static str> { @@ -11,3 +12,39 @@ pub fn detect_language(path: &str) -> Option<&'static str> { _ => None, } } + +pub fn collect_whole_test_paths( + sources: &[(String, String)], +) -> Result, String> { + Ok(sources + .iter() + .map(|(path, _)| path) + .filter(|path| is_whole_test_path(path)) + .cloned() + .collect()) +} + +fn is_whole_test_path(path: &str) -> bool { + let Some(language) = detect_language(path) else { + return false; + }; + + if Path::new(path).components().any(|component| { + matches!( + component.as_os_str().to_str(), + Some("__tests__" | "e2e" | "cypress" | "playwright") + ) + }) { + return true; + } + + let filename = Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + let Some(stem) = filename.strip_suffix(&format!(".{language}")) else { + return false; + }; + + stem.ends_with(".test") || stem.ends_with(".spec") || stem.ends_with(".cy") +} diff --git a/tests/javascript_tests.rs b/tests/javascript_tests.rs new file mode 100644 index 0000000..e00af24 --- /dev/null +++ b/tests/javascript_tests.rs @@ -0,0 +1,28 @@ +use git_diff_stat::lang::javascript::collect_whole_test_paths; + +#[test] +fn collects_javascript_and_typescript_test_paths() { + let sources = vec![ + ("src/app.ts".to_string(), String::new()), + ("src/__tests__/app.ts".to_string(), String::new()), + ("web/app.test.tsx".to_string(), String::new()), + ("web/app.spec.jsx".to_string(), String::new()), + ("tests/e2e/login.ts".to_string(), String::new()), + ("cypress/e2e/home.cy.js".to_string(), String::new()), + ("playwright/auth.spec.ts".to_string(), String::new()), + ("playwright.config.ts".to_string(), String::new()), + ("scripts/build.mjs".to_string(), String::new()), + ]; + + let whole_test_paths = collect_whole_test_paths(&sources).unwrap(); + + assert!(whole_test_paths.contains("src/__tests__/app.ts")); + assert!(whole_test_paths.contains("web/app.test.tsx")); + assert!(whole_test_paths.contains("web/app.spec.jsx")); + assert!(whole_test_paths.contains("tests/e2e/login.ts")); + assert!(whole_test_paths.contains("cypress/e2e/home.cy.js")); + assert!(whole_test_paths.contains("playwright/auth.spec.ts")); + assert!(!whole_test_paths.contains("src/app.ts")); + assert!(!whole_test_paths.contains("playwright.config.ts")); + assert!(!whole_test_paths.contains("scripts/build.mjs")); +} From bc0b1721ac16cad257d9b63ba5b7597c2b90dbdd Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 01:50:51 +0800 Subject: [PATCH 4/9] feat: add js and ts family test filtering --- src/lang/javascript.rs | 6 ++ src/test_filter.rs | 47 ++++++++++++- tests/cli_smoke.rs | 153 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 2 deletions(-) diff --git a/src/lang/javascript.rs b/src/lang/javascript.rs index 4a1e056..e9de245 100644 --- a/src/lang/javascript.rs +++ b/src/lang/javascript.rs @@ -1,6 +1,12 @@ use std::collections::HashSet; use std::path::Path; +const JS_TS_FAMILY_LANGS: &[&str] = &["js", "ts", "jsx", "tsx", "cjs", "mjs"]; + +pub fn family_langs() -> &'static [&'static str] { + JS_TS_FAMILY_LANGS +} + pub fn detect_language(path: &str) -> Option<&'static str> { match Path::new(path).extension().and_then(|ext| ext.to_str()) { Some("js") => Some("js"), diff --git a/src/test_filter.rs b/src/test_filter.rs index a3a4a66..44e3904 100644 --- a/src/test_filter.rs +++ b/src/test_filter.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use crate::change::FileChange; use crate::cli::TestFilterMode; use crate::git::Git; -use crate::lang::{detect_language, python, rust}; +use crate::lang::{detect_language, javascript, python, rust}; use crate::patch::parse_patch; use crate::render::DisplayStat; use crate::revision::{RevisionEndpoints, RevisionSelection}; @@ -44,7 +44,9 @@ pub fn build_test_filtered_stats( let (added, deleted) = match language { "rs" => build_counts_for_rust(&context, &whole_test_paths, change)?, "py" => build_counts_for_python(&context, &whole_test_paths, change)?, - "js" | "ts" => select_counts_from_whole_file(change, false, false, context.mode), + "js" | "ts" | "jsx" | "tsx" | "cjs" | "mjs" => { + build_counts_for_javascript(&context, &whole_test_paths, change) + } _ => continue, }; @@ -93,10 +95,18 @@ fn build_whole_test_paths( let mut old = HashMap::new(); old.insert("rs", rust::collect_whole_test_paths(&old_sources)?); old.insert("py", python::collect_whole_test_paths(&old_sources)?); + let old_javascript_paths = javascript::collect_whole_test_paths(&old_sources)?; + for language in javascript::family_langs() { + old.insert(*language, old_javascript_paths.clone()); + } let mut new = HashMap::new(); new.insert("rs", rust::collect_whole_test_paths(&new_sources)?); new.insert("py", python::collect_whole_test_paths(&new_sources)?); + let new_javascript_paths = javascript::collect_whole_test_paths(&new_sources)?; + for language in javascript::family_langs() { + new.insert(*language, new_javascript_paths.clone()); + } Ok(WholeTestPaths { old, new }) } @@ -131,6 +141,23 @@ fn build_counts_for_python( ) } +fn build_counts_for_javascript( + context: &BuildContext<'_>, + whole_test_paths: &WholeTestPaths, + change: &FileChange, +) -> (usize, usize) { + let Some(language) = change_language(change) else { + return (0, 0); + }; + + select_counts_for_whole_file_only( + change, + whole_test_paths.old.get(language), + whole_test_paths.new.get(language), + context.mode, + ) +} + fn build_counts( context: &BuildContext<'_>, old_whole_test_paths: Option<&HashSet>, @@ -194,6 +221,22 @@ where Ok(select_counts_from_split(&split, context.mode)) } +fn select_counts_for_whole_file_only( + change: &FileChange, + old_whole_test_paths: Option<&HashSet>, + new_whole_test_paths: Option<&HashSet>, + mode: TestFilterMode, +) -> (usize, usize) { + let old_is_whole_test = old_whole_test_paths + .map(|paths| paths.contains(&change.old_path)) + .unwrap_or(false); + let new_is_whole_test = new_whole_test_paths + .map(|paths| paths.contains(&change.new_path)) + .unwrap_or(false); + + select_counts_from_whole_file(change, old_is_whole_test, new_is_whole_test, mode) +} + trait TestSplitCounts { fn test_added(&self) -> usize; fn test_deleted(&self) -> usize; diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index a6d56ff..32106e6 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -550,6 +550,159 @@ fn mixed_rust_and_python_non_test_filter_handles_both_languages() { .stdout(predicate::str::contains("app/main.py")); } +#[test] +fn default_non_test_filter_excludes_javascript_and_typescript_test_files() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("web")).unwrap(); + fs::create_dir_all(tempdir.path().join("tests/e2e")).unwrap(); + fs::write( + tempdir.path().join("web/app.tsx"), + "export function App() {\n return
before
;\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("web/app.test.tsx"), + "test('app', () => {\n expect(true).toBe(true);\n});\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("tests/e2e/login.ts"), + "test('login', async () => {\n expect(true).toBe(true);\n});\n", + ) + .unwrap(); + run_git( + tempdir.path(), + ["add", "web/app.tsx", "web/app.test.tsx", "tests/e2e/login.ts"], + ); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("web/app.tsx"), + "export function App() {\n return
after
;\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("web/app.test.tsx"), + "test('app', () => {\n expect(false).toBe(false);\n});\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("tests/e2e/login.ts"), + "test('login', async () => {\n expect(false).toBe(false);\n});\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .assert() + .success() + .stdout(predicate::str::contains("web/app.tsx")) + .stdout(predicate::str::contains("web/app.test.tsx").not()) + .stdout(predicate::str::contains("tests/e2e/login.ts").not()); +} + +#[test] +fn test_filter_includes_javascript_and_typescript_test_files() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("web")).unwrap(); + fs::create_dir_all(tempdir.path().join("cypress/e2e")).unwrap(); + fs::write( + tempdir.path().join("web/app.tsx"), + "export function App() {\n return
before
;\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("web/app.spec.tsx"), + "test('app', () => {\n expect(true).toBe(true);\n});\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("cypress/e2e/home.cy.js"), + "it('home', () => {\n expect(true).to.eq(true);\n});\n", + ) + .unwrap(); + run_git( + tempdir.path(), + ["add", "web/app.tsx", "web/app.spec.tsx", "cypress/e2e/home.cy.js"], + ); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("web/app.tsx"), + "export function App() {\n return
after
;\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("web/app.spec.tsx"), + "test('app', () => {\n expect(false).toBe(false);\n});\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("cypress/e2e/home.cy.js"), + "it('home', () => {\n expect(false).to.eq(false);\n});\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .arg("--test") + .assert() + .success() + .stdout(predicate::str::contains("web/app.tsx").not()) + .stdout(predicate::str::contains("web/app.spec.tsx")) + .stdout(predicate::str::contains("cypress/e2e/home.cy.js")); +} + +#[test] +fn no_test_filter_includes_javascript_and_typescript_test_files() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("web")).unwrap(); + fs::create_dir_all(tempdir.path().join("playwright")).unwrap(); + fs::write( + tempdir.path().join("web/app.jsx"), + "export function App() {\n return
before
;\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("playwright/auth.spec.ts"), + "test('auth', async () => {\n expect(true).toBe(true);\n});\n", + ) + .unwrap(); + run_git( + tempdir.path(), + ["add", "web/app.jsx", "playwright/auth.spec.ts"], + ); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("web/app.jsx"), + "export function App() {\n return
after
;\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("playwright/auth.spec.ts"), + "test('auth', async () => {\n expect(false).toBe(false);\n});\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .arg("--no-test-filter") + .assert() + .success() + .stdout(predicate::str::contains("web/app.jsx")) + .stdout(predicate::str::contains("playwright/auth.spec.ts")); +} + #[test] fn test_filter_counts_rust_integration_test_files_as_test() { let tempdir = tempdir().unwrap(); From d1c8339834e649fb1cd064f05b7ed0f591850803 Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 01:52:58 +0800 Subject: [PATCH 5/9] perf: avoid bulk reading frontend sources --- src/test_filter.rs | 74 +++++++++++++++++++++++++++++++++++----------- tests/cli_smoke.rs | 31 +++++++++++++++++++ 2 files changed, 88 insertions(+), 17 deletions(-) diff --git a/src/test_filter.rs b/src/test_filter.rs index 44e3904..98eac5c 100644 --- a/src/test_filter.rs +++ b/src/test_filter.rs @@ -81,29 +81,45 @@ fn build_whole_test_paths( endpoints: Option<&RevisionEndpoints>, langs: &[&str], ) -> Result { - let (old_sources, new_sources) = match endpoints { + let (old_paths, new_paths) = match endpoints { Some(endpoints) => ( - load_revision_sources(git, &endpoints.old, langs)?, - load_revision_sources(git, &endpoints.new, langs)?, + load_revision_paths(git, &endpoints.old, langs)?, + load_revision_paths(git, &endpoints.new, langs)?, ), None => ( - load_index_sources(git, langs)?, - load_worktree_sources(git, langs)?, + load_index_paths(git, langs)?, + load_worktree_paths(git, langs)?, ), }; + let (old_rust_sources, new_rust_sources) = if langs.contains(&"rs") { + match endpoints { + Some(endpoints) => ( + load_revision_sources(git, &endpoints.old, &["rs"])?, + load_revision_sources(git, &endpoints.new, &["rs"])?, + ), + None => ( + load_index_sources(git, &["rs"])?, + load_worktree_sources(git, &["rs"])?, + ), + } + } else { + (Vec::new(), Vec::new()) + }; + let old_path_entries = path_entries(&old_paths); + let new_path_entries = path_entries(&new_paths); let mut old = HashMap::new(); - old.insert("rs", rust::collect_whole_test_paths(&old_sources)?); - old.insert("py", python::collect_whole_test_paths(&old_sources)?); - let old_javascript_paths = javascript::collect_whole_test_paths(&old_sources)?; + old.insert("rs", rust::collect_whole_test_paths(&old_rust_sources)?); + old.insert("py", python::collect_whole_test_paths(&old_path_entries)?); + let old_javascript_paths = javascript::collect_whole_test_paths(&old_path_entries)?; for language in javascript::family_langs() { old.insert(*language, old_javascript_paths.clone()); } let mut new = HashMap::new(); - new.insert("rs", rust::collect_whole_test_paths(&new_sources)?); - new.insert("py", python::collect_whole_test_paths(&new_sources)?); - let new_javascript_paths = javascript::collect_whole_test_paths(&new_sources)?; + new.insert("rs", rust::collect_whole_test_paths(&new_rust_sources)?); + new.insert("py", python::collect_whole_test_paths(&new_path_entries)?); + let new_javascript_paths = javascript::collect_whole_test_paths(&new_path_entries)?; for language in javascript::family_langs() { new.insert(*language, new_javascript_paths.clone()); } @@ -320,6 +336,10 @@ fn load_index_sources(git: &Git, langs: &[&str]) -> Result }) } +fn load_index_paths(git: &Git, langs: &[&str]) -> Result, String> { + Ok(filter_paths(git.tracked_files()?, langs)) +} + fn load_worktree_sources(git: &Git, langs: &[&str]) -> Result, String> { let mut paths = git.tracked_files()?; paths.retain(|path| git.worktree_file_exists(path)); @@ -327,6 +347,13 @@ fn load_worktree_sources(git: &Git, langs: &[&str]) -> Result Result, String> { + let mut paths = git.tracked_files()?; + paths.retain(|path| git.worktree_file_exists(path)); + paths.extend(git.untracked_files()?); + Ok(filter_paths(paths, langs)) +} + fn load_revision_sources( git: &Git, revision: &str, @@ -337,6 +364,10 @@ fn load_revision_sources( }) } +fn load_revision_paths(git: &Git, revision: &str, langs: &[&str]) -> Result, String> { + Ok(filter_paths(git.revision_files(revision)?, langs)) +} + fn load_sources( paths: Vec, langs: &[&str], @@ -347,21 +378,30 @@ where { let mut sources = Vec::new(); - for path in paths { - if !should_load_source(&path, langs) { - continue; - } - + for path in filter_paths(paths, langs) { sources.push((path.clone(), read_source(&path)?)); } Ok(sources) } -fn should_load_source(path: &str, langs: &[&str]) -> bool { +fn filter_paths(paths: Vec, langs: &[&str]) -> Vec { + paths.into_iter() + .filter(|path| should_include_path(path, langs)) + .collect() +} + +fn should_include_path(path: &str, langs: &[&str]) -> bool { let Some(language) = detect_language(path) else { return false; }; langs.is_empty() || langs.contains(&language) } + +fn path_entries(paths: &[String]) -> Vec<(String, String)> { + paths.iter() + .cloned() + .map(|path| (path, String::new())) + .collect() +} diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 32106e6..e6a7b17 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -703,6 +703,37 @@ fn no_test_filter_includes_javascript_and_typescript_test_files() { .stdout(predicate::str::contains("playwright/auth.spec.ts")); } +#[test] +fn default_non_test_filter_skips_bulk_reading_frontend_sources_for_path_rules() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("web")).unwrap(); + fs::create_dir_all(tempdir.path().join("scripts")).unwrap(); + fs::write( + tempdir.path().join("web/app.tsx"), + "export function App() {\n return
before
;\n}\n", + ) + .unwrap(); + fs::write(tempdir.path().join("scripts/build.mjs"), [0xff, 0xfe, 0xfd]).unwrap(); + run_git(tempdir.path(), ["add", "web/app.tsx", "scripts/build.mjs"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("web/app.tsx"), + "export function App() {\n return
after
;\n}\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .assert() + .success() + .stdout(predicate::str::contains("web/app.tsx")) + .stdout(predicate::str::contains("scripts/build.mjs").not()); +} + #[test] fn test_filter_counts_rust_integration_test_files_as_test() { let tempdir = tempdir().unwrap(); From 3185cf2892e1ed31c35db39700166d5946ebded1 Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 01:54:40 +0800 Subject: [PATCH 6/9] docs: describe all supported language defaults --- README.md | 18 ++++++++++-------- src/cli.rs | 2 +- tests/cli_smoke.rs | 4 ++++ tests/readme_presence.rs | 3 +++ 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a317bd6..38dfbb2 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ - untracked files included in default stats - language filtering with `--lang` -- Rust-only, non-test-only stats by default, with `--test`, `--no-test`, and `--no-test-filter` -- test-aware filtering for Rust and Python +- non-test-only stats by default across all supported languages, with `--test`, `--no-test`, and `--no-test-filter` +- test-aware filtering for Rust, Python, and JS/TS families - single-commit and revision-range support This repository also ships `rust-test-audit`, a companion CLI for auditing Rust source trees @@ -75,22 +75,23 @@ git diff-stat --last git diff-stat --last --no-test-filter git diff-stat HEAD~1..HEAD --lang py --no-test-filter git diff-stat --lang py --test +git diff-stat --lang tsx --test git diff-stat --test ``` ## Usage ```bash -git diff-stat [ | | ] [--lang rs,py,js] [--test | --no-test | --no-test-filter] +git diff-stat [ | | ] [--lang rs,py,js,ts,jsx,tsx,cjs,mjs] [--test | --no-test | --no-test-filter] ``` Defaults: -- `--lang` defaults to `rs,py` +- `--lang` defaults to all supported languages: `rs,py,js,ts,jsx,tsx,cjs,mjs` - test filtering defaults to `--no-test` - output always begins with a header line describing the comparison scope, languages, and test scope -That means plain `git diff-stat` already reports Rust and Python non-test changes together. +That means plain `git diff-stat` already reports non-test changes across all currently supported languages. ## Rust Test Audit @@ -131,8 +132,9 @@ test regions cross configurable density thresholds. - `--lang` currently uses file extensions. - `--test` and `--no-test` treat Rust files under `tests/` and Rust files imported by `#[cfg(test)]` module declarations as whole-file test code. Other Rust files still use code-region splitting for `#[cfg(test)]` modules and test-annotated functions such as `#[test]` and `#[tokio::test]`. - `--test` and `--no-test` treat Python files under `tests/`, `test_*.py`, `*_test.py`, and `conftest.py` as whole-file test code. Other Python files split test regions using `def test_*` and `class Test*`. -- `--no-test-filter` disables Rust and Python test splitting entirely and reports full-file stats for the selected languages. -- because `--lang` defaults to `rs,py`, use `--lang rs` or `--lang py` when you want a narrower language set. +- `--test` and `--no-test` treat JS/TS family files under `__tests__/`, `e2e/`, `cypress/`, and `playwright/`, plus files matching `*.test.*`, `*.spec.*`, and `*.cy.*`, as whole-file test code. +- `--no-test-filter` disables Rust and Python region splitting and reports full-file stats for the selected languages. +- `--lang` defaults to all supported languages, so use `--lang rs`, `--lang py`, or `--lang tsx` when you want a narrower language set. - `--last` is sugar for the patch introduced by `HEAD`, equivalent to `HEAD^!`. -- rendered output starts with a Chinese description line such as `未提交的 rs,py 文件中,非测试代码统计如下:`. +- rendered output starts with a Chinese description line such as `未提交的 rs,py,js,ts,jsx,tsx,cjs,mjs 文件中,非测试代码统计如下:`. - Output is intentionally close to `git diff --stat`, but not byte-for-byte identical. diff --git a/src/cli.rs b/src/cli.rs index f8439c4..cf82f0d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,7 +11,7 @@ pub enum TestFilterMode { #[command(name = "git-diff-stat")] #[command(about = "Enhanced git diff --stat with untracked and test filtering")] #[command( - after_help = "Examples:\n git diff-stat\n git diff-stat --commit HEAD\n git diff-stat --last\n git diff-stat --last --no-test-filter\n git diff-stat HEAD~1..HEAD --lang py --no-test-filter\n git diff-stat --lang py --test\n git diff-stat --test\n\nDefaults:\n --lang rs,py,js,ts\n test filter: --no-test" + after_help = "Examples:\n git diff-stat\n git diff-stat --commit HEAD\n git diff-stat --last\n git diff-stat --last --no-test-filter\n git diff-stat HEAD~1..HEAD --lang py --no-test-filter\n git diff-stat --lang py --test\n git diff-stat --lang tsx --test\n git diff-stat --test\n\nDefaults:\n --lang all supported languages (rs,py,js,ts,jsx,tsx,cjs,mjs)\n test filter: --no-test" )] pub struct Cli { #[arg(long, conflicts_with_all = ["no_test", "no_test_filter"])] diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index e6a7b17..bd4e8a2 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -64,6 +64,10 @@ fn help_mentions_common_examples() { .stdout(predicate::str::contains( "git diff-stat --last --no-test-filter", )) + .stdout(predicate::str::contains("git diff-stat --lang tsx --test")) + .stdout(predicate::str::contains( + "--lang all supported languages (rs,py,js,ts,jsx,tsx,cjs,mjs)", + )) .stdout(predicate::str::contains("--no-test-filter")); } diff --git a/tests/readme_presence.rs b/tests/readme_presence.rs index 4bb1e9d..ae88651 100644 --- a/tests/readme_presence.rs +++ b/tests/readme_presence.rs @@ -4,4 +4,7 @@ fn readme_mentions_github_release_install() { assert!(readme.contains("GitHub Releases")); assert!(readme.contains("v0.1.0")); + assert!(readme.contains("`--lang` defaults to all supported languages")); + assert!(readme.contains("*.test.*")); + assert!(readme.contains("cypress/")); } From 57e6859bc0b86f180d4f248729463931bf6b0856 Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 02:02:51 +0800 Subject: [PATCH 7/9] chore: finalize js ts family support --- .../2026-03-21-js-ts-family-support-design.md | 274 +++++++++++++++ docs/plans/2026-03-21-js-ts-family-support.md | 311 ++++++++++++++++++ src/lang/javascript.rs | 4 +- src/test_filter.rs | 6 +- tests/cli_smoke.rs | 32 +- 5 files changed, 614 insertions(+), 13 deletions(-) create mode 100644 docs/plans/2026-03-21-js-ts-family-support-design.md create mode 100644 docs/plans/2026-03-21-js-ts-family-support.md diff --git a/docs/plans/2026-03-21-js-ts-family-support-design.md b/docs/plans/2026-03-21-js-ts-family-support-design.md new file mode 100644 index 0000000..4a37cdc --- /dev/null +++ b/docs/plans/2026-03-21-js-ts-family-support-design.md @@ -0,0 +1,274 @@ +# JS/TS Family Language Support Design + +**Context** + +`git-diff-stat` currently supports Rust and Python as first-class test-aware languages. The language layer in [`src/lang/mod.rs`](../../src/lang/mod.rs) still has two structural limits: + +- the default `--lang` behavior is represented as a hard-coded CLI string instead of "all supported languages" +- JS and TS are only partially recognized as file extensions, and they do not participate in `--test` or `--no-test` + +The next goal is broader frontend language coverage: + +- support `js`, `ts`, `jsx`, `tsx`, `cjs`, and `mjs` +- treat unit tests and e2e tests as test code +- change default `--lang` semantics from a fixed subset to "all supported languages" + +The user explicitly approved a narrow first version for JS/TS test semantics: + +- whole-file test classification only +- no file-internal `describe` / `it` / `test` region splitting + +**Goal** + +Add first-class JS/TS family support with test-aware filtering, while making default language selection come from the language registry rather than from a duplicated string literal in the CLI layer. + +The result should make future language additions easier, not harder. + +**Approaches** + +1. Minimal patching + - Add more extensions directly in [`src/lang/mod.rs`](../../src/lang/mod.rs) + - Change the CLI default string from `rs,py` to a longer comma-separated list + - Add ad hoc JS/TS branches in [`src/test_filter.rs`](../../src/test_filter.rs) + - Rejected because the default language list would still be duplicated across language detection, CLI help, README, and tests. + +2. Registry-driven defaults with lightweight JS/TS backend support + - Introduce a single source of truth for supported languages + - Derive default `--lang` behavior from that registry + - Add a JS/TS backend that performs whole-file test classification only + - Recommended. + +3. Full backend capability framework + - Build a more generic trait/capability model for path matching, whole-file classification, region splitting, aliasing, and default inclusion + - Technically clean, but over-designed for the current repository size + - Rejected for now. + +**Decision** + +Use registry-driven defaults plus a lightweight JS/TS backend. + +This keeps the current Rust/Python design direction, but tightens two pieces that are now becoming important: + +- "supported languages" must live in one place +- test-aware orchestration must support languages that only provide whole-file test classification + +**Default Language Semantics** + +`--lang` should no longer default to a hard-coded subset such as `rs,py`. + +Instead: + +- if the user passes `--lang`, respect exactly that explicit set +- if the user omits `--lang`, treat it as "all supported languages" + +For the current repository state after this change, "all supported languages" means: + +- `rs` +- `py` +- `js` +- `ts` +- `jsx` +- `tsx` +- `cjs` +- `mjs` + +This should be surfaced consistently in: + +- CLI parsing +- output headers +- help text examples +- README defaults +- tests + +The critical rule is that the support list should be declared once in the language layer and reused everywhere else. + +**Proposed Structure** + +- `src/lang/mod.rs` + - registry of supported language tokens + - parsing for explicit `--lang` values + - default-language expansion when `--lang` is omitted + - path-to-language detection +- `src/lang/rust.rs` + - existing Rust support + - whole-file test classification plus region splitting +- `src/lang/python.rs` + - existing Python support + - whole-file test classification plus region splitting +- `src/lang/javascript.rs` + - JS/TS family path matching + - whole-file test classification only +- `src/test_filter.rs` + - shared orchestration across selected languages + - support for backends that only classify whole-file test paths + +This is still a moderate refactor, not a rewrite. + +**Language Registry Shape** + +The registry only needs to answer a few central questions: + +- which language tokens are supported? +- which token matches a given path? +- what is the default language set when `--lang` is omitted? + +One practical model is: + +- `supported_langs() -> &'static [&'static str]` +- `default_langs() -> &'static [&'static str]` +- `parse_langs(value: Option<&str>) -> Vec<&str>` +- `detect_language(path: &str) -> Option<&'static str>` + +For now, `default_langs()` can simply return the same list as `supported_langs()`. + +This avoids duplicating the support list in [`src/cli.rs`](../../src/cli.rs) and [`README.md`](../../README.md). + +**JS/TS Family Matching** + +The new frontend backend should recognize these extensions directly: + +- `.js` +- `.ts` +- `.jsx` +- `.tsx` +- `.cjs` +- `.mjs` + +Each extension should map to its own `--lang` token. This keeps filtering precise: + +- `--lang js` should not automatically include `ts` +- `--lang tsx` should only include `.tsx` +- omitting `--lang` includes all of them + +This is a better fit for current CLI semantics than collapsing everything into a single `web` alias. + +**JS/TS Test Semantics** + +The approved first version is whole-file classification only. + +Treat these as test files: + +- any file under a `__tests__/` path component +- filenames matching `*.test.` +- filenames matching `*.spec.` +- any file under an `e2e/` path component +- any file under a `cypress/` path component +- any file under a `playwright/` path component +- filenames matching `*.cy.` + +Where `` is one of: + +- `js` +- `ts` +- `jsx` +- `tsx` +- `cjs` +- `mjs` + +These rules intentionally cover both unit and e2e test conventions. + +**Out of Scope** + +Not in scope for the first JS/TS version: + +- file-internal test block detection using `describe`, `it`, `test`, or `suite` +- `vitest` inline test detection such as `import.meta.vitest` +- framework-specific config discovery from Jest, Vitest, Playwright, Cypress, or custom tooling +- special handling for snapshot files + +This is intentional. Most real-world JS/TS repositories still place tests in dedicated files or directories, so whole-file classification captures the highest-value cases with low false-positive risk. + +**Test-Filter Orchestration** + +The shared builder in [`src/test_filter.rs`](../../src/test_filter.rs) currently assumes that selected languages either: + +- have whole-file test paths and region splitting, or +- are ignored entirely + +JS/TS adds a third useful case: + +- whole-file test classification only + +The orchestration should therefore support: + +1. languages with whole-file and region split support +2. languages with whole-file-only support + +For JS/TS family files: + +- if a file matches a whole-file test rule, count it as test code +- otherwise, count the full file diff as non-test code + +That preserves correct semantics for `--test`, `--no-test`, and `--no-test-filter` without introducing AST parsing. + +**Source Loading Strategy** + +This addition makes source-loading efficiency more important. + +Rust still needs source contents for path-imported `#[cfg(test)]` module detection. Python and JS/TS whole-file classification are path-driven. The design should avoid eager content reads for languages that only need paths. + +That means the shared builder should distinguish between: + +- path-only whole-file classification +- source-assisted whole-file classification +- region splitting + +Even if the implementation stays simple, it should at least avoid bulk-reading JS/TS files just to classify them by filename or directory name. + +**Data Flow** + +After the refactor, runtime behavior should look like this: + +1. Parse CLI. +2. Resolve revision selection. +3. Parse explicit `--lang`, or expand to all supported languages if omitted. +4. Filter `FileChange` values to the selected languages. +5. If `--no-test-filter`, render full-file stats directly. +6. Otherwise: + - compute whole-file test paths for each selected language backend + - use region splitting only for languages that implement it + - treat JS/TS non-test files as full-file non-test diffs +7. Render the existing header with the updated language scope. + +The biggest behavioral change is that plain `git diff-stat` now means "all supported languages" instead of "Rust and Python only". + +**Testing Strategy** + +Add coverage at three levels: + +1. Registry and extension tests + - supported language parsing + - default language expansion + - path detection for `js`, `ts`, `jsx`, `tsx`, `cjs`, `mjs` +2. JS/TS test classification unit tests + - `__tests__/` + - `*.test.*` + - `*.spec.*` + - `e2e/` + - `cypress/` + - `playwright/` + - `*.cy.*` +3. CLI smoke tests + - default run includes supported frontend files + - default `--no-test` excludes JS/TS unit and e2e test files + - `--test` includes those files + - `--no-test-filter` restores full-file counting + - explicit `--lang tsx` or `--lang cjs` behaves narrowly + +The smoke suite should also prove that mixed repositories still combine Rust, Python, and JS/TS families correctly. + +**Risks** + +- If default-language logic remains duplicated, future additions will drift again between CLI, README, and actual behavior. +- If JS/TS whole-file rules are too broad, application code under directories like `tests-data/` or `playwright.config.ts` could be misclassified; the patterns should stay intentionally narrow and component-based. +- If the builder eagerly reads all JS/TS sources, repositories with many frontend assets could take an unnecessary performance hit. + +**Outcome** + +After this change: + +1. plain `git diff-stat` covers all supported languages +2. JS/TS family files participate in `--test` and `--no-test` +3. test-aware orchestration no longer assumes every language must support region splitting + +That is enough structure to add more path-driven languages later without reworking the CLI defaults again. diff --git a/docs/plans/2026-03-21-js-ts-family-support.md b/docs/plans/2026-03-21-js-ts-family-support.md new file mode 100644 index 0000000..c4482b7 --- /dev/null +++ b/docs/plans/2026-03-21-js-ts-family-support.md @@ -0,0 +1,311 @@ +# JS/TS Family Language Support Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add `js`, `ts`, `jsx`, `tsx`, `cjs`, and `mjs` as first-class `--lang` values, make omitted `--lang` mean "all supported languages", and extend `--test` / `--no-test` to JS/TS unit and e2e test files. + +**Architecture:** Centralize supported/default languages in `src/lang/mod.rs`, add a `src/lang/javascript.rs` backend for whole-file test classification, and update `src/test_filter.rs` to support languages that only provide whole-file test semantics. Preserve Rust and Python behavior while broadening default language coverage. + +**Tech Stack:** Rust, clap, assert_cmd, predicates, cargo test, cargo clippy + +--- + +### Task 1: Lock down registry-driven default language behavior + +**Files:** +- Modify: `tests/lang_filter.rs` +- Modify: `tests/cli_smoke.rs` +- Modify: `src/lang/mod.rs` +- Modify: `src/cli.rs` + +**Step 1: Write the failing test** + +Add unit coverage proving that omitted `--lang` expands to all supported languages instead of a fixed `rs,py` subset. Add one CLI smoke regression proving plain `git diff-stat` includes a frontend source file in addition to Rust/Python when that file has non-test changes. + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test lang_filter --test cli_smoke -v +``` + +Expected: FAIL because the CLI and language parser still default to `rs,py`. + +**Step 3: Write minimal implementation** + +Move default-language responsibility into `src/lang/mod.rs`. Remove the hard-coded CLI default value and make omitted `--lang` resolve to all supported tokens from the registry. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test --test lang_filter --test cli_smoke -v +``` + +Expected: PASS + +### Task 2: Add full JS/TS family extension support + +**Files:** +- Modify: `src/lang/mod.rs` +- Create: `src/lang/javascript.rs` +- Modify: `src/lib.rs` +- Modify: `tests/lang_filter.rs` + +**Step 1: Write the failing test** + +Add unit tests proving language detection and filtering recognize: + +- `app.js` +- `app.ts` +- `component.jsx` +- `component.tsx` +- `config.cjs` +- `entry.mjs` + +Also add a regression proving explicit `--lang tsx` only keeps `.tsx` files. + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test lang_filter -v +``` + +Expected: FAIL because the new extensions are not fully supported yet. + +**Step 3: Write minimal implementation** + +Add `src/lang/javascript.rs` and route JS/TS family extension detection through it. Keep the token model explicit: each extension remains its own `--lang` value rather than collapsing into one alias. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test --test lang_filter -v +``` + +Expected: PASS + +### Task 3: Add JS/TS whole-file test classification primitives + +**Files:** +- Create: `tests/javascript_tests.rs` +- Modify: `src/lang/javascript.rs` + +**Step 1: Write the failing test** + +Add focused unit tests proving these are classified as whole-file tests: + +- `src/__tests__/app.ts` +- `web/app.test.tsx` +- `web/app.spec.jsx` +- `tests/e2e/login.ts` +- `cypress/e2e/home.cy.js` +- `playwright/auth.spec.ts` + +Also add negative cases such as: + +- `src/app.ts` +- `scripts/build.mjs` +- `playwright.config.ts` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test javascript_tests -v +``` + +Expected: FAIL because JS/TS whole-file test classification does not exist yet. + +**Step 3: Write minimal implementation** + +Implement path-based whole-file classification in `src/lang/javascript.rs` using path components and filename patterns only. Do not add AST parsing or inline test detection. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test --test javascript_tests -v +``` + +Expected: PASS + +### Task 4: Teach the shared test filter to handle whole-file-only languages + +**Files:** +- Modify: `src/test_filter.rs` +- Modify: `src/main.rs` +- Modify: `src/lang/javascript.rs` +- Modify: `tests/cli_smoke.rs` + +**Step 1: Write the failing test** + +Add CLI smoke coverage proving: + +- default `--no-test` excludes `*.test.tsx` and e2e files from the output +- `--test` includes those same files +- `--no-test-filter` reports them as ordinary full-file stats +- non-test JS/TS source files are still counted under `--no-test` + +Use a mixed repository that contains at least one frontend source file and at least one frontend test file. + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test cli_smoke -v +``` + +Expected: FAIL because the shared builder currently only dispatches Rust and Python test-aware behavior. + +**Step 3: Write minimal implementation** + +Extend `src/test_filter.rs` so JS/TS family backends can contribute whole-file test paths without region splitting. For files that are not whole-file tests, treat the full diff as non-test code. + +Keep the builder scoped to selected languages only. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test --test cli_smoke -v +``` + +Expected: PASS + +### Task 5: Avoid unnecessary frontend source reads during whole-file classification + +**Files:** +- Modify: `src/test_filter.rs` +- Modify: `src/lang/rust.rs` +- Modify: `src/lang/python.rs` +- Modify: `src/lang/javascript.rs` +- Modify: `tests/cli_smoke.rs` + +**Step 1: Write the failing test** + +Add a regression similar to the existing Python review fix: create a tracked JS/TS family file with non-UTF8 bytes that should be ignored when the selected languages do not include it, and prove the command still succeeds. + +Also add coverage proving JS/TS whole-file classification can be computed without bulk-reading frontend source contents when only path-based rules are needed. + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test cli_smoke -v +``` + +Expected: FAIL if the builder still eagerly reads irrelevant frontend sources. + +**Step 3: Write minimal implementation** + +Separate path-only whole-file classification from source-assisted whole-file classification inside `src/test_filter.rs`. Preserve Rust behavior for imported `#[cfg(test)]` modules while avoiding unnecessary JS/TS content reads. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test --test cli_smoke -v +``` + +Expected: PASS + +### Task 6: Update help text and README for all-supported-language defaults + +**Files:** +- Modify: `src/cli.rs` +- Modify: `README.md` +- Modify: `tests/cli_smoke.rs` +- Modify: `tests/readme_presence.rs` + +**Step 1: Write the failing test** + +Update help-text expectations and README assertions so they require: + +- default `--lang` meaning all supported languages +- examples that mention JS/TS family usage +- notes describing frontend whole-file test detection + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test cli_smoke help_mentions_common_examples -v +cargo test --test readme_presence -v +``` + +Expected: FAIL because docs and help still describe the older default set. + +**Step 3: Write minimal implementation** + +Update help examples, defaults text, README usage, and notes so the supported language list and default behavior are explicit and consistent with the registry. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test --test cli_smoke help_mentions_common_examples -v +cargo test --test readme_presence -v +``` + +Expected: PASS + +### Task 7: Run the full verification suite + +**Files:** +- Modify: none + +**Step 1: Run targeted tests** + +Run: + +```bash +cargo test --test lang_filter -v +cargo test --test javascript_tests -v +cargo test --test python_tests -v +cargo test --test rust_tests -v +cargo test --test cli_smoke -v +``` + +Expected: PASS + +**Step 2: Run the full test suite** + +Run: + +```bash +cargo test -v +``` + +Expected: PASS + +**Step 3: Run lint** + +Run: + +```bash +cargo clippy --all-targets --all-features -- -D warnings +``` + +Expected: PASS + +**Step 4: Commit** + +```bash +git add README.md src tests docs/plans/2026-03-21-js-ts-family-support-design.md docs/plans/2026-03-21-js-ts-family-support.md +git commit -m "feat: add js and ts family language support" +``` diff --git a/src/lang/javascript.rs b/src/lang/javascript.rs index e9de245..93fe975 100644 --- a/src/lang/javascript.rs +++ b/src/lang/javascript.rs @@ -19,9 +19,7 @@ pub fn detect_language(path: &str) -> Option<&'static str> { } } -pub fn collect_whole_test_paths( - sources: &[(String, String)], -) -> Result, String> { +pub fn collect_whole_test_paths(sources: &[(String, String)]) -> Result, String> { Ok(sources .iter() .map(|(path, _)| path) diff --git a/src/test_filter.rs b/src/test_filter.rs index 98eac5c..0d685cc 100644 --- a/src/test_filter.rs +++ b/src/test_filter.rs @@ -386,7 +386,8 @@ where } fn filter_paths(paths: Vec, langs: &[&str]) -> Vec { - paths.into_iter() + paths + .into_iter() .filter(|path| should_include_path(path, langs)) .collect() } @@ -400,7 +401,8 @@ fn should_include_path(path: &str, langs: &[&str]) -> bool { } fn path_entries(paths: &[String]) -> Vec<(String, String)> { - paths.iter() + paths + .iter() .cloned() .map(|path| (path, String::new())) .collect() diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index bd4e8a2..0ed6888 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -46,7 +46,7 @@ fn working_tree_output_mentions_scope_lang_and_test_mode() { .assert() .success() .stdout(predicate::str::contains( - "未提交的 rs,py,js,ts 文件中,非测试代码统计如下:", + "未提交的 rs,py,js,ts,jsx,tsx,cjs,mjs 文件中,非测试代码统计如下:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("web.js")); @@ -100,7 +100,7 @@ fn last_flag_reports_head_patch() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs,py,js,ts 文件中,测试与非测试代码统计如下:", + "最后一次提交的 rs,py,js,ts,jsx,tsx,cjs,mjs 文件中,测试与非测试代码统计如下:", )) .stdout(predicate::str::contains("src/tracked.rs")) .stdout(predicate::str::contains("1 insertion")); @@ -162,7 +162,7 @@ fn default_filters_to_rust_non_test_changes() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs,py,js,ts 文件中,非测试代码统计如下:", + "最后一次提交的 rs,py,js,ts,jsx,tsx,cjs,mjs 文件中,非测试代码统计如下:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("tests/integration.rs").not()) @@ -225,7 +225,7 @@ fn no_test_filter_includes_all_rust_changes_but_keeps_default_lang() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs,py,js,ts 文件中,测试与非测试代码统计如下:", + "最后一次提交的 rs,py,js,ts,jsx,tsx,cjs,mjs 文件中,测试与非测试代码统计如下:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("tests/integration.rs")) @@ -262,7 +262,13 @@ fn default_lang_includes_rust_and_python_non_test_changes() { .unwrap(); run_git( tempdir.path(), - ["add", "src/lib.rs", "app/main.py", "web.js", "tests/test_app.py"], + [ + "add", + "src/lib.rs", + "app/main.py", + "web.js", + "tests/test_app.py", + ], ); run_git(tempdir.path(), ["commit", "-m", "initial"]); @@ -293,7 +299,7 @@ fn default_lang_includes_rust_and_python_non_test_changes() { .assert() .success() .stdout(predicate::str::contains( - "未提交的 rs,py,js,ts 文件中,非测试代码统计如下:", + "未提交的 rs,py,js,ts,jsx,tsx,cjs,mjs 文件中,非测试代码统计如下:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("app/main.py")) @@ -578,7 +584,12 @@ fn default_non_test_filter_excludes_javascript_and_typescript_test_files() { .unwrap(); run_git( tempdir.path(), - ["add", "web/app.tsx", "web/app.test.tsx", "tests/e2e/login.ts"], + [ + "add", + "web/app.tsx", + "web/app.test.tsx", + "tests/e2e/login.ts", + ], ); run_git(tempdir.path(), ["commit", "-m", "initial"]); @@ -632,7 +643,12 @@ fn test_filter_includes_javascript_and_typescript_test_files() { .unwrap(); run_git( tempdir.path(), - ["add", "web/app.tsx", "web/app.spec.tsx", "cypress/e2e/home.cy.js"], + [ + "add", + "web/app.tsx", + "web/app.spec.tsx", + "cypress/e2e/home.cy.js", + ], ); run_git(tempdir.path(), ["commit", "-m", "initial"]); From 2c094a5c32f5646c115c02243ae701ff7bbcd422 Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 07:30:30 +0800 Subject: [PATCH 8/9] fix: handle cross-language test-filter renames --- src/lang/mod.rs | 7 +- src/test_filter.rs | 221 +++++++++++++++++++++++++++++++++++++++++-- tests/cli_smoke.rs | 38 ++++++++ tests/lang_filter.rs | 20 ++++ 4 files changed, 275 insertions(+), 11 deletions(-) diff --git a/src/lang/mod.rs b/src/lang/mod.rs index f488193..5788e92 100644 --- a/src/lang/mod.rs +++ b/src/lang/mod.rs @@ -15,9 +15,10 @@ pub fn filter_by_langs(changes: &[FileChange], langs: &[&str]) -> Result build_counts_for_rust(&context, &whole_test_paths, change)?, - "py" => build_counts_for_python(&context, &whole_test_paths, change)?, - "js" | "ts" | "jsx" | "tsx" | "cjs" | "mjs" => { - build_counts_for_javascript(&context, &whole_test_paths, change) - } - _ => continue, + let (added, deleted) = match (old_language, new_language) { + (Some(old), Some(new)) if old != new => build_counts_for_cross_language_change( + &context, + &whole_test_paths, + change, + old, + new, + )?, + _ => match language { + "rs" => build_counts_for_rust(&context, &whole_test_paths, change)?, + "py" => build_counts_for_python(&context, &whole_test_paths, change)?, + "js" | "ts" | "jsx" | "tsx" | "cjs" | "mjs" => { + build_counts_for_javascript(&context, &whole_test_paths, change) + } + _ => continue, + }, }; if added + deleted == 0 { @@ -73,6 +85,7 @@ struct BuildContext<'a> { git: &'a Git, endpoints: &'a Option, patch_map: &'a HashMap, + langs: &'a [&'a str], mode: TestFilterMode, } @@ -174,6 +187,31 @@ fn build_counts_for_javascript( ) } +fn build_counts_for_cross_language_change( + context: &BuildContext<'_>, + whole_test_paths: &WholeTestPaths, + change: &FileChange, + old_language: &'static str, + new_language: &'static str, +) -> Result<(usize, usize), String> { + let (old_added, old_deleted) = build_side_counts_for_language( + context, + whole_test_paths, + change, + old_language, + ChangeSide::Old, + )?; + let (new_added, new_deleted) = build_side_counts_for_language( + context, + whole_test_paths, + change, + new_language, + ChangeSide::New, + )?; + + Ok((old_added + new_added, old_deleted + new_deleted)) +} + fn build_counts( context: &BuildContext<'_>, old_whole_test_paths: Option<&HashSet>, @@ -253,6 +291,173 @@ fn select_counts_for_whole_file_only( select_counts_from_whole_file(change, old_is_whole_test, new_is_whole_test, mode) } +#[derive(Clone, Copy)] +enum ChangeSide { + Old, + New, +} + +fn build_side_counts_for_language( + context: &BuildContext<'_>, + whole_test_paths: &WholeTestPaths, + change: &FileChange, + language: &'static str, + side: ChangeSide, +) -> Result<(usize, usize), String> { + if !context.langs.contains(&language) { + return Ok((0, 0)); + } + + match language { + "rs" => build_side_counts( + context, + whole_test_paths, + change, + language, + side, + rust::split_file_patch, + ), + "py" => build_side_counts( + context, + whole_test_paths, + change, + language, + side, + python::split_file_patch, + ), + "js" | "ts" | "jsx" | "tsx" | "cjs" | "mjs" => Ok(select_side_counts_for_whole_file_only( + change, + whole_test_paths.old.get(language), + whole_test_paths.new.get(language), + side, + context.mode, + )), + _ => Ok((0, 0)), + } +} + +fn build_side_counts( + context: &BuildContext<'_>, + whole_test_paths: &WholeTestPaths, + change: &FileChange, + language: &'static str, + side: ChangeSide, + split_patch: PatchFn, +) -> Result<(usize, usize), String> +where + Split: TestSplitCounts, + PatchFn: Fn(&crate::patch::FilePatch, &str, &str) -> Result, +{ + let old_is_whole_test = whole_test_paths + .old + .get(language) + .map(|paths| paths.contains(&change.old_path)) + .unwrap_or(false); + let new_is_whole_test = whole_test_paths + .new + .get(language) + .map(|paths| paths.contains(&change.new_path)) + .unwrap_or(false); + + if old_is_whole_test || new_is_whole_test { + return Ok(select_side_counts_from_whole_file( + change, + old_is_whole_test, + new_is_whole_test, + side, + context.mode, + )); + } + + let file_patch = context + .patch_map + .get(&change.new_path) + .ok_or_else(|| format!("missing patch data for {}", change.path))?; + let old_source = match context.endpoints { + Some(endpoints) => context + .git + .show_file_at_revision(&endpoints.old, &change.old_path) + .unwrap_or_default(), + None => context + .git + .show_index_file(&change.old_path) + .unwrap_or_default(), + }; + let new_source = match context.endpoints { + Some(endpoints) => context + .git + .show_file_at_revision(&endpoints.new, &change.new_path) + .unwrap_or_default(), + None => context + .git + .read_worktree_file(&change.new_path) + .unwrap_or_default(), + }; + let split = match side { + ChangeSide::Old => split_patch(file_patch, &old_source, "")?, + ChangeSide::New => split_patch(file_patch, "", &new_source)?, + }; + + Ok(select_side_counts_from_split(&split, side, context.mode)) +} + +fn select_side_counts_from_whole_file( + change: &FileChange, + old_is_whole_test: bool, + new_is_whole_test: bool, + side: ChangeSide, + mode: TestFilterMode, +) -> (usize, usize) { + match side { + ChangeSide::Old => match mode { + TestFilterMode::TestOnly => (0, if old_is_whole_test { change.deleted } else { 0 }), + TestFilterMode::NonTestOnly => (0, if old_is_whole_test { 0 } else { change.deleted }), + TestFilterMode::All => (0, change.deleted), + }, + ChangeSide::New => match mode { + TestFilterMode::TestOnly => (if new_is_whole_test { change.added } else { 0 }, 0), + TestFilterMode::NonTestOnly => (if new_is_whole_test { 0 } else { change.added }, 0), + TestFilterMode::All => (change.added, 0), + }, + } +} + +fn select_side_counts_for_whole_file_only( + change: &FileChange, + old_whole_test_paths: Option<&HashSet>, + new_whole_test_paths: Option<&HashSet>, + side: ChangeSide, + mode: TestFilterMode, +) -> (usize, usize) { + let old_is_whole_test = old_whole_test_paths + .map(|paths| paths.contains(&change.old_path)) + .unwrap_or(false); + let new_is_whole_test = new_whole_test_paths + .map(|paths| paths.contains(&change.new_path)) + .unwrap_or(false); + + select_side_counts_from_whole_file(change, old_is_whole_test, new_is_whole_test, side, mode) +} + +fn select_side_counts_from_split( + split: &impl TestSplitCounts, + side: ChangeSide, + mode: TestFilterMode, +) -> (usize, usize) { + match side { + ChangeSide::Old => match mode { + TestFilterMode::TestOnly => (0, split.test_deleted()), + TestFilterMode::NonTestOnly => (0, split.non_test_deleted()), + TestFilterMode::All => (0, split.test_deleted() + split.non_test_deleted()), + }, + ChangeSide::New => match mode { + TestFilterMode::TestOnly => (split.test_added(), 0), + TestFilterMode::NonTestOnly => (split.non_test_added(), 0), + TestFilterMode::All => (split.test_added() + split.non_test_added(), 0), + }, + } +} + trait TestSplitCounts { fn test_added(&self) -> usize; fn test_deleted(&self) -> usize; diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 0ed6888..9e9b927 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -959,6 +959,44 @@ fn no_test_filter_handles_renamed_rust_files() { .stdout(predicate::str::contains("1 deletion")); } +#[test] +fn test_filter_counts_deleted_python_tests_across_language_rename() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("tests")).unwrap(); + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::write( + tempdir.path().join("tests/test_mod.py"), + "def test_old():\n value = 1\n value = value + 1\n value = value + 1\n value = value + 1\n assert value == 4\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "tests/test_mod.py"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + run_git(tempdir.path(), ["mv", "tests/test_mod.py", "src/lib.rs"]); + fs::write( + tempdir.path().join("src/lib.rs"), + "def test_old():\n value = 1\n value = value + 1\n value = value + 1\n value = value + 2\n assert value == 5\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/lib.rs"]); + run_git( + tempdir.path(), + ["commit", "-m", "rename python test to rust file"], + ); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--last", "--lang", "py,rs", "--test"]) + .assert() + .success() + .stdout(predicate::str::contains("test_mod.py => src/lib.rs")) + .stdout(predicate::str::contains("2 deletions(-)")) + .stdout(predicate::str::contains("0 files changed").not()); +} + fn init_repo(repo: &Path) { run_git(repo, ["init"]); run_git(repo, ["config", "user.name", "Codex"]); diff --git a/tests/lang_filter.rs b/tests/lang_filter.rs index 5e1ef87..1d41dfd 100644 --- a/tests/lang_filter.rs +++ b/tests/lang_filter.rs @@ -130,3 +130,23 @@ fn filters_js_ts_family_extensions_individually() { assert_eq!(mjs.len(), 1); assert_eq!(mjs[0].path, "web/entry.mjs"); } + +#[test] +fn keeps_cross_language_renames_for_either_selected_language() { + let changes = vec![FileChange { + path: "tests/test_mod.py => src/lib.rs".to_string(), + old_path: "tests/test_mod.py".to_string(), + new_path: "src/lib.rs".to_string(), + added: 2, + deleted: 2, + untracked: false, + }]; + + let python = filter_by_langs(&changes, &["py"]).unwrap(); + let rust = filter_by_langs(&changes, &["rs"]).unwrap(); + let javascript = filter_by_langs(&changes, &["js"]).unwrap(); + + assert_eq!(python.len(), 1); + assert_eq!(rust.len(), 1); + assert!(javascript.is_empty()); +} From f525a4860fe037d0bb563a6a9f15589b5c2dacc3 Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 07:49:14 +0800 Subject: [PATCH 9/9] fix: split mixed-language rename stats by side --- src/main.rs | 17 ++-------- src/test_filter.rs | 26 +++++++++++---- tests/cli_smoke.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 20 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2ffc99d..748b815 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use git_diff_stat::change::collect_changes; use git_diff_stat::cli::{Cli, TestFilterMode}; use git_diff_stat::git::Git; use git_diff_stat::lang::{filter_by_langs, parse_langs}; -use git_diff_stat::render::{DisplayStat, StatsDescription, render_stats}; +use git_diff_stat::render::{StatsDescription, render_stats}; use git_diff_stat::revision::RevisionSelection; use git_diff_stat::test_filter::build_test_filtered_stats; @@ -26,19 +26,8 @@ fn run() -> Result<(), String> { changes = filter_by_langs(&changes, &langs)?; } - let stats = match cli.test_filter_mode() { - TestFilterMode::TestOnly | TestFilterMode::NonTestOnly => { - build_test_filtered_stats(&git, &selection, &changes, &langs, cli.test_filter_mode())? - } - TestFilterMode::All => changes - .into_iter() - .map(|change| DisplayStat { - path: change.path, - added: change.added, - deleted: change.deleted, - }) - .collect(), - }; + let stats = + build_test_filtered_stats(&git, &selection, &changes, &langs, cli.test_filter_mode())?; let description = StatsDescription { comparison_scope: selection.describe_scope(&git, cli.last)?, diff --git a/src/test_filter.rs b/src/test_filter.rs index a0c43eb..ac681eb 100644 --- a/src/test_filter.rs +++ b/src/test_filter.rs @@ -45,7 +45,7 @@ pub fn build_test_filtered_stats( }; let (added, deleted) = match (old_language, new_language) { - (Some(old), Some(new)) if old != new => build_counts_for_cross_language_change( + (old, new) if old != new => build_counts_for_mixed_language_change( &context, &whole_test_paths, change, @@ -187,21 +187,21 @@ fn build_counts_for_javascript( ) } -fn build_counts_for_cross_language_change( +fn build_counts_for_mixed_language_change( context: &BuildContext<'_>, whole_test_paths: &WholeTestPaths, change: &FileChange, - old_language: &'static str, - new_language: &'static str, + old_language: Option<&'static str>, + new_language: Option<&'static str>, ) -> Result<(usize, usize), String> { - let (old_added, old_deleted) = build_side_counts_for_language( + let (old_added, old_deleted) = build_side_counts_for_detected_language( context, whole_test_paths, change, old_language, ChangeSide::Old, )?; - let (new_added, new_deleted) = build_side_counts_for_language( + let (new_added, new_deleted) = build_side_counts_for_detected_language( context, whole_test_paths, change, @@ -212,6 +212,20 @@ fn build_counts_for_cross_language_change( Ok((old_added + new_added, old_deleted + new_deleted)) } +fn build_side_counts_for_detected_language( + context: &BuildContext<'_>, + whole_test_paths: &WholeTestPaths, + change: &FileChange, + language: Option<&'static str>, + side: ChangeSide, +) -> Result<(usize, usize), String> { + let Some(language) = language else { + return Ok((0, 0)); + }; + + build_side_counts_for_language(context, whole_test_paths, change, language, side) +} + fn build_counts( context: &BuildContext<'_>, old_whole_test_paths: Option<&HashSet>, diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 9e9b927..728225b 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -997,6 +997,85 @@ fn test_filter_counts_deleted_python_tests_across_language_rename() { .stdout(predicate::str::contains("0 files changed").not()); } +#[test] +fn no_test_filter_splits_cross_language_rename_by_selected_language() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("tests")).unwrap(); + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::write( + tempdir.path().join("tests/test_mod.py"), + "def test_old():\n value = 1\n value = value + 1\n value = value + 1\n value = value + 1\n assert value == 4\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "tests/test_mod.py"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + run_git(tempdir.path(), ["mv", "tests/test_mod.py", "src/lib.rs"]); + fs::write( + tempdir.path().join("src/lib.rs"), + "def test_old():\n value = 1\n value = value + 1\n value = value + 1\n value = value + 2\n assert value == 5\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/lib.rs"]); + run_git( + tempdir.path(), + ["commit", "-m", "rename python test to rust file"], + ); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--last", "--lang", "py", "--no-test-filter"]) + .assert() + .success() + .stdout(predicate::str::contains("test_mod.py => src/lib.rs")) + .stdout(predicate::str::contains("0 insertions(+), 2 deletions(-)")); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--last", "--lang", "rs", "--no-test-filter"]) + .assert() + .success() + .stdout(predicate::str::contains("test_mod.py => src/lib.rs")) + .stdout(predicate::str::contains("2 insertions(+), 0 deletions(-)")); +} + +#[test] +fn non_test_filter_splits_supported_to_unsupported_rename_by_selected_language() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::write( + tempdir.path().join("README.md"), + "pub fn answer() -> i32 {\n 41\n}\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "README.md"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + run_git(tempdir.path(), ["mv", "README.md", "src/lib.rs"]); + fs::write( + tempdir.path().join("src/lib.rs"), + "pub fn answer() -> i32 {\n 42\n}\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/lib.rs"]); + run_git(tempdir.path(), ["commit", "-m", "rename markdown to rust"]); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--last", "--lang", "rs", "--no-test"]) + .assert() + .success() + .stdout(predicate::str::contains("README.md => src/lib.rs")) + .stdout(predicate::str::contains("1 insertions(+), 0 deletions(-)")); +} + fn init_repo(repo: &Path) { run_git(repo, ["init"]); run_git(repo, ["config", "user.name", "Codex"]);