Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Cargo.lock

# IDE / Editor
.claude/
.worktrees/
.vscode/
.idea/
*.swp
Expand Down
234 changes: 234 additions & 0 deletions crates/infigraph-core/src/ignore_rules.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
//! Shared, .gitignore- and .infigraphignore-aware ignore rules used by
//! every directory walker and the file watcher, so a project convention
//! excluded via .gitignore (or .infigraphignore) is honored everywhere
//! consistently, instead of each call site maintaining its own hardcoded
//! directory-name list.

use std::path::Path;

use ignore::gitignore::{Gitignore, GitignoreBuilder};
use ignore::WalkBuilder;

/// Directories always excluded, regardless of what any .gitignore or
/// .infigraphignore says. Union of every previously-independent hardcoded
/// list this module replaces (collect_files, the watcher, doc indexing,
/// grep search, security scanning) -- unifying them must not silently
/// reduce protection in a repo whose own .gitignore happens to be sparse.
pub const IGNORE_SAFETY_LIST: &[&str] = &[
".infigraph",
".git",
"node_modules",
"__pycache__",
".venv",
"venv",
"target",
"build",
"dist",
".tox",
"vendor",
".idea",
".mypy_cache",
"coverage",
".pytest_cache",
];

fn is_safety_excluded(name: &str) -> bool {
IGNORE_SAFETY_LIST.contains(&name)
}

/// A pre-configured `WalkBuilder` for `root`: respects `.gitignore`,
/// `.infigraphignore`, and the safety list above. Callers may add further
/// configuration (e.g. `.max_depth`) before calling `.build()`.
pub fn walk_builder(root: &Path) -> WalkBuilder {
let mut builder = WalkBuilder::new(root);
builder
.hidden(true)
.git_ignore(true)
.require_git(false)
.add_custom_ignore_filename(".infigraphignore")
.filter_entry(|entry| !is_safety_excluded(&entry.file_name().to_string_lossy()));
builder
}

/// Point-wise matcher for a single path (e.g. a file-watcher event), where
/// there's no directory tree to walk. Built from the same safety list and
/// the same `.gitignore`/`.infigraphignore` files `walk_builder` would
/// discover -- rebuild when those files may have changed (the watcher
/// rebuilds this on its periodic tick; see `watch_project_with_periodic`).
pub struct IgnoreMatcher {
root: std::path::PathBuf,
gitignore: Gitignore,
}

impl IgnoreMatcher {
/// Discovers every `.gitignore`/`.infigraphignore` under `root`
/// (skipping the safety list, same as `walk_builder`, so this never
/// wastes time descending into e.g. `node_modules/` hunting for nested
/// ignore files there -- nothing inside is ever relevant since the
/// whole directory is always excluded), then builds one matcher from
/// all of them. `.hidden(false)` here (unlike `walk_builder`) because
/// the ignore files themselves are dot-prefixed and must be visited as
/// walk results to be found; `.git_ignore(true)` + `.require_git(false)`
/// still prune any subtree an already-discovered ancestor `.gitignore`
/// excludes (with `require_git(false)` allowing this even in non-git roots),
/// so this stays proportional to directory count, not full file count.
pub fn build(root: &Path) -> Self {
let root = root.to_path_buf();
let mut gi_builder = GitignoreBuilder::new(&root);

let mut discovery = WalkBuilder::new(&root);
discovery
.hidden(false)
.git_ignore(true)
.require_git(false)
.add_custom_ignore_filename(".infigraphignore")
.filter_entry(|entry| !is_safety_excluded(&entry.file_name().to_string_lossy()));

for result in discovery.build() {
let Ok(entry) = result else { continue };
let name = entry.file_name().to_string_lossy();
if name == ".gitignore" || name == ".infigraphignore" {
let _ = gi_builder.add(entry.path());
}
}

let gitignore = gi_builder.build().unwrap_or_else(|_| Gitignore::empty());
IgnoreMatcher { root, gitignore }
}

/// True if `path` should be excluded -- either via the safety list
/// (checked against every path component, so a nested occurrence like
/// `foo/node_modules/bar` is still caught) or via a discovered
/// `.gitignore`/`.infigraphignore` rule.
pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
if path
.components()
.any(|c| is_safety_excluded(&c.as_os_str().to_string_lossy()))
{
return true;
}
// Strip root prefix to get relative path for gitignore matching.
let rel_path = path.strip_prefix(&self.root).unwrap_or(path);

// Check if the path itself matches gitignore rules
if self.gitignore.matched(rel_path, is_dir).is_ignore() {
return true;
}

// Check if any parent directory matches gitignore rules (directories
// like "scratchpad/" in .gitignore should exclude all descendants)
let mut current = rel_path;
while let Some(parent) = current.parent() {
if parent == Path::new("") {
break;
}
if self.gitignore.matched(parent, true).is_ignore() {
return true;
}
current = parent;
}

false
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::fs;

fn make_fixture() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".gitignore"), "scratchpad/\n*.log\n").unwrap();
fs::create_dir_all(dir.path().join("scratchpad/wt-foo")).unwrap();
fs::write(dir.path().join("scratchpad/wt-foo/README.md"), "# copy").unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
fs::write(dir.path().join("debug.log"), "noisy").unwrap();
fs::create_dir_all(dir.path().join("node_modules/pkg")).unwrap();
fs::write(dir.path().join("node_modules/pkg/index.js"), "//").unwrap();
dir
}

#[test]
fn walk_builder_skips_gitignored_scratchpad_and_safety_list() {
let dir = make_fixture();
let mut found = Vec::new();
for result in walk_builder(dir.path()).build() {
let entry = result.unwrap();
if entry.file_type().is_some_and(|ft| ft.is_file()) {
found.push(entry.path().to_path_buf());
}
}
assert!(found.iter().any(|p| p.ends_with("src/main.rs")));
assert!(
!found
.iter()
.any(|p| p.to_string_lossy().contains("scratchpad")),
"scratchpad/ is gitignored and must not be walked: {found:?}"
);
assert!(
!found
.iter()
.any(|p| p.to_string_lossy().contains("node_modules")),
"node_modules/ is in the safety list and must not be walked: {found:?}"
);
assert!(
!found.iter().any(|p| p.ends_with("debug.log")),
"*.log is gitignored and must not be walked: {found:?}"
);
}

#[test]
fn ignore_matcher_agrees_with_walk_builder() {
let dir = make_fixture();
let matcher = IgnoreMatcher::build(dir.path());

assert!(!matcher.is_ignored(&dir.path().join("src/main.rs"), false));
assert!(matcher.is_ignored(&dir.path().join("scratchpad/wt-foo/README.md"), false));
assert!(matcher.is_ignored(&dir.path().join("scratchpad"), true));
assert!(matcher.is_ignored(&dir.path().join("debug.log"), false));
assert!(matcher.is_ignored(&dir.path().join("node_modules/pkg/index.js"), false));
}

#[test]
fn infigraphignore_is_honored_like_gitignore() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".infigraphignore"), "vendored/\n").unwrap();
fs::create_dir_all(dir.path().join("vendored")).unwrap();
fs::write(dir.path().join("vendored/lib.rs"), "// vendored").unwrap();
fs::write(dir.path().join("real.rs"), "fn f() {}").unwrap();

let matcher = IgnoreMatcher::build(dir.path());
assert!(matcher.is_ignored(&dir.path().join("vendored/lib.rs"), false));
assert!(!matcher.is_ignored(&dir.path().join("real.rs"), false));

let mut found = Vec::new();
for result in walk_builder(dir.path()).build() {
let entry = result.unwrap();
if entry.file_type().is_some_and(|ft| ft.is_file()) {
found.push(entry.path().to_path_buf());
}
}
assert!(!found
.iter()
.any(|p| p.to_string_lossy().contains("vendored")));
assert!(found.iter().any(|p| p.ends_with("real.rs")));
}

#[test]
fn ignore_matcher_works_in_git_initialized_directory() {
let dir = tempfile::tempdir().unwrap();
// Initialize as a git directory
let _ = std::fs::create_dir(dir.path().join(".git"));

fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap();
fs::create_dir_all(dir.path().join("ignored")).unwrap();
fs::write(dir.path().join("ignored/file.txt"), "ignored").unwrap();
fs::write(dir.path().join("kept.txt"), "kept").unwrap();

let matcher = IgnoreMatcher::build(dir.path());
assert!(matcher.is_ignored(&dir.path().join("ignored/file.txt"), false));
assert!(!matcher.is_ignored(&dir.path().join("kept.txt"), false));
}
}
16 changes: 2 additions & 14 deletions crates/infigraph-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod embed;
pub mod export;
pub mod extract;
pub mod graph;
pub mod ignore_rules;
pub mod lang;
pub mod learned;
pub mod lockfile;
Expand Down Expand Up @@ -596,21 +597,8 @@ impl Infigraph {
}

fn collect_files(&self) -> Result<Vec<PathBuf>> {
use ignore::WalkBuilder;

let mut files = Vec::new();
let walker = WalkBuilder::new(&self.root)
.hidden(true)
.add_custom_ignore_filename(".infigraphignore")
.git_ignore(true)
.filter_entry(|e| {
let name = e.file_name().to_string_lossy();
!matches!(
name.as_ref(),
".infigraph" | "node_modules" | "__pycache__" | ".tox"
)
})
.build();
let walker = crate::ignore_rules::walk_builder(&self.root).build();

for result in walker {
let entry = match result {
Expand Down
95 changes: 33 additions & 62 deletions crates/infigraph-core/src/search/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,85 +388,56 @@ pub fn grep_search(
.map_err(|e| anyhow::anyhow!("invalid file pattern: {}", e))?;

let mut matches = Vec::new();
walk_and_search(root, root, &re, &glob_pat, limit, &mut matches)?;
walk_and_search(root, &re, &glob_pat, limit, &mut matches)?;
Ok(matches)
}

/// Directories to skip during the grep walk (same set as Infigraph::walk_dir).
const IGNORE_DIRS: &[&str] = &[
".infigraph",
".git",
"node_modules",
"__pycache__",
".venv",
"venv",
"target",
"build",
"dist",
".tox",
];

fn walk_and_search(
base: &Path,
dir: &Path,
re: &Regex,
glob_pat: &Option<glob::Pattern>,
limit: usize,
matches: &mut Vec<GrepMatch>,
) -> Result<()> {
if matches.len() >= limit {
return Ok(());
}

let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return Ok(()), // skip unreadable dirs
};

for entry in entries {
for result in crate::ignore_rules::walk_builder(base).build() {
if matches.len() >= limit {
return Ok(());
}
let entry = entry?;
let entry = match result {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();

if path.is_dir() {
if !IGNORE_DIRS.contains(&name_str.as_ref()) && !name_str.starts_with('.') {
walk_and_search(base, &path, re, glob_pat, limit, matches)?;
}
} else if path.is_file() {
let rel = path
.strip_prefix(base)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");

// Apply optional file-name glob filter
if let Some(ref gp) = glob_pat {
if !gp.matches(&rel) {
continue;
}
let rel = path
.strip_prefix(base)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");

if let Some(ref gp) = glob_pat {
if !gp.matches(&rel) {
continue;
}
}

// Skip binary files — try to read as UTF-8
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};

for (idx, line) in content.lines().enumerate() {
if matches.len() >= limit {
return Ok(());
}
if re.is_match(line) {
matches.push(GrepMatch {
file: rel.clone(),
line_number: idx + 1,
line_text: line.to_string(),
});
}
for (idx, line) in content.lines().enumerate() {
if matches.len() >= limit {
return Ok(());
}
if re.is_match(line) {
matches.push(GrepMatch {
file: rel.clone(),
line_number: idx + 1,
line_text: line.to_string(),
});
}
}
}
Expand Down
Loading
Loading