Skip to content

Commit d6b6148

Browse files
committed
feat(check-case-conflict): implement builtin hook
1 parent 1205b6d commit d6b6148

File tree

4 files changed

+345
-0
lines changed

4 files changed

+345
-0
lines changed
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
use std::path::{Path, PathBuf};
2+
3+
use anyhow::Result;
4+
use rustc_hash::FxHashSet;
5+
6+
use crate::git::get_added_files;
7+
use crate::hook::Hook;
8+
9+
pub(crate) async fn check_case_conflict(
10+
hook: &Hook,
11+
filenames: &[&Path],
12+
) -> Result<(i32, Vec<u8>)> {
13+
let work_dir = hook.work_dir();
14+
15+
// Get all files in the repo
16+
let output = tokio::process::Command::new("git")
17+
.arg("ls-files")
18+
.current_dir(work_dir)
19+
.output()
20+
.await?;
21+
22+
let repo_files: FxHashSet<PathBuf> = String::from_utf8_lossy(&output.stdout)
23+
.lines()
24+
.map(PathBuf::from)
25+
.collect();
26+
27+
// Add directories for repo files
28+
let mut repo_files_with_dirs = repo_files.clone();
29+
for file in &repo_files {
30+
repo_files_with_dirs.extend(get_parent_dirs(file));
31+
}
32+
33+
// Get relevant files (filenames + added files)
34+
let added = get_added_files(work_dir).await?;
35+
let mut relevant_files: FxHashSet<PathBuf> =
36+
filenames.iter().map(|p| p.to_path_buf()).collect();
37+
relevant_files.extend(added);
38+
39+
// Add directories for relevant files
40+
let mut relevant_files_with_dirs = relevant_files.clone();
41+
for file in &relevant_files {
42+
relevant_files_with_dirs.extend(get_parent_dirs(file));
43+
}
44+
45+
// Remove relevant files from repo files (we only check conflicts with existing files)
46+
for file in &relevant_files_with_dirs {
47+
repo_files_with_dirs.remove(file);
48+
}
49+
50+
let mut retv = 0;
51+
let mut conflicts = FxHashSet::default();
52+
53+
// Check for conflicts between new files and existing files
54+
let repo_lower = to_lowercase_set(&repo_files_with_dirs);
55+
let relevant_lower = to_lowercase_set(&relevant_files_with_dirs);
56+
conflicts.extend(repo_lower.intersection(&relevant_lower).cloned());
57+
58+
// Check for conflicts among new files themselves
59+
let mut lowercase_relevant = to_lowercase_set(&relevant_files_with_dirs);
60+
for filename in &relevant_files_with_dirs {
61+
let lower = filename.to_string_lossy().to_lowercase();
62+
if lowercase_relevant.contains(&lower) {
63+
lowercase_relevant.remove(&lower);
64+
} else {
65+
conflicts.insert(lower);
66+
}
67+
}
68+
69+
let mut output = Vec::new();
70+
if !conflicts.is_empty() {
71+
let mut conflicting_files: Vec<PathBuf> = repo_files_with_dirs
72+
.union(&relevant_files_with_dirs)
73+
.filter(|f| conflicts.contains(&f.to_string_lossy().to_lowercase()))
74+
.cloned()
75+
.collect();
76+
conflicting_files.sort();
77+
78+
for filename in conflicting_files {
79+
let line = format!(
80+
"Case-insensitivity conflict found: {}\n",
81+
filename.display()
82+
);
83+
output.extend(line.into_bytes());
84+
}
85+
retv = 1;
86+
}
87+
88+
Ok((retv, output))
89+
}
90+
91+
fn get_parent_dirs(file: &Path) -> Vec<PathBuf> {
92+
let mut dirs = Vec::new();
93+
let mut current = file;
94+
while let Some(parent) = current.parent() {
95+
if parent == Path::new("") {
96+
break;
97+
}
98+
dirs.push(parent.to_path_buf());
99+
current = parent;
100+
}
101+
dirs
102+
}
103+
104+
fn to_lowercase_set(files: &FxHashSet<PathBuf>) -> FxHashSet<String> {
105+
files
106+
.iter()
107+
.map(|p| p.to_string_lossy().to_lowercase())
108+
.collect()
109+
}
110+
111+
#[cfg(test)]
112+
mod tests {
113+
use super::*;
114+
115+
#[test]
116+
fn test_get_parent_dirs() {
117+
let parents = get_parent_dirs(Path::new("foo/bar/baz.txt"));
118+
assert_eq!(parents, vec![Path::new("foo/bar"), Path::new("foo")]);
119+
120+
let parents = get_parent_dirs(Path::new("single.txt"));
121+
assert!(parents.is_empty());
122+
123+
let parents = get_parent_dirs(Path::new("a/b/c/d.txt"));
124+
assert_eq!(
125+
parents,
126+
vec![Path::new("a/b/c"), Path::new("a/b"), Path::new("a")]
127+
);
128+
}
129+
130+
#[test]
131+
fn test_to_lowercase_set() {
132+
let mut files = FxHashSet::default();
133+
files.insert(PathBuf::from("Foo.txt"));
134+
files.insert(PathBuf::from("BAR.txt"));
135+
files.insert(PathBuf::from("baz.TXT"));
136+
137+
let lower = to_lowercase_set(&files);
138+
assert!(lower.contains("foo.txt"));
139+
assert!(lower.contains("bar.txt"));
140+
assert!(lower.contains("baz.txt"));
141+
assert_eq!(lower.len(), 3);
142+
}
143+
144+
#[test]
145+
fn test_get_parent_dirs_nested() {
146+
let parents = get_parent_dirs(Path::new("a/b/c/d/e/f.txt"));
147+
assert_eq!(
148+
parents,
149+
vec![
150+
Path::new("a/b/c/d/e"),
151+
Path::new("a/b/c/d"),
152+
Path::new("a/b/c"),
153+
Path::new("a/b"),
154+
Path::new("a")
155+
]
156+
);
157+
}
158+
159+
#[test]
160+
fn test_get_parent_dirs_no_slash() {
161+
let parents = get_parent_dirs(Path::new("file.txt"));
162+
assert!(parents.is_empty());
163+
}
164+
165+
#[test]
166+
fn test_to_lowercase_set_empty() {
167+
let files: FxHashSet<PathBuf> = FxHashSet::default();
168+
let lower = to_lowercase_set(&files);
169+
assert!(lower.is_empty());
170+
}
171+
172+
#[test]
173+
fn test_to_lowercase_set_mixed_case() {
174+
let mut files = FxHashSet::default();
175+
files.insert(PathBuf::from("FooBar.TXT"));
176+
files.insert(PathBuf::from("FOOBAR.txt"));
177+
files.insert(PathBuf::from("foobar.Txt"));
178+
179+
let lower = to_lowercase_set(&files);
180+
// All three should map to the same lowercase version
181+
assert!(lower.contains("foobar.txt"));
182+
assert_eq!(lower.len(), 1);
183+
}
184+
}

src/builtin/pre_commit_hooks/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use tracing::debug;
77
use crate::hook::Hook;
88

99
mod check_added_large_files;
10+
mod check_case_conflict;
1011
mod check_json;
1112
mod check_toml;
1213
mod check_yaml;
@@ -18,6 +19,7 @@ mod mixed_line_ending;
1819
pub(crate) enum Implemented {
1920
TrailingWhitespace,
2021
CheckAddedLargeFiles,
22+
CheckCaseConflict,
2123
EndOfFileFixer,
2224
FixByteOrderMarker,
2325
CheckJson,
@@ -33,6 +35,7 @@ impl FromStr for Implemented {
3335
match s {
3436
"trailing-whitespace" => Ok(Self::TrailingWhitespace),
3537
"check-added-large-files" => Ok(Self::CheckAddedLargeFiles),
38+
"check-case-conflict" => Ok(Self::CheckCaseConflict),
3639
"end-of-file-fixer" => Ok(Self::EndOfFileFixer),
3740
"fix-byte-order-marker" => Ok(Self::FixByteOrderMarker),
3841
"check-json" => Ok(Self::CheckJson),
@@ -66,6 +69,9 @@ impl Implemented {
6669
Self::FixByteOrderMarker => {
6770
fix_byte_order_marker::fix_byte_order_marker(hook, filenames).await
6871
}
72+
Self::CheckCaseConflict => {
73+
check_case_conflict::check_case_conflict(hook, filenames).await
74+
}
6975
Self::CheckJson => check_json::check_json(hook, filenames).await,
7076
Self::CheckToml => check_toml::check_toml(hook, filenames).await,
7177
Self::CheckYaml => check_yaml::check_yaml(hook, filenames).await,

src/git.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,20 @@ pub(crate) async fn intent_to_add_files(root: &Path) -> Result<Vec<PathBuf>, Err
9595
Ok(zsplit(&output.stdout)?)
9696
}
9797

98+
pub(crate) async fn get_added_files(root: &Path) -> Result<Vec<PathBuf>, Error> {
99+
let output = git_cmd("get added files")?
100+
.current_dir(root)
101+
.arg("diff")
102+
.arg("--staged")
103+
.arg("--name-only")
104+
.arg("--diff-filter=A")
105+
.arg("-z") // Use NUL as line terminator
106+
.check(true)
107+
.output()
108+
.await?;
109+
Ok(zsplit(&output.stdout)?)
110+
}
111+
98112
pub(crate) async fn get_changed_files(
99113
old: &str,
100114
new: &str,

tests/builtin_hooks.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,3 +758,144 @@ fn fix_byte_order_marker_hook() -> Result<()> {
758758

759759
Ok(())
760760
}
761+
762+
#[test]
763+
fn check_case_conflict_hook() -> Result<()> {
764+
let context = TestContext::new();
765+
context.init_project();
766+
context.configure_git_author();
767+
768+
// Create initial files and commit
769+
let cwd = context.work_dir();
770+
cwd.child("README.md").write_str("Initial commit")?;
771+
cwd.child("src/foo.txt").write_str("existing file")?;
772+
context.git_add(".");
773+
context.git_commit("Initial commit");
774+
775+
context.write_pre_commit_config(indoc::indoc! {r"
776+
repos:
777+
- repo: https://github.com/pre-commit/pre-commit-hooks
778+
rev: v5.0.0
779+
hooks:
780+
- id: check-case-conflict
781+
"});
782+
783+
// Try to add a file with conflicting case
784+
cwd.child("src/FOO.txt").write_str("conflicting case")?;
785+
context.git_add(".");
786+
787+
// First run: should fail due to case conflict
788+
cmd_snapshot!(context.filters(), context.run(), @r#"
789+
success: false
790+
exit_code: 1
791+
----- stdout -----
792+
check for case conflicts.................................................Failed
793+
- hook id: check-case-conflict
794+
- exit code: 1
795+
Case-insensitivity conflict found: src/FOO.txt
796+
Case-insensitivity conflict found: src/foo.txt
797+
798+
----- stderr -----
799+
"#);
800+
801+
// Remove the conflicting file
802+
context.git_rm("src/FOO.txt");
803+
804+
// Add a non-conflicting file
805+
cwd.child("src/bar.txt").write_str("no conflict")?;
806+
context.git_add(".");
807+
808+
// Second run: should pass
809+
cmd_snapshot!(context.filters(), context.run(), @r#"
810+
success: true
811+
exit_code: 0
812+
----- stdout -----
813+
check for case conflicts.................................................Passed
814+
815+
----- stderr -----
816+
"#);
817+
818+
Ok(())
819+
}
820+
821+
#[test]
822+
fn check_case_conflict_directory() -> Result<()> {
823+
let context = TestContext::new();
824+
context.init_project();
825+
context.configure_git_author();
826+
827+
// Create directory with file
828+
let cwd = context.work_dir();
829+
cwd.child("src/utils/helper.py").write_str("helper")?;
830+
context.git_add(".");
831+
context.git_commit("Initial commit");
832+
833+
context.write_pre_commit_config(indoc::indoc! {r"
834+
repos:
835+
- repo: https://github.com/pre-commit/pre-commit-hooks
836+
rev: v5.0.0
837+
hooks:
838+
- id: check-case-conflict
839+
"});
840+
841+
// Try to add a file that conflicts with directory name
842+
cwd.child("src/UTILS/other.py").write_str("conflict")?;
843+
context.git_add(".");
844+
845+
cmd_snapshot!(context.filters(), context.run(), @r#"
846+
success: false
847+
exit_code: 1
848+
----- stdout -----
849+
check for case conflicts.................................................Failed
850+
- hook id: check-case-conflict
851+
- exit code: 1
852+
Case-insensitivity conflict found: src/UTILS
853+
Case-insensitivity conflict found: src/utils
854+
855+
----- stderr -----
856+
"#);
857+
858+
Ok(())
859+
}
860+
861+
#[test]
862+
fn check_case_conflict_among_new_files() -> Result<()> {
863+
let context = TestContext::new();
864+
context.init_project();
865+
context.configure_git_author();
866+
867+
let cwd = context.work_dir();
868+
cwd.child("README.md").write_str("Initial")?;
869+
context.git_add(".");
870+
context.git_commit("Initial commit");
871+
872+
context.write_pre_commit_config(indoc::indoc! {r"
873+
repos:
874+
- repo: https://github.com/pre-commit/pre-commit-hooks
875+
rev: v5.0.0
876+
hooks:
877+
- id: check-case-conflict
878+
"});
879+
880+
// Add multiple new files with conflicting cases
881+
cwd.child("NewFile.txt").write_str("file 1")?;
882+
cwd.child("newfile.txt").write_str("file 2")?;
883+
cwd.child("NEWFILE.TXT").write_str("file 3")?;
884+
context.git_add(".");
885+
886+
cmd_snapshot!(context.filters(), context.run(), @r#"
887+
success: false
888+
exit_code: 1
889+
----- stdout -----
890+
check for case conflicts.................................................Failed
891+
- hook id: check-case-conflict
892+
- exit code: 1
893+
Case-insensitivity conflict found: NEWFILE.TXT
894+
Case-insensitivity conflict found: NewFile.txt
895+
Case-insensitivity conflict found: newfile.txt
896+
897+
----- stderr -----
898+
"#);
899+
900+
Ok(())
901+
}

0 commit comments

Comments
 (0)