Skip to content

Commit bbc9fcd

Browse files
committed
fix(grep): grep -r on a single file returns empty instead of matching
OverlayFs::read_dir() returned Ok(vec![]) for file paths instead of Err, causing grep -r's fallback file-read path to never trigger. Added explicit file-type check in OverlayFs::read_dir(). Closes #1074
1 parent ca01912 commit bbc9fcd

File tree

2 files changed

+110
-7
lines changed

2 files changed

+110
-7
lines changed

crates/bashkit/src/builtins/grep.rs

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -850,7 +850,7 @@ fn try_indexed_search(
850850
#[cfg(test)]
851851
mod tests {
852852
use super::*;
853-
use crate::fs::{FileSystem, InMemoryFs};
853+
use crate::fs::{FileSystem, InMemoryFs, OverlayFs};
854854
use std::collections::HashMap;
855855
use std::path::PathBuf;
856856
use std::sync::Arc;
@@ -1063,6 +1063,91 @@ mod tests {
10631063
assert!(!result.stdout.contains("b.log"));
10641064
}
10651065

1066+
#[tokio::test]
1067+
async fn test_grep_recursive_single_file() {
1068+
let grep = Grep;
1069+
let fs = Arc::new(InMemoryFs::new());
1070+
fs.mkdir(&PathBuf::from("/data"), true).await.unwrap();
1071+
fs.write_file(&PathBuf::from("/data/test.md"), b"hello world\n")
1072+
.await
1073+
.unwrap();
1074+
1075+
let mut vars = HashMap::new();
1076+
let mut cwd = PathBuf::from("/");
1077+
let args: Vec<String> = ["-r", "hello", "/data/test.md"]
1078+
.iter()
1079+
.map(|s| s.to_string())
1080+
.collect();
1081+
1082+
let ctx = Context {
1083+
args: &args,
1084+
env: &HashMap::new(),
1085+
variables: &mut vars,
1086+
cwd: &mut cwd,
1087+
fs,
1088+
stdin: None,
1089+
#[cfg(feature = "http_client")]
1090+
http_client: None,
1091+
#[cfg(feature = "git")]
1092+
git_client: None,
1093+
#[cfg(feature = "ssh")]
1094+
ssh_client: None,
1095+
shell: None,
1096+
};
1097+
1098+
let result = grep.execute(ctx).await.unwrap();
1099+
assert_eq!(result.exit_code, 0, "grep -r on a single file should match");
1100+
assert!(
1101+
result.stdout.contains("hello world"),
1102+
"expected 'hello world' in stdout, got: {:?}",
1103+
result.stdout
1104+
);
1105+
}
1106+
1107+
/// Regression: grep -r on a single file with OverlayFs returned empty
1108+
/// because OverlayFs::read_dir returned Ok(vec![]) for files instead of Err.
1109+
#[tokio::test]
1110+
async fn test_grep_recursive_single_file_overlay() {
1111+
let grep = Grep;
1112+
let base = Arc::new(InMemoryFs::new());
1113+
let fs: Arc<dyn FileSystem> = Arc::new(OverlayFs::new(base));
1114+
fs.mkdir(&PathBuf::from("/data"), true).await.unwrap();
1115+
fs.write_file(&PathBuf::from("/data/test.md"), b"hello world\n")
1116+
.await
1117+
.unwrap();
1118+
1119+
let mut vars = HashMap::new();
1120+
let mut cwd = PathBuf::from("/");
1121+
let args: Vec<String> = ["-r", "hello", "/data/test.md"]
1122+
.iter()
1123+
.map(|s| s.to_string())
1124+
.collect();
1125+
1126+
let ctx = Context {
1127+
args: &args,
1128+
env: &HashMap::new(),
1129+
variables: &mut vars,
1130+
cwd: &mut cwd,
1131+
fs,
1132+
stdin: None,
1133+
#[cfg(feature = "http_client")]
1134+
http_client: None,
1135+
#[cfg(feature = "git")]
1136+
git_client: None,
1137+
#[cfg(feature = "ssh")]
1138+
ssh_client: None,
1139+
shell: None,
1140+
};
1141+
1142+
let result = grep.execute(ctx).await.unwrap();
1143+
assert_eq!(result.exit_code, 0, "grep -r on single file via OverlayFs");
1144+
assert!(
1145+
result.stdout.contains("hello world"),
1146+
"expected 'hello world' in stdout, got: {:?}",
1147+
result.stdout
1148+
);
1149+
}
1150+
10661151
#[tokio::test]
10671152
async fn test_grep_recursive_exclude() {
10681153
let grep = Grep;

crates/bashkit/src/fs/overlay.rs

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -619,13 +619,33 @@ impl FileSystem for OverlayFs {
619619
return Err(IoError::new(ErrorKind::NotFound, "not found").into());
620620
}
621621

622+
// Check if the path is a non-directory (file/symlink) — read_dir must fail
623+
let is_dir_lower = if let Ok(meta) = self.lower.stat(&path).await {
624+
if !meta.file_type.is_dir() {
625+
return Err(IoError::other("not a directory").into());
626+
}
627+
true
628+
} else {
629+
false
630+
};
631+
let is_dir_upper = if let Ok(meta) = self.upper.stat(&path).await {
632+
if !meta.file_type.is_dir() {
633+
return Err(IoError::other("not a directory").into());
634+
}
635+
true
636+
} else {
637+
false
638+
};
639+
640+
if !is_dir_lower && !is_dir_upper {
641+
return Err(IoError::new(ErrorKind::NotFound, "not found").into());
642+
}
643+
622644
let mut entries: std::collections::HashMap<String, DirEntry> =
623645
std::collections::HashMap::new();
624646

625647
// Get entries from lower (if not whited out)
626-
if self.lower.exists(&path).await.unwrap_or(false)
627-
&& let Ok(lower_entries) = self.lower.read_dir(&path).await
628-
{
648+
if is_dir_lower && let Ok(lower_entries) = self.lower.read_dir(&path).await {
629649
for entry in lower_entries {
630650
// Skip whited out entries
631651
let entry_path = path.join(&entry.name);
@@ -636,9 +656,7 @@ impl FileSystem for OverlayFs {
636656
}
637657

638658
// Overlay with entries from upper (overriding lower)
639-
if self.upper.exists(&path).await.unwrap_or(false)
640-
&& let Ok(upper_entries) = self.upper.read_dir(&path).await
641-
{
659+
if is_dir_upper && let Ok(upper_entries) = self.upper.read_dir(&path).await {
642660
for entry in upper_entries {
643661
entries.insert(entry.name.clone(), entry);
644662
}

0 commit comments

Comments
 (0)